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..7f42256b8e9 --- /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)`; 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`. + +**`@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`**: `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/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/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 4aa02f5a3a5..1b4876c6d7b 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, 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. @@ -950,24 +953,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(); /** @@ -1596,43 +1581,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`), @@ -1857,174 +1813,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. @@ -4423,27 +4222,30 @@ 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 - } - } + // (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'); + 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/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/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/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'); + }); +}); 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..500be3c30ae 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -17,6 +17,17 @@ 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'; +// [#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-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/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/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/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/declared-position-provenance.dogfood.test.ts b/packages/qa/dogfood/test/declared-position-provenance.dogfood.test.ts index af1f3f4d57f..ffb8ad12795 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'); }; @@ -237,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); 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..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 @@ -50,7 +50,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'; // 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 @@ -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 bootStack(showcaseStack); + // 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); @@ -211,7 +238,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 f3162a21cc4..99efbff7330 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 @@ -47,8 +47,8 @@ // refusal would 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, @@ -137,7 +137,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'); @@ -183,7 +183,7 @@ describe('[#21860] Discard Overlay refuses every set no code package ships, with // The cold boot. 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/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 }); }); 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..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 @@ -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, @@ -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/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 b761722edc7..4eecd9b14ad 100644 --- a/packages/qa/dogfood/test/shared-showcase.ts +++ b/packages/qa/dogfood/test/shared-showcase.ts @@ -48,8 +48,8 @@ // 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 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'; /** @@ -81,7 +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) }).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..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 @@ -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,8 @@ 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 { showcaseAppDefaultSecurity } from './showcase-security.js'; const SYS = { isSystem: true } as const; @@ -99,7 +100,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 92eb9225e15..f610f274cf6 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 @@ -111,7 +111,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'; @@ -143,7 +144,7 @@ describe('dogfood: the object-extension fold and the i18n catalog disagree on sc * which this family of defects is observable at all. Over a database FILE, * so a second boot is a real cold start on the rows the first one left. */ - const boot = () => bootStack(showcaseStack, { + const boot = () => bootShowcase({ databaseFile, extraPlugins: [ new MetadataPlugin({ 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/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 })], }); 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/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/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/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 new file mode 100644 index 00000000000..7aff8419c49 --- /dev/null +++ b/packages/verify/src/harness.one-composition.test.ts @@ -0,0 +1,164 @@ +// 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('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] }); + 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); +}); 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..9cf64d28f5b 100644 --- a/packages/verify/src/harness.ts +++ b/packages/verify/src/harness.ts @@ -37,7 +37,9 @@ import { PlatformObjectsPlugin } from '@objectstack/platform-objects/plugin'; // verification — `@objectstack/organizations` above all — must be resolved from // THAT app, not from `packages/verify`'s own realpath inside this workspace. import { createHostImporter, hostImportFailureKind } from '@objectstack/types/node'; +import { materializeStackPlugin } from '@objectstack/core'; import { 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 +328,17 @@ 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 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 * framework's own `node_modules`, so an app-installed package was invisible no @@ -352,6 +365,13 @@ 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. Either way the service is handed + * {@link BootOptions.hostRoot} as its `packageRoot`. */ automation?: boolean | { suspendedRunStore?: 'auto' | 'memory' }; /** @@ -395,9 +415,25 @@ 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 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[]; /** @@ -432,15 +468,244 @@ 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 }); +} + +// [#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; 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 { + /** 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 live kernel holds references into its nested definitions, so a second ' + + 'kernel would share both. ' + + 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. + * + * 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, 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'; @@ -502,6 +767,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 +807,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 +902,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 +949,16 @@ 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 } : {}), - })); + // [#22301] The app's root, as `serve` hands it — see BootOptions.hostRoot. + packageRoot: hostRoot, + }); + await kernel.use(automationPlugin as any); } // Caller-supplied optional service pairs (see BootOptions.extraPlugins). @@ -694,6 +967,57 @@ 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, + // 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 ?? []), + ...appPlugins.map((entry) => entry.plugin), + ], + dependents: appPlugins.map((entry) => entry.plugin), + 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 +1033,27 @@ 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); + + // [#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 @@ -939,6 +1283,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 @@ -965,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 diff --git a/packages/verify/src/required-providers.ts b/packages/verify/src/required-providers.ts new file mode 100644 index 00000000000..d58d50a4cbf --- /dev/null +++ b/packages/verify/src/required-providers.ts @@ -0,0 +1,160 @@ +// 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`, 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. +// +// ## What is this boot's own +// +// · CONSTRUCTION. `serve` reads mail, SMS and storage transports from the app +// and the environment; a verification boot never sends mail or SMS from a +// test, so every provider is constructed with its own defaults — the way +// `bootStack({ automation: true })` has always constructed the automation +// service. The one argument kept is `automation`'s `packageRoot`, the +// app's root, which `serve` passes for the same token (`hostRoot` here). +// · THE HARD DEPENDENCIES of what it mounts. `serve` mounts its always-on +// slate (`queue`, `job`, `messaging`, …) on every boot, and some providers +// hard-depend on one of them: `triggers`' schedule extras on `job`, its API +// trigger on `queue`, `webhooks` on `messaging`. This boot does not mount +// the slate, so it mounts exactly the slate providers a mounted plugin +// hard-depends on — a provider it constructs, or a plugin of the app's own +// `plugins` array — found in the same table, among the tokens a served boot +// of this app would have mounted (its `requires` and the slate). A +// dependency no such token supplies is left to the kernel, whose refusal +// names it, as `serve`'s would. +// · 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, + * the app's own `plugins`). + * @param dependents - The other plugins this boot mounts that are not providers + * it constructs — the app's own `plugins` array. Their hard dependencies are + * searched like a mounted provider's, as `serve`'s always-on slate would + * supply them. + * @param isRegistered - Whether a plugin of that name is already registered on + * the kernel (a dependency the boot itself satisfies). + * @param packageRoot - The app's root, handed to `automation` as `serve` does. + */ +export async function constructRequiredProviders(opts: { + config: unknown; + held: readonly unknown[]; + dependents?: readonly unknown[]; + isRegistered: (name: string) => boolean; + packageRoot: string; +}): Promise { + const declared = [...new Set(stackDeclaredCapabilities(opts.config))]; + const named: unknown[] = []; + const dependencies: unknown[] = []; + const all = (): unknown[] => [...opts.held, ...dependencies, ...named]; + + 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 [...(opts.dependents ?? []), ...named, ...dependencies]) { + for (const dependency of hardDependencies(plugin)) { + if (present.has(dependency) || opts.isRegistered(dependency)) continue; + next = searched.find((t) => { + const spec = CAPABILITY_PROVIDERS[t]; + return spec !== undefined && spec.identities.includes(dependency) && !providesCapability(all(), spec.identities); + }); + if (next) break; + } + if (next) break; + } + 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 ec05c06f590..faa02156289 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3025,24 +3025,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 @@ -3055,15 +3073,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