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
14 changes: 14 additions & 0 deletions .changeset/23254-auth-org-record-page-option.md
Original file line number Diff line number Diff line change
@@ -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.
27 changes: 27 additions & 0 deletions packages/plugins/plugin-auth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
48 changes: 47 additions & 1 deletion packages/plugins/plugin-auth/src/auth-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,33 @@ export interface AuthPluginOptions extends Partial<AuthConfig> {
* @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;
}

/**
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.
//
Expand Down
Original file line number Diff line number Diff line change
@@ -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<AuthPluginOptions, 'registerOrganizationRecordPage'> = {}) =>
new AuthPlugin({ secret: 'test-secret-at-least-32-chars-long', baseUrl: 'http://localhost:3000', ...options });

let kernel: ObjectKernel | undefined;
const savedEnv: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>> = {};

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>('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));
});
});
Loading