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
2 changes: 1 addition & 1 deletion .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
"@objectstack/plugin-reports",
"@objectstack/plugin-security",
"@objectstack/plugin-sharing",
"@objectstack/plugin-sms",
"@objectstack/service-sms",
"@objectstack/plugin-webhooks",
"@objectstack/trigger-record-change",
"@objectstack/trigger-api",
Expand Down
15 changes: 15 additions & 0 deletions .changeset/rename-plugin-sms-to-service-sms.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@objectstack/service-sms": patch
"@objectstack/cli": patch
---

refactor(sms): rename `@objectstack/plugin-sms` to `@objectstack/service-sms`

Infrastructure services follow the `service-*` convention
(`service-messaging`, `service-settings`, …) — the `plugin-*` prefix was a
misfit for a package whose whole job is registering the `sms` kernel
service (`plugin-email` is legacy debt, not precedent). Same exports, same
`SmsServicePlugin` class, same `sms` service id and settings namespace —
only the package name and its home (`packages/services/service-sms`)
change. The one published `@objectstack/plugin-sms@14.3.0` release should
be npm-deprecated in favour of `@objectstack/service-sms`.
18 changes: 9 additions & 9 deletions content/docs/plugins/packages.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ ObjectStack is organized into **71 package manifests** across multiple categorie
| **Client / DX** | 5 | `client`, `client-react`, `cli`, `create-objectstack`, `vscode-objectstack` |
| **Framework adapters** | 1 | `hono` (other frameworks: build a thin adapter on `HttpDispatcher` — see below) |
| **Drivers** | 4 | `driver-memory`, `driver-sql`, `driver-sqlite-wasm`, `driver-mongodb` |
| **Plugins** | 19 | `plugin-auth`, `plugin-security`, `plugin-org-scoping`, `plugin-audit`, `plugin-approvals`, `plugin-sharing`, `plugin-email`, `plugin-sms`, `plugin-webhooks`, `plugin-reports`, `plugin-hono-server`, `plugin-dev`, `mcp`, trigger plugins (`trigger-api`, `trigger-record-change`, `trigger-schedule`), and knowledge/embedder plugins (`knowledge-memory`, `knowledge-ragflow`, `embedder-openai`) |
| **Platform services** | 15 | `service-analytics`, `service-automation`, `service-cache`, `service-cluster`, `service-cluster-redis`, `service-datasource`, `service-i18n`, `service-job`, `service-knowledge`, `service-messaging`, `service-package`, `service-queue`, `service-realtime`, `service-settings`, `service-storage` |
| **Plugins** | 18 | `plugin-auth`, `plugin-security`, `plugin-org-scoping`, `plugin-audit`, `plugin-approvals`, `plugin-sharing`, `plugin-email`, `plugin-webhooks`, `plugin-reports`, `plugin-hono-server`, `plugin-dev`, `mcp`, trigger plugins (`trigger-api`, `trigger-record-change`, `trigger-schedule`), and knowledge/embedder plugins (`knowledge-memory`, `knowledge-ragflow`, `embedder-openai`) |
| **Platform services** | 16 | `service-analytics`, `service-automation`, `service-cache`, `service-cluster`, `service-cluster-redis`, `service-datasource`, `service-i18n`, `service-job`, `service-knowledge`, `service-messaging`, `service-package`, `service-queue`, `service-realtime`, `service-settings`, `service-sms`, `service-storage` |


## Core Packages
Expand Down Expand Up @@ -226,6 +226,13 @@ All services implement contracts from `@objectstack/spec/contracts` and are kern
- **Features**: `MessagingChannel` registry, `emit()` fan-out, always-on inbox channel; email/webhook/push/IM channels plug in
- **When to use**: Notifying users across channels from flows, hooks, and plugins

### @objectstack/service-sms

**SMS Service** — Outbound SMS delivery (`sms` service).

- **Features**: Provider adapters (Aliyun SMS, Twilio), `sms` settings namespace with live rebind, backs phone-number OTP sign-in/reset and the messaging `sms` channel
- **When to use**: Phone OTP first-login / self-service reset, SMS notifications

### @objectstack/service-i18n

**I18n Service** — Internationalization with file-based locales.
Expand Down Expand Up @@ -363,13 +370,6 @@ All services implement contracts from `@objectstack/spec/contracts` and are kern
- **Features**: Provider adapters, MJML templates, delivery tracking
- **When to use**: Transactional and workflow-driven email

### @objectstack/plugin-sms

**SMS Plugin** — Outbound SMS delivery (`sms` service).

- **Features**: Provider adapters (Aliyun SMS, Twilio), `sms` settings namespace with live rebind, backs phone-number OTP sign-in/reset and the messaging `sms` channel
- **When to use**: Phone OTP first-login / self-service reset, SMS notifications

### @objectstack/plugin-webhooks

**Webhooks Plugin** — Outbound HTTP webhook delivery.
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@
"@objectstack/plugin-reports": "workspace:*",
"@objectstack/plugin-security": "workspace:*",
"@objectstack/plugin-sharing": "workspace:*",
"@objectstack/plugin-sms": "workspace:*",
"@objectstack/service-sms": "workspace:*",
"@objectstack/plugin-webhooks": "workspace:*",
"@objectstack/rest": "workspace:*",
"@objectstack/runtime": "workspace:^",
Expand Down
10 changes: 8 additions & 2 deletions packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1259,6 +1259,12 @@ export default class Serve extends Command {
// operator override (env wins in buildPluginList()).
passwordRejectBreached:
String(process.env.OS_AUTH_PASSWORD_REJECT_BREACHED ?? 'false').toLowerCase() === 'true',
// #2766/#2780 — phone-number sign-in (phone+password always;
// OTP sign-in/reset once the sms capability has a deliverable
// provider). Opt-in: without this env the config flag had no
// `objectstack serve` switch at all.
phoneNumber:
String(process.env.OS_AUTH_PHONE_NUMBER_ENABLED ?? 'false').toLowerCase() === 'true',
},
advanced: process.env.OS_COOKIE_DOMAIN
? ({
Expand Down Expand Up @@ -1666,9 +1672,9 @@ export default class Serve extends Command {
// the messaging `sms` channel. Provider config lives in the `sms`
// settings namespace (OS_SMS_* env keys win at the resolver);
// unconfigured ⇒ dev LogSmsTransport (no real send).
pkg: '@objectstack/plugin-sms',
pkg: '@objectstack/service-sms',
export: 'SmsServicePlugin',
nameMatch: ['plugin-sms', 'SmsServicePlugin'],
nameMatch: ['service-sms', 'SmsServicePlugin'],
},
sharing: {
pkg: '@objectstack/plugin-sharing',
Expand Down
49 changes: 30 additions & 19 deletions packages/plugins/plugin-auth/src/auth-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1143,29 +1143,42 @@ describe('AuthManager', () => {
expect(sms.sent[0].templateParams).toEqual({ code: '123456' });
});

it('enforces the per-number cooldown (TOO_MANY_REQUESTS, no second SMS)', async () => {
it('enforces the per-number cooldown at ADMISSION (before-hook), not in sendOTP', async () => {
const { manager, opts } = await bootOtp();
const sms = fakeSms();
manager.setSmsService(sms.service);

await opts.sendOTP({ phoneNumber: PHONE, code: '111111' });
await expect(opts.sendOTP({ phoneNumber: PHONE, code: '222222' }))
// Admission guard: first request per number passes, immediate second 429s.
await manager.assertPhoneOtpSendAllowed(PHONE);
await expect(manager.assertPhoneOtpSendAllowed(PHONE))
.rejects.toThrow(/Too many verification codes/);
expect(sms.sent).toHaveLength(1);

// The sendOTP callback itself must NOT re-guard: better-auth stores the
// fresh code BEFORE invoking it, so a rejection at that point would
// still rotate (void) the previously delivered code. Delivery always
// proceeds once a request was admitted.
await opts.sendOTP({ phoneNumber: PHONE, code: '111111' });
await opts.sendOTP({ phoneNumber: PHONE, code: '222222' });
expect(sms.sent).toHaveLength(2);
});

it('sendPasswordResetOTP shares the same guarded SMS path (cross-flow budget)', async () => {
const { manager, opts } = await bootOtp();
const sms = fakeSms();
manager.setSmsService(sms.service);
it('the admission budget spans both flows (send-otp + request-password-reset)', async () => {
const { manager } = await bootOtp();
manager.setSmsService(fakeSms().service);

await opts.sendPasswordResetOTP({ phoneNumber: PHONE, code: '333333' });
expect(sms.sent[0].body).toContain('333333');
// The cooldown spans both flows — a reset send blocks an immediate sign-in send.
await expect(opts.sendOTP({ phoneNumber: PHONE, code: '444444' }))
// One admitted send (whatever the flow) blocks an immediate second one.
await manager.assertPhoneOtpSendAllowed(PHONE);
await expect(manager.assertPhoneOtpSendAllowed(PHONE))
.rejects.toThrow(/Too many verification codes/);
});

it('admission is a no-op while OTP is undeliverable (sendOTP fails loudly instead)', async () => {
const { manager } = await bootOtp();
// No SMS service wired — the guard must not consume budget or throw.
await manager.assertPhoneOtpSendAllowed(PHONE);
await manager.assertPhoneOtpSendAllowed(PHONE);
});

it('surfaces a failed SMS delivery WITHOUT the code in the error', async () => {
const { manager, opts } = await bootOtp();
const sms = fakeSms({ failed: true });
Expand All @@ -1175,13 +1188,11 @@ describe('AuthManager', () => {
.rejects.toSatisfy((e: Error) => /provider down/.test(e.message) && !e.message.includes('555555'));
});

it('honours phoneOtp knobs (cooldown off ⇒ back-to-back sends allowed)', async () => {
const { manager, opts } = await bootOtp({ phoneOtp: { cooldownSeconds: 0, maxPerHour: 0 } });
const sms = fakeSms();
manager.setSmsService(sms.service);
await opts.sendOTP({ phoneNumber: PHONE, code: '111111' });
await opts.sendOTP({ phoneNumber: PHONE, code: '222222' });
expect(sms.sent).toHaveLength(2);
it('honours phoneOtp knobs (cooldown off ⇒ back-to-back admissions allowed)', async () => {
const { manager } = await bootOtp({ phoneOtp: { cooldownSeconds: 0, maxPerHour: 0 } });
manager.setSmsService(fakeSms().service);
await manager.assertPhoneOtpSendAllowed(PHONE);
await manager.assertPhoneOtpSendAllowed(PHONE);
});

it('features.phoneNumberOtp requires plugin + deliverable SMS', async () => {
Expand Down
57 changes: 44 additions & 13 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,7 @@ export interface AuthManagerOptions extends Partial<AuthConfig> {
*
* Resolved lazily through {@link AuthManager.getSmsService}; safe to set
* after construction. AuthPlugin wires this from the kernel service
* registry (`sms`, see `@objectstack/plugin-sms`) on `kernel:ready`.
* registry (`sms`, see `@objectstack/service-sms`) on `kernel:ready`.
*/
smsService?: ISmsService;

Expand Down Expand Up @@ -818,6 +818,23 @@ export class AuthManager {
// sees `userCount > 0` and the toggle is enforced again.
hooks: {
before: createAuthMiddleware(async (ctx: any) => {
// ── #2780: per-number OTP send guard (admission control) ─────
// MUST run BEFORE the phone-number endpoints: better-auth's
// send-otp handler stores a fresh code and only THEN invokes
// `sendOTP` — a guard that throws inside the callback would
// still rotate (invalidate) the previously delivered code, so a
// blocked resend (or an attacker spamming the endpoint) could
// keep voiding the user's valid OTP. Rejecting here leaves the
// stored code untouched. Applies uniformly to registered and
// unregistered numbers (no account-existence oracle).
if (
ctx?.path === '/phone-number/send-otp' ||
ctx?.path === '/phone-number/request-password-reset'
) {
const phone = typeof ctx?.body?.phoneNumber === 'string' ? ctx.body.phoneNumber : '';
await this.assertPhoneOtpSendAllowed(phone);
}

// ── ADR-0069 D1: password complexity (validator) ────────────
// better-auth enforces only min/max length; class-mix is custom.
// Runs on the password-mutating endpoints; reads the candidate from
Expand Down Expand Up @@ -2057,7 +2074,7 @@ export class AuthManager {
* OTP callbacks and the SMS invite path (#2780). Mirrors
* {@link setEmailService}: resolved lazily per send, so it is safe to set
* after construction — AuthPlugin wires it on `kernel:ready` once
* `ctx.getService('sms')` (plugin-sms) resolves.
* `ctx.getService('sms')` (service-sms) resolves.
*/
setSmsService(sms: ISmsService | undefined): void {
this.config.smsService = sms;
Expand All @@ -2084,15 +2101,36 @@ export class AuthManager {
return this._otpSendGuard;
}

/**
* #2780 — admission check for the OTP send endpoints, called from the
* `hooks.before` middleware (see the `/phone-number/*` branch there for
* 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.
*/
async assertPhoneOtpSendAllowed(phone: string): Promise<void> {
if (!phone || !this.isPhoneOtpDeliverable()) return;
const decision = await this.getOtpSendGuard().checkAndRecord(phone);
if (!decision.ok) {
const { APIError } = await import('better-auth/api');
throw new APIError('TOO_MANY_REQUESTS', {
message: `Too many verification codes requested for this phone number. Retry in ${decision.retryAfterSeconds ?? 60}s.`,
});
}
}

/**
* #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).
* - Per-number cooldown + hourly cap BEFORE the provider is called
* (SMS-pumping cost abuse); violations throw TOO_MANY_REQUESTS, which
* /phone-number/send-otp surfaces as an honest 429 while the
* request-password-reset route logs-and-200s (no enumeration oracle).
* - 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
* rotate (invalidate) the previously delivered code — letting a
* blocked resend or an endpoint-spamming attacker void a user's valid
* OTP. See the `/phone-number/*` branch in `hooks.before`.
* - The code is embedded in the message body ONLY — it must never reach
* a log line or an error message (the SmsService logs masked numbers
* and statuses, never bodies).
Expand All @@ -2108,13 +2146,6 @@ export class AuthManager {
'Phone sign-in is password-based (POST /sign-in/phone-number).',
);
}
const decision = await this.getOtpSendGuard().checkAndRecord(phone);
if (!decision.ok) {
const { APIError } = await import('better-auth/api');
throw new APIError('TOO_MANY_REQUESTS', {
message: `Too many verification codes requested for this phone number. Retry in ${decision.retryAfterSeconds ?? 60}s.`,
});
}
const otpCfg = this.config.phoneOtp ?? {};
const minutes = Math.max(1, Math.round((otpCfg.expiresIn ?? 300) / 60));
const result = await sms.send({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ export class MessagingServicePlugin implements Plugin {
});

// SMS channel (#2780): same pattern as email — register when an
// `sms` service (plugin-sms) is present at kernel:ready; absent
// `sms` service (service-sms) is present at kernel:ready; absent
// sms ⇒ no channel, so a notify(channels:['sms']) reports "not
// registered" rather than silently no-opping.
const getSms = () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/services/service-messaging/src/sms-channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
import { USER_OBJECT } from './email-channel.js';

/**
* Structural view of the SMS service (`@objectstack/plugin-sms`'s
* Structural view of the SMS service (`@objectstack/service-sms`'s
* `SmsService`), declared locally so service-messaging takes no runtime
* dependency on it — the channel resolves whatever is registered under the
* `sms` service and sends through this shape (mirrors `EmailSenderSurface`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,6 @@ export const smsTestActionHandler: SettingsActionHandler = async ({ values }) =>
return {
ok: true,
severity: 'info',
message: `Configuration looks valid (provider=${provider}). Wire @objectstack/plugin-sms for actual delivery.`,
message: `Configuration looks valid (provider=${provider}). Wire @objectstack/service-sms for actual delivery.`,
};
};
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# @objectstack/plugin-sms
# @objectstack/service-sms

## 14.3.0

Expand All @@ -9,7 +9,7 @@
#2766 shipped phone+password sign-in but no OTP — the platform had no SMS
delivery capability. This adds the missing infrastructure end to end:

- **New `@objectstack/plugin-sms`** — `ISmsService`/`ISmsTransport` contracts
- **New `@objectstack/service-sms`** — `ISmsService`/`ISmsTransport` contracts
(spec) with Aliyun SMS (ACS3-HMAC-SHA256, template-based) and Twilio
transports plus a dev log fallback. Configured through the new `sms`
settings namespace (live provider rebind, encrypted secrets, send-test
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"name": "@objectstack/plugin-sms",
"name": "@objectstack/service-sms",
"version": "14.3.0",
"license": "Apache-2.0",
"description": "SMS service plugin for ObjectStack — ISmsService + transport-pluggable outbound delivery (Aliyun / Twilio / log).",
"description": "SMS service for ObjectStack — ISmsService + transport-pluggable outbound delivery (Aliyun / Twilio / log).",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
Expand Down
4 changes: 2 additions & 2 deletions packages/spec/src/contracts/sms-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* ISmsService — Outbound SMS Service Contract (#2780)
*
* Sends short text messages through a pluggable transport (Aliyun SMS,
* Twilio, etc.). Concrete implementations live in `@objectstack/plugin-sms`;
* Twilio, etc.). Concrete implementations live in `@objectstack/service-sms`;
* provider integrations plug in as an `ISmsTransport`.
*
* Mirrors the shape of `IEmailService`/`IEmailTransport` (email-service.ts)
Expand Down Expand Up @@ -71,7 +71,7 @@ export interface SmsTransportSendResult {
}

/**
* Pluggable SMS transport. plugin-sms ships a `LogSmsTransport` for
* Pluggable SMS transport. service-sms ships a `LogSmsTransport` for
* development; production deployments configure a concrete provider
* (Aliyun / Twilio) or inject their own implementation of this shape.
*
Expand Down
2 changes: 1 addition & 1 deletion packages/spec/src/system/auth-config.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ export const AuthPluginConfigSchema = lazySchema(() => z.object({
* (`/phone-number/send-otp` + `/verify` for sign-in/verification,
* `/phone-number/request-password-reset` + `/reset-password` for
* self-service reset) additionally require a configured SMS delivery
* service (`@objectstack/plugin-sms`, #2780) and stay loudly NOT_SUPPORTED
* service (`@objectstack/service-sms`, #2780) and stay loudly NOT_SUPPORTED
* without one. Employees without an email address are created with a
* generated placeholder address (never a real recipient — see
* placeholder-email.ts) and sign in with phone + password or phone OTP.
Expand Down
Loading