Skip to content

Commit fb63d39

Browse files
refactor(plugin-auth): move AuthManager's password, auth-gate and session policy into auth-manager-password-session-policy.ts (#25224 cut 3) (#25362)
<!-- 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, 6351993113 The basis for the `Clause-②: no` line: this is a move. No accept set, export, route, refusal 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 155 names under ESM and CJS (measured below). Cut 3 of the `auth-manager.ts` split: **the password, auth-gate and session policy (ADR-0069).** This is the rest of the card's "password policy, lockout, auth gates, session controls" area; cut 2 took its sign-in lockout path. Twelve `AuthManager` method bodies, 481 lines, move unedited (re-indented one level) into one new module, `packages/plugins/plugin-auth/src/auth-manager-password-session-policy.ts`. #25224 stays open for the later cuts. This follows the amended claim `6356007354`, which amends `6354594310` on the file the sweep PR left (#25326, landed note `6355882048`). The shape is cut 1's and cut 2's, which is #23050's. Branch: `claude/project-thread-lg3wrj`, the harness-pinned branch for this session, restarted from `origin/main` `5f4a3f088c` after #25326 merged. It names no issue, so the card relation is this body's first line. `auth-manager.ts` goes from 9,927 to 9,587 lines. The cut is +731 / −481 over 5 files: 1,212 changed lines, under the 3,000-line line, and two of the files are the regenerated tenant-audit census. It is not a governed surface (`check-governed-merges.mjs --branch HEAD`: 0 of the 5 paths hit the register). ## Why this seam, and what it leaves Ruling `6351993113` held back nine methods until their counted query-options erasures were swept. #25326 did that in place, so the bodies can move without carrying a counted site into a file the baseline does not list. This cut takes eight of those nine, plus the four private bodies of the same area that they lean on: `assertPasswordComplexity`, `stampPasswordChangedAt`, `parseHashes` and `resolvePasswordChangeUserId`. What stays in the class: - **`stampIdentitySource`**, the ninth. It is cloud ADR-0024 D4 provenance (`sys_user.source`), a different area, and moves with that area's cut. It no longer carries a counted site, so nothing holds it back. - **`isAuthGateActive`, `computeAuthGate`, `noteMustChangePasswordIssued` and `checkPasswordComplexity`.** Each is a one-expression body no longer than the stub it would leave, and `isAuthGateActive` is the per-request check its docblock calls cheap. ## What moved | method | base body (signature to brace) | lines | members it reads | |---|---|---:|---| | `assertPasswordComplexity` (D1) | `:8895`–`:8912` | 18 | `config` | | `refreshMustChangeCacheIfStale` | `:8998`–`:9017` | 20 | `_mustChangeCache`, `_mustChangeRefreshing` (both written), `getDataEngine` | | `refreshOrgMfaCacheIfStale` | `:9025`–`:9045` | 21 | `_orgMfaCache`, `_orgMfaRefreshing` (both written), `config`, `getDataEngine` | | `resolveUserAuthGates` | `:9159`–`:9251` | 93 | `_mustChangeCache`, `_orgMfaCache`, `config`, `getDataEngine`, `refreshMustChangeCacheIfStale`, `refreshOrgMfaCacheIfStale` | | `stampPasswordChangedAt` (D3) | `:9258`–`:9285` | 28 | `getDataEngine`, `logDurabilityDegradation` | | `parseHashes` | `:9291`–`:9299` | 9 | none | | `resolvePasswordChangeUserId` | `:9324`–`:9353` | 30 | `resolveActor` | | `assertPasswordNotReused` (D3) | `:9362`–`:9408` | 47 | `config`, `getDataEngine`, `parseHashes` | | `recordPasswordHistory` (D3) | `:9414`–`:9459` | 46 | `config`, `getDataEngine`, `logDurabilityDegradation`, `parseHashes` | | `unlockUser` (D2) | `:9685`–`:9731` | 47 | `getDataEngine`, `logDurabilityDegradation` | | `enforceSessionControls` (D4) | `:9740`–`:9818` | 79 | `config`, `getDataEngine`, `logDurabilityDegradation` | | `enforceConcurrentCap` (D4) | `:9825`–`:9874` | 50 | `config`, `getDataEngine`, `logDurabilityDegradation` | Lines are base `5f4a3f088c`. 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`. Only the two cache refreshes assign a `this` member, and only their own cache fields. No module-level declaration moves. The two imports the moved bodies use (`AuthGate` from `@objectstack/core`, `recoverInternalFieldsForSystemRead` from `./internal-field-readback.js`) are also used by code that stays, so `auth-manager.ts` keeps them. The new module imports each from the same place. ## The shape - **The class keeps every method** at its place, with its name, signature and docblock: 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 `PasswordSessionPolicyHost`, plus `.call`. `parseHashes` reads no member and calls its function directly. - **`PasswordSessionPolicyHost`** lives in the new module. It names the 11 members the bodies read, each typed as the class member (`AuthManager['getDataEngine']`, …). The module imports `AuthManager` as a type only, so there is no runtime cycle. - **Methods go as late-bound closures** (`(...a) => this.getDataEngine(...a)`), so a test double or spy set on the manager is still the one a body reaches. That includes `parseHashes` and the two cache refreshes, which bodies here ask through the manager. - **`config` goes as a getter.** The class reassigns it after construction (`setBaseUrl`, `applyConfigPatch`), and the bodies read it after an `await`. - **The four cache fields go as accessors.** `_orgMfaCache`, `_orgMfaRefreshing`, `_mustChangeCache` and `_mustChangeRefreshing` go as getters. In the two refreshes that assign them, they go as a get/set pair, whose setter writes the manager's own field through a closure (`(v: AuthManager['_mustChangeCache']) => { this._mustChangeCache = v; }`). The refreshes write from a background task that settles after the method returned, and the class's `isAuthGateActive` and `noteMustChangePasswordIssued` read and write the same fields. So every read and every write lands on the field, never on a copy. - **Nine of the methods are no longer `async`.** Each returns the moved async function's own promise, so a caller awaits the same promise the body settles. Building the host cannot throw, so no method now throws synchronously where it used to reject. `refreshMustChangeCacheIfStale`, `refreshOrgMfaCacheIfStale` and `parseHashes` were synchronous and stay so. - **Each call builds one small host object.** `resolveUserAuthGates`, `enforceSessionControls` and the two refreshes run per request on the session path, so that is one allocation per call there. This is the same per-call cost cut 1 and cut 2 put on their request-path methods. - **Nothing re-exports the new module**, so the published surface does not move. ## Readers re-pointed in the same PR | reader | what it reads | base | head | |---|---|---|---| | `api-error-code.pin.test.ts` | the sum of `new APIError(` sites over the files `AuthManager`'s bodies live in (floor 24) | 17 + 9 + 1 = 27 | 15 + 9 + 1 + 2 = 27. The `PASSWORD_POLICY_VIOLATION` throw of `assertPasswordComplexity` and the `PASSWORD_REUSE` throw of `assertPasswordNotReused` moved, so the module joins the list. | | the tenant-audit census (`check-tenant-audit-census.mjs`, its committed page and counts file) | every engine write call site, keyed by file | `auth-manager.ts` `update`: `sys_user` 5, `sys_session` 3, `sys_account` 1, `sys_two_factor` 1 | `auth-manager.ts`: `sys_user` 2. `auth-manager-password-session-policy.ts`: `sys_user` 3, `sys_session` 3, `sys_account` 1, `sys_two_factor` 1. Regenerated with `node scripts/tenant-audit-census.mjs --write`, and the page's hand-written erased-receiver figures follow (see below). | Without the pin edit the sum would read 25 and the floor of 24 would hold in silence, which is the drift the card forbids. Measured as reading no moved line: - **Plugin-factory scans** in `better-auth-schema-parity.test.ts` and `managed-extension-fields.test.ts`: the moved bodies import only `better-auth/api`, which the scan pattern does not match. - **`pnpm check:dispatcher-error-vocabulary`**: exit 0. Its `auth-manager.ts` row names `deliverPhoneOtp`, which did not move. - **`pnpm check:durability-log-level`**: exit 0. - **The query-options erasure ratchet**: exit 0, "baseline key set verified against 5f4a3f0: no files added". The moved bodies' `engine.update(…, { context: SYSTEM_CTX } as any)` casts move as written; the ratchet does not count them. - **Every symbol anchor into the file**, with the same reading at base and at head: `check-adr-symbol-anchors` (2,383 anchors, 466 symbol), `check-adr-anchors`, `check-scripts-symbol-anchors`, `check-spec-docblock-symbol-anchors`, `check-system-context-census` and `check-platform-checklist`, all exit 0. Its one anchor into this region, `auth-manager.ts#computeAuthGate`, names a method the class keeps. These checkers were run by hand: `dispatch-gates` does not derive the ADR one. ## The census reading The census reads 237 engine write sites at base and at head. Compared per site, with the new module's file name mapped back to `auth-manager.ts`, every site keeps its object, verb, tenancy, context and elevation reading: 0 differences, and 0 unresolved and 0 unledgered receivers on both sides. What changes is how the census reaches the 8 `engine.update` sites in the moved bodies: | method | object | sites | |---|---|---:| | `resolveUserAuthGates` | `sys_user` | 1 | | `stampPasswordChangedAt` | `sys_user` | 1 | | `recordPasswordHistory` | `sys_account` | 1 | | `unlockUser` | `sys_user`, `sys_two_factor` | 2 | | `enforceSessionControls` | `sys_session` | 2 | | `enforceConcurrentCap` | `sys_session` | 1 | - **In the class**, their receiver is `const engine = this.getDataEngine()`. The census reads it through the method's declared return type in the same file, so the 8 counted as "receiver carried a readable engine type". - **In the module**, the same text runs on a `this` typed as `Pick<PasswordSessionPolicyHost, ...>`. The census does not follow a function's `this` parameter, so it places them by their literal object names, all of them declared objects. - **The generated table** moves `readable engine type` 193 → 185 and `placed by the object NAME` 34 → 42, the only total that moves. The page's two hand-written erased-receiver counts (`PROSE_COUNTS`) are that sum plus the parameter and ledger rows, so they move 44 → 52. 52 of 237 is 21.9%, so the sentence that called 44 "just under a fifth of the population" now says "just over a fifth". - **The corpus-scale block** (not enforced) is re-measured too: 712 sources, one of them the new module, and 172 non-engine subtractions, the same count at base and at head. The page's older 708 and 171 were measured at `75983184c`. The effect is the same as cut 2's (3 sites there). Keeping these 8 typed would mean annotating `engine` in the moved bodies, which is not a move, or teaching the census to read `this` parameters, which is a census logic change. So neither belongs in this PR. ## Proof Builds and suites ran through `os-verify-lock`, and every exit code was captured before any pipe. Base `5f4a3f088c`, head `16770e81ae`. 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 12 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. - The class method keeps its docblock and signature, with `async` dropped. - Result: 12 of 12 pass, 481 body lines. - **Negative control:** one token changed in the module (`60_000` → `60_001` in `refreshMustChangeCacheIfStale`) makes that method fail (tokens and lines DIFFER), and the run exits 1. Restored afterwards; `git diff HEAD` was empty. - **An independent reading:** `git diff --color-moved=plain --color-moved-ws=allow-indentation-change 5f4a3f0 16770e8` over the two source files marks 457 of the 466 deleted lines and 485 of the 710 added lines as moved. The other added lines are the stubs' host literals, `PasswordSessionPolicyHost`, the module header, the per-function pointer docblocks and the new import. **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` | `4bc04c66f724c79782493249820b1b7d9bff7c826931ce6b362d325af8797891` | `4bc04c66f724c79782493249820b1b7d9bff7c826931ce6b362d325af8797891` | 0 | `3c8612c88f3f41c963c243e91c2523d2754b2accb4a4c2ba52a5627fd6eaa1e5` | `3c8612c88f3f41c963c243e91c2523d2754b2accb4a4c2ba52a5627fd6eaa1e5` | | `dist/index.d.mts` | `d70d7d8063053de2d39bd0dbae06423bbe0ef1dcecf5130b867c6f4d79a0c8cd` | `d70d7d8063053de2d39bd0dbae06423bbe0ef1dcecf5130b867c6f4d79a0c8cd` | 0 | `51510aad856bbacd24757d1c62dc7273bfd6313ed543c8b1f1e86195fc2c7cd0` | `51510aad856bbacd24757d1c62dc7273bfd6313ed543c8b1f1e86195fc2c7cd0` | - 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 assertPasswordNotReused;` → `assertPasswordNotReused;`, line 4001, a one-line change) hashes raw `7d3450b84103f5d80cfbc8a2e4391ee22919d8137744c6a4550a1b237bcc5115` and normalised `56cff0cedafef19e8cf0e559a612c61d84b48aef3134c6c01d977887a8ef061e`. Both differ from the readings above. - **Export lists:** ESM `dist/index.mjs` and CJS `dist/index.js` each have 155 names at base and at head, the same list (the sha256 of the sorted names is `ed3ac8b902031d30…` 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 187 passed (187)`, `Tests 3525 passed | 10 skipped (3535)`, 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. - `eslint --no-inline-config` (the root `lint` script's binary and flags) over the 3 TypeScript files: exit 0. **4. Gates.** - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives 89 commands for the 5 paths. All 89 ran at `16770e81ae` after the full build, and all 89 exited 0. - `--ran`: 89 derived, 89 run, 0 NOT-MEASURED, 0 UNRUN. - `check-governed-merges.mjs --branch HEAD`: NOT governed, 1,212 changed lines. - **Docs drift** (`node scripts/docs-audit/affected-docs.mjs --json 5f4a3f0`, exit 0): 31 pages, 12 of them release-owned. Every page is reached through a literal the moved bodies carry unchanged (`sys_session`, `sys_account`, `sys_organization`, `sys_two_factor`, `expires_at`, `revoked_at`, `previous_password_hashes`, `must_change_password`, `MFA_REQUIRED`, `PASSWORD_EXPIRED`, `PASSWORD_RESET_REQUIRED`), or through `resolveActor`, which the class keeps (`system-context.mdx`, whose census reads the same at base and head). No page restates a rule this move changes, so no page edit is owed beyond the census's own two documents. - **Serial constraints** (in the claim): of the open PRs, only #25309 touches `auth-manager.ts`, at the import block and near `:8011`, outside every moved body. `git merge-tree` of this branch with #25309's head is clean. ## 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 4d7e2b1 commit fb63d39

5 files changed

Lines changed: 731 additions & 481 deletions

File tree

‎content/docs/permissions/tenant-audit-census.mdx‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ what moved this page's population from 225 to 227; nothing about the two sites
9393
changed, only whether this instrument could see them.
9494

9595
**The expensive failure direction is a keyword.** Sites whose receiver the author
96-
typed `any` have no type to read, and there are 44 of them — just under a fifth
96+
typed `any` have no type to read, and there are 52 of them — just over a fifth
9797
of the population, concentrated in exactly the seed and bootstrap paths this
9898
control exists for. Scoring an unreadable receiver as "not an engine" would have
9999
dropped every one of them silently, with a clean exit and a smaller number that
@@ -210,7 +210,7 @@ be stated is what this instrument counts, which is written above and re-runnable
210210
at any commit.
211211

212212
Two structural facts do plausibly widen this reading against any hand or regex
213-
one, and both are counted in the generated tables below: the 44 sites reached
213+
one, and both are counted in the generated tables below: the 52 sites reached
214214
through an erased (`any`) receiver, and the 51 that name their object through a
215215
`const` rather than inline. An instrument that read either the way a person does
216216
would report a smaller number and would not say so.
@@ -249,8 +249,8 @@ cannot read, and they are neither in nor out.
249249

250250
| how the instrument reached the site | count |
251251
| :--- | ---: |
252-
| receiver carried a readable engine type | 193 |
253-
| receiver erased, placed by the object NAME | 34 |
252+
| receiver carried a readable engine type | 185 |
253+
| receiver erased, placed by the object NAME | 42 |
254254
| receiver erased, placed by an `object: string` PARAMETER | 5 |
255255
| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 |
256256

@@ -307,13 +307,13 @@ holds still. They are required to be HERE and to say WHEN they were true;
307307
their values are not compared. The reasoning, and the measurement behind it,
308308
are in `scripts/check-tenant-audit-census.mjs`.
309309

310-
Measured on 2026-10-22 at `75983184c`.
310+
Measured on 2026-10-23 at `5f4a3f088`.
311311

312312
| corpus scale (not enforced) | count |
313313
| :--- | ---: |
314-
| tracked non-test sources scanned | 708 |
314+
| tracked non-test sources scanned | 712 |
315315
| engine-shaped types recognised | 87 |
316316
| declared objects in the registry | 117 |
317-
| same-named calls subtracted as non-engine | 171 |
317+
| same-named calls subtracted as non-engine | 172 |
318318

319319
{/* END GENERATED: tenant-audit-census */}

‎docs/audits/2026-08-tenant-audit-write-call-sites.counts.md‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -90,14 +90,14 @@ holds still. They are required to be HERE and to say WHEN they were true;
9090
their values are not compared. The reasoning, and the measurement behind it,
9191
are in `scripts/check-tenant-audit-census.mjs`.
9292

93-
Measured on 2026-10-22 at `75983184c`.
93+
Measured on 2026-10-23 at `5f4a3f088`.
9494

9595
| corpus scale (not enforced) | count |
9696
| :--- | ---: |
97-
| tracked non-test sources scanned | 708 |
97+
| tracked non-test sources scanned | 712 |
9898
| engine-shaped types recognised | 87 |
9999
| declared objects in the registry | 117 |
100-
| same-named calls subtracted as non-engine | 171 |
100+
| same-named calls subtracted as non-engine | 172 |
101101

102102
## Every site
103103

@@ -130,11 +130,12 @@ Measured on 2026-10-22 at `75983184c`.
130130
| `packages/plugins/plugin-auth/src/admin-user-endpoints.ts` | `update` | `sys_user` | enabled | elevated | 1 |
131131
| `packages/plugins/plugin-auth/src/adopt-membership.ts` | `update` | `SystemObjectName.MEMBER` | undecidable | PROVABLY NONE | 1 |
132132
| `packages/plugins/plugin-auth/src/audience-gate-test-support.ts` | `insert` | `sys_invitation` | enabled | elevated | 1 |
133+
| `packages/plugins/plugin-auth/src/auth-manager-password-session-policy.ts` | `update` | `sys_account` | enabled | options unreadable | 1 |
134+
| `packages/plugins/plugin-auth/src/auth-manager-password-session-policy.ts` | `update` | `sys_session` | enabled | options unreadable | 3 |
135+
| `packages/plugins/plugin-auth/src/auth-manager-password-session-policy.ts` | `update` | `sys_two_factor` | enabled | options unreadable | 1 |
136+
| `packages/plugins/plugin-auth/src/auth-manager-password-session-policy.ts` | `update` | `sys_user` | enabled | options unreadable | 3 |
133137
| `packages/plugins/plugin-auth/src/auth-manager-sign-in-lockout.ts` | `update` | `sys_user` | enabled | options unreadable | 3 |
134-
| `packages/plugins/plugin-auth/src/auth-manager.ts` | `update` | `sys_account` | enabled | options unreadable | 1 |
135-
| `packages/plugins/plugin-auth/src/auth-manager.ts` | `update` | `sys_session` | enabled | options unreadable | 3 |
136-
| `packages/plugins/plugin-auth/src/auth-manager.ts` | `update` | `sys_two_factor` | enabled | options unreadable | 1 |
137-
| `packages/plugins/plugin-auth/src/auth-manager.ts` | `update` | `sys_user` | enabled | options unreadable | 5 |
138+
| `packages/plugins/plugin-auth/src/auth-manager.ts` | `update` | `sys_user` | enabled | options unreadable | 2 |
138139
| `packages/plugins/plugin-auth/src/auth-manager.ts` | `insert` | `sys_user_permission_set` | enabled | elevated | 1 |
139140
| `packages/plugins/plugin-auth/src/auth-manager.ts` | `delete` | `sys_verification` | enabled | elevated | 1 |
140141
| `packages/plugins/plugin-auth/src/auth-manager.ts` | `insert` | `sys_verification` | enabled | elevated | 1 |

‎packages/plugins/plugin-auth/src/api-error-code.pin.test.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,12 @@ describe('every new APIError( in plugin-auth/src carries an ADR-0112 code (#2424
175175
// throw does not have to edit this line; only losing them does.
176176
// [#25224] `AuthManager`'s throws are counted over the files its methods'
177177
// bodies live in: `auth-manager.ts` and the bodies moved out of it whole.
178-
const authManagerFiles = ['auth-manager.ts', 'auth-manager-actor-guards.ts', 'auth-manager-sign-in-lockout.ts'];
178+
const authManagerFiles = [
179+
'auth-manager.ts',
180+
'auth-manager-actor-guards.ts',
181+
'auth-manager-sign-in-lockout.ts',
182+
'auth-manager-password-session-policy.ts',
183+
];
179184
expect(sites.length).toBeGreaterThanOrEqual(30);
180185
expect(new Set(sites.map((s) => s.file)).size).toBeGreaterThanOrEqual(4);
181186
expect(perFile.filter((f) => authManagerFiles.includes(f.file)).reduce((n, f) => n + f.sites.length, 0)).toBeGreaterThanOrEqual(24);

0 commit comments

Comments
 (0)