Skip to content

Commit 96242ef

Browse files
committed
feat(auth): AuthPlugin derives app org roles itself — hosts pass nothing (#3723, cloud#897)
Per-host `additionalOrgRoles` wiring was the defect pattern: five hosts boot AuthPlugin from a stack, three of them (verify harness, DevPlugin, cloud's ArtifactKernelFactory) at some point forgot the parameter, and the failure is silent — app-declared roles are simply absent. cloud is also order-hostile: it mounts AuthPlugin before the app metadata exists, so no init-time walk could ever cover it. AuthPlugin now derives the roles in its own kernel:ready hook — the one point that fires after all metadata is registered in every host — via `collectRegisteredOrgRoles` (the late-bound twin of `collectStackOrgRoles`; same dual read as `bootstrapDeclaredPositions`: engine registry first, metadata-service facade as fallback). Both consumers update from the derived union: - better-auth side: `applyConfigPatch` merges the roles; the instance is built lazily (or reset by the patch), so the org-plugin roles map carries the full set on next use. - select side: `sys_invitation` / `sys_member` are re-registered with widened `role` options under the same package id — an explicitly supported registry path (owner contribution replaced, merge cache invalidated). No DDL: options are validator/picker metadata, the column stays TEXT. serve / verify harness / DevPlugin drop their wiring — deliberately, so the dogfood invite gates only stay green if the auto-derivation works. Explicit `additionalOrgRoles` remains as an override for roles outside stack metadata (unioned with the derived set). One bug found the hard way and worth recording: the hook originally acquired the engine via `ctx.getServiceAsync('objectql')` — a method PluginContext does not have. The optional-chain made that silently yield `undefined`, the metadata facade still fed the better-auth half, and the select half was skipped without an error: derivation logged success while invitations kept failing. PluginContext's sync `getService` is the API. Verified: dogfood app-org-role-invite + delegated-admin-invite gates green with ZERO host wiring (10/10); org-roles unit tests 29 (5 new for collectRegisteredOrgRoles); plugin-auth 603; full dogfood 375 passed.
1 parent a06b30b commit 96242ef

7 files changed

Lines changed: 242 additions & 32 deletions

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
'@objectstack/plugin-auth': minor
3+
'@objectstack/plugin-dev': patch
4+
'@objectstack/verify': patch
5+
'@objectstack/cli': patch
6+
---
7+
8+
feat(auth): AuthPlugin derives app-declared organization roles itself — hosts pass nothing (#3723 follow-up, cloud#897)
9+
10+
Five hosts boot `AuthPlugin` from a stack, and per-host `additionalOrgRoles`
11+
wiring proved to be the defect pattern: three of them (the verify harness,
12+
`DevPlugin`, cloud's `ArtifactKernelFactory`) at some point forgot it, and the
13+
failure is silent — app-declared roles are simply absent. One host (cloud)
14+
mounts `AuthPlugin` before the app metadata even exists, so no init-time walk
15+
could ever cover it.
16+
17+
`AuthPlugin` now derives the roles in its own `kernel:ready` hook — the one
18+
point that fires after all metadata is registered in every host — via the new
19+
`collectRegisteredOrgRoles(engine, metadataService?)` (the late-bound twin of
20+
`collectStackOrgRoles`). Both consumers are updated from the derived union:
21+
better-auth's org-plugin roles map (`applyConfigPatch`; the instance builds
22+
lazily) and the `sys_invitation.role` / `sys_member.role` select options
23+
(re-registration under the same package id — a supported registry path; no
24+
DDL, options are validator/picker metadata).
25+
26+
`objectstack serve`, the `@objectstack/verify` harness and `DevPlugin` no
27+
longer pass `additionalOrgRoles` — deliberately, so the dogfood invite gate
28+
only stays green if the auto-derivation works. The option remains for roles
29+
declared OUTSIDE stack metadata; explicit entries are unioned with the derived
30+
set. `collectStackOrgRoles` stays exported for hosts that want an init-time
31+
walk of a raw stack object.

packages/cli/src/commands/serve.ts

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1396,7 +1396,7 @@ export default class Serve extends Command {
13961396
if (!hasAuthPlugin && tierEnabled('auth')) {
13971397
try {
13981398
const authPkg = '@objectstack/plugin-auth';
1399-
const { AuthPlugin, collectStackOrgRoles } = await import(/* webpackIgnore: true */ authPkg);
1399+
const { AuthPlugin } = await import(/* webpackIgnore: true */ authPkg);
14001400

14011401
// In dev, fall back to a stable local secret so users don't have
14021402
// to set OS_AUTH_SECRET just to try the login/register flow.
@@ -1491,22 +1491,17 @@ export default class Serve extends Command {
14911491
if (!trustedOrigins.includes(wildcard)) trustedOrigins.push(wildcard);
14921492
}
14931493

1494-
// Collect application-defined org roles from the stack so
1495-
// Better-Auth's organization plugin accepts invitations to
1496-
// those names (otherwise it 400s with `ROLE_NOT_FOUND`) AND the
1497-
// `sys_invitation.role` / `sys_member.role` selects accept them on
1498-
// write. #3723: the walk lives in plugin-auth so `serve`, the
1499-
// `@objectstack/verify` harness and any embedder derive the list
1500-
// identically — a second copy of it here is how the harness came to
1501-
// boot without app roles while `serve` had them.
1502-
const additionalOrgRoles = collectStackOrgRoles(config);
1503-
1494+
// App-declared organization roles (positions / permission sets)
1495+
// need no wiring here: AuthPlugin derives them from the registered
1496+
// metadata in its own kernel:ready hook (#3723 / cloud#897) — the
1497+
// per-host walk was the defect pattern (three of five hosts forgot
1498+
// it). `additionalOrgRoles` remains available for roles OUTSIDE
1499+
// the stack metadata, which this host has none of.
15041500
await kernel.use(new AuthPlugin({
15051501
secret,
15061502
baseUrl,
15071503
socialProviders: Object.keys(socialProviders).length > 0 ? socialProviders : undefined,
15081504
trustedOrigins: trustedOrigins.length ? trustedOrigins : undefined,
1509-
...(additionalOrgRoles.length > 0 ? { additionalOrgRoles } : {}),
15101505
// Enable the admin plugin by default so the Setup app's
15111506
// ban/unban/set-password/impersonate/set-role row actions
15121507
// resolve to real endpoints. The plugin self-gates by role

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

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,17 @@ import { runSetInitialPassword } from './set-initial-password.js';
3232
import { runRegisterSsoProviderFromForm, runRegisterSamlProviderFromForm, runRequestDomainVerification, runVerifyDomain } from './register-sso-provider.js';
3333
import { runResendVerificationEmail } from './send-verification-email.js';
3434
import {
35+
AUTH_PLUGIN_ID,
3536
authIdentityObjects,
3637
authPluginManifestHeader,
3738
} from './manifest.js';
38-
import { normalizeAdditionalOrgRoles, withMembershipRoleOptions, type OrgRoleInput } from './org-roles.js';
39+
import {
40+
MEMBERSHIP_ROLE_OBJECTS,
41+
collectRegisteredOrgRoles,
42+
normalizeAdditionalOrgRoles,
43+
withMembershipRoleOptions,
44+
type OrgRoleInput,
45+
} from './org-roles.js';
3946

4047
/**
4148
* Auth Plugin Options
@@ -398,6 +405,74 @@ export class AuthPlugin implements Plugin {
398405
: {}),
399406
});
400407

408+
// [#3723 / cloud#897] Late-bound organization roles — hosts pass nothing.
409+
//
410+
// Five hosts boot AuthPlugin from a stack; three of them (verify harness,
411+
// DevPlugin, cloud's ArtifactKernelFactory) at some point forgot to wire
412+
// `additionalOrgRoles`, and the failure is silent: app-declared roles are
413+
// simply absent, no error anywhere. Per-host wiring is the defect pattern;
414+
// this hook removes the need for it. cloud is also ORDER-hostile: it
415+
// mounts AuthPlugin before the app metadata exists, so no init-time walk
416+
// could ever cover it — `kernel:ready` is the one point that fires after
417+
// all metadata is registered in every host.
418+
//
419+
// What happens when app roles are found beyond the explicit config:
420+
// - better-auth side: `applyConfigPatch` merges them; the instance is
421+
// built lazily (or reset by the patch), so the org-plugin roles map is
422+
// rebuilt with the full set on next use.
423+
// - select side: `sys_invitation` / `sys_member` are re-registered with
424+
// widened `role` options under the SAME package id — an explicitly
425+
// supported re-registration path (the registry replaces the owner
426+
// contribution and invalidates the merge cache). No DDL: options are
427+
// validator/picker metadata, the column stays TEXT.
428+
//
429+
// Explicit `additionalOrgRoles` remains as override/extra — the union is
430+
// what both consumers see, preserving the one-list invariant.
431+
ctx.hook('kernel:ready', async () => {
432+
try {
433+
// PluginContext has no getServiceAsync — the sync getService is the
434+
// API (it throws for unknown names, hence the guard). At kernel:ready
435+
// the engine is long registered in every real boot; `undefined` only
436+
// in mock/Lite kernels, where the metadata facade below still serves
437+
// the better-auth half and the select half has no engine to update.
438+
const engine = (() => {
439+
try { return ctx.getService?.('objectql') as unknown; } catch { return undefined; }
440+
})();
441+
const metadataService = (() => {
442+
try { return ctx.getService?.('metadata') as { list?: (t: string) => unknown } | undefined; }
443+
catch { return undefined; }
444+
})();
445+
if (!engine && !metadataService) return; // mock/Lite boots — nothing to derive from
446+
447+
const registered = await collectRegisteredOrgRoles(engine, metadataService, ctx.logger);
448+
if (registered.length === 0) return;
449+
450+
const known = new Set(additionalOrgRoles.map((d) => d.name));
451+
const discovered = registered.filter((d) => !known.has(d.name));
452+
if (discovered.length === 0) return;
453+
454+
const merged = [...additionalOrgRoles, ...discovered];
455+
this.authManager?.applyConfigPatch({ additionalOrgRoles: merged });
456+
457+
const ql = engine as { registerObject?: (s: unknown, pkg?: string, ns?: string) => string } | undefined;
458+
if (typeof ql?.registerObject === 'function') {
459+
const widened = withMembershipRoleOptions(authIdentityObjects, merged)
460+
.filter((o) => (MEMBERSHIP_ROLE_OBJECTS as readonly string[]).includes((o as { name?: string })?.name ?? ''));
461+
for (const schema of widened) {
462+
ql.registerObject(schema, AUTH_PLUGIN_ID, authPluginManifestHeader.namespace);
463+
}
464+
}
465+
ctx.logger.info('[auth] app-declared organization roles derived from registered metadata', {
466+
discovered: discovered.map((d) => d.name),
467+
total: merged.length,
468+
});
469+
} catch (e) {
470+
// Derivation is additive — a failure leaves the explicit config in
471+
// force, which is exactly the pre-hook behavior. Loud, not fatal.
472+
ctx.logger.warn('[auth] organization-role derivation failed', { error: (e as Error).message });
473+
}
474+
});
475+
401476
ctx.logger.info('Auth Plugin initialized successfully');
402477
}
403478

packages/plugins/plugin-auth/src/org-roles.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { describe, it, expect, vi } from 'vitest';
1414
import { BUILTIN_MEMBERSHIP_ROLE_OPTIONS } from '@objectstack/spec/identity';
1515
import { SysInvitation, SysMember, SysUser } from '@objectstack/platform-objects/identity';
1616
import {
17+
collectRegisteredOrgRoles,
1718
collectStackOrgRoles,
1819
membershipRoleLabel,
1920
membershipRoleOptions,
@@ -225,3 +226,63 @@ describe('#3723 collectStackOrgRoles — one walk for every host', () => {
225226
expect(values(roleOptionsOf(member))).toContain('sales_user');
226227
});
227228
});
229+
230+
describe('#3723/cloud#897 collectRegisteredOrgRoles — the late-bound twin', () => {
231+
it('reads position + permission items from the engine registry', async () => {
232+
const engine = {
233+
_registry: {
234+
listItems: (type: string) =>
235+
type === 'position'
236+
? [{ content: { name: 'sales_rep', label: '销售代表' } }]
237+
: type === 'permission'
238+
? [{ name: 'sales_user' }] // bare item (no content wrapper) — both shapes occur
239+
: [],
240+
},
241+
};
242+
expect(await collectRegisteredOrgRoles(engine)).toEqual([
243+
{ name: 'sales_rep', label: '销售代表' },
244+
{ name: 'sales_user' },
245+
]);
246+
});
247+
248+
it('falls back to the metadata-service facade when the registry has nothing', async () => {
249+
const metadataService = {
250+
list: (type: string) =>
251+
Promise.resolve(type === 'position' ? [{ name: 'ops', label: 'Operations' }] : []),
252+
};
253+
expect(await collectRegisteredOrgRoles(undefined, metadataService)).toEqual([
254+
{ name: 'ops', label: 'Operations' },
255+
]);
256+
});
257+
258+
it('registry wins over the facade per type — no double counting', async () => {
259+
const engine = { _registry: { listItems: (t: string) => (t === 'position' ? [{ name: 'ops' }] : []) } };
260+
const metadataService = {
261+
list: (t: string) => (t === 'position' ? [{ name: 'ops' }, { name: 'ghost' }] : [{ name: 'sales_user' }]),
262+
};
263+
// position served by the registry (facade ignored for it); permission
264+
// empty in the registry → facade serves it.
265+
expect(await collectRegisteredOrgRoles(engine, metadataService)).toEqual([
266+
{ name: 'ops' },
267+
{ name: 'sales_user' },
268+
]);
269+
});
270+
271+
it('normalizes like every other entry point — built-ins and bad names drop', async () => {
272+
const engine = {
273+
_registry: {
274+
listItems: (t: string) =>
275+
t === 'position' ? [{ name: 'owner' }, { name: 'bad.name' }, { name: 'ok_role' }] : [],
276+
},
277+
};
278+
expect(await collectRegisteredOrgRoles(engine)).toEqual([{ name: 'ok_role' }]);
279+
});
280+
281+
it('nothing to read from → empty, never a throw', async () => {
282+
expect(await collectRegisteredOrgRoles(undefined, undefined)).toEqual([]);
283+
expect(await collectRegisteredOrgRoles({}, {})).toEqual([]);
284+
expect(
285+
await collectRegisteredOrgRoles({ _registry: { listItems: () => { throw new Error('boom'); } } }),
286+
).toEqual([]);
287+
});
288+
});

packages/plugins/plugin-auth/src/org-roles.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,3 +292,57 @@ export function collectStackOrgRoles(stack: unknown, logger?: OrgRoleLogger): Or
292292
}
293293
return normalizeAdditionalOrgRoles(entries, logger);
294294
}
295+
296+
/**
297+
* Collect the organization roles REGISTERED in the running kernel — the
298+
* late-bound twin of {@link collectStackOrgRoles}.
299+
*
300+
* `collectStackOrgRoles` needs the raw stack object in the host's hand, which
301+
* is exactly why it kept being forgotten: five hosts booted `AuthPlugin` from
302+
* a stack, and three of them (verify harness, DevPlugin, cloud's
303+
* ArtifactKernelFactory) never wired the walk — a capability silently absent,
304+
* no error anywhere. Worse, one host (cloud) mounts `AuthPlugin` BEFORE the
305+
* app metadata even exists, so no init-time walk could ever cover it.
306+
*
307+
* This variant instead reads the `position` / `permission` metadata already
308+
* registered with the engine, at whatever point it is called — `AuthPlugin`
309+
* calls it from its own `kernel:ready` hook, which in every host fires after
310+
* ALL plugins (and therefore all app metadata) are in. Hosts pass nothing.
311+
*
312+
* Dual read, same as `bootstrapDeclaredPositions`: the engine's
313+
* SchemaRegistry (`_registry.listItems`) is populated by `manifest.register`
314+
* in every boot path; the metadata-service facade is the fallback for boots
315+
* where the registry shape differs.
316+
*/
317+
export async function collectRegisteredOrgRoles(
318+
engine: unknown,
319+
metadataService?: { list?: (type: string) => unknown },
320+
logger?: OrgRoleLogger,
321+
): Promise<OrgRoleDescriptor[]> {
322+
const entries: OrgRoleInput[] = [];
323+
const push = (items: unknown) => {
324+
if (!Array.isArray(items)) return;
325+
for (const raw of items) {
326+
const item = (raw as { content?: unknown })?.content ?? raw;
327+
if (item && typeof (item as { name?: unknown }).name === 'string') {
328+
const { name, label } = item as { name: string; label?: unknown };
329+
entries.push(typeof label === 'string' ? { name, label } : { name });
330+
}
331+
}
332+
};
333+
for (const type of ['position', 'permission']) {
334+
let items: unknown = undefined;
335+
try {
336+
const reg = (engine as { _registry?: { listItems?: (t: string) => unknown } })?._registry;
337+
if (typeof reg?.listItems === 'function') items = reg.listItems(type);
338+
} catch { /* fall through to the facade */ }
339+
if (!Array.isArray(items) || items.length === 0) {
340+
try {
341+
const listed = metadataService?.list?.(type);
342+
items = typeof (listed as Promise<unknown>)?.then === 'function' ? await listed : listed;
343+
} catch { items = undefined; }
344+
}
345+
push(items);
346+
}
347+
return normalizeAdditionalOrgRoles(entries, logger);
348+
}

packages/plugins/plugin-dev/src/dev-plugin.ts

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -522,21 +522,15 @@ export class DevPlugin implements Plugin {
522522
let authMounted = false;
523523
if (enabled('auth')) {
524524
try {
525-
const { AuthPlugin, collectStackOrgRoles } = await import('@objectstack/plugin-auth') as any;
526-
// [#3723] The same stack walk `objectstack serve` and the verify
527-
// harness use. DevPlugin advertises itself as equivalent to the full
528-
// stack, so a stack whose declared positions / permission sets are
529-
// invitable under `serve` must be invitable here too — otherwise
530-
// "equivalent" quietly excludes app-declared organization roles.
531-
// Guarded: an older plugin-auth on disk simply yields none.
532-
const additionalOrgRoles: string[] =
533-
typeof collectStackOrgRoles === 'function'
534-
? collectStackOrgRoles(this.options.stack, ctx.logger)
535-
: [];
525+
const { AuthPlugin } = await import('@objectstack/plugin-auth') as any;
526+
// [#3723] App-declared organization roles need no wiring: AuthPlugin
527+
// derives them from the registered metadata in its own kernel:ready
528+
// hook, so DevPlugin's "equivalent to the full stack" claim holds
529+
// without this host having to remember a parameter (it was one of the
530+
// three hosts that forgot it).
536531
const authPlugin = new AuthPlugin({
537532
secret: this.options.authSecret,
538533
baseUrl: this.options.authBaseUrl,
539-
...(additionalOrgRoles.length > 0 ? { additionalOrgRoles } : {}),
540534
});
541535
this.childPlugins.push(authPlugin);
542536
authMounted = true;

packages/verify/src/harness.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import { ObjectQLPlugin } from '@objectstack/objectql';
2525
import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm';
2626
import { HonoServerPlugin } from '@objectstack/plugin-hono-server';
2727
import { createRestApiPlugin } from '@objectstack/rest';
28-
import { AuthPlugin, collectStackOrgRoles } from '@objectstack/plugin-auth';
28+
import { AuthPlugin } from '@objectstack/plugin-auth';
2929
import { SecurityPlugin } from '@objectstack/plugin-security';
3030
import { SharingServicePlugin } from '@objectstack/plugin-sharing';
3131
import { SettingsServicePlugin, LocalCryptoProvider } from '@objectstack/service-settings';
@@ -182,15 +182,15 @@ export async function bootStack(
182182
// create, ADR-0062 federation, ADR-0086 two-doors). The bootstrap itself is
183183
// covered by plugin-auth unit tests + browser E2E.
184184
//
185-
// [#3723] `additionalOrgRoles` IS derived here, from the same
186-
// `collectStackOrgRoles` walk `objectstack serve` uses. The harness used to
187-
// omit it, so a dogfood proof booted a stack whose declared roles better-auth
188-
// had never heard of — the one surface that drives the real HTTP route was
189-
// blind to the exact class of bug it exists to catch.
185+
// [#3723] App-declared organization roles need no wiring: AuthPlugin derives
186+
// them from the registered metadata in its own kernel:ready hook. The
187+
// harness passing NOTHING is deliberate and is itself part of the proof —
188+
// the dogfood invite gate only stays green if the auto-derivation works,
189+
// which is exactly the guarantee per-host wiring could never give (this
190+
// harness was one of the three hosts that forgot it).
190191
await kernel.use(new AuthPlugin({
191192
secret: opts.authSecret ?? DEFAULT_AUTH_SECRET,
192193
autoDefaultOrganization: false,
193-
additionalOrgRoles: collectStackOrgRoles(config),
194194
}));
195195

196196
// ADR-0062 — datasource connection service (registers 'datasource-connection'),

0 commit comments

Comments
 (0)