Skip to content

Commit e939357

Browse files
spec: WebhookSchema marks secret and headers as credential fields; the redactor registry derives meta read-door redaction from the schema (ruling B+ on #24966) (#25036)
Closes #24994 Refs #24966 Clause-②: no (narrowing) ## What the ruling binds (B+ on #24966, record 6304303975) Quoted from the ruling record, items as recorded: > 1. **The marker, in spec.** `WebhookSchema.secret` and `WebhookSchema.headers` are declared credential fields with a schema-level marker (zod `.meta({ credential: … })`, the repo's existing `.meta` convention), `headers` as a whole map. The fact "this field is a credential" lives on the declaration, once. > 2. **The derivation, once, in the kernel registry.** `getMetadataTypeRedactor(type)` resolves, after the explicit registrations and built-ins, a redactor DERIVED from the type's registered schema: every marked field is dropped (not masked) and served as its cleared form, and `redactedKeys` names them. Any metadata type that marks a field is redacted on every meta read door with nothing else to write. `webhook` is the first type served this way. > 3. **The guard.** A spec ratchet test reads every registered metadata-type schema and fails when a field whose name or description reads as a credential (secret, password, token, signing, api key, credential) carries no marker and sits on no explicit allow-list — the fail-closed half the datasource module asked for. > 4. **Unchanged from B:** the redaction form (`secret` and the whole `headers` map dropped, cleared forms served), the write-path carry-forward accepted, delivery signing and door reach untouched. `datasource` and `flow` keep their explicit redactors (they take precedence over the derivation) and are not migrated. > 5. **Standing word, recorded here:** 「类似的问题都可以这样处理」 — where a per-type behaviour is a function of the declared shape, declare the fact on the schema and derive the behaviour once in the kernel, rather than writing a per-type function. Seats cite this ruling when the same choice recurs. Disclosure: the maintainer's ruling on #9387 (`5323310017`) applies. This PR carries location, rule and abstract reach only. ## What changed - **The marker** (`packages/spec/src/automation/webhook.zod.ts`): `secret` carries `.meta({ credential: 'value' })` and `headers` carries `.meta({ credential: 'map' })`. Nothing else about the schema changes: no key is added, removed or re-typed. The marker key is `credential` and its vocabulary is closed: `value` (one credential, cleared form the empty string) and `map` (a map whose entries cannot be told apart, so the whole map is the credential, cleared form the empty map). - **The derivation** (`packages/spec/src/kernel/metadata-credential-fields.ts`, new, not re-exported from the `kernel` barrel): walks the type's declared object shape and each nested object shape below it (through optional, nullable, default, pipe and lazy wrappers), collects the marked fields, and builds a pure redactor. It is memoised per schema identity, so the walk runs once per schema, not per read. - **The third resolution** (`packages/spec/src/kernel/metadata-type-redaction.ts`): `getMetadataTypeRedactor(type)` resolves explicit registration, then built-in, then the redactor derived from `getMetadataTypeSchema(type)`. `listMetadataTypeRedactorTypes()` now includes the types whose schema derives one. The module note records the ruling and the precedence. - **The guard** (`packages/spec/src/kernel/metadata-credential-fields.test.ts`): the ratchet, described below. - **Door pins** (`packages/qa/dogfood/test/webhook-meta-door-credential-redaction.dogfood.test.ts`), in both compositions. - **Changeset** `.changeset/24994-webhook-credential-marker.md`: `@objectstack/spec` **minor**, with a **BREAKING** banner and a `spec!:` headline. The declaration is `Clause-②: no (narrowing)`, and the ADR-0087 disposition is `not-required (no-migration-prescription)`. During the launch window a narrowing ships as `minor`, not `patch`. That rule comes from `scripts/check-changeset-no-major.mjs` (maintainer ruling 2026-09-04, batch #35, on #15294) and outranks the card's `patch` workload line. Every `no (narrowing)` changeset on main is graded `minor`, and this one now is too. ### The served form, read precisely A marked field that holds credential material is **removed** from the served item and named in `redactedKeys`. A stored cleared form (an empty-string `secret`, an empty `headers` map) holds nothing, so it is served as written and not reported. This is how the governing precedents put it: the `flow` projection's "Dropped, not masked", and the dev report's option A ("keys removed, not masked; serve the two cleared forms as written … so a reader tells 'cleared' from 'withheld'"). It is also what ruling item 5 of the card needs: the carry-forward keeps the stored values for a save that **omits** the two fields. If the cleared form were put in place of the withheld value, a save of the served body would be indistinguishable from a deliberate clear. A reviewer who reads card item 2 ("serves its cleared form") as "substitute the cleared form" should note that this reading was weighed and not taken, for that reason. ### The ratchet, and what "reads as a credential" means - The schema set is `listMetadataTypeSchemaTypes()` **plus** `listUnregisteredKindSchemaTypes()`. `webhook` is a stack-collection kind bound in `UNREGISTERED_KIND_SCHEMAS`, not in the registered-kind list, so the first list alone would not read the one type this card is about. `getMetadataTypeSchema('webhook')` resolves through that map, and the derivation reads it from there. - A field the walk reaches reads as a credential when its name, split into words, contains secret / password / passwd / token / signing / credential (or a plural), or `api` then `key`; or when the first sentence of its description contains one of those words. Only fields that can hold a credential count: a string, a record, a union, an unknown. A number, boolean, enum or array cannot hold a secret, and without this limit token budgets would be flagged. - Allow-list: one entry, `datasource:external.credentialsRef`. It is a reference into the secrets store, never the credential itself. A pin fails if an entry stops matching a field the rule flags, so the list cannot rot. - Two more pins keep the marker honest: every marker uses the closed vocabulary and a `map` marker sits on a record; and no marker sits where the derivation cannot reach it (inside an array, a union or a record value). A probe test checks that the second pin can see such a marker. - A synthetic case shows the ratchet fails on an unmarked credential-named field (top-level and nested) and passes once that field is marked. ### Unchanged (ruling item 5 of the card) - **The write-path carry-forward** still keys on "a redactor resolves for the type". A save of the served form keeps the stored values; the dogfood file pins it in both compositions. - **The dispatcher's declared-webhook source** reads the registry's raw declaration and re-parses it, never a served copy. Pinned with plugin-webhooks mounted: both credentials are still present there, so delivery signing is unaffected. - **Who may reach a meta read door** does not change. - **plugin-webhooks** registers nothing. - **`datasource` and `flow`** keep their explicit redactors, which take precedence. A unit test pins that an explicit registration wins over the derivation and that the datasource built-in still resolves. ## Door pins (both compositions: without plugin-webhooks, and with it mounted) Each pin reads one door and asserts three things by rule: the read answers 200, it names the webhook (a positive control, so a refusal or an empty answer cannot pass), and it holds neither declared credential field. The doors: - the meta list, both spellings - the item read - the layered view - the published read - the metadata protocol's list and item reads - the stored-row data door and its history twin Meta doors are read as an administrator and as a signed-in member. The data door is read as an administrator, the only caller who can reach it. The fixture's credentials are obviously-fake placeholders. ## Verification (head `0cf6eed565`) - `@objectstack/spec`: `pnpm --filter @objectstack/spec test` green (run at `441ce0128d`; the later commit touches only the walker's definition read and adds one test). At `0cf6eed565`: the two touched files `src/kernel/metadata-credential-fields.test.ts` and `src/kernel/metadata-type-redaction.test.ts` give 2 files and 39 tests passed, and `typecheck` passed. After `spec build`, `check:generated` reports all 14 generated artifacts up to date, so no artifact regenerated. No public export was added: the new module is not re-exported from the `kernel` barrel, and `check:api-surface` is green. - `@objectstack/rest`: `test` green at `0cf6eed565` (307 files; 6157 passed, 326 skipped), and `typecheck` passed. An earlier head failed `rest-exposure-gate-execution-read.test.ts`, and the cause was in this PR. That test wraps the registered `object` schema in a second Proxy. A `_zod` read through a Proxy over a `lazySchema` Proxy throws a Proxy invariant error, so the walk read that way turned the instrumented type's meta reads into 500s. The walker now reads the public `.def`, and a unit test pins derivation through a doubly proxied schema. - `@objectstack/metadata-protocol` and `@objectstack/plugin-webhooks`: `test` and `typecheck` green (run at `441ce0128d`). `@objectstack/dogfood` `typecheck` is green. - Dogfood at `0cf6eed565`: the new pin file plus `webhook-materialization` and `wall-declared-webhook-deployment-wide` give 3 files and 24 tests passed. - **Ablation** (marker removed, the pin reads red). Both `credential` markers on `WebhookSchema` were rewritten to a non-marker key through `scripts/ablation-replace.mjs`: anchor 1 to 0 for each, on-disk counts 0 and 0. `@objectstack/spec` was rebuilt, and `scripts/ablation-dist-preflight.mjs --absent` confirmed both markers absent from all 232 built files. The pin file then read 12 failed and 3 passed. The 3 that stayed green are the two composition guards and the dispatcher-source pin, which do not depend on redaction. Restore: blob equal to HEAD and `git diff HEAD` empty, spec rebuilt, the preflight confirmed both markers present in 18 built files, and the pin file read 15 of 15 passed. A first attempt was refused by the replace tool before anything ran, because its replacement-count check rejected the anchor shape; nothing was mutated, and that attempt counts for nothing. - Derived gate families (`scripts/pm/dispatch-gates.mjs --commands`, 87 families): all 87 run, with the spec and changeset subset run again at `0cf6eed565`, and all exit 0 except one. `check:type-check-debt` was run under the lock at `0cf6eed565`: the re-measure is OK, and no ledger entry is above its recorded number. The exception: `check:dual-build-cjs-loads` is NOT MEASURED, because it needs every workspace package built and only the dogfood closure was built. That one is declared to CI. ### Fix round (head `e86f32235f`) This round merged `origin/main` and re-graded the changeset to `minor`, adding the BREAKING banner. The changeset gates all exit 0: `check-changeset-no-major` (`--base origin/main` and `--self-test`), `check-adr-0087-registration` (both), `check-empty-changeset` (both), and `check:changeset-gate-self-tests`. After them, `pnpm --filter @objectstack/spec test` passed 703 files and 21388 tests (1 skipped, 1 todo), and `typecheck` passed. Both ran under the lock, with VERDICT command-exit 0. The `dispatch-gates --commands` derivation is unchanged from the previous head (same 87 families). The gates this round's delta touches also exit 0: `check:nul-bytes`, `check:issue-citations`, `check:doc-authoring`, `check:pm-changeset-deadline-census`, closing-keyword parity, `check:objectui-changeset` and `check:closed-ceremony-text`. ## Acceptance notes - `.meta({ credential })` is carried into the generated JSON Schema of a webhook (zod copies metadata into the JSON Schema node), so `/meta/types` and the IDE schema now show `credential: 'value' | 'map'` on the two properties. This is an annotation only: no gate artifact changed (`check:generated` reports all 14 up to date). - The derivation reads a type's schema the first time that type's redactor is resolved, which means a lazily built schema is built on its first meta read. This is memoised per schema identity. - MCP: the card's door list does not name an MCP door. The dev report on #24966 measured that the MCP HTTP tools refuse the stored metadata objects, and that the stdio data bridge applies the same stored-row projection this registry drives. No MCP pin is added here. --- _Generated by [Claude Code](https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent eddffc3 commit e939357

7 files changed

Lines changed: 838 additions & 10 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
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).
6+
7+
**BREAKING:** the `/meta` read doors no longer serve a webhook's `secret` or `headers`.
8+
9+
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.
10+
11+
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.
12+
13+
<!-- adr-0087: not-required (no-migration-prescription) no authorable key, export or stored shape is removed or renamed; the read doors serve less, and a stored webhook needs no migration -->
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [ruling B+ on #24966, #24994] Every meta read door withholds a webhook's
4+
// declared credentials — `secret` (a credential value) and `headers` (a
5+
// credential map, as a whole) — in BOTH compositions: without
6+
// `@objectstack/plugin-webhooks`, and with it mounted.
7+
//
8+
// The rule under test: `WebhookSchema` marks the two fields with
9+
// `.meta({ credential: … })`, and `getMetadataTypeRedactor('webhook')`
10+
// resolves a redactor DERIVED from those markers (`@objectstack/spec/kernel`),
11+
// present from module load whatever the composition mounts. A webhook's items
12+
// reach the meta read doors identically with and without the plugin (declared
13+
// ones through manifest decomposition, saved ones through the meta save door),
14+
// so a plugin registration would protect only one of the two compositions;
15+
// this file boots both.
16+
//
17+
// Doors pinned, per composition: the meta list (both spellings), the item read,
18+
// the layered view, the published read, the metadata protocol's list and item
19+
// reads, and the stored-row data door with its history twin. Each door read
20+
// carries a positive control (the response names the webhook), so a refusal or
21+
// an empty answer cannot pass for a withheld credential.
22+
//
23+
// Also pinned: the stored record is never mutated (redaction is a serving
24+
// act); a save of the served form keeps the stored values (the metadata
25+
// protocol's carry-forward); and, with the plugin mounted, the dispatcher's
26+
// declared-webhook source still carries both credentials, so delivery signing
27+
// is unaffected.
28+
//
29+
// Disclosure: every credential below is an obviously-fake placeholder, and
30+
// every assertion is absence by rule, never a served value.
31+
32+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
33+
import { bootStack, type VerifyStack } from '@objectstack/verify';
34+
import { MessagingServicePlugin } from '@objectstack/service-messaging';
35+
import { WebhookOutboxPlugin } from '@objectstack/plugin-webhooks';
36+
import { defineStack } from '@objectstack/spec';
37+
import { ObjectSchema, Field } from '@objectstack/spec/data';
38+
import { defineWebhook } from '@objectstack/spec/automation';
39+
40+
const SYSTEM = { isSystem: true, positions: [], permissions: [] };
41+
42+
/** Obviously-fake placeholders: absence of these strings is the whole assertion. */
43+
const DECLARED_SECRET = 'TEST_SECRET_DECLARED_24994';
44+
const DECLARED_HEADER = 'TEST_HEADER_DECLARED_24994';
45+
const SAVED_SECRET = 'TEST_SECRET_SAVED_24994';
46+
const SAVED_HEADER = 'TEST_HEADER_SAVED_24994';
47+
const PLACEHOLDERS = [DECLARED_SECRET, DECLARED_HEADER, SAVED_SECRET, SAVED_HEADER] as const;
48+
49+
const DECLARED = 'wcr_declared';
50+
const SAVED = 'wcr_saved';
51+
52+
const WcrTask = ObjectSchema.create({
53+
name: 'wcr_task',
54+
sharingModel: 'public_read_write',
55+
label: 'WCR Task',
56+
pluralLabel: 'WCR Tasks',
57+
fields: { name: Field.text({ label: 'Name', required: true }) },
58+
});
59+
60+
const fixture = defineStack({
61+
manifest: {
62+
id: 'com.dogfood.webhook-credential-redaction',
63+
namespace: 'wcr',
64+
version: '0.0.0',
65+
type: 'app',
66+
name: 'Webhook Credential Redaction Fixture',
67+
description: 'One object and one declared webhook carrying both declared credential fields, for the meta read-door pins of ruling B+ on #24966.',
68+
},
69+
objects: [WcrTask],
70+
webhooks: [
71+
defineWebhook({
72+
name: DECLARED,
73+
label: 'WCR Declared',
74+
object: 'wcr_task',
75+
triggers: ['create'],
76+
url: 'https://hooks.example/wcr-declared',
77+
headers: { 'X-Placeholder': DECLARED_HEADER },
78+
secret: DECLARED_SECRET,
79+
isActive: false,
80+
}),
81+
],
82+
});
83+
84+
const savedBody = () => ({
85+
name: SAVED,
86+
label: 'WCR Saved',
87+
object: 'wcr_task',
88+
triggers: ['update'],
89+
url: 'https://hooks.example/wcr-saved',
90+
headers: { 'X-Placeholder': SAVED_HEADER },
91+
secret: SAVED_SECRET,
92+
isActive: false,
93+
});
94+
95+
const rowsOf = (r: any): any[] => (Array.isArray(r) ? r : Array.isArray(r?.records) ? r.records : Array.isArray(r?.value) ? r.value : []);
96+
const holdsAPlaceholder = (v: unknown): boolean => {
97+
const text = typeof v === 'string' ? v : JSON.stringify(v ?? null);
98+
return PLACEHOLDERS.some((p) => text.includes(p));
99+
};
100+
const bodyOf = (row: any): Record<string, unknown> =>
101+
typeof row?.metadata === 'string' ? JSON.parse(row.metadata) : (row?.metadata ?? {});
102+
103+
const COMPOSITIONS = [
104+
{ label: 'without plugin-webhooks', plugins: () => [] as unknown[] },
105+
{ label: 'with plugin-webhooks mounted', plugins: () => [new MessagingServicePlugin(), new WebhookOutboxPlugin()] },
106+
] as const;
107+
108+
for (const composition of COMPOSITIONS) {
109+
describe(`[ruling B+] every meta read door withholds a webhook's declared credentials — ${composition.label}`, () => {
110+
let stack: VerifyStack;
111+
let admin: string;
112+
let member: string;
113+
let ql: any;
114+
let protocol: any;
115+
116+
const stored = async (object: 'sys_metadata' | 'sys_metadata_history', name: string) =>
117+
rowsOf(await ql.find(object, { where: { type: 'webhook', name }, context: SYSTEM }));
118+
119+
/** One door read: 200, names the webhook (positive control), and holds no credential. */
120+
const expectWithheld = async (token: string, path: string, names: readonly string[]) => {
121+
const res = await stack.apiAs(token, 'GET', path);
122+
const text = await res.text();
123+
expect(res.status, `${path}: ${text.slice(0, 200)}`).toBe(200);
124+
for (const name of names) expect(text.includes(name), `${path} names ${name}`).toBe(true);
125+
expect(holdsAPlaceholder(text), `${path} withholds the declared credential fields`).toBe(false);
126+
};
127+
128+
beforeAll(async () => {
129+
stack = await bootStack(fixture as never, { extraPlugins: composition.plugins() as never });
130+
ql = await stack.kernel.getServiceAsync('objectql');
131+
protocol = await stack.kernel.getServiceAsync('protocol');
132+
admin = await stack.signIn();
133+
member = await stack.signUp(`wcr-member-${composition.plugins().length}@redaction.test`);
134+
const put = await stack.apiAs(admin, 'PUT', `/meta/webhook/${SAVED}`, savedBody());
135+
expect(put.status, await put.clone().text()).toBe(200);
136+
}, 180_000);
137+
138+
afterAll(async () => {
139+
await stack?.stop?.();
140+
});
141+
142+
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 () => {
143+
const [row] = await stored('sys_metadata', SAVED);
144+
expect(bodyOf(row).secret).toBe(SAVED_SECRET);
145+
expect(bodyOf(row).headers).toEqual({ 'X-Placeholder': SAVED_HEADER });
146+
let declaredService: unknown;
147+
try {
148+
declaredService = stack.kernel.getService('declaredWebhooks');
149+
} catch {
150+
declaredService = undefined;
151+
}
152+
expect(Boolean(declaredService)).toBe(composition.plugins().length > 0);
153+
});
154+
155+
it('the meta list, both spellings, withholds both fields of the declared and the saved webhook', async () => {
156+
for (const token of [admin, member]) {
157+
await expectWithheld(token, '/meta/webhook', [DECLARED, SAVED]);
158+
await expectWithheld(token, '/meta/webhooks', [DECLARED, SAVED]);
159+
}
160+
});
161+
162+
it('the item read and the layered view withhold both fields', async () => {
163+
for (const token of [admin, member]) {
164+
for (const name of [DECLARED, SAVED]) {
165+
await expectWithheld(token, `/meta/webhook/${name}`, [name]);
166+
await expectWithheld(token, `/meta/webhook/${name}/layers`, [name]);
167+
}
168+
}
169+
});
170+
171+
it('the published read withholds both fields', async () => {
172+
for (const token of [admin, member]) await expectWithheld(token, `/meta/webhook/${SAVED}/published`, [SAVED]);
173+
});
174+
175+
it('the metadata protocol\'s list and item reads withhold both fields', async () => {
176+
const listed = await protocol.getMetaItems({ type: 'webhook' });
177+
expect(JSON.stringify(listed).includes(SAVED)).toBe(true);
178+
expect(holdsAPlaceholder(listed)).toBe(false);
179+
for (const name of [DECLARED, SAVED]) {
180+
const item = await protocol.getMetaItem({ type: 'webhook', name });
181+
expect(JSON.stringify(item).includes(name), name).toBe(true);
182+
expect(holdsAPlaceholder(item), name).toBe(false);
183+
}
184+
});
185+
186+
it('the stored-row data door and its history twin withhold both fields', async () => {
187+
const filter = encodeURIComponent(JSON.stringify({ type: 'webhook', name: SAVED }));
188+
await expectWithheld(admin, `/data/sys_metadata?filter=${filter}`, [SAVED]);
189+
await expectWithheld(admin, `/data/sys_metadata_history?filter=${filter}`, [SAVED]);
190+
});
191+
192+
it('a save of the served form keeps the stored values (the carry-forward), and the store is never redacted', async () => {
193+
const read = await stack.apiAs(admin, 'GET', `/meta/webhook/${SAVED}`);
194+
expect(read.status).toBe(200);
195+
const json = (await read.json()) as any;
196+
const served = (json?.item ?? json?.data?.item ?? json?.data ?? json) as Record<string, unknown>;
197+
expect(served?.name).toBe(SAVED);
198+
const body = Object.fromEntries(Object.entries(served).filter(([k]) => !k.startsWith('_')));
199+
expect(Object.keys(body)).not.toContain('secret');
200+
expect(Object.keys(body)).not.toContain('headers');
201+
202+
const put = await stack.apiAs(admin, 'PUT', `/meta/webhook/${SAVED}`, { ...body, label: 'WCR Saved (edited)' });
203+
expect(put.status, await put.clone().text()).toBe(200);
204+
205+
const [row] = await stored('sys_metadata', SAVED);
206+
expect(bodyOf(row).label).toBe('WCR Saved (edited)');
207+
expect(bodyOf(row).secret).toBe(SAVED_SECRET);
208+
expect(bodyOf(row).headers).toEqual({ 'X-Placeholder': SAVED_HEADER });
209+
});
210+
211+
if (composition.plugins().length > 0) {
212+
it('the dispatcher\'s declared-webhook source still carries both credentials: delivery signing is unaffected', async () => {
213+
const declaredWebhooks = (await stack.kernel.getServiceAsync('declaredWebhooks')) as any;
214+
const declared = await declaredWebhooks.declaredWebhookNamed(DECLARED);
215+
expect(declared?.webhook?.secret).toBe(DECLARED_SECRET);
216+
expect(declared?.webhook?.headers).toEqual({ 'X-Placeholder': DECLARED_HEADER });
217+
});
218+
}
219+
});
220+
}

‎packages/spec/src/automation/webhook.zod.ts‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -193,14 +193,26 @@ export const WebhookSchema = lazySchema(() => strictObject({
193193
url: z.string().url().describe('External webhook endpoint URL'),
194194
method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']).default('POST').describe('HTTP method'),
195195

196-
/** Headers */
197-
headers: z.record(z.string(), z.string()).optional().describe('Custom HTTP headers'),
196+
/**
197+
* Headers. A credential MAP as a whole (`.meta({ credential: 'map' })`):
198+
* the platform cannot tell which entry of an authored header map carries a
199+
* credential, so every meta read door withholds the whole map, and the
200+
* schema-derived redactor in `kernel/metadata-type-redaction.ts` is what
201+
* reads this marker (ruling B+ on #24966).
202+
*/
203+
headers: z.record(z.string(), z.string()).optional().describe('Custom HTTP headers').meta({ credential: 'map' }),
198204

199205
/** Timeout */
200206
timeoutMs: z.number().int().min(1000).max(300000).default(30000).describe('Request timeout in milliseconds'),
201207

202-
/** Security */
203-
secret: z.string().optional().describe('Signing secret for HMAC signature verification'),
208+
/**
209+
* Security. A credential VALUE (`.meta({ credential: 'value' })`): every
210+
* meta read door withholds it, derived from this marker by
211+
* `kernel/metadata-type-redaction.ts` (ruling B+ on #24966). The dispatcher
212+
* reads the declaration itself, never a served copy, so delivery signing is
213+
* unaffected.
214+
*/
215+
secret: z.string().optional().describe('Signing secret for HMAC signature verification').meta({ credential: 'value' }),
204216

205217
/** Status */
206218
isActive: z.boolean().default(true).describe('Whether webhook is active'),

0 commit comments

Comments
 (0)