Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .changeset/22301-verify-boots-the-served-slate.md
Original file line number Diff line number Diff line change
@@ -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)

<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling or stored shape is removed, renamed or re-shaped, and no stored row is read, rewritten, converted or dropped, so there is nothing for `objectstack migrate meta` to rewrite. What narrows is the in-process verification boot of @objectstack/verify: a configuration whose mail or SMS settings (or the OS_EMAIL_* / OS_SMS_* environment) name a transport that cannot deliver now fails the boot where the provider was absent or built with defaults. The other categories are closed on facts: every bumped package publishes (not unpublished); no ADR-0087 id covers these paths and this diff adds none (not registered / already-registered); and the narrowed surface is a runtime boot function, not an interface or a type (not runtime-interface-only / type-surface-only). -->

**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.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 30 additions & 12 deletions packages/cli/src/commands/serve-auth-app-name.contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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<string, any>, env: NodeJS.ProcessEnv, configAppName?: string) =>
(resolveEmailCapabilityArg(cfgEmail, env, configAppName).options
.defaultTemplateContext as Record<string, unknown>).appName;
((resolveCapabilityArgument('email', {
stack: { email: cfgEmail, appName: configAppName },
packageRoot: '/app',
providerModule: pluginEmail,
env,
}).argument as { defaultTemplateContext: Record<string, unknown> }).defaultTemplateContext).appName;

describe('resolveDeploymentAppName is the email chain, rung for rung', () => {
const RUNGS: Array<[string, Record<string, any>, NodeJS.ProcessEnv, string | undefined, string]> = [
Expand All @@ -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);
Expand All @@ -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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//
Expand Down
Loading
Loading