From 60d1c1512b200934535389064236b223f935112e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 00:04:09 +0000 Subject: [PATCH 1/6] feat(spec): schema-derived credential redactor; WebhookSchema marks secret and headers (#24994) Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz Co-authored-by: Claude --- .changeset/24994-webhook-credential-marker.md | 9 + packages/spec/src/automation/webhook.zod.ts | 22 +- .../kernel/metadata-credential-fields.test.ts | 194 ++++++++++++++ .../src/kernel/metadata-credential-fields.ts | 242 ++++++++++++++++++ .../kernel/metadata-type-redaction.test.ts | 93 +++++++ .../src/kernel/metadata-type-redaction.ts | 48 +++- 6 files changed, 598 insertions(+), 10 deletions(-) create mode 100644 .changeset/24994-webhook-credential-marker.md create mode 100644 packages/spec/src/kernel/metadata-credential-fields.test.ts create mode 100644 packages/spec/src/kernel/metadata-credential-fields.ts diff --git a/.changeset/24994-webhook-credential-marker.md b/.changeset/24994-webhook-credential-marker.md new file mode 100644 index 00000000000..2a625c339cd --- /dev/null +++ b/.changeset/24994-webhook-credential-marker.md @@ -0,0 +1,9 @@ +--- +'@objectstack/spec': patch +--- + +spec: `WebhookSchema` declares `secret` (a credential value) and `headers` (a credential map, as a whole) with `.meta({ credential: 'value' | 'map' })`, and `getMetadataTypeRedactor(type)` gains a third resolution — after explicit registrations and built-ins — a redactor derived from the credential markers on the type's registered schema. Every meta read door now withholds a webhook's `secret` and `headers`, with or without `@objectstack/plugin-webhooks` mounted (ruling B+ on #24966). + +Clause-②: no (narrowing) — the `/meta` read doors return less for `webhook`: a stored `secret` or non-empty `headers` map is dropped from the served item and named in `redactedKeys`; the cleared forms (an empty string, an empty map) are served as written. A save that omits the two fields keeps the stored values (the metadata protocol's carry-forward); to remove a credential, save its cleared form. The dispatcher reads the declaration itself, so delivery signing is unchanged. + +A metadata type that stores a credential in a declared field marks it on its schema instead of registering a redactor; a spec ratchet reds on a credential-named field that carries no marker. diff --git a/packages/spec/src/automation/webhook.zod.ts b/packages/spec/src/automation/webhook.zod.ts index f7b4cd3730d..11fc93c17f8 100644 --- a/packages/spec/src/automation/webhook.zod.ts +++ b/packages/spec/src/automation/webhook.zod.ts @@ -193,14 +193,28 @@ export const WebhookSchema = lazySchema(() => strictObject({ url: z.string().url().describe('External webhook endpoint URL'), method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']).default('POST').describe('HTTP method'), - /** Headers */ - headers: z.record(z.string(), z.string()).optional().describe('Custom HTTP headers'), + /** + * Headers. A credential MAP as a whole (`.meta({ credential: 'map' })`): + * the platform cannot tell which entry of an authored header map carries a + * credential, so every meta read door withholds the whole map, and the + * schema-derived redactor in `kernel/metadata-type-redaction.ts` is what + * reads this marker (ruling B+ on #24966). + */ + headers: z.record(z.string(), z.string()).optional().describe('Custom HTTP headers') + .meta({ credential: 'map' }), /** Timeout */ timeoutMs: z.number().int().min(1000).max(300000).default(30000).describe('Request timeout in milliseconds'), - /** Security */ - secret: z.string().optional().describe('Signing secret for HMAC signature verification'), + /** + * Security. A credential VALUE (`.meta({ credential: 'value' })`): every + * meta read door withholds it, derived from this marker by + * `kernel/metadata-type-redaction.ts` (ruling B+ on #24966). The dispatcher + * reads the declaration itself, never a served copy, so delivery signing is + * unaffected. + */ + secret: z.string().optional().describe('Signing secret for HMAC signature verification') + .meta({ credential: 'value' }), /** Status */ isActive: z.boolean().default(true).describe('Whether webhook is active'), diff --git a/packages/spec/src/kernel/metadata-credential-fields.test.ts b/packages/spec/src/kernel/metadata-credential-fields.test.ts new file mode 100644 index 00000000000..5a7360296a3 --- /dev/null +++ b/packages/spec/src/kernel/metadata-credential-fields.test.ts @@ -0,0 +1,194 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ruling B+ on #24966] The fail-closed guard beside the schema-derived + * credential redactor (`metadata-credential-fields.ts`). + * + * A positive marker under-redacts when an author forgets it + * (`data/datasource-credential-redaction.ts` says so of its own `format` + * marker), so this ratchet reads every registered metadata-type schema and + * reds when a field the derivation reaches READS as a credential and carries + * no marker and is on no allow-list. Three more pins keep the marker honest: + * the vocabulary is closed, a `map` marker sits on a record, and no marker sits + * where the derivation cannot reach it. + * + * "Reads as a credential": + * - the field NAME, split into words, contains secret / password / passwd / + * token / signing / credential (or a plural), or `api` followed by `key`; + * - or the first sentence of its description contains one of those words; + * - and the field can hold a credential at all: a string, a record, a union, + * or an unknown. A number, a boolean, an enum or an array of non-strings + * holds no secret — `maxTokens` is a budget, not a token. + */ + +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; + +import { + CREDENTIAL_FIELD_KINDS, + credentialMarkerOf, + isRecordSchema, + walkDeclaredFields, + wrapperChainOf, + zodDefOf, + type DeclaredField, +} from './metadata-credential-fields'; +import { + getMetadataTypeSchema, + listMetadataTypeSchemaTypes, + listUnregisteredKindSchemaTypes, +} from './metadata-type-schemas'; + +/** + * The explicit allow-list: `type:dotted.path` → why the field is NOT a + * credential although it reads as one. Shrink-only in spirit — an entry that + * no longer matches a flagged field reds below, so the list cannot rot. + */ +const NOT_A_CREDENTIAL: Readonly> = { + 'datasource:external.credentialsRef': + 'a reference into the secrets store, never the credential itself (the schema says: never inline credentials)', +}; + +const CREDENTIAL_WORDS = new Set([ + 'secret', 'secrets', 'password', 'passwords', 'passwd', 'token', 'tokens', + 'signing', 'credential', 'credentials', +]); +const DESCRIPTION_RULE = /\b(secrets?|passwords?|passwd|tokens?|signing|credentials?|api[ _-]?keys?)\b/i; +const CAN_HOLD_A_CREDENTIAL = new Set(['string', 'record', 'union', 'unknown', 'any', 'template_literal']); + +const wordsOf = (key: string): string[] => + key.replace(/([a-z0-9])([A-Z])/g, '$1 $2').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean); + +function nameReadsAsCredential(key: string): boolean { + const words = wordsOf(key); + return words.some((w, i) => CREDENTIAL_WORDS.has(w) || w === 'apikey' || (w === 'api' && /^keys?$/.test(words[i + 1] ?? ''))); +} + +function descriptionOf(schema: unknown): string { + for (const node of wrapperChainOf(schema)) { + const read = (node as { meta?: () => { description?: unknown } | undefined }).meta; + const description = typeof read === 'function' ? read.call(node)?.description : undefined; + if (typeof description === 'string' && description !== '') return description; + } + return ''; +} + +const firstSentence = (text: string): string => text.split(/(?<=[.;:])\s|\n/)[0] ?? ''; + +function coreKind(schema: unknown): string | undefined { + const chain = wrapperChainOf(schema); + return zodDefOf(chain[chain.length - 1])?.type; +} + +/** The ratchet's verdict for one reached field: `true` when it reads as a credential. */ +function readsAsCredential(field: DeclaredField): boolean { + if (!CAN_HOLD_A_CREDENTIAL.has(coreKind(field.schema) ?? '')) return false; + const key = field.path[field.path.length - 1] as string; + return nameReadsAsCredential(key) || DESCRIPTION_RULE.test(firstSentence(descriptionOf(field.schema))); +} + +/** Every unmarked, non-allow-listed field of `schema` that reads as a credential, as `type:path`. */ +function unmarkedCredentialFields(type: string, schema: unknown, allow: Readonly>): string[] { + return walkDeclaredFields(schema) + .filter((field) => field.marker === undefined && readsAsCredential(field)) + .map((field) => `${type}:${field.path.join('.')}`) + .filter((id) => !(id in allow)); +} + +/** + * Every position of a `credential` marker anywhere under `schema` — arrays, + * unions, record values, tuples and intersections included — as dotted paths + * with `[]` for a hop the derivation does not take. + */ +function everyMarkerUnder(schema: unknown): string[] { + const found: string[] = []; + const seen = new Set(); + const visit = (node: unknown, path: string): void => { + if (!zodDefOf(node) || seen.has(node)) return; + seen.add(node); + if (path !== '' && credentialMarkerOf(node) !== undefined) found.push(path); + const def = zodDefOf(node) as Record; + if (def.type === 'lazy' && typeof def.getter === 'function') { + visit((def.getter as () => unknown)(), path); + return; + } + if (def.type === 'object' && def.shape && typeof def.shape === 'object') { + for (const [key, field] of Object.entries(def.shape as Record)) visit(field, path ? `${path}.${key}` : key); + } + for (const [prop, child] of Object.entries(def)) { + if (prop === 'shape') continue; + const wrapper = ['innerType', 'in', 'out'].includes(prop); + const children = Array.isArray(child) ? child : [child]; + for (const c of children) if (zodDefOf(c)) visit(c, wrapper ? path : `${path}[]`); + } + }; + visit(schema, ''); + return found; +} + +const ALL_TYPES = [...listMetadataTypeSchemaTypes(), ...listUnregisteredKindSchemaTypes()].sort(); + +describe('[ruling B+ on #24966] ratchet: a field that reads as a credential carries the marker', () => { + it('reads every registered metadata-type schema and the stack-collection kinds (webhook among them)', () => { + expect(ALL_TYPES).toContain('webhook'); + expect(ALL_TYPES).toContain('datasource'); + for (const type of ALL_TYPES) expect(getMetadataTypeSchema(type), type).toBeDefined(); + }); + + it('no registered schema has an unmarked credential-named field off the allow-list', () => { + const findings = ALL_TYPES.flatMap((type) => unmarkedCredentialFields(type, getMetadataTypeSchema(type), NOT_A_CREDENTIAL)); + expect(findings, 'mark each with .meta({ credential: \'value\' | \'map\' }), or allow-list it with a reason').toEqual([]); + }); + + it('every allow-list entry still names a field the rule flags (the list cannot rot)', () => { + const flagged = new Set(ALL_TYPES.flatMap((type) => unmarkedCredentialFields(type, getMetadataTypeSchema(type), {}))); + for (const id of Object.keys(NOT_A_CREDENTIAL)) expect(flagged.has(id), id).toBe(true); + }); + + it('the guard reds on a credential-named field without a marker, and passes once it is marked', () => { + const unmarked = z.object({ name: z.string(), apiKey: z.string().optional(), nested: z.object({ hook: z.string().describe('Signing secret for the hook') }) }); + expect(unmarkedCredentialFields('zz_rule', unmarked, {})).toEqual(['zz_rule:apiKey', 'zz_rule:nested.hook']); + const marked = z.object({ + name: z.string(), + apiKey: z.string().optional().meta({ credential: 'value' }), + nested: z.object({ hook: z.string().describe('Signing secret for the hook').meta({ credential: 'value' }) }), + }); + expect(unmarkedCredentialFields('zz_rule', marked, {})).toEqual([]); + }); + + it('a budget is not a credential: a number named for tokens does not red', () => { + expect(unmarkedCredentialFields('zz_rule', z.object({ maxTokens: z.number() }), {})).toEqual([]); + }); + + it('webhook marks secret as a value and headers as a whole map', () => { + const marked = walkDeclaredFields(getMetadataTypeSchema('webhook')) + .filter((f) => f.marker !== undefined) + .map((f) => [f.path.join('.'), f.marker]); + expect(Object.fromEntries(marked)).toEqual({ headers: 'map', secret: 'value' }); + }); +}); + +describe('[ruling B+ on #24966] the marker is honest', () => { + it('every marker uses the closed vocabulary, and a map marker sits on a record', () => { + for (const type of ALL_TYPES) { + for (const field of walkDeclaredFields(getMetadataTypeSchema(type))) { + if (field.marker === undefined) continue; + const id = `${type}:${field.path.join('.')}`; + expect(CREDENTIAL_FIELD_KINDS as readonly unknown[], id).toContain(field.marker); + expect(isRecordSchema(field.schema), id).toBe(field.marker === 'map'); + } + } + }); + + it('no marker sits where the derivation cannot reach it (inside an array, a union or a record value)', () => { + const unreachable = ALL_TYPES.flatMap((type) => + everyMarkerUnder(getMetadataTypeSchema(type)).filter((p) => p.includes('[]')).map((p) => `${type}:${p}`)); + expect(unreachable).toEqual([]); + }); + + it('the reach probe itself finds a marker the derivation would miss', () => { + const schema = z.object({ items: z.array(z.object({ key: z.string().meta({ credential: 'value' }) })) }); + expect(everyMarkerUnder(schema)).toEqual(['items[].key']); + expect(walkDeclaredFields(schema).filter((f) => f.marker !== undefined)).toEqual([]); + }); +}); diff --git a/packages/spec/src/kernel/metadata-credential-fields.ts b/packages/spec/src/kernel/metadata-credential-fields.ts new file mode 100644 index 00000000000..cbe0ef7fd56 --- /dev/null +++ b/packages/spec/src/kernel/metadata-credential-fields.ts @@ -0,0 +1,242 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Schema-declared credential fields, and the read-path redactor derived from + * them (ruling B+ on #24966, recorded 6304303975). + * + * ## The rule this module carries + * + * Where a per-type behaviour is a function of the declared shape, the FACT is + * declared on the schema and the behaviour is derived once, here — never a + * per-type redaction function. A metadata type marks a field as a credential + * with the repo's existing zod `.meta({ … })` convention: + * + * - `.meta({ credential: 'value' })` — the field's value is one credential + * (`WebhookSchema.secret`). Its cleared form is the empty string. + * - `.meta({ credential: 'map' })` — the field is a map whose entries cannot + * be told apart, so the WHOLE map is the credential + * (`WebhookSchema.headers`). Its cleared form is the empty map. + * + * {@link getMetadataTypeRedactor} (`metadata-type-redaction.ts`) resolves, after + * the explicit registrations and the built-ins, the redactor + * {@link deriveCredentialRedactor} builds from the type's registered schema. + * Any type that marks a field is therefore redacted on every meta read door + * with nothing else to write. + * + * ## Dropped, not masked — and the cleared form served as written + * + * A marked field holding credential material is REMOVED from the served item + * and named in `redactedKeys`, the posture of the `datasource` built-in and of + * the `flow` projection: a mask is a non-blank string a later save would store + * as the literal credential. Absence on the write path means "unchanged" — the + * metadata protocol's generic carry-forward keeps the stored value for a save + * that omits the key — so the cleared form (an empty string, an empty map) is + * the one way to REMOVE a credential, and a stored cleared form is served as + * written and not reported: it holds nothing, and a reader can tell "cleared" + * from "withheld". + * + * ## What the walk reaches, and the guard beside it + * + * The walk reads the type's declared object shape and every nested object + * shape below it (through optional / nullable / default / pipe / lazy + * wrappers). It does not descend into arrays, unions or record values: a + * position there has no single stable path, so a marker placed there could + * not be honoured. `metadata-credential-fields.test.ts` is the fail-closed + * half the datasource module asks for (`datasource-credential-redaction.ts`: + * a positive marker under-redacts when an author forgets it): it reds on any + * credential-named field the walk reaches that carries no marker and is on no + * allow-list, and on any marker the walk cannot reach. + * + * Pure, no I/O, as the `MetadataTypeRedactor` contract requires: the input is + * never mutated, and an item with nothing to withhold is returned by + * reference. + */ + +import type { MetadataRedactionResult, MetadataTypeRedactor } from './metadata-type-redaction'; + +/** The `.meta()` key that declares a field a credential. */ +export const CREDENTIAL_FIELD_META_KEY = 'credential'; + +/** The marker's closed vocabulary: one credential value, or a credential map as a whole. */ +export const CREDENTIAL_FIELD_KINDS = ['value', 'map'] as const; +export type CredentialFieldKind = (typeof CREDENTIAL_FIELD_KINDS)[number]; + +/** One field the walk reached: its item-relative path, its schema, and its marker if any. */ +export interface DeclaredField { + /** Item-relative key path (`['secret']`, `['external', 'credentialsRef']`). */ + path: string[]; + /** The field's schema as declared on its parent shape (wrappers included). */ + schema: unknown; + /** + * The raw `credential` marker value, when one is declared at any wrapper + * level of the field. Not narrowed: the guard pins the vocabulary. + */ + marker: unknown; +} + +/** One marked field, as the derived redactor applies it. */ +export interface CredentialFieldDeclaration { + path: string[]; + kind: CredentialFieldKind; +} + +interface ZodDefLike { + type?: string; + innerType?: unknown; + in?: unknown; + getter?: () => unknown; + shape?: Record; +} + +/** The `_zod.def` of a schema, or `undefined` for anything that is not one. */ +export function zodDefOf(schema: unknown): ZodDefLike | undefined { + if (!schema || (typeof schema !== 'object' && typeof schema !== 'function')) return undefined; + const internals = (schema as { _zod?: { def?: ZodDefLike } })._zod; + return internals?.def; +} + +/** Wrapper kinds whose `innerType` IS the field (a default or an optional changes no shape). */ +const INNER_TYPE_WRAPPERS = new Set(['optional', 'nullable', 'default', 'prefault', 'readonly', 'catch', 'nonoptional']); + +/** + * Every wrapper level of `schema`, outermost first, ending at the first node + * that is not a wrapper. A pipe contributes its INPUT side (what an author + * writes and what is stored); a lazy schema its resolved getter. + */ +export function wrapperChainOf(schema: unknown): unknown[] { + const chain: unknown[] = []; + let node = schema; + for (let hops = 0; hops < 32 && node; hops += 1) { + chain.push(node); + const def = zodDefOf(node); + if (!def) break; + if (def.type && INNER_TYPE_WRAPPERS.has(def.type)) node = def.innerType; + else if (def.type === 'pipe') node = def.in; + else if (def.type === 'lazy' && typeof def.getter === 'function') node = def.getter(); + else break; + } + return chain; +} + +/** The declared object shape under `schema`'s wrappers, or `undefined` when it is not an object. */ +export function objectShapeOf(schema: unknown): Record | undefined { + const chain = wrapperChainOf(schema); + const core = zodDefOf(chain[chain.length - 1]); + return core?.type === 'object' && core.shape && typeof core.shape === 'object' ? core.shape : undefined; +} + +/** The `credential` marker declared at any wrapper level of a field, outermost first. */ +export function credentialMarkerOf(schema: unknown): unknown { + for (const node of wrapperChainOf(schema)) { + const read = (node as { meta?: () => Record | undefined }).meta; + if (typeof read !== 'function') continue; + const meta = read.call(node); + if (meta && Object.prototype.hasOwnProperty.call(meta, CREDENTIAL_FIELD_META_KEY)) { + return meta[CREDENTIAL_FIELD_META_KEY]; + } + } + return undefined; +} + +/** + * Every field the derivation reaches: the declared object shape of `schema` + * and each nested object shape below it. A MARKED field is a leaf — the walk + * does not descend into it. A shape already on the current path is not + * re-entered, so a recursive schema terminates. + */ +export function walkDeclaredFields(schema: unknown): DeclaredField[] { + const out: DeclaredField[] = []; + const visit = (node: unknown, prefix: string[], onPath: Set>): void => { + const shape = objectShapeOf(node); + if (!shape || onPath.has(shape)) return; + onPath.add(shape); + for (const [key, field] of Object.entries(shape)) { + const path = [...prefix, key]; + const marker = credentialMarkerOf(field); + out.push({ path, schema: field, marker }); + if (marker === undefined) visit(field, path, onPath); + } + onPath.delete(shape); + }; + visit(schema, [], new Set()); + return out; +} + +/** + * The marked fields of `schema`, as the derived redactor applies them. A + * marker outside the vocabulary is read as `value` unless the field is a + * record — failing closed: the field is still withheld, and the guard reds on + * the spelling. + */ +export function credentialFieldsOf(schema: unknown): CredentialFieldDeclaration[] { + const fields: CredentialFieldDeclaration[] = []; + for (const field of walkDeclaredFields(schema)) { + if (field.marker === undefined || field.marker === false || field.marker === null) continue; + const kind: CredentialFieldKind = + field.marker === 'map' || (field.marker !== 'value' && isRecordSchema(field.schema)) ? 'map' : 'value'; + fields.push({ path: field.path, kind }); + } + return fields; +} + +/** Whether the field's core schema (under its wrappers) is a `z.record`. */ +export function isRecordSchema(schema: unknown): boolean { + const chain = wrapperChainOf(schema); + return zodDefOf(chain[chain.length - 1])?.type === 'record'; +} + +const isPlainRecord = (v: unknown): v is Record => + !!v && typeof v === 'object' && !Array.isArray(v); + +/** Whether `value` is the field's cleared form: it holds no credential, so it is served as written. */ +function isClearedForm(kind: CredentialFieldKind, value: unknown): boolean { + return kind === 'value' ? 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) }; +} + +/** + * Build the read-path redactor a schema's markers declare, or `undefined` when + * the schema marks nothing — which {@link getMetadataTypeRedactor} answers as + * "no redactor, serve as is". + */ +export function deriveCredentialRedactor(schema: unknown): MetadataTypeRedactor | undefined { + const fields = credentialFieldsOf(schema); + if (fields.length === 0) return undefined; + return (item: Record): MetadataRedactionResult => { + let served = item; + const redactedKeys: string[] = []; + for (const { path, kind } of fields) { + let parent: unknown = served; + for (const key of path.slice(0, -1)) parent = isPlainRecord(parent) ? parent[key] : undefined; + if (!isPlainRecord(parent)) continue; + const leaf = path[path.length - 1] as string; + if (!Object.prototype.hasOwnProperty.call(parent, leaf)) continue; + const value = parent[leaf]; + if (value === undefined || isClearedForm(kind, value)) continue; + served = withoutKeyAt(served, path); + redactedKeys.push(path.join('.')); + } + if (redactedKeys.length === 0) return { item, redactedKeys: [] }; + return { item: served, redactedKeys: redactedKeys.sort() }; + }; +} + +/** One derivation per schema identity: the walk runs once, not on every read. */ +const DERIVED = new WeakMap(); + +/** {@link deriveCredentialRedactor}, memoised on the schema's identity. */ +export function derivedCredentialRedactorFor(schema: unknown): MetadataTypeRedactor | undefined { + if (!schema || (typeof schema !== 'object' && typeof schema !== 'function')) return undefined; + const key = schema as object; + if (!DERIVED.has(key)) DERIVED.set(key, deriveCredentialRedactor(schema) ?? null); + return DERIVED.get(key) ?? undefined; +} diff --git a/packages/spec/src/kernel/metadata-type-redaction.test.ts b/packages/spec/src/kernel/metadata-type-redaction.test.ts index 97d653ed635..2cb5aead3e9 100644 --- a/packages/spec/src/kernel/metadata-type-redaction.test.ts +++ b/packages/spec/src/kernel/metadata-type-redaction.test.ts @@ -29,6 +29,8 @@ import { type MetadataRedactionResult, type MetadataTypeRedactor, } from './metadata-type-redaction'; +import { z } from 'zod'; +import { registerMetadataTypeSchema } from './metadata-type-schemas'; describe('register/lookup round trip (the registry pattern of registerMetadataTypeSchema)', () => { it('a registered redactor is returned for its type', () => { @@ -262,3 +264,94 @@ describe('stored metadata ROWS — the family-wide seam every exit routes throug expect(redactStoredMetadataBody('view', '{broken')).toEqual({ ok: true, body: '{broken' }); }); }); + +describe('[ruling B+ on #24966] the third resolution: a redactor DERIVED from the credential markers on the type\'s schema', () => { + // Obviously-fake placeholders: the assertions name keys, never served values. + const declaredWebhook = () => ({ + name: 'rule_webhook', + label: 'Rule webhook', + object: 'rule_task', + triggers: ['create'], + url: 'https://hooks.example/rule', + method: 'POST', + headers: { 'X-Placeholder': 'TEST_HEADER_VALUE' }, + timeoutMs: 30000, + secret: 'TEST_SECRET', + isActive: true, + _packageId: 'rule.pkg', + }); + + it('webhook resolves a redactor with zero registration calls, and the snapshot lists it', () => { + expect(getMetadataTypeRedactor('webhook')).toBeDefined(); + expect(listMetadataTypeRedactorTypes()).toContain('webhook'); + }); + + it('drops both marked fields, names them in redactedKeys, and leaves every other key intact', () => { + const stored = declaredWebhook(); + const result = getMetadataTypeRedactor('webhook')!(stored); + expect(result.redactedKeys).toEqual(['headers', 'secret']); + expect(Object.prototype.hasOwnProperty.call(result.item, 'secret')).toBe(false); + expect(Object.prototype.hasOwnProperty.call(result.item, 'headers')).toBe(false); + const { secret: _s, headers: _h, ...rest } = stored; + expect(result.item).toEqual(rest); + // Pure: the stored body is never mutated. + expect(stored).toEqual(declaredWebhook()); + }); + + it('serves the cleared forms as written and reports nothing: they hold no credential', () => { + const cleared = { ...declaredWebhook(), secret: '', headers: {} }; + const result = getMetadataTypeRedactor('webhook')!(cleared); + expect(result.redactedKeys).toEqual([]); + expect(result.item).toBe(cleared); + }); + + it('a body without either field runs and finds nothing', () => { + const { secret: _s, headers: _h, ...bare } = declaredWebhook(); + const result = getMetadataTypeRedactor('webhook')!(bare); + expect(result).toEqual({ item: bare, redactedKeys: [] }); + }); + + it('the stored-row seam withholds both, plural spelling included', () => { + const outcome = redactStoredMetadataBody('webhooks', JSON.stringify(declaredWebhook())); + expect(outcome.ok).toBe(true); + const served = JSON.parse((outcome as { ok: true; body: string }).body) as Record; + expect(Object.keys(served)).not.toContain('secret'); + expect(Object.keys(served)).not.toContain('headers'); + }); + + it('an unmarked type derives no redactor: undefined, "serve as is"', () => { + registerMetadataTypeSchema('zz_unmarked_rule_test', z.object({ name: z.string(), note: z.string().optional() })); + expect(getMetadataTypeRedactor('zz_unmarked_rule_test')).toBeUndefined(); + expect(listMetadataTypeRedactorTypes()).not.toContain('zz_unmarked_rule_test'); + }); + + it('walks nested declared object shapes and a value marker inside a wrapper', () => { + registerMetadataTypeSchema('zz_nested_rule_test', z.object({ + name: z.string(), + auth: z.object({ + user: z.string(), + key: z.string().meta({ credential: 'value' }).optional(), + }).optional(), + })); + const result = getMetadataTypeRedactor('zz_nested_rule_test')!({ name: 'n', auth: { user: 'u', key: 'TEST_SECRET' } }); + expect(result).toEqual({ item: { name: 'n', auth: { user: 'u' } }, redactedKeys: ['auth.key'] }); + }); + + it('precedence: an explicit registration wins over the derivation', () => { + registerMetadataTypeSchema('zz_marked_rule_test', z.object({ name: z.string(), token: z.string().meta({ credential: 'value' }) })); + const derived = getMetadataTypeRedactor('zz_marked_rule_test'); + expect(derived).toBeDefined(); + const explicit: MetadataTypeRedactor = (item) => ({ item, redactedKeys: [] }); + registerMetadataTypeRedactor('zz_marked_rule_test', explicit); + expect(getMetadataTypeRedactor('zz_marked_rule_test')).toBe(explicit); + }); + + it('precedence: the datasource built-in is still the datasource redactor', () => { + const result = getMetadataTypeRedactor('datasource')!({ name: 'ds', driver: 'postgres', config: { host: 'h', password: 'TEST_SECRET' } }); + expect(result.redactedKeys).toEqual(['config.password']); + }); + + it('the derivation is memoised per schema identity: one redactor across lookups', () => { + expect(getMetadataTypeRedactor('webhook')).toBe(getMetadataTypeRedactor('webhook')); + }); +}); diff --git a/packages/spec/src/kernel/metadata-type-redaction.ts b/packages/spec/src/kernel/metadata-type-redaction.ts index a6765170c50..2a0199a7fa7 100644 --- a/packages/spec/src/kernel/metadata-type-redaction.ts +++ b/packages/spec/src/kernel/metadata-type-redaction.ts @@ -44,9 +44,33 @@ * (`data/datasource-credential-redaction.ts`), so the honest, non-opt-in * wiring is a built-in entry: present the moment this module loads, on every * composition that can serve a datasource row, with nothing to forget. + * + * ## The third resolution: a redactor DERIVED from the type's schema + * + * Ruling B+ on #24966 (recorded 6304303975): where the redaction is a function + * of the declared shape, the fact is declared on the schema and the behaviour + * is derived once, here. A field marked `.meta({ credential: 'value' })` or + * `.meta({ credential: 'map' })` on a type's registered schema + * ({@link getMetadataTypeSchema}) is dropped from every served item and named + * in `redactedKeys` — `metadata-credential-fields.ts` holds the marker, the + * walk, the cleared forms and the guard that keeps a forgotten marker from + * passing silently. `webhook` is the first type served this way: its items + * reach the meta read doors with or without plugin-webhooks mounted, so a + * registration from that plugin would be the fail-open shape described above, + * 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. */ import { redactDatasourceConfig } from '../data/datasource-credential-redaction'; +import { derivedCredentialRedactorFor } from './metadata-credential-fields'; +import { + getMetadataTypeSchema, + listMetadataTypeSchemaTypes, + listUnregisteredKindSchemaTypes, +} from './metadata-type-schemas'; import { PLURAL_TO_SINGULAR } from '../shared/metadata-collection.zod'; // [#21565] The family set and its predicate: declared in a leaf, re-exported below. // [#21654] So is the one prescription a refusal of the family's reach ends on. @@ -118,12 +142,16 @@ const EXTRA_METADATA_TYPE_REDACTORS = new Map(); * Look up the read-path redactor for a metadata type. * * Returns the user-registered override if any, otherwise the built-in - * redactor. Returns `undefined` for a type with no redactor — which the - * consumer must treat as "serve as-is", never as an error: absence of a - * registration is a fact about the type, not a failure. + * redactor, otherwise the redactor DERIVED from the credential markers on the + * type's registered schema (see the module note). Returns `undefined` for a + * type with none of the three — which the consumer must treat as "serve + * as-is", never as an error: absence of a redactor is a fact about the type, + * not a failure. */ export function getMetadataTypeRedactor(type: string): MetadataTypeRedactor | undefined { - return EXTRA_METADATA_TYPE_REDACTORS.get(type) ?? BUILTIN_METADATA_TYPE_REDACTORS[type]; + return EXTRA_METADATA_TYPE_REDACTORS.get(type) + ?? BUILTIN_METADATA_TYPE_REDACTORS[type] + ?? derivedCredentialRedactorFor(getMetadataTypeSchema(type)); } /** @@ -141,16 +169,24 @@ export function getMetadataTypeRedactor(type: string): MetadataTypeRedactor | un * plugin (the #8300 datasource measurement), the redactor belongs in * `BUILTIN_METADATA_TYPE_REDACTORS` in this package instead — a redaction * that looks installed but isn't loaded is the worst available outcome, - * because it reads as protected. + * because it reads as protected. When the credentials are declared fields of + * the type's schema, mark them there (`.meta({ credential: … })`) and write + * no redactor at all: the derivation covers every composition. */ export function registerMetadataTypeRedactor(type: string, redactor: MetadataTypeRedactor): void { EXTRA_METADATA_TYPE_REDACTORS.set(type, redactor); } -/** Snapshot of every type that currently has a redactor (built-in + extras), sorted. */ +/** + * Snapshot of every type that currently has a redactor (built-in, extras, and + * the types whose schema derives one), sorted. + */ export function listMetadataTypeRedactorTypes(): string[] { const types = new Set(Object.keys(BUILTIN_METADATA_TYPE_REDACTORS)); for (const t of EXTRA_METADATA_TYPE_REDACTORS.keys()) types.add(t); + for (const t of [...listMetadataTypeSchemaTypes(), ...listUnregisteredKindSchemaTypes()]) { + if (derivedCredentialRedactorFor(getMetadataTypeSchema(t))) types.add(t); + } return Array.from(types).sort(); } From f65b0c9bc175d26fd5f4fdbbb904aa9597a5da6d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 00:04:52 +0000 Subject: [PATCH 2/6] chore(changeset): ADR-0087 disposition for #24994 Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz Co-authored-by: Claude --- .changeset/24994-webhook-credential-marker.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.changeset/24994-webhook-credential-marker.md b/.changeset/24994-webhook-credential-marker.md index 2a625c339cd..6aa6814d7a5 100644 --- a/.changeset/24994-webhook-credential-marker.md +++ b/.changeset/24994-webhook-credential-marker.md @@ -7,3 +7,5 @@ spec: `WebhookSchema` declares `secret` (a credential value) and `headers` (a cr Clause-②: no (narrowing) — the `/meta` read doors return less for `webhook`: a stored `secret` or non-empty `headers` map is dropped from the served item and named in `redactedKeys`; the cleared forms (an empty string, an empty map) are served as written. A save that omits the two fields keeps the stored values (the metadata protocol's carry-forward); to remove a credential, save its cleared form. The dispatcher reads the declaration itself, so delivery signing is unchanged. A metadata type that stores a credential in a declared field marks it on its schema instead of registering a redactor; a spec ratchet reds on a credential-named field that carries no marker. + + From 4e876386fbc2b5a1ee593b14fddddc2f937be80e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 00:12:30 +0000 Subject: [PATCH 3/6] test(dogfood): webhook meta read doors withhold declared credentials in both compositions (#24994) Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz Co-authored-by: Claude --- ...-door-credential-redaction.dogfood.test.ts | 220 ++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 packages/qa/dogfood/test/webhook-meta-door-credential-redaction.dogfood.test.ts diff --git a/packages/qa/dogfood/test/webhook-meta-door-credential-redaction.dogfood.test.ts b/packages/qa/dogfood/test/webhook-meta-door-credential-redaction.dogfood.test.ts new file mode 100644 index 00000000000..f12fbbd3a90 --- /dev/null +++ b/packages/qa/dogfood/test/webhook-meta-door-credential-redaction.dogfood.test.ts @@ -0,0 +1,220 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [ruling B+ on #24966, #24994] Every meta read door withholds a webhook's +// declared credentials — `secret` (a credential value) and `headers` (a +// credential map, as a whole) — in BOTH compositions: without +// `@objectstack/plugin-webhooks`, and with it mounted. +// +// The rule under test: `WebhookSchema` marks the two fields with +// `.meta({ credential: … })`, and `getMetadataTypeRedactor('webhook')` +// resolves a redactor DERIVED from those markers (`@objectstack/spec/kernel`), +// present from module load whatever the composition mounts. A webhook's items +// reach the meta read doors identically with and without the plugin (declared +// ones through manifest decomposition, saved ones through the meta save door), +// so a plugin registration would protect only one of the two compositions; +// this file boots both. +// +// Doors pinned, per composition: the meta list (both spellings), the item read, +// the layered view, the published read, the metadata protocol's list and item +// reads, and the stored-row data door with its history twin. Each door read +// carries a positive control (the response names the webhook), so a refusal or +// an empty answer cannot pass for a withheld credential. +// +// Also pinned: the stored record is never mutated (redaction is a serving +// act); a save of the served form keeps the stored values (the metadata +// protocol's carry-forward); and, with the plugin mounted, the dispatcher's +// declared-webhook source still carries both credentials, so delivery signing +// is unaffected. +// +// Disclosure: every credential below is an obviously-fake placeholder, and +// every assertion is absence by rule, never a served value. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { MessagingServicePlugin } from '@objectstack/service-messaging'; +import { WebhookOutboxPlugin } from '@objectstack/plugin-webhooks'; +import { defineStack } from '@objectstack/spec'; +import { ObjectSchema, Field } from '@objectstack/spec/data'; +import { defineWebhook } from '@objectstack/spec/automation'; + +const SYSTEM = { isSystem: true, positions: [], permissions: [] }; + +/** Obviously-fake placeholders: absence of these strings is the whole assertion. */ +const DECLARED_SECRET = 'TEST_SECRET_DECLARED_24994'; +const DECLARED_HEADER = 'TEST_HEADER_DECLARED_24994'; +const SAVED_SECRET = 'TEST_SECRET_SAVED_24994'; +const SAVED_HEADER = 'TEST_HEADER_SAVED_24994'; +const PLACEHOLDERS = [DECLARED_SECRET, DECLARED_HEADER, SAVED_SECRET, SAVED_HEADER] as const; + +const DECLARED = 'wcr_declared'; +const SAVED = 'wcr_saved'; + +const WcrTask = ObjectSchema.create({ + name: 'wcr_task', + sharingModel: 'public_read_write', + label: 'WCR Task', + pluralLabel: 'WCR Tasks', + fields: { name: Field.text({ label: 'Name', required: true }) }, +}); + +const fixture = defineStack({ + manifest: { + id: 'com.dogfood.webhook-credential-redaction', + namespace: 'wcr', + version: '0.0.0', + type: 'app', + name: 'Webhook Credential Redaction Fixture', + description: 'One object and one declared webhook carrying both declared credential fields, for the meta read-door pins of ruling B+ on #24966.', + }, + objects: [WcrTask], + webhooks: [ + defineWebhook({ + name: DECLARED, + label: 'WCR Declared', + object: 'wcr_task', + triggers: ['create'], + url: 'https://hooks.example/wcr-declared', + headers: { 'X-Placeholder': DECLARED_HEADER }, + secret: DECLARED_SECRET, + isActive: false, + }), + ], +}); + +const savedBody = () => ({ + name: SAVED, + label: 'WCR Saved', + object: 'wcr_task', + triggers: ['update'], + url: 'https://hooks.example/wcr-saved', + headers: { 'X-Placeholder': SAVED_HEADER }, + secret: SAVED_SECRET, + isActive: false, +}); + +const rowsOf = (r: any): any[] => (Array.isArray(r) ? r : Array.isArray(r?.records) ? r.records : Array.isArray(r?.value) ? r.value : []); +const holdsAPlaceholder = (v: unknown): boolean => { + const text = typeof v === 'string' ? v : JSON.stringify(v ?? null); + return PLACEHOLDERS.some((p) => text.includes(p)); +}; +const bodyOf = (row: any): Record => + typeof row?.metadata === 'string' ? JSON.parse(row.metadata) : (row?.metadata ?? {}); + +const COMPOSITIONS = [ + { label: 'without plugin-webhooks', plugins: () => [] as unknown[] }, + { label: 'with plugin-webhooks mounted', plugins: () => [new MessagingServicePlugin(), new WebhookOutboxPlugin()] }, +] as const; + +for (const composition of COMPOSITIONS) { + describe(`[ruling B+] every meta read door withholds a webhook's declared credentials — ${composition.label}`, () => { + let stack: VerifyStack; + let admin: string; + let member: string; + let ql: any; + let protocol: any; + + const stored = async (object: 'sys_metadata' | 'sys_metadata_history', name: string) => + rowsOf(await ql.find(object, { where: { type: 'webhook', name }, context: SYSTEM })); + + /** One door read: 200, names the webhook (positive control), and holds no credential. */ + 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 the declared credential fields`).toBe(false); + }; + + beforeAll(async () => { + stack = await bootStack(fixture as never, { extraPlugins: composition.plugins() as never }); + ql = await stack.kernel.getServiceAsync('objectql'); + protocol = await stack.kernel.getServiceAsync('protocol'); + admin = await stack.signIn(); + member = await stack.signUp(`wcr-member-${composition.plugins().length}@redaction.test`); + const put = await stack.apiAs(admin, 'PUT', `/meta/webhook/${SAVED}`, savedBody()); + expect(put.status, await put.clone().text()).toBe(200); + }, 180_000); + + afterAll(async () => { + await stack?.stop?.(); + }); + + it('guard the guard: the saved webhook is stored with both credential fields, and the plugin is mounted exactly when this composition says so', async () => { + const [row] = await stored('sys_metadata', SAVED); + expect(bodyOf(row).secret).toBe(SAVED_SECRET); + expect(bodyOf(row).headers).toEqual({ 'X-Placeholder': SAVED_HEADER }); + let declaredService: unknown; + try { + declaredService = stack.kernel.getService('declaredWebhooks'); + } catch { + declaredService = undefined; + } + expect(Boolean(declaredService)).toBe(composition.plugins().length > 0); + }); + + it('the meta list, both spellings, withholds both fields of the declared and the saved webhook', async () => { + for (const token of [admin, member]) { + await expectWithheld(token, '/meta/webhook', [DECLARED, SAVED]); + await expectWithheld(token, '/meta/webhooks', [DECLARED, SAVED]); + } + }); + + it('the item read and the layered view withhold both fields', async () => { + for (const token of [admin, member]) { + for (const name of [DECLARED, SAVED]) { + await expectWithheld(token, `/meta/webhook/${name}`, [name]); + await expectWithheld(token, `/meta/webhook/${name}/layers`, [name]); + } + } + }); + + it('the published read withholds both fields', async () => { + for (const token of [admin, member]) await expectWithheld(token, `/meta/webhook/${SAVED}/published`, [SAVED]); + }); + + it('the metadata protocol\'s list and item reads withhold both fields', async () => { + const listed = await protocol.getMetaItems({ type: 'webhook' }); + expect(JSON.stringify(listed).includes(SAVED)).toBe(true); + expect(holdsAPlaceholder(listed)).toBe(false); + for (const name of [DECLARED, SAVED]) { + const item = await protocol.getMetaItem({ type: 'webhook', name }); + expect(JSON.stringify(item).includes(name), name).toBe(true); + expect(holdsAPlaceholder(item), name).toBe(false); + } + }); + + it('the stored-row data door and its history twin withhold both fields', async () => { + const filter = encodeURIComponent(JSON.stringify({ type: 'webhook', name: SAVED })); + await expectWithheld(admin, `/data/sys_metadata?filter=${filter}`, [SAVED]); + await expectWithheld(admin, `/data/sys_metadata_history?filter=${filter}`, [SAVED]); + }); + + it('a save of the served form keeps the stored values (the carry-forward), and the store is never redacted', async () => { + const read = await stack.apiAs(admin, 'GET', `/meta/webhook/${SAVED}`); + expect(read.status).toBe(200); + const json = (await read.json()) as any; + const served = (json?.item ?? json?.data?.item ?? json?.data ?? json) as Record; + expect(served?.name).toBe(SAVED); + const body = Object.fromEntries(Object.entries(served).filter(([k]) => !k.startsWith('_'))); + expect(Object.keys(body)).not.toContain('secret'); + expect(Object.keys(body)).not.toContain('headers'); + + const put = await stack.apiAs(admin, 'PUT', `/meta/webhook/${SAVED}`, { ...body, label: 'WCR Saved (edited)' }); + expect(put.status, await put.clone().text()).toBe(200); + + const [row] = await stored('sys_metadata', SAVED); + expect(bodyOf(row).label).toBe('WCR Saved (edited)'); + expect(bodyOf(row).secret).toBe(SAVED_SECRET); + expect(bodyOf(row).headers).toEqual({ 'X-Placeholder': SAVED_HEADER }); + }); + + if (composition.plugins().length > 0) { + it('the dispatcher\'s declared-webhook source still carries both credentials: delivery signing is unaffected', async () => { + const declaredWebhooks = (await stack.kernel.getServiceAsync('declaredWebhooks')) as any; + const declared = await declaredWebhooks.declaredWebhookNamed(DECLARED); + expect(declared?.webhook?.secret).toBe(DECLARED_SECRET); + expect(declared?.webhook?.headers).toEqual({ 'X-Placeholder': DECLARED_HEADER }); + }); + } + }); +} From 441ce0128d925be7526e7a3fee607ec3e040236f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 00:28:04 +0000 Subject: [PATCH 4/6] refactor(spec): one-line credential markers on WebhookSchema Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz Co-authored-by: Claude --- packages/spec/src/automation/webhook.zod.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/spec/src/automation/webhook.zod.ts b/packages/spec/src/automation/webhook.zod.ts index 11fc93c17f8..2cb01878e38 100644 --- a/packages/spec/src/automation/webhook.zod.ts +++ b/packages/spec/src/automation/webhook.zod.ts @@ -200,8 +200,7 @@ export const WebhookSchema = lazySchema(() => strictObject({ * schema-derived redactor in `kernel/metadata-type-redaction.ts` is what * reads this marker (ruling B+ on #24966). */ - headers: z.record(z.string(), z.string()).optional().describe('Custom HTTP headers') - .meta({ credential: 'map' }), + headers: z.record(z.string(), z.string()).optional().describe('Custom HTTP headers').meta({ credential: 'map' }), /** Timeout */ timeoutMs: z.number().int().min(1000).max(300000).default(30000).describe('Request timeout in milliseconds'), @@ -213,8 +212,7 @@ export const WebhookSchema = lazySchema(() => strictObject({ * reads the declaration itself, never a served copy, so delivery signing is * unaffected. */ - secret: z.string().optional().describe('Signing secret for HMAC signature verification') - .meta({ credential: 'value' }), + secret: z.string().optional().describe('Signing secret for HMAC signature verification').meta({ credential: 'value' }), /** Status */ isActive: z.boolean().default(true).describe('Whether webhook is active'), From 0cf6eed5651022fe1d691a1e7c753c95c432c685 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 01:49:53 +0000 Subject: [PATCH 5/6] fix(spec): the credential walk reads a schema's public def, so an instrumented lazy schema derives instead of throwing (#24994) Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz Co-authored-by: Claude --- .../spec/src/kernel/metadata-credential-fields.ts | 12 +++++++++++- .../spec/src/kernel/metadata-type-redaction.test.ts | 10 +++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/spec/src/kernel/metadata-credential-fields.ts b/packages/spec/src/kernel/metadata-credential-fields.ts index cbe0ef7fd56..d9d6270916a 100644 --- a/packages/spec/src/kernel/metadata-credential-fields.ts +++ b/packages/spec/src/kernel/metadata-credential-fields.ts @@ -88,9 +88,19 @@ interface ZodDefLike { shape?: Record; } -/** The `_zod.def` of a schema, or `undefined` for anything that is not one. */ +/** + * The definition of a schema, or `undefined` for anything that is not one. + * + * Read through the public `.def` first, and `_zod.def` only for a core schema + * that has no `.def`: a `lazySchema` Proxy reports `_zod` as a non-configurable + * own property its function target does not have, so a second Proxy wrapped + * around it (a test that instruments a registered schema does exactly this) + * throws a Proxy invariant TypeError on any `_zod` read, while `.def` resolves. + */ export function zodDefOf(schema: unknown): ZodDefLike | undefined { if (!schema || (typeof schema !== 'object' && typeof schema !== 'function')) return undefined; + const publicDef = (schema as { def?: ZodDefLike }).def; + if (publicDef && typeof publicDef === 'object' && typeof publicDef.type === 'string') return publicDef; const internals = (schema as { _zod?: { def?: ZodDefLike } })._zod; return internals?.def; } diff --git a/packages/spec/src/kernel/metadata-type-redaction.test.ts b/packages/spec/src/kernel/metadata-type-redaction.test.ts index 2cb5aead3e9..838997dd748 100644 --- a/packages/spec/src/kernel/metadata-type-redaction.test.ts +++ b/packages/spec/src/kernel/metadata-type-redaction.test.ts @@ -30,7 +30,7 @@ import { type MetadataTypeRedactor, } from './metadata-type-redaction'; import { z } from 'zod'; -import { registerMetadataTypeSchema } from './metadata-type-schemas'; +import { getMetadataTypeSchema as getMetadataTypeSchemaForTest, registerMetadataTypeSchema } from './metadata-type-schemas'; describe('register/lookup round trip (the registry pattern of registerMetadataTypeSchema)', () => { it('a registered redactor is returned for its type', () => { @@ -351,6 +351,14 @@ describe('[ruling B+ on #24966] the third resolution: a redactor DERIVED from th expect(result.redactedKeys).toEqual(['config.password']); }); + it('derives through a registered schema wrapped in a second Proxy (an instrumented lazy schema)', () => { + const webhook = getMetadataTypeSchemaForTest('webhook'); + const wrapped = new Proxy(webhook as object, { get: (t, p, r) => Reflect.get(t, p, r) }); + registerMetadataTypeSchema('zz_proxied_rule_test', wrapped as never); + const result = getMetadataTypeRedactor('zz_proxied_rule_test')!(declaredWebhook()); + expect(result.redactedKeys).toEqual(['headers', 'secret']); + }); + it('the derivation is memoised per schema identity: one redactor across lookups', () => { expect(getMetadataTypeRedactor('webhook')).toBe(getMetadataTypeRedactor('webhook')); }); From e86f32235fc03bc032d3b1aaf271e23feaad69b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 03:15:32 +0000 Subject: [PATCH 6/6] chore(changeset): #24994 ships minor with a BREAKING banner (a narrowing in the launch window) Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz Co-authored-by: Claude --- .changeset/24994-webhook-credential-marker.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.changeset/24994-webhook-credential-marker.md b/.changeset/24994-webhook-credential-marker.md index 6aa6814d7a5..f4485026446 100644 --- a/.changeset/24994-webhook-credential-marker.md +++ b/.changeset/24994-webhook-credential-marker.md @@ -1,8 +1,10 @@ --- -'@objectstack/spec': patch +'@objectstack/spec': minor --- -spec: `WebhookSchema` declares `secret` (a credential value) and `headers` (a credential map, as a whole) with `.meta({ credential: 'value' | 'map' })`, and `getMetadataTypeRedactor(type)` gains a third resolution — after explicit registrations and built-ins — a redactor derived from the credential markers on the type's registered schema. Every meta read door now withholds a webhook's `secret` and `headers`, with or without `@objectstack/plugin-webhooks` mounted (ruling B+ on #24966). +spec!: `WebhookSchema` declares `secret` (a credential value) and `headers` (a credential map, as a whole) with `.meta({ credential: 'value' | 'map' })`, and `getMetadataTypeRedactor(type)` gains a third resolution — after explicit registrations and built-ins — a redactor derived from the credential markers on the type's registered schema. Every meta read door now withholds a webhook's `secret` and `headers`, with or without `@objectstack/plugin-webhooks` mounted (ruling B+ on #24966). + +**BREAKING:** the `/meta` read doors no longer serve a webhook's `secret` or `headers`. Clause-②: no (narrowing) — the `/meta` read doors return less for `webhook`: a stored `secret` or non-empty `headers` map is dropped from the served item and named in `redactedKeys`; the cleared forms (an empty string, an empty map) are served as written. A save that omits the two fields keeps the stored values (the metadata protocol's carry-forward); to remove a credential, save its cleared form. The dispatcher reads the declaration itself, so delivery signing is unchanged.