diff --git a/.changeset/harden-connector-credentials.md b/.changeset/harden-connector-credentials.md new file mode 100644 index 00000000000..1a6aaf47c27 --- /dev/null +++ b/.changeset/harden-connector-credentials.md @@ -0,0 +1,29 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec)!: the connector write door refuses an inline secret on every instance auth kind; the metadata read redacts connector credential positions + +Clause-②: no (narrowing) +Metadata-protocol: narrowed + + + +**BREAKING** accept-set narrowing on a provider-bound connector instance's `auth`, at every authoring door that parses `DeclarativeConnectorEntrySchema`: `defineStack({ connectors })`, `os validate` / `os compile`, and the metadata save door `PUT /api/v1/meta/connector/:name`. + +- **Before.** The four static instance-auth kinds (`none`, `bearer`, `api-key`, `basic`) were plain objects: an undeclared key was dropped at parse and the entry still validated. The three ADR-0143 kinds (`oauth2`, `app-credential`, `signed`) already refused one. +- **Now.** All seven kinds refuse an undeclared key with a located error at `auth` (`unrecognized_keys`; at the save door `422 INVALID_METADATA`, the envelope an inline `authentication` already met there). An inline secret — `token` on `bearer`, `key` on `api-key`, `password` on `basic`, and their usual spellings — meets the `credentialRef` prescription; `none` names no credential and says so. The refusal ADR-0143 D3 records for every variant now holds for the static kinds too. +- **Read half (additive).** `connector` joins `datasource` as a built-in of the metadata-type redactor registry (`getMetadataTypeRedactor('connector')`), so every read exit that serves a stored body through that registry — the `/meta` item, list and layered reads and the metadata protocol's reads among them — withholds a stored connector's credential positions: every `auth` key its kind does not declare, every key of a runtime `authentication` block but `type`, and the provider header and environment maps (`providerConfig.headers`, `providerConfig.defaultHeaders`, `providerConfig.transport.headers`, `providerConfig.transport.env`), each map whole. A save of the served form keeps the stored values (the metadata protocol's carry-forward), and the runtime keeps reading the unredacted execution view, so a materialized connector still sends its headers. +- **Unchanged.** A static kind resolves its secret from `credentialRef` alone at materialization, as before; nothing ever read an undeclared key, so no connector's behaviour moves. + +## FROM → TO + +```ts +// before — parsed green; the inline value was never used +auth: { type: 'bearer', credentialRef: 'BILLING_API_TOKEN', token: '…' }, + +// after — refused at `auth` with the credentialRef prescription; keep the reference only +auth: { type: 'bearer', credentialRef: 'BILLING_API_TOKEN' }, +``` + +Set the secret under the name `credentialRef` gives — an environment variable in the open tier, or Setup → Connector credentials — and delete the inline key. There is deliberately no automatic rewrite: the inline value may be the only copy of the secret its author holds. The upgrade guide entry is `connector-instance-auth-inline-secret-refused`. diff --git a/packages/qa/dogfood/test/connector-meta-door-credential-hardening.dogfood.test.ts b/packages/qa/dogfood/test/connector-meta-door-credential-hardening.dogfood.test.ts new file mode 100644 index 00000000000..538826d4bf4 --- /dev/null +++ b/packages/qa/dogfood/test/connector-meta-door-credential-hardening.dogfood.test.ts @@ -0,0 +1,266 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Connector credentials at the metadata doors, booted on the showcase with its +// own connector plugins: +// +// - WRITE: the connector save door refuses an inline secret on an instance's +// `auth` — every static kind (`bearer`, `api-key`, `basic`, `none`) — with +// the same `422 INVALID_METADATA` envelope the publish door gives an inline +// `authentication`, located at `auth`, and stores nothing. +// - CONTROL: an instance that names its secret with `credentialRef` saves, +// materializes, and dispatches upstream with the reference resolved and its +// default headers sent — including after a save of its served form. +// - READ: a stored row holding inline credential positions (seeded through +// the system context, the shape a row stored before the write refusal can +// have) is served with those positions withheld, to a member and to an +// administrator, by name, in the list and in the layered view, and through +// the metadata protocol's reads; every other field is served as stored, and +// the stored row itself is never redacted. +// +// Every credential below is an obviously-fake placeholder, and every assertion +// on one is its absence from what a door served. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { createServer, type IncomingHttpHeaders, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; + +const SYSTEM = { isSystem: true, positions: [], permissions: [] }; + +const PLACEHOLDERS = { + inlineToken: 'TEST_PLACEHOLDER_INLINE_TOKEN', + inlineKey: 'TEST_PLACEHOLDER_INLINE_KEY', + inlinePassword: 'TEST_PLACEHOLDER_INLINE_PASSWORD', + headerValue: 'TEST_PLACEHOLDER_HEADER_VALUE', + defaultHeaderValue: 'TEST_PLACEHOLDER_DEFAULT_HEADER_VALUE', + runtimeToken: 'TEST_PLACEHOLDER_RUNTIME_TOKEN', + resolvedRef: 'TEST_PLACEHOLDER_RESOLVED_REFERENCE', +} as const; +const ALL_PLACEHOLDERS = Object.values(PLACEHOLDERS); + +/** The reference the CONTROL instance names; set in this process's environment before it is saved. */ +const CONTROL_REF = 'CCH_CONTROL_CREDENTIAL_REF'; +const CONTROL = 'cch_control'; +const STORED = 'cch_stored'; +/** One name per refused kind, so each case's "nothing is stored" reads only its own write. */ +const refusedName = (kind: string) => `cch_refused_${kind.replace(/-/g, '_')}`; + +const rowsOf = (r: any): any[] => (Array.isArray(r) ? r : Array.isArray(r?.records) ? r.records : Array.isArray(r?.value) ? r.value : []); +const bodyOf = (row: any): Record => + typeof row?.metadata === 'string' ? JSON.parse(row.metadata) : (row?.metadata ?? {}); +const itemOf = (json: any): Record => (json?.item ?? json?.data?.item ?? json?.data ?? json) as Record; +const holdsAPlaceholder = (v: unknown): boolean => { + const text = typeof v === 'string' ? v : JSON.stringify(v ?? null); + return ALL_PLACEHOLDERS.some((p) => text.includes(p)); +}; + +describe('connector metadata doors: inline credentials are refused on write and withheld on read', () => { + let stack: VerifyStack; + let admin: string; + let member: string; + let ql: any; + let protocol: any; + let automation: any; + let upstream: Server; + let upstreamUrl: string; + const received: IncomingHttpHeaders[] = []; + let previousRef: string | undefined; + + const stored = async (name: string) => + rowsOf(await ql.find('sys_metadata', { where: { type: 'connector', name }, context: SYSTEM })); + + const instance = (name: string, over: Record = {}) => ({ + name, + label: `CCH ${name}`, + type: 'api', + description: 'A provider-bound instance for the connector credential pins.', + provider: 'rest', + providerConfig: { baseUrl: upstreamUrl }, + auth: { type: 'bearer', credentialRef: CONTROL_REF }, + ...over, + }); + + /** One door read: 200, names the connector (positive control), and serves no placeholder. */ + const expectWithheld = async (token: string, path: string, names: readonly string[]) => { + const res = await stack.apiAs(token, 'GET', path); + const text = await res.text(); + expect(res.status, `${path}: ${text.slice(0, 200)}`).toBe(200); + for (const name of names) expect(text.includes(name), `${path} names ${name}`).toBe(true); + expect(holdsAPlaceholder(text), `${path} withholds every credential position`).toBe(false); + return text; + }; + + const dispatch = async (name: string): Promise => { + const handler = automation.resolveConnectorAction(name, 'request'); + expect(handler, `${name} is materialized with its request action`).toBeTypeOf('function'); + const before = received.length; + const out = await handler({ method: 'GET', path: '/probe' }, {}); + expect(out?.status, JSON.stringify(out)).toBe(200); + expect(received.length).toBe(before + 1); + return received[received.length - 1]!; + }; + + const waitReady = async (name: string): Promise => { + const deadline = Date.now() + 15_000; + let last = ''; + while (Date.now() < deadline) { + const res = await stack.apiAs(admin, 'GET', '/automation/connectors'); + const json = (await res.json()) as any; + const found = (json?.data?.connectors ?? []).find((c: any) => c?.name === name); + last = JSON.stringify(found ?? null); + if (found?.state === 'ready') return; + await new Promise((r) => setTimeout(r, 100)); + } + throw new Error(`connector ${name} never reached ready: ${last}`); + }; + + beforeAll(async () => { + upstream = createServer((req, res) => { + received.push(req.headers); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('{"ok":true}'); + }); + await new Promise((r) => upstream.listen(0, '127.0.0.1', () => r())); + upstreamUrl = `http://127.0.0.1:${(upstream.address() as AddressInfo).port}`; + previousRef = process.env[CONTROL_REF]; + process.env[CONTROL_REF] = PLACEHOLDERS.resolvedRef; + + stack = await bootShowcase({}); + ql = await stack.kernel.getServiceAsync('objectql'); + protocol = await stack.kernel.getServiceAsync('protocol'); + automation = await stack.kernel.getServiceAsync('automation'); + admin = await stack.signIn(); + member = await stack.signUp('cch-member@connector-credentials.test'); + }, 180_000); + + afterAll(async () => { + await stack?.stop?.(); + await new Promise((r) => (upstream ? upstream.close(() => r()) : r())); + if (previousRef === undefined) delete process.env[CONTROL_REF]; + else process.env[CONTROL_REF] = previousRef; + }); + + describe('WRITE: the save door refuses an inline secret on an instance\'s auth', () => { + it.each([ + ['bearer', { type: 'bearer', credentialRef: CONTROL_REF, token: PLACEHOLDERS.inlineToken }, 'token'], + ['api-key', { type: 'api-key', credentialRef: CONTROL_REF, key: PLACEHOLDERS.inlineKey }, 'key'], + ['basic', { type: 'basic', username: 'svc', credentialRef: CONTROL_REF, password: PLACEHOLDERS.inlinePassword }, 'password'], + ['none', { type: 'none', token: PLACEHOLDERS.inlineToken }, 'token'], + ] as const)('%s: 422 INVALID_METADATA located at `auth`, naming the key, and nothing is stored', async (kind, auth, key) => { + const name = refusedName(kind); + const res = await stack.apiAs(admin, 'PUT', `/meta/connector/${name}`, instance(name, { auth })); + const json = (await res.json()) as any; + expect(res.status, JSON.stringify(json).slice(0, 400)).toBe(422); + expect(json.code, JSON.stringify(json).slice(0, 400)).toBe('INVALID_METADATA'); + const issues = (json.issues ?? json.error?.issues ?? json.details?.issues ?? []) as Array<{ path?: unknown; message?: string }>; + const located = issues.find((i) => i.path === 'auth' || (Array.isArray(i.path) && i.path.join('.') === 'auth')); + expect(located, JSON.stringify(issues).slice(0, 400)).toBeDefined(); + expect(located!.message).toContain(`\`${key}\``); + expect(holdsAPlaceholder(json), 'the refusal never echoes the value').toBe(false); + expect(await stored(name)).toHaveLength(0); + }); + }); + + describe('CONTROL: a credentialRef-only instance saves and works', () => { + it('saves, materializes ready, and dispatches with the reference resolved and its default headers sent', async () => { + const put = await stack.apiAs(admin, 'PUT', `/meta/connector/${CONTROL}`, instance(CONTROL, { + providerConfig: { baseUrl: upstreamUrl, defaultHeaders: { 'X-Placeholder-Header': PLACEHOLDERS.defaultHeaderValue } }, + })); + expect(put.status, await put.clone().text()).toBe(200); + await waitReady(CONTROL); + const headers = await dispatch(CONTROL); + expect(headers.authorization).toBe(`Bearer ${PLACEHOLDERS.resolvedRef}`); + expect(headers['x-placeholder-header']).toBe(PLACEHOLDERS.defaultHeaderValue); + }); + + it('its read withholds the header map; a save of the served form keeps it, and dispatch still sends it', async () => { + const read = await stack.apiAs(admin, 'GET', `/meta/connector/${CONTROL}`); + expect(read.status).toBe(200); + const served = itemOf(await read.json()); + expect(served.name).toBe(CONTROL); + expect(served.providerConfig).toEqual({ baseUrl: upstreamUrl }); + expect(served.auth).toEqual({ type: 'bearer', credentialRef: CONTROL_REF }); + + const body = Object.fromEntries(Object.entries(served).filter(([k]) => !k.startsWith('_'))); + const put = await stack.apiAs(admin, 'PUT', `/meta/connector/${CONTROL}`, { ...body, label: 'CCH control (edited)' }); + expect(put.status, await put.clone().text()).toBe(200); + const [row] = await stored(CONTROL); + expect(bodyOf(row).label).toBe('CCH control (edited)'); + expect(bodyOf(row).providerConfig.defaultHeaders).toEqual({ 'X-Placeholder-Header': PLACEHOLDERS.defaultHeaderValue }); + + await waitReady(CONTROL); + expect((await dispatch(CONTROL))['x-placeholder-header']).toBe(PLACEHOLDERS.defaultHeaderValue); + }); + }); + + describe('READ: a stored row\'s inline credential positions are withheld from every reader', () => { + const seeded = () => ({ + ...instance(STORED, { label: 'CCH stored (seeded)' }), + providerConfig: { + baseUrl: upstreamUrl, + headers: { Authorization: `Bearer ${PLACEHOLDERS.headerValue}` }, + defaultHeaders: { Authorization: `Bearer ${PLACEHOLDERS.defaultHeaderValue}` }, + }, + auth: { type: 'bearer', credentialRef: 'CCH_STORED_REF', token: PLACEHOLDERS.inlineToken }, + authentication: { type: 'bearer', token: PLACEHOLDERS.runtimeToken }, + }); + + beforeAll(async () => { + // A clean save makes the row; the system context then writes the body a + // row stored before the write refusal can hold. + const put = await stack.apiAs(admin, 'PUT', `/meta/connector/${STORED}`, instance(STORED, { + auth: { type: 'bearer', credentialRef: 'CCH_STORED_REF' }, + })); + expect(put.status, await put.clone().text()).toBe(200); + const [row] = await stored(STORED); + await ql.update('sys_metadata', { id: row.id, metadata: JSON.stringify(seeded()) }, { context: SYSTEM }); + }); + + it('guard the guard: the stored row holds every seeded credential position', async () => { + const [row] = await stored(STORED); + const body = bodyOf(row); + expect(body.label).toBe('CCH stored (seeded)'); + expect(body.auth.token).toBe(PLACEHOLDERS.inlineToken); + expect(body.authentication.token).toBe(PLACEHOLDERS.runtimeToken); + expect(body.providerConfig.headers.Authorization).toContain(PLACEHOLDERS.headerValue); + expect(body.providerConfig.defaultHeaders.Authorization).toContain(PLACEHOLDERS.defaultHeaderValue); + }); + + it('the item read and the layered view withhold every position, for a member and for an administrator', async () => { + for (const token of [member, admin]) { + const text = await expectWithheld(token, `/meta/connector/${STORED}`, [STORED, 'CCH stored (seeded)']); + const served = itemOf(JSON.parse(text)); + expect(served.auth).toEqual({ type: 'bearer', credentialRef: 'CCH_STORED_REF' }); + expect(served.authentication).toEqual({ type: 'bearer' }); + expect(served.providerConfig).toEqual({ baseUrl: upstreamUrl }); + expect(served.description).toBe('A provider-bound instance for the connector credential pins.'); + expect(served.provider).toBe('rest'); + expect(served.type).toBe('api'); + await expectWithheld(token, `/meta/connector/${STORED}/layers`, [STORED]); + } + }); + + it('the list, both spellings, withholds every position, for a member and for an administrator', async () => { + for (const token of [member, admin]) { + await expectWithheld(token, '/meta/connector', [STORED, CONTROL]); + await expectWithheld(token, '/meta/connectors', [STORED, CONTROL]); + } + }); + + it('the metadata protocol\'s list and item reads withhold every position', async () => { + const listed = await protocol.getMetaItems({ type: 'connector' }); + expect(JSON.stringify(listed).includes(STORED)).toBe(true); + expect(holdsAPlaceholder(listed)).toBe(false); + const item = await protocol.getMetaItem({ type: 'connector', name: STORED }); + expect(JSON.stringify(item).includes('CCH stored (seeded)')).toBe(true); + expect(holdsAPlaceholder(item)).toBe(false); + }); + + it('the stored row is never redacted: redaction is a serving act', async () => { + const [row] = await stored(STORED); + expect(bodyOf(row).auth.token).toBe(PLACEHOLDERS.inlineToken); + expect(bodyOf(row).providerConfig.defaultHeaders.Authorization).toContain(PLACEHOLDERS.defaultHeaderValue); + }); + }); +}); diff --git a/packages/spec/src/integration/connector-auth-static-kinds.test.ts b/packages/spec/src/integration/connector-auth-static-kinds.test.ts new file mode 100644 index 00000000000..8e349c9bbf8 --- /dev/null +++ b/packages/spec/src/integration/connector-auth-static-kinds.test.ts @@ -0,0 +1,93 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// ADR-0097 §3 / ADR-0143 D3 — the four static kinds of declarative connector +// instance auth (`none`, `bearer`, `api-key`, `basic`) refuse an undeclared +// key at the publish door (`DeclarativeConnectorEntrySchema`, which both +// authoring doors parse), and an inline secret meets the `credentialRef` +// prescription — the refusal the three ADR-0143 kinds already gave. Every +// refusal is pinned by its issue `code`, `path` and `keys`; every kind has a +// CONTROL accept, so a refusal that fired on everything could not pass. + +import { describe, it, expect } from 'vitest'; +import { DeclarativeConnectorEntrySchema } from './connector.zod'; +import { ObjectStackDefinitionSchema } from '../stack.zod'; + +/** An obviously-fake placeholder: the refusal must never echo it. */ +const PLACEHOLDER = 'TEST_PLACEHOLDER_NOT_A_SECRET'; + +const entry = (auth: unknown) => ({ + name: 'vendor', + label: 'Vendor', + type: 'api', + provider: 'rest', + providerConfig: { baseUrl: 'https://vendor.example.com' }, + auth, +}); + +const DECLARED = { + none: { type: 'none' }, + bearer: { type: 'bearer', credentialRef: 'VENDOR_TOKEN' }, + 'api-key': { type: 'api-key', credentialRef: 'VENDOR_KEY', headerName: 'X-Vendor-Key' }, + 'api-key (query)': { type: 'api-key', credentialRef: 'VENDOR_KEY', paramName: 'key' }, + basic: { type: 'basic', username: 'svc_objectstack', credentialRef: 'VENDOR_PASSWORD' }, +} as const; + +/** The one issue of a refused parse, as `[code, path, keys]`, and its message. */ +function refusal(input: unknown): { located: [string, string, unknown]; message: string } { + const result = DeclarativeConnectorEntrySchema.safeParse(input); + expect(result.success, 'the entry must be refused').toBe(false); + expect(result.error!.issues).toHaveLength(1); + const issue = result.error!.issues[0]!; + return { + located: [issue.code, issue.path.join('.'), (issue as { keys?: unknown }).keys], + message: issue.message, + }; +} + +describe('the four static instance-auth kinds refuse an undeclared key (ADR-0143 D3)', () => { + it.each(Object.entries(DECLARED))('CONTROL: %s with only its declared keys parses and comes back unchanged', (_kind, auth) => { + expect(DeclarativeConnectorEntrySchema.parse(entry(auth)).auth).toEqual(auth); + }); + + it.each([ + ['bearer', { ...DECLARED.bearer, token: PLACEHOLDER }, 'token'], + ['bearer', { ...DECLARED.bearer, accessToken: PLACEHOLDER }, 'accessToken'], + ['api-key', { ...DECLARED['api-key'], key: PLACEHOLDER }, 'key'], + ['api-key', { ...DECLARED['api-key'], apiKey: PLACEHOLDER }, 'apiKey'], + ['basic', { ...DECLARED.basic, password: PLACEHOLDER }, 'password'], + ] as const)('%s refuses an inline secret (`%s`) by name at `auth`, pointing at credentialRef', (kind, auth, key) => { + const { located, message } = refusal(entry(auth)); + expect(located).toEqual(['unrecognized_keys', 'auth', [key]]); + expect(message).toContain(`Not authorable on \`auth: { type: '${kind}' }\``); + expect(message).toContain('credentialRef'); + expect(message).not.toContain(PLACEHOLDER); + }); + + it('none refuses a credential, and sends the author to a kind that takes one', () => { + const { located, message } = refusal(entry({ type: 'none', token: PLACEHOLDER })); + expect(located).toEqual(['unrecognized_keys', 'auth', ['token']]); + expect(message).toContain('takes no credential'); + expect(message).not.toContain(PLACEHOLDER); + }); + + it('none refuses a credentialRef too: it names an upstream that takes no credential', () => { + expect(refusal(entry({ type: 'none', credentialRef: 'VENDOR_TOKEN' })).located) + .toEqual(['unrecognized_keys', 'auth', ['credentialRef']]); + }); + + it('a non-secret undeclared key is refused as well — the shape is closed, not a denylist', () => { + const { located, message } = refusal(entry({ ...DECLARED.bearer, headerName: 'X-Vendor-Key' })); + expect(located).toEqual(['unrecognized_keys', 'auth', ['headerName']]); + expect(message).toContain('Unrecognized key(s) on this `bearer` connector auth'); + }); + + it('the refusal runs on the authored stack collection as well (defineStack `connectors:`)', () => { + const result = ObjectStackDefinitionSchema.safeParse({ + manifest: { id: 'com.example.vendor', name: 'vendor', version: '1.0.0', type: 'app' as const }, + connectors: [entry({ ...DECLARED.bearer, token: PLACEHOLDER })], + }); + expect(result.success).toBe(false); + const paths = result.error!.issues.map((i) => `${i.code} @ ${i.path.join('.')}`); + expect(paths).toContain('unrecognized_keys @ connectors.0.auth'); + }); +}); diff --git a/packages/spec/src/integration/connector-credential-redaction.test.ts b/packages/spec/src/integration/connector-credential-redaction.test.ts new file mode 100644 index 00000000000..bf0fa302a3d --- /dev/null +++ b/packages/spec/src/integration/connector-credential-redaction.test.ts @@ -0,0 +1,189 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// The built-in `connector` read-path redactor +// (`connector-credential-redaction.ts`): which positions it withholds, what it +// leaves byte for byte, and the fail-closed census that keeps a secret from +// ever becoming a DECLARED instance-auth key unnoticed. +// +// Every value below is an obviously-fake placeholder; every assertion on one is +// its absence from the served item. + +import { describe, it, expect } from 'vitest'; +import { + CONNECTOR_CREDENTIAL_MAP_PATHS, + declaredConnectorAuthKeys, + redactConnectorItem, +} from './connector-credential-redaction'; + +const P = (n: number) => `TEST_PLACEHOLDER_${n}`; + +const base = { + name: 'vendor', + label: 'Vendor', + type: 'api', + description: 'A vendor API.', + provider: 'rest', + enabled: true, + _diagnostics: { valid: false, issues: [{ path: 'auth', message: 'Unrecognized key(s)' }] }, +}; + +const holds = (v: unknown, ...needles: string[]) => needles.some((n) => JSON.stringify(v).includes(n)); + +describe('the connector redactor — instance `auth`', () => { + it('withholds an undeclared key on a static kind and serves the declared ones as stored', () => { + const item = { ...base, auth: { type: 'bearer', credentialRef: 'VENDOR_TOKEN', token: P(1) } }; + const { item: served, redactedKeys } = redactConnectorItem(item); + expect(redactedKeys).toEqual(['auth.token']); + expect(served.auth).toEqual({ type: 'bearer', credentialRef: 'VENDOR_TOKEN' }); + expect(holds(served, P(1))).toBe(false); + }); + + it.each([ + ['api-key', { type: 'api-key', credentialRef: 'K', headerName: 'X-Key', key: P(2) }, ['auth.key']], + ['basic', { type: 'basic', username: 'svc', credentialRef: 'PW', password: P(3) }, ['auth.password']], + ['none', { type: 'none', token: P(4), key: P(5) }, ['auth.key', 'auth.token']], + ] as const)('%s: withholds exactly the undeclared keys', (_kind, auth, expected) => { + const { item: served, redactedKeys } = redactConnectorItem({ ...base, auth }); + expect(redactedKeys).toEqual(expected); + expect(holds(served, P(2), P(3), P(4), P(5))).toBe(false); + }); + + it('a grant kind keeps every key its grant declares and withholds the secret beside them', () => { + const auth = { + type: 'oauth2', grant: 'authorization_code', authorizationUrl: 'https://v.example/authorize', + tokenUrl: 'https://v.example/token', clientId: 'client-1', credentialRef: 'V_CLIENT_SECRET', + scopes: ['read'], clientSecret: P(6), refreshToken: P(7), + }; + const { item: served, redactedKeys } = redactConnectorItem({ ...base, auth }); + expect(redactedKeys).toEqual(['auth.clientSecret', 'auth.refreshToken']); + const { clientSecret: _s, refreshToken: _r, ...declared } = auth; + expect(served.auth).toEqual(declared); + }); + + it('a signed scheme keeps its own declared reference keys and withholds key material', () => { + const auth = { + type: 'signed', scheme: 'alipay-v3', keyId: 'app-1', credentialRef: 'ALIPAY_PRIVATE_KEY', + vendorPublicKeyRef: 'ALIPAY_PUBLIC_KEY', appPrivateKey: P(8), + }; + const { item: served, redactedKeys } = redactConnectorItem({ ...base, auth }); + expect(redactedKeys).toEqual(['auth.appPrivateKey']); + expect((served.auth as Record).vendorPublicKeyRef).toBe('ALIPAY_PUBLIC_KEY'); + }); + + it('a `type` that names no kind leaves `type` alone declared (fail closed)', () => { + const { item: served, redactedKeys } = redactConnectorItem({ ...base, auth: { type: 'digest', credentialRef: 'X', secret: P(9) } }); + expect(redactedKeys).toEqual(['auth.credentialRef', 'auth.secret']); + expect(served.auth).toEqual({ type: 'digest' }); + }); + + it('an unknown grant keeps the keys its kind\'s grants declare, never the secret', () => { + const keys = declaredConnectorAuthKeys({ type: 'oauth2', grant: 'password' }); + expect(keys.has('tokenUrl')).toBe(true); + expect(keys.has('authorizationUrl')).toBe(true); + expect(keys.has('clientSecret')).toBe(false); + }); +}); + +describe('the connector redactor — runtime `authentication` and provider maps', () => { + it('withholds every `authentication` key but `type`', () => { + const { item: served, redactedKeys } = redactConnectorItem({ + ...base, authentication: { type: 'api-key', key: P(10), headerName: 'X-Key' }, + }); + expect(redactedKeys).toEqual(['authentication.headerName', 'authentication.key']); + expect(served.authentication).toEqual({ type: 'api-key' }); + }); + + it('`authentication: { type: \'none\' }` holds nothing to withhold', () => { + const item = { ...base, authentication: { type: 'none' } }; + expect(redactConnectorItem(item)).toEqual({ item, redactedKeys: [] }); + }); + + it('withholds each header and environment map WHOLE, and serves the rest of providerConfig as stored', () => { + const item = { + ...base, + providerConfig: { + baseUrl: 'https://vendor.example.com', + headers: { Authorization: `Bearer ${P(11)}` }, + defaultHeaders: { Accept: 'application/json', 'X-Api-Key': P(12) }, + transport: { kind: 'http', url: 'https://mcp.example.com', headers: { Cookie: P(13) }, env: { VENDOR_TOKEN: P(14) } }, + }, + }; + const { item: served, redactedKeys } = redactConnectorItem(item); + expect(redactedKeys).toEqual([ + 'providerConfig.defaultHeaders', + 'providerConfig.headers', + 'providerConfig.transport.env', + 'providerConfig.transport.headers', + ]); + expect(served.providerConfig).toEqual({ + baseUrl: 'https://vendor.example.com', + transport: { kind: 'http', url: 'https://mcp.example.com' }, + }); + expect(holds(served, P(11), P(12), P(13), P(14))).toBe(false); + }); + + it('serves a cleared map and a null as written, and reports neither', () => { + const item = { ...base, providerConfig: { baseUrl: 'https://v.example', headers: {}, defaultHeaders: null } }; + expect(redactConnectorItem(item)).toEqual({ item, redactedKeys: [] }); + }); + + it('withholds a non-map value at a map position too (fail closed)', () => { + const { redactedKeys } = redactConnectorItem({ ...base, providerConfig: { headers: `Authorization: Bearer ${P(15)}` } }); + expect(redactedKeys).toEqual(['providerConfig.headers']); + }); + + it('the four map positions are the ones the providers send upstream', () => { + expect(CONNECTOR_CREDENTIAL_MAP_PATHS.map((p) => p.join('.'))).toEqual([ + 'providerConfig.headers', + 'providerConfig.defaultHeaders', + 'providerConfig.transport.headers', + 'providerConfig.transport.env', + ]); + }); +}); + +describe('the connector redactor — purity and the untouched remainder', () => { + it('never mutates its input, and leaves every other key (`_diagnostics` included) byte for byte', () => { + const item = { + ...base, + auth: { type: 'bearer', credentialRef: 'VENDOR_TOKEN', token: P(16) }, + providerConfig: { baseUrl: 'https://v.example', defaultHeaders: { Authorization: P(17) } }, + retryConfig: { maxAttempts: 3 }, + }; + const before = JSON.stringify(item); + const { item: served } = redactConnectorItem(item); + expect(JSON.stringify(item)).toBe(before); + for (const key of ['name', 'label', 'type', 'description', 'provider', 'enabled', '_diagnostics', 'retryConfig']) { + expect(served[key], key).toEqual((item as Record)[key]); + } + }); + + it('an item with nothing to withhold is returned by reference with []', () => { + const item = { ...base, auth: { type: 'bearer', credentialRef: 'VENDOR_TOKEN' }, providerConfig: { baseUrl: 'https://v.example' } }; + const result = redactConnectorItem(item); + expect(result.item).toBe(item); + expect(result.redactedKeys).toEqual([]); + }); +}); + +/** + * Fail-closed census: the redactor serves every key an instance-auth kind + * DECLARES, so a secret that became a declared key would be served. Every key + * any kind declares is listed here; a key added to the union reds this test + * until it is confirmed to be a reference or not a secret, and listed. + */ +const DECLARED_NOT_A_SECRET = [ + 'appId', 'authorizationUrl', 'clientId', 'credentialRef', 'grant', 'headerName', 'keyId', + 'paramName', 'resource', 'scheme', 'scopes', 'tokenUrl', 'type', 'username', 'vendor', + 'vendorPublicKeyRef', +]; + +describe('fail-closed census of the declared instance-auth keys', () => { + it('every key any kind declares is a reference or not a secret, and is listed', () => { + const all = new Set(); + for (const type of ['none', 'bearer', 'api-key', 'basic', 'oauth2', 'app-credential', 'signed']) { + for (const key of declaredConnectorAuthKeys({ type })) all.add(key); + } + expect([...all].sort()).toEqual(DECLARED_NOT_A_SECRET); + }); +}); diff --git a/packages/spec/src/integration/connector-credential-redaction.ts b/packages/spec/src/integration/connector-credential-redaction.ts new file mode 100644 index 00000000000..17f2f2bf417 --- /dev/null +++ b/packages/spec/src/integration/connector-credential-redaction.ts @@ -0,0 +1,188 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Read-path credential redaction for a stored `connector` body — the built-in + * `connector` entry of the metadata-type redactor registry + * (`kernel/metadata-type-redaction.ts`). Internal: reached through + * `getMetadataTypeRedactor('connector')`, never exported on its own. + * + * ## Why a built-in, and why not the schema derivation + * + * A `connector` row reaches `sys_metadata` through the metadata save door with + * or without any connector plugin mounted, so a registration from a plugin + * would protect only the compositions that mount it — the fail-open shape the + * registry's module note records for `datasource`. And the positions are not + * declared fields the `.meta({ credential })` derivation can reach: `auth` is a + * discriminated union and `providerConfig` a record the provider owns, and the + * derivation descends into neither (`kernel/metadata-credential-fields.ts`). + * So the positions are named here, read from the declared shapes where a + * declared shape exists. + * + * ## The positions withheld + * + * 1. **`auth.` — every key the item's instance-auth kind does not + * declare.** Every key a kind declares is a reference (`credentialRef`, + * `vendorPublicKeyRef`) or not a secret (`username`, `keyId`, `clientId`, + * the endpoints) — ADR-0097 §3, ADR-0143 D3 — so an undeclared key is the + * one place an inline secret can sit. The declared keys are read from + * {@link ConnectorInstanceAuthSchema} itself, so a kind added to the union + * later is covered with nothing to edit here. A `type` that names no kind + * leaves `type` alone declared. + * 2. **`authentication.` — every key but `type`.** The runtime shape + * carries its secret inline (`token`, `key`, `password`), and an authored + * entry may hold only `{ type: 'none' }` (the publish refusal registered as + * `connector-inline-authentication-publish-refused`), so any other key is + * the shape of an inline secret on a row stored before that refusal. + * 3. **The header and environment maps a provider sends upstream, each + * WHOLE**: `providerConfig.headers`, `providerConfig.defaultHeaders` + * (`rest`), `providerConfig.transport.headers` (`mcp` over http) and + * `providerConfig.transport.env` (`mcp` over stdio, where a server takes + * its credentials). A map's entries cannot be told apart — an + * `Authorization` entry and an `Accept` entry are both strings — so the + * whole map is withheld, the posture ruled for a webhook's `headers` + * (`.meta({ credential: 'map' })`). + * + * ## Cleared forms, and what is never touched + * + * A position holding nothing — `null`, `''`, or an empty map — is served as + * written and not reported, as the schema derivation serves its cleared forms: + * a reader can tell "cleared" from "withheld". Every other key of the item is + * left byte for byte, `_diagnostics` included, and the input is never mutated: + * redaction is a SERVING act. The runtime reads connectors through the + * protocol's execution read, which applies no serving decoration, so a + * withheld map still reaches the provider that sends it. + */ + +import { ConnectorInstanceAuthSchema } from '../shared/connector-auth.zod'; +import { objectShapeOf, wrapperChainOf, zodDefOf } from '../kernel/metadata-credential-fields'; +import type { MetadataRedactionResult } from '../kernel/metadata-type-redaction'; + +/** The map positions under `providerConfig` withheld whole (see the module note, item 3). */ +export const CONNECTOR_CREDENTIAL_MAP_PATHS: readonly (readonly string[])[] = Object.freeze([ + ['providerConfig', 'headers'], + ['providerConfig', 'defaultHeaders'], + ['providerConfig', 'transport', 'headers'], + ['providerConfig', 'transport', 'env'], +]); + +/** One instance-auth kind as the union declares it: its literal-valued keys, and every key it declares. */ +interface DeclaredAuthKind { + /** Each key whose schema is a literal, with the values it accepts (`type`, `grant`, `scheme`). */ + literals: ReadonlyMap; + keys: ReadonlySet; +} + +const isPlainRecord = (v: unknown): v is Record => + !!v && typeof v === 'object' && !Array.isArray(v); + +/** The values a literal field accepts, or `undefined` when the field is not a literal. */ +function literalValuesOf(field: unknown): readonly unknown[] | undefined { + const chain = wrapperChainOf(field); + const def = zodDefOf(chain[chain.length - 1]) as { type?: string; values?: unknown[] } | undefined; + return def?.type === 'literal' && Array.isArray(def.values) ? def.values : undefined; +} + +/** Every object kind under `schema`, flattening nested unions (`oauth2` by grant, `signed` by scheme). */ +function declaredKindsOf(schema: unknown): DeclaredAuthKind[] { + const chain = wrapperChainOf(schema); + const core = chain[chain.length - 1]; + const shape = objectShapeOf(core); + if (shape) { + const literals = new Map(); + for (const [key, field] of Object.entries(shape)) { + const values = literalValuesOf(field); + if (values) literals.set(key, values); + } + return [{ literals, keys: new Set(Object.keys(shape)) }]; + } + const def = zodDefOf(core) as { type?: string; options?: unknown[] } | undefined; + if (def?.type === 'union' && Array.isArray(def.options)) return def.options.flatMap(declaredKindsOf); + return []; +} + +/** Read once, on first use: the union is a lazy schema and is not evaluated at module load. */ +let declaredAuthKinds: DeclaredAuthKind[] | undefined; +function authKinds(): DeclaredAuthKind[] { + declaredAuthKinds ??= declaredKindsOf(ConnectorInstanceAuthSchema); + return declaredAuthKinds; +} + +/** + * The keys an `auth` value may carry: the union of the keys of every kind its + * literal keys select. The candidates are the kinds whose `type` accepts the + * value's `type`; each other literal key the value carries (`grant`, `scheme`) + * narrows them to the kinds that accept its value — and narrows nothing when no + * candidate does, so an unknown `grant` or `scheme` keeps the keys its kind's + * siblings declare. No candidate ⇒ `type` alone. + */ +export function declaredConnectorAuthKeys(auth: Record): ReadonlySet { + let candidates = authKinds().filter((kind) => kind.literals.get('type')?.includes(auth.type) === true); + const narrowingKeys = new Set(candidates.flatMap((kind) => [...kind.literals.keys()])); + narrowingKeys.delete('type'); + for (const key of narrowingKeys) { + if (!Object.prototype.hasOwnProperty.call(auth, key)) continue; + const accepting = candidates.filter((kind) => kind.literals.get(key)?.includes(auth[key]) === true); + if (accepting.length > 0) candidates = accepting; + } + const declared = new Set(['type']); + for (const kind of candidates) for (const key of kind.keys) declared.add(key); + return declared; +} + +/** A position holding nothing: served as written, never reported. */ +function isClearedForm(value: unknown): boolean { + return value === undefined || value === null || value === '' || (isPlainRecord(value) && Object.keys(value).length === 0); +} + +/** `item` with the key at `path` removed, copying each container on the way; the input is never touched. */ +function withoutKeyAt(item: Record, path: readonly string[]): Record { + const [head, ...rest] = path as [string, ...string[]]; + if (rest.length === 0) { + const { [head]: _withheld, ...kept } = item; + return kept; + } + return { ...item, [head]: withoutKeyAt(item[head] as Record, rest) }; +} + +/** The value at `path` in `item`, and whether its parent is a plain object holding the key. */ +function readAt(item: Record, path: readonly string[]): { present: boolean; value: unknown } { + let parent: unknown = item; + for (const key of path.slice(0, -1)) parent = isPlainRecord(parent) ? parent[key] : undefined; + const leaf = path[path.length - 1] as string; + if (!isPlainRecord(parent) || !Object.prototype.hasOwnProperty.call(parent, leaf)) return { present: false, value: undefined }; + return { present: true, value: parent[leaf] }; +} + +/** Every credential position present in `item`, as item-relative paths. */ +function credentialPathsOf(item: Record): string[][] { + const paths: string[][] = []; + const auth = item.auth; + if (isPlainRecord(auth)) { + const declared = declaredConnectorAuthKeys(auth); + for (const key of Object.keys(auth)) if (!declared.has(key)) paths.push(['auth', key]); + } + const authentication = item.authentication; + if (isPlainRecord(authentication)) { + for (const key of Object.keys(authentication)) if (key !== 'type') paths.push(['authentication', key]); + } + for (const path of CONNECTOR_CREDENTIAL_MAP_PATHS) paths.push([...path]); + return paths; +} + +/** + * The built-in `connector` redactor: withholds the positions the module note + * lists, names each in `redactedKeys` (dotted, sorted), and returns the input + * by reference when there is nothing to withhold. + */ +export function redactConnectorItem(item: Record): MetadataRedactionResult { + let served = item; + const redactedKeys: string[] = []; + for (const path of credentialPathsOf(item)) { + const { present, value } = readAt(served, path); + if (!present || isClearedForm(value)) continue; + served = withoutKeyAt(served, path); + redactedKeys.push(path.join('.')); + } + if (redactedKeys.length === 0) return { item, redactedKeys: [] }; + return { item: served, redactedKeys: redactedKeys.sort() }; +} diff --git a/packages/spec/src/kernel/metadata-type-redaction.test.ts b/packages/spec/src/kernel/metadata-type-redaction.test.ts index 0718db10452..873c9e46cdf 100644 --- a/packages/spec/src/kernel/metadata-type-redaction.test.ts +++ b/packages/spec/src/kernel/metadata-type-redaction.test.ts @@ -159,6 +159,42 @@ describe('FAIL-CLOSED: the datasource redactor is a BUILT-IN, not a plugin regis }); }); +describe('FAIL-CLOSED: the connector redactor is a BUILT-IN as well', () => { + // A connector row reaches the metadata save door with or without any + // connector plugin mounted — the same fail-open shape as `datasource` — and + // its credential positions sit where the schema derivation does not reach + // (a union and a provider-owned record). Every value is a placeholder. + const stored = { + name: 'vendor', + label: 'Vendor', + type: 'api', + provider: 'rest', + providerConfig: { baseUrl: 'https://vendor.example.com', defaultHeaders: { Authorization: 'TEST_PLACEHOLDER_A' } }, + auth: { type: 'bearer', credentialRef: 'VENDOR_TOKEN', token: 'TEST_PLACEHOLDER_B' }, + }; + + it('is resolvable with zero registration calls, and the snapshot lists it', () => { + expect(getMetadataTypeRedactor('connector')).toBeDefined(); + expect(listMetadataTypeRedactorTypes()).toContain('connector'); + }); + + it('withholds the inline secret and the header map, and names both', () => { + const { item, redactedKeys } = getMetadataTypeRedactor('connector')!(stored); + expect(redactedKeys).toEqual(['auth.token', 'providerConfig.defaultHeaders']); + expect(JSON.stringify(item)).not.toMatch(/TEST_PLACEHOLDER/); + expect(item.auth).toEqual({ type: 'bearer', credentialRef: 'VENDOR_TOKEN' }); + }); + + it('the stored-row seam withholds both on a serialized body, under either type spelling', () => { + for (const type of ['connector', 'connectors']) { + const row = { id: 'r1', type, name: 'vendor', metadata: JSON.stringify(stored) }; + const served = redactStoredMetadataRow('sys_metadata', row); + expect(served.metadata, type).not.toMatch(/TEST_PLACEHOLDER/); + expect(JSON.parse(served.metadata as string).providerConfig.baseUrl, type).toBe('https://vendor.example.com'); + } + }); +}); + describe('absence is distinguishable from "nothing to redact" (the consumer contract of the per-type redaction hook)', () => { it('a type with no redactor answers undefined — a fact, not a failure', () => { expect(getMetadataTypeRedactor('object')).toBeUndefined(); diff --git a/packages/spec/src/kernel/metadata-type-redaction.ts b/packages/spec/src/kernel/metadata-type-redaction.ts index 121196a7b78..d90f37ddcc6 100644 --- a/packages/spec/src/kernel/metadata-type-redaction.ts +++ b/packages/spec/src/kernel/metadata-type-redaction.ts @@ -60,11 +60,13 @@ * and the derivation is present from module load on every composition. * * Precedence: an explicit registration wins, then a built-in, then the - * derivation. `datasource` (schema-reading plus URL scrubbing) and `flow` - * (nested regions, write-path strip) keep their explicit redactors. + * derivation. `datasource` (schema-reading plus URL scrubbing), `connector` + * (positions inside a union and a provider-owned record) and `flow` (nested + * regions, write-path strip) keep their explicit redactors. */ import { redactDatasourceConfig } from '../data/datasource-credential-redaction'; +import { redactConnectorItem } from '../integration/connector-credential-redaction'; import { derivedCredentialRedactorFor } from './metadata-credential-fields'; import { getMetadataTypeSchema, @@ -131,9 +133,16 @@ function redactDatasourceItem(item: Record): MetadataRedactionR * A type omitted here serves its stored body unredacted (most types hold no * secret). See the module note for why `datasource` is wired here rather than * registered from the opt-in admin plugin. + * + * `connector` is a built-in for the same reason: its rows reach the metadata + * save door with or without any connector plugin mounted. Its credential + * positions sit inside a union (`auth`) and a provider-owned record + * (`providerConfig`), which the schema derivation does not reach, so the + * redactor names them (`integration/connector-credential-redaction.ts`). */ const BUILTIN_METADATA_TYPE_REDACTORS: Record = { datasource: redactDatasourceItem, + connector: redactConnectorItem, }; /** Runtime-extensible overlay populated via `registerMetadataTypeRedactor`. */ diff --git a/packages/spec/src/migrations/entries/semantic/18.connector-instance-auth-inline-secret-refused.ts b/packages/spec/src/migrations/entries/semantic/18.connector-instance-auth-inline-secret-refused.ts new file mode 100644 index 00000000000..1ab95b87282 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.connector-instance-auth-inline-secret-refused.ts @@ -0,0 +1,31 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'connector-instance-auth-inline-secret-refused', + surface: 'connector.auth on AUTHORED entries of the four static kinds (`none`, `bearer`, `api-key`, ' + + '`basic`) — defineStack `connectors:`, `PUT /meta/connector/:name` — an undeclared key, an inline ' + + 'secret (`token`, `key`, `password`) above all', + replacement: 'keep only the keys the kind declares: `bearer` → `{ type, credentialRef }`; `api-key` → ' + + '`{ type, credentialRef, headerName?, paramName? }`; `basic` → `{ type, username, credentialRef }`; ' + + '`none` → `{ type }`. A secret written beside the reference goes into the secrets layer under the ' + + 'name `credentialRef` already gives (an environment variable in the open tier, or Setup → ' + + 'Connector credentials), and the key is deleted.', + reason: + 'The four static kinds were plain objects, so an undeclared key was dropped at parse and the entry ' + + 'still validated, while the three ADR-0143 kinds (`oauth2`, `app-credential`, `signed`) refused one ' + + 'by name. All seven now refuse an undeclared key with a located error, and an inline secret meets the ' + + '`credentialRef` prescription: the one refusal ADR-0143 D3 records for every variant. The platform ' + + 'never read such a key — a static kind resolves its secret from `credentialRef` alone at ' + + 'materialization — so moving the value changes no connector\'s behaviour. There is deliberately no ' + + 'mechanical rewrite: the inline value may be the only copy of the secret the author holds, so ' + + 'deleting it from a source is a judgement about where that secret goes next, not a rename. The ' + + 'metadata read now also withholds the undeclared `auth` keys, the non-`type` keys of a runtime ' + + '`authentication` block, and the provider header and environment maps of a stored connector.', + acceptanceCriteria: + 'Every authored connector entry parses through `DeclarativeConnectorEntrySchema` with no undeclared ' + + 'key under `auth`; each secret that sat inline is set under its `credentialRef` name and the instance ' + + 'still materializes (`GET /api/v1/automation/connectors` reports it `ready`); no stored `sys_metadata` ' + + 'connector row reports an undeclared `auth` key in its `_diagnostics`.', +}; diff --git a/packages/spec/src/shared/connector-auth.zod.ts b/packages/spec/src/shared/connector-auth.zod.ts index 362de7a8ef6..23924211862 100644 --- a/packages/spec/src/shared/connector-auth.zod.ts +++ b/packages/spec/src/shared/connector-auth.zod.ts @@ -148,6 +148,14 @@ export type ResolvedConnectorAuth = Extract< // secret here: stack metadata is authored, versioned, and shipped, so a raw // token must never live in it (ADR-0097 §3, ADR-0143 D3). // +// "No field" is not enough on its own: a plain `z.object` drops an undeclared +// key and the parse still succeeds, so a door that stores what it was sent +// would keep the secret the shape has no field for. So EVERY kind is a +// `strictObject` that refuses an undeclared key, and names the secret keys an +// author is likely to reach for with the `credentialRef` prescription — the +// refusal ADR-0143 D3 records ("publish keeps refusing an inline secret") and +// its Acceptance asks for on any variant, with a located error. +// // 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 @@ -158,14 +166,50 @@ export type ResolvedConnectorAuth = Extract< // test vectors. // ============================================================================ +/** + * The one `history` sentence the four static kinds share: what an undeclared + * key met before these shapes were closed. + */ +const STATIC_KIND_HISTORY = + 'Until this shape was closed, an undeclared key here was dropped at parse rather than refused.'; + +/** + * The guidance a credential meets on `auth: { type: 'none' }`: the kind names + * an upstream that takes none, so the fix is a different kind, not a reference. + */ +const NO_AUTH_CREDENTIAL_GUIDANCE = + "Not authorable on `auth: { type: 'none' }` — `none` declares an upstream that takes no credential, so it " + + "carries none. Choose the kind the upstream expects (`bearer`, `api-key`, `basic`, `oauth2`, `app-credential` " + + 'or `signed`) and name its secret with `credentialRef`.'; + /** No authentication — the upstream is public. */ -export const ConnectorInstanceNoAuthSchema = lazySchema(() => z.object({ +export const ConnectorInstanceNoAuthSchema = lazySchema(() => strictObject({ + surface: "this `none` connector auth", + history: STATIC_KIND_HISTORY, + guidance: { + token: NO_AUTH_CREDENTIAL_GUIDANCE, + key: NO_AUTH_CREDENTIAL_GUIDANCE, + apiKey: NO_AUTH_CREDENTIAL_GUIDANCE, + password: NO_AUTH_CREDENTIAL_GUIDANCE, + secret: NO_AUTH_CREDENTIAL_GUIDANCE, + credentialRef: NO_AUTH_CREDENTIAL_GUIDANCE, + }, +}, { type: z.literal('none'), })); export type ConnectorInstanceNoAuth = z.input; /** Bearer-token auth; the token is resolved from `credentialRef` when the instance materializes. */ -export const ConnectorInstanceBearerAuthSchema = lazySchema(() => z.object({ +export const ConnectorInstanceBearerAuthSchema = lazySchema(() => strictObject({ + surface: "this `bearer` connector auth", + history: STATIC_KIND_HISTORY, + guidance: { + token: inlineSecretGuidance('bearer', 'token'), + accessToken: inlineSecretGuidance('bearer', 'token'), + bearerToken: inlineSecretGuidance('bearer', 'token'), + secret: inlineSecretGuidance('bearer', 'token'), + }, +}, { type: z.literal('bearer'), credentialRef: z.string().min(1).describe( 'Secrets-layer reference (e.g. an env-var name in the open tier) resolved to the bearer token at materialization. Never an inline token.', @@ -174,7 +218,17 @@ export const ConnectorInstanceBearerAuthSchema = lazySchema(() => z.object({ export type ConnectorInstanceBearerAuth = z.input; /** API-key auth; the key is resolved from `credentialRef` when the instance materializes. */ -export const ConnectorInstanceAPIKeyAuthSchema = lazySchema(() => z.object({ +export const ConnectorInstanceAPIKeyAuthSchema = lazySchema(() => strictObject({ + surface: "this `api-key` connector auth", + history: STATIC_KIND_HISTORY, + guidance: { + key: inlineSecretGuidance('api-key', 'key'), + apiKey: inlineSecretGuidance('api-key', 'key'), + value: inlineSecretGuidance('api-key', 'key'), + token: inlineSecretGuidance('api-key', 'key'), + secret: inlineSecretGuidance('api-key', 'key'), + }, +}, { type: z.literal('api-key'), credentialRef: z.string().min(1).describe( 'Secrets-layer reference resolved to the API key at materialization. Never an inline key.', @@ -185,7 +239,15 @@ export const ConnectorInstanceAPIKeyAuthSchema = lazySchema(() => z.object({ export type ConnectorInstanceAPIKeyAuth = z.input; /** Basic auth; the password is resolved from `credentialRef` when the instance materializes. */ -export const ConnectorInstanceBasicAuthSchema = lazySchema(() => z.object({ +export const ConnectorInstanceBasicAuthSchema = lazySchema(() => strictObject({ + surface: "this `basic` connector auth", + history: STATIC_KIND_HISTORY, + guidance: { + password: inlineSecretGuidance('basic', 'password'), + passwd: inlineSecretGuidance('basic', 'password'), + secret: inlineSecretGuidance('basic', 'password'), + }, +}, { type: z.literal('basic'), username: z.string().describe('Username (not a secret; safe to keep in metadata).'), credentialRef: z.string().min(1).describe( @@ -195,10 +257,11 @@ export const ConnectorInstanceBasicAuthSchema = lazySchema(() => z.object({ export type ConnectorInstanceBasicAuth = z.input; /** - * The guidance an inline secret meets on an ADR-0143 instance kind (`oauth2`, - * `app-credential`, `signed`): 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"). + * The guidance an inline secret meets on an instance kind that takes one + * (`bearer`, `api-key`, `basic`, and ADR-0143's `oauth2`, `app-credential`, + * `signed`): 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 `