Skip to content

Commit 70ff998

Browse files
refactor(plugin-auth): move AuthManager's audience gate and membership admission into auth-manager-membership-admission.ts (#25224 cut 8) (#25423)
<!-- 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, 6364006498 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). Ruling `6364006498` (Sales's ruling A on this card, for bodies that read shared unexported helpers) is cited because the claim runs under it; no body in this cut reads such a helper, so its `index.ts` name list does not land here. Cut 8 of the `auth-manager.ts` split: **who may join, and what joining settles.** Four `AuthManager` method bodies (163 lines) move unedited, re-indented one level, into one new module, `packages/plugins/plugin-auth/src/auth-manager-membership-admission.ts`. #25224 stays open for the later cuts. This follows the amended claim `6365318480`, which amends `6363170079` on the file cut 7 left (#25404, landed note `6364312052`). The shape is cuts 1 to 7's, which is #23050's. Branch: `claude/project-thread-lg3wrj`, the harness-pinned branch for this session, restarted from `origin/main` `f0fdd45bbc` after #25404 merged. It names no issue, so the card relation is this body's first line. `auth-manager.ts` goes from 8,909 to 8,790 lines. The cut is +321 / −173 over 5 files: 494 changed lines, under the 3,000-line line. It is not a governed surface (`check-governed-merges.mjs --branch HEAD`: 0 of the 5 paths hit the register). Two commits: the move, and the census documents `--write` regenerates for it. ## Why this seam These are the audience gate and what a new user's creation settles: - `validateAudienceAdmission`: the gate better-auth's `user.validateUserInfo` callback runs on every user creation. It asks the manager the bootstrap, invitation and operator-provisioning questions, gives the audience verdict and refusal codes, and stages the owner-verified stamp and the self-registration grant; - `settleMembership`: the membership reconciler's one assembly of inputs, which both creation seams call, with the live membership policy; - `landSelfRegistrationGrant`: the self-registration permission set written for the membership's organization, idempotently; - `reportUngrantedSelfRegistrant`: the durability-level report of a grant that did not land. What stays, and why: - `settleSelfRegistrationGrant` stays. Its body does `this.pendingSelfRegistrationGrants.delete(email)` (`:5857`). The tenant-audit census reads a `this.` receiver's type from class property declarations only, so in the new module that call is a receiver it cannot read, and `check-tenant-audit-census` fails with an untyped-receiver problem (measured on a draft that moved it). Placing it would need a new `UNTYPED_RECEIVERS` row in `scripts/tenant-audit-census.mjs`, a ledger that is shrink-only. In the class, the census reads it as before. - `sendExistingAccountSignUpNotice` stays: `auth-email-locale.test.ts` describes the auth mail surface by its call sites (see the finding at the end). - The membership-area methods that read a `private static` stay, as agreed with the seat: reading one from outside the class is TS2341, and making it public would change the `.d.ts`. ## What moved | method | base signature line | body lines | members it reads | |---|---:|---:|---| | `settleMembership` | `:4899` | 24 | `config`, `getMembershipPolicy` | | `validateAudienceAdmission` | `:5158` | 112 | `audienceLogError`, `config`, `enterpriseOAuthProviderIds`, `getAudience`, `hasPendingInvitationFor`, `isBootstrapCreation`, `isOperatorProvisioning`, `selfRegistrationSetResolvable`, `stageOwnerVerifiedStamp`, `stageSelfRegistrationGrant` | | `landSelfRegistrationGrant` | `:5894` | 19 | `config` | | `reportUngrantedSelfRegistrant` | `:6057` | 8 | `audienceLogError` | Lines are base `f0fdd45bbc`. 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 assigns to a `this` member or reads a module-level declaration of `auth-manager.ts`. **The one value read of `AuthManager`.** `validateAudienceAdmission` reads the public static `AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD` (base `:5183`). So the new module imports `AuthManager` by value from `./auth-manager.js`, the first cut in the series to do so. That is the only value read: the module's other `AuthManager` mentions are the host interface's indexed-access types and comments. It sits inside the function body and runs only when better-auth calls the gate. The module's top level is six imports, an interface and four function declarations, so nothing reads the class while the two modules are first evaluated. **Imports.** These leave `auth-manager.ts`, each now with no reader there, and the new module imports each from the same place: - `classifyCreationMethod`, `decideAudienceAdmission`, `AUDIENCE_CONFIG_ERROR` and `type AudienceCreationClass` from `./audience-posture.js` (the line stays for its six other names); - `shouldStampOwnerVerifiedAtCreation` (`./walled-owner-operator-stamp.js`) and `GRANT_SET_NAME_FIELD` (`./grant-set-name.js`), whose import lines go; - `reconcileMembership` (`./reconcile-membership.js`). The line stays for `type MembershipPolicy` and `type ReconcileOutcome`, the second still `settleMembership`'s return type on the class. The new module also imports `withSystemReadContext` from `./objectql-adapter.js`, which `auth-manager.ts` keeps importing. **Evaluation order.** The three modules whose value import leaves are now first reached through the new module's import line, later in `auth-manager.ts`'s import list. Their top levels are declarations and literal constants. Every module they import (`@objectstack/types`, `@objectstack/core`, `@objectstack/spec/security`, `./auth-actor-attribution.js`) is imported by `auth-manager.ts` above the new module's line. So the change in order cannot be observed. ## The shape - **The class keeps all four 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 `MembershipAdmissionHost`, plus `.call`. - **`MembershipAdmissionHost`** lives in the new module. It names the 11 members the bodies read, each typed as the class member (`AuthManager['config']`, …). - **Every method goes as a late-bound closure** (`(...a) => this.m(...a)`), so a double or spy set on the manager is still the one a body reaches: the bootstrap, invitation and operator-provisioning probes, the two stagings and `getMembershipPolicy` (a live platform setting) all stay on the class. - **`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 three `async` 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. `reportUngrantedSelfRegistrant` was synchronous and stays so. - **The cost:** one small host object per call. The four run on user creation (and `validateAudienceAdmission` also on the password sign-up route) and on the self-registration grant's paths, not on every request. - **Nothing re-exports the new module**, so the published surface does not move. ## Path readers **Migrated in this PR:** - **The tenant-audit census and its two documents.** The moved `landSelfRegistrationGrant` carries one census site, the `sys_user_permission_set` insert (base `:5907`). `node scripts/tenant-audit-census.mjs --write` (the second commit) moves its row in `docs/audits/2026-08-tenant-audit-write-call-sites.counts.md` from `auth-manager.ts` to the new file. It also re-measures the corpus-scale block in both documents, which the checker does not enforce: measured at `487d57fed` (the move commit), 723 tracked sources scanned (716 at the last write; 722 at base, 723 at head) and 174 same-named calls subtracted (172 at the last write; 174 at base and head). No prose figure moves, and `check-tenant-audit-census` exits 0. - **`find-envelope-limb-removal.test.ts`'s B5 `site:` label**, which named `auth-manager.ts` as the file of the `sys_user_permission_set` existence read. That read moves with `landSelfRegistrationGrant`, so the label names the new file. It is a test-title string only; no gate or test reads it back. **Measured as reading no moved line:** - **`check-dispatcher-error-vocabulary.mjs`** and **`api-error-code.pin.test.ts`'s `authManagerFiles` floor**: no `new APIError(` sits in the moved bodies. The gate reads 56 sites found and 56 classified at base and at head. - **The plugin-factory scans** in `better-auth-schema-parity.test.ts` and `managed-extension-fields.test.ts`: the imports that leave are local modules, not plugin factories, and the moved text has no dynamic import. - **`check-auth-mount-ledger.mjs`** and **`auth-manager-base-path.test.ts`**: no base-path literal or declaration sits in the moved text. - **The system-context census** reads 148 elevation read sites at base and at head. - **Callers through the manager**: the suites that drive the four methods call each through the class method, and all pass at head (below): - `audience-posture.test.ts` (the gate's verdicts and refusal codes); - `logger-receiver-detach.test.ts` (the degradation report's logger receiver); - `find-envelope-limb-removal.test.ts` (the grant's existence read); - `self-registration-grant-first-membership.test.ts` (the grant's landing); - `dev-admin-seed-credential-gate.test.ts` (operator provisioning through the gate; it reads `AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD` itself); - `signup-existing-address-refusal.test.ts`. - **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, and all 29 are `declaration`-class at base, with the 4 bodies blanked, and at head. None names a moved method. Two name a token the moved text also carries, and both resolve on declarations that stay: `#AuthManager` (ADR-0135; the moved gate reads `AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD`) and `#isBootstrapCreation` (`docs/qa/platform-checklist/areas/identity-auth.json:1810`; the gate calls `this.isBootstrapCreation`). ## The decision-letter layer does not move The seat asks each cut's report for this count at both ends. `check-adr-anchors`' decision-letter layer reads 2,004 at base `f0fdd45bbc` and 2,004 at head, and the gate exits 0 at both. The moved text cites `ADR-0078` without a decision letter, so only the number layer sees it move: 52,156 citations at both, across 6,632 files at base and 6,633 at head. No anchor entry is added for the new module, as for cuts 1 to 7's modules. ## Proof Builds and suites ran through `os-verify-lock`, and every exit code was captured before any pipe. Base `f0fdd45bbc`, head `fa0187ad1f` (the move is `487d57fedd`). 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 4 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 the three that had it. - Result: 4 of 4 pass, 163 body lines. | method | body lines | tokens | |---|---:|---:| | `settleMembership` | 24 | 93 | | `validateAudienceAdmission` | 112 | 466 | | `landSelfRegistrationGrant` | 19 | 154 | | `reportUngrantedSelfRegistrant` | 8 | 22 | - **Negative control:** one token changed in the module (`'sys_user_permission_set'` → `'sys_user_permission_sets'` in `landSelfRegistrationGrant`'s insert) 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 f0fdd45 fa0187a` over the two source files marks 161 of the 165 deleted lines and 167 of the 313 added lines as moved. - The other 4 deleted lines are the `reconcileMembership` import line and the three `async` signature lines. - The other added lines are the stubs' host literals, `MembershipAdmissionHost`, 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 validateAudienceAdmission;` → `validateAudienceAdmission;`, line 2107, a one-line change) hashes raw `a44eb00dde9098cdcb14efc26e659d46f940a6df42d3cc1759bb78ad6d0f1db8` and normalised `df635741a7c0a9d7d3f34be34bc52a32ea6406d9ada1112acd0de9f924990750`. 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)`, exit 0. Head: the same counts, 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 3 source files: exit 0. **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, 1 in the new module at head, 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,004 at both, as above. - `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. **5. Gates.** - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives 89 commands for the 5 paths. All 89 ran at `fa0187ad1f` after the full build, and all 89 exited 0. - `--ran` over the record with each exit code: 89 derived, 89 run, 0 NOT-MEASURED, a derived zero. - `check-governed-merges.mjs --branch HEAD`: NOT governed, 494 changed lines. - **Docs drift** (`node scripts/docs-audit/affected-docs.mjs --json f0fdd45`, exit 0): 12 pages, 6 of them release-owned. Every one is listed through `sys_user_permission_set`, a literal `landSelfRegistrationGrant` carries unchanged. No page restates a rule this move changes, so no doc edit is owed. - **Serial constraints** (in the claim): 11 PRs are open, and none touches `plugin-auth/src`, the census script or its two documents, or a reader above. `origin/main` is still `f0fdd45bbc`. ## Finding, outside this cut The population comments in `plugin-auth/src/auth-email-locale.test.ts` (around `:44`, `:179` and `:857`–`:865`) describe the auth mail surface as five `sendTemplate` call sites, and say no other file under `plugin-auth/src` calls one. On base there are six: five in `auth-manager.ts` and one in `auth-manager-email-sms-delivery.ts` (moved there by cut 5). This cut touches neither the sends nor the comments, and the comments are not a gate. Reported on the card for the seat to triage. ## 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 141056b commit 70ff998

5 files changed

Lines changed: 321 additions & 173 deletions

File tree

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -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-23 at `d45e01440`.
310+
Measured on 2026-10-23 at `487d57fed`.
311311

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

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

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

Lines changed: 4 additions & 4 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-23 at `d45e01440`.
93+
Measured on 2026-10-23 at `487d57fed`.
9494

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

102102
## Every site
103103

@@ -132,12 +132,12 @@ Measured on 2026-10-23 at `d45e01440`.
132132
| `packages/plugins/plugin-auth/src/adopt-membership.ts` | `update` | `SystemObjectName.MEMBER` | undecidable | PROVABLY NONE | 1 |
133133
| `packages/plugins/plugin-auth/src/audience-gate-test-support.ts` | `insert` | `sys_invitation` | enabled | elevated | 1 |
134134
| `packages/plugins/plugin-auth/src/auth-manager-database-hooks.ts` | `update` | `sys_user` | enabled | options unreadable | 2 |
135+
| `packages/plugins/plugin-auth/src/auth-manager-membership-admission.ts` | `insert` | `sys_user_permission_set` | enabled | elevated | 1 |
135136
| `packages/plugins/plugin-auth/src/auth-manager-password-session-policy.ts` | `update` | `sys_account` | enabled | options unreadable | 1 |
136137
| `packages/plugins/plugin-auth/src/auth-manager-password-session-policy.ts` | `update` | `sys_session` | enabled | options unreadable | 3 |
137138
| `packages/plugins/plugin-auth/src/auth-manager-password-session-policy.ts` | `update` | `sys_two_factor` | enabled | options unreadable | 1 |
138139
| `packages/plugins/plugin-auth/src/auth-manager-password-session-policy.ts` | `update` | `sys_user` | enabled | options unreadable | 3 |
139140
| `packages/plugins/plugin-auth/src/auth-manager-sign-in-lockout.ts` | `update` | `sys_user` | enabled | options unreadable | 3 |
140-
| `packages/plugins/plugin-auth/src/auth-manager.ts` | `insert` | `sys_user_permission_set` | enabled | elevated | 1 |
141141
| `packages/plugins/plugin-auth/src/auth-manager.ts` | `delete` | `sys_verification` | enabled | elevated | 1 |
142142
| `packages/plugins/plugin-auth/src/auth-manager.ts` | `insert` | `sys_verification` | enabled | elevated | 1 |
143143
| `packages/plugins/plugin-auth/src/auth-plugin.ts` | `update` | `sys_oauth_application` | enabled | PROVABLY NONE | 1 |
Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25224 cut 8] Who may join, and what joining settles — the audience gate
5+
* better-auth's `user.validateUserInfo` callback runs on every user creation,
6+
* the membership reconciler's one assembly of inputs that both creation seams
7+
* call, and the self-registration permission set a self-serve sign-up was
8+
* promised (landed, or reported as not granted) — moved out of
9+
* `auth-manager.ts` whole.
10+
*
11+
* A MOVE, not a redesign. Each function below is the body of the
12+
* `AuthManager` method of the same name as it stood, re-indented one level
13+
* and otherwise unedited: the same audience verdicts and refusal codes, the
14+
* same bootstrap, invitation and operator-provisioning questions asked of the
15+
* manager, the same membership policy read, the same grant landing and the
16+
* same degradation report.
17+
*
18+
* ## How the moved bodies reach the class
19+
*
20+
* The shape #23050 set for `rest-server.ts` (its `data-action-routes.ts`
21+
* header carries the full reasoning), which `auth-manager-actor-guards.ts`,
22+
* `auth-manager-sign-in-lockout.ts`, `auth-manager-password-session-policy.ts`,
23+
* `auth-manager-database-hooks.ts`, `auth-manager-email-sms-delivery.ts`,
24+
* `auth-manager-plugin-assembly.ts` and `auth-manager-database-secret.ts`
25+
* follow, applied per method. The class keeps all four methods — the
26+
* published `dist/*.d.ts` lists each one — and each body is one literal of
27+
* exactly the {@link MembershipAdmissionHost} members that method reads, plus
28+
* a `.call`. The three that were `async` no longer are: each returns the moved
29+
* function's own promise, so a caller awaits the same promise the body
30+
* settles, with no extra wrapper.
31+
*
32+
* A method goes as a closure that calls it on the manager at call time, so a
33+
* test double or spy set on the manager is still the one a body reaches: the
34+
* bootstrap, invitation and operator-provisioning probes the audience gate
35+
* asks (which stay on the class), the stagings it writes, and
36+
* `getMembershipPolicy` (a live platform setting). `config` goes as a GETTER:
37+
* the class reassigns it after construction (`setRuntimeBaseUrl` and
38+
* `applyConfigPatch`), so it is read off the manager when a body reads it, as
39+
* before.
40+
*
41+
* `AuthManager` is imported here by VALUE, for one read: the public static
42+
* `AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD`, inside
43+
* `validateAudienceAdmission`, when better-auth calls the gate. The two
44+
* modules import each other, and that is safe because nothing reads the class
45+
* while they are first evaluated: this module's top level is imports, an
46+
* interface and function declarations.
47+
*
48+
* Nothing re-exports this module, so the published surface does not move.
49+
*/
50+
51+
import { AuthManager } from './auth-manager.js';
52+
import {
53+
classifyCreationMethod,
54+
decideAudienceAdmission,
55+
AUDIENCE_CONFIG_ERROR,
56+
type AudienceCreationClass,
57+
} from './audience-posture.js';
58+
import { GRANT_SET_NAME_FIELD } from './grant-set-name.js';
59+
import { withSystemReadContext } from './objectql-adapter.js';
60+
import { reconcileMembership, type ReconcileOutcome } from './reconcile-membership.js';
61+
import { shouldStampOwnerVerifiedAtCreation } from './walled-owner-operator-stamp.js';
62+
63+
/**
64+
* The `AuthManager` members the functions below read — every one of them, and
65+
* no other; each function's `this` is the `Pick` of the members IT reads. Each
66+
* is typed AS the class member (an indexed access on the class), so a change
67+
* to a member's signature on the class is a change here too, and naming a
68+
* member the class does not have is a compile error. See the module header for
69+
* why `config` is a getter.
70+
*/
71+
export interface MembershipAdmissionHost {
72+
readonly config: AuthManager['config'];
73+
audienceLogError: AuthManager['audienceLogError'];
74+
enterpriseOAuthProviderIds: AuthManager['enterpriseOAuthProviderIds'];
75+
getAudience: AuthManager['getAudience'];
76+
getMembershipPolicy: AuthManager['getMembershipPolicy'];
77+
hasPendingInvitationFor: AuthManager['hasPendingInvitationFor'];
78+
isBootstrapCreation: AuthManager['isBootstrapCreation'];
79+
isOperatorProvisioning: AuthManager['isOperatorProvisioning'];
80+
selfRegistrationSetResolvable: AuthManager['selfRegistrationSetResolvable'];
81+
stageOwnerVerifiedStamp: AuthManager['stageOwnerVerifiedStamp'];
82+
stageSelfRegistrationGrant: AuthManager['stageSelfRegistrationGrant'];
83+
}
84+
85+
/** The body of `AuthManager#settleMembership`; the method's docblock there is its contract. */
86+
export async function settleMembership(this: Pick<MembershipAdmissionHost, 'config' | 'getMembershipPolicy'>, userId: unknown): Promise<ReconcileOutcome | undefined> {
87+
try {
88+
const result = await reconcileMembership(
89+
this.config.dataEngine,
90+
typeof userId === 'string' && userId ? userId : undefined,
91+
{
92+
// #5152 — read through the accessor, not `this.config` directly: it is
93+
// the single source the backfill path reads too.
94+
policy: this.getMembershipPolicy(),
95+
resolveTargetOrg: async () => {
96+
const tenancy = this.config.getTenancy?.();
97+
// Single-org → default org; multi-org → none (invite/JIT own it).
98+
return tenancy ? await tenancy.defaultOrgId() : null;
99+
},
100+
logger: this.config.logger,
101+
},
102+
);
103+
return result.outcome;
104+
} catch {
105+
// reconcileMembership never throws, but guard regardless — membership
106+
// bookkeeping must never break user creation or session creation.
107+
return undefined;
108+
}
109+
}
110+
111+
/** The body of `AuthManager#validateAudienceAdmission`; the method's docblock there is its contract. */
112+
export async function validateAudienceAdmission(
113+
this: Pick<MembershipAdmissionHost, 'audienceLogError' | 'config' | 'enterpriseOAuthProviderIds' | 'getAudience' | 'hasPendingInvitationFor' | 'isBootstrapCreation' | 'isOperatorProvisioning' | 'selfRegistrationSetResolvable' | 'stageOwnerVerifiedStamp' | 'stageSelfRegistrationGrant'>,
114+
data: {
115+
user?: Record<string, unknown>;
116+
source?: { action?: string; method?: string; oauth?: { providerId?: string } };
117+
},
118+
ctx?: unknown,
119+
): Promise<{ error: string; errorDescription?: string } | undefined> {
120+
try {
121+
// link-account / provider sign-in concern an EXISTING user's identity,
122+
// not audience admission — the posture judges only who may COME INTO
123+
// EXISTENCE.
124+
if (data?.source?.action !== 'create-user') return undefined;
125+
const audience = this.getAudience();
126+
const email = typeof data?.user?.email === 'string' ? (data.user.email as string) : undefined;
127+
// [#14157] A creation the deployment's own boot command staged is the
128+
// OPERATOR class — the vendor's `source.method` cannot carry that,
129+
// because the seed reaches better-auth through the same `signUpEmail`
130+
// API a person's sign-up does. See `stageOperatorProvisioning` for why
131+
// this is a declared ticket and not a wider bootstrap probe.
132+
// [#14373] …and why email alone no longer decides it: the ticket's
133+
// random value must also match, read off the SAME endpoint context the
134+
// `disableSignUp` before-hook already reads `ctx.body.email` from
135+
// (`getCurrentAuthEndpointContext()` — one context per request, shared
136+
// across the before/after hooks and this validateUserInfo callback).
137+
const provisioningTicket = (ctx as { body?: Record<string, unknown> } | undefined)?.body?.[
138+
AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD
139+
];
140+
const creationClass: AudienceCreationClass = this.isOperatorProvisioning(email, provisioningTicket)
141+
? 'operator'
142+
: classifyCreationMethod(data?.source, {
143+
enterpriseOAuthProviderIds: this.enterpriseOAuthProviderIds(),
144+
});
145+
let isBootstrap = false;
146+
let hasPendingInvitation = false;
147+
if (creationClass === 'self-serve') {
148+
// Only the gated class pays for the probes.
149+
isBootstrap = await this.isBootstrapCreation();
150+
if (!isBootstrap && email) hasPendingInvitation = await this.hasPendingInvitationFor(email);
151+
}
152+
const verdict = decideAudienceAdmission({
153+
audience,
154+
creationClass,
155+
email,
156+
hasPendingInvitation,
157+
isBootstrap,
158+
});
159+
if (!verdict.admit) {
160+
if (verdict.code === AUDIENCE_CONFIG_ERROR) {
161+
// Config-unusable refusals are the operator's bug, not policy —
162+
// report at error level with the offending value in the line
163+
// (#5205: never let "your posture is not a posture" read as
164+
// "policy said no").
165+
this.audienceLogError(`[audience] refusing self-registration: ${verdict.message}`, {
166+
method: data?.source?.method,
167+
});
168+
} else {
169+
this.config.logger?.info?.('[audience] self-registration refused by posture', {
170+
code: verdict.code,
171+
method: data?.source?.method,
172+
posture: audience.posture,
173+
});
174+
}
175+
return { error: verdict.code, errorDescription: verdict.message };
176+
}
177+
// [#12751] Walled deployments: an ADMITTED creation of the declared
178+
// platform owner through an operator provisioning path (operator class,
179+
// or the bootstrap carve-out) is stamped email-verified at creation —
180+
// maintainer ruling 2026-08-28, 「运营方创建即视为已验证」. The decision
181+
// (and the per-path argument) lives in `walled-owner-operator-stamp.ts`;
182+
// this seam only STAGES it, because the admission gate is the one place
183+
// that holds the vendor's own `source.method` signal AND the bootstrap
184+
// probe. Consumed once by the composed `user.create.before` hook, so the
185+
// row is BORN verified; an email UPDATE can never traverse that seam,
186+
// which is what keeps a later change-to-owner-address from inheriting
187+
// the stamp.
188+
if (
189+
email &&
190+
shouldStampOwnerVerifiedAtCreation({ email, creationClass, isBootstrap })
191+
) {
192+
this.stageOwnerVerifiedStamp(email);
193+
}
194+
if (verdict.grantPermissionSet) {
195+
const setName = audience.selfRegistrationPermissionSet;
196+
if (!setName) {
197+
// Unreachable past entry validation; keep the fail-closed floor —
198+
// admitting without the declared grant would be ADR-0078's
199+
// "declared but inert" at runtime.
200+
const message =
201+
`audience posture '${audience.posture}' permits self-registration but declares no ` +
202+
'selfRegistrationPermissionSet — self-registration is refused until the configuration is fixed.';
203+
this.audienceLogError(`[audience] ${message}`);
204+
return { error: AUDIENCE_CONFIG_ERROR, errorDescription: message };
205+
}
206+
const resolvable = await this.selfRegistrationSetResolvable(setName);
207+
if (!resolvable) {
208+
const message =
209+
`the declared self-registration permission set '${setName}' cannot be resolved ` +
210+
'(not in the security catalog, switched off in sys_metadata_activation, or the store ' +
211+
'is unavailable) — ' +
212+
'self-registration is refused rather than admitting an ungranted user. ' +
213+
'Declare or switch on the set, or point selfRegistrationPermissionSet at an existing one.';
214+
this.audienceLogError(`[audience] ${message}`);
215+
return { error: AUDIENCE_CONFIG_ERROR, errorDescription: message };
216+
}
217+
if (email) this.stageSelfRegistrationGrant(email, setName);
218+
}
219+
return undefined;
220+
} catch (error) {
221+
// Fail CLOSED: an admission question this gate cannot answer must not
222+
// admit. Operator/provider classes never reach I/O, so they cannot land
223+
// here.
224+
const message = 'audience admission could not be evaluated — self-registration refused.';
225+
this.audienceLogError(`[audience] ${message}`, {
226+
error: (error as Error)?.message ?? String(error),
227+
});
228+
return { error: AUDIENCE_CONFIG_ERROR, errorDescription: message };
229+
}
230+
}
231+
232+
/** The body of `AuthManager#landSelfRegistrationGrant`; the method's docblock there is its contract. */
233+
export async function landSelfRegistrationGrant(
234+
this: Pick<MembershipAdmissionHost, 'config'>,
235+
engine: unknown,
236+
userId: string,
237+
setName: string,
238+
organizationId: string,
239+
): Promise<void> {
240+
const sys = withSystemReadContext(engine as never) as any;
241+
const existing: any[] = await sys.find('sys_user_permission_set', {
242+
where: { user_id: userId, [GRANT_SET_NAME_FIELD]: setName },
243+
limit: 1,
244+
});
245+
if (existing.length > 0) return;
246+
const id = `ups_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
247+
await sys.insert(
248+
'sys_user_permission_set',
249+
{ id, user_id: userId, [GRANT_SET_NAME_FIELD]: setName, organization_id: organizationId },
250+
{ context: { isSystem: true } },
251+
);
252+
this.config.logger?.info?.('[audience] granted the declared self-registration permission set', {
253+
userId,
254+
permissionSet: setName,
255+
organizationId,
256+
});
257+
}
258+
259+
/** The body of `AuthManager#reportUngrantedSelfRegistrant`; the method's docblock there is its contract. */
260+
export function reportUngrantedSelfRegistrant(this: Pick<MembershipAdmissionHost, 'audienceLogError'>, userId: string, setName: string, cause: string): void {
261+
this.audienceLogError(
262+
`[audience] a self-registrant was ADMITTED but the declared permission set '${setName}' was NOT granted ` +
263+
`(user ${userId}) — the account works and everything looks normal, but it holds only baseline access, ` +
264+
`and nothing retries this. Fix: verify the set exists and is active for the target organization, then grant it ` +
265+
`manually (sys_user_permission_set) or have the user re-created. Cause: ${cause}`,
266+
);
267+
}

0 commit comments

Comments
 (0)