Skip to content

Commit 086fa46

Browse files
feat(cli,plugin-dev): an app that declares its own sys_organization default record page boots under os serve and DevPlugin; the conflict remedy names a page, not a package (#23550)
Fixes #23310 Clause-②: yes ## What this does Ruling A on #23015 (record `6134056653`) allows one `isDefault: true` record page per object per deployment. PR #23309 added `AuthPluginOptions.registerOrganizationRecordPage` so a host that ships its own `sys_organization` default can leave out `plugin-auth`'s `sys_organization_detail`. The two composition roots that construct `AuthPlugin` themselves, `os serve` and `DevPlugin`, never passed the option. An app served by either one that ships its own `sys_organization` default was refused at boot with `DEFAULT_RECORD_PAGE_CONFLICT`, and the app had no switch to set. - **Derived, not forwarded.** Both roots now read the served app's own pages before they construct `AuthPlugin`. If the app declares an `isDefault: true` record page for `sys_organization`, they pass `registerOrganizationRecordPage: false`. Otherwise they pass `true`, which is today's behaviour. There is no new stack-config key: the page the author already wrote is the switch. - **One predicate, two callers.** `stackDeclaresOrganizationRecordPage(stack, plugins?)` is a new export of `@objectstack/core` (`packages/core/src/stack-organization-record-page.ts`). `serve.ts` calls it with `(config, plugins)` and `dev-plugin.ts` with `(this.options.stack)`. It reads `pages` by `resolveStackCollection`'s rule (the top level, or each package body for a multi-package stack) off the stack and off each app bundle already in `plugins`. Each page is judged by `defaultRecordPageObject` (`@objectstack/spec/ui`), the reading the registration refusal uses. - **The remedy sentence (`packages/objectql/src/registry.ts`).** "...or compose the deployment without the package that ships the other" now reads "...or compose the deployment without the other package's page". It names no option, no package and no tracker number. The code, the `422` status, the named pages and the first sentence are unchanged. - ⛔ No precedence ladder and ⛔ no registration-order rule. The answer is one yes/no on a declared fact and does not depend on plugin order. A second default the app or any other package brings is still refused by the registry, naming both pages. ## Derive vs forward (the measurement) | question | derive | forward | |:--|:--|:--| | Does the root have the app's pages before `new AuthPlugin(...)`? | **yes.** `serve.ts` has `config` in scope and has already appended `new AppPlugin(config)` to `plugins` (step 3, before step 5d). `DevPlugin` has `this.options.stack`. | n/a | | New authorable surface | none | a spec key, its liveness row, docs, generated baselines | | Can the author get it wrong? | No. The declaration is the switch. | Yes. An author who ships the page and forgets the key is still refused at boot, and a key set with no page silently drops the Members / Invitations / Teams tabs. | | Is it a single yes/no on a declared fact? | yes (`defaultRecordPageObject(page) === 'sys_organization'` over the app's pages) | n/a | Derive was reachable cleanly from both roots, so forward was not built. **Where the predicate lives.** In `@objectstack/core`: `@objectstack/cli` and `@objectstack/plugin-dev` both depend on it, and neither can import the other. Core already hosts `resolveStackCollection` and the auth-family gate `resolvePlatformAuthComposition` for the same reason. Core already depends on `@objectstack/spec`, so nothing points the wrong way. It is a new file rather than an addition to `stack-auth.ts`, because that module's header promises "importing this module loads nothing", and this one imports `@objectstack/spec/ui`. **File surface vs the claim.** The claim names `serve.ts`, `dev-plugin.ts`, `registry.ts`, their tests and the changeset. Two further paths: - `packages/core/src/{stack-organization-record-page.ts,index.ts}` and the predicate's test hold the shared predicate. The dispatch asked for one predicate shared by both roots. - `packages/cli/src/utils/boot-preparation-parity.test.ts` gains one `SERVE_ONLY` row. That pin reds when `serve.ts` uses a core function that `os migrate`'s one-shot boot does not. The reason recorded in the row: the migrate boot composes `plugin-auth`'s objects through `createIdentityObjectsPlugin`, which registers no page, so it has no organization page to leave out. ## Pins (each with a negative control) - **`packages/cli/test/serve-organization-record-page.e2e.test.ts`, a real `os serve`.** It boots a `defineStack` app, signs in as the seeded dev admin and reads `GET /api/v1/meta/page`. 1. The app declares `orgpage_organization_detail` as the `sys_organization` default. The boot reaches its banner with no conflict, and the `sys_organization` defaults are exactly `["orgpage_organization_detail of com.example.orgpage"]`. 2. Control: the same app without the page. The defaults are exactly `["sys_organization_detail of com.objectstack.plugin-auth"]`, so the reading in case 1 is not vacuous. - Both cases also read `sys_user` = `["sys_user_detail of com.objectstack.plugin-auth"]`: the option removes exactly one page. - It is in the nightly `e2e` tier, by name. Two `os serve` boots take about 28 s, so per-PR CI runs the contract pin below instead. - **`packages/cli/src/commands/serve-auth-organization-record-page.contract.test.ts` (unit tier, per PR).** The single `new AuthPlugin(` literal in `serve.ts` (comments masked, as `serve-auth-app-name.contract.test.ts` reads it) passes `registerOrganizationRecordPage: !stackDeclaresOrganizationRecordPage(config, plugins)`. The predicate reads an AppPlugin-shaped bundle, with an `isDefault: false` control. - **`packages/plugins/plugin-dev/src/dev-plugin-organization-record-page.test.ts`.** `DevPlugin.init()` with a recording `AuthPlugin`: the app page gives `false`. Controls: the same page with `isDefault: false`, no pages, and no stack each give `true`. - **`packages/core/src/stack-organization-record-page.test.ts`.** 11 cases. Yes: top-level pages, an untyped page (a record page by default), a page in a `packages[]` body, an app plugin's `bundle`. No: `isDefault: false` or absent, another object, a `home` page, an empty stack, a body or bundle without the default. - **`packages/objectql/src/registry-default-record-page.test.ts`**, new case: `code` + `status` (`DEFAULT_RECORD_PAGE_CONFLICT` / `422`), and the remedy sentence asserted verbatim: "Keep one default per object: set `isDefault: false` on one of the two pages, or compose the deployment without the other package's page". The message contains neither "without the package" nor an option name, a package name or a `#NNN`. - **Pin sweep.** I grepped the old sentence and `DEFAULT_RECORD_PAGE_CONFLICT` repo-wide, examples included. No test pinned the remedy text: the existing pins read only the first sentence, `split('. ')[0]`, and stay verbatim. The other hits are prose; see Acceptance notes. **Ablations** (`scripts/ablation-replace.mjs`, under `os-verify-lock`, each restored to the HEAD blob with `git diff HEAD` empty): | mutation | anchor | result | |:--|:--|:--| | `serve.ts`: the derived option replaced by `registerOrganizationRecordPage: true,` | x1 to x0, blob `e06e62ff1b24` to `6bd5bb650dd5` | e2e case 1 **red**: `os serve ... exited 1 before /Press Ctrl\+C to stop/`, with the child printing `✗ Default record page conflict: page "orgpage_organization_detail" of package "com.example.orgpage" cannot be the default record page of object "sys_organization": page "sys_organization_detail" of package "com.objectstack.plugin-auth" already is. Keep one default per object: ... or compose the deployment without the other package's page.` The control stays green. 1 failed, 1 passed. | | `dev-plugin.ts`: the same mutation | x1 to x0, blob `55c505e1c936` to `20335a650c35` | the app-page case **red**, the 2 controls green | | `registry.ts`: the old remedy text restored | x1 to x0, blob `8fb229d5bace` to `7da871a01cc3` | the new remedy case **red**, 17 others green | `serve.ts` runs from `src/` through tsx (`bin/run-dev.js`). The plugin-dev and objectql tests import their subject relatively. So none of the three mutated subjects resolves through a `dist/`. ## Verification Head `c2db30e787` (`origin/main` `5fd2c76d6` merged in, a plain merge with no generated artifacts on either side), after a full `turbo run build` (76/76 tasks). All readings are on a shared box. - **Derived gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived **77** commands from the 12-path change set. All 77 ran, each exit code captured before any pipe, and `--ran` reconciled `77 derived, 77 run, 0 NOT-MEASURED, 0 UNRUN`. - `check:type-check-debt` exited 3 in the battery (`queue-timeout`, never acquired). It was re-run under the lock as its refusal prescribes: `none above its recorded number`, exit 0. - Pre-merge, `check-engine-split-ratio` refused on the shallow clone (exit 2). It read 98.5% after `fetch --shallow-since=2026-07-09`. The `dual-build` and `i18n` gates exited 3 (unbuilt CLI). All three pass on the merged, built head. - **Tests on the merged head:** - `@objectstack/core` `stack-organization-record-page.test.ts`: 11 passed. - `@objectstack/objectql` default-record-page registry and save-door tests: 38 passed. - `@objectstack/plugin-dev` full suite: 10 files, 89 tests passed. - `@objectstack/cli` unit: the contract pin, `boot-preparation-parity.test.ts` and `vitest-tiers-partition.test.ts`: 61 passed. - `@objectstack/plugin-auth` `organization-record-page-option.test.ts`: 3 passed. - The e2e above: 2 passed, 27.9 s. - `@objectstack/spec` `test:repo`: 61 files, 1033 tests passed. - **Full suites before the merge** (at `7c4bce2697`, before the merge. The merge changed no source file of these packages; it did change `packages/spec`, whose `test:repo` and the targeted suites above re-ran green on the merged head, and the full suites were not re-run): - `@objectstack/core`: 99 files, 2452 tests passed. - `@objectstack/objectql`: 418 files, 8109 tests passed. - `@objectstack/cli --project unit`: 288 passed, 1 failed. The failure was `boot-preparation-parity.test.ts` naming `stackDeclaresOrganizationRecordPage`, fixed in `891cc1736` and green above. - The `integration` tier is declared to CI; the only spawn file this diff adds is the e2e above, run locally. - **Typecheck:** `@objectstack/core`, `@objectstack/plugin-dev`, `@objectstack/objectql` and `@objectstack/cli` all exit 0. Every `check:test-typecheck` is OK with no new ledger entries. - **Lint, a proven narrowing:** `eslint --no-inline-config --format json` over the 11 changed `.ts` files reports 11 files linted, 0 errors, 0 warnings, and none reported as ignored. The repo config has no `parserOptions.project` or `projectService` (type-aware linting is off), so this diff cannot move the verdict on any untouched file. The full `pnpm lint` is CI's. ## Acceptance notes - **The save door's remedy names the same untakeable step.** `@objectstack/metadata-protocol`'s `saveMetaItem` refusal (`protocol.ts`, the `remedy` built beside `DEFAULT_RECORD_PAGE_CONFLICT`) says "a page a package ships stays the default unless the deployment is composed without that package". It is the same family as this card's second half, in a file outside this claim. Its reach through a public door was not measured. Successor: none. - **The spec docblock carries the old sentence too.** `packages/spec/src/ui/page.zod.ts`, `PageSchema.isDefault` TSDoc: "compose the deployment without the package that ships the other". This is author-facing prose, not runtime text, and `packages/spec` is outside this claim, so it is unchanged. Successor: none. - `.changeset/pre/23015-one-default-record-page-per-object.md` keeps the old wording. It is a pre-mode release record and not this PR's to edit. - `plugin-auth`'s README still shows the option set by hand. That stays correct for a host that composes its own kernel; under `os serve` and `DevPlugin` it is now derived. - A malformed `packages` on an option-B stack that carries no top-level `pages` makes the predicate raise the collection reader's `INVALID_ARTIFACT_PACKAGES`. That is the refusal the same boot raises when it registers that stack, and it is not swallowed here. --- _Generated by [Claude Code](https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0b9391b commit 086fa46

12 files changed

Lines changed: 610 additions & 5 deletions
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/core': minor
3+
'@objectstack/cli': minor
4+
'@objectstack/plugin-dev': minor
5+
'@objectstack/objectql': patch
6+
---
7+
8+
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
9+
10+
Clause-②: yes (widening)
11+
12+
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.
13+
14+
- **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.
15+
- **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.
16+
- **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.
17+
- **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.
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// `objectstack serve` constructs `AuthPlugin` itself, so it decides whether
4+
// `plugin-auth`'s `sys_organization` default record page registers. One default
5+
// record page per object per deployment (ruling A on objectstack#23015, record
6+
// 6134056653): when the app this boot serves declares its own default record
7+
// page for `sys_organization`, `serve` passes
8+
// `registerOrganizationRecordPage: false`, from the one predicate `DevPlugin`
9+
// asks too (`stackDeclaresOrganizationRecordPage`, `@objectstack/core`).
10+
//
11+
// The construction is read from `serve.ts` with comments masked, as
12+
// `serve-auth-app-name.contract.test.ts` reads it: `AuthPlugin` is imported
13+
// through a variable specifier, so the type checker cannot see the literal and
14+
// only a booted CLI could. The booted half — an app with its own page boots and
15+
// its page is the single default; without it, plugin-auth's page serves — is
16+
// `test/serve-organization-record-page.e2e.test.ts`, over a real `os serve`.
17+
18+
import { describe, it, expect } from 'vitest';
19+
import { readFileSync } from 'node:fs';
20+
import path from 'node:path';
21+
import { fileURLToPath } from 'node:url';
22+
import { stackDeclaresOrganizationRecordPage } from '@objectstack/core';
23+
import { maskComments } from '../../../../scripts/js-comment-mask.mjs';
24+
25+
const SERVE_SOURCE = maskComments(
26+
readFileSync(path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'serve.ts'), 'utf8'),
27+
);
28+
29+
/** The argument text of every `new AuthPlugin(` call. */
30+
function authPluginConstructions(source: string): string[] {
31+
const out: string[] = [];
32+
const needle = 'new AuthPlugin(';
33+
for (let i = source.indexOf(needle); i !== -1; i = source.indexOf(needle, i + 1)) {
34+
const open = i + needle.length - 1;
35+
let depth = 0;
36+
for (let j = open; j < source.length; j++) {
37+
if (source[j] === '(') depth++;
38+
else if (source[j] === ')' && --depth === 0) {
39+
out.push(source.slice(open + 1, j));
40+
break;
41+
}
42+
}
43+
}
44+
return out;
45+
}
46+
47+
const normalise = (text: string) => text.replace(/\s+/g, '');
48+
49+
describe('serve constructs AuthPlugin with the organization page derived from the served app', () => {
50+
it('passes registerOrganizationRecordPage from the shared predicate, over this boot\'s config and plugins', () => {
51+
const constructions = authPluginConstructions(SERVE_SOURCE);
52+
// Absence must be loud: a scan that finds no construction proves nothing.
53+
expect(constructions.length, 'serve.ts must construct AuthPlugin exactly once').toBe(1);
54+
expect(normalise(constructions[0])).toContain(
55+
'registerOrganizationRecordPage:!stackDeclaresOrganizationRecordPage(config,plugins)',
56+
);
57+
});
58+
59+
it('the predicate reads the app wrap serve appends (an AppPlugin carrying the config as its bundle)', () => {
60+
const page = { name: 'host_org', type: 'record', object: 'sys_organization', isDefault: true, regions: [] };
61+
// serve's step 3 appends `new AppPlugin(config)`; a config boot's bundle
62+
// arrives the same way from `createStandaloneStack`. Either one is read.
63+
expect(stackDeclaresOrganizationRecordPage({}, [{ name: 'plugin.app.host', bundle: { pages: [page] } }])).toBe(true);
64+
expect(stackDeclaresOrganizationRecordPage({ pages: [page] }, [])).toBe(true);
65+
// Control: the same composition with the page not the default.
66+
expect(stackDeclaresOrganizationRecordPage({ pages: [{ ...page, isDefault: false }] }, [])).toBe(false);
67+
});
68+
});

‎packages/cli/src/commands/serve.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import {
4444
resolveAuthSecret,
4545
resolvePlatformAuthComposition,
4646
stackSuppliesAuthPlugin,
47+
stackDeclaresOrganizationRecordPage,
4748
isDevelopmentBoot,
4849
type CapabilitySpec,
4950
} from '@objectstack/core';
@@ -4042,6 +4043,14 @@ export default class Serve extends Command {
40424043
},
40434044
} as any)
40444045
: undefined,
4046+
// One default record page per object per deployment (ruling A on
4047+
// objectstack#23015). When the app this boot serves declares its
4048+
// own `sys_organization` default record page, the deployment is
4049+
// composed without plugin-auth's; otherwise plugin-auth's page
4050+
// registers as before. One yes/no on the app's own declaration,
4051+
// the predicate `DevPlugin` asks too — no ordering, no
4052+
// precedence, and no second switch for the author to keep in step.
4053+
registerOrganizationRecordPage: !stackDeclaresOrganizationRecordPage(config, plugins),
40454054
}));
40464055
trackPlugin('Auth');
40474056

‎packages/cli/src/utils/boot-preparation-parity.test.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,11 @@ const SERVE_ONLY: Record<string, string> = {
164164
stackSuppliesAuthPlugin:
165165
'serve\'s own "is an AuthPlugin already composed" read ahead of its auth block; the auth-family gate '
166166
+ '(resolvePlatformAuthComposition, called by both boots) asks the same predicate first',
167+
stackDeclaresOrganizationRecordPage:
168+
'decides whether the AuthPlugin serve constructs registers plugin-auth\'s sys_organization default record page '
169+
+ '(one default record page per object, ruling A on objectstack#23015): the declaration boot composes '
170+
+ 'plugin-auth\'s objects through createIdentityObjectsPlugin, which registers no page, so it has no '
171+
+ 'organization page to leave out',
167172
resolveCapabilityArgument:
168173
'what serve constructs each capability provider WITH (the app\'s cubes, its mail and SMS configuration, '
169174
+ 'the storage root; [#22301], shared with @objectstack/verify\'s bootStack): the declaration boot constructs '
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* A REAL `os serve` boot of an app that ships its own default record page for
5+
* `sys_organization`, read back through the metadata door.
6+
*
7+
* ## The rule
8+
*
9+
* At most one `isDefault: true` record page per object per deployment, and a
10+
* second is refused at registration with `DEFAULT_RECORD_PAGE_CONFLICT`
11+
* (maintainer ruling A on objectstack#23015, record 6134056653). `plugin-auth`
12+
* ships `sys_organization_detail` unless it is constructed with
13+
* `registerOrganizationRecordPage: false`. `os serve` constructs `AuthPlugin`
14+
* itself, so it passes that option from the app's own declaration
15+
* (`stackDeclaresOrganizationRecordPage`, `@objectstack/core`): an app that
16+
* declares its own `sys_organization` default needs no second switch.
17+
*
18+
* ## What each boot is asked
19+
*
20+
* - THE CARD'S BOOT: the app declares `orgpage_organization_detail` as the
21+
* default record page of `sys_organization`. The boot reaches its banner
22+
* with no conflict, and the served `sys_organization` defaults are exactly
23+
* that one page, of the app's package.
24+
* - CONTROL: the same app without that page. `plugin-auth`'s
25+
* `sys_organization_detail` serves as before — which is also what makes the
26+
* first reading non-vacuous: the door does list plugin-auth's page when it
27+
* registers.
28+
*
29+
* Both read `sys_user_detail` too: the option removes exactly one page.
30+
*
31+
* Signed in as the dev admin that `bin/run-dev.js` seeds (`admin@objectos.ai`).
32+
*/
33+
34+
import { afterAll, describe, expect, it } from 'vitest';
35+
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
36+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
37+
import { tmpdir } from 'node:os';
38+
import { join } from 'node:path';
39+
import {
40+
CLI,
41+
E2E_SECRET_KEY,
42+
TSX,
43+
childEnv,
44+
portContentionError,
45+
portDriftError,
46+
probeThroughChild,
47+
randomPort,
48+
} from './helpers/serve-process.js';
49+
import { linkSpec } from './helpers/define-stack-fixture.js';
50+
51+
const READY = /Press Ctrl\+C to stop/;
52+
53+
const config = (withOrganizationPage: boolean) => `
54+
import { defineStack } from '@objectstack/spec';
55+
export default defineStack({
56+
manifest: { id: 'com.example.orgpage', name: 'orgpage', namespace: 'orgpage', version: '1.0.0', type: 'app' },
57+
objects: [{ name: 'orgpage_task', label: 'Task', sharingModel: 'private', fields: { title: { type: 'text', label: 'Title' } } }],
58+
pages: [${withOrganizationPage
59+
? "{ name: 'orgpage_organization_detail', label: 'Organization', type: 'record', object: 'sys_organization', isDefault: true, regions: [] }"
60+
: ''}],
61+
}, { strict: false });
62+
`;
63+
64+
const dirs: string[] = [];
65+
const children: ChildProcessWithoutNullStreams[] = [];
66+
67+
function project(withOrganizationPage: boolean): string {
68+
const dir = mkdtempSync(join(tmpdir(), 'os-org-record-page-'));
69+
dirs.push(dir);
70+
writeFileSync(join(dir, 'objectstack.config.ts'), config(withOrganizationPage), 'utf8');
71+
linkSpec(dir);
72+
return dir;
73+
}
74+
75+
interface LiveServe {
76+
child: ChildProcessWithoutNullStreams;
77+
output: () => string;
78+
}
79+
80+
function bootServe(dir: string, port: string): Promise<LiveServe> {
81+
return new Promise((resolveBoot, rejectBoot) => {
82+
const child = spawn(TSX, [CLI, 'serve', 'objectstack.config.ts', '-p', port], {
83+
cwd: dir,
84+
stdio: ['pipe', 'pipe', 'pipe'],
85+
// `childEnv`, never a bare `...process.env` — see its header (commit 1ddda1d00).
86+
env: childEnv({
87+
NO_COLOR: '1',
88+
OS_DATABASE_URL: ':memory:',
89+
OS_LOG_LEVEL: 'warn',
90+
OS_DISABLE_CONSOLE: '1',
91+
OS_SECRET_KEY: E2E_SECRET_KEY,
92+
}),
93+
}) as ChildProcessWithoutNullStreams;
94+
children.push(child);
95+
const what = `os serve in ${dir}`;
96+
let out = '';
97+
let settled = false;
98+
const settle = (err: Error | null) => {
99+
if (settled) return;
100+
settled = true;
101+
clearTimeout(timer);
102+
if (err) rejectBoot(err);
103+
else resolveBoot({ child, output: () => out });
104+
};
105+
const timer = setTimeout(
106+
() => settle(new Error(`${what} never printed ${READY}\n--- output ---\n${out.slice(-4000)}`)),
107+
180_000,
108+
);
109+
const onData = (d: unknown) => {
110+
out += String(d);
111+
// The child is the authority on the port it bound (#12525).
112+
if (READY.test(out)) settle(portDriftError(out, what, port));
113+
};
114+
child.stdout.on('data', onData);
115+
child.stderr.on('data', onData);
116+
child.on('exit', (code) =>
117+
settle(
118+
portContentionError(out, what, port)
119+
?? new Error(`${what} exited ${String(code)} before ${READY}\n--- output ---\n${out.slice(-4000)}`),
120+
),
121+
);
122+
});
123+
}
124+
125+
async function stop(child: ChildProcessWithoutNullStreams): Promise<void> {
126+
if (child.exitCode !== null || child.signalCode !== null) return;
127+
await new Promise<void>((done) => {
128+
const give = setTimeout(() => {
129+
try {
130+
child.kill('SIGKILL');
131+
} catch {
132+
/* already gone */
133+
}
134+
done();
135+
}, 10_000);
136+
child.once('exit', () => {
137+
clearTimeout(give);
138+
done();
139+
});
140+
try {
141+
child.kill('SIGTERM');
142+
} catch {
143+
clearTimeout(give);
144+
done();
145+
}
146+
});
147+
}
148+
149+
/** One HTTP exchange, attributed to the child if the transport fails (#15653). ⛔ No assertion inside. */
150+
async function request(serve: LiveServe, what: string, url: string, init: RequestInit = {}): Promise<{ status: number; body: any }> {
151+
return probeThroughChild(
152+
{
153+
child: serve.child,
154+
transcript: () => `\n--- child output ---\n${serve.output().slice(-4000)}`,
155+
label: 'serve-organization-record-page',
156+
what,
157+
},
158+
async () => {
159+
const r = await fetch(url, init);
160+
const text = await r.text();
161+
let body: unknown = text;
162+
try { body = JSON.parse(text); } catch { /* keep the text */ }
163+
return { status: r.status, body };
164+
},
165+
);
166+
}
167+
168+
/** Every default record page the booted deployment serves for `object`, as `page of package`. */
169+
type Defaults = Record<string, string[]>;
170+
171+
async function servedDefaults(withOrganizationPage: boolean): Promise<{ defaults: Defaults; output: string }> {
172+
const port = randomPort();
173+
const base = `http://localhost:${port}/api/v1`;
174+
const serve = await bootServe(project(withOrganizationPage), port);
175+
try {
176+
const signIn = await request(serve, 'the sign-in probe', `${base}/auth/sign-in/email`, {
177+
method: 'POST',
178+
headers: { 'content-type': 'application/json' },
179+
body: JSON.stringify({ email: 'admin@objectos.ai', password: 'admin123' }),
180+
});
181+
const token = signIn.body?.token;
182+
if (signIn.status !== 200 || typeof token !== 'string') {
183+
throw new Error(`sign-in answered ${signIn.status}: ${JSON.stringify(signIn.body)}\n--- output ---\n${serve.output().slice(-3000)}`);
184+
}
185+
const pages = await request(serve, 'the page list', `${base}/meta/page`, {
186+
headers: { authorization: `Bearer ${token}` },
187+
});
188+
if (pages.status !== 200 || !Array.isArray(pages.body?.items)) {
189+
throw new Error(`GET /meta/page answered ${pages.status}: ${JSON.stringify(pages.body).slice(0, 500)}`);
190+
}
191+
const defaults: Defaults = {};
192+
for (const page of pages.body.items) {
193+
const isRecord = page?.type === undefined || page?.type === 'record';
194+
if (page?.isDefault !== true || !isRecord || typeof page?.object !== 'string') continue;
195+
(defaults[page.object] ??= []).push(`${page.name} of ${page._packageId}`);
196+
}
197+
return { defaults, output: serve.output() };
198+
} finally {
199+
await stop(serve.child);
200+
}
201+
}
202+
203+
afterAll(async () => {
204+
for (const child of children) await stop(child);
205+
for (const dir of dirs) rmSync(dir, { recursive: true, force: true });
206+
});
207+
208+
describe('os serve — one default record page for sys_organization (ruling A on #23015)', () => {
209+
it(
210+
'an app with its own sys_organization default boots, and its page is the single default',
211+
async () => {
212+
const { defaults, output } = await servedDefaults(true);
213+
expect(output, 'the boot was refused').not.toMatch(/DEFAULT_RECORD_PAGE_CONFLICT|Default record page conflict/);
214+
expect(defaults.sys_organization).toEqual(['orgpage_organization_detail of com.example.orgpage']);
215+
expect(defaults.sys_user).toEqual(['sys_user_detail of com.objectstack.plugin-auth']);
216+
},
217+
300_000,
218+
);
219+
220+
it(
221+
'control: without the app page, plugin-auth\'s page serves as before',
222+
async () => {
223+
const { defaults } = await servedDefaults(false);
224+
expect(defaults.sys_organization).toEqual(['sys_organization_detail of com.objectstack.plugin-auth']);
225+
expect(defaults.sys_user).toEqual(['sys_user_detail of com.objectstack.plugin-auth']);
226+
},
227+
300_000,
228+
);
229+
});

‎packages/core/src/index.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ export * from './stack-plugins.js';
3737
// family (and the security plugin paired with it): `os serve` and
3838
// `os migrate security-catalog-overlays` answer it alike. Same reason.
3939
export * from './stack-auth.js';
40+
// And whether the served app declares its own `sys_organization` default
41+
// record page, so both roots that construct `AuthPlugin` (`os serve` and
42+
// `DevPlugin`) leave `plugin-auth`'s out alike (ruling A on objectstack#23015).
43+
export * from './stack-organization-record-page.js';
4044
export * from './lite-kernel.js';
4145
export * from './types.js';
4246
export * from './logger.js';

0 commit comments

Comments
 (0)