Skip to content

Commit 0d784da

Browse files
fix(cli,plugin-security): os migrate organization-ownership --apply resolves the built-in positions and the everyone baseline (#23433)
Fixes #23397 Clause-②: no (the `authored-conversion` guard and the rewrite gate are unchanged; the catalog they read gains what a started kernel already serves, per `schema-migration-plugins.ts`'s "What a schema command needs from a plugin is its DECLARATIONS, and the kernel contract puts those in `init()`"; no export is added) ## What was wrong `os migrate organization-ownership --apply` boots the deployment's served composition with every platform provider composed for its declarations only. The security plugin's `init()` runs; its `start()` and every hook do not. The plugin declared the six built-in positions in `start()`, and `everyone`'s baseline from a `kernel:ready` hook registered in `start()`. So the ceremony's catalog held neither, and every 17.x database's seeded `everyone` to `member_default` binding blocked the ceremony. ## What changed (route (a) of the card, inside `plugin-security`) - `SecurityPlugin.init()` declares the six built-in positions, with `everyone` naming the deployment's baseline (the app's `isDefault` set composed with `member_default`), through the same `registerBuiltinPositions` / `declareEveryoneBaseline` seam as before. These are registry writes only. No database read or write is added. - The baseline in `init()` is the **fail-closed reading**: the high-privilege check runs with no declared-capability context, because the stack's declarations are still arriving while plugins initialize. With no context a token is never excused, so this list is never wider than the one `kernel:ready` derives. - `kernel:ready`'s `declareEveryone` is unchanged in what it computes. It runs the check against every declared capability, adds the accepted package defaults (a database read, which stays there), and replaces the `init()` declaration. The baseline filter is one private method that both call (`everyoneBaseline`), so there is no second derivation. - The "the built-in positions … were NOT declared as position metadata" warning moved with the declaration into `init()`. It still fires when the engine exposes no registry. When no engine is registered, `init()` says nothing, because `start()` already reports that. - No cli source changed: no new export, no host workaround, and nothing in `organization-ownership-apply.ts`, `organization-ownership-plan.ts` or `schema-migration-plugins.ts`. ## What a started kernel sees Its catalog after boot is the same. Three things move: 1. The six positions and `everyone`'s baseline are in the engine registry from the plugin's `init()` instead of from `start()` and `kernel:ready`. Any reader between the two now sees the final names earlier. 2. If the `kernel:ready` derivation throws, `everyone` keeps the `init()` baseline. It used to name no set. 3. The no-registry warning prints from `init()`. Unchanged: the cold boot's environment-held-name refusal skips the built-in names, as it did. A stored definition under a built-in name still shadows the declaration at read. objectql's `protocol-boot-hydration-scoped.test.ts` control already declares the built-in in Phase 1 and holds both. ## Measurement of the dispatch hypotheses (on `5656c5638`) - **H1 (confirmed).** `registerBuiltinPositions` was in `start()`, and `declareEveryoneBaseline` was inside `declareEveryone`, which ran from a `kernel:ready` hook registered in `start()`. The apply boot composes the security plugin twice: once through `composeAuthGatedSecurity` (`composeForDeclarations`) and once in the served platform's auth family (`composeProviderForDeclarations`). The kernel keeps the later one, so the surviving instance is the provider posture, which withholds every hook including `kernel:ready`. Measured: the boot logs "Plugin superseded: 'com.objectstack.security' — the later registration REPLACED the earlier one". So only `init()` runs, and a `kernel:ready` hook registered from `init()` would not have reached the ceremony either. - **H2 (confirmed).** `ql.registry` is reachable in `init()`: the plugin depends on `com.objectstack.engine.objectql`, and `init()` already reads that plugin's `manifest` service. No reader depends on the declaration happening in `start()`. The cold-boot check skips the built-in names, the item seam's built-in carve-out holds in either order, and the plugin-security, plugin-auth, rest, runtime and organizations suites listed below are green. - **H3.** Both `authored-conversion` and the rewrite gate compare the binding rows against the `permissionSets` that the catalog's position definition names (`declaredPositionSets`, which feeds `convertPositionBindingRows`). The fate must be `declared`. `authored-conversion` additionally needs the position to exist. So both need the built-in position and the deployment baseline. The accepted-default half is a `sys_audience_binding_suggestion` read and stays at `kernel:ready`; the ceremony does not read it (Acceptance notes). - **H4 (held).** No write is added on `os migrate plan`. The no-write pins stay green: `schema-migrate.host-composition.integration.test.ts` ("writes NOTHING while planning — no host seeder runs", "still writes NOTHING — the declaration-phase suppression is intact", the #13332 "THE FIX: the declaration boot lands none of them"), `schema-migration-plugins.declaration-boot-write-guard.test.ts` and `security-catalog-overlays.integration.test.ts`: 3 files, 40 tests passed. ## Proof **The card's own case, the real command.** I ran `os migrate organization-ownership --apply --backup-taken --json` in-process against a database that this release's apply composition provisioned, plus the 17.x rows: the seeded `everyone` position, `member_default` and the binding between them, with no `org_member` binding. | | before (old placement, dist rebuilt) | after | |---|---|---| | `single` | `authored-conversion` **blocked**: "Binding rows give position everyone the sets member_default, and the catalog holds no position of that name to carry them." | `authored-conversion` done, `rewrite` done, `mirror-deletion` deleted 3 | | `OS_MULTI_ORG_ENABLED=true` (posture `isolated`) | `rewrite` **blocked**: "Binding rows give position everyone sets its catalog definition does not name (member_default); deleting them would lose a grant." | `rewrite` done, `mirror-deletion` deleted 3 | After the fix, both runs stop at `column-drops` on `sys_metadata*`.`organization_id` ("not proposed for dropping"). That is the inventory's `column-drop` rows, out of bounds here. **The pins:** - `packages/cli/src/utils/organization-ownership-apply.served-catalog.integration.test.ts`. It runs the apply command's boot flag for flag, with core's catalog reader, against the 17.x rows plus a control binding to `zz_undeclared`. It checks that the six built-ins resolve and `everyone` names `member_default`. Under `single`, `authored-conversion` blocks on the control alone. Walled, the rewrite gate blocks on the control alone. **A real conversion still blocks** when a binding names a position that is absent. - `packages/plugins/plugin-security/src/builtin-positions.declaration-phase.test.ts`. It runs `init()` alone over a real engine registry. The six resolve, owned by the plugin. `everyone` names the app default and then `member_default`. The fail-closed reading leaves out a set whose token only a declaration excuses, and says nothing. The warning fires on an engine with no registry, and stays silent when no engine is registered. **Negative control (one-shot, at `7f3b67b69`).** I restored `security-plugin.ts` from the base (on-disk hash `b042c660f9` equals the base blob) and rebuilt plugin-security. `ablation-dist-preflight --absent` showed the init call gone from all 6 built files. Then: - the plugin pin goes red, 4 failed and 1 passed (the no-engine case); - the cli pin goes red, 3 of 3 failed; - the card's two messages above reproduce. Restored with `git checkout HEAD`: hash `7a64f14215` equals the HEAD blob, and `git diff HEAD` is empty. I rebuilt, and the preflight found the marker in 2 built files. Both pins are green again. ## The grant goldens moved by one recorded step Four plugin-security goldens record a harness that runs `init()` and `start()` but fires no `kernel:ready`: - `grant-permission-set-name.equivalence.test.ts` - `grant-permission-set-name-backfill.test.ts` - `grant-readers-by-name.golden.test.ts` - `resolve-authz-grant-set-by-name.golden.test.ts` In that harness `everyone` used to name nothing. It now names the baseline, so every principal holding `everyone` gains `member_default`, and nothing else moves. That is what a started kernel's `kernel:ready` derivation has always given: `builtin-positions.boot.test.ts`'s grant golden, which fires the hooks, already carries `member_default` for every principal and is unchanged. Each golden's note records the move. The backfill's "an unnamed grant confers nothing" check now asserts exactly `['member_default']` (what `everyone` names) instead of `[]`. ## Verification (final HEAD `1ebf9c5d7`) - `pnpm --filter @objectstack/plugin-security exec vitest run`: 186 files, 3781 passed, 45 skipped. `typecheck`: exit 0. - `@objectstack/cli` unit tier, in three shards: 96 + 96 + 95 files. Two `published-subpath-*` pins first reported PREREQUISITE NOT MET because the cli was not built; after `pnpm --filter @objectstack/cli build` they pass, 29 tests. cli `typecheck`: exit 0. - cli integration, the files named above: the new pin (3), plus the H4 set (40). - Consumers that boot the plugin without `kernel:ready`: plugin-auth 5 files / 41, rest 9 files / 120, runtime `share-links-enforcement-context` 28, organizations `create-explicit-organization-wall` 21. All green. - `dispatch-gates --ran`: 68 derived, 68 run, 0 NOT-MEASURED, every family exit 0. `check:dual-build-cjs-loads` and `check:i18n-coverage` first answered PREREQUISITE NOT MET (exit 3, dist of unrelated packages); re-run with the dist present, both exit 0. - `pnpm lint` (full `eslint .`) at `1ebf9c5d7`: exit 0. ## Acceptance notes - **The accepted package defaults stay out of the ceremony's catalog.** That half of `everyone` is a database read and stays at `kernel:ready`. A binding to a set that only a deployment-level acceptance puts on `everyone` still blocks the ceremony. A 17.x database holds no such acceptance (rows resolved before the deployment-level decision are re-opened as pending), so the card's population is unaffected. This is measured by reading, not reproduced. Carrier: none. - **Two objectql comments now describe the old timing.** `packages/objectql/src/registry.ts` around line 2795 and line 4468 say the platform declares its built-ins "in its own `start()`". It now does so in `init()`, and `everyone` is re-declared at `kernel:ready`. The built-in carve-out they explain is still load-bearing for that re-declaration. Not edited here: outside the claimed file surface. Carrier: none. - **The curated capabilities are still declared in `start()`.** No ceremony step resolves a capability from the catalog: `authored-conversion` reports `sys_capability` rows without a catalog read. So this card does not need them. - **#23359** (the apply without the security plugin composed) is not changed by this PR. Without the plugin, nothing declares the built-ins either, so on a 17.x database the ceremony still stops at `authored-conversion` on `everyone`, with the "Declare the position (or remove the bindings)" remedy. That is the same cause and remedy shape #23359 names for the shipped sets. #23359 remains open. --- _Generated by [Claude Code](https://claude.ai/code/session_015sSBzUJfLxTo3ZZ68LLfCG)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2d2e3ce commit 0d784da

9 files changed

Lines changed: 444 additions & 75 deletions
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
'@objectstack/cli': patch
4+
---
5+
6+
fix(cli,plugin-security): `os migrate organization-ownership --apply` resolves the built-in positions and the `everyone` baseline, so a 17.x database's seeded `everyone` binding no longer blocks the ceremony
7+
8+
Clause-②: no
9+
10+
- **What was wrong.** The ceremony's boot composes the security plugin for its declarations only: `init()` runs, and `start()` and every lifecycle hook do not. The plugin declared the six built-in positions (`platform_admin`, `org_owner`, `org_admin`, `org_member`, `everyone`, `guest`) in `start()`, and the `everyone` baseline at `kernel:ready`. So the ceremony's catalog held neither. Every 17.x environment database carries a seeded binding from `everyone` to `member_default`. On every such database, `authored-conversion` stopped with "Binding rows give position everyone the sets member_default, and the catalog holds no position of that name to carry them". With `OS_MULTI_ORG_ENABLED=true`, the rewrite gate stopped on the same binding ("deleting them would lose a grant").
11+
- **What changed.** `SecurityPlugin.init()` now declares the six built-in positions. It also declares `everyone` naming the deployment baseline: the app's `isDefault` set composed with `member_default`. These are registry declarations only, and nothing is read from or written to the database. The ceremony's catalog now resolves `everyone` with `member_default`, as a started kernel does, so that binding converts as declared. A binding that names a position no catalog declares still blocks.
12+
- **The baseline in `init()` is the fail-closed reading.** The high-privilege check runs with no declared-capability context there, because the stack's declarations are still arriving while plugins initialize. So a shipped baseline set whose system permission only an app's capability declaration excuses is not named in `init()`. The `everyone` derivation at `kernel:ready` is unchanged. It runs the check against every declared capability, adds the package defaults a `manage_metadata` holder accepted, and replaces the `init()` declaration.
13+
- **What a started kernel sees.** Its catalog after boot is the same. Three things move earlier or stay put:
14+
- the six positions, and `everyone`'s baseline, are in the engine registry from the plugin's `init()` instead of from its `start()` and `kernel:ready`;
15+
- if the `kernel:ready` derivation fails, `everyone` keeps the baseline from `init()`, where it used to name no set;
16+
- the "the built-in positions … were NOT declared as position metadata" warning, for an engine that exposes no registry, now prints from `init()`.
17+
- **Unchanged.** The cold boot's refusal of an environment-held name still skips the built-in names. A stored definition under a built-in name still shadows the declaration at read. The ceremony's `authored-conversion` and rewrite steps are unchanged. The ceremony still does not read the accepted package defaults: a binding to a set that only an acceptance puts on `everyone` still blocks.
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The ownership ceremony's catalog is the one `os migrate organization-ownership
5+
* --apply` boots: the deployment's served composition, every platform provider
6+
* in it composed for its declarations only (`init()` runs; `start()` and every
7+
* hook do not). `authored-conversion` and the rewrite gate resolve the position
8+
* bindings an older release wrote against THAT catalog, so it must hold the
9+
* built-in positions and the `everyone` baseline a started kernel serves.
10+
*
11+
* ## The fixture
12+
*
13+
* The catalog: the apply command's boot (`bootSchemaStack` with its four
14+
* composition flags) over a compiled artifact, with an auth secret set so the
15+
* security plugin is composed, read through core's security catalog reader as
16+
* the command reads it. The database: the 17.x catalog rows every environment
17+
* database holds — the seeded `everyone` position, the seeded `member_default`
18+
* set and the seeded binding between them — plus the control: a seeded binding
19+
* that names a position no catalog declares.
20+
*
21+
* ## What each case measures
22+
*
23+
* The catalog holds the six built-ins and `everyone`'s baseline. Under
24+
* `single`, `authored-conversion` blocks on the control and on nothing else;
25+
* under a wall it reports, and the rewrite gate blocks on the control and on
26+
* nothing else. With the declarations made from `start()` and `kernel:ready`,
27+
* which this boot never runs, both block on `everyone` as well.
28+
*/
29+
30+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
31+
import Database from 'better-sqlite3';
32+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
33+
import { tmpdir } from 'node:os';
34+
import { join } from 'node:path';
35+
import { createSecurityCatalogReader } from '@objectstack/core';
36+
import { AUDIENCE_ANCHOR_POSITIONS, BUILTIN_IDENTITY_NAMES } from '@objectstack/spec';
37+
import { bootSchemaStack, type SchemaStack } from './schema-migrate.js';
38+
import {
39+
applyOrganizationOwnership,
40+
type CatalogPort,
41+
type CeremonyCheckpoint,
42+
type OrganizationOwnershipApplyReport,
43+
type StepReport,
44+
} from './organization-ownership-apply.js';
45+
46+
// [#10126] Pay the first load of what the boot reaches through dynamic
47+
// `import()`s at MODULE LOAD, outside the clocked hook.
48+
import '@objectstack/runtime';
49+
import '@objectstack/metadata-protocol';
50+
import '@objectstack/platform-objects/plugin';
51+
import '@objectstack/plugin-security';
52+
53+
const CASE_TIMEOUT_MS = 180_000;
54+
const ENV_KEYS = ['OS_ARTIFACT_PATH', 'OS_DATABASE_URL', 'DATABASE_URL', 'OS_DATABASE_DRIVER', 'OS_HOME', 'NODE_ENV',
55+
'OS_AUTH_SECRET', 'AUTH_SECRET', 'BETTER_AUTH_SECRET', 'OS_ENVIRONMENT_ID', 'OS_MULTI_ORG_ENABLED'] as const;
56+
const savedEnv: Record<string, string | undefined> = {};
57+
58+
let dir: string;
59+
let stack: SchemaStack;
60+
let catalog: CatalogPort;
61+
62+
beforeAll(async () => {
63+
for (const key of ENV_KEYS) savedEnv[key] = process.env[key];
64+
for (const key of ENV_KEYS) delete process.env[key];
65+
process.env.NODE_ENV = 'production';
66+
process.env.OS_AUTH_SECRET = 'organization-ownership-catalog-'.padEnd(40, 'x');
67+
dir = mkdtempSync(join(tmpdir(), 'os-ownership-catalog-'));
68+
mkdirSync(join(dir, 'dist'), { recursive: true });
69+
process.env.OS_HOME = join(dir, 'home');
70+
process.env.OS_ARTIFACT_PATH = join(dir, 'dist', 'objectstack.json');
71+
writeFileSync(process.env.OS_ARTIFACT_PATH, JSON.stringify({
72+
manifest: { id: 'com.example.ownership-catalog', name: 'ownership-catalog', version: '1.0.0', type: 'app' },
73+
}));
74+
// The apply command's boot, flag for flag.
75+
stack = await bootSchemaStack({
76+
jsonOutput: false,
77+
databaseUrl: `file:${join(dir, 'boot.db')}`,
78+
projectRoot: dir,
79+
deferSchemaDdl: true,
80+
composeHostStack: true,
81+
composeServedPlatform: true,
82+
composeAuthGatedSecurity: true,
83+
});
84+
const kernel = stack.kernel as { getService(name: string): unknown };
85+
const engine = kernel.getService('objectql') as { registry: never };
86+
catalog = createSecurityCatalogReader({ registry: engine.registry, metadata: kernel.getService('metadata') as never });
87+
}, CASE_TIMEOUT_MS);
88+
89+
afterAll(async () => {
90+
await stack?.shutdown();
91+
for (const [key, value] of Object.entries(savedEnv)) {
92+
if (value === undefined) delete process.env[key];
93+
else process.env[key] = value;
94+
}
95+
rmSync(dir, { recursive: true, force: true });
96+
});
97+
98+
/** The 17.x catalog rows, and the control binding to a position no catalog declares. */
99+
function v17Database(name: string): Database.Database {
100+
const db = new Database(join(dir, `${name}.db`));
101+
db.exec(`
102+
CREATE TABLE sys_position (id TEXT PRIMARY KEY, organization_id TEXT, name TEXT, label TEXT, managed_by TEXT,
103+
delegatable INTEGER, active INTEGER);
104+
INSERT INTO sys_position VALUES
105+
('pos_everyone', NULL, 'everyone', 'Everyone', 'platform', 0, 1),
106+
('pos_gone', NULL, 'zz_undeclared', 'Undeclared', 'platform', 0, 1);
107+
CREATE TABLE sys_permission_set (id TEXT PRIMARY KEY, organization_id TEXT, name TEXT, label TEXT, managed_by TEXT,
108+
active INTEGER, object_permissions TEXT, system_permissions TEXT);
109+
INSERT INTO sys_permission_set VALUES ('ps_member_default', NULL, 'member_default', 'Member', 'platform', 1, '{}', '[]');
110+
CREATE TABLE sys_position_permission_set (id TEXT PRIMARY KEY, organization_id TEXT, position_id TEXT,
111+
permission_set_id TEXT);
112+
INSERT INTO sys_position_permission_set VALUES
113+
('bind_everyone', NULL, 'pos_everyone', 'ps_member_default'),
114+
('bind_gone', NULL, 'pos_gone', 'ps_member_default');
115+
`);
116+
return db;
117+
}
118+
119+
async function ceremony(posture: string, db: Database.Database): Promise<OrganizationOwnershipApplyReport> {
120+
let checkpoint: CeremonyCheckpoint | null = null;
121+
return applyOrganizationOwnership({
122+
executor: {
123+
client: 'better-sqlite3',
124+
query: async (sql, params = []) => db.prepare(sql).all(...(params as unknown[])) as Record<string, unknown>[],
125+
execute: async (sql, params = []) => { db.prepare(sql).run(...(params as unknown[])); },
126+
},
127+
posture,
128+
database: 'v17',
129+
catalog,
130+
door: { save: async (type, name) => { throw new Error(`the door was asked to save ${type} ${name}`); } },
131+
templates: { find: async () => undefined },
132+
columnDrops: { drop: async (tables) => tables.map((table) => ({ table, outcome: 'not-proposed' as const })) },
133+
ledger: { read: async () => checkpoint, write: async (cp) => { checkpoint = cp; } },
134+
});
135+
}
136+
137+
const step = (report: OrganizationOwnershipApplyReport, name: StepReport['step']): StepReport =>
138+
report.steps.find((s) => s.step === name)!;
139+
140+
describe('the ownership ceremony resolves bindings against what a started kernel serves', () => {
141+
it('the apply boot\'s catalog holds the six built-in positions, and everyone names member_default', async () => {
142+
for (const name of [...BUILTIN_IDENTITY_NAMES, ...AUDIENCE_ANCHOR_POSITIONS]) {
143+
expect((await catalog.resolve('position', name))?.definition.name, name).toBe(name);
144+
}
145+
expect((await catalog.resolve('position', 'everyone'))?.definition.permissionSets).toContain('member_default');
146+
});
147+
148+
it('single: authored-conversion blocks on the undeclared position, and not on everyone', async () => {
149+
const report = await ceremony('single', v17Database('single'));
150+
const conversion = step(report, 'authored-conversion');
151+
expect(conversion.status).toBe('blocked');
152+
expect(conversion.blockers).toHaveLength(1);
153+
expect(conversion.blockers[0]).toContain('position zz_undeclared');
154+
expect(conversion.blockers.join('\n')).not.toContain('everyone');
155+
}, CASE_TIMEOUT_MS);
156+
157+
it('walled: the rewrite gate blocks on the undeclared position, and not on everyone', async () => {
158+
const report = await ceremony('isolated', v17Database('walled'));
159+
expect(step(report, 'authored-conversion').status).toBe('done');
160+
const rewrite = step(report, 'rewrite');
161+
expect(rewrite.status).toBe('blocked');
162+
expect(rewrite.blockers).toHaveLength(1);
163+
expect(rewrite.blockers[0]).toContain('position zz_undeclared');
164+
expect(rewrite.blockers.join('\n')).not.toContain('everyone');
165+
}, CASE_TIMEOUT_MS);
166+
});
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [ADR-0131 D2, ADR-0090 D5] The six built-in positions, and the `everyone`
5+
* anchor's deployment baseline, are declared in `init()` — the kernel
6+
* contract's declaration phase.
7+
*
8+
* A boot that composes this plugin for its declarations only runs `init()` and
9+
* no `start()` and no hook; `os migrate organization-ownership --apply`
10+
* resolves the position bindings an older release wrote against that boot's
11+
* catalog. Each case here runs `init()` ALONE, over a real engine registry, and
12+
* reads through core's security catalog reader — the read the ceremony and the
13+
* authorization resolver both make.
14+
*/
15+
16+
import { describe, expect, it, vi } from 'vitest';
17+
import { ObjectQL } from '@objectstack/objectql';
18+
import { createSecurityCatalogReader } from '@objectstack/core';
19+
import { AUDIENCE_ANCHOR_POSITIONS, BUILTIN_IDENTITY_NAMES, EVERYONE_POSITION } from '@objectstack/spec';
20+
import type { PermissionSet } from '@objectstack/spec/security';
21+
import { SecurityPlugin } from './security-plugin.js';
22+
import { SECURITY_PLUGIN_ID } from './manifest.js';
23+
24+
const NOT_DECLARED = 'were NOT declared as position metadata';
25+
const WITHHELD = 'the everyone anchor does not name a baseline set';
26+
27+
/** `init()` alone, with the engine registry the plugin declares to. */
28+
async function initOnly(plugin: SecurityPlugin, engine: unknown) {
29+
const hook = vi.fn();
30+
const ctx: any = {
31+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
32+
registerService: vi.fn(),
33+
getService: (name: string) => {
34+
if (name === 'manifest') return { register: vi.fn() };
35+
if (name === 'objectql' && engine !== undefined) return engine;
36+
throw new Error(`service not registered: ${name}`);
37+
},
38+
hook,
39+
};
40+
await plugin.init(ctx);
41+
const warned = (text: string) => ctx.logger.warn.mock.calls.filter((c: unknown[]) => String(c[0]).includes(text)).length;
42+
return { ctx, warned };
43+
}
44+
45+
const catalogOf = (engine: ObjectQL) =>
46+
createSecurityCatalogReader({
47+
registry: (engine as any).registry,
48+
metadata: { get: async () => undefined, list: async () => [] },
49+
});
50+
51+
describe('the built-in positions and the everyone baseline, after init() alone', () => {
52+
it('resolves all six built-in positions, owned by this plugin', async () => {
53+
const engine = new ObjectQL();
54+
const { warned } = await initOnly(new SecurityPlugin(), engine);
55+
const catalog = catalogOf(engine);
56+
for (const name of [...BUILTIN_IDENTITY_NAMES, ...AUDIENCE_ANCHOR_POSITIONS]) {
57+
const entry = await catalog.resolve('position', name);
58+
expect(entry?.definition.name, name).toBe(name);
59+
expect(entry?.packageId, name).toBe(SECURITY_PLUGIN_ID);
60+
}
61+
expect(warned(NOT_DECLARED)).toBe(0);
62+
});
63+
64+
it('names the deployment baseline on everyone: the app’s default set, then member_default', async () => {
65+
const engine = new ObjectQL();
66+
await initOnly(new SecurityPlugin({ fallbackPermissionSet: 'field_default' }), engine);
67+
const everyone = await catalogOf(engine).resolve('position', EVERYONE_POSITION);
68+
expect(everyone?.definition.permissionSets).toEqual(['field_default', 'member_default']);
69+
});
70+
71+
// The declared-capability context is the stack's, and the stack is still
72+
// declaring while plugins initialize: `init()` takes the fail-closed reading,
73+
// so a token only a declaration excuses is not named yet — and nothing is
74+
// reported, because `kernel:ready` derives again with every declaration in
75+
// (security-plugin.test.ts pins that the excused set is named there).
76+
it('takes the fail-closed reading: a set whose token only a declaration excuses is not named, and nothing is said', async () => {
77+
const engine = new ObjectQL();
78+
(engine as any).registry.registerItem('capability', { name: 'crm.export_pipeline', label: 'Export' }, 'name', 'com.example.app');
79+
const appDefault = {
80+
name: 'app_member_default',
81+
label: 'App Member Default',
82+
isDefault: true,
83+
systemPermissions: ['crm.export_pipeline'],
84+
objects: {},
85+
} as unknown as PermissionSet;
86+
const { warned } = await initOnly(new SecurityPlugin({ defaultPermissionSets: [appDefault] }), engine);
87+
const everyone = await catalogOf(engine).resolve('position', EVERYONE_POSITION);
88+
expect(everyone?.definition.permissionSets).toEqual(['member_default']);
89+
expect(warned(WITHHELD)).toBe(0);
90+
});
91+
92+
it('says so when the engine exposes no registry that can register them', async () => {
93+
const { warned } = await initOnly(new SecurityPlugin(), { registerMiddleware: () => {} });
94+
expect(warned(NOT_DECLARED)).toBe(1);
95+
});
96+
97+
it('declares nothing and says nothing when no engine is registered: start() reports that', async () => {
98+
const { warned } = await initOnly(new SecurityPlugin(), undefined);
99+
expect(warned(NOT_DECLARED)).toBe(0);
100+
});
101+
});

‎packages/plugins/plugin-security/src/builtin-positions.ts‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
* ## One list, one home
99
*
1010
* {@link securityBuiltinPositions} is the only place the six are listed.
11-
* `SecurityPlugin.start` declares them to the engine registry
11+
* `SecurityPlugin.init` declares them to the engine registry
1212
* ({@link registerBuiltinPositions}), under this plugin's package id, so the
1313
* security catalog read (`createSecurityCatalogReader`, `@objectstack/core`)
1414
* and the metadata door (`GET /api/v1/meta/position`) list them beside every
@@ -51,9 +51,12 @@
5151
* [ADR-0090 D5, ADR-0131 D3/D4] The `everyone` anchor distributes the
5252
* deployment's baseline sets — the app's `isDefault` set composed with the
5353
* platform's `member_default` — in its definition's `permissionSets`, which
54-
* the authorization resolver reads. The plugin declares it at `kernel:ready`
55-
* ({@link declareEveryoneBaseline}), once the stack's declared capabilities
56-
* the high-privilege check reads are in; no other built-in names a set.
54+
* the authorization resolver reads. The plugin declares it
55+
* ({@link declareEveryoneBaseline}) in `init()` with the fail-closed reading of
56+
* the high-privilege check, so a boot that composes the plugin for its
57+
* declarations only holds it too, and again at `kernel:ready`, once the stack's
58+
* declared capabilities that check reads are in, with the accepted package
59+
* defaults; no other built-in names a set.
5760
*/
5861

5962
import {

0 commit comments

Comments
 (0)