diff --git a/.changeset/22748-stack-email-sms-door.md b/.changeset/22748-stack-email-sms-door.md new file mode 100644 index 00000000000..97a9d554e51 --- /dev/null +++ b/.changeset/22748-stack-email-sms-door.md @@ -0,0 +1,19 @@ +--- +"@objectstack/spec": minor +--- + +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`, 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 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`. `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, 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/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 740a474b453..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 — 1517 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 | 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** | **195** | **1517** | 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, 278 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) | `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..97f19a54ed7 --- /dev/null +++ b/content/docs/references/system/sms-config.mdx @@ -0,0 +1,83 @@ +--- +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`, 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. + +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` + + +## TypeScript Usage + +```typescript +import { StackSmsConfigSchema } from '@objectstack/spec/system'; +import type { StackSmsConfig } from '@objectstack/spec/system'; + +// Validate data +const result = StackSmsConfigSchema.parse(data); +``` + +--- + +## StackSmsConfig + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **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/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/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..a2d64612b50 --- /dev/null +++ b/packages/cli/src/commands/serve-stack-email-sms-door.contract.test.ts @@ -0,0 +1,104 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// A `defineStack`-built `email` / `sms` / `appName` reaches the providers +// `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 +// 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. +// +// `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 * as pluginEmail from '@objectstack/plugin-email'; +import * as serviceSms 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 { 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_* 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); + }); + + 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' }); + }); +}); 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/api-surface/system.json b/packages/spec/api-surface/system.json index 8fa55c8f070..98ec9a151e2 100644 --- a/packages/spec/api-surface/system.json +++ b/packages/spec/api-surface/system.json @@ -648,12 +648,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..1e1f8beb211 100644 --- a/packages/spec/authorable-surface/system.json +++ b/packages/spec/authorable-surface/system.json @@ -1109,9 +1109,23 @@ "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: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..8e9c821f02e 100644 --- a/packages/spec/declaration-map/system.json +++ b/packages/spec/declaration-map/system.json @@ -423,10 +423,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..241dbee055f 100644 --- a/packages/spec/export-origins/system.json +++ b/packages/spec/export-origins/system.json @@ -619,12 +619,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..3aef2c59248 100644 --- a/packages/spec/json-schema.manifest/system.json +++ b/packages/spec/json-schema.manifest/system.json @@ -231,8 +231,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/packages/spec/llms.txt b/packages/spec/llms.txt index 1106a70dfa5..f317552992c 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/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..eb7dc1b59c4 --- /dev/null +++ b/packages/spec/src/stack-email-sms.test.ts @@ -0,0 +1,245 @@ +// 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. `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()` + * + * 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 { 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 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', () => { + // 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.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: { 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: { authToken: 'secret' } })); + expect(issue?.keys).toEqual(['authToken']); + expect(issue?.message).toContain('OS_SMS_TWILIO_AUTH_TOKEN'); + }); + + 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({ retries: 1, 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..a70d6663b3e 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,48 @@ 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}: `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 — 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', + ), + /** * ObjectAI: Artificial Intelligence Layer * @@ -1093,6 +1137,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..0015c421bb5 --- /dev/null +++ b/packages/spec/src/system/sms-config.zod.ts @@ -0,0 +1,103 @@ +// 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`, 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. + * + * 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. + */ + +/** + * The one prescription for SMS provider settings written into a stack + * 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. 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 one member `resolveSmsCapabilityArg` reads + * off `config.sms` that a stack can deliver. 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: { + provider: STACK_SMS_PROVIDER_SETTINGS_PRESCRIPTION, + providerOptions: STACK_SMS_PROVIDER_SETTINGS_PRESCRIPTION, + authToken: STACK_SMS_PROVIDER_SETTINGS_PRESCRIPTION, + accessKeySecret: STACK_SMS_PROVIDER_SETTINGS_PRESCRIPTION, + }, + }, + { + /** + * 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. + */ + retries: z.number().int().min(0).optional() + .describe( + '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', + ), + }, +)); +export type StackSmsConfig = z.input; +/** Post-parse shape of {@link StackSmsConfig} — defaults applied, transforms run (ADR-0122). */ +export type StackSmsConfigParsed = z.infer; 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.