From 6b7d3f0200385db2d23f5cc2b31b7101b6efe87a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 04:33:58 +0000 Subject: [PATCH 1/2] fix(cli,service-sms)!: refuse an unknown OS_SMS_PROVIDER at boot instead of silently degrading to LogSmsTransport (#5713) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `os serve` read `OS_SMS_PROVIDER` / `config.sms.provider` while assembling the kernel and handed the string to `SmsServicePlugin` with nothing to compare it against. The plugin caught the resulting `makeSmsTransport: unknown provider` throw and substituted `LogSmsTransport`, so a typo booted a server whose `send()` answers `status: 'sent'` and delivers nothing. This path never reaches `SettingsService`, which is why the `sms` namespace's `select` options table, its write-path enforcement (#5131) and the env-override gate (#5204) could none of them see it. `resolveSmsCapabilityArg` is extracted (mirroring `resolveEmailCapabilityArg`) and throws on a tag outside the vocabulary; the capability loop turns that into a hard boot error for a declared `requires: ['sms']`, else a loud console.error. Credentials are deliberately NOT demanded here — the settings namespace binds them at kernel:ready. `@objectstack/service-sms` exports `SMS_TRANSPORT_PROVIDERS` / `isSmsTransportProvider`, with `SmsProviderTag` derived from the array, so the CLI reads the vocabulary rather than restating it (#5094). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DWUR56YsttL5sTF72Q75TQ --- .../cli-sms-provider-unknown-tag-throws.md | 74 ++++++++++ .../src/commands/serve-sms-capability.test.ts | 131 ++++++++++++++++++ packages/cli/src/commands/serve.ts | 85 +++++++++++- packages/services/service-sms/src/index.ts | 2 + .../service-sms/src/transports/index.ts | 32 ++++- .../src/transports/transports.test.ts | 34 ++++- 6 files changed, 349 insertions(+), 9 deletions(-) create mode 100644 .changeset/cli-sms-provider-unknown-tag-throws.md create mode 100644 packages/cli/src/commands/serve-sms-capability.test.ts diff --git a/.changeset/cli-sms-provider-unknown-tag-throws.md b/.changeset/cli-sms-provider-unknown-tag-throws.md new file mode 100644 index 0000000000..a58deab8f9 --- /dev/null +++ b/.changeset/cli-sms-provider-unknown-tag-throws.md @@ -0,0 +1,74 @@ +--- +"@objectstack/service-sms": minor +"@objectstack/cli": major +--- + +fix(cli,service-sms)!: `OS_SMS_PROVIDER=twilo` now fails the boot instead of silently becoming the log transport (#5713) + +**BREAKING for one configuration: a provider tag no SMS transport can build.** +`os serve` used to hand `OS_SMS_PROVIDER` (or `config.sms.provider`) straight to +`SmsServicePlugin` with nothing to compare it against. The plugin then caught the +`makeSmsTransport: unknown provider 'twilo'` throw, substituted `LogSmsTransport`, +and booted normally — measured, not inferred: + +``` +new SmsServicePlugin({ provider: 'twilo' }).init(ctx) + booted_without_throw: true transport_class: 'LogSmsTransport' + isConfigured(): false logger.warn × 1, logger.error × 0 + service.send(…) → { status: 'sent', messageId: 'dev-sms-…' } +``` + +So a phone-OTP sign-in answered "code sent", the user waited for an SMS that was +never dispatched, and the one `warn` line scrolled past in the boot log. That is +the declared-but-not-delivered shape of Prime Directive #10, and the same one +#5132 closed for **mail** in the neighbouring arm of the very same capability +loop. + +Three gates already guard the `sms` provider value and none of them could see +this path: the `sms` settings namespace declares `provider` as a `select` with an +options table, #5131 enforces that table on the write path, and #5204 closed the +`SettingsService` env-override branch. All three live behind `SettingsService` — +this read happens while the kernel is being assembled, *before* a settings +service exists. + +**`resolveSmsCapabilityArg` now refuses a provider tag outside +`log` / `aliyun` / `twilio`**, the way its neighbouring `resolveEmailCapabilityArg` +already did, and the capability loop turns that into the loud failure it should +be — a hard boot error when the app declared `requires: ['sms']`, otherwise a +`console.error` and no SMS service. + +**What it deliberately does NOT do:** demand credentials. Unlike mail, SMS +provider credentials are not a boot-time input — the `sms` settings namespace +binds them at `kernel:ready`, and that is their documented home. A bare +`OS_SMS_PROVIDER=twilio` on a host whose Twilio keys live in Settings is a +complete configuration and passes through untouched. `SmsServicePlugin`'s own +fallback is likewise untouched: for a *known* provider with incomplete +constructor credentials it is correct (the settings bind can still swap in a +working transport), and it remains the last line of defence for hosts that +construct the plugin themselves. `os serve` simply stops feeding it input it can +never use. + +**Who is affected:** deployments that set `OS_SMS_PROVIDER` (or +`config.sms.provider`) to a value outside the supported three — in practice a +typo, or a provider that was never implemented — and relied on the fallback to +boot. An unset `OS_SMS_PROVIDER` still defaults to `log`; every supported tag +still boots with or without credentials. + +**Migration — one line, either direction:** + +- the environment is *not* meant to send SMS → `OS_SMS_PROVIDER=log` (that + explicit value is the supported way to say so, and why refusing the others is + fair); +- the environment *is* meant to send SMS → fix the tag to `aliyun` or `twilio` + and put the credentials in Settings → SMS Delivery (or + `config.sms.providerOptions`). + +The error names the consequence and both fixes, per AGENTS.md's +degradation-log-level rule. + +`@objectstack/service-sms` gains the vocabulary the CLI reads instead of +restating: `SMS_TRANSPORT_PROVIDERS` and `isSmsTransportProvider()`, with +`SmsProviderTag` now derived from the array rather than declared beside it. One +vocabulary, two consumers — a second literal list in the CLI is how the mail +settings dropdown and the mail transports drifted apart in the first place +(#5094). diff --git a/packages/cli/src/commands/serve-sms-capability.test.ts b/packages/cli/src/commands/serve-sms-capability.test.ts new file mode 100644 index 0000000000..221cd287ca --- /dev/null +++ b/packages/cli/src/commands/serve-sms-capability.test.ts @@ -0,0 +1,131 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * framework#5713 — what `SmsServicePlugin` is constructed with on the + * `os serve` path, and what happens when the provider tag cannot deliver. + * + * The `sms` settings namespace declares `provider` as a `select` with an + * options table (`log` / `aliyun` / `twilio`), #5131 enforces that table on the + * write path, and #5204 closed the `SettingsService` env-override branch that + * bypassed it. None of those three gates can see this path: `os serve` reads + * `OS_SMS_PROVIDER` while assembling the kernel, *before* a settings service + * exists, and handed the string straight to the plugin. + * + * Measured on `origin/main` before this change, `provider: 'twilo'` (a plausible + * misspelling of `twilio`) reached `SmsServicePlugin.init`, threw inside + * `makeSmsTransport`, was caught, and became `LogSmsTransport`: + * + * booted_without_throw: true transport_class: 'LogSmsTransport' + * isConfigured(): false send() → { status: 'sent', messageId: 'dev-sms-…' } + * + * — a server that answers every OTP send "sent" and delivers nothing. That is + * the declared-but-not-delivered shape of Prime Directive #10, and the same one + * #5132 closed for mail in the neighbouring arm of this very loop. + * + * These pin the invariant in one piece: a configuration this server can deliver + * through reaches the plugin unchanged (credentials included — they legitimately + * arrive later, from the settings namespace at `kernel:ready`), and a provider + * tag it cannot deliver through throws. The counterpart the throw depends on is + * pinned too: an operator who does not want SMS sent says so with + * `OS_SMS_PROVIDER=log`, and that still boots. + */ + +import { describe, it, expect } from 'vitest'; +import { SMS_TRANSPORT_PROVIDERS } from '@objectstack/service-sms'; +import { resolveSmsCapabilityArg } from './serve.js'; + +describe('resolveSmsCapabilityArg', () => { + it('defaults to the log provider when nothing is configured', () => { + const { options } = resolveSmsCapabilityArg({}, {}); + expect(options).toMatchObject({ provider: 'log' }); + expect(options).not.toHaveProperty('providerOptions'); + expect(options).not.toHaveProperty('retries'); + }); + + it('boots on an EXPLICIT provider=log — the way to say "this environment does not send SMS"', () => { + // The premise of every throw below: refusing an undeliverable provider is + // only fair because "no SMS from here" has its own spelling. If this ever + // stops booting, the errors elsewhere in this file stop being actionable. + expect(() => resolveSmsCapabilityArg({}, { OS_SMS_PROVIDER: 'log' })).not.toThrow(); + expect(resolveSmsCapabilityArg({}, { OS_SMS_PROVIDER: 'log' }).options) + .toMatchObject({ provider: 'log' }); + // …including from objectstack.config.ts. + expect(resolveSmsCapabilityArg({ provider: 'log' }, {}).options).toMatchObject({ provider: 'log' }); + }); + + it('lets env beat config, and normalizes the case', () => { + expect(resolveSmsCapabilityArg({ provider: 'aliyun' }, { OS_SMS_PROVIDER: 'twilio' }).options) + .toMatchObject({ provider: 'twilio' }); + // `OS_SMS_PROVIDER=Twilio` is the same declaration — the guard runs on the + // lower-cased value, never on the raw env string. + expect(resolveSmsCapabilityArg({}, { OS_SMS_PROVIDER: 'Twilio' }).options) + .toMatchObject({ provider: 'twilio' }); + expect(resolveSmsCapabilityArg({ provider: 'ALIYUN' }, {}).options) + .toMatchObject({ provider: 'aliyun' }); + }); + + it('passes a deliverable provider through WITHOUT demanding credentials', () => { + // Unlike mail, SMS credentials are not a boot-time input: the `sms` + // settings namespace binds them at kernel:ready. A bare provider tag is a + // complete configuration here, so this arm refuses the tag and nothing + // else — demanding keys would break every host that stores them in + // Settings, which is the documented home for them. + for (const provider of SMS_TRANSPORT_PROVIDERS) { + expect(() => resolveSmsCapabilityArg({}, { OS_SMS_PROVIDER: provider }), provider).not.toThrow(); + expect(resolveSmsCapabilityArg({}, { OS_SMS_PROVIDER: provider }).options, provider) + .toMatchObject({ provider }); + } + }); + + it('carries config.sms.providerOptions and retries through untouched', () => { + const { options } = resolveSmsCapabilityArg( + { provider: 'aliyun', providerOptions: { accessKeyId: 'ak', signName: '签名' }, retries: 2 }, + {}, + ); + expect(options).toMatchObject({ + provider: 'aliyun', + providerOptions: { accessKeyId: 'ak', signName: '签名' }, + retries: 2, + }); + // `retries: 0` is a real declaration (no retry), not an absence. + expect(resolveSmsCapabilityArg({ retries: 0 }, {}).options).toMatchObject({ retries: 0 }); + }); + + it('THROWS on a provider tag no transport can deliver — no silent LogSmsTransport (#5713)', () => { + const boot = () => resolveSmsCapabilityArg({}, { OS_SMS_PROVIDER: 'twilo' }); + expect(boot).toThrow(/provider='twilo'/); + // Consequence AND fix in the one message (AGENTS.md degradation-log-level). + expect(boot).toThrow(/nothing would leave the box/); + expect(boot).toThrow(/log \/ aliyun \/ twilio/); + expect(boot).toThrow(/OS_SMS_PROVIDER=log/); + // …and never the old silent rewrite. + expect(boot).not.toThrow(/falling back to LogSmsTransport/); + }); + + it('refuses the same tag declared through config.sms.provider', () => { + // A typo in objectstack.config.ts is the same declaration by another + // channel — and the one an operator cannot fix with an env var. + expect(() => resolveSmsCapabilityArg({ provider: 'aliyn' }, {})) + .toThrow(/provider='aliyn'/); + // Retired-looking and never-supported tags land in the same arm, and the + // message names the vocabulary rather than guessing at an intent. + for (const tag of ['sendgrid', 'aws-sns', 'tencent', 'smtp']) { + expect(() => resolveSmsCapabilityArg({}, { OS_SMS_PROVIDER: tag }), tag) + .toThrow(/log \/ aliyun \/ twilio/); + } + }); + + it('reads its vocabulary from @objectstack/service-sms, not a second literal', () => { + // #5094's lesson, pinned: if a transport is added to service-sms and this + // file kept its own list, the new provider would be refused at boot while + // the plugin could build it. The error message enumerates the exported + // vocabulary, so this assertion goes red the day the two diverge. + let message = ''; + try { + resolveSmsCapabilityArg({}, { OS_SMS_PROVIDER: 'definitely-not-a-provider' }); + } catch (err) { + message = (err as Error).message; + } + expect(message).toContain(SMS_TRANSPORT_PROVIDERS.join(' / ')); + }); +}); diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index f2742e2e7e..ea4bb0b044 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -28,6 +28,13 @@ import { missingProviderMessage } from '../utils/capability-preflight.js'; // only (no plugin class): `os serve` loads `EmailServicePlugin` itself through // the capability loop's dynamic import, host copy first. import { isEmailTransportProvider, emailProviderRequiresApiKey, unsupportedProviderFix } from '@objectstack/plugin-email'; +// The SMS provider vocabulary, read from the package that materialises the +// transports, for the same reason and by the same rule as the mail one above +// (#5713). `resolveSmsCapabilityArg` has to refuse exactly the tags +// `makeSmsTransport` cannot build — restating `log`/`aliyun`/`twilio` here would +// be the second literal #5094 was filed for. Values only (no plugin class): the +// capability loop dynamic-imports `SmsServicePlugin` itself, host copy first. +import { isSmsTransportProvider, SMS_TRANSPORT_PROVIDERS } from '@objectstack/service-sms'; import { resolveObjectStackHome } from '@objectstack/runtime'; import { LOG_LEVELS, resolveLogLevel, readLogLevelEnv } from '../utils/log-level.js'; import { BootLogCapture, isVerboseBootLevel } from '../utils/boot-log-capture.js'; @@ -2342,13 +2349,12 @@ export default class Serve extends Command { // 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 } : {}), - }; + // + // Throws on a provider tag no transport can deliver (#5713) — the + // catch below turns that into the boot failure / loud error it + // should be, never a LogSmsTransport substituted behind the + // operator's back. Same shape as the `email` arm above. + arg = resolveSmsCapabilityArg((config as any).sms ?? {}, process.env).options; } 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 @@ -3231,6 +3237,71 @@ export function resolveEmailCapabilityArg( return { options }; } +/** Constructor options for `SmsServicePlugin`, as the capability loop builds them. */ +export interface SmsCapabilityArg { + options: Record; +} + +/** + * Resolve `SmsServicePlugin` constructor options from `config.sms` + `OS_SMS_*` + * env, and **refuse a provider tag no transport can deliver through** (#5713). + * + * The refusal is the point. Credentials for a real provider normally arrive from + * the `sms` settings namespace at `kernel:ready`, so this function deliberately + * does NOT demand them — a bare `OS_SMS_PROVIDER=twilio` on a host whose Twilio + * keys are stored in Settings is a complete, working configuration and passes + * through untouched. What it refuses is the one thing settings can never repair: + * a provider *tag* outside `SMS_TRANSPORT_PROVIDERS`. + * + * That tag used to travel all the way into the plugin, which caught the + * `makeSmsTransport: unknown provider 'twilo'` throw and substituted + * `LogSmsTransport` behind the operator's back. Measured on `origin/main` before + * this change, `new SmsServicePlugin({ provider: 'twilo' }).init(ctx)`: + * + * - boots without throwing, registers the `sms` service; + * - transport = `LogSmsTransport`, `isConfigured() === false`; + * - one `logger.warn` line, then `send()` answers + * `{ status: 'sent', messageId: 'dev-sms-…' }`. + * + * So a phone-OTP sign-in tells the user "code sent" and nothing leaves the box — + * the same declared-but-not-delivered shape #5132 closed for mail one layer up, + * and the same door #5204 closed on the `SettingsService` env branch. This path + * never reaches `SettingsService`: it runs at kernel-assembly time, before the + * settings service exists, which is exactly why the `sms` namespace's `select` + * options table (`sms.manifest.ts`) could not see it. + * + * The plugin's fallback is left alone on purpose. For a *known* provider with + * incomplete constructor credentials it is correct — the settings bind can still + * swap in a working transport — and it stays the last line of defence for hosts + * that construct `SmsServicePlugin` themselves. `os serve` simply stops handing + * it input it cannot use. + * + * `OS_SMS_PROVIDER=log` (the default) is how an environment says "this box does + * not send SMS", which is what makes refusing the rest fair. + */ +export function resolveSmsCapabilityArg( + cfgSms: Record = {}, + env: NodeJS.ProcessEnv = process.env, +): SmsCapabilityArg { + const provider = String(env.OS_SMS_PROVIDER || cfgSms.provider || 'log').toLowerCase(); + if (!isSmsTransportProvider(provider)) { + throw new Error( + `provider='${provider}' is not a transport this server can deliver through, so every OTP and ` + + "notification SMS would be answered status: 'sent' and nothing would leave the box — " + + `pick one of ${SMS_TRANSPORT_PROVIDERS.join(' / ')} (Settings → SMS Delivery → Provider). ` + + 'On this boot path the provider is OS_SMS_PROVIDER or config.sms.provider; set ' + + 'OS_SMS_PROVIDER=log if this environment is not meant to send SMS.', + ); + } + return { + options: { + provider, + ...(cfgSms.providerOptions ? { providerOptions: cfgSms.providerOptions } : {}), + ...(cfgSms.retries != null ? { retries: cfgSms.retries } : {}), + }, + }; +} + /** * Best-effort driver introspection. * diff --git a/packages/services/service-sms/src/index.ts b/packages/services/service-sms/src/index.ts index c1ab678c6a..23c3abc4c8 100644 --- a/packages/services/service-sms/src/index.ts +++ b/packages/services/service-sms/src/index.ts @@ -10,6 +10,8 @@ export { export { SmsServicePlugin, type SmsServicePluginOptions } from './sms-plugin.js'; export { makeSmsTransport, + SMS_TRANSPORT_PROVIDERS, + isSmsTransportProvider, AliyunSmsTransport, TwilioSmsTransport, type SmsProviderTag, diff --git a/packages/services/service-sms/src/transports/index.ts b/packages/services/service-sms/src/transports/index.ts index 844a52d26e..607aa14db8 100644 --- a/packages/services/service-sms/src/transports/index.ts +++ b/packages/services/service-sms/src/transports/index.ts @@ -8,7 +8,37 @@ import { TwilioSmsTransport } from './twilio.js'; export { AliyunSmsTransport, type AliyunSmsTransportOptions } from './aliyun.js'; export { TwilioSmsTransport, type TwilioSmsTransportOptions } from './twilio.js'; -export type SmsProviderTag = 'log' | 'aliyun' | 'twilio'; +/** + * The provider vocabulary — every tag `makeSmsTransport` below can build, and + * nothing else. It is the **one** literal: the `SmsProviderTag` type is derived + * from it, the `switch` is exhaustive over it, and callers that have to judge an + * operator-supplied provider string (the CLI's `sms` capability arm, #5713) read + * it from here rather than restating the list. + * + * Two literals describing one vocabulary is how the mail settings dropdown and + * the mail transports drifted apart (#5094) — `sendgrid`/`ses` were offered with + * no transport behind them while `resend` had a working transport nobody could + * pick. The SMS boot path had the same shape from the other side: `os serve` + * passed `OS_SMS_PROVIDER` straight into `SmsServicePlugin` with nothing to + * compare it against, so a typo (`twilo`) reached `makeSmsTransport`, threw + * there, was caught, and became `LogSmsTransport` — a server that answers every + * OTP send `status: 'sent'` and delivers nothing. + */ +export const SMS_TRANSPORT_PROVIDERS = ['log', 'aliyun', 'twilio'] as const; + +/** A provider tag `makeSmsTransport` can materialise a transport for. */ +export type SmsProviderTag = (typeof SMS_TRANSPORT_PROVIDERS)[number]; + +/** + * Narrow an unknown value to a buildable provider tag. The counterpart of + * `isEmailTransportProvider` in `@objectstack/plugin-email`, and used by the CLI + * for the same reason: a provider that cannot deliver must be refused where the + * operator declared it, not silently downgraded where it is materialised. + */ +export function isSmsTransportProvider(value: unknown): value is SmsProviderTag { + return typeof value === 'string' + && (SMS_TRANSPORT_PROVIDERS as readonly string[]).includes(value); +} export interface MakeSmsTransportOptions { provider: SmsProviderTag; diff --git a/packages/services/service-sms/src/transports/transports.test.ts b/packages/services/service-sms/src/transports/transports.test.ts index d1346a8d69..e67f50c8db 100644 --- a/packages/services/service-sms/src/transports/transports.test.ts +++ b/packages/services/service-sms/src/transports/transports.test.ts @@ -3,7 +3,7 @@ import { describe, it, expect, vi } from 'vitest'; import { AliyunSmsTransport } from './aliyun.js'; import { TwilioSmsTransport } from './twilio.js'; -import { makeSmsTransport } from './index.js'; +import { makeSmsTransport, SMS_TRANSPORT_PROVIDERS, isSmsTransportProvider } from './index.js'; import { LogSmsTransport } from '../sms-service.js'; const jsonResponse = (body: any, status = 200) => @@ -112,3 +112,35 @@ describe('makeSmsTransport', () => { expect(() => makeSmsTransport({ provider: 'nope' as any })).toThrow(/unknown provider/); }); }); + +// #5713 — the vocabulary the CLI's `sms` capability arm judges OS_SMS_PROVIDER +// against. It has to be *this* list and not a copy of it: two literals for one +// vocabulary is how the mail dropdown and the mail transports drifted (#5094). +describe('SMS_TRANSPORT_PROVIDERS / isSmsTransportProvider', () => { + it('names exactly the tags makeSmsTransport can build', () => { + expect([...SMS_TRANSPORT_PROVIDERS]).toEqual(['log', 'aliyun', 'twilio']); + // Every member builds (given credentials) — none is an aspiration. + const credentials: Record> = { + log: {}, + aliyun: { accessKeyId: 'a', accessKeySecret: 'b', signName: 'c' }, + twilio: { accountSid: 'a', authToken: 'b', from: '+1' }, + }; + for (const provider of SMS_TRANSPORT_PROVIDERS) { + expect(() => makeSmsTransport({ provider, options: credentials[provider] }), provider).not.toThrow(); + } + }); + + it('narrows a provider string, rejecting the typo that used to reach makeSmsTransport', () => { + for (const provider of SMS_TRANSPORT_PROVIDERS) expect(isSmsTransportProvider(provider)).toBe(true); + // `twilo` is the specimen from #5713: a plausible misspelling of a real + // provider, which used to pass the boot path untouched, throw inside + // makeSmsTransport, get caught, and become LogSmsTransport. + expect(isSmsTransportProvider('twilo')).toBe(false); + expect(() => makeSmsTransport({ provider: 'twilo' as any })).toThrow(/unknown provider 'twilo'/); + // Non-strings and near-misses are rejected too — the guard is what stands + // between an operator's env value and an `as SmsProviderTag` cast. + for (const bad of ['', 'LOG', 'sms', undefined, null, 42, {}]) { + expect(isSmsTransportProvider(bad), String(bad)).toBe(false); + } + }); +}); From 2d9c0027a10b98eb5f369d5072750b3ad1fd7cb1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 04:46:57 +0000 Subject: [PATCH 2/2] =?UTF-8?q?docs(sms-service):=20=E8=AF=B4=E6=98=8E=20O?= =?UTF-8?q?S=5FSMS=5FPROVIDER=20=E6=9C=89=E4=B8=A4=E4=B8=AA=E8=AF=BB?= =?UTF-8?q?=E5=8F=96=E6=97=B6=E5=88=BB,=E5=90=AF=E5=8A=A8=E6=9C=9F?= =?UTF-8?q?=E9=82=A3=E6=AC=A1=E7=8E=B0=E5=9C=A8=E4=BC=9A=E6=8B=92=E7=BB=9D?= =?UTF-8?q?=E8=A1=A8=E5=A4=96=E5=8F=96=E5=80=BC=20(#5713)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 该页此前只把 OS_SMS_PROVIDER 描述成 settings 的 env 覆盖,读者无从得知它还会在 `os serve` 组装 kernel 时(settings 服务尚不存在)被读一次 —— 而新的启动期拒绝正 发生在那一次。补上两个读取时刻的区分、被检查的只有 provider tag(凭据仍由该命名空间 在 kernel:ready 提供)、以及 OS_SMS_PROVIDER=log 这个明确的退出方式。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DWUR56YsttL5sTF72Q75TQ --- .../docs/kernel/runtime-services/sms-service.mdx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/content/docs/kernel/runtime-services/sms-service.mdx b/content/docs/kernel/runtime-services/sms-service.mdx index 9a294c507e..8992e07fc0 100644 --- a/content/docs/kernel/runtime-services/sms-service.mdx +++ b/content/docs/kernel/runtime-services/sms-service.mdx @@ -57,6 +57,19 @@ 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. +`OS_SMS_PROVIDER` (and `config.sms.provider`) is read twice, at two different +moments: once by `os serve` while it assembles the kernel — before any settings +service exists — to pick the plugin's initial transport, and again by the +settings namespace once that binds at `kernel:ready`. **A provider value outside +`log` / `aliyun` / `twilio` is refused at the first of those** (#5713): the boot +fails when the app declares `requires: ['sms']`, and otherwise logs an error and +starts without an SMS service. It used to become the `log` transport silently, so +a typo like `OS_SMS_PROVIDER=twilo` produced a server that answered every send +`status: 'sent'` and delivered nothing. Credentials are *not* required at boot — +only the provider tag is checked, because the credentials legitimately arrive +later, from this namespace. An environment that is not meant to send SMS says so +with `OS_SMS_PROVIDER=log`, which is the default. + ## Consumers - **Phone-number OTP auth** — sign-in verification codes and self-service