From bd4354ff16d3fed126a88854af10c5f25632d509 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 01:27:17 +0000 Subject: [PATCH 1/5] fix(plugin-auth): list-user-invitations honours the declared requireEmailVerificationOnInvitation better-auth 1.7.2's listUserInvitations refuses every unverified session unconditionally, while accept / reject / get-invitation read the option AuthManager declares false. Rebuild the endpoint in place on the organization plugin's own endpoints record from the vendor's own options object, with the verification refusal asked against the declared option; the listing stays the vendor's getOrgAdapter(...).listUserInvitations (session email, pending only). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 --- ...-user-invitations-declared-verification.md | 11 + .../plugins/plugin-auth/src/auth-manager.ts | 49 +- ...list-user-invitations-verification.test.ts | 502 ++++++++++++++++++ .../src/list-user-invitations-verification.ts | 206 +++++++ 4 files changed, 766 insertions(+), 2 deletions(-) create mode 100644 .changeset/list-user-invitations-declared-verification.md create mode 100644 packages/plugins/plugin-auth/src/list-user-invitations-verification.test.ts create mode 100644 packages/plugins/plugin-auth/src/list-user-invitations-verification.ts diff --git a/.changeset/list-user-invitations-declared-verification.md b/.changeset/list-user-invitations-declared-verification.md new file mode 100644 index 0000000000..db025d1b21 --- /dev/null +++ b/.changeset/list-user-invitations-declared-verification.md @@ -0,0 +1,11 @@ +--- +"@objectstack/plugin-auth": patch +--- + +`GET /organization/list-user-invitations` now honours the declared `requireEmailVerificationOnInvitation` — the per-user invitation inbox works for the unverified sessions it was declared open to + +`AuthManager` constructs better-auth's organization plugin with `requireEmailVerificationOnInvitation: false` on purpose: without a mailer wired in, nothing can ever verify an invitee, so requiring verification would dead-end every invite flow. The pinned better-auth 1.7.2 reads that option on `accept-invitation`, `reject-invitation` and `get-invitation`, but its `listUserInvitations` handler refuses every unverified session unconditionally. Measured on the real pipeline: the same unverified invitee got `200` from all three id-addressed routes and `403 EMAIL_VERIFICATION_REQUIRED_FOR_INVITATION` from the listing, so on exactly the deployment shape the declaration exists for, an invitee could accept an invitation they were handed but never list it, and the SDK's `organizations.invitations.listMine()` inbox page was empty-by-403 for every user. + +The endpoint is now rebuilt in place on the organization plugin's own `endpoints` record, from the vendor endpoint's own options object (same path, method, query schema and OpenAPI entry), with one predicate changed: the verification refusal is asked against the declared option instead of assumed. The listing itself is still the vendor's own `getOrgAdapter(...).listUserInvitations(sessionEmail)` — invitations addressed to the session's email, pending only — so nothing widens beyond what the same session can already accept one by one. A client-side `?email=` is still refused with the vendor's `400`, and a request with no session keeps the vendor's `400`. + +Declared `true` keeps today's refusal byte-for-byte; an undeclared option keeps the vendor's list-route posture (refuse) rather than re-deriving the vendor-internal default the sibling routes use. No new public error code, no new export from the package entry. diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 574c92703b..04c35c4c88 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -1,6 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import type { Auth, BetterAuthOptions } from 'better-auth'; +import type { OrganizationOptions } from 'better-auth/plugins/organization'; import type { SCIMIdentityState, SCIMTransactionContext } from '@better-auth/scim'; // better-auth value imports (betterAuth + plugins) are deferred via dynamic // import() in getOrCreateAuth() / buildPluginList() so that disabled plugins @@ -72,6 +73,9 @@ import { resetVerifiedOnTwoFactorReenrollment } from './two-factor-reenrollment- import { applyPlatformAdminImpersonation, } from './admin-impersonate-endpoint.js'; +import { + applyDeclaredInvitationVerificationToListing, +} from './list-user-invitations-verification.js'; import { invitationRoleCapFailure, isPlainMemberInvitation, @@ -2792,7 +2796,12 @@ export class AuthManager { // [#8289] Same map, same request lifetime — see the field's doc for why // the before-hook cannot read it back off `ctx`. this.orgRolesMap = customOrgRoles; - return organization({ + // [#16569] Held as a named object rather than an inline literal: the + // rebuilt `/organization/list-user-invitations` endpoint below needs the + // VERY object the vendor plugin was constructed with — it is what the + // vendor's own `getOrgAdapter(ctx.context, options)` reads, and the + // declaration it honours lives on it. + const organizationOptions = { schema: buildOrganizationPluginSchema(), // Enable the team sub-feature so the framework's `sys_team` / // `sys_team_member` tables (already declared in platform-objects) @@ -3168,7 +3177,43 @@ export class AuthManager { console.error(`[AuthManager] sendInvitationEmail failed (swallowed): ${err?.message ?? err}`); } }, - }); + } satisfies OrganizationOptions; + const organizationPlugin: any = organization(organizationOptions); + + // [#16569] `GET /organization/list-user-invitations` — make the vendor's + // listing honour the `requireEmailVerificationOnInvitation: false` + // declared above, the way `accept-invitation`, `reject-invitation` and + // `get-invitation` already do. better-auth 1.7.2's `listUserInvitations` + // refuses every unverified session UNCONDITIONALLY (it never reads the + // option), so on exactly the no-mailer deployment the declaration exists + // for, an invitee could accept an invitation but never list it and the + // SDK's `organizations.invitations.listMine()` inbox was empty-by-403. + // Rebuilt IN PLACE on this plugin's own endpoints record — the same + // shape as `applyPlatformAdminImpersonation` above, for the same reasons + // (one owner for the path; every hook keyed on it still fires; the + // request contract is the vendor's own options object, never a copy). + // The listing itself stays the vendor's `getOrgAdapter(...) + // .listUserInvitations(sessionEmail)`: no second definition of which + // rows a session may see. `list-user-invitations-verification.ts` + // carries the full reading. + const listingRewired = await applyDeclaredInvitationVerificationToListing( + organizationPlugin, + organizationOptions, + ); + if (!listingRewired) { + // The vendor renamed or dropped the endpoint. Say so loudly: the + // route then falls back to the vendor's own handler, which refuses + // every unverified session — an empty inbox, not an open door. + console.error( + '[AuthManager] better-auth\'s organization plugin no longer exposes a ' + + '`listUserInvitations` endpoint at /organization/list-user-invitations, ' + + 'so the declared `requireEmailVerificationOnInvitation` could NOT be ' + + 'applied to the invitation inbox. Unverified users will be refused ' + + '(403 EMAIL_VERIFICATION_REQUIRED_FOR_INVITATION) until ' + + 'list-user-invitations-verification.ts is updated for the new vendor shape.', + ); + } + return organizationPlugin; }); } diff --git a/packages/plugins/plugin-auth/src/list-user-invitations-verification.test.ts b/packages/plugins/plugin-auth/src/list-user-invitations-verification.test.ts new file mode 100644 index 0000000000..a2ce7d3c64 --- /dev/null +++ b/packages/plugins/plugin-auth/src/list-user-invitations-verification.test.ts @@ -0,0 +1,502 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#16569] `GET /organization/list-user-invitations` must honour the declared +// `requireEmailVerificationOnInvitation: false`, exactly as `accept-invitation`, +// `reject-invitation` and `get-invitation` already do. +// +// These run the REAL better-auth pipeline through a REAL `AuthManager` — the +// organization plugin, the audience-gate invitation carve-out, the membership +// reconciler and the ObjectQL adapter — the same shape as +// `accept-invitation-adopt-membership.test.ts`. Nothing on the listing path is +// stubbed: the rows come back through the vendor's own `getOrgAdapter(...) +// .listUserInvitations(email)`, which is the one definition of "which +// invitations may this session see" — this suite pins that the rebuilt route +// never widens it (own email only, no client-side `?email=`). +// +// The premise the fix rests on is MEASURED here, not quoted: the same +// unverified invitee already gets 200 from accept / reject / get-invitation on +// this deployment shape, so a listing scoped to the session's own email grants +// nothing the deployment has not already granted. +// +// ## One fixture fact every inbox assertion has to know +// +// Fixture users beyond the first enter through the audience gate's invitation +// carve-out (`audience-gate-test-support.ts`), which seeds a PENDING +// `sys_invitation` row addressed to the sign-up email under +// `org_audience_gate`. That row is a real pending invitation to the very email +// being listed, so it is CORRECTLY in the invitee's inbox — the suite counts +// it as B's (it strengthens the own-email pin) and excludes it only where an +// exact id set is compared. + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { readFileSync, existsSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; +import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/objectql'; +import { AuthManager } from './auth-manager'; +import { inviteForAudienceGate } from './audience-gate-test-support'; +import { + EMAIL_VERIFICATION_REQUIRED_FOR_INVITATION, + LIST_USER_INVITATIONS_PATH, + applyDeclaredInvitationVerificationToListing, + listingRequiresVerifiedEmail, +} from './list-user-invitations-verification'; + +const SECRET = 'test-secret-at-least-32-chars-long!!'; +const BASE = 'http://localhost:3000'; +const DEFAULT_ORG = 'org_default'; +const PARTNER_ORG = 'org_partner'; +/** The org the audience-gate carve-out seeds its row under (see header). */ +const AUDIENCE_GATE_ORG = 'org_audience_gate'; +const PASSWORD = 'S3cure!Passw0rd-16569'; + +/** The minimal engine double the other end-to-end auth-manager suites use. */ +const createMemoryEngine = () => { + const tables = new Map(); + const rows = (name: string) => { + if (!tables.has(name)) tables.set(name, []); + return tables.get(name)!; + }; + const eq = (a: any, b: any) => + a instanceof Date || b instanceof Date + ? new Date(a as any).getTime() === new Date(b as any).getTime() + : a === b; + const matches = (row: any, where: Record = {}) => + Object.entries(where).every(([k, v]) => { + if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); + const actual = row[k]; + if (v && typeof v === 'object' && !Array.isArray(v) && !(v instanceof Date)) { + if ('$ne' in v) return !eq(actual, v.$ne); + if ('$in' in v) return (v.$in as any[]).some((x) => eq(actual, x)); + if ('$gt' in v) return actual > v.$gt; + if ('$gte' in v) return actual >= v.$gte; + if ('$lt' in v) return actual < v.$lt; + if ('$lte' in v) return actual <= v.$lte; + if ('$regex' in v) return new RegExp(String(v.$regex)).test(String(actual ?? '')); + } + return eq(actual, v); + }); + + let seq = 0; + return { + tables, + async insert(name: string, data: any) { + const row = { id: data.id ?? `row_${++seq}`, ...data }; + rows(name).push(row); + return { ...row }; + }, + async findOne(name: string, q: any = {}) { + assertEngineFindOnePredicate(name, q); + const found = rows(name).find((r) => matches(r, q.where)); + return found ? { ...found } : null; + }, + async find(name: string, q: any = {}) { + let out = rows(name).filter((r) => matches(r, q.where)); + const order = q.orderBy?.[0]; + if (order) { + out = [...out].sort( + (a, b) => (a[order.field] > b[order.field] ? 1 : -1) * (order.order === 'desc' ? -1 : 1), + ); + } + if (q.offset) out = out.slice(q.offset); + if (q.limit) out = out.slice(0, q.limit); + return out.map((r) => ({ ...r })); + }, + async count(name: string, q: any = {}) { + return rows(name).filter((r) => matches(r, q.where)).length; + }, + async update(name: string, patch: any, options?: any) { + assertEngineUpdateDispatch(patch, options); + const row = rows(name).find((r) => r.id === patch.id); + if (!row) return null; + Object.assign(row, patch); + return { ...row }; + }, + async delete(name: string, q: any = {}) { + assertEngineDeleteDispatch(q); + const table = rows(name); + const keep = table.filter((r) => !matches(r, q.where)); + tables.set(name, keep); + return table.length - keep.length; + }, + }; +}; + +type MemoryEngine = ReturnType; + +const singleOrgTenancy = () => + ({ + posture: 'single', + requestedPosture: 'single', + isolationActive: false, + requested: false, + degraded: false, + defaultOrgId: async () => DEFAULT_ORG, + }) as any; + +const makeManager = (engine: MemoryEngine) => + new AuthManager({ + secret: SECRET, + baseUrl: BASE, + dataEngine: engine as any, + membershipPolicy: 'auto', + getTenancy: () => singleOrgTenancy(), + }); + +const cookieFrom = (response: Response): string => + (response.headers.getSetCookie?.() ?? [response.headers.get('set-cookie') ?? '']) + .map((c) => c.split(';')[0]) + .filter(Boolean) + .join('; '); + +const post = (manager: AuthManager, path: string, body: unknown, cookie?: string) => + manager.handleRequest( + new Request(`${BASE}/api/v1/auth${path}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(cookie ? { cookie } : {}), + }, + body: JSON.stringify(body), + }), + ); + +const get = (manager: AuthManager, path: string, cookie?: string) => + manager.handleRequest( + new Request(`${BASE}/api/v1/auth${path}`, { + method: 'GET', + headers: cookie ? { cookie } : {}, + }), + ); + +/** + * Sign a user up and return their session cookie + user id. No mailer is wired + * (the deployment shape the declared option exists for), so the account stays + * UNVERIFIED — asserted, because that is the whole population of this suite. + */ +const signUp = async (manager: AuthManager, engine: MemoryEngine, email: string) => { + inviteForAudienceGate(engine, email); + const res = await post(manager, '/sign-up/email', { email, password: PASSWORD, name: email }); + expect(res.status, await res.clone().text()).toBe(200); + const user = (engine.tables.get('sys_user') ?? []).find((u) => u.email === email); + expect(user, `sign-up did not create ${email}`).toBeDefined(); + expect(Boolean(user!.email_verified), `${email} should be unverified (no mailer)`).toBe(false); + return { cookie: cookieFrom(res), userId: String(user!.id) }; +}; + +const membersOf = (engine: MemoryEngine, organizationId: string, userId: string) => + (engine.tables.get('sys_member') ?? []).filter( + (m) => m.organization_id === organizationId && m.user_id === userId, + ); + +const setRole = (engine: MemoryEngine, organizationId: string, userId: string, role: string) => { + const [row] = membersOf(engine, organizationId, userId); + expect(row, 'no membership to promote — the reconciler did not bind').toBeDefined(); + row!.role = role; +}; + +const seedOrganizations = (engine: MemoryEngine) => { + engine.tables.set('sys_organization', [ + { id: DEFAULT_ORG, name: 'Default', slug: 'default' }, + { id: PARTNER_ORG, name: 'Partner', slug: 'partner' }, + ]); +}; + +/** Invite `email` into `organizationId` as the owner; returns the vendor's row. */ +const invite = async ( + manager: AuthManager, + ownerCookie: string, + email: string, + organizationId: string, +) => { + const res = await post( + manager, + '/organization/invite-member', + { email, role: 'member', organizationId }, + ownerCookie, + ); + expect(res.status, await res.clone().text()).toBe(200); + const body: any = await res.json(); + expect(body.id, 'invite-member answered without an id').toBeTruthy(); + return body as { id: string; email: string; organizationId: string; status: string }; +}; + +/** The inbox rows that are NOT the audience-gate seed — for exact id comparisons. */ +const issuedRows = (body: any[]) => body.filter((i) => i.organizationId !== AUDIENCE_GATE_ORG); + +/** + * Bring up a deployment with an owner of BOTH organizations who can issue + * invitations into either. The owner is unverified too (no mailer), which is + * what makes the owner's own `/invite-member` 200s a second reading of the + * premise: invitation-family routes are already open to unverified sessions + * on this shape. + */ +const bootWithOwner = async () => { + const engine = createMemoryEngine(); + seedOrganizations(engine); + const manager = makeManager(engine); + const owner = await signUp(manager, engine, 'owner@example.com'); + setRole(engine, DEFAULT_ORG, owner.userId, 'owner'); + await engine.insert('sys_member', { + id: 'mem_owner_partner', + organization_id: PARTNER_ORG, + user_id: owner.userId, + role: 'owner', + created_at: new Date(), + }); + return { engine, manager, owner }; +}; + +/** + * The fixture every test reads: two invitations addressed to B (one per + * organization) and one addressed to C — the row that must NEVER appear in + * B's inbox. Invitations are issued BEFORE the invitees exist, because + * `invite-member` refuses an address that is already a member of the target + * org, and B's sign-up auto-binds B to the default org. + */ +const bootInbox = async () => { + const { engine, manager, owner } = await bootWithOwner(); + const bDefault = await invite(manager, owner.cookie, 'b@example.com', DEFAULT_ORG); + const bPartner = await invite(manager, owner.cookie, 'b@example.com', PARTNER_ORG); + const cPartner = await invite(manager, owner.cookie, 'c@example.com', PARTNER_ORG); + const b = await signUp(manager, engine, 'b@example.com'); + return { engine, manager, owner, b, bDefault, bPartner, cPartner }; +}; + +describe('#16569 — list-user-invitations honours the declared requireEmailVerificationOnInvitation', () => { + beforeEach(() => { + vi.spyOn(console, 'info').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('PREMISE (measured, not quoted): the same unverified invitee already gets 200 from accept, reject and get-invitation', async () => { + const { engine, manager, b, bDefault, bPartner } = await bootInbox(); + + // get-invitation — the vendor reads the option here. + const got = await get(manager, `/organization/get-invitation?id=${bPartner.id}`, b.cookie); + expect(got.status, await got.clone().text()).toBe(200); + + // reject — reads the option. + const rejected = await post( + manager, + '/organization/reject-invitation', + { invitationId: bPartner.id }, + b.cookie, + ); + expect(rejected.status, await rejected.clone().text()).toBe(200); + + // accept — reads the option. Accepting is strictly stronger than listing: + // it writes a membership. + const accepted = await post( + manager, + '/organization/accept-invitation', + { invitationId: bDefault.id }, + b.cookie, + ); + expect(accepted.status, await accepted.clone().text()).toBe(200); + expect(membersOf(engine, DEFAULT_ORG, b.userId)).toHaveLength(1); + + // Still unverified after all three — the option, not a verification, let + // them through. + const user = (engine.tables.get('sys_user') ?? []).find((u) => u.id === b.userId); + expect(Boolean(user?.email_verified)).toBe(false); + }); + + it('the reported bug: the unverified invitee can LIST the invitations they can already accept', async () => { + const { manager, b, bDefault, bPartner } = await bootInbox(); + + const res = await get(manager, LIST_USER_INVITATIONS_PATH, b.cookie); + // Before the fix: 403 EMAIL_VERIFICATION_REQUIRED_FOR_INVITATION. + expect(res.status, await res.clone().text()).toBe(200); + const body: any = await res.json(); + expect(Array.isArray(body), 'the vendor route answers a bare array').toBe(true); + expect(issuedRows(body).map((i: any) => i.id).sort()).toEqual([bDefault.id, bPartner.id].sort()); + for (const inv of body) { + expect(inv.email).toBe('b@example.com'); + expect(inv.status).toBe('pending'); + } + }); + + it('SCOPE PIN: the listing is the session email\'s own — another user\'s invitation is never visible', async () => { + const { manager, b, cPartner } = await bootInbox(); + + const res = await get(manager, LIST_USER_INVITATIONS_PATH, b.cookie); + expect(res.status, await res.clone().text()).toBe(200); + const body: any[] = await res.json(); + expect(body.length).toBeGreaterThan(0); + expect(body.some((i) => i.id === cPartner.id), 'C\'s invitation leaked into B\'s inbox').toBe(false); + expect(body.some((i) => i.email === 'c@example.com')).toBe(false); + // Every row — the audience-gate seed included — is addressed to B. + expect(body.every((i) => i.email === 'b@example.com')).toBe(true); + }); + + it('SCOPE PIN: a client-side `?email=` is still refused with the vendor\'s 400 — no listing by arbitrary address', async () => { + const { manager, b } = await bootInbox(); + + const res = await get( + manager, + `${LIST_USER_INVITATIONS_PATH}?email=${encodeURIComponent('c@example.com')}`, + b.cookie, + ); + expect(res.status, await res.clone().text()).toBe(400); + const body: any = await res.json(); + expect(String(body.message)).toMatch(/cannot be passed for client side/i); + }); + + it('SCOPE PIN: no session and no email keeps the vendor\'s 400 (the route was never anonymous)', async () => { + const { manager } = await bootInbox(); + const res = await get(manager, LIST_USER_INVITATIONS_PATH); + expect(res.status, await res.clone().text()).toBe(400); + }); + + it('only PENDING rows are listed — a rejected invitation drops out, as it does for a verified user', async () => { + const { manager, b, bDefault, bPartner } = await bootInbox(); + + const rejected = await post( + manager, + '/organization/reject-invitation', + { invitationId: bPartner.id }, + b.cookie, + ); + expect(rejected.status, await rejected.clone().text()).toBe(200); + + const res = await get(manager, LIST_USER_INVITATIONS_PATH, b.cookie); + expect(res.status, await res.clone().text()).toBe(200); + const body: any[] = await res.json(); + expect(issuedRows(body).map((i) => i.id)).toEqual([bDefault.id]); + }); + + it('PARITY: the unverified answer is byte-for-byte the VERIFIED answer — the option grants exactly the verified listing, nothing more', async () => { + const { engine, manager, b, bDefault, bPartner } = await bootInbox(); + + const unverified = await get(manager, LIST_USER_INVITATIONS_PATH, b.cookie); + expect(unverified.status, await unverified.clone().text()).toBe(200); + const unverifiedBody: any[] = await unverified.json(); + + const user = (engine.tables.get('sys_user') ?? []).find((u) => u.id === b.userId); + user!.email_verified = true; + + const verified = await get(manager, LIST_USER_INVITATIONS_PATH, b.cookie); + expect(verified.status, await verified.clone().text()).toBe(200); + const verifiedBody: any[] = await verified.json(); + + expect(unverifiedBody).toEqual(verifiedBody); + expect(issuedRows(verifiedBody).map((i) => i.id).sort()).toEqual([bDefault.id, bPartner.id].sort()); + }); +}); + +describe('#16569 — listingRequiresVerifiedEmail honours ONLY the declared option', () => { + it('declared false → not required (the deployment shape the option exists for)', () => { + expect(listingRequiresVerifiedEmail({ requireEmailVerificationOnInvitation: false })).toBe(false); + }); + + it('declared true → required, exactly as the vendor answers today', () => { + expect(listingRequiresVerifiedEmail({ requireEmailVerificationOnInvitation: true })).toBe(true); + }); + + it('undeclared → required: the vendor\'s own list-route posture, never a re-derivation of its id-generation heuristic', () => { + expect(listingRequiresVerifiedEmail({})).toBe(true); + expect(listingRequiresVerifiedEmail({ requireEmailVerificationOnInvitation: undefined })).toBe(true); + }); + + it('a non-boolean is not a declaration → required (fail closed)', () => { + expect(listingRequiresVerifiedEmail({ requireEmailVerificationOnInvitation: 'false' as any })).toBe(true); + expect(listingRequiresVerifiedEmail({ requireEmailVerificationOnInvitation: 0 as any })).toBe(true); + }); +}); + +describe('#16569 — the rebuilt endpoint is the vendor\'s contract with one predicate changed', () => { + it('replaces the endpoint IN PLACE, under the vendor\'s key, from the vendor\'s own options object', async () => { + const { organization } = await import('better-auth/plugins/organization'); + const options = { requireEmailVerificationOnInvitation: false as const }; + const plugin: any = organization(options); + const vendor = plugin.endpoints.listUserInvitations; + expect(vendor?.path).toBe(LIST_USER_INVITATIONS_PATH); + const keysBefore = Object.keys(plugin.endpoints).sort(); + + await expect(applyDeclaredInvitationVerificationToListing(plugin, options)).resolves.toBe(true); + + const rebuilt = plugin.endpoints.listUserInvitations; + expect(rebuilt).not.toBe(vendor); + expect(rebuilt.path).toBe(LIST_USER_INVITATIONS_PATH); + // The SAME options object — no second copy of the request contract exists. + expect(rebuilt.options).toBe(vendor.options); + // No endpoint added, none dropped: one owner for the path. + expect(Object.keys(plugin.endpoints).sort()).toEqual(keysBefore); + }); + + it('a plugin without the endpoint is left untouched and reported (the vendor-drift door)', async () => { + const plugin = { id: 'organization', endpoints: { listInvitations: { path: '/organization/list-invitations', options: {} } } }; + await expect(applyDeclaredInvitationVerificationToListing(plugin, {})).resolves.toBe(false); + expect(Object.keys(plugin.endpoints)).toEqual(['listInvitations']); + }); + + it('the locally restated refusal is the vendor\'s own $ERROR_CODES entry', async () => { + const { organization } = await import('better-auth/plugins/organization'); + const plugin: any = organization({}); + expect(plugin.$ERROR_CODES?.EMAIL_VERIFICATION_REQUIRED_FOR_INVITATION).toEqual( + EMAIL_VERIFICATION_REQUIRED_FOR_INVITATION, + ); + }); +}); + +// --------------------------------------------------------------------------- +// The vendor still has the defect — the pin that retires this module +// --------------------------------------------------------------------------- + +/** + * Locate this package by walking up from the CWD — the idiom + * `member-role-canonical.test.ts` uses here and states the reason for: + * plugin-auth is CJS-typed (no `"type": "module"`), so under + * `module: NodeNext` `import.meta` is a TS1470 in this package. + */ +function findUp(predicate: (dir: string) => boolean, what: string): string { + let dir = process.cwd(); + for (;;) { + if (predicate(dir)) return dir; + const parent = dirname(dir); + if (parent === dir) throw new Error(`could not locate ${what}`); + dir = parent; + } +} + +const PKG = findUp((dir) => { + const manifest = join(dir, 'package.json'); + if (!existsSync(manifest)) return false; + const { name } = JSON.parse(readFileSync(manifest, 'utf8')) as { name?: string }; + return name === '@objectstack/plugin-auth'; +}, 'the @objectstack/plugin-auth package root'); + +// Resolved from THIS package, so the file read is the better-auth this package +// is pinned to — not whatever a hoist happens to put at the repo root. +const require_ = createRequire(join(PKG, 'probe.js')); +const VENDOR_DIST = dirname(require_.resolve('better-auth')); +const CRUD_INVITES = join(VENDOR_DIST, 'plugins', 'organization', 'routes', 'crud-invites.mjs'); + +describe('#16569 — vendor pin: better-auth\'s listUserInvitations still refuses unconditionally', () => { + const source = readFileSync(CRUD_INVITES, 'utf8'); + const start = source.indexOf('const listUserInvitations = '); + const listing = start >= 0 ? source.slice(start) : ''; + + it('positive control: the file read is the invite-route module and declares the route', () => { + expect(start, 'listUserInvitations declaration not found — vendor file moved?').toBeGreaterThanOrEqual(0); + expect(listing).toContain('"/organization/list-user-invitations"'); + }); + + it('the three id-addressed siblings still ask the option, and the listing still does not', () => { + // Siblings: accept / reject / get-invitation — one call each. + const siblingCalls = source.slice(0, start).match(/shouldRequireVerifiedEmailForInvitationIdAction\(\{/g) ?? []; + expect(siblingCalls).toHaveLength(3); + // The listing: the unconditional refusal, verbatim. When upstream makes + // this line read the option, this pin goes red and + // `list-user-invitations-verification.ts` is what should be deleted. + expect(listing).toMatch( + /if \(session && !session\.user\.emailVerified\) throw APIError\.from\("FORBIDDEN", ORGANIZATION_ERROR_CODES\.EMAIL_VERIFICATION_REQUIRED_FOR_INVITATION\);/, + ); + expect(listing).not.toContain('shouldRequireVerifiedEmailForInvitationIdAction('); + }); +}); diff --git a/packages/plugins/plugin-auth/src/list-user-invitations-verification.ts b/packages/plugins/plugin-auth/src/list-user-invitations-verification.ts new file mode 100644 index 0000000000..9c78867440 --- /dev/null +++ b/packages/plugins/plugin-auth/src/list-user-invitations-verification.ts @@ -0,0 +1,206 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#16569] `GET /organization/list-user-invitations` — honour the DECLARED + * `requireEmailVerificationOnInvitation`, exactly as `accept-invitation`, + * `reject-invitation` and `get-invitation` already do. + * + * ## The defect, and where it is minted + * + * Not here: it comes out of the pinned vendor. Measured against the installed + * better-auth `1.7.2`, `dist/plugins/organization/routes/crud-invites.mjs`: + * the three id-addressed routes ask + * `shouldRequireVerifiedEmailForInvitationIdAction({ organizationOptions, … })`, + * whose first line is + * + * ```js + * if (organizationOptions.requireEmailVerificationOnInvitation !== void 0) + * return organizationOptions.requireEmailVerificationOnInvitation; + * ``` + * + * while `listUserInvitations` never asks — its handler reads + * + * ```js + * if (session && !session.user.emailVerified) + * throw APIError.from("FORBIDDEN", ORGANIZATION_ERROR_CODES.EMAIL_VERIFICATION_REQUIRED_FOR_INVITATION); + * ``` + * + * unconditionally. `auth-manager.ts` declares the option `false` on purpose + * (no mailer wired ⇒ nothing can ever verify an invitee ⇒ requiring + * verification dead-ends every invite flow), so on exactly the deployment + * shape the option exists for an invitee can ACCEPT an invitation and can + * never LIST it, and the SDK's `organizations.invitations.listMine()` inbox + * is empty-by-403 for every user. `list-user-invitations-verification.test.ts` + * measures both halves on the real pipeline — the 403, and the 200s from the + * three siblings for the same unverified session — and pins that the vendor + * still has the defect, so an upstream fix turns that pin red and this module + * is what gets deleted. + * + * ## Why this shape: the endpoint is rebuilt IN PLACE, from the vendor's own options + * + * The same door `admin-impersonate-endpoint.ts` takes, for the same reasons: + * + * - **One route, one owner.** The endpoint is replaced on the organization + * plugin's own `endpoints` record under the vendor's own key, so exactly one + * plugin ever registers the path — `checkEndpointConflicts` sees one entry + * and logs nothing — and `auth.api.listUserInvitations` IS this endpoint. + * - **Every hook keyed on the path still fires.** A global before-hook that + * answered the listing itself would short-circuit better-auth's dispatch + * before `runAfterHooks` (`dist/api/dispatch.mjs` returns the before-hook's + * response without running them), silently detaching the `bearer()` + * plugin's `set-auth-token` echo and every ObjectStack after-hook from this + * route. Rebuilding the endpoint keeps the full pipeline. + * - **No second copy of the request contract.** The vendor endpoint's OWN + * `options` object (`method`, the `query` schema with its `email` field, + * `use: [orgMiddleware]`, the OpenAPI entry) is handed straight back to + * `createAuthEndpoint`, so nothing about the contract is retyped and nothing + * about it can drift on a dependency bump. + * + * ## No second definition of a security filter + * + * "Which invitations may this session see" is answered by the vendor's own + * exported `getOrgAdapter(ctx.context, options).listUserInvitations(email)` — + * the identical call the vendor handler makes — keyed on the SESSION's email, + * followed by the vendor's own `status === "pending"` post-filter. This module + * writes no query, joins nothing and reads no other table. The two guards that + * keep the listing from widening into "list by organization" or "fetch by + * address" are the vendor's, carried verbatim and in the vendor's order: a + * client-side `?email=` is refused with the vendor's 400 BEFORE the session is + * consulted, and a request with neither session nor email is refused with the + * vendor's 400 AFTER it. + * + * ## What actually changed vs. the vendor handler — ONE predicate + * + * The verification refusal is asked through {@link listingRequiresVerifiedEmail} + * instead of unconditionally. That predicate honours the DECLARED option only: + * + * - `false` → the listing is open to an unverified session — the declared + * posture, and the one `accept` / `reject` / `get-invitation` already apply + * to the same session on this deployment; + * - `true` → refused, byte-identical to the vendor's answer today; + * - undeclared (or any non-boolean) → refused, the vendor's own list-route + * posture. The siblings derive an undeclared option from + * `hasBuiltInOpaqueInvitationIdGeneration(...)`, a vendor internal this + * module deliberately refuses to re-implement: honouring what is DECLARED + * restores `declared = enforced`; re-deriving an undeclared default would + * be a second definition of a security posture. + * + * Direction, stated: the accept set of this route GROWS only for a session the + * deployment has explicitly declared exempt from verification, and only by the + * rows the same session can already accept one by one. Nothing the vendor + * refuses for any other reason is admitted. + * + * ## Refusal envelope — the vendor's, on purpose + * + * Refusals keep better-auth's flat `{ message, code }` shape and the vendor's + * OWN code constant, read off the plugin's `$ERROR_CODES` rather than retyped + * (the local restatement is a fallback the test pins equal to the vendor's). + * No new public error code is minted, so nothing here reaches the spec + * error-code ledger. + */ + +import type { OrganizationOptions } from 'better-auth/plugins/organization'; + +/** The vendor's path for the per-user invitation inbox. */ +export const LIST_USER_INVITATIONS_PATH = '/organization/list-user-invitations'; + +/** The slice of better-auth's `organization` plugin this module rewrites. */ +export interface OrganizationPluginLike { + id: string; + endpoints: Record; + $ERROR_CODES?: Record; +} + +/** + * better-auth's `ORGANIZATION_ERROR_CODES.EMAIL_VERIFICATION_REQUIRED_FOR_INVITATION`, + * restated locally as the fallback for a plugin object carrying no + * `$ERROR_CODES`. The vendor does not export the constant from a public + * entry; `list-user-invitations-verification.test.ts` pins this equal to the + * plugin's own value, so a vendor rename turns that red. + */ +export const EMAIL_VERIFICATION_REQUIRED_FOR_INVITATION = { + code: 'EMAIL_VERIFICATION_REQUIRED_FOR_INVITATION', + message: 'Email verification required to view or list invitations for the session email', +} as const; + +/** + * Does the listing refuse an UNVERIFIED session? Honours the DECLARED option + * only — see the module header for why an undeclared option is read as the + * vendor's own list-route posture rather than re-derived. + */ +export function listingRequiresVerifiedEmail( + options: Pick, +): boolean { + return options.requireEmailVerificationOnInvitation !== false; +} + +/** + * Replace `organization`'s `/organization/list-user-invitations` with the + * declared-option-honouring endpoint, in place, on the plugin's own + * `endpoints` record. + * + * `options` MUST be the very object handed to `organization(options)`: it is + * what the vendor's own `getOrgAdapter(ctx.context, options)` reads, and the + * declaration this module honours lives on it. + * + * Returns the SAME plugin object (mutated), so the plugin's id, schema, hooks, + * `$ERROR_CODES` and every other endpoint stay exactly as the vendor built + * them, and only one plugin ever claims the path. + * + * A vendor bump that renames or drops the endpoint leaves the plugin untouched + * and reports `false` — loudly handled by the caller — rather than silently + * adding a second endpoint nobody routes to. + */ +export async function applyDeclaredInvitationVerificationToListing( + plugin: OrganizationPluginLike, + options: OrganizationOptions, +): Promise { + const vendor = plugin?.endpoints?.listUserInvitations; + if (!vendor || vendor.path !== LIST_USER_INVITATIONS_PATH || !vendor.options) return false; + + const [{ createAuthEndpoint, APIError, getSessionFromCtx }, { getOrgAdapter }] = await Promise.all([ + import('better-auth/api'), + import('better-auth/plugins/organization'), + ]); + + const verificationRequired = + plugin.$ERROR_CODES?.EMAIL_VERIFICATION_REQUIRED_FOR_INVITATION ?? + EMAIL_VERIFICATION_REQUIRED_FOR_INVITATION; + + // The vendor's own options object — method, `query` schema, `use` + // (orgMiddleware) and OpenAPI metadata — is handed straight back to + // `createAuthEndpoint`. Nothing about the request contract is retyped here, + // so nothing about it can drift. + plugin.endpoints.listUserInvitations = createAuthEndpoint( + LIST_USER_INVITATIONS_PATH, + vendor.options, + async (ctx: any) => { + // ── the vendor handler, in the vendor's order ──────────────────────── + const session = await getSessionFromCtx(ctx); + if (ctx.request && ctx.query?.email) { + throw APIError.fromStatus('BAD_REQUEST', { + message: 'User email cannot be passed for client side API calls.', + }); + } + + // ── THE changed predicate: asked, not assumed ──────────────────────── + if (session && !session.user.emailVerified && listingRequiresVerifiedEmail(options)) { + throw APIError.from('FORBIDDEN', verificationRequired); + } + + // ── everything below is the vendor handler, unchanged ──────────────── + const userEmail = session?.user.email || ctx.query?.email; + if (!userEmail) { + throw APIError.fromStatus('BAD_REQUEST', { + message: 'Missing session headers, or email query parameter.', + }); + } + const pendingInvitations = ( + await getOrgAdapter(ctx.context, options).listUserInvitations(userEmail) + ).filter((inv: { status?: string }) => inv.status === 'pending'); + return ctx.json(pendingInvitations); + }, + ); + + return true; +} From cd9d89b14d9c1a1652e11069c8db14331041d030 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 01:32:38 +0000 Subject: [PATCH 2/5] test(plugin-auth): pin the rebuilt listing endpoint by contract identity, not options identity createAuthEndpoint shallow-copies the options record to append its base middleware, and the vendor's $ERROR_CODES entry carries a toString helper; pin method/query/metadata by identity and code/message by value. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 --- ...list-user-invitations-verification.test.ts | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/packages/plugins/plugin-auth/src/list-user-invitations-verification.test.ts b/packages/plugins/plugin-auth/src/list-user-invitations-verification.test.ts index a2ce7d3c64..db66d6cbf2 100644 --- a/packages/plugins/plugin-auth/src/list-user-invitations-verification.test.ts +++ b/packages/plugins/plugin-auth/src/list-user-invitations-verification.test.ts @@ -423,8 +423,17 @@ describe('#16569 — the rebuilt endpoint is the vendor\'s contract with one pre const rebuilt = plugin.endpoints.listUserInvitations; expect(rebuilt).not.toBe(vendor); expect(rebuilt.path).toBe(LIST_USER_INVITATIONS_PATH); - // The SAME options object — no second copy of the request contract exists. - expect(rebuilt.options).toBe(vendor.options); + // The request contract is the vendor's own objects, by IDENTITY — no + // second copy exists to drift. (`createAuthEndpoint` shallow-copies the + // options record to append its own base middleware to `use`, measured on + // better-call 1.4.0 `createEndpoint.create`; the vendor's entries are all + // still there.) + expect(rebuilt.options.method).toBe(vendor.options.method); + expect(rebuilt.options.query).toBe(vendor.options.query); + expect(rebuilt.options.metadata).toBe(vendor.options.metadata); + for (const middleware of vendor.options.use ?? []) { + expect(rebuilt.options.use).toContain(middleware); + } // No endpoint added, none dropped: one owner for the path. expect(Object.keys(plugin.endpoints).sort()).toEqual(keysBefore); }); @@ -438,9 +447,11 @@ describe('#16569 — the rebuilt endpoint is the vendor\'s contract with one pre it('the locally restated refusal is the vendor\'s own $ERROR_CODES entry', async () => { const { organization } = await import('better-auth/plugins/organization'); const plugin: any = organization({}); - expect(plugin.$ERROR_CODES?.EMAIL_VERIFICATION_REQUIRED_FOR_INVITATION).toEqual( - EMAIL_VERIFICATION_REQUIRED_FOR_INVITATION, - ); + // `code` and `message` are the wire contract; the vendor's entry also + // carries a `toString` helper that never reaches the wire. + const vendorEntry = plugin.$ERROR_CODES?.EMAIL_VERIFICATION_REQUIRED_FOR_INVITATION; + expect(vendorEntry?.code).toBe(EMAIL_VERIFICATION_REQUIRED_FOR_INVITATION.code); + expect(vendorEntry?.message).toBe(EMAIL_VERIFICATION_REQUIRED_FOR_INVITATION.message); }); }); From 85c101db05cef7fdaf69d5c9d28d50b04a434e26 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 01:49:26 +0000 Subject: [PATCH 3/5] chore(plugin-auth): gate follow-through for the rebuilt listing endpoint - classify the restated vendor code as foreign-vocabulary (dispatcher error vocabulary row, same shape as the impersonate precedent's) - let the engine-double ledger record the new suite's doubles - apply the fake engine's limit by presence, not truthiness - log the vendor-drift fallback at warn: a visible 403 is a functional degradation under AGENTS.md's one-question rule, not a durability one Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 --- .../plugins/plugin-auth/src/auth-manager.ts | 5 ++++- .../list-user-invitations-verification.test.ts | 6 ++++-- .../runtime/src/dispatcher-error-vocabulary.ts | 18 ++++++++++++++++++ scripts/engine-double-contract.pinned.json | 15 +++++++++++++++ 4 files changed, 41 insertions(+), 3 deletions(-) diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 04c35c4c88..0325555fc0 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -3204,7 +3204,10 @@ export class AuthManager { // The vendor renamed or dropped the endpoint. Say so loudly: the // route then falls back to the vendor's own handler, which refuses // every unverified session — an empty inbox, not an open door. - console.error( + // `warn`, not `error` (AGENTS.md → Degradation log levels): the + // system is VISIBLY smaller — the inbox answers a 403 the caller sees + // — and nothing claims a persistence it did not perform. + console.warn( '[AuthManager] better-auth\'s organization plugin no longer exposes a ' + '`listUserInvitations` endpoint at /organization/list-user-invitations, ' + 'so the declared `requireEmailVerificationOnInvitation` could NOT be ' + diff --git a/packages/plugins/plugin-auth/src/list-user-invitations-verification.test.ts b/packages/plugins/plugin-auth/src/list-user-invitations-verification.test.ts index db66d6cbf2..5c056144f8 100644 --- a/packages/plugins/plugin-auth/src/list-user-invitations-verification.test.ts +++ b/packages/plugins/plugin-auth/src/list-user-invitations-verification.test.ts @@ -98,8 +98,10 @@ const createMemoryEngine = () => { (a, b) => (a[order.field] > b[order.field] ? 1 : -1) * (order.order === 'desc' ? -1 : 1), ); } - if (q.offset) out = out.slice(q.offset); - if (q.limit) out = out.slice(0, q.limit); + if (typeof q.offset === 'number') out = out.slice(q.offset); + // By PRESENCE, not truthiness: `limit: 0` is a bound, not its absence + // (`check:objectql-double-limit`). + if (typeof q.limit === 'number') out = out.slice(0, q.limit); return out.map((r) => ({ ...r })); }, async count(name: string, q: any = {}) { diff --git a/packages/runtime/src/dispatcher-error-vocabulary.ts b/packages/runtime/src/dispatcher-error-vocabulary.ts index cf4f742811..f2190497c7 100644 --- a/packages/runtime/src/dispatcher-error-vocabulary.ts +++ b/packages/runtime/src/dispatcher-error-vocabulary.ts @@ -523,6 +523,24 @@ export const UNREGISTERED_CODE_SITES: readonly UnregisteredCodeSite[] = [ "into a code this repo owns by accident: it is still the vendor's string on the vendor's " + 'wire, and a vendor rename turns the pin red rather than silently minting a local code.', }, + { + code: 'EMAIL_VERIFICATION_REQUIRED_FOR_INVITATION', + file: 'packages/plugins/plugin-auth/src/list-user-invitations-verification.ts', + shape: 'objlit', + door: 'none', + verdict: 'foreign-vocabulary', + why: + "better-auth 1.7.2's own organization-plugin vocabulary — verified in the then-installed " + + 'vendor at `dist/plugins/organization/error-codes`, spelled there exactly as it is here — ' + + 'and read at runtime off `plugin.$ERROR_CODES`; the local restatement is only the fallback ' + + 'for a plugin object carrying no `$ERROR_CODES`, and ' + + "`list-user-invitations-verification.test.ts` pins it equal to the vendor's own entry so a " + + 'vendor rename turns the pin red rather than silently minting a local code. Raised ' + + "`APIError.from('FORBIDDEN', verificationRequired)` inside the rebuilt " + + '`/organization/list-user-invitations` better-auth endpoint (#16569), so it leaves as the ' + + "vendor's own `Response` on the vendor's wire — the identical refusal the vendor handler " + + 'raised unconditionally before the rebuild — and never as a throw this repo classifies.', + }, { code: 'OS_METADATA_CONVERTED', diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 5b6bc99505..2a419d1a71 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -2446,6 +2446,21 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/plugins/plugin-auth/src/list-user-invitations-verification.test.ts", + "verb": "delete", + "pinned": 1 + }, + { + "file": "packages/plugins/plugin-auth/src/list-user-invitations-verification.test.ts", + "verb": "findOne", + "pinned": 1 + }, + { + "file": "packages/plugins/plugin-auth/src/list-user-invitations-verification.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/plugins/plugin-auth/src/member-role-canonical.test.ts", "verb": "update", From c62690f75ff36642d5ba36d6b3aef86e24519d5e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 02:04:42 +0000 Subject: [PATCH 4/5] chore(runtime): keep the tracker id out of the vocabulary row's prose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check:doc-authoring — a runtime string reaches readers who cannot resolve an issue id; the anchor lives in git history and the source comment. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 --- packages/runtime/src/dispatcher-error-vocabulary.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/runtime/src/dispatcher-error-vocabulary.ts b/packages/runtime/src/dispatcher-error-vocabulary.ts index f2190497c7..123f5c9b31 100644 --- a/packages/runtime/src/dispatcher-error-vocabulary.ts +++ b/packages/runtime/src/dispatcher-error-vocabulary.ts @@ -537,7 +537,7 @@ export const UNREGISTERED_CODE_SITES: readonly UnregisteredCodeSite[] = [ "`list-user-invitations-verification.test.ts` pins it equal to the vendor's own entry so a " + 'vendor rename turns the pin red rather than silently minting a local code. Raised ' + "`APIError.from('FORBIDDEN', verificationRequired)` inside the rebuilt " + - '`/organization/list-user-invitations` better-auth endpoint (#16569), so it leaves as the ' + + '`/organization/list-user-invitations` better-auth endpoint, so it leaves as the ' + "vendor's own `Response` on the vendor's wire — the identical refusal the vendor handler " + 'raised unconditionally before the rebuild — and never as a throw this repo classifies.', }, From f8685bbde9a9d5a8f5225d243d4bb8157e2c4e22 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 09:08:05 +0000 Subject: [PATCH 5/5] chore(gates): declare the list-user-invitations endpoint conformant in PLUGIN_ROUTE_MODULES The module added for the declared-verification fix writes a Hono response, so the route-envelope walk discovers it, and a discovered file absent from the table is an ERROR rather than a default. Measured with the gate's own `scanHonoRouteSource`: one body, all six asserted counters zero. The single write is `return ctx.json(pendingInvitations)`, whose argument is an identifier -- the deliberate relayed-body blindness -- and the three refusals are `throw APIError.*`, which this surface does not count. So `{}` is the honest declaration: nothing this file builds departs from the envelope. No ruled state applies and nothing was hoisted. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 --- scripts/check-route-envelope.mjs | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/scripts/check-route-envelope.mjs b/scripts/check-route-envelope.mjs index 0a444a2510..9330412e2b 100644 --- a/scripts/check-route-envelope.mjs +++ b/scripts/check-route-envelope.mjs @@ -758,6 +758,30 @@ const PLUGIN_ROUTE_MODULES = { // our own shells, and the migration was one key. 'packages/adapters/hono/src/index.ts': {}, + // [#16569] FIRST AUDIT — swept in by the walk, verdict conformant. The module + // rebuilds better-auth's `/organization/list-user-invitations` endpoint in place + // so the DECLARED `requireEmailVerificationOnInvitation` is honoured, and it + // BUILDS exactly ONE body: `return ctx.json(pendingInvitations)`. The argument + // is an IDENTIFIER, so the counters read it as relayed — the same deliberate + // blindness `inbound-rate-limit.ts` sits behind on surface 4 — and what it names + // is better-auth's own rows: the vendor's exported + // `getOrgAdapter(ctx.context, options).listUserInvitations(email)` produces them + // and the vendor's `status === 'pending'` post-filter narrows them. No shape is + // minted here, so there is no literal for a counter to read and none to hoist. + // The three refusals are `throw APIError.*` — the vendor's flat + // `{ message, code }`, RAISED rather than written — which this surface does not + // count either; the single countable write is therefore the file's whole visible + // departure, not a sample of it. + // + // Worth keeping distinct from its twin: `admin-impersonate-endpoint.ts` below + // takes the SAME in-place-rebuild door in this same package and needed the + // `vendorWire` ruling, because reimplementing that handler turned a relay into a + // BUILT literal (`ctx.json({ session, user })`) and made a vendor-owned shape + // visible to the counters. Here the rebuild never re-shapes the body, so nothing + // became visible and no ruled state applies. Read this `{}` for what it is: + // nothing this file BUILDS departs from the envelope. + 'packages/plugins/plugin-auth/src/list-user-invitations-verification.ts': {}, + // ── Ratchet: real, tracked, NOT blessed ───────────────────────────────── // // Measured by #9267 when this surface was added, not chosen. Each entry names