From 40edb765b0437a0cdcc8e03369d2ae42290e5869 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 01:24:47 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_013LXUXU66dBaP3SSG4ZVtuH --- .changeset/docs-sms-phone-otp.md | 6 ++ .../docs/kernel/runtime-services/index.mdx | 1 + .../docs/kernel/runtime-services/meta.json | 1 + .../kernel/runtime-services/sms-service.mdx | 70 ++++++++++++++++ content/docs/permissions/authentication.mdx | 83 +++++++++++++++++++ 5 files changed, 161 insertions(+) create mode 100644 .changeset/docs-sms-phone-otp.md create mode 100644 content/docs/kernel/runtime-services/sms-service.mdx diff --git a/.changeset/docs-sms-phone-otp.md b/.changeset/docs-sms-phone-otp.md new file mode 100644 index 0000000000..5f69ef4633 --- /dev/null +++ b/.changeset/docs-sms-phone-otp.md @@ -0,0 +1,6 @@ +--- +--- + +Docs-only: `services.sms` runtime-service reference + phone-number +authentication (SMS OTP) section in the authentication guide. No package +changes. diff --git a/content/docs/kernel/runtime-services/index.mdx b/content/docs/kernel/runtime-services/index.mdx index 25e06e50e2..335304802d 100644 --- a/content/docs/kernel/runtime-services/index.mdx +++ b/content/docs/kernel/runtime-services/index.mdx @@ -39,6 +39,7 @@ Each page links the canonical TypeScript source used to derive signatures. - Sharing: `packages/spec/src/contracts/sharing-service.ts` - Queue: `packages/spec/src/contracts/queue-service.ts` - Email: `packages/spec/src/contracts/email-service.ts` +- SMS: `packages/spec/src/contracts/sms-service.ts` - Storage: `packages/spec/src/contracts/storage-service.ts` - Settings: `packages/services/service-settings/src/settings-service.ts` - Audit bridge: `packages/services/service-settings/src/settings-service.types.ts` diff --git a/content/docs/kernel/runtime-services/meta.json b/content/docs/kernel/runtime-services/meta.json index ddff2f6049..e6f85138cc 100644 --- a/content/docs/kernel/runtime-services/meta.json +++ b/content/docs/kernel/runtime-services/meta.json @@ -7,6 +7,7 @@ "audit-service", "queue-service", "email-service", + "sms-service", "settings-service", "storage-service", "examples", diff --git a/content/docs/kernel/runtime-services/sms-service.mdx b/content/docs/kernel/runtime-services/sms-service.mdx new file mode 100644 index 0000000000..9a294c507e --- /dev/null +++ b/content/docs/kernel/runtime-services/sms-service.mdx @@ -0,0 +1,70 @@ +--- +title: services.sms +description: Outbound SMS delivery through pluggable providers (Aliyun SMS, Twilio). +--- + +# `services.sms` + +- **Stability:** `stable` +- **Canonical source:** `packages/spec/src/contracts/sms-service.ts` + +## Methods + +```ts +services.sms.send(input: SendSmsInput): Promise +services.sms.isConfigured(): boolean +``` + +`SendSmsInput` carries the recipient (`to`, E.164 recommended), the rendered +`body`, and optional provider-template fields (`templateId`, +`templateParams`) for template-only providers such as Aliyun SMS — which +refuse free-form bodies and only deliver pre-registered templates. + +`isConfigured()` is `false` while the service runs on the development +`LogSmsTransport` fallback (messages are logged, never delivered). Consumers +that advertise SMS-dependent features — phone OTP sign-in, SMS invitations — +gate on it in production (`features.phoneNumberOtp`). + +## Returns + +`SendSmsResult`: + +- `id: string` — correlation id for the attempt +- `status: 'sent' | 'failed'` +- `messageId?: string` — provider message id (Aliyun BizId, Twilio SID) +- `error?: string` — transport detail when `status='failed'` + +## No persistence, no body logging + +Unlike `services.email` (which persists to `sys_email`), the SMS service +deliberately has **no persistence surface and never logs message bodies**: +SMS bodies routinely carry one-time passwords, so a message log would be a +credential store. Diagnostics log masked recipients and statuses only. + +## Configuration + +Providers are configured through the `sms` settings namespace +(Setup → Settings → SMS Delivery), with live rebinding — no restart needed: + +- `provider` — `log` (default, no real delivery) / `aliyun` / `twilio` +- Aliyun: `aliyun_access_key_id`, `aliyun_access_key_secret` (encrypted), + `aliyun_sign_name`, `aliyun_template_code` (default catch-all template with + a single `${content}` variable for generic notification SMS) +- Twilio: `twilio_account_sid`, `twilio_auth_token` (encrypted), and + `twilio_from_number` or `twilio_messaging_service_sid` + +Every key accepts the standard settings env override (`OS_SMS_PROVIDER`, +`OS_SMS_ALIYUN_ACCESS_KEY_ID`, …). The **Send test SMS** action exercises the +live (or unsaved) provider configuration. + +## Consumers + +- **Phone-number OTP auth** — sign-in verification codes and self-service + password-reset codes (`/phone-number/*`, see + [Authentication](/docs/permissions/authentication)). +- **Messaging `sms` channel** — `notify(channels:['sms'])` resolves the + recipient's `sys_user.phone_number`, renders the `(topic, 'sms', locale)` + row from `sys_notification_template`, and delivers through this service + with outbox retry/dead-letter. +- **Identity import SMS invitations** — the `invite` password policy sends a + credential-free invitation SMS to phone-only rows. diff --git a/content/docs/permissions/authentication.mdx b/content/docs/permissions/authentication.mdx index 5248236f42..ffcc037e1e 100644 --- a/content/docs/permissions/authentication.mdx +++ b/content/docs/permissions/authentication.mdx @@ -270,6 +270,89 @@ const response = await fetch('http://localhost:3000/api/v1/auth/send-verificatio const response = await fetch(`http://localhost:3000/api/v1/auth/verify-email?token=${token}`); ``` +### Phone-Number Authentication (SMS OTP) + +Phone numbers are a first-class sign-in identifier (better-auth `phoneNumber` +plugin). Opt in per deployment: + +```bash +OS_AUTH_PHONE_NUMBER_ENABLED=true # or auth.plugins.phoneNumber in config +``` + +The plugin adds `sys_user.phone_number` (unique) and `phone_number_verified`. +Phone-only employees are created by the admin create-user / import routes with +an undeliverable placeholder email (`u-@placeholder.invalid`) — never +by OTP self-signup. + +#### Phone + password sign-in + +Always available once the plugin is on: + +```typescript +const response = await fetch('http://localhost:3000/api/v1/auth/sign-in/phone-number', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ phoneNumber: '+8613800000000', password: 'password123' }) +}); +``` + +#### 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 +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. + +```typescript +// Sign-in / verification: request a code, then verify (creates a session) +await fetch('/api/v1/auth/phone-number/send-otp', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ phoneNumber: '+8613800000000' }) +}); +await fetch('/api/v1/auth/phone-number/verify', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ phoneNumber: '+8613800000000', code: '123456' }) +}); + +// Self-service password reset over SMS +await fetch('/api/v1/auth/phone-number/request-password-reset', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ phoneNumber: '+8613800000000' }) +}); +await fetch('/api/v1/auth/phone-number/reset-password', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ phoneNumber: '+8613800000000', otp: '123456', newPassword: 'NewPass123!' }) +}); +``` + +Abuse hardening ships with the feature (SMS is a paid channel): + +- **Per-number admission guard** (always on): 60s cooldown + 5 sends per + rolling hour per number, shared across the sign-in and reset flows and — + in a cluster — across nodes via the shared KV. Rejected requests get an + honest `429` with the retry window and never invalidate an already + delivered code. Tune via `AuthManager` `phoneOtp` + (`cooldownSeconds` / `maxPerHour` / `allowedAttempts` / `expiresIn` / + `otpLength`). +- **Wrong-code attempts** are capped at 3 (`allowedAttempts`), then the code + is invalidated. +- **Per-IP rate limiting**: the plugin ships a `/phone-number/*` default + (10/min), and the `auth` settings `rate_limit_max` / + `rate_limit_window_seconds` tighten all four OTP endpoints. +- **OTP codes never reach logs** — the SMS service logs masked numbers and + statuses only; `request-password-reset` always answers `{status:true}` so + it cannot be used as an account-existence oracle. + +The identity import endpoint's `invite` policy also supports phone-only rows +when SMS is deliverable: the created account receives a **credential-free +invitation SMS** and the employee completes first sign-in via phone OTP, then +sets their own password. + --- ## OAuth Providers