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..0325555fc0 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,46 @@ 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. + // `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 ' + + '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..5c056144f8 --- /dev/null +++ b/packages/plugins/plugin-auth/src/list-user-invitations-verification.test.ts @@ -0,0 +1,515 @@ +// 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 (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 = {}) { + 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 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); + }); + + 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({}); + // `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); + }); +}); + +// --------------------------------------------------------------------------- +// 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; +} diff --git a/packages/runtime/src/dispatcher-error-vocabulary.ts b/packages/runtime/src/dispatcher-error-vocabulary.ts index cf4f742811..123f5c9b31 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, 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/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 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",