From ed4ff95ebf4d4f4e014d61a2bc05ceb3fde4e676 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 14:16:56 +0000 Subject: [PATCH 1/3] feat(plugin-auth): registerOrganizationRecordPage option leaves out sys_organization_detail for a host that ships its own default Claude-Session: https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ Co-authored-by: Claude --- .../plugins/plugin-auth/src/auth-plugin.ts | 48 ++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index d9a3be7b7cb..72c5e5482b2 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -253,6 +253,33 @@ export interface AuthPluginOptions extends Partial { * @default false */ hostSignInHandoff?: boolean; + + /** + * Whether this plugin registers the platform's default record page for + * `sys_organization`: `sys_organization_detail`, the Members / Invitations / + * Teams tab strip (`SysOrganizationDetailPage`, `@objectstack/platform-objects`). + * + * A deployment holds at most ONE `isDefault: true` record page per object; a + * second is refused at registration with `DEFAULT_RECORD_PAGE_CONFLICT` + * (maintainer ruling A on objectstack#23015, record 6134056653). A host that + * ships its OWN default record page for `sys_organization` passes `false`, so + * its page is the single default by composition. Neither registration order + * nor a precedence rule decides it: ruling A takes neither. + * + * `false` removes exactly one registration, `sys_organization_detail`. + * Everything else stays: the `sys_organization` object and every other + * identity object, the `sys_user_detail` record page, the System Overview + * dashboard, and the auth routes and services. The host's page then owns what + * the platform page gave. Set `false` with no default record page of the + * host's own, and `sys_organization`'s record view opens the synthesized + * default layout, without the Members / Invitations / Teams tabs. + * + * Only an explicit `false` removes the page; `true` and an absent key keep + * it. Read from this option alone (no env var, no setting), so the code that + * composes the host's own page is the one place the choice is stated. + * @default true + */ + registerOrganizationRecordPage?: boolean; } /** @@ -647,6 +674,10 @@ export class AuthPlugin implements Plugin { ctx.registerService('tenancy', tenancy); this.tenancy = tenancy; + // Only an explicit `false` leaves the organization page out — see + // `AuthPluginOptions.registerOrganizationRecordPage`. + const registerOrganizationRecordPage = this.options.registerOrganizationRecordPage !== false; + ctx.getService<{ register(m: any): void }>('manifest').register({ // The header, `objects` and `objectExtensions`, from the ONE builder // `IdentityObjectsPlugin` registers on its own in a reduced kernel, so the @@ -671,7 +702,13 @@ export class AuthPlugin implements Plugin { // sys_organization gets a Members / Invitations / Teams tab strip // (see SysOrganizationDetailPage for the rationale and the // intentionally-omitted OAuth / SSO tabs). - pages: [SysOrganizationDetailPage, SysUserDetailPage], + // + // `registerOrganizationRecordPage: false` leaves the organization page + // out, for a host that ships its own `sys_organization` default record + // page: one default per object per deployment, kept by composition + // (ruling A on objectstack#23015). `SysUserDetailPage` is registered + // either way. + pages: [...(registerOrganizationRecordPage ? [SysOrganizationDetailPage] : []), SysUserDetailPage], // List views for each Setup-nav object are defined on the schema // itself via the canonical `listViews` map (e.g. // sys_user.listViews.{all_users,unverified,two_factor}). Registering @@ -713,6 +750,15 @@ export class AuthPlugin implements Plugin { } : {}), }); + if (!registerOrganizationRecordPage) { + // A deliberate composition, said once at boot so a missing tab strip is + // never left to be diagnosed from its absence. + ctx.logger.info( + '[auth] sys_organization_detail is not registered (registerOrganizationRecordPage: false): the host ' + + 'composes its own default record page for sys_organization. Without one, the organization record ' + + 'view opens the synthesized default layout, with no Members / Invitations / Teams tabs.', + ); + } // [ADR-0108 / #3723] There is no organization-role derivation any more. // From a162f1f89821e7842ad8457c8aeb487b72034829 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 14:24:58 +0000 Subject: [PATCH 2/3] test(plugin-auth): pin registerOrganizationRecordPage's three cases on a real kernel Claude-Session: https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ Co-authored-by: Claude --- .../organization-record-page-option.test.ts | 218 ++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 packages/plugins/plugin-auth/src/organization-record-page-option.test.ts diff --git a/packages/plugins/plugin-auth/src/organization-record-page-option.test.ts b/packages/plugins/plugin-auth/src/organization-record-page-option.test.ts new file mode 100644 index 00000000000..8ca85e0de4f --- /dev/null +++ b/packages/plugins/plugin-auth/src/organization-record-page-option.test.ts @@ -0,0 +1,218 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `AuthPluginOptions.registerOrganizationRecordPage`: a host that ships its own + * default record page for `sys_organization` composes without plugin-auth's. + * + * ## The rule this option serves + * + * One `isDefault: true` record page per object per deployment, a second refused + * at registration with `DEFAULT_RECORD_PAGE_CONFLICT` (maintainer ruling A on + * objectstack#23015, record 6134056653). Ruling A keeps one page "by + * overlay-by-name or by composition"; this option is the composition route. It + * is not a precedence ladder and not a registration-order rule: with it set, + * plugin-auth registers no organization page at all, so the host's page is the + * single default whichever plugin initialises first. + * + * ## The three pins, each with its control + * + * 1. With the option (`false`), the host's page is the single default record + * page of `sys_organization` and the boot succeeds, in either plugin order. + * Control: the same composition without the option is refused (pin 3). + * 2. Without it, plugin-auth's `sys_organization_detail` registers as today. + * Control: with the option and no host page, `sys_organization` has no + * default record page, so the assertion can tell the two apart. + * `sys_user_detail` registers in both. + * 3. With both pages and no option, `DEFAULT_RECORD_PAGE_CONFLICT` still + * refuses the boot, naming both pages, in either plugin order. Control: an + * explicit `true` is today's behaviour and is refused the same way. + * + * ## The composition + * + * The cheapest real one: a real `ObjectKernel`, a real ObjectQL engine on + * in-memory SQLite, the real `AuthPlugin`, and a host plugin in the place of a + * product package (the cloud control plane's tenant service is the first such + * host) registering its own organization page through the `manifest` service, + * as `AppPlugin.init()` does. The refusal is the registry's own, reached + * through that door. + * + * ## Resolution + * + * `AuthPlugin` is imported from SOURCE (relative). `ObjectKernel`, + * `ObjectQLPlugin` and `SqlDriver` are the fixed instruments, read from + * `dist/` as this package's other kernel tests read them, and so is + * `@objectstack/spec/ui`'s `defaultRecordPageObject`, the one reading of "the + * default record page of object X" the registry's refusal asks too. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { ObjectKernel, type Plugin, type PluginContext } from '@objectstack/core'; +import { ObjectQLPlugin, type ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { defaultRecordPageObject } from '@objectstack/spec/ui'; +import { AuthPlugin, type AuthPluginOptions } from './auth-plugin.js'; +import { AUTH_PLUGIN_ID } from './manifest.js'; + +const HOST_PACKAGE_ID = 'com.example.host-tenant'; +const HOST_PAGE = 'host_organization_detail'; +const AUTH_PAGE = 'sys_organization_detail'; + +/** Env the auth plugin's boot branches on; cleared per case so the default `single` posture holds. */ +const ENV_KEYS = ['OS_TENANCY_POSTURE', 'OS_MULTI_ORG_ENABLED', 'OS_PLATFORM_OWNER_EMAIL'] as const; + +type Holder = { kind: 'package'; packageId: string; page: string } | { kind: 'environment'; page: string }; +type Refusal = Error & { code?: string; status?: number; object?: string; incoming?: Holder; existing?: Holder }; + +/** Publishes an in-memory SQLite driver the way a datasource plugin does. */ +function sqliteDriverPlugin(): Plugin { + return { + name: 'test.driver.sqlite', + type: 'standard', + version: '1.0.0', + async init(ctx: PluginContext) { + ctx.registerService( + 'driver.default', + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), + ); + }, + }; +} + +/** + * A host shipping its own default record page for `sys_organization`, + * registered through the `manifest` service in `init()` as `AppPlugin.init()` + * does. No edge to `AuthPlugin`, so the order the case uses it in is the order + * the two initialise in. + */ +function hostPlugin(): Plugin { + return { + name: HOST_PACKAGE_ID, + type: 'app', + version: '1.0.0', + dependencies: ['com.objectstack.engine.objectql'], + async init(ctx: PluginContext) { + ctx.getService<{ register(m: unknown): unknown }>('manifest').register({ + id: HOST_PACKAGE_ID, + name: 'host-tenant', + version: '1.0.0', + type: 'app', + pages: [ + { + name: HOST_PAGE, + label: 'Workspace', + type: 'record', + object: 'sys_organization', + isDefault: true, + regions: [], + }, + ], + }); + }, + }; +} + +const authPlugin = (options: Pick = {}) => + new AuthPlugin({ secret: 'test-secret-at-least-32-chars-long', baseUrl: 'http://localhost:3000', ...options }); + +let kernel: ObjectKernel | undefined; +const savedEnv: Partial> = {}; + +beforeEach(() => { + for (const key of ENV_KEYS) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } +}); + +afterEach(async () => { + try { + await kernel?.shutdown(); + } catch { + /* a refused boot leaves the kernel stopped */ + } + kernel = undefined; + for (const key of ENV_KEYS) { + if (savedEnv[key] === undefined) delete process.env[key]; + else process.env[key] = savedEnv[key]; + } +}); + +/** Boot the composition with `plugins` used in the given order; resolves to the engine or the refusal. */ +async function boot(plugins: Plugin[]): Promise<{ ql?: ObjectQL; refusal?: Refusal }> { + await kernel?.shutdown().catch(() => undefined); + kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false }); + await kernel.use(sqliteDriverPlugin()); + await kernel.use(new ObjectQLPlugin()); + for (const p of plugins) await kernel.use(p); + try { + await kernel.bootstrap(); + } catch (e) { + return { refusal: e as Refusal }; + } + return { ql: kernel.getService('objectql') }; +} + +/** Every default record page of `object` in the booted registry: its name and the package that registered it. */ +function defaultsOf(ql: ObjectQL, object: string): Array<{ name: string; packageId: string }> { + return ql.registry + .listItems<{ name: string; _packageId: string }>('page') + .filter((p) => defaultRecordPageObject(p) === object) + .map((p) => ({ name: p.name, packageId: p._packageId })); +} + +const pkgPage = (packageId: string, page: string): Holder => ({ kind: 'package', packageId, page }); + +/** The ADR-0112 envelope of the refusal and the two pages it names. */ +function expectConflict(refusal: Refusal | undefined, incoming: Holder, existing: Holder) { + expect(refusal, 'the boot was refused').toBeDefined(); + expect({ code: refusal!.code, status: refusal!.status }).toEqual({ code: 'DEFAULT_RECORD_PAGE_CONFLICT', status: 422 }); + expect(refusal!.object).toBe('sys_organization'); + expect(refusal!.incoming).toEqual(incoming); + expect(refusal!.existing).toEqual(existing); +} + +describe('registerOrganizationRecordPage: a host with its own sys_organization default record page', () => { + it('pin 1: with the option, the host\'s page is the single default and the boot succeeds, in either order', async () => { + for (const order of ['auth first', 'host first'] as const) { + const auth = authPlugin({ registerOrganizationRecordPage: false }); + const { ql, refusal } = await boot(order === 'auth first' ? [auth, hostPlugin()] : [hostPlugin(), auth]); + + expect(refusal, `${order}: the boot succeeds`).toBeUndefined(); + expect(defaultsOf(ql!, 'sys_organization'), order).toEqual([{ name: HOST_PAGE, packageId: HOST_PACKAGE_ID }]); + expect(ql!.registry.listItems<{ name: string }>('page', AUTH_PLUGIN_ID).map((p) => p.name), order).not.toContain( + AUTH_PAGE, + ); + // `SysUserDetailPage` is unchanged by the option. + expect(defaultsOf(ql!, 'sys_user'), order).toEqual([{ name: 'sys_user_detail', packageId: AUTH_PLUGIN_ID }]); + } + }); + + it('pin 2: without the option, plugin-auth\'s sys_organization_detail registers as today', async () => { + const today = await boot([authPlugin()]); + expect(today.refusal).toBeUndefined(); + expect(defaultsOf(today.ql!, 'sys_organization')).toEqual([{ name: AUTH_PAGE, packageId: AUTH_PLUGIN_ID }]); + expect(defaultsOf(today.ql!, 'sys_user')).toEqual([{ name: 'sys_user_detail', packageId: AUTH_PLUGIN_ID }]); + + // Control: the option, with no page of the host's own, leaves the object + // with no default record page — the reading above is not vacuous. + const omitted = await boot([authPlugin({ registerOrganizationRecordPage: false })]); + expect(omitted.refusal).toBeUndefined(); + expect(defaultsOf(omitted.ql!, 'sys_organization')).toEqual([]); + expect(defaultsOf(omitted.ql!, 'sys_user')).toEqual([{ name: 'sys_user_detail', packageId: AUTH_PLUGIN_ID }]); + }); + + it('pin 3: with both pages and no option, DEFAULT_RECORD_PAGE_CONFLICT still refuses the boot, in either order', async () => { + const authFirst = await boot([authPlugin(), hostPlugin()]); + expect(authFirst.ql).toBeUndefined(); + expectConflict(authFirst.refusal, pkgPage(HOST_PACKAGE_ID, HOST_PAGE), pkgPage(AUTH_PLUGIN_ID, AUTH_PAGE)); + + const hostFirst = await boot([hostPlugin(), authPlugin()]); + expect(hostFirst.ql).toBeUndefined(); + expectConflict(hostFirst.refusal, pkgPage(AUTH_PLUGIN_ID, AUTH_PAGE), pkgPage(HOST_PACKAGE_ID, HOST_PAGE)); + + // Control: an explicit `true` is today's behaviour, refused the same way. + const explicit = await boot([authPlugin({ registerOrganizationRecordPage: true }), hostPlugin()]); + expect(explicit.ql).toBeUndefined(); + expectConflict(explicit.refusal, pkgPage(HOST_PACKAGE_ID, HOST_PAGE), pkgPage(AUTH_PLUGIN_ID, AUTH_PAGE)); + }); +}); From 859822b518d17edb743e8785e31618032a00b564 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 14:26:53 +0000 Subject: [PATCH 3/3] docs(plugin-auth): document registerOrganizationRecordPage; add its changeset Claude-Session: https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ Co-authored-by: Claude --- .../23254-auth-org-record-page-option.md | 14 ++++++++++ packages/plugins/plugin-auth/README.md | 27 +++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 .changeset/23254-auth-org-record-page-option.md diff --git a/.changeset/23254-auth-org-record-page-option.md b/.changeset/23254-auth-org-record-page-option.md new file mode 100644 index 00000000000..c16e7c799a4 --- /dev/null +++ b/.changeset/23254-auth-org-record-page-option.md @@ -0,0 +1,14 @@ +--- +'@objectstack/plugin-auth': minor +--- + +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 + +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`. `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. + +- **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. +- **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. +- **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`. +- **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. diff --git a/packages/plugins/plugin-auth/README.md b/packages/plugins/plugin-auth/README.md index b5d530a6d5d..178e18a4aee 100644 --- a/packages/plugins/plugin-auth/README.md +++ b/packages/plugins/plugin-auth/README.md @@ -106,6 +106,33 @@ The plugin accepts configuration via `AuthConfig` schema from `@objectstack/spec - `plugins` - Enable additional auth features (organization, backend 2FA, passkeys, magic link) - `session` - Session configuration (expiry, update frequency) +### Shipping your own organization record page + +`AuthPlugin` registers the platform's default record page for `sys_organization`: +`sys_organization_detail`, the Members / Invitations / Teams tab strip. 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`. A host that ships its own default record page for +`sys_organization` turns the platform page off, so the host's page is the only default: + +```typescript +new AuthPlugin({ + secret: process.env.AUTH_SECRET, + baseUrl: 'http://localhost:3000', + // The host registers its own `isDefault: true` record page for sys_organization. + registerOrganizationRecordPage: false, +}) +``` + +- `registerOrganizationRecordPage` (default `true`): only an explicit `false` leaves out + `sys_organization_detail`. Nothing else changes: the identity objects, the `sys_user_detail` + record page, the System Overview dashboard, and the auth routes and services are registered + as before. +- Neither plugin order nor any precedence rule decides which page is the default. Without the + option, the platform page and the host's page conflict, and the boot is refused whichever + plugin starts first. +- 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. + ## API Routes 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.