From b9e26de009ed7d896ddaf903b7e0ae3ead52ea40 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 10:49:06 +0000 Subject: [PATCH 1/4] wip(spec): close the authored connector entry; withhold URL credentials and non-object auth on read Claude-Session: https://claude.ai/code/session_017xRBcVy4VXmkQjn2MGeaWk Co-authored-by: Claude --- .../data/datasource-credential-redaction.ts | 12 +- .../connector-credential-redaction.test.ts | 105 ++++++++++++++ .../connector-credential-redaction.ts | 130 +++++++++++++++++- .../connector-entry-undeclared-keys.test.ts | 120 ++++++++++++++++ .../spec/src/integration/connector.zod.ts | 111 +++++++++++++-- 5 files changed, 466 insertions(+), 12 deletions(-) create mode 100644 packages/spec/src/integration/connector-entry-undeclared-keys.test.ts diff --git a/packages/spec/src/data/datasource-credential-redaction.ts b/packages/spec/src/data/datasource-credential-redaction.ts index 09a4a27b33a..b491b004f62 100644 --- a/packages/spec/src/data/datasource-credential-redaction.ts +++ b/packages/spec/src/data/datasource-credential-redaction.ts @@ -437,8 +437,16 @@ export function redactUrlPassword(value: string): string { * REFUSED at the write door — the same reasoning as the dropped inline keys. * A query left empty takes its `?` with it, so the served shape is exactly * what the write door accepts. + * + * `params` defaults to the datasource set; the connector read redactor + * (`integration/connector-credential-redaction.ts`) passes its own name list + * through this same function, so the two read paths keep ONE definition of + * where a query runs and what a pair is. */ -export function redactUrlCredentialQueryParams(value: string): string { +export function redactUrlCredentialQueryParams( + value: string, + params: readonly string[] = CREDENTIAL_URL_QUERY_PARAM_NAMES, +): string { const hashIdx = value.indexOf('#'); const head = hashIdx === -1 ? value : value.slice(0, hashIdx); const fragment = hashIdx === -1 ? '' : value.slice(hashIdx); @@ -447,7 +455,7 @@ export function redactUrlCredentialQueryParams(value: string): string { const kept = head .slice(queryIdx + 1) .split('&') - .filter((pair) => credentialQueryParamOf(pair, CREDENTIAL_URL_QUERY_PARAM_NAMES) === undefined); + .filter((pair) => credentialQueryParamOf(pair, params) === undefined); const base = head.slice(0, queryIdx); return `${kept.length > 0 ? `${base}?${kept.join('&')}` : base}${fragment}`; } diff --git a/packages/spec/src/integration/connector-credential-redaction.test.ts b/packages/spec/src/integration/connector-credential-redaction.test.ts index bf0fa302a3d..1c62f51377c 100644 --- a/packages/spec/src/integration/connector-credential-redaction.test.ts +++ b/packages/spec/src/integration/connector-credential-redaction.test.ts @@ -11,6 +11,7 @@ import { describe, it, expect } from 'vitest'; import { CONNECTOR_CREDENTIAL_MAP_PATHS, + CONNECTOR_CREDENTIAL_URL_QUERY_PARAM_NAMES, declaredConnectorAuthKeys, redactConnectorItem, } from './connector-credential-redaction'; @@ -142,6 +143,110 @@ describe('the connector redactor — runtime `authentication` and provider maps' }); }); +describe('the connector redactor — `auth` / `authentication` that is not an object', () => { + it.each([ + ['auth', 'a string', P(20)], + ['auth', 'a number', 4242], + ['auth', 'an array', [P(21)]], + ['authentication', 'a string', P(22)], + ] as const)('`%s` holding %s is withheld whole', (position, _what, value) => { + const { item: served, redactedKeys } = redactConnectorItem({ ...base, [position]: value }); + expect(redactedKeys).toEqual([position]); + expect(served).not.toHaveProperty(position); + expect(holds(served, P(20), P(21), P(22))).toBe(false); + expect(served.name).toBe('vendor'); + }); + + it.each([ + ['an empty string', ''], + ['a null', null], + ] as const)('a cleared form (%s) is served as written and not reported', (_what, value) => { + const item = { ...base, auth: value, authentication: value }; + expect(redactConnectorItem(item)).toEqual({ item, redactedKeys: [] }); + }); +}); + +describe('the connector redactor — credentials inside a URL under `providerConfig`', () => { + const at = (providerConfig: Record, extra: Record = {}) => + redactConnectorItem({ ...base, providerConfig, ...extra }); + + it('rewrites the userinfo out of `baseUrl`, keeping the rest of the URL byte for byte', () => { + const { item: served, redactedKeys } = at({ baseUrl: `https://svc:${P(30)}@api.vendor.example:8443/v2/?region=eu#top` }); + expect(redactedKeys).toEqual(['providerConfig.baseUrl']); + expect((served.providerConfig as Record).baseUrl).toBe('https://api.vendor.example:8443/v2/?region=eu#top'); + }); + + it('a bare token in the user part goes too', () => { + const { item: served } = at({ baseUrl: `https://${P(31)}@api.vendor.example/v1` }); + expect((served.providerConfig as Record).baseUrl).toBe('https://api.vendor.example/v1'); + }); + + it('strips each credential query parameter, case-insensitively, and keeps the others in order', () => { + const { item: served, redactedKeys } = at({ + baseUrl: `https://api.vendor.example/v1?region=eu&API_KEY=${P(32)}&page=2&access_token=${P(33)}`, + }); + expect(redactedKeys).toEqual(['providerConfig.baseUrl']); + expect((served.providerConfig as Record).baseUrl).toBe('https://api.vendor.example/v1?region=eu&page=2'); + }); + + it('covers `transport.url` and a URL `spec`, and leaves a package-relative `spec` path alone', () => { + const { item: served, redactedKeys } = at({ + spec: `https://u:${P(34)}@spec.vendor.example/openapi.json?token=${P(35)}`, + transport: { kind: 'http', url: `https://mcp.vendor.example/mcp?sig=${P(36)}` }, + }); + expect(redactedKeys).toEqual(['providerConfig.spec', 'providerConfig.transport.url']); + expect(served.providerConfig).toEqual({ + spec: 'https://spec.vendor.example/openapi.json', + transport: { kind: 'http', url: 'https://mcp.vendor.example/mcp' }, + }); + const relative = { ...base, providerConfig: { spec: './vendor-openapi.json' } }; + expect(redactConnectorItem(relative)).toEqual({ item: relative, redactedKeys: [] }); + }); + + it('strips the query parameter an `api-key` instance itself names with `paramName`', () => { + const { item: served } = at( + { baseUrl: `https://api.vendor.example/v1?appid=${P(37)}&units=metric` }, + { auth: { type: 'api-key', credentialRef: 'VENDOR_KEY', paramName: 'appid' } }, + ); + expect((served.providerConfig as Record).baseUrl).toBe('https://api.vendor.example/v1?units=metric'); + expect(served.auth).toEqual({ type: 'api-key', credentialRef: 'VENDOR_KEY', paramName: 'appid' }); + }); + + it('a URL with no credential part, and a string that is not a URL, are untouched', () => { + const item = { + ...base, + providerConfig: { + baseUrl: 'https://api.vendor.example/v1?region=eu', + note: `call ?token=${P(38)} by hand`, + include: ['search'], + }, + }; + expect(redactConnectorItem(item)).toEqual({ item, redactedKeys: [] }); + }); + + it('arrays and dot-spelled keys are off the walk (the carry-forward cannot address them)', () => { + const item = { + ...base, + providerConfig: { + servers: [`https://u:${P(39)}@a.example`], + 'v1.url': `https://u:${P(40)}@b.example`, + }, + }; + expect(redactConnectorItem(item)).toEqual({ item, redactedKeys: [] }); + }); + + it('a map withheld whole is not walked for URLs: it is reported once, as the map', () => { + const { redactedKeys } = at({ headers: { Referer: `https://u:${P(41)}@c.example` } }); + expect(redactedKeys).toEqual(['providerConfig.headers']); + }); + + it('the query names include the datasource set and the HTTP conventions', () => { + for (const name of ['password', 'authToken', 'api_key', 'apikey', 'access_token', 'token', 'key', 'sig', 'client_secret']) { + expect(CONNECTOR_CREDENTIAL_URL_QUERY_PARAM_NAMES).toContain(name); + } + }); +}); + 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 = { diff --git a/packages/spec/src/integration/connector-credential-redaction.ts b/packages/spec/src/integration/connector-credential-redaction.ts index 17f2f2bf417..6c94d4d1979 100644 --- a/packages/spec/src/integration/connector-credential-redaction.ts +++ b/packages/spec/src/integration/connector-credential-redaction.ts @@ -41,6 +41,28 @@ * `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' })`). + * 4. **`auth` / `authentication` that is not an object, WHOLE.** Every door + * that authors an entry refuses such a value (`invalid_type`), so a stored + * one came from outside them, and what it holds cannot be judged key by + * key: an `auth: ''` is the secret itself. Withheld whole, fail + * closed, rather than served as stored. + * 5. **The credential parts of every URL under `providerConfig`** — rewritten, + * not removed: the userinfo (`https://user:pass@host`, and the + * `https://@host` form several HTTP APIs read as a token, so the + * user part goes too) and each credential query parameter + * ({@link CONNECTOR_CREDENTIAL_URL_QUERY_PARAM_NAMES}, plus the + * `auth.paramName` an `api-key` instance itself names), with the rest of + * the URL byte for byte. That is the read half the datasource redactor + * applies to a driver URL (`data/datasource-credential-redaction.ts`), and + * the query half runs through the same `redactUrlCredentialQueryParams`. + * It covers `providerConfig.baseUrl` (`rest`, `openapi`), + * `providerConfig.transport.url` (`mcp` over http) and a URL + * `providerConfig.spec` (`openapi`), and any other URL a provider keeps at + * an OBJECT depth of `providerConfig`: a string is judged when it starts + * with `scheme://`. Arrays are off the walk, as they are for the datasource + * scrub, and so is a key spelled with a dot: the carry-forward addresses a + * withheld position by its dotted path, so a position it cannot address + * would turn a save of the served form into a silent credential deletion. * * ## Cleared forms, and what is never touched * @@ -51,9 +73,17 @@ * 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. + * + * A rewritten URL is a withheld position like a removed one: it is named in + * `redactedKeys`, and a save that sends the served URL back unchanged keeps the + * stored one (the protocol's carry-forward compares the incoming value with + * the served form and re-grafts the stored value only where they match), while + * a URL the author edits is the author's word. */ import { ConnectorInstanceAuthSchema } from '../shared/connector-auth.zod'; +import { CREDENTIAL_URL_QUERY_PARAM_NAMES } from '../data/driver/common.zod'; +import { redactUrlCredentialQueryParams } from '../data/datasource-credential-redaction'; import { objectShapeOf, wrapperChainOf, zodDefOf } from '../kernel/metadata-credential-fields'; import type { MetadataRedactionResult } from '../kernel/metadata-type-redaction'; @@ -65,6 +95,59 @@ export const CONNECTOR_CREDENTIAL_MAP_PATHS: readonly (readonly string[])[] = Ob ['providerConfig', 'transport', 'env'], ]); +/** + * The query parameters whose value a connector URL is read as carrying a + * credential in (see the module note, item 5): the datasource set + * (`password`, `authToken`) and the names HTTP APIs conventionally take a key, + * token or signature under. Matched case-insensitively on the decoded name + * (`credentialQueryParamOf`). A name here that holds no secret on some API + * costs that API's served URL the parameter, never its stored value: the + * carry-forward keeps it on a save of the served form. + */ +export const CONNECTOR_CREDENTIAL_URL_QUERY_PARAM_NAMES: readonly string[] = Object.freeze([ + ...CREDENTIAL_URL_QUERY_PARAM_NAMES, + 'access_token', + 'api_key', + 'api-key', + 'apikey', + 'auth_token', + 'client_secret', + 'id_token', + 'key', + 'passwd', + 'refresh_token', + 'secret', + 'sig', + 'signature', + 'token', + 'x-api-key', +]); + +/** `scheme://` — the strings item 5 judges. */ +const URL_SCHEME_RE = /^[a-z][a-z0-9+.\-]*:\/\//i; + +/** + * `scheme://userinfo@` — the whole userinfo. Every class excludes `/?#`, so the + * `@` must sit in the authority; the greedy class backtracks to the LAST `@` + * before the path, the userinfo boundary RFC 3986 defines (the reading + * `URL_USERINFO_RE` in the datasource module documents). + */ +const URL_USERINFO_RE = /^([a-z][a-z0-9+.\-]*:\/\/)[^/?#]*@/i; + +/** One URL with its userinfo and credential query parameters removed; the input when there is none. */ +function redactConnectorUrl(value: string, params: readonly string[]): string { + return redactUrlCredentialQueryParams(value.replace(URL_USERINFO_RE, '$1'), params); +} + +/** The query parameter names item 5 strips for `item`: the fixed set plus its own `auth.paramName`. */ +function credentialQueryParamsOf(item: Record): readonly string[] { + const auth = item.auth; + const own = isPlainRecord(auth) && auth.type === 'api-key' && typeof auth.paramName === 'string' && auth.paramName !== '' + ? auth.paramName + : undefined; + return own === undefined ? CONNECTOR_CREDENTIAL_URL_QUERY_PARAM_NAMES : [...CONNECTOR_CREDENTIAL_URL_QUERY_PARAM_NAMES, own]; +} + /** 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`). */ @@ -153,22 +236,62 @@ function readAt(item: Record, path: readonly string[]): { prese return { present: true, value: parent[leaf] }; } -/** Every credential position present in `item`, as item-relative paths. */ +/** Every credential position present in `item` that is withheld by removal, 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]); + } else { + // Item 4: not an object, so withheld whole (a cleared form is skipped by the caller). + paths.push(['auth']); } const authentication = item.authentication; if (isPlainRecord(authentication)) { for (const key of Object.keys(authentication)) if (key !== 'type') paths.push(['authentication', key]); + } else { + paths.push(['authentication']); } for (const path of CONNECTOR_CREDENTIAL_MAP_PATHS) paths.push([...path]); return paths; } +/** `item` with the value at `path` replaced, copying each container on the way; the input is never touched. */ +function withValueAt(item: Record, path: readonly string[], value: unknown): Record { + const [head, ...rest] = path as [string, ...string[]]; + if (rest.length === 0) return { ...item, [head]: value }; + return { ...item, [head]: withValueAt(item[head] as Record, rest, value) }; +} + +/** + * Item 5: every URL under `providerConfig`, at object depth, whose credential + * parts {@link redactConnectorUrl} removes — the path and the rewritten value. + */ +function credentialUrlRewritesOf( + item: Record, +): Array<{ path: string[]; value: string }> { + const rewrites: Array<{ path: string[]; value: string }> = []; + const providerConfig = item.providerConfig; + if (!isPlainRecord(providerConfig)) return rewrites; + const params = credentialQueryParamsOf(item); + const walk = (node: Record, prefix: readonly string[]): void => { + for (const [key, value] of Object.entries(node)) { + if (key.includes('.')) continue; + const path = [...prefix, key]; + if (typeof value === 'string') { + if (!URL_SCHEME_RE.test(value)) continue; + const redacted = redactConnectorUrl(value, params); + if (redacted !== value) rewrites.push({ path, value: redacted }); + } else if (isPlainRecord(value)) { + walk(value, path); + } + } + }; + walk(providerConfig, ['providerConfig']); + return rewrites; +} + /** * The built-in `connector` redactor: withholds the positions the module note * lists, names each in `redactedKeys` (dotted, sorted), and returns the input @@ -183,6 +306,11 @@ export function redactConnectorItem(item: Record): MetadataReda served = withoutKeyAt(served, path); redactedKeys.push(path.join('.')); } + // After the removals, so a map withheld whole is never walked for URLs. + for (const { path, value } of credentialUrlRewritesOf(served)) { + served = withValueAt(served, path, value); + redactedKeys.push(path.join('.')); + } if (redactedKeys.length === 0) return { item, redactedKeys: [] }; return { item: served, redactedKeys: redactedKeys.sort() }; } diff --git a/packages/spec/src/integration/connector-entry-undeclared-keys.test.ts b/packages/spec/src/integration/connector-entry-undeclared-keys.test.ts new file mode 100644 index 00000000000..04666f1822c --- /dev/null +++ b/packages/spec/src/integration/connector-entry-undeclared-keys.test.ts @@ -0,0 +1,120 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// An authored connector entry (`DeclarativeConnectorEntrySchema`, behind +// `defineStack({ connectors })`, `os validate` / `os compile`, the artifact load +// and `PUT /meta/connector/:name`) refuses every TOP-LEVEL key its shape does +// not declare, located at the key, where it used to parse green. Every refusal +// is pinned by its issue `code`, `path` and `keys`; every case has a CONTROL +// accept, so a refusal that fired on everything could not pass. The open +// runtime carrier, `ConnectorSchema`, keeps its posture, pinned as a control. +// +// Every value below is an obviously-fake placeholder, and the refusal must +// never echo it. + +import { describe, it, expect } from 'vitest'; +import { ConnectorSchema, DeclarativeConnectorEntrySchema } from './connector.zod'; +import { ObjectStackDefinitionSchema } from '../stack.zod'; + +const PLACEHOLDER = 'TEST_PLACEHOLDER_NOT_A_SECRET'; + +const INSTANCE = { + name: 'vendor', + label: 'Vendor', + type: 'api', + provider: 'rest', + providerConfig: { baseUrl: 'https://vendor.example.com' }, + auth: { type: 'bearer', credentialRef: 'VENDOR_TOKEN' }, +} as const; + +const DESCRIPTOR = { name: 'erp', label: 'ERP', type: 'database', enabled: false } 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, JSON.stringify(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('an authored connector entry refuses an undeclared top-level key', () => { + it.each([ + ['a provider-bound instance', INSTANCE], + ['a catalog descriptor', DESCRIPTOR], + ] as const)('CONTROL: %s with only declared keys parses', (_label, entry) => { + expect(DeclarativeConnectorEntrySchema.safeParse(entry).success).toBe(true); + }); + + it.each([ + ['a credential-shaped scalar', { apiKey: PLACEHOLDER }, ['apiKey']], + ['a nested container', { extraConfig: { secret: PLACEHOLDER } }, ['extraConfig']], + ['two undeclared keys', { token: PLACEHOLDER, vendorRegion: 'eu' }, ['token', 'vendorRegion']], + ] as const)('%s: refused at the entry root, naming each key, never the value', (_label, extra, keys) => { + for (const entry of [INSTANCE, DESCRIPTOR]) { + const { located, message } = refusal({ ...entry, ...extra }); + expect(located).toEqual(['unrecognized_keys', '', keys]); + expect(message).toContain('this connector entry'); + for (const key of keys) expect(message).toContain(`\`${key}\``); + expect(message).not.toContain(PLACEHOLDER); + } + }); + + it('a credential-shaped key meets the credentialRef prescription', () => { + const { message } = refusal({ ...INSTANCE, apiKey: PLACEHOLDER }); + expect(message).toContain('`auth: { type, credentialRef }`'); + }); + + it('a provider setting written at the top level is pointed at `providerConfig`', () => { + const { message } = refusal({ ...INSTANCE, baseUrl: 'https://vendor.example.com' }); + expect(message).toContain('`providerConfig`'); + }); + + it('the refusal is terminal: an undeclared key beside a refined fault is the one issue', () => { + // An inline `authentication` is the ADR-0097 refinement's refusal; with an + // undeclared key beside it the entry meets the closed shape's refusal alone. + const { located } = refusal({ ...INSTANCE, authentication: { type: 'bearer', token: 'x' }, vendorRegion: 'eu' }); + expect(located).toEqual(['unrecognized_keys', '', ['vendorRegion']]); + }); + + it('the ADR-0010 protection envelope is declared, so a stamped entry still parses', () => { + const stamped = { + ...INSTANCE, + _lock: 'full', + _lockReason: 'shipped by a package', + _lockSource: 'package', + _provenance: 'package', + _packageId: 'com.example.vendor', + _packageVersion: '1.0.0', + }; + const parsed = DeclarativeConnectorEntrySchema.safeParse(stamped); + expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true); + expect(parsed.data!._packageId).toBe('com.example.vendor'); + }); + + it('a retired key keeps its own prescription, and a retired default stays accepted as residue', () => { + const retired = DeclarativeConnectorEntrySchema.safeParse({ ...INSTANCE, triggers: [] }); + expect(retired.success).toBe(false); + expect(retired.error!.issues.map((i) => i.code)).not.toContain('unrecognized_keys'); + expect(DeclarativeConnectorEntrySchema.safeParse({ ...INSTANCE, status: 'inactive' }).success).toBe(true); + }); + + it('the stack door refuses it too, located at the entry', () => { + const result = ObjectStackDefinitionSchema.safeParse({ connectors: [{ ...INSTANCE, apiKey: PLACEHOLDER }] }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.code === 'unrecognized_keys'); + expect(issue?.path.join('.')).toBe('connectors.0'); + expect((issue as { keys?: unknown }).keys).toEqual(['apiKey']); + expect(ObjectStackDefinitionSchema.safeParse({ connectors: [INSTANCE] }).success).toBe(true); + }); + + it('CONTROL: the runtime carrier `ConnectorSchema` keeps its open posture', () => { + // The def a plugin hands to `registerConnector` is a code-to-code shape and + // is not narrowed here: it still drops an undeclared key at parse. + const parsed = ConnectorSchema.safeParse({ ...INSTANCE, vendorRegion: 'eu' }); + expect(parsed.success).toBe(true); + expect(parsed.data).not.toHaveProperty('vendorRegion'); + }); +}); diff --git a/packages/spec/src/integration/connector.zod.ts b/packages/spec/src/integration/connector.zod.ts index ca715a6aa48..087fe28da50 100644 --- a/packages/spec/src/integration/connector.zod.ts +++ b/packages/spec/src/integration/connector.zod.ts @@ -4,6 +4,7 @@ import { z } from 'zod'; import { ConnectorAuthConfigSchema, ConnectorInstanceAuthSchema } from '../shared/connector-auth.zod'; import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; import { acceptRetiredDefaultResidue, retiredKey } from '../shared/retired-key'; +import { strictObject } from '../shared/strict-object'; /** * Connector Protocol - LEVEL 3: Enterprise Connector @@ -822,10 +823,20 @@ export type ConnectorAction = z.input; // `triggers` key it was the only carrier of — see "REMOVED: `triggers`" above. /** - * Base Connector Schema - * Core connector configuration shared across all connector types + * The connector's declared keys — the one declaration both published carriers + * are built from: the open {@link ConnectorBaseSchema} behind + * {@link ConnectorSchema} and the closed {@link DeclarativeConnectorEntryBaseSchema} + * behind {@link DeclarativeConnectorEntrySchema}. + * + * A FUNCTION, so each carrier gets its own field instances. The strict entry's + * `guidance` is a promise about the shape it was declared with, and + * `computeGuidanceRoutes()` (`scripts/build-schemas.ts`) pairs an emitted def + * with its declaration by the identity of each field: one shared field set would + * pair the OPEN `Connector` def with the closed declaration too, and charge it + * prescriptions an open shape never delivers (the `shared/` ledger row's + * one-declaration, two-doors finding). */ -const ConnectorBaseSchema = lazySchema(() => z.object({ +const connectorBaseShape = () => ({ /** * Machine name (snake_case) */ @@ -1079,7 +1090,85 @@ const ConnectorBaseSchema = lazySchema(() => z.object({ // Pure-additive and internal: every key is `_`-prefixed and optional, so no // author-facing field changes and nothing that parsed before stops parsing. ...MetadataProtectionFields, -})); +}); + +/** + * Base Connector Schema — the OPEN carrier: core connector configuration shared + * across all connector types, behind {@link ConnectorSchema} (the def a plugin + * hands to `registerConnector`, and `defineConnector`). Its posture is unchanged; + * the closed carrier for an AUTHORED entry is + * {@link DeclarativeConnectorEntryBaseSchema} below. + */ +const ConnectorBaseSchema = lazySchema(() => z.object(connectorBaseShape())); + +/** + * What a credential-shaped key meets on an authored connector entry. A + * connector entry holds no secret at any key: a provider-bound instance names + * its credential with `auth.credentialRef` and the value lives in the secrets + * layer (ADR-0097 §3, ADR-0143 D3). + */ +const ENTRY_CREDENTIAL_KEY_GUIDANCE = + 'Not authorable on a connector entry — connector metadata never holds a credential. A provider-bound ' + + 'instance names its secret with `auth: { type, credentialRef }`, and the administrator sets the value ' + + 'under that name (an environment variable in the open tier, or Setup → Connector credentials).'; + +/** + * What a provider setting written at the top level of an entry meets: it + * belongs under `providerConfig`, which the provider validates at + * materialization (ADR-0097). + */ +const ENTRY_PROVIDER_CONFIG_KEY_GUIDANCE = + 'Not a top-level connector key — provider settings go under `providerConfig` (`{ baseUrl }` for `rest`, ' + + '`{ spec, baseUrl? }` for `openapi`, `{ transport }` for `mcp`), which the provider validates when the ' + + 'instance materializes.'; + +/** + * The CLOSED carrier behind {@link DeclarativeConnectorEntrySchema}: the same + * declared keys as {@link ConnectorBaseSchema}, refusing every key it does not + * declare. + * + * An authored entry is a stored, served metadata type, not a wire payload: the + * `PUT /meta/connector/:name` door keeps the request body it accepts (ADR-0005 + * appendix (c), ruling B — every type but `view` stores its body, not the + * parse), and every read exit serves that body. On an open shape an undeclared + * key therefore parsed green and was kept, while `defineStack`, `os validate` + * and `os compile` dropped it without a word. Closed, every door that parses + * this schema refuses it by name, located at the key: `defineStack({ connectors + * })`, `os validate` / `os compile`, the artifact load at boot and reload, and + * the save door with `422 INVALID_METADATA`. The ADR-0010 protection envelope + * is declared in the shape, so a stamped, package-loaded entry still parses. + * + * The refusal is terminal ({@link strictObject}): a body with an undeclared key + * meets that one refusal, not the ADR-0097 refinements below as well. + */ +const DeclarativeConnectorEntryBaseSchema = lazySchema(() => strictObject({ + surface: 'this connector entry', + history: 'Until this shape was closed, an undeclared key here was accepted without effect.', + aliases: { + config: 'providerConfig', + providerOptions: 'providerConfig', + timeout: 'requestTimeoutMs', + timeoutMs: 'requestTimeoutMs', + retry: 'retryConfig', + }, + guidance: { + apiKey: ENTRY_CREDENTIAL_KEY_GUIDANCE, + key: ENTRY_CREDENTIAL_KEY_GUIDANCE, + token: ENTRY_CREDENTIAL_KEY_GUIDANCE, + accessToken: ENTRY_CREDENTIAL_KEY_GUIDANCE, + secret: ENTRY_CREDENTIAL_KEY_GUIDANCE, + clientSecret: ENTRY_CREDENTIAL_KEY_GUIDANCE, + password: ENTRY_CREDENTIAL_KEY_GUIDANCE, + credentials: ENTRY_CREDENTIAL_KEY_GUIDANCE, + credentialRef: ENTRY_CREDENTIAL_KEY_GUIDANCE, + baseUrl: ENTRY_PROVIDER_CONFIG_KEY_GUIDANCE, + url: ENTRY_PROVIDER_CONFIG_KEY_GUIDANCE, + spec: ENTRY_PROVIDER_CONFIG_KEY_GUIDANCE, + transport: ENTRY_PROVIDER_CONFIG_KEY_GUIDANCE, + headers: ENTRY_PROVIDER_CONFIG_KEY_GUIDANCE, + defaultHeaders: ENTRY_PROVIDER_CONFIG_KEY_GUIDANCE, + }, +}, connectorBaseShape())); /** * Core connector configuration — the authorable shape behind the ruled @@ -1141,9 +1230,13 @@ export function defineConnector(config: z.input): Connec * refinement still belongs on the inner object. * * **Subtype route:** extend `ConnectorBaseSchema` and re-wrap the result with - * `acceptRetiredDefaultResidue(…, CONNECTOR_RETIRED_KEY_RESIDUE)` — exactly - * what this schema does below, and the `EffectiveObjectPermissionSchema` - * precedent. + * `acceptRetiredDefaultResidue(…, CONNECTOR_RETIRED_KEY_RESIDUE)` — the + * `EffectiveObjectPermissionSchema` precedent. This schema refines the CLOSED + * twin, {@link DeclarativeConnectorEntryBaseSchema}, the same way. + * + * **Closed.** An authored entry refuses every key the shape does not declare, + * at every door that parses it; {@link ConnectorSchema} keeps its open posture + * for the defs plugins register. See {@link DeclarativeConnectorEntryBaseSchema}. * * One rule applies to EVERY authored entry (#7990, maintainer-ruled 2026-08-12): * - NO entry may inline secrets via `authentication`. A published connector @@ -1182,7 +1275,7 @@ function isMcpHttpInstance(entry: { provider?: string; providerConfig?: Record - // [#12840 precedent] The ADR-0097 refusals ride on the BASE, INSIDE the + // [#12840 precedent] The ADR-0097 refusals ride on the (closed) BASE, INSIDE the // residue stage, for the reason `ObjectPermissionSchema` records: in zod 4 // `.superRefine()` on a `ZodObject` returns a `ZodObject` that keeps // `.shape`, while the same call on the residue PIPE returns a schema with no @@ -1190,7 +1283,7 @@ export const DeclarativeConnectorEntrySchema = lazySchema(() => // authorable-surface / liveness walkers duck-test. Wrapping second also keeps // this door's residue tolerance identical to the base's rather than a second // dialect. - acceptRetiredDefaultResidue(ConnectorBaseSchema.superRefine((entry, ctx) => { + acceptRetiredDefaultResidue(DeclarativeConnectorEntryBaseSchema.superRefine((entry, ctx) => { const isInstance = typeof entry.provider === 'string' && entry.provider.length > 0; // #7990 — the one rule that binds EVERY authored entry, descriptor and // instance alike: `authentication` is the RUNTIME shape (its secret fields From fda28c8856e08354ccace0b81fabe179747766df Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 10:55:00 +0000 Subject: [PATCH 2/4] wip(spec): ADR-0087 D3 entry, ledger re-class, docblock refresh Claude-Session: https://claude.ai/code/session_017xRBcVy4VXmkQjn2MGeaWk Co-authored-by: Claude --- .../integration.md | 2 +- .../2026-07-unknown-key-strictness-ledger.md | 2 +- .../spec/src/integration/connector.zod.ts | 8 ++--- .../src/kernel/metadata-type-schemas.test.ts | 14 ++++++--- ....connector-entry-undeclared-key-refused.ts | 31 +++++++++++++++++++ 5 files changed, 46 insertions(+), 11 deletions(-) create mode 100644 packages/spec/src/migrations/entries/semantic/18.connector-entry-undeclared-key-refused.ts diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/integration.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/integration.md index f7bbc67c53d..7ad938eb5e4 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/integration.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/integration.md @@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate. | Dir | Sites | |---|---| -| `integration/` | 3 | +| `integration/` | 4 | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.md b/docs/audits/2026-07-unknown-key-strictness-ledger.md index b2f6c8c13cc..a170814bdd7 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.md @@ -1375,7 +1375,7 @@ rest at #5107. | `kernel/` | **mixed · `manifest.zod.ts` authorable, the rest wire** | ⚠️ **Split at #14192 — the flat `wire` verdict this row carried was right for the directory and wrong for one file in it, the `api/` row's finding one directory over.** `kernel/manifest.zod.ts` is `manifest:` — the hand-authored identity block of every `objectstack.config.ts`, every `os init` template, every example app, the Studio package form and the `objectstack.plugin.json` that `os plugin build` reads — and it was a plain open `z.object` under a cell that read "code-to-code". Measured first (2026-09-02 at `bd4aa4e49`, from `src` and through the built `dist`, identical readings): an unknown key parsed green and was DROPPED at all seven doors that reach `ManifestSchema` — the stack `manifest:` member (`defineStack` / `os validate` / `os compile`), `devPlugins[]`, the artifact `packages[]` entry in both its authoring and assembled forms, `os plugin build`, the `InstalledPackage` row schema, and the six package request/snapshot wire shapes that embed it. A transposed `namesapce` left `manifest.namespace` undefined at every one of them with exit 0 — and that key decides table names, REST paths and the install-time namespace gate (ADR-0129 D1–D2, ADR-0048 §3.2, ADR-0130 D1/D3). The same silent drop held one level down for `contributes` (`kind:` for `kinds:`), `contributes.kinds[]` and `engines` (`protocl:` for `protocol:`, which switches the load-time protocol handshake off). **The order the `api/` row records was kept: measurement, then the close.** No stored-row caller feeds this schema — `registry.validate('package' \| 'plugin')` has no live caller (`installPackage` stores the caller's object verbatim, `registerPlugin` is uncalled), the durable `sys_packages` row is re-registered through `registerApp` without a parse, and no bookkeeping key (`packageId` / `state` / `published*` / `package`) reaches any door — so the `peelStoredEnvelope` debt the `api/` row had to pay first does not exist here, and no bookkeeping key was taught to the authoring schema. Exactly ONE undeclared key was found at a real door, and it was DECLARED rather than curated: `main`, which `os plugin build` reads off the source `objectstack.plugin.json` to pick its entry and writes back into the compiled manifest as `dist/index.mjs`; three CLI fixtures author it, and a naked `strictObject` would have refused every plugin that names its entry — the shape this row would otherwise have recorded as refused. `ManifestSchema`, `contributes`, `contributes.kinds[]`, `engine` and `PluginEnginesSchema` are `strictObject` now: an undeclared key is a named rejection carrying the surface, the key and a rename (`namesapce` → `namespace`, `protocl` → `protocol`); the retired keys keep their `retiredKey()` tombstones (a tombstone types the key `never` and carries the upgrade text, which a bare refusal would not, and `acceptsNothing` keeps them out of the rename candidates); one curated wrong-layer pointer ships, `specVersion` (the retired CLI advisory axis — the live one is `engines.protocol`). `AssembledPackageBodySchema` (`stack.zod.ts`) inherits the closed posture through `.extend()`, so the load gate refuses an undeclared key on an assembled body too; that file's docblock sentence "NOT `strictObject`: `ManifestSchema` is an open object" is stale as of this change and is reported for its owner rather than edited here. Blast radius, measured with a strict preview before the close: 0 undeclared keys across the four example apps, the three `os init` templates, the `create-objectstack` blank template, the downstream-contract stack, the empty-kernel host and the i18n service manifest; the only shipped-block failure — `packages/cli/src/commands/create.ts`'s template manifest — fails the OPEN schema too (it lacks `id` and `type`) and is reported separately. Cloud is NOT MEASURED for this file (no checkout in the container, code search refused); if cloud parses a published `.osplugin` manifest through this schema, the compiled manifest it receives carries only declared keys (`main` and `integrity` included) by construction of `os plugin build`. The rest of `kernel/` is unchanged: plugin/kernel contracts, code-to-code | | `cloud/` | wire | multi-tenant runtime | | `ai/` | mixed | agent/tool/skill definitions authored (partially strict already); model/provider payloads wire | -| `integration/` | wire | connector payloads — upstream adds fields freely | +| `integration/` | **mixed · `connector.zod.ts`'s declarative entry authorable, the rest wire** | ⚠️ **Split at the connector-entry close — the flat `wire` verdict this row carried was right for the payloads and wrong for one shape, the `api/` and `kernel/` rows' finding again.** `DeclarativeConnectorEntrySchema` is the `connectors:` stack collection and the `connector` row the metadata save door keeps (`PUT /api/v1/meta/connector/:name`, bound in `UNREGISTERED_KIND_SCHEMAS`, which stores the request body for every type but `view` — ADR-0005 appendix (c), ruling B): an AUTHORED, STORED, SERVED type, not an upstream payload, yet it was a plain `z.object`, so an undeclared top-level key parsed green at every door that reads it. It is closed through a dedicated `strictObject` carrier (`DeclarativeConnectorEntryBaseSchema`) built from the same shape factory as the open one, each with its own field instances so `computeGuidanceRoutes()` never pairs the open `Connector` def with the closed declaration's `guidance`. Every door that parses the entry — `defineStack({ connectors })`, `os validate` / `os compile`, the artifact load at boot and reload, and the save door (`422 INVALID_METADATA`) — refuses the key by name, located at it; two curated families ride the refusal: credential-shaped keys → `auth.credentialRef`, provider settings → `providerConfig`. The ADR-0010 envelope was already declared on the shape (commit b5404f496), so no stamped entry is refused. Governing texts read before the close: ADR-0097, ADR-0143, ADR-0104 and the type-schema suite's #2657 fence — none keeps the authored entry open; the `ConnectorSchema is NOT .strict()` sentences across `connector.zod.ts` state the fact that made each retirement a `retiredKey()` tombstone, not a decision to stay open. ⛔ Deliberately NOT closed: `ConnectorSchema`, the def a plugin hands to `registerConnector` (code-to-code — the wire half of this row) and the shape `defineConnector()` parses, so `defineConnector({ … })` still drops an undeclared key before the entry sees it; closing that carrier is its own decision. The rest of `integration/` is unchanged: connector payloads, upstream adds fields freely | | `identity/` | mixed | position/user shapes authored (`PositionSchema` **strict as of #4001 step 2**, with the ADR-0010 envelope declared); auth payloads wire. **34 → 33 in #4641**: `identity.zod.ts` lost its `SessionSchema` site — a second, importerless declaration of a name `api/auth.zod.ts` already owned (the #4411 dual-source trap), deleted rather than reclassified | | `shared/` | **mixed · `protection.zod.ts` and `http.zod.ts`'s rate-limit budget authorable, the rest as written** | ⚠️ **Annotated again at #18578 — a FOURTH instance of the same shape, and this one was invisible to a sweep that went looking for it.** `shared/http.zod.ts`'s `RateLimitConfigSchema` is a building block with two mounts, and this row's rationale — *strictness decided at the consuming schema* — held for exactly one of them: `system/stack-server.zod.ts` re-postured it closed, and it did so by building `strictObject(…, RateLimitConfigSchema.shape)` **from this shape object**, so ONE declaration answered for TWO emitted defs with OPPOSITE doors. `api/endpoint.zod.ts` mounts the same schema BARE on `apis[].rateLimit` — a registered metadata type since #5312, authored through `defineStack({ apis })`, the Studio form and `PUT /meta/api/:name` — where nothing re-postures it, so the two `guidance` entries (`keyBy`, `store`) prescribed to nobody and a misspelled budget was worse: `windowSeconds: 60` parsed green and metered the 60000 ms default, a thousandfold miss reported as success. ⚠️ **The sweep that should have caught it could not.** A `CONTRACT_REVIEW_TIER` review swept this exact class with live controls (5 `.strip()` sites, 0 `z.object(XSchema.shape…)`, 0 `z.object(bareIdentifier)`) and found nothing, because the sharing runs the other way round here: it hunted an OPEN clone built from a STRICT schema's shape, and this is the STRICT one built from the OPEN one's shape — every grep shape in that sweep is blind to that direction by construction. It was found instead by driving the gate's own instrument (`computeGuidanceRoutes()` in `scripts/build-schemas.ts`) over every emitted def: match each def to its declaration, then ask the def what it actually does with the key. Census on `42f8df1723`: 1527 emitted defs, 258 resolving to exactly one declaration, 779 keys promised, **770 delivered, 9 not** — 2 of them this live silent strip, the other 7 union defs whose discriminator the probe's one-key document cannot supply (they DO deliver to an author who writes a whole document). The close moves the strictness and both tables onto the shared schema, leaving ONE declaration and ONE door for both defs: 772 delivered, 7 not. ⚠️ The residual 7 are a PROBE boundary, ⛔ not a clean zero and ⛔ not a finding. ⚠️ **Annotated at #16845 — the flat `n/a` verdict this row carried was right for the directory and wrong for one file in it, the `api/` and `kernel/` rows' finding a third time.** — the flat `n/a` verdict this row carried was right for the directory and wrong for one file in it, the `api/` and `kernel/` rows' finding a third time.** ⛔ **This directory is still UNTRIAGED and this is not a per-file row** — `shared/` has never had per-file rows, so `shared/protection.zod.ts` was **never enumerated** by this campaign; it was not deliberately accepted, because no verdict was ever taken on it. Two measurements say why the silence carried no information. **①** This row's own rationale — *strictness decided at the consuming schema* — is false for this file: all **16** `protection: ProtectionSchema` mounts across **14** files are the bare `ProtectionSchema.optional()`, not one `.extend()` / `.merge()` / `.omit()` / re-posture among them, so both the strictness and the MESSAGE are decided here in `shared/` and inherited verbatim by objects, views, dashboards, datasets, reports, apps, flows, webhooks, permissions, positions, email templates, agents, tools and skills — very nearly every authorable metadata type in the platform. A row is read as licence, which is what this ledger's own gate exists to prevent. **②** Even a per-file row would not have surfaced the defect, and this is the part worth keeping: `ProtectionSchema` has been `.strict()` since it was introduced, so it counts in the `strict` column and could never carry a remaining-strip row. **This ratchet's axis is CLOSURE; the defect was MESSAGE QUALITY** — a closed shape with no error map, refusing `lockk` with zod's bare `Unrecognized key: "lockk"`: no surface, no declared-key list, no rename, on every one of those mounts. `strictObject` at #16845 (the `PluginPermissionsSchema` conversion at #16328 one directory over is the precedent). ⛔ The population of *closed shapes still carrying zod's bare message* is **NOT MEASURED** here and is not this row's claim — #14722's rule stands that a sweep needs its own card with its own measured count. The rest of `shared/` is unchanged: utilities and building blocks whose strictness really is decided at the consuming schema | | `qa/` | n/a | test fixtures | diff --git a/packages/spec/src/integration/connector.zod.ts b/packages/spec/src/integration/connector.zod.ts index 087fe28da50..b279003bc2b 100644 --- a/packages/spec/src/integration/connector.zod.ts +++ b/packages/spec/src/integration/connector.zod.ts @@ -421,8 +421,8 @@ const ERROR_MAPPING_RETIRED = // ============================================================================ // // A bounded (`min(1000).max(300000)`), defaulted (`30000`), `.describe()`d key -// on this schema and — because both published carriers wrap the same private -// `ConnectorBaseSchema` — on +// on this schema and — because both published carriers are built from the same +// private connector shape (`connectorBaseShape`) — on // `DeclarativeConnectorEntrySchema`, so it was authorable from `stack.connectors[]`, // from `PUT /meta/connector/:name`, and served back by `/meta/connector`. Every // signal an authoring surface can give said it worked. @@ -582,7 +582,7 @@ const CONNECTOR_RETIRED_KEY_RESIDUE = { // `ConnectorSchema` is NOT `.strict()`, so a plain delete would be a silent // strip (ADR-0104): each carrier key is a `retiredKey()` tombstone below, // inherited by `DeclarativeConnectorEntrySchema` because both published -// carriers wrap the same private `ConnectorBaseSchema`. The shapes behind them +// carriers are built from the same private `connectorBaseShape`. The shapes behind them // leave whole — `integration/ConnectorHealth`, `integration/HealthCheckConfig`, // `integration/CircuitBreakerConfig`, `integration/ConnectorStatus`, // `integration/WebhookConfig`, `integration/WebhookEvent` and @@ -1035,7 +1035,7 @@ const connectorBaseShape = () => ({ * would be a silent strip (ADR-0104); the tombstone makes the removal audible * in the two channels an upgrading author actually hits — `tsc` and the * parse — and `DeclarativeConnectorEntrySchema` carries it too (both - * published carriers wrap the same private `ConnectorBaseSchema`; until the + * published carriers are built from the same private `connectorBaseShape`; until the * `connectionTimeoutMs` retirement the entry schema was literally * `ConnectorSchema.superRefine(...)`), so `stack.connectors[]` and the * `/meta/connector` door refuse diff --git a/packages/spec/src/kernel/metadata-type-schemas.test.ts b/packages/spec/src/kernel/metadata-type-schemas.test.ts index 135314de568..e5dcb9006d3 100644 --- a/packages/spec/src/kernel/metadata-type-schemas.test.ts +++ b/packages/spec/src/kernel/metadata-type-schemas.test.ts @@ -318,9 +318,12 @@ const UNDECLARED_ENVELOPE_UNREGISTERED = new Set([]); * alone: the #4001 closure campaign and its count (the `describe` below), * `metadata-create-seeds`, `MetadataTypeSchema` membership, * `DEFAULT_METADATA_TYPE_REGISTRY` descriptors. In particular there is - * deliberately NO unknown-key/posture case here — `sharing_rule` is strict and - * `connector` is not, and choosing between them is #2657's decision to make, not - * this suite's. + * deliberately NO unknown-key/posture case here — `sharing_rule` was strict and + * `connector` was not when this fence was drawn, and choosing between them was + * #2657's decision to make, not this suite's. (The connector entry has since + * closed on its own evidence — its pins live in + * `integration/connector-entry-undeclared-keys.test.ts` — and the fence still + * holds: this block asserts the envelope and nothing else.) */ describe('the envelope invariant also covers UNREGISTERED_KIND_SCHEMAS', () => { const bound = listUnregisteredKindSchemaTypes(); @@ -372,8 +375,9 @@ describe('the envelope invariant also covers UNREGISTERED_KIND_SCHEMAS', () => { it.each(bound.filter((t) => !UNDECLARED_ENVELOPE_UNREGISTERED.has(t)))( '%s DECLARES the protection envelope', (type) => { - // `connector`'s class: non-strict + undeclared ⇒ a SILENT strip, no 422, - // nothing in any log. The quieter half, and the one that survived #6245. + // `connector`'s class when this was written: non-strict + undeclared ⇒ a + // SILENT strip, no 422, nothing in any log. The quieter half, and the one + // that survived #6245. const shapes = objectShapes(getMetadataTypeSchema(type)); expect( shapes.some((shape) => '_packageId' in shape), diff --git a/packages/spec/src/migrations/entries/semantic/18.connector-entry-undeclared-key-refused.ts b/packages/spec/src/migrations/entries/semantic/18.connector-entry-undeclared-key-refused.ts new file mode 100644 index 00000000000..568d5dfa01c --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.connector-entry-undeclared-key-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-entry-undeclared-key-refused', + surface: 'connector entries AUTHORED through defineStack `connectors:`, `os validate` / `os compile`, the ' + + 'artifact load and `PUT /meta/connector/:name` — a top-level key the entry does not declare', + replacement: 'keep only the keys `DeclarativeConnectorEntrySchema` declares. A provider setting moves under ' + + '`providerConfig` (`baseUrl` for `rest`, `spec` / `baseUrl` for `openapi`, `transport` for `mcp`); a ' + + 'credential goes into the secrets layer under the name `auth.credentialRef` gives (an environment variable ' + + 'in the open tier, or Setup → Connector credentials) and the key is deleted; free-form notes go in ' + + '`description` or `metadata`.', + reason: + 'The authored entry was a plain object, so an undeclared top-level key parsed green at every door and ' + + 'meant nothing to the runtime: no provider, the automation service or the dispatcher ever read one. It is ' + + 'refused now, by name and located at the key, with a prescription for the two families an author most ' + + 'often misplaces: a credential and a provider setting. There is deliberately no mechanical rewrite: an ' + + 'undeclared key may be a misplaced provider setting whose right home depends on the provider, or a secret ' + + 'whose only copy is the value written there, and where either goes next is a judgement about the ' + + 'connector, not a rename. The runtime connector shape (`ConnectorSchema`, the def a plugin hands to ' + + '`registerConnector`) is unchanged. The metadata read also now withholds the credential parts of a URL ' + + 'under `providerConfig` (userinfo and credential query parameters) and an `auth` or `authentication` ' + + 'value that is not an object.', + acceptanceCriteria: + 'Every authored connector entry parses through `DeclarativeConnectorEntrySchema` with no ' + + '`unrecognized_keys` issue at its root; each misplaced provider setting sits under `providerConfig` and ' + + 'the instance still materializes (`GET /api/v1/automation/connectors` reports it `ready`); each secret ' + + 'that sat at a top-level key is set under its `credentialRef` name; no stored `sys_metadata` connector ' + + 'row reports an undeclared top-level key in its `_diagnostics`.', +}; From 14e25f5ded66818656acc9c3ae42fb61eb03f959 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 11:06:27 +0000 Subject: [PATCH 3/4] wip: authoring-lint graduation, changeset, dogfood pins Claude-Session: https://claude.ai/code/session_017xRBcVy4VXmkQjn2MGeaWk Co-authored-by: Claude --- .../harden-connector-undeclared-keys.md | 34 +++ ...or-entry-and-url-hardening.dogfood.test.ts | 275 ++++++++++++++++++ .../kernel/metadata-authoring-lint.test.ts | 44 ++- 3 files changed, 337 insertions(+), 16 deletions(-) create mode 100644 .changeset/harden-connector-undeclared-keys.md create mode 100644 packages/qa/dogfood/test/connector-meta-door-entry-and-url-hardening.dogfood.test.ts diff --git a/.changeset/harden-connector-undeclared-keys.md b/.changeset/harden-connector-undeclared-keys.md new file mode 100644 index 00000000000..9bc5cd4e886 --- /dev/null +++ b/.changeset/harden-connector-undeclared-keys.md @@ -0,0 +1,34 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec)!: an authored connector entry refuses a top-level key it does not declare; the connector read withholds URL credential parts and a non-object `auth` + +Clause-②: no (narrowing) +Metadata-protocol: narrowed + + + +**BREAKING** accept-set narrowing on an authored connector entry (`DeclarativeConnectorEntrySchema`), at every door that parses it: `defineStack({ connectors })`, `os validate` / `os compile`, the metadata plugin's artifact load at boot and on reload, and the metadata save door `PUT /api/v1/meta/connector/:name`. + +- **Before.** The entry was a plain object: a top-level key it does not declare parsed green and meant nothing to the runtime. +- **Now.** The entry is closed. Such a key is refused by name with a located error (`unrecognized_keys` at the entry; at the save door `422 INVALID_METADATA`). A credential-shaped key (`apiKey`, `token`, `secret`, `password`, …) meets the `auth.credentialRef` prescription, and a provider setting written at the top level (`baseUrl`, `spec`, `transport`, `headers`, …) is pointed at `providerConfig`. The ADR-0010 protection envelope stays declared, so a package-stamped entry still parses. An artifact `os build` wrote carries no undeclared key (it serializes the parsed stack), so only a hand-assembled artifact can meet the refusal at boot. +- **Unchanged.** `ConnectorSchema` — the def a plugin hands to `registerConnector`, and the shape `defineConnector()` parses — keeps its posture. Nothing in the runtime ever read an undeclared entry key, so no connector's behaviour moves. +- **Read half.** The built-in `connector` redactor also withholds, on every read exit that serves a stored body through the metadata-type redactor registry: the userinfo and the credential query parameters of every URL under `providerConfig` (`baseUrl`, `transport.url`, a URL `spec`, and any other URL a provider keeps at object depth), the rest of each URL served byte for byte; and an `auth` or `authentication` value that is not an object, whole. A save of the served form keeps the stored value (the metadata protocol's carry-forward), and the runtime keeps reading the unredacted execution view. +- `redactUrlCredentialQueryParams` (`@objectstack/spec/data`) takes an optional parameter-name list; its default, and every existing caller, is unchanged. + +## FROM → TO + +```ts +// before — parsed green; neither key was read by anything +{ name: 'billing_api', label: 'Billing API', type: 'api', provider: 'rest', + baseUrl: 'https://billing.example.com', apiKey: '…', + auth: { type: 'bearer', credentialRef: 'BILLING_API_TOKEN' } }, + +// after — refused by name; the provider setting moves under providerConfig, the secret moves out of metadata +{ name: 'billing_api', label: 'Billing API', type: 'api', provider: 'rest', + providerConfig: { baseUrl: 'https://billing.example.com' }, + 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 key. There is deliberately no automatic rewrite: an undeclared key may be a provider setting whose home depends on the provider, or the only copy of a secret. The upgrade guide entry is `connector-entry-undeclared-key-refused`. diff --git a/packages/qa/dogfood/test/connector-meta-door-entry-and-url-hardening.dogfood.test.ts b/packages/qa/dogfood/test/connector-meta-door-entry-and-url-hardening.dogfood.test.ts new file mode 100644 index 00000000000..870594c4c02 --- /dev/null +++ b/packages/qa/dogfood/test/connector-meta-door-entry-and-url-hardening.dogfood.test.ts @@ -0,0 +1,275 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// The connector metadata doors, booted on the showcase with its own connector +// plugins — the authored-entry close and two read-path withholdings: +// +// - WRITE: the save door refuses a connector entry carrying a top-level key the +// entry does not declare — a scalar or a nested container — with the +// `422 INVALID_METADATA` envelope, naming the key, and stores nothing. +// - CONTROL: an instance that names its secret with `credentialRef` and keeps +// its provider settings under `providerConfig` saves, materializes, and +// dispatches upstream with the reference resolved. +// - READ: a stored row whose provider URLs carry credential parts, and one +// whose `auth` / `authentication` is not an object (both seeded through the +// system context, the only way such a row is written), is served with those +// parts withheld, to a member and to an administrator, in the item, list and +// layered reads; the stored row itself is never touched. +// - CARRY-FORWARD: a save of the served form keeps the stored URL whole, and +// the instance still dispatches with it. +// +// Every credential below is an obviously-fake placeholder, and every assertion +// on one is its absence from what a door served, or its presence at rest. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { createServer, 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 = { + topLevel: 'TEST_PLACEHOLDER_TOP_LEVEL_VALUE', + nested: 'TEST_PLACEHOLDER_NESTED_VALUE', + urlPassword: 'TEST_PLACEHOLDER_URL_PASSWORD', + urlQuery: 'TEST_PLACEHOLDER_URL_QUERY', + carried: 'TEST_PLACEHOLDER_CARRIED_QUERY', + stringAuth: 'TEST_PLACEHOLDER_STRING_AUTH', + stringAuthentication: 'TEST_PLACEHOLDER_STRING_AUTHENTICATION', + resolvedRef: 'TEST_PLACEHOLDER_RESOLVED_REFERENCE', +} as const; +const ALL_PLACEHOLDERS = Object.values(PLACEHOLDERS).filter((p) => p !== PLACEHOLDERS.resolvedRef); + +/** The reference every instance here names; set in this process's environment before any save. */ +const REF = 'CEU_CONTROL_CREDENTIAL_REF'; +const CONTROL = 'ceu_control'; +const URL_ROW = 'ceu_url_stored'; +const CARRIED = 'ceu_url_carried'; +const NON_OBJECT = 'ceu_non_object_auth'; + +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: undeclared entry keys refused; URL credentials and non-object auth withheld on read', () => { + let stack: VerifyStack; + let admin: string; + let member: string; + let ql: any; + let automation: any; + let upstream: Server; + let upstreamUrl: string; + let upstreamHostPort: string; + const received: string[] = []; + 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: `CEU ${name}`, + type: 'api', + description: 'A provider-bound instance for the connector entry and URL pins.', + provider: 'rest', + providerConfig: { baseUrl: upstreamUrl }, + auth: { type: 'bearer', credentialRef: 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 part`).toBe(false); + return text; + }; + + /** Dispatch the instance's generic `request` action once; the URL the upstream received. */ + const dispatch = async (name: string, path: 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 }, {}); + 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}`); + }; + + /** A clean save makes the row; the system context then writes `body` over it. */ + const seed = async (name: string, body: Record) => { + const put = await stack.apiAs(admin, 'PUT', `/meta/connector/${name}`, instance(name)); + expect(put.status, await put.clone().text()).toBe(200); + const [row] = await stored(name); + await ql.update('sys_metadata', { id: row.id, metadata: JSON.stringify(body) }, { context: SYSTEM }); + }; + + beforeAll(async () => { + upstream = createServer((req, res) => { + received.push(req.url ?? ''); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('{"ok":true}'); + }); + await new Promise((r) => upstream.listen(0, '127.0.0.1', () => r())); + upstreamHostPort = `127.0.0.1:${(upstream.address() as AddressInfo).port}`; + upstreamUrl = `http://${upstreamHostPort}`; + previousRef = process.env[REF]; + process.env[REF] = PLACEHOLDERS.resolvedRef; + + stack = await bootShowcase({}); + ql = await stack.kernel.getServiceAsync('objectql'); + automation = await stack.kernel.getServiceAsync('automation'); + admin = await stack.signIn(); + member = await stack.signUp('ceu-member@connector-entry.test'); + }, 180_000); + + afterAll(async () => { + await stack?.stop?.(); + await new Promise((r) => (upstream ? upstream.close(() => r()) : r())); + if (previousRef === undefined) delete process.env[REF]; + else process.env[REF] = previousRef; + }); + + describe('WRITE: the save door refuses a top-level key the connector entry does not declare', () => { + it.each([ + ['a credential-shaped scalar', 'ceu_refused_scalar', { apiKey: PLACEHOLDERS.topLevel }, 'apiKey', 'credentialRef'], + ['a nested container', 'ceu_refused_nested', { extraConfig: { secret: PLACEHOLDERS.nested } }, 'extraConfig', 'Unrecognized'], + ['a misplaced provider setting', 'ceu_refused_provider', { baseUrl: 'http://127.0.0.1:9/v1' }, 'baseUrl', 'providerConfig'], + ] as const)('%s: 422 INVALID_METADATA naming the key, and nothing is stored', async (_label, name, extra, key, prescription) => { + const res = await stack.apiAs(admin, 'PUT', `/meta/connector/${name}`, { ...instance(name), ...extra }); + 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; code?: string; message?: string }>; + const located = issues.find((i) => i.code === 'unrecognized_keys' && (i.path === '' || i.path === undefined)); + expect(located, JSON.stringify(issues).slice(0, 400)).toBeDefined(); + expect(located!.message).toContain(`\`${key}\``); + expect(located!.message).toContain(prescription); + expect(holdsAPlaceholder(json), 'the refusal never echoes the value').toBe(false); + expect(await stored(name)).toHaveLength(0); + const read = await stack.apiAs(member, 'GET', `/meta/connector/${name}`); + expect(read.status, 'a refused entry is not served to anyone').toBe(404); + }); + }); + + describe('CONTROL: a declared-keys-only instance saves and works', () => { + it('saves, materializes ready, and dispatches with the reference resolved', async () => { + const put = await stack.apiAs(admin, 'PUT', `/meta/connector/${CONTROL}`, instance(CONTROL, { + providerConfig: { baseUrl: `${upstreamUrl}/v1` }, + })); + expect(put.status, await put.clone().text()).toBe(200); + await waitReady(CONTROL); + expect(await dispatch(CONTROL, '/probe')).toBe('/v1/probe'); + }); + }); + + describe('READ: credential parts of a stored URL, and a non-object auth, are withheld from every reader', () => { + beforeAll(async () => { + await seed(URL_ROW, { + ...instance(URL_ROW, { label: 'CEU url (seeded)' }), + providerConfig: { + baseUrl: `http://svc:${PLACEHOLDERS.urlPassword}@${upstreamHostPort}/v1?api_key=${PLACEHOLDERS.urlQuery}®ion=eu`, + spec: `https://svc:${PLACEHOLDERS.urlPassword}@spec.invalid/openapi.json?token=${PLACEHOLDERS.urlQuery}`, + transport: { kind: 'http', url: `https://mcp.invalid/mcp?access_token=${PLACEHOLDERS.urlQuery}` }, + }, + }); + await seed(NON_OBJECT, { + ...instance(NON_OBJECT, { label: 'CEU non-object (seeded)' }), + auth: PLACEHOLDERS.stringAuth, + authentication: PLACEHOLDERS.stringAuthentication, + }); + }); + + it('guard the guard: the stored rows hold every seeded credential part', async () => { + const url = bodyOf((await stored(URL_ROW))[0]); + expect(url.label).toBe('CEU url (seeded)'); + expect(url.providerConfig.baseUrl).toContain(PLACEHOLDERS.urlPassword); + expect(url.providerConfig.baseUrl).toContain(PLACEHOLDERS.urlQuery); + expect(url.providerConfig.spec).toContain(PLACEHOLDERS.urlPassword); + expect(url.providerConfig.transport.url).toContain(PLACEHOLDERS.urlQuery); + const nonObject = bodyOf((await stored(NON_OBJECT))[0]); + expect(nonObject.auth).toBe(PLACEHOLDERS.stringAuth); + expect(nonObject.authentication).toBe(PLACEHOLDERS.stringAuthentication); + }); + + it('the item read and the layered view withhold every part, for a member and for an administrator', async () => { + for (const token of [member, admin]) { + const urlText = await expectWithheld(token, `/meta/connector/${URL_ROW}`, [URL_ROW, 'CEU url (seeded)']); + expect(itemOf(JSON.parse(urlText)).providerConfig).toEqual({ + baseUrl: `http://${upstreamHostPort}/v1?region=eu`, + spec: 'https://spec.invalid/openapi.json', + transport: { kind: 'http', url: 'https://mcp.invalid/mcp' }, + }); + await expectWithheld(token, `/meta/connector/${URL_ROW}/layers`, [URL_ROW]); + + const nonObjectText = await expectWithheld(token, `/meta/connector/${NON_OBJECT}`, [NON_OBJECT, 'CEU non-object (seeded)']); + const served = itemOf(JSON.parse(nonObjectText)); + expect(served).not.toHaveProperty('auth'); + expect(served).not.toHaveProperty('authentication'); + expect(served.provider).toBe('rest'); + await expectWithheld(token, `/meta/connector/${NON_OBJECT}/layers`, [NON_OBJECT]); + } + }); + + it('the list, both spellings, withholds every part, for a member and for an administrator', async () => { + for (const token of [member, admin]) { + await expectWithheld(token, '/meta/connector', [URL_ROW, NON_OBJECT, CONTROL]); + await expectWithheld(token, '/meta/connectors', [URL_ROW, NON_OBJECT, CONTROL]); + } + }); + + it('the stored rows are never redacted: redaction is a serving act', async () => { + expect(bodyOf((await stored(URL_ROW))[0]).providerConfig.baseUrl).toContain(PLACEHOLDERS.urlPassword); + expect(bodyOf((await stored(NON_OBJECT))[0]).auth).toBe(PLACEHOLDERS.stringAuth); + }); + }); + + describe('CARRY-FORWARD: a save of the served form keeps the stored URL, and dispatch still uses it', () => { + it('the served URL drops the credential parameter; the re-saved row keeps it; the upstream receives it', async () => { + const carriedUrl = `${upstreamUrl}/v1?api_key=${PLACEHOLDERS.carried}®ion=eu`; + const put = await stack.apiAs(admin, 'PUT', `/meta/connector/${CARRIED}`, instance(CARRIED, { + providerConfig: { baseUrl: carriedUrl }, + })); + expect(put.status, await put.clone().text()).toBe(200); + await waitReady(CARRIED); + + const read = await stack.apiAs(admin, 'GET', `/meta/connector/${CARRIED}`); + const served = itemOf(await read.json()); + expect(served.providerConfig).toEqual({ baseUrl: `${upstreamUrl}/v1?region=eu` }); + expect(holdsAPlaceholder(served)).toBe(false); + + const body = Object.fromEntries(Object.entries(served).filter(([k]) => !k.startsWith('_'))); + const resave = await stack.apiAs(admin, 'PUT', `/meta/connector/${CARRIED}`, { ...body, label: 'CEU carried (edited)' }); + expect(resave.status, await resave.clone().text()).toBe(200); + const atRest = bodyOf((await stored(CARRIED))[0]); + expect(atRest.label).toBe('CEU carried (edited)'); + expect(atRest.providerConfig.baseUrl).toBe(carriedUrl); + + await waitReady(CARRIED); + // No request path: the rest provider appends a path to the base URL as + // text, so the base URL's own query stays the query only without one. + expect(await dispatch(CARRIED, '')).toBe(`/v1?api_key=${PLACEHOLDERS.carried}®ion=eu`); + }); + }); +}); diff --git a/packages/spec/src/kernel/metadata-authoring-lint.test.ts b/packages/spec/src/kernel/metadata-authoring-lint.test.ts index 53ccea63ebb..38dd7a3f6c7 100644 --- a/packages/spec/src/kernel/metadata-authoring-lint.test.ts +++ b/packages/spec/src/kernel/metadata-authoring-lint.test.ts @@ -81,15 +81,23 @@ describe('coverage derivation (no third hand-written list)', () => { // rather than the campaign advancing. See the block below — `view` moves // into the excluded list, `connectors` is the honest remainder, and the // count stays 1 because the number was never counting what it claimed to. - expect(lintables.length).toBeGreaterThanOrEqual(1); - // `connectors` is the LAST open root. When it closes, this layer has - // nothing left to warn about at a ROOT. That is the campaign finishing, not - // the lint breaking. At that point change the floor to 0 and assert the - // empty set DELIBERATELY — do not delete this test, because an empty result - // that nobody chose is indistinguishable from a derivation that broke. - for (const expected of ['connector']) { - expect(lintableTypes, `expected '${expected}' to be lint-covered`).toContain(expected); - } + // `connectors` was the LAST open root, and it closed: an authored connector + // entry refuses an undeclared top-level key at the parse + // (`integration/connector-entry-undeclared-keys.test.ts`). So this layer + // has nothing left to warn about at a ROOT — the campaign finishing, not + // the lint breaking — and the floor is 0, asserted as the empty set + // DELIBERATELY. The departed root was confirmed REJECTED by the parse + // before the number moved, the check this list asks of every shrink, and + // it is asserted here so a walk that broke cannot pass in its place. + expect(lintables).toEqual([]); + expect(lintableTypes.has('connector')).toBe(false); + const departed = ObjectStackDefinitionSchema.safeParse({ connectors: [{ name: 'c', label: 'C', type: 'api', zzz: 1 }] }); + expect(departed.success, 'the departed connector root is REJECTED by the parse').toBe(false); + expect(JSON.stringify(departed.success ? [] : departed.error.issues)).toContain('"keys":["zzz"]'); + expect( + ObjectStackDefinitionSchema.safeParse({ connectors: [{ name: 'c', label: 'C', type: 'api' }] }).success, + 'control: the entry without the key parses', + ).toBe(true); }); it('reads posture from the schema the PARSE applies, not the type registry', () => { @@ -252,13 +260,15 @@ describe('the object/field unknown-key warnings survive the generalization', () // broken walk cannot hide behind the same empty result). // // The open-collection fixture was `views` until #10039 established that the - // parse had been REFUSING those keys all along; `connectors` is the honest - // remainder, and it is carried here alongside two closed collections so the - // "must not contaminate" property still has something to be true about. + // parse had been REFUSING those keys all along, then `connectors` until its + // root closed too. No open ROOT is left, so the open half is now a nested + // strip site under a closed root — `connectors[].retryConfig`, which the + // walk still visits per node — carried alongside two closed collections so + // the "must not contaminate" property still has something to be true about. const findings = lintAuthoring({ objects: [{ name: 'a', label: 'A', indexes: [{ fields: ['a'], zzz: 1 }] }], views: [{ name: 'v', object: 'a', zzz: 1 }], - connectors: [{ name: 'c', zzz: 1 }], + connectors: [{ name: 'c', retryConfig: { zzz: 1 } }], }); // NOT deduped. This assertion used to run through a `new Set(...)` because // the walk emitted every union-root finding twice (#10039) — a mask over a @@ -266,7 +276,7 @@ describe('the object/field unknown-key warnings survive the generalization', () // is asserted now, so a re-introduced duplicate fails here rather than // being absorbed on the way to the assertion. expect(findings.map((f) => `${f.surface}:${f.path}`).sort()).toEqual([ - 'connector:connectors.c.zzz', + 'connector:connectors.c.retryConfig.zzz', ]); // The hand-off half for the view fixture: not unreported — rejected, by the // strict container that made the old warning a contradiction. @@ -595,13 +605,15 @@ describe('one voice, and a single report per record', () => { // The key is unique to this test: `warnedUnknownAuthoringKeys` is a // MODULE-level set, so a path any other test already warned on would make // this one read zero warnings and pass vacuously. + // A nested strip site (`retryConfig`) since the connector root closed — + // see 'reports across collections in one walk' above. const connectorStack = { manifest: { ...MANIFEST, id: 'com.example.connectors' }, - connectors: [{ name: 'c1', zzz_once_probe_key: 1 }], + connectors: [{ name: 'c1', retryConfig: { zzz_once_probe_key: 1 } }], }; const findings = lintAuthoring(connectorStack); expect(findings.map((f) => `${f.surface}:${f.path}`)).toEqual([ - 'connector:connectors.c1.zzz_once_probe_key', + 'connector:connectors.c1.retryConfig.zzz_once_probe_key', ]); const warned: string[] = []; From 91b48bc4b77feb5981bab7fdd2a8643f4d9268a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 14:13:21 +0000 Subject: [PATCH 4/4] test(cli): probe the package-union key walk at a nested strip site; the connector entry root is closed Claude-Session: https://claude.ai/code/session_017xRBcVy4VXmkQjn2MGeaWk Co-authored-by: Claude --- packages/cli/test/package-union-readers.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/cli/test/package-union-readers.test.ts b/packages/cli/test/package-union-readers.test.ts index 9518fab4796..10157c36b7a 100644 --- a/packages/cli/test/package-union-readers.test.ts +++ b/packages/cli/test/package-union-readers.test.ts @@ -95,7 +95,7 @@ const app = { name: 'unr_app', label: 'Union', navigation: [{ id: 'nav_tickets', const ticketZh = { 'zh-CN': { objects: { unr_ticket: { label: '工单', pluralLabel: '工单', fields: { title: { label: '标题' } } } }, apps: { unr_app: { label: '联合', navigation: { nav_tickets: { label: '工单' } } } } } }; const noteZh = { 'zh-CN': { objects: { unr_note: { label: '笔记', fields: { name: { label: '名称' } } } } } }; -const conn = { name: 'unr_conn', label: 'Conn', type: 'api', zzzUnionProbeKey: 1 }; +const conn = { name: 'unr_conn', label: 'Conn', type: 'api', retryConfig: { zzzUnionProbeKey: 1 } }; `; const CONFIGS: Record = { @@ -121,6 +121,9 @@ export default defineStack({ manifest: appManifest, objects: [ticket, note], app `, // An undeclared connector key. \`strict: false\` keeps it past the producer's // parse, so the doors' own pre-parse walk is the only carrier into \`--json\`. + // It sits one level down, in \`retryConfig\` (a strip-mode shape the walk still + // visits): the connector entry's own root is closed, so a top-level key there + // is refused by the artifact load rather than warned about. keyTwo: `${PIECES} const svc = defineStack({ manifest: svcManifest, objects: [note], connectors: [conn] } as any, { strict: false }); const appStack = defineStack({ manifest: appManifest, objects: [ticket], apps: [app] } as any); @@ -259,6 +262,6 @@ describe('#22238 — the undeclared-authoring-key walk reads the package union', it('`os validate --json` carries the finding in `warnings` for a two-package app', () => { const run = r.validateTwo; expect(run.code, `${run.stdout}\n${run.stderr}`).toBe(0); - expect(named(run)).toEqual([expect.stringContaining('connectors.unr_conn.zzzUnionProbeKey')]); + expect(named(run)).toEqual([expect.stringContaining('connectors.unr_conn.retryConfig.zzzUnionProbeKey')]); }); });