Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/docs-sms-phone-otp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
---

Docs-only: `services.sms` runtime-service reference + phone-number
authentication (SMS OTP) section in the authentication guide. No package
changes.
1 change: 1 addition & 0 deletions content/docs/kernel/runtime-services/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`
1 change: 1 addition & 0 deletions content/docs/kernel/runtime-services/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"audit-service",
"queue-service",
"email-service",
"sms-service",
"settings-service",
"storage-service",
"examples",
Expand Down
70 changes: 70 additions & 0 deletions content/docs/kernel/runtime-services/sms-service.mdx
Original file line number Diff line number Diff line change
@@ -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<SendSmsResult>
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.
83 changes: 83 additions & 0 deletions content/docs/permissions/authentication.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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-<random>@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
Expand Down