Skip to content

Commit 40edb76

Browse files
committed
docs: services.sms reference + phone-number OTP authentication guide (#2780)
The #2780 line shipped code without hand-written docs: - new kernel/runtime-services/sms-service.mdx (mirrors email-service.mdx): send/isConfigured surface, no-persistence/no-body-logging red line, provider configuration via the `sms` settings namespace, consumers (auth OTP, messaging sms channel, import SMS invitations) - permissions/authentication.mdx: Phone-Number Authentication section - OS_AUTH_PHONE_NUMBER_ENABLED, phone+password sign-in, the four OTP endpoints gated on a deliverable SMS service (features.phoneNumberOtp), abuse hardening (admission cooldown/cap, allowedAttempts, per-IP rules, no-oracle reset), import SMS invitations - runtime-services index + meta: register the new page Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013LXUXU66dBaP3SSG4ZVtuH
1 parent 1c19139 commit 40edb76

5 files changed

Lines changed: 161 additions & 0 deletions

File tree

.changeset/docs-sms-phone-otp.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
---
3+
4+
Docs-only: `services.sms` runtime-service reference + phone-number
5+
authentication (SMS OTP) section in the authentication guide. No package
6+
changes.

content/docs/kernel/runtime-services/index.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ Each page links the canonical TypeScript source used to derive signatures.
3939
- Sharing: `packages/spec/src/contracts/sharing-service.ts`
4040
- Queue: `packages/spec/src/contracts/queue-service.ts`
4141
- Email: `packages/spec/src/contracts/email-service.ts`
42+
- SMS: `packages/spec/src/contracts/sms-service.ts`
4243
- Storage: `packages/spec/src/contracts/storage-service.ts`
4344
- Settings: `packages/services/service-settings/src/settings-service.ts`
4445
- Audit bridge: `packages/services/service-settings/src/settings-service.types.ts`

content/docs/kernel/runtime-services/meta.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"audit-service",
88
"queue-service",
99
"email-service",
10+
"sms-service",
1011
"settings-service",
1112
"storage-service",
1213
"examples",
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
---
2+
title: services.sms
3+
description: Outbound SMS delivery through pluggable providers (Aliyun SMS, Twilio).
4+
---
5+
6+
# `services.sms`
7+
8+
- **Stability:** `stable`
9+
- **Canonical source:** `packages/spec/src/contracts/sms-service.ts`
10+
11+
## Methods
12+
13+
```ts
14+
services.sms.send(input: SendSmsInput): Promise<SendSmsResult>
15+
services.sms.isConfigured(): boolean
16+
```
17+
18+
`SendSmsInput` carries the recipient (`to`, E.164 recommended), the rendered
19+
`body`, and optional provider-template fields (`templateId`,
20+
`templateParams`) for template-only providers such as Aliyun SMS — which
21+
refuse free-form bodies and only deliver pre-registered templates.
22+
23+
`isConfigured()` is `false` while the service runs on the development
24+
`LogSmsTransport` fallback (messages are logged, never delivered). Consumers
25+
that advertise SMS-dependent features — phone OTP sign-in, SMS invitations —
26+
gate on it in production (`features.phoneNumberOtp`).
27+
28+
## Returns
29+
30+
`SendSmsResult`:
31+
32+
- `id: string` — correlation id for the attempt
33+
- `status: 'sent' | 'failed'`
34+
- `messageId?: string` — provider message id (Aliyun BizId, Twilio SID)
35+
- `error?: string` — transport detail when `status='failed'`
36+
37+
## No persistence, no body logging
38+
39+
Unlike `services.email` (which persists to `sys_email`), the SMS service
40+
deliberately has **no persistence surface and never logs message bodies**:
41+
SMS bodies routinely carry one-time passwords, so a message log would be a
42+
credential store. Diagnostics log masked recipients and statuses only.
43+
44+
## Configuration
45+
46+
Providers are configured through the `sms` settings namespace
47+
(Setup → Settings → SMS Delivery), with live rebinding — no restart needed:
48+
49+
- `provider``log` (default, no real delivery) / `aliyun` / `twilio`
50+
- Aliyun: `aliyun_access_key_id`, `aliyun_access_key_secret` (encrypted),
51+
`aliyun_sign_name`, `aliyun_template_code` (default catch-all template with
52+
a single `${content}` variable for generic notification SMS)
53+
- Twilio: `twilio_account_sid`, `twilio_auth_token` (encrypted), and
54+
`twilio_from_number` or `twilio_messaging_service_sid`
55+
56+
Every key accepts the standard settings env override (`OS_SMS_PROVIDER`,
57+
`OS_SMS_ALIYUN_ACCESS_KEY_ID`, …). The **Send test SMS** action exercises the
58+
live (or unsaved) provider configuration.
59+
60+
## Consumers
61+
62+
- **Phone-number OTP auth** — sign-in verification codes and self-service
63+
password-reset codes (`/phone-number/*`, see
64+
[Authentication](/docs/permissions/authentication)).
65+
- **Messaging `sms` channel**`notify(channels:['sms'])` resolves the
66+
recipient's `sys_user.phone_number`, renders the `(topic, 'sms', locale)`
67+
row from `sys_notification_template`, and delivers through this service
68+
with outbox retry/dead-letter.
69+
- **Identity import SMS invitations** — the `invite` password policy sends a
70+
credential-free invitation SMS to phone-only rows.

content/docs/permissions/authentication.mdx

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,89 @@ const response = await fetch('http://localhost:3000/api/v1/auth/send-verificatio
270270
const response = await fetch(`http://localhost:3000/api/v1/auth/verify-email?token=${token}`);
271271
```
272272

273+
### Phone-Number Authentication (SMS OTP)
274+
275+
Phone numbers are a first-class sign-in identifier (better-auth `phoneNumber`
276+
plugin). Opt in per deployment:
277+
278+
```bash
279+
OS_AUTH_PHONE_NUMBER_ENABLED=true # or auth.plugins.phoneNumber in config
280+
```
281+
282+
The plugin adds `sys_user.phone_number` (unique) and `phone_number_verified`.
283+
Phone-only employees are created by the admin create-user / import routes with
284+
an undeliverable placeholder email (`u-<random>@placeholder.invalid`) — never
285+
by OTP self-signup.
286+
287+
#### Phone + password sign-in
288+
289+
Always available once the plugin is on:
290+
291+
```typescript
292+
const response = await fetch('http://localhost:3000/api/v1/auth/sign-in/phone-number', {
293+
method: 'POST',
294+
headers: { 'Content-Type': 'application/json' },
295+
body: JSON.stringify({ phoneNumber: '+8613800000000', password: 'password123' })
296+
});
297+
```
298+
299+
#### OTP sign-in and self-service reset (requires SMS delivery)
300+
301+
The OTP surface opens only when a **deliverable SMS service** is configured
302+
(`services.sms`, Setup → Settings → SMS Delivery); without one these endpoints
303+
fail loudly with `NOT_SUPPORTED`. The public config advertises real
304+
availability as `features.phoneNumberOtp`, which is what the Console login UI
305+
gates its "Sign in with verification code" mode and the SMS reset branch on.
306+
307+
```typescript
308+
// Sign-in / verification: request a code, then verify (creates a session)
309+
await fetch('/api/v1/auth/phone-number/send-otp', {
310+
method: 'POST',
311+
headers: { 'Content-Type': 'application/json' },
312+
body: JSON.stringify({ phoneNumber: '+8613800000000' })
313+
});
314+
await fetch('/api/v1/auth/phone-number/verify', {
315+
method: 'POST',
316+
headers: { 'Content-Type': 'application/json' },
317+
body: JSON.stringify({ phoneNumber: '+8613800000000', code: '123456' })
318+
});
319+
320+
// Self-service password reset over SMS
321+
await fetch('/api/v1/auth/phone-number/request-password-reset', {
322+
method: 'POST',
323+
headers: { 'Content-Type': 'application/json' },
324+
body: JSON.stringify({ phoneNumber: '+8613800000000' })
325+
});
326+
await fetch('/api/v1/auth/phone-number/reset-password', {
327+
method: 'POST',
328+
headers: { 'Content-Type': 'application/json' },
329+
body: JSON.stringify({ phoneNumber: '+8613800000000', otp: '123456', newPassword: 'NewPass123!' })
330+
});
331+
```
332+
333+
Abuse hardening ships with the feature (SMS is a paid channel):
334+
335+
- **Per-number admission guard** (always on): 60s cooldown + 5 sends per
336+
rolling hour per number, shared across the sign-in and reset flows and —
337+
in a cluster — across nodes via the shared KV. Rejected requests get an
338+
honest `429` with the retry window and never invalidate an already
339+
delivered code. Tune via `AuthManager` `phoneOtp`
340+
(`cooldownSeconds` / `maxPerHour` / `allowedAttempts` / `expiresIn` /
341+
`otpLength`).
342+
- **Wrong-code attempts** are capped at 3 (`allowedAttempts`), then the code
343+
is invalidated.
344+
- **Per-IP rate limiting**: the plugin ships a `/phone-number/*` default
345+
(10/min), and the `auth` settings `rate_limit_max` /
346+
`rate_limit_window_seconds` tighten all four OTP endpoints.
347+
- **OTP codes never reach logs** — the SMS service logs masked numbers and
348+
statuses only; `request-password-reset` always answers `{status:true}` so
349+
it cannot be used as an account-existence oracle.
350+
351+
The identity import endpoint's `invite` policy also supports phone-only rows
352+
when SMS is deliverable: the created account receives a **credential-free
353+
invitation SMS** and the employee completes first sign-in via phone OTP, then
354+
sets their own password.
355+
273356
---
274357

275358
## OAuth Providers

0 commit comments

Comments
 (0)