Skip to content
Merged
13 changes: 13 additions & 0 deletions .changeset/21793-phone-otp-no-provider-4xx.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@objectstack/spec': minor
'@objectstack/plugin-auth': patch
---

Phone-number OTP with no deliverable SMS service now answers `400 SMS_SERVICE_REQUIRED` instead of a `500` with an empty body (#21793).

Clause-②: yes (widening)

- **`@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.
- **`@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.

**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.
10 changes: 7 additions & 3 deletions content/docs/permissions/authentication.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -384,8 +384,11 @@ const response = await fetch('http://localhost:3000/api/v1/auth/sign-in/phone-nu
#### OTP sign-in and self-service reset (requires SMS delivery)

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

Expand Down Expand Up @@ -756,7 +759,8 @@ The OTP surfaces — `POST /phone-number/send-otp` + `POST /phone-number/verify`
(sign-in / verification) and `POST /phone-number/request-password-reset` +
`POST /phone-number/reset-password` (self-service reset) — only work when an
SMS service is wired (`@objectstack/service-sms` registers the `sms` kernel
service). Without one, `send-otp` fails loudly instead of silently dropping
service). Without one, `send-otp` fails loudly — `400` with
`code: "SMS_SERVICE_REQUIRED"` — instead of silently dropping
the code; phone sign-in is then password-based only (an admin sets the
password — see Admin User Management below). OTP requests are rate-limited
per phone number to prevent SMS-pumping abuse.
Expand Down
3 changes: 2 additions & 1 deletion content/docs/references/api/contract.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const result = ApiErrorSchema.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **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) |
| **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) |
| **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) |
| **message** | `string` | ✅ | Readable error message |
| **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`. |
Expand Down Expand Up @@ -321,6 +321,7 @@ const result = ApiErrorSchema.parse(data);
* `SHARE_REVOKE_FAILED`
* `SHARING_NOT_ENABLED`
* `SIGN_IN_REQUIRED`
* `SMS_SERVICE_REQUIRED`
* `SQL_DIALECT_EMISSION_UNSUPPORTED`
* `SSO_REGISTER_FAILED`
* `SSO_REGISTER_FORBIDDEN`
Expand Down
1 change: 1 addition & 0 deletions content/docs/references/api/error-code-ledger.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,7 @@ const result = ErrorCode.parse(data);
* `SHARE_REVOKE_FAILED`
* `SHARING_NOT_ENABLED`
* `SIGN_IN_REQUIRED`
* `SMS_SERVICE_REQUIRED`
* `SQL_DIALECT_EMISSION_UNSUPPORTED`
* `SSO_REGISTER_FAILED`
* `SSO_REGISTER_FORBIDDEN`
Expand Down
17 changes: 9 additions & 8 deletions docs/qa/platform-checklist/areas/identity-auth.json
Original file line number Diff line number Diff line change
Expand Up @@ -151,15 +151,15 @@
"title": "Every supported auth method signs in when enabled, is absent when disabled, and is advertised exactly as configured",
"since": "v16",
"status": "active",
"revision": 4,
"revision": 5,
"priority": "P1",
"surface": "mixed",
"personas": ["anonymous visitor", "provisioned user per method", "admin (for env configuration)"],
"fixtures": {
"app": "showcase",
"requires": [
"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",
"an SMS service (@objectstack/service-sms) is required ONLY to make phone-OTP pass; its ABSENCE is itself a tested state (loud NOT_SUPPORTED)"
"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)"
],
"knownGaps": [
"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",
Expand All @@ -171,7 +171,7 @@
"for each variant: boot with the method configured ON, GET /api/v1/auth/config, and record the advertisement (features.* flag, emailPassword block, socialProviders list)",
"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",
"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",
"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)",
"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)",
"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",
"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",
"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",
Expand Down Expand Up @@ -200,9 +200,9 @@
"evidence": "the sign-in + session trace"
},
{
"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",
"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",
"oracle": "api",
"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)",
"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)",
"evidence": "the rejection + the /auth/config read"
},
{
Expand Down Expand Up @@ -244,15 +244,15 @@
],
"negative": [
"a silent 200 on any disabled method's endpoint is a FAIL — a gate that only hides the button is not a gate",
"phone OTP hanging or returning 2xx with no SMS service is a FAIL (the spec's own contract is 'loudly NOT_SUPPORTED')",
"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)",
"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",
"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",
"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"
],
"variants": [
"email+password (POST /api/v1/auth/sign-in/email, /sign-up/email, /sign-out, /get-session)",
"phone+password (better-auth phone-number plugin sign-in surface)",
"phone OTP sign-in + reset (requires SMS service; loud NOT_SUPPORTED without — #2780)",
"phone OTP sign-in + reset (requires SMS service; loud 400 SMS_SERVICE_REQUIRED on send-otp without — #2780)",
"enterprise SSO / generic OIDC (oidcProviders[] via genericOAuth; login button gated on usable providers)",
"social OAuth (socialProviders map, per-provider enabled)",
"device authorization grant (RFC 8628 — CLI/TV login)",
Expand All @@ -274,7 +274,8 @@
{ "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" },
{ "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" },
{ "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" },
{ "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" }
{ "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" },
{ "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" }
]
},
{
Expand Down
50 changes: 47 additions & 3 deletions packages/plugins/plugin-auth/src/auth-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2109,9 +2109,53 @@ describe('AuthManager', () => {
expect(opts.phoneNumberValidator('bob@example.com')).toBe(false);
});

it('sendOTP throws NOT_SUPPORTED without an SMS service (pre-#2780 behaviour preserved)', async () => {
const { opts } = await bootOtp();
await expect(opts.sendOTP({ phoneNumber: PHONE, code: '123456' })).rejects.toThrow(/NOT_SUPPORTED/);
// No deliverable SMS service ⇒ a typed refusal the router can map: an
// `APIError` carrying 400 + `SMS_SERVICE_REQUIRED`. A plain `Error` here
// reached the caller as a 500 with a null body (better-call maps only
// `APIError`); the wire answer is pinned in
// `phone-otp-no-sms-service-refusal.test.ts`.
describe('no deliverable SMS service refuses with 400 SMS_SERVICE_REQUIRED', () => {
const refusalOf = async (run: () => Promise<unknown>): Promise<any> => {
try {
await run();
} catch (e) {
return e;
}
throw new Error('expected the call to reject, but it resolved');
};

it('no SMS service wired', async () => {
const { opts } = await bootOtp();
const err = await refusalOf(() => opts.sendOTP({ phoneNumber: PHONE, code: '123456' }));
const { isAPIError } = await import('better-auth/api');
expect(isAPIError(err)).toBe(true);
expect(err.status).toBe('BAD_REQUEST');
expect(err.statusCode).toBe(400);
expect(err.body?.code).toBe('SMS_SERVICE_REQUIRED');
expect(String(err.message)).not.toContain('123456');
});

it('a log-only SMS transport in production', async () => {
vi.stubEnv('NODE_ENV', 'production');
try {
const { manager, opts } = await bootOtp();
const sent: unknown[] = [];
manager.setSmsService({
async send(input: unknown) {
sent.push(input);
return { id: 'sms_log', status: 'sent' };
},
isConfigured: () => false,
} as any);
const err = await refusalOf(() => opts.sendOTP({ phoneNumber: PHONE, code: '654321' }));
expect(err.statusCode).toBe(400);
expect(err.body?.code).toBe('SMS_SERVICE_REQUIRED');
expect(String(err.message)).not.toContain('654321');
expect(sent).toHaveLength(0);
} finally {
vi.unstubAllEnvs();
}
});
});

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