Skip to content

Commit c6fe02d

Browse files
feat(plugin-auth, plugin-security): createIdentityObjectsPlugin() preset; SecurityPlugin refuses at boot a kernel without sys_user / sys_member (#22173)
Fixes #22074 Clause-②: yes (narrowing) ## What changed 1. **plugin-auth exports the identity-object registration as a plugin it composes itself.** `createIdentityObjectsPlugin()` registers `authIdentityManifest()` (new, `packages/plugins/plugin-auth/src/manifest.ts:147`): the manifest header, `authIdentityObjects` and `authObjectExtensions`. `AuthPlugin` spreads the same builder into the one manifest it registers (`auth-plugin.ts:656`), so a reduced kernel and a full kernel register one list under one package id, and nothing is registered twice on a full kernel. 2. **plugin-security declares what its authorization store reads and refuses at boot without it.** `security-plugin.ts:1088` declares `sys_user` and `sys_member`; a `kernel:ready` handler subscribed at the head of `start()` (`:1654`) asks the engine registry for both and throws `AuthzIdentityObjectsMissingError` naming the missing objects and the plugin that registers them. plugin-security registers neither object. ## Public surface, for the at-tier contract review - **New exports on `@objectstack/plugin-auth`** (through `export *` of the new module `src/identity-objects-plugin.ts`): `createIdentityObjectsPlugin(options?)`, `IdentityObjectsPlugin`, `IdentityObjectsPluginOptions` (`manifestDatasource`, same meaning as on `AuthPluginOptions`), `IDENTITY_OBJECTS_PLUGIN_NAME` (`com.objectstack.auth.identity-objects`). `authIdentityManifest` lives in `manifest.ts`, which the package index does not export. - **The boot refusal, and whom it newly refuses.** A kernel where `SecurityPlugin.start()` ran and the engine registry does not hold `sys_user` or `sys_member` at `kernel:ready`: `bootstrap()` now rejects. Before, it booted and every authenticated permission read failed with `AuthzStoreUnavailableError` (`SERVICE_UNAVAILABLE`, 503). Newly refused, measured: the three in-repo harnesses listed under Cross-lane files. Newly refused, by code reading only (not run): `DevPlugin` with `services: { auth: false }` and security left on, because `DevPlugin.start()` hands the real context to `SecurityPlugin.start()`. - **Not refused.** Any kernel mounting `AuthPlugin` (`os serve` pairs the two; `@objectstack/verify`'s `bootStack` mounts `AuthPlugin`); a kernel registering the two objects any other way (the gate asks the registry, not who registered); a declarations-only boot (`os migrate` composes host plugins with `start()` suppressed). The last is measured: `packages/cli/src/utils/schema-migrate.host-composition.integration.test.ts`, which composes `SecurityPlugin` without `AuthPlugin`, stays green on this tree. - **A second, smaller refusal.** `IdentityObjectsPlugin.init()` refuses a kernel that also mounts `AuthPlugin` (both register the same objects under one package id, and, read from the registry code rather than run, the later registration replaces the package record). `optionalDependencies: ['com.objectstack.auth']` orders `AuthPlugin` ahead, so its `auth` service is registered when `init()` asks. ## Measure first: `@objectstack/verify` Measured, read only: `bootStack` in `packages/verify/src/harness.ts` mounts `AuthPlugin` unconditionally (`kernel.use(new AuthPlugin(...))`, around `:569`), so its in-process handle already registers the identity objects. It is the sanctioned kit for an app suite; the new module's header says so. The export lands for kits that compose their own kernel (hotcrm's nine harnesses are that shape). `packages/verify` is not edited. ## Mechanism hypotheses - **H1, confirmed.** `authIdentityObjects` in `manifest.ts` is the single list, registered through the `manifest` service under `AUTH_PLUGIN_ID`. Both paths now read it through `authIdentityManifest()`. Pinned: `AuthPlugin.init()` registers exactly one manifest whose `objects` is the same array (`toBe`) and whose identity half equals `authIdentityManifest()`, pages and dashboards still present; a real `AuthPlugin` + `SecurityPlugin` kernel registers plugin-auth's list once under `com.objectstack.plugin-auth`. - **H2, measured: `sys_user` and `sys_member`, not `sys_member` alone.** `resolveUserAuthzGrants` (`packages/core/src/security/resolve-authz-context.ts:757`, `:794`, `:796`) reads `sys_user` (whenever the caller seeded no email, no `ai_seat`, or a platform-admin list is configured) and `sys_member`, plus five objects plugin-security registers itself (`sys_user_position`, `sys_user_permission_set`, `sys_position`, `sys_position_permission_set`, `sys_permission_set`). plugin-security's own boot passes also read `sys_account` and `sys_organization`, but through local read helpers that degrade with a warn; they are not the authz store and are not declared. Ablation reading below: with the gate removed, the boot passes and the first `resolveUserAuthzGrants` call fails with `AuthzStoreUnavailableError`, code `SERVICE_UNAVAILABLE`, status 503, object `sys_member`. - **H3, measured: no kernel mechanism declares objects.** `dependencies` / `optionalDependencies` / `requiresServices` name plugins and services (`packages/core/src/plugin-order.ts`). The existing mechanism used is the kernel's documented boot-gate moment: a `kernel:ready` handler that throws fails `bootstrap()` on both kernels (`packages/core/src/kernel-base.ts`, `lite-kernel.ts`). The check stays inside plugin-security. It is subscribed in `start()`, not `init()`: `os migrate` composes host plugins for `init()` only and fires `kernel:ready` anyway, so an `init()` subscription would refuse a schema command that never reads the store. - **H4, measured: three harnesses outside the two packages.** Candidates: every test file outside plugin-auth / plugin-security that imports `@objectstack/plugin-security` without going through `bootStack` or `AuthPlugin` (runtime 7 files, service-automation 2, rest 9, organizations 1, cli 3, dogfood 1, plugin-dev's whole suite), run against the built change. Red: `packages/runtime/src/standalone-stack-seeder-declaration-copy.test.ts` (1 file), `packages/services/service-automation/src/builtin/write-nodes-stored-metadata-family-refusal.integration.test.ts` (1 case) and `.../runas-system-stamping.integration.test.ts` (2 cases), all with `AuthzIdentityObjectsMissingError`. Inside plugin-security, three test files whose engine doubles fire every `kernel:ready` handler (14 cases) now answer the two names from `getSchema`. - **H5, decided: package id `com.objectstack.plugin-auth`, kernel plugin name `com.objectstack.auth.identity-objects`, provenance `@objectstack/plugin-auth`.** The registry records one owning package per object and refuses a second package claiming it (ADR-0029 D3, `ObjectOwnershipConflictError`), so the manifest id decides the objects' provenance; a different id would make `sys_user`'s owner depend on which plugin a kernel mounts. The kernel plugin name differs from `AuthPlugin`'s only because the kernel keys plugins by name. The export ships from plugin-auth, the package that owns the list, rather than as a copy built from `@objectstack/platform-objects/identity` imports. ## Cross-lane files (outside plugin-auth / plugin-security), for the seat to declare - `packages/runtime/src/standalone-stack-seeder-declaration-copy.test.ts`: mounts `createIdentityObjectsPlugin()` before `SecurityPlugin` (runtime already depends on plugin-auth). - `packages/services/service-automation/src/builtin/write-nodes-stored-metadata-family-refusal.integration.test.ts` and `packages/services/service-automation/src/runas-system-stamping.integration.test.ts`: the same mount. - `packages/services/service-automation/package.json`: `@objectstack/plugin-auth` as a devDependency (no workspace cycle: plugin-auth's closure does not contain service-automation), with `pnpm-lock.yaml` (+3 lines). - `packages/services/service-automation/vitest.config.ts`: an anchored alias of `@objectstack/plugin-auth` to source, because `check:test-source-alias` refuses a new unaliased artifact import (its ledger is shrink-only). ## Tests (HEAD `90bb6549a8`) - `packages/plugins/plugin-security/src/authz-identity-objects-boot-refusal.test.ts` (3 cases, real kernel, ObjectQL, SQLite): refused with both names; only the missing name; boots when the registry holds both, registered by a test package. - `packages/plugins/plugin-auth/src/identity-objects-plugin.test.ts` (9 cases): the card's four pins, plus a control (without the preset the `sys_user` insert is refused `OBJECT_NOT_FOUND` / 404), the datasource override on both paths, the ordering edge, and the co-mount refusal. The app in pin 1 registers its manifest through the `manifest` service, as `AppPlugin.init()` does; `AppPlugin` itself lives in `@objectstack/runtime`, which depends on plugin-auth, so this suite cannot import it without a workspace cycle. - Full suites, at `1a507892` (the one later commit, `90bb6549a8`, only re-spells `IdentityObjectsPlugin.optionalDependencies` as a literal; its pin file was re-run there, 9 passed): plugin-security 173 files, 3666 passed, 45 skipped; plugin-auth 127 files, 2633 passed, 10 skipped; service-automation 175 files, 2120 passed. Runtime: the edited file, 13 passed. - Typecheck: `pnpm --filter` `typecheck` for plugin-auth, plugin-security, service-automation and runtime, all exit 0 (test layers OK, debt ledgers unchanged). ## Ablation of the boot-refusal pin Committed first; `node scripts/ablation-replace.mjs` replaced the one `kernel:ready` subscription line in `security-plugin.ts` (anchor x1 to x0, marker x0 to x1, blob `b90b3e0f` to `e8696819`). The suites resolve plugin-security from source (relative import, and plugin-auth's vitest alias), so no build sat between edit and run. Mutated: the boot-refusal file went red 2 of 3 (the "boots when both are registered" case stays green, as it should), and plugin-auth's pin 3 went red 1 of 9. The ablated boot passed and the first permission read answered `AuthzStoreUnavailableError` / `SERVICE_UNAVAILABLE` / 503 / `sys_member` (a scratch probe, deleted after). Restore: blob back to `b90b3e0f`, equal to HEAD, `git diff HEAD` empty. ## Gates (HEAD `90bb6549a8`) `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 80 commands; all 80 ran and exited 0, reconciled with `--ran` (80 derived, 80 run, 0 NOT MEASURED, every line carrying its exit code). Run in addition because the diff touches plugin lifecycle code: `check:startup-registry-verdict`, `check:init-service-contract` (it caught an `optionalDependencies` spelled through a constant, now a literal), `check:durability-log-level`, all exit 0. The derivation names no `check:api-surface` family for these packages (that gate covers `packages/spec`); `check:dts-closure` and `check:published-files` are green. ESLint on the 14 changed TypeScript files: 0 errors, 0 warnings (population from `eslint.config.mjs`, which enables no type-aware linting, so untouched files' verdicts cannot move). ## Notes - The branch sits on `033e5c53`; `origin/main` has since gained four commits, none touching a file in this diff. Not merged here; CI's merge ref carries the joint check. - No `packages/spec`, `packages/platform-objects` or `packages/verify` edit. ## Acceptance notes - A test kit loads `createIdentityObjectsPlugin()` through plugin-auth's root entry, which also evaluates better-auth. A light subpath entry would need a `package.json` `exports` and tsup entry edit, outside this card's fence. Carrier: none. - `DevPlugin` with `services: { auth: false }` and security on is now refused at boot (by code reading). `DevPlugin` could mount `createIdentityObjectsPlugin()` itself in that branch; that is a plugin-dev change, outside this card. Carrier: none. --- _Generated by [Claude Code](https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 29678f2 commit c6fe02d

18 files changed

Lines changed: 664 additions & 26 deletions
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/plugin-auth': minor
3+
---
4+
5+
`createIdentityObjectsPlugin()` registers plugin-auth's identity objects (`sys_user`, `sys_member`, `sys_organization` and the rest of the list `AuthPlugin` registers) on a kernel that does not mount `AuthPlugin`, such as an app's or a plugin's own test kit.
6+
7+
Clause-②: yes (widening)
8+
9+
- **What is new.** `createIdentityObjectsPlugin(options?)`, the `IdentityObjectsPlugin` class it returns, `IdentityObjectsPluginOptions` (`manifestDatasource`, with the meaning `AuthPluginOptions.manifestDatasource` has) and `IDENTITY_OBJECTS_PLUGIN_NAME` (`com.objectstack.auth.identity-objects`, its kernel plugin name).
10+
- **One list.** It registers the identity half of plugin-auth's manifest: the header, the objects and the field plugin-auth adds to `sys_sso_provider`. `AuthPlugin` spreads the same builder into the one manifest it registers, so a reduced kernel and a full one register the same objects, and a kit no longer copies plugin-auth's object list or manifest id.
11+
- **Same owner.** The objects register under plugin-auth's package id, `com.objectstack.plugin-auth`, as `AuthPlugin` registers them. The registry records one owning package per object, so a different id would make the owner of `sys_user` depend on which plugin a kernel mounts.
12+
- **No authentication.** It registers objects only: no sessions, no routes, no `auth` service.
13+
- **Not beside `AuthPlugin`.** `AuthPlugin` registers the same objects itself. A kernel that mounts both is refused at boot, by the identity plugin's `init()`, with an error naming both.
14+
- **Usage.** `await kernel.use(createIdentityObjectsPlugin())` after `ObjectQLPlugin`. A suite that boots through `@objectstack/verify` already gets these objects: its `bootStack` mounts `AuthPlugin`.
15+
- **Unchanged.** `AuthPlugin` registers the same manifest it registered before, under the same id.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/plugin-security': minor
3+
---
4+
5+
`SecurityPlugin` declares the identity objects its authorization store reads, `sys_user` and `sys_member`, and refuses to boot a kernel that does not register them. The refusal names the missing objects and the plugin that registers them, where the kernel used to boot and then fail every authenticated request's permission read as an outage.
6+
7+
Clause-②: yes (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) A boot refusal of a plugin composition, not a metadata change: no spec key, export, option or stored shape is removed, renamed or re-shaped, so there is no tombstone and nothing for `objectstack migrate meta` to rewrite. The remedy is a kernel composition edit (mount plugin-auth's identity objects), which no ledger entry can derive from metadata. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this boot path and this diff adds none (not already-registered); and what narrows is which kernels boot, not a runtime interface or a type surface (not runtime-interface-only / type-surface-only). -->
10+
11+
**BREAKING**: a kernel that booted before is refused at boot, shipped as `minor` under the launch-window convention.
12+
13+
**Why.** Permission resolution, `resolveUserAuthzGrants` in `@objectstack/core`, reads `sys_user` and `sys_member`, which `@objectstack/plugin-auth` registers. The engine refuses a read of an object its registry does not hold, so a kernel with `SecurityPlugin` and without those objects booted cleanly, and then every authenticated request's permission read failed with `AuthzStoreUnavailableError` (`SERVICE_UNAVAILABLE`, 503), which reads as an outage and names no missing dependency.
14+
15+
**What is refused.** A kernel where `SecurityPlugin.start()` ran and the engine registry does not hold `sys_user` or `sys_member` at `kernel:ready`: `bootstrap()` rejects with an error named `AuthzIdentityObjectsMissingError`, whose `missingObjects` lists the absent names and whose message names `@objectstack/plugin-auth`, `AuthPlugin` and `createIdentityObjectsPlugin()`. Measured in this repository: three test harnesses that boot `SecurityPlugin` without `AuthPlugin` (one in `@objectstack/runtime`, two in `@objectstack/service-automation`), each now mounting `createIdentityObjectsPlugin()`. By reading the code, not by a run: `objectstack dev` through `DevPlugin` with `services: { auth: false }` and security left on is refused too.
16+
17+
**What still boots, unchanged.** A kernel that mounts `AuthPlugin` (`os serve` pairs the two; `@objectstack/verify`'s `bootStack` mounts both); a kernel that registers the two objects any other way; and a boot that composes `SecurityPlugin` for its declarations only (`os migrate`, which suppresses `start()`).
18+
19+
**The remedy.** on a kernel that mounts `SecurityPlugin` without `AuthPlugin`, such as a test kit, add `await kernel.use(createIdentityObjectsPlugin())` from `@objectstack/plugin-auth`. It registers plugin-auth's own identity list, so a hand-written plugin that registers `SysUser` and `SysMember` is no longer needed, though it still satisfies the check. A `DevPlugin` stack with `services: { auth: false }` either turns security off as well or passes `createIdentityObjectsPlugin()` in `extraPlugins`. Nothing in an app's metadata changes.

‎packages/plugins/plugin-auth/src/auth-plugin.ts‎

Lines changed: 16 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -69,11 +69,7 @@ import { runSetInitialPassword } from './set-initial-password.js';
6969
import { runRegisterSsoProviderFromForm, runRegisterSamlProviderFromForm, runRequestDomainVerification, runVerifyDomain } from './register-sso-provider.js';
7070
import { runResendVerificationEmail } from './send-verification-email.js';
7171
import type { CounterStore } from './rate-limit-storage.js';
72-
import {
73-
authIdentityObjects,
74-
authObjectExtensions,
75-
authPluginManifestHeader,
76-
} from './manifest.js';
72+
import { authIdentityManifest } from './manifest.js';
7773
import { scheduleLegacySsoSecretMigration } from './sso-client-secret.js';
7874
import {
7975
devSeedAdminEmail,
@@ -643,22 +639,21 @@ export class AuthPlugin implements Plugin {
643639
this.tenancy = tenancy;
644640

645641
ctx.getService<{ register(m: any): void }>('manifest').register({
646-
...authPluginManifestHeader,
647-
...(this.options.manifestDatasource
648-
? { defaultDatasource: this.options.manifestDatasource }
649-
: {}),
650-
// [ADR-0108 / #3723] Registered as authored: nothing widens the
651-
// `sys_invitation.role` / `sys_member.role` selects at boot. The closed
652-
// four-name vocabulary those objects declare statically
653-
// (`BUILTIN_MEMBERSHIP_ROLE_OPTIONS`) is the whole list, and it is the
654-
// write-side guardrail that keeps an ungoverned capability grant
655-
// unrepresentable.
656-
objects: authIdentityObjects,
657-
// [#8009] `sys_sso_provider.oidc_client_secret` — the encrypted home of the
658-
// OIDC client secret that used to sit in cleartext inside `oidc_config`.
659-
// See `manifest.ts` for why the field is declared here and not on the
660-
// object file.
661-
objectExtensions: authObjectExtensions,
642+
// The header, `objects` and `objectExtensions`, from the ONE builder
643+
// `IdentityObjectsPlugin` registers on its own in a reduced kernel, so the
644+
// two paths cannot carry different lists. In it:
645+
// - [ADR-0108 / #3723] `objects` registered as authored: nothing widens
646+
// the `sys_invitation.role` / `sys_member.role` selects at boot. The
647+
// closed four-name vocabulary those objects declare statically
648+
// (`BUILTIN_MEMBERSHIP_ROLE_OPTIONS`) is the whole list, and it is the
649+
// write-side guardrail that keeps an ungoverned capability grant
650+
// unrepresentable.
651+
// - [#8009] `objectExtensions` carries
652+
// `sys_sso_provider.oidc_client_secret`, the encrypted home of the OIDC
653+
// client secret that used to sit in cleartext inside `oidc_config`. See
654+
// `manifest.ts` for why the field is declared there and not on the
655+
// object file.
656+
...authIdentityManifest({ datasource: this.options.manifestDatasource }),
662657
// ADR-0048 — Setup/Studio/Account apps (and the Setup nav contributions)
663658
// moved to their own one-app packages (@objectstack/{setup,studio,account}),
664659
// each registering under its own package id so /apps/<packageId> resolves
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `createIdentityObjectsPlugin()`: plugin-auth's identity objects for a kernel
5+
* that mounts ObjectQL without `AuthPlugin`, the reduced kernel an app's or a
6+
* plugin's own test suite boots.
7+
*
8+
* Pins:
9+
* 1. ObjectQL + an app + this plugin: a `sys_user` fixture inserts, and the
10+
* registered set is plugin-auth's list under plugin-auth's package id.
11+
* Without the plugin the same insert is refused `OBJECT_NOT_FOUND` (control).
12+
* 2. Adding `SecurityPlugin`: the kernel boots and permissions resolve from
13+
* `sys_member` through `@objectstack/core`'s `resolveUserAuthzGrants`.
14+
* 3. `SecurityPlugin` without the identity objects is refused at boot by name
15+
* (`plugin-security`'s own suite pins the refusal; here, the remedy it names
16+
* is this export).
17+
* 4. The full kernel is unchanged: `AuthPlugin` registers the same identity
18+
* manifest, from the same builder, in its one registration.
19+
*
20+
* "An app" is a plugin that registers its manifest through the `manifest`
21+
* service in `init()`, which is what `AppPlugin.init()` does. `AppPlugin` itself
22+
* lives in `@objectstack/runtime`, which depends on this package, so this suite
23+
* cannot import it without a workspace cycle.
24+
*/
25+
26+
import { afterEach, describe, expect, it, vi } from 'vitest';
27+
import {
28+
ObjectKernel,
29+
resolvePluginOrder,
30+
resolveUserAuthzGrants,
31+
type OrderablePlugin,
32+
type Plugin,
33+
type PluginContext,
34+
} from '@objectstack/core';
35+
import { ObjectQLPlugin, type ObjectQL } from '@objectstack/objectql';
36+
import { SqlDriver } from '@objectstack/driver-sql';
37+
import { SecurityPlugin } from '@objectstack/plugin-security';
38+
import { Field } from '@objectstack/spec/data';
39+
import {
40+
IDENTITY_OBJECTS_PLUGIN_NAME,
41+
IdentityObjectsPlugin,
42+
createIdentityObjectsPlugin,
43+
} from './identity-objects-plugin.js';
44+
import { AUTH_PLUGIN_ID, authIdentityManifest, authIdentityObjects, authObjectExtensions } from './manifest.js';
45+
import { AuthPlugin } from './auth-plugin.js';
46+
47+
const SYS = { isSystem: true } as const;
48+
49+
/** Publishes an in-memory SQLite driver the way a datasource plugin does. */
50+
function sqliteDriverPlugin(): Plugin {
51+
return {
52+
name: 'test.driver.sqlite',
53+
type: 'standard',
54+
version: '1.0.0',
55+
async init(ctx: PluginContext) {
56+
ctx.registerService(
57+
'driver.default',
58+
new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }),
59+
);
60+
},
61+
};
62+
}
63+
64+
/** An app registering its manifest through the `manifest` service, as `AppPlugin.init()` does. */
65+
function appPlugin(): Plugin {
66+
return {
67+
name: 'com.example.kit-app',
68+
type: 'app',
69+
version: '1.0.0',
70+
dependencies: ['com.objectstack.engine.objectql'],
71+
async init(ctx: PluginContext) {
72+
ctx.getService<{ register(m: unknown): unknown }>('manifest').register({
73+
id: 'com.example.kit-app',
74+
version: '1.0.0',
75+
type: 'app',
76+
name: 'Kit App',
77+
objects: [{ name: 'kit_account', label: 'Account', fields: { name: Field.text({ label: 'Name' }) } }],
78+
});
79+
},
80+
};
81+
}
82+
83+
describe('createIdentityObjectsPlugin() — identity objects for a kernel without AuthPlugin', () => {
84+
let kernel: ObjectKernel | undefined;
85+
86+
afterEach(async () => {
87+
try {
88+
await kernel?.shutdown();
89+
} catch {
90+
/* a refused boot leaves the kernel stopped */
91+
}
92+
kernel = undefined;
93+
});
94+
95+
async function boot(plugins: Plugin[]): Promise<ObjectQL> {
96+
kernel = new ObjectKernel({ logger: { level: 'silent' } });
97+
await kernel.use(sqliteDriverPlugin());
98+
await kernel.use(new ObjectQLPlugin());
99+
await kernel.use(appPlugin());
100+
for (const p of plugins) await kernel.use(p);
101+
await kernel.bootstrap();
102+
return kernel.getService<ObjectQL>('objectql');
103+
}
104+
105+
it('1. registers plugin-auth\'s list under plugin-auth\'s package id, and a sys_user fixture inserts', async () => {
106+
const ql = await boot([createIdentityObjectsPlugin()]);
107+
108+
const registered = ql.registry.getAllObjects(AUTH_PLUGIN_ID).map((o) => o.name).sort();
109+
expect(registered).toEqual(authIdentityObjects.map((o) => o.name).sort());
110+
111+
await ql.insert('sys_user', { id: 'usr_kit', name: 'Kit User', email: 'kit@example.com' }, { context: SYS });
112+
const row = await ql.findOne('sys_user', { where: { id: 'usr_kit' }, context: SYS });
113+
expect(row?.email).toBe('kit@example.com');
114+
});
115+
116+
it('1 (control). without it, the same sys_user insert is refused OBJECT_NOT_FOUND', async () => {
117+
const ql = await boot([]);
118+
119+
const refusal = await ql
120+
.insert('sys_user', { id: 'usr_kit', name: 'Kit User', email: 'kit@example.com' }, { context: SYS })
121+
.then(() => undefined, (err: unknown) => err as { code?: string; status?: number });
122+
expect(refusal).toMatchObject({ code: 'OBJECT_NOT_FOUND', status: 404 });
123+
});
124+
125+
it('2. with SecurityPlugin added, the kernel boots and permissions resolve from sys_member', async () => {
126+
const ql = await boot([createIdentityObjectsPlugin(), new SecurityPlugin()]);
127+
128+
await ql.insert('sys_user', { id: 'usr_kit', name: 'Kit User', email: 'kit@example.com' }, { context: SYS });
129+
await ql.insert('sys_organization', { id: 'org_kit', name: 'Kit Org' }, { context: SYS });
130+
await ql.insert(
131+
'sys_member',
132+
{ id: 'mem_kit', user_id: 'usr_kit', organization_id: 'org_kit', role: 'admin' },
133+
{ context: SYS },
134+
);
135+
136+
const grants = await resolveUserAuthzGrants(ql, 'usr_kit', { tenantId: 'org_kit' });
137+
expect(grants.positions).toContain('org_admin');
138+
expect(grants.accessible_org_ids).toEqual(['org_kit']);
139+
expect(grants.email).toBe('kit@example.com');
140+
});
141+
142+
it('3. SecurityPlugin without them is refused at boot, and the refusal names this export', async () => {
143+
const refusal = await boot([new SecurityPlugin()]).then(() => undefined, (err: unknown) => err as Error);
144+
145+
expect(refusal?.name).toBe('AuthzIdentityObjectsMissingError');
146+
expect(refusal?.message).toContain('createIdentityObjectsPlugin()');
147+
expect(refusal?.message).toContain('@objectstack/plugin-auth');
148+
});
149+
150+
it('4. the full kernel (AuthPlugin, no preset) boots SecurityPlugin unchanged', async () => {
151+
const ql = await boot([
152+
new AuthPlugin({ secret: 'test-secret-at-least-32-chars-long', baseUrl: 'http://localhost:3000' }),
153+
new SecurityPlugin(),
154+
]);
155+
156+
const registered = ql.registry.getAllObjects(AUTH_PLUGIN_ID).map((o) => o.name).sort();
157+
expect(registered).toEqual(authIdentityObjects.map((o) => o.name).sort());
158+
});
159+
});
160+
161+
describe('one list: AuthPlugin and IdentityObjectsPlugin register the same identity manifest', () => {
162+
/** A context whose `manifest.register` records what was registered. */
163+
function capturingContext(services: Record<string, unknown> = {}) {
164+
const registered: Array<Record<string, unknown>> = [];
165+
const ctx = {
166+
registerService: vi.fn(),
167+
getService: vi.fn((name: string) => {
168+
if (name === 'manifest') return { register: (m: Record<string, unknown>) => registered.push(m) };
169+
if (name in services) return services[name];
170+
throw new Error(`service not registered: ${name}`);
171+
}),
172+
getServices: vi.fn(() => new Map()),
173+
hook: vi.fn(),
174+
trigger: vi.fn(),
175+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
176+
getKernel: vi.fn(),
177+
} as unknown as PluginContext;
178+
return { ctx, registered };
179+
}
180+
181+
it('4. AuthPlugin\'s one registration carries the identity manifest unchanged (the full kernel)', async () => {
182+
const { ctx, registered } = capturingContext({ data: undefined });
183+
await new AuthPlugin({ secret: 'test-secret-at-least-32-chars-long', baseUrl: 'http://localhost:3000' }).init(ctx);
184+
185+
expect(registered).toHaveLength(1);
186+
const [manifest] = registered;
187+
expect(manifest).toMatchObject(authIdentityManifest());
188+
expect(manifest.objects).toBe(authIdentityObjects);
189+
expect(manifest.objectExtensions).toBe(authObjectExtensions);
190+
expect(manifest.id).toBe(AUTH_PLUGIN_ID);
191+
// The rest of AuthPlugin's manifest is still there.
192+
expect(Array.isArray(manifest.pages) && manifest.pages.length).toBeGreaterThan(0);
193+
expect(Array.isArray(manifest.dashboards) && manifest.dashboards.length).toBeGreaterThan(0);
194+
});
195+
196+
it('4. IdentityObjectsPlugin registers exactly that manifest, and honours the datasource override as AuthPlugin does', async () => {
197+
const plain = capturingContext();
198+
await createIdentityObjectsPlugin().init(plain.ctx);
199+
expect(plain.registered).toEqual([authIdentityManifest()]);
200+
expect(plain.registered[0].objects).toBe(authIdentityObjects);
201+
202+
const kit = capturingContext();
203+
await createIdentityObjectsPlugin({ manifestDatasource: 'default' }).init(kit.ctx);
204+
const full = capturingContext({ data: undefined });
205+
await new AuthPlugin({
206+
secret: 'test-secret-at-least-32-chars-long',
207+
baseUrl: 'http://localhost:3000',
208+
manifestDatasource: 'default',
209+
}).init(full.ctx);
210+
expect(kit.registered[0].defaultDatasource).toBe('default');
211+
expect(full.registered[0].defaultDatasource).toBe('default');
212+
});
213+
});
214+
215+
describe('not beside AuthPlugin', () => {
216+
it('orders AuthPlugin ahead when both are composed, whatever the insertion order', () => {
217+
const engine: OrderablePlugin = { name: 'com.objectstack.engine.objectql' };
218+
const identity = createIdentityObjectsPlugin() as unknown as OrderablePlugin;
219+
const auth = new AuthPlugin({ secret: 'test-secret-at-least-32-chars-long' }) as unknown as OrderablePlugin;
220+
const order = resolvePluginOrder(
221+
new Map([engine, identity, auth].map((p) => [p.name, p])),
222+
).map((p) => p.name);
223+
224+
expect(order.indexOf('com.objectstack.auth')).toBeLessThan(order.indexOf(IDENTITY_OBJECTS_PLUGIN_NAME));
225+
});
226+
227+
it('refuses to register when AuthPlugin has registered the auth service', async () => {
228+
const register = vi.fn();
229+
const ctx = {
230+
getService: vi.fn((name: string) => {
231+
if (name === 'auth') return {};
232+
if (name === 'manifest') return { register };
233+
throw new Error(`service not registered: ${name}`);
234+
}),
235+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
236+
} as unknown as PluginContext;
237+
238+
const refusal = await new IdentityObjectsPlugin().init(ctx).then(() => undefined, (err: unknown) => err as Error);
239+
expect(refusal?.message).toContain(IDENTITY_OBJECTS_PLUGIN_NAME);
240+
expect(refusal?.message).toContain('AuthPlugin');
241+
expect(register).not.toHaveBeenCalled();
242+
});
243+
});

0 commit comments

Comments
 (0)