From 8afa0643d0d7cce829874808feafc778b734e299 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 19:47:22 +0000 Subject: [PATCH 1/4] feat(cli)!: delete os meta resync, and delete the example position binders (#15204 stage 6b-2a) Claude-Session: https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49 Co-authored-by: Claude --- ...15204-s6b2a-meta-resync-example-binders.md | 18 + content/docs/data-modeling/drivers.mdx | 2 +- .../areas/access-security.json | 2 +- docs/qa/platform-checklist/areas/search.json | 2 +- examples/app-crm/objectstack.config.ts | 10 - .../src/security/bind-position-sets.ts | 121 ------ .../app-crm/test/position-bindings.test.ts | 18 +- examples/app-showcase/objectstack.config.ts | 4 - examples/app-showcase/src/data/seed/index.ts | 10 +- .../src/security/bind-position-sets.ts | 123 ------ .../src/security/permission-sets.ts | 4 +- .../app-showcase/src/security/positions.ts | 7 +- .../src/security/seed-approval-demo.ts | 6 +- .../app-showcase/test/inert-wirings.test.ts | 18 +- .../meta/resync-skip-explanation.test.ts | 80 ---- packages/cli/src/commands/meta/resync.ts | 243 ------------ packages/cli/src/utils/format.ts | 2 +- ...form-migrations-arming.integration.test.ts | 2 +- ...te.dev-self-heal-fence.integration.test.ts | 4 +- .../schema-migrate.lazy-driver-import.test.ts | 2 +- ...igrate.one-shot-family.integration.test.ts | 8 +- packages/cli/src/utils/schema-migrate.ts | 12 +- .../cli/test/json-stdout-purity.e2e.test.ts | 1 - packages/drivers/driver-sql/src/sql-driver.ts | 2 +- ...p-platform-admin-seeded-provenance.test.ts | 369 ------------------ .../src/bootstrap-platform-admin.test.ts | 143 ------- .../src/claim-seed-ownership.ts | 2 +- .../src/permission-set-projection.ts | 2 +- packages/runtime/src/standalone-stack.ts | 2 +- scripts/engine-double-contract.baseline.json | 8 - scripts/objectql-double-limit.baseline.json | 3 - .../references/operations.md | 2 +- 32 files changed, 73 insertions(+), 1159 deletions(-) create mode 100644 .changeset/15204-s6b2a-meta-resync-example-binders.md delete mode 100644 examples/app-crm/src/security/bind-position-sets.ts delete mode 100644 examples/app-showcase/src/security/bind-position-sets.ts delete mode 100644 packages/cli/src/commands/meta/resync-skip-explanation.test.ts delete mode 100644 packages/cli/src/commands/meta/resync.ts delete mode 100644 packages/plugins/plugin-security/src/bootstrap-platform-admin-seeded-provenance.test.ts delete mode 100644 packages/plugins/plugin-security/src/bootstrap-platform-admin.test.ts diff --git a/.changeset/15204-s6b2a-meta-resync-example-binders.md b/.changeset/15204-s6b2a-meta-resync-example-binders.md new file mode 100644 index 00000000000..eb592f48db0 --- /dev/null +++ b/.changeset/15204-s6b2a-meta-resync-example-binders.md @@ -0,0 +1,18 @@ +--- +'@objectstack/cli': minor +'@objectstack/plugin-security': patch +'@objectstack/driver-sql': patch +'@objectstack/runtime': patch +--- + +feat(cli)!: `os meta resync` is deleted + +Clause-②: yes (narrowing) + + + +**BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. + +`os meta resync` reconciled the platform-default `sys_permission_set` rows (`admin_full_access`, `member_default`, …) to the shipped declaration. Since the authorization resolver reads permission sets from the security catalog (ADR-0131 D3), an edited default set is served from its declaration at the next boot, and the rows the command reconciled decide nothing. The command, its `--yes` / `--json` modes and its skip-count explanation are gone; a script that calls it gets oclif's unknown-command error. + +In `@objectstack/plugin-security`, `@objectstack/driver-sql` and `@objectstack/runtime`, shipped comments and one warning sentence (the permission-set backfill's re-provision list) no longer name the command. Their behaviour is unchanged. diff --git a/content/docs/data-modeling/drivers.mdx b/content/docs/data-modeling/drivers.mdx index 5065db485e9..3021dfe3ccd 100644 --- a/content/docs/data-modeling/drivers.mdx +++ b/content/docs/data-modeling/drivers.mdx @@ -735,7 +735,7 @@ A file-backed SQLite database is switched to **WAL** (write-ahead logging) the first time ObjectStack connects to it. SQLite's own default is a rollback journal (`journal_mode = delete`), which is the wrong trade for the way this platform is actually used — **several processes on one file**: a dev server, -`os migrate`, `os meta resync`, a test run. +`os migrate`, a test run. | | rollback journal (SQLite default) | WAL (ObjectStack default) | |:---|:---|:---| diff --git a/docs/qa/platform-checklist/areas/access-security.json b/docs/qa/platform-checklist/areas/access-security.json index 0034176ba3e..e379a6cbc9b 100644 --- a/docs/qa/platform-checklist/areas/access-security.json +++ b/docs/qa/platform-checklist/areas/access-security.json @@ -809,7 +809,7 @@ "app": "showcase", "requires": [ "showcase_inquiry is OWD private with create granted to the baseline — depth only matters where the baseline stops (permission-sets.ts docblock)", - "personas staffed by granting the sets (sys_user_permission_set) or assigning the bound positions manager/exec/ops (bind-position-sets.ts wires position → set at boot)" + "personas staffed by granting the sets (sys_user_permission_set) or assigning the positions manager/exec/ops (each declares its set in permissionSets, examples/app-showcase/src/security/positions.ts)" ] }, "steps": [ diff --git a/docs/qa/platform-checklist/areas/search.json b/docs/qa/platform-checklist/areas/search.json index 4538380d3a8..f3c3120cc97 100644 --- a/docs/qa/platform-checklist/areas/search.json +++ b/docs/qa/platform-checklist/areas/search.json @@ -11,7 +11,7 @@ "provenance": "run #7629 (framework, 2026-08-11) drove search.rls-both-personas to 4/4 PASS on exactly this persona — 3 invoices visible to the member vs 15 to admin. Promoted from prose on that one item to a replayable area recipe by the 17.1.0 sweep (#10236 B1); the Mei/Ada measurements above are from #10257. Step 1 re-pointed invite-first by #21060 after QA run #21056 met the invite_only default (403 SELF_REGISTRATION_CLOSED on an uninvited sign-up).", "app": "showcase", "requires": [ - "a stock dev boot — the position→permission-set bindings are created automatically at startup on `kernel:bootstrapped`, including ['contributor','showcase_contributor'] (examples/app-showcase/src/security/bind-position-sets.ts, POSITION_PERMISSION_SET_BINDINGS). ⛔ Do NOT hand-create a sys_position_permission_set row: it already exists, and a duplicate proves nothing about the merge", + "a stock dev boot — the `contributor` position declares `showcase_contributor` in its `permissionSets` (examples/app-showcase/src/security/positions.ts), which the resolver reads by name (ADR-0131 D3/D4). ⛔ Do NOT hand-create a sys_position_permission_set row: no resolver reads it, so it proves nothing about the merge", "system-context write access for the position binding (the sys_user_position insert) — the same pattern access-security.json's permission-set items already replay", "an isolated boot (own port + file DB, dogfood §0); export OS_PORT= as well as passing -p (RUNNER.md's connector self-URL fact) so unrelated flow noise does not pollute the run", "an admin session to INVITE the step-1 address before step 1: POST /api/v1/auth/organization/invite-member {\"email\": \"\", \"role\": \"member\"}. The stock showcase declares no auth.audience, so its posture is the invite_only default, which answers an uninvited sign-up 403 SELF_REGISTRATION_CLOSED; a pending, unexpired sys_invitation row for the address is what admits it (an admin POST /api/v1/auth/admin/create-user is the other door, and answers mustChangePassword:true)" diff --git a/examples/app-crm/objectstack.config.ts b/examples/app-crm/objectstack.config.ts index 8ff69b242ba..10ffc96fc6b 100644 --- a/examples/app-crm/objectstack.config.ts +++ b/examples/app-crm/objectstack.config.ts @@ -18,7 +18,6 @@ import { SalesUserPermissionSet, GuestPortalProfile, } from './src/security/index.js'; -import { registerCrmPositionBindings } from './src/security/bind-position-sets.js'; import { CrmSeedData } from './src/data/index.js'; import { CrmDatasource, CrmAnalyticsDatasource } from './src/datasources/crm.datasource.js'; import { CrmTranslationBundle } from './src/translations/crm.translation.js'; @@ -118,12 +117,3 @@ export default defineStack({ // Seed data data: CrmSeedData, }); - -/** - * [#8060] Ensure the persona position↔permission-set bindings exist after the - * security bootstraps (cannot be a seed — see bind-position-sets.ts). Mirrors - * app-showcase's own `onEnable` wiring. - */ -export const onEnable = async (ctx: unknown): Promise => { - registerCrmPositionBindings(ctx as Parameters[0]); -}; diff --git a/examples/app-crm/src/security/bind-position-sets.ts b/examples/app-crm/src/security/bind-position-sets.ts deleted file mode 100644 index e8b9944d199..00000000000 --- a/examples/app-crm/src/security/bind-position-sets.ts +++ /dev/null @@ -1,121 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [#8060] Position ↔ permission-set bindings for the CRM example. - * - * [ADR-0131 D3/D4] The bindings are DECLARED on the positions - * (`permissionSets` in `sales-positions.ts`), and that is what the - * authorization resolver reads. This binder still writes the same pairs as - * `sys_position_permission_set` rows for the readers that have not moved off - * the junction yet; it is deleted with the last of them, and - * `test/position-bindings.test.ts` holds its list equal to the declarations. - * - * Mirrors `examples/app-showcase/src/security/bind-position-sets.ts`. The - * history: bindings used to live only as `sys_position_permission_set` rows, - * and this app declared three positions - * (`sales_rep`, `sales_manager`, `finance_approver`) and a `crm_sales_user` - * permission set that never met — every persona silently degraded to the - * `everyone` baseline (probe-blocked on every CRM object) until an admin - * hand-assigned the set. - * - * This cannot be a declarative SEED: the seed loader runs before the security - * bootstrap creates the `sys_position` / `sys_permission_set` rows, so the - * name references cannot resolve. We play the admin's part imperatively — - * inserting each missing binding idempotently (dedup by position+set pair, - * stable ids) — on `kernel:bootstrapped`, the anchor the kernel fires only - * AFTER every `kernel:ready` handler (incl. the security bootstrap) has - * settled. - * - * `crm_sales_user` is deliberately NOT marked `isDefault`: that would - * auto-bind it to the `everyone` anchor and grant every user — including - * ones holding none of the three positions — full CRUD on every CRM object, - * which changes the example's security story instead of completing it - * (ruling on #8060). Only the three declared positions get the set. - */ - -export const BINDINGS: ReadonlyArray = [ - ['sales_rep', 'crm_sales_user'], - ['sales_manager', 'crm_sales_user'], - ['finance_approver', 'crm_sales_user'], -]; - -const SYS = { isSystem: true } as const; - -interface BindHostContext { - ql: { - find: (object: string, query: unknown, options?: unknown) => Promise; - insert: (object: string, data: Record, options?: unknown) => Promise; - }; - logger?: { info?: (...a: unknown[]) => void; warn?: (...a: unknown[]) => void }; - hook?: (event: string, handler: () => Promise | void) => void; -} - -/** Find one row by `name`, passing the system context the way the engine's own - * read path expects it (merged from `query.context`; see objectql `find`). */ -async function findOneByName(ctx: BindHostContext, object: string, name: string): Promise<{ id?: string } | undefined> { - try { - const rows = (await ctx.ql.find(object, { where: { name }, limit: 1, context: SYS })) as - | Array<{ id?: string }> - | { records?: Array<{ id?: string }> }; - if (Array.isArray(rows)) return rows[0]; - return rows?.records?.[0]; - } catch (err) { - ctx.logger?.warn?.('[crm] position binding lookup failed', { - object, - name, - error: err instanceof Error ? err.message : String(err), - }); - return undefined; - } -} - -export function registerCrmPositionBindings(ctx: BindHostContext): void { - const run = async (): Promise => { - let created = 0; - for (const [positionName, setName] of BINDINGS) { - const position = await findOneByName(ctx, 'sys_position', positionName); - const set = await findOneByName(ctx, 'sys_permission_set', setName); - if (!position?.id || !set?.id) { - ctx.logger?.warn?.('[crm] position binding skipped (row missing)', { position: positionName, set: setName }); - continue; - } - const existing = (await ctx.ql.find( - 'sys_position_permission_set', - { where: { position_id: position.id, permission_set_id: set.id }, limit: 1, context: SYS }, - )) as unknown; - const hit = Array.isArray(existing) ? existing[0] : (existing as { records?: unknown[] })?.records?.[0]; - if (hit) continue; - try { - await ctx.ql.insert( - 'sys_position_permission_set', - { id: `ppsb_crm_${positionName}`, position_id: position.id, permission_set_id: set.id }, - { context: SYS }, - ); - created += 1; - } catch (err) { - ctx.logger?.warn?.('[crm] position binding insert failed', { - position: positionName, - set: setName, - error: err instanceof Error ? err.message : String(err), - }); - } - } - ctx.logger?.info?.('[crm] position bindings ensured', { created, total: BINDINGS.length }); - }; - - // Bind on `kernel:bootstrapped` — the anchor that fires only after every - // `kernel:ready` handler (incl. the security bootstrap that seeds the - // position/set rows) has settled. Fall back to a deferred immediate run - // if the host context somehow omits the hook registrar (never true for the - // real runtime `PluginContext`, which always provides one — this branch is - // unreachable in practice, so a microtask deferral is equivalent to the - // macrotask `setTimeout` it replaces). Pure-ES deferral, no ambient - // `setTimeout`/`window`/`node` global: `examples/**` sources reachable from - // `scripts/analytics-reconcile/*.ts` type-check under the workspace ROOT - // tsconfig, which declares neither `dom` nor `types: ["node"]`. - if (typeof ctx.hook === 'function') { - ctx.hook('kernel:bootstrapped', run); - } else { - void Promise.resolve().then(run); - } -} diff --git a/examples/app-crm/test/position-bindings.test.ts b/examples/app-crm/test/position-bindings.test.ts index 7756be56f1f..68ea7cead93 100644 --- a/examples/app-crm/test/position-bindings.test.ts +++ b/examples/app-crm/test/position-bindings.test.ts @@ -2,23 +2,23 @@ /** * [ADR-0131 D3/D4] The CRM personas' permission sets are declared on their - * positions (`permissionSets`), which the authorization resolver reads. The - * junction binder (`src/security/bind-position-sets.ts`) still writes the same - * pairs for the readers that have not moved off `sys_position_permission_set`; - * until it is deleted, the two must name the same pairs, or those readers - * judge a position by bindings the resolver does not grant. + * positions (`permissionSets`), which the authorization resolver reads; no + * junction row is written for them. Pinned literally so a persona losing its + * set is a red here, not a silently narrower boot. */ import { describe, expect, it } from 'vitest'; -import { BINDINGS } from '../src/security/bind-position-sets.js'; import { FinanceApproverPosition, SalesManagerPosition, SalesRepPosition } from '../src/security/sales-positions.js'; describe('CRM position bindings', () => { - it('the junction binder writes exactly the bindings the position definitions declare', () => { + it('each persona position declares crm_sales_user', () => { const declared = [SalesRepPosition, SalesManagerPosition, FinanceApproverPosition] .flatMap((p) => (p.permissionSets ?? []).map((s) => `${p.name} -> ${s}`)) .sort(); - expect(declared).toHaveLength(3); - expect(BINDINGS.map(([p, s]) => `${p} -> ${s}`).sort()).toEqual(declared); + expect(declared).toEqual([ + 'finance_approver -> crm_sales_user', + 'sales_manager -> crm_sales_user', + 'sales_rep -> crm_sales_user', + ]); }); }); diff --git a/examples/app-showcase/objectstack.config.ts b/examples/app-showcase/objectstack.config.ts index ce5fefe511b..f1777eba834 100644 --- a/examples/app-showcase/objectstack.config.ts +++ b/examples/app-showcase/objectstack.config.ts @@ -18,7 +18,6 @@ import { ShowcaseExternalDatasource } from './src/system/datasources/showcase-ex import { ExternalCustomer, ExternalOrder } from './src/data/objects/external/index.js'; import { setupShowcaseExternalDatasource } from './src/system/datasources/external-fixture.js'; import { registerRecalcEndpoint } from './src/system/server/recalc-endpoint.js'; -import { registerShowcasePositionBindings } from './src/security/bind-position-sets.js'; import { registerShowcaseApprovalDemo } from './src/security/seed-approval-demo.js'; import { TaskViews, ProjectViews, InquiryViews, BusinessUnitViews, ContactViews, FieldZooViews } from './src/ui/views/index.js'; import { ShowcaseApp } from './src/ui/apps/index.js'; @@ -293,9 +292,6 @@ export const onEnable = async (ctx: unknown): Promise => { await setupShowcaseExternalDatasource(ctx as Parameters[0]); // Mount the custom REST endpoint behind the `showcase_recalc_estimate` api action. registerRecalcEndpoint(ctx as Parameters[0]); - // [#2926 ②] Ensure the persona position↔permission-set bindings exist after - // the security bootstraps (cannot be a seed — see bind-position-sets.ts). - registerShowcasePositionBindings(ctx as Parameters[0]); // Make the v16 approval features (会签 / quorum) demonstrable on a fresh boot: // assign the dev admin the approver positions and launch the signoff flows so // real pending requests land in the inbox (cannot be a seed — see diff --git a/examples/app-showcase/src/data/seed/index.ts b/examples/app-showcase/src/data/seed/index.ts index 3a24a642188..80abc28c1b5 100644 --- a/examples/app-showcase/src/data/seed/index.ts +++ b/examples/app-showcase/src/data/seed/index.ts @@ -258,12 +258,10 @@ const orgUnits = SeedSchema.parse({ ], }); -// [#2926 ②] Position ↔ permission-set bindings are NOT seeded here: the seed -// loader runs before the security bootstrap creates the sys_position / -// sys_permission_set rows, so the required name references cannot resolve. -// They are ensured imperatively on kernel:bootstrapped instead (after every -// kernel:ready handler, incl. the security bootstrap, has settled) — see -// `src/security/bind-position-sets.ts` (wired via `onEnable`). +// [#2926 ②, ADR-0131 D3/D4] Position ↔ permission-set bindings are NOT seeded +// here: each position declares the sets it distributes in `permissionSets` +// (`src/security/positions.ts`), and the authorization resolver reads them +// from the definition — no binding row exists to seed. const teams = defineSeed(Team, { mode: 'upsert', diff --git a/examples/app-showcase/src/security/bind-position-sets.ts b/examples/app-showcase/src/security/bind-position-sets.ts deleted file mode 100644 index 645ccd00166..00000000000 --- a/examples/app-showcase/src/security/bind-position-sets.ts +++ /dev/null @@ -1,123 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [#2926 ②] Position ↔ permission-set bindings for the showcase personas. - * - * [ADR-0131 D3/D4] The bindings are DECLARED on the positions - * (`permissionSets` in `positions.ts`), and that is what the authorization - * resolver reads. This binder still writes the same pairs as - * `sys_position_permission_set` rows for the readers that have not moved off - * the junction yet (the delegated-admin gate, the explainer, the sharing - * recipients); it is deleted with the last of them. - * `test/inert-wirings.test.ts` holds this list equal to the declarations. - * - * This cannot be a declarative SEED: the seed loader runs before the security - * bootstrap creates the `sys_position` / `sys_permission_set` rows, so the name - * references cannot resolve and the required lookups fail validation. So we play - * the admin's part imperatively — inserting each missing binding idempotently - * (dedup by position+set pair, stable ids). - * - * Timing matters. `kernel:ready` handlers run SEQUENTIALLY, each awaited, in - * registration order (`kernel.ts` `trigger`). The showcase AppPlugin starts - * BEFORE the Security plugin, so an app hook on `kernel:ready` runs *before* - * the security bootstrap has created the position/set rows — the rows never - * appear from inside that hook. We therefore bind on **`kernel:bootstrapped`**, - * the anchor the kernel fires only AFTER every `kernel:ready` handler has - * settled (`kernel.ts` Phase 3.5 / `lite-kernel.ts`), so the bootstrap rows are - * guaranteed present. - * - * `everyone → showcase_member_default` is NOT bound here: the security plugin - * auto-binds the app's `isDefault` set (resolved as its `fallbackPermissionSet`) - * to `everyone` at boot. This list only carries the persona → set bindings the - * framework cannot infer. - */ - -/** - * The persona bindings as junction rows — the same pairs the position - * definitions declare (`inert-wirings.test.ts` pins the equality). - */ -export const POSITION_PERMISSION_SET_BINDINGS: ReadonlyArray = [ - ['contributor', 'showcase_contributor'], - ['manager', 'showcase_manager'], - ['exec', 'showcase_executive'], - ['auditor', 'showcase_auditor'], - ['ops', 'showcase_ops'], - ['field_ops_delegate', 'showcase_field_ops_delegate'], - ['client_liaison', 'showcase_client_liaison'], - ['client_portal_user', 'showcase_guest_portal'], -]; - -const SYS = { isSystem: true } as const; - -interface BindHostContext { - ql: { - find: (object: string, query: unknown, options?: unknown) => Promise; - insert: (object: string, data: Record, options?: unknown) => Promise; - }; - logger?: { info?: (...a: unknown[]) => void; warn?: (...a: unknown[]) => void }; - hook?: (event: string, handler: () => Promise | void) => void; -} - -/** Find one row by `name`, passing the system context the way the engine's own - * read path expects it (merged from `query.context`; see objectql `find`). */ -async function findOneByName(ctx: BindHostContext, object: string, name: string): Promise<{ id?: string } | undefined> { - try { - const rows = (await ctx.ql.find(object, { where: { name }, limit: 1, context: SYS })) as - | Array<{ id?: string }> - | { records?: Array<{ id?: string }> }; - if (Array.isArray(rows)) return rows[0]; - return rows?.records?.[0]; - } catch (err) { - ctx.logger?.warn?.('[showcase] position binding lookup failed', { - object, - name, - error: err instanceof Error ? err.message : String(err), - }); - return undefined; - } -} - -export function registerShowcasePositionBindings(ctx: BindHostContext): void { - const run = async (): Promise => { - let created = 0; - for (const [positionName, setName] of POSITION_PERMISSION_SET_BINDINGS) { - const position = await findOneByName(ctx, 'sys_position', positionName); - const set = await findOneByName(ctx, 'sys_permission_set', setName); - if (!position?.id || !set?.id) { - ctx.logger?.warn?.('[showcase] position binding skipped (row missing)', { position: positionName, set: setName }); - continue; - } - const existing = (await ctx.ql.find( - 'sys_position_permission_set', - { where: { position_id: position.id, permission_set_id: set.id }, limit: 1, context: SYS }, - )) as unknown; - const hit = Array.isArray(existing) ? existing[0] : (existing as { records?: unknown[] })?.records?.[0]; - if (hit) continue; - try { - await ctx.ql.insert( - 'sys_position_permission_set', - { id: `ppsb_showcase_${positionName}`, position_id: position.id, permission_set_id: set.id }, - { context: SYS }, - ); - created += 1; - } catch (err) { - ctx.logger?.warn?.('[showcase] position binding insert failed', { - position: positionName, - set: setName, - error: err instanceof Error ? err.message : String(err), - }); - } - } - ctx.logger?.info?.('[showcase] position bindings ensured', { created, total: POSITION_PERMISSION_SET_BINDINGS.length }); - }; - - // Bind on `kernel:bootstrapped` — the anchor that fires only after every - // `kernel:ready` handler (incl. the security bootstrap that seeds the - // position/set rows) has settled. Fall back to a deferred immediate run - // if the host context somehow omits the hook registrar. - if (typeof ctx.hook === 'function') { - ctx.hook('kernel:bootstrapped', run); - } else { - setTimeout(() => void run(), 0); - } -} diff --git a/examples/app-showcase/src/security/permission-sets.ts b/examples/app-showcase/src/security/permission-sets.ts index 631edfd1a0d..62a413f870b 100644 --- a/examples/app-showcase/src/security/permission-sets.ts +++ b/examples/app-showcase/src/security/permission-sets.ts @@ -72,8 +72,8 @@ export const ContributorPermissionSet = definePermissionSet({ // // No policy below lists `positions`, deliberately. A set's row-level policies // are part of what the set grants, so they hold for EVERY holder of the set, - // whichever way it is held: through the `contributor` position binding - // (bind-position-sets.ts) or granted directly (the delegated admin below may + // whichever way it is held: through the `contributor` position's declared + // `permissionSets` (positions.ts) or granted directly (the delegated admin below may // hand this set out). `positions` narrows a policy to callers holding one of // the listed positions (ADR-0090 P2) — a tool for a policy on a baseline set // every member resolves, not for a set that is itself the grant. diff --git a/examples/app-showcase/src/security/positions.ts b/examples/app-showcase/src/security/positions.ts index 297cd2e2401..3a8d66a1bcb 100644 --- a/examples/app-showcase/src/security/positions.ts +++ b/examples/app-showcase/src/security/positions.ts @@ -10,10 +10,9 @@ * (ADR-0090 D3; the old `parent` field on positions never existed at runtime). * * Each position names the permission sets it distributes in `permissionSets` - * (ADR-0131 D3/D4): the authorization resolver reads them from here, by name. - * `src/security/bind-position-sets.ts` still writes the same bindings as - * `sys_position_permission_set` rows, for the readers that have not moved off - * the junction yet; `test/inert-wirings.test.ts` holds the two equal. + * (ADR-0131 D3/D4): the authorization resolver reads them from here, by name, + * and no binding row is written for them. `test/inert-wirings.test.ts` pins + * the declared pairs. * * Two positions are BUILT-IN and never declared by an app: `everyone` * (implicitly held by every authenticated member — the tenant baseline, diff --git a/examples/app-showcase/src/security/seed-approval-demo.ts b/examples/app-showcase/src/security/seed-approval-demo.ts index a0d0b09a7e7..74ff671836d 100644 --- a/examples/app-showcase/src/security/seed-approval-demo.ts +++ b/examples/app-showcase/src/security/seed-approval-demo.ts @@ -18,9 +18,9 @@ * - `sys_approval_request` is engine-owned (ADR-0103: get/list only), so a * request can't be inserted through the generic data API either. * - * So we play the admin's part imperatively, exactly like `bind-position-sets.ts`: - * on `kernel:bootstrapped` (after the security bootstrap has created the - * position/permission rows and the automation engine is wired) we + * So we play the admin's part imperatively: on `kernel:bootstrapped` (after + * every `kernel:ready` handler has settled and the automation engine is + * wired) we * 1. assign the dev-seeded admin to `manager` / `finance` / `legal` so they * are a resolvable approver on every demo request (and can act in the * inbox); diff --git a/examples/app-showcase/test/inert-wirings.test.ts b/examples/app-showcase/test/inert-wirings.test.ts index 1af7f7ca9f0..c5111fab2c9 100644 --- a/examples/app-showcase/test/inert-wirings.test.ts +++ b/examples/app-showcase/test/inert-wirings.test.ts @@ -26,7 +26,6 @@ import { validateFunctionalCompleteness } from '@objectstack/lint'; import type { JobHandlerContext } from '@objectstack/runtime'; import { healthFor, sweepProjectHealth } from '../src/automation/jobs/index.js'; import * as jobsModule from '../src/automation/jobs/index.js'; -import { POSITION_PERMISSION_SET_BINDINGS } from '../src/security/bind-position-sets.js'; import { ADMIN_EMAIL, PHONE_DEMO_USER, @@ -705,10 +704,21 @@ describe('sharing rules target an object under record-sharing enforcement (#9237 // readers that have not moved off `sys_position_permission_set`; until it is // deleted the two must name the same pairs, or those readers judge a // position by bindings the resolver does not grant. - it('the junction binder writes exactly the bindings the position definitions declare', () => { + it('each persona position declares the permission set its personas hold (ADR-0131 D3/D4)', () => { + // The position definition is the binding the resolver reads; no junction + // row is written for it. Pinned literally so a persona losing its set is a + // red here, not a silently narrower dogfood boot. const declared = positions.flatMap((p) => (p.permissionSets ?? []).map((s) => `${p.name} -> ${s}`)).sort(); - expect(declared.length).toBeGreaterThan(0); - expect(POSITION_PERMISSION_SET_BINDINGS.map(([p, s]) => `${p} -> ${s}`).sort()).toEqual(declared); + expect(declared).toEqual([ + 'auditor -> showcase_auditor', + 'client_liaison -> showcase_client_liaison', + 'client_portal_user -> showcase_guest_portal', + 'contributor -> showcase_contributor', + 'exec -> showcase_executive', + 'field_ops_delegate -> showcase_field_ops_delegate', + 'manager -> showcase_manager', + 'ops -> showcase_ops', + ]); }); it('no rule is anchored on an object whose OWD leaves nothing to widen', () => { diff --git a/packages/cli/src/commands/meta/resync-skip-explanation.test.ts b/packages/cli/src/commands/meta/resync-skip-explanation.test.ts deleted file mode 100644 index 6408df66492..00000000000 --- a/packages/cli/src/commands/meta/resync-skip-explanation.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// -// #9184 — `os meta resync` reports a nonzero skip count with no explanation. -// -// The docblock half (#9130 / PR #9183) already explains, IN SOURCE, that a -// stored `'admin'` (or legacy `'user'`) stamp is not always a deliberate -// Setup takeover — on any install created before commit 712e185db the platform's own -// seeder wrote that exact stamp, so `resynced 0 / skipped N` is a permanent, -// by-design outcome there. This card is the runtime-output half: the same -// explanation has to reach the operator who is staring at the terminal, -// never having gone looking for the source. -// -// What is pinned here: -// • the explanation fires exactly when `resyncSkipped > 0` — the same -// condition the skip-count summary itself uses, never a narrower one -// (e.g. `resynced === 0`); -// • it names BOTH spellings of the legacy-vs-current provenance value -// (`admin` and `user`), because a stored legacy row can surface under -// either; -// • it says the skip is expected / by design / not a failure — the whole -// point of the card; -// • it does NOT reintroduce "intentional override" framing for the -// admin-owned case — the docblock removed that as false (before commit 712e185db the -// seeder wrote the stamp, not an admin), and this line must not -// contradict it. - -import { describe, it, expect } from 'vitest'; -import { resyncSkipExplanationLine } from './resync.js'; - -describe('resyncSkipExplanationLine — the runtime-output half of #9184', () => { - it('is silent when nothing was skipped', () => { - expect(resyncSkipExplanationLine(0)).toBeNull(); - }); - - it('is silent for a negative count too (defensive — should never occur)', () => { - expect(resyncSkipExplanationLine(-1)).toBeNull(); - }); - - it('fires for a full skip (the classic pre-#8692 "resynced 0 / skipped N" case)', () => { - const line = resyncSkipExplanationLine(8); - expect(line).not.toBeNull(); - expect(line).toContain('admin'); - expect(line).toContain('user'); - expect(line).toContain("before the seeder began stamping its default sets 'platform'"); - }); - - it('fires identically for a PARTIAL skip — same trigger as the summary, not `resynced === 0`', () => { - // The card requires this line to ride the same condition as the skip - // summary (`resyncSkipped > 0`), not a narrower "totally stuck" gate. A - // caller with resynced=3/skipped=2 must get the same explanation as - // resynced=0/skipped=8. - expect(resyncSkipExplanationLine(2)).toBe(resyncSkipExplanationLine(8)); - }); - - it('says the outcome is expected / by design / not a failure', () => { - const line = resyncSkipExplanationLine(1)!; - expect(line.toLowerCase()).toMatch(/expected|by design/); - expect(line.toLowerCase()).toContain('not a failure'); - }); - - it('never claims the admin-owned case is a deliberate override — the framing PR #9183 removed', () => { - const line = resyncSkipExplanationLine(1)!; - // The case predating commit 712e185db is exactly the platform's own seeder inheriting a - // field default, not an administrator deciding anything — the docblock - // in bootstrap-platform-admin.ts is explicit that stating this as - // "(intentional override)" is a lie for those rows. This line must keep - // that same neutral framing rather than reintroducing it. - expect(line).toContain("isn't always a deliberate Setup takeover"); - expect(line).not.toMatch(/admin[^.]*\(intentional override\)/i); - }); - - it('keeps the package-owned case correctly framed as ALWAYS deliberate, unlike admin', () => { - // Unlike a stored 'admin'/'user' stamp, a package-owned row genuinely is - // always a deliberate override — the docblock draws this distinction and - // the runtime line must not blur it by applying uniform "override" - // language to both provenance classes. - const line = resyncSkipExplanationLine(1)!; - expect(line).toMatch(/package-owned row.*always a deliberate override/s); - }); -}); diff --git a/packages/cli/src/commands/meta/resync.ts b/packages/cli/src/commands/meta/resync.ts deleted file mode 100644 index b1c92228da8..00000000000 --- a/packages/cli/src/commands/meta/resync.ts +++ /dev/null @@ -1,243 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import { Command, Flags } from '@oclif/core'; -import chalk from 'chalk'; -import { createInterface } from 'node:readline'; -import { - printHeader, - printSuccess, - printWarning, - printError, - printInfo, - printStep, - createTimer, - emitJson, - errorCodeFields, -} from '../../utils/format.js'; -import { bootSchemaStack } from '../../utils/schema-migrate.js'; -import { bootstrapPlatformAdmin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; - -async function confirm(question: string): Promise { - if (!process.stdin.isTTY) return false; // non-interactive → require --yes - const rl = createInterface({ input: process.stdin, output: process.stdout }); - try { - const answer: string = await new Promise((resolve) => rl.question(question, resolve)); - return /^y(es)?$/i.test(answer.trim()); - } finally { - rl.close(); - } -} - -function safeGetService(kernel: any, name: string): any { - try { - return kernel?.getService?.(name); - } catch { - return undefined; - } -} - -/** - * The dim explanatory line printed under the skip-summary `printWarning` - * when `resyncSkipped > 0` — the runtime-output half of #9184 (the docblock - * half, #9130/PR #9183, already explains this in source). - * - * `null` when nothing was skipped, so the caller's `if (resyncSkipped > 0)` - * gate is the single source of truth for the trigger condition: this line - * rides the SAME condition as the skip-count summary it sits under, never a - * separate one (e.g. `resynced === 0`) — a partial skip deserves the same - * explanation as a total one, and "alongside the skip summary" in the issue - * means exactly that pairing. - * - * Deliberately neutral about the skipped row's OWN provenance, matching the - * seeder docblock (`bootstrap-platform-admin.ts`) this line must not - * contradict: the skip decision is unconditionally intentional (only - * platform-owned rows are ever reconciled), but a stored `'admin'` stamp is - * NOT always a deliberate Setup takeover — on any install predating commit 712e185db - * the platform's own seeder wrote that exact stamp, so `resynced 0 / skipped - * N` is a permanent, by-design outcome there rather than a sign the command - * failed. `'user'` is named too: it is the legacy spelling of the same - * class, healed to `'admin'` by `normalizeManagedByVocab`, so a stored - * legacy row can still surface here under either spelling. - */ -export function resyncSkipExplanationLine(resyncSkipped: number): string | null { - if (resyncSkipped <= 0) return null; - // One unwrapped sentence per concatenated piece — never split by an - // embedded '\n' — so a key phrase can never straddle a hard line break - // (the terminal soft-wraps on its own, same as the file's other dim - // follow-up lines a few lines up). - return ( - " Expected, not a failure — resync only reconciles platform-owned rows. " + - "A stored 'admin' stamp (or the legacy 'user' spelling) isn't always a deliberate Setup takeover: " + - "on installs created before the seeder began stamping its default sets 'platform', the platform's own " + - "seeded defaults carry that same stamp, so a " + - "persistent skip count here can be permanent by design. A package-owned row, by contrast, is " + - "always a deliberate override by the package that owns it." - ); -} - -/** - * `os meta resync` — reconcile materialized metadata to the compiled `dist` - * without a `--fresh` wipe (#2705). - * - * The default permission sets (admin_full_access / member_default / - * viewer_readonly …) are seeded INSERT-ONCE at boot: an existing row is never - * clobbered, so an edit to a default set's source is served with its OLD value - * until the DB is wiped — a silent dev-loop trap (a permission-gated action - * "mysteriously" keeps its old behavior). Every OTHER metadata seed (declared - * permission sets, positions, built-in roles, capabilities) already upserts on - * boot; this command closes the one remaining gap by force-reconciling the - * default permission-set rows to the shipped declaration. - * - * Business data is never touched — only `sys_permission_set` definition rows, - * and only rows still `managed_by:'platform'` are reconciled. A row a package - * owns (`'package'`) is left alone as a deliberate override. A row stamped - * `'admin'` — or the legacy spelling `'user'`, healed to `'admin'` by the - * boot-time vocabulary normalizer (`normalizeManagedByVocab`) — is left alone - * too, but is NOT always a deliberate override: on any install created before - * commit 712e185db (the 2026-08-15 ruling) the platform's OWN seeded default sets carry that same - * `'admin'` stamp, indistinguishable from a genuine Setup takeover, so - * `resynced 0 / skipped N` is a permanent, by-design outcome on those - * installs rather than a bug. - */ -export default class MetaResync extends Command { - static override description = - 'Reconcile materialized metadata (default permission sets, which boot seeds insert-once) to the compiled dist without a --fresh wipe'; - - static override examples = [ - '$ os meta resync', - '$ os meta resync --yes', - '$ os meta resync --json', - ]; - - static override flags = { - 'database-url': Flags.string({ - description: 'Database URL to reconcile (defaults to $OS_DATABASE_URL / the project DB)', - env: 'OS_DATABASE_URL', - }), - yes: Flags.boolean({ char: 'y', description: 'Skip the confirmation prompt', default: false }), - json: Flags.boolean({ description: 'Output as JSON (implies non-interactive; requires --yes to mutate)' }), - }; - - async run(): Promise { - const { flags } = await this.parse(MetaResync); - const timer = createTimer(); - let exitCode = 0; - - if (!flags.json) { - printHeader('Meta · resync'); - printStep('Booting runtime stack…'); - } - - // [#21391] A run that can never reach the write — no `--yes`, and nobody - // at a terminal to confirm (`--json`, or stdin not a TTY) — answers - // `confirmation_required` and writes nothing, so it boots READ-ONLY, the - // boot `os migrate plan` takes. A run that may write keeps the plain boot: - // `sys_permission_set` must exist before the resync writes into it, and - // the interactive prompt comes after the boot. - const mayWrite = flags.yes || (!flags.json && process.stdin.isTTY === true); - - let stack; - try { - stack = await bootSchemaStack({ - jsonOutput: flags.json, - databaseUrl: flags['database-url'], - ...(mayWrite ? {} : { deferSchemaDdl: true, readOnlyProbe: true }), - }); - } catch (error: any) { - if (flags.json) await emitJson({ error: error.message, ...errorCodeFields(error) }, 0, { compact: true }); - else printError(error.message || String(error)); - process.exit(1); - } - - try { - const ql = safeGetService(stack.kernel, 'objectql'); - // Prefer the SecurityPlugin's resolved set when a full stack is booted (it - // picks up an app's `defaultPermissionSets` override); fall back to the - // platform defaults, which is what the standalone stack this command boots - // exposes. Either way these are the sets `bootstrapPlatformAdmin` seeds. - const svcSets = safeGetService(stack.kernel, 'security.bootstrapPermissionSets'); - const sets: any[] = Array.isArray(svcSets) && svcSets.length > 0 ? svcSets : securityDefaultPermissionSets; - - if (!ql) { - if (flags.json) { - await emitJson({ error: 'objectql_unavailable' }, 0, { compact: true }); - return; - } - printError('ObjectQL service is not available — cannot resync.'); - return; - } - if (!Array.isArray(sets) || sets.length === 0) { - if (flags.json) { - await emitJson({ resynced: 0, resyncSkipped: 0, inserted: 0, message: 'no_default_permission_sets' }, 0, { compact: true }); - return; - } - printWarning('No default permission sets are available to resync.'); - return; - } - - // Confirmation gate — resync overwrites the default permission-set - // definitions from the compiled dist, so admin Setup edits to those sets - // are replaced. Business data is never touched. - if (!flags.yes) { - if (flags.json || !process.stdin.isTTY) { - if (flags.json) { - await emitJson({ resynced: 0, resyncSkipped: 0, inserted: 0, message: 'confirmation_required', hint: 'pass --yes' }, 0, { compact: true }); - return; - } - printWarning('Confirmation required. Re-run with --yes to resync.'); - return; - } - printInfo(`Database: ${chalk.white(stack.dbLabel)}`); - printWarning('This overwrites the default permission-set definitions from the compiled dist.'); - console.log(chalk.dim(' Admin Setup edits to those sets are replaced. Business data is untouched.')); - console.log(chalk.dim(' Sets an admin or a package has taken over (managed_by user/package) are left alone.')); - const ok = await confirm(chalk.bold(`\nResync ${sets.length} default permission set(s) to ${stack.dbLabel}? [y/N] `)); - if (!ok) { - printInfo('Aborted — no changes made.'); - return; - } - } - - const logger = flags.json - ? undefined - : { info: (m: string) => printInfo(m), warn: (m: string) => printWarning(m) }; - - const report = await bootstrapPlatformAdmin(ql, sets as any[], { resync: true, logger }); - const resynced = report.resynced ?? 0; - const resyncSkipped = report.resyncSkipped ?? 0; - // seeded = existing + newly inserted; existing (under resync) = resynced + - // resyncSkipped, so the remainder is what this run freshly seeded. - const inserted = Math.max(0, (report.seeded ?? 0) - resynced - resyncSkipped); - - if (flags.json) { - await emitJson({ database: stack.dbLabel, resynced, resyncSkipped, inserted, duration: timer.elapsed() }); - return; - } - - console.log(''); - if (resynced > 0 || inserted > 0) { - printSuccess(`Reconciled ${resynced} default permission set(s) to dist${inserted > 0 ? `, seeded ${inserted} new` : ''}.`); - } else { - printInfo('Nothing reconciled.'); - } - if (resyncSkipped > 0) { - printWarning(`Left ${resyncSkipped} set(s) untouched (admin- or package-owned).`); - const note = resyncSkipExplanationLine(resyncSkipped); - if (note) console.log(chalk.dim(note)); - } - console.log(chalk.dim(` ${timer.display()}`)); - console.log(''); - } catch (error: any) { - exitCode = 1; - if (flags.json) await emitJson({ error: error.message, ...errorCodeFields(error) }, 0, { compact: true }); - else printError(error.message || String(error)); - } finally { - await stack.shutdown(); - // A one-shot command must exit even when the booted app stack left - // keep-alive handles open (schedulers/watchers registered on kernel:ready - // that `runtime.stop()` cannot fully drain). Matches the process.exit - // posture the other one-shot CLI commands use. - process.exit(exitCode); - } - } -} diff --git a/packages/cli/src/utils/format.ts b/packages/cli/src/utils/format.ts index 5804f510bee..60b6aa464bb 100644 --- a/packages/cli/src/utils/format.ts +++ b/packages/cli/src/utils/format.ts @@ -45,7 +45,7 @@ export const CLI_ALIAS = 'os'; * * Every other `--json` site in this CLI reports its duration INSIDE the * payload (`{ ...report, duration: timer.elapsed() }` — `os lint`, - * `os migrate meta`, `os migrate summary-nulls`, `os meta resync`), which is + * `os migrate meta`, `os migrate summary-nulls`), which is * what those two meant to do as well. With this union a duration in the exit * slot is a compile error, so the mistake cannot be made silently again. * diff --git a/packages/cli/src/utils/platform-migrations-arming.integration.test.ts b/packages/cli/src/utils/platform-migrations-arming.integration.test.ts index 55fc8398bea..1681e61b4a9 100644 --- a/packages/cli/src/utils/platform-migrations-arming.integration.test.ts +++ b/packages/cli/src/utils/platform-migrations-arming.integration.test.ts @@ -322,7 +322,7 @@ describe('#9380 the kernel:ready platform migrations, on the boots that reach th // WRITE modes boot without `deferSchemaDdl` — `--apply` of `os migrate // meta --stored`, `summary-nulls`, `value-shapes`, `recorded-by`, // `files-to-references` and `audit-metadata-bodies`, `os migrate resume - // --run`, `os secret orphans --delete`, `os meta resync --yes`. (Every + // --run`, `os secret orphans --delete`. (Every // no-write mode boots deferred since #21391.) Each writes only what the // operator confirmed; if the arming were gated on deferral instead of on // being a one-shot boot, every one of them would also repair rows the diff --git a/packages/cli/src/utils/schema-migrate.dev-self-heal-fence.integration.test.ts b/packages/cli/src/utils/schema-migrate.dev-self-heal-fence.integration.test.ts index a935878def5..9bd08f8a6fe 100644 --- a/packages/cli/src/utils/schema-migrate.dev-self-heal-fence.integration.test.ts +++ b/packages/cli/src/utils/schema-migrate.dev-self-heal-fence.integration.test.ts @@ -11,8 +11,8 @@ * one-shot command boot under `NODE_ENV=development` would otherwise inherit * the self-heal: * - * - a NON-deferred one-shot boot (every write mode: `os meta resync --yes`, - * the data commands' `--apply`) runs boot schema sync, so it would apply + * - a NON-deferred one-shot boot (every write mode: the data commands' + * `--apply`) runs boot schema sync, so it would apply * safe drift its operator never saw in a preview; * - `os migrate plan` defers its DDL (#3917), so its own boot never reaches * the reconcile — this file pins that it stays write-free on the card's diff --git a/packages/cli/src/utils/schema-migrate.lazy-driver-import.test.ts b/packages/cli/src/utils/schema-migrate.lazy-driver-import.test.ts index 62794d8f06a..9484e911919 100644 --- a/packages/cli/src/utils/schema-migrate.lazy-driver-import.test.ts +++ b/packages/cli/src/utils/schema-migrate.lazy-driver-import.test.ts @@ -7,7 +7,7 @@ * oclif's `findCommand` walks the command table and `import()`s every command * module on **every** CLI invocation, so a broken import chain anywhere in that * table is charged to whatever command you actually ran. `schema-migrate.ts` is - * shared by nine commands (`meta:resync`, `migrate`, and seven `migrate:*`), and + * was shared by nine commands (then `meta:resync`, `migrate`, and seven `migrate:*`), and * its one static `import { isInPlaceSchemaWork } from '@objectstack/driver-sql'` * meant that an unbuilt `packages/drivers/driver-sql/dist` made `os dev` print * nine `MODULE_NOT_FOUND` blocks (eighteen — `dev` forks a child) naming nine diff --git a/packages/cli/src/utils/schema-migrate.one-shot-family.integration.test.ts b/packages/cli/src/utils/schema-migrate.one-shot-family.integration.test.ts index adfba40bdd5..6dd09036b82 100644 --- a/packages/cli/src/utils/schema-migrate.one-shot-family.integration.test.ts +++ b/packages/cli/src/utils/schema-migrate.one-shot-family.integration.test.ts @@ -54,7 +54,6 @@ import type { IObjectQLEngine } from '@objectstack/spec/contracts'; import { isExitSignal } from './format.js'; import { bootSchemaStack } from './schema-migrate.js'; import { buildDataMigrationPlugins } from './data-migration-plugins.js'; -import MetaResync from '../commands/meta/resync.js'; import MigrateAccountIssuer from '../commands/migrate/account-issuer.js'; import MigrateApply from '../commands/migrate/apply.js'; import MigrateAuditMetadataBodies from '../commands/migrate/audit-metadata-bodies.js'; @@ -146,11 +145,6 @@ const invoke = (command: { run: (argv: string[], opts: { root: string }) => Prom * modes it has. The first case below holds this table equal to the source. */ const CALLERS: Record = { - 'commands/meta/resync.ts': { - run: invoke(MetaResync), - noWrite: [{ label: 'meta resync (no --yes)', argv: ['--database-url', '@DB@', '--json'] }], - write: [{ label: 'meta resync --yes', argv: ['--yes', '--database-url', '@DB@', '--json'] }], - }, 'commands/migrate/account-issuer.ts': { run: invoke(MigrateAccountIssuer), noWrite: [{ label: 'migrate account-issuer', argv: ['--database-url', '@DB@', '--json'] }], @@ -309,7 +303,7 @@ interface RunResult { * process-global and `emitJson` sets it, so it is saved and restored. The * stdout spy is installed before the boot, because a `--json` boot reserves * stdout and keeps whatever `process.stdout.write` is at that moment. - * `os meta resync` ends in `process.exit()`, so that is trapped too. + * A command that ends in `process.exit()` is trapped too. */ async function runJson(command: Invoke, argv: string[]): Promise { const savedExit = process.exitCode; diff --git a/packages/cli/src/utils/schema-migrate.ts b/packages/cli/src/utils/schema-migrate.ts index c59ed50e908..a3b8cb6ef2b 100644 --- a/packages/cli/src/utils/schema-migrate.ts +++ b/packages/cli/src/utils/schema-migrate.ts @@ -137,7 +137,7 @@ export interface SchemaStack { dbSource: DatabaseSource; managedTableCount: number; /** The booted kernel — `getService('objectql')` etc. for one-shot commands - * beyond schema migration (e.g. `os meta resync`, #2705). */ + * beyond schema migration. */ kernel: any; /** * Create-table / add-column work the boot sync was held back from running @@ -579,7 +579,7 @@ export async function bootSchemaStack( * a `--json` flag, and each of them re-introduced the same defect * independently: `os migrate plan` / `apply` / `resume` / `recorded-by` / * `summary-nulls` / `value-shapes` / `files-to-references`, `os migrate - * meta --stored` and `os meta resync` all emitted ~60 INFO lines around + * meta --stored` and the since-retired `os meta resync` all emitted ~60 INFO lines around * their payload. Booting the stack is what makes a command a member of * this family, so this is the one place a new member cannot avoid — and * with no default, a new member has to *decide* rather than inherit the @@ -641,7 +641,7 @@ export async function bootSchemaStack( * about. * * A WRITE mode that needs the tables to exist before it writes (`--apply` - * of the data commands, `os meta resync --yes`) leaves this off. + * of the data commands) leaves this off. */ deferSchemaDdl?: boolean; /** @@ -789,7 +789,7 @@ export async function bootSchemaStack( // • the write modes — `--apply` of `os migrate meta --stored` / // `value-shapes` / `recorded-by` / `summary-nulls` / // `files-to-references` / `audit-metadata-bodies`, `os migrate resume - // --run`, `os secret orphans --delete`, `os meta resync --yes` — boot + // --run`, `os secret orphans --delete` — boot // NOT deferred, so a `defer`-keyed policy would have left all of them // repairing rows. They write, but only the change the operator // confirmed; `os migrate apply` likewise. A repair riding along is a @@ -1063,8 +1063,8 @@ export async function bootSchemaStack( * package is `import type`, which erases. A *static* value import for it was * not a local cost, because this file is not loaded only when someone migrates: * oclif's `findCommand` `import()`s every command module on **every** CLI - * invocation, and nine commands reach this file (`meta:resync`, `migrate`, and - * seven `migrate:*`). So an unbuilt `packages/drivers/driver-sql/dist` did not + * invocation, and nine commands reached this file (then `meta:resync`, `migrate`, + * and seven `migrate:*`). So an unbuilt `packages/drivers/driver-sql/dist` did not * merely break those nine — running *any* command, `os dev` included, printed * one `MODULE_NOT_FOUND` block per command in front of the output you asked for * (and `os dev` forks a child, so you saw each one twice), while the nine diff --git a/packages/cli/test/json-stdout-purity.e2e.test.ts b/packages/cli/test/json-stdout-purity.e2e.test.ts index 698034ba9bc..0bb95fb871e 100644 --- a/packages/cli/test/json-stdout-purity.e2e.test.ts +++ b/packages/cli/test/json-stdout-purity.e2e.test.ts @@ -81,7 +81,6 @@ const COMMANDS_DIR = resolve(HERE, '../src/commands'); * --stored` rather than `migrate meta`. */ const FAMILY: Record = { - 'meta resync': [], // Its bare form boots: it takes the `os migrate plan` shape (`deferSchemaDdl` // + `readOnlyProbe`), so a fixture with no database file is opened as an // empty in-memory one rather than being brought into existence. No table diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index 01edeb164c7..64886ac39d7 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -7067,7 +7067,7 @@ export class SqlDriver implements IDataDriver { * Put a file-backed SQLite database in WAL mode (#3941). * * ObjectStack's normal shape is **several processes on one file**: a dev - * server, `os migrate`, `os meta resync`, a test run. SQLite's built-in + * server, `os migrate`, a test run. SQLite's built-in * default — `journal_mode = delete`, a rollback journal — is the worst mode * for that, in two measured ways: * diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin-seeded-provenance.test.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin-seeded-provenance.test.ts deleted file mode 100644 index 92957e006b3..00000000000 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin-seeded-provenance.test.ts +++ /dev/null @@ -1,369 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [commit 712e185db] What provenance a REAL `bootstrapPlatformAdmin` run leaves on the - * platform default permission sets, and what `os meta resync` then does with it. - * - * ## Why this file exists at all - * - * `bootstrap-platform-admin.test.ts` covers the resync branch three ways — - * `managed_by` hand-seeded to `null`, to `'user'`, to `'package'` — and every - * one of them hand-builds the pre-image. That is correct unit coverage of the - * BRANCH, and it is deliberately left untouched here. What none of them can - * answer is which pre-image the seeder itself produces, because a fake `ql` - * stores exactly the columns it is handed and a declared field `defaultValue` - * never runs. So the one row shape the platform actually creates was the one - * row shape nothing exercised, and the drift was invisible in both directions. - * - * This file closes that by seeding through the REAL shipped - * `defaultPermissionSets` against a real `ObjectQL` engine over a real - * better-sqlite3 `SqlDriver` — the same wiring `security-plugin.ts` and - * `os meta resync` use, which hand the bare engine straight to the seeder. - * - * ## The ruling these assertions now pin (2026-08-15, Option A) - * - * The original version of this file recorded a MEASUREMENT and explicitly - * refused to endorse it: a seeded row stored `'admin'` (the declaration's - * `defaultValue`), so `os meta resync` returned `resynced 0 / resyncSkipped 8` - * and skipped every platform default set — the inverse of what #2705 built the - * flag for. It left the product question open: - * - * > are the platform default permission sets meant to be platform-owned - * > (and therefore resyncable), or env-authored (and therefore deliberately - * > left alone)? - * - * ⛔ That question is CLOSED. The maintainer ruled **Option A** on 2026-08-15: - * the platform default sets are platform-owned and resyncable, and the seeder - * stamps `managed_by: 'platform'` on its insert. This file no longer records an - * undecided state — it pins the ruling, on BOTH sides of the line the ruling - * drew: - * - * 1. **Forward (section 1)** — a fresh install stores `'platform'` and a real - * resync reconciles every default set. - * 2. **Legacy (section 2)** — a row from a PRE-ruling install still carries - * `'admin'` and is still SKIPPED, forever and on purpose. The ruling - * forbids a migration: a stored `'admin'` cannot be told apart from a - * genuine Setup takeover, so restamping could silently overwrite a real - * admin's edits on the next resync. Report, don't rewrite. - * - * Section 2 is the half that is easy to lose. Deleting it would leave the - * upgrade path — the one every existing deployment is actually on — with no - * coverage at all, and a later "cleanup" that restamps legacy rows would then - * go green. - */ - -import { describe, it, expect, afterEach } from 'vitest'; -import { ObjectQL } from '@objectstack/objectql'; -import { SqlDriver } from '@objectstack/driver-sql'; -// `sys_user` is what the promotion half enumerates. Registered as the REAL -// declaration so its verdict ("no human users yet") is a genuine reading of an -// empty table rather than a table-missing error `tryFind` swallowed into `[]` — -// the two are indistinguishable from the outside and only one of them is the -// first-boot ordering this file means to reproduce. -import { SysUser } from '@objectstack/platform-objects/identity'; -// The REAL shipped seeder and the REAL shipped declarations. A local -// re-implementation of either would make this a test of the copy, which is -// precisely the gap the file exists to close. -import { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js'; -import { SysPermissionSet } from './objects/sys-permission-set.object.js'; -import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; -import { defaultPermissionSets } from './objects/default-permission-sets.js'; - -const SYSTEM_CTX = { isSystem: true }; - -const engines: ObjectQL[] = []; - -afterEach(async () => { - while (engines.length) { - try { - await engines.pop()?.destroy(); - } catch { - /* noop */ - } - } -}); - -/** - * A fresh engine on its own `:memory:` database, carrying the permission-set - * declaration passed in. `declaration` exists for section 3's counterfactual; - * every case in sections 1 and 2 boots the shipped declaration unmodified. - */ -async function boot(declaration: any = SysPermissionSet): Promise { - const engine = new ObjectQL(); - engine.registerDriver( - new SqlDriver({ - client: 'better-sqlite3', - connection: { filename: ':memory:' }, - useNullAsDefault: true, - }), - true, - ); - await engine.init(); - engine.registerApp({ - id: 'com.objectstack.security-objects', - name: 'Security Objects', - version: '1.0.0', - type: 'plugin', - scope: 'system', - objects: [declaration, SysUserPermissionSet, SysUser], - } as any); - await engine.syncSchemas(); - engines.push(engine); - return engine; -} - -/** - * The row as it is physically STORED, read at the driver past every engine-side - * projection. The resync branch reads the engine's view, so both are asserted - * where it matters — a default applied only on read and a value written on - * insert are different facts about the database, and only one of them survives - * a restart. - */ -async function storedRow(engine: ObjectQL, name: string): Promise { - const driver: any = (engine as any).getDriver('sys_permission_set'); - const rows: any[] = await driver.knex('sys_permission_set').select('*').where({ name }); - return rows[0]; -} - -/** The row as the seeder's own `tryFind` sees it — the value resync branches on. */ -async function rowViaEngine(engine: ObjectQL, name: string): Promise { - const rows: any[] = await (engine as any).find( - 'sys_permission_set', - { where: { name }, limit: 1 }, - { context: SYSTEM_CTX }, - ); - return rows[0]; -} - -/** - * A row exactly as an install before commit 712e185db holds it — written the way the old - * seeder wrote it, which is to say WITHOUT `managed_by`, so the value comes - * from the declaration's `defaultValue: 'admin'` by the very mechanism that - * produced it on every install created before the ruling. - * - * Reproducing the legacy pre-image through its original mechanism rather than - * hand-writing `'admin'` is deliberate: it keeps the case anchored to the real - * declaration, and each case asserts the resulting value before relying on it, - * so a moved default shows up as a failure here instead of quietly turning the - * legacy pin into a test of something else. - */ -async function seedLegacyRow(engine: ObjectQL, ps: any): Promise { - await (engine as any).insert( - 'sys_permission_set', - { - id: `ps_legacy_${ps.name}`, - name: ps.name, - label: ps.label ?? ps.name, - // A deliberately STALE payload: the resync would rewrite these if it ever - // touched the row, so section 2 can prove "left untouched" by content and - // not merely by a counter. - object_permissions: '{}', - field_permissions: '{}', - system_permissions: '[]', - row_level_security: '[]', - tab_permissions: '{}', - active: true, - }, - { context: SYSTEM_CTX }, - ); -} - -function collectingLogger() { - const info: string[] = []; - const warn: string[] = []; - return { - info, - warn, - logger: { - info: (m: string) => info.push(m), - warn: (m: string) => warn.push(m), - }, - }; -} - -describe('#8692 — provenance of a seeder-created default permission set (measured, real engine)', () => { - // ─────────────────────────────────────────────────────────────────────────── - // 1. FORWARD — a fresh install after the ruling - // ─────────────────────────────────────────────────────────────────────────── - - it('ANTI-VACUITY: the shipped defaults are non-empty and really get seeded', async () => { - // Every count below is relative to this set. If it were empty, "resync - // reconciled all of them" would be trivially true of nothing — the shape in - // which this whole file could pass while measuring air. - expect(defaultPermissionSets.length).toBeGreaterThan(0); - - const engine = await boot(); - const out = await bootstrapPlatformAdmin(engine as any, defaultPermissionSets); - expect(out.seeded).toBe(defaultPermissionSets.length); - }); - - it('a seeder-created row is stored `managed_by: "platform"` — stamped, not defaulted', async () => { - // THE RULING, pinned (Option A, 2026-08-15). Before it, this row stored - // `'admin'` — the declaration's `defaultValue` — because the insert omitted - // `managed_by` entirely. The seeder now stamps provenance explicitly, in - // line with `bootstrap-builtin-positions.ts`, which always has. - const engine = await boot(); - await bootstrapPlatformAdmin(engine as any, defaultPermissionSets); - - // Physically stored, not merely projected on read: the value survives a - // restart, so every later boot reads it too. - expect((await storedRow(engine, 'admin_full_access')).managed_by).toBe('platform'); - // …and the same value is what the seeder's own read sees, which is the one - // the resync condition branches on. - expect((await rowViaEngine(engine, 'admin_full_access')).managed_by).toBe('platform'); - }); - - it('so `os meta resync` now reconciles EVERY platform default set', async () => { - // The behaviour #2705 built the flag for, finally reachable: resynced == the - // whole shipped set, resyncSkipped 0. Asserted against the live length so - // the pin stays true if a default set is added or removed. - const engine = await boot(); - await bootstrapPlatformAdmin(engine as any, defaultPermissionSets); - - const out = await bootstrapPlatformAdmin(engine as any, defaultPermissionSets, { resync: true }); - - expect(out.resynced).toBe(defaultPermissionSets.length); - expect(out.resyncSkipped).toBe(0); - }); - - it('a resync of a fresh install warns about nothing', async () => { - // The counterpart to the old measurement, which emitted one bogus - // "intentional override" warn per shipped set. Nothing is skipped now, so - // nothing is warned about. - const engine = await boot(); - await bootstrapPlatformAdmin(engine as any, defaultPermissionSets); - - const { warn, logger } = collectingLogger(); - await bootstrapPlatformAdmin(engine as any, defaultPermissionSets, { resync: true, logger }); - - expect(warn).toEqual([]); - }); - - it('resync reconciles a STALE platform-owned row to the shipped declaration', async () => { - // Counts alone cannot show the reconcile actually landed. Blank the payload - // on a seeded row, resync, and read the declaration back out of storage. - const engine = await boot(); - await bootstrapPlatformAdmin(engine as any, defaultPermissionSets); - - const driver: any = (engine as any).getDriver('sys_permission_set'); - await driver.knex('sys_permission_set') - .where({ name: 'admin_full_access' }) - .update({ system_permissions: '[]' }); - expect((await storedRow(engine, 'admin_full_access')).system_permissions).toBe('[]'); - - await bootstrapPlatformAdmin(engine as any, defaultPermissionSets, { resync: true }); - - const shipped = defaultPermissionSets.find((p) => p.name === 'admin_full_access')!; - expect(JSON.parse((await storedRow(engine, 'admin_full_access')).system_permissions)) - .toEqual(shipped.systemPermissions ?? []); - // …and the reconcile did NOT touch provenance: `platformOwnedFields` carries - // no `managed_by`, which is what keeps a resync from restamping any row. - expect((await storedRow(engine, 'admin_full_access')).managed_by).toBe('platform'); - }); - - // ─────────────────────────────────────────────────────────────────────────── - // 2. LEGACY — a pre-ruling install, which keeps the skip forever - // ─────────────────────────────────────────────────────────────────────────── - - it('LEGACY: a pre-ruling `admin`-stamped row is still skipped, and its content survives', async () => { - // ⛔ The ruling's load-bearing half: forward-stamp only, NO migration. A - // stored `'admin'` is indistinguishable between "the old seeder's field - // default" and "an administrator took this set over in Setup", so it must - // never be reconciled on the strength of a guess. - const engine = await boot(); - const legacy = defaultPermissionSets[0]; - await seedLegacyRow(engine, legacy); - - // The pre-image really is what a pre-ruling install holds — asserted, not - // assumed, so this case cannot pass by testing some other row shape. - expect((await storedRow(engine, legacy.name)).managed_by).toBe('admin'); - - // An upgraded install: the seeder runs, finds the legacy row and leaves it, - // and inserts the remaining sets freshly stamped `'platform'`. - await bootstrapPlatformAdmin(engine as any, defaultPermissionSets); - expect((await storedRow(engine, legacy.name)).managed_by).toBe('admin'); - - const out = await bootstrapPlatformAdmin(engine as any, defaultPermissionSets, { resync: true }); - - // The mixed shape an upgrade produces: everything the platform stamped is - // reconciled, the one legacy row is not. - expect(out.resyncSkipped).toBe(1); - expect(out.resynced).toBe(defaultPermissionSets.length - 1); - - // "Left untouched" proven by CONTENT, not just by a counter: the stale - // payload seeded above is still stale, and provenance was not restamped. - const row = await storedRow(engine, legacy.name); - expect(row.system_permissions).toBe('[]'); - expect(row.object_permissions).toBe('{}'); - expect(row.managed_by).toBe('admin'); - }); - - it('LEGACY: the skip is logged neutrally — no claim of intent', async () => { - // The warn used to end "(intentional override)", which is false for exactly - // these rows: on a pre-ruling install the only writer may have been the - // seeder itself one call earlier. The reworded line states the provenance - // and the action and asserts nothing about anybody's intent. - const engine = await boot(); - const legacy = defaultPermissionSets[0]; - await seedLegacyRow(engine, legacy); - await bootstrapPlatformAdmin(engine as any, defaultPermissionSets); - - const { warn, logger } = collectingLogger(); - await bootstrapPlatformAdmin(engine as any, defaultPermissionSets, { resync: true, logger }); - - expect(warn).toEqual([ - `[security] resync left ${legacy.name} untouched — row is admin-owned`, - ]); - // Pinned as a substring too, so a future reword cannot quietly reintroduce - // the intent claim in some other sentence shape. - expect(warn.join('\n')).not.toContain('intentional override'); - }); - - // ─────────────────────────────────────────────────────────────────────────── - // 3. ATTRIBUTION — which link actually supplies the value - // ─────────────────────────────────────────────────────────────────────────── - - /** - * A counterfactual, and note it runs the OPPOSITE way round from the one this - * file carried before the ruling. Back then the seeder wrote no `managed_by`, - * so respelling the declared default flipped the stored value — which is what - * proved the default was the source. Now the seeder stamps explicitly, so the - * declared default must no longer be able to move the value at all. - * - * Predicted direction, written down before running: boot a CLONE of the - * declaration whose `managed_by` default is respelled to `'package'`, seed - * with the same unmodified seeder, and the row must STILL store `'platform'`. - * - * That one reading establishes what section 1 cannot on its own: the value - * comes from the seeder's stamp and not from a declaration that merely - * happens to agree with it. Without this case, respelling - * `defaultValue: 'admin'` to `'platform'` in the object file would make every - * assertion in section 1 pass with the stamp deleted. - */ - it('ATTRIBUTION: the seed stamp beats the declared default, so the default cannot move it', async () => { - const respelled = { - ...SysPermissionSet, - fields: { - ...(SysPermissionSet as any).fields, - managed_by: { ...(SysPermissionSet as any).fields.managed_by, defaultValue: 'package' }, - }, - }; - // Guard: the clone is a copy, and the shipped declaration is untouched by - // building it. Without this the case could be measuring a mutation it made. - // The shipped default stays `'admin'` deliberately — it is the right default - // for a set an admin creates in Setup, and the ruling changed the SEEDER, - // not the declaration. - expect((SysPermissionSet as any).fields.managed_by.defaultValue).toBe('admin'); - - const engine = await boot(respelled); - await bootstrapPlatformAdmin(engine as any, defaultPermissionSets); - - expect((await storedRow(engine, 'admin_full_access')).managed_by).toBe('platform'); - - // And the consequence still holds under a default that would otherwise have - // caused a skip: resync reconciles everything. - const out = await bootstrapPlatformAdmin(engine as any, defaultPermissionSets, { resync: true }); - expect(out.resynced).toBe(defaultPermissionSets.length); - expect(out.resyncSkipped).toBe(0); - }); -}); diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin.test.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin.test.ts deleted file mode 100644 index 9701a38b887..00000000000 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * bootstrapPlatformAdmin — permission-set materialization, focused on the - * insert-once vs `resync` (#2705) split. - * - * The default boot path is insert-once: an existing default permission-set row - * is env-authored config and is never clobbered on restart (so admin Setup - * edits survive). That protection is CORRECT for prod but makes a dev source - * edit silently stale until a `--fresh` wipe. `os meta resync` passes - * `{ resync: true }` to reconcile platform-owned rows to the shipped dist - * without touching business data — while still refusing to overwrite a row an - * admin or a package has explicitly taken over. - */ - -import { describe, it, expect } from 'vitest'; -import { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js'; -import { applyManagedWriteDenies } from './managed-object-write-denies.js'; - -/** Minimal in-memory ql. Only `sys_permission_set` is modeled; the admin- - * promotion tables return empty, so promotion short-circuits and we assert the - * seed/resync outcome (carried on every return) directly. */ -function makeQl(seedRows: any[] = []) { - const rows: any[] = seedRows.map((r) => ({ ...r })); - return { - rows, - async find(object: string, q: any) { - if (object !== 'sys_permission_set') return []; - const where = q?.where ?? {}; - return rows.filter((r) => Object.entries(where).every(([k, v]) => { if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); return r[k] === v; })); - }, - async insert(object: string, data: any) { - if (object !== 'sys_permission_set') return null; - rows.push({ ...data }); - return { id: data.id }; - }, - async update(object: string, data: any) { - if (object !== 'sys_permission_set') return; - const r = rows.find((x) => x.id === data.id); - if (r) Object.assign(r, data); - }, - }; -} - -/** Shipped declaration: member_default now grants `setup.access`. */ -const memberDefault = (over: Record = {}) => - ({ - name: 'member_default', - label: 'Member', - objects: { crm_lead: { allowRead: true } }, - systemPermissions: ['setup.access'], - ...over, - }) as any; - -const row = (ql: ReturnType) => ql.rows.find((x) => x.name === 'member_default'); - -describe('bootstrapPlatformAdmin — insert-once vs resync (#2705)', () => { - it('default (no resync): leaves an existing row stale — the deliberate insert-once posture', async () => { - const ql = makeQl([ - { id: 'ps_old', name: 'member_default', system_permissions: '[]', object_permissions: '{}' }, - ]); - const r = await bootstrapPlatformAdmin(ql, [memberDefault()]); - expect(r.resynced).toBe(0); - // Same row, and the shipped `setup.access` did NOT land — this is the - // #2705 boot behavior (protects admin edits; stale in the dev loop). - expect(row(ql)!.id).toBe('ps_old'); - expect(row(ql)!.system_permissions).toBe('[]'); - }); - - it('resync: reconciles a platform-owned row to the shipped declaration in place', async () => { - const ql = makeQl([ - { id: 'ps_old', name: 'member_default', system_permissions: '[]', object_permissions: '{}', managed_by: null }, - ]); - const r = await bootstrapPlatformAdmin(ql, [memberDefault()], { resync: true }); - expect(r.resynced).toBe(1); - expect(r.resyncSkipped).toBe(0); - // Updated in place (no new insert), and the declaration is now live. - expect(ql.rows.filter((x) => x.name === 'member_default')).toHaveLength(1); - expect(row(ql)!.id).toBe('ps_old'); - expect(JSON.parse(row(ql)!.system_permissions)).toEqual(['setup.access']); - expect(JSON.parse(row(ql)!.object_permissions)).toEqual({ crm_lead: { allowRead: true } }); - }); - - it('resync: leaves an admin-owned (managed_by:user) row untouched', async () => { - const ql = makeQl([ - { id: 'ps_custom', name: 'member_default', system_permissions: '["custom.perm"]', managed_by: 'user' }, - ]); - const r = await bootstrapPlatformAdmin(ql, [memberDefault()], { resync: true }); - expect(r.resynced).toBe(0); - expect(r.resyncSkipped).toBe(1); - expect(row(ql)!.system_permissions).toBe('["custom.perm"]'); - }); - - it('resync: leaves a package-owned row untouched', async () => { - const ql = makeQl([ - { id: 'ps_pkg', name: 'member_default', system_permissions: '[]', managed_by: 'package', package_id: 'com.x' }, - ]); - const r = await bootstrapPlatformAdmin(ql, [memberDefault()], { resync: true }); - expect(r.resynced).toBe(0); - expect(r.resyncSkipped).toBe(1); - }); - - it('resync: inserts a set that does not exist yet (nothing to reconcile)', async () => { - const ql = makeQl([]); - const r = await bootstrapPlatformAdmin(ql, [memberDefault()], { resync: true }); - expect(r.resynced).toBe(0); - expect(row(ql)).toBeTruthy(); - expect(JSON.parse(row(ql)!.system_permissions)).toEqual(['setup.access']); - }); -}); - -// #3325 — the transform runs upstream in runBootstrap; here we confirm that once -// a set has been enriched by applyManagedWriteDenies, bootstrapPlatformAdmin -// serializes the injected denies into the seeded object_permissions JSON. -describe('bootstrapPlatformAdmin — managed write denies land in the seed row (#3325)', () => { - const schemas = [ - { name: 'sys_sso_provider', managedBy: 'better-auth' }, - { name: 'crm_lead', managedBy: 'platform' }, - ]; - // member_default is a target set; give it the wildcard shape the real one has. - const enriched = () => { - const set = { name: 'member_default', label: 'Member', objects: { '*': { allowRead: true, allowCreate: true } } } as any; - applyManagedWriteDenies([set], schemas); - return set; - }; - - it('fresh DB: the injected deny serializes into object_permissions', async () => { - const ql = makeQl([]); - await bootstrapPlatformAdmin(ql, [enriched()]); - const perms = JSON.parse(row(ql)!.object_permissions); - expect(perms.sys_sso_provider).toEqual({ allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false }); - expect(perms.crm_lead).toBeUndefined(); // platform bucket never denied - }); - - it('resync: reconciles a stale platform-owned row that predates the deny', async () => { - const ql = makeQl([ - { id: 'ps_old', name: 'member_default', system_permissions: '[]', object_permissions: '{}', managed_by: null }, - ]); - const r = await bootstrapPlatformAdmin(ql, [enriched()], { resync: true }); - expect(r.resynced).toBe(1); - expect(JSON.parse(row(ql)!.object_permissions).sys_sso_provider.allowCreate).toBe(false); - }); -}); diff --git a/packages/plugins/plugin-security/src/claim-seed-ownership.ts b/packages/plugins/plugin-security/src/claim-seed-ownership.ts index c4898cbfcc6..ae4bfca26a3 100644 --- a/packages/plugins/plugin-security/src/claim-seed-ownership.ts +++ b/packages/plugins/plugin-security/src/claim-seed-ownership.ts @@ -561,7 +561,7 @@ export async function claimSeedOwnership( // nor any later object. The rows stay unowned and the next run claims // them, because the predicate is still true of them. That run is the // next `app:seeded` (this boot or a later one) or the next promotion — - // ⚠️ NOT `os meta resync` and NOT a bootstrap replay: on an install that + // ⚠️ NOT a bootstrap replay: on an install that // already has an admin both short-circuit on `already_have_admin` and // never reach this function. logger?.warn?.( diff --git a/packages/plugins/plugin-security/src/permission-set-projection.ts b/packages/plugins/plugin-security/src/permission-set-projection.ts index 9e91bba87ed..463ca038640 100644 --- a/packages/plugins/plugin-security/src/permission-set-projection.ts +++ b/packages/plugins/plugin-security/src/permission-set-projection.ts @@ -1677,7 +1677,7 @@ export async function reconcilePermissionSetProjection( '`sys_permission_set` records with NO metadata definition backing them, and the one-time backfill ' + 'did not write one. Nothing will look broken: the records still list in Setup and the evaluator ' + 'still resolves them from the table — but the definitions are absent from the metadata store, so a ' + - 'metadata-driven re-provision (fresh environment, package reinstall, `meta resync`) recreates none ' + + 'metadata-driven re-provision (fresh environment, package reinstall) recreates none ' + 'of them, and every boot retries and fails identically. Fix: make the record body spec-valid — the ' + 'error below names the offending key; `permissionSetBodyFromRow()` already drops storage columns ' + '(`active`, timestamps, provenance), so a rejection here means the stored facet JSON itself is ' + diff --git a/packages/runtime/src/standalone-stack.ts b/packages/runtime/src/standalone-stack.ts index ec6d82723f3..fc8fb2beee0 100644 --- a/packages/runtime/src/standalone-stack.ts +++ b/packages/runtime/src/standalone-stack.ts @@ -812,7 +812,7 @@ export async function createStandaloneStack(config?: StandaloneStackConfig): Pro // // Keyed on an EXPLICIT `dev: true`, never on `factoryDev`'s NODE_ENV // default: `bootSchemaStack` passes no `dev`, and a non-deferred one-shot - // boot (`os meta resync --yes`, the data commands' `--apply`) runs schema + // boot (the data commands' `--apply`) runs schema // sync — under `NODE_ENV=development` it would apply safe drift its // operator never confirmed. The step-down keeps the default; the self-heal // is a serving host's declaration. diff --git a/scripts/engine-double-contract.baseline.json b/scripts/engine-double-contract.baseline.json index bdf92642bfb..fced54533c2 100644 --- a/scripts/engine-double-contract.baseline.json +++ b/scripts/engine-double-contract.baseline.json @@ -392,14 +392,6 @@ "why": "MEASURED (#5480): the `update` slice of this gate is NEW — `resolveEngineUpdateDispatch` did not exist before #5480, so no double in the repo could route through it and the whole discovered set enters this ledger in one act. Not newly written looseness and not a raised ratchet: it is the first measurement of a contract that had no producer-side predicate to measure against, which is exactly what this script's header used to list under deliberately-not-covered (\"update's twin dispatch ... needs its own producer-side predicate extracted first\"). Discovered at line 12. The package does not depend on @objectstack/objectql yet, and the devDependency route is AVAILABLE rather than cyclic. Measured on this branch, not cited: the edge was added to @objectstack/plugin-security's devDependencies and turbo 2.10.7 accepted the graph (`turbo run build --filter=@objectstack/plugin-security --dry`, no circular-dependency warning), then the edge was reverted. WHAT THIS ENTRY DOES NOT CLAIM: unlike the #5629 delete batch above it carries NO per-file dormancy probe. Nothing here says the looseness is unexercised — only that the double is structurally free to be looser than ObjectQL.update on the shape a hand-written guard always drops (`where: { id: { $in: [...] } }` looks like an id and is a multi-row predicate) — a shape the producer refuses in `data.id` too since objectstack#5748 put the payload half through the SAME scalar test, so `data.id` still outranks `where` and `multi`, but only when it IS a scalar id.", "closes": "add @objectstack/objectql to this package's devDependencies (verified acyclic — see `why`), then open the fake's update with assertEngineUpdateDispatch(data, options)" }, - { - "file": "packages/plugins/plugin-security/src/bootstrap-platform-admin.test.ts", - "verb": "update", - "unguarded": 1, - "kind": "DEBT", - "why": "MEASURED (#5480): the `update` slice of this gate is NEW — `resolveEngineUpdateDispatch` did not exist before #5480, so no double in the repo could route through it and the whole discovered set enters this ledger in one act. Not newly written looseness and not a raised ratchet: it is the first measurement of a contract that had no producer-side predicate to measure against, which is exactly what this script's header used to list under deliberately-not-covered (\"update's twin dispatch ... needs its own producer-side predicate extracted first\"). Discovered at line 25. The package does not depend on @objectstack/objectql yet, and the devDependency route is AVAILABLE rather than cyclic. Measured on this branch, not cited: the edge was added to @objectstack/plugin-security's devDependencies and turbo 2.10.7 accepted the graph (`turbo run build --filter=@objectstack/plugin-security --dry`, no circular-dependency warning), then the edge was reverted. WHAT THIS ENTRY DOES NOT CLAIM: unlike the #5629 delete batch above it carries NO per-file dormancy probe. Nothing here says the looseness is unexercised — only that the double is structurally free to be looser than ObjectQL.update on the shape a hand-written guard always drops (`where: { id: { $in: [...] } }` looks like an id and is a multi-row predicate) — a shape the producer refuses in `data.id` too since objectstack#5748 put the payload half through the SAME scalar test, so `data.id` still outranks `where` and `multi`, but only when it IS a scalar id.", - "closes": "add @objectstack/objectql to this package's devDependencies (verified acyclic — see `why`), then open the fake's update with assertEngineUpdateDispatch(data, options)" - }, { "file": "packages/plugins/plugin-security/src/permission-set-projection.test.ts", "verb": "delete", diff --git a/scripts/objectql-double-limit.baseline.json b/scripts/objectql-double-limit.baseline.json index 9a37c3fd650..e8e3a67c64f 100644 --- a/scripts/objectql-double-limit.baseline.json +++ b/scripts/objectql-double-limit.baseline.json @@ -588,9 +588,6 @@ "packages/plugins/plugin-security/src/bootstrap-platform-admin-walled-owner.test.ts": { "blind": 1 }, - "packages/plugins/plugin-security/src/bootstrap-platform-admin.test.ts": { - "blind": 1 - }, "packages/plugins/plugin-security/src/bootstrap-seed-round-trips.test.ts": { "unjudged": 1 }, diff --git a/skills/objectstack-platform/references/operations.md b/skills/objectstack-platform/references/operations.md index 0d0a5237658..4c790c33a4f 100644 --- a/skills/objectstack-platform/references/operations.md +++ b/skills/objectstack-platform/references/operations.md @@ -92,7 +92,7 @@ Port resolution is the same for `os dev` and `os start` (both spawn `os serve`): |:--------|:-------------| | `os data create` / `get` / `query` / `update` / `delete` | Record-level CRUD against a running server (there is no `os data seed` / `export` / `import` — seed data in the `data:` collection loads automatically at boot) | | `os diff` | Compare two ObjectStack config files and detect breaking changes | -| `os meta register` / `os meta resync` | Register (create/update) metadata on a target server / re-sync it (there is no `os meta apply`) | +| `os meta register` | Register (create/update) metadata on a target server (there is no `os meta apply`) | | `os migrate plan` / `os migrate apply` | Dry-run / apply physical-DB drift reconciliation from metadata (forward-only — no batch rollback; `os rollback` was removed) | ## Environments & deploy From 45c1afe70203d5d92710e27f828323074c9c7755 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 20:40:18 +0000 Subject: [PATCH 2/4] chore(docs-audit): the CLI command-anchor specimen is os meta register, a command that exists (#15204 stage 6b-2a) Claude-Session: https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49 Co-authored-by: Claude --- scripts/check-cli-command-ids.mjs | 2 +- scripts/docs-audit/affected-docs.mjs | 32 ++++++++++++++-------------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/scripts/check-cli-command-ids.mjs b/scripts/check-cli-command-ids.mjs index a7db333dfc6..1a0c4fe1b8f 100644 --- a/scripts/check-cli-command-ids.mjs +++ b/scripts/check-cli-command-ids.mjs @@ -317,7 +317,7 @@ const ROOT_DIR_WATCH_HINTS = ['scripts/**']; const FIXTURE_EXEMPTIONS = [ { file: 'scripts/docs-audit/affected-docs.mjs', - text: 'os meta resync-plan', + text: 'os meta register-plan', why: "affected-docs's own self-test case for 'a sibling command id is not this one'", }, { diff --git a/scripts/docs-audit/affected-docs.mjs b/scripts/docs-audit/affected-docs.mjs index 6010e3c025e..2a549dcc8e3 100644 --- a/scripts/docs-audit/affected-docs.mjs +++ b/scripts/docs-audit/affected-docs.mjs @@ -5008,18 +5008,18 @@ function selfTest() { const commandIdCases = [ // [path under the commands root, expected id, label] - ['meta/resync.ts', 'meta resync', 'topic + command — the #9230 specimen'], + ['meta/register.ts', 'meta register', 'topic + command (the #9230 specimen was `meta resync`, since retired)'], ['build.ts', 'build', 'a top-level command'], ['migrate/recorded-by.ts', 'migrate recorded-by', 'a hyphen is an ordinary id char'], ['migrate/index.ts', 'migrate', 'a topic index IS the topic, not "migrate index"'], ['a/b/c.ts', 'a b c', 'deeper nesting is read off the path, not special-cased'], ['cloud/whoami.js', 'cloud whoami', 'the compiled extension resolves the same way'], - ['meta/resync-skip-explanation.test.ts', null, 'a test file leaves a `.test` segment — declined, not guessed'], + ['meta/delete-json-error-code.test.ts', null, 'a test file leaves a `.test` segment — declined, not guessed'], ['__tests__/helper.ts', null, 'an underscore dir is not an oclif id segment'], ['meta/fixture.json', null, 'a non-source file under the commands dir'], ['index.ts', null, 'a bare index at the commands root would name the binary, not a command'], - ['meta/Resync.ts', null, 'an uppercase filename is not an oclif id'], + ['meta/Register.ts', null, 'an uppercase filename is not an oclif id'], ]; for (const [rel, want, label] of commandIdCases) check('commandIdFor', label, rel, want, commandIdFor(rel)); @@ -5043,7 +5043,7 @@ function selfTest() { const readFakePkg = (dir) => (dir === 'packages/cli' ? cliPkg : { name: '@objectstack/spec' }); const anchorFor = (f) => commandAnchorFor(f, readFakePkg, (d) => cliTree.has(d)); const commandAnchorCases = [ - ['packages/cli/src/commands/meta/resync.ts', 'os meta resync', 'the specimen resolves to its phrase'], + ['packages/cli/src/commands/meta/register.ts', 'os meta register', 'the specimen resolves to its phrase'], ['packages/cli/src/commands/build.ts', 'os build', 'a top-level command'], ['packages/cli/src/utils/schema-migrate.js', null, 'a CLI file OUTSIDE the commands dir is not a command'], ['packages/spec/src/commands/thing.ts', null, 'a commands dir in a package that declares no oclif is ignored'], @@ -5057,20 +5057,20 @@ function selfTest() { check('commandAnchorFor', 'a file outside any commands dir is not reported as unmapped either', 'packages/cli/src/utils/schema-migrate.js', null, anchorFor('packages/cli/src/utils/schema-migrate.js')); - // The doc-side matcher. `os meta resync` must find the page that writes it and must NOT + // The doc-side matcher. `os meta register` must find the page that writes it and must NOT // find prose that merely uses the words. - const cmdRe = commandPatternFor('meta resync', ['os', 'objectstack']); + const cmdRe = commandPatternFor('meta register', ['os', 'objectstack']); const commandMatchCases = [ - ['`os migrate`, `os meta resync`, a test run.', true, 'the real drivers.mdx line — inside a code span'], - ['Run `objectstack meta resync --json` to re-sync.', true, 'the alternate declared binary'], - ['npx objectstack meta resync', true, 'the npx form is the same phrase with a prefix'], - ['os meta\tresync', true, 'extra horizontal whitespace between the words'], - ['resync the metadata cache', false, 'the BARE token never matches — the whole point of the phrase'], - ['the meta resync step', false, 'the words without a binary name are prose'], - ['macos meta resync', false, 'a binary name must stand alone, not end another word'], - ['os meta resyncAll', false, 'a longer identifier is not the command'], - ['os meta resync-plan', false, 'a sibling command id is not this one'], - ['os meta\nresync', false, 'a line break is not intra-command whitespace'], + ['`os migrate`, `os meta register`, a test run.', true, 'inside a code span'], + ['Run `objectstack meta register --json` to register it.', true, 'the alternate declared binary'], + ['npx objectstack meta register', true, 'the npx form is the same phrase with a prefix'], + ['os meta\tregister', true, 'extra horizontal whitespace between the words'], + ['register the metadata cache', false, 'the BARE token never matches — the whole point of the phrase'], + ['the meta register step', false, 'the words without a binary name are prose'], + ['macos meta register', false, 'a binary name must stand alone, not end another word'], + ['os meta registerAll', false, 'a longer identifier is not the command'], + ['os meta register-plan', false, 'a sibling command id is not this one'], + ['os meta\nregister', false, 'a line break is not intra-command whitespace'], ]; for (const [text, want, label] of commandMatchCases) { check('commandPatternFor', label, JSON.stringify(text), want, cmdRe.test(text)); From 675d1bce3d0b220f2c985226159e06c40eb97010 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 21:56:30 +0000 Subject: [PATCH 3/4] fix(release-text): the pending #22581 changeset no longer lists os meta resync; two stale test comments (#15204 stage 6b-2a) The command is deleted in this PR, and #22581's changeset ships in the same release, so its list of commands that load .env files drops it. The showcase binder comment says the binder is gone; 'is was' reads 'was'. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49 --- .changeset/22581-migrate-reads-env-files.md | 2 +- examples/app-showcase/test/inert-wirings.test.ts | 6 ++---- .../cli/src/utils/schema-migrate.lazy-driver-import.test.ts | 2 +- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/.changeset/22581-migrate-reads-env-files.md b/.changeset/22581-migrate-reads-env-files.md index 1dd7be40975..50724f395d2 100644 --- a/.changeset/22581-migrate-reads-env-files.md +++ b/.changeset/22581-migrate-reads-env-files.md @@ -6,7 +6,7 @@ fix(cli): `os migrate` reads the project's `.env` files as `os serve` does, and `os serve`, `os start` and `os dev` load the project's `.env*` files before they read any variable. `os migrate` read only the process environment. So a project keeping `OS_DATABASE_URL` in `.env` served one database and migrated another: `os migrate plan` reported `.objectstack/data/objectstack.db` while `os serve` opened the `.env` database. An `OS_AUTH_SECRET` kept in `.env` left the auth family (`sys_user`, `sys_account`, …) out of the plan. -Every command that boots the one-shot schema stack now loads the same files first, through the same `dotenv-flow` call, with `os serve`'s mode rule: `NODE_ENV`, else `production`, and `development` for `os migrate security-catalog-overlays --dev`, which composes as `os serve --dev`. That covers every `os migrate` subcommand, and also `os meta resync`, `os secret rewrap`, `os secret orphans` and `os storage orphans`. A variable exported in the shell still wins over `.env`, as it does for `os serve`. The SQLite occupancy check that `os migrate apply` and the other write commands run before booting reads the same files, so it checks the database the command is about to migrate. +Every command that boots the one-shot schema stack now loads the same files first, through the same `dotenv-flow` call, with `os serve`'s mode rule: `NODE_ENV`, else `production`, and `development` for `os migrate security-catalog-overlays --dev`, which composes as `os serve --dev`. That covers every `os migrate` subcommand, and also `os secret rewrap`, `os secret orphans` and `os storage orphans`. A variable exported in the shell still wins over `.env`, as it does for `os serve`. The SQLite occupancy check that `os migrate apply` and the other write commands run before booting reads the same files, so it checks the database the command is about to migrate. This supersedes two phrasings in this release's `os migrate plan` / `apply` auth-family entry: `os migrate` now does read the project's `.env` files, and the deployment's `OS_AUTH_SECRET` composes the auth family whether it is exported or kept in `.env`. diff --git a/examples/app-showcase/test/inert-wirings.test.ts b/examples/app-showcase/test/inert-wirings.test.ts index c5111fab2c9..a27c26d2a4e 100644 --- a/examples/app-showcase/test/inert-wirings.test.ts +++ b/examples/app-showcase/test/inert-wirings.test.ts @@ -700,10 +700,8 @@ describe('sharing rules target an object under record-sharing enforcement (#9237 expect(rules.length).toBeGreaterThan(0); }); - // [ADR-0131 D3/D4] The junction binder still writes the same bindings for the - // readers that have not moved off `sys_position_permission_set`; until it is - // deleted the two must name the same pairs, or those readers judge a - // position by bindings the resolver does not grant. + // [ADR-0131 D3/D4] The junction binder is deleted: the position's own + // `permissionSets` is the one binding, and no row repeats it. it('each persona position declares the permission set its personas hold (ADR-0131 D3/D4)', () => { // The position definition is the binding the resolver reads; no junction // row is written for it. Pinned literally so a persona losing its set is a diff --git a/packages/cli/src/utils/schema-migrate.lazy-driver-import.test.ts b/packages/cli/src/utils/schema-migrate.lazy-driver-import.test.ts index 9484e911919..49d758ac305 100644 --- a/packages/cli/src/utils/schema-migrate.lazy-driver-import.test.ts +++ b/packages/cli/src/utils/schema-migrate.lazy-driver-import.test.ts @@ -6,7 +6,7 @@ * The cost of one such import is not paid by the command that needs the driver. * oclif's `findCommand` walks the command table and `import()`s every command * module on **every** CLI invocation, so a broken import chain anywhere in that - * table is charged to whatever command you actually ran. `schema-migrate.ts` is + * table is charged to whatever command you actually ran. `schema-migrate.ts` * was shared by nine commands (then `meta:resync`, `migrate`, and seven `migrate:*`), and * its one static `import { isInPlaceSchemaWork } from '@objectstack/driver-sql'` * meant that an unbuilt `packages/drivers/driver-sql/dist` made `os dev` print From a9339b02f08813840c34362e796d23d8db88f559 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 22:19:51 +0000 Subject: [PATCH 4/4] test(plugin-security): keep the platform-admin seeding suites until the code they pin is deleted (#15204 stage 6b-2a) bootstrap-platform-admin.test.ts and bootstrap-platform-admin-seeded-provenance.test.ts pin bootstrapPlatformAdmin's seeding, which 6b-2 deletes; no pin of a behaviour 6b-2 deletes leaves main before it. Restored byte-identical to main with their ledger entries; their deletion moves to the T1 PR, which lands directly before 6b-2. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_013XKtmoR5QCUSTMjiQ3os49 --- ...p-platform-admin-seeded-provenance.test.ts | 369 ++++++++++++++++++ .../src/bootstrap-platform-admin.test.ts | 143 +++++++ scripts/engine-double-contract.baseline.json | 8 + scripts/objectql-double-limit.baseline.json | 3 + 4 files changed, 523 insertions(+) create mode 100644 packages/plugins/plugin-security/src/bootstrap-platform-admin-seeded-provenance.test.ts create mode 100644 packages/plugins/plugin-security/src/bootstrap-platform-admin.test.ts diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin-seeded-provenance.test.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin-seeded-provenance.test.ts new file mode 100644 index 00000000000..92957e006b3 --- /dev/null +++ b/packages/plugins/plugin-security/src/bootstrap-platform-admin-seeded-provenance.test.ts @@ -0,0 +1,369 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [commit 712e185db] What provenance a REAL `bootstrapPlatformAdmin` run leaves on the + * platform default permission sets, and what `os meta resync` then does with it. + * + * ## Why this file exists at all + * + * `bootstrap-platform-admin.test.ts` covers the resync branch three ways — + * `managed_by` hand-seeded to `null`, to `'user'`, to `'package'` — and every + * one of them hand-builds the pre-image. That is correct unit coverage of the + * BRANCH, and it is deliberately left untouched here. What none of them can + * answer is which pre-image the seeder itself produces, because a fake `ql` + * stores exactly the columns it is handed and a declared field `defaultValue` + * never runs. So the one row shape the platform actually creates was the one + * row shape nothing exercised, and the drift was invisible in both directions. + * + * This file closes that by seeding through the REAL shipped + * `defaultPermissionSets` against a real `ObjectQL` engine over a real + * better-sqlite3 `SqlDriver` — the same wiring `security-plugin.ts` and + * `os meta resync` use, which hand the bare engine straight to the seeder. + * + * ## The ruling these assertions now pin (2026-08-15, Option A) + * + * The original version of this file recorded a MEASUREMENT and explicitly + * refused to endorse it: a seeded row stored `'admin'` (the declaration's + * `defaultValue`), so `os meta resync` returned `resynced 0 / resyncSkipped 8` + * and skipped every platform default set — the inverse of what #2705 built the + * flag for. It left the product question open: + * + * > are the platform default permission sets meant to be platform-owned + * > (and therefore resyncable), or env-authored (and therefore deliberately + * > left alone)? + * + * ⛔ That question is CLOSED. The maintainer ruled **Option A** on 2026-08-15: + * the platform default sets are platform-owned and resyncable, and the seeder + * stamps `managed_by: 'platform'` on its insert. This file no longer records an + * undecided state — it pins the ruling, on BOTH sides of the line the ruling + * drew: + * + * 1. **Forward (section 1)** — a fresh install stores `'platform'` and a real + * resync reconciles every default set. + * 2. **Legacy (section 2)** — a row from a PRE-ruling install still carries + * `'admin'` and is still SKIPPED, forever and on purpose. The ruling + * forbids a migration: a stored `'admin'` cannot be told apart from a + * genuine Setup takeover, so restamping could silently overwrite a real + * admin's edits on the next resync. Report, don't rewrite. + * + * Section 2 is the half that is easy to lose. Deleting it would leave the + * upgrade path — the one every existing deployment is actually on — with no + * coverage at all, and a later "cleanup" that restamps legacy rows would then + * go green. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +// `sys_user` is what the promotion half enumerates. Registered as the REAL +// declaration so its verdict ("no human users yet") is a genuine reading of an +// empty table rather than a table-missing error `tryFind` swallowed into `[]` — +// the two are indistinguishable from the outside and only one of them is the +// first-boot ordering this file means to reproduce. +import { SysUser } from '@objectstack/platform-objects/identity'; +// The REAL shipped seeder and the REAL shipped declarations. A local +// re-implementation of either would make this a test of the copy, which is +// precisely the gap the file exists to close. +import { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js'; +import { SysPermissionSet } from './objects/sys-permission-set.object.js'; +import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; +import { defaultPermissionSets } from './objects/default-permission-sets.js'; + +const SYSTEM_CTX = { isSystem: true }; + +const engines: ObjectQL[] = []; + +afterEach(async () => { + while (engines.length) { + try { + await engines.pop()?.destroy(); + } catch { + /* noop */ + } + } +}); + +/** + * A fresh engine on its own `:memory:` database, carrying the permission-set + * declaration passed in. `declaration` exists for section 3's counterfactual; + * every case in sections 1 and 2 boots the shipped declaration unmodified. + */ +async function boot(declaration: any = SysPermissionSet): Promise { + const engine = new ObjectQL(); + engine.registerDriver( + new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }), + true, + ); + await engine.init(); + engine.registerApp({ + id: 'com.objectstack.security-objects', + name: 'Security Objects', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: [declaration, SysUserPermissionSet, SysUser], + } as any); + await engine.syncSchemas(); + engines.push(engine); + return engine; +} + +/** + * The row as it is physically STORED, read at the driver past every engine-side + * projection. The resync branch reads the engine's view, so both are asserted + * where it matters — a default applied only on read and a value written on + * insert are different facts about the database, and only one of them survives + * a restart. + */ +async function storedRow(engine: ObjectQL, name: string): Promise { + const driver: any = (engine as any).getDriver('sys_permission_set'); + const rows: any[] = await driver.knex('sys_permission_set').select('*').where({ name }); + return rows[0]; +} + +/** The row as the seeder's own `tryFind` sees it — the value resync branches on. */ +async function rowViaEngine(engine: ObjectQL, name: string): Promise { + const rows: any[] = await (engine as any).find( + 'sys_permission_set', + { where: { name }, limit: 1 }, + { context: SYSTEM_CTX }, + ); + return rows[0]; +} + +/** + * A row exactly as an install before commit 712e185db holds it — written the way the old + * seeder wrote it, which is to say WITHOUT `managed_by`, so the value comes + * from the declaration's `defaultValue: 'admin'` by the very mechanism that + * produced it on every install created before the ruling. + * + * Reproducing the legacy pre-image through its original mechanism rather than + * hand-writing `'admin'` is deliberate: it keeps the case anchored to the real + * declaration, and each case asserts the resulting value before relying on it, + * so a moved default shows up as a failure here instead of quietly turning the + * legacy pin into a test of something else. + */ +async function seedLegacyRow(engine: ObjectQL, ps: any): Promise { + await (engine as any).insert( + 'sys_permission_set', + { + id: `ps_legacy_${ps.name}`, + name: ps.name, + label: ps.label ?? ps.name, + // A deliberately STALE payload: the resync would rewrite these if it ever + // touched the row, so section 2 can prove "left untouched" by content and + // not merely by a counter. + object_permissions: '{}', + field_permissions: '{}', + system_permissions: '[]', + row_level_security: '[]', + tab_permissions: '{}', + active: true, + }, + { context: SYSTEM_CTX }, + ); +} + +function collectingLogger() { + const info: string[] = []; + const warn: string[] = []; + return { + info, + warn, + logger: { + info: (m: string) => info.push(m), + warn: (m: string) => warn.push(m), + }, + }; +} + +describe('#8692 — provenance of a seeder-created default permission set (measured, real engine)', () => { + // ─────────────────────────────────────────────────────────────────────────── + // 1. FORWARD — a fresh install after the ruling + // ─────────────────────────────────────────────────────────────────────────── + + it('ANTI-VACUITY: the shipped defaults are non-empty and really get seeded', async () => { + // Every count below is relative to this set. If it were empty, "resync + // reconciled all of them" would be trivially true of nothing — the shape in + // which this whole file could pass while measuring air. + expect(defaultPermissionSets.length).toBeGreaterThan(0); + + const engine = await boot(); + const out = await bootstrapPlatformAdmin(engine as any, defaultPermissionSets); + expect(out.seeded).toBe(defaultPermissionSets.length); + }); + + it('a seeder-created row is stored `managed_by: "platform"` — stamped, not defaulted', async () => { + // THE RULING, pinned (Option A, 2026-08-15). Before it, this row stored + // `'admin'` — the declaration's `defaultValue` — because the insert omitted + // `managed_by` entirely. The seeder now stamps provenance explicitly, in + // line with `bootstrap-builtin-positions.ts`, which always has. + const engine = await boot(); + await bootstrapPlatformAdmin(engine as any, defaultPermissionSets); + + // Physically stored, not merely projected on read: the value survives a + // restart, so every later boot reads it too. + expect((await storedRow(engine, 'admin_full_access')).managed_by).toBe('platform'); + // …and the same value is what the seeder's own read sees, which is the one + // the resync condition branches on. + expect((await rowViaEngine(engine, 'admin_full_access')).managed_by).toBe('platform'); + }); + + it('so `os meta resync` now reconciles EVERY platform default set', async () => { + // The behaviour #2705 built the flag for, finally reachable: resynced == the + // whole shipped set, resyncSkipped 0. Asserted against the live length so + // the pin stays true if a default set is added or removed. + const engine = await boot(); + await bootstrapPlatformAdmin(engine as any, defaultPermissionSets); + + const out = await bootstrapPlatformAdmin(engine as any, defaultPermissionSets, { resync: true }); + + expect(out.resynced).toBe(defaultPermissionSets.length); + expect(out.resyncSkipped).toBe(0); + }); + + it('a resync of a fresh install warns about nothing', async () => { + // The counterpart to the old measurement, which emitted one bogus + // "intentional override" warn per shipped set. Nothing is skipped now, so + // nothing is warned about. + const engine = await boot(); + await bootstrapPlatformAdmin(engine as any, defaultPermissionSets); + + const { warn, logger } = collectingLogger(); + await bootstrapPlatformAdmin(engine as any, defaultPermissionSets, { resync: true, logger }); + + expect(warn).toEqual([]); + }); + + it('resync reconciles a STALE platform-owned row to the shipped declaration', async () => { + // Counts alone cannot show the reconcile actually landed. Blank the payload + // on a seeded row, resync, and read the declaration back out of storage. + const engine = await boot(); + await bootstrapPlatformAdmin(engine as any, defaultPermissionSets); + + const driver: any = (engine as any).getDriver('sys_permission_set'); + await driver.knex('sys_permission_set') + .where({ name: 'admin_full_access' }) + .update({ system_permissions: '[]' }); + expect((await storedRow(engine, 'admin_full_access')).system_permissions).toBe('[]'); + + await bootstrapPlatformAdmin(engine as any, defaultPermissionSets, { resync: true }); + + const shipped = defaultPermissionSets.find((p) => p.name === 'admin_full_access')!; + expect(JSON.parse((await storedRow(engine, 'admin_full_access')).system_permissions)) + .toEqual(shipped.systemPermissions ?? []); + // …and the reconcile did NOT touch provenance: `platformOwnedFields` carries + // no `managed_by`, which is what keeps a resync from restamping any row. + expect((await storedRow(engine, 'admin_full_access')).managed_by).toBe('platform'); + }); + + // ─────────────────────────────────────────────────────────────────────────── + // 2. LEGACY — a pre-ruling install, which keeps the skip forever + // ─────────────────────────────────────────────────────────────────────────── + + it('LEGACY: a pre-ruling `admin`-stamped row is still skipped, and its content survives', async () => { + // ⛔ The ruling's load-bearing half: forward-stamp only, NO migration. A + // stored `'admin'` is indistinguishable between "the old seeder's field + // default" and "an administrator took this set over in Setup", so it must + // never be reconciled on the strength of a guess. + const engine = await boot(); + const legacy = defaultPermissionSets[0]; + await seedLegacyRow(engine, legacy); + + // The pre-image really is what a pre-ruling install holds — asserted, not + // assumed, so this case cannot pass by testing some other row shape. + expect((await storedRow(engine, legacy.name)).managed_by).toBe('admin'); + + // An upgraded install: the seeder runs, finds the legacy row and leaves it, + // and inserts the remaining sets freshly stamped `'platform'`. + await bootstrapPlatformAdmin(engine as any, defaultPermissionSets); + expect((await storedRow(engine, legacy.name)).managed_by).toBe('admin'); + + const out = await bootstrapPlatformAdmin(engine as any, defaultPermissionSets, { resync: true }); + + // The mixed shape an upgrade produces: everything the platform stamped is + // reconciled, the one legacy row is not. + expect(out.resyncSkipped).toBe(1); + expect(out.resynced).toBe(defaultPermissionSets.length - 1); + + // "Left untouched" proven by CONTENT, not just by a counter: the stale + // payload seeded above is still stale, and provenance was not restamped. + const row = await storedRow(engine, legacy.name); + expect(row.system_permissions).toBe('[]'); + expect(row.object_permissions).toBe('{}'); + expect(row.managed_by).toBe('admin'); + }); + + it('LEGACY: the skip is logged neutrally — no claim of intent', async () => { + // The warn used to end "(intentional override)", which is false for exactly + // these rows: on a pre-ruling install the only writer may have been the + // seeder itself one call earlier. The reworded line states the provenance + // and the action and asserts nothing about anybody's intent. + const engine = await boot(); + const legacy = defaultPermissionSets[0]; + await seedLegacyRow(engine, legacy); + await bootstrapPlatformAdmin(engine as any, defaultPermissionSets); + + const { warn, logger } = collectingLogger(); + await bootstrapPlatformAdmin(engine as any, defaultPermissionSets, { resync: true, logger }); + + expect(warn).toEqual([ + `[security] resync left ${legacy.name} untouched — row is admin-owned`, + ]); + // Pinned as a substring too, so a future reword cannot quietly reintroduce + // the intent claim in some other sentence shape. + expect(warn.join('\n')).not.toContain('intentional override'); + }); + + // ─────────────────────────────────────────────────────────────────────────── + // 3. ATTRIBUTION — which link actually supplies the value + // ─────────────────────────────────────────────────────────────────────────── + + /** + * A counterfactual, and note it runs the OPPOSITE way round from the one this + * file carried before the ruling. Back then the seeder wrote no `managed_by`, + * so respelling the declared default flipped the stored value — which is what + * proved the default was the source. Now the seeder stamps explicitly, so the + * declared default must no longer be able to move the value at all. + * + * Predicted direction, written down before running: boot a CLONE of the + * declaration whose `managed_by` default is respelled to `'package'`, seed + * with the same unmodified seeder, and the row must STILL store `'platform'`. + * + * That one reading establishes what section 1 cannot on its own: the value + * comes from the seeder's stamp and not from a declaration that merely + * happens to agree with it. Without this case, respelling + * `defaultValue: 'admin'` to `'platform'` in the object file would make every + * assertion in section 1 pass with the stamp deleted. + */ + it('ATTRIBUTION: the seed stamp beats the declared default, so the default cannot move it', async () => { + const respelled = { + ...SysPermissionSet, + fields: { + ...(SysPermissionSet as any).fields, + managed_by: { ...(SysPermissionSet as any).fields.managed_by, defaultValue: 'package' }, + }, + }; + // Guard: the clone is a copy, and the shipped declaration is untouched by + // building it. Without this the case could be measuring a mutation it made. + // The shipped default stays `'admin'` deliberately — it is the right default + // for a set an admin creates in Setup, and the ruling changed the SEEDER, + // not the declaration. + expect((SysPermissionSet as any).fields.managed_by.defaultValue).toBe('admin'); + + const engine = await boot(respelled); + await bootstrapPlatformAdmin(engine as any, defaultPermissionSets); + + expect((await storedRow(engine, 'admin_full_access')).managed_by).toBe('platform'); + + // And the consequence still holds under a default that would otherwise have + // caused a skip: resync reconciles everything. + const out = await bootstrapPlatformAdmin(engine as any, defaultPermissionSets, { resync: true }); + expect(out.resynced).toBe(defaultPermissionSets.length); + expect(out.resyncSkipped).toBe(0); + }); +}); diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin.test.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin.test.ts new file mode 100644 index 00000000000..9701a38b887 --- /dev/null +++ b/packages/plugins/plugin-security/src/bootstrap-platform-admin.test.ts @@ -0,0 +1,143 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * bootstrapPlatformAdmin — permission-set materialization, focused on the + * insert-once vs `resync` (#2705) split. + * + * The default boot path is insert-once: an existing default permission-set row + * is env-authored config and is never clobbered on restart (so admin Setup + * edits survive). That protection is CORRECT for prod but makes a dev source + * edit silently stale until a `--fresh` wipe. `os meta resync` passes + * `{ resync: true }` to reconcile platform-owned rows to the shipped dist + * without touching business data — while still refusing to overwrite a row an + * admin or a package has explicitly taken over. + */ + +import { describe, it, expect } from 'vitest'; +import { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js'; +import { applyManagedWriteDenies } from './managed-object-write-denies.js'; + +/** Minimal in-memory ql. Only `sys_permission_set` is modeled; the admin- + * promotion tables return empty, so promotion short-circuits and we assert the + * seed/resync outcome (carried on every return) directly. */ +function makeQl(seedRows: any[] = []) { + const rows: any[] = seedRows.map((r) => ({ ...r })); + return { + rows, + async find(object: string, q: any) { + if (object !== 'sys_permission_set') return []; + const where = q?.where ?? {}; + return rows.filter((r) => Object.entries(where).every(([k, v]) => { if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); return r[k] === v; })); + }, + async insert(object: string, data: any) { + if (object !== 'sys_permission_set') return null; + rows.push({ ...data }); + return { id: data.id }; + }, + async update(object: string, data: any) { + if (object !== 'sys_permission_set') return; + const r = rows.find((x) => x.id === data.id); + if (r) Object.assign(r, data); + }, + }; +} + +/** Shipped declaration: member_default now grants `setup.access`. */ +const memberDefault = (over: Record = {}) => + ({ + name: 'member_default', + label: 'Member', + objects: { crm_lead: { allowRead: true } }, + systemPermissions: ['setup.access'], + ...over, + }) as any; + +const row = (ql: ReturnType) => ql.rows.find((x) => x.name === 'member_default'); + +describe('bootstrapPlatformAdmin — insert-once vs resync (#2705)', () => { + it('default (no resync): leaves an existing row stale — the deliberate insert-once posture', async () => { + const ql = makeQl([ + { id: 'ps_old', name: 'member_default', system_permissions: '[]', object_permissions: '{}' }, + ]); + const r = await bootstrapPlatformAdmin(ql, [memberDefault()]); + expect(r.resynced).toBe(0); + // Same row, and the shipped `setup.access` did NOT land — this is the + // #2705 boot behavior (protects admin edits; stale in the dev loop). + expect(row(ql)!.id).toBe('ps_old'); + expect(row(ql)!.system_permissions).toBe('[]'); + }); + + it('resync: reconciles a platform-owned row to the shipped declaration in place', async () => { + const ql = makeQl([ + { id: 'ps_old', name: 'member_default', system_permissions: '[]', object_permissions: '{}', managed_by: null }, + ]); + const r = await bootstrapPlatformAdmin(ql, [memberDefault()], { resync: true }); + expect(r.resynced).toBe(1); + expect(r.resyncSkipped).toBe(0); + // Updated in place (no new insert), and the declaration is now live. + expect(ql.rows.filter((x) => x.name === 'member_default')).toHaveLength(1); + expect(row(ql)!.id).toBe('ps_old'); + expect(JSON.parse(row(ql)!.system_permissions)).toEqual(['setup.access']); + expect(JSON.parse(row(ql)!.object_permissions)).toEqual({ crm_lead: { allowRead: true } }); + }); + + it('resync: leaves an admin-owned (managed_by:user) row untouched', async () => { + const ql = makeQl([ + { id: 'ps_custom', name: 'member_default', system_permissions: '["custom.perm"]', managed_by: 'user' }, + ]); + const r = await bootstrapPlatformAdmin(ql, [memberDefault()], { resync: true }); + expect(r.resynced).toBe(0); + expect(r.resyncSkipped).toBe(1); + expect(row(ql)!.system_permissions).toBe('["custom.perm"]'); + }); + + it('resync: leaves a package-owned row untouched', async () => { + const ql = makeQl([ + { id: 'ps_pkg', name: 'member_default', system_permissions: '[]', managed_by: 'package', package_id: 'com.x' }, + ]); + const r = await bootstrapPlatformAdmin(ql, [memberDefault()], { resync: true }); + expect(r.resynced).toBe(0); + expect(r.resyncSkipped).toBe(1); + }); + + it('resync: inserts a set that does not exist yet (nothing to reconcile)', async () => { + const ql = makeQl([]); + const r = await bootstrapPlatformAdmin(ql, [memberDefault()], { resync: true }); + expect(r.resynced).toBe(0); + expect(row(ql)).toBeTruthy(); + expect(JSON.parse(row(ql)!.system_permissions)).toEqual(['setup.access']); + }); +}); + +// #3325 — the transform runs upstream in runBootstrap; here we confirm that once +// a set has been enriched by applyManagedWriteDenies, bootstrapPlatformAdmin +// serializes the injected denies into the seeded object_permissions JSON. +describe('bootstrapPlatformAdmin — managed write denies land in the seed row (#3325)', () => { + const schemas = [ + { name: 'sys_sso_provider', managedBy: 'better-auth' }, + { name: 'crm_lead', managedBy: 'platform' }, + ]; + // member_default is a target set; give it the wildcard shape the real one has. + const enriched = () => { + const set = { name: 'member_default', label: 'Member', objects: { '*': { allowRead: true, allowCreate: true } } } as any; + applyManagedWriteDenies([set], schemas); + return set; + }; + + it('fresh DB: the injected deny serializes into object_permissions', async () => { + const ql = makeQl([]); + await bootstrapPlatformAdmin(ql, [enriched()]); + const perms = JSON.parse(row(ql)!.object_permissions); + expect(perms.sys_sso_provider).toEqual({ allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false }); + expect(perms.crm_lead).toBeUndefined(); // platform bucket never denied + }); + + it('resync: reconciles a stale platform-owned row that predates the deny', async () => { + const ql = makeQl([ + { id: 'ps_old', name: 'member_default', system_permissions: '[]', object_permissions: '{}', managed_by: null }, + ]); + const r = await bootstrapPlatformAdmin(ql, [enriched()], { resync: true }); + expect(r.resynced).toBe(1); + expect(JSON.parse(row(ql)!.object_permissions).sys_sso_provider.allowCreate).toBe(false); + }); +}); diff --git a/scripts/engine-double-contract.baseline.json b/scripts/engine-double-contract.baseline.json index fced54533c2..bdf92642bfb 100644 --- a/scripts/engine-double-contract.baseline.json +++ b/scripts/engine-double-contract.baseline.json @@ -392,6 +392,14 @@ "why": "MEASURED (#5480): the `update` slice of this gate is NEW — `resolveEngineUpdateDispatch` did not exist before #5480, so no double in the repo could route through it and the whole discovered set enters this ledger in one act. Not newly written looseness and not a raised ratchet: it is the first measurement of a contract that had no producer-side predicate to measure against, which is exactly what this script's header used to list under deliberately-not-covered (\"update's twin dispatch ... needs its own producer-side predicate extracted first\"). Discovered at line 12. The package does not depend on @objectstack/objectql yet, and the devDependency route is AVAILABLE rather than cyclic. Measured on this branch, not cited: the edge was added to @objectstack/plugin-security's devDependencies and turbo 2.10.7 accepted the graph (`turbo run build --filter=@objectstack/plugin-security --dry`, no circular-dependency warning), then the edge was reverted. WHAT THIS ENTRY DOES NOT CLAIM: unlike the #5629 delete batch above it carries NO per-file dormancy probe. Nothing here says the looseness is unexercised — only that the double is structurally free to be looser than ObjectQL.update on the shape a hand-written guard always drops (`where: { id: { $in: [...] } }` looks like an id and is a multi-row predicate) — a shape the producer refuses in `data.id` too since objectstack#5748 put the payload half through the SAME scalar test, so `data.id` still outranks `where` and `multi`, but only when it IS a scalar id.", "closes": "add @objectstack/objectql to this package's devDependencies (verified acyclic — see `why`), then open the fake's update with assertEngineUpdateDispatch(data, options)" }, + { + "file": "packages/plugins/plugin-security/src/bootstrap-platform-admin.test.ts", + "verb": "update", + "unguarded": 1, + "kind": "DEBT", + "why": "MEASURED (#5480): the `update` slice of this gate is NEW — `resolveEngineUpdateDispatch` did not exist before #5480, so no double in the repo could route through it and the whole discovered set enters this ledger in one act. Not newly written looseness and not a raised ratchet: it is the first measurement of a contract that had no producer-side predicate to measure against, which is exactly what this script's header used to list under deliberately-not-covered (\"update's twin dispatch ... needs its own producer-side predicate extracted first\"). Discovered at line 25. The package does not depend on @objectstack/objectql yet, and the devDependency route is AVAILABLE rather than cyclic. Measured on this branch, not cited: the edge was added to @objectstack/plugin-security's devDependencies and turbo 2.10.7 accepted the graph (`turbo run build --filter=@objectstack/plugin-security --dry`, no circular-dependency warning), then the edge was reverted. WHAT THIS ENTRY DOES NOT CLAIM: unlike the #5629 delete batch above it carries NO per-file dormancy probe. Nothing here says the looseness is unexercised — only that the double is structurally free to be looser than ObjectQL.update on the shape a hand-written guard always drops (`where: { id: { $in: [...] } }` looks like an id and is a multi-row predicate) — a shape the producer refuses in `data.id` too since objectstack#5748 put the payload half through the SAME scalar test, so `data.id` still outranks `where` and `multi`, but only when it IS a scalar id.", + "closes": "add @objectstack/objectql to this package's devDependencies (verified acyclic — see `why`), then open the fake's update with assertEngineUpdateDispatch(data, options)" + }, { "file": "packages/plugins/plugin-security/src/permission-set-projection.test.ts", "verb": "delete", diff --git a/scripts/objectql-double-limit.baseline.json b/scripts/objectql-double-limit.baseline.json index e8e3a67c64f..9a37c3fd650 100644 --- a/scripts/objectql-double-limit.baseline.json +++ b/scripts/objectql-double-limit.baseline.json @@ -588,6 +588,9 @@ "packages/plugins/plugin-security/src/bootstrap-platform-admin-walled-owner.test.ts": { "blind": 1 }, + "packages/plugins/plugin-security/src/bootstrap-platform-admin.test.ts": { + "blind": 1 + }, "packages/plugins/plugin-security/src/bootstrap-seed-round-trips.test.ts": { "unjudged": 1 },