From e25c7cdc1f3767c66485ddb3a25c5ff837cb155e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 02:29:18 +0000 Subject: [PATCH 1/7] feat(plugin-auth): the self-registration grant settles on the first membership (WIP) Claude-Session: https://claude.ai/code/session_01KYBqeqPDxig2tPYhHQxYNX Co-authored-by: Claude --- .../plugins/plugin-auth/src/auth-manager.ts | 252 +++++++++++++++--- 1 file changed, 211 insertions(+), 41 deletions(-) diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 7299c18397c..debc765199a 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -1657,6 +1657,11 @@ export class AuthManager { // full rationale. installWebContainerRequestStatePolyfill(); + // [ADR-0131 D3] The self-registration grant settles on the first + // membership: watch `sys_member` inserts on the engine from boot, so a + // claim persisted before a restart settles whichever writer binds it. + this.observeFirstMemberships(config.dataEngine); + // Use provided auth instance if (config.authInstance) { this.auth = config.authInstance; @@ -5630,9 +5635,8 @@ export class AuthManager { * tenant-column object carries when nothing on the write names one: the * Default Organization under `single` — the only organization there is — * and none under a wall, where `tenancy.defaultOrgId()` answers `null`. One - * reading for every such producer here (the self-registration grant and the - * auth mails that are about a user rather than an organization), so they - * cannot disagree about it. `undefined` when no tenancy service is wired or + * reading for every such producer here (the auth mails that are about a user + * rather than an organization), so they cannot disagree about it. `undefined` when no tenancy service is wired or * it cannot answer: the producer then names nothing, as before. */ private async derivableWriteOrganizationId(): Promise { @@ -5652,14 +5656,21 @@ export class AuthManager { } /** - * Land the staged self-registration grant for a freshly created user — the - * `user.create.after` half of an admitted self-registration. Idempotent - * (existing `(user, set)` pair wins), owned by the organization - * {@link derivableWriteOrganizationId} derives — the same target org the - * membership reconciler resolves. Under a wall none is derivable when the - * registrant is created (multi-org self-registration binds no org either), - * so the row is written without one and its fate is the engine's (ADR-0131 - * D9). + * [ADR-0131 D3/D9 — the self-registration grant settles on the first + * membership] The `user.create.after` half of an admitted self-registration. + * The grant is an organization row (D3), so it is written WITH a membership, + * owned by that membership's organization, under every posture: + * + * - the registrant already holds one (`single`, where the reconciler bound + * the Default Organization a moment earlier in the same hook, or a host + * hook that provisioned a personal organization first) → the grant lands + * now, owned by it; + * - it holds none (a wall: the registrant joins or creates an organization + * later, possibly days and restarts later) → the grant is PERSISTED as a + * pending claim ({@link persistSelfRegistrationGrantClaim}) and lands when + * the first `sys_member` row for the user is written + * ({@link settleDeferredSelfRegistrationGrant}). Nothing is written to + * `sys_user_permission_set` without an organization. * * A failure here is reported at the DURABILITY level (`error` when the sink * carries it): the system keeps looking normal from the outside while a @@ -5683,30 +5694,15 @@ export class AuthManager { return; } const sys = withSystemReadContext(engine) as any; - const organizationId = await this.derivableWriteOrganizationId(); - // [ADR-0131 D3/D4] The grant names the declared set BY NAME — the name - // admission asked the security catalog and the activation ledger about — - // so no `sys_permission_set` row is read to write it, and none needs to - // exist. Already held? Asked by the same name: a self-registrant is a - // user created moments ago, so any grant it holds naming this set, in any - // organization, settles the question and no second grant is written. - const existing: any[] = await sys.find('sys_user_permission_set', { - where: { user_id: userId, [GRANT_SET_NAME_FIELD]: staged.setName }, - limit: 1, - }); - if (existing.length > 0) return; - const id = `ups_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`; - await sys.insert('sys_user_permission_set', { - id, - user_id: userId, - [GRANT_SET_NAME_FIELD]: staged.setName, - ...(organizationId ? { organization_id: organizationId } : {}), - }); - this.config.logger?.info?.('[audience] granted the declared self-registration permission set', { - userId, - permissionSet: staged.setName, - organizationId, - }); + // A user created moments ago holds at most the one membership its own + // creation bound, so the first row found is THE first membership. + const memberships: any[] = await sys.find('sys_member', { where: { user_id: userId }, limit: 1 }); + const organizationId = memberships[0]?.organization_id; + if (typeof organizationId === 'string' && organizationId) { + await this.landSelfRegistrationGrant(engine, userId, staged.setName, organizationId); + return; + } + await this.persistSelfRegistrationGrantClaim(engine, userId, staged.setName); } catch (error) { this.reportUngrantedSelfRegistrant( typeof user?.id === 'string' ? user.id : '(unknown)', @@ -5716,6 +5712,178 @@ export class AuthManager { } } + /** + * Write the self-registration grant, owned by `organizationId` — the + * organization of the membership it travels with. Idempotent: a grant the + * user already holds naming this set settles the question. + * + * [ADR-0131 D3/D4] The grant names the declared set BY NAME — the name + * admission asked the security catalog and the activation ledger about — so + * no `sys_permission_set` row is read to write it, and none needs to exist. + * Already held? Asked by the same name, in any organization: the grant is + * the self-registrant's one declared set, so a second membership (or a + * replayed claim) writes no second grant. + */ + private async landSelfRegistrationGrant( + engine: unknown, + userId: string, + setName: string, + organizationId: string, + ): Promise { + const sys = withSystemReadContext(engine as never) as any; + const existing: any[] = await sys.find('sys_user_permission_set', { + where: { user_id: userId, [GRANT_SET_NAME_FIELD]: setName }, + limit: 1, + }); + if (existing.length > 0) return; + const id = `ups_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`; + await sys.insert('sys_user_permission_set', { + id, + user_id: userId, + [GRANT_SET_NAME_FIELD]: setName, + organization_id: organizationId, + }); + this.config.logger?.info?.('[audience] granted the declared self-registration permission set', { + userId, + permissionSet: setName, + organizationId, + }); + } + + /** + * The `identifier` prefix of a pending self-registration grant claim — a + * `sys_verification` row whose identifier is this prefix plus the user id + * and whose `value` is the declared set's name. + * + * Why `sys_verification`, and not a new object or a column: it is the auth + * layer's existing store of one-shot claims keyed on an identifier with an + * expiry (reset tokens, OTPs, magic links), it survives a restart, and it is + * deployment-level — no organization column (ADR-0131 D1, fate 1), so a + * claim held before any organization exists is not an organization-less + * tenant row. Nothing the user row or account records tells a self-registrant + * apart from an invited, operator-created or bootstrap user (admission + * decides that from the request, not from stored state), so the claim is the + * one persisted fact that does. + */ + private static readonly SELF_REG_GRANT_CLAIM_PREFIX = 'self-registration-grant:'; + + /** + * How long a pending claim waits for the first membership. better-auth's + * own verification cleanup deletes expired rows, and an expired claim is + * never honoured, so a registrant who first joins an organization after + * this window holds only the deployment's baseline. + */ + private static readonly SELF_REG_GRANT_CLAIM_TTL_MS = 365 * 24 * 60 * 60 * 1000; + + /** + * Persist the pending grant for a registrant who belongs to no organization + * yet. Refused (reported, nothing written) when the engine offers no + * middleware seam: the claim could never settle there, and a claim nothing + * will consume is the declared≠enforced state this path closes. + */ + private async persistSelfRegistrationGrantClaim(engine: unknown, userId: string, setName: string): Promise { + if (!AuthManager.firstMembershipObservedEngines.has(engine as object)) { + this.reportUngrantedSelfRegistrant( + userId, + setName, + 'the user belongs to no organization yet and the data engine offers no middleware seam on which the ' + + 'first membership could settle the grant', + ); + return; + } + const sys = withSystemReadContext(engine as never) as any; + await sys.insert('sys_verification', { + id: `ver_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`, + identifier: `${AuthManager.SELF_REG_GRANT_CLAIM_PREFIX}${userId}`, + value: setName, + expires_at: new Date(Date.now() + AuthManager.SELF_REG_GRANT_CLAIM_TTL_MS), + }); + this.config.logger?.info?.( + '[audience] the self-registrant belongs to no organization yet — the declared permission set is granted ' + + 'with their first membership', + { userId, permissionSet: setName }, + ); + } + + /** The engines whose `sys_member` inserts {@link observeFirstMemberships} watches. */ + private static readonly firstMembershipObservedEngines = new WeakSet(); + + /** + * Register, once per engine, the middleware that settles a pending + * self-registration grant when a `sys_member` row is written. A data-layer + * seam rather than one hook per membership path, because the first + * membership arrives through several writers — better-auth's organization + * create (the creator as owner) and invitation acceptance, its server-side + * add-member, the membership reconciler, the Default Organization bootstrap + * and host hooks — and every one of them inserts through this engine. + */ + private observeFirstMemberships(engine: unknown): void { + if (!engine || typeof engine !== 'object') return; + const register = (engine as { registerMiddleware?: unknown }).registerMiddleware; + if (typeof register !== 'function') return; + if (AuthManager.firstMembershipObservedEngines.has(engine)) return; + try { + (register as (fn: (opCtx: any, next: () => Promise) => Promise) => void).call( + engine, + async (opCtx: any, next: () => Promise) => { + await next(); + if (opCtx?.object !== 'sys_member') return; + if (opCtx?.operation !== 'insert' && opCtx?.operation !== 'create') return; + const written = opCtx?.result ?? opCtx?.data; + for (const row of Array.isArray(written) ? written : [written]) { + const userId = row?.user_id; + const organizationId = row?.organization_id; + if (typeof userId !== 'string' || !userId) continue; + if (typeof organizationId !== 'string' || !organizationId) continue; + await this.settleDeferredSelfRegistrationGrant(engine, userId, organizationId); + } + }, + ); + AuthManager.firstMembershipObservedEngines.add(engine); + } catch { + // An engine that refuses the registration has no seam: a registrant with + // no organization is then reported ungranted at sign-up, never parked. + } + } + + /** + * Land a pending self-registration grant on the user's first membership, + * owned by that membership's organization. The claim is CONSUMED before the + * grant is written — read, deleted by id, then acted on — so it is honoured + * once; a later membership finds no claim and writes no second grant. + */ + private async settleDeferredSelfRegistrationGrant( + engine: unknown, + userId: string, + organizationId: string, + ): Promise { + let setName: string | undefined; + try { + const sys = withSystemReadContext(engine as never) as any; + const claims: any[] = await sys.find('sys_verification', { + where: { + identifier: `${AuthManager.SELF_REG_GRANT_CLAIM_PREFIX}${userId}`, + expires_at: { $gt: new Date() }, + }, + limit: 1, + }); + const claim = claims[0]; + if (!claim?.id) return; + // `value` is `internal: true`: the engine's read path strips it, so it is + // recovered through the privileged accessor (`internal-field-readback.ts`). + await recoverInternalFieldsForSystemRead(engine as never, 'sys_verification', claim, ['value']); + await sys.delete('sys_verification', { where: { id: claim.id } }); + setName = typeof claim.value === 'string' && claim.value ? claim.value : undefined; + if (!setName) { + this.reportUngrantedSelfRegistrant(userId, '(unreadable)', 'the pending grant claim carries no set name'); + return; + } + await this.landSelfRegistrationGrant(engine, userId, setName, organizationId); + } catch (error) { + this.reportUngrantedSelfRegistrant(userId, setName ?? '(unknown)', (error as Error)?.message ?? String(error)); + } + } + private reportUngrantedSelfRegistrant(userId: string, setName: string, cause: string): void { this.audienceLogError( `[audience] a self-registrant was ADMITTED but the declared permission set '${setName}' was NOT granted ` + @@ -8295,12 +8463,14 @@ export class AuthManager { const hostUserAfter = (host as any)?.user?.create?.after; const membershipReconciler = async (user: any) => { await this.settleMembership(user?.id); - // [#11739] The second half of an admitted SELF-registration: land the - // DECLARED permission-set grant staged by the validateUserInfo gate. - // Same seam as the membership bind (the one every creation path flows - // through), same never-throw posture — but a failure here is reported - // at the durability level, because an admitted-but-ungranted - // self-registrant is the declared≠enforced state this card closes. + // [#11739] The second half of an admitted SELF-registration: settle the + // DECLARED permission-set grant staged by the validateUserInfo gate — + // landed now, owned by the membership the line above bound, or held as + // a claim until the first membership (ADR-0131 D3). Same seam as the + // membership bind (the one every creation path flows through), same + // never-throw posture — but a failure here is reported at the + // durability level, because an admitted-but-ungranted self-registrant + // is the declared≠enforced state this card closes. await this.settleSelfRegistrationGrant(user); }; const userAfter = hostUserAfter From 1f76b87ca91e60be06c8c6f88e60540c6531eb52 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 02:39:08 +0000 Subject: [PATCH 2/7] test(plugin-auth): pin the self-registration grant on the first membership Claude-Session: https://claude.ai/code/session_01KYBqeqPDxig2tPYhHQxYNX Co-authored-by: Claude --- .../plugin-auth/src/audience-posture.test.ts | 16 ++ .../src/producers-name-organization.test.ts | 61 +---- ...egistration-grant-first-membership.test.ts | 224 ++++++++++++++++++ 3 files changed, 249 insertions(+), 52 deletions(-) create mode 100644 packages/plugins/plugin-auth/src/self-registration-grant-first-membership.test.ts diff --git a/packages/plugins/plugin-auth/src/audience-posture.test.ts b/packages/plugins/plugin-auth/src/audience-posture.test.ts index c4ce935adbc..8c35f016d9d 100644 --- a/packages/plugins/plugin-auth/src/audience-posture.test.ts +++ b/packages/plugins/plugin-auth/src/audience-posture.test.ts @@ -122,6 +122,16 @@ const PASSWORD = 'S3cure!Passw0rd-4785'; const bindRowCatalog = (engine: any) => bindTestSecurityCatalog(engine, () => catalogFromTables({ sys_permission_set: engine.tables.get('sys_permission_set') })); +/** + * [ADR-0131 D3] The self-registration grant is written WITH a membership. The + * end-of-chain cases that assert the grant lands at sign-up run `single`: the + * reconciler binds the Default Organization in the same `user.create.after` + * hook, and the grant is owned by it. The walled half (no membership yet, the + * grant held as a claim until the first one) is pinned in + * `self-registration-grant-first-membership.test.ts`. + */ +const SINGLE_TENANCY = () => ({ defaultOrgId: async () => 'org_default' }); + const makeManager = (engine: any, config: Record = {}) => new AuthManager({ secret: SECRET, @@ -731,6 +741,7 @@ describe('end of the chain: better-auth pipeline over the memory engine (#11739) seedExistingUser(engine); seedPermissionSet(engine, 'portal_user'); const manager = makeManager(engine, { + getTenancy: SINGLE_TENANCY, audience: { posture: 'email_domain', allowedEmailDomains: ['acme.com'], @@ -747,6 +758,8 @@ describe('end of the chain: better-auth pipeline over the memory engine (#11739) expect(grants[0].permission_set).toBe('portal_user'); const created = (engine.tables.get('sys_user') ?? []).find((u: any) => u.email === 'user+tag@acme.com'); expect(grants[0].user_id).toBe(created?.id); + // ADR-0131 D3: owned by the membership it travels with. + expect(grants[0].organization_id).toBe('org_default'); }); const refused = await signUp(manager, 'user@mail.acme.com'); // subdomain: NOT implied @@ -764,6 +777,7 @@ describe('end of the chain: better-auth pipeline over the memory engine (#11739) const engine = createMemoryEngine(); seedExistingUser(engine); const manager = makeManager(engine, { + getTenancy: SINGLE_TENANCY, audience: { posture: 'open', selfRegistrationPermissionSet: 'portal_user' }, }); bindTestSecurityCatalog(engine, { permissions: [{ name: 'portal_user' }] }); @@ -838,6 +852,7 @@ describe('end of the chain: better-auth pipeline over the memory engine (#11739) seedExistingUser(engine); seedPermissionSet(engine, 'member_default', { active: false }); const manager = makeManager(engine, { + getTenancy: SINGLE_TENANCY, audience: { posture: 'open', selfRegistrationPermissionSet: 'member_default' }, }); const res = await signUp(manager, 'anyone@anywhere.com'); @@ -852,6 +867,7 @@ describe('end of the chain: better-auth pipeline over the memory engine (#11739) seedExistingUser(engine); seedPermissionSet(engine, 'member_default'); const manager = makeManager(engine, { + getTenancy: SINGLE_TENANCY, audience: { posture: 'open', selfRegistrationPermissionSet: 'member_default' }, }); const res = await signUp(manager, 'anyone@anywhere.com'); diff --git a/packages/plugins/plugin-auth/src/producers-name-organization.test.ts b/packages/plugins/plugin-auth/src/producers-name-organization.test.ts index d51c6fe17fc..5e1eb4a5e18 100644 --- a/packages/plugins/plugin-auth/src/producers-name-organization.test.ts +++ b/packages/plugins/plugin-auth/src/producers-name-organization.test.ts @@ -1,16 +1,19 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [ADR-0131 D9] The writes AuthManager makes on tenant-column objects for a - * USER — the self-registration grant (`sys_user_permission_set`) and the auth - * mails about that user (`sys_email`, through `IEmailService.sendTemplate`) — - * name the one organization they can derive: the Default Organization under - * `single`, which `tenancy.defaultOrgId()` answers, and none under a wall, - * where it answers `null` (the registrant belongs to no organization yet). + * [ADR-0131 D9] The auth mails AuthManager sends about a USER (`sys_email`, + * through `IEmailService.sendTemplate`) name the one organization they can + * derive: the Default Organization under `single`, which + * `tenancy.defaultOrgId()` answers, and none under a wall, where it answers + * `null` (the registrant belongs to no organization yet). * * Under `single` this is the owner the engine derives for an org-less system * insert, so the stamp moves no reader; under a wall the row stays org-less * and its fate is the engine's. Both halves are pinned per writer. + * + * The self-registration grant is no longer one of these writers: it is + * written WITH the registrant's first membership, owned by that membership's + * organization (ADR-0131 D3) — `self-registration-grant-first-membership.test.ts`. */ import { describe, it, expect, vi } from 'vitest'; @@ -109,49 +112,3 @@ describe('[ADR-0131 D9] the auth mails about a user name the derivable organizat expect('organizationId' in walled[0]).toBe(false); }); }); - -/** An engine double recording the grant insert; `find` answers no existing grant. */ -function grantEngine() { - const inserts: Array<[string, Record]> = []; - return { - inserts, - async find() { return []; }, - async insert(object: string, data: Record) { - inserts.push([object, { ...data }]); - return { ...data }; - }, - }; -} - -async function settleGrant(tenancy: Tenancy) { - const engine = grantEngine(); - const errors: string[] = []; - const manager = new AuthManager({ - secret: 'test-secret-at-least-32-chars-long', - baseUrl: 'http://localhost:3000', - dataEngine: engine as never, - getTenancy: () => tenancy, - logger: { error: (m: string) => errors.push(String(m)), warn: () => {}, info: () => {} }, - } as never); - (manager as never as { stageSelfRegistrationGrant: Function }).stageSelfRegistrationGrant(USER.email, 'portal_user'); - await (manager as never as { settleSelfRegistrationGrant: Function }).settleSelfRegistrationGrant(USER); - return { inserts: engine.inserts, errors }; -} - -describe('[ADR-0131 D9] the self-registration grant names the derivable organization', () => { - it('is owned by the Default Organization under single', async () => { - const { inserts, errors } = await settleGrant(SINGLE); - expect(errors).toEqual([]); - expect(inserts).toHaveLength(1); - expect(inserts[0][0]).toBe('sys_user_permission_set'); - expect(inserts[0][1]).toMatchObject({ user_id: USER.id, permission_set: 'portal_user', organization_id: DEFAULT_ORG }); - }); - - it('names none under a wall, where the registrant belongs to no organization yet', async () => { - const { inserts, errors } = await settleGrant(WALLED); - expect(errors).toEqual([]); - expect(inserts).toHaveLength(1); - expect(inserts[0][1]).toMatchObject({ user_id: USER.id, permission_set: 'portal_user' }); - expect('organization_id' in inserts[0][1]).toBe(false); - }); -}); diff --git a/packages/plugins/plugin-auth/src/self-registration-grant-first-membership.test.ts b/packages/plugins/plugin-auth/src/self-registration-grant-first-membership.test.ts new file mode 100644 index 00000000000..59705294e6c --- /dev/null +++ b/packages/plugins/plugin-auth/src/self-registration-grant-first-membership.test.ts @@ -0,0 +1,224 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D3/D9] The self-registration grant settles on the FIRST + * MEMBERSHIP, under every posture: the grant (`sys_user_permission_set`) is an + * organization row, so it is written with a membership and owned by that + * membership's organization — never without one. + * + * - `single`: the reconciler binds the Default Organization in the same + * `user.create.after` hook, just before the grant settles, so a sign-up + * ends with exactly the grant it always had, owned by the Default + * Organization. + * - A wall: the registrant belongs to no organization at sign-up, so sign-up + * writes NO grant; it persists a pending claim (`sys_verification`, a + * deployment-level store) and the first `sys_member` row written for the + * user — creating an organization, accepting an invitation, being added — + * lands the one grant, owned by that organization. A second membership + * writes no second grant. + * + * The engine double runs registered middleware around every insert the way + * `ObjectQL.executeWithMiddleware` does (driver step innermost, `opCtx.result` + * set before the chain unwinds), because the first-membership seam is a + * `sys_member` insert middleware. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { assertEngineDeleteDispatch } from '@objectstack/objectql'; +import { AuthManager } from './auth-manager'; + +const USER = { id: 'usr_registrant', email: 'ada@example.com', name: 'Ada' }; +const SET = 'member_default'; +const CLAIM_IDENTIFIER = `self-registration-grant:${USER.id}`; + +type Tables = Map; +type Mw = (opCtx: any, next: () => Promise) => Promise; + +/** + * An in-memory engine over `tables` (shared across engines to model a store + * that outlives a restart). `withMiddleware: false` models an engine that + * offers no middleware seam. + */ +function memoryEngine(tables: Tables = new Map(), opts: { withMiddleware?: boolean } = {}) { + const rows = (name: string) => { + if (!tables.has(name)) tables.set(name, []); + return tables.get(name)!; + }; + const matches = (row: any, where: Record = {}) => + Object.entries(where).every(([k, v]) => { + if (v && typeof v === 'object' && !(v instanceof Date) && '$gt' in v) { + return new Date(row[k]).getTime() > new Date(v.$gt).getTime(); + } + return row[k] === v; + }); + const middlewares: Mw[] = []; + let seq = 0; + const engine: any = { + tables, + async insert(object: string, data: any) { + const opCtx: any = { object, operation: 'insert', data }; + const chain = async (index: number): Promise => { + if (index < middlewares.length) return middlewares[index](opCtx, () => chain(index + 1)); + const row = { id: data.id ?? `row_${++seq}`, ...data }; + rows(object).push(row); + opCtx.result = { ...row }; + }; + await chain(0); + return opCtx.result; + }, + async find(object: string, q: any = {}) { + let out = rows(object).filter((r) => matches(r, q.where)); + if (typeof q.limit === 'number') out = out.slice(0, q.limit); + return out.map((r) => ({ ...r })); + }, + async delete(object: string, q: any = {}) { + assertEngineDeleteDispatch(q); + const table = rows(object); + const keep = table.filter((r) => !matches(r, q.where)); + tables.set(object, keep); + return table.length - keep.length; + }, + }; + if (opts.withMiddleware !== false) { + engine.registerMiddleware = (fn: Mw) => { + middlewares.push(fn); + }; + } + return engine; +} + +const SINGLE = { defaultOrgId: async () => 'org_default' }; +const WALLED = { defaultOrgId: async () => null }; + +function makeManager(engine: any, tenancy: { defaultOrgId(): Promise }) { + const errors: string[] = []; + const manager = new AuthManager({ + secret: 'test-secret-at-least-32-chars-long', + baseUrl: 'http://localhost:3000', + dataEngine: engine, + getTenancy: () => tenancy, + logger: { error: (m: string) => errors.push(String(m)), warn: () => {}, info: () => {}, debug: () => {} }, + } as never); + return { manager: manager as any, errors }; +} + +/** + * The `user.create.after` composition for an ADMITTED self-registrant: the + * admission gate staged the set, then the hook runs the membership reconciler + * and the grant settle, in that order. + */ +async function signUp(manager: any) { + manager.stageSelfRegistrationGrant(USER.email, SET); + await manager.settleMembership(USER.id); + await manager.settleSelfRegistrationGrant(USER); +} + +const grantsOf = (engine: any) => engine.tables.get('sys_user_permission_set') ?? []; +const claimsOf = (engine: any) => engine.tables.get('sys_verification') ?? []; + +describe('[ADR-0131 D3] under single the grant lands at sign-up, owned by the Default Organization', () => { + it('writes exactly the grant it always had — one row, the Default Organization, no claim', async () => { + const engine = memoryEngine(); + const { manager, errors } = makeManager(engine, SINGLE); + await signUp(manager); + expect(errors).toEqual([]); + expect(engine.tables.get('sys_member')).toMatchObject([{ user_id: USER.id, organization_id: 'org_default' }]); + expect(grantsOf(engine)).toHaveLength(1); + expect(grantsOf(engine)[0]).toMatchObject({ user_id: USER.id, permission_set: SET, organization_id: 'org_default' }); + expect(claimsOf(engine)).toEqual([]); + }); +}); + +describe('[ADR-0131 D3/D9] under a wall the grant waits for the first membership', () => { + it('sign-up writes no grant: it persists one claim, deployment-level, naming the set', async () => { + const engine = memoryEngine(); + const { manager, errors } = makeManager(engine, WALLED); + await signUp(manager); + expect(errors).toEqual([]); + expect(engine.tables.get('sys_member') ?? []).toEqual([]); + expect(grantsOf(engine)).toEqual([]); + expect(claimsOf(engine)).toHaveLength(1); + const claim = claimsOf(engine)[0]; + expect(claim).toMatchObject({ identifier: CLAIM_IDENTIFIER, value: SET }); + expect('organization_id' in claim).toBe(false); + expect(new Date(claim.expires_at).getTime()).toBeGreaterThan(Date.now()); + }); + + it('the first membership lands exactly one grant, owned by its organization, and consumes the claim', async () => { + const engine = memoryEngine(); + const { manager, errors } = makeManager(engine, WALLED); + await signUp(manager); + // Creating an organization: better-auth writes the creator's owner row. + await engine.insert('sys_member', { id: 'mem_1', user_id: USER.id, organization_id: 'org_acme', role: 'owner' }); + expect(errors).toEqual([]); + expect(grantsOf(engine)).toHaveLength(1); + expect(grantsOf(engine)[0]).toMatchObject({ user_id: USER.id, permission_set: SET, organization_id: 'org_acme' }); + expect(claimsOf(engine)).toEqual([]); + }); + + it('a second membership writes no second self-registration grant', async () => { + const engine = memoryEngine(); + const { manager } = makeManager(engine, WALLED); + await signUp(manager); + await engine.insert('sys_member', { id: 'mem_1', user_id: USER.id, organization_id: 'org_acme', role: 'owner' }); + await engine.insert('sys_member', { id: 'mem_2', user_id: USER.id, organization_id: 'org_beta', role: 'member' }); + expect(grantsOf(engine)).toHaveLength(1); + expect(grantsOf(engine)[0].organization_id).toBe('org_acme'); + }); + + it('the claim survives a restart: a fresh manager over the same store settles it on the first membership', async () => { + const tables: Tables = new Map(); + const before = memoryEngine(tables); + await signUp(makeManager(before, WALLED).manager); + expect(claimsOf(before)).toHaveLength(1); + + // A restart: new engine and manager instances, the same stored rows, no + // in-memory stage carried over. + const after = memoryEngine(tables); + const { errors } = makeManager(after, WALLED); + await after.insert('sys_member', { id: 'mem_1', user_id: USER.id, organization_id: 'org_acme', role: 'member' }); + expect(errors).toEqual([]); + expect(grantsOf(after)).toHaveLength(1); + expect(grantsOf(after)[0]).toMatchObject({ user_id: USER.id, permission_set: SET, organization_id: 'org_acme' }); + }); + + it('a membership of a user who holds no claim (invited, operator-created) writes no grant', async () => { + const engine = memoryEngine(); + makeManager(engine, WALLED); + await engine.insert('sys_member', { id: 'mem_1', user_id: 'usr_invited', organization_id: 'org_acme', role: 'member' }); + expect(grantsOf(engine)).toEqual([]); + }); + + it('an expired claim is not honoured', async () => { + const engine = memoryEngine(); + makeManager(engine, WALLED); + engine.tables.set('sys_verification', [ + { id: 'ver_old', identifier: CLAIM_IDENTIFIER, value: SET, expires_at: new Date(Date.now() - 1000) }, + ]); + await engine.insert('sys_member', { id: 'mem_1', user_id: USER.id, organization_id: 'org_acme', role: 'owner' }); + expect(grantsOf(engine)).toEqual([]); + }); + + it('an engine with no middleware seam parks nothing: the registrant is reported ungranted at sign-up', async () => { + const engine = memoryEngine(new Map(), { withMiddleware: false }); + const { manager, errors } = makeManager(engine, WALLED); + await signUp(manager); + expect(grantsOf(engine)).toEqual([]); + expect(claimsOf(engine)).toEqual([]); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain(`'${SET}' was NOT granted`); + }); +}); + +describe('the first-membership seam is registered once per engine', () => { + it('two managers over one engine settle a claim once', async () => { + const engine = memoryEngine(); + const register = vi.spyOn(engine, 'registerMiddleware'); + const { manager } = makeManager(engine, WALLED); + makeManager(engine, WALLED); + expect(register).toHaveBeenCalledTimes(1); + await signUp(manager); + await engine.insert('sys_member', { id: 'mem_1', user_id: USER.id, organization_id: 'org_acme', role: 'owner' }); + expect(grantsOf(engine)).toHaveLength(1); + }); +}); From b7e0ef8d9db7f8814a75eec13ea9ab3aa54b3031 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 02:40:29 +0000 Subject: [PATCH 3/7] test(dogfood): pin the walled self-registration grant on the first organization; changeset Claude-Session: https://claude.ai/code/session_01KYBqeqPDxig2tPYhHQxYNX Co-authored-by: Claude --- .../24343-8b0f-grant-on-first-membership.md | 14 +++ ...nt-first-membership-walled.dogfood.test.ts | 102 ++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 .changeset/24343-8b0f-grant-on-first-membership.md create mode 100644 packages/qa/dogfood/test/self-registration-grant-first-membership-walled.dogfood.test.ts diff --git a/.changeset/24343-8b0f-grant-on-first-membership.md b/.changeset/24343-8b0f-grant-on-first-membership.md new file mode 100644 index 00000000000..698b1e5747d --- /dev/null +++ b/.changeset/24343-8b0f-grant-on-first-membership.md @@ -0,0 +1,14 @@ +--- +"@objectstack/plugin-auth": patch +--- + +fix(plugin-auth): the self-registration grant settles on the registrant's first membership, owned by that organization (ADR-0131 D3) + +Clause-②: no + +The declared self-registration permission set (`auth.audience.selfRegistrationPermissionSet`) is an assignment, and an assignment is an organization row (ADR-0131 D3). It is now written with the registrant's first membership and owned by that membership's organization, under every posture. It is never written without an organization. + +- **`single`: unchanged.** The membership reconciler binds the Default Organization in the same `user.create.after` hook, so a sign-up still ends with one grant, owned by the Default Organization. +- **A wall (`group`, `isolated`).** A self-registrant belongs to no organization at sign-up, so the sign-up writes no `sys_user_permission_set` row. It used to write one with no organization. The grant is held as a pending claim in `sys_verification`, which is deployment-level and survives a restart. The first `sys_member` row written for the user lands exactly one grant, owned by that organization. That row can come from creating an organization (the creator becomes its owner), accepting an invitation, being added by an administrator, or any other membership writer. A later membership writes no second grant. +- **The claim waits 365 days.** A registrant who first joins an organization after that holds only the deployment's baseline. An engine with no middleware seam cannot settle a claim, so there a registrant with no organization is reported ungranted at `error` at sign-up instead. +- Grants already written without an organization by earlier walled sign-ups are not touched here. diff --git a/packages/qa/dogfood/test/self-registration-grant-first-membership-walled.dogfood.test.ts b/packages/qa/dogfood/test/self-registration-grant-first-membership-walled.dogfood.test.ts new file mode 100644 index 00000000000..75d5ae9899c --- /dev/null +++ b/packages/qa/dogfood/test/self-registration-grant-first-membership-walled.dogfood.test.ts @@ -0,0 +1,102 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [ADR-0131 D3/D9] On a walled boot the self-registration grant settles on the +// registrant's FIRST MEMBERSHIP, end to end on the real stack. A stranger who +// self-registers through `POST /auth/sign-up/email` belongs to no organization +// yet, so the sign-up writes NO `sys_user_permission_set` row (the grant is an +// organization row and there is no organization to own it); creating their +// first organization through better-auth's `POST /auth/organization/create` +// writes exactly one, owned by that organization; a second organization writes +// no second self-registration grant. +// +// The `single` half — the sign-up's own grant, owned by the Default +// Organization — is pinned by `signup-organization-census.dogfood.test.ts`. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import crmStack from '@objectstack/example-crm'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; + +const SYS = { context: { isSystem: true } }; +const SET = 'member_default'; +const REGISTRANT = 'walled-registrant@dogfood.test'; +const PASSWORD = 'Walled-Passw0rd!'; + +describe('[ADR-0131 D3] a walled self-registration is granted with its first membership', () => { + let stack: VerifyStack; + let ql: any; + let registrantId: string; + let registrantToken: string; + + const grantsOfRegistrant = async (): Promise>> => + ql.find('sys_user_permission_set', { where: { user_id: registrantId, permission_set: SET }, ...SYS }); + + const createOrganization = async (name: string, slug: string): Promise => { + const res = await stack.apiAs(registrantToken, 'POST', '/auth/organization/create', { name, slug }); + expect(res.status, `organization/create: ${await res.clone().text()}`).toBe(200); + const org = (await res.json()) as { id?: string }; + expect(org.id).toBeTruthy(); + return org.id!; + }; + + beforeAll(async () => { + stack = await bootStack(crmStack as never, { multiTenant: 'posture-only' }); + const adminToken = await stack.signIn(); + ql = await stack.kernel.getServiceAsync('objectql'); + + // An open audience: a stranger may self-register and is owed the declared set. + const opened = await stack.raw('/api/settings/auth', { + method: 'PUT', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${adminToken}` }, + body: JSON.stringify({ + values: { audience_posture: 'open', audience_self_registration_permission_set: SET }, + }), + }); + expect(opened.status, `open the audience: ${await opened.clone().text()}`).toBe(200); + + const signedUp = await stack.api('/auth/sign-up/email', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: REGISTRANT, password: PASSWORD, name: 'Walled Registrant' }), + }); + expect(signedUp.status, `sign-up: ${await signedUp.clone().text()}`).toBe(200); + const registrant = await ql.findOne('sys_user', { where: { email: REGISTRANT }, ...SYS }); + expect(registrant?.id, 'the registrant exists').toBeTruthy(); + registrantId = registrant.id; + + // The open posture sends a verification mail; the link is not what this + // file is about, so the address is marked verified in place before the + // registrant signs in. + await ql.update('sys_user', { id: registrantId, email_verified: true }, SYS); + registrantToken = await stack.signIn(REGISTRANT, PASSWORD); + }, 180_000); + + afterAll(async () => { + await stack?.stop?.(); + }); + + it('guard: the boot is walled, and the registrant belongs to no organization after sign-up', async () => { + const tenancy = await stack.kernel.getServiceAsync<{ posture: string; isolationActive: boolean }>('tenancy'); + expect(tenancy.posture).toBe('isolated'); + expect(tenancy.isolationActive).toBe(true); + expect(await ql.find('sys_member', { where: { user_id: registrantId }, ...SYS })).toEqual([]); + }); + + it('the sign-up wrote no self-registration grant — and no grant without an organization', async () => { + expect(await grantsOfRegistrant()).toEqual([]); + const anyGrant = await ql.find('sys_user_permission_set', { where: { user_id: registrantId }, ...SYS }); + expect(anyGrant.filter((g: any) => g.organization_id == null || g.organization_id === '')).toEqual([]); + }); + + it('creating the first organization lands exactly one grant, owned by it; a second organization lands none', async () => { + const first = await createOrganization('Walled Registrant Co', 'walled-registrant-co'); + const afterFirst = await grantsOfRegistrant(); + expect(afterFirst).toHaveLength(1); + expect(afterFirst[0].organization_id).toBe(first); + + const second = await createOrganization('Walled Registrant Two', 'walled-registrant-two'); + expect(second).not.toBe(first); + const afterSecond = await grantsOfRegistrant(); + expect(afterSecond).toHaveLength(1); + expect(afterSecond[0].organization_id).toBe(first); + }); +}); From 2623d6d6c6a2cdf743ca6c4b7c52982c589a811b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 03:03:29 +0000 Subject: [PATCH 4/7] test(plugin-auth): the D4 grant-by-name pin binds the membership the grant settles with Claude-Session: https://claude.ai/code/session_01KYBqeqPDxig2tPYhHQxYNX Co-authored-by: Claude --- .../plugin-auth/src/find-envelope-limb-removal.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/plugins/plugin-auth/src/find-envelope-limb-removal.test.ts b/packages/plugins/plugin-auth/src/find-envelope-limb-removal.test.ts index 8a6be10697c..027d917ca90 100644 --- a/packages/plugins/plugin-auth/src/find-envelope-limb-removal.test.ts +++ b/packages/plugins/plugin-auth/src/find-envelope-limb-removal.test.ts @@ -440,7 +440,8 @@ describe('#15597 — the blocks driven through their real production entry point * the grant's id from, and commit 9e9f03abe made it refuse a malformed row * rather than drop it (B4 was that row read). [ADR-0131 D4] The grant now * names its set and reads no `sys_permission_set` row at all, so there is no - * candidate row to drop, malformed or otherwise — pinned below. + * candidate row to drop, malformed or otherwise — pinned below. The grant is + * owned by the membership it settles with (D3), so the case binds one first. */ describe('[ADR-0131 D4] settleSelfRegistrationGrant names the set and reads no sys_permission_set row', () => { const USER = { id: 'usr_new', name: 'New Person', email: 'new@corp.example' }; @@ -459,6 +460,9 @@ describe('[ADR-0131 D4] settleSelfRegistrationGrant names the set and reads no s logger: { error: (m: string) => lines.push(String(m)), warn: (m: string) => lines.push(String(m)), info: () => {} }, } as never); (manager as never as { stageSelfRegistrationGrant: Function }).stageSelfRegistrationGrant(USER.email, 'portal_user'); + // The `user.create.after` order: the reconciler binds the Default + // Organization, then the grant settles with that membership (ADR-0131 D3). + await (manager as never as { settleMembership: Function }).settleMembership(USER.id); await (manager as never as { settleSelfRegistrationGrant: Function }).settleSelfRegistrationGrant(USER); const rows = (await engine.find('sys_user_permission_set', { limit: 100 }, SYSTEM)) as Array>; From 4f634614dd5b96980bcaeff039150c4a9b5800e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 03:11:22 +0000 Subject: [PATCH 5/7] fix(plugin-auth): the grant and claim writes state their system context; tenant-audit census regenerated Claude-Session: https://claude.ai/code/session_01KYBqeqPDxig2tPYhHQxYNX Co-authored-by: Claude --- .../docs/permissions/tenant-audit-census.mdx | 50 +++++++++---------- ...08-tenant-audit-write-call-sites.counts.md | 26 +++++----- .../plugins/plugin-auth/src/auth-manager.ts | 29 ++++++----- 3 files changed, 55 insertions(+), 50 deletions(-) diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx index 1777c88cb49..3f71d3bdc5b 100644 --- a/content/docs/permissions/tenant-audit-census.mdx +++ b/content/docs/permissions/tenant-audit-census.mdx @@ -83,7 +83,7 @@ what moved this page's population from 225 to 227; nothing about the two sites changed, only whether this instrument could see them. **The expensive failure direction is a keyword.** Sites whose receiver the author -typed `any` have no type to read, and there are 38 of them — just under a fifth +typed `any` have no type to read, and there are 40 of them — just under a fifth of the population, concentrated in exactly the seed and bootstrap paths this control exists for. Scoring an unreadable receiver as "not an engine" would have dropped every one of them silently, with a clean exit and a smaller number that @@ -122,7 +122,7 @@ are reported as `undecidable` rather than assumed either way. The same holds twice over for the context. An options argument spelled as a literal can be read; one spelled `options`, `{ ...opts }`, or handed through a -forwarding shim cannot, and **53 of the 229 sites are spelled that way**. A +forwarding shim cannot, and **52 of the 231 sites are spelled that way**. A context resolved from an inline literal or a local `const` can be tested for `isSystem`; one arriving from a helper call cannot. @@ -150,8 +150,8 @@ now **0**: nothing on this surface threads a context that provably lacks the fla **"No tenant context" counted sites it had not read.** An options argument the walker could not parse was folded into the same bucket as one it had read and -found empty. That published **61 sites "carrying no tenant context at all"** -when 8 said so and 53 were simply unread — an over-claim in the *alarming* +found empty. That published **60 sites "carrying no tenant context at all"** +when 8 said so and 52 were simply unread — an over-claim in the *alarming* direction, on the very figure this page tells other cards to cite. `carries` is now three-valued, and an unreadable argument can never contribute to the provable count. @@ -187,10 +187,10 @@ reproduce them. Where it disagrees, it disagrees on the page: | carried figure | where it survives | this census | | :--- | :--- | ---: | -| 175 write call sites | quoted in the merged changeset | **229** | -| 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **32** more whose options argument is unreadable | -| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **158 of 229** decidable, **71** undecidable | -| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 125 decidably elevated, 0 decidably not, 96 undecidable | +| 175 write call sites | quoted in the merged changeset | **231** | +| 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **31** more whose options argument is unreadable | +| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **160 of 231** decidable, **71** undecidable | +| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 128 decidably elevated, 0 decidably not, 95 undecidable | | 141 and 132, two independent re-derivations | the card that filed this work | — | **The differences are not reconciled, and deliberately so.** The old census's @@ -200,21 +200,21 @@ be stated is what this instrument counts, which is written above and re-runnable at any commit. Two structural facts do plausibly widen this reading against any hand or regex -one, and both are counted in the generated tables below: the 38 sites reached +one, and both are counted in the generated tables below: the 40 sites reached through an erased (`any`) receiver, and the 52 that name their object through a `const` rather than inline. An instrument that read either the way a person does would report a smaller number and would not say so. The fourth row is the one worth flagging to anyone citing it. **The 135 / 77% figure has no surviving corroboration anywhere in the tree.** This census reads -125 of 229 (55%) as decidably elevated, with 96 more whose elevation is a +128 of 231 (55%) as decidably elevated, with 95 more whose elevation is a run-time fact — so the claim is neither confirmed nor refuted, and the honest answer is that a static reading cannot settle it. -⇒ **Cite `2 / 229`, and say what it is**: the sites whose options argument was +⇒ **Cite `2 / 231`, and say what it is**: the sites whose options argument was READ and holds no tenant context, against a decidably tenancy-enabled object. That is the control's provable yield surface. ⛔ Do not cite it as "the sites -without tenant context" — **32 further sites** have an options argument this +without tenant context" — **31 further sites** have an options argument this cannot read, and they are neither in nor out. {/* BEGIN GENERATED: tenant-audit-census (scripts/tenant-audit-census.mjs) — DO NOT EDIT */} @@ -223,28 +223,28 @@ cannot read, and they are neither in nor out. | what | count | | :--- | ---: | -| write call sites on the application surface | **229** | -| …whose object name is statically decidable | 158 | +| write call sites on the application surface | **231** | +| …whose object name is statically decidable | 160 | | …whose object name is chosen at run time | 71 | -| …against an object with tenancy ENABLED | 157 | +| …against an object with tenancy ENABLED | 159 | | …against an object that declares tenancy off | 1 | -| threading a tenant context | 168 | +| threading a tenant context | 171 | | PROVABLY carrying none (options read, no context key) | **8** | | …of those, against a decidably tenancy-enabled object | **2** | -| options argument UNREADABLE — may or may not carry one | 53 | -| …of those, against a decidably tenancy-enabled object | 32 | -| threading a decidably ELEVATED (`isSystem`) context | 125 | +| options argument UNREADABLE — may or may not carry one | 52 | +| …of those, against a decidably tenancy-enabled object | 31 | +| threading a decidably ELEVATED (`isSystem`) context | 128 | | threading a context that is decidably NOT elevated | 0 | -| threading a context whose elevation is a run-time fact | 96 | +| threading a context whose elevation is a run-time fact | 95 | | how the instrument reached the site | count | | :--- | ---: | | receiver carried a readable engine type | 191 | -| receiver erased, placed by the object NAME | 28 | +| receiver erased, placed by the object NAME | 30 | | receiver erased, placed by an `object: string` PARAMETER | 5 | | receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 | -| object name spelled inline | 106 | +| object name spelled inline | 108 | | object name spelled through a `const` | 52 | | object name is an `object: string` parameter | 7 | | object name is some other run-time expression | 64 | @@ -297,13 +297,13 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-16 at `22b217924`. +Measured on 2026-10-19 at `2623d6d6c`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 657 | +| tracked non-test sources scanned | 670 | | engine-shaped types recognised | 79 | | declared objects in the registry | 116 | -| same-named calls subtracted as non-engine | 165 | +| same-named calls subtracted as non-engine | 166 | {/* END GENERATED: tenant-audit-census */} diff --git a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md index 15309bd214e..7626b4b1f53 100644 --- a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md +++ b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md @@ -33,19 +33,19 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution. | Measure | Value | |---|---:| -| Write call sites | 229 | -| Object name statically decidable | 158 | +| Write call sites | 231 | +| Object name statically decidable | 160 | | Object name chosen at run time | 71 | -| Against a tenancy-enabled object | 157 | +| Against a tenancy-enabled object | 159 | | Against an object declaring tenancy off | 1 | -| Threading a tenant context | 168 | +| Threading a tenant context | 171 | | Provably carrying none | 8 | | …and decidably tenancy-enabled | 2 | -| Options argument unreadable | 53 | -| …and decidably tenancy-enabled | 32 | -| Threading a decidably elevated context | 125 | +| Options argument unreadable | 52 | +| …and decidably tenancy-enabled | 31 | +| Threading a decidably elevated context | 128 | | Threading a decidably non-elevated context | 0 | -| Threading a context of undecidable elevation | 96 | +| Threading a context of undecidable elevation | 95 | ## Subtractions the census could NOT defend — enforced @@ -90,14 +90,14 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-16 at `22b217924`. +Measured on 2026-10-19 at `2623d6d6c`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 657 | +| tracked non-test sources scanned | 670 | | engine-shaped types recognised | 79 | | declared objects in the registry | 116 | -| same-named calls subtracted as non-engine | 165 | +| same-named calls subtracted as non-engine | 166 | ## Every site @@ -133,7 +133,9 @@ Measured on 2026-10-16 at `22b217924`. | `packages/plugins/plugin-auth/src/auth-manager.ts` | `update` | `sys_session` | enabled | options unreadable | 3 | | `packages/plugins/plugin-auth/src/auth-manager.ts` | `update` | `sys_two_factor` | enabled | options unreadable | 1 | | `packages/plugins/plugin-auth/src/auth-manager.ts` | `update` | `sys_user` | enabled | options unreadable | 8 | -| `packages/plugins/plugin-auth/src/auth-manager.ts` | `insert` | `sys_user_permission_set` | enabled | options unreadable | 1 | +| `packages/plugins/plugin-auth/src/auth-manager.ts` | `insert` | `sys_user_permission_set` | enabled | elevated | 1 | +| `packages/plugins/plugin-auth/src/auth-manager.ts` | `delete` | `sys_verification` | enabled | elevated | 1 | +| `packages/plugins/plugin-auth/src/auth-manager.ts` | `insert` | `sys_verification` | enabled | elevated | 1 | | `packages/plugins/plugin-auth/src/auth-plugin.ts` | `update` | `sys_oauth_application` | enabled | PROVABLY NONE | 1 | | `packages/plugins/plugin-auth/src/auth-plugin.ts` | `update` | `sys_user` | enabled | elevated | 1 | | `packages/plugins/plugin-auth/src/auth-plugin.ts` | `update` | `SystemObjectName.USER` | undecidable | elevated | 1 | diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index debc765199a..0ccf501585e 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -5737,12 +5737,11 @@ export class AuthManager { }); if (existing.length > 0) return; const id = `ups_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`; - await sys.insert('sys_user_permission_set', { - id, - user_id: userId, - [GRANT_SET_NAME_FIELD]: setName, - organization_id: organizationId, - }); + await sys.insert( + 'sys_user_permission_set', + { id, user_id: userId, [GRANT_SET_NAME_FIELD]: setName, organization_id: organizationId }, + { context: { isSystem: true } }, + ); this.config.logger?.info?.('[audience] granted the declared self-registration permission set', { userId, permissionSet: setName, @@ -5792,12 +5791,16 @@ export class AuthManager { return; } const sys = withSystemReadContext(engine as never) as any; - await sys.insert('sys_verification', { - id: `ver_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`, - identifier: `${AuthManager.SELF_REG_GRANT_CLAIM_PREFIX}${userId}`, - value: setName, - expires_at: new Date(Date.now() + AuthManager.SELF_REG_GRANT_CLAIM_TTL_MS), - }); + await sys.insert( + 'sys_verification', + { + id: `ver_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`, + identifier: `${AuthManager.SELF_REG_GRANT_CLAIM_PREFIX}${userId}`, + value: setName, + expires_at: new Date(Date.now() + AuthManager.SELF_REG_GRANT_CLAIM_TTL_MS), + }, + { context: { isSystem: true } }, + ); this.config.logger?.info?.( '[audience] the self-registrant belongs to no organization yet — the declared permission set is granted ' + 'with their first membership', @@ -5872,7 +5875,7 @@ export class AuthManager { // `value` is `internal: true`: the engine's read path strips it, so it is // recovered through the privileged accessor (`internal-field-readback.ts`). await recoverInternalFieldsForSystemRead(engine as never, 'sys_verification', claim, ['value']); - await sys.delete('sys_verification', { where: { id: claim.id } }); + await sys.delete('sys_verification', { where: { id: claim.id }, context: { isSystem: true } }); setName = typeof claim.value === 'string' && claim.value ? claim.value : undefined; if (!setName) { this.reportUngrantedSelfRegistrant(userId, '(unreadable)', 'the pending grant claim carries no set name'); From 551848ebe92cce5a3dfa4c8d76f4a171b9159a59 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 03:45:42 +0000 Subject: [PATCH 6/7] chore(engine-double-contract): pin the first-membership test's delete double Claude-Session: https://claude.ai/code/session_01KYBqeqPDxig2tPYhHQxYNX Co-authored-by: Claude --- scripts/engine-double-contract.pinned.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 4ee5784f0e0..aebfa7dea7e 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -3151,6 +3151,11 @@ "verb": "findOne", "pinned": 1 }, + { + "file": "packages/plugins/plugin-auth/src/self-registration-grant-first-membership.test.ts", + "verb": "delete", + "pinned": 1 + }, { "file": "packages/plugins/plugin-auth/src/session-of-record.test.ts", "verb": "delete", From 29b223ed33d3a3e7181d9c3fb87e75e7392d661d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 04:00:40 +0000 Subject: [PATCH 7/7] test(plugin-auth): the first-membership double refuses WHERE combinators Claude-Session: https://claude.ai/code/session_01KYBqeqPDxig2tPYhHQxYNX Co-authored-by: Claude --- ...registration-grant-first-membership.test.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/plugins/plugin-auth/src/self-registration-grant-first-membership.test.ts b/packages/plugins/plugin-auth/src/self-registration-grant-first-membership.test.ts index 59705294e6c..86513c1f7d8 100644 --- a/packages/plugins/plugin-auth/src/self-registration-grant-first-membership.test.ts +++ b/packages/plugins/plugin-auth/src/self-registration-grant-first-membership.test.ts @@ -34,6 +34,17 @@ const CLAIM_IDENTIFIER = `self-registration-grant:${USER.id}`; type Tables = Map; type Mw = (opCtx: any, next: () => Promise) => Promise; +/** Equality, plus the one operator the writer sends (`$gt` on a date); every combinator is refused. */ +function matches(row: any, where: Record = {}): boolean { + return Object.entries(where).every(([k, v]) => { + if (k.startsWith('$')) throw new Error(`memory engine: unsupported combinator ${k}`); + if (v && typeof v === 'object' && !(v instanceof Date) && '$gt' in v) { + return new Date(row[k]).getTime() > new Date(v.$gt).getTime(); + } + return row[k] === v; + }); +} + /** * An in-memory engine over `tables` (shared across engines to model a store * that outlives a restart). `withMiddleware: false` models an engine that @@ -44,13 +55,6 @@ function memoryEngine(tables: Tables = new Map(), opts: { withMiddleware?: boole if (!tables.has(name)) tables.set(name, []); return tables.get(name)!; }; - const matches = (row: any, where: Record = {}) => - Object.entries(where).every(([k, v]) => { - if (v && typeof v === 'object' && !(v instanceof Date) && '$gt' in v) { - return new Date(row[k]).getTime() > new Date(v.$gt).getTime(); - } - return row[k] === v; - }); const middlewares: Mw[] = []; let seq = 0; const engine: any = {