Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/

Expand Down
75 changes: 75 additions & 0 deletions packages/plugins/plugin-auth/src/auth-manager-password-hasher.ts
Original file line number Diff line number Diff line change
@@ -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<string>; verify: (args: { hash: string; password: string }) => Promise<boolean> } | 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<Uint8Array> =>
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;
}
}
41 changes: 8 additions & 33 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 (
Expand Down Expand Up @@ -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<string>; verify: (args: { hash: string; password: string }) => Promise<boolean> } | 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<Uint8Array> =>
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();
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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."
}
Loading