diff --git a/.changeset/config.json b/.changeset/config.json index e88c868b77..bd5e166f97 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -41,6 +41,7 @@ "@objectstack/plugin-dev", "@objectstack/plugin-email", "@objectstack/plugin-hono-server", + "@objectstack/organizations", "@objectstack/mcp", "@objectstack/plugin-pinyin-search", "@objectstack/plugin-reports", diff --git a/.changeset/open-core-multi-organization-runtime.md b/.changeset/open-core-multi-organization-runtime.md new file mode 100644 index 0000000000..4718a4d61e --- /dev/null +++ b/.changeset/open-core-multi-organization-runtime.md @@ -0,0 +1,37 @@ +--- +'@objectstack/organizations': minor +'@objectstack/plugin-security': patch +'@objectstack/service-cluster': patch +'@objectstack/spec': patch +--- + +Ship the multi-organization runtime as open source: `@objectstack/organizations` is now an +Apache-2.0 package in this repository (ADR-0132). + +Single-database, row-level organization isolation was already open — the tenant Layer 0 wall, +the three tenancy postures, the organization and invitation objects, better-auth's organization +plugin, and the `requiresService: 'org-scoping'` Setup gates. What was closed was the one +registrar of the `org-scoping` service, so an install that set `OS_TENANCY_POSTURE=isolated` +could not enforce it: `serve` refused the boot, and the only way past was +`OS_ALLOW_DEGRADED_TENANCY=1` — the wall configured but not enforced. This package is that +missing registrar. + +It provides: + +- **`organization_id` auto-stamp on insert**, from the caller's active organization. A supplied + — possibly forged — value is overwritten, never trusted. +- **Per-organization seed replay** on `sys_organization` insert, from the app's own seed + definitions. Never another organization's rows. +- **Default-organization bootstrap** for the platform admin, idempotent. +- **The walled-posture membership-policy gate**: a deployment that raises the wall must declare + what a new user joins, or the boot is refused. + +Only the commercial **entitlement** stays closed. The open class carries no licence check of any +kind and offers no hook for one; an enterprise deployment resolves the same package name to a +private, licence-gated subclass through its own `workspace:*` declaration, so which class is +mounted is decided by the manifest that declares the name. + +⚠️ Shipping the registrar is not yet the same as an open install raising the wall: `objectstack +serve` still resolves the runtime from the served app's own declaration and is not yet wired to +mount this package off `OS_TENANCY_POSTURE`. That, and the isolation matrix run against a real +registrar rather than a posture stub, are tracked separately. diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index d6dbd2a5f7..0700ebf5e1 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -9,8 +9,8 @@ the seed loader replaying package fixtures, a plugin's boot reconciler, a service self-write, a migration. This page is **the authority** for what that flag actually does. It exists -because the flag is not one concept: it is a single boolean read at **105 -distinct sites across 19 packages**, and knowing three of those behaviours gives +because the flag is not one concept: it is a single boolean read at **106 +distinct sites across 20 packages**, and knowing three of those behaviours gives no hint that the other hundred-and-three exist. Every documented app-side bug traced to `isSystem` had the same shape — the metadata was complete and correct, and the gap was observable only by querying the resulting rows. @@ -124,7 +124,7 @@ that silently does not happen. ### 3. Sharing (`plugin-sharing`) -The largest single consumer — **17 of the 105 sites**. +The largest single consumer — **17 of the 106 sites**. | # | Behaviour when `isSystem` | What you get / what you lose | Anchor | |:--|:---|:---|:---| @@ -152,7 +152,7 @@ The largest single consumer — **17 of the 105 sites**. | 46 | Comment access hooks return early (insert + update + delete, and the read AST) | plugin-audit | Lose: comment visibility scoping | `comment-access-hooks.ts:322`, `:449`, `:488`, `:540` | | 47 | Knowledge search returns hits unfiltered | service-knowledge | Lose: the permission filter over search results | `service-knowledge/src/knowledge-service.ts:316` | -### 5. Actions, metadata plane, provenance +### 5. Actions, metadata plane, provenance, the organization wall | # | Behaviour when `isSystem` | Package | What you get / what you lose | Anchor | |:--|:---|:---|:---|:---| @@ -170,6 +170,7 @@ The largest single consumer — **17 of the 105 sites**. | 59 | Email-template / webhook provenance stamps skipped | plugin-email, plugin-webhooks | Lose: the row is not marked as an admin customization | `email-template-provenance.ts:77`, `webhook-provenance.ts:68` | | 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 | `runtime-identity.ts:279`, called from `builtin/crud-nodes.ts:319` | | 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 | `inbox-caller.ts:148` | +| 62 | **`organization_id` is not auto-stamped on INSERT** — the organization-axis twin of the `owner_id` gap above | organizations | Get: an elevated write may name another organization deliberately, which is what the per-organization seed replay, the orphan-row claim, imports and migrations all rely on. Lose: the authoritative stamp, so an elevated insert that names no organization lands `organization_id = NULL` and the wall hides it. ⛔ This is why a forged `organization_id` is overwritten on the non-elevated path and not here: elevation is the seam the legitimate cross-organization writers use | `organizations-plugin.ts:302` | ### 6. Reads that only carry the flag onward @@ -179,10 +180,10 @@ a reader tracing where elevation travels needs them. | # | Site | Package | What it does | |:--|:---|:---|:---| -| 62 | `objectql/src/engine.ts:3737` | objectql | Propagates `isSystem` into the hook session so hooks can tell engine self-writes from user writes | -| 63 | `objectql/src/engine.ts:15016` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag | -| 64 | `plugin-reports/src/report-service.ts:556` | plugin-reports | Threads the flag into the engine call that runs a report | -| 65 | `body-runner.ts:279` | runtime | Rebuilds an `ExecutionContext` from a hook session, carrying the flag across | +| 63 | `objectql/src/engine.ts:3737` | objectql | Propagates `isSystem` into the hook session so hooks can tell engine self-writes from user writes | +| 64 | `objectql/src/engine.ts:15016` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag | +| 65 | `plugin-reports/src/report-service.ts:556` | plugin-reports | Threads the flag into the engine call that runs a report | +| 66 | `body-runner.ts:279` | runtime | Rebuilds an `ExecutionContext` from a hook session, carrying the flag across | --- @@ -269,8 +270,8 @@ Ownership injection, `readonly` bypass and sharing materialisation are independent decisions, and a seed loader plausibly wants the first two but not the third. The concept is nevertheless **staying as one boolean**: -- **Shipped semantics.** `isSystem` is a published contract with 105 read sites - in 19 packages. Splitting it is a breaking contract change across all of them. +- **Shipped semantics.** `isSystem` is a published contract with 106 read sites + in 20 packages. Splitting it is a breaking contract change across all of them. (The ruling was taken when the census read 80 sites in 18 packages; the count has grown, which strengthens rather than weakens the argument.) - **No business pull.** No app has asked for the combinations a split would @@ -326,15 +327,15 @@ still holds equal to the census on every pull request: | Appearances of the bare identifier `isSystem` in non-test sources | 813 | — | | — parsed as a declaration | 22 | ✅ | | — parsed as an object-literal / type key (producers and option objects) | 310 | — | -| — parsed as a property **read** | 111 | ✅ | +| — parsed as a property **read** | 112 | ✅ | | — parsed in some other syntactic position (a local, a cast, a conditional) | 9 | ✅ | | — the remainder: text inside comments and string literals | 358 | — | | Of those reads: reads of one of the unrelated metadata fields | 6 | ✅ | -| Of those reads: reads of `ExecutionContext.isSystem` | **105** | ✅ | -| — behaviour-bearing (rows 1–61 above) | 101 | ✅ | -| — carry the flag onward only (rows 62–65 above) | 4 | ✅ | -| Packages containing at least one elevation read | **19** | ✅ | -| Files containing at least one elevation read | 44 | ✅ | +| Of those reads: reads of `ExecutionContext.isSystem` | **106** | ✅ | +| — behaviour-bearing (rows 1–62 above) | 102 | ✅ | +| — carry the flag onward only (rows 63–66 above) | 4 | ✅ | +| Packages containing at least one elevation read | **20** | ✅ | +| Files containing at least one elevation read | 45 | ✅ | The six rows marked — are a **dated decomposition, not a live claim**: they were measured on 2026-08-29 at `ca1965f2b5` and CI does not re-derive them. They count diff --git a/docs/adr/0105-group-tenancy-posture-and-first-class-org-scope.md b/docs/adr/0105-group-tenancy-posture-and-first-class-org-scope.md index 99f2511923..ed08842dad 100644 --- a/docs/adr/0105-group-tenancy-posture-and-first-class-org-scope.md +++ b/docs/adr/0105-group-tenancy-posture-and-first-class-org-scope.md @@ -1,6 +1,6 @@ # ADR-0105: Group Tenancy Posture — Organization Scope as a First-Class Authorization Dimension -**Status**: Accepted (2026-07-27; proposed 2026-07-25) — Phase 0/1 implemented (#3559). Amended 2026-07-27: **D12 correction** — `group` posture activation is entitled, not open (#3570; see the D12 Amendment). Phase 2 **D8** and **D9** implemented 2026-07-28 — D8: #3645 (host seam) → #3663 (placement engine) → #3674 (`/security/my-delegable-scope`) → #3695 (issuer-grant resolution) → #3722 (`delegated_admin` + invitation role cap, #3697) → #3767 (`sys_member` governed), console objectui#2868/#2891, e2e cloud#886; the membership-role channel D8's placement replaces is closed by [ADR-0108](./0108-membership-grade-is-not-a-capability-channel.md). D9: #3824 + #3873 (see the D9 amendment below). D10 **withdrawn** 2026-09-04 by maintainer ruling (「不考虑集团级模板行,作废相关需求」「不考虑 分层主数据」; recorded in [ADR-0131](./0131-total-organization-ownership-no-null-organization-id.md) D12 — see the note under D10); D13 not started +**Status**: Accepted (2026-07-27; proposed 2026-07-25) — Phase 0/1 implemented (#3559). Amended 2026-07-27: **D12 correction** — `group` posture activation is entitled, not open (#3570; see the D12 Amendment). Phase 2 **D8** and **D9** implemented 2026-07-28 — D8: #3645 (host seam) → #3663 (placement engine) → #3674 (`/security/my-delegable-scope`) → #3695 (issuer-grant resolution) → #3722 (`delegated_admin` + invitation role cap, #3697) → #3767 (`sys_member` governed), console objectui#2868/#2891, e2e cloud#886; the membership-role channel D8's placement replaces is closed by [ADR-0108](./0108-membership-grade-is-not-a-capability-channel.md). D9: #3824 + #3873 (see the D9 amendment below). **D12 amended 2026-09-06** by [ADR-0132](./0132-multi-organization-runtime-is-open-core.md) — the multi-org runtime moves to open core; the entitlement stays commercial. D10 **withdrawn** 2026-09-04 by maintainer ruling (「不考虑集团级模板行,作废相关需求」「不考虑 分层主数据」; recorded in [ADR-0131](./0131-total-organization-ownership-no-null-organization-id.md) D12 — see the note under D10); D13 not started **Deciders**: ObjectStack Protocol Architects **Builds on**: [ADR-0049](./0049-no-unenforced-security-properties.md) (enforce-or-remove), [ADR-0057](./0057-erp-authorization-core-business-units-and-scope-depth.md) (business units + scope depth), [ADR-0066](./0066-unified-authorization-model.md) (unified authz, superuser bypass), [ADR-0086](./0086-authz-metadata-config-boundary-and-cross-package-composition.md), [ADR-0090](./0090-permission-model-v2-concept-convergence.md) (permission set / position / business unit), [ADR-0091](./0091-grant-lifecycle-and-recertification.md) (validity windows), [ADR-0092](./0092-sys-user-profile-field-delegation.md) (identity write guard + field whitelist), [ADR-0093](./0093-tenancy-mode-and-membership-lifecycle.md) (tenancy service), [ADR-0095](./0095-authz-kernel-tenant-layer-and-posture-ladder.md) (tenant Layer 0, posture ladder), [ADR-0103](./0103-managedby-write-policy-and-engine-write-guard.md); cloud ADR-0016 (open/paid boundary: 强制免费、治理收费), cloud ADR-0081 (`@objectstack/organizations`) **Tracking**: #3541 (P0 findings F1/F2 became #3539/#3540, closed by #3559); cloud-side tracking cloud #874 @@ -324,7 +324,10 @@ reserves the concept and its place in Phase 2. **D12 — Edition split, per the cloud ADR-0016 iron rule (强制免费、治理收费).** *(As amended 2026-07-27, #3570 — see the Amendment below for the original -text and why it was wrong.)* The split is **code vs. activation**, not code +text and why it was wrong; and again 2026-09-06 by +[ADR-0132](./0132-multi-organization-runtime-is-open-core.md), which moves the +multi-org RUNTIME to open core and leaves only the entitlement commercial — see +the ADR-0132 Amendment at the end of this section.)* The split is **code vs. activation**, not code vs. code. The wall's *implementation* ships open — D3/D4 correctness, the Layer 0 predicates, D5 stamping/validation, `accessible_org_ids` resolution, the D6 red-line lints — exactly as `isolated`'s wall has always lived in @@ -342,6 +345,29 @@ org lifecycle management, grouping/registry UI, scoped invitations UX, cross-org approval templates, master-data distribution management, per-org seed/config replay, org analytics, and the D13 promotion tooling. +> **Amendment (2026-09-06, [ADR-0132](./0132-multi-organization-runtime-is-open-core.md)).** +> The *split itself* stands: it is still code vs. activation, and enabling +> multi-organization operation is still an entitlement on the commercial side. +> What ADR-0132 changes is **where the code lives**. The multi-org runtime +> `@objectstack/organizations` now ships from this repository under Apache-2.0; +> the commercial repository keeps a private package of the same name whose class +> subclasses the open one and calls its licence gate in its own constructor, and +> every commercial host resolves that name through a `workspace:*` declaration +> that can only reach the local package. So this section's sentences about the +> runtime being closed-source read as history: the paragraph below still +> describes how activation is gated, on the commercial side, and no longer +> describes who may read the source. +> +> Two consequences for this section specifically. **The `supportedPostures` +> declaration**: D12's argument that "which shapes of multi-org" is a packaging +> decision belonging in the commercial runtime does not carry to the open +> package, which entitles both walled postures by construction (ADR-0132 D4) — +> the commercial runtime may still narrow what it entitles. **The +> boot refusal**: ADR-0093 D5 still refuses a walled posture with no runtime +> present, but "absent" stops being the normal state for an open install once +> #16137 wires `serve` to the open registrar. ⛔ ADR-0132 does not itself +> deliver that; it ships the registrar. + > **Citation note (2026-08-16) — hygiene, not a decision.** Code and tests > carried this entitlement as **"ADR-0081 D2"**, a label inherited from a > decision record that predates this repo's ADR series — the same pre-repo diff --git a/docs/adr/0132-multi-organization-runtime-is-open-core.md b/docs/adr/0132-multi-organization-runtime-is-open-core.md new file mode 100644 index 0000000000..f4d4a206f5 --- /dev/null +++ b/docs/adr/0132-multi-organization-runtime-is-open-core.md @@ -0,0 +1,225 @@ +# ADR-0132: The multi-organization runtime is open core — single-database organization isolation ships open; only the entitlement stays commercial + +- **Status**: Proposed (2026-09-06) — awaiting the maintainer's hand-merge, which is itself the + acceptance act for a governed surface (Prime Directive #14). ⛔ Nothing below is settled until + this record merges. +- **Deciders**: ObjectStack maintainer, 2026-09-06, live chat, verbatim and untranslated: the + question that opened it 「感觉 单库多组织隔离是开源基本需求,如果迁移回开源项目成本有多大」, the + instruction that chartered the work 「直接立专题卡派发处理吧」, and the statement of the effect + the migration is measured against 「迁移之后的效果应该是开源版就可以把元数据应用使用单库多租户的方式运行。」 +- **Reverses**: cloud ADR-0081 **D2** (the multi-organization machinery closes into an enterprise + package). D1, D3 and D4 of that record are untouched — see + [What this record does not decide](#what-this-record-does-not-decide). +- **Amends**: [ADR-0105](./0105-group-tenancy-posture-and-first-class-org-scope.md) **D12** — the + edition split's *code vs. activation* line is unchanged, but D12's argument for putting the + `supportedPostures` declaration in the commercial runtime no longer applies to the open package. +- **Builds on**: [ADR-0093](./0093-tenancy-mode-and-membership-lifecycle.md) D5 (degraded tenancy + fails fast — the refusal an open install meets today), + [ADR-0095](./0095-authz-kernel-tenant-layer-and-posture-ladder.md) D1 (the tenant Layer 0 that is + already open), [ADR-0049](./0049-no-unenforced-security-properties.md) (enforce or remove — the + posture this record applies to a wall that can be configured but not enforced), + [ADR-0131](./0131-total-organization-ownership-no-null-organization-id.md) D9 (no silent NULL + organization stamping) +- **Evidence**: [#16130](https://github.com/objectstack-ai/objectstack/issues/16130) (the card, its + phase-1 line-level classification of all 1660 lines, and the PM's binding answers) +- **Discharged elsewhere**: [#16137](https://github.com/objectstack-ai/objectstack/issues/16137) — + an open-only install actually raising the wall. ⛔ This record does not claim it. + +--- + +## Provenance — read this before citing this file + +The decision this file reverses was taken in the sibling `objectstack-ai/cloud` +repository, as **cloud ADR-0081** (Accepted, founder-decided in session, +2026-07-09), whose **D2** put the multi-organization machinery into a +closed-source enterprise package. That record governs a commercial packaging +choice, so it lives there and is cited from here as `cloud ADR-0081` — never as +a bare number, which resolves against *this* repository's ADR-0081 (the trusted +`kind:'react'` page tier, an unrelated document). + +This file is the mechanism half, in the repository whose code it now governs, +per Prime Directive #13. The commercial half — what an enterprise subscription +buys, and the licence gate that answers for it — stays in cloud and is **not** +restated here. The cloud record needs a one-line pointer back to this number; +that edit belongs to the commercial repository and is not made by the PR that +lands this file. + +⚠️ Where this document and the cloud record disagree about what the *commercial* +boundary is, the cloud record decides. What this document decides, and cloud +does not, is where the **code** lives. + +--- + +## Context — the wall was already open; only the switch was closed + +Every part of single-database organization isolation was already Apache-2.0 in +this repository, and had been through three cross-organization repairs in the +week before this record was written: + +| piece | where | +|---|---| +| the wall itself | open — `plugin-security`'s tenant Layer 0, and the three postures in `packages/spec` | +| the posture knob | open — `resolveTenancyPosture()` in `packages/types` | +| organizations and invitations as objects | open — `platform-objects`' identity surface | +| organization CRUD, membership, invitations | open — better-auth's organization plugin, mounted in `plugin-auth` (cloud ADR-0081 D1) | +| the Setup surface for it | open — the `requiresService: 'org-scoping'` navigation gates | +| **the `org-scoping` registrar** | ⛔ closed — the one missing piece | + +The consequence was precise and bad. An open-source install that set +`OS_TENANCY_POSTURE=isolated` **could not enforce it**. `serve` treats every +posture but `single` as multi-tenant, finds no `org-scoping` runtime, and +refuses the boot (ADR-0093 D5) — correctly. The only route past that refusal was +`OS_ALLOW_DEGRADED_TENANCY=1`, which boots with the wall *configured but not +enforced*: exactly the shape ADR-0049 refuses in general and ADR-0131 D9 refuses +for this surface in particular. + +So the open edition did not offer a weaker wall. It offered a wall that could be +asked for and never raised — and the honest reading of ADR-0016's iron rule +(强制免费、治理收费, "enforcement is free, governance is paid") is that a wall is +enforcement. + +### The move was measured before it was made + +Phase 1 of #16130 classified all 1660 lines of the closed package line by line, +read through `git show origin/main:PATH` in both repositories rather than a +working tree. 1300 lines move (1265 as-is, 35 changed), 342 stay, 18 are +deleted. Coupling to the commercial repository was exactly two import sites, +both entitlement. None of the four stop conditions the card set — a third +coupling point, an inseparable membership gate, any need for +`security-enterprise` — fired. + +--- + +## Decision + +### D1 — The multi-organization runtime is open core + +`@objectstack/organizations` ships from this repository, Apache-2.0, as +`packages/plugins/organizations`. It registers the `org-scoping` service, the +`organization_id` insert auto-stamp, the per-organization seed replay, the +default-organization bootstrap and the walled-posture membership-policy gate. + +This reverses cloud ADR-0081 D2's placement of that machinery and nothing else +about that record. + +### D2 — The entitlement, and only the entitlement, stays commercial + +The commercial repository keeps its licence gate and the entitlement it answers +for. The open class carries **no licence check of any kind**: no gate call, no +constructor option, no callback, no hook, no protected method that exists to be +overridden for gating, and no way to detect which edition it is running under. + +The commercial package keeps construction-time refusal by **subclassing**: its +own `OrganizationsPlugin extends` the open class and calls its gate in its own +constructor. Cloud code, cloud gate. This preserves the requirement cloud#1020 +records — that the gate be answered by the package that implements multi-org, at +construction — with no seam on the open side, and it leaves the two existing +`new X.OrganizationsPlugin()` call sites and `serve`'s two-stage classifier +(which keys on *which stage threw*, not on the error's shape) working unchanged. + +### D3 — One name, two packages; the declaring manifest decides which + +Both packages are named `@objectstack/organizations`. That is the mechanism, not +a collision to repair: + +- every commercial host declares `"@objectstack/organizations": "workspace:*"`, + and pnpm's `workspace:` protocol resolves **only** to the local workspace + package — it cannot fall through to the registry, and a missing one fails the + install rather than substituting silently; +- an open install declares the same name from npm and gets the open package; +- `objectstack serve` reaches it through the host-anchored importer, which + refuses a package the served app has not declared at all (#4719), so the + resolution base is always the served app's own manifest. + +Taking the name the loader already spells is what makes this migration require +**no loader change**: `ORGANIZATIONS_RUNTIME_PKG` and every pin over it are +untouched. + +⛔ **The one thing that would break D3**, and it is therefore forbidden: a +framework package taking `@objectstack/organizations` as its own dependency. +The commercial repository consumes the framework by `link:`, so such a +dependency would place the ungated package inside the framework tree a +commercial app links against, reachable by a bare import that never consults the +app's manifest — the entitlement bypassed by resolution rather than by any +defect in the gate. Apps declare this package; packages do not. A pin in the +package holds it. + +### D4 — The open package entitles both walled postures, by construction + +ADR-0105 D12 put the `supportedPostures` declaration in the commercial runtime +on the argument that "which shapes of multi-org" is a *packaging* decision open +core should not hard-code. For the open package there is no packaging decision +left to make: an installation that has the package has the wall, in both of the +shapes the wall comes in. `['group', 'isolated']` is the open runtime's own +constant, not a tier. + +⛔ And it is not a place a tier may later be drawn. The declaration in the closed +runtime carried a comment advertising that "gating it behind a licence flag … +becomes a one-line edit HERE". In an open file that sentence is an invitation to +add exactly the check D2 forbids, sitting in the file where it would go; it is +reworded at the move, and the ⛔ replacing it is part of this decision rather +than editorial tidying. + +*ADR-0105 D12 is otherwise unchanged.* Its code-vs-activation split still holds, +and the commercial runtime may still narrow what **it** entitles. + +### D5 — The multi-node gate carrier stays commercial-only + +`MULTI_NODE_GATE_CARRIER_PACKAGES` names two packages, and the open package +acquires neither obligation nor the `security-enterprise` import that discharges +it. One consequence is a changed **diagnostic** on an open install, recorded so +it is not read as a regression: that carrier's import used to fail +(`unavailable`) and now succeeds while registering nothing +(`loaded-without-gate`). Both leave no gate registered, so the fail-closed +default refuses a multi-node verdict exactly as before. ⛔ The fix for the new +outcome is not to teach the open package to register a gate. + +### D6 — The service name `org-scoping` does not change + +It survived the move out and it survives the move back. The open core's +`getService('org-scoping')` probes and the `requiresService: 'org-scoping'` +navigation gates are anchored on it; renaming it would silently flip RLS posture +and unmount the Setup surface across every deployment. The plugin id +`com.objectstack.organizations` is kept for the same reason. + +--- + +## What this record does not decide + +- **It does not deliver the acceptance.** An open-only install that sets + `OS_TENANCY_POSTURE=isolated` with `OS_ALLOW_DEGRADED_TENANCY` **unset**, + boots with the wall active and enforces the isolation matrix — that is + #16137, which is blocked on this, and where it is measured. Shipping the + registrar is a necessary condition, not the acceptance. +- **It does not touch cloud ADR-0081 D1, D3 or D4** — the open member-management + basics, the organization record page, and the org-scoped roster reads. Those + are mirrored into this repository by + [#14508](https://github.com/objectstack-ai/objectstack/issues/14508), which was + unstarted when this file was written. ⚠️ That card's Shape section says D2 + "stays in cloud"; this record is what makes that line stale, and its writer + should cite this number rather than open a competing record. +- **It does not change what an enterprise subscription buys.** The commercial + surface ADR-0105 D12 lists is untouched. + +--- + +## Consequences + +**Good.** An open-source deployment can run a metadata application in +single-database multi-tenant mode — the maintainer's stated effect. The +open tree stops shipping a posture it can accept and cannot honour, closing an +ADR-0049 instance in the security-sensitive direction. The three +cross-organization repairs already landed in the open tree gain a runtime that +can actually exercise them. + +**Costs, stated plainly.** One package name now denotes two packages, which is a +real hazard managed by a real mechanism (D3) plus a pin, not by convention. +Documentation and one spec roster row that described the runtime as +closed-source and absent from npm become wrong on merge and are corrected in the +same change. And the commercial repository owes a follow-up PR — bump its +framework pin, delete what moved, subclass, keep the gate — without which its +package still carries a full copy of the moved code. + +**Reversibility.** High. The commercial package can re-absorb the code by +un-subclassing; nothing in the open tree depends on this package, by D3's own +prohibition. diff --git a/packages/plugins/organizations/LICENSE b/packages/plugins/organizations/LICENSE new file mode 100644 index 0000000000..16bc23f404 --- /dev/null +++ b/packages/plugins/organizations/LICENSE @@ -0,0 +1,202 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute + must include a readable copy of the attribution notices + contained within such NOTICE file, excluding those notices + that do not pertain to any part of the Derivative Works, + in at least one of the following places: within a NOTICE + text file distributed as part of the Derivative Works; within + the Source form or documentation, if provided along with + the Derivative Works; or, within a display generated by the + Derivative Works, if and wherever such third-party notices + normally appear. The contents of the NOTICE file are for + informational purposes only and do not modify the License. + You may add Your own attribution notices within Derivative + Works that You distribute, alongside or as an addendum to + the NOTICE text from the Work, provided that such additional + attribution notices cannot be construed as modifying the + License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 ObjectStack + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/plugins/organizations/README.md b/packages/plugins/organizations/README.md new file mode 100644 index 0000000000..e72e5a8897 --- /dev/null +++ b/packages/plugins/organizations/README.md @@ -0,0 +1,82 @@ +# @objectstack/organizations + +The multi-organization runtime: single-database, row-level Organization isolation for +ObjectStack. Installing this package is what turns the organization wall **on** — it +registers the `org-scoping` service that `@objectstack/plugin-security` probes and that the +Setup navigation's `requiresService: 'org-scoping'` gates key on. + +Recorded in [ADR-0132](../../../docs/adr/0132-multi-organization-runtime-is-open-core.md). +The machinery shipped here originally, moved to a closed runtime under cloud ADR-0081 D2, +and has now returned to open core; only the commercial **entitlement** stayed behind. + +## What it does + +- **`organization_id` auto-stamp on insert.** Every authenticated insert into an object + that declares `organization_id` is stamped from `ExecutionContext.tenantId`. A user + cannot choose which organization a row lands in: a supplied — possibly forged — value is + overwritten, never trusted. On-behalf writes running under a system context are untouched. +- **Per-org seed replay.** After a `sys_organization` insert, the app's *own* seed datasets + are replayed into the new organization. ⛔ Never another organization's rows: a new + organization's data comes from the app's seed definitions, or it starts empty. +- **Default-organization bootstrap.** Ensures the platform admin has an organization to + operate in, idempotently, on `kernel:ready` and after the writes that can move the "who is + the platform admin" answer. +- **Walled-posture membership-policy gate.** A deployment that raises the wall must + *declare* what a new user joins. Running the framework default `auto` merely because + nobody configured it refuses the boot, with a message that names the remedy. + +## Install + +```bash +pnpm add @objectstack/organizations +``` + +```ts +import { OrganizationsPlugin } from '@objectstack/organizations'; + +await kernel.use(new OrganizationsPlugin()); +``` + +Register it **before** `SecurityPlugin`, so the posture probe finds the service. + +> ⚠️ `objectstack serve` does not yet mount this package off `OS_TENANCY_POSTURE` — it +> still resolves one hard-coded runtime spelling from the host app. Wiring `serve` to this +> registrar, so an open install can set `OS_TENANCY_POSTURE=isolated` and boot with the wall +> active, is tracked separately and is not delivered by shipping this package. + +## Key exports + +| Export | Kind | Description | +|:---|:---|:---| +| `OrganizationsPlugin` | class | The plugin. Registers the `org-scoping` service, the two ObjectQL middlewares and the boot gate. | +| `OrganizationsPluginOptions` | type | Constructor options — currently `ensureDefaultOrganization`. | +| `OrgScopingPlugin` | alias | Alias of `OrganizationsPlugin`, matching the package name. | +| `OrgScopingPluginOptions` | alias | Alias of `OrganizationsPluginOptions`. | +| `claimOrphanOrgRows` | function | One-time back-fill of `organization_id` on orphaned seed rows, for the first organization. | +| `claimOrgSeedOwnership` | function | Hands an organization's seeded rows to its owner (`owner_id` back-fill, scoped to one org). | +| `ensureDefaultOrganization` | function | Multi-org flavour of the default-org bootstrap; wraps the open `plugin-auth` helper and adds the per-org seed-ownership handoff. | +| `assertWalledMembershipPolicyDeclared` | function | The boot gate: throws unless a walled deployment declared its membership policy. | +| `isWalledMembershipPolicyError` | function | Structural discriminator for that refusal — survives duplicate module instances. | +| `readMembershipPolicyDeclaration` | function | Total read of what the deployment declared, and where it came from. | +| `walledMembershipPolicyFatalMessage` | function | The operator-facing refusal text. | +| `WalledMembershipPolicyError` | class | The refusal. | +| `organizationsPluginManifestHeader` | const | Manifest header shared by compile-time config and runtime registration. | + +## Boundaries + +⛔ This package carries **no licence check of any kind** and offers no hook for one +(ADR-0132 boundary 3). Commercial gating of multi-organization operation lives in a private +package of the same name, whose class `extends OrganizationsPlugin` and answers its own +licence gate in its own constructor. + +That shared name is the mechanism, not a collision. Which class a deployment mounts is +decided by the manifest that **declares** the name: a commercial host declares +`"@objectstack/organizations": "workspace:*"`, which pnpm resolves only to its local +workspace package and never to the registry; an open install declares the same name from +npm and gets this package. `objectstack serve` reaches it through a host-anchored importer +that refuses a package the served app has not declared, so the resolution base is always +the app's own manifest. + +## License + +Apache-2.0 diff --git a/packages/plugins/organizations/package.json b/packages/plugins/organizations/package.json new file mode 100644 index 0000000000..aabd54d2cb --- /dev/null +++ b/packages/plugins/organizations/package.json @@ -0,0 +1,62 @@ +{ + "name": "@objectstack/organizations", + "version": "17.3.0", + "license": "Apache-2.0", + "description": "Multi-organization runtime for ObjectStack — registers the `org-scoping` service that turns single-database row-level Organization isolation on: `organization_id` auto-stamp on insert, per-org seed replay, default-organization bootstrap, and the walled-posture membership-policy gate.", + "main": "dist/index.js", + "module": "dist/index.mjs", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + } + }, + "scripts": { + "build": "tsup --config ../../../tsup.config.ts && node ../../../scripts/check-dts-emitted.mjs", + "test": "vitest run", + "typecheck": "tsc --noEmit && pnpm check:test-typecheck", + "check:test-typecheck": "tsx ../../../scripts/check-test-typecheck.mts --self-test && tsx ../../../scripts/check-test-typecheck.mts --package packages/plugins/organizations --project tsconfig.test.json" + }, + "dependencies": { + "@objectstack/core": "workspace:*", + "@objectstack/plugin-auth": "workspace:*", + "@objectstack/spec": "workspace:*", + "@objectstack/types": "workspace:*" + }, + "devDependencies": { + "@objectstack/metadata-core": "workspace:*", + "@types/node": "^26.2.0", + "tsx": "^4.23.12", + "typescript": "^6.0.3", + "vitest": "^4.1.10" + }, + "keywords": [ + "objectstack", + "plugin", + "organizations", + "multi-org", + "org-scoping", + "multi-tenant" + ], + "author": "ObjectStack", + "repository": { + "type": "git", + "url": "https://github.com/objectstack-ai/objectstack.git", + "directory": "packages/plugins/organizations" + }, + "homepage": "https://objectstack.ai/docs", + "bugs": "https://github.com/objectstack-ai/objectstack/issues", + "publishConfig": { + "access": "public" + }, + "files": [ + "dist", + "README.md", + "CHANGELOG.md" + ], + "engines": { + "node": ">=22.0.0" + } +} diff --git a/packages/plugins/organizations/src/claim-org-seed-ownership.test.ts b/packages/plugins/organizations/src/claim-org-seed-ownership.test.ts new file mode 100644 index 0000000000..dfa8c5bde0 --- /dev/null +++ b/packages/plugins/organizations/src/claim-org-seed-ownership.test.ts @@ -0,0 +1,93 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, vi } from 'vitest'; +import { claimOrgSeedOwnership } from './claim-org-seed-ownership.js'; +// The fake engines below open `update()` with `assertEngineUpdateDispatch` +// (`pnpm check:engine-double-contract`). A double looser than the real +// `ObjectQLEngine.update` is how a dead write path ships with its suite green; +// one call pins these fakes to the producer's rejection surface and, unlike a +// mirrored `if`, cannot drift when that rule changes. +import { assertEngineUpdateDispatch } from '@objectstack/metadata-core'; + +const ORG = 'org_1'; +const OWNER = 'usr_admin'; + +function makeQL(schemas: any[], rowsByObject: Record) { + const updates: { object: string; data: any }[] = []; + const ql: any = { + registry: { getAllObjects: () => schemas }, + find: vi.fn(async (object: string, query: any) => { + const all = rowsByObject[object] ?? []; + const w = query?.where ?? {}; + return all.filter((r) => { + if ('organization_id' in w && (r.organization_id ?? null) !== (w.organization_id ?? null)) return false; + if ('owner_id' in w && (r.owner_id ?? null) !== (w.owner_id ?? null)) return false; + return true; + }); + }), + update: vi.fn(async (object: string, data: any, options?: any) => { + assertEngineUpdateDispatch(data, options); + updates.push({ object, data }); + const row = (rowsByObject[object] ?? []).find((r) => r.id === data.id); + if (row) row.owner_id = data.owner_id; + return row; + }), + }; + return { ql, updates }; +} + +describe('claimOrgSeedOwnership', () => { + it('returns [] when registry is unavailable', async () => { + const ql: any = { find: vi.fn(), update: vi.fn() }; + expect(await claimOrgSeedOwnership(ql, ORG, OWNER)).toEqual([]); + }); + + it('no-ops without an org or owner', async () => { + const schemas = [{ name: 'crm_lead', fields: [{ name: 'owner_id' }, { name: 'organization_id' }] }]; + const { ql, updates } = makeQL(schemas, { crm_lead: [{ id: 'l1', organization_id: ORG, owner_id: null }] }); + expect(await claimOrgSeedOwnership(ql, '', OWNER)).toEqual([]); + expect(await claimOrgSeedOwnership(ql, ORG, '')).toEqual([]); + expect(updates).toHaveLength(0); + }); + + it('skips managedBy / sys_* and objects missing owner_id or organization_id', async () => { + const schemas = [ + { name: 'sys_user', managedBy: 'better-auth', fields: [{ name: 'owner_id' }, { name: 'organization_id' }] }, + { name: 'sys_widget', fields: [{ name: 'owner_id' }, { name: 'organization_id' }] }, + { name: 'crm_pricebook', fields: [{ name: 'organization_id' }] }, // no owner_id + { name: 'crm_global', fields: [{ name: 'owner_id' }] }, // no organization_id + ]; + const { ql, updates } = makeQL(schemas, { + sys_user: [{ id: 'u1', organization_id: ORG, owner_id: null }], + sys_widget: [{ id: 'w1', organization_id: ORG, owner_id: null }], + crm_pricebook: [{ id: 'p1', organization_id: ORG }], + crm_global: [{ id: 'g1', owner_id: null }], + }); + expect(await claimOrgSeedOwnership(ql, ORG, OWNER)).toEqual([]); + expect(updates).toHaveLength(0); + }); + + it('claims this org\'s NULL-owner rows only, leaving other orgs and human-owned rows untouched', async () => { + const schemas = [{ name: 'crm_lead', fields: [{ name: 'owner_id' }, { name: 'organization_id' }] }]; + const rows = [ + { id: 'l1', organization_id: ORG, owner_id: null }, // claimed + { id: 'l2', organization_id: ORG, owner_id: 'usr_someone' }, // already owned — untouched + { id: 'l3', organization_id: 'org_2', owner_id: null }, // other org — untouched + ]; + const { ql, updates } = makeQL(schemas, { crm_lead: rows }); + const result = await claimOrgSeedOwnership(ql, ORG, OWNER); + + expect(result).toEqual([{ object: 'crm_lead', count: 1 }]); + expect(updates).toHaveLength(1); + expect(updates[0].data).toMatchObject({ id: 'l1', owner_id: OWNER }); + expect(rows.find((r) => r.id === 'l2')!.owner_id).toBe('usr_someone'); + expect(rows.find((r) => r.id === 'l3')!.owner_id).toBeNull(); + }); + + it('is idempotent — a second run claims nothing', async () => { + const schemas = [{ name: 'crm_lead', fields: [{ name: 'owner_id' }, { name: 'organization_id' }] }]; + const { ql } = makeQL(schemas, { crm_lead: [{ id: 'l1', organization_id: ORG, owner_id: null }] }); + await claimOrgSeedOwnership(ql, ORG, OWNER); + expect(await claimOrgSeedOwnership(ql, ORG, OWNER)).toEqual([]); + }); +}); diff --git a/packages/plugins/organizations/src/claim-org-seed-ownership.ts b/packages/plugins/organizations/src/claim-org-seed-ownership.ts new file mode 100644 index 0000000000..3b8d03f67a --- /dev/null +++ b/packages/plugins/organizations/src/claim-org-seed-ownership.ts @@ -0,0 +1,116 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * claimOrgSeedOwnership — hand an organization's seeded records to its owner. + * + * The multi-tenant twin of plugin-security's `claimSeedOwnership` (single-tenant + * first-admin handoff). Seeded rows land `owner_id = NULL` (the author leaves it + * unset and `cel`os.user.id`` resolves to NULL at seed time, since the owning + * admin does not exist yet). In multi-tenant mode those rows are scoped to an + * org by `claimOrphanOrgRows` / per-org replay, but their `owner_id` stays NULL + * — so "My" views, owner reports and owner notifications are empty for the org's + * members until ownership is assigned. + * + * This runs when the org's owner is established (e.g. `ensureDefaultOrganization` + * binds the platform admin as the default org's `owner`) and assigns + * `owner_id = ownerUserId` to that org's NULL-owned rows — the ownership + * companion to `claimOrphanOrgRows`'s `organization_id` back-fill. + * + * Scoped to a single org (`organization_id = organizationId`) so it never + * touches another tenant's rows. Idempotent: only NULL-owned rows are updated. + * `managedBy` and `sys_*` tables are skipped. + */ + +import type { ServiceObject } from '@objectstack/spec/data'; + +interface ClaimOwnershipOptions { + logger?: { + info: (message: string, meta?: Record) => void; + warn: (message: string, meta?: Record) => void; + }; +} + +const SYSTEM_CTX = { isSystem: true }; + +function hasField(schema: ServiceObject, field: string): boolean { + const fields: any = (schema as any)?.fields; + if (!fields) return false; + if (Array.isArray(fields)) return fields.some((f) => f?.name === field); + return Object.prototype.hasOwnProperty.call(fields, field); +} + +/** + * Assign `owner_id = ownerUserId` to every NULL-owned seed row of `organizationId`. + * + * Walks `ql.registry.getAllObjects()`, filters to schemas that + * (a) are not `managedBy` (skip sys_/auth/platform tables), + * (b) are not `sys_*`-namespaced, + * (c) declare BOTH `owner_id` and `organization_id`, + * and updates the org's unowned rows as `isSystem`. Returns a per-object summary. + */ +export async function claimOrgSeedOwnership( + ql: any, + organizationId: string, + ownerUserId: string, + options: ClaimOwnershipOptions = {}, +): Promise<{ object: string; count: number }[]> { + const logger = options.logger; + if (!organizationId || !ownerUserId) return []; + if (!ql || typeof ql.update !== 'function' || typeof ql.find !== 'function') return []; + const registry = (ql as any).registry; + if (!registry || typeof registry.getAllObjects !== 'function') { + logger?.warn?.('[org-scoping] claimOrgSeedOwnership: registry unavailable'); + return []; + } + + const schemas: ServiceObject[] = registry.getAllObjects(); + const results: { object: string; count: number }[] = []; + + for (const schema of schemas) { + if (!schema?.name) continue; + if ((schema as any).managedBy) continue; + if (schema.name.startsWith('sys_')) continue; + // Both columns are required: owner_id to assign, organization_id to scope. + if (!hasField(schema, 'owner_id') || !hasField(schema, 'organization_id')) continue; + + try { + const orphans = await ql.find( + schema.name, + { where: { organization_id: organizationId, owner_id: null }, limit: 10_000, fields: ['id'] }, + { context: SYSTEM_CTX }, + ); + const list: any[] = Array.isArray(orphans) + ? orphans + : Array.isArray(orphans?.records) + ? orphans.records + : []; + if (list.length === 0) continue; + + let updated = 0; + for (const row of list) { + if (!row?.id) continue; + try { + await ql.update(schema.name, { id: row.id, owner_id: ownerUserId }, { context: SYSTEM_CTX }); + updated += 1; + } catch (e) { + logger?.warn?.(`[org-scoping] claimOrgSeedOwnership failed for ${schema.name}:${row.id}`, { + error: (e as Error).message, + }); + } + } + if (updated > 0) results.push({ object: schema.name, count: updated }); + } catch (e) { + logger?.warn?.(`[org-scoping] claimOrgSeedOwnership scan failed for ${schema.name}`, { + error: (e as Error).message, + }); + } + } + + if (results.length > 0) { + const total = results.reduce((s, r) => s + r.count, 0); + logger?.info?.(`[org-scoping] handed ${total} seeded row(s) of org ${organizationId} to owner ${ownerUserId}`, { + breakdown: results, + }); + } + return results; +} diff --git a/packages/plugins/organizations/src/claim-orphan-org-rows.test.ts b/packages/plugins/organizations/src/claim-orphan-org-rows.test.ts new file mode 100644 index 0000000000..c22e16ff60 --- /dev/null +++ b/packages/plugins/organizations/src/claim-orphan-org-rows.test.ts @@ -0,0 +1,134 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, vi } from 'vitest'; +import { claimOrphanOrgRows } from './claim-orphan-org-rows.js'; +// The fake engines below open `update()` with `assertEngineUpdateDispatch` +// (`pnpm check:engine-double-contract`). A double looser than the real +// `ObjectQLEngine.update` is how a dead write path ships with its suite green; +// one call pins these fakes to the producer's rejection surface and, unlike a +// mirrored `if`, cannot drift when that rule changes. +import { assertEngineUpdateDispatch } from '@objectstack/metadata-core'; + +function makeQL(schemas: any[], rowsByObject: Record) { + const updates: { object: string; data: any; options: any }[] = []; + const ql: any = { + registry: { getAllObjects: () => schemas }, + find: vi.fn(async (object: string, query: any, _options: any) => { + const all = rowsByObject[object] ?? []; + // emulate `where: { organization_id: null }` + if (query?.where?.organization_id === null) { + return all.filter((r) => r.organization_id == null); + } + return all; + }), + update: vi.fn(async (object: string, data: any, options: any) => { + assertEngineUpdateDispatch(data, options); + updates.push({ object, data, options }); + const row = (rowsByObject[object] ?? []).find((r) => r.id === data.id); + if (row) row.organization_id = data.organization_id; + return row; + }), + }; + return { ql, updates }; +} + +describe('claimOrphanOrgRows', () => { + it('returns [] when registry is unavailable', async () => { + const ql: any = { find: vi.fn(), update: vi.fn() }; + const result = await claimOrphanOrgRows(ql, 'org_1'); + expect(result).toEqual([]); + }); + + it('skips schemas with managedBy set', async () => { + const schemas = [ + { name: 'better_auth_user', managedBy: 'better-auth', fields: [{ name: 'organization_id' }] }, + ]; + const { ql, updates } = makeQL(schemas, { + better_auth_user: [{ id: 'u1', organization_id: null }], + }); + const result = await claimOrphanOrgRows(ql, 'org_1'); + expect(updates).toHaveLength(0); + expect(result).toEqual([]); + }); + + it('skips sys_-prefixed schemas even without managedBy', async () => { + const schemas = [ + { name: 'sys_permission_set', fields: [{ name: 'organization_id' }] }, + ]; + const { ql, updates } = makeQL(schemas, { + sys_permission_set: [{ id: 'ps1', organization_id: null }], + }); + await claimOrphanOrgRows(ql, 'org_1'); + expect(updates).toHaveLength(0); + }); + + it('skips schemas without an organization_id field', async () => { + const schemas = [{ name: 'global_setting', fields: [{ name: 'key' }, { name: 'value' }] }]; + const { ql, updates } = makeQL(schemas, { + global_setting: [{ id: 's1' }], + }); + await claimOrphanOrgRows(ql, 'org_1'); + expect(updates).toHaveLength(0); + }); + + it('updates only orphan rows and reports per-object counts', async () => { + const schemas = [ + { name: 'lead', fields: [{ name: 'organization_id' }] }, + { name: 'account', fields: [{ name: 'organization_id' }] }, + ]; + const { ql, updates } = makeQL(schemas, { + lead: [ + { id: 'l1', organization_id: null }, + { id: 'l2', organization_id: null }, + { id: 'l3', organization_id: 'org_other' }, + ], + account: [{ id: 'a1', organization_id: null }], + }); + const result = await claimOrphanOrgRows(ql, 'org_1'); + expect(updates).toHaveLength(3); + expect(updates.every((u) => u.options.context?.isSystem === true)).toBe(true); + expect(updates.every((u) => u.data.organization_id === 'org_1')).toBe(true); + expect(result).toEqual([ + { object: 'lead', count: 2 }, + { object: 'account', count: 1 }, + ]); + }); + + it('continues past rows whose update throws (e.g. user hooks)', async () => { + const schemas = [{ name: 'quote', fields: [{ name: 'organization_id' }] }]; + const ql: any = { + registry: { getAllObjects: () => schemas }, + find: vi.fn(async () => [ + { id: 'q1', organization_id: null }, + { id: 'q2', organization_id: null }, + ]), + update: vi.fn(async (_o: string, data: any, options?: any) => { + assertEngineUpdateDispatch(data, options); + if (data.id === 'q1') throw new Error('hook rejected'); + return { id: data.id }; + }), + }; + const logger = { info: vi.fn(), warn: vi.fn() }; + const result = await claimOrphanOrgRows(ql, 'org_1', { logger }); + expect(result).toEqual([{ object: 'quote', count: 1 }]); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('claim failed for quote:q1'), + expect.objectContaining({ error: 'hook rejected' }), + ); + }); + + it('is a no-op when no orphans exist', async () => { + const schemas = [{ name: 'lead', fields: [{ name: 'organization_id' }] }]; + const { ql, updates } = makeQL(schemas, { + lead: [{ id: 'l1', organization_id: 'org_other' }], + }); + const result = await claimOrphanOrgRows(ql, 'org_1'); + expect(updates).toHaveLength(0); + expect(result).toEqual([]); + }); + + it('returns [] when ql lacks find/update', async () => { + const result = await claimOrphanOrgRows({} as any, 'org_1'); + expect(result).toEqual([]); + }); +}); diff --git a/packages/plugins/organizations/src/claim-orphan-org-rows.ts b/packages/plugins/organizations/src/claim-orphan-org-rows.ts new file mode 100644 index 0000000000..08eb36418c --- /dev/null +++ b/packages/plugins/organizations/src/claim-orphan-org-rows.ts @@ -0,0 +1,135 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * claimOrphanOrgRows — assign seed-loaded records to the first organization. + * + * Seeds (`defineSeed`) are inserted by `SeedLoaderService` using + * `{ context: { isSystem: true } }`, which intentionally bypasses + * SecurityPlugin's `organization_id` auto-fill. As a result, in + * multi-tenant mode every seed row lands with `organization_id = NULL`. + * + * That's correct for **cross-tenant metadata** — `sys_permission_set` + * rows, default roles, etc. (objects whose schema has `managedBy` set) + * — but for **business-domain seeds** (CRM `lead`, `account`, `contact`, + * …) it means the rows are invisible to anyone bound to an organization + * (the default `tenant_isolation` RLS policy + * `organization_id = current_user.organization_id` filters them out). + * + * This helper runs **once**, on first-organization creation, and + * back-fills `organization_id` on every orphaned (`organization_id IS + * NULL`) seed row of every user-defined object that declares the + * column. Result: out of the box, the freshly registered owner sees the + * shipped demo data scoped to their first org — no manual claim step. + * + * Idempotent: a no-op once an organization-tagged row exists, and + * `managedBy` schemas (`sys_*` better-auth/platform tables) are always + * skipped so cross-tenant defaults stay cross-tenant. + */ + +import type { ServiceObject } from '@objectstack/spec/data'; + +interface ClaimOptions { + logger?: { + info: (message: string, meta?: Record) => void; + warn: (message: string, meta?: Record) => void; + }; +} + +const SYSTEM_CTX = { isSystem: true }; + +function hasOrganizationField(schema: ServiceObject): boolean { + const fields: any = (schema as any)?.fields; + if (!fields) return false; + if (Array.isArray(fields)) { + return fields.some((f) => f?.name === 'organization_id'); + } + return Object.prototype.hasOwnProperty.call(fields, 'organization_id'); +} + +/** + * Assign every orphaned seed row to `organizationId`. + * + * Walks `ql.registry.getAllObjects()`, filters to schemas that + * (a) are not `managedBy` (skip sys_/auth/platform tables), + * (b) declare an `organization_id` field, + * and runs an `update(where: { organization_id: null }, patch: { + * organization_id: organizationId })` against each as `isSystem`. + * + * Returns a per-object summary `{ object, count }[]`. + */ +export async function claimOrphanOrgRows( + ql: any, + organizationId: string, + options: ClaimOptions = {}, +): Promise<{ object: string; count: number }[]> { + const logger = options.logger; + if (!ql || typeof ql.update !== 'function' || typeof ql.find !== 'function') { + return []; + } + const registry = (ql as any).registry; + if (!registry || typeof registry.getAllObjects !== 'function') { + logger?.warn?.('[org-scoping] claimOrphanOrgRows: registry unavailable'); + return []; + } + + const schemas: ServiceObject[] = registry.getAllObjects(); + const results: { object: string; count: number }[] = []; + + for (const schema of schemas) { + if (!schema?.name) continue; + if ((schema as any).managedBy) continue; + // Defense in depth: any platform-namespaced object (`sys_*`) is + // off-limits for tenant claim regardless of `managedBy`. Platform + // tables that should be tenant-scoped are inserted with an explicit + // `organization_id` by the code that owns them, so they will never + // be orphans here. + if (schema.name.startsWith('sys_')) continue; + if (!hasOrganizationField(schema)) continue; + + try { + const orphans = await ql.find( + schema.name, + { where: { organization_id: null }, limit: 10_000, fields: ['id'] }, + { context: SYSTEM_CTX }, + ); + const list: any[] = Array.isArray(orphans) + ? orphans + : Array.isArray(orphans?.records) + ? orphans.records + : []; + if (list.length === 0) continue; + + let updated = 0; + for (const row of list) { + if (!row?.id) continue; + try { + await ql.update( + schema.name, + { id: row.id, organization_id: organizationId }, + { context: SYSTEM_CTX }, + ); + updated += 1; + } catch (e) { + logger?.warn?.(`[org-scoping] claim failed for ${schema.name}:${row.id}`, { + error: (e as Error).message, + }); + } + } + if (updated > 0) { + results.push({ object: schema.name, count: updated }); + } + } catch (e) { + logger?.warn?.(`[org-scoping] claim scan failed for ${schema.name}`, { + error: (e as Error).message, + }); + } + } + + if (results.length > 0) { + const total = results.reduce((s, r) => s + r.count, 0); + logger?.info?.(`[org-scoping] claimed ${total} orphan seed row(s) for organization ${organizationId}`, { + breakdown: results, + }); + } + return results; +} diff --git a/packages/plugins/organizations/src/ensure-default-organization.ts b/packages/plugins/organizations/src/ensure-default-organization.ts new file mode 100644 index 0000000000..da45ee8fab --- /dev/null +++ b/packages/plugins/organizations/src/ensure-default-organization.ts @@ -0,0 +1,44 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ensureDefaultOrganization — multi-org flavour of the default-org bootstrap. + * + * The helper itself moved to `@objectstack/plugin-auth` (ADR-0081 D1: the + * open member-management basics own it — single-org mode runs it too, from + * AuthPlugin). This wrapper keeps the multi-org semantics this plugin always + * had by injecting the per-org seed-ownership handoff step + * (`claimOrgSeedOwnership`), which belongs to the org seed pipeline here, + * not to the basics. + * + * See the plugin-auth helper for the full strategy documentation. + */ + +import { + ensureDefaultOrganization as ensureDefaultOrganizationBase, + type EnsureDefaultOrganizationResult, +} from '@objectstack/plugin-auth'; +import { claimOrgSeedOwnership } from './claim-org-seed-ownership.js'; + +interface EnsureOptions { + logger?: { + info: (message: string, meta?: Record) => void; + warn: (message: string, meta?: Record) => void; + }; +} + +export type { EnsureDefaultOrganizationResult }; + +/** + * Ensure the platform admin has a Default Organization to operate in, + * then hand the org's seeded rows to them. Idempotent (stable slug + * `default` + the admin's existing memberships short-circuit). + */ +export async function ensureDefaultOrganization( + ql: any, + options: EnsureOptions = {}, +): Promise { + return ensureDefaultOrganizationBase(ql, { + ...options, + claimSeedOwnership: claimOrgSeedOwnership, + }); +} diff --git a/packages/plugins/organizations/src/index.ts b/packages/plugins/organizations/src/index.ts new file mode 100644 index 0000000000..d9fb083472 --- /dev/null +++ b/packages/plugins/organizations/src/index.ts @@ -0,0 +1,103 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * @objectstack/organizations — the multi-organization runtime, in open + * core (ADR-0132; the return trip of cloud ADR-0081 D2, which had moved this + * machinery into the closed `@objectstack/organizations`). + * + * Row-level Organization isolation for ObjectStack: + * - auto-stamps `organization_id` on insert from + * `ExecutionContext.tenantId`, + * - replays the APP's own seed datasets on every `sys_organization` + * insert (never another organization's rows — cloud#1345), + * - bootstraps a Default Organization for the first platform admin + * (multi-org flavour: reuses plugin-auth's open helper and injects the + * per-org seed-ownership handoff). + * + * Pair with `@objectstack/plugin-security` for full multi-tenant RBAC + + * RLS + Field-Level Security — plugin-security detects this plugin's + * presence via `getService('org-scoping')` (the historical service name, + * kept on purpose across both moves) and adjusts wildcard tenant policy + * handling. Without a registrar, deployments are single-org (the open + * member-management basics still work — plugin-auth, cloud ADR-0081 D1). + * + * ⚠️ Shipping this package is NOT yet the same as an open install raising the + * wall. `objectstack serve` still resolves one hard-coded runtime spelling off + * the tenancy posture and does not know this package exists; teaching it, and + * running the open isolated-posture matrices against this registrar instead of + * their hand-written posture stub, is #16137. Until that lands, this package is + * the registrar an app wires by hand. + * + * ⛔ This package carries NO licence check of any kind and offers no hook for + * one (ADR-0132 boundary 3). + * + * ## Why a private package in the commercial repo shares this name + * + * It is the mechanism, not a collision. The commercial multi-org entitlement + * is a package of the same name that `extends OrganizationsPlugin` and calls + * its own licence gate in its own constructor. Which one a deployment mounts + * is decided by the manifest that DECLARES the name, never by this package: + * + * - every commercial host declares `"@objectstack/organizations": + * "workspace:*"`, and pnpm's `workspace:` protocol resolves only to the + * local workspace package — it cannot fall through to the registry, and a + * missing one fails the install rather than substituting silently; + * - an open install declares the same name from npm and gets this package; + * - `objectstack serve` reaches it through the host-anchored importer, which + * refuses a package the served app has not declared at all (#4719), so the + * resolution base is always the app's own manifest. + * + * ⛔ Two consequences for anyone editing this package. It must never be added + * as a dependency of another framework package — that would put an ungated + * copy inside the framework tree a commercial app links, where a bare import + * could reach it (`no-framework-dependents.pin.test.ts` holds this). And it + * must never gain a way to detect or announce which of the two it is. + */ + +export { OrganizationsPlugin } from './organizations-plugin.js'; +export type { OrganizationsPluginOptions } from './organizations-plugin.js'; +// Aliases matching the package name — the historical spelling this code +// shipped under before its round trip through the closed runtime, and the one +// the service name still reads as. +export { OrganizationsPlugin as OrgScopingPlugin } from './organizations-plugin.js'; +export type { OrganizationsPluginOptions as OrgScopingPluginOptions } from './organizations-plugin.js'; +export { claimOrphanOrgRows } from './claim-orphan-org-rows.js'; +export { claimOrgSeedOwnership } from './claim-org-seed-ownership.js'; +// ⛔ No donor-org clone is exported, and none may be re-added (cloud#1345). +// A new organization's rows come from the APP's own seed definitions +// (`seed-datasets` / `seed-replayer`, replayed per tenant) or the organization +// starts empty — never from another organization's data. The retired +// `cloneOrgSeedData` copied the FIRST organization's business rows into every +// subsequent one, which on a self-serve SaaS deployment handed customer #2 a +// copy of customer #1's records. +export { + ensureDefaultOrganization, + type EnsureDefaultOrganizationResult, +} from './ensure-default-organization.js'; +export { + organizationsObjects, + organizationsPluginManifestHeader, + ORGANIZATIONS_PLUGIN_ID, + ORGANIZATIONS_PLUGIN_VERSION, +} from './manifest.js'; +// Walled-posture MEMBERSHIP-POLICY gate (cloud#1092). A deployment that puts up +// the organization wall must DECLARE what a new user joins (`invite-only`, or +// `auto` knowingly accepted); running `auto` merely because nobody configured it +// refuses the boot. Enforced from this plugin's own boot hook — hosts do not +// call it — but the pieces are exported so a host can re-report the refusal +// structurally (`isWalledMembershipPolicyError`) rather than by string match. +export { + assertWalledMembershipPolicyDeclared, + isWalledMembershipPolicyError, + readMembershipPolicyDeclaration, + walledMembershipPolicyFatalMessage, + WalledMembershipPolicyError, + MEMBERSHIP_POLICY_ENV, + MEMBERSHIP_POLICY_ERROR_CODE, + MEMBERSHIP_POLICY_SETTING, + type MembershipPolicyAuthSurface, + type MembershipPolicyDeclaration, + type MembershipPolicyProbe, + type MembershipPolicySettingsSurface, + type MembershipPolicySource, +} from './membership-policy-gate.js'; diff --git a/packages/plugins/organizations/src/manifest.ts b/packages/plugins/organizations/src/manifest.ts new file mode 100644 index 0000000000..d957ac8872 --- /dev/null +++ b/packages/plugins/organizations/src/manifest.ts @@ -0,0 +1,28 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Canonical @objectstack/organizations manifest source — imported by the + * plugin's runtime `manifest.register` (and any compile-time config) so the + * registration paths cannot drift. + */ + +export const ORGANIZATIONS_PLUGIN_ID = 'com.objectstack.organizations'; +export const ORGANIZATIONS_PLUGIN_VERSION = '1.0.0'; + +/** This plugin owns no `sys_*` objects — Organization itself lives in `@objectstack/platform-objects`. */ +export const organizationsObjects = [] as const; + +/** Manifest header shared by compile-time config and runtime registration. */ +export const organizationsPluginManifestHeader = { + id: ORGANIZATIONS_PLUGIN_ID, + namespace: 'sys', + version: ORGANIZATIONS_PLUGIN_VERSION, + type: 'plugin' as const, + scope: 'system' as const, + defaultDatasource: 'cloud', + name: 'Organizations', + description: + 'Multi-organization runtime: row-level Organization isolation (auto-stamps ' + + '`organization_id` on insert from `ExecutionContext.tenantId`), per-org seed replay, and the ' + + 'multi-org default-organization bootstrap.', +}; diff --git a/packages/plugins/organizations/src/membership-policy-gate.test.ts b/packages/plugins/organizations/src/membership-policy-gate.test.ts new file mode 100644 index 0000000000..e3e7480566 --- /dev/null +++ b/packages/plugins/organizations/src/membership-policy-gate.test.ts @@ -0,0 +1,475 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// cloud#1092 — a walled deployment must DECLARE what a new user joins. +// +// The subject of this suite is one distinction and nothing else: DECLARED vs +// DEFAULTED. Asserting the OUTCOME ("no membership was created") would be green +// on an undeclared deployment too — that is precisely the hole cloud#1092 +// reports — so every case here keys on the settings service's `source`, which +// is the only thing that can tell the two apart. + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { + assertWalledMembershipPolicyDeclared, + isWalledMembershipPolicyError, + readMembershipPolicyDeclaration, + walledMembershipPolicyFatalMessage, + MEMBERSHIP_POLICY_ENV, + MEMBERSHIP_POLICY_ERROR_CODE, + MEMBERSHIP_POLICY_SETTING, + WalledMembershipPolicyError, + type MembershipPolicyDeclaration, +} from './membership-policy-gate.js'; +import { OrganizationsPlugin } from './organizations-plugin.js'; + +// ⛔ No entitlement grant: the open package has no licence gate (ADR-0132 +// boundary 3). The subject is unchanged — DECLARED vs DEFAULTED. + +// ── Fakes ──────────────────────────────────────────────────────────────────── + +/** + * How the `auth.membership_policy` setting resolves on the fake kernel. + * + * `'absent'` / `'throws'` / `'no-key'` model the three ways the namespace can + * fail to answer; an object models a real resolution, where `source` is the + * settings service's own cascade verdict (`default` = the manifest default + * nobody chose, anything else = an operator wrote it). + */ +type SettingsFixture = + | 'absent' + | 'throws' + | 'no-key' + | { source: 'env' | 'global' | 'tenant' | 'user' | 'default'; value: unknown }; + +interface CtxFixture { + settings?: SettingsFixture; + /** `undefined` = no `auth` service at all. */ + authPolicy?: string; + /** Extra services the fake kernel should resolve. */ + services?: Record; +} + +function makeCtx(fixture: CtxFixture = {}) { + const services: Record = { ...(fixture.services ?? {}) }; + + const settings = fixture.settings ?? { source: 'default', value: 'auto' }; + if (settings !== 'absent') { + services.settings = { + getNamespace: vi.fn(async (namespace: string) => { + if (settings === 'throws') throw new Error(`namespace '${namespace}' is not registered`); + if (settings === 'no-key') return { values: {} }; + return { values: { membership_policy: { value: settings.value, source: settings.source } } }; + }), + }; + } + + if (fixture.authPolicy !== undefined) { + services.auth = { getMembershipPolicy: () => fixture.authPolicy }; + } + + const hooks = new Map unknown>>(); + const ctx = { + logger: { info: vi.fn(), warn: vi.fn(), debug: vi.fn(), error: vi.fn() }, + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + registerService: (name: string, svc: unknown) => { + services[name] = svc; + }, + hook: (name: string, handler: () => unknown) => { + if (!hooks.has(name)) hooks.set(name, []); + hooks.get(name)!.push(handler); + }, + trigger: async (name: string) => { + for (const h of hooks.get(name) ?? []) await h(); + }, + }; + return { ctx, hooks, services }; +} + +/** Set `OS_TENANCY_POSTURE` for one test; restored by the `afterEach` below. */ +const savedPosture = process.env.OS_TENANCY_POSTURE; +function posture(value: string | undefined): void { + if (value === undefined) delete process.env.OS_TENANCY_POSTURE; + else process.env.OS_TENANCY_POSTURE = value; +} +afterEach(() => { + if (savedPosture === undefined) delete process.env.OS_TENANCY_POSTURE; + else process.env.OS_TENANCY_POSTURE = savedPosture; +}); + +/** Run the gate and return the refusal, or `null` when it allowed the boot. */ +async function refusal(ctx: Parameters[0]) { + try { + await assertWalledMembershipPolicyDeclared(ctx); + return null; + } catch (e) { + return e as WalledMembershipPolicyError; + } +} + +// ── The four acceptance cases (cloud#1092) ─────────────────────────────────── + +describe('walled posture + membership policy (cloud#1092)', () => { + it('walled + NEVER CONFIGURED → refuses to boot', async () => { + posture('isolated'); + const { ctx } = makeCtx({ authPolicy: 'auto', settings: { source: 'default', value: 'auto' } }); + + const err = await refusal(ctx); + expect(err, 'an undeclared walled deployment booted').not.toBeNull(); + expect(err).toBeInstanceOf(WalledMembershipPolicyError); + expect(err!.posture).toBe('isolated'); + expect(err!.declaration.declared).toBe(false); + expect(err!.declaration.source).toBe('default'); + }); + + it('walled + EXPLICIT invite-only → boots', async () => { + posture('isolated'); + const { ctx } = makeCtx({ + authPolicy: 'invite-only', + settings: { source: 'env', value: 'invite-only' }, + }); + expect(await refusal(ctx)).toBeNull(); + }); + + it('walled + EXPLICIT auto → boots (knowingly accepting auto-join is a decision)', async () => { + posture('isolated'); + const { ctx } = makeCtx({ authPolicy: 'auto', settings: { source: 'env', value: 'auto' } }); + + // The effective policy, the resulting behavior, and the sign-up outcome are + // all IDENTICAL to the refused case above. Only the declaration differs — + // which is the entire content of this issue. + expect(await refusal(ctx)).toBeNull(); + }); + + it('single posture + NEVER CONFIGURED → boots (no check, no behavior change)', async () => { + posture('single'); + const { ctx, services } = makeCtx({ + authPolicy: 'auto', + settings: { source: 'default', value: 'auto' }, + }); + + expect(await refusal(ctx)).toBeNull(); + // Not merely "did not throw": an unwalled deployment must not even be + // interrogated, so the settings namespace is never read. + expect((services.settings as { getNamespace: ReturnType }).getNamespace) + .not.toHaveBeenCalled(); + }); +}); + +// ── Posture coverage ───────────────────────────────────────────────────────── + +describe('which postures the gate covers', () => { + it('refuses under `group` as well as `isolated` — both are walls', async () => { + posture('group'); + const { ctx } = makeCtx({ authPolicy: 'auto', settings: { source: 'default', value: 'auto' } }); + const err = await refusal(ctx); + expect(err).not.toBeNull(); + expect(err!.posture).toBe('group'); + }); + + it('covers the legacy boolean pathway — OS_MULTI_ORG_ENABLED=true resolves to a wall', async () => { + // ADR-0105 D1 demoted the boolean to a FALLBACK for the posture, so it + // still expresses the same request. A gate that keyed off + // `OS_TENANCY_POSTURE` being set would miss every deployment on the old + // knob — cloud#1020's hole, verbatim. + posture(undefined); + const saved = process.env.OS_MULTI_ORG_ENABLED; + process.env.OS_MULTI_ORG_ENABLED = 'true'; + try { + const { ctx } = makeCtx({ authPolicy: 'auto', settings: { source: 'default', value: 'auto' } }); + expect(await refusal(ctx)).not.toBeNull(); + } finally { + if (saved === undefined) delete process.env.OS_MULTI_ORG_ENABLED; + else process.env.OS_MULTI_ORG_ENABLED = saved; + } + }); + + it('a MALFORMED posture is not this gate\'s failure to report — it fails open', async () => { + // `resolveTenancyPosture()` throws on garbage. Replacing the framework's + // own "that is not a posture" refusal with a membership-policy lecture + // would send the operator to the wrong knob. + posture('isolatedd'); + const { ctx } = makeCtx({ authPolicy: 'auto', settings: { source: 'default', value: 'auto' } }); + expect(await refusal(ctx)).toBeNull(); + }); +}); + +// ── What counts as a declaration ───────────────────────────────────────────── + +describe('what counts as a declaration', () => { + it.each(['global', 'tenant', 'user'] as const)( + 'a STORED row (source: %s) is a declaration — Setup → Authentication → Membership counts', + async (source) => { + posture('isolated'); + // Deliberately paired with an auth manager still reporting `auto`: the + // settings binding and this gate both run at boot, and hook order is not + // ours to depend on. A deployment that configured the policy through the + // UI must not be refused because we asked the manager a beat too early. + const { ctx } = makeCtx({ authPolicy: 'auto', settings: { source, value: 'invite-only' } }); + expect(await refusal(ctx)).toBeNull(); + }, + ); + + it('an AuthPlugin CONSTRUCTION-time policy is a declaration, with no setting involved', async () => { + // `service-cloud`'s control-plane preset declares `invite-only` where it + // constructs AuthPlugin (cloud#957/#962). No `sys_setting` row exists, so + // the source is `default` — and refusing that would be a false refusal of a + // deployment that declared the policy in code. + posture('isolated'); + const { ctx } = makeCtx({ + authPolicy: 'invite-only', + settings: { source: 'default', value: 'auto' }, + }); + expect(await refusal(ctx)).toBeNull(); + }); + + it('a DECLARED value outside the closed vocabulary refuses — it never took effect', async () => { + // `OS_AUTH_MEMBERSHIP_POLICY` bypasses the settings option table, so a typo + // reaches the runtime. The framework rejects it and keeps running `auto` + // (it does not coerce), which leaves the operator believing the wall + // decides membership while every sign-up is auto-join eligible. + posture('isolated'); + const { ctx } = makeCtx({ + authPolicy: 'auto', + settings: { source: 'env', value: 'invite_only' }, + }); + + const err = await refusal(ctx); + expect(err).not.toBeNull(); + expect(err!.declaration.invalid).toBe(true); + expect(err!.message).toContain('invite_only'); + // The headline must NOT claim the policy was never declared — the operator + // knows they set it, and would go looking for a second, missing setting. + expect(err!.message).toContain('is not one this runtime can enforce'); + expect(err!.message).not.toContain('never declared a membership policy'); + }); + + it('an invalid setting refuses even when construction set invite-only', async () => { + // Configuration saying one thing while the runtime does another IS the + // defect. Letting the construction-time value paper over it would restore + // exactly the "declared but unenforced" state ADR-0049 rules out. + posture('isolated'); + const { ctx } = makeCtx({ + authPolicy: 'invite-only', + settings: { source: 'global', value: 'INVITE-ONLY' }, + }); + expect(await refusal(ctx)).not.toBeNull(); + }); + + it('no `auth` service → skipped: nothing creates memberships, so nothing to declare', async () => { + posture('isolated'); + const { ctx, services } = makeCtx({ settings: { source: 'default', value: 'auto' } }); + expect(services.auth).toBeUndefined(); + expect(await refusal(ctx)).toBeNull(); + }); + + it('auth present but the settings namespace is UNREADABLE → refuses', async () => { + // `OS_AUTH_MEMBERSHIP_POLICY` is inert on a kernel with no settings + // service, so the effective policy is whatever AuthPlugin was constructed + // with — `auto` here, unfixable by configuration. Fail closed, and say so + // rather than pointing at an env variable that would do nothing. + posture('isolated'); + const { ctx } = makeCtx({ authPolicy: 'auto', settings: 'absent' }); + + const err = await refusal(ctx); + expect(err).not.toBeNull(); + expect(err!.declaration.source).toBe('unreadable'); + expect(err!.message).toContain('@objectstack/service-settings'); + // The env remedy would be a lie here — it must NOT be offered. + expect(err!.message).not.toContain(`${MEMBERSHIP_POLICY_ENV}=invite-only`); + }); + + it.each([ + ['the namespace read throws', 'throws' as const], + ['the namespace carries no membership_policy key', 'no-key' as const], + ])('unreadable: %s', async (_label, settings) => { + posture('isolated'); + const { ctx } = makeCtx({ authPolicy: 'auto', settings }); + const err = await refusal(ctx); + expect(err).not.toBeNull(); + expect(err!.declaration.source).toBe('unreadable'); + }); +}); + +// ── The reader in isolation ────────────────────────────────────────────────── + +describe('readMembershipPolicyDeclaration', () => { + it('reports source and validity without judging them', async () => { + const settings = { + getNamespace: async () => ({ + values: { membership_policy: { value: 'invite-only', source: 'env' } }, + }), + }; + await expect(readMembershipPolicyDeclaration(settings)).resolves.toEqual({ + declared: true, + invalid: false, + source: 'env', + configured: 'invite-only', + }); + }); + + it('never throws — an exploding settings service becomes `unreadable`', async () => { + const settings = { + getNamespace: async () => { + throw new Error('boom'); + }, + }; + const decl = await readMembershipPolicyDeclaration(settings); + expect(decl.source).toBe('unreadable'); + expect(decl.declared).toBe(false); + expect(decl.readError).toContain('boom'); + }); + + it('treats a missing `source` as `default` — an absent verdict is not a declaration', async () => { + const settings = { + getNamespace: async () => ({ values: { membership_policy: { value: 'auto' } } }), + }; + const decl = await readMembershipPolicyDeclaration(settings); + expect(decl.source).toBe('default'); + expect(decl.declared).toBe(false); + }); +}); + +// ── The refusal itself ─────────────────────────────────────────────────────── + +describe('the refusal', () => { + const undeclared: MembershipPolicyDeclaration = { + declared: false, + invalid: false, + source: 'default', + configured: 'auto', + }; + + it('is structurally recognisable across module instances', async () => { + posture('isolated'); + const { ctx } = makeCtx({ authPolicy: 'auto' }); + const err = await refusal(ctx); + expect(isWalledMembershipPolicyError(err)).toBe(true); + expect((err as { code?: string }).code).toBe(MEMBERSHIP_POLICY_ERROR_CODE); + // A plain object with the right `code` is recognised too — that is the + // point of a structural tag (hosts import this package dynamically). + expect(isWalledMembershipPolicyError({ code: MEMBERSHIP_POLICY_ERROR_CODE })).toBe(true); + expect(isWalledMembershipPolicyError(new Error('nope'))).toBe(false); + }); + + // The commercial runtime's copy of this case also asserted + // `isMultiOrgLicenseError(err) === false`. That discriminator lives with the + // licence gate, which stayed in cloud (ADR-0132 boundary 1), so the open copy + // keeps the half that is about THIS refusal: its message rules the licensing + // remedy out in its own first sentences, which is what an operator reads. + it('rules out the licensing and missing-package remedies in its own words', async () => { + posture('isolated'); + const { ctx } = makeCtx({ authPolicy: 'auto' }); + const err = await refusal(ctx); + expect(err!.message).toMatch(/NOT a licensing failure/); + expect(err!.message).toMatch(/NOT a missing package/i); + }); + + it('names the posture, both remedies, and the knob — an operator can act on it alone', () => { + const msg = walledMembershipPolicyFatalMessage('isolated', undeclared); + expect(msg).toContain("tenancy posture 'isolated' is walled"); + expect(msg).toContain(MEMBERSHIP_POLICY_SETTING); + expect(msg).toContain(`${MEMBERSHIP_POLICY_ENV}=invite-only`); + expect(msg).toContain(`${MEMBERSHIP_POLICY_ENV}=auto`); + expect(msg).toContain('Setup → Authentication →'); + expect(msg).toContain('OS_TENANCY_POSTURE=single'); + }); + + it('offers BOTH values — the gate forces a decision, it does not make one', () => { + const msg = walledMembershipPolicyFatalMessage('group', undeclared); + // If this ever reads as "set invite-only", the gate has quietly become a + // product opinion instead of a declaration requirement. + expect(msg).toContain('EITHER is a declaration'); + expect(msg).toContain('knowingly accept automatic joins'); + }); + + it('refuses to let OS_ALLOW_DEGRADED_TENANCY look like a way past', () => { + const msg = walledMembershipPolicyFatalMessage('isolated', undeclared); + expect(msg).toMatch(/OS_ALLOW_DEGRADED_TENANCY does NOT apply/); + }); + + it('reports the source verbatim so "why was I refused" is answerable from the message', () => { + expect(walledMembershipPolicyFatalMessage('isolated', undeclared)).toContain( + 'source: default — nobody configured it', + ); + }); +}); + +// ── Wiring: the plugin's own boot hook ─────────────────────────────────────── + +describe('OrganizationsPlugin boot wiring', () => { + function pluginCtx(fixture: CtxFixture) { + const baseSchema = { name: 'task', fields: { id: { name: 'id' } } }; + const ql = { + registerMiddleware: vi.fn(), + getSchema: () => baseSchema, + find: vi.fn(async () => []), + insert: vi.fn(async () => ({ id: 'x' })), + }; + return makeCtx({ + ...fixture, + services: { objectql: ql, metadata: { get: async () => baseSchema }, manifest: { register: vi.fn() } }, + }); + } + + it('registers the gate on `kernel:bootstrapped`, not `kernel:ready`', async () => { + // `kernel:ready` is where SettingsServicePlugin late-binds its DATA ENGINE, + // and hook order is registration order — reading there can miss a STORED + // row and refuse a deployment that did configure the policy. + // `kernel:bootstrapped` fires after every `kernel:ready` handler has + // settled, and still BEFORE `kernel:listening` opens the socket. + posture('isolated'); + const plugin = new OrganizationsPlugin(); + const { ctx, hooks } = pluginCtx({ authPolicy: 'auto' }); + await plugin.start(ctx as never); + expect(hooks.get('kernel:bootstrapped') ?? []).toHaveLength(1); + }); + + it('the registered hook refuses an undeclared walled boot', async () => { + posture('isolated'); + const plugin = new OrganizationsPlugin(); + const { ctx } = pluginCtx({ authPolicy: 'auto', settings: { source: 'default', value: 'auto' } }); + await plugin.start(ctx as never); + await expect(ctx.trigger('kernel:bootstrapped')).rejects.toThrow(WalledMembershipPolicyError); + }); + + it('the registered hook lets a declared walled boot through', async () => { + posture('isolated'); + const plugin = new OrganizationsPlugin(); + const { ctx } = pluginCtx({ + authPolicy: 'invite-only', + settings: { source: 'env', value: 'invite-only' }, + }); + await plugin.start(ctx as never); + await expect(ctx.trigger('kernel:bootstrapped')).resolves.toBeUndefined(); + }); + + it('start() registers the gate BEFORE it can return early on a missing engine', async () => { + // The wall is being mounted whether or not ObjectQL turned up, so an early + // return must not be able to skip the declaration check. + posture('isolated'); + const plugin = new OrganizationsPlugin(); + const { ctx, hooks } = makeCtx({ authPolicy: 'auto' }); // no `objectql` service + await plugin.start(ctx as never); + expect(hooks.get('kernel:bootstrapped') ?? []).toHaveLength(1); + await expect(ctx.trigger('kernel:bootstrapped')).rejects.toThrow(WalledMembershipPolicyError); + }); + + it('a kernel with no hook seam is checked INLINE rather than not at all', async () => { + posture('isolated'); + const plugin = new OrganizationsPlugin(); + const { ctx } = pluginCtx({ authPolicy: 'auto' }); + const hookless = { ...ctx, hook: undefined }; + await expect(plugin.start(hookless as never)).rejects.toThrow(WalledMembershipPolicyError); + }); + + it('leaves a single-posture boot completely untouched', async () => { + posture('single'); + const plugin = new OrganizationsPlugin(); + const { ctx } = pluginCtx({ authPolicy: 'auto' }); + await plugin.start(ctx as never); + await expect(ctx.trigger('kernel:bootstrapped')).resolves.toBeUndefined(); + }); +}); diff --git a/packages/plugins/organizations/src/membership-policy-gate.ts b/packages/plugins/organizations/src/membership-policy-gate.ts new file mode 100644 index 0000000000..b739464979 --- /dev/null +++ b/packages/plugins/organizations/src/membership-policy-gate.ts @@ -0,0 +1,437 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Walled-posture MEMBERSHIP-POLICY gate (cloud#1092) — a deployment that puts +// up the organization wall must SAY what a new user joins. +// +// ## What went wrong before +// +// On a walled posture (`isolated` / `group`) a deployment that never +// configured `auth.membership_policy` runs the framework default, `auto`. +// `auto` means "bind every newly created user to this deployment's default +// organization". Today that binds nobody — but only as a SIDE EFFECT: under a +// wall `TenancyService.defaultOrgId()` refuses to guess a target org (ADR-0093 +// D3), so the reconciler answers `no-target-org` and writes nothing. +// +// The end state is right and was never declared. `no-target-org` and +// `policy-skip` look identical from outside, so "the new user has no +// organization" is green on an undeclared deployment and locks in nothing. The +// posture resolution underneath has already regressed once (cloud#957 / #962: +// self-serve sign-ups landed as `member` of a stranger's organization, able to +// list and open that org's environments), and the day it degrades again the +// same undeclared deployment starts auto-joining strangers — silently, because +// nothing ever claimed otherwise. cloud#1012 raised exactly this; PR #1091 made +// the policy configurable and left the undeclared case open. +// +// ## The shape +// +// Refuse to boot. Not warn: warnings on a boot screen are read once and then +// scrolled past, and the maintainer's ruling on cloud#1092 (2026-08-04) is that +// `declared = enforced` lands in one step rather than through a warn → refuse +// migration nobody would be around to complete. +// +// That ruling was argued from EE having no deployed customers — zero breakage +// surface — and that argument does NOT transfer to open core. The conclusion +// does, from a fact anyone can check instead: on the day this gate arrived in +// the open tree, no open installation could reach a walled posture at all. +// `serve` treats every posture but `single` as multi-tenant and refuses the +// boot when no `org-scoping` runtime is present (ADR-0093 D5); the only way +// past that refusal was `OS_ALLOW_DEGRADED_TENANCY=1`, which leaves the runtime +// unmounted — so this gate never ran on any open deployment either. The +// breakage surface at the moment of the move is genuinely zero, and it is zero +// for a reason a reader can re-derive from `serve.ts` rather than from a +// customer count. ADR-0132 is the record. +// +// The gate lives HERE, in the package that implements the wall, for the same +// reason cloud#1020's licensing gate does: the component that needs the +// guarantee answers for it, so no host can forget it and no new knob can route +// around it. That reasoning is about the WALL, not about the entitlement — the +// licence gate stays in the commercial runtime (ADR-0132 boundary 1), and this +// one moved with the wall it guards. +// +// ## What counts as a declaration +// +// DECLARED vs DEFAULTED is the whole question, so the gate reads the settings +// service's own `source` for `auth.membership_policy` rather than its value: +// +// • `env` (`OS_AUTH_MEMBERSHIP_POLICY`) or a stored row (`global` / `tenant` +// / `user`) — an operator wrote it. ALLOWED, including explicit `auto`: +// knowingly accepting automatic joins is a decision, and this gate exists +// to force the decision, not to pick it. +// • `default` — the manifest default (`auto`) nobody chose. REFUSED. +// • a declared value outside the closed vocabulary (a typo'd `invite_only` +// through the env, which bypasses the settings option table) — REFUSED. +// The framework logs an error and keeps running `auto`, so the operator +// believes the wall is up while every sign-up is auto-bound. That is the +// failure this gate is for, wearing a different hat. +// +// A host may also declare the policy where it constructs `AuthPlugin` +// (`membershipPolicy: 'invite-only'` — what the cloud control plane's preset +// does). No setting is involved there, so the gate accepts any EFFECTIVE +// policy that is not `auto`: a non-default policy cannot arrive by accident. +// +// ## Deliberate NON-features +// +// • **No env escape hatch.** `OS_ALLOW_DEGRADED_TENANCY=1` does NOT open this +// gate (it covers an ABSENT multi-org runtime, ADR-0093 D5), and neither +// does a licence. The remedy for this refusal is to configure the policy — +// which is one env variable or one Setup toggle away, on purpose. +// • **No `single`-posture check.** An unwalled deployment has one +// organization; `auto` there is the documented, harmless product default. +// Non-walled postures see no check and no behavior change at all. + +import { isMembershipPolicy, MEMBERSHIP_POLICIES } from '@objectstack/plugin-auth'; +import { resolveTenancyPosture } from '@objectstack/types'; + +/** The settings key this gate adjudicates, in the framework's `auth` namespace. */ +export const MEMBERSHIP_POLICY_SETTING = 'auth.membership_policy'; + +/** The env pathway the settings service accepts for {@link MEMBERSHIP_POLICY_SETTING}. */ +export const MEMBERSHIP_POLICY_ENV = 'OS_AUTH_MEMBERSHIP_POLICY'; + +/** Stable, cross-module-instance discriminator for this refusal. */ +export const MEMBERSHIP_POLICY_ERROR_CODE = 'WALLED_MEMBERSHIP_POLICY_UNDECLARED'; + +/** + * A refusal on DECLARATION grounds — deliberately distinct from cloud#1020's + * `MULTI_ORG_NOT_LICENSED` (authorization) and from ADR-0093 D5's + * "the multi-org runtime could not be loaded" (capability absence). All + * three refuse the same boot for different reasons with different remedies, so + * every message says which one it is in its first sentence. + */ +export class WalledMembershipPolicyError extends Error { + /** Structural tag — survives duplicate module instances, unlike `instanceof`. */ + readonly code = MEMBERSHIP_POLICY_ERROR_CODE; + + /** The walled posture this deployment requested. */ + readonly posture: string; + + /** What the gate found, for hosts that want to re-report it structurally. */ + readonly declaration: MembershipPolicyDeclaration; + + constructor(message: string, posture: string, declaration: MembershipPolicyDeclaration) { + super(message); + this.name = 'WalledMembershipPolicyError'; + this.posture = posture; + this.declaration = declaration; + } +} + +/** + * Whether an unknown error is this gate's refusal. + * + * Structural (`code`), not `instanceof`: hosts reach this package through a + * dynamic `import()` and may hold a different module instance than the thrower + * — a multi-kernel host that imports this package per environment is exactly + * that case. + */ +export function isWalledMembershipPolicyError(err: unknown): boolean { + return ( + typeof err === 'object' && + err !== null && + (err as { code?: unknown }).code === MEMBERSHIP_POLICY_ERROR_CODE + ); +} + +/** + * Where the configured value came from. The first four are the settings + * service's own `source` values; `unreadable` is this gate's own verdict when + * the `auth` namespace could not be consulted at all. + */ +export type MembershipPolicySource = 'env' | 'global' | 'tenant' | 'user' | 'default' | 'unreadable'; + +export interface MembershipPolicyDeclaration { + /** True when an operator wrote the value (env or a stored row). */ + declared: boolean; + /** True when a DECLARED value is outside {@link MEMBERSHIP_POLICIES}. */ + invalid: boolean; + /** Where the value came from. */ + source: MembershipPolicySource; + /** The raw configured value, exactly as the settings service reports it. */ + configured: unknown; + /** Why the namespace could not be read — set only when `source` is `unreadable`. */ + readError?: string; +} + +/** The slice of the settings service this gate needs. Structural on purpose. */ +export interface MembershipPolicySettingsSurface { + getNamespace( + namespace: string, + ctx?: unknown, + ): Promise<{ values?: Record }>; +} + +/** The slice of the `auth` service this gate needs (plugin-auth's AuthManager). */ +export interface MembershipPolicyAuthSurface { + getMembershipPolicy(): string; +} + +/** The slice of `PluginContext` this gate needs. */ +export interface MembershipPolicyProbe { + getService(name: string): unknown; + // `meta` is `Record` to stay assignable from the framework's + // `PluginContext['logger']`, whose parameter is exactly that — a narrower + // `unknown` here would make every real ctx fail to satisfy this interface. + logger?: { + info?: (msg: string, meta?: Record) => void; + warn?: (msg: string, meta?: Record) => void; + }; +} + +/** `ctx.getService`, without the throw — an absent service is a normal answer here. */ +function probeService(ctx: MembershipPolicyProbe, name: string): T | undefined { + try { + return (ctx.getService(name) as T | undefined) ?? undefined; + } catch { + return undefined; + } +} + +/** + * Read what this deployment DECLARED for {@link MEMBERSHIP_POLICY_SETTING}. + * + * Total — never throws. `unreadable` is a distinct outcome from `default` on + * purpose: "nobody chose" and "we could not find out" need different messages, + * because on an unreadable kernel `OS_AUTH_MEMBERSHIP_POLICY` is inert and + * telling the operator to set it would be a lie. + */ +export async function readMembershipPolicyDeclaration( + settings: MembershipPolicySettingsSurface | undefined, +): Promise { + const unreadable = (readError: string): MembershipPolicyDeclaration => ({ + declared: false, + invalid: false, + source: 'unreadable', + configured: undefined, + readError, + }); + + if (!settings || typeof settings.getNamespace !== 'function') { + return unreadable('no `settings` service is registered on this kernel'); + } + + let payload: Awaited>; + try { + payload = await settings.getNamespace('auth'); + } catch (e) { + return unreadable(`reading the \`auth\` settings namespace failed: ${(e as Error)?.message ?? e}`); + } + + const entry = payload?.values?.membership_policy; + if (!entry) { + return unreadable( + 'the `auth` settings namespace does not carry a `membership_policy` key on this kernel', + ); + } + + const source = String(entry.source ?? 'default') as MembershipPolicySource; + const declared = source !== 'default'; + return { + declared, + invalid: declared && !isMembershipPolicy(entry.value), + source, + configured: entry.value, + }; +} + +/** Why the gate refused — selects the middle paragraphs of the message. */ +type RefusalReason = 'undeclared' | 'invalid' | 'unreadable'; + +function refusalReason(declaration: MembershipPolicyDeclaration): RefusalReason { + if (declaration.source === 'unreadable') return 'unreadable'; + if (declaration.invalid) return 'invalid'; + return 'undeclared'; +} + +/** + * The operator-facing refusal, in cloud#1020's licensing-gate register. Every + * block is load-bearing: + * + * - the first sentence names DECLARATION as the failure, and rules out the two + * neighbouring refusals (licence, missing package) outright — an operator who + * has met either of those will otherwise reach for their remedies first; + * - it explains why an outcome that looks correct today is still refused, + * because "but no user is being auto-joined" is the obvious objection; + * - it lists BOTH ways forward, `auto` included, so it reads as "decide", not + * as "we picked invite-only for you". + */ +export function walledMembershipPolicyFatalMessage( + posture: string, + declaration: MembershipPolicyDeclaration, +): string { + const reason = refusalReason(declaration); + const vocabulary = MEMBERSHIP_POLICIES.join(' | '); + + const found: string[] = + reason === 'unreadable' + ? [ + ` membership policy : UNKNOWN — ${declaration.readError}`, + ` setting key : ${MEMBERSHIP_POLICY_SETTING} (env: ${MEMBERSHIP_POLICY_ENV})`, + ` tenancy posture : ${posture}`, + ] + : [ + ` membership policy : ${JSON.stringify(declaration.configured)} (source: ${declaration.source}${declaration.source === 'default' ? ' — nobody configured it' : ''})`, + ` setting key : ${MEMBERSHIP_POLICY_SETTING} (env: ${MEMBERSHIP_POLICY_ENV})`, + ` tenancy posture : ${posture}`, + ]; + + const why: string[] = + reason === 'invalid' + ? [ + ` ${JSON.stringify(declaration.configured)} is not a membership policy. The closed vocabulary is`, + ` ${vocabulary} (framework \`MEMBERSHIP_POLICIES\`), and the framework REJECTS an`, + ' unrecognised value rather than coercing it — so this deployment is running `auto`', + ' while its configuration says something else. An operator reading that configuration', + ' believes the wall decides membership; every new user is auto-join eligible instead.', + ' Refusing to boot on a policy that was set but never took effect.', + ] + : reason === 'unreadable' + ? [ + ' A walled deployment decides what a NEW USER joins, and this kernel cannot be asked', + ' what it decided — so the answer in force is the framework default, `auto`: bind every', + ' newly created user to the default organization. Refusing to boot on a wall whose', + ' membership rule cannot be established.', + ] + : [ + ' A walled deployment decides what a NEW USER joins. This one has not: the effective', + ' policy is `auto` — bind every newly created user to this deployment\'s default', + ' organization — purely because nothing ever set it.', + '', + ' That binds nobody TODAY, but only as a side effect: under a wall the framework', + ' refuses to guess a target organization (ADR-0093 D3), so the reconciler answers', + ' `no-target-org` and writes nothing. An outcome, not a declaration — and the', + // The tracker ids this sentence used to carry (cloud#957 / #962) are + // in the header instead — an operator reading a boot refusal has no + // tracker to resolve them against. + ' resolution underneath has already regressed once: self-serve sign-ups landed', + ' inside a stranger\'s organization. The day it degrades again, this', + ' deployment starts auto-joining strangers without anything having claimed otherwise.', + ]; + + const remedy: string[] = + reason === 'unreadable' + ? [ + ' Fix one of:', + ' • mount the platform settings service (@objectstack/service-settings) so', + ` ${MEMBERSHIP_POLICY_SETTING} — and ${MEMBERSHIP_POLICY_ENV} — resolve here, then`, + ' declare the policy, or', + ' • declare it where this host constructs AuthPlugin:', + " `new AuthPlugin({ membershipPolicy: 'invite-only' })` (what the cloud control", + ' plane does), or', + ' • run single-organization: set OS_TENANCY_POSTURE=single.', + ] + : [ + ' Fix one of — EITHER is a declaration, and that is the entire point:', + ' • invitation only, what a walled deployment almost always wants: set', + ` ${MEMBERSHIP_POLICY_ENV}=invite-only, or Setup → Authentication →`, + ' Membership → "Invitation only". Membership then comes solely from an explicit', + ' act — creating a workspace, accepting an invitation, an admin adding you, SSO', + ' just-in-time provisioning; or', + ` • knowingly accept automatic joins: set ${MEMBERSHIP_POLICY_ENV}=auto`, + ' (or pick "Join the default organization automatically" in Setup). Boot then', + ' proceeds with exactly the behavior above — declared this time, so it survives', + ' a change in how the posture resolves; or', + ' • run single-organization: set OS_TENANCY_POSTURE=single, where `auto` is the', + ' documented product default and this gate does not apply.', + ]; + + // The headline states which of the three refusals this is. "Never declared" + // would be a lie on the other two, and an operator who typo'd the env value + // would spend the first minute looking for a setting they know they set. + const headline = + reason === 'invalid' + ? `✖ FATAL: tenancy posture '${posture}' is walled, and its membership policy is not one this runtime can enforce.` + : reason === 'unreadable' + ? `✖ FATAL: tenancy posture '${posture}' is walled, and its membership policy cannot be established on this kernel.` + : `✖ FATAL: tenancy posture '${posture}' is walled, but this deployment never declared a membership policy.`; + + return [ + headline, + '', + ...why, + '', + ' This is NOT a licensing failure and NOT a missing package: the multi-org runtime is', + ' present and about to enforce the wall. Do not chase either.', + '', + ...found, + '', + ...remedy, + '', + // ⛔ No tracker id in the rendered text (`pnpm check:doc-authoring`): an + // operator reading a boot refusal cannot resolve `#NNNN`. The provenance + // lives in this file's header, which the reader who CAN resolve it reads. + ' OS_ALLOW_DEGRADED_TENANCY does NOT apply here — it covers an ABSENT multi-org runtime', + ' (ADR-0093 D5), never a present one asking to be configured.', + ].join('\n'); +} + +/** + * Throw unless a walled deployment has DECLARED its membership policy. + * + * Ordering, in the order the checks appear: + * + * 1. Non-walled posture → return. `single` sees no check and no behavior + * change; `resolveTenancyPosture()` throwing (malformed knob) is NOT this + * gate's failure to report, so it fails open here and the framework's own + * posture validation reports it. + * 2. No `auth` service → return. The membership reconciler lives in + * plugin-auth; with no plugin-auth mounted nothing creates memberships + * automatically, so there is no policy to declare and nothing to refuse. + * 3. A DECLARED, valid setting → return, whichever of the two values it is. + * 4. An effective policy that is not `auto` → return: the host declared it at + * AuthPlugin construction, and a non-default policy cannot arrive by + * accident. Checked AFTER the setting so a declared-but-invalid setting + * still refuses even when construction happens to have set `invite-only` — + * configuration that says one thing while the runtime does another is the + * defect, not a detail. + * 5. Otherwise → refuse. + */ +export async function assertWalledMembershipPolicyDeclared( + ctx: MembershipPolicyProbe, + posture?: string, +): Promise { + let requested: string; + if (posture !== undefined) { + requested = posture; + } else { + try { + requested = resolveTenancyPosture(); + } catch { + // A malformed OS_TENANCY_POSTURE is the framework's refusal to make, and + // it makes it. Reporting it from here would blame the wrong knob. + return; + } + } + if (requested === 'single') return; + + const auth = probeService(ctx, 'auth'); + if (!auth || typeof auth.getMembershipPolicy !== 'function') { + ctx.logger?.info?.( + // Same rule as the fatal message above: no tracker id in the rendered + // string. This gate's provenance is cloud#1092, recorded in the header. + '[org-scoping] membership-policy gate skipped: no `auth` service on this kernel, so nothing ' + + 'creates memberships automatically and there is no policy to declare', + { posture: requested }, + ); + return; + } + + const declaration = await readMembershipPolicyDeclaration( + probeService(ctx, 'settings'), + ); + + if (declaration.declared && !declaration.invalid) return; + + let effective: string | undefined; + try { + effective = auth.getMembershipPolicy(); + } catch { + effective = undefined; + } + if (!declaration.invalid && effective !== undefined && effective !== 'auto') return; + + throw new WalledMembershipPolicyError( + walledMembershipPolicyFatalMessage(requested, declaration), + requested, + declaration, + ); +} diff --git a/packages/plugins/organizations/src/no-framework-dependents.pin.test.ts b/packages/plugins/organizations/src/no-framework-dependents.pin.test.ts new file mode 100644 index 0000000000..9aca54cdd8 --- /dev/null +++ b/packages/plugins/organizations/src/no-framework-dependents.pin.test.ts @@ -0,0 +1,148 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// ⭐ The mechanical half of ADR-0132's entitlement boundary. +// +// This package and the commercial multi-org runtime share one package name, +// `@objectstack/organizations`, and that is the design: the commercial one is a +// private workspace package whose class `extends` this one and calls its licence +// gate in its own constructor. Which class a deployment mounts is decided by the +// manifest that DECLARES the name — `workspace:*` in every commercial host +// (pnpm's `workspace:` protocol resolves only to the local package and cannot +// fall through to the registry), the npm copy for an open install, and in both +// cases through `objectstack serve`'s host-anchored importer, which refuses a +// package the served app has not declared (#4719). +// +// ## What can break that, and what this file refuses +// +// Exactly one thing: a FRAMEWORK package taking `@objectstack/organizations` as +// its own dependency. The commercial repo consumes the framework by `link:`, so +// such a dependency would install THIS package — the ungated one — inside the +// framework tree a commercial app links against, reachable from framework code +// by a bare `import()` that never consults the app's manifest. The entitlement +// would then be bypassed not by a defect in the gate but by resolution picking +// the other class with the same name, which is the worst outcome this whole +// move has available and the one least likely to be noticed in review. +// +// ⛔ So: no workspace package may declare `@objectstack/organizations`, in any +// of the four dependency fields, ever. Apps declare it; packages do not. Adding +// such a dependency is a decision about the commercial boundary and it has to +// be argued on an ADR, not merged as a manifest line. +// +// The prohibition is asymmetric on purpose and this file does NOT say the +// converse: this package may depend on framework packages freely (it depends on +// four), because that direction puts nothing ungated anywhere new. + +import { describe, it, expect } from 'vitest'; +import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs'; +import { dirname, join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +/** The repo root — the directory carrying `pnpm-workspace.yaml`. */ +function findUp(predicate: (dir: string) => boolean): string { + let dir = HERE; + for (;;) { + if (predicate(dir)) return dir; + const parent = dirname(dir); + if (parent === dir) throw new Error('reached the filesystem root without a match'); + dir = parent; + } +} + +const REPO = findUp((dir) => existsSync(join(dir, 'pnpm-workspace.yaml'))); + +const SELF = '@objectstack/organizations'; + +/** The fields whose KEYS count as a declaration — `HOST_DECLARATION_FIELDS`. */ +const DEP_FIELDS = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']; + +/** + * The workspace globs that hold PACKAGES, i.e. the publishable/library tier. + * + * `apps/*` and `examples/*` are deliberately excluded: those ARE hosts, and a + * host declaring the runtime it wants to mount is the supported wiring — the + * very act `serve`'s host-anchored importer is built around. Narrowing the + * population to `packages/**` is what keeps this pin about the hazard rather + * than about all uses of the name. + */ +const PACKAGE_ROOTS = [ + 'packages', + 'packages/apps', + 'packages/drivers', + 'packages/plugins', + 'packages/qa', + 'packages/triggers', + 'packages/services', + 'packages/adapters', + 'packages/connectors', +]; + +function workspacePackageManifests(): { dir: string; name: string; manifest: Record }[] { + const out: { dir: string; name: string; manifest: Record }[] = []; + for (const root of PACKAGE_ROOTS) { + const abs = join(REPO, root); + if (!existsSync(abs)) continue; + for (const entry of readdirSync(abs)) { + const dir = join(abs, entry); + if (!statSync(dir).isDirectory()) continue; + const manifestPath = join(dir, 'package.json'); + if (!existsSync(manifestPath)) continue; + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as Record; + out.push({ dir: relative(REPO, dir), name: String(manifest.name ?? entry), manifest }); + } + } + return out; +} + +describe('ADR-0132 boundary: no framework package depends on @objectstack/organizations', () => { + // ── The anti-vacuity control, first. A walk that found nothing would pass + // the pin below while proving nothing at all, and "the glob stopped + // matching" is the silent way this file dies. So assert the population is + // real and that it contains this package itself. + it('walks a real population that includes this package', () => { + const manifests = workspacePackageManifests(); + expect(manifests.length).toBeGreaterThan(40); + expect(manifests.map((m) => m.name)).toContain(SELF); + }); + + it('no workspace package declares it in any dependency field', () => { + const offenders: string[] = []; + for (const { dir, name, manifest } of workspacePackageManifests()) { + if (name === SELF) continue; // itself — nothing to declare + for (const field of DEP_FIELDS) { + const deps = manifest[field] as Record | undefined; + if (deps && Object.prototype.hasOwnProperty.call(deps, SELF)) { + offenders.push(`${dir} (${name}) → ${field}["${SELF}"] = ${deps[SELF]}`); + } + } + } + expect(offenders).toEqual([]); + }); + + // The control for the check above: the detector must actually find a + // declaration when one is present. Without this, an offenders list that is + // empty because the field lookup is broken reads exactly like compliance. + it('the detector finds a declaration when one exists', () => { + const planted = { dependencies: { [SELF]: 'workspace:*' } } as Record; + const found = DEP_FIELDS.filter((field) => { + const deps = planted[field] as Record | undefined; + return !!deps && Object.prototype.hasOwnProperty.call(deps, SELF); + }); + expect(found).toEqual(['dependencies']); + }); + + it('this package declares no licence-gate dependency of its own', () => { + const self = JSON.parse(readFileSync(join(HERE, '..', 'package.json'), 'utf8')) as Record< + string, + Record | undefined + >; + const all = DEP_FIELDS.flatMap((f) => Object.keys(self[f] ?? {})); + // The two commercial packages the closed runtime coupled to, named + // explicitly rather than by a substring guess: `security-enterprise` was + // the licence gate's import and the multi-node gate carrier, and the + // package must never re-acquire either. + expect(all).not.toContain('@objectstack/security-enterprise'); + expect(all.filter((d) => d.includes('license') || d.includes('entitle'))).toEqual([]); + }); +}); diff --git a/packages/plugins/organizations/src/org-creation-no-cross-org-copy.test.ts b/packages/plugins/organizations/src/org-creation-no-cross-org-copy.test.ts new file mode 100644 index 0000000000..62eb15d1ed --- /dev/null +++ b/packages/plugins/organizations/src/org-creation-no-cross-org-copy.test.ts @@ -0,0 +1,307 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * cloud#1345 — THE INVARIANT: a newly created organization's rows come from + * the APP's own seed definitions, or the organization starts EMPTY. They never + * come from another organization's data. + * + * What this replaces. `clone-org-seed-data-posture-gate.test.ts` (cloud#1006) + * pinned the behaviour of `cloneOrgSeedData` — a "Fallback B" that copied the + * FIRST organization's business rows into every subsequent one, gated OFF under + * the `group` posture and ON under `isolated`. That whole mechanism is retired + * here, so its suite goes with it rather than being re-spelled: its subject no + * longer exists. The maintainer's ruling (2026-08-16) denies the requirement, + * not merely the default: + * + * 「每个新组织注册时克隆第一个组织的全部业务行,没有这个需求啊, + * 比如 hotcrm seed 数据应该从代码中加载。」 + * + * What this suite pins instead is the DISCLOSURE SHAPE, permanently: two + * organizations, one database, and org #2 holding zero rows traceable to org + * #1. That assertion outlives any particular mechanism — it reddens whether a + * future clone comes back as a donor pattern, a template-org pattern, or an + * accident in the seed pipeline. + * + * ── The sentinel (why this test can fail) ───────────────────────────────── + * + * "org #2 has no rows from org #1" is worthless if the probe could not have + * seen them anyway. So org #1 writes a distinctive row AFTER its seed + * (`SENTINEL_NAME`), and every assertion below is preceded by proving the very + * same probe DOES surface that row when pointed at org #1. Only then is its + * absence from org #2 evidence of anything. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { OrganizationsPlugin } from './organizations-plugin.js'; +// The fake engines below open `update()` with `assertEngineUpdateDispatch` +// (`pnpm check:engine-double-contract`). A double looser than the real +// `ObjectQLEngine.update` is how a dead write path ships with its suite green; +// one call pins these fakes to the producer's rejection surface and, unlike a +// mirrored `if`, cannot drift when that rule changes. +import { assertEngineUpdateDispatch } from '@objectstack/metadata-core'; + +// ⛔ No entitlement grant, and none is needed: the open package has no licence +// gate (ADR-0132 boundary 3). The closed runtime's copy of this suite granted +// one because its constructor refused without it (cloud#1020). The subject +// here is unchanged — the seed pipeline and its no-donor-clone invariant. + +const ORG_ONE = 'org_customer_one'; +const ORG_TWO = 'org_customer_two'; + +/** Org #1's post-seed private row. Absent from any app seed definition. */ +const SENTINEL_NAME = 'CUSTOMER-ONE-PRIVATE-a3f19c7d'; + +/** What the app's own seed definitions produce, per organization. */ +const SEEDED_ACCOUNT_NAME = 'Acme Corporation'; + +const BUSINESS_OBJECTS = ['crm_account', 'crm_opportunity'] as const; + +/** + * A minimal in-memory ObjectQL stand-in: a registry of user-defined objects + * that declare `organization_id`, plus find/insert/update over a row store. + * Enough to observe exactly what the org-creation pipeline WROTE — which is + * the whole point of "assert org #2 holds nothing of org #1's". + */ +function makeFakeQl() { + const schemas = BUSINESS_OBJECTS.map((name) => ({ + name, + fields: { + id: { name: 'id', type: 'text' }, + organization_id: { name: 'organization_id', type: 'text' }, + name: { name: 'name', type: 'text' }, + amount: { name: 'amount', type: 'number' }, + }, + })); + + const store: Record[]> = { + sys_organization: [{ id: ORG_ONE, name: 'Customer One', created_at: '2026-01-01' }], + }; + for (const name of BUSINESS_OBJECTS) store[name] = []; + + const ql: any = { + registry: { getAllObjects: () => schemas }, + registerMiddleware: (_mw: any) => undefined, + getSchema: (name: string) => schemas.find((s) => s.name === name), + find: vi.fn(async (object: string, query: any = {}) => { + let rows = [...(store[object] ?? [])]; + const where = query?.where ?? {}; + for (const [k, v] of Object.entries(where)) rows = rows.filter((r) => r[k] === v); + if (typeof query?.limit === 'number') rows = rows.slice(0, query.limit); + return rows; + }), + insert: vi.fn(async (object: string, data: Record) => { + (store[object] ??= []).push({ ...data }); + return data; + }), + update: vi.fn(async (object: string, data: any, options?: any) => { + assertEngineUpdateDispatch(data, options); + const row = (store[object] ?? []).find((r) => r.id === data.id); + if (row) Object.assign(row, data); + return row; + }), + }; + + /** + * THE PROBE. Every row an object holds for one organization — the same + * surface used for the sentinel proof and for the absence assertions, so a + * probe that cannot see rows cannot silently pass the absence checks. + */ + const rowsFor = (object: string, orgId: string) => + (store[object] ?? []).filter((r) => r.organization_id === orgId); + + /** Every row of every business object held by one organization. */ + const allRowsFor = (orgId: string): Record[] => + BUSINESS_OBJECTS.flatMap((o) => + rowsFor(o, orgId).map((r): Record => ({ ...r, object: o })), + ); + + return { ql, store, rowsFor, allRowsFor }; +} + +function makeLogger() { + return { info: vi.fn(), warn: vi.fn() }; +} + +/** + * A stand-in for AppPlugin's registered `seed-replayer`: writes the APP's own + * seed definitions into whichever organization is being seeded. Deliberately + * ignorant of every other organization — that is the sanctioned mechanism's + * defining property, and what makes it donor-less. + */ +function makeAppSeedReplayer(ql: any) { + return vi.fn(async (organizationId: string) => { + await ql.insert('crm_account', { + id: `acc_${organizationId}`, + organization_id: organizationId, + name: SEEDED_ACCOUNT_NAME, + amount: 0, + }); + return { inserted: 1, updated: 0, skipped: 0, errors: [] as unknown[] }; + }); +} + +/** + * Drive the REAL plugin's Middleware B (the per-org seed pipeline) for a + * `sys_organization` insert — the exact seam org creation runs through. + * + * `services` decides which deployment shape is under test: register + * `seed-datasets` + `seed-replayer` for an app that ships seed definitions, + * register neither for one that does not (the shape where the retired clone + * used to fire). + */ +async function createOrganizationThroughPipeline( + ql: any, + newOrgId: string, + logger: { info: any; warn: any }, + extraServices: Record = {}, +) { + const plugin = new OrganizationsPlugin(); + const services: Record = { + manifest: { register: vi.fn() }, + objectql: ql, + metadata: { get: async () => undefined }, + ...extraServices, + }; + const middlewares: any[] = []; + ql.registerMiddleware = (mw: any) => middlewares.push(mw); + const ctx: any = { + logger, + // Park `kernel:ready` so the default-org bootstrap never fires: it is + // fire-and-forget and would race the row assertions below with writes that + // have nothing to do with the seed pipeline. + hook: vi.fn(), + registerService: (name: string, svc: any) => { + services[name] = svc; + }, + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + await plugin.init(ctx); + await plugin.start(ctx); + + // The org row itself, then the pipeline that fires off its insert. + await ql.insert('sys_organization', { id: newOrgId, name: newOrgId, created_at: '2026-02-01' }); + // middlewares[0] = organization_id auto-stamp, middlewares[1] = seed pipeline. + await middlewares[1]( + { + object: 'sys_organization', + operation: 'insert', + data: { id: newOrgId, name: newOrgId }, + result: { id: newOrgId }, + context: { isSystem: true }, + }, + async () => {}, + ); +} + +describe('cloud#1345 — a new organization never receives another organization\'s rows', () => { + let fake: ReturnType; + let logger: ReturnType; + + beforeEach(async () => { + fake = makeFakeQl(); + logger = makeLogger(); + + // Customer #1 is live: seeded demo rows PLUS the private row they created + // afterwards. Both are attributed to org #1, exactly as the wall requires. + await fake.ql.insert('crm_account', { + id: 'acc_one_seeded', + organization_id: ORG_ONE, + name: SEEDED_ACCOUNT_NAME, + amount: 0, + }); + await fake.ql.insert('crm_account', { + id: 'acc_one_private', + organization_id: ORG_ONE, + name: SENTINEL_NAME, + amount: 4_200, + }); + await fake.ql.insert('crm_opportunity', { + id: 'opp_one_private', + organization_id: ORG_ONE, + name: SENTINEL_NAME, + amount: 99_000, + }); + }); + + it('SENTINEL CONTROL: the probe DOES surface org #1\'s post-seed rows when pointed at org #1', () => { + // Without this, every absence assertion below would pass on a probe that + // sees nothing at all. Both objects, so the multi-object sweeps are covered. + const accounts = fake.rowsFor('crm_account', ORG_ONE); + const opportunities = fake.rowsFor('crm_opportunity', ORG_ONE); + expect(accounts.map((r) => r.name)).toContain(SENTINEL_NAME); + expect(opportunities.map((r) => r.name)).toContain(SENTINEL_NAME); + expect(fake.allRowsFor(ORG_ONE).filter((r) => r.name === SENTINEL_NAME)).toHaveLength(2); + }); + + it('with NO app seed datasets, org #2 is created EMPTY — not populated from org #1', async () => { + // The exact shape the retired `cloneOrgSeedData` fired on: a second + // organization, no replayer registered. It used to copy every one of org + // #1's rows — the seeded ones AND the private ones — into org #2. + await createOrganizationThroughPipeline(fake.ql, ORG_TWO, logger); + + // Sentinel re-proof at the moment of assertion: org #1 still holds the rows + // the probe is being asked to look for. + expect(fake.allRowsFor(ORG_ONE).map((r) => r.name)).toContain(SENTINEL_NAME); + + expect(fake.allRowsFor(ORG_TWO)).toEqual([]); + for (const object of BUSINESS_OBJECTS) { + expect(fake.rowsFor(object, ORG_TWO), `${object} rows in org #2`).toHaveLength(0); + } + }); + + it('with app seed datasets, org #2 holds exactly the APP\'s seed rows and nothing traceable to org #1', async () => { + const replayer = makeAppSeedReplayer(fake.ql); + await createOrganizationThroughPipeline(fake.ql, ORG_TWO, logger, { + 'seed-datasets': [{ object: 'crm_account', records: [{ name: SEEDED_ACCOUNT_NAME }] }], + 'seed-replayer': replayer, + }); + + expect(replayer).toHaveBeenCalledWith(ORG_TWO); + expect(fake.allRowsFor(ORG_ONE).map((r) => r.name)).toContain(SENTINEL_NAME); + + // Org #2's rows come from the app's seed definitions: same natural key, + // its own physical row. + const twoAccounts = fake.rowsFor('crm_account', ORG_TWO); + expect(twoAccounts.map((r) => r.name)).toEqual([SEEDED_ACCOUNT_NAME]); + const oneIds = new Set(fake.allRowsFor(ORG_ONE).map((r) => String(r.id))); + for (const row of fake.allRowsFor(ORG_TWO)) { + expect(oneIds.has(String(row.id)), `row ${row.id} is org #1's physical row`).toBe(false); + } + + // And NOTHING of org #1's post-seed private data. + expect(fake.allRowsFor(ORG_TWO).map((r) => r.name)).not.toContain(SENTINEL_NAME); + // Row COUNT is part of the invariant too: the retired clone reproduced org + // #1's row count, so "one seeded account, no opportunities" is what + // distinguishes an app-seeded org from a cloned one. + expect(fake.rowsFor('crm_opportunity', ORG_TWO)).toHaveLength(0); + }); + + it('the pipeline reads NO rows out of any other organization while creating org #2', async () => { + // The disclosure happens at READ time — a clone that is later hidden by the + // wall has already crossed it. So this asserts on the queries themselves: + // the pipeline may look at `sys_organization` (it counts orgs), but it must + // never query a business object scoped to another organization. + await createOrganizationThroughPipeline(fake.ql, ORG_TWO, logger); + + const businessReads = (fake.ql.find as any).mock.calls.filter( + (c: any[]) => c[0] !== 'sys_organization', + ); + for (const [object, query] of businessReads) { + const where = (query ?? {}).where ?? {}; + expect( + where.organization_id === undefined || where.organization_id === ORG_TWO, + `${object} read scoped to ${String(where.organization_id)} while creating ${ORG_TWO}`, + ).toBe(true); + } + }); + + it('creating org #2 leaves org #1\'s own rows untouched', async () => { + const before = fake.allRowsFor(ORG_ONE).length; + await createOrganizationThroughPipeline(fake.ql, ORG_TWO, logger); + expect(fake.allRowsFor(ORG_ONE)).toHaveLength(before); + expect(fake.allRowsFor(ORG_ONE).map((r) => r.name)).toContain(SENTINEL_NAME); + }); +}); diff --git a/packages/plugins/organizations/src/organizations-plugin.test.ts b/packages/plugins/organizations/src/organizations-plugin.test.ts new file mode 100644 index 0000000000..64061c96ee --- /dev/null +++ b/packages/plugins/organizations/src/organizations-plugin.test.ts @@ -0,0 +1,374 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, vi } from 'vitest'; +import { OrganizationsPlugin } from './organizations-plugin.js'; + +// ⛔ Nothing is granted here, and nothing needs to be. In the closed runtime +// this suite opened with `grantDevelopmentMultiOrgEntitlement(...)` because the +// constructor refused without an entitlement (cloud#1020). The open package +// carries no licence check at all (ADR-0132 boundary 3), so the ~17 instances +// below construct on their own account. A grant reappearing here would mean a +// gate had been re-added to a package that must not have one. + +function makeCtx(extraServices: Record = {}) { + const middlewares: any[] = []; + const baseSchema = { + name: 'task', + fields: { + id: { name: 'id' }, + organization_id: { name: 'organization_id' }, + owner_id: { name: 'owner_id' }, + name: { name: 'name' }, + }, + }; + const ql: any = { + registerMiddleware: (mw: any) => middlewares.push(mw), + getSchema: () => baseSchema, + find: vi.fn(async () => []), + insert: vi.fn(async () => ({ id: 'x' })), + }; + const metadata: any = { get: async () => baseSchema }; + const services: Record = { + manifest: { register: vi.fn() }, + objectql: ql, + metadata, + ...extraServices, + }; + const registered: Record = {}; + const ctx: any = { + logger: { info: vi.fn(), warn: vi.fn() }, + registerService: (name: string, svc: any) => { + registered[name] = svc; + services[name] = svc; + }, + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + return { ctx, ql, middlewares, registered }; +} + +describe('OrganizationsPlugin', () => { + it('has correct metadata', () => { + const plugin = new OrganizationsPlugin(); + expect(plugin.name).toBe('com.objectstack.organizations'); + expect(plugin.version).toBe('1.0.0'); + expect(plugin.dependencies).toContain('com.objectstack.engine.objectql'); + }); + + it('registers itself as `org-scoping` service during init', async () => { + const plugin = new OrganizationsPlugin(); + const { ctx, registered } = makeCtx(); + await plugin.init(ctx); + expect(registered['org-scoping']).toBe(plugin); + }); + + // [ADR-0105 D12, as amended by ADR-0132] The registered service carries the ENTITLEMENT. + // Open core reads `supportedPostures` off it and fails closed on anything + // unlisted, so THIS package — not open core — decides which multi-org shapes + // it sells. Both walled postures are entitled today; `single` never is (it is + // the no-wall posture and needs no runtime). + describe('posture entitlement (ADR-0105 D12)', () => { + it('declares the walled postures it entitles on the registered service', async () => { + const plugin = new OrganizationsPlugin(); + const { ctx, registered } = makeCtx(); + await plugin.init(ctx); + const service = registered['org-scoping'] as { supportedPostures?: readonly string[] }; + expect(service.supportedPostures).toBeDefined(); + expect([...(service.supportedPostures ?? [])].sort()).toEqual(['group', 'isolated']); + }); + + it('never entitles `single` — the no-wall posture needs no runtime', () => { + expect(new OrganizationsPlugin().supportedPostures).not.toContain('single'); + }); + + it('entitles at least one posture — an empty list would brick every multi-org deployment', () => { + // Guard against an edit that empties the list. Open core fails closed on + // an unentitled posture, so `[]` would make every deployment that requested + // a wall REFUSE TO BOOT (ADR-0093 D5), not merely lose a feature. + expect(new OrganizationsPlugin().supportedPostures.length).toBeGreaterThan(0); + }); + }); + + it('auto-stamps organization_id on insert from tenantId', async () => { + const plugin = new OrganizationsPlugin(); + const { ctx, middlewares } = makeCtx(); + await plugin.init(ctx); + await plugin.start(ctx); + const insertMw = middlewares[0]; + const opCtx: any = { + object: 'task', + operation: 'insert', + data: { name: 'A' }, + context: { userId: 'u1', tenantId: 'org-1' }, + }; + await insertMw(opCtx, async () => {}); + expect(opCtx.data.organization_id).toBe('org-1'); + }); + + // [#2937] AUTHORITATIVE overwrite (behavior delta). A user-context insert may + // not choose its tenant: a supplied — possibly forged — organization_id is + // OVERWRITTEN with the caller's active org, closing the cross-tenant insert + // gap. (Previously a non-empty value was preserved — the vulnerability.) + it('[#2937] OVERWRITES a forged organization_id in user context with the active tenant', async () => { + const plugin = new OrganizationsPlugin(); + const { ctx, middlewares } = makeCtx(); + await plugin.init(ctx); + await plugin.start(ctx); + const opCtx: any = { + object: 'task', + operation: 'insert', + // 'org-2' is another tenant — the attacker's forged value. + data: { name: 'A', organization_id: 'org-2' }, + context: { userId: 'u1', tenantId: 'org-1' }, + }; + await middlewares[0](opCtx, async () => {}); + // Normalized to the caller's active org — NOT the forged value. + expect(opCtx.data.organization_id).toBe('org-1'); + }); + + it('[#2937] a same-tenant explicit organization_id is preserved (idempotent overwrite)', async () => { + const plugin = new OrganizationsPlugin(); + const { ctx, middlewares } = makeCtx(); + await plugin.init(ctx); + await plugin.start(ctx); + const opCtx: any = { + object: 'task', + operation: 'insert', + data: { name: 'A', organization_id: 'org-1' }, + context: { userId: 'u1', tenantId: 'org-1' }, + }; + await middlewares[0](opCtx, async () => {}); + expect(opCtx.data.organization_id).toBe('org-1'); + }); + + it('skips auto-stamping in system context', async () => { + const plugin = new OrganizationsPlugin(); + const { ctx, middlewares } = makeCtx(); + await plugin.init(ctx); + await plugin.start(ctx); + const opCtx: any = { + object: 'task', + operation: 'insert', + data: { name: 'A' }, + context: { isSystem: true, tenantId: 'org-1' }, + }; + await middlewares[0](opCtx, async () => {}); + expect(opCtx.data.organization_id).toBeUndefined(); + }); + + // [#2937] The legitimate "set org_id on behalf" path (per-org seed replay / + // clone / orphan-claim, imports, migrations) runs under SYSTEM_CTX — it must + // keep an explicit cross-org value verbatim, NOT be overwritten. + it('[#2937] system context preserves an explicit cross-org organization_id (on-behalf writes unaffected)', async () => { + const plugin = new OrganizationsPlugin(); + const { ctx, middlewares } = makeCtx(); + await plugin.init(ctx); + await plugin.start(ctx); + const opCtx: any = { + object: 'task', + operation: 'insert', + data: { name: 'A', organization_id: 'org-donor' }, + context: { isSystem: true, tenantId: 'org-1' }, + }; + await middlewares[0](opCtx, async () => {}); + expect(opCtx.data.organization_id).toBe('org-donor'); + }); + + // A non-`isSystem` context that carries a tenant but NO principal (a service + // acting with an org scope) keeps the prior FILL-ONLY semantics — it may still + // set an explicit value; only USER-context inserts are overwritten. + it('[#2937] principal-less (non-system) context keeps fill-only semantics', async () => { + const plugin = new OrganizationsPlugin(); + const { ctx, middlewares } = makeCtx(); + await plugin.init(ctx); + await plugin.start(ctx); + const opCtx: any = { + object: 'task', + operation: 'insert', + data: { name: 'A', organization_id: 'org-explicit' }, + context: { tenantId: 'org-1' }, // no userId, not isSystem + }; + await middlewares[0](opCtx, async () => {}); + expect(opCtx.data.organization_id).toBe('org-explicit'); + }); + + it('no-ops when tenantId is absent', async () => { + const plugin = new OrganizationsPlugin(); + const { ctx, middlewares } = makeCtx(); + await plugin.init(ctx); + await plugin.start(ctx); + const opCtx: any = { + object: 'task', + operation: 'insert', + data: { name: 'A' }, + context: { userId: 'u1' }, + }; + await middlewares[0](opCtx, async () => {}); + expect(opCtx.data.organization_id).toBeUndefined(); + }); + + it('skips when target object has no organization_id field', async () => { + const plugin = new OrganizationsPlugin(); + const { ctx, middlewares, ql } = makeCtx(); + // Replace schema so the column does not exist. `ql` is the same object the + // fake ctx hands back from `getService('objectql')` — reached through the + // harness rather than through a lookup erased to `any`, which + // `pnpm check:slot-lookup` refuses and whose baseline never grows. + ql.getSchema = () => ({ + name: 'task', + fields: { id: { name: 'id' }, name: { name: 'name' } }, + }); + await plugin.init(ctx); + await plugin.start(ctx); + const opCtx: any = { + object: 'task', + operation: 'insert', + data: { name: 'A' }, + context: { userId: 'u1', tenantId: 'org-1' }, + }; + await middlewares[0](opCtx, async () => {}); + expect(opCtx.data.organization_id).toBeUndefined(); + }); + + // ── Middleware B: per-org seed replay on sys_organization insert ───────── + // + // The consumer side of the framework #3453 fix. In multi-tenant mode a brand-new + // org gets its own copy of demo data by replaying the kernel's `seed-datasets` + // list — which, post-fix, holds the UNION of every seed source (every config app + // + every marketplace package) — via the `seed-replayer` callable AppPlugin + // registers. These tests pin that Middleware B invokes the replayer for the new + // org and honours a multi-source result, and that a misconfigured producer + // (datasets but no replayer) is surfaced, not silently skipped. + describe('per-org seed replay (Middleware B, framework #3453)', () => { + const newOrgInsert = (id: string) => ({ + object: 'sys_organization', + operation: 'insert' as const, + data: { id, name: 'New Co' }, + result: { id }, + // On-behalf seed writes run under SYSTEM_CTX (see Middleware A tests above). + context: { isSystem: true }, + }); + + it('replays the full multi-source seed union for a newly inserted organization', async () => { + const plugin = new OrganizationsPlugin(); + // What the FIXED framework producer now registers: one shared array holding + // the union of every source — two config apps plus a marketplace package. + const datasets = [ + { object: 'crm_account', records: [{ id: 'a1' }, { id: 'a2' }] }, // config app A + { object: 'crm_contact', records: [{ id: 'b1' }] }, // config app B + { object: 'hot_lead', records: [{ id: 'm1' }] }, // marketplace pkg + ]; + const { ctx, middlewares } = makeCtx({ 'seed-datasets': datasets }); + + // A faithful stand-in for AppPlugin's registered replayer: reads the LIVE + // `seed-datasets` service and reports what it seeded, scoped to the org. + let replayedWith: { orgId: string; objects: string[] } | undefined; + const seedReplayer = vi.fn(async (orgId: string) => { + const live = ctx.getService('seed-datasets') as any[]; + replayedWith = { orgId, objects: live.map((d) => d.object) }; + const inserted = live.reduce((n: number, d: any) => n + d.records.length, 0); + return { inserted, updated: 0, skipped: 0, errors: [] }; + }); + ctx.registerService('seed-replayer', seedReplayer); + + await plugin.init(ctx); + await plugin.start(ctx); + + // A brand-new org is inserted → the per-org seed pipeline (Middleware B) runs. + await middlewares[1](newOrgInsert('org_new'), async () => {}); + + // The replayer ran once, scoped to the new org, over EVERY source — not just + // the first config app (the #3453 regression, locked in end-to-end here). + expect(seedReplayer).toHaveBeenCalledTimes(1); + expect(seedReplayer).toHaveBeenCalledWith('org_new'); + expect(replayedWith).toEqual({ + orgId: 'org_new', + objects: ['crm_account', 'crm_contact', 'hot_lead'], + }); + // A successful replay short-circuits the legacy claim/clone fallbacks. + expect( + (ctx.logger.info as any).mock.calls.some((c: any[]) => + String(c[0]).includes('per-org seed replay for org_new'), + ), + ).toBe(true); + }); + + it('surfaces a missing replayer — datasets present, no replayer registered — instead of silently skipping', async () => { + const plugin = new OrganizationsPlugin(); + const datasets = [{ object: 'crm_account', records: [{ id: 'a1' }] }]; + // `seed-datasets` registered, but NO `seed-replayer`. Post-#881 the + // resolver returns `undefined` for an unregistered service (instead of + // the lookup throwing into the generic outer catch), so the pipeline + // reaches the PRECISE misconfiguration warning rather than the vague + // "replay failed" one — still loud, never silently un-seeded. + const { ctx, middlewares } = makeCtx({ 'seed-datasets': datasets }); + + await plugin.init(ctx); + await plugin.start(ctx); + + await middlewares[1](newOrgInsert('org_new'), async () => {}); + + expect( + (ctx.logger.warn as any).mock.calls.some((c: any[]) => + String(c[0]).includes('datasets present but no replayer registered'), + ), + ).toBe(true); + }); + + it('#881: resolves an ASYNC-registered replayer via getServiceAsync — the real kernel shape', async () => { + const plugin = new OrganizationsPlugin(); + const datasets = [{ object: 'crm_account', records: [{ id: 'a1' }, { id: 'a2' }] }]; + const seedReplayer = vi.fn(async () => ({ inserted: 2, updated: 0, skipped: 0, errors: [] })); + const { ctx, middlewares } = makeCtx({ 'seed-datasets': datasets }); + // The real kernel registers `seed-replayer` (and `seed-datasets`) via an + // async factory: sync getService THROWS "Service is async - use await", + // and only getServiceAsync resolves. Before #881 that landed every boot + // in the fallback path with "per-org seed replay failed, falling back". + const syncGetService = ctx.getService; + ctx.getService = (name: string) => { + if (name === 'seed-replayer' || name === 'seed-datasets') { + throw new Error(`Service '${name}' is async - use await`); + } + return syncGetService(name); + }; + ctx.getServiceAsync = async (name: string) => { + if (name === 'seed-replayer') return seedReplayer; + if (name === 'seed-datasets') return datasets; + return syncGetService(name); + }; + + await plugin.init(ctx); + await plugin.start(ctx); + + await middlewares[1](newOrgInsert('org_async'), async () => {}); + + // The PRIMARY path engaged — no fallback warning fired. + expect(seedReplayer).toHaveBeenCalledTimes(1); + expect(seedReplayer).toHaveBeenCalledWith('org_async'); + expect( + (ctx.logger.warn as any).mock.calls.some((c: any[]) => + String(c[0]).includes('per-org seed replay failed, falling back'), + ), + ).toBe(false); + }); + + it('ignores inserts on other objects (only sys_organization drives per-org replay)', async () => { + const plugin = new OrganizationsPlugin(); + const { ctx, middlewares } = makeCtx({ 'seed-datasets': [{ object: 'x', records: [{ id: '1' }] }] }); + const seedReplayer = vi.fn(async () => ({ inserted: 1, updated: 0, skipped: 0, errors: [] })); + ctx.registerService('seed-replayer', seedReplayer); + + await plugin.init(ctx); + await plugin.start(ctx); + + // A plain business-object insert must NOT trigger the seed pipeline. + await middlewares[1]( + { object: 'task', operation: 'insert', data: { id: 't1' }, result: { id: 't1' }, context: { isSystem: true } }, + async () => {}, + ); + expect(seedReplayer).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/plugins/organizations/src/organizations-plugin.ts b/packages/plugins/organizations/src/organizations-plugin.ts new file mode 100644 index 0000000000..62c07176c8 --- /dev/null +++ b/packages/plugins/organizations/src/organizations-plugin.ts @@ -0,0 +1,554 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { Plugin, PluginContext } from '@objectstack/core'; +import { claimOrphanOrgRows } from './claim-orphan-org-rows.js'; +import { isDefaultOrganizationBootstrapTrigger } from '@objectstack/plugin-auth'; +import { ensureDefaultOrganization } from './ensure-default-organization.js'; +import { assertWalledMembershipPolicyDeclared } from './membership-policy-gate.js'; +import { + organizationsObjects, + organizationsPluginManifestHeader, +} from './manifest.js'; + +/** + * Resolve a kernel service that may be registered ASYNC (a service factory). + * + * The real kernel's sync `getService` THROWS "Service '' is async - use + * await" for factory-registered services — which silently disabled the + * primary per-org seed-replay path on every boot (the `seed-replayer` + * callable AppPlugin registers is async; #881). Prefer `getServiceAsync` + * when the host exposes it; fall back to the sync lookup for embeddings and + * tests that register plain values. Returns `undefined` when the service is + * not registered either way — callers decide how loudly that matters. + */ +async function resolveKernelService(kernel: any, name: string): Promise { + if (typeof kernel?.getServiceAsync === 'function') { + try { + const svc = await kernel.getServiceAsync(name); + if (svc != null) return svc; + } catch { + /* not registered async — try the sync path below */ + } + } + try { + return kernel?.getService?.(name); + } catch { + return undefined; + } +} + +export interface OrganizationsPluginOptions { + /** + * Whether to auto-create a `Default Organization` (slug `default`) + * and bind the first platform admin as `owner` when they have zero + * memberships. Set to `false` for deployments that fully self-manage + * org provisioning via invitation links or a custom onboarding flow. + * + * @default true + */ + ensureDefaultOrganization?: boolean; + + // ⛔ There is deliberately NO option here for cloning one organization's + // rows into another (cloud#1345). `cloneSeedDataUnderGroupPosture` used to + // sit at this spot, gating a donor-org clone that ran by default under + // `isolated`. Both the option and the clone are gone: the maintainer's + // ruling denies the REQUIREMENT, not merely the default, so there is no + // knob to re-enable and none is to be re-added. Demo data on signup is the + // app's own seed definitions replayed per tenant (`seed-datasets` / + // `seed-replayer`). +} + +/** + * The `objectql` slot's surface THIS plugin uses, written structurally. + * + * Not `any`: `pnpm check:slot-lookup` refuses erasing a service lookup to `any` + * (#4251), and the baseline it ratchets never grows — so a file arriving in this + * repository types its lookups rather than inheriting a grandfather clause it is + * not on. Structural rather than the engine's full contract for the same reason + * `membership-policy-gate.ts` states about ITS probes: this plugin needs three + * members, the `catch` arms below already treat every one of them as possibly + * absent, and naming the whole engine interface here would claim a coupling the + * runtime checks do not make. + */ +interface OrgScopingQuerySlot { + registerMiddleware(mw: (opCtx: any, next: () => Promise) => Promise): void; + find(object: string, query: unknown, options?: unknown): Promise; + getSchema?(object: string): any; +} + +/** The `metadata` slot's surface this plugin uses. Structural, same reasoning. */ +interface OrgScopingMetadataSlot { + get?(type: string, name: string): Promise; +} + +/** + * OrganizationsPlugin — the multi-organization runtime, in open core + * (ADR-0132; cloud ADR-0081 D2 had moved it to the commercial runtime and + * this is the round trip back). The machinery shipped here originally as + * `plugin-org-scoping`, was migrated verbatim into the closed + * `@objectstack/organizations`, and has now returned under that second name — + * the service name `org-scoping` was kept on purpose through both moves, so + * no consumer ever had to follow it. What stays commercial is the + * ENTITLEMENT and nothing else. + * + * ⚠️ A package with this exact name also exists, private, in the commercial + * repo, and that is the DESIGN, not a collision to repair. It subclasses this + * class and calls its licence gate in its own constructor; every commercial + * host declares the name as `workspace:*`, which pnpm can only resolve to + * that local package — never to the registry — so a commercial deployment + * mounts the gated subclass while an open deployment, declaring the same name + * from npm, mounts this one. One spelling, resolved from the manifest that + * declares it. ⛔ Do not "de-duplicate" the two by giving this class a way to + * know which it is. + * + * Makes `sys_organization` a first-class row-level isolation boundary: + * + * 1. **insert auto-stamp** — on every authenticated `insert` whose + * target object declares `organization_id`, fill the column from + * `ExecutionContext.tenantId`. Without this, freshly-created + * rows have `organization_id = NULL` and the default + * `tenant_isolation` RLS policy hides them from the very user + * who just created them. + * + * 2. **per-org seed replay** — after `sys_organization` insert, load + * the APP's own demo seed data into the new org. Two paths: + * a. replay registered `seed-datasets` via the kernel-level + * `seed-replayer` callable (set by AppPlugin), + * b. for the FIRST org, `claimOrphanOrgRows` adopts any + * NULL-org rows a previous inline-seed may have inserted. + * Neither reads another organization's rows, and that is the + * INVARIANT (cloud#1345): a new organization's data comes from + * the app's seed definitions, or the organization starts empty. + * A third path used to exist — `cloneOrgSeedData` shallow-cloned + * the FIRST organization's business rows into every subsequent + * one — and it is retired, not disabled: on a self-serve SaaS + * deployment it handed customer #2 a copy of customer #1's + * records. ⛔ Do not re-add a donor-clone path here in any form. + * + * 3. **default-org bootstrap** — on `kernel:ready` and after every + * `sys_user_permission_set` insert, ensure the platform admin has + * a Default Organization to operate in (idempotent on slug + * `default` + admin's existing memberships). + * + * Why split from plugin-security: + * - plugin-security is a single-tenant-aware RBAC + RLS engine; it + * should not know about Organization-specific seed flows. + * - This plugin is purely opt-in: not installing it gives a + * single-ORG deployment (no `organization_id` injection, no per-org + * seed replay; the member-management BASICS — single-org default-org + * bootstrap + better-auth invitations — stay in the open plugin-auth, + * cloud ADR-0081 D1). plugin-security detects this plugin's presence via + * `getService('org-scoping')` and adjusts RLS policy stripping + * accordingly. + * + * Naming note: "org-scoping" deliberately avoids the word "tenant" + * because in ObjectStack "tenant" already means *physical isolation* + * (one Environment = one database, per ADR-0002 and driver-turso's + * multi-tenant router). This plugin is about LOGICAL row-level + * scoping inside a single database — orthogonal to physical tenancy. + * + * Dependencies: + * - `objectql` (engine middleware host) + */ +export class OrganizationsPlugin implements Plugin { + name = 'com.objectstack.organizations'; + type = 'standard' as const; + version = '1.0.0'; + dependencies = ['com.objectstack.engine.objectql']; + + /** + * [ADR-0105 D12, as amended by ADR-0132] Which tenancy postures THIS runtime + * entitles. + * + * The core reads this off the `org-scoping` service and fails closed on any + * posture not listed (`OrgScopingEntitlement` in + * `@objectstack/spec/security`). ADR-0105 D12 argued this declaration into + * the commercial runtime, on the reasoning that "which shapes of multi-org" + * is a PACKAGING question open core should not answer. ADR-0132 settles it + * the other way for the open package: **an open install is entitled to both + * walled postures by construction.** There is no packaging question left to + * answer here — an installation that has this package has the wall, in both + * of the shapes the wall comes in — so the declaration below is the + * open runtime's own constant, not a tier. + * + * Declared explicitly even though it matches the core default (omitting the + * field entitles every walled posture), because the two `readonly` names are + * what `OrgScopingEntitlement` reads and a silent default is harder to trace + * from a boot refusal than a literal is. Removing a posture makes every + * deployment that requested it refuse to boot (ADR-0093 D5). + * + * ⛔ And this is NOT the place a tier is drawn. The sentence that used to + * stand here invited the opposite — narrowing the boundary later, "gating it + * behind a licence flag", was advertised as a one-line edit at this spot. + * That edit is now forbidden in this file and in this package: ADR-0132 + * boundary 3 is that the open package carries no licence check of any kind, + * offers no hook for one, and takes no entitlement callback. A deployment + * that wants multi-org gated buys that from the commercial runtime, which + * subclasses this class and answers its own gate in its own constructor — + * cloud code, cloud gate, on cloud's side of the split. + * + * - `isolated` — the hard legal-entity wall (`organization_id = active org`). + * - `group` — organizations as membership boundaries over one shared dataset, + * with union read access (`organization_id IN accessible_org_ids`). + */ + readonly supportedPostures: readonly ('single' | 'group' | 'isolated')[] = ['group', 'isolated']; + + /** Per-object field-name cache; same shape as SecurityPlugin's. */ + private readonly fieldNamesCache = new Map | null>(); + + private readonly opts: Required; + + // ⛔ NO LICENCE GATE HERE, and none is to be added (ADR-0132 boundary 3). + // The closed package's constructor opened with `assertMultiOrgEntitled()`; + // that call and its gate stayed in `@objectstack/organizations` when the + // rest of this file moved. Constructing this plugin is enough to run + // multi-org, on purpose — that IS the decision, not an omission someone + // should repair. The commercial runtime keeps the refusal by SUBCLASSING: + // its own `OrganizationsPlugin extends` this one and calls its gate in its + // own constructor, so the entitlement is answered at construction exactly + // as before (cloud#1020's requirement) with no seam on this side. + // ⛔ Do not add an `assertEntitled` option, a hook, a callback, or a + // protected method for a subclass to override "for" gating — any of those + // is the hook boundary 3 forbids, and a host could reach it. + constructor(options: OrganizationsPluginOptions = {}) { + this.opts = { + ensureDefaultOrganization: options.ensureDefaultOrganization !== false, + }; + } + + async init(ctx: PluginContext): Promise { + ctx.logger.info('Initializing Organizations Plugin...'); + // The service name stays 'org-scoping' ON PURPOSE (cloud ADR-0081 D2, kept by ADR-0132): it is + // plugin-security's "multi-tenant mode is on" probe (SecurityPlugin + // queries `getService('org-scoping')` and keeps wildcard + // `current_user.organization_id` RLS policies when this returns) AND the + // `requiresService: 'org-scoping'` nav-gate anchor. Renaming it would + // silently flip RLS posture and nav visibility across every deployment. + ctx.registerService('org-scoping', this); + + ctx + .getService<{ register(m: any): void }>('manifest') + .register({ + ...organizationsPluginManifestHeader, + objects: organizationsObjects, + }); + ctx.logger.info('Organizations Plugin initialized'); + } + + async start(ctx: PluginContext): Promise { + ctx.logger.info('Starting Organizations Plugin...'); + + // ── MEMBERSHIP-POLICY gate (cloud#1092) ────────────────────────── + // A walled deployment must DECLARE what a new user joins; running + // the framework default `auto` because nobody said otherwise is + // refused. See membership-policy-gate.ts for what counts as a + // declaration and why the undeclared case is fatal rather than a + // warning. + // + // FIRST statement of start(), above the ObjectQL probe below, so no + // early return can skip it — the wall is being mounted either way. + // + // On `kernel:bootstrapped`, not here and not `kernel:ready`: + // • the answer lives in the `auth` settings namespace, and + // SettingsServicePlugin late-binds its DATA ENGINE inside its own + // `kernel:ready` handler. Reading during Phase 2 — or from an + // earlier `kernel:ready` handler, since hook order is registration + // order — sees env + manifest defaults only, so a deployment that + // configured `invite-only` through Setup (a stored `sys_setting` + // row) would be refused for not having configured it. + // • `kernel:bootstrapped` is the framework's documented "all + // synchronous bootstrap has settled" anchor and it fires BEFORE + // `kernel:listening` opens the socket — so this is still a boot + // refusal, not a runtime one. No request is ever served by a + // deployment this rejects. + // + // Throwing (not `process.exit`) is deliberate: kernel bootstrap + // propagates it, `objectstack serve` prints the message verbatim and + // exits 1, and a multi-tenant host embedding this plugin can catch it + // per-kernel instead of taking down every other environment it serves. + const runMembershipPolicyGate = () => assertWalledMembershipPolicyDeclared(ctx); + if (typeof (ctx as any).hook === 'function') { + (ctx as any).hook('kernel:bootstrapped', runMembershipPolicyGate); + } else { + // No hook seam (a lean embedding / test kernel). Run it inline: a + // deployment that cannot be asked later must still be asked. + await runMembershipPolicyGate(); + } + + let ql: OrgScopingQuerySlot | undefined; + let metadata: OrgScopingMetadataSlot | undefined; + try { + ql = ctx.getService('objectql'); + try { + metadata = ctx.getService('metadata'); + } catch { + metadata = undefined; + } + } catch { + ctx.logger.warn( + 'ObjectQL service not available, org-scoping middleware not registered', + ); + return; + } + if (!ql || typeof ql.registerMiddleware !== 'function') { + ctx.logger.warn( + 'ObjectQL engine does not support middleware, org-scoping middleware not registered', + ); + return; + } + + // ── Middleware A: auto-stamp `organization_id` on insert ────────── + ql.registerMiddleware(async (opCtx: any, next: () => Promise) => { + if (opCtx.context?.isSystem) return next(); + if ( + opCtx.operation === 'insert' && + opCtx.data && + typeof opCtx.data === 'object' && + !Array.isArray(opCtx.data) && + opCtx.context?.tenantId + ) { + const fields = await this.getObjectFieldNames(metadata, opCtx.object, ql); + if (fields && fields.has('organization_id')) { + const data = opCtx.data as Record; + // [#2937] AUTHORITATIVE stamp for USER-context inserts. A user may not + // choose which tenant a row lands in: their insert ALWAYS carries the + // caller's active organization, so a supplied — possibly FORGED — + // `organization_id` pointing at another org is OVERWRITTEN, never + // trusted. (Previously this only FILLED a missing value, so a forged + // non-empty value slipped through and — absent the Layer 0 insert + // post-image check — landed in the victim tenant.) `isSystem` + // short-circuited above (line ~136), so legitimate on-behalf writes + // that deliberately set another org — the per-org seed replay + // / orphan-claim, imports, migrations — run under SYSTEM_CTX and are + // untouched. A non-`isSystem` context with a tenant but NO principal + // (a service acting with an org scope) keeps the prior fill-only + // semantics so it can still set an explicit value. + const isUserContext = !!opCtx.context.userId; + if (isUserContext) { + data.organization_id = opCtx.context.tenantId; + } else if (data.organization_id == null || data.organization_id === '') { + data.organization_id = opCtx.context.tenantId; + } + } + } + await next(); + }); + + // ── Middleware B: per-org seed pipeline on sys_organization insert ─ + ql.registerMiddleware(async (opCtx: any, next: () => Promise) => { + await next(); + if ( + opCtx?.object !== 'sys_organization' || + (opCtx?.operation !== 'create' && opCtx?.operation !== 'insert') + ) { + return; + } + const newOrgId = opCtx?.result?.id ?? opCtx?.data?.id; + if (!newOrgId) return; + + const kernel: any = (ctx as any).kernel ?? ctx; + const datasetsRaw = await resolveKernelService(kernel, 'seed-datasets'); + const datasets: any[] | undefined = + Array.isArray(datasetsRaw) && datasetsRaw.length > 0 ? datasetsRaw : undefined; + + // Count existing orgs to pick the right fallback path. + let orgCount = 0; + try { + const allOrgs = await ql.find( + 'sys_organization', + { limit: 2, fields: ['id'] }, + { context: { isSystem: true } }, + ); + const list: any[] = Array.isArray(allOrgs) + ? allOrgs + : Array.isArray(allOrgs?.records) + ? allOrgs.records + : []; + orgCount = list.length; + } catch (e) { + ctx.logger.warn('[org-scoping] failed to count organizations', { + error: (e as Error).message, + }); + } + + // Primary path: SeedLoader replay scoped to newOrgId. + let replayed = false; + try { + const replayer: any = await resolveKernelService(kernel, 'seed-replayer'); + if (typeof replayer === 'function') { + const summary = await replayer(newOrgId); + const total = (summary?.inserted ?? 0) + (summary?.updated ?? 0); + ctx.logger.info( + `[org-scoping] per-org seed replay for ${newOrgId}: +${summary?.inserted ?? 0} inserted, ${summary?.updated ?? 0} updated, ${summary?.errors?.length ?? 0} error(s)`, + { + organizationId: newOrgId, + errors: summary?.errors?.slice?.(0, 5), + }, + ); + if (total > 0) replayed = true; + } else if (datasets) { + ctx.logger.warn( + '[org-scoping] per-org seed: datasets present but no replayer registered', + { organizationId: newOrgId }, + ); + } + } catch (e) { + ctx.logger.warn( + '[org-scoping] per-org seed replay failed, falling back', + { organizationId: newOrgId, error: (e as Error).message }, + ); + } + if (replayed) return; + + // Fallback A: legacy claim for first org. + if (orgCount === 1) { + try { + const claims = await claimOrphanOrgRows(ql, newOrgId, { logger: ctx.logger }); + if (claims.length > 0) { + const total = claims.reduce((s, c) => s + c.count, 0); + ctx.logger.info( + `[org-scoping] claimed ${total} orphan seed row(s) for first organization ${newOrgId}`, + { breakdown: claims }, + ); + return; + } + } catch (e) { + ctx.logger.warn('[org-scoping] claim-orphan-org-rows failed', { + error: (e as Error).message, + }); + } + } + + // ⛔ NO THIRD PATH (cloud#1345). There used to be a "Fallback B" + // here: for every org after the first, `cloneOrgSeedData` + // shallow-copied the FIRST organization's business rows into the + // new one. On a self-serve SaaS deployment — one database, orgs + // that are real customers (cloud#1331) — that meant customer #2's + // signup cloned customer #1's accounts, contacts and + // opportunities into their org. The wall then isolated the two + // copies correctly; the disclosure had already happened at clone + // time. + // + // The maintainer's ruling (2026-08-16) denies the REQUIREMENT, + // not merely the default: 「每个新组织注册时克隆第一个组织的全部 + // 业务行,没有这个需求啊,比如 hotcrm seed 数据应该从代码中加载。」 + // So it is removed rather than defaulted off — a template-donor + // variant is equally unwanted, and a disabled copy is a permanent + // maintenance obligation bought for nothing. + // + // Where a populated-on-signup experience IS wanted, it comes from + // the app's own seed definitions replayed per tenant — the + // primary path above. An org whose deployment ships no seed + // datasets simply starts EMPTY, which is the correct outcome. + if (orgCount > 1 && !replayed) { + ctx.logger.info( + `[org-scoping] organization ${newOrgId} starts empty: no app seed datasets replayed. ` + + // The invariant's tracker id (cloud#1345) stays in the comment block + // above and out of the log line — a log reader cannot resolve it. + 'Demo data on signup comes from the app\'s own seed definitions — never from another organization\'s rows.', + { organizationId: newOrgId }, + ); + } + }); + + // ── Default-org bootstrap on kernel:ready + on admin grant ──────── + if (this.opts.ensureDefaultOrganization) { + const runEnsure = async () => { + try { + const res = await ensureDefaultOrganization(ql, { logger: ctx.logger }); + if (res.defaultOrgCreated) { + ctx.logger.info( + `[org-scoping] created Default Organization ${res.defaultOrgId} for platform admin`, + ); + } + } catch (e) { + ctx.logger.warn?.('[org-scoping] ensureDefaultOrganization failed', { + error: (e as Error).message, + }); + } + }; + if (typeof (ctx as any).hook === 'function') { + (ctx as any).hook('kernel:ready', runEnsure); + } else { + void runEnsure(); + } + // Re-run after every write that can move the "who is the platform + // admin" answer, asking the framework's OWN predicate rather than a + // second opinion about it (#13685 exports it for exactly + // this — "every wiring consumes the SAME predicate instead of + // re-deriving it", the `shouldReplayBootstrapFor` pattern). + // + // Why this stopped being "on admin grant" alone: #13514 + // (L4) retired the walled grant row, so on a walled deployment the + // grant-insert arm never fires again and `kernel:ready` (which runs + // before any user exists) was the only run left — the default org + // never appeared, which is how cloud's guided-path suite caught it. + // The declared owner becomes resolvable on the `sys_user` write that + // creates the row or moves `email`/`email_verified`, and those arms + // are in the predicate. The helper is idempotent and short-circuits in + // one query once the org exists, so the extra re-runs are cheap. + ql.registerMiddleware(async (opCtx: any, next: () => Promise) => { + await next(); + if (isDefaultOrganizationBootstrapTrigger(opCtx ?? {})) { + await runEnsure(); + } + }); + } + + ctx.logger.info('Organizations middleware registered on ObjectQL engine'); + } + + async destroy(): Promise { + // No cleanup needed + } + + /** + * Resolve the column-name set for an object (mirrors SecurityPlugin's + * loader so the two plugins behave consistently). Returns `null` if + * the schema can't be loaded — caller skips injection. + */ + private async getObjectFieldNames( + metadata: OrgScopingMetadataSlot | undefined, + objectName: string, + ql?: OrgScopingQuerySlot, + ): Promise | null> { + if (this.fieldNamesCache.has(objectName)) { + return this.fieldNamesCache.get(objectName) ?? null; + } + const result = await this.loadObjectFieldNames(metadata, objectName, ql); + if (result) this.fieldNamesCache.set(objectName, result); + return result; + } + + private async loadObjectFieldNames( + metadata: OrgScopingMetadataSlot | undefined, + objectName: string, + ql?: OrgScopingQuerySlot, + ): Promise | null> { + try { + let obj: any = + typeof ql?.getSchema === 'function' ? ql.getSchema(objectName) : null; + if (!obj || !obj.fields) { + obj = await metadata?.get?.('object', objectName); + } + if (!obj || !obj.fields) return null; + const set = new Set(['id']); + if (Array.isArray(obj.fields)) { + for (const f of obj.fields) { + if (f?.name) set.add(String(f.name)); + } + } else if (typeof obj.fields === 'object') { + for (const key of Object.keys(obj.fields)) { + set.add(key); + const v = (obj.fields as Record)[key]; + if (v && typeof v === 'object' && v.name) set.add(String(v.name)); + } + } else { + return null; + } + return set; + } catch { + return null; + } + } +} diff --git a/packages/plugins/organizations/src/walled-default-org-self-registrant.pin.test.ts b/packages/plugins/organizations/src/walled-default-org-self-registrant.pin.test.ts new file mode 100644 index 0000000000..d713e65e46 --- /dev/null +++ b/packages/plugins/organizations/src/walled-default-org-self-registrant.pin.test.ts @@ -0,0 +1,348 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// objectstack#11225 / objectstack#11184 clause 2 — the ENTERPRISE half. +// +// The ruling (2026-08-23) says: under a walled posture + `invite-only`, NO +// self-registrant is auto-merged into the Default Organization — it belongs to +// the operator only. The OPEN half of that was already pinned in +// `plugin-auth`'s own suite, because `AuthPlugin` SKIPS its default-org +// bootstrap under a wall (`!postureEnforcesWall(resolveTenancyPosture())`) and +// hands the job to THIS package. So the open pin proves nothing about the +// runtime that actually does the work on a walled deployment: this file is it. +// +// ## What this suite is about, stated as a mechanism rather than an outcome +// +// The seam card's worry was a specific WIRING question. `OrganizationsPlugin` +// re-runs the bootstrap on every write matched by +// `isDefaultOrganizationBootstrapTrigger`, and since objectstack#11973 that +// predicate's FIRST arm is a plain `sys_user` insert. So the bootstrap on a +// walled box IS registration-triggered — the inference "objectstack#11211 took +// the admin grant away, so the trigger is gone too" is FALSE, and clause 2 +// cannot rest on it. +// +// What clause 2 actually rests on is that the helper is ADMIN-KEYED, not +// first-registrant-keyed: it binds the account resolved by the config anchor +// (a declared `OS_PLATFORM_OWNER_EMAIL` address whose stored row reads +// VERIFIED) or by the legacy cross-tenant `admin_full_access` grant — and on a +// walled deployment `bootstrap-platform-admin.ts` mints no such grant for +// anybody. A self-registrant matches neither, so the trigger fires and the +// helper answers `no_admin`. +// +// ⚠️ Therefore an outcome-only assertion would be worthless here: "no org was +// created" is also what an empty fake store says when nothing is wired at all. +// Every negative case below is paired with a POSITIVE control that flips +// exactly one fact and makes the same middleware create the org — so a green +// negative is evidence about the KEYING, not about the harness being inert. + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { resetPlatformAdminEmailMemo } from '@objectstack/core'; +import { OrganizationsPlugin } from './organizations-plugin.js'; + +// ⛔ No entitlement grant: the open package has no licence gate to satisfy +// (ADR-0132 boundary 3). + +const OWNER_EMAIL = 'ops@operator.test'; +/** cloud#1509's own reproduction address, kept verbatim. */ +const SELF_REGISTRANT_EMAIL = 'alice@tenant-a.test'; + +interface Row { + [key: string]: unknown; +} + +/** + * A fake ObjectQL engine over an in-memory store, recording every insert. + * + * `find` implements only the shapes `ensureDefaultOrganization` actually + * issues: exact-match `where` over one table, `null` matching a missing or + * null column (the unscoped-grant probe spells `organization_id: null`). + */ +function makeEngine(store: Record) { + const inserts: Array<{ object: string; data: Row }> = []; + const matches = (row: Row, where: Row | undefined): boolean => { + if (!where) return true; + return Object.entries(where).every(([key, value]) => { + // ⛔ REFUSE a combinator rather than reading it as a field name + // (`pnpm check:where-matcher`). A `$and` / `$or` key looked up as a column + // is `undefined`, so the row drops and the fake answers "no rows" for a + // filter it cannot express — and every negative assertion in this file + // would read that empty answer as evidence. Refusing turns the same + // situation into a loud failure naming the shape this double lacks. + if (key.startsWith('$')) { + throw new Error( + `fake engine: WHERE combinator \`${key}\` is not implemented — this double ` + + 'supports exact-match keys only (and `null` for a missing column)', + ); + } + const actual = row[key]; + if (value === null) return actual == null; + return actual === value; + }); + }; + const ql: any = { + registerMiddleware: vi.fn(), + getSchema: () => null, + find: vi.fn(async (object: string, query: any) => { + const rows = (store[object] ?? []).filter((r) => matches(r, query?.where)); + return rows.slice(0, query?.limit ?? rows.length); + }), + insert: vi.fn(async (object: string, data: Row) => { + inserts.push({ object, data }); + (store[object] ??= []).push({ ...data }); + return { ...data }; + }), + }; + const middlewares: any[] = []; + ql.registerMiddleware = (mw: any) => middlewares.push(mw); + return { ql, inserts, middlewares }; +} + +function makeCtx(ql: any) { + const hooks = new Map unknown>>(); + const services: Record = { + manifest: { register: vi.fn() }, + objectql: ql, + // `invite-only`, declared through the settings cascade with a non-`default` + // source — the shape `membership-policy-gate.ts` requires a walled + // deployment to present (cloud#1092). Anything less refuses the boot, so + // this is what "walled + invite-only" means at this seam. + settings: { + getNamespace: vi.fn(async () => ({ + values: { membership_policy: { value: 'invite-only', source: 'env' } }, + })), + }, + auth: { getMembershipPolicy: () => 'invite-only' }, + }; + const ctx: any = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + registerService: (name: string, svc: unknown) => { + services[name] = svc; + }, + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + hook: (name: string, handler: () => unknown) => { + if (!hooks.has(name)) hooks.set(name, []); + hooks.get(name)!.push(handler); + }, + }; + const trigger = async (name: string) => { + for (const h of hooks.get(name) ?? []) await h(); + }; + return { ctx, trigger }; +} + +const savedPosture = process.env.OS_TENANCY_POSTURE; +const savedOwner = process.env.OS_PLATFORM_OWNER_EMAIL; +afterEach(() => { + if (savedPosture === undefined) delete process.env.OS_TENANCY_POSTURE; + else process.env.OS_TENANCY_POSTURE = savedPosture; + if (savedOwner === undefined) delete process.env.OS_PLATFORM_OWNER_EMAIL; + else process.env.OS_PLATFORM_OWNER_EMAIL = savedOwner; + resetPlatformAdminEmailMemo(); +}); + +/** + * Boot the plugin on a walled + invite-only rig over `store`, run the + * `kernel:ready` bootstrap leg, then drive one write through the bootstrap + * middleware as the engine would. + * + * The middleware under test is the LAST one registered by `start()` — the + * bootstrap re-run arm. Asserting its index would pin the registration order + * of two unrelated middlewares, so it is taken from the end. + */ +async function bootAndFire( + store: Record, + write: { object: string; operation: string; data?: Row }, +) { + process.env.OS_TENANCY_POSTURE = 'isolated'; + process.env.OS_PLATFORM_OWNER_EMAIL = OWNER_EMAIL; + resetPlatformAdminEmailMemo(); + + const { ql, inserts, middlewares } = makeEngine(store); + const { ctx, trigger } = makeCtx(ql); + const plugin = new OrganizationsPlugin(); + await plugin.init(ctx); + await plugin.start(ctx); + await trigger('kernel:bootstrapped'); + await trigger('kernel:ready'); + + const bootstrapMw = middlewares[middlewares.length - 1]; + await bootstrapMw(write, async () => {}); + + return { + inserts, + orgInserts: inserts.filter((i) => i.object === 'sys_organization'), + memberInserts: inserts.filter((i) => i.object === 'sys_member'), + }; +} + +/** The rows every walled deployment has once `plugin-security` has seeded. */ +function seededPermissionSets(): Row[] { + return [{ id: 'ps_admin', name: 'admin_full_access' }]; +} + +describe('walled + invite-only: the multi-org default-org bootstrap never joins a self-registrant (#11184 clause 2)', () => { + it('case (a) — the FIRST self-registrant creates NO organization and NO membership', async () => { + // The exact rig cloud#1509 reported against: walled + invite-only, an + // operator address DECLARED but not yet registered, and Alice is the first + // account on the box. Post-objectstack#11211 no `sys_user_permission_set` + // row is minted for her, which is why the store has none. + const store: Record = { + sys_permission_set: seededPermissionSets(), + sys_user_permission_set: [], + sys_user: [ + { + id: 'usr_alice', + email: SELF_REGISTRANT_EMAIL, + email_verified: true, + created_at: '2026-01-01T00:00:00.000Z', + }, + ], + sys_organization: [], + sys_member: [], + }; + + const { orgInserts, memberInserts } = await bootAndFire(store, { + object: 'sys_user', + operation: 'insert', + data: { id: 'usr_alice', email: SELF_REGISTRANT_EMAIL }, + }); + + expect(orgInserts, 'a Default Organization was created for a self-registrant').toEqual([]); + expect(memberInserts, 'a self-registrant was bound into an organization').toEqual([]); + expect(store.sys_organization).toEqual([]); + expect(store.sys_member).toEqual([]); + }); + + it('case (b) — a LATER self-registrant is treated identically, with the Default Organization already present', async () => { + // The second registrant, on a box where the operator has already arrived + // and owns the Default Organization. The helper short-circuits on the + // ADMIN's membership, so the risk here is the opposite of case (a): not + // "create an org" but "bind Bob into the existing one". + const store: Record = { + sys_permission_set: seededPermissionSets(), + sys_user_permission_set: [], + sys_user: [ + { id: 'usr_ops', email: OWNER_EMAIL, email_verified: true, created_at: '2026-01-01T00:00:00.000Z' }, + { id: 'usr_bob', email: 'bob@tenant-b.test', email_verified: true, created_at: '2026-02-02T00:00:00.000Z' }, + ], + sys_organization: [{ id: 'org_default', slug: 'default', name: 'Default Organization' }], + sys_member: [{ id: 'mem_ops', organization_id: 'org_default', user_id: 'usr_ops', role: 'owner' }], + }; + + const { orgInserts, memberInserts } = await bootAndFire(store, { + object: 'sys_user', + operation: 'insert', + data: { id: 'usr_bob', email: 'bob@tenant-b.test' }, + }); + + expect(orgInserts).toEqual([]); + expect(memberInserts, 'a later self-registrant was bound into the Default Organization').toEqual([]); + expect(store.sys_member).toHaveLength(1); + expect(store.sys_member[0]!.user_id).toBe('usr_ops'); + }); + + it('POSITIVE CONTROL — the same middleware DOES bootstrap the operator once their declared address verifies', async () => { + // One fact differs from case (a): the account carrying the DECLARED owner + // address exists and reads verified. If this did not create the org, both + // negatives above would be vacuous. + const store: Record = { + sys_permission_set: seededPermissionSets(), + sys_user_permission_set: [], + sys_user: [ + { id: 'usr_alice', email: SELF_REGISTRANT_EMAIL, email_verified: true, created_at: '2026-01-01T00:00:00.000Z' }, + { id: 'usr_ops', email: OWNER_EMAIL, email_verified: true, created_at: '2026-03-03T00:00:00.000Z' }, + ], + sys_organization: [], + sys_member: [], + }; + + const { orgInserts, memberInserts } = await bootAndFire(store, { + object: 'sys_user', + operation: 'update', + data: { id: 'usr_ops', email_verified: true }, + }); + + expect(orgInserts).toHaveLength(1); + expect(orgInserts[0]!.data.slug).toBe('default'); + expect(memberInserts).toHaveLength(1); + // ⛔ The bind goes to the OPERATOR, never to the older self-registrant — + // "oldest account wins" is the retired pre-#11211 rule and Alice is older. + expect(memberInserts[0]!.data.user_id).toBe('usr_ops'); + expect(memberInserts[0]!.data.role).toBe('owner'); + }); + + it('POSITIVE CONTROL — a legacy cross-tenant grant still anchors the bootstrap, which is what makes case (a) a measurement', async () => { + // This is cloud#1509's reported defect reconstructed at the seam: give the + // first self-registrant the unscoped `admin_full_access` grant that walled + // deployments used to mint for her, and the SAME middleware creates the + // Default Organization and merges her into it — `positions: [… org_owner, + // platform_admin], activeOrganizationId: `, verbatim from the + // card. Case (a) is green only because objectstack#11211 stopped minting + // that row on a walled box, not because this path is inert. + const store: Record = { + sys_permission_set: seededPermissionSets(), + sys_user_permission_set: [ + { id: 'ups_1', permission_set_id: 'ps_admin', organization_id: null, user_id: 'usr_alice' }, + ], + sys_user: [ + { id: 'usr_alice', email: SELF_REGISTRANT_EMAIL, email_verified: true, created_at: '2026-01-01T00:00:00.000Z' }, + ], + sys_organization: [], + sys_member: [], + }; + + const { orgInserts, memberInserts } = await bootAndFire(store, { + object: 'sys_user', + operation: 'insert', + data: { id: 'usr_alice', email: SELF_REGISTRANT_EMAIL }, + }); + + expect(orgInserts).toHaveLength(1); + expect(memberInserts).toHaveLength(1); + expect(memberInserts[0]!.data.user_id).toBe('usr_alice'); + }); + + it('the bootstrap really IS registration-triggered here — a `sys_user` insert reaches the helper', async () => { + // The seam card's inference was that removing the admin grant removes the + // TRIGGER derivatively. It does not: `isDefaultOrganizationBootstrapTrigger` + // fires on a plain `sys_user` insert (objectstack#11973), so clause 2 must + // hold on the KEYING, which is what the cases above measure. Pinned so the + // wrong reason cannot be quietly re-adopted: this asserts the helper was + // CONSULTED on a self-registrant's insert (it read the anchors) while + // writing nothing. + const store: Record = { + sys_permission_set: seededPermissionSets(), + sys_user_permission_set: [], + sys_user: [ + { id: 'usr_alice', email: SELF_REGISTRANT_EMAIL, email_verified: true, created_at: '2026-01-01T00:00:00.000Z' }, + ], + sys_organization: [], + sys_member: [], + }; + + process.env.OS_TENANCY_POSTURE = 'isolated'; + process.env.OS_PLATFORM_OWNER_EMAIL = OWNER_EMAIL; + resetPlatformAdminEmailMemo(); + const { ql, inserts, middlewares } = makeEngine(store); + const { ctx, trigger } = makeCtx(ql); + const plugin = new OrganizationsPlugin(); + await plugin.init(ctx); + await plugin.start(ctx); + await trigger('kernel:bootstrapped'); + await trigger('kernel:ready'); + + const readsBefore = ql.find.mock.calls.length; + const bootstrapMw = middlewares[middlewares.length - 1]; + await bootstrapMw( + { object: 'sys_user', operation: 'insert', data: { id: 'usr_alice' } }, + async () => {}, + ); + expect( + ql.find.mock.calls.length, + 'the sys_user insert did not reach the default-org bootstrap at all', + ).toBeGreaterThan(readsBefore); + expect(inserts).toEqual([]); + }); +}); diff --git a/packages/plugins/organizations/tsconfig.json b/packages/plugins/organizations/tsconfig.json new file mode 100644 index 0000000000..159d0932fe --- /dev/null +++ b/packages/plugins/organizations/tsconfig.json @@ -0,0 +1,43 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + // Widened from `./src` as a CONSEQUENCE of the `paths` rules below — the same + // consequence `packages/plugins/plugin-security/tsconfig.json` and + // `packages/plugins/plugin-auth/tsconfig.json` each record: redirecting a + // workspace specifier to its source puts that package's `src/**` into this + // program, and `rootDir` is enforced over every program file even under + // `--noEmit`. `../..` (= `packages/`) is the directory that contains every + // file in the program. Emit is unaffected: this package builds with tsup, and + // `typecheck` passes `--noEmit`. + "rootDir": "../..", + "types": ["node"], + // Without these rules tsc resolves each specifier through the dependency's + // `exports` map — `dist/index.d.ts`, a BUILD ARTIFACT — so this package's + // typecheck would render a verdict about the last `pnpm build` rather than + // about the producer's source in the checkout, which + // `check:type-source-resolution` refuses (its header states why the dangerous + // case is a typecheck that PASSES). + // + // `tsconfig.test.json` inherits this map rather than declaring its own — a + // child that declared `paths` would REPLACE it, silently sending a + // source-resolved specifier back to `dist/`. That is why the test-only + // dependency (`@objectstack/metadata-core`, which the moved fakes open their + // `update()` with) is listed HERE. + // + // A subpath rule only where the dependency really publishes one and this + // package really imports it: `@objectstack/spec/data` is imported for + // `ServiceObject`. A `paths` target matching nothing on disk would fall back + // to node resolution silently. + "paths": { + "@objectstack/core": ["../../core/src/index.ts"], + "@objectstack/metadata-core": ["../../metadata-core/src/index.ts"], + "@objectstack/plugin-auth": ["../plugin-auth/src/index.ts"], + "@objectstack/spec": ["../../spec/src/index.ts"], + "@objectstack/spec/*": ["../../spec/src/*/index.ts"], + "@objectstack/types": ["../../types/src/index.ts"] + } + }, + "include": ["src/**/*"], + "exclude": ["dist", "node_modules", "**/*.test.ts"] +} diff --git a/packages/plugins/organizations/tsconfig.test.json b/packages/plugins/organizations/tsconfig.test.json new file mode 100644 index 0000000000..caccbb6cb3 --- /dev/null +++ b/packages/plugins/organizations/tsconfig.test.json @@ -0,0 +1,39 @@ +// The TEST-layer type-check program, in the shape the `packages/plugins/**` +// family adopted (#14062). `tsconfig.json` beside this one is the BUILD +// config and stays exactly as it is; this sibling puts the test layer in front +// of tsc under the module semantics vitest really executes it with, and +// `package.json`'s `typecheck` script NAMES it (via `check:test-typecheck +// --project`), because a config no script invokes is a phantom check. +// +// What differs from the build config, and what deliberately does NOT: +// - MODULE SEMANTICS ONLY, plus `lib`. The tests are written and executed as +// ESM by vitest (esbuild/vite); matching that is fidelity, not laxity. +// - ⛔ STRICTNESS IS UNTOUCHED. `strict`, `noUnusedLocals`, +// `noUnusedParameters`, `noImplicitReturns`, `noFallthroughCasesInSwitch`, +// `rootDir` and `types` are all INHERITED from `tsconfig.json` and none of +// them is re-declared here. ⚠️ A child that declared its own `paths` would +// REPLACE the parent map rather than merge into it, silently sending a +// source-resolved specifier back to `dist/` — so this file declares none. +// Nothing here may loosen a type rule; if a test does not compile, that is +// the finding. +// - `lib: ["ES2022"]`, for the reason `packages/rest` states: the root +// config's `lib` is ES2020 and vitest runs on a Node that has es2022 +// builtins, so the gap is reported as TS2550 about the CHECK. No `DOM`: +// nothing in this layer touches a browser global. +// +// This package arrives COVERED (AGENTS.md: "new packages arrive covered"), so +// its `test-typecheck-debt.json` beside this config is expected to be empty. +// The ledger is EXACT and shrink-only: a file that gains an error is red, one +// that loses an error is red until re-recorded, and a file NOT listed there may +// have no errors at all. +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "module": "esnext", + "moduleResolution": "bundler", + "lib": ["ES2022"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/plugins/organizations/vitest.config.ts b/packages/plugins/organizations/vitest.config.ts new file mode 100644 index 0000000000..524d14c4d9 --- /dev/null +++ b/packages/plugins/organizations/vitest.config.ts @@ -0,0 +1,71 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { defineConfig } from 'vitest/config'; +import path from 'path'; + +export default defineConfig({ + resolve: { + // Every workspace dependency this package's tests reach AS A VALUE is + // aliased to source, which is `pnpm check:test-source-alias`'s prescribed + // fix (#7668/#7778); registering the package in that gate's unaliased + // ledger is explicitly NOT — the ledger is shrink-only. + // + // The three entries, and why each one is a VALUE reach rather than a type + // reach (a `import type` is erased before anything resolves and needs no + // alias): + // - `@objectstack/plugin-auth` — `organizations-plugin.ts` imports + // `isDefaultOrganizationBootstrapTrigger`, `ensure-default-organization.ts` + // imports the open `ensureDefaultOrganization` helper it wraps, and + // `membership-policy-gate.ts` imports `isMembershipPolicy` / + // `MEMBERSHIP_POLICIES`. That last pair is the closed VOCABULARY this + // gate adjudicates against, so a `dist/` copy behind the source would + // let a declared-but-invalid policy read as valid — the gate's whole + // subject, answered off a build artifact. + // - `@objectstack/types` — `resolveTenancyPosture()`, which decides + // whether the membership-policy gate runs at all. + // - `@objectstack/core` — `resetPlatformAdminEmailMemo` in + // `walled-default-org-self-registrant.pin.test.ts`, whose subject is + // exactly which principal the default-org bootstrap treats as the + // declared owner. + // + // ANCHORED regex, array form, deliberately: a bare string `find` matches by + // PREFIX, so with a FILE replacement it would also swallow a subpath and + // resolve it to `…/src/index.ts/` — `ENOTDIR`, at run time, from a + // config that reads as correct. `@objectstack/core` really does publish + // subpaths (`./logger`, `./node`), so this is not hypothetical here. + alias: [ + { + find: /^@objectstack\/plugin-auth$/, + replacement: path.resolve(__dirname, '../plugin-auth/src/index.ts'), + }, + { + find: /^@objectstack\/types$/, + replacement: path.resolve(__dirname, '../../types/src/index.ts'), + }, + { + find: /^@objectstack\/core$/, + replacement: path.resolve(__dirname, '../../core/src/index.ts'), + }, + { + // Test-only: the moved fakes open their `update()` with + // `assertEngineUpdateDispatch`, which is the PREDICATE those doubles are + // pinned to (`pnpm check:engine-double-contract`). Resolved from `dist/` + // it would pin them to a stale copy of the producer's rejection rule — + // the exact silent-green this gate exists to stop. + find: /^@objectstack\/metadata-core$/, + replacement: path.resolve(__dirname, '../../metadata-core/src/index.ts'), + }, + ], + }, + test: { + // A late console.* must not redden a green suite (#10374): vitest's worker + // forwards console output over RPC and discards the promise, and a write + // landing after teardown's rpcDone() snapshot is rejected into an unhandled + // error — a fully green run that exits 1. Disarming removes the mechanism. + // Mechanism + measured costs: examples/app-showcase/vitest.config.ts. + // Enforced repo-wide by scripts/check-console-intercept-disarm.mjs. + disableConsoleIntercept: true, + environment: 'node', + include: ['src/**/*.test.ts'], + }, +}); diff --git a/packages/plugins/plugin-security/README.md b/packages/plugins/plugin-security/README.md index b05da10791..21c888ffd5 100644 --- a/packages/plugins/plugin-security/README.md +++ b/packages/plugins/plugin-security/README.md @@ -38,21 +38,24 @@ await kernel.bootstrap(); `SecurityPlugin` is single-tenant by default. It enforces RBAC, owner-based RLS, and Field-Level Security regardless of mode. For **multi-tenant** (logical row-level Organization scoping) the organization wall itself -is **not in this package and not in this repository**. It ships as the enterprise -`@objectstack/organizations` runtime, whose `OrganizationsPlugin` registers the -`org-scoping` service; a host app declares and installs it in its own `package.json`, and -`objectstack serve` resolves it from the app rather than from the framework. It must be -registered **before** `SecurityPlugin`, so the posture probe below finds it. +is **not in this package**. It ships as the separate `@objectstack/organizations` runtime, +whose `OrganizationsPlugin` registers the `org-scoping` service; a host app declares and +installs it in its own `package.json`, and `objectstack serve` resolves it from the app +rather than from the framework. It must be registered **before** `SecurityPlugin`, so the +posture probe below finds it. Asking for the wall without the package is not a silent downgrade: `objectstack serve` prints `FATAL: tenancy posture '' was requested but @objectstack/organizations could not be loaded` and **refuses to boot** (ADR-0093 D5), unless the operator explicitly sets `OS_ALLOW_DEGRADED_TENANCY=1`. `objectstack doctor` reports the same missing runtime. -> ⚠️ Earlier revisions of this page told readers to install `@objectstack/plugin-org-scoping` -> and register an `OrgScopingPlugin` from it. No such package exists — not on npm, and in no -> directory of this repo. The open edition ships no organization wall; there is nothing to -> install *here* to get one. +> ℹ️ `@objectstack/organizations` is Apache-2.0 and lives in this repository +> (`packages/plugins/organizations`), as of ADR-0132 — earlier revisions of this page said +> the wall was not open source at all, and that is no longer true. An enterprise / cloud +> deployment resolves the same package name to a private, licence-gated subclass through its +> own workspace declaration; which one a deployment mounts is decided by the manifest that +> declares the name. The historical `@objectstack/plugin-org-scoping` spelling, and the +> `OrgScopingPlugin` class name, remain as aliases. SecurityPlugin resolves the tenancy **posture** (`single` | `group` | `isolated`) once at start time — preferring the `tenancy` service, and falling back to probing `getService('org-scoping')` (present ⇒ the historical `isolated` posture). Two consequences: diff --git a/packages/qa/dogfood/tsconfig.json b/packages/qa/dogfood/tsconfig.json index 655acc9c9b..0a25cb2d3c 100644 --- a/packages/qa/dogfood/tsconfig.json +++ b/packages/qa/dogfood/tsconfig.json @@ -36,8 +36,20 @@ // — tsc falls back to node resolution, i.e. to `dist`, silently. `rootDir` // already spans the repo root, so pulling the driver's source into this // program needs no widening. + // + // [#16130] `@objectstack/organizations` arrived in this workspace when + // ADR-0132 moved the multi-org runtime to open core, and + // `test/enterprise-organizations.ts` spells a literal + // `import('@objectstack/organizations')` — inside a doc comment, quoting the + // constant-false probe it replaced. `check:type-source-resolution` reads + // specifiers textually and in the fail-closed direction, so it counts that + // one; the rule is what keeps the answer SOURCE either way, and it is the + // answer this suite would want the day it really imports the package. Same + // ONE bare-name shape as the entry above: that package's `exports` map + // carries only `"."`. "paths": { - "@objectstack/driver-turso": ["../../drivers/driver-turso/src/index.ts"] + "@objectstack/driver-turso": ["../../drivers/driver-turso/src/index.ts"], + "@objectstack/organizations": ["../../plugins/organizations/src/index.ts"] } }, "include": ["test/**/*"], diff --git a/packages/runtime/src/dispatcher-error-vocabulary.ts b/packages/runtime/src/dispatcher-error-vocabulary.ts index 7abeec943d..7cdbb1beb8 100644 --- a/packages/runtime/src/dispatcher-error-vocabulary.ts +++ b/packages/runtime/src/dispatcher-error-vocabulary.ts @@ -706,6 +706,29 @@ export const UNREGISTERED_CODE_SITES: readonly UnregisteredCodeSite[] = [ 'UNregistered by #8035 because the CLI rethrows it pre-HTTP and aborts. "Host boot matching ' + 'is not wire vocabulary." Its throw site and constant deliberately live on.', }, + // [#16130] The walled-posture membership-policy gate, which arrived in this + // repository with `@objectstack/organizations` when ADR-0132 moved the + // multi-org runtime to open core. Its row is written here rather than in the + // ledger for the same reason as the two rows below it. + { + code: 'WALLED_MEMBERSHIP_POLICY_UNDECLARED', + file: 'packages/plugins/organizations/src/membership-policy-gate.ts', + shape: 'classconst', + door: 'none', + verdict: 'boot-refusal', + why: + 'Thrown from the plugin\'s own `kernel:bootstrapped` hook, which fires BEFORE ' + + '`kernel:listening` opens the socket — so no request is ever served by a deployment this ' + + 'refuses, and no HTTP boundary exists on the path. `objectstack serve` prints the message ' + + 'verbatim and exits 1; a multi-kernel host catches it per kernel. The `code` field exists ' + + 'to let such a host discriminate this refusal from the two neighbouring boot refusals ' + + '(a licence failure and an absent package) STRUCTURALLY rather than by string match, ' + + 'across module instances — which is a host-boot concern, not wire vocabulary. Same class ' + + // The precedent is #8035's ruling on the MULTI_TENANT_UNSUPPORTED pair + // below — in a comment, not in the string: `pnpm check:doc-authoring` + // keeps tracker ids out of prose a reader cannot resolve them from. + 'and the same reasoning as the MULTI_TENANT_UNSUPPORTED pair below.', + }, { code: 'MEMORY_MULTI_TENANT_UNSUPPORTED', file: 'packages/drivers/driver-memory/src/memory-tenancy-guard.ts', diff --git a/packages/services/service-cluster/src/multi-node-gate-mount.ts b/packages/services/service-cluster/src/multi-node-gate-mount.ts index 3064d7b6d1..9118797303 100644 --- a/packages/services/service-cluster/src/multi-node-gate-mount.ts +++ b/packages/services/service-cluster/src/multi-node-gate-mount.ts @@ -66,6 +66,18 @@ import { hasMultiNodeGate } from './multi-node-gate.js'; * A carrier's obligation is: **register the gate as a side effect of module * load** (`registerMultiNodeGate` at module scope), so that being imported — * by any route — is sufficient to mount it. + * + * ⚠️ `@objectstack/organizations` is ONE NAME over TWO packages since ADR-0132: + * the framework publishes an Apache-2.0 package of that name and the commercial + * repo keeps a private licence-gated subclass of it, each resolved from the + * manifest that declares it. Only the commercial one carries the gate, and that + * asymmetry is deliberate (ADR-0132 boundary 2 — the open package must not + * acquire the carrier). What changed for an OPEN install is the DIAGNOSTIC and + * nothing else: this carrier's import used to fail, recording `unavailable`, and + * now succeeds while registering nothing, recording `loaded-without-gate`. Both + * leave `registered: false`, so the gate's fail-closed default refuses a + * multi-node verdict exactly as before. ⛔ Do not "repair" the new outcome by + * teaching the open package to register a gate. */ export const MULTI_NODE_GATE_CARRIER_PACKAGES: readonly string[] = Object.freeze([ '@objectstack/security-enterprise', diff --git a/packages/spec/llms.txt b/packages/spec/llms.txt index 7cef3c7d65..40bc8372c6 100644 --- a/packages/spec/llms.txt +++ b/packages/spec/llms.txt @@ -173,9 +173,9 @@ function registerObject(rawConfig: unknown) { --- -## 7. Package Ecosystem (68 packages) +## 7. Package Ecosystem (69 packages) -The workspace publishes 68 packages under the `@objectstack` scope. The table +The workspace publishes 69 packages under the `@objectstack` scope. The table below is a curated entry-point list, not the full set — drivers, connectors, triggers, plugins and kernel-managed services each form their own family. diff --git a/packages/spec/src/kernel/platform-capabilities.ts b/packages/spec/src/kernel/platform-capabilities.ts index 48d7f1b2ae..70219897a7 100644 --- a/packages/spec/src/kernel/platform-capabilities.ts +++ b/packages/spec/src/kernel/platform-capabilities.ts @@ -237,9 +237,14 @@ export const PLATFORM_PLUGIN_WIRED_RUNTIMES: Readonly p.includes('`Row 34`') && p.includes('is row 35')) && - falsifiedRefs.some((p) => p.includes('`rows 1–61`')), + falsifiedRefs.some((p) => p.includes('`rows 1–62`')), ablated.problems.join(' | ') ); t( diff --git a/scripts/cross-package-test-inputs.mjs b/scripts/cross-package-test-inputs.mjs index 11096e738b..8dd4920315 100644 --- a/scripts/cross-package-test-inputs.mjs +++ b/scripts/cross-package-test-inputs.mjs @@ -299,6 +299,35 @@ export const CROSS_PACKAGE_TEST_INPUTS = { // in ci.yml's `crosspkg:` filter. Its own header records that trade. globs: ['packages/**/*.ts'], }, + '@objectstack/organizations': { + // src/no-framework-dependents.pin.test.ts is ADR-0132 D3's mechanical half. + // This package and the commercial multi-org runtime share ONE package name; + // which class a deployment mounts is decided by the manifest that declares + // that name, and the one thing that would break it is a FRAMEWORK package + // taking `@objectstack/organizations` as its own dependency — which would + // put the ungated copy inside the tree a commercial app links, reachable by + // a bare import that never consults the app's manifest. So the pin reads + // every workspace package manifest and fails on any such declaration. + // + // The radius is the MANIFESTS and nothing else: the pin parses + // `package.json` and never opens a source file, so a glob over + // `packages/**` would put this suite on every source edit in the repo for a + // read it does not make. It stays inside `packages/` on purpose — `apps/` + // and `examples/` are HOSTS, and a host declaring the runtime it wants to + // mount is the supported wiring rather than the hazard — so this entry adds + // no new top-level root for `check-ci-filter-parity.mjs` to want in ci.yml. + globs: ['packages/**/package.json'], + heldBy: { + // The pin seeds a recognised `findUp(pnpm-workspace.yaml)` expression and + // then descends with `readdirSync(dir)` on a LOOP VARIABLE, so the escape + // verdict resolves and the NAME does not — the trade `pathExpression` + // documents. Measured: no literal path on this package's roster matches + // this glob, so the pin is all that holds it. + 'packages/**/package.json': [ + 'packages/plugins/organizations/src/no-framework-dependents.pin.test.ts', + ], + }, + }, '@objectstack/cli': { // src/commands/serve-verify-security-parity.contract.test.ts diffs // cli's serve.ts against verify's harness.ts. diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 4d0fdda458..519c268bd4 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -2136,6 +2136,21 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/plugins/organizations/src/claim-org-seed-ownership.test.ts", + "verb": "update", + "pinned": 1 + }, + { + "file": "packages/plugins/organizations/src/claim-orphan-org-rows.test.ts", + "verb": "update", + "pinned": 2 + }, + { + "file": "packages/plugins/organizations/src/org-creation-no-cross-org-copy.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/plugins/plugin-approvals/src/admin-exemption-retired.test.ts", "verb": "delete", diff --git a/turbo.json b/turbo.json index 7a424f0721..0ffdd66122 100644 --- a/turbo.json +++ b/turbo.json @@ -109,6 +109,17 @@ "$TURBO_ROOT$/packages/**/*.ts" ] }, + "@objectstack/organizations#test": { + "dependsOn": ["^build"], + "outputs": [], + "inputs": [ + "$TURBO_DEFAULT$", + "!dist/**", + "!coverage/**", + "!.turbo/**", + "$TURBO_ROOT$/packages/**/package.json" + ] + }, "@objectstack/cli#test": { "dependsOn": ["build"], "outputs": [],