Skip to content

Commit f0fdd45

Browse files
refactor(plugin-auth): move AuthManager's database and auth secret inputs into auth-manager-database-secret.ts (#25224 cut 7) (#25404)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuMjbPDrsigsyqoKsV4WZQMz)_ Part of #25224 Clause-②: no Metadata-protocol: none Ruling-ref: 6348891160 The basis for the `Clause-②: no` line: this is a move. No accept set, export, route, refusal, code or answer changes. The `.d.ts` bar is ruling `6348891160` item 1 (on #25222, covering this card). `@objectstack/plugin-auth`'s published `dist/index.d.ts` and `dist/index.d.mts` are raw byte-identical to base (0 diff lines, so the union-order normalisation has nothing to do), with the ruling's positive control. Its entry exports the same 159 names under ESM and CJS (measured below). Cut 7 of the `auth-manager.ts` split: **the database and the auth secret `createAuthInstance` hands better-auth.** Three `AuthManager` method bodies (97 lines) move unedited, re-indented one level, into one new module, `packages/plugins/plugin-auth/src/auth-manager-database-secret.ts`. #25224 stays open for the later cuts. This follows the amended claim `6363170079`, which amends `6361212912` on the file cut 6 left (#25400, landed note `6362338361`). The shape is cuts 1 to 6's, which is #23050's. Branch: `claude/project-thread-lg3wrj`, the harness-pinned branch for this session, restarted from `origin/main` `860f002f8b` after #25400 merged. It names no issue, so the card relation is this body's first line. `auth-manager.ts` goes from 8,971 to 8,909 lines. The cut is +192 / −91 over 2 files: 283 changed lines, under the 3,000-line line. It is not a governed surface (`check-governed-merges.mjs --branch HEAD`: 0 of the 2 paths hit the register). ## Why this seam These are the two inputs `createAuthInstance` reads from the manager when it builds the better-auth instance (`secret: this.resolveAuthSecret()` and `database: await this.createDatabaseConfig()`), and the environment read behind the first: - `createDatabaseConfig`: the ObjectQL adapter factory, with the created-record callback that stages the id of a user the current endpoint call created, or, with no data engine, better-auth's in-memory store keyed by model name and the warning that it is not for production; - `resolveAuthSecret`: the auth secret, resolved once per manager and memoised in `resolvedAuthSecret`; - `generateSecret`: the secret from `OS_AUTH_SECRET` (or its two legacy spellings), the refusal to boot production without one, and the development fallback with its warning. What stays, and why: - `createAuthInstance` stays. Its body reads `readDisableSignUpEnv`, a module-level function that `auth-manager.ts` does not export and that `getPublicConfig` also reads. `index.ts` re-exports `auth-manager.ts` whole (`export * from './auth-manager.js'`), so exporting it there would publish it. Per the landed note `6360455119`, a body that reads an unexported module helper comes back as `needs_decision` before it is cut. - `buildPluginList` and `resolvePasswordHasher` stay for the same reason (`readBooleanEnv`, `isWebContainerRuntime`). - `stageUserCreatedInRequest` stays: it writes the request-scoped record of created users that the session seam reads, part of the membership area, a later cut. ## What moved | method | base signature line | body lines | members it reads | |---|---:|---:|---| | `createDatabaseConfig` | `:4670` | 66 | `config`, `stageUserCreatedInRequest` | | `resolveAuthSecret` | `:4741` | 6 | `config`, `generateSecret`, `resolvedAuthSecret` (read and written) | | `generateSecret` | `:4748` | 25 | none | Lines are base `860f002f8b`. The members come from a walk of every `this.X` in each body with the TypeScript compiler API. No body has a bare `this`, `this[...]`, `arguments` or `super`, or a `this` inside a `function` or a nested class. No body reads a static (`AuthManager.X`) or a module-level declaration of `auth-manager.ts`. The one assignment to a `this` member is `resolveAuthSecret`'s to `resolvedAuthSecret`. **Imports.** No import leaves `auth-manager.ts`. The new module imports `readEnvWithDeprecation` from `@objectstack/types` and `createObjectQLAdapterFactory` from `./objectql-adapter.js`, the places `auth-manager.ts` imports them from. `auth-manager.ts` keeps both import lines unchanged: - `readEnvWithDeprecation` for its other read (`:3024`, the CORS origin); - `createObjectQLAdapterFactory` because a docblock that stays links it (`{@link createObjectQLAdapterFactory}`, `:895`), and TypeScript resolves that link through the import. The same import line brings `withSystemContext` and `withSystemReadContext`, which the file still calls. **Evaluation order.** Nothing moves. Both modules the new one imports are imported by `auth-manager.ts` above the new module's import line, so they are evaluated before it, as before. The new module's top level is three imports (one type-only), an interface and three function declarations, so the point at which it is evaluated cannot be observed. **The dynamic import.** `createDatabaseConfig`'s callback reads `tryGetCurrentAuthEndpointContext` through `await import('@better-auth/core/context')`, at call time. It moves with the body, unedited. A bare specifier resolves the same from both files: they sit in the same directory and are bundled into the same entry. ## The shape - **The class keeps all three methods** at their places, with their names, signatures and docblocks: the published `.d.ts` lists each one. Each body is now a host literal of exactly the members that body reads, typed as a `Pick` of `DatabaseSecretHost`, plus `.call`. `generateSecret` reads no member, so its method calls the moved function directly. - **`DatabaseSecretHost`** lives in the new module. It names the 4 members the bodies read, each typed as the class member (`AuthManager['config']`, …). The module imports `AuthManager` as a type only, so there is no runtime cycle. - **`stageUserCreatedInRequest` and `generateSecret` go as late-bound closures** (`(...a) => this.stageUserCreatedInRequest(...a)`). The adapter's created-record callback runs `stageUserCreatedInRequest` inside every creating endpoint call, long after the factory was built, and still reaches the method on the manager, so a spy or double set there is still the one it reaches. - **`config` goes as a getter.** The class reassigns it after construction (`setRuntimeBaseUrl`, `applyConfigPatch`), so it is read off the manager when a body reads it, as before. The adapter factory reads `this.config.dataEngine` when `createDatabaseConfig` runs, as before. - **`resolvedAuthSecret` goes as a get/set pair.** `resolveAuthSecret` stores the secret on the manager, so every consumer still reads the one memo, and a manager resolves its secret once. - **`createDatabaseConfig` is no longer `async`.** It returns the moved async function's own promise, so `createAuthInstance` awaits the same promise the body settles. Building the host cannot throw, so the method does not now throw synchronously where it used to reject. `resolveAuthSecret` and `generateSecret` were synchronous and stay so: `generateSecret`'s production refusal still throws synchronously from the method. - **The cost:** one small host object per call. Both methods with a host run when the auth instance is built (`resolveAuthSecret` also from `buildPluginList`'s SCIM arm), not per request. - **Nothing re-exports the new module**, so the published surface does not move. ## Path readers None needs migrating: no reader keys on a moved line. Measured as reading no moved line: - **Plugin-factory scans** in `better-auth-schema-parity.test.ts` and `managed-extension-fields.test.ts`. The moved text carries one dynamic import their pattern matches: `const { tryGetCurrentAuthEndpointContext } = await import('@better-auth/core/context')`. Both scans skip every `@better-auth/core` specifier ("runtime utilities … not plugin factories, and they declare no schema"), so the factory set they derive from `auth-manager.ts` and `auth-manager-actor-guards.ts` is the same at base and head, and the new module is not added to their file lists. - **`check-dispatcher-error-vocabulary.mjs`'s file-scoped import bindings** (guard ③, which reads the dynamic `const { … } = await import(…)` form). `tryGetCurrentAuthEndpointContext` leaves `auth-manager.ts`'s binding set. A binding matters to the gate only as the object of a member expression in an `APIError` argument, and neither the moved text nor the new module has a `new APIError(`. The gate reads 56 sites found and 56 classified at base and at head; its scope grows by the one new file (3,394 → 3,395 non-test source files). - **`api-error-code.pin.test.ts`'s `authManagerFiles` floor**: no `new APIError(` sits in the moved bodies, so the floor reads the same sites over the same files at base and head. - **`check-auth-mount-ledger.mjs`** and **`auth-manager-base-path.test.ts`**: no base-path literal or declaration sits in the moved text. - **The tenant-audit census** and its two documents: the moved text holds no engine write. Compared per site, base and head read the same 238 sites with the same totals (below), so neither document is regenerated. - **The system-context census**: the moved text holds no `isSystem` read. The census reads 148 elevation read sites at base and at head. - **Callers through the manager**: the suites that drive the three methods call each through the class method, and all pass at head (below): - `auth-manager.test.ts`: "createDatabaseConfig – adapter wrapping", and "generateSecret – production secret guard (P0-1)", which calls `generateSecret()` on the manager (the boot refusal in production without a secret, the ephemeral development secret, and `OS_AUTH_SECRET` used even in production); - `user-created-in-request.test.ts` (ADR-0093 D7), which awaits `createDatabaseConfig()` on the manager and drives the created-record callback; - `auth-route-ledger.conformance.test.ts`, on the in-memory fallback. - **The staging the callback reaches**: `first-session-membership-ordering.test.ts` drives `stageUserCreatedInRequest` on the manager directly. That method stays, and it is the one the callback's closure reaches, so the test reads the same member at base and head. - **Every symbol anchor into the file.** Before the cut, every `auth-manager.ts#symbol` anchor in the tracked tree outside changelogs was mapped with `symbolSegmentResolution` from `scripts/symbol-anchors.mjs`. There are 29, in ADR-0127, ADR-0134, ADR-0135 and four platform-checklist areas, and all 29 are `declaration`-class at base, with the 3 bodies blanked, and at head. None names a moved method. One names a token the moved text also carries: `#AuthManager` (ADR-0135), in the in-memory warning's text. It resolves on the class declaration, which stays. ## The decision-letter layer drops by one The seat asks each cut's report for this count at both ends. - `check-adr-anchors`' decision-letter layer checks `ADR-NNNN Dk` citations only in anchored files. `auth-manager.ts` is anchored; the new module is not, as cuts 1 to 6's modules are not. - `createDatabaseConfig`'s callback comment cites `ADR-0093 D7` (the adapter as the one place that sees the created user's id inside the creating endpoint call). It moves with the body, so the layer reads one citation fewer: 2,015 at base `860f002f8b` and 2,014 at head. On `origin/main` `2dfeb1a772` (below) it reads 2,005 alone and 2,004 with this cut. The gate exits 0 at both. - `auth-manager.ts`'s anchor entry names ADR-0108 and ADR-0093, and its invariant describes the role map and the D2 membership seams, not D7. Its required text stays in `auth-manager.ts`, and the entry still holds. - No anchor entry is added for the new module. An entry would need new invariant text, which a move-only cut does not add; the seat ruled the same for cut 5's `ADR-0131 D7`. ## Proof Builds and suites ran through `os-verify-lock`, and every exit code was captured before any pipe. Base `860f002f8b`, head `83f5b0249e`. A full `turbo run build` ran first. Then `plugin-auth` was built and its suite run at base, then built and run at head, on the same dependency dists. **1. The moved text.** - For each of the 3 methods, the base class body and the new module's function body are the same TypeScript token sequence (scanner tokens, kind and text, trivia skipped, each template literal re-scanned at its substitutions as the parser does). - Every line is the base line minus exactly two leading spaces, and the parameters and return type are the same text. - Each class method keeps its docblock and signature, with `async` dropped from `createDatabaseConfig`. - Result: 3 of 3 pass, 97 body lines. | method | body lines | tokens | |---|---:|---:| | `createDatabaseConfig` | 66 | 192 | | `resolveAuthSecret` | 6 | 32 | | `generateSecret` | 25 | 79 | - **Negative control:** one token changed in the module (`'sys_user'` → `'sys_users'` in `createDatabaseConfig`'s created-record callback) makes that method fail (tokens and lines DIFFER), and the run exits 1. Restored afterwards; `cmp` against the saved copy was clean. - **An independent reading:** `git diff --color-moved=plain --color-moved-ws=allow-indentation-change 860f002 83f5b02` over the two source files marks 90 of the 91 deleted lines and 100 of the 192 added lines as moved. - The one other deleted line is `createDatabaseConfig`'s `async` signature line. - The other added lines are the stubs' host literals, `DatabaseSecretHost`, the module header, the per-function pointer docblocks and the import blocks. **2. `.d.ts` and exports (ruling `6348891160` items 1 and 2).** | file | raw base | raw head | raw diff lines | normalised base | normalised head | |---|---|---|---:|---|---| | `dist/index.d.ts` | `12679e13f74973b0510551a65b50808e7c3936a2d273cf2f262a9196648c0737` | `12679e13f74973b0510551a65b50808e7c3936a2d273cf2f262a9196648c0737` | 0 | `c7a9fa25f118cfec858d770b979767744d27d9757a9ea4063faeea6238c33c01` | `c7a9fa25f118cfec858d770b979767744d27d9757a9ea4063faeea6238c33c01` | | `dist/index.d.mts` | `df57562ff950159e738d790cb7ee82a655ba0fffddafe4153bdcc50c4537336f` | `df57562ff950159e738d790cb7ee82a655ba0fffddafe4153bdcc50c4537336f` | 0 | `68f824aea0947d9895b968ed46a7972eaae43ef0f2bda7e3ba9113002233ade6` | `68f824aea0947d9895b968ed46a7972eaae43ef0f2bda7e3ba9113002233ade6` | - The normaliser, verbatim (the ruling's one-liner): `node -e 'const s=require("fs").readFileSync(process.argv[1],"utf8");process.stdout.write(s.replace(/"[^"\n]*"(?:\s*\|\s*"[^"\n]*")+/g,r=>r.split(/\s*\|\s*/).sort().join(" | ")))' dist/index.d.ts | sha256sum`. - The raw files are already byte-identical, so no union-order swap occurs in this cut: items 1(b) and 1(d) hold with nothing to account for. - **Positive control:** the head `dist/index.d.ts` with one private member made public (`private createDatabaseConfig;` → `createDatabaseConfig;`, line 1842, a one-line change) hashes raw `4def12da7d94e4d585fad40a829795f80a7fdfeec9d8d4936f68d770a8e3e53d` and normalised `f98f154135c7fb43f7716274364a3263a4a40d749febc84653c2ad97a4e6f19d`. Both differ from the readings above. - **Export lists:** ESM `dist/index.mjs` and CJS `dist/index.js` each have 159 names at base and at head, the same list (the sha256 of the sorted names is `3abb126f5babb701…` on all four). - **The bundle:** 18 files in `dist/` on both sides, the same list. Only `index.js`, `index.mjs` and their two `.map` files differ, as a move of code inside the bundle must. **3. Tests and lint.** - `pnpm exec vitest run --maxWorkers=3` in `packages/plugins/plugin-auth`: base `Test Files 189 passed (189)`, `Tests 3580 passed | 10 skipped (3590)`, VERDICT command-exit 0. Head: the same counts, VERDICT command-exit 0. - `pnpm --filter @objectstack/plugin-auth typecheck` (both `tsc` projects and `check:test-typecheck`): exit 0. `pnpm --filter @objectstack/runtime typecheck`: exit 0. - `eslint --no-inline-config` (the root `lint` script's binary and flags) over the 2 files: exit 0. - **The plugin-factory scan**, run as both tests run it over `auth-manager.ts` and `auth-manager-actor-guards.ts`: 16 factories at base and the same 16 at head. The `@better-auth/core` imports it skips are 3 at base and 2 at head; the one that left is `tryGetCurrentAuthEndpointContext`, now in the new module. **4. The census and the anchors, at base and at head.** - The tenant-audit census (`node scripts/tenant-audit-census.mjs --json`), compared per site with the new module's file name mapped back to `auth-manager.ts`: 238 sites on both sides, 0 in the new module, 0 per-site differences in object, verb, tenancy, context and elevation, 0 unresolved and 0 unledgered on both, and every total the same. - These checkers were run by hand at base and at head, because `dispatch-gates` does not derive the ADR one. All six exit 0 at both: - `check-adr-symbol-anchors`: 2,384 anchors across 142 records, 466 symbol (440 declaration, 26 literal), at both. - `check-adr-anchors`: 80 anchored files at both. The decision-letter layer reads 2,015 at base and 2,014 at head, as above. The number layer reads 52,147 citations at both, across 6,630 files at base and 6,631 at head (the new module carries the moved `ADR-0093 D7`). - `check-scripts-symbol-anchors` (4,061 anchors), `check-spec-docblock-symbol-anchors` (5,071 anchors, both read in the same built tree), `check-system-context-census` (148 elevation read sites) and `check-platform-checklist` (16 areas, 329 items): the same reading at both. - `check-dispatcher-error-vocabulary`: 56 found and 56 classified at both. **5. Gates.** - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives 53 commands for the 2 paths. All 53 ran at `83f5b0249e` after the full build, and all 53 exited 0. - `--ran` over the record with each exit code: 53 derived, 53 run, 0 NOT-MEASURED, a derived zero. - `check-governed-merges.mjs --branch HEAD`: NOT governed, 283 changed lines. - **Docs drift** (`node scripts/docs-audit/affected-docs.mjs --json 860f002`, exit 0): 11 pages, 3 of them release-owned. Every one is listed through a literal the moved text carries unchanged: `OS_AUTH_SECRET`, `AUTH_SECRET` and `BETTER_AUTH_SECRET` in `generateSecret`, and `sys_oauth_resource` in a comment in `createDatabaseConfig`. No page restates a rule this move changes, so no doc edit is owed. - **Serial constraints** (in the claim): before the run, none of the 13 open PRs touched `plugin-auth/src` or a reader above. Since then #25401 (`plugin-security` only) merged, and `origin/main` is `2dfeb1a772`. On that tree with this cut applied (no conflict): - the move proof against `2dfeb1a772` passes 3 of 3; - `check-tenant-audit-census`, `check-adr-anchors`, `check-adr-symbol-anchors`, `check-platform-checklist`, `check-system-context-census`, `check-auth-mount-ledger` and `check-dispatcher-error-vocabulary` exit 0; - the decision-letter layer reads 2,005 alone and 2,004 with this cut; - the seven suites that drive the moved methods, the staging they reach, or read the file pass (397 tests). ## Changeset None: `skip-changeset`, per ruling `6348891160` item 4. The published declarations and export lists are unchanged, and the bundle's bytes move only because code moved inside it. --- _Generated by [Claude Code](https://claude.ai/code/session_01LSXBc2GCBwiAL7vV4kWkyr)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01LSXBc2GCBwiAL7vV4kWkyr Co-authored-by: Claude <noreply@anthropic.com>
1 parent 830a63a commit f0fdd45

2 files changed

Lines changed: 192 additions & 91 deletions

File tree

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25224 cut 7] The database and the secret `AuthManager` hands better-auth
5+
* when it builds the instance — the ObjectQL adapter factory (or, with no
6+
* data engine, the in-memory store keyed by model name), the auth secret
7+
* resolved once per manager, and the environment read behind it that refuses
8+
* to boot production without one — moved out of `auth-manager.ts` whole.
9+
*
10+
* A MOVE, not a redesign. Each function below is the body of the
11+
* `AuthManager` method of the same name as it stood, re-indented one level
12+
* and otherwise unedited: the same adapter and created-user staging, the same
13+
* fallback store, the same secret precedence, the same production refusal and
14+
* development warning.
15+
*
16+
* ## How the moved bodies reach the class
17+
*
18+
* The shape #23050 set for `rest-server.ts` (its `data-action-routes.ts`
19+
* header carries the full reasoning), which `auth-manager-actor-guards.ts`,
20+
* `auth-manager-sign-in-lockout.ts`, `auth-manager-password-session-policy.ts`,
21+
* `auth-manager-database-hooks.ts`, `auth-manager-email-sms-delivery.ts` and
22+
* `auth-manager-plugin-assembly.ts` follow, applied per method. The class
23+
* keeps all three methods — the published `dist/*.d.ts` lists each one — and
24+
* each body is one literal of exactly the {@link DatabaseSecretHost} members
25+
* that method reads, plus a `.call`; `generateSecret` reads none, so its
26+
* method calls the function here directly. `createDatabaseConfig` is no
27+
* longer `async`: it returns the moved function's own promise, so a caller
28+
* awaits the same promise the body settles, with no extra wrapper.
29+
*
30+
* A method goes as a closure that calls it on the manager at call time, so a
31+
* test double or spy set on the manager is still the one a body reaches:
32+
* `stageUserCreatedInRequest`, which the adapter's created-record callback
33+
* runs inside every creating endpoint call, long after the factory was
34+
* built, and `generateSecret`. `config` goes as a GETTER: the class reassigns
35+
* it after construction (`setRuntimeBaseUrl` and `applyConfigPatch`), so it is
36+
* read off the manager when a body reads it, as before. `resolvedAuthSecret`
37+
* goes as a get/set pair, because `resolveAuthSecret` stores the secret on
38+
* the manager: every consumer still reads the one memo.
39+
*
40+
* Nothing re-exports this module, so the published surface does not move.
41+
*/
42+
43+
import { readEnvWithDeprecation } from '@objectstack/types';
44+
import type { AuthManager } from './auth-manager.js';
45+
import { createObjectQLAdapterFactory } from './objectql-adapter.js';
46+
47+
/**
48+
* The `AuthManager` members the functions below read — every one of them, and
49+
* no other; each function's `this` is the `Pick` of the members IT reads. Each
50+
* is typed AS the class member (an indexed access on the class), so a change
51+
* to a member's signature on the class is a change here too, and naming a
52+
* member the class does not have is a compile error. See the module header for
53+
* why `config` is a getter and `resolvedAuthSecret` an accessor.
54+
*/
55+
export interface DatabaseSecretHost {
56+
readonly config: AuthManager['config'];
57+
resolvedAuthSecret: AuthManager['resolvedAuthSecret'];
58+
generateSecret: AuthManager['generateSecret'];
59+
stageUserCreatedInRequest: AuthManager['stageUserCreatedInRequest'];
60+
}
61+
62+
/** The body of `AuthManager#createDatabaseConfig`; the method's docblock there is its contract. */
63+
export async function createDatabaseConfig(this: Pick<DatabaseSecretHost, 'config' | 'stageUserCreatedInRequest'>): Promise<any> {
64+
// Use ObjectQL adapter factory if dataEngine is provided
65+
if (this.config.dataEngine) {
66+
// createObjectQLAdapterFactory returns an AdapterFactory
67+
// (options => DBAdapter) which better-auth invokes via getBaseAdapter().
68+
// The factory is created by better-auth's createAdapterFactory and
69+
// automatically applies modelName/fields transformations declared in
70+
// the betterAuth config above.
71+
return createObjectQLAdapterFactory(this.config.dataEngine, {
72+
// ADR-0093 D7 — the adapter is the one place that sees the created
73+
// user's id INSIDE the creating endpoint call; staging it here lets the
74+
// session seam recognise that user without reading the row back.
75+
onRecordCreated: async (objectName, row) => {
76+
if (objectName !== 'sys_user') return;
77+
const { tryGetCurrentAuthEndpointContext } = await import('@better-auth/core/context');
78+
this.stageUserCreatedInRequest(tryGetCurrentAuthEndpointContext(), row?.id);
79+
},
80+
});
81+
}
82+
83+
// Fallback warning if no dataEngine is provided
84+
console.warn(
85+
'⚠️ WARNING: No dataEngine provided to AuthManager! ' +
86+
'Using in-memory storage. This is NOT suitable for production. ' +
87+
'Please provide a dataEngine instance (e.g., ObjectQL) in AuthManagerOptions.'
88+
);
89+
90+
// ⛔ NOT `undefined`, and ⛔ do not "simplify" it back to that.
91+
//
92+
// Handing better-auth no `database` makes it build its own in-memory store
93+
// in `getBaseAdapter`, and that store is keyed by the schema KEY while
94+
// every read resolves by `modelName`. Re-measured 2026-10-21 for #25096 on
95+
// better-auth 1.7.12, unchanged since the 1.7.2 reading
96+
// (`db/adapter-base.mjs` builds the store by schema KEY;
97+
// `@better-auth/memory-adapter` throws on the lookup, naming the model):
98+
//
99+
// getAuthTables(options) -> { oauthResource: { modelName: 'sys_oauth_resource' }, … }
100+
// its memoryDB -> { oauthResource: [] } // keyed by KEY
101+
// the adapter then asks -> 'sys_oauth_resource' // resolved by modelName
102+
// => Error: Model sys_oauth_resource not found
103+
//
104+
// So on that path EVERY model this package renames is unreachable —
105+
// `user`/`sys_user` included. It stayed invisible for as long as nothing
106+
// touched a renamed model during boot; the RFC 8707 resource seed does,
107+
// from the oauth-provider plugin's `init`, where the throw surfaces as an
108+
// UNHANDLED REJECTION rather than a failed request.
109+
//
110+
// Keying the store by `modelName` is what the adapter actually reads, so
111+
// this fixes the dev/test fallback instead of working around it. Production
112+
// never reaches this branch — it returns the ObjectQL factory above.
113+
//
114+
// The import is dynamic on purpose (the rest of better-auth is loaded the
115+
// same way here); `createAuthInstance` awaits this method, and better-auth
116+
// then calls the returned factory SYNCHRONOUSLY, so the module has to be
117+
// resolved before we hand it over, not inside it.
118+
const [{ memoryAdapter }, { getAuthTables }] = await Promise.all([
119+
import('better-auth/adapters/memory'),
120+
import('@better-auth/core/db'),
121+
]);
122+
return (options: any) => {
123+
const tables = getAuthTables(options) as Record<string, { modelName?: string }>;
124+
const db: Record<string, unknown[]> = {};
125+
for (const [key, table] of Object.entries(tables)) db[table?.modelName ?? key] = [];
126+
return memoryAdapter(db)(options);
127+
};
128+
}
129+
130+
/** The body of `AuthManager#resolveAuthSecret`; the method's docblock there is its contract. */
131+
export function resolveAuthSecret(this: Pick<DatabaseSecretHost, 'config' | 'generateSecret' | 'resolvedAuthSecret'>): string {
132+
if (!this.resolvedAuthSecret) {
133+
this.resolvedAuthSecret = this.config.secret || this.generateSecret();
134+
}
135+
return this.resolvedAuthSecret;
136+
}
137+
138+
/** The body of `AuthManager#generateSecret`; the method's docblock there is its contract. */
139+
export function generateSecret(): string {
140+
const envSecret = readEnvWithDeprecation('OS_AUTH_SECRET', ['AUTH_SECRET', 'BETTER_AUTH_SECRET'], { silent: true });
141+
if (envSecret) return envSecret;
142+
143+
// No secret configured. In production this is FATAL: a predictable
144+
// `dev-secret-<timestamp>` makes session tokens forgeable (session
145+
// forgery). Refuse to boot rather than run insecurely.
146+
if (process.env.NODE_ENV === 'production') {
147+
throw new Error(
148+
'[auth] OS_AUTH_SECRET is required in production but is not set. ' +
149+
'Refusing to boot with a temporary development secret — session tokens ' +
150+
'would be forgeable. Set OS_AUTH_SECRET to a strong random value.'
151+
);
152+
}
153+
154+
// Development / test only: fall back to an ephemeral secret, loudly.
155+
const fallbackSecret = 'dev-secret-' + Date.now();
156+
console.warn(
157+
'⚠️ WARNING: No OS_AUTH_SECRET environment variable set! ' +
158+
'Using a temporary development secret. ' +
159+
'This is NOT secure for production use. ' +
160+
'Please set OS_AUTH_SECRET in your environment variables.'
161+
);
162+
return fallbackSecret;
163+
}

‎packages/plugins/plugin-auth/src/auth-manager.ts‎

Lines changed: 29 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,15 @@ import {
230230
reportJwtSigningFailure,
231231
type PluginAssemblyHost,
232232
} from './auth-manager-plugin-assembly.js';
233+
// [#25224 cut 7] The database and the secret handed to better-auth; the
234+
// methods of the same names below hand each body the members it reads
235+
// (`DatabaseSecretHost`).
236+
import {
237+
createDatabaseConfig,
238+
generateSecret,
239+
resolveAuthSecret,
240+
type DatabaseSecretHost,
241+
} from './auth-manager-database-secret.js';
233242

234243
/**
235244
* Detect WebContainer (StackBlitz) environment.
@@ -4667,108 +4676,37 @@ export class AuthManager {
46674676
* silently. We therefore wrap the ObjectQL adapter in a factory function
46684677
* so it is correctly recognised as a `DBAdapterInstance`.
46694678
*/
4670-
private async createDatabaseConfig(): Promise<any> {
4671-
// Use ObjectQL adapter factory if dataEngine is provided
4672-
if (this.config.dataEngine) {
4673-
// createObjectQLAdapterFactory returns an AdapterFactory
4674-
// (options => DBAdapter) which better-auth invokes via getBaseAdapter().
4675-
// The factory is created by better-auth's createAdapterFactory and
4676-
// automatically applies modelName/fields transformations declared in
4677-
// the betterAuth config above.
4678-
return createObjectQLAdapterFactory(this.config.dataEngine, {
4679-
// ADR-0093 D7 — the adapter is the one place that sees the created
4680-
// user's id INSIDE the creating endpoint call; staging it here lets the
4681-
// session seam recognise that user without reading the row back.
4682-
onRecordCreated: async (objectName, row) => {
4683-
if (objectName !== 'sys_user') return;
4684-
const { tryGetCurrentAuthEndpointContext } = await import('@better-auth/core/context');
4685-
this.stageUserCreatedInRequest(tryGetCurrentAuthEndpointContext(), row?.id);
4686-
},
4687-
});
4688-
}
4689-
4690-
// Fallback warning if no dataEngine is provided
4691-
console.warn(
4692-
'⚠️ WARNING: No dataEngine provided to AuthManager! ' +
4693-
'Using in-memory storage. This is NOT suitable for production. ' +
4694-
'Please provide a dataEngine instance (e.g., ObjectQL) in AuthManagerOptions.'
4695-
);
4696-
4697-
// ⛔ NOT `undefined`, and ⛔ do not "simplify" it back to that.
4698-
//
4699-
// Handing better-auth no `database` makes it build its own in-memory store
4700-
// in `getBaseAdapter`, and that store is keyed by the schema KEY while
4701-
// every read resolves by `modelName`. Re-measured 2026-10-21 for #25096 on
4702-
// better-auth 1.7.12, unchanged since the 1.7.2 reading
4703-
// (`db/adapter-base.mjs` builds the store by schema KEY;
4704-
// `@better-auth/memory-adapter` throws on the lookup, naming the model):
4705-
//
4706-
// getAuthTables(options) -> { oauthResource: { modelName: 'sys_oauth_resource' }, … }
4707-
// its memoryDB -> { oauthResource: [] } // keyed by KEY
4708-
// the adapter then asks -> 'sys_oauth_resource' // resolved by modelName
4709-
// => Error: Model sys_oauth_resource not found
4710-
//
4711-
// So on that path EVERY model this package renames is unreachable —
4712-
// `user`/`sys_user` included. It stayed invisible for as long as nothing
4713-
// touched a renamed model during boot; the RFC 8707 resource seed does,
4714-
// from the oauth-provider plugin's `init`, where the throw surfaces as an
4715-
// UNHANDLED REJECTION rather than a failed request.
4716-
//
4717-
// Keying the store by `modelName` is what the adapter actually reads, so
4718-
// this fixes the dev/test fallback instead of working around it. Production
4719-
// never reaches this branch — it returns the ObjectQL factory above.
4720-
//
4721-
// The import is dynamic on purpose (the rest of better-auth is loaded the
4722-
// same way here); `createAuthInstance` awaits this method, and better-auth
4723-
// then calls the returned factory SYNCHRONOUSLY, so the module has to be
4724-
// resolved before we hand it over, not inside it.
4725-
const [{ memoryAdapter }, { getAuthTables }] = await Promise.all([
4726-
import('better-auth/adapters/memory'),
4727-
import('@better-auth/core/db'),
4728-
]);
4729-
return (options: any) => {
4730-
const tables = getAuthTables(options) as Record<string, { modelName?: string }>;
4731-
const db: Record<string, unknown[]> = {};
4732-
for (const [key, table] of Object.entries(tables)) db[table?.modelName ?? key] = [];
4733-
return memoryAdapter(db)(options);
4679+
private createDatabaseConfig(): Promise<any> {
4680+
// [#25224 cut 7] The body moved to `auth-manager-database-secret.ts` (see `DatabaseSecretHost`).
4681+
const readConfig = () => this.config;
4682+
const host: Pick<DatabaseSecretHost, 'config' | 'stageUserCreatedInRequest'> = {
4683+
get config() { return readConfig(); },
4684+
stageUserCreatedInRequest: (...a) => this.stageUserCreatedInRequest(...a),
47344685
};
4686+
return createDatabaseConfig.call(host);
47354687
}
47364688

47374689
/**
47384690
* Generate a secure secret if not provided
47394691
*/
47404692
/** The auth secret, resolved once — see {@link resolvedAuthSecret}. */
47414693
private resolveAuthSecret(): string {
4742-
if (!this.resolvedAuthSecret) {
4743-
this.resolvedAuthSecret = this.config.secret || this.generateSecret();
4744-
}
4745-
return this.resolvedAuthSecret;
4694+
// [#25224 cut 7] The body moved to `auth-manager-database-secret.ts` (see `DatabaseSecretHost`).
4695+
const readConfig = () => this.config;
4696+
const readResolvedAuthSecret = () => this.resolvedAuthSecret;
4697+
const writeResolvedAuthSecret = (v: AuthManager['resolvedAuthSecret']) => { this.resolvedAuthSecret = v; };
4698+
const host: Pick<DatabaseSecretHost, 'config' | 'generateSecret' | 'resolvedAuthSecret'> = {
4699+
get config() { return readConfig(); },
4700+
generateSecret: (...a) => this.generateSecret(...a),
4701+
get resolvedAuthSecret() { return readResolvedAuthSecret(); },
4702+
set resolvedAuthSecret(v) { writeResolvedAuthSecret(v); },
4703+
};
4704+
return resolveAuthSecret.call(host);
47464705
}
47474706

47484707
private generateSecret(): string {
4749-
const envSecret = readEnvWithDeprecation('OS_AUTH_SECRET', ['AUTH_SECRET', 'BETTER_AUTH_SECRET'], { silent: true });
4750-
if (envSecret) return envSecret;
4751-
4752-
// No secret configured. In production this is FATAL: a predictable
4753-
// `dev-secret-<timestamp>` makes session tokens forgeable (session
4754-
// forgery). Refuse to boot rather than run insecurely.
4755-
if (process.env.NODE_ENV === 'production') {
4756-
throw new Error(
4757-
'[auth] OS_AUTH_SECRET is required in production but is not set. ' +
4758-
'Refusing to boot with a temporary development secret — session tokens ' +
4759-
'would be forgeable. Set OS_AUTH_SECRET to a strong random value.'
4760-
);
4761-
}
4762-
4763-
// Development / test only: fall back to an ephemeral secret, loudly.
4764-
const fallbackSecret = 'dev-secret-' + Date.now();
4765-
console.warn(
4766-
'⚠️ WARNING: No OS_AUTH_SECRET environment variable set! ' +
4767-
'Using a temporary development secret. ' +
4768-
'This is NOT secure for production use. ' +
4769-
'Please set OS_AUTH_SECRET in your environment variables.'
4770-
);
4771-
return fallbackSecret;
4708+
// [#25224 cut 7] The body moved to `auth-manager-database-secret.ts`; it reads no member of this class.
4709+
return generateSecret();
47724710
}
47734711

47744712
/**

0 commit comments

Comments
 (0)