diff --git a/.changeset/22301-verify-boots-the-served-slate.md b/.changeset/22301-verify-boots-the-served-slate.md new file mode 100644 index 00000000000..944001b9afd --- /dev/null +++ b/.changeset/22301-verify-boots-the-served-slate.md @@ -0,0 +1,32 @@ +--- +'@objectstack/core': minor +'@objectstack/verify': minor +'@objectstack/plugin-email': minor +'@objectstack/service-sms': minor +'@objectstack/cli': patch +--- + +`bootStack` mounts the always-on capability slate `objectstack serve` mounts for every app, and builds each provider from the app's configuration the way `serve` builds it + +Clause-②: yes (narrowing: a configuration whose mail or SMS settings, or the OS_EMAIL_* / OS_SMS_* environment, name a transport that cannot deliver, now fails bootStack where it booted; widening: bootStack mounts the always-on slate objectstack serve mounts for every app and hands each provider the app's configuration, so the app's analytics cubes now reach the registry, where it mounted fewer and built them with defaults) + + + +**BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes. + +**`@objectstack/verify` — the composition `serve` mounts, built the way `serve` builds it.** For one configuration, `bootStack(config, opts)` already mounted the providers the app's `requires` names and the plugins in its own `plugins` array. It now also: + +- **Mounts the always-on slate** `serve` mounts for every app, whether or not `requires` names it: `queue`, `job`, `cache`, `settings`, `email`, `storage`, `sms`, `sharing`, `messaging`, `analytics` and `package-registry`. Before, a slate provider was mounted only where a mounted plugin hard-depended on it, so an app's tests ran without the inbox delivery, the mail service, the file storage and the package registry its users' server has. +- **Builds each provider from the app's configuration**, by the same rule `serve` reads: the analytics service gets the app's `analyticsCubes` (the top level, then each package body's), the email service the app's `email` block with `OS_EMAIL_*` over it, the SMS service the `sms` block with `OS_SMS_*` over it, and the storage service the `OS_STORAGE_LOCAL_ROOT` root (`.objectstack/data/uploads` under the working directory by default). Before, each was built with its own defaults: no cubes, no mail configuration. So the app's cubes now reach the analytics registry; a cube the service refuses (one over an object the API does not serve) is warned and skipped by the service, under `bootStack` as under `serve`, and no boot fails on it. +- A caller's `extraPlugins`, `security` and `analytics` instances still take precedence by identity, and are not handed the configuration. A suite that needs a provider configured its own way (a temporary storage root, a mail transport) passes its instance in `extraPlugins`. +- Not mounted, because they are decisions about a server process rather than about the configuration: `serve`'s MCP endpoint (`OS_MCP_SERVER_ENABLED`) and its pinyin search (`OS_SEARCH_PINYIN_ENABLED`). A suite that exercises either passes the provider in `extraPlugins`. + +**What now fails that booted before (the narrowing).** A provider `bootStack` constructs and cannot build fails the boot, naming the capability token and the package — whether the app declared the token or the slate appended it. `serve` logs a slate provider it cannot build and boots on; a test boot does not, so an app's tests never pass on a composition its server does not run. In practice: + +- a configuration whose `email` or `sms` settings, or the `OS_EMAIL_*` / `OS_SMS_*` environment of the test process, name a transport that cannot deliver (`provider: 'smtp'` with no host, `resend` / `postmark` with no API key, an unknown provider tag) is refused with the reader's own remedy. Set `OS_EMAIL_PROVIDER=log` / `OS_SMS_PROVIDER=log` in the test environment if it is not meant to send mail or SMS. + +**`@objectstack/core`** exports the rule both boots read: `resolveServedCapabilities` (given the tokens a stack declares, which capability tokens a served boot mounts providers for — those, `email` for a declared `auth`, the host's own defaults, the always-on slate unless the preset is `minimal`, and `job` / `queue` ahead of what schedules background work) and `resolveCapabilityArgument` (what each provider is constructed with), with their `ServedCapabilities`, `CapabilityArgumentInput` and `CapabilityArgument` types; and `resolveStorageCapabilityArg`, `resolveStorageLocalRootEnv` and `StorageCapabilityArg`, moved here from the CLI. + +**`@objectstack/plugin-email`** exports `resolveEmailCapabilityArg`, `resolveDeploymentAppName` and `EmailCapabilityArg`, and **`@objectstack/service-sms`** exports `resolveSmsCapabilityArg` and `SmsCapabilityArg` — the readers that build each provider from the deployment's configuration, moved from the CLI to sit beside the transport vocabulary they refuse against. `resolveCapabilityArgument` reads each off the provider module it is handed. + +**`@objectstack/cli`**: `serve` reads both rules from `@objectstack/core` and composes exactly what it composed before. `os migrate plan` / `apply`'s declaration boot reads the same token rule for the providers it composes (the same set as before; a declared `auth` now places `email` right after the declared tokens, as `serve` does). `os verify` boots through `bootStack`, so it now mounts the always-on slate and builds the providers from the app's configuration. The moved functions stay importable from the `serve` command module. diff --git a/packages/cli/src/commands/serve-audit-registration.contract.test.ts b/packages/cli/src/commands/serve-audit-registration.contract.test.ts index 226ad9bc78f..d62d7bf932b 100644 --- a/packages/cli/src/commands/serve-audit-registration.contract.test.ts +++ b/packages/cli/src/commands/serve-audit-registration.contract.test.ts @@ -40,7 +40,9 @@ // ## Why a source scan rather than a boot // // Same reason as this directory's `serve-verify-security-parity.contract.test.ts` -// and `serve-email-config-parity.contract.test.ts`: the failure mode is an EDIT +// and `@objectstack/plugin-email`'s `capability-arg.config-parity.contract.test.ts` +// (this directory's `serve-email-config-parity.contract.test.ts` until #22301): +// the failure mode is an EDIT // to these files, and every one of the three facts above is invisible to a // behavioural test. `serve.ts`'s audit block is reachable only from a live // `objectstack serve` boot with `@objectstack/plugin-auth` and diff --git a/packages/cli/src/commands/serve-auth-app-name.contract.test.ts b/packages/cli/src/commands/serve-auth-app-name.contract.test.ts index d4d1c1c18b5..bcdc8208a1d 100644 --- a/packages/cli/src/commands/serve-auth-app-name.contract.test.ts +++ b/packages/cli/src/commands/serve-auth-app-name.contract.test.ts @@ -14,17 +14,23 @@ // // Two halves, both needed. The resolver half proves the chain is the email // chain, rung for rung, so the two consumers cannot disagree about the name. -// The construction half scans `serve.ts` (comments masked, as its siblings -// `serve-email-config-parity.contract.test.ts` and -// `serve-verify-security-parity.contract.test.ts` do), because nothing else -// sees the AuthPlugin literal: the plugin is imported through a variable -// specifier, so the type checker cannot, and only a booted CLI could. +// [#22301] The email half is driven through the call `serve` makes for every +// capability provider — `resolveCapabilityArgument` (`@objectstack/core`) with +// `@objectstack/plugin-email` as the provider module — because that call, not +// a line of `serve.ts`, decides which of the stack's keys the email +// capability is built from. The construction half scans `serve.ts` (comments +// masked, as its sibling `serve-verify-security-parity.contract.test.ts` +// does), because nothing else sees the AuthPlugin literal: the plugin is +// imported through a variable specifier, so the type checker cannot, and only +// a booted CLI could. import { describe, it, expect } from 'vitest'; import { readFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { resolveDeploymentAppName, resolveEmailCapabilityArg } from './serve.js'; +import { resolveCapabilityArgument } from '@objectstack/core'; +import * as pluginEmail from '@objectstack/plugin-email'; +import { resolveDeploymentAppName } from './serve.js'; import { maskComments } from '../../../../scripts/js-comment-mask.mjs'; const SERVE_SOURCE = maskComments( @@ -58,9 +64,14 @@ function callArguments(source: string, callee: string): string[] { /** Argument text compared as code: whitespace and a trailing comma dropped. */ const normalise = (args: string) => args.replace(/\s+/g, '').replace(/,$/, ''); +/** The app name the email capability is built with, by the call `serve`'s capability loop makes. */ const emailAppName = (cfgEmail: Record, env: NodeJS.ProcessEnv, configAppName?: string) => - (resolveEmailCapabilityArg(cfgEmail, env, configAppName).options - .defaultTemplateContext as Record).appName; + ((resolveCapabilityArgument('email', { + stack: { email: cfgEmail, appName: configAppName }, + packageRoot: '/app', + providerModule: pluginEmail, + env, + }).argument as { defaultTemplateContext: Record }).defaultTemplateContext).appName; describe('resolveDeploymentAppName is the email chain, rung for rung', () => { const RUNGS: Array<[string, Record, NodeJS.ProcessEnv, string | undefined, string]> = [ @@ -80,7 +91,7 @@ describe('resolveDeploymentAppName is the email chain, rung for rung', () => { }); describe('serve constructs AuthPlugin with the deployment app name', () => { - it('passes appName from resolveDeploymentAppName, with the arguments the email capability gets', () => { + it('passes appName from resolveDeploymentAppName, over the keys the email capability is built from', () => { const constructions = callArguments(SERVE_SOURCE, 'new AuthPlugin'); // Absence must be loud: a scan that finds no construction proves nothing. expect(constructions.length, 'serve.ts must construct AuthPlugin exactly once').toBe(1); @@ -90,8 +101,15 @@ describe('serve constructs AuthPlugin with the deployment app name', () => { expect(passed.length, 'AuthPlugin must receive appName from resolveDeploymentAppName').toBe(1); expect(literal).toMatch(/\bappName:\s*resolveDeploymentAppName\(/); - const emailCalls = callArguments(SERVE_SOURCE, 'resolveEmailCapabilityArg'); - expect(emailCalls.length, 'serve.ts must resolve the email capability exactly once').toBe(1); - expect(normalise(passed[0])).toBe(normalise(emailCalls[0])); + // [#22301] The email capability is resolved by `resolveCapabilityArgument`, + // from the stack's `email` block (or `{}`), the environment and the stack's + // `appName` — the call the rungs above drive. AuthPlugin must read those + // same three off this boot's config, and the loop must hand the rule this + // boot's config and environment. + expect(normalise(passed[0])).toBe(normalise('(config as any).email ?? {}, process.env, (config as any).appName')); + const dispatched = callArguments(SERVE_SOURCE, 'resolveCapabilityArgument'); + expect(dispatched.length, 'serve.ts must resolve the provider arguments at exactly one site').toBe(1); + expect(normalise(dispatched[0])).toContain('stack:config,'); + expect(normalise(dispatched[0])).toContain('env:process.env'); }); }); diff --git a/packages/cli/src/commands/serve-verify-security-parity.contract.test.ts b/packages/cli/src/commands/serve-verify-security-parity.contract.test.ts index 35d3dbc67bf..1aa8d19367a 100644 --- a/packages/cli/src/commands/serve-verify-security-parity.contract.test.ts +++ b/packages/cli/src/commands/serve-verify-security-parity.contract.test.ts @@ -25,8 +25,9 @@ // Neither can see THIS file's failure mode, which is the one that actually // happened: nothing in the repo pins `serve.ts`'s side, so re-open-coding the // wiring here — or dropping it — would be green everywhere while the paths -// separate again. Hence a source scan, in the shape of this package's -// `serve-email-config-parity.contract.test.ts`: the grep that would have caught +// separate again. Hence a source scan, in the shape of `@objectstack/plugin-email`'s +// `capability-arg.config-parity.contract.test.ts` (this package's +// `serve-email-config-parity.contract.test.ts` until #22301): the grep that would have caught // it, mechanised, so the second divergence fails a build instead of waiting for // someone to run it. // diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 62175cae68a..3b64f30af33 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -29,10 +29,14 @@ import { import { readEnvWithDeprecation, resolveTenancyPosture, resolveAllowDegradedTenancy, isMcpServerEnabled, stampSearchPinyinEnabled, isModuleNotFoundError } from '@objectstack/types'; import { PLATFORM_CAPABILITY_TOKENS, PLATFORM_ALWAYS_ON_CAPABILITIES, RETIRED_PLATFORM_CAPABILITY_GUIDANCE } from '@objectstack/spec/kernel'; // [#22301] The `requires` token → provider table and its exact identity match, -// shared with `@objectstack/verify`'s `bootStack` — see `Serve.CAPABILITY_PROVIDERS`. +// shared with `@objectstack/verify`'s `bootStack` — see `Serve.CAPABILITY_PROVIDERS` +// — and, beside it, which tokens a served boot mounts (`requires` plus the +// always-on slate) and what it constructs each provider with. import { CAPABILITY_PROVIDERS, providesCapability, + resolveServedCapabilities, + resolveCapabilityArgument, materializeStackPlugin, STACK_TIER_PRESETS, CAPABILITY_TO_TIER as SHARED_CAPABILITY_TO_TIER, @@ -61,24 +65,16 @@ import { type SeedSuppressionReason, } from '@objectstack/spec/contracts'; import { missingProviderMessage } from '../utils/capability-preflight.js'; -// The mail provider vocabulary, read from the package that materialises the -// transports rather than restated here (#5132) — `resolveEmailCapabilityArg` -// has to refuse exactly the configurations `makeTransport` cannot build, and -// two literal lists for one vocabulary is the drift #5094 was filed for. Values -// only (no plugin class): the capability loop loads `EmailServicePlugin` itself -// with a bare `import()`, resolved against THIS CLI's own realpath — its -// bundled copy always wins, never the host app's. Contrast `importConfigPlugin` -// below, which IS host-anchored: an app-declared package wins there (per commit 5a90c56d1). -import { isEmailTransportProvider, emailProviderRequiresApiKey, unsupportedProviderFix } from '@objectstack/plugin-email'; -// The SMS provider vocabulary, read from the package that materialises the -// transports, for the same reason and by the same rule as the mail one above -// (#5713). `resolveSmsCapabilityArg` has to refuse exactly the tags -// `makeSmsTransport` cannot build — restating `log`/`aliyun`/`twilio` here would -// be the second literal #5094 was filed for. Values only (no plugin class): the -// capability loop dynamic-imports `SmsServicePlugin` itself the same way — a -// bare `import()` resolved against this CLI's own realpath, so its bundled -// copy wins, never the host's (measured in commit 5a90c56d1). -import { isSmsTransportProvider, SMS_TRANSPORT_PROVIDERS } from '@objectstack/service-sms'; +// The deployment's product name, for `AuthPlugin` — the same resolver the email +// capability's template context reads (`resolveEmailCapabilityArg`), so the two +// cannot disagree. Both moved to `@objectstack/plugin-email` beside the mail +// provider vocabulary they read (#22301; see the re-exports at the foot of this +// file). The capability loop still loads `EmailServicePlugin` itself with a bare +// `import()`, resolved against THIS CLI's own realpath — its bundled copy always +// wins, never the host app's — and reads the email and SMS readers off that +// module. Contrast `importConfigPlugin` below, which IS host-anchored: an +// app-declared package wins there (per commit 5a90c56d1). +import { resolveDeploymentAppName } from '@objectstack/plugin-email'; import { createHash } from 'node:crypto'; import { resolveObjectStackHome } from '@objectstack/runtime'; import { LOG_LEVELS, resolveLogLevel, readLogLevelEnv } from '../utils/log-level.js'; @@ -2704,78 +2700,46 @@ export default class Serve extends Command { // and shadow any capability resolution (i.e. an explicit // instance wins over the auto-loader). const presetName = flags.preset ?? (isDev ? 'default' : 'default'); - // Dedupe `requires` (Set keeps first-seen order). The deprecated - // `aiStudio`/`aiSeat` alias canonicalization was removed in framework#3308 - // — legacy spellings are now unknown tokens (warned below, rejected at - // authoring by defineStack). + // [#22301] WHICH tokens a served boot mounts providers for is ONE rule, + // `resolveServedCapabilities` (`@objectstack/core`), read by this command, + // by `@objectstack/verify`'s `bootStack` and by `os migrate plan`'s + // declaration boot, so an app's tests mount the services its users' + // server mounts. In its order: the tokens the stack declares, as + // `stackDeclaredCapabilities` reads them (the top-level list, otherwise + // each package body's, #22288), deduplicated; `email` when `auth` is + // declared; the host defaults below; the always-on slate + // (`Serve.ALWAYS_ON_CAPABILITIES`) unless `--preset minimal`; and `job` + // and `queue` moved ahead of the tokens that schedule background work. + // Its `declared` set carries the "required" INTENT (#1597): a declared + // capability whose provider cannot be provided is a hard boot error, + // whereas a token the platform appended stays best-effort (warn + + // continue) — that half is this command's own, in the resolver below. // - // [#22288] Read by `resolveStackCollection`'s rule: the top-level list - // when the config carries one, otherwise each package body's. A - // multi-package `preserve` config carries `requires` only in the body of - // the package that declared it, so a top-level read mounted none of its - // providers on a config boot with no compiled artifact. (An artifact boot - // already answered here: `createStandaloneStack` resolves the artifact's - // packages and `mergeBootConfig` lays its `requires` over the top level.) - const rawRequires: string[] = stackDeclaredCapabilities(config); - const requires: string[] = [...new Set(rawRequires)]; - // Snapshot the app's EXPLICIT capability declarations BEFORE the platform - // appends its own convenience defaults (auth→email, mcp, pinyin-search, - // ALWAYS_ON_CAPABILITIES, queue/job). Only these explicit declarations carry - // "required" INTENT (#1597): a declared capability whose provider package is - // absent is a hard boot error, whereas an auto-injected default that happens - // to be absent stays best-effort (warn + continue). - const declaredRequires = new Set(requires); - // Auth callbacks (password-reset, email-verification, magic-link, - // invitation) depend on the email service. Auto-pull `email` when - // `auth` is required so transactional mail works out of the box - // (LogTransport fallback when no provider is configured). - if (requires.includes('auth') && !requires.includes('email')) { - requires.push('email'); - } - // MCP is a default-on core capability: serve `/api/v1/mcp` unless - // `OS_MCP_SERVER_ENABLED=false` opts out. The dispatcher gates the route - // on the SAME decision point (`isMcpServerEnabled`), so serving the - // route without also loading the MCP plugin would 501 every request - // (#2698: the default must yield a connectable MCP endpoint). Explicit - // `requires: ['mcp']` in config works regardless of the env var. - if (isMcpServerEnabled() && !requires.includes('mcp')) { - requires.push('mcp'); - } - // Pinyin search recall (#2486): locale-gated platform capability. When - // `OS_SEARCH_PINYIN_ENABLED` is unset, the default derives from the - // stack's configured locales (any `zh-*` → on), and the resolved - // decision is stamped back into the env var — every later consumer - // (each engine's SchemaRegistry provisioning the `__search` companion - // column, the plugin's own gate) reads the same answer via the no-arg - // `resolveSearchPinyinEnabled()`. The shared `stampSearchPinyinEnabled` - // helper is also what `createStandaloneStack` stamps from the compiled - // artifact, so serve/dev and `os migrate plan`/`apply` cannot compute - // different schema views of the same source tree (#3955). - if (stampSearchPinyinEnabled((config as any).i18n)) { - if (!requires.includes('pinyin-search')) requires.push('pinyin-search'); - } - // Default capability slate — every preset except `minimal` gets the - // foundational services (queue + job + cache + settings + email + - // storage). Opt out with `objectstack serve --preset minimal`. - // Keeping `auth → email` above as a defensive rule for users who - // explicitly opt into `minimal` but still enable auth. - const ALWAYS_CAPS = Serve.ALWAYS_ON_CAPABILITIES; - if (presetName !== 'minimal') { - for (const cap of ALWAYS_CAPS) { - if (!requires.includes(cap)) requires.push(cap); - } - } - // The email + approvals services schedule background work - // (durable retries, SLA escalation). Auto-pull - // 'job' and 'queue' so plugins can opt into durable scheduling. - // IMPORTANT: prepend, so their plugins load (and their kernel:ready - // hooks fire) BEFORE consumers like email/approvals that subscribe - // to queues during their own kernel:ready phase. - const NEEDS_JOB_OR_QUEUE = ['email', 'approvals', 'auth']; - if (NEEDS_JOB_OR_QUEUE.some((c) => requires.includes(c))) { - if (!requires.includes('queue')) requires.unshift('queue'); - if (!requires.includes('job')) requires.unshift('job'); - } + // The two host defaults are this process's own decisions, read from its + // environment, so they are handed to the rule rather than made by it: + // + // - MCP is a default-on core capability: serve `/api/v1/mcp` unless + // `OS_MCP_SERVER_ENABLED=false` opts out. The dispatcher gates the route + // on the SAME decision point (`isMcpServerEnabled`), so serving the + // route without also loading the MCP plugin would 501 every request + // (#2698: the default must yield a connectable MCP endpoint). Explicit + // `requires: ['mcp']` in config works regardless of the env var. + // - Pinyin search recall (#2486): locale-gated platform capability. When + // `OS_SEARCH_PINYIN_ENABLED` is unset, the default derives from the + // stack's configured locales (any `zh-*` → on), and the resolved + // decision is stamped back into the env var — every later consumer + // (each engine's SchemaRegistry provisioning the `__search` companion + // column, the plugin's own gate) reads the same answer via the no-arg + // `resolveSearchPinyinEnabled()`. The shared `stampSearchPinyinEnabled` + // helper is also what `createStandaloneStack` stamps from the compiled + // artifact, so serve/dev and `os migrate plan`/`apply` cannot compute + // different schema views of the same source tree (#3955). + const hostDefaults: string[] = []; + if (isMcpServerEnabled()) hostDefaults.push('mcp'); + if (stampSearchPinyinEnabled((config as any).i18n)) hostDefaults.push('pinyin-search'); + const served = resolveServedCapabilities(stackDeclaredCapabilities(config), { preset: presetName, hostDefaults }); + const requires: string[] = [...served.tokens]; + const declaredRequires: ReadonlySet = served.declared; // Capability → tier: any capability that is gated by a tier // (Serve.CAPABILITY_TO_TIER) automatically opens that tier when listed // in `requires`. Capabilities NOT in that map (e.g. `automation`, @@ -4661,63 +4625,37 @@ export default class Serve extends Command { console.warn(chalk.yellow(` ⚠ Capability "${cap}": ${spec.pkg} did not export ${spec.export}`)); continue; } - // analytics needs cubes from config, others take no args - let arg: any; - if (cap === 'automation') { - // #3016 — anchor declarative connector file refs (e.g. the openapi - // provider's `providerConfig.spec: './billing-openapi.json'`) to the - // project folder (next to objectstack.config.ts), mirroring how the - // standalone sqlite default is anchored above. Reads are confined to - // this root by the automation service's package file loader. - arg = { packageRoot: path.dirname(absolutePath) }; - } else if (spec.configKey === 'analyticsCubes') { - // [#22288] The top level first, as before (its own array, then the - // legacy `cubes` spelling), and only then the package bodies: a - // multi-package config carries a package's `analyticsCubes` in that - // body alone, and `analytics` is always on, so its cubes were - // dropped on every boot of such an app (`os dev` included). - const cubes = (config as any).analyticsCubes ?? (config as any).cubes - ?? resolveStackCollection(config, 'analyticsCubes'); - arg = { cubes }; - } else if (cap === 'email') { - // Throws on a mail configuration that cannot deliver (#5087, - // #5132) — the catch below turns that into the boot failure / - // loud error it should be, never a LogTransport substituted - // behind the operator's back. - arg = resolveEmailCapabilityArg( - (config as any).email ?? {}, - process.env, - (config as any).appName, - ).options; - } else if (cap === 'sms') { - // Compose SmsServicePlugin options from config.sms + OS_SMS_* env - // (#2780). Same precedence as email: env beats config. Provider - // credentials normally live in the `sms` settings namespace - // (bound at kernel:ready); constructor opts cover pre-settings - // boot and hosts without the settings service. - // - // Throws on a provider tag no transport can deliver (#5713) — the - // catch below turns that into the boot failure / loud error it - // should be, never a LogSmsTransport substituted behind the - // operator's back. Same shape as the `email` arm above. - arg = resolveSmsCapabilityArg((config as any).sms ?? {}, process.env).options; - } else if (cap === 'storage') { - // Storage is now in the default capability slate. If the host - // hasn't configured a backend explicitly we fall back to the - // local-disk driver under `.objectstack/data/uploads/` so - // avatars / attachments / report files work out of the box. - // In production mode we emit a single loud warning so the - // operator knows to point storage at S3 / GCS / Azure before - // shipping (data on a single pod is volatile / non-replicated). - const storageArg = resolveStorageCapabilityArg(resolveStorageLocalRootEnv()); - arg = storageArg.options; - if (storageArg.localRoot && !isDev) { - // Names only the channels that actually work — `config.storage` - // was in this sentence and was never read (framework#4167). - console.warn(chalk.yellow( - ` ⚠ StorageServicePlugin using local driver (${storageArg.localRoot}) — switch to S3/GCS/Azure for production (set OS_STORAGE_* or configure storage in Setup → Settings).`, - )); - } + // [#22301] WHAT the provider is constructed with is ONE rule, + // `resolveCapabilityArgument` (`@objectstack/core`), which + // `@objectstack/verify`'s `bootStack` reads too: `automation` gets the + // app's root (#3016 — declarative connector file refs resolve from + // the project folder, next to objectstack.config.ts, never from the + // working directory); `analytics` the stack's cubes, the top level + // first and then the package bodies (#22288); `email` and `sms` the + // deployment's mail and SMS configuration, read by the readers their + // own packages export off `mod` (env beats config); `storage` its + // local root. The mail and SMS readers THROW on a configuration no + // transport can deliver through (#5087, #5132, #5713) — the catch + // below turns that into the boot failure / loud error it should be, + // never a log transport substituted behind the operator's back. + const resolved = resolveCapabilityArgument(cap, { + stack: config, + packageRoot: path.dirname(absolutePath), + providerModule: mod, + env: process.env, + }); + const arg: any = resolved.argument; + if (resolved.localStorageRoot && !isDev) { + // Storage is in the default capability slate, and a host that + // configured no backend gets the local-disk driver. In production + // mode say so once, loudly, so the operator points storage at + // S3 / GCS / Azure before shipping (data on a single pod is + // volatile / non-replicated). Names only the channels that + // actually work — `config.storage` was in this sentence and was + // never read (framework#4167). + console.warn(chalk.yellow( + ` ⚠ StorageServicePlugin using local driver (${resolved.localStorageRoot}) — switch to S3/GCS/Azure for production (set OS_STORAGE_* or configure storage in Setup → Settings).`, + )); } const provider = arg !== undefined ? new Ctor(arg) : new Ctor(); await kernel.use(provider); @@ -6202,406 +6140,27 @@ export function formatUnusableAuthBaseUrlDiagnostic( + ' OS_BASE_URL / http://localhost:.'; } -/** - * Constructor options for `StorageServicePlugin`, plus the local root to name in - * the production warning (absent when the host configured a backend itself). - */ -export interface StorageCapabilityArg { - options: Record; - localRoot?: string; -} - -/** - * Resolve what `StorageServicePlugin` is constructed with (#4096). - * - * Storage is in the default capability slate, so a host that configures nothing - * still gets local disk under `.objectstack/data/uploads/` and avatars / - * attachments / report files work out of the box. - * - * The fallback used to be `{ driver: 'local', root }` — neither of which - * `StorageServicePluginOptions` declares. Both were dropped on the floor, so the - * plugin applied its OWN default (`./storage`), the storage-root env var changed - * nothing, and uploads landed somewhere the operator never named. The `storage` - * settings namespace then corrected the root on its first read (its manifest - * default IS `./.objectstack/data/uploads`), which swapped the adapter and - * warned about stranded files — on every boot of a healthy server. - * - * #4096 fixed the option SHAPE; the value still could not reach the settings - * side, because the CLI and the settings service spelled the env var - * differently. {@link resolveStorageLocalRootEnv} is the channel that closes - * that gap (#4968) — read the root through it, never off `process.env` - * directly. - * - * `config.storage` is deliberately NOT read (framework#4167). It was never a - * stack key: `ObjectStackDefinitionSchema` does not declare it, and the schema - * is not `.strict()`, so `defineStack` — which every documented authoring path - * and every compiled artifact goes through — strips it before `serve` could - * ever see it. It arrived here only from a bare-object config on the - * config-boot path, i.e. one unreachable-in-practice combination, where it then - * ALSO carried the `driver`/`root` spelling the plugin does not read. Honouring - * it on that one path meant the same authoring key worked in one place and - * vanished in every other, which is worse than not having it. - * - * The storage backend is a deployment concern with two real channels: the - * `OS_STORAGE_*` env vars (below) and the `storage` settings namespace, which - * is also the one with proper credential handling. Authors who write `storage:` - * anyway now get told so — `lintUnknownStackKeys` reports undeclared top-level - * keys, and `STACK_KEY_GUIDANCE` names both channels. - */ -export function resolveStorageCapabilityArg(envRoot?: string): StorageCapabilityArg { - const rootDir = envRoot?.trim() || '.objectstack/data/uploads'; - return { options: { adapter: 'local', local: { rootDir } }, localRoot: rootDir }; -} - -/** - * The ONE env channel for the local storage root (#4968). - * - * The CLI used to invent its own name, `OS_STORAGE_ROOT`, while the settings - * service derives the env name for the same value from the namespace it owns: - * `envKeyOf('storage', 'local_root')` = `OS_STORAGE_LOCAL_ROOT`. Nothing in the - * repo ever set that name, so the two channels never met — the CLI constructed - * an adapter at the root the operator asked for, and `StorageServicePlugin` - * then re-resolved from settings at `kernel:ready`, found nothing but the - * manifest's schema DEFAULT, and swapped the adapter to - * `./.objectstack/data/uploads`. - * - * The consequences were not log noise: - * - * - `OS_STORAGE_ROOT` took effect for exactly one value — the one that happens - * to equal the manifest default. Every other value (`/srv/uploads`, a - * `--fresh` tempdir) was constructed and then discarded, so an operator - * following `backup-restore.mdx` backed up an empty directory. - * - `dev --fresh` promised the tempdir "owns ALL persistent state for this - * run"; uploads actually landed under the project cwd and survived exit. - * - The "adapter swapped … may be unreachable" warning on every clean boot was - * ACCURATE — the swap really happened. It is not touched here, and it stops - * firing because the swap stops happening. - * - * So the fix is at the producer, not in a tolerant consumer: write the name the - * settings service already declares. The legacy name is read for one more - * release via {@link readEnvWithDeprecation} and, when it is the one that - * supplied the value, STAMPED onto the canonical name — the settings service - * reads `process.env` live through its own `env` reference and only ever looks - * up `OS_STORAGE_LOCAL_ROOT`, so without the stamp a legacy deployment would - * keep the exact bug this fixes. With it, settings resolves - * `source: 'env'`/`locked: true` at the value the adapter was built with, - * `needsStorageSwap` answers false, and the two channels agree by construction. - * - * Side-effecting on purpose, and idempotent: `readEnvWithDeprecation` - * deduplicates its warning process-wide, and once stamped the canonical branch - * wins on every later call. - * - * @returns The resolved root, or `undefined` when neither name is set (the - * caller then falls through to `resolveStorageCapabilityArg`'s - * built-in default, which matches the manifest default). - */ -export function resolveStorageLocalRootEnv(): string | undefined { - const value = readEnvWithDeprecation('OS_STORAGE_LOCAL_ROOT', 'OS_STORAGE_ROOT'); - if (value === undefined) return undefined; - // Bridge the legacy spelling onto the canonical one the settings service - // reads. Guarded so we never rewrite a canonical value with itself. - if (typeof process !== 'undefined' && process.env - && process.env.OS_STORAGE_LOCAL_ROOT === undefined) { - process.env.OS_STORAGE_LOCAL_ROOT = value; - } - return value; -} - -/** - * Constructor options for `EmailServicePlugin`. - * - * There is no `warning` channel here any more (#5132). It carried exactly one - * message — "provider=resend but no apiKey, falling back to LogTransport" — - * and that fallback is now a throw, because a mail configuration that cannot - * deliver has no "degraded but still fine" reading: it is a server that - * accepts every send and delivers nothing. - */ -export interface EmailCapabilityArg { - options: Record; -} - -/** - * The ONE truth table this file reads its `OS_EMAIL_*_ENABLED` booleans with - * (#5447). - * - * Extracted rather than restated: `OS_EMAIL_QUEUE_ENABLED` carried this list - * inline, and a second boolean flag written a second way is how one env var - * ends up accepting `on` while its neighbour does not — the operator-visible - * half of the "two literals describing one vocabulary" trap that split the - * settings dropdown from the transports (#5094). - * - * Tri-state on purpose: `undefined` means the variable is unset and the caller - * must fall through to config, which is what keeps an absent flag from - * silently reading as `false` and overriding a config that said `true`. - * An empty string is a SET variable and resolves to `false`, matching the - * behaviour `OS_EMAIL_QUEUE_ENABLED` already had. - */ -function envBooleanFlag(raw: string | undefined): boolean | undefined { - if (raw == null) return undefined; - return ['1', 'true', 'yes', 'on'].includes(String(raw).trim().toLowerCase()); -} - -/** - * The deployment's product name: `OS_APP_NAME` > `config.email.appName` > - * `config.email.defaultTemplateContext.appName` > top-level `config.appName` > - * `'ObjectStack'` (the chain {@link resolveEmailCapabilityArg} documents). - * - * ONE resolver for every boot-time consumer of the name, so they cannot - * disagree: the email capability's template context, and the `appName` - * AuthPlugin receives (the auth emails' `{{appName}}` and the TOTP issuer an - * authenticator app lists the account under). AuthPlugin used to be built - * without it, so `OS_APP_NAME` never reached auth: its emails, whose own - * `appName` outranks the template context, said 'ObjectStack'. - */ -export function resolveDeploymentAppName( - cfgEmail: Record = {}, - env: NodeJS.ProcessEnv = process.env, - configAppName?: string, -): string { - return env.OS_APP_NAME || cfgEmail.appName || cfgEmail.defaultTemplateContext?.appName - || configAppName || 'ObjectStack'; -} - -/** - * Resolve what `EmailServicePlugin` is constructed with, from `config.email` - * plus `OS_EMAIL_*` env (env wins, so an operator can override per environment). - * - * SMTP (#5087, ADR-0012) is configured through `OS_EMAIL_SMTP_HOST` / `_PORT` / - * `_SECURE` / `_USER` / `_PASSWORD` — the `OS_{DOMAIN}_{FEATURE}_{QUALIFIER}` - * shape of Prime Directive #9, grouped with the email vars rather than the bare - * third-party `SMTP_*` names — layered over `config.email.options`. - * - * **Every provider that cannot deliver throws** — `smtp` with no host, and - * (since #5132) `resend`/`postmark` with no API key, or a provider tag outside - * `EMAIL_TRANSPORT_PROVIDERS` altogether. The capability loop turns that into a - * loud failure — a hard boot error when the app declared `requires: ['email']`, - * otherwise a `console.error` and no email service — which is the point: the - * alternative (quietly substituting the LogTransport, as this function's - * `resend`/`postmark` arm used to do for a missing API key) hands the operator a - * server that accepts every send, records it in `sys_email` as sent, and - * delivers nothing — the exact declared-but-not-delivered gap #5087 closed - * inside the plugin, left behind one layer up. - * - * Refusing is only defensible because "this environment does not send mail" has - * a way to say itself: `OS_EMAIL_PROVIDER=log` (the default). An operator who - * names a delivery provider has declared an intent, and the honest answer to an - * intent we cannot honour is a failure, not a substitute transport. - * - * The provider vocabulary and the "needs an API key" question are both read - * from `@objectstack/plugin-email` — the package that has to materialise the - * transport — rather than restated here. Two literals describing one vocabulary - * is how the settings dropdown and the transports drifted apart (#5094). - * - * `OS_EMAIL_QUEUE_ENABLED=true` (or `config.email.queueDelivery`) switches - * delivery from inline to the durable `sys_job_queue` path (#5160). It reuses - * `OS_EMAIL_RETRIES` as its attempt budget rather than adding a second retry - * knob — see `EmailServicePlugin.makeQueueDelivery`. - * - * `OS_EMAIL_PERSIST_ENABLED=false` (or `config.email.persist: false`) stops - * every delivery attempt being written to `sys_email` (#5447). The plugin - * option has been live since the plugin had one — it builds no - * `EmailPersistence` when `persist === false` — but nothing carried the - * declared `config.email.persist` here, so a PII-sensitive deployment that - * switched persistence off in `objectstack.config.ts` type-checked, parsed, - * read "Persist to sys_email (default true)" in the generated reference, and - * went on writing every message body to the database. Resolution order is this - * function's own, per setting: env > `config.email.persist` > the plugin - * default (persist ON) — so a config and an env that say nothing leave the - * option absent and the plugin's default untouched. - * - * The template context's `appName` follows the same env-wins rule as every - * other setting here (#5448): `OS_APP_NAME` > `config.email.appName` > - * `config.email.defaultTemplateContext.appName` > top-level `config.appName` > - * `'ObjectStack'`. It used to be the file's one exception — the whole - * `defaultTemplateContext` was spread OVER the resolved value, so a config that - * spelled `defaultTemplateContext: { appName: … }` made `OS_APP_NAME` inert and - * the per-environment override an operator has for a repo-pinned config did - * nothing, silently, in the mail body AND in the `no-reply@.local` - * fallback sender derived from it. Every OTHER key of `defaultTemplateContext` - * is unchanged: it has no env or dedicated-config carrier, so the author's - * context is still spread through wholesale. - */ -export function resolveEmailCapabilityArg( - cfgEmail: Record = {}, - env: NodeJS.ProcessEnv = process.env, - configAppName?: string, -): EmailCapabilityArg { - const provider = String(env.OS_EMAIL_PROVIDER || cfgEmail.provider || 'log').toLowerCase(); - const apiKey = env.OS_EMAIL_API_KEY || cfgEmail.apiKey; - - // OS_EMAIL_FROM supports either "addr@x" or "Name ". - let defaultFrom = cfgEmail.defaultFrom; - if (env.OS_EMAIL_FROM) { - const m = env.OS_EMAIL_FROM.match(/^\s*(?:"?([^"<]*?)"?\s*<\s*([^>]+)\s*>|(\S+))\s*$/); - if (m) { - const name = (m[1] ?? '').trim(); - const address = (m[2] ?? m[3] ?? '').trim(); - if (address) defaultFrom = name ? { name, address } : { address }; - } - } - const retries = env.OS_EMAIL_RETRIES ? Number(env.OS_EMAIL_RETRIES) : cfgEmail.retries; - // `OS_EMAIL_QUEUE_ENABLED` — a boolean feature flag, so `_ENABLED` and - // default-off (Prime Directive #9; a bare `OS_EMAIL_QUEUE` would read as a - // config value, e.g. a queue name). Whether the declaration can be HONOURED - // is not knowable here — no kernel exists yet — so the plugin asserts it on - // `kernel:ready`, where the service registry has settled, and fails the boot - // there if no durable queue showed up. - const queueDelivery = envBooleanFlag(env.OS_EMAIL_QUEUE_ENABLED) ?? cfgEmail.queueDelivery; - // `OS_EMAIL_PERSIST_ENABLED` — the carrier `config.email.persist` never had - // (#5447). `_ENABLED` is Prime Directive #9's boolean-flag shape; unlike the - // queue flag it is default-ON rather than default-off, because it does not - // enable a new capability — it is the off switch for one that has always - // been on, and a deployment that says nothing must keep its `sys_email` - // audit trail. Absent from BOTH sources means the key is left out of the - // constructor options entirely, so the plugin's own default decides. - const persist = envBooleanFlag(env.OS_EMAIL_PERSIST_ENABLED) ?? cfgEmail.persist; - // `appName` is resolved AFTER the context spread, not before it (#5448). - // The other keys of `defaultTemplateContext` are still spread wholesale and - // are the only source for themselves; `appName` alone has dedicated carriers - // above it, and this file's stated contract — "env overrides per setting" — - // has to hold for it like it does for apiKey / defaultFrom / retries / - // queueDelivery / persist / SMTP. Spreading the context over the resolved - // value inverted exactly that one key: an author who wrote - // `defaultTemplateContext: { appName: 'Acme Dev' }` made `OS_APP_NAME` - // silently inert, so the one lever an operator has for a repo-pinned config - // deployed to several environments did nothing — and since the fallback - // sender is slugged from this value, the wrong name reached the envelope as - // well as the body. - // - // `defaultTemplateContext.appName` stays IN the chain rather than losing to - // the two dedicated sources and vanishing: dropping it would demote every - // config that spells only the context form straight to 'ObjectStack', - // trading one silently wrong value for a worse one. Order: env > - // `config.email.appName` > `config.email.defaultTemplateContext.appName` > - // top-level `config.appName` > 'ObjectStack'. - const cfgTemplateContext = cfgEmail.defaultTemplateContext || {}; - const defaultTemplateContext = { - ...cfgTemplateContext, - appName: resolveDeploymentAppName(cfgEmail, env, configAppName), - }; - // Provide a sensible fallback `from` so templates can render even before - // operators configure SMTP/SaaS. The log transport simply prints to stdout; - // the address never leaves the box. - if (!defaultFrom) { - const slug = String(defaultTemplateContext.appName || 'objectstack') - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, '') || 'objectstack'; - defaultFrom = { name: defaultTemplateContext.appName, address: `no-reply@${slug}.local` }; - } - - const smtpEnv: Record = {}; - const smtpHost = env.OS_EMAIL_SMTP_HOST?.trim(); - if (smtpHost) smtpEnv.host = smtpHost; - if (env.OS_EMAIL_SMTP_PORT) smtpEnv.port = Number(env.OS_EMAIL_SMTP_PORT); - if (env.OS_EMAIL_SMTP_SECURE != null) { - const raw = String(env.OS_EMAIL_SMTP_SECURE).trim().toLowerCase(); - smtpEnv.secure = raw !== 'false' && raw !== '0'; - } - if (env.OS_EMAIL_SMTP_USER) smtpEnv.user = env.OS_EMAIL_SMTP_USER; - if (env.OS_EMAIL_SMTP_PASSWORD) smtpEnv.password = env.OS_EMAIL_SMTP_PASSWORD; - const providerOptions = { ...(cfgEmail.options ?? {}), ...smtpEnv }; - - const options: Record = { - provider, - ...(apiKey ? { apiKey } : {}), - ...(Object.keys(providerOptions).length > 0 ? { providerOptions } : {}), - defaultFrom, - ...(retries != null && !Number.isNaN(retries) ? { retries } : {}), - ...(queueDelivery != null ? { queueDelivery: !!queueDelivery } : {}), - ...(persist != null ? { persist: !!persist } : {}), - defaultTemplateContext, - }; - - if (!isEmailTransportProvider(provider)) { - throw new Error( - `provider='${provider}' is not a transport this server can deliver through, so no mail would go out — ` - + `${unsupportedProviderFix(provider)} ` - + 'On this boot path the provider is OS_EMAIL_PROVIDER or config.email.provider; set ' - + 'OS_EMAIL_PROVIDER=log if this environment is not meant to send mail.', - ); - } - if (provider === 'smtp' && !providerOptions.host) { - throw new Error( - "provider='smtp' selects SMTP delivery but no SMTP host is configured — set OS_EMAIL_SMTP_HOST " - + '(plus OS_EMAIL_SMTP_PORT / _SECURE / _USER / _PASSWORD) or config.email.options.host, ' - + 'or choose another provider.', - ); - } - if (emailProviderRequiresApiKey(provider) && !apiKey) { - throw new Error( - `provider='${provider}' selects ${provider} delivery but no API key is configured, so every send would ` - + 'be recorded in sys_email as sent and nothing would leave the box — set OS_EMAIL_API_KEY ' - + '(or config.email.apiKey), or set OS_EMAIL_PROVIDER=log if this environment is not meant to send mail.', - ); - } - return { options }; -} - -/** Constructor options for `SmsServicePlugin`, as the capability loop builds them. */ -export interface SmsCapabilityArg { - options: Record; -} - -/** - * Resolve `SmsServicePlugin` constructor options from `config.sms` + `OS_SMS_*` - * env, and **refuse a provider tag no transport can deliver through** (#5713). - * - * The refusal is the point. Credentials for a real provider normally arrive from - * the `sms` settings namespace at `kernel:ready`, so this function deliberately - * does NOT demand them — a bare `OS_SMS_PROVIDER=twilio` on a host whose Twilio - * keys are stored in Settings is a complete, working configuration and passes - * through untouched. What it refuses is the one thing settings can never repair: - * a provider *tag* outside `SMS_TRANSPORT_PROVIDERS`. - * - * That tag used to travel all the way into the plugin, which caught the - * `makeSmsTransport: unknown provider 'twilo'` throw and substituted - * `LogSmsTransport` behind the operator's back. Measured on `origin/main` before - * this change, `new SmsServicePlugin({ provider: 'twilo' }).init(ctx)`: - * - * - boots without throwing, registers the `sms` service; - * - transport = `LogSmsTransport`, `isConfigured() === false`; - * - one `logger.warn` line, then `send()` answers - * `{ status: 'sent', messageId: 'dev-sms-…' }`. - * - * So a phone-OTP sign-in tells the user "code sent" and nothing leaves the box — - * the same declared-but-not-delivered shape #5132 closed for mail one layer up, - * and the same door #5204 closed on the `SettingsService` env branch. This path - * never reaches `SettingsService`: it runs at kernel-assembly time, before the - * settings service exists, which is exactly why the `sms` namespace's `select` - * options table (`sms.manifest.ts`) could not see it. - * - * The plugin's fallback is left alone on purpose. For a *known* provider with - * incomplete constructor credentials it is correct — the settings bind can still - * swap in a working transport — and it stays the last line of defence for hosts - * that construct `SmsServicePlugin` themselves. `os serve` simply stops handing - * it input it cannot use. - * - * `OS_SMS_PROVIDER=log` (the default) is how an environment says "this box does - * not send SMS", which is what makes refusing the rest fair. - */ -export function resolveSmsCapabilityArg( - cfgSms: Record = {}, - env: NodeJS.ProcessEnv = process.env, -): SmsCapabilityArg { - const provider = String(env.OS_SMS_PROVIDER || cfgSms.provider || 'log').toLowerCase(); - if (!isSmsTransportProvider(provider)) { - throw new Error( - `provider='${provider}' is not a transport this server can deliver through, so every OTP and ` - + "notification SMS would be answered status: 'sent' and nothing would leave the box — " - + `pick one of ${SMS_TRANSPORT_PROVIDERS.join(' / ')} (Settings → SMS Delivery → Provider). ` - + 'On this boot path the provider is OS_SMS_PROVIDER or config.sms.provider; set ' - + 'OS_SMS_PROVIDER=log if this environment is not meant to send SMS.', - ); - } - return { - options: { - provider, - ...(cfgSms.providerOptions ? { providerOptions: cfgSms.providerOptions } : {}), - ...(cfgSms.retries != null ? { retries: cfgSms.retries } : {}), - }, - }; -} +// [#22301] The constructor arguments a served boot hands the `storage`, `email` +// and `sms` providers are read by ONE rule, `resolveCapabilityArgument` +// (`@objectstack/core`), which `@objectstack/verify`'s `bootStack` reads too — +// so an app's tests construct those providers from the configuration its users' +// server does. The readers it calls moved with it: the storage root lives in +// `@objectstack/core`; the mail and SMS readers live beside the transport +// vocabulary they refuse against, in `@objectstack/plugin-email` and +// `@objectstack/service-sms` (the reasons are in each module's header). These +// names stay importable from here, as handles over those declarations — ⛔ never +// a second copy. +export { + resolveStorageCapabilityArg, + resolveStorageLocalRootEnv, + type StorageCapabilityArg, +} from '@objectstack/core'; +export { + resolveEmailCapabilityArg, + resolveDeploymentAppName, + type EmailCapabilityArg, +} from '@objectstack/plugin-email'; +export { resolveSmsCapabilityArg, type SmsCapabilityArg } from '@objectstack/service-sms'; /** * The multi-node gate verdict, as `os serve` consumes it. diff --git a/packages/cli/src/utils/boot-preparation-parity.test.ts b/packages/cli/src/utils/boot-preparation-parity.test.ts index b97f05beeca..c7857db3587 100644 --- a/packages/cli/src/utils/boot-preparation-parity.test.ts +++ b/packages/cli/src/utils/boot-preparation-parity.test.ts @@ -61,6 +61,7 @@ import { providesCapability, resolveAuthSecret, resolvePlatformAuthComposition, + resolveServedCapabilities, resolveStackTiers, } from '@objectstack/core'; import { createStandaloneStack } from '@objectstack/runtime'; @@ -110,6 +111,9 @@ const STEPS: readonly Step[] = [ stackDeclaresMetadata, resolveStackCollection, stackDeclaredCapabilities, + // [#22301] Which tokens get a provider — the declared ones, `email` for + // `auth`, the always-on slate, `job` / `queue` ahead — read by both boots. + resolveServedCapabilities, stampSearchPinyinEnabled, providesCapability, ], @@ -156,6 +160,11 @@ const SERVE_ONLY: Record = { stackSuppliesAuthPlugin: 'serve\'s own "is an AuthPlugin already composed" read ahead of its auth block; the auth-family gate ' + '(resolvePlatformAuthComposition, called by both boots) asks the same predicate first', + resolveCapabilityArgument: + 'what serve constructs each capability provider WITH (the app\'s cubes, its mail and SMS configuration, ' + + 'the storage root; [#22301], shared with @objectstack/verify\'s bootStack): the declaration boot constructs ' + + 'each provider for its declarations only, with a measured posture per token ' + + '(DECLARATION_PROVIDER_POSTURES), and runs no start() that would read a configuration', findSqlDriverForKernel: 'the artifact boot migration gate, a kernel:ready hook over a booted kernel — a serving-boot policy that ' + 'runs after preparation, not a step that shapes what is prepared', diff --git a/packages/cli/src/utils/schema-migration-plugins.ts b/packages/cli/src/utils/schema-migration-plugins.ts index d8a6bd2add1..dfb1afe2880 100644 --- a/packages/cli/src/utils/schema-migration-plugins.ts +++ b/packages/cli/src/utils/schema-migration-plugins.ts @@ -1906,12 +1906,17 @@ async function materializeHostPlugins( * host plugins, so a config's own instance of the same name supersedes it. * - **The capability providers** (`serve` 5): the stack's `requires`, read by * `serve`'s rule (a host config's own; otherwise the compiled artifact's when - * it declares any; otherwise the config's), plus the always-on slate every - * served boot mounts (`PLATFORM_ALWAYS_ON_CAPABILITIES`, `@objectstack/spec`) - * — looked up in `CAPABILITY_PROVIDERS` and skipped when already held, by - * `providesCapability`'s exact identity match (`@objectstack/core`). The + * it declares any; otherwise the config's), expanded by the rule `serve` + * and `@objectstack/verify`'s `bootStack` read — `resolveServedCapabilities` + * (`@objectstack/core`, #22301): `email` for a declared `auth`, the + * always-on slate every served boot mounts (`PLATFORM_ALWAYS_ON_CAPABILITIES`, + * `@objectstack/spec`), `job` / `queue` ahead of what schedules background + * work — looked up in `CAPABILITY_PROVIDERS` and skipped when already held, + * by `providesCapability`'s exact identity match (`@objectstack/core`). The * slate is `serve`'s with no `--preset`: these commands take no preset, as - * they take no `--dev`. + * they take no `--dev`; and no host default (MCP, pinyin search), as + * `bootStack` passes none. Each provider is still constructed for its + * declarations only (below), never from the deployment's configuration. * - **The REST API plugin**, which `serve` composes on every boot and whose * `init()` declares `sys_import_job` (#22202). * @@ -1944,8 +1949,7 @@ async function composeServedPlatform(input: { artifactRequires?: readonly string[]; packageRoot: string; }): Promise<{ authFamily: unknown[]; plugins: unknown[]; notes: string[] }> { - const { CAPABILITY_PROVIDERS, providesCapability } = await import('@objectstack/core'); - const { PLATFORM_ALWAYS_ON_CAPABILITIES } = await import('@objectstack/spec/kernel'); + const { CAPABILITY_PROVIDERS, providesCapability, resolveServedCapabilities } = await import('@objectstack/core'); const config = input.config ?? {}; // `serve`'s `requires` — the rule {@link composeAuthGatedSecurity} states. const requires = !isHostConfig(config) && Array.isArray(input.artifactRequires) @@ -1996,7 +2000,7 @@ async function composeServedPlatform(input: { } return composeProviderForDeclarations(new Ctor() as object); }; - for (const token of [...new Set([...requires, ...PLATFORM_ALWAYS_ON_CAPABILITIES])]) { + for (const token of resolveServedCapabilities(requires).tokens) { const spec = CAPABILITY_PROVIDERS[token]; // A tier token (`auth`, `ui`, `i18n`, `ai`) or one no open package // provides mounts nothing in `serve`'s resolver either. diff --git a/packages/cli/test/normalized-call-sites.test.ts b/packages/cli/test/normalized-call-sites.test.ts index c57f5215171..f6559533902 100644 --- a/packages/cli/test/normalized-call-sites.test.ts +++ b/packages/cli/test/normalized-call-sites.test.ts @@ -399,10 +399,6 @@ const READS: Readonly> = { why: 'its one caller is `lintConfig`, which hands it the stack it folded on entry.', }, // ── os serve ─────────────────────────────────────────────────────────── - 'commands/serve.ts :: config.analyticsCubes': { - reads: 'top-level', - why: 'the first leg of the rule, kept so the legacy `cubes` spelling keeps its precedence: the top level\'s own array, then `cubes`, then `resolveStackCollection(config, \'analyticsCubes\')` for the bodies.', - }, 'commands/serve.ts :: config.docs': { reads: 'top-level', why: 'the dev mirror of `os build`: the read is the top level\'s own inline `docs`, which never move. The root `src/docs` collection is placed by `placeCollectedDocs`, as `os build` places it: on the body of the package whose id is the stack\'s `manifest.id` on a multi-package stack, on the top level when no single package owns it, and each package\'s own docs on that package\'s body.', diff --git a/packages/core/src/capability-composition.test.ts b/packages/core/src/capability-composition.test.ts new file mode 100644 index 00000000000..12640c1daba --- /dev/null +++ b/packages/core/src/capability-composition.test.ts @@ -0,0 +1,186 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #22301 — the rule both `os serve` and `@objectstack/verify`'s `bootStack` + * read for which capability tokens a served boot mounts providers for, and + * what it constructs each provider with. One case per step of each rule, each + * against the answer the step changes, so a step that stops running turns a + * case red rather than leaving every case green. + */ + +import { afterEach, describe, expect, it } from 'vitest'; +import { composeStacks, defineStack } from '@objectstack/spec'; +import { PLATFORM_ALWAYS_ON_CAPABILITIES } from '@objectstack/spec/kernel'; +import { resolveCapabilityArgument, resolveServedCapabilities } from './capability-composition.js'; +import { stackDeclaredCapabilities } from './stack-collections.js'; + +const SLATE = [...PLATFORM_ALWAYS_ON_CAPABILITIES]; + +const svcManifest = { id: 'com.example.ccp.svc', name: 'CCP Service', namespace: 'ccp', version: '1.0.0', type: 'module' }; +const appManifest = { id: 'com.example.ccp.app', name: 'CCP App', namespace: 'ccp', version: '1.0.0', type: 'app' }; +const note = { + name: 'ccp_note', label: 'Note', pluralLabel: 'Notes', sharingModel: 'public_read_write', + fields: { name: { name: 'name', type: 'text', label: 'Name', required: true } }, +}; +const ticket = { + name: 'ccp_ticket', label: 'Ticket', pluralLabel: 'Tickets', sharingModel: 'public_read_write', + fields: { title: { name: 'title', type: 'text', label: 'Title', required: true } }, +}; + +/** Two packages through the real producer: `svcExtra` lives only in the service package's body. */ +const twoPackages = (svcExtra: Record): Record => + composeStacks( + [ + defineStack({ manifest: svcManifest, objects: [note], ...svcExtra } as never), + defineStack({ manifest: appManifest, objects: [ticket] } as never), + ], + { manifest: 'preserve' }, + ) as unknown as Record; + +describe('resolveServedCapabilities — which tokens a served boot mounts', () => { + it('an app that declares nothing gets the always-on slate, in the slate\'s order', () => { + const served = resolveServedCapabilities([]); + // Anti-vacuity: the slate is the spec's, and it is not empty. + expect(SLATE.length).toBeGreaterThan(0); + expect(served.tokens).toEqual(SLATE); + expect([...served.declared]).toEqual([]); + }); + + it('the declared tokens come first, deduplicated, and a slate member declared is not repeated', () => { + const served = resolveServedCapabilities(['automation', 'cache', 'automation']); + expect(served.tokens.slice(0, 2)).toEqual(['automation', 'cache']); + expect(served.tokens.filter((token) => token === 'cache')).toEqual(['cache']); + expect(served.tokens.slice(2)).toEqual(SLATE.filter((token) => token !== 'cache')); + expect([...served.declared]).toEqual(['automation', 'cache']); + }); + + it('`--preset minimal` mounts no slate — only what the stack declares', () => { + expect(resolveServedCapabilities(['automation'], { preset: 'minimal' }).tokens).toEqual(['automation']); + // Control: any other preset name keeps the slate. + expect(resolveServedCapabilities(['automation'], { preset: 'default' }).tokens).toEqual([ + 'automation', + ...SLATE, + ]); + }); + + it('a declared `auth` brings `email`, and `job` / `queue` move ahead of what schedules background work', () => { + const served = resolveServedCapabilities(['auth'], { preset: 'minimal' }); + expect(served.tokens).toEqual(['job', 'queue', 'auth', 'email']); + // `email` was appended by the platform, not declared by the stack. + expect([...served.declared]).toEqual(['auth']); + // Control: nothing that schedules background work, nothing moved. + expect(resolveServedCapabilities(['automation'], { preset: 'minimal' }).tokens).toEqual(['automation']); + }); + + it('the host defaults land after the declared tokens and before the slate', () => { + const served = resolveServedCapabilities(['automation'], { hostDefaults: ['mcp', 'pinyin-search'] }); + expect(served.tokens).toEqual(['automation', 'mcp', 'pinyin-search', ...SLATE]); + expect(served.declared.has('mcp')).toBe(false); + }); + + it('a package body\'s `requires`, as `stackDeclaredCapabilities` reads it for the boots, is what the rule mounts', () => { + const stack = twoPackages({ requires: ['approvals'] }); + // Anti-vacuity: the producer really left `requires` out of the top level. + expect(stack.requires).toBeUndefined(); + const served = resolveServedCapabilities(stackDeclaredCapabilities(stack), { preset: 'minimal' }); + expect(served.tokens).toEqual(['job', 'queue', 'approvals']); + expect([...served.declared]).toEqual(['approvals']); + }); +}); + +describe('resolveCapabilityArgument — what each provider is constructed with', () => { + it('`automation` is handed the app\'s root', () => { + expect(resolveCapabilityArgument('automation', { stack: {}, packageRoot: '/srv/app' }).argument).toEqual({ + packageRoot: '/srv/app', + }); + }); + + it('`analytics` is handed the stack\'s cubes: the top level first, then `cubes`, then the package bodies', () => { + const top = [{ name: 'top_cube' }]; + const legacy = [{ name: 'legacy_cube' }]; + const arg = (stack: unknown) => resolveCapabilityArgument('analytics', { stack, packageRoot: '/app' }).argument; + expect(arg({ analyticsCubes: top, cubes: legacy })).toEqual({ cubes: top }); + expect(arg({ cubes: legacy })).toEqual({ cubes: legacy }); + const bodyCube = { + name: 'ccp_note_cube', title: 'Notes', sql: 'ccp_note', + measures: { count: { label: 'Count', type: 'count', sql: '*' } }, + dimensions: { name: { label: 'Name', type: 'string', sql: 'name' } }, + }; + const stack = twoPackages({ analyticsCubes: [bodyCube] }); + expect(stack.analyticsCubes).toBeUndefined(); + expect((arg(stack) as { cubes: Array<{ name: string }> }).cubes.map((cube) => cube.name)).toEqual(['ccp_note_cube']); + // Control: a stack with no cubes hands an empty list, never someone else's. + expect(arg({})).toEqual({ cubes: [] }); + }); + + it('`email` and `sms` are built by the reader their provider module exports, from the stack and the env', () => { + const calls: unknown[][] = []; + const providerModule = { + resolveEmailCapabilityArg: (...args: unknown[]) => { + calls.push(['email', ...args]); + return { options: { built: 'email' } }; + }, + resolveSmsCapabilityArg: (...args: unknown[]) => { + calls.push(['sms', ...args]); + return { options: { built: 'sms' } }; + }, + }; + const env = { OS_EMAIL_PROVIDER: 'log' }; + const stack = { email: { persist: false }, sms: { provider: 'log' }, appName: 'Acme' }; + expect(resolveCapabilityArgument('email', { stack, packageRoot: '/app', providerModule, env }).argument).toEqual({ + built: 'email', + }); + expect(resolveCapabilityArgument('sms', { stack, packageRoot: '/app', providerModule, env }).argument).toEqual({ + built: 'sms', + }); + expect(calls).toEqual([ + ['email', { persist: false }, env, 'Acme'], + ['sms', { provider: 'log' }, env], + ]); + // An absent block is read as `{}`, never skipped. + calls.length = 0; + resolveCapabilityArgument('email', { stack: {}, packageRoot: '/app', providerModule, env }); + expect(calls).toEqual([['email', {}, env, undefined]]); + }); + + it('a provider module without its reader is refused by name, never built with defaults', () => { + expect(() => resolveCapabilityArgument('email', { stack: {}, packageRoot: '/app', providerModule: {} })).toThrow( + /does not export resolveEmailCapabilityArg/, + ); + expect(() => resolveCapabilityArgument('sms', { stack: {}, packageRoot: '/app' })).toThrow( + /does not export resolveSmsCapabilityArg/, + ); + }); + + describe('`storage` is handed its local root', () => { + const saved = { canonical: process.env.OS_STORAGE_LOCAL_ROOT, legacy: process.env.OS_STORAGE_ROOT }; + afterEach(() => { + for (const [name, value] of [['OS_STORAGE_LOCAL_ROOT', saved.canonical], ['OS_STORAGE_ROOT', saved.legacy]] as const) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + }); + + it('the built-in root when the environment names none, and the environment\'s when it does', () => { + delete process.env.OS_STORAGE_LOCAL_ROOT; + delete process.env.OS_STORAGE_ROOT; + expect(resolveCapabilityArgument('storage', { stack: {}, packageRoot: '/app' })).toEqual({ + argument: { adapter: 'local', local: { rootDir: '.objectstack/data/uploads' } }, + localStorageRoot: '.objectstack/data/uploads', + }); + process.env.OS_STORAGE_LOCAL_ROOT = '/srv/uploads'; + expect(resolveCapabilityArgument('storage', { stack: {}, packageRoot: '/app' }).argument).toEqual({ + adapter: 'local', + local: { rootDir: '/srv/uploads' }, + }); + }); + }); + + it('any other token is constructed with no argument', () => { + for (const token of ['cache', 'queue', 'job', 'messaging', 'approvals', 'not-a-token']) { + expect(resolveCapabilityArgument(token, { stack: { analyticsCubes: [] }, packageRoot: '/app' }), token).toEqual({ + argument: undefined, + }); + } + }); +}); diff --git a/packages/core/src/capability-composition.ts b/packages/core/src/capability-composition.ts new file mode 100644 index 00000000000..d6bcaa74c5c --- /dev/null +++ b/packages/core/src/capability-composition.ts @@ -0,0 +1,342 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * What a served boot MOUNTS for a stack's capabilities, and what it CONSTRUCTS + * each provider with — the one rule `os serve` (`@objectstack/cli`) and the + * verification handle (`@objectstack/verify`'s `bootStack`) both read. + * + * ## Why this lives in `@objectstack/core` + * + * For one configuration, `bootStack` composes what `objectstack serve` + * composes (#22301, ruling A). `capability-providers.ts`, beside this file, + * answers WHICH provider a `requires` token names. Two more answers decided + * what a served boot mounts, and until #22301 both were written inside + * `serve`'s command body, where the handle could not read them + * (`@objectstack/cli` depends on `@objectstack/verify`): + * + * - WHICH TOKENS ({@link resolveServedCapabilities}). `serve` appends the + * always-on slate (`PLATFORM_ALWAYS_ON_CAPABILITIES`, `@objectstack/spec`) to + * every app's `requires`; the handle mounted only what `requires` named, and + * a slate provider only where a mounted plugin hard-depended on it. + * - WITH WHAT ({@link resolveCapabilityArgument}). `serve` hands `analytics` + * the app's `analyticsCubes`, `email` and `sms` the deployment's mail and SMS + * configuration, `storage` its local root, and `automation` the app's root; + * the handle built every provider but `automation` with its own defaults. + * + * So an app's tests ran without the cubes, the mail configuration and the + * services the same app's users get. Both answers are here, and both boots read + * them: ⛔ never a second copy beside a reader. + * + * ## The boundary, measured + * + * What stays with each boot, because it is that boot's own state: + * + * - `serve`'s `--preset` (a CLI flag): `minimal` mounts no slate. Passed in as + * `preset`; the handle takes no preset, as `os migrate plan` takes none. + * - The host's process-level defaults, decided by process env and passed in as + * `hostDefaults`: `serve`'s MCP HTTP surface (`OS_MCP_SERVER_ENABLED`) and + * pinyin search (`OS_SEARCH_PINYIN_ENABLED`, which `serve` stamps from the + * stack's locales). Neither is a slate member nor a token the app declares. + * - What a boot does when a provider cannot be built (`serve`: a declared + * token fails the boot, a slate token is logged and skipped; the handle: any + * one fails the boot, with its remedy), and `serve`'s production warning + * about a local storage root. + * + * What lives in the provider's own package, and why: the `email` and `sms` + * arguments are read by `resolveEmailCapabilityArg` (`@objectstack/plugin-email`) + * and `resolveSmsCapabilityArg` (`@objectstack/service-sms`). Each refuses + * exactly the configurations its package's transports cannot build, so it reads + * that package's transport vocabulary — and both packages depend on this one, + * so this package cannot import them. {@link resolveCapabilityArgument} reads + * each reader from the module the provider is constructed from, which every + * boot has already loaded to reach the provider's class. + * + * Pure apart from the environment reads of the storage root, the mail and SMS + * readers: importing this module loads nothing. + */ + +import { PLATFORM_ALWAYS_ON_CAPABILITIES } from '@objectstack/spec/kernel'; +import { readEnvWithDeprecation } from '@objectstack/types'; +import { CAPABILITY_PROVIDERS } from './capability-providers.js'; +import { resolveStackCollection } from './stack-collections.js'; + +type Bag = Record; + +const asBag = (value: unknown): Bag | undefined => + value && typeof value === 'object' ? (value as Bag) : undefined; + +/** The tokens a served boot mounts providers for, and which of them the stack declares. */ +export interface ServedCapabilities { + /** Every token, deduplicated, in the order a served boot mounts its provider. */ + readonly tokens: readonly string[]; + /** + * The tokens the stack itself declares — the "required" intent + * (#1597): under `serve` a declared token whose provider cannot be provided + * is a hard boot error, where a token the platform appended is best-effort. + */ + readonly declared: ReadonlySet; +} + +/** + * The tokens whose providers schedule background work (durable retries, SLA + * escalation, auth mail), so `job` and `queue` are mounted AHEAD of them. + */ +const NEEDS_JOB_AND_QUEUE: readonly string[] = ['email', 'approvals', 'auth']; + +/** + * The capability tokens a served boot mounts providers for, in mount order, + * given the tokens the stack DECLARES — the rule `os serve` reads, in its + * order: + * + * 1. the declared tokens, deduplicated in first-seen order. A reader hands + * them in as it reads them: `serve` and the handle by + * `stackDeclaredCapabilities` (the top-level `requires`, otherwise each + * package body's); `os migrate plan`'s declaration boot by the same reader, + * or off a compiled artifact for a host config. Reading `requires` stays + * each boot's own step, so it is ONE call shared with the other readers of + * `requires`, never a second copy inside this rule; + * 2. `email` when `auth` is declared — auth callbacks (password reset, email + * verification, magic link, invitation) depend on the email service, which + * falls back to its log transport when no provider is configured; + * 3. `hostDefaults`, the host's own process-level defaults (see the module + * header); + * 4. the always-on slate (`PLATFORM_ALWAYS_ON_CAPABILITIES`), unless the + * preset is `minimal`; + * 5. `job`, then `queue`, moved to the FRONT when a token that schedules + * background work is present and they are not already mounted — so their + * plugins load, and their `kernel:ready` hooks fire, before a consumer + * subscribes to a queue from its own. + * + * A token with no provider row (a tier token such as `auth` or `ui`) is kept: + * which tokens mount nothing is each reader's lookup in `CAPABILITY_PROVIDERS`. + */ +export function resolveServedCapabilities( + declaredTokens: readonly string[], + opts: { + /** `serve`'s `--preset`; `minimal` mounts no slate. */ + readonly preset?: string; + /** The host's process-level default tokens, appended before the slate. */ + readonly hostDefaults?: readonly string[]; + } = {}, +): ServedCapabilities { + const tokens = [...new Set(declaredTokens)]; + const declared: ReadonlySet = new Set(tokens); + if (tokens.includes('auth') && !tokens.includes('email')) tokens.push('email'); + for (const token of opts.hostDefaults ?? []) { + if (!tokens.includes(token)) tokens.push(token); + } + if (opts.preset !== 'minimal') { + for (const token of PLATFORM_ALWAYS_ON_CAPABILITIES) { + if (!tokens.includes(token)) tokens.push(token); + } + } + if (NEEDS_JOB_AND_QUEUE.some((token) => tokens.includes(token))) { + if (!tokens.includes('queue')) tokens.unshift('queue'); + if (!tokens.includes('job')) tokens.unshift('job'); + } + return { tokens, declared }; +} + +/** What {@link resolveCapabilityArgument} reads to build one provider's argument. */ +export interface CapabilityArgumentInput { + /** The stack being booted: its `analyticsCubes` (or `cubes`), `email`, `sms` and `appName` are read. */ + readonly stack: unknown; + /** The app's root — the directory holding its `objectstack.config.ts` — handed to `automation`. */ + readonly packageRoot: string; + /** + * The module the provider is constructed from. Its own configuration reader + * is read from it for `email` and `sms` (see the module header); required + * for those two tokens. + */ + readonly providerModule?: Readonly>; + /** The environment the mail and SMS readers read. Defaults to `process.env`. */ + readonly env?: Readonly>; +} + +/** One provider's constructor argument. */ +export interface CapabilityArgument { + /** Construct the provider with this, or with no argument when `undefined`. */ + readonly argument: unknown; + /** + * `storage` only: the local root the argument names, which `serve` warns + * about on a production boot (uploads on one pod's disk). + */ + readonly localStorageRoot?: string; +} + +/** A provider module's own configuration reader, read off the module by name. */ +function providerReader( + token: string, + input: CapabilityArgumentInput, + exportName: string, +): (...args: A) => { options: Record } { + const reader = input.providerModule?.[exportName]; + if (typeof reader !== 'function') { + throw new Error( + `Capability "${token}": ${CAPABILITY_PROVIDERS[token]?.pkg ?? 'its provider package'} does not export ` + + `${exportName}, the reader that builds the provider from this deployment's configuration — install a ` + + 'version of that package released with this one.', + ); + } + return reader as (...args: A) => { options: Record }; +} + +/** + * The argument a served boot constructs `token`'s MAIN provider with — the + * rule `os serve` reads (its capability resolver) and the handle reads too. + * A provider's `extras` take no argument. + * + * - `automation`: `{ packageRoot }` — declarative connector file refs + * (`providerConfig.spec: './billing-openapi.json'`) resolve from the app's + * root, never from the process's working directory (#3016). + * - a token whose row declares `configKey: 'analyticsCubes'` (`analytics`): + * `{ cubes }` — the stack's top-level `analyticsCubes`, then its legacy + * `cubes` spelling, and only then each package body's + * (`resolveStackCollection`): a multi-package stack carries a package's + * cubes in that body alone (#22288). + * - `email`: the mail provider's options from `stack.email`, `OS_EMAIL_*` and + * `stack.appName` — `resolveEmailCapabilityArg`, read off the provider + * module. It THROWS on a configuration no transport can deliver through. + * - `sms`: the SMS provider's options from `stack.sms` and `OS_SMS_*` — + * `resolveSmsCapabilityArg`, read off the provider module. It THROWS on a + * provider tag no transport can deliver through. + * - `storage`: the local adapter at {@link resolveStorageLocalRootEnv}'s root, + * else `.objectstack/data/uploads` ({@link resolveStorageCapabilityArg}). + * - any other token: no argument. + * + * A throw is the boot's to report: whether it fails the boot or is logged is + * each reader's rule (see the module header). + */ +export function resolveCapabilityArgument(token: string, input: CapabilityArgumentInput): CapabilityArgument { + const stack = asBag(input.stack) ?? {}; + if (token === 'automation') { + return { argument: { packageRoot: input.packageRoot } }; + } + if (CAPABILITY_PROVIDERS[token]?.configKey === 'analyticsCubes') { + const cubes = stack.analyticsCubes ?? stack.cubes ?? resolveStackCollection(input.stack, 'analyticsCubes'); + return { argument: { cubes } }; + } + const env = input.env ?? (typeof process !== 'undefined' ? process.env : {}); + if (token === 'email') { + const read = providerReader<[unknown, unknown, unknown]>(token, input, 'resolveEmailCapabilityArg'); + return { argument: read(stack.email ?? {}, env, stack.appName).options }; + } + if (token === 'sms') { + const read = providerReader<[unknown, unknown]>(token, input, 'resolveSmsCapabilityArg'); + return { argument: read(stack.sms ?? {}, env).options }; + } + if (token === 'storage') { + const storage = resolveStorageCapabilityArg(resolveStorageLocalRootEnv()); + return { argument: storage.options, localStorageRoot: storage.localRoot }; + } + return { argument: undefined }; +} + +// ── storage ─────────────────────────────────────────────────────────────── +// Moved here from `@objectstack/cli`'s `serve.ts` (#22301), which re-exports +// both names, so every boot that composes what `serve` composes reads one root. + +/** + * Constructor options for `StorageServicePlugin`, plus the local root to name in + * the production warning (absent when the host configured a backend itself). + */ +export interface StorageCapabilityArg { + options: Record; + localRoot?: string; +} + +/** + * Resolve what `StorageServicePlugin` is constructed with (#4096). + * + * Storage is in the default capability slate, so a host that configures nothing + * still gets local disk under `.objectstack/data/uploads/` and avatars / + * attachments / report files work out of the box. + * + * The fallback used to be `{ driver: 'local', root }` — neither of which + * `StorageServicePluginOptions` declares. Both were dropped on the floor, so the + * plugin applied its OWN default (`./storage`), the storage-root env var changed + * nothing, and uploads landed somewhere the operator never named. The `storage` + * settings namespace then corrected the root on its first read (its manifest + * default IS `./.objectstack/data/uploads`), which swapped the adapter and + * warned about stranded files — on every boot of a healthy server. + * + * #4096 fixed the option SHAPE; the value still could not reach the settings + * side, because the CLI and the settings service spelled the env var + * differently. {@link resolveStorageLocalRootEnv} is the channel that closes + * that gap (#4968) — read the root through it, never off `process.env` + * directly. + * + * `config.storage` is deliberately NOT read (framework#4167). It was never a + * stack key: `ObjectStackDefinitionSchema` does not declare it, and the schema + * is not `.strict()`, so `defineStack` — which every documented authoring path + * and every compiled artifact goes through — strips it before `serve` could + * ever see it. It arrived here only from a bare-object config on the + * config-boot path, i.e. one unreachable-in-practice combination, where it then + * ALSO carried the `driver`/`root` spelling the plugin does not read. Honouring + * it on that one path meant the same authoring key worked in one place and + * vanished in every other, which is worse than not having it. + * + * The storage backend is a deployment concern with two real channels: the + * `OS_STORAGE_*` env vars (below) and the `storage` settings namespace, which + * is also the one with proper credential handling. Authors who write `storage:` + * anyway now get told so — `lintUnknownStackKeys` reports undeclared top-level + * keys, and `STACK_KEY_GUIDANCE` names both channels. + */ +export function resolveStorageCapabilityArg(envRoot?: string): StorageCapabilityArg { + const rootDir = envRoot?.trim() || '.objectstack/data/uploads'; + return { options: { adapter: 'local', local: { rootDir } }, localRoot: rootDir }; +} + +/** + * The ONE env channel for the local storage root (#4968). + * + * The CLI used to invent its own name, `OS_STORAGE_ROOT`, while the settings + * service derives the env name for the same value from the namespace it owns: + * `envKeyOf('storage', 'local_root')` = `OS_STORAGE_LOCAL_ROOT`. Nothing in the + * repo ever set that name, so the two channels never met — the CLI constructed + * an adapter at the root the operator asked for, and `StorageServicePlugin` + * then re-resolved from settings at `kernel:ready`, found nothing but the + * manifest's schema DEFAULT, and swapped the adapter to + * `./.objectstack/data/uploads`. + * + * The consequences were not log noise: + * + * - `OS_STORAGE_ROOT` took effect for exactly one value — the one that happens + * to equal the manifest default. Every other value (`/srv/uploads`, a + * `--fresh` tempdir) was constructed and then discarded, so an operator + * following `backup-restore.mdx` backed up an empty directory. + * - `dev --fresh` promised the tempdir "owns ALL persistent state for this + * run"; uploads actually landed under the project cwd and survived exit. + * - The "adapter swapped … may be unreachable" warning on every clean boot was + * ACCURATE — the swap really happened. It is not touched here, and it stops + * firing because the swap stops happening. + * + * So the fix is at the producer, not in a tolerant consumer: write the name the + * settings service already declares. The legacy name is read for one more + * release via {@link readEnvWithDeprecation} and, when it is the one that + * supplied the value, STAMPED onto the canonical name — the settings service + * reads `process.env` live through its own `env` reference and only ever looks + * up `OS_STORAGE_LOCAL_ROOT`, so without the stamp a legacy deployment would + * keep the exact bug this fixes. With it, settings resolves + * `source: 'env'`/`locked: true` at the value the adapter was built with, + * `needsStorageSwap` answers false, and the two channels agree by construction. + * + * Side-effecting on purpose, and idempotent: `readEnvWithDeprecation` + * deduplicates its warning process-wide, and once stamped the canonical branch + * wins on every later call. + * + * @returns The resolved root, or `undefined` when neither name is set (the + * caller then falls through to `resolveStorageCapabilityArg`'s + * built-in default, which matches the manifest default). + */ +export function resolveStorageLocalRootEnv(): string | undefined { + const value = readEnvWithDeprecation('OS_STORAGE_LOCAL_ROOT', 'OS_STORAGE_ROOT'); + if (value === undefined) return undefined; + // Bridge the legacy spelling onto the canonical one the settings service + // reads. Guarded so we never rewrite a canonical value with itself. + if (typeof process !== 'undefined' && process.env + && process.env.OS_STORAGE_LOCAL_ROOT === undefined) { + process.env.OS_STORAGE_LOCAL_ROOT = value; + } + return value; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ebca4d0e295..0324073a701 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -25,6 +25,11 @@ export * from './artifact-packages.js'; // both already depend on this package. export * from './stack-collections.js'; export * from './capability-providers.js'; +// [#22301] And which tokens a served boot mounts providers for (the always-on +// slate appended to `requires`) and what it constructs each provider with +// (the app's cubes, its mail and SMS configuration, the storage root). Same +// reason. +export * from './capability-composition.js'; // [#22301] And the rule for the app's own `plugins` array, which both boots // mount: what a string, bundle or instance entry becomes. Same reason. export * from './stack-plugins.js'; diff --git a/packages/cli/src/commands/serve-email-config-parity.contract.test.ts b/packages/plugins/plugin-email/src/capability-arg.config-parity.contract.test.ts similarity index 89% rename from packages/cli/src/commands/serve-email-config-parity.contract.test.ts rename to packages/plugins/plugin-email/src/capability-arg.config-parity.contract.test.ts index 9e477b7e4f1..99c3c127cb5 100644 --- a/packages/cli/src/commands/serve-email-config-parity.contract.test.ts +++ b/packages/plugins/plugin-email/src/capability-arg.config-parity.contract.test.ts @@ -4,8 +4,11 @@ // actually reads off `config.email` — #5307. // // `config.email` has exactly ONE reader in the whole repo (this file's -// neighbour, `serve.ts`), and `EmailServiceConfigSchema` is the operator-facing -// contract for it. Nothing held the two together, so the schema fell behind the +// neighbour, `capability-arg.ts` — written in `@objectstack/cli`'s `serve.ts` +// until #22301 moved it here, beside the transport vocabulary it refuses +// against, so `os serve` and `@objectstack/verify`'s `bootStack` construct the +// email provider by one reader; this test moved with it), and +// `EmailServiceConfigSchema` is the operator-facing contract for it. Nothing held the two together, so the schema fell behind the // reader twice in one family: // // - #5104 — `provider: 'smtp'` shipped in #5087 and the enum still stopped at @@ -27,10 +30,10 @@ // is added. // // This package's `tsconfig.json` includes `src` (tests and all), so the -// compile-time witness below is real: `pnpm --filter @objectstack/cli +// compile-time witness below is real: `pnpm --filter @objectstack/plugin-email // typecheck` fails on a config the schema cannot express, before any test runs. // The spec-side companion — `@objectstack/spec`'s own `system/email-config.test.ts`, -// named without its repo-relative path here for the reason this directory's +// named without its repo-relative path here for the reason `@objectstack/cli`'s // `serve-multi-node-cap-advisory.pin.test.ts` gives for the same gate: a quoted // literal starting at `packages/` would force `check:cross-package-test-inputs` // to demand a glob for a file this test never actually reads — has to be @@ -40,10 +43,9 @@ import { describe, it, expect } from 'vitest'; import { readFileSync } from 'node:fs'; import path from 'node:path'; -import { fileURLToPath } from 'node:url'; import { EmailServiceConfigSchema } from '@objectstack/spec/system'; import type { EmailServiceConfig } from '@objectstack/spec/system'; -import { resolveEmailCapabilityArg } from './serve.js'; +import { resolveEmailCapabilityArg } from './capability-arg.js'; // The repo's one comment/code separator (#9367). This file used to scan RAW // source with no separator at all (until commit 5359a9b4c): a docblock or a `// TODO: also // read cfgEmail.foo` line was indistinguishable from a real dot access. Typed @@ -52,23 +54,24 @@ import { resolveEmailCapabilityArg } from './serve.js'; // suppression. import { maskComments } from '../../../../scripts/js-comment-mask.mjs'; -const SERVE_SOURCE = readFileSync( - path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'serve.ts'), - 'utf8', -); +// Seeded from `__dirname`, as this package's `smtp-port-contract.test.ts` is: +// the package's build config compiles to CommonJS, where `import.meta` does +// not type-check. +const READER_SOURCE = readFileSync(path.resolve(__dirname, 'capability-arg.ts'), 'utf8'); /** - * `serve.ts` with every comment span blanked (offsets preserved, bytes - * replaced with spaces) — see `keysReadFromConfigEmail` below for why the - * scan reads this instead of `SERVE_SOURCE` directly (since commit 5359a9b4c). + * `capability-arg.ts` with every comment span blanked (offsets preserved, + * bytes replaced with spaces) — see `keysReadFromConfigEmail` below for why + * the scan reads this instead of `READER_SOURCE` directly (since commit + * 5359a9b4c). */ -const MASKED_SERVE_SOURCE = maskComments(SERVE_SOURCE); +const MASKED_READER_SOURCE = maskComments(READER_SOURCE); /** * Every `config.email` key the resolver reads, straight from its source — the * issue's own repro command: * - * grep -oE "cfgEmail\.[a-zA-Z]+" packages/cli/src/commands/serve.ts | sort -u + * grep -oE "cfgEmail\.[a-zA-Z]+" /src/capability-arg.ts | sort -u * * `cfgEmail` is the resolver's parameter name for `config.email`, and every * read of it in that function is a dot access (no destructuring, no computed @@ -77,11 +80,11 @@ const MASKED_SERVE_SOURCE = maskComments(SERVE_SOURCE); * the next reader learns the scan has to change with it. * * Takes `source` explicitly (default the real file's masked text, since commit 5359a9b4c) - * rather than closing over `SERVE_SOURCE`/`MASKED_SERVE_SOURCE` directly, so + * rather than closing over `READER_SOURCE`/`MASKED_READER_SOURCE` directly, so * the vacuity-proof tests below can drive the exact same regex over a raw vs. * a masked variant of a shape and show the two disagree. */ -function keysReadFromConfigEmail(source: string = MASKED_SERVE_SOURCE): string[] { +function keysReadFromConfigEmail(source: string = MASKED_READER_SOURCE): string[] { const reads = source.match(/cfgEmail\.[A-Za-z_$][\w$]*/g) ?? []; return [...new Set(reads.map((r) => r.slice('cfgEmail.'.length)))].sort(); } @@ -154,7 +157,7 @@ describe('EmailServiceConfigSchema ↔ resolveEmailCapabilityArg', () => { * Vacuity proof (commit 5359a9b4c): both directions the raw scan was one ordinary * comment away from getting wrong, reproduced on synthetic sources shaped * like the real resolver so the two legs (raw vs. masked) can be compared - * without waiting for `serve.ts` to actually regress. Each `it` shows the RAW + * without waiting for `capability-arg.ts` to actually regress. Each `it` shows the RAW * leg producing the wrong verdict — the verdict this file's scan would have * produced before commit 5359a9b4c — and the MASKED leg producing the right one. */ @@ -244,7 +247,7 @@ describe('a config the schema accepts reaches the plugin intact', () => { // `appName` AFTER the spread. This pin now guards the new order. // // Its angle is this file's own, and not a restatement of - // `serve-email-appname-precedence.test.ts`: that file feeds the resolver + // `@objectstack/cli`'s `serve-email-appname-precedence.test.ts`: that file feeds the resolver // raw objects, whereas the config below goes through the real // `EmailServiceConfigSchema.parse()` first. So what is pinned here is that // the two keys #5307 added to the CONTRACT survive the parse AND land on diff --git a/packages/plugins/plugin-email/src/capability-arg.ts b/packages/plugins/plugin-email/src/capability-arg.ts new file mode 100644 index 00000000000..770a26e8da1 --- /dev/null +++ b/packages/plugins/plugin-email/src/capability-arg.ts @@ -0,0 +1,262 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * What `EmailServicePlugin` is constructed with on a served boot: the + * deployment's mail configuration (`config.email`, `OS_EMAIL_*`, `OS_APP_NAME`), + * read into the plugin's options — and refused when no transport this package + * ships can deliver through it. + * + * ## Why this lives here, and not in `@objectstack/cli` or `@objectstack/core` + * + * It was written in `os serve`'s command body (`@objectstack/cli`), which read + * the provider vocabulary from this package "rather than restated here" + * (#5132): the reader has to refuse exactly the configurations `makeTransport` + * cannot build. For one configuration, `@objectstack/verify`'s `bootStack` + * composes what `serve` composes (#22301, ruling A) — the email provider + * included, built from the same configuration — and the handle cannot import + * the CLI (`@objectstack/cli` depends on `@objectstack/verify`). The rule for + * which token gets which argument moved to `@objectstack/core` + * (`resolveCapabilityArgument`), but `@objectstack/core` cannot import this + * package either (this package depends on it). So the reader lives beside the + * vocabulary it refuses against, and `resolveCapabilityArgument` reads it off + * this module — the module every boot already loads to construct + * `EmailServicePlugin`. `@objectstack/cli` re-exports both names from here. + * ⛔ Never a second copy beside a reader. + */ + +import { + emailProviderRequiresApiKey, + isEmailTransportProvider, + unsupportedProviderFix, +} from './transports/index.js'; + +/** + * Constructor options for `EmailServicePlugin`. + * + * There is no `warning` channel here any more (#5132). It carried exactly one + * message — "provider=resend but no apiKey, falling back to LogTransport" — + * and that fallback is now a throw, because a mail configuration that cannot + * deliver has no "degraded but still fine" reading: it is a server that + * accepts every send and delivers nothing. + */ +export interface EmailCapabilityArg { + options: Record; +} + +/** + * The ONE truth table this file reads its `OS_EMAIL_*_ENABLED` booleans with + * (#5447). + * + * Extracted rather than restated: `OS_EMAIL_QUEUE_ENABLED` carried this list + * inline, and a second boolean flag written a second way is how one env var + * ends up accepting `on` while its neighbour does not — the operator-visible + * half of the "two literals describing one vocabulary" trap that split the + * settings dropdown from the transports (#5094). + * + * Tri-state on purpose: `undefined` means the variable is unset and the caller + * must fall through to config, which is what keeps an absent flag from + * silently reading as `false` and overriding a config that said `true`. + * An empty string is a SET variable and resolves to `false`, matching the + * behaviour `OS_EMAIL_QUEUE_ENABLED` already had. + */ +function envBooleanFlag(raw: string | undefined): boolean | undefined { + if (raw == null) return undefined; + return ['1', 'true', 'yes', 'on'].includes(String(raw).trim().toLowerCase()); +} + +/** + * The deployment's product name: `OS_APP_NAME` > `config.email.appName` > + * `config.email.defaultTemplateContext.appName` > top-level `config.appName` > + * `'ObjectStack'` (the chain {@link resolveEmailCapabilityArg} documents). + * + * ONE resolver for every boot-time consumer of the name, so they cannot + * disagree: the email capability's template context, and the `appName` + * AuthPlugin receives (the auth emails' `{{appName}}` and the TOTP issuer an + * authenticator app lists the account under). AuthPlugin used to be built + * without it, so `OS_APP_NAME` never reached auth: its emails, whose own + * `appName` outranks the template context, said 'ObjectStack'. + */ +export function resolveDeploymentAppName( + cfgEmail: Record = {}, + env: NodeJS.ProcessEnv = process.env, + configAppName?: string, +): string { + return env.OS_APP_NAME || cfgEmail.appName || cfgEmail.defaultTemplateContext?.appName + || configAppName || 'ObjectStack'; +} + +/** + * Resolve what `EmailServicePlugin` is constructed with, from `config.email` + * plus `OS_EMAIL_*` env (env wins, so an operator can override per environment). + * + * SMTP (#5087, ADR-0012) is configured through `OS_EMAIL_SMTP_HOST` / `_PORT` / + * `_SECURE` / `_USER` / `_PASSWORD` — the `OS_{DOMAIN}_{FEATURE}_{QUALIFIER}` + * shape of Prime Directive #9, grouped with the email vars rather than the bare + * third-party `SMTP_*` names — layered over `config.email.options`. + * + * **Every provider that cannot deliver throws** — `smtp` with no host, and + * (since #5132) `resend`/`postmark` with no API key, or a provider tag outside + * `EMAIL_TRANSPORT_PROVIDERS` altogether. The capability loop turns that into a + * loud failure — a hard boot error when the app declared `requires: ['email']`, + * otherwise a `console.error` and no email service — which is the point: the + * alternative (quietly substituting the LogTransport, as this function's + * `resend`/`postmark` arm used to do for a missing API key) hands the operator a + * server that accepts every send, records it in `sys_email` as sent, and + * delivers nothing — the exact declared-but-not-delivered gap #5087 closed + * inside the plugin, left behind one layer up. + * + * Refusing is only defensible because "this environment does not send mail" has + * a way to say itself: `OS_EMAIL_PROVIDER=log` (the default). An operator who + * names a delivery provider has declared an intent, and the honest answer to an + * intent we cannot honour is a failure, not a substitute transport. + * + * The provider vocabulary and the "needs an API key" question are both read + * from `@objectstack/plugin-email` — the package that has to materialise the + * transport — rather than restated here. Two literals describing one vocabulary + * is how the settings dropdown and the transports drifted apart (#5094). + * + * `OS_EMAIL_QUEUE_ENABLED=true` (or `config.email.queueDelivery`) switches + * delivery from inline to the durable `sys_job_queue` path (#5160). It reuses + * `OS_EMAIL_RETRIES` as its attempt budget rather than adding a second retry + * knob — see `EmailServicePlugin.makeQueueDelivery`. + * + * `OS_EMAIL_PERSIST_ENABLED=false` (or `config.email.persist: false`) stops + * every delivery attempt being written to `sys_email` (#5447). The plugin + * option has been live since the plugin had one — it builds no + * `EmailPersistence` when `persist === false` — but nothing carried the + * declared `config.email.persist` here, so a PII-sensitive deployment that + * switched persistence off in `objectstack.config.ts` type-checked, parsed, + * read "Persist to sys_email (default true)" in the generated reference, and + * went on writing every message body to the database. Resolution order is this + * function's own, per setting: env > `config.email.persist` > the plugin + * default (persist ON) — so a config and an env that say nothing leave the + * option absent and the plugin's default untouched. + * + * The template context's `appName` follows the same env-wins rule as every + * other setting here (#5448): `OS_APP_NAME` > `config.email.appName` > + * `config.email.defaultTemplateContext.appName` > top-level `config.appName` > + * `'ObjectStack'`. It used to be the file's one exception — the whole + * `defaultTemplateContext` was spread OVER the resolved value, so a config that + * spelled `defaultTemplateContext: { appName: … }` made `OS_APP_NAME` inert and + * the per-environment override an operator has for a repo-pinned config did + * nothing, silently, in the mail body AND in the `no-reply@.local` + * fallback sender derived from it. Every OTHER key of `defaultTemplateContext` + * is unchanged: it has no env or dedicated-config carrier, so the author's + * context is still spread through wholesale. + */ +export function resolveEmailCapabilityArg( + cfgEmail: Record = {}, + env: NodeJS.ProcessEnv = process.env, + configAppName?: string, +): EmailCapabilityArg { + const provider = String(env.OS_EMAIL_PROVIDER || cfgEmail.provider || 'log').toLowerCase(); + const apiKey = env.OS_EMAIL_API_KEY || cfgEmail.apiKey; + + // OS_EMAIL_FROM supports either "addr@x" or "Name ". + let defaultFrom = cfgEmail.defaultFrom; + if (env.OS_EMAIL_FROM) { + const m = env.OS_EMAIL_FROM.match(/^\s*(?:"?([^"<]*?)"?\s*<\s*([^>]+)\s*>|(\S+))\s*$/); + if (m) { + const name = (m[1] ?? '').trim(); + const address = (m[2] ?? m[3] ?? '').trim(); + if (address) defaultFrom = name ? { name, address } : { address }; + } + } + const retries = env.OS_EMAIL_RETRIES ? Number(env.OS_EMAIL_RETRIES) : cfgEmail.retries; + // `OS_EMAIL_QUEUE_ENABLED` — a boolean feature flag, so `_ENABLED` and + // default-off (Prime Directive #9; a bare `OS_EMAIL_QUEUE` would read as a + // config value, e.g. a queue name). Whether the declaration can be HONOURED + // is not knowable here — no kernel exists yet — so the plugin asserts it on + // `kernel:ready`, where the service registry has settled, and fails the boot + // there if no durable queue showed up. + const queueDelivery = envBooleanFlag(env.OS_EMAIL_QUEUE_ENABLED) ?? cfgEmail.queueDelivery; + // `OS_EMAIL_PERSIST_ENABLED` — the carrier `config.email.persist` never had + // (#5447). `_ENABLED` is Prime Directive #9's boolean-flag shape; unlike the + // queue flag it is default-ON rather than default-off, because it does not + // enable a new capability — it is the off switch for one that has always + // been on, and a deployment that says nothing must keep its `sys_email` + // audit trail. Absent from BOTH sources means the key is left out of the + // constructor options entirely, so the plugin's own default decides. + const persist = envBooleanFlag(env.OS_EMAIL_PERSIST_ENABLED) ?? cfgEmail.persist; + // `appName` is resolved AFTER the context spread, not before it (#5448). + // The other keys of `defaultTemplateContext` are still spread wholesale and + // are the only source for themselves; `appName` alone has dedicated carriers + // above it, and this file's stated contract — "env overrides per setting" — + // has to hold for it like it does for apiKey / defaultFrom / retries / + // queueDelivery / persist / SMTP. Spreading the context over the resolved + // value inverted exactly that one key: an author who wrote + // `defaultTemplateContext: { appName: 'Acme Dev' }` made `OS_APP_NAME` + // silently inert, so the one lever an operator has for a repo-pinned config + // deployed to several environments did nothing — and since the fallback + // sender is slugged from this value, the wrong name reached the envelope as + // well as the body. + // + // `defaultTemplateContext.appName` stays IN the chain rather than losing to + // the two dedicated sources and vanishing: dropping it would demote every + // config that spells only the context form straight to 'ObjectStack', + // trading one silently wrong value for a worse one. Order: env > + // `config.email.appName` > `config.email.defaultTemplateContext.appName` > + // top-level `config.appName` > 'ObjectStack'. + const cfgTemplateContext = cfgEmail.defaultTemplateContext || {}; + const defaultTemplateContext = { + ...cfgTemplateContext, + appName: resolveDeploymentAppName(cfgEmail, env, configAppName), + }; + // Provide a sensible fallback `from` so templates can render even before + // operators configure SMTP/SaaS. The log transport simply prints to stdout; + // the address never leaves the box. + if (!defaultFrom) { + const slug = String(defaultTemplateContext.appName || 'objectstack') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') || 'objectstack'; + defaultFrom = { name: defaultTemplateContext.appName, address: `no-reply@${slug}.local` }; + } + + const smtpEnv: Record = {}; + const smtpHost = env.OS_EMAIL_SMTP_HOST?.trim(); + if (smtpHost) smtpEnv.host = smtpHost; + if (env.OS_EMAIL_SMTP_PORT) smtpEnv.port = Number(env.OS_EMAIL_SMTP_PORT); + if (env.OS_EMAIL_SMTP_SECURE != null) { + const raw = String(env.OS_EMAIL_SMTP_SECURE).trim().toLowerCase(); + smtpEnv.secure = raw !== 'false' && raw !== '0'; + } + if (env.OS_EMAIL_SMTP_USER) smtpEnv.user = env.OS_EMAIL_SMTP_USER; + if (env.OS_EMAIL_SMTP_PASSWORD) smtpEnv.password = env.OS_EMAIL_SMTP_PASSWORD; + const providerOptions = { ...(cfgEmail.options ?? {}), ...smtpEnv }; + + const options: Record = { + provider, + ...(apiKey ? { apiKey } : {}), + ...(Object.keys(providerOptions).length > 0 ? { providerOptions } : {}), + defaultFrom, + ...(retries != null && !Number.isNaN(retries) ? { retries } : {}), + ...(queueDelivery != null ? { queueDelivery: !!queueDelivery } : {}), + ...(persist != null ? { persist: !!persist } : {}), + defaultTemplateContext, + }; + + if (!isEmailTransportProvider(provider)) { + throw new Error( + `provider='${provider}' is not a transport this server can deliver through, so no mail would go out — ` + + `${unsupportedProviderFix(provider)} ` + + 'On this boot path the provider is OS_EMAIL_PROVIDER or config.email.provider; set ' + + 'OS_EMAIL_PROVIDER=log if this environment is not meant to send mail.', + ); + } + if (provider === 'smtp' && !providerOptions.host) { + throw new Error( + "provider='smtp' selects SMTP delivery but no SMTP host is configured — set OS_EMAIL_SMTP_HOST " + + '(plus OS_EMAIL_SMTP_PORT / _SECURE / _USER / _PASSWORD) or config.email.options.host, ' + + 'or choose another provider.', + ); + } + if (emailProviderRequiresApiKey(provider) && !apiKey) { + throw new Error( + `provider='${provider}' selects ${provider} delivery but no API key is configured, so every send would ` + + 'be recorded in sys_email as sent and nothing would leave the box — set OS_EMAIL_API_KEY ' + + '(or config.email.apiKey), or set OS_EMAIL_PROVIDER=log if this environment is not meant to send mail.', + ); + } + return { options }; +} diff --git a/packages/plugins/plugin-email/src/index.ts b/packages/plugins/plugin-email/src/index.ts index 961be0479e8..66764896fe2 100644 --- a/packages/plugins/plugin-email/src/index.ts +++ b/packages/plugins/plugin-email/src/index.ts @@ -16,6 +16,15 @@ export { EmailServicePlugin, resolveDurableQueue, resolveAttachmentStore } from './email-plugin.js'; export type { EmailServicePluginOptions } from './email-plugin.js'; +// [#22301] What a served boot constructs `EmailServicePlugin` with, from the +// deployment's mail configuration — read by `@objectstack/core`'s +// `resolveCapabilityArgument` off this module, for `os serve` and for +// `@objectstack/verify`'s `bootStack` alike. +export { + resolveEmailCapabilityArg, + resolveDeploymentAppName, + type EmailCapabilityArg, +} from './capability-arg.js'; export { LogTransport, normalizeMessage, diff --git a/packages/plugins/plugin-email/src/transports/index.ts b/packages/plugins/plugin-email/src/transports/index.ts index a18fcf6ccd8..d80b55bfb1b 100644 --- a/packages/plugins/plugin-email/src/transports/index.ts +++ b/packages/plugins/plugin-email/src/transports/index.ts @@ -44,11 +44,12 @@ export function isEmailTransportProvider(value: unknown): value is EmailTranspor * * It exists as a value because more than one assembly site has to answer that * question and refuse an incomplete configuration: {@link makeTransport} here, - * and `resolveEmailCapabilityArg` in `@objectstack/cli` (#5132), which used to - * answer it with its own `provider !== 'log' && provider !== 'smtp'` literal - * and silently rewrote the provider to `log` when the key was missing. Two - * literals describing one vocabulary is exactly the drift #5094 was filed for, - * so the CLI imports this instead of restating it. + * and `resolveEmailCapabilityArg` (#5132, written in `@objectstack/cli` and in + * `../capability-arg.ts` since #22301), which used to answer it with its own + * `provider !== 'log' && provider !== 'smtp'` literal and silently rewrote the + * provider to `log` when the key was missing. Two literals describing one + * vocabulary is exactly the drift #5094 was filed for, so that reader imports + * this instead of restating it. * * The tie to {@link makeTransport} is a compile error rather than a convention: * its `resend` / `postmark` arms pass their tag to `requireApiKey`, whose diff --git a/packages/qa/dogfood/test/activity-parent-read-gate.dogfood.test.ts b/packages/qa/dogfood/test/activity-parent-read-gate.dogfood.test.ts index 38a02a22c49..dc4d79e82f8 100644 --- a/packages/qa/dogfood/test/activity-parent-read-gate.dogfood.test.ts +++ b/packages/qa/dogfood/test/activity-parent-read-gate.dogfood.test.ts @@ -109,7 +109,15 @@ describe('sys_activity: a member reads the activity of records it can read, and const parent = await stack.apiAs(memberTok, 'GET', `/data/${r.object_name}/${r.record_id}`); expect(parent.status, `activity row ${r.id} is about ${r.object_name}/${r.record_id}`).toBe(200); } - }); + // [#22301] One request per readable row, and the rows are every write the + // boot mirrored — platform objects included (the header). `bootStack` mounts + // the always-on slate `objectstack serve` mounts, and the email service it + // brings seeds its built-in templates at boot (24 `sys_activity` rows about + // `sys_email_template` on this fixture), so this case outgrew vitest's + // 5000 ms default under a loaded run: measured 4372 ms alone, against + // 2690 ms with the slate ablated, on one shared box. The bound is the + // work, not a stall. + }, 30_000); it('list: the total is narrowed exactly like the rows', async () => { const body = (await (await stack.apiAs(memberTok, 'GET', '/data/sys_activity?limit=1000')).json()) as any; diff --git a/packages/qa/dogfood/test/attachments-permission-matrix.dogfood.test.ts b/packages/qa/dogfood/test/attachments-permission-matrix.dogfood.test.ts index c656d3b5687..93b127f3524 100644 --- a/packages/qa/dogfood/test/attachments-permission-matrix.dogfood.test.ts +++ b/packages/qa/dogfood/test/attachments-permission-matrix.dogfood.test.ts @@ -661,6 +661,22 @@ describe('attachments permission matrix (#2755)', () => { // security plugin (whose seam is present and unused), org-bound members — and // no gate. A parent-record editor's delete of another member's attachment is // refused by the floor; the row's creator, whom the floor admits, may delete it. +// +// [#22301] `bootStack` mounts the always-on slate `objectstack serve` mounts for +// every app, `storage` included, so a composition WITHOUT service-storage is +// now one the boot is told about rather than one it falls into: a stand-in +// under service-storage's registered name in `extraPlugins` takes the +// provider's place by identity (the caller-wins rule), registers nothing, and +// keeps the real plugin — its objects and its floor alternate — out, the shape +// `serve --preset minimal` (no slate) or a host that mounts no storage boots. +/** Holds service-storage's identity so the boot does not mount it, and provides nothing. */ +const noStorageService = { + name: 'com.objectstack.service.storage', + type: 'standard' as const, + version: '0.0.0', + async init() {}, + async start() {}, +}; /** Registers `sys_attachment` the way service-storage does, and installs nothing else. */ const sysAttachmentWithoutStorage = { name: 'com.dogfood.sys-attachment-without-storage', @@ -687,7 +703,7 @@ describe('attachments without service-storage (a′)', () => { beforeAll(async () => { stack = await bootStack(attachmentsFixtureStack as never, { security: attachmentsFixtureSecurity(), - extraPlugins: [sysAttachmentWithoutStorage as never], + extraPlugins: [noStorageService as never, sysAttachmentWithoutStorage as never], orgContext: true, }); ql = await stack.kernel.getServiceAsync('objectql'); diff --git a/packages/qa/dogfood/test/audit-log-parent-read-gate.dogfood.test.ts b/packages/qa/dogfood/test/audit-log-parent-read-gate.dogfood.test.ts index 8197d7f20fa..5393a12d462 100644 --- a/packages/qa/dogfood/test/audit-log-parent-read-gate.dogfood.test.ts +++ b/packages/qa/dogfood/test/audit-log-parent-read-gate.dogfood.test.ts @@ -153,7 +153,14 @@ describe('[#21175] sys_audit_log: a ledger reader reads the rows about records i if (!opened.has(key)) opened.set(key, (await stack.apiAs(readerTok, 'GET', `/data/${key}`)).status); expect(opened.get(key), `ledger row ${r.id} is about ${key}`).toBe(200); } - }); + // [#22301] One request per record the readable rows name, and the rows are + // every write the boot ledgered (the header). `bootStack` mounts the + // always-on slate `objectstack serve` mounts, and the email service it + // brings seeds its built-in templates at boot, so this case outgrew + // vitest's 5000 ms default: measured 5025 ms (timed out) alone, against + // 3032 ms with the slate ablated, on one shared box. The bound is the + // work, not a stall — the same bound `activity-parent-read-gate` carries. + }, 30_000); it('list: the total is narrowed exactly like the rows', async () => { const body = (await (await stack.apiAs(readerTok, 'GET', `/data/${LEDGER}?limit=1000`)).json()) as any; diff --git a/packages/services/service-sms/src/capability-arg.ts b/packages/services/service-sms/src/capability-arg.ts new file mode 100644 index 00000000000..1a97ceb5357 --- /dev/null +++ b/packages/services/service-sms/src/capability-arg.ts @@ -0,0 +1,90 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * What `SmsServicePlugin` is constructed with on a served boot: the + * deployment's SMS configuration (`config.sms`, `OS_SMS_*`), read into the + * plugin's options — and refused when no transport this package ships can + * deliver through it. + * + * ## Why this lives here, and not in `@objectstack/cli` or `@objectstack/core` + * + * It was written in `os serve`'s command body (`@objectstack/cli`), reading the + * provider vocabulary from this package by the same rule as the mail one + * (#5713): the reader has to refuse exactly the tags `makeSmsTransport` cannot + * build. For one configuration, `@objectstack/verify`'s `bootStack` composes + * what `serve` composes (#22301, ruling A) — the SMS provider included, built + * from the same configuration — and the handle cannot import the CLI. The rule + * for which token gets which argument moved to `@objectstack/core` + * (`resolveCapabilityArgument`), which cannot import this package (this package + * depends on it). So the reader lives beside the vocabulary it refuses against, + * and `resolveCapabilityArgument` reads it off this module — the module every + * boot already loads to construct `SmsServicePlugin`. `@objectstack/cli` + * re-exports it from here. ⛔ Never a second copy beside a reader. + */ + +import { isSmsTransportProvider, SMS_TRANSPORT_PROVIDERS } from './transports/index.js'; + +/** Constructor options for `SmsServicePlugin`, as the capability loop builds them. */ +export interface SmsCapabilityArg { + options: Record; +} + +/** + * Resolve `SmsServicePlugin` constructor options from `config.sms` + `OS_SMS_*` + * env, and **refuse a provider tag no transport can deliver through** (#5713). + * + * The refusal is the point. Credentials for a real provider normally arrive from + * the `sms` settings namespace at `kernel:ready`, so this function deliberately + * does NOT demand them — a bare `OS_SMS_PROVIDER=twilio` on a host whose Twilio + * keys are stored in Settings is a complete, working configuration and passes + * through untouched. What it refuses is the one thing settings can never repair: + * a provider *tag* outside `SMS_TRANSPORT_PROVIDERS`. + * + * That tag used to travel all the way into the plugin, which caught the + * `makeSmsTransport: unknown provider 'twilo'` throw and substituted + * `LogSmsTransport` behind the operator's back. Measured on `origin/main` before + * this change, `new SmsServicePlugin({ provider: 'twilo' }).init(ctx)`: + * + * - boots without throwing, registers the `sms` service; + * - transport = `LogSmsTransport`, `isConfigured() === false`; + * - one `logger.warn` line, then `send()` answers + * `{ status: 'sent', messageId: 'dev-sms-…' }`. + * + * So a phone-OTP sign-in tells the user "code sent" and nothing leaves the box — + * the same declared-but-not-delivered shape #5132 closed for mail one layer up, + * and the same door #5204 closed on the `SettingsService` env branch. This path + * never reaches `SettingsService`: it runs at kernel-assembly time, before the + * settings service exists, which is exactly why the `sms` namespace's `select` + * options table (`sms.manifest.ts`) could not see it. + * + * The plugin's fallback is left alone on purpose. For a *known* provider with + * incomplete constructor credentials it is correct — the settings bind can still + * swap in a working transport — and it stays the last line of defence for hosts + * that construct `SmsServicePlugin` themselves. `os serve` simply stops handing + * it input it cannot use. + * + * `OS_SMS_PROVIDER=log` (the default) is how an environment says "this box does + * not send SMS", which is what makes refusing the rest fair. + */ +export function resolveSmsCapabilityArg( + cfgSms: Record = {}, + env: NodeJS.ProcessEnv = process.env, +): SmsCapabilityArg { + const provider = String(env.OS_SMS_PROVIDER || cfgSms.provider || 'log').toLowerCase(); + if (!isSmsTransportProvider(provider)) { + throw new Error( + `provider='${provider}' is not a transport this server can deliver through, so every OTP and ` + + "notification SMS would be answered status: 'sent' and nothing would leave the box — " + + `pick one of ${SMS_TRANSPORT_PROVIDERS.join(' / ')} (Settings → SMS Delivery → Provider). ` + + 'On this boot path the provider is OS_SMS_PROVIDER or config.sms.provider; set ' + + 'OS_SMS_PROVIDER=log if this environment is not meant to send SMS.', + ); + } + return { + options: { + provider, + ...(cfgSms.providerOptions ? { providerOptions: cfgSms.providerOptions } : {}), + ...(cfgSms.retries != null ? { retries: cfgSms.retries } : {}), + }, + }; +} diff --git a/packages/services/service-sms/src/index.ts b/packages/services/service-sms/src/index.ts index b13cbae2a36..75afe9cc2b6 100644 --- a/packages/services/service-sms/src/index.ts +++ b/packages/services/service-sms/src/index.ts @@ -8,6 +8,11 @@ export { type SmsServiceOptions, } from './sms-service.js'; export { SmsServicePlugin, type SmsServicePluginOptions } from './sms-plugin.js'; +// [#22301] What a served boot constructs `SmsServicePlugin` with, from the +// deployment's SMS configuration — read by `@objectstack/core`'s +// `resolveCapabilityArgument` off this module, for `os serve` and for +// `@objectstack/verify`'s `bootStack` alike. +export { resolveSmsCapabilityArg, type SmsCapabilityArg } from './capability-arg.js'; export { SmsDailyQuota, SMS_QUOTA_EXCEEDED_CODE, diff --git a/packages/services/service-sms/src/transports/index.ts b/packages/services/service-sms/src/transports/index.ts index 607aa14db83..82f125373e9 100644 --- a/packages/services/service-sms/src/transports/index.ts +++ b/packages/services/service-sms/src/transports/index.ts @@ -12,7 +12,8 @@ export { TwilioSmsTransport, type TwilioSmsTransportOptions } from './twilio.js' * The provider vocabulary — every tag `makeSmsTransport` below can build, and * nothing else. It is the **one** literal: the `SmsProviderTag` type is derived * from it, the `switch` is exhaustive over it, and callers that have to judge an - * operator-supplied provider string (the CLI's `sms` capability arm, #5713) read + * operator-supplied provider string (`resolveSmsCapabilityArg` in + * `../capability-arg.ts`, #5713 — written in the CLI until #22301) read * it from here rather than restating the list. * * Two literals describing one vocabulary is how the mail settings dropdown and @@ -31,8 +32,8 @@ export type SmsProviderTag = (typeof SMS_TRANSPORT_PROVIDERS)[number]; /** * Narrow an unknown value to a buildable provider tag. The counterpart of - * `isEmailTransportProvider` in `@objectstack/plugin-email`, and used by the CLI - * for the same reason: a provider that cannot deliver must be refused where the + * `isEmailTransportProvider` in `@objectstack/plugin-email`, and used by + * `resolveSmsCapabilityArg` for the same reason: a provider that cannot deliver must be refused where the * operator declared it, not silently downgraded where it is materialised. */ export function isSmsTransportProvider(value: unknown): value is SmsProviderTag { diff --git a/packages/verify/src/harness.required-providers.test.ts b/packages/verify/src/harness.required-providers.test.ts index 46c0e6267c0..568d83e084a 100644 --- a/packages/verify/src/harness.required-providers.test.ts +++ b/packages/verify/src/harness.required-providers.test.ts @@ -14,8 +14,14 @@ * One case per branch of the reader's rule — the top-level list, the package * bodies when there is none, and the top level winning when both are present — * plus the control (no `requires`), the explicit-wins rule, and the hard - * dependencies a mounted provider brings (`triggers` → `job` / `queue`, which - * `serve` mounts through its always-on slate). + * dependencies a mounted provider has (`triggers` → `job` / `queue`, which + * `serve` mounts through its always-on slate, and so does `bootStack` since the + * slate stage of #22301 — `harness.served-composition.test.ts` pins the slate + * itself and the configuration each provider is built from). + * + * The tokens these cases name are OFF the always-on slate (`approvals`, + * `realtime`), so mounting one is evidence of the `requires` reader and never + * of the slate. */ import { readFileSync } from 'node:fs'; @@ -25,6 +31,7 @@ import { composeStacks, defineStack } from '@objectstack/spec'; import { bootStack, type VerifyStack } from './harness.js'; const APPROVALS = 'com.objectstack.service.approvals'; +const REALTIME = 'com.objectstack.service.realtime'; const svcManifest = { id: 'com.example.rqp.svc', name: 'RQP Service', namespace: 'rqp', version: '1.0.0', type: 'module' }; const appManifest = { id: 'com.example.rqp.app', name: 'RQP App', namespace: 'rqp', version: '1.0.0', type: 'app' }; @@ -79,15 +86,15 @@ describe('bootStack mounts what `requires` names, by serve\'s reader', () => { }, 120_000); it('the top-level list wins over the bodies when present', async () => { - stack = await bootStack({ ...twoPackages({ requires: ['approvals'] }), requires: ['cache'] }); - expect(stack.kernel.hasPlugin('com.objectstack.service.cache')).toBe(true); + stack = await bootStack({ ...twoPackages({ requires: ['approvals'] }), requires: ['realtime'] }); + expect(stack.kernel.hasPlugin(REALTIME)).toBe(true); expect(approvalsMounted(stack)).toEqual({ plugin: false, object: false }); }, 120_000); it('control — an app that requires nothing gets none of them', async () => { stack = await bootStack(onePackage()); expect(approvalsMounted(stack)).toEqual({ plugin: false, object: false }); - expect(stack.kernel.hasPlugin('com.objectstack.service.cache')).toBe(false); + expect(stack.kernel.hasPlugin(REALTIME)).toBe(false); }, 120_000); }); @@ -105,7 +112,7 @@ describe('serve\'s other two rules', () => { expect(approvalsMounted(stack)).toEqual({ plugin: true, object: false }); }, 120_000); - it('a mounted provider\'s hard dependencies come with it: `triggers` brings `job` and `queue`', async () => { + it('a mounted provider\'s hard dependencies are mounted with it: `triggers` has `job` and `queue`', async () => { stack = await bootStack(onePackage({ requires: ['automation', 'triggers'] })); for (const name of [ 'com.objectstack.service-automation', diff --git a/packages/verify/src/harness.served-composition.test.ts b/packages/verify/src/harness.served-composition.test.ts new file mode 100644 index 00000000000..c9dfe81d51d --- /dev/null +++ b/packages/verify/src/harness.served-composition.test.ts @@ -0,0 +1,161 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #22301 item 1, the composition gap the stage-2 contract review recorded: + * for one configuration `bootStack` composes what `objectstack serve` + * composes (ruling A), and two halves of that were still `serve`'s alone — + * + * - the always-on slate `serve` mounts for EVERY app + * (`PLATFORM_ALWAYS_ON_CAPABILITIES`): the handle mounted a slate provider + * only where a mounted plugin hard-depended on it; + * - the configuration each provider is built from: the handle built every + * provider with its own defaults — the analytics service with no cubes, the + * email service with no mail configuration. + * + * Both now come from the rule `serve` reads (`resolveServedCapabilities` / + * `resolveCapabilityArgument`, `@objectstack/core`). One pin per half, each + * with its control; each was turned red by an ablation of exactly the half it + * pins (recorded in the PR that added this file). + * + * The configuration-built cases add `email` to the configuration object, not + * through `defineStack`: that is the shape `serve`'s reader reads, and this + * file pins that both boots hand it to the provider — what an author may + * declare is the stack schema's question, not this file's. + */ + +import { afterEach, describe, expect, it } from 'vitest'; +import { CAPABILITY_PROVIDERS } from '@objectstack/core'; +import { composeStacks, defineStack } from '@objectstack/spec'; +import { PLATFORM_ALWAYS_ON_CAPABILITIES } from '@objectstack/spec/kernel'; +import { bootStack, type VerifyStack } from './harness.js'; + +const BOOT_TIMEOUT = 120_000; + +const svcManifest = { id: 'com.example.svc.svc', name: 'SVC Service', namespace: 'svc', version: '1.0.0', type: 'module' }; +const appManifest = { id: 'com.example.svc.app', name: 'SVC App', namespace: 'svc', version: '1.0.0', type: 'app' }; +const note = { + name: 'svc_note', label: 'Note', pluralLabel: 'Notes', sharingModel: 'public_read_write', + fields: { name: { name: 'name', type: 'text', label: 'Name', required: true } }, +}; +const ticket = { + name: 'svc_ticket', label: 'Ticket', pluralLabel: 'Tickets', sharingModel: 'public_read_write', + fields: { title: { name: 'title', type: 'text', label: 'Title', required: true } }, +}; +const CUBE = 'svc_note_cube'; +const cube = { + name: CUBE, title: 'Notes', sql: 'svc_note', + measures: { count: { label: 'Count', type: 'count', sql: '*' } }, + dimensions: { name: { label: 'Name', type: 'string', sql: 'name' } }, +}; + +type Bag = Record; + +/** One package, `extra` at its top level — a fresh object per call (the instance rule keys on identity). */ +const onePackage = (extra: Bag = {}): Bag => + defineStack({ manifest: appManifest, objects: [ticket, note], ...extra } as never) as unknown as Bag; + +/** Two packages through the real producer: `svcExtra` lives only in the service package's body. */ +const twoPackages = (svcExtra: Bag): Bag => + composeStacks( + [ + defineStack({ manifest: svcManifest, objects: [note], ...svcExtra } as never), + defineStack({ manifest: appManifest, objects: [ticket] } as never), + ], + { manifest: 'preserve' }, + ) as unknown as Bag; + +let stack: VerifyStack | undefined; +afterEach(async () => { + await stack?.stop(); + stack = undefined; +}); + +/** The registered plugin name each slate token's provider registers under (the table's first identity). */ +const SLATE_PROVIDERS = PLATFORM_ALWAYS_ON_CAPABILITIES.flatMap((token) => { + const spec = CAPABILITY_PROVIDERS[token]; + return spec ? [{ token, name: spec.identities[0]! }] : []; +}); + +describe('the always-on slate `serve` mounts for every app is mounted', () => { + it('an app that requires nothing gets every slate provider — and nothing off the slate', async () => { + // Anti-vacuity: the slate is the spec's, and most of it has a provider row. + expect(SLATE_PROVIDERS.length).toBeGreaterThanOrEqual(8); + const config = onePackage(); + expect(config.requires).toBeUndefined(); + stack = await bootStack(config); + const missing = SLATE_PROVIDERS.filter(({ name }) => !stack!.kernel.hasPlugin(name)).map(({ token }) => token); + expect(missing).toEqual([]); + // Control: a provider neither declared nor on the slate is not mounted, so + // the cases above prove the slate, not the whole table. + expect(stack.kernel.hasPlugin(CAPABILITY_PROVIDERS.approvals!.identities[0]!)).toBe(false); + expect(stack.kernel.hasPlugin(CAPABILITY_PROVIDERS.realtime!.identities[0]!)).toBe(false); + }, BOOT_TIMEOUT); +}); + +/** The cube names the booted analytics service holds. */ +async function cubeNames(s: VerifyStack): Promise { + const analytics = await s.kernel.getServiceAsync<{ cubeRegistry: { names(): string[] } }>('analytics'); + return analytics.cubeRegistry.names(); +} + +describe('the analytics service is built with the app\'s `analyticsCubes`', () => { + it('a cube the app declares is registered', async () => { + stack = await bootStack(onePackage({ analyticsCubes: [cube] })); + expect(await cubeNames(stack)).toContain(CUBE); + }, BOOT_TIMEOUT); + + it('a cube a package body declares is registered — the top level carries none', async () => { + const config = twoPackages({ analyticsCubes: [cube] }); + expect(config.analyticsCubes).toBeUndefined(); + stack = await bootStack(config); + expect(await cubeNames(stack)).toContain(CUBE); + }, BOOT_TIMEOUT); + + it('control — an app that declares no cube registers none of this name', async () => { + stack = await bootStack(onePackage()); + expect(await cubeNames(stack)).not.toContain(CUBE); + }, BOOT_TIMEOUT); +}); + +const PROBE_SUBJECT = 'served-composition probe'; + +/** + * Send one message through the booted email service and count the `sys_email` + * rows it left. The sender is passed explicitly, so the count answers ONE + * question — was `persist` handed to the provider — and not also whether the + * configured default sender was. + */ +async function probeRowsAfterSend(s: VerifyStack): Promise { + const email = await s.kernel.getServiceAsync<{ send(input: Record): Promise }>('email'); + await email.send({ from: 'sender@example.com', to: 'probe@example.com', subject: PROBE_SUBJECT, text: 'probe' }); + const engine = await s.kernel.getServiceAsync<{ + find(object: string, query: Record): Promise; + }>('objectql'); + const found = await engine.find('sys_email', { where: { subject: PROBE_SUBJECT }, context: { isSystem: true } }); + const rows = Array.isArray(found) ? found : ((found as { records?: unknown[] })?.records ?? []); + return rows.length; +} + +describe('the email service is built from the app\'s mail configuration', () => { + it('`email.persist: false` reaches the provider: a send leaves no `sys_email` row', async () => { + stack = await bootStack({ ...onePackage(), email: { persist: false } }); + expect(await probeRowsAfterSend(stack)).toBe(0); + }, BOOT_TIMEOUT); + + it('control — with no mail configuration, the same send leaves one row', async () => { + stack = await bootStack(onePackage()); + expect(await probeRowsAfterSend(stack)).toBe(1); + }, BOOT_TIMEOUT); + + it('a mail configuration no transport can deliver through fails the boot, naming the provider and the fix', async () => { + const saved = process.env.OS_EMAIL_SMTP_HOST; + delete process.env.OS_EMAIL_SMTP_HOST; + try { + await expect(bootStack({ ...onePackage(), email: { provider: 'smtp' } })).rejects.toThrow( + /EmailServicePlugin[\s\S]*provider='smtp'[\s\S]*OS_EMAIL_SMTP_HOST/, + ); + } finally { + if (saved !== undefined) process.env.OS_EMAIL_SMTP_HOST = saved; + } + }, BOOT_TIMEOUT); +}); diff --git a/packages/verify/src/harness.ts b/packages/verify/src/harness.ts index ea115f37ddc..f1c3fd1fbeb 100644 --- a/packages/verify/src/harness.ts +++ b/packages/verify/src/harness.ts @@ -37,9 +37,9 @@ import { PlatformObjectsPlugin } from '@objectstack/platform-objects/plugin'; // verification — `@objectstack/organizations` above all — must be resolved from // THAT app, not from `packages/verify`'s own realpath inside this workspace. import { createHostImporter, hostImportFailureKind } from '@objectstack/types/node'; -import { materializeStackPlugin } from '@objectstack/core'; +import { materializeStackPlugin, resolveCapabilityArgument } from '@objectstack/core'; import { createHandle, type VerifyHandle } from './handle.js'; -import { constructRequiredProviders } from './required-providers.js'; +import { constructServedProviders } from './required-providers.js'; /** A Hono app exposes `.request(path, init)` returning a standard `Response`. */ interface InjectableApp { @@ -197,9 +197,13 @@ export interface BootOptions { */ security?: SecurityPlugin; /** - * Override the AnalyticsServicePlugin instance. Defaults to a vanilla - * `new AnalyticsServicePlugin()`, which auto-bridges `getReadScope` to the - * `security` service. + * Override the AnalyticsServicePlugin instance. Defaults to the instance + * `objectstack serve` constructs for the app — `new AnalyticsServicePlugin({ + * cubes })` with the app's `analyticsCubes` (the top level, then its legacy + * `cubes` spelling, then each package body's; `resolveCapabilityArgument`, + * `@objectstack/core`, #22301) — which auto-bridges `getReadScope` to the + * `security` service. An instance passed here wins whole: it is not handed + * the app's cubes. * * The reason to override is to prove ADR-0021 D-C's SECOND belt in isolation * (#3602): pass `new AnalyticsServicePlugin({ getReadScope: () => undefined })` @@ -419,15 +423,18 @@ export interface BootOptions { * `AuditPlugin` for the attachments surface). Registered in array order. * Default `[]`. * - * [#22301] Not for what the configuration itself declares: the boot mounts - * the providers the app's `requires` names, by `objectstack serve`'s table - * (see `./required-providers.ts`), and the plugins in the app's own `plugins` - * array, by `serve`'s rule for an entry (`materializeStackPlugin`, - * `@objectstack/core`). A plugin here TAKES PRECEDENCE over both, by - * identity: + * [#22301] Not for what `objectstack serve` composes from the configuration: + * the boot mounts the providers the app's `requires` names and the always-on + * slate `serve` mounts for every app, each built from the app's configuration + * by `serve`'s rules (see `./required-providers.ts`), and the plugins in the + * app's own `plugins` array, by `serve`'s rule for an entry + * (`materializeStackPlugin`, `@objectstack/core`). A plugin here TAKES + * PRECEDENCE over both, by identity: * - * - over a `requires` provider it IS (exact `name` or class name, `serve`'s - * "an explicit instance wins" rule) — the capability is skipped whole; + * - over a provider it IS (exact `name` or class name, `serve`'s "an + * explicit instance wins" rule) — the capability is skipped whole, so a + * suite that needs a provider configured its own way (a storage root, a + * mail transport) passes its instance here; * - over an entry of the app's `plugins` array that has the same `name`, * the identity the kernel registers a plugin under — that entry is not * mounted, and this instance is the one the boot keeps. @@ -651,14 +658,27 @@ function claimConfiguration(config: unknown): ConfigurationClaim { * ## What it composes — one composition rule (#22301, ruling A) * * For one configuration, what `objectstack serve` composes from it: the - * harness's service set, the providers the app's `requires` names - * (`./required-providers.ts`), and the plugins in the app's own `plugins` - * array, by `serve`'s rule for an entry — with {@link BootOptions.extraPlugins} - * taking precedence by identity, and every app-relative path anchored to - * {@link BootOptions.hostRoot}. An entry of that array that cannot be loaded - * or registered fails the boot, with its remedy. There is no switch: an app's - * tests boot what the app declares, so a plugin the app forgot to declare - * fails in its tests the way it would in production. + * harness's service set; the providers `serve` mounts for it — the ones the + * app's `requires` names and the always-on slate `serve` mounts for every app + * (`queue`, `job`, `cache`, `settings`, `email`, `storage`, `sms`, `sharing`, + * `messaging`, `analytics`, `package-registry`), each built from the app's + * configuration the way `serve` builds it: the app's `analyticsCubes`, its + * `email` / `sms` blocks with the `OS_EMAIL_*` / `OS_SMS_*` environment over + * them, the `OS_STORAGE_LOCAL_ROOT` storage root (`./required-providers.ts`); + * and the plugins in the app's own `plugins` array, by `serve`'s rule for an + * entry — with {@link BootOptions.extraPlugins} taking precedence by + * identity, and every app-relative path anchored to + * {@link BootOptions.hostRoot}. A provider or an entry of that array that + * cannot be built, loaded or registered fails the boot, with its remedy. There + * is no switch: an app's tests boot what the app declares, so a plugin the app + * forgot to declare, or a mail configuration no transport can deliver + * through, fails in its tests the way it would in production. + * + * Not composed here, because they are decisions about a server PROCESS rather + * than about the configuration: `serve`'s MCP endpoint (`OS_MCP_SERVER_ENABLED`) + * and its pinyin search (`OS_SEARCH_PINYIN_ENABLED`, which `serve` stamps from + * the stack's locales). A suite that exercises either passes the provider in + * {@link BootOptions.extraPlugins}. * * ### Offline CI: `OS_CLOUD_URL=off` * @@ -810,7 +830,17 @@ async function bootClaimed( const cryptoProvider = harnessCryptoProvider(); const settingsPlugin = new SettingsServicePlugin({ cryptoProvider }); await kernel.use(settingsPlugin); - const analyticsPlugin = opts.analytics ?? new AnalyticsServicePlugin(); + // [#22301] Built from the app's configuration the way `objectstack serve` + // builds it — the app's `analyticsCubes` — by the rule both boots read + // (`resolveCapabilityArgument`, `@objectstack/core`). A caller's instance wins + // whole (see BootOptions.analytics). + const analyticsPlugin = + opts.analytics ?? + new AnalyticsServicePlugin( + resolveCapabilityArgument('analytics', { stack: config, packageRoot: hostRoot }).argument as ConstructorParameters< + typeof AnalyticsServicePlugin + >[0], + ); await kernel.use(analyticsPlugin); // [ADR-0131 D3 / D11] The production `single` shape, as `objectstack dev` / // `serve` boot it: `AuthPlugin`'s defaults, owner bind included. Under @@ -990,14 +1020,16 @@ async function bootClaimed( throw e; } - // [#22301] The providers the app's `requires` names, as `objectstack serve` - // mounts them — same reader, same table, same "an explicit instance wins" - // rule (see `./required-providers.ts`). In the `extraPlugins` slot, after it, - // so a caller's own instance is the one that stays. The harness's sharing - // service is constructed here so a `requires: ['sharing']` sees it held. + // [#22301] The providers `objectstack serve` mounts for this configuration — + // the app's `requires` and the always-on slate, each built from the app's + // configuration — by `serve`'s own rules: the same token rule, table, + // argument rule and "an explicit instance wins" match (see + // `./required-providers.ts`). In the `extraPlugins` slot, after it, so a + // caller's own instance is the one that stays. The harness's sharing service + // is constructed here so the slate's `sharing` sees it held. const sharingPlugin = new SharingServicePlugin(); try { - const requiredProviders = await constructRequiredProviders({ + const servedProviders = await constructServedProviders({ config, held: [ settingsPlugin, @@ -1007,11 +1039,9 @@ async function bootClaimed( ...(opts.extraPlugins ?? []), ...appPlugins.map((entry) => entry.plugin), ], - dependents: appPlugins.map((entry) => entry.plugin), - isRegistered: (name) => kernel.hasPlugin(name), packageRoot: hostRoot, }); - for (const plugin of requiredProviders) { + for (const plugin of servedProviders) { await kernel.use(plugin as any); } } catch (e) { diff --git a/packages/verify/src/required-providers.ts b/packages/verify/src/required-providers.ts index d58d50a4cbf..8eccca5b473 100644 --- a/packages/verify/src/required-providers.ts +++ b/packages/verify/src/required-providers.ts @@ -1,25 +1,35 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. // -// [#22301] The providers an app's `requires` names, constructed for the -// verification boot by `objectstack serve`'s own lookup. +// [#22301] The capability providers `objectstack serve` mounts for a +// configuration, constructed for the verification boot by `serve`'s own rules. // // `serve` turns each `requires` token into its provider plugin (its capability -// resolver, step 5). `bootStack` booted a fixed plugin set instead, so an app's -// tests ran without the services the same app gets on a real server — no -// record-change trigger fired on a write, no approval node had a service — and -// an app named the plugins by hand in `extraPlugins`. Under the maintainer's B′ -// ruling an app's tests reach the real engine through this handle and nothing -// hand-built, so that list was a local stand-in for a platform gap. +// resolver, step 5), appends the always-on slate to every app, and builds each +// provider from the app's configuration. `bootStack` booted a fixed plugin set +// instead, then the providers `requires` names built with their own defaults; +// so an app's tests ran without the services, the cubes and the mail settings +// the same app gets on a real server, and an app named plugins by hand in +// `extraPlugins`. Under the maintainer's B′ ruling an app's tests reach the +// real engine through this handle and nothing hand-built, and under ruling A +// (#22301) `bootStack` composes what `serve` composes for one configuration. // -// ## What is `serve`'s, read from where `serve` reads it +// ## What is `serve`'s, read from where `serve` reads it (`@objectstack/core`) // -// · WHICH tokens: `stackDeclaredCapabilities` (`@objectstack/core`) — the top- -// level `requires` when the stack carries one, otherwise each package -// body's. One reader for both boots, so a multi-package app boots one set -// of providers under `serve` and under its tests. -// · WHICH provider: `CAPABILITY_PROVIDERS` (`@objectstack/core`), the table +// · WHICH tokens: `resolveServedCapabilities` over the tokens the app +// declares as `stackDeclaredCapabilities` reads them (the top-level +// `requires` when the stack carries one, otherwise each package body's) — +// `email` for a declared `auth`, the always-on slate +// (`PLATFORM_ALWAYS_ON_CAPABILITIES`), and `job` / `queue` ahead of the +// tokens that schedule background work. No preset: `bootStack` takes none, +// so the slate is always mounted, as on a `serve` with no `--preset`. +// · WHICH provider: `CAPABILITY_PROVIDERS`, the table // `Serve.CAPABILITY_PROVIDERS` is a handle over — main provider, then its // `extras`. +// · WITH WHAT: `resolveCapabilityArgument` — `automation` the app's root +// (`hostRoot` here), `analytics` the app's cubes, `email` / `sms` the +// deployment's mail and SMS configuration (from the configuration and the +// `OS_EMAIL_*` / `OS_SMS_*` environment, by the readers their packages +// export), `storage` its local root. An extra takes no argument. // · WHEN NOT: `providesCapability`'s exact identity match against what this // boot already holds — the harness's own settings / analytics / sharing // services, `opts.automation`, every `opts.extraPlugins` entry, and every @@ -33,128 +43,125 @@ // // ## What is this boot's own // -// · CONSTRUCTION. `serve` reads mail, SMS and storage transports from the app -// and the environment; a verification boot never sends mail or SMS from a -// test, so every provider is constructed with its own defaults — the way -// `bootStack({ automation: true })` has always constructed the automation -// service. The one argument kept is `automation`'s `packageRoot`, the -// app's root, which `serve` passes for the same token (`hostRoot` here). -// · THE HARD DEPENDENCIES of what it mounts. `serve` mounts its always-on -// slate (`queue`, `job`, `messaging`, …) on every boot, and some providers -// hard-depend on one of them: `triggers`' schedule extras on `job`, its API -// trigger on `queue`, `webhooks` on `messaging`. This boot does not mount -// the slate, so it mounts exactly the slate providers a mounted plugin -// hard-depends on — a provider it constructs, or a plugin of the app's own -// `plugins` array — found in the same table, among the tokens a served boot -// of this app would have mounted (its `requires` and the slate). A -// dependency no such token supplies is left to the kernel, whose refusal -// names it, as `serve`'s would. +// · THE HOST DEFAULTS. `serve` also mounts MCP (`OS_MCP_SERVER_ENABLED`, on +// by default) and pinyin search (`OS_SEARCH_PINYIN_ENABLED`, which `serve` +// stamps into its process environment from the stack's locales). Both are +// decisions about a server process, not about the configuration, and this +// boot passes none: a suite that exercises either passes the provider in +// `extraPlugins` (the MCP and pinyin dogfood suites do). // · FAILURE. A provider this boot set out to construct and could not is a -// thrown boot error naming the token and the package — every package in the -// table is a dependency of `@objectstack/verify` (pinned), so a failure here -// is a broken install or a provider that refuses to start, never an absence -// to scroll past. +// thrown boot error naming the token and the package, whether the app +// declared the token or the slate appended it — `serve` logs a slate +// provider it cannot build and boots on, and a test boot that went on +// without it would pass green on a composition production does not run. +// Every package in the table is a dependency of `@objectstack/verify` +// (pinned), so an import failure is a broken install; a configuration the +// mail or SMS reader refuses names its own remedy. -import { CAPABILITY_PROVIDERS, providesCapability, stackDeclaredCapabilities } from '@objectstack/core'; -import { PLATFORM_ALWAYS_ON_CAPABILITIES } from '@objectstack/spec/kernel'; +import { + CAPABILITY_PROVIDERS, + providesCapability, + resolveCapabilityArgument, + resolveServedCapabilities, + stackDeclaredCapabilities, +} from '@objectstack/core'; -/** A plugin's registered `name`, as the kernel keys it. */ -function pluginName(plugin: unknown): string | undefined { - const name = (plugin as { name?: unknown } | null | undefined)?.name; - return typeof name === 'string' ? name : undefined; +/** Why this boot constructs a provider, as a refusal names it. */ +function reasonFor(token: string, declared: ReadonlySet): string { + return declared.has(token) + ? `requires: ['${token}']` + : `the always-on '${token}' capability (objectstack serve mounts it for every app)`; } -/** A plugin's HARD dependencies, as the kernel orders them. */ -function hardDependencies(plugin: unknown): string[] { - const deps = (plugin as { dependencies?: unknown } | null | undefined)?.dependencies; - return Array.isArray(deps) ? deps.filter((d): d is string => typeof d === 'string') : []; -} - -/** One provider of the table, constructed with this boot's argument for its token. */ -async function constructProvider( - token: string, - pkg: string, - exportName: string, - arg: unknown, -): Promise { +/** One provider of the table, loaded and constructed with this configuration's argument. */ +async function constructProvider(opts: { + reason: string; + pkg: string; + exportName: string; + /** The constructor argument, read off the loaded module; absent ⇒ no argument. */ + argumentFor?: (mod: Record) => unknown; +}): Promise { + const { reason, pkg, exportName } = opts; + type ProviderClass = new (arg?: unknown) => unknown; + let loaded: { mod: Record; Ctor: ProviderClass }; try { const mod = (await import(/* webpackIgnore: true */ pkg)) as Record; - const Ctor = mod[exportName] as (new (arg?: unknown) => unknown) | undefined; + const Ctor = mod[exportName]; if (typeof Ctor !== 'function') throw new Error(`${pkg} does not export ${exportName}`); - return arg === undefined ? new Ctor() : new Ctor(arg); + loaded = { mod, Ctor: Ctor as ProviderClass }; } catch (e) { throw new Error( - `verify: requires: ['${token}'] names ${exportName} (${pkg}), and this boot could not construct it: ` + + `verify: ${reason} names ${exportName} (${pkg}), and this boot could not load it: ` + `${(e as Error).message}. Every provider in the capability table is a dependency of @objectstack/verify, ` + - 'so this is a broken install or a provider that refuses to start — the boot does not continue without ' + - 'a service the app declares.', + 'so this is a broken install — the boot does not continue without a service objectstack serve mounts ' + + 'for this configuration.', + ); + } + const { mod, Ctor } = loaded; + let arg: unknown; + try { + arg = opts.argumentFor?.(mod); + } catch (e) { + throw new Error( + `verify: ${reason} names ${exportName} (${pkg}), and this configuration cannot build it: ` + + `${(e as Error).message} objectstack serve refuses or skips the same configuration; the boot does not ` + + 'continue without the service.', + ); + } + try { + return arg === undefined ? new Ctor() : new Ctor(arg); + } catch (e) { + throw new Error( + `verify: ${reason} names ${exportName} (${pkg}), and it refused to construct: ${(e as Error).message}. ` + + 'The boot does not continue without a service objectstack serve mounts for this configuration.', ); } } /** - * The provider plugins the app's `requires` names that this boot does not - * already hold, plus the always-on providers they hard-depend on — in the order - * to register them (dependencies first). Constructed, not registered: the - * caller registers them in its own slot. + * The provider plugins `objectstack serve` mounts for this configuration that + * this boot does not already hold — the app's `requires` and the always-on + * slate, in `serve`'s mount order, each built from the configuration as + * `serve` builds it. Constructed, not registered: the caller registers them in + * its own slot. * * @param held - Every plugin instance this boot mounts on its own or was handed * (`opts.extraPlugins`, `opts.automation`'s instance, the harness's services, * the app's own `plugins`). - * @param dependents - The other plugins this boot mounts that are not providers - * it constructs — the app's own `plugins` array. Their hard dependencies are - * searched like a mounted provider's, as `serve`'s always-on slate would - * supply them. - * @param isRegistered - Whether a plugin of that name is already registered on - * the kernel (a dependency the boot itself satisfies). * @param packageRoot - The app's root, handed to `automation` as `serve` does. */ -export async function constructRequiredProviders(opts: { +export async function constructServedProviders(opts: { config: unknown; held: readonly unknown[]; - dependents?: readonly unknown[]; - isRegistered: (name: string) => boolean; packageRoot: string; }): Promise { - const declared = [...new Set(stackDeclaredCapabilities(opts.config))]; - const named: unknown[] = []; - const dependencies: unknown[] = []; - const all = (): unknown[] => [...opts.held, ...dependencies, ...named]; + const { tokens, declared } = resolveServedCapabilities(stackDeclaredCapabilities(opts.config)); + const constructed: unknown[] = []; + const all = (): unknown[] => [...opts.held, ...constructed]; - for (const token of declared) { + for (const token of tokens) { const spec = CAPABILITY_PROVIDERS[token]; if (!spec) continue; if (providesCapability(all(), spec.identities)) continue; - const arg = token === 'automation' ? { packageRoot: opts.packageRoot } : undefined; - named.push(await constructProvider(token, spec.pkg, spec.export, arg)); + const reason = reasonFor(token, declared); + constructed.push( + await constructProvider({ + reason, + pkg: spec.pkg, + exportName: spec.export, + argumentFor: (mod) => + resolveCapabilityArgument(token, { + stack: opts.config, + packageRoot: opts.packageRoot, + providerModule: mod, + }).argument, + }), + ); for (const extra of spec.extras ?? []) { if (providesCapability(all(), extra.identities)) continue; - named.push(await constructProvider(token, extra.pkg, extra.export, undefined)); - } - } - - // The hard dependencies of what this boot mounts, to a fixed point (a - // dependency's own dependencies included), searched among the tokens a - // served boot of this app would have mounted. - const searched = [...new Set([...declared, ...PLATFORM_ALWAYS_ON_CAPABILITIES])]; - for (;;) { - const present = new Set(all().map(pluginName).filter((n): n is string => n !== undefined)); - let next: string | undefined; - for (const plugin of [...(opts.dependents ?? []), ...named, ...dependencies]) { - for (const dependency of hardDependencies(plugin)) { - if (present.has(dependency) || opts.isRegistered(dependency)) continue; - next = searched.find((t) => { - const spec = CAPABILITY_PROVIDERS[t]; - return spec !== undefined && spec.identities.includes(dependency) && !providesCapability(all(), spec.identities); - }); - if (next) break; - } - if (next) break; + constructed.push(await constructProvider({ reason, pkg: extra.pkg, exportName: extra.export })); } - if (!next) break; - const spec = CAPABILITY_PROVIDERS[next]!; - dependencies.push(await constructProvider(next, spec.pkg, spec.export, undefined)); } - return [...dependencies, ...named]; + return constructed; }