Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/33016-webhook-exact-qualifier-door.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@objectstack/runtime': patch
'@objectstack/plugin-security': patch
'@objectstack/spec': patch
---

fix(runtime,plugin-security): the declared-webhook activation door switches one of two holders the namespace address cannot tell apart, and the upgrade report names every withheld holder

Clause-②: yes (widening)
Metadata-protocol: none

Two holders of one declared webhook name that the namespace address cannot tell apart, two packages co-owning one namespace or a package that declares no namespace beside another holder, are both dispatched, but neither could be switched: `POST /api/v1/security/_activation/webhook/NAME` and `.../webhook/NS/NAME` both answered `409` naming both. The bare address now takes `?package=PKG`, the exact qualifier `/meta` reads, and switches exactly PKG's declaration, writing PKG's own activation row and no other.

- FROM `400 VALIDATION_ERROR` (unknown query parameter) TO `200` writing PKG's row, `data.packageId` = PKG, for `POST /security/_activation/webhook/NAME?package=PKG` when PKG declares NAME and is enabled.
- A PKG that does not serve NAME (a disabled package included) answers `409 RESOURCE_CONFLICT` naming the holders that do, or `404` when no enabled package declares it. Nothing is written.
- `package` is the only query parameter of the bare webhook address; the namespace address takes none, and an empty or repeated `package` is `400`, as on the permission-set door.

The upgrade-impact report's withheld-webhook finding now reports each holder of the name, a disabled second holder included: FROM one finding (the holder the bare artifact lookup answered) TO one finding per holder. Its log lines name each finding's switch, `POST /api/v1/security/_activation/webhook/NAME?package=PKG`, where they named the namespace address. A withheld-webhook finding whose package is disabled is marked `packageDisabled: true` (an added optional field), and the line says to enable that package first: its address answers `409` until then.

The `webhook` liveness ledger's `name` evidence (`@objectstack/spec`, text only) names one slot per served `(package, name)` holder and its `declared:PKG/NAME` reference.
6 changes: 5 additions & 1 deletion content/docs/automation/webhooks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,11 @@ A webhook reaches the dispatcher one of two ways:
`POST /api/v1/security/_activation/webhook/:name` and body
`{ "enabled": true | false }`, or, when packages of two namespaces each
declare the name, `POST /api/v1/security/_activation/webhook/:ns/:name`
(the bare address then answers `409` naming both). It writes that holder's
(the bare address then answers `409` naming both). Two holders a namespace
cannot tell apart (two packages sharing one namespace, or a package that
declares no namespace beside another holder) are each switched by the exact
qualifier, `POST /api/v1/security/_activation/webhook/:name?package=<package>`.
It writes that holder's
own `sys_metadata_activation` row of type `webhook` and overrides the
declared `isActive` in either direction
(ADR-0131 D6, regime C — the door, its authority tiers and its refusals are
Expand Down
10 changes: 8 additions & 2 deletions docs/qa/platform-checklist/areas/cli.json
Original file line number Diff line number Diff line change
Expand Up @@ -2080,7 +2080,7 @@
"title": "`os migrate organization-ownership` upgrades a database 17.x or an 18.0 pre-release (18.0.0-next.*) created (18.x): a bare run writes the reviewed plan and changes nothing; `--apply --plan-file PLAN --backup-taken` runs every step — attribution through the moves, the declared webhook and sharing-rule settlements, the column drops and NOT NULL — and certifies the marker at this release's ownership shape generation; the served boot refuses an uncertified database, naming the command, and boots a certified one; `os migrate apply --allow-destructive` still withholds every organization_id drop the ceremony owns, and `os migrate comment-reactions --apply` stays pinned",
"since": "v18",
"status": "active",
"revision": 9,
"revision": 10,
"priority": "P0",
"surface": "cli",
"personas": [
Expand Down Expand Up @@ -2145,7 +2145,7 @@
{
"clause": "an 18.0 pre-release database (one an 18.0.0-next.* release created and served) takes the same path as a v17 one and keeps its data: the served boot refuses it, the reviewed apply completes, and the next boot serves it with its identity mail, its global setting audit, its pending mail jobs and its declared webhook carried over",
"oracle": "build",
"verify": "steps[5], each leg read at its own layer. Boot output: the first `os serve` exits 1 before any listening line, opening \"This database carries no organization-ownership marker\" and naming the tables still carrying organization_id (sys_flow_credential and sys_secret on 18.0.0-next.5); a database an 18.0.0-next.3 ceremony already certified at shape generation 0 is refused instead with \"… certified by an organization-ownership ceremony of shape generation 0, and this release requires generation 1 …\". The apply exits 0 with complete true and blocking 0, and the second boot reaches its serving line. Row reads, before the plan and after the second boot: every identity mail row (related_object sys_user, which is the reset and verification mail, and any magic-link mail) is in sys_identity_email under its original id, equal over the columns both tables hold, and gone from sys_email, while organization invitations stay in sys_email (packages/cli/src/utils/organization-ownership-apply.ts#SIBLING_MOVES); every sys_setting_audit row with scope 'global' is in sys_platform_setting_audit and gone from sys_setting_audit; every pending or running email.send.async or email.attachment.reclaim job naming a moved mail carries object sys_identity_email in its payload and metadata, while completed jobs and the organization-invitation job are unchanged; and no row disappears outside the apply report: each pre-existing table's count after equals its count before, less what the report's steps say they moved or deleted. On the cli-18-prerelease-db restore, sys_automation_run in the telemetry sibling keeps its count across the second boot but not two of its ids: the recipe's knownGaps[3] says why, and those two ids are not data the apply lost. The door's answer: each pre-release user signs in with its 18.0 password (200; a wrong one 401); the token read from the moved reset mail resets (200); and the one showcase_task PATCH writes exactly one sys_http_delivery row whose ref_id is declared:showcase_task_changed",
"verify": "steps[5], each leg read at its own layer. Boot output: the first `os serve` exits 1 before any listening line, opening \"This database carries no organization-ownership marker\" and naming the tables still carrying organization_id (sys_flow_credential and sys_secret on 18.0.0-next.5); a database an 18.0.0-next.3 ceremony already certified at shape generation 0 is refused instead with \"… certified by an organization-ownership ceremony of shape generation 0, and this release requires generation 1 …\". The apply exits 0 with complete true and blocking 0, and the second boot reaches its serving line. Row reads, before the plan and after the second boot: every identity mail row (related_object sys_user, which is the reset and verification mail, and any magic-link mail) is in sys_identity_email under its original id, equal over the columns both tables hold, and gone from sys_email, while organization invitations stay in sys_email (packages/cli/src/utils/organization-ownership-apply.ts#SIBLING_MOVES); every sys_setting_audit row with scope 'global' is in sys_platform_setting_audit and gone from sys_setting_audit; every pending or running email.send.async or email.attachment.reclaim job naming a moved mail carries object sys_identity_email in its payload and metadata, while completed jobs and the organization-invitation job are unchanged; and no row disappears outside the apply report: each pre-existing table's count after equals its count before, less what the report's steps say they moved or deleted. On the cli-18-prerelease-db restore, sys_automation_run in the telemetry sibling keeps its count across the second boot but not two of its ids: the recipe's knownGaps[3] says why, and those two ids are not data the apply lost. The door's answer: each pre-release user signs in with its 18.0 password (200; a wrong one 401); the token read from the moved reset mail resets (200); and the one showcase_task PATCH writes exactly one sys_http_delivery row whose ref_id is declared:com.example.showcase/showcase_task_changed (the declaring package's id, then the name: packages/qa/dogfood/test/upgrade-18x-harness.dogfood.test.ts pins it)",
"evidence": "the refused boot's excerpt and exit code, the apply report's complete and blocking, the serving line; the before and after reads of sys_email, sys_identity_email, sys_setting_audit, sys_platform_setting_audit and the mail jobs, with per-table counts; the sign-in, reset and delivery answers"
}
],
Expand Down Expand Up @@ -2222,6 +2222,12 @@
"date": "2026-11-08",
"change": "steps[1], steps[3] and steps[4]: `os migrate apply` and `os migrate comment-reactions` take no `--dev` flag; only `os migrate organization-ownership` and `os serve` of the ceremony's commands do. Checklist-accuracy finding of QA run #31486 (owed:weekly, at 0647dabb). Read at base 4894cfe1",
"ref": "#31499"
},
{
"revision": 10,
"date": "2026-11-14",
"change": "acceptance[5].verify's delivery reference follows the per-holder slot a declared webhook is dispatched under since its ref_id names the declaring package: ref_id declared:com.example.showcase/showcase_task_changed, as packages/qa/dogfood/test/upgrade-18x-harness.dogfood.test.ts pins. The bare declared:showcase_task_changed failed a correct product. No clause weakened; read from source at base 4c152685",
"ref": "#33016"
}
]
},
Expand Down
10 changes: 8 additions & 2 deletions docs/qa/platform-checklist/areas/integration-system.json
Original file line number Diff line number Diff line change
Expand Up @@ -588,7 +588,7 @@
"title": "Outbound webhooks are read from the registry (spec object→object_name, no sys_webhook row) and switched through the activation ledger, fire per trigger variant through the sys_http_delivery outbox with HMAC + timeout honored, redeliver terminal rows tenant-scoped via POST /api/v1/webhooks/redeliver, and reject retired trigger kinds",
"since": "v15",
"status": "active",
"revision": 14,
"revision": 15,
"priority": "P1",
"surface": "mixed",
"fixtures": {
Expand All @@ -615,7 +615,7 @@
],
"steps": [
"boot the showcase on a fresh database; verify the declared webhook is NOT materialized (ADR-0131 D2 retired the boot seeder): read sys_webhook over /api/v1/data and find NO showcase_task_changed row — the dispatcher reads the declaration from the registry",
"switch the shipped declared showcase_task_changed on as the admin: POST /api/v1/security/_activation/webhook/showcase_task_changed with { enabled: true } — 200 { type: 'webhook', name, enabled: true }, and one sys_metadata_activation row of metadata_type 'webhook' whose `active` column is true (the door's body and answer spell it `enabled`, the row's column is `active`); an undeclared name answers 404. A declared webhook is read-only: pointing it at the receiver means a copy under a new name (the scratch webhook below). This switch is the subject of acceptance[6] ONLY: switched on, the declaration delivers under `single` (one sys_http_delivery row with ref_id declared:showcase_task_changed, stamped with the Default Organization, per task change), but only to its placeholder url (fixtures.requires[0]). Author the in-organization scratch webhook beside it for the delivery legs",
"switch the shipped declared showcase_task_changed on as the admin: POST /api/v1/security/_activation/webhook/showcase_task_changed with { enabled: true } — 200 { type: 'webhook', name, enabled: true }, and one sys_metadata_activation row of metadata_type 'webhook' whose `active` column is true (the door's body and answer spell it `enabled`, the row's column is `active`); an undeclared name answers 404. A declared webhook is read-only: pointing it at the receiver means a copy under a new name (the scratch webhook below). This switch is the subject of acceptance[6] ONLY: switched on, the declaration delivers under `single` (one sys_http_delivery row with ref_id declared:com.example.showcase/showcase_task_changed — the declaring package's id, then the name, packages/plugins/plugin-webhooks/src/declared-webhooks.ts#declaredWebhookRefId — stamped with the Default Organization, per task change), but only to its placeholder url (fixtures.requires[0]). Author the in-organization scratch webhook beside it for the delivery legs",
"with the in-organization scratch webhook (fixtures.requires[0]) active and pointed at the receiver, create, update, then delete a showcase_task; capture the three deliveries at the receiver (headers incl. the HMAC signature when the row's `signing_secret` is set, body incl. recordId). The create needs showcase_task's required `project` lookup — pass an existing showcase_project id, or the create answers 400 VALIDATION_FAILED 'Project is required' and no create event fires",
"run a predicate multi-update and multi-delete (multi:true) matching several rows — through a FLOW node, not REST: author update_record / delete_record nodes with multi:true and fire them via the api trigger (the REST batch routes strip options.multi, #3897 — see the knownGap); capture the bulk deliveries and their { object, matched } shape",
"read sys_http_delivery over /api/v1/data (a `$filter` is a JSON filter object): one row per delivery with status/attempts/response_code",
Expand Down Expand Up @@ -770,6 +770,12 @@
"date": "2026-11-10",
"change": "fixtures.requires[0], steps[2] and steps[6] name the keys a runner met. Checklist-accuracy findings of QA run #32135 (at ff04ad81; card #32168): the scratch webhook's on/off column is `active`, not `is_active`; its secret goes in `signing_secret` and its custom headers in `headers_secret`, not in definition_json; redeliver's body key is `deliveryId`. Re-read at base 8523ef9a: packages/plugins/plugin-webhooks/src/sys-webhook.object.ts (active, signing_secret, headers_secret; definition_json carries no credentials), packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts (the redeliver route reads body.deliveryId, else 400 MISSING_REQUIRED_FIELD). No clause changed",
"ref": "#32168"
},
{
"revision": 15,
"date": "2026-11-14",
"change": "steps[1]'s delivery reference follows the per-holder slot a declared webhook is dispatched under since its ref_id names the declaring package: ref_id declared:com.example.showcase/showcase_task_changed, as packages/qa/dogfood/test/upgrade-18x-harness.dogfood.test.ts pins (packages/plugins/plugin-webhooks/src/declared-webhooks.ts#declaredWebhookRefId). The bare declared:showcase_task_changed failed a correct product. No clause weakened; read from source at base 4c152685",
"ref": "#33016"
}
],
"enumSource": {
Expand Down
39 changes: 36 additions & 3 deletions packages/plugins/plugin-security/src/upgrade-impact-report.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,10 @@ function fakeRegistry(r: {
},
getPackage: (id) => packages[id],
isPackageDisabled: disabled,
packageScopedHoldersOf(type, name, _caller, options) {
const ids = items.filter((i) => i.type === type && i.name === name && !i.tenant && i.packageId).map((i) => i.packageId!);
return [...new Set(ids)].filter((id) => options?.includeDisabledPackages === true || !disabled(id));
},
getAllApps: () => Object.keys(r.apps ?? {}).map((name) => ({ name })),
getAppNavDiagnostics: (app) => r.apps?.[app] ?? [],
};
Expand Down Expand Up @@ -398,7 +402,7 @@ describe('webhook: a sys_webhook row holding a declared name', () => {

// [#31699] A webhook is addressed by (package, name): two packages of two
// namespaces may each declare one name, and a row holding it withholds both.
it('[FROM one TO each] two packages declaring the name: one finding per withheld holder, and the remedy names the namespace address', async () => {
it('[FROM one TO each] two packages declaring the name: one finding per withheld holder, and the remedy names the exact-qualifier address', async () => {
const d = deployment({
webhooks: [{ id: 'wh_1', name: 'acme_deal_won', organization_id: 'org_tenant' }],
registry: { items: [
Expand All @@ -411,8 +415,37 @@ describe('webhook: a sys_webhook row holding a declared name', () => {
{ name: 'acme_deal_won', packageId: OTHER_VENDOR, rowId: 'wh_1', organizationId: 'org_tenant' },
{ name: 'acme_deal_won', packageId: VENDOR, rowId: 'wh_1', organizationId: 'org_tenant' },
]);
expect(errorLines(r).some((m) => m.includes('POST /api/v1/security/_activation/webhook/<namespace>/<name> with'))).toBe(true);
});
// [#33016 nit 2] The exact qualifier serves every enabled holder, whatever
// its namespace; every finding names a package, so no bare-address arm.
const line = errorLines(r).find((m) => m.includes('sys_webhook row(s) hold the name of a webhook')) ?? '';
expect(line).toContain('POST /api/v1/security/_activation/webhook/<name>?package=<package>');
expect(line).not.toContain('names no package');
expect(line).not.toContain('<namespace>');
});

// [#33016 nit 3] The bare artifact lookup answers one holder; a disabled
// SECOND holder used to get no finding. Each holder is reported, in both
// registration orders, disabled or not.
for (const order of [[VENDOR, OTHER_VENDOR], [OTHER_VENDOR, VENDOR]]) {
it(`[FROM one TO each] registered ${order.join(', ')}, ${order[1]} disabled: each holder's withheld declaration is a finding`, async () => {
const d = deployment({
webhooks: [{ id: 'wh_1', name: 'acme_deal_won', organization_id: 'org_tenant' }],
registry: {
items: order.map((packageId) => ({ type: 'webhook', name: 'acme_deal_won', packageId })),
packages: { [order[1]]: { source: 'code', disabled: true } },
},
});
const r = await boot(d, 'walled');
expect(r.report.withheldWebhooks.map((w) => w.packageId).sort()).toEqual([OTHER_VENDOR, VENDOR].sort());
// [#33016 S2] The disabled holder's address answers 409 until it is
// enabled again: its finding says so, and the line says to enable first.
expect(r.report.withheldWebhooks.map((w) => [w.packageId, w.packageDisabled ?? false]).sort())
.toEqual([[order[0], false], [order[1], true]].sort());
const line = errorLines(r).find((m) => m.includes('sys_webhook row(s) hold the name of a webhook')) ?? '';
expect(line).toContain('A finding marked packageDisabled names a disabled package');
expect(line).toContain('enable the package first, then switch it there');
});
}

it('[FROM no finding TO orphan] a webhook switch naming a package that no longer ships the name is an orphan, whichever other package does; its rename is a suspected re-enablement', async () => {
const d = deployment({
Expand Down
Loading
Loading