diff --git a/.changeset/23310-serve-org-record-page.md b/.changeset/23310-serve-org-record-page.md new file mode 100644 index 00000000000..80f68bd2091 --- /dev/null +++ b/.changeset/23310-serve-org-record-page.md @@ -0,0 +1,17 @@ +--- +'@objectstack/core': minor +'@objectstack/cli': minor +'@objectstack/plugin-dev': minor +'@objectstack/objectql': patch +--- + +feat(cli,plugin-dev): an app under `os serve` or `DevPlugin` that declares its own `sys_organization` default record page boots, and its page is the only default + +Clause-②: yes (widening) + +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`. `plugin-auth` registers `sys_organization_detail` as the default record page of `sys_organization` unless it is constructed with `registerOrganizationRecordPage: false`. `os serve` and `DevPlugin` construct `AuthPlugin` themselves and never passed that option, so an app served by either one that shipped its own default page for `sys_organization` was refused at boot, with no switch to set. + +- **What changes.** Both roots now read the app's own pages before they construct `AuthPlugin`. When the app declares an `isDefault: true` record page for `sys_organization`, they pass `registerOrganizationRecordPage: false`, and the app's page is the only default. When it does not, `plugin-auth`'s page registers as before. There is no new configuration key: the page the author already wrote is the switch. Plugin order and precedence rules play no part. +- **One rule for both roots.** The reading is `stackDeclaresOrganizationRecordPage(stack, plugins?)`, a new export of `@objectstack/core`. It reads the stack's `pages` (or each package body's, for a multi-package stack) and the bundle of each app plugin already in `plugins`, and judges each page with `defaultRecordPageObject` from `@objectstack/spec/ui`, the reading the registration refusal uses. +- **The refusal's remedy (`@objectstack/objectql`).** `DEFAULT_RECORD_PAGE_CONFLICT`'s message now says to set `isDefault: false` on one of the two pages, "or compose the deployment without the other package's page". It said to compose the deployment without the other package, which a host cannot do when that package is the platform's auth plugin. The code, the `422` status and the named pages are unchanged. +- **Nothing to adopt.** An app that ships its own `sys_organization` default record page boots under `os serve` and `DevPlugin` with no further change. A deployment that composes its own kernel still sets `registerOrganizationRecordPage: false` on `AuthPlugin` itself. diff --git a/packages/cli/src/commands/serve-auth-organization-record-page.contract.test.ts b/packages/cli/src/commands/serve-auth-organization-record-page.contract.test.ts new file mode 100644 index 00000000000..e4c74acdd38 --- /dev/null +++ b/packages/cli/src/commands/serve-auth-organization-record-page.contract.test.ts @@ -0,0 +1,68 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// `objectstack serve` constructs `AuthPlugin` itself, so it decides whether +// `plugin-auth`'s `sys_organization` default record page registers. One default +// record page per object per deployment (ruling A on objectstack#23015, record +// 6134056653): when the app this boot serves declares its own default record +// page for `sys_organization`, `serve` passes +// `registerOrganizationRecordPage: false`, from the one predicate `DevPlugin` +// asks too (`stackDeclaresOrganizationRecordPage`, `@objectstack/core`). +// +// The construction is read from `serve.ts` with comments masked, as +// `serve-auth-app-name.contract.test.ts` reads it: `AuthPlugin` is imported +// through a variable specifier, so the type checker cannot see the literal and +// only a booted CLI could. The booted half — an app with its own page boots and +// its page is the single default; without it, plugin-auth's page serves — is +// `test/serve-organization-record-page.e2e.test.ts`, over a real `os serve`. + +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { stackDeclaresOrganizationRecordPage } from '@objectstack/core'; +import { maskComments } from '../../../../scripts/js-comment-mask.mjs'; + +const SERVE_SOURCE = maskComments( + readFileSync(path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'serve.ts'), 'utf8'), +); + +/** The argument text of every `new AuthPlugin(` call. */ +function authPluginConstructions(source: string): string[] { + const out: string[] = []; + const needle = 'new AuthPlugin('; + for (let i = source.indexOf(needle); i !== -1; i = source.indexOf(needle, i + 1)) { + const open = i + needle.length - 1; + let depth = 0; + for (let j = open; j < source.length; j++) { + if (source[j] === '(') depth++; + else if (source[j] === ')' && --depth === 0) { + out.push(source.slice(open + 1, j)); + break; + } + } + } + return out; +} + +const normalise = (text: string) => text.replace(/\s+/g, ''); + +describe('serve constructs AuthPlugin with the organization page derived from the served app', () => { + it('passes registerOrganizationRecordPage from the shared predicate, over this boot\'s config and plugins', () => { + const constructions = authPluginConstructions(SERVE_SOURCE); + // Absence must be loud: a scan that finds no construction proves nothing. + expect(constructions.length, 'serve.ts must construct AuthPlugin exactly once').toBe(1); + expect(normalise(constructions[0])).toContain( + 'registerOrganizationRecordPage:!stackDeclaresOrganizationRecordPage(config,plugins)', + ); + }); + + it('the predicate reads the app wrap serve appends (an AppPlugin carrying the config as its bundle)', () => { + const page = { name: 'host_org', type: 'record', object: 'sys_organization', isDefault: true, regions: [] }; + // serve's step 3 appends `new AppPlugin(config)`; a config boot's bundle + // arrives the same way from `createStandaloneStack`. Either one is read. + expect(stackDeclaresOrganizationRecordPage({}, [{ name: 'plugin.app.host', bundle: { pages: [page] } }])).toBe(true); + expect(stackDeclaresOrganizationRecordPage({ pages: [page] }, [])).toBe(true); + // Control: the same composition with the page not the default. + expect(stackDeclaresOrganizationRecordPage({ pages: [{ ...page, isDefault: false }] }, [])).toBe(false); + }); +}); diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 0fa07d1065a..e06e62ff1b2 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -44,6 +44,7 @@ import { resolveAuthSecret, resolvePlatformAuthComposition, stackSuppliesAuthPlugin, + stackDeclaresOrganizationRecordPage, isDevelopmentBoot, type CapabilitySpec, } from '@objectstack/core'; @@ -4042,6 +4043,14 @@ export default class Serve extends Command { }, } as any) : undefined, + // One default record page per object per deployment (ruling A on + // objectstack#23015). When the app this boot serves declares its + // own `sys_organization` default record page, the deployment is + // composed without plugin-auth's; otherwise plugin-auth's page + // registers as before. One yes/no on the app's own declaration, + // the predicate `DevPlugin` asks too — no ordering, no + // precedence, and no second switch for the author to keep in step. + registerOrganizationRecordPage: !stackDeclaresOrganizationRecordPage(config, plugins), })); trackPlugin('Auth'); diff --git a/packages/cli/src/utils/boot-preparation-parity.test.ts b/packages/cli/src/utils/boot-preparation-parity.test.ts index aca17313a10..2416d0dbdec 100644 --- a/packages/cli/src/utils/boot-preparation-parity.test.ts +++ b/packages/cli/src/utils/boot-preparation-parity.test.ts @@ -164,6 +164,11 @@ const SERVE_ONLY: Record = { stackSuppliesAuthPlugin: 'serve\'s own "is an AuthPlugin already composed" read ahead of its auth block; the auth-family gate ' + '(resolvePlatformAuthComposition, called by both boots) asks the same predicate first', + stackDeclaresOrganizationRecordPage: + 'decides whether the AuthPlugin serve constructs registers plugin-auth\'s sys_organization default record page ' + + '(one default record page per object, ruling A on objectstack#23015): the declaration boot composes ' + + 'plugin-auth\'s objects through createIdentityObjectsPlugin, which registers no page, so it has no ' + + 'organization page to leave out', resolveCapabilityArgument: 'what serve constructs each capability provider WITH (the app\'s cubes, its mail and SMS configuration, ' + 'the storage root; [#22301], shared with @objectstack/verify\'s bootStack): the declaration boot constructs ' diff --git a/packages/cli/test/serve-organization-record-page.e2e.test.ts b/packages/cli/test/serve-organization-record-page.e2e.test.ts new file mode 100644 index 00000000000..11154ce6b01 --- /dev/null +++ b/packages/cli/test/serve-organization-record-page.e2e.test.ts @@ -0,0 +1,229 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A REAL `os serve` boot of an app that ships its own default record page for + * `sys_organization`, read back through the metadata door. + * + * ## The rule + * + * At most one `isDefault: true` record page per object per deployment, and a + * second is refused at registration with `DEFAULT_RECORD_PAGE_CONFLICT` + * (maintainer ruling A on objectstack#23015, record 6134056653). `plugin-auth` + * ships `sys_organization_detail` unless it is constructed with + * `registerOrganizationRecordPage: false`. `os serve` constructs `AuthPlugin` + * itself, so it passes that option from the app's own declaration + * (`stackDeclaresOrganizationRecordPage`, `@objectstack/core`): an app that + * declares its own `sys_organization` default needs no second switch. + * + * ## What each boot is asked + * + * - THE CARD'S BOOT: the app declares `orgpage_organization_detail` as the + * default record page of `sys_organization`. The boot reaches its banner + * with no conflict, and the served `sys_organization` defaults are exactly + * that one page, of the app's package. + * - CONTROL: the same app without that page. `plugin-auth`'s + * `sys_organization_detail` serves as before — which is also what makes the + * first reading non-vacuous: the door does list plugin-auth's page when it + * registers. + * + * Both read `sys_user_detail` too: the option removes exactly one page. + * + * Signed in as the dev admin that `bin/run-dev.js` seeds (`admin@objectos.ai`). + */ + +import { afterAll, describe, expect, it } from 'vitest'; +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + CLI, + E2E_SECRET_KEY, + TSX, + childEnv, + portContentionError, + portDriftError, + probeThroughChild, + randomPort, +} from './helpers/serve-process.js'; +import { linkSpec } from './helpers/define-stack-fixture.js'; + +const READY = /Press Ctrl\+C to stop/; + +const config = (withOrganizationPage: boolean) => ` +import { defineStack } from '@objectstack/spec'; +export default defineStack({ + manifest: { id: 'com.example.orgpage', name: 'orgpage', namespace: 'orgpage', version: '1.0.0', type: 'app' }, + objects: [{ name: 'orgpage_task', label: 'Task', sharingModel: 'private', fields: { title: { type: 'text', label: 'Title' } } }], + pages: [${withOrganizationPage + ? "{ name: 'orgpage_organization_detail', label: 'Organization', type: 'record', object: 'sys_organization', isDefault: true, regions: [] }" + : ''}], +}, { strict: false }); +`; + +const dirs: string[] = []; +const children: ChildProcessWithoutNullStreams[] = []; + +function project(withOrganizationPage: boolean): string { + const dir = mkdtempSync(join(tmpdir(), 'os-org-record-page-')); + dirs.push(dir); + writeFileSync(join(dir, 'objectstack.config.ts'), config(withOrganizationPage), 'utf8'); + linkSpec(dir); + return dir; +} + +interface LiveServe { + child: ChildProcessWithoutNullStreams; + output: () => string; +} + +function bootServe(dir: string, port: string): Promise { + return new Promise((resolveBoot, rejectBoot) => { + const child = spawn(TSX, [CLI, 'serve', 'objectstack.config.ts', '-p', port], { + cwd: dir, + stdio: ['pipe', 'pipe', 'pipe'], + // `childEnv`, never a bare `...process.env` — see its header (commit 1ddda1d00). + env: childEnv({ + NO_COLOR: '1', + OS_DATABASE_URL: ':memory:', + OS_LOG_LEVEL: 'warn', + OS_DISABLE_CONSOLE: '1', + OS_SECRET_KEY: E2E_SECRET_KEY, + }), + }) as ChildProcessWithoutNullStreams; + children.push(child); + const what = `os serve in ${dir}`; + let out = ''; + let settled = false; + const settle = (err: Error | null) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (err) rejectBoot(err); + else resolveBoot({ child, output: () => out }); + }; + const timer = setTimeout( + () => settle(new Error(`${what} never printed ${READY}\n--- output ---\n${out.slice(-4000)}`)), + 180_000, + ); + const onData = (d: unknown) => { + out += String(d); + // The child is the authority on the port it bound (#12525). + if (READY.test(out)) settle(portDriftError(out, what, port)); + }; + child.stdout.on('data', onData); + child.stderr.on('data', onData); + child.on('exit', (code) => + settle( + portContentionError(out, what, port) + ?? new Error(`${what} exited ${String(code)} before ${READY}\n--- output ---\n${out.slice(-4000)}`), + ), + ); + }); +} + +async function stop(child: ChildProcessWithoutNullStreams): Promise { + if (child.exitCode !== null || child.signalCode !== null) return; + await new Promise((done) => { + const give = setTimeout(() => { + try { + child.kill('SIGKILL'); + } catch { + /* already gone */ + } + done(); + }, 10_000); + child.once('exit', () => { + clearTimeout(give); + done(); + }); + try { + child.kill('SIGTERM'); + } catch { + clearTimeout(give); + done(); + } + }); +} + +/** One HTTP exchange, attributed to the child if the transport fails (#15653). ⛔ No assertion inside. */ +async function request(serve: LiveServe, what: string, url: string, init: RequestInit = {}): Promise<{ status: number; body: any }> { + return probeThroughChild( + { + child: serve.child, + transcript: () => `\n--- child output ---\n${serve.output().slice(-4000)}`, + label: 'serve-organization-record-page', + what, + }, + async () => { + const r = await fetch(url, init); + const text = await r.text(); + let body: unknown = text; + try { body = JSON.parse(text); } catch { /* keep the text */ } + return { status: r.status, body }; + }, + ); +} + +/** Every default record page the booted deployment serves for `object`, as `page of package`. */ +type Defaults = Record; + +async function servedDefaults(withOrganizationPage: boolean): Promise<{ defaults: Defaults; output: string }> { + const port = randomPort(); + const base = `http://localhost:${port}/api/v1`; + const serve = await bootServe(project(withOrganizationPage), port); + try { + const signIn = await request(serve, 'the sign-in probe', `${base}/auth/sign-in/email`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email: 'admin@objectos.ai', password: 'admin123' }), + }); + const token = signIn.body?.token; + if (signIn.status !== 200 || typeof token !== 'string') { + throw new Error(`sign-in answered ${signIn.status}: ${JSON.stringify(signIn.body)}\n--- output ---\n${serve.output().slice(-3000)}`); + } + const pages = await request(serve, 'the page list', `${base}/meta/page`, { + headers: { authorization: `Bearer ${token}` }, + }); + if (pages.status !== 200 || !Array.isArray(pages.body?.items)) { + throw new Error(`GET /meta/page answered ${pages.status}: ${JSON.stringify(pages.body).slice(0, 500)}`); + } + const defaults: Defaults = {}; + for (const page of pages.body.items) { + const isRecord = page?.type === undefined || page?.type === 'record'; + if (page?.isDefault !== true || !isRecord || typeof page?.object !== 'string') continue; + (defaults[page.object] ??= []).push(`${page.name} of ${page._packageId}`); + } + return { defaults, output: serve.output() }; + } finally { + await stop(serve.child); + } +} + +afterAll(async () => { + for (const child of children) await stop(child); + for (const dir of dirs) rmSync(dir, { recursive: true, force: true }); +}); + +describe('os serve — one default record page for sys_organization (ruling A on #23015)', () => { + it( + 'an app with its own sys_organization default boots, and its page is the single default', + async () => { + const { defaults, output } = await servedDefaults(true); + expect(output, 'the boot was refused').not.toMatch(/DEFAULT_RECORD_PAGE_CONFLICT|Default record page conflict/); + expect(defaults.sys_organization).toEqual(['orgpage_organization_detail of com.example.orgpage']); + expect(defaults.sys_user).toEqual(['sys_user_detail of com.objectstack.plugin-auth']); + }, + 300_000, + ); + + it( + 'control: without the app page, plugin-auth\'s page serves as before', + async () => { + const { defaults } = await servedDefaults(false); + expect(defaults.sys_organization).toEqual(['sys_organization_detail of com.objectstack.plugin-auth']); + expect(defaults.sys_user).toEqual(['sys_user_detail of com.objectstack.plugin-auth']); + }, + 300_000, + ); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4aeda7bd7a8..5a54a95a88f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -37,6 +37,10 @@ export * from './stack-plugins.js'; // family (and the security plugin paired with it): `os serve` and // `os migrate security-catalog-overlays` answer it alike. Same reason. export * from './stack-auth.js'; +// And whether the served app declares its own `sys_organization` default +// record page, so both roots that construct `AuthPlugin` (`os serve` and +// `DevPlugin`) leave `plugin-auth`'s out alike (ruling A on objectstack#23015). +export * from './stack-organization-record-page.js'; export * from './lite-kernel.js'; export * from './types.js'; export * from './logger.js'; diff --git a/packages/core/src/stack-organization-record-page.test.ts b/packages/core/src/stack-organization-record-page.test.ts new file mode 100644 index 00000000000..f875f5df32c --- /dev/null +++ b/packages/core/src/stack-organization-record-page.test.ts @@ -0,0 +1,73 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `stackDeclaresOrganizationRecordPage` — the one yes/no both `AuthPlugin` + * composition roots (`os serve`, `DevPlugin`) ask before they construct it: + * does the served app declare its own default record page for + * `sys_organization` (ruling A on objectstack#23015, record 6134056653)? + * + * Each YES case has a NO control that differs in exactly one declared fact, + * so no case passes by reading nothing. + */ + +import { describe, expect, it } from 'vitest'; +import { stackDeclaresOrganizationRecordPage } from './stack-organization-record-page.js'; + +const page = (extra: Record = {}) => ({ + name: 'host_organization_detail', + label: 'Organization', + type: 'record', + object: 'sys_organization', + isDefault: true, + regions: [], + ...extra, +}); + +describe('the stack\'s own pages', () => { + it('yes: a default record page for sys_organization', () => { + expect(stackDeclaresOrganizationRecordPage({ pages: [page()] })).toBe(true); + }); + + it('yes: a page that declares no `type` is a record page', () => { + const { type: _omitted, ...untyped } = page(); + expect(stackDeclaresOrganizationRecordPage({ pages: [untyped] })).toBe(true); + }); + + it.each([ + ['isDefault: false', page({ isDefault: false })], + ['no isDefault', page({ isDefault: undefined })], + ['another object', page({ object: 'crm_account' })], + ['not a record page', page({ type: 'home' })], + ])('no: %s', (_label, candidate) => { + expect(stackDeclaresOrganizationRecordPage({ pages: [candidate] })).toBe(false); + }); + + it('no: no pages, no stack', () => { + expect(stackDeclaresOrganizationRecordPage({})).toBe(false); + expect(stackDeclaresOrganizationRecordPage({ pages: [] })).toBe(false); + expect(stackDeclaresOrganizationRecordPage(undefined)).toBe(false); + }); +}); + +describe('the packages a multi-package stack carries (ADR-0130 D4)', () => { + const body = (pages: unknown[]) => ({ id: 'com.example.tenant', name: 'tenant', version: '1.0.0', type: 'app', pages }); + + it('yes: a package body declares it and the top level carries no `pages`', () => { + expect(stackDeclaresOrganizationRecordPage({ packages: [{ manifest: body([page()]) }] })).toBe(true); + }); + + it('no (control): the same package body without the default', () => { + expect(stackDeclaresOrganizationRecordPage({ packages: [{ manifest: body([page({ isDefault: false })]) }] })).toBe(false); + }); +}); + +describe('the app bundles the root\'s plugins already carry', () => { + it('yes: an app plugin\'s bundle declares it', () => { + expect(stackDeclaresOrganizationRecordPage({}, [{ name: 'plugin.app.tenant', bundle: { pages: [page()] } }])).toBe(true); + }); + + it('no (control): the same bundle without the default, and plugins with no bundle', () => { + expect(stackDeclaresOrganizationRecordPage({}, [{ bundle: { pages: [page({ isDefault: false })] } }])).toBe(false); + expect(stackDeclaresOrganizationRecordPage({}, [{ name: 'x' }, null, 'str'])).toBe(false); + }); +}); diff --git a/packages/core/src/stack-organization-record-page.ts b/packages/core/src/stack-organization-record-page.ts new file mode 100644 index 00000000000..1a916a6af3c --- /dev/null +++ b/packages/core/src/stack-organization-record-page.ts @@ -0,0 +1,71 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { SystemObjectName } from '@objectstack/spec/system'; +import { defaultRecordPageObject } from '@objectstack/spec/ui'; +import { resolveStackCollection } from './stack-collections.js'; + +/** + * Does the app a composition root serves declare its OWN default record page + * for `sys_organization`? + * + * ## The rule it serves + * + * A deployment holds at most one `isDefault: true` record page per object, and + * a second is refused at registration with `DEFAULT_RECORD_PAGE_CONFLICT` + * (maintainer ruling A on objectstack#23015, record 6134056653). `plugin-auth` + * ships one for `sys_organization` (`sys_organization_detail`) unless it is + * constructed with `registerOrganizationRecordPage: false`. A host that + * composes its own kernel sets that option where it composes its own page. A + * composition root that builds `AuthPlugin` itself — `os serve` + * (`@objectstack/cli`) and `DevPlugin` (`@objectstack/plugin-dev`) — composes + * the app's pages too, so it reads the app's own declaration here and passes + * the option from the answer. The app needs no second switch that must agree + * with the page it already wrote. + * + * ## What it reads + * + * One yes/no on a declared fact: is any page of the served app a default + * record page of `sys_organization`, by `defaultRecordPageObject` + * (`@objectstack/spec/ui`) — the same reading the registration refusal uses? + * The pages are read off: + * + * - the stack itself, by {@link resolveStackCollection}'s rule (the top-level + * `pages` when the stack carries the key, otherwise every package body's); + * - each app bundle the root's `plugins` already carry (an entry exposing a + * `bundle`, as `AppPlugin` does), by the same rule. + * + * ⛔ Not an ordering or precedence rule: the answer does not depend on which + * plugin registers first, and nothing outranks anything. When the app declares + * its own page, the root composes the deployment without `plugin-auth`'s; when + * it does not, `plugin-auth`'s page registers as before. A second default the + * app or any other package brings is still refused by the registry, naming + * both pages. + * + * Both roots call this one function, so the same stack gets the same answer + * under each. It lives here because both depend on this package and neither + * can import the other. + * + * @param stack - The stack the root serves (`os serve`'s loaded config, + * `DevPlugin`'s `stack` option). + * @param plugins - The root's own `plugins`, when it has any. Only entries + * carrying a `bundle` are read. + * @throws Whatever the stack collection reader raises for a malformed + * `packages` (`INVALID_ARTIFACT_PACKAGES`), the refusal the same boot raises + * when it registers that stack. + */ +export function stackDeclaresOrganizationRecordPage( + stack: unknown, + plugins: readonly unknown[] = [], +): boolean { + const bundles: unknown[] = [stack]; + for (const plugin of plugins) { + const bundle = plugin && typeof plugin === 'object' ? (plugin as { bundle?: unknown }).bundle : undefined; + if (bundle && typeof bundle === 'object') bundles.push(bundle); + } + return bundles.some((bundle) => + bundle !== null + && typeof bundle === 'object' + && resolveStackCollection(bundle, 'pages').some( + (page) => defaultRecordPageObject(page) === SystemObjectName.ORGANIZATION, + )); +} diff --git a/packages/objectql/src/registry-default-record-page.test.ts b/packages/objectql/src/registry-default-record-page.test.ts index 3363fd75f1f..88ffd719d45 100644 --- a/packages/objectql/src/registry-default-record-page.test.ts +++ b/packages/objectql/src/registry-default-record-page.test.ts @@ -121,6 +121,25 @@ describe('a second package registering a default record page for an object anoth expect(registry.listItems('page').map((p) => p.name)).toEqual(['acme_org_detail']); }); + it('names a remedy every holder can take: leave out a PAGE, never a whole package', async () => { + // The remedy sentence is what an author (or an AI) acts on, so its text is + // the contract here. It names the page to leave out, not the package: a + // deployment cannot leave out a package such as the platform's auth + // plugin, and can still leave out the one page it ships. It names no + // option, no package and no tracker number, so it stays true whichever + // holder is on the other side. + const { register } = await boot(); + expect(await register(pkg('com.acme.auth', [recordPage('acme_org_detail', 'acme_org')]))).toBeUndefined(); + const err = await register(pkg('com.acme.tenant', [recordPage('tenant_org_detail', 'acme_org')])); + expect({ code: err?.code, status: err?.status }).toEqual({ code: DEFAULT_RECORD_PAGE_CONFLICT_CODE, status: 422 }); + expect(err!.message.split('. ')[1]).toBe( + 'Keep one default per object: set `isDefault: false` on one of the two pages, or compose the deployment ' + + 'without the other package\'s page', + ); + expect(err!.message).not.toMatch(/without the package/); + expect(err!.message).not.toMatch(/#\d+|registerOrganizationRecordPage|plugin-auth/); + }); + it('is refused in either order — whichever registers second is the second default', async () => { const { register } = await boot(); expect(await register(pkg('com.acme.tenant', [recordPage('tenant_org_detail', 'acme_org')]))).toBeUndefined(); diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index 69df6079e5a..8fb229d5bac 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -1747,8 +1747,12 @@ export interface DefaultRecordPageConflict { * the `500` fallback. Its own code, not the namespace gate's: the condition is * not a name already taken but an object's one default slot already held, and * the remedy differs — keep one default (`isDefault: false` on the other), or - * compose the deployment without the package shipping the other page; renaming - * fixes nothing. + * compose the deployment without the other package's page; renaming fixes + * nothing. The remedy names the PAGE, not the package: a deployment can keep a + * package and leave out the one page it ships (a page-level composition switch, + * where that package offers one), and a package a host cannot leave out at all + * still has a page it can. The sentence names no option and no package, so it + * stays true whichever holder is on the other side. * * Every conflict the registration carries is listed — `conflicts` — so one boot * reports all of them; the top-level fields repeat the first. @@ -1776,8 +1780,8 @@ export class DefaultRecordPageConflictError extends Error { ); super( `Default record page conflict: ${lines.join('; ')}. Keep one default per object: set ` + - `\`isDefault: false\` on one of the two pages, or compose the deployment without the package that ` + - `ships the other. At most one \`isDefault: true\` record page may exist for an object in a ` + + `\`isDefault: false\` on one of the two pages, or compose the deployment without the other ` + + `package's page. At most one \`isDefault: true\` record page may exist for an object in a ` + `deployment: the record view opens that page with no tie-break, so with two the page would be ` + `chosen by registration order.`, ); diff --git a/packages/plugins/plugin-dev/src/dev-plugin-organization-record-page.test.ts b/packages/plugins/plugin-dev/src/dev-plugin-organization-record-page.test.ts new file mode 100644 index 00000000000..48ca012f7b5 --- /dev/null +++ b/packages/plugins/plugin-dev/src/dev-plugin-organization-record-page.test.ts @@ -0,0 +1,100 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { DevPlugin } from './dev-plugin'; + +/** + * `DevPlugin` constructs `AuthPlugin` itself, so it is one of the two + * composition roots (with `os serve`) that decide whether `plugin-auth`'s + * `sys_organization` default record page registers. One default record page + * per object per deployment (ruling A on objectstack#23015, record + * 6134056653): when the stack this plugin serves declares its own default + * record page for `sys_organization`, the root passes + * `registerOrganizationRecordPage: false`; otherwise `true`, today's page. + * What `false` then does inside `plugin-auth` (the host's page is the single + * default, in either plugin order) is pinned on a real kernel there: + * `organization-record-page-option.test.ts`. + * + * `@objectstack/plugin-auth` is replaced by a class that records the options it + * is constructed with. Every other optional dependency is genuinely absent, as + * in `dev-plugin.test.ts` (#3060), so the boot reaches the auth step and the + * record is the only thing read. + */ + +const constructed: Array> = []; + +vi.mock('@objectstack/plugin-auth', () => ({ + AuthPlugin: class { + name = 'com.objectstack.auth'; + constructor(options: Record) { + constructed.push(options); + } + async init() {} + async start() {} + }, +})); +vi.mock('@objectstack/objectql', () => { throw Object.assign(new Error("Cannot find package '@objectstack/objectql'"), { code: 'ERR_MODULE_NOT_FOUND' }); }); +vi.mock('@objectstack/runtime', () => { throw Object.assign(new Error("Cannot find package '@objectstack/runtime'"), { code: 'ERR_MODULE_NOT_FOUND' }); }); +vi.mock('@objectstack/service-i18n', () => { throw Object.assign(new Error("Cannot find package '@objectstack/service-i18n'"), { code: 'ERR_MODULE_NOT_FOUND' }); }); +vi.mock('@objectstack/service-storage', () => { throw Object.assign(new Error("Cannot find package '@objectstack/service-storage'"), { code: 'ERR_MODULE_NOT_FOUND' }); }); +vi.mock('@objectstack/service-realtime', () => { throw Object.assign(new Error("Cannot find package '@objectstack/service-realtime'"), { code: 'ERR_MODULE_NOT_FOUND' }); }); +vi.mock('@objectstack/plugin-security', () => { throw Object.assign(new Error("Cannot find package '@objectstack/plugin-security'"), { code: 'ERR_MODULE_NOT_FOUND' }); }); +vi.mock('@objectstack/plugin-hono-server', () => { throw Object.assign(new Error("Cannot find package '@objectstack/plugin-hono-server'"), { code: 'ERR_MODULE_NOT_FOUND' }); }); +vi.mock('@objectstack/rest', () => { throw Object.assign(new Error("Cannot find package '@objectstack/rest'"), { code: 'ERR_MODULE_NOT_FOUND' }); }); +vi.mock('@objectstack/setup', () => { throw Object.assign(new Error("Cannot find package '@objectstack/setup'"), { code: 'ERR_MODULE_NOT_FOUND' }); }); +vi.mock('@objectstack/account', () => { throw Object.assign(new Error("Cannot find package '@objectstack/account'"), { code: 'ERR_MODULE_NOT_FOUND' }); }); + +function mockCtx(): any { + const services = new Map(); + return { + logger: { info: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn() }, + getService: vi.fn().mockImplementation((name: string) => { + if (services.has(name)) return services.get(name); + throw new Error('not found'); + }), + getServices: vi.fn().mockReturnValue(new Map()), + registerService: vi.fn().mockImplementation((name: string, svc: unknown) => services.set(name, svc)), + hook: vi.fn(), + trigger: vi.fn(), + getKernel: vi.fn(), + }; +} + +const manifest = { id: 'com.example.tenant', name: 'tenant', version: '1.0.0', type: 'app' }; +const organizationPage = (isDefault: boolean) => ({ + name: 'tenant_organization_detail', + label: 'Organization', + type: 'record', + object: 'sys_organization', + isDefault, + regions: [], +}); + +/** The options `DevPlugin` constructed `AuthPlugin` with, for one stack. */ +async function authOptionsFor(stack: Record | undefined) { + constructed.length = 0; + await new DevPlugin({ seedAdminUser: false, ...(stack ? { stack } : {}) }).init(mockCtx()); + expect(constructed, 'DevPlugin constructs AuthPlugin exactly once').toHaveLength(1); + return constructed[0]; +} + +describe('DevPlugin leaves out plugin-auth\'s organization page exactly when the stack declares its own', () => { + beforeEach(() => { + constructed.length = 0; + }); + + it('the stack declares its own sys_organization default record page: registerOrganizationRecordPage is false', async () => { + const options = await authOptionsFor({ manifest, pages: [organizationPage(true)] }); + expect(options.registerOrganizationRecordPage).toBe(false); + }); + + it('control: the same page with isDefault: false keeps plugin-auth\'s page', async () => { + const options = await authOptionsFor({ manifest, pages: [organizationPage(false)] }); + expect(options.registerOrganizationRecordPage).toBe(true); + }); + + it('control: a stack with no pages, and no stack at all, keep plugin-auth\'s page', async () => { + expect((await authOptionsFor({ manifest })).registerOrganizationRecordPage).toBe(true); + expect((await authOptionsFor(undefined)).registerOrganizationRecordPage).toBe(true); + }); +}); diff --git a/packages/plugins/plugin-dev/src/dev-plugin.ts b/packages/plugins/plugin-dev/src/dev-plugin.ts index 08b6c510791..55c505e1c93 100644 --- a/packages/plugins/plugin-dev/src/dev-plugin.ts +++ b/packages/plugins/plugin-dev/src/dev-plugin.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { Plugin, PluginContext } from '@objectstack/core'; +import { Plugin, PluginContext, stackDeclaresOrganizationRecordPage } from '@objectstack/core'; import { resolveAllowDegradedTenancy, resolveAllowDevPlugin, resolveTenancyPosture } from '@objectstack/types'; import { postureEnforcesWall } from '@objectstack/spec/security'; @@ -715,6 +715,12 @@ export class DevPlugin implements Plugin { const authPlugin = new AuthPlugin({ secret: this.options.authSecret, baseUrl: this.options.authBaseUrl, + // One default record page per object per deployment (ruling A on + // objectstack#23015): when the stack this plugin serves declares its + // own `sys_organization` default record page, plugin-auth's is left + // out; otherwise it registers as before. The predicate `os serve` + // asks too, so the same stack gets the same answer under both roots. + registerOrganizationRecordPage: !stackDeclaresOrganizationRecordPage(this.options.stack), }); this.childPlugins.push(authPlugin); authMounted = true;