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
1 change: 1 addition & 0 deletions .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"@objectstack/plugin-reports",
"@objectstack/plugin-security",
"@objectstack/plugin-sharing",
"@objectstack/plugin-sms",
"@objectstack/plugin-webhooks",
"@objectstack/trigger-record-change",
"@objectstack/trigger-api",
Expand Down
38 changes: 38 additions & 0 deletions .changeset/sms-infrastructure-phone-otp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
"@objectstack/plugin-sms": minor
"@objectstack/plugin-auth": minor
"@objectstack/service-messaging": minor
"@objectstack/service-settings": minor
"@objectstack/spec": minor
"@objectstack/cli": minor
---

feat(messaging/auth): SMS infrastructure + phone-number OTP first-login/reset (#2780)

#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
(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
action; `OS_SMS_*` env keys win at the resolver). Deliberately NO message
persistence and NO body logging — SMS bodies carry OTP codes.
- **Messaging `sms` channel** — registered at kernel:ready when an `sms`
service is present; `notify(channels:['sms'])` resolves
`sys_user.phone_number`, renders `(topic,'sms',locale)` templates, and
inherits outbox retry/dead-letter.
- **Phone OTP flows open** — the phoneNumber plugin's `sendOTP` /
`sendPasswordResetOTP` now deliver via SMS, enabling
`/phone-number/send-otp` + `/verify` (OTP sign-in/verification) and
`/phone-number/request-password-reset` + `/reset-password` (self-service
reset). Without a deliverable SMS service they keep failing loudly
(NOT_SUPPORTED); `features.phoneNumberOtp` advertises real availability.
Shipped with the abuse hardening: explicit `allowedAttempts: 3`, always-on
per-number cooldown (60s) + rolling-hour cap (5, secondaryStorage-shared
across nodes), `/phone-number/*` in the settings-bound per-IP rate-limit
rules, and OTP codes never reach logs or error messages.
- **Import SMS invites** — `/admin/import-users`'s `invite` policy now
supports phone-only rows: a credential-free invitation SMS points the
employee at phone-OTP first sign-in followed by self-set password; mixed
files validate the reachable channel per row.
11 changes: 9 additions & 2 deletions content/docs/plugins/packages.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ description: Complete guide to all ObjectStack packages, services, drivers, plug

# Package Overview

ObjectStack is organized into **70 package manifests** across multiple categories. This guide provides an overview of the framework packages, services, drivers, plugins, and adapters in the [framework repository](https://github.com/objectstack-ai/framework/tree/main/packages).
ObjectStack is organized into **71 package manifests** across multiple categories. This guide provides an overview of the framework packages, services, drivers, plugins, and adapters in the [framework repository](https://github.com/objectstack-ai/framework/tree/main/packages).

### Package categories at a glance

Expand All @@ -15,7 +15,7 @@ ObjectStack is organized into **70 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** | 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`) |
| **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` |


Expand Down Expand Up @@ -363,6 +363,13 @@ 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
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
"@objectstack/plugin-reports": "workspace:*",
"@objectstack/plugin-security": "workspace:*",
"@objectstack/plugin-sharing": "workspace:*",
"@objectstack/plugin-sms": "workspace:*",
"@objectstack/plugin-webhooks": "workspace:*",
"@objectstack/rest": "workspace:*",
"@objectstack/runtime": "workspace:^",
Expand Down
26 changes: 25 additions & 1 deletion packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,9 @@ export default class Serve extends Command {
* mirror this list on their per-project kernels.
*/
static readonly ALWAYS_ON_CAPABILITIES: readonly string[] = Object.freeze([
'queue', 'job', 'cache', 'settings', 'email', 'storage', 'sharing', 'messaging',
// The first six form the pinned foundational prefix (see
// serve-defaults.test.ts) — grow the slate AFTER them.
'queue', 'job', 'cache', 'settings', 'email', 'storage', 'sms', 'sharing', 'messaging',
// `analytics` is foundational post-ADR-0021: the AnalyticsService backs the
// dataset/cube query endpoints (`/api/v1/analytics/*`). It must exist even
// when an app declares no `analyticsCubes`, because a `dataset` can be
Expand Down Expand Up @@ -1659,6 +1661,15 @@ export default class Serve extends Command {
export: 'EmailServicePlugin',
nameMatch: ['plugin-email', 'EmailServicePlugin'],
},
sms: {
// #2780 — backs phone-number OTP sign-in/reset (plugin-auth) and
// 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',
export: 'SmsServicePlugin',
nameMatch: ['plugin-sms', 'SmsServicePlugin'],
},
sharing: {
pkg: '@objectstack/plugin-sharing',
export: 'SharingServicePlugin',
Expand Down Expand Up @@ -1758,6 +1769,19 @@ export default class Serve extends Command {
));
arg.provider = 'log';
}
} else if (cap === 'sms') {
// Compose SmsServicePlugin options from config.sms + OS_SMS_* env
// (#2780). Same precedence as email: env beats config. Provider
// credentials normally live in the `sms` settings namespace
// (bound at kernel:ready); constructor opts cover pre-settings
// boot and hosts without the settings service.
const cfgSms = (config as any).sms ?? {};
const provider = (process.env.OS_SMS_PROVIDER || cfgSms.provider || 'log').toLowerCase();
arg = {
provider,
...(cfgSms.providerOptions ? { providerOptions: cfgSms.providerOptions } : {}),
...(cfgSms.retries != null ? { retries: cfgSms.retries } : {}),
};
} else if (cap === 'storage') {
// Storage is now in the default capability slate. If the host
// hasn't configured a backend explicitly we fall back to the
Expand Down
85 changes: 84 additions & 1 deletion packages/plugins/plugin-auth/src/admin-import-users.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ function makeDeps(opts: {
existingUsers?: Array<Record<string, any>>;
phoneEnabled?: boolean;
emailAvailable?: boolean;
smsInviteAvailable?: boolean;
resetFails?: boolean;
smsFails?: boolean;
} = {}) {
const existing = opts.existingUsers ?? [];
let nextId = 1;
Expand All @@ -37,15 +39,20 @@ function makeDeps(opts: {
const insert = vi.fn(async () => ({}));
const warn = vi.fn();
const noteMustChangePasswordIssued = vi.fn();
const sendInviteSms = vi.fn(async () => {
if (opts.smsFails) throw new Error('sms provider down');
});
const deps: IdentityImportDeps = {
getAuthApi: async () => ({ createUser, requestPasswordReset }),
getDataEngine: () => ({ find, update, insert }),
phoneNumberEnabled: () => opts.phoneEnabled ?? false,
emailServiceAvailable: () => opts.emailAvailable ?? true,
smsInviteAvailable: () => opts.smsInviteAvailable ?? false,
sendInviteSms,
noteMustChangePasswordIssued,
logger: { warn },
};
return { deps, createUser, requestPasswordReset, find, update, insert, warn, noteMustChangePasswordIssued };
return { deps, createUser, requestPasswordReset, sendInviteSms, find, update, insert, warn, noteMustChangePasswordIssued };
}

/** Red line: no generated password may reach any persistence/log surface. */
Expand Down Expand Up @@ -255,6 +262,82 @@ describe('runAdminImportUsers — invite policy', () => {
expect(row.code).toBe('INVITE_EMAIL_FAILED');
expect((res.body.data as any).summary.created).toBe(1);
});

// #2780 — SMS invite variant for phone-only rows.
it('sends an SMS invite (not a reset email) to phone-only rows when SMS is available', async () => {
const m = makeDeps({ phoneEnabled: true, smsInviteAvailable: true });
const res = await runAdminImportUsers(
m.deps,
makeRequest({
passwordPolicy: 'invite', format: 'json',
rows: [
{ email: 'a@x.co', name: 'Mail' },
{ phone_number: '+86 138 0000 0002', name: 'PhoneOnly' },
],
}),
ACTOR,
);
const data = res.body.data as any;
expect(data.summary.created).toBe(2);
// Email row → reset email; phone-only row → invitation SMS to the
// NORMALIZED number, never a reset email to the placeholder address.
expect(m.requestPasswordReset).toHaveBeenCalledTimes(1);
expect(m.requestPasswordReset.mock.calls[0][0].body.email).toBe('a@x.co');
expect(m.sendInviteSms).toHaveBeenCalledTimes(1);
expect(m.sendInviteSms.mock.calls[0][0]).toBe('+8613800000002');
// The phone-only account got a placeholder email that never leaks the phone.
const phoneCreate = m.createUser.mock.calls.find((c) => c[0].body?.data?.phoneNumber);
expect(phoneCreate![0].body.email).toMatch(/@placeholder\.invalid$/);
// No temp passwords under invite.
expect(data.rows.every((r: any) => r.temporaryPassword === undefined)).toBe(true);
});

it('keeps the row created (with INVITE_SMS_FAILED) when the SMS fails — no rollback', async () => {
const m = makeDeps({ phoneEnabled: true, smsInviteAvailable: true, smsFails: true });
const res = await runAdminImportUsers(
m.deps,
makeRequest({ passwordPolicy: 'invite', format: 'json', rows: [{ phone_number: '+8613800000003' }] }),
ACTOR,
);
const row = (res.body.data as any).rows[0];
expect(row.ok).toBe(true);
expect(row.action).toBe('created');
expect(row.code).toBe('INVITE_SMS_FAILED');
expect((res.body.data as any).summary.created).toBe(1);
});

it('with SMS but no email service: phone-only rows invite, email rows fail per-row', async () => {
const m = makeDeps({ phoneEnabled: true, emailAvailable: false, smsInviteAvailable: true });
const res = await runAdminImportUsers(
m.deps,
makeRequest({
passwordPolicy: 'invite', format: 'json',
rows: [
{ email: 'a@x.co' },
{ phone_number: '+8613800000004' },
],
}),
ACTOR,
);
expect(res.status).toBe(200); // not rejected outright — one channel works
const rows = (res.body.data as any).rows;
expect(rows[0].code).toBe('EMAIL_SERVICE_REQUIRED');
expect(rows[1].action).toBe('created');
expect(m.sendInviteSms).toHaveBeenCalledTimes(1);
expect(m.requestPasswordReset).not.toHaveBeenCalled();
});

it('still rejects invite outright when NEITHER email nor SMS is wired', async () => {
const m = makeDeps({ phoneEnabled: true, emailAvailable: false, smsInviteAvailable: false });
const res = await runAdminImportUsers(
m.deps,
makeRequest({ passwordPolicy: 'invite', format: 'json', rows: [{ phone_number: '+8613800000005' }] }),
ACTOR,
);
expect(res.status).toBe(400);
expect(res.body.error?.code).toBe('EMAIL_SERVICE_REQUIRED');
expect(m.createUser).not.toHaveBeenCalled();
});
});

describe('runAdminImportUsers — upsert', () => {
Expand Down
Loading