// --------------------------------------------------------------------------- // OAuth HTTP API — the v2 OAuth surface. // // OAuth is a credential mechanism, not an integration type. A `createClient` // registers an owner-scoped app (its own endpoints + client id/secret); `start` // runs that client's flow to mint a Connection for one integration; `complete` // exchanges the authorization code; `cancel` drops an in-flight session; // `probe` discovers an authorization-server's metadata for the onboarding UI. // // NOTE(v2): `start`/`complete` are STUBBED in the SDK (milestone 2) — the routes // are wired to call them but will fail at runtime until the flow is implemented. // --------------------------------------------------------------------------- import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi"; import { Schema } from "effect"; import { AuthTemplateSlug, ConnectionAddress, ConnectionName, EnterpriseManagedStartInputSchema, IntegrationSlug, InternalError, OAuthClientSlug, OAuthCompleteError, OAuthProbeError, OAuthRegisterDynamicError, OAuthSessionNotFoundError, OAuthStartError, OAuthState, Owner, ProviderKey, } from "@executor-js/sdk/shared"; // --------------------------------------------------------------------------- // Shared connection projection (start "connected" / complete results). // --------------------------------------------------------------------------- const ConnectionResponse = Schema.Struct({ owner: Owner, name: ConnectionName, integration: IntegrationSlug, template: AuthTemplateSlug, provider: ProviderKey, address: ConnectionAddress, identityLabel: Schema.NullOr(Schema.String), expiresAt: Schema.NullOr(Schema.Number), // The OAuth app (`oauth_client` slug) that minted this connection — these // results always come from an OAuth flow, so it is non-null in practice. Just // a slug, never a secret; kept consistent with the connections-list shape. oauthClient: Schema.NullOr(OAuthClientSlug), oauthClientOwner: Schema.NullOr(Owner), oauthScope: Schema.NullOr(Schema.String), missingOAuthScopes: Schema.Array(Schema.String), }); // --------------------------------------------------------------------------- // createClient — register an owner-scoped OAuth app. // --------------------------------------------------------------------------- const CreateClientPayload = Schema.Struct({ owner: Owner, slug: OAuthClientSlug, authorizationUrl: Schema.String, tokenUrl: Schema.String, grant: Schema.Literals(["authorization_code", "client_credentials", "id_jag"]), clientId: Schema.String, clientSecret: Schema.String, resource: Schema.optional(Schema.NullOr(Schema.String)), /** Integration whose connect dialog registered this manual app. Recorded so * the picker matches it to this integration by intent, not root domain. */ originIntegration: Schema.optional(Schema.NullOr(IntegrationSlug)), }); const CreateClientResponse = Schema.Struct({ client: OAuthClientSlug, }); // --------------------------------------------------------------------------- // registerDynamic — RFC 7591 Dynamic Client Registration. The server mints the // client id (and possibly a client secret); the user pastes NOTHING. The payload // deliberately carries NO clientId/clientSecret, and the response is the slug // only — the minted secret is never returned over the wire. // --------------------------------------------------------------------------- const RegisterDynamicPayload = Schema.Struct({ owner: Owner, slug: OAuthClientSlug, issuer: Schema.optional(Schema.NullOr(Schema.String)), registrationEndpoint: Schema.String, authorizationUrl: Schema.String, tokenUrl: Schema.String, resource: Schema.optional(Schema.NullOr(Schema.String)), scopes: Schema.Array(Schema.String), tokenEndpointAuthMethodsSupported: Schema.optional(Schema.Array(Schema.String)), clientName: Schema.optional(Schema.String), redirectUri: Schema.optional(Schema.NullOr(Schema.String)), originIntegration: Schema.optional(Schema.NullOr(IntegrationSlug)), }); const RegisterDynamicResponse = Schema.Struct({ client: OAuthClientSlug, }); // --------------------------------------------------------------------------- // listClients — metadata-only summaries of the clients visible to the caller // (their org's shared clients + their own user clients). The `clientSecret` is // NEVER part of this projection. // --------------------------------------------------------------------------- const OAuthClientSummaryResponse = Schema.Struct({ owner: Owner, slug: OAuthClientSlug, grant: Schema.Literals(["authorization_code", "client_credentials", "id_jag"]), authorizationUrl: Schema.String, tokenUrl: Schema.String, resource: Schema.optional(Schema.NullOr(Schema.String)), clientId: Schema.String, origin: Schema.Union([ Schema.Struct({ kind: Schema.Literal("manual") }), Schema.Struct({ kind: Schema.Literal("dynamic_client_registration"), integration: Schema.optional(Schema.NullOr(IntegrationSlug)), }), /** Host-operated app declared in executor config — every org connects * through it; nothing to paste. `integrations` ranks it as the default * for those integrations; `allowedScopes` is the host-enforced scope * boundary the picker mirrors before offering it. */ Schema.Struct({ kind: Schema.Literal("first_party"), integrations: Schema.optional(Schema.Array(IntegrationSlug)), allowedScopes: Schema.optional(Schema.Array(Schema.String)), }), ]), }); const ListClientsResponse = Schema.Array(OAuthClientSummaryResponse); // --------------------------------------------------------------------------- // removeClient — permanently delete an owner-scoped OAuth app. The app is keyed // by (owner, slug) — the slug alone is not globally unique — so the slug is a // path param and the owner is in the payload (mirrors the policies/connections // delete shape). Idempotent: removing an already-gone app still returns // `{ removed: true }`. Connections that referenced the slug are NOT cascaded; // they keep their stored value and fail at the next token refresh. // --------------------------------------------------------------------------- const RemoveClientParams = { slug: OAuthClientSlug }; const RemoveClientPayload = Schema.Struct({ owner: Owner, }); const RemoveClientResponse = Schema.Struct({ removed: Schema.Boolean, }); // --------------------------------------------------------------------------- // start — run a client's flow to mint a connection for one integration. The // status discriminates "connected" (inline, e.g. client_credentials) from // "redirect" (user must visit the authorization URL). // --------------------------------------------------------------------------- const StartPayload = Schema.Struct({ client: OAuthClientSlug, /** The owner of `client` (a Personal connection may use a shared Workspace app). */ clientOwner: Owner, owner: Owner, name: ConnectionName, integration: IntegrationSlug, template: AuthTemplateSlug, identityLabel: Schema.optional(Schema.NullOr(Schema.String)), /** Mint a NEW connection: a taken `name` resolves to the next free suffixed * name server-side instead of re-minting the existing row. */ newConnection: Schema.optional(Schema.Boolean), redirectUri: Schema.optional(Schema.NullOr(Schema.String)), /** Enterprise-managed authorization inputs (MCP EMA profile). Required when * the named client's grant is `id_jag`, ignored otherwise: the client's own * id/secret authenticate at the MCP server's authorization server, while * these name the SECOND registration at the enterprise identity provider and * carry the identity assertion the user already holds from single sign-on. */ enterprise: Schema.optional(EnterpriseManagedStartInputSchema), }); const StartResponse = Schema.Union([ Schema.Struct({ status: Schema.Literal("connected"), connection: ConnectionResponse, }), Schema.Struct({ status: Schema.Literal("redirect"), authorizationUrl: Schema.String, state: OAuthState, }), ]); // --------------------------------------------------------------------------- // complete — exchange the authorization code, mint the connection. // --------------------------------------------------------------------------- const CompletePayload = Schema.Struct({ state: OAuthState, code: Schema.String, /** Regional host echoed back by the authorization server (Datadog's * `domain`/`site`); forwarded so the code is redeemed at the org's region. */ callbackDomain: Schema.optional(Schema.NullOr(Schema.String)), }); // --------------------------------------------------------------------------- // cancel — drop an in-flight session without exchanging. // --------------------------------------------------------------------------- const CancelPayload = Schema.Struct({ state: OAuthState, }); const CancelResponse = Schema.Struct({ cancelled: Schema.Boolean, }); // --------------------------------------------------------------------------- // probe — discover an authorization-server's metadata. // --------------------------------------------------------------------------- const ProbePayload = Schema.Struct({ url: Schema.String, }); const ProbeResponse = Schema.Struct({ issuer: Schema.optional(Schema.NullOr(Schema.String)), authorizationUrl: Schema.String, tokenUrl: Schema.String, resource: Schema.optional(Schema.NullOr(Schema.String)), scopesSupported: Schema.optional(Schema.Array(Schema.String)), registrationEndpoint: Schema.optional(Schema.NullOr(Schema.String)), tokenEndpointAuthMethodsSupported: Schema.optional(Schema.Array(Schema.String)), clientIdMetadataDocumentSupported: Schema.optional(Schema.Boolean), }); // --------------------------------------------------------------------------- // callback — GET with `state` + `code` (or `error`) query params. Renders the // popup HTML directly; the popup script posts the completion result back to the // opener via `postMessage` / `BroadcastChannel`. // --------------------------------------------------------------------------- const CallbackUrlParams = Schema.Struct({ state: Schema.String, code: Schema.optional(Schema.String), error: Schema.optional(Schema.String), error_description: Schema.optional(Schema.String), // Non-standard region hints (Datadog: `domain` is a bare host, `site` a full // origin). Captured so the token exchange can target the org's region. domain: Schema.optional(Schema.String), site: Schema.optional(Schema.String), }); const HtmlResponse = Schema.String.pipe(HttpApiSchema.asText()); // --------------------------------------------------------------------------- // Error schemas with HTTP status annotations // --------------------------------------------------------------------------- const OAuthStart = OAuthStartError.annotate({ httpApiStatus: 400 }); const OAuthComplete = OAuthCompleteError.annotate({ httpApiStatus: 400 }); const OAuthProbe = OAuthProbeError.annotate({ httpApiStatus: 400 }); const OAuthRegisterDynamic = OAuthRegisterDynamicError.annotate({ httpApiStatus: 400 }); const OAuthSessionNotFound = OAuthSessionNotFoundError.annotate({ httpApiStatus: 404 }); // --------------------------------------------------------------------------- // Group // --------------------------------------------------------------------------- export const OAuthApi = HttpApiGroup.make("oauth") .add( HttpApiEndpoint.post("createClient", "/oauth/clients", { payload: CreateClientPayload, success: CreateClientResponse, error: InternalError, }), ) .add( HttpApiEndpoint.post("registerDynamic", "/oauth/clients/register-dynamic", { payload: RegisterDynamicPayload, success: RegisterDynamicResponse, error: [InternalError, OAuthRegisterDynamic], }), ) .add( HttpApiEndpoint.get("listClients", "/oauth/clients", { success: ListClientsResponse, error: InternalError, }), ) .add( HttpApiEndpoint.delete("removeClient", "/oauth/clients/:slug", { params: RemoveClientParams, payload: RemoveClientPayload, success: RemoveClientResponse, error: InternalError, }), ) .add( HttpApiEndpoint.post("start", "/oauth/start", { payload: StartPayload, success: StartResponse, error: [InternalError, OAuthStart], }), ) .add( HttpApiEndpoint.post("complete", "/oauth/complete", { payload: CompletePayload, success: ConnectionResponse, error: [InternalError, OAuthComplete, OAuthSessionNotFound], }), ) .add( HttpApiEndpoint.post("cancel", "/oauth/cancel", { payload: CancelPayload, success: CancelResponse, error: InternalError, }), ) .add( HttpApiEndpoint.post("probe", "/oauth/probe", { payload: ProbePayload, success: ProbeResponse, error: [InternalError, OAuthProbe], }), ) .add( HttpApiEndpoint.get("callback", "/oauth/callback", { query: CallbackUrlParams, success: HtmlResponse, error: [InternalError, OAuthComplete, OAuthSessionNotFound], }), );