Skip to content

Commit a43d90a

Browse files
fix(plugin-auth): phone send-otp with no deliverable SMS service answers 400 SMS_SERVICE_REQUIRED instead of a bare 500 (#21858)
Fixes #21793 Clause-②: yes (widening) ## What changed `POST /api/v1/auth/phone-number/send-otp` on a deployment with phone sign-in on but no SMS service that can deliver a code (none wired, or only the log transport in production) answered **500 with an empty body**. It now answers **400** with `{ "code": "SMS_SERVICE_REQUIRED", "message": "..." }`. - `packages/plugins/plugin-auth/src/auth-manager.ts`: the no-provider branch of `deliverPhoneOtp` throws better-auth's `APIError('BAD_REQUEST', { message, code: 'SMS_SERVICE_REQUIRED' })`, the same construction the sibling refusals in this file use (for example `PASSWORD_POLICY_VIOLATION`). The message names the missing SMS delivery service and where an administrator configures it (Setup, Settings, SMS Delivery), and points the user at phone and password sign-in. It never carries the one-time code. The quota branch and the delivery path are unchanged. Four doc comments that said `NOT_SUPPORTED` now name the new answer. - `packages/spec/src/api/error-code-ledger.zod.ts`: `SMS_SERVICE_REQUIRED` is registered for `@objectstack/plugin-auth`, beside `EMAIL_SERVICE_REQUIRED`. The reference pages `content/docs/references/api/{contract,error-code-ledger}.mdx` are regenerated (`gen:docs`, as `check:generated` named). - `content/docs/permissions/authentication.mdx`: the two passages that said "fails loudly with `NOT_SUPPORTED`" now state the 400 and the code. The first also says that `request-password-reset` keeps answering `{status:true}`. - `docs/qa/platform-checklist/areas/identity-auth.json`: `identity-auth.auth-method-matrix` (revision 5) names the shipped refusal in clause 4, its negative, step 4, the fixtures row and the variant. A bare 500 is now named a FAIL, and the clause cites the door pin. - Changeset: `@objectstack/spec` **minor** (the ledger accepts one more value), `@objectstack/plugin-auth` **patch** (the bug fix). ## H2: which branch, and the measurement that decided it **Branch (b): a new registered code.** No registered code honestly names "phone OTP needs an SMS delivery service" (measured at merge base `6fb71152c`): - `@objectstack/plugin-auth`'s row: `EMAIL_SERVICE_REQUIRED` names the email service. `PHONE_NOT_ENABLED` means the phone plugin is off, which is not this case. `INVITE_SMS_FAILED` is a failed invitation send. No other row is about SMS. - The standard catalog has no SMS member. `SERVICE_UNAVAILABLE` and `NOT_IMPLEMENTED` misname the cause and are 5xx. - No other package's row names SMS delivery. The spelling `SMS_SERVICE_REQUIRED` already exists in this package: `sendPhoneInviteSms` throws it as a plain-`Error` prefix for the same condition. So one name now covers one condition. It passes the #8211 synonym rule, because the token `SMS` is in no standard member. The status is **400**, the status of the email sibling (`admin-import-users.ts` answers `EMAIL_SERVICE_REQUIRED` with `fail(400, ...)`). ## Mechanism hypotheses, measured All door readings use the real `AuthManager.handleRequest` over the installed better-auth 1.7.3 / better-call 1.4.0. - **H1, confirmed.** Before the fix, the no-provider branch answered `500`, no `content-type`, body `""` (measured under the ablation below). The quota branch answered `429`, `application/json`, `{"message":"Too many verification codes requested. Please try again later."}`, with no `code` field. After the fix, no-provider answers `400`, `application/json`, `{"message":"Phone verification codes are unavailable: ...","code":"SMS_SERVICE_REQUIRED"}`. The quota answer is unchanged. - **H2:** see above. - **H3:** 400, from the email sibling. - **H4, confirmed** at objectui `9dfaca65` (the `.objectui-sha` pin). `packages/auth/src/createAuthClient.ts` `postPhoneNumberEndpoint` reads the top-level `payload.code` and `payload.message` of this vendor-shaped body. `LoginForm.handleSendOtp` shows `errorMessages[code]` if one is mapped, and the message otherwise. Before the fix the payload was `null`, so the user saw "Auth request failed with status 500". ## Tests - New `packages/plugins/plugin-auth/src/phone-otp-no-sms-service-refusal.test.ts` is the door pin. It sends a real request through a real better-auth pipeline and a pinned memory engine. It checks: - With no SMS service: `400`, `code === 'SMS_SERVICE_REQUIRED'`, and `ErrorCode.safeParse(code)` succeeds, so the code is registered. - The refusal text never contains the OTP that better-auth stored. - With `NODE_ENV=production` and a log-only transport: the same 400 and code, and nothing is sent. - `request-password-reset` for a registered number still answers `200 {status:true}`. A pass-through spy proves the route reached the refusing send. - Outside production, a log-only transport still delivers. - In `auth-manager.test.ts`, the old `rejects.toThrow(/NOT_SUPPORTED/)` case became two cases on the error object: `isAPIError`, `BAD_REQUEST`/400, `body.code`, and no code in the message. - **Ablation.** The plain `Error` was put back through `scripts/ablation-replace.mjs` (mutation landed: anchor 1 to 0, blob `9d24bb3f` to `9c6b1b90`). Result: **5 failed / 282 passed**. That is 3 door cases (`expected 500 to be 400`, and the deliver spy rejects with `Error: NOT_SUPPORTED...` instead of the 400 shape) and 2 unit cases (`isAPIError` false, `statusCode` undefined). The tool's restore leg proved blob == HEAD `9d24bb3f` and an empty `git diff HEAD`. The first attempt was refused by the tool before any test ran, because the replacement text contained the anchor. It was redone with a whole-block anchor. - `pnpm --filter @objectstack/plugin-auth exec vitest run`: 120 files, 2515 passed, 10 skipped (at `b37edd67`). Typecheck exit 0. After the last test-file edit, the two changed files were re-run at `3e8ab846`: 286 passed. - `pnpm --filter @objectstack/spec exec vitest run`: 668 files, 19286 passed, 1 todo (at `b37edd67`). Typecheck exit 0 (at `3e8ab846`). - `pnpm --filter @objectstack/spec check:generated`: all 15 artifacts up to date, after a spec rebuild on the final merge `c6b17163`. - Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `c6b17163` derived 124 commands. All 124 were run on that head and exited 0. `--ran` with the exit codes recorded: 124 derived, 124 run, 0 NOT-MEASURED, 0 UNRUN. On the first pass (at `b37edd67`), `check:engine-double-contract` and `check:objectql-double-limit` were red on the new test's engine double. The double now applies `limit`/`offset` by presence, and `--write` recorded the pinned double (additions only). The local scope is the targeted set above; the rest of the farm is CI's. ## Riding comments (domain:spec pointer 5989650462) These are comment-only edits in `error-code-ledger.zod.ts`. No code, status or owner moved. Each claim was checked against the tree: 1. `DRIVER_UNAVAILABLE` (`@objectstack/cloud-connection`): **rewritten.** Measured: the only emitter is the purge-sample-data door (`marketplace-install-local-plugin.ts`, the `!ql || !metadata` branch, 500, introduced by #21773). So the comment now states that condition, rather than adding it as an "also". 2. `RESEED_SKIPPED`: **not edited. The claim does not hold on this tree.** Since #21780 (`e09f1aca`), a walled session with no active organization gets `403 PERMISSION_DENIED` on the purge (and on the reseed), through `NO_ACTIVE_ORGANIZATION_CODE`. `RESEED_SKIPPED` is now emitted only by the reseed, for its other declines. The existing comment ("reseed declined to run; message carries why") is accurate. 3. `OS_PROTOCOL_INCOMPATIBLE` (`@objectstack/metadata-core`): **rewritten** to name both doors. `POST /api/v1/packages` (`runtime/src/domains/packages.ts`) and, since #21805, `POST /api/v1/marketplace/install-local` both answer through the shared `protocolIncompatibleAnswer`. ## Acceptance notes - The quota branch answers 429 with **no** `code` in its body (measured above). This is deliberate: `auth-manager.test.ts` pins `bodyCode: undefined`, so both walls look the same from outside. It is untouched here. - `sendPhoneInviteSms` still throws a plain `Error('SMS_SERVICE_REQUIRED: ...')` when no SMS service is wired. No door reaches that throw, because its one caller gates on `isSmsServiceAvailable()` first. It is the delivery path, so it was out of scope and is unchanged. - Files outside the expected surface, all made false or required by this change: the checklist item, the two regenerated reference pages, and `scripts/engine-double-contract.pinned.json`. That last one records the new pinned test double, written by `check-engine-double-contract.mjs --write`, additions only. --- _Generated by [Claude Code](https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c9be1f1 commit a43d90a

10 files changed

Lines changed: 417 additions & 30 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/plugin-auth': patch
4+
---
5+
6+
Phone-number OTP with no deliverable SMS service now answers `400 SMS_SERVICE_REQUIRED` instead of a `500` with an empty body (#21793).
7+
8+
Clause-②: yes (widening)
9+
10+
- **`@objectstack/plugin-auth`.** `POST /api/v1/auth/phone-number/send-otp` on a deployment that turned phone sign-in on but has no SMS service that can deliver a code (none wired, or only the log transport in production) used to answer `500` with a `null` body: the send callback threw a plain `Error`, and better-auth's router turns anything but its own `APIError` into a bare 500. The login page had nothing to branch on and showed a generic failure. It now answers `400` with the body `{ "code": "SMS_SERVICE_REQUIRED", "message": "…" }`, a typed `APIError`, as the daily-quota branch of the same send already was. The message names the missing SMS delivery service and where an administrator configures it, and never carries the one-time code. `request-password-reset` is unchanged: it still answers `{ "status": true }` and sends nothing, so it reveals nothing about which numbers are registered.
11+
- **`@objectstack/spec`.** `SMS_SERVICE_REQUIRED` is registered for `@objectstack/plugin-auth` in the ADR-0112 error-code ledger, beside its email sibling `EMAIL_SERVICE_REQUIRED`. `ErrorCode` (and so `ApiErrorSchema.code`) accepts one more value. Nothing that parsed before is refused now.
12+
13+
**Action for clients.** A client that branched on the old `500` for this case should branch on `code === 'SMS_SERVICE_REQUIRED'` instead. The public config already advertises the capability as `features.phoneNumberOtp`, which stays `false` on such a deployment.

‎content/docs/permissions/authentication.mdx‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -384,8 +384,11 @@ const response = await fetch('http://localhost:3000/api/v1/auth/sign-in/phone-nu
384384
#### OTP sign-in and self-service reset (requires SMS delivery)
385385

386386
The OTP surface opens only when a **deliverable SMS service** is configured
387-
(`services.sms`, Setup → Settings → SMS Delivery); without one these endpoints
388-
fail loudly with `NOT_SUPPORTED`. The public config advertises real
387+
(`services.sms`, Setup → Settings → SMS Delivery). Without one — no SMS
388+
service wired, or only the log transport in production — `send-otp` answers
389+
`400` with `code: "SMS_SERVICE_REQUIRED"` and a message naming the fix, while
390+
`request-password-reset` keeps answering `{status:true}` (it never reveals
391+
whether a number is registered) and sends nothing. The public config advertises real
389392
availability as `features.phoneNumberOtp`, which is what the Console login UI
390393
gates its "Sign in with verification code" mode and the SMS reset branch on.
391394

@@ -756,7 +759,8 @@ The OTP surfaces — `POST /phone-number/send-otp` + `POST /phone-number/verify`
756759
(sign-in / verification) and `POST /phone-number/request-password-reset` +
757760
`POST /phone-number/reset-password` (self-service reset) — only work when an
758761
SMS service is wired (`@objectstack/service-sms` registers the `sms` kernel
759-
service). Without one, `send-otp` fails loudly instead of silently dropping
762+
service). Without one, `send-otp` fails loudly — `400` with
763+
`code: "SMS_SERVICE_REQUIRED"` — instead of silently dropping
760764
the code; phone sign-in is then password-based only (an admin sets the
761765
password — see Admin User Management below). OTP requests are rate-limited
762766
per phone number to prevent SMS-pumping abuse.

‎content/docs/references/api/contract.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ const result = ApiErrorSchema.parse(data);
2828

2929
| Property | Type | Required | Description |
3030
| :--- | :--- | :--- | :--- |
31-
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +321 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
31+
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +322 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
3232
| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) |
3333
| **message** | `string` | ✅ | Readable error message |
3434
| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. |
@@ -321,6 +321,7 @@ const result = ApiErrorSchema.parse(data);
321321
* `SHARE_REVOKE_FAILED`
322322
* `SHARING_NOT_ENABLED`
323323
* `SIGN_IN_REQUIRED`
324+
* `SMS_SERVICE_REQUIRED`
324325
* `SQL_DIALECT_EMISSION_UNSUPPORTED`
325326
* `SSO_REGISTER_FAILED`
326327
* `SSO_REGISTER_FORBIDDEN`

‎content/docs/references/api/error-code-ledger.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -488,6 +488,7 @@ const result = ErrorCode.parse(data);
488488
* `SHARE_REVOKE_FAILED`
489489
* `SHARING_NOT_ENABLED`
490490
* `SIGN_IN_REQUIRED`
491+
* `SMS_SERVICE_REQUIRED`
491492
* `SQL_DIALECT_EMISSION_UNSUPPORTED`
492493
* `SSO_REGISTER_FAILED`
493494
* `SSO_REGISTER_FORBIDDEN`

‎docs/qa/platform-checklist/areas/identity-auth.json‎

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -151,15 +151,15 @@
151151
"title": "Every supported auth method signs in when enabled, is absent when disabled, and is advertised exactly as configured",
152152
"since": "v16",
153153
"status": "active",
154-
"revision": 4,
154+
"revision": 5,
155155
"priority": "P1",
156156
"surface": "mixed",
157157
"personas": ["anonymous visitor", "provisioned user per method", "admin (for env configuration)"],
158158
"fixtures": {
159159
"app": "showcase",
160160
"requires": [
161161
"per-variant env boots: the matrix flips AuthPluginConfigSchema flags (phoneNumber, twoFactor, deviceAuthorization, oidcProvider) and the socialProviders/oidcProviders maps — each variant names which boot it needs",
162-
"an SMS service (@objectstack/service-sms) is required ONLY to make phone-OTP pass; its ABSENCE is itself a tested state (loud NOT_SUPPORTED)"
162+
"an SMS service (@objectstack/service-sms) is required ONLY to make phone-OTP pass; its ABSENCE is itself a tested state (loud 400 SMS_SERVICE_REQUIRED)"
163163
],
164164
"knownGaps": [
165165
"magic-link and passkeys: the AuthPluginConfigSchema flags still exist, but objectui ships NO login UI for either, so #7481 withdrew both from the /api/v1/auth/config payload (protocol 17) — features.magicLink / features.passkeys are now ABSENT by design, not false. Run these variants as blocked(dependency, objectui#4179) at the browser lane. The flag-advertisement clause inverts here: seeing either key in the payload is itself a FAIL now, and magic-link's endpoints (/magic-link/send, /magic-link/verify) stay live and drivable without a UI",
@@ -171,7 +171,7 @@
171171
"for each variant: boot with the method configured ON, GET /api/v1/auth/config, and record the advertisement (features.* flag, emailPassword block, socialProviders list)",
172172
"email+password: POST /api/v1/auth/sign-up/email (when sign-up enabled), POST /api/v1/auth/sign-in/email, GET /api/v1/auth/get-session, POST /api/v1/auth/sign-out — capture each response and the session cookie lifecycle",
173173
"phone+password: create a phone-carrying user with a known password, POST the phone sign-in (better-auth /sign-in/phone-number surface — 'always works' when the plugin is on per the spec), verify get-session identifies the user",
174-
"phone OTP with NO SMS service configured: request /phone-number/send-otp and capture the loud NOT_SUPPORTED rejection (never a silent 200, never a hang)",
174+
"phone OTP with NO SMS service configured: request /phone-number/send-otp and capture the loud 400 SMS_SERVICE_REQUIRED rejection (never a silent 200, never a hang, never a bare 500)",
175175
"SSO/OIDC: with an oidcProviders[] entry configured, verify the login page shows the SSO button (features.sso is refined to 'usable' — ≥1 provider); drive the authorization-code round trip or cite the pinned OIDC dogfood test per rule 6",
176176
"device authorization: with deviceAuthorization on, drive the RFC 8628 flow (POST /device/code, approve via /device/approve in a signed-in browser, poll /device/token) against the LIVE server's routes, recording which path spelling the server actually serves",
177177
"2FA: with twoFactor on, enable it for a user (sys_user enable_two_factor action, gated on features.twoFactor), sign in, and verify the server-driven challenge (ADR-0069) interrupts before a session is granted",
@@ -200,9 +200,9 @@
200200
"evidence": "the sign-in + session trace"
201201
},
202202
{
203-
"clause": "phone OTP without a deliverable SMS service fails LOUDLY (NOT_SUPPORTED) — the capability degrades to a named error, never a silent success or a hang",
203+
"clause": "phone OTP without a deliverable SMS service fails LOUDLY (400 SMS_SERVICE_REQUIRED) — the capability degrades to a named error, never a silent success, a hang or a bare 500",
204204
"oracle": "api",
205-
"verify": "the send-otp response is a non-2xx carrying the not-supported error; features.phoneNumberOtp is NOT advertised in /auth/config (only advertised when SMS is deliverable, #2780)",
205+
"verify": "the send-otp response is 400 with a JSON body whose code is SMS_SERVICE_REQUIRED (pinned by packages/plugins/plugin-auth/src/phone-otp-no-sms-service-refusal.test.ts); features.phoneNumberOtp is NOT advertised in /auth/config (only advertised when SMS is deliverable, #2780)",
206206
"evidence": "the rejection + the /auth/config read"
207207
},
208208
{
@@ -244,15 +244,15 @@
244244
],
245245
"negative": [
246246
"a silent 200 on any disabled method's endpoint is a FAIL — a gate that only hides the button is not a gate",
247-
"phone OTP hanging or returning 2xx with no SMS service is a FAIL (the spec's own contract is 'loudly NOT_SUPPORTED')",
247+
"phone OTP hanging, returning 2xx, or answering a bare 500 with an empty body with no SMS service is a FAIL (the contract is a loud 400 SMS_SERVICE_REQUIRED)",
248248
"ticking magic-link or passkeys as pass at the browser lane is a false positive — there is no UI to drive (objectui#4179); the honest verdict is blocked",
249249
"features.magicLink or features.passkeys appearing in the /api/v1/auth/config payload is a FAIL — #7481 withdrew both until objectui#4179 ships the UI, so their return means the stop-advertising posture regressed",
250250
"a discovery document whose issuer/endpoints point at a base the server does not actually mount is a FAIL — a wrong .well-known breaks every downstream RP/relying party silently"
251251
],
252252
"variants": [
253253
"email+password (POST /api/v1/auth/sign-in/email, /sign-up/email, /sign-out, /get-session)",
254254
"phone+password (better-auth phone-number plugin sign-in surface)",
255-
"phone OTP sign-in + reset (requires SMS service; loud NOT_SUPPORTED without — #2780)",
255+
"phone OTP sign-in + reset (requires SMS service; loud 400 SMS_SERVICE_REQUIRED on send-otp without — #2780)",
256256
"enterprise SSO / generic OIDC (oidcProviders[] via genericOAuth; login button gated on usable providers)",
257257
"social OAuth (socialProviders map, per-provider enabled)",
258258
"device authorization grant (RFC 8628 — CLI/TV login)",
@@ -274,7 +274,8 @@
274274
{ "revision": 1, "date": "2026-08-07", "change": "new matrix item: per-method sign-in proof with both-sides gate checks and advertisement parity, grounded in the spec's plugin config + public feature registry", "ref": "claude/platform-test-checklist-ocwugl" },
275275
{ "revision": 2, "date": "2026-08-08", "change": "added the .well-known/openid-configuration + oauth-authorization-server discovery-document clause (issuer/endpoints match the mounted base, jwks cross-check) and self-service change-email + delete-user clauses; recorded the live-route divergences (device flow, password reset) from the spec paths (PENDING-GAPS §D)", "ref": "claude/platform-test-checklist-ocwugl" },
276276
{ "revision": 3, "date": "2026-08-20", "change": "scoped scan-functionality (扫描功能) sweep: the 2FA clause asserted the interrupt but cited no pin — added packages/qa/dogfood/test/two-factor-lockout.dogfood.test.ts to source and noted in the clause verify + variant row that cookie-lane completion is pinned end-to-end by that test (cite per rule 6 instead of re-deriving). Enrollment lifecycle around the gate now owned by the four new identity-auth.two-factor-* items. No restructuring", "ref": "claude/new-session-0pv25p" },
277-
{ "revision": 4, "date": "2026-10-05", "change": "clause 10 and step 9 re-pointed: the delete-user half only (stale, #21784 VF4). plugin-auth's auth-route-ledger books POST /api/v1/auth/delete-user with disposition 'disabled': user.deleteUser is deliberately unconfigured (auth-manager), per the maintainer ruling of 2026-08-12 on #7735, so it answers 404 to every caller. The clause dates from revision 1 (2026-08-07) and predates the ruling. The verifier's wording is used. The change-email half is unchanged", "ref": "#21797" }
277+
{ "revision": 4, "date": "2026-10-05", "change": "clause 10 and step 9 re-pointed: the delete-user half only (stale, #21784 VF4). plugin-auth's auth-route-ledger books POST /api/v1/auth/delete-user with disposition 'disabled': user.deleteUser is deliberately unconfigured (auth-manager), per the maintainer ruling of 2026-08-12 on #7735, so it answers 404 to every caller. The clause dates from revision 1 (2026-08-07) and predates the ruling. The verifier's wording is used. The change-email half is unchanged", "ref": "#21797" },
278+
{ "revision": 5, "date": "2026-10-05", "change": "clause 4, its negative, step 4, the fixtures row and the phone-OTP variant re-pointed from NOT_SUPPORTED to the shipped refusal: with no deliverable SMS service, send-otp answers 400 with code SMS_SERVICE_REQUIRED (registered for @objectstack/plugin-auth in the ADR-0112 ledger) instead of a 500 with an empty body (#21784 A4). A bare 500 is now named a FAIL. The clause verify cites the door pin", "ref": "#21793" }
278279
]
279280
},
280281
{

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

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2109,9 +2109,53 @@ describe('AuthManager', () => {
21092109
expect(opts.phoneNumberValidator('bob@example.com')).toBe(false);
21102110
});
21112111

2112-
it('sendOTP throws NOT_SUPPORTED without an SMS service (pre-#2780 behaviour preserved)', async () => {
2113-
const { opts } = await bootOtp();
2114-
await expect(opts.sendOTP({ phoneNumber: PHONE, code: '123456' })).rejects.toThrow(/NOT_SUPPORTED/);
2112+
// No deliverable SMS service ⇒ a typed refusal the router can map: an
2113+
// `APIError` carrying 400 + `SMS_SERVICE_REQUIRED`. A plain `Error` here
2114+
// reached the caller as a 500 with a null body (better-call maps only
2115+
// `APIError`); the wire answer is pinned in
2116+
// `phone-otp-no-sms-service-refusal.test.ts`.
2117+
describe('no deliverable SMS service refuses with 400 SMS_SERVICE_REQUIRED', () => {
2118+
const refusalOf = async (run: () => Promise<unknown>): Promise<any> => {
2119+
try {
2120+
await run();
2121+
} catch (e) {
2122+
return e;
2123+
}
2124+
throw new Error('expected the call to reject, but it resolved');
2125+
};
2126+
2127+
it('no SMS service wired', async () => {
2128+
const { opts } = await bootOtp();
2129+
const err = await refusalOf(() => opts.sendOTP({ phoneNumber: PHONE, code: '123456' }));
2130+
const { isAPIError } = await import('better-auth/api');
2131+
expect(isAPIError(err)).toBe(true);
2132+
expect(err.status).toBe('BAD_REQUEST');
2133+
expect(err.statusCode).toBe(400);
2134+
expect(err.body?.code).toBe('SMS_SERVICE_REQUIRED');
2135+
expect(String(err.message)).not.toContain('123456');
2136+
});
2137+
2138+
it('a log-only SMS transport in production', async () => {
2139+
vi.stubEnv('NODE_ENV', 'production');
2140+
try {
2141+
const { manager, opts } = await bootOtp();
2142+
const sent: unknown[] = [];
2143+
manager.setSmsService({
2144+
async send(input: unknown) {
2145+
sent.push(input);
2146+
return { id: 'sms_log', status: 'sent' };
2147+
},
2148+
isConfigured: () => false,
2149+
} as any);
2150+
const err = await refusalOf(() => opts.sendOTP({ phoneNumber: PHONE, code: '654321' }));
2151+
expect(err.statusCode).toBe(400);
2152+
expect(err.body?.code).toBe('SMS_SERVICE_REQUIRED');
2153+
expect(String(err.message)).not.toContain('654321');
2154+
expect(sent).toHaveLength(0);
2155+
} finally {
2156+
vi.unstubAllEnvs();
2157+
}
2158+
});
21152159
});
21162160

21172161
it('sendOTP delivers the code in the SMS body (and only there)', async () => {

0 commit comments

Comments
 (0)