From 25d303bd65c126c7f29dd12a74cdef5c6a822b78 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 04:26:25 +0000 Subject: [PATCH 1/5] wip(spec): the stack definition admits appName and the non-secret email / sms members Claude-Session: https://claude.ai/code/session_01S3aAf11JjbW1mSGL1EhfFj Co-authored-by: Claude --- ...erve-stack-email-sms-door.contract.test.ts | 133 ++++++++++ .../option-b-reader-acceptance.pin.test.ts | 7 +- .../spec/src/assembled-package-body.test.ts | 3 + packages/spec/src/stack-email-sms.test.ts | 235 ++++++++++++++++++ packages/spec/src/stack.zod.ts | 50 ++++ packages/spec/src/system/email-config.zod.ts | 121 ++++++++- packages/spec/src/system/index.ts | 1 + packages/spec/src/system/sms-config.zod.ts | 106 ++++++++ 8 files changed, 650 insertions(+), 6 deletions(-) create mode 100644 packages/cli/src/commands/serve-stack-email-sms-door.contract.test.ts create mode 100644 packages/spec/src/stack-email-sms.test.ts create mode 100644 packages/spec/src/system/sms-config.zod.ts diff --git a/packages/cli/src/commands/serve-stack-email-sms-door.contract.test.ts b/packages/cli/src/commands/serve-stack-email-sms-door.contract.test.ts new file mode 100644 index 00000000000..851a304ac57 --- /dev/null +++ b/packages/cli/src/commands/serve-stack-email-sms-door.contract.test.ts @@ -0,0 +1,133 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// A `defineStack`-built `email` / `sms` / `appName` reaches the providers +// `objectstack serve` constructs — and the SMS provider vocabulary the stack +// declares is the one the transports can build. +// +// The stack definition used to refuse all three keys while the mail and SMS +// readers read them, so the configuration arm of both readers was reachable +// only from a config `defineStack` did not build. The stack now admits their +// non-secret members (credentials stay environment-only); this file proves the +// admitted shape is the one the readers read, by driving the call `serve` makes +// for every capability provider — `resolveCapabilityArgument` +// (`@objectstack/core`) with the provider module — on a stack `defineStack` +// returned. That `serve` makes exactly that call with `stack: config` and +// `env: process.env`, and builds AuthPlugin's name from +// `resolveDeploymentAppName(config.email, process.env, config.appName)`, is +// pinned by the source scan in `serve-auth-app-name.contract.test.ts`; the two +// files together are the reading from authored stack to constructed provider. +// +// The second half is a CROSS-PACKAGE vocabulary assertion, for the reason +// `@objectstack/plugin-email`'s `spec-provider-parity.contract.test.ts` gives +// for mail: `SmsProviderSchema` (the authoring contract) and +// `SMS_TRANSPORT_PROVIDERS` (the array `makeSmsTransport` switches on) are two +// literals describing one vocabulary, and two literals left alone drift. It +// lives here because this package depends on both `@objectstack/spec` and +// `@objectstack/service-sms`; the SMS package's own tests read no spec value. +// This package's `tsconfig.json` includes `src`, tests and all, so the +// compile-time half below is real. + +import { describe, it, expect } from 'vitest'; +import { resolveCapabilityArgument } from '@objectstack/core'; +import { defineStack } from '@objectstack/spec'; +import { SmsProviderSchema, type SmsProvider } from '@objectstack/spec/system'; +import * as pluginEmail from '@objectstack/plugin-email'; +import * as serviceSms from '@objectstack/service-sms'; +import { SMS_TRANSPORT_PROVIDERS, type SmsProviderTag } from '@objectstack/service-sms'; + +const manifest = { + id: 'com.example.mailer', + name: 'mailer', + version: '1.0.0', + type: 'app' as const, + namespace: 'mailer', +}; + +/** The argument `serve`'s capability loop constructs the provider with, for this stack and environment. */ +function argumentFor(token: 'email' | 'sms', stack: unknown, env: NodeJS.ProcessEnv): Record { + return resolveCapabilityArgument(token, { + stack, + packageRoot: '/app', + providerModule: token === 'email' ? pluginEmail : serviceSms, + env, + }).argument as Record; +} + +describe('a defineStack-built email / sms / appName reaches the providers serve constructs', () => { + it('email { provider, defaultFrom, options } reaches the email provider, with the env-only password layered over', () => { + const stack = defineStack({ + manifest, + appName: 'Acme', + email: { + provider: 'smtp', + defaultFrom: { name: 'Acme', address: 'no-reply@acme.example' }, + persist: false, + options: { host: 'smtp.acme.example', user: 'mailer' }, + }, + }); + const argument = argumentFor('email', stack, { OS_EMAIL_SMTP_PASSWORD: 'from-the-environment' }); + + expect(argument.provider).toBe('smtp'); + expect(argument.defaultFrom).toEqual({ name: 'Acme', address: 'no-reply@acme.example' }); + expect(argument.persist).toBe(false); + // The file carries host and user; the credential the stack refuses arrives + // from the environment and composes with them into one SMTP connection. + expect(argument.providerOptions).toEqual({ host: 'smtp.acme.example', user: 'mailer', password: 'from-the-environment' }); + // The top-level appName is the reader's configured bottom rung. + expect(argument.defaultTemplateContext.appName).toBe('Acme'); + // …and AuthPlugin's name, by the call serve makes for it. + expect(pluginEmail.resolveDeploymentAppName(stack.email ?? {}, {}, stack.appName)).toBe('Acme'); + }); + + it('sms { provider, retries } reaches the SMS provider', () => { + const stack = defineStack({ manifest, sms: { provider: 'twilio', retries: 3 } }); + expect(argumentFor('sms', stack, {})).toEqual({ provider: 'twilio', retries: 3 }); + }); + + it('OS_EMAIL_* / OS_SMS_PROVIDER still override the stack per key', () => { + const stack = defineStack({ manifest, email: { provider: 'log', retries: 1 }, sms: { provider: 'log' } }); + const email = argumentFor('email', stack, { OS_EMAIL_PROVIDER: 'smtp', OS_EMAIL_SMTP_HOST: 'relay.example', OS_EMAIL_RETRIES: '4' }); + expect(email.provider).toBe('smtp'); + expect(email.retries).toBe(4); + expect(argumentFor('sms', stack, { OS_SMS_PROVIDER: 'aliyun' }).provider).toBe('aliyun'); + }); + + it('CONTROL — env-only configuration is unchanged: a stack with no block builds what the reader builds from no config', () => { + const stack = defineStack({ manifest }); + const env = { + OS_EMAIL_PROVIDER: 'resend', + OS_EMAIL_API_KEY: 're_from_env', + OS_EMAIL_FROM: 'Ops ', + OS_APP_NAME: 'Env Name', + OS_SMS_PROVIDER: 'aliyun', + }; + const email = argumentFor('email', stack, env); + expect(email).toEqual(pluginEmail.resolveEmailCapabilityArg({}, env).options); + expect(email.provider).toBe('resend'); + expect(email.apiKey).toBe('re_from_env'); + expect(email.defaultTemplateContext.appName).toBe('Env Name'); + expect(argumentFor('sms', stack, env)).toEqual(serviceSms.resolveSmsCapabilityArg({}, env).options); + expect(argumentFor('sms', stack, env)).toEqual({ provider: 'aliyun' }); + }); +}); + +/** + * Compile-time halves of the vocabulary invariant: each union must be + * assignable to the other, so a member added on one side only makes `true` + * unassignable — a type error in this package's `typecheck`. + */ +type SpecAssignableToTransport = SmsProvider extends SmsProviderTag ? true : never; +type TransportAssignableToSpec = SmsProviderTag extends SmsProvider ? true : never; +const MUTUALLY_ASSIGNABLE: [SpecAssignableToTransport, TransportAssignableToSpec] = [true, true]; + +describe('SmsProviderSchema ↔ SMS_TRANSPORT_PROVIDERS', () => { + it('declares exactly the providers the SMS transports can build, both directions', () => { + expect(MUTUALLY_ASSIGNABLE).toEqual([true, true]); + const declared = [...SmsProviderSchema.options].sort(); + const buildable = [...SMS_TRANSPORT_PROVIDERS].sort(); + expect(declared.filter((p) => !buildable.includes(p as SmsProviderTag))).toEqual([]); + expect(buildable.filter((p) => !declared.includes(p))).toEqual([]); + // Anti-vacuity: two empty lists would agree. + expect(buildable.length).toBeGreaterThan(1); + }); +}); diff --git a/packages/cli/test/option-b-reader-acceptance.pin.test.ts b/packages/cli/test/option-b-reader-acceptance.pin.test.ts index dba48087ded..42c59cad189 100644 --- a/packages/cli/test/option-b-reader-acceptance.pin.test.ts +++ b/packages/cli/test/option-b-reader-acceptance.pin.test.ts @@ -214,8 +214,11 @@ describe('#15004 — option-B acceptance pin: every subsystem must see its colle expect(ARTIFACT_ENVELOPE_KEYS).toEqual( // #17556 added `devHint` / `devLogins`: what the DEVELOPMENT BOOT BANNER // prints, read off the top level by the CLI and refused inside a package - // body — envelope keys by the same rule `devPlugins` is one. - ['api', 'devHint', 'devLogins', 'devPlugins', 'i18n', 'manifest', 'onEnable', 'packages', 'plugins', 'runtimeModule', 'server'], + // body — envelope keys by the same rule `devPlugins` is one. `appName` / + // `email` / `sms` are the deployment's product name and its mail and SMS + // configuration, read off the top level when the boot builds those + // providers. + ['api', 'appName', 'devHint', 'devLogins', 'devPlugins', 'email', 'i18n', 'manifest', 'onEnable', 'packages', 'plugins', 'runtimeModule', 'server', 'sms'], ); // Every key option B drops is a package-owned collection, and every key it diff --git a/packages/spec/src/assembled-package-body.test.ts b/packages/spec/src/assembled-package-body.test.ts index c1006365461..281cf6c3a8f 100644 --- a/packages/spec/src/assembled-package-body.test.ts +++ b/packages/spec/src/assembled-package-body.test.ts @@ -79,6 +79,9 @@ const ARTIFACT_ENVELOPE_KEYS = [ 'devPlugins', // the `os dev` load list — the same class as `plugins` (#15219 ruling A) 'devHint', // one sentence the development BOOT BANNER prints — a property of the boot, not of a package (#17556) 'devLogins', // the first-run credentials that banner prints — read off the top level, never out of a body (#17556) + 'appName', // the deployment's product name — read off the top level by the boot's mail/auth wiring + 'email', // the deployment's mail configuration — read off the top level when the boot builds the email provider + 'sms', // the deployment's SMS configuration — read off the top level when the boot builds the SMS provider ].sort(); const shapeKeys = (schema: unknown): string[] => diff --git a/packages/spec/src/stack-email-sms.test.ts b/packages/spec/src/stack-email-sms.test.ts new file mode 100644 index 00000000000..3829a9fc896 --- /dev/null +++ b/packages/spec/src/stack-email-sms.test.ts @@ -0,0 +1,235 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The stack definition's door for the mail and SMS configuration the boot + * reads: `appName`, `email` and `sms`. + * + * ## The accept-set delta this file measures + * + * Before this change all three keys were REFUSED at the top level (the door is + * strict), while `resolveEmailCapabilityArg` / `resolveSmsCapabilityArg` read + * `config.email`, `config.sms` and `config.appName` — so the configuration arm + * of both readers was reachable only from a config `defineStack` did not build. + * The delta is: the three keys are writable, carrying the NON-SECRET members the + * readers read, and the credentials are refused BY NAME with the environment + * variable that carries them. A stack definition is compiled into the + * published artifact, which is why the credentials stop here. + * + * ## Why the refusals assert the envelope and the issue, never `toThrow()` + * + * A bare throw assertion passes on any failure. Each refusal names the error + * `code` + `status` (ADR-0112), the Zod issue's path and key, and the env var + * the prescription carries — the env var is the contract here (it is what the + * author must do instead), so it is asserted beside the code, not instead of it. + */ + +import { describe, it, expect } from 'vitest'; + +import { + ObjectStackDefinitionSchema, + defineStack, + composeStacks, + COMPOSE_KEY_DISPOSITIONS, + STACK_DEFINITION_KEYS, +} from './stack.zod'; +import { EmailServiceConfigSchema, StackEmailConfigSchema } from './system/email-config.zod'; +import { SmsProviderSchema, StackSmsConfigSchema } from './system/sms-config.zod'; + +const manifest = { + id: 'com.example.mailer', + name: 'mailer', + version: '1.0.0', + type: 'app' as const, + namespace: 'mailer', +}; + +const parse = (raw: Record) => ObjectStackDefinitionSchema.safeParse(raw); + +/** The `unrecognized_keys` issue of a failed parse, with its path, keys and message — or undefined. */ +const unrecognized = (verdict: ReturnType) => { + if (verdict.success) return undefined; + const issue = verdict.error.issues.find((i) => i.code === 'unrecognized_keys'); + return issue + ? { path: issue.path.map(String), keys: (issue as unknown as { keys: string[] }).keys, message: issue.message } + : undefined; +}; + +/** What `defineStack` threw, or a sentinel when it did not throw. */ +const refusalOf = (raw: Record) => { + try { + defineStack(raw as Parameters[0]); + } catch (e) { + return e as { code?: string; status?: number; message: string; issues?: readonly { code: string; path: PropertyKey[]; keys?: string[] }[] }; + } + return { code: 'DID_NOT_THROW', status: 0, message: '', issues: [] }; +}; + +// ─── GREEN — the non-secret members are writable and survive the parse ─── + +describe('accept — appName, email and sms carry the non-secret configuration', () => { + it('a defineStack with email { provider, defaultFrom } builds and carries the block through', () => { + const stack = defineStack({ + manifest, + email: { provider: 'smtp', defaultFrom: { name: 'Acme', address: 'no-reply@acme.example' }, options: { host: 'smtp.acme.example' } }, + }); + expect(stack.email).toEqual({ + provider: 'smtp', + defaultFrom: { name: 'Acme', address: 'no-reply@acme.example' }, + options: { host: 'smtp.acme.example' }, + }); + }); + + it('carries every non-secret member the mail reader reads, and the top-level appName', () => { + const email = { + provider: 'postmark' as const, + defaultFrom: { address: 'ops@acme.example' }, + retries: 2, + persist: false, + queueDelivery: true, + appName: 'Acme CRM', + defaultTemplateContext: { supportEmail: 'help@acme.example' }, + options: { messageStream: 'broadcast', host: 'smtp.acme.example', port: 2525, secure: false, user: 'mailer' }, + }; + const stack = defineStack({ manifest, appName: 'Acme', email }); + expect(stack.appName).toBe('Acme'); + expect(stack.email).toEqual(email); + }); + + it('carries the SMS provider tag and retries', () => { + const stack = defineStack({ manifest, sms: { provider: 'twilio', retries: 1 } }); + expect(stack.sms).toEqual({ provider: 'twilio', retries: 1 }); + }); + + it('the email block is DERIVED from EmailServiceConfigSchema: every member except the credential apiKey', () => { + // A key added to the operator contract has to arrive on the stack, and a + // secret one has to be left out on purpose — this is that statement. + const operator = Object.keys(EmailServiceConfigSchema.shape).sort(); + const stack = Object.keys(StackEmailConfigSchema.shape).sort(); + expect(operator.filter((k) => !stack.includes(k))).toEqual(['apiKey']); + expect(stack.filter((k) => !operator.includes(k))).toEqual([]); + }); + + it('the three keys are declared top-level keys composed as single values', () => { + for (const key of ['appName', 'email', 'sms'] as const) { + expect(STACK_DEFINITION_KEYS).toContain(key); + expect(COMPOSE_KEY_DISPOSITIONS[key]).toBe('single'); + } + }); +}); + +// ─── RED — the credentials are refused BY NAME, with their env variable ─── + +describe('refuse — a credential on the stack names itself and its environment variable', () => { + it('email.apiKey is refused by name, prescribing OS_EMAIL_API_KEY', () => { + const raw = { manifest, email: { provider: 'resend', apiKey: 're_live_secret' } }; + const issue = unrecognized(parse(raw)); + expect(issue?.path).toEqual(['email']); + expect(issue?.keys).toEqual(['apiKey']); + expect(issue?.message).toContain('OS_EMAIL_API_KEY'); + + const err = refusalOf(raw); + expect(err.code).toBe('STACK_SCHEMA_INVALID'); + expect(err.status).toBe(422); + expect(err.message).toContain('`apiKey`'); + expect(err.message).toContain('OS_EMAIL_API_KEY'); + }); + + it('email.options.password is refused by name, prescribing OS_EMAIL_SMTP_PASSWORD', () => { + const raw = { manifest, email: { provider: 'smtp', options: { host: 'smtp.acme.example', user: 'u', password: 'p' } } }; + const issue = unrecognized(parse(raw)); + expect(issue?.path).toEqual(['email', 'options']); + expect(issue?.keys).toEqual(['password']); + expect(issue?.message).toContain('OS_EMAIL_SMTP_PASSWORD'); + + const err = refusalOf(raw); + expect(err.code).toBe('STACK_SCHEMA_INVALID'); + expect(err.status).toBe(422); + }); + + it('email.options.apiKey is refused too — makeTransport would spread it over the real key', () => { + const issue = unrecognized(parse({ manifest, email: { provider: 'postmark', options: { apiKey: 'pm' } } })); + expect(issue?.path).toEqual(['email', 'options']); + expect(issue?.keys).toEqual(['apiKey']); + expect(issue?.message).toContain('OS_EMAIL_API_KEY'); + }); + + it('sms.providerOptions is refused by name, prescribing the sms settings namespace and its OS_SMS_* overrides', () => { + const raw = { manifest, sms: { provider: 'twilio', providerOptions: { accountSid: 'AC1', authToken: 'secret', from: '+15005550006' } } }; + const issue = unrecognized(parse(raw)); + expect(issue?.path).toEqual(['sms']); + expect(issue?.keys).toEqual(['providerOptions']); + expect(issue?.message).toContain('OS_SMS_TWILIO_AUTH_TOKEN'); + expect(issue?.message).toContain('Settings'); + + const err = refusalOf(raw); + expect(err.code).toBe('STACK_SCHEMA_INVALID'); + expect(err.status).toBe(422); + }); + + it('an SMS credential written flat on the block gets the same prescription', () => { + const issue = unrecognized(parse({ manifest, sms: { provider: 'twilio', authToken: 'secret' } })); + expect(issue?.keys).toEqual(['authToken']); + expect(issue?.message).toContain('OS_SMS_TWILIO_AUTH_TOKEN'); + }); + + it('an SMS provider tag no transport builds is refused at authoring', () => { + const verdict = parse({ manifest, sms: { provider: 'twilo' } }); + expect(verdict.success).toBe(false); + if (verdict.success) return; + expect(verdict.error.issues.map((i) => `${i.code} @ ${i.path.map(String).join('.')}`)).toContain('invalid_value @ sms.provider'); + expect(SmsProviderSchema.options).toEqual(['log', 'aliyun', 'twilio']); + }); + + it('`from` is pointed at `defaultFrom`', () => { + const issue = unrecognized(parse({ manifest, email: { from: { address: 'a@b.example' } } })); + expect(issue?.keys).toEqual(['from']); + expect(issue?.message).toContain('`defaultFrom`'); + }); +}); + +// ─── CONTROL — nothing else moved ─── + +describe('control — the door opened for exactly these keys', () => { + it('a stack with no mail or SMS configuration is unchanged by the parse', () => { + const verdict = parse({ manifest }); + expect(verdict.success).toBe(true); + if (!verdict.success) return; + expect('email' in verdict.data).toBe(false); + expect('sms' in verdict.data).toBe(false); + expect('appName' in verdict.data).toBe(false); + }); + + it('a neighbouring undeclared top-level key still refuses', () => { + for (const key of ['mail', 'smtp', 'emailConfig']) { + const issue = unrecognized(parse({ manifest, [key]: {} })); + expect(issue?.path, `'${key}' must still refuse at the top level`).toEqual([]); + expect(issue?.keys).toEqual([key]); + } + }); + + it('the narrowed blocks are closed: an undocumented key is refused, not shipped into the artifact', () => { + expect(unrecognized(parse({ manifest, email: { options: { transportOptions: { auth: { pass: 'x' } } } } }))?.keys) + .toEqual(['transportOptions']); + expect(StackSmsConfigSchema.safeParse({ provider: 'log', bogus: 1 }).success).toBe(false); + }); +}); + +// ─── COMPOSE — one configuration per boot ─── + +describe('compose — an identical configuration passes, a different one is named', () => { + const stackWith = (id: string, email: Record) => defineStack({ + manifest: { ...manifest, id: `com.example.${id}`, name: id, namespace: id }, + appName: 'Acme', + email, + }); + + it('passes an identical email block through and refuses two different ones by key', () => { + const a = stackWith('one', { provider: 'log', persist: false }); + const b = stackWith('two', { provider: 'log', persist: false }); + expect(composeStacks([a, b]).email).toEqual({ provider: 'log', persist: false }); + expect(composeStacks([a, b]).appName).toBe('Acme'); + + const c = stackWith('three', { provider: 'log', persist: true }); + expect(() => composeStacks([a, c])).toThrow("top-level key 'email'"); + }); +}); diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index d0f97cc1a89..7a354e373c8 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -8,6 +8,8 @@ import { PLATFORM_CAPABILITY_TOKENS, RETIRED_PLATFORM_CAPABILITY_GUIDANCE } from import { DatasourceSchema } from './data/datasource.zod'; import { TranslationBundleSchema, TranslationConfigSchema } from './system/translation.zod'; import { StackServerConfigSchema } from './system/stack-server.zod'; +import { StackEmailConfigSchema } from './system/email-config.zod'; +import { StackSmsConfigSchema } from './system/sms-config.zod'; import { DevLoginSchema } from './system/dev-login.zod'; import { hasPlatformObjectPrefix } from './system/constants/platform-object-names'; import { objectStackErrorMap, formatZodError } from './shared/error-map.zod'; @@ -586,6 +588,47 @@ const STACK_DEFINITION_COLLECTIONS_SHAPE = { server: StackServerConfigSchema.optional() .describe('Server-level runtime config consumed by objectstack serve/dev (inbound rate limit, proxy trust)'), + /** + * The deployment's product name — the bottom configured rung of the chain + * `resolveDeploymentAppName` (`@objectstack/plugin-email`) reads: + * `OS_APP_NAME` > `email.appName` > `email.defaultTemplateContext.appName` > + * this > `'ObjectStack'`. `objectstack serve` hands the result to the email + * templates (`{{appName}}`) and to AuthPlugin (its emails and the TOTP + * issuer); `bootStack` reads it through the same function. + */ + appName: z.string().optional() + .describe( + 'Deployment product name for email templates and AuthPlugin — OS_APP_NAME env wins, then email.appName, ' + + 'then email.defaultTemplateContext.appName, then this, then "ObjectStack"', + ), + + /** + * Outbound mail configuration — {@link StackEmailConfigSchema}: the + * non-secret members of `EmailServiceConfigSchema`, read by + * `resolveEmailCapabilityArg` when `objectstack serve` / `bootStack` + * construct the email provider. `OS_EMAIL_*` overrides it per key, and the + * credentials (`apiKey`, the SMTP `options.password`) are refused here by + * name — they are environment-only, because this definition is compiled + * into the published artifact. + */ + email: StackEmailConfigSchema.optional() + .describe( + 'Outbound mail configuration (non-secret members of EmailServiceConfig; OS_EMAIL_* env overrides per key). ' + + 'Credentials are environment-only: OS_EMAIL_API_KEY, OS_EMAIL_SMTP_PASSWORD', + ), + + /** + * SMS configuration — {@link StackSmsConfigSchema}: the non-secret members + * `resolveSmsCapabilityArg` reads. Provider credentials (`providerOptions`) + * are refused here by name; they live in the `sms` settings namespace and + * its `OS_SMS_*` environment overrides. + */ + sms: StackSmsConfigSchema.optional() + .describe( + 'SMS configuration (provider tag and retries; OS_SMS_PROVIDER env wins). Provider credentials are not ' + + 'authorable here: Setup → Settings → SMS Delivery, or its OS_SMS_* env overrides', + ), + /** * ObjectAI: Artificial Intelligence Layer * @@ -1093,6 +1136,13 @@ export const COMPOSE_KEY_DISPOSITIONS = Object.freeze({ onEnable: 'single', // #5051: the last key still on last-wins; aligned here, see the note above. i18n: 'single', + // Deployment configuration the boot reads off the top level — one product + // name, one mail configuration and one SMS configuration per booted stack, + // so two stacks declaring different ones is a conflict to name, like + // `api` / `server`. + appName: 'single', + email: 'single', + sms: 'single', } as const satisfies Record); /** diff --git a/packages/spec/src/system/email-config.zod.ts b/packages/spec/src/system/email-config.zod.ts index 18f15ec281b..e9d625633b2 100644 --- a/packages/spec/src/system/email-config.zod.ts +++ b/packages/spec/src/system/email-config.zod.ts @@ -2,6 +2,7 @@ import { z } from 'zod'; import { lazySchema } from '../shared/lazy-schema'; +import { strictObject } from '../shared/strict-object'; /** * Email Service Configuration Protocol @@ -11,9 +12,17 @@ import { lazySchema } from '../shared/lazy-schema'; * + provider-specific settings; concrete `IEmailTransport` * implementations live in `@objectstack/plugin-email/transports/*`. * - * Resolution order in `serve.ts`: - * 1. `config.email.*` from objectstack.config.ts - * 2. `OS_EMAIL_*` environment variables (override per setting) + * Resolution order in `resolveEmailCapabilityArg` (`@objectstack/plugin-email`, + * the one reader; `objectstack serve` and `bootStack` both construct the email + * provider through it): + * 1. `config.email.*` from objectstack.config.ts — the NON-SECRET members + * only. A config file is compiled into the published artifact, so the + * stack definition carries {@link StackEmailConfigSchema}: every member + * below except `apiKey`, with `options` narrowed to host / port / secure / + * user / messageStream. `apiKey` and the SMTP `options.password` are + * refused there by name; they are environment-only. + * 2. `OS_EMAIL_*` environment variables (override per setting) — the only + * carrier of `OS_EMAIL_API_KEY` and `OS_EMAIL_SMTP_PASSWORD` * 3. Default → provider='log' (LogTransport, no real send) * * `appName` is the one key whose env layer is not `OS_EMAIL_*` — it is @@ -30,7 +39,9 @@ import { lazySchema } from '../shared/lazy-schema'; * SMTP delivery is built in (ADR-0012): select it with provider='smtp' * and supply the connection through `options` (host / port / secure / * user / password) or the matching OS_EMAIL_SMTP_HOST / _PORT / - * _SECURE / _USER / _PASSWORD environment variables. + * _SECURE / _USER / _PASSWORD environment variables. From a config file the + * password always arrives through OS_EMAIL_SMTP_PASSWORD, which the reader + * layers over the host / port / secure / user the file carries. */ /** @@ -217,3 +228,105 @@ export const EmailServiceConfigSchema = lazySchema(() => z.object({ export type EmailServiceConfig = z.input; /** Post-parse shape of {@link EmailServiceConfig} — defaults applied, transforms run (ADR-0122). */ export type EmailServiceConfigParsed = z.infer; + +/** + * The one prescription for a provider API key written into a stack definition, + * shared by the two places an author can write one (`email.apiKey`, and + * `email.options.apiKey`, which `makeTransport` spreads over the key for + * resend / postmark). + */ +const STACK_EMAIL_API_KEY_PRESCRIPTION = + 'Not authorable here. A provider API key is a credential, and a stack definition is compiled into the ' + + 'published artifact — set OS_EMAIL_API_KEY in the deployment environment instead (required for provider ' + + 'resend / postmark).'; + +/** + * `email.options` of a stack definition — the NON-SECRET provider extras. + * + * {@link EmailServiceConfigSchema}'s `options` is a free-form record whose + * TSDoc names the keys each transport reads: smtp `host` / `port` / `secure` / + * `user` / `password`, postmark `messageStream`. A stack definition is what + * `objectstack build` compiles into the artifact, and artifacts are published, + * so this block declares those keys minus the credential: the SMTP `password` + * (and an `apiKey`, which `makeTransport` would spread over the real one) is + * refused by name with its environment variable. The reader layers + * `OS_EMAIL_SMTP_*` over this block per key, so a file that carries host and + * user and an environment that carries `OS_EMAIL_SMTP_PASSWORD` compose into + * one working SMTP connection. + * + * Strict, unlike the record it narrows: a key no transport documents (raw + * `transportOptions`, `timeout`, `endpoint`) is refused rather than shipped + * into an artifact unexamined — `transportOptions` in particular is the SMTP + * escape hatch that can carry credentials of its own. + */ +export const StackEmailProviderOptionsSchema = lazySchema(() => strictObject( + { + surface: 'the stack `email.options` block', + history: + 'This block is strict from birth — a stack definition carried no `email` key before it, so an unknown ' + + 'key here was never accepted.', + guidance: { + password: + 'Not authorable here. The SMTP password is a credential, and a stack definition is compiled into the ' + + 'published artifact — set OS_EMAIL_SMTP_PASSWORD in the deployment environment instead; it is layered ' + + 'over the host / port / secure / user this block carries.', + apiKey: STACK_EMAIL_API_KEY_PRESCRIPTION, + }, + }, + { + host: z.string().optional() + .describe('SMTP server hostname (OS_EMAIL_SMTP_HOST env wins). Required for provider smtp unless the env supplies it'), + port: z.number().int().optional() + .describe('SMTP port (OS_EMAIL_SMTP_PORT env wins). Default 587; 465 selects implicit TLS'), + secure: z.boolean().optional() + .describe('Require TLS (OS_EMAIL_SMTP_SECURE env wins). Default true'), + user: z.string().optional() + .describe('SMTP AUTH username (OS_EMAIL_SMTP_USER env wins). Its password is environment-only: OS_EMAIL_SMTP_PASSWORD'), + messageStream: z.string().optional() + .describe('Postmark message stream. Default outbound'), + }, +)); +export type StackEmailProviderOptions = z.input; +/** Post-parse shape of {@link StackEmailProviderOptions} — defaults applied, transforms run (ADR-0122). */ +export type StackEmailProviderOptionsParsed = z.infer; + +/** + * `email` of a stack definition — what `objectstack.config.ts` may carry of + * {@link EmailServiceConfigSchema}. + * + * DERIVED, not transcribed: every member of that schema except the credential + * `apiKey`, with `options` narrowed to {@link StackEmailProviderOptionsSchema}. + * A key added to the operator contract therefore arrives here the day it + * lands; a secret one has to be omitted here on purpose, in this expression. + * + * Why the credentials stop at this door: the stack definition is what + * `objectstack build` compiles into the artifact, and artifacts are published + * (the marketplace ingest stores them). So a credential is refused BY NAME, + * with the environment variable that carries it, rather than as a bare unknown + * key — the same answer the stack gives `storage`. Everything else the reader + * reads (`resolveEmailCapabilityArg` in `@objectstack/plugin-email`, which + * `objectstack serve` and `bootStack` both construct the email provider + * through) is authorable here, and `OS_EMAIL_*` still overrides it per key. + */ +export const StackEmailConfigSchema = lazySchema(() => strictObject( + { + surface: 'the stack `email` block', + history: + 'This block is strict from birth — a stack definition carried no `email` key before it, so an unknown ' + + 'key here was never accepted.', + aliases: { from: 'defaultFrom' }, + guidance: { apiKey: STACK_EMAIL_API_KEY_PRESCRIPTION }, + }, + { + ...EmailServiceConfigSchema.omit({ apiKey: true, options: true }).shape, + options: StackEmailProviderOptionsSchema.optional() + .describe( + 'Provider-specific extras, non-secret members only. smtp: host / port / secure / user, mirroring ' + + 'OS_EMAIL_SMTP_HOST / _PORT / _SECURE / _USER (the password is OS_EMAIL_SMTP_PASSWORD only). ' + + 'postmark: messageStream', + ), + }, +)); +export type StackEmailConfig = z.input; +/** Post-parse shape of {@link StackEmailConfig} — defaults applied, transforms run (ADR-0122). */ +export type StackEmailConfigParsed = z.infer; diff --git a/packages/spec/src/system/index.ts b/packages/spec/src/system/index.ts index 075d4276428..3297f5d2cba 100644 --- a/packages/spec/src/system/index.ts +++ b/packages/spec/src/system/index.ts @@ -72,6 +72,7 @@ export * from './book.zod'; export * from './email-config.zod'; export * from './email-template.zod'; export * from './email-template.form'; +export * from './sms-config.zod'; export * from './metadata-form-registry'; // compliance.zod (GDPR/HIPAA/PCI configs) and masking.zod (role-based data // masking) were REMOVED per ADR-0056 D8 "design+enforce or remove": both were diff --git a/packages/spec/src/system/sms-config.zod.ts b/packages/spec/src/system/sms-config.zod.ts new file mode 100644 index 00000000000..fa7e008fa8a --- /dev/null +++ b/packages/spec/src/system/sms-config.zod.ts @@ -0,0 +1,106 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { z } from 'zod'; +import { lazySchema } from '../shared/lazy-schema'; +import { strictObject } from '../shared/strict-object'; + +/** + * SMS Service Configuration — the stack definition's `sms` block. + * + * There was no SMS configuration schema in the spec before this file. The + * shape below is typed from what its one reader reads: + * `resolveSmsCapabilityArg` in `@objectstack/service-sms`, which + * `objectstack serve` and `bootStack` both construct `SmsServicePlugin` + * through. It reads three keys off `config.sms`: + * + * | key | secret? | here | + * |---|---|---| + * | `provider` | no — a transport tag | admitted ({@link SmsProviderSchema}); `OS_SMS_PROVIDER` wins | + * | `retries` | no | admitted | + * | `providerOptions` | YES — every non-`log` transport requires a credential inside it (Twilio `authToken`, Aliyun `accessKeySecret`) | refused by name | + * + * `providerOptions` is refused WHOLE rather than narrowed to its non-secret + * members, and the difference from mail's `options` is measured, not a + * preference. The mail reader layers `OS_EMAIL_SMTP_*` over `config.email.options` + * per key, so a file carrying host and user composes with an environment + * carrying the password. The SMS reader hands `providerOptions` to the + * transport as written, and nothing merges an environment credential into it: + * a Twilio or Aliyun transport built from the non-secret members alone cannot + * be constructed, so the plugin falls back to its log transport. A narrowed + * `providerOptions` would therefore be a declared key no deployment could ever + * deliver through. SMS provider settings — credentials included — live in the + * `sms` settings namespace (Setup → Settings → SMS Delivery), whose every key + * accepts its `OS_SMS_*` environment override, and the refusal says so. + * + * Why the credentials stop at this door at all: the stack definition is what + * `objectstack build` compiles into the artifact, and artifacts are published. + */ + +/** + * SMS transport selector — the tags `@objectstack/service-sms` can build a + * transport for. + * + * - `log` — LogSmsTransport (development; logs, never delivers). + * - `aliyun` — Aliyun SMS (template-only). + * - `twilio` — Twilio Programmable Messaging. + * + * This list is the authoring-contract side of one vocabulary: the other sides + * are `SMS_TRANSPORT_PROVIDERS` in `@objectstack/service-sms` (the array + * `makeSmsTransport` switches on) and the `sms` settings manifest's provider + * options. A cross-package contract test in `@objectstack/cli`, which depends + * on both packages, holds this enum equal to `SMS_TRANSPORT_PROVIDERS` in both + * directions — a tag declared here with no transport behind it would be a + * provider no deployment can deliver through, and the reader refuses it at boot. + */ +export const SmsProviderSchema = lazySchema(() => z.enum(['log', 'aliyun', 'twilio'])); +export type SmsProvider = z.input; + +/** + * The one prescription for SMS provider settings written into a stack + * definition — the key that carries them, and the credential names an author + * might write flat on the block. + */ +const STACK_SMS_PROVIDER_SETTINGS_PRESCRIPTION = + 'Not authorable here. SMS provider settings carry the provider credentials (Twilio auth token, Aliyun ' + + 'AccessKey secret), and a stack definition is compiled into the published artifact. Configure them in ' + + 'Setup → Settings → SMS Delivery, or through that namespace\'s environment overrides: OS_SMS_PROVIDER plus ' + + 'OS_SMS_TWILIO_ACCOUNT_SID / OS_SMS_TWILIO_AUTH_TOKEN / OS_SMS_TWILIO_FROM_NUMBER (Twilio) or ' + + 'OS_SMS_ALIYUN_ACCESS_KEY_ID / OS_SMS_ALIYUN_ACCESS_KEY_SECRET / OS_SMS_ALIYUN_SIGN_NAME (Aliyun).'; + +/** + * `sms` of a stack definition — the non-secret members `resolveSmsCapabilityArg` + * reads off `config.sms`. See the file header for the census. + */ +export const StackSmsConfigSchema = lazySchema(() => strictObject( + { + surface: 'the stack `sms` block', + history: + 'This block is strict from birth — a stack definition carried no `sms` key before it, so an unknown ' + + 'key here was never accepted.', + guidance: { + providerOptions: STACK_SMS_PROVIDER_SETTINGS_PRESCRIPTION, + authToken: STACK_SMS_PROVIDER_SETTINGS_PRESCRIPTION, + accessKeySecret: STACK_SMS_PROVIDER_SETTINGS_PRESCRIPTION, + }, + }, + { + /** + * The tag `SmsServicePlugin` is constructed with. The credentials never + * come from here, so for `aliyun` / `twilio` the delivering transport is + * the one the `sms` settings namespace builds at `kernel:ready` — from its + * own `provider` setting (`OS_SMS_PROVIDER` is that setting's override as + * well as this key's). + */ + provider: SmsProviderSchema.optional() + .describe( + 'SMS transport tag (OS_SMS_PROVIDER env wins). Default log. Credentials never come from the stack: ' + + 'the delivering aliyun / twilio transport is built at kernel:ready from the sms settings namespace ' + + '(Setup → Settings → SMS Delivery, or its OS_SMS_* env overrides), whose own provider setting decides', + ), + /** Retry attempts on transport throw. Default 0. */ + retries: z.number().int().min(0).optional().describe('Retry attempts on transport throw (default 0)'), + }, +)); +export type StackSmsConfig = z.input; +/** Post-parse shape of {@link StackSmsConfig} — defaults applied, transforms run (ADR-0122). */ +export type StackSmsConfigParsed = z.infer; From 64fbaae589ddcfffee9795af8de226ca7a9de218 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 04:33:32 +0000 Subject: [PATCH 2/5] wip(spec): regenerate artifacts, docs and the platform skill's key enumeration; changeset Claude-Session: https://claude.ai/code/session_01S3aAf11JjbW1mSGL1EhfFj Co-authored-by: Claude --- .changeset/22748-stack-email-sms-door.md | 20 +++++ .../docs/getting-started/quick-reference.mdx | 2 +- content/docs/getting-started/quick-start.mdx | 6 +- content/docs/references/index.mdx | 11 +-- .../docs/references/system/email-config.mdx | 72 +++++++++++++++-- content/docs/references/system/index.mdx | 3 +- content/docs/references/system/meta.json | 1 + content/docs/references/system/sms-config.mdx | 77 +++++++++++++++++++ .../system.md | 2 +- packages/spec/api-surface/system.json | 11 +++ packages/spec/authorable-defaults/system.json | 1 + packages/spec/authorable-surface/system.json | 15 ++++ packages/spec/declaration-map/system.json | 8 ++ packages/spec/export-origins/system.json | 11 +++ .../spec/json-schema.manifest/system.json | 4 + skills/objectstack-platform/SKILL.md | 5 +- 16 files changed, 230 insertions(+), 19 deletions(-) create mode 100644 .changeset/22748-stack-email-sms-door.md create mode 100644 content/docs/references/system/sms-config.mdx diff --git a/.changeset/22748-stack-email-sms-door.md b/.changeset/22748-stack-email-sms-door.md new file mode 100644 index 00000000000..7c89cb21527 --- /dev/null +++ b/.changeset/22748-stack-email-sms-door.md @@ -0,0 +1,20 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): the stack definition admits `appName` and the non-secret members of `email` and `sms`; their credentials are refused by name + +Clause-②: yes (widening: the stack definition admits `appName` and the non-secret members of `email` and `sms`, which it refused as unrecognized keys) + +- **What you can write now.** `defineStack()` accepts three top-level keys it used to refuse as unrecognized. The mail and SMS readers already read all three, and `objectstack serve` and `bootStack` construct the email and SMS providers through those readers: + - `appName`: the deployment's product name. It is the last configured rung of the name chain (`OS_APP_NAME` > `email.appName` > `email.defaultTemplateContext.appName` > `appName` > `'ObjectStack'`), and it reaches the email templates and AuthPlugin. + - `email` (`StackEmailConfigSchema`): every member of `EmailServiceConfigSchema` except `apiKey`. That is `provider`, `defaultFrom`, `retries`, `persist`, `queueDelivery`, `appName` and `defaultTemplateContext`, plus `options` narrowed to `host` / `port` / `secure` / `user` / `messageStream`. + - `sms` (`StackSmsConfigSchema`): `provider` (`SmsProviderSchema`: `log` / `aliyun` / `twilio`) and `retries`. + - `OS_EMAIL_*` and `OS_SMS_PROVIDER` still override the file per key. +- **What stays in the environment, and is refused by name.** A stack definition is compiled into the published artifact, so it does not carry credentials. Each refusal names the key and the variable that replaces it: + - `email.apiKey` and `email.options.apiKey`: set `OS_EMAIL_API_KEY`. + - `email.options.password`: set `OS_EMAIL_SMTP_PASSWORD`. The reader layers it over the host / port / secure / user the file carries. + - `sms.providerOptions`: configure the provider in Setup → Settings → SMS Delivery, or through that namespace's overrides (`OS_SMS_PROVIDER`, `OS_SMS_TWILIO_*`, `OS_SMS_ALIYUN_*`). It is refused whole, because every non-`log` SMS transport needs a credential inside it and nothing merges an environment credential into it. +- **Composition.** All three keys compose as single values. `composeStacks` passes an identical value through and refuses two different ones by key. +- **Nothing to migrate.** No stack that was accepted before is refused now. A configuration that set these keys only in an object `defineStack` did not build (the one `objectstack serve` boots) reads the same as before. To build it, move any credential it carries into the environment. +- New exports from `@objectstack/spec/system`: `StackEmailConfigSchema`, `StackEmailProviderOptionsSchema`, `StackSmsConfigSchema`, `SmsProviderSchema`, and their `z.input` / `z.infer` types. diff --git a/content/docs/getting-started/quick-reference.mdx b/content/docs/getting-started/quick-reference.mdx index 7e143ab1363..1b8f6cd0ea9 100644 --- a/content/docs/getting-started/quick-reference.mdx +++ b/content/docs/getting-started/quick-reference.mdx @@ -85,7 +85,7 @@ Plugin architecture, manifests, and kernel runtime. | **[Metadata Loader](/docs/references/kernel/metadata-loader)** | `metadata-loader.zod.ts` | MetadataLoaderContract | Metadata loading | | **[Package Registry](/docs/references/kernel/package-registry)** | `package-registry.zod.ts` | InstalledPackage, InstallPackageRequest | Package resolution | -## System Protocol (16 of 34 schemas) +## System Protocol (16 of 35 schemas) Runtime environment, logging, jobs, caching, and observability. diff --git a/content/docs/getting-started/quick-start.mdx b/content/docs/getting-started/quick-start.mdx index 3034579af4f..dcfd3b62e96 100644 --- a/content/docs/getting-started/quick-start.mdx +++ b/content/docs/getting-started/quick-start.mdx @@ -232,8 +232,10 @@ schema page is the authority until one lands. **Not in the table, and why.** `viewItems` and `runtimeModule` are not authorable at all (`viewItems` is `z.never()`, the machine-assembled channel for runtime-assembled manifests; `runtimeModule` is written by `objectstack build`); -`manifest`, `i18n`, `api`, `server`, `requires`, `tiers`, `plugins` and -`devPlugins` configure the stack rather than declare metadata items; and +`manifest`, `i18n`, `api`, `server`, `appName`, `email`, `sms`, `requires`, +`tiers`, `plugins`, `devPlugins`, `devHint` and `devLogins` configure the stack +rather than declare metadata items (`email` and `sms` carry no credentials — +those stay in the environment or in Settings); and `functions` and `onEnable` are code the runtime calls, not metadata it stores. ## How you verify diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index a1db2132191..654e2692f26 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,7 +1,7 @@ --- title: Protocol reference — every schema by module navTitle: Protocol Reference -description: Every schema published by @objectstack/spec — 1516 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1520 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -32,9 +32,9 @@ counts are sums of the rows they head. Regenerate with | [Security Protocol](/docs/references/security) | 5 | 30 | Permission sets, row-level security, sharing rules, tenancy posture. | | [Shared Protocol](/docs/references/shared) | 10 | 31 | Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. | | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | -| [System Protocol](/docs/references/system) | 34 | 275 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | +| [System Protocol](/docs/references/system) | 35 | 279 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 166 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **195** | **1516** | 14 protocol modules | +| **Total** | **196** | **1520** | 14 protocol modules | --- @@ -317,7 +317,7 @@ Studio designer metadata — the authoring surfaces for the protocols above. ## System Protocol -**Source:** `packages/spec/src/system/` · **Import:** `@objectstack/spec/system` · **34 pages, 275 schemas** +**Source:** `packages/spec/src/system/` · **Import:** `@objectstack/spec/system` · **35 pages, 279 schemas** The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. @@ -333,7 +333,7 @@ The runtime environment — logging, jobs, cache, metrics, notifications, i18n a | [`dev-login.zod.ts`](/docs/references/system/dev-login) | `DevLogin` | | [`disaster-recovery.zod.ts`](/docs/references/system/disaster-recovery) | `BackupConfig`, `BackupRetention`, `BackupStrategy`, `DisasterRecoveryPlan`, `FailoverConfig`, `FailoverMode`, `RPO`, `RTO` | | [`doc.zod.ts`](/docs/references/system/doc) | `Doc` | -| [`email-config.zod.ts`](/docs/references/system/email-config) | `EmailAddressConfig`, `EmailProvider`, `EmailServiceConfig` | +| [`email-config.zod.ts`](/docs/references/system/email-config) | `EmailAddressConfig`, `EmailProvider`, `EmailServiceConfig`, `StackEmailConfig`, `StackEmailProviderOptions` | | [`email-template.zod.ts`](/docs/references/system/email-template) | `EmailTemplateDefinition`, `EmailTemplateDefinitionCategory`, `EmailTemplateDefinitionVariable` | | [`encryption.zod.ts`](/docs/references/system/encryption) | `EncryptionAlgorithm`, `EncryptionConfig`, `FieldEncryption`, `KeyManagementProvider`, `KeyRotationPolicy` | | [`environment-artifact.zod.ts`](/docs/references/system/environment-artifact) | `Sha256Digest` | @@ -351,6 +351,7 @@ The runtime environment — logging, jobs, cache, metrics, notifications, i18n a | [`security-context.zod.ts`](/docs/references/system/security-context) | `ComplianceAuditRequirement`, `ComplianceEncryptionRequirement`, `ComplianceFramework`, `DataClassification`, `DataClassificationPolicy`, `MaskingVisibilityRule`, `SecurityContextConfig`, `SecurityEventCorrelation` | | [`settings-client.zod.ts`](/docs/references/system/settings-client) | `SettingsChangeEvent` | | [`settings-manifest.zod.ts`](/docs/references/system/settings-manifest) | `ResolvedSettingValue`, `SettingsActionResult`, `SettingsManifest`, `SettingsNamespacePayload`, `Specifier`, `SpecifierHandler`, `SpecifierOption`, `SpecifierScope`, `SpecifierType`, `SpecifierValueDomain` | +| [`sms-config.zod.ts`](/docs/references/system/sms-config) | `SmsProvider`, `StackSmsConfig` | | [`stack-server.zod.ts`](/docs/references/system/stack-server) | `ServerRateLimitConfig`, `StackServerConfig`, `StackServerSecurity` | | [`supplier-security.zod.ts`](/docs/references/system/supplier-security) | `SupplierAssessmentStatus`, `SupplierRiskLevel`, `SupplierSecurityAssessment`, `SupplierSecurityPolicy`, `SupplierSecurityRequirement` | | [`tenant.zod.ts`](/docs/references/system/tenant) | `DatabaseLevelIsolationStrategy`, `DatabaseProvider`, `QuotaEnforcementResult`, `RowLevelIsolationStrategy`, `SchemaLevelIsolationStrategy`, `Tenant`, `TenantConnectionConfig`, `TenantIsolationConfig`, `TenantIsolationLevel`, `TenantQuota`, `TenantSecurityPolicy`, `TenantUsage` | diff --git a/content/docs/references/system/email-config.mdx b/content/docs/references/system/email-config.mdx index 35f258f5d2d..db0008a2d91 100644 --- a/content/docs/references/system/email-config.mdx +++ b/content/docs/references/system/email-config.mdx @@ -13,9 +13,17 @@ transport for the EmailServicePlugin. Provider is a `provider` tag + provider-specific settings; concrete `IEmailTransport` implementations live in `@objectstack/plugin-email/transports/*`. -Resolution order in `serve.ts`: - 1. `config.email.*` from objectstack.config.ts - 2. `OS_EMAIL_*` environment variables (override per setting) +Resolution order in `resolveEmailCapabilityArg` (`@objectstack/plugin-email`, +the one reader; `objectstack serve` and `bootStack` both construct the email +provider through it): + 1. `config.email.*` from objectstack.config.ts — the NON-SECRET members + only. A config file is compiled into the published artifact, so the + stack definition carries `StackEmailConfigSchema`: every member + below except `apiKey`, with `options` narrowed to host / port / secure / + user / messageStream. `apiKey` and the SMTP `options.password` are + refused there by name; they are environment-only. + 2. `OS_EMAIL_*` environment variables (override per setting) — the only + carrier of `OS_EMAIL_API_KEY` and `OS_EMAIL_SMTP_PASSWORD` 3. Default → provider='log' (LogTransport, no real send) `appName` is the one key whose env layer is not `OS_EMAIL_*` — it is @@ -32,7 +40,9 @@ defaultTemplateContext), both times with the spec on the lagging side. SMTP delivery is built in (ADR-0012): select it with provider='smtp' and supply the connection through `options` (host / port / secure / user / password) or the matching OS_EMAIL_SMTP_HOST / _PORT / -_SECURE / _USER / _PASSWORD environment variables. +_SECURE / _USER / _PASSWORD environment variables. From a config file the +password always arrives through OS_EMAIL_SMTP_PASSWORD, which the reader +layers over the host / port / secure / user the file carries. **Source:** `packages/spec/src/system/email-config.zod.ts` @@ -41,8 +51,8 @@ _SECURE / _USER / _PASSWORD environment variables. ## TypeScript Usage ```typescript -import { EmailAddressConfigSchema, EmailProviderSchema, EmailServiceConfigSchema } from '@objectstack/spec/system'; -import type { EmailAddressConfig, EmailProvider, EmailServiceConfig } from '@objectstack/spec/system'; +import { EmailAddressConfigSchema, EmailProviderSchema, EmailServiceConfigSchema, StackEmailConfigSchema, StackEmailProviderOptionsSchema } from '@objectstack/spec/system'; +import type { EmailAddressConfig, EmailProvider, EmailServiceConfig, StackEmailConfig, StackEmailProviderOptions } from '@objectstack/spec/system'; // Validate data const result = EmailAddressConfigSchema.parse(data); @@ -100,3 +110,53 @@ const result = EmailAddressConfigSchema.parse(data); --- +## StackEmailConfig + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **provider** | `Enum<'log' \| 'resend' \| 'postmark' \| 'smtp'>` | optional (default: `"log"`) | Transport to deliver through (OS_EMAIL_PROVIDER env). Default log — boots, sends nothing | +| **defaultFrom** | `{ name?: string; address: string }` | optional | | +| **retries** | `integer` | optional | Retry attempts on transport throw | +| **persist** | `boolean` | optional | Persist to sys_email (default true) | +| **queueDelivery** | `boolean` | optional | Deliver through the durable sys_job_queue instead of inline (or OS_EMAIL_QUEUE_ENABLED env). Default false. Reuses `retries` as the queue attempt budget; requires a queue service and sys_email persistence, else the boot fails | +| **appName** | `string` | optional | Product name templates interpolate as the appName variable — OS_APP_NAME env wins, then this, then defaultTemplateContext.appName, then the top-level config appName, then "ObjectStack". Also seeds the placeholder no-reply sender when no defaultFrom is configured | +| **defaultTemplateContext** | `Record` | optional | Free-form render context merged into every sendTemplate() call, under the per-call data. Passed through unchanged except appName, which is resolved by its own chain — OS_APP_NAME and the appName key both override the value written here | +| **options** | `{ host?: string; port?: integer; secure?: boolean; user?: string; … }` | optional | Provider-specific extras, non-secret members only. smtp: host / port / secure / user, mirroring OS_EMAIL_SMTP_HOST / _PORT / _SECURE / _USER (the password is OS_EMAIL_SMTP_PASSWORD only). postmark: messageStream | + +### Nested Shape: `StackEmailConfig.defaultFrom` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **name** | `string` | optional | Display name (e.g. "Acme CRM") | +| **address** | `string` | ✅ | RFC-5322 address | + +### Nested Shape: `StackEmailConfig.options` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **host** | `string` | optional | SMTP server hostname (OS_EMAIL_SMTP_HOST env wins). Required for provider smtp unless the env supplies it | +| **port** | `integer` | optional | SMTP port (OS_EMAIL_SMTP_PORT env wins). Default 587; 465 selects implicit TLS | +| **secure** | `boolean` | optional | Require TLS (OS_EMAIL_SMTP_SECURE env wins). Default true | +| **user** | `string` | optional | SMTP AUTH username (OS_EMAIL_SMTP_USER env wins). Its password is environment-only: OS_EMAIL_SMTP_PASSWORD | +| **messageStream** | `string` | optional | Postmark message stream. Default outbound | + + +--- + +## StackEmailProviderOptions + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **host** | `string` | optional | SMTP server hostname (OS_EMAIL_SMTP_HOST env wins). Required for provider smtp unless the env supplies it | +| **port** | `integer` | optional | SMTP port (OS_EMAIL_SMTP_PORT env wins). Default 587; 465 selects implicit TLS | +| **secure** | `boolean` | optional | Require TLS (OS_EMAIL_SMTP_SECURE env wins). Default true | +| **user** | `string` | optional | SMTP AUTH username (OS_EMAIL_SMTP_USER env wins). Its password is environment-only: OS_EMAIL_SMTP_PASSWORD | +| **messageStream** | `string` | optional | Postmark message stream. Default outbound | + + +--- + diff --git a/content/docs/references/system/index.mdx b/content/docs/references/system/index.mdx index 5fd4a81fa22..f3132db8961 100644 --- a/content/docs/references/system/index.mdx +++ b/content/docs/references/system/index.mdx @@ -1,7 +1,7 @@ --- title: System Protocol — complete schema reference navTitle: System Protocol -description: "The ObjectStack System Protocol in 34 reference pages: every schema in @objectstack/spec with its properties, types, defaults and a TypeScript example." +description: "The ObjectStack System Protocol in 35 reference pages: every schema in @objectstack/spec with its properties, types, defaults and a TypeScript example." --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -37,6 +37,7 @@ This section contains all protocol schemas for the system layer of ObjectStack. + diff --git a/content/docs/references/system/meta.json b/content/docs/references/system/meta.json index 1f70e00c914..257fa318569 100644 --- a/content/docs/references/system/meta.json +++ b/content/docs/references/system/meta.json @@ -40,6 +40,7 @@ "doc", "---More---", "dev-login", + "sms-config", "stack-server" ] } \ No newline at end of file diff --git a/content/docs/references/system/sms-config.mdx b/content/docs/references/system/sms-config.mdx new file mode 100644 index 00000000000..f17c6b5124d --- /dev/null +++ b/content/docs/references/system/sms-config.mdx @@ -0,0 +1,77 @@ +--- +title: Sms Config schema — System Protocol reference +navTitle: Sms Config +description: "SMS Service Configuration — the stack definition's sms block. There was no SMS configuration schema in the spec before this file." +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} + +SMS Service Configuration — the stack definition's `sms` block. + +There was no SMS configuration schema in the spec before this file. The +shape below is typed from what its one reader reads: +`resolveSmsCapabilityArg` in `@objectstack/service-sms`, which +`objectstack serve` and `bootStack` both construct `SmsServicePlugin` +through. It reads three keys off `config.sms`: + +| key | secret? | here | +|---|---|---| +| `provider` | no — a transport tag | admitted (`SmsProviderSchema`); `OS_SMS_PROVIDER` wins | +| `retries` | no | admitted | +| `providerOptions` | YES — every non-`log` transport requires a credential inside it (Twilio `authToken`, Aliyun `accessKeySecret`) | refused by name | + +`providerOptions` is refused WHOLE rather than narrowed to its non-secret +members, and the difference from mail's `options` is measured, not a +preference. The mail reader layers `OS_EMAIL_SMTP_*` over `config.email.options` +per key, so a file carrying host and user composes with an environment +carrying the password. The SMS reader hands `providerOptions` to the +transport as written, and nothing merges an environment credential into it: +a Twilio or Aliyun transport built from the non-secret members alone cannot +be constructed, so the plugin falls back to its log transport. A narrowed +`providerOptions` would therefore be a declared key no deployment could ever +deliver through. SMS provider settings — credentials included — live in the +`sms` settings namespace (Setup → Settings → SMS Delivery), whose every key +accepts its `OS_SMS_*` environment override, and the refusal says so. + +Why the credentials stop at this door at all: the stack definition is what +`objectstack build` compiles into the artifact, and artifacts are published. + + +**Source:** `packages/spec/src/system/sms-config.zod.ts` + + +## TypeScript Usage + +```typescript +import { SmsProviderSchema, StackSmsConfigSchema } from '@objectstack/spec/system'; +import type { SmsProvider, StackSmsConfig } from '@objectstack/spec/system'; + +// Validate data +const result = SmsProviderSchema.parse(data); +``` + +--- + +## SmsProvider + +### Allowed Values + +* `log` +* `aliyun` +* `twilio` + + +--- + +## StackSmsConfig + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **provider** | `Enum<'log' \| 'aliyun' \| 'twilio'>` | optional | SMS transport tag (OS_SMS_PROVIDER env wins). Default log. Credentials never come from the stack: the delivering aliyun / twilio transport is built at kernel:ready from the sms settings namespace (Setup → Settings → SMS Delivery, or its OS_SMS_* env overrides), whose own provider setting decides | +| **retries** | `integer` | optional | Retry attempts on transport throw (default 0) | + + +--- + diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md index a45160e4971..773b28aef3e 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md @@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate. | Dir | Sites | |---|---| -| `system/` | 355 | +| `system/` | 358 | diff --git a/packages/spec/api-surface/system.json b/packages/spec/api-surface/system.json index 8fa55c8f070..8c89382653b 100644 --- a/packages/spec/api-surface/system.json +++ b/packages/spec/api-surface/system.json @@ -615,6 +615,8 @@ "SettingsUnsubscribe (type)", "Sha256Digest (type)", "Sha256DigestSchema (const)", + "SmsProvider (type)", + "SmsProviderSchema (const)", "SocialProviderConfig (type)", "SocialProviderConfigParsed (type)", "SocialProviderConfigSchema (const)", @@ -648,12 +650,21 @@ "SpecifierType (type)", "SpecifierValueDomain (type)", "SpecifierValueDomainSchema (const)", + "StackEmailConfig (type)", + "StackEmailConfigParsed (type)", + "StackEmailConfigSchema (const)", + "StackEmailProviderOptions (type)", + "StackEmailProviderOptionsParsed (type)", + "StackEmailProviderOptionsSchema (const)", "StackServerConfig (type)", "StackServerConfigParsed (type)", "StackServerConfigSchema (const)", "StackServerSecurity (type)", "StackServerSecurityParsed (type)", "StackServerSecuritySchema (const)", + "StackSmsConfig (type)", + "StackSmsConfigParsed (type)", + "StackSmsConfigSchema (const)", "StorageAcl (type)", "StorageAclSchema (const)", "StorageClass (type)", diff --git a/packages/spec/authorable-defaults/system.json b/packages/spec/authorable-defaults/system.json index 395da8cb265..4f73427e46e 100644 --- a/packages/spec/authorable-defaults/system.json +++ b/packages/spec/authorable-defaults/system.json @@ -216,6 +216,7 @@ "system/Span:kind = \"internal\"", "system/Span:links = []", "system/Specifier:required = false", + "system/StackEmailConfig:provider = \"log\"", "system/StackServerConfig:trustProxy = false", "system/StorageConnection:useSSL = true", "system/SupplierSecurityPolicy:enabled = true", diff --git a/packages/spec/authorable-surface/system.json b/packages/spec/authorable-surface/system.json index bb139fbe61a..273a39aade9 100644 --- a/packages/spec/authorable-surface/system.json +++ b/packages/spec/authorable-surface/system.json @@ -1109,9 +1109,24 @@ "system/SpecifierOption:icon", "system/SpecifierOption:label", "system/SpecifierOption:value", + "system/StackEmailConfig:appName", + "system/StackEmailConfig:defaultFrom", + "system/StackEmailConfig:defaultTemplateContext", + "system/StackEmailConfig:options", + "system/StackEmailConfig:persist", + "system/StackEmailConfig:provider", + "system/StackEmailConfig:queueDelivery", + "system/StackEmailConfig:retries", + "system/StackEmailProviderOptions:host", + "system/StackEmailProviderOptions:messageStream", + "system/StackEmailProviderOptions:port", + "system/StackEmailProviderOptions:secure", + "system/StackEmailProviderOptions:user", "system/StackServerConfig:security", "system/StackServerConfig:trustProxy", "system/StackServerSecurity:rateLimit", + "system/StackSmsConfig:provider", + "system/StackSmsConfig:retries", "system/StorageConnection:accessKeyId", "system/StorageConnection:accountKey", "system/StorageConnection:accountName", diff --git a/packages/spec/declaration-map/system.json b/packages/spec/declaration-map/system.json index ffd9cdbb2ec..10ccbdc4281 100644 --- a/packages/spec/declaration-map/system.json +++ b/packages/spec/declaration-map/system.json @@ -398,6 +398,8 @@ "SettingsNamespacePayloadSchema": "system/SettingsNamespacePayload", "Sha256Digest": "system/Sha256Digest", "Sha256DigestSchema": "system/Sha256Digest", + "SmsProvider": "system/SmsProvider", + "SmsProviderSchema": "system/SmsProvider", "SocialProviderConfig": "system/SocialProviderConfig", "SocialProviderConfigSchema": "system/SocialProviderConfig", "Span": "system/Span", @@ -423,10 +425,16 @@ "SpecifierType": "system/SpecifierType", "SpecifierValueDomain": "system/SpecifierValueDomain", "SpecifierValueDomainSchema": "system/SpecifierValueDomain", + "StackEmailConfig": "system/StackEmailConfig", + "StackEmailConfigSchema": "system/StackEmailConfig", + "StackEmailProviderOptions": "system/StackEmailProviderOptions", + "StackEmailProviderOptionsSchema": "system/StackEmailProviderOptions", "StackServerConfig": "system/StackServerConfig", "StackServerConfigSchema": "system/StackServerConfig", "StackServerSecurity": "system/StackServerSecurity", "StackServerSecuritySchema": "system/StackServerSecurity", + "StackSmsConfig": "system/StackSmsConfig", + "StackSmsConfigSchema": "system/StackSmsConfig", "StorageAcl": "system/StorageAcl", "StorageAclSchema": "system/StorageAcl", "StorageClass": "system/StorageClass", diff --git a/packages/spec/export-origins/system.json b/packages/spec/export-origins/system.json index 06af731ed39..5c746e12918 100644 --- a/packages/spec/export-origins/system.json +++ b/packages/spec/export-origins/system.json @@ -589,6 +589,8 @@ "SettingsUnsubscribe": "src/system/settings-client.zod.ts#SettingsUnsubscribe (type)", "Sha256Digest": "src/system/environment-artifact.zod.ts#Sha256Digest (type)", "Sha256DigestSchema": "src/system/environment-artifact.zod.ts#Sha256DigestSchema (const)", + "SmsProvider": "src/system/sms-config.zod.ts#SmsProvider (type)", + "SmsProviderSchema": "src/system/sms-config.zod.ts#SmsProviderSchema (const)", "SocialProviderConfig": "src/system/auth-config.zod.ts#SocialProviderConfig (type)", "SocialProviderConfigParsed": "src/system/auth-config.zod.ts#SocialProviderConfigParsed (type)", "SocialProviderConfigSchema": "src/system/auth-config.zod.ts#SocialProviderConfigSchema (const)", @@ -619,12 +621,21 @@ "SpecifierType": "src/system/settings-manifest.zod.ts#SpecifierType (type)", "SpecifierValueDomain": "src/system/settings-manifest.zod.ts#SpecifierValueDomain (type)", "SpecifierValueDomainSchema": "src/system/settings-manifest.zod.ts#SpecifierValueDomainSchema (const)", + "StackEmailConfig": "src/system/email-config.zod.ts#StackEmailConfig (type)", + "StackEmailConfigParsed": "src/system/email-config.zod.ts#StackEmailConfigParsed (type)", + "StackEmailConfigSchema": "src/system/email-config.zod.ts#StackEmailConfigSchema (const)", + "StackEmailProviderOptions": "src/system/email-config.zod.ts#StackEmailProviderOptions (type)", + "StackEmailProviderOptionsParsed": "src/system/email-config.zod.ts#StackEmailProviderOptionsParsed (type)", + "StackEmailProviderOptionsSchema": "src/system/email-config.zod.ts#StackEmailProviderOptionsSchema (const)", "StackServerConfig": "src/system/stack-server.zod.ts#StackServerConfig (type)", "StackServerConfigParsed": "src/system/stack-server.zod.ts#StackServerConfigParsed (type)", "StackServerConfigSchema": "src/system/stack-server.zod.ts#StackServerConfigSchema (const)", "StackServerSecurity": "src/system/stack-server.zod.ts#StackServerSecurity (type)", "StackServerSecurityParsed": "src/system/stack-server.zod.ts#StackServerSecurityParsed (type)", "StackServerSecuritySchema": "src/system/stack-server.zod.ts#StackServerSecuritySchema (const)", + "StackSmsConfig": "src/system/sms-config.zod.ts#StackSmsConfig (type)", + "StackSmsConfigParsed": "src/system/sms-config.zod.ts#StackSmsConfigParsed (type)", + "StackSmsConfigSchema": "src/system/sms-config.zod.ts#StackSmsConfigSchema (const)", "StorageAcl": "src/system/object-storage.zod.ts#StorageAcl (type)", "StorageAclSchema": "src/system/object-storage.zod.ts#StorageAclSchema (const)", "StorageClass": "src/system/object-storage.zod.ts#StorageClass (type)", diff --git a/packages/spec/json-schema.manifest/system.json b/packages/spec/json-schema.manifest/system.json index ad5a1f45f25..d4b0c071502 100644 --- a/packages/spec/json-schema.manifest/system.json +++ b/packages/spec/json-schema.manifest/system.json @@ -217,6 +217,7 @@ "system/SettingsManifest", "system/SettingsNamespacePayload", "system/Sha256Digest", + "system/SmsProvider", "system/SocialProviderConfig", "system/Span", "system/SpanAttributeValue", @@ -231,8 +232,11 @@ "system/SpecifierScope", "system/SpecifierType", "system/SpecifierValueDomain", + "system/StackEmailConfig", + "system/StackEmailProviderOptions", "system/StackServerConfig", "system/StackServerSecurity", + "system/StackSmsConfig", "system/StorageAcl", "system/StorageClass", "system/StorageConnection", diff --git a/skills/objectstack-platform/SKILL.md b/skills/objectstack-platform/SKILL.md index 85b2d8fb4ad..f681797ac71 100644 --- a/skills/objectstack-platform/SKILL.md +++ b/skills/objectstack-platform/SKILL.md @@ -49,7 +49,7 @@ are `manifest`, `packages`, `objects`, `objectExtensions`, `picklists`, `agents`, `tools`, `skills`, `hooks`, `functions`, `mappings`, `analyticsCubes`, `connectors`, `data` (seed), `datasources`, `datasourceMapping`, `translations`, `i18n`, `plugins`, `devPlugins`, -`devHint`, `devLogins`, `requires`, `tiers`, `onEnable`. +`devHint`, `devLogins`, `requires`, `tiers`, `onEnable`, `appName`, `email`, `sms`. There is deliberately **no** top-level `workflows` or `approvals` collection: an approval is authored as a flow with Approval nodes (ADR-0019), and record @@ -81,8 +81,7 @@ keeping straight: For the exact Zod shape — including which keys are optional and what types the collection items take — read `node_modules/@objectstack/spec/src/stack.zod.ts` -(`ObjectStackDefinitionSchema`; the input type is -`ObjectStackDefinitionInput`). Each collection's item shape lives in +(`ObjectStackDefinitionSchema`). Each collection's item shape lives in its own domain folder (`data/object.zod.ts`, `ui/view.zod.ts`, …). → Moved verbatim to [references/bootstrap.md](./references/bootstrap.md) § Map Format (Key → Name) · § Barrel Import Pattern. From 4fdbd8611ffee329c33d6f5a1acce1efe25c3d8a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 05:08:02 +0000 Subject: [PATCH 3/5] wip(spec): pin SmsProviderSchema as isomorphic; llms.txt inventory counts Claude-Session: https://claude.ai/code/session_01S3aAf11JjbW1mSGL1EhfFj Co-authored-by: Claude --- packages/spec/llms.txt | 4 ++-- .../spec/src/type-alias-convention.pin.test.ts | 18 +++++++++++++++--- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/spec/llms.txt b/packages/spec/llms.txt index 7e9e524c457..badc9e9306a 100644 --- a/packages/spec/llms.txt +++ b/packages/spec/llms.txt @@ -77,7 +77,7 @@ const query = { --- -## 3. Schema Inventory by Domain (203 schemas) +## 3. Schema Inventory by Domain (204 schemas) Counted as `*.zod.ts` modules under `packages/spec/src//` — the sources that ship in this tarball (`files` includes `src/**/*.zod.ts`), so every number @@ -85,7 +85,7 @@ here is verifiable from the installed package. | Domain | Count | Key Schemas | |--------|-------|-------------| -| system | 34 | Auth, Cache, Compliance, Dev Login, Encryption, HTTP Server, License, Logging, Metrics | +| system | 35 | Auth, Cache, Dev Login, Email / SMS Config, Encryption, HTTP Server, License, Logging, Metrics | | kernel | 31 | Plugin, Manifest, Events (6 sub-modules), Feature, Context, Package Registry | | data | 30 | Object, Field, Picklist, Query, Filter, Driver (SQL/NoSQL/Memory/Mongo/Postgres), Cube | | api | 31 | Endpoint, REST Server, Discovery, OData, Batch, WebSocket, Response Envelope, Package Lifecycle, Package API (assembled stage) | diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index 1adb5627057..bf4d15db18d 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -272,9 +272,12 @@ import type * as M185 from './shared/epoch.zod.js'; import type * as M187 from './shared/duration.zod.js'; // [#18451] The build-progress PHASE vocabulary -- new module, next free index. import type * as M188 from './ai/build-progress.zod.js'; +// [#22748] The stack definition's SMS block and its provider vocabulary -- new +// module, next free index. +import type * as M189 from './system/sms-config.zod.js'; // --------------------------------------------------------------------------- -// 775 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. +// 776 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. // // That number is machine-checked, not hand-kept. The runtime companion at the // bottom of this file recomputes the pin count from the source and asserts that @@ -1373,6 +1376,9 @@ export type Iso_system_settingsManifest__SpecifierScopeSchema = Assert, z.infer< typeof M147.SpecifierType > >>; export type Iso_system_settingsManifest__SpecifierValueDomainSchema = Assert, z.infer< typeof M147.SpecifierValueDomainSchema > >>; +// system/sms-config.zod.ts +export type Iso_system_smsConfig__SmsProviderSchema = Assert, z.infer< typeof M189.SmsProviderSchema > >>; + // system/supplier-security.zod.ts export type Iso_system_supplierSecurity__SupplierAssessmentStatusSchema = Assert, z.infer< typeof M148.SupplierAssessmentStatusSchema > >>; export type Iso_system_supplierSecurity__SupplierRiskLevelSchema = Assert, z.infer< typeof M148.SupplierRiskLevelSchema > >>; @@ -1673,7 +1679,7 @@ describe('ADR-0122 type-alias convention', () => { // this title and the section header above the pin list — are now asserted // against the recomputed count below, so neither can go stale without a red // test naming it. - it('still declares all 775 isomorphic pins', () => { + it('still declares all 776 isomorphic pins', () => { // The truth of each pin is proved by tsc, not here — an `Assert>` // that stops holding is a compile error with the alias named. What tsc // cannot notice is a pin that was DELETED: removing the assertion removes @@ -2447,7 +2453,13 @@ describe('ADR-0122 type-alias convention', () => { // exported `UploadScopeSchema`, a bare enum with no default and no // transform, so it is pinned here (Iso_api_storage__UploadScopeSchema) // rather than given an `UploadScopeParsed` synonym. +1 added. - expect(pins).toHaveLength(775); + // + // 775 -> 776 is #22748: the stack definition's new `sms` block types its + // provider with the new exported `SmsProviderSchema`, a bare enum with no + // default and no transform, so it is pinned here + // (Iso_system_smsConfig__SmsProviderSchema) rather than given an + // `SmsProviderParsed` synonym. +1 added. + expect(pins).toHaveLength(776); // The count is stated in PROSE twice as well — this case's title and the // section header above the pin list — and until commit c6b05c76a nothing read either From 3a688300328a5e3e5b75f8c39165370b4a66ed5b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 06:45:14 +0000 Subject: [PATCH 4/5] chore(spec): regenerate the reference index from the merged tree Claude-Session: https://claude.ai/code/session_01S3aAf11JjbW1mSGL1EhfFj Co-authored-by: Claude --- content/docs/references/index.mdx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 654e2692f26..b616fe62ccf 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,7 +1,7 @@ --- title: Protocol reference — every schema by module navTitle: Protocol Reference -description: Every schema published by @objectstack/spec — 1520 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1521 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -23,7 +23,7 @@ counts are sums of the rows they head. Regenerate with | [AI Protocol](/docs/references/ai) | 12 | 68 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. | | [API Protocol](/docs/references/api) | 32 | 432 | REST contracts, endpoints, routing, realtime, batch, discovery. | | [Automation Protocol](/docs/references/automation) | 13 | 70 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | -| [Data Protocol](/docs/references/data) | 29 | 175 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | +| [Data Protocol](/docs/references/data) | 29 | 176 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | | [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. | | [Integration Protocol](/docs/references/integration) | 1 | 12 | The single connector protocol (ADR-0097) — catalog descriptors and provider-bound instances. | | [Kernel Protocol](/docs/references/kernel) | 30 | 157 | Plugin lifecycle and manifests, capabilities and security, metadata loading, service registry. | @@ -34,7 +34,7 @@ counts are sums of the rows they head. Regenerate with | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 35 | 279 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 166 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **196** | **1520** | 14 protocol modules | +| **Total** | **196** | **1521** | 14 protocol modules | --- @@ -130,7 +130,7 @@ Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execu ## Data Protocol -**Source:** `packages/spec/src/data/` · **Import:** `@objectstack/spec/data` · **29 pages, 175 schemas** +**Source:** `packages/spec/src/data/` · **Import:** `@objectstack/spec/data` · **29 pages, 176 schemas** Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. @@ -138,7 +138,7 @@ Objects, fields, queries, filters, datasources and drivers — the ObjectQL laye | :--- | :--- | | [`analytics.zod.ts`](/docs/references/data/analytics) | `AggregationMetricType`, `AnalyticsDateRange`, `AnalyticsDateRangePreset`, `AnalyticsQuery`, `Cube`, `CubeJoin`, `Dimension`, `DimensionType`, `Metric`, `TimeUpdateInterval` | | [`context-tokens.zod.ts`](/docs/references/data/context-tokens) | `ContextToken`, `ContextTokenPlaceholder` | -| [`data-engine.zod.ts`](/docs/references/data/data-engine) | `BaseEngineOptions`, `DataEngineAggregateOptions`, `DataEngineAggregateRequest`, `DataEngineCountOptions`, `DataEngineCountRequest`, `DataEngineDeleteOptions`, `DataEngineDeleteRequest`, `DataEngineExecuteRequest`, `DataEngineFilter`, `DataEngineFindOneRequest`, `DataEngineFindRequest`, `DataEngineInsertOptions`, `DataEngineInsertRequest`, `DataEngineQueryOptions`, `DataEngineRequest`, `DataEngineSort`, `DataEngineUpdateOptions`, `DataEngineUpdateRequest`, `DataEngineVectorFindRequest`, `DroppedFieldsEvent`, `EngineAggregateOptions`, `EngineCountOptions`, `EngineDeleteOptions`, `EngineQueryOptions`, `EngineUpdateOptions`, `QueryTransportParams`, `QueryWithTransport` | +| [`data-engine.zod.ts`](/docs/references/data/data-engine) | `BaseEngineOptions`, `DataEngineAggregateOptions`, `DataEngineAggregateRequest`, `DataEngineCountOptions`, `DataEngineCountRequest`, `DataEngineDeleteOptions`, `DataEngineDeleteRequest`, `DataEngineExecuteRequest`, `DataEngineFilter`, `DataEngineFindOneRequest`, `DataEngineFindRequest`, `DataEngineInsertOptions`, `DataEngineInsertRequest`, `DataEngineQueryOptions`, `DataEngineRequest`, `DataEngineSort`, `DataEngineUpdateOptions`, `DataEngineUpdateRequest`, `DataEngineVectorFindRequest`, `DroppedFieldsEvent`, `EngineAggregateOptions`, `EngineCountOptions`, `EngineDeleteOptions`, `EngineQueryOptions`, `EngineUpdateOptions`, `QueryTransportParams`, `QueryWithTransport`, `ValidationAdvisoryEvent` | | [`datasource.zod.ts`](/docs/references/data/datasource) | `Datasource`, `DriverDefinition`, `DriverType`, `ExternalDatasourceSettings`, `SchemaMode` | | [`date-macros.zod.ts`](/docs/references/data/date-macros) | `DateMacroPlaceholder`, `DateMacroToken` | | [`driver.zod.ts`](/docs/references/data/driver) | `DriverCapabilities`, `DriverConfig`, `DriverOptions`, `PoolConfig` | From 34286f646a982afcdc8ecdc962ad1afeaa93d043 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 07:01:04 +0000 Subject: [PATCH 5/5] fix(spec): refuse sms.provider by name; sms carries retries alone The stack cannot select the delivering SMS transport: the sms settings namespace builds it from its own provider setting, so a stack-declared provider is refused with the OS_SMS_PROVIDER / Settings prescription, as providerOptions is. SmsProviderSchema leaves with its only consumer. Claude-Session: https://claude.ai/code/session_01S3aAf11JjbW1mSGL1EhfFj Co-authored-by: Claude --- .changeset/22748-stack-email-sms-door.md | 23 +++-- content/docs/references/index.mdx | 10 +- content/docs/references/system/sms-config.mdx | 66 +++++++------ ...erve-stack-email-sms-door.contract.test.ts | 51 +++------- packages/spec/api-surface/system.json | 2 - packages/spec/authorable-surface/system.json | 1 - packages/spec/declaration-map/system.json | 2 - packages/spec/export-origins/system.json | 2 - .../spec/json-schema.manifest/system.json | 1 - packages/spec/src/stack-email-sms.test.ts | 42 ++++---- packages/spec/src/stack.zod.ts | 13 +-- packages/spec/src/system/sms-config.zod.ts | 95 +++++++++---------- .../src/type-alias-convention.pin.test.ts | 18 +--- 13 files changed, 145 insertions(+), 181 deletions(-) diff --git a/.changeset/22748-stack-email-sms-door.md b/.changeset/22748-stack-email-sms-door.md index 7c89cb21527..97a9d554e51 100644 --- a/.changeset/22748-stack-email-sms-door.md +++ b/.changeset/22748-stack-email-sms-door.md @@ -2,19 +2,18 @@ "@objectstack/spec": minor --- -feat(spec): the stack definition admits `appName` and the non-secret members of `email` and `sms`; their credentials are refused by name +feat(spec): the stack definition admits `appName`, the non-secret members of `email`, and `sms.retries`; credentials and the SMS provider are refused by name -Clause-②: yes (widening: the stack definition admits `appName` and the non-secret members of `email` and `sms`, which it refused as unrecognized keys) +Clause-②: yes (widening: the stack definition admits `appName`, the non-secret members of `email`, and `sms` carrying `retries` alone, which it refused as unrecognized keys) -- **What you can write now.** `defineStack()` accepts three top-level keys it used to refuse as unrecognized. The mail and SMS readers already read all three, and `objectstack serve` and `bootStack` construct the email and SMS providers through those readers: +- **What you can write now.** `defineStack()` accepts three top-level keys it used to refuse as unrecognized. The mail and SMS readers already read them, and `objectstack serve` and `bootStack` construct the email and SMS providers through those readers: - `appName`: the deployment's product name. It is the last configured rung of the name chain (`OS_APP_NAME` > `email.appName` > `email.defaultTemplateContext.appName` > `appName` > `'ObjectStack'`), and it reaches the email templates and AuthPlugin. - - `email` (`StackEmailConfigSchema`): every member of `EmailServiceConfigSchema` except `apiKey`. That is `provider`, `defaultFrom`, `retries`, `persist`, `queueDelivery`, `appName` and `defaultTemplateContext`, plus `options` narrowed to `host` / `port` / `secure` / `user` / `messageStream`. - - `sms` (`StackSmsConfigSchema`): `provider` (`SmsProviderSchema`: `log` / `aliyun` / `twilio`) and `retries`. - - `OS_EMAIL_*` and `OS_SMS_PROVIDER` still override the file per key. -- **What stays in the environment, and is refused by name.** A stack definition is compiled into the published artifact, so it does not carry credentials. Each refusal names the key and the variable that replaces it: - - `email.apiKey` and `email.options.apiKey`: set `OS_EMAIL_API_KEY`. - - `email.options.password`: set `OS_EMAIL_SMTP_PASSWORD`. The reader layers it over the host / port / secure / user the file carries. - - `sms.providerOptions`: configure the provider in Setup → Settings → SMS Delivery, or through that namespace's overrides (`OS_SMS_PROVIDER`, `OS_SMS_TWILIO_*`, `OS_SMS_ALIYUN_*`). It is refused whole, because every non-`log` SMS transport needs a credential inside it and nothing merges an environment credential into it. + - `email` (`StackEmailConfigSchema`): every member of `EmailServiceConfigSchema` except `apiKey`. That is `provider`, `defaultFrom`, `retries`, `persist`, `queueDelivery`, `appName` and `defaultTemplateContext`, plus `options` narrowed to `host` / `port` / `secure` / `user` / `messageStream`. `OS_EMAIL_*` still overrides it per key. + - `sms` (`StackSmsConfigSchema`): `retries`, the SMS service's attempt budget. It applies to whichever transport the `sms` settings namespace selects. +- **What stays elsewhere, and is refused by name.** Each refusal names the key and the setting that replaces it: + - `email.apiKey` and `email.options.apiKey`: a credential, and a stack definition is compiled into the published artifact. Set `OS_EMAIL_API_KEY`. + - `email.options.password`: a credential. Set `OS_EMAIL_SMTP_PASSWORD`; the reader layers it over the host / port / secure / user the file carries. + - `sms.provider`, `sms.providerOptions` (and a flat `authToken` / `accessKeySecret`): the delivering SMS transport is built by the `sms` settings namespace from its own provider and credentials, never from a stack definition. Configure them in Setup → Settings → SMS Delivery, or through that namespace's overrides (`OS_SMS_PROVIDER`, `OS_SMS_TWILIO_*`, `OS_SMS_ALIYUN_*`). - **Composition.** All three keys compose as single values. `composeStacks` passes an identical value through and refuses two different ones by key. -- **Nothing to migrate.** No stack that was accepted before is refused now. A configuration that set these keys only in an object `defineStack` did not build (the one `objectstack serve` boots) reads the same as before. To build it, move any credential it carries into the environment. -- New exports from `@objectstack/spec/system`: `StackEmailConfigSchema`, `StackEmailProviderOptionsSchema`, `StackSmsConfigSchema`, `SmsProviderSchema`, and their `z.input` / `z.infer` types. +- **Nothing to migrate.** No stack that was accepted before is refused now. A configuration that set these keys only in an object `defineStack` did not build (the one `objectstack serve` boots) reads the same as before. To build it, move any credential, and the SMS provider, into the environment or Settings. +- New exports from `@objectstack/spec/system`: `StackEmailConfigSchema`, `StackEmailProviderOptionsSchema`, `StackSmsConfigSchema`, and their `z.input` / `z.infer` types. diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index b616fe62ccf..bc2c5bfb412 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,7 +1,7 @@ --- title: Protocol reference — every schema by module navTitle: Protocol Reference -description: Every schema published by @objectstack/spec — 1521 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1520 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -32,9 +32,9 @@ counts are sums of the rows they head. Regenerate with | [Security Protocol](/docs/references/security) | 5 | 30 | Permission sets, row-level security, sharing rules, tenancy posture. | | [Shared Protocol](/docs/references/shared) | 10 | 31 | Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. | | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | -| [System Protocol](/docs/references/system) | 35 | 279 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | +| [System Protocol](/docs/references/system) | 35 | 278 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 166 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **196** | **1521** | 14 protocol modules | +| **Total** | **196** | **1520** | 14 protocol modules | --- @@ -317,7 +317,7 @@ Studio designer metadata — the authoring surfaces for the protocols above. ## System Protocol -**Source:** `packages/spec/src/system/` · **Import:** `@objectstack/spec/system` · **35 pages, 279 schemas** +**Source:** `packages/spec/src/system/` · **Import:** `@objectstack/spec/system` · **35 pages, 278 schemas** The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. @@ -351,7 +351,7 @@ The runtime environment — logging, jobs, cache, metrics, notifications, i18n a | [`security-context.zod.ts`](/docs/references/system/security-context) | `ComplianceAuditRequirement`, `ComplianceEncryptionRequirement`, `ComplianceFramework`, `DataClassification`, `DataClassificationPolicy`, `MaskingVisibilityRule`, `SecurityContextConfig`, `SecurityEventCorrelation` | | [`settings-client.zod.ts`](/docs/references/system/settings-client) | `SettingsChangeEvent` | | [`settings-manifest.zod.ts`](/docs/references/system/settings-manifest) | `ResolvedSettingValue`, `SettingsActionResult`, `SettingsManifest`, `SettingsNamespacePayload`, `Specifier`, `SpecifierHandler`, `SpecifierOption`, `SpecifierScope`, `SpecifierType`, `SpecifierValueDomain` | -| [`sms-config.zod.ts`](/docs/references/system/sms-config) | `SmsProvider`, `StackSmsConfig` | +| [`sms-config.zod.ts`](/docs/references/system/sms-config) | `StackSmsConfig` | | [`stack-server.zod.ts`](/docs/references/system/stack-server) | `ServerRateLimitConfig`, `StackServerConfig`, `StackServerSecurity` | | [`supplier-security.zod.ts`](/docs/references/system/supplier-security) | `SupplierAssessmentStatus`, `SupplierRiskLevel`, `SupplierSecurityAssessment`, `SupplierSecurityPolicy`, `SupplierSecurityRequirement` | | [`tenant.zod.ts`](/docs/references/system/tenant) | `DatabaseLevelIsolationStrategy`, `DatabaseProvider`, `QuotaEnforcementResult`, `RowLevelIsolationStrategy`, `SchemaLevelIsolationStrategy`, `Tenant`, `TenantConnectionConfig`, `TenantIsolationConfig`, `TenantIsolationLevel`, `TenantQuota`, `TenantSecurityPolicy`, `TenantUsage` | diff --git a/content/docs/references/system/sms-config.mdx b/content/docs/references/system/sms-config.mdx index f17c6b5124d..97f19a54ed7 100644 --- a/content/docs/references/system/sms-config.mdx +++ b/content/docs/references/system/sms-config.mdx @@ -12,29 +12,47 @@ There was no SMS configuration schema in the spec before this file. The shape below is typed from what its one reader reads: `resolveSmsCapabilityArg` in `@objectstack/service-sms`, which `objectstack serve` and `bootStack` both construct `SmsServicePlugin` -through. It reads three keys off `config.sms`: - -| key | secret? | here | -|---|---|---| -| `provider` | no — a transport tag | admitted (`SmsProviderSchema`); `OS_SMS_PROVIDER` wins | -| `retries` | no | admitted | -| `providerOptions` | YES — every non-`log` transport requires a credential inside it (Twilio `authToken`, Aliyun `accessKeySecret`) | refused by name | - -`providerOptions` is refused WHOLE rather than narrowed to its non-secret +through. It reads three keys off `config.sms`, and only one of them can do +from a stack definition what it says: + +| key | secret? | delivered from a stack? | here | +|---|---|---|---| +| `retries` | no | yes | admitted | +| `provider` | no — a transport tag | NO, measured | refused by name | +| `providerOptions` | YES — every non-`log` transport requires a credential inside it (Twilio `authToken`, Aliyun `accessKeySecret`) | — | refused by name | + +**`retries`** is the SMS service's own attempt budget. The service keeps it +when the `sms` settings namespace swaps its transport at `kernel:ready` +(`setTransport` replaces the transport and nothing else), so it applies to +whichever transport ends up delivering. It has no environment or settings +carrier, so this block is its only authoring door. + +**`provider`** cannot select a delivering transport from here. A stack is +not allowed credentials (below), so the transport the plugin constructs from +this tag falls back to its log transport, and the transport that DOES +deliver is the one the settings namespace builds at `kernel:ready` from its +own `provider` setting — it never reads this one. Measured through +`bootStack`: `sms: { provider: 'twilio' }` with the Twilio credentials in the +environment boots with `isConfigured() === false`; adding `OS_SMS_PROVIDER` +makes it `true`. A declared key the runtime does not honour is refused rather +than admitted, with the setting that does work as its prescription. It comes +back, with a provider vocabulary, in the change that makes `SmsServicePlugin` +honour it. + +**`providerOptions`** is refused WHOLE rather than narrowed to its non-secret members, and the difference from mail's `options` is measured, not a preference. The mail reader layers `OS_EMAIL_SMTP_*` over `config.email.options` per key, so a file carrying host and user composes with an environment carrying the password. The SMS reader hands `providerOptions` to the transport as written, and nothing merges an environment credential into it: a Twilio or Aliyun transport built from the non-secret members alone cannot -be constructed, so the plugin falls back to its log transport. A narrowed -`providerOptions` would therefore be a declared key no deployment could ever -deliver through. SMS provider settings — credentials included — live in the -`sms` settings namespace (Setup → Settings → SMS Delivery), whose every key -accepts its `OS_SMS_*` environment override, and the refusal says so. +be constructed, so the plugin falls back to its log transport. Why the credentials stop at this door at all: the stack definition is what `objectstack build` compiles into the artifact, and artifacts are published. +SMS provider settings — the provider and its credentials — live in the `sms` +settings namespace (Setup → Settings → SMS Delivery), whose every key +accepts its `OS_SMS_*` environment override; every refusal here says so. **Source:** `packages/spec/src/system/sms-config.zod.ts` @@ -43,24 +61,13 @@ Why the credentials stop at this door at all: the stack definition is what ## TypeScript Usage ```typescript -import { SmsProviderSchema, StackSmsConfigSchema } from '@objectstack/spec/system'; -import type { SmsProvider, StackSmsConfig } from '@objectstack/spec/system'; +import { StackSmsConfigSchema } from '@objectstack/spec/system'; +import type { StackSmsConfig } from '@objectstack/spec/system'; // Validate data -const result = SmsProviderSchema.parse(data); +const result = StackSmsConfigSchema.parse(data); ``` ---- - -## SmsProvider - -### Allowed Values - -* `log` -* `aliyun` -* `twilio` - - --- ## StackSmsConfig @@ -69,8 +76,7 @@ const result = SmsProviderSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **provider** | `Enum<'log' \| 'aliyun' \| 'twilio'>` | optional | SMS transport tag (OS_SMS_PROVIDER env wins). Default log. Credentials never come from the stack: the delivering aliyun / twilio transport is built at kernel:ready from the sms settings namespace (Setup → Settings → SMS Delivery, or its OS_SMS_* env overrides), whose own provider setting decides | -| **retries** | `integer` | optional | Retry attempts on transport throw (default 0) | +| **retries** | `integer` | optional | Retry attempts on transport throw (default 0); applies to whichever transport the sms settings namespace selects. The provider and its credentials are not authorable here: Setup → Settings → SMS Delivery, or OS_SMS_PROVIDER and the other OS_SMS_* overrides | --- diff --git a/packages/cli/src/commands/serve-stack-email-sms-door.contract.test.ts b/packages/cli/src/commands/serve-stack-email-sms-door.contract.test.ts index 851a304ac57..a2d64612b50 100644 --- a/packages/cli/src/commands/serve-stack-email-sms-door.contract.test.ts +++ b/packages/cli/src/commands/serve-stack-email-sms-door.contract.test.ts @@ -1,8 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. // // A `defineStack`-built `email` / `sms` / `appName` reaches the providers -// `objectstack serve` constructs — and the SMS provider vocabulary the stack -// declares is the one the transports can build. +// `objectstack serve` constructs. // // The stack definition used to refuse all three keys while the mail and SMS // readers read them, so the configuration arm of both readers was reachable @@ -17,23 +16,16 @@ // pinned by the source scan in `serve-auth-app-name.contract.test.ts`; the two // files together are the reading from authored stack to constructed provider. // -// The second half is a CROSS-PACKAGE vocabulary assertion, for the reason -// `@objectstack/plugin-email`'s `spec-provider-parity.contract.test.ts` gives -// for mail: `SmsProviderSchema` (the authoring contract) and -// `SMS_TRANSPORT_PROVIDERS` (the array `makeSmsTransport` switches on) are two -// literals describing one vocabulary, and two literals left alone drift. It -// lives here because this package depends on both `@objectstack/spec` and -// `@objectstack/service-sms`; the SMS package's own tests read no spec value. -// This package's `tsconfig.json` includes `src`, tests and all, so the -// compile-time half below is real. +// `sms` carries `retries` alone: the stack refuses `sms.provider` by name, +// because the delivering SMS transport is built by the `sms` settings +// namespace from its own provider setting (`OS_SMS_PROVIDER`), so the provider +// below always comes from the environment. import { describe, it, expect } from 'vitest'; import { resolveCapabilityArgument } from '@objectstack/core'; import { defineStack } from '@objectstack/spec'; -import { SmsProviderSchema, type SmsProvider } from '@objectstack/spec/system'; import * as pluginEmail from '@objectstack/plugin-email'; import * as serviceSms from '@objectstack/service-sms'; -import { SMS_TRANSPORT_PROVIDERS, type SmsProviderTag } from '@objectstack/service-sms'; const manifest = { id: 'com.example.mailer', @@ -79,17 +71,17 @@ describe('a defineStack-built email / sms / appName reaches the providers serve expect(pluginEmail.resolveDeploymentAppName(stack.email ?? {}, {}, stack.appName)).toBe('Acme'); }); - it('sms { provider, retries } reaches the SMS provider', () => { - const stack = defineStack({ manifest, sms: { provider: 'twilio', retries: 3 } }); - expect(argumentFor('sms', stack, {})).toEqual({ provider: 'twilio', retries: 3 }); + it('sms { retries } reaches the SMS provider, beside the provider OS_SMS_PROVIDER names', () => { + const stack = defineStack({ manifest, sms: { retries: 3 } }); + expect(argumentFor('sms', stack, {})).toEqual({ provider: 'log', retries: 3 }); + expect(argumentFor('sms', stack, { OS_SMS_PROVIDER: 'twilio' })).toEqual({ provider: 'twilio', retries: 3 }); }); - it('OS_EMAIL_* / OS_SMS_PROVIDER still override the stack per key', () => { - const stack = defineStack({ manifest, email: { provider: 'log', retries: 1 }, sms: { provider: 'log' } }); + it('OS_EMAIL_* still override the stack per key', () => { + const stack = defineStack({ manifest, email: { provider: 'log', retries: 1 } }); const email = argumentFor('email', stack, { OS_EMAIL_PROVIDER: 'smtp', OS_EMAIL_SMTP_HOST: 'relay.example', OS_EMAIL_RETRIES: '4' }); expect(email.provider).toBe('smtp'); expect(email.retries).toBe(4); - expect(argumentFor('sms', stack, { OS_SMS_PROVIDER: 'aliyun' }).provider).toBe('aliyun'); }); it('CONTROL — env-only configuration is unchanged: a stack with no block builds what the reader builds from no config', () => { @@ -110,24 +102,3 @@ describe('a defineStack-built email / sms / appName reaches the providers serve expect(argumentFor('sms', stack, env)).toEqual({ provider: 'aliyun' }); }); }); - -/** - * Compile-time halves of the vocabulary invariant: each union must be - * assignable to the other, so a member added on one side only makes `true` - * unassignable — a type error in this package's `typecheck`. - */ -type SpecAssignableToTransport = SmsProvider extends SmsProviderTag ? true : never; -type TransportAssignableToSpec = SmsProviderTag extends SmsProvider ? true : never; -const MUTUALLY_ASSIGNABLE: [SpecAssignableToTransport, TransportAssignableToSpec] = [true, true]; - -describe('SmsProviderSchema ↔ SMS_TRANSPORT_PROVIDERS', () => { - it('declares exactly the providers the SMS transports can build, both directions', () => { - expect(MUTUALLY_ASSIGNABLE).toEqual([true, true]); - const declared = [...SmsProviderSchema.options].sort(); - const buildable = [...SMS_TRANSPORT_PROVIDERS].sort(); - expect(declared.filter((p) => !buildable.includes(p as SmsProviderTag))).toEqual([]); - expect(buildable.filter((p) => !declared.includes(p))).toEqual([]); - // Anti-vacuity: two empty lists would agree. - expect(buildable.length).toBeGreaterThan(1); - }); -}); diff --git a/packages/spec/api-surface/system.json b/packages/spec/api-surface/system.json index 8c89382653b..98ec9a151e2 100644 --- a/packages/spec/api-surface/system.json +++ b/packages/spec/api-surface/system.json @@ -615,8 +615,6 @@ "SettingsUnsubscribe (type)", "Sha256Digest (type)", "Sha256DigestSchema (const)", - "SmsProvider (type)", - "SmsProviderSchema (const)", "SocialProviderConfig (type)", "SocialProviderConfigParsed (type)", "SocialProviderConfigSchema (const)", diff --git a/packages/spec/authorable-surface/system.json b/packages/spec/authorable-surface/system.json index 273a39aade9..1e1f8beb211 100644 --- a/packages/spec/authorable-surface/system.json +++ b/packages/spec/authorable-surface/system.json @@ -1125,7 +1125,6 @@ "system/StackServerConfig:security", "system/StackServerConfig:trustProxy", "system/StackServerSecurity:rateLimit", - "system/StackSmsConfig:provider", "system/StackSmsConfig:retries", "system/StorageConnection:accessKeyId", "system/StorageConnection:accountKey", diff --git a/packages/spec/declaration-map/system.json b/packages/spec/declaration-map/system.json index 10ccbdc4281..8e9c821f02e 100644 --- a/packages/spec/declaration-map/system.json +++ b/packages/spec/declaration-map/system.json @@ -398,8 +398,6 @@ "SettingsNamespacePayloadSchema": "system/SettingsNamespacePayload", "Sha256Digest": "system/Sha256Digest", "Sha256DigestSchema": "system/Sha256Digest", - "SmsProvider": "system/SmsProvider", - "SmsProviderSchema": "system/SmsProvider", "SocialProviderConfig": "system/SocialProviderConfig", "SocialProviderConfigSchema": "system/SocialProviderConfig", "Span": "system/Span", diff --git a/packages/spec/export-origins/system.json b/packages/spec/export-origins/system.json index 5c746e12918..241dbee055f 100644 --- a/packages/spec/export-origins/system.json +++ b/packages/spec/export-origins/system.json @@ -589,8 +589,6 @@ "SettingsUnsubscribe": "src/system/settings-client.zod.ts#SettingsUnsubscribe (type)", "Sha256Digest": "src/system/environment-artifact.zod.ts#Sha256Digest (type)", "Sha256DigestSchema": "src/system/environment-artifact.zod.ts#Sha256DigestSchema (const)", - "SmsProvider": "src/system/sms-config.zod.ts#SmsProvider (type)", - "SmsProviderSchema": "src/system/sms-config.zod.ts#SmsProviderSchema (const)", "SocialProviderConfig": "src/system/auth-config.zod.ts#SocialProviderConfig (type)", "SocialProviderConfigParsed": "src/system/auth-config.zod.ts#SocialProviderConfigParsed (type)", "SocialProviderConfigSchema": "src/system/auth-config.zod.ts#SocialProviderConfigSchema (const)", diff --git a/packages/spec/json-schema.manifest/system.json b/packages/spec/json-schema.manifest/system.json index d4b0c071502..3aef2c59248 100644 --- a/packages/spec/json-schema.manifest/system.json +++ b/packages/spec/json-schema.manifest/system.json @@ -217,7 +217,6 @@ "system/SettingsManifest", "system/SettingsNamespacePayload", "system/Sha256Digest", - "system/SmsProvider", "system/SocialProviderConfig", "system/Span", "system/SpanAttributeValue", diff --git a/packages/spec/src/stack-email-sms.test.ts b/packages/spec/src/stack-email-sms.test.ts index 3829a9fc896..eb7dc1b59c4 100644 --- a/packages/spec/src/stack-email-sms.test.ts +++ b/packages/spec/src/stack-email-sms.test.ts @@ -13,7 +13,10 @@ * The delta is: the three keys are writable, carrying the NON-SECRET members the * readers read, and the credentials are refused BY NAME with the environment * variable that carries them. A stack definition is compiled into the - * published artifact, which is why the credentials stop here. + * published artifact, which is why the credentials stop here. `sms` carries + * `retries` alone: `sms.provider` is refused by name too, because a stack + * cannot select the delivering SMS transport — the `sms` settings namespace + * builds it from its own provider setting (`OS_SMS_PROVIDER`). * * ## Why the refusals assert the envelope and the issue, never `toThrow()` * @@ -33,7 +36,7 @@ import { STACK_DEFINITION_KEYS, } from './stack.zod'; import { EmailServiceConfigSchema, StackEmailConfigSchema } from './system/email-config.zod'; -import { SmsProviderSchema, StackSmsConfigSchema } from './system/sms-config.zod'; +import { StackSmsConfigSchema } from './system/sms-config.zod'; const manifest = { id: 'com.example.mailer', @@ -95,9 +98,9 @@ describe('accept — appName, email and sms carry the non-secret configuration', expect(stack.email).toEqual(email); }); - it('carries the SMS provider tag and retries', () => { - const stack = defineStack({ manifest, sms: { provider: 'twilio', retries: 1 } }); - expect(stack.sms).toEqual({ provider: 'twilio', retries: 1 }); + it('carries the SMS retries — the one SMS member a stack can deliver', () => { + const stack = defineStack({ manifest, sms: { retries: 1 } }); + expect(stack.sms).toEqual({ retries: 1 }); }); it('the email block is DERIVED from EmailServiceConfigSchema: every member except the credential apiKey', () => { @@ -153,8 +156,23 @@ describe('refuse — a credential on the stack names itself and its environment expect(issue?.message).toContain('OS_EMAIL_API_KEY'); }); + it('sms.provider is refused by name, prescribing OS_SMS_PROVIDER and Settings — a stack cannot select the delivering transport', () => { + const raw = { manifest, sms: { provider: 'twilio', retries: 1 } }; + const issue = unrecognized(parse(raw)); + expect(issue?.path).toEqual(['sms']); + expect(issue?.keys).toEqual(['provider']); + expect(issue?.message).toContain('OS_SMS_PROVIDER'); + expect(issue?.message).toContain('Settings → SMS Delivery'); + + const err = refusalOf(raw); + expect(err.code).toBe('STACK_SCHEMA_INVALID'); + expect(err.status).toBe(422); + expect(err.message).toContain('`provider`'); + expect(err.message).toContain('OS_SMS_PROVIDER'); + }); + it('sms.providerOptions is refused by name, prescribing the sms settings namespace and its OS_SMS_* overrides', () => { - const raw = { manifest, sms: { provider: 'twilio', providerOptions: { accountSid: 'AC1', authToken: 'secret', from: '+15005550006' } } }; + const raw = { manifest, sms: { providerOptions: { accountSid: 'AC1', authToken: 'secret', from: '+15005550006' } } }; const issue = unrecognized(parse(raw)); expect(issue?.path).toEqual(['sms']); expect(issue?.keys).toEqual(['providerOptions']); @@ -167,19 +185,11 @@ describe('refuse — a credential on the stack names itself and its environment }); it('an SMS credential written flat on the block gets the same prescription', () => { - const issue = unrecognized(parse({ manifest, sms: { provider: 'twilio', authToken: 'secret' } })); + const issue = unrecognized(parse({ manifest, sms: { authToken: 'secret' } })); expect(issue?.keys).toEqual(['authToken']); expect(issue?.message).toContain('OS_SMS_TWILIO_AUTH_TOKEN'); }); - it('an SMS provider tag no transport builds is refused at authoring', () => { - const verdict = parse({ manifest, sms: { provider: 'twilo' } }); - expect(verdict.success).toBe(false); - if (verdict.success) return; - expect(verdict.error.issues.map((i) => `${i.code} @ ${i.path.map(String).join('.')}`)).toContain('invalid_value @ sms.provider'); - expect(SmsProviderSchema.options).toEqual(['log', 'aliyun', 'twilio']); - }); - it('`from` is pointed at `defaultFrom`', () => { const issue = unrecognized(parse({ manifest, email: { from: { address: 'a@b.example' } } })); expect(issue?.keys).toEqual(['from']); @@ -210,7 +220,7 @@ describe('control — the door opened for exactly these keys', () => { it('the narrowed blocks are closed: an undocumented key is refused, not shipped into the artifact', () => { expect(unrecognized(parse({ manifest, email: { options: { transportOptions: { auth: { pass: 'x' } } } } }))?.keys) .toEqual(['transportOptions']); - expect(StackSmsConfigSchema.safeParse({ provider: 'log', bogus: 1 }).success).toBe(false); + expect(StackSmsConfigSchema.safeParse({ retries: 1, bogus: 1 }).success).toBe(false); }); }); diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index 7a354e373c8..a70d6663b3e 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -618,15 +618,16 @@ const STACK_DEFINITION_COLLECTIONS_SHAPE = { ), /** - * SMS configuration — {@link StackSmsConfigSchema}: the non-secret members - * `resolveSmsCapabilityArg` reads. Provider credentials (`providerOptions`) - * are refused here by name; they live in the `sms` settings namespace and - * its `OS_SMS_*` environment overrides. + * SMS configuration — {@link StackSmsConfigSchema}: `retries`, the one + * member `resolveSmsCapabilityArg` reads that a stack can deliver. The + * provider and its credentials (`provider`, `providerOptions`) are refused + * here by name: the delivering transport is built by the `sms` settings + * namespace from its own settings and their `OS_SMS_*` environment overrides. */ sms: StackSmsConfigSchema.optional() .describe( - 'SMS configuration (provider tag and retries; OS_SMS_PROVIDER env wins). Provider credentials are not ' - + 'authorable here: Setup → Settings → SMS Delivery, or its OS_SMS_* env overrides', + 'SMS configuration — retries only. The provider and its credentials are not authorable here: ' + + 'Setup → Settings → SMS Delivery, or OS_SMS_PROVIDER and the other OS_SMS_* env overrides', ), /** diff --git a/packages/spec/src/system/sms-config.zod.ts b/packages/spec/src/system/sms-config.zod.ts index fa7e008fa8a..0015c421bb5 100644 --- a/packages/spec/src/system/sms-config.zod.ts +++ b/packages/spec/src/system/sms-config.zod.ts @@ -11,65 +11,65 @@ import { strictObject } from '../shared/strict-object'; * shape below is typed from what its one reader reads: * `resolveSmsCapabilityArg` in `@objectstack/service-sms`, which * `objectstack serve` and `bootStack` both construct `SmsServicePlugin` - * through. It reads three keys off `config.sms`: + * through. It reads three keys off `config.sms`, and only one of them can do + * from a stack definition what it says: * - * | key | secret? | here | - * |---|---|---| - * | `provider` | no — a transport tag | admitted ({@link SmsProviderSchema}); `OS_SMS_PROVIDER` wins | - * | `retries` | no | admitted | - * | `providerOptions` | YES — every non-`log` transport requires a credential inside it (Twilio `authToken`, Aliyun `accessKeySecret`) | refused by name | + * | key | secret? | delivered from a stack? | here | + * |---|---|---|---| + * | `retries` | no | yes | admitted | + * | `provider` | no — a transport tag | NO, measured | refused by name | + * | `providerOptions` | YES — every non-`log` transport requires a credential inside it (Twilio `authToken`, Aliyun `accessKeySecret`) | — | refused by name | * - * `providerOptions` is refused WHOLE rather than narrowed to its non-secret + * **`retries`** is the SMS service's own attempt budget. The service keeps it + * when the `sms` settings namespace swaps its transport at `kernel:ready` + * (`setTransport` replaces the transport and nothing else), so it applies to + * whichever transport ends up delivering. It has no environment or settings + * carrier, so this block is its only authoring door. + * + * **`provider`** cannot select a delivering transport from here. A stack is + * not allowed credentials (below), so the transport the plugin constructs from + * this tag falls back to its log transport, and the transport that DOES + * deliver is the one the settings namespace builds at `kernel:ready` from its + * own `provider` setting — it never reads this one. Measured through + * `bootStack`: `sms: { provider: 'twilio' }` with the Twilio credentials in the + * environment boots with `isConfigured() === false`; adding `OS_SMS_PROVIDER` + * makes it `true`. A declared key the runtime does not honour is refused rather + * than admitted, with the setting that does work as its prescription. It comes + * back, with a provider vocabulary, in the change that makes `SmsServicePlugin` + * honour it. + * + * **`providerOptions`** is refused WHOLE rather than narrowed to its non-secret * members, and the difference from mail's `options` is measured, not a * preference. The mail reader layers `OS_EMAIL_SMTP_*` over `config.email.options` * per key, so a file carrying host and user composes with an environment * carrying the password. The SMS reader hands `providerOptions` to the * transport as written, and nothing merges an environment credential into it: * a Twilio or Aliyun transport built from the non-secret members alone cannot - * be constructed, so the plugin falls back to its log transport. A narrowed - * `providerOptions` would therefore be a declared key no deployment could ever - * deliver through. SMS provider settings — credentials included — live in the - * `sms` settings namespace (Setup → Settings → SMS Delivery), whose every key - * accepts its `OS_SMS_*` environment override, and the refusal says so. + * be constructed, so the plugin falls back to its log transport. * * Why the credentials stop at this door at all: the stack definition is what * `objectstack build` compiles into the artifact, and artifacts are published. + * SMS provider settings — the provider and its credentials — live in the `sms` + * settings namespace (Setup → Settings → SMS Delivery), whose every key + * accepts its `OS_SMS_*` environment override; every refusal here says so. */ -/** - * SMS transport selector — the tags `@objectstack/service-sms` can build a - * transport for. - * - * - `log` — LogSmsTransport (development; logs, never delivers). - * - `aliyun` — Aliyun SMS (template-only). - * - `twilio` — Twilio Programmable Messaging. - * - * This list is the authoring-contract side of one vocabulary: the other sides - * are `SMS_TRANSPORT_PROVIDERS` in `@objectstack/service-sms` (the array - * `makeSmsTransport` switches on) and the `sms` settings manifest's provider - * options. A cross-package contract test in `@objectstack/cli`, which depends - * on both packages, holds this enum equal to `SMS_TRANSPORT_PROVIDERS` in both - * directions — a tag declared here with no transport behind it would be a - * provider no deployment can deliver through, and the reader refuses it at boot. - */ -export const SmsProviderSchema = lazySchema(() => z.enum(['log', 'aliyun', 'twilio'])); -export type SmsProvider = z.input; - /** * The one prescription for SMS provider settings written into a stack - * definition — the key that carries them, and the credential names an author - * might write flat on the block. + * definition — the provider tag, the key that carries the credentials, and the + * credential names an author might write flat on the block. */ const STACK_SMS_PROVIDER_SETTINGS_PRESCRIPTION = - 'Not authorable here. SMS provider settings carry the provider credentials (Twilio auth token, Aliyun ' - + 'AccessKey secret), and a stack definition is compiled into the published artifact. Configure them in ' - + 'Setup → Settings → SMS Delivery, or through that namespace\'s environment overrides: OS_SMS_PROVIDER plus ' + 'Not authorable here. The SMS provider and its credentials are chosen by the sms settings namespace, which ' + + 'builds the delivering transport from its own settings, never from a stack definition (and a stack ' + + 'definition, compiled into the published artifact, carries no credentials). Configure them in Setup → ' + + 'Settings → SMS Delivery, or through that namespace\'s environment overrides: OS_SMS_PROVIDER plus ' + 'OS_SMS_TWILIO_ACCOUNT_SID / OS_SMS_TWILIO_AUTH_TOKEN / OS_SMS_TWILIO_FROM_NUMBER (Twilio) or ' + 'OS_SMS_ALIYUN_ACCESS_KEY_ID / OS_SMS_ALIYUN_ACCESS_KEY_SECRET / OS_SMS_ALIYUN_SIGN_NAME (Aliyun).'; /** - * `sms` of a stack definition — the non-secret members `resolveSmsCapabilityArg` - * reads off `config.sms`. See the file header for the census. + * `sms` of a stack definition — the one member `resolveSmsCapabilityArg` reads + * off `config.sms` that a stack can deliver. See the file header for the census. */ export const StackSmsConfigSchema = lazySchema(() => strictObject( { @@ -78,6 +78,7 @@ export const StackSmsConfigSchema = lazySchema(() => strictObject( 'This block is strict from birth — a stack definition carried no `sms` key before it, so an unknown ' + 'key here was never accepted.', guidance: { + provider: STACK_SMS_PROVIDER_SETTINGS_PRESCRIPTION, providerOptions: STACK_SMS_PROVIDER_SETTINGS_PRESCRIPTION, authToken: STACK_SMS_PROVIDER_SETTINGS_PRESCRIPTION, accessKeySecret: STACK_SMS_PROVIDER_SETTINGS_PRESCRIPTION, @@ -85,20 +86,16 @@ export const StackSmsConfigSchema = lazySchema(() => strictObject( }, { /** - * The tag `SmsServicePlugin` is constructed with. The credentials never - * come from here, so for `aliyun` / `twilio` the delivering transport is - * the one the `sms` settings namespace builds at `kernel:ready` — from its - * own `provider` setting (`OS_SMS_PROVIDER` is that setting's override as - * well as this key's). + * Retry attempts on transport throw. Default 0. The SMS service keeps it + * across the settings namespace's transport swap, so it applies to the + * transport that delivers. */ - provider: SmsProviderSchema.optional() + retries: z.number().int().min(0).optional() .describe( - 'SMS transport tag (OS_SMS_PROVIDER env wins). Default log. Credentials never come from the stack: ' - + 'the delivering aliyun / twilio transport is built at kernel:ready from the sms settings namespace ' - + '(Setup → Settings → SMS Delivery, or its OS_SMS_* env overrides), whose own provider setting decides', + 'Retry attempts on transport throw (default 0); applies to whichever transport the sms settings ' + + 'namespace selects. The provider and its credentials are not authorable here: Setup → Settings → ' + + 'SMS Delivery, or OS_SMS_PROVIDER and the other OS_SMS_* overrides', ), - /** Retry attempts on transport throw. Default 0. */ - retries: z.number().int().min(0).optional().describe('Retry attempts on transport throw (default 0)'), }, )); export type StackSmsConfig = z.input; diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index 1ca3f411f41..e677bf0cdf4 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -272,12 +272,9 @@ import type * as M185 from './shared/epoch.zod.js'; import type * as M187 from './shared/duration.zod.js'; // [#18451] The build-progress PHASE vocabulary -- new module, next free index. import type * as M188 from './ai/build-progress.zod.js'; -// [#22748] The stack definition's SMS block and its provider vocabulary -- new -// module, next free index. -import type * as M189 from './system/sms-config.zod.js'; // --------------------------------------------------------------------------- -// 777 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. +// 776 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. // // That number is machine-checked, not hand-kept. The runtime companion at the // bottom of this file recomputes the pin count from the source and asserts that @@ -1377,9 +1374,6 @@ export type Iso_system_settingsManifest__SpecifierScopeSchema = Assert, z.infer< typeof M147.SpecifierType > >>; export type Iso_system_settingsManifest__SpecifierValueDomainSchema = Assert, z.infer< typeof M147.SpecifierValueDomainSchema > >>; -// system/sms-config.zod.ts -export type Iso_system_smsConfig__SmsProviderSchema = Assert, z.infer< typeof M189.SmsProviderSchema > >>; - // system/supplier-security.zod.ts export type Iso_system_supplierSecurity__SupplierAssessmentStatusSchema = Assert, z.infer< typeof M148.SupplierAssessmentStatusSchema > >>; export type Iso_system_supplierSecurity__SupplierRiskLevelSchema = Assert, z.infer< typeof M148.SupplierRiskLevelSchema > >>; @@ -1680,7 +1674,7 @@ describe('ADR-0122 type-alias convention', () => { // this title and the section header above the pin list — are now asserted // against the recomputed count below, so neither can go stale without a red // test naming it. - it('still declares all 777 isomorphic pins', () => { + it('still declares all 776 isomorphic pins', () => { // The truth of each pin is proved by tsc, not here — an `Assert>` // that stops holding is a compile error with the alias named. What tsc // cannot notice is a pin that was DELETED: removing the assertion removes @@ -2461,13 +2455,7 @@ describe('ADR-0122 type-alias convention', () => { // tree, so it is pinned here // (Iso_data_dataEngine__ValidationAdvisoryEventSchema) rather than given a // `ValidationAdvisoryEventParsed` synonym. +1 added. - // - // 776 -> 777 is #22748: the stack definition's new `sms` block types its - // provider with the new exported `SmsProviderSchema`, a bare enum with no - // default and no transform, so it is pinned here - // (Iso_system_smsConfig__SmsProviderSchema) rather than given an - // `SmsProviderParsed` synonym. +1 added. - expect(pins).toHaveLength(777); + expect(pins).toHaveLength(776); // The count is stated in PROSE twice as well — this case's title and the // section header above the pin list — and until commit c6b05c76a nothing read either