diff --git a/packages/plugins/plugin-auth/src/auth-manager-membership-admission.ts b/packages/plugins/plugin-auth/src/auth-manager-membership-admission.ts index e064d46a794..1be746b5352 100644 --- a/packages/plugins/plugin-auth/src/auth-manager-membership-admission.ts +++ b/packages/plugins/plugin-auth/src/auth-manager-membership-admission.ts @@ -45,6 +45,12 @@ * while they are first evaluated: this module's top level is imports, an * interface and function declarations. * + * `settleMembership` below is governed by ADR-0093 D2: the membership + * reconciler is the single owner of the "every user gets a membership" + * invariant, and this is the manager's single assembly of its inputs, which + * both creation seams call. `scripts/adr-anchors/` carries the invariant for + * this file, as it does for `auth-manager.ts`. + * * Nothing re-exports this module, so the published surface does not move. */ diff --git a/packages/plugins/plugin-auth/src/auth-manager-password-hasher.ts b/packages/plugins/plugin-auth/src/auth-manager-password-hasher.ts new file mode 100644 index 00000000000..356d30afb76 --- /dev/null +++ b/packages/plugins/plugin-auth/src/auth-manager-password-hasher.ts @@ -0,0 +1,75 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25224 cut 11] The WebContainer password hasher of `AuthManager` — the + * pure-JS scrypt hasher handed to better-auth when the runtime is StackBlitz's + * WebContainer, whose `node:crypto.scrypt` polyfill fails sign-up — moved out + * of `auth-manager.ts` whole. + * + * A MOVE, not a redesign. The function below is the body of the + * `AuthManager` method of the same name as it stood, re-indented one level + * and otherwise unedited: the same runtime test, the same scrypt parameters + * and `{saltHex}:{keyHex}` storage format, the same lazy load of + * `@noble/hashes` (only inside WebContainer), and the same warning and + * fallback when that load fails. + * + * ## How the moved body reaches the class + * + * The shape #23050 set for `rest-server.ts` (its `data-action-routes.ts` + * header carries the full reasoning), which the ten `auth-manager-*.ts` + * modules split out before this one follow. The class keeps the method — the + * published `dist/*.d.ts` lists it — and its body is a plain call: it reads + * no member of the class, so it is handed none, and there is no host type. + * It is no longer `async`: it returns the moved function's own promise, so a + * caller awaits the same promise the body settles, with no extra wrapper. + * + * `isWebContainerRuntime` stays: the request-state polyfill in + * `auth-manager.ts`, which the constructor installs, reads it too. So that + * file exports it and this module imports it by VALUE, for one read inside + * `resolvePasswordHasher`, when the better-auth instance is built. + * `index.ts` lists `auth-manager.ts`'s published names one by one, so that + * export is not published. The two modules import each other, and that is + * safe because nothing reads the binding while they are first evaluated: this + * module's top level is one import and one function declaration. + * + * Nothing re-exports this module, so the published surface does not move. + */ + +import { isWebContainerRuntime } from './auth-manager.js'; + +/** The body of `AuthManager#resolvePasswordHasher`; the method's docblock there is its contract. */ +export async function resolvePasswordHasher(): Promise< + { hash: (password: string) => Promise; verify: (args: { hash: string; password: string }) => Promise } | undefined +> { + if (!isWebContainerRuntime()) return undefined; + try { + const { scryptAsync } = await import('@noble/hashes/scrypt.js'); + const PARAMS = { N: 16384, r: 16, p: 1, dkLen: 64, maxmem: 128 * 16384 * 16 * 2 } as const; + const toHex = (b: Uint8Array): string => { + let s = ''; + for (let i = 0; i < b.length; i++) s += b[i]!.toString(16).padStart(2, '0'); + return s; + }; + const generateKey = (password: string, saltHex: string): Promise => + scryptAsync(password.normalize('NFKC'), saltHex, PARAMS); + return { + hash: async (password: string) => { + const saltBytes = (globalThis as any).crypto.getRandomValues(new Uint8Array(16)); + const saltHex = toHex(saltBytes); + const key = await generateKey(password, saltHex); + return `${saltHex}:${toHex(key)}`; + }, + verify: async ({ hash, password }) => { + const [saltHex, keyHex] = hash.split(':'); + if (!saltHex || !keyHex) throw new Error('Invalid password hash'); + const target = await generateKey(password, saltHex); + return toHex(target) === keyHex; + }, + }; + } catch (err: any) { + console.warn( + `[AuthManager] WebContainer detected but pure-JS scrypt unavailable: ${err?.message ?? err}. Falling back to default.`, + ); + return undefined; + } +} diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 380ddd3f482..a23c06231ad 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -249,6 +249,10 @@ import { ownsRoute, type RequestRoutingHost, } from './auth-manager-request-routing.js'; +// [#25224 cut 11] The WebContainer password hasher; the method of the same +// name below calls it, and hands it nothing, because it reads no member of +// this class. +import { resolvePasswordHasher } from './auth-manager-password-hasher.js'; /** * Detect WebContainer (StackBlitz) environment. @@ -259,7 +263,7 @@ import { * propagate the store across `await` boundaries the way Node's native * implementation does. */ -function isWebContainerRuntime(): boolean { +export function isWebContainerRuntime(): boolean { if (typeof globalThis === 'undefined') return false; const proc = (globalThis as any).process; return ( @@ -3157,40 +3161,11 @@ export class AuthManager { * Returns `undefined` outside WebContainer so production deployments keep * the native (fast) hasher and never load `@noble/hashes`. */ - private async resolvePasswordHasher(): Promise< + private resolvePasswordHasher(): Promise< { hash: (password: string) => Promise; verify: (args: { hash: string; password: string }) => Promise } | undefined > { - if (!isWebContainerRuntime()) return undefined; - try { - const { scryptAsync } = await import('@noble/hashes/scrypt.js'); - const PARAMS = { N: 16384, r: 16, p: 1, dkLen: 64, maxmem: 128 * 16384 * 16 * 2 } as const; - const toHex = (b: Uint8Array): string => { - let s = ''; - for (let i = 0; i < b.length; i++) s += b[i]!.toString(16).padStart(2, '0'); - return s; - }; - const generateKey = (password: string, saltHex: string): Promise => - scryptAsync(password.normalize('NFKC'), saltHex, PARAMS); - return { - hash: async (password: string) => { - const saltBytes = (globalThis as any).crypto.getRandomValues(new Uint8Array(16)); - const saltHex = toHex(saltBytes); - const key = await generateKey(password, saltHex); - return `${saltHex}:${toHex(key)}`; - }, - verify: async ({ hash, password }) => { - const [saltHex, keyHex] = hash.split(':'); - if (!saltHex || !keyHex) throw new Error('Invalid password hash'); - const target = await generateKey(password, saltHex); - return toHex(target) === keyHex; - }, - }; - } catch (err: any) { - console.warn( - `[AuthManager] WebContainer detected but pure-JS scrypt unavailable: ${err?.message ?? err}. Falling back to default.`, - ); - return undefined; - } + // [#25224 cut 11] The body moved to `auth-manager-password-hasher.ts`; it reads no member of this class. + return resolvePasswordHasher(); } /** diff --git a/scripts/adr-anchors/packages__plugins__plugin-auth__src__auth-manager-membership-admission.ts.json b/scripts/adr-anchors/packages__plugins__plugin-auth__src__auth-manager-membership-admission.ts.json new file mode 100644 index 00000000000..e176690d07f --- /dev/null +++ b/scripts/adr-anchors/packages__plugins__plugin-auth__src__auth-manager-membership-admission.ts.json @@ -0,0 +1,7 @@ +{ + "file": "packages/plugins/plugin-auth/src/auth-manager-membership-admission.ts", + "adrs": [ + "ADR-0093" + ], + "invariant": "ADR-0093 D2 — the membership reconciler is the SINGLE owner of the \"every user gets a membership\" invariant, and `settleMembership` is this manager's single assembly of its inputs. TWO seams call it and must never disagree: `user.create.after` (the creation seam every path flows through — email signup, admin create-user, bulk import, SSO JIT, none of which need a session) and `session.create.before` (which settles BEFORE resolving the session's active organization, so a user's first session is not minted tenant-less — #8245). Assembling the deps per call site would let them drift on the policy, which is a live platform setting read through `getMembershipPolicy()` (#5152): a captured option leaves one seam auto-binding after an admin switched the deployment to `invite-only`. The session-seam call must also stay a pure ORDERING change — same reconciler, same policy, same target-org resolution — so `invite-only` and multi-org still bind nobody and those sessions still mint with no active organization, which is the legal state the #8247 ruling declares." +}