From 81c4382bb368a1910eec064c53fd047a786c52ed Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 14:20:30 +0000 Subject: [PATCH 1/9] feat(spec): oauth2 and app-credential connector instance auth, runtime oauth2 variant retired (ADR-0143 D2.1, D2.2, D7) [wip] Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibvmENgz1tZkajp5SuJ --- .../src/integration/connector-provider.ts | 103 +++++++ .../spec/src/integration/connector.zod.ts | 42 +++ .../spec/src/shared/connector-auth.zod.ts | 272 +++++++++++++++--- .../types/src/connector-credential-source.ts | 77 ++--- 4 files changed, 400 insertions(+), 94 deletions(-) diff --git a/packages/spec/src/integration/connector-provider.ts b/packages/spec/src/integration/connector-provider.ts index 385dfcde14f..7c11b492586 100644 --- a/packages/spec/src/integration/connector-provider.ts +++ b/packages/spec/src/integration/connector-provider.ts @@ -21,6 +21,90 @@ import type { ResolvedConnectorAuth } from '../shared/connector-auth.zod'; * already avoid importing the engine, using a structural registry surface). */ +// ============================================================================ +// Per-dispatch credential handle (ADR-0143 D4, D7) +// +// A grant-bearing credential (`oauth2`, `app-credential`) is not resolved once +// at materialization: its access token expires, is refreshed on expiry and +// once on an upstream `401`, and a rotating refresh token must never be spent +// twice. So a consumer does not receive a value — it receives a HANDLE it asks +// on every request. ADR-0143 D7: "a provider receives a credential source +// answered per dispatch, an interface rather than a schema", separate from the +// `CredentialResolver` that keeps resolving `credentialRef`s. +// +// These types were first declared in `@objectstack/types` +// (`connector-credential-source.ts`) for the datasource borrow path (ADR-0015 +// amendment D7), which re-exports them from here unchanged. They live in the +// spec now because a connector package depends on `@objectstack/spec` only, and +// `ConnectorProviderContext` below hands a provider the same handle. +// +// ⛔ A credential value crosses this seam only as the return of `get` / +// `refresh`, into the request it authenticates; nothing here logs, stores or +// echoes it. +// ============================================================================ + +/** + * The credential a consumer presents on one request — the resolved form of a + * connector instance's `auth`, never its reference. A grant-bearing kind's + * access token takes one of these forms too: `bearer` for an OAuth2 token, and + * the header or query parameter a vendor's app-credential exchange documents + * (`api-key` with `headerName` / `paramName`). + */ +export type BorrowedConnectorAuth = + | { type: 'bearer'; token: string } + | { type: 'api-key'; key: string; headerName: string; paramName?: string } + | { type: 'basic'; username: string; password: string }; + +/** One answer of a {@link ConnectorCredentialHandle}. */ +export interface BorrowedConnectorCredential { + /** What to send. */ + readonly auth: BorrowedConnectorAuth; + /** + * When the credential stops being valid, in epoch milliseconds. Absent for + * a credential with no known lifetime (the static kinds). A credential at + * or past `expiresAt` minus the skew margin is refreshed before it is sent. + */ + readonly expiresAt?: number; +} + +/** What a credential is asked in the name of. */ +export interface ConnectorCredentialRequest { + /** + * The organization of the request the credential is for (ADR-0143 Q1 = A, + * an organization-shared connection). Absent for a read that carries none + * — a boot-time drift check, a schedule — which ADR-0143 D4 answers from + * the instance's connection only when exactly one exists. A static + * credential is environment-level and answers the same either way. + */ + readonly organizationId?: string; +} + +/** + * A credential bound to one connector instance, asked on every request. The + * datasource borrow path receives one from `IConnectorCredentialSource.borrow` + * (`@objectstack/types`); a provider factory receives one as + * {@link ConnectorProviderContext.credentialHandle}. + */ +export interface ConnectorCredentialHandle { + /** The connector instance this handle answers for. */ + readonly connector: string; + /** + * The credential to send now. Throws a refusal carrying `code` and + * `status` when none can be answered (the credential is not set, the + * store cannot be read, the instance was disabled, the grant was revoked): + * `503 SERVICE_UNAVAILABLE`, since each clears without a change to the + * consumer's own declaration. + */ + get(request?: ConnectorCredentialRequest): Promise; + /** + * A credential obtained again, for the request that was refused with the + * one {@link get} answered (a `401`) or that would send an expired one. + * Concurrent calls on one handle share one refresh, so a rotating grant is + * never spent twice. Throws as {@link get} does. + */ + refresh(request?: ConnectorCredentialRequest): Promise; +} + /** * A single materialized connector action handler. Receives the input the * `connector_action` node mapped from the flow, ignores the dispatch context @@ -82,7 +166,26 @@ export interface ConnectorProviderContext { readonly icon?: string; readonly type: string; readonly providerConfig: Record; + /** + * The entry's static credential, resolved once from its `credentialRef` + * (`none` / `bearer` / `api-key` / `basic`). `undefined` when the entry + * declares no auth, and for a grant-bearing kind, which arrives as + * {@link credentialHandle} instead. + */ readonly auth?: ResolvedConnectorAuth; + /** + * The per-dispatch credential of a grant-bearing instance — `auth: { type: + * 'oauth2' }` or `{ type: 'app-credential' }` (ADR-0143 D4, D7). The host + * sets it for those kinds, and only for them; {@link auth} is then + * `undefined`. A factory asks it on every upstream request rather than + * closing over one value, refreshes once on a `401` and retries once, and + * treats a refusal from the handle as the dispatch's failure. + * + * `undefined` for the static kinds and on a host that predates this member; + * a factory handed a grant-bearing entry without it fails the instance with + * a clear message rather than calling the upstream unauthenticated. + */ + readonly credentialHandle?: ConnectorCredentialHandle; /** * The entry's declared retry policy, **resolved** — the host parses it so the * schema's defaults (`maxAttempts: 3`, `[408, 429, 500, 502, 503, 504]`, …) diff --git a/packages/spec/src/integration/connector.zod.ts b/packages/spec/src/integration/connector.zod.ts index 359410d846b..d805e9c2c25 100644 --- a/packages/spec/src/integration/connector.zod.ts +++ b/packages/spec/src/integration/connector.zod.ts @@ -1168,6 +1168,19 @@ export function defineConnector(config: z.input): Connec * retirement prescription, and a provider-bound refusal could only ever repeat * that verdict with a wrong reason (see "REMOVED: `triggers`" above). */ +/** + * True for a `provider: 'mcp'` instance whose `providerConfig.transport` is an + * http transport — the one place an `oauth2` instance may leave its endpoints + * and client id to the MCP authorization flow (ADR-0143 D2.1). Read + * structurally: the provider owns `providerConfig`'s vocabulary and validates + * it at materialization, so anything else answers false here. + */ +function isMcpHttpInstance(entry: { provider?: string; providerConfig?: Record }): boolean { + if (entry.provider !== 'mcp') return false; + const transport = entry.providerConfig?.transport; + return typeof transport === 'object' && transport !== null && (transport as { kind?: unknown }).kind === 'http'; +} + export const DeclarativeConnectorEntrySchema = lazySchema(() => // [#12840 precedent] The ADR-0097 refusals ride on the BASE, INSIDE the // residue stage, for the reason `ObjectPermissionSchema` records: in zod 4 @@ -1217,6 +1230,27 @@ export const DeclarativeConnectorEntrySchema = lazySchema(() => message: `Provider-bound connector instance '${entry.name}' must not author \`actions\` — the '${entry.provider}' provider derives them from the upstream at boot (ADR-0097 §5).`, }); } + // ADR-0143 D2.1 — an `oauth2` instance names its endpoints and its client, + // except on `provider: 'mcp'` over an http transport, where the provider + // runs the MCP authorization flow (protected-resource and + // authorization-server metadata, dynamic client registration) and keeps + // the registered client in the connection record, never in metadata. The + // rule needs the provider, so it lives here rather than on the auth shape. + if (entry.auth?.type === 'oauth2' && !isMcpHttpInstance(entry)) { + const missing: Array<[key: 'tokenUrl' | 'clientId' | 'authorizationUrl', why: string]> = []; + if (entry.auth.tokenUrl === undefined) missing.push(['tokenUrl', 'its token endpoint']); + if (entry.auth.clientId === undefined) missing.push(['clientId', 'its pre-registered client id']); + if (entry.auth.grant === 'authorization_code' && entry.auth.authorizationUrl === undefined) { + missing.push(['authorizationUrl', "its authorization endpoint (`grant: 'authorization_code'`)"]); + } + for (const [key, why] of missing) { + ctx.addIssue({ + code: 'custom', + path: ['auth', key], + message: `Connector instance '${entry.name}' declares \`auth: { type: 'oauth2' }\` without \`${key}\`: name ${why}. Only \`provider: 'mcp'\` over an http transport may omit the endpoints and the client id, because that provider discovers them and registers a client itself (ADR-0143 D2.1).`, + }); + } + } }), CONNECTOR_RETIRED_KEY_RESIDUE), ); @@ -1232,6 +1266,10 @@ export { ConnectorInstanceBearerAuthSchema, ConnectorInstanceAPIKeyAuthSchema, ConnectorInstanceBasicAuthSchema, + ConnectorInstanceOAuth2AuthSchema, + ConnectorInstanceAppCredentialAuthSchema, + ConnectorOAuth2GrantSchema, + ConnectorAppCredentialVendorSchema, } from '../shared/connector-auth.zod'; export type { ConnectorInstanceAuth, @@ -1244,4 +1282,8 @@ export type { ConnectorInstanceBearerAuth, ConnectorInstanceAPIKeyAuth, ConnectorInstanceBasicAuth, + ConnectorInstanceOAuth2Auth, + ConnectorInstanceAppCredentialAuth, + ConnectorOAuth2Grant, + ConnectorAppCredentialVendor, } from '../shared/connector-auth.zod'; diff --git a/packages/spec/src/shared/connector-auth.zod.ts b/packages/spec/src/shared/connector-auth.zod.ts index 349e3d60612..03754b27e35 100644 --- a/packages/spec/src/shared/connector-auth.zod.ts +++ b/packages/spec/src/shared/connector-auth.zod.ts @@ -1,7 +1,8 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { z } from 'zod'; -import { EpochMs } from './epoch.zod'; +import { lazySchema } from './lazy-schema'; +import { strictObject } from './strict-object'; /** * SHARED CONNECTOR AUTHENTICATION SCHEMAS @@ -11,33 +12,48 @@ import { EpochMs } from './epoch.zod'; * ⚠️ Two shapes live here, and which one a door accepts is load-bearing (#7990): * * - {@link ConnectorAuthConfigSchema} is the RUNTIME shape — every non-`none` - * variant REQUIRES its secret inline (`token` / `key` / `password` / - * `clientSecret`), because it describes what a provider factory receives - * AFTER resolution, or what a plugin hands to `registerConnector`. It must - * never be accepted at an authoring/publish door: a published connector row - * is stored whole in `sys_metadata`, so an inline secret is cleartext at - * rest. `DeclarativeConnectorEntrySchema` enforces that refusal on every - * authored entry (descriptor or instance) since #7990. + * variant REQUIRES its secret inline (`token` / `key` / `password`), + * because it describes what a provider factory receives AFTER resolution, + * or what a plugin hands to `registerConnector`. It must never be accepted + * at an authoring/publish door: a published connector row is stored whole + * in `sys_metadata`, so an inline secret is cleartext at rest. + * `DeclarativeConnectorEntrySchema` enforces that refusal on every authored + * entry (descriptor or instance) since #7990. * - {@link ConnectorInstanceAuthSchema} is the AUTHORED shape — secret-bearing * variants carry a `credentialRef` reference resolved through the - * secrets/env layer at materialization (ADR-0097 §3), never the secret. + * secrets/env layer (ADR-0097 §3, ADR-0143 D3), never the secret. */ /** - * OAuth2 Authentication Schema + * Prescription for the retired runtime `oauth2` variant of + * {@link ConnectorAuthConfigSchema} (ADR-0143 D7). + * + * The variant inlined a client secret and a refresh token, and nothing + * consumed it: no provider factory read it (they take + * {@link ResolvedConnectorAuth}, the four static kinds) and no authored door + * accepted it (`DeclarativeConnectorEntrySchema` refuses any non-`none` + * `authentication`). ADR-0143 D7 retires it rather than redefining it as a + * resolved shape: an OAuth2 credential is a grant whose tokens are runtime + * state, answered per dispatch (D4), so it is declared on a provider-bound + * instance's `auth` and never handed to a connector inline. + * + * Like the retired view `api` data source this retires a VALUE of the + * discriminator, not a key, so there is no `retiredKey()` tombstone to hang + * the prescription on: the union's own `error` map carries it, keyed on + * `input.type === 'oauth2'`, and every other unknown discriminator keeps zod's + * default report. The D3 semantic entry + * `connector-auth-config-oauth2-retired` carries the judgement an upgrader + * owes; there is no D2 conversion, because the replacement lives on another + * surface and needs a secret moved out of the source. */ -import { lazySchema } from './lazy-schema'; -export const ConnectorOAuth2Schema = lazySchema(() => z.object({ - type: z.literal('oauth2'), - authorizationUrl: z.string().url().describe('OAuth2 authorization endpoint'), - tokenUrl: z.string().url().describe('OAuth2 token endpoint'), - clientId: z.string().describe('OAuth2 client ID'), - clientSecret: z.string().describe('OAuth2 client secret (typically from ENV)'), - scopes: z.array(z.string()).optional().describe('Requested OAuth2 scopes'), - redirectUri: z.string().url().optional().describe('OAuth2 redirect URI'), - refreshToken: z.string().optional().describe('Refresh token for token renewal'), - tokenExpiry: EpochMs.optional().describe('Token expiry timestamp (Unix milliseconds)'), -})); +const CONNECTOR_AUTH_CONFIG_OAUTH2_RETIRED = + "`authentication: { type: 'oauth2' }` was removed in @objectstack/spec 18 (ADR-0143 D7) — the runtime " + + 'variant inlined a client secret and a refresh token and no provider ever read it, so a connector that ' + + 'declared it was dispatched without the credential. Declare the connector as a provider-bound instance ' + + "(`provider: 'openapi' | 'rest' | 'mcp'`) with `auth: { type: 'oauth2', grant, tokenUrl, clientId, " + + "credentialRef, ... }`, where `credentialRef` names the client secret in the secrets layer, and set " + + "`authentication` to `{ type: 'none' }` or drop it. The platform obtains, stores and refreshes the " + + 'tokens itself; a refresh token never appears in a definition.'; /** * API Key Authentication Schema @@ -77,24 +93,41 @@ export const ConnectorNoAuthSchema = lazySchema(() => z.object({ * Unified Connector Auth Configuration Schema */ export const ConnectorAuthConfigSchema = lazySchema(() => z.discriminatedUnion('type', [ - ConnectorOAuth2Schema, + // The `oauth2` variant (`ConnectorOAuth2Schema`) was REMOVED here (ADR-0143 + // D7); see `CONNECTOR_AUTH_CONFIG_OAUTH2_RETIRED` above for what replaces it + // and the `error` map below for how an author meets it. ConnectorAPIKeySchema, ConnectorBasicAuthSchema, ConnectorBearerAuthSchema, ConnectorNoAuthSchema, -])); +], { + // The retired variant's prescription, raised at `type` for exactly the + // spelling that used to be legal; a literal comparison, so any other unknown + // value meets zod's own report listing the live variants. + error: (issue) => { + const input = issue.input; + return typeof input === 'object' && input !== null + && (input as { type?: unknown }).type === 'oauth2' + ? CONNECTOR_AUTH_CONFIG_OAUTH2_RETIRED + : undefined; + }, +})); export type ConnectorAuthConfig = z.input; /** Post-parse shape of {@link ConnectorAuthConfig} — defaults applied, transforms run (ADR-0122). */ export type ConnectorAuthConfigParsed = z.infer; /** - * The **static** subset of {@link ConnectorAuthConfig} — the open-source auth - * tier a generic executor can apply with no token-acquisition flow (`none` / - * `api-key` / `basic` / `bearer`). This is the shape a provider factory receives - * *after* a declarative instance's {@link ConnectorInstanceAuth} `credentialRef` - * has been resolved to its secret at materialization (ADR-0097). OAuth2 — - * authorization-code/refresh lifecycle — is the enterprise tier (ADR-0015). + * The **static** subset of {@link ConnectorAuthConfig} — what a generic + * executor can apply with no token-acquisition flow (`none` / `api-key` / + * `basic` / `bearer`). This is the shape a provider factory receives *after* a + * declarative instance's {@link ConnectorInstanceAuth} `credentialRef` has been + * resolved to its secret at materialization (ADR-0097). + * + * It stays the four static kinds (ADR-0143 D4): the grant-bearing kinds + * (`oauth2`, `app-credential`) are not resolved once at materialization but + * per dispatch, and reach a provider as the `credentialHandle` member of + * `ConnectorProviderContext`, never as a value of this type. */ export type ResolvedConnectorAuth = Extract< ConnectorAuthConfig, @@ -108,10 +141,16 @@ export type ResolvedConnectorAuth = Extract< // ConnectorAuthConfigSchema — the runtime shape, which carries the *resolved* // secret inline (a plugin passes `{ type: 'bearer', token }`) — this shape // carries a `credentialRef` **reference** that the automation service resolves -// through the secrets/env layer at boot. There is deliberately no field to -// inline a secret here: stack metadata is authored, versioned, and shipped, so -// a raw token must never live in it (ADR-0097 §3). OAuth2 is intentionally -// absent (enterprise tier, ADR-0015). +// through the secrets/env layer. There is deliberately no field to inline a +// secret here: stack metadata is authored, versioned, and shipped, so a raw +// token must never live in it (ADR-0097 §3, ADR-0143 D3). +// +// The four static kinds resolve once, at materialization. The two grant-bearing +// kinds ADR-0143 D2 adds — `oauth2` (D2.1) and `app-credential` (D2.2) — are +// open tier (ADR-0143 D1, which reverses the enterprise placement ADR-0097 §3 +// recorded) and resolve per dispatch (D4): their tokens are runtime state in a +// connection record, never metadata. The third D2 kind, `signed`, enters this +// union only with its signers and test vectors (D2.3). // ============================================================================ /** No authentication — the upstream is public. */ @@ -150,16 +189,177 @@ export const ConnectorInstanceBasicAuthSchema = lazySchema(() => z.object({ })); export type ConnectorInstanceBasicAuth = z.input; +/** + * The guidance an inline secret meets on a grant-bearing instance kind: the + * strict shape refuses the key by name and sends the author to + * `credentialRef` (ADR-0143 D3 / Acceptance: "an inline secret on any variant + * is refused at publish with a located error"). + */ +function inlineSecretGuidance(kind: string, secret: string): string { + return `Not authorable on \`auth: { type: '${kind}' }\`. The ${secret} is a credential, and a connector ` + + 'definition is stored whole and shipped with the app — put the value in the secrets layer (an ' + + 'environment variable in the open tier) and name it with `credentialRef`.'; +} + +/** + * The tokens a grant mints are runtime state (ADR-0143 D3), so an author who + * writes one into the definition is told where it comes from instead. + */ +function runtimeTokenGuidance(kind: string): string { + return `Not authorable on \`auth: { type: '${kind}' }\`. Tokens are obtained, refreshed and stored by ` + + 'the platform at run time (the access token in memory, a refresh token encrypted in the connection ' + + 'record) and never appear in a connector definition. Delete the key.'; +} + +/** + * The RFC 6749 grants an `oauth2` instance may declare (ADR-0143 D2.1). Closed: + * a new grant is a spec change together with its implementation, never a + * free-form value (Prime Directive #12). + */ +export const ConnectorOAuth2GrantSchema = z.enum(['authorization_code', 'client_credentials']) + .describe('OAuth2 grant (RFC 6749): `authorization_code` (an administrator connects once; PKCE S256 always) or `client_credentials` (no user step).'); +export type ConnectorOAuth2Grant = z.infer; + +/** + * OAuth2 auth for a provider-bound instance (ADR-0143 D2.1). + * + * Carries what the grant needs and no secret: the client secret is a + * `credentialRef`; the access token, a refresh token and a dynamically + * registered client are runtime state in the instance's connection record + * (D3), obtained and refreshed per dispatch (D4). `authorization_code` always + * uses PKCE with `S256` — there is deliberately no switch to turn it off. + * + * Which keys are required depends on where the instance points, so two rules + * live elsewhere: + * - Here, the rules that hold for every instance: `client_credentials` has no + * authorization step (no `authorizationUrl`) and is only for a confidential + * client (RFC 6749 §4.4, so `credentialRef` is required beside `clientId`); + * and a client secret belongs to a pre-registered `clientId`. + * - On `DeclarativeConnectorEntrySchema`, the rule that needs the instance's + * provider: `tokenUrl`, `clientId` and (for `authorization_code`) + * `authorizationUrl` are required, except on `provider: 'mcp'` over an http + * transport, where the provider discovers the endpoints and registers a + * client through the MCP authorization flow. + * + * How a pre-registered client authenticates at the token endpoint + * (`client_secret_basic` or `client_secret_post`) is not a key here: D2.1 + * lists what the variant carries, and a client registered dynamically is + * assigned its method by the registration. The server slice that runs the + * grant owns that choice. + */ +export const ConnectorInstanceOAuth2AuthSchema = lazySchema(() => strictObject({ + surface: "this `oauth2` connector auth", + history: 'This shape is strict from birth — the `oauth2` instance kind had no earlier spelling, so an unknown key here was never accepted.', + guidance: { + clientSecret: inlineSecretGuidance('oauth2', 'client secret'), + secret: inlineSecretGuidance('oauth2', 'client secret'), + accessToken: runtimeTokenGuidance('oauth2'), + refreshToken: runtimeTokenGuidance('oauth2'), + token: runtimeTokenGuidance('oauth2'), + pkce: 'Not authorable. `authorization_code` always uses PKCE with S256 (ADR-0143 D2.1); there is no switch. Delete the key.', + }, + // A different word for the same intent, out of reach of edit distance. + aliases: { audience: 'resource' }, +}, { + type: z.literal('oauth2'), + grant: ConnectorOAuth2GrantSchema, + authorizationUrl: z.string().url().optional().describe( + 'Authorization endpoint. Required for `authorization_code` (except `provider: \'mcp\'` over http, which discovers it); refused on `client_credentials`.', + ), + tokenUrl: z.string().url().optional().describe( + 'Token endpoint. Required (except `provider: \'mcp\'` over http, which discovers it).', + ), + scopes: z.array(z.string().min(1)).optional().describe('Scopes to request; sent space-separated.'), + resource: z.string().url().optional().describe( + 'Resource indicator (RFC 8707) — the API the token is for, where the authorization server asks for one (some call it the audience).', + ), + clientId: z.string().min(1).optional().describe( + 'OAuth2 client id (not a secret). Required (except `provider: \'mcp\'` over http, where the provider may register a client dynamically).', + ), + credentialRef: z.string().min(1).optional().describe( + 'Secrets-layer reference resolved to the client secret. Omit only for a public client; required for `client_credentials`. Never an inline secret.', + ), +}).superRefine((auth, ctx) => { + if (auth.grant === 'client_credentials' && auth.authorizationUrl !== undefined) { + ctx.addIssue({ + code: 'custom', + path: ['authorizationUrl'], + message: '`authorizationUrl` is refused on `grant: \'client_credentials\'` — that grant has no authorization step; the client exchanges its credentials at `tokenUrl` directly. Delete the key, or use `grant: \'authorization_code\'` if an administrator should connect the instance.', + }); + } + if (auth.credentialRef !== undefined && auth.clientId === undefined) { + ctx.addIssue({ + code: 'custom', + path: ['credentialRef'], + message: 'A client secret belongs to a pre-registered client: declare its `clientId` beside `credentialRef`, or omit both on `provider: \'mcp\'` over http to let the provider register a client.', + }); + } + if (auth.grant === 'client_credentials' && auth.clientId !== undefined && auth.credentialRef === undefined) { + ctx.addIssue({ + code: 'custom', + path: ['credentialRef'], + message: '`grant: \'client_credentials\'` is only for a confidential client (RFC 6749 §4.4): name the client secret with `credentialRef`.', + }); + } +})); +export type ConnectorInstanceOAuth2Auth = z.input; + +/** + * The vendors whose app-credential exchange the platform implements (ADR-0143 + * D2.2). Closed: each value names one exchange — the token endpoint, its + * request and response, where the token rides on the outgoing call — + * implemented and tested in the shared transport, so nothing about it is + * authored. A new vendor is a spec change together with its exchange. + */ +export const ConnectorAppCredentialVendorSchema = z.enum(['feishu', 'dingtalk', 'wecom']) + .describe('Vendor whose app-credential token exchange to run: `feishu`, `dingtalk` or `wecom`.'); +export type ConnectorAppCredentialVendor = z.infer; + +/** + * Vendor app-credential auth for a provider-bound instance (ADR-0143 D2.2): + * the non-interactive exchange of an app id and secret for a short-lived access + * token, which is not an RFC 6749 grant. The app secret is a `credentialRef`; + * the token is runtime state, re-derived from the secret whenever it expires + * (D3, D4). + */ +export const ConnectorInstanceAppCredentialAuthSchema = lazySchema(() => strictObject({ + surface: "this `app-credential` connector auth", + history: 'This shape is strict from birth — the `app-credential` instance kind had no earlier spelling, so an unknown key here was never accepted.', + guidance: { + appSecret: inlineSecretGuidance('app-credential', 'app secret'), + secret: inlineSecretGuidance('app-credential', 'app secret'), + corpSecret: inlineSecretGuidance('app-credential', 'app secret'), + accessToken: runtimeTokenGuidance('app-credential'), + token: runtimeTokenGuidance('app-credential'), + }, + // The vendors' own words for the app id: Feishu's `app_id`, DingTalk's + // `appKey` / `clientId`, WeCom's `corpId`. + aliases: { appKey: 'appId', corpId: 'appId', clientId: 'appId' }, +}, { + type: z.literal('app-credential'), + vendor: ConnectorAppCredentialVendorSchema, + appId: z.string().min(1).describe( + "The vendor's app identifier (not a secret): Feishu `app_id`, DingTalk `appKey`, WeCom `corpid`.", + ), + credentialRef: z.string().min(1).describe( + 'Secrets-layer reference resolved to the app secret (WeCom: the application `corpsecret`). Never an inline secret.', + ), +})); +export type ConnectorInstanceAppCredentialAuth = z.input; + /** * Declarative connector-instance auth: a discriminated union whose secret-bearing - * variants carry a `credentialRef` instead of an inline secret (ADR-0097 §3). - * Consumed by the `auth` field on a provider-bound `connectors:` entry. + * variants carry a `credentialRef` instead of an inline secret (ADR-0097 §3, + * ADR-0143 D3). Consumed by the `auth` field on a provider-bound `connectors:` + * entry. Closed: an unknown `type` is refused at publish. */ export const ConnectorInstanceAuthSchema = lazySchema(() => z.discriminatedUnion('type', [ ConnectorInstanceNoAuthSchema, ConnectorInstanceBearerAuthSchema, ConnectorInstanceAPIKeyAuthSchema, ConnectorInstanceBasicAuthSchema, + ConnectorInstanceOAuth2AuthSchema, + ConnectorInstanceAppCredentialAuthSchema, ])); export type ConnectorInstanceAuth = z.input; diff --git a/packages/types/src/connector-credential-source.ts b/packages/types/src/connector-credential-source.ts index c63a39216f3..b0cd945875e 100644 --- a/packages/types/src/connector-credential-source.ts +++ b/packages/types/src/connector-credential-source.ts @@ -49,67 +49,28 @@ * before a driver throws it. */ +// The four handle types — `BorrowedConnectorAuth`, `BorrowedConnectorCredential`, +// `ConnectorCredentialRequest` and `ConnectorCredentialHandle` — are declared in +// `@objectstack/spec/integration` (ADR-0143 D7), because a connector provider +// receives the same handle on `ConnectorProviderContext` and a connector package +// depends on the spec only. They are re-exported here unchanged, so every +// importer of this package keeps its spelling. +import type { + BorrowedConnectorAuth, + ConnectorCredentialHandle, + ConnectorCredentialRequest, +} from '@objectstack/spec/integration'; + +export type { + BorrowedConnectorAuth, + BorrowedConnectorCredential, + ConnectorCredentialHandle, + ConnectorCredentialRequest, +} from '@objectstack/spec/integration'; + /** The kernel service name the connector credential source is registered under. */ export const CONNECTOR_CREDENTIAL_SOURCE_SERVICE = 'connector-credential-source'; -/** - * The credential a driver presents on one request — the resolved form of a - * connector instance's `auth`, never its reference. - */ -export type BorrowedConnectorAuth = - | { type: 'bearer'; token: string } - | { type: 'api-key'; key: string; headerName: string; paramName?: string } - | { type: 'basic'; username: string; password: string }; - -/** One answer of a {@link ConnectorCredentialHandle}. */ -export interface BorrowedConnectorCredential { - /** What to send. */ - readonly auth: BorrowedConnectorAuth; - /** - * When the credential stops being valid, in epoch milliseconds. Absent for - * a credential with no known lifetime (the static kinds). A credential at - * or past `expiresAt` minus the skew margin is refreshed before it is sent. - */ - readonly expiresAt?: number; -} - -/** What a borrow is asked in the name of. */ -export interface ConnectorCredentialRequest { - /** - * The organization of the request the credential is for (ADR-0143 Q1 = A, - * an organization-shared connection). Absent for a read that carries none - * — a boot-time drift check, a schedule — which ADR-0143 D4 answers from - * the instance's connection only when exactly one exists. A static - * credential is environment-level and answers the same either way. - */ - readonly organizationId?: string; -} - -/** - * A borrowed credential, bound to one connector instance. Returned by - * {@link IConnectorCredentialSource.borrow}; the driver keeps it for the life - * of its connection and asks it on every request. - */ -export interface ConnectorCredentialHandle { - /** The connector instance this handle borrows from. */ - readonly connector: string; - /** - * The credential to send now. Throws a refusal carrying `code` and - * `status` when none can be answered (the credential is not set, the - * store cannot be read, the instance was disabled): `503 - * SERVICE_UNAVAILABLE`, since each clears without a change to the - * datasource. - */ - get(request?: ConnectorCredentialRequest): Promise; - /** - * A credential obtained again, for the request that was refused with the - * one {@link get} answered (a `401`) or that would send an expired one. - * Concurrent calls on one handle share one refresh, so a rotating grant is - * never spent twice. Throws as {@link get} does. - */ - refresh(request?: ConnectorCredentialRequest): Promise; -} - /** The provider side: answers a handle for a declared connector instance. */ export interface IConnectorCredentialSource { /** From 08e99d8f2e8dc012944555918f4df9c60ee2c060 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 14:24:06 +0000 Subject: [PATCH 2/9] feat(spec): oauth2 auth as one strict shape per grant; regenerated manifests [wip] Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibvmENgz1tZkajp5SuJ --- .../spec/authorable-surface/integration.json | 19 ++ .../json-schema.manifest/integration.json | 6 + .../spec/src/integration/connector.zod.ts | 4 + .../spec/src/shared/connector-auth.zod.ts | 166 ++++++++++-------- 4 files changed, 126 insertions(+), 69 deletions(-) diff --git a/packages/spec/authorable-surface/integration.json b/packages/spec/authorable-surface/integration.json index 5ccde44b9e9..f6e79542bd1 100644 --- a/packages/spec/authorable-surface/integration.json +++ b/packages/spec/authorable-surface/integration.json @@ -42,12 +42,31 @@ "integration/ConnectorInstanceAPIKeyAuth:headerName", "integration/ConnectorInstanceAPIKeyAuth:paramName", "integration/ConnectorInstanceAPIKeyAuth:type", + "integration/ConnectorInstanceAppCredentialAuth:appId", + "integration/ConnectorInstanceAppCredentialAuth:credentialRef", + "integration/ConnectorInstanceAppCredentialAuth:type", + "integration/ConnectorInstanceAppCredentialAuth:vendor", "integration/ConnectorInstanceBasicAuth:credentialRef", "integration/ConnectorInstanceBasicAuth:type", "integration/ConnectorInstanceBasicAuth:username", "integration/ConnectorInstanceBearerAuth:credentialRef", "integration/ConnectorInstanceBearerAuth:type", "integration/ConnectorInstanceNoAuth:type", + "integration/ConnectorInstanceOAuth2AuthorizationCodeAuth:authorizationUrl", + "integration/ConnectorInstanceOAuth2AuthorizationCodeAuth:clientId", + "integration/ConnectorInstanceOAuth2AuthorizationCodeAuth:credentialRef", + "integration/ConnectorInstanceOAuth2AuthorizationCodeAuth:grant", + "integration/ConnectorInstanceOAuth2AuthorizationCodeAuth:resource", + "integration/ConnectorInstanceOAuth2AuthorizationCodeAuth:scopes", + "integration/ConnectorInstanceOAuth2AuthorizationCodeAuth:tokenUrl", + "integration/ConnectorInstanceOAuth2AuthorizationCodeAuth:type", + "integration/ConnectorInstanceOAuth2ClientCredentialsAuth:clientId", + "integration/ConnectorInstanceOAuth2ClientCredentialsAuth:credentialRef", + "integration/ConnectorInstanceOAuth2ClientCredentialsAuth:grant", + "integration/ConnectorInstanceOAuth2ClientCredentialsAuth:resource", + "integration/ConnectorInstanceOAuth2ClientCredentialsAuth:scopes", + "integration/ConnectorInstanceOAuth2ClientCredentialsAuth:tokenUrl", + "integration/ConnectorInstanceOAuth2ClientCredentialsAuth:type", "integration/DeclarativeConnectorEntry:_lock", "integration/DeclarativeConnectorEntry:_lockDocsUrl", "integration/DeclarativeConnectorEntry:_lockReason", diff --git a/packages/spec/json-schema.manifest/integration.json b/packages/spec/json-schema.manifest/integration.json index 8dff5352f18..2299d9aca67 100644 --- a/packages/spec/json-schema.manifest/integration.json +++ b/packages/spec/json-schema.manifest/integration.json @@ -5,11 +5,17 @@ "integration/Connector", "integration/ConnectorAction", "integration/ConnectorActionEffect", + "integration/ConnectorAppCredentialVendor", "integration/ConnectorInstanceAPIKeyAuth", + "integration/ConnectorInstanceAppCredentialAuth", "integration/ConnectorInstanceAuth", "integration/ConnectorInstanceBasicAuth", "integration/ConnectorInstanceBearerAuth", "integration/ConnectorInstanceNoAuth", + "integration/ConnectorInstanceOAuth2Auth", + "integration/ConnectorInstanceOAuth2AuthorizationCodeAuth", + "integration/ConnectorInstanceOAuth2ClientCredentialsAuth", + "integration/ConnectorOAuth2Grant", "integration/ConnectorRetryStrategy", "integration/ConnectorType", "integration/DeclarativeConnectorEntry", diff --git a/packages/spec/src/integration/connector.zod.ts b/packages/spec/src/integration/connector.zod.ts index d805e9c2c25..cc6fa7e8b49 100644 --- a/packages/spec/src/integration/connector.zod.ts +++ b/packages/spec/src/integration/connector.zod.ts @@ -1267,6 +1267,8 @@ export { ConnectorInstanceAPIKeyAuthSchema, ConnectorInstanceBasicAuthSchema, ConnectorInstanceOAuth2AuthSchema, + ConnectorInstanceOAuth2AuthorizationCodeAuthSchema, + ConnectorInstanceOAuth2ClientCredentialsAuthSchema, ConnectorInstanceAppCredentialAuthSchema, ConnectorOAuth2GrantSchema, ConnectorAppCredentialVendorSchema, @@ -1283,6 +1285,8 @@ export type { ConnectorInstanceAPIKeyAuth, ConnectorInstanceBasicAuth, ConnectorInstanceOAuth2Auth, + ConnectorInstanceOAuth2AuthorizationCodeAuth, + ConnectorInstanceOAuth2ClientCredentialsAuth, ConnectorInstanceAppCredentialAuth, ConnectorOAuth2Grant, ConnectorAppCredentialVendor, diff --git a/packages/spec/src/shared/connector-auth.zod.ts b/packages/spec/src/shared/connector-auth.zod.ts index 03754b27e35..0fff4204aa7 100644 --- a/packages/spec/src/shared/connector-auth.zod.ts +++ b/packages/spec/src/shared/connector-auth.zod.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { lazySchema } from './lazy-schema'; import { strictObject } from './strict-object'; +import { dependentRequired } from './refinement-projection'; /** * SHARED CONNECTOR AUTHENTICATION SCHEMAS @@ -220,26 +221,104 @@ export const ConnectorOAuth2GrantSchema = z.enum(['authorization_code', 'client_ .describe('OAuth2 grant (RFC 6749): `authorization_code` (an administrator connects once; PKCE S256 always) or `client_credentials` (no user step).'); export type ConnectorOAuth2Grant = z.infer; +/** The `oauth2` instance keys both grants share (ADR-0143 D2.1). */ +function oauth2SharedShape() { + return { + type: z.literal('oauth2'), + tokenUrl: z.string().url().optional().describe( + 'Token endpoint. Required (except `provider: \'mcp\'` over http, which discovers it).', + ), + scopes: z.array(z.string().min(1)).optional().describe('Scopes to request; sent space-separated.'), + resource: z.string().url().optional().describe( + 'Resource indicator (RFC 8707) — the API the token is for, where the authorization server asks for one (some call it the audience).', + ), + clientId: z.string().min(1).optional().describe( + 'OAuth2 client id (not a secret). Required (except `provider: \'mcp\'` over http, where the provider may register a client dynamically).', + ), + }; +} + +/** The strict-shape guidance both `oauth2` grants share. */ +const OAUTH2_GUIDANCE: Readonly> = { + clientSecret: inlineSecretGuidance('oauth2', 'client secret'), + secret: inlineSecretGuidance('oauth2', 'client secret'), + accessToken: runtimeTokenGuidance('oauth2'), + refreshToken: runtimeTokenGuidance('oauth2'), + token: runtimeTokenGuidance('oauth2'), + pkce: 'Not authorable. `authorization_code` always uses PKCE with S256 (ADR-0143 D2.1); there is no switch. Delete the key.', +}; + /** - * OAuth2 auth for a provider-bound instance (ADR-0143 D2.1). + * `oauth2` with `grant: 'authorization_code'` — an administrator connects the + * instance once from the Console; the platform keeps the refresh token in the + * connection record and refreshes per dispatch (ADR-0143 D3, D4). PKCE with + * `S256` always, with no switch to turn it off. `credentialRef` (the client + * secret) is optional: a public client has none. + */ +export const ConnectorInstanceOAuth2AuthorizationCodeAuthSchema = lazySchema(() => strictObject({ + surface: "this `oauth2` connector auth (`grant: 'authorization_code'`)", + history: 'This shape is strict from birth — the `oauth2` instance kind had no earlier spelling, so an unknown key here was never accepted.', + guidance: OAUTH2_GUIDANCE, + // A different word for the same intent, out of reach of edit distance. + aliases: { audience: 'resource' }, +}, { + ...oauth2SharedShape(), + grant: z.literal('authorization_code').describe('An administrator connects once; the platform refreshes the grant per dispatch.'), + authorizationUrl: z.string().url().optional().describe( + 'Authorization endpoint. Required (except `provider: \'mcp\'` over http, which discovers it).', + ), + credentialRef: z.string().min(1).optional().describe( + 'Secrets-layer reference resolved to the client secret of a pre-registered client (requires `clientId`). Omit for a public client. Never an inline secret.', + ), +}).refine(dependentRequired({ credentialRef: ['clientId'] }), { + message: 'A client secret belongs to a pre-registered client: declare its `clientId` beside `credentialRef`, or omit both on `provider: \'mcp\'` over http to let the provider register a client.', + path: ['credentialRef'], +})); +export type ConnectorInstanceOAuth2AuthorizationCodeAuth = z.input; + +/** + * `oauth2` with `grant: 'client_credentials'` — no user step and no + * authorization endpoint; the platform exchanges the client's credentials at + * `tokenUrl` and re-derives the token whenever it expires (ADR-0143 D3, D4). + * The grant is for a confidential client only (RFC 6749 §4.4), so a declared + * `clientId` and its `credentialRef` come together. + */ +export const ConnectorInstanceOAuth2ClientCredentialsAuthSchema = lazySchema(() => strictObject({ + surface: "this `oauth2` connector auth (`grant: 'client_credentials'`)", + history: 'This shape is strict from birth — the `oauth2` instance kind had no earlier spelling, so an unknown key here was never accepted.', + guidance: { + ...OAUTH2_GUIDANCE, + authorizationUrl: "Not authorable on `grant: 'client_credentials'` — that grant has no authorization step; the client " + + "exchanges its credentials at `tokenUrl` directly. Delete the key, or use `grant: 'authorization_code'` if an " + + 'administrator should connect the instance.', + }, + aliases: { audience: 'resource' }, +}, { + ...oauth2SharedShape(), + grant: z.literal('client_credentials').describe('No user step: the client exchanges its own credentials for a token.'), + credentialRef: z.string().min(1).optional().describe( + 'Secrets-layer reference resolved to the client secret; declared together with `clientId` (a `client_credentials` client is confidential). Never an inline secret.', + ), +}).refine(dependentRequired({ credentialRef: ['clientId'], clientId: ['credentialRef'] }), { + message: "`grant: 'client_credentials'` is only for a confidential client (RFC 6749 §4.4): declare `clientId` and the `credentialRef` naming its secret together, or omit both on `provider: 'mcp'` over http to let the provider register a client.", +})); +export type ConnectorInstanceOAuth2ClientCredentialsAuth = z.input; + +/** + * OAuth2 auth for a provider-bound instance (ADR-0143 D2.1), one shape per + * RFC 6749 grant — {@link ConnectorOAuth2GrantSchema} is the closed list. * * Carries what the grant needs and no secret: the client secret is a * `credentialRef`; the access token, a refresh token and a dynamically * registered client are runtime state in the instance's connection record - * (D3), obtained and refreshed per dispatch (D4). `authorization_code` always - * uses PKCE with `S256` — there is deliberately no switch to turn it off. + * (D3), obtained and refreshed per dispatch (D4). * - * Which keys are required depends on where the instance points, so two rules - * live elsewhere: - * - Here, the rules that hold for every instance: `client_credentials` has no - * authorization step (no `authorizationUrl`) and is only for a confidential - * client (RFC 6749 §4.4, so `credentialRef` is required beside `clientId`); - * and a client secret belongs to a pre-registered `clientId`. - * - On `DeclarativeConnectorEntrySchema`, the rule that needs the instance's - * provider: `tokenUrl`, `clientId` and (for `authorization_code`) - * `authorizationUrl` are required, except on `provider: 'mcp'` over an http - * transport, where the provider discovers the endpoints and registers a - * client through the MCP authorization flow. + * Which endpoints and client keys are required depends on where the instance + * points, so that rule lives on `DeclarativeConnectorEntrySchema`, which sees + * the provider: `tokenUrl`, `clientId` and (for `authorization_code`) + * `authorizationUrl` are required, except on `provider: 'mcp'` over an http + * transport, where the provider discovers the endpoints and registers a client + * through the MCP authorization flow. * * How a pre-registered client authenticates at the token endpoint * (`client_secret_basic` or `client_secret_post`) is not a key here: D2.1 @@ -247,61 +326,10 @@ export type ConnectorOAuth2Grant = z.infer; * assigned its method by the registration. The server slice that runs the * grant owns that choice. */ -export const ConnectorInstanceOAuth2AuthSchema = lazySchema(() => strictObject({ - surface: "this `oauth2` connector auth", - history: 'This shape is strict from birth — the `oauth2` instance kind had no earlier spelling, so an unknown key here was never accepted.', - guidance: { - clientSecret: inlineSecretGuidance('oauth2', 'client secret'), - secret: inlineSecretGuidance('oauth2', 'client secret'), - accessToken: runtimeTokenGuidance('oauth2'), - refreshToken: runtimeTokenGuidance('oauth2'), - token: runtimeTokenGuidance('oauth2'), - pkce: 'Not authorable. `authorization_code` always uses PKCE with S256 (ADR-0143 D2.1); there is no switch. Delete the key.', - }, - // A different word for the same intent, out of reach of edit distance. - aliases: { audience: 'resource' }, -}, { - type: z.literal('oauth2'), - grant: ConnectorOAuth2GrantSchema, - authorizationUrl: z.string().url().optional().describe( - 'Authorization endpoint. Required for `authorization_code` (except `provider: \'mcp\'` over http, which discovers it); refused on `client_credentials`.', - ), - tokenUrl: z.string().url().optional().describe( - 'Token endpoint. Required (except `provider: \'mcp\'` over http, which discovers it).', - ), - scopes: z.array(z.string().min(1)).optional().describe('Scopes to request; sent space-separated.'), - resource: z.string().url().optional().describe( - 'Resource indicator (RFC 8707) — the API the token is for, where the authorization server asks for one (some call it the audience).', - ), - clientId: z.string().min(1).optional().describe( - 'OAuth2 client id (not a secret). Required (except `provider: \'mcp\'` over http, where the provider may register a client dynamically).', - ), - credentialRef: z.string().min(1).optional().describe( - 'Secrets-layer reference resolved to the client secret. Omit only for a public client; required for `client_credentials`. Never an inline secret.', - ), -}).superRefine((auth, ctx) => { - if (auth.grant === 'client_credentials' && auth.authorizationUrl !== undefined) { - ctx.addIssue({ - code: 'custom', - path: ['authorizationUrl'], - message: '`authorizationUrl` is refused on `grant: \'client_credentials\'` — that grant has no authorization step; the client exchanges its credentials at `tokenUrl` directly. Delete the key, or use `grant: \'authorization_code\'` if an administrator should connect the instance.', - }); - } - if (auth.credentialRef !== undefined && auth.clientId === undefined) { - ctx.addIssue({ - code: 'custom', - path: ['credentialRef'], - message: 'A client secret belongs to a pre-registered client: declare its `clientId` beside `credentialRef`, or omit both on `provider: \'mcp\'` over http to let the provider register a client.', - }); - } - if (auth.grant === 'client_credentials' && auth.clientId !== undefined && auth.credentialRef === undefined) { - ctx.addIssue({ - code: 'custom', - path: ['credentialRef'], - message: '`grant: \'client_credentials\'` is only for a confidential client (RFC 6749 §4.4): name the client secret with `credentialRef`.', - }); - } -})); +export const ConnectorInstanceOAuth2AuthSchema = lazySchema(() => z.discriminatedUnion('grant', [ + ConnectorInstanceOAuth2AuthorizationCodeAuthSchema, + ConnectorInstanceOAuth2ClientCredentialsAuthSchema, +])); export type ConnectorInstanceOAuth2Auth = z.input; /** From 4ab60a22d5cec5e6c3d4289215f1ef675e672613 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 14:34:12 +0000 Subject: [PATCH 3/9] fix(service-automation): hold a grant-bearing connector instance degraded and refuse to lend it until per-dispatch resolution lands (ADR-0143 D4) Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibvmENgz1tZkajp5SuJ --- .../src/connector-credential-source.test.ts | 41 ++++ .../src/connector-credential-source.ts | 15 +- .../src/connector-materialization.test.ts | 47 +++++ .../services/service-automation/src/plugin.ts | 41 +++- .../connector-auth-grant-kinds.test.ts | 191 ++++++++++++++++++ .../spec/src/integration/connector.test.ts | 66 +----- 6 files changed, 340 insertions(+), 61 deletions(-) create mode 100644 packages/spec/src/integration/connector-auth-grant-kinds.test.ts diff --git a/packages/services/service-automation/src/connector-credential-source.test.ts b/packages/services/service-automation/src/connector-credential-source.test.ts index 7d43276c307..81031648e8e 100644 --- a/packages/services/service-automation/src/connector-credential-source.test.ts +++ b/packages/services/service-automation/src/connector-credential-source.test.ts @@ -298,3 +298,44 @@ describe('[ADR-0015 D7] createConnectorCredentialSource — onLent announces eac expect(later).toEqual(['sap_s4']); }); }); + +describe('[ADR-0143 D4] createConnectorCredentialSource — a grant-bearing kind is refused, not lent', () => { + // S1 replaces this refusal with per-dispatch resolution; until then the + // borrow refuses by name rather than lending a handle that could only + // ever answer nothing — and it never reads the chain. + const source = (auth: unknown, reads: { n: number }) => createConnectorCredentialSource({ + readDeclared: async () => [sap({ auth })], + chain: async () => { reads.n += 1; return { env: () => DUMMY_VALUE }; }, + vaultWired: () => false, + generation: () => 0, + }); + + it.each([ + ['oauth2', { type: 'oauth2', grant: 'client_credentials', tokenUrl: 'https://idp.example.com/token', clientId: 'c', credentialRef: REF }], + ['app-credential', { type: 'app-credential', vendor: 'feishu', appId: 'cli_x', credentialRef: REF }], + ])('refuses to lend a `%s` instance (422, naming the kind and the instance)', async (kind, auth) => { + const reads = { n: 0 }; + const refusal = await source(auth, reads).borrow('sap_s4').catch((e: Error) => e); + expect(refusal).toMatchObject({ code: 'VALIDATION_ERROR', status: 422 }); + expect((refusal as Error).message).toContain("connector instance 'sap_s4'"); + expect((refusal as Error).message).toContain(`auth: { type: '${kind}' }`); + expect((refusal as Error).message).toContain('grant-bearing'); + expect(reads.n).toBe(0); + }); + + it.each([ + ['bearer', { type: 'bearer', credentialRef: REF }, { type: 'bearer', token: DUMMY_VALUE }], + ['api-key', { type: 'api-key', credentialRef: REF }, { type: 'api-key', key: DUMMY_VALUE, headerName: 'X-API-Key' }], + ['basic', { type: 'basic', username: 'u', credentialRef: REF }, { type: 'basic', username: 'u', password: DUMMY_VALUE }], + ])('CONTROL: still lends the static `%s` kind', async (_kind, auth, sent) => { + const handle = await source(auth, { n: 0 }).borrow('sap_s4'); + await expect(handle.get()).resolves.toEqual({ auth: sent }); + }); + + it('CONTROL: `none` keeps its own refusal, not the grant-bearing one', async () => { + const refusal = await source({ type: 'none' }, { n: 0 }).borrow('sap_s4').catch((e: Error) => e); + expect(refusal).toMatchObject({ code: 'VALIDATION_ERROR', status: 422 }); + expect((refusal as Error).message).toContain('declares no auth credential'); + expect((refusal as Error).message).not.toContain('grant-bearing'); + }); +}); diff --git a/packages/services/service-automation/src/connector-credential-source.ts b/packages/services/service-automation/src/connector-credential-source.ts index 39517b84cd6..fd988aba5cd 100644 --- a/packages/services/service-automation/src/connector-credential-source.ts +++ b/packages/services/service-automation/src/connector-credential-source.ts @@ -78,7 +78,11 @@ export interface ConnectorCredentialSourceDeps { generation(): number; } -type Lendable = LendableConnectorItem & { name: string; provider: string; auth: Exclude }; +type Lendable = LendableConnectorItem & { + name: string; + provider: string; + auth: Exclude; +}; function findDeclared(items: readonly unknown[] | undefined, name: string): LendableConnectorItem | undefined { for (const raw of items ?? []) { @@ -111,6 +115,15 @@ function lendRefusal(connector: string, item: LendableConnectorItem | undefined) return `${subject} declares no auth credential, so there is nothing to borrow. Declare its auth, or remove ` + 'external.connector from the datasource if the upstream needs no credential.'; } + // ADR-0143 D4 — a grant-bearing kind's token is obtained and refreshed per + // dispatch, which this server does not run yet: refused by name rather + // than lent as a handle that could only ever answer nothing. + if (auth.type === 'oauth2' || auth.type === 'app-credential') { + return `${subject} declares \`auth: { type: '${auth.type}' }\`, a grant-bearing kind whose token is obtained and ` + + 'refreshed per dispatch (ADR-0143 D4). This server does not run that resolution yet, so the instance ' + + 'cannot lend its credential to a datasource. Declare a static kind (bearer, api-key or basic) on it, or ' + + 'remove external.connector from the datasource.'; + } if (item.enabled === false) { return `${subject} is disabled (enabled: false), which turns the vendor off for this datasource's records as for ` + "the connector's actions. Enable the instance to read through it."; diff --git a/packages/services/service-automation/src/connector-materialization.test.ts b/packages/services/service-automation/src/connector-materialization.test.ts index 9d171e04ce9..bee684a7529 100644 --- a/packages/services/service-automation/src/connector-materialization.test.ts +++ b/packages/services/service-automation/src/connector-materialization.test.ts @@ -1044,3 +1044,50 @@ describe('ADR-0097 — the entry\'s resilience policy on ConnectorProviderContex await kernel.shutdown(); }); }); + +// ADR-0143 D4 — the grant-bearing kinds resolve per dispatch, which this server +// does not run yet (S1 replaces this hold). Until then such an instance is +// held degraded with a named reason, and its factory is never invoked — so it +// is never materialized without its credential and never called upstream +// unauthenticated. A boot does not fail on it: it is not an authoring fault. +describe('ADR-0143 D4 — a grant-bearing instance is held degraded, never materialized unauthenticated', () => { + const grantBearing: Array<[string, ConnectorInstanceAuth]> = [ + ['oauth2', { type: 'oauth2', grant: 'client_credentials', tokenUrl: 'https://idp.example.com/token', clientId: 'c', credentialRef: 'SECRET' }], + ['app-credential', { type: 'app-credential', vendor: 'feishu', appId: 'cli_x', credentialRef: 'SECRET' }], + ]; + + it.each(grantBearing)('holds a `%s` instance degraded with the reason, without calling its factory', async (kind, auth) => { + const { factory, calls } = makeFakeProvider(); + const kernel = await boot([providerConnector('vendor', { auth })], { + providerFactory: factory, + credentialResolver: () => 'resolved-secret', + }); + const engine = automationOf(kernel); + expect(calls).toHaveLength(0); + const desc = engine.getConnectorDescriptors().find((d) => d.name === 'vendor'); + expect(desc?.state).toBe('degraded'); + expect(desc?.actions).toEqual([]); + expect(engine.getConnectorDegradedReason('vendor')).toContain(`auth type '${kind}'`); + expect(engine.getConnectorDegradedReason('vendor')).toContain('never called without its credential'); + expect(engine.resolveConnectorAction('vendor', 'ping')).toBeUndefined(); + await kernel.shutdown(); + }); + + it('CONTROL: the four static kinds still materialize, each handed its resolved credential', async () => { + const { factory, calls } = makeFakeProvider(); + const kernel = await boot( + [ + providerConnector('k_none', { auth: { type: 'none' } }), + providerConnector('k_bearer', { auth: { type: 'bearer', credentialRef: 'TOKEN' } }), + providerConnector('k_key', { auth: { type: 'api-key', credentialRef: 'TOKEN' } }), + providerConnector('k_basic', { auth: { type: 'basic', username: 'u', credentialRef: 'TOKEN' } }), + ], + { providerFactory: factory, credentialResolver: () => 'resolved-secret' }, + ); + expect(Object.fromEntries(calls.map((c) => [c.name, c.auth?.type]))).toEqual({ + k_none: 'none', k_bearer: 'bearer', k_key: 'api-key', k_basic: 'basic', + }); + expect(automationOf(kernel).getConnectorDescriptors().filter((d) => d.state === 'degraded')).toEqual([]); + await kernel.shutdown(); + }); +}); diff --git a/packages/services/service-automation/src/plugin.ts b/packages/services/service-automation/src/plugin.ts index 6c39ff1fb02..52c9e1182fc 100644 --- a/packages/services/service-automation/src/plugin.ts +++ b/packages/services/service-automation/src/plugin.ts @@ -399,14 +399,35 @@ function basicUsernameOf(auth: ConnectorInstanceAuth | undefined): string | unde return auth?.type === 'basic' ? auth.username : undefined; } +/** The grant-bearing instance auth kinds (ADR-0143 D2.1, D2.2): resolved per dispatch, never once at materialization. */ +type GrantBearingInstanceAuth = Extract; + +/** + * ADR-0143 D4 — why a grant-bearing instance is not served by this server. + * The per-dispatch resolution these kinds need is not here yet, so the + * reconcile holds such an instance degraded with this reason rather than + * materializing it with no credential: it is never called upstream + * unauthenticated. + */ +function grantBearingNotServedReason(name: string, auth: GrantBearingInstanceAuth): string { + return `connector instance '${name}' declares auth type '${auth.type}', a grant-bearing kind whose token is obtained ` + + 'and refreshed per dispatch (ADR-0143 D4). This server does not run that resolution yet, so the instance is ' + + 'not served and is never called without its credential. Declare a static kind (bearer, api-key or basic) ' + + 'to dispatch it now.'; +} + /** * Build the static {@link ResolvedConnectorAuth} a provider factory applies * from a declarative instance's `auth` and the secret its `credentialRef` * resolved to (ADR-0097 §3). `secret` is present whenever `auth` names a * reference — an unresolved one never reaches here: the reconcile degrades - * the instance first (#27237). + * the instance first (#27237). A grant-bearing kind never reaches here either: + * the reconcile holds it degraded ({@link grantBearingNotServedReason}). */ -function resolveInstanceAuth(auth: ConnectorInstanceAuth | undefined, secret: string | undefined): ResolvedConnectorAuth | undefined { +function resolveInstanceAuth( + auth: Exclude | undefined, + secret: string | undefined, +): ResolvedConnectorAuth | undefined { if (!auth) return undefined; if (auth.type === 'none') return { type: 'none' }; const value = secret ?? ''; @@ -2245,6 +2266,22 @@ export class AutomationServicePlugin implements Plugin { continue; } + // ADR-0143 D4 — a grant-bearing kind (`oauth2`, `app-credential`) + // is held degraded with a named reason until per-dispatch + // resolution lands, never materialized without its credential. + if (entry.auth?.type === 'oauth2' || entry.auth?.type === 'app-credential') { + this.failDegradedConnectorInstance(engine, ctx, { + name, + entry, + provider, + signature, + hasLive: existing !== undefined, + reason: grantBearingNotServedReason(name, entry.auth), + cause: undefined, + }); + continue; + } + // [#27237] env → the stored row → the host's resolver // (`./connector-credentials.ts`). A reference no arm answers for, or a // stored one this deployment cannot read now, degrades THIS instance diff --git a/packages/spec/src/integration/connector-auth-grant-kinds.test.ts b/packages/spec/src/integration/connector-auth-grant-kinds.test.ts new file mode 100644 index 00000000000..11227dce27b --- /dev/null +++ b/packages/spec/src/integration/connector-auth-grant-kinds.test.ts @@ -0,0 +1,191 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// ADR-0143 D2.1 / D2.2 — the two grant-bearing kinds of declarative connector +// instance auth, `oauth2` and `app-credential`, as an author meets them at the +// publish door (`DeclarativeConnectorEntrySchema`, which both authoring doors +// parse). Every refusal is pinned by its issue `code` and `path`, the located +// error the ADR's Acceptance asks for; every member has a CONTROL accept, so a +// refusal that fired on everything could not pass. + +import { describe, it, expect } from 'vitest'; +import { DeclarativeConnectorEntrySchema, ConnectorInstanceAuthSchema } from './connector.zod'; + +const entry = (auth: unknown, over: Record = {}) => ({ + name: 'vendor', + label: 'Vendor', + type: 'api', + provider: 'openapi', + providerConfig: { spec: 'https://vendor.example.com/openapi.json' }, + auth, + ...over, +}); + +const mcpHttp = { provider: 'mcp', providerConfig: { transport: { kind: 'http', url: 'https://mcp.example.com/mcp' } } }; + +const authorizationCode = { + type: 'oauth2', + grant: 'authorization_code', + authorizationUrl: 'https://vendor.example.com/oauth/authorize', + tokenUrl: 'https://vendor.example.com/oauth/token', + scopes: ['crm.read'], + clientId: 'client-123', + credentialRef: 'VENDOR_CLIENT_SECRET', +}; + +const clientCredentials = { + type: 'oauth2', + grant: 'client_credentials', + tokenUrl: 'https://vendor.example.com/oauth/token', + resource: 'https://api.vendor.example.com', + clientId: 'client-123', + credentialRef: 'VENDOR_CLIENT_SECRET', +}; + +const appCredential = { type: 'app-credential', vendor: 'feishu', appId: 'cli_a1b2', credentialRef: 'FEISHU_APP_SECRET' }; + +/** The issues of a refused parse, as `code @ path` — what each refusal pins. */ +function located(input: unknown): string[] { + const result = DeclarativeConnectorEntrySchema.safeParse(input); + expect(result.success, 'the entry must be refused').toBe(false); + return result.error!.issues.map((i) => `${i.code} @ ${i.path.join('.')}`); +} + +describe('ADR-0143 D2.1 — `oauth2` instance auth', () => { + it('CONTROL: accepts authorization_code with endpoints, client id and the secret as a reference', () => { + const parsed = DeclarativeConnectorEntrySchema.parse(entry(authorizationCode)); + expect(parsed.auth).toEqual(authorizationCode); + }); + + it('CONTROL: accepts a public authorization_code client (no client secret)', () => { + const { credentialRef: _omitted, ...publicClient } = authorizationCode; + expect(DeclarativeConnectorEntrySchema.safeParse(entry(publicClient)).success).toBe(true); + }); + + it('CONTROL: accepts client_credentials with a token endpoint, client id and secret reference', () => { + expect(DeclarativeConnectorEntrySchema.parse(entry(clientCredentials)).auth).toEqual(clientCredentials); + }); + + it("CONTROL: accepts `provider: 'mcp'` over http with the endpoints and the client id omitted", () => { + const parsed = DeclarativeConnectorEntrySchema.parse(entry({ type: 'oauth2', grant: 'authorization_code' }, mcpHttp)); + expect(parsed.auth).toEqual({ type: 'oauth2', grant: 'authorization_code' }); + }); + + it("CONTROL: accepts `provider: 'mcp'` over http with a pre-registered client id and secret", () => { + const { authorizationUrl: _a, tokenUrl: _t, ...preRegistered } = authorizationCode; + expect(DeclarativeConnectorEntrySchema.safeParse(entry(preRegistered, mcpHttp)).success).toBe(true); + }); + + it('refuses each missing endpoint and the missing client id, located under `auth`', () => { + expect(located(entry({ type: 'oauth2', grant: 'authorization_code' }))).toEqual([ + 'custom @ auth.tokenUrl', + 'custom @ auth.clientId', + 'custom @ auth.authorizationUrl', + ]); + const { tokenUrl: _t, ...noToken } = clientCredentials; + expect(located(entry(noToken))).toEqual(['custom @ auth.tokenUrl']); + }); + + it("refuses the omission on an mcp instance over stdio: only http runs the MCP authorization flow", () => { + const stdio = { provider: 'mcp', providerConfig: { transport: { kind: 'stdio', command: 'vendor-mcp' } } }; + expect(located(entry({ type: 'oauth2', grant: 'authorization_code' }, stdio))).toEqual([ + 'custom @ auth.tokenUrl', + 'custom @ auth.clientId', + 'custom @ auth.authorizationUrl', + ]); + }); + + it('refuses an unknown grant at `auth.grant`', () => { + expect(located(entry({ ...authorizationCode, grant: 'password' }))).toEqual(['invalid_union @ auth.grant']); + }); + + it('refuses an inline client secret by name, pointing at credentialRef', () => { + const result = DeclarativeConnectorEntrySchema.safeParse(entry({ ...authorizationCode, clientSecret: 'shh' })); + expect(result.success).toBe(false); + const issue = result.error!.issues[0]!; + expect([issue.code, issue.path.join('.')]).toEqual(['unrecognized_keys', 'auth']); + expect((issue as { keys?: string[] }).keys).toEqual(['clientSecret']); + expect(issue.message).toContain('credentialRef'); + }); + + it('refuses an inline client secret on client_credentials too', () => { + const result = DeclarativeConnectorEntrySchema.safeParse(entry({ ...clientCredentials, clientSecret: 'shh' })); + expect(result.success).toBe(false); + expect(result.error!.issues.map((i) => [i.code, i.path.join('.'), (i as { keys?: string[] }).keys])).toEqual([ + ['unrecognized_keys', 'auth', ['clientSecret']], + ]); + }); + + it('refuses a refresh token: tokens are runtime state, never metadata', () => { + const result = DeclarativeConnectorEntrySchema.safeParse(entry({ ...authorizationCode, refreshToken: 'r1' })); + expect(result.success).toBe(false); + expect(result.error!.issues.map((i) => [i.code, i.path.join('.'), (i as { keys?: string[] }).keys])).toEqual([ + ['unrecognized_keys', 'auth', ['refreshToken']], + ]); + }); + + it('refuses `authorizationUrl` on client_credentials, which has no authorization step', () => { + const result = DeclarativeConnectorEntrySchema.safeParse( + entry({ ...clientCredentials, authorizationUrl: 'https://vendor.example.com/oauth/authorize' }), + ); + expect(result.success).toBe(false); + expect(result.error!.issues.map((i) => [i.code, i.path.join('.'), (i as { keys?: string[] }).keys])).toEqual([ + ['unrecognized_keys', 'auth', ['authorizationUrl']], + ]); + }); + + it('refuses a client secret without its client id', () => { + const { clientId: _c, ...secretOnly } = authorizationCode; + expect(located(entry(secretOnly, mcpHttp))).toEqual(['custom @ auth.credentialRef']); + }); + + it('refuses client_credentials without its secret: the grant is for a confidential client', () => { + const { credentialRef: _r, ...noSecret } = clientCredentials; + expect(located(entry(noSecret))).toEqual(['custom @ auth']); + }); + + it('has no PKCE switch: S256 is always on', () => { + const result = DeclarativeConnectorEntrySchema.safeParse(entry({ ...authorizationCode, pkce: false })); + expect(result.success).toBe(false); + expect(result.error!.issues.map((i) => [i.code, i.path.join('.'), (i as { keys?: string[] }).keys])).toEqual([ + ['unrecognized_keys', 'auth', ['pkce']], + ]); + }); +}); + +describe('ADR-0143 D2.2 — `app-credential` instance auth', () => { + it('CONTROL: accepts each vendor with an app id and the app secret as a reference', () => { + for (const vendor of ['feishu', 'dingtalk', 'wecom']) { + const auth = { ...appCredential, vendor }; + expect(DeclarativeConnectorEntrySchema.parse(entry(auth, { provider: 'rest', providerConfig: {} })).auth).toEqual(auth); + } + }); + + it('refuses an unknown vendor at `auth.vendor`', () => { + expect(located(entry({ ...appCredential, vendor: 'lark' }))).toEqual(['invalid_value @ auth.vendor']); + }); + + it('refuses an inline app secret by name, pointing at credentialRef', () => { + const result = DeclarativeConnectorEntrySchema.safeParse(entry({ ...appCredential, appSecret: 'shh' })); + expect(result.success).toBe(false); + const issue = result.error!.issues[0]!; + expect([issue.code, issue.path.join('.'), (issue as { keys?: string[] }).keys]).toEqual([ + 'unrecognized_keys', 'auth', ['appSecret'], + ]); + expect(issue.message).toContain('credentialRef'); + }); + + it('requires the app secret reference', () => { + const { credentialRef: _r, ...noSecret } = appCredential; + expect(located(entry(noSecret))).toEqual(['invalid_type @ auth.credentialRef']); + }); +}); + +describe('ConnectorInstanceAuthSchema — closed', () => { + it('refuses an unknown kind, and `signed` is not a member until its signers land (ADR-0143 D2.3)', () => { + for (const type of ['signed', 'oauth1']) { + const result = ConnectorInstanceAuthSchema.safeParse({ type, credentialRef: 'X' }); + expect(result.success).toBe(false); + expect(result.error!.issues.map((i) => `${i.code} @ ${i.path.join('.')}`)).toEqual(['invalid_union @ type']); + } + }); +}); diff --git a/packages/spec/src/integration/connector.test.ts b/packages/spec/src/integration/connector.test.ts index 295008e88f9..40f73a0a6cd 100644 --- a/packages/spec/src/integration/connector.test.ts +++ b/packages/spec/src/integration/connector.test.ts @@ -58,7 +58,6 @@ import { // Import shared auth schemas from canonical source import { ConnectorAPIKeySchema as APIKeySchema, - ConnectorOAuth2Schema as OAuth2Schema, ConnectorBasicAuthSchema as BasicAuthSchema, ConnectorBearerAuthSchema as BearerAuthSchema, ConnectorNoAuthSchema as NoAuthSchema, @@ -71,7 +70,6 @@ import { } from '../../scripts/lib/export-origins-testkit'; // Deriving types from schemas type APIKey = z.infer; -type OAuth2 = z.infer; // ============================================================================ // Authentication Schemas Tests (from shared/connector-auth.zod.ts) @@ -111,65 +109,20 @@ describe('APIKeySchema', () => { }); }); -describe('OAuth2Schema', () => { - it('should accept valid OAuth2 configuration', () => { - const auth: OAuth2 = { - type: 'oauth2', - clientId: 'client-id', - clientSecret: 'client-secret', - authorizationUrl: 'https://auth.example.com/authorize', - tokenUrl: 'https://auth.example.com/token', - }; - - expect(() => OAuth2Schema.parse(auth)).not.toThrow(); - }); - - it('should accept OAuth2 with scopes and refresh token', () => { - const auth = { - type: 'oauth2' as const, - clientId: 'client-id', - clientSecret: 'client-secret', - authorizationUrl: 'https://auth.example.com/authorize', - tokenUrl: 'https://auth.example.com/token', - scopes: ['read', 'write'], - refreshToken: 'refresh-token-xyz', - }; - - const parsed = OAuth2Schema.parse(auth); - expect(parsed.scopes).toHaveLength(2); - expect(parsed.refreshToken).toBe('refresh-token-xyz'); - }); - - it('should accept OAuth2 without optional fields', () => { - const auth = { - type: 'oauth2' as const, - clientId: 'client-id', - clientSecret: 'client-secret', - authorizationUrl: 'https://auth.example.com/authorize', - tokenUrl: 'https://auth.example.com/token', - }; - - const parsed = OAuth2Schema.parse(auth); - expect(parsed.type).toBe('oauth2'); - expect(parsed.clientId).toBe('client-id'); - }); -}); +// (`OAuth2Schema` tests lived here until ADR-0143 D7 retired the runtime +// `oauth2` variant — it inlined a client secret and a refresh token and no +// provider read it. The retirement is pinned in `shared/connector-auth.test.ts`; +// OAuth2 is now declared on a provider-bound instance's `auth`.) describe('ConnectorAuthConfigSchema (Authentication)', () => { it('should accept all authentication types via discriminated union', () => { const keyAuth = { type: 'api-key' as const, key: 'key' }; - const oauth2Auth = { - type: 'oauth2' as const, - clientId: 'id', - clientSecret: 'secret', - authorizationUrl: 'https://auth.example.com/authorize', - tokenUrl: 'https://auth.example.com/token', - }; + const bearerAuth = { type: 'bearer' as const, token: 'tok' }; const basicAuth = { type: 'basic' as const, username: 'user', password: 'pass' }; const noAuth = { type: 'none' as const }; expect(() => AuthConfigSchema.parse(keyAuth)).not.toThrow(); - expect(() => AuthConfigSchema.parse(oauth2Auth)).not.toThrow(); + expect(() => AuthConfigSchema.parse(bearerAuth)).not.toThrow(); expect(() => AuthConfigSchema.parse(basicAuth)).not.toThrow(); expect(() => AuthConfigSchema.parse(noAuth)).not.toThrow(); }); @@ -326,11 +279,8 @@ describe('ConnectorSchema', () => { description: 'A comprehensive connector', icon: 'cloud', authentication: { - type: 'oauth2' as const, - clientId: 'client', - clientSecret: 'secret', - authorizationUrl: 'https://auth.example.com/authorize', - tokenUrl: 'https://auth.example.com/token', + type: 'bearer' as const, + token: 'secret', }, // `syncConfig` and `fieldMappings` were authored here until ADR-0049 // retired them (`connector-sync-retirement.test.ts`). From 8c98d56e021fd7353a2c5ac42575e072e4976aa2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 14:38:51 +0000 Subject: [PATCH 4/9] feat(spec): ADR-0087 entry for the retired runtime oauth2 variant; docs, ledger, fixture and changeset [wip] Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibvmENgz1tZkajp5SuJ --- .../28137-connector-grant-bearing-auth.md | 24 ++++ content/docs/automation/connectors.mdx | 77 ++++++++++--- .../src/additional-domains.fixtures.ts | 12 +- .../connector-auth-grant-kinds.test.ts | 2 +- ...18.connector-auth-config-oauth2-retired.ts | 40 +++++++ .../spec/src/migrations/registry.ts.template | 13 +++ .../spec/src/shared/connector-auth.test.ts | 108 +++++++----------- .../spec/src/shared/connector-auth.zod.ts | 1 + packages/spec/variant-docs.json | 18 ++- 9 files changed, 202 insertions(+), 93 deletions(-) create mode 100644 .changeset/28137-connector-grant-bearing-auth.md create mode 100644 packages/spec/src/migrations/entries/semantic/18.connector-auth-config-oauth2-retired.ts diff --git a/.changeset/28137-connector-grant-bearing-auth.md b/.changeset/28137-connector-grant-bearing-auth.md new file mode 100644 index 00000000000..506000b9d8b --- /dev/null +++ b/.changeset/28137-connector-grant-bearing-auth.md @@ -0,0 +1,24 @@ +--- +'@objectstack/spec': minor +'@objectstack/types': patch +'@objectstack/service-automation': patch +--- + +feat(spec)!: `oauth2` and `app-credential` join declarative connector-instance auth, and the runtime `oauth2` variant is retired (ADR-0143 S0, #28137) + +Clause-②: yes (narrowing) +Metadata-protocol: narrowed + + + +An upstream that takes no static credential (an OAuth 2.0 API, a Feishu, DingTalk or WeCom app) had no open-tier way to be declared: the instance `auth` stopped at four static kinds, and the runtime `authentication` shape had an `oauth2` variant that held a client secret and a refresh token inline and that nothing ran. ADR-0143 opens the grant-bearing kinds on the instance, with the secret as a reference. + +- **`@objectstack/spec`**: `ConnectorInstanceAuthSchema` gains `oauth2` (D2.1), one strict shape per `grant` (`authorization_code` with PKCE always on, `client_credentials` for a confidential client), and `app-credential` (D2.2, `vendor` `feishu` / `dingtalk` / `wecom`, `appId`, a required `credentialRef`). The publish door refuses a missing `tokenUrl`, `clientId` or `authorizationUrl` at its key under `auth`, except on `provider: 'mcp'` over `http`, where the MCP authorization flow discovers them. An inline `clientSecret`, `refreshToken`, `accessToken` or `appSecret` is refused by name with the `credentialRef` prescription. `signed` is not a member (#26710). `ConnectorOAuth2Schema` is removed from `ConnectorAuthConfigSchema` (D7), and `ResolvedConnectorAuth` stays the four static kinds. The per-dispatch handle types (`ConnectorCredentialHandle`, `BorrowedConnectorCredential`, `BorrowedConnectorAuth`, `ConnectorCredentialRequest`) move into `@objectstack/spec/integration`, and `ConnectorProviderContext` gains an optional `credentialHandle` (D4). The D3 entry `connector-auth-config-oauth2-retired` names the removal for `os migrate meta`. +- **`@objectstack/types`**: re-exports the four handle types from `@objectstack/spec/integration`; the same names, structurally unchanged. +- **`@objectstack/service-automation`**: this server does not run the per-dispatch token resolution yet (ADR-0143 S1). A grant-bearing instance is held **degraded** with a reason that names its kind, and its upstream is never called without the credential. A datasource that borrows it through `external.connector` is refused with `422`, naming the kind. + +**BREAKING** for a connector that writes `authentication: { type: 'oauth2', ... }`. + +- **Before.** `authentication: { type: 'oauth2', authorizationUrl, tokenUrl, clientId, clientSecret, refreshToken }` was accepted, with the secret and the refresh token inline, and no runtime obtained or refreshed a token from it. +- **Now.** `ConnectorAuthConfigSchema` refuses it at `type`, with a prescription naming ADR-0143 D7 and the replacement. No first-party source declares it. +- **Fix.** Declare the grant on a declarative instance's `auth`, with the secret as a reference: `auth: { type: 'oauth2', grant: 'authorization_code', authorizationUrl, tokenUrl, scopes, clientId, credentialRef: 'VENDOR_CLIENT_SECRET' }`, or `grant: 'client_credentials'` without `authorizationUrl`. Code that registers a connector with a token it already holds passes `{ type: 'bearer', token }`. diff --git a/content/docs/automation/connectors.mdx b/content/docs/automation/connectors.mdx index 396c2d5b91a..6284404615b 100644 --- a/content/docs/automation/connectors.mdx +++ b/content/docs/automation/connectors.mdx @@ -394,22 +394,67 @@ auth: { type: 'bearer', credentialRef: 'BILLING_API_TOKEN' }, — fails validation, and `auth: { type: 'bearer', token: … }` is not even a legal shape (the declarative union has no `token` field to put a secret in). -### Where is `oauth2`? - -Deliberately absent from declarative instances — **not** an oversight to work -around. The runtime auth shape has five variants (`none`, `bearer`, -`api-key`, `basic`, `oauth2`); the declarative shape stops at four. Static -credentials resolved from env/config are the open tier; the OAuth2 -authorization-code/refresh **lifecycle** — token acquisition, rotation, -per-tenant connections — is the enterprise tier (ADR-0015). If your upstream -needs OAuth2, use an enterprise credential resolver or register the connector -from a plugin that manages its own tokens; don't try to emulate it by stuffing -a short-lived access token behind a `bearer` ref. +### Grant-bearing kinds: `oauth2` and `app-credential` + +Some upstreams take no static credential: the token is obtained from a token +endpoint and refreshed as it expires. Two more `auth` kinds declare that, in +the open tier (ADR-0143). They are **grant-bearing**: the metadata carries the +endpoints, the client id and a `credentialRef` for the client or app secret, +never a token. Access and refresh tokens are runtime state and have no key here. + +```ts +// OAuth 2.0 — a user grants access once (`authorization_code`, PKCE S256 always on): +auth: { + type: 'oauth2', + grant: 'authorization_code', + authorizationUrl: 'https://vendor.example.com/oauth/authorize', + tokenUrl: 'https://vendor.example.com/oauth/token', + scopes: ['crm.read'], + clientId: 'objectstack-prod', + credentialRef: 'VENDOR_CLIENT_SECRET', // omit for a public client +} + +// OAuth 2.0 — server to server (`client_credentials`, a confidential client): +auth: { + type: 'oauth2', + grant: 'client_credentials', + tokenUrl: 'https://vendor.example.com/oauth/token', + resource: 'https://api.vendor.example.com', + clientId: 'objectstack-prod', + credentialRef: 'VENDOR_CLIENT_SECRET', // required for this grant +} + +// A vendor app credential (Feishu, DingTalk, WeCom): app id + app secret → tenant token +auth: { type: 'app-credential', vendor: 'feishu', appId: 'cli_a1b2', credentialRef: 'FEISHU_APP_SECRET' } +``` + +What the publish door checks, each refusal located at its key under `auth`: + +- **`oauth2`** needs `tokenUrl` and `clientId`, plus `authorizationUrl` on + `authorization_code`. The one exception is `provider: 'mcp'` over an `http` + transport, which may omit the endpoints and the client id: the MCP + authorization flow discovers the server's metadata and can register the + client. `client_credentials` has no authorization step, so + `authorizationUrl` is refused there, and it requires `credentialRef`. + A `credentialRef` without its `clientId` is refused. An inline + `clientSecret`, `refreshToken` or `accessToken` is refused by name, pointing + at `credentialRef`. There is no PKCE switch. +- **`app-credential`** takes `vendor` (`feishu`, `dingtalk` or `wecom`), `appId` + and a required `credentialRef`. An inline `appSecret` is refused by name. + +A server that does not yet run the per-dispatch token resolution holds such an +instance **degraded**, with a reason that names the grant-bearing kind, and +never calls the upstream without its credential. A datasource that borrows +from it (`external.connector`) is refused with `422`. Until the server serves +them, declare a static kind (`bearer`, `api-key` or `basic`) to dispatch now; +don't emulate a grant by putting a short-lived access token behind a `bearer` +ref. ### The runtime shape: `authentication` -The `authentication` field is the **runtime** auth config — it carries the -resolved secret *inline* (`{ type: 'bearer', token }`), because it is supplied +The `authentication` field is the **runtime** auth config, with four static +variants (`none`, `bearer`, `api-key`, `basic`). It carries the resolved +secret *inline* (`{ type: 'bearer', token }`), because it is supplied by **host code** (a plugin calling `engine.registerConnector`, or the executors' hand-wired instance options reading `process.env`). That is a different trust anchor than metadata: code review guards what enters it, and @@ -418,6 +463,11 @@ leave `authentication` alone — provider-bound instances must use `auth`, and catalog descriptors documenting a planned integration must still never carry a live secret value. +The runtime shape's `oauth2` variant was removed in `@objectstack/spec` 18 +(ADR-0143 D7): it held a client secret and a refresh token inline and nothing +ran its lifecycle. `authentication: { type: 'oauth2' }` is refused with that +prescription; declare the grant on an instance's `auth` instead, as above. + ## Dispatching from a flow A materialized instance is dispatched exactly like any other connector — by @@ -449,6 +499,7 @@ retry): |:---|:---| | `provider` names no installed factory | **Hard boot error** naming the entry, the provider, and the plugin that supplies it — add the executor to `plugins:` | | Invalid `providerConfig` (missing `baseUrl` / `spec` / `transport`, wrong shape) | **Hard boot error** from the provider factory | +| `auth` is a grant-bearing kind (`oauth2`, `app-credential`) on a server that does not yet run its per-dispatch resolution | **Not fatal**: the instance registers **degraded**, naming the kind, and the upstream is never called without its credential; a datasource borrowing it is refused with `422` | | `credentialRef` resolves to nothing | **Not fatal**: the instance registers **degraded**, naming the reference — set it (`PUT …/credential-refs/:ref`), and it materializes without a restart | | The upstream refuses the credential (`401` / `403`) | **Not fatal**: the instance registers **degraded**. A stored credential is marked `rejected` and waits until it is set again (or a restart); an environment or vault credential is retried on the backoff | | Instance `name` collides with a plugin-registered connector | **Hard boot error** — there is no silent precedence between the two worlds | diff --git a/packages/qa/downstream-contract/src/additional-domains.fixtures.ts b/packages/qa/downstream-contract/src/additional-domains.fixtures.ts index ac233e96d17..86b8ffcb0ac 100644 --- a/packages/qa/downstream-contract/src/additional-domains.fixtures.ts +++ b/packages/qa/downstream-contract/src/additional-domains.fixtures.ts @@ -34,12 +34,14 @@ export const DcConnector: Connector = { label: 'DC HubSpot', type: 'saas', description: 'Example SaaS connector.', + // The runtime `oauth2` variant was retired in @objectstack/spec 18 (#28137, + // ADR-0143 D7): an intended break, documented in that PR's BREAKING changeset + // and its ADR-0087 entry `connector-auth-config-oauth2-retired`. A third party + // that wrote `{ type: 'oauth2', ... }` here now meets that prescription; the + // grant lives on a declarative instance's `auth`, the secret as a reference. authentication: { - type: 'oauth2', - authorizationUrl: 'https://example.com/oauth/authorize', - tokenUrl: 'https://example.com/oauth/token', - clientId: 'env:DC_CLIENT_ID', - clientSecret: 'env:DC_CLIENT_SECRET', + type: 'bearer', + token: 'env:DC_TOKEN', }, actions: [ { diff --git a/packages/spec/src/integration/connector-auth-grant-kinds.test.ts b/packages/spec/src/integration/connector-auth-grant-kinds.test.ts index 11227dce27b..96fd30d66ea 100644 --- a/packages/spec/src/integration/connector-auth-grant-kinds.test.ts +++ b/packages/spec/src/integration/connector-auth-grant-kinds.test.ts @@ -140,7 +140,7 @@ describe('ADR-0143 D2.1 — `oauth2` instance auth', () => { it('refuses client_credentials without its secret: the grant is for a confidential client', () => { const { credentialRef: _r, ...noSecret } = clientCredentials; - expect(located(entry(noSecret))).toEqual(['custom @ auth']); + expect(located(entry(noSecret))).toEqual(['custom @ auth.credentialRef']); }); it('has no PKCE switch: S256 is always on', () => { diff --git a/packages/spec/src/migrations/entries/semantic/18.connector-auth-config-oauth2-retired.ts b/packages/spec/src/migrations/entries/semantic/18.connector-auth-config-oauth2-retired.ts new file mode 100644 index 00000000000..96e0f0f89de --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.connector-auth-config-oauth2-retired.ts @@ -0,0 +1,40 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// ADR-0143 D7 (Accepted 2026-10-28): the runtime `oauth2` variant of +// `ConnectorAuthConfigSchema` — `ConnectorOAuth2Schema`, the `authentication` +// shape a plugin hands to registerConnector — is retired, not redefined as a +// resolved shape. It inlined a client secret and a refresh token, and nothing +// consumed it: every provider factory takes the four static kinds, and the +// authoring door refused any non-`none` `authentication`. OAuth2 moves onto a +// provider-bound instance's `auth` (ADR-0143 D2.1), where the secret is a +// `credentialRef` and the tokens are runtime state. No D2 conversion: the +// replacement lives on another surface and needs the inline secret moved into +// the secrets layer, which no mechanical edit may do; the union's own error map +// refuses the variant at parse with the prescription. +export const entry: SemanticMigration = { + id: 'connector-auth-config-oauth2-retired', + surface: "connector.authentication (`type: 'oauth2'`, with its inline `clientSecret`, `refreshToken`, " + + '`tokenExpiry` and `redirectUri`) — the runtime auth of a connector definition, as `defineConnector` ' + + 'and registerConnector parse it; the exported ConnectorOAuth2Schema leaves with it', + replacement: "A provider-bound connector instance (`provider: 'openapi' | 'rest' | 'mcp'`) declaring " + + "`auth: { type: 'oauth2', grant: 'authorization_code' | 'client_credentials', tokenUrl, clientId, " + + 'credentialRef, ... }`, where `credentialRef` names the client secret in the secrets layer and the ' + + 'platform obtains, stores and refreshes the tokens; `authentication` becomes `{ type: \'none\' }` or is dropped.', + reason: 'There is no mechanical rewrite. The variant carried a client secret and a refresh token inline, and ' + + 'its replacement carries neither: the secret moves into the secrets layer under a reference the author ' + + 'names, and a refresh token is never declared at all — an administrator connects an `authorization_code` ' + + 'instance once and the platform keeps the grant in its connection record. Which provider serves the ' + + "vendor (an OpenAPI document, a REST base URL or an MCP server) is the author's call too, because a " + + 'connector registered from plugin code with this variant never had its credential applied by anything: ' + + 'its own handlers carried their transport. So the parse refuses the variant by name with the ' + + 'prescription, and the author decides for each such connector whether it becomes a provider-bound ' + + 'instance or keeps its hand-written handlers with `authentication: { type: \'none\' }`.', + acceptanceCriteria: "No connector definition passed to `defineConnector` or registerConnector carries " + + "`authentication.type: 'oauth2'`, and no source imports ConnectorOAuth2Schema; the connector parse refuses " + + 'one with the prescription at `authentication.type`. Every vendor such a connector reached through OAuth2 ' + + "is either a provider-bound instance whose `auth` is `{ type: 'oauth2', ... }` with its client secret " + + 'behind `credentialRef` and no token in the definition, or a hand-written connector whose handlers carry ' + + 'their own transport and whose `authentication` is `none`.', +}; diff --git a/packages/spec/src/migrations/registry.ts.template b/packages/spec/src/migrations/registry.ts.template index cc12bcf621b..e8732b60f0c 100644 --- a/packages/spec/src/migrations/registry.ts.template +++ b/packages/spec/src/migrations/registry.ts.template @@ -1374,6 +1374,19 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + 'tombstones with no D2 conversion (`document` is no stack collection member), ' + 'registered in RETIRED_KEYS_BY_MAJOR[18] with one D3 semantic entry.', }, + { + id: 'connector-auth-config-oauth2-retired', + order: 115, + text: + "It also retires the runtime `oauth2` variant of a connector's `authentication` (ADR-0143 D7), the " + + '`ConnectorOAuth2Schema` shape a plugin handed to registerConnector. It inlined a client secret and a ' + + 'refresh token, and nothing consumed it: every provider factory takes the four static kinds, and the ' + + "authoring door refused any non-`none` `authentication`. OAuth2 is declared instead on a provider-bound " + + "instance's `auth` (ADR-0143 D2.1), with the client secret behind a `credentialRef` and the tokens kept " + + 'by the platform as runtime state. The variant is refused by name on the union\'s own error map, with no ' + + 'D2 conversion (the secret must move into the secrets layer, which no mechanical edit may do); its D3 ' + + 'record is the semantic entry `connector-auth-config-oauth2-retired`.', + }, { id: 'connector-error-mapping-retired', order: 22, diff --git a/packages/spec/src/shared/connector-auth.test.ts b/packages/spec/src/shared/connector-auth.test.ts index 48ff6eecdb1..e1136850161 100644 --- a/packages/spec/src/shared/connector-auth.test.ts +++ b/packages/spec/src/shared/connector-auth.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; +import { ConnectorSchema } from '../integration/connector.zod'; import { - ConnectorOAuth2Schema, ConnectorAPIKeySchema, ConnectorBasicAuthSchema, ConnectorBearerAuthSchema, @@ -8,63 +8,6 @@ import { ConnectorAuthConfigSchema, } from './connector-auth.zod'; -describe('ConnectorOAuth2Schema', () => { - const validOAuth2 = { - type: 'oauth2' as const, - authorizationUrl: 'https://auth.example.com/authorize', - tokenUrl: 'https://auth.example.com/token', - clientId: 'my-client-id', - clientSecret: 'my-client-secret', - }; - - it('should accept valid minimal oauth2 config', () => { - const result = ConnectorOAuth2Schema.parse(validOAuth2); - expect(result.type).toBe('oauth2'); - expect(result.authorizationUrl).toBe('https://auth.example.com/authorize'); - }); - - it('should accept oauth2 with all optional fields', () => { - const result = ConnectorOAuth2Schema.parse({ - ...validOAuth2, - scopes: ['read', 'write'], - redirectUri: 'https://app.example.com/callback', - refreshToken: 'refresh-token-value', - tokenExpiry: 1700000000, - }); - expect(result.scopes).toEqual(['read', 'write']); - expect(result.redirectUri).toBe('https://app.example.com/callback'); - expect(result.refreshToken).toBe('refresh-token-value'); - expect(result.tokenExpiry).toBe(1700000000); - }); - - it('should have optional fields as undefined when not provided', () => { - const result = ConnectorOAuth2Schema.parse(validOAuth2); - expect(result.scopes).toBeUndefined(); - expect(result.redirectUri).toBeUndefined(); - expect(result.refreshToken).toBeUndefined(); - expect(result.tokenExpiry).toBeUndefined(); - }); - - it('should reject invalid authorizationUrl', () => { - expect(() => - ConnectorOAuth2Schema.parse({ ...validOAuth2, authorizationUrl: 'not-a-url' }), - ).toThrow(); - }); - - it('should reject invalid tokenUrl', () => { - expect(() => - ConnectorOAuth2Schema.parse({ ...validOAuth2, tokenUrl: 'not-a-url' }), - ).toThrow(); - }); - - it('should reject missing required fields', () => { - expect(() => ConnectorOAuth2Schema.parse({ type: 'oauth2' })).toThrow(); - expect(() => - ConnectorOAuth2Schema.parse({ type: 'oauth2', authorizationUrl: 'https://a.com' }), - ).toThrow(); - }); -}); - describe('ConnectorAPIKeySchema', () => { it('should accept valid api-key config with default headerName', () => { const result = ConnectorAPIKeySchema.parse({ @@ -151,17 +94,6 @@ describe('ConnectorNoAuthSchema', () => { }); describe('ConnectorAuthConfigSchema', () => { - it('should accept oauth2 via discriminated union', () => { - const result = ConnectorAuthConfigSchema.parse({ - type: 'oauth2', - authorizationUrl: 'https://auth.example.com/authorize', - tokenUrl: 'https://auth.example.com/token', - clientId: 'id', - clientSecret: 'secret', - }); - expect(result.type).toBe('oauth2'); - }); - it('should accept api-key via discriminated union', () => { const result = ConnectorAuthConfigSchema.parse({ type: 'api-key', @@ -200,3 +132,41 @@ describe('ConnectorAuthConfigSchema', () => { expect(() => ConnectorAuthConfigSchema.parse({ key: 'value' })).toThrow(); }); }); + +// ADR-0143 D7 — the runtime `oauth2` variant (`ConnectorOAuth2Schema`) is +// retired, not redefined: it inlined a client secret and a refresh token and +// no provider read it. Its one remaining reader is an author who still writes +// it, and that author meets the prescription, at `type`, on both carriers. +describe('ConnectorAuthConfigSchema — the retired `oauth2` variant (ADR-0143 D7)', () => { + const retired = { + type: 'oauth2', + authorizationUrl: 'https://auth.example.com/authorize', + tokenUrl: 'https://auth.example.com/token', + clientId: 'id', + clientSecret: 'secret', + }; + + it('refuses it at `type` with the prescription naming the instance auth that replaces it', () => { + const result = ConnectorAuthConfigSchema.safeParse(retired); + expect(result.success).toBe(false); + const [issue] = result.error!.issues; + expect([issue!.code, issue!.path.join('.')]).toEqual(['invalid_union', 'type']); + expect(issue!.message).toMatch(/oauth2.*was removed in @objectstack\/spec 18 \(ADR-0143 D7\)/s); + expect(issue!.message).toContain("auth: { type: 'oauth2'"); + expect(issue!.message).toContain('credentialRef'); + }); + + it('refuses it on the connector definition too, located at `authentication.type`', () => { + const result = ConnectorSchema.safeParse({ name: 'crm', label: 'CRM', type: 'saas', authentication: retired }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'authentication.type'); + expect(issue?.code).toBe('invalid_union'); + expect(issue?.message).toContain('ADR-0143 D7'); + }); + + it("CONTROL: any other unknown variant keeps zod's own report, not the retirement text", () => { + const result = ConnectorAuthConfigSchema.safeParse({ type: 'custom' }); + expect(result.success).toBe(false); + expect(result.error!.issues[0]!.message).not.toContain('ADR-0143'); + }); +}); diff --git a/packages/spec/src/shared/connector-auth.zod.ts b/packages/spec/src/shared/connector-auth.zod.ts index 0fff4204aa7..f486566242e 100644 --- a/packages/spec/src/shared/connector-auth.zod.ts +++ b/packages/spec/src/shared/connector-auth.zod.ts @@ -301,6 +301,7 @@ export const ConnectorInstanceOAuth2ClientCredentialsAuthSchema = lazySchema(() ), }).refine(dependentRequired({ credentialRef: ['clientId'], clientId: ['credentialRef'] }), { message: "`grant: 'client_credentials'` is only for a confidential client (RFC 6749 §4.4): declare `clientId` and the `credentialRef` naming its secret together, or omit both on `provider: 'mcp'` over http to let the provider register a client.", + path: ['credentialRef'], })); export type ConnectorInstanceOAuth2ClientCredentialsAuth = z.input; diff --git a/packages/spec/variant-docs.json b/packages/spec/variant-docs.json index 762210a9ef7..52e01152990 100644 --- a/packages/spec/variant-docs.json +++ b/packages/spec/variant-docs.json @@ -82,20 +82,28 @@ "reason": "No hand-written guide covers the Job type; content/docs/references/system/job.mdx is generated from JobSchema. Note that automation/flows.mdx's `schedule: { type: 'cron' }` is a DIFFERENT surface — a flow start node's own config (flow.zod.ts tombstones a top-level `schedule`) — so it neither documents nor governs this union." }, { - "key": "type:api-key|basic|bearer|none|oauth2", + "key": "type:api-key|basic|bearer|none", "label": "connector authentication", "docs": [ "content/docs/automation/connectors.mdx" ], - "note": "The runtime auth shape (ConnectorAuthConfigSchema) — five variants including the enterprise-tier `oauth2`. Was exempt generated-reference-only while no hand-written connector page existed; #4289 wrote the guide and bound it, per the old exemption's own instruction." + "note": "The runtime auth shape (ConnectorAuthConfigSchema): the four static variants. Its `oauth2` variant was retired in spec 18 (ADR-0143 D7, #28137, ADR-0087 entry connector-auth-config-oauth2-retired); the grant moved to the instance `auth` below. Was exempt generated-reference-only while no hand-written connector page existed; #4289 wrote the guide and bound it, per the old exemption's own instruction." }, { - "key": "type:api-key|basic|bearer|none", - "label": "connector auth (environment-artifact projection)", + "key": "type:api-key|app-credential|basic|bearer|none", + "label": "connector instance auth", + "docs": [ + "content/docs/automation/connectors.mdx" + ], + "note": "The declarative connector-instance auth shape (ADR-0097, ADR-0143), reached via EnvironmentArtifactSchema.metadata.connectors[].auth and the assembled package manifest: `credentialRef` references only. `oauth2` is a member too, keyed separately because it is itself a union on `grant` (the walk reports the inner discriminator); see the grant entry below. The guide's 'Grant-bearing kinds' section covers `oauth2` and `app-credential`." + }, + { + "key": "grant:authorization_code|client_credentials", + "label": "connector instance oauth2 grant", "docs": [ "content/docs/automation/connectors.mdx" ], - "note": "The declarative connector-instance auth shape (ADR-0097), reached via EnvironmentArtifactSchema.metadata.connectors[].auth: `credentialRef` references only, and `oauth2` deliberately absent (enterprise tier, ADR-0015) — the guide states that absence explicitly so authors don't read it as an omission." + "note": "The `oauth2` member of connector-instance auth, one strict shape per grant (ADR-0143 D2.1): `authorizationUrl` exists only on authorization_code, and client_credentials requires `credentialRef`." }, { "key": "strategy:isolated_db|isolated_schema|shared_schema", From 44282b648ea25a85ed3337851ada9f29cd7faa66 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 14:43:50 +0000 Subject: [PATCH 5/9] chore(spec): regenerate api-surface, export-origins, declaration-map, reference docs and strictness counts; SYNC_ARCHITECTURE example off the retired variant [wip] Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibvmENgz1tZkajp5SuJ --- content/docs/references/index.mdx | 10 +- .../docs/references/integration/connector.mdx | 261 +++++++++++++++--- .../shared.md | 2 +- packages/spec/api-surface/integration.json | 16 ++ .../spec/declaration-map/integration.json | 12 + packages/spec/docs/SYNC_ARCHITECTURE.md | 24 +- packages/spec/export-origins/integration.json | 16 ++ ...18.connector-auth-config-oauth2-retired.ts | 2 +- 8 files changed, 286 insertions(+), 57 deletions(-) diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index ad5714aef47..e81f4bfa13e 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,7 +1,7 @@ --- title: Protocol reference — every schema by module navTitle: Protocol Reference -description: Every schema published by @objectstack/spec — 1549 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1555 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -25,7 +25,7 @@ counts are sums of the rows they head. Regenerate with | [Automation Protocol](/docs/references/automation) | 15 | 77 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | | [Data Protocol](/docs/references/data) | 31 | 174 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | | [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. | -| [Integration Protocol](/docs/references/integration) | 1 | 12 | The single connector protocol (ADR-0097) — catalog descriptors and provider-bound instances. | +| [Integration Protocol](/docs/references/integration) | 1 | 18 | The single connector protocol (ADR-0097) — catalog descriptors and provider-bound instances. | | [Kernel Protocol](/docs/references/kernel) | 29 | 151 | Plugin lifecycle and manifests, capabilities and security, metadata loading, service registry. | | [Marketplace Protocol](/docs/references/marketplace) | 4 | 30 | The package & marketplace format — package identity and versions, listing, publish, review, search, install, template manifests. | | [QA Protocol](/docs/references/qa) | 1 | 8 | Declarative test suites — scenarios, steps, actions and assertions. | @@ -34,7 +34,7 @@ counts are sums of the rows they head. Regenerate with | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 37 | 287 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 172 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **202** | **1549** | 14 protocol modules | +| **Total** | **202** | **1555** | 14 protocol modules | --- @@ -190,13 +190,13 @@ Users and accounts, organizations, positions, SCIM provisioning. ## Integration Protocol -**Source:** `packages/spec/src/integration/` · **Import:** `@objectstack/spec/integration` · **1 page, 12 schemas** +**Source:** `packages/spec/src/integration/` · **Import:** `@objectstack/spec/integration` · **1 page, 18 schemas** The single connector protocol (ADR-0097) — catalog descriptors and provider-bound instances. | File | Schemas | | :--- | :--- | -| [`connector.zod.ts`](/docs/references/integration/connector) | `Connector`, `ConnectorAction`, `ConnectorActionEffect`, `ConnectorInstanceAPIKeyAuth`, `ConnectorInstanceAuth`, `ConnectorInstanceBasicAuth`, `ConnectorInstanceBearerAuth`, `ConnectorInstanceNoAuth`, `ConnectorRetryStrategy`, `ConnectorType`, `DeclarativeConnectorEntry`, `RetryConfig` | +| [`connector.zod.ts`](/docs/references/integration/connector) | `Connector`, `ConnectorAction`, `ConnectorActionEffect`, `ConnectorAppCredentialVendor`, `ConnectorInstanceAPIKeyAuth`, `ConnectorInstanceAppCredentialAuth`, `ConnectorInstanceAuth`, `ConnectorInstanceBasicAuth`, `ConnectorInstanceBearerAuth`, `ConnectorInstanceNoAuth`, `ConnectorInstanceOAuth2Auth`, `ConnectorInstanceOAuth2AuthorizationCodeAuth`, `ConnectorInstanceOAuth2ClientCredentialsAuth`, `ConnectorOAuth2Grant`, `ConnectorRetryStrategy`, `ConnectorType`, `DeclarativeConnectorEntry`, `RetryConfig` | --- diff --git a/content/docs/references/integration/connector.mdx b/content/docs/references/integration/connector.mdx index 5c84fa303ad..4a42d19d55d 100644 --- a/content/docs/references/integration/connector.mdx +++ b/content/docs/references/integration/connector.mdx @@ -165,8 +165,8 @@ an exception for itself.) ## TypeScript Usage ```typescript -import { ConnectorSchema, ConnectorActionSchema, ConnectorActionEffectSchema, ConnectorInstanceAPIKeyAuthSchema, ConnectorInstanceAuthSchema, ConnectorInstanceBasicAuthSchema, ConnectorInstanceBearerAuthSchema, ConnectorInstanceNoAuthSchema, ConnectorRetryStrategySchema, ConnectorTypeSchema, DeclarativeConnectorEntrySchema, RetryConfigSchema } from '@objectstack/spec/integration'; -import type { Connector, ConnectorAction, ConnectorActionEffect, ConnectorInstanceAPIKeyAuth, ConnectorInstanceAuth, ConnectorInstanceBasicAuth, ConnectorInstanceBearerAuth, ConnectorInstanceNoAuth, ConnectorRetryStrategy, ConnectorType, DeclarativeConnectorEntry, RetryConfig } from '@objectstack/spec/integration'; +import { ConnectorSchema, ConnectorActionSchema, ConnectorActionEffectSchema, ConnectorAppCredentialVendorSchema, ConnectorInstanceAPIKeyAuthSchema, ConnectorInstanceAppCredentialAuthSchema, ConnectorInstanceAuthSchema, ConnectorInstanceBasicAuthSchema, ConnectorInstanceBearerAuthSchema, ConnectorInstanceNoAuthSchema, ConnectorInstanceOAuth2AuthSchema, ConnectorInstanceOAuth2AuthorizationCodeAuthSchema, ConnectorInstanceOAuth2ClientCredentialsAuthSchema, ConnectorOAuth2GrantSchema, ConnectorRetryStrategySchema, ConnectorTypeSchema, DeclarativeConnectorEntrySchema, RetryConfigSchema } from '@objectstack/spec/integration'; +import type { Connector, ConnectorAction, ConnectorActionEffect, ConnectorAppCredentialVendor, ConnectorInstanceAPIKeyAuth, ConnectorInstanceAppCredentialAuth, ConnectorInstanceAuth, ConnectorInstanceBasicAuth, ConnectorInstanceBearerAuth, ConnectorInstanceNoAuth, ConnectorInstanceOAuth2Auth, ConnectorInstanceOAuth2AuthorizationCodeAuth, ConnectorInstanceOAuth2ClientCredentialsAuth, ConnectorOAuth2Grant, ConnectorRetryStrategy, ConnectorType, DeclarativeConnectorEntry, RetryConfig } from '@objectstack/spec/integration'; // Validate data const result = ConnectorSchema.parse(data); @@ -185,10 +185,10 @@ const result = ConnectorSchema.parse(data); | **type** | `Enum<'saas' \| 'database' \| 'file_storage' \| 'message_queue' \| 'api' \| 'custom'>` | ✅ | Connector type | | **description** | `string` | optional | Connector description | | **icon** | `string` | optional | Icon identifier | -| **authentication** | `{ type: 'oauth2'; authorizationUrl: string; tokenUrl: string; clientId: string; … } \| { type: 'api-key'; key: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; password: string } \| { type: 'bearer'; token: string } \| { type: 'none' }` | optional (default: `{"type":"none"}`) | Authentication configuration (runtime shape with inline secrets — plugin-supplied at registerConnector). Authored entries must not inline secrets: use `auth.credentialRef` on a provider-bound instance. | +| **authentication** | `{ type: 'api-key'; key: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; password: string } \| { type: 'bearer'; token: string } \| { type: 'none' }` | optional (default: `{"type":"none"}`) | Authentication configuration (runtime shape with inline secrets — plugin-supplied at registerConnector). Authored entries must not inline secrets: use `auth.credentialRef` on a provider-bound instance. | | **provider** | `string` | optional | Generic-executor key that materializes this declarative entry at boot (e.g. openapi/mcp/rest). Omit for a catalog-only descriptor. Unknown provider ⇒ hard boot error (ADR-0097). | | **providerConfig** | `Record` | optional | Provider-specific config validated by the provider factory at boot (e.g. `{ spec, baseUrl }` for openapi, where spec is an inline document, a package-relative file path like './billing-openapi.json', or an http(s) URL). Requires `provider`. | -| **auth** | `{ type: 'none' } \| { type: 'bearer'; credentialRef: string } \| { type: 'api-key'; credentialRef: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; credentialRef: string }` | optional | Declarative instance auth — references credentials via `credentialRef` (resolved when the instance materializes), never inline secrets. Requires `provider` (ADR-0097). | +| **auth** | `{ type: 'none' } \| { type: 'bearer'; credentialRef: string } \| { type: 'api-key'; credentialRef: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; credentialRef: string } \| … +2 more` | optional | Declarative instance auth — references credentials via `credentialRef` (resolved when the instance materializes), never inline secrets. Requires `provider` (ADR-0097). | | **actions** | `{ key: string; label: string; description?: string; inputSchema?: Record; … }[]` | optional | | | **triggers** | `never` | optional | [REMOVED] `connector.triggers` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — a connector trigger never started anything: `AutomationEngine.registerConnector` registers a connector's actions only, no polling loop read `intervalSeconds` (or the `interval` spelling it was renamed from), and no receiver was driven by a `webhook` trigger. Delete the key; the `ConnectorTrigger` shape leaves with it. To start work from an external system, write a flow that calls the connector's action in a `connector_action` node: for an external event, an `api` flow that the event's sender calls; for a scheduled pull, a `schedule` flow. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **syncConfig** | `never` | optional | [REMOVED] `connector.syncConfig` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — no engine ever ran a connector-attached sync: nothing read `strategy`, `direction`, `realtimeSync`, `timestampField`, `conflictResolution`, `batchSize`, `deleteMode` or `filters`, so the `latest_wins` and `soft_delete` defaults resolved and deleted nothing. Delete the key; the `DataSyncConfig` shape leaves with it. A sync is defined on its TARGET: a `mapping` (`targetObject`, `fieldMapping`, `mode`, `upsertKey`) whose `connectorSource` names the `rest` or `openapi` connector it pulls from, the read action and an optional timestamp `watermark`, with a `job` for the cadence. Its pull runs when a `job` drives it — a `job` whose `pull: { mapping }` names that mapping; the binding alone moves no rows. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | @@ -211,20 +211,6 @@ const result = ConnectorSchema.parse(data); | **_packageVersion** | `string` | optional | Owning package version. | | **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | -### Nested Shape: `Connector.authentication[type='oauth2']` - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **type** | `'oauth2'` | ✅ | | -| **authorizationUrl** | `string` | ✅ | OAuth2 authorization endpoint | -| **tokenUrl** | `string` | ✅ | OAuth2 token endpoint | -| **clientId** | `string` | ✅ | OAuth2 client ID | -| **clientSecret** | `string` | ✅ | OAuth2 client secret (typically from ENV) | -| **scopes** | `string[]` | optional | Requested OAuth2 scopes | -| **redirectUri** | `string` | optional | OAuth2 redirect URI | -| **refreshToken** | `string` | optional | Refresh token for token renewal | -| **tokenExpiry** | `integer` | optional | Token expiry timestamp (Unix milliseconds) | - ### Nested Shape: `Connector.authentication[type='api-key']` | Property | Type | Required | Description | @@ -249,14 +235,14 @@ const result = ConnectorSchema.parse(data); | **type** | `'bearer'` | ✅ | | | **token** | `string` | ✅ | Bearer token | -### Nested Shape: `Connector.auth[type='bearer']` +### Nested Shape: `Connector.auth[option 2]` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **type** | `'bearer'` | ✅ | | | **credentialRef** | `string` | ✅ | Secrets-layer reference (e.g. an env-var name in the open tier) resolved to the bearer token at materialization. Never an inline token. | -### Nested Shape: `Connector.auth[type='api-key']` +### Nested Shape: `Connector.auth[option 3]` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | @@ -265,7 +251,7 @@ const result = ConnectorSchema.parse(data); | **headerName** | `string` | optional | HTTP header carrying the key (default X-API-Key). | | **paramName** | `string` | optional | Query parameter carrying the key (alternative to header). | -### Nested Shape: `Connector.auth[type='basic']` +### Nested Shape: `Connector.auth[option 4]` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | @@ -273,6 +259,40 @@ const result = ConnectorSchema.parse(data); | **username** | `string` | ✅ | Username (not a secret; safe to keep in metadata). | | **credentialRef** | `string` | ✅ | Secrets-layer reference resolved to the password at materialization. Never an inline password. | +### Nested Shape: `Connector.auth[option 5][grant='authorization_code']` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **type** | `'oauth2'` | ✅ | | +| **tokenUrl** | `string` | optional | Token endpoint. Required (except `provider: 'mcp'` over http, which discovers it). | +| **scopes** | `string[]` | optional | Scopes to request; sent space-separated. | +| **resource** | `string` | optional | Resource indicator (RFC 8707) — the API the token is for, where the authorization server asks for one (some call it the audience). | +| **clientId** | `string` | optional | OAuth2 client id (not a secret). Required (except `provider: 'mcp'` over http, where the provider may register a client dynamically). | +| **grant** | `'authorization_code'` | ✅ | An administrator connects once; the platform refreshes the grant per dispatch. | +| **authorizationUrl** | `string` | optional | Authorization endpoint. Required (except `provider: 'mcp'` over http, which discovers it). | +| **credentialRef** | `string` | optional | Secrets-layer reference resolved to the client secret of a pre-registered client (requires `clientId`). Omit for a public client. Never an inline secret. | + +### Nested Shape: `Connector.auth[option 5][grant='client_credentials']` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **type** | `'oauth2'` | ✅ | | +| **tokenUrl** | `string` | optional | Token endpoint. Required (except `provider: 'mcp'` over http, which discovers it). | +| **scopes** | `string[]` | optional | Scopes to request; sent space-separated. | +| **resource** | `string` | optional | Resource indicator (RFC 8707) — the API the token is for, where the authorization server asks for one (some call it the audience). | +| **clientId** | `string` | optional | OAuth2 client id (not a secret). Required (except `provider: 'mcp'` over http, where the provider may register a client dynamically). | +| **grant** | `'client_credentials'` | ✅ | No user step: the client exchanges its own credentials for a token. | +| **credentialRef** | `string` | optional | Secrets-layer reference resolved to the client secret; declared together with `clientId` (a `client_credentials` client is confidential). Never an inline secret. | + +### Nested Shape: `Connector.auth[option 6]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **type** | `'app-credential'` | ✅ | | +| **vendor** | `Enum<'feishu' \| 'dingtalk' \| 'wecom'>` | ✅ | Vendor whose app-credential token exchange to run: `feishu`, `dingtalk` or `wecom`. | +| **appId** | `string` | ✅ | The vendor's app identifier (not a secret): Feishu `app_id`, DingTalk `appKey`, WeCom `corpid`. | +| **credentialRef** | `string` | ✅ | Secrets-layer reference resolved to the app secret (WeCom: the application `corpsecret`). Never an inline secret. | + ### Nested Shape: `Connector.actions[number]` | Property | Type | Required | Description | @@ -326,6 +346,19 @@ What the action does upstream: 'read' never mutates (reports acted:0); 'write' d * `write` +--- + +## ConnectorAppCredentialVendor + +Vendor whose app-credential token exchange to run: `feishu`, `dingtalk` or `wecom`. + +### Allowed Values + +* `feishu` +* `dingtalk` +* `wecom` + + --- ## ConnectorInstanceAPIKeyAuth @@ -340,6 +373,20 @@ What the action does upstream: 'read' never mutates (reports acted:0); 'write' d | **paramName** | `string` | optional | Query parameter carrying the key (alternative to header). | +--- + +## ConnectorInstanceAppCredentialAuth + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **type** | `'app-credential'` | ✅ | | +| **vendor** | `Enum<'feishu' \| 'dingtalk' \| 'wecom'>` | ✅ | Vendor whose app-credential token exchange to run: `feishu`, `dingtalk` or `wecom`. | +| **appId** | `string` | ✅ | The vendor's app identifier (not a secret): Feishu `app_id`, DingTalk `appKey`, WeCom `corpid`. | +| **credentialRef** | `string` | ✅ | Secrets-layer reference resolved to the app secret (WeCom: the application `corpsecret`). Never an inline secret. | + + --- ## ConnectorInstanceAuth @@ -402,6 +449,27 @@ This schema accepts one of the following structures: --- +#### Option 5 + +Type: `{ type: 'oauth2'; tokenUrl?: string; scopes?: string[]; resource?: string; … } | … +1 more` + +--- + +#### Option 6 + +**Type:** `app-credential` + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **type** | `'app-credential'` | ✅ | | +| **vendor** | `Enum<'feishu' \| 'dingtalk' \| 'wecom'>` | ✅ | Vendor whose app-credential token exchange to run: `feishu`, `dingtalk` or `wecom`. | +| **appId** | `string` | ✅ | The vendor's app identifier (not a secret): Feishu `app_id`, DingTalk `appKey`, WeCom `corpid`. | +| **credentialRef** | `string` | ✅ | Secrets-layer reference resolved to the app secret (WeCom: the application `corpsecret`). Never an inline secret. | + +--- + --- @@ -439,6 +507,99 @@ This schema accepts one of the following structures: | **type** | `'none'` | ✅ | | +--- + +## ConnectorInstanceOAuth2Auth + +### Union Options + +This schema accepts one of the following structures: + +#### Option 1 + +**Type:** `oauth2` + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **type** | `'oauth2'` | ✅ | | +| **tokenUrl** | `string` | optional | Token endpoint. Required (except `provider: 'mcp'` over http, which discovers it). | +| **scopes** | `string[]` | optional | Scopes to request; sent space-separated. | +| **resource** | `string` | optional | Resource indicator (RFC 8707) — the API the token is for, where the authorization server asks for one (some call it the audience). | +| **clientId** | `string` | optional | OAuth2 client id (not a secret). Required (except `provider: 'mcp'` over http, where the provider may register a client dynamically). | +| **grant** | `'authorization_code'` | ✅ | An administrator connects once; the platform refreshes the grant per dispatch. | +| **authorizationUrl** | `string` | optional | Authorization endpoint. Required (except `provider: 'mcp'` over http, which discovers it). | +| **credentialRef** | `string` | optional | Secrets-layer reference resolved to the client secret of a pre-registered client (requires `clientId`). Omit for a public client. Never an inline secret. | + +--- + +#### Option 2 + +**Type:** `oauth2` + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **type** | `'oauth2'` | ✅ | | +| **tokenUrl** | `string` | optional | Token endpoint. Required (except `provider: 'mcp'` over http, which discovers it). | +| **scopes** | `string[]` | optional | Scopes to request; sent space-separated. | +| **resource** | `string` | optional | Resource indicator (RFC 8707) — the API the token is for, where the authorization server asks for one (some call it the audience). | +| **clientId** | `string` | optional | OAuth2 client id (not a secret). Required (except `provider: 'mcp'` over http, where the provider may register a client dynamically). | +| **grant** | `'client_credentials'` | ✅ | No user step: the client exchanges its own credentials for a token. | +| **credentialRef** | `string` | optional | Secrets-layer reference resolved to the client secret; declared together with `clientId` (a `client_credentials` client is confidential). Never an inline secret. | + +--- + + +--- + +## ConnectorInstanceOAuth2AuthorizationCodeAuth + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **type** | `'oauth2'` | ✅ | | +| **tokenUrl** | `string` | optional | Token endpoint. Required (except `provider: 'mcp'` over http, which discovers it). | +| **scopes** | `string[]` | optional | Scopes to request; sent space-separated. | +| **resource** | `string` | optional | Resource indicator (RFC 8707) — the API the token is for, where the authorization server asks for one (some call it the audience). | +| **clientId** | `string` | optional | OAuth2 client id (not a secret). Required (except `provider: 'mcp'` over http, where the provider may register a client dynamically). | +| **grant** | `'authorization_code'` | ✅ | An administrator connects once; the platform refreshes the grant per dispatch. | +| **authorizationUrl** | `string` | optional | Authorization endpoint. Required (except `provider: 'mcp'` over http, which discovers it). | +| **credentialRef** | `string` | optional | Secrets-layer reference resolved to the client secret of a pre-registered client (requires `clientId`). Omit for a public client. Never an inline secret. | + + +--- + +## ConnectorInstanceOAuth2ClientCredentialsAuth + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **type** | `'oauth2'` | ✅ | | +| **tokenUrl** | `string` | optional | Token endpoint. Required (except `provider: 'mcp'` over http, which discovers it). | +| **scopes** | `string[]` | optional | Scopes to request; sent space-separated. | +| **resource** | `string` | optional | Resource indicator (RFC 8707) — the API the token is for, where the authorization server asks for one (some call it the audience). | +| **clientId** | `string` | optional | OAuth2 client id (not a secret). Required (except `provider: 'mcp'` over http, where the provider may register a client dynamically). | +| **grant** | `'client_credentials'` | ✅ | No user step: the client exchanges its own credentials for a token. | +| **credentialRef** | `string` | optional | Secrets-layer reference resolved to the client secret; declared together with `clientId` (a `client_credentials` client is confidential). Never an inline secret. | + + +--- + +## ConnectorOAuth2Grant + +OAuth2 grant (RFC 6749): `authorization_code` (an administrator connects once; PKCE S256 always) or `client_credentials` (no user step). + +### Allowed Values + +* `authorization_code` +* `client_credentials` + + --- ## ConnectorRetryStrategy @@ -482,10 +643,10 @@ Connector type | **type** | `Enum<'saas' \| 'database' \| 'file_storage' \| 'message_queue' \| 'api' \| 'custom'>` | ✅ | Connector type | | **description** | `string` | optional | Connector description | | **icon** | `string` | optional | Icon identifier | -| **authentication** | `{ type: 'oauth2'; authorizationUrl: string; tokenUrl: string; clientId: string; … } \| { type: 'api-key'; key: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; password: string } \| { type: 'bearer'; token: string } \| { type: 'none' }` | optional (default: `{"type":"none"}`) | Authentication configuration (runtime shape with inline secrets — plugin-supplied at registerConnector). Authored entries must not inline secrets: use `auth.credentialRef` on a provider-bound instance. | +| **authentication** | `{ type: 'api-key'; key: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; password: string } \| { type: 'bearer'; token: string } \| { type: 'none' }` | optional (default: `{"type":"none"}`) | Authentication configuration (runtime shape with inline secrets — plugin-supplied at registerConnector). Authored entries must not inline secrets: use `auth.credentialRef` on a provider-bound instance. | | **provider** | `string` | optional | Generic-executor key that materializes this declarative entry at boot (e.g. openapi/mcp/rest). Omit for a catalog-only descriptor. Unknown provider ⇒ hard boot error (ADR-0097). | | **providerConfig** | `Record` | optional | Provider-specific config validated by the provider factory at boot (e.g. `{ spec, baseUrl }` for openapi, where spec is an inline document, a package-relative file path like './billing-openapi.json', or an http(s) URL). Requires `provider`. | -| **auth** | `{ type: 'none' } \| { type: 'bearer'; credentialRef: string } \| { type: 'api-key'; credentialRef: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; credentialRef: string }` | optional | Declarative instance auth — references credentials via `credentialRef` (resolved when the instance materializes), never inline secrets. Requires `provider` (ADR-0097). | +| **auth** | `{ type: 'none' } \| { type: 'bearer'; credentialRef: string } \| { type: 'api-key'; credentialRef: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; credentialRef: string } \| … +2 more` | optional | Declarative instance auth — references credentials via `credentialRef` (resolved when the instance materializes), never inline secrets. Requires `provider` (ADR-0097). | | **actions** | `{ key: string; label: string; description?: string; inputSchema?: Record; … }[]` | optional | | | **triggers** | `never` | optional | [REMOVED] `connector.triggers` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — a connector trigger never started anything: `AutomationEngine.registerConnector` registers a connector's actions only, no polling loop read `intervalSeconds` (or the `interval` spelling it was renamed from), and no receiver was driven by a `webhook` trigger. Delete the key; the `ConnectorTrigger` shape leaves with it. To start work from an external system, write a flow that calls the connector's action in a `connector_action` node: for an external event, an `api` flow that the event's sender calls; for a scheduled pull, a `schedule` flow. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **syncConfig** | `never` | optional | [REMOVED] `connector.syncConfig` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — no engine ever ran a connector-attached sync: nothing read `strategy`, `direction`, `realtimeSync`, `timestampField`, `conflictResolution`, `batchSize`, `deleteMode` or `filters`, so the `latest_wins` and `soft_delete` defaults resolved and deleted nothing. Delete the key; the `DataSyncConfig` shape leaves with it. A sync is defined on its TARGET: a `mapping` (`targetObject`, `fieldMapping`, `mode`, `upsertKey`) whose `connectorSource` names the `rest` or `openapi` connector it pulls from, the read action and an optional timestamp `watermark`, with a `job` for the cadence. Its pull runs when a `job` drives it — a `job` whose `pull: { mapping }` names that mapping; the binding alone moves no rows. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | @@ -508,20 +669,6 @@ Connector type | **_packageVersion** | `string` | optional | Owning package version. | | **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | -### Nested Shape: `DeclarativeConnectorEntry.authentication[type='oauth2']` - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **type** | `'oauth2'` | ✅ | | -| **authorizationUrl** | `string` | ✅ | OAuth2 authorization endpoint | -| **tokenUrl** | `string` | ✅ | OAuth2 token endpoint | -| **clientId** | `string` | ✅ | OAuth2 client ID | -| **clientSecret** | `string` | ✅ | OAuth2 client secret (typically from ENV) | -| **scopes** | `string[]` | optional | Requested OAuth2 scopes | -| **redirectUri** | `string` | optional | OAuth2 redirect URI | -| **refreshToken** | `string` | optional | Refresh token for token renewal | -| **tokenExpiry** | `integer` | optional | Token expiry timestamp (Unix milliseconds) | - ### Nested Shape: `DeclarativeConnectorEntry.authentication[type='api-key']` | Property | Type | Required | Description | @@ -546,14 +693,14 @@ Connector type | **type** | `'bearer'` | ✅ | | | **token** | `string` | ✅ | Bearer token | -### Nested Shape: `DeclarativeConnectorEntry.auth[type='bearer']` +### Nested Shape: `DeclarativeConnectorEntry.auth[option 2]` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **type** | `'bearer'` | ✅ | | | **credentialRef** | `string` | ✅ | Secrets-layer reference (e.g. an env-var name in the open tier) resolved to the bearer token at materialization. Never an inline token. | -### Nested Shape: `DeclarativeConnectorEntry.auth[type='api-key']` +### Nested Shape: `DeclarativeConnectorEntry.auth[option 3]` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | @@ -562,7 +709,7 @@ Connector type | **headerName** | `string` | optional | HTTP header carrying the key (default X-API-Key). | | **paramName** | `string` | optional | Query parameter carrying the key (alternative to header). | -### Nested Shape: `DeclarativeConnectorEntry.auth[type='basic']` +### Nested Shape: `DeclarativeConnectorEntry.auth[option 4]` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | @@ -570,6 +717,40 @@ Connector type | **username** | `string` | ✅ | Username (not a secret; safe to keep in metadata). | | **credentialRef** | `string` | ✅ | Secrets-layer reference resolved to the password at materialization. Never an inline password. | +### Nested Shape: `DeclarativeConnectorEntry.auth[option 5][grant='authorization_code']` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **type** | `'oauth2'` | ✅ | | +| **tokenUrl** | `string` | optional | Token endpoint. Required (except `provider: 'mcp'` over http, which discovers it). | +| **scopes** | `string[]` | optional | Scopes to request; sent space-separated. | +| **resource** | `string` | optional | Resource indicator (RFC 8707) — the API the token is for, where the authorization server asks for one (some call it the audience). | +| **clientId** | `string` | optional | OAuth2 client id (not a secret). Required (except `provider: 'mcp'` over http, where the provider may register a client dynamically). | +| **grant** | `'authorization_code'` | ✅ | An administrator connects once; the platform refreshes the grant per dispatch. | +| **authorizationUrl** | `string` | optional | Authorization endpoint. Required (except `provider: 'mcp'` over http, which discovers it). | +| **credentialRef** | `string` | optional | Secrets-layer reference resolved to the client secret of a pre-registered client (requires `clientId`). Omit for a public client. Never an inline secret. | + +### Nested Shape: `DeclarativeConnectorEntry.auth[option 5][grant='client_credentials']` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **type** | `'oauth2'` | ✅ | | +| **tokenUrl** | `string` | optional | Token endpoint. Required (except `provider: 'mcp'` over http, which discovers it). | +| **scopes** | `string[]` | optional | Scopes to request; sent space-separated. | +| **resource** | `string` | optional | Resource indicator (RFC 8707) — the API the token is for, where the authorization server asks for one (some call it the audience). | +| **clientId** | `string` | optional | OAuth2 client id (not a secret). Required (except `provider: 'mcp'` over http, where the provider may register a client dynamically). | +| **grant** | `'client_credentials'` | ✅ | No user step: the client exchanges its own credentials for a token. | +| **credentialRef** | `string` | optional | Secrets-layer reference resolved to the client secret; declared together with `clientId` (a `client_credentials` client is confidential). Never an inline secret. | + +### Nested Shape: `DeclarativeConnectorEntry.auth[option 6]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **type** | `'app-credential'` | ✅ | | +| **vendor** | `Enum<'feishu' \| 'dingtalk' \| 'wecom'>` | ✅ | Vendor whose app-credential token exchange to run: `feishu`, `dingtalk` or `wecom`. | +| **appId** | `string` | ✅ | The vendor's app identifier (not a secret): Feishu `app_id`, DingTalk `appKey`, WeCom `corpid`. | +| **credentialRef** | `string` | ✅ | Secrets-layer reference resolved to the app secret (WeCom: the application `corpsecret`). Never an inline secret. | + ### Nested Shape: `DeclarativeConnectorEntry.actions[number]` | Property | Type | Required | Description | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/shared.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/shared.md index 94d3e05194e..858a34e15d6 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/shared.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/shared.md @@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate. | Dir | Sites | |---|---| -| `shared/` | 21 | +| `shared/` | 23 | diff --git a/packages/spec/api-surface/integration.json b/packages/spec/api-surface/integration.json index d85aba30d29..8b39c7bb886 100644 --- a/packages/spec/api-surface/integration.json +++ b/packages/spec/api-surface/integration.json @@ -2,6 +2,8 @@ "description": "Every exported `name (kind)` of one published entry point of @objectstack/spec — the breadth half of the ADR-0059 backward-compatibility gate. Sharded by entry point (#5837) so two PRs touching different entry points never share a file. Reads the BUILT dist/*.d.ts: regenerate with `pnpm --filter @objectstack/spec gen:api-surface` after a real build.", "entry": "./integration", "exports": [ + "BorrowedConnectorAuth (type)", + "BorrowedConnectorCredential (interface)", "CONNECTOR_CREDENTIAL_REJECTED (const)", "CONNECTOR_UPSTREAM_UNAVAILABLE (const)", "Connector (type)", @@ -10,11 +12,17 @@ "ConnectorActionEffect (type)", "ConnectorActionEffectSchema (const)", "ConnectorActionSchema (const)", + "ConnectorAppCredentialVendor (type)", + "ConnectorAppCredentialVendorSchema (const)", + "ConnectorCredentialHandle (interface)", "ConnectorCredentialRejectedError (class)", + "ConnectorCredentialRequest (interface)", "ConnectorDescriptor (interface)", "ConnectorFetchPolicy (interface)", "ConnectorInstanceAPIKeyAuth (type)", "ConnectorInstanceAPIKeyAuthSchema (const)", + "ConnectorInstanceAppCredentialAuth (type)", + "ConnectorInstanceAppCredentialAuthSchema (const)", "ConnectorInstanceAuth (type)", "ConnectorInstanceAuthSchema (const)", "ConnectorInstanceBasicAuth (type)", @@ -23,8 +31,16 @@ "ConnectorInstanceBearerAuthSchema (const)", "ConnectorInstanceNoAuth (type)", "ConnectorInstanceNoAuthSchema (const)", + "ConnectorInstanceOAuth2Auth (type)", + "ConnectorInstanceOAuth2AuthSchema (const)", + "ConnectorInstanceOAuth2AuthorizationCodeAuth (type)", + "ConnectorInstanceOAuth2AuthorizationCodeAuthSchema (const)", + "ConnectorInstanceOAuth2ClientCredentialsAuth (type)", + "ConnectorInstanceOAuth2ClientCredentialsAuthSchema (const)", "ConnectorMaterialization (interface)", "ConnectorMaterializationHandler (type)", + "ConnectorOAuth2Grant (type)", + "ConnectorOAuth2GrantSchema (const)", "ConnectorOrigin (type)", "ConnectorParsed (type)", "ConnectorProviderContext (interface)", diff --git a/packages/spec/declaration-map/integration.json b/packages/spec/declaration-map/integration.json index e4fd24dd344..b760f20ca37 100644 --- a/packages/spec/declaration-map/integration.json +++ b/packages/spec/declaration-map/integration.json @@ -7,8 +7,12 @@ "ConnectorActionEffect": "integration/ConnectorActionEffect", "ConnectorActionEffectSchema": "integration/ConnectorActionEffect", "ConnectorActionSchema": "integration/ConnectorAction", + "ConnectorAppCredentialVendor": "integration/ConnectorAppCredentialVendor", + "ConnectorAppCredentialVendorSchema": "integration/ConnectorAppCredentialVendor", "ConnectorInstanceAPIKeyAuth": "integration/ConnectorInstanceAPIKeyAuth", "ConnectorInstanceAPIKeyAuthSchema": "integration/ConnectorInstanceAPIKeyAuth", + "ConnectorInstanceAppCredentialAuth": "integration/ConnectorInstanceAppCredentialAuth", + "ConnectorInstanceAppCredentialAuthSchema": "integration/ConnectorInstanceAppCredentialAuth", "ConnectorInstanceAuth": "integration/ConnectorInstanceAuth", "ConnectorInstanceAuthSchema": "integration/ConnectorInstanceAuth", "ConnectorInstanceBasicAuth": "integration/ConnectorInstanceBasicAuth", @@ -17,6 +21,14 @@ "ConnectorInstanceBearerAuthSchema": "integration/ConnectorInstanceBearerAuth", "ConnectorInstanceNoAuth": "integration/ConnectorInstanceNoAuth", "ConnectorInstanceNoAuthSchema": "integration/ConnectorInstanceNoAuth", + "ConnectorInstanceOAuth2Auth": "integration/ConnectorInstanceOAuth2Auth", + "ConnectorInstanceOAuth2AuthSchema": "integration/ConnectorInstanceOAuth2Auth", + "ConnectorInstanceOAuth2AuthorizationCodeAuth": "integration/ConnectorInstanceOAuth2AuthorizationCodeAuth", + "ConnectorInstanceOAuth2AuthorizationCodeAuthSchema": "integration/ConnectorInstanceOAuth2AuthorizationCodeAuth", + "ConnectorInstanceOAuth2ClientCredentialsAuth": "integration/ConnectorInstanceOAuth2ClientCredentialsAuth", + "ConnectorInstanceOAuth2ClientCredentialsAuthSchema": "integration/ConnectorInstanceOAuth2ClientCredentialsAuth", + "ConnectorOAuth2Grant": "integration/ConnectorOAuth2Grant", + "ConnectorOAuth2GrantSchema": "integration/ConnectorOAuth2Grant", "ConnectorRetryStrategy": "integration/ConnectorRetryStrategy", "ConnectorRetryStrategySchema": "integration/ConnectorRetryStrategy", "ConnectorSchema": "integration/Connector", diff --git a/packages/spec/docs/SYNC_ARCHITECTURE.md b/packages/spec/docs/SYNC_ARCHITECTURE.md index d4df7399b77..1b6339a4bc1 100644 --- a/packages/spec/docs/SYNC_ARCHITECTURE.md +++ b/packages/spec/docs/SYNC_ARCHITECTURE.md @@ -284,14 +284,13 @@ const sapConnector: Connector = { type: 'saas', description: 'Enterprise-grade SAP ERP integration', - // OAuth2 Authentication + // Runtime authentication, supplied by host code: one of the four static + // kinds. (`type: 'oauth2'` sat here until ADR-0143 D7 retired it — it + // inlined a client secret and nothing ran its lifecycle. OAuth2 is declared + // on a provider-bound instance's `auth`, the secret as a `credentialRef`.) authentication: { - type: 'oauth2', - authorizationUrl: 'https://sap.example.com/oauth/authorize', - tokenUrl: 'https://sap.example.com/oauth/token', - clientId: process.env.SAP_CLIENT_ID!, - clientSecret: process.env.SAP_CLIENT_SECRET!, - scopes: ['read:orders', 'write:orders'] + type: 'bearer', + token: process.env.SAP_API_TOKEN! }, // (`syncConfig` and `fieldMappings` sat here until ADR-0049 retired them — @@ -335,16 +334,21 @@ const sapConnector: Connector = { ### Authentication Methods +The runtime `authentication` takes the four static kinds: + | Method | Type | Use Case | |--------|------|----------| -| `oauth2` | OAuth 2.0 | Modern SaaS applications (Salesforce, Google) | -| `jwt` | JSON Web Token | Microservices, API gateways | -| `saml` | SAML 2.0 | Enterprise SSO (Okta, Azure AD) | | `api-key` | API Key | Simple API authentication | | `basic` | Basic Auth | Legacy systems, simple authentication | | `bearer` | Bearer Token | Token-based APIs | | `none` | No Auth | Public APIs | +A token obtained from a token endpoint (OAuth 2.0 `authorization_code` or +`client_credentials`, or a Feishu, DingTalk or WeCom app credential) is declared +on a provider-bound instance's `auth` as `oauth2` or `app-credential`, with the +secret behind a `credentialRef` (ADR-0143). See +[Connectors](../../../content/docs/automation/connectors.mdx). + ### Best Practices - **Security First**: Always use encrypted credentials and secure storage diff --git a/packages/spec/export-origins/integration.json b/packages/spec/export-origins/integration.json index 48948f79eee..ccfaf4cf01b 100644 --- a/packages/spec/export-origins/integration.json +++ b/packages/spec/export-origins/integration.json @@ -2,6 +2,8 @@ "description": "Which SOURCE DECLARATION each name exported by one public entry point of @objectstack/spec resolves to, after its alias chain is unwound: `# ()`. Two exports share an origin string iff they are the same declaration — so equal origins across two entries are a harmless re-export, and different origins under one name are the #4411 dual-source trap. Generated from src/ (no build needed) and read by the export-surface pin tests, which compare against it instead of each building their own ts.createProgram — that was ~55s of compilation per CI lap and a non-deterministic timeout that ejected unrelated PRs from the merge queue (#4796). Sharded by entry point (#5837) so two retirement PRs never share a file. Carries NO line numbers: the pins asserted the line as `\\d+`, and recording it would rewrite this artifact on every edit that shifts a line in any .zod.ts. Regenerate with `pnpm --filter @objectstack/spec gen:export-origins` and read the diff.", "entry": "./integration", "exports": { + "BorrowedConnectorAuth": "src/integration/connector-provider.ts#BorrowedConnectorAuth (type)", + "BorrowedConnectorCredential": "src/integration/connector-provider.ts#BorrowedConnectorCredential (interface)", "CONNECTOR_CREDENTIAL_REJECTED": "src/integration/connector-provider-errors.ts#CONNECTOR_CREDENTIAL_REJECTED (const)", "CONNECTOR_UPSTREAM_UNAVAILABLE": "src/integration/connector-provider-errors.ts#CONNECTOR_UPSTREAM_UNAVAILABLE (const)", "Connector": "src/integration/connector.zod.ts#Connector (type)", @@ -10,11 +12,17 @@ "ConnectorActionEffect": "src/integration/connector.zod.ts#ConnectorActionEffect (type)", "ConnectorActionEffectSchema": "src/integration/connector.zod.ts#ConnectorActionEffectSchema (const)", "ConnectorActionSchema": "src/integration/connector.zod.ts#ConnectorActionSchema (const)", + "ConnectorAppCredentialVendor": "src/shared/connector-auth.zod.ts#ConnectorAppCredentialVendor (type)", + "ConnectorAppCredentialVendorSchema": "src/shared/connector-auth.zod.ts#ConnectorAppCredentialVendorSchema (const)", + "ConnectorCredentialHandle": "src/integration/connector-provider.ts#ConnectorCredentialHandle (interface)", "ConnectorCredentialRejectedError": "src/integration/connector-provider-errors.ts#ConnectorCredentialRejectedError (class)", + "ConnectorCredentialRequest": "src/integration/connector-provider.ts#ConnectorCredentialRequest (interface)", "ConnectorDescriptor": "src/integration/connector-descriptor.ts#ConnectorDescriptor (interface)", "ConnectorFetchPolicy": "src/integration/connector-fetch-policy.ts#ConnectorFetchPolicy (interface)", "ConnectorInstanceAPIKeyAuth": "src/shared/connector-auth.zod.ts#ConnectorInstanceAPIKeyAuth (type)", "ConnectorInstanceAPIKeyAuthSchema": "src/shared/connector-auth.zod.ts#ConnectorInstanceAPIKeyAuthSchema (const)", + "ConnectorInstanceAppCredentialAuth": "src/shared/connector-auth.zod.ts#ConnectorInstanceAppCredentialAuth (type)", + "ConnectorInstanceAppCredentialAuthSchema": "src/shared/connector-auth.zod.ts#ConnectorInstanceAppCredentialAuthSchema (const)", "ConnectorInstanceAuth": "src/shared/connector-auth.zod.ts#ConnectorInstanceAuth (type)", "ConnectorInstanceAuthSchema": "src/shared/connector-auth.zod.ts#ConnectorInstanceAuthSchema (const)", "ConnectorInstanceBasicAuth": "src/shared/connector-auth.zod.ts#ConnectorInstanceBasicAuth (type)", @@ -23,8 +31,16 @@ "ConnectorInstanceBearerAuthSchema": "src/shared/connector-auth.zod.ts#ConnectorInstanceBearerAuthSchema (const)", "ConnectorInstanceNoAuth": "src/shared/connector-auth.zod.ts#ConnectorInstanceNoAuth (type)", "ConnectorInstanceNoAuthSchema": "src/shared/connector-auth.zod.ts#ConnectorInstanceNoAuthSchema (const)", + "ConnectorInstanceOAuth2Auth": "src/shared/connector-auth.zod.ts#ConnectorInstanceOAuth2Auth (type)", + "ConnectorInstanceOAuth2AuthSchema": "src/shared/connector-auth.zod.ts#ConnectorInstanceOAuth2AuthSchema (const)", + "ConnectorInstanceOAuth2AuthorizationCodeAuth": "src/shared/connector-auth.zod.ts#ConnectorInstanceOAuth2AuthorizationCodeAuth (type)", + "ConnectorInstanceOAuth2AuthorizationCodeAuthSchema": "src/shared/connector-auth.zod.ts#ConnectorInstanceOAuth2AuthorizationCodeAuthSchema (const)", + "ConnectorInstanceOAuth2ClientCredentialsAuth": "src/shared/connector-auth.zod.ts#ConnectorInstanceOAuth2ClientCredentialsAuth (type)", + "ConnectorInstanceOAuth2ClientCredentialsAuthSchema": "src/shared/connector-auth.zod.ts#ConnectorInstanceOAuth2ClientCredentialsAuthSchema (const)", "ConnectorMaterialization": "src/integration/connector-provider.ts#ConnectorMaterialization (interface)", "ConnectorMaterializationHandler": "src/integration/connector-provider.ts#ConnectorMaterializationHandler (type)", + "ConnectorOAuth2Grant": "src/shared/connector-auth.zod.ts#ConnectorOAuth2Grant (type)", + "ConnectorOAuth2GrantSchema": "src/shared/connector-auth.zod.ts#ConnectorOAuth2GrantSchema (const)", "ConnectorOrigin": "src/integration/connector-descriptor.ts#ConnectorOrigin (type)", "ConnectorParsed": "src/integration/connector.zod.ts#ConnectorParsed (type)", "ConnectorProviderContext": "src/integration/connector-provider.ts#ConnectorProviderContext (interface)", diff --git a/packages/spec/src/migrations/entries/semantic/18.connector-auth-config-oauth2-retired.ts b/packages/spec/src/migrations/entries/semantic/18.connector-auth-config-oauth2-retired.ts index 96e0f0f89de..25037ccfdac 100644 --- a/packages/spec/src/migrations/entries/semantic/18.connector-auth-config-oauth2-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.connector-auth-config-oauth2-retired.ts @@ -17,7 +17,7 @@ export const entry: SemanticMigration = { id: 'connector-auth-config-oauth2-retired', surface: "connector.authentication (`type: 'oauth2'`, with its inline `clientSecret`, `refreshToken`, " + '`tokenExpiry` and `redirectUri`) — the runtime auth of a connector definition, as `defineConnector` ' - + 'and registerConnector parse it; the exported ConnectorOAuth2Schema leaves with it', + + 'and registerConnector parse it; the module-level ConnectorOAuth2Schema, never on a published entry point, leaves with it', replacement: "A provider-bound connector instance (`provider: 'openapi' | 'rest' | 'mcp'`) declaring " + "`auth: { type: 'oauth2', grant: 'authorization_code' | 'client_credentials', tokenUrl, clientId, " + 'credentialRef, ... }`, where `credentialRef` names the client secret in the secrets layer and the ' From 4609d0cb61caca1a5c731b1a8b0e189462589e1e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 15:43:06 +0000 Subject: [PATCH 6/9] chore(spec): pin the new connector-auth aliases isomorphic (ADR-0122) [wip] Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibvmENgz1tZkajp5SuJ --- packages/spec/src/shared/connector-auth.zod.ts | 4 ++-- packages/spec/src/type-alias-convention.pin.test.ts | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/spec/src/shared/connector-auth.zod.ts b/packages/spec/src/shared/connector-auth.zod.ts index f486566242e..38bf57727c7 100644 --- a/packages/spec/src/shared/connector-auth.zod.ts +++ b/packages/spec/src/shared/connector-auth.zod.ts @@ -219,7 +219,7 @@ function runtimeTokenGuidance(kind: string): string { */ export const ConnectorOAuth2GrantSchema = z.enum(['authorization_code', 'client_credentials']) .describe('OAuth2 grant (RFC 6749): `authorization_code` (an administrator connects once; PKCE S256 always) or `client_credentials` (no user step).'); -export type ConnectorOAuth2Grant = z.infer; +export type ConnectorOAuth2Grant = z.input; /** The `oauth2` instance keys both grants share (ADR-0143 D2.1). */ function oauth2SharedShape() { @@ -342,7 +342,7 @@ export type ConnectorInstanceOAuth2Auth = z.input; +export type ConnectorAppCredentialVendor = z.input; /** * Vendor app-credential auth for a provider-bound instance (ADR-0143 D2.2): diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index 10de7763a9a..85520405c1d 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -1160,11 +1160,17 @@ export type Iso_security_sharing__SharingLevel = Assert, z.infer< typeof M180.SharingRuleType > >>; // shared/connector-auth.zod.ts +export type Iso_shared_connectorAuth__ConnectorAppCredentialVendorSchema = Assert, z.infer< typeof M109.ConnectorAppCredentialVendorSchema > >>; export type Iso_shared_connectorAuth__ConnectorInstanceAPIKeyAuthSchema = Assert, z.infer< typeof M109.ConnectorInstanceAPIKeyAuthSchema > >>; +export type Iso_shared_connectorAuth__ConnectorInstanceAppCredentialAuthSchema = Assert, z.infer< typeof M109.ConnectorInstanceAppCredentialAuthSchema > >>; export type Iso_shared_connectorAuth__ConnectorInstanceAuthSchema = Assert, z.infer< typeof M109.ConnectorInstanceAuthSchema > >>; export type Iso_shared_connectorAuth__ConnectorInstanceBasicAuthSchema = Assert, z.infer< typeof M109.ConnectorInstanceBasicAuthSchema > >>; export type Iso_shared_connectorAuth__ConnectorInstanceBearerAuthSchema = Assert, z.infer< typeof M109.ConnectorInstanceBearerAuthSchema > >>; export type Iso_shared_connectorAuth__ConnectorInstanceNoAuthSchema = Assert, z.infer< typeof M109.ConnectorInstanceNoAuthSchema > >>; +export type Iso_shared_connectorAuth__ConnectorInstanceOAuth2AuthSchema = Assert, z.infer< typeof M109.ConnectorInstanceOAuth2AuthSchema > >>; +export type Iso_shared_connectorAuth__ConnectorInstanceOAuth2AuthorizationCodeAuthSchema = Assert, z.infer< typeof M109.ConnectorInstanceOAuth2AuthorizationCodeAuthSchema > >>; +export type Iso_shared_connectorAuth__ConnectorInstanceOAuth2ClientCredentialsAuthSchema = Assert, z.infer< typeof M109.ConnectorInstanceOAuth2ClientCredentialsAuthSchema > >>; +export type Iso_shared_connectorAuth__ConnectorOAuth2GrantSchema = Assert, z.infer< typeof M109.ConnectorOAuth2GrantSchema > >>; // shared/duration.zod.ts — the closed DURATION vocabulary (#18122), step ① of // ruling A on #18115 and the counterpart of the `shared/epoch` instant. Both are From 6c4a33fb69a7deb2c032a6822762b551d3cffed4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 16:19:55 +0000 Subject: [PATCH 7/9] test(spec): the isomorphic pin count states the six new connector-auth pins (ADR-0122) [wip] Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibvmENgz1tZkajp5SuJ --- .../spec/src/type-alias-convention.pin.test.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index 85520405c1d..3a367c502af 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -285,7 +285,7 @@ import type * as M194 from './automation/assignment-rule.zod.js'; import type * as M195 from './security/queue.zod.js'; // --------------------------------------------------------------------------- -// 795 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. +// 801 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. // // That number is machine-checked, not hand-kept. The runtime companion at the // bottom of this file recomputes the pin count from the source and asserts that @@ -1735,7 +1735,7 @@ describe('ADR-0122 type-alias convention', () => { // this title and the section header above the pin list — are now asserted // against the recomputed count below, so neither can go stale without a red // test naming it. - it('still declares all 795 isomorphic pins', () => { + it('still declares all 801 isomorphic pins', () => { // The truth of each pin is proved by tsc, not here — an `Assert>` // that stops holding is a compile error with the alias named. What tsc // cannot notice is a pin that was DELETED: removing the assertion removes @@ -2675,7 +2675,16 @@ describe('ADR-0122 type-alias convention', () => { // here (Iso_data_dataEngine__DataEngineExecuteRequestSchema, // Iso_data_dataEngine__DataEngineInsertRequestSchema, // Iso_data_dataEngine__DataEngineVectorFindRequestSchema). -3 removed. - expect(pins).toHaveLength(795); + // 795 -> 801 is #28137 (ADR-0143 S0): the grant-bearing connector instance + // auth kinds (`shared/connector-auth.zod.ts`) — the `oauth2` union and its + // two per-grant shapes, the `app-credential` shape, and the `grant` and + // `vendor` enums — carry no default and no transform, so they are pinned + // here (Iso_shared_connectorAuth__ConnectorInstanceOAuth2AuthSchema, + // …OAuth2AuthorizationCodeAuthSchema, …OAuth2ClientCredentialsAuthSchema, + // …AppCredentialAuthSchema, …ConnectorOAuth2GrantSchema, + // …ConnectorAppCredentialVendorSchema) rather than given `…Parsed` + // synonyms. +6 added. + expect(pins).toHaveLength(801); // The count is stated in PROSE twice as well — this case's title and the // section header above the pin list — and until commit c6b05c76a nothing read either From 2ee1b72333a45afacf8d758b9b72a822c5371641 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 17:05:47 +0000 Subject: [PATCH 8/9] docs(adr): ADR-0143 anchors the retired ConnectorOAuth2Schema at its ADR-0087 record Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibvmENgz1tZkajp5SuJ --- docs/adr/0143-outbound-connector-auth-is-open-tier.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/adr/0143-outbound-connector-auth-is-open-tier.md b/docs/adr/0143-outbound-connector-auth-is-open-tier.md index 615208b908e..618ffcf3795 100644 --- a/docs/adr/0143-outbound-connector-auth-is-open-tier.md +++ b/docs/adr/0143-outbound-connector-auth-is-open-tier.md @@ -85,7 +85,7 @@ The card's re-check `git grep -n "intentionally absent"` on that file answers no the sentence is still there: it wraps, "OAuth2 is intentionally" ending line 113 and "absent (enterprise tier, ADR-0015)." opening line 114. The file holds two shapes, for two doors: -- **`ConnectorOAuth2Schema`** (`packages/spec/src/shared/connector-auth.zod.ts#ConnectorOAuth2Schema`) is a +- **`ConnectorOAuth2Schema`** (retired by D7; its record is `packages/spec/src/migrations/entries/semantic/18.connector-auth-config-oauth2-retired.ts`) is a variant of the **runtime** union `ConnectorAuthConfigSchema` (`packages/spec/src/shared/connector-auth.zod.ts#ConnectorAuthConfigSchema`): what a plugin hands to `registerConnector`, with `clientSecret` and `refreshToken` **inline**. It may never be authored — `DeclarativeConnectorEntrySchema` refuses any non-`none` `authentication` on every published entry @@ -234,7 +234,7 @@ and must not be revoked or replaced by an unrelated sign-in. Whatever the storag ### D7 — The unused runtime `oauth2` shape is retired -`ConnectorOAuth2Schema` (`packages/spec/src/shared/connector-auth.zod.ts#ConnectorOAuth2Schema`) inlines a client secret and a refresh token +`ConnectorOAuth2Schema` (retired by D7; its record is `packages/spec/src/migrations/entries/semantic/18.connector-auth-config-oauth2-retired.ts`) inlines a client secret and a refresh token and has no consumer. The spec slice that adds D2's `oauth2` variant **retires** it through ADR-0087. It is not redefined as a resolved shape. Under D4 a provider receives a credential source answered per dispatch, an interface rather than a schema, and that source is separate from `CredentialResolver`, which From c9b16a83881491e0566788582025c012aefad06a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 17:11:06 +0000 Subject: [PATCH 9/9] fix(spec): the D7 prescription names ConnectorOAuth2Schema; ADR-0143 restored to main Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibvmENgz1tZkajp5SuJ --- docs/adr/0143-outbound-connector-auth-is-open-tier.md | 4 ++-- packages/spec/src/shared/connector-auth.zod.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/adr/0143-outbound-connector-auth-is-open-tier.md b/docs/adr/0143-outbound-connector-auth-is-open-tier.md index 618ffcf3795..615208b908e 100644 --- a/docs/adr/0143-outbound-connector-auth-is-open-tier.md +++ b/docs/adr/0143-outbound-connector-auth-is-open-tier.md @@ -85,7 +85,7 @@ The card's re-check `git grep -n "intentionally absent"` on that file answers no the sentence is still there: it wraps, "OAuth2 is intentionally" ending line 113 and "absent (enterprise tier, ADR-0015)." opening line 114. The file holds two shapes, for two doors: -- **`ConnectorOAuth2Schema`** (retired by D7; its record is `packages/spec/src/migrations/entries/semantic/18.connector-auth-config-oauth2-retired.ts`) is a +- **`ConnectorOAuth2Schema`** (`packages/spec/src/shared/connector-auth.zod.ts#ConnectorOAuth2Schema`) is a variant of the **runtime** union `ConnectorAuthConfigSchema` (`packages/spec/src/shared/connector-auth.zod.ts#ConnectorAuthConfigSchema`): what a plugin hands to `registerConnector`, with `clientSecret` and `refreshToken` **inline**. It may never be authored — `DeclarativeConnectorEntrySchema` refuses any non-`none` `authentication` on every published entry @@ -234,7 +234,7 @@ and must not be revoked or replaced by an unrelated sign-in. Whatever the storag ### D7 — The unused runtime `oauth2` shape is retired -`ConnectorOAuth2Schema` (retired by D7; its record is `packages/spec/src/migrations/entries/semantic/18.connector-auth-config-oauth2-retired.ts`) inlines a client secret and a refresh token +`ConnectorOAuth2Schema` (`packages/spec/src/shared/connector-auth.zod.ts#ConnectorOAuth2Schema`) inlines a client secret and a refresh token and has no consumer. The spec slice that adds D2's `oauth2` variant **retires** it through ADR-0087. It is not redefined as a resolved shape. Under D4 a provider receives a credential source answered per dispatch, an interface rather than a schema, and that source is separate from `CredentialResolver`, which diff --git a/packages/spec/src/shared/connector-auth.zod.ts b/packages/spec/src/shared/connector-auth.zod.ts index 38bf57727c7..633172658d0 100644 --- a/packages/spec/src/shared/connector-auth.zod.ts +++ b/packages/spec/src/shared/connector-auth.zod.ts @@ -48,8 +48,8 @@ import { dependentRequired } from './refinement-projection'; * surface and needs a secret moved out of the source. */ const CONNECTOR_AUTH_CONFIG_OAUTH2_RETIRED = - "`authentication: { type: 'oauth2' }` was removed in @objectstack/spec 18 (ADR-0143 D7) — the runtime " - + 'variant inlined a client secret and a refresh token and no provider ever read it, so a connector that ' + "`ConnectorOAuth2Schema` (`authentication: { type: 'oauth2' }`) was removed in @objectstack/spec 18 " + + '(ADR-0143 D7) — the runtime variant inlined a client secret and a refresh token and no provider ever read it, so a connector that ' + 'declared it was dispatched without the credential. Declare the connector as a provider-bound instance ' + "(`provider: 'openapi' | 'rest' | 'mcp'`) with `auth: { type: 'oauth2', grant, tokenUrl, clientId, " + "credentialRef, ... }`, where `credentialRef` names the client secret in the secrets layer, and set "