Skip to content

Commit 592e1bd

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-23168-onsuccess-row-executor
2 parents a2bfa8b + 404fc47 commit 592e1bd

4 files changed

Lines changed: 306 additions & 1 deletion

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/plugin-auth': minor
3+
---
4+
5+
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
6+
7+
Clause-②: yes (widening)
8+
9+
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.
10+
11+
- **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.
12+
- **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.
13+
- **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`.
14+
- **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.

‎packages/plugins/plugin-auth/README.md‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,33 @@ The plugin accepts configuration via `AuthConfig` schema from `@objectstack/spec
106106
- `plugins` - Enable additional auth features (organization, backend 2FA, passkeys, magic link)
107107
- `session` - Session configuration (expiry, update frequency)
108108

109+
### Shipping your own organization record page
110+
111+
`AuthPlugin` registers the platform's default record page for `sys_organization`:
112+
`sys_organization_detail`, the Members / Invitations / Teams tab strip. A deployment holds at
113+
most one `isDefault: true` record page per object, and a second one is refused at registration
114+
with `DEFAULT_RECORD_PAGE_CONFLICT`. A host that ships its own default record page for
115+
`sys_organization` turns the platform page off, so the host's page is the only default:
116+
117+
```typescript
118+
new AuthPlugin({
119+
secret: process.env.AUTH_SECRET,
120+
baseUrl: 'http://localhost:3000',
121+
// The host registers its own `isDefault: true` record page for sys_organization.
122+
registerOrganizationRecordPage: false,
123+
})
124+
```
125+
126+
- `registerOrganizationRecordPage` (default `true`): only an explicit `false` leaves out
127+
`sys_organization_detail`. Nothing else changes: the identity objects, the `sys_user_detail`
128+
record page, the System Overview dashboard, and the auth routes and services are registered
129+
as before.
130+
- Neither plugin order nor any precedence rule decides which page is the default. Without the
131+
option, the platform page and the host's page conflict, and the boot is refused whichever
132+
plugin starts first.
133+
- With the option set and no default page of the host's own, the organization record view
134+
opens the synthesized default layout, without the Members / Invitations / Teams tabs.
135+
109136
## API Routes
110137

111138
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.

‎packages/plugins/plugin-auth/src/auth-plugin.ts‎

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,33 @@ export interface AuthPluginOptions extends Partial<AuthConfig> {
253253
* @default false
254254
*/
255255
hostSignInHandoff?: boolean;
256+
257+
/**
258+
* Whether this plugin registers the platform's default record page for
259+
* `sys_organization`: `sys_organization_detail`, the Members / Invitations /
260+
* Teams tab strip (`SysOrganizationDetailPage`, `@objectstack/platform-objects`).
261+
*
262+
* A deployment holds at most ONE `isDefault: true` record page per object; a
263+
* second is refused at registration with `DEFAULT_RECORD_PAGE_CONFLICT`
264+
* (maintainer ruling A on objectstack#23015, record 6134056653). A host that
265+
* ships its OWN default record page for `sys_organization` passes `false`, so
266+
* its page is the single default by composition. Neither registration order
267+
* nor a precedence rule decides it: ruling A takes neither.
268+
*
269+
* `false` removes exactly one registration, `sys_organization_detail`.
270+
* Everything else stays: the `sys_organization` object and every other
271+
* identity object, the `sys_user_detail` record page, the System Overview
272+
* dashboard, and the auth routes and services. The host's page then owns what
273+
* the platform page gave. Set `false` with no default record page of the
274+
* host's own, and `sys_organization`'s record view opens the synthesized
275+
* default layout, without the Members / Invitations / Teams tabs.
276+
*
277+
* Only an explicit `false` removes the page; `true` and an absent key keep
278+
* it. Read from this option alone (no env var, no setting), so the code that
279+
* composes the host's own page is the one place the choice is stated.
280+
* @default true
281+
*/
282+
registerOrganizationRecordPage?: boolean;
256283
}
257284

258285
/**
@@ -647,6 +674,10 @@ export class AuthPlugin implements Plugin {
647674
ctx.registerService('tenancy', tenancy);
648675
this.tenancy = tenancy;
649676

677+
// Only an explicit `false` leaves the organization page out — see
678+
// `AuthPluginOptions.registerOrganizationRecordPage`.
679+
const registerOrganizationRecordPage = this.options.registerOrganizationRecordPage !== false;
680+
650681
ctx.getService<{ register(m: any): void }>('manifest').register({
651682
// The header, `objects` and `objectExtensions`, from the ONE builder
652683
// `IdentityObjectsPlugin` registers on its own in a reduced kernel, so the
@@ -671,7 +702,13 @@ export class AuthPlugin implements Plugin {
671702
// sys_organization gets a Members / Invitations / Teams tab strip
672703
// (see SysOrganizationDetailPage for the rationale and the
673704
// intentionally-omitted OAuth / SSO tabs).
674-
pages: [SysOrganizationDetailPage, SysUserDetailPage],
705+
//
706+
// `registerOrganizationRecordPage: false` leaves the organization page
707+
// out, for a host that ships its own `sys_organization` default record
708+
// page: one default per object per deployment, kept by composition
709+
// (ruling A on objectstack#23015). `SysUserDetailPage` is registered
710+
// either way.
711+
pages: [...(registerOrganizationRecordPage ? [SysOrganizationDetailPage] : []), SysUserDetailPage],
675712
// List views for each Setup-nav object are defined on the schema
676713
// itself via the canonical `listViews` map (e.g.
677714
// sys_user.listViews.{all_users,unverified,two_factor}). Registering
@@ -713,6 +750,15 @@ export class AuthPlugin implements Plugin {
713750
}
714751
: {}),
715752
});
753+
if (!registerOrganizationRecordPage) {
754+
// A deliberate composition, said once at boot so a missing tab strip is
755+
// never left to be diagnosed from its absence.
756+
ctx.logger.info(
757+
'[auth] sys_organization_detail is not registered (registerOrganizationRecordPage: false): the host ' +
758+
'composes its own default record page for sys_organization. Without one, the organization record ' +
759+
'view opens the synthesized default layout, with no Members / Invitations / Teams tabs.',
760+
);
761+
}
716762

717763
// [ADR-0108 / #3723] There is no organization-role derivation any more.
718764
//
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `AuthPluginOptions.registerOrganizationRecordPage`: a host that ships its own
5+
* default record page for `sys_organization` composes without plugin-auth's.
6+
*
7+
* ## The rule this option serves
8+
*
9+
* One `isDefault: true` record page per object per deployment, a second refused
10+
* at registration with `DEFAULT_RECORD_PAGE_CONFLICT` (maintainer ruling A on
11+
* objectstack#23015, record 6134056653). Ruling A keeps one page "by
12+
* overlay-by-name or by composition"; this option is the composition route. It
13+
* is not a precedence ladder and not a registration-order rule: with it set,
14+
* plugin-auth registers no organization page at all, so the host's page is the
15+
* single default whichever plugin initialises first.
16+
*
17+
* ## The three pins, each with its control
18+
*
19+
* 1. With the option (`false`), the host's page is the single default record
20+
* page of `sys_organization` and the boot succeeds, in either plugin order.
21+
* Control: the same composition without the option is refused (pin 3).
22+
* 2. Without it, plugin-auth's `sys_organization_detail` registers as today.
23+
* Control: with the option and no host page, `sys_organization` has no
24+
* default record page, so the assertion can tell the two apart.
25+
* `sys_user_detail` registers in both.
26+
* 3. With both pages and no option, `DEFAULT_RECORD_PAGE_CONFLICT` still
27+
* refuses the boot, naming both pages, in either plugin order. Control: an
28+
* explicit `true` is today's behaviour and is refused the same way.
29+
*
30+
* ## The composition
31+
*
32+
* The cheapest real one: a real `ObjectKernel`, a real ObjectQL engine on
33+
* in-memory SQLite, the real `AuthPlugin`, and a host plugin in the place of a
34+
* product package (the cloud control plane's tenant service is the first such
35+
* host) registering its own organization page through the `manifest` service,
36+
* as `AppPlugin.init()` does. The refusal is the registry's own, reached
37+
* through that door.
38+
*
39+
* ## Resolution
40+
*
41+
* `AuthPlugin` is imported from SOURCE (relative). `ObjectKernel`,
42+
* `ObjectQLPlugin` and `SqlDriver` are the fixed instruments, read from
43+
* `dist/` as this package's other kernel tests read them, and so is
44+
* `@objectstack/spec/ui`'s `defaultRecordPageObject`, the one reading of "the
45+
* default record page of object X" the registry's refusal asks too.
46+
*/
47+
48+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
49+
import { ObjectKernel, type Plugin, type PluginContext } from '@objectstack/core';
50+
import { ObjectQLPlugin, type ObjectQL } from '@objectstack/objectql';
51+
import { SqlDriver } from '@objectstack/driver-sql';
52+
import { defaultRecordPageObject } from '@objectstack/spec/ui';
53+
import { AuthPlugin, type AuthPluginOptions } from './auth-plugin.js';
54+
import { AUTH_PLUGIN_ID } from './manifest.js';
55+
56+
const HOST_PACKAGE_ID = 'com.example.host-tenant';
57+
const HOST_PAGE = 'host_organization_detail';
58+
const AUTH_PAGE = 'sys_organization_detail';
59+
60+
/** Env the auth plugin's boot branches on; cleared per case so the default `single` posture holds. */
61+
const ENV_KEYS = ['OS_TENANCY_POSTURE', 'OS_MULTI_ORG_ENABLED', 'OS_PLATFORM_OWNER_EMAIL'] as const;
62+
63+
type Holder = { kind: 'package'; packageId: string; page: string } | { kind: 'environment'; page: string };
64+
type Refusal = Error & { code?: string; status?: number; object?: string; incoming?: Holder; existing?: Holder };
65+
66+
/** Publishes an in-memory SQLite driver the way a datasource plugin does. */
67+
function sqliteDriverPlugin(): Plugin {
68+
return {
69+
name: 'test.driver.sqlite',
70+
type: 'standard',
71+
version: '1.0.0',
72+
async init(ctx: PluginContext) {
73+
ctx.registerService(
74+
'driver.default',
75+
new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }),
76+
);
77+
},
78+
};
79+
}
80+
81+
/**
82+
* A host shipping its own default record page for `sys_organization`,
83+
* registered through the `manifest` service in `init()` as `AppPlugin.init()`
84+
* does. No edge to `AuthPlugin`, so the order the case uses it in is the order
85+
* the two initialise in.
86+
*/
87+
function hostPlugin(): Plugin {
88+
return {
89+
name: HOST_PACKAGE_ID,
90+
type: 'app',
91+
version: '1.0.0',
92+
dependencies: ['com.objectstack.engine.objectql'],
93+
async init(ctx: PluginContext) {
94+
ctx.getService<{ register(m: unknown): unknown }>('manifest').register({
95+
id: HOST_PACKAGE_ID,
96+
name: 'host-tenant',
97+
version: '1.0.0',
98+
type: 'app',
99+
pages: [
100+
{
101+
name: HOST_PAGE,
102+
label: 'Workspace',
103+
type: 'record',
104+
object: 'sys_organization',
105+
isDefault: true,
106+
regions: [],
107+
},
108+
],
109+
});
110+
},
111+
};
112+
}
113+
114+
const authPlugin = (options: Pick<AuthPluginOptions, 'registerOrganizationRecordPage'> = {}) =>
115+
new AuthPlugin({ secret: 'test-secret-at-least-32-chars-long', baseUrl: 'http://localhost:3000', ...options });
116+
117+
let kernel: ObjectKernel | undefined;
118+
const savedEnv: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>> = {};
119+
120+
beforeEach(() => {
121+
for (const key of ENV_KEYS) {
122+
savedEnv[key] = process.env[key];
123+
delete process.env[key];
124+
}
125+
});
126+
127+
afterEach(async () => {
128+
try {
129+
await kernel?.shutdown();
130+
} catch {
131+
/* a refused boot leaves the kernel stopped */
132+
}
133+
kernel = undefined;
134+
for (const key of ENV_KEYS) {
135+
if (savedEnv[key] === undefined) delete process.env[key];
136+
else process.env[key] = savedEnv[key];
137+
}
138+
});
139+
140+
/** Boot the composition with `plugins` used in the given order; resolves to the engine or the refusal. */
141+
async function boot(plugins: Plugin[]): Promise<{ ql?: ObjectQL; refusal?: Refusal }> {
142+
await kernel?.shutdown().catch(() => undefined);
143+
kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false });
144+
await kernel.use(sqliteDriverPlugin());
145+
await kernel.use(new ObjectQLPlugin());
146+
for (const p of plugins) await kernel.use(p);
147+
try {
148+
await kernel.bootstrap();
149+
} catch (e) {
150+
return { refusal: e as Refusal };
151+
}
152+
return { ql: kernel.getService<ObjectQL>('objectql') };
153+
}
154+
155+
/** Every default record page of `object` in the booted registry: its name and the package that registered it. */
156+
function defaultsOf(ql: ObjectQL, object: string): Array<{ name: string; packageId: string }> {
157+
return ql.registry
158+
.listItems<{ name: string; _packageId: string }>('page')
159+
.filter((p) => defaultRecordPageObject(p) === object)
160+
.map((p) => ({ name: p.name, packageId: p._packageId }));
161+
}
162+
163+
const pkgPage = (packageId: string, page: string): Holder => ({ kind: 'package', packageId, page });
164+
165+
/** The ADR-0112 envelope of the refusal and the two pages it names. */
166+
function expectConflict(refusal: Refusal | undefined, incoming: Holder, existing: Holder) {
167+
expect(refusal, 'the boot was refused').toBeDefined();
168+
expect({ code: refusal!.code, status: refusal!.status }).toEqual({ code: 'DEFAULT_RECORD_PAGE_CONFLICT', status: 422 });
169+
expect(refusal!.object).toBe('sys_organization');
170+
expect(refusal!.incoming).toEqual(incoming);
171+
expect(refusal!.existing).toEqual(existing);
172+
}
173+
174+
describe('registerOrganizationRecordPage: a host with its own sys_organization default record page', () => {
175+
it('pin 1: with the option, the host\'s page is the single default and the boot succeeds, in either order', async () => {
176+
for (const order of ['auth first', 'host first'] as const) {
177+
const auth = authPlugin({ registerOrganizationRecordPage: false });
178+
const { ql, refusal } = await boot(order === 'auth first' ? [auth, hostPlugin()] : [hostPlugin(), auth]);
179+
180+
expect(refusal, `${order}: the boot succeeds`).toBeUndefined();
181+
expect(defaultsOf(ql!, 'sys_organization'), order).toEqual([{ name: HOST_PAGE, packageId: HOST_PACKAGE_ID }]);
182+
expect(ql!.registry.listItems<{ name: string }>('page', AUTH_PLUGIN_ID).map((p) => p.name), order).not.toContain(
183+
AUTH_PAGE,
184+
);
185+
// `SysUserDetailPage` is unchanged by the option.
186+
expect(defaultsOf(ql!, 'sys_user'), order).toEqual([{ name: 'sys_user_detail', packageId: AUTH_PLUGIN_ID }]);
187+
}
188+
});
189+
190+
it('pin 2: without the option, plugin-auth\'s sys_organization_detail registers as today', async () => {
191+
const today = await boot([authPlugin()]);
192+
expect(today.refusal).toBeUndefined();
193+
expect(defaultsOf(today.ql!, 'sys_organization')).toEqual([{ name: AUTH_PAGE, packageId: AUTH_PLUGIN_ID }]);
194+
expect(defaultsOf(today.ql!, 'sys_user')).toEqual([{ name: 'sys_user_detail', packageId: AUTH_PLUGIN_ID }]);
195+
196+
// Control: the option, with no page of the host's own, leaves the object
197+
// with no default record page — the reading above is not vacuous.
198+
const omitted = await boot([authPlugin({ registerOrganizationRecordPage: false })]);
199+
expect(omitted.refusal).toBeUndefined();
200+
expect(defaultsOf(omitted.ql!, 'sys_organization')).toEqual([]);
201+
expect(defaultsOf(omitted.ql!, 'sys_user')).toEqual([{ name: 'sys_user_detail', packageId: AUTH_PLUGIN_ID }]);
202+
});
203+
204+
it('pin 3: with both pages and no option, DEFAULT_RECORD_PAGE_CONFLICT still refuses the boot, in either order', async () => {
205+
const authFirst = await boot([authPlugin(), hostPlugin()]);
206+
expect(authFirst.ql).toBeUndefined();
207+
expectConflict(authFirst.refusal, pkgPage(HOST_PACKAGE_ID, HOST_PAGE), pkgPage(AUTH_PLUGIN_ID, AUTH_PAGE));
208+
209+
const hostFirst = await boot([hostPlugin(), authPlugin()]);
210+
expect(hostFirst.ql).toBeUndefined();
211+
expectConflict(hostFirst.refusal, pkgPage(AUTH_PLUGIN_ID, AUTH_PAGE), pkgPage(HOST_PACKAGE_ID, HOST_PAGE));
212+
213+
// Control: an explicit `true` is today's behaviour, refused the same way.
214+
const explicit = await boot([authPlugin({ registerOrganizationRecordPage: true }), hostPlugin()]);
215+
expect(explicit.ql).toBeUndefined();
216+
expectConflict(explicit.refusal, pkgPage(HOST_PACKAGE_ID, HOST_PAGE), pkgPage(AUTH_PLUGIN_ID, AUTH_PAGE));
217+
});
218+
});

0 commit comments

Comments
 (0)