Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/23310-serve-org-record-page.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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);
});
});
9 changes: 9 additions & 0 deletions packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
resolveAuthSecret,
resolvePlatformAuthComposition,
stackSuppliesAuthPlugin,
stackDeclaresOrganizationRecordPage,
isDevelopmentBoot,
type CapabilitySpec,
} from '@objectstack/core';
Expand Down Expand Up @@ -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');

Expand Down
5 changes: 5 additions & 0 deletions packages/cli/src/utils/boot-preparation-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,11 @@ const SERVE_ONLY: Record<string, string> = {
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 '
Expand Down
229 changes: 229 additions & 0 deletions packages/cli/test/serve-organization-record-page.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -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<LiveServe> {
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<void> {
if (child.exitCode !== null || child.signalCode !== null) return;
await new Promise<void>((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<string, string[]>;

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,
);
});
4 changes: 4 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading
Loading