Repository navigation
Commit eca6b61
<!-- 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 155 names under ESM and CJS
(measured below).
Cut 5 of the `auth-manager.ts` split: **the email and SMS delivery.**
Seven `AuthManager` method bodies (230 lines) and the four module-level
declarations that only they read (81 lines) move unedited, the bodies
re-indented one level, into one new module,
`packages/plugins/plugin-auth/src/auth-manager-email-sms-delivery.ts`.
#25224 stays open for the later cuts.
This follows the amended claim `6359424739`, which amends `6357765071`
on the file cut 4 left (#25383, landed note `6358711845`). The shape is
cuts 1 to 4's, which is #23050's.
Branch: `claude/project-thread-lg3wrj`, the harness-pinned branch for
this session, restarted from `origin/main` `ca381a8a19` after #25383
merged. It names no issue, so the card relation is this body's first
line.
`auth-manager.ts` goes from 9,212 to 8,968 lines. The cut is +527 / −332
over 5 files: 859 changed lines, under the 3,000-line line. One file is
cross-lane (see "Readers re-pointed"). It is not a governed surface
(`check-governed-merges.mjs --branch HEAD`: 0 of the 5 paths hit the
register).
## Why this seam
These are the sends `AuthManager` makes itself, outside better-auth's
callbacks:
- the notice to the address a change-email request moves the account
away from;
- the phone OTP send, with the per-number send guard and the admission
check in front of it;
- the SMS invitation;
- and the two helpers both kinds of send share: the read of the
recipient's own stored `sys_user.locale`, and the auth SMS body render.
The four module-level declarations are the SMS quota wall's: the code
the SMS service reports a quota refusal with and its matcher, the 429
the wall answers with, and the one ADR-0112 code both phone OTP send
walls carry. Every reader of each, comments included, is in the moved
text. So they move with it rather than being exported: `index.ts`
re-exports `auth-manager.ts` whole, so an export there would widen the
published surface, and a value import of that file from the new module
would be a cycle.
What stays, and why:
- `emailLocaleArg` and the `setDefault*Locale` setters stay. The first
reads `authEmailLocaleFromRequest` and `normalizeAuthEmailLocale`, which
`auth-manager.ts` exports.
- The transport wiring stays: `getEmailService`, `hasEmailTransport`,
`setSmsService`, `getSmsService`, `isSmsServiceAvailable` and
`isPhoneOtpDeliverable`. These are one- to four-line bodies that other
sections of the class call too, and the moved bodies reach the ones they
need through the host.
- The other email sends (reset, verification, invitation, magic link,
existing-account sign-up) are closures inside `createAuthInstance` and
`buildPluginList`, and stay for those areas.
## What moved
| method | base signature line | body lines | members it reads |
|---|---:|---:|---|
| `sendChangeEmailNotice` | `:6299` | 37 | `config`, `emailLocaleArg`,
`getAppName`, `getEmailService`, `storedRecipientLocale` |
| `getOtpSendGuard` | `:6359` | 23 | `_otpSendGuard` (read and written),
`config` |
| `assertPhoneOtpSendAllowed` | `:6391` | 12 | `getOtpSendGuard`,
`isPhoneOtpDeliverable` |
| `deliverPhoneOtp` | `:6420` | 78 | `config`, `getAppName`,
`getSmsService`, `isPhoneOtpDeliverable`, `renderPhoneSmsBody`,
`storedRecipientLocale` |
| `sendPhoneInviteSms` | `:6508` | 52 | `getAppName`,
`getCanonicalOrigin`, `getConsolePageUrl`, `getSmsService`,
`renderPhoneSmsBody`, `storedRecipientLocale` |
| `storedRecipientLocale` | `:6661` | 17 | `getDataEngine` |
| `renderPhoneSmsBody` | `:6745` | 11 | `config`, `getDataEngine`,
`smsLocale` |
| module-level declaration | base lines | its readers |
|---|---|---|
| `SMS_QUOTA_EXCEEDED_CODE` | `:1216`–`:1247` | `isSmsQuotaRefusal`, and
the docblock of `smsQuotaExceededApiError` |
| `isSmsQuotaRefusal` | `:1265`–`:1275` | `deliverPhoneOtp`,
`sendPhoneInviteSms` |
| `smsQuotaExceededApiError` | `:1409`–`:1435` | `deliverPhoneOtp`,
`sendPhoneInviteSms` |
| `PHONE_OTP_SEND_REFUSAL_CODE` | `:1437`–`:1447` |
`assertPhoneOtpSendAllowed`, `smsQuotaExceededApiError` |
Lines are base `ca381a8a19`. 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`).
The one assignment to a `this` member is `getOtpSendGuard`'s to
`_otpSendGuard`.
The four declarations keep their order in the module, and
`smsQuotaExceededApiError` reads `PHONE_OTP_SEND_REFUSAL_CODE` only when
it is called, as before. `SCIM_PROTOCOL_PATH_PREFIX` and
`isScimProtocolPath`, which sat between them, stay.
**Imports.** Five imported names are read only by the moved text, so
they leave `auth-manager.ts` and the new module imports each from the
same place:
- `PHONE_SMS_TOPICS`, `interpolatePhoneSms` and
`resolvePhoneSmsTemplateBody` (`./phone-sms-texts.js`);
- `RECIPIENT_LOCALE_FIELD` and `normalizeRecipientLocale`
(`@objectstack/service-messaging`). The comment that explains why that
normaliser is reused moves with its import, verbatim.
`OtpSendGuard` (`./otp-send-guard.js`) is read by the moved `new
OtpSendGuard(…)` and by the field type `_otpSendGuard?: OtpSendGuard`
that stays, so both files import it. So do both files import the
`StandardErrorCode` type: `SLUG_GUARD_READ_FAULT_CODE` stays.
**Evaluation order.** `auth-manager.ts` no longer imports
`./phone-sms-texts.js` or `@objectstack/service-messaging` itself.
Nothing observable moves:
- `@objectstack/service-messaging` is first evaluated through
`./idp-locale-claim.js`, which `auth-manager.ts` imports at `:39`, at
base and at head alike.
- `./phone-sms-texts.js` has no imports, and its top level is
declarations with literal initialisers, so the point at which it is
evaluated cannot be observed.
## The shape
- **The class keeps all seven 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 `EmailSmsDeliveryHost`, plus `.call`.
- **`EmailSmsDeliveryHost`** lives in the new module. It names the 14
members the bodies read, each typed as the class member
(`AuthManager['storedRecipientLocale']`, …). The module imports
`AuthManager` as a type only, so there is no runtime cycle.
- **Methods go as late-bound closures** (`(...a) =>
this.storedRecipientLocale(...a)`). A test double or spy set on the
manager is still the one a body reaches. That includes
`storedRecipientLocale`, `renderPhoneSmsBody` and `getOtpSendGuard`,
which the moved bodies ask through the manager.
- **`config` and `smsLocale` go as getters.** The class reassigns
`config` after construction (`setRuntimeBaseUrl`, `applyConfigPatch`)
and `smsLocale` on every `setDefaultSmsLocale`, so each is read off the
manager when a body reads it, as before.
- **`_otpSendGuard` goes as a get/set pair.** `getOtpSendGuard` creates
the guard once and stores it, so the guard, and the per-number budget it
counts, stays the one the manager holds.
- **Six stubs 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. `sendPhoneInviteSms`'s
missing-service `throw` is still inside an async function, so it still
rejects. `getOtpSendGuard` was synchronous and stays so.
- **The cost:** one small host object per call. A phone OTP send builds
five: the admission check, the guard lookup, the send, and the send's
locale read and body render.
- **Nothing re-exports the new module**, so the published surface does
not move.
- **Two `{@link normalizeRecipientLocale}` references stay in
`auth-manager.ts`,** in the docblocks of `storedRecipientLocale` and
`emailLocaleArg`. The name is no longer imported there, so an editor no
longer resolves those two links. The base file already carries one such
unresolved link, `{@link phoneSmsLocaleChain}`, which it never imported.
The text is unchanged, and neither docblock reaches the `.d.ts` (both
methods are private).
## Readers re-pointed in the same PR
| reader | what it reads | base | head |
|---|---|---|---|
| `api-error-code.pin.test.ts` `authManagerFiles` | a floor (≥ 24) on
the `new APIError(` sites over the files `AuthManager`'s bodies live in
| 27 sites: `auth-manager.ts` 15, actor guards 9, password and session
policy 2, sign-in lockout 1 | 27 sites. The list gains
`auth-manager-email-sms-delivery.ts`, which holds the 3 sites that moved
(`assertPhoneOtpSendAllowed`, `deliverPhoneOtp`,
`smsQuotaExceededApiError`). Without it the floor would pass on 24, in
silence. |
| `packages/runtime/src/dispatcher-error-vocabulary.ts`, the
`UNRESOLVED_CODE_HELPERS` row for `deliverPhoneOtp` (**cross-lane:
`domain:cli`**, as the seat agreed on this card) |
`check-dispatcher-error-vocabulary.mjs` finds the `objlithelper` helper
`deliverPhoneOtp(code)` in its declaring file, and the row must name
that file | `file: '…/auth-manager.ts'` | `file:
'…/auth-manager-email-sms-delivery.ts'`. The row's evidence said "Its
two in-file callers are better-auth `sendOTP` hooks". It now says those
two hooks are in `auth-manager.ts` and reach the helper through the
class method of the same name. Nothing else in the row or the file
changes. |
| `auth-email-locale.test.ts`, two comments | the stated population of
the five auth sends: "the five `sendTemplate` call sites in
`auth-manager.ts`", and "no other file under `plugin-auth/src` calls
either" | names `auth-manager.ts` | names `auth-manager.ts` and
`auth-manager-email-sms-delivery.ts`, where the change-email notice's
send now sits. No assertion changes. |
**Each migration is load-bearing.** Measured on the draft:
- With the vocabulary row unmigrated, `node
scripts/check-dispatcher-error-vocabulary.mjs` exits 1:
`stale-helper-row` (the row names a file the helper left) and
`unresolved-constant` (the helper is found in the new module with no
row). Migrated, it exits 0: 56 sites found, 56 classified, the same as
base.
- The `authManagerFiles` floor is the silent case: unmigrated, it reads
24 and passes. So the per-file count above is the evidence, not the
test's exit code.
**Measured as reading no moved line:**
- **The tenant-audit census** (`check-tenant-audit-census.mjs` and its
two documents): the moved text holds no engine write.
`storedRecipientLocale`'s `findOne` and `resolvePhoneSmsTemplateBody`'s
template read are reads. Compared per site, base and head read the same
237 sites with the same totals, and no total moves (below). So neither
document is regenerated.
- **Plugin-factory scans** in `better-auth-schema-parity.test.ts` and
`managed-extension-fields.test.ts`: the moved text's three `await
import(…)` are of `better-auth/api`, which their pattern does not match.
- **`check-auth-mount-ledger.mjs`** and
**`auth-manager-base-path.test.ts`**: the moved text does not spell the
base-path default.
- **`check-dispatcher-error-vocabulary.mjs`'s `liveMemberFile` record**
names `BASE_ERROR_CODES.USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL` in
`auth-manager.ts`, which stays. Its self-test passes (a derived gate
below).
- **The census's `UNTYPED_RECEIVERS` row for `auth-manager.ts`**: it
names the SCIM transaction's `db`, which stays.
- **The system-context census**: the moved text holds no `isSystem`
read. `storedRecipientLocale`'s `{ isSystem: true, … }` is a context it
builds, not a read. The census reads 148 elevation read sites at base
and at head.
- **`scripts/adr-anchors/`**: no entry's invariant describes these
sends, so no entry is added. The `auth-manager.ts` entry (ADR-0093 and
ADR-0108) is unchanged and still holds: the moved text cites neither.
- **Callers through the manager**: the suites that drive the seven
methods, among them `phone-otp-no-sms-service-refusal.test.ts` (a spy on
`deliverPhoneOtp`),
`phone-sms-template-organization.integration.test.ts`,
`auth-email-locale.test.ts`, `auth-manager.test.ts`,
`auth-plugin.test.ts` and the two `admin-import-users-*` suites, call
each through the class method. All pass at head (below).
- **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 7 bodies and 4 declarations
blanked, and at head. None names a moved method or declaration. Three
name a token the moved text also carries, and each resolves on a
declaration that stays: `#secondaryStorage` (ADR-0127), `#AuthManager`
(ADR-0135) and `#isPhoneOtpDeliverable` (`identity-auth.json:1345`).
**One reading the move does shift: `check-adr-anchors`' decision-letter
layer.** That layer checks each `ADR-NNNN Dk` citation in an anchored
file's text against the cited record's decisions. It reads anchored
files only, by design (the checker's header: "Scope is the anchored
surface deliberately").
- The change-email notice body carries one such citation, `[ADR-0131 D7,
Q2 A]` (a notice to the account holder is identity mail). It moves from
`auth-manager.ts`, which is anchored, to the new module, which is not.
- So that layer reads 2,017 citations at base and 2,016 at head. The
citation still resolves in the number layer, which covers the whole
tree: 52,096 citations across 6,621 files at base, 52,097 across 6,622
at head. Both readings exit 0.
- An anchor entry for the new module would bring it back. But it would
need an `invariant` paragraph that no existing entry carries for these
sends. Writing one is new governance text, not a move, so this PR does
not add it. The modules of cuts 1 to 3 carry no anchor entry either.
## Proof
Builds and suites ran through `os-verify-lock`, and every exit code was
captured before any pipe. Base `ca381a8a19`, head `b2f9b260de`. 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 7 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 six that had it.
- Each of the 4 module-level ranges is in the module verbatim and absent
from `auth-manager.ts`.
- Result: 7 of 7 and 4 of 4 pass, 230 body lines.
| method | body lines | tokens |
|---|---:|---:|
| `sendChangeEmailNotice` | 37 | 227 |
| `getOtpSendGuard` | 23 | 120 |
| `assertPhoneOtpSendAllowed` | 12 | 75 |
| `deliverPhoneOtp` | 78 | 207 |
| `sendPhoneInviteSms` | 52 | 151 |
| `storedRecipientLocale` | 17 | 100 |
| `renderPhoneSmsBody` | 11 | 77 |
- **Negative control:** one token changed in the module
(`otpCfg.expiresIn ?? 300` → `?? 301` in `deliverPhoneOtp`) 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 ca381a8 b2f9b26` over
the two source files marks 317 of the 323 deleted lines and 345 of the
513 added lines as moved.
- The other 6 deleted lines are the six `async` signature lines.
- The other added lines are the stubs' host literals,
`EmailSmsDeliveryHost`, 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` |
`8a39f1035b6a64888117bb152de21865289e67bc8111d5a14ea457e0fb084d51` |
`8a39f1035b6a64888117bb152de21865289e67bc8111d5a14ea457e0fb084d51` | 0 |
`486138d3045a7ae05205c64d640eb98c77a85e3ac797a0be0e9ee351e8230bca` |
`486138d3045a7ae05205c64d640eb98c77a85e3ac797a0be0e9ee351e8230bca` |
| `dist/index.d.mts` |
`074a15f3d7e075752bfb5aa299151d77d013fedfae947a8bcc8d28d8446b15df` |
`074a15f3d7e075752bfb5aa299151d77d013fedfae947a8bcc8d28d8446b15df` | 0 |
`8cb244809d4e1cee802060cfe8c37ff784d6f16d341672b7e2d74700ba2e1229` |
`8cb244809d4e1cee802060cfe8c37ff784d6f16d341672b7e2d74700ba2e1229` |
- 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 deliverPhoneOtp;` → `deliverPhoneOtp;`,
line 2703, a one-line change) hashes raw
`72873388418fd744bdf706e2f4099a8650e58da20cf49526a1b8aed2b9b31c7e` and
normalised
`b15a8158861a2a047848872c97c420a06660ff583e9fa806bad0766fddd323b4`. 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:** 20 files in `dist/` on both sides, the same list. Only
`index.js`, `index.mjs`, their two `.map` files and the two build-input
hash stamps 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 3526 passed | 10 skipped (3536)`, 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`) and `pnpm --filter
@objectstack/runtime typecheck`: exit 0.
- `eslint --no-inline-config` (the root `lint` script's binary and
flags) over the 5 TypeScript 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`: 237 sites on both sides, 0 per-site differences in
object, verb, tenancy, context and elevation, 0 unresolved and 0
unledgered on both, and every total the same.
`check-tenant-audit-census` passes at head on the committed documents.
- 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,383 anchors across 142 records, 466
symbol (440 declaration, 26 literal), the same at both.
- `check-adr-anchors`: 80 anchored files at both, and the
decision-letter shift above.
- `check-scripts-symbol-anchors` (4,058 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 59 commands for the 5 paths. All 59
ran at `b2f9b260de` after the full build, and all 59 exited 0.
- `--ran`: 59 derived, 59 run, 0 NOT-MEASURED.
- `check-governed-merges.mjs --branch HEAD`: NOT governed, 859 changed
lines.
- **Docs drift** (`node scripts/docs-audit/affected-docs.mjs --json
ca381a8`, exit 0): 7 hand-written pages and 1 release-owned page
(`releases/v17/17-7.mdx`). Each is reached through a literal the moved
text carries unchanged: `RATE_LIMIT_EXCEEDED` (the value of
`PHONE_OTP_SEND_REFUSAL_CODE`) or `SMS_SERVICE_REQUIRED` (in
`deliverPhoneOtp`). No page restates a rule this move changes, so no
page edit is owed.
- **Serial constraints** (in the claim): no open PR touches
`auth-manager.ts`, the new module, `api-error-code.pin.test.ts`,
`auth-email-locale.test.ts` or `dispatcher-error-vocabulary.ts`.
`origin/main` has since moved to `5f6c2f1952`. Of its 12 new commits,
only #25388 touches `plugin-auth` (`auth-manager-actor-guards.ts`,
`objectql-adapter.ts`, `last-admin-guard.ts` and four suites), none of
this cut's files. `git merge-tree` of it with this head is clean, and on
that merge `check-dispatcher-error-vocabulary` (56 found, 56 classified)
and `api-error-code.pin.test.ts` pass.
## 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 2abc0d3 commit eca6b61
5 files changed
Lines changed: 527 additions & 332 deletions
File tree
- packages
- plugins/plugin-auth/src
- runtime/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
180 | 180 | | |
181 | 181 | | |
182 | 182 | | |
| 183 | + | |
183 | 184 | | |
184 | 185 | | |
185 | 186 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
858 | 858 | | |
859 | 859 | | |
860 | 860 | | |
861 | | - | |
862 | | - | |
863 | | - | |
864 | | - | |
| 861 | + | |
| 862 | + | |
| 863 | + | |
| 864 | + | |
| 865 | + | |
| 866 | + | |
865 | 867 | | |
866 | 868 | | |
867 | 869 | | |
| |||
1120 | 1122 | | |
1121 | 1123 | | |
1122 | 1124 | | |
1123 | | - | |
1124 | | - | |
| 1125 | + | |
| 1126 | + | |
| 1127 | + | |
1125 | 1128 | | |
1126 | 1129 | | |
1127 | 1130 | | |
| |||
0 commit comments