From a2336ee7f7c04fc3a7f1c65a2e6116ce5f374d01 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 06:52:36 +0000 Subject: [PATCH 1/6] test(plugin-auth): door pin for phone send-otp with no deliverable SMS service (WIP) Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN Co-authored-by: Claude --- .../phone-otp-no-sms-service-refusal.test.ts | 254 ++++++++++++++++++ 1 file changed, 254 insertions(+) create mode 100644 packages/plugins/plugin-auth/src/phone-otp-no-sms-service-refusal.test.ts diff --git a/packages/plugins/plugin-auth/src/phone-otp-no-sms-service-refusal.test.ts b/packages/plugins/plugin-auth/src/phone-otp-no-sms-service-refusal.test.ts new file mode 100644 index 00000000000..a85b539f8a3 --- /dev/null +++ b/packages/plugins/plugin-auth/src/phone-otp-no-sms-service-refusal.test.ts @@ -0,0 +1,254 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Phone-number OTP with no deliverable SMS service, driven on the wire. + * + * `POST /phone-number/send-otp` on a deployment that turned the phoneNumber + * plugin on but has no SMS service to deliver the code (none wired, or a + * log-only transport in production) used to answer **500 with a null body**: + * `deliverPhoneOtp` threw a plain `Error`, and better-call (better-auth's + * router) maps ONLY an `APIError` to a real status — everything else takes its + * `console.error` + `500 / null` branch. The login page then had nothing to + * branch on and showed a generic failure, while the quota wall in the same + * function already answered with a typed `APIError`. + * + * The refusal is now `400` with `code: 'SMS_SERVICE_REQUIRED'`, registered for + * `@objectstack/plugin-auth` in the ADR-0112 error-code ledger beside its email + * sibling `EMAIL_SERVICE_REQUIRED` (which the identity import answers at 400 + * for the same "a delivery service is not configured" condition). + * + * This file asserts at the seam a caller uses: real `AuthManager.handleRequest` + * over a real better-auth pipeline. The console's phone-number client reads the + * top-level `code` and `message` of exactly this body, so the status, the code + * and the absence of the one-time code from the message are all pinned here. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/objectql'; +import { ErrorCode } from '@objectstack/spec/api'; +import { AuthManager } from './auth-manager'; + +// ─────────────────────────────────────────────────────────────────────────── +// Harness +// ─────────────────────────────────────────────────────────────────────────── + +interface MemoryRow { id: string; [column: string]: unknown } +/** + * The index signature makes this assignable to the engine dispatch inputs, so + * the predicates below are called with a real type rather than through a cast. + */ +interface MemoryQuery { + where?: Record; + fields?: string[]; + limit?: number; + offset?: number; + multi?: boolean; + [option: string]: unknown; +} + +/** + * In-memory `IDataEngine`, the shape `change-email-delete-user-wiring.test.ts` + * uses — both destructive verbs pinned to ObjectQL's OWN dispatch predicates + * (`pnpm check:engine-double-contract`), so this double cannot accept a call + * the real engine refuses. + */ +function createMemoryEngine() { + const tables = new Map(); + const rows = (name: string): MemoryRow[] => { + if (!tables.has(name)) tables.set(name, []); + return tables.get(name)!; + }; + const eq = (a: unknown, b: unknown): boolean => + a instanceof Date || b instanceof Date + ? new Date(a as string).getTime() === new Date(b as string).getTime() + : a === b; + const matches = (row: MemoryRow, where: Record = {}): boolean => + Object.entries(where).every(([key, expected]) => { + if (key.startsWith('$')) throw new Error(`fake driver: unsupported operator ${key}`); + const actual = row[key]; + if (expected && typeof expected === 'object' && !Array.isArray(expected) && !(expected instanceof Date)) { + const operators = expected as Record; + if ('$ne' in operators) return !eq(actual, operators.$ne); + if ('$in' in operators) return (operators.$in as unknown[]).some((v) => eq(actual, v)); + } + return eq(actual, expected); + }); + const project = (row: MemoryRow, fields?: string[]): MemoryRow => { + if (!Array.isArray(fields) || fields.length === 0) return { ...row }; + const out = { id: row.id } as MemoryRow; + for (const field of fields) if (field in row) out[field] = row[field]; + return out; + }; + let seq = 0; + return { + tables, + async insert(name: string, data: Record): Promise { + const row = { ...data, id: (data.id as string) ?? `row_${++seq}` } as MemoryRow; + rows(name).push(row); + return { ...row }; + }, + async findOne(name: string, query: MemoryQuery = {}): Promise { + assertEngineFindOnePredicate(name, query); + const row = rows(name).find((r) => matches(r, query.where)); + return row ? project(row, query.fields) : null; + }, + async find(name: string, query: MemoryQuery = {}): Promise { + let out = rows(name).filter((r) => matches(r, query.where)); + if (query.offset) out = out.slice(query.offset); + if (query.limit) out = out.slice(0, query.limit); + return out.map((r) => project(r, query.fields)); + }, + async count(name: string, query: MemoryQuery = {}): Promise { + return rows(name).filter((r) => matches(r, query.where)).length; + }, + async update(name: string, data: Record, options?: MemoryQuery): Promise { + assertEngineUpdateDispatch(data, options); + const row = rows(name).find((r) => r.id === data.id); + if (!row) return null; + Object.assign(row, data); + return { ...row }; + }, + async delete(name: string, options: MemoryQuery = {}): Promise { + assertEngineDeleteDispatch(options); + const table = rows(name); + const keep = table.filter((r) => !matches(r, options.where)); + tables.set(name, keep); + return table.length - keep.length; + }, + }; +} + +type MemoryEngine = ReturnType; + +const SECRET = 'test-secret-at-least-32-chars-long!!'; +const ORIGIN = 'http://localhost:3000'; +const AUTH = `${ORIGIN}/api/v1/auth`; +const PHONE = '+15550100'; + +function makeManager(engine: MemoryEngine, smsService?: unknown): AuthManager { + return new AuthManager({ + secret: SECRET, + baseUrl: ORIGIN, + dataEngine: engine, + plugins: { phoneNumber: true }, + ...(smsService ? { smsService } : {}), + } as never); +} + +/** + * An SMS service whose transport is the log-only fallback: wired, but + * `isConfigured()` is false. Outside production that still prints the message + * (local OTP flows stay testable); in production it delivers nothing, so the + * OTP surface must refuse exactly as if no service were wired. + */ +function createLogOnlySmsService() { + const sent: unknown[] = []; + return { + sent, + service: { + async send(input: unknown) { + sent.push(input); + return { id: 'sms_log', status: 'sent' }; + }, + isConfigured: () => false, + }, + }; +} + +const sendOtp = (manager: AuthManager, phoneNumber: string = PHONE) => + manager.handleRequest( + new Request(`${AUTH}/phone-number/send-otp`, { + method: 'POST', + headers: { 'content-type': 'application/json', origin: ORIGIN }, + body: JSON.stringify({ phoneNumber }), + }), + ); + +/** The body as the console's phone-number client reads it: JSON, or nothing. */ +const jsonBody = async (response: Response): Promise | null> => { + const text = await response.text(); + if (!text) return null; + try { + return JSON.parse(text) as Record | null; + } catch { + return { raw: text }; + } +}; + +/** The one-time codes better-auth stored for `phone` — what must never leak. */ +const storedOtpCodes = (engine: MemoryEngine, phone: string = PHONE): string[] => + [...engine.tables.values()] + .flat() + .filter((row) => row.identifier === phone && typeof row.value === 'string') + .map((row) => String(row.value).split(':')[0]) + .filter(Boolean); + +beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(console, 'info').mockImplementation(() => {}); +}); +afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); +}); + +// ─────────────────────────────────────────────────────────────────────────── +describe('POST /phone-number/send-otp without a deliverable SMS service', () => { + it('no SMS service wired: answers 400 SMS_SERVICE_REQUIRED, never a bare 500', async () => { + const engine = createMemoryEngine(); + const manager = makeManager(engine); + + const response = await sendOtp(manager); + const body = await jsonBody(response); + + expect(response.status, JSON.stringify(body)).toBe(400); + expect(body?.code).toBe('SMS_SERVICE_REQUIRED'); + // The code on the wire is a member of the ADR-0112 vocabulary, not an + // unregistered spelling (which would fail `ApiErrorSchema` parse). + expect(ErrorCode.safeParse(body?.code).success).toBe(true); + // The message names the missing capability, so a client with no mapping + // for the code still shows the reason instead of a generic failure. + expect(String(body?.message)).toMatch(/SMS/); + }); + + it('the refusal never carries the one-time code better-auth stored', async () => { + const engine = createMemoryEngine(); + const manager = makeManager(engine); + + const response = await sendOtp(manager); + const text = await response.text(); + + // better-auth stores the fresh code BEFORE invoking the send callback, so + // a code exists at the moment of refusal; it reaches only an SMS body. + const codes = storedOtpCodes(engine); + expect(codes.length, 'better-auth stores the code before the send callback').toBeGreaterThan(0); + for (const code of codes) expect(text).not.toContain(code); + }); + + it('production with a log-only SMS transport: the same 400 SMS_SERVICE_REQUIRED, and nothing is sent', async () => { + vi.stubEnv('NODE_ENV', 'production'); + const engine = createMemoryEngine(); + const sms = createLogOnlySmsService(); + const manager = makeManager(engine, sms.service); + + const response = await sendOtp(manager); + const body = await jsonBody(response); + + expect(response.status, JSON.stringify(body)).toBe(400); + expect(body?.code).toBe('SMS_SERVICE_REQUIRED'); + expect(sms.sent).toHaveLength(0); + }); + + it('outside production a log-only transport still delivers (the refusal is production-only for it)', async () => { + vi.stubEnv('NODE_ENV', 'development'); + const engine = createMemoryEngine(); + const sms = createLogOnlySmsService(); + const manager = makeManager(engine, sms.service); + + const response = await sendOtp(manager); + + expect(response.status, await response.clone().text()).toBe(200); + expect(sms.sent).toHaveLength(1); + }); +}); From 69447ff30819c81dd21c5d0179e60c02b04e1734 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 07:04:04 +0000 Subject: [PATCH 2/6] fix(plugin-auth): phone send-otp with no deliverable SMS service answers 400 SMS_SERVICE_REQUIRED The no-provider branch of deliverPhoneOtp threw a plain Error, which better-call maps to a 500 with a null body. It now throws better-auth's APIError (400) carrying SMS_SERVICE_REQUIRED, registered for @objectstack/plugin-auth in the ADR-0112 error-code ledger beside EMAIL_SERVICE_REQUIRED. Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN Co-authored-by: Claude --- .changeset/21793-phone-otp-no-provider-4xx.md | 13 +++++ content/docs/permissions/authentication.mdx | 10 ++-- .../plugin-auth/src/auth-manager.test.ts | 50 +++++++++++++++++-- .../plugins/plugin-auth/src/auth-manager.ts | 32 ++++++++---- .../phone-otp-no-sms-service-refusal.test.ts | 37 ++++++++++++++ .../spec/src/api/error-code-ledger.zod.ts | 1 + 6 files changed, 127 insertions(+), 16 deletions(-) create mode 100644 .changeset/21793-phone-otp-no-provider-4xx.md diff --git a/.changeset/21793-phone-otp-no-provider-4xx.md b/.changeset/21793-phone-otp-no-provider-4xx.md new file mode 100644 index 00000000000..9b0d23f3667 --- /dev/null +++ b/.changeset/21793-phone-otp-no-provider-4xx.md @@ -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. diff --git a/content/docs/permissions/authentication.mdx b/content/docs/permissions/authentication.mdx index 493c3f1ab95..d213b3cedeb 100644 --- a/content/docs/permissions/authentication.mdx +++ b/content/docs/permissions/authentication.mdx @@ -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. @@ -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. diff --git a/packages/plugins/plugin-auth/src/auth-manager.test.ts b/packages/plugins/plugin-auth/src/auth-manager.test.ts index 2187b74b567..345955f357f 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.test.ts @@ -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): Promise => { + 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 () => { diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index a6696db0375..9d24bb3fe33 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -737,7 +737,7 @@ export interface AuthManagerOptions extends Partial { * Optional outbound SMS service used by the phoneNumber plugin's OTP * callbacks (`sendOTP`, `sendPasswordResetOTP`) and the import SMS-invite * path (#2780). When omitted, `/phone-number/send-otp` fails loudly with - * NOT_SUPPORTED (the pre-SMS behaviour) instead of silently logging. + * 400 `SMS_SERVICE_REQUIRED` instead of silently logging. * * Resolved lazily through {@link AuthManager.getSmsService}; safe to set * after construction. AuthPlugin wires this from the kernel service @@ -3559,7 +3559,7 @@ export class AuthManager { // `/reset-password`) whenever an SMS service is available — resolved // lazily per send so the plugin list stays stable while the capability // upgrades at kernel:ready. Without one, sendOTP still fails loudly - // (NOT_SUPPORTED) instead of silently logging. signUpOnVerification + // (400 SMS_SERVICE_REQUIRED) instead of silently logging. signUpOnVerification // stays deliberately NOT configured — phone-only accounts are created // by the admin create-user/import routes with a placeholder email // (see placeholder-email.ts), never by OTP self-signup. @@ -5313,7 +5313,7 @@ export class AuthManager { * why it cannot live in the `sendOTP` callback). Consumes one unit of the * per-number budget and throws TOO_MANY_REQUESTS when the cooldown / * hourly cap is exhausted. No-op while OTP is undeliverable — the send - * callback then fails loudly with NOT_SUPPORTED instead. + * callback then fails loudly with 400 SMS_SERVICE_REQUIRED instead. */ async assertPhoneOtpSendAllowed(phone: string): Promise { if (!phone || !this.isPhoneOtpDeliverable()) return; @@ -5330,7 +5330,8 @@ export class AuthManager { * #2780 — deliver a phone OTP through the SMS service. * * Security posture (all named requirements of #2780): - * - No SMS service ⇒ throw NOT_SUPPORTED (loud, like the pre-SMS wiring). + * - No deliverable SMS service ⇒ throw a 400 `SMS_SERVICE_REQUIRED` + * `APIError` (loud, and a status the caller can branch on). * - The per-number cooldown + hourly cap live in the `hooks.before` * admission check, NOT here: better-auth stores the fresh code before * invoking this callback, so a rejection at this point would still @@ -5345,12 +5346,23 @@ export class AuthManager { const sms = this.getSmsService(); if (!sms || !this.isPhoneOtpDeliverable()) { // Absent service, or a log-only transport in production (the code - // would vanish into a log no user can read) — fail loudly, exactly - // like the pre-SMS wiring. - throw new Error( - 'NOT_SUPPORTED: phone-number OTP requires a configured SMS delivery service. ' + - 'Phone sign-in is password-based (POST /sign-in/phone-number).', - ); + // would vanish into a log no user can read) — refuse loudly, as a real + // `APIError` like the quota branch below: better-call maps ONLY an + // `APIError` to a status, and a plain `Error` reached the caller as a + // 500 with a null body, leaving the login page nothing to branch on. + // 400 + `SMS_SERVICE_REQUIRED` mirrors the email sibling + // (`EMAIL_SERVICE_REQUIRED`, also 400): a delivery service this request + // needs is not configured on this deployment. The code is registered + // for this package in the ADR-0112 error-code ledger. The message names + // the capability and the fix, and never the OTP code. + const { APIError } = await import('better-auth/api'); + throw new APIError('BAD_REQUEST', { + message: + 'Phone verification codes are unavailable: this deployment has no configured SMS ' + + 'delivery service. An administrator can configure one under Setup → Settings → SMS ' + + 'Delivery; until then, sign in with your phone number and password.', + code: 'SMS_SERVICE_REQUIRED', + }); } const otpCfg = this.config.phoneOtp ?? {}; const minutes = Math.max(1, Math.round((otpCfg.expiresIn ?? 300) / 60)); diff --git a/packages/plugins/plugin-auth/src/phone-otp-no-sms-service-refusal.test.ts b/packages/plugins/plugin-auth/src/phone-otp-no-sms-service-refusal.test.ts index a85b539f8a3..93abbef9a10 100644 --- a/packages/plugins/plugin-auth/src/phone-otp-no-sms-service-refusal.test.ts +++ b/packages/plugins/plugin-auth/src/phone-otp-no-sms-service-refusal.test.ts @@ -240,6 +240,43 @@ describe('POST /phone-number/send-otp without a deliverable SMS service', () => expect(sms.sent).toHaveLength(0); }); + it('request-password-reset for a REGISTERED number still answers {status:true} — no existence oracle', async () => { + const engine = createMemoryEngine(); + // A phone-carrying account, so the route reaches the send callback (an + // unregistered number returns before it, and would prove nothing). + await engine.insert('sys_user', { + id: 'usr_phone_reset', + email: 'u-phone-reset@placeholder.invalid', + name: 'Phone Reset Subject', + phone_number: PHONE, + phone_number_verified: true, + }); + const manager = makeManager(engine); + // Pass-through spy: proves the route REACHED the refusing send for this + // registered number, rather than returning early as it does for an + // unregistered one. + const deliver = vi.spyOn(manager as any, 'deliverPhoneOtp'); + + const response = await manager.handleRequest( + new Request(`${AUTH}/phone-number/request-password-reset`, { + method: 'POST', + headers: { 'content-type': 'application/json', origin: ORIGIN }, + body: JSON.stringify({ phoneNumber: PHONE }), + }), + ); + + // better-auth runs this send through `runInBackgroundOrAwait`, which logs a + // throw and answers success — so the refusal cannot leak, by status or + // body, whether the number belongs to an account. + expect(response.status, await response.clone().text()).toBe(200); + expect(await response.json()).toEqual({ status: true }); + expect(deliver).toHaveBeenCalledTimes(1); + await expect(deliver.mock.results[0]?.value).rejects.toMatchObject({ + statusCode: 400, + body: { code: 'SMS_SERVICE_REQUIRED' }, + }); + }); + it('outside production a log-only transport still delivers (the refusal is production-only for it)', async () => { vi.stubEnv('NODE_ENV', 'development'); const engine = createMemoryEngine(); diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index be7b029a07d..c4969a0ee3d 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -527,6 +527,7 @@ export const ERROR_CODE_LEDGER = { 'DOMAIN_VERIFICATION_FAILED', // pass-through from better-auth 'EMAIL_DOMAIN_NOT_ALLOWED', // [#11739] audience posture email_domain: the address's domain is off the allowlist 'EMAIL_SERVICE_REQUIRED', + 'SMS_SERVICE_REQUIRED', // [#21793] 400 on /phone-number/send-otp: phone OTP needs a deliverable SMS service and none is configured (none wired, or a log-only transport in production) 'ENV_ACCESS_DENIED', 'INVALID_EMAIL', 'INVALID_PHONE', From 1e6367e3ec340e4d0159a123f2094b10cc831d4f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 07:12:43 +0000 Subject: [PATCH 3/6] docs(spec): ledger comments for DRIVER_UNAVAILABLE and OS_PROTOCOL_INCOMPATIBLE match their emitters Comment-only, riding the SMS_SERVICE_REQUIRED registration in the same file. Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN Co-authored-by: Claude --- packages/spec/src/api/error-code-ledger.zod.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index c4969a0ee3d..902ff00ab15 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -637,10 +637,12 @@ export const ERROR_CODE_LEDGER = { // never satisfy, and the remedy is a body change // (`objectstack migrate meta --from N`). It matches this package's own // METADATA_SCHEMA_INVALID. It is not 409, which this ledger keeps for - // refusals from environment state (FLOW_CONVERSION_CONFLICT). The one HTTP - // door that reaches the throw, `POST /api/v1/packages`, carries - // `requiredRange`, `rangeSource`, `protocolVersion`, `targetMajor` and - // `migrateCommand` in `error.details`. + // refusals from environment state (FLOW_CONVERSION_CONFLICT). The two HTTP + // doors that reach the throw, `POST /api/v1/packages` and (since #21805) + // `POST /api/v1/marketplace/install-local`, answer the same bytes through + // the shared `protocolIncompatibleAnswer`, carrying `requiredRange`, + // `rangeSource`, `protocolVersion`, `targetMajor` and `migrateCommand` in + // `error.details`. 'OS_PROTOCOL_INCOMPATIBLE', ], '@objectstack/objectql': [ @@ -1014,7 +1016,7 @@ export const ERROR_CODE_LEDGER = { 'CLOUD_FETCH_FAILED', // fetching the manifest/bundle from cloud failed 'CLOUD_UNCONFIGURED', // no cloud endpoint configured on this runtime 'DEVICE_CODE_FAILED', // cloud rejected the device-code exchange - 'DRIVER_UNAVAILABLE', // no driver service — cannot purge seeded rows + 'DRIVER_UNAVAILABLE', // purge-sample-data: the data engine (objectql) or the metadata service is unavailable — cannot purge seeded rows (since #21773) 'ENVIRONMENT_BIND_FAILED', 'ENVIRONMENT_NOT_FOUND', // [#13353] `requireInstallCapability`'s 403 From 9519ac2838dda494808a2ea9db97800e3c81f08f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 07:15:33 +0000 Subject: [PATCH 4/6] docs(qa): auth-method-matrix names the shipped no-SMS refusal, 400 SMS_SERVICE_REQUIRED (revision 5) Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN Co-authored-by: Claude --- .../platform-checklist/areas/identity-auth.json | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/docs/qa/platform-checklist/areas/identity-auth.json b/docs/qa/platform-checklist/areas/identity-auth.json index ca5da09cabd..f0734d3cb74 100644 --- a/docs/qa/platform-checklist/areas/identity-auth.json +++ b/docs/qa/platform-checklist/areas/identity-auth.json @@ -151,7 +151,7 @@ "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)"], @@ -159,7 +159,7 @@ "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", @@ -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", @@ -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" }, { @@ -244,7 +244,7 @@ ], "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" @@ -252,7 +252,7 @@ "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)", @@ -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" } ] }, { From b37edd67d95fb8dc13681d737a05deb8a4c0d129 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 07:27:13 +0000 Subject: [PATCH 5/6] docs(spec): regenerate the reference pages for the SMS_SERVICE_REQUIRED ledger row Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN Co-authored-by: Claude --- content/docs/references/api/contract.mdx | 3 ++- content/docs/references/api/error-code-ledger.mdx | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/content/docs/references/api/contract.mdx b/content/docs/references/api/contract.mdx index d3334f22095..0fe81e25063 100644 --- a/content/docs/references/api/contract.mdx +++ b/content/docs/references/api/contract.mdx @@ -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`. | @@ -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` diff --git a/content/docs/references/api/error-code-ledger.mdx b/content/docs/references/api/error-code-ledger.mdx index f715b93bdd5..4be28519b78 100644 --- a/content/docs/references/api/error-code-ledger.mdx +++ b/content/docs/references/api/error-code-ledger.mdx @@ -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` From 3e8ab8460208f05b5dd03983f4cacd754b34ef2b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 07:51:06 +0000 Subject: [PATCH 6/6] test(plugin-auth): the door pin's engine double bounds by presence, and the pinned-double ledger records it Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN Co-authored-by: Claude --- .../src/phone-otp-no-sms-service-refusal.test.ts | 6 ++++-- scripts/engine-double-contract.pinned.json | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/plugins/plugin-auth/src/phone-otp-no-sms-service-refusal.test.ts b/packages/plugins/plugin-auth/src/phone-otp-no-sms-service-refusal.test.ts index 93abbef9a10..193270b4f8f 100644 --- a/packages/plugins/plugin-auth/src/phone-otp-no-sms-service-refusal.test.ts +++ b/packages/plugins/plugin-auth/src/phone-otp-no-sms-service-refusal.test.ts @@ -94,8 +94,10 @@ function createMemoryEngine() { }, async find(name: string, query: MemoryQuery = {}): Promise { let out = rows(name).filter((r) => matches(r, query.where)); - if (query.offset) out = out.slice(query.offset); - if (query.limit) out = out.slice(0, query.limit); + // The caller's bounds, applied after the filter and by PRESENCE: a + // `limit: 0` is a bound of zero rows, not an absent one. + if (typeof query.offset === 'number') out = out.slice(query.offset); + if (typeof query.limit === 'number') out = out.slice(0, query.limit); return out.map((r) => project(r, query.fields)); }, async count(name: string, query: MemoryQuery = {}): Promise { diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 19c69f678a8..89b9933b08f 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -2796,6 +2796,21 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/plugins/plugin-auth/src/phone-otp-no-sms-service-refusal.test.ts", + "verb": "delete", + "pinned": 1 + }, + { + "file": "packages/plugins/plugin-auth/src/phone-otp-no-sms-service-refusal.test.ts", + "verb": "findOne", + "pinned": 1 + }, + { + "file": "packages/plugins/plugin-auth/src/phone-otp-no-sms-service-refusal.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/plugins/plugin-auth/src/remove-member-permission-guard.test.ts", "verb": "delete",