From 9a7cbbc7bb6e9bc2851cb8f57a6281b7c4ed00d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 23:23:56 +0000 Subject: [PATCH 01/14] feat(core,cli,verify): bootStack mounts the providers an app's requires names, by serve's own reader and table Re-applies ef5396ca2 (reverted at b3186ec18 pending the item-1 ruling) onto current main. The `requires` token -> provider table and its exact identity match move from the `Serve` command to `@objectstack/core`, beside the package-owned collection reader `os serve` reads `requires` with (moved there from the CLI's utils, which re-export it). `Serve.CAPABILITY_PROVIDERS` and `Serve.providesCapability` become handles over the core declarations. `@objectstack/verify`'s `bootStack` then constructs the providers the app's `requires` names, skips any provider the boot already holds, and mounts the always-on providers a mounted provider hard-depends on. Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- packages/cli/src/commands/serve.ts | 239 ++-------------- packages/cli/src/utils/stack-collections.ts | 171 ++---------- .../test/serve-capability-identity.test.ts | 61 ++-- .../test/serve-capability-vocabulary.test.ts | 15 +- packages/core/src/capability-providers.ts | 262 ++++++++++++++++++ packages/core/src/index.ts | 8 + packages/core/src/stack-collections.ts | 191 +++++++++++++ packages/verify/package.json | 17 ++ .../src/harness.required-providers.test.ts | 136 +++++++++ packages/verify/src/harness.ts | 66 ++++- packages/verify/src/required-providers.ts | 152 ++++++++++ pnpm-lock.yaml | 51 ++++ 12 files changed, 954 insertions(+), 415 deletions(-) create mode 100644 packages/core/src/capability-providers.ts create mode 100644 packages/core/src/stack-collections.ts create mode 100644 packages/verify/src/harness.required-providers.test.ts create mode 100644 packages/verify/src/required-providers.ts diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 8c8420b0604..5aaddf83353 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -28,6 +28,9 @@ import { // boolean was the banner, and that was exactly the drift #4801 fixed. 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`. +import { CAPABILITY_PROVIDERS, providesCapability, type CapabilitySpec } from '@objectstack/core'; // The posture vocabulary, read from the package that DEFINES it (#5359) — the // boot gate's fix list enumerates the accepted values, and a second literal // list would be free to drift the day a posture is added. @@ -949,24 +952,6 @@ export function runtimeSeedSettlementChannels( }; } -/** - * The IDENTITIES a capability provider registers under: full `plugin.name` ids - * (`com.objectstack.mcp`) and/or exported class names (`MCPServerPlugin`). - * - * Compared EXACTLY by {@link Serve.providesCapability} — never as substrings. - * These used to be free-form *fragments* tested with `String.includes()`; see - * that method for the whole class of bug that spelling caused (#7652). - */ -type CapabilityIdentities = string[]; - -type CapabilitySpec = { - pkg: string; - export: string; // named export to import - identities: CapabilityIdentities; // exact provider identities — see the type - configKey?: string; // optional config field passed as constructor arg - extras?: Array<{ pkg: string; export: string; identities: CapabilityIdentities }>; -}; - const hostImporters = new Map(); /** @@ -1595,43 +1580,14 @@ export default class Serve extends Command { * Is one of `identities` ALREADY loaded — i.e. did the app supply this * capability's provider itself, so the resolver must not load a second one? * - * Compares a plugin's `name` and its constructor name against the declared - * identities by EQUALITY. That exactness is the fix for #7652, not a detail: - * - * This check used to treat `identities` as free-form fragments and test them - * with `String.includes()`. Substring matching cannot tell a capability's - * PROVIDER from one of its CONSUMERS, because a consumer is conventionally - * named after the thing it consumes — so any plugin whose name merely - * CONTAINED a fragment satisfied the capability and SUPPRESSED the real - * provider. The stock showcase hit exactly that: it loads - * `com.objectstack.connector.mcp` (the outbound MCP *client* connector), - * whose name contains the `mcp` fragment, so `MCPServerPlugin` never loaded - * and the MCP endpoint the boot banner advertises answered 501. - * - * `mcp` was not the only fragment short enough to collide (`audit` was one - * consumer away from the same fate, and every class-name fragment was - * satisfied by any class merely ENDING in it, e.g. `MyAuditPlugin` for - * `AuditPlugin`). Equality closes the class: a plugin either IS the provider - * or it is not, and no naming convention can blur that. - * - * Both directions matter. Tightening the comparison must not stop a genuine - * provider being recognised, so the registry below declares each provider's - * REAL registered `name` (measured from its package, and pinned by - * `serve-capability-identity.test.ts` so a rename can't silently reintroduce - * double-loading) alongside its exported class name. + * A handle over `providesCapability` (`@objectstack/core`), where the rule and + * its history (#7652: exact identities, never substrings) now live — the + * verification handle (`@objectstack/verify`) applies the same rule, and it + * cannot import this package (#22301). Kept as a static so every existing + * caller and test keeps its spelling: one declaration, several readers. */ - static providesCapability(plugins: readonly unknown[], identities: readonly string[]): boolean { - const wanted = new Set(identities.filter((id) => id !== '')); - if (wanted.size === 0) return false; - return plugins.some((p) => { - const name = (p as { name?: unknown } | null | undefined)?.name; - const ctor = (p as { constructor?: { name?: unknown } } | null | undefined)?.constructor?.name; - return ( - (typeof name === 'string' && wanted.has(name)) || - (typeof ctor === 'string' && wanted.has(ctor)) - ); - }); - } + static readonly providesCapability: (plugins: readonly unknown[], identities: readonly string[]) => boolean = + providesCapability; /** * Identities of the local-install surface (`MarketplaceInstallLocalPlugin`), @@ -1856,174 +1812,17 @@ export default class Serve extends Command { /** * Registry of `requires` token → built-in service-plugin provider for the - * standalone serve path. Keys are canonical kebab-case platform capability - * tokens — a drift test asserts every key is in the spec-owned - * PLATFORM_CAPABILITY_TOKENS vocabulary (framework#3265). Adding a built-in - * capability = one entry here + its token in the spec vocabulary. + * standalone serve path — a handle over `CAPABILITY_PROVIDERS` + * (`@objectstack/core`), where the table, its per-row notes and its drift + * pins' subject now live (#22301): `@objectstack/verify`'s `bootStack` mounts + * the providers an app's `requires` names through the same table, and it + * cannot import this package. Adding a built-in capability is an edit THERE. * - * `identities` are matched EXACTLY (see {@link Serve.providesCapability}), so - * each entry names the provider's real registered `plugin.name` — NOT a - * shortened fragment of it. Before #7652 most of these name fragments were in - * fact dead (`service-cache` never matched `com.objectstack.service.cache`: - * dash vs dot), and the entries were carried entirely by their class name. + * Kept as a static for the reason `ALWAYS_ON_CAPABILITIES` above is: a stable + * handle for existing callers and tests — one declaration, several readers, + * ⛔ never a second copy. */ - static readonly CAPABILITY_PROVIDERS: Record = { - automation: { - // Self-contained: AutomationServicePlugin seeds all built-in node - // executors itself (ADR-0018), so flows have executors with no - // companion node-pack plugins. - pkg: '@objectstack/service-automation', - export: 'AutomationServicePlugin', - identities: ['com.objectstack.service-automation', 'AutomationServicePlugin'], - }, - analytics: { - pkg: '@objectstack/service-analytics', - export: 'AnalyticsServicePlugin', - identities: ['com.objectstack.service-analytics', 'AnalyticsServicePlugin'], - configKey: 'analyticsCubes', - }, - audit: { - pkg: '@objectstack/plugin-audit', - export: 'AuditPlugin', - identities: ['com.objectstack.audit', 'AuditPlugin'], - }, - cache: { - pkg: '@objectstack/service-cache', - export: 'CacheServicePlugin', - identities: ['com.objectstack.service.cache', 'CacheServicePlugin'], - }, - storage: { - pkg: '@objectstack/service-storage', - export: 'StorageServicePlugin', - identities: ['com.objectstack.service.storage', 'StorageServicePlugin'], - }, - queue: { - pkg: '@objectstack/service-queue', - export: 'QueueServicePlugin', - identities: ['com.objectstack.service.queue', 'QueueServicePlugin'], - }, - job: { - pkg: '@objectstack/service-job', - export: 'JobServicePlugin', - identities: ['com.objectstack.service.job', 'JobServicePlugin'], - }, - messaging: { - // Backs the `notify` flow node (ADR-0012): delivers to a user's - // channels (inbox by default → `sys_inbox_message` rows). Without - // this the notify node degrades to a logged no-op. - pkg: '@objectstack/service-messaging', - export: 'MessagingServicePlugin', - identities: ['com.objectstack.service.messaging', 'MessagingServicePlugin'], - }, - triggers: { - // Makes autolaunched flows actually fire. The automation engine ships - // the `FlowTrigger` wiring; these plugins are the concrete triggers: - // record-change (ObjectQL lifecycle hooks) + schedule (cron/interval - // via the job service — so pair `triggers` with `job`). - pkg: '@objectstack/trigger-record-change', - export: 'RecordChangeTriggerPlugin', - identities: ['com.objectstack.trigger.record-change', 'RecordChangeTriggerPlugin'], - extras: [ - { - pkg: '@objectstack/trigger-schedule', - export: 'ScheduleTriggerPlugin', - identities: ['com.objectstack.trigger.schedule', 'ScheduleTriggerPlugin'], - }, - { - // Declarative time-relative sweep (#1874) — arms flows whose start - // node declares `config.timeRelative` (fire daily for records whose - // date field is within N days / at T-minus offsets). Ships in - // @objectstack/trigger-schedule; needs the job service + ObjectQL. - pkg: '@objectstack/trigger-schedule', - export: 'TimeRelativeTriggerPlugin', - identities: ['com.objectstack.trigger.time-relative', 'TimeRelativeTriggerPlugin'], - }, - { - // Inbound webhook/HTTP trigger (ADR-0041 Tier 1) — arms - // `type: 'api'` flows with HMAC-verified, queue-backed hooks. - pkg: '@objectstack/trigger-api', - export: 'ApiTriggerPlugin', - identities: ['com.objectstack.trigger.api', 'ApiTriggerPlugin'], - }, - ], - }, - realtime: { - pkg: '@objectstack/service-realtime', - export: 'RealtimeServicePlugin', - identities: ['com.objectstack.service.realtime', 'RealtimeServicePlugin'], - }, - // `feed` removed (ADR-0052 §5): `sys_comment`/`sys_activity` (durable, - // default-loaded, UI-wired) is the canonical record collaboration + - // timeline backend. `@objectstack/service-feed` was an in-memory, - // non-durable, UI-unconsumed parallel implementation — retired to end - // the split-brain. The unified typed timeline lives on `sys_activity`. - mcp: { - pkg: '@objectstack/mcp', - export: 'MCPServerPlugin', - identities: ['com.objectstack.mcp', 'MCPServerPlugin'], - }, - marketplace: { - pkg: '@objectstack/service-package', - export: 'PackageServicePlugin', - identities: ['package-service', 'PackageServicePlugin'], - }, - // The always-on persistence half of the `marketplace` / `package-registry` - // split (#17676 ruling A' items 1-2): `sys_packages` and its boot - // hydration, so `protocol.installPackage` / `updatePackage` find the - // `package` service on a stock boot. Keyed at the provider the spec's - // PLATFORM_CAPABILITY_PROVIDERS row declares for this token — the SAME - // package and plugin as `marketplace` above, because that is what the spec - // map says today. Repointing `marketplace` at the browse surface starts at - // that spec row, and this table follows it; until then an app declaring - // `marketplace` gets ONE PackageServicePlugin, not two — see - // `resolverMounted` in the capability resolver. - 'package-registry': { - pkg: '@objectstack/service-package', - export: 'PackageServicePlugin', - identities: ['package-service', 'PackageServicePlugin'], - }, - email: { - pkg: '@objectstack/plugin-email', - export: 'EmailServicePlugin', - identities: ['com.objectstack.service.email', 'EmailServicePlugin'], - }, - sms: { - // #2780 — backs phone-number OTP sign-in/reset (plugin-auth) and - // the messaging `sms` channel. Provider config lives in the `sms` - // settings namespace (OS_SMS_* env keys win at the resolver); - // unconfigured ⇒ dev LogSmsTransport (no real send). - pkg: '@objectstack/service-sms', - export: 'SmsServicePlugin', - identities: ['com.objectstack.service.sms', 'SmsServicePlugin'], - }, - sharing: { - pkg: '@objectstack/plugin-sharing', - export: 'SharingServicePlugin', - identities: ['com.objectstack.service.sharing', 'SharingServicePlugin'], - }, - // #2486 — auto-required above when resolveSearchPinyinEnabled() - // (explicit env, else any configured zh-* locale) says on. - 'pinyin-search': { - pkg: '@objectstack/plugin-pinyin-search', - export: 'PinyinSearchPlugin', - identities: ['com.objectstack.plugin.pinyin-search', 'PinyinSearchPlugin'], - }, - approvals: { - pkg: '@objectstack/plugin-approvals', - export: 'ApprovalsServicePlugin', - identities: ['com.objectstack.service.approvals', 'ApprovalsServicePlugin'], - }, - settings: { - pkg: '@objectstack/service-settings', - export: 'SettingsServicePlugin', - identities: ['com.objectstack.service.settings', 'SettingsServicePlugin'], - }, - webhooks: { - pkg: '@objectstack/plugin-webhooks', - export: 'WebhookOutboxPlugin', - identities: ['com.objectstack.plugin-webhook-outbox', 'WebhookOutboxPlugin'], - }, - }; + static readonly CAPABILITY_PROVIDERS: Record = CAPABILITY_PROVIDERS; async run(): Promise { // `metadata` is oclif's record of WHERE each flag's value came from. diff --git a/packages/cli/src/utils/stack-collections.ts b/packages/cli/src/utils/stack-collections.ts index 92be27d44fc..4916edb2d18 100644 --- a/packages/cli/src/utils/stack-collections.ts +++ b/packages/cli/src/utils/stack-collections.ts @@ -76,7 +76,7 @@ * that silence. */ -import { resolveArtifactPackageOrder } from '@objectstack/core'; +import { collectFromPackageBodies, resolveStackCollection, stackPackageBodies } from '@objectstack/core'; import { AssembledPackageBodySchema, ObjectStackDefinitionSchema } from '@objectstack/spec'; type Bag = Record; @@ -85,157 +85,18 @@ const asBag = (value: unknown): Bag | undefined => value && typeof value === 'object' ? (value as Bag) : undefined; /** - * A stack's `packages` value, judged ONCE for every reader in this package that - * walks it: the entries, by position, or `[]` when the key is absent. - * - * ## A present non-array `packages` is refused, never read as "no packages" - * - * A `packages` that is present but is not an array (`{}`, `0`, `'x'`) is - * MALFORMED, not absent (ruling A on #15293). The rule is stated once, beside - * `AssembledPackageBodySchema` (`@objectstack/spec`, `stack.zod.ts`), and it is - * enforced by `resolveArtifactPackageOrder` (`@objectstack/core`), which - * refuses the value as `INVALID_ARTIFACT_PACKAGES` (ADR-0112, `status: 422`). - * The runtime, `@objectstack/core` and the plugin readers already refuse it. - * This package's readers used to answer "no packages" instead (#19925): `os - * info` printed `0` objects for such a stack and `os lint` passed it. So this - * function spells neither the rule nor the refusal. It hands a non-array value - * to the resolver, and the refusal the author sees is the resolver's own. - * - * - The key ABSENT (`undefined`) answers `[]`. This is the only value the - * function answers on its own. - * - An array is returned BY REFERENCE and unparsed. The docs readers need - * entries by POSITION (`packages[i]` is where collected docs attach), and the - * resolver answers bodies in LOAD order, so they cannot read its result. - * Parsing each entry is the resolver's job on the path that registers - * packages ({@link packageBodies} reaches it). Adding that parse to the docs - * readers would widen what they refuse, and that is a separate change. - * - Every other value goes to the resolver, `null` included. A non-array is - * refused there. - * - * ## `null` follows the resolver, and is never judged here - * - * Ruling A on #19926 (`5805260775`) settles `null`: it is malformed at every - * reader, and `resolveArtifactPackageOrder` drops its `null` branch. That core - * change lands separately (#19926), and until it does, the resolver still - * answers `null` through its ABSENT branch. So this function does not answer - * `null` itself. It asks the resolver and reads the answer: - * - * - The resolver's absent answer is `[artifact]`, holding the caller's own - * object BY REFERENCE (ADR-0130 D4, second branch). This function recognises - * that answer by IDENTITY against the object it passed in, and returns `[]`. - * That is today's answer for `null`, byte for byte. - * - Once the resolver refuses `null`, its `INVALID_ARTIFACT_PACKAGES` reaches - * every reader here, with no edit to this package. - * - * ⛔ Never add a private `null` branch here, in either direction. Answering - * `null` as absent here would keep the CLI reading it as absent after the - * resolver starts refusing it. Refusing it here would be a second copy of a - * rule the resolver owns. - * - * ⛔ Never put an `Array.isArray` in front of this function as a fall-through - * to "no packages". That silent answer is exactly what this function removes. - * - * @throws Whatever the resolver raises for a present non-array `packages`: - * today `INVALID_ARTIFACT_PACKAGES` for every non-array except `null`. + * [#22301] The rule itself — `declaredPackageEntries`, the package bodies, the + * one-key fold, `resolveStackCollection` and `stackDeclaredCapabilities` — moved + * to `@objectstack/core` (`stack-collections.ts` there carries their documents), + * because `@objectstack/verify`'s `bootStack` reads `requires` by it too and + * cannot import this package. Re-exported so every reader in this package keeps + * its import; ⛔ never a second copy here. */ -export function declaredPackageEntries(packages: unknown): readonly unknown[] { - // The key is absent: there is nothing to judge. - if (packages === undefined) return []; - if (Array.isArray(packages)) return packages; - // Present and not an array, `null` included: the resolver decides. - const probe = { packages }; - const answer = resolveArtifactPackageOrder(probe); - // The resolver's ABSENT answer holds the probe itself, by reference. - if (answer.length === 1 && answer[0] === probe) return []; - // Reached only if the resolver answers a non-array with anything but a - // refusal or its absent answer. An empty answer here would bring back the - // silent fall-through, so fail loudly. - throw new Error( - `resolveArtifactPackageOrder accepted a \`packages\` of type ${packages === null ? 'null' : typeof packages}; ` - + 'the CLI package readers cannot walk it by position.', - ); -} - -/** - * The assembled package bodies this stack carries, in dependency-topological - * order — or `[]` when it carries no `packages` list of its own. - * - * `resolveArtifactPackageOrder` answers `[artifact]` for a stack with no - * `packages` key (ADR-0130 D4, second branch: the caller's own object IS the - * one package's body). That answer is correct there and useless here — folding - * a stack's own top level back onto itself resolves nothing — so this returns - * an empty list for that case, and every caller below reads the top level - * first anyway. - * - * A `packages` that is present but is not an array goes through - * {@link declaredPackageEntries}, which hands it to the resolver: it is refused, - * or, for `null` while the resolver still reads it as absent, answered `[]`. - */ -function packageBodies(stack: unknown): Bag[] { - if (declaredPackageEntries(asBag(stack)?.packages).length === 0) return []; - return (resolveArtifactPackageOrder(stack) as unknown[]) - .map(asBag) - .filter((b): b is Bag => b !== undefined); -} +export { declaredPackageEntries, resolveStackCollection, stackDeclaredCapabilities } from '@objectstack/core'; -/** - * One collection, concatenated across already-resolved bodies. - * - * ⚠️ The TOP LEVEL IS NOT CONSULTED — this is the option-B leg only. Callers - * that must preserve today's answer read their own expression first; see - * {@link resolveStackCollection} for the combined form. - * - * Takes the BODIES rather than the stack so a caller asking about several keys - * resolves the package list once. `resolveArtifactPackageOrder` parses every - * entry whole, and `authoringRuleUnionStack` asks about all 37 collections — - * re-resolving per key would run that parse 37 times on every `os build`. - */ -function collectFrom(bodies: readonly Bag[], key: string): unknown[] { - const out: unknown[] = []; - for (const body of bodies) { - const value = body[key]; - if (Array.isArray(value)) out.push(...value); - } - return out; -} - -/** {@link collectFrom} for a caller that has a stack and asks about one key. */ +/** `collectFromPackageBodies` for a caller that has a stack and asks about one key. */ function packageCollection(stack: unknown, key: string): unknown[] { - return collectFrom(packageBodies(stack), key); -} - -/** - * The effective value of one package-owned collection. - * - * The top-level array WINS whenever the key is present — in today's additive - * shape that array already IS the union (`composeStacks` flattened it), so - * unioning again would double every item. `packages[]` is consulted only when - * the top level does not carry the key at all, which is precisely the option-B - * shape. - */ -export function resolveStackCollection(stack: unknown, key: string): unknown[] { - const top = asBag(stack)?.[key]; - if (Array.isArray(top)) return top; - return packageCollection(stack, key); -} - -/** - * The capability tokens a stack DECLARES in `requires`, by - * {@link resolveStackCollection}'s rule: the top-level list when the stack - * carries one, otherwise every package body's, in package order. String - * entries only, duplicates kept (each caller dedupes in its own order). - * - * A multi-package `composeStacks(…, { manifest: 'preserve' })` stack carries - * `requires` only inside the body of the package that declared it (ADR-0130 - * D4, 2026-09-22 addendum). A reader of the top level alone read `[]` there: - * `os serve` did not mount the provider a package declared, `os migrate plan` - * could not order a plugin that hard-depends on it, and `os generate` told the - * author to declare a token a package already declares (#22288). The build - * doors attribute each token to its package instead - * (`preflightDeclaredCapabilities`), on the same rule. - */ -export function stackDeclaredCapabilities(stack: unknown): string[] { - return resolveStackCollection(stack, 'requires').filter((token): token is string => typeof token === 'string'); + return collectFromPackageBodies(stackPackageBodies(stack), key); } /** @@ -314,9 +175,9 @@ export function shouldAutoRegisterStorageDriver(stack: unknown, plugins: readonl export function stackDeclaresMetadata(stack: unknown): boolean { const bag = asBag(stack); if (bag?.objects || bag?.manifest || bag?.apps || bag?.flows || bag?.apis) return true; - const bodies = packageBodies(stack); + const bodies = stackPackageBodies(stack); return ['objects', 'apps', 'flows', 'apis'] - .some((key) => collectFrom(bodies, key).length > 0); + .some((key) => collectFromPackageBodies(bodies, key).length > 0); } /** @@ -342,8 +203,8 @@ export function bundleDeclaresTranslations(bundle: unknown): boolean { if (manifest && ((Array.isArray(manifest.translations) && manifest.translations.length > 0) || manifest.i18n)) { return true; } - const bodies = packageBodies(bundle); - if (collectFrom(bodies, 'translations').length > 0) return true; + const bodies = stackPackageBodies(bundle); + if (collectFromPackageBodies(bodies, 'translations').length > 0) return true; return bodies.some((body) => !!body.i18n); } @@ -456,13 +317,13 @@ function shapeKeys(schema: unknown, name: string): string[] { * fills keys that are absent. */ export function authoringRuleUnionStack(stack: T): T { - const bodies = packageBodies(stack); + const bodies = stackPackageBodies(stack); if (bodies.length === 0) return stack; let folded: Bag | undefined; for (const key of packageOwnedCollectionKeys()) { if (stack[key] !== undefined && stack[key] !== null) continue; - const items = collectFrom(bodies, key); + const items = collectFromPackageBodies(bodies, key); if (items.length > 0) { folded ??= { ...stack }; folded[key] = items; diff --git a/packages/cli/test/serve-capability-identity.test.ts b/packages/cli/test/serve-capability-identity.test.ts index 1fe47ad9cfd..2ec76b9b428 100644 --- a/packages/cli/test/serve-capability-identity.test.ts +++ b/packages/cli/test/serve-capability-identity.test.ts @@ -32,10 +32,23 @@ import { describe, expect, it } from 'vitest'; import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { CAPABILITY_PROVIDERS, providesCapability } from '@objectstack/core'; import Serve from '../src/commands/serve.js'; const HERE = resolve(fileURLToPath(import.meta.url), '..'); +/** + * [#22301] The table and the rule live in `@objectstack/core`, read by `os serve` + * AND by `@objectstack/verify`'s `bootStack`; `Serve`'s statics are handles over + * them. Pinned by identity, so a second copy kept on `Serve` cannot pass. + */ +describe('one declaration, several readers', () => { + it('Serve.CAPABILITY_PROVIDERS and Serve.providesCapability ARE the @objectstack/core declarations', () => { + expect(Serve.CAPABILITY_PROVIDERS).toBe(CAPABILITY_PROVIDERS); + expect(Serve.providesCapability).toBe(providesCapability); + }); +}); + /** Minimal stand-in for a loaded plugin: what the resolver actually reads. */ function plugin(name: string, ctorName: string): { name: string } { const Ctor = { [ctorName]: class { name: string; constructor(n: string) { this.name = n; } } }[ctorName]!; @@ -43,23 +56,23 @@ function plugin(name: string, ctorName: string): { name: string } { } describe('#7652: providesCapability compares identities, not substrings', () => { - const MCP = Serve.CAPABILITY_PROVIDERS.mcp; + const MCP = CAPABILITY_PROVIDERS.mcp; it('the outbound MCP CLIENT connector does not satisfy the `mcp` capability', () => { // The exact plugin the showcase loads (`packages/connectors/connector-mcp`). const consumer = plugin('com.objectstack.connector.mcp', 'ConnectorMcpPlugin'); expect( - Serve.providesCapability([consumer], MCP.identities), + providesCapability([consumer], MCP.identities), 'a consumer named after the capability must never suppress its provider', ).toBe(false); }); it('the real MCP server plugin still satisfies it — by name and by class', () => { - expect(Serve.providesCapability([plugin('com.objectstack.mcp', 'MCPServerPlugin')], MCP.identities)).toBe(true); + expect(providesCapability([plugin('com.objectstack.mcp', 'MCPServerPlugin')], MCP.identities)).toBe(true); // A host that constructs the class under another id (or a subclass) is still // recognised through whichever identity survives. - expect(Serve.providesCapability([plugin('com.acme.custom-mcp', 'MCPServerPlugin')], MCP.identities)).toBe(true); - expect(Serve.providesCapability([plugin('com.objectstack.mcp', 'WrappedMcp')], MCP.identities)).toBe(true); + expect(providesCapability([plugin('com.acme.custom-mcp', 'MCPServerPlugin')], MCP.identities)).toBe(true); + expect(providesCapability([plugin('com.objectstack.mcp', 'WrappedMcp')], MCP.identities)).toBe(true); }); it('rejects the near-misses substring matching used to accept', () => { @@ -71,18 +84,18 @@ describe('#7652: providesCapability compares identities, not substrings', () => // provider class name used to match it. plugin('com.acme.thing', 'FakeMCPServerPlugin'), ]) { - expect(Serve.providesCapability([near], MCP.identities), `${near.name} must not satisfy \`mcp\``).toBe(false); + expect(providesCapability([near], MCP.identities), `${near.name} must not satisfy \`mcp\``).toBe(false); } }); it('an empty identity list never matches, and empty strings are ignored', () => { - expect(Serve.providesCapability([plugin('com.objectstack.mcp', 'MCPServerPlugin')], [])).toBe(false); - expect(Serve.providesCapability([plugin('', 'Anon')], [''])).toBe(false); + expect(providesCapability([plugin('com.objectstack.mcp', 'MCPServerPlugin')], [])).toBe(false); + expect(providesCapability([plugin('', 'Anon')], [''])).toBe(false); }); it('survives plugins with no name / a null-prototype object', () => { - expect(Serve.providesCapability([{}, null, undefined, Object.create(null)], MCP.identities)).toBe(false); - expect(Serve.providesCapability([{ name: 'com.objectstack.mcp' }], MCP.identities)).toBe(true); + expect(providesCapability([{}, null, undefined, Object.create(null)], MCP.identities)).toBe(false); + expect(providesCapability([{ name: 'com.objectstack.mcp' }], MCP.identities)).toBe(true); }); }); @@ -120,20 +133,20 @@ const EXPECTED_PROVIDER_NAME: Record = { describe('#7652: every registered provider is still recognised (the other direction)', () => { it('covers every CAPABILITY_PROVIDERS token — the table cannot silently fall behind', () => { - expect(Object.keys(EXPECTED_PROVIDER_NAME).sort()).toEqual(Object.keys(Serve.CAPABILITY_PROVIDERS).sort()); + expect(Object.keys(EXPECTED_PROVIDER_NAME).sort()).toEqual(Object.keys(CAPABILITY_PROVIDERS).sort()); }); - it.each(Object.entries(Serve.CAPABILITY_PROVIDERS))( + it.each(Object.entries(CAPABILITY_PROVIDERS))( '`%s` is satisfied by its own provider, by name and by class', (cap, spec) => { const realName = EXPECTED_PROVIDER_NAME[cap]!; - expect(Serve.providesCapability([plugin(realName, 'Unrelated')], spec.identities)).toBe(true); - expect(Serve.providesCapability([plugin('com.example.unrelated', spec.export)], spec.identities)).toBe(true); + expect(providesCapability([plugin(realName, 'Unrelated')], spec.identities)).toBe(true); + expect(providesCapability([plugin('com.example.unrelated', spec.export)], spec.identities)).toBe(true); }, ); it('every entry declares its exported class name as an identity', () => { - for (const [cap, spec] of Object.entries(Serve.CAPABILITY_PROVIDERS)) { + for (const [cap, spec] of Object.entries(CAPABILITY_PROVIDERS)) { expect(spec.identities, `'${cap}' must accept an explicitly-constructed ${spec.export}`).toContain(spec.export); for (const ex of spec.extras ?? []) { expect(ex.identities, `'${cap}' extra ${ex.export}`).toContain(ex.export); @@ -142,7 +155,7 @@ describe('#7652: every registered provider is still recognised (the other direct }); it('no identity is a bare fragment — ids are fully qualified, class names are not ids', () => { - for (const [cap, spec] of Object.entries(Serve.CAPABILITY_PROVIDERS)) { + for (const [cap, spec] of Object.entries(CAPABILITY_PROVIDERS)) { const all = [spec, ...(spec.extras ?? [])]; for (const entry of all) { for (const id of entry.identities) { @@ -168,9 +181,9 @@ describe('#7652: every registered provider is still recognised (the other direct plugin('com.objectstack.connector.rest', 'ConnectorRestPlugin'), plugin('com.objectstack.connector.slack', 'ConnectorSlackPlugin'), ]; - for (const [cap, spec] of Object.entries(Serve.CAPABILITY_PROVIDERS)) { + for (const [cap, spec] of Object.entries(CAPABILITY_PROVIDERS)) { expect( - Serve.providesCapability(consumers, spec.identities), + providesCapability(consumers, spec.identities), `a connector must not stand in for the '${cap}' provider`, ).toBe(false); } @@ -187,7 +200,7 @@ describe('#7652: every registered provider is still recognised (the other direct * asserted too whenever the plugin can be constructed without arguments. */ describe('#7652: declared identities match what the provider packages register', () => { - const entries = Object.entries(Serve.CAPABILITY_PROVIDERS).flatMap(([cap, spec]) => [ + const entries = Object.entries(CAPABILITY_PROVIDERS).flatMap(([cap, spec]) => [ { cap, pkg: spec.pkg, export: spec.export, identities: spec.identities }, ...(spec.extras ?? []).map((ex) => ({ cap: `${cap}:${ex.export}`, pkg: ex.pkg, export: ex.export, identities: ex.identities })), ]); @@ -207,9 +220,9 @@ describe('#7652: declared identities match what the provider packages register', expect(src).toContain("name = 'com.objectstack.connector.mcp'"); expect(src).toContain('export class ConnectorMcpPlugin'); // And that name must NOT satisfy the capability it consumes. - expect(Serve.providesCapability( + expect(providesCapability( [plugin('com.objectstack.connector.mcp', 'ConnectorMcpPlugin')], - Serve.CAPABILITY_PROVIDERS.mcp.identities, + CAPABILITY_PROVIDERS.mcp.identities, )).toBe(false); }); @@ -238,7 +251,7 @@ describe('#7652: declared identities match what the provider packages register', * SHIPPED build, and every registry that declares one is enumerated here. * * What was measured, and why the drift block above could not see it: - * `Serve.providesCapability` recognises a provider by comparing + * `providesCapability` recognises a provider by comparing * `plugin.constructor.name` against the declared identities, so a class-name * identity is a genuine SECOND way to recognise a provider only while it equals * the class's runtime `.name`. Nothing compared those two. The block above @@ -300,7 +313,7 @@ type IdentitySource = { /** Every declared class-name identity in `serve.ts`, from both registry shapes. */ const IDENTITY_SOURCES: IdentitySource[] = [ - ...Object.entries(Serve.CAPABILITY_PROVIDERS).flatMap(([cap, spec]) => [ + ...Object.entries(CAPABILITY_PROVIDERS).flatMap(([cap, spec]) => [ { label: `CAPABILITY_PROVIDERS.${cap}`, pkg: spec.pkg, export: spec.export, identities: spec.identities }, ...(spec.extras ?? []).map((ex) => ({ label: `CAPABILITY_PROVIDERS.${cap} → ${ex.export}`, @@ -351,7 +364,7 @@ describe('#8645: every declared class-name identity equals the runtime class nam // satisfy this, which is the redundancy the registry claims to have. const bare = Object.create((Ctor as { prototype: object }).prototype) as unknown; expect( - Serve.providesCapability([bare], identities), + providesCapability([bare], identities), `${label}: the class-name limb must recognise ${exportName} by itself`, ).toBe(true); }, diff --git a/packages/cli/test/serve-capability-vocabulary.test.ts b/packages/cli/test/serve-capability-vocabulary.test.ts index e5dd0ecb77b..598c11766d0 100644 --- a/packages/cli/test/serve-capability-vocabulary.test.ts +++ b/packages/cli/test/serve-capability-vocabulary.test.ts @@ -4,6 +4,7 @@ import { PLATFORM_CAPABILITY_PROVIDERS, PLATFORM_PLUGIN_WIRED_RUNTIMES, } from '@objectstack/spec/kernel'; +import { CAPABILITY_PROVIDERS } from '@objectstack/core'; import Serve from '../src/commands/serve.js'; // framework#3265 — drift guard: the serve path's provider registries must stay @@ -12,7 +13,7 @@ import Serve from '../src/commands/serve.js'; describe('serve capability registries vs spec vocabulary (#3265)', () => { it('every CAPABILITY_PROVIDERS token is in PLATFORM_CAPABILITY_TOKENS', () => { - for (const token of Object.keys(Serve.CAPABILITY_PROVIDERS)) { + for (const token of Object.keys(CAPABILITY_PROVIDERS)) { expect(PLATFORM_CAPABILITY_TOKENS, `provider token '${token}' missing from spec vocabulary`).toContain(token); } }); @@ -25,13 +26,13 @@ describe('serve capability registries vs spec vocabulary (#3265)', () => { it('registries use only canonical spellings — never the removed camelCase aliases (#3308)', () => { const legacy = ['aiStudio', 'aiSeat']; - for (const token of [...Object.keys(Serve.CAPABILITY_PROVIDERS), ...Object.keys(Serve.CAPABILITY_TO_TIER)]) { + for (const token of [...Object.keys(CAPABILITY_PROVIDERS), ...Object.keys(Serve.CAPABILITY_TO_TIER)]) { expect(legacy).not.toContain(token); } }); it('tier-gated and provider-backed tokens do not overlap (each token has ONE resolution path)', () => { - const providerTokens = new Set(Object.keys(Serve.CAPABILITY_PROVIDERS)); + const providerTokens = new Set(Object.keys(CAPABILITY_PROVIDERS)); for (const tierToken of Object.keys(Serve.CAPABILITY_TO_TIER)) { expect(providerTokens.has(tierToken)).toBe(false); } @@ -84,7 +85,7 @@ describe('serve capability registries vs spec vocabulary (#3265)', () => { * a warning for a case this makes unreachable. */ it('every always-on slate token has a serve mount — a provider entry or a tier (#19387)', () => { - const providerTokens = new Set(Object.keys(Serve.CAPABILITY_PROVIDERS)); + const providerTokens = new Set(Object.keys(CAPABILITY_PROVIDERS)); const tierTokens = new Set(Object.keys(Serve.CAPABILITY_TO_TIER)); // Non-vacuity: an empty slate would pass the filter below over nothing. expect(Serve.ALWAYS_ON_CAPABILITIES.length).toBeGreaterThan(0); @@ -94,7 +95,7 @@ describe('serve capability registries vs spec vocabulary (#3265)', () => { expect( unmounted, 'always-on tokens that `serve` force-appends to every app and then mounts NOTHING for — ' + - 'key each one in Serve.CAPABILITY_PROVIDERS at the provider PLATFORM_CAPABILITY_PROVIDERS declares', + 'key each one in CAPABILITY_PROVIDERS at the provider PLATFORM_CAPABILITY_PROVIDERS declares', ).toEqual([]); }); }); @@ -115,7 +116,7 @@ describe('PLATFORM_CAPABILITY_PROVIDERS vs vocabulary + serve resolver (#3366)', }); it('open-edition service tokens name the SAME package as serve CAPABILITY_PROVIDERS', () => { - for (const [token, spec] of Object.entries(Serve.CAPABILITY_PROVIDERS)) { + for (const [token, spec] of Object.entries(CAPABILITY_PROVIDERS)) { const provider = PLATFORM_CAPABILITY_PROVIDERS[token]; expect(provider, `serve provider '${token}' has no registry entry`).toBeTruthy(); expect(provider.package, `package mismatch for '${token}'`).toBe(spec.pkg); @@ -203,7 +204,7 @@ describe('PLATFORM_PLUGIN_WIRED_RUNTIMES vs providers + serve resolver (#11263)' }); it('no roster package appears in serve CAPABILITY_PROVIDERS — plugins[]-wired is not requires-resolved by serve', () => { - const servePackages = Object.values(Serve.CAPABILITY_PROVIDERS).map((s) => s.pkg); + const servePackages = Object.values(CAPABILITY_PROVIDERS).map((s) => s.pkg); for (const pkg of Object.keys(PLATFORM_PLUGIN_WIRED_RUNTIMES)) { expect(servePackages, `'${pkg}' is loaded by serve's open-edition resolver — it belongs in the token-keyed map`).not.toContain(pkg); } diff --git a/packages/core/src/capability-providers.ts b/packages/core/src/capability-providers.ts new file mode 100644 index 00000000000..c436c197007 --- /dev/null +++ b/packages/core/src/capability-providers.ts @@ -0,0 +1,262 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The `requires` token → built-in provider plugin table, and the ONE rule that + * decides whether a boot already holds a capability's provider. + * + * ## Why this lives in `@objectstack/core` and not in `serve` + * + * Two boots resolve an app's `requires` and they sit in packages that cannot + * import each other: `os serve` (`@objectstack/cli`) and the in-process + * verification handle (`@objectstack/verify`'s `bootStack`). + * `@objectstack/cli` depends on `@objectstack/verify`, so the handle could + * never read a table kept on the `Serve` command — and an app's tests booted a + * stack that ignored the providers its `requires` names, which `serve` mounts. + * `@objectstack/core` is already a dependency of both, so hosting the table + * here adds NO package edge to the graph (the reason `artifact-packages.ts` + * gives for its own home). `Serve.CAPABILITY_PROVIDERS` and + * `Serve.providesCapability` stay as handles over THESE declarations — one + * declaration, several readers, ⛔ never a second copy. + * + * What is shared is the LOOKUP: the token table and the exact identity match. + * How each boot constructs a provider stays with that boot — `serve` reads mail, + * SMS and storage configuration from the app and the environment; the handle + * constructs each provider with its own defaults; the schema-migration boot + * takes a declared posture per token. + * + * Pure data and one pure function: importing this module loads nothing — a + * reader imports a provider package itself, from its own dependencies. + */ + +/** + * The IDENTITIES a capability provider registers under: full `plugin.name` ids + * (`com.objectstack.mcp`) and/or exported class names (`MCPServerPlugin`). + * + * Compared EXACTLY by {@link providesCapability} — never as substrings. + * These used to be free-form *fragments* tested with `String.includes()`; see + * that function for the whole class of bug that spelling caused (#7652). + */ +export type CapabilityIdentities = string[]; + +/** One provider row of {@link CAPABILITY_PROVIDERS}. */ +export type CapabilitySpec = { + pkg: string; + export: string; // named export to import + identities: CapabilityIdentities; // exact provider identities — see the type + configKey?: string; // optional config field passed as constructor arg + extras?: Array<{ pkg: string; export: string; identities: CapabilityIdentities }>; +}; + +/** + * Registry of `requires` token → built-in service-plugin provider. Keys are + * canonical kebab-case platform capability tokens — a drift test asserts every + * key is in the spec-owned PLATFORM_CAPABILITY_TOKENS vocabulary + * (framework#3265). Adding a built-in capability = one entry here + its token + * in the spec vocabulary + the provider package declared by every boot that + * reads this table (`@objectstack/cli`, `@objectstack/verify` — each pins it). + * + * `identities` are matched EXACTLY (see {@link providesCapability}), so each + * entry names the provider's real registered `plugin.name` — NOT a shortened + * fragment of it. Before #7652 most of these name fragments were in fact dead + * (`service-cache` never matched `com.objectstack.service.cache`: dash vs dot), + * and the entries were carried entirely by their class name. + */ +export const CAPABILITY_PROVIDERS: Readonly> = { + automation: { + // Self-contained: AutomationServicePlugin seeds all built-in node + // executors itself (ADR-0018), so flows have executors with no + // companion node-pack plugins. + pkg: '@objectstack/service-automation', + export: 'AutomationServicePlugin', + identities: ['com.objectstack.service-automation', 'AutomationServicePlugin'], + }, + analytics: { + pkg: '@objectstack/service-analytics', + export: 'AnalyticsServicePlugin', + identities: ['com.objectstack.service-analytics', 'AnalyticsServicePlugin'], + configKey: 'analyticsCubes', + }, + audit: { + pkg: '@objectstack/plugin-audit', + export: 'AuditPlugin', + identities: ['com.objectstack.audit', 'AuditPlugin'], + }, + cache: { + pkg: '@objectstack/service-cache', + export: 'CacheServicePlugin', + identities: ['com.objectstack.service.cache', 'CacheServicePlugin'], + }, + storage: { + pkg: '@objectstack/service-storage', + export: 'StorageServicePlugin', + identities: ['com.objectstack.service.storage', 'StorageServicePlugin'], + }, + queue: { + pkg: '@objectstack/service-queue', + export: 'QueueServicePlugin', + identities: ['com.objectstack.service.queue', 'QueueServicePlugin'], + }, + job: { + pkg: '@objectstack/service-job', + export: 'JobServicePlugin', + identities: ['com.objectstack.service.job', 'JobServicePlugin'], + }, + messaging: { + // Backs the `notify` flow node (ADR-0012): delivers to a user's + // channels (inbox by default → `sys_inbox_message` rows). Without + // this the notify node degrades to a logged no-op. + pkg: '@objectstack/service-messaging', + export: 'MessagingServicePlugin', + identities: ['com.objectstack.service.messaging', 'MessagingServicePlugin'], + }, + triggers: { + // Makes autolaunched flows actually fire. The automation engine ships + // the `FlowTrigger` wiring; these plugins are the concrete triggers: + // record-change (ObjectQL lifecycle hooks) + schedule (cron/interval + // via the job service — so pair `triggers` with `job`). + pkg: '@objectstack/trigger-record-change', + export: 'RecordChangeTriggerPlugin', + identities: ['com.objectstack.trigger.record-change', 'RecordChangeTriggerPlugin'], + extras: [ + { + pkg: '@objectstack/trigger-schedule', + export: 'ScheduleTriggerPlugin', + identities: ['com.objectstack.trigger.schedule', 'ScheduleTriggerPlugin'], + }, + { + // Declarative time-relative sweep (#1874) — arms flows whose start + // node declares `config.timeRelative` (fire daily for records whose + // date field is within N days / at T-minus offsets). Ships in + // @objectstack/trigger-schedule; needs the job service + ObjectQL. + pkg: '@objectstack/trigger-schedule', + export: 'TimeRelativeTriggerPlugin', + identities: ['com.objectstack.trigger.time-relative', 'TimeRelativeTriggerPlugin'], + }, + { + // Inbound webhook/HTTP trigger (ADR-0041 Tier 1) — arms + // `type: 'api'` flows with HMAC-verified, queue-backed hooks. + pkg: '@objectstack/trigger-api', + export: 'ApiTriggerPlugin', + identities: ['com.objectstack.trigger.api', 'ApiTriggerPlugin'], + }, + ], + }, + realtime: { + pkg: '@objectstack/service-realtime', + export: 'RealtimeServicePlugin', + identities: ['com.objectstack.service.realtime', 'RealtimeServicePlugin'], + }, + // `feed` removed (ADR-0052 §5): `sys_comment`/`sys_activity` (durable, + // default-loaded, UI-wired) is the canonical record collaboration + + // timeline backend. `@objectstack/service-feed` was an in-memory, + // non-durable, UI-unconsumed parallel implementation — retired to end + // the split-brain. The unified typed timeline lives on `sys_activity`. + mcp: { + pkg: '@objectstack/mcp', + export: 'MCPServerPlugin', + identities: ['com.objectstack.mcp', 'MCPServerPlugin'], + }, + marketplace: { + pkg: '@objectstack/service-package', + export: 'PackageServicePlugin', + identities: ['package-service', 'PackageServicePlugin'], + }, + // The always-on persistence half of the `marketplace` / `package-registry` + // split (#17676 ruling A' items 1-2): `sys_packages` and its boot + // hydration, so `protocol.installPackage` / `updatePackage` find the + // `package` service on a stock boot. Keyed at the provider the spec's + // PLATFORM_CAPABILITY_PROVIDERS row declares for this token — the SAME + // package and plugin as `marketplace` above, because that is what the spec + // map says today. Repointing `marketplace` at the browse surface starts at + // that spec row, and this table follows it; until then an app declaring + // `marketplace` gets ONE PackageServicePlugin, not two — see + // `resolverMounted` in the capability resolver. + 'package-registry': { + pkg: '@objectstack/service-package', + export: 'PackageServicePlugin', + identities: ['package-service', 'PackageServicePlugin'], + }, + email: { + pkg: '@objectstack/plugin-email', + export: 'EmailServicePlugin', + identities: ['com.objectstack.service.email', 'EmailServicePlugin'], + }, + sms: { + // #2780 — backs phone-number OTP sign-in/reset (plugin-auth) and + // the messaging `sms` channel. Provider config lives in the `sms` + // settings namespace (OS_SMS_* env keys win at the resolver); + // unconfigured ⇒ dev LogSmsTransport (no real send). + pkg: '@objectstack/service-sms', + export: 'SmsServicePlugin', + identities: ['com.objectstack.service.sms', 'SmsServicePlugin'], + }, + sharing: { + pkg: '@objectstack/plugin-sharing', + export: 'SharingServicePlugin', + identities: ['com.objectstack.service.sharing', 'SharingServicePlugin'], + }, + // #2486 — auto-required above when resolveSearchPinyinEnabled() + // (explicit env, else any configured zh-* locale) says on. + 'pinyin-search': { + pkg: '@objectstack/plugin-pinyin-search', + export: 'PinyinSearchPlugin', + identities: ['com.objectstack.plugin.pinyin-search', 'PinyinSearchPlugin'], + }, + approvals: { + pkg: '@objectstack/plugin-approvals', + export: 'ApprovalsServicePlugin', + identities: ['com.objectstack.service.approvals', 'ApprovalsServicePlugin'], + }, + settings: { + pkg: '@objectstack/service-settings', + export: 'SettingsServicePlugin', + identities: ['com.objectstack.service.settings', 'SettingsServicePlugin'], + }, + webhooks: { + pkg: '@objectstack/plugin-webhooks', + export: 'WebhookOutboxPlugin', + identities: ['com.objectstack.plugin-webhook-outbox', 'WebhookOutboxPlugin'], + }, +}; + +/** + * Is one of `identities` ALREADY loaded — i.e. did the app (or the boot) supply + * this capability's provider itself, so the resolver must not load a second one? + * + * Compares a plugin's `name` and its constructor name against the declared + * identities by EQUALITY. That exactness is the fix for #7652, not a detail: + * + * This check used to treat `identities` as free-form fragments and test them + * with `String.includes()`. Substring matching cannot tell a capability's + * PROVIDER from one of its CONSUMERS, because a consumer is conventionally + * named after the thing it consumes — so any plugin whose name merely + * CONTAINED a fragment satisfied the capability and SUPPRESSED the real + * provider. The stock showcase hit exactly that: it loads + * `com.objectstack.connector.mcp` (the outbound MCP *client* connector), + * whose name contains the `mcp` fragment, so `MCPServerPlugin` never loaded + * and the MCP endpoint the boot banner advertises answered 501. + * + * `mcp` was not the only fragment short enough to collide (`audit` was one + * consumer away from the same fate, and every class-name fragment was + * satisfied by any class merely ENDING in it, e.g. `MyAuditPlugin` for + * `AuditPlugin`). Equality closes the class: a plugin either IS the provider + * or it is not, and no naming convention can blur that. + * + * Both directions matter. Tightening the comparison must not stop a genuine + * provider being recognised, so the registry above declares each provider's + * REAL registered `name` (measured from its package, and pinned by the CLI's + * `serve-capability-identity.test.ts` so a rename can't silently reintroduce + * double-loading) alongside its exported class name. + */ +export function providesCapability(plugins: readonly unknown[], identities: readonly string[]): boolean { + const wanted = new Set(identities.filter((id) => id !== '')); + if (wanted.size === 0) return false; + return plugins.some((p) => { + const name = (p as { name?: unknown } | null | undefined)?.name; + const ctor = (p as { constructor?: { name?: unknown } } | null | undefined)?.constructor?.name; + return ( + (typeof name === 'string' && wanted.has(name)) || + (typeof ctor === 'string' && wanted.has(ctor)) + ); + }); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2278c6fb03f..c5feb0d7da3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -17,6 +17,14 @@ export * from './plugin-order.js'; // because the ordering it performs is `resolvePluginOrder` directly above. // `@objectstack/objectql` re-exports it, so its published surface is unchanged. export * from './artifact-packages.js'; +// [#22301] The two halves of resolving an app's `requires` that `os serve` +// (`@objectstack/cli`) and the verification handle (`@objectstack/verify`) both +// read — the package-owned collection reader (ADR-0130 D4) and the token → +// provider table with its exact identity match. Here for `artifact-packages`' +// reason: the CLI depends on the handle, so neither reader can host them, and +// both already depend on this package. +export * from './stack-collections.js'; +export * from './capability-providers.js'; export * from './lite-kernel.js'; export * from './types.js'; export * from './logger.js'; diff --git a/packages/core/src/stack-collections.ts b/packages/core/src/stack-collections.ts new file mode 100644 index 00000000000..92faeae4a71 --- /dev/null +++ b/packages/core/src/stack-collections.ts @@ -0,0 +1,191 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0130 D4 / option B — resolving a package-owned collection off a stack, + * whatever shape that stack arrived in: the top-level list when the stack + * carries one, otherwise each package body's, in package order. + * + * ## Why this lives in `@objectstack/core` + * + * It started life in `@objectstack/cli` (`src/utils/stack-collections.ts`, + * which keeps every CLI-only predicate built on it and re-exports these), and + * every `os` command reads through it. The `requires` reader then gained a + * reader outside the CLI: `@objectstack/verify`'s `bootStack` mounts the + * providers an app's `requires` names, and it has to read `requires` by the + * SAME rule `os serve` does, or a multi-package app would boot one set of + * providers under `serve` and another under its own tests. `@objectstack/cli` + * depends on `@objectstack/verify`, so the handle cannot import the CLI; both + * already depend on `@objectstack/core`, which owns the package ordering this + * rule is built on (`resolveArtifactPackageOrder`). Hosting it here adds NO + * package edge — ⛔ never a second copy of the rule beside a reader. + * + * ## The resolution rule, and why it is strictly additive + * + * `resolveStackCollection` answers with the top-level array FIRST and only then + * consults `packages[]`. In the additive shape that array already IS the union + * (`composeStacks` flattened it), so unioning again would double every item. + * (The CLI module header carries the history of the four config-load boundaries + * that made this the one reader.) + */ + +import { resolveArtifactPackageOrder } from './artifact-packages.js'; + +type Bag = Record; + +const asBag = (value: unknown): Bag | undefined => + value && typeof value === 'object' ? (value as Bag) : undefined; + +/** + * A stack's `packages` value, judged ONCE for every reader in this package that + * walks it: the entries, by position, or `[]` when the key is absent. + * + * ## A present non-array `packages` is refused, never read as "no packages" + * + * A `packages` that is present but is not an array (`{}`, `0`, `'x'`) is + * MALFORMED, not absent (ruling A on #15293). The rule is stated once, beside + * `AssembledPackageBodySchema` (`@objectstack/spec`, `stack.zod.ts`), and it is + * enforced by `resolveArtifactPackageOrder` (`@objectstack/core`), which + * refuses the value as `INVALID_ARTIFACT_PACKAGES` (ADR-0112, `status: 422`). + * The runtime, `@objectstack/core` and the plugin readers already refuse it. + * This package's readers used to answer "no packages" instead (#19925): `os + * info` printed `0` objects for such a stack and `os lint` passed it. So this + * function spells neither the rule nor the refusal. It hands a non-array value + * to the resolver, and the refusal the author sees is the resolver's own. + * + * - The key ABSENT (`undefined`) answers `[]`. This is the only value the + * function answers on its own. + * - An array is returned BY REFERENCE and unparsed. The docs readers need + * entries by POSITION (`packages[i]` is where collected docs attach), and the + * resolver answers bodies in LOAD order, so they cannot read its result. + * Parsing each entry is the resolver's job on the path that registers + * packages ({@link stackPackageBodies} reaches it). Adding that parse to the docs + * readers would widen what they refuse, and that is a separate change. + * - Every other value goes to the resolver, `null` included. A non-array is + * refused there. + * + * ## `null` follows the resolver, and is never judged here + * + * Ruling A on #19926 (`5805260775`) settles `null`: it is malformed at every + * reader, and `resolveArtifactPackageOrder` drops its `null` branch. That core + * change lands separately (#19926), and until it does, the resolver still + * answers `null` through its ABSENT branch. So this function does not answer + * `null` itself. It asks the resolver and reads the answer: + * + * - The resolver's absent answer is `[artifact]`, holding the caller's own + * object BY REFERENCE (ADR-0130 D4, second branch). This function recognises + * that answer by IDENTITY against the object it passed in, and returns `[]`. + * That is today's answer for `null`, byte for byte. + * - Once the resolver refuses `null`, its `INVALID_ARTIFACT_PACKAGES` reaches + * every reader here, with no edit to this package. + * + * ⛔ Never add a private `null` branch here, in either direction. Answering + * `null` as absent here would keep the CLI reading it as absent after the + * resolver starts refusing it. Refusing it here would be a second copy of a + * rule the resolver owns. + * + * ⛔ Never put an `Array.isArray` in front of this function as a fall-through + * to "no packages". That silent answer is exactly what this function removes. + * + * @throws Whatever the resolver raises for a present non-array `packages`: + * today `INVALID_ARTIFACT_PACKAGES` for every non-array except `null`. + */ +export function declaredPackageEntries(packages: unknown): readonly unknown[] { + // The key is absent: there is nothing to judge. + if (packages === undefined) return []; + if (Array.isArray(packages)) return packages; + // Present and not an array, `null` included: the resolver decides. + const probe = { packages }; + const answer = resolveArtifactPackageOrder(probe); + // The resolver's ABSENT answer holds the probe itself, by reference. + if (answer.length === 1 && answer[0] === probe) return []; + // Reached only if the resolver answers a non-array with anything but a + // refusal or its absent answer. An empty answer here would bring back the + // silent fall-through, so fail loudly. + throw new Error( + `resolveArtifactPackageOrder accepted a \`packages\` of type ${packages === null ? 'null' : typeof packages}; ` + + 'the stack collection readers cannot walk it by position.', + ); +} + +/** + * The assembled package bodies this stack carries, in dependency-topological + * order — or `[]` when it carries no `packages` list of its own. + * + * `resolveArtifactPackageOrder` answers `[artifact]` for a stack with no + * `packages` key (ADR-0130 D4, second branch: the caller's own object IS the + * one package's body). That answer is correct there and useless here — folding + * a stack's own top level back onto itself resolves nothing — so this returns + * an empty list for that case, and every caller below reads the top level + * first anyway. + * + * A `packages` that is present but is not an array goes through + * {@link declaredPackageEntries}, which hands it to the resolver: it is refused, + * or, for `null` while the resolver still reads it as absent, answered `[]`. + */ +export function stackPackageBodies(stack: unknown): Array> { + if (declaredPackageEntries(asBag(stack)?.packages).length === 0) return []; + return (resolveArtifactPackageOrder(stack) as unknown[]) + .map(asBag) + .filter((b): b is Bag => b !== undefined); +} + +/** + * One collection, concatenated across already-resolved bodies. + * + * ⚠️ The TOP LEVEL IS NOT CONSULTED — this is the option-B leg only. Callers + * that must preserve today's answer read their own expression first; see + * {@link resolveStackCollection} for the combined form. + * + * Takes the BODIES rather than the stack so a caller asking about several keys + * resolves the package list once. `resolveArtifactPackageOrder` parses every + * entry whole, and `authoringRuleUnionStack` asks about all 37 collections — + * re-resolving per key would run that parse 37 times on every `os build`. + */ +export function collectFromPackageBodies(bodies: readonly Record[], key: string): unknown[] { + const out: unknown[] = []; + for (const body of bodies) { + const value = body[key]; + if (Array.isArray(value)) out.push(...value); + } + return out; +} + +/** {@link collectFromPackageBodies} for a caller that has a stack and asks about one key. */ +function packageCollection(stack: unknown, key: string): unknown[] { + return collectFromPackageBodies(stackPackageBodies(stack), key); +} + +/** + * The effective value of one package-owned collection. + * + * The top-level array WINS whenever the key is present — in today's additive + * shape that array already IS the union (`composeStacks` flattened it), so + * unioning again would double every item. `packages[]` is consulted only when + * the top level does not carry the key at all, which is precisely the option-B + * shape. + */ +export function resolveStackCollection(stack: unknown, key: string): unknown[] { + const top = asBag(stack)?.[key]; + if (Array.isArray(top)) return top; + return packageCollection(stack, key); +} + +/** + * The capability tokens a stack DECLARES in `requires`, by + * {@link resolveStackCollection}'s rule: the top-level list when the stack + * carries one, otherwise every package body's, in package order. String + * entries only, duplicates kept (each caller dedupes in its own order). + * + * A multi-package `composeStacks(…, { manifest: 'preserve' })` stack carries + * `requires` only inside the body of the package that declared it (ADR-0130 + * D4, 2026-09-22 addendum). A reader of the top level alone read `[]` there: + * `os serve` did not mount the provider a package declared, `os migrate plan` + * could not order a plugin that hard-depends on it, and `os generate` told the + * author to declare a token a package already declares (#22288). The build + * doors attribute each token to its package instead + * (`preflightDeclaredCapabilities`), on the same rule, and `@objectstack/verify`'s + * `bootStack` mounts the providers it names (#22301). + */ +export function stackDeclaredCapabilities(stack: unknown): string[] { + return resolveStackCollection(stack, 'requires').filter((token): token is string => typeof token === 'string'); +} diff --git a/packages/verify/package.json b/packages/verify/package.json index f6383e1aa7e..73f6776b069 100644 --- a/packages/verify/package.json +++ b/packages/verify/package.json @@ -27,19 +27,36 @@ }, "dependencies": { "@objectstack/core": "workspace:*", + "@objectstack/mcp": "workspace:*", "@objectstack/objectql": "workspace:*", "@objectstack/platform-objects": "workspace:*", + "@objectstack/plugin-approvals": "workspace:*", + "@objectstack/plugin-audit": "workspace:*", "@objectstack/plugin-auth": "workspace:*", + "@objectstack/plugin-email": "workspace:*", "@objectstack/plugin-hono-server": "workspace:*", + "@objectstack/plugin-pinyin-search": "workspace:*", "@objectstack/plugin-security": "workspace:*", "@objectstack/plugin-sharing": "workspace:*", + "@objectstack/plugin-webhooks": "workspace:*", "@objectstack/rest": "workspace:*", "@objectstack/runtime": "workspace:*", "@objectstack/service-analytics": "workspace:*", "@objectstack/service-automation": "workspace:*", + "@objectstack/service-cache": "workspace:*", "@objectstack/service-datasource": "workspace:*", + "@objectstack/service-job": "workspace:*", + "@objectstack/service-messaging": "workspace:*", + "@objectstack/service-package": "workspace:*", + "@objectstack/service-queue": "workspace:*", + "@objectstack/service-realtime": "workspace:*", "@objectstack/service-settings": "workspace:*", + "@objectstack/service-sms": "workspace:*", + "@objectstack/service-storage": "workspace:*", "@objectstack/spec": "workspace:*", + "@objectstack/trigger-api": "workspace:*", + "@objectstack/trigger-record-change": "workspace:*", + "@objectstack/trigger-schedule": "workspace:*", "@objectstack/types": "workspace:*" }, "devDependencies": { diff --git a/packages/verify/src/harness.required-providers.test.ts b/packages/verify/src/harness.required-providers.test.ts new file mode 100644 index 00000000000..46c0e6267c0 --- /dev/null +++ b/packages/verify/src/harness.required-providers.test.ts @@ -0,0 +1,136 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #22301 item 1 — `bootStack` mounts the providers an app's `requires` names, + * as `objectstack serve` does: `serve`'s reader (`stackDeclaredCapabilities`), + * `serve`'s table (`CAPABILITY_PROVIDERS`), `serve`'s "an explicit instance + * wins" rule (`providesCapability`) — all three now in `@objectstack/core`. + * + * Measured before the fix (`origin/main` 28bff18d0): a `requires: ['approvals']` + * app booted through `bootStack` registered no `com.objectstack.service.approvals` + * plugin and no `sys_approval_request` object, in either stack shape; the app + * had to name the plugin in `extraPlugins` (hotcrm keeps five such entries). + * + * 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). + */ + +import { readFileSync } from 'node:fs'; +import { afterEach, describe, expect, it } from 'vitest'; +import { CAPABILITY_PROVIDERS } from '@objectstack/core'; +import { composeStacks, defineStack } from '@objectstack/spec'; +import { bootStack, type VerifyStack } from './harness.js'; + +const APPROVALS = 'com.objectstack.service.approvals'; + +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' }; +const note = { + name: 'rqp_note', label: 'Note', pluralLabel: 'Notes', sharingModel: 'public_read_write', + fields: { name: { name: 'name', type: 'text', label: 'Name', required: true } }, +}; +const ticket = { + name: 'rqp_ticket', label: 'Ticket', pluralLabel: 'Tickets', sharingModel: 'public_read_write', + fields: { title: { name: 'title', type: 'text', label: 'Title', required: true } }, +}; + +type Bag = Record; + +/** One package, `extra` at its top level. */ +const onePackage = (extra: Bag = {}): Bag => + defineStack({ manifest: appManifest, objects: [ticket, note], ...extra } as never) as unknown as Bag; + +/** Two packages through the real producer: `requires` lives only in the bodies. */ +const twoPackages = (svcExtra: Bag = {}, appExtra: Bag = {}): Bag => + composeStacks( + [ + defineStack({ manifest: svcManifest, objects: [note], ...svcExtra } as never), + defineStack({ manifest: appManifest, objects: [ticket], ...appExtra } as never), + ], + { manifest: 'preserve' }, + ) as unknown as Bag; + +let stack: VerifyStack | undefined; +afterEach(async () => { + await stack?.stop(); + stack = undefined; +}); + +const approvalsMounted = (s: VerifyStack) => ({ + plugin: s.kernel.hasPlugin(APPROVALS), + object: s.metadata.object('sys_approval_request') !== undefined, +}); + +describe('bootStack mounts what `requires` names, by serve\'s reader', () => { + it('top-level `requires` — the provider is mounted and its objects registered', async () => { + stack = await bootStack(onePackage({ requires: ['approvals'] })); + expect(approvalsMounted(stack)).toEqual({ plugin: true, object: true }); + }, 120_000); + + it('a package body\'s `requires` — counted when the top level carries none', async () => { + const config = twoPackages({ requires: ['approvals'] }); + // Anti-vacuity: the producer really left `requires` out of the top level. + expect(config.requires).toBeUndefined(); + stack = await bootStack(config); + expect(approvalsMounted(stack)).toEqual({ plugin: true, object: true }); + }, 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); + 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); + }, 120_000); +}); + +describe('serve\'s other two rules', () => { + it('an explicit instance wins: a caller\'s provider is kept, the boot\'s is not constructed', async () => { + // A stand-in that registers under the provider's identity and nothing else + // — so the absence of `sys_approval_request` proves the real one never ran. + class StandInApprovals { + readonly name = APPROVALS; + readonly version = '0.0.0'; + readonly type = 'standard'; + async init(): Promise {} + } + stack = await bootStack(onePackage({ requires: ['approvals'] }), { extraPlugins: [new StandInApprovals()] }); + 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 () => { + stack = await bootStack(onePackage({ requires: ['automation', 'triggers'] })); + for (const name of [ + 'com.objectstack.service-automation', + 'com.objectstack.trigger.record-change', + 'com.objectstack.trigger.schedule', + 'com.objectstack.trigger.time-relative', + 'com.objectstack.trigger.api', + 'com.objectstack.service.job', + 'com.objectstack.service.queue', + ]) { + expect(stack.kernel.hasPlugin(name), name).toBe(true); + } + }, 120_000); +}); + +describe('every provider the table names is a dependency of this package', () => { + it('so a declared token never fails to import from @objectstack/verify', () => { + const manifest = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { + dependencies: Record; + }; + const packages = Object.values(CAPABILITY_PROVIDERS).flatMap((spec) => [ + spec.pkg, + ...(spec.extras ?? []).map((extra) => extra.pkg), + ]); + expect(packages.length).toBeGreaterThan(0); + expect(packages.filter((pkg) => !(pkg in manifest.dependencies))).toEqual([]); + }); +}); diff --git a/packages/verify/src/harness.ts b/packages/verify/src/harness.ts index a7d5e7b8a69..cdcdb4da76d 100644 --- a/packages/verify/src/harness.ts +++ b/packages/verify/src/harness.ts @@ -38,6 +38,7 @@ import { PlatformObjectsPlugin } from '@objectstack/platform-objects/plugin'; // THAT app, not from `packages/verify`'s own realpath inside this workspace. import { createHostImporter, hostImportFailureKind } from '@objectstack/types/node'; import { createHandle, type VerifyHandle } from './handle.js'; +import { constructRequiredProviders } from './required-providers.js'; /** A Hono app exposes `.request(path, init)` returning a standard `Response`. */ interface InjectableApp { @@ -326,6 +327,9 @@ export interface BootOptions { * the current working directory — a programmatic harness verifying several * apps in one process, or a test fixture on a temp path. * + * [#22301] It is also the `packageRoot` a required `automation` service is + * handed — the app's root, where `objectstack serve` anchors the same token. + * * Exists because Node ESM resolves a bare `import()` against the importer's * own realpath: without a host anchor, `packages/verify` can only ever see the * framework's own `node_modules`, so an app-installed package was invisible no @@ -352,6 +356,12 @@ export interface BootOptions { * #4420 grew in exactly that gap. * * Pass `{ suspendedRunStore: 'memory' }` to opt a fixture back out. + * + * [#22301] An app that DECLARES `requires: ['automation']` gets the service + * without this option, as it does under `objectstack serve` (see + * `./required-providers.ts`). Set it anyway for an app that does not declare + * it, or for the `suspendedRunStore` choice above: with it set, this option's + * instance is the one the boot keeps. */ automation?: boolean | { suspendedRunStore?: 'auto' | 'memory' }; /** @@ -395,9 +405,14 @@ export interface BootOptions { * Extra plugins to register between the app/service pairs and the * SecurityPlugin — the slot where `objectstack dev` auto-loads optional * service pairs the lean harness omits (e.g. `StorageServicePlugin` + - * `AuditPlugin` for the attachments surface). The caller instantiates the - * plugins so `@objectstack/verify` gains no new dependencies. Registered in - * array order. Default `[]`. + * `AuditPlugin` for the attachments surface). Registered in array order. + * Default `[]`. + * + * [#22301] Not for the providers the app's `requires` names: the boot mounts + * those itself, by `objectstack serve`'s table (see `./required-providers.ts`). + * A plugin here that IS such a provider wins over the boot's own — the + * capability is then skipped whole, `serve`'s "an explicit instance wins" + * rule — so a suite that needs a provider configured its own way passes it. */ extraPlugins?: unknown[]; /** @@ -502,6 +517,9 @@ export async function bootStack( }; const kernel = new ObjectKernel(); + // The host app's root — where `multiTenant` resolves the enterprise package + // from, and the `packageRoot` a required `automation` is handed (#22301). + const hostRoot = opts.hostRoot ?? process.cwd(); // Data engine + in-memory SQLite (pure-JS WASM driver — no native build, CI-safe). // The default datasource is a DECLARED DEFINITION connected through the @@ -539,8 +557,10 @@ export async function bootStack( // [#21499] The settings service seals under the harness's in-process key, // never under a provider of its own (see `harnessCryptoProvider`). const cryptoProvider = harnessCryptoProvider(); - await kernel.use(new SettingsServicePlugin({ cryptoProvider })); - await kernel.use(opts.analytics ?? new AnalyticsServicePlugin()); + const settingsPlugin = new SettingsServicePlugin({ cryptoProvider }); + await kernel.use(settingsPlugin); + const analyticsPlugin = opts.analytics ?? new AnalyticsServicePlugin(); + 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 // `single` the Default Organization is a boot invariant — `AuthPlugin.start()` @@ -632,7 +652,6 @@ export async function bootStack( // it" can hand in a name the workspace can never supply. Production callers // pass nothing and get `ORGANIZATIONS_PKG` — see BootOptions.organizationsPackage. const organizationsPkg = opts.organizationsPackage ?? ORGANIZATIONS_PKG; - const hostRoot = opts.hostRoot ?? process.cwd(); let mod: any; try { mod = await createHostImporter(hostRoot, { @@ -680,12 +699,14 @@ export async function bootStack( // approval e2e covered, and #4420 grew there. It now boots the plugin's own // `'auto'` default, the same wiring `objectstack dev`/`serve` get, and a // fixture that wants the old behaviour asks for it explicitly. + let automationPlugin: unknown; if (opts.automation) { const { AutomationServicePlugin } = await import('@objectstack/service-automation'); const automationOpts = typeof opts.automation === 'object' ? opts.automation : {}; - await kernel.use(new AutomationServicePlugin({ + automationPlugin = new AutomationServicePlugin({ ...(automationOpts.suspendedRunStore ? { suspendedRunStore: automationOpts.suspendedRunStore } : {}), - })); + }); + await kernel.use(automationPlugin as any); } // Caller-supplied optional service pairs (see BootOptions.extraPlugins). @@ -694,6 +715,33 @@ export async function bootStack( await kernel.use(plugin as any); } + // [#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. + const sharingPlugin = new SharingServicePlugin(); + try { + const requiredProviders = await constructRequiredProviders({ + config, + held: [ + settingsPlugin, + analyticsPlugin, + sharingPlugin, + ...(automationPlugin ? [automationPlugin] : []), + ...(opts.extraPlugins ?? []), + ], + isRegistered: (name) => kernel.hasPlugin(name), + packageRoot: hostRoot, + }); + for (const plugin of requiredProviders) { + await kernel.use(plugin as any); + } + } catch (e) { + restoreTenancyPosture(); + throw e; + } + // [#7001] The app's DECLARED default profile, resolved the one way every boot // path resolves it. Character-for-character what `objectstack serve` does // (`packages/cli/src/commands/serve.ts`) — the same helper, the same argument @@ -709,7 +757,7 @@ export async function bootStack( // Sharing service — apps that declare `requires: ['sharing']` rely on it for // record-share grants; without it their RLS/sharing rules are inert and the // verifier would under-report authorization. - await kernel.use(new SharingServicePlugin()); + await kernel.use(sharingPlugin); // REST + dispatcher route surfaces (mount onto the http-server service). // Anonymous access to object data is denied unconditionally (#3963 retired the diff --git a/packages/verify/src/required-providers.ts b/packages/verify/src/required-providers.ts new file mode 100644 index 00000000000..8a847443b34 --- /dev/null +++ b/packages/verify/src/required-providers.ts @@ -0,0 +1,152 @@ +// 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. +// +// `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. +// +// ## What is `serve`'s, read from where `serve` reads it +// +// · 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 +// `Serve.CAPABILITY_PROVIDERS` is a handle over — main provider, then its +// `extras`. +// · WHEN NOT: `providesCapability`'s exact identity match against what this +// boot already holds — the harness's own settings / analytics / sharing +// services, `opts.automation`, and every `opts.extraPlugins` entry. A +// provider already held skips its whole token, extras included: `serve`'s +// "an explicit instance wins" rule, so a suite that passes its own +// instance keeps it. +// · A token with no row — a tier token (`auth`, `ui`, `i18n`, `ai`) or a +// known token no open package provides (`hierarchy-security`) — mounts +// nothing here, as it mounts nothing in `serve`'s resolver. +// +// ## 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 provider +// hard-depends on — 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. +// · 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. + +import { CAPABILITY_PROVIDERS, providesCapability, stackDeclaredCapabilities } from '@objectstack/core'; +import { PLATFORM_ALWAYS_ON_CAPABILITIES } from '@objectstack/spec/kernel'; + +/** 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; +} + +/** 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 { + try { + const mod = (await import(/* webpackIgnore: true */ pkg)) as Record; + const Ctor = mod[exportName] as (new (arg?: unknown) => unknown) | undefined; + if (typeof Ctor !== 'function') throw new Error(`${pkg} does not export ${exportName}`); + return arg === undefined ? new Ctor() : new Ctor(arg); + } catch (e) { + throw new Error( + `verify: requires: ['${token}'] names ${exportName} (${pkg}), and this boot could not construct 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.', + ); + } +} + +/** + * 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. + * + * @param held - Every plugin instance this boot mounts on its own or was handed + * (`opts.extraPlugins`, `opts.automation`'s instance, the harness's services). + * @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: { + config: unknown; + held: 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]; + + for (const token of declared) { + 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)); + 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 [...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; + } + if (!next) break; + const spec = CAPABILITY_PROVIDERS[next]!; + dependencies.push(await constructProvider(next, spec.pkg, spec.export, undefined)); + } + + return [...dependencies, ...named]; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e3d1796e74..36c46a52fab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3022,24 +3022,42 @@ importers: '@objectstack/core': specifier: workspace:* version: link:../core + '@objectstack/mcp': + specifier: workspace:* + version: link:../mcp '@objectstack/objectql': specifier: workspace:* version: link:../objectql '@objectstack/platform-objects': specifier: workspace:* version: link:../platform-objects + '@objectstack/plugin-approvals': + specifier: workspace:* + version: link:../plugins/plugin-approvals + '@objectstack/plugin-audit': + specifier: workspace:* + version: link:../plugins/plugin-audit '@objectstack/plugin-auth': specifier: workspace:* version: link:../plugins/plugin-auth + '@objectstack/plugin-email': + specifier: workspace:* + version: link:../plugins/plugin-email '@objectstack/plugin-hono-server': specifier: workspace:* version: link:../plugins/plugin-hono-server + '@objectstack/plugin-pinyin-search': + specifier: workspace:* + version: link:../plugins/plugin-pinyin-search '@objectstack/plugin-security': specifier: workspace:* version: link:../plugins/plugin-security '@objectstack/plugin-sharing': specifier: workspace:* version: link:../plugins/plugin-sharing + '@objectstack/plugin-webhooks': + specifier: workspace:* + version: link:../plugins/plugin-webhooks '@objectstack/rest': specifier: workspace:* version: link:../rest @@ -3052,15 +3070,48 @@ importers: '@objectstack/service-automation': specifier: workspace:* version: link:../services/service-automation + '@objectstack/service-cache': + specifier: workspace:* + version: link:../services/service-cache '@objectstack/service-datasource': specifier: workspace:* version: link:../services/service-datasource + '@objectstack/service-job': + specifier: workspace:* + version: link:../services/service-job + '@objectstack/service-messaging': + specifier: workspace:* + version: link:../services/service-messaging + '@objectstack/service-package': + specifier: workspace:* + version: link:../services/service-package + '@objectstack/service-queue': + specifier: workspace:* + version: link:../services/service-queue + '@objectstack/service-realtime': + specifier: workspace:* + version: link:../services/service-realtime '@objectstack/service-settings': specifier: workspace:* version: link:../services/service-settings + '@objectstack/service-sms': + specifier: workspace:* + version: link:../services/service-sms + '@objectstack/service-storage': + specifier: workspace:* + version: link:../services/service-storage '@objectstack/spec': specifier: workspace:* version: link:../spec + '@objectstack/trigger-api': + specifier: workspace:* + version: link:../triggers/trigger-api + '@objectstack/trigger-record-change': + specifier: workspace:* + version: link:../triggers/trigger-record-change + '@objectstack/trigger-schedule': + specifier: workspace:* + version: link:../triggers/trigger-schedule '@objectstack/types': specifier: workspace:* version: link:../types From f64d7cd62ca7065562fafcee7b1a94e97400795c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 23:35:47 +0000 Subject: [PATCH 02/14] wip(core,cli,verify): bootStack mounts the app's own plugins by serve's entry rule; the instance rule Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- packages/cli/src/commands/serve.ts | 37 ++- packages/core/src/index.ts | 3 + packages/core/src/stack-plugins.ts | 73 ++++++ packages/verify/src/harness.ts | 296 +++++++++++++++++++++- packages/verify/src/required-providers.ts | 24 +- 5 files changed, 398 insertions(+), 35 deletions(-) create mode 100644 packages/core/src/stack-plugins.ts diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 5aaddf83353..2a22d6a7f1d 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -30,7 +30,7 @@ import { readEnvWithDeprecation, resolveTenancyPosture, resolveAllowDegradedTena 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`. -import { CAPABILITY_PROVIDERS, providesCapability, type CapabilitySpec } from '@objectstack/core'; +import { CAPABILITY_PROVIDERS, providesCapability, materializeStackPlugin, type CapabilitySpec } from '@objectstack/core'; // The posture vocabulary, read from the package that DEFINES it (#5359) — the // boot gate's fix list enumerates the accepted values, and a second literal // list would be free to drift the day a posture is added. @@ -4221,27 +4221,26 @@ export default class Serve extends Command { if (plugins.length > 0) { for (const plugin of plugins) { try { - let pluginToLoad = plugin; - - // Resolve string references (package names) - if (typeof plugin === 'string') { + // [#22301] What an entry becomes — a string is a package specifier, + // a plain bundle (no `init`) is wrapped into `AppPlugin`, an instance + // is itself — is ONE rule, `materializeStackPlugin` (`@objectstack/ + // core`), which `@objectstack/verify`'s `bootStack` mounts the same + // array by. Only the loading is this boot's own: + const pluginToLoad: any = await materializeStackPlugin(plugin, { // Host-anchored, NOT a bare `import()`: this specifier comes from // the served app's own config, so what the app DECLARES about it is // the contract (commit 9cc6777d3). The helper carries the failure wrapper too. - const imported = await Serve.importConfigPlugin(plugin, hostRoot); - pluginToLoad = imported.default || imported; - } - - // Wrap raw config objects (no init/start) into AppPlugin - // This handles plugins defined as plain { name, objects, ... } bundles - if (pluginToLoad && typeof pluginToLoad === 'object' && !pluginToLoad.init) { - try { - const { AppPlugin } = await import('@objectstack/runtime'); - pluginToLoad = new AppPlugin(pluginToLoad); - } catch (e: any) { - // Fall through to kernel.use which will report the error - } - } + importSpecifier: (specifier) => Serve.importConfigPlugin(specifier, hostRoot), + wrapBundle: async (bundle) => { + try { + const { AppPlugin } = await import('@objectstack/runtime'); + return new AppPlugin(bundle); + } catch { + // Fall through to kernel.use which will report the error + return bundle; + } + }, + }); // [#9863 / #9864] The superseding half of the pair documented at // the `AuditPlugin` auto-registration above: a stack plugin whose diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c5feb0d7da3..500be3c30ae 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -25,6 +25,9 @@ export * from './artifact-packages.js'; // both already depend on this package. export * from './stack-collections.js'; export * from './capability-providers.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'; export * from './lite-kernel.js'; export * from './types.js'; export * from './logger.js'; diff --git a/packages/core/src/stack-plugins.ts b/packages/core/src/stack-plugins.ts new file mode 100644 index 00000000000..3a2d5fe162f --- /dev/null +++ b/packages/core/src/stack-plugins.ts @@ -0,0 +1,73 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The ONE rule that turns an entry of a stack's own `plugins` array into the + * plugin a boot registers — read by `os serve` (`@objectstack/cli`) and by the + * verification handle (`@objectstack/verify`'s `bootStack`). + * + * ## Why this lives in `@objectstack/core` + * + * For one configuration, `bootStack` composes what `objectstack serve` + * composes (#22301, ruling A): the providers the app's `requires` names + * (`capability-providers.ts`, beside this file) AND the plugins in the app's + * own `plugins` array. The two boots sit in packages that cannot import each + * other — `@objectstack/cli` depends on `@objectstack/verify` — and both already + * depend on this package, so the rule lives here and adds no package edge + * (`capability-providers.ts` gives the same reason). ⛔ Never a second copy of + * it beside a reader. + * + * ## What is shared, and what stays with each boot + * + * Shared: WHAT an entry is and what it becomes. An entry is one of three + * shapes, and the answer per shape is the rule: + * + * · a STRING is a package specifier: the module is loaded, and its default + * export (or, failing that, the module itself) is the plugin; + * · an OBJECT WITH NO `init` is a plain metadata bundle (`{ name, objects, … }`): + * it is wrapped into the plugin that registers a bundle — `AppPlugin`; + * · anything else (a plugin instance) is the plugin, as written. + * + * Each boot's own: HOW a specifier is loaded and HOW a bundle is wrapped + * ({@link StackPluginLoaders}). `serve` loads a specifier host-anchored from + * the served app's root with its own diagnostic wrapper, and wraps a bundle + * with `@objectstack/runtime`'s `AppPlugin`; the handle loads from the + * `hostRoot` it is given. `@objectstack/core` cannot import `AppPlugin` itself + * (`@objectstack/runtime` depends on this package), which is why the wrap is + * injected rather than performed here. + * + * Pure: importing this module loads nothing. + */ + +/** How one boot loads a specifier entry and wraps a bundle entry. */ +export interface StackPluginLoaders { + /** + * Load a string entry of `plugins` — a package specifier, resolved the way + * this boot resolves an app-declared package — and answer the module. + */ + importSpecifier(specifier: string): Promise; + /** + * Wrap a plain bundle entry (an object with no `init`) into the plugin that + * registers it. + */ + wrapBundle(bundle: Record): unknown | Promise; +} + +/** + * The plugin one entry of a stack's `plugins` array stands for, by the rule in + * this module's header. Answers the plugin to register; registering it is the + * caller's. + * + * Throws whatever the injected loader throws for a specifier it cannot load — + * each boot decides how loud that is. + */ +export async function materializeStackPlugin(entry: unknown, loaders: StackPluginLoaders): Promise { + let plugin: unknown = entry; + if (typeof entry === 'string') { + const mod = (await loaders.importSpecifier(entry)) as { default?: unknown } | null | undefined; + plugin = mod?.default || mod; + } + if (plugin && typeof plugin === 'object' && !(plugin as { init?: unknown }).init) { + plugin = await loaders.wrapBundle(plugin as Record); + } + return plugin; +} diff --git a/packages/verify/src/harness.ts b/packages/verify/src/harness.ts index cdcdb4da76d..b1dd634e803 100644 --- a/packages/verify/src/harness.ts +++ b/packages/verify/src/harness.ts @@ -37,6 +37,7 @@ 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 { createHandle, type VerifyHandle } from './handle.js'; import { constructRequiredProviders } from './required-providers.js'; @@ -327,8 +328,16 @@ export interface BootOptions { * the current working directory — a programmatic harness verifying several * apps in one process, or a test fixture on a temp path. * - * [#22301] It is also the `packageRoot` a required `automation` service is - * handed — the app's root, where `objectstack serve` anchors the same token. + * [#22301] It is also the app's root in the two places `objectstack serve` + * anchors at the directory holding `objectstack.config.ts`: the `packageRoot` + * the automation service is handed (whether the app's `requires` names + * `automation` or {@link BootOptions.automation} asks for it), which is where + * a declarative connector's package-relative file ref is read from; and the + * root a string entry of the app's own `plugins` array is resolved from. A + * suite whose working directory is not the app's directory passes the app's + * directory here — otherwise such a file ref resolves against the working + * directory, and the boot refuses the connector loudly, as `serve` would + * from the wrong root. * * Exists because Node ESM resolves a bare `import()` against the importer's * own realpath: without a host anchor, `packages/verify` can only ever see the @@ -361,7 +370,8 @@ export interface BootOptions { * without this option, as it does under `objectstack serve` (see * `./required-providers.ts`). Set it anyway for an app that does not declare * it, or for the `suspendedRunStore` choice above: with it set, this option's - * instance is the one the boot keeps. + * instance is the one the boot keeps. Either way the service is handed + * {@link BootOptions.hostRoot} as its `packageRoot`. */ automation?: boolean | { suspendedRunStore?: 'auto' | 'memory' }; /** @@ -408,11 +418,22 @@ export interface BootOptions { * `AuditPlugin` for the attachments surface). Registered in array order. * Default `[]`. * - * [#22301] Not for the providers the app's `requires` names: the boot mounts - * those itself, by `objectstack serve`'s table (see `./required-providers.ts`). - * A plugin here that IS such a provider wins over the boot's own — the - * capability is then skipped whole, `serve`'s "an explicit instance wins" - * rule — so a suite that needs a provider configured its own way passes it. + * [#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: + * + * - over a `requires` provider it IS (exact `name` or class name, `serve`'s + * "an explicit instance wins" rule) — the capability is skipped whole; + * - 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. + * + * So a suite that needs one of those configured its own way passes it here. + * {@link BootOptions.security} and {@link BootOptions.analytics} take + * precedence over an app plugin of the same `name` the same way. */ extraPlugins?: unknown[]; /** @@ -447,15 +468,226 @@ export interface BootOptions { organizationsPackage?: string; } +/** One entry of the app's own `plugins` array, resolved, and how a refusal names it. */ +interface AppPluginEntry { + plugin: unknown; + label: string; +} + +/** A plugin's registered `name` — the identity the kernel keys it by. */ +function registeredName(plugin: unknown): string | undefined { + const name = (plugin as { name?: unknown } | null | undefined)?.name; + return typeof name === 'string' && name !== '' ? name : undefined; +} + +/** + * [#22301] The plugins of the app's own `plugins` array, each resolved by + * `objectstack serve`'s rule for an entry (`materializeStackPlugin`, + * `@objectstack/core`: a string is a package specifier, a plain bundle is + * wrapped into `AppPlugin`, an instance is itself), minus every entry whose + * `name` a caller-handed instance already has — that instance takes precedence + * (see BootOptions.extraPlugins). + * + * `name` is the precedence key because it is the kernel's identity for a + * plugin: two plugins with different names both run, and of two with one name + * the kernel keeps one. A class-name match is deliberately NOT used here — + * every bundle entry becomes an `AppPlugin`, so a caller's own `AppPlugin` + * would silently drop every bundle the app declares. + * + * Reads the top-level `plugins` only, as `serve` does; `devPlugins` is the + * `objectstack dev`-only addition `serve` does not mount, and is not read. + */ +async function resolveAppPlugins( + config: unknown, + opts: { hostRoot: string; callerInstances: readonly unknown[] }, +): Promise { + const declared = (config as { plugins?: unknown } | null | undefined)?.plugins; + // Absent (or `null`) is no plugins, as `serve`'s `config.plugins || []` reads it. + if (declared === undefined || declared === null) return []; + if (!Array.isArray(declared)) { + throw new Error( + `verify: the configuration's \`plugins\` is ${typeof declared}, not an array, so bootStack cannot mount it ` + + 'the way objectstack serve mounts the plugins an app declares. Declare `plugins` as an array of plugin ' + + 'instances, bundles or package names.', + ); + } + const callerNames = new Set( + opts.callerInstances.map(registeredName).filter((n): n is string => n !== undefined), + ); + let importer: ((specifier: string) => Promise) | undefined; + const resolved: AppPluginEntry[] = []; + for (const [index, entry] of declared.entries()) { + const at = typeof entry === 'string' ? `plugins[${index}] ('${entry}')` : `plugins[${index}]`; + let plugin: unknown; + try { + plugin = await materializeStackPlugin(entry, { + importSpecifier: (specifier) => { + // Host-anchored, as `serve` loads an app-declared package (the + // `multiTenant` import below uses the same helper the same way). + importer ??= createHostImporter(opts.hostRoot, { + fallbackImport: (s) => import(/* webpackIgnore: true */ s), + }); + return importer(specifier); + }, + wrapBundle: (bundle) => new AppPlugin(bundle as any), + }); + } catch (e) { + throw new Error( + `verify: the app's ${at} could not be loaded: ${(e as Error).message}. bootStack mounts the plugins of ` + + "the app's own `plugins` array as `objectstack serve` does, and does not boot on without one. A package " + + `name resolves from the app's root, BootOptions.hostRoot (${opts.hostRoot}): declare and install it ` + + "there, or pass the app's directory as hostRoot. A relative path never resolves from the app " + + '(`serve` refuses it); write a package name or an absolute URL.', + ); + } + const name = registeredName(plugin); + if (name !== undefined && callerNames.has(name)) continue; + resolved.push({ plugin, label: name !== undefined ? `${at} '${name}'` : at }); + } + return resolved; +} + +/** + * [#22301] Configurations with a live `bootStack` kernel in this process, and + * the app-plugin instances those kernels mounted — the instance rule's two + * keys (see {@link bootStack}). Weak, so a configuration nobody holds any more + * is never kept alive by having booted once. + */ +const LIVE_CONFIGURATIONS = new WeakSet(); +const LIVE_APP_PLUGINS = new WeakSet(); + +/** How a configuration names itself in a refusal: its manifest id, else its name. */ +function configurationLabel(config: unknown): string { + const c = config as { manifest?: { id?: unknown }; name?: unknown; id?: unknown } | null | undefined; + const id = c?.manifest?.id ?? c?.id ?? c?.name; + return typeof id === 'string' && id !== '' ? `'${id}'` : '(no manifest id)'; +} + +/** + * The instance rule's refusal — the ADR-0112 shape (`code` + `status`) a test + * asserts on, with `RESOURCE_CONFLICT` from the standard catalog: the + * configuration is held by a live kernel, and the second boot conflicts with it. + */ +function instanceRuleRefusal(message: string): Error { + return Object.assign(new Error(message), { code: 'RESOURCE_CONFLICT', status: 409 }); +} + +const INSTANCE_RULE_REMEDY = + 'stop() the live stack before booting this configuration again, or share it: bootStackOnce(config, opts) ' + + 'hands every caller with the same config and options object the one boot.'; + +/** One boot's hold on its configuration and on the app-plugin instances it mounts. */ +interface ConfigurationClaim { + /** Hold these app-plugin instances too; refuses one another live kernel already mounted. */ + holdAppPlugins(entries: ReadonlyArray<{ plugin: unknown; label: string }>): void; + /** Let go of everything held. Idempotent. */ + release(): void; +} + +/** + * Claim `config` for one live kernel, or refuse — synchronously, before the + * boot awaits anything, so two boots started together are refused too. + */ +function claimConfiguration(config: unknown): ConfigurationClaim { + const key = config !== null && typeof config === 'object' ? (config as object) : undefined; + if (key && LIVE_CONFIGURATIONS.has(key)) { + throw instanceRuleRefusal( + `verify: configuration ${configurationLabel(config)} already has a live bootStack kernel in this process. ` + + 'One configuration supports one live kernel at a time: the plugins in its own `plugins` array are ' + + 'module-level instances, and a second kernel would mount the same instances. ' + + INSTANCE_RULE_REMEDY, + ); + } + if (key) LIVE_CONFIGURATIONS.add(key); + const heldPlugins: object[] = []; + let released = false; + return { + holdAppPlugins(entries) { + for (const { plugin, label } of entries) { + if (plugin === null || typeof plugin !== 'object') continue; + if (LIVE_APP_PLUGINS.has(plugin)) { + throw instanceRuleRefusal( + `verify: the app's ${label} is already mounted by another live bootStack kernel in this process — ` + + `configuration ${configurationLabel(config)} is a copy of one that is booted, and a copy carries ` + + 'the same plugin instances. One configuration supports one live kernel at a time. ' + + INSTANCE_RULE_REMEDY, + ); + } + } + for (const { plugin } of entries) { + if (plugin === null || typeof plugin !== 'object') continue; + LIVE_APP_PLUGINS.add(plugin); + heldPlugins.push(plugin); + } + }, + release() { + if (released) return; + released = true; + if (key) LIVE_CONFIGURATIONS.delete(key); + for (const plugin of heldPlugins) LIVE_APP_PLUGINS.delete(plugin); + }, + }; +} + /** * Boot an app config in-process and return a live verification stack. * * `NODE_ENV` is forced to `development` so the auth plugin's dev-admin * bootstrap provisions a known, loginable admin (mirrors `objectstack dev`). + * + * ## 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. + * + * ### Offline CI: `OS_CLOUD_URL=off` + * + * An app whose `plugins` array wires marketplace-facing plugins (the + * `@objectstack/cloud-connection` set) gets them mounted here, as `serve` + * mounts them, and their marketplace routes reach the control plane they are + * pointed at — by default the public ObjectStack catalog. A suite that must + * stay offline sets `OS_CLOUD_URL=off` in its environment before the + * configuration module is imported: a configuration that reads the URL with + * `resolveCloudUrl()` (`@objectstack/cloud-connection`) then declines the + * marketplace, the same switch a fully-offline `serve` uses. + * `packages/qa/dogfood` sets it in its vitest config. + * + * ## The instance rule: one live kernel per configuration, per process + * + * A second `bootStack` of the SAME configuration object while the first is + * still live is refused with `RESOURCE_CONFLICT` (`status: 409`), so the + * module-level plugin instances in its `plugins` array are never mounted by two + * kernels at once. A copy of a booted configuration (`{ ...config }`) is + * refused the same way when it carries an app-plugin instance a live kernel + * mounted. Live means from the call until `stop()` resolves, or until the boot + * fails. Booting again after `stop()` is fine, and {@link bootStackOnce} + * shares one boot among callers instead of starting a second. */ export async function bootStack( config: any, opts: BootOptions = {}, +): Promise { + const claim = claimConfiguration(config); + try { + return await bootClaimed(config, opts, claim); + } catch (e) { + claim.release(); + throw e; + } +} + +/** {@link bootStack}'s boot, under a claim the caller holds and releases on failure. */ +async function bootClaimed( + config: any, + opts: BootOptions, + claim: ConfigurationClaim, ): Promise { process.env.NODE_ENV = 'development'; @@ -705,6 +937,8 @@ export async function bootStack( const automationOpts = typeof opts.automation === 'object' ? opts.automation : {}; automationPlugin = new AutomationServicePlugin({ ...(automationOpts.suspendedRunStore ? { suspendedRunStore: automationOpts.suspendedRunStore } : {}), + // [#22301] The app's root, as `serve` hands it — see BootOptions.hostRoot. + packageRoot: hostRoot, }); await kernel.use(automationPlugin as any); } @@ -715,6 +949,28 @@ export async function bootStack( await kernel.use(plugin as any); } + // [#22301] The plugins in the app's own `plugins` array, as `objectstack + // serve` mounts them. Resolved HERE, before the `requires` providers, because + // `serve`'s "an explicit instance wins" rule counts them as held; registered + // below, after the harness's own services, in `serve`'s slot for them. An + // instance the caller handed this boot takes precedence by identity (see + // BootOptions.extraPlugins), and the instance rule holds the rest. + let appPlugins: AppPluginEntry[]; + try { + appPlugins = await resolveAppPlugins(config, { + hostRoot, + callerInstances: [ + ...(opts.extraPlugins ?? []), + ...(opts.security ? [opts.security] : []), + ...(opts.analytics ? [opts.analytics] : []), + ], + }); + claim.holdAppPlugins(appPlugins); + } catch (e) { + restoreTenancyPosture(); + 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, @@ -730,7 +986,9 @@ export async function bootStack( sharingPlugin, ...(automationPlugin ? [automationPlugin] : []), ...(opts.extraPlugins ?? []), + ...appPlugins.map((entry) => entry.plugin), ], + dependents: appPlugins.map((entry) => entry.plugin), isRegistered: (name) => kernel.hasPlugin(name), packageRoot: hostRoot, }); @@ -759,6 +1017,26 @@ export async function bootStack( // verifier would under-report authorization. await kernel.use(sharingPlugin); + // [#22301] The app's own `plugins` (resolved above), in `serve`'s slot for + // them: after the services the boot composes itself, so an entry that shares + // a `name` with one of THOSE supersedes it by the kernel's declared + // last-one-wins contract, as it does under `serve`; before the route + // surfaces. `serve` logs an entry it cannot register and boots on; a test + // boot that went on without a plugin the app declares would pass green on a + // composition production never runs, so here the boot stops, with the remedy. + for (const { plugin, label } of appPlugins) { + try { + await kernel.use(plugin as any); + } catch (e) { + restoreTenancyPosture(); + throw new Error( + `verify: the app's ${label} could not be registered: ${(e as Error).message}. bootStack mounts the ` + + "plugins of the app's own `plugins` array as `objectstack serve` does, and does not boot on without one. " + + 'Fix that entry, or hand bootStack an instance with the same `name` in `extraPlugins`, which takes its place.', + ); + } + } + // REST + dispatcher route surfaces (mount onto the http-server service). // Anonymous access to object data is denied unconditionally (#3963 retired the // `requireAuth` opt-out), so every dogfood proof — anonymous-deny, public-form @@ -987,6 +1265,8 @@ export async function bootStack( /* best-effort */ } restoreTenancyPosture(); + // [#22301] The kernel is gone, so the configuration may boot again. + claim.release(); }; // The in-process handle over the SAME kernel (hotcrm#1579 step 5a). Built diff --git a/packages/verify/src/required-providers.ts b/packages/verify/src/required-providers.ts index 8a847443b34..d58d50a4cbf 100644 --- a/packages/verify/src/required-providers.ts +++ b/packages/verify/src/required-providers.ts @@ -22,10 +22,11 @@ // `extras`. // · WHEN NOT: `providesCapability`'s exact identity match against what this // boot already holds — the harness's own settings / analytics / sharing -// services, `opts.automation`, and every `opts.extraPlugins` entry. A -// provider already held skips its whole token, extras included: `serve`'s -// "an explicit instance wins" rule, so a suite that passes its own -// instance keeps it. +// services, `opts.automation`, every `opts.extraPlugins` entry, and every +// plugin of the app's own `plugins` array. A provider already held skips +// its whole token, extras included: `serve`'s "an explicit instance wins" +// rule, so an app that wires a provider itself, or a suite that passes its +// own instance, keeps it. // · A token with no row — a tier token (`auth`, `ui`, `i18n`, `ai`) or a // known token no open package provides (`hierarchy-security`) — mounts // nothing here, as it mounts nothing in `serve`'s resolver. @@ -42,8 +43,9 @@ // 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 provider -// hard-depends on — found in the same table, among the tokens a served boot +// 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. @@ -97,7 +99,12 @@ async function constructProvider( * 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). + * (`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. @@ -105,6 +112,7 @@ async function constructProvider( export async function constructRequiredProviders(opts: { config: unknown; held: readonly unknown[]; + dependents?: readonly unknown[]; isRegistered: (name: string) => boolean; packageRoot: string; }): Promise { @@ -132,7 +140,7 @@ export async function constructRequiredProviders(opts: { for (;;) { const present = new Set(all().map(pluginName).filter((n): n is string => n !== undefined)); let next: string | undefined; - for (const plugin of [...named, ...dependencies]) { + 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) => { From 662e101f49bb11fcd469e2f2b0e17ec3ee372a7d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 23:38:09 +0000 Subject: [PATCH 03/14] wip(verify,dogfood): pins for the one composition rule; the showcase helper anchors hostRoot; the dogfood run declines the marketplace Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- packages/core/src/stack-plugins.test.ts | 72 ++++++++ packages/qa/dogfood/test/shared-showcase.ts | 18 +- packages/qa/dogfood/vitest.config.ts | 18 +- .../src/harness.one-composition.test.ts | 155 ++++++++++++++++++ 4 files changed, 260 insertions(+), 3 deletions(-) create mode 100644 packages/core/src/stack-plugins.test.ts create mode 100644 packages/verify/src/harness.one-composition.test.ts diff --git a/packages/core/src/stack-plugins.test.ts b/packages/core/src/stack-plugins.test.ts new file mode 100644 index 00000000000..256efea2f7f --- /dev/null +++ b/packages/core/src/stack-plugins.test.ts @@ -0,0 +1,72 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #22301 — the ONE rule for what an entry of a stack's own `plugins` array + * becomes, read by `os serve` and by `@objectstack/verify`'s `bootStack`. One + * case per shape of the rule; the loaders are injected, so each case also + * proves which loader the shape reaches and that the others are not touched. + */ + +import { describe, expect, it } from 'vitest'; +import { materializeStackPlugin, type StackPluginLoaders } from './stack-plugins.js'; + +/** Loaders that record what they were asked, answering fixed values. */ +function recordingLoaders(module: unknown): StackPluginLoaders & { imported: string[]; wrapped: unknown[] } { + const imported: string[] = []; + const wrapped: unknown[] = []; + return { + imported, + wrapped, + async importSpecifier(specifier) { + imported.push(specifier); + return module; + }, + wrapBundle(bundle) { + wrapped.push(bundle); + return { wrappedBundle: bundle }; + }, + }; +} + +describe('materializeStackPlugin — the shared rule for one `plugins` entry', () => { + it('an instance (it has `init`) is the plugin, as written — no loader runs', async () => { + const instance = { name: 'com.example.instance', init: async () => {} }; + const loaders = recordingLoaders(undefined); + expect(await materializeStackPlugin(instance, loaders)).toBe(instance); + expect(loaders.imported).toEqual([]); + expect(loaders.wrapped).toEqual([]); + }); + + it('a plain bundle (no `init`) is handed to the boot\'s wrap, and the wrap\'s answer is the plugin', async () => { + const bundle = { manifest: { id: 'com.example.bundle' }, objects: [] }; + const loaders = recordingLoaders(undefined); + expect(await materializeStackPlugin(bundle, loaders)).toEqual({ wrappedBundle: bundle }); + expect(loaders.wrapped).toEqual([bundle]); + expect(loaders.imported).toEqual([]); + }); + + it('a string is a specifier: loaded by the boot, and the module\'s default export is the plugin', async () => { + const plugin = { name: 'com.example.loaded', init: async () => {} }; + const loaders = recordingLoaders({ default: plugin }); + expect(await materializeStackPlugin('@example/plugin', loaders)).toBe(plugin); + expect(loaders.imported).toEqual(['@example/plugin']); + expect(loaders.wrapped).toEqual([]); + }); + + it('a loaded module with no default export is itself the plugin — and a bundle-shaped one is wrapped', async () => { + const module = { manifest: { id: 'com.example.module-bundle' } }; + const loaders = recordingLoaders(module); + expect(await materializeStackPlugin('@example/bundle', loaders)).toEqual({ wrappedBundle: module }); + expect(loaders.wrapped).toEqual([module]); + }); + + it('a loader that cannot load the specifier fails the call — the boot decides how loud', async () => { + const loaders: StackPluginLoaders = { + importSpecifier: async (specifier) => { + throw new Error(`cannot load ${specifier}`); + }, + wrapBundle: (bundle) => bundle, + }; + await expect(materializeStackPlugin('@example/missing', loaders)).rejects.toThrow('@example/missing'); + }); +}); diff --git a/packages/qa/dogfood/test/shared-showcase.ts b/packages/qa/dogfood/test/shared-showcase.ts index b761722edc7..320bd5a62b8 100644 --- a/packages/qa/dogfood/test/shared-showcase.ts +++ b/packages/qa/dogfood/test/shared-showcase.ts @@ -48,10 +48,23 @@ // Scope every list assertion to records the file itself created (unique // emails / name prefixes). // Anything else stays in the `isolated` project (plain vitest defaults). +import { fileURLToPath } from 'node:url'; import showcaseStack from '@objectstack/example-showcase'; import { bootStack, type VerifyStack } from '@objectstack/verify'; import { showcaseAppDefaultSecurity } from './showcase-security.js'; +/** + * [#22301] The showcase's own directory — the `hostRoot` its boot is anchored + * to. `bootStack` composes what `objectstack serve` composes (ruling A), so it + * mounts the showcase's `plugins` array, and with `requires: ['automation']` + * the automation service materializes the declarative connectors at start — + * one of which (`showcase_status_openapi`) reads a package-relative file. + * `serve` reads it from the directory holding `objectstack.config.ts`; this + * suite runs each file in a temporary working directory (#21914), so the boot + * names the app's directory instead of inheriting that one. + */ +const SHOWCASE_DIR = fileURLToPath(new URL('../../../../examples/app-showcase/', import.meta.url)); + /** * [#5491] The two scaffolding grants these shared fixtures add on top of the * app's declared default — named objects, never a wildcard. @@ -81,7 +94,10 @@ let booted: Promise | undefined; /** Boot (once per worker) and return the shared plain-showcase stack. */ export function getSharedShowcase(): Promise { - booted ??= bootStack(showcaseStack, { security: showcaseAppDefaultSecurity(SHARED_FIXTURE_GRANTS) }).then(async (stack) => { + booted ??= bootStack(showcaseStack, { + security: showcaseAppDefaultSecurity(SHARED_FIXTURE_GRANTS), + hostRoot: SHOWCASE_DIR, + }).then(async (stack) => { // First sign-in provisions the dev admin; later files' own signIn() calls // are idempotent (~0.16s) and just mint fresh admin tokens. await stack.signIn(); diff --git a/packages/qa/dogfood/vitest.config.ts b/packages/qa/dogfood/vitest.config.ts index e2662b3a95c..825b6552165 100644 --- a/packages/qa/dogfood/vitest.config.ts +++ b/packages/qa/dogfood/vitest.config.ts @@ -38,6 +38,20 @@ // that behaves differently under a test runner would make every log reading // in tests a reading of something other than production. The request lives // HERE, in the harness, where the test author can see it. +// +// ── #22301: this suite declines the marketplace, DECLARATIVELY ────────────── +// +// `bootStack` composes what `objectstack serve` composes (ruling A), so a +// showcase boot mounts the showcase's own `plugins` array — and that array +// wires the marketplace-facing `@objectstack/cloud-connection` plugins at the +// URL `resolveCloudUrl()` reads from `OS_CLOUD_URL` when the configuration +// module is imported, which defaults to the PUBLIC catalog. `OS_CLOUD_URL=off` +// is the configuration's own switch for a fully-offline run (it then wires no +// marketplace plugin at all), the one `@objectstack/verify`'s `bootStack` +// documents for offline CI. Measured: CI sets no `OS_CLOUD_URL`, and turbo's +// strict env mode would not hand one to `@objectstack/dogfood#test` anyway +// (its task `env` declares only `OS_TEST_TIERS` / `OS_TEST_SHARD`), so the +// run declares it here, per project, beside `OS_REGISTRY_LOG`. import { defineConfig } from 'vitest/config'; import path from 'path'; import { parseCLI, type TestUserConfig } from 'vitest/node'; @@ -167,7 +181,7 @@ export default defineConfig({ // engine's own `OS_REGISTRY_LOG` seam, not a change to its shipped // default. Header docblock carries the measurement and the rationale. // PER PROJECT for the same measured reason as the line above. - env: { OS_REGISTRY_LOG: 'warn' }, + env: { OS_REGISTRY_LOG: 'warn', OS_CLOUD_URL: 'off' }, name: 'shared-showcase', include: SHARED_SHOWCASE, isolate: false, @@ -337,7 +351,7 @@ export default defineConfig({ // engine's own `OS_REGISTRY_LOG` seam, not a change to its shipped // default. Header docblock carries the measurement and the rationale. // PER PROJECT for the same measured reason as the line above. - env: { OS_REGISTRY_LOG: 'warn' }, + env: { OS_REGISTRY_LOG: 'warn', OS_CLOUD_URL: 'off' }, name: 'isolated', include: ['test/**/*.test.ts'], exclude: SHARED_SHOWCASE, diff --git a/packages/verify/src/harness.one-composition.test.ts b/packages/verify/src/harness.one-composition.test.ts new file mode 100644 index 00000000000..a0ae939217a --- /dev/null +++ b/packages/verify/src/harness.one-composition.test.ts @@ -0,0 +1,155 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #22301 item 1, ruling A — ONE composition rule: for one configuration, + * `bootStack` composes what `objectstack serve` composes. The `requires` half + * is pinned in `harness.required-providers.test.ts`; this file pins the rest: + * + * - the plugins in the app's own `plugins` array are mounted (`serve`'s rule + * for an entry, `materializeStackPlugin` in `@objectstack/core`); + * - a caller's `extraPlugins` instance takes precedence over an app plugin of + * the same `name` — the app's instance never runs; + * - an entry that cannot be loaded fails the boot, naming the entry; + * - the instance rule: a second live boot of one configuration is refused + * with `RESOURCE_CONFLICT` / 409 — and so is a copy that carries a mounted + * app-plugin instance — while a boot after `stop()` succeeds. + * + * Measured before the change (`origin/main` 6a53564b9): `bootStack` never read + * `config.plugins` — an app's own plugin was simply absent from the kernel — + * and two concurrent boots of one configuration both succeeded. + */ + +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, afterEach, describe, expect, it } from 'vitest'; +import { defineStack } from '@objectstack/spec'; +import { bootStack, type VerifyStack } from './harness.js'; + +const BOOT_TIMEOUT = 120_000; +const PROBE = 'com.example.one-composition.probe'; +const PROBE_SERVICE = 'one-composition.probe'; + +/** A plugin that records each init and registers which instance it is. */ +class ProbePlugin { + readonly name = PROBE; + readonly version = '1.0.0'; + readonly type = 'standard'; + inits = 0; + constructor(readonly tag: string) {} + async init(ctx: { registerService(name: string, service: unknown): void }): Promise { + this.inits += 1; + ctx.registerService(PROBE_SERVICE, this.tag); + } +} + +const manifest = { id: 'com.example.one-composition', name: 'One Composition', namespace: 'ocp', version: '1.0.0', type: 'app' }; +const note = { + name: 'ocp_note', label: 'Note', pluralLabel: 'Notes', sharingModel: 'public_read_write', + fields: { name: { name: 'name', type: 'text', label: 'Name', required: true } }, +}; + +/** A fresh configuration object — the instance rule keys on identity. */ +const app = (extra: Record = {}): Record => + defineStack({ manifest, objects: [note], ...extra } as never) as unknown as Record; + +const live: VerifyStack[] = []; +const keep = (stack: VerifyStack): VerifyStack => { + live.push(stack); + return stack; +}; +afterEach(async () => { + for (const stack of live.splice(0)) await stack.stop(); +}); + +const probeTag = (stack: VerifyStack): unknown => stack.kernel.getService(PROBE_SERVICE); + +describe("bootStack mounts the app's own `plugins`, as serve does", () => { + it("an app's own plugin instance is mounted and runs", async () => { + const own = new ProbePlugin('app'); + const config = app({ plugins: [own] }); + // Anti-vacuity: the producer kept the instance, by reference. + expect((config.plugins as unknown[])[0]).toBe(own); + const stack = keep(await bootStack(config)); + expect(stack.kernel.hasPlugin(PROBE)).toBe(true); + expect(probeTag(stack)).toBe('app'); + expect(own.inits).toBe(1); + }, BOOT_TIMEOUT); + + it('control — an app with no `plugins` boots without the probe', async () => { + const stack = keep(await bootStack(app())); + expect(stack.kernel.hasPlugin(PROBE)).toBe(false); + }, BOOT_TIMEOUT); + + it("extraPlugins takes precedence by identity: the caller's instance runs, the app's never does", async () => { + const own = new ProbePlugin('app'); + const callers = new ProbePlugin('caller'); + const stack = keep(await bootStack(app({ plugins: [own] }), { extraPlugins: [callers] })); + expect(probeTag(stack)).toBe('caller'); + expect(callers.inits).toBe(1); + expect(own.inits).toBe(0); + }, BOOT_TIMEOUT); + + describe('an entry that cannot be loaded fails the boot, naming it', () => { + const hostRoot = mkdtempSync(join(tmpdir(), 'verify-one-composition-')); + writeFileSync(join(hostRoot, 'package.json'), JSON.stringify({ name: 'host', version: '0.0.0' })); + afterAll(() => rmSync(hostRoot, { recursive: true, force: true })); + + it('a package the app root cannot resolve', async () => { + const missing = '@fixture/one-composition-never-installed'; + const failure = await bootStack(app({ plugins: [missing] }), { hostRoot }).then( + (stack) => { + keep(stack); + return undefined; + }, + (e: unknown) => e as Error, + ); + expect(failure, 'the boot went on without the plugin the app declares').toBeInstanceOf(Error); + expect(failure!.message).toContain(`plugins[0] ('${missing}')`); + expect(failure!.message).toContain(hostRoot); + }, BOOT_TIMEOUT); + }); +}); + +describe('the instance rule: one live kernel per configuration, per process', () => { + const refusal = { code: 'RESOURCE_CONFLICT', status: 409 }; + + it('a second live boot of the same configuration is refused; the first keeps working', async () => { + const config = app({ plugins: [new ProbePlugin('app')] }); + const first = keep(await bootStack(config)); + await expect(bootStack(config)).rejects.toMatchObject(refusal); + expect(probeTag(first)).toBe('app'); + }, BOOT_TIMEOUT); + + it('two boots started together: exactly one is refused', async () => { + const config = app(); + const settled = await Promise.allSettled([bootStack(config), bootStack(config)]); + for (const s of settled) if (s.status === 'fulfilled') keep(s.value); + expect(settled.map((s) => s.status).sort()).toEqual(['fulfilled', 'rejected']); + const rejected = settled.find((s): s is PromiseRejectedResult => s.status === 'rejected'); + expect(rejected?.reason).toMatchObject(refusal); + }, BOOT_TIMEOUT); + + it('a copy that carries a mounted app-plugin instance is refused too', async () => { + const config = app({ plugins: [new ProbePlugin('app')] }); + keep(await bootStack(config)); + await expect(bootStack({ ...config })).rejects.toMatchObject(refusal); + }, BOOT_TIMEOUT); + + it('a sequential re-boot works: boot, stop, boot', async () => { + const own = new ProbePlugin('app'); + const config = app({ plugins: [own] }); + const first = await bootStack(config); + await first.stop(); + const second = keep(await bootStack(config)); + expect(probeTag(second)).toBe('app'); + expect(own.inits).toBe(2); + }, BOOT_TIMEOUT); + + it('a refused or failed boot does not hold the configuration', async () => { + const config = app({ plugins: ['@fixture/one-composition-never-installed'] }); + await expect(bootStack(config)).rejects.toThrow(); + // The failed boot released its claim: the same object is not refused as live. + await expect(bootStack(config)).rejects.not.toMatchObject(refusal); + }, BOOT_TIMEOUT); +}); From 09dcac304029a3d79bcb433b144ad369f069569d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 00:00:56 +0000 Subject: [PATCH 04/14] wip: changeset for the one composition rule Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- .../22301-verify-boots-what-serve-boots.md | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .changeset/22301-verify-boots-what-serve-boots.md diff --git a/.changeset/22301-verify-boots-what-serve-boots.md b/.changeset/22301-verify-boots-what-serve-boots.md new file mode 100644 index 00000000000..f3c602d1be0 --- /dev/null +++ b/.changeset/22301-verify-boots-what-serve-boots.md @@ -0,0 +1,31 @@ +--- +'@objectstack/core': minor +'@objectstack/verify': minor +'@objectstack/cli': patch +--- + +`bootStack` composes what `objectstack serve` composes from the same configuration: the providers the app's `requires` names, and the plugins in the app's own `plugins` array + +Clause-②: yes (narrowing) + + + +**BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes. + +**`@objectstack/verify` — one composition rule.** For one configuration, `bootStack(config, opts)` now mounts what `objectstack serve` mounts from it, so an app's tests boot the composition its users get: + +- **The providers the app's `requires` names**, by `serve`'s own reader and table (top-level `requires`, otherwise each package body's), plus the always-on providers a mounted plugin hard-depends on. An app no longer lists them in `extraPlugins` by hand. +- **The plugins in the app's own `plugins` array**, by `serve`'s rule for an entry: an instance is mounted as written, a plain bundle is wrapped into `AppPlugin`, a package name is loaded from the app's root (`hostRoot`). +- **The caller wins by identity.** An `extraPlugins` instance takes precedence over a `requires` provider it is (exact `name` or class name) and over an app plugin with the same `name`; that app plugin is not mounted. `security` and `analytics` instances take precedence over an app plugin of the same `name` the same way. +- **`hostRoot` is the app's root** in the two places `serve` uses the config's directory: the automation service's `packageRoot` (where a declarative connector's package-relative file ref is read) — now also when `automation: true` asks for the service — and the root a string `plugins` entry is resolved from. A suite that does not run from the app's directory passes it. +- **Offline CI.** An app whose `plugins` array wires the marketplace-facing `@objectstack/cloud-connection` plugins gets them mounted, pointed at `OS_CLOUD_URL` (by default the public catalog). Set `OS_CLOUD_URL=off` in the test environment, before the configuration module is imported, to keep the suite offline. + +**What now fails that booted before (the narrowing).** + +- **A second live boot of the same configuration object is refused** with `code: 'RESOURCE_CONFLICT'`, `status: 409`, and so is a copy (`{ ...config }`) that carries an app-plugin instance a live boot mounted: the instances in a `plugins` array are module-level, and two kernels must not share them. Live means until `stop()` resolves. Remedy: `stop()` the first stack before booting again, or share one boot with `bootStackOnce(config, opts)`. +- **An app `plugins` entry that cannot be loaded or registered fails the boot**, naming the entry (`plugins[i]`) and its remedy. `serve` logs such an entry and boots on; a test boot does not, so a plugin the app declares is never silently absent from its tests. +- **A provider or app plugin that refuses to start fails the boot** where the fixed plugin set never mounted it — for example a declarative connector whose package-relative file ref does not resolve from `hostRoot`. + +**`@objectstack/core`** exports the pieces both boots read: `CAPABILITY_PROVIDERS`, `CapabilitySpec`, `CapabilityIdentities` and `providesCapability` (the `requires` token → provider table and its exact identity match); `stackDeclaredCapabilities`, `resolveStackCollection`, `declaredPackageEntries`, `stackPackageBodies` and `collectFromPackageBodies` (the package-owned collection reader); and `materializeStackPlugin` with `StackPluginLoaders` (what a `plugins` entry becomes). + +**`@objectstack/cli`**: no behaviour change. `Serve.CAPABILITY_PROVIDERS` and `Serve.providesCapability` are handles over the `@objectstack/core` declarations, the stack-collection readers are re-exported from there, and `serve`'s `plugins` loop reads the entry rule from there. From c9a2c6123f399d8d324cc95cdbce644ef903c73f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 00:12:52 +0000 Subject: [PATCH 05/14] wip(cli): keep the boot loop's helper call spelled as its source pins read it Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- packages/cli/src/commands/serve.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 2a22d6a7f1d..2baafe50148 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -4230,7 +4230,11 @@ export default class Serve extends Command { // Host-anchored, NOT a bare `import()`: this specifier comes from // the served app's own config, so what the app DECLARES about it is // the contract (commit 9cc6777d3). The helper carries the failure wrapper too. - importSpecifier: (specifier) => Serve.importConfigPlugin(specifier, hostRoot), + // (Spelled as the boot loop's own call: two source pins — + // `serve-config-plugin-host-resolution.test.ts` and + // `serve-config-plugin-relative-refusal.test.ts` — hold this + // loop to routing every string entry through the helper.) + importSpecifier: async (plugin) => await Serve.importConfigPlugin(plugin, hostRoot), wrapBundle: async (bundle) => { try { const { AppPlugin } = await import('@objectstack/runtime'); From 8063846be04e5859bcb29902d2b01da9e686a1af Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 00:59:28 +0000 Subject: [PATCH 06/14] test(dogfood,verify): one owner for the showcase's test root; consumers of the instance rule boot a configuration built again Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- .../22301-verify-boots-what-serve-boots.md | 2 +- ...auth-tokens-not-serialized.dogfood.test.ts | 6 +- .../action-params-contract.dogfood.test.ts | 6 +- .../activity-withheld-update.dogfood.test.ts | 6 +- ...admin-credential-lifecycle.dogfood.test.ts | 6 +- ...admin-identity-audit-trail.dogfood.test.ts | 6 +- ...in-platform-admin-standing.dogfood.test.ts | 6 +- ...min-route-nonadmin-refusal.dogfood.test.ts | 6 +- ...pi-key-hash-not-serialized.dogfood.test.ts | 6 +- .../test/api-key-owner-revoke.dogfood.test.ts | 6 +- .../api-key-revoke-lifecycle.dogfood.test.ts | 6 +- .../qa/dogfood/test/armed.dogfood.test.ts | 18 ++++-- .../audit-log-admin-search.dogfood.test.ts | 6 +- .../audit-log-internal-fields.dogfood.test.ts | 6 +- .../auth-session-audit-trail.dogfood.test.ts | 6 +- ...on-authoring-doors-durable.dogfood.test.ts | 6 +- ...earer-lane-password-change.dogfood.test.ts | 6 +- ...r-delete-federated-fixture.dogfood.test.ts | 5 +- ...shboard-designer-roundtrip.dogfood.test.ts | 6 +- ...ta-door-reaches-admin-door.dogfood.test.ts | 5 +- ...tasource-restore-code-wins.dogfood.test.ts | 5 +- .../delegated-admin-invite.dogfood.test.ts | 6 +- .../test/delegation-of-duty.dogfood.test.ts | 6 +- .../discovery-auth-families.dogfood.test.ts | 8 +-- ...late-overlay-survives-boot.dogfood.test.ts | 6 +- ...-code-datasource-namespace.dogfood.test.ts | 5 +- ...-import-destructive-remedy.dogfood.test.ts | 5 +- ...nal-import-saves-like-meta.dogfood.test.ts | 5 +- ...validate-sees-runtime-save.dogfood.test.ts | 5 +- ...al-validate-start-ordering.dogfood.test.ts | 5 +- ...ederated-anchor-provenance.dogfood.test.ts | 5 +- ...erated-phantom-share-grant.dogfood.test.ts | 5 +- .../federated-rls-injectors.dogfood.test.ts | 5 +- ...ederated-sweep-projections.dogfood.test.ts | 5 +- .../test/field-zoo-roundtrip.dogfood.test.ts | 6 +- .../test/fixtures/attachments-fixture.ts | 27 +++++++- .../dogfood/test/fixtures/comments-fixture.ts | 27 +++++++- ...low-provenance-server-held.dogfood.test.ts | 6 +- ...-shipped-name-by-name-read.dogfood.test.ts | 6 +- ...-shipped-name-layered-read.dogfood.test.ts | 6 +- ...hipped-name-published-door.dogfood.test.ts | 6 +- ...ipped-name-stored-row-boot.dogfood.test.ts | 6 +- ...nshipped-name-layered-code.dogfood.test.ts | 6 +- ...tity-admin-fields-org-peer.dogfood.test.ts | 6 +- ...l-local-listing-not-loaded.dogfood.test.ts | 6 +- ...-local-listing-sample-data.dogfood.test.ts | 6 +- ...cal-no-active-organization.dogfood.test.ts | 6 +- ...ll-local-purge-sample-data.dogfood.test.ts | 8 +-- ...cal-reseed-intact-baseline.dogfood.test.ts | 8 +-- ...cal-sample-data-not-loaded.dogfood.test.ts | 6 +- ...nvitation-ledger-row-scope.dogfood.test.ts | 6 +- ...apps-and-everyone-baseline.dogfood.test.ts | 6 +- ...mbership-actor-attribution.dogfood.test.ts | 6 +- ...ership-decided-at-creation.dogfood.test.ts | 6 +- ...rship-ended-session-revoke.dogfood.test.ts | 6 +- .../membership-reconciler.dogfood.test.ts | 6 +- ...membership-role-vocabulary.dogfood.test.ts | 6 +- .../meta-door-code-datasource.dogfood.test.ts | 5 +- ...published-and-state-routes.dogfood.test.ts | 5 +- .../meta-types-create-seed.dogfood.test.ts | 6 +- ...organization-write-refusal.dogfood.test.ts | 6 +- ...ect-designer-field-reorder.dogfood.test.ts | 6 +- ...dc-authorization-code-flow.dogfood.test.ts | 6 +- .../oidc-authorize-env-gate.dogfood.test.ts | 6 +- ...org-admin-affordance-reach.dogfood.test.ts | 6 +- .../org-create-default-team.dogfood.test.ts | 6 +- ...coped-sharing-rule-listing.dogfood.test.ts | 6 +- ...n-delete-federated-fixture.dogfood.test.ts | 5 +- .../organization-update-door.dogfood.test.ts | 6 +- ...ner-anchor-and-bulk-writes.dogfood.test.ts | 6 +- ...ed-activation-ledger-reach.dogfood.test.ts | 8 +-- ...ged-flow-write-door-parity.dogfood.test.ts | 6 +- ...-write-refusal-not-visible.dogfood.test.ts | 47 ++++++++------ ...sion-set-assignment-window.dogfood.test.ts | 6 +- ...clone-boot-unowned-warning.dogfood.test.ts | 8 +-- ...iscard-overlay-eligibility.dogfood.test.ts | 8 +-- ...on-set-lock-row-provenance.dogfood.test.ts | 8 +-- ...te-through-package-binding.dogfood.test.ts | 6 +- ...ite-unreadable-not-matched.dogfood.test.ts | 45 +++++++++----- .../primary-bu-projection.dogfood.test.ts | 6 +- ...e-ledger-live-mount-parity.dogfood.test.ts | 6 +- ...e-sweep-organization-scope.dogfood.test.ts | 21 ++++++- .../security-catalog-showcase.dogfood.test.ts | 5 +- .../test/semantic-roles.dogfood.test.ts | 8 +-- ...ssion-token-not-serialized.dogfood.test.ts | 6 +- ...ttings-config-change-audit.dogfood.test.ts | 6 +- .../share-links-self-list.dogfood.test.ts | 6 +- packages/qa/dogfood/test/shared-showcase.ts | 24 ++----- ...ing-rule-criteria-required.dogfood.test.ts | 6 +- ...aring-rule-org-less-caller.dogfood.test.ts | 6 +- ...se-anonymous-deny-surfaces.dogfood.test.ts | 34 ++++++---- packages/qa/dogfood/test/showcase-boot.ts | 42 +++++++++++++ ...wcase-bu-hierarchy-sharing.dogfood.test.ts | 6 +- ...se-client-liaison-fixtures.dogfood.test.ts | 6 +- ...owcase-crud-persona-matrix.dogfood.test.ts | 6 +- ...howcase-d3-d4-capabilities.dogfood.test.ts | 6 +- ...howcase-d7-default-profile.dogfood.test.ts | 5 +- ...case-declarative-endpoints.dogfood.test.ts | 24 +++++-- .../showcase-declarative-mcp.dogfood.test.ts | 6 +- .../showcase-default-profile.dogfood.test.ts | 6 +- ...se-demo-personas-loginable.dogfood.test.ts | 8 ++- ...e-demo-personas-membership.dogfood.test.ts | 5 +- .../showcase-expand-crud-gate.dogfood.test.ts | 6 +- ...wcase-external-autoconnect.dogfood.test.ts | 7 ++- ...owcase-fls-read-mask-strip.dogfood.test.ts | 6 +- .../test/showcase-invoice-cbp.dogfood.test.ts | 6 +- ...ase-invoice-seed-isolation.dogfood.test.ts | 6 +- ...showcase-mcp-http-identity.dogfood.test.ts | 6 +- ...owcase-mcp-self-connection.dogfood.test.ts | 6 +- ...object-extension-meta-read.dogfood.test.ts | 5 +- ...xtension-scalar-divergence.dogfood.test.ts | 5 +- ...case-permission-projection.dogfood.test.ts | 6 +- ...howcase-permission-seeding.dogfood.test.ts | 6 +- ...wcase-public-form-redirect.dogfood.test.ts | 6 +- ...-public-form-walled-intake.dogfood.test.ts | 6 +- ...lic-form-withdrawal-layers.dogfood.test.ts | 6 +- ...ase-public-form-withdrawal.dogfood.test.ts | 6 +- .../test/showcase-public-form.dogfood.test.ts | 6 +- ...wcase-scope-depth-fallback.dogfood.test.ts | 6 +- ...showcase-scope-depth-write.dogfood.test.ts | 6 +- .../test/showcase-scope-depth.dogfood.test.ts | 6 +- ...gle-tenant-identity-create.dogfood.test.ts | 6 +- .../test/storage-growth.dogfood.test.ts | 6 +- .../test/temporal-storage-e2e.dogfood.test.ts | 6 +- ...-factor-backup-code-reveal.dogfood.test.ts | 6 +- .../test/two-factor-lockout.dogfood.test.ts | 6 +- ...iner-cross-package-default.dogfood.test.ts | 6 +- ...or-unreadable-is-not-found.dogfood.test.ts | 43 ++++++++----- packages/verify/src/handle.test.ts | 27 ++++++-- .../src/harness.app-default-profile.test.ts | 62 ++++++++++++------- .../src/harness.one-composition.test.ts | 9 +++ packages/verify/src/harness.ts | 24 ++++++- 132 files changed, 703 insertions(+), 468 deletions(-) create mode 100644 packages/qa/dogfood/test/showcase-boot.ts diff --git a/.changeset/22301-verify-boots-what-serve-boots.md b/.changeset/22301-verify-boots-what-serve-boots.md index f3c602d1be0..ff391f38d17 100644 --- a/.changeset/22301-verify-boots-what-serve-boots.md +++ b/.changeset/22301-verify-boots-what-serve-boots.md @@ -22,7 +22,7 @@ Clause-②: yes (narrowing) **What now fails that booted before (the narrowing).** -- **A second live boot of the same configuration object is refused** with `code: 'RESOURCE_CONFLICT'`, `status: 409`, and so is a copy (`{ ...config }`) that carries an app-plugin instance a live boot mounted: the instances in a `plugins` array are module-level, and two kernels must not share them. Live means until `stop()` resolves. Remedy: `stop()` the first stack before booting again, or share one boot with `bootStackOnce(config, opts)`. +- **A second live boot of the same configuration object is refused** with `code: 'RESOURCE_CONFLICT'`, `status: 409`, and so is a copy (`{ ...config }`) that carries an app-plugin instance a live boot mounted: the instances in a `plugins` array are module-level, and two kernels must not share them. Live means until `stop()` resolves. Remedy: `stop()` the first stack before booting again; or share one boot with `bootStackOnce(config, opts)`; or, to keep two stacks of one app live at once, boot the second on a configuration built again (call its builder once more, or import a fresh module instance of it). A `{ ...config }` spread is not a configuration of its own: a live boot keeps references into the configuration's nested definitions. - **An app `plugins` entry that cannot be loaded or registered fails the boot**, naming the entry (`plugins[i]`) and its remedy. `serve` logs such an entry and boots on; a test boot does not, so a plugin the app declares is never silently absent from its tests. - **A provider or app plugin that refuses to start fails the boot** where the fixed plugin set never mounted it — for example a declarative connector whose package-relative file ref does not resolve from `hostRoot`. diff --git a/packages/qa/dogfood/test/account-oauth-tokens-not-serialized.dogfood.test.ts b/packages/qa/dogfood/test/account-oauth-tokens-not-serialized.dogfood.test.ts index 724126ff5b9..d8a77feaf5f 100644 --- a/packages/qa/dogfood/test/account-oauth-tokens-not-serialized.dogfood.test.ts +++ b/packages/qa/dogfood/test/account-oauth-tokens-not-serialized.dogfood.test.ts @@ -71,8 +71,8 @@ */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { assertArmed, armedWhen } from './armed.js'; const MEMBER_EMAIL = 'account-oauth-member@verify.test'; @@ -129,7 +129,7 @@ describe('#7987: sys_account OAuth tokens never serialize on the generic data pa }; beforeAll(async () => { - stack = await bootStack(showcaseStack, {}); + stack = await bootShowcase({}); ql = await stack.kernel.getServiceAsync('objectql'); adminToken = await stack.signIn(); memberToken = await stack.signUp(MEMBER_EMAIL, MEMBER_PASSWORD); diff --git a/packages/qa/dogfood/test/action-params-contract.dogfood.test.ts b/packages/qa/dogfood/test/action-params-contract.dogfood.test.ts index bc0188669a5..4b373a7eb0d 100644 --- a/packages/qa/dogfood/test/action-params-contract.dogfood.test.ts +++ b/packages/qa/dogfood/test/action-params-contract.dogfood.test.ts @@ -20,8 +20,8 @@ // unnoticed unless a test drives it. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; const ACTION_PATH = '/actions/showcase_field_zoo/showcase_action_param_gallery'; @@ -30,7 +30,7 @@ describe('dogfood: action param contract enforced at dispatch (ADR-0104 D2)', () let token: string; beforeAll(async () => { - stack = await bootStack(showcaseStack); + stack = await bootShowcase(); token = await stack.signIn(); }, 60_000); diff --git a/packages/qa/dogfood/test/activity-withheld-update.dogfood.test.ts b/packages/qa/dogfood/test/activity-withheld-update.dogfood.test.ts index b5e8b03d585..f439adf5590 100644 --- a/packages/qa/dogfood/test/activity-withheld-update.dogfood.test.ts +++ b/packages/qa/dogfood/test/activity-withheld-update.dogfood.test.ts @@ -26,8 +26,8 @@ // Fixtures are synthetic. ⚠️ No test title states a value. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { AuditPlugin } from '@objectstack/plugin-audit'; import { PermissionSetSchema } from '@objectstack/spec/security'; import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; @@ -84,7 +84,7 @@ describe('[#21388] an org peer is withheld the activity rows of a colleague’s }; beforeAll(async () => { - stack = await bootStack(showcaseStack as unknown as Parameters[0], { + stack = await bootShowcase({ security: new SecurityPlugin({ defaultPermissionSets: [...securityDefaultPermissionSets, activityReadSet] }), extraPlugins: [new AuditPlugin()], }); diff --git a/packages/qa/dogfood/test/admin-credential-lifecycle.dogfood.test.ts b/packages/qa/dogfood/test/admin-credential-lifecycle.dogfood.test.ts index 2686ba57ae3..a47576e5c2d 100644 --- a/packages/qa/dogfood/test/admin-credential-lifecycle.dogfood.test.ts +++ b/packages/qa/dogfood/test/admin-credential-lifecycle.dogfood.test.ts @@ -50,8 +50,8 @@ // @proof: admin-credential-lifecycle import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; const SYS = { isSystem: true }; @@ -105,7 +105,7 @@ describe('#9482 C1/C2: admin credential lifecycle, both sides', () => { // precede boot. Same derivation `admin-identity-audit-trail` uses. priorScim = process.env.OS_SCIM_ENABLED; process.env.OS_SCIM_ENABLED = 'true'; - stack = await bootStack(showcaseStack); + stack = await bootShowcase(); adminToken = await stack.signIn(); // the seeded dev admin (platform admin) memberToken = await stack.signUp('credlife.member@example.com', 'Member-Pass-123'); ql = await stack.kernel.getServiceAsync('objectql'); diff --git a/packages/qa/dogfood/test/admin-identity-audit-trail.dogfood.test.ts b/packages/qa/dogfood/test/admin-identity-audit-trail.dogfood.test.ts index 212939ff3bf..3064f4ec3d4 100644 --- a/packages/qa/dogfood/test/admin-identity-audit-trail.dogfood.test.ts +++ b/packages/qa/dogfood/test/admin-identity-audit-trail.dogfood.test.ts @@ -51,8 +51,8 @@ */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { AuditPlugin } from '@objectstack/plugin-audit'; const SYSTEM_CTX = { isSystem: true }; @@ -99,7 +99,7 @@ describe('#4940: what an admin identity operation leaves in sys_audit_log', () = beforeAll(async () => { priorScim = process.env.OS_SCIM_ENABLED; process.env.OS_SCIM_ENABLED = 'true'; - stack = await bootStack(showcaseStack, { extraPlugins: [new AuditPlugin()] }); + stack = await bootShowcase({ extraPlugins: [new AuditPlugin()] }); adminToken = await stack.signIn(); // the seeded dev admin (platform admin) ql = await stack.kernel.getServiceAsync('objectql'); const [admin] = await findRows(ql, 'sys_user', { email: 'admin@objectos.ai' }, 1); diff --git a/packages/qa/dogfood/test/admin-platform-admin-standing.dogfood.test.ts b/packages/qa/dogfood/test/admin-platform-admin-standing.dogfood.test.ts index 0c4384ea823..d6aada8a98c 100644 --- a/packages/qa/dogfood/test/admin-platform-admin-standing.dogfood.test.ts +++ b/packages/qa/dogfood/test/admin-platform-admin-standing.dogfood.test.ts @@ -113,8 +113,8 @@ // @proof: admin-platform-admin-standing import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; const SYS = { isSystem: true }; const AUTH_BASE = '/api/v1/auth'; @@ -304,7 +304,7 @@ describe('#9482: what an ObjectStack platform admin gets from every /admin/ rout // manager is constructed, so it must precede boot. priorScim = process.env.OS_SCIM_ENABLED; process.env.OS_SCIM_ENABLED = 'true'; - stack = await bootStack(showcaseStack); + stack = await bootShowcase(); adminToken = await stack.signIn(); // the seeded dev admin ql = await stack.kernel.getServiceAsync('objectql'); diff --git a/packages/qa/dogfood/test/admin-route-nonadmin-refusal.dogfood.test.ts b/packages/qa/dogfood/test/admin-route-nonadmin-refusal.dogfood.test.ts index 3307216482e..4b20a7bd814 100644 --- a/packages/qa/dogfood/test/admin-route-nonadmin-refusal.dogfood.test.ts +++ b/packages/qa/dogfood/test/admin-route-nonadmin-refusal.dogfood.test.ts @@ -174,8 +174,8 @@ // @proof: admin-route-nonadmin-refusal import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; const SYS = { isSystem: true }; const AUTH_BASE = '/api/v1/auth'; @@ -473,7 +473,7 @@ describe('#9482 C9: every derived /admin/ route refuses a non-admin', () => { // uses. Read when the auth manager is constructed, so it must precede boot. priorScim = process.env.OS_SCIM_ENABLED; process.env.OS_SCIM_ENABLED = 'true'; - stack = await bootStack(showcaseStack); + stack = await bootShowcase(); adminToken = await stack.signIn(); // seeded dev admin (platform admin) memberToken = await stack.signUp('refusal.probe.member@example.com', 'Member-Pass-123'); diff --git a/packages/qa/dogfood/test/api-key-hash-not-serialized.dogfood.test.ts b/packages/qa/dogfood/test/api-key-hash-not-serialized.dogfood.test.ts index e4c159ff4b4..014367f3fed 100644 --- a/packages/qa/dogfood/test/api-key-hash-not-serialized.dogfood.test.ts +++ b/packages/qa/dogfood/test/api-key-hash-not-serialized.dogfood.test.ts @@ -37,8 +37,8 @@ */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; describe('#7728: sys_api_key.key (hash) never serializes on the generic read path', () => { let stack: VerifyStack; @@ -70,7 +70,7 @@ describe('#7728: sys_api_key.key (hash) never serializes on the generic read pat }; beforeAll(async () => { - stack = await bootStack(showcaseStack, {}); + stack = await bootShowcase({}); token = await stack.signIn(); }, 120_000); diff --git a/packages/qa/dogfood/test/api-key-owner-revoke.dogfood.test.ts b/packages/qa/dogfood/test/api-key-owner-revoke.dogfood.test.ts index c82a1b93b1e..f4956023397 100644 --- a/packages/qa/dogfood/test/api-key-owner-revoke.dogfood.test.ts +++ b/packages/qa/dogfood/test/api-key-owner-revoke.dogfood.test.ts @@ -61,8 +61,8 @@ */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; describe('#8053: a member revokes their OWN sys_api_key', () => { let stack: VerifyStack; @@ -107,7 +107,7 @@ describe('#8053: a member revokes their OWN sys_api_key', () => { }; beforeAll(async () => { - stack = await bootStack(showcaseStack, {}); + stack = await bootShowcase({}); adminToken = await stack.signIn(); memberToken = await stack.signUp(MEMBER_EMAIL); }, 180_000); diff --git a/packages/qa/dogfood/test/api-key-revoke-lifecycle.dogfood.test.ts b/packages/qa/dogfood/test/api-key-revoke-lifecycle.dogfood.test.ts index bc24e5999b3..583eb289040 100644 --- a/packages/qa/dogfood/test/api-key-revoke-lifecycle.dogfood.test.ts +++ b/packages/qa/dogfood/test/api-key-revoke-lifecycle.dogfood.test.ts @@ -43,8 +43,8 @@ */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; describe('#7727: sys_api_key revoke/restore through the declared product route', () => { let stack: VerifyStack; @@ -73,7 +73,7 @@ describe('#7727: sys_api_key revoke/restore through the declared product route', }; beforeAll(async () => { - stack = await bootStack(showcaseStack, {}); + stack = await bootShowcase({}); token = await stack.signIn(); }, 120_000); diff --git a/packages/qa/dogfood/test/armed.dogfood.test.ts b/packages/qa/dogfood/test/armed.dogfood.test.ts index a1f733f07e2..e708393c9fc 100644 --- a/packages/qa/dogfood/test/armed.dogfood.test.ts +++ b/packages/qa/dogfood/test/armed.dogfood.test.ts @@ -37,9 +37,9 @@ // Boots two stacks, uses custom boot options and mutates auth config, so it // stays out of `SHARED_SHOWCASE` (see `vitest.config.ts`). -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { assertArmed, armedWhen, @@ -77,8 +77,16 @@ describe('[#8074] assertArmed: the guard against assertions that cannot fail', ( let insideMember: string; beforeAll(async () => { - outside = await bootStack(showcaseStack, {}); - inside = await bootStack(showcaseStack, { orgContext: true }); + outside = await bootShowcase({}); + // [#22301] The instance rule: one configuration supports one live kernel + // in a process, because the showcase's `plugins` array holds module-level + // plugin instances and two kernels must never mount the same ones. So the + // second stack, live beside the first, boots a FRESH MODULE INSTANCE of the + // showcase configuration — its plugin instances and every nested + // definition built again — and the two kernels share nothing. + vi.resetModules(); + const { default: freshShowcase } = await import('@objectstack/example-showcase'); + inside = await bootShowcase({ orgContext: true }, freshShowcase); // The first user is the seeded dev admin; a fresh sign-up is the plain // member #8023's fixture measures. diff --git a/packages/qa/dogfood/test/audit-log-admin-search.dogfood.test.ts b/packages/qa/dogfood/test/audit-log-admin-search.dogfood.test.ts index f46bbc60745..7125916796f 100644 --- a/packages/qa/dogfood/test/audit-log-admin-search.dogfood.test.ts +++ b/packages/qa/dogfood/test/audit-log-admin-search.dogfood.test.ts @@ -26,8 +26,8 @@ // change to it is a verdict on its last build. ⚠️ No test title states a value. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { AuditPlugin } from '@objectstack/plugin-audit'; import { ApprovalsServicePlugin } from '@objectstack/plugin-approvals'; import { assertArmed, armedWhen } from './armed.js'; @@ -56,7 +56,7 @@ describe('[#21154] the stock admin searches the ledger and the activity stream w }; beforeAll(async () => { - stack = await bootStack(showcaseStack as unknown as Parameters[0], { + stack = await bootShowcase({ extraPlugins: [new AuditPlugin(), new ApprovalsServicePlugin()], }); adminToken = await stack.signIn(); diff --git a/packages/qa/dogfood/test/audit-log-internal-fields.dogfood.test.ts b/packages/qa/dogfood/test/audit-log-internal-fields.dogfood.test.ts index e100d504d29..9eea7ca74d4 100644 --- a/packages/qa/dogfood/test/audit-log-internal-fields.dogfood.test.ts +++ b/packages/qa/dogfood/test/audit-log-internal-fields.dogfood.test.ts @@ -22,8 +22,8 @@ // change to it is a verdict on its last build. ⚠️ No test title states a value. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { AuditPlugin } from '@objectstack/plugin-audit'; import { showcaseAppDefaultSecurity } from './showcase-security.js'; @@ -46,7 +46,7 @@ describe('[#21197] ledger rows about internal-declared objects carry none of tho ql.find(LEDGER, { where: { object_name: object, record_id: recordId }, context: { ...SYS } }); beforeAll(async () => { - stack = await bootStack(showcaseStack, { + stack = await bootShowcase({ security: showcaseAppDefaultSecurity(), extraPlugins: [new AuditPlugin()], }); diff --git a/packages/qa/dogfood/test/auth-session-audit-trail.dogfood.test.ts b/packages/qa/dogfood/test/auth-session-audit-trail.dogfood.test.ts index f32c07961ee..3103aabfbb4 100644 --- a/packages/qa/dogfood/test/auth-session-audit-trail.dogfood.test.ts +++ b/packages/qa/dogfood/test/auth-session-audit-trail.dogfood.test.ts @@ -40,8 +40,8 @@ */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { AuditPlugin } from '@objectstack/plugin-audit'; const SYSTEM_CTX = { isSystem: true }; @@ -86,7 +86,7 @@ describe('#8144: auth session events reach sys_audit_log', () => { let memberOrgId: string; beforeAll(async () => { - stack = await bootStack(showcaseStack, { extraPlugins: [new AuditPlugin()], orgContext: true }); + stack = await bootShowcase({ extraPlugins: [new AuditPlugin()], orgContext: true }); ql = await stack.kernel.getServiceAsync('objectql'); adminToken = await stack.signIn(); diff --git a/packages/qa/dogfood/test/automation-authoring-doors-durable.dogfood.test.ts b/packages/qa/dogfood/test/automation-authoring-doors-durable.dogfood.test.ts index e7d2b82ce5c..a010a86c367 100644 --- a/packages/qa/dogfood/test/automation-authoring-doors-durable.dogfood.test.ts +++ b/packages/qa/dogfood/test/automation-authoring-doors-durable.dogfood.test.ts @@ -33,8 +33,8 @@ // - a packaged flow's name is still refused as a locked base (the control). import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { ConnectorRestPlugin } from '@objectstack/connector-rest'; import { ConnectorOpenApiPlugin } from '@objectstack/connector-openapi'; import { ConnectorMcpPlugin } from '@objectstack/connector-mcp'; @@ -98,7 +98,7 @@ const plugins = () => [ ]; async function boot(databaseFile: string): Promise { - return bootStack(showcaseStack, { automation: true, databaseFile, extraPlugins: plugins() }); + return bootShowcase({ automation: true, databaseFile, extraPlugins: plugins() }); } describe('the /automation definition doors persist what they register (showcase, cold boot)', () => { diff --git a/packages/qa/dogfood/test/bearer-lane-password-change.dogfood.test.ts b/packages/qa/dogfood/test/bearer-lane-password-change.dogfood.test.ts index 4f1871ff773..c9ede3ff6c4 100644 --- a/packages/qa/dogfood/test/bearer-lane-password-change.dogfood.test.ts +++ b/packages/qa/dogfood/test/bearer-lane-password-change.dogfood.test.ts @@ -77,8 +77,8 @@ */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { assertArmed, authSettingArmed } from './armed.js'; const SYS = { context: { isSystem: true } }; @@ -137,7 +137,7 @@ describe('#8049: /auth/change-password clears the force-change flag and enforces beforeAll(async () => { priorScim = process.env.OS_SCIM_ENABLED; process.env.OS_SCIM_ENABLED = 'true'; - stack = await bootStack(showcaseStack, {}); + stack = await bootShowcase({}); ql = await stack.kernel.getServiceAsync('objectql'); // Arm ADR-0069 D1's history ring. Default is 0 (off), under which every diff --git a/packages/qa/dogfood/test/business-unit-and-user-delete-federated-fixture.dogfood.test.ts b/packages/qa/dogfood/test/business-unit-and-user-delete-federated-fixture.dogfood.test.ts index 7f3c5021d61..a94f7c307da 100644 --- a/packages/qa/dogfood/test/business-unit-and-user-delete-federated-fixture.dogfood.test.ts +++ b/packages/qa/dogfood/test/business-unit-and-user-delete-federated-fixture.dogfood.test.ts @@ -62,7 +62,8 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import showcaseStack, { onEnable } from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { resolveInjectedColumnProvenance } from '@objectstack/metadata-core'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; @@ -100,7 +101,7 @@ describe('[#21918] business-unit and user deletes with the showcase federated fi // harness imports only the stack's default export, so `onEnable` never // runs on its own). await onEnable({ logger: { info() {}, warn() {} } } as never); - stack = await bootStack(showcaseStack, { + stack = await bootShowcase({ orgContext: true, databaseFile: join(dir, 'showcase.db'), }); diff --git a/packages/qa/dogfood/test/dashboard-designer-roundtrip.dogfood.test.ts b/packages/qa/dogfood/test/dashboard-designer-roundtrip.dogfood.test.ts index 22a09bebbbd..27abc51cf07 100644 --- a/packages/qa/dogfood/test/dashboard-designer-roundtrip.dogfood.test.ts +++ b/packages/qa/dogfood/test/dashboard-designer-roundtrip.dogfood.test.ts @@ -17,8 +17,8 @@ // dashboard surfaces it. This is the test that would have caught it before merge. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; // Designer-shaped: each widget binds a dataset + dimensions/values but carries // NO `layout` — exactly what the Studio designer writes. `columns` is set, as @@ -42,7 +42,7 @@ describe('dogfood: a Studio-designer-shaped (layout-less) dashboard saves + publ let token: string; beforeAll(async () => { - stack = await bootStack(showcaseStack); + stack = await bootShowcase(); token = await stack.signIn(); }, 90_000); diff --git a/packages/qa/dogfood/test/datasource-meta-door-reaches-admin-door.dogfood.test.ts b/packages/qa/dogfood/test/datasource-meta-door-reaches-admin-door.dogfood.test.ts index 039d3adc57d..2828230c38e 100644 --- a/packages/qa/dogfood/test/datasource-meta-door-reaches-admin-door.dogfood.test.ts +++ b/packages/qa/dogfood/test/datasource-meta-door-reaches-admin-door.dogfood.test.ts @@ -40,7 +40,8 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import showcaseStack, { onEnable } from '@objectstack/example-showcase'; import { registerDatasourceAdminRoutes } from '@objectstack/service-datasource'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -93,7 +94,7 @@ describe('[#21923] the metadata door and the admin door agree on a runtime datas }, }); const boot = async () => { - stack = await bootStack(showcaseStack, { databaseFile: join(dir, 'showcase.db'), extraPlugins: [routes()] }); + stack = await bootShowcase({ databaseFile: join(dir, 'showcase.db'), extraPlugins: [routes()] }); token = await stack.signIn(); }; const restart = async () => { diff --git a/packages/qa/dogfood/test/datasource-restore-code-wins.dogfood.test.ts b/packages/qa/dogfood/test/datasource-restore-code-wins.dogfood.test.ts index 5aec6f19865..2cbb9952651 100644 --- a/packages/qa/dogfood/test/datasource-restore-code-wins.dogfood.test.ts +++ b/packages/qa/dogfood/test/datasource-restore-code-wins.dogfood.test.ts @@ -50,7 +50,8 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import showcaseStack, { onEnable } from '@objectstack/example-showcase'; import { registerDatasourceAdminRoutes } from '@objectstack/service-datasource'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -107,7 +108,7 @@ describe('[#21922 / #21944] a stored datasource row never displaces a code datas }, }); const boot = async () => { - stack = await bootStack(showcaseStack, { databaseFile: join(dir, 'showcase.db'), extraPlugins: [routes()] }); + stack = await bootShowcase({ databaseFile: join(dir, 'showcase.db'), extraPlugins: [routes()] }); token = await stack.signIn(); }; const restart = async () => { diff --git a/packages/qa/dogfood/test/delegated-admin-invite.dogfood.test.ts b/packages/qa/dogfood/test/delegated-admin-invite.dogfood.test.ts index 99192c21afd..8d758c4c730 100644 --- a/packages/qa/dogfood/test/delegated-admin-invite.dogfood.test.ts +++ b/packages/qa/dogfood/test/delegated-admin-invite.dogfood.test.ts @@ -32,8 +32,8 @@ */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; const SYSTEM_CTX = { isSystem: true }; @@ -62,7 +62,7 @@ describe('#3697: delegated_admin may invite — as `member` only', () => { let plainMemberToken: string; beforeAll(async () => { - stack = await bootStack(showcaseStack, {}); + stack = await bootShowcase({}); adminToken = await stack.signIn(); // the seeded dev admin ql = await stack.kernel.getServiceAsync('objectql'); diff --git a/packages/qa/dogfood/test/delegation-of-duty.dogfood.test.ts b/packages/qa/dogfood/test/delegation-of-duty.dogfood.test.ts index 9f09d82c436..22ad78f88a5 100644 --- a/packages/qa/dogfood/test/delegation-of-duty.dogfood.test.ts +++ b/packages/qa/dogfood/test/delegation-of-duty.dogfood.test.ts @@ -18,8 +18,8 @@ // @proof: delegation-of-duty import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; import { PermissionSetSchema } from '@objectstack/spec/security'; import { resolveAuthzContext } from '@objectstack/core'; @@ -59,7 +59,7 @@ describe('delegation of duty (ADR-0091 D3) — end to end', () => { const sessionFor = (userId: string) => async () => ({ user: { id: userId }, session: { activeOrganizationId: orgId } }); beforeAll(async () => { - stack = await bootStack(showcaseStack, { + stack = await bootShowcase({ security: new SecurityPlugin({ defaultPermissionSets: [...securityDefaultPermissionSets, delegMember], fallbackPermissionSet: 'deleg_member', diff --git a/packages/qa/dogfood/test/discovery-auth-families.dogfood.test.ts b/packages/qa/dogfood/test/discovery-auth-families.dogfood.test.ts index 5ce88fa86a9..530ba66b3de 100644 --- a/packages/qa/dogfood/test/discovery-auth-families.dogfood.test.ts +++ b/packages/qa/dogfood/test/discovery-auth-families.dogfood.test.ts @@ -38,8 +38,8 @@ // under a running stack. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; /** One deployment's three readings of "is the admin family mounted", plus the wire. */ interface Readings { @@ -91,7 +91,7 @@ describe('discovery reports the better-auth admin family — stock composition ( beforeAll(async () => { priorScim = process.env.OS_SCIM_ENABLED; delete process.env.OS_SCIM_ENABLED; - stack = await bootStack(showcaseStack); + stack = await bootShowcase(); readings = await measure(stack); }, 300_000); @@ -126,7 +126,7 @@ describe('discovery reports the better-auth admin family — admin plugin on (OS beforeAll(async () => { priorScim = process.env.OS_SCIM_ENABLED; process.env.OS_SCIM_ENABLED = 'true'; - stack = await bootStack(showcaseStack); + stack = await bootShowcase(); readings = await measure(stack); }, 300_000); diff --git a/packages/qa/dogfood/test/email-template-overlay-survives-boot.dogfood.test.ts b/packages/qa/dogfood/test/email-template-overlay-survives-boot.dogfood.test.ts index 4a9e3aaf174..74644d65157 100644 --- a/packages/qa/dogfood/test/email-template-overlay-survives-boot.dogfood.test.ts +++ b/packages/qa/dogfood/test/email-template-overlay-survives-boot.dogfood.test.ts @@ -38,8 +38,8 @@ // `customized: true` that survived the boot. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { EmailServicePlugin } from '@objectstack/plugin-email'; import { fileURLToPath } from 'node:url'; import { mkdtempSync, rmSync } from 'node:fs'; @@ -64,7 +64,7 @@ const emailPlugin = () => new EmailServicePlugin({ }); const boot = (databaseFile: string) => - bootStack(showcaseStack, { databaseFile, orgContext: true, extraPlugins: [emailPlugin()] }); + bootShowcase({ databaseFile, orgContext: true, extraPlugins: [emailPlugin()] }); describe('[#21785] a metadata-door email template edit survives a cold boot (showcase)', () => { let prevCwd: string; diff --git a/packages/qa/dogfood/test/external-import-code-datasource-namespace.dogfood.test.ts b/packages/qa/dogfood/test/external-import-code-datasource-namespace.dogfood.test.ts index e56ff72bbfb..278b712b77e 100644 --- a/packages/qa/dogfood/test/external-import-code-datasource-namespace.dogfood.test.ts +++ b/packages/qa/dogfood/test/external-import-code-datasource-namespace.dogfood.test.ts @@ -36,7 +36,8 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import showcaseStack, { onEnable } from '@objectstack/example-showcase'; import { ExternalDatasourceServicePlugin } from '@objectstack/service-datasource'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -80,7 +81,7 @@ describe('an import over a code-defined datasource is held to its package\'s nam // harness imports only the stack's default export, so `onEnable` never // runs on its own). await onEnable({ logger: { info() {}, warn() {} } } as never); - stack = await bootStack(showcaseStack, { + stack = await bootShowcase({ databaseFile: join(dir, 'showcase.db'), extraPlugins: [new ExternalDatasourceServicePlugin()], }); diff --git a/packages/qa/dogfood/test/external-import-destructive-remedy.dogfood.test.ts b/packages/qa/dogfood/test/external-import-destructive-remedy.dogfood.test.ts index 6d27d0e8c26..bc3c9746e1e 100644 --- a/packages/qa/dogfood/test/external-import-destructive-remedy.dogfood.test.ts +++ b/packages/qa/dogfood/test/external-import-destructive-remedy.dogfood.test.ts @@ -38,7 +38,8 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import showcaseStack, { onEnable } from '@objectstack/example-showcase'; import { ExternalDatasourceServicePlugin } from '@objectstack/service-datasource'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -101,7 +102,7 @@ describe('a destructive re-import is refused with a remedy that works (showcase) process.chdir(dir); // Provision the remote tables, exactly as `os dev` does at boot. await onEnable({ logger: { info() {}, warn() {} } } as never); - stack = await bootStack(showcaseStack, { + stack = await bootShowcase({ databaseFile: join(dir, 'showcase.db'), extraPlugins: [new ExternalDatasourceServicePlugin()], }); diff --git a/packages/qa/dogfood/test/external-import-saves-like-meta.dogfood.test.ts b/packages/qa/dogfood/test/external-import-saves-like-meta.dogfood.test.ts index 1280f3ff66c..777678c9629 100644 --- a/packages/qa/dogfood/test/external-import-saves-like-meta.dogfood.test.ts +++ b/packages/qa/dogfood/test/external-import-saves-like-meta.dogfood.test.ts @@ -42,7 +42,8 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import showcaseStack, { onEnable } from '@objectstack/example-showcase'; import { ExternalDatasourceServicePlugin } from '@objectstack/service-datasource'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -62,7 +63,7 @@ function recordsOf(json: unknown): Array> { } async function boot(databaseFile: string): Promise { - return bootStack(showcaseStack, { databaseFile, extraPlugins: [new ExternalDatasourceServicePlugin()] }); + return bootShowcase({ databaseFile, extraPlugins: [new ExternalDatasourceServicePlugin()] }); } describe('Import as Object persists like the metadata door (showcase, cold boot)', () => { diff --git a/packages/qa/dogfood/test/external-validate-sees-runtime-save.dogfood.test.ts b/packages/qa/dogfood/test/external-validate-sees-runtime-save.dogfood.test.ts index 3d555e508c4..c7ecabaefae 100644 --- a/packages/qa/dogfood/test/external-validate-sees-runtime-save.dogfood.test.ts +++ b/packages/qa/dogfood/test/external-validate-sees-runtime-save.dogfood.test.ts @@ -35,7 +35,8 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import showcaseStack, { onEnable } from '@objectstack/example-showcase'; import { ExternalDatasourceServicePlugin } from '@objectstack/service-datasource'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -91,7 +92,7 @@ describe('external validate lists a federated object saved at runtime, with no r // harness imports only the stack's default export, so `onEnable` never // runs on its own). await onEnable({ logger: { info() {}, warn() {} } } as never); - stack = await bootStack(showcaseStack, { + stack = await bootShowcase({ databaseFile: join(dir, 'showcase.db'), extraPlugins: [new ExternalDatasourceServicePlugin()], }); diff --git a/packages/qa/dogfood/test/external-validate-start-ordering.dogfood.test.ts b/packages/qa/dogfood/test/external-validate-start-ordering.dogfood.test.ts index de9ad6da51d..ef2f01a5bab 100644 --- a/packages/qa/dogfood/test/external-validate-start-ordering.dogfood.test.ts +++ b/packages/qa/dogfood/test/external-validate-start-ordering.dogfood.test.ts @@ -41,7 +41,8 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import showcaseStack, { onEnable } from '@objectstack/example-showcase'; import { ExternalDatasourceServicePlugin, resolveSqliteDriver } from '@objectstack/service-datasource'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -96,7 +97,7 @@ describe('federation on a composition with no metadata plugin (the objectstack s // runs on its own), then drift one column away. await onEnable({ logger: { info() {}, warn() {} } } as never); await driftRemoteCustomers(); - stack = await bootStack(showcaseStack, { extraPlugins: [new ExternalDatasourceServicePlugin()] }); + stack = await bootShowcase({ extraPlugins: [new ExternalDatasourceServicePlugin()] }); token = await stack.signIn(); }, 180_000); diff --git a/packages/qa/dogfood/test/federated-anchor-provenance.dogfood.test.ts b/packages/qa/dogfood/test/federated-anchor-provenance.dogfood.test.ts index 1d5f1616e00..5ee092a6ed3 100644 --- a/packages/qa/dogfood/test/federated-anchor-provenance.dogfood.test.ts +++ b/packages/qa/dogfood/test/federated-anchor-provenance.dogfood.test.ts @@ -35,7 +35,8 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import showcaseStack, { onEnable } from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import type { IObjectQLEngine, ISecurityService } from '@objectstack/spec/contracts'; import type { ServiceObject } from '@objectstack/spec/data'; import { @@ -105,7 +106,7 @@ describe('[#7865] injected-anchor provenance on a real showcase boot', () => { beforeAll(async () => { await onEnable({ logger: { info() {}, warn() {} } } as never); - stack = await bootStack(showcaseStack, { multiTenant: 'posture-only' }); + stack = await bootShowcase({ multiTenant: 'posture-only' }); ql = stack.kernel.getService('objectql'); security = stack.kernel.getService('security'); federated = ql.getSchema(FEDERATED) as ServiceObject; diff --git a/packages/qa/dogfood/test/federated-phantom-share-grant.dogfood.test.ts b/packages/qa/dogfood/test/federated-phantom-share-grant.dogfood.test.ts index a09c47e6f24..9c84b03b622 100644 --- a/packages/qa/dogfood/test/federated-phantom-share-grant.dogfood.test.ts +++ b/packages/qa/dogfood/test/federated-phantom-share-grant.dogfood.test.ts @@ -62,7 +62,8 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import showcaseStack, { onEnable } from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { resolveAuthzContext } from '@objectstack/core'; import { platformProvisionsStorage, @@ -122,7 +123,7 @@ describe('[#8119] federated phantom anchor: single-record gates + share posture' // Provision the "remote" database (the showcase's own fixture provisioner), // then boot the real app. await onEnable({ logger: { info() {}, warn() {} } } as never); - stack = await bootStack(showcaseStack, { multiTenant: 'posture-only' }); + stack = await bootShowcase({ multiTenant: 'posture-only' }); ql = stack.kernel.getService('objectql'); sharing = stack.kernel.getService('sharing'); diff --git a/packages/qa/dogfood/test/federated-rls-injectors.dogfood.test.ts b/packages/qa/dogfood/test/federated-rls-injectors.dogfood.test.ts index 4e096d5335f..646ae59046b 100644 --- a/packages/qa/dogfood/test/federated-rls-injectors.dogfood.test.ts +++ b/packages/qa/dogfood/test/federated-rls-injectors.dogfood.test.ts @@ -35,7 +35,8 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import showcaseStack, { onEnable } from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import type { IObjectQLEngine, ISecurityService } from '@objectstack/spec/contracts'; import type { ServiceObject } from '@objectstack/spec/data'; @@ -77,7 +78,7 @@ describe('[#7835] federated objects and the plugin-security tenant wall', () => // of its own, which is precisely right here: what is under test is the // PREDICATE plugin-security composes, not whether an org wall holds. await onEnable({ logger: { info() {}, warn() {} } } as never); - stack = await bootStack(showcaseStack, { multiTenant: 'posture-only' }); + stack = await bootShowcase({ multiTenant: 'posture-only' }); // `ObjectKernel.getService` is already generic over the slot's contract, so // neither the kernel handle nor either result needs erasing: `objectql` is // `IObjectQLEngine` and `security` is `ISecurityService`, both declared in diff --git a/packages/qa/dogfood/test/federated-sweep-projections.dogfood.test.ts b/packages/qa/dogfood/test/federated-sweep-projections.dogfood.test.ts index 5b800a56908..46ed1f6d638 100644 --- a/packages/qa/dogfood/test/federated-sweep-projections.dogfood.test.ts +++ b/packages/qa/dogfood/test/federated-sweep-projections.dogfood.test.ts @@ -58,7 +58,8 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import showcaseStack, { onEnable } from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import type { IObjectQLEngine } from '@objectstack/spec/contracts'; /** The datasource the showcase federates from; also the driver's registered name. */ @@ -133,7 +134,7 @@ describe('[#8414] background sweeps do not project phantom columns off a federat // Provision the "remote" fixture database (the separate SQLite file the // declared external datasource auto-connects to at boot). await onEnable({ logger: { info() {}, warn() {} } } as never); - stack = await bootStack(showcaseStack, { multiTenant: 'posture-only' }); + stack = await bootShowcase({ multiTenant: 'posture-only' }); ql = stack.kernel.getService('objectql'); engine = ql as unknown as EngineTestSurface; diff --git a/packages/qa/dogfood/test/field-zoo-roundtrip.dogfood.test.ts b/packages/qa/dogfood/test/field-zoo-roundtrip.dogfood.test.ts index 5a2795f2ed4..7753cc59806 100644 --- a/packages/qa/dogfood/test/field-zoo-roundtrip.dogfood.test.ts +++ b/packages/qa/dogfood/test/field-zoo-roundtrip.dogfood.test.ts @@ -17,9 +17,9 @@ // Field.time rejected time-of-day). import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; import { SECRET_MASK } from '@objectstack/objectql'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { MATRIX, REFERENCE_TARGETS } from './field-zoo.matrix.js'; describe('dogfood: field-type capability matrix round-trips over HTTP (#2004)', () => { @@ -29,7 +29,7 @@ describe('dogfood: field-type capability matrix round-trips over HTTP (#2004)', const referenceIds: Record = {}; beforeAll(async () => { - stack = await bootStack(showcaseStack); + stack = await bootShowcase(); const token = await stack.signIn(); // [#4441] Create a REAL row in each reference target first. diff --git a/packages/qa/dogfood/test/fixtures/attachments-fixture.ts b/packages/qa/dogfood/test/fixtures/attachments-fixture.ts index 9a467bd77f4..021dc28a849 100644 --- a/packages/qa/dogfood/test/fixtures/attachments-fixture.ts +++ b/packages/qa/dogfood/test/fixtures/attachments-fixture.ts @@ -27,7 +27,13 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; import { PermissionSetSchema, type PermissionSet } from '@objectstack/spec/security'; import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; -export const AttCase = ObjectSchema.create({ +/** + * [#22301] Builds `AttCase` again, every nested definition new — for a suite that + * keeps two stacks live at once and so boots each on a configuration of its + * own (`bootStack`'s instance rule; a boot keeps live references into the + * definitions it registers). + */ +export const buildAttCase = () => ObjectSchema.create({ name: 'att_case', label: 'Attachment Case', pluralLabel: 'Attachment Cases', @@ -37,8 +43,15 @@ export const AttCase = ObjectSchema.create({ name: Field.text({ label: 'Name', required: true }), }, }); +export const AttCase = buildAttCase(); -export const AttSecret = ObjectSchema.create({ +/** + * [#22301] Builds `AttSecret` again, every nested definition new — for a suite that + * keeps two stacks live at once and so boots each on a configuration of its + * own (`bootStack`'s instance rule; a boot keeps live references into the + * definitions it registers). + */ +export const buildAttSecret = () => ObjectSchema.create({ name: 'att_secret', label: 'Attachment Secret', pluralLabel: 'Attachment Secrets', @@ -50,6 +63,7 @@ export const AttSecret = ObjectSchema.create({ owner_id: Field.text({ label: 'Owner' }), }, }); +export const AttSecret = buildAttSecret(); export const AttNoFiles = ObjectSchema.create({ name: 'att_nofiles', @@ -61,7 +75,13 @@ export const AttNoFiles = ObjectSchema.create({ }, }); -export const AttReadonly = ObjectSchema.create({ +/** + * [#22301] Builds `AttReadonly` again, every nested definition new — for a suite that + * keeps two stacks live at once and so boots each on a configuration of its + * own (`bootStack`'s instance rule; a boot keeps live references into the + * definitions it registers). + */ +export const buildAttReadonly = () => ObjectSchema.create({ name: 'att_readonly', label: 'Attachment Readonly', pluralLabel: 'Attachment Readonlys', @@ -74,6 +94,7 @@ export const AttReadonly = ObjectSchema.create({ owner_id: Field.text({ label: 'Owner' }), }, }); +export const AttReadonly = buildAttReadonly(); /** * The domain grant a real app ships when it turns the attachments panel on diff --git a/packages/qa/dogfood/test/fixtures/comments-fixture.ts b/packages/qa/dogfood/test/fixtures/comments-fixture.ts index a38293dd87b..501156f935e 100644 --- a/packages/qa/dogfood/test/fixtures/comments-fixture.ts +++ b/packages/qa/dogfood/test/fixtures/comments-fixture.ts @@ -32,7 +32,13 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; import { PermissionSetSchema, type PermissionSet } from '@objectstack/spec/security'; import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; -export const CmtOpen = ObjectSchema.create({ +/** + * [#22301] Builds `CmtOpen` again, every nested definition new — for a suite that + * keeps two stacks live at once and so boots each on a configuration of its + * own (`bootStack`'s instance rule; a boot keeps live references into the + * definitions it registers). + */ +export const buildCmtOpen = () => ObjectSchema.create({ name: 'cmt_open', label: 'Comment Open Record', pluralLabel: 'Comment Open Records', @@ -41,8 +47,15 @@ export const CmtOpen = ObjectSchema.create({ name: Field.text({ label: 'Name', required: true }), }, }); +export const CmtOpen = buildCmtOpen(); -export const CmtPrivate = ObjectSchema.create({ +/** + * [#22301] Builds `CmtPrivate` again, every nested definition new — for a suite that + * keeps two stacks live at once and so boots each on a configuration of its + * own (`bootStack`'s instance rule; a boot keeps live references into the + * definitions it registers). + */ +export const buildCmtPrivate = () => ObjectSchema.create({ name: 'cmt_private', label: 'Comment Private Record', pluralLabel: 'Comment Private Records', @@ -53,8 +66,15 @@ export const CmtPrivate = ObjectSchema.create({ owner_id: Field.text({ label: 'Owner' }), }, }); +export const CmtPrivate = buildCmtPrivate(); -export const CmtReadonly = ObjectSchema.create({ +/** + * [#22301] Builds `CmtReadonly` again, every nested definition new — for a suite that + * keeps two stacks live at once and so boots each on a configuration of its + * own (`bootStack`'s instance rule; a boot keeps live references into the + * definitions it registers). + */ +export const buildCmtReadonly = () => ObjectSchema.create({ name: 'cmt_readonly', label: 'Comment Readonly Record', pluralLabel: 'Comment Readonly Records', @@ -64,6 +84,7 @@ export const CmtReadonly = ObjectSchema.create({ owner_id: Field.text({ label: 'Owner' }), }, }); +export const CmtReadonly = buildCmtReadonly(); export const CmtNoFeeds = ObjectSchema.create({ name: 'cmt_nofeeds', diff --git a/packages/qa/dogfood/test/flow-provenance-server-held.dogfood.test.ts b/packages/qa/dogfood/test/flow-provenance-server-held.dogfood.test.ts index 35d2e0159a8..27b9721cbbd 100644 --- a/packages/qa/dogfood/test/flow-provenance-server-held.dogfood.test.ts +++ b/packages/qa/dogfood/test/flow-provenance-server-held.dogfood.test.ts @@ -35,8 +35,8 @@ // tenant's installed base saves, and a shipped flow is a locked base. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { isCodeArtifactBody } from '@objectstack/metadata-core'; import { ConnectorRestPlugin } from '@objectstack/connector-rest'; import { ConnectorOpenApiPlugin } from '@objectstack/connector-openapi'; @@ -102,7 +102,7 @@ const plugins = () => [ ]; async function boot(databaseFile: string): Promise { - return bootStack(showcaseStack, { automation: true, databaseFile, extraPlugins: plugins() }); + return bootShowcase({ automation: true, databaseFile, extraPlugins: plugins() }); } describe('a flow\'s package provenance is the server\'s fact at every door (showcase)', () => { diff --git a/packages/qa/dogfood/test/flow-shipped-name-by-name-read.dogfood.test.ts b/packages/qa/dogfood/test/flow-shipped-name-by-name-read.dogfood.test.ts index 62da18cb5e9..9a830fca35d 100644 --- a/packages/qa/dogfood/test/flow-shipped-name-by-name-read.dogfood.test.ts +++ b/packages/qa/dogfood/test/flow-shipped-name-by-name-read.dogfood.test.ts @@ -35,8 +35,8 @@ // - a name no managed package ships still answers its stored row (control). import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { RecordChangeTriggerPlugin } from '@objectstack/trigger-record-change'; import { ConnectorRestPlugin } from '@objectstack/connector-rest'; import { ConnectorOpenApiPlugin } from '@objectstack/connector-openapi'; @@ -93,7 +93,7 @@ const plugins = () => [ ]; async function boot(databaseFile: string): Promise { - return bootStack(showcaseStack, { automation: true, databaseFile, extraPlugins: plugins() }); + return bootShowcase({ automation: true, databaseFile, extraPlugins: plugins() }); } const nodeIds = (flow: FlowBody | null | undefined) => (flow?.nodes ?? []).map((n) => n.id); diff --git a/packages/qa/dogfood/test/flow-shipped-name-layered-read.dogfood.test.ts b/packages/qa/dogfood/test/flow-shipped-name-layered-read.dogfood.test.ts index 734b486941f..e035563319c 100644 --- a/packages/qa/dogfood/test/flow-shipped-name-layered-read.dogfood.test.ts +++ b/packages/qa/dogfood/test/flow-shipped-name-layered-read.dogfood.test.ts @@ -43,8 +43,8 @@ // answers for a shipped name is left to the card's decision. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { RecordChangeTriggerPlugin } from '@objectstack/trigger-record-change'; import { ConnectorRestPlugin } from '@objectstack/connector-rest'; import { ConnectorOpenApiPlugin } from '@objectstack/connector-openapi'; @@ -109,7 +109,7 @@ const plugins = () => [ ]; async function boot(databaseFile: string): Promise { - return bootStack(showcaseStack, { automation: true, databaseFile, extraPlugins: plugins() }); + return bootShowcase({ automation: true, databaseFile, extraPlugins: plugins() }); } const nodeIds = (flow: FlowBody | null | undefined) => (flow?.nodes ?? []).map((n) => n.id); diff --git a/packages/qa/dogfood/test/flow-shipped-name-published-door.dogfood.test.ts b/packages/qa/dogfood/test/flow-shipped-name-published-door.dogfood.test.ts index 589f09adc34..d807ffda47b 100644 --- a/packages/qa/dogfood/test/flow-shipped-name-published-door.dogfood.test.ts +++ b/packages/qa/dogfood/test/flow-shipped-name-published-door.dogfood.test.ts @@ -38,8 +38,8 @@ // `packages/runtime/src/domains/meta-published-runtime-publish.test.ts`. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { RecordChangeTriggerPlugin } from '@objectstack/trigger-record-change'; import { ConnectorRestPlugin } from '@objectstack/connector-rest'; import { ConnectorOpenApiPlugin } from '@objectstack/connector-openapi'; @@ -105,7 +105,7 @@ const plugins = () => [ ]; async function boot(databaseFile: string): Promise { - return bootStack(showcaseStack, { automation: true, databaseFile, extraPlugins: plugins() }); + return bootShowcase({ automation: true, databaseFile, extraPlugins: plugins() }); } const nodeIds = (flow: unknown) => ((flow as FlowBody | null | undefined)?.nodes ?? []).map((n) => n.id); diff --git a/packages/qa/dogfood/test/flow-shipped-name-stored-row-boot.dogfood.test.ts b/packages/qa/dogfood/test/flow-shipped-name-stored-row-boot.dogfood.test.ts index 026894294c5..9dbf0dccc5a 100644 --- a/packages/qa/dogfood/test/flow-shipped-name-stored-row-boot.dogfood.test.ts +++ b/packages/qa/dogfood/test/flow-shipped-name-stored-row-boot.dogfood.test.ts @@ -36,8 +36,8 @@ // - an organization-scoped row stays out of reach. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { RecordChangeTriggerPlugin } from '@objectstack/trigger-record-change'; import { ConnectorRestPlugin } from '@objectstack/connector-rest'; import { ConnectorOpenApiPlugin } from '@objectstack/connector-openapi'; @@ -85,7 +85,7 @@ const plugins = () => [ ]; async function boot(databaseFile: string): Promise { - return bootStack(showcaseStack, { automation: true, databaseFile, extraPlugins: plugins() }); + return bootShowcase({ automation: true, databaseFile, extraPlugins: plugins() }); } const nodeIds = (flow: { nodes?: Array<{ id: string }> } | null) => (flow?.nodes ?? []).map((n) => n.id); diff --git a/packages/qa/dogfood/test/flow-unshipped-name-layered-code.dogfood.test.ts b/packages/qa/dogfood/test/flow-unshipped-name-layered-code.dogfood.test.ts index f97acca708b..ac4352cba57 100644 --- a/packages/qa/dogfood/test/flow-unshipped-name-layered-code.dogfood.test.ts +++ b/packages/qa/dogfood/test/flow-unshipped-name-layered-code.dogfood.test.ts @@ -36,8 +36,8 @@ // (control — the fallback's stated purpose). import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { RecordChangeTriggerPlugin } from '@objectstack/trigger-record-change'; import { ConnectorRestPlugin } from '@objectstack/connector-rest'; import { ConnectorOpenApiPlugin } from '@objectstack/connector-openapi'; @@ -106,7 +106,7 @@ const plugins = () => [ ]; async function boot(databaseFile: string): Promise { - return bootStack(showcaseStack, { automation: true, databaseFile, extraPlugins: plugins() }); + return bootShowcase({ automation: true, databaseFile, extraPlugins: plugins() }); } /** A copy of a loader's body with every underscore-prefixed key dropped. */ diff --git a/packages/qa/dogfood/test/identity-admin-fields-org-peer.dogfood.test.ts b/packages/qa/dogfood/test/identity-admin-fields-org-peer.dogfood.test.ts index 6e7ced1e3f5..81dc3a97f4a 100644 --- a/packages/qa/dogfood/test/identity-admin-fields-org-peer.dogfood.test.ts +++ b/packages/qa/dogfood/test/identity-admin-fields-org-peer.dogfood.test.ts @@ -40,8 +40,8 @@ // Fixtures are synthetic. ⚠️ No test title states a value. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { AuditPlugin } from '@objectstack/plugin-audit'; import { PermissionSetSchema } from '@objectstack/spec/security'; import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; @@ -116,7 +116,7 @@ describe('[#21237] the identity object Admin group at the HTTP door: org peers a const servedGroup = (row: Row) => GROUP.filter((f) => f in row); beforeAll(async () => { - stack = await bootStack(showcaseStack as unknown as Parameters[0], { + stack = await bootShowcase({ security: new SecurityPlugin({ defaultPermissionSets: [...securityDefaultPermissionSets, activityReadSet] }), extraPlugins: [new AuditPlugin()], }); diff --git a/packages/qa/dogfood/test/install-local-listing-not-loaded.dogfood.test.ts b/packages/qa/dogfood/test/install-local-listing-not-loaded.dogfood.test.ts index 11d96899db0..34c3b73a508 100644 --- a/packages/qa/dogfood/test/install-local-listing-not-loaded.dogfood.test.ts +++ b/packages/qa/dogfood/test/install-local-listing-not-loaded.dogfood.test.ts @@ -37,9 +37,9 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; import crmStack from '@objectstack/example-crm'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { LocalManifestSource, MarketplaceInstallLocalPlugin } from '@objectstack/cloud-connection'; import { PROTOCOL_MAJOR } from '@objectstack/spec/kernel'; import { buildShapedArtifact } from './build-shaped-artifact.js'; @@ -106,7 +106,7 @@ describe('dogfood: the install-local listing marks a package the restart refused let uninstall: { status: number; body: any }; // eslint-disable-line @typescript-eslint/no-explicit-any let afterUninstall: { status: number; body: any }; // eslint-disable-line @typescript-eslint/no-explicit-any - const boot = (databaseFile: string) => bootStack(showcaseStack, { + const boot = (databaseFile: string) => bootShowcase({ databaseFile, extraPlugins: [new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir })], }); diff --git a/packages/qa/dogfood/test/install-local-listing-sample-data.dogfood.test.ts b/packages/qa/dogfood/test/install-local-listing-sample-data.dogfood.test.ts index 1baf929ef98..979becd69c2 100644 --- a/packages/qa/dogfood/test/install-local-listing-sample-data.dogfood.test.ts +++ b/packages/qa/dogfood/test/install-local-listing-sample-data.dogfood.test.ts @@ -29,9 +29,9 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; import crmStack from '@objectstack/example-crm'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { LocalManifestSource, MarketplaceInstallLocalPlugin } from '@objectstack/cloud-connection'; import type { IObjectQLEngine } from '@objectstack/spec/contracts'; import type { ExecutionContext } from '@objectstack/spec/kernel'; @@ -86,7 +86,7 @@ describe('dogfood: the install-local listing answers withSampleData per organiza const beforeRestart: Record<'A' | 'B', View> = {} as never; const afterRestart: Record<'A' | 'B', View> = {} as never; - const boot = (databaseFile: string) => bootStack(showcaseStack, { + const boot = (databaseFile: string) => bootShowcase({ // `posture-only` requests the `isolated` posture — the wall is ACTIVE — // without the organizations runtime; the memberships are written by hand. multiTenant: 'posture-only', diff --git a/packages/qa/dogfood/test/install-local-no-active-organization.dogfood.test.ts b/packages/qa/dogfood/test/install-local-no-active-organization.dogfood.test.ts index dcd267cd0bc..828d72e0324 100644 --- a/packages/qa/dogfood/test/install-local-no-active-organization.dogfood.test.ts +++ b/packages/qa/dogfood/test/install-local-no-active-organization.dogfood.test.ts @@ -39,9 +39,9 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; import crmStack from '@objectstack/example-crm'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { MarketplaceInstallLocalPlugin } from '@objectstack/cloud-connection'; import type { IObjectQLEngine } from '@objectstack/spec/contracts'; import type { ExecutionContext } from '@objectstack/spec/kernel'; @@ -115,7 +115,7 @@ describe('dogfood: install-local under an organization wall, with a session that storageDir = mkdtempSync(join(tmpdir(), 'dogfood-install-local-no-active-org-')); // `posture-only` requests the `isolated` posture — the wall is ACTIVE — // without the organizations runtime; the memberships are written by hand. - stack = await bootStack(showcaseStack, { + stack = await bootShowcase({ multiTenant: 'posture-only', extraPlugins: [new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir })], }); diff --git a/packages/qa/dogfood/test/install-local-purge-sample-data.dogfood.test.ts b/packages/qa/dogfood/test/install-local-purge-sample-data.dogfood.test.ts index 8da5fd2f6ea..62f7b961f6a 100644 --- a/packages/qa/dogfood/test/install-local-purge-sample-data.dogfood.test.ts +++ b/packages/qa/dogfood/test/install-local-purge-sample-data.dogfood.test.ts @@ -47,9 +47,9 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; import crmStack from '@objectstack/example-crm'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { AuditPlugin } from '@objectstack/plugin-audit'; import { LocalManifestSource, MarketplaceInstallLocalPlugin } from '@objectstack/cloud-connection'; import type { IObjectQLEngine } from '@objectstack/spec/contracts'; @@ -149,7 +149,7 @@ describe('dogfood: install-local purge on the single-tenant posture — the card beforeAll(async () => { storageDir = mkdtempSync(join(tmpdir(), 'dogfood-install-local-purge-')); - stack = await bootStack(showcaseStack, { + stack = await bootShowcase({ extraPlugins: [new AuditPlugin(), new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir })], }); ql = stack.kernel.getService('objectql'); @@ -250,7 +250,7 @@ describe('dogfood: install-local purge under an organization wall — one organi // `posture-only` requests the `isolated` posture — the wall is ACTIVE — // without the organizations runtime; the memberships are written by hand // (the shape `no-active-organization-write-refusal.dogfood.test.ts` uses). - stack = await bootStack(showcaseStack, { + stack = await bootShowcase({ multiTenant: 'posture-only', extraPlugins: [new AuditPlugin(), new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir })], }); diff --git a/packages/qa/dogfood/test/install-local-reseed-intact-baseline.dogfood.test.ts b/packages/qa/dogfood/test/install-local-reseed-intact-baseline.dogfood.test.ts index 6df75b41cd5..f61e1c4944f 100644 --- a/packages/qa/dogfood/test/install-local-reseed-intact-baseline.dogfood.test.ts +++ b/packages/qa/dogfood/test/install-local-reseed-intact-baseline.dogfood.test.ts @@ -34,9 +34,9 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; import crmStack from '@objectstack/example-crm'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { MarketplaceInstallLocalPlugin } from '@objectstack/cloud-connection'; import type { IObjectQLEngine } from '@objectstack/spec/contracts'; import type { ExecutionContext } from '@objectstack/spec/kernel'; @@ -109,7 +109,7 @@ describe('dogfood: install-local reseed over an intact baseline, then after a pu beforeAll(async () => { storageDir = mkdtempSync(join(tmpdir(), 'dogfood-install-local-reseed-intact-')); - stack = await bootStack(showcaseStack, { + stack = await bootShowcase({ extraPlugins: [new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir })], }); ql = stack.kernel.getService('objectql'); @@ -183,7 +183,7 @@ describe('dogfood: install-local reseed of a package with no seed record for thi beforeAll(async () => { storageDir = mkdtempSync(join(tmpdir(), 'dogfood-install-local-reseed-none-')); - stack = await bootStack(showcaseStack, { + stack = await bootShowcase({ extraPlugins: [new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir })], }); ql = stack.kernel.getService('objectql'); diff --git a/packages/qa/dogfood/test/install-local-sample-data-not-loaded.dogfood.test.ts b/packages/qa/dogfood/test/install-local-sample-data-not-loaded.dogfood.test.ts index 5bb21b2e74c..ef5bcaa7c90 100644 --- a/packages/qa/dogfood/test/install-local-sample-data-not-loaded.dogfood.test.ts +++ b/packages/qa/dogfood/test/install-local-sample-data-not-loaded.dogfood.test.ts @@ -41,9 +41,9 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; import crmStack from '@objectstack/example-crm'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { LocalManifestSource, MarketplaceInstallLocalPlugin } from '@objectstack/cloud-connection'; import { PROTOCOL_MAJOR, type ExecutionContext } from '@objectstack/spec/kernel'; import type { IObjectQLEngine } from '@objectstack/spec/contracts'; @@ -154,7 +154,7 @@ describe('dogfood: reseed and purge refuse a package the restart refused to load let recordedAfterPurge: unknown; let uninstall: Answer; - const boot = (databaseFile: string) => bootStack(showcaseStack, { + const boot = (databaseFile: string) => bootShowcase({ databaseFile, extraPlugins: [new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir })], }); diff --git a/packages/qa/dogfood/test/invitation-ledger-row-scope.dogfood.test.ts b/packages/qa/dogfood/test/invitation-ledger-row-scope.dogfood.test.ts index 887b68c484a..a6fb3c659e4 100644 --- a/packages/qa/dogfood/test/invitation-ledger-row-scope.dogfood.test.ts +++ b/packages/qa/dogfood/test/invitation-ledger-row-scope.dogfood.test.ts @@ -73,8 +73,8 @@ */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; const SYSTEM_CTX = { isSystem: true }; @@ -166,7 +166,7 @@ describe('#8095/#8240: the sys_invitation ledger, read by four personas', () => let ownerUserId: string; beforeAll(async () => { - stack = await bootStack(showcaseStack, {}); + stack = await bootShowcase({}); const adminToken = await stack.signIn(); // the seeded dev admin (platform admin) ql = await stack.kernel.getServiceAsync('objectql'); diff --git a/packages/qa/dogfood/test/me-apps-and-everyone-baseline.dogfood.test.ts b/packages/qa/dogfood/test/me-apps-and-everyone-baseline.dogfood.test.ts index 87f760a9ffa..5cc137795b6 100644 --- a/packages/qa/dogfood/test/me-apps-and-everyone-baseline.dogfood.test.ts +++ b/packages/qa/dogfood/test/me-apps-and-everyone-baseline.dogfood.test.ts @@ -29,8 +29,8 @@ // @proof: me-apps-and-everyone-baseline import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { SecurityPlugin } from '@objectstack/plugin-security'; const SYS = { isSystem: true } as const; @@ -56,7 +56,7 @@ describe('ADR-0090 D5 closures: /me/apps + anchor-bindable baseline', () => { // asymmetry #7001 closed. The harness now honours an app's declared default, // so the file that genuinely wants the platform's own baseline asks for it. // Nothing about the claim below changed; only who is saying it. - stack = await bootStack(showcaseStack, { security: new SecurityPlugin() }); + stack = await bootShowcase({ security: new SecurityPlugin() }); adminTok = await stack.signIn(); memberTok = await stack.signUp('baseline-member@verify.test'); ql = await stack.kernel.getServiceAsync('objectql'); diff --git a/packages/qa/dogfood/test/membership-actor-attribution.dogfood.test.ts b/packages/qa/dogfood/test/membership-actor-attribution.dogfood.test.ts index cac72c2d4d1..ba00e6ed68a 100644 --- a/packages/qa/dogfood/test/membership-actor-attribution.dogfood.test.ts +++ b/packages/qa/dogfood/test/membership-actor-attribution.dogfood.test.ts @@ -34,8 +34,8 @@ */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { AuditPlugin } from '@objectstack/plugin-audit'; import { AUTO_ORG_ADMIN_GRANT_REASON_PREFIX } from '@objectstack/plugin-security'; @@ -114,7 +114,7 @@ describe('#4586: the better-auth actor reaches sys_member history and the grant' let memberRowId: string; beforeAll(async () => { - stack = await bootStack(showcaseStack, { extraPlugins: [new AuditPlugin()] }); + stack = await bootShowcase({ extraPlugins: [new AuditPlugin()] }); adminToken = await stack.signIn(); // the seeded dev admin ql = await stack.kernel.getServiceAsync('objectql'); diff --git a/packages/qa/dogfood/test/membership-decided-at-creation.dogfood.test.ts b/packages/qa/dogfood/test/membership-decided-at-creation.dogfood.test.ts index d04d4b0bdfa..9f37b0087f7 100644 --- a/packages/qa/dogfood/test/membership-decided-at-creation.dogfood.test.ts +++ b/packages/qa/dogfood/test/membership-decided-at-creation.dogfood.test.ts @@ -14,8 +14,8 @@ */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; const SYSTEM_CTX = { isSystem: true }; @@ -41,7 +41,7 @@ describe('membership under the auto policy is decided at user creation (ADR-0093 let orgId: string; beforeAll(async () => { - stack = await bootStack(showcaseStack); + stack = await bootShowcase(); adminToken = await stack.signIn(); ql = await stack.kernel.getServiceAsync('objectql'); diff --git a/packages/qa/dogfood/test/membership-ended-session-revoke.dogfood.test.ts b/packages/qa/dogfood/test/membership-ended-session-revoke.dogfood.test.ts index 149c161a5e5..7215004f0cc 100644 --- a/packages/qa/dogfood/test/membership-ended-session-revoke.dogfood.test.ts +++ b/packages/qa/dogfood/test/membership-ended-session-revoke.dogfood.test.ts @@ -25,8 +25,8 @@ */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { MEMBERSHIP_ENDED_REVOKE_REASON } from '@objectstack/plugin-auth'; const SYSTEM_CTX = { isSystem: true }; @@ -54,7 +54,7 @@ describe('#15784: a membership that ends takes the session\'s claim on that orga let partnerOrgId: string; beforeAll(async () => { - stack = await bootStack(showcaseStack); + stack = await bootShowcase(); adminToken = await stack.signIn(); ql = await stack.kernel.getServiceAsync('objectql'); diff --git a/packages/qa/dogfood/test/membership-reconciler.dogfood.test.ts b/packages/qa/dogfood/test/membership-reconciler.dogfood.test.ts index aecc3264241..7219a11ca59 100644 --- a/packages/qa/dogfood/test/membership-reconciler.dogfood.test.ts +++ b/packages/qa/dogfood/test/membership-reconciler.dogfood.test.ts @@ -19,8 +19,8 @@ */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { backfillMemberships, reconcileMembership } from '@objectstack/plugin-auth'; const SYSTEM_CTX = { isSystem: true }; @@ -36,7 +36,7 @@ describe('ADR-0093: membership lifecycle (single-org, real stack)', () => { let defaultOrgId: string; beforeAll(async () => { - stack = await bootStack(showcaseStack, {}); // single-org (no OS_MULTI_ORG_ENABLED) + stack = await bootShowcase({}); // single-org (no OS_MULTI_ORG_ENABLED) await stack.signIn(); ql = await stack.kernel.getServiceAsync('objectql'); // [ADR-0131 D3] The Default Organization is a boot invariant under diff --git a/packages/qa/dogfood/test/membership-role-vocabulary.dogfood.test.ts b/packages/qa/dogfood/test/membership-role-vocabulary.dogfood.test.ts index 00568724035..66fe79caf63 100644 --- a/packages/qa/dogfood/test/membership-role-vocabulary.dogfood.test.ts +++ b/packages/qa/dogfood/test/membership-role-vocabulary.dogfood.test.ts @@ -43,8 +43,8 @@ */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; const SYSTEM_CTX = { isSystem: true }; @@ -70,7 +70,7 @@ describe('ADR-0108: the membership-role vocabulary is closed; capability goes th let ownerToken: string; beforeAll(async () => { - stack = await bootStack(showcaseStack, {}); + stack = await bootShowcase({}); ownerToken = await stack.signIn(); // the seeded dev admin ql = await stack.kernel.getServiceAsync('objectql'); diff --git a/packages/qa/dogfood/test/meta-door-code-datasource.dogfood.test.ts b/packages/qa/dogfood/test/meta-door-code-datasource.dogfood.test.ts index 43b7c23d9dc..5ce45a6980b 100644 --- a/packages/qa/dogfood/test/meta-door-code-datasource.dogfood.test.ts +++ b/packages/qa/dogfood/test/meta-door-code-datasource.dogfood.test.ts @@ -42,7 +42,8 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import showcaseStack, { onEnable } from '@objectstack/example-showcase'; import { registerDatasourceAdminRoutes } from '@objectstack/service-datasource'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -102,7 +103,7 @@ describe('[#21899] the metadata door answers a code-defined datasource as read-o }, }); const boot = async () => { - stack = await bootStack(showcaseStack, { databaseFile: join(dir, 'showcase.db'), extraPlugins: [routes()] }); + stack = await bootShowcase({ databaseFile: join(dir, 'showcase.db'), extraPlugins: [routes()] }); token = await stack.signIn(); }; const restart = async () => { diff --git a/packages/qa/dogfood/test/meta-published-and-state-routes.dogfood.test.ts b/packages/qa/dogfood/test/meta-published-and-state-routes.dogfood.test.ts index 14c33040a3a..f1e651ef3f7 100644 --- a/packages/qa/dogfood/test/meta-published-and-state-routes.dogfood.test.ts +++ b/packages/qa/dogfood/test/meta-published-and-state-routes.dogfood.test.ts @@ -23,7 +23,8 @@ import { join } from 'node:path'; import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { MetadataPlugin } from '@objectstack/metadata'; import { writeBuildShapedArtifact } from './build-shaped-artifact.js'; @@ -46,7 +47,7 @@ describe('dogfood: /meta/:type/:name/published and /meta/object/:name/state/:fie // none of what it advertises (commit c39a911ae). writeBuildShapedArtifact(showcaseStack as unknown as Record, artifactPath); - stack = await bootStack(showcaseStack, { + stack = await bootShowcase({ extraPlugins: [ new MetadataPlugin({ rootDir: tempDir, diff --git a/packages/qa/dogfood/test/meta-types-create-seed.dogfood.test.ts b/packages/qa/dogfood/test/meta-types-create-seed.dogfood.test.ts index cbcb2aee9f5..583eef2e45c 100644 --- a/packages/qa/dogfood/test/meta-types-create-seed.dogfood.test.ts +++ b/packages/qa/dogfood/test/meta-types-create-seed.dogfood.test.ts @@ -17,8 +17,8 @@ // cannot rot back apart. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { getMetadataCreateSeed, listMetadataCreateSeedTypes } from '@objectstack/spec/kernel'; describe('dogfood: /meta/types exposes authoritative create seeds (spec-derived create-shape contract)', () => { @@ -27,7 +27,7 @@ describe('dogfood: /meta/types exposes authoritative create seeds (spec-derived let entries: Array>; beforeAll(async () => { - stack = await bootStack(showcaseStack); + stack = await bootShowcase(); token = await stack.signIn(); const res = await stack.apiAs(token, 'GET', '/meta/types'); expect(res.status).toBe(200); diff --git a/packages/qa/dogfood/test/no-active-organization-write-refusal.dogfood.test.ts b/packages/qa/dogfood/test/no-active-organization-write-refusal.dogfood.test.ts index 2e87fe3bf4e..8fc0beb0ea5 100644 --- a/packages/qa/dogfood/test/no-active-organization-write-refusal.dogfood.test.ts +++ b/packages/qa/dogfood/test/no-active-organization-write-refusal.dogfood.test.ts @@ -49,8 +49,8 @@ // @proof: no-active-organization-write-refusal import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { resolveAuthzContext } from '@objectstack/core'; import type { IObjectQLEngine } from '@objectstack/spec/contracts'; import type { ExecutionContext } from '@objectstack/spec/kernel'; @@ -74,7 +74,7 @@ describe('[ADR-0123 D2 / #8208] a tenant-scoped write with no active organizatio beforeAll(async () => { // `posture-only` is the mode #8208 measured on: it requests the `isolated` // posture (the wall is ACTIVE) without the organizations runtime. - stack = await bootStack(showcaseStack, { multiTenant: 'posture-only' }); + stack = await bootShowcase({ multiTenant: 'posture-only' }); ql = stack.kernel.getService('objectql'); adminToken = await stack.signIn(); diff --git a/packages/qa/dogfood/test/object-designer-field-reorder.dogfood.test.ts b/packages/qa/dogfood/test/object-designer-field-reorder.dogfood.test.ts index f41a275a7dc..2b9d26a922b 100644 --- a/packages/qa/dogfood/test/object-designer-field-reorder.dogfood.test.ts +++ b/packages/qa/dogfood/test/object-designer-field-reorder.dogfood.test.ts @@ -20,9 +20,9 @@ // dropped. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; import { hashSpec } from '@objectstack/metadata-core'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; const OBJ = 'dogfood_field_reorder'; const SYSTEM_CTX = { isSystem: true }; @@ -61,7 +61,7 @@ describe('dogfood: an object designer field reorder publishes and reads back in let ql: Ql; beforeAll(async () => { - stack = await bootStack(showcaseStack); + stack = await bootShowcase(); token = await stack.signIn(); ql = (await stack.kernel.getServiceAsync('objectql')) as unknown as Ql; }, 90_000); diff --git a/packages/qa/dogfood/test/oidc-authorization-code-flow.dogfood.test.ts b/packages/qa/dogfood/test/oidc-authorization-code-flow.dogfood.test.ts index 24856e365f9..914128b6185 100644 --- a/packages/qa/dogfood/test/oidc-authorization-code-flow.dogfood.test.ts +++ b/packages/qa/dogfood/test/oidc-authorization-code-flow.dogfood.test.ts @@ -21,8 +21,8 @@ import { createHash } from 'node:crypto'; import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; // Must be on before the AuthPlugin builds its plugin list (kernel.use during // bootStack) — this is the same switch cloud/objectstack dev deployments use. @@ -49,7 +49,7 @@ describe('OIDC authorization-code flow (oauth-provider 1.7)', () => { let cookie: string; beforeAll(async () => { - stack = await bootStack(showcaseStack, {}); + stack = await bootShowcase({}); // Seed the OAuth client the way cloud's seedPlatformSsoClient does. const ql = await stack.kernel.getServiceAsync('objectql'); diff --git a/packages/qa/dogfood/test/oidc-authorize-env-gate.dogfood.test.ts b/packages/qa/dogfood/test/oidc-authorize-env-gate.dogfood.test.ts index a7ecd4b2788..01744c00d19 100644 --- a/packages/qa/dogfood/test/oidc-authorize-env-gate.dogfood.test.ts +++ b/packages/qa/dogfood/test/oidc-authorize-env-gate.dogfood.test.ts @@ -63,8 +63,8 @@ import { createHash } from 'node:crypto'; import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { assertArmed, authSettingArmed } from './armed.js'; // Must be on before the AuthPlugin builds its plugin list (kernel.use during @@ -150,7 +150,7 @@ describe('#8102: the D5.1 /oauth2/authorize env-access gate runs on every creden let gateCalls: GateCall[] = []; beforeAll(async () => { - stack = await bootStack(showcaseStack, {}); + stack = await bootShowcase({}); // Seed the OAuth client the way cloud's seedPlatformSsoClient does. // `skip_consent` matters: without it a fall-through would stop at a consent diff --git a/packages/qa/dogfood/test/org-admin-affordance-reach.dogfood.test.ts b/packages/qa/dogfood/test/org-admin-affordance-reach.dogfood.test.ts index 489bd38e0b8..c1923a5053d 100644 --- a/packages/qa/dogfood/test/org-admin-affordance-reach.dogfood.test.ts +++ b/packages/qa/dogfood/test/org-admin-affordance-reach.dogfood.test.ts @@ -60,8 +60,8 @@ */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { celEngine } from '@objectstack/formula'; const SYSTEM_CTX = { isSystem: true }; @@ -141,7 +141,7 @@ describe('org-admin affordances follow the membership grade (served metadata × const standingOwner = {} as { token: string; userId: string; session: Record; served: Map }; beforeAll(async () => { - stack = await bootStack(showcaseStack, {}); + stack = await bootShowcase({}); tokens.owner = await stack.signIn(); // the seeded dev admin ql = await stack.kernel.getServiceAsync('objectql'); diff --git a/packages/qa/dogfood/test/org-create-default-team.dogfood.test.ts b/packages/qa/dogfood/test/org-create-default-team.dogfood.test.ts index 9b32b419547..a63e3cc302d 100644 --- a/packages/qa/dogfood/test/org-create-default-team.dogfood.test.ts +++ b/packages/qa/dogfood/test/org-create-default-team.dogfood.test.ts @@ -21,8 +21,8 @@ */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; /** * ── How this fixture opens the route (#5261) ───────────────────────────────── @@ -56,7 +56,7 @@ describe('#3624: org create provisions its default team', () => { let token: string; beforeAll(async () => { - stack = await bootStack(showcaseStack, { multiTenant: 'posture-only' }); + stack = await bootShowcase({ multiTenant: 'posture-only' }); token = await stack.signIn(); }, 120_000); diff --git a/packages/qa/dogfood/test/org-scoped-sharing-rule-listing.dogfood.test.ts b/packages/qa/dogfood/test/org-scoped-sharing-rule-listing.dogfood.test.ts index cb37856855b..9d9f7974a1a 100644 --- a/packages/qa/dogfood/test/org-scoped-sharing-rule-listing.dogfood.test.ts +++ b/packages/qa/dogfood/test/org-scoped-sharing-rule-listing.dogfood.test.ts @@ -38,8 +38,8 @@ // @proof: org-scoped-sharing-rule-listing import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; const RULES = '/sharing/rules'; const SYS = { isSystem: true } as const; @@ -71,7 +71,7 @@ describe('#7676 — package-seeded (org-less) sharing rules stay visible to an O let admin: string; beforeAll(async () => { - stack = await bootStack(showcaseStack, { orgContext: true }); + stack = await bootShowcase({ orgContext: true }); admin = await stack.signIn(); ql = await stack.kernel.getServiceAsync('objectql'); }, 120_000); diff --git a/packages/qa/dogfood/test/organization-delete-federated-fixture.dogfood.test.ts b/packages/qa/dogfood/test/organization-delete-federated-fixture.dogfood.test.ts index 4573a768c98..6966c18c0eb 100644 --- a/packages/qa/dogfood/test/organization-delete-federated-fixture.dogfood.test.ts +++ b/packages/qa/dogfood/test/organization-delete-federated-fixture.dogfood.test.ts @@ -51,7 +51,8 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import showcaseStack, { onEnable } from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { resolveInjectedColumnProvenance } from '@objectstack/metadata-core'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; @@ -83,7 +84,7 @@ describe('[#21910] an organization delete with the showcase federated fixture pr // harness imports only the stack's default export, so `onEnable` never // runs on its own). await onEnable({ logger: { info() {}, warn() {} } } as never); - stack = await bootStack(showcaseStack, { + stack = await bootShowcase({ orgContext: true, databaseFile: join(dir, 'showcase.db'), }); diff --git a/packages/qa/dogfood/test/organization-update-door.dogfood.test.ts b/packages/qa/dogfood/test/organization-update-door.dogfood.test.ts index 253d7e1d2ad..0d896efad4b 100644 --- a/packages/qa/dogfood/test/organization-update-door.dogfood.test.ts +++ b/packages/qa/dogfood/test/organization-update-door.dogfood.test.ts @@ -70,8 +70,8 @@ */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; describe('#15873: sys_organization platform-owned columns through PATCH /data/sys_organization/{id}', () => { let stack: VerifyStack; @@ -102,7 +102,7 @@ describe('#15873: sys_organization platform-owned columns through PATCH /data/sy // WALL (`BootOptions.multiTenant` states the limit), and nothing below // asserts isolation. It is the same fixture `org-create-default-team // .dogfood.test.ts` opens the route with. - stack = await bootStack(showcaseStack, { multiTenant: 'posture-only' }); + stack = await bootShowcase({ multiTenant: 'posture-only' }); token = await stack.signIn(); // better-auth's `organization/create` — the `create_organization` row diff --git a/packages/qa/dogfood/test/owner-anchor-and-bulk-writes.dogfood.test.ts b/packages/qa/dogfood/test/owner-anchor-and-bulk-writes.dogfood.test.ts index 735ce77d17e..123c5fd3127 100644 --- a/packages/qa/dogfood/test/owner-anchor-and-bulk-writes.dogfood.test.ts +++ b/packages/qa/dogfood/test/owner-anchor-and-bulk-writes.dogfood.test.ts @@ -28,8 +28,8 @@ // authz-row: bulk-write-owner-scoping import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { resolveAuthzContext } from '@objectstack/core'; import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; import { PermissionSetSchema } from '@objectstack/spec/security'; @@ -78,7 +78,7 @@ describe('owner anchor guard + owner-scoped bulk writes (#3004 / #2982)', () => (await ql.findOne('showcase_private_note', { where: { id: noteId }, context: { isSystem: true } }))?.owner_id; beforeAll(async () => { - stack = await bootStack(showcaseStack, { + stack = await bootShowcase({ security: new SecurityPlugin({ defaultPermissionSets: [...securityDefaultPermissionSets, noteDeleteSet], }), diff --git a/packages/qa/dogfood/test/packaged-activation-ledger-reach.dogfood.test.ts b/packages/qa/dogfood/test/packaged-activation-ledger-reach.dogfood.test.ts index 85b50ad5833..aad0e188e06 100644 --- a/packages/qa/dogfood/test/packaged-activation-ledger-reach.dogfood.test.ts +++ b/packages/qa/dogfood/test/packaged-activation-ledger-reach.dogfood.test.ts @@ -49,8 +49,8 @@ // re-arming every artifact an administrator had switched off. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; // The showcase declares `connectors:` bound to these providers, and the // automation service REFUSES TO START without their factories (ADR-0097) — // exactly as `objectstack dev` would. Only the automation-carrying boot needs @@ -105,7 +105,7 @@ describe('#12359 — actions and NO automation service: the ledger is there', () beforeAll(async () => { // The exact boot #12359 measured: no `automation` option, so // `@objectstack/service-automation` is not composed at all. - stack = await bootStack(showcaseStack); + stack = await bootShowcase(); token = await stack.signIn(); }, 120_000); @@ -211,7 +211,7 @@ describe('#12159 Part 1 — a composition WITH automation: flows and actions bot beforeAll(async () => { prevCwd = process.cwd(); process.chdir(SHOWCASE_DIR); - stack = await bootStack(showcaseStack, { + stack = await bootShowcase({ automation: true, extraPlugins: [ new ConnectorRestPlugin(), diff --git a/packages/qa/dogfood/test/packaged-flow-write-door-parity.dogfood.test.ts b/packages/qa/dogfood/test/packaged-flow-write-door-parity.dogfood.test.ts index 9f8f2d7b119..6ae65381954 100644 --- a/packages/qa/dogfood/test/packaged-flow-write-door-parity.dogfood.test.ts +++ b/packages/qa/dogfood/test/packaged-flow-write-door-parity.dogfood.test.ts @@ -32,8 +32,8 @@ // the whole assertion, and the byte-identical read-backs prove it. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; // The showcase declares `connectors:` bound to these providers, and the // automation service refuses to start without their factories (ADR-0097) — // the same composition `packaged-activation-ledger-reach.dogfood.test.ts` boots. @@ -101,7 +101,7 @@ describe('a packaged flow keeps its locked base at every write door (showcase)', beforeAll(async () => { prevCwd = process.cwd(); process.chdir(SHOWCASE_DIR); - stack = await bootStack(showcaseStack, { + stack = await bootShowcase({ automation: true, extraPlugins: [ new ConnectorRestPlugin(), diff --git a/packages/qa/dogfood/test/parent-derived-write-refusal-not-visible.dogfood.test.ts b/packages/qa/dogfood/test/parent-derived-write-refusal-not-visible.dogfood.test.ts index bdcd2287a7b..051ccc86e62 100644 --- a/packages/qa/dogfood/test/parent-derived-write-refusal-not-visible.dogfood.test.ts +++ b/packages/qa/dogfood/test/parent-derived-write-refusal-not-visible.dogfood.test.ts @@ -55,29 +55,36 @@ import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plug import { bootStack, type VerifyStack } from '@objectstack/verify'; import { StorageServicePlugin } from '@objectstack/service-storage'; import { AuditPlugin } from '@objectstack/plugin-audit'; -import { - AttSecret, - AttReadonly, - attFixtureBaselineSet, - attachmentManagerSet, -} from './fixtures/attachments-fixture.js'; -import { CmtPrivate, CmtReadonly, commentManagerSet } from './fixtures/comments-fixture.js'; +import { buildAttSecret, buildAttReadonly, attFixtureBaselineSet, attachmentManagerSet } from './fixtures/attachments-fixture.js'; +import { buildCmtPrivate, buildCmtReadonly, commentManagerSet } from './fixtures/comments-fixture.js'; import { armedWhen, assertArmed, leaveOrganization, principalArmed, resolveAuthzFor } from './armed.js'; const SYS = { isSystem: true } as const; -/** Both parent-derived join objects, private and read-only parents for each. */ -const stackDefinition = defineStack({ - manifest: { - id: 'com.dogfood.parent-derived-write-refusal', - version: '0.0.0', - type: 'app', - name: 'Parent-derived write refusal fixture', - description: - 'Private and read-only parents for sys_attachment and sys_comment: the write refusal a caller who cannot read the parent receives.', - }, - objects: [AttSecret, AttReadonly, CmtPrivate, CmtReadonly], -}); +/** + * Both parent-derived join objects, private and read-only parents for each. + * + * [#22301] A BUILDER, called once per boot: this file keeps two stacks live at + * once (outside and inside the organization), and `bootStack`'s instance rule + * refuses a second live boot of one configuration object. A shallow copy would + * not be a configuration of its own — a boot keeps live references into the + * nested definitions it registers (the registry stores each object as a shallow + * copy whose field definitions are the authored objects: + * `packages/objectql/src/registry.ts`, `definition: { ...schema, name: fqn }`) — + * so every nested definition is built again. + */ +const buildStackDefinition = () => + defineStack({ + manifest: { + id: 'com.dogfood.parent-derived-write-refusal', + version: '0.0.0', + type: 'app', + name: 'Parent-derived write refusal fixture', + description: + 'Private and read-only parents for sys_attachment and sys_comment: the write refusal a caller who cannot read the parent receives.', + }, + objects: [buildAttSecret(), buildAttReadonly(), buildCmtPrivate(), buildCmtReadonly()], + }); function security(): SecurityPlugin { return new SecurityPlugin({ @@ -110,7 +117,7 @@ interface Booted { */ async function boot(inside: boolean, email: string): Promise { const rootDir = mkdtempSync(join(tmpdir(), 'parent-refusal-')); - const stack = await bootStack(stackDefinition as never, { + const stack = await bootStack(buildStackDefinition() as never, { orgContext: inside, security: security(), extraPlugins: [ diff --git a/packages/qa/dogfood/test/permission-set-assignment-window.dogfood.test.ts b/packages/qa/dogfood/test/permission-set-assignment-window.dogfood.test.ts index 88696a2da2d..dfb76a555fa 100644 --- a/packages/qa/dogfood/test/permission-set-assignment-window.dogfood.test.ts +++ b/packages/qa/dogfood/test/permission-set-assignment-window.dogfood.test.ts @@ -37,8 +37,8 @@ // so a verdict on a change to either is a verdict on its last build. import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { assertArmed, armedWhen } from './armed.js'; const SYS = { isSystem: true } as const; @@ -77,7 +77,7 @@ describe('a permission-set assignment grants only inside its validity window (AD const door = async (who: string) => (await stack.apiAs(tok[who], 'GET', `${DOOR}?$top=1`)).status; beforeAll(async () => { - stack = await bootStack(showcaseStack); + stack = await bootShowcase(); adminTok = await stack.signIn(); ql = await stack.kernel.getServiceAsync('objectql'); diff --git a/packages/qa/dogfood/test/permission-set-clone-boot-unowned-warning.dogfood.test.ts b/packages/qa/dogfood/test/permission-set-clone-boot-unowned-warning.dogfood.test.ts index 41bcd6563e4..5a897849fe8 100644 --- a/packages/qa/dogfood/test/permission-set-clone-boot-unowned-warning.dogfood.test.ts +++ b/packages/qa/dogfood/test/permission-set-clone-boot-unowned-warning.dogfood.test.ts @@ -37,8 +37,8 @@ // facet added to the action reaches this pin without an edit here. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { securityObjects } from '@objectstack/plugin-security'; import { fileURLToPath } from 'node:url'; import { mkdtempSync, rmSync } from 'node:fs'; @@ -101,7 +101,7 @@ describe('[#21669] a cloned permission set boots without the unowned-declaration dbFile = join(dir, 'showcase.db'); // ── boot 1: clone a packaged set through the Setup dialog's own path ── - stack = await bootStack(showcaseStack, { databaseFile: dbFile }); + stack = await bootShowcase({ databaseFile: dbFile }); const token = await stack.signIn(); const engine: any = await stack.kernel.getServiceAsync('objectql'); const [base] = await engine.find('sys_permission_set', { where: { name: BASE }, limit: 1 }, SYS); @@ -133,7 +133,7 @@ describe('[#21669] a cloned permission set boots without the unowned-declaration stack = undefined; // ── boot 2: same file, nothing authored ─────────────────────────────── - const second = await captureOutput(() => bootStack(showcaseStack, { databaseFile: dbFile })); + const second = await captureOutput(() => bootShowcase({ databaseFile: dbFile })); stack = second.value; bootLines = second.lines; ql = await stack.kernel.getServiceAsync('objectql'); diff --git a/packages/qa/dogfood/test/permission-set-discard-overlay-eligibility.dogfood.test.ts b/packages/qa/dogfood/test/permission-set-discard-overlay-eligibility.dogfood.test.ts index ae909758ccf..f92a317e645 100644 --- a/packages/qa/dogfood/test/permission-set-discard-overlay-eligibility.dogfood.test.ts +++ b/packages/qa/dogfood/test/permission-set-discard-overlay-eligibility.dogfood.test.ts @@ -41,8 +41,8 @@ // prove nothing. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { securityObjects, computePermissionSetDriftDiagnostics } from '@objectstack/plugin-security'; import { fileURLToPath } from 'node:url'; import { mkdtempSync, rmSync } from 'node:fs'; @@ -126,7 +126,7 @@ describe('[#21860] Discard Overlay refuses every set no code package ships, with process.chdir(SHOWCASE_DIR); dir = mkdtempSync(join(tmpdir(), 'dogfood-21860-')); dbFile = join(dir, 'showcase.db'); - stack = await bootStack(showcaseStack, { databaseFile: dbFile }); + stack = await bootShowcase({ databaseFile: dbFile }); token = await stack.signIn(); ql = await stack.kernel.getServiceAsync('objectql'); @@ -187,7 +187,7 @@ describe('[#21860] Discard Overlay refuses every set no code package ships, with // The cold boot that makes the legacy row a real overlay. stack = undefined; - stack = await bootStack(showcaseStack, { databaseFile: dbFile }); + stack = await bootShowcase({ databaseFile: dbFile }); token = await stack.signIn(); ql = await stack.kernel.getServiceAsync('objectql'); diff --git a/packages/qa/dogfood/test/permission-set-lock-row-provenance.dogfood.test.ts b/packages/qa/dogfood/test/permission-set-lock-row-provenance.dogfood.test.ts index 8704f64ddb0..72ccea5f54a 100644 --- a/packages/qa/dogfood/test/permission-set-lock-row-provenance.dogfood.test.ts +++ b/packages/qa/dogfood/test/permission-set-lock-row-provenance.dogfood.test.ts @@ -55,8 +55,8 @@ // three shapes again before anything is written. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { securityObjects } from '@objectstack/plugin-security'; import { fileURLToPath } from 'node:url'; import { mkdtempSync, rmSync } from 'node:fs'; @@ -142,7 +142,7 @@ describe('[#21789] the permission-set lock reads the row\'s provenance — the t process.chdir(SHOWCASE_DIR); dir = mkdtempSync(join(tmpdir(), 'dogfood-21789-')); dbFile = join(dir, 'showcase.db'); - stack = await bootStack(showcaseStack, { databaseFile: dbFile }); + stack = await bootShowcase({ databaseFile: dbFile }); token = await stack.signIn(); ql = await stack.kernel.getServiceAsync('objectql'); @@ -247,7 +247,7 @@ describe('[#21789] the permission-set lock reads the row\'s provenance — the t beforeAll(async () => { await stack?.stop(); stack = undefined; - stack = await bootStack(showcaseStack, { databaseFile: dbFile }); + stack = await bootShowcase({ databaseFile: dbFile }); token = await stack.signIn(); ql = await stack.kernel.getServiceAsync('objectql'); }, 300_000); diff --git a/packages/qa/dogfood/test/permission-set-write-through-package-binding.dogfood.test.ts b/packages/qa/dogfood/test/permission-set-write-through-package-binding.dogfood.test.ts index 9697e9514ee..f0dba0ef8cc 100644 --- a/packages/qa/dogfood/test/permission-set-write-through-package-binding.dogfood.test.ts +++ b/packages/qa/dogfood/test/permission-set-write-through-package-binding.dogfood.test.ts @@ -39,8 +39,8 @@ // any of them is visible. Each case counts before and after its edit. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { fileURLToPath } from 'node:url'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; @@ -119,7 +119,7 @@ describe('[#21861] a data-door edit of a permission set updates its own sys_meta prevCwd = process.cwd(); process.chdir(SHOWCASE_DIR); dir = mkdtempSync(join(tmpdir(), 'dogfood-21861-')); - stack = await bootStack(showcaseStack, { databaseFile: join(dir, 'showcase.db') }); + stack = await bootShowcase({ databaseFile: join(dir, 'showcase.db') }); token = await stack.signIn(); ql = await stack.kernel.getServiceAsync('objectql'); diff --git a/packages/qa/dogfood/test/predicate-write-unreadable-not-matched.dogfood.test.ts b/packages/qa/dogfood/test/predicate-write-unreadable-not-matched.dogfood.test.ts index 0063fd3c199..7524e9e8bd1 100644 --- a/packages/qa/dogfood/test/predicate-write-unreadable-not-matched.dogfood.test.ts +++ b/packages/qa/dogfood/test/predicate-write-unreadable-not-matched.dogfood.test.ts @@ -49,19 +49,19 @@ import { bootStack, type VerifyStack } from '@objectstack/verify'; import { StorageServicePlugin } from '@objectstack/service-storage'; import { AuditPlugin } from '@objectstack/plugin-audit'; import { - AttCase, - AttSecret, - AttReadonly, + buildAttCase, + buildAttSecret, + buildAttReadonly, attFixtureBaselineSet, attachmentManagerSet, } from './fixtures/attachments-fixture.js'; -import { CmtOpen, CmtPrivate, CmtReadonly, commentManagerSet } from './fixtures/comments-fixture.js'; +import { buildCmtOpen, buildCmtPrivate, buildCmtReadonly, commentManagerSet } from './fixtures/comments-fixture.js'; import { armedWhen, assertArmed, leaveOrganization, principalArmed, resolveAuthzFor } from './armed.js'; const SYS = { isSystem: true } as const; /** The plain row-level-security object: reads reach own or shared rows, writes reach own rows or one team's. */ -const PwLedger = ObjectSchema.create({ +const buildPwLedger = () => ObjectSchema.create({ name: 'pw_ledger', label: 'Predicate Write Ledger', pluralLabel: 'Predicate Write Ledgers', @@ -85,17 +85,28 @@ const ledgerMemberSet: PermissionSet = PermissionSetSchema.parse({ ], }); -const stackDefinition = defineStack({ - manifest: { - id: 'com.dogfood.predicate-write-unreadable-not-matched', - version: '0.0.0', - type: 'app', - name: 'Predicate write: an unreadable row is not matched', - description: - 'Open, private and read-only parents for sys_attachment and sys_comment, and a row-level-security ledger: the predicate write answer for rows the caller cannot read, beside the answer for a predicate matching nothing.', - }, - objects: [AttCase, AttSecret, AttReadonly, CmtOpen, CmtPrivate, CmtReadonly, PwLedger], -}); +/** + * [#22301] A BUILDER, called once per boot: this file keeps two stacks live at + * once (outside and inside the organization), and `bootStack`'s instance rule + * refuses a second live boot of one configuration object. A shallow copy would + * not be a configuration of its own — a boot keeps live references into the + * nested definitions it registers (the registry stores each object as a shallow + * copy whose field definitions are the authored objects: + * `packages/objectql/src/registry.ts`, `definition: { ...schema, name: fqn }`) — + * so every nested definition is built again. + */ +const buildStackDefinition = () => + defineStack({ + manifest: { + id: 'com.dogfood.predicate-write-unreadable-not-matched', + version: '0.0.0', + type: 'app', + name: 'Predicate write: an unreadable row is not matched', + description: + 'Open, private and read-only parents for sys_attachment and sys_comment, and a row-level-security ledger: the predicate write answer for rows the caller cannot read, beside the answer for a predicate matching nothing.', + }, + objects: [buildAttCase(), buildAttSecret(), buildAttReadonly(), buildCmtOpen(), buildCmtPrivate(), buildCmtReadonly(), buildPwLedger()], + }); function security(): SecurityPlugin { return new SecurityPlugin({ @@ -129,7 +140,7 @@ interface Booted { */ async function boot(inside: boolean, email: string): Promise { const rootDir = mkdtempSync(join(tmpdir(), 'predicate-write-nm-')); - const stack = await bootStack(stackDefinition as never, { + const stack = await bootStack(buildStackDefinition() as never, { orgContext: inside, security: security(), extraPlugins: [ diff --git a/packages/qa/dogfood/test/primary-bu-projection.dogfood.test.ts b/packages/qa/dogfood/test/primary-bu-projection.dogfood.test.ts index 26fb79f427f..42d636cf2cf 100644 --- a/packages/qa/dogfood/test/primary-bu-projection.dogfood.test.ts +++ b/packages/qa/dogfood/test/primary-bu-projection.dogfood.test.ts @@ -13,8 +13,8 @@ */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; const SYS = { context: { isSystem: true } } as const; const EMAIL = 'd12-primary-bu@verify.test'; @@ -32,7 +32,7 @@ describe('ADR-0057 D12: sys_user.primary_business_unit_id projection', () => { (await findOne('sys_user', { id }, ['id', 'primary_business_unit_id']))?.primary_business_unit_id ?? null; beforeAll(async () => { - stack = await bootStack(showcaseStack, {}); + stack = await bootShowcase({}); await stack.signIn(); await stack.signUp(EMAIL); ql = await stack.kernel.getServiceAsync('objectql'); diff --git a/packages/qa/dogfood/test/route-ledger-live-mount-parity.dogfood.test.ts b/packages/qa/dogfood/test/route-ledger-live-mount-parity.dogfood.test.ts index 248c7aa3784..408e047f4af 100644 --- a/packages/qa/dogfood/test/route-ledger-live-mount-parity.dogfood.test.ts +++ b/packages/qa/dogfood/test/route-ledger-live-mount-parity.dogfood.test.ts @@ -57,8 +57,8 @@ // {@link UNEXERCISED_BY_THIS_BOOT}. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { StorageServicePlugin } from '@objectstack/service-storage'; // `.js` on the relative source imports: without it `moduleResolution: nodenext` @@ -170,7 +170,7 @@ describe('route ledger ↔ live mount parity (#7526)', () => { let ledgerRows: LedgerRow[]; beforeAll(async () => { - stack = await bootStack(showcaseStack, { + stack = await bootShowcase({ // Boot as WIDE as this gate can, so as few rows as possible have to be // pinned: every plugin added here converts a pin into a measurement. // `StorageServicePlugin` alone converts ten — the whole storage ledger, diff --git a/packages/qa/dogfood/test/schedule-sweep-organization-scope.dogfood.test.ts b/packages/qa/dogfood/test/schedule-sweep-organization-scope.dogfood.test.ts index 180bb78ba22..8b1af785045 100644 --- a/packages/qa/dogfood/test/schedule-sweep-organization-scope.dogfood.test.ts +++ b/packages/qa/dogfood/test/schedule-sweep-organization-scope.dogfood.test.ts @@ -49,6 +49,25 @@ const TARGET_OBJECT = 'sched_org_target'; const SWEEP_FLOW = 'sched_org_sweep'; const SWEEP_JOB = `flow-time-relative:${SWEEP_FLOW}`; +/** + * [#22301] The fixture's `requires: ['automation', 'triggers', 'messaging']` is + * now honoured by `bootStack`, as `objectstack serve` honours it, so the boot + * mounts the real trigger plugins — `TimeRelativeTriggerPlugin` among them. That + * real trigger would bind this file's sweep flow to the REAL job service at + * `registerFlow`, before the trigger this file registers by hand (over its fake + * job service) ever saw it. A caller-held instance under a provider's identity + * takes precedence (`serve`'s "an explicit instance wins"), so this stand-in — + * registered under the time-relative provider's `name` and doing nothing — + * keeps the fixture's `requires` as authored and leaves the time-relative + * trigger to the one this file registers. + */ +class TimeRelativeTriggerStandIn { + readonly name = 'com.objectstack.trigger.time-relative'; + readonly version = '0.0.0'; + readonly type = 'standard'; + async init(): Promise {} +} + /** A job service the test fires by hand — the sweep's cadence is not the subject. */ function fakeJobService(): { service: JobServiceSurface; @@ -133,7 +152,7 @@ for (const databaseDriver of ['sqlite-wasm', 'memory'] as const) { // it off: with the outbox + dispatcher on, `sys_inbox_message` is // written by a background dispatcher on its own schedule, so a count // taken right after the tick reads empty whatever the sweep selected. - extraPlugins: [new MessagingServicePlugin({ reliableDelivery: false })], + extraPlugins: [new MessagingServicePlugin({ reliableDelivery: false }), new TimeRelativeTriggerStandIn()], }); await stack.signIn(); ql = await stack.kernel.getServiceAsync('objectql'); diff --git a/packages/qa/dogfood/test/security-catalog-showcase.dogfood.test.ts b/packages/qa/dogfood/test/security-catalog-showcase.dogfood.test.ts index 07c077e7389..387e4dfda4c 100644 --- a/packages/qa/dogfood/test/security-catalog-showcase.dogfood.test.ts +++ b/packages/qa/dogfood/test/security-catalog-showcase.dogfood.test.ts @@ -31,7 +31,8 @@ // declared by its host root. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import showcaseStack from '@objectstack/example-showcase'; import { securityDefaultPermissionSets } from '@objectstack/plugin-security'; import { createSecurityCatalogReader, type SecurityCatalogType } from '@objectstack/core'; @@ -77,7 +78,7 @@ describe.each(POSTURES)('showcase, $label: the security catalog read', ({ opts, let reader: ReturnType; beforeAll(async () => { - booted = await bootStack(showcaseStack as Parameters[0], opts); + booted = await bootShowcase(opts); admin = await booted.signIn(); // eslint-disable-next-line @typescript-eslint/no-explicit-any const ql: any = await booted.kernel.getServiceAsync('objectql'); diff --git a/packages/qa/dogfood/test/semantic-roles.dogfood.test.ts b/packages/qa/dogfood/test/semantic-roles.dogfood.test.ts index 5f75e775414..0cb35234f2d 100644 --- a/packages/qa/dogfood/test/semantic-roles.dogfood.test.ts +++ b/packages/qa/dogfood/test/semantic-roles.dogfood.test.ts @@ -18,8 +18,8 @@ // `examples/app-showcase/src/objects/semantic-zoo.object.ts`. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { deriveFieldGroupLayout } from '@objectstack/spec/data'; let stack: VerifyStack; @@ -56,11 +56,11 @@ async function servedObject(name: string): Promise> { // run measured 18.3s (vitest-reported `Duration`) / 19.5s wall clock for the // whole file even on an otherwise-idle box (boot dominates; the 5 tests // themselves run in ~3.5s). 180_000ms follows this package's existing house -// pattern for the identical `bootStack(showcaseStack, …)` call — see +// pattern for the identical `bootShowcase(…)` call — see // `admin-identity-audit-trail.dogfood.test.ts`'s `beforeAll(…, 180_000)` — // rather than inventing a new number for the same operation. beforeAll(async () => { - stack = await bootStack(showcaseStack); + stack = await bootShowcase(); token = await stack.signIn(); }, 180_000); diff --git a/packages/qa/dogfood/test/session-token-not-serialized.dogfood.test.ts b/packages/qa/dogfood/test/session-token-not-serialized.dogfood.test.ts index de99d04ef18..231d2c3e6b8 100644 --- a/packages/qa/dogfood/test/session-token-not-serialized.dogfood.test.ts +++ b/packages/qa/dogfood/test/session-token-not-serialized.dogfood.test.ts @@ -49,8 +49,8 @@ */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; const MEMBER_EMAIL = 'session-token-member@verify.test'; @@ -82,7 +82,7 @@ describe('#7823: sys_session.token (a live bearer) never serializes on the gener }; beforeAll(async () => { - stack = await bootStack(showcaseStack, {}); + stack = await bootShowcase({}); adminToken = await stack.signIn(); memberToken = await stack.signUp(MEMBER_EMAIL); diff --git a/packages/qa/dogfood/test/settings-config-change-audit.dogfood.test.ts b/packages/qa/dogfood/test/settings-config-change-audit.dogfood.test.ts index 2e503b4b427..81815eb87a3 100644 --- a/packages/qa/dogfood/test/settings-config-change-audit.dogfood.test.ts +++ b/packages/qa/dogfood/test/settings-config-change-audit.dogfood.test.ts @@ -45,8 +45,8 @@ */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { AuditPlugin } from '@objectstack/plugin-audit'; const SYSTEM_CTX = { isSystem: true }; @@ -78,7 +78,7 @@ describe('#8145: a settings write reaches sys_audit_log as config_change', () => const WORKSPACE_NAME = 'ObjectStack 8145'; beforeAll(async () => { - stack = await bootStack(showcaseStack, { extraPlugins: [new AuditPlugin()] }); + stack = await bootShowcase({ extraPlugins: [new AuditPlugin()] }); token = await stack.signIn(); // the seeded dev admin (platform admin) ql = await stack.kernel.getServiceAsync('objectql'); diff --git a/packages/qa/dogfood/test/share-links-self-list.dogfood.test.ts b/packages/qa/dogfood/test/share-links-self-list.dogfood.test.ts index e7edb460d46..36b129f9042 100644 --- a/packages/qa/dogfood/test/share-links-self-list.dogfood.test.ts +++ b/packages/qa/dogfood/test/share-links-self-list.dogfood.test.ts @@ -55,8 +55,8 @@ */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { SHARE_LINK_SERVICE } from '@objectstack/spec/contracts'; import { showcaseAppDefaultSecurity } from './showcase-security.js'; @@ -112,7 +112,7 @@ describe('#21328: GET /share-links is self-scoped for a plain member', () => { }; beforeAll(async () => { - stack = await bootStack(showcaseStack, { security: showcaseAppDefaultSecurity() }); + stack = await bootShowcase({ security: showcaseAppDefaultSecurity() }); adminTok = await stack.signIn(); // the seeded admin first, so the sign-ups below are plain members aTok = await stack.signUp(A_EMAIL); bTok = await stack.signUp(B_EMAIL); diff --git a/packages/qa/dogfood/test/shared-showcase.ts b/packages/qa/dogfood/test/shared-showcase.ts index 320bd5a62b8..4eecd9b14ad 100644 --- a/packages/qa/dogfood/test/shared-showcase.ts +++ b/packages/qa/dogfood/test/shared-showcase.ts @@ -48,23 +48,10 @@ // Scope every list assertion to records the file itself created (unique // emails / name prefixes). // Anything else stays in the `isolated` project (plain vitest defaults). -import { fileURLToPath } from 'node:url'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { showcaseAppDefaultSecurity } from './showcase-security.js'; -/** - * [#22301] The showcase's own directory — the `hostRoot` its boot is anchored - * to. `bootStack` composes what `objectstack serve` composes (ruling A), so it - * mounts the showcase's `plugins` array, and with `requires: ['automation']` - * the automation service materializes the declarative connectors at start — - * one of which (`showcase_status_openapi`) reads a package-relative file. - * `serve` reads it from the directory holding `objectstack.config.ts`; this - * suite runs each file in a temporary working directory (#21914), so the boot - * names the app's directory instead of inheriting that one. - */ -const SHOWCASE_DIR = fileURLToPath(new URL('../../../../examples/app-showcase/', import.meta.url)); - /** * [#5491] The two scaffolding grants these shared fixtures add on top of the * app's declared default — named objects, never a wildcard. @@ -94,10 +81,9 @@ let booted: Promise | undefined; /** Boot (once per worker) and return the shared plain-showcase stack. */ export function getSharedShowcase(): Promise { - booted ??= bootStack(showcaseStack, { - security: showcaseAppDefaultSecurity(SHARED_FIXTURE_GRANTS), - hostRoot: SHOWCASE_DIR, - }).then(async (stack) => { + // [#22301] Anchored at the showcase's own directory by `bootShowcase`, the + // one owner of the showcase's test root (see `./showcase-boot.ts`). + booted ??= bootShowcase({ security: showcaseAppDefaultSecurity(SHARED_FIXTURE_GRANTS) }).then(async (stack) => { // First sign-in provisions the dev admin; later files' own signIn() calls // are idempotent (~0.16s) and just mint fresh admin tokens. await stack.signIn(); diff --git a/packages/qa/dogfood/test/sharing-rule-criteria-required.dogfood.test.ts b/packages/qa/dogfood/test/sharing-rule-criteria-required.dogfood.test.ts index eb868cad685..6ec135ef64f 100644 --- a/packages/qa/dogfood/test/sharing-rule-criteria-required.dogfood.test.ts +++ b/packages/qa/dogfood/test/sharing-rule-criteria-required.dogfood.test.ts @@ -23,8 +23,8 @@ // @proof: sharing-rule-criteria-required import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; // Sharing-rule routes are anchored on the API base, not under `/data/:object` // (they administer rules tenant-wide) — `/api/v1/sharing/rules`. @@ -38,7 +38,7 @@ describe('#3896 — a sharing rule POSTed without criteria is rejected, never ma let admin: string; beforeAll(async () => { - stack = await bootStack(showcaseStack); + stack = await bootShowcase(); admin = await stack.signIn(); ql = await stack.kernel.getServiceAsync('objectql'); }, 90_000); diff --git a/packages/qa/dogfood/test/sharing-rule-org-less-caller.dogfood.test.ts b/packages/qa/dogfood/test/sharing-rule-org-less-caller.dogfood.test.ts index c81e5c26b3b..a0ba2a1ba6e 100644 --- a/packages/qa/dogfood/test/sharing-rule-org-less-caller.dogfood.test.ts +++ b/packages/qa/dogfood/test/sharing-rule-org-less-caller.dogfood.test.ts @@ -61,8 +61,8 @@ // @proof: sharing-rule-org-less-caller import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { leaveOrganization } from './armed.js'; const RULES = '/sharing/rules'; @@ -123,7 +123,7 @@ describe('#8158 — a manage_sharing holder with NO active organization cannot r }); beforeAll(async () => { - stack = await bootStack(showcaseStack); + stack = await bootShowcase(); await stack.signIn(); // the first user: the bootstrap account ql = await stack.kernel.getServiceAsync('objectql'); diff --git a/packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts b/packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts index fdab9adb174..31e18cb534b 100644 --- a/packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts @@ -495,13 +495,15 @@ describe('showcase: anonymous posture is uniform across surfaces (#2567)', () => // ── /automation (dispatcher-mounted; runtime domains/automation.ts) ───── // // The gate is DOMAIN-WIDE and sits ahead of the `isServiceServeable` probe on - // purpose: this stack installs no `@objectstack/service-automation`, so the - // domain's own answer here is 501. If the gate ran after the probe, anonymous - // and authenticated callers would both get 501 and the 401/501 difference - // would fingerprint whether a deployment mounts automation at all. The - // authenticated 501 case below is what gives these three cases their teeth: - // in this one process, the same route answers 401 to anonymous and 501 to a - // member, so the 401 can only be the gate's answer. + // purpose: if the gate ran after the probe, a deployment without + // `@objectstack/service-automation` would answer 501 to anonymous and + // authenticated callers alike, and the 401/501 difference would fingerprint + // whether it mounts automation at all. [#22301] This stack is the SERVED + // composition — `bootStack` mounts what `objectstack serve` mounts, and the + // showcase's `requires` names `automation` — so the domain's own answer to a + // member is 200. The authenticated case below is what gives these three cases + // their teeth: in this one process, the same route answers 401 to anonymous + // and 200 to a member, so the 401 can only be the gate's answer. it('anonymous POST /automation/:name/trigger is denied (401)', async () => { const r = await anon('POST', `/automation/${FLOW}/trigger`, { recordId: 'anon-probe-id' }); expect(r.status, 'anonymous flow trigger must be 401').toBe(401); @@ -521,14 +523,20 @@ describe('showcase: anonymous posture is uniform across surfaces (#2567)', () => expect(r.status, 'anonymous flow deregistration must be 401').toBe(401); }); - it('an authenticated caller reaches the domain, which answers 501 — not 401', async () => { + it('an authenticated caller reaches the domain, which answers 200 — not 401', async () => { + // [#22301] The served composition: the showcase's `requires: ['automation']` + // mounts the service, as `objectstack serve` does. + expect( + stack.kernel.hasPlugin('com.objectstack.service-automation'), + 'the showcase requires automation, so the served composition mounts it', + ).toBe(true); const r = await stack.apiAs(memberToken, 'GET', '/automation/_status'); expect(r.status, 'authenticated flow-inventory read must clear the auth gate').not.toBe(401); - // The domain's OWN answer on a stack with no automation service. Asserting - // it (rather than only `.not.toBe(401)`) is what proves the anonymous 401 - // above is produced by the gate and not by the domain: drop the gate and - // the anonymous cases collapse onto THIS status. - expect(r.status, 'no @objectstack/service-automation is installed on this boot').toBe(501); + // The domain's OWN answer on a stack with the automation service mounted. + // Asserting it (rather than only `.not.toBe(401)`) is what proves the + // anonymous 401 above is produced by the gate and not by the domain: drop + // the gate and the anonymous cases collapse onto THIS status. + expect(r.status, 'the mounted automation domain answers the member').toBe(200); }); // ── /packages (dispatcher-mounted; runtime domains/packages.ts) — #7033/#7023 ─ diff --git a/packages/qa/dogfood/test/showcase-boot.ts b/packages/qa/dogfood/test/showcase-boot.ts new file mode 100644 index 00000000000..e8932fc42ca --- /dev/null +++ b/packages/qa/dogfood/test/showcase-boot.ts @@ -0,0 +1,42 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#22301] The ONE owner of the showcase's test root: every dogfood boot of the +// showcase goes through `bootShowcase`. +// +// `bootStack` composes what `objectstack serve` composes from a configuration +// (ruling A on #22301): the providers the showcase's `requires` names and the +// plugins in its own `plugins` array. With `requires: ['automation']` and its +// connector plugins mounted, the automation service materializes the +// showcase's declarative connectors at start, and `showcase_status_openapi` +// reads its spec from a PACKAGE-RELATIVE file. `serve` anchors that read at the +// directory holding `objectstack.config.ts`; the handle anchors it at +// `BootOptions.hostRoot`. This suite runs every file in a temporary working +// directory (#21914), so a boot that inherited the working directory as its +// root refuses the connector — measured: 102 files at `c9a2c6123`, every one +// `ENOENT` on `./src/system/connectors/status-openapi.json`. +// +// So the root is named HERE, once, and never at a call site: a change to how +// the showcase is anchored in tests edits this file and nothing else. +// ⛔ Do not pass `hostRoot` to `bootStack` for the showcase anywhere else, and +// ⛔ do not `chdir` into the showcase to make a boot find its files — a file +// that runs from the showcase's directory writes `.objectstack/data` there, +// which is the cross-file state #21914 removed. +// +// A file that boots a DERIVED showcase configuration (the showcase spread with +// an `onEnable`, a fresh module instance of it) hands that configuration in as +// the second argument; it is the same app, anchored at the same root. +import { fileURLToPath } from 'node:url'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type BootOptions, type VerifyStack } from '@objectstack/verify'; + +/** `examples/app-showcase` — the directory holding the showcase's `objectstack.config.ts`. */ +const SHOWCASE_DIR = fileURLToPath(new URL('../../../../examples/app-showcase/', import.meta.url)); + +/** + * Boot the showcase (or a configuration derived from it) anchored at the + * showcase's own directory. Every other option is the caller's; `hostRoot` is + * this helper's, and a caller's own is overridden. + */ +export function bootShowcase(opts: BootOptions = {}, config: unknown = showcaseStack): Promise { + return bootStack(config, { ...opts, hostRoot: SHOWCASE_DIR }); +} diff --git a/packages/qa/dogfood/test/showcase-bu-hierarchy-sharing.dogfood.test.ts b/packages/qa/dogfood/test/showcase-bu-hierarchy-sharing.dogfood.test.ts index dfe9ee1d578..798bfabfffa 100644 --- a/packages/qa/dogfood/test/showcase-bu-hierarchy-sharing.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-bu-hierarchy-sharing.dogfood.test.ts @@ -30,8 +30,8 @@ // authz-row: hierarchy-widening import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { showcaseAppDefaultSecurity } from './showcase-security.js'; const OBJ = '/data/showcase_private_note'; @@ -50,7 +50,7 @@ describe('showcase: business-unit hierarchy sharing rule (ADR-0057 D6 / #2077)', // grant-less sign-up can no longer create the private note this fixture // shares. Boot the showcase the way the CLI does — under its OWN declared // default profile, which grants `showcase_private_note` create/read/edit. - stack = await bootStack(showcaseStack, { security: showcaseAppDefaultSecurity() }); + stack = await bootShowcase({ security: showcaseAppDefaultSecurity() }); await stack.signIn(); ownerTok = await stack.signUp('bu-owner@verify.test'); mgrTok = await stack.signUp('bu-mgr@verify.test'); // parent BU diff --git a/packages/qa/dogfood/test/showcase-client-liaison-fixtures.dogfood.test.ts b/packages/qa/dogfood/test/showcase-client-liaison-fixtures.dogfood.test.ts index fbab954b86d..c94d606949b 100644 --- a/packages/qa/dogfood/test/showcase-client-liaison-fixtures.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-client-liaison-fixtures.dogfood.test.ts @@ -34,8 +34,8 @@ // `applyRedaction` and the field masker actually do. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { showcaseAppDefaultSecurity } from './showcase-security.js'; const SYS = { isSystem: true } as const; @@ -73,7 +73,7 @@ describe('showcase client-liaison fixtures (#9308 fixtures 2 + 4)', () => { let projectId = ''; beforeAll(async () => { - stack = await bootStack(showcaseStack, { security: showcaseAppDefaultSecurity() }); + stack = await bootShowcase({ security: showcaseAppDefaultSecurity() }); adminTok = await stack.signIn(); ql = await stack.kernel.getServiceAsync('objectql'); diff --git a/packages/qa/dogfood/test/showcase-crud-persona-matrix.dogfood.test.ts b/packages/qa/dogfood/test/showcase-crud-persona-matrix.dogfood.test.ts index f7280bbc576..1e52d08d6de 100644 --- a/packages/qa/dogfood/test/showcase-crud-persona-matrix.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-crud-persona-matrix.dogfood.test.ts @@ -64,8 +64,8 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { showcaseAppDefaultSecurity } from './showcase-security.js'; const SYS = { isSystem: true } as const; @@ -304,7 +304,7 @@ describe('showcase: persona × CRUD-cell matrix (#9481)', () => { }; beforeAll(async () => { - stack = await bootStack(showcaseStack, { security: showcaseAppDefaultSecurity() }); + stack = await bootShowcase({ security: showcaseAppDefaultSecurity() }); adminTok = await stack.signIn(); ql = await stack.kernel.getServiceAsync('objectql'); diff --git a/packages/qa/dogfood/test/showcase-d3-d4-capabilities.dogfood.test.ts b/packages/qa/dogfood/test/showcase-d3-d4-capabilities.dogfood.test.ts index f3d64273039..8133cba650e 100644 --- a/packages/qa/dogfood/test/showcase-d3-d4-capabilities.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-d3-d4-capabilities.dogfood.test.ts @@ -20,8 +20,8 @@ // @proof: showcase-d3-d4-capabilities import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; import { PermissionSetSchema } from '@objectstack/spec/security'; @@ -54,7 +54,7 @@ describe('showcase: D3 compound sharing (#1887) + D4 RLS check', () => { let invId: string; beforeAll(async () => { - stack = await bootStack(showcaseStack, { + stack = await bootShowcase({ security: new SecurityPlugin({ defaultPermissionSets: [...securityDefaultPermissionSets, memberSet], fallbackPermissionSet: 'showcase_d34_member', diff --git a/packages/qa/dogfood/test/showcase-d7-default-profile.dogfood.test.ts b/packages/qa/dogfood/test/showcase-d7-default-profile.dogfood.test.ts index 102e48b1ee8..a76c85fd94c 100644 --- a/packages/qa/dogfood/test/showcase-d7-default-profile.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-d7-default-profile.dogfood.test.ts @@ -38,7 +38,8 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { SecurityPlugin, appDefaultPermissionSetName } from '@objectstack/plugin-security'; // Mirror the CLI: pull the app-declared default profile's name off the stack @@ -61,7 +62,7 @@ describe('showcase: app-declared default profile, CLI-wired (ADR-0056 D7)', () = // `defaultPermissionSets` — that declares one permission set under two // packages, and the boot is refused (`NAMESPACE_CONFLICT`, both holders // named): a permission set holds one name per deployment. - stack = await bootStack(showcaseStack, { + stack = await bootShowcase({ security: new SecurityPlugin({ fallbackPermissionSet: appDefault }), }); await stack.signIn(); diff --git a/packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts b/packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts index e1f4fe59a09..a76e024c228 100644 --- a/packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts @@ -51,7 +51,8 @@ import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { MetadataPlugin } from '@objectstack/metadata'; import { ConnectorRestPlugin } from '@objectstack/connector-rest'; import { ConnectorOpenApiPlugin } from '@objectstack/connector-openapi'; @@ -125,7 +126,7 @@ beforeAll(async () => { writeBuildShapedArtifact(showcaseStack as Record, artifactPath); artifactOnDisk = JSON.parse(readFileSync(artifactPath, 'utf8')) as Record; - stack = await bootStack(showcaseStack, { + stack = await bootShowcase({ // The `flow`-typed endpoint delegates to `IAutomationService.execute`; // without the service the honest answer is a 501, not a flow run. automation: true, @@ -180,11 +181,26 @@ describe('[#6293] the stand-in artifact carries what a built one carries', () => ).toEqual({ handler: 'sweepProjectHealth', effect: 'writes' }); }); + /** + * [#22301] The showcase configuration as `JSON.stringify` reads it, minus its + * `plugins`. `bootStack` now mounts the app's own plugin instances, as + * `objectstack serve` does, and a mounted instance holds a reference into its + * kernel — so after the boot above the whole configuration no longer + * serializes (`Converting circular structure to JSON`). The plugins are live + * instances, not declarations, and no part of what the two cases below + * measure (the `functions` map), so the copy leaves them out rather than + * cloning a kernel. + */ + const serializableShowcase = (): Record => ({ + ...(showcaseStack as Record), + plugins: undefined, + }); + it('the substitute it replaced still drops them — the trap, pinned', () => { // Not a test of `JSON.stringify`: a test of why the helper above exists, // executable at the one call site that was bitten. If this ever stops // holding, the helper's whole premise is up for re-reading. - const naive = JSON.parse(JSON.stringify(showcaseStack)) as Record; + const naive = JSON.parse(JSON.stringify(serializableShowcase())) as Record; expect( naive.functions, 'the bare entry vanishes key and all; the declared one is left a headless husk', @@ -203,7 +219,7 @@ describe('[#6293] the stand-in artifact carries what a built one carries', () => // and the SPEC refuses it, here and in `objectstack build` alike. The // reconciliation stays as the backstop for a producer that starts dropping // again. - const residue = JSON.parse(JSON.stringify(showcaseStack)) as Record; + const residue = JSON.parse(JSON.stringify(serializableShowcase())) as Record; expect(() => buildShapedArtifact(residue)) .toThrowError(/does not satisfy ObjectStackDefinitionSchema[\s\S]*functions/); }); diff --git a/packages/qa/dogfood/test/showcase-declarative-mcp.dogfood.test.ts b/packages/qa/dogfood/test/showcase-declarative-mcp.dogfood.test.ts index 2d654c6a1cc..b72f24228dc 100644 --- a/packages/qa/dogfood/test/showcase-declarative-mcp.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-declarative-mcp.dogfood.test.ts @@ -17,8 +17,8 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { fileURLToPath } from 'node:url'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { ConnectorMcpPlugin } from '@objectstack/connector-mcp'; import { ConnectorOpenApiPlugin } from '@objectstack/connector-openapi'; import { ConnectorRestPlugin } from '@objectstack/connector-rest'; @@ -44,7 +44,7 @@ describe('showcase declarative MCP connector — ADR-0097 §6 acceptance (#3056) // The three generic executors, exactly as objectstack.config.ts wires // them (bootStack does not register a stack's `plugins:` — it mirrors // the service pairs only — so the harness injects them here). - stack = await bootStack(showcaseStack, { + stack = await bootShowcase({ automation: true, extraPlugins: [ new ConnectorRestPlugin(), diff --git a/packages/qa/dogfood/test/showcase-default-profile.dogfood.test.ts b/packages/qa/dogfood/test/showcase-default-profile.dogfood.test.ts index fd19705a779..d6125d19c88 100644 --- a/packages/qa/dogfood/test/showcase-default-profile.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-default-profile.dogfood.test.ts @@ -40,8 +40,8 @@ // authz-row: default-profile import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; import { PermissionSetSchema } from '@objectstack/spec/security'; @@ -60,7 +60,7 @@ describe('showcase: app-declared default profile (ADR-0056 D7)', () => { let memberToken: string; beforeAll(async () => { - stack = await bootStack(showcaseStack, { + stack = await bootShowcase({ // NOTE: no `fallbackPermissionSet` passed — it MUST resolve from `isDefault`. security: new SecurityPlugin({ defaultPermissionSets: [...securityDefaultPermissionSets, demoDefault], diff --git a/packages/qa/dogfood/test/showcase-demo-personas-loginable.dogfood.test.ts b/packages/qa/dogfood/test/showcase-demo-personas-loginable.dogfood.test.ts index e1f6f4d7f15..ac7958d37a3 100644 --- a/packages/qa/dogfood/test/showcase-demo-personas-loginable.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-demo-personas-loginable.dogfood.test.ts @@ -57,7 +57,7 @@ // ## Why this boot passes `onEnable` // // The persona bootstrap is an `onEnable` → `kernel:bootstrapped` hook, and -// `bootStack(showcaseStack)` passes only the DEFAULT export, so the hook never +// `bootShowcase()` passes only the DEFAULT export, so the hook never // runs in the ordinary dogfood boot. Spreading the stack and re-attaching // `onEnable` is what makes this boot the one a `pnpm dev:showcase` operator // actually gets (`AppPlugin` resolves the hook owner off the bundle). @@ -70,7 +70,9 @@ import { AUDITOR_DEMO_USER, DEMO_PERSONA_PASSWORD, } from '@objectstack/example-showcase/security-personas'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; +import { bootShowcase } from './showcase-boot.js'; import { showcaseAppDefaultSecurity } from './showcase-security.js'; const SYS = { isSystem: true } as const; @@ -99,7 +101,7 @@ describe('showcase demo personas are real logins (#9308 fixture 1)', () => { )[0]; beforeAll(async () => { - stack = await bootStack(showcaseBundleWithHook, { security: showcaseAppDefaultSecurity() }); + stack = await bootShowcase({ security: showcaseAppDefaultSecurity() }, showcaseBundleWithHook); await stack.signIn(); ql = await stack.kernel.getServiceAsync('objectql'); adminId = String((await userByEmail(ADMIN_EMAIL))?.id ?? ''); diff --git a/packages/qa/dogfood/test/showcase-demo-personas-membership.dogfood.test.ts b/packages/qa/dogfood/test/showcase-demo-personas-membership.dogfood.test.ts index a97dd70287c..2699d8f6084 100644 --- a/packages/qa/dogfood/test/showcase-demo-personas-membership.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-demo-personas-membership.dogfood.test.ts @@ -13,7 +13,8 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import showcaseStack, { onEnable } from '@objectstack/example-showcase'; import { ADMIN_EMAIL, PHONE_DEMO_USER, AUDITOR_DEMO_USER } from '@objectstack/example-showcase/security-personas'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { showcaseAppDefaultSecurity } from './showcase-security.js'; const SYS = { isSystem: true } as const; @@ -40,7 +41,7 @@ describe('showcase demo personas are created into the admin\'s organization (ADR }; beforeAll(async () => { - stack = await bootStack(showcaseBundleWithHook, { security: showcaseAppDefaultSecurity(), orgContext: true }); + stack = await bootShowcase({ security: showcaseAppDefaultSecurity(), orgContext: true }, showcaseBundleWithHook); await stack.signIn(); ql = await stack.kernel.getServiceAsync('objectql'); }, 300_000); diff --git a/packages/qa/dogfood/test/showcase-expand-crud-gate.dogfood.test.ts b/packages/qa/dogfood/test/showcase-expand-crud-gate.dogfood.test.ts index a4722deac85..7cedd4273ba 100644 --- a/packages/qa/dogfood/test/showcase-expand-crud-gate.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-expand-crud-gate.dogfood.test.ts @@ -33,8 +33,8 @@ // unregistered orphan to `proof-registry.mts`. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { showcaseAppDefaultSecurity } from './showcase-security.js'; const SYS = { isSystem: true } as const; @@ -56,7 +56,7 @@ describe('showcase: $expand honours the referenced object’s CRUD gate + OWD (# let foreignInvoiceId: string; beforeAll(async () => { - stack = await bootStack(showcaseStack, { security: showcaseAppDefaultSecurity() }); + stack = await bootShowcase({ security: showcaseAppDefaultSecurity() }); adminTok = await stack.signIn(); contribTok = await stack.signUp(CONTRIB_EMAIL); ql = await stack.kernel.getServiceAsync('objectql'); diff --git a/packages/qa/dogfood/test/showcase-external-autoconnect.dogfood.test.ts b/packages/qa/dogfood/test/showcase-external-autoconnect.dogfood.test.ts index 4ec39ddc42d..b38b69f7890 100644 --- a/packages/qa/dogfood/test/showcase-external-autoconnect.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-external-autoconnect.dogfood.test.ts @@ -9,7 +9,8 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import showcaseStack, { onEnable } from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; function listOf(body: unknown): Array> { const b = body as { records?: unknown[]; data?: unknown[] } | unknown[]; @@ -28,7 +29,7 @@ describe('showcase: external datasource auto-connects with no onEnable bridge (A // tables, exactly as `os dev` does at boot. Crucially this does NOT register a // driver (ADR-0062 D8); auto-connect (below, during bootStack) does that. await onEnable({ logger: { info() {}, warn() {} } } as never); - stack = await bootStack(showcaseStack); + stack = await bootShowcase(); admin = await stack.signIn(); }, 60_000); @@ -46,7 +47,7 @@ describe('showcase: external datasource auto-connects with no onEnable bridge (A // at boot with no `onEnable` driver bridge, and its federated objects // answer a genuine authenticated read through the real REST stack. // - // What it does NOT cover: the organization wall. `bootStack(showcaseStack)` + // What it does NOT cover: the organization wall. `bootShowcase()` // above passes NO options, and `bootStack` requests // `OS_TENANCY_POSTURE = 'isolated'` only when `opts.multiTenant` is truthy // (`packages/verify/src/harness.ts`, `requestIsolatedPosture`), and it diff --git a/packages/qa/dogfood/test/showcase-fls-read-mask-strip.dogfood.test.ts b/packages/qa/dogfood/test/showcase-fls-read-mask-strip.dogfood.test.ts index 9ce0ac04149..4de8520e3a3 100644 --- a/packages/qa/dogfood/test/showcase-fls-read-mask-strip.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-fls-read-mask-strip.dogfood.test.ts @@ -71,8 +71,8 @@ // @proof: showcase-fls-read-mask-strip import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { showcaseAppDefaultSecurity } from './showcase-security.js'; const SYS = { isSystem: true } as const; @@ -102,7 +102,7 @@ describe('showcase FLS read side: a readable:false field is STRIPPED, not masked let storedSpent: number; beforeAll(async () => { - stack = await bootStack(showcaseStack, { security: showcaseAppDefaultSecurity() }); + stack = await bootShowcase({ security: showcaseAppDefaultSecurity() }); adminTok = await stack.signIn(); memberTok = await stack.signUp(MEMBER); ql = await stack.kernel.getServiceAsync('objectql'); diff --git a/packages/qa/dogfood/test/showcase-invoice-cbp.dogfood.test.ts b/packages/qa/dogfood/test/showcase-invoice-cbp.dogfood.test.ts index 38ce88fdee6..5d04c370d70 100644 --- a/packages/qa/dogfood/test/showcase-invoice-cbp.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-invoice-cbp.dogfood.test.ts @@ -11,8 +11,8 @@ // is then exercised as a real member over HTTP. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; import { PermissionSetSchema, RLS } from '@objectstack/spec/security'; @@ -44,7 +44,7 @@ describe('showcase: invoice-line controlled-by-parent (ADR-0055)', () => { let memberLineId: string; beforeAll(async () => { - stack = await bootStack(showcaseStack, { + stack = await bootShowcase({ security: new SecurityPlugin({ defaultPermissionSets: [...securityDefaultPermissionSets, memberSet], fallbackPermissionSet: 'showcase_cbp_member', diff --git a/packages/qa/dogfood/test/showcase-invoice-seed-isolation.dogfood.test.ts b/packages/qa/dogfood/test/showcase-invoice-seed-isolation.dogfood.test.ts index b5534debaf9..c404c9ef28a 100644 --- a/packages/qa/dogfood/test/showcase-invoice-seed-isolation.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-invoice-seed-isolation.dogfood.test.ts @@ -27,8 +27,8 @@ // matches the seeded `owner` values. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { showcaseAppDefaultSecurity } from './showcase-security.js'; const SYS = { isSystem: true } as const; @@ -94,7 +94,7 @@ describe('showcase: seeded invoice/line owner isolation on the shipped contribut }; beforeAll(async () => { - stack = await bootStack(showcaseStack, { security: showcaseAppDefaultSecurity() }); + stack = await bootShowcase({ security: showcaseAppDefaultSecurity() }); await stack.signIn(); ql = await stack.kernel.getServiceAsync('objectql'); diff --git a/packages/qa/dogfood/test/showcase-mcp-http-identity.dogfood.test.ts b/packages/qa/dogfood/test/showcase-mcp-http-identity.dogfood.test.ts index f7bef824e00..7a9b8a40112 100644 --- a/packages/qa/dogfood/test/showcase-mcp-http-identity.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-mcp-http-identity.dogfood.test.ts @@ -18,8 +18,8 @@ // this test would see cross-owner rows and FAIL. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { showcaseAppDefaultSecurity } from './showcase-security.js'; import { MCPServerPlugin } from '@objectstack/mcp'; @@ -73,7 +73,7 @@ describe('showcase: MCP HTTP surface is identity-admitted (ADR-0096 / #3167)', ( // [#5491] Under the app's OWN declared default profile — the platform // baseline no longer carries a `'*'` grant, so alice and bob need the // showcase's `showcase_private_note` declaration to own a note at all. - stack = await bootStack(showcaseStack, { + stack = await bootShowcase({ extraPlugins: [new MCPServerPlugin()], security: showcaseAppDefaultSecurity(), }); diff --git a/packages/qa/dogfood/test/showcase-mcp-self-connection.dogfood.test.ts b/packages/qa/dogfood/test/showcase-mcp-self-connection.dogfood.test.ts index ea8662514fa..428c0f57761 100644 --- a/packages/qa/dogfood/test/showcase-mcp-self-connection.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-mcp-self-connection.dogfood.test.ts @@ -17,8 +17,8 @@ // separate, carefully-gated follow-up. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { MCPServerPlugin } from '@objectstack/mcp'; import { createMcpConnector } from '@objectstack/connector-mcp'; @@ -30,7 +30,7 @@ describe('showcase: the platform connects to its OWN MCP endpoint (#3167 self-co beforeAll(async () => { // Serve side up (isMcpServerEnabled default-on; the lean harness injects the // plugin the way `os dev`/`serve` auto-load it). - stack = await bootStack(showcaseStack, { extraPlugins: [new MCPServerPlugin()] }); + stack = await bootShowcase({ extraPlugins: [new MCPServerPlugin()] }); const adminToken = await stack.signIn(); // Mint an osk_ key — the self-connection's identity (acts AS the admin caller). diff --git a/packages/qa/dogfood/test/showcase-object-extension-meta-read.dogfood.test.ts b/packages/qa/dogfood/test/showcase-object-extension-meta-read.dogfood.test.ts index 27c45d8797a..b96db5ffa83 100644 --- a/packages/qa/dogfood/test/showcase-object-extension-meta-read.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-object-extension-meta-read.dogfood.test.ts @@ -35,7 +35,8 @@ import { join } from 'node:path'; import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { MetadataPlugin } from '@objectstack/metadata'; import { writeBuildShapedArtifact } from './build-shaped-artifact.js'; @@ -64,7 +65,7 @@ describe('dogfood: an object extension reaches every /meta read (#7556)', () => // what it advertises (commit c39a911ae). writeBuildShapedArtifact(showcaseStack as unknown as Record, artifactPath); - stack = await bootStack(showcaseStack, { + stack = await bootShowcase({ extraPlugins: [ new MetadataPlugin({ rootDir: tempDir, diff --git a/packages/qa/dogfood/test/showcase-object-extension-scalar-divergence.dogfood.test.ts b/packages/qa/dogfood/test/showcase-object-extension-scalar-divergence.dogfood.test.ts index 2d159ac9963..98b770d1027 100644 --- a/packages/qa/dogfood/test/showcase-object-extension-scalar-divergence.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-object-extension-scalar-divergence.dogfood.test.ts @@ -99,7 +99,8 @@ import { join } from 'node:path'; import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { MetadataPlugin } from '@objectstack/metadata'; import { writeBuildShapedArtifact } from './build-shaped-artifact.js'; @@ -133,7 +134,7 @@ describe('dogfood: the object-extension fold and the i18n catalog disagree on sc // Boots from a COMPILED ARTIFACT, whose `objects` and `objectExtensions` // are separate collections — the deployment shape, and the only one on // which this family of defects is observable at all. - stack = await bootStack(showcaseStack, { + stack = await bootShowcase({ extraPlugins: [ new MetadataPlugin({ rootDir: tempDir, diff --git a/packages/qa/dogfood/test/showcase-permission-projection.dogfood.test.ts b/packages/qa/dogfood/test/showcase-permission-projection.dogfood.test.ts index 342b8ccd99c..0c88b8c2dbc 100644 --- a/packages/qa/dogfood/test/showcase-permission-projection.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-permission-projection.dogfood.test.ts @@ -31,8 +31,8 @@ // the `allowRuntimeCreate` tier — see two-doors-permission 块2.) import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; describe('sys_permission_set pure projection (ADR-0094)', () => { let stack: VerifyStack; @@ -41,7 +41,7 @@ describe('sys_permission_set pure projection (ADR-0094)', () => { let adminToken: string; beforeAll(async () => { - stack = await bootStack(showcaseStack); + stack = await bootShowcase(); adminToken = await stack.signIn(); ql = await stack.kernel.getServiceAsync('objectql'); protocol = await stack.kernel.getServiceAsync('protocol'); diff --git a/packages/qa/dogfood/test/showcase-permission-seeding.dogfood.test.ts b/packages/qa/dogfood/test/showcase-permission-seeding.dogfood.test.ts index 7d27aa2ce7f..251657ca92e 100644 --- a/packages/qa/dogfood/test/showcase-permission-seeding.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-permission-seeding.dogfood.test.ts @@ -13,15 +13,15 @@ // authz-row: declarative-permission-seeding import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; describe('showcase: declared permission-set seeding (ADR-0086 D5)', () => { let stack: VerifyStack; let ql: any; beforeAll(async () => { - stack = await bootStack(showcaseStack); + stack = await bootShowcase(); await stack.signIn(); ql = await stack.kernel.getServiceAsync('objectql'); }, 60_000); diff --git a/packages/qa/dogfood/test/showcase-public-form-redirect.dogfood.test.ts b/packages/qa/dogfood/test/showcase-public-form-redirect.dogfood.test.ts index bc21624206c..e13bedb3a58 100644 --- a/packages/qa/dogfood/test/showcase-public-form-redirect.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-public-form-redirect.dogfood.test.ts @@ -35,8 +35,8 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; import { createConsoleStaticPlugin } from '../../../cli/src/utils/console.js'; @@ -74,7 +74,7 @@ afterAll(() => { }); const boot = (extra: { multiTenant?: 'posture-only' } = {}): Promise => - bootStack(showcaseStack, { + bootShowcase({ ...extra, security: new SecurityPlugin({ defaultPermissionSets: [...securityDefaultPermissionSets] }), extraPlugins: [createConsoleStaticPlugin(distPath)], diff --git a/packages/qa/dogfood/test/showcase-public-form-walled-intake.dogfood.test.ts b/packages/qa/dogfood/test/showcase-public-form-walled-intake.dogfood.test.ts index cb40752e185..a348eccb9b8 100644 --- a/packages/qa/dogfood/test/showcase-public-form-walled-intake.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-public-form-walled-intake.dogfood.test.ts @@ -20,8 +20,8 @@ // `public-form-withdrawal-walled.dogfood.test.ts`. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; const VIEW = '/meta/view/showcase_inquiry.contact'; @@ -65,7 +65,7 @@ describe('showcase, walled posture: the public contact form is not offered, and }; beforeAll(async () => { - stack = await bootStack(showcaseStack, { + stack = await bootShowcase({ multiTenant: 'posture-only', security: new SecurityPlugin({ defaultPermissionSets: [...securityDefaultPermissionSets] }), }); diff --git a/packages/qa/dogfood/test/showcase-public-form-withdrawal-layers.dogfood.test.ts b/packages/qa/dogfood/test/showcase-public-form-withdrawal-layers.dogfood.test.ts index 449ea548bfa..1498b9088b5 100644 --- a/packages/qa/dogfood/test/showcase-public-form-withdrawal-layers.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-public-form-withdrawal-layers.dogfood.test.ts @@ -21,8 +21,8 @@ // - open at both layers (control): both doors accept. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; const VIEW = '/meta/view/showcase_inquiry.contact'; @@ -76,7 +76,7 @@ describe('showcase: a public form withdrawal at any metadata layer holds', () => }; beforeAll(async () => { - stack = await bootStack(showcaseStack, { + stack = await bootShowcase({ orgContext: true, security: new SecurityPlugin({ defaultPermissionSets: [...securityDefaultPermissionSets] }), }); diff --git a/packages/qa/dogfood/test/showcase-public-form-withdrawal.dogfood.test.ts b/packages/qa/dogfood/test/showcase-public-form-withdrawal.dogfood.test.ts index 99761833b2b..e8f14b855aa 100644 --- a/packages/qa/dogfood/test/showcase-public-form-withdrawal.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-public-form-withdrawal.dogfood.test.ts @@ -21,8 +21,8 @@ // organization republish the row lands in that organization. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; const VIEW = '/meta/view/showcase_inquiry.contact'; @@ -77,7 +77,7 @@ describe('showcase: withdrawing the public contact form closes every intake door }; beforeAll(async () => { - stack = await bootStack(showcaseStack, { + stack = await bootShowcase({ orgContext: true, security: new SecurityPlugin({ defaultPermissionSets: [...securityDefaultPermissionSets] }), }); diff --git a/packages/qa/dogfood/test/showcase-public-form.dogfood.test.ts b/packages/qa/dogfood/test/showcase-public-form.dogfood.test.ts index 49a1f176ff4..aebdfa4b70f 100644 --- a/packages/qa/dogfood/test/showcase-public-form.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-public-form.dogfood.test.ts @@ -17,15 +17,15 @@ // authz-row: public-form-managed-anchors import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; describe('showcase: web-to-lead public form (ADR-0056 Option A)', () => { let stack: VerifyStack; beforeAll(async () => { - stack = await bootStack(showcaseStack, { + stack = await bootShowcase({ security: new SecurityPlugin({ defaultPermissionSets: [...securityDefaultPermissionSets], }), diff --git a/packages/qa/dogfood/test/showcase-scope-depth-fallback.dogfood.test.ts b/packages/qa/dogfood/test/showcase-scope-depth-fallback.dogfood.test.ts index f30950e0c84..33d70d40866 100644 --- a/packages/qa/dogfood/test/showcase-scope-depth-fallback.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-scope-depth-fallback.dogfood.test.ts @@ -19,8 +19,8 @@ // @proof: showcase-scope-depth-fallback import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { SecurityPlugin, appDefaultPermissionSetName } from '@objectstack/plugin-security'; const OBJ = '/data/showcase_private_note'; @@ -51,7 +51,7 @@ interface World { stack: VerifyStack; tokens: Record; } // into `sys_permission_set` (the runtime home of an app-declared `permission`) // and reached only by NAME via `fallbackPermissionSet`. async function bootFallbackWorld(withResolver = true): Promise { - const stack = await bootStack(showcaseStack, { + const stack = await bootShowcase({ // Mirror the CLI exactly: the app's isDefault profile name, computed off the // declared `permissions[]`, handed to SecurityPlugin as the fallback. No // `defaultPermissionSets` carry the scope profile — it must resolve from DB. diff --git a/packages/qa/dogfood/test/showcase-scope-depth-write.dogfood.test.ts b/packages/qa/dogfood/test/showcase-scope-depth-write.dogfood.test.ts index 6b91c7c954a..768df95c32c 100644 --- a/packages/qa/dogfood/test/showcase-scope-depth-write.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-scope-depth-write.dogfood.test.ts @@ -25,8 +25,8 @@ // @proof: showcase-scope-depth-write import { describe, it, expect, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; import { PermissionSetSchema } from '@objectstack/spec/security'; @@ -64,7 +64,7 @@ interface World { // Each owns one note. Reference hierarchy resolver as in showcase-scope-depth // (test fixture for the enterprise seam) — only the 'unit' branch is needed. async function bootWriteWorld(tag: string, opts: { writeScope?: 'unit'; withResolver?: boolean }): Promise { - const stack = await bootStack(showcaseStack, { + const stack = await bootShowcase({ security: new SecurityPlugin({ defaultPermissionSets: [...securityDefaultPermissionSets, writeProfile(tag, opts.writeScope)], }), diff --git a/packages/qa/dogfood/test/showcase-scope-depth.dogfood.test.ts b/packages/qa/dogfood/test/showcase-scope-depth.dogfood.test.ts index 4cf33775234..86fc566e7cb 100644 --- a/packages/qa/dogfood/test/showcase-scope-depth.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-scope-depth.dogfood.test.ts @@ -20,8 +20,8 @@ // authz-row: scope-depth import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; import { PermissionSetSchema } from '@objectstack/spec/security'; // [#6139] The reference resolver is typed against the REAL contract, not `any`. @@ -58,7 +58,7 @@ interface World { stack: VerifyStack; tokens: Record; } // Build a BU world: bu_parent ⊃ bu_child (sibling bu_other is separate). // alice+carol ∈ bu_parent, bob ∈ bu_child, dave ∈ bu_other. Each owns one note. async function bootScopeWorld(scope: 'unit' | 'unit_and_below' | 'own_and_reports', withResolver = true): Promise { - const stack = await bootStack(showcaseStack, { + const stack = await bootShowcase({ security: new SecurityPlugin({ defaultPermissionSets: [...securityDefaultPermissionSets, scopeProfile(scope)], }), diff --git a/packages/qa/dogfood/test/single-tenant-identity-create.dogfood.test.ts b/packages/qa/dogfood/test/single-tenant-identity-create.dogfood.test.ts index 49bfed71627..431a914bffd 100644 --- a/packages/qa/dogfood/test/single-tenant-identity-create.dogfood.test.ts +++ b/packages/qa/dogfood/test/single-tenant-identity-create.dogfood.test.ts @@ -23,15 +23,15 @@ */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; describe('ADR-0057: org-scoped identity creatable single-tenant', () => { let stack: VerifyStack; let token: string; beforeAll(async () => { - stack = await bootStack(showcaseStack, {}); // single-tenant: no org-scoping; the Default Organization exists from the boot + stack = await bootShowcase({}); // single-tenant: no org-scoping; the Default Organization exists from the boot token = await stack.signIn(); }, 120_000); diff --git a/packages/qa/dogfood/test/storage-growth.dogfood.test.ts b/packages/qa/dogfood/test/storage-growth.dogfood.test.ts index 54cf97ca3b1..97eccd4e820 100644 --- a/packages/qa/dogfood/test/storage-growth.dogfood.test.ts +++ b/packages/qa/dogfood/test/storage-growth.dogfood.test.ts @@ -23,8 +23,8 @@ // and it exercises the exact path the Reaper sweeps. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; -import showcaseStack from '@objectstack/example-showcase'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; // [#10126] Pay the first transform of these dist-resolved workspace deps at MODULE // LOAD. Each is reached below through a dynamic `import()` inside an `it()` body or a @@ -74,7 +74,7 @@ describe('objectstack verify LIFECYCLE (ADR-0057): declared policies bound growt const backdated = (days: number) => new Date(Date.now() - days * DAY_MS); beforeAll(async () => { - stack = await bootStack(showcaseStack); + stack = await bootShowcase(); engine = stack.kernel.getService('objectql') as unknown as EngineLike; lifecycle = stack.kernel.getService('lifecycle') as unknown as LifecycleLike; expect(lifecycle?.sweep, 'the ObjectQLPlugin must register the ADR-0057 lifecycle service').toBeTruthy(); diff --git a/packages/qa/dogfood/test/temporal-storage-e2e.dogfood.test.ts b/packages/qa/dogfood/test/temporal-storage-e2e.dogfood.test.ts index 922bc133917..ae29dad4a5e 100644 --- a/packages/qa/dogfood/test/temporal-storage-e2e.dogfood.test.ts +++ b/packages/qa/dogfood/test/temporal-storage-e2e.dogfood.test.ts @@ -33,8 +33,8 @@ // it tolerates other suites' data. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; const P = 'tstor'; // name prefix — every row this file owns @@ -78,7 +78,7 @@ describe('dogfood: temporal storage is one shape end-to-end (#3912/#3994/#4033)' let masterId: string; beforeAll(async () => { - stack = await bootStack(showcaseStack); + stack = await bootShowcase(); token = await stack.signIn(); // `showcase_field_zoo.f_master_detail` is a REQUIRED master_detail, so diff --git a/packages/qa/dogfood/test/two-factor-backup-code-reveal.dogfood.test.ts b/packages/qa/dogfood/test/two-factor-backup-code-reveal.dogfood.test.ts index 7cd49e2e032..4c59cf215a1 100644 --- a/packages/qa/dogfood/test/two-factor-backup-code-reveal.dogfood.test.ts +++ b/packages/qa/dogfood/test/two-factor-backup-code-reveal.dogfood.test.ts @@ -39,8 +39,8 @@ */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { SysUser } from '@objectstack/platform-objects'; import { secretFromTotpUri, totp } from './totp.js'; @@ -97,7 +97,7 @@ describe('#10681 — the declared reveal resolves against the live response', () priorTwoFactor = process.env.OS_AUTH_TWO_FACTOR; process.env.OS_AUTH_TWO_FACTOR = 'true'; - stack = await bootStack(showcaseStack, {}); + stack = await bootShowcase({}); ql = await stack.kernel.getServiceAsync('objectql'); token = await stack.signIn(); diff --git a/packages/qa/dogfood/test/two-factor-lockout.dogfood.test.ts b/packages/qa/dogfood/test/two-factor-lockout.dogfood.test.ts index d010a67a8e1..2c256bcafc1 100644 --- a/packages/qa/dogfood/test/two-factor-lockout.dogfood.test.ts +++ b/packages/qa/dogfood/test/two-factor-lockout.dogfood.test.ts @@ -44,8 +44,8 @@ */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { assertArmed, authSettingArmed } from './armed.js'; import { secretFromTotpUri, totp } from './totp.js'; @@ -85,7 +85,7 @@ describe('#3624 follow-up: better-auth 2FA lockout counts wrong codes', () => { priorTwoFactor = process.env.OS_AUTH_TWO_FACTOR; process.env.OS_AUTH_TWO_FACTOR = 'true'; - stack = await bootStack(showcaseStack, {}); + stack = await bootShowcase({}); ql = await stack.kernel.getServiceAsync('objectql'); // [#3690] Apply the operator policy the same way the settings service does diff --git a/packages/qa/dogfood/test/view-container-cross-package-default.dogfood.test.ts b/packages/qa/dogfood/test/view-container-cross-package-default.dogfood.test.ts index 15cf7439c29..04f246d2877 100644 --- a/packages/qa/dogfood/test/view-container-cross-package-default.dogfood.test.ts +++ b/packages/qa/dogfood/test/view-container-cross-package-default.dogfood.test.ts @@ -35,8 +35,8 @@ // in-process, in `packages/metadata-protocol/src/view-container-runtime-expansion.test.ts`. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; const OBJECT = 'showcase_task'; const DEFAULT = `${OBJECT}.default`; @@ -62,7 +62,7 @@ describe('dogfood: a bare-list container on another package\'s object leaves its let token: string; beforeAll(async () => { - stack = await bootStack(showcaseStack); + stack = await bootShowcase(); token = await stack.signIn(); }, 180_000); diff --git a/packages/qa/dogfood/test/write-door-unreadable-is-not-found.dogfood.test.ts b/packages/qa/dogfood/test/write-door-unreadable-is-not-found.dogfood.test.ts index f7c4dd71e9d..11009a1e4c2 100644 --- a/packages/qa/dogfood/test/write-door-unreadable-is-not-found.dogfood.test.ts +++ b/packages/qa/dogfood/test/write-door-unreadable-is-not-found.dogfood.test.ts @@ -44,29 +44,40 @@ import { bootStack, type VerifyStack } from '@objectstack/verify'; import { StorageServicePlugin } from '@objectstack/service-storage'; import { AuditPlugin } from '@objectstack/plugin-audit'; import { - AttCase, - AttSecret, - AttReadonly, + buildAttCase, + buildAttSecret, + buildAttReadonly, attFixtureBaselineSet, attachmentManagerSet, } from './fixtures/attachments-fixture.js'; -import { CmtOpen, CmtPrivate, CmtReadonly, commentManagerSet } from './fixtures/comments-fixture.js'; +import { buildCmtOpen, buildCmtPrivate, buildCmtReadonly, commentManagerSet } from './fixtures/comments-fixture.js'; import { armedWhen, assertArmed, leaveOrganization, principalArmed, resolveAuthzFor } from './armed.js'; const SYS = { isSystem: true } as const; const RECORD_SENTENCE = BUILTIN_OPERATION_MESSAGES.en.record_access_denied!; -const stackDefinition = defineStack({ - manifest: { - id: 'com.dogfood.write-door-unreadable-is-not-found', - version: '0.0.0', - type: 'app', - name: 'Write door: an unreadable row is a missing row', - description: - 'Open, private and read-only parents for sys_attachment and sys_comment: the by-id write answer for a row the caller cannot read, beside the answer for a missing id.', - }, - objects: [AttCase, AttSecret, AttReadonly, CmtOpen, CmtPrivate, CmtReadonly], -}); +/** + * [#22301] A BUILDER, called once per boot: this file keeps two stacks live at + * once (outside and inside the organization), and `bootStack`'s instance rule + * refuses a second live boot of one configuration object. A shallow copy would + * not be a configuration of its own — a boot keeps live references into the + * nested definitions it registers (the registry stores each object as a shallow + * copy whose field definitions are the authored objects: + * `packages/objectql/src/registry.ts`, `definition: { ...schema, name: fqn }`) — + * so every nested definition is built again. + */ +const buildStackDefinition = () => + defineStack({ + manifest: { + id: 'com.dogfood.write-door-unreadable-is-not-found', + version: '0.0.0', + type: 'app', + name: 'Write door: an unreadable row is a missing row', + description: + 'Open, private and read-only parents for sys_attachment and sys_comment: the by-id write answer for a row the caller cannot read, beside the answer for a missing id.', + }, + objects: [buildAttCase(), buildAttSecret(), buildAttReadonly(), buildCmtOpen(), buildCmtPrivate(), buildCmtReadonly()], + }); function security(): SecurityPlugin { return new SecurityPlugin({ @@ -98,7 +109,7 @@ interface Booted { */ async function boot(inside: boolean, email: string): Promise { const rootDir = mkdtempSync(join(tmpdir(), 'write-door-nf-')); - const stack = await bootStack(stackDefinition as never, { + const stack = await bootStack(buildStackDefinition() as never, { orgContext: inside, security: security(), extraPlugins: [ diff --git a/packages/verify/src/handle.test.ts b/packages/verify/src/handle.test.ts index f976b5637a8..df66cfeb96e 100644 --- a/packages/verify/src/handle.test.ts +++ b/packages/verify/src/handle.test.ts @@ -17,7 +17,7 @@ // permission check at all), so it is pinned with a control that FIRES: the // admin, on the identical call, is admitted by both doors. -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import { bootStack, bootStackOnce, type VerifyStack, type BootOptions } from './harness.js'; import { isVerifyRefusal } from './handle.js'; @@ -51,6 +51,22 @@ afterAll(async () => { await stack?.stop().catch(() => undefined); }); +/** + * [#22301] A configuration of its own, for a stack that must be live BESIDE + * this file's shared boot. `bootStack`'s instance rule refuses a second live + * boot of one configuration object, and a shallow `{ ...handleFixtureStack }` + * copy would not be a configuration of its own: a boot keeps live references + * into the configuration's nested definitions (the registry stores each object + * as a shallow copy whose field definitions are the authored objects — + * `packages/objectql/src/registry.ts`, `definition: { ...schema, name: fqn }`). + * So the fixture is BUILT AGAIN: a fresh module instance of `./handle.fixture.js` + * runs its builder once more, and every nested definition is new. + */ +async function freshHandleFixtureStack(): Promise { + vi.resetModules(); + return (await import('./handle.fixture.js')).handleFixtureStack; +} + /** Unique per run, so list assertions never see another test's rows. */ const uniq = (prefix: string): string => `${prefix}-${Math.random().toString(36).slice(2, 8)}`; @@ -337,7 +353,7 @@ describe('tenancy — the service AuthPlugin registered', () => { // breaks the service's isolation probe turns this red while leaving the // `single` case above green. it('reports the walled posture a multi-tenant boot runs under — same reader, other stack', async () => { - const walled = await bootStack(handleFixtureStack, { multiTenant: 'posture-only' }); + const walled = await bootStack(await freshHandleFixtureStack(), { multiTenant: 'posture-only' }); try { const t = walled.tenancy(); expect(t.requestedPosture).toBe('isolated'); @@ -361,8 +377,11 @@ describe('bootStackOnce — one boot per (config, opts) identity', () => { expect(() => bootStackOnce('not-a-config' as never)).toThrow(/identity/); }); - it('bootStack (unshared) still returns a distinct stack', async () => { - const other = await bootStack(handleFixtureStack, { automation: true }); + it('bootStack (unshared) still returns a distinct stack — on a configuration of its own', async () => { + // [#22301] Not on `handleFixtureStack` itself: this file's shared boot of + // it is live, and the instance rule refuses a second live boot of one + // configuration (`harness.one-composition.test.ts` pins the refusal). + const other = await bootStack(await freshHandleFixtureStack(), { automation: true }); try { expect(other).not.toBe(stack); expect(typeof other.hooks.run).toBe('function'); diff --git a/packages/verify/src/harness.app-default-profile.test.ts b/packages/verify/src/harness.app-default-profile.test.ts index 2e28b8222fa..663c0658491 100644 --- a/packages/verify/src/harness.app-default-profile.test.ts +++ b/packages/verify/src/harness.app-default-profile.test.ts @@ -76,27 +76,43 @@ const memo = (namespace: string) => * `isDefault` set is the `everyone` baseline suggestion (ADR-0090 D5) and the * D7 anchor gate refuses high-privilege bits on one. */ -const AppDefaultProfile = PermissionSetSchema.parse({ - name: APP_DEFAULT_SET, - label: 'App Member (Default)', - isDefault: true, - objects: { - appdefault_memo: { allowRead: true, allowCreate: true }, - }, -}); +const appDefaultProfile = () => + PermissionSetSchema.parse({ + name: APP_DEFAULT_SET, + label: 'App Member (Default)', + isDefault: true, + objects: { + appdefault_memo: { allowRead: true, allowCreate: true }, + }, + }); -/** Declares an `isDefault` profile — the #5491 migration the CLI already honours. */ -const appWithDeclaredDefault = defineStack({ - manifest: { - id: 'com.example.app-default-profile', - namespace: 'appdefault', - version: '0.0.1', - type: 'app', - name: 'App Default Profile Fixture', - }, - objects: [memo('appdefault')], - permissions: [AppDefaultProfile], -}); +/** + * Declares an `isDefault` profile — the #5491 migration the CLI already honours. + * + * [#22301] A BUILDER, called once per boot. This file keeps every stack it + * boots live until `afterAll`, and `bootStack`'s instance rule refuses a second + * live boot of one configuration object. A shallow `{ ...config }` copy would + * not be a configuration of its own: a boot keeps live references into the + * configuration's nested definitions (the registry stores each object as a + * shallow copy — `packages/objectql/src/registry.ts`, `definition: { ...schema, + * name: fqn }` — whose field definitions are the authored objects). So each boot + * builds every nested definition again. + */ +const buildAppWithDeclaredDefault = () => + defineStack({ + manifest: { + id: 'com.example.app-default-profile', + namespace: 'appdefault', + version: '0.0.1', + type: 'app', + name: 'App Default Profile Fixture', + }, + objects: [memo('appdefault')], + permissions: [appDefaultProfile()], + }); + +/** The declared-default configuration the non-booting cases read. */ +const appWithDeclaredDefault = buildAppWithDeclaredDefault(); /** Declares NO default profile — the shape the vast majority of apps still have. */ const appWithoutDeclaredDefault = defineStack({ @@ -129,7 +145,7 @@ describe('bootStack honours the app-declared default permission set (#7001)', () it( 'wires the SAME profile `serve` wires for the same config', async () => { - const stack = await boot(appWithDeclaredDefault); + const stack = await boot(buildAppWithDeclaredDefault()); // The exact expression `serve.ts` evaluates for its `fallbackPermissionSet`. const servesChoice = appDefaultPermissionSetName(appWithDeclaredDefault.permissions); @@ -146,7 +162,7 @@ describe('bootStack honours the app-declared default permission set (#7001)', () it( 'and that profile is load-bearing: a fresh member holds the declared grants', async () => { - const stack = await boot(appWithDeclaredDefault); + const stack = await boot(buildAppWithDeclaredDefault()); await stack.signIn(); // first user is the seeded dev admin const memberToken = await stack.signUp('appdefault-member@verify.test'); @@ -178,7 +194,7 @@ describe('bootStack honours the app-declared default permission set (#7001)', () // choice rather than the silent default. A caller-supplied plugin wins // whole: it already carries its own constructor options, and quietly // rewriting one of them would be a second, worse surprise. - const stack = await boot(appWithDeclaredDefault, { security: new SecurityPlugin() }); + const stack = await boot(buildAppWithDeclaredDefault(), { security: new SecurityPlugin() }); await expect(wiredBaseline(stack)).resolves.toBe('member_default'); }, BOOT_TIMEOUT, diff --git a/packages/verify/src/harness.one-composition.test.ts b/packages/verify/src/harness.one-composition.test.ts index a0ae939217a..7aff8419c49 100644 --- a/packages/verify/src/harness.one-composition.test.ts +++ b/packages/verify/src/harness.one-composition.test.ts @@ -136,6 +136,15 @@ describe('the instance rule: one live kernel per configuration, per process', () await expect(bootStack({ ...config })).rejects.toMatchObject(refusal); }, BOOT_TIMEOUT); + it('the third remedy: a configuration BUILT AGAIN boots beside a live one', async () => { + // Same app, same options, each configuration built by its own builder call + // (fresh nested definitions, fresh plugin instance) — what the refusal's + // remedy names for a suite that needs two stacks live at once. + const first = keep(await bootStack(app({ plugins: [new ProbePlugin('first')] }))); + const second = keep(await bootStack(app({ plugins: [new ProbePlugin('second')] }))); + expect([probeTag(first), probeTag(second)]).toEqual(['first', 'second']); + }, BOOT_TIMEOUT); + it('a sequential re-boot works: boot, stop, boot', async () => { const own = new ProbePlugin('app'); const config = app({ plugins: [own] }); diff --git a/packages/verify/src/harness.ts b/packages/verify/src/harness.ts index b1dd634e803..c2ea8240b8e 100644 --- a/packages/verify/src/harness.ts +++ b/packages/verify/src/harness.ts @@ -572,9 +572,20 @@ function instanceRuleRefusal(message: string): Error { return Object.assign(new Error(message), { code: 'RESOURCE_CONFLICT', status: 409 }); } +// [#22301] Three remedies, and the third is spelled by a measurement: a boot +// keeps live references into the configuration's nested definitions (the +// registry stores each object as a shallow copy whose field definitions are +// the authored objects — `@objectstack/objectql` `registry.ts`, +// `definition: { ...schema, name: fqn }`; and the engine's `registerApp` writes +// `objDef.name` into a map-form `objects` entry in place). So a `{ ...config }` +// spread is NOT a configuration of its own, and the remedy names building it +// again instead. const INSTANCE_RULE_REMEDY = - 'stop() the live stack before booting this configuration again, or share it: bootStackOnce(config, opts) ' + - 'hands every caller with the same config and options object the one boot.'; + 'stop() the live stack before booting this configuration again; or share it: bootStackOnce(config, opts) ' + + 'hands every caller with the same config and options object the one boot; or, to keep two stacks live at ' + + 'once, boot the second on a configuration of its own, BUILT AGAIN (call its builder once more, or import a ' + + 'fresh module instance of it), never a `{ ...config }` copy, which shares the nested definitions and plugin ' + + 'instances a live boot holds.'; /** One boot's hold on its configuration and on the app-plugin instances it mounts. */ interface ConfigurationClaim { @@ -594,7 +605,8 @@ function claimConfiguration(config: unknown): ConfigurationClaim { throw instanceRuleRefusal( `verify: configuration ${configurationLabel(config)} already has a live bootStack kernel in this process. ` + 'One configuration supports one live kernel at a time: the plugins in its own `plugins` array are ' + - 'module-level instances, and a second kernel would mount the same instances. ' + + 'module-level instances, and a live kernel holds references into its nested definitions, so a second ' + + 'kernel would share both. ' + INSTANCE_RULE_REMEDY, ); } @@ -669,6 +681,12 @@ function claimConfiguration(config: unknown): ConfigurationClaim { * mounted. Live means from the call until `stop()` resolves, or until the boot * fails. Booting again after `stop()` is fine, and {@link bootStackOnce} * shares one boot among callers instead of starting a second. + * + * A suite that needs two stacks of one app live at once (two postures, two + * option sets) boots the second on a configuration of its own, BUILT AGAIN — + * its builder called once more, or a fresh module instance of the module that + * exports it. A `{ ...config }` spread is not one: a live boot holds references + * into the configuration's nested definitions as well as its plugin instances. */ export async function bootStack( config: any, From 44bac8365a3d1dde182bc30f014bad02776fac7a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 01:13:32 +0000 Subject: [PATCH 07/14] test(dogfood): the publish-route pin reads the served composition; drop a doubled helper import Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- .../route-ledger-live-mount-parity.dogfood.test.ts | 11 +++++++---- .../showcase-demo-personas-loginable.dogfood.test.ts | 1 - 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/qa/dogfood/test/route-ledger-live-mount-parity.dogfood.test.ts b/packages/qa/dogfood/test/route-ledger-live-mount-parity.dogfood.test.ts index 408e047f4af..bbbab3357a0 100644 --- a/packages/qa/dogfood/test/route-ledger-live-mount-parity.dogfood.test.ts +++ b/packages/qa/dogfood/test/route-ledger-live-mount-parity.dogfood.test.ts @@ -399,15 +399,18 @@ describe('route ledger ↔ live mount parity (#7526)', () => { expect(server.resolveMountedRoute!('POST', '/api/v1/packages/publish')) .toEqual({ method: 'POST', pattern: '/api/v1/packages/publish' }); - // …and on the wire. This boot composes no `package` service, so the honest - // answer is the publish route's own 404 naming the surface — never a 405 + // …and on the wire. [#22301] This boot is the SERVED composition: the + // showcase's `requires` names `marketplace`, so `bootStack` composes the + // `package` service as `objectstack serve` does, and the honest answer to an + // empty body is the publish route's OWN refusal of it — never a 405 // advertising `DELETE, GET, HEAD, PATCH`, which are `/packages/:id`'s verbs // over a package whose id is the literal string `publish`. + expect(stack.kernel.hasPlugin('package-service'), 'the showcase requires marketplace').toBe(true); const token = await stack.signIn(); const res = await stack.apiAs(token, 'POST', '/packages/publish', {}); - expect(res.status).toBe(404); + expect(res.status).toBe(400); expect(res.headers.get('Allow')).toBeNull(); - expect((await res.json())?.error?.message).toContain('marketplace publish surface'); + expect((await res.json())?.error?.code).toBe('MISSING_REQUIRED_FIELD'); }, 60_000); // The other two defects, pinned as live-router facts rather than as prose. diff --git a/packages/qa/dogfood/test/showcase-demo-personas-loginable.dogfood.test.ts b/packages/qa/dogfood/test/showcase-demo-personas-loginable.dogfood.test.ts index ac7958d37a3..8b8ba08efca 100644 --- a/packages/qa/dogfood/test/showcase-demo-personas-loginable.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-demo-personas-loginable.dogfood.test.ts @@ -72,7 +72,6 @@ import { } from '@objectstack/example-showcase/security-personas'; import { type VerifyStack } from '@objectstack/verify'; import { bootShowcase } from './showcase-boot.js'; -import { bootShowcase } from './showcase-boot.js'; import { showcaseAppDefaultSecurity } from './showcase-security.js'; const SYS = { isSystem: true } as const; From 3ed015b4ab72715da07437dae1cd678317dba67a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 01:37:59 +0000 Subject: [PATCH 08/14] test(dogfood): #12359's no-automation case boots a showcase-derived configuration that does not declare automation Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- ...ed-activation-ledger-reach.dogfood.test.ts | 33 +++++++++++++++++-- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/packages/qa/dogfood/test/packaged-activation-ledger-reach.dogfood.test.ts b/packages/qa/dogfood/test/packaged-activation-ledger-reach.dogfood.test.ts index aad0e188e06..759665414a0 100644 --- a/packages/qa/dogfood/test/packaged-activation-ledger-reach.dogfood.test.ts +++ b/packages/qa/dogfood/test/packaged-activation-ledger-reach.dogfood.test.ts @@ -49,6 +49,7 @@ // re-arming every artifact an administrator had switched off. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; import { type VerifyStack } from '@objectstack/verify'; import { bootShowcase } from './showcase-boot.js'; // The showcase declares `connectors:` bound to these providers, and the @@ -98,14 +99,40 @@ async function actionRows(stack: VerifyStack): Promise { + const declared = showcaseStack as unknown as { + requires?: string[]; + plugins?: Array<{ dependencies?: readonly string[] }>; + }; + return { + ...(showcaseStack as unknown as Record), + requires: (declared.requires ?? []).filter((token) => token !== 'automation'), + plugins: (declared.plugins ?? []).filter((p) => !(p.dependencies ?? []).includes(AUTOMATION_PROVIDER)), + }; +} + describe('#12359 — actions and NO automation service: the ledger is there', () => { let stack: VerifyStack; let token: string; beforeAll(async () => { - // The exact boot #12359 measured: no `automation` option, so - // `@objectstack/service-automation` is not composed at all. - stack = await bootShowcase(); + // The composition #12359 measured: `@objectstack/service-automation` + // is not composed at all — no `automation` option, and (since #22301) + // a configuration that does not declare it either. + stack = await bootShowcase({}, showcaseWithoutAutomation()); token = await stack.signIn(); }, 120_000); From f7f6a2e094b8cd48f1eccfe3aca8a42b432bde3b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 02:17:19 +0000 Subject: [PATCH 09/14] test(dogfood): the newly landed declared-position-provenance boot goes through bootShowcase Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- .../test/declared-position-provenance.dogfood.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/qa/dogfood/test/declared-position-provenance.dogfood.test.ts b/packages/qa/dogfood/test/declared-position-provenance.dogfood.test.ts index af1f3f4d57f..42ef50daceb 100644 --- a/packages/qa/dogfood/test/declared-position-provenance.dogfood.test.ts +++ b/packages/qa/dogfood/test/declared-position-provenance.dogfood.test.ts @@ -42,7 +42,8 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { fileURLToPath } from 'node:url'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; @@ -98,7 +99,7 @@ describe('[#22360] a package-declared position: definition locked at the data do call('PATCH', `/data/sys_position/${(await one(name)).id}`, body); const outcome = (r: { status: number; code: unknown }) => ({ status: r.status, code: r.code }); const start = async () => { - stack = await bootStack(showcaseStack, { databaseFile: dbFile }); + stack = await bootShowcase({ databaseFile: dbFile }); token = await stack.signIn(); ql = await stack.kernel.getServiceAsync('objectql'); }; From 08929776c764ac608cbc14ee2eaff300ce57c269 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 03:24:51 +0000 Subject: [PATCH 10/14] fix(cli): os verify anchors the app at its config's directory, as serve does Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- .../22301-verify-boots-what-serve-boots.md | 2 +- packages/cli/src/commands/verify.ts | 15 +- packages/cli/test/verify-host-root.test.ts | 141 ++++++++++++++++++ 3 files changed, 155 insertions(+), 3 deletions(-) create mode 100644 packages/cli/test/verify-host-root.test.ts diff --git a/.changeset/22301-verify-boots-what-serve-boots.md b/.changeset/22301-verify-boots-what-serve-boots.md index ff391f38d17..7f42256b8e9 100644 --- a/.changeset/22301-verify-boots-what-serve-boots.md +++ b/.changeset/22301-verify-boots-what-serve-boots.md @@ -28,4 +28,4 @@ Clause-②: yes (narrowing) **`@objectstack/core`** exports the pieces both boots read: `CAPABILITY_PROVIDERS`, `CapabilitySpec`, `CapabilityIdentities` and `providesCapability` (the `requires` token → provider table and its exact identity match); `stackDeclaredCapabilities`, `resolveStackCollection`, `declaredPackageEntries`, `stackPackageBodies` and `collectFromPackageBodies` (the package-owned collection reader); and `materializeStackPlugin` with `StackPluginLoaders` (what a `plugins` entry becomes). -**`@objectstack/cli`**: no behaviour change. `Serve.CAPABILITY_PROVIDERS` and `Serve.providesCapability` are handles over the `@objectstack/core` declarations, the stack-collection readers are re-exported from there, and `serve`'s `plugins` loop reads the entry rule from there. +**`@objectstack/cli`**: `os verify` boots the app anchored at the directory holding its config (`hostRoot`), as `serve` anchors it, so `os verify --app path/to/objectstack.config.ts` run from another directory reads the app's package-relative files (a declarative connector's spec, a string `plugins` entry) and resolves `--multi-tenant`'s organizations package from the app, not from the working directory. `serve` itself does not change: `Serve.CAPABILITY_PROVIDERS` and `Serve.providesCapability` are handles over the `@objectstack/core` declarations, the stack-collection readers are re-exported from there, and `serve`'s `plugins` loop reads the entry rule from there. diff --git a/packages/cli/src/commands/verify.ts b/packages/cli/src/commands/verify.ts index 4a32e2bcfee..ae2cbb51803 100644 --- a/packages/cli/src/commands/verify.ts +++ b/packages/cli/src/commands/verify.ts @@ -189,10 +189,21 @@ export default class Verify extends Command { const multiTenant = resolveVerifyMultiTenant(flags); + // [#22301] The app's root — the directory holding its `objectstack.config.ts`, + // where `os serve` anchors the same app. `bootStack` composes what `serve` + // composes (the providers `requires` names, the app's own `plugins`), and it + // anchors every app-relative read at `hostRoot`: a declarative connector's + // package-relative file ref, a string `plugins` entry, the multi-tenant + // package. Left to its default (the process cwd), `os verify --app + // examples/app-showcase/objectstack.config.ts` run from the repository root + // resolved the showcase's `./src/system/connectors/status-openapi.json` + // against the root, and the boot refused it (ENOENT). + const hostRoot = dirname(absolutePath); + // Data fidelity runs on its own pristine stack. let crud: VerifyReport; { - const stack = await bootStack(config, { multiTenant }); + const stack = await bootStack(config, { multiTenant, hostRoot }); try { const adminToken = await stack.signIn(); crud = await runCrudVerification(stack, adminToken, config); @@ -216,7 +227,7 @@ export default class Verify extends Command { // `rlsProbeSecurity` registers the capability #7665's acceptance // criterion 2 names (object read+edit, owner-scoped SELECT only) and // carries the app's declared default profile through unchanged. - const rlsStack = await bootStack(config, { multiTenant, security: rlsProbeSecurity(config) }); + const rlsStack = await bootStack(config, { multiTenant, hostRoot, security: rlsProbeSecurity(config) }); try { const adminToken = await rlsStack.signIn(); let probeToken: string; diff --git a/packages/cli/test/verify-host-root.test.ts b/packages/cli/test/verify-host-root.test.ts new file mode 100644 index 00000000000..63b4cd0d8ca --- /dev/null +++ b/packages/cli/test/verify-host-root.test.ts @@ -0,0 +1,141 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * PIN — `os verify --app /objectstack.config.mjs`, run from a working + * directory that is NOT the app's, boots the app anchored at the app's own + * directory (#22301). + * + * ## The defect + * + * `bootStack` composes what `objectstack serve` composes from a configuration + * (ruling A on #22301): the providers its `requires` names and the plugins of + * its own `plugins` array. Every app-relative read of that composition is + * anchored at `BootOptions.hostRoot` — a declarative connector's + * package-relative file ref, a string `plugins` entry, the multi-tenant + * package — and `os verify` handed it no `hostRoot`, so it defaulted to the + * process cwd. Measured in CI (`Dogfood Verify CLI`, which runs + * `os verify --app examples/app-showcase/objectstack.config.ts --rls` from the + * repository root): the boot refused the showcase's `showcase_status_openapi` + * connector, its `./src/system/connectors/status-openapi.json` resolved + * against the repository root (ENOENT). `serve` anchors the same reads at the + * directory holding the config; `os verify` now does too. + * + * ## What is pinned + * + * The app declares a string `plugins` entry that only ITS directory can + * resolve: a package installed in the app's own `node_modules` and declared in + * the app's own `package.json`. Run from an empty directory elsewhere, the + * verify still loads it — the plugin's `init` leaves a marker beside the + * package — and the run passes. Anchored at the cwd instead, the entry does + * not resolve and the boot refuses it, naming `plugins[0]`. + * + * Spawned rather than run in-process: the subject is the process working + * directory, which only a real process has. + */ + +import { describe, expect, it, beforeAll, afterAll } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { CLI, TSX, childEnv } from './helpers/serve-process.js'; +import { defineStackSource, linkSpec } from './helpers/define-stack-fixture.js'; + +/** A package only the app's own directory declares and installs. */ +const APP_ONLY_PLUGIN = '@fixture/verify-host-root-plugin'; +/** What the plugin's `init` writes beside itself, so its having run is observable from outside. */ +const MOUNTED_MARKER = 'mounted.marker'; + +const STACK = { + manifest: { + id: 'com.example.verify-host-root', + namespace: 'vhr', + version: '1.0.0', + name: 'Verify Host Root', + type: 'app', + engines: { protocol: '^17' }, + }, + objects: [ + { + name: 'vhr_note', + label: 'Note', + pluralLabel: 'Notes', + sharingModel: 'private', + fields: { + title: { type: 'text', label: 'Title', required: true }, + }, + }, + ], + plugins: [APP_ONLY_PLUGIN], +}; + +/** The plugin package, written into the app's own `node_modules`. */ +function installAppOnlyPlugin(appDir: string): string { + const pkgDir = join(appDir, 'node_modules', ...APP_ONLY_PLUGIN.split('/')); + mkdirSync(pkgDir, { recursive: true }); + writeFileSync( + join(pkgDir, 'package.json'), + JSON.stringify({ name: APP_ONLY_PLUGIN, version: '1.0.0', type: 'module', exports: { '.': './index.js' } }), + ); + writeFileSync( + join(pkgDir, 'index.js'), + [ + "import { writeFileSync } from 'node:fs';", + "import { fileURLToPath } from 'node:url';", + 'export default {', + " name: 'com.fixture.verify-host-root',", + " version: '1.0.0',", + " type: 'standard',", + ' async init() {', + ` writeFileSync(fileURLToPath(new URL('./${MOUNTED_MARKER}', import.meta.url)), 'mounted');`, + ' },', + '};', + '', + ].join('\n'), + ); + return pkgDir; +} + +describe('os verify anchors the app at its own directory, not the process cwd (#22301)', () => { + let appDir: string; + let elsewhere: string; + let pluginDir: string; + let run: { status: number | null; stdout: string; stderr: string }; + + beforeAll(() => { + appDir = mkdtempSync(join(tmpdir(), 'os-verify-host-root-app-')); + elsewhere = mkdtempSync(join(tmpdir(), 'os-verify-host-root-cwd-')); + writeFileSync(join(appDir, 'objectstack.config.mjs'), defineStackSource(STACK)); + // The declaration the host importer reads: the app — and only the app — declares the package. + writeFileSync( + join(appDir, 'package.json'), + JSON.stringify({ name: 'verify-host-root-app', version: '0.0.0', dependencies: { [APP_ONLY_PLUGIN]: '1.0.0' } }), + ); + linkSpec(appDir); + pluginDir = installAppOnlyPlugin(appDir); + + // Through tsx, so the child runs this checkout's `src/` (see verify-json-stdout.test.ts). + const r = spawnSync(TSX, [CLI, 'verify', '--app', join(appDir, 'objectstack.config.mjs')], { + cwd: elsewhere, + encoding: 'utf8', + env: childEnv({ NO_COLOR: '1' }), + maxBuffer: 64 * 1024 * 1024, + }); + run = { status: r.status, stdout: r.stdout ?? '', stderr: r.stderr ?? '' }; + }, 360_000); + + afterAll(() => { + for (const dir of [appDir, elsewhere]) if (dir) rmSync(dir, { recursive: true, force: true }); + }); + + it('premise: the working directory is not the app directory, and declares nothing', () => { + expect(elsewhere).not.toBe(appDir); + expect(existsSync(join(elsewhere, 'package.json'))).toBe(false); + }); + + it("loads the app's own plugin from the app's directory, and the verify passes", () => { + expect(run.status, `os verify failed from a foreign cwd:\n${run.stdout}\n${run.stderr}`).toBe(0); + expect(existsSync(join(pluginDir, MOUNTED_MARKER)), "the app's plugin never ran").toBe(true); + expect(run.stdout).toContain('verify passed'); + }); +}); From 0bb3c97ee7ed68a2a11d3f80f13f4697d4bb17e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 06:27:26 +0000 Subject: [PATCH 11/14] docs: bootStackOnce's second options key and the no-automation composition, as #22301 now has them The bootStackOnce TSDoc states that a second options key on a configuration whose first boot is still live is refused by the instance rule. The platform-core activation-ledger item (revision 4) and FOLLOW-UPS restate the no-automation composition as the dogfood suite builds it: a showcase-derived configuration that does not declare automation. Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- docs/qa/platform-checklist/FOLLOW-UPS.md | 9 +++++++-- .../platform-checklist/areas/platform-core.json | 16 +++++++++++----- packages/verify/src/harness.ts | 8 ++++++++ 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/docs/qa/platform-checklist/FOLLOW-UPS.md b/docs/qa/platform-checklist/FOLLOW-UPS.md index 650088cedff..6f00a6dcd5c 100644 --- a/docs/qa/platform-checklist/FOLLOW-UPS.md +++ b/docs/qa/platform-checklist/FOLLOW-UPS.md @@ -401,8 +401,13 @@ posture, then decide which shape is wanted). - a `group`/`isolated` posture boot recipe → unblocks the §5 operator-gate legs (`access-security.activation-write-operator-gate`) and card row D2. - a documented no-automation lean-composition boot for manual runners → the dogfood - harness (`bootStack(showcaseStack)` minus automation) is currently the only path for - `platform-core.activation-ledger-registration-home`'s 503-turnaround leg. + harness is currently the only path for + `platform-core.activation-ledger-registration-home`'s 503-turnaround leg. It boots a + showcase-derived configuration that does not declare automation: the `automation` + token is taken out of `requires`, and the app plugins that depend on the automation + service are left out (`packaged-activation-ledger-reach`). Since #22301, + `bootStack(showcaseStack)` composes what `os serve` composes, so it mounts automation + whether or not the `automation` option is passed. ### 8e. Checked and CLEAN (so the next sweep does not re-derive) diff --git a/docs/qa/platform-checklist/areas/platform-core.json b/docs/qa/platform-checklist/areas/platform-core.json index 255a202b50e..af93fef3e71 100644 --- a/docs/qa/platform-checklist/areas/platform-core.json +++ b/docs/qa/platform-checklist/areas/platform-core.json @@ -1435,7 +1435,7 @@ "title": "The activation ledger's registration home: sys_metadata_activation is registered by PlatformObjectsPlugin under its OWN manifest, so packaged disable works with or without the automation service — one owner, datasource binding carried across the move", "since": "v17", "status": "active", - "revision": 3, + "revision": 4, "priority": "P1", "surface": "api", "personas": ["seeded admin (admin@objectos.ai / admin123)"], @@ -1443,14 +1443,14 @@ "app": "showcase", "requires": [ "the with-automation composition is any stock boot: `objectstack dev`/`os serve` composes AutomationServicePlugin whenever the app requires the 'automation' capability token (packages/cli/src/commands/serve.ts CAPABILITY_PROVIDERS), and the showcase does — so the live-boot legs below need nothing beyond an isolated stock boot", - "the NO-automation composition — the one #12359 measured the 503 on — is `bootStack(showcaseStack)` from @objectstack/verify with the `automation` option omitted (packages/verify/src/harness.ts; the option is only honored), which is exactly how the pinned dogfood suite constructs it" + "the NO-automation composition — the one #12359 measured the 503 on — is a showcase-derived configuration that does not declare automation, booted with `bootStack` from @objectstack/verify: the showcase with the 'automation' token taken out of its `requires`, and with the plugins of its own `plugins` array that depend on com.objectstack.service-automation left out, which is exactly how the pinned dogfood suite constructs it (packages/qa/dogfood/test/packaged-activation-ledger-reach.dogfood.test.ts#showcaseWithoutAutomation). `bootStack(showcaseStack)` with the `automation` option omitted is NOT that composition: since #22301 bootStack composes what `os serve` composes, so the showcase's own `requires` mounts the automation service (packages/verify/src/harness.ts; the option only adds the service, never removes it)" ], "knownGaps": [ "NO stock CLI path boots the showcase WITHOUT the automation service: serve/dev compose it from the app's own `requires`, so a manual runner cannot stage the no-automation composition with `os dev` flags alone. The no-automation legs therefore ride the pinned dogfood suite (automated.ref — its first describe carries an anti-vacuity control asserting the automation service is genuinely absent) or an authored scratch stack config that drops the requirement; the run records WHICH of the two its verdict rests on. Scoring those legs off a stock boot measures the wrong composition" ] }, "steps": [ - "run the pinned suite: pnpm --filter @objectstack/dogfood exec vitest run test/packaged-activation-ledger-reach.dogfood.test.ts — BOTH describes: '#12359 — actions and NO automation service' (the 503-turnaround leg, bootStack with no automation) and '#12159 Part 1 — a composition WITH automation' (the move's second end); capture the full output", + "run the pinned suite: pnpm --filter @objectstack/dogfood exec vitest run test/packaged-activation-ledger-reach.dogfood.test.ts — BOTH describes: '#12359 — actions and NO automation service' (the 503-turnaround leg, booted on the showcase-derived configuration that does not declare automation) and '#12159 Part 1 — a composition WITH automation' (the move's second end); capture the full output", "live with-automation boot (stock `objectstack dev`, isolated port/DB), as admin: POST /api/v1/actions/_activation/showcase_task/showcase_mark_done {\"enabled\":false} → 200; GET /api/v1/data/sys_metadata_activation and capture the row (metadata_type 'action', name showcase_mark_done, active false/0) together with its KEY SET — organization_id is ABSENT from it, the ledger having no tenant column at all (#15024 / ADR-0131 D7). ⛔ Never capture the tenant half as a value: `row.organization_id ?? null` answers `null` for a column that does not exist, so a value read passes while measuring nothing (the sibling item platform-core.activation-ledger-row-contract owns the schema-side probe)", "same boot, the flow half: POST /api/v1/automation/showcase_task_completed/toggle {\"enabled\":false} → 200; re-read the ledger — a metadata_type 'flow' row for showcase_task_completed appears BESIDE the action row, and the flow-name and action-name lists never cross (the discriminator is load-bearing, not decorative)", "restore both switches ({\"enabled\":true} / toggle on) — both rows persist with active true (updated, not deleted); leave the boot as found", @@ -1504,7 +1504,7 @@ "packages/platform-objects/src/plugin.ts#ACTIVATION_LEDGER_MANIFEST (the registration-home rationale — the measured 503; MOVE-not-add), (why the ledger rides its OWN manifest — the datasource-routing measurement), (ACTIVATION_LEDGER_MANIFEST), (the two register calls), (lean-kernel degradation: no manifest service → the door refuses loudly with 503 rather than keeping a bit that reverts)", "packages/services/service-automation/src/plugin.ts#runObjectRegistered (the flow leg attaches by probe() of the real table — runObjectRegistered no longer vouches for it; not attached on a failed probe)", "packages/spec/src/system/constants/platform-object-names.ts#PLATFORM_OBJECTS_BY_PACKAGE (PLATFORM_OBJECTS_BY_PACKAGE receipt)", - "packages/verify/src/harness.ts#bootStack (bootStack's automation option — how the no-automation composition is constructed)", + "packages/verify/src/harness.ts#bootStack (bootStack composes what the configuration declares, as `os serve` does, so the no-automation composition is a configuration that does not declare automation — the `automation` option only adds the service, never removes it)", "docs/adr/0126-packaged-metadata-customization-model.md §4 (one generic activation ledger)", "docs/adr/0131-total-organization-ownership-no-null-organization-id.md D7 (deployment-level state has no organization column — sys_metadata_activation is named there as reverted before 17.3 and not returning, which is why the row reads here assert a key set rather than a NULL value)", "#12438 (the scoped sweep this item lands from), #12419 (the registration-home PR), #12359 (the 503 measurement + the 2026-08-26 「同意」 ruling: registration follows the declaration), #15024 (the column drop these read-backs are re-grounded against)" @@ -1513,7 +1513,7 @@ { "revision": 1, "date": "2026-08-26", - "change": "new — authored in the #12438 scoped sweep (ADR-0126 disable+clone). Grounding fixed the item's shape twice: (1) the no-automation composition has NO stock CLI path (serve/dev compose automation from the app's own requires), so that leg rides the pinned dogfood suite's bootStack(showcaseStack) with the automation option omitted, recorded as a knownGap rather than pretended manual; (2) the single-owner clause is proven by the boot succeeding plus a registry read, because a double registration is a boot FAILURE (registerObject throws), never an observable duplicate", + "change": "new — authored in the #12438 scoped sweep (ADR-0126 disable+clone). Grounding fixed the item's shape twice: (1) the no-automation composition has NO stock CLI path (serve/dev compose automation from the app's own requires), so that leg rides the pinned dogfood suite's bootStack(showcaseStack) with the automation option omitted [superseded at revision 4: the suite now boots a showcase-derived configuration that does not declare automation], recorded as a knownGap rather than pretended manual; (2) the single-owner clause is proven by the boot succeeding plus a registry read, because a double registration is a boot FAILURE (registerObject throws), never an observable duplicate", "ref": "#12438" }, { @@ -1527,6 +1527,12 @@ "date": "2026-10-04", "change": "A5 / step 6 re-pointed: the literal array count is dropped — it read 41, the 'platform-objects' array holds 46 names at 316be321ef (platform-object-names.ts), and the count moves with every platform object added while the one-owner membership is the point. Per RUNNER.md's convention for counts that move (the discriminator is the fact, never its digits; a literal is pinned only where the validator enforces it, as enumSource does), the clause now asserts single-array membership and records the count as context only. Assertion defect", "ref": "#21735" + }, + { + "revision": 4, + "date": "2026-10-09", + "change": "the no-automation composition is restated as the dogfood suite now builds it. #22301 made bootStack compose what `os serve` composes, the app's own `requires` included, so `bootStack(showcaseStack)` with the automation option omitted now mounts the automation service and no longer stages the #12359 composition. The suite boots a showcase-derived configuration that does not declare automation instead: the 'automation' token taken out of `requires`, and the app plugins that depend on the automation service left out. fixtures.requires, step 1, the harness source entry and revision 1's history note are re-said to match. Steps, legs, clauses and expected verdicts are otherwise unchanged, and the anti-vacuity control is still the suite's first test", + "ref": "#22301" } ] }, diff --git a/packages/verify/src/harness.ts b/packages/verify/src/harness.ts index c2ea8240b8e..9cf64d28f5b 100644 --- a/packages/verify/src/harness.ts +++ b/packages/verify/src/harness.ts @@ -1311,6 +1311,14 @@ const SHARED_BOOTS = new WeakMap>>(); * even one spelled identically, is a different stack: the memo never guesses * that two `SecurityPlugin` instances mean the same thing. * + * A different stack over the SAME `config` is still a second boot of that + * configuration, so the instance rule `bootStack` enforces applies to it: while + * the first key's boot is live, a second `opts` key on that `config` is refused + * (`RESOURCE_CONFLICT`, status 409), the returned promise rejects, and the memo + * drops that key so a later caller boots again. To keep two stacks live at + * once, pass the second a configuration BUILT AGAIN (its builder called once + * more, or a fresh module instance), never a `{ ...config }` copy. + * * Sharing only makes sense under `isolate: false` (files in one worker share * one module registry); under vitest's default isolation every file still * boots its own. The eligibility rules dogfood wrote for its shared stack From 4a606ff8bd8998671dea3bb3c51b898646ee177c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 08:36:53 +0000 Subject: [PATCH 12/14] test(dogfood): the declared-position cold-boot case carries its boot's budget The case reboots the showcase inside the it(), under vitest's 5000 ms default. bootShowcase now composes the showcase as os serve does, and that reboot read 2809 ms quiet and timed out at 5225 ms on a loaded shard. It now carries the 300_000 budget beforeAll gives the same start(), as every other in-case boot in the swept files already does. Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- .../test/declared-position-provenance.dogfood.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/qa/dogfood/test/declared-position-provenance.dogfood.test.ts b/packages/qa/dogfood/test/declared-position-provenance.dogfood.test.ts index 42ef50daceb..ffb8ad12795 100644 --- a/packages/qa/dogfood/test/declared-position-provenance.dogfood.test.ts +++ b/packages/qa/dogfood/test/declared-position-provenance.dogfood.test.ts @@ -238,7 +238,13 @@ describe('[#22360] a package-declared position: definition locked at the data do expect(outcome(refused), JSON.stringify(refused.json)).toEqual(REFUSED); expect((await one(DRIFTED)).label).toBe(declaredLabel(DRIFTED)); - }); + // This case boots the showcase again (`start()`), so it carries the + // budget `beforeAll` gives that same `start()`, not vitest's 5000 ms + // default. Since #22301 the showcase boots with the providers its + // `requires` names and its own `plugins`, as `os serve` boots it: the + // reboot read 2809 ms on a quiet shard and timed out at 5225 ms on a + // loaded one. + }, 300_000); it('controls after the cold boot: administrator- and environment-authored positions keep unmanaged, editable rows', async () => { const admin = await one(ADMIN_AUTHORED); From 8d985cf8f677c049e55775db4113222fdcf0d78c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 09:52:55 +0000 Subject: [PATCH 13/14] test(dogfood): the newly landed storage-unclaimed-download boot goes through bootShowcase Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- .../dogfood/test/storage-unclaimed-download.dogfood.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/qa/dogfood/test/storage-unclaimed-download.dogfood.test.ts b/packages/qa/dogfood/test/storage-unclaimed-download.dogfood.test.ts index e4f947c8049..96d76502d72 100644 --- a/packages/qa/dogfood/test/storage-unclaimed-download.dogfood.test.ts +++ b/packages/qa/dogfood/test/storage-unclaimed-download.dogfood.test.ts @@ -23,8 +23,8 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { mkdtempSync, promises as fs } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { StorageServicePlugin } from '@objectstack/service-storage'; import { showcaseAppDefaultSecurity } from './showcase-security.js'; @@ -95,7 +95,7 @@ describe('[#22431] a download of a file with no attachments scope and no field o beforeAll(async () => { rootDir = mkdtempSync(join(tmpdir(), 'unclaimed-download-')); - stack = await bootStack(showcaseStack, { + stack = await bootShowcase({ security: showcaseAppDefaultSecurity(), extraPlugins: [new StorageServicePlugin({ adapter: 'local', local: { rootDir }, bindToSettings: false })], }); From 749197628fc07c97865c2389e3d4f6cb002ca7bf Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 11:35:24 +0000 Subject: [PATCH 14/14] test(dogfood): the newly landed position-environment-write-through boot goes through bootShowcase bootShowcase passes the showcase's own directory as hostRoot, so the file no longer changes the process cwd to reach it. Its databaseFile option and its two reboots after stop() are unchanged. Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- ...sition-environment-write-through.dogfood.test.ts | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/packages/qa/dogfood/test/position-environment-write-through.dogfood.test.ts b/packages/qa/dogfood/test/position-environment-write-through.dogfood.test.ts index 1d6a8c381db..3061db9b4ca 100644 --- a/packages/qa/dogfood/test/position-environment-write-through.dogfood.test.ts +++ b/packages/qa/dogfood/test/position-environment-write-through.dogfood.test.ts @@ -31,22 +31,18 @@ // boot changes nothing. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import showcaseStack from '@objectstack/example-showcase'; -import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; import { createSecurityCatalogReader } from '@objectstack/core'; -import { fileURLToPath } from 'node:url'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -/** Package-relative refs resolve against the cwd — see the sibling cold-boot files. */ -const SHOWCASE_DIR = fileURLToPath(new URL('../../../../examples/app-showcase/', import.meta.url)); const SYS = { context: { isSystem: true } } as const; /** The backfill's ledger row (`position-environment-backfill.ts`). */ const LEDGER_ID = 'adr-0131-position-environment-backfill'; describe('[ADR-0131 D3] Setup positions reach the environment ledger under single (showcase)', () => { - let prevCwd: string; let dir: string; let db: string; let stack: VerifyStack | undefined; @@ -68,14 +64,12 @@ describe('[ADR-0131 D3] Setup positions reach the environment ledger under singl return entry !== undefined && entry.name === name; }; const start = async () => { - stack = await bootStack(showcaseStack, { databaseFile: db }); + stack = await bootShowcase({ databaseFile: db }); token = await stack.signIn(); ql = await stack.kernel.getServiceAsync('objectql'); }; beforeAll(async () => { - prevCwd = process.cwd(); - process.chdir(SHOWCASE_DIR); dir = mkdtempSync(join(tmpdir(), 'dogfood-15196-s7-')); db = join(dir, 'showcase.db'); await start(); @@ -83,7 +77,6 @@ describe('[ADR-0131 D3] Setup positions reach the environment ledger under singl afterAll(async () => { await stack?.stop(); - if (prevCwd) process.chdir(prevCwd); if (dir) rmSync(dir, { recursive: true, force: true }); });