diff --git a/.changeset/15212-8b-walled-platform-insert-refused.md b/.changeset/15212-8b-walled-platform-insert-refused.md new file mode 100644 index 00000000000..592df71f840 --- /dev/null +++ b/.changeset/15212-8b-walled-platform-insert-refused.md @@ -0,0 +1,17 @@ +--- +'@objectstack/objectql': minor +--- + +feat(objectql)!: under a walled posture (`group` / `isolated`), a system insert into a platform object that names no organization is refused; it no longer lands with `organization_id` NULL + +Clause-②: yes (narrowing) + + + +**BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D9, D11). This is the walled half of the change whose `single` half shipped in 18.0. + +- **Before.** Under `group` or `isolated`, a system-context insert (`isSystem`, no `tenantId`) into a `sys_` / `cloud_` / `ai_` object that carries an organization column, but that the tenancy inventory had not admitted, skipped the organization rule. The row landed with `organization_id` NULL, a row no organization owns and the wall hides from every organization. +- **Now.** That insert goes through the same decision every application object already gets under a wall: no organization is derivable, so it is refused with `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` (status 500, reason `walled-posture`), and nothing is written. Apart from the two tables named below, the posture no longer changes which objects the rule covers. +- **Unchanged.** A write that carries an organization, on the execution context or on the record, is never re-stamped or refused. Objects with no organization column (`systemFields: { tenant: false }`), objects declaring `tenancy: { enabled: false }`, and federated objects are outside the rule. `sys_secret` and `sys_flow_credential` keep their organization-less system inserts under a wall, as before: a global-scope encrypted setting, a datasource credential and a flow credential are deployment-level, and both tables drop their organization column in a later 18.x release, which retires this bridge (`WALLED_ORGANIZATION_LESS_BRIDGE`). Updates are untouched. The engine's diagnostics-only tenant-audit mute still reads the inventory. + +**The remedy.** If a refusal names your insert, carry the organization on the write: `ql.insert(object, { ...row, organization_id: orgId }, { context: { isSystem: true } })`, or `{ context: { isSystem: true, tenantId: orgId } }`. If the row is deployment-level by design, the object should declare no organization column: `systemFields: { tenant: false }`. diff --git a/.changeset/15212-declared-webhook-walled-deployment-wide.md b/.changeset/15212-declared-webhook-walled-deployment-wide.md new file mode 100644 index 00000000000..dedc299240a --- /dev/null +++ b/.changeset/15212-declared-webhook-walled-deployment-wide.md @@ -0,0 +1,15 @@ +--- +'@objectstack/plugin-webhooks': minor +--- + +feat(plugin-webhooks): under `group` and `isolated`, a declared webhook is deployment-wide, as it is under `single`; it receives every organization's events, and each delivery is stamped with its event's organization + +Clause-②: yes (widening) + +A webhook declared with `defineStack({ webhooks })` belongs to the deployment under every tenancy posture (ADR-0131 D2/D6, the epic's ruling A on the walled shape of a declared webhook). + +- **Before.** Under a walled posture (`group` / `isolated`) a declared webhook had no organization, so the dispatcher refused every organization-stamped event it matched. It logged one warning per webhook and delivered nothing. +- **Now.** Switched on, it receives every organization's matching events, as under `single`. Each `sys_http_delivery` row is stamped with its event's organization, so an organization reads only its own deliveries. The switch is unchanged: one deployment-level `sys_metadata_activation` row through `POST /api/v1/security/_activation/webhook/`. Under a wall only the platform operator may flip it; an organization administrator gets `403 PERMISSION_DENIED`. +- **Unchanged.** Webhooks an organization authors as `sys_webhook` rows still receive only their own organization's events. + +**If you relied on the old behaviour.** A walled deployment that declared a webhook it did not want delivered across organizations should switch it off: `POST /api/v1/security/_activation/webhook/` with `{ "enabled": false }`, as the platform operator. diff --git a/.changeset/15212-walled-posture-boots.md b/.changeset/15212-walled-posture-boots.md new file mode 100644 index 00000000000..1d3c0737e5d --- /dev/null +++ b/.changeset/15212-walled-posture-boots.md @@ -0,0 +1,13 @@ +--- +'@objectstack/cli': minor +--- + +feat(cli): `os serve` (and so `os dev` and `os start`) boots a walled tenancy posture again; `group` and `isolated` are supported on fresh installs + +Clause-②: yes (widening) + +18.0 refused a walled posture at boot, because the walled half of the platform-object organization rule had not shipped. It ships in this release (see the `@objectstack/objectql` changeset on walled system inserts), so the refusal is removed. + +- **Before.** With `OS_TENANCY_POSTURE=group` or `isolated`, or `OS_MULTI_ORG_ENABLED=true`, `os serve` exited `1` with a FATAL `NOT_IMPLEMENTED` before it read a config. `os doctor` printed a `Tenancy posture` warning saying so, and the unrecognised-posture fix list marked both walled values as refused in 18.0. +- **Now.** A walled posture boots like `single`, under the same prerequisites as before 18.0: the enterprise `@objectstack/organizations` runtime, and `OS_PLATFORM_OWNER_EMAIL`. `os doctor` reports no row for a valid posture, and the fix list offers `group` and `isolated` without the 18.0 note. +- **Unchanged.** An unrecognised posture is still refused at boot. Upgrading an existing database still waits for the upgrade ceremony, which still refuses in this release. diff --git a/.changeset/15212-webhook-declared-name-guard.md b/.changeset/15212-webhook-declared-name-guard.md new file mode 100644 index 00000000000..ebc62f47e2c --- /dev/null +++ b/.changeset/15212-webhook-declared-name-guard.md @@ -0,0 +1,18 @@ +--- +'@objectstack/plugin-webhooks': minor +--- + +feat(plugin-webhooks)!: a `sys_webhook` row can no longer take a declared webhook's name — an insert of one, or a rename onto one, is refused with `409 RESOURCE_CONFLICT` + +Clause-②: yes (narrowing) + + + +**BREAKING** accept-set narrowing, shipped as `minor` under the launch-window convention for breaking changes (ADR-0131 D6 regime C). + +A declared webhook (`defineStack({ webhooks })`) is read-only, and a `sys_webhook` row holding its name withholds the declaration for the whole deployment. Under a walled posture one organization's same-name row would silence the declaration for every organization. So no write may take a declared name, as declared sharing rules already do. + +- **Refused now.** A non-system insert into `sys_webhook` whose `name` is declared, switched on or off, and an update that changes `name` onto a declared one. Every door is covered (Setup, the `/data` API, scripts, AI tools): the refusal is `DeclaredWebhookNameConflictError`, `code: 'RESOURCE_CONFLICT'`, `status: 409`, naming the declared webhook. +- **Unchanged.** System writes (the platform's own sweeps and migrations). An update that keeps its name, so the rows an 18.0 install's seeder wrote, and the ones an administrator customised there, stay editable as before. Names nothing declares. + +**The remedy.** To change a declared webhook, author your own `sys_webhook` row under another name, and switch the declared one off: `POST /api/v1/security/_activation/webhook/` with `{ "enabled": false }`. diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index dd6f1128007..60268b319f0 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -203,7 +203,7 @@ organization-ownership shape until the [ownership ceremony](#os-migrate-organiza has run on it. -18.0 supports fresh `single` installs; upgrades and walled postures arrive in 18.x. In 18.0 the ceremony refuses, so `os dev` stops at step 2 with its +Fresh installs are supported under every tenancy posture; the upgrade path arrives in a later 18.x release. Until then the ceremony refuses, so `os dev` stops at step 2 with its refusal and changes nothing. 18.0 does not upgrade a v17 database: keep a v17 database on a 17.x release until the 18.x release that carries the upgrade path. For a local SQLite file, @@ -1216,7 +1216,7 @@ where the data lives. | `os migrate value-shapes` | Scan stored reference and structured-JSON field values against the platform's value contract, and record the deployment's migration flag when clean | | `os migrate summary-nulls` | Backfill roll-up `count` / `sum` columns still stored as `NULL` on parent rows created before the insert-time seed. Repairs values; no flag, nothing depends on it having run | | `os migrate comment-reactions` | Carry the emoji reactions a 17.x deployment stored in the retired `sys_comment.reactions` column into `sys_comment_reaction` records, list every stored value that does not read, and record the verified run that lets `os migrate apply --allow-destructive` drop the column | -| `os migrate organization-ownership` | The v18 ownership ceremony (ADR-0131 D10): write a plan of what each table's rows will become, then `--apply` that reviewed plan. A v18 server refuses to start until it has run. 18.0 supports fresh `single` installs; upgrades and walled postures arrive in 18.x. In 18.0 the command refuses. See [below](#os-migrate-organization-ownership) | +| `os migrate organization-ownership` | The v18 ownership ceremony (ADR-0131 D10): write a plan of what each table's rows will become, then `--apply` that reviewed plan. A v18 server refuses to start until it has run. Fresh installs are supported under every tenancy posture; the upgrade path arrives in a later 18.x release. Until then the command refuses. See [below](#os-migrate-organization-ownership) | | `os migrate activity-changed-fields` | Stamp the two read-rule columns (`changed_fields`, `milestone_key`) on the `sys_activity` rows written before they existed. Until it runs, those rows are withheld from every reader but the system, so run it at once after upgrading. No flag; idempotent | | `os migrate comment-parent-pair` | Stamp the parent pair (`object_name`, `record_id`) on the `sys_comment` rows written before it existed. Until it runs, a broad comment read past 2,000 candidate rows omits comments the reader may see, and its total agrees with the short list, so run it at once after upgrading. No flag; idempotent | | `os migrate meta --stored` | Replay the metadata conversion chain over this deployment's `sys_metadata` rows and rewrite the ones still carrying a pre-protocol shape. Hygiene, not a gate — nothing depends on it having run | @@ -1496,7 +1496,7 @@ has run. In the 18.x release that carries the upgrade path, `os dev` runs it on its own for a local SQLite database (see [`os dev`](#os-dev)). -18.0 supports fresh `single` installs; upgrades and walled postures arrive in 18.x. In 18.0 this command refuses, plan and apply alike: it exits `1` with +Fresh installs are supported under every tenancy posture; the upgrade path arrives in a later 18.x release. Until then this command refuses, plan and apply alike: it exits `1` with `NOT_IMPLEMENTED` (under `--json`, `code` beside `error`) and reads and writes nothing. Keep a v17 database on a 17.x release until the 18.x release that carries the upgrade path. The invocations below, and what the list after diff --git a/content/docs/deployment/environment-variables.mdx b/content/docs/deployment/environment-variables.mdx index b97646692b2..6a6c5798e16 100644 --- a/content/docs/deployment/environment-variables.mdx +++ b/content/docs/deployment/environment-variables.mdx @@ -98,7 +98,7 @@ Read by `@objectstack/service-print` when `PrintServicePlugin` is composed; see | `OS_AUTH_GOOGLE_ENABLED` | boolean | settings default | Settings env override for `auth.google_enabled`. Requires Google OAuth credentials from Settings or env. | | `GOOGLE_CLIENT_ID` | string | — | Deployment-level Google OAuth client id for the open-source Google login implementation. | | `GOOGLE_CLIENT_SECRET` | string | — | Deployment-level Google OAuth client secret for the open-source Google login implementation. | -| `OS_TENANCY_POSTURE` | `single` \| `group` \| `isolated` | derived from `OS_MULTI_ORG_ENABLED` | Which organization wall the authorization kernel enforces (ADR-0105 D1). `single` = no wall. `group` = `organization_id IN accessible_org_ids` — organizations are membership boundaries over one shared dataset, with union read access across every organization the caller belongs to. `isolated` = `organization_id = `, the hard legal-entity wall (formerly spelled `multi`). Unset derives from `OS_MULTI_ORG_ENABLED` (`true` ⇒ `isolated`, else `single`), so existing deployments are unchanged. An unrecognized value **refuses to boot** rather than silently falling back to a posture with no wall. Both walled postures require the enterprise `@objectstack/organizations` runtime — without it the request resolves to `single` and boot is refused unless `OS_ALLOW_DEGRADED_TENANCY=1`. 18.0 supports fresh `single` installs; upgrades and walled postures arrive in 18.x. In 18.0 `os serve` refuses `group` and `isolated` at boot. | +| `OS_TENANCY_POSTURE` | `single` \| `group` \| `isolated` | derived from `OS_MULTI_ORG_ENABLED` | Which organization wall the authorization kernel enforces (ADR-0105 D1). `single` = no wall. `group` = `organization_id IN accessible_org_ids` — organizations are membership boundaries over one shared dataset, with union read access across every organization the caller belongs to. `isolated` = `organization_id = `, the hard legal-entity wall (formerly spelled `multi`). Unset derives from `OS_MULTI_ORG_ENABLED` (`true` ⇒ `isolated`, else `single`), so existing deployments are unchanged. An unrecognized value **refuses to boot** rather than silently falling back to a posture with no wall. Both walled postures require the enterprise `@objectstack/organizations` runtime — without it the request resolves to `single` and boot is refused unless `OS_ALLOW_DEGRADED_TENANCY=1`. | | `OS_MULTI_ORG_ENABLED` | boolean | `false` | Superseded by `OS_TENANCY_POSTURE`, and still honoured: `true` selects the `isolated` posture. When `true`, organization creation/switching UI is exposed. | | `OS_AUTOMATION_SCHEDULED_WORK_ENABLED` | boolean | `false` | Whether this deployment runs **package-authored scheduled work**: time-triggered flows (a `type: 'schedule'` flow with a `config.schedule` cadence, and the `timeRelative` sweep) and packaged `defineJob` cron jobs. **OFF by default in every posture and every kernel** — a clock-driven workload's cost is a fact about the deployment, not about the flow, so an author cannot decide it and no metadata key asks them to. Accepts `true`/`1`/`on`/`yes` (case-insensitive); anything else, including unset, is off. While it is off nothing arms, and every such flow is listed by `getTriggerBindingAudit()` and the CLI startup summary as **disabled by deployment policy** — never as a binding failure, and nothing about the flow needs fixing. While it is on, what a time-triggered flow owes depends on the posture: under `isolated` it must declare `config.organization` on its start node or it is not armed; under `group` the declaration is optional and an undeclared sweep reads group-wide with each run acting as its own swept record's organization (a record-less cron there carries none, and its tenant-scoped writes are refused — declare one if it writes); under `single` it needs no declaration and its runs carry no organization. ⛔ Platform-internal scheduled work — approvals escalation, the lifecycle Reaper, the messaging dispatch loop, membership backfill — is **not** gated by this: the boundary is *authored by a package*, not *runs on the job service*. `os doctor` prints the effective value. | | `OS_PLATFORM_OWNER_EMAIL` | csv | — | The deployment's platform administrators: one email address, or a comma-separated list. A caller resolves `PLATFORM_ADMIN` when their own stored `sys_user` row carries a declared address **and** reads email-verified. Comparison is trimmed and case-insensitive; duplicates collapse and blank entries are dropped. One **unparseable** entry refuses the **whole** variable rather than just that entry — the deployment then has zero configured administrators, loudly — because a silently narrower administrator set is the worse failure. **Required under the walled postures** (`group` / `isolated`): unset or blank there **refuses to boot**, since the first self-registrant is not promoted and no grant row is written. Unset under `single` is normal — that posture still promotes the first human account. Read live per resolution, so revocation is a config change plus a process reload; there is no runtime endpoint that changes it. See [First boot: create the admin](/docs/deployment/self-hosting#first-boot-create-the-admin). | diff --git a/content/docs/deployment/self-hosting.mdx b/content/docs/deployment/self-hosting.mdx index ef667678886..61f911d836b 100644 --- a/content/docs/deployment/self-hosting.mdx +++ b/content/docs/deployment/self-hosting.mdx @@ -466,8 +466,7 @@ lock sign-up down via `OS_AUTH_SIGNUP_ENABLED` / configuration.** First-registrant promotion is removed there: with self-registration reachable, whoever posts to the sign-up endpoint first would otherwise receive cross-tenant access. No grant row is written on those -postures, ever. Declare the administrators **before first boot** instead -(18.0 supports fresh `single` installs; upgrades and walled postures arrive in 18.x. In 18.0 `os serve` refuses a walled posture at boot): +postures, ever. Declare the administrators **before first boot** instead: ```bash # one address, or a comma-separated list diff --git a/content/docs/deployment/tenancy-modes.mdx b/content/docs/deployment/tenancy-modes.mdx index 0828424cc67..ffc0d6cdc49 100644 --- a/content/docs/deployment/tenancy-modes.mdx +++ b/content/docs/deployment/tenancy-modes.mdx @@ -16,14 +16,10 @@ it generalizes the two-valued *tenancy mode* of whose `tenancy` service, membership lifecycle and degraded-tenancy boot guard all still apply — widened to the three postures. - -18.0 supports fresh `single` installs; upgrades and walled postures arrive in 18.x. In 18.0 `os serve` (and so `os dev` and `os start`) refuses `group` and -`isolated` at boot with `NOT_IMPLEMENTED`, before it reads a config. -A database an 18.0 install creates takes the same 18.x upgrade path as a v17 one. -The refusal covers the `os` boot path only. The one-shot `os migrate`, `os verify`, -`os secret` and `os storage` boots and embedded hosts (`createStandaloneStack`, -`@objectstack/adapter-hono`) are not refused, and none of them is a supported -walled deployment in 18.0. + +Fresh installs are supported under every posture: `single`, `group` and +`isolated`. The upgrade path for an existing database arrives in a later 18.x +release, and a database an 18.0 install created takes the same path as a v17 one. diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 6a4fd22525f..c82007f2aa7 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -193,7 +193,7 @@ One of the largest consumers of the flag; | 55b | Walled environment-metadata refusal does not fire | metadata-core, rest, runtime | Get: under `isolated` / `group`, a write of the environment's metadata, of any type, at the `/meta` write doors (save, reset, publish, rollback) and `POST /meta/_migrate-stored` on both transports, at every `/packages` write door (create, the manifest `PATCH`, enable, disable, publish, `publish-drafts`, `discard-drafts`, `revert`, `rollback`, `commits/:commitId/revert`, `duplicate`, `adopt-orphans`, delete), at the `/automation` definition writes (create, update, delete, clone), and at the permission-set clone door. Every other caller below the platform-operator rung is refused `403` there (ADR-0131 D3, D12 items 7–8, D11), because one organization's write would change what every organization resolves. The seeders, the package door and migrations write the environment's metadata this way. Under `single` the refusal is inert for every caller | `packages/metadata-core/src/meta-write-capability.ts#walledCatalogWriteVerdict`, `packages/rest/src/rest-server.ts#walledCatalogRefusal`, `packages/runtime/src/domains/packages.ts#refuseWalledCatalogPackageWrite`, `packages/runtime/src/domains/automation.ts#refuseWalledFlowDefinitionWrite` | | 57 | Automation run-state read, flow-authoring write, the paused-run caller gate (screen read and resume), the two operator run-lifecycle writes and the elevated self-triggered start at every door that starts a flow by name all pass | runtime | Get: run state, flow writes, a paused run's screen and its resume with no grant and without being the run's starter — and cancelling or restoring a suspension without the platform-operator rung. The screen read and the resume ask one predicate, so this is one bypass for both. The lifecycle bypass is the in-process owner's door: plugin-approvals' revise-window recall (ADR-0044) cancels on behalf of a decision it already authorized and recorded. And starting a flow declared `runAs: 'system'` whose type is self-triggered (`autolaunched`, `record_change`, `schedule`) through any door that starts a flow by name — the trigger route, an action of `type: 'flow'` (REST `/actions` and the MCP `run_action` bridge) and a declared endpoint of `type: 'flow'` — which every other caller, the guest principal included, is refused `403 PERMISSION_DENIED` (the maintainer's rulings, letters B and A). The three doors ask one predicate, so this is one bypass for all of them, and it is the in-process caller's, such as a job: inbound HTTP never carries the flag | `packages/runtime/src/domains/automation.ts#mayReadRunState`, `#refuseUngrantedFlowWrite`, `#isRunStarterOrRunStateReader`, `#refuseUngrantedRunLifecycleWrite`, `packages/runtime/src/flow-start-admission.ts#refusesElevatedSelfTriggeredStart` | | 58 | Audience-binding suggestion authority gate passes | plugin-security | Get: a system caller lists, accepts and dismisses suggestions without `manage_metadata` and, under a wall, without the platform-operator rung | `packages/plugins/plugin-security/src/suggested-audience-bindings.ts#assertDeploymentAuthority` | -| 59 | Email-template organization door passes; webhook provenance stamp skipped | plugin-email, plugin-webhooks | Get (`sys_email_template`): a create or update of a template row. The door refuses every other write that names a caller, `403 PERMISSION_DENIED` (ADR-0131 D6); the plugin itself no longer writes the table (templates resolve from the registry), so the v18 migration ceremony's promotion is the one remaining writer. Lose (`sys_webhook`): the row is not marked as an admin customization | `packages/plugins/plugin-email/src/email-template-door.ts#isOrganizationWrite`, `packages/plugins/plugin-webhooks/src/webhook-provenance.ts#bindWebhookProvenanceStamp` | +| 59 | Email-template organization door passes; webhook provenance stamp skipped | plugin-email, plugin-webhooks | Get (`sys_email_template`): a create or update of a template row. The door refuses every other write that names a caller, `403 PERMISSION_DENIED` (ADR-0131 D6); the plugin itself no longer writes the table (templates resolve from the registry), so the v18 migration ceremony's promotion is the one remaining writer. Lose (`sys_webhook`): the row is not marked as an admin customization, and the declared-name guard does not run (a new row or a rename taking a declared webhook's name, 409) — the system writers are the platform's machinery (the legacy-secret sweep, the ownership ceremony, migrations) | `packages/plugins/plugin-email/src/email-template-door.ts#isOrganizationWrite`, `packages/plugins/plugin-webhooks/src/webhook-provenance.ts#bindWebhookProvenanceStamp`, `packages/plugins/plugin-webhooks/src/declared-webhooks.ts#bindDeclaredWebhookNameGuard` | | 60 | **Automation flow data nodes re-add the `owner_id` stamp** (the one place row 2's gap is compensated inline) | service-automation | Get: a flow-authored INSERT under system elevation still lands owned, when the run resolved a user. Fill-only — flow-authored values win | `packages/services/service-automation/src/runtime-identity.ts#stampSystemInsertOwner`, called from `packages/services/service-automation/src/builtin/crud-nodes.ts#registerCrudNodes` | | 61 | Inbox caller refusal names `isSystem` as what was carried | service-messaging | Get: nothing — the refusal still fires. The flag only shapes the diagnostic, because privilege is not an authorization subject | `packages/services/service-messaging/src/inbox-caller.ts#resolveInboxRecipient` | | 61b | **The principal-session mint refuses a system context** | plugin-auth | Get: nothing — `mintPrincipalSession` answers `PERMISSION_DENIED` / `403` before anything is minted. A principal session acts as the requesting user and never as the system, so a render or any other in-process act that needs a session must carry the user's own context | `packages/plugins/plugin-auth/src/principal-session-mint.ts#refuseUpFront` | diff --git a/docs/qa/platform-checklist/RUNNER.md b/docs/qa/platform-checklist/RUNNER.md index abbe861c993..744918859f1 100644 --- a/docs/qa/platform-checklist/RUNNER.md +++ b/docs/qa/platform-checklist/RUNNER.md @@ -456,48 +456,52 @@ contradicts it, and correct it here when it does. strength of the `automated` field alone: an unrun pin is a claim, not evidence — which is the same "ticking on a label" failure clause-grained acceptance exists to prevent. -- **18.0 boots the `single` tenancy posture only: a walled posture is refused before - anything is served, so a clause that needs a walled boot has no live leg on 18.0.** - `OS_TENANCY_POSTURE=group` or `isolated` — or the legacy `OS_MULTI_ORG_ENABLED=true`, - which `resolveTenancyPosture()` (`packages/types/src/env.ts`) reads as `isolated` — makes - `os serve` print `✖ FATAL: tenancy posture 'isolated' is not supported in 18.0 - (NOT_IMPLEMENTED).` and exit **1** before a config is read, a plugin is mounted or the - HTTP server starts (`walledPostureRefusal()` in `packages/cli/src/commands/serve.ts`, - ruling record 6259698357 on the v18 epic; pinned by - `packages/cli/src/commands/serve-walled-posture-refusal.test.ts`). `os dev` and `os start` - boot through `serve`, and nothing skips it: `OS_ALLOW_DEGRADED_TENANCY` answers a - different refusal (an absent multi-org runtime), which this one now runs ahead of. The - refusal promises the walled postures for "an 18.x release" and names none. Kernel-level - boots (`@objectstack/verify`'s `bootStack`, the dogfood wall suites) never pass through - `serve` and still boot walled, which is why a walled clause is proven by its pins. - Measured at `bfc1539fe0`: `isolated`, `group` and `OS_MULTI_ORG_ENABLED=true` each exit 1 - with that FATAL on `os serve` and on `os start`, `single` passes the gate, and the pin - reports 5 passed. **So:** score a walled clause from the pins its item names, and record - its live leg `skipped` with this entry cited — "not scored on a stock 18.0 boot" — as the - clauses of the withdrawn organization-ownership command are. ⛔ Never `blocked(fixture)`: - no fixture provisions a posture the release refuses. The 18.0 sweep's records met it and - scored the live leg both `blocked(environment)` (QA runs #24557, #24559) and - `blocked(fixture)` (QA run #24558); this entry is the one word. Each item that carries - such a leg names it in its own `fixtures.knownGaps`. - -- **A stock 18.0 boot holds exactly one organization, and no door mints a second — so an - org-switch clause has no live leg on a stock boot either.** +- **A walled boot is a fixture of its own: `os serve` boots `group` and `isolated` once the + walled prerequisites are provisioned, and the stock showcase provisions none of them.** + 18.0 refused a walled posture at boot; 18.x step 4 (#24580) removed that refusal, so + `OS_TENANCY_POSTURE=group` or `isolated` (or the legacy `OS_MULTI_ORG_ENABLED=true`, which + `resolveTenancyPosture()` in `packages/types/src/env.ts` reads as `isolated`) boots through + `os serve`, and so `os dev` and `os start`. Each prerequisite below refuses the boot with a + FATAL naming itself when absent, so a refusal tells the runner what is missing, never that + the posture is unsupported: + 1. the multi-org runtime **declared by the app**: `@objectstack/organizations` in the app's + own `package.json` and installed there. Reachable only through `NODE_PATH` or a hoisted + store is refused on purpose (ADR-0093 D5). No example app declares it, so a walled run + uses a scratch app that does; + 2. `OS_PLATFORM_OWNER_EMAIL`, the deployment's platform administrators: first-registrant + promotion is off under a wall; + 3. a declared membership policy, `OS_AUTH_MEMBERSHIP_POLICY` (for example `invite-only`). + + Measured at `45898dbf6`: a scratch app declaring the real runtime, booted by the built CLI + under `isolated` with the other two set, mounted `Organizations`, answered + `GET /api/v1/discovery` 200 and logged no system-write refusal. The seven walled + e2e cases in `packages/cli/test/serve-organizations-*.e2e.test.ts` spawn that boot. + **So:** score a walled clause's live leg on such a boot, and its pins as its item says. + A run that does not provision the walled boot records the leg `blocked(fixture)`, naming + this entry and the missing prerequisite — never `fail`, and never on a faked posture. + Each item that carries such a leg names it in its own `fixtures.knownGaps`. + +- **A stock `single` boot holds exactly one organization, and no door mints a second; a + walled boot mints them through the auth API.** `POST /api/v1/auth/organization/create` answers **`403 ORGANIZATION_CREATION_DISABLED`** wherever the effective posture enforces no organization wall (`beforeCreateOrganization` in `packages/plugins/plugin-auth/src/auth-manager.ts`, the same derivation `/auth/config` publishes as `features.multiOrgEnabled: false`; pinned by - `packages/plugins/plugin-auth/src/org-create-posture-gate.test.ts`), which on 18.0 is - every boot, by the entry above. `sys_organization` offers no create over the data API - (`apiMethods: ['get', 'list', 'update']`, - `packages/platform-objects/src/identity/sys-organization.object.ts`), and 18.0 serves - fresh installs only (the same refusal's text; a 17.x database is refused at boot, - `platform-core.v18-boot-refusals`). `POST /api/v1/auth/organization/set-active` therefore - has the Default Organization, or null, to switch between. **So:** a clause that needs a - second organization — an org switch mid-flow, organization A against B — is not scored on - a stock 18.0 boot: record its live leg `skipped` with this entry cited, and score what its - pins prove. A run that stages the second organization itself (QA run #24560 wrote one with - a system write) scores a fixture of its own making, not a stock boot, and its record says - so. QA runs #24560 and #24558 met the 403; the pin reports 25 passed at `bfc1539fe0`. + `packages/plugins/plugin-auth/src/org-create-posture-gate.test.ts`), which is every stock + boot: the stock showcase boots `single`. `sys_organization` offers no create over the + data API (`apiMethods: ['get', 'list', 'update']`, + `packages/platform-objects/src/identity/sys-organization.object.ts`). On a stock boot + `POST /api/v1/auth/organization/set-active` therefore has the Default Organization, or + null, to switch between. On a walled boot (the entry above) a signed-in user creates an + organization with that same `POST /api/v1/auth/organization/create` and switches to it + with `set-active`; the walled dogfood suites + (`packages/qa/dogfood/test/wall-*.dogfood.test.ts`) create two that way. **So:** a clause + that needs a second organization (an org switch mid-flow, organization A against B) is + scored on a walled boot. On a stock boot record its live leg `blocked(fixture)` with this + entry cited, and score what its pins prove. A run that stages the second organization + with a system write (QA run #24560 did) scores a fixture of its own making, not a stock + boot, and its record says so. QA runs #24560 and #24558 met the 403; the pin reports 25 + passed at `bfc1539fe0`. - **One container builds one tree at a time: never run `pnpm build` and `pnpm objectui:build` together, and give the heavy steps a concurrency the shell's memory diff --git a/docs/qa/platform-checklist/areas/access-security.json b/docs/qa/platform-checklist/areas/access-security.json index dadff6450a8..ad5e9f278fd 100644 --- a/docs/qa/platform-checklist/areas/access-security.json +++ b/docs/qa/platform-checklist/areas/access-security.json @@ -2347,7 +2347,7 @@ "title": "A session with no active organization is a LEGAL state with fail-closed semantics: tenant-scoped reads resolve to nothing, tenant-scoped writes are refused loudly naming the missing org, and nothing is ever NULL-stamped", "since": "v17", "status": "active", - "revision": 3, + "revision": 4, "priority": "P0", "surface": "api", "personas": [ @@ -2364,7 +2364,7 @@ "knownGaps": [ "The state is timing-derived on the signup path (better-auth defers the membership write past the signup transaction, ADR-0093), so reproducing it by racing signup is flaky. The DURABLE reproduction is the removed-member path, or a multi-organization deployment whose reconciler binds nobody (ADR-0093 D1 `no-target-org`). Record WHICH producer the run used — a clause proven only on a racy producer is weaker evidence and must say so.", "No stock showcase fixture is known to configure `invite-only` or SSO-JIT-pending; those two producers are blocked(fixture) unless the run configures them.", - "18.0 adds a reason no fixture can answer: every clause needs a tenancy-walled deployment (fixtures.requires[0]), and os serve — and os dev / os start, which boot through it — refuses OS_TENANCY_POSTURE=group|isolated (and the legacy OS_MULTI_ORG_ENABLED=true) before a config is read: FATAL: tenancy posture '…' is not supported in 18.0 (NOT_IMPLEMENTED), exit 1 (packages/cli/src/commands/serve.ts#walledPostureRefusal); see RUNNER.md's environment-facts entry '18.0 boots the single tenancy posture only'. So on 18.0 the item cannot run live on any CLI boot, whatever is provisioned — not even with the @objectstack/organizations runtime revision 2's history names, which is open code since ADR-0132 and would load only after that refusal. blocked (fixture, #9334) stays the marker; on 18.0 the item's only evidence is its pin (automated.ref, which boots the posture in-process). The walled postures return in a later 18.x release" + "requires[0]'s tenancy-walled deployment is a walled boot. Since 18.x step 4 (#24580) os serve — and os dev / os start, which boot through it — boots OS_TENANCY_POSTURE=group|isolated (and the legacy OS_MULTI_ORG_ENABLED=true): the 18.0 refusal (serve.ts walledPostureRefusal) is deleted. The walled boot is provisioned per RUNNER.md's environment-facts entry 'A walled boot is a fixture of its own': the multi-org runtime declared by the app (no example app declares it), OS_PLATFORM_OWNER_EMAIL, and a declared OS_AUTH_MEMBERSHIP_POLICY; each one absent refuses the boot naming itself. The item stays blocked (fixture, #9334) for the producer reasons above, not for the posture; its pin (automated.ref) boots the posture in-process." ] }, "blocked": { "by": "fixture", "ref": "#9334" }, @@ -2461,6 +2461,12 @@ "date": "2026-10-19", "change": "fixtures.knownGaps[2] added — the 18.0 sweep's wave-wide theme '18.0 refuses every walled posture' (wave anchor #24579 §2; the item stays blocked and hidden from the selector, #24579 §0). Re-derived at base bfc1539fe0: serve.ts walledPostureRefusal refuses group and isolated before a config is read, ahead of the multi-org runtime load revision 2 cites as the refusal. No clause or marker changed", "ref": "#24687" + }, + { + "revision": 4, + "date": "2026-10-20", + "change": "fixtures.knownGaps[2] re-pointed — 18.x step 4 (#24580) deletes serve.ts walledPostureRefusal, so a walled os serve boots once provisioned per RUNNER.md's 'A walled boot is a fixture of its own'. The blocked marker stays for its own producer reasons. No clause changed", + "ref": "#24580" } ] }, @@ -2469,7 +2475,7 @@ "title": "Activation-ledger write authority (ADR-0126 §5): the manage_metadata tier refuses first on every posture, and the shared operator gate — INERT on stock `single` — demands the platform_admin POSITION in walled postures, at both the flow and action doors", "since": "v17", "status": "active", - "revision": 5, + "revision": 6, "priority": "P1", "surface": "api", "personas": [ @@ -2487,7 +2493,7 @@ "a runtime-authored scratch permission set with system_permissions ['manage_metadata'] granted to a fresh member via a sys_user_permission_set row (same runtime-authoring pattern access-security.fls-mask-and-strip's read half uses) — no stock showcase set grants manage_metadata" ], "knownGaps": [ - "The group/isolated legs (clause 5, acceptance[4]; steps[8]; variants[1]–[2]) have no live leg on 18.0: os serve — and os dev / os start, which boot through it — refuses OS_TENANCY_POSTURE=group|isolated (and the legacy OS_MULTI_ORG_ENABLED=true) before a config is read: FATAL: tenancy posture '…' is not supported in 18.0 (NOT_IMPLEMENTED), exit 1 (packages/cli/src/commands/serve.ts#walledPostureRefusal); see RUNNER.md's environment-facts entry '18.0 boots the single tenancy posture only'. That refusal runs ahead of the multi-org runtime load this gap used to name, and the runtime itself is open code since ADR-0132, so neither an 'enterprise runtime' fixture nor ADR-0093 D5's absent-runtime refusal is what a runner meets now, and no fixture unblocks the legs. Clause 5 is scored from the unit pins in automated.ref (oracle test), which drive the posture x identity matrix in-process; its live HTTP leg is not scored on a stock 18.0 boot. The walled postures return in a later 18.x release. Never fake a posture to tick them.", + "The group/isolated legs (clause 5, acceptance[4]; steps[8]; variants[1]–[2]) run live on a walled boot, which a stock showcase boot is not (fixtures.requires[0] confirms `single`). Since 18.x step 4 (#24580) os serve — and os dev / os start, which boot through it — boots OS_TENANCY_POSTURE=group|isolated (and the legacy OS_MULTI_ORG_ENABLED=true): the 18.0 refusal (serve.ts walledPostureRefusal) is deleted. The walled boot is provisioned per RUNNER.md's environment-facts entry 'A walled boot is a fixture of its own': the multi-org runtime declared by the app (no example app declares it), OS_PLATFORM_OWNER_EMAIL, and a declared OS_AUTH_MEMBERSHIP_POLICY; each one absent refuses the boot naming itself. A run that does not provision it records those legs blocked(fixture) naming that entry, never fail. Clause 5 is also scored from the unit pins in automated.ref (oracle test), which drive the posture x identity matrix in-process. Never fake a posture to tick them.", "⚠️ On the STOCK `single` posture the §5 operator gate is INERT by design (activation-gate.ts — postureEnforcesWall('single') is false, so the gate returns before the position is ever read). The refusal an unentitled caller gets on stock showcase is the manage_metadata AUTHORING TIER, not the operator gate — score clause 1/2 against the tier and never against §5, or the run records the wrong gate as proven." ] }, @@ -2500,7 +2506,7 @@ "author the scratch manage_metadata permission set, grant it to a fresh member, and prove server-side that the member's positions do NOT include platform_admin (session row / GET /auth/get-session — never the grant gesture)", "as that member: POST /api/v1/automation/showcase_urgent_task_alert/toggle {\"enabled\": false} — expect 200; read the ledger row back (metadata_type 'flow', active false, and NO organization_id key on the row — the ledger carries no tenant column at all, #15024 / ADR-0131 D7); then re-enable ({\"enabled\": true}) to restore the fixture", "as the same member: POST /api/v1/actions/_activation/showcase_task/showcase_mark_done {\"enabled\": false} — expect 200 + the metadata_type 'action' row; re-enable to restore", - "walled-posture legs: not run live on 18.0, which refuses a walled boot (knownGaps[0]) — the group/isolated matrix (org admin with manage_metadata refused naming the posture and the per-artifact remedy; platform_admin POSITION admitted; no row on refusal) is pinned at unit level by automated.ref; run those pins for clause 5" + "walled-posture legs: on a walled boot provisioned per knownGaps[0] — the group/isolated matrix (org admin with manage_metadata refused naming the posture and the per-artifact remedy; platform_admin POSITION admitted; no row on refusal) — and run the unit pins in automated.ref for clause 5" ], "acceptance": [ { @@ -2528,9 +2534,9 @@ "evidence": "the position proof + both 200 traces + the ledger rows" }, { - "clause": "walled postures (`group`/`isolated`) — pin-scored, since 18.0 refuses the walled boot a live probe needs (knownGaps[0]): an org admin holding manage_metadata is refused 403 PERMISSION_DENIED with a message naming the POSTURE and the per-artifact remedy — both remedies name the platform operator and NEITHER recommends a clone (ADR-0131 D6 · D12 items 7–8: under a wall a clone is environment authoring, the operator's too; both pins assert the operator and no 'clone') — no row is written on refusal, and a caller at the ADR-0095 PLATFORM_ADMIN posture rung (not a position name, #15981; not a capability) is admitted", + "clause": "walled postures (`group`/`isolated`) — pin-scored, with its live leg on a walled boot (knownGaps[0]): an org admin holding manage_metadata is refused 403 PERMISSION_DENIED with a message naming the POSTURE and the per-artifact remedy — both remedies name the platform operator and NEITHER recommends a clone (ADR-0131 D6 · D12 items 7–8: under a wall a clone is environment authoring, the operator's too; both pins assert the operator and no 'clone') — no row is written on refusal, and a caller at the ADR-0095 PLATFORM_ADMIN posture rung (not a position name, #15981; not a capability) is admitted", "oracle": "test", - "verify": "stands on the unit pins, since 18.0 refuses the walled boot a live probe needs (knownGaps[0]): packages/runtime/src/domains/automation-activation-posture-gate.test.ts (#12157) and action-activation-posture-gate.test.ts (#12160) drive the posture x identity matrix against both doors and assert toggleFlow/the write was never entered on refusal; the refusal sentence is packages/runtime/src/domains/activation-gate.ts#refuseUngrantedActivationWrite's, and the remedies are the `remedy` of packages/runtime/src/domains/activation-gate.ts#FLOW_ACTIVATION_SUBJECT and packages/runtime/src/domains/activation-gate.ts#ACTION_ACTIVATION_SUBJECT. Do not tick this clause from an HTTP probe on a faked posture", + "verify": "stands on the unit pins, corroborated live on a walled boot (knownGaps[0]): packages/runtime/src/domains/automation-activation-posture-gate.test.ts (#12157) and action-activation-posture-gate.test.ts (#12160) drive the posture x identity matrix against both doors and assert toggleFlow/the write was never entered on refusal; the refusal sentence is packages/runtime/src/domains/activation-gate.ts#refuseUngrantedActivationWrite's, and the remedies are the `remedy` of packages/runtime/src/domains/activation-gate.ts#FLOW_ACTIVATION_SUBJECT and packages/runtime/src/domains/activation-gate.ts#ACTION_ACTIVATION_SUBJECT. Do not tick this clause from an HTTP probe on a faked posture", "evidence": "the two test runs' output, cited by test name" }, { @@ -2548,8 +2554,8 @@ ], "variants": [ "single (stock — gate inert, tier decides)", - "group (pin-only on 18.0: the walled boot is refused)", - "isolated (pin-only on 18.0: the walled boot is refused)" + "group (live on a walled boot, knownGaps[0]; the pins either way)", + "isolated (live on a walled boot, knownGaps[0]; the pins either way)" ], "enumSource": { "file": "packages/spec/src/security/tenancy-posture.ts", @@ -2598,6 +2604,12 @@ "date": "2026-10-20", "change": "line citations re-pointed — the carried note from round 5 of wave anchor #24579 (its report, comment 6279808387). acceptance[3].clause, acceptance[4].verify, acceptance[5].verify, negative[3] and source[0] cited activation-gate.ts by line number in a comma-range spelling (',141-147', ',155-163', ',190', and source[0]'s five ranges), the rot class the line-citation limb exists for and does not detect; at this base those lines hold other code. Each now anchors the declaring symbol (refuseUngrantedActivationWrite, refuseUngrantedActivationAuthoring, readEffectiveTenancyPosture, FLOW_ACTIVATION_SUBJECT, ACTION_ACTIVATION_SUBJECT) or names the header section in prose. Reading the gate for that, acceptance[4].clause was corrected beside its walled lead, which is unchanged: it said flows recommend the clone path and that the platform_admin POSITION admits; both remedies name the operator and neither a clone (ADR-0131 D6 · D12 items 7–8, as both pins assert), and the gate reads the PLATFORM_ADMIN posture rung, never a position name (#15981). Not loosened: the oracle and the pins are unchanged", "ref": "#24717" + }, + { + "revision": 6, + "date": "2026-10-20", + "change": "fixtures.knownGaps[0], steps[8], acceptance[4] (clause, verify) and variants[1]–[2] re-pointed — 18.x step 4 (#24580) deletes serve.ts walledPostureRefusal, so the walled legs run live on a walled boot provisioned per RUNNER.md's 'A walled boot is a fixture of its own'; unprovisioned, they are blocked(fixture). Clause 5 keeps its unit pins as its oracle; no clause deleted", + "ref": "#24580" } ] }, @@ -2798,7 +2810,7 @@ "title": "OS_PLATFORM_OWNER_EMAIL platform-admin anchor: only a VERIFIED stored-email match confers PLATFORM_ADMIN — unset, unverified match, non-match and malformed lists each confer nothing, fail closed", "since": "v17", "status": "active", - "revision": 4, + "revision": 5, "priority": "P1", "surface": "api", "personas": [ @@ -2816,7 +2828,7 @@ ] }, "steps": [ - "boot showcase single (OS_TENANCY_POSTURE=single — also what a deployment with no tenancy configuration resolves to) with the variable UNSET — a walled posture (group / isolated) is out of scope here (see negative), and on 18.0 it does not boot at all: os serve refuses it before a config is read, whether the variable is set or unset (packages/cli/src/commands/serve.ts#walledPostureRefusal; RUNNER.md environment facts) — and keep that posture for every restart below; admin session; sign up fresh member M and record M's email; as M: GET /api/v1/datasources and GET /api/v1/auth/me/permissions (read systemPermissions) — the baseline refusals", + "boot showcase single (OS_TENANCY_POSTURE=single — also what a deployment with no tenancy configuration resolves to) with the variable UNSET — a walled posture (group / isolated) is out of scope here (see negative), and it boots only once provisioned per RUNNER.md's environment-facts entry 'A walled boot is a fixture of its own', where this very variable is a prerequisite: unset, the walled boot is refused at plugin-auth init — and keep that posture for every restart below; admin session; sign up fresh member M and record M's email; as M: GET /api/v1/datasources and GET /api/v1/auth/me/permissions (read systemPermissions) — the baseline refusals", "restart with OS_PLATFORM_OWNER_EMAIL= (M's row is unverified); as M repeat both probes", "restart with OS_PLATFORM_OWNER_EMAIL=; repeat M's probes", "entitled side (see knownGaps for the verified-member fixture): with M's row verified and the variable naming M, as M repeat the probes and read /api/v1/auth/me/permissions — expect the declared admin envelope; where the fixture is unavailable, run the unit pin and cite its output instead. This step runs BEFORE the malformed variant on purpose: the malformed list's 'the well-formed sibling confers nothing' check only discriminates once M is verified", @@ -2919,6 +2931,12 @@ "date": "2026-10-20", "change": "steps[0]'s walled parenthetical corrected — the carried note from round 5 of wave anchor #24579 (its report, comment 6279808387). It said a walled posture refuses to boot with the variable unset (plugin-auth init); on 18.0 serve.ts#walledPostureRefusal refuses a walled posture earlier, before any config or plugin, whether the variable is set or unset. The item boots single throughout and no clause scores the walled case, so no clause changed", "ref": "#24717" + }, + { + "revision": 5, + "date": "2026-10-20", + "change": "steps[0]'s walled parenthetical re-pointed — 18.x step 4 (#24580) deletes serve.ts walledPostureRefusal, so a walled posture boots once provisioned per RUNNER.md's 'A walled boot is a fixture of its own', and with this item's variable unset it is refused at plugin-auth init again (revision 3's reading). The item still boots single throughout and no clause scores the walled case; no clause changed", + "ref": "#24580" } ] }, @@ -3367,7 +3385,7 @@ "title": "Under a walled posture (group / isolated) environment metadata and the security catalog belong to the platform operator: an org owner holding manage_metadata is refused 403 on every /meta write verb, every /packages write, install-local of a catalog-carrying package, _migrate-stored and POST /automation — and the single posture is unchanged", "since": "v18", "status": "active", - "revision": 2, + "revision": 3, "priority": "P0", "surface": "api", "personas": [ @@ -3383,7 +3401,7 @@ "on the walled boot: a second organization whose owner is granted manage_metadata but not the platform_admin position" ], "knownGaps": [ - "Every live leg here needs the walled boot fixtures.requires[1] names and the second organization fixtures.requires[2] names, and a stock 18.0 boot has neither: os serve — and os dev / os start, which boot through it — refuses OS_TENANCY_POSTURE=group|isolated (and the legacy OS_MULTI_ORG_ENABLED=true) before a config is read: FATAL: tenancy posture '…' is not supported in 18.0 (NOT_IMPLEMENTED), exit 1 (packages/cli/src/commands/serve.ts#walledPostureRefusal), and a stock boot holds one organization (see RUNNER.md's environment-facts entries '18.0 boots the single tenancy posture only' and 'A stock 18.0 boot holds exactly one organization'). So on a stock 18.0 boot acceptance[1], acceptance[2] and negative[0] are not scored: record them skipped with those entries cited, never blocked(fixture) and never fail. steps[3]'s single-posture admit is only the control for the walled refusal and proves nothing alone. The item's 18.0 evidence is acceptance[0]: its pins boot the walled posture at kernel level, where serve's refusal never runs. The clauses stay: the walled postures return in a later 18.x release" + "Every live leg here needs the walled boot fixtures.requires[1] names and the second organization fixtures.requires[2] names. Since 18.x step 4 (#24580) os serve — and os dev / os start, which boot through it — boots OS_TENANCY_POSTURE=group|isolated (and the legacy OS_MULTI_ORG_ENABLED=true): the 18.0 refusal (serve.ts walledPostureRefusal) is deleted. The walled boot is provisioned per RUNNER.md's environment-facts entry 'A walled boot is a fixture of its own': the multi-org runtime declared by the app (no example app declares it), OS_PLATFORM_OWNER_EMAIL, and a declared OS_AUTH_MEMBERSHIP_POLICY; each one absent refuses the boot naming itself. On that boot the second organization is created through POST /api/v1/auth/organization/create (RUNNER.md 'A stock `single` boot holds exactly one organization, and no door mints a second; a walled boot mints them through the auth API'). A run that does not provision them records acceptance[1], acceptance[2] and negative[0] blocked(fixture) naming those entries, never fail. steps[3]'s single-posture admit is only the control for the walled refusal and proves nothing alone. acceptance[0]'s pins boot the walled posture at kernel level and stay the item's pinned evidence" ] }, "steps": [ @@ -3440,6 +3458,12 @@ "date": "2026-10-19", "change": "fixtures.knownGaps added and steps[0]'s object body made to declare sharingModel — the 18.0 sweep's wave-wide theme '18.0 refuses every walled posture' (wave anchor #24579 §2; QA runs #24556, #24558 and #24559 scored acceptance[1], acceptance[2] and negative[0] blocked on the boot's NOT_IMPLEMENTED FATAL), and round 2's report on the same wave anchor (a minimal object body is refused 422 security-owd-unset). Re-derived at base bfc1539fe0: serve.ts walledPostureRefusal refuses the walled boot before a config is read; meta-item-write-routes.ts asks the walled refusal ahead of saveMetaItem, so the org owner's refusal is unaffected, while the operator's and the single-posture admin's repeats reach the authoring rules, where validate-security-posture.ts refuses an object with no sharingModel at error tier. No clause deleted", "ref": "#24687" + }, + { + "revision": 3, + "date": "2026-10-20", + "change": "fixtures.knownGaps[0] re-pointed — 18.x step 4 (#24580) deletes serve.ts walledPostureRefusal: the walled boot fixtures.requires[1] names now boots once provisioned per RUNNER.md's 'A walled boot is a fixture of its own', and the second organization is created on it through the auth API. Unprovisioned legs are blocked(fixture), no longer skipped. No clause changed", + "ref": "#24580" } ] }, @@ -3684,7 +3708,7 @@ "title": "Grant-table write contract (sys_user_permission_set / sys_user_position): the retired permission_set_id column is refused, a duplicate grant is 409, an inverted validity window is 400, granted_by is stamped with the caller, and (walled) a grant to a non-member is refused", "since": "v18", "status": "active", - "revision": 3, + "revision": 4, "priority": "P1", "surface": "api", "personas": [ @@ -3698,7 +3722,7 @@ "the member's user id and a showcase permission-set NAME" ], "knownGaps": [ - "acceptance[4] and steps[4] are written for a walled boot holding a second organization A, and a stock 18.0 boot has neither: os serve — and os dev / os start, which boot through it — refuses OS_TENANCY_POSTURE=group|isolated (and the legacy OS_MULTI_ORG_ENABLED=true) before a config is read: FATAL: tenancy posture '…' is not supported in 18.0 (NOT_IMPLEMENTED), exit 1 (packages/cli/src/commands/serve.ts#walledPostureRefusal), and a stock boot holds one organization (see RUNNER.md's environment-facts entries '18.0 boots the single tenancy posture only' and 'A stock 18.0 boot holds exactly one organization'). As written, acceptance[4] is not scored on a stock 18.0 boot: record it skipped with those entries cited, never blocked(fixture) and never fail. Its refusal is pinned through acceptance[0] (grant-holder-membership-refusal.test.ts in automated.ref). ⚠️ The refusal itself does not read the posture — that pin's case 'the predicate is the same on every posture' refuses an organization-scoped row naming a non-member under single too (packages/plugins/plugin-security/src/grant-holder-membership-refusal.ts#registerGrantHolderMembershipRefusal) — so the leg is walled by its wording, not by its mechanism. A single-posture rig with a grantee who holds no membership in the writer's organization is not measured here" + "acceptance[4] and steps[4] are written for a walled boot holding a second organization A, which a stock showcase boot is not. Since 18.x step 4 (#24580) os serve — and os dev / os start, which boot through it — boots OS_TENANCY_POSTURE=group|isolated (and the legacy OS_MULTI_ORG_ENABLED=true): the 18.0 refusal (serve.ts walledPostureRefusal) is deleted. The walled boot is provisioned per RUNNER.md's environment-facts entry 'A walled boot is a fixture of its own': the multi-org runtime declared by the app (no example app declares it), OS_PLATFORM_OWNER_EMAIL, and a declared OS_AUTH_MEMBERSHIP_POLICY; each one absent refuses the boot naming itself. The second organization is created on that boot through POST /api/v1/auth/organization/create (RUNNER.md 'A stock `single` boot holds exactly one organization, and no door mints a second; a walled boot mints them through the auth API'). A run that does not provision them records acceptance[4] blocked(fixture) naming those entries, never fail. Its refusal is pinned through acceptance[0] (grant-holder-membership-refusal.test.ts in automated.ref). ⚠️ The refusal itself does not read the posture — that pin's case 'the predicate is the same on every posture' refuses an organization-scoped row naming a non-member under single too (packages/plugins/plugin-security/src/grant-holder-membership-refusal.ts#registerGrantHolderMembershipRefusal) — so the leg is walled by its wording, not by its mechanism. A single-posture rig with a grantee who holds no membership in the writer's organization is not measured here" ] }, "steps": [ @@ -3767,6 +3791,12 @@ "date": "2026-10-19", "change": "fixtures.knownGaps added — the 18.0 sweep's wave-wide theme '18.0 refuses every walled posture' (wave anchor #24579 §2; QA run #24559 scored acceptance[4] pass on its pin with the live leg blocked(environment) on the boot's NOT_IMPLEMENTED FATAL; rounds 2 and 3 named the leg). Re-derived at base bfc1539fe0: serve.ts walledPostureRefusal refuses the walled boot; grant-holder-membership-refusal.ts's predicate is posture-independent, which the gap records without re-pointing the clause. No clause deleted", "ref": "#24687" + }, + { + "revision": 4, + "date": "2026-10-20", + "change": "fixtures.knownGaps[0] re-pointed — 18.x step 4 (#24580) deletes serve.ts walledPostureRefusal, so acceptance[4]'s walled boot with a second organization is provisioned per RUNNER.md's 'A walled boot is a fixture of its own'; unprovisioned, it is blocked(fixture). The posture-independence note is kept. No clause changed", + "ref": "#24580" } ] }, @@ -3775,7 +3805,7 @@ "title": "Write guards tightened on v18: a walled update cannot null out organization_id, a detail write naming an unreadable header is reference_not_found, a validation rule reading an unreadable header answers like a missing one, and a controlled_by_parent child's attachments / comments / explain follow the master's edit grant", "since": "v18", "status": "active", - "revision": 2, + "revision": 3, "priority": "P1", "surface": "api", "personas": [ @@ -3790,7 +3820,7 @@ "for the organization leg: the walled boot" ], "knownGaps": [ - "acceptance[1] and steps[0] (the organization leg, on fixtures.requires[2]'s walled boot) need a walled posture: the Layer 0 write wall refuses an update that empties organization_id only under one, and its pin's control says the single posture is unchanged (tenant-wall-organization-removal.test.ts in automated.ref). 18.0 refuses the walled boot: os serve — and os dev / os start, which boot through it — refuses OS_TENANCY_POSTURE=group|isolated (and the legacy OS_MULTI_ORG_ENABLED=true) before a config is read: FATAL: tenancy posture '…' is not supported in 18.0 (NOT_IMPLEMENTED), exit 1 (packages/cli/src/commands/serve.ts#walledPostureRefusal); see RUNNER.md's environment-facts entry '18.0 boots the single tenancy posture only'. On a stock 18.0 boot acceptance[1] is not scored: record it skipped with that entry cited, never blocked(fixture) and never fail; acceptance[0]'s pin carries the leg. acceptance[2] and acceptance[3] are not walled legs and run as written" + "acceptance[1] and steps[0] (the organization leg, on fixtures.requires[2]'s walled boot) need a walled posture: the Layer 0 write wall refuses an update that empties organization_id only under one, and its pin's control says the single posture is unchanged (tenant-wall-organization-removal.test.ts in automated.ref). Since 18.x step 4 (#24580) os serve — and os dev / os start, which boot through it — boots OS_TENANCY_POSTURE=group|isolated (and the legacy OS_MULTI_ORG_ENABLED=true): the 18.0 refusal (serve.ts walledPostureRefusal) is deleted. The walled boot is provisioned per RUNNER.md's environment-facts entry 'A walled boot is a fixture of its own': the multi-org runtime declared by the app (no example app declares it), OS_PLATFORM_OWNER_EMAIL, and a declared OS_AUTH_MEMBERSHIP_POLICY; each one absent refuses the boot naming itself. A run that does not provision it records acceptance[1] blocked(fixture) naming that entry, never fail; acceptance[0]'s pin carries the leg. acceptance[2] and acceptance[3] are not walled legs and run as written" ] }, "steps": [ @@ -3853,6 +3883,12 @@ "date": "2026-10-19", "change": "fixtures.knownGaps added — the 18.0 sweep's wave-wide theme '18.0 refuses every walled posture' (wave anchor #24579 §2; QA run #24559 scored acceptance[1] pass on its pin with the live leg blocked(environment) on the boot's NOT_IMPLEMENTED FATAL). Re-derived at base bfc1539fe0: serve.ts walledPostureRefusal refuses the walled boot, and tenant-wall-organization-removal.test.ts pins the emptied-organization refusal as a walled-posture rule with the single posture unchanged. No clause deleted", "ref": "#24687" + }, + { + "revision": 3, + "date": "2026-10-20", + "change": "fixtures.knownGaps[0] re-pointed — 18.x step 4 (#24580) deletes serve.ts walledPostureRefusal, so the organization leg's walled boot is provisioned per RUNNER.md's 'A walled boot is a fixture of its own'; unprovisioned, it is blocked(fixture). No clause changed", + "ref": "#24580" } ] }, diff --git a/docs/qa/platform-checklist/areas/attachments-storage.json b/docs/qa/platform-checklist/areas/attachments-storage.json index 3d1de7b3e6d..a43269e943a 100644 --- a/docs/qa/platform-checklist/areas/attachments-storage.json +++ b/docs/qa/platform-checklist/areas/attachments-storage.json @@ -1086,7 +1086,7 @@ "title": "Chunked upload integrity on v18: complete with a missing chunk is 409 with details.missingChunks and the session stays in_progress, a re-sent chunk counts once, parallel chunks all record, and an org switch mid-upload answers 409 on chunk and complete, and on progress only once the session's expiry stamp is due", "since": "v18", "status": "active", - "revision": 4, + "revision": 5, "priority": "P2", "surface": "api", "personas": [ @@ -1098,7 +1098,7 @@ "a fresh boot of the showcase: `pnpm dev -- --fresh -p PORT --seed-admin` (ephemeral DB; the seeded admin is printed)" ], "knownGaps": [ - "acceptance[0] names the org-switch guard among what the pins prove, but none of the three automated.ref files drives a CHUNKED door after an organization switch, so the door-level 409 of acceptance[3] (chunk PUT, chunked complete, a progress read whose expiry stamp is due) is unpinned. The guard is the doors' shared requireStartingOrganization (packages/services/service-storage/src/storage-routes.ts, asking packages/services/service-storage/src/metadata-store.ts#organizationOutOfWriteReach); the one door-level pin of it is the presigned COMMIT door's 409 RESOURCE_CONFLICT in packages/services/service-storage/src/error-envelope.conformance.test.ts, a sibling door, and chunk-part-record-concurrency.test.ts pins only the store-level miss. Score acceptance[0] on chunk accounting and the missing-chunk refusal alone, and do not cite the pins for the org-switch guard. acceptance[3]'s live leg, steps[3], needs a second organization the admin can make active, and a stock 18.0 boot holds one: POST /api/v1/auth/organization/create answers 403 ORGANIZATION_CREATION_DISABLED on every 18.0 boot, since 18.0 boots no walled posture (packages/plugins/plugin-auth/src/auth-manager.ts#ORGANIZATION_CREATION_DISABLED_CODE; QA runs #24560 / #24558; see RUNNER.md's environment-facts entry 'A stock 18.0 boot holds exactly one organization'). On a stock 18.0 boot acceptance[3] is not scored: record it skipped with that entry cited, never blocked(fixture) and never fail. With no chunked-door pin of the guard either, a stock 18.0 run carries no verdict on it; a run that stages the second organization itself, as QA run #24560 did with a system write, scores a fixture of its own making and records that its verdict rests on it" + "acceptance[0] names the org-switch guard among what the pins prove, but none of the three automated.ref files drives a CHUNKED door after an organization switch, so the door-level 409 of acceptance[3] (chunk PUT, chunked complete, a progress read whose expiry stamp is due) is unpinned. The guard is the doors' shared requireStartingOrganization (packages/services/service-storage/src/storage-routes.ts, asking packages/services/service-storage/src/metadata-store.ts#organizationOutOfWriteReach); the one door-level pin of it is the presigned COMMIT door's 409 RESOURCE_CONFLICT in packages/services/service-storage/src/error-envelope.conformance.test.ts, a sibling door, and chunk-part-record-concurrency.test.ts pins only the store-level miss. Score acceptance[0] on chunk accounting and the missing-chunk refusal alone, and do not cite the pins for the org-switch guard. acceptance[3]'s live leg, steps[3], needs a second organization the admin can make active. A stock boot holds one: POST /api/v1/auth/organization/create answers 403 ORGANIZATION_CREATION_DISABLED on every boot whose posture enforces no wall (packages/plugins/plugin-auth/src/auth-manager.ts#ORGANIZATION_CREATION_DISABLED_CODE; QA runs #24560 / #24558). Since 18.x step 4 (#24580) a walled boot provisioned per RUNNER.md's environment-facts entry 'A walled boot is a fixture of its own' creates it through that same door (RUNNER.md 'A stock `single` boot holds exactly one organization, and no door mints a second; a walled boot mints them through the auth API'). A run that provisions neither records acceptance[3] blocked(fixture) naming those entries, never fail. With no chunked-door pin of the guard either, such a run carries no verdict on it; a run that stages the second organization with a system write, as QA run #24560 did, scores a fixture of its own making and records that its verdict rests on it" ] }, "steps": [ @@ -1165,6 +1165,12 @@ "date": "2026-10-19", "change": "fixtures.knownGaps[0]'s pointer to the wave's second-organization theme replaced by the theme's own statement — the 18.0 sweep's wave-wide theme '18.0 permits no second organization' (wave anchor #24579 §2; QA runs #24560 and #24558). Re-derived at base bfc1539fe0: auth-manager.ts beforeCreateOrganization answers 403 ORGANIZATION_CREATION_DISABLED wherever the effective posture enforces no wall, which on 18.0 is every boot (serve.ts walledPostureRefusal), and sys_organization declares no create method. The clause and its pins are unchanged; the gap now says how a stock run scores it", "ref": "#24687" + }, + { + "revision": 5, + "date": "2026-10-20", + "change": "fixtures.knownGaps[0]'s second-organization leg re-pointed — 18.x step 4 (#24580) lets a walled boot create the second organization through the auth API, per RUNNER.md's renamed entries 'A walled boot is a fixture of its own' and 'A stock `single` boot holds exactly one organization…'; unprovisioned, acceptance[3] is blocked(fixture). No clause changed", + "ref": "#24580" } ] } diff --git a/docs/qa/platform-checklist/areas/platform-core.json b/docs/qa/platform-checklist/areas/platform-core.json index 7fefa25f2cd..bed72a18245 100644 --- a/docs/qa/platform-checklist/areas/platform-core.json +++ b/docs/qa/platform-checklist/areas/platform-core.json @@ -3211,7 +3211,7 @@ "title": "Settings on v18: a global-scope key is stored in sys_platform_setting (readable through /data only with manage_platform_settings), and under a walled posture one organization's tenant save or lock does not change or lock another's, with a data-API read of sys_setting filtered to readable namespaces", "since": "v18", "status": "active", - "revision": 2, + "revision": 3, "priority": "P1", "surface": "api", "personas": [ @@ -3227,7 +3227,7 @@ "on the walled boot: two organizations A and B with an admin each" ], "knownGaps": [ - "acceptance[2] and acceptance[3] are walled legs, and fixtures.requires[1]–[2], personas[2] and steps[2]–[3] serve only them. 18.0 refuses the boot they need: os serve — and os dev / os start, which boot through it — refuses OS_TENANCY_POSTURE=group|isolated (and the legacy OS_MULTI_ORG_ENABLED=true) before a config is read: FATAL: tenancy posture '…' is not supported in 18.0 (NOT_IMPLEMENTED), exit 1 (packages/cli/src/commands/serve.ts#walledPostureRefusal). A stock 18.0 boot also holds one organization, so there is no B to read (see RUNNER.md's environment-facts entries '18.0 boots the single tenancy posture only' and 'A stock 18.0 boot holds exactly one organization'). On a stock 18.0 boot neither clause is scored: record both skipped with those entries cited, never blocked(fixture) and never fail. Their mechanism is scored through acceptance[0]: settings-organization-isolation.pin.test.ts drives two organizations, the lock and the organization-less refusal under a walled posture in-process, and asserts that `single` does not refuse. acceptance[0] and acceptance[1] run as written. The clauses stay: the walled postures return in a later 18.x release" + "acceptance[2] and acceptance[3] are walled legs, and fixtures.requires[1]–[2], personas[2] and steps[2]–[3] serve only them. Since 18.x step 4 (#24580) os serve — and os dev / os start, which boot through it — boots OS_TENANCY_POSTURE=group|isolated (and the legacy OS_MULTI_ORG_ENABLED=true): the 18.0 refusal (serve.ts walledPostureRefusal) is deleted. The walled boot is provisioned per RUNNER.md's environment-facts entry 'A walled boot is a fixture of its own': the multi-org runtime declared by the app (no example app declares it), OS_PLATFORM_OWNER_EMAIL, and a declared OS_AUTH_MEMBERSHIP_POLICY; each one absent refuses the boot naming itself. On that boot organizations A and B are created through POST /api/v1/auth/organization/create (RUNNER.md 'A stock `single` boot holds exactly one organization, and no door mints a second; a walled boot mints them through the auth API'). A run that does not provision them records both clauses blocked(fixture) naming those entries, never fail. Their mechanism is scored through acceptance[0]: settings-organization-isolation.pin.test.ts drives two organizations, the lock and the organization-less refusal under a walled posture in-process, and asserts that `single` does not refuse. acceptance[0] and acceptance[1] run as written." ] }, "steps": [ @@ -3287,6 +3287,12 @@ "date": "2026-10-19", "change": "fixtures.knownGaps added and acceptance[3].clause marked walled — the 18.0 sweep's wave-wide theme '18.0 refuses every walled posture' (wave anchor #24579 §2; QA runs #24557 and #24558 scored both clauses blocked(environment) on the boot's NOT_IMPLEMENTED FATAL). Re-derived at base bfc1539fe0: serve.ts walledPostureRefusal refuses group and isolated before a config is read, and settings-service.ts refuses an organization-less tenant key only under a walled posture, so acceptance[3], which steps[3] already ran on the walled boot, was the second walled leg. No clause deleted; the shared statement is RUNNER.md's environment-facts entry, which this gap points at", "ref": "#24687" + }, + { + "revision": 3, + "date": "2026-10-20", + "change": "fixtures.knownGaps[0] re-pointed — 18.x step 4 (#24580) deletes serve.ts walledPostureRefusal, so the walled boot fixtures.requires[1]–[2] name is provisioned per RUNNER.md's 'A walled boot is a fixture of its own', and A and B are created on it through the auth API; unprovisioned, the walled clauses are blocked(fixture). No clause changed", + "ref": "#24580" } ] }, diff --git a/packages/cli/src/commands/doctor-env-provenance.test.ts b/packages/cli/src/commands/doctor-env-provenance.test.ts index ddd6058e53f..d85da1eef51 100644 --- a/packages/cli/src/commands/doctor-env-provenance.test.ts +++ b/packages/cli/src/commands/doctor-env-provenance.test.ts @@ -455,9 +455,7 @@ describe('os doctor, end to end, against a posture that only exists in .env', () it('names the file, refuses to call the environment functional, and exits 1', async () => { // ── Control: the same .env, a posture that parses ───────────────────── - // `single`, not `isolated`: 18.0 refuses a walled posture at `os serve`, so - // doctor reports one, and a walled control would carry a posture row. - fs.writeFileSync(path.join(tmp, '.env'), 'OS_TENANCY_POSTURE=single\n'); + fs.writeFileSync(path.join(tmp, '.env'), 'OS_TENANCY_POSTURE=isolated\n'); const healthy = await runDoctor(); expect(healthy.exitCode).toBeUndefined(); diff --git a/packages/cli/src/commands/doctor-organizations-message-spelling.test.ts b/packages/cli/src/commands/doctor-organizations-message-spelling.test.ts index f078a2b6692..ec0e5f84e82 100644 --- a/packages/cli/src/commands/doctor-organizations-message-spelling.test.ts +++ b/packages/cli/src/commands/doctor-organizations-message-spelling.test.ts @@ -168,7 +168,7 @@ describe('doctor — the posture description an operator reads names the declara it('leg (i) — the `isolated` fix-list bullet names it, with the spacing intact', () => { expect(renderIsolatedBullet()).toBe( ` • OS_TENANCY_POSTURE=isolated — organization wall + the ${PKG} runtime ` - + "(the legacy spelling 'multi' is accepted and normalizes to this); refused at boot in 18.0, which supports single only", + + "(the legacy spelling 'multi' is accepted and normalizes to this)", ); }); @@ -242,14 +242,14 @@ describe('#12464 CONTROL — these pins can say no', () => { // decorative. Anchored on the exact regression interpolation invites. const expected = ` • OS_TENANCY_POSTURE=isolated — organization wall + the ${PKG} runtime ` - + "(the legacy spelling 'multi' is accepted and normalizes to this); refused at boot in 18.0, which supports single only"; + + "(the legacy spelling 'multi' is accepted and normalizes to this)"; expect( ` • OS_TENANCY_POSTURE=isolated — organization wall + the ${PKG} runtime` - + "(the legacy spelling 'multi' is accepted and normalizes to this); refused at boot in 18.0, which supports single only", + + "(the legacy spelling 'multi' is accepted and normalizes to this)", ).not.toBe(expected); expect( ` • OS_TENANCY_POSTURE=isolated — organization wall + the ${PKG} runtime ` - + "(the legacy spelling 'multi' is accepted and normalizes to this); refused at boot in 18.0, which supports single only", + + "(the legacy spelling 'multi' is accepted and normalizes to this)", ).not.toBe(expected); // …and says yes to the real thing, so the two `not.toBe`s above are a // reading rather than a pair of vacuous truths. diff --git a/packages/cli/src/commands/doctor-tenancy-posture-report.test.ts b/packages/cli/src/commands/doctor-tenancy-posture-report.test.ts index 0b15a5beace..2466fcf6569 100644 --- a/packages/cli/src/commands/doctor-tenancy-posture-report.test.ts +++ b/packages/cli/src/commands/doctor-tenancy-posture-report.test.ts @@ -61,7 +61,6 @@ import { TENANCY_POSTURES } from '@objectstack/spec/security'; import Doctor, { resolveTenancyPostureOrFinding, - walledPostureFinding, readDotenvFiles, type DotenvReading, } from './doctor.js'; @@ -291,9 +290,7 @@ describe('os doctor reports an unrecognized posture and exits non-zero', () => { it('names OS_TENANCY_POSTURE, refuses to call the environment functional, and exits 1', async () => { // ── Control: the same environment with a posture that parses ────────── - // `single`, not `isolated`: 18.0 refuses a walled posture at `os serve`, so - // doctor reports one (`walledPostureFinding`, below) and it is no control. - process.env.OS_TENANCY_POSTURE = 'single'; + process.env.OS_TENANCY_POSTURE = 'isolated'; const healthy = await runDoctor(); // Doctor completes normally. This is the sentence #5382 quoted, and here it @@ -346,33 +343,4 @@ describe('os doctor reports an unrecognized posture and exits non-zero', () => { // `}, 60_000)` cases in this package (`utils/sqlite-occupancy.test.ts`, // `utils/schema-migrate.deferred-ddl.integration.test.ts`). }, 60_000); - - it('18.0 — a walled posture is reported as a warning naming the `os serve` refusal, and exits 0', async () => { - process.env.OS_TENANCY_POSTURE = 'group'; - const walled = await runDoctor(); - expect(walled.out).toContain('Tenancy posture'); - expect(walled.out).toContain("the tenancy posture is 'group'"); - expect(walled.out).toContain('`os serve`'); - expect(walled.out).toContain('Environment is functional but has some warnings'); - expect(walled.exitCode).toBeUndefined(); - }, 60_000); -}); - -describe('walledPostureFinding — 18.0 supports `single` only', () => { - it('answers null for `single`, a warning row for each walled posture', () => { - for (const posture of TENANCY_POSTURES) { - const row = walledPostureFinding(posture); - if (posture === 'single') { - expect(row).toBeNull(); - continue; - } - expect(row?.name).toBe('Tenancy posture'); - expect(row?.status).toBe('warning'); - expect(row?.message).toContain(`'${posture}'`); - expect(row?.message).toContain('18.0'); - expect(row?.fix).toContain('OS_TENANCY_POSTURE=single'); - } - expect(TENANCY_POSTURES).toContain('group'); - expect(TENANCY_POSTURES).toContain('isolated'); - }); }); diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index 13fa0b55e35..7d2cb9411fc 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -34,7 +34,7 @@ import { // The posture vocabulary, read from the package that DEFINES it (#5382) — the // fix list below enumerates the accepted values, and a second literal list // would be free to drift the day a posture is added. -import { TENANCY_POSTURES, postureEnforcesWall, type TenancyPosture } from '@objectstack/spec/security'; +import { TENANCY_POSTURES, type TenancyPosture } from '@objectstack/spec/security'; interface HealthCheckResult { name: string; @@ -825,32 +825,6 @@ export function resolveTenancyPostureOrFinding(reading: DotenvReading): TenancyP } } -/** - * The row doctor reports for a walled posture (`group` / `isolated`) in 18.0, - * or `null` for any other. - * - * 18.0 supports the `single` posture only, and `os serve` (so `os dev` and - * `os start`) refuses a walled one before it reads a config - * (`walledPostureRefusal` in `serve.ts`). Without this row doctor printed - * nothing about a valid posture, so it said nothing about a boot that will be - * refused. A `warning`, not an `error`: the one-shot commands doctor also - * fronts (`os migrate`, `os verify`, `os secret`, `os storage`) still boot - * under it. The 18.x release that restores walled postures deletes this row. - */ -export function walledPostureFinding(posture: TenancyPosture): HealthCheckResult | null { - if (!postureEnforcesWall(posture)) return null; - return { - name: 'Tenancy posture', - status: 'warning', - message: - `the tenancy posture is '${posture}' — 18.0 supports the 'single' posture only, so the next` - + ' `os serve` (and so `os dev` and `os start`) refuses it at boot', - fix: - 'Set OS_TENANCY_POSTURE=single, or unset both OS_TENANCY_POSTURE and OS_MULTI_ORG_ENABLED;\n' - + ' or keep this deployment on a 17.x release until the 18.x release that carries walled postures.', - }; -} - // ─── Config-Aware Checks ──────────────────────────────────────────── // Exported for the pin on its carrier reading below; `doctor` itself is the @@ -2419,13 +2393,9 @@ export default class Doctor extends Command { // #5382 — the posture verdict resolved at the top of `run()`, reported here // among the other environment facts. Only an unrecognized value produces a // row: a valid posture is not a finding, and doctor's output for every - // environment that can actually start is unchanged. In 18.0 a walled - // posture cannot start under `os serve`, so it gets a row too. + // environment that can actually start is unchanged. if (!postureReading.ok) { results.push(postureReading.result); - } else { - const walled = walledPostureFinding(postureReading.posture); - if (walled) results.push(walled); } // ── Installed-package ledger readability (#5429) ───────────────── diff --git a/packages/cli/src/commands/serve-organizations-message-spelling.test.ts b/packages/cli/src/commands/serve-organizations-message-spelling.test.ts index cd8367bf52b..d98957d73eb 100644 --- a/packages/cli/src/commands/serve-organizations-message-spelling.test.ts +++ b/packages/cli/src/commands/serve-organizations-message-spelling.test.ts @@ -316,7 +316,7 @@ describe('serve — the posture description an operator reads names the declarat if (verdict.ok) return; expect(lines(verdict.fatal)).toContain( ` • set OS_TENANCY_POSTURE=isolated — organization wall + the ${PKG} runtime ` - + "(the legacy spelling 'multi' is accepted and normalizes to this); refused at boot in 18.0, which supports single only", + + "(the legacy spelling 'multi' is accepted and normalizes to this)", ); }); diff --git a/packages/cli/src/commands/serve-walled-posture-refusal.test.ts b/packages/cli/src/commands/serve-walled-posture-refusal.test.ts deleted file mode 100644 index e2288f0621f..00000000000 --- a/packages/cli/src/commands/serve-walled-posture-refusal.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * 18.0 supports the `single` posture only (ruling record 6259698357 on the v18 - * epic). `os serve` refuses a walled posture (`group`, `isolated`) loudly, - * naming the 18.x release that will carry it. It refuses at its tenancy-posture - * gate, before a config is read. `os dev` and `os start` boot through `serve`. - * - * Under `single` nothing changes: `walledPostureRefusal` answers `null`, and - * every `serve` integration test that boots with the posture unset - * (`serve-no-server-banner.integration.test.ts`, - * `serve-no-storage-boot-guard.integration.test.ts`, …) boots as before. - * - * Kernel-level boots never pass through `serve` (`@objectstack/verify`'s - * `bootStack`, the dogfood wall suites), and they keep booting walled postures. - */ - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { TENANCY_POSTURES, postureEnforcesWall } from '@objectstack/spec/security'; - -import Serve, { walledPostureRefusal } from './serve.js'; - -const CLI_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); - -/** SGR codes, written as `\x1b` (never the raw byte, which makes grep read the file as binary). */ -const SGR = /\x1b\[[0-9;]*m/g; -const plain = (s: string) => s.replace(SGR, ''); - -const TOUCHED = ['OS_TENANCY_POSTURE', 'OS_MULTI_ORG_ENABLED'] as const; -let saved: Record = {}; - -beforeEach(() => { - saved = Object.fromEntries(TOUCHED.map((k) => [k, process.env[k]])); - for (const k of TOUCHED) delete process.env[k]; -}); - -afterEach(() => { - for (const k of TOUCHED) { - if (saved[k] === undefined) delete process.env[k]; - else process.env[k] = saved[k]; - } -}); - -const WALLED = TENANCY_POSTURES.filter(postureEnforcesWall); - -describe('walledPostureRefusal — the verdict', () => { - it('answers null for single, the one posture 18.0 supports', () => { - expect(walledPostureRefusal('single')).toBeNull(); - }); - - it('refuses every walled posture the spec vocabulary declares', () => { - expect(WALLED.length).toBeGreaterThan(0); - for (const posture of WALLED) { - process.env.OS_TENANCY_POSTURE = posture; - const text = plain(walledPostureRefusal(posture) ?? ''); - expect(text).toContain(`FATAL: tenancy posture '${posture}' is not supported in 18.0 (NOT_IMPLEMENTED).`); - expect(text).toContain('an 18.x release'); - expect(text).toContain('set OS_TENANCY_POSTURE=single'); - expect(text).toContain(`OS_TENANCY_POSTURE="${posture}"`); - } - }); - - it('names OS_MULTI_ORG_ENABLED as the source when the posture was derived from it', () => { - process.env.OS_MULTI_ORG_ENABLED = 'true'; - const text = plain(walledPostureRefusal('isolated') ?? ''); - expect(text).toContain('OS_MULTI_ORG_ENABLED="true" (OS_TENANCY_POSTURE is unset)'); - }); -}); - -/** Run the real `serve` in-process, its `process.exit` made catchable; the FATAL and every diagnostic captured. */ -async function runServe(): Promise<{ raised: unknown; stderr: string; diagnostics: string[] }> { - const savedNodeEnv = process.env.NODE_ENV; - const errors: string[] = []; - const diagnostics: string[] = []; - const errSpy = vi.spyOn(console, 'error').mockImplementation((...a: unknown[]) => { - errors.push(a.join(' ')); - }); - const stdoutWrite = process.stdout.write; - const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: unknown) => { - diagnostics.push(String(chunk)); - return true; - }) as typeof process.stderr.write); - const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { - throw new Error(`__PROCESS_EXIT__:${code}`); - }) as never); - let raised: unknown; - try { - // `--dev` keeps the port probe on the auto-shift path, so a busy port cannot pre-empt the gate. - await Serve.run(['--dev', '--port', '39873'], { root: CLI_ROOT }); - } catch (err) { - raised = err; - } finally { - errSpy.mockRestore(); - stderrSpy.mockRestore(); - exitSpy.mockRestore(); - // `run()` reserves stdout for the process's lifetime; put it back for the next case. - process.stdout.write = stdoutWrite; - if (savedNodeEnv === undefined) delete process.env.NODE_ENV; - else process.env.NODE_ENV = savedNodeEnv; - } - return { raised, stderr: plain(errors.join('\n')), diagnostics }; -} - -describe('os serve refuses a walled posture before any boot work', () => { - it('OS_TENANCY_POSTURE=isolated: exit 1, the FATAL, and no config read', async () => { - process.env.OS_TENANCY_POSTURE = 'isolated'; - const { raised, stderr, diagnostics } = await runServe(); - expect((raised as Error | undefined)?.message).toBe('__PROCESS_EXIT__:1'); - expect(stderr).toContain("FATAL: tenancy posture 'isolated' is not supported in 18.0"); - // The config load is serve's first announced boot step; nothing at all was announced. - expect(diagnostics).toEqual([]); - }, 60_000); - - it('OS_MULTI_ORG_ENABLED=true (the legacy switch, read as isolated) is refused the same way', async () => { - process.env.OS_MULTI_ORG_ENABLED = 'true'; - const { raised, stderr, diagnostics } = await runServe(); - expect((raised as Error | undefined)?.message).toBe('__PROCESS_EXIT__:1'); - expect(stderr).toContain("FATAL: tenancy posture 'isolated' is not supported in 18.0"); - expect(diagnostics).toEqual([]); - }, 60_000); -}); diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index aad9925c748..f28becea4a5 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -52,7 +52,7 @@ import { // The posture vocabulary, read from the package that DEFINES it (#5359) — the // boot gate's fix list enumerates the accepted values, and a second literal // list would be free to drift the day a posture is added. -import { TENANCY_POSTURES, postureEnforcesWall, type TenancyPosture } from '@objectstack/spec/security'; +import { TENANCY_POSTURES, type TenancyPosture } from '@objectstack/spec/security'; // #17329 — the PUBLISHED settlement contract, READ rather than re-derived, so // the `objectstack:seed-settled` announcement below asks the one component that // knows whether this boot's seeding has come to rest. The service is registered @@ -2408,20 +2408,6 @@ export default class Serve extends Command { } const tenancyPosture = postureGate.posture; - // ── 18.0: the `single` posture only (ruling record 6259698357) ───── - // A walled posture is refused here, at the same point and in the same - // shape as an unrecognized one: before a config is read, a plugin is - // mounted or anything is written. `os dev` and `os start` both boot - // through this command, so this is every operator's boot. Kernel-level - // boots (`@objectstack/verify`'s `bootStack`, the standalone stack) - // never pass here, which is what keeps the walled test suites running for - // the 18.x release that lifts this. No flag and no variable skips it. - const walledRefusal = walledPostureRefusal(tenancyPosture); - if (walledRefusal) { - console.error(walledRefusal); - process.exit(1); - } - // [#22371] `--dev`, or NODE_ENV exactly `development` — `isDevelopmentBoot` // (`@objectstack/core`), the reading the auth secret's development fallback // is decided by here and in `os migrate security-catalog-overlays --dev`. @@ -6252,39 +6238,6 @@ export function resolveTenancyPostureOrRefusal(): TenancyPostureGateVerdict { } -/** - * The FATAL text `os serve` refuses a walled posture with in 18.0, or `null` - * for a posture 18.0 supports (ruling record 6259698357 on the v18 epic). - * - * 18.0 supports the `single` posture only, on fresh installs. The walled - * postures (`group`, `isolated`) arrive in an 18.x release: under `single` - * every system insert is stamped with the Default Organization, and the - * walled-only prerequisites were deferred. The code is the standard - * `NOT_IMPLEMENTED` (ADR-0112). The walled set is read off the spec's posture - * vocabulary (`postureEnforcesWall`), never listed here. - */ -export function walledPostureRefusal(posture: TenancyPosture): string | null { - if (!postureEnforcesWall(posture)) return null; - const walled = TENANCY_POSTURES.filter(postureEnforcesWall).join(', '); - const env = (globalThis as { process?: { env?: Record } }).process?.env ?? {}; - const raw = env.OS_TENANCY_POSTURE; - const source = raw != null && String(raw).trim() !== '' - ? `OS_TENANCY_POSTURE=${JSON.stringify(String(raw))}` - : `OS_MULTI_ORG_ENABLED=${JSON.stringify(String(env.OS_MULTI_ORG_ENABLED ?? ''))} (OS_TENANCY_POSTURE is unset)`; - return chalk.red( - `\n ✖ FATAL: tenancy posture '${posture}' is not supported in 18.0 (NOT_IMPLEMENTED).\n` - + ` Refusing to boot. 18.0 supports the 'single' posture only, on fresh installs; the walled\n` - + ` postures (${walled}) arrive in an 18.x release.\n\n` - + ' No config has been loaded, no plugin has been mounted, and the HTTP server was\n' - + ' never started — this deployment has not served a single request.\n\n' - + ' Fix one of:\n' - + ' • set OS_TENANCY_POSTURE=single, or unset both OS_TENANCY_POSTURE and OS_MULTI_ORG_ENABLED;\n' - + ' • keep this deployment on a 17.x release until the 18.x release that carries walled postures.\n\n' - + ` The posture was read from ${source}, in the process environment or a .env file\n` - + ' dotenv-flow loaded for this mode.\n', - ); -} - /** * The diagnosis the optional i18n service load prints when it fails — a * DIAGNOSIS ahead of behaviour that does not change (#14042's rule, applied at diff --git a/packages/cli/src/utils/tenancy-posture-hints.ts b/packages/cli/src/utils/tenancy-posture-hints.ts index 28c7649b618..7adf3b04444 100644 --- a/packages/cli/src/utils/tenancy-posture-hints.ts +++ b/packages/cli/src/utils/tenancy-posture-hints.ts @@ -131,18 +131,12 @@ export const ORGANIZATIONS_RUNTIME_PKG = '@objectstack/organizations'; * hold hard-coded expectations of this prose, so a reword here reddens a pin on * each command from one edit. If only one ever reddened, the table would be * shared in name only. - * - * The two walled entries say that 18.0 refuses them, because `os serve` does, - * before it reads a config: a fix list that offered them bare would send the - * operator from one refusal to the next. The 18.x release that restores walled - * postures deletes {@link WALLED_POSTURE_RELEASE_NOTE}. */ -export const WALLED_POSTURE_RELEASE_NOTE = '; refused at boot in 18.0, which supports single only'; export const TENANCY_POSTURE_FIX_HINTS: Readonly> = { single: 'one organization, no organization wall — the default', - group: `organization wall enforced by the open engine, one shared database${WALLED_POSTURE_RELEASE_NOTE}`, + group: 'organization wall enforced by the open engine, one shared database', isolated: `organization wall + the ${ORGANIZATIONS_RUNTIME_PKG} runtime ` - + `(the legacy spelling 'multi' is accepted and normalizes to this)${WALLED_POSTURE_RELEASE_NOTE}`, + + "(the legacy spelling 'multi' is accepted and normalizes to this)", }; diff --git a/packages/cli/test/serve-organizations-host-resolution.e2e.test.ts b/packages/cli/test/serve-organizations-host-resolution.e2e.test.ts index ca3984c8ca9..6980863636a 100644 --- a/packages/cli/test/serve-organizations-host-resolution.e2e.test.ts +++ b/packages/cli/test/serve-organizations-host-resolution.e2e.test.ts @@ -27,20 +27,7 @@ * that is the whole point), registering the same `org-scoping` service and * posture entitlement the real one does. What is under test here is * RESOLUTION, not the enterprise semantics: proof that the real plugin walls - * - * ## 18.0: the walled postures are refused before this resolution runs - * - * 18.0 supports the `single` posture only (ruling record 6259698357 on the - * v18 epic). `os serve` refuses `group` and `isolated` at its - * tenancy-posture gate, before a config is read (`walledPostureRefusal`, - * pinned in-process in `src/commands/serve-walled-posture-refusal.test.ts`). - * So in 18.0 no walled boot reaches the stages this file was written for. The - * cases below keep its fixtures and pin, over the real process, that the 18.0 - * refusal comes FIRST whatever the package state, and that - * `OS_ALLOW_DEGRADED_TENANCY` does not lift it. The 18.x release that carries - * the walled postures restores this file's resolution cases from git history - * (the parent of the commit that added this section), together with - * `serve-organizations-mount-failure.e2e.test.ts`. + * tenants lives in cloud's security-enterprise multi-org integration test. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; @@ -168,33 +155,88 @@ const SERVE_ENV = { OS_PLATFORM_OWNER_EMAIL: 'operator@corp.example', }; -describe('os serve — 18.0 refuses the walled posture before the organizations runtime resolves', () => { - const cases: ReadonlyArray<{ name: string; app: () => string; env: Record }> = [ - { name: 'the package installed in the APP', app: () => appWithPackage, env: {} }, - { name: 'no package in the app', app: () => appWithoutPackage, env: {} }, - { name: 'the package reachable only through NODE_PATH', app: () => appWithoutPackage, env: { NODE_PATH: hoistedStore } }, - ]; - for (const c of cases) { - it( - `${c.name}: refused in 18.0, and no organizations stage ran`, - async () => { - const { stdout, stderr } = await runServe(c.app(), [], { - waitFor: /Press Ctrl\+C to stop/, - env: { ...SERVE_ENV, ...c.env }, - timeoutMs: 240_000, - }); - const seen = `\n--- stdout ---\n${stdout.slice(-4000)}\n--- stderr ---\n${stderr.slice(-4000)}`; - expect(stderr, `the 18.0 walled-posture refusal did not fire${seen}`).toContain( - "FATAL: tenancy posture 'isolated' is not supported in 18.0 (NOT_IMPLEMENTED).", - ); - expect(stderr, `serve served traffic under a walled posture${seen}`).not.toContain('Press Ctrl+C to stop'); - // Neither organizations stage was reached: no absent-package fail-fast, - // no mount refusal, no degraded boot. - expect(stderr, `the organizations import ran before the refusal${seen}`).not.toMatch(/could not be loaded|was requested/); - expect(stderr, `the organizations mount ran before the refusal${seen}`).not.toMatch(/WAS found and loaded/); - expect(stderr, `a degraded boot ran before the refusal${seen}`).not.toMatch(/DEGRADED TENANCY/); - }, - 300_000, - ); - } +describe('os serve — enterprise organizations resolution (cloud#1013)', () => { + it( + 'boots a walled posture with the package installed in the APP, not the framework', + async () => { + const { stdout, stderr } = await runServe(appWithPackage, [], { + waitFor: /Press Ctrl\+C to stop/, + env: { ...SERVE_ENV }, + timeoutMs: 240_000, + }); + + const seen = `\n--- stdout ---\n${stdout.slice(-4000)}\n--- stderr ---\n${stderr.slice(-4000)}`; + + // The load-bearing assertion. Before the fix, the bare import resolved + // from the CLI's realpath in the framework workspace, threw + // MODULE_NOT_FOUND, and this boot died on the D5 fail-fast instead of + // reaching the banner at all. + expect(stderr, `the D5 fail-fast fired — the app-installed package was not found${seen}`) + .not.toMatch(/could not be loaded/); + expect(stderr, `serve never reached its banner${seen}`).toContain('Press Ctrl+C to stop'); + // …and it is the APP's package that got mounted: `Organizations` is + // tracked only on the path that actually registered the plugin. + expect(stderr, `OrganizationsPlugin was not registered${seen}`).toContain('Organizations'); + }, + 300_000, + ); + + it( + 'still refuses to boot a walled posture when the app does not ship the package', + async () => { + // The other half of the contract: the fix must not turn the ADR-0093 D5 + // fail-fast into a lenient skip. An app that requests isolation without + // the enterprise runtime must still die rather than serve traffic with + // the organization wall inactive. + const { stdout, stderr } = await runServe(appWithoutPackage, [], { + waitFor: /Press Ctrl\+C to stop/, + env: { ...SERVE_ENV }, + timeoutMs: 240_000, + }); + + const seen = `\n--- stdout ---\n${stdout.slice(-4000)}\n--- stderr ---\n${stderr.slice(-4000)}`; + expect(stderr, `the D5 fail-fast did not fire${seen}`).toMatch( + /FATAL: tenancy posture 'isolated' was requested/, + ); + // The remedy names the app, because that is where the package has to go. + expect(stderr).toMatch(/to THIS APP/); + expect(stderr, `serve served traffic without the wall${seen}`).not.toContain( + 'Press Ctrl+C to stop', + ); + }, + 300_000, + ); + + it( + 'refuses when the package is reachable only through NODE_PATH — the pnpm shim shape (#4719)', + async () => { + // The #4719 defect, over a real process, with the launcher reproduced + // exactly. Same app as the case above (declares nothing), same posture — + // the only difference is the NODE_PATH every pnpm bin shim exports. Before + // this change that single environment variable was enough to boot the + // organization wall off a package the app had never declared, so whether + // ADR-0093 D5 fired came down to HOW the process was started. + const { stdout, stderr } = await runServe(appWithoutPackage, [], { + waitFor: /Press Ctrl\+C to stop/, + env: { ...SERVE_ENV, NODE_PATH: hoistedStore }, + timeoutMs: 240_000, + }); + + const seen = `\n--- stdout ---\n${stdout.slice(-4000)}\n--- stderr ---\n${stderr.slice(-4000)}`; + expect( + stderr, + `NODE_PATH got an undeclared app past the D5 wall — the #4719 defect${seen}`, + ).toMatch(/FATAL: tenancy posture 'isolated' was requested/); + // …and the remedy is the declaration one, naming why reachability lost. + expect(stderr).toMatch(/to THIS APP/); + expect(stderr).toMatch(/NODE_PATH/); + expect(stderr, `serve served traffic without the wall${seen}`).not.toContain( + 'Press Ctrl+C to stop', + ); + expect(stderr, `the hoisted package was mounted anyway${seen}`).not.toContain( + 'Organizations', + ); + }, + 300_000, + ); }); diff --git a/packages/cli/test/serve-organizations-mount-failure.e2e.test.ts b/packages/cli/test/serve-organizations-mount-failure.e2e.test.ts index 0e10ed78d56..1fb7b499a28 100644 --- a/packages/cli/test/serve-organizations-mount-failure.e2e.test.ts +++ b/packages/cli/test/serve-organizations-mount-failure.e2e.test.ts @@ -34,20 +34,6 @@ * `packages/plugins/organizations` carries no licence check of any kind (the * shape cloud#1020 gave its license gate). What is asserted is the CLI's * CLASSIFICATION and its message, not any runtime's semantics. - * - * ## 18.0: the walled postures are refused before this resolution runs - * - * 18.0 supports the `single` posture only (ruling record 6259698357 on the - * v18 epic). `os serve` refuses `group` and `isolated` at its - * tenancy-posture gate, before a config is read (`walledPostureRefusal`, - * pinned in-process in `src/commands/serve-walled-posture-refusal.test.ts`). - * So in 18.0 no walled boot reaches the stages this file was written for. The - * cases below keep its fixtures and pin, over the real process, that the 18.0 - * refusal comes FIRST whatever the package state, and that - * `OS_ALLOW_DEGRADED_TENANCY` does not lift it. The 18.x release that carries - * the walled postures restores this file's resolution cases from git history - * (the parent of the commit that added this section), together with - * `serve-organizations-host-resolution.e2e.test.ts`. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; @@ -158,34 +144,118 @@ const SERVE_ENV = { OS_PLATFORM_OWNER_EMAIL: 'operator@corp.example', }; -describe('os serve — 18.0 refuses the walled posture before the organizations runtime resolves', () => { - const cases: ReadonlyArray<{ name: string; app: () => string; env: Record }> = [ - { name: 'the package ABSENT', app: () => appAbsent, env: { OS_ALLOW_DEGRADED_TENANCY: undefined } }, - { name: 'the package ABSENT, OS_ALLOW_DEGRADED_TENANCY=1', app: () => appAbsent, env: { OS_ALLOW_DEGRADED_TENANCY: '1' } }, - { name: 'the package PRESENT and refusing', app: () => appRefusing, env: { OS_ALLOW_DEGRADED_TENANCY: undefined } }, - { name: 'the package PRESENT and refusing, OS_ALLOW_DEGRADED_TENANCY=1', app: () => appRefusing, env: { OS_ALLOW_DEGRADED_TENANCY: '1' } }, - ]; - for (const c of cases) { +const BANNER = 'Press Ctrl+C to stop'; +const BANNER_RE = /Press Ctrl\+C to stop/; + +function seenOf(stdout: string, stderr: string): string { + return `\n--- stdout ---\n${stdout.slice(-4000)}\n--- stderr ---\n${stderr.slice(-4000)}`; +} + +describe('os serve — organizations import stage vs mount stage (#4818)', () => { + describe('import stage fails — the package is ABSENT (behaviour must be unchanged)', () => { it( - `${c.name}: refused in 18.0, and no organizations stage ran`, + 'refuses to boot with the ADR-0093 D5 "could not be loaded" diagnosis', async () => { - const { stdout, stderr } = await runServe(c.app(), [], { - waitFor: /Press Ctrl\+C to stop/, - env: { ...SERVE_ENV, ...c.env }, + const { stdout, stderr } = await runServe(appAbsent, [], { + waitFor: BANNER_RE, + env: { ...SERVE_ENV, OS_ALLOW_DEGRADED_TENANCY: undefined }, timeoutMs: 240_000, }); - const seen = `\n--- stdout ---\n${stdout.slice(-4000)}\n--- stderr ---\n${stderr.slice(-4000)}`; - expect(stderr, `the 18.0 walled-posture refusal did not fire${seen}`).toContain( - "FATAL: tenancy posture 'isolated' is not supported in 18.0 (NOT_IMPLEMENTED).", + const seen = seenOf(stdout, stderr); + + expect(stderr, `the D5 fail-fast did not fire${seen}`).toMatch( + /FATAL: tenancy posture 'isolated' was requested/, ); - expect(stderr, `serve served traffic under a walled posture${seen}`).not.toContain('Press Ctrl+C to stop'); - // Neither organizations stage was reached: no absent-package fail-fast, - // no mount refusal, no degraded boot. - expect(stderr, `the organizations import ran before the refusal${seen}`).not.toMatch(/could not be loaded|was requested/); - expect(stderr, `the organizations mount ran before the refusal${seen}`).not.toMatch(/WAS found and loaded/); - expect(stderr, `a degraded boot ran before the refusal${seen}`).not.toMatch(/DEGRADED TENANCY/); + // This wording is CORRECT here and must survive the stage split: the + // package really is not on this machine. + expect(stderr, `the absent-package diagnosis was lost${seen}`).toMatch(/could not be loaded/); + // …and the escape hatch is still offered on the path it belongs to. + expect(stderr).toMatch(/set OS_ALLOW_DEGRADED_TENANCY=1 to boot/); + expect(stderr, `serve served traffic without the wall${seen}`).not.toContain(BANNER); }, 300_000, ); - } + + it( + 'boots degraded when the operator explicitly sets OS_ALLOW_DEGRADED_TENANCY=1', + async () => { + // The escape hatch keeps its one legitimate meaning: "the capability is + // absent and I accept running without it". Pinned so the stage split + // cannot regress it. + const { stdout, stderr } = await runServe(appAbsent, [], { + waitFor: BANNER_RE, + env: { ...SERVE_ENV, OS_ALLOW_DEGRADED_TENANCY: '1' }, + timeoutMs: 240_000, + }); + const seen = seenOf(stdout, stderr); + + expect(stderr, `serve never reached its banner${seen}`).toContain(BANNER); + expect(stderr, `the degraded boot was not branded${seen}`).toMatch(/DEGRADED TENANCY/); + expect(stderr, `the degraded opt-in still fired the fail-fast${seen}`).not.toMatch(/✖ FATAL/); + }, + 300_000, + ); + }); + + describe('mount stage fails — the package is PRESENT and its plugin refused', () => { + it( + "surfaces the plugin's own error verbatim and exits, without the absent-package wording", + async () => { + const { stdout, stderr } = await runServe(appRefusing, [], { + waitFor: BANNER_RE, + env: { ...SERVE_ENV, OS_ALLOW_DEGRADED_TENANCY: undefined }, + timeoutMs: 240_000, + }); + const seen = seenOf(stdout, stderr); + + // D5's posture is unchanged: isolation was requested and cannot be + // delivered, so the boot still dies. + expect(stderr, `serve served traffic without the wall${seen}`).not.toContain(BANNER); + expect(stderr, `no fail-fast fired for a refusing plugin${seen}`).toMatch(/✖ FATAL/); + + // The crux: the operator is told the package IS there, and reads the + // plugin's own words — not a fabricated cause, and not a module + // resolution wild goose chase. + expect(stderr, `the mount refusal was misreported as an absent package${seen}`).not.toMatch( + /could not be loaded/, + ); + expect(stderr, `the plugin's message was not surfaced verbatim${seen}`).toContain(REFUSAL_MESSAGE); + expect(stderr, `the plugin's structured code was not surfaced${seen}`).toContain(REFUSAL_CODE); + expect(stderr, `the message does not say the package was found${seen}`).toMatch( + /WAS found and loaded/, + ); + // Honest remaining alternative, and no dead-end suggestion. + expect(stderr).toMatch(/OS_TENANCY_POSTURE=single/); + expect(stderr, `the escape hatch was offered on a path it cannot fix${seen}`).toMatch( + /OS_ALLOW_DEGRADED_TENANCY does NOT apply/, + ); + }, + 300_000, + ); + + it( + 'still exits 1 when OS_ALLOW_DEGRADED_TENANCY=1 is set — the hatch must not swallow a refusal', + async () => { + // THE issue. Before the fix this booted with a warning: an env var + // silently overrode whatever gate the plugin was enforcing. + const { stdout, stderr } = await runServe(appRefusing, [], { + waitFor: BANNER_RE, + env: { ...SERVE_ENV, OS_ALLOW_DEGRADED_TENANCY: '1' }, + timeoutMs: 240_000, + }); + const seen = seenOf(stdout, stderr); + + expect( + stderr, + `OS_ALLOW_DEGRADED_TENANCY swallowed a plugin refusal and served traffic${seen}`, + ).not.toContain(BANNER); + expect(stderr, `the refusal did not fail fast under the escape hatch${seen}`).toMatch(/✖ FATAL/); + expect(stderr, `the refusal was downgraded to a degraded-boot warning${seen}`).not.toMatch( + /DEGRADED TENANCY \(OS_ALLOW_DEGRADED_TENANCY=1\)/, + ); + expect(stderr, `the plugin's message was not surfaced verbatim${seen}`).toContain(REFUSAL_MESSAGE); + }, + 300_000, + ); + }); }); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 4fb4bcaa070..d00a9567287 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -214,6 +214,7 @@ import { resolveSystemWriteOrganization, resolveTenantFieldName, carriesOrganization, + isWalledOrganizationLessBridgeObject, SystemWriteOrganizationRequiredError, ORGANIZATION_OBJECT, } from './tenancy/system-write-organization.js'; @@ -7110,8 +7111,9 @@ export class ObjectQL implements IObjectQLEngine { const hasTenant = tenantScope !== undefined; const hasTz = execCtx?.timezone !== undefined; const isSystem = execCtx?.isSystem === true; - // [#13491] The object's tenancy classification — the ONE reading both the - // mute below and `resolveSystemInsertOrganization` narrow by. An object + // [#13491] The object's tenancy classification — the reading the mute + // below narrows by (ADR-0131 C8 stage 8b retired its other reader, the + // system-insert exemption, under every posture). An object // with no tenant field is out by construction; a platform object is out // unless the inventory adjudicated it tenant-scoped. const isTenantAuditInScope = @@ -7388,18 +7390,20 @@ export class ObjectQL implements IObjectQLEngine { // system write that threaded one. Nothing to resolve; this is the shape the // ruling asks every system write to reach. if (carriesOrganization(execCtx?.tenantId)) return undefined; - // [ADR-0131 D9, C8 stage 8b] Under `single` a platform object has no + // [ADR-0131 D9, C8 stage 8b] Under every posture a platform object has no // exemption: with a tenant column it is in scope like any other object, so - // its organization-less system insert is stamped with the Default - // Organization or refused below — never left NULL. + // its organization-less system insert is stamped with the derived + // organization or refused below — never left NULL. The walled half landed + // in 18.x, after the stages that gave the walled writers (identity mail, + // global audit, declared webhooks) an organization or a sibling of their + // own. ⛔ Never re-introduce a per-table or per-posture cut here — with + // ONE cited, temporary exception: // - // ⚠️ WALLED POSTURES ONLY: #13491's per-object exemption still stands under - // `group` / `isolated` (epic ruling record 6259899486 on #15194, item 4). - // 18.0 refuses a walled posture at the operator's boot path, and the walled - // half of the lift lands in 18.x, after the stages that give the walled - // writers an organization of their own. The cut is a POSTURE condition on - // purpose — ⛔ never re-narrow it with a per-table list. - if (isPlatformObjectOutOfTenantAuditScope(object) && this.resolveEnginePosture() !== 'single') { + // [BRIDGE, epic ruling record 6270035914 on #15194 item 2, #24582] Under a + // wall, `sys_secret` and `sys_flow_credential` keep their organization-less + // system inserts until the steps 5+6 landing flips them column-less. See + // `WALLED_ORGANIZATION_LESS_BRIDGE`; delete this with the flips. + if (isWalledOrganizationLessBridgeObject(object) && this.resolveEnginePosture() !== 'single') { return undefined; } const objectSchema = this._registry.getObject(object) as any; diff --git a/packages/objectql/src/index.ts b/packages/objectql/src/index.ts index efde3c270c9..13b37b68420 100644 --- a/packages/objectql/src/index.ts +++ b/packages/objectql/src/index.ts @@ -545,6 +545,8 @@ export { resolveSystemWriteOrganization, resolveTenantFieldName, isPlatformNamespaceObject, + WALLED_ORGANIZATION_LESS_BRIDGE, + isWalledOrganizationLessBridgeObject, carriesOrganization, isSystemWriteOrganizationRequiredError, SystemWriteOrganizationRequiredError, diff --git a/packages/objectql/src/system-write-organization.test.ts b/packages/objectql/src/system-write-organization.test.ts index 15992a8ed29..55fa6290a80 100644 --- a/packages/objectql/src/system-write-organization.test.ts +++ b/packages/objectql/src/system-write-organization.test.ts @@ -124,10 +124,10 @@ const DISPATCH_ORDER = { /** * A platform-namespace object the #13491 inventory did NOT adjudicate. * - * ⚠️ Its exclusion below is `unclassified`, not `global`, and since ADR-0131 - * C8 stage 8b it holds under a WALLED posture only (epic ruling record - * 6259899486 on #15194, item 4). Under `single` the object is judged like any - * other — derived or refused. The admitted and `global` sides are pinned in + * ⚠️ Its old exclusion was `unclassified`, not `global`. ADR-0131 C8 stage + * 8b retired it under every posture (`single` first, the walled postures in + * 18.x): the object is judged like any other — derived or refused. The + * admitted and `global` sides are pinned in * `tenancy-by-object-classification.test.ts`. */ const SYS_LEDGER = { @@ -389,15 +389,21 @@ describe('#8844 multi-organization — the write is REFUSED, never defaulted', ( describe('#8844 the exclusions — populations the refusal must not touch', () => { it.each(['isolated', 'group'] as const)( - 'a platform-namespace object stays org-less on the %s posture — the walled exemption stands in 18.0', + '[C8 stage 8b] %s posture: a platform-namespace object is no longer excluded — its org-less system insert is REFUSED, and never written', async (posture) => { - // ⚠️ [#13491] Reads as an UNCLASSIFIED verdict, not a namespace one, and - // [C8 stage 8b] holds under a wall only: 18.0 refuses a walled posture at - // the operator's boot path, and the walled half of the lift is 18.x's - // (epic ruling record 6259899486 on #15194, item 4). + // ⚠️ [#13491] The old exclusion read an UNCLASSIFIED verdict, not a + // namespace one. The walled half of the lift (18.x) retired it: under a + // wall no organization is derivable, so the write is refused loudly + // instead of landing NULL. const { engine, observed } = await makeEngine({ posture }); - await engine.insert('sys_audit_entry', { subject: 'e1' }, { context: SYSTEM_CTX } as any); - expect(lastWrite(observed, 'sys_audit_entry')?.options?.tenantId).toBeUndefined(); + await expect( + engine.insert('sys_audit_entry', { subject: 'e1' }, { context: SYSTEM_CTX } as any), + ).rejects.toMatchObject({ + code: 'ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED', + status: 500, + reason: 'walled-posture', + }); + expect(lastWrite(observed, 'sys_audit_entry')).toBeUndefined(); }, ); @@ -425,9 +431,8 @@ describe('#8844 the exclusions — populations the refusal must not touch', () = it('never probes `sys_organization` for a write it does not judge', async () => { // The cost half of the exclusions: an ordinary write must pay two property // reads and a regexp, not a query. - // [C8 stage 8b] A platform object is judged under `single` now, so it is - // not in this population: only the walled exemption skips its probe, and - // a walled refusal never probes anyway. + // [C8 stage 8b] A platform object is judged under every posture now, so + // it is not in this population (and a walled refusal never probes anyway). const { engine, observed } = await makeEngine({ organizations: [ORG_ID] }); await engine.insert('billing_license', { subject: 'lic' }, { context: SYSTEM_CTX } as any); await engine.insert('dispatch_order', { subject: 'session' }, { context: SESSION_CTX } as any); diff --git a/packages/objectql/src/tenancy-by-object-classification.test.ts b/packages/objectql/src/tenancy-by-object-classification.test.ts index 9703830ec12..27101b02bc0 100644 --- a/packages/objectql/src/tenancy-by-object-classification.test.ts +++ b/packages/objectql/src/tenancy-by-object-classification.test.ts @@ -196,10 +196,9 @@ describe('#13491 gate 1 — an admitted platform object reaches #8844 derive/ref expect(lastWrite(observed, 'sys_file')?.options?.tenantId).toBe(ORG_ID); }); - // [ADR-0131 C8 stage 8b] Under `single` the classification no longer decides - // the write: every platform object with a tenant column is judged, admitted - // or not. The walled cases below are the control — there the exemption - // stands in 18.0 (epic ruling record 6259899486 on #15194, item 4). + // [ADR-0131 C8 stage 8b] The classification no longer decides the write + // under any posture: every platform object with a tenant column is judged, + // admitted or not (`single` first, the walled postures in 18.x). it.each([ ['sys_permission_set', { label: 'Admin' }], ['sys_audit_entry', { subject: 'e1' }], @@ -230,12 +229,19 @@ describe('#13491 gate 1 — an admitted platform object reaches #8844 derive/ref }, ); - it.each(['isolated', 'group'] as const)( - '%s posture: an UNCLASSIFIED platform object still stays org-less — the walled exemption stands in 18.0', - async (posture) => { + it.each([ + ['isolated', 'sys_audit_entry', { subject: 'e1' }], + ['group', 'sys_audit_entry', { subject: 'e1' }], + ['isolated', 'sys_permission_set', { label: 'Admin' }], + ['group', 'sys_permission_set', { label: 'Admin' }], + ] as const)( + '[C8 stage 8b] %s posture: the un-admitted platform object %s is judged too — its org-less system insert is REFUSED, and never written', + async (posture, object, row) => { const { engine, observed } = await makeEngine({ posture }); - await engine.insert('sys_audit_entry', { subject: 'e1' }, { context: SYSTEM_CTX } as any); - expect(lastWrite(observed, 'sys_audit_entry')?.options?.tenantId).toBeUndefined(); + await expect( + engine.insert(object, { ...row }, { context: SYSTEM_CTX } as any), + ).rejects.toMatchObject({ code: 'ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED', status: 500 }); + expect(lastWrite(observed, object)).toBeUndefined(); }, ); @@ -256,11 +262,14 @@ describe('#13491 gate 1 — an admitted platform object reaches #8844 derive/ref ); it.each(['isolated', 'group'] as const)( - '%s posture: a GLOBAL platform object is NOT refused — the discriminating control', + '%s posture: an object with NO tenant column is NOT refused — the discriminating control', async (posture) => { + // ADR-0131 D7: a deployment-level table declares no organization column, + // and that declaration — not a platform-namespace verdict — is what keeps + // its rows organization-less under a wall. const { engine, observed } = await makeEngine({ posture }); - await engine.insert('sys_permission_set', { label: 'Admin' }, { context: SYSTEM_CTX } as any); - expect(lastWrite(observed, 'sys_permission_set')?.options?.tenantId).toBeUndefined(); + await engine.insert('billing_license', { subject: 'lic' }, { context: SYSTEM_CTX } as any); + expect(lastWrite(observed, 'billing_license')?.options?.tenantId).toBeUndefined(); }, ); diff --git a/packages/objectql/src/tenancy/platform-object-tenancy.ts b/packages/objectql/src/tenancy/platform-object-tenancy.ts index cc76ce2a757..4c7851e45bb 100644 --- a/packages/objectql/src/tenancy/platform-object-tenancy.ts +++ b/packages/objectql/src/tenancy/platform-object-tenancy.ts @@ -5,23 +5,19 @@ * * ## ⚠️ What still reads this ledger (ADR-0131 C8 stage 8b) * - * Under the `single` posture the ledger no longer decides any write: ADR-0131 - * D9 made the system-insert rule structural there — a platform object with a - * tenant column is in scope like any other, so its organization-less system - * insert takes the Default Organization or is refused - * (`Engine.resolveSystemInsertOrganization`). Two readers remain, and they - * are why the ledger has not retired yet (D13): + * Under no posture does the ledger decide a write any more: ADR-0131 D9 made + * the system-insert rule structural — a platform object with a tenant column + * is in scope like any other, so its organization-less system insert takes + * the derived organization or is refused + * (`Engine.resolveSystemInsertOrganization`). Stage 8b lifted the exemption + * under `single` first and under the walled postures (`group` / `isolated`) + * in 18.x. One reader remains, and it is why the ledger has not retired yet + * (D13): the engine's `bypassTenantAudit` isSystem mute + * (`Engine.buildDriverOptions`), a diagnostics-only reading in every posture + * that changes nothing a write touches. * - * 1. the system-insert exemption under a WALLED posture (`group` / - * `isolated`), which stands in 18.0 by epic ruling record 6259899486 on - * #15194, item 4 — the walled half of the lift lands in 18.x; - * 2. the engine's `bypassTenantAudit` isSystem mute - * (`Engine.buildDriverOptions`), a diagnostics-only reading in every - * posture that changes nothing a write touches. - * - * ⛔ Do not add an entry to make a `single` write behave: under `single` an - * entry changes nothing but the mute. The ledger retires with its walled - * reader. + * ⛔ Do not add an entry to make a write behave: an entry changes nothing but + * the mute. * * ## The ruling this implements * @@ -97,14 +93,14 @@ * * ## What admission actually changes, per object * - * A `tenant-scoped` classification lets the object reach - * `resolveSystemInsertOrganization` (#8844) on a walled posture - * (`group` / `isolated`), where an org-less write is REFUSED loudly - * ({@link SystemWriteOrganizationRequiredError}). Under `single` every + * A `tenant-scoped` classification stops the engine auto-muting the + * driver's tenant-audit warning for elevated writes on the object. It no + * longer decides whether an org-less write reaches + * `resolveSystemInsertOrganization` (#8844): under every posture every * platform object with a tenant column reaches it whatever its entry says - * (stage 8b, above). It also stops the engine - * auto-muting the driver's tenant-audit warning for elevated writes on it. - * Both directions are the ruled one — a refusal or a warning, never a silent + * (stage 8b, above), and an org-less write no organization is derivable for + * is REFUSED loudly ({@link SystemWriteOrganizationRequiredError}). Both + * directions are the ruled one — a refusal or a warning, never a silent * rewrite of what the write touches (execution point 3). */ diff --git a/packages/objectql/src/tenancy/system-write-organization.ts b/packages/objectql/src/tenancy/system-write-organization.ts index 7b0ebb0dcde..4ecde671e93 100644 --- a/packages/objectql/src/tenancy/system-write-organization.ts +++ b/packages/objectql/src/tenancy/system-write-organization.ts @@ -57,12 +57,12 @@ * and (since the Default Organization became a boot invariant, D3) zero * organizations alike. ⛔ Nothing defaults to any owner but the derivable one. * - * ## The three exclusions, and why the refusal is not broader + * ## The exclusions, and why the refusal is not broader * * A refusal that fires too widely breaks every system write on a walled install * — including the hooks and cron that run unattended, where a loud failure * surfaces as a stalled automation rather than as a 4xx someone reads. So the - * decision runs only where the harm is real, and three populations are outside + * decision runs only where the harm is real, and these populations are outside * it BY CONSTRUCTION rather than by exemption: * * - **Objects with no organization column, `tenancy.enabled: false` objects @@ -71,28 +71,22 @@ * org-less rows is `tenancy: { enabled: false }` — a metadata declaration, * loud and checkable — never a per-write bypass flag, which is exactly the * lenient-consumer accommodation Prime Directive #12 forbids. - * - **Under a WALLED posture only, platform-namespace objects the inventory - * did not admit** ({@link isPlatformObjectOutOfTenantAuditScope}, - * `platform-object-tenancy.ts`). Under `single` this exclusion is gone - * (ADR-0131 C8 stage 8b): a platform object with a tenant column takes the - * Default Organization or is refused, like every other object. - * - * ⚠️ This exclusion used to be the whole `sys_` / `cloud_` / `ai_` - * NAMESPACE, on the reasoning commit ff08691e6 recorded, that "an org-less row is defensible for - * `sys_permission_set`". The maintainer WITHDREW the wholesale form on - * 2026-08-31 (#13491, 联案 #13497): that reasoning inherits **per object**, - * and the same namespace also holds objects whose org-less rows are a - * defect — five instances (#12745, #12928, #10673, #8617, cloud#1239, one a - * credentials table), every one found by a person reading call sites and - * none by this control. So the exclusion is now a per-object verdict, and an - * object nobody has adjudicated stays excluded and stays ON THE LIST. - * - * ⚠️ C8 stage 8b lifted this exclusion under `single` only. Under a wall it - * stands in 18.0 (epic ruling record 6259899486 on #15194, item 4) and - * retires with the ledger in 18.x (D13). C1 withdrew the seed loader's - * namespace exemption. #8686's backfill (`seed-tenancy-backfill.ts`) still - * carries the NAMESPACE form; its residue attribution is C7's. Read the - * three together before changing any of them. + * + * ⚠️ Platform-namespace objects used to be a fourth population: first the + * whole `sys_` / `cloud_` / `ai_` NAMESPACE, then (#13491) the per-object + * verdict of `PLATFORM_OBJECT_TENANCY`. ADR-0131 C8 stage 8b retired that + * write-path reader — under `single` first, then under the walled postures + * in 18.x — so a platform object with a tenant column takes the derived + * organization or is refused, like every other object. The ledger keeps + * its other reader, the tenant-audit mute in `Engine.buildDriverOptions` + * ({@link isPlatformObjectOutOfTenantAuditScope}). C1 withdrew the seed + * loader's namespace exemption. #8686's backfill + * (`seed-tenancy-backfill.ts`) still carries the NAMESPACE form; its + * residue attribution is C7's. Read the three together before changing any + * of them. + * - **Under a walled posture only, `sys_secret` and `sys_flow_credential`** — + * a cited, temporary bridge ({@link WALLED_ORGANIZATION_LESS_BRIDGE}) until + * the steps 5+6 landing flips both tables column-less. * - **Writes that already carry an organization** — on the execution context * or on the row itself. That IS "carrying an explicit organization"; the * ruling asks for nothing more. @@ -160,13 +154,9 @@ export const DEFAULT_TENANT_FIELD = 'organization_id'; * #13491's re-ruling (2026-08-31, 联案 #13497) withdrew the wholesale namespace * reading — but its scope was the RUNTIME control only. So today: * - * - **the runtime write path** no longer cuts under `single` (C8 stage 8b). - * Under a walled posture it cuts PER OBJECT, by the hand-adjudicated - * classification in `platform-object-tenancy.ts` — the - * `PLATFORM_OBJECT_TENANCY` ledger, read through - * {@link classifyPlatformObjectTenancy} and - * {@link isPlatformObjectOutOfTenantAuditScope}, which is what - * `Engine.resolveSystemInsertOrganization` calls. + * - **the runtime write path** no longer cuts under any posture (C8 stage + * 8b: `single` first, the walled postures in 18.x). + * `Engine.resolveSystemInsertOrganization` reads no platform ledger. * - **`seed-loader.ts`** no longer cuts at all: ADR-0131 D3 withdrew its * namespace exemption (C1), so every seed row is stamped with the resolved * organization — `sys_` / `cloud_` / `ai_` seeds included — or refused. @@ -181,6 +171,34 @@ export const DEFAULT_TENANT_FIELD = 'organization_id'; */ const PLATFORM_NAMESPACE = /^(sys_|cloud_|ai_)/; +/** + * [ADR-0131 C8 stage 8b, BRIDGE — delete it with the flips] The two platform + * tables whose organization-less SYSTEM inserts keep landing under a walled + * posture, as they did before the walled half of the lift. + * + * Their deployment-level rows (a global-scope encrypted setting's and a + * datasource credential's `sys_secret` ciphertext, a flow's + * `sys_flow_credential`) are organization-less by ruling until those tables + * drop the column: epic ruling record 6270035914 on #15194, item 2, moved the + * deferred `sys_secret` / `sys_flow_credential` `systemFields: { tenant: false }` + * flips into the steps 5+6 landing (#24582), because declaring them column-less + * any earlier refuses every 18.0 database at boot. Until then the walled lift + * would turn each of those writes into a 500 `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED`. + * + * It is a walled-posture bridge only: under `single` these inserts take the + * Default Organization like any other. ⛔ Add no table here. When the flips + * land, the tables have no tenant column, the engine exits on that before this + * list is read, and the tripwire pin in `@objectstack/service-automation` + * (`walled-organization-less-bridge.test.ts`) turns red until this list, its + * reader in `Engine.resolveSystemInsertOrganization` and that pin are deleted. + */ +export const WALLED_ORGANIZATION_LESS_BRIDGE: readonly string[] = Object.freeze(['sys_flow_credential', 'sys_secret']); + +/** Is `object` one of {@link WALLED_ORGANIZATION_LESS_BRIDGE}'s two tables? */ +export function isWalledOrganizationLessBridgeObject(object: string): boolean { + return WALLED_ORGANIZATION_LESS_BRIDGE.includes(object); +} + /** * Is this object in one of the platform namespaces? * diff --git a/packages/plugins/plugin-auth/src/last-admin-guard.config-anchor.test.ts b/packages/plugins/plugin-auth/src/last-admin-guard.config-anchor.test.ts index 982040e52b4..6c8c990da5c 100644 --- a/packages/plugins/plugin-auth/src/last-admin-guard.config-anchor.test.ts +++ b/packages/plugins/plugin-auth/src/last-admin-guard.config-anchor.test.ts @@ -110,6 +110,8 @@ const sysPermissionSet = { const sysMetadataActivation = { name: 'sys_metadata_activation', label: 'Metadata Activation', + // Deployment-level, as the platform object declares it: no organization column. + systemFields: { tenant: false }, fields: { id: { name: 'id', type: 'text' as const, primaryKey: true }, metadata_type: { name: 'metadata_type', type: 'text' as const }, @@ -195,10 +197,9 @@ function requestPosture(value: 'single' | 'group' | 'isolated' | undefined): voi async function boot(opts: { unguarded?: boolean } = {}): Promise { const engine = new ObjectQL(); engines.push(engine); - engine.registerDriver( - new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), - true, - ); + const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); + driverOf.set(engine, driver); + engine.registerDriver(driver, true); await engine.init(); for (const o of [sysUser, sysMember, sysPermissionSet, sysUserPermissionSet, sysMetadataActivation]) { engine.registry.registerObject(o as never); @@ -212,6 +213,22 @@ async function boot(opts: { unguarded?: boolean } = {}): Promise { return engine; } +/** The driver behind each booted engine, for {@link seedLegacyRow}. */ +const driverOf = new WeakMap(); + +/** + * [#11663 L5] Seed an organization-less legacy row — the unscoped + * `admin_full_access` grant and its catalog row — as a database written before + * the walled half of ADR-0131 C8 stage 8b holds it. Since that lift a system + * write can no longer create such a row under a wall (the engine refuses the + * organization-less insert), so it goes straight through the driver. Under + * `single` with no organization object the engine left the same row NULL, so + * the rig is unchanged there. + */ +async function seedLegacyRow(engine: ObjectQL, object: string, row: Record): Promise { + await driverOf.get(engine)!.create(object, row as never); +} + async function seedUser( engine: ObjectQL, id: string, @@ -427,11 +444,11 @@ describe('[#11663 L5] under a WALLED posture the legacy grant is not an administ const engine = await boot(); await seedUser(engine, 'usr_owner', OWNER, true); await seedUser(engine, 'usr_legacy', 'legacy@corp.example', true); - await engine.insert('sys_permission_set', { id: 'ps_a', name: ADMIN_FULL_ACCESS, active: true }, SYSTEM); - await engine.insert( + await seedLegacyRow(engine, 'sys_permission_set', { id: 'ps_a', name: ADMIN_FULL_ACCESS, active: true }); + await seedLegacyRow( + engine, 'sys_user_permission_set', { id: 'ups_1', user_id: 'usr_legacy', permission_set_id: 'ps_a', permission_set: ADMIN_FULL_ACCESS }, - SYSTEM, ); return engine; } @@ -479,11 +496,11 @@ describe('[#11663 L5] under a WALLED posture the legacy grant is not an administ declare(undefined); const engine = await boot(); await seedUser(engine, 'usr_legacy', 'legacy@corp.example', true); - await engine.insert('sys_permission_set', { id: 'ps_a', name: ADMIN_FULL_ACCESS, active: true }, SYSTEM); - await engine.insert( + await seedLegacyRow(engine, 'sys_permission_set', { id: 'ps_a', name: ADMIN_FULL_ACCESS, active: true }); + await seedLegacyRow( + engine, 'sys_user_permission_set', { id: 'ups_1', user_id: 'usr_legacy', permission_set_id: 'ps_a', permission_set: ADMIN_FULL_ACCESS }, - SYSTEM, ); await expect( @@ -500,11 +517,11 @@ describe('[#11663 L5] under a WALLED posture the legacy grant is not an administ declare(undefined); const engine = await boot(); await seedUser(engine, 'usr_legacy', 'legacy@corp.example', true); - await engine.insert('sys_permission_set', { id: 'ps_a', name: ADMIN_FULL_ACCESS, active: true }, SYSTEM); - await engine.insert( + await seedLegacyRow(engine, 'sys_permission_set', { id: 'ps_a', name: ADMIN_FULL_ACCESS, active: true }); + await seedLegacyRow( + engine, 'sys_user_permission_set', { id: 'ups_1', user_id: 'usr_legacy', permission_set_id: 'ps_a', permission_set: ADMIN_FULL_ACCESS }, - SYSTEM, ); await expect( @@ -543,10 +560,10 @@ describe('[#11663 L5] under a WALLED posture the legacy grant is not an administ const engine = await boot(); await seedUser(engine, 'usr_legacy', 'legacy@corp.example', true); await switchAdminSetOff(engine); - await engine.insert( + await seedLegacyRow( + engine, 'sys_user_permission_set', { id: 'ups_1', user_id: 'usr_legacy', permission_set_id: 'ps_a', permission_set: ADMIN_FULL_ACCESS }, - SYSTEM, ); await expect( @@ -562,10 +579,10 @@ describe('[#11663 L5] under a WALLED posture the legacy grant is not an administ const engine = await boot(); await seedUser(engine, 'usr_legacy', 'legacy@corp.example', true); await switchAdminSetOff(engine); - await engine.insert( + await seedLegacyRow( + engine, 'sys_user_permission_set', { id: 'ups_1', user_id: 'usr_legacy', permission_set_id: 'ps_a', permission_set: ADMIN_FULL_ACCESS }, - SYSTEM, ); let message = ''; diff --git a/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts b/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts index 52b7909c936..24c2d6cbbfd 100644 --- a/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts +++ b/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts @@ -530,10 +530,11 @@ for (const name of Object.keys(SCENARIOS) as ScenarioName[]) { } // Positive control: the census sees a row — one insert reads back as one. // [C8 stage 8b] Under `single` the organization-less system insert takes - // the sole organization (ADR-0131 D9); under the wall it stays unstamped, - // the exemption 18.0 keeps there. - await engine.insert('sys_permission_set', { id: 'ps_census_control', name: 'zz_census_control', label: 'Control' }, { context: SYS } as any); - const stamped = scenario.posture === 'single' ? (scenario.organizationAtBoot ? ORG : 'org_default') : '-'; + // the sole organization (ADR-0131 D9). Under the wall no organization is + // derivable, so the control carries its own, as a walled system write must. + const controlContext = scenario.posture === 'isolated' ? { ...SYS, tenantId: ORG } : SYS; + await engine.insert('sys_permission_set', { id: 'ps_census_control', name: 'zz_census_control', label: 'Control' }, { context: controlContext } as any); + const stamped = scenario.posture === 'single' ? (scenario.organizationAtBoot ? ORG : 'org_default') : ORG; expect(await permissionSetCensus(engine)).toEqual([`zz_census_control | ${stamped}`]); await engine.delete('sys_permission_set', { where: { id: 'ps_census_control' }, context: SYS } as any); expect(await permissionSetCensus(engine)).toEqual([]); diff --git a/packages/plugins/plugin-security/src/grant-holder-membership-refusal.test.ts b/packages/plugins/plugin-security/src/grant-holder-membership-refusal.test.ts index 0a237ad9d4a..d450b652659 100644 --- a/packages/plugins/plugin-security/src/grant-holder-membership-refusal.test.ts +++ b/packages/plugins/plugin-security/src/grant-holder-membership-refusal.test.ts @@ -177,11 +177,12 @@ async function boot(opts: BootOptions = {}) { vi.spyOn((engine as any).logger, 'warn').mockImplementation(() => undefined); // The catalog the rows name, seeded where every writer reads it: a position - // and a permission set with no organization. + // and a permission set. [C8 stage 8b] Under the wall a system insert must + // carry its organization, so the position rows are the writers' organization's. await engine.insert('sys_position', [ { id: 'pos_rep', name: 'qa_rep', label: 'Rep', active: true }, { id: 'pos_lead', name: 'qa_lead', label: 'Lead', active: true }, - ], { context: SYS } as any); + ], { context: opts.walled ? { ...SYS, tenantId: ORG_A } : SYS } as any); // [ADR-0131 D3/D4] The set a grant names lives in the catalog (the registry). engine.registry.registerItem('permission', { name: 'qa_rep_set', label: 'QA Rep' } as any, 'name' as any, 'qa.grant-holder'); if (withMembers) { diff --git a/packages/plugins/plugin-sharing/src/reconcile-refused-grant-continues.test.ts b/packages/plugins/plugin-sharing/src/reconcile-refused-grant-continues.test.ts index 2734cbe29c8..26d77bb9313 100644 --- a/packages/plugins/plugin-sharing/src/reconcile-refused-grant-continues.test.ts +++ b/packages/plugins/plugin-sharing/src/reconcile-refused-grant-continues.test.ts @@ -173,6 +173,7 @@ async function boot(posture: 'single' | 'isolated' | 'group' = 'isolated') { const warn = vi.fn(); const sharing = new SharingService({ engine: ql as never }); const rules = new SharingRuleService({ engine: ql as never, sharing, logger: { warn } }); + driverOf.set(rules, driver); return { driver, @@ -225,19 +226,36 @@ afterEach(async () => { while (open.length) await open.pop()?.disconnect?.(); }); -const platformGlobalRule = (rules: SharingRuleService, name: string, accessLevel: 'read' | 'edit' = 'read') => - rules.defineRule( - { - name, - label: 'Platform won', - object: OBJECT, - criteria: WON, - recipientType: 'user', - recipientId: 'u_plat', - accessLevel, - } as never, - SYSTEM, - ); +/** The driver behind each booted rule service, for {@link platformGlobalRule}. */ +const driverOf = new WeakMap(); + +/** + * A platform-global rule (`organization_id` null), as a database written + * before the walled half of ADR-0131 C8 stage 8b holds one. Since that lift a + * system write can no longer create such a row under a wall (the engine + * refuses the organization-less insert), so the fixture seeds it straight + * through the driver, as it seeds its organization-less records, and the + * reconcile below meets the row such a database still holds. + */ +const platformGlobalRule = async (rules: SharingRuleService, name: string, accessLevel: 'read' | 'edit' = 'read') => { + const now = '2026-01-01T00:00:00Z'; + await driverOf.get(rules)!.create('sys_sharing_rule', { + id: `srule_${name}`, + name, + label: 'Platform won', + object_name: OBJECT, + criteria_json: JSON.stringify(WON), + recipient_type: 'user', + recipient_id: 'u_plat', + access_level: accessLevel, + active: true, + managed_by: 'admin', + customized: false, + created_at: now, + updated_at: now, + } as never); + return (await rules.getRule(`srule_${name}`, SYSTEM))!; +}; describe('[#14754] reconcile: a refused grant is counted and the pass CONTINUES', () => { it('CONTROL: on an `isolated` posture the organization-less record really is refused — the precondition, measured', async () => { diff --git a/packages/plugins/plugin-webhooks/src/auto-enqueuer.ts b/packages/plugins/plugin-webhooks/src/auto-enqueuer.ts index bb4bbf1025d..a84360b4471 100644 --- a/packages/plugins/plugin-webhooks/src/auto-enqueuer.ts +++ b/packages/plugins/plugin-webhooks/src/auto-enqueuer.ts @@ -155,17 +155,17 @@ interface CachedSubscription { */ organizationId?: string; /** - * [ADR-0131 D2/D9] Set on a DECLARED webhook when the deployment's posture - * builds no organization wall (`single`). A declared webhook is the - * application's, not one organization's, so it has no `organizationId` of - * its own — and under `single` every record event is stamped with the - * Default Organization (D9), so the org-less refusal below would make it - * receive nothing at all. Deployment-wide, it receives every organization's - * events and each delivery row is stamped with the EVENT's organization: - * exactly what the 18.0 seeded copy did, whose row the boot stamped with - * that same Default Organization. Never set under a wall, where the - * ruling's org-less refusal ({@link AutoEnqueuer.admitsOrganization}) - * stands. + * [ADR-0131 D2/D9] Set on every DECLARED webhook, under every posture. A + * declared webhook is the application's, not one organization's, so it has + * no `organizationId` of its own, and the org-less refusal below would make + * it receive nothing on any posture where events carry an organization + * (every one, since D9). Deployment-wide, it receives every organization's + * events and each delivery row is stamped with the EVENT's organization, + * so its delivery history is walled like any other organization-stamped + * row. Under `single` that is what the 18.0 seeded copy did; under `group` + * and `isolated` it is the epic's ruling A on the walled shape of a + * declared webhook (record 6278609968 on #24580). Its one switch is the + * deployment-level activation-ledger row, operator-gated under a wall. */ deploymentWide?: boolean; /** @@ -206,14 +206,6 @@ export interface AutoEnqueuerOptions { */ declaredWebhooks?: DeclaredWebhookReader; - /** - * Whether the deployment's tenancy posture builds no organization wall, - * read on every refresh. True makes each declared webhook deployment-wide - * (see `CachedSubscription.deploymentWide`); absent or false keeps it - * org-less, refused for an organization-walled event. - */ - declaredWebhooksDeploymentWide?: () => boolean; - logger?: OptionalLogger; } @@ -318,7 +310,6 @@ export class AutoEnqueuer { private readonly organizationRefusalReported = new Set(); /** [ADR-0131 D2/D6] The declared-webhook reader, when the host binds one. */ private readonly declared?: DeclaredWebhookReader; - private readonly declaredDeploymentWide?: () => boolean; /** Self-heal subscription on the activation ledger — bound only with {@link declared}. */ private subIdLedger: string | undefined; /** @@ -338,7 +329,6 @@ export class AutoEnqueuer { this.refreshIntervalMs = opts.refreshIntervalMs ?? 60_000; this.logger = opts.logger; this.declared = opts.declaredWebhooks; - this.declaredDeploymentWide = opts.declaredWebhooksDeploymentWide; } /** @@ -578,12 +568,6 @@ export class AutoEnqueuer { else heldByRow.add(String(row.name)); } - let deploymentWide = false; - try { - deploymentWide = this.declaredDeploymentWide?.() === true; - } catch { - /* an unreadable posture is not an open one: org-less, as under a wall */ - } const subscriptions: CachedSubscription[] = []; for (const d of declared) { const name = d.webhook.name; @@ -595,7 +579,8 @@ export class AutoEnqueuer { if (!d.active) continue; const sub = this.projectDeclared(d); if (!sub) continue; - if (deploymentWide) sub.deploymentWide = true; + // Deployment-wide under every posture (`CachedSubscription.deploymentWide`). + sub.deploymentWide = true; subscriptions.push(sub); } return { subscriptions, mirrorIds }; @@ -1417,9 +1402,9 @@ export class AutoEnqueuer { * * **A deployment-wide declared webhook — deliver, whatever the event names.** * Checked first ({@link CachedSubscription.deploymentWide}): a declared - * webhook on a posture with no wall belongs to the deployment, so every - * organization's event is its own. Without it the `none` × `org A` row - * above would refuse every event on a `single` install. + * webhook belongs to the deployment on every posture (ruling A, record + * 6278609968 on #24580), so every organization's event is its own. Without + * it the `none` × `org A` row above would refuse every event it matches. * * Both refusals are said ONCE per subscription (the #8022 say-once rule, * ledger {@link organizationRefusalReported}): the first refused event is @@ -1433,8 +1418,8 @@ export class AutoEnqueuer { eventOrganizationId: string | undefined, event: RealtimeEventPayload, ): boolean { - // [ADR-0131 D2/D9] A declared webhook on a deployment with no wall - // belongs to the deployment, so every organization's event is its own. + // [ADR-0131 D2/D9, ruling A] A declared webhook belongs to the + // deployment on every posture, so every organization's event is its own. if (sub.deploymentWide) return true; // org A × org A, and none × absent. if (sub.organizationId === eventOrganizationId) return true; diff --git a/packages/plugins/plugin-webhooks/src/declared-webhook-name-guard.test.ts b/packages/plugins/plugin-webhooks/src/declared-webhook-name-guard.test.ts new file mode 100644 index 00000000000..0b288b7f8a9 --- /dev/null +++ b/packages/plugins/plugin-webhooks/src/declared-webhook-name-guard.test.ts @@ -0,0 +1,212 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D6 regime C] No `sys_webhook` row may take a declared webhook's + * name. + * + * The dispatcher withholds a declaration whenever a `sys_webhook` row holds + * its name (`heldByRow`, `auto-enqueuer.ts`), and it does so for the whole + * deployment. Under a wall one organization's same-name row would therefore + * silence a deployment-wide declaration for every organization. So an insert + * of a declared name, and a rename onto one, are refused with 409 + * `RESOURCE_CONFLICT` — the shape `bindDeclaredRuleNameGuard` gives the + * declared sharing rules. + * + * Each refusal below carries its discriminating control: an undeclared name, + * an update that keeps its name (the 18.0 mirror rows an upgraded database + * holds), a system write — so a guard that refused everything goes red too. + */ + +import { describe, expect, it } from 'vitest'; +import { HttpStatusErrorCodeMap } from '@objectstack/spec/api'; +import { + DECLARED_WEBHOOK_NAME_GUARD_PACKAGE, + DeclaredWebhookSource, + bindDeclaredWebhookNameGuard, +} from './declared-webhooks.js'; +import { WebhookOutboxPlugin } from './webhook-outbox-plugin.js'; + +const LEDGER = 'sys_metadata_activation'; + +interface HookEntry { + event: string; + handler: (ctx: any) => any; + object?: string; + packageId?: string; +} + +function declaredWebhook(over: Record = {}): any { + return { + name: 'task_changed', + label: 'Task Changed', + object: 'showcase_task', + triggers: ['create', 'update'], + url: 'https://hooks.example/task', + method: 'POST', + isActive: true, + ...over, + }; +} + +/** + * The engine surface the guard and the reader touch: the hook registry, a + * `find` over an in-memory ledger, and a registry of declared items. `fire` + * runs the bound hooks the way the engine's write path does. + */ +function makeEngine(declared: any[], ledger: any[] = []) { + let hooks: HookEntry[] = []; + const engine = { + registry: { getObject: (name: string) => (name === LEDGER ? { name } : undefined) }, + get _registry() { + return { listItems: (type: string) => (type === 'webhook' ? [...declared] : []) }; + }, + async find(name: string, q?: any): Promise { + if (name !== LEDGER) return []; + const f = q?.filter ?? {}; + return ledger + .filter((r) => Object.entries(f).every(([k, v]) => { + if (k.startsWith('$')) throw new Error(`fake engine: the ${k} combinator is not implemented`); + return r[k] === v; + })) + .map((r) => ({ ...r })); + }, + registerHook(event: string, handler: (ctx: any) => any, options?: Record): void { + hooks.push({ event, handler, object: options?.object, packageId: options?.packageId }); + }, + unregisterHooksByPackage(packageId: string): number { + const before = hooks.length; + hooks = hooks.filter((h) => h.packageId !== packageId); + return before - hooks.length; + }, + hooksFor: (packageId: string) => hooks.filter((h) => h.packageId === packageId), + async fire(event: string, object: string, ctx: Record): Promise { + for (const h of hooks.filter((x) => x.event === event && x.object === object)) await h.handler(ctx); + }, + }; + return engine; +} + +const ADMIN = { userId: 'u_admin', tenantId: 'org_a' }; +const SYSTEM = { isSystem: true }; + +function insertCtx(name: string, session: Record = ADMIN) { + return { input: { data: { name, url: 'https://hooks.example/mine' } }, session }; +} + +function updateCtx(previousName: string, data: Record, session: Record = ADMIN) { + return { input: { id: 'whk_1', data }, previous: { id: 'whk_1', name: previousName }, session }; +} + +async function refusalOf(p: Promise): Promise { + try { + await p; + } catch (err: any) { + return { code: err?.code, status: err?.status, webhook: err?.webhook, name: err?.name }; + } + throw new Error('expected the write to be REFUSED, but it resolved successfully'); +} + +function guarded(declared: any[] = [declaredWebhook()], ledger: any[] = []) { + const engine = makeEngine(declared, ledger); + bindDeclaredWebhookNameGuard(engine, new DeclaredWebhookSource(engine, undefined)); + return engine; +} + +describe('the declared-name guard on sys_webhook — an insert', () => { + it('a non-system insert of a declared name is refused 409 RESOURCE_CONFLICT, naming the declaration', async () => { + const engine = guarded(); + const refusal = await refusalOf(engine.fire('beforeInsert', 'sys_webhook', insertCtx('task_changed'))); + expect(refusal).toEqual({ + code: 'RESOURCE_CONFLICT', + status: 409, + webhook: 'task_changed', + name: 'DeclaredWebhookNameConflictError', + }); + // The catalogue's own code for the status, not a restated pair. + expect(HttpStatusErrorCodeMap[409]).toBe(refusal.code); + }); + + it('a declaration switched OFF — shipped inactive, or by the ledger — still holds its name', async () => { + const shippedOff = guarded([declaredWebhook({ isActive: false })]); + expect((await refusalOf(shippedOff.fire('beforeInsert', 'sys_webhook', insertCtx('task_changed')))).code) + .toBe('RESOURCE_CONFLICT'); + const ledgerOff = guarded([declaredWebhook()], [{ metadata_type: 'webhook', name: 'task_changed', active: false }]); + expect((await refusalOf(ledgerOff.fire('beforeInsert', 'sys_webhook', insertCtx('task_changed')))).code) + .toBe('RESOURCE_CONFLICT'); + }); + + it('control: an undeclared name passes', async () => { + const engine = guarded(); + await expect(engine.fire('beforeInsert', 'sys_webhook', insertCtx('my_own_hook'))).resolves.toBeUndefined(); + }); + + it('control: a system insert of a declared name passes — the platform machinery, never an author', async () => { + const engine = guarded(); + await expect(engine.fire('beforeInsert', 'sys_webhook', insertCtx('task_changed', SYSTEM))).resolves.toBeUndefined(); + }); + + it('control: a deployment that declares no webhook refuses nothing', async () => { + const engine = guarded([]); + await expect(engine.fire('beforeInsert', 'sys_webhook', insertCtx('task_changed'))).resolves.toBeUndefined(); + }); +}); + +describe('the declared-name guard on sys_webhook — an update', () => { + it('a rename onto a declared name is refused 409 RESOURCE_CONFLICT', async () => { + const engine = guarded(); + const refusal = await refusalOf( + engine.fire('beforeUpdate', 'sys_webhook', updateCtx('my_own_hook', { name: 'task_changed' })), + ); + expect(refusal).toMatchObject({ code: 'RESOURCE_CONFLICT', status: 409, webhook: 'task_changed' }); + }); + + it('control: an update that keeps a declared name passes — an 18.0 row holding it stays editable', async () => { + const engine = guarded(); + // A full-form save re-sends the name it already has. + await expect( + engine.fire('beforeUpdate', 'sys_webhook', updateCtx('task_changed', { name: 'task_changed', active: false })), + ).resolves.toBeUndefined(); + // A patch that does not name it at all. + await expect( + engine.fire('beforeUpdate', 'sys_webhook', updateCtx('task_changed', { active: false })), + ).resolves.toBeUndefined(); + }); + + it('control: a rename onto an undeclared name passes, and a system rename onto a declared one passes', async () => { + const engine = guarded(); + await expect( + engine.fire('beforeUpdate', 'sys_webhook', updateCtx('task_changed', { name: 'renamed_hook' })), + ).resolves.toBeUndefined(); + await expect( + engine.fire('beforeUpdate', 'sys_webhook', updateCtx('my_own_hook', { name: 'task_changed' }, SYSTEM)), + ).resolves.toBeUndefined(); + }); +}); + +describe('the plugin binds the guard', () => { + it('WebhookOutboxPlugin binds it on sys_webhook beside the reader, and unbinds it on destroy', async () => { + const engine = makeEngine([declaredWebhook()]); + const services: Record = { objectql: engine, manifest: { register() {} } }; + const hooks: Record Promise>> = {}; + const ctx: any = { + logger: { info() {}, warn() {}, error() {}, debug() {} }, + getService: (n: string) => { + if (!(n in services)) throw new Error(`no ${n}`); + return services[n]; + }, + registerService: (n: string, s: unknown) => { services[n] = s; }, + hook: (event: string, fn: () => Promise) => { (hooks[event] ??= []).push(fn); }, + }; + const plugin = new WebhookOutboxPlugin({ autoEnqueue: false }); + await plugin.init(ctx); + for (const fn of hooks['kernel:ready'] ?? []) await fn(); + + expect(engine.hooksFor(DECLARED_WEBHOOK_NAME_GUARD_PACKAGE).map((h) => [h.event, h.object]).sort()) + .toEqual([['beforeInsert', 'sys_webhook'], ['beforeUpdate', 'sys_webhook']]); + const refusal = await refusalOf(engine.fire('beforeInsert', 'sys_webhook', insertCtx('task_changed'))); + expect(refusal.code).toBe('RESOURCE_CONFLICT'); + + await plugin.destroy(); + expect(engine.hooksFor(DECLARED_WEBHOOK_NAME_GUARD_PACKAGE)).toEqual([]); + }); +}); diff --git a/packages/plugins/plugin-webhooks/src/declared-webhooks.test.ts b/packages/plugins/plugin-webhooks/src/declared-webhooks.test.ts index 134245506bf..79ccb30887f 100644 --- a/packages/plugins/plugin-webhooks/src/declared-webhooks.test.ts +++ b/packages/plugins/plugin-webhooks/src/declared-webhooks.test.ts @@ -211,7 +211,7 @@ async function flush() { await new Promise((r) => setTimeout(r, 0)); } -async function startEnqueuer(engine: FakeEngine, logger?: any, deploymentWide?: boolean) { +async function startEnqueuer(engine: FakeEngine, logger?: any) { const realtime = new FakeRealtime(); const calls: any[] = []; const enqueue: HttpEnqueueFn = async (input) => { @@ -222,7 +222,6 @@ async function startEnqueuer(engine: FakeEngine, logger?: any, deploymentWide?: const enqueuer = new AutoEnqueuer(engine as any, realtime as any, enqueue, { refreshIntervalMs: 0, declaredWebhooks, - ...(deploymentWide !== undefined ? { declaredWebhooksDeploymentWide: () => deploymentWide } : {}), ...(logger ? { logger } : {}), }); await enqueuer.start(); @@ -319,12 +318,12 @@ describe('declared webhooks — the organization dimension follows the posture ( payload: { recordId: 't-1', organizationId }, }); - it('with no wall (`single`) a declared webhook receives an organization-stamped event, its delivery stamped with that organization', async () => { + it('a declared webhook receives an organization-stamped event, its delivery stamped with that organization', async () => { // Under `single` every record carries the Default Organization (D9), and // the 18.0 seeded copy carried it too, so it delivered. The declaration // has no organization of its own; deployment-wide, it delivers as well. const engine = newEngine({ declared: { webhook: [declaredWebhook()] } }); - const { enqueuer, realtime, calls } = await startEnqueuer(engine, undefined, true); + const { enqueuer, realtime, calls } = await startEnqueuer(engine); await realtime.publish(orgEvent('org_default')); await flush(); expect(calls).toHaveLength(1); @@ -332,19 +331,22 @@ describe('declared webhooks — the organization dimension follows the posture ( await enqueuer.stop(); }); - it('under a wall a declared webhook stays org-less: an organization-walled event is refused, said once', async () => { + it('[ruling A] two organizations: one delivery per event, each stamped with its event\'s organization, and nothing refused', async () => { const warn = vi.fn(); const engine = newEngine({ declared: { webhook: [declaredWebhook()] } }); - const { enqueuer, realtime, calls } = await startEnqueuer(engine, { warn, debug() {} }, false); - await realtime.publish(orgEvent('org_a')); + const { enqueuer, realtime, calls } = await startEnqueuer(engine, { warn, debug() {} }); await realtime.publish(orgEvent('org_a')); + await realtime.publish(orgEvent('org_b')); await flush(); - expect(calls).toEqual([]); - expect(warn.mock.calls.filter(([m]) => String(m).includes('belongs to NO organization'))).toHaveLength(1); + expect(calls.map((c) => [c.refId, c.organizationId])).toEqual([ + [declaredWebhookRefId('task_changed'), 'org_a'], + [declaredWebhookRefId('task_changed'), 'org_b'], + ]); + expect(warn.mock.calls.filter(([m]) => String(m).includes('belongs to NO organization'))).toEqual([]); await enqueuer.stop(); }); - it('the plugin reads the posture from the tenancy service: `single` is deployment-wide, an absent service is walled', async () => { + it('[ruling A] the plugin serves it deployment-wide under every posture, and with no tenancy service', async () => { const boot = async (tenancy?: unknown) => { const engine = newEngine({ declared: { webhook: [declaredWebhook()] } }); const realtime = new FakeRealtime(); @@ -379,9 +381,10 @@ describe('declared webhooks — the organization dimension follows the posture ( await plugin.destroy(); return calls; }; - expect(await boot({ posture: 'single' })).toHaveLength(1); - expect(await boot({ posture: 'isolated' })).toEqual([]); - expect(await boot()).toEqual([]); + for (const tenancy of [{ posture: 'single' }, { posture: 'group' }, { posture: 'isolated' }, undefined]) { + const calls = await boot(tenancy); + expect(calls.map((c: any) => c.organizationId), JSON.stringify(tenancy)).toEqual(['org_default']); + } }); }); diff --git a/packages/plugins/plugin-webhooks/src/declared-webhooks.ts b/packages/plugins/plugin-webhooks/src/declared-webhooks.ts index 23e538264e1..2ee34596942 100644 --- a/packages/plugins/plugin-webhooks/src/declared-webhooks.ts +++ b/packages/plugins/plugin-webhooks/src/declared-webhooks.ts @@ -45,6 +45,17 @@ * sharing-rule reader's rules: a composition with no ledger object issues no * read, and a read that fails switches nothing for this pass. * + * ## Every posture: deployment-wide + * + * A declared webhook is the deployment's under `single`, `group` and + * `isolated` alike (the epic's ruling A, record 6278609968 on #24580): switched + * on, it receives every organization's events, and each `sys_http_delivery` + * row is stamped with its event's organization, so delivery history is walled + * like any other organization-stamped row. There is one switch, the ledger row + * above — deployment-level, and the platform operator's under a wall (the + * activation door's ADR-0126 §5 posture rule); an organization administrator + * is refused there. + * * ## An 18.0 database: the dual read (expand first) * * The 18.0 seeder's rows stay where they are; deleting them, and re-sourcing @@ -253,3 +264,83 @@ export async function readWebhookActivation(engine: unknown, logger?: Logger): P export function classifyDeclaredNameHolder(row: { managed_by?: unknown; customized?: unknown }): 'mirror' | 'row' { return row.managed_by === 'package' && row.customized !== true ? 'mirror' : 'row'; } + +/** + * [ADR-0131 D6 regime C] A `sys_webhook` row — new, or renamed — that would + * take a declared webhook's name. + * + * A declared webhook is read-only and clonable: it is customised by a CLONE + * under a new name, with the declaration switched off through the activation + * ledger. A row holding a declared name withholds the declaration for the + * whole deployment (the dispatcher's `heldByRow`), so under a wall one + * organization's same-name row would silence a deployment-wide declaration + * for every organization. No write may take a declared name — Setup's New, a + * script, an AI tool, the `/data` door — and every such write is refused + * before it lands. + * + * `code` + `status` are the declared envelope a door answers at its own status + * (409 `RESOURCE_CONFLICT`, the catalogue's code for a 409 — + * `HttpStatusErrorCodeMap`): the name is taken by a declaration, which is a + * conflict, not a missing capability. The same shape as the sharing rules' + * `DeclaredSharingRuleNameConflictError`. + */ +export class DeclaredWebhookNameConflictError extends Error { + readonly code = 'RESOURCE_CONFLICT'; + readonly status = 409; + /** The declared webhook whose name the write would take. */ + readonly webhook: string; + constructor(name: string, write: 'create' | 'rename') { + super( + `webhook '${name}' is declared by the application, so ` + + (write === 'create' ? 'a new webhook cannot take its name' : 'no webhook can be renamed to it') + + `. Declared webhooks are read-only: to customise '${name}', create your own webhook under another ` + + `name and switch the declared one off (POST /api/v1/security/_activation/${WEBHOOK_ACTIVATION_TYPE}/${name} ` + + 'with { "enabled": false }).', + ); + this.name = 'DeclaredWebhookNameConflictError'; + this.webhook = name; + } +} + +/** Package id of {@link bindDeclaredWebhookNameGuard}'s hooks. */ +export const DECLARED_WEBHOOK_NAME_GUARD_PACKAGE = 'plugin-webhooks:declared-name-guard'; + +/** + * [ADR-0131 D6 regime C] Bind the guard above on `sys_webhook` writes: an + * insert of a declared name is refused, and so is an update that CHANGES + * `name` onto one; an update that leaves the name alone passes — so an 18.0 + * database's seeded mirror rows, and the rows an administrator customised + * there, stay editable exactly as they were. + * + * System writes pass: they are the platform's own machinery (the boot sweep, + * the ownership ceremony, migrations), never an author taking a name. + */ +export function bindDeclaredWebhookNameGuard( + engine: { + registerHook(event: string, handler: (ctx: any) => any, options?: Record): void; + unregisterHooksByPackage?(packageId: string): number; + }, + declared: Pick, + object: string = 'sys_webhook', +): void { + engine.unregisterHooksByPackage?.(DECLARED_WEBHOOK_NAME_GUARD_PACKAGE); + const guard = (insert: boolean) => async (ctx: any) => { + if ((ctx?.session as any)?.isSystem) return; + const data = ctx?.input?.data; + if (!data || typeof data !== 'object' || Array.isArray(data)) return; + if (typeof data.name !== 'string') return; + // An update is a rename only against its pre-image (`ctx.previous`, the + // `HookContext` contract both update shapes bind before `beforeUpdate`): a + // full-form save re-sends the name it already has. + if (!insert) { + const previous = ctx?.previous as Record | undefined; + if (!previous || typeof previous !== 'object' || data.name === previous.name) return; + } + if (await declared.declaredWebhookNamed(data.name)) { + throw new DeclaredWebhookNameConflictError(data.name, insert ? 'create' : 'rename'); + } + }; + const opts = { object, packageId: DECLARED_WEBHOOK_NAME_GUARD_PACKAGE, priority: 100 }; + engine.registerHook('beforeInsert', guard(true), opts); + engine.registerHook('beforeUpdate', guard(false), opts); +} diff --git a/packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts b/packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts index c581513b29c..1cd063f8b8b 100644 --- a/packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts +++ b/packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts @@ -19,8 +19,11 @@ import { AutoEnqueuer, type AutoEnqueuerOptions } from './auto-enqueuer.js'; /** What the auth service's in-process session read answers, as `AuthSessionApi` declares it. */ type SessionRead = Awaited>>; import { SysWebhook } from './sys-webhook.object.js'; -import { DeclaredWebhookSource } from './declared-webhooks.js'; -import { normalizeTenancyPosture, postureEnforcesWall } from '@objectstack/spec/security'; +import { + DeclaredWebhookSource, + DECLARED_WEBHOOK_NAME_GUARD_PACKAGE, + bindDeclaredWebhookNameGuard, +} from './declared-webhooks.js'; import { migrateLegacyWebhookSecrets } from './migrate-webhook-secrets.js'; import { createWebhookRedeliverGuard } from './redeliver-guard.js'; import { bindWebhookProvenanceStamp, unbindWebhookProvenanceStamp } from './webhook-provenance.js'; @@ -275,6 +278,7 @@ export class WebhookOutboxPlugin implements Plugin { try { unbindWebhookProvenanceStamp(this.boundEngine); } catch { /* best effort */ } try { unbindWebhookHeadersShapeGate(this.boundEngine); } catch { /* best effort */ } try { unbindWebhookLegacyCleartextGate(this.boundEngine); } catch { /* best effort */ } + try { (this.boundEngine as any).unregisterHooksByPackage?.(DECLARED_WEBHOOK_NAME_GUARD_PACKAGE); } catch { /* best effort */ } this.boundEngine = undefined; } } @@ -339,6 +343,10 @@ export class WebhookOutboxPlugin implements Plugin { try { metadataService = ctx.getService('metadata'); } catch { /* optional */ } this.declaredSource = new DeclaredWebhookSource(engine, metadataService, ctx.logger as any); ctx.registerService(DECLARED_WEBHOOKS_SLOT, this.declaredSource); + // And the declared-name guard: a row that took a declared name would + // withhold the declaration for the whole deployment, so no non-system + // write may take one (409 `RESOURCE_CONFLICT`). + bindDeclaredWebhookNameGuard(engine as any, this.declaredSource); // [#7799] Then heal the rows that still hold a key in cleartext: the // ones an administrator authored or edited, and any 18.0 seeded row the // seeder had not yet rewritten. @@ -384,12 +392,7 @@ export class WebhookOutboxPlugin implements Plugin { (input) => messaging.enqueueHttp(input), { ...enqOpts, - ...(this.declaredSource - ? { - declaredWebhooks: this.declaredSource, - declaredWebhooksDeploymentWide: () => this.postureBuildsNoWall(ctx), - } - : {}), + ...(this.declaredSource ? { declaredWebhooks: this.declaredSource } : {}), logger: ctx.logger, }, ); @@ -479,23 +482,6 @@ export class WebhookOutboxPlugin implements Plugin { return true; } - /** - * [ADR-0131 D9] Whether the deployment's tenancy posture builds no - * organization wall — the `tenancy` service's posture, read as the sharing - * plugin reads it for its declared rules: an absent or unreadable service - * is read as walled, never as open. - */ - private postureBuildsNoWall(ctx: PluginContext): boolean { - try { - const probe = ctx.getService<{ posture?: unknown; isolationActive?: boolean }>('tenancy'); - const posture = normalizeTenancyPosture(probe?.posture); - if (posture) return !postureEnforcesWall(posture); - return probe?.isolationActive === false; - } catch { - return false; - } - } - private tryGetService(ctx: PluginContext, names: string[]): T | undefined { for (const n of names) { try { diff --git a/packages/qa/dogfood/test/wall-declared-webhook-deployment-wide.dogfood.test.ts b/packages/qa/dogfood/test/wall-declared-webhook-deployment-wide.dogfood.test.ts new file mode 100644 index 00000000000..68f1eecbf51 --- /dev/null +++ b/packages/qa/dogfood/test/wall-declared-webhook-deployment-wide.dogfood.test.ts @@ -0,0 +1,168 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [ADR-0131 D2/D6, ruling A on the walled shape of a declared webhook] Under +// `group` and `isolated` a declared webhook is the DEPLOYMENT's, as it is under +// `single`: switched on, it receives every organization's events, and each +// `sys_http_delivery` row is stamped with its event's organization, so delivery +// history is walled like any other organization-stamped row. The switch is the +// deployment-level activation-ledger row, and it is the platform operator's +// under a wall. +// +// Before the ruling a declared webhook under a wall was organization-less, and +// the dispatcher refused every organization-walled event it matched (one +// warning, nothing delivered). This boots a real walled posture with two +// organizations and pins the three halves of the ruling: +// +// 1. one delivery per event, stamped with that event's organization; +// 2. each organization reads only its own deliveries; +// 3. an organization administrator is refused at the activation door, and +// the platform operator is not. +// +// The realtime service is an in-test hand bus registered as `realtime`, so the +// engine's own record events reach the plugin's real dispatcher. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { MessagingServicePlugin } from '@objectstack/service-messaging'; +import { WebhookOutboxPlugin } from '@objectstack/plugin-webhooks'; +import { webhookFixtureStack } from './fixtures/webhook-materialization-fixture.js'; + +const SYS = { isSystem: true, positions: [], permissions: [] }; +const DECLARED_REF = 'declared:wm_task_changed'; +const DELIVERY_READER = 'dw_delivery_reader'; + +/** The realtime surface the engine publishes to and the dispatcher subscribes through. */ +function handRealtime() { + const subs = new Map unknown; opts?: { object?: string } }>(); + let n = 0; + return { + async publish(event: any) { + for (const s of subs.values()) { + if (s.opts?.object && s.opts.object !== event.object) continue; + await s.handler(event); + } + }, + async subscribe(_channel: string, handler: (e: any) => unknown, opts?: { object?: string }) { + const id = `s-${++n}`; + subs.set(id, { handler, opts }); + return id; + }, + async unsubscribe(id: string) { + subs.delete(id); + }, + }; +} + +function realtimePlugin() { + const bus = handRealtime(); + return { + name: 'dogfood.hand-realtime', + version: '0.0.0', + init: async (ctx: any) => { + ctx.registerService('realtime', bus); + }, + }; +} + +async function settle(): Promise { + for (let i = 0; i < 5; i++) await new Promise((r) => setTimeout(r, 10)); +} + +for (const posture of ['isolated', 'group'] as const) { + describe(`[ruling A] ${posture}: a declared webhook is deployment-wide`, () => { + let stack: VerifyStack; + let ql: any; + let operator: string; + let tokA: string; + let tokB: string; + let orgA: string; + let orgB: string; + const previous = process.env.OS_TENANCY_POSTURE; + + const deliveries = async () => + ((await ql.find('sys_http_delivery', { where: { ref_id: DECLARED_REF }, limit: 100, context: SYS })) as any[]); + + beforeAll(async () => { + process.env.OS_TENANCY_POSTURE = posture; + stack = await bootStack(webhookFixtureStack as never, { + multiTenant: 'posture-only', + extraPlugins: [realtimePlugin(), new MessagingServicePlugin(), new WebhookOutboxPlugin()], + }); + ql = await stack.kernel.getServiceAsync('objectql'); + operator = await stack.signIn(); + tokA = await stack.signUp(`dw-a-${posture}@wall.test`); + tokB = await stack.signUp(`dw-b-${posture}@wall.test`); + for (const [tok, slug] of [[tokA, `dw-a-${posture}`], [tokB, `dw-b-${posture}`]] as const) { + const created = await stack.apiAs(tok, 'POST', '/auth/organization/create', { name: slug, slug }); + expect(created.status, await created.clone().text()).toBe(200); + const id = ((await created.json()) as { id: string }).id; + if (tok === tokA) orgA = id; else orgB = id; + const active = await stack.apiAs(tok, 'POST', '/auth/organization/set-active', { organizationSlug: slug }); + expect(active.status, await active.clone().text()).toBe(200); + } + // Each organization's member holds, inside their own organization only + // (an organization-scoped grant), read on the delivery table and + // `manage_metadata`: the read below is judged by the wall, not refused by + // object permission, and the activation door's `manage_metadata` gate + // admits them, so only the walled posture rule can refuse them there. + const saved = await stack.apiAs(operator, 'PUT', `/meta/permission/${DELIVERY_READER}`, { + name: DELIVERY_READER, label: 'Delivery reader', systemPermissions: ['manage_metadata'], + objects: { sys_http_delivery: { allowRead: true } }, + }); + expect(saved.status, await saved.clone().text()).toBe(200); + for (const [email, organization] of [[`dw-a-${posture}@wall.test`, orgA], [`dw-b-${posture}@wall.test`, orgB]] as const) { + const user = String((await ql.find('sys_user', { where: { email }, limit: 1, context: SYS }))[0]?.id); + await ql.insert('sys_user_permission_set', { user_id: user, permission_set: DELIVERY_READER, organization_id: organization }, + { context: { ...SYS, tenantId: organization } }); + } + }, 180_000); + + afterAll(async () => { + await stack?.stop?.(); + if (previous === undefined) delete process.env.OS_TENANCY_POSTURE; + else process.env.OS_TENANCY_POSTURE = previous; + }); + + it('guard the guard: a real walled posture with two organizations', async () => { + const tenancy = await stack.kernel.getServiceAsync<{ posture: string; degraded: boolean }>('tenancy'); + expect(tenancy.posture).toBe(posture); + expect(tenancy.degraded).toBe(false); + expect(orgA).toBeTruthy(); + expect(orgB).toBeTruthy(); + expect(orgA).not.toBe(orgB); + }); + + it('an organization administrator is refused at the activation door; the platform operator switches it on', async () => { + const refused = await stack.apiAs(tokA, 'POST', '/security/_activation/webhook/wm_task_changed', { enabled: false }); + expect(refused.status, await refused.clone().text()).toBe(403); + expect(((await refused.json()) as any)?.error?.code).toBe('PERMISSION_DENIED'); + const ledger = await ql.find('sys_metadata_activation', { where: { metadata_type: 'webhook' }, context: SYS }); + expect(ledger).toEqual([]); + + const on = await stack.apiAs(operator, 'POST', '/security/_activation/webhook/wm_task_changed', { enabled: true }); + expect(on.status, await on.clone().text()).toBe(200); + }); + + it("one delivery per event, each stamped with that event's organization", async () => { + expect(await deliveries()).toEqual([]); + await ql.insert('wm_task', { name: 'task in A' }, { context: { ...SYS, tenantId: orgA } }); + await ql.insert('wm_task', { name: 'task in B' }, { context: { ...SYS, tenantId: orgB } }); + await settle(); + + const rows = await deliveries(); + expect(rows.map((r) => r.organization_id).sort()).toEqual([orgA, orgB].sort()); + }); + + it('each organization reads only its own deliveries', async () => { + const read = async (token: string) => { + const res = await stack.apiAs(token, 'GET', `/data/sys_http_delivery?filter=${encodeURIComponent(JSON.stringify({ ref_id: DECLARED_REF }))}`); + expect(res.status, await res.clone().text()).toBe(200); + const body = (await res.json()) as any; + const rows = (body?.data?.records ?? body?.records ?? body?.data ?? []) as any[]; + return rows.map((r) => r.organization_id); + }; + expect(await read(tokA)).toEqual([orgA]); + expect(await read(tokB)).toEqual([orgB]); + }); + }); +} diff --git a/packages/services/service-automation/src/walled-organization-less-bridge.test.ts b/packages/services/service-automation/src/walled-organization-less-bridge.test.ts new file mode 100644 index 00000000000..ef799d2bbcd --- /dev/null +++ b/packages/services/service-automation/src/walled-organization-less-bridge.test.ts @@ -0,0 +1,121 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 C8 stage 8b, BRIDGE] `sys_secret` and `sys_flow_credential` keep + * their organization-less system inserts under a walled posture until the + * steps 5+6 landing flips both tables column-less (epic ruling record + * 6270035914 on #15194, item 2; #24582). `WALLED_ORGANIZATION_LESS_BRIDGE` in + * `@objectstack/objectql` is that bridge. + * + * Two things are pinned here: + * + * 1. **The tripwire.** The bridge names exactly those two tables, and each + * still carries its tenant column. The day either declares + * `systemFields: { tenant: false }` this goes red, and the remedy is to + * DELETE the bridge (the list, its reader in + * `Engine.resolveSystemInsertOrganization`) and this tripwire with it, in + * the PR that lands the flip. ⛔ Never extend the list to green it. + * 2. **The flow-credential writer under a wall**, on a real engine over + * SQLite: saving a flow that carries a credential writes its + * `sys_flow_credential` row and, through the engine's secret field, its + * `sys_secret` ciphertext — both organization-less, both landing under + * `isolated` and `group`. Without the bridge both are refused with + * `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED`. + */ +import { afterEach, describe, expect, it } from 'vitest'; +import { ObjectQL, WALLED_ORGANIZATION_LESS_BRIDGE } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { SysSecret } from '@objectstack/platform-objects'; +import type { CryptoContext, CryptoHandle, ICryptoProvider } from '@objectstack/spec/contracts'; + +import { FLOW_CREDENTIAL_OBJECT, FlowCredentialChannel } from './flow-credential-channel.js'; +import { SysFlowCredential } from './sys-flow-credential.object.js'; + +const SYSTEM = { isSystem: true } as const; +const HOOK = 'pin-bridge-hook-41d7'; + +/** Reversible stand-in cipher — the seam's behaviour is the engine's, not the cipher's. */ +function fakeCrypto(): ICryptoProvider { + let n = 0; + return { + async encrypt(plain: string, _ctx: CryptoContext): Promise { + n += 1; + return { id: `sec_${n}`, kmsKeyId: 'test', alg: 'test-rev', version: 1, ciphertext: [...plain].reverse().join('') }; + }, + async decrypt(handle: CryptoHandle): Promise { + return [...handle.ciphertext].reverse().join(''); + }, + async rotateKey(handle: CryptoHandle): Promise { + return { ...handle, version: handle.version + 1 }; + }, + digest: (plain: string) => `d:${plain.length}`, + keyedDigest: async (plain: string) => `k:${plain.length}`, + }; +} + +const engines: ObjectQL[] = []; +afterEach(async () => { + for (const e of engines.splice(0)) { + try { await (e as any).destroy?.(); } catch { /* noop */ } + } +}); + +async function boot(posture: 'isolated' | 'group'): Promise { + const ql = new ObjectQL(); + engines.push(ql); + const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); + await driver.connect(); + ql.registerDriver(driver, true); + await ql.init(); + ql.setTenancyPostureProvider(() => posture); + (ql as any).registry.registerObject(SysSecret as never, 'test'); + (ql as any).registry.registerObject(SysFlowCredential as never, 'test'); + await ql.syncSchemas(); + ql.setCryptoProvider(fakeCrypto()); + return ql; +} + +/** An inbound flow whose start node carries one credential. */ +function inbound() { + return { + name: 'pin_bridge', + label: 'Pin bridge', + type: 'api', + nodes: [ + { id: 'begin', type: 'start', label: 'Start', config: { triggerType: 'api', hookId: 'h1', secret: HOOK } }, + { id: 'finish', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'begin', target: 'finish' }], + }; +} + +describe('[C8 stage 8b] the walled organization-less bridge — the tripwire', () => { + it('names exactly sys_secret and sys_flow_credential, and each still carries its tenant column', () => { + expect([...WALLED_ORGANIZATION_LESS_BRIDGE].sort()).toEqual(['sys_flow_credential', 'sys_secret']); + for (const object of [SysSecret, SysFlowCredential] as any[]) { + expect( + object.systemFields?.tenant, + `${object.name} now declares systemFields.tenant: false — the steps 5+6 flip has landed, so DELETE ` + + 'WALLED_ORGANIZATION_LESS_BRIDGE, its reader in Engine.resolveSystemInsertOrganization and this tripwire', + ).not.toBe(false); + expect(object.tenancy?.enabled, `${object.name} declares tenancy.enabled: false`).not.toBe(false); + } + }); +}); + +describe('[C8 stage 8b] the walled organization-less bridge — saving a flow credential', () => { + it.each(['isolated', 'group'] as const)( + '%s: the organization-less sys_flow_credential row and its sys_secret ciphertext land', + async (posture) => { + const ql = await boot(posture); + await new FlowCredentialChannel(() => ql as never).store({ name: 'pin_bridge', state: 'active', body: inbound() }); + + const credentials = (await ql.find(FLOW_CREDENTIAL_OBJECT, { context: SYSTEM } as never)) as any[]; + expect(credentials).toHaveLength(1); + expect(credentials[0].organization_id ?? null).toBeNull(); + const secrets = (await ql.find('sys_secret', { context: SYSTEM } as never)) as any[]; + expect(secrets.length).toBeGreaterThan(0); + for (const row of secrets) expect(row.organization_id ?? null).toBeNull(); + }, + ); +}); diff --git a/packages/services/service-datasource/package.json b/packages/services/service-datasource/package.json index bae94f24a07..9be7e62be9e 100644 --- a/packages/services/service-datasource/package.json +++ b/packages/services/service-datasource/package.json @@ -63,6 +63,8 @@ "devDependencies": { "@objectstack/driver-mongodb": "workspace:*", "@objectstack/driver-sqlite-wasm": "workspace:*", + "@objectstack/objectql": "workspace:*", + "@objectstack/platform-objects": "workspace:*", "@objectstack/plugin-hono-server": "workspace:*", "@types/node": "^26.6.3", "tsup": "^8.5.1", diff --git a/packages/services/service-datasource/src/datasource-secret-binder.walled.test.ts b/packages/services/service-datasource/src/datasource-secret-binder.walled.test.ts new file mode 100644 index 00000000000..07d6c65aaf2 --- /dev/null +++ b/packages/services/service-datasource/src/datasource-secret-binder.walled.test.ts @@ -0,0 +1,79 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 C8 stage 8b, BRIDGE] Binding a datasource credential under a + * walled posture, through a REAL ObjectQL engine over SQLite. + * + * The binder persists the ciphertext as a `sys_secret` row with the system + * context and no organization: a datasource is deployment-level. `sys_secret` + * keeps its organization column until the steps 5+6 landing flips it (epic + * ruling record 6270035914 on #15194, item 2), so under `group` / `isolated` + * that organization-less system insert must keep landing: the walled + * organization-less bridge in `Engine.resolveSystemInsertOrganization` holds + * it. Without the bridge the admin door's credential bind is a 500 + * `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED`, and nothing is written. + */ + +import { afterEach, describe, expect, it } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { SysSecret } from '@objectstack/platform-objects/system'; +import type { CryptoContext, CryptoHandle, ICryptoProvider } from '@objectstack/spec/contracts'; +import { createDatasourceSecretBinder } from './datasource-secret-binder.js'; + +/** Reversible stand-in cipher — the subject is the write, not the cipher. */ +function fakeCrypto(): ICryptoProvider { + let n = 0; + return { + async encrypt(plain: string, _ctx: CryptoContext): Promise { + n += 1; + return { id: `sec_${n}`, kmsKeyId: 'test', alg: 'test-rev', version: 1, ciphertext: [...plain].reverse().join('') }; + }, + async decrypt(handle: CryptoHandle): Promise { + return [...handle.ciphertext].reverse().join(''); + }, + async rotateKey(handle: CryptoHandle): Promise { + return { ...handle, version: handle.version + 1 }; + }, + digest: (plain: string) => `d:${plain.length}`, + keyedDigest: async (plain: string) => `k:${plain.length}`, + }; +} + +const engines: ObjectQL[] = []; +afterEach(async () => { + for (const e of engines.splice(0)) { + try { await (e as any).destroy?.(); } catch { /* noop */ } + } +}); + +async function boot(posture: 'isolated' | 'group'): Promise { + const ql = new ObjectQL(); + engines.push(ql); + const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); + await driver.connect(); + ql.registerDriver(driver, true); + await ql.init(); + ql.setTenancyPostureProvider(() => posture); + (ql as any).registry.registerObject(SysSecret as never, 'test'); + await ql.syncSchemas(); + return ql; +} + +describe('[C8 stage 8b] a datasource credential bind under a wall, on a real engine', () => { + it.each(['isolated', 'group'] as const)( + '%s: the organization-less sys_secret row lands, and the credential resolves back', + async (posture) => { + const ql = await boot(posture); + const binder = createDatasourceSecretBinder({ engine: ql as never, cryptoProvider: fakeCrypto() }); + + const ref = await binder.bind({ value: 'pin-ds-password-9c2e' }, { name: 'warehouse' }); + + const rows = (await ql.find('sys_secret', { context: { isSystem: true } } as never)) as any[]; + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ namespace: 'datasource', key: 'warehouse' }); + expect(rows[0].organization_id ?? null).toBeNull(); + expect(await binder.resolve(ref)).toBe('pin-ds-password-9c2e'); + }, + ); +}); diff --git a/packages/services/service-datasource/vitest.config.ts b/packages/services/service-datasource/vitest.config.ts index 04ed6c0e9ff..4a17cccf261 100644 --- a/packages/services/service-datasource/vitest.config.ts +++ b/packages/services/service-datasource/vitest.config.ts @@ -52,9 +52,19 @@ export default defineConfig({ // whatever the last build of that predicate emitted rather than to the // predicate sitting next to it — a green pin over a stale contract, which is // the one failure a contract pin exists to make impossible. + // `@objectstack/objectql` and `@objectstack/platform-objects/system` became + // TEST-layer imports with the walled credential-bind pin + // (`datasource-secret-binder.walled.test.ts`), which runs the binder over a + // real engine and the real `sys_secret` object. Same reasoning again: + // unaliased, that pin would judge the engine's last build rather than the + // walled bridge sitting in its source. `platform-objects` gets the explicit + // subpath entry its non-uniform export map needs (the shape + // `service-settings` uses). alias: [ { find: /^@objectstack\/core$/, replacement: path.resolve(__dirname, '../../core/src/index.ts') }, { find: /^@objectstack\/metadata-core$/, replacement: path.resolve(__dirname, '../../metadata-core/src/index.ts') }, + { find: /^@objectstack\/objectql$/, replacement: path.resolve(__dirname, '../../objectql/src/index.ts') }, + { find: /^@objectstack\/platform-objects\/system$/, replacement: path.resolve(__dirname, '../../platform-objects/src/system/index.ts') }, ], }, }); diff --git a/packages/services/service-settings/src/settings-global-secret-walled.test.ts b/packages/services/service-settings/src/settings-global-secret-walled.test.ts new file mode 100644 index 00000000000..efdaa92a0ea --- /dev/null +++ b/packages/services/service-settings/src/settings-global-secret-walled.test.ts @@ -0,0 +1,119 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 C8 stage 8b, BRIDGE] A global-scope encrypted setting under a + * walled posture, through a REAL ObjectQL engine. + * + * A global value is deployment-level, so its `sys_secret` ciphertext names no + * organization (`settings-secret-organization.test.ts` pins the row shape, + * over a fake engine). `sys_secret` keeps its organization column until the + * steps 5+6 landing flips it (epic ruling record 6270035914 on #15194, item + * 2), so under `group` / `isolated` that organization-less system insert must + * keep landing: the walled organization-less bridge in + * `Engine.resolveSystemInsertOrganization` holds it. Without the bridge the + * operator's global encrypted setting is a 500 + * `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED`, and nothing is written. + * + * The writer is the production pair: `SettingsService` building the row and + * the plugin's own secret store (`SettingsServicePlugin` → `buildSecretStore`) + * inserting it with `{ context: { isSystem: true } }`. + */ + +import { randomBytes } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SysSecret } from '@objectstack/platform-objects/system'; +import { SettingsService } from './settings-service.js'; +import { SettingsServicePlugin } from './settings-service-plugin.js'; +import { LocalCryptoProvider } from './local-crypto-provider.js'; +import { mailSettingsManifest } from './manifests/mail.manifest.js'; +import type { SettingsSecretStore } from './settings-service.types.js'; + +/** A driver over plain Maps — enough of `IDataDriver` for a secret insert and its read. */ +function makeMemoryDriver() { + const rows = new Map>>(); + const rowsOf = (object: string) => { + let s = rows.get(object); + if (!s) { s = new Map(); rows.set(object, s); } + return s; + }; + const matches = (row: Record, where: any): boolean => { + if (!where || typeof where !== 'object') return true; + return Object.entries(where).every(([k, v]) => { + if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); + return (row[k] ?? null) === (v ?? null); + }); + }; + const driver: any = { + name: 'memory', version: '0.0.0', supports: {} as any, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, + async execute() { return null; }, + async find(object: string, ast: any) { + const found = [...rowsOf(object).values()].filter((r) => matches(r, ast?.where)); + const page = typeof ast?.limit === 'number' ? found.slice(0, ast.limit) : found; + return page.map((r) => ({ ...r })); + }, + async findOne(object: string, ast: any) { + for (const r of rowsOf(object).values()) if (matches(r, ast?.where)) return { ...r }; + return null; + }, + async create(object: string, data: Record) { + const row = { ...data, id: (data.id as string) ?? `row_${rowsOf(object).size + 1}` }; + rowsOf(object).set(row.id as string, row); + return { ...row }; + }, + async update(object: string, id: string, data: Record) { + const cur = rowsOf(object).get(id); + if (!cur) return null; + const next = { ...cur, ...data, id }; + rowsOf(object).set(id, next); + return { ...next }; + }, + async delete(object: string, id: string) { return rowsOf(object).delete(id); }, + async count(object: string, ast: any) { + return [...rowsOf(object).values()].filter((r) => matches(r, ast?.where)).length; + }, + async bulkCreate(object: string, list: Record[]) { + return Promise.all(list.map((r) => this.create(object, r))); + }, + async bulkUpdate() { return []; }, + async bulkDelete() {}, + async syncSchema() {}, async dropTable() {}, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; + return { driver, rowsOf }; +} + +async function boot(posture: 'isolated' | 'group') { + const engine = new ObjectQL(); + const { driver, rowsOf } = makeMemoryDriver(); + engine.registerDriver(driver, true); + await engine.init(); + engine.setTenancyPostureProvider(() => posture); + engine.registry.registerObject(SysSecret as any, 'test'); + const secretStore = (new SettingsServicePlugin() as any).buildSecretStore(engine) as SettingsSecretStore; + const svc = new SettingsService({ + env: {}, + cryptoProvider: new LocalCryptoProvider({ key: randomBytes(32) }), + secretStore, + }); + svc.registerManifest(mailSettingsManifest); + return { svc, secrets: () => [...rowsOf('sys_secret').values()] }; +} + +describe('[C8 stage 8b] a global encrypted setting under a wall, on a real engine', () => { + it.each(['isolated', 'group'] as const)( + '%s: the platform operator sets one, and its organization-less sys_secret row lands', + async (posture) => { + const { svc, secrets } = await boot(posture); + await svc.setMany('mail', { provider: 'resend', api_key: 're_bridge_pin', from_email: 'ops@example.com' }, { userId: 'usr-op' }); + + const stored = secrets(); + expect(stored).toHaveLength(1); + expect(stored[0]).toMatchObject({ namespace: 'mail', key: 'api_key' }); + expect(stored[0].organization_id ?? null).toBeNull(); + expect((await svc.get('mail', 'api_key', {}))?.value).toBe('re_bridge_pin'); + }, + ); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b1520a40846..cdaa6b00f09 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2662,6 +2662,12 @@ importers: '@objectstack/driver-sqlite-wasm': specifier: workspace:* version: link:../../drivers/driver-sqlite-wasm + '@objectstack/objectql': + specifier: workspace:* + version: link:../../objectql + '@objectstack/platform-objects': + specifier: workspace:* + version: link:../../platform-objects '@objectstack/plugin-hono-server': specifier: workspace:* version: link:../../plugins/plugin-hono-server