@objectstack/plugin-webhooks 17.1.0 → 17.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +212 -0
- package/dist/index.cjs +141 -92
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +74 -5
- package/dist/index.d.ts +74 -5
- package/dist/index.js +81 -32
- package/dist/index.js.map +1 -1
- package/dist/schema.d.cts +7 -0
- package/dist/schema.d.ts +7 -0
- package/package.json +6 -6
package/dist/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["/home/runner/work/objectstack/objectstack/packages/plugins/plugin-webhooks/dist/index.cjs","../src/webhook-secret.ts","../src/webhook-headers.ts","../src/auto-enqueuer.ts","../src/bootstrap-declared-webhooks.ts","../src/migrate-webhook-secrets.ts","../src/redeliver-guard.ts","../src/webhook-provenance.ts","../src/webhook-headers-gate.ts","../src/webhook-outbox-plugin.ts"],"names":[],"mappings":"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","file":"/home/runner/work/objectstack/objectstack/packages/plugins/plugin-webhooks/dist/index.cjs","sourcesContent":[null,"// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * [#7799] The persistence seam for a webhook's HMAC signing secret.\n *\n * ## The defect\n * `bootstrapDeclaredWebhooks` used to persist the whole validated `Webhook`\n * envelope — `secret` included — as `definition_json: JSON.stringify(wh)`, and\n * `AutoEnqueuer.parseRow` read `defn.secret` straight back out to sign\n * deliveries. `definition_json` is an ordinary textarea on an admin-authorable\n * object with no restrictive `enable.apiMethods`, so an ordinary\n * `GET /api/v1/data/sys_webhook` returned the key to every persona that can read\n * the object. That key is the receiver's ONLY proof a delivery came from us.\n *\n * #7722 removed the same secret's per-attempt copies from `sys_http_delivery`;\n * this is the remaining cleartext location, and unlike the delivery table it is\n * not bounded by a retention window.\n *\n * ## The seam\n * Nothing about the AUTHORING envelope changes — authors still write\n * `secret: '…'` on `defineWebhook()`, and `webhook.zod.ts` is untouched. What\n * changes is where the value LANDS:\n *\n * authored `secret` → `sys_webhook.signing_secret` (`type: 'secret'`)\n * → engine encrypts → `sys_secret` ciphertext row\n * → row keeps only an opaque `secret:<id>` ref\n * → every read path returns the mask\n *\n * `definition_json` → the same envelope MINUS `secret`\n *\n * and the enqueuer recovers the plaintext server-side, at cache-refresh time,\n * through `engine.resolveSecretField()` — the privileged, driver-level\n * dereference added alongside this change, because the encrypted channel masks\n * its own ref on every supported read path and a server-side consumer\n * previously had no way to get at it.\n *\n * ## Two things this file deliberately does NOT do\n * - It does not invent a second cipher store. The engine owns the\n * `ICryptoProvider` (the host injects it via `setCryptoProvider`, and it is\n * not a kernel service), so the plugin cannot encrypt on its own — it writes\n * cleartext INTO the `secret`-typed column exactly once and lets the engine's\n * own write path do the wrapping. That also inherits the engine's fail-closed\n * posture for free: no provider ⇒ the write throws ⇒ we skip the webhook\n * loudly, rather than silently re-opening the hole in a new column.\n * - It does not guess. When a row HAS a stored secret the enqueuer cannot\n * resolve, the subscription is dropped rather than delivered unsigned — an\n * undelivered webhook is visible and safe, an unsigned one is invisible and\n * is precisely the failure this issue is about.\n */\n\nimport type { IDataEngine } from '@objectstack/spec/contracts';\n\n/** Column on `sys_webhook` holding the encrypted signing key. */\nexport const WEBHOOK_SECRET_FIELD = 'signing_secret';\n\n/** Object whose rows carry it. Kept here so seeder/enqueuer/sweep agree. */\nexport const WEBHOOK_OBJECT = 'sys_webhook';\n\n/**\n * Error code + status carried by the refusal this seam can raise, per ADR-0112:\n * a consumer branches on `code`, not on message text. `INTERNAL_ERROR`/500 is\n * the standard-catalog member for \"the server is misconfigured and cannot honour\n * this safely\" — no CryptoProvider is wired, so there is nowhere to put the key\n * that is not cleartext.\n */\nexport const WEBHOOK_SECRET_REFUSAL_CODE = 'INTERNAL_ERROR';\nexport const WEBHOOK_SECRET_REFUSAL_STATUS = 500;\n\n/**\n * True when `err` is the engine's fail-closed refusal to persist a `secret`\n * field — no CryptoProvider registered, or no reachable `sys_secret` store.\n * Matched on the engine's own wording because that path throws a bare `Error`;\n * a false negative only costs a less specific log line, never cleartext.\n */\nexport function isSecretProtectionFailure(err: unknown): boolean {\n const msg = String((err as Error)?.message ?? err ?? '');\n return /Cannot persist secret field/i.test(msg);\n}\n\n/**\n * [#8542] A signing secret IS stored on the row and could not be recovered.\n *\n * ## Why this is an error and not a `undefined`\n * `resolveWebhookSecret` used to return `undefined` for two different facts —\n * *\"the author configured this webhook unsigned\"* and *\"a key is stored but\n * nothing came back\"* — and its caller acts on the first reading, which is the\n * legitimate one. So the second silently became the first: the subscription\n * ARMED and every delivery went out unauthenticated while `sys_webhook` kept\n * reading `active: true`. Nothing logged, nothing dropped. That is the #7799\n * signing invariant failing OPEN, and the direction is the whole defect — the\n * two adjacent failure modes (a throwing resolver, an engine with no encrypted\n * channel) both fail CLOSED and loud.\n *\n * Presence is decidable even when the value is not: the generic read path\n * returns the engine's mask for a set secret and `null` for an unset one, so\n * the caller already knows a value is stored before it asks for the plaintext.\n * Raising here rather than at each caller is what makes the rule one rule —\n * `AutoEnqueuer.attachSecret` needs no new branch, because a stored-but-\n * unresolvable key now arrives exactly the way a throwing resolver already did.\n *\n * Carries the ADR-0112 pair as fields so a consumer branches on `code`/`status`\n * rather than on message text. Same pair the seeder's refusal already reports\n * for the same underlying cause.\n */\nexport class WebhookSecretUnresolvableError extends Error {\n readonly code = WEBHOOK_SECRET_REFUSAL_CODE;\n readonly status = WEBHOOK_SECRET_REFUSAL_STATUS;\n constructor(message: string) {\n super(message);\n this.name = 'WebhookSecretUnresolvableError';\n }\n}\n\n/**\n * True when `err` is this seam's refusal to hand back a key it could not\n * recover — as opposed to any other failure, which means \"we could not even\n * check\" and must not be softened into a verdict.\n *\n * The distinction has one consumer today: the redeliver guard, whose contract\n * is a returned refusal REASON rather than a throw (#8069). Everything on the\n * enqueue path just lets it propagate into the `catch` that already parks the\n * subscription.\n */\nexport function isWebhookSecretUnresolvable(\n err: unknown,\n): err is WebhookSecretUnresolvableError {\n return err instanceof WebhookSecretUnresolvableError;\n}\n\n/**\n * Split an authored envelope into the part that is safe to serialize into\n * `definition_json` and the key that must go to the encrypted column.\n *\n * The key is REMOVED, not blanked: leaving `\"secret\": \"\"` behind would still\n * teach the next reader that this blob is where the key lives, and a later\n * merge could refill it.\n */\nexport function splitWebhookSecret<T extends Record<string, unknown>>(\n wh: T,\n): { envelope: Omit<T, 'secret'>; secret: string | undefined } {\n const { secret, ...envelope } = wh as T & { secret?: unknown };\n const value = typeof secret === 'string' && secret.length > 0 ? secret : undefined;\n return { envelope: envelope as Omit<T, 'secret'>, secret: value };\n}\n\n/**\n * Read a legacy cleartext secret out of a `definition_json` blob.\n *\n * Rows written before #7799 — and rows an admin hand-edited into the textarea —\n * still carry one. Returns `undefined` for anything else, including unparseable\n * JSON (a malformed blob is not a credential).\n */\nexport function readLegacySecret(definitionJson: unknown): string | undefined {\n if (typeof definitionJson !== 'string' || definitionJson.length === 0) return undefined;\n try {\n const parsed = JSON.parse(definitionJson);\n const secret = (parsed as { secret?: unknown } | null)?.secret;\n return typeof secret === 'string' && secret.length > 0 ? secret : undefined;\n } catch {\n return undefined;\n }\n}\n\n// `stripSecretFromDefinition` lived here until #7986. Its single caller — the\n// boot sweep — now has to remove BOTH credential passengers from the blob, and\n// doing that as two independent parse/serialize round-trips would let the two\n// removals disagree about what the blob contained. The sweep owns one\n// `stripCredentialsFromDefinition` instead, built from `splitWebhookSecret` +\n// `splitWebhookHeaders` over a single parse.\n\n/**\n * objectql's two wire forms for the encrypted channel, restated here ONLY as a\n * \"this value is not the key\" guard.\n *\n * This package deliberately takes no dependency on `@objectstack/objectql` (it\n * declares the messaging surface structurally for the same reason), so the\n * constants cannot be imported — and a signing key is the one place where\n * guessing is unacceptable: sign with the mask and every receiver rejects every\n * delivery, silently, forever. `webhook-secret-at-rest.test.ts` pins both\n * against objectql's own exports so a rename there reddens here.\n */\nconst OBJECTQL_SECRET_MASK = '••••••••';\nconst OBJECTQL_SECRET_REF_PREFIX = 'secret:';\n\n/** True when a column value is objectql's mask or ref — opaque, never the key. */\nexport function isOpaqueSecretForm(value: unknown): boolean {\n return (\n typeof value === 'string'\n && (value === OBJECTQL_SECRET_MASK || value.startsWith(OBJECTQL_SECRET_REF_PREFIX))\n );\n}\n\n/** Test-only accessors for the pin above. */\nexport const __objectqlSecretWireForms = {\n mask: OBJECTQL_SECRET_MASK,\n refPrefix: OBJECTQL_SECRET_REF_PREFIX,\n} as const;\n\n/** Engines that expose the privileged dereference (ObjectQL ≥ #7799). */\ntype SecretResolvingEngine = IDataEngine & {\n resolveSecretField?(object: string, recordId: string, field: string): Promise<string | null>;\n onCryptoProviderChange?(listener: () => void): () => void;\n};\n\n/** True when this engine can dereference an encrypted field. */\nexport function canResolveSecrets(engine: IDataEngine | undefined): boolean {\n return typeof (engine as SecretResolvingEngine | undefined)?.resolveSecretField === 'function';\n}\n\n/**\n * [#8022] Subscribe to the engine's crypto-provider registration. Returns an\n * unsubscribe function, or `undefined` when the engine has no such channel.\n *\n * ## Why this exists\n * Resolving a stored key stays fail-closed (#7799) — that is not what this\n * changes. What it changes is how long a fail-closed READ is allowed to stand\n * when the reason for it is about to disappear. \"No CryptoProvider\" is not only\n * a misconfiguration: on every host it is also a *transient boot state*, because\n * plugins run inside `kernel:ready` and the composition root injects the\n * provider only after `runtime.start()` returns. So the enqueuer's FIRST cache\n * build reliably precedes the capability it needs, drops every secret-bearing\n * subscription (correctly, on what it could see), and — before this — stayed\n * dropped until the next periodic refresh 60s later.\n *\n * Feature-detected rather than required, exactly like `resolveSecretField`\n * above, because this package deliberately takes no dependency on\n * `@objectstack/objectql`. An engine without the channel keeps the previous\n * behaviour — the periodic refresh remains the backstop — rather than failing\n * to start.\n */\nexport function onCryptoProviderChange(\n engine: IDataEngine | undefined,\n listener: () => void,\n): (() => void) | undefined {\n const observable = engine as SecretResolvingEngine | undefined;\n if (typeof observable?.onCryptoProviderChange !== 'function') return undefined;\n return observable.onCryptoProviderChange(listener);\n}\n\n/**\n * Recover a row's signing key. Returns `undefined` for EXACTLY one fact — the\n * row has no stored key — which is not an error: `secret` is optional on the\n * authoring envelope, and an unsigned webhook is a legitimate authored choice.\n *\n * Throws {@link WebhookSecretUnresolvableError} when a key IS stored and does\n * not come back. Callers must treat that as \"drop this subscription\", never as\n * \"deliver unsigned\".\n *\n * ## [#8542] Why \"did not come back\" is not spelled `undefined`\n * The dereference has three measured ways to answer `null` while a value is\n * genuinely stored, all of them reaching this function identically:\n *\n * 1. the `sys_webhook` row is deleted between the enqueuer's cache read and\n * this dereference (`resolveSecretField` opens `if (!row) return null`);\n * 2. the column holds something that is not a `secret:` ref — measured as\n * reachable only through a write that BYPASSES the engine (a hand-edited\n * column, a dump restored without its `sys_secret` rows, a seed script\n * writing at driver level). The engine's own write path defends both\n * obvious routes: an echoed mask is dropped and cleartext is re-encrypted;\n * 3. the ciphertext decrypts to the empty string — reachable through the\n * ORDINARY data API, which accepts `signing_secret: ''`, mints a real\n * `sys_secret` row for it, and leaves the column holding a perfectly valid\n * ref that reads back as the mask.\n *\n * In all three the row still advertises a stored secret on every read path, so\n * returning `undefined` told the caller the opposite of what the row says.\n */\nexport async function resolveWebhookSecret(\n engine: IDataEngine,\n row: { id: string; [k: string]: unknown },\n object: string = WEBHOOK_OBJECT,\n): Promise<string | undefined> {\n const stored = row[WEBHOOK_SECRET_FIELD];\n // Unset / cleared. On the generic read path a set secret comes back as the\n // engine's mask (a non-empty string) and an unset one as `null`, so presence\n // is decidable here WITHOUT the value ever being readable. Everything below\n // this line therefore runs with \"a secret IS stored\" already established —\n // which is the knowledge the old `undefined` return threw away.\n if (stored == null || stored === '') return undefined;\n\n const resolver = engine as SecretResolvingEngine;\n if (typeof resolver.resolveSecretField !== 'function') {\n // An engine with no encrypted-field channel stored verbatim what the seeder\n // handed it, so the column IS the key — reading it is correct, not a\n // fallback. The refusal below is for the narrow case where the value is one\n // of objectql's opaque forms and there is no way to invert it.\n if (!isOpaqueSecretForm(stored)) return String(stored);\n throw new WebhookSecretUnresolvableError(\n `Webhook \"${String(row.name ?? row.id)}\" stores an encrypted signing secret, but this data `\n + 'engine does not implement resolveSecretField() — the key cannot be recovered, so the '\n + 'subscription is dropped rather than delivered unsigned (#7799).',\n );\n }\n const plain = await resolver.resolveSecretField(object, String(row.id), WEBHOOK_SECRET_FIELD);\n if (typeof plain === 'string' && plain.length > 0) return plain;\n\n throw new WebhookSecretUnresolvableError(\n `Webhook \"${String(row.name ?? row.id)}\" stores a signing secret in `\n + `${object}.${WEBHOOK_SECRET_FIELD} that resolved to nothing. A value IS stored — the read `\n + 'path returns the engine mask for it — so this is NOT an unsigned webhook, and delivering '\n + 'it unsigned would strip the receiver of its only proof of origin (#7799, #8542). Causes, '\n + 'in the order worth checking: the row was deleted while this refresh was reading it; the '\n + 'column holds something that is not a secret: ref (a hand-edited column, or a dump restored '\n + 'without its sys_secret rows); or the stored value decrypts to an empty string. Fix: re-save '\n + 'the webhook secret so the column holds a fresh ref, or CLEAR the field to null if this '\n + 'webhook is meant to be unsigned — an empty secret is not the same thing as no secret.',\n );\n}\n","// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * [#7986] The persistence seam for a webhook's custom `headers` map — the\n * sibling passenger #7799 left behind on the blob it emptied.\n *\n * ## The defect\n * #7799 moved the signing secret out of `sys_webhook.definition_json` into an\n * encrypted column. It did not move `headers`, and `headers` is the ordinary\n * place an `Authorization: Bearer …` goes. Same column, same object with no\n * `enable` block at all (so the FULL default data API), same unbounded\n * retention — the only thing that differed was which key of the blob the card\n * happened to name. `GET /api/v1/data/sys_webhook` handed the header map back\n * to every persona that can read the object.\n *\n * That framing is the finding: the COLUMN was the problem and the secret was\n * only one of its passengers.\n *\n * ## Why the WHOLE map moves, and not just the credential-looking entries\n * A signing secret is one opaque value with one consumer. `headers` is an\n * open-ended `Record<string, string>` in which only some entries are\n * credentials — and **the platform cannot tell which**. Three ways to decide\n * were on the table; this is why the map moves whole:\n *\n * - **Guess from the header NAME** (`authorization`, `x-api-key`, …). Rejected:\n * it is fail-OPEN on precisely the names most likely to be a credential in\n * practice — `X-Acme-Token`, `X-Vendor-Key` — and a heuristic that silently\n * passes the one header that mattered is worse than no heuristic, because it\n * reads as coverage. Every other credential decision in this repo fails\n * closed; this one would not.\n * - **Have the author DECLARE which are sensitive** (`secretHeaders: [...]`).\n * That is a change to the authoring envelope (`webhook.zod.ts`), which is\n * the spec seat's surface, not this one — and it would still leave the\n * `source: 'flow'` half of the same exposure untouched, because a flow\n * `http` node's headers are interpolated per run and never parsed through\n * `WebhookSchema` at all. Escalated rather than attempted here (#7986).\n * - **Move the whole map.** Fail-closed by construction, needs no authoring\n * change, and the cost it is accused of — \"it encrypts non-sensitive headers\n * too\" — is measured and small: `definition_json` is a raw JSON textarea\n * pending a real builder (see `sys-webhook.object.ts`), so what an admin\n * loses is the ability to READ back a `Content-Type` they typed, on a\n * surface that was never the intended authoring UI.\n *\n * ## The seam\n * Identical in shape to `webhook-secret.ts`, deliberately — one mechanism, two\n * passengers, so a reader who has understood #7799 has already understood this:\n *\n * authored `headers` → `sys_webhook.headers_secret` (`type: 'secret'`)\n * → engine encrypts the SERIALIZED map → `sys_secret`\n * → row keeps only an opaque `secret:<id>` ref\n * → every read path returns the mask\n *\n * `definition_json` → the same envelope MINUS `headers` (and MINUS\n * `secret`, as #7799 already established)\n *\n * The map is serialized because the encrypted channel carries a string. That is\n * an encoding detail and not a second format: {@link parseStoredHeaders} is the\n * only reader, and it treats anything that is not a flat string map as absent\n * rather than guessing.\n *\n * ## What this file deliberately does NOT do\n * - It does not invent a second cipher store, for the same layering reason\n * `webhook-secret.ts` gives: the engine owns the `ICryptoProvider`, so the\n * plugin writes cleartext INTO the `secret`-typed column exactly once and\n * lets the engine's write path wrap it. The fail-closed posture comes free.\n * - It does not deliver partially. A row whose stored headers cannot be\n * resolved DROPS the subscription rather than delivering it with the headers\n * missing — see {@link resolveWebhookHeaders}.\n *\n * [#8558] That last line was a statement of intent this file did not keep. Only\n * a THROWING resolver reached the caller's `catch`; a resolver that answered\n * `null` — or handed back a value that was not a flat string map — folded onto\n * the `undefined` this seam uses for \"no headers stored\", and the subscription\n * armed and delivered without them. {@link WebhookHeadersUnresolvableError} is\n * what makes the sentence true.\n */\n\nimport type { IDataEngine } from '@objectstack/spec/contracts';\nimport {\n WEBHOOK_SECRET_REFUSAL_CODE,\n WEBHOOK_SECRET_REFUSAL_STATUS,\n isOpaqueSecretForm,\n} from './webhook-secret.js';\n\n/** Column on `sys_webhook` holding the encrypted custom-header map. */\nexport const WEBHOOK_HEADERS_FIELD = 'headers_secret';\n\n/** Engines that expose the privileged dereference (ObjectQL ≥ #7799). */\ntype SecretResolvingEngine = IDataEngine & {\n resolveSecretField?(object: string, recordId: string, field: string): Promise<string | null>;\n};\n\n/** A header map, as the authoring envelope declares it. */\nexport type WebhookHeaders = Record<string, string>;\n\n/**\n * True when `value` is a flat `Record<string, string>` with at least one entry.\n *\n * Anything else — an array, a nested object, a map of numbers — is treated as\n * ABSENT rather than coerced. A header map is about to be written onto the\n * wire; a coerced `[object Object]` header value is a silently corrupted\n * request, and the authoring schema (`z.record(z.string(), z.string())`)\n * already rejects the shape at every declared door.\n */\nfunction isHeaderMap(value: unknown): value is WebhookHeaders {\n if (!value || typeof value !== 'object' || Array.isArray(value)) return false;\n const entries = Object.entries(value as Record<string, unknown>);\n if (entries.length === 0) return false;\n return entries.every(([, v]) => typeof v === 'string');\n}\n\n/**\n * Split an authored envelope into the part that is safe to serialize into\n * `definition_json` and the header map that must go to the encrypted column.\n *\n * The map is REMOVED, not blanked, for the reason `splitWebhookSecret` gives\n * about the secret: leaving `\"headers\": {}` behind still teaches the next\n * reader that this blob is where headers live, and a later merge could refill\n * it.\n */\nexport function splitWebhookHeaders<T extends Record<string, unknown>>(\n wh: T,\n): { envelope: Omit<T, 'headers'>; headers: WebhookHeaders | undefined } {\n const { headers, ...envelope } = wh as T & { headers?: unknown };\n return {\n envelope: envelope as Omit<T, 'headers'>,\n headers: isHeaderMap(headers) ? headers : undefined,\n };\n}\n\n/** Serialize a header map for the encrypted column (which carries a string). */\nexport function serializeHeaders(headers: WebhookHeaders): string {\n return JSON.stringify(headers);\n}\n\n/** Inverse of {@link serializeHeaders}. Non-conforming input reads as absent. */\nexport function parseStoredHeaders(stored: unknown): WebhookHeaders | undefined {\n if (typeof stored !== 'string' || stored.length === 0) return undefined;\n try {\n const parsed = JSON.parse(stored);\n return isHeaderMap(parsed) ? parsed : undefined;\n } catch {\n return undefined;\n }\n}\n\n/**\n * Read a legacy cleartext header map out of a `definition_json` blob.\n *\n * Rows written before this change — and rows an admin hand-edited into the\n * textarea — still carry one. Returns `undefined` for anything else, including\n * unparseable JSON (a malformed blob is not a credential).\n */\nexport function readLegacyHeaders(definitionJson: unknown): WebhookHeaders | undefined {\n if (typeof definitionJson !== 'string' || definitionJson.length === 0) return undefined;\n try {\n const parsed = JSON.parse(definitionJson) as { headers?: unknown } | null;\n return isHeaderMap(parsed?.headers) ? parsed.headers : undefined;\n } catch {\n return undefined;\n }\n}\n\n/**\n * [#8558] A header map IS stored on the row and did not come back as one.\n *\n * ## Why this is an error and not an `undefined`\n * `resolveWebhookHeaders` used to return `undefined` for two different facts —\n * *\"the author configured this webhook with no custom headers\"* and *\"a map is\n * stored and did not come back\"* — and its caller acts on the first reading,\n * which is the legitimate one. So the second silently became the first: the\n * subscription ARMED and every delivery went out missing the entire authored\n * header map, while `sys_webhook` kept reading `active: true` with\n * `headers_secret` masked, i.e. still reporting \"custom headers are\n * configured\". Measured end to end, what reached the receiver was a delivery\n * that SUCCEEDED, carrying a byte-correct `X-Objectstack-Signature`, with the\n * `Authorization` the author declared simply absent — and nothing logged.\n *\n * That the signature is VALID is what makes the direction so bad. It tells the\n * receiver the request is genuinely ours, so a receiver that authenticates by\n * signature has every reason to accept a request that no longer matches the\n * configuration its operator wrote. Against an endpoint that does not require\n * the header at all — a routing `X-Tenant-Id`, an `X-Environment: staging` —\n * the delivery is simply wrong and nobody finds out.\n *\n * Presence is decidable even when the value is not, and this is the one place\n * worth stating plainly because the field LOOKS like it should behave\n * differently: `headers_secret` is a map only in the plaintext. At the storage\n * layer it is an ordinary scalar `secret` column holding the serialized map, so\n * the generic read path returns the engine's mask for a set map and `null` for\n * an unset one — the same decidable signal `signing_secret` gives, for the same\n * reason. The \"it is a map, not a scalar\" worry does not survive measurement.\n *\n * Carries the ADR-0112 pair as fields so a consumer branches on `code`/`status`\n * rather than on message text — the same pair `attachHeaders`' drop report and\n * the signing seam's refusal already carry for the same class of cause.\n *\n * ## Why ONE error class for two conditions\n * A stored map reaches this seam and fails in two distinguishable ways: it\n * could not be RECOVERED (nothing came back), or it was recovered fine and is\n * not a usable header map. They deserve different remedies and get different\n * messages. They do not deserve different types: every consumer of this seam\n * branches on the ADR-0112 pair and the disposition, both identical — park the\n * subscription, record the discarded event, say it once. A second class with no\n * consumer would be a distinction the tree cannot act on.\n */\nexport class WebhookHeadersUnresolvableError extends Error {\n readonly code = WEBHOOK_SECRET_REFUSAL_CODE;\n readonly status = WEBHOOK_SECRET_REFUSAL_STATUS;\n constructor(message: string) {\n super(message);\n this.name = 'WebhookHeadersUnresolvableError';\n }\n}\n\n/**\n * The remedy clause both refusals end with — one wording, stated once.\n *\n * [#8566] Exported because the WRITE door quotes it too: the shape gate refuses\n * the same malformed map at authoring time that this file refuses at delivery\n * time, and an author who meets both should be told to do the same thing both\n * times. Two hand-kept copies of one remedy is how they drift.\n */\nexport const HEADERS_REMEDY =\n 'Fix: re-save the webhook headers as a flat JSON object of string values so the column holds a '\n + 'fresh ref, or CLEAR the field to null if this webhook is meant to send no custom headers — an '\n + 'empty or unparseable header map is not the same thing as no header map, and only the second '\n + 'one means \"send nothing extra\".';\n\n/**\n * Parse a recovered value into the map, or refuse.\n *\n * {@link parseStoredHeaders} answers `undefined` for every string that is not a\n * flat `Record` of strings, which is right for its own job and wrong as an\n * answer to *\"what are this webhook's headers?\"* once a value is known to be\n * stored. This is the narrow wrapper that turns the second reading into a\n * refusal, so the rule lives at the seam and no caller re-derives it.\n */\nfunction requireHeaderMap(\n recovered: unknown,\n row: { id: string; [k: string]: unknown },\n where: string,\n): WebhookHeaders {\n const parsed = parseStoredHeaders(recovered);\n if (parsed) return parsed;\n\n throw new WebhookHeadersUnresolvableError(\n `Webhook \"${String(row.name ?? row.id)}\" stores custom headers in ${where} that came back but are `\n + 'not a flat JSON object of string values, so there is no header map to send. A value IS stored '\n + '— the read path returns the engine mask for it — so this is NOT a webhook authored without '\n + 'headers, and delivering it without them would silently drop whatever the author put in that '\n + 'map, including an Authorization credential, on a delivery that is otherwise correctly signed '\n + 'and therefore looks genuine to the receiver (#7986, #8558). Causes, in the order worth '\n + 'checking: the value was typed into the Custom Headers field and is not valid JSON; it parses '\n + 'but is an array, an empty object, or has a non-string value ({\"X-Count\": 5}); or it is a '\n + `nested object where the wire format allows only strings. ${HEADERS_REMEDY}`,\n );\n}\n\n/**\n * Recover a row's custom headers. Returns `undefined` for EXACTLY one fact —\n * the row stores no headers — which is not an error: `headers` is optional on\n * the authoring envelope, and a webhook with no custom headers is a legitimate\n * authored configuration.\n *\n * Throws {@link WebhookHeadersUnresolvableError} when a map IS stored and does\n * not come back as one. Callers must treat that as \"drop this subscription\",\n * never as \"deliver without them\" — see `AutoEnqueuer.attachCredentials` for\n * why partial delivery is the invisible failure and a stopped subscription is\n * the visible one.\n *\n * ## [#8558] Why \"did not come back\" is not spelled `undefined`\n * This is the sibling of #8542 on `webhook-secret.ts`, and the measurement that\n * produced it found the header path is WIDER than the signing path rather than\n * symmetric to it. A signing secret is an opaque scalar: any non-empty answer\n * is a usable key, so only the empty string collapses. A header map's CONTENT\n * decides, so every one of these reaches this function as a stored-but-unusable\n * value, all confirmed against a real engine:\n *\n * 1. the `sys_webhook` row is deleted between the enqueuer's cache read and\n * this dereference (`resolveSecretField` opens `if (!row) return null`);\n * 2. the column holds something that is not a `secret:` ref — reachable only\n * through a write that BYPASSES the engine (a hand-edited column, a dump\n * restored without its `sys_secret` rows, a seed script writing at driver\n * level). The engine's own write path defends both obvious routes: an\n * echoed mask is dropped and cleartext is re-encrypted;\n * 3. the ciphertext decrypts to the empty string;\n * 4. ⭐ the ciphertext decrypts to a perfectly readable string that is not a\n * flat string map — `{}`, `[]`, `{\"X-Count\": 5}`, a nested object, or any\n * typo. Reachable through the ORDINARY data API with no privileged access,\n * and it is the WIDEST road here rather than an exotic one:\n * `sys_webhook.headers_secret` is an admin-authorable field whose own\n * description instructs the author to type a JSON object into it.\n *\n * In all four the row still advertises stored headers on every read path, so\n * returning `undefined` told the caller the opposite of what the row says.\n */\nexport async function resolveWebhookHeaders(\n engine: IDataEngine,\n row: { id: string; [k: string]: unknown },\n object: string,\n): Promise<WebhookHeaders | undefined> {\n const stored = row[WEBHOOK_HEADERS_FIELD];\n // Unset / cleared. On the generic read path a set secret comes back as the\n // engine's mask (a non-empty string) and an unset one as `null`, so presence\n // is decidable here WITHOUT the value ever being readable. Everything below\n // this line therefore runs with \"headers ARE stored\" already established —\n // which is the knowledge the old `undefined` return threw away.\n if (stored == null || stored === '') return undefined;\n\n const resolver = engine as SecretResolvingEngine;\n if (typeof resolver.resolveSecretField !== 'function') {\n // An engine with no encrypted-field channel stored verbatim what the seeder\n // handed it, so the column IS the serialized map — reading it is correct,\n // not a fallback. It can still fail to parse, and that arm used to answer\n // `undefined` too; it is refused here for the same reason as everything\n // else on this seam.\n if (!isOpaqueSecretForm(stored)) {\n return requireHeaderMap(stored, row, `${object}.${WEBHOOK_HEADERS_FIELD}`);\n }\n throw new WebhookHeadersUnresolvableError(\n `Webhook \"${String(row.name ?? row.id)}\" stores encrypted custom headers, but this data engine `\n + 'does not implement resolveSecretField() — they cannot be recovered, so the subscription is '\n + 'dropped rather than delivered without the headers it was authored with (#7986).',\n );\n }\n const plain = await resolver.resolveSecretField(object, String(row.id), WEBHOOK_HEADERS_FIELD);\n if (plain == null || plain === '') {\n throw new WebhookHeadersUnresolvableError(\n `Webhook \"${String(row.name ?? row.id)}\" stores custom headers in `\n + `${object}.${WEBHOOK_HEADERS_FIELD} that resolved to nothing. A value IS stored — the read `\n + 'path returns the engine mask for it — so this is NOT a webhook authored without headers, '\n + 'and delivering it without them would silently drop whatever the author put in that map, '\n + 'including an Authorization credential, on a delivery that is otherwise correctly signed and '\n + 'therefore looks genuine to the receiver (#7986, #8558). Causes, in the order worth checking: '\n + 'the row was deleted while this refresh was reading it; the column holds something that is '\n + 'not a secret: ref (a hand-edited column, or a dump restored without its sys_secret rows); '\n + `or the stored value decrypts to an empty string. ${HEADERS_REMEDY}`,\n );\n }\n return requireHeaderMap(plain, row, `${object}.${WEBHOOK_HEADERS_FIELD}`);\n}\n\n/**\n * Decide what a RE-SEED should do with an existing row's `headers_secret`.\n *\n * Same discipline, and the same reason, as `secretPatch` in\n * `bootstrap-declared-webhooks.ts`: a `secret`-typed write always mints a fresh\n * `sys_secret` ciphertext row and the engine never deletes the superseded one,\n * so blindly restating the declared headers on every boot would leak one orphan\n * cipher row per webhook per restart.\n *\n * - declared map differs from stored ⇒ write it (an edit in code propagates);\n * - identical ⇒ omit the key entirely, leaving the existing ref untouched;\n * - declared headers removed, row still holds some ⇒ write `null` to CLEAR\n * (code remains the authority for package rows);\n * - engine cannot dereference (older engine, or the compare threw) ⇒ fall back\n * to writing the declared value. A correct request beats tidy storage.\n */\nexport async function headersPatch(\n engine: IDataEngine,\n declared: WebhookHeaders | undefined,\n row: { id: string; [k: string]: unknown },\n object: string,\n): Promise<Record<string, unknown>> {\n const hasStored = row?.[WEBHOOK_HEADERS_FIELD] != null && row[WEBHOOK_HEADERS_FIELD] !== '';\n\n if (!declared) return hasStored ? { [WEBHOOK_HEADERS_FIELD]: null } : {};\n\n const serialized = serializeHeaders(declared);\n const resolver = engine as SecretResolvingEngine;\n if (!hasStored || typeof resolver.resolveSecretField !== 'function') {\n return { [WEBHOOK_HEADERS_FIELD]: serialized };\n }\n\n try {\n const current = await resolver.resolveSecretField(object, String(row.id), WEBHOOK_HEADERS_FIELD);\n // Compared as the CANONICAL serialization on both sides, not as raw\n // strings: the stored form was produced by this same function, so key order\n // is stable, and a re-parse guards against a hand-edited value that differs\n // only in whitespace re-encrypting on every boot.\n const stored = parseStoredHeaders(current);\n return stored && serializeHeaders(stored) === serialized\n ? {}\n : { [WEBHOOK_HEADERS_FIELD]: serialized };\n } catch {\n return { [WEBHOOK_HEADERS_FIELD]: serialized };\n }\n}\n","// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\nimport type { IDataEngine, IRealtimeService, RealtimeEventPayload } from '@objectstack/spec/contracts';\nimport type { WebhookTriggerType } from '@objectstack/spec/automation';\nimport type { EnqueueHttpInput } from '@objectstack/service-messaging';\nimport {\n WEBHOOK_SECRET_FIELD,\n WEBHOOK_SECRET_REFUSAL_CODE,\n WEBHOOK_SECRET_REFUSAL_STATUS,\n onCryptoProviderChange,\n readLegacySecret,\n resolveWebhookSecret,\n} from './webhook-secret.js';\nimport {\n WEBHOOK_HEADERS_FIELD,\n readLegacyHeaders,\n resolveWebhookHeaders,\n} from './webhook-headers.js';\n\n/**\n * The authored trigger vocabulary, taken from the spec rather than restated\n * here — this file both validates authored triggers and maps events onto them,\n * so a locally-spelled union would be a second contract free to drift from the\n * one authors are validated against.\n */\ntype WebhookTrigger = WebhookTriggerType;\n\n/**\n * Enqueue callback into the shared `service-messaging` HTTP outbox (ADR-0018 M3).\n * The plugin supplies one bound to `messaging.enqueueHttp(...)`; webhooks no\n * longer own a delivery outbox/dispatcher — they share the generic substrate.\n *\n * [#8069] It MUST be `MessagingService.enqueueHttp`, not `IHttpOutbox.enqueue`.\n * The enqueuer now emits two kinds of input through this one door — an ordinary\n * delivery, and a PARKED event whose subscription lost its credentials — and\n * only the messaging seam routes the second to `recordUndeliverable()`. Wired\n * to the raw outbox instead, the parked input is refused at the delivery door\n * (correctly — the alternative is a `pending` unsigned row) and the durable\n * record is lost; {@link AutoEnqueuer} reports that at `error` rather than\n * letting it pass as an ordinary enqueue failure.\n */\nexport type HttpEnqueueFn = (input: EnqueueHttpInput) => Promise<string>;\n\n/**\n * Which encrypted credential a drop is about, and the words its report needs.\n *\n * Parameterised rather than duplicated because the two reports differ only in\n * the noun and the consequence clause — everything an `error` owes (the\n * consequence, concretely, and the fix) is identical, and a second hand-written\n * copy is how one of them drifts into being less actionable than the other.\n */\ninterface DropReason {\n /** Column the value lives in — travels in the ADR-0112 meta. */\n field: string;\n /** How the credential is named in prose. */\n noun: string;\n /** Indefinite form for \"webhook X holds …\". */\n article: string;\n /** What delivering anyway would mean — the harm being refused. */\n ratherThan: string;\n /** Issue this drop rule comes from. */\n issue: string;\n /** Issue pair for the repeat line. */\n issues: string;\n}\n\nconst SIGNING_SECRET_CREDENTIAL: DropReason = {\n field: WEBHOOK_SECRET_FIELD,\n noun: 'signing secret',\n article: 'an encrypted signing secret',\n ratherThan: 'delivered unsigned',\n issue: '#7799',\n issues: '#7799/#8022',\n};\n\nconst CUSTOM_HEADERS_CREDENTIAL: DropReason = {\n field: WEBHOOK_HEADERS_FIELD,\n noun: 'custom header map',\n article: 'encrypted custom headers',\n ratherThan: 'delivered without the headers it was authored with',\n issue: '#7986',\n issues: '#7986/#8022',\n};\n\n/**\n * Optional logger interface (subset of console / kernel logger).\n */\ninterface OptionalLogger {\n info?(msg: string, meta?: unknown): void;\n warn?(msg: string, meta?: unknown): void;\n debug?(msg: string, meta?: unknown): void;\n error?(msg: string, err?: unknown, meta?: unknown): void;\n}\n\n/**\n * Per-row subscription cached in memory. Mirrors a subset of the\n * `sys_webhook` object — only what the auto-enqueuer needs to match an\n * event and build an `EnqueueInput`.\n */\ninterface CachedSubscription {\n id: string;\n name: string;\n objectName: string | undefined; // empty = matches all objects\n triggers: Set<WebhookTrigger>;\n url: string;\n method?: string;\n headers?: Record<string, string>;\n secret?: string;\n timeoutMs?: number;\n /**\n * [#8069] Set when a credential this subscription needs could not be\n * recovered. The subscription stays CACHED — that is the change — but every\n * event it matches is written to `sys_http_delivery` as a parked `dead` row\n * carrying this text, instead of being discarded with nothing to find.\n *\n * Before this, `attachCredentials` returning false removed the row from the\n * cache entirely, so matching events found no subscription and vanished:\n * fail-closed and correct, but leaving an operator with a log line (#8043)\n * and no durable trace. A parked subscription is still fail-closed —\n * {@link secret} and {@link headers} stay unset, so nothing can be sent —\n * it is merely no longer silent.\n */\n parkedReason?: string;\n}\n\nexport interface AutoEnqueuerOptions {\n /**\n * Object name holding webhook subscriptions. Defaults to `sys_webhook`,\n * the platform-objects schema authored in apps.\n */\n subscriptionsObject?: string;\n\n /**\n * Periodic full-cache refresh interval (ms). Belt-and-braces in case\n * the subscription-change event is missed. Default 60s.\n */\n refreshIntervalMs?: number;\n\n logger?: OptionalLogger;\n}\n\n/**\n * Bridge between `IRealtimeService` (`data.record.*` events emitted by\n * the engine) and `IWebhookOutbox` (durable delivery rows the dispatcher\n * picks up).\n *\n * ## Why a separate class\n * Keeps `WebhookOutboxPlugin` lean: the plugin wires services, this\n * class owns the runtime fan-out logic + subscription cache.\n *\n * ## Hot path\n * Every `engine.insert/update/delete` fires a `data.record.*` event.\n * The handler:\n * 1. Looks up matching subscriptions in an in-memory `Map<object, sub[]>`\n * — O(1) per event, no DB hit on the write path.\n * 2. Calls `outbox.enqueue()` fire-and-forget for each match. The\n * enqueue itself is a single INSERT, which runs *after* the user's\n * request has already returned.\n *\n * Net cost on the write path: one synchronous Map lookup (~microseconds).\n *\n * ## Cache freshness\n * The cache is rebuilt:\n * 1. Once on `start()`.\n * 2. On every `data.record.{created,updated,deleted}` event whose\n * object is `sys_webhook` (self-healing — when a user toggles a\n * webhook, the handler refreshes the cache before returning).\n * 3. Periodically (default 60s) as belt-and-braces.\n *\n * For multi-node clusters this is *eventually consistent* — node B may\n * not see node A's edit for up to one cycle. That's acceptable for\n * webhook configuration changes (humans don't expect millisecond\n * propagation) and matches Hasura's behaviour.\n *\n * ## Determinism\n * `eventId` is computed from `${object}:${recordId}:${type}:${timestamp}`\n * so the outbox dedup index catches duplicates that could arise from\n * upstream replay or buggy producers — and is stable across nodes.\n *\n * An aggregate `data.records.*` event (#4639) has no record to key on, so it\n * dedups on the producer's event uuid instead: two predicate sweeps in the\n * same millisecond are genuinely different events and must not collapse into\n * one delivery, which a timestamp-based key would do.\n */\nexport class AutoEnqueuer {\n private readonly subscriptions = new Map<string, CachedSubscription[]>();\n private readonly subscriptionsObject: string;\n private readonly refreshIntervalMs: number;\n private readonly logger: OptionalLogger;\n private subId: string | undefined;\n private subIdSelfHeal: string | undefined;\n private refreshTimer: ReturnType<typeof setInterval> | undefined;\n private running = false;\n private refreshing: Promise<void> | undefined;\n /** [#8022] Detach for the engine's crypto-registration listener. */\n private unbindCryptoListener: (() => void) | undefined;\n /**\n * [#8022] Webhook ids currently dropped for an unresolvable credential —\n * the signing key (#7799) or, since #7986, the custom header map. ONE set\n * for both on purpose: a subscription is either armed or dropped, so a\n * per-credential ledger would let a row already silenced for its key report\n * loudly again for its headers on the very next refresh.\n * Held so the loud first report is said ONCE per outage (AGENTS.md\n * \"Degradation log levels\": *say it once, at the first degradation*) and\n * again if the same webhook breaks after recovering — not once per row per\n * refresh, forever.\n */\n private readonly droppedForSecret = new Set<string>();\n\n constructor(\n private readonly engine: IDataEngine,\n private readonly realtime: IRealtimeService,\n private readonly enqueue: HttpEnqueueFn,\n opts: AutoEnqueuerOptions = {},\n ) {\n this.subscriptionsObject = opts.subscriptionsObject ?? 'sys_webhook';\n this.refreshIntervalMs = opts.refreshIntervalMs ?? 60_000;\n this.logger = opts.logger ?? {};\n }\n\n /**\n * Load the subscription cache and start listening for events.\n */\n async start(): Promise<void> {\n if (this.running) return;\n this.running = true;\n\n // [#8022] Bound BEFORE the first build, not after: on every host the\n // composition root wires the CryptoProvider after `runtime.start()`\n // returns, i.e. after the `kernel:ready` handler that runs this method\n // — so the registration we need to hear about can land at any point\n // from here on, including while the await below is still in flight.\n // Subscribing first makes that unmissable; subscribing after the\n // refresh would reintroduce the same race in miniature.\n this.unbindCryptoListener = onCryptoProviderChange(this.engine, () =>\n this.rearmAfterCryptoRegistered(),\n );\n\n await this.refresh();\n\n // Main subscription: every data event → match → enqueue.\n this.subId = await this.realtime.subscribe(\n 'webhook-auto-enqueuer',\n (event) => this.handleEvent(event),\n );\n\n // Self-healing: any change to sys_webhook refreshes the cache.\n this.subIdSelfHeal = await this.realtime.subscribe(\n 'webhook-auto-enqueuer-self-heal',\n (event) => this.handleSelfHealEvent(event),\n { object: this.subscriptionsObject },\n );\n\n if (this.refreshIntervalMs > 0) {\n this.refreshTimer = setInterval(() => {\n this.refresh().catch((err) =>\n this.logger.warn?.('[webhook-auto-enqueuer] periodic refresh failed', err),\n );\n }, this.refreshIntervalMs);\n // Don't keep the process alive solely for this timer.\n this.refreshTimer.unref?.();\n }\n }\n\n async stop(): Promise<void> {\n if (!this.running) return;\n this.running = false;\n if (this.subId) await this.realtime.unsubscribe(this.subId);\n if (this.subIdSelfHeal) await this.realtime.unsubscribe(this.subIdSelfHeal);\n if (this.refreshTimer) clearInterval(this.refreshTimer);\n this.unbindCryptoListener?.();\n this.subId = undefined;\n this.subIdSelfHeal = undefined;\n this.refreshTimer = undefined;\n this.unbindCryptoListener = undefined;\n }\n\n /**\n * [#8022] The engine just gained a CryptoProvider — rebuild the cache so\n * subscriptions dropped for an unresolvable signing key re-arm now, instead\n * of at the next periodic refresh up to {@link refreshIntervalMs} away.\n *\n * It deliberately does NOT call {@link refresh} directly. `refresh()`\n * coalesces onto an in-flight build, and the build most likely to be in\n * flight right now is the one from `start()` — the very build whose rows\n * were read while there was no provider. Joining it would return \"refreshed\"\n * having re-armed nothing, which is this issue with an extra step. So: let\n * whatever is running finish, then read again.\n */\n private rearmAfterCryptoRegistered(): void {\n const inFlight = this.refreshing ?? Promise.resolve();\n void inFlight\n // A failed in-flight refresh already logged; it must not stop the\n // re-arm, which is the whole point of this callback.\n .catch(() => undefined)\n .then(() => (this.running ? this.refresh() : undefined))\n .catch((err) =>\n this.logger.warn?.(\n '[webhook-auto-enqueuer] re-arm after CryptoProvider registration failed',\n err,\n ),\n );\n }\n\n /**\n * Force-refresh the subscription cache from storage. Concurrent\n * callers share a single in-flight refresh.\n */\n async refresh(): Promise<void> {\n if (this.refreshing) return this.refreshing;\n this.refreshing = this.doRefresh().finally(() => {\n this.refreshing = undefined;\n });\n return this.refreshing;\n }\n\n private async doRefresh(): Promise<void> {\n let rows: any[];\n try {\n rows = await this.engine.find(this.subscriptionsObject, {\n where: { active: true },\n });\n } catch (err) {\n this.logger.warn?.(\n `[webhook-auto-enqueuer] failed to load ${this.subscriptionsObject}`,\n err,\n );\n return;\n }\n\n const next = new Map<string, CachedSubscription[]>();\n for (const row of rows) {\n const sub = this.parseRow(row);\n if (!sub) continue;\n // [#7799, #7986] Neither credential is in the row we just read —\n // the signing key and the custom header map both live encrypted in\n // `sys_secret`, and this read path returns only a mask. Dereference\n // them here, on the 60s refresh, rather than per event: the cache\n // already holds the plaintext in memory (it always did), so this\n // changes where the values come FROM, not how long they are held. A\n // row whose credentials cannot be recovered is PARKED — cached with\n // `parkedReason` set and no credentials, so its events are recorded\n // as undeliverable instead of silently discarded (#8069). See\n // `attachCredentials`.\n await this.attachCredentials(sub, row);\n // Empty objectName == \"any object\" → indexed under '*'.\n const key = sub.objectName ?? '*';\n const arr = next.get(key) ?? [];\n arr.push(sub);\n next.set(key, arr);\n }\n\n this.subscriptions.clear();\n for (const [k, v] of next) this.subscriptions.set(k, v);\n\n // [#8022] Forget rows this refresh no longer sees — deleted, or\n // deactivated. Otherwise the set grows for the life of the process, and\n // a webhook turned off while broken and later turned back on still\n // broken would have its first report suppressed as a repeat.\n if (this.droppedForSecret.size > 0) {\n const live = new Set(rows.map((r) => String(r?.id)));\n for (const id of this.droppedForSecret) {\n if (!live.has(id)) this.droppedForSecret.delete(id);\n }\n }\n\n this.logger.debug?.('[webhook-auto-enqueuer] cache refreshed', {\n objects: this.subscriptions.size,\n rows: rows.length,\n });\n }\n\n /**\n * [#7799, #7986] Resolve BOTH encrypted credentials for one cached\n * subscription. Returns `false` when the subscription must be dropped from\n * the cache.\n *\n * The two halves are deliberately resolved on the SAME build rather than on\n * separate schedules. #8022's re-arm rebuilds the whole cache when a\n * CryptoProvider registers; a header map recovered on any other cadence\n * would let the enqueuer re-arm into a delivery that is correctly signed and\n * silently missing its `Authorization`, which is the failure mode of both\n * cards at once.\n *\n * The drop ledger is cleared only when BOTH succeed — otherwise a row whose\n * secret resolves and whose headers do not would clear its own \"already\n * reported\" mark on every refresh and shout the same `error` every 60s,\n * which is precisely the unreadable-error-channel failure #8022's say-once\n * rule exists to prevent.\n *\n * Cost: up to two point reads + two decrypts per credential-bearing row per\n * refresh (default 60s), off the write path entirely. Deliberately NOT\n * memoised across refreshes — the only cheap cache key would be\n * `updated_at`, which nothing guarantees is stamped when a credential is\n * rotated, and a stale key signs every delivery with a signature the\n * receiver rejects.\n */\n private async attachCredentials(sub: CachedSubscription, row: any): Promise<boolean> {\n if (!(await this.attachSecret(sub, row))) return false;\n if (!(await this.attachHeaders(sub, row))) return false;\n // Recovered — a later break is a new outage and gets said loudly again\n // rather than being swallowed as a repeat.\n this.droppedForSecret.delete(sub.id);\n return true;\n }\n\n /**\n * [#8069] Mark a subscription parked and strip anything sendable off it.\n *\n * Called from the two `attachX` failure paths, which each already reported\n * the drop at `error` (say-once, #8022). The credentials are cleared rather\n * than merely \"not set\": `attachSecret` can succeed and `attachHeaders`\n * fail, and a parked row must not carry the header map — that map is the\n * ordinary place an `Authorization: Bearer …` goes (#7986), and copying it\n * onto a row that will sit in `sys_http_delivery` for the full 30d\n * retention window without ever being sent is a credential copy bought for\n * nothing.\n */\n /**\n * [#8069] Report a failed outbox write off the hot path, at the level the\n * loss actually deserves.\n *\n * AGENTS.md decides that with one question — *does the system still look\n * normal from the outside while something it claims is persisted has not\n * landed?* For a PARKED subscription the answer is unambiguously yes, and\n * worse than for an ordinary enqueue failure: the durable record is the\n * only trace this event ever existed, so losing the write puts us back\n * exactly where this issue started, silently. So `error` there, and the\n * pre-existing `warn` for an ordinary enqueue, where the delivery itself is\n * the thing that did not happen and the subscription is otherwise healthy.\n *\n * The realistic cause of the parked branch is a host that wired\n * {@link HttpEnqueueFn} straight to `IHttpOutbox.enqueue` instead of\n * `MessagingService.enqueueHttp`: only the messaging seam routes a parked\n * input to `recordUndeliverable()`, and the raw delivery door refuses the\n * discriminator rather than minting a `pending` unsigned row from it. The\n * message names that, because it is not guessable from \"enqueue failed\".\n */\n private reportWriteFailure(\n sub: CachedSubscription,\n eventId: string,\n err: unknown,\n verb: string,\n ): void {\n const meta = { webhook: sub.name, eventId, err: (err as Error)?.message ?? err };\n if (!sub.parkedReason) {\n this.logger.warn?.(`[webhook-auto-enqueuer] ${verb} failed`, meta);\n return;\n }\n const message =\n `[webhook-auto-enqueuer] could not record the undeliverable event for webhook `\n + `'${sub.name}' — the subscription is parked for an unresolvable credential, and this `\n + `event is now DISCARDED WITH NO TRACE in sys_http_delivery, which is the durability `\n + `gap #8069 closes. Most likely cause: the enqueue callback was wired directly to `\n + `IHttpOutbox.enqueue instead of MessagingService.enqueueHttp — only the messaging seam `\n + `routes a parked event to recordUndeliverable(), and the delivery door refuses it `\n + `rather than minting a pending row that would be sent UNSIGNED.`;\n if (typeof this.logger.error === 'function') {\n this.logger.error(message, err, meta);\n } else {\n this.logger.warn?.(message, meta);\n }\n }\n\n private park(sub: CachedSubscription, err: unknown, credential: DropReason): void {\n sub.secret = undefined;\n sub.headers = undefined;\n sub.parkedReason =\n `[${WEBHOOK_SECRET_REFUSAL_CODE}/${WEBHOOK_SECRET_REFUSAL_STATUS}] webhook '${sub.name}' `\n + `holds ${credential.article} that could not be decrypted, so this event was NOT `\n + `delivered — recording it here rather than ${credential.ratherThan} (${credential.issue}, `\n + `#8069). This row was never sent and cannot be redelivered: it carries no HMAC signature, `\n + `because the ${credential.noun} that would have produced one is exactly what is missing. `\n + `Fix: register a CryptoProvider (engine.setCryptoProvider — LocalCryptoProvider in dev, `\n + `KMS/Vault in production) with the same key the ${credential.noun} was written under, and `\n + `make sure the sys_secret row is reachable; the subscription re-arms on registration `\n + `(#8022) and at the next periodic refresh, and later events are delivered normally. `\n + `Cause: ${(err as Error)?.message ?? String(err)}`;\n }\n\n /**\n * [#7799] Resolve `sub.secret`. Returns `false` when the subscription must\n * be dropped.\n *\n * Three sources, in order:\n * 1. `sys_webhook.signing_secret` — the encrypted column. The read path\n * returns a mask, so presence is decidable here but the value is not;\n * `resolveWebhookSecret` dereferences it server-side.\n * 2. `definition_json.secret` — a row not yet swept by\n * `migrateLegacyWebhookSecrets` (or hand-edited back in). Still honoured\n * so an un-migrated deployment keeps signing, and warned about once per\n * refresh so the exposure is visible rather than silently permanent.\n * 3. Neither — an unsigned webhook, which is a legitimate authored choice\n * (`secret` is optional on the envelope).\n *\n * A stored-but-unresolvable key DROPS the subscription instead of\n * delivering unsigned. The signature is the receiver's only proof of\n * origin (#7722, #7799): a webhook that stops arriving is visible and gets\n * investigated, while one that keeps arriving unsigned is invisible and\n * teaches the receiver to accept unauthenticated traffic.\n *\n * [#8542] Case 3 means what it says only because the seam was fixed to say\n * it. `resolveWebhookSecret` used to answer `undefined` for BOTH \"no key is\n * stored\" and \"a key is stored and did not come back\", so this method read\n * the second as the third and armed the subscription — the invariant above\n * failing OPEN, silently, on the producer path. Nothing here changed: the\n * seam now raises for that case, so it lands in the `catch` below exactly\n * the way a throwing resolver already did, and the drop, the say-once\n * `error` and the #8069 park all apply to it unchanged.\n */\n private async attachSecret(sub: CachedSubscription, row: any): Promise<boolean> {\n try {\n const stored = await resolveWebhookSecret(this.engine, row, this.subscriptionsObject);\n if (stored) {\n sub.secret = stored;\n return true;\n }\n } catch (err) {\n this.reportDrop(sub, err, SIGNING_SECRET_CREDENTIAL);\n this.park(sub, err, SIGNING_SECRET_CREDENTIAL);\n return false;\n }\n\n const legacy = readLegacySecret(row?.definition_json);\n if (legacy) {\n this.logger.warn?.(\n `[webhook-auto-enqueuer] webhook '${sub.name}' still carries its signing secret as ` +\n `CLEARTEXT in definition_json, readable over the data API (#7799). Signing continues ` +\n `from it; run the boot sweep (migrateLegacyWebhookSecrets) with a CryptoProvider wired ` +\n `to move it into sys_secret.`,\n { id: sub.id },\n );\n sub.secret = legacy;\n }\n return true;\n }\n\n /**\n * [#7986] Resolve `sub.headers` from the encrypted column, with the same\n * three-source shape as {@link attachSecret} and for the same reasons.\n *\n * A stored-but-unresolvable header map DROPS the subscription rather than\n * delivering without it. That is the identical trade #7799 made for the\n * signature, and it needs restating because the intuition runs the other\n * way: a missing `Authorization` looks self-announcing, since the receiver\n * answers 401 and the attempt lands in `sys_http_delivery` for anyone to\n * find. But that is only the AUTHENTICATED case. Against an endpoint that\n * does not require the header — a routing `X-Tenant-Id`, an\n * `X-Environment: staging` — the delivery SUCCEEDS while quietly deviating\n * from the configuration the author wrote, and nothing anywhere records\n * that it went out incomplete. A subscription that stops is visible; a\n * delivery that arrives subtly wrong is not.\n *\n * [#8558] And that is what this method used to do, for the same reason its\n * signing sibling did (#8542): `resolveWebhookHeaders` answered `undefined`\n * for BOTH \"no headers are stored\" and \"a map is stored and did not come\n * back as one\", so this method read the second as the first and armed the\n * subscription — the paragraph above failing OPEN. Measured, the delivery\n * then went out SUCCESSFULLY and correctly SIGNED with the whole authored\n * map missing, which is the worst available combination: the signature\n * tells the receiver the request is genuinely ours. Nothing here changed:\n * the seam now raises, so it lands in the `catch` below exactly the way a\n * throwing resolver already did, and the drop, the say-once `error` and the\n * #8069 park all apply to it unchanged.\n */\n private async attachHeaders(sub: CachedSubscription, row: any): Promise<boolean> {\n try {\n const stored = await resolveWebhookHeaders(this.engine, row, this.subscriptionsObject);\n if (stored) {\n sub.headers = stored;\n return true;\n }\n } catch (err) {\n this.reportDrop(sub, err, CUSTOM_HEADERS_CREDENTIAL);\n this.park(sub, err, CUSTOM_HEADERS_CREDENTIAL);\n return false;\n }\n\n const legacy = readLegacyHeaders(row?.definition_json);\n if (legacy) {\n this.logger.warn?.(\n `[webhook-auto-enqueuer] webhook '${sub.name}' still carries its custom headers as ` +\n `CLEARTEXT in definition_json, readable over the data API (#7986) — that map is the ` +\n `ordinary place an Authorization header goes. Delivery continues from it; run the boot ` +\n `sweep (migrateLegacyWebhookSecrets) with a CryptoProvider wired to move them into ` +\n `sys_secret.`,\n { id: sub.id },\n );\n sub.headers = legacy;\n }\n return true;\n }\n\n /**\n * [#8022] Report a subscription dropped for an unresolvable signing key.\n *\n * ## Why `error`, and why only the first time\n * AGENTS.md decides the level with one question: *after the degradation,\n * does the system still look normal from the outside while something the\n * system claims is happening is not?* Here the answer is yes, and it is the\n * whole defect — `GET /api/v1/data/sys_webhook` keeps reading\n * `active: true`, Setup keeps showing the webhook armed, and every matching\n * record change is discarded with no delivery and no `sys_http_delivery`\n * row to find afterwards. That is a durability degradation wearing a\n * functional degradation's clothes, so it owes the two things an `error`\n * owes: the consequence, concretely, and the fix.\n *\n * Said ONCE per outage per webhook, per the same section. The cache is\n * rebuilt every {@link refreshIntervalMs}; an unfixed misconfiguration would\n * otherwise print this line every 60s forever, which is how an `error`\n * channel becomes unreadable — the failure mode that made the founding\n * incident's `warn` invisible. Repeats drop to `debug`; a recovery clears\n * the id, so a re-break is loud again.\n *\n * ADR-0112: `code` + `status` travel in the meta so a consumer branches on\n * the pair, not on message text. Same pair the seeder's refusal carries for\n * the same underlying cause.\n */\n private reportDrop(\n sub: CachedSubscription,\n err: unknown,\n credential: DropReason = SIGNING_SECRET_CREDENTIAL,\n ): void {\n const meta = {\n id: sub.id,\n webhook: sub.name,\n field: credential.field,\n code: WEBHOOK_SECRET_REFUSAL_CODE,\n status: WEBHOOK_SECRET_REFUSAL_STATUS,\n err: (err as Error)?.message ?? err,\n };\n if (this.droppedForSecret.has(sub.id)) {\n this.logger.debug?.(\n `[webhook-auto-enqueuer] webhook '${sub.name}' is still dropped for an unresolvable ` +\n `${credential.noun} (${credential.issues})`,\n meta,\n );\n return;\n }\n this.droppedForSecret.add(sub.id);\n // [#8069] The consequence clause used to end \"…with NO delivery and NO\n // sys_http_delivery row\". The second half is no longer true — that is\n // precisely what this card changed — and an `error` that misdescribes\n // the consequence sends an operator looking in the wrong place, which\n // is worse than the old accurate-but-bleaker line. It now names where\n // the evidence IS.\n const message =\n `[webhook-auto-enqueuer] webhook '${sub.name}' holds ${credential.article} that ` +\n `could not be decrypted — the subscription is PARKED rather than ${credential.ratherThan} ` +\n `(${credential.issue}), so every matching record change is discarded with NO delivery, ` +\n 'while the row keeps reading active:true in Setup. Each discarded event IS recorded in ' +\n 'sys_http_delivery as a dead row with 0 attempts carrying this cause (#8069) — look there ' +\n 'for the backlog; those rows can never be sent or redelivered, because a parked row has no ' +\n 'HMAC signature. Fix: register a ' +\n 'CryptoProvider (engine.setCryptoProvider — LocalCryptoProvider in dev, KMS/Vault in ' +\n `production) with the same key the ${credential.noun} was written under, and make sure the ` +\n 'sys_secret row is reachable; the subscription re-arms on registration (#8022) and at the ' +\n 'next periodic refresh.';\n // The logger surface is a subset of console/kernel logger — `error` is\n // optional on it, so fall back rather than silently losing the report\n // on a logger that only implements `warn`.\n if (typeof this.logger.error === 'function') {\n this.logger.error(message, err, meta);\n } else {\n this.logger.warn?.(message, meta);\n }\n }\n\n private parseRow(row: any): CachedSubscription | null {\n if (!row?.id || !row?.url) return null;\n // `triggers` is now authored as a multi-select (stored as an array), but\n // legacy rows stored a comma-separated string (and some drivers hand a\n // JSON-encoded array back as a string). Accept all three shapes so a\n // schema change never silently drops a subscription's events.\n const rawTriggers = row.triggers;\n let triggerList: string[];\n if (Array.isArray(rawTriggers)) {\n triggerList = rawTriggers.map((t) => String(t));\n } else {\n const s = String(rawTriggers ?? '').trim();\n if (s.startsWith('[')) {\n try {\n const parsed = JSON.parse(s);\n triggerList = Array.isArray(parsed) ? parsed.map((t) => String(t)) : [s];\n } catch {\n triggerList = s.split(',');\n }\n } else {\n triggerList = s.split(',');\n }\n }\n const normalized = triggerList.map((t) => t.trim().toLowerCase()).filter(Boolean);\n // [#3196] Drop (and warn about) any trigger the enqueuer can't map to an\n // emitted record event — e.g. a legacy `sys_webhook` row authored with\n // the now-removed `undelete`/`api` values, which would otherwise sit in\n // the cache matching nothing. A loud drift-guard so a dead trigger can't\n // silently no-op again.\n const unknown = normalized.filter((t) => !DISPATCHABLE_WEBHOOK_TRIGGERS.has(t));\n if (unknown.length > 0) {\n this.logger.warn?.(\n `[webhook-auto-enqueuer] webhook '${(row.name as string) ?? row.id}' declares trigger(s) the engine never emits: ` +\n `${unknown.join(', ')} — ignored. Dispatchable triggers: ` +\n `${[...DISPATCHABLE_WEBHOOK_TRIGGERS].join(', ')}.`,\n { id: row.id, unknown },\n );\n }\n const triggers = new Set(\n normalized.filter((t) => DISPATCHABLE_WEBHOOK_TRIGGERS.has(t)) as WebhookTrigger[],\n );\n if (triggers.size === 0) {\n // [ADR-0078 Phase 4] No dispatchable triggers — the webhook can\n // never fire on ANY path, so say so instead of skipping silently.\n // This comment used to read \"(or a manual-only webhook with\n // none)\", but that mode does not exist: the `api` trigger was\n // REMOVED (#3196, `webhook.zod.ts`) precisely because there is no\n // manual fire path — the only webhook HTTP surface re-queues\n // already-failed deliveries. So a zero-trigger row is not an off\n // switch (that is `active`), it is a dead subscription that looks\n // armed in Setup. Same rule id as the author-time gate\n // (`webhook/without-triggers`) so the boot log greps into the\n // same docs. Only active rows reach parseRow, so a deliberately\n // disabled webhook stays warning-free.\n this.logger.warn?.(\n `[webhook-auto-enqueuer] webhook '${(row.name as string) ?? row.id}' has no dispatchable ` +\n `triggers — it will NEVER fire (rule webhook/without-triggers): there is no manual fire ` +\n `path (#3196), so this row is dead while looking armed in Setup. Declare ` +\n `one of: ${[...DISPATCHABLE_WEBHOOK_TRIGGERS].join(', ')}, or set it inactive if it ` +\n `should be off.`,\n { id: row.id },\n );\n return null;\n }\n\n // The \"definition_json\" field carries advanced config (timeout);\n // attempt a best-effort parse. Fall back to top-level fields where\n // present. It no longer carries either credential — the signing secret\n // (#7799) and the custom headers (#7986) are both sourced from their\n // encrypted columns by `attachCredentials`.\n let defn: Record<string, any> = {};\n if (typeof row.definition_json === 'string' && row.definition_json.length > 0) {\n try {\n defn = JSON.parse(row.definition_json) ?? {};\n } catch {\n defn = {};\n }\n }\n\n return {\n id: row.id as string,\n name: (row.name as string) ?? row.id,\n objectName: row.object_name ? String(row.object_name) : undefined,\n triggers,\n url: String(row.url),\n // Method is authored via a select whose option values are lowercased\n // (get/post/…); upper-case here so delivery uses a canonical HTTP\n // method regardless of whether the row was authored before or after\n // the select change (legacy rows stored 'POST').\n method: String(row.method ?? defn.method ?? 'POST').toUpperCase(),\n // `headers` and `secret` are both filled by attachCredentials()\n // from their encrypted columns, NOT read off the row — see #7799\n // (secret) and #7986 (headers).\n timeoutMs: defn.timeoutMs,\n };\n }\n\n /**\n * Handler for the firehose subscription.\n *\n * NOTE: we intentionally `void` the inner enqueue() so the realtime\n * publisher (and therefore the user's request) is never blocked on\n * webhook persistence.\n */\n private handleEvent(event: RealtimeEventPayload): void {\n if (!event.object) return;\n if (event.object === this.subscriptionsObject) return; // self-heal handles its own\n\n // [#4639] A predicate write publishes the aggregate `data.records.*`\n // instead, which has no record to describe — separate path, separate\n // trigger, separate delivery shape.\n if (event.type?.startsWith('data.records.')) {\n this.handleBulkEvent(event);\n return;\n }\n if (!event.type?.startsWith('data.record.')) return;\n\n const action = event.type.slice('data.record.'.length) as\n | 'created' | 'updated' | 'deleted' | string;\n const trigger = mapActionToTrigger(action);\n if (!trigger) return;\n\n const subs = [\n ...(this.subscriptions.get(event.object) ?? []),\n ...(this.subscriptions.get('*') ?? []),\n ];\n if (subs.length === 0) return;\n\n // [#4626] The envelope's `payload` IS the spec's `DataEvent`\n // (`@objectstack/spec/api`): `recordId` is a REQUIRED top-level string\n // the ObjectQL engine validates before publishing. Read it directly.\n // The old `recordId ?? id ?? after?.id ?? before?.id ?? 'unknown'`\n // chain was consumer-side tolerance for a producer that never filled\n // the contract (AGENTS.md PD #12) — and its `'unknown'` fallback\n // silently turned an unnameable record into a delivered webhook. An\n // off-contract event is now DROPPED loudly: the producer is broken and\n // gets fixed there.\n const payload = event.payload ?? {};\n const recordId = (payload as { recordId?: unknown }).recordId;\n if (typeof recordId !== 'string' || recordId === '') {\n this.logger.warn?.(\n '[webhook-auto-enqueuer] dropping off-contract data event: payload is not a DataEvent ' +\n '(no top-level string `recordId`) — fix the producer',\n { type: event.type, object: event.object },\n );\n return;\n }\n\n // Deterministic eventId — same input on any node → same id.\n // Includes timestamp so two distinct updates to the same record\n // don't accidentally dedup.\n const eventId = `${event.object}:${recordId}:${action}:${event.timestamp}`;\n\n for (const sub of subs) {\n if (!sub.triggers.has(trigger)) continue;\n\n // Fire-and-forget — never await on the hot path. Map the webhook\n // delivery onto the generic HTTP-outbox shape (ADR-0018 M3):\n // - source 'webhook' + dedupKey '<webhookId>:<eventId>' preserves\n // the old (event_id, webhook_id) at-most-once enqueue;\n // - refId = webhookId keeps per-webhook partition affinity / ordering;\n // - label = event type → X-Objectstack-Event header.\n void this.enqueue({\n source: 'webhook',\n refId: sub.id,\n dedupKey: `${sub.id}:${eventId}`,\n label: event.type,\n url: sub.url,\n method: sub.method,\n headers: sub.headers,\n signingSecret: sub.secret,\n // [#8069] Set only for a PARKED subscription, and then this is\n // not an enqueue at all: the messaging seam routes it to\n // `recordUndeliverable()`, which writes a terminal `dead` row\n // with this reason and no signature. Undefined for every healthy\n // subscription, so the delivery path is byte-identical to before.\n undeliverableReason: sub.parkedReason,\n timeoutMs: sub.timeoutMs,\n // [#3946] Envelope keys are written LAST so the event payload\n // cannot rewrite them. Behaviour-neutral for the engine's own\n // publishers — since #4626 a `data.record.*` payload is a\n // `DataEvent` (`id`, `type`, `object`, `recordId`, `changes?`,\n // `after?`, `userId?`, `timestamp`), whose `object` /\n // `recordId` / `timestamp` carry the SAME values written here\n // and whose record fields stay nested under `after`. It is the\n // shape that was wrong: a publisher that flattened record\n // fields into the payload would have silently rewritten the\n // `object` / `action` / `timestamp` a subscriber receives.\n payload: {\n ...payload,\n object: event.object,\n recordId,\n action,\n timestamp: event.timestamp,\n },\n }).catch((err) => this.reportWriteFailure(sub, eventId, err, 'enqueue'));\n }\n }\n\n /**\n * Handler for aggregate `data.records.*` events — a predicate write\n * (`multi: true`) that the driver reports only as an affected-row count\n * (#4639).\n *\n * Deliberately NOT folded into {@link handleEvent}'s per-record path. The\n * delivered body has no `recordId` and no record fields, so a subscriber\n * to `update` that started receiving these would get a payload missing\n * everything it reads — which is how the pre-#4626 `recordId: ''`\n * fabrication broke consumers, just arriving from the other side. A\n * webhook opts in with `bulk_update` / `bulk_delete`.\n */\n private handleBulkEvent(event: RealtimeEventPayload): void {\n const action = event.type.slice('data.records.'.length);\n const trigger = mapBulkActionToTrigger(action);\n if (!trigger) return;\n\n const subs = [\n ...(this.subscriptions.get(event.object!) ?? []),\n ...(this.subscriptions.get('*') ?? []),\n ];\n if (subs.length === 0) return;\n\n // Same contract discipline as the per-record path: the payload IS the\n // spec's `BulkDataEvent`, whose `matched` the engine validates before\n // publishing. An off-contract event is dropped loudly rather than\n // delivered with a guessed count — `matched` is the entire substance\n // of a bulk delivery, so a wrong one is worse than none.\n const payload = event.payload ?? {};\n const matched = (payload as { matched?: unknown }).matched;\n if (typeof matched !== 'number' || !Number.isInteger(matched) || matched < 0) {\n this.logger.warn?.(\n '[webhook-auto-enqueuer] dropping off-contract bulk data event: payload is not a ' +\n 'BulkDataEvent (no top-level non-negative integer `matched`) — fix the producer',\n { type: event.type, object: event.object },\n );\n return;\n }\n\n // A predicate write has no natural key to build a deterministic id\n // from — `${object}:${action}:${timestamp}` would collide between two\n // sweeps landing in the same millisecond, and silently drop the\n // second. The producer's own event uuid is generated once and travels\n // with the event, so it dedups redelivery of the SAME event without\n // ever conflating two distinct ones.\n const eventUuid = (payload as { id?: unknown }).id;\n if (typeof eventUuid !== 'string' || eventUuid === '') {\n this.logger.warn?.(\n '[webhook-auto-enqueuer] dropping off-contract bulk data event: payload has no ' +\n 'top-level string `id` to dedup on — fix the producer',\n { type: event.type, object: event.object },\n );\n return;\n }\n const eventId = `${event.object}:${event.type}:${eventUuid}`;\n\n for (const sub of subs) {\n if (!sub.triggers.has(trigger)) continue;\n\n void this.enqueue({\n source: 'webhook',\n refId: sub.id,\n dedupKey: `${sub.id}:${eventId}`,\n label: event.type,\n url: sub.url,\n method: sub.method,\n headers: sub.headers,\n signingSecret: sub.secret,\n // [#8069] See the per-record path — parked subscriptions record\n // an undeliverable row instead of enqueuing a delivery.\n undeliverableReason: sub.parkedReason,\n timeoutMs: sub.timeoutMs,\n // [#3946] Envelope keys last so the payload cannot rewrite them.\n payload: {\n ...payload,\n object: event.object,\n matched,\n action,\n timestamp: event.timestamp,\n },\n }).catch((err) => this.reportWriteFailure(sub, eventId, err, 'bulk enqueue'));\n }\n }\n\n private handleSelfHealEvent(event: RealtimeEventPayload): void {\n if (event.object !== this.subscriptionsObject) return;\n // [#4639] A predicate write over `sys_webhook` (deactivate every\n // webhook on an object, say) changes the subscription set exactly like\n // a per-record edit does, so it must refresh the cache too — matching\n // only `data.record.` would leave the enqueuer dispatching from rows\n // the admin just turned off.\n if (!event.type?.startsWith('data.record.') && !event.type?.startsWith('data.records.')) return;\n this.refresh().catch((err) =>\n this.logger.warn?.('[webhook-auto-enqueuer] self-heal refresh failed', err),\n );\n }\n\n /** Test / admin accessor. */\n snapshot(): ReadonlyMap<string, ReadonlyArray<CachedSubscription>> {\n return this.subscriptions;\n }\n}\n\nfunction mapActionToTrigger(\n action: string,\n): 'create' | 'update' | 'delete' | null {\n switch (action) {\n case 'created':\n return 'create';\n case 'updated':\n return 'update';\n case 'deleted':\n return 'delete';\n default:\n return null;\n }\n}\n\n/** [#4639] `data.records.{action}` → its opt-in bulk trigger. */\nfunction mapBulkActionToTrigger(action: string): 'bulk_update' | 'bulk_delete' | null {\n switch (action) {\n case 'updated':\n return 'bulk_update';\n case 'deleted':\n return 'bulk_delete';\n default:\n return null;\n }\n}\n\n/** The trigger values the enqueuer can actually map from an emitted record event. */\nconst DISPATCHABLE_WEBHOOK_TRIGGERS: ReadonlySet<string> = new Set([\n 'create',\n 'update',\n 'delete',\n 'bulk_update',\n 'bulk_delete',\n]);\n","// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * bootstrapDeclaredWebhooks — materialize stack/connector-declared `webhooks`\n * into `sys_webhook` rows so the dispatcher can actually see them (closes #3461).\n *\n * ## The disconnect this closes\n * The spec authoring surface (`WebhookSchema` — `defineStack({ webhooks })`,\n * `@objectstack/spec/automation/webhook`) declares `object` / `isActive`, and\n * is generically decomposed into the ObjectQL registry at boot as metadata\n * type `webhook`. But the runtime dispatcher ({@link AutoEnqueuer}) reads\n * `sys_webhook` DATA rows (`object_name` / `active`), which until now were only\n * ever written by hand through the object's CRUD UI. Nothing bridged the two —\n * so authoring `webhooks:` on a stack produced metadata artifacts that never\n * became dispatchable rows (a silent no-op; ADR-0078). This seeder is that\n * missing ingestion path.\n *\n * ## Shape translation (authoring → runtime row)\n * The spec shape diverges from the runtime column names; we map only at this\n * boundary and stash the validated envelope in `definition_json` (whence the\n * enqueuer reads headers / timeout):\n * - `object` → `object_name`\n * - `isActive` → `active`\n * - `triggers` / `url` / `method` / `label` / `description` → same-named columns\n * - `secret` → `signing_secret` (ENCRYPTED — see below)\n * - `headers` → `headers_secret` (ENCRYPTED — #7986, same channel)\n * - the rest of the parsed {@link Webhook} → `definition_json` (JSON string)\n *\n * ## Neither credential goes in `definition_json` (#7799, #7986)\n * It used to: `definition_json: JSON.stringify(wh)` serialized the whole\n * envelope, key included, into an ordinary textarea on an admin-authorable\n * object — so `GET /api/v1/data/sys_webhook` returned the receiver's only proof\n * of origin to anyone who could read the object. The authored key now goes to\n * `sys_webhook.signing_secret`, a `type: 'secret'` column the engine encrypts\n * into `sys_secret` and masks on read; `definition_json` carries the same\n * envelope MINUS `secret`. Nothing about the authoring surface changes —\n * `webhook.zod.ts` still declares `secret` and authors still write it.\n *\n * Fail-closed: with no CryptoProvider registered the engine REFUSES the write\n * (it will not store cleartext), so a secret-bearing webhook is skipped with an\n * actionable log line instead of being seeded with an exposed key.\n *\n * Each item is validated through `WebhookSchema.parse()` first — this gives the\n * spec schema a real consumer (defaults for `method`/`isActive`/`timeoutMs` get\n * applied) and rejects malformed authoring with a warning instead of crashing\n * boot.\n *\n * ## Seed-not-clobber (mirrors sys_sharing_rule, #2909)\n * `sys_webhook` is admin-editable (`managedBy: 'config'`). Declared webhooks\n * ship with the app/package, so they seed with `managed_by: 'package'`\n * provenance and re-seed on every boot — but a row an admin has created\n * (`managed_by: 'admin'`) or edited (`customized: true`, stamped by\n * {@link bindWebhookProvenanceStamp}) is never overwritten. Most importantly,\n * an admin's `active: false` on a noisy webhook survives redeploys.\n *\n * MUST run before {@link AutoEnqueuer.start} so the enqueuer's first cache\n * refresh already sees the declared rows.\n */\n\nimport type { IDataEngine } from '@objectstack/spec/contracts';\nimport { WebhookSchema, type Webhook } from '@objectstack/spec/automation';\nimport {\n WEBHOOK_SECRET_FIELD,\n WEBHOOK_SECRET_REFUSAL_CODE,\n WEBHOOK_SECRET_REFUSAL_STATUS,\n canResolveSecrets,\n isSecretProtectionFailure,\n splitWebhookSecret,\n} from './webhook-secret.js';\nimport {\n WEBHOOK_HEADERS_FIELD,\n headersPatch,\n serializeHeaders,\n splitWebhookHeaders,\n} from './webhook-headers.js';\n\n/** System write context — the boot seeder is not an admin authoring action. */\nconst SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const;\n\ninterface Logger {\n info?: (msg: string, meta?: unknown) => void;\n warn?: (msg: string, meta?: unknown) => void;\n}\n\n/** Random id with a stable prefix — mirrors the sharing-rule seeder. */\nfunction uid(prefix: string): string {\n const g: any = globalThis as any;\n if (g.crypto?.randomUUID) return `${prefix}_${g.crypto.randomUUID()}`;\n return `${prefix}_${Math.random().toString(36).slice(2, 10)}`;\n}\n\n/**\n * Read declared `webhook` items from the ObjectQL registry (where the manifest\n * decomposition parks `stack.webhooks`), falling back to the metadata service.\n *\n * [#8378] Both reads hand back the authoring document itself. The sentence that\n * used to stand here — \"Items may be wrapped as `{ content }` — unwrap to the\n * raw authoring object\" — described an envelope with **no producer**:\n * `registerMetadataCollections` (objectql `engine.ts`) registers each\n * `stack.webhooks` element as-is, `loadMetaFromDb` registers the parsed body\n * rather than the `sys_metadata` row, and `MetadataFacade` shed its own copy of\n * this unwrap in #7519. `WebhookSchema` declares no `content` key and rejects\n * one as unrecognized, so wherever the key did appear the unwrap replaced the\n * whole webhook with one of its values — and `''` (falsy, non-nullish) passed\n * `??` and then died at `filter(Boolean)`, dropping the webhook silently.\n */\nfunction readDeclared(engine: any, metadataService: any, type: string): any[] {\n try {\n const reg = engine?._registry;\n if (reg?.listItems) {\n const items = (reg.listItems(type) ?? []).filter(Boolean);\n if (items.length > 0) return items;\n }\n } catch {\n /* fall through to metadata service */\n }\n try {\n const listed = metadataService?.list?.(type);\n const arr = typeof (listed as any)?.then === 'function' ? [] : (listed ?? []);\n return Array.isArray(arr) ? arr.filter(Boolean) : [];\n } catch {\n return [];\n }\n}\n\nexport interface BootstrapDeclaredWebhooksResult {\n seeded: number;\n skipped: number;\n}\n\n/**\n * Materialize declared webhooks into `sys_webhook`. Idempotent and safe to run\n * on every boot.\n */\nexport async function bootstrapDeclaredWebhooks(\n engine: IDataEngine,\n metadataService: any,\n logger?: Logger,\n subscriptionsObject = 'sys_webhook',\n): Promise<BootstrapDeclaredWebhooksResult> {\n const declared = readDeclared(engine, metadataService, 'webhook');\n if (declared.length === 0) return { seeded: 0, skipped: 0 };\n\n const now = new Date().toISOString();\n let seeded = 0;\n let skipped = 0;\n\n for (const raw of declared) {\n // Validate + fill defaults through the canonical spec schema. A real\n // consumer at last — a malformed webhook warns and is skipped, never\n // crashing boot.\n let wh: Webhook;\n try {\n wh = WebhookSchema.parse(raw);\n } catch (err: any) {\n logger?.warn?.('[webhook] declared webhook failed validation — skipped', {\n name: (raw as any)?.name,\n error: err?.message ?? String(err),\n });\n skipped += 1;\n continue;\n }\n\n try {\n const existing = await engine.find(subscriptionsObject, {\n where: { name: wh.name },\n limit: 1,\n context: SYSTEM_CTX,\n } as any);\n const row: any = Array.isArray(existing) ? existing[0] : undefined;\n\n if (row) {\n // Admin owns a same-named row, or has edited this seeded one — never\n // clobber. `active: false` on a noisy webhook must survive redeploys.\n if (row.managed_by === 'admin') {\n logger?.warn?.('[webhook] declared name collides with an admin-authored row — seed skipped', {\n name: wh.name,\n });\n skipped += 1;\n continue;\n }\n if (row.customized === true) {\n skipped += 1;\n continue;\n }\n const patch = {\n id: row.id,\n ...mapWebhookToRow(wh),\n ...(await secretPatch(engine, wh, row, subscriptionsObject)),\n ...(await headersPatch(\n engine,\n splitWebhookHeaders(wh as Record<string, unknown>).headers,\n row,\n subscriptionsObject,\n )),\n // Adopt pristine/legacy (pre-provenance) rows so future boots\n // recognize them as package-managed.\n managed_by: 'package',\n updated_at: now,\n };\n await engine.update(subscriptionsObject, patch, { context: SYSTEM_CTX } as any);\n seeded += 1;\n continue;\n }\n\n const { secret } = splitWebhookSecret(wh as Record<string, unknown>);\n const { headers } = splitWebhookHeaders(wh as Record<string, unknown>);\n const newRow = {\n id: uid('whk'),\n ...mapWebhookToRow(wh),\n // Cleartext goes in exactly once, into the `secret`-typed column; the\n // engine's write path wraps it into `sys_secret` and leaves an opaque\n // ref behind. Omitted entirely when unauthored, so a webhook with no\n // secret costs no crypto and needs no CryptoProvider.\n ...(secret ? { [WEBHOOK_SECRET_FIELD]: secret } : {}),\n // [#7986] Same channel, same rule, for the header map — omitted when\n // unauthored so a header-less webhook still seeds on a host with no\n // CryptoProvider wired.\n ...(headers ? { [WEBHOOK_HEADERS_FIELD]: serializeHeaders(headers) } : {}),\n managed_by: 'package',\n customized: false,\n created_at: now,\n updated_at: now,\n };\n await engine.insert(subscriptionsObject, newRow, { context: SYSTEM_CTX } as any);\n seeded += 1;\n } catch (err: any) {\n // [#7799] The engine refuses to persist a `secret` field with no\n // CryptoProvider wired, rather than falling back to cleartext. Say so in\n // those words — \"seed failed\" would read as a transient glitch, when what\n // actually happened is that the deployment has nowhere safe to put a key.\n const protection = isSecretProtectionFailure(err);\n logger?.warn?.(\n protection\n ? '[webhook] declared webhook NOT seeded — its signing secret cannot be stored encrypted (#7799)'\n : '[webhook] declared webhook seed failed',\n {\n name: wh.name,\n ...(protection\n ? { code: WEBHOOK_SECRET_REFUSAL_CODE, status: WEBHOOK_SECRET_REFUSAL_STATUS }\n : {}),\n error: err?.message ?? String(err),\n },\n );\n skipped += 1;\n }\n }\n\n logger?.info?.('[webhook] declared webhooks materialized into sys_webhook', {\n seeded,\n skipped,\n total: declared.length,\n });\n return { seeded, skipped };\n}\n\n/**\n * Decide what a RE-SEED should do with an existing row's `signing_secret`.\n *\n * Re-seeding runs on every boot, and a `secret`-typed write always mints a\n * fresh `sys_secret` ciphertext row — so blindly restating the declared key\n * would leak one orphan cipher row per webhook per restart. Compare against the\n * stored plaintext first (via the engine's privileged dereference) and write\n * only on an actual change:\n *\n * - declared key differs from stored ⇒ write it (rotation in code propagates,\n * exactly as it did when the whole envelope was rewritten every boot);\n * - identical ⇒ omit the key entirely, leaving the existing ref untouched;\n * - declared key removed, row still holds one ⇒ write `null` to CLEAR it\n * (code remains the authority for package rows);\n * - engine cannot dereference (older engine, or the compare threw) ⇒ fall back\n * to writing the declared value. Correct signatures beat tidy storage.\n */\nasync function secretPatch(\n engine: IDataEngine,\n wh: Webhook,\n row: any,\n subscriptionsObject: string,\n): Promise<Record<string, unknown>> {\n const { secret } = splitWebhookSecret(wh as Record<string, unknown>);\n const hasStored = row?.[WEBHOOK_SECRET_FIELD] != null && row[WEBHOOK_SECRET_FIELD] !== '';\n\n if (!secret) return hasStored ? { [WEBHOOK_SECRET_FIELD]: null } : {};\n if (!hasStored || !canResolveSecrets(engine)) return { [WEBHOOK_SECRET_FIELD]: secret };\n\n try {\n const current = await (engine as any).resolveSecretField(\n subscriptionsObject,\n String(row.id),\n WEBHOOK_SECRET_FIELD,\n );\n return current === secret ? {} : { [WEBHOOK_SECRET_FIELD]: secret };\n } catch {\n return { [WEBHOOK_SECRET_FIELD]: secret };\n }\n}\n\n/**\n * Translate a validated {@link Webhook} into `sys_webhook` column values.\n * `object → object_name`, `isActive → active`; the envelope MINUS its two\n * credential passengers — `secret` (#7799) and `headers` (#7986) — is stashed\n * in `definition_json` for the enqueuer's advanced-config read (`timeoutMs`).\n * Both credentials are written separately, into their own encrypted columns.\n */\nfunction mapWebhookToRow(wh: Webhook): Record<string, unknown> {\n const { envelope: withoutSecret } = splitWebhookSecret(wh as Record<string, unknown>);\n const { envelope } = splitWebhookHeaders(withoutSecret as Record<string, unknown>);\n return {\n name: wh.name,\n label: wh.label ?? wh.name,\n object_name: wh.object ?? null,\n triggers: wh.triggers ?? [],\n url: wh.url,\n // Store lowercase to match the object's Field.select option values\n // (get/post/…); the enqueuer upper-cases before delivery either way.\n method: String(wh.method ?? 'POST').toLowerCase(),\n description: wh.description ?? null,\n active: wh.isActive !== false,\n definition_json: JSON.stringify(envelope),\n };\n}\n","// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * [#7799] One-shot boot sweep that moves already-persisted cleartext signing\n * secrets out of `sys_webhook.definition_json` and into the encrypted\n * `signing_secret` column.\n *\n * ## Why a sweep and not just the seeder\n * `bootstrapDeclaredWebhooks` re-seeds package-declared rows on every boot, so\n * those heal themselves the moment the new mapping lands. The rows that do NOT\n * heal are exactly the ones most likely to hold a real production key:\n *\n * - `managed_by: 'admin'` — authored in Setup, never touched by the seeder;\n * - `customized: true` — a package row an admin edited, deliberately frozen\n * against re-seeding (seed-not-clobber, #3461 / #2909).\n *\n * Leaving those behind would make this a half-migration: the code path that\n * created the exposure would be fixed while the exposed values stayed in the\n * table. So the sweep is keyed off the DATA (does this blob contain a secret?),\n * not off provenance.\n *\n * ## What it is careful about\n * - **System context.** The provenance hook exempts `isSystem` writes, so\n * migrating a package row does not stamp `customized: true` and freeze it\n * against future seeding.\n * - **Idempotent.** A row whose blob no longer carries a `secret` is skipped,\n * so the sweep is free on every boot after the first.\n * - **Fail-closed, per row.** With no CryptoProvider the encrypted write throws\n * and the row is LEFT AS IT WAS — still exposed, but intact and still\n * signing. It is reported with an ADR-0112 `code`/`status` pair so an\n * operator can see exactly which rows are still cleartext and why, rather\n * than the sweep quietly reporting success.\n * - **Never widens the blast radius.** The cleartext is only removed from\n * `definition_json` in the SAME update that stores the encrypted copy; a\n * failure cannot land the strip without the store.\n */\n\nimport type { IDataEngine } from '@objectstack/spec/contracts';\nimport type { EngineQueryOptions } from '@objectstack/spec/data';\nimport {\n WEBHOOK_OBJECT,\n WEBHOOK_SECRET_FIELD,\n WEBHOOK_SECRET_REFUSAL_CODE,\n WEBHOOK_SECRET_REFUSAL_STATUS,\n isSecretProtectionFailure,\n readLegacySecret,\n splitWebhookSecret,\n} from './webhook-secret.js';\nimport {\n WEBHOOK_HEADERS_FIELD,\n readLegacyHeaders,\n serializeHeaders,\n splitWebhookHeaders,\n} from './webhook-headers.js';\n\n/** System write context — a boot reconciler is not an admin authoring action. */\nconst SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const;\n\n/** The read side of the same context, typed so `tsc` still checks the keys. */\nconst SYSTEM_QUERY: EngineQueryOptions = {\n context: { isSystem: true, positions: [], permissions: [] },\n};\n\ninterface Logger {\n info?: (msg: string, meta?: unknown) => void;\n warn?: (msg: string, meta?: unknown) => void;\n}\n\nexport interface MigrateWebhookSecretsResult {\n /** Rows whose blob carried a cleartext secret. */\n found: number;\n /** Rows now holding an encrypted secret and a secret-free blob. */\n migrated: number;\n /** Rows still holding cleartext because the encrypted write was refused. */\n failed: number;\n}\n\n/**\n * Move every cleartext `definition_json.secret` into the encrypted column.\n * Safe to run on every boot; returns counts for the caller to log.\n */\nexport async function migrateLegacyWebhookSecrets(\n engine: IDataEngine,\n logger?: Logger,\n subscriptionsObject: string = WEBHOOK_OBJECT,\n): Promise<MigrateWebhookSecretsResult> {\n const out: MigrateWebhookSecretsResult = { found: 0, migrated: 0, failed: 0 };\n\n let rows: any[];\n try {\n const found = await engine.find(subscriptionsObject, SYSTEM_QUERY);\n rows = Array.isArray(found) ? found : ((found as any)?.data ?? []);\n } catch (err: any) {\n logger?.warn?.('[webhook] legacy secret sweep skipped — could not read subscriptions', {\n object: subscriptionsObject,\n error: err?.message ?? String(err),\n });\n return out;\n }\n\n for (const row of rows) {\n if (!row?.id) continue;\n const legacySecret = readLegacySecret(row.definition_json);\n const legacyHeaders = readLegacyHeaders(row.definition_json);\n // [#7986] A row counts as found when it carries EITHER passenger. The two\n // move in ONE update on purpose: two updates would mint two revisions of\n // the same row, and a failure between them could land a blob stripped of\n // its headers while the encrypted copy was never written — the exact\n // \"never widens the blast radius\" rule the secret half already states,\n // which only holds if the strip and the store stay in the same write.\n if (!legacySecret && !legacyHeaders) continue;\n out.found += 1;\n\n try {\n await engine.update(\n subscriptionsObject,\n {\n id: row.id,\n ...(legacySecret ? { [WEBHOOK_SECRET_FIELD]: legacySecret } : {}),\n ...(legacyHeaders ? { [WEBHOOK_HEADERS_FIELD]: serializeHeaders(legacyHeaders) } : {}),\n definition_json: stripCredentialsFromDefinition(row.definition_json as string),\n },\n { context: SYSTEM_CTX } as any,\n );\n out.migrated += 1;\n } catch (err: any) {\n out.failed += 1;\n const protection = isSecretProtectionFailure(err);\n const what = legacySecret && legacyHeaders\n ? 'signing secret and custom headers'\n : legacySecret ? 'signing secret' : 'custom headers';\n logger?.warn?.(\n protection\n ? `[webhook] ${what} STILL CLEARTEXT in definition_json — no CryptoProvider to encrypt them (#7799/#7986)`\n : `[webhook] ${what} migration failed — row left unchanged (#7799/#7986)`,\n {\n name: row.name ?? row.id,\n id: row.id,\n code: WEBHOOK_SECRET_REFUSAL_CODE,\n status: WEBHOOK_SECRET_REFUSAL_STATUS,\n error: err?.message ?? String(err),\n },\n );\n }\n }\n\n if (out.found > 0) {\n logger?.info?.('[webhook] legacy cleartext credentials swept into sys_secret', { ...out });\n }\n return out;\n}\n\n/**\n * Strip both credential passengers from a serialized envelope, preserving every\n * other key. Parsed and re-serialized ONCE so the two removals cannot disagree\n * about what the blob contained.\n */\nfunction stripCredentialsFromDefinition(definitionJson: string): string {\n const parsed = JSON.parse(definitionJson) as Record<string, unknown>;\n const { envelope: withoutSecret } = splitWebhookSecret(parsed);\n const { envelope } = splitWebhookHeaders(withoutSecret as Record<string, unknown>);\n return JSON.stringify(envelope);\n}\n","// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * [#8069] The webhook lane's veto over redelivering one of its own\n * `sys_http_delivery` rows.\n *\n * ## Why a guard exists at all\n * `service-messaging` owns the replay mechanics and deliberately knows nothing\n * about `sys_webhook`. So the row-local refusal it can make on its own — \"this\n * row was never sent, so there is nothing to re-send\" — cannot answer the\n * question the maintainer's ruling of 2026-08-12 actually asks: *is the signing\n * configuration for this delivery still available?* That question is only\n * answerable here, where the subscription and its encrypted secret live.\n *\n * ## What it refuses, and why each case\n * A redelivery replays the row's bytes together with the signature computed at\n * enqueue. That is safe exactly while the configuration those bytes were\n * authorised under still stands. It refuses when:\n *\n * 1. **The subscription is gone.** Nothing is left to say whether this URL\n * should still receive this payload, or under which key — and an operator\n * deleting a webhook has expressed that it should stop. The maintainer\n * named this case specifically.\n * 2. **A secret is stored but does not come back.** The subscription is signed\n * and the key cannot be recovered — a rotated KMS key, an unregistered\n * CryptoProvider, a deleted `sys_secret` row. Deliveries for it are being\n * dropped right now; replaying an old one is the same fail-open by another\n * route.\n * 3. **The lookup itself failed.** Handled by the caller\n * (`assertRedeliverAllowed` turns a throwing guard into a refusal), because\n * \"we could not check\" must never read as \"allowed\".\n *\n * It ALLOWS a subscription that is legitimately unsigned (`secret` is optional\n * on the authoring envelope) and any row from another producer (`source !==\n * 'webhook'`) — this guard speaks only for webhook rows.\n *\n * ## The narrow fail-open this closes deliberately\n * Case 2 is checked as *\"a value is stored but nothing came back\"*, not as\n * *\"the resolver threw\"*. Presence is decidable from the masked read even\n * though the value is not, so the guard asks the question it can actually\n * answer.\n *\n * [#8542] That reasoning has since moved DOWN into `resolveWebhookSecret`,\n * which now raises `WebhookSecretUnresolvableError` instead of returning the\n * same `undefined` it uses for \"authored unsigned\". The reason it had to move:\n * the ENQUEUE path had the identical ambiguity and no way to see it — and there\n * it failed open, arming the subscription and delivering unsigned. One seam,\n * one rule, so a consumer cannot forget to re-derive it. This guard keeps its\n * own presence check because the refusal REASON it returns is written from the\n * subscription row, and keeps its behaviour byte for byte: an unresolvable key\n * is refused with the text below, and anything else still propagates.\n */\n\nimport type { IDataEngine } from '@objectstack/spec/contracts';\nimport {\n WEBHOOK_OBJECT,\n WEBHOOK_SECRET_FIELD,\n isWebhookSecretUnresolvable,\n resolveWebhookSecret,\n} from './webhook-secret.js';\n\n/** The delivery-row fields this guard reads. Structural — no messaging import. */\nexport interface RedeliverGuardRow {\n /** Producer domain; only `'webhook'` rows are this guard's business. */\n source: string;\n /** Partition/ordering anchor — the `sys_webhook` row id for webhook rows. */\n refId: string;\n}\n\n/**\n * Build the guard `MessagingService.registerRedeliverGuard('webhook', …)` takes.\n *\n * Returns a refusal reason, or `undefined` to allow.\n */\nexport function createWebhookRedeliverGuard(\n engine: IDataEngine,\n subscriptionsObject: string = WEBHOOK_OBJECT,\n): (row: RedeliverGuardRow) => Promise<string | undefined> {\n return async (row) => {\n if (row.source !== 'webhook') return undefined;\n\n const subscription = (await engine.findOne(subscriptionsObject, {\n where: { id: row.refId },\n })) as Record<string, unknown> | null;\n\n if (!subscription) {\n return (\n `the ${subscriptionsObject} subscription '${row.refId}' this delivery belongs to no `\n + 'longer exists, so there is nothing left to say whether it may still be signed and '\n + 'sent (#8069). Recreate the webhook if the endpoint should keep receiving events; '\n + 'new events are then delivered signed.'\n );\n }\n\n // Presence is decidable on the masked read — a set secret comes back as\n // the engine's mask, an unset one as null — even though the value is not.\n const storesSecret =\n subscription[WEBHOOK_SECRET_FIELD] != null\n && subscription[WEBHOOK_SECRET_FIELD] !== '';\n if (!storesSecret) return undefined;\n\n // [#8542] The seam now RAISES for the case this guard used to detect on\n // its own — the enqueue path needed the same distinction and could only\n // get it from a throw (its `catch` is what parks the subscription), so\n // the rule moved down one level instead of being written twice. This\n // guard's contract is unchanged in both directions, which is the point:\n // a stored-but-unresolvable key still returns the refusal REASON below\n // (case 2), and any OTHER failure still propagates, because \"we could\n // not check\" must never read as \"allowed\" (case 3, handled by\n // `assertRedeliverAllowed`).\n let plaintext: string | undefined;\n try {\n plaintext = await resolveWebhookSecret(engine, subscription as { id: string }, subscriptionsObject);\n } catch (err) {\n if (!isWebhookSecretUnresolvable(err)) throw err;\n }\n if (plaintext) return undefined;\n\n return (\n `webhook '${String(subscription.name ?? row.refId)}' stores a signing secret that cannot `\n + 'be recovered, so this delivery cannot be authenticated as coming from us — refusing '\n + 'rather than sending (#7799, #8069). Fix: register a CryptoProvider with the same key '\n + 'the secret was written under and make sure the sys_secret row is reachable.'\n );\n };\n}\n","// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * [#3461] Provenance stamp for `sys_webhook`.\n *\n * `sys_webhook` is RECORD-AUTHORITATIVE: a code-declared webhook is a boot seed\n * (`bootstrapDeclaredWebhooks`), and the row — including any admin tuning such\n * as flipping a noisy webhook to `active: false` — is the authority. The seeder\n * skips rows marked `customized`, so this hook is the half that DETECTS the\n * admin edit: any non-system update touching a `package`/`platform`-seeded row\n * stamps `customized: true` onto the payload.\n *\n * Why a data hook (and not a write gate or the REST layer), verbatim to the\n * sys_sharing_rule rationale (#2909 T1):\n * - admins edit webhooks through several doors (Setup UI generic data door,\n * scripts, console) — an engine hook covers them all;\n * - there is deliberately NO write gate here: webhooks are a first-class admin\n * authoring surface, so edits are allowed — they just have to be remembered;\n * - both provenance columns are `readonly`, and the engine's readonly strip\n * exempts isSystem callers while snapshotting supplied keys BEFORE hooks run\n * — so a caller can never forge/clear `customized`, while this hook's stamp\n * survives.\n *\n * Known boundary: multi-row updates (no single `input.id`) are not stamped —\n * every webhook-editing UI path updates by id.\n */\n\ninterface MinimalEngine {\n find(object: string, opts?: any): Promise<any[]>;\n registerHook(event: string, handler: (ctx: any) => any, options?: Record<string, any>): void;\n unregisterHooksByPackage(packageId: string): number;\n}\n\ninterface MinimalLogger {\n info?: (msg: string, meta?: Record<string, any>) => void;\n warn?: (msg: string, meta?: Record<string, any>) => void;\n}\n\nexport const WEBHOOK_PROVENANCE_PACKAGE = 'plugin-webhooks:provenance';\n\nconst SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const;\n\nexport function bindWebhookProvenanceStamp(engine: MinimalEngine, logger?: MinimalLogger): void {\n if (typeof engine?.registerHook !== 'function') return;\n engine.registerHook(\n 'beforeUpdate',\n async (ctx: any) => {\n // Seeder / boot reconcilers write with isSystem — the package door, not\n // an admin customization.\n if ((ctx?.session as any)?.isSystem) return;\n const id = ctx?.input?.id ?? (ctx?.input?.data as any)?.id;\n if (!id) return; // multi-row update — see boundary note above\n const data = ctx?.input?.data;\n if (!data || typeof data !== 'object') return;\n try {\n // `previous` is not resolved before beforeUpdate hooks run — read the\n // current row ourselves (system ctx: this is a provenance check, not\n // an authorization decision).\n const rows = await engine.find('sys_webhook', {\n where: { id },\n fields: ['id', 'managed_by', 'customized'],\n limit: 1,\n context: SYSTEM_CTX,\n });\n const row = Array.isArray(rows) ? rows[0] : undefined;\n if (!row) return;\n if ((row.managed_by === 'package' || row.managed_by === 'platform') && row.customized !== true) {\n (data as any).customized = true;\n }\n } catch (err: any) {\n logger?.warn?.('[webhook] provenance stamp failed (edit proceeds unstamped)', {\n id,\n error: err?.message,\n });\n }\n },\n { object: 'sys_webhook', packageId: WEBHOOK_PROVENANCE_PACKAGE, priority: 150 },\n );\n logger?.info?.('[webhook] provenance stamp hook bound');\n}\n\nexport function unbindWebhookProvenanceStamp(engine: MinimalEngine): void {\n if (typeof engine?.unregisterHooksByPackage === 'function') {\n engine.unregisterHooksByPackage(WEBHOOK_PROVENANCE_PACKAGE);\n }\n}\n","// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * [#8566] The WRITE DOOR for `sys_webhook.headers_secret`'s plaintext shape.\n *\n * ## The defect this closes\n * `headers_secret` is a `Field.secret()` whose plaintext is not an opaque blob:\n * it is a serialized header map with a required shape — a flat JSON object of\n * string values — and {@link parseStoredHeaders} is its only reader. Nothing\n * validated that shape on the way in. The ordinary data API accepted any\n * string, the engine encrypted it like any other secret, minted a real\n * `sys_secret` row, and left the column holding a perfectly valid `secret:` ref\n * that reads back as the mask with `active: true`. Measured on a real engine\n * through `engine.update()` — no privileged access — every one of these was\n * accepted and is a value the plugin can never use: `{}`, `[]`,\n * `{\"X-Count\": 5}`, a nested object, and `{X-Team: crm}` (a typo).\n *\n * ## What this is NOT\n * ⛔ Not an exposure fix, and it must not be graded as one. #8558/#8565 already\n * closed the consumer half: a webhook whose stored header map does not come\n * back as a flat string map PARKS the subscription and reports at `error`\n * rather than delivering header-less with a valid signature. Nothing leaks and\n * nothing is silently lost today.\n *\n * What remains — and all this file changes — is **when the author finds out**.\n * Today: at the next matching record change, an unbounded time after the\n * mistake and in a completely different surface from the one where it was made.\n * With this gate: at the write door, where the author is still standing. The\n * field is directly admin-authorable and its own description instructs the\n * author to type a JSON object into it, which makes a typo the EXPECTED failure\n * rather than an exotic one.\n *\n * ## Why a hook, and why THIS hook\n * Maintainer ruling 2026-08-13 (option 2). Validating at the plugin's own write\n * paths (`bootstrapDeclaredWebhooks` / `headersPatch` / the migration sweep)\n * was rejected as insufficient: a direct `PATCH /api/v1/data/sys_webhook` never\n * goes through any of them, and that is the measured trigger. An engine hook\n * covers every door at once — the generic data API, the Setup UI, scripts, the\n * console — and the plugin's own write paths inherit it automatically, so there\n * is deliberately NO second check on them.\n *\n * Same rationale, and the same shape, as {@link bindWebhookProvenanceStamp}\n * next door: one engine hook rather than N door-side checks.\n *\n * ## ⭐ Order is the whole mechanism: this MUST run before `encryptSecretFields`\n * The engine encrypts a `secret` field on the way to the driver; one step later\n * the plaintext is gone and the column holds an opaque ref. A validator that\n * ran after it would have nothing left to validate. `beforeInsert` /\n * `beforeUpdate` hooks are dispatched BEFORE that encryption on every write\n * path (measured in `packages/objectql/src/engine.ts`: insert triggers its\n * hooks and then encrypts; both the by-id and the multi update arms do the\n * same), which is what makes this seam the right one and not merely a\n * convenient one. `webhook-headers-gate.test.ts` pins the ordering against the\n * real engine rather than trusting this paragraph.\n *\n * ## The four values this gate deliberately lets through\n * Each is someone else's verdict, and duplicating any of them here would create\n * a second owner for a rule that already has one:\n *\n * 1. **the key is absent** — \"leave the stored value unchanged\";\n * 2. **`null` / `undefined`** — the CLEAR spelling, which the engine honours\n * and which the refusal message below points authors at;\n * 3. **`\"\"`** — governed by #8559's ruling and refused by the engine's own\n * `encryptSecretFields` a few lines later, with a message that already\n * names `null` as the way to clear. ⚠️ The dispatch note said this gate\n * \"can assume it never sees `\\\"\\\"`\"; measured, that is inverted — this hook\n * runs FIRST, so it does see it and must pass it through untouched for\n * #8559's seam to answer. Refusing it here would duplicate that ruling and\n * put two different messages on one door;\n * 4. **the engine's opaque wire forms** ({@link isOpaqueSecretForm}) — the\n * read mask and a `secret:` ref. The mask is the echoed-read-mask case the\n * ruling calls out by name: a caller that GETs a row and PATCHes it back\n * unchanged sends the mask, and the engine drops that key as \"unchanged\".\n * Refusing it would break every round-trip through the Setup form, which is\n * the single most ordinary write this object receives. A ref is the same\n * story one layer down (the engine leaves an already-encrypted ref alone).\n *\n * ## Why the verdict is `parseStoredHeaders`, not a second shape rule\n * The door refuses EXACTLY what the consumer cannot use, because it asks the\n * consumer's own question: the value is normalized the way the engine will\n * normalize it, then handed to {@link parseStoredHeaders} — the same function\n * the enqueuer reads stored headers with. A hand-written second predicate here\n * could drift from that one, and a door that refuses a value the consumer would\n * have accepted (or accepts one it cannot use) is worse than no door. One rule,\n * one definition.\n *\n * ## ⛔ The refusal never echoes the value\n * This column carries credentials — an `Authorization: Bearer …` is the header\n * the field's own description uses as its example. A validation message that\n * quoted the rejected input would print that token into logs and HTTP error\n * bodies, i.e. re-open in the diagnostic exactly the exposure #7986 moved this\n * field onto the encrypted channel to close. So the diagnostic names TYPES and\n * KEYS only — header names are not credentials, their values are — and never a\n * value.\n *\n * ## Promotion path (⛔ not built here)\n * A general capability on the `secret` channel — letting any `secret`-typed\n * field declare a plaintext validator — is the principled generalization and is\n * recorded as the shape this becomes the moment a SECOND shaped-plaintext\n * `secret` field exists. It is deliberately not built for one consumer\n * (maintainer ruling 2026-08-13, item 3; startup scope). Whoever hits that\n * second field files against this precedent.\n */\n\nimport {\n HEADERS_REMEDY,\n WEBHOOK_HEADERS_FIELD,\n parseStoredHeaders,\n} from './webhook-headers.js';\nimport { WEBHOOK_OBJECT, isOpaqueSecretForm } from './webhook-secret.js';\n\n/**\n * ADR-0112 envelope for this refusal. `VALIDATION_ERROR`/400 is the standard\n * catalog member for \"the payload is not acceptable\" — the SAME pair #8559's\n * `EmptyCredentialWriteError` carries at the same door for the same class of\n * verdict, so a client branching on `code`/`status` handles both malformed\n * credential writes identically. A standard-catalog code needs no ledger entry.\n */\nexport const WEBHOOK_HEADERS_SHAPE_REFUSAL_CODE = 'VALIDATION_ERROR';\nexport const WEBHOOK_HEADERS_SHAPE_REFUSAL_STATUS = 400;\n\n/**\n * The shape the field's own description asks for, quoted in the refusal so the\n * error and the authoring surface cannot drift into two different specs.\n * Kept verbatim from `sys-webhook.object.ts`'s `headers_secret` description.\n */\nconst DECLARED_SHAPE =\n 'Custom HTTP headers sent with each delivery, as a JSON object '\n + '({\"Authorization\": \"Bearer ...\"})';\n\n/**\n * [#8566] Refusal to persist a `headers_secret` plaintext that is not a flat\n * JSON object of string values.\n *\n * Carries the ADR-0112 pair plus the LOCATION (`object`/`field`) as fields, so\n * a consumer branches on `code`/`status` rather than on message text — the same\n * discipline {@link WebhookHeadersUnresolvableError} follows on the read side\n * of this seam, and `EmptyCredentialWriteError` follows on the write side.\n */\nexport class WebhookHeadersShapeError extends Error {\n readonly code = WEBHOOK_HEADERS_SHAPE_REFUSAL_CODE;\n readonly status = WEBHOOK_HEADERS_SHAPE_REFUSAL_STATUS;\n readonly object: string;\n readonly field: string;\n\n constructor(object: string, field: string, diagnosis: string) {\n super(\n `Custom headers refused for \"${object}.${field}\": ${diagnosis}. The required shape is a FLAT `\n + 'JSON object of string values, which is what the field itself asks for — its description '\n + `reads: \"${DECLARED_SHAPE}\". This is checked at the write door because one step later `\n + 'there is nothing left to check: the engine encrypts this value into sys_secret and every '\n + 'read path returns only the mask, so a stored value that can never be used is '\n + 'indistinguishable from one that works until the next delivery tries to send it — at '\n + 'which point the subscription parks and the report arrives an unbounded time later, in a '\n + `different surface from the one it was typed into (#7986, #8558, #8566). ${HEADERS_REMEDY}`,\n );\n this.name = 'WebhookHeadersShapeError';\n this.object = object;\n this.field = field;\n }\n}\n\n/**\n * Describe a parsed value's SHAPE for the diagnostic — types and keys only,\n * never values (see the file header's note on why this message must not echo\n * the input). Header names are safe to name and are the single most useful\n * thing a typo-hunting author can be told.\n */\nfunction describeParsed(parsed: unknown): string {\n if (parsed === null) return 'null';\n if (Array.isArray(parsed)) return 'a JSON array';\n if (typeof parsed !== 'object') return `a JSON ${typeof parsed}`;\n\n const entries = Object.entries(parsed as Record<string, unknown>);\n if (entries.length === 0) {\n return 'an EMPTY JSON object, which is not the same thing as \"send no custom headers\"';\n }\n const bad = entries.filter(([, v]) => typeof v !== 'string');\n if (bad.length > 0) {\n const named = bad\n .map(([k, v]) => `${JSON.stringify(k)} (${Array.isArray(v) ? 'array' : v === null ? 'null' : typeof v})`)\n .join(', ');\n return (\n `a JSON object, but the wire carries only strings and ${bad.length === 1 ? 'this value is' : 'these values are'} `\n + `not a string: ${named}`\n );\n }\n // Unreachable while `parseStoredHeaders` accepts exactly non-empty flat\n // string maps; kept truthful rather than asserting a shape we did not check.\n return 'a JSON object the header seam does not accept';\n}\n\n/** Describe the raw payload value, resolving the string/JSON layer first. */\nfunction describeRejected(value: unknown): string {\n if (typeof value === 'string') {\n let parsed: unknown;\n try {\n parsed = JSON.parse(value);\n } catch {\n return (\n 'the value is a string that is not valid JSON at all — check for unquoted keys or values '\n + '({X-Team: crm}), single quotes instead of double, or a trailing comma'\n );\n }\n return `the value parses as JSON but is ${describeParsed(parsed)}`;\n }\n return `the value is ${describeParsed(value)}`;\n}\n\n/**\n * The verdict, as a pure function of the write payload — exported so the gate\n * can be reasoned about and tested without booting an engine, and so any future\n * caller uses the same one rule rather than restating it.\n *\n * Mutates nothing and returns nothing: it either passes or throws\n * {@link WebhookHeadersShapeError}.\n */\nexport function assertWritableWebhookHeaders(\n data: Record<string, unknown> | null | undefined,\n object: string = WEBHOOK_OBJECT,\n field: string = WEBHOOK_HEADERS_FIELD,\n): void {\n if (!data || typeof data !== 'object') return;\n if (!Object.prototype.hasOwnProperty.call(data, field)) return; // omitted ⇒ unchanged\n\n const value = data[field];\n if (value === null || typeof value === 'undefined') return; // the CLEAR spelling\n if (value === '') return; // #8559's seam owns this — see the file header\n if (isOpaqueSecretForm(value)) return; // echoed read-mask, or an existing ref\n\n // Normalize EXACTLY as the engine is about to: a string is taken as the\n // serialized map it claims to be, and anything else is JSON.stringify'd —\n // which is what `encryptSecretFields` does with a non-string secret value, so\n // an authored object that really is a flat string map keeps working (it\n // serializes to precisely the form the consumer reads back).\n let serialized: string;\n if (typeof value === 'string') {\n serialized = value;\n } else {\n try {\n serialized = JSON.stringify(value) as string;\n } catch {\n // Circular / unserializable: the engine would store \"[object Object]\"-\n // class garbage or throw deeper in. Refuse it here, where the message can\n // say something useful.\n throw new WebhookHeadersShapeError(\n object,\n field,\n 'the value cannot be serialized to JSON at all (it contains a circular reference)',\n );\n }\n // `JSON.stringify` answers `undefined` for a function or a symbol.\n if (typeof serialized !== 'string') {\n throw new WebhookHeadersShapeError(object, field, `the value is a ${typeof value}`);\n }\n }\n\n // The consumer's own question, asked at the door (see the file header).\n if (parseStoredHeaders(serialized)) return;\n\n throw new WebhookHeadersShapeError(object, field, describeRejected(value));\n}\n\n/** Minimal engine surface this binding needs — mirrors `webhook-provenance.ts`. */\ninterface MinimalEngine {\n registerHook(event: string, handler: (ctx: any) => any, options?: Record<string, any>): void;\n unregisterHooksByPackage(packageId: string): number;\n}\n\ninterface MinimalLogger {\n info?: (msg: string, meta?: Record<string, any>) => void;\n}\n\nexport const WEBHOOK_HEADERS_GATE_PACKAGE = 'plugin-webhooks:headers-shape-gate';\n\n/**\n * Priority 50 — ahead of the provenance stamp's 150 (lower runs first), so a\n * refused write is refused before anything else spends work on it. The stamp\n * issues a `find` against `sys_webhook` on every non-system update; there is no\n * reason to pay for it on a payload that is about to be rejected. Nothing about\n * correctness depends on the two hooks' relative order — only on both running\n * before `encryptSecretFields`, which every `before*` hook does.\n */\nconst GATE_PRIORITY = 50;\n\n/**\n * Bind the shape gate to both write events on `sys_webhook`.\n *\n * ## Deliberately NOT exempt for `isSystem`\n * The provenance stamp next door skips system writes because it is detecting an\n * ADMIN edit; this is a validity verdict on a payload, and a malformed header\n * map is exactly as unusable when a seeder writes it. Ruling item 2 says the\n * plugin's own write paths inherit this validation through the hook, which is\n * only true if system writes are covered. They pass by construction —\n * `bootstrapDeclaredWebhooks` and the migration sweep both write\n * `serializeHeaders(...)` of an already `isHeaderMap`-filtered map — so\n * covering them costs nothing and closes the door for a future write path that\n * is less careful.\n *\n * Registered in CODE rather than from metadata, which also means\n * `session.skipAutomations` (an import run with automations unchecked) cannot\n * suppress it: the engine only skips metadata-bound entries. A validation door\n * that an import could switch off would not be a door.\n */\nexport function bindWebhookHeadersShapeGate(engine: MinimalEngine, logger?: MinimalLogger): void {\n if (typeof engine?.registerHook !== 'function') return;\n\n const handler = (ctx: any) => {\n assertWritableWebhookHeaders(ctx?.input?.data as Record<string, unknown> | undefined);\n };\n\n for (const event of ['beforeInsert', 'beforeUpdate'] as const) {\n engine.registerHook(event, handler, {\n object: WEBHOOK_OBJECT,\n packageId: WEBHOOK_HEADERS_GATE_PACKAGE,\n priority: GATE_PRIORITY,\n });\n }\n\n logger?.info?.('[webhook] headers_secret shape gate bound (refuses non-flat-string-map plaintext)');\n}\n\n/** Remove the gate — mirrors `unbindWebhookProvenanceStamp`, for `dispose()`. */\nexport function unbindWebhookHeadersShapeGate(engine: MinimalEngine): void {\n if (typeof engine?.unregisterHooksByPackage === 'function') {\n engine.unregisterHooksByPackage(WEBHOOK_HEADERS_GATE_PACKAGE);\n }\n}\n","// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\nimport type { Plugin, PluginContext } from '@objectstack/core';\nimport type {\n IDataEngine,\n II18nService,\n IMetadataService,\n IRealtimeService,\n} from '@objectstack/spec/contracts';\nimport type { EnqueueHttpInput } from '@objectstack/service-messaging';\nimport { AutoEnqueuer, type AutoEnqueuerOptions } from './auto-enqueuer.js';\nimport { SysWebhook } from './sys-webhook.object.js';\nimport { bootstrapDeclaredWebhooks } from './bootstrap-declared-webhooks.js';\nimport { migrateLegacyWebhookSecrets } from './migrate-webhook-secrets.js';\nimport { createWebhookRedeliverGuard } from './redeliver-guard.js';\nimport { bindWebhookProvenanceStamp, unbindWebhookProvenanceStamp } from './webhook-provenance.js';\nimport {\n bindWebhookHeadersShapeGate,\n unbindWebhookHeadersShapeGate,\n} from './webhook-headers-gate.js';\n\n/**\n * Structural view of `@objectstack/service-messaging`'s HTTP-outbox surface\n * (ADR-0018 M3) — declared locally so this plugin doesn't take a hard runtime\n * import on the service. Webhook deliveries are enqueued onto the shared\n * `sys_http_delivery` outbox and drained by the messaging `HttpDispatcher`.\n */\ninterface MessagingHttpSurface {\n isHttpDeliveryReady(): boolean;\n enqueueHttp(input: EnqueueHttpInput): Promise<string>;\n redeliverHttp(id: string): Promise<{ id: string; status: string }>;\n /**\n * [#8069] Where this plugin's veto over redelivering `source: 'webhook'`\n * rows is installed. Declared REQUIRED on this structural view even though\n * the import is type-only: a messaging build without it cannot enforce the\n * refusal, and {@link WebhookOutboxPlugin.installRedeliverGuard} says so at\n * `error` rather than arming the endpoint with a guarantee nothing keeps.\n */\n registerRedeliverGuard(\n source: string,\n guard: (row: { source: string; refId: string }) => Promise<string | undefined>,\n ): void;\n}\n\nexport interface WebhookOutboxPluginOptions {\n /**\n * Auto-enqueue config. When enabled (default `true` if the realtime + data\n * engine services are available), the plugin subscribes to `data.record.*`\n * events and enqueues a delivery onto the shared messaging HTTP outbox for\n * every matching `sys_webhook` row.\n *\n * Set `false` to disable and enqueue webhooks imperatively elsewhere.\n */\n autoEnqueue?: boolean | AutoEnqueuerOptions;\n}\n\n/**\n * Wires webhook fan-out on top of the shared outbound-HTTP delivery substrate\n * (ADR-0018 M3).\n *\n * Webhooks are no longer their own delivery engine: the durable outbox, the\n * cluster-coordinated dispatcher, the retry/backoff/dead-letter schedule, and\n * the retention sweep all live in `@objectstack/service-messaging`\n * (`sys_http_delivery` + `HttpDispatcher`). This plugin owns only the\n * webhook-specific concerns:\n * - the `sys_webhook` configuration object,\n * - the {@link AutoEnqueuer} that turns `data.record.*` events into outbox\n * rows (`source: 'webhook'`), and\n * - the redeliver admin endpoint.\n *\n * End-to-end flow:\n *\n * engine.insert('contact', {...})\n * → engine publishes data.record.created via IRealtimeService\n * → AutoEnqueuer matches active sys_webhook rows in O(1)\n * → messaging.enqueueHttp() runs fire-and-forget (off the write path)\n * → messaging HttpDispatcher claims and POSTs (cluster-coordinated, retried)\n *\n * **Requires** `MessagingServicePlugin` (`@objectstack/service-messaging`),\n * which is a foundational, always-on capability.\n */\nexport class WebhookOutboxPlugin implements Plugin {\n name = 'com.objectstack.plugin-webhook-outbox';\n version = '2.0.0';\n type = 'standard' as const;\n dependencies = ['com.objectstack.service.messaging'];\n /**\n * `init()` registers this plugin's schema through `manifest` with no\n * fallback. Until #4187 that was safe only TRANSITIVELY — messaging happens\n * to depend on ObjectQL, which provides `manifest` — so the guarantee would\n * have evaporated silently the day messaging stopped depending on the\n * engine, and the failure would have surfaced as an unrelated plugin's\n * init crash. Declaring the requirement directly makes the kernel check it\n * regardless of what messaging depends on.\n */\n requiresServices = ['manifest'];\n\n private autoEnqueuer: AutoEnqueuer | undefined;\n /** Engine the provenance hook was bound to, so `dispose()` can unbind it. */\n private boundEngine: any;\n\n constructor(private readonly options: WebhookOutboxPluginOptions = {}) {}\n\n async init(ctx: PluginContext): Promise<void> {\n // Register the webhook config object (ADR-0029 K2.a). The delivery\n // telemetry now lives in messaging's `sys_http_delivery`, so the nav's\n // \"Deliveries\" entry points there (filtered to source=webhook in views).\n const manifest = ctx.getService<{ register(m: any): void }>('manifest');\n if (manifest && typeof manifest.register === 'function') {\n manifest.register({\n id: 'com.objectstack.plugin-webhook-outbox.schema',\n namespace: 'sys',\n version: this.version,\n type: 'plugin',\n scope: 'system',\n name: 'Webhook Schemas',\n description: 'Registers sys_webhook (configuration). Deliveries use messaging\\'s sys_http_delivery outbox.',\n objects: [SysWebhook],\n navigationContributions: [\n {\n app: 'setup',\n group: 'group_integrations',\n priority: 100,\n items: [\n { id: 'nav_webhooks', type: 'object', label: 'Webhooks', objectName: 'sys_webhook', icon: 'webhook', requiresObject: 'sys_webhook' },\n { id: 'nav_http_deliveries', type: 'object', label: 'HTTP Deliveries', objectName: 'sys_http_delivery', icon: 'send', requiresObject: 'sys_http_delivery' },\n ],\n },\n ],\n });\n } else {\n ctx.logger.warn?.(\n '[webhook-outbox] manifest service unavailable — sys_webhook will NOT appear in REST or Studio nav. Register MetadataService before WebhookOutboxPlugin.',\n );\n }\n\n // ADR-0029 D8 — contribute object translations once i18n is up.\n if (typeof (ctx as any).hook === 'function') {\n (ctx as any).hook('kernel:ready', async () => {\n try {\n const i18n = ctx.getService<II18nService>('i18n');\n if (i18n && typeof i18n.loadTranslations === 'function') {\n const { WebhooksTranslations } = await import('./translations/index.js');\n for (const [locale, data] of Object.entries(WebhooksTranslations)) {\n i18n.loadTranslations(locale, data as Record<string, unknown>);\n }\n }\n } catch { /* i18n optional */ }\n });\n }\n\n const autoEnqueueOpt = this.options.autoEnqueue ?? true;\n\n if (typeof (ctx as any).hook === 'function') {\n (ctx as any).hook('kernel:ready', async () => {\n // Materialize declared webhooks FIRST — this only needs the data\n // engine, so it must not be gated behind the auto-enqueue\n // dispatch prerequisites (realtime + messaging). Otherwise a\n // deployment without realtime would silently fail to materialize\n // declared webhooks — the very no-op this bridge closes (#3461).\n await this.bootDeclaredWebhooks(ctx);\n await this.bootAutoEnqueue(ctx, autoEnqueueOpt);\n this.registerAdminRoutes(ctx);\n });\n }\n\n ctx.logger.info?.('[webhook-outbox] initialised (delivery via shared messaging HTTP outbox)', {\n autoEnqueue: autoEnqueueOpt !== false,\n });\n }\n\n async dispose(): Promise<void> {\n await this.autoEnqueuer?.stop();\n if (this.boundEngine) {\n try { unbindWebhookProvenanceStamp(this.boundEngine); } catch { /* best effort */ }\n try { unbindWebhookHeadersShapeGate(this.boundEngine); } catch { /* best effort */ }\n this.boundEngine = undefined;\n }\n }\n\n private getMessaging(ctx: PluginContext): MessagingHttpSurface | undefined {\n const svc = this.tryGetService<MessagingHttpSurface>(ctx, ['messaging']);\n return svc && typeof svc.enqueueHttp === 'function' ? svc : undefined;\n }\n\n /**\n * [#3461] Bridge the declarative authoring surface to the dispatcher:\n * materialize stack/connector-declared `webhook` metadata into `sys_webhook`\n * rows so the auto-enqueuer (and the Studio UI) can see them.\n *\n * Gated on the DATA ENGINE alone — deliberately independent of the\n * auto-enqueue dispatch prerequisites (realtime + messaging). Materializing\n * rows is a pure write; a deployment that mounts the webhook plugin without\n * realtime must still get its declared webhooks into the table (and the\n * Setup UI), even if nothing dispatches them yet. Runs before\n * {@link bootAutoEnqueue} so the enqueuer's first cache refresh sees the rows.\n */\n private async bootDeclaredWebhooks(ctx: PluginContext): Promise<void> {\n const engine = this.tryGetService<IDataEngine>(ctx, ['objectql', 'data']);\n if (!engine) {\n ctx.logger.warn?.('[webhook] declared-webhook bootstrap skipped — no data engine available');\n return;\n }\n // Bind the provenance stamp so an admin edit freezes a seeded row.\n this.boundEngine = engine;\n bindWebhookProvenanceStamp(engine as any, ctx.logger as any);\n // [#8566] And the headers_secret shape gate, BEFORE the seeder below\n // runs its first write — a validation door that arms after the first\n // write it is meant to judge is not a door. It covers every write path\n // at once (the generic data API included, which is the measured\n // trigger), so the plugin's own writers deliberately carry no second\n // check of their own.\n bindWebhookHeadersShapeGate(engine as any, ctx.logger as any);\n let metadataService: IMetadataService | undefined;\n try { metadataService = ctx.getService<IMetadataService>('metadata'); } catch { /* optional */ }\n try {\n await bootstrapDeclaredWebhooks(engine, metadataService, ctx.logger as any);\n } catch (err: any) {\n ctx.logger.warn?.('[webhook] declared-webhook bootstrap failed (dispatcher still serves admin rows)', {\n error: err?.message ?? String(err),\n });\n }\n // [#7799] Then heal the rows the seeder cannot touch. Package rows are\n // rewritten above; `managed_by: 'admin'` and `customized: true` rows are\n // deliberately frozen against re-seeding, and those are precisely the\n // ones holding hand-authored production keys in cleartext. Runs AFTER\n // the seeder so a row it just rewrote is already secret-free and the\n // sweep is a no-op on it.\n try {\n await migrateLegacyWebhookSecrets(engine, ctx.logger as any);\n } catch (err: any) {\n ctx.logger.warn?.('[webhook] legacy signing-secret sweep failed (rows left unchanged)', {\n error: err?.message ?? String(err),\n });\n }\n }\n\n private async bootAutoEnqueue(\n ctx: PluginContext,\n opt: boolean | AutoEnqueuerOptions,\n ): Promise<void> {\n if (opt === false) return;\n const engine = this.tryGetService<IDataEngine>(ctx, ['objectql', 'data']);\n const realtime = this.tryGetService<IRealtimeService>(ctx, ['realtime']);\n const messaging = this.getMessaging(ctx);\n if (!engine || !realtime || !messaging) {\n ctx.logger.warn?.(\n '[webhook-auto-enqueuer] disabled — ObjectQL, Realtime, or Messaging service not available',\n { hasEngine: !!engine, hasRealtime: !!realtime, hasMessaging: !!messaging },\n );\n return;\n }\n if (!messaging.isHttpDeliveryReady()) {\n ctx.logger.warn?.(\n '[webhook-auto-enqueuer] messaging HTTP outbox not ready (no data engine / reliableDelivery off) — webhook deliveries will not be durable',\n );\n }\n\n const enqOpts = (typeof opt === 'object' ? opt : {}) as AutoEnqueuerOptions;\n // [#8069] Install the redelivery veto BEFORE the enqueuer starts\n // writing rows, so no delivery row can ever exist while the refusal\n // that protects it does not.\n this.installRedeliverGuard(ctx, messaging, engine, enqOpts.subscriptionsObject);\n this.autoEnqueuer = new AutoEnqueuer(\n engine,\n realtime,\n (input) => messaging.enqueueHttp(input),\n { ...enqOpts, logger: ctx.logger },\n );\n await this.autoEnqueuer.start();\n ctx.registerService('webhook.autoEnqueuer', this.autoEnqueuer);\n ctx.logger.info?.('[webhook-auto-enqueuer] started (enqueues source=webhook onto sys_http_delivery)');\n }\n\n /**\n * [#8069] Register {@link createWebhookRedeliverGuard} with messaging, so\n * `redeliver()` refuses a webhook row whose signing configuration is no\n * longer available — for EVERY caller, not just the\n * `POST /api/v1/webhooks/redeliver` route.\n *\n * Absence is loud, and `error` is the right level by AGENTS.md's one\n * question: with no guard installed the endpoint still answers 200 and the\n * dispatcher still reports a delivery, while the fail-closed signing\n * guarantee the system claims (#7799) is not actually being kept. That is a\n * durability/consistency degradation wearing a functional one's clothes.\n */\n private installRedeliverGuard(\n ctx: PluginContext,\n messaging: MessagingHttpSurface,\n engine: IDataEngine,\n subscriptionsObject?: string,\n ): void {\n if (typeof messaging.registerRedeliverGuard !== 'function') {\n ctx.logger.error?.(\n '[webhook-outbox] messaging service exposes no registerRedeliverGuard() — redelivery '\n + 'of a webhook whose signing configuration is gone CANNOT be refused, so an operator '\n + 'pressing redeliver may send a delivery that can no longer be authenticated '\n + '(#7799, #8069). The POST /api/v1/webhooks/redeliver endpoint is reachable by any '\n + 'authenticated user. Fix: upgrade @objectstack/service-messaging to a build that '\n + 'implements registerRedeliverGuard.',\n );\n return;\n }\n messaging.registerRedeliverGuard(\n 'webhook',\n createWebhookRedeliverGuard(engine, subscriptionsObject),\n );\n ctx.logger.debug?.('[webhook-outbox] redeliver guard installed for source=webhook');\n }\n\n private tryGetService<T>(ctx: PluginContext, names: string[]): T | undefined {\n for (const n of names) {\n try {\n const svc = ctx.getService<T>(n);\n if (svc) return svc;\n } catch {\n // fall through\n }\n }\n return undefined;\n }\n\n /**\n * Mount POST /api/v1/webhooks/redeliver on the host Hono app, if one is\n * available. Delegates to `messaging.redeliverHttp(deliveryId)`. Auth is the\n * better-auth session cookie — every authenticated user counts.\n */\n private registerAdminRoutes(ctx: PluginContext): void {\n const http = this.tryGetService<any>(ctx, ['http-server']);\n if (!http || typeof http.getRawApp !== 'function') {\n ctx.logger.debug?.('[webhook-outbox] HTTP server not available; redeliver endpoint not mounted');\n return;\n }\n const rawApp = http.getRawApp();\n const messaging = this.getMessaging(ctx);\n if (!rawApp || !messaging) return;\n\n rawApp.post('/api/v1/webhooks/redeliver', async (c: any) => {\n const userId = await this.resolveSessionUserId(ctx, c);\n if (!userId) {\n return c.json(\n { success: false, error: { code: 'UNAUTHENTICATED', message: 'Sign in to redeliver webhook deliveries.' } },\n 401,\n );\n }\n let body: any;\n try {\n body = await c.req.json();\n } catch {\n return c.json({ success: false, error: { code: 'INVALID_REQUEST', message: 'Request body must be JSON.' } }, 400);\n }\n const deliveryId = typeof body?.deliveryId === 'string' ? body.deliveryId.trim() : '';\n if (!deliveryId) {\n return c.json(\n { success: false, error: { code: 'MISSING_REQUIRED_FIELD', message: 'Body must include `deliveryId: string`.' } },\n 400,\n );\n }\n try {\n const row = await messaging.redeliverHttp(deliveryId);\n ctx.logger.info?.('[webhook-outbox] redelivered', { deliveryId, requestedBy: userId });\n return c.json({ success: true, data: { id: row.id, status: row.status } });\n } catch (err: any) {\n const code = err?.code;\n if (code === 'RESOURCE_NOT_FOUND') {\n return c.json({ success: false, error: { code, message: err.message } }, 404);\n }\n // [#8069] `DELIVERY_NEVER_SENT` is a refusal, not a server\n // fault: the row is a parked record of a delivery that was\n // never prepared, and re-sending it would be a FIRST delivery —\n // unsigned, because the secret that would have signed it is\n // what went missing. 409 alongside the eligibility refusal, so\n // an operator tool can present both the same way; without this\n // arm it would surface as a 500 and read as a transient glitch\n // worth retrying.\n if (code === 'DELIVERY_NOT_ELIGIBLE' || code === 'DELIVERY_NEVER_SENT') {\n return c.json({ success: false, error: { code, message: err.message } }, 409);\n }\n ctx.logger.error?.('[webhook-outbox] redeliver failed', err as Error);\n return c.json(\n { success: false, error: { code: 'INTERNAL_ERROR', message: err?.message ?? String(err) } },\n 500,\n );\n }\n });\n\n ctx.logger.info?.('[webhook-outbox] redeliver endpoint mounted at POST /api/v1/webhooks/redeliver');\n }\n\n private async resolveSessionUserId(ctx: PluginContext, c: any): Promise<string | undefined> {\n try {\n const authService: any = this.tryGetService<any>(ctx, ['auth']);\n if (!authService) return undefined;\n let api: any = authService.api;\n if (!api && typeof authService.getApi === 'function') {\n api = await authService.getApi();\n }\n if (!api?.getSession) return undefined;\n const session = await api.getSession({ headers: c.req.raw.headers });\n const uid = session?.user?.id;\n return typeof uid === 'string' && uid.length > 0 ? uid : undefined;\n } catch {\n return undefined;\n }\n }\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["/home/runner/work/objectstack/objectstack/packages/plugins/plugin-webhooks/dist/index.cjs","../src/webhook-secret.ts","../src/webhook-headers.ts","../src/auto-enqueuer.ts","../src/bootstrap-declared-webhooks.ts","../src/migrate-webhook-secrets.ts","../src/redeliver-guard.ts","../src/webhook-provenance.ts","../src/webhook-headers-gate.ts","../src/webhook-outbox-plugin.ts"],"names":[],"mappings":"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","file":"/home/runner/work/objectstack/objectstack/packages/plugins/plugin-webhooks/dist/index.cjs","sourcesContent":[null,"// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * [#7799] The persistence seam for a webhook's HMAC signing secret.\n *\n * ## The defect\n * `bootstrapDeclaredWebhooks` used to persist the whole validated `Webhook`\n * envelope — `secret` included — as `definition_json: JSON.stringify(wh)`, and\n * `AutoEnqueuer.parseRow` read `defn.secret` straight back out to sign\n * deliveries. `definition_json` is an ordinary textarea on an admin-authorable\n * object with no restrictive `enable.apiMethods`, so an ordinary\n * `GET /api/v1/data/sys_webhook` returned the key to every persona that can read\n * the object. That key is the receiver's ONLY proof a delivery came from us.\n *\n * #7722 removed the same secret's per-attempt copies from `sys_http_delivery`;\n * this is the remaining cleartext location, and unlike the delivery table it is\n * not bounded by a retention window.\n *\n * ## The seam\n * Nothing about the AUTHORING envelope changes — authors still write\n * `secret: '…'` on `defineWebhook()`, and `webhook.zod.ts` is untouched. What\n * changes is where the value LANDS:\n *\n * authored `secret` → `sys_webhook.signing_secret` (`type: 'secret'`)\n * → engine encrypts → `sys_secret` ciphertext row\n * → row keeps only an opaque `secret:<id>` ref\n * → every read path returns the mask\n *\n * `definition_json` → the same envelope MINUS `secret`\n *\n * and the enqueuer recovers the plaintext server-side, at cache-refresh time,\n * through `engine.resolveSecretField()` — the privileged, driver-level\n * dereference added alongside this change, because the encrypted channel masks\n * its own ref on every supported read path and a server-side consumer\n * previously had no way to get at it.\n *\n * ## Two things this file deliberately does NOT do\n * - It does not invent a second cipher store. The engine owns the\n * `ICryptoProvider` (the host injects it via `setCryptoProvider`, and it is\n * not a kernel service), so the plugin cannot encrypt on its own — it writes\n * cleartext INTO the `secret`-typed column exactly once and lets the engine's\n * own write path do the wrapping. That also inherits the engine's fail-closed\n * posture for free: no provider ⇒ the write throws ⇒ we skip the webhook\n * loudly, rather than silently re-opening the hole in a new column.\n * - It does not guess. When a row HAS a stored secret the enqueuer cannot\n * resolve, the subscription is dropped rather than delivered unsigned — an\n * undelivered webhook is visible and safe, an unsigned one is invisible and\n * is precisely the failure this issue is about.\n */\n\nimport type { IDataEngine } from '@objectstack/spec/contracts';\n\n/** Column on `sys_webhook` holding the encrypted signing key. */\nexport const WEBHOOK_SECRET_FIELD = 'signing_secret';\n\n/** Object whose rows carry it. Kept here so seeder/enqueuer/sweep agree. */\nexport const WEBHOOK_OBJECT = 'sys_webhook';\n\n/**\n * Error code + status carried by the refusal this seam can raise, per ADR-0112:\n * a consumer branches on `code`, not on message text. `INTERNAL_ERROR`/500 is\n * the standard-catalog member for \"the server is misconfigured and cannot honour\n * this safely\" — no CryptoProvider is wired, so there is nowhere to put the key\n * that is not cleartext.\n */\nexport const WEBHOOK_SECRET_REFUSAL_CODE = 'INTERNAL_ERROR';\nexport const WEBHOOK_SECRET_REFUSAL_STATUS = 500;\n\n/**\n * True when `err` is the engine's fail-closed refusal to persist a `secret`\n * field — no CryptoProvider registered, or no reachable `sys_secret` store.\n * Matched on the engine's own wording because that path throws a bare `Error`;\n * a false negative only costs a less specific log line, never cleartext.\n */\nexport function isSecretProtectionFailure(err: unknown): boolean {\n const msg = String((err as Error)?.message ?? err ?? '');\n return /Cannot persist secret field/i.test(msg);\n}\n\n/**\n * [#8542] A signing secret IS stored on the row and could not be recovered.\n *\n * ## Why this is an error and not a `undefined`\n * `resolveWebhookSecret` used to return `undefined` for two different facts —\n * *\"the author configured this webhook unsigned\"* and *\"a key is stored but\n * nothing came back\"* — and its caller acts on the first reading, which is the\n * legitimate one. So the second silently became the first: the subscription\n * ARMED and every delivery went out unauthenticated while `sys_webhook` kept\n * reading `active: true`. Nothing logged, nothing dropped. That is the #7799\n * signing invariant failing OPEN, and the direction is the whole defect — the\n * two adjacent failure modes (a throwing resolver, an engine with no encrypted\n * channel) both fail CLOSED and loud.\n *\n * Presence is decidable even when the value is not: the generic read path\n * returns the engine's mask for a set secret and `null` for an unset one, so\n * the caller already knows a value is stored before it asks for the plaintext.\n * Raising here rather than at each caller is what makes the rule one rule —\n * `AutoEnqueuer.attachSecret` needs no new branch, because a stored-but-\n * unresolvable key now arrives exactly the way a throwing resolver already did.\n *\n * Carries the ADR-0112 pair as fields so a consumer branches on `code`/`status`\n * rather than on message text. Same pair the seeder's refusal already reports\n * for the same underlying cause.\n */\nexport class WebhookSecretUnresolvableError extends Error {\n readonly code = WEBHOOK_SECRET_REFUSAL_CODE;\n readonly status = WEBHOOK_SECRET_REFUSAL_STATUS;\n constructor(message: string) {\n super(message);\n this.name = 'WebhookSecretUnresolvableError';\n }\n}\n\n/**\n * True when `err` is this seam's refusal to hand back a key it could not\n * recover — as opposed to any other failure, which means \"we could not even\n * check\" and must not be softened into a verdict.\n *\n * The distinction has one consumer today: the redeliver guard, whose contract\n * is a returned refusal REASON rather than a throw (#8069). Everything on the\n * enqueue path just lets it propagate into the `catch` that already parks the\n * subscription.\n */\nexport function isWebhookSecretUnresolvable(\n err: unknown,\n): err is WebhookSecretUnresolvableError {\n return err instanceof WebhookSecretUnresolvableError;\n}\n\n/**\n * Split an authored envelope into the part that is safe to serialize into\n * `definition_json` and the key that must go to the encrypted column.\n *\n * The key is REMOVED, not blanked: leaving `\"secret\": \"\"` behind would still\n * teach the next reader that this blob is where the key lives, and a later\n * merge could refill it.\n */\nexport function splitWebhookSecret<T extends Record<string, unknown>>(\n wh: T,\n): { envelope: Omit<T, 'secret'>; secret: string | undefined } {\n const { secret, ...envelope } = wh as T & { secret?: unknown };\n const value = typeof secret === 'string' && secret.length > 0 ? secret : undefined;\n return { envelope: envelope as Omit<T, 'secret'>, secret: value };\n}\n\n/**\n * Read a legacy cleartext secret out of a `definition_json` blob.\n *\n * Rows written before #7799 — and rows an admin hand-edited into the textarea —\n * still carry one. Returns `undefined` for anything else, including unparseable\n * JSON (a malformed blob is not a credential).\n */\nexport function readLegacySecret(definitionJson: unknown): string | undefined {\n if (typeof definitionJson !== 'string' || definitionJson.length === 0) return undefined;\n try {\n const parsed = JSON.parse(definitionJson);\n const secret = (parsed as { secret?: unknown } | null)?.secret;\n return typeof secret === 'string' && secret.length > 0 ? secret : undefined;\n } catch {\n return undefined;\n }\n}\n\n// `stripSecretFromDefinition` lived here until #7986. Its single caller — the\n// boot sweep — now has to remove BOTH credential passengers from the blob, and\n// doing that as two independent parse/serialize round-trips would let the two\n// removals disagree about what the blob contained. The sweep owns one\n// `stripCredentialsFromDefinition` instead, built from `splitWebhookSecret` +\n// `splitWebhookHeaders` over a single parse.\n\n/**\n * objectql's two wire forms for the encrypted channel, restated here ONLY as a\n * \"this value is not the key\" guard.\n *\n * This package deliberately takes no dependency on `@objectstack/objectql` (it\n * declares the messaging surface structurally for the same reason), so the\n * constants cannot be imported — and a signing key is the one place where\n * guessing is unacceptable: sign with the mask and every receiver rejects every\n * delivery, silently, forever. `webhook-secret-at-rest.test.ts` pins both\n * against objectql's own exports so a rename there reddens here.\n */\nconst OBJECTQL_SECRET_MASK = '••••••••';\nconst OBJECTQL_SECRET_REF_PREFIX = 'secret:';\n\n/** True when a column value is objectql's mask or ref — opaque, never the key. */\nexport function isOpaqueSecretForm(value: unknown): boolean {\n return (\n typeof value === 'string'\n && (value === OBJECTQL_SECRET_MASK || value.startsWith(OBJECTQL_SECRET_REF_PREFIX))\n );\n}\n\n/** Test-only accessors for the pin above. */\nexport const __objectqlSecretWireForms = {\n mask: OBJECTQL_SECRET_MASK,\n refPrefix: OBJECTQL_SECRET_REF_PREFIX,\n} as const;\n\n/** Engines that expose the privileged dereference (ObjectQL ≥ #7799). */\ntype SecretResolvingEngine = IDataEngine & {\n resolveSecretField?(object: string, recordId: string, field: string): Promise<string | null>;\n onCryptoProviderChange?(listener: () => void): () => void;\n};\n\n/** True when this engine can dereference an encrypted field. */\nexport function canResolveSecrets(engine: IDataEngine | undefined): boolean {\n return typeof (engine as SecretResolvingEngine | undefined)?.resolveSecretField === 'function';\n}\n\n/**\n * [#8022] Subscribe to the engine's crypto-provider registration. Returns an\n * unsubscribe function, or `undefined` when the engine has no such channel.\n *\n * ## Why this exists\n * Resolving a stored key stays fail-closed (#7799) — that is not what this\n * changes. What it changes is how long a fail-closed READ is allowed to stand\n * when the reason for it is about to disappear. \"No CryptoProvider\" is not only\n * a misconfiguration: on every host it is also a *transient boot state*, because\n * plugins run inside `kernel:ready` and the composition root injects the\n * provider only after `runtime.start()` returns. So the enqueuer's FIRST cache\n * build reliably precedes the capability it needs, drops every secret-bearing\n * subscription (correctly, on what it could see), and — before this — stayed\n * dropped until the next periodic refresh 60s later.\n *\n * Feature-detected rather than required, exactly like `resolveSecretField`\n * above, because this package deliberately takes no dependency on\n * `@objectstack/objectql`. An engine without the channel keeps the previous\n * behaviour — the periodic refresh remains the backstop — rather than failing\n * to start.\n */\nexport function onCryptoProviderChange(\n engine: IDataEngine | undefined,\n listener: () => void,\n): (() => void) | undefined {\n const observable = engine as SecretResolvingEngine | undefined;\n if (typeof observable?.onCryptoProviderChange !== 'function') return undefined;\n return observable.onCryptoProviderChange(listener);\n}\n\n/**\n * Recover a row's signing key. Returns `undefined` for EXACTLY one fact — the\n * row has no stored key — which is not an error: `secret` is optional on the\n * authoring envelope, and an unsigned webhook is a legitimate authored choice.\n *\n * Throws {@link WebhookSecretUnresolvableError} when a key IS stored and does\n * not come back. Callers must treat that as \"drop this subscription\", never as\n * \"deliver unsigned\".\n *\n * ## [#8542] Why \"did not come back\" is not spelled `undefined`\n * The dereference has three measured ways to answer `null` while a value is\n * genuinely stored, all of them reaching this function identically:\n *\n * 1. the `sys_webhook` row is deleted between the enqueuer's cache read and\n * this dereference (`resolveSecretField` opens `if (!row) return null`);\n * 2. the column holds something that is not a `secret:` ref — measured as\n * reachable only through a write that BYPASSES the engine (a hand-edited\n * column, a dump restored without its `sys_secret` rows, a seed script\n * writing at driver level). The engine's own write path defends both\n * obvious routes: an echoed mask is dropped and cleartext is re-encrypted;\n * 3. the ciphertext decrypts to the empty string — reachable through the\n * ORDINARY data API, which accepts `signing_secret: ''`, mints a real\n * `sys_secret` row for it, and leaves the column holding a perfectly valid\n * ref that reads back as the mask.\n *\n * In all three the row still advertises a stored secret on every read path, so\n * returning `undefined` told the caller the opposite of what the row says.\n */\nexport async function resolveWebhookSecret(\n engine: IDataEngine,\n row: { id: string; [k: string]: unknown },\n object: string = WEBHOOK_OBJECT,\n): Promise<string | undefined> {\n const stored = row[WEBHOOK_SECRET_FIELD];\n // Unset / cleared. On the generic read path a set secret comes back as the\n // engine's mask (a non-empty string) and an unset one as `null`, so presence\n // is decidable here WITHOUT the value ever being readable. Everything below\n // this line therefore runs with \"a secret IS stored\" already established —\n // which is the knowledge the old `undefined` return threw away.\n if (stored == null || stored === '') return undefined;\n\n const resolver = engine as SecretResolvingEngine;\n if (typeof resolver.resolveSecretField !== 'function') {\n // An engine with no encrypted-field channel stored verbatim what the seeder\n // handed it, so the column IS the key — reading it is correct, not a\n // fallback. The refusal below is for the narrow case where the value is one\n // of objectql's opaque forms and there is no way to invert it.\n if (!isOpaqueSecretForm(stored)) return String(stored);\n throw new WebhookSecretUnresolvableError(\n `Webhook \"${String(row.name ?? row.id)}\" stores an encrypted signing secret, but this data `\n + 'engine does not implement resolveSecretField() — the key cannot be recovered, so the '\n + 'subscription is dropped rather than delivered unsigned (#7799).',\n );\n }\n const plain = await resolver.resolveSecretField(object, String(row.id), WEBHOOK_SECRET_FIELD);\n if (typeof plain === 'string' && plain.length > 0) return plain;\n\n throw new WebhookSecretUnresolvableError(\n `Webhook \"${String(row.name ?? row.id)}\" stores a signing secret in `\n + `${object}.${WEBHOOK_SECRET_FIELD} that resolved to nothing. A value IS stored — the read `\n + 'path returns the engine mask for it — so this is NOT an unsigned webhook, and delivering '\n + 'it unsigned would strip the receiver of its only proof of origin (#7799, #8542). Causes, '\n + 'in the order worth checking: the row was deleted while this refresh was reading it; the '\n + 'column holds something that is not a secret: ref (a hand-edited column, or a dump restored '\n + 'without its sys_secret rows); or the stored value decrypts to an empty string. Fix: re-save '\n + 'the webhook secret so the column holds a fresh ref, or CLEAR the field to null if this '\n + 'webhook is meant to be unsigned — an empty secret is not the same thing as no secret.',\n );\n}\n","// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * [#7986] The persistence seam for a webhook's custom `headers` map — the\n * sibling passenger #7799 left behind on the blob it emptied.\n *\n * ## The defect\n * #7799 moved the signing secret out of `sys_webhook.definition_json` into an\n * encrypted column. It did not move `headers`, and `headers` is the ordinary\n * place an `Authorization: Bearer …` goes. Same column, same object with no\n * `enable` block at all (so the FULL default data API), same unbounded\n * retention — the only thing that differed was which key of the blob the card\n * happened to name. `GET /api/v1/data/sys_webhook` handed the header map back\n * to every persona that can read the object.\n *\n * That framing is the finding: the COLUMN was the problem and the secret was\n * only one of its passengers.\n *\n * ## Why the WHOLE map moves, and not just the credential-looking entries\n * A signing secret is one opaque value with one consumer. `headers` is an\n * open-ended `Record<string, string>` in which only some entries are\n * credentials — and **the platform cannot tell which**. Three ways to decide\n * were on the table; this is why the map moves whole:\n *\n * - **Guess from the header NAME** (`authorization`, `x-api-key`, …). Rejected:\n * it is fail-OPEN on precisely the names most likely to be a credential in\n * practice — `X-Acme-Token`, `X-Vendor-Key` — and a heuristic that silently\n * passes the one header that mattered is worse than no heuristic, because it\n * reads as coverage. Every other credential decision in this repo fails\n * closed; this one would not.\n * - **Have the author DECLARE which are sensitive** (`secretHeaders: [...]`).\n * That is a change to the authoring envelope (`webhook.zod.ts`), which is\n * the spec seat's surface, not this one — and it would still leave the\n * `source: 'flow'` half of the same exposure untouched, because a flow\n * `http` node's headers are interpolated per run and never parsed through\n * `WebhookSchema` at all. Escalated rather than attempted here (#7986).\n * - **Move the whole map.** Fail-closed by construction, needs no authoring\n * change, and the cost it is accused of — \"it encrypts non-sensitive headers\n * too\" — is measured and small: `definition_json` is a raw JSON textarea\n * pending a real builder (see `sys-webhook.object.ts`), so what an admin\n * loses is the ability to READ back a `Content-Type` they typed, on a\n * surface that was never the intended authoring UI.\n *\n * ## The seam\n * Identical in shape to `webhook-secret.ts`, deliberately — one mechanism, two\n * passengers, so a reader who has understood #7799 has already understood this:\n *\n * authored `headers` → `sys_webhook.headers_secret` (`type: 'secret'`)\n * → engine encrypts the SERIALIZED map → `sys_secret`\n * → row keeps only an opaque `secret:<id>` ref\n * → every read path returns the mask\n *\n * `definition_json` → the same envelope MINUS `headers` (and MINUS\n * `secret`, as #7799 already established)\n *\n * The map is serialized because the encrypted channel carries a string. That is\n * an encoding detail and not a second format: {@link parseStoredHeaders} is the\n * only reader, and it treats anything that is not a flat string map as absent\n * rather than guessing.\n *\n * ## What this file deliberately does NOT do\n * - It does not invent a second cipher store, for the same layering reason\n * `webhook-secret.ts` gives: the engine owns the `ICryptoProvider`, so the\n * plugin writes cleartext INTO the `secret`-typed column exactly once and\n * lets the engine's write path wrap it. The fail-closed posture comes free.\n * - It does not deliver partially. A row whose stored headers cannot be\n * resolved DROPS the subscription rather than delivering it with the headers\n * missing — see {@link resolveWebhookHeaders}.\n *\n * [#8558] That last line was a statement of intent this file did not keep. Only\n * a THROWING resolver reached the caller's `catch`; a resolver that answered\n * `null` — or handed back a value that was not a flat string map — folded onto\n * the `undefined` this seam uses for \"no headers stored\", and the subscription\n * armed and delivered without them. {@link WebhookHeadersUnresolvableError} is\n * what makes the sentence true.\n */\n\nimport type { IDataEngine } from '@objectstack/spec/contracts';\nimport {\n WEBHOOK_SECRET_REFUSAL_CODE,\n WEBHOOK_SECRET_REFUSAL_STATUS,\n isOpaqueSecretForm,\n} from './webhook-secret.js';\n\n/** Column on `sys_webhook` holding the encrypted custom-header map. */\nexport const WEBHOOK_HEADERS_FIELD = 'headers_secret';\n\n/** Engines that expose the privileged dereference (ObjectQL ≥ #7799). */\ntype SecretResolvingEngine = IDataEngine & {\n resolveSecretField?(object: string, recordId: string, field: string): Promise<string | null>;\n};\n\n/** A header map, as the authoring envelope declares it. */\nexport type WebhookHeaders = Record<string, string>;\n\n/**\n * True when `value` is a flat `Record<string, string>` with at least one entry.\n *\n * Anything else — an array, a nested object, a map of numbers — is treated as\n * ABSENT rather than coerced. A header map is about to be written onto the\n * wire; a coerced `[object Object]` header value is a silently corrupted\n * request, and the authoring schema (`z.record(z.string(), z.string())`)\n * already rejects the shape at every declared door.\n */\nfunction isHeaderMap(value: unknown): value is WebhookHeaders {\n if (!value || typeof value !== 'object' || Array.isArray(value)) return false;\n const entries = Object.entries(value as Record<string, unknown>);\n if (entries.length === 0) return false;\n return entries.every(([, v]) => typeof v === 'string');\n}\n\n/**\n * Split an authored envelope into the part that is safe to serialize into\n * `definition_json` and the header map that must go to the encrypted column.\n *\n * The map is REMOVED, not blanked, for the reason `splitWebhookSecret` gives\n * about the secret: leaving `\"headers\": {}` behind still teaches the next\n * reader that this blob is where headers live, and a later merge could refill\n * it.\n */\nexport function splitWebhookHeaders<T extends Record<string, unknown>>(\n wh: T,\n): { envelope: Omit<T, 'headers'>; headers: WebhookHeaders | undefined } {\n const { headers, ...envelope } = wh as T & { headers?: unknown };\n return {\n envelope: envelope as Omit<T, 'headers'>,\n headers: isHeaderMap(headers) ? headers : undefined,\n };\n}\n\n/** Serialize a header map for the encrypted column (which carries a string). */\nexport function serializeHeaders(headers: WebhookHeaders): string {\n return JSON.stringify(headers);\n}\n\n/** Inverse of {@link serializeHeaders}. Non-conforming input reads as absent. */\nexport function parseStoredHeaders(stored: unknown): WebhookHeaders | undefined {\n if (typeof stored !== 'string' || stored.length === 0) return undefined;\n try {\n const parsed = JSON.parse(stored);\n return isHeaderMap(parsed) ? parsed : undefined;\n } catch {\n return undefined;\n }\n}\n\n/**\n * Read a legacy cleartext header map out of a `definition_json` blob.\n *\n * Rows written before this change — and rows an admin hand-edited into the\n * textarea — still carry one. Returns `undefined` for anything else, including\n * unparseable JSON (a malformed blob is not a credential).\n */\nexport function readLegacyHeaders(definitionJson: unknown): WebhookHeaders | undefined {\n if (typeof definitionJson !== 'string' || definitionJson.length === 0) return undefined;\n try {\n const parsed = JSON.parse(definitionJson) as { headers?: unknown } | null;\n return isHeaderMap(parsed?.headers) ? parsed.headers : undefined;\n } catch {\n return undefined;\n }\n}\n\n/**\n * [#8558] A header map IS stored on the row and did not come back as one.\n *\n * ## Why this is an error and not an `undefined`\n * `resolveWebhookHeaders` used to return `undefined` for two different facts —\n * *\"the author configured this webhook with no custom headers\"* and *\"a map is\n * stored and did not come back\"* — and its caller acts on the first reading,\n * which is the legitimate one. So the second silently became the first: the\n * subscription ARMED and every delivery went out missing the entire authored\n * header map, while `sys_webhook` kept reading `active: true` with\n * `headers_secret` masked, i.e. still reporting \"custom headers are\n * configured\". Measured end to end, what reached the receiver was a delivery\n * that SUCCEEDED, carrying a byte-correct `X-Objectstack-Signature`, with the\n * `Authorization` the author declared simply absent — and nothing logged.\n *\n * That the signature is VALID is what makes the direction so bad. It tells the\n * receiver the request is genuinely ours, so a receiver that authenticates by\n * signature has every reason to accept a request that no longer matches the\n * configuration its operator wrote. Against an endpoint that does not require\n * the header at all — a routing `X-Tenant-Id`, an `X-Environment: staging` —\n * the delivery is simply wrong and nobody finds out.\n *\n * Presence is decidable even when the value is not, and this is the one place\n * worth stating plainly because the field LOOKS like it should behave\n * differently: `headers_secret` is a map only in the plaintext. At the storage\n * layer it is an ordinary scalar `secret` column holding the serialized map, so\n * the generic read path returns the engine's mask for a set map and `null` for\n * an unset one — the same decidable signal `signing_secret` gives, for the same\n * reason. The \"it is a map, not a scalar\" worry does not survive measurement.\n *\n * Carries the ADR-0112 pair as fields so a consumer branches on `code`/`status`\n * rather than on message text — the same pair `attachHeaders`' drop report and\n * the signing seam's refusal already carry for the same class of cause.\n *\n * ## Why ONE error class for two conditions\n * A stored map reaches this seam and fails in two distinguishable ways: it\n * could not be RECOVERED (nothing came back), or it was recovered fine and is\n * not a usable header map. They deserve different remedies and get different\n * messages. They do not deserve different types: every consumer of this seam\n * branches on the ADR-0112 pair and the disposition, both identical — park the\n * subscription, record the discarded event, say it once. A second class with no\n * consumer would be a distinction the tree cannot act on.\n */\nexport class WebhookHeadersUnresolvableError extends Error {\n readonly code = WEBHOOK_SECRET_REFUSAL_CODE;\n readonly status = WEBHOOK_SECRET_REFUSAL_STATUS;\n constructor(message: string) {\n super(message);\n this.name = 'WebhookHeadersUnresolvableError';\n }\n}\n\n/**\n * The remedy clause both refusals end with — one wording, stated once.\n *\n * [#8566] Exported because the WRITE door quotes it too: the shape gate refuses\n * the same malformed map at authoring time that this file refuses at delivery\n * time, and an author who meets both should be told to do the same thing both\n * times. Two hand-kept copies of one remedy is how they drift.\n */\nexport const HEADERS_REMEDY =\n 'Fix: re-save the webhook headers as a flat JSON object of string values so the column holds a '\n + 'fresh ref, or CLEAR the field to null if this webhook is meant to send no custom headers — an '\n + 'empty or unparseable header map is not the same thing as no header map, and only the second '\n + 'one means \"send nothing extra\".';\n\n/**\n * Parse a recovered value into the map, or refuse.\n *\n * {@link parseStoredHeaders} answers `undefined` for every string that is not a\n * flat `Record` of strings, which is right for its own job and wrong as an\n * answer to *\"what are this webhook's headers?\"* once a value is known to be\n * stored. This is the narrow wrapper that turns the second reading into a\n * refusal, so the rule lives at the seam and no caller re-derives it.\n */\nfunction requireHeaderMap(\n recovered: unknown,\n row: { id: string; [k: string]: unknown },\n where: string,\n): WebhookHeaders {\n const parsed = parseStoredHeaders(recovered);\n if (parsed) return parsed;\n\n throw new WebhookHeadersUnresolvableError(\n `Webhook \"${String(row.name ?? row.id)}\" stores custom headers in ${where} that came back but are `\n + 'not a flat JSON object of string values, so there is no header map to send. A value IS stored '\n + '— the read path returns the engine mask for it — so this is NOT a webhook authored without '\n + 'headers, and delivering it without them would silently drop whatever the author put in that '\n + 'map, including an Authorization credential, on a delivery that is otherwise correctly signed '\n + 'and therefore looks genuine to the receiver (#7986, #8558). Causes, in the order worth '\n + 'checking: the value was typed into the Custom Headers field and is not valid JSON; it parses '\n + 'but is an array, an empty object, or has a non-string value ({\"X-Count\": 5}); or it is a '\n + `nested object where the wire format allows only strings. ${HEADERS_REMEDY}`,\n );\n}\n\n/**\n * Recover a row's custom headers. Returns `undefined` for EXACTLY one fact —\n * the row stores no headers — which is not an error: `headers` is optional on\n * the authoring envelope, and a webhook with no custom headers is a legitimate\n * authored configuration.\n *\n * Throws {@link WebhookHeadersUnresolvableError} when a map IS stored and does\n * not come back as one. Callers must treat that as \"drop this subscription\",\n * never as \"deliver without them\" — see `AutoEnqueuer.attachCredentials` for\n * why partial delivery is the invisible failure and a stopped subscription is\n * the visible one.\n *\n * ## [#8558] Why \"did not come back\" is not spelled `undefined`\n * This is the sibling of #8542 on `webhook-secret.ts`, and the measurement that\n * produced it found the header path is WIDER than the signing path rather than\n * symmetric to it. A signing secret is an opaque scalar: any non-empty answer\n * is a usable key, so only the empty string collapses. A header map's CONTENT\n * decides, so every one of these reaches this function as a stored-but-unusable\n * value, all confirmed against a real engine:\n *\n * 1. the `sys_webhook` row is deleted between the enqueuer's cache read and\n * this dereference (`resolveSecretField` opens `if (!row) return null`);\n * 2. the column holds something that is not a `secret:` ref — reachable only\n * through a write that BYPASSES the engine (a hand-edited column, a dump\n * restored without its `sys_secret` rows, a seed script writing at driver\n * level). The engine's own write path defends both obvious routes: an\n * echoed mask is dropped and cleartext is re-encrypted;\n * 3. the ciphertext decrypts to the empty string;\n * 4. ⭐ the ciphertext decrypts to a perfectly readable string that is not a\n * flat string map — `{}`, `[]`, `{\"X-Count\": 5}`, a nested object, or any\n * typo. Reachable through the ORDINARY data API with no privileged access,\n * and it is the WIDEST road here rather than an exotic one:\n * `sys_webhook.headers_secret` is an admin-authorable field whose own\n * description instructs the author to type a JSON object into it.\n *\n * In all four the row still advertises stored headers on every read path, so\n * returning `undefined` told the caller the opposite of what the row says.\n */\nexport async function resolveWebhookHeaders(\n engine: IDataEngine,\n row: { id: string; [k: string]: unknown },\n object: string,\n): Promise<WebhookHeaders | undefined> {\n const stored = row[WEBHOOK_HEADERS_FIELD];\n // Unset / cleared. On the generic read path a set secret comes back as the\n // engine's mask (a non-empty string) and an unset one as `null`, so presence\n // is decidable here WITHOUT the value ever being readable. Everything below\n // this line therefore runs with \"headers ARE stored\" already established —\n // which is the knowledge the old `undefined` return threw away.\n if (stored == null || stored === '') return undefined;\n\n const resolver = engine as SecretResolvingEngine;\n if (typeof resolver.resolveSecretField !== 'function') {\n // An engine with no encrypted-field channel stored verbatim what the seeder\n // handed it, so the column IS the serialized map — reading it is correct,\n // not a fallback. It can still fail to parse, and that arm used to answer\n // `undefined` too; it is refused here for the same reason as everything\n // else on this seam.\n if (!isOpaqueSecretForm(stored)) {\n return requireHeaderMap(stored, row, `${object}.${WEBHOOK_HEADERS_FIELD}`);\n }\n throw new WebhookHeadersUnresolvableError(\n `Webhook \"${String(row.name ?? row.id)}\" stores encrypted custom headers, but this data engine `\n + 'does not implement resolveSecretField() — they cannot be recovered, so the subscription is '\n + 'dropped rather than delivered without the headers it was authored with (#7986).',\n );\n }\n const plain = await resolver.resolveSecretField(object, String(row.id), WEBHOOK_HEADERS_FIELD);\n if (plain == null || plain === '') {\n throw new WebhookHeadersUnresolvableError(\n `Webhook \"${String(row.name ?? row.id)}\" stores custom headers in `\n + `${object}.${WEBHOOK_HEADERS_FIELD} that resolved to nothing. A value IS stored — the read `\n + 'path returns the engine mask for it — so this is NOT a webhook authored without headers, '\n + 'and delivering it without them would silently drop whatever the author put in that map, '\n + 'including an Authorization credential, on a delivery that is otherwise correctly signed and '\n + 'therefore looks genuine to the receiver (#7986, #8558). Causes, in the order worth checking: '\n + 'the row was deleted while this refresh was reading it; the column holds something that is '\n + 'not a secret: ref (a hand-edited column, or a dump restored without its sys_secret rows); '\n + `or the stored value decrypts to an empty string. ${HEADERS_REMEDY}`,\n );\n }\n return requireHeaderMap(plain, row, `${object}.${WEBHOOK_HEADERS_FIELD}`);\n}\n\n/**\n * Decide what a RE-SEED should do with an existing row's `headers_secret`.\n *\n * Same discipline, and the same reason, as `secretPatch` in\n * `bootstrap-declared-webhooks.ts`: a `secret`-typed write always mints a fresh\n * `sys_secret` ciphertext row and the engine never deletes the superseded one,\n * so blindly restating the declared headers on every boot would leak one orphan\n * cipher row per webhook per restart.\n *\n * - declared map differs from stored ⇒ write it (an edit in code propagates);\n * - identical ⇒ omit the key entirely, leaving the existing ref untouched;\n * - declared headers removed, row still holds some ⇒ write `null` to CLEAR\n * (code remains the authority for package rows);\n * - engine cannot dereference (older engine, or the compare threw) ⇒ fall back\n * to writing the declared value. A correct request beats tidy storage.\n */\nexport async function headersPatch(\n engine: IDataEngine,\n declared: WebhookHeaders | undefined,\n row: { id: string; [k: string]: unknown },\n object: string,\n): Promise<Record<string, unknown>> {\n const hasStored = row?.[WEBHOOK_HEADERS_FIELD] != null && row[WEBHOOK_HEADERS_FIELD] !== '';\n\n if (!declared) return hasStored ? { [WEBHOOK_HEADERS_FIELD]: null } : {};\n\n const serialized = serializeHeaders(declared);\n const resolver = engine as SecretResolvingEngine;\n if (!hasStored || typeof resolver.resolveSecretField !== 'function') {\n return { [WEBHOOK_HEADERS_FIELD]: serialized };\n }\n\n try {\n const current = await resolver.resolveSecretField(object, String(row.id), WEBHOOK_HEADERS_FIELD);\n // Compared as the CANONICAL serialization on both sides, not as raw\n // strings: the stored form was produced by this same function, so key order\n // is stable, and a re-parse guards against a hand-edited value that differs\n // only in whitespace re-encrypting on every boot.\n const stored = parseStoredHeaders(current);\n return stored && serializeHeaders(stored) === serialized\n ? {}\n : { [WEBHOOK_HEADERS_FIELD]: serialized };\n } catch {\n return { [WEBHOOK_HEADERS_FIELD]: serialized };\n }\n}\n","// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\nimport type { IDataEngine, IRealtimeService, RealtimeEventPayload } from '@objectstack/spec/contracts';\nimport type { WebhookTriggerType } from '@objectstack/spec/automation';\nimport type { EnqueueHttpInput } from '@objectstack/service-messaging';\nimport {\n WEBHOOK_SECRET_FIELD,\n WEBHOOK_SECRET_REFUSAL_CODE,\n WEBHOOK_SECRET_REFUSAL_STATUS,\n onCryptoProviderChange,\n readLegacySecret,\n resolveWebhookSecret,\n} from './webhook-secret.js';\nimport {\n WEBHOOK_HEADERS_FIELD,\n readLegacyHeaders,\n resolveWebhookHeaders,\n} from './webhook-headers.js';\n\n/**\n * The authored trigger vocabulary, taken from the spec rather than restated\n * here — this file both validates authored triggers and maps events onto them,\n * so a locally-spelled union would be a second contract free to drift from the\n * one authors are validated against.\n */\ntype WebhookTrigger = WebhookTriggerType;\n\n/**\n * Enqueue callback into the shared `service-messaging` HTTP outbox (ADR-0018 M3).\n * The plugin supplies one bound to `messaging.enqueueHttp(...)`; webhooks no\n * longer own a delivery outbox/dispatcher — they share the generic substrate.\n *\n * [#8069] It MUST be `MessagingService.enqueueHttp`, not `IHttpOutbox.enqueue`.\n * The enqueuer now emits two kinds of input through this one door — an ordinary\n * delivery, and a PARKED event whose subscription lost its credentials — and\n * only the messaging seam routes the second to `recordUndeliverable()`. Wired\n * to the raw outbox instead, the parked input is refused at the delivery door\n * (correctly — the alternative is a `pending` unsigned row) and the durable\n * record is lost; {@link AutoEnqueuer} reports that at `error` rather than\n * letting it pass as an ordinary enqueue failure.\n */\nexport type HttpEnqueueFn = (input: EnqueueHttpInput) => Promise<string>;\n\n/**\n * Which encrypted credential a drop is about, and the words its report needs.\n *\n * Parameterised rather than duplicated because the two reports differ only in\n * the noun and the consequence clause — everything an `error` owes (the\n * consequence, concretely, and the fix) is identical, and a second hand-written\n * copy is how one of them drifts into being less actionable than the other.\n */\ninterface DropReason {\n /** Column the value lives in — travels in the ADR-0112 meta. */\n field: string;\n /** How the credential is named in prose. */\n noun: string;\n /** Indefinite form for \"webhook X holds …\". */\n article: string;\n /** What delivering anyway would mean — the harm being refused. */\n ratherThan: string;\n /** Issue this drop rule comes from. */\n issue: string;\n /** Issue pair for the repeat line. */\n issues: string;\n}\n\nconst SIGNING_SECRET_CREDENTIAL: DropReason = {\n field: WEBHOOK_SECRET_FIELD,\n noun: 'signing secret',\n article: 'an encrypted signing secret',\n ratherThan: 'delivered unsigned',\n issue: '#7799',\n issues: '#7799/#8022',\n};\n\nconst CUSTOM_HEADERS_CREDENTIAL: DropReason = {\n field: WEBHOOK_HEADERS_FIELD,\n noun: 'custom header map',\n article: 'encrypted custom headers',\n ratherThan: 'delivered without the headers it was authored with',\n issue: '#7986',\n issues: '#7986/#8022',\n};\n\n/**\n * Optional logger interface (subset of console / kernel logger).\n */\ninterface OptionalLogger {\n info?(msg: string, meta?: unknown): void;\n /**\n * The GUARANTEED fallback channel (#9754). `error` stays optional — hosts do\n * inject reduced sinks — so `warn` is where a durability report lands when\n * `error` is absent, and a fallback that may itself be missing is not a\n * fallback. Call sites keep the `logger?.warn?.(…)` spelling as the backstop\n * for hosts the TYPE cannot reach; `SweepLogger` in plugin-email's\n * `outbox-sweep.ts` carries the full reasoning and the measurement.\n */\n warn(msg: string, meta?: unknown): void;\n debug?(msg: string, meta?: unknown): void;\n error?(msg: string, err?: unknown, meta?: unknown): void;\n}\n\n/**\n * Per-row subscription cached in memory. Mirrors a subset of the\n * `sys_webhook` object — only what the auto-enqueuer needs to match an\n * event and build an `EnqueueInput`.\n */\ninterface CachedSubscription {\n id: string;\n name: string;\n objectName: string | undefined; // empty = matches all objects\n triggers: Set<WebhookTrigger>;\n url: string;\n method?: string;\n headers?: Record<string, string>;\n secret?: string;\n timeoutMs?: number;\n /**\n * [#8069] Set when a credential this subscription needs could not be\n * recovered. The subscription stays CACHED — that is the change — but every\n * event it matches is written to `sys_http_delivery` as a parked `dead` row\n * carrying this text, instead of being discarded with nothing to find.\n *\n * Before this, `attachCredentials` returning false removed the row from the\n * cache entirely, so matching events found no subscription and vanished:\n * fail-closed and correct, but leaving an operator with a log line (#8043)\n * and no durable trace. A parked subscription is still fail-closed —\n * {@link secret} and {@link headers} stay unset, so nothing can be sent —\n * it is merely no longer silent.\n */\n parkedReason?: string;\n}\n\nexport interface AutoEnqueuerOptions {\n /**\n * Object name holding webhook subscriptions. Defaults to `sys_webhook`,\n * the platform-objects schema authored in apps.\n */\n subscriptionsObject?: string;\n\n /**\n * Periodic full-cache refresh interval (ms). Belt-and-braces in case\n * the subscription-change event is missed. Default 60s.\n */\n refreshIntervalMs?: number;\n\n logger?: OptionalLogger;\n}\n\n/**\n * Bridge between `IRealtimeService` (`data.record.*` events emitted by\n * the engine) and `IWebhookOutbox` (durable delivery rows the dispatcher\n * picks up).\n *\n * ## Why a separate class\n * Keeps `WebhookOutboxPlugin` lean: the plugin wires services, this\n * class owns the runtime fan-out logic + subscription cache.\n *\n * ## Hot path\n * Every `engine.insert/update/delete` fires a `data.record.*` event.\n * The handler:\n * 1. Looks up matching subscriptions in an in-memory `Map<object, sub[]>`\n * — O(1) per event, no DB hit on the write path.\n * 2. Calls `outbox.enqueue()` fire-and-forget for each match. The\n * enqueue itself is a single INSERT, which runs *after* the user's\n * request has already returned.\n *\n * Net cost on the write path: one synchronous Map lookup (~microseconds).\n *\n * ## Cache freshness\n * The cache is rebuilt:\n * 1. Once on `start()`.\n * 2. On every `data.record.{created,updated,deleted}` event whose\n * object is `sys_webhook` (self-healing — when a user toggles a\n * webhook, the handler refreshes the cache before returning).\n * 3. Periodically (default 60s) as belt-and-braces.\n *\n * For multi-node clusters this is *eventually consistent* — node B may\n * not see node A's edit for up to one cycle. That's acceptable for\n * webhook configuration changes (humans don't expect millisecond\n * propagation) and matches Hasura's behaviour.\n *\n * ## Determinism\n * `eventId` is computed from `${object}:${recordId}:${type}:${timestamp}`\n * so the outbox dedup index catches duplicates that could arise from\n * upstream replay or buggy producers — and is stable across nodes.\n *\n * An aggregate `data.records.*` event (#4639) has no record to key on, so it\n * dedups on the producer's event uuid instead: two predicate sweeps in the\n * same millisecond are genuinely different events and must not collapse into\n * one delivery, which a timestamp-based key would do.\n */\nexport class AutoEnqueuer {\n private readonly subscriptions = new Map<string, CachedSubscription[]>();\n private readonly subscriptionsObject: string;\n private readonly refreshIntervalMs: number;\n /**\n * Optional, and deliberately NOT defaulted to `{}` (#10556).\n *\n * `OptionalLogger` guarantees a `warn` channel under #9754, so `{}` stopped being a\n * legal value of the type — which is the gate working: an empty object is a\n * sink that declares it can report and then discards everything. The repair\n * is to say what is TRUE — there may be no logger at all — rather than to\n * mint a sink that lies. Runtime behaviour is unchanged in both directions:\n * absent logger and `{}` both printed nothing before, and print nothing now.\n *\n * ⛔ What this deliberately does NOT decide: whether an absent host sink should\n * instead default to a `console`-backed one. That is the open design call the\n * #9754 ledger records against `plugin-security`'s `= {}` field, and it is a\n * maintainer decision — not something to settle here to make a checker green.\n */\n private readonly logger?: OptionalLogger;\n private subId: string | undefined;\n private subIdSelfHeal: string | undefined;\n private refreshTimer: ReturnType<typeof setInterval> | undefined;\n private running = false;\n private refreshing: Promise<void> | undefined;\n /** [#8022] Detach for the engine's crypto-registration listener. */\n private unbindCryptoListener: (() => void) | undefined;\n /**\n * [#8022] Webhook ids currently dropped for an unresolvable credential —\n * the signing key (#7799) or, since #7986, the custom header map. ONE set\n * for both on purpose: a subscription is either armed or dropped, so a\n * per-credential ledger would let a row already silenced for its key report\n * loudly again for its headers on the very next refresh.\n * Held so the loud first report is said ONCE per outage (AGENTS.md\n * \"Degradation log levels\": *say it once, at the first degradation*) and\n * again if the same webhook breaks after recovering — not once per row per\n * refresh, forever.\n */\n private readonly droppedForSecret = new Set<string>();\n\n constructor(\n private readonly engine: IDataEngine,\n private readonly realtime: IRealtimeService,\n private readonly enqueue: HttpEnqueueFn,\n opts: AutoEnqueuerOptions = {},\n ) {\n this.subscriptionsObject = opts.subscriptionsObject ?? 'sys_webhook';\n this.refreshIntervalMs = opts.refreshIntervalMs ?? 60_000;\n this.logger = opts.logger;\n }\n\n /**\n * Load the subscription cache and start listening for events.\n */\n async start(): Promise<void> {\n if (this.running) return;\n this.running = true;\n\n // [#8022] Bound BEFORE the first build, not after: on every host the\n // composition root wires the CryptoProvider after `runtime.start()`\n // returns, i.e. after the `kernel:ready` handler that runs this method\n // — so the registration we need to hear about can land at any point\n // from here on, including while the await below is still in flight.\n // Subscribing first makes that unmissable; subscribing after the\n // refresh would reintroduce the same race in miniature.\n this.unbindCryptoListener = onCryptoProviderChange(this.engine, () =>\n this.rearmAfterCryptoRegistered(),\n );\n\n await this.refresh();\n\n // Main subscription: every data event → match → enqueue.\n this.subId = await this.realtime.subscribe(\n 'webhook-auto-enqueuer',\n (event) => this.handleEvent(event),\n );\n\n // Self-healing: any change to sys_webhook refreshes the cache.\n this.subIdSelfHeal = await this.realtime.subscribe(\n 'webhook-auto-enqueuer-self-heal',\n (event) => this.handleSelfHealEvent(event),\n { object: this.subscriptionsObject },\n );\n\n if (this.refreshIntervalMs > 0) {\n this.refreshTimer = setInterval(() => {\n this.refresh().catch((err) =>\n this.logger?.warn?.('[webhook-auto-enqueuer] periodic refresh failed', err),\n );\n }, this.refreshIntervalMs);\n // Don't keep the process alive solely for this timer.\n this.refreshTimer.unref?.();\n }\n }\n\n async stop(): Promise<void> {\n if (!this.running) return;\n this.running = false;\n if (this.subId) await this.realtime.unsubscribe(this.subId);\n if (this.subIdSelfHeal) await this.realtime.unsubscribe(this.subIdSelfHeal);\n if (this.refreshTimer) clearInterval(this.refreshTimer);\n this.unbindCryptoListener?.();\n this.subId = undefined;\n this.subIdSelfHeal = undefined;\n this.refreshTimer = undefined;\n this.unbindCryptoListener = undefined;\n }\n\n /**\n * [#8022] The engine just gained a CryptoProvider — rebuild the cache so\n * subscriptions dropped for an unresolvable signing key re-arm now, instead\n * of at the next periodic refresh up to {@link refreshIntervalMs} away.\n *\n * It deliberately does NOT call {@link refresh} directly. `refresh()`\n * coalesces onto an in-flight build, and the build most likely to be in\n * flight right now is the one from `start()` — the very build whose rows\n * were read while there was no provider. Joining it would return \"refreshed\"\n * having re-armed nothing, which is this issue with an extra step. So: let\n * whatever is running finish, then read again.\n */\n private rearmAfterCryptoRegistered(): void {\n const inFlight = this.refreshing ?? Promise.resolve();\n void inFlight\n // A failed in-flight refresh already logged; it must not stop the\n // re-arm, which is the whole point of this callback.\n .catch(() => undefined)\n .then(() => (this.running ? this.refresh() : undefined))\n .catch((err) =>\n this.logger?.warn?.(\n '[webhook-auto-enqueuer] re-arm after CryptoProvider registration failed',\n err,\n ),\n );\n }\n\n /**\n * Force-refresh the subscription cache from storage. Concurrent\n * callers share a single in-flight refresh.\n */\n async refresh(): Promise<void> {\n if (this.refreshing) return this.refreshing;\n this.refreshing = this.doRefresh().finally(() => {\n this.refreshing = undefined;\n });\n return this.refreshing;\n }\n\n private async doRefresh(): Promise<void> {\n let rows: any[];\n try {\n rows = await this.engine.find(this.subscriptionsObject, {\n where: { active: true },\n });\n } catch (err) {\n this.logger?.warn?.(\n `[webhook-auto-enqueuer] failed to load ${this.subscriptionsObject}`,\n err,\n );\n return;\n }\n\n const next = new Map<string, CachedSubscription[]>();\n for (const row of rows) {\n const sub = this.parseRow(row);\n if (!sub) continue;\n // [#7799, #7986] Neither credential is in the row we just read —\n // the signing key and the custom header map both live encrypted in\n // `sys_secret`, and this read path returns only a mask. Dereference\n // them here, on the 60s refresh, rather than per event: the cache\n // already holds the plaintext in memory (it always did), so this\n // changes where the values come FROM, not how long they are held. A\n // row whose credentials cannot be recovered is PARKED — cached with\n // `parkedReason` set and no credentials, so its events are recorded\n // as undeliverable instead of silently discarded (#8069). See\n // `attachCredentials`.\n await this.attachCredentials(sub, row);\n // Empty objectName == \"any object\" → indexed under '*'.\n const key = sub.objectName ?? '*';\n const arr = next.get(key) ?? [];\n arr.push(sub);\n next.set(key, arr);\n }\n\n this.subscriptions.clear();\n for (const [k, v] of next) this.subscriptions.set(k, v);\n\n // [#8022] Forget rows this refresh no longer sees — deleted, or\n // deactivated. Otherwise the set grows for the life of the process, and\n // a webhook turned off while broken and later turned back on still\n // broken would have its first report suppressed as a repeat.\n if (this.droppedForSecret.size > 0) {\n const live = new Set(rows.map((r) => String(r?.id)));\n for (const id of this.droppedForSecret) {\n if (!live.has(id)) this.droppedForSecret.delete(id);\n }\n }\n\n this.logger?.debug?.('[webhook-auto-enqueuer] cache refreshed', {\n objects: this.subscriptions.size,\n rows: rows.length,\n });\n }\n\n /**\n * [#7799, #7986] Resolve BOTH encrypted credentials for one cached\n * subscription. Returns `false` when the subscription must be dropped from\n * the cache.\n *\n * The two halves are deliberately resolved on the SAME build rather than on\n * separate schedules. #8022's re-arm rebuilds the whole cache when a\n * CryptoProvider registers; a header map recovered on any other cadence\n * would let the enqueuer re-arm into a delivery that is correctly signed and\n * silently missing its `Authorization`, which is the failure mode of both\n * cards at once.\n *\n * The drop ledger is cleared only when BOTH succeed — otherwise a row whose\n * secret resolves and whose headers do not would clear its own \"already\n * reported\" mark on every refresh and shout the same `error` every 60s,\n * which is precisely the unreadable-error-channel failure #8022's say-once\n * rule exists to prevent.\n *\n * Cost: up to two point reads + two decrypts per credential-bearing row per\n * refresh (default 60s), off the write path entirely. Deliberately NOT\n * memoised across refreshes — the only cheap cache key would be\n * `updated_at`, which nothing guarantees is stamped when a credential is\n * rotated, and a stale key signs every delivery with a signature the\n * receiver rejects.\n */\n private async attachCredentials(sub: CachedSubscription, row: any): Promise<boolean> {\n if (!(await this.attachSecret(sub, row))) return false;\n if (!(await this.attachHeaders(sub, row))) return false;\n // Recovered — a later break is a new outage and gets said loudly again\n // rather than being swallowed as a repeat.\n this.droppedForSecret.delete(sub.id);\n return true;\n }\n\n /**\n * [#8069] Mark a subscription parked and strip anything sendable off it.\n *\n * Called from the two `attachX` failure paths, which each already reported\n * the drop at `error` (say-once, #8022). The credentials are cleared rather\n * than merely \"not set\": `attachSecret` can succeed and `attachHeaders`\n * fail, and a parked row must not carry the header map — that map is the\n * ordinary place an `Authorization: Bearer …` goes (#7986), and copying it\n * onto a row that will sit in `sys_http_delivery` for the full 30d\n * retention window without ever being sent is a credential copy bought for\n * nothing.\n */\n /**\n * [#8069] Report a failed outbox write off the hot path, at the level the\n * loss actually deserves.\n *\n * AGENTS.md decides that with one question — *does the system still look\n * normal from the outside while something it claims is persisted has not\n * landed?* For a PARKED subscription the answer is unambiguously yes, and\n * worse than for an ordinary enqueue failure: the durable record is the\n * only trace this event ever existed, so losing the write puts us back\n * exactly where this issue started, silently. So `error` there, and the\n * pre-existing `warn` for an ordinary enqueue, where the delivery itself is\n * the thing that did not happen and the subscription is otherwise healthy.\n *\n * The realistic cause of the parked branch is a host that wired\n * {@link HttpEnqueueFn} straight to `IHttpOutbox.enqueue` instead of\n * `MessagingService.enqueueHttp`: only the messaging seam routes a parked\n * input to `recordUndeliverable()`, and the raw delivery door refuses the\n * discriminator rather than minting a `pending` unsigned row from it. The\n * message names that, because it is not guessable from \"enqueue failed\".\n */\n private reportWriteFailure(\n sub: CachedSubscription,\n eventId: string,\n err: unknown,\n verb: string,\n ): void {\n const meta = { webhook: sub.name, eventId, err: (err as Error)?.message ?? err };\n if (!sub.parkedReason) {\n this.logger?.warn?.(`[webhook-auto-enqueuer] ${verb} failed`, meta);\n return;\n }\n const message =\n `[webhook-auto-enqueuer] could not record the undeliverable event for webhook `\n + `'${sub.name}' — the subscription is parked for an unresolvable credential, and this `\n + `event is now DISCARDED WITH NO TRACE in sys_http_delivery, which is the durability `\n + `gap #8069 closes. Most likely cause: the enqueue callback was wired directly to `\n + `IHttpOutbox.enqueue instead of MessagingService.enqueueHttp — only the messaging seam `\n + `routes a parked event to recordUndeliverable(), and the delivery door refuses it `\n + `rather than minting a pending row that would be sent UNSIGNED.`;\n if (typeof this.logger?.error === 'function') {\n this.logger?.error(message, err, meta);\n } else {\n this.logger?.warn?.(message, meta);\n }\n }\n\n private park(sub: CachedSubscription, err: unknown, credential: DropReason): void {\n sub.secret = undefined;\n sub.headers = undefined;\n sub.parkedReason =\n `[${WEBHOOK_SECRET_REFUSAL_CODE}/${WEBHOOK_SECRET_REFUSAL_STATUS}] webhook '${sub.name}' `\n + `holds ${credential.article} that could not be decrypted, so this event was NOT `\n + `delivered — recording it here rather than ${credential.ratherThan} (${credential.issue}, `\n + `#8069). This row was never sent and cannot be redelivered: it carries no HMAC signature, `\n + `because the ${credential.noun} that would have produced one is exactly what is missing. `\n + `Fix: register a CryptoProvider (engine.setCryptoProvider — LocalCryptoProvider in dev, `\n + `KMS/Vault in production) with the same key the ${credential.noun} was written under, and `\n + `make sure the sys_secret row is reachable; the subscription re-arms on registration `\n + `(#8022) and at the next periodic refresh, and later events are delivered normally. `\n + `Cause: ${(err as Error)?.message ?? String(err)}`;\n }\n\n /**\n * [#7799] Resolve `sub.secret`. Returns `false` when the subscription must\n * be dropped.\n *\n * Three sources, in order:\n * 1. `sys_webhook.signing_secret` — the encrypted column. The read path\n * returns a mask, so presence is decidable here but the value is not;\n * `resolveWebhookSecret` dereferences it server-side.\n * 2. `definition_json.secret` — a row not yet swept by\n * `migrateLegacyWebhookSecrets` (or hand-edited back in). Still honoured\n * so an un-migrated deployment keeps signing, and warned about once per\n * refresh so the exposure is visible rather than silently permanent.\n * 3. Neither — an unsigned webhook, which is a legitimate authored choice\n * (`secret` is optional on the envelope).\n *\n * A stored-but-unresolvable key DROPS the subscription instead of\n * delivering unsigned. The signature is the receiver's only proof of\n * origin (#7722, #7799): a webhook that stops arriving is visible and gets\n * investigated, while one that keeps arriving unsigned is invisible and\n * teaches the receiver to accept unauthenticated traffic.\n *\n * [#8542] Case 3 means what it says only because the seam was fixed to say\n * it. `resolveWebhookSecret` used to answer `undefined` for BOTH \"no key is\n * stored\" and \"a key is stored and did not come back\", so this method read\n * the second as the third and armed the subscription — the invariant above\n * failing OPEN, silently, on the producer path. Nothing here changed: the\n * seam now raises for that case, so it lands in the `catch` below exactly\n * the way a throwing resolver already did, and the drop, the say-once\n * `error` and the #8069 park all apply to it unchanged.\n */\n private async attachSecret(sub: CachedSubscription, row: any): Promise<boolean> {\n try {\n const stored = await resolveWebhookSecret(this.engine, row, this.subscriptionsObject);\n if (stored) {\n sub.secret = stored;\n return true;\n }\n } catch (err) {\n this.reportDrop(sub, err, SIGNING_SECRET_CREDENTIAL);\n this.park(sub, err, SIGNING_SECRET_CREDENTIAL);\n return false;\n }\n\n const legacy = readLegacySecret(row?.definition_json);\n if (legacy) {\n this.logger?.warn?.(\n `[webhook-auto-enqueuer] webhook '${sub.name}' still carries its signing secret as ` +\n `CLEARTEXT in definition_json, readable over the data API (#7799). Signing continues ` +\n `from it; run the boot sweep (migrateLegacyWebhookSecrets) with a CryptoProvider wired ` +\n `to move it into sys_secret.`,\n { id: sub.id },\n );\n sub.secret = legacy;\n }\n return true;\n }\n\n /**\n * [#7986] Resolve `sub.headers` from the encrypted column, with the same\n * three-source shape as {@link attachSecret} and for the same reasons.\n *\n * A stored-but-unresolvable header map DROPS the subscription rather than\n * delivering without it. That is the identical trade #7799 made for the\n * signature, and it needs restating because the intuition runs the other\n * way: a missing `Authorization` looks self-announcing, since the receiver\n * answers 401 and the attempt lands in `sys_http_delivery` for anyone to\n * find. But that is only the AUTHENTICATED case. Against an endpoint that\n * does not require the header — a routing `X-Tenant-Id`, an\n * `X-Environment: staging` — the delivery SUCCEEDS while quietly deviating\n * from the configuration the author wrote, and nothing anywhere records\n * that it went out incomplete. A subscription that stops is visible; a\n * delivery that arrives subtly wrong is not.\n *\n * [#8558] And that is what this method used to do, for the same reason its\n * signing sibling did (#8542): `resolveWebhookHeaders` answered `undefined`\n * for BOTH \"no headers are stored\" and \"a map is stored and did not come\n * back as one\", so this method read the second as the first and armed the\n * subscription — the paragraph above failing OPEN. Measured, the delivery\n * then went out SUCCESSFULLY and correctly SIGNED with the whole authored\n * map missing, which is the worst available combination: the signature\n * tells the receiver the request is genuinely ours. Nothing here changed:\n * the seam now raises, so it lands in the `catch` below exactly the way a\n * throwing resolver already did, and the drop, the say-once `error` and the\n * #8069 park all apply to it unchanged.\n */\n private async attachHeaders(sub: CachedSubscription, row: any): Promise<boolean> {\n try {\n const stored = await resolveWebhookHeaders(this.engine, row, this.subscriptionsObject);\n if (stored) {\n sub.headers = stored;\n return true;\n }\n } catch (err) {\n this.reportDrop(sub, err, CUSTOM_HEADERS_CREDENTIAL);\n this.park(sub, err, CUSTOM_HEADERS_CREDENTIAL);\n return false;\n }\n\n const legacy = readLegacyHeaders(row?.definition_json);\n if (legacy) {\n this.logger?.warn?.(\n `[webhook-auto-enqueuer] webhook '${sub.name}' still carries its custom headers as ` +\n `CLEARTEXT in definition_json, readable over the data API (#7986) — that map is the ` +\n `ordinary place an Authorization header goes. Delivery continues from it; run the boot ` +\n `sweep (migrateLegacyWebhookSecrets) with a CryptoProvider wired to move them into ` +\n `sys_secret.`,\n { id: sub.id },\n );\n sub.headers = legacy;\n }\n return true;\n }\n\n /**\n * [#8022] Report a subscription dropped for an unresolvable signing key.\n *\n * ## Why `error`, and why only the first time\n * AGENTS.md decides the level with one question: *after the degradation,\n * does the system still look normal from the outside while something the\n * system claims is happening is not?* Here the answer is yes, and it is the\n * whole defect — `GET /api/v1/data/sys_webhook` keeps reading\n * `active: true`, Setup keeps showing the webhook armed, and every matching\n * record change is discarded with no delivery and no `sys_http_delivery`\n * row to find afterwards. That is a durability degradation wearing a\n * functional degradation's clothes, so it owes the two things an `error`\n * owes: the consequence, concretely, and the fix.\n *\n * Said ONCE per outage per webhook, per the same section. The cache is\n * rebuilt every {@link refreshIntervalMs}; an unfixed misconfiguration would\n * otherwise print this line every 60s forever, which is how an `error`\n * channel becomes unreadable — the failure mode that made the founding\n * incident's `warn` invisible. Repeats drop to `debug`; a recovery clears\n * the id, so a re-break is loud again.\n *\n * ADR-0112: `code` + `status` travel in the meta so a consumer branches on\n * the pair, not on message text. Same pair the seeder's refusal carries for\n * the same underlying cause.\n */\n private reportDrop(\n sub: CachedSubscription,\n err: unknown,\n credential: DropReason = SIGNING_SECRET_CREDENTIAL,\n ): void {\n const meta = {\n id: sub.id,\n webhook: sub.name,\n field: credential.field,\n code: WEBHOOK_SECRET_REFUSAL_CODE,\n status: WEBHOOK_SECRET_REFUSAL_STATUS,\n err: (err as Error)?.message ?? err,\n };\n if (this.droppedForSecret.has(sub.id)) {\n this.logger?.debug?.(\n `[webhook-auto-enqueuer] webhook '${sub.name}' is still dropped for an unresolvable ` +\n `${credential.noun} (${credential.issues})`,\n meta,\n );\n return;\n }\n this.droppedForSecret.add(sub.id);\n // [#8069] The consequence clause used to end \"…with NO delivery and NO\n // sys_http_delivery row\". The second half is no longer true — that is\n // precisely what this card changed — and an `error` that misdescribes\n // the consequence sends an operator looking in the wrong place, which\n // is worse than the old accurate-but-bleaker line. It now names where\n // the evidence IS.\n const message =\n `[webhook-auto-enqueuer] webhook '${sub.name}' holds ${credential.article} that ` +\n `could not be decrypted — the subscription is PARKED rather than ${credential.ratherThan} ` +\n `(${credential.issue}), so every matching record change is discarded with NO delivery, ` +\n 'while the row keeps reading active:true in Setup. Each discarded event IS recorded in ' +\n 'sys_http_delivery as a dead row with 0 attempts carrying this cause (#8069) — look there ' +\n 'for the backlog; those rows can never be sent or redelivered, because a parked row has no ' +\n 'HMAC signature. Fix: register a ' +\n 'CryptoProvider (engine.setCryptoProvider — LocalCryptoProvider in dev, KMS/Vault in ' +\n `production) with the same key the ${credential.noun} was written under, and make sure the ` +\n 'sys_secret row is reachable; the subscription re-arms on registration (#8022) and at the ' +\n 'next periodic refresh.';\n // The logger surface is a subset of console/kernel logger — `error` is\n // optional on it, so fall back rather than silently losing the report\n // on a logger that only implements `warn`.\n if (typeof this.logger?.error === 'function') {\n this.logger?.error(message, err, meta);\n } else {\n this.logger?.warn?.(message, meta);\n }\n }\n\n private parseRow(row: any): CachedSubscription | null {\n if (!row?.id || !row?.url) return null;\n // `triggers` is now authored as a multi-select (stored as an array), but\n // legacy rows stored a comma-separated string (and some drivers hand a\n // JSON-encoded array back as a string). Accept all three shapes so a\n // schema change never silently drops a subscription's events.\n const rawTriggers = row.triggers;\n let triggerList: string[];\n if (Array.isArray(rawTriggers)) {\n triggerList = rawTriggers.map((t) => String(t));\n } else {\n const s = String(rawTriggers ?? '').trim();\n if (s.startsWith('[')) {\n try {\n const parsed = JSON.parse(s);\n triggerList = Array.isArray(parsed) ? parsed.map((t) => String(t)) : [s];\n } catch {\n triggerList = s.split(',');\n }\n } else {\n triggerList = s.split(',');\n }\n }\n const normalized = triggerList.map((t) => t.trim().toLowerCase()).filter(Boolean);\n // [#3196] Drop (and warn about) any trigger the enqueuer can't map to an\n // emitted record event — e.g. a legacy `sys_webhook` row authored with\n // the now-removed `undelete`/`api` values, which would otherwise sit in\n // the cache matching nothing. A loud drift-guard so a dead trigger can't\n // silently no-op again.\n const unknown = normalized.filter((t) => !DISPATCHABLE_WEBHOOK_TRIGGERS.has(t));\n if (unknown.length > 0) {\n this.logger?.warn?.(\n `[webhook-auto-enqueuer] webhook '${(row.name as string) ?? row.id}' declares trigger(s) the engine never emits: ` +\n `${unknown.join(', ')} — ignored. Dispatchable triggers: ` +\n `${[...DISPATCHABLE_WEBHOOK_TRIGGERS].join(', ')}.`,\n { id: row.id, unknown },\n );\n }\n const triggers = new Set(\n normalized.filter((t) => DISPATCHABLE_WEBHOOK_TRIGGERS.has(t)) as WebhookTrigger[],\n );\n if (triggers.size === 0) {\n // [ADR-0078 Phase 4] No dispatchable triggers — the webhook can\n // never fire on ANY path, so say so instead of skipping silently.\n // This comment used to read \"(or a manual-only webhook with\n // none)\", but that mode does not exist: the `api` trigger was\n // REMOVED (#3196, `webhook.zod.ts`) precisely because there is no\n // manual fire path — the only webhook HTTP surface re-queues\n // already-failed deliveries. So a zero-trigger row is not an off\n // switch (that is `active`), it is a dead subscription that looks\n // armed in Setup. Same rule id as the author-time gate\n // (`webhook/without-triggers`) so the boot log greps into the\n // same docs. Only active rows reach parseRow, so a deliberately\n // disabled webhook stays warning-free.\n this.logger?.warn?.(\n `[webhook-auto-enqueuer] webhook '${(row.name as string) ?? row.id}' has no dispatchable ` +\n `triggers — it will NEVER fire (rule webhook/without-triggers): there is no manual fire ` +\n `path (#3196), so this row is dead while looking armed in Setup. Declare ` +\n `one of: ${[...DISPATCHABLE_WEBHOOK_TRIGGERS].join(', ')}, or set it inactive if it ` +\n `should be off.`,\n { id: row.id },\n );\n return null;\n }\n\n // The \"definition_json\" field carries advanced config (timeout);\n // attempt a best-effort parse. Fall back to top-level fields where\n // present. It no longer carries either credential — the signing secret\n // (#7799) and the custom headers (#7986) are both sourced from their\n // encrypted columns by `attachCredentials`.\n let defn: Record<string, any> = {};\n if (typeof row.definition_json === 'string' && row.definition_json.length > 0) {\n try {\n defn = JSON.parse(row.definition_json) ?? {};\n } catch {\n defn = {};\n }\n }\n\n return {\n id: row.id as string,\n name: (row.name as string) ?? row.id,\n objectName: row.object_name ? String(row.object_name) : undefined,\n triggers,\n url: String(row.url),\n // Method is authored via a select whose option values are lowercased\n // (get/post/…); upper-case here so delivery uses a canonical HTTP\n // method regardless of whether the row was authored before or after\n // the select change (legacy rows stored 'POST').\n method: String(row.method ?? defn.method ?? 'POST').toUpperCase(),\n // `headers` and `secret` are both filled by attachCredentials()\n // from their encrypted columns, NOT read off the row — see #7799\n // (secret) and #7986 (headers).\n timeoutMs: defn.timeoutMs,\n };\n }\n\n /**\n * Handler for the firehose subscription.\n *\n * NOTE: we intentionally `void` the inner enqueue() so the realtime\n * publisher (and therefore the user's request) is never blocked on\n * webhook persistence.\n */\n private handleEvent(event: RealtimeEventPayload): void {\n if (!event.object) return;\n if (event.object === this.subscriptionsObject) return; // self-heal handles its own\n\n // [#4639] A predicate write publishes the aggregate `data.records.*`\n // instead, which has no record to describe — separate path, separate\n // trigger, separate delivery shape.\n if (event.type?.startsWith('data.records.')) {\n this.handleBulkEvent(event);\n return;\n }\n if (!event.type?.startsWith('data.record.')) return;\n\n const action = event.type.slice('data.record.'.length) as\n | 'created' | 'updated' | 'deleted' | string;\n const trigger = mapActionToTrigger(action);\n if (!trigger) return;\n\n const subs = [\n ...(this.subscriptions.get(event.object) ?? []),\n ...(this.subscriptions.get('*') ?? []),\n ];\n if (subs.length === 0) return;\n\n // [#4626] The envelope's `payload` IS the spec's `DataEvent`\n // (`@objectstack/spec/api`): `recordId` is a REQUIRED top-level string\n // the ObjectQL engine validates before publishing. Read it directly.\n // The old `recordId ?? id ?? after?.id ?? before?.id ?? 'unknown'`\n // chain was consumer-side tolerance for a producer that never filled\n // the contract (AGENTS.md PD #12) — and its `'unknown'` fallback\n // silently turned an unnameable record into a delivered webhook. An\n // off-contract event is now DROPPED loudly: the producer is broken and\n // gets fixed there.\n const payload = event.payload ?? {};\n const recordId = (payload as { recordId?: unknown }).recordId;\n if (typeof recordId !== 'string' || recordId === '') {\n this.logger?.warn?.(\n '[webhook-auto-enqueuer] dropping off-contract data event: payload is not a DataEvent ' +\n '(no top-level string `recordId`) — fix the producer',\n { type: event.type, object: event.object },\n );\n return;\n }\n\n // Deterministic eventId — same input on any node → same id.\n // Includes timestamp so two distinct updates to the same record\n // don't accidentally dedup.\n const eventId = `${event.object}:${recordId}:${action}:${event.timestamp}`;\n\n for (const sub of subs) {\n if (!sub.triggers.has(trigger)) continue;\n\n // Fire-and-forget — never await on the hot path. Map the webhook\n // delivery onto the generic HTTP-outbox shape (ADR-0018 M3):\n // - source 'webhook' + dedupKey '<webhookId>:<eventId>' preserves\n // the old (event_id, webhook_id) at-most-once enqueue;\n // - refId = webhookId keeps per-webhook partition affinity / ordering;\n // - label = event type → X-Objectstack-Event header.\n void this.enqueue({\n source: 'webhook',\n refId: sub.id,\n dedupKey: `${sub.id}:${eventId}`,\n label: event.type,\n url: sub.url,\n method: sub.method,\n headers: sub.headers,\n signingSecret: sub.secret,\n // [#8069] Set only for a PARKED subscription, and then this is\n // not an enqueue at all: the messaging seam routes it to\n // `recordUndeliverable()`, which writes a terminal `dead` row\n // with this reason and no signature. Undefined for every healthy\n // subscription, so the delivery path is byte-identical to before.\n undeliverableReason: sub.parkedReason,\n timeoutMs: sub.timeoutMs,\n // [#3946] Envelope keys are written LAST so the event payload\n // cannot rewrite them. Behaviour-neutral for the engine's own\n // publishers — since #4626 a `data.record.*` payload is a\n // `DataEvent` (`id`, `type`, `object`, `recordId`, `changes?`,\n // `after?`, `userId?`, `timestamp`), whose `object` /\n // `recordId` / `timestamp` carry the SAME values written here\n // and whose record fields stay nested under `after`. It is the\n // shape that was wrong: a publisher that flattened record\n // fields into the payload would have silently rewritten the\n // `object` / `action` / `timestamp` a subscriber receives.\n payload: {\n ...payload,\n object: event.object,\n recordId,\n action,\n timestamp: event.timestamp,\n },\n }).catch((err) => this.reportWriteFailure(sub, eventId, err, 'enqueue'));\n }\n }\n\n /**\n * Handler for aggregate `data.records.*` events — a predicate write\n * (`multi: true`) that the driver reports only as an affected-row count\n * (#4639).\n *\n * Deliberately NOT folded into {@link handleEvent}'s per-record path. The\n * delivered body has no `recordId` and no record fields, so a subscriber\n * to `update` that started receiving these would get a payload missing\n * everything it reads — which is how the pre-#4626 `recordId: ''`\n * fabrication broke consumers, just arriving from the other side. A\n * webhook opts in with `bulk_update` / `bulk_delete`.\n */\n private handleBulkEvent(event: RealtimeEventPayload): void {\n const action = event.type.slice('data.records.'.length);\n const trigger = mapBulkActionToTrigger(action);\n if (!trigger) return;\n\n const subs = [\n ...(this.subscriptions.get(event.object!) ?? []),\n ...(this.subscriptions.get('*') ?? []),\n ];\n if (subs.length === 0) return;\n\n // Same contract discipline as the per-record path: the payload IS the\n // spec's `BulkDataEvent`, whose `matched` the engine validates before\n // publishing. An off-contract event is dropped loudly rather than\n // delivered with a guessed count — `matched` is the entire substance\n // of a bulk delivery, so a wrong one is worse than none.\n const payload = event.payload ?? {};\n const matched = (payload as { matched?: unknown }).matched;\n if (typeof matched !== 'number' || !Number.isInteger(matched) || matched < 0) {\n this.logger?.warn?.(\n '[webhook-auto-enqueuer] dropping off-contract bulk data event: payload is not a ' +\n 'BulkDataEvent (no top-level non-negative integer `matched`) — fix the producer',\n { type: event.type, object: event.object },\n );\n return;\n }\n\n // A predicate write has no natural key to build a deterministic id\n // from — `${object}:${action}:${timestamp}` would collide between two\n // sweeps landing in the same millisecond, and silently drop the\n // second. The producer's own event uuid is generated once and travels\n // with the event, so it dedups redelivery of the SAME event without\n // ever conflating two distinct ones.\n const eventUuid = (payload as { id?: unknown }).id;\n if (typeof eventUuid !== 'string' || eventUuid === '') {\n this.logger?.warn?.(\n '[webhook-auto-enqueuer] dropping off-contract bulk data event: payload has no ' +\n 'top-level string `id` to dedup on — fix the producer',\n { type: event.type, object: event.object },\n );\n return;\n }\n const eventId = `${event.object}:${event.type}:${eventUuid}`;\n\n for (const sub of subs) {\n if (!sub.triggers.has(trigger)) continue;\n\n void this.enqueue({\n source: 'webhook',\n refId: sub.id,\n dedupKey: `${sub.id}:${eventId}`,\n label: event.type,\n url: sub.url,\n method: sub.method,\n headers: sub.headers,\n signingSecret: sub.secret,\n // [#8069] See the per-record path — parked subscriptions record\n // an undeliverable row instead of enqueuing a delivery.\n undeliverableReason: sub.parkedReason,\n timeoutMs: sub.timeoutMs,\n // [#3946] Envelope keys last so the payload cannot rewrite them.\n payload: {\n ...payload,\n object: event.object,\n matched,\n action,\n timestamp: event.timestamp,\n },\n }).catch((err) => this.reportWriteFailure(sub, eventId, err, 'bulk enqueue'));\n }\n }\n\n private handleSelfHealEvent(event: RealtimeEventPayload): void {\n if (event.object !== this.subscriptionsObject) return;\n // [#4639] A predicate write over `sys_webhook` (deactivate every\n // webhook on an object, say) changes the subscription set exactly like\n // a per-record edit does, so it must refresh the cache too — matching\n // only `data.record.` would leave the enqueuer dispatching from rows\n // the admin just turned off.\n if (!event.type?.startsWith('data.record.') && !event.type?.startsWith('data.records.')) return;\n this.refresh().catch((err) =>\n this.logger?.warn?.('[webhook-auto-enqueuer] self-heal refresh failed', err),\n );\n }\n\n /** Test / admin accessor. */\n snapshot(): ReadonlyMap<string, ReadonlyArray<CachedSubscription>> {\n return this.subscriptions;\n }\n}\n\nfunction mapActionToTrigger(\n action: string,\n): 'create' | 'update' | 'delete' | null {\n switch (action) {\n case 'created':\n return 'create';\n case 'updated':\n return 'update';\n case 'deleted':\n return 'delete';\n default:\n return null;\n }\n}\n\n/** [#4639] `data.records.{action}` → its opt-in bulk trigger. */\nfunction mapBulkActionToTrigger(action: string): 'bulk_update' | 'bulk_delete' | null {\n switch (action) {\n case 'updated':\n return 'bulk_update';\n case 'deleted':\n return 'bulk_delete';\n default:\n return null;\n }\n}\n\n/** The trigger values the enqueuer can actually map from an emitted record event. */\nconst DISPATCHABLE_WEBHOOK_TRIGGERS: ReadonlySet<string> = new Set([\n 'create',\n 'update',\n 'delete',\n 'bulk_update',\n 'bulk_delete',\n]);\n","// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * bootstrapDeclaredWebhooks — materialize stack/connector-declared `webhooks`\n * into `sys_webhook` rows so the dispatcher can actually see them (closes #3461).\n *\n * ## The disconnect this closes\n * The spec authoring surface (`WebhookSchema` — `defineStack({ webhooks })`,\n * `@objectstack/spec/automation/webhook`) declares `object` / `isActive`, and\n * is generically decomposed into the ObjectQL registry at boot as metadata\n * type `webhook`. But the runtime dispatcher ({@link AutoEnqueuer}) reads\n * `sys_webhook` DATA rows (`object_name` / `active`), which until now were only\n * ever written by hand through the object's CRUD UI. Nothing bridged the two —\n * so authoring `webhooks:` on a stack produced metadata artifacts that never\n * became dispatchable rows (a silent no-op; ADR-0078). This seeder is that\n * missing ingestion path.\n *\n * ## Shape translation (authoring → runtime row)\n * The spec shape diverges from the runtime column names; we map only at this\n * boundary and stash the validated envelope in `definition_json` (whence the\n * enqueuer reads headers / timeout):\n * - `object` → `object_name`\n * - `isActive` → `active`\n * - `triggers` / `url` / `method` / `label` / `description` → same-named columns\n * - `secret` → `signing_secret` (ENCRYPTED — see below)\n * - `headers` → `headers_secret` (ENCRYPTED — #7986, same channel)\n * - the rest of the parsed {@link Webhook} → `definition_json` (JSON string)\n *\n * ## Neither credential goes in `definition_json` (#7799, #7986)\n * It used to: `definition_json: JSON.stringify(wh)` serialized the whole\n * envelope, key included, into an ordinary textarea on an admin-authorable\n * object — so `GET /api/v1/data/sys_webhook` returned the receiver's only proof\n * of origin to anyone who could read the object. The authored key now goes to\n * `sys_webhook.signing_secret`, a `type: 'secret'` column the engine encrypts\n * into `sys_secret` and masks on read; `definition_json` carries the same\n * envelope MINUS `secret`. Nothing about the authoring surface changes —\n * `webhook.zod.ts` still declares `secret` and authors still write it.\n *\n * Fail-closed: with no CryptoProvider registered the engine REFUSES the write\n * (it will not store cleartext), so a secret-bearing webhook is skipped with an\n * actionable log line instead of being seeded with an exposed key.\n *\n * Each item is validated through `WebhookSchema.parse()` first — this gives the\n * spec schema a real consumer (defaults for `method`/`isActive`/`timeoutMs` get\n * applied) and rejects malformed authoring with a warning instead of crashing\n * boot.\n *\n * ## Seed-not-clobber (mirrors sys_sharing_rule, #2909)\n * `sys_webhook` is admin-editable (`managedBy: 'config'`). Declared webhooks\n * ship with the app/package, so they seed with `managed_by: 'package'`\n * provenance and re-seed on every boot — but a row an admin has created\n * (`managed_by: 'admin'`) or edited (`customized: true`, stamped by\n * {@link bindWebhookProvenanceStamp}) is never overwritten. Most importantly,\n * an admin's `active: false` on a noisy webhook survives redeploys.\n *\n * MUST run before {@link AutoEnqueuer.start} so the enqueuer's first cache\n * refresh already sees the declared rows.\n */\n\nimport type { IDataEngine } from '@objectstack/spec/contracts';\nimport { WebhookSchema, type Webhook } from '@objectstack/spec/automation';\nimport {\n WEBHOOK_SECRET_FIELD,\n WEBHOOK_SECRET_REFUSAL_CODE,\n WEBHOOK_SECRET_REFUSAL_STATUS,\n canResolveSecrets,\n isSecretProtectionFailure,\n splitWebhookSecret,\n} from './webhook-secret.js';\nimport {\n WEBHOOK_HEADERS_FIELD,\n headersPatch,\n serializeHeaders,\n splitWebhookHeaders,\n} from './webhook-headers.js';\n\n/** System write context — the boot seeder is not an admin authoring action. */\nconst SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const;\n\ninterface Logger {\n info?: (msg: string, meta?: unknown) => void;\n warn?: (msg: string, meta?: unknown) => void;\n}\n\n/** Random id with a stable prefix — mirrors the sharing-rule seeder. */\nfunction uid(prefix: string): string {\n const g: any = globalThis as any;\n if (g.crypto?.randomUUID) return `${prefix}_${g.crypto.randomUUID()}`;\n return `${prefix}_${Math.random().toString(36).slice(2, 10)}`;\n}\n\n/**\n * Read declared `webhook` items from the ObjectQL registry (where the manifest\n * decomposition parks `stack.webhooks`), falling back to the metadata service.\n *\n * [#8378] Both reads hand back the authoring document itself. The sentence that\n * used to stand here — \"Items may be wrapped as `{ content }` — unwrap to the\n * raw authoring object\" — described an envelope with **no producer**:\n * `registerMetadataCollections` (objectql `engine.ts`) registers each\n * `stack.webhooks` element as-is, `loadMetaFromDb` registers the parsed body\n * rather than the `sys_metadata` row, and `MetadataFacade` shed its own copy of\n * this unwrap in #7519. `WebhookSchema` declares no `content` key and rejects\n * one as unrecognized, so wherever the key did appear the unwrap replaced the\n * whole webhook with one of its values — and `''` (falsy, non-nullish) passed\n * `??` and then died at `filter(Boolean)`, dropping the webhook silently.\n */\nfunction readDeclared(engine: any, metadataService: any, type: string): any[] {\n try {\n const reg = engine?._registry;\n if (reg?.listItems) {\n const items = (reg.listItems(type) ?? []).filter(Boolean);\n if (items.length > 0) return items;\n }\n } catch {\n /* fall through to metadata service */\n }\n try {\n const listed = metadataService?.list?.(type);\n const arr = typeof (listed as any)?.then === 'function' ? [] : (listed ?? []);\n return Array.isArray(arr) ? arr.filter(Boolean) : [];\n } catch {\n return [];\n }\n}\n\nexport interface BootstrapDeclaredWebhooksResult {\n seeded: number;\n skipped: number;\n}\n\n/**\n * Materialize declared webhooks into `sys_webhook`. Idempotent and safe to run\n * on every boot.\n */\nexport async function bootstrapDeclaredWebhooks(\n engine: IDataEngine,\n metadataService: any,\n logger?: Logger,\n subscriptionsObject = 'sys_webhook',\n): Promise<BootstrapDeclaredWebhooksResult> {\n const declared = readDeclared(engine, metadataService, 'webhook');\n if (declared.length === 0) return { seeded: 0, skipped: 0 };\n\n const now = new Date().toISOString();\n let seeded = 0;\n let skipped = 0;\n\n for (const raw of declared) {\n // Validate + fill defaults through the canonical spec schema. A real\n // consumer at last — a malformed webhook warns and is skipped, never\n // crashing boot.\n let wh: Webhook;\n try {\n wh = WebhookSchema.parse(raw);\n } catch (err: any) {\n logger?.warn?.('[webhook] declared webhook failed validation — skipped', {\n name: (raw as any)?.name,\n error: err?.message ?? String(err),\n });\n skipped += 1;\n continue;\n }\n\n try {\n const existing = await engine.find(subscriptionsObject, {\n where: { name: wh.name },\n limit: 1,\n context: SYSTEM_CTX,\n } as any);\n const row: any = Array.isArray(existing) ? existing[0] : undefined;\n\n if (row) {\n // Admin owns a same-named row, or has edited this seeded one — never\n // clobber. `active: false` on a noisy webhook must survive redeploys.\n if (row.managed_by === 'admin') {\n logger?.warn?.('[webhook] declared name collides with an admin-authored row — seed skipped', {\n name: wh.name,\n });\n skipped += 1;\n continue;\n }\n if (row.customized === true) {\n skipped += 1;\n continue;\n }\n const patch = {\n id: row.id,\n ...mapWebhookToRow(wh),\n ...(await secretPatch(engine, wh, row, subscriptionsObject)),\n ...(await headersPatch(\n engine,\n splitWebhookHeaders(wh as Record<string, unknown>).headers,\n row,\n subscriptionsObject,\n )),\n // Adopt pristine/legacy (pre-provenance) rows so future boots\n // recognize them as package-managed.\n managed_by: 'package',\n updated_at: now,\n };\n await engine.update(subscriptionsObject, patch, { context: SYSTEM_CTX } as any);\n seeded += 1;\n continue;\n }\n\n const { secret } = splitWebhookSecret(wh as Record<string, unknown>);\n const { headers } = splitWebhookHeaders(wh as Record<string, unknown>);\n const newRow = {\n id: uid('whk'),\n ...mapWebhookToRow(wh),\n // Cleartext goes in exactly once, into the `secret`-typed column; the\n // engine's write path wraps it into `sys_secret` and leaves an opaque\n // ref behind. Omitted entirely when unauthored, so a webhook with no\n // secret costs no crypto and needs no CryptoProvider.\n ...(secret ? { [WEBHOOK_SECRET_FIELD]: secret } : {}),\n // [#7986] Same channel, same rule, for the header map — omitted when\n // unauthored so a header-less webhook still seeds on a host with no\n // CryptoProvider wired.\n ...(headers ? { [WEBHOOK_HEADERS_FIELD]: serializeHeaders(headers) } : {}),\n managed_by: 'package',\n customized: false,\n created_at: now,\n updated_at: now,\n };\n await engine.insert(subscriptionsObject, newRow, { context: SYSTEM_CTX } as any);\n seeded += 1;\n } catch (err: any) {\n // [#7799] The engine refuses to persist a `secret` field with no\n // CryptoProvider wired, rather than falling back to cleartext. Say so in\n // those words — \"seed failed\" would read as a transient glitch, when what\n // actually happened is that the deployment has nowhere safe to put a key.\n const protection = isSecretProtectionFailure(err);\n logger?.warn?.(\n protection\n ? '[webhook] declared webhook NOT seeded — its signing secret cannot be stored encrypted (#7799)'\n : '[webhook] declared webhook seed failed',\n {\n name: wh.name,\n ...(protection\n ? { code: WEBHOOK_SECRET_REFUSAL_CODE, status: WEBHOOK_SECRET_REFUSAL_STATUS }\n : {}),\n error: err?.message ?? String(err),\n },\n );\n skipped += 1;\n }\n }\n\n logger?.info?.('[webhook] declared webhooks materialized into sys_webhook', {\n seeded,\n skipped,\n total: declared.length,\n });\n return { seeded, skipped };\n}\n\n/**\n * Decide what a RE-SEED should do with an existing row's `signing_secret`.\n *\n * Re-seeding runs on every boot, and a `secret`-typed write always mints a\n * fresh `sys_secret` ciphertext row — so blindly restating the declared key\n * would leak one orphan cipher row per webhook per restart. Compare against the\n * stored plaintext first (via the engine's privileged dereference) and write\n * only on an actual change:\n *\n * - declared key differs from stored ⇒ write it (rotation in code propagates,\n * exactly as it did when the whole envelope was rewritten every boot);\n * - identical ⇒ omit the key entirely, leaving the existing ref untouched;\n * - declared key removed, row still holds one ⇒ write `null` to CLEAR it\n * (code remains the authority for package rows);\n * - engine cannot dereference (older engine, or the compare threw) ⇒ fall back\n * to writing the declared value. Correct signatures beat tidy storage.\n */\nasync function secretPatch(\n engine: IDataEngine,\n wh: Webhook,\n row: any,\n subscriptionsObject: string,\n): Promise<Record<string, unknown>> {\n const { secret } = splitWebhookSecret(wh as Record<string, unknown>);\n const hasStored = row?.[WEBHOOK_SECRET_FIELD] != null && row[WEBHOOK_SECRET_FIELD] !== '';\n\n if (!secret) return hasStored ? { [WEBHOOK_SECRET_FIELD]: null } : {};\n if (!hasStored || !canResolveSecrets(engine)) return { [WEBHOOK_SECRET_FIELD]: secret };\n\n try {\n const current = await (engine as any).resolveSecretField(\n subscriptionsObject,\n String(row.id),\n WEBHOOK_SECRET_FIELD,\n );\n return current === secret ? {} : { [WEBHOOK_SECRET_FIELD]: secret };\n } catch {\n return { [WEBHOOK_SECRET_FIELD]: secret };\n }\n}\n\n/**\n * Translate a validated {@link Webhook} into `sys_webhook` column values.\n * `object → object_name`, `isActive → active`; the envelope MINUS its two\n * credential passengers — `secret` (#7799) and `headers` (#7986) — is stashed\n * in `definition_json` for the enqueuer's advanced-config read (`timeoutMs`).\n * Both credentials are written separately, into their own encrypted columns.\n */\nfunction mapWebhookToRow(wh: Webhook): Record<string, unknown> {\n const { envelope: withoutSecret } = splitWebhookSecret(wh as Record<string, unknown>);\n const { envelope } = splitWebhookHeaders(withoutSecret as Record<string, unknown>);\n return {\n name: wh.name,\n label: wh.label ?? wh.name,\n object_name: wh.object ?? null,\n triggers: wh.triggers ?? [],\n url: wh.url,\n // Store lowercase to match the object's Field.select option values\n // (get/post/…); the enqueuer upper-cases before delivery either way.\n method: String(wh.method ?? 'POST').toLowerCase(),\n description: wh.description ?? null,\n active: wh.isActive !== false,\n definition_json: JSON.stringify(envelope),\n };\n}\n","// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * [#7799] One-shot boot sweep that moves already-persisted cleartext signing\n * secrets out of `sys_webhook.definition_json` and into the encrypted\n * `signing_secret` column.\n *\n * ## Why a sweep and not just the seeder\n * `bootstrapDeclaredWebhooks` re-seeds package-declared rows on every boot, so\n * those heal themselves the moment the new mapping lands. The rows that do NOT\n * heal are exactly the ones most likely to hold a real production key:\n *\n * - `managed_by: 'admin'` — authored in Setup, never touched by the seeder;\n * - `customized: true` — a package row an admin edited, deliberately frozen\n * against re-seeding (seed-not-clobber, #3461 / #2909).\n *\n * Leaving those behind would make this a half-migration: the code path that\n * created the exposure would be fixed while the exposed values stayed in the\n * table. So the sweep is keyed off the DATA (does this blob contain a secret?),\n * not off provenance.\n *\n * ## What it is careful about\n * - **System context.** The provenance hook exempts `isSystem` writes, so\n * migrating a package row does not stamp `customized: true` and freeze it\n * against future seeding.\n * - **Idempotent.** A row whose blob no longer carries a `secret` is skipped,\n * so the sweep is free on every boot after the first.\n * - **Fail-closed, per row.** With no CryptoProvider the encrypted write throws\n * and the row is LEFT AS IT WAS — still exposed, but intact and still\n * signing. It is reported with an ADR-0112 `code`/`status` pair so an\n * operator can see exactly which rows are still cleartext and why, rather\n * than the sweep quietly reporting success.\n * - **Never widens the blast radius.** The cleartext is only removed from\n * `definition_json` in the SAME update that stores the encrypted copy; a\n * failure cannot land the strip without the store.\n */\n\nimport type { IDataEngine } from '@objectstack/spec/contracts';\nimport type { EngineQueryOptions } from '@objectstack/spec/data';\nimport {\n WEBHOOK_OBJECT,\n WEBHOOK_SECRET_FIELD,\n WEBHOOK_SECRET_REFUSAL_CODE,\n WEBHOOK_SECRET_REFUSAL_STATUS,\n isSecretProtectionFailure,\n readLegacySecret,\n splitWebhookSecret,\n} from './webhook-secret.js';\nimport {\n WEBHOOK_HEADERS_FIELD,\n readLegacyHeaders,\n serializeHeaders,\n splitWebhookHeaders,\n} from './webhook-headers.js';\n\n/** System write context — a boot reconciler is not an admin authoring action. */\nconst SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const;\n\n/** The read side of the same context, typed so `tsc` still checks the keys. */\nconst SYSTEM_QUERY: EngineQueryOptions = {\n context: { isSystem: true, positions: [], permissions: [] },\n};\n\ninterface Logger {\n info?: (msg: string, meta?: unknown) => void;\n warn?: (msg: string, meta?: unknown) => void;\n}\n\nexport interface MigrateWebhookSecretsResult {\n /** Rows whose blob carried a cleartext secret. */\n found: number;\n /** Rows now holding an encrypted secret and a secret-free blob. */\n migrated: number;\n /** Rows still holding cleartext because the encrypted write was refused. */\n failed: number;\n}\n\n/**\n * Move every cleartext `definition_json.secret` into the encrypted column.\n * Safe to run on every boot; returns counts for the caller to log.\n */\nexport async function migrateLegacyWebhookSecrets(\n engine: IDataEngine,\n logger?: Logger,\n subscriptionsObject: string = WEBHOOK_OBJECT,\n): Promise<MigrateWebhookSecretsResult> {\n const out: MigrateWebhookSecretsResult = { found: 0, migrated: 0, failed: 0 };\n\n let rows: any[];\n try {\n const found = await engine.find(subscriptionsObject, SYSTEM_QUERY);\n rows = Array.isArray(found) ? found : ((found as any)?.data ?? []);\n } catch (err: any) {\n logger?.warn?.('[webhook] legacy secret sweep skipped — could not read subscriptions', {\n object: subscriptionsObject,\n error: err?.message ?? String(err),\n });\n return out;\n }\n\n for (const row of rows) {\n if (!row?.id) continue;\n const legacySecret = readLegacySecret(row.definition_json);\n const legacyHeaders = readLegacyHeaders(row.definition_json);\n // [#7986] A row counts as found when it carries EITHER passenger. The two\n // move in ONE update on purpose: two updates would mint two revisions of\n // the same row, and a failure between them could land a blob stripped of\n // its headers while the encrypted copy was never written — the exact\n // \"never widens the blast radius\" rule the secret half already states,\n // which only holds if the strip and the store stay in the same write.\n if (!legacySecret && !legacyHeaders) continue;\n out.found += 1;\n\n try {\n await engine.update(\n subscriptionsObject,\n {\n id: row.id,\n ...(legacySecret ? { [WEBHOOK_SECRET_FIELD]: legacySecret } : {}),\n ...(legacyHeaders ? { [WEBHOOK_HEADERS_FIELD]: serializeHeaders(legacyHeaders) } : {}),\n definition_json: stripCredentialsFromDefinition(row.definition_json as string),\n },\n { context: SYSTEM_CTX } as any,\n );\n out.migrated += 1;\n } catch (err: any) {\n out.failed += 1;\n const protection = isSecretProtectionFailure(err);\n const what = legacySecret && legacyHeaders\n ? 'signing secret and custom headers'\n : legacySecret ? 'signing secret' : 'custom headers';\n logger?.warn?.(\n protection\n ? `[webhook] ${what} STILL CLEARTEXT in definition_json — no CryptoProvider to encrypt them (#7799/#7986)`\n : `[webhook] ${what} migration failed — row left unchanged (#7799/#7986)`,\n {\n name: row.name ?? row.id,\n id: row.id,\n code: WEBHOOK_SECRET_REFUSAL_CODE,\n status: WEBHOOK_SECRET_REFUSAL_STATUS,\n error: err?.message ?? String(err),\n },\n );\n }\n }\n\n if (out.found > 0) {\n logger?.info?.('[webhook] legacy cleartext credentials swept into sys_secret', { ...out });\n }\n return out;\n}\n\n/**\n * Strip both credential passengers from a serialized envelope, preserving every\n * other key. Parsed and re-serialized ONCE so the two removals cannot disagree\n * about what the blob contained.\n */\nfunction stripCredentialsFromDefinition(definitionJson: string): string {\n const parsed = JSON.parse(definitionJson) as Record<string, unknown>;\n const { envelope: withoutSecret } = splitWebhookSecret(parsed);\n const { envelope } = splitWebhookHeaders(withoutSecret as Record<string, unknown>);\n return JSON.stringify(envelope);\n}\n","// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * [#8069] The webhook lane's veto over redelivering one of its own\n * `sys_http_delivery` rows.\n *\n * ## Why a guard exists at all\n * `service-messaging` owns the replay mechanics and deliberately knows nothing\n * about `sys_webhook`. So the row-local refusal it can make on its own — \"this\n * row was never sent, so there is nothing to re-send\" — cannot answer the\n * question the maintainer's ruling of 2026-08-12 actually asks: *is the signing\n * configuration for this delivery still available?* That question is only\n * answerable here, where the subscription and its encrypted secret live.\n *\n * ## What it refuses, and why each case\n * A redelivery replays the row's bytes together with the signature computed at\n * enqueue. That is safe exactly while the configuration those bytes were\n * authorised under still stands. It refuses when:\n *\n * 1. **The subscription is gone.** Nothing is left to say whether this URL\n * should still receive this payload, or under which key — and an operator\n * deleting a webhook has expressed that it should stop. The maintainer\n * named this case specifically.\n * 2. **A secret is stored but does not come back.** The subscription is signed\n * and the key cannot be recovered — a rotated KMS key, an unregistered\n * CryptoProvider, a deleted `sys_secret` row. Deliveries for it are being\n * dropped right now; replaying an old one is the same fail-open by another\n * route.\n * 3. **The lookup itself failed.** Handled by the caller\n * (`assertRedeliverAllowed` turns a throwing guard into a refusal), because\n * \"we could not check\" must never read as \"allowed\".\n *\n * It ALLOWS a subscription that is legitimately unsigned (`secret` is optional\n * on the authoring envelope) and any row from another producer (`source !==\n * 'webhook'`) — this guard speaks only for webhook rows.\n *\n * ## The narrow fail-open this closes deliberately\n * Case 2 is checked as *\"a value is stored but nothing came back\"*, not as\n * *\"the resolver threw\"*. Presence is decidable from the masked read even\n * though the value is not, so the guard asks the question it can actually\n * answer.\n *\n * [#8542] That reasoning has since moved DOWN into `resolveWebhookSecret`,\n * which now raises `WebhookSecretUnresolvableError` instead of returning the\n * same `undefined` it uses for \"authored unsigned\". The reason it had to move:\n * the ENQUEUE path had the identical ambiguity and no way to see it — and there\n * it failed open, arming the subscription and delivering unsigned. One seam,\n * one rule, so a consumer cannot forget to re-derive it. This guard keeps its\n * own presence check because the refusal REASON it returns is written from the\n * subscription row, and keeps its behaviour byte for byte: an unresolvable key\n * is refused with the text below, and anything else still propagates.\n */\n\nimport type { IDataEngine } from '@objectstack/spec/contracts';\nimport {\n WEBHOOK_OBJECT,\n WEBHOOK_SECRET_FIELD,\n isWebhookSecretUnresolvable,\n resolveWebhookSecret,\n} from './webhook-secret.js';\n\n/** The delivery-row fields this guard reads. Structural — no messaging import. */\nexport interface RedeliverGuardRow {\n /** Producer domain; only `'webhook'` rows are this guard's business. */\n source: string;\n /** Partition/ordering anchor — the `sys_webhook` row id for webhook rows. */\n refId: string;\n}\n\n/**\n * Build the guard `MessagingService.registerRedeliverGuard('webhook', …)` takes.\n *\n * Returns a refusal reason, or `undefined` to allow.\n */\nexport function createWebhookRedeliverGuard(\n engine: IDataEngine,\n subscriptionsObject: string = WEBHOOK_OBJECT,\n): (row: RedeliverGuardRow) => Promise<string | undefined> {\n return async (row) => {\n if (row.source !== 'webhook') return undefined;\n\n const subscription = (await engine.findOne(subscriptionsObject, {\n where: { id: row.refId },\n })) as Record<string, unknown> | null;\n\n if (!subscription) {\n return (\n `the ${subscriptionsObject} subscription '${row.refId}' this delivery belongs to no `\n + 'longer exists, so there is nothing left to say whether it may still be signed and '\n + 'sent (#8069). Recreate the webhook if the endpoint should keep receiving events; '\n + 'new events are then delivered signed.'\n );\n }\n\n // Presence is decidable on the masked read — a set secret comes back as\n // the engine's mask, an unset one as null — even though the value is not.\n const storesSecret =\n subscription[WEBHOOK_SECRET_FIELD] != null\n && subscription[WEBHOOK_SECRET_FIELD] !== '';\n if (!storesSecret) return undefined;\n\n // [#8542] The seam now RAISES for the case this guard used to detect on\n // its own — the enqueue path needed the same distinction and could only\n // get it from a throw (its `catch` is what parks the subscription), so\n // the rule moved down one level instead of being written twice. This\n // guard's contract is unchanged in both directions, which is the point:\n // a stored-but-unresolvable key still returns the refusal REASON below\n // (case 2), and any OTHER failure still propagates, because \"we could\n // not check\" must never read as \"allowed\" (case 3, handled by\n // `assertRedeliverAllowed`).\n let plaintext: string | undefined;\n try {\n plaintext = await resolveWebhookSecret(engine, subscription as { id: string }, subscriptionsObject);\n } catch (err) {\n if (!isWebhookSecretUnresolvable(err)) throw err;\n }\n if (plaintext) return undefined;\n\n return (\n `webhook '${String(subscription.name ?? row.refId)}' stores a signing secret that cannot `\n + 'be recovered, so this delivery cannot be authenticated as coming from us — refusing '\n + 'rather than sending (#7799, #8069). Fix: register a CryptoProvider with the same key '\n + 'the secret was written under and make sure the sys_secret row is reachable.'\n );\n };\n}\n","// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * [#3461] Provenance stamp for `sys_webhook`.\n *\n * `sys_webhook` is RECORD-AUTHORITATIVE: a code-declared webhook is a boot seed\n * (`bootstrapDeclaredWebhooks`), and the row — including any admin tuning such\n * as flipping a noisy webhook to `active: false` — is the authority. The seeder\n * skips rows marked `customized`, so this hook is the half that DETECTS the\n * admin edit: any non-system update touching a `package`/`platform`-seeded row\n * stamps `customized: true` onto the payload.\n *\n * Why a data hook (and not a write gate or the REST layer), verbatim to the\n * sys_sharing_rule rationale (#2909 T1):\n * - admins edit webhooks through several doors (Setup UI generic data door,\n * scripts, console) — an engine hook covers them all;\n * - there is deliberately NO write gate here: webhooks are a first-class admin\n * authoring surface, so edits are allowed — they just have to be remembered;\n * - both provenance columns are `readonly`, and the engine's readonly strip\n * exempts isSystem callers while snapshotting supplied keys BEFORE hooks run\n * — so a caller can never forge/clear `customized`, while this hook's stamp\n * survives.\n *\n * Known boundary: multi-row updates (no single `input.id`) are not stamped —\n * every webhook-editing UI path updates by id.\n */\n\ninterface MinimalEngine {\n find(object: string, opts?: any): Promise<any[]>;\n registerHook(event: string, handler: (ctx: any) => any, options?: Record<string, any>): void;\n unregisterHooksByPackage(packageId: string): number;\n}\n\ninterface MinimalLogger {\n info?: (msg: string, meta?: Record<string, any>) => void;\n warn?: (msg: string, meta?: Record<string, any>) => void;\n}\n\nexport const WEBHOOK_PROVENANCE_PACKAGE = 'plugin-webhooks:provenance';\n\nconst SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const;\n\nexport function bindWebhookProvenanceStamp(engine: MinimalEngine, logger?: MinimalLogger): void {\n if (typeof engine?.registerHook !== 'function') return;\n engine.registerHook(\n 'beforeUpdate',\n async (ctx: any) => {\n // Seeder / boot reconcilers write with isSystem — the package door, not\n // an admin customization.\n if ((ctx?.session as any)?.isSystem) return;\n const id = ctx?.input?.id ?? (ctx?.input?.data as any)?.id;\n if (!id) return; // multi-row update — see boundary note above\n const data = ctx?.input?.data;\n if (!data || typeof data !== 'object') return;\n try {\n // `previous` is not resolved before beforeUpdate hooks run — read the\n // current row ourselves (system ctx: this is a provenance check, not\n // an authorization decision).\n const rows = await engine.find('sys_webhook', {\n where: { id },\n fields: ['id', 'managed_by', 'customized'],\n limit: 1,\n context: SYSTEM_CTX,\n });\n const row = Array.isArray(rows) ? rows[0] : undefined;\n if (!row) return;\n if ((row.managed_by === 'package' || row.managed_by === 'platform') && row.customized !== true) {\n (data as any).customized = true;\n }\n } catch (err: any) {\n logger?.warn?.('[webhook] provenance stamp failed (edit proceeds unstamped)', {\n id,\n error: err?.message,\n });\n }\n },\n { object: 'sys_webhook', packageId: WEBHOOK_PROVENANCE_PACKAGE, priority: 150 },\n );\n logger?.info?.('[webhook] provenance stamp hook bound');\n}\n\nexport function unbindWebhookProvenanceStamp(engine: MinimalEngine): void {\n if (typeof engine?.unregisterHooksByPackage === 'function') {\n engine.unregisterHooksByPackage(WEBHOOK_PROVENANCE_PACKAGE);\n }\n}\n","// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * [#8566] The WRITE DOOR for `sys_webhook.headers_secret`'s plaintext shape.\n *\n * ## The defect this closes\n * `headers_secret` is a `Field.secret()` whose plaintext is not an opaque blob:\n * it is a serialized header map with a required shape — a flat JSON object of\n * string values — and {@link parseStoredHeaders} is its only reader. Nothing\n * validated that shape on the way in. The ordinary data API accepted any\n * string, the engine encrypted it like any other secret, minted a real\n * `sys_secret` row, and left the column holding a perfectly valid `secret:` ref\n * that reads back as the mask with `active: true`. Measured on a real engine\n * through `engine.update()` — no privileged access — every one of these was\n * accepted and is a value the plugin can never use: `{}`, `[]`,\n * `{\"X-Count\": 5}`, a nested object, and `{X-Team: crm}` (a typo).\n *\n * ## What this is NOT\n * ⛔ Not an exposure fix, and it must not be graded as one. #8558/#8565 already\n * closed the consumer half: a webhook whose stored header map does not come\n * back as a flat string map PARKS the subscription and reports at `error`\n * rather than delivering header-less with a valid signature. Nothing leaks and\n * nothing is silently lost today.\n *\n * What remains — and all this file changes — is **when the author finds out**.\n * Today: at the next matching record change, an unbounded time after the\n * mistake and in a completely different surface from the one where it was made.\n * With this gate: at the write door, where the author is still standing. The\n * field is directly admin-authorable and its own description instructs the\n * author to type a JSON object into it, which makes a typo the EXPECTED failure\n * rather than an exotic one.\n *\n * ## Why a hook, and why THIS hook\n * Maintainer ruling 2026-08-13 (option 2). Validating at the plugin's own write\n * paths (`bootstrapDeclaredWebhooks` / `headersPatch` / the migration sweep)\n * was rejected as insufficient: a direct `PATCH /api/v1/data/sys_webhook` never\n * goes through any of them, and that is the measured trigger. An engine hook\n * covers every door at once — the generic data API, the Setup UI, scripts, the\n * console — and the plugin's own write paths inherit it automatically, so there\n * is deliberately NO second check on them.\n *\n * Same rationale, and the same shape, as {@link bindWebhookProvenanceStamp}\n * next door: one engine hook rather than N door-side checks.\n *\n * ## ⭐ Order is the whole mechanism: this MUST run before `encryptSecretFields`\n * The engine encrypts a `secret` field on the way to the driver; one step later\n * the plaintext is gone and the column holds an opaque ref. A validator that\n * ran after it would have nothing left to validate. `beforeInsert` /\n * `beforeUpdate` hooks are dispatched BEFORE that encryption on every write\n * path (measured in `packages/objectql/src/engine.ts`: insert triggers its\n * hooks and then encrypts; both the by-id and the multi update arms do the\n * same), which is what makes this seam the right one and not merely a\n * convenient one. `webhook-headers-gate.test.ts` pins the ordering against the\n * real engine rather than trusting this paragraph.\n *\n * ## The four values this gate deliberately lets through\n * Each is someone else's verdict, and duplicating any of them here would create\n * a second owner for a rule that already has one:\n *\n * 1. **the key is absent** — \"leave the stored value unchanged\";\n * 2. **`null` / `undefined`** — the CLEAR spelling, which the engine honours\n * and which the refusal message below points authors at;\n * 3. **`\"\"`** — governed by #8559's ruling and refused by the engine's own\n * `encryptSecretFields` a few lines later, with a message that already\n * names `null` as the way to clear. ⚠️ The dispatch note said this gate\n * \"can assume it never sees `\\\"\\\"`\"; measured, that is inverted — this hook\n * runs FIRST, so it does see it and must pass it through untouched for\n * #8559's seam to answer. Refusing it here would duplicate that ruling and\n * put two different messages on one door;\n * 4. **the engine's opaque wire forms** ({@link isOpaqueSecretForm}) — the\n * read mask and a `secret:` ref. The mask is the echoed-read-mask case the\n * ruling calls out by name: a caller that GETs a row and PATCHes it back\n * unchanged sends the mask, and the engine drops that key as \"unchanged\".\n * Refusing it would break every round-trip through the Setup form, which is\n * the single most ordinary write this object receives. A ref is the same\n * story one layer down (the engine leaves an already-encrypted ref alone).\n *\n * ## Why the verdict is `parseStoredHeaders`, not a second shape rule\n * The door refuses EXACTLY what the consumer cannot use, because it asks the\n * consumer's own question: the value is normalized the way the engine will\n * normalize it, then handed to {@link parseStoredHeaders} — the same function\n * the enqueuer reads stored headers with. A hand-written second predicate here\n * could drift from that one, and a door that refuses a value the consumer would\n * have accepted (or accepts one it cannot use) is worse than no door. One rule,\n * one definition.\n *\n * ## ⛔ The refusal never echoes the value\n * This column carries credentials — an `Authorization: Bearer …` is the header\n * the field's own description uses as its example. A validation message that\n * quoted the rejected input would print that token into logs and HTTP error\n * bodies, i.e. re-open in the diagnostic exactly the exposure #7986 moved this\n * field onto the encrypted channel to close. So the diagnostic names TYPES and\n * KEYS only — header names are not credentials, their values are — and never a\n * value.\n *\n * ## Promotion path (⛔ not built here)\n * A general capability on the `secret` channel — letting any `secret`-typed\n * field declare a plaintext validator — is the principled generalization and is\n * recorded as the shape this becomes the moment a SECOND shaped-plaintext\n * `secret` field exists. It is deliberately not built for one consumer\n * (maintainer ruling 2026-08-13, item 3; startup scope). Whoever hits that\n * second field files against this precedent.\n */\n\nimport {\n HEADERS_REMEDY,\n WEBHOOK_HEADERS_FIELD,\n parseStoredHeaders,\n} from './webhook-headers.js';\nimport { WEBHOOK_OBJECT, isOpaqueSecretForm } from './webhook-secret.js';\n\n/**\n * ADR-0112 envelope for this refusal. `VALIDATION_ERROR`/400 is the standard\n * catalog member for \"the payload is not acceptable\" — the SAME pair #8559's\n * `EmptyCredentialWriteError` carries at the same door for the same class of\n * verdict, so a client branching on `code`/`status` handles both malformed\n * credential writes identically. A standard-catalog code needs no ledger entry.\n */\nexport const WEBHOOK_HEADERS_SHAPE_REFUSAL_CODE = 'VALIDATION_ERROR';\nexport const WEBHOOK_HEADERS_SHAPE_REFUSAL_STATUS = 400;\n\n/**\n * The shape the field's own description asks for, quoted in the refusal so the\n * error and the authoring surface cannot drift into two different specs.\n * Kept verbatim from `sys-webhook.object.ts`'s `headers_secret` description.\n */\nconst DECLARED_SHAPE =\n 'Custom HTTP headers sent with each delivery, as a JSON object '\n + '({\"Authorization\": \"Bearer ...\"})';\n\n/**\n * [#8566] Refusal to persist a `headers_secret` plaintext that is not a flat\n * JSON object of string values.\n *\n * Carries the ADR-0112 pair plus the LOCATION (`object`/`field`) as fields, so\n * a consumer branches on `code`/`status` rather than on message text — the same\n * discipline {@link WebhookHeadersUnresolvableError} follows on the read side\n * of this seam, and `EmptyCredentialWriteError` follows on the write side.\n */\nexport class WebhookHeadersShapeError extends Error {\n readonly code = WEBHOOK_HEADERS_SHAPE_REFUSAL_CODE;\n readonly status = WEBHOOK_HEADERS_SHAPE_REFUSAL_STATUS;\n readonly object: string;\n readonly field: string;\n\n constructor(object: string, field: string, diagnosis: string) {\n super(\n `Custom headers refused for \"${object}.${field}\": ${diagnosis}. The required shape is a FLAT `\n + 'JSON object of string values, which is what the field itself asks for — its description '\n + `reads: \"${DECLARED_SHAPE}\". This is checked at the write door because one step later `\n + 'there is nothing left to check: the engine encrypts this value into sys_secret and every '\n + 'read path returns only the mask, so a stored value that can never be used is '\n + 'indistinguishable from one that works until the next delivery tries to send it — at '\n + 'which point the subscription parks and the report arrives an unbounded time later, in a '\n + `different surface from the one it was typed into (#7986, #8558, #8566). ${HEADERS_REMEDY}`,\n );\n this.name = 'WebhookHeadersShapeError';\n this.object = object;\n this.field = field;\n }\n}\n\n/**\n * Describe a parsed value's SHAPE for the diagnostic — types and keys only,\n * never values (see the file header's note on why this message must not echo\n * the input). Header names are safe to name and are the single most useful\n * thing a typo-hunting author can be told.\n */\nfunction describeParsed(parsed: unknown): string {\n if (parsed === null) return 'null';\n if (Array.isArray(parsed)) return 'a JSON array';\n if (typeof parsed !== 'object') return `a JSON ${typeof parsed}`;\n\n const entries = Object.entries(parsed as Record<string, unknown>);\n if (entries.length === 0) {\n return 'an EMPTY JSON object, which is not the same thing as \"send no custom headers\"';\n }\n const bad = entries.filter(([, v]) => typeof v !== 'string');\n if (bad.length > 0) {\n const named = bad\n .map(([k, v]) => `${JSON.stringify(k)} (${Array.isArray(v) ? 'array' : v === null ? 'null' : typeof v})`)\n .join(', ');\n return (\n `a JSON object, but the wire carries only strings and ${bad.length === 1 ? 'this value is' : 'these values are'} `\n + `not a string: ${named}`\n );\n }\n // Unreachable while `parseStoredHeaders` accepts exactly non-empty flat\n // string maps; kept truthful rather than asserting a shape we did not check.\n return 'a JSON object the header seam does not accept';\n}\n\n/** Describe the raw payload value, resolving the string/JSON layer first. */\nfunction describeRejected(value: unknown): string {\n if (typeof value === 'string') {\n let parsed: unknown;\n try {\n parsed = JSON.parse(value);\n } catch {\n return (\n 'the value is a string that is not valid JSON at all — check for unquoted keys or values '\n + '({X-Team: crm}), single quotes instead of double, or a trailing comma'\n );\n }\n return `the value parses as JSON but is ${describeParsed(parsed)}`;\n }\n return `the value is ${describeParsed(value)}`;\n}\n\n/**\n * The verdict, as a pure function of the write payload — exported so the gate\n * can be reasoned about and tested without booting an engine, and so any future\n * caller uses the same one rule rather than restating it.\n *\n * Mutates nothing and returns nothing: it either passes or throws\n * {@link WebhookHeadersShapeError}.\n */\nexport function assertWritableWebhookHeaders(\n data: Record<string, unknown> | null | undefined,\n object: string = WEBHOOK_OBJECT,\n field: string = WEBHOOK_HEADERS_FIELD,\n): void {\n if (!data || typeof data !== 'object') return;\n if (!Object.prototype.hasOwnProperty.call(data, field)) return; // omitted ⇒ unchanged\n\n const value = data[field];\n if (value === null || typeof value === 'undefined') return; // the CLEAR spelling\n if (value === '') return; // #8559's seam owns this — see the file header\n if (isOpaqueSecretForm(value)) return; // echoed read-mask, or an existing ref\n\n // Normalize EXACTLY as the engine is about to: a string is taken as the\n // serialized map it claims to be, and anything else is JSON.stringify'd —\n // which is what `encryptSecretFields` does with a non-string secret value, so\n // an authored object that really is a flat string map keeps working (it\n // serializes to precisely the form the consumer reads back).\n let serialized: string;\n if (typeof value === 'string') {\n serialized = value;\n } else {\n try {\n serialized = JSON.stringify(value) as string;\n } catch {\n // Circular / unserializable: the engine would store \"[object Object]\"-\n // class garbage or throw deeper in. Refuse it here, where the message can\n // say something useful.\n throw new WebhookHeadersShapeError(\n object,\n field,\n 'the value cannot be serialized to JSON at all (it contains a circular reference)',\n );\n }\n // `JSON.stringify` answers `undefined` for a function or a symbol.\n if (typeof serialized !== 'string') {\n throw new WebhookHeadersShapeError(object, field, `the value is a ${typeof value}`);\n }\n }\n\n // The consumer's own question, asked at the door (see the file header).\n if (parseStoredHeaders(serialized)) return;\n\n throw new WebhookHeadersShapeError(object, field, describeRejected(value));\n}\n\n/** Minimal engine surface this binding needs — mirrors `webhook-provenance.ts`. */\ninterface MinimalEngine {\n registerHook(event: string, handler: (ctx: any) => any, options?: Record<string, any>): void;\n unregisterHooksByPackage(packageId: string): number;\n}\n\ninterface MinimalLogger {\n info?: (msg: string, meta?: Record<string, any>) => void;\n}\n\nexport const WEBHOOK_HEADERS_GATE_PACKAGE = 'plugin-webhooks:headers-shape-gate';\n\n/**\n * Priority 50 — ahead of the provenance stamp's 150 (lower runs first), so a\n * refused write is refused before anything else spends work on it. The stamp\n * issues a `find` against `sys_webhook` on every non-system update; there is no\n * reason to pay for it on a payload that is about to be rejected. Nothing about\n * correctness depends on the two hooks' relative order — only on both running\n * before `encryptSecretFields`, which every `before*` hook does.\n */\nconst GATE_PRIORITY = 50;\n\n/**\n * Bind the shape gate to both write events on `sys_webhook`.\n *\n * ## Deliberately NOT exempt for `isSystem`\n * The provenance stamp next door skips system writes because it is detecting an\n * ADMIN edit; this is a validity verdict on a payload, and a malformed header\n * map is exactly as unusable when a seeder writes it. Ruling item 2 says the\n * plugin's own write paths inherit this validation through the hook, which is\n * only true if system writes are covered. They pass by construction —\n * `bootstrapDeclaredWebhooks` and the migration sweep both write\n * `serializeHeaders(...)` of an already `isHeaderMap`-filtered map — so\n * covering them costs nothing and closes the door for a future write path that\n * is less careful.\n *\n * Registered in CODE rather than from metadata, which also means\n * `session.skipAutomations` (an import run with automations unchecked) cannot\n * suppress it: the engine only skips metadata-bound entries. A validation door\n * that an import could switch off would not be a door.\n */\nexport function bindWebhookHeadersShapeGate(engine: MinimalEngine, logger?: MinimalLogger): void {\n if (typeof engine?.registerHook !== 'function') return;\n\n const handler = (ctx: any) => {\n assertWritableWebhookHeaders(ctx?.input?.data as Record<string, unknown> | undefined);\n };\n\n for (const event of ['beforeInsert', 'beforeUpdate'] as const) {\n engine.registerHook(event, handler, {\n object: WEBHOOK_OBJECT,\n packageId: WEBHOOK_HEADERS_GATE_PACKAGE,\n priority: GATE_PRIORITY,\n });\n }\n\n logger?.info?.('[webhook] headers_secret shape gate bound (refuses non-flat-string-map plaintext)');\n}\n\n/** Remove the gate — mirrors `unbindWebhookProvenanceStamp`, for `dispose()`. */\nexport function unbindWebhookHeadersShapeGate(engine: MinimalEngine): void {\n if (typeof engine?.unregisterHooksByPackage === 'function') {\n engine.unregisterHooksByPackage(WEBHOOK_HEADERS_GATE_PACKAGE);\n }\n}\n","// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\nimport type { Plugin, PluginContext } from '@objectstack/core';\nimport type {\n IDataEngine,\n II18nService,\n IMetadataService,\n IRealtimeService,\n} from '@objectstack/spec/contracts';\nimport type { EnqueueHttpInput } from '@objectstack/service-messaging';\nimport { AutoEnqueuer, type AutoEnqueuerOptions } from './auto-enqueuer.js';\nimport { SysWebhook } from './sys-webhook.object.js';\nimport { bootstrapDeclaredWebhooks } from './bootstrap-declared-webhooks.js';\nimport { migrateLegacyWebhookSecrets } from './migrate-webhook-secrets.js';\nimport { createWebhookRedeliverGuard } from './redeliver-guard.js';\nimport { bindWebhookProvenanceStamp, unbindWebhookProvenanceStamp } from './webhook-provenance.js';\nimport {\n bindWebhookHeadersShapeGate,\n unbindWebhookHeadersShapeGate,\n} from './webhook-headers-gate.js';\n\n/**\n * Structural view of `@objectstack/service-messaging`'s HTTP-outbox surface\n * (ADR-0018 M3) — declared locally so this plugin doesn't take a hard runtime\n * import on the service. Webhook deliveries are enqueued onto the shared\n * `sys_http_delivery` outbox and drained by the messaging `HttpDispatcher`.\n */\ninterface MessagingHttpSurface {\n isHttpDeliveryReady(): boolean;\n enqueueHttp(input: EnqueueHttpInput): Promise<string>;\n /**\n * [#10740] Takes the REQUESTING caller's organization. Declared with the\n * required-but-nullable `tenantId` the service declares, so this plugin\n * cannot call the endpoint's backing method without deciding what tenant\n * the request carries — the omission this structural view would otherwise\n * type-check happily.\n */\n redeliverHttp(\n id: string,\n options: { tenantId: string | undefined },\n ): Promise<{ id: string; status: string }>;\n /**\n * [#8069] Where this plugin's veto over redelivering `source: 'webhook'`\n * rows is installed. Declared REQUIRED on this structural view even though\n * the import is type-only: a messaging build without it cannot enforce the\n * refusal, and {@link WebhookOutboxPlugin.installRedeliverGuard} says so at\n * `error` rather than arming the endpoint with a guarantee nothing keeps.\n */\n registerRedeliverGuard(\n source: string,\n guard: (row: { source: string; refId: string }) => Promise<string | undefined>,\n ): void;\n}\n\nexport interface WebhookOutboxPluginOptions {\n /**\n * Auto-enqueue config. When enabled (default `true` if the realtime + data\n * engine services are available), the plugin subscribes to `data.record.*`\n * events and enqueues a delivery onto the shared messaging HTTP outbox for\n * every matching `sys_webhook` row.\n *\n * Set `false` to disable and enqueue webhooks imperatively elsewhere.\n */\n autoEnqueue?: boolean | AutoEnqueuerOptions;\n}\n\n/**\n * Wires webhook fan-out on top of the shared outbound-HTTP delivery substrate\n * (ADR-0018 M3).\n *\n * Webhooks are no longer their own delivery engine: the durable outbox, the\n * cluster-coordinated dispatcher, the retry/backoff/dead-letter schedule, and\n * the retention sweep all live in `@objectstack/service-messaging`\n * (`sys_http_delivery` + `HttpDispatcher`). This plugin owns only the\n * webhook-specific concerns:\n * - the `sys_webhook` configuration object,\n * - the {@link AutoEnqueuer} that turns `data.record.*` events into outbox\n * rows (`source: 'webhook'`), and\n * - the redeliver admin endpoint.\n *\n * End-to-end flow:\n *\n * engine.insert('contact', {...})\n * → engine publishes data.record.created via IRealtimeService\n * → AutoEnqueuer matches active sys_webhook rows in O(1)\n * → messaging.enqueueHttp() runs fire-and-forget (off the write path)\n * → messaging HttpDispatcher claims and POSTs (cluster-coordinated, retried)\n *\n * **Requires** `MessagingServicePlugin` (`@objectstack/service-messaging`),\n * which is a foundational, always-on capability.\n */\nexport class WebhookOutboxPlugin implements Plugin {\n name = 'com.objectstack.plugin-webhook-outbox';\n version = '2.0.0';\n type = 'standard' as const;\n dependencies = ['com.objectstack.service.messaging'];\n /**\n * `init()` registers this plugin's schema through `manifest` with no\n * fallback. Until #4187 that was safe only TRANSITIVELY — messaging happens\n * to depend on ObjectQL, which provides `manifest` — so the guarantee would\n * have evaporated silently the day messaging stopped depending on the\n * engine, and the failure would have surfaced as an unrelated plugin's\n * init crash. Declaring the requirement directly makes the kernel check it\n * regardless of what messaging depends on.\n */\n requiresServices = ['manifest'];\n\n private autoEnqueuer: AutoEnqueuer | undefined;\n /** Engine the provenance hook was bound to, so `dispose()` can unbind it. */\n private boundEngine: any;\n\n constructor(private readonly options: WebhookOutboxPluginOptions = {}) {}\n\n async init(ctx: PluginContext): Promise<void> {\n // Register the webhook config object (ADR-0029 K2.a). The delivery\n // telemetry now lives in messaging's `sys_http_delivery`, so the nav's\n // \"Deliveries\" entry points there (filtered to source=webhook in views).\n const manifest = ctx.getService<{ register(m: any): void }>('manifest');\n if (manifest && typeof manifest.register === 'function') {\n manifest.register({\n id: 'com.objectstack.plugin-webhook-outbox.schema',\n namespace: 'sys',\n version: this.version,\n type: 'plugin',\n scope: 'system',\n name: 'Webhook Schemas',\n description: 'Registers sys_webhook (configuration). Deliveries use messaging\\'s sys_http_delivery outbox.',\n objects: [SysWebhook],\n navigationContributions: [\n {\n app: 'setup',\n group: 'group_integrations',\n priority: 100,\n items: [\n { id: 'nav_webhooks', type: 'object', label: 'Webhooks', objectName: 'sys_webhook', icon: 'webhook', requiresObject: 'sys_webhook' },\n { id: 'nav_http_deliveries', type: 'object', label: 'HTTP Deliveries', objectName: 'sys_http_delivery', icon: 'send', requiresObject: 'sys_http_delivery' },\n ],\n },\n ],\n });\n } else {\n ctx.logger.warn?.(\n '[webhook-outbox] manifest service unavailable — sys_webhook will NOT appear in REST or Studio nav. Register MetadataService before WebhookOutboxPlugin.',\n );\n }\n\n // ADR-0029 D8 — contribute object translations once i18n is up.\n if (typeof (ctx as any).hook === 'function') {\n (ctx as any).hook('kernel:ready', async () => {\n try {\n const i18n = ctx.getService<II18nService>('i18n');\n if (i18n && typeof i18n.loadTranslations === 'function') {\n const { WebhooksTranslations } = await import('./translations/index.js');\n for (const [locale, data] of Object.entries(WebhooksTranslations)) {\n i18n.loadTranslations(locale, data as Record<string, unknown>);\n }\n }\n } catch { /* i18n optional */ }\n });\n }\n\n const autoEnqueueOpt = this.options.autoEnqueue ?? true;\n\n if (typeof (ctx as any).hook === 'function') {\n (ctx as any).hook('kernel:ready', async () => {\n // Materialize declared webhooks FIRST — this only needs the data\n // engine, so it must not be gated behind the auto-enqueue\n // dispatch prerequisites (realtime + messaging). Otherwise a\n // deployment without realtime would silently fail to materialize\n // declared webhooks — the very no-op this bridge closes (#3461).\n await this.bootDeclaredWebhooks(ctx);\n await this.bootAutoEnqueue(ctx, autoEnqueueOpt);\n this.registerAdminRoutes(ctx);\n });\n }\n\n ctx.logger.info?.('[webhook-outbox] initialised (delivery via shared messaging HTTP outbox)', {\n autoEnqueue: autoEnqueueOpt !== false,\n });\n }\n\n /**\n * Teardown — the kernel's ONLY teardown hook.\n *\n * [#10772] This body used to be spelled `dispose()`. `Plugin`\n * (`@objectstack/core`'s `types.ts`) declares `init()`, `start?(ctx)` and\n * `destroy?()` and no `dispose()`, and `ObjectKernel.performShutdown()` /\n * `LiteKernel.destroy()` walk the plugins in reverse calling\n * `plugin.destroy()` — so after `await kernel.shutdown()` had RESOLVED the\n * auto-enqueuer was still running and both engine hooks were still bound.\n * Measured on the same revision: `dispose()` had ZERO callers anywhere in\n * the repo, so this teardown had never run in any process at all.\n *\n * Idempotent: `boundEngine` is cleared as it is unbound, so a second\n * teardown is a no-op rather than a second unbind.\n */\n async destroy(): Promise<void> {\n await this.autoEnqueuer?.stop();\n if (this.boundEngine) {\n try { unbindWebhookProvenanceStamp(this.boundEngine); } catch { /* best effort */ }\n try { unbindWebhookHeadersShapeGate(this.boundEngine); } catch { /* best effort */ }\n this.boundEngine = undefined;\n }\n }\n\n /**\n * Retained alias for {@link destroy}. Kept because it is public API of an\n * exported class: an embedder may have learned to call it directly\n * precisely BECAUSE the kernel never did, and deleting it would break them.\n * Same signature, same return type — a direct caller sees no change.\n */\n async dispose(): Promise<void> {\n await this.destroy();\n }\n\n private getMessaging(ctx: PluginContext): MessagingHttpSurface | undefined {\n const svc = this.tryGetService<MessagingHttpSurface>(ctx, ['messaging']);\n return svc && typeof svc.enqueueHttp === 'function' ? svc : undefined;\n }\n\n /**\n * [#3461] Bridge the declarative authoring surface to the dispatcher:\n * materialize stack/connector-declared `webhook` metadata into `sys_webhook`\n * rows so the auto-enqueuer (and the Studio UI) can see them.\n *\n * Gated on the DATA ENGINE alone — deliberately independent of the\n * auto-enqueue dispatch prerequisites (realtime + messaging). Materializing\n * rows is a pure write; a deployment that mounts the webhook plugin without\n * realtime must still get its declared webhooks into the table (and the\n * Setup UI), even if nothing dispatches them yet. Runs before\n * {@link bootAutoEnqueue} so the enqueuer's first cache refresh sees the rows.\n */\n private async bootDeclaredWebhooks(ctx: PluginContext): Promise<void> {\n const engine = this.tryGetService<IDataEngine>(ctx, ['objectql', 'data']);\n if (!engine) {\n ctx.logger.warn?.('[webhook] declared-webhook bootstrap skipped — no data engine available');\n return;\n }\n // Bind the provenance stamp so an admin edit freezes a seeded row.\n this.boundEngine = engine;\n bindWebhookProvenanceStamp(engine as any, ctx.logger as any);\n // [#8566] And the headers_secret shape gate, BEFORE the seeder below\n // runs its first write — a validation door that arms after the first\n // write it is meant to judge is not a door. It covers every write path\n // at once (the generic data API included, which is the measured\n // trigger), so the plugin's own writers deliberately carry no second\n // check of their own.\n bindWebhookHeadersShapeGate(engine as any, ctx.logger as any);\n let metadataService: IMetadataService | undefined;\n try { metadataService = ctx.getService<IMetadataService>('metadata'); } catch { /* optional */ }\n try {\n await bootstrapDeclaredWebhooks(engine, metadataService, ctx.logger as any);\n } catch (err: any) {\n ctx.logger.warn?.('[webhook] declared-webhook bootstrap failed (dispatcher still serves admin rows)', {\n error: err?.message ?? String(err),\n });\n }\n // [#7799] Then heal the rows the seeder cannot touch. Package rows are\n // rewritten above; `managed_by: 'admin'` and `customized: true` rows are\n // deliberately frozen against re-seeding, and those are precisely the\n // ones holding hand-authored production keys in cleartext. Runs AFTER\n // the seeder so a row it just rewrote is already secret-free and the\n // sweep is a no-op on it.\n try {\n await migrateLegacyWebhookSecrets(engine, ctx.logger as any);\n } catch (err: any) {\n ctx.logger.warn?.('[webhook] legacy signing-secret sweep failed (rows left unchanged)', {\n error: err?.message ?? String(err),\n });\n }\n }\n\n private async bootAutoEnqueue(\n ctx: PluginContext,\n opt: boolean | AutoEnqueuerOptions,\n ): Promise<void> {\n if (opt === false) return;\n const engine = this.tryGetService<IDataEngine>(ctx, ['objectql', 'data']);\n const realtime = this.tryGetService<IRealtimeService>(ctx, ['realtime']);\n const messaging = this.getMessaging(ctx);\n if (!engine || !realtime || !messaging) {\n ctx.logger.warn?.(\n '[webhook-auto-enqueuer] disabled — ObjectQL, Realtime, or Messaging service not available',\n { hasEngine: !!engine, hasRealtime: !!realtime, hasMessaging: !!messaging },\n );\n return;\n }\n if (!messaging.isHttpDeliveryReady()) {\n ctx.logger.warn?.(\n '[webhook-auto-enqueuer] messaging HTTP outbox not ready (no data engine / reliableDelivery off) — webhook deliveries will not be durable',\n );\n }\n\n const enqOpts = (typeof opt === 'object' ? opt : {}) as AutoEnqueuerOptions;\n // [#8069] Install the redelivery veto BEFORE the enqueuer starts\n // writing rows, so no delivery row can ever exist while the refusal\n // that protects it does not.\n this.installRedeliverGuard(ctx, messaging, engine, enqOpts.subscriptionsObject);\n this.autoEnqueuer = new AutoEnqueuer(\n engine,\n realtime,\n (input) => messaging.enqueueHttp(input),\n { ...enqOpts, logger: ctx.logger },\n );\n await this.autoEnqueuer.start();\n ctx.registerService('webhook.autoEnqueuer', this.autoEnqueuer);\n ctx.logger.info?.('[webhook-auto-enqueuer] started (enqueues source=webhook onto sys_http_delivery)');\n }\n\n /**\n * [#8069] Register {@link createWebhookRedeliverGuard} with messaging, so\n * `redeliver()` refuses a webhook row whose signing configuration is no\n * longer available — for EVERY caller, not just the\n * `POST /api/v1/webhooks/redeliver` route.\n *\n * Absence is loud, and `error` is the right level by AGENTS.md's one\n * question: with no guard installed the endpoint still answers 200 and the\n * dispatcher still reports a delivery, while the fail-closed signing\n * guarantee the system claims (#7799) is not actually being kept. That is a\n * durability/consistency degradation wearing a functional one's clothes.\n */\n private installRedeliverGuard(\n ctx: PluginContext,\n messaging: MessagingHttpSurface,\n engine: IDataEngine,\n subscriptionsObject?: string,\n ): void {\n if (typeof messaging.registerRedeliverGuard !== 'function') {\n ctx.logger.error?.(\n '[webhook-outbox] messaging service exposes no registerRedeliverGuard() — redelivery '\n + 'of a webhook whose signing configuration is gone CANNOT be refused, so an operator '\n + 'pressing redeliver may send a delivery that can no longer be authenticated '\n + '(#7799, #8069). The POST /api/v1/webhooks/redeliver endpoint is reachable by any '\n + 'authenticated user. Fix: upgrade @objectstack/service-messaging to a build that '\n + 'implements registerRedeliverGuard.',\n );\n return;\n }\n messaging.registerRedeliverGuard(\n 'webhook',\n createWebhookRedeliverGuard(engine, subscriptionsObject),\n );\n ctx.logger.debug?.('[webhook-outbox] redeliver guard installed for source=webhook');\n }\n\n private tryGetService<T>(ctx: PluginContext, names: string[]): T | undefined {\n for (const n of names) {\n try {\n const svc = ctx.getService<T>(n);\n if (svc) return svc;\n } catch {\n // fall through\n }\n }\n return undefined;\n }\n\n /**\n * Mount POST /api/v1/webhooks/redeliver on the host Hono app, if one is\n * available. Delegates to `messaging.redeliverHttp(deliveryId, …)`. Auth is\n * the better-auth session cookie — every authenticated user counts.\n *\n * [#10740] Which is precisely why the caller's ACTIVE ORGANIZATION is\n * resolved here and threaded into the call. `sys_http_delivery` is\n * tenant-scoped, and this is the one door on it a request can reach: an\n * unscoped replay from here is an authenticated user reaching another\n * organization's delivery row on a walled deployment. With the tenant\n * threaded, a row outside the caller's organization is simply not found.\n *\n * ⚠️ A session with no active organization threads `undefined`, and the\n * driver's tenant-audit line then fires for that write. That is deliberate:\n * the deployment could not tell us who is asking, and reporting the gap is\n * the correct outcome. ⛔ It is never repaired with `bypassTenantAudit`,\n * which would silence the report without closing anything.\n */\n private registerAdminRoutes(ctx: PluginContext): void {\n const http = this.tryGetService<any>(ctx, ['http-server']);\n if (!http || typeof http.getRawApp !== 'function') {\n ctx.logger.debug?.('[webhook-outbox] HTTP server not available; redeliver endpoint not mounted');\n return;\n }\n const rawApp = http.getRawApp();\n const messaging = this.getMessaging(ctx);\n if (!rawApp || !messaging) return;\n\n rawApp.post('/api/v1/webhooks/redeliver', async (c: any) => {\n const session = await this.resolveSession(ctx, c);\n const userId = session?.user?.id;\n if (typeof userId !== 'string' || userId.length === 0) {\n return c.json(\n { success: false, error: { code: 'UNAUTHENTICATED', message: 'Sign in to redeliver webhook deliveries.' } },\n 401,\n );\n }\n let body: any;\n try {\n body = await c.req.json();\n } catch {\n return c.json({ success: false, error: { code: 'INVALID_REQUEST', message: 'Request body must be JSON.' } }, 400);\n }\n const deliveryId = typeof body?.deliveryId === 'string' ? body.deliveryId.trim() : '';\n if (!deliveryId) {\n return c.json(\n { success: false, error: { code: 'MISSING_REQUIRED_FIELD', message: 'Body must include `deliveryId: string`.' } },\n 400,\n );\n }\n try {\n // [#10740] `session.session.activeOrganizationId` is the\n // canonical spelling of the caller's active organization\n // (better-auth's organization plugin; see\n // `plugin-auth/auth-schema-config.ts`). Read from the one\n // place it lives — a `??` chain over alternative spellings\n // would make a MISSING organization indistinguishable from a\n // differently-shaped one, and the missing case is the one that\n // must stay visible.\n const activeOrg = session?.session?.activeOrganizationId;\n const tenantId = typeof activeOrg === 'string' && activeOrg.length > 0\n ? activeOrg\n : undefined;\n const row = await messaging.redeliverHttp(deliveryId, { tenantId });\n ctx.logger.info?.('[webhook-outbox] redelivered', { deliveryId, requestedBy: userId, tenantId });\n return c.json({ success: true, data: { id: row.id, status: row.status } });\n } catch (err: any) {\n const code = err?.code;\n if (code === 'RESOURCE_NOT_FOUND') {\n return c.json({ success: false, error: { code, message: err.message } }, 404);\n }\n // [#8069] `DELIVERY_NEVER_SENT` is a refusal, not a server\n // fault: the row is a parked record of a delivery that was\n // never prepared, and re-sending it would be a FIRST delivery —\n // unsigned, because the secret that would have signed it is\n // what went missing. 409 alongside the eligibility refusal, so\n // an operator tool can present both the same way; without this\n // arm it would surface as a 500 and read as a transient glitch\n // worth retrying.\n if (code === 'DELIVERY_NOT_ELIGIBLE' || code === 'DELIVERY_NEVER_SENT') {\n return c.json({ success: false, error: { code, message: err.message } }, 409);\n }\n ctx.logger.error?.('[webhook-outbox] redeliver failed', err as Error);\n return c.json(\n { success: false, error: { code: 'INTERNAL_ERROR', message: err?.message ?? String(err) } },\n 500,\n );\n }\n });\n\n ctx.logger.info?.('[webhook-outbox] redeliver endpoint mounted at POST /api/v1/webhooks/redeliver');\n }\n\n /**\n * [#10740] The better-auth session envelope (`{ user, session }`) for this\n * request, or `undefined`.\n *\n * Widened from the previous `resolveSessionUserId` because the route now\n * needs two facts from ONE lookup: who is asking (`user.id`, the\n * authentication gate) and which organization they are asking as\n * (`session.activeOrganizationId`, the tenant threaded into the write).\n * Resolving them separately would mean two `getSession` calls that can\n * disagree.\n */\n private async resolveSession(ctx: PluginContext, c: any): Promise<any | undefined> {\n try {\n const authService: any = this.tryGetService<any>(ctx, ['auth']);\n if (!authService) return undefined;\n let api: any = authService.api;\n if (!api && typeof authService.getApi === 'function') {\n api = await authService.getApi();\n }\n if (!api?.getSession) return undefined;\n return await api.getSession({ headers: c.req.raw.headers });\n } catch {\n return undefined;\n }\n }\n}\n"]}
|