diff --git a/.changeset/webhook-subscriber-signing-secret-encrypted.md b/.changeset/webhook-subscriber-signing-secret-encrypted.md new file mode 100644 index 0000000000..aabe0f2aa4 --- /dev/null +++ b/.changeset/webhook-subscriber-signing-secret-encrypted.md @@ -0,0 +1,52 @@ +--- +"@objectstack/plugin-webhooks": patch +"@objectstack/objectql": patch +--- + +fix(webhooks): the subscriber's HMAC signing secret is no longer readable from `sys_webhook` over the data API (#7799) + +`bootstrapDeclaredWebhooks` persisted the whole validated `Webhook` envelope — +**`secret` included** — as `definition_json: JSON.stringify(wh)`, and +`AutoEnqueuer.parseRow` read `defn.secret` straight back out to sign deliveries. +`definition_json` is an ordinary textarea on an admin-authorable object with no +restrictive `enable.apiMethods`, so a plain `GET /api/v1/data/sys_webhook` +returned the key to **every persona that can read the object**. That key is the +receiver's only proof that a delivery came from us. + +This is the remaining half of #7722, which removed the same secret's per-attempt +copies from `sys_http_delivery`. Unlike the delivery table, no retention window +ever aged these out. + +**What changed.** The authored key now lands in `sys_webhook.signing_secret`, a +new `type: 'secret'` column: the engine encrypts it into `sys_secret` on write, +keeps only an opaque `secret:` ref on the row, and returns a mask on every +read path. `definition_json` carries the same envelope **minus** `secret`. The +auto-enqueuer recovers the plaintext server-side when it refreshes its +subscription cache. + +**Nothing about authoring changes.** `packages/spec/src/automation/webhook.zod.ts` +is untouched — `defineWebhook({ secret })` is written exactly as before, and the +delivered `X-Objectstack-Signature` is byte-identical, so no receiver has to +change anything. + +**Existing rows are migrated.** A boot sweep moves any cleartext +`definition_json.secret` into the encrypted column — including the rows the +seeder deliberately never rewrites (`managed_by: 'admin'`, and package rows an +admin froze with `customized: true`), which are the ones most likely to hold a +real production key. The sweep is idempotent and stores the encrypted copy in +the same update that strips the blob, so a failure can never leave a webhook +stripped *and* unsigned. Until a row is swept, signing keeps working from the +legacy blob and the enqueuer warns that the value is still exposed. + +**Fail-closed.** With no `ICryptoProvider` wired the engine refuses the write +rather than storing cleartext, so a secret-bearing webhook is skipped — and a +legacy row is left intact — with an actionable log line carrying an ADR-0112 +`code`/`status` pair. It is never seeded with an exposed key in a new column. + +Also adds `ObjectQL.resolveSecretField(object, recordId, field)` — the privileged, +driver-level dereference of one row's `secret`-typed field. `resolveSecret()` was +already documented for "privileged consumers … against the stored ref", but the +read mask meant no consumer could obtain that ref; this is why the webhook key +can live in the encrypted channel at all. It refuses any field not declared +`type: 'secret'`, so it cannot become a mask bypass over a `password` field +(plaintext at rest by design — ADR-0100). diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 9cd8bdbe88..20fc125b90 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -4837,6 +4837,58 @@ export class ObjectQL implements IObjectQLEngine { }); } + /** + * Privileged: recover the plaintext of ONE row's `secret`-typed field. + * + * {@link resolveSecret} is documented for "privileged consumers … against the + * stored ref", but until #7799 there was no supported way for such a consumer + * to OBTAIN that ref: {@link maskSecretFields} replaces it with + * {@link SECRET_MASK} on every `find`/`findOne`, unconditionally and after + * hooks. A server-side reader that genuinely needs the value therefore had + * two options, both bad — keep the credential in cleartext somewhere the read + * path does not mask (which is the defect #7799 reports on + * `sys_webhook.definition_json`), or reach around the engine into the driver + * from a plugin. This method is the supported third option, and it is why the + * webhook signing secret can now live in the encrypted channel at all. + * + * The row is read at DRIVER level on purpose — that is the only layer where + * the ref still exists — which means it bypasses read hooks, field-level + * security and sharing. That is the same trust `resolveSecret` already places + * in its caller, and the reason both are spelled as explicit, separately-named + * privileged verbs rather than an option on `find`: there is no query string + * that reaches this, so it cannot be turned on from outside the process. + * + * Refuses any field that is not declared `type: 'secret'`. Without that guard + * this would be a generic mask-bypass — in particular over a `password` field, + * which is stored as PLAINTEXT at rest and is masked for exactly that reason + * (ADR-0100). Only the encrypted channel is dereferenceable. + * + * Fail-closed like {@link resolveSecret}: throws when no CryptoProvider is + * registered or the `sys_secret` row has gone missing. Returns `null` when the + * row does not exist or the field holds no secret (never set, or cleared). + */ + async resolveSecretField( + object: string, + recordId: string, + field: string, + opts?: { tenantId?: string }, + ): Promise { + const schema = this._registry.getObject(object); + if (!collectSecretFields(schema).includes(field)) { + throw new Error( + `Cannot resolve secret field "${object}.${field}": it is not declared as type 'secret'. ` + + 'Only the encrypted secret channel is dereferenceable — a `password` field is stored as ' + + 'plaintext at rest and is masked deliberately (ADR-0100), so dereferencing one here ' + + 'would be a mask bypass, not a decrypt.', + ); + } + const driver = this.getDriver(object); + const found = await driver.find(object, { where: { id: recordId } }); + const row: any = Array.isArray(found) ? found[0] : found; + if (!row) return null; + return this.resolveSecret(row[field], opts); + } + /** * Helper to get object definition */ diff --git a/packages/objectql/src/secret-fields.test.ts b/packages/objectql/src/secret-fields.test.ts index 3de4916a99..aad8ec0f5e 100644 --- a/packages/objectql/src/secret-fields.test.ts +++ b/packages/objectql/src/secret-fields.test.ts @@ -33,15 +33,21 @@ function makeStubDriver() { } return true; }; + // Rows leave the driver as COPIES, like a real driver's do. Handing out the + // live stored object makes the double lie: `maskSecretFields` mutates the + // rows it is given, so one `find` would stamp SECRET_MASK over the stored ref + // and the next `resolveSecret*` would read the mask back as "no secret" — an + // artefact of the double that reads exactly like an engine bug (#7799). + const copy = (r: T): T => (r == null ? r : ({ ...r } as T)); const driver: any = { name: 'memory', version: '0.0.0', supports: {}, async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; }, async find(object: string, ast: any) { - return Array.from(storeFor(object).values()).filter((r) => matches(r, ast?.where)); + return Array.from(storeFor(object).values()).filter((r) => matches(r, ast?.where)).map(copy); }, async findOne(object: string, ast: any) { - for (const r of storeFor(object).values()) if (matches(r, ast?.where)) return r; + for (const r of storeFor(object).values()) if (matches(r, ast?.where)) return copy(r); return null; }, async create(object: string, data: Record) { @@ -49,7 +55,7 @@ function makeStubDriver() { const id = (data.id as string) ?? `r_${nextId}`; const row = { ...data, id }; storeFor(object).set(id, row); - return row; + return copy(row); }, async update(object: string, id: string, data: Record) { const s = storeFor(object); @@ -57,7 +63,7 @@ function makeStubDriver() { if (!cur) throw new Error(`not found: ${object}/${id}`); const updated = { ...cur, ...data, id }; s.set(id, updated); - return updated; + return copy(updated); }, async upsert(object: string, data: Record) { const id = data.id as string | undefined; @@ -204,6 +210,44 @@ describe('objectql secret-field channel', () => { expect(secretReads[0].ast.where).toEqual({ id: expect.any(String) }); }); + // [#7799] `resolveSecret` is documented for privileged consumers "against the + // stored ref" — but the read mask means no consumer can OBTAIN that ref, so + // the only ways to reach a stored credential were to keep a cleartext copy + // somewhere unmasked (the defect #7799 reports on `sys_webhook`) or to reach + // past the engine into the driver. This is the supported third way. + it("resolveSecretField recovers one row's plaintext without the caller ever seeing the ref", async () => { + const created = await ctx.engine.insert('ext_datasource', { name: 'pg', db_password: 's3cr3t' }); + + // The caller has ONLY what the generic read path gives it — the mask. + const viaFind = (await ctx.engine.find('ext_datasource', { where: { id: created.id } }))[0] as any; + expect(viaFind.db_password).toBe(SECRET_MASK); + + expect(await ctx.engine.resolveSecretField('ext_datasource', created.id, 'db_password')).toBe('s3cr3t'); + }); + + it('resolveSecretField returns null for an unset secret and for a row that is gone', async () => { + const created = await ctx.engine.insert('ext_datasource', { name: 'pg' }); + expect(await ctx.engine.resolveSecretField('ext_datasource', created.id, 'db_password')).toBeNull(); + expect(await ctx.engine.resolveSecretField('ext_datasource', 'nope', 'db_password')).toBeNull(); + }); + + it('resolveSecretField refuses a non-secret field — it is a decrypt, not a mask bypass', async () => { + const created = await ctx.engine.insert('ext_datasource', { name: 'pg', db_password: 's3cr3t' }); + // A plain column is not dereferenceable… + await expect( + ctx.engine.resolveSecretField('ext_datasource', created.id, 'name'), + ).rejects.toThrow(/not declared as type 'secret'/i); + + // …and neither is a `password` field, which is PLAINTEXT at rest and masked + // for exactly that reason (ADR-0100). Allowing it here would hand back the + // very value the mask exists to withhold. + const pw = await buildPasswordEngine(); + const device = await pw.engine.insert('device', { name: 'router', admin_password: 'hunter2' }); + await expect( + pw.engine.resolveSecretField('device', device.id, 'admin_password'), + ).rejects.toThrow(/not declared as type 'secret'/i); + }); + it('fail-closed: writing a secret field with no CryptoProvider throws', async () => { const bare = await buildEngine(false); await expect( diff --git a/packages/plugins/plugin-webhooks/package.json b/packages/plugins/plugin-webhooks/package.json index 9de2d40d16..63e155e965 100644 --- a/packages/plugins/plugin-webhooks/package.json +++ b/packages/plugins/plugin-webhooks/package.json @@ -30,6 +30,7 @@ }, "devDependencies": { "@objectstack/metadata-core": "workspace:*", + "@objectstack/objectql": "workspace:*", "@types/node": "^26.1.2", "typescript": "^6.0.3", "vitest": "^4.1.10" diff --git a/packages/plugins/plugin-webhooks/src/auto-enqueuer.ts b/packages/plugins/plugin-webhooks/src/auto-enqueuer.ts index f959dd56a2..47afd95c2a 100644 --- a/packages/plugins/plugin-webhooks/src/auto-enqueuer.ts +++ b/packages/plugins/plugin-webhooks/src/auto-enqueuer.ts @@ -3,6 +3,7 @@ import type { IDataEngine, IRealtimeService, RealtimeEventPayload } from '@objectstack/spec/contracts'; import type { WebhookTriggerType } from '@objectstack/spec/automation'; import type { EnqueueHttpInput } from '@objectstack/service-messaging'; +import { WEBHOOK_SECRET_FIELD, readLegacySecret, resolveWebhookSecret } from './webhook-secret.js'; /** * The authored trigger vocabulary, taken from the spec rather than restated @@ -201,6 +202,14 @@ export class AutoEnqueuer { for (const row of rows) { const sub = this.parseRow(row); if (!sub) continue; + // [#7799] The signing key is no longer in the row we just read — it + // lives encrypted in `sys_secret`, and this read path returns only a + // mask. Dereference it here, on the 60s refresh, rather than per + // event: the cache already holds the plaintext in memory (it always + // did), so this changes where the value comes FROM, not how long it + // is held. A row whose key cannot be recovered is DROPPED — see + // `attachSecret`. + if (!(await this.attachSecret(sub, row))) continue; // Empty objectName == "any object" → indexed under '*'. const key = sub.objectName ?? '*'; const arr = next.get(key) ?? []; @@ -217,6 +226,64 @@ export class AutoEnqueuer { }); } + /** + * [#7799] Resolve `sub.secret` for one cached subscription. Returns `false` + * when the subscription must be dropped from the cache. + * + * Three sources, in order: + * 1. `sys_webhook.signing_secret` — the encrypted column. The read path + * returns a mask, so presence is decidable here but the value is not; + * `resolveWebhookSecret` dereferences it server-side. + * 2. `definition_json.secret` — a row not yet swept by + * `migrateLegacyWebhookSecrets` (or hand-edited back in). Still honoured + * so an un-migrated deployment keeps signing, and warned about once per + * refresh so the exposure is visible rather than silently permanent. + * 3. Neither — an unsigned webhook, which is a legitimate authored choice + * (`secret` is optional on the envelope). + * + * A stored-but-unresolvable key DROPS the subscription instead of + * delivering unsigned. The signature is the receiver's only proof of + * origin (#7722, #7799): a webhook that stops arriving is visible and gets + * investigated, while one that keeps arriving unsigned is invisible and + * teaches the receiver to accept unauthenticated traffic. + * + * Cost: one point read + one decrypt per secret-bearing row per refresh + * (default 60s), off the write path entirely. Deliberately NOT memoised + * across refreshes — the only cheap cache key would be `updated_at`, which + * nothing guarantees is stamped when a secret is rotated, and a stale key + * signs every delivery with a signature the receiver rejects. + */ + private async attachSecret(sub: CachedSubscription, row: any): Promise { + try { + const stored = await resolveWebhookSecret(this.engine, row, this.subscriptionsObject); + if (stored) { + sub.secret = stored; + return true; + } + } catch (err) { + this.logger.warn?.( + `[webhook-auto-enqueuer] webhook '${sub.name}' holds an encrypted signing secret that ` + + `could not be decrypted — the subscription is DROPPED rather than delivered unsigned ` + + `(#7799). Deliveries resume once the sys_secret row and CryptoProvider are reachable.`, + { id: sub.id, field: WEBHOOK_SECRET_FIELD, err: (err as Error)?.message ?? err }, + ); + return false; + } + + const legacy = readLegacySecret(row?.definition_json); + if (legacy) { + this.logger.warn?.( + `[webhook-auto-enqueuer] webhook '${sub.name}' still carries its signing secret as ` + + `CLEARTEXT in definition_json, readable over the data API (#7799). Signing continues ` + + `from it; run the boot sweep (migrateLegacyWebhookSecrets) with a CryptoProvider wired ` + + `to move it into sys_secret.`, + { id: sub.id }, + ); + sub.secret = legacy; + } + return true; + } + private parseRow(row: any): CachedSubscription | null { if (!row?.id || !row?.url) return null; // `triggers` is now authored as a multi-select (stored as an array), but @@ -283,8 +350,9 @@ export class AutoEnqueuer { } // The "definition_json" field carries advanced config (headers, - // secret, timeout); attempt a best-effort parse. Fall back to - // top-level fields where present. + // timeout); attempt a best-effort parse. Fall back to top-level fields + // where present. It no longer carries the signing secret (#7799) — + // `attachSecret` sources that from the encrypted column. let defn: Record = {}; if (typeof row.definition_json === 'string' && row.definition_json.length > 0) { try { @@ -306,7 +374,8 @@ export class AutoEnqueuer { // the select change (legacy rows stored 'POST'). method: String(row.method ?? defn.method ?? 'POST').toUpperCase(), headers: defn.headers, - secret: defn.secret, + // `secret` is filled by attachSecret() from the encrypted column, + // NOT read off the row — see #7799. timeoutMs: defn.timeoutMs, }; } diff --git a/packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.test.ts b/packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.test.ts index 4efc769730..4838930f0e 100644 --- a/packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.test.ts +++ b/packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.test.ts @@ -275,8 +275,12 @@ describe('bootstrapDeclaredWebhooks', () => { expect(calls).toHaveLength(1); expect(calls[0].url).toBe('https://hooks.example/task'); - // headers + secret came from the definition_json envelope the bridge wrote. + // Headers come from the definition_json envelope the bridge wrote. The + // signing secret does NOT (#7799) — it goes to the `signing_secret` column, + // which this fake engine (no encrypted-field channel) stores verbatim, so + // the enqueuer reads it back from there. expect(calls[0].signingSecret).toBe('shh'); + expect(engine.rows['sys_webhook'][0].definition_json).not.toContain('shh'); expect(calls[0].headers).toEqual({ 'X-Env': 'prod' }); await ae.stop(); }); diff --git a/packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.ts b/packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.ts index b95ccd7447..e4e857ea33 100644 --- a/packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.ts +++ b/packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.ts @@ -17,12 +17,27 @@ * * ## Shape translation (authoring → runtime row) * The spec shape diverges from the runtime column names; we map only at this - * boundary and stash the full validated envelope in `definition_json` (whence - * the enqueuer reads headers / secret / timeout): + * boundary and stash the validated envelope in `definition_json` (whence the + * enqueuer reads headers / timeout): * - `object` → `object_name` * - `isActive` → `active` * - `triggers` / `url` / `method` / `label` / `description` → same-named columns - * - the entire parsed {@link Webhook} → `definition_json` (JSON string) + * - `secret` → `signing_secret` (ENCRYPTED — see below) + * - the rest of the parsed {@link Webhook} → `definition_json` (JSON string) + * + * ## The secret does NOT go in `definition_json` (#7799) + * It used to: `definition_json: JSON.stringify(wh)` serialized the whole + * envelope, key included, into an ordinary textarea on an admin-authorable + * object — so `GET /api/v1/data/sys_webhook` returned the receiver's only proof + * of origin to anyone who could read the object. The authored key now goes to + * `sys_webhook.signing_secret`, a `type: 'secret'` column the engine encrypts + * into `sys_secret` and masks on read; `definition_json` carries the same + * envelope MINUS `secret`. Nothing about the authoring surface changes — + * `webhook.zod.ts` still declares `secret` and authors still write it. + * + * Fail-closed: with no CryptoProvider registered the engine REFUSES the write + * (it will not store cleartext), so a secret-bearing webhook is skipped with an + * actionable log line instead of being seeded with an exposed key. * * Each item is validated through `WebhookSchema.parse()` first — this gives the * spec schema a real consumer (defaults for `method`/`isActive`/`timeoutMs` get @@ -43,6 +58,14 @@ import type { IDataEngine } from '@objectstack/spec/contracts'; import { WebhookSchema, type Webhook } from '@objectstack/spec/automation'; +import { + WEBHOOK_SECRET_FIELD, + WEBHOOK_SECRET_REFUSAL_CODE, + WEBHOOK_SECRET_REFUSAL_STATUS, + canResolveSecrets, + isSecretProtectionFailure, + splitWebhookSecret, +} from './webhook-secret.js'; /** System write context — the boot seeder is not an admin authoring action. */ const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const; @@ -146,6 +169,7 @@ export async function bootstrapDeclaredWebhooks( const patch = { id: row.id, ...mapWebhookToRow(wh), + ...(await secretPatch(engine, wh, row, subscriptionsObject)), // Adopt pristine/legacy (pre-provenance) rows so future boots // recognize them as package-managed. managed_by: 'package', @@ -156,9 +180,15 @@ export async function bootstrapDeclaredWebhooks( continue; } + const { secret } = splitWebhookSecret(wh as Record); const newRow = { id: uid('whk'), ...mapWebhookToRow(wh), + // Cleartext goes in exactly once, into the `secret`-typed column; the + // engine's write path wraps it into `sys_secret` and leaves an opaque + // ref behind. Omitted entirely when unauthored, so a webhook with no + // secret costs no crypto and needs no CryptoProvider. + ...(secret ? { [WEBHOOK_SECRET_FIELD]: secret } : {}), managed_by: 'package', customized: false, created_at: now, @@ -167,10 +197,23 @@ export async function bootstrapDeclaredWebhooks( await engine.insert(subscriptionsObject, newRow, { context: SYSTEM_CTX } as any); seeded += 1; } catch (err: any) { - logger?.warn?.('[webhook] declared webhook seed failed', { - name: wh.name, - error: err?.message ?? String(err), - }); + // [#7799] The engine refuses to persist a `secret` field with no + // CryptoProvider wired, rather than falling back to cleartext. Say so in + // those words — "seed failed" would read as a transient glitch, when what + // actually happened is that the deployment has nowhere safe to put a key. + const protection = isSecretProtectionFailure(err); + logger?.warn?.( + protection + ? '[webhook] declared webhook NOT seeded — its signing secret cannot be stored encrypted (#7799)' + : '[webhook] declared webhook seed failed', + { + name: wh.name, + ...(protection + ? { code: WEBHOOK_SECRET_REFUSAL_CODE, status: WEBHOOK_SECRET_REFUSAL_STATUS } + : {}), + error: err?.message ?? String(err), + }, + ); skipped += 1; } } @@ -183,12 +226,56 @@ export async function bootstrapDeclaredWebhooks( return { seeded, skipped }; } +/** + * Decide what a RE-SEED should do with an existing row's `signing_secret`. + * + * Re-seeding runs on every boot, and a `secret`-typed write always mints a + * fresh `sys_secret` ciphertext row — so blindly restating the declared key + * would leak one orphan cipher row per webhook per restart. Compare against the + * stored plaintext first (via the engine's privileged dereference) and write + * only on an actual change: + * + * - declared key differs from stored ⇒ write it (rotation in code propagates, + * exactly as it did when the whole envelope was rewritten every boot); + * - identical ⇒ omit the key entirely, leaving the existing ref untouched; + * - declared key removed, row still holds one ⇒ write `null` to CLEAR it + * (code remains the authority for package rows); + * - engine cannot dereference (older engine, or the compare threw) ⇒ fall back + * to writing the declared value. Correct signatures beat tidy storage. + */ +async function secretPatch( + engine: IDataEngine, + wh: Webhook, + row: any, + subscriptionsObject: string, +): Promise> { + const { secret } = splitWebhookSecret(wh as Record); + const hasStored = row?.[WEBHOOK_SECRET_FIELD] != null && row[WEBHOOK_SECRET_FIELD] !== ''; + + if (!secret) return hasStored ? { [WEBHOOK_SECRET_FIELD]: null } : {}; + if (!hasStored || !canResolveSecrets(engine)) return { [WEBHOOK_SECRET_FIELD]: secret }; + + try { + const current = await (engine as any).resolveSecretField( + subscriptionsObject, + String(row.id), + WEBHOOK_SECRET_FIELD, + ); + return current === secret ? {} : { [WEBHOOK_SECRET_FIELD]: secret }; + } catch { + return { [WEBHOOK_SECRET_FIELD]: secret }; + } +} + /** * Translate a validated {@link Webhook} into `sys_webhook` column values. - * `object → object_name`, `isActive → active`; the full envelope is stashed in - * `definition_json` for the enqueuer's advanced-config read (headers/secret/…). + * `object → object_name`, `isActive → active`; the envelope MINUS its `secret` + * is stashed in `definition_json` for the enqueuer's advanced-config read + * (headers/timeout). The key itself is written separately, into the encrypted + * `signing_secret` column — see the module header and #7799. */ function mapWebhookToRow(wh: Webhook): Record { + const { envelope } = splitWebhookSecret(wh as Record); return { name: wh.name, label: wh.label ?? wh.name, @@ -200,6 +287,6 @@ function mapWebhookToRow(wh: Webhook): Record { method: String(wh.method ?? 'POST').toLowerCase(), description: wh.description ?? null, active: wh.isActive !== false, - definition_json: JSON.stringify(wh), + definition_json: JSON.stringify(envelope), }; } diff --git a/packages/plugins/plugin-webhooks/src/index.ts b/packages/plugins/plugin-webhooks/src/index.ts index 988d0d9306..e0a6dc1d00 100644 --- a/packages/plugins/plugin-webhooks/src/index.ts +++ b/packages/plugins/plugin-webhooks/src/index.ts @@ -27,3 +27,14 @@ export { export { AutoEnqueuer, type AutoEnqueuerOptions, type HttpEnqueueFn } from './auto-enqueuer.js'; export { SysWebhook } from './sys-webhook.object.js'; + +/** + * [#7799] The signing-secret seam. Exported so a host that boots the pieces + * itself (rather than mounting {@link WebhookOutboxPlugin}) can still run the + * cleartext sweep, and so the column name has one spelling. + */ +export { WEBHOOK_SECRET_FIELD } from './webhook-secret.js'; +export { + migrateLegacyWebhookSecrets, + type MigrateWebhookSecretsResult, +} from './migrate-webhook-secrets.js'; diff --git a/packages/plugins/plugin-webhooks/src/migrate-webhook-secrets.ts b/packages/plugins/plugin-webhooks/src/migrate-webhook-secrets.ts new file mode 100644 index 0000000000..8eb27438e4 --- /dev/null +++ b/packages/plugins/plugin-webhooks/src/migrate-webhook-secrets.ts @@ -0,0 +1,133 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#7799] One-shot boot sweep that moves already-persisted cleartext signing + * secrets out of `sys_webhook.definition_json` and into the encrypted + * `signing_secret` column. + * + * ## Why a sweep and not just the seeder + * `bootstrapDeclaredWebhooks` re-seeds package-declared rows on every boot, so + * those heal themselves the moment the new mapping lands. The rows that do NOT + * heal are exactly the ones most likely to hold a real production key: + * + * - `managed_by: 'admin'` — authored in Setup, never touched by the seeder; + * - `customized: true` — a package row an admin edited, deliberately frozen + * against re-seeding (seed-not-clobber, #3461 / #2909). + * + * Leaving those behind would make this a half-migration: the code path that + * created the exposure would be fixed while the exposed values stayed in the + * table. So the sweep is keyed off the DATA (does this blob contain a secret?), + * not off provenance. + * + * ## What it is careful about + * - **System context.** The provenance hook exempts `isSystem` writes, so + * migrating a package row does not stamp `customized: true` and freeze it + * against future seeding. + * - **Idempotent.** A row whose blob no longer carries a `secret` is skipped, + * so the sweep is free on every boot after the first. + * - **Fail-closed, per row.** With no CryptoProvider the encrypted write throws + * and the row is LEFT AS IT WAS — still exposed, but intact and still + * signing. It is reported with an ADR-0112 `code`/`status` pair so an + * operator can see exactly which rows are still cleartext and why, rather + * than the sweep quietly reporting success. + * - **Never widens the blast radius.** The cleartext is only removed from + * `definition_json` in the SAME update that stores the encrypted copy; a + * failure cannot land the strip without the store. + */ + +import type { IDataEngine } from '@objectstack/spec/contracts'; +import type { EngineQueryOptions } from '@objectstack/spec/data'; +import { + WEBHOOK_OBJECT, + WEBHOOK_SECRET_FIELD, + WEBHOOK_SECRET_REFUSAL_CODE, + WEBHOOK_SECRET_REFUSAL_STATUS, + isSecretProtectionFailure, + readLegacySecret, + stripSecretFromDefinition, +} from './webhook-secret.js'; + +/** System write context — a boot reconciler is not an admin authoring action. */ +const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const; + +/** The read side of the same context, typed so `tsc` still checks the keys. */ +const SYSTEM_QUERY: EngineQueryOptions = { + context: { isSystem: true, positions: [], permissions: [] }, +}; + +interface Logger { + info?: (msg: string, meta?: unknown) => void; + warn?: (msg: string, meta?: unknown) => void; +} + +export interface MigrateWebhookSecretsResult { + /** Rows whose blob carried a cleartext secret. */ + found: number; + /** Rows now holding an encrypted secret and a secret-free blob. */ + migrated: number; + /** Rows still holding cleartext because the encrypted write was refused. */ + failed: number; +} + +/** + * Move every cleartext `definition_json.secret` into the encrypted column. + * Safe to run on every boot; returns counts for the caller to log. + */ +export async function migrateLegacyWebhookSecrets( + engine: IDataEngine, + logger?: Logger, + subscriptionsObject: string = WEBHOOK_OBJECT, +): Promise { + const out: MigrateWebhookSecretsResult = { found: 0, migrated: 0, failed: 0 }; + + let rows: any[]; + try { + const found = await engine.find(subscriptionsObject, SYSTEM_QUERY); + rows = Array.isArray(found) ? found : ((found as any)?.data ?? []); + } catch (err: any) { + logger?.warn?.('[webhook] legacy secret sweep skipped — could not read subscriptions', { + object: subscriptionsObject, + error: err?.message ?? String(err), + }); + return out; + } + + for (const row of rows) { + const legacy = readLegacySecret(row?.definition_json); + if (!legacy || !row?.id) continue; + out.found += 1; + + try { + await engine.update( + subscriptionsObject, + { + id: row.id, + [WEBHOOK_SECRET_FIELD]: legacy, + definition_json: stripSecretFromDefinition(row.definition_json as string), + }, + { context: SYSTEM_CTX } as any, + ); + out.migrated += 1; + } catch (err: any) { + out.failed += 1; + const protection = isSecretProtectionFailure(err); + logger?.warn?.( + protection + ? '[webhook] signing secret STILL CLEARTEXT in definition_json — no CryptoProvider to encrypt it (#7799)' + : '[webhook] signing secret migration failed — row left unchanged (#7799)', + { + name: row.name ?? row.id, + id: row.id, + code: WEBHOOK_SECRET_REFUSAL_CODE, + status: WEBHOOK_SECRET_REFUSAL_STATUS, + error: err?.message ?? String(err), + }, + ); + } + } + + if (out.found > 0) { + logger?.info?.('[webhook] legacy cleartext signing secrets swept into sys_secret', { ...out }); + } + return out; +} diff --git a/packages/plugins/plugin-webhooks/src/sys-webhook.object.ts b/packages/plugins/plugin-webhooks/src/sys-webhook.object.ts index 56179dd1a2..15531a3bd4 100644 --- a/packages/plugins/plugin-webhooks/src/sys-webhook.object.ts +++ b/packages/plugins/plugin-webhooks/src/sys-webhook.object.ts @@ -181,7 +181,39 @@ export const SysWebhook = ObjectSchema.create({ definition_json: Field.textarea({ label: 'Definition', required: true, - description: 'Serialised Webhook JSON (see @objectstack/spec/automation/webhook) — full headers/auth/retry/payload config', + description: 'Serialised Webhook JSON (see @objectstack/spec/automation/webhook) — headers/timeout config. The signing secret is NOT stored here; it lives in the encrypted `signing_secret` field.', + group: 'Definition', + }), + + /** + * [#7799] HMAC signing key, in the engine's ENCRYPTED credential channel. + * + * It used to ride inside `definition_json` as cleartext, because that column + * is where the seeder parked the whole authored envelope. `definition_json` + * is an ordinary textarea on an admin-authorable object with no restrictive + * `enable.apiMethods`, so `GET /api/v1/data/sys_webhook` handed the key back + * to every persona that can read the object — and that key is the receiver's + * ONLY proof a delivery came from us. Same exposure class as #7722's + * per-attempt copies, minus the retention window that eventually aged those + * out. + * + * `type: 'secret'` moves it onto the channel built for this: the engine + * encrypts on write via the registered `ICryptoProvider`, stores the + * ciphertext as a `sys_secret` row, keeps only an opaque `secret:` ref + * on this column, and returns the mask on every read path. The enqueuer + * recovers the plaintext server-side through `engine.resolveSecretField()` + * when it refreshes its subscription cache. + * + * Fail-closed by construction: with no CryptoProvider wired the engine + * REFUSES the write rather than falling back to cleartext, so the seeder + * skips that webhook loudly instead of re-opening the hole in a new column. + */ + signing_secret: Field.secret({ + label: 'Signing Secret', + required: false, + description: + 'HMAC-SHA256 key used to sign deliveries (X-Objectstack-Signature). Encrypted at rest into ' + + 'sys_secret; reads return a mask, never the key. Leave the mask untouched to keep the current value.', group: 'Definition', }), diff --git a/packages/plugins/plugin-webhooks/src/translations/en.objects.generated.ts b/packages/plugins/plugin-webhooks/src/translations/en.objects.generated.ts index 1f51af055b..75b291cab8 100644 --- a/packages/plugins/plugin-webhooks/src/translations/en.objects.generated.ts +++ b/packages/plugins/plugin-webhooks/src/translations/en.objects.generated.ts @@ -65,6 +65,10 @@ export const enObjects: NonNullable = { label: "Definition", help: "Serialised Webhook JSON (see @objectstack/spec/automation/webhook) — full headers/auth/retry/payload config" }, + signing_secret: { + label: "Signing Secret", + help: "HMAC-SHA256 key used to sign deliveries (X-Objectstack-Signature). Encrypted at rest into sys_secret; reads return a mask, never the key. Leave the mask untouched to keep the current value." + }, managed_by: { label: "Managed By", help: "Record provenance: platform = framework built-in / package = app/package-declared (boot-seeded from defineStack webhooks) / admin = created in Setup.", diff --git a/packages/plugins/plugin-webhooks/src/translations/es-ES.objects.generated.ts b/packages/plugins/plugin-webhooks/src/translations/es-ES.objects.generated.ts index b1092f7318..a89c62e52a 100644 --- a/packages/plugins/plugin-webhooks/src/translations/es-ES.objects.generated.ts +++ b/packages/plugins/plugin-webhooks/src/translations/es-ES.objects.generated.ts @@ -65,6 +65,10 @@ export const esESObjects: NonNullable = { label: "Definición", help: "JSON serializado de Webhook (consulte @objectstack/spec/automation/webhook): configuración completa de cabeceras/auth/reintentos/payload." }, + signing_secret: { + label: "Secreto de firma", + help: "Clave HMAC-SHA256 usada para firmar las entregas (X-Objectstack-Signature). Se cifra en reposo en sys_secret; las lecturas devuelven una máscara, nunca la clave. Deje la máscara sin tocar para conservar el valor actual." + }, managed_by: { label: "Gestionado por", help: "Procedencia del registro: platform = integrado en el framework / package = declarado por app o paquete (sembrado al arrancar desde los webhooks de defineStack) / admin = creado en Setup.", diff --git a/packages/plugins/plugin-webhooks/src/translations/ja-JP.objects.generated.ts b/packages/plugins/plugin-webhooks/src/translations/ja-JP.objects.generated.ts index 72365f80f2..0c8030e234 100644 --- a/packages/plugins/plugin-webhooks/src/translations/ja-JP.objects.generated.ts +++ b/packages/plugins/plugin-webhooks/src/translations/ja-JP.objects.generated.ts @@ -65,6 +65,10 @@ export const jaJPObjects: NonNullable = { label: "定義", help: "シリアライズされた Webhook JSON(@objectstack/spec/automation/webhook 参照)— ヘッダー/認証/リトライ/ペイロード設定を含む" }, + signing_secret: { + label: "署名シークレット", + help: "配信の署名に使う HMAC-SHA256 キー(X-Objectstack-Signature)。保存時は sys_secret に暗号化され、読み取りではマスクのみが返りキーは返りません。マスクをそのままにすると現在の値が維持されます。" + }, managed_by: { label: "管理元", help: "レコードの出所: platform = フレームワーク組み込み / package = アプリ・パッケージ宣言(defineStack の webhooks から起動時シード)/ admin = Setup で作成。", diff --git a/packages/plugins/plugin-webhooks/src/translations/zh-CN.objects.generated.ts b/packages/plugins/plugin-webhooks/src/translations/zh-CN.objects.generated.ts index 639fc6aae8..40d656bac7 100644 --- a/packages/plugins/plugin-webhooks/src/translations/zh-CN.objects.generated.ts +++ b/packages/plugins/plugin-webhooks/src/translations/zh-CN.objects.generated.ts @@ -65,6 +65,10 @@ export const zhCNObjects: NonNullable = { label: "定义", help: "序列化的 Webhook JSON(参见 @objectstack/spec/automation/webhook)——包含完整的 headers/auth/retry/payload 配置" }, + signing_secret: { + label: "签名密钥", + help: "用于对投递请求签名的 HMAC-SHA256 密钥(X-Objectstack-Signature)。静态存储时加密写入 sys_secret;读取只返回掩码,绝不返回密钥。保持掩码不变即保留当前值。" + }, managed_by: { label: "管理来源", help: "记录来源:platform = 框架内置 / package = 应用包声明(由 defineStack 的 webhooks 在启动时种入)/ admin = 在 Setup 中创建。", diff --git a/packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts b/packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts index 8f117f3c57..2ed3f25015 100644 --- a/packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts +++ b/packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts @@ -11,6 +11,7 @@ import type { EnqueueHttpInput } from '@objectstack/service-messaging'; import { AutoEnqueuer, type AutoEnqueuerOptions } from './auto-enqueuer.js'; import { SysWebhook } from './sys-webhook.object.js'; import { bootstrapDeclaredWebhooks } from './bootstrap-declared-webhooks.js'; +import { migrateLegacyWebhookSecrets } from './migrate-webhook-secrets.js'; import { bindWebhookProvenanceStamp, unbindWebhookProvenanceStamp } from './webhook-provenance.js'; /** @@ -195,6 +196,19 @@ export class WebhookOutboxPlugin implements Plugin { error: err?.message ?? String(err), }); } + // [#7799] Then heal the rows the seeder cannot touch. Package rows are + // rewritten above; `managed_by: 'admin'` and `customized: true` rows are + // deliberately frozen against re-seeding, and those are precisely the + // ones holding hand-authored production keys in cleartext. Runs AFTER + // the seeder so a row it just rewrote is already secret-free and the + // sweep is a no-op on it. + try { + await migrateLegacyWebhookSecrets(engine, ctx.logger as any); + } catch (err: any) { + ctx.logger.warn?.('[webhook] legacy signing-secret sweep failed (rows left unchanged)', { + error: err?.message ?? String(err), + }); + } } private async bootAutoEnqueue( diff --git a/packages/plugins/plugin-webhooks/src/webhook-secret-at-rest.test.ts b/packages/plugins/plugin-webhooks/src/webhook-secret-at-rest.test.ts new file mode 100644 index 0000000000..e8ce1f9a3d --- /dev/null +++ b/packages/plugins/plugin-webhooks/src/webhook-secret-at-rest.test.ts @@ -0,0 +1,457 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #7799 — the subscriber's signing secret must not be recoverable from + * `sys_webhook`, and signing must keep producing the byte-identical signature + * existing receivers already verify. + * + * These run against a REAL {@link ObjectQL} engine with the REAL + * {@link SysWebhook} schema registered, not an engine fake, because the whole + * claim is about what the engine's own write and read paths do with a + * `type: 'secret'` field: a fake that echoes back whatever it was handed would + * pass every assertion here while the product stayed broken. Only the DRIVER is + * a double (equality-only WHERE, in-memory maps), which also gives the at-rest + * scan something to look at — `stores` holds exactly the bytes a real table + * would. + * + * Sibling coverage: `webhook-signing-secret.test.ts` (#7722) pins the same wire + * signature for the delivery-row half. + */ + +import { createHmac, randomUUID } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import { ObjectQL, SECRET_MASK, SECRET_REF_PREFIX } from '@objectstack/objectql'; +import { DataEventSchema } from '@objectstack/spec/api'; +import type { + IRealtimeService, + RealtimeEventHandler, + RealtimeEventPayload, + ICryptoProvider, + CryptoHandle, + CryptoContext, +} from '@objectstack/spec/contracts'; +import { MemoryHttpOutbox, HttpDispatcher, type FetchImpl } from '@objectstack/service-messaging'; +import { AutoEnqueuer } from './auto-enqueuer.js'; +import { bootstrapDeclaredWebhooks } from './bootstrap-declared-webhooks.js'; +import { migrateLegacyWebhookSecrets } from './migrate-webhook-secrets.js'; +import { SysWebhook } from './sys-webhook.object.js'; +import { WEBHOOK_SECRET_FIELD, __objectqlSecretWireForms } from './webhook-secret.js'; + +const SECRET = 'whsec_7799_subscriber_key'; +const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const; + +// --------------------------------------------------------------------------- +// Doubles: driver (in-memory), crypto provider (reversible base64), realtime +// --------------------------------------------------------------------------- + +/** + * Driver-shaped double — `update(object, id, data)`, primary key SECOND. This + * is an `IDataDriver`, not an `IDataEngine`, so the engine's own dispatch + * contract still runs above it (see `check:engine-double-contract`). + */ +function makeStubDriver() { + const stores = new Map>>(); + const storeFor = (obj: string) => { + let s = stores.get(obj); + if (!s) { s = new Map(); stores.set(obj, s); } + return s; + }; + let nextId = 0; + const matches = (row: any, where: any): boolean => { + if (!where || typeof where !== 'object') return true; + for (const [k, v] of Object.entries(where)) { + if (k.startsWith('$')) continue; + const expected = (v && typeof v === 'object' && '$eq' in (v as any)) ? (v as any).$eq : v; + if ((row[k] ?? null) !== (expected ?? null)) return false; + } + return true; + }; + // Rows leave the driver as COPIES, exactly as a real driver's do. Handing + // out the live stored object would make this file lie in the one direction + // it must not: `maskSecretFields` mutates the rows it is given, so a shared + // reference would stamp the mask onto the "at rest" bytes the byte-scan + // reads, and the very same read would destroy the ref the signing path + // needs. Both would look like product bugs. + const copy = (r: T): T => (r == null ? r : ({ ...r } as T)); + const driver: any = { + name: 'memory', version: '0.0.0', supports: {}, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, + async execute() { return null; }, + async find(object: string, ast: any) { + return Array.from(storeFor(object).values()).filter((r) => matches(r, ast?.where)).map(copy); + }, + async findOne(object: string, ast: any) { + for (const r of storeFor(object).values()) if (matches(r, ast?.where)) return copy(r); + return null; + }, + async create(object: string, data: Record) { + nextId += 1; + const id = (data.id as string) ?? `r_${nextId}`; + const row = { ...data, id }; + storeFor(object).set(id, row); + return copy(row); + }, + async update(object: string, id: string, data: Record) { + const s = storeFor(object); + const cur = s.get(id); + if (!cur) throw new Error(`not found: ${object}/${id}`); + const updated = { ...cur, ...data, id }; + s.set(id, updated); + return copy(updated); + }, + async upsert(object: string, data: Record) { + const id = data.id as string | undefined; + if (id && storeFor(object).has(id)) return this.update(object, id, data); + return this.create(object, data); + }, + async delete(object: string, id: string) { return storeFor(object).delete(id); }, + async count(object: string, ast: any) { return (await this.find(object, ast)).length; }, + async bulkCreate(object: string, rows: Record[]) { + return Promise.all(rows.map((r) => this.create(object, r))); + }, + async bulkUpdate() { return []; }, + async bulkDelete() {}, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; + return { driver, stores }; +} + +/** + * Reversible test crypto. Base64 is NOT encryption — the point is only that the + * stored form is a TRANSFORM of the plaintext, so a byte-scan for the raw key + * cannot pass by accident on a value that merely looks scrambled. + */ +function makeFakeCrypto() { + let n = 0; + const provider: ICryptoProvider = { + async encrypt(plain: string, _ctx: CryptoContext): Promise { + n += 1; + return { + id: `sec_${n}`, kmsKeyId: 'local', alg: 'test-b64', version: 1, + ciphertext: Buffer.from(plain, 'utf8').toString('base64'), + }; + }, + async decrypt(handle: CryptoHandle, _ctx: CryptoContext): Promise { + return Buffer.from(handle.ciphertext, 'base64').toString('utf8'); + }, + async rotateKey(handle: CryptoHandle): Promise { + return { ...handle, version: handle.version + 1 }; + }, + digest(plain: string): string { return `d:${plain.length}`; }, + }; + return provider; +} + +class FakeRealtime implements IRealtimeService { + private subs = new Map(); + private n = 0; + async publish(event: RealtimeEventPayload): Promise { + for (const sub of this.subs.values()) { + const o = sub.opts ?? {}; + if (o.object && event.object !== o.object) continue; + await sub.handler(event); + } + } + async subscribe(_channel: string, handler: any, opts?: any): Promise { + const id = `s-${++this.n}`; + this.subs.set(id, { handler, opts }); + return id; + } + async unsubscribe(id: string): Promise { this.subs.delete(id); } +} + +/** Minimal `sys_secret` — the cipher store the `secret` channel writes into. */ +const sysSecretObject = { + name: 'sys_secret', label: 'Secret', + fields: { + id: { name: 'id', label: 'ID', type: 'text' as const }, + namespace: { name: 'namespace', label: 'Namespace', type: 'text' as const }, + key: { name: 'key', label: 'Key', type: 'text' as const }, + kms_key_id: { name: 'kms_key_id', label: 'KMS', type: 'text' as const }, + alg: { name: 'alg', label: 'Alg', type: 'text' as const }, + version: { name: 'version', label: 'Version', type: 'number' as const }, + ciphertext: { name: 'ciphertext', label: 'Ciphertext', type: 'text' as const }, + created_at: { name: 'created_at', label: 'Created', type: 'datetime' as const }, + }, +}; + +async function buildEngine(opts: { withCrypto?: boolean } = {}) { + const engine = new ObjectQL(); + const { driver, stores } = makeStubDriver(); + engine.registerDriver(driver, true); + await engine.init(); + engine.registry.registerObject(sysSecretObject as any, 'test'); + engine.registry.registerObject(SysWebhook as any, 'test'); + if (opts.withCrypto !== false) engine.setCryptoProvider(makeFakeCrypto()); + return { engine, stores, driver }; +} + +/** A declared webhook, as `defineStack({ webhooks })` would author it. */ +function declaredWebhook(overrides: Record = {}) { + return { + name: 'crm_hook', + object: 'contact', + triggers: ['create'], + url: 'https://receiver.example/hook', + method: 'POST', + headers: { 'X-Team': 'crm' }, + secret: SECRET, + ...overrides, + }; +} + +/** Metadata service that hands the seeder its declared items. */ +function metadataWith(items: unknown[]) { + return { list: (type: string) => (type === 'webhook' ? items : []) }; +} + +function recordEvent(object: string, record: any): RealtimeEventPayload { + const payload = DataEventSchema.parse({ + id: randomUUID(), + type: 'data.record.created', + object, + recordId: String(record.id), + after: record, + timestamp: '2026-08-12T00:00:00.000Z', + }); + return { type: payload.type, object, payload: { ...payload }, timestamp: payload.timestamp }; +} + +function makeFetch() { + const calls: Array<{ headers: Record; body: string }> = []; + const impl: FetchImpl = async (_url, init) => { + calls.push({ headers: init.headers, body: init.body }); + return { ok: true, status: 200, async text() { return 'ok'; } }; + }; + return { impl, calls }; +} + +/** Drive one create event all the way to the wire; return what the receiver saw. */ +async function deliverOnce(engine: any) { + const realtime = new FakeRealtime(); + const outbox = new MemoryHttpOutbox(); + const enqueuer = new AutoEnqueuer(engine, realtime, (input) => outbox.enqueue(input)); + await enqueuer.start(); + await realtime.publish(recordEvent('contact', { id: 'c1', name: 'Ada' })); + // The enqueue is deliberately fire-and-forget on the hot path. + await new Promise((r) => setTimeout(r, 0)); + const { impl, calls } = makeFetch(); + await new HttpDispatcher({ nodeId: 'n1', outbox, fetchImpl: impl, partitionCount: 1 }).tick(); + await enqueuer.stop(); + return { calls, outbox }; +} + +// --------------------------------------------------------------------------- + +describe('webhook signing secret at rest (#7799)', () => { + // `webhook-secret.ts` restates objectql's two opaque wire forms because this + // package takes no dependency on objectql. Pin them: if either is renamed + // there, the plugin would stop recognising an unrecoverable value and could + // hand the MASK to the HMAC — every delivery silently rejected downstream. + it('the locally-restated objectql secret wire forms still match objectql', () => { + expect(__objectqlSecretWireForms.mask).toBe(SECRET_MASK); + expect(__objectqlSecretWireForms.refPrefix).toBe(SECRET_REF_PREFIX); + }); + + it('the secret\'s bytes appear nowhere in the persisted sys_webhook row', async () => { + const { engine, stores } = await buildEngine(); + + await bootstrapDeclaredWebhooks(engine, metadataWith([declaredWebhook()])); + + // ── The read path an ordinary GET /api/v1/data/sys_webhook takes ── + // Substring, not a field check: the defect was the key NESTED inside a + // serialized blob, which any per-field assertion walks straight past. + const viaApi = await engine.find('sys_webhook', { where: { name: 'crm_hook' } }); + expect(viaApi).toHaveLength(1); + expect(JSON.stringify(viaApi)).not.toContain(SECRET); + expect(String(viaApi[0].definition_json)).not.toContain(SECRET); + + // ── And at rest, in the bytes the table itself holds ── + const stored = Array.from(stores.get('sys_webhook')!.values())[0] as any; + expect(JSON.stringify(stored)).not.toContain(SECRET); + expect(String(stored.definition_json)).not.toContain(SECRET); + // The row keeps an opaque handle, and the cipher store holds a transform. + expect(String(stored[WEBHOOK_SECRET_FIELD])).toMatch(/^secret:/); + const ciphered = Array.from(stores.get('sys_secret')!.values()) as any[]; + expect(ciphered).toHaveLength(1); + expect(ciphered[0].ciphertext).not.toContain(SECRET); + + // The rest of the envelope survives — this is a strip, not a truncation. + expect(JSON.parse(String(stored.definition_json))).toMatchObject({ + name: 'crm_hook', + url: 'https://receiver.example/hook', + headers: { 'X-Team': 'crm' }, + }); + }); + + it('signs with the byte-identical signature existing receivers already verify', async () => { + const { engine } = await buildEngine(); + await bootstrapDeclaredWebhooks(engine, metadataWith([declaredWebhook()])); + + const { calls, outbox } = await deliverOnce(engine); + + // The receiver's own check: recompute HMAC-SHA256 over the RAW body with + // the key it was given out-of-band. Unchanged wire format means this + // computation — which no receiver had to update — still matches. + expect(calls).toHaveLength(1); + const expected = createHmac('sha256', SECRET).update(calls[0].body).digest('hex'); + expect(calls[0].headers['X-Objectstack-Signature']).toBe(`sha256=${expected}`); + // A blank body would satisfy the HMAC too — pin what was actually signed. + expect(JSON.parse(calls[0].body)).toMatchObject({ object: 'contact', recordId: 'c1', action: 'created' }); + expect(calls[0].headers['X-Team']).toBe('crm'); + // …and #7722's invariant still holds on the delivery row. + expect(JSON.stringify(await outbox.list())).not.toContain(SECRET); + }); + + it('re-seeding an unchanged webhook does not mint a second sys_secret row', async () => { + const { engine, stores } = await buildEngine(); + const declared = metadataWith([declaredWebhook()]); + + await bootstrapDeclaredWebhooks(engine, declared); + await bootstrapDeclaredWebhooks(engine, declared); + await bootstrapDeclaredWebhooks(engine, declared); + + // Every boot re-seeds package rows; a blind restatement of the key would + // leave one orphan ciphertext row per restart. + expect(stores.get('sys_secret')!.size).toBe(1); + }); + + it('rotating the declared secret in code re-encrypts and signs with the new key', async () => { + const { engine } = await buildEngine(); + await bootstrapDeclaredWebhooks(engine, metadataWith([declaredWebhook()])); + + const ROTATED = 'whsec_7799_rotated'; + await bootstrapDeclaredWebhooks(engine, metadataWith([declaredWebhook({ secret: ROTATED })])); + + const { calls } = await deliverOnce(engine); + const expected = createHmac('sha256', ROTATED).update(calls[0].body).digest('hex'); + expect(calls[0].headers['X-Objectstack-Signature']).toBe(`sha256=${expected}`); + }); + + it('a webhook authored without a secret still materializes and delivers unsigned', async () => { + const { engine, stores } = await buildEngine(); + await bootstrapDeclaredWebhooks(engine, metadataWith([declaredWebhook({ secret: undefined })])); + + // No crypto work at all for a secret-free webhook. + expect(stores.get('sys_secret')?.size ?? 0).toBe(0); + const { calls } = await deliverOnce(engine); + expect(calls).toHaveLength(1); + expect(calls[0].headers['X-Objectstack-Signature']).toBeUndefined(); + }); +}); + +describe('legacy cleartext migration (#7799)', () => { + /** A pre-#7799 row: the whole envelope, key included, in `definition_json`. */ + async function seedLegacyRow(engine: any) { + await engine.insert('sys_webhook', { + id: 'whk_legacy', + name: 'legacy_hook', + label: 'Legacy', + object_name: 'contact', + triggers: ['create'], + url: 'https://receiver.example/hook', + method: 'post', + active: true, + // The shape the seeder used to write, verbatim. + definition_json: JSON.stringify({ + name: 'legacy_hook', url: 'https://receiver.example/hook', + headers: { 'X-Team': 'crm' }, secret: SECRET, timeoutMs: 30000, + }), + managed_by: 'admin', + customized: true, + created_at: '2026-01-01T00:00:00.000Z', + }, { context: SYSTEM_CTX } as any); + } + + it('sweeps an admin-authored row the seeder never touches, and keeps it signing', async () => { + const { engine, stores } = await buildEngine(); + await seedLegacyRow(engine); + + // Precondition: this is genuinely the exposed shape. + expect(JSON.stringify(await engine.find('sys_webhook', {}))).toContain(SECRET); + + const result = await migrateLegacyWebhookSecrets(engine); + expect(result).toEqual({ found: 1, migrated: 1, failed: 0 }); + + // Gone from the API read AND from the bytes at rest. + expect(JSON.stringify(await engine.find('sys_webhook', {}))).not.toContain(SECRET); + expect(JSON.stringify(Array.from(stores.get('sys_webhook')!.values()))).not.toContain(SECRET); + + // …and the same key still produces the same signature. + const { calls } = await deliverOnce(engine); + const expected = createHmac('sha256', SECRET).update(calls[0].body).digest('hex'); + expect(calls[0].headers['X-Objectstack-Signature']).toBe(`sha256=${expected}`); + + // The sweep does not re-freeze provenance, and is free on the next boot. + const row = Array.from(stores.get('sys_webhook')!.values())[0] as any; + expect(row.managed_by).toBe('admin'); + expect(await migrateLegacyWebhookSecrets(engine)).toEqual({ found: 0, migrated: 0, failed: 0 }); + }); + + it('an un-swept row keeps signing — the enqueuer reads the legacy blob and says so', async () => { + const { engine } = await buildEngine(); + await seedLegacyRow(engine); + + const warnings: string[] = []; + const realtime = new FakeRealtime(); + const outbox = new MemoryHttpOutbox(); + const enqueuer = new AutoEnqueuer(engine, realtime, (i) => outbox.enqueue(i), { + logger: { warn: (m: string) => { warnings.push(m); } }, + }); + await enqueuer.start(); + await realtime.publish(recordEvent('contact', { id: 'c1', name: 'Ada' })); + await new Promise((r) => setTimeout(r, 0)); + const { impl, calls } = makeFetch(); + await new HttpDispatcher({ nodeId: 'n1', outbox, fetchImpl: impl, partitionCount: 1 }).tick(); + await enqueuer.stop(); + + const expected = createHmac('sha256', SECRET).update(calls[0].body).digest('hex'); + expect(calls[0].headers['X-Objectstack-Signature']).toBe(`sha256=${expected}`); + expect(warnings.join('\n')).toMatch(/CLEARTEXT in definition_json/); + }); +}); + +describe('fail-closed: no CryptoProvider (#7799)', () => { + it('refuses to seed a secret-bearing webhook, reporting an ADR-0112 code + status', async () => { + const { engine, stores } = await buildEngine({ withCrypto: false }); + const warns: Array<{ msg: string; meta: any }> = []; + + const result = await bootstrapDeclaredWebhooks(engine, metadataWith([declaredWebhook()]), { + warn: (msg: string, meta?: unknown) => { warns.push({ msg, meta }); }, + }); + + expect(result).toEqual({ seeded: 0, skipped: 1 }); + // Nothing was written — not the row, not a cleartext fallback column. + expect(stores.get('sys_webhook')?.size ?? 0).toBe(0); + expect(stores.get('sys_secret')?.size ?? 0).toBe(0); + + // ADR-0112: a consumer branches on the pair, not on the message text. + const refusal = warns.find((w) => w.meta?.code); + expect(refusal?.meta).toMatchObject({ code: 'INTERNAL_ERROR', status: 500 }); + expect(refusal?.msg).toMatch(/cannot be stored encrypted/); + }); + + it('leaves a legacy row exactly as it was, and reports it as still cleartext', async () => { + const { engine, stores } = await buildEngine({ withCrypto: false }); + await engine.insert('sys_webhook', { + id: 'whk_legacy', name: 'legacy_hook', label: 'Legacy', object_name: 'contact', + triggers: ['create'], url: 'https://receiver.example/hook', method: 'post', active: true, + definition_json: JSON.stringify({ name: 'legacy_hook', secret: SECRET }), + managed_by: 'admin', created_at: '2026-01-01T00:00:00.000Z', + }, { context: SYSTEM_CTX } as any); + + const warns: Array<{ msg: string; meta: any }> = []; + const result = await migrateLegacyWebhookSecrets(engine, { + warn: (msg: string, meta?: unknown) => { warns.push({ msg, meta }); }, + }); + + expect(result).toEqual({ found: 1, migrated: 0, failed: 1 }); + expect(warns[0].meta).toMatchObject({ code: 'INTERNAL_ERROR', status: 500 }); + // Partial application would be the dangerous outcome: the blob stripped + // while nothing encrypted holds the key, silently unsigning the webhook. + const row = Array.from(stores.get('sys_webhook')!.values())[0] as any; + expect(String(row.definition_json)).toContain(SECRET); + expect(row[WEBHOOK_SECRET_FIELD] ?? null).toBeNull(); + }); +}); diff --git a/packages/plugins/plugin-webhooks/src/webhook-secret.ts b/packages/plugins/plugin-webhooks/src/webhook-secret.ts new file mode 100644 index 0000000000..1d1b07f5f6 --- /dev/null +++ b/packages/plugins/plugin-webhooks/src/webhook-secret.ts @@ -0,0 +1,193 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#7799] The persistence seam for a webhook's HMAC signing secret. + * + * ## The defect + * `bootstrapDeclaredWebhooks` used to persist the whole validated `Webhook` + * envelope — `secret` included — as `definition_json: JSON.stringify(wh)`, and + * `AutoEnqueuer.parseRow` read `defn.secret` straight back out to sign + * deliveries. `definition_json` is an ordinary textarea on an admin-authorable + * object with no restrictive `enable.apiMethods`, so an ordinary + * `GET /api/v1/data/sys_webhook` returned the key to every persona that can read + * the object. That key is the receiver's ONLY proof a delivery came from us. + * + * #7722 removed the same secret's per-attempt copies from `sys_http_delivery`; + * this is the remaining cleartext location, and unlike the delivery table it is + * not bounded by a retention window. + * + * ## The seam + * Nothing about the AUTHORING envelope changes — authors still write + * `secret: '…'` on `defineWebhook()`, and `webhook.zod.ts` is untouched. What + * changes is where the value LANDS: + * + * authored `secret` → `sys_webhook.signing_secret` (`type: 'secret'`) + * → engine encrypts → `sys_secret` ciphertext row + * → row keeps only an opaque `secret:` ref + * → every read path returns the mask + * + * `definition_json` → the same envelope MINUS `secret` + * + * and the enqueuer recovers the plaintext server-side, at cache-refresh time, + * through `engine.resolveSecretField()` — the privileged, driver-level + * dereference added alongside this change, because the encrypted channel masks + * its own ref on every supported read path and a server-side consumer + * previously had no way to get at it. + * + * ## Two things this file deliberately does NOT do + * - It does not invent a second cipher store. The engine owns the + * `ICryptoProvider` (the host injects it via `setCryptoProvider`, and it is + * not a kernel service), so the plugin cannot encrypt on its own — it writes + * cleartext INTO the `secret`-typed column exactly once and lets the engine's + * own write path do the wrapping. That also inherits the engine's fail-closed + * posture for free: no provider ⇒ the write throws ⇒ we skip the webhook + * loudly, rather than silently re-opening the hole in a new column. + * - It does not guess. When a row HAS a stored secret the enqueuer cannot + * resolve, the subscription is dropped rather than delivered unsigned — an + * undelivered webhook is visible and safe, an unsigned one is invisible and + * is precisely the failure this issue is about. + */ + +import type { IDataEngine } from '@objectstack/spec/contracts'; + +/** Column on `sys_webhook` holding the encrypted signing key. */ +export const WEBHOOK_SECRET_FIELD = 'signing_secret'; + +/** Object whose rows carry it. Kept here so seeder/enqueuer/sweep agree. */ +export const WEBHOOK_OBJECT = 'sys_webhook'; + +/** + * Error code + status carried by the refusal this seam can raise, per ADR-0112: + * a consumer branches on `code`, not on message text. `INTERNAL_ERROR`/500 is + * the standard-catalog member for "the server is misconfigured and cannot honour + * this safely" — no CryptoProvider is wired, so there is nowhere to put the key + * that is not cleartext. + */ +export const WEBHOOK_SECRET_REFUSAL_CODE = 'INTERNAL_ERROR'; +export const WEBHOOK_SECRET_REFUSAL_STATUS = 500; + +/** + * True when `err` is the engine's fail-closed refusal to persist a `secret` + * field — no CryptoProvider registered, or no reachable `sys_secret` store. + * Matched on the engine's own wording because that path throws a bare `Error`; + * a false negative only costs a less specific log line, never cleartext. + */ +export function isSecretProtectionFailure(err: unknown): boolean { + const msg = String((err as Error)?.message ?? err ?? ''); + return /Cannot persist secret field/i.test(msg); +} + +/** + * Split an authored envelope into the part that is safe to serialize into + * `definition_json` and the key that must go to the encrypted column. + * + * The key is REMOVED, not blanked: leaving `"secret": ""` behind would still + * teach the next reader that this blob is where the key lives, and a later + * merge could refill it. + */ +export function splitWebhookSecret>( + wh: T, +): { envelope: Omit; secret: string | undefined } { + const { secret, ...envelope } = wh as T & { secret?: unknown }; + const value = typeof secret === 'string' && secret.length > 0 ? secret : undefined; + return { envelope: envelope as Omit, secret: value }; +} + +/** + * Read a legacy cleartext secret out of a `definition_json` blob. + * + * Rows written before #7799 — and rows an admin hand-edited into the textarea — + * still carry one. Returns `undefined` for anything else, including unparseable + * JSON (a malformed blob is not a credential). + */ +export function readLegacySecret(definitionJson: unknown): string | undefined { + if (typeof definitionJson !== 'string' || definitionJson.length === 0) return undefined; + try { + const parsed = JSON.parse(definitionJson); + const secret = (parsed as { secret?: unknown } | null)?.secret; + return typeof secret === 'string' && secret.length > 0 ? secret : undefined; + } catch { + return undefined; + } +} + +/** Strip `secret` from a serialized envelope, preserving every other key. */ +export function stripSecretFromDefinition(definitionJson: string): string { + const parsed = JSON.parse(definitionJson) as Record; + const { envelope } = splitWebhookSecret(parsed); + return JSON.stringify(envelope); +} + +/** + * objectql's two wire forms for the encrypted channel, restated here ONLY as a + * "this value is not the key" guard. + * + * This package deliberately takes no dependency on `@objectstack/objectql` (it + * declares the messaging surface structurally for the same reason), so the + * constants cannot be imported — and a signing key is the one place where + * guessing is unacceptable: sign with the mask and every receiver rejects every + * delivery, silently, forever. `webhook-secret-at-rest.test.ts` pins both + * against objectql's own exports so a rename there reddens here. + */ +const OBJECTQL_SECRET_MASK = '••••••••'; +const OBJECTQL_SECRET_REF_PREFIX = 'secret:'; + +/** True when a column value is objectql's mask or ref — opaque, never the key. */ +export function isOpaqueSecretForm(value: unknown): boolean { + return ( + typeof value === 'string' + && (value === OBJECTQL_SECRET_MASK || value.startsWith(OBJECTQL_SECRET_REF_PREFIX)) + ); +} + +/** Test-only accessors for the pin above. */ +export const __objectqlSecretWireForms = { + mask: OBJECTQL_SECRET_MASK, + refPrefix: OBJECTQL_SECRET_REF_PREFIX, +} as const; + +/** Engines that expose the privileged dereference (ObjectQL ≥ #7799). */ +type SecretResolvingEngine = IDataEngine & { + resolveSecretField?(object: string, recordId: string, field: string): Promise; +}; + +/** True when this engine can dereference an encrypted field. */ +export function canResolveSecrets(engine: IDataEngine | undefined): boolean { + return typeof (engine as SecretResolvingEngine | undefined)?.resolveSecretField === 'function'; +} + +/** + * Recover a row's signing key. Returns `undefined` when the row has no stored + * key — which is not an error: `secret` is optional on the authoring envelope, + * and an unsigned webhook is a legitimate (authored) configuration. + * + * Throws when a key IS stored but cannot be dereferenced. Callers must treat + * that as "drop this subscription", never as "deliver unsigned". + */ +export async function resolveWebhookSecret( + engine: IDataEngine, + row: { id: string; [k: string]: unknown }, + object: string = WEBHOOK_OBJECT, +): Promise { + const stored = row[WEBHOOK_SECRET_FIELD]; + // Unset / cleared. On the generic read path a set secret comes back as the + // engine's mask (a non-empty string) and an unset one as `null`, so presence + // is decidable here WITHOUT the value ever being readable. + if (stored == null || stored === '') return undefined; + + const resolver = engine as SecretResolvingEngine; + if (typeof resolver.resolveSecretField !== 'function') { + // An engine with no encrypted-field channel stored verbatim what the seeder + // handed it, so the column IS the key — reading it is correct, not a + // fallback. The refusal below is for the narrow case where the value is one + // of objectql's opaque forms and there is no way to invert it. + if (!isOpaqueSecretForm(stored)) return String(stored); + throw new Error( + `Webhook "${String(row.name ?? row.id)}" stores an encrypted signing secret, but this data ` + + 'engine does not implement resolveSecretField() — the key cannot be recovered, so the ' + + 'subscription is dropped rather than delivered unsigned (#7799).', + ); + } + const plain = await resolver.resolveSecretField(object, String(row.id), WEBHOOK_SECRET_FIELD); + return typeof plain === 'string' && plain.length > 0 ? plain : undefined; +} diff --git a/packages/spec/liveness/webhook.json b/packages/spec/liveness/webhook.json index 3c8f110055..38fb410a35 100644 --- a/packages/spec/liveness/webhook.json +++ b/packages/spec/liveness/webhook.json @@ -1,6 +1,6 @@ { "type": "webhook", - "_note": "WebhookSchema (outbound webhook — packages/spec/src/automation/webhook.zod.ts). Governed via a spec-only schema override in the gate (SPEC_ONLY_SCHEMAS): webhook is still NOT a registered metadata type (absent from kernel/metadata-type-schemas.ts) — registering it would turn on Studio webhook CRUD + saveMetaItem overlay + create-seeds; that reassessment is tracked in #3490 and deliberately deferred. TWO THINGS CLOSED THE OLD 'entire surface is dead' classification: (1) #3494 PRUNED the aspirational dead props outright — body / payloadFields / includeSession / retryPolicy / tags / authentication are gone from the schema; (2) the #3461 materializer bridge (PR #3489) makes every REMAINING prop live. `bootstrapDeclaredWebhooks` (plugins/plugin-webhooks/src/bootstrap-declared-webhooks.ts) materializes each stack/connector-authored webhook into a `sys_webhook` DATA row on boot — WebhookSchema.parse (:114) → mapWebhookToRow (:191): `object`→`object_name` (:195), `isActive`→`active` (:202), same-named `name`/`label`/`triggers`/`url`/`method`/`description`, and the FULL envelope → `definition_json` (:203). The dispatcher (AutoEnqueuer) reads those rows (auto-enqueuer.ts:175 `where:{active:true}`) and fans out on data.record.* events (plus the aggregate data.records.* a predicate write publishes, dispatched under the opt-in bulk_update/bulk_delete triggers — #4639), reading `object_name`/`name`/`url`/`method`/`triggers` off the row and `headers`/`secret`/`timeoutMs` back out of `definition_json` (auto-enqueuer.ts:266-277). So all 11 remaining props are LIVE; there is no dead/experimental prop left, so nothing carries an authorWarn (the old per-webhook `url` heads-up is gone — authoring is no longer a no-op). Seed-not-clobber: an admin-edited row (`customized`) is never re-seeded (bootstrap-declared-webhooks.ts:143). The `object` prop carries the ADR-0054 runtime proof for the whole materialization pipeline (bound high-risk class `webhook-materialization`). Field-level line refs: materializer bootstrap-declared-webhooks.ts, runtime object plugins/plugin-webhooks/src/sys-webhook.object.ts, dispatcher auto-enqueuer.ts.", + "_note": "WebhookSchema (outbound webhook — packages/spec/src/automation/webhook.zod.ts). Governed via a spec-only schema override in the gate (SPEC_ONLY_SCHEMAS): webhook is still NOT a registered metadata type (absent from kernel/metadata-type-schemas.ts) — registering it would turn on Studio webhook CRUD + saveMetaItem overlay + create-seeds; that reassessment is tracked in #3490 and deliberately deferred. TWO THINGS CLOSED THE OLD 'entire surface is dead' classification: (1) #3494 PRUNED the aspirational dead props outright — body / payloadFields / includeSession / retryPolicy / tags / authentication are gone from the schema; (2) the #3461 materializer bridge (PR #3489) makes every REMAINING prop live. `bootstrapDeclaredWebhooks` (plugins/plugin-webhooks/src/bootstrap-declared-webhooks.ts) materializes each stack/connector-authored webhook into a `sys_webhook` DATA row on boot — WebhookSchema.parse (:114) → mapWebhookToRow (:191): `object`→`object_name` (:195), `isActive`→`active` (:202), same-named `name`/`label`/`triggers`/`url`/`method`/`description`, `secret`→the encrypted `signing_secret` column (#7799), and the REST of the envelope → `definition_json`. The dispatcher (AutoEnqueuer) reads those rows (auto-enqueuer.ts:175 `where:{active:true}`) and fans out on data.record.* events (plus the aggregate data.records.* a predicate write publishes, dispatched under the opt-in bulk_update/bulk_delete triggers — #4639), reading `object_name`/`name`/`url`/`method`/`triggers` off the row, `headers`/`timeoutMs` back out of `definition_json`, and the signing key through `engine.resolveSecretField()` against the encrypted `signing_secret` column (#7799). So all 11 remaining props are LIVE; there is no dead/experimental prop left, so nothing carries an authorWarn (the old per-webhook `url` heads-up is gone — authoring is no longer a no-op). Seed-not-clobber: an admin-edited row (`customized`) is never re-seeded (bootstrap-declared-webhooks.ts:143). The `object` prop carries the ADR-0054 runtime proof for the whole materialization pipeline (bound high-risk class `webhook-materialization`). Field-level line refs: materializer bootstrap-declared-webhooks.ts, runtime object plugins/plugin-webhooks/src/sys-webhook.object.ts, dispatcher auto-enqueuer.ts.", "props": { "name": { "status": "live", @@ -45,8 +45,8 @@ }, "secret": { "status": "live", - "evidence": "Bridge folds the envelope into sys_webhook.definition_json (bootstrap-declared-webhooks.ts:203, #3489); the dispatcher reads defn.secret and uses it as the HMAC signing secret (auto-enqueuer.ts:276 → signingSecret at :334).", - "note": "Was dead pre-bridge (definition_json only reachable via hand-authored rows). Now materialized from authoring." + "evidence": "Bridge writes the authored key to sys_webhook.signing_secret — a `type: 'secret'` column the engine encrypts into sys_secret and masks on read (bootstrap-declared-webhooks.ts mapWebhookToRow + secretPatch, #7799); the dispatcher recovers the plaintext via engine.resolveSecretField() on each cache refresh (auto-enqueuer.ts attachSecret) and passes it as signingSecret to the outbox, which consumes it for one HMAC without persisting it (#7722).", + "note": "Was dead pre-bridge (definition_json only reachable via hand-authored rows), then materialized from authoring into definition_json as CLEARTEXT — recoverable over the ordinary data API, which is #7799. It no longer rides in that blob; the authored key itself is unchanged." }, "isActive": { "status": "live", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e00b45736e..e94514aa56 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1784,6 +1784,9 @@ importers: '@objectstack/metadata-core': specifier: workspace:* version: link:../../metadata-core + '@objectstack/objectql': + specifier: workspace:* + version: link:../../objectql '@types/node': specifier: ^26.1.2 version: 26.1.2