316 lines · 11.8 KB · TypeScript
RawDownload
// ---------------------------------------------------------------------------// 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,  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),}); // ---------------------------------------------------------------------------// 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"]),  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"]),  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)),    }),  ]),}); 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)),  redirectUri: Schema.optional(Schema.NullOr(Schema.String)),}); 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],    }),  );