// ---------------------------------------------------------------------------// 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), 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"]), 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)), }), /** 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)),}); 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], }), );
// ---------------------------------------------------------------------------
// 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),
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"]),
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)),
}),
/** 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)),
});
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],
}),
);