Skip to content

Commit 404fc47

Browse files
feat(plugin-auth): registerOrganizationRecordPage: false leaves out sys_organization_detail for a host that ships its own default record page (#23309)
Fixes #23254 Clause-②: yes (a new declared `AuthPluginOptions` key; the default is unchanged) ## What this does Ruling A on #23015 (record `6134056653`) allows one `isDefault: true` record page per object per deployment, and keeps one page "by overlay-by-name or by composition". The cloud seat measured that overlay-by-name does not reach the organization page. This PR is the composition route. `AuthPlugin` gains one declared option, `registerOrganizationRecordPage` (default `true`). With an explicit `false`, `plugin-auth` does not register `sys_organization_detail` (`SysOrganizationDetailPage`), so a host that ships its own default record page for `sys_organization` is the single default by construction. - `packages/plugins/plugin-auth/src/auth-plugin.ts`: the option on `AuthPluginOptions`, with a docblock that states what it removes and what stays. The page list in `init()` reads it; `SysUserDetailPage` registers either way. When the page is left out, `init()` logs one `info` line naming the option ("absence must be loud"). - ⛔ No precedence ladder and ⛔ no registration-order rule. The default is unchanged. The one-default refusal from PR #23206 is untouched. - Cloud's half (setting the option, retiring its order-pinning test) is objectstack-ai/cloud#2773's, not this PR's. ## Option name and shape (H1) The option is named `registerOrganizationRecordPage`, a boolean whose `true` keeps today's behaviour. It follows `registerRoutes?: boolean` (`@default true`) on the same interface. - **It names what the runtime does, not a claim about the host.** The alternative was a host declaration in the shape of `hostSignInHandoff` (for example `hostOrganizationRecordPage: true`). That spelling would assert a fact the runtime never checks. This one says exactly what `plugin-auth` does with it, and the pins prove it, so declared is enforced. - **Only an explicit `false` removes the page.** The read is `!== false`, not a constructor default. A constructor default spread under the caller's options would let an explicit `undefined` override it and silently drop the page. - **One boolean, not a page list.** No caller needs to leave out any other page (`sys_user_detail` has one default). A general list option would be capability with no pull behind it. ## Where else the organization page is wired (H2) Measured at `6c181625f`, re-read on the merged head: | place | reads `sys_organization_detail` how | made false by the option? | |:--|:--|:--| | `packages/plugins/plugin-auth/src/manifest.ts` and `identity-objects-plugin.ts` | no page list (objects only) | no; untouched | | `packages/platform-objects/src/apps/translations/{en,zh-CN,ja-JP,es-ES}.ts` | `pages.sys_organization_detail.label`, a bundle entry looked up by page name | no. An entry for a page that is not registered is never read. The host ships its own page's translations. | | `packages/cli/scripts/check-app-nav-i18n.mjs`, `packages/cli/test/platform-page-i18n-parity.test.ts`, `packages/platform-objects/scripts/i18n-extract.config.ts`, `scripts/check-i18n-coverage.mjs` | static, over the `@objectstack/platform-objects/pages` barrel and source | no. The page still ships in the barrel, and none of them boots a host that sets the option. | | navigation / apps | none: no nav item or app names the page. Record pages are found by `object` plus `isDefault`, never by name. | nothing dangles | | objectui `7578323` | named only in `useNavigationSync` tests and a comment, as an example of a `sys_` name | no | Nothing else needed changing. ## Pins (H3): `organization-record-page-option.test.ts` The harness is a real `ObjectKernel`, a real ObjectQL engine on in-memory SQLite (the shape this package's other kernel tests boot), the real `AuthPlugin` from source, and a host plugin that registers its own `sys_organization` default record page through the `manifest` service, as `AppPlugin.init()` does. The refusal is the registry's own, reached through that door. The "single default" reading uses `@objectstack/spec/ui`'s `defaultRecordPageObject`, the same reading the refusal uses. 1. **With the option, the host's page is the single default and the boot succeeds, in either plugin order.** `sys_organization`'s defaults are exactly `[host_organization_detail of com.example.host-tenant]`, and `sys_user_detail` is unchanged. Control: pin 3, the same composition without the option, is refused. 2. **Without it, `plugin-auth`'s page registers as today.** The defaults are exactly `[sys_organization_detail of com.objectstack.plugin-auth]`. Control: the option with no host page leaves `sys_organization` with no default at all, so the reading is not vacuous. 3. **With both pages and no option, `DEFAULT_RECORD_PAGE_CONFLICT` still refuses, in either order.** The test asserts `code` + `status` (`422`), `object`, and the `incoming` / `existing` pair. Control: an explicit `true` is refused the same way. **Ablation** (`scripts/ablation-replace.mjs`, wrap mode, under `os-verify-lock`): the conditional was made to ignore the option (anchor `pages: [...(registerOrganizationRecordPage ? [SysOrganizationDetailPage] : []), SysUserDetailPage],` replaced with `pages: [SysOrganizationDetailPage, SysUserDetailPage],`). The tool reported anchor x1 to x0 and blob `72c5e5482b2a` to `8195f6d48184`. Result: **pin 1 red** (`auth first: the boot succeeds: expected DefaultRecordPageConflictError ... to be undefined`), pin 2's control red (`expected [ {...} ] to deeply equal []`), pin 3 green. 2 failed, 1 passed. Restore: blob after restore `72c5e5482b2a` equals the blob at HEAD, and `git diff HEAD` is empty. AuthPlugin is imported from source (relative), so no `dist/` leg applies. ## Verification All readings below are on a shared box: `os-verify-lock` excluded other locked runs only. - **Derived gates, on the merged head `3f7b18399`** (`origin/main` `543b33dee` merged with `scripts/pm/os-regen-merge.sh`, as a merge commit). `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 68 commands from the 4-path change set. All 68 ran with each exit code captured before any pipe: **68 exit 0**. `--ran` reconciliation: `68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN`. - Before the merge, at `859822b51`, `check:dual-build-cjs-loads` exited 3 (`PREREQUISITE NOT MET`: other packages had no `dist/`). That run is not counted. After a full `turbo run build` (74/74 tasks) it reads `108 published require entry point(s) across 67 package(s) load`. - Other readings from the merged-head run: `check-adr-0087-registration: this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen)`, `check-empty-changeset: 1 declaring changeset(s) added`, `check:type-check-debt: none above its recorded number`, `check:cross-package-test-inputs OK`. - NOT MEASURED locally: the 6 families whose argv carries a value that exists only in a CI run (`check-shard-attestation`, `check-test-completeness`, `check-issue-citations --census`), and the CI job lanes. These belong to CI. - **`@objectstack/plugin-auth` suite**, `pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2`, on `3f7b18399`: `Test Files 138 passed (138)`, `Tests 2794 passed | 10 skipped (2804)`. Before the merge, at `859822b51`, the reading was the same. - **`@objectstack/plugin-auth` typecheck** on `3f7b18399`: exit 0. `check:test-typecheck: OK`, 94 errors held in the shrink-only ledger, none new, so the new test file compiles clean. - **`pnpm --filter @objectstack/spec test:repo`** (the tree-scoped pins) at `859822b51`: `Test Files 58 passed (58)`, `Tests 1007 passed (1007)`. Not re-run after the merge: this diff touches nothing under `packages/spec`, and it is CI's on the PR. - **Ablation:** see the pins section above. ## Acceptance notes - The refusal's message still says "compose the deployment without the package that ships the other". The option is a narrower form of that remedy, and the message is unchanged on purpose (no change to PR #23206's refusal). - `pages.sys_organization_detail.label` stays in the platform bundles. It is read only when the page is registered, and the default composition still registers it. - The new test file's wall time is dominated by module import (the AuthPlugin chain), the same as this package's other real-kernel tests. Its three tests took 1.23 s in total, on a shared box. --- _Generated by [Claude Code](https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5c9bfb0 commit 404fc47

4 files changed

Lines changed: 306 additions & 1 deletion

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/plugin-auth': minor
3+
---
4+
5+
feat(plugin-auth): `registerOrganizationRecordPage: false` leaves out the platform's `sys_organization` default record page, so a host that ships its own page is the only default
6+
7+
Clause-②: yes (widening)
8+
9+
A deployment holds at most one `isDefault: true` record page per object, and a second one is refused at registration with `DEFAULT_RECORD_PAGE_CONFLICT`. `AuthPlugin` always registered `sys_organization_detail` (the Members / Invitations / Teams tab strip) as the default record page of `sys_organization`, so a deployment composing `AuthPlugin` beside a host that ships its own default page for that object was refused at boot.
10+
11+
- **New option.** `AuthPluginOptions.registerOrganizationRecordPage` defaults to `true`. Only an explicit `false` leaves out `sys_organization_detail`, so the host's page is the only default whichever plugin starts first. Plugin order and precedence rules play no part.
12+
- **Nothing else moves.** With `false`, `AuthPlugin` still registers the identity objects, the `sys_user_detail` record page, the System Overview dashboard, and the auth routes and services. It logs one `info` line at boot naming the option.
13+
- **The default is unchanged.** A deployment that does not set the option registers `sys_organization_detail` as before. A host page beside it, with no option set, is still refused with `DEFAULT_RECORD_PAGE_CONFLICT`.
14+
- **To adopt it:** construct `new AuthPlugin({ …, registerOrganizationRecordPage: false })` in the host that registers its own `isDefault: true` record page for `sys_organization`. With the option set and no default page of the host's own, the organization record view opens the synthesized default layout, without the Members / Invitations / Teams tabs.

‎packages/plugins/plugin-auth/README.md‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,33 @@ The plugin accepts configuration via `AuthConfig` schema from `@objectstack/spec
106106
- `plugins` - Enable additional auth features (organization, backend 2FA, passkeys, magic link)
107107
- `session` - Session configuration (expiry, update frequency)
108108

109+
### Shipping your own organization record page
110+
111+
`AuthPlugin` registers the platform's default record page for `sys_organization`:
112+
`sys_organization_detail`, the Members / Invitations / Teams tab strip. A deployment holds at
113+
most one `isDefault: true` record page per object, and a second one is refused at registration
114+
with `DEFAULT_RECORD_PAGE_CONFLICT`. A host that ships its own default record page for
115+
`sys_organization` turns the platform page off, so the host's page is the only default:
116+
117+
```typescript
118+
new AuthPlugin({
119+
secret: process.env.AUTH_SECRET,
120+
baseUrl: 'http://localhost:3000',
121+
// The host registers its own `isDefault: true` record page for sys_organization.
122+
registerOrganizationRecordPage: false,
123+
})
124+
```
125+
126+
- `registerOrganizationRecordPage` (default `true`): only an explicit `false` leaves out
127+
`sys_organization_detail`. Nothing else changes: the identity objects, the `sys_user_detail`
128+
record page, the System Overview dashboard, and the auth routes and services are registered
129+
as before.
130+
- Neither plugin order nor any precedence rule decides which page is the default. Without the
131+
option, the platform page and the host's page conflict, and the boot is refused whichever
132+
plugin starts first.
133+
- With the option set and no default page of the host's own, the organization record view
134+
opens the synthesized default layout, without the Members / Invitations / Teams tabs.
135+
109136
## API Routes
110137

111138
The plugin forwards all requests under `/api/v1/auth/*` directly to better-auth's universal handler, so **better-auth's own route table is the route table** — the plugin does not hand-register a `login` / `register` / `logout` / `session` route set.

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

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,33 @@ export interface AuthPluginOptions extends Partial<AuthConfig> {
253253
* @default false
254254
*/
255255
hostSignInHandoff?: boolean;
256+
257+
/**
258+
* Whether this plugin registers the platform's default record page for
259+
* `sys_organization`: `sys_organization_detail`, the Members / Invitations /
260+
* Teams tab strip (`SysOrganizationDetailPage`, `@objectstack/platform-objects`).
261+
*
262+
* A deployment holds at most ONE `isDefault: true` record page per object; a
263+
* second is refused at registration with `DEFAULT_RECORD_PAGE_CONFLICT`
264+
* (maintainer ruling A on objectstack#23015, record 6134056653). A host that
265+
* ships its OWN default record page for `sys_organization` passes `false`, so
266+
* its page is the single default by composition. Neither registration order
267+
* nor a precedence rule decides it: ruling A takes neither.
268+
*
269+
* `false` removes exactly one registration, `sys_organization_detail`.
270+
* Everything else stays: the `sys_organization` object and every other
271+
* identity object, the `sys_user_detail` record page, the System Overview
272+
* dashboard, and the auth routes and services. The host's page then owns what
273+
* the platform page gave. Set `false` with no default record page of the
274+
* host's own, and `sys_organization`'s record view opens the synthesized
275+
* default layout, without the Members / Invitations / Teams tabs.
276+
*
277+
* Only an explicit `false` removes the page; `true` and an absent key keep
278+
* it. Read from this option alone (no env var, no setting), so the code that
279+
* composes the host's own page is the one place the choice is stated.
280+
* @default true
281+
*/
282+
registerOrganizationRecordPage?: boolean;
256283
}
257284

258285
/**
@@ -647,6 +674,10 @@ export class AuthPlugin implements Plugin {
647674
ctx.registerService('tenancy', tenancy);
648675
this.tenancy = tenancy;
649676

677+
// Only an explicit `false` leaves the organization page out — see
678+
// `AuthPluginOptions.registerOrganizationRecordPage`.
679+
const registerOrganizationRecordPage = this.options.registerOrganizationRecordPage !== false;
680+
650681
ctx.getService<{ register(m: any): void }>('manifest').register({
651682
// The header, `objects` and `objectExtensions`, from the ONE builder
652683
// `IdentityObjectsPlugin` registers on its own in a reduced kernel, so the
@@ -671,7 +702,13 @@ export class AuthPlugin implements Plugin {
671702
// sys_organization gets a Members / Invitations / Teams tab strip
672703
// (see SysOrganizationDetailPage for the rationale and the
673704
// intentionally-omitted OAuth / SSO tabs).
674-
pages: [SysOrganizationDetailPage, SysUserDetailPage],
705+
//
706+
// `registerOrganizationRecordPage: false` leaves the organization page
707+
// out, for a host that ships its own `sys_organization` default record
708+
// page: one default per object per deployment, kept by composition
709+
// (ruling A on objectstack#23015). `SysUserDetailPage` is registered
710+
// either way.
711+
pages: [...(registerOrganizationRecordPage ? [SysOrganizationDetailPage] : []), SysUserDetailPage],
675712
// List views for each Setup-nav object are defined on the schema
676713
// itself via the canonical `listViews` map (e.g.
677714
// sys_user.listViews.{all_users,unverified,two_factor}). Registering
@@ -713,6 +750,15 @@ export class AuthPlugin implements Plugin {
713750
}
714751
: {}),
715752
});
753+
if (!registerOrganizationRecordPage) {
754+
// A deliberate composition, said once at boot so a missing tab strip is
755+
// never left to be diagnosed from its absence.
756+
ctx.logger.info(
757+
'[auth] sys_organization_detail is not registered (registerOrganizationRecordPage: false): the host ' +
758+
'composes its own default record page for sys_organization. Without one, the organization record ' +
759+
'view opens the synthesized default layout, with no Members / Invitations / Teams tabs.',
760+
);
761+
}
716762

717763
// [ADR-0108 / #3723] There is no organization-role derivation any more.
718764
//
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `AuthPluginOptions.registerOrganizationRecordPage`: a host that ships its own
5+
* default record page for `sys_organization` composes without plugin-auth's.
6+
*
7+
* ## The rule this option serves
8+
*
9+
* One `isDefault: true` record page per object per deployment, a second refused
10+
* at registration with `DEFAULT_RECORD_PAGE_CONFLICT` (maintainer ruling A on
11+
* objectstack#23015, record 6134056653). Ruling A keeps one page "by
12+
* overlay-by-name or by composition"; this option is the composition route. It
13+
* is not a precedence ladder and not a registration-order rule: with it set,
14+
* plugin-auth registers no organization page at all, so the host's page is the
15+
* single default whichever plugin initialises first.
16+
*
17+
* ## The three pins, each with its control
18+
*
19+
* 1. With the option (`false`), the host's page is the single default record
20+
* page of `sys_organization` and the boot succeeds, in either plugin order.
21+
* Control: the same composition without the option is refused (pin 3).
22+
* 2. Without it, plugin-auth's `sys_organization_detail` registers as today.
23+
* Control: with the option and no host page, `sys_organization` has no
24+
* default record page, so the assertion can tell the two apart.
25+
* `sys_user_detail` registers in both.
26+
* 3. With both pages and no option, `DEFAULT_RECORD_PAGE_CONFLICT` still
27+
* refuses the boot, naming both pages, in either plugin order. Control: an
28+
* explicit `true` is today's behaviour and is refused the same way.
29+
*
30+
* ## The composition
31+
*
32+
* The cheapest real one: a real `ObjectKernel`, a real ObjectQL engine on
33+
* in-memory SQLite, the real `AuthPlugin`, and a host plugin in the place of a
34+
* product package (the cloud control plane's tenant service is the first such
35+
* host) registering its own organization page through the `manifest` service,
36+
* as `AppPlugin.init()` does. The refusal is the registry's own, reached
37+
* through that door.
38+
*
39+
* ## Resolution
40+
*
41+
* `AuthPlugin` is imported from SOURCE (relative). `ObjectKernel`,
42+
* `ObjectQLPlugin` and `SqlDriver` are the fixed instruments, read from
43+
* `dist/` as this package's other kernel tests read them, and so is
44+
* `@objectstack/spec/ui`'s `defaultRecordPageObject`, the one reading of "the
45+
* default record page of object X" the registry's refusal asks too.
46+
*/
47+
48+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
49+
import { ObjectKernel, type Plugin, type PluginContext } from '@objectstack/core';
50+
import { ObjectQLPlugin, type ObjectQL } from '@objectstack/objectql';
51+
import { SqlDriver } from '@objectstack/driver-sql';
52+
import { defaultRecordPageObject } from '@objectstack/spec/ui';
53+
import { AuthPlugin, type AuthPluginOptions } from './auth-plugin.js';
54+
import { AUTH_PLUGIN_ID } from './manifest.js';
55+
56+
const HOST_PACKAGE_ID = 'com.example.host-tenant';
57+
const HOST_PAGE = 'host_organization_detail';
58+
const AUTH_PAGE = 'sys_organization_detail';
59+
60+
/** Env the auth plugin's boot branches on; cleared per case so the default `single` posture holds. */
61+
const ENV_KEYS = ['OS_TENANCY_POSTURE', 'OS_MULTI_ORG_ENABLED', 'OS_PLATFORM_OWNER_EMAIL'] as const;
62+
63+
type Holder = { kind: 'package'; packageId: string; page: string } | { kind: 'environment'; page: string };
64+
type Refusal = Error & { code?: string; status?: number; object?: string; incoming?: Holder; existing?: Holder };
65+
66+
/** Publishes an in-memory SQLite driver the way a datasource plugin does. */
67+
function sqliteDriverPlugin(): Plugin {
68+
return {
69+
name: 'test.driver.sqlite',
70+
type: 'standard',
71+
version: '1.0.0',
72+
async init(ctx: PluginContext) {
73+
ctx.registerService(
74+
'driver.default',
75+
new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }),
76+
);
77+
},
78+
};
79+
}
80+
81+
/**
82+
* A host shipping its own default record page for `sys_organization`,
83+
* registered through the `manifest` service in `init()` as `AppPlugin.init()`
84+
* does. No edge to `AuthPlugin`, so the order the case uses it in is the order
85+
* the two initialise in.
86+
*/
87+
function hostPlugin(): Plugin {
88+
return {
89+
name: HOST_PACKAGE_ID,
90+
type: 'app',
91+
version: '1.0.0',
92+
dependencies: ['com.objectstack.engine.objectql'],
93+
async init(ctx: PluginContext) {
94+
ctx.getService<{ register(m: unknown): unknown }>('manifest').register({
95+
id: HOST_PACKAGE_ID,
96+
name: 'host-tenant',
97+
version: '1.0.0',
98+
type: 'app',
99+
pages: [
100+
{
101+
name: HOST_PAGE,
102+
label: 'Workspace',
103+
type: 'record',
104+
object: 'sys_organization',
105+
isDefault: true,
106+
regions: [],
107+
},
108+
],
109+
});
110+
},
111+
};
112+
}
113+
114+
const authPlugin = (options: Pick<AuthPluginOptions, 'registerOrganizationRecordPage'> = {}) =>
115+
new AuthPlugin({ secret: 'test-secret-at-least-32-chars-long', baseUrl: 'http://localhost:3000', ...options });
116+
117+
let kernel: ObjectKernel | undefined;
118+
const savedEnv: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>> = {};
119+
120+
beforeEach(() => {
121+
for (const key of ENV_KEYS) {
122+
savedEnv[key] = process.env[key];
123+
delete process.env[key];
124+
}
125+
});
126+
127+
afterEach(async () => {
128+
try {
129+
await kernel?.shutdown();
130+
} catch {
131+
/* a refused boot leaves the kernel stopped */
132+
}
133+
kernel = undefined;
134+
for (const key of ENV_KEYS) {
135+
if (savedEnv[key] === undefined) delete process.env[key];
136+
else process.env[key] = savedEnv[key];
137+
}
138+
});
139+
140+
/** Boot the composition with `plugins` used in the given order; resolves to the engine or the refusal. */
141+
async function boot(plugins: Plugin[]): Promise<{ ql?: ObjectQL; refusal?: Refusal }> {
142+
await kernel?.shutdown().catch(() => undefined);
143+
kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false });
144+
await kernel.use(sqliteDriverPlugin());
145+
await kernel.use(new ObjectQLPlugin());
146+
for (const p of plugins) await kernel.use(p);
147+
try {
148+
await kernel.bootstrap();
149+
} catch (e) {
150+
return { refusal: e as Refusal };
151+
}
152+
return { ql: kernel.getService<ObjectQL>('objectql') };
153+
}
154+
155+
/** Every default record page of `object` in the booted registry: its name and the package that registered it. */
156+
function defaultsOf(ql: ObjectQL, object: string): Array<{ name: string; packageId: string }> {
157+
return ql.registry
158+
.listItems<{ name: string; _packageId: string }>('page')
159+
.filter((p) => defaultRecordPageObject(p) === object)
160+
.map((p) => ({ name: p.name, packageId: p._packageId }));
161+
}
162+
163+
const pkgPage = (packageId: string, page: string): Holder => ({ kind: 'package', packageId, page });
164+
165+
/** The ADR-0112 envelope of the refusal and the two pages it names. */
166+
function expectConflict(refusal: Refusal | undefined, incoming: Holder, existing: Holder) {
167+
expect(refusal, 'the boot was refused').toBeDefined();
168+
expect({ code: refusal!.code, status: refusal!.status }).toEqual({ code: 'DEFAULT_RECORD_PAGE_CONFLICT', status: 422 });
169+
expect(refusal!.object).toBe('sys_organization');
170+
expect(refusal!.incoming).toEqual(incoming);
171+
expect(refusal!.existing).toEqual(existing);
172+
}
173+
174+
describe('registerOrganizationRecordPage: a host with its own sys_organization default record page', () => {
175+
it('pin 1: with the option, the host\'s page is the single default and the boot succeeds, in either order', async () => {
176+
for (const order of ['auth first', 'host first'] as const) {
177+
const auth = authPlugin({ registerOrganizationRecordPage: false });
178+
const { ql, refusal } = await boot(order === 'auth first' ? [auth, hostPlugin()] : [hostPlugin(), auth]);
179+
180+
expect(refusal, `${order}: the boot succeeds`).toBeUndefined();
181+
expect(defaultsOf(ql!, 'sys_organization'), order).toEqual([{ name: HOST_PAGE, packageId: HOST_PACKAGE_ID }]);
182+
expect(ql!.registry.listItems<{ name: string }>('page', AUTH_PLUGIN_ID).map((p) => p.name), order).not.toContain(
183+
AUTH_PAGE,
184+
);
185+
// `SysUserDetailPage` is unchanged by the option.
186+
expect(defaultsOf(ql!, 'sys_user'), order).toEqual([{ name: 'sys_user_detail', packageId: AUTH_PLUGIN_ID }]);
187+
}
188+
});
189+
190+
it('pin 2: without the option, plugin-auth\'s sys_organization_detail registers as today', async () => {
191+
const today = await boot([authPlugin()]);
192+
expect(today.refusal).toBeUndefined();
193+
expect(defaultsOf(today.ql!, 'sys_organization')).toEqual([{ name: AUTH_PAGE, packageId: AUTH_PLUGIN_ID }]);
194+
expect(defaultsOf(today.ql!, 'sys_user')).toEqual([{ name: 'sys_user_detail', packageId: AUTH_PLUGIN_ID }]);
195+
196+
// Control: the option, with no page of the host's own, leaves the object
197+
// with no default record page — the reading above is not vacuous.
198+
const omitted = await boot([authPlugin({ registerOrganizationRecordPage: false })]);
199+
expect(omitted.refusal).toBeUndefined();
200+
expect(defaultsOf(omitted.ql!, 'sys_organization')).toEqual([]);
201+
expect(defaultsOf(omitted.ql!, 'sys_user')).toEqual([{ name: 'sys_user_detail', packageId: AUTH_PLUGIN_ID }]);
202+
});
203+
204+
it('pin 3: with both pages and no option, DEFAULT_RECORD_PAGE_CONFLICT still refuses the boot, in either order', async () => {
205+
const authFirst = await boot([authPlugin(), hostPlugin()]);
206+
expect(authFirst.ql).toBeUndefined();
207+
expectConflict(authFirst.refusal, pkgPage(HOST_PACKAGE_ID, HOST_PAGE), pkgPage(AUTH_PLUGIN_ID, AUTH_PAGE));
208+
209+
const hostFirst = await boot([hostPlugin(), authPlugin()]);
210+
expect(hostFirst.ql).toBeUndefined();
211+
expectConflict(hostFirst.refusal, pkgPage(AUTH_PLUGIN_ID, AUTH_PAGE), pkgPage(HOST_PACKAGE_ID, HOST_PAGE));
212+
213+
// Control: an explicit `true` is today's behaviour, refused the same way.
214+
const explicit = await boot([authPlugin({ registerOrganizationRecordPage: true }), hostPlugin()]);
215+
expect(explicit.ql).toBeUndefined();
216+
expectConflict(explicit.refusal, pkgPage(HOST_PACKAGE_ID, HOST_PAGE), pkgPage(AUTH_PLUGIN_ID, AUTH_PAGE));
217+
});
218+
});

0 commit comments

Comments
 (0)