diff --git a/.changeset/retire-the-dev-stub-table.md b/.changeset/retire-the-dev-stub-table.md new file mode 100644 index 0000000000..30b3759c1c --- /dev/null +++ b/.changeset/retire-the-dev-stub-table.md @@ -0,0 +1,33 @@ +--- +'@objectstack/plugin-dev': minor +--- + +feat(plugin-dev)!: the stub table is retired — DevPlugin assembles real plugins and registers no service implementations of its own (ADR-0115, #4093, #4104). + +DevPlugin used to fill every core-service slot no real plugin occupied with a dev stub. Every one of those stubs is gone. A slot nothing fills now stays EMPTY, exactly as in production: routes answer 404/501, discovery reports `unavailable`, and in-process consumers must handle absence — which production already required of them. FROM → TO per retired slot: + +| Slot | The stub did | Instead | +|:---|:---|:---| +| `security.permissions` | allow-all `checkObjectPermission()` | install `@objectstack/plugin-security` (already part of the default assembly) | +| `security.rls` | compiled no row filter | same — `plugin-security` | +| `security.fieldMasker` | returned results unmasked | same — `plugin-security` | +| `auth` | `verify()` accepted everyone as admin | install `@objectstack/plugin-auth` (already part of the default assembly) | +| `data` | accepted writes, stored nothing | install `@objectstack/objectql` (already part of the default assembly) | +| `ui` | shapeless `{}` placeholder | nothing consumed it; handle the absent slot | +| `ai` | placeholder chat/complete answers | install a real AI service | +| `automation` | `execute()` reported success without running | install an automation engine plugin | +| `notification` | claimed "sent", delivered nothing | install a notification service | +| `file-storage` | in-memory files lost on restart | `@objectstack/service-storage` — now auto-wired by DevPlugin when installed (local-disk adapter) | +| `realtime` | in-process pub/sub copy | `@objectstack/service-realtime` — now auto-wired by DevPlugin when installed (its default in-memory adapter) | +| `search` | in-memory substring index | no consumer resolves this slot; a future search service ships its own dev strategy | +| `workflow` | unvalidated state transitions | no consumer resolves this slot; a future workflow service ships its own dev strategy | +| `metadata` | a second hand-written copy of core's `createMemoryMetadata` | no behavior change — the kernel pre-injects core's fallback for empty core slots (`CORE_FALLBACK_FACTORIES`), and ObjectQL registers the real metadata service in the default assembly | +| `cache` / `queue` / `job` / `i18n` | re-registered core's `createMemory*` fallbacks | no behavior change — the kernel pre-injects the same core fallbacks automatically; install `@objectstack/service-cache` / `service-queue` / `service-job` for real engines, and i18n auto-wires from the stack's translations (unchanged) | + +Also new, from the same ADR: + +- **Production guard** (first shipped with the security-trio subset): `DevPlugin.init()` throws when `NODE_ENV === 'production'` — the assembly is built around a well-known default auth secret and a seeded dev admin. Escape hatch: `OS_ALLOW_DEV_PLUGIN=1`. +- **Assembly auto-wire**: `@objectstack/service-storage` and `@objectstack/service-realtime` are wired as optional child plugins when installed (both ship with DevPlugin's dependencies), so dev keeps working file storage and realtime through real implementations. +- `options.services` keys for the retired stubs are accepted and ignored; `'file-storage'` / `'realtime'` now toggle the real service wiring. + +One-line fix for an upgrading stack: if something you called in dev now throws "service not found" or 404s, that call was consuming a fabricated answer — install the real service for that slot (table above), or make the caller tolerate absence the way it already must in production. diff --git a/content/docs/kernel/services-checklist.mdx b/content/docs/kernel/services-checklist.mdx index d1e685e446..3f9839d6d2 100644 --- a/content/docs/kernel/services-checklist.mdx +++ b/content/docs/kernel/services-checklist.mdx @@ -310,30 +310,24 @@ methods, so this entry describes a contract with no implementer on either side. `getLocales`, `getTranslations`, `getFieldLabels` **Service Name**: `i18n` · **Criticality**: `core` -**Implementations**: `@objectstack/service-i18n` (production — file-based) · Built-in in-memory fallback · Dev Plugin (in-memory stub) +**Implementations**: `@objectstack/service-i18n` (production — file-based) · In-memory fallback (`createMemoryI18n` from `@objectstack/core`) **Route Mount**: `/api/v1/i18n` **Contract**: `II18nService` in `@objectstack/spec/contracts` #### Service Registration -The i18n service is a **core built-in service** with automatic in-memory fallback. If no plugin registers the service, the kernel automatically injects an in-memory implementation during startup: - | Environment | Provider | Registration | |:------------|:---------|:-------------| | **Production** | `I18nServicePlugin` | File-based `FileI18nAdapter` loads JSON locale files from disk | -| **Built-in Fallback** | Kernel | In-memory Map-backed stub, auto-injected when no plugin provides `i18n` | -| **Development** | `DevPlugin` | In-memory Map-backed stub, supports `loadTranslations()` | +| **In-memory fallback** | `@objectstack/core` (`createMemoryI18n`) | Registered by `AppPlugin` when the stack declares translation bundles and no i18n service is present; self-describes as `degraded` (ADR-0076 D12) | +| **Development** | `DevPlugin` | Auto-wires `I18nServicePlugin` when the stack declares translations; otherwise the AppPlugin fallback above applies. DevPlugin registers no stub of its own (ADR-0115) | ```typescript -// Production (overrides built-in fallback) +// Production (real service) kernel.use(new I18nServicePlugin({ defaultLocale: 'en', localesDir: './i18n' })); -// Development (automatic — DevPlugin registers i18n stub for all 16 core services) +// Development (automatic — DevPlugin wires I18nServicePlugin when translations are declared) kernel.use(new DevPlugin()); - -// No plugin required — kernel auto-injects in-memory fallback if none registered -const kernel = new ObjectKernel(); -await kernel.bootstrap(); // i18n service available via built-in fallback ``` #### Discovery & Handler Consistency diff --git a/content/docs/plugins/packages.mdx b/content/docs/plugins/packages.mdx index cc3f1210c5..8201a58c19 100644 --- a/content/docs/plugins/packages.mdx +++ b/content/docs/plugins/packages.mdx @@ -344,10 +344,10 @@ All services implement contracts from `@objectstack/spec/contracts` and are kern ### @objectstack/plugin-dev -**Local Development Plugin** — one-line local stack: wires ObjectQL, the in-memory driver, auth, security, the HTTP server, REST and the dispatcher, then fills any still-unclaimed core service slot with an in-memory dev implementation. +**Development Assembly Plugin** — one plugin that wires the real platform stack for zero-config local development. -- **Features**: Composes the real plugins above; registers dev implementations for unclaimed slots, each declaring what kind of fake it is (`__serviceInfo` — `degraded` when it really does the work in memory, `stub` when it fabricates). Slots that would fabricate an answer over HTTP get no implementation at all: `analytics` and the three `security.*` handles stay empty on purpose. -- **When to use**: Local development only. **`init()` throws under `NODE_ENV=production`** — set `OS_ALLOW_DEV_PLUGIN=1` only if you deliberately want the dev slate under a production `NODE_ENV`. +- **Features**: Auto-assembles ObjectQL + in-memory driver + auth + security + Hono server + REST + dispatcher + app metadata, plus optional real services when installed (storage, realtime, i18n); registers no stubs — a slot no plugin fills stays empty, as in production (ADR-0115); refuses to boot with `NODE_ENV=production` (`OS_ALLOW_DEV_PLUGIN` escape hatch) +- **When to use**: Zero-config local development and playgrounds - **README**: [View README](https://github.com/objectstack-ai/objectstack/blob/main/packages/plugins/plugin-dev/README.md) ### @objectstack/plugin-approvals diff --git a/docs/adr/0115-plugin-dev-assembly-not-stub-table.md b/docs/adr/0115-plugin-dev-assembly-not-stub-table.md index fe16728a01..328a395014 100644 --- a/docs/adr/0115-plugin-dev-assembly-not-stub-table.md +++ b/docs/adr/0115-plugin-dev-assembly-not-stub-table.md @@ -88,7 +88,7 @@ Precision, for the record: the `auth` stub is **dead code on the real identity p **D5 — Direct cut inside the 17.x rc window; the changeset carries the FROM → TO per slot.** No deprecation window and no stub-preserving grace release: the honest `stub`/`degraded` self-descriptions shipped by #4082/#4086 already are the deprecation notice (discovery has been telling every consumer these are not capabilities), and keeping an allow-all security fake alive one extra release "to be polite" is exactly what D2 forbids. External rc consumers get the standard breaking-changeset prescription (Post-Task Checklist rule: FROM → TO and the one-line fix — *install the corresponding real service, or handle the absent slot as production already requires*). Resolves open question 1. -**D6 — plugin-dev grows a production guard, in the same change as Tier A.** `DevPlugin.init` throws when `NODE_ENV === 'production'`, escape hatch `OS_ALLOW_DEV_PLUGIN_IN_PRODUCTION=1` (Prime Directive #9 `OS_ALLOW_*` shape — deliberately scary). Deleting the fakes removes the security-semantics hazard; the guard covers what deletion cannot: the plugin still assembles a stack around a well-known default auth secret and a seeded dev admin, which no production deployment should get by accident. It does not ship as a separate earlier fix because Tier A itself waits for nothing (resolves open question 3 — the "maybe it shouldn't wait" concern was about the security stubs, and D2 removes them rather than gating them). +**D6 — plugin-dev grows a production guard, in the same change as Tier A.** `DevPlugin.init` throws when `NODE_ENV === 'production'`, escape hatch `OS_ALLOW_DEV_PLUGIN=1` (Prime Directive #9 `OS_ALLOW_*` shape — deliberately scary; the shipped name, landed by #4126's subset). Deleting the fakes removes the security-semantics hazard; the guard covers what deletion cannot: the plugin still assembles a stack around a well-known default auth secret and a seeded dev admin, which no production deployment should get by accident. It does not ship as a separate earlier fix because Tier A itself waits for nothing (resolves open question 3 — the "maybe it shouldn't wait" concern was about the security stubs, and D2 removes them rather than gating them). **D7 — The descriptions converge on what the plugin is.** `content/docs/plugins/packages.mdx` and the plugin README/class doc describe an assembly plugin ("auto-wires ObjectQL + driver-memory + auth + security + HTTP server + REST + dispatcher + app metadata for local development"); the "Stub services" section and the "simulating all 17+ kernel services" claim are removed. Docs describing the retired design are ADR-0078's silent lie in prose form. @@ -108,6 +108,15 @@ Sequenced to avoid editing `dev-plugin.ts` under an in-flight PR. Ratified by th End state, mechanically checkable: `DEV_STUB_FACTORIES`, `DEV_STUB_SELF_INFO`, `SHAPELESS_STUB_SELF_INFO`, `NO_DEV_STUB_SERVICES`, and the fill loop no longer exist in `dev-plugin.ts`; `grep -r "createMemoryMetadata\|registerInMemory" packages/plugins/plugin-dev` is empty. The two-PR split is the ratified shape; the tier boundaries remain the decision. +### Update (2026-07-30, post-#4086 reconciliation): executed as ONE PR + +Three facts changed between ratification and execution, all shrinking the work: + +1. **#4086's merged form deleted nothing.** It gated the six dispatcher domains on `handlerReady` and explicitly deferred "should the fabricators keep occupying slots" to this ADR's Tier A. So `ai` / `automation` / `notification` join Tier A's deletion list here — the Context table's "retired" row described their HTTP surface (gated to empty-slot answers since #4086), not their registration. +2. **#4089 closed at the source.** #4082 had already given core's five fallbacks their `__serviceInfo` self-descriptions, so Tier B's core half was done before PR-2 existed; what remained of Tier B was only plugin-dev's `metadata` copy and the four wrappers. +3. **With the core half gone, the PR-1/PR-2 boundary lost its reason** (Tier B no longer reached into core), and the maintainer directed completing all remaining work at once. The implementation therefore landed as one PR: Tiers A+B+C, the D6 guard, the D4 assembly auto-wire, and the D7 docs convergence — the full end state above, under a single FROM → TO changeset narrative. +4. **#4126 landed the D2 security trio + D6 guard as a first subset while the full PR was in flight.** Its choices are canonical where they overlap: the escape hatch is `OS_ALLOW_DEV_PLUGIN` (not the longer name this ADR first wrote), the guard is a module-level `assertNotProduction()`, and an empty security slot gets one loud boot-log warn ("RBAC, row-level security and field masking are NOT enforced") instead of silence. It also filed #4113 — the dispatcher's `/auth` domain carries its own mock fallback in `packages/runtime` — as the remaining fabricator OUTSIDE plugin-dev; retiring plugin-dev's `auth` stub neither worsens nor fixes that path (both the stub and the runtime mock fabricate a 200; neither yields a session the identity resolver accepts), so #4113 stays the one place that class of fake survives. + ## Consequences - **+** "The capability is present" has one meaning across dev and production; the D12 producer inventory for plugin-dev closes completely instead of shrinking by one per issue. diff --git a/packages/plugins/plugin-dev/README.md b/packages/plugins/plugin-dev/README.md index c6ca0254f6..2e8135615e 100644 --- a/packages/plugins/plugin-dev/README.md +++ b/packages/plugins/plugin-dev/README.md @@ -1,17 +1,12 @@ # @objectstack/plugin-dev -> Development Mode Plugin for ObjectStack — auto-enables **all 17+ kernel services** for a full-featured API development environment. +> Development Assembly Plugin for ObjectStack — wires the **real** platform stack for zero-config local development. ## Overview Instead of manually wiring up ObjectQL, drivers, auth, HTTP server, REST endpoints, dispatcher, security, and metadata for local development, use `DevPlugin` to get a fully functional stack in one line. -The dev environment simulates **all kernel services** so you can: -- CRUD business objects via REST API -- Read, modify, and save views/apps/dashboards via metadata API (`PUT /api/v1/meta/:type/:name`) -- Use GraphQL, analytics, storage, and automation endpoints -- Authenticate with dev credentials (no real auth provider needed) -- Test UI permissions, workflows, and notifications with dev stubs +Everything it wires is a **real implementation** — there are no simulated services. A capability whose package is not installed is simply absent, exactly as in production: its routes answer 404/501 and discovery reports it `unavailable` (ADR-0115). That keeps "the capability is present" meaning the same thing in dev and production. ## Usage @@ -53,16 +48,14 @@ plugins: [ port: 4000, seedAdminUser: true, services: { - auth: false, // Skip auth for quick prototyping - dispatcher: false, // Skip extended API routes + dispatcher: false, // Skip extended API routes + 'file-storage': false, // Skip the storage service }, }), ] ``` -## What it auto-configures - -### Real plugin implementations +## What it assembles (all real implementations) | Service | Package | Description | |---------|---------|-------------| @@ -73,27 +66,22 @@ plugins: [ | Security | `@objectstack/plugin-security` | RBAC, RLS, field-level masking | | Hono Server | `@objectstack/plugin-hono-server` | HTTP server on configured port | | REST API | `@objectstack/rest` | Auto-generated CRUD + metadata endpoints | -| Dispatcher | `@objectstack/runtime` | Auth routes, GraphQL, analytics, packages, storage | - -### ⛔ Local development only - -`DevPlugin.init()` **throws under `NODE_ENV=production`**. It fills unclaimed service slots with fakes — some of which report success for work they never did — so a production process must not load it. Remove it from that deployment's plugin list and install the real services. `OS_ALLOW_DEV_PLUGIN=1` overrides the refusal if you deliberately want the dev slate under a production `NODE_ENV` (a staging box mimicking prod, a smoke test that pins the variable). +| Dispatcher | `@objectstack/runtime` | Auth routes, GraphQL, packages, storage bridges | +| Storage | `@objectstack/service-storage` | `file-storage` service (local-disk adapter, files under `./storage`) | +| Realtime | `@objectstack/service-realtime` | `realtime` service (in-memory adapter) | +| I18n | `@objectstack/service-i18n` | Auto-registered when the stack declares translations | -### Dev stubs (in-memory / no-op) +Every part is loaded via dynamic import and skipped with a log line when its package is not installed, and each can be disabled via `options.services`. -Most core kernel services not provided by a real plugin are registered as a dev stub, so the kernel service map is populated and callers get correct return types instead of `undefined`. Each one declares what kind of fake it is (`__serviceInfo`, ADR-0076 D12) — consumers, the dispatcher included, gate on that: +## Empty slots stay empty -| Class | Slots | Meaning | -|:---|:---|:---| -| `degraded` | `cache`, `queue`, `job`, `file-storage`, `search`, `realtime`, `i18n`, `workflow`, `metadata` | Really does the work, in memory only. Served normally over HTTP. | -| `stub` | `data`, `auth`, plus `ui` (placeholder with no implementation) | Fabricates its answer. Reported as a stub in discovery, and every dispatcher-owned domain answers it exactly as it answers an empty slot. | +This plugin registers **no service implementations of its own**. The earlier design filled every unoccupied kernel-service slot with a dev stub — fabricated answers such as allow-all permission checks and "sent" notifications that were never delivered. That design is retired (ADR-0115): a slot no real plugin fills stays empty, and consumers handle absence exactly as they already must in production. -**Never stubbed** — these slots stay empty on purpose, which is what production has when the real plugin isn't installed: +To use a capability locally, install its real service — e.g. `@objectstack/service-analytics` for `/analytics` (it runs an InMemory strategy), `@objectstack/service-cache` / `service-queue` / `service-job` for cache/queue/job. -- `analytics` (#4000). Install `@objectstack/service-analytics` (it runs an InMemory strategy). -- `security.permissions`, `security.rls`, `security.fieldMasker` (#4093). The former stubs answered "allowed" for every permission check, compiled no row-level filter, and returned rows unmasked — inverting the decisions they stood in for. ADR-0076 D12's rule is that a fallback may degrade features, **never security semantics**. Without `@objectstack/plugin-security` nothing enforces RBAC, RLS or field masking, and the boot log says so rather than a fake quietly saying yes. +## Production guard -All services are **optional** — if a peer package isn't installed it is skipped, and for the slots above a stub takes its place. +`init()` throws when `NODE_ENV === 'production'`: the assembly is built around a well-known default auth secret and a seeded dev admin. If you really mean it, set `OS_ALLOW_DEV_PLUGIN=1`. ## API Endpoints (when all services enabled) @@ -120,7 +108,7 @@ All services are **optional** — if a peer package isn't installed it is skippe | `authSecret` | `string` | dev default | JWT secret for auth sessions | | `authBaseUrl` | `string` | `http://localhost:{port}` | Auth callback URL | | `verbose` | `boolean` | `true` | Enable verbose logging | -| `services` | `Record` | all `true` | Enable/disable individual services | +| `services` | `Record` | all `true` | Enable/disable individual parts of the assembly | | `extraPlugins` | `Plugin[]` | `[]` | Additional plugins to load | | `stack` | `object` | — | Stack definition to load as project metadata | diff --git a/packages/plugins/plugin-dev/package.json b/packages/plugins/plugin-dev/package.json index 1711975068..9d4ae06ad9 100644 --- a/packages/plugins/plugin-dev/package.json +++ b/packages/plugins/plugin-dev/package.json @@ -2,7 +2,7 @@ "name": "@objectstack/plugin-dev", "version": "17.0.0-rc.0", "license": "Apache-2.0", - "description": "Development Mode Plugin for ObjectStack — auto-enables all services with in-memory implementations", + "description": "Development Assembly Plugin for ObjectStack — wires the real platform stack for zero-config local development", "main": "dist/index.js", "types": "dist/index.d.ts", "exports": { @@ -27,6 +27,8 @@ "@objectstack/rest": "workspace:^", "@objectstack/runtime": "workspace:^", "@objectstack/service-i18n": "workspace:^", + "@objectstack/service-realtime": "workspace:^", + "@objectstack/service-storage": "workspace:^", "@objectstack/setup": "workspace:^", "@objectstack/spec": "workspace:*", "@objectstack/types": "workspace:*" diff --git a/packages/plugins/plugin-dev/src/dev-plugin.test.ts b/packages/plugins/plugin-dev/src/dev-plugin.test.ts index 9fa7ac754c..db2b259dc1 100644 --- a/packages/plugins/plugin-dev/src/dev-plugin.test.ts +++ b/packages/plugins/plugin-dev/src/dev-plugin.test.ts @@ -1,25 +1,46 @@ -import { describe, it, expect, vi } from 'vitest'; -import { readServiceSelfInfo } from '@objectstack/spec/api'; +import { describe, it, expect, vi, afterEach } from 'vitest'; import { DevPlugin } from './dev-plugin'; // #3060: init()'s graceful-degradation path dynamically imports ~10 real // workspace packages (objectql, runtime, plugin-auth, …). Under a fully // parallel `pnpm test` those vite transforms alone can blow past the test // timeout — the "handle missing deps" test flaked at 15s while passing in -// <100ms standalone. The test's INTENT is "peer deps missing", so make them +// <100ms standalone. The tests' INTENT is "peer deps missing", so make them // genuinely missing: each factory throws the same shape an absent package // produces. The degradation branch is exercised for real, with zero real -// module resolution on the hot path. (The stub-contract tests below disable -// all real services, so they never reach these imports.) +// module resolution on the hot path. // (vi.mock calls are hoisted above any const, so the factories are inline.) vi.mock('@objectstack/objectql', () => { throw Object.assign(new Error("Cannot find package '@objectstack/objectql'"), { code: 'ERR_MODULE_NOT_FOUND' }); }); vi.mock('@objectstack/runtime', () => { throw Object.assign(new Error("Cannot find package '@objectstack/runtime'"), { code: 'ERR_MODULE_NOT_FOUND' }); }); vi.mock('@objectstack/driver-memory', () => { throw Object.assign(new Error("Cannot find package '@objectstack/driver-memory'"), { code: 'ERR_MODULE_NOT_FOUND' }); }); vi.mock('@objectstack/service-i18n', () => { throw Object.assign(new Error("Cannot find package '@objectstack/service-i18n'"), { code: 'ERR_MODULE_NOT_FOUND' }); }); +vi.mock('@objectstack/service-storage', () => { throw Object.assign(new Error("Cannot find package '@objectstack/service-storage'"), { code: 'ERR_MODULE_NOT_FOUND' }); }); +vi.mock('@objectstack/service-realtime', () => { throw Object.assign(new Error("Cannot find package '@objectstack/service-realtime'"), { code: 'ERR_MODULE_NOT_FOUND' }); }); vi.mock('@objectstack/plugin-auth', () => { throw Object.assign(new Error("Cannot find package '@objectstack/plugin-auth'"), { code: 'ERR_MODULE_NOT_FOUND' }); }); vi.mock('@objectstack/plugin-security', () => { throw Object.assign(new Error("Cannot find package '@objectstack/plugin-security'"), { code: 'ERR_MODULE_NOT_FOUND' }); }); vi.mock('@objectstack/plugin-hono-server', () => { throw Object.assign(new Error("Cannot find package '@objectstack/plugin-hono-server'"), { code: 'ERR_MODULE_NOT_FOUND' }); }); vi.mock('@objectstack/rest', () => { throw Object.assign(new Error("Cannot find package '@objectstack/rest'"), { code: 'ERR_MODULE_NOT_FOUND' }); }); +vi.mock('@objectstack/setup', () => { throw Object.assign(new Error("Cannot find package '@objectstack/setup'"), { code: 'ERR_MODULE_NOT_FOUND' }); }); +vi.mock('@objectstack/account', () => { throw Object.assign(new Error("Cannot find package '@objectstack/account'"), { code: 'ERR_MODULE_NOT_FOUND' }); }); + +function mockCtx() { + const registeredServices = new Map(); + const ctx: any = { + logger: { info: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn() }, + getService: vi.fn().mockImplementation((name: string) => { + if (registeredServices.has(name)) return registeredServices.get(name); + throw new Error('not found'); + }), + getServices: vi.fn().mockReturnValue(new Map()), + registerService: vi.fn().mockImplementation((name: string, svc: any) => { + registeredServices.set(name, svc); + }), + hook: vi.fn(), + trigger: vi.fn(), + getKernel: vi.fn(), + }; + return { ctx, registeredServices }; +} describe('DevPlugin', () => { it('should have correct metadata', () => { @@ -46,21 +67,7 @@ describe('DevPlugin', () => { }); it('should init with mocked context and handle missing deps gracefully', async () => { - const ctx: any = { - logger: { - info: vi.fn(), - debug: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }, - getService: vi.fn().mockImplementation(() => { throw new Error('not found'); }), - getServices: vi.fn().mockReturnValue(new Map()), - registerService: vi.fn(), - hook: vi.fn(), - trigger: vi.fn(), - getKernel: vi.fn(), - }; - + const { ctx } = mockCtx(); // DevPlugin should not throw even if peer dependencies are missing. // Deps are mocked-away above (#3060), so the default timeout suffices — // if someone removes the mocks, the slowness resurfaces loudly here. @@ -68,346 +75,100 @@ describe('DevPlugin', () => { await expect(plugin.init(ctx)).resolves.not.toThrow(); }); - it('should register contract-compliant dev stubs for every core service except analytics', async () => { - const registeredServices = new Map(); - const ctx: any = { - logger: { - info: vi.fn(), - debug: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }, - getService: vi.fn().mockImplementation((name: string) => { - if (registeredServices.has(name)) return registeredServices.get(name); - throw new Error('not found'); - }), - getServices: vi.fn().mockReturnValue(new Map()), - registerService: vi.fn().mockImplementation((name: string, svc: any) => { - registeredServices.set(name, svc); - }), - hook: vi.fn(), - trigger: vi.fn(), - getKernel: vi.fn(), - }; - - // Disable real plugins (which need real packages) but allow stubs - const plugin = new DevPlugin({ - seedAdminUser: false, - services: { - objectql: false, - driver: false, - auth: false, - setup: false, - security: false, - server: false, - rest: false, - dispatcher: false, - }, - }); - - await plugin.init(ctx); + // [ADR-0115 D1] The stub table is retired. This plugin registers NO service + // implementations of its own: with every child plugin unavailable, init() + // must complete without putting anything into any slot. An empty slot is + // the honest signal — identical to production — and the retired fakes + // (allow-all security.*, verify()→admin auth, success-without-work + // automation/notification/ai, the shapeless `ui` placeholder) must never + // come back. This test is the tombstone's guard. + it('registers no service implementation of its own — every unfilled slot stays empty', async () => { + const { ctx, registeredServices } = mockCtx(); + + await new DevPlugin({ seedAdminUser: false }).init(ctx); + + // Not one slot was filled by this plugin itself. + expect(ctx.registerService).not.toHaveBeenCalled(); + expect(registeredServices.size).toBe(0); + + // Pin the retired names explicitly so a partial re-introduction of the + // table fails with the slot's name in the message. + for (const name of [ + 'data', 'auth', 'ui', + 'security.permissions', 'security.rls', 'security.fieldMasker', + 'ai', 'automation', 'notification', 'analytics', + 'cache', 'queue', 'job', 'i18n', 'metadata', + 'file-storage', 'search', 'realtime', 'workflow', + ]) { + expect(registeredServices.has(name), `${name} slot must stay empty`).toBe(false); + } - // Should have registered stubs for all core + security services + // And the boot log no longer advertises stubs. const stubLog = ctx.logger.info.mock.calls.find( - (call: any[]) => typeof call[0] === 'string' && call[0].includes('dev stubs registered'), + (call: any[]) => typeof call[0] === 'string' && call[0].includes('dev stub'), ); - expect(stubLog).toBeDefined(); - - // ── Verify ICacheService contract ── - const cache = registeredServices.get('cache'); - // [#4058] The wrapped kernel fallback keeps its OWN self-description — - // plugin-dev never overwrites one, and `createMemoryCache` knows better - // than this plugin what it is (a real cache, process-local). - expect(readServiceSelfInfo(cache)?.status).toBe('degraded'); - await cache.set('k1', 'v1'); - expect(await cache.get('k1')).toBe('v1'); - expect(await cache.has('k1')).toBe(true); - expect(await cache.delete('k1')).toBe(true); - expect(await cache.has('k1')).toBe(false); - const stats = await cache.stats(); - expect(typeof stats.hits).toBe('number'); - expect(typeof stats.misses).toBe('number'); - expect(typeof stats.keyCount).toBe('number'); - - // ── Verify IQueueService contract ── - const queue = registeredServices.get('queue'); - const msgId = await queue.publish('test-q', { hello: 'world' }); - expect(typeof msgId).toBe('string'); - expect(await queue.getQueueSize('test-q')).toBe(0); - - // ── Verify IJobService contract ── - const job = registeredServices.get('job'); - const jobs = await job.listJobs(); - expect(Array.isArray(jobs)).toBe(true); - - // ── Verify IStorageService contract ── - const storage = registeredServices.get('file-storage'); - await storage.upload('test.txt', Buffer.from('hello')); - expect(await storage.exists('test.txt')).toBe(true); - const info = await storage.getInfo('test.txt'); - expect(info.key).toBe('test.txt'); - expect(info.size).toBeGreaterThan(0); - const downloaded = await storage.download('test.txt'); - expect(downloaded.toString()).toBe('hello'); - - // ── Verify ISearchService contract ── - const search = registeredServices.get('search'); - await search.index('users', '1', { name: 'Alice' }); - const searchResult = await search.search('users', 'alice'); - expect(searchResult.hits).toHaveLength(1); - expect(typeof searchResult.totalHits).toBe('number'); - - // ── Verify IAutomationService contract ── - const automation = registeredServices.get('automation'); - const execResult = await automation.execute('test_flow'); - expect(execResult.success).toBe(true); - expect(Array.isArray(await automation.listFlows())).toBe(true); - - // ── analytics: deliberately NOT stubbed (#4000) ── - // #3891/#3989 retired the degraded analytics shim so an empty slot is the - // honest signal (route unmounted → 404, discovery `unavailable`). A dev - // stub refilled the slot and the dispatcher, gating on presence alone, - // served its fabricated rows with a 200 — the retired shape, one layer - // down. The slot stays empty; install @objectstack/service-analytics. - expect(registeredServices.has('analytics')).toBe(false); - expect(stubLog[0]).not.toContain('analytics'); - - // ── Verify IRealtimeService contract ── - const realtime = registeredServices.get('realtime'); - const subId = await realtime.subscribe('ch', () => {}); - expect(typeof subId).toBe('string'); - - // ── Verify INotificationService contract ── - const notif = registeredServices.get('notification'); - const notifResult = await notif.send({ channel: 'email', to: 'test@dev', body: 'hello' }); - expect(notifResult.success).toBe(true); - expect(typeof notifResult.messageId).toBe('string'); - - // ── Verify IAIService contract ── - const ai = registeredServices.get('ai'); - const chatResult = await ai.chat([{ role: 'user', content: 'hi' }]); - expect(typeof chatResult.content).toBe('string'); - expect(typeof chatResult.model).toBe('string'); - expect(Array.isArray(await ai.listModels())).toBe(true); - - // ── Verify II18nService contract ── - const i18n = registeredServices.get('i18n'); - i18n.loadTranslations('en', { 'hello': 'Hello {{name}}' }); - expect(i18n.t('hello', 'en', { name: 'World' })).toBe('Hello World'); - expect(i18n.t('missing', 'en')).toBe('missing'); - expect(Array.isArray(i18n.getLocales())).toBe(true); + expect(stubLog).toBeUndefined(); - // ── Verify IWorkflowService contract ── - const workflow = registeredServices.get('workflow'); - const transResult = await workflow.transition({ recordId: 'r1', object: 'order', targetState: 'approved' }); - expect(transResult.success).toBe(true); - expect(transResult.currentState).toBe('approved'); - const status = await workflow.getStatus('order', 'r1'); - expect(status.currentState).toBe('approved'); - expect(Array.isArray(status.availableTransitions)).toBe(true); - - // ── Verify IMetadataService contract (stub fallback) ── - const metadata = registeredServices.get('metadata'); - metadata.register('object', { name: 'account' }); - expect(metadata.get('object', 'account')).toBeDefined(); - expect(Array.isArray(metadata.list('object'))).toBe(true); - expect(Array.isArray(metadata.listObjects())).toBe(true); - - // Security sub-services are registered by either the real SecurityPlugin - // or dev stubs (when security is disabled, they're skipped entirely). - // The stubs follow the same contracts as the real implementations. - }); - - // [#4058] The classification is the deliverable, so it is pinned here rather - // than left to a code review of the table. Every dev implementation must say - // what it IS: `degraded` when it really does the work with reduced capability, - // `stub` when the answer is fabricated. One blanket `_dev: true` used to - // declare all of them equally fake, which is why the two could not be told - // apart — and why a consumer had no way to distinguish "search works here" - // from "this AI reply is invented". - it('every dev implementation self-describes honestly (degraded vs stub)', async () => { - const registeredServices = new Map(); - const ctx: any = { - logger: { info: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn() }, - getService: vi.fn().mockImplementation((name: string) => { - if (registeredServices.has(name)) return registeredServices.get(name); - throw new Error('not found'); - }), - getServices: vi.fn().mockReturnValue(new Map()), - registerService: vi.fn().mockImplementation((name: string, svc: any) => { - registeredServices.set(name, svc); - }), - hook: vi.fn(), - trigger: vi.fn(), - getKernel: vi.fn(), - }; - - // Only the plugin-loading toggles are turned off — `auth` and `security` - // stay ON so their stub slots are exercised too (the real plugins behind - // them are mocked away as missing at the top of this file, so init falls - // through to the stubs exactly as a dev boot without them would). - await new DevPlugin({ - seedAdminUser: false, - services: { - objectql: false, driver: false, setup: false, - server: false, rest: false, dispatcher: false, - }, - }).init(ctx); - - // Really do the work, just less of it — their answers are true answers. - const DEGRADED = ['cache', 'queue', 'job', 'file-storage', 'search', 'realtime', 'i18n', 'workflow', 'metadata']; - // Fabricate the answer — must never be mistaken for a capability. - // (`security.*` are no longer in this list because they are no longer - // registered at all — see the dedicated test below, #4093.) - const STUB = ['automation', 'notification', 'ai', 'data', 'auth']; - - for (const name of DEGRADED) { - const info = readServiceSelfInfo(registeredServices.get(name)); - expect(info?.status, `${name} must be degraded`).toBe('degraded'); - expect(info?.message, `${name} must explain what is missing`).toBeTruthy(); - } - for (const name of STUB) { - const info = readServiceSelfInfo(registeredServices.get(name)); - expect(info?.status, `${name} must be stub`).toBe('stub'); - // A fabricated answer is never served by a ready handler. - expect(info?.handlerReady, `${name} handlerReady`).toBe(false); - expect(info?.message, `${name} must say what it fakes`).toBeTruthy(); - } - - // No HTTP/WS surface is mounted for these, so no handler can be ready — - // independent of the implementation being real (ADR-0076 D12, realtime). - // `file-storage` joined them in #4087: the dispatcher's `/storage` bridge - // was the only thing that ever routed HTTP to this slot, and it was - // retired — `@objectstack/service-storage` owns that surface and mounts - // its own routes against its own adapters, never this implementation. - for (const name of ['cache', 'queue', 'job', 'realtime', 'file-storage']) { - expect(readServiceSelfInfo(registeredServices.get(name))?.handlerReady, `${name} handlerReady`).toBe(false); - } - - // `ui` has no factory at all — the shapeless placeholder must still be - // honest about being nothing. - const ui = readServiceSelfInfo(registeredServices.get('ui')); - expect(ui?.status).toBe('stub'); - expect(ui?.handlerReady).toBe(false); - - // The retired analytics slot stays empty (#4000). - expect(registeredServices.has('analytics')).toBe(false); - }); - - // [#4093] The one thing a fallback may never fake. ADR-0076 D12, from #3891: - // "a fallback may degrade features, never security semantics" — and these - // three did not merely degrade, they inverted. `checkObjectPermission()` - // answered `true` for everything, `compileFilter()` returned `null` so no - // row-level predicate applied, `maskResults()` returned rows unmasked. The - // contract calls them plugin-security's internals and requires - // access-narrowing answers to fail CLOSED; a fake registered by another - // package under those names is the opposite. The slots stay empty. - it('registers NO security sub-service stub, and says so loudly', async () => { - const registered = new Map(); - const ctx: any = { - logger: { info: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn() }, - getService: vi.fn().mockImplementation((name: string) => { - if (registered.has(name)) return registered.get(name); - throw new Error('not found'); - }), - getServices: vi.fn().mockReturnValue(new Map()), - registerService: vi.fn().mockImplementation((n: string, s: any) => { registered.set(n, s); }), - hook: vi.fn(), - trigger: vi.fn(), - getKernel: vi.fn(), - }; - - // `security` left ENABLED — plugin-security is mocked as missing at the top - // of this file, so this is exactly the boot that used to get allow-all. - await new DevPlugin({ - seedAdminUser: false, - services: { objectql: false, driver: false, setup: false, server: false, rest: false, dispatcher: false }, - }).init(ctx); - - for (const slot of ['security.permissions', 'security.rls', 'security.fieldMasker']) { - expect(registered.has(slot), `${slot} must NOT be registered`).toBe(false); - } - - // "Nothing is enforcing RBAC/RLS/masking" is not a silent condition. - const warned = ctx.logger.warn.mock.calls.find( - (call: any[]) => typeof call[0] === 'string' && call[0].includes('No security services'), + // The empty security slots are still called out loudly (#4126's boot-log + // honesty): empty beats a fake that answers "allowed", but silence about + // unenforced RBAC/RLS/masking would be its own kind of fake. + const securityWarn = ctx.logger.warn.mock.calls.find( + (call: any[]) => typeof call[0] === 'string' && call[0].includes('NOT enforced'), ); - expect(warned, 'must warn that security is unenforced').toBeDefined(); - expect(warned[0]).toContain('plugin-security'); + expect(securityWarn).toBeDefined(); }); - // [#4093] The package is published, has no environment check of its own, and - // registers fakes that report success for work they never did. A production - // process that loads it is misconfigured in a way no runtime behaviour can - // make safe, so refuse the boot instead of degrading quietly. - describe('production guard', () => { - const makeCtx = () => ({ - logger: { info: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn() }, - getService: vi.fn().mockImplementation(() => { throw new Error('not found'); }), - getServices: vi.fn().mockReturnValue(new Map()), - registerService: vi.fn(), - hook: vi.fn(), - trigger: vi.fn(), - getKernel: vi.fn(), - }) as any; - - // Restored per-case: these tests mutate process.env deliberately. - const withEnv = async (env: Record, run: () => Promise) => { - const saved: Record = {}; - for (const [k, v] of Object.entries(env)) { - saved[k] = process.env[k]; - if (v === undefined) delete process.env[k]; else process.env[k] = v; - } - try { await run(); } finally { - for (const [k, v] of Object.entries(saved)) { - if (v === undefined) delete process.env[k]; else process.env[k] = v; - } - } - }; + describe('production guard (ADR-0115 D6)', () => { + const savedNodeEnv = process.env.NODE_ENV; + const savedAllow = process.env.OS_ALLOW_DEV_PLUGIN; - it('refuses to initialize under NODE_ENV=production', async () => { - await withEnv({ NODE_ENV: 'production', OS_ALLOW_DEV_PLUGIN: undefined }, async () => { - const ctx = makeCtx(); - await expect(new DevPlugin({ seedAdminUser: false }).init(ctx)).rejects.toThrow(/NODE_ENV=production/); - // And nothing was registered before the refusal. - expect(ctx.registerService).not.toHaveBeenCalled(); - }); + afterEach(() => { + if (savedNodeEnv === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = savedNodeEnv; + if (savedAllow === undefined) delete process.env.OS_ALLOW_DEV_PLUGIN; + else process.env.OS_ALLOW_DEV_PLUGIN = savedAllow; }); - it('names the escape hatch in the refusal, and honours it', async () => { - await withEnv({ NODE_ENV: 'production', OS_ALLOW_DEV_PLUGIN: undefined }, async () => { - await expect(new DevPlugin({ seedAdminUser: false }).init(makeCtx())) - .rejects.toThrow(/OS_ALLOW_DEV_PLUGIN=1/); - }); - await withEnv({ NODE_ENV: 'production', OS_ALLOW_DEV_PLUGIN: '1' }, async () => { - await expect(new DevPlugin({ seedAdminUser: false }).init(makeCtx())).resolves.not.toThrow(); - }); + it('refuses to init when NODE_ENV=production', async () => { + process.env.NODE_ENV = 'production'; + delete process.env.OS_ALLOW_DEV_PLUGIN; + + const { ctx } = mockCtx(); + await expect(new DevPlugin({ seedAdminUser: false }).init(ctx)) + .rejects.toThrow(/NODE_ENV=production/); + // The refusal happens before any assembly work. + expect(ctx.registerService).not.toHaveBeenCalled(); }); - it('stays out of the way for every other NODE_ENV', async () => { - for (const value of ['development', 'test', undefined]) { - await withEnv({ NODE_ENV: value, OS_ALLOW_DEV_PLUGIN: undefined }, async () => { - await expect(new DevPlugin({ seedAdminUser: false }).init(makeCtx())).resolves.not.toThrow(); - }); - } + it('OS_ALLOW_DEV_PLUGIN=1 overrides the guard, deliberately', async () => { + process.env.NODE_ENV = 'production'; + process.env.OS_ALLOW_DEV_PLUGIN = '1'; + + const { ctx } = mockCtx(); + await expect(new DevPlugin({ seedAdminUser: false }).init(ctx)).resolves.not.toThrow(); }); }); + // [ADR-0115 D4] The slots the retired stubs used to fake are filled by the + // REAL service packages when installed (assembly, not simulation). With the + // packages mocked-missing the wiring must degrade to a logged skip that + // names the slot left empty — never to a fake. + it('storage/realtime auto-wire degrades to an honest skip when the packages are missing', async () => { + const { ctx, registeredServices } = mockCtx(); + + await new DevPlugin({ seedAdminUser: false }).init(ctx); + + const logLines = ctx.logger.info.mock.calls + .map((call: any[]) => call[0]) + .filter((line: any) => typeof line === 'string'); + expect(logLines.some((l: string) => l.includes('service-storage not installed'))).toBe(true); + expect(logLines.some((l: string) => l.includes('service-realtime not installed'))).toBe(true); + expect(registeredServices.has('file-storage')).toBe(false); + expect(registeredServices.has('realtime')).toBe(false); + }); + it('should skip disabled services', async () => { - const ctx: any = { - logger: { - info: vi.fn(), - debug: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }, - getService: vi.fn().mockImplementation(() => { throw new Error('not found'); }), - getServices: vi.fn().mockReturnValue(new Map()), - registerService: vi.fn(), - hook: vi.fn(), - trigger: vi.fn(), - getKernel: vi.fn(), - }; + const { ctx } = mockCtx(); const plugin = new DevPlugin({ seedAdminUser: false, @@ -420,35 +181,21 @@ describe('DevPlugin', () => { rest: false, dispatcher: false, security: false, - // Disable all core services too - metadata: false, - data: false, - cache: false, - queue: false, - job: false, + i18n: false, 'file-storage': false, - search: false, - automation: false, - graphql: false, - analytics: false, realtime: false, - notification: false, - ai: false, - i18n: false, - ui: false, - workflow: false, }, }); await plugin.init(ctx); - // No child plugins AND no stubs should be registered + // No child plugins at all — and nothing registered. const initLog = ctx.logger.info.mock.calls.find( (call: any[]) => typeof call[0] === 'string' && call[0].includes('initialized'), ); expect(initLog).toBeDefined(); expect(initLog[0]).toContain('0 plugin'); - expect(initLog[0]).toContain('0 dev stub'); + expect(ctx.registerService).not.toHaveBeenCalled(); }); it('should destroy without errors', async () => { diff --git a/packages/plugins/plugin-dev/src/dev-plugin.ts b/packages/plugins/plugin-dev/src/dev-plugin.ts index 276e05493e..ae2e5a08d0 100644 --- a/packages/plugins/plugin-dev/src/dev-plugin.ts +++ b/packages/plugins/plugin-dev/src/dev-plugin.ts @@ -1,407 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { Plugin, PluginContext, createMemoryCache, createMemoryQueue, createMemoryJob, createMemoryI18n } from '@objectstack/core'; +import { Plugin, PluginContext } from '@objectstack/core'; import { resolveMultiOrgEnabled } from '@objectstack/types'; -import { SERVICE_SELF_INFO_KEY } from '@objectstack/spec/api'; - -/** - * All 17 core kernel service names as defined in CoreServiceName. - * @see packages/spec/src/system/core-services.zod.ts - */ -const CORE_SERVICE_NAMES = [ - 'metadata', 'data', 'auth', - 'file-storage', 'search', 'cache', 'queue', - 'automation', 'analytics', 'realtime', - 'job', 'notification', 'ai', 'i18n', 'ui', 'workflow', -] as const; - -/** - * Security sub-services registered by the SecurityPlugin — and, deliberately, - * by NOTHING ELSE (#4093). - * - * This plugin used to stub all three when SecurityPlugin was absent. Each fake - * inverted the decision it stood in for: `checkObjectPermission()` answered - * `true` for everything, `compileFilter()` returned `null` so no row-level - * predicate was applied, and `maskResults()` handed rows back unmasked. - * - * That is the one thing ADR-0076 D12 says a fallback may never do. Its rule, - * learned from #3891's analytics shim: **a fallback may degrade features, never - * security semantics** — and the shim only dropped the caller's RLS scoping, - * where these three answered "allowed" outright. The contract agrees from the - * other side: `packages/spec/src/contracts/security-service.ts` calls these - * three "implementation internals and deliberately NOT part of this contract", - * and specifies that access-narrowing answers **fail CLOSED** — "a consumer - * must never treat a thrown error or a deny filter as 'no restriction'". - * Fakes registered under those names by a *different* package are the exact - * opposite of failing closed. - * - * Triggering it took no exotic setup: this plugin loads SecurityPlugin through - * the same optional dynamic import as everything else, so `@objectstack/ - * plugin-security` merely not being installed swapped real RBAC/RLS/masking for - * allow-all, behind one `warn` line. - * - * The slots now stay empty, which is what production has without SecurityPlugin, - * and the consumers already handle: the enforcement paths live inside the plugin - * itself (registered hooks), and the only reader of a slot — - * `plugin-hono-server`'s `/auth/me/permissions` + `/me/apps` — resolves it - * defensively and fails open on *presentation* only, over data the read path has - * already enforced. Kept as a named list because the registration loop must - * still skip these slots and say why. - */ -const SECURITY_SERVICE_NAMES = [ - 'security.permissions', 'security.rls', 'security.fieldMasker', -] as const; - -/** - * Contract-compliant dev stub implementations. - * - * Each stub implements the interface defined in `packages/spec/src/contracts/` - * (e.g. ICacheService, IQueueService, IAutomationService, …) so that - * downstream code calling these services in dev mode receives the correct - * return types — not just `undefined`. - * - * Where an interface method is optional (marked with `?`), the stub only - * implements the required methods plus any optional ones that have - * a trivially useful implementation. - */ - -/** IStorageService — in-memory file storage stub */ -function createStorageStub() { - const files = new Map(); - return { - _serviceName: 'file-storage', - async upload(key: string, data: any, options?: any): Promise { - files.set(key, { data: Buffer.from(data), meta: { contentType: options?.contentType, metadata: options?.metadata } }); - }, - async download(key: string): Promise { return files.get(key)?.data ?? Buffer.alloc(0); }, - async delete(key: string): Promise { files.delete(key); }, - async exists(key: string): Promise { return files.has(key); }, - async getInfo(key: string) { - const f = files.get(key); - return { key, size: f?.data?.length ?? 0, contentType: f?.meta?.contentType, lastModified: new Date(), metadata: f?.meta?.metadata }; - }, - async list(prefix: string) { - return [...files.entries()].filter(([k]) => k.startsWith(prefix)).map(([key, f]) => - ({ key, size: f.data.length, contentType: f.meta?.contentType, lastModified: new Date() })); - }, - }; -} - -/** ISearchService — in-memory full-text search stub */ -function createSearchStub() { - const indexes = new Map>>(); - return { - _serviceName: 'search', - async index(object: string, id: string, document: Record): Promise { - if (!indexes.has(object)) indexes.set(object, new Map()); - indexes.get(object)!.set(id, document); - }, - async remove(object: string, id: string): Promise { indexes.get(object)?.delete(id); }, - async search(object: string, query: string) { - const docs = indexes.get(object) ?? new Map(); - const q = query.toLowerCase(); - const hits = [...docs.entries()] - .filter(([, doc]) => JSON.stringify(doc).toLowerCase().includes(q)) - .map(([id, doc]) => ({ id, score: 1, document: doc })); - return { hits, totalHits: hits.length, processingTimeMs: 0 }; - }, - async bulkIndex(object: string, documents: Array<{ id: string; document: Record }>): Promise { - if (!indexes.has(object)) indexes.set(object, new Map()); - for (const d of documents) { - indexes.get(object)!.set(d.id, d.document); - } - }, - async deleteIndex(object: string): Promise { indexes.delete(object); }, - }; -} - -/** IAutomationService — no-op flow execution stub */ -function createAutomationStub() { - const flows = new Map(); - return { - _serviceName: 'automation', - async execute(_flowName: string) { return { success: true, output: undefined, durationMs: 0 }; }, - async listFlows(): Promise { return [...flows.keys()]; }, - registerFlow(name: string, definition: unknown) { flows.set(name, definition); }, - unregisterFlow(name: string) { flows.delete(name); }, - }; -} - - -/** IRealtimeService — in-memory pub/sub stub */ -function createRealtimeStub() { - const subs = new Map(); - let subId = 0; - return { - _serviceName: 'realtime', - async publish(event: any): Promise { for (const fn of subs.values()) fn(event); }, - async subscribe(_channel: string, handler: Function): Promise { - const id = `dev-sub-${++subId}`; subs.set(id, handler); return id; - }, - async unsubscribe(subscriptionId: string): Promise { subs.delete(subscriptionId); }, - }; -} - -/** INotificationService — in-memory log stub */ -function createNotificationStub() { - const sent: any[] = []; - return { - _serviceName: 'notification', - async send(message: any) { sent.push(message); return { success: true, messageId: `dev-notif-${sent.length}` }; }, - async sendBatch(messages: any[]) { return messages.map(m => { sent.push(m); return { success: true, messageId: `dev-notif-${sent.length}` }; }); }, - getChannels() { return ['email', 'in-app'] as const; }, - }; -} - -/** IAIService — dev stub returning placeholder responses */ -function createAIStub() { - return { - _serviceName: 'ai', - async chat() { return { content: '[dev-stub] AI not available in development mode', model: 'dev-stub' }; }, - async complete() { return { content: '[dev-stub] AI not available in development mode', model: 'dev-stub' }; }, - async embed() { return [[0]]; }, - async listModels() { return ['dev-stub']; }, - }; -} - -/** II18nService — delegates to createMemoryI18n from core with locale fallback */ -function createI18nStub() { - const base = createMemoryI18n(); - return { - ...base, - _serviceName: 'i18n', - }; -} - -/** IWorkflowService — in-memory workflow state stub */ -function createWorkflowStub() { - const states = new Map(); // recordKey → currentState - const key = (obj: string, id: string) => `${obj}:${id}`; - return { - _serviceName: 'workflow', - async transition(t: any) { - states.set(key(t.object, t.recordId), t.targetState); - return { success: true, currentState: t.targetState }; - }, - async getStatus(object: string, recordId: string) { - return { recordId, object, currentState: states.get(key(object, recordId)) ?? 'draft', availableTransitions: [] }; - }, - async getHistory() { return []; }, - }; -} - -/** IMetadataService — in-memory metadata registry stub (fallback) */ -function createMetadataStub() { - const store = new Map>(); // type → (name → def) - return { - _serviceName: 'metadata', - register(type: string, nameOrDef: string | Record, data?: unknown) { - if (!store.has(type)) store.set(type, new Map()); - if (typeof nameOrDef === 'object' && nameOrDef !== null) { - const key = nameOrDef.name ?? nameOrDef.id ?? 'unknown'; - store.get(type)!.set(key, nameOrDef); - } else { - store.get(type)!.set(nameOrDef, data); - } - }, - // Mirror MetadataManager.registerInMemory — AppPlugin gates code-defined - // datasource / stack-RBAC registration on its presence (see - // packages/core/src/fallbacks/memory-metadata.ts). - registerInMemory(type: string, nameOrDef: string | Record, data?: unknown) { - if (!store.has(type)) store.set(type, new Map()); - if (typeof nameOrDef === 'object' && nameOrDef !== null) { - const key = nameOrDef.name ?? nameOrDef.id ?? 'unknown'; - store.get(type)!.set(key, nameOrDef); - } else { - store.get(type)!.set(nameOrDef, data); - } - }, - get(type: string, name: string) { return store.get(type)?.get(name); }, - list(type: string) { return [...(store.get(type)?.values() ?? [])]; }, - unregister(type: string, name: string) { store.get(type)?.delete(name); }, - exists(type: string, name: string) { return store.get(type)?.has(name) ?? false; }, - listNames(type: string) { return [...(store.get(type)?.keys() ?? [])]; }, - getObject(name: string) { return store.get('object')?.get(name); }, - listObjects() { return [...(store.get('object')?.values() ?? [])]; }, - unregisterPackage() {}, - }; -} - -/** IAuthService — dev auth stub returning success for all */ -function createAuthStub() { - return { - _serviceName: 'auth', - async handleRequest() { return new Response(JSON.stringify({ success: true }), { status: 200 }); }, - async verify() { return { success: true, user: { id: 'dev-admin', email: 'admin@dev.local', name: 'Admin', roles: ['admin'] } }; }, - async logout() {}, - async getCurrentUser() { return { id: 'dev-admin', email: 'admin@dev.local', name: 'Admin', roles: ['admin'] }; }, - }; -} - -/** IDataEngine — minimal no-op data stub (fallback) */ -function createDataStub() { - return { - _serviceName: 'data', - async find() { return []; }, - async findOne() { return undefined; }, - async insert(_obj: string, params: any) { return { id: `dev-${Date.now()}`, ...params?.data }; }, - async update(_obj: string, _id: string, params: any) { return params?.data ?? {}; }, - async delete() { return true; }, - async count() { return 0; }, - async aggregate() { return []; }, - }; -} - -// [#4093] The three security sub-service stubs are GONE — see -// SECURITY_SERVICE_NAMES below for why. They were: -// -// security.permissions → checkObjectPermission() { return true; } // allow-all -// security.rls → compileFilter() { return null; } // no predicate -// security.fieldMasker → maskResults(r) { return r; } // unmasked -// -// Nothing replaces them: an empty slot is what production has when -// SecurityPlugin isn't installed, and every consumer already handles it. - -/** - * Map of service names → contract-compliant stub factory functions. - * Each factory creates a new instance implementing the protocol interface - * from `packages/spec/src/contracts/`. - */ -const DEV_STUB_FACTORIES: Record Record> = { - 'cache': () => createMemoryCache(), - 'queue': () => createMemoryQueue(), - 'job': () => createMemoryJob(), - 'file-storage': createStorageStub, - 'search': createSearchStub, - 'automation': createAutomationStub, - 'realtime': createRealtimeStub, - 'notification': createNotificationStub, - 'ai': createAIStub, - 'i18n': createI18nStub, - 'workflow': createWorkflowStub, - 'metadata': createMetadataStub, - 'data': createDataStub, - 'auth': createAuthStub, -}; - -/** - * How each dev implementation describes ITSELF (ADR-0076 D12 `__serviceInfo`), - * in one reviewable table — the point of #4058. - * - * Every stub used to carry the same `_dev: true`, which `readServiceSelfInfo` - * normalizes to `{ status: 'stub', handlerReady: false }`. That single marker - * declared all of these equally fake, and so could not tell apart the two - * kinds actually in here: - * - * - **`degraded`** — the implementation really does the work, with reduced - * capability (no persistence, no cross-process scope, no validation). Its - * answers are true answers. `storage` really stores and returns the bytes; - * `search` really indexes and matches; `metadata` really registers and lists. - * Calling these "stub" understated them and pushed consumers to ignore a - * capability that works. - * - **`stub`** — the answer is fabricated. `ai.chat` returns invented text, - * `automation.execute` reports success without running the flow, - * `notification.send` claims delivery and delivers nothing, `data` accepts - * writes and stores none, `auth.verify` waves everyone through as an admin, - * and the `security.*` trio answers allow-all. These must never be mistaken - * for a capability — the AI stub already misled an agent (ADR-0076 D12). - * - * `handlerReady` answers a different question: does an HTTP handler genuinely - * serve this? It stays `false` for every `stub`, and also for `degraded` - * services with no HTTP surface at all (`realtime` — the D12 precedent, joined - * by `file-storage` in #4087: the dispatcher's `/storage` bridge — the one - * thing that ever routed HTTP to this slot — was retired, and the surface it - * claimed belongs to `@objectstack/service-storage`, which mounts its own - * routes and does not use this implementation). - * - * Entries are only needed for plugin-dev's OWN fakes. The wrapped kernel - * fallbacks (`cache` / `queue` / `job` / `i18n`) already carry their own - * `__serviceInfo`, and {@link applySelfInfo} never overwrites one. - */ -const DEV_STUB_SELF_INFO: Record = { - 'file-storage': { status: 'degraded', handlerReady: false, message: 'Dev in-memory file storage — really stores and returns bytes to in-process callers, but nothing survives a restart and no HTTP surface is mounted. Register @objectstack/service-storage for durable files and the upload/download protocol.' }, - 'search': { status: 'degraded', message: 'Dev in-memory index — real substring matching over indexed documents, no ranking, analyzers, or persistence. Register a search plugin for the real engine.' }, - 'metadata': { status: 'degraded', message: 'Dev in-memory metadata registry — real reads and writes, no persistence. Register MetadataPlugin for a persisted registry.' }, - 'workflow': { status: 'degraded', message: 'Dev in-memory workflow state — transitions are recorded and read back, but NOTHING is validated: every transition is accepted and availableTransitions is always empty.' }, - 'realtime': { status: 'degraded', handlerReady: false, message: 'Dev in-process pub/sub — real delivery to in-process subscribers, but no HTTP or WebSocket surface is mounted.' }, - 'automation': { status: 'stub', message: 'Dev stub — execute() reports success WITHOUT running the flow. Register an automation plugin to actually run flows.' }, - 'notification': { status: 'stub', message: 'Dev stub — messages are recorded in memory and reported as sent; nothing is delivered. Register a notification plugin to actually send.' }, - 'ai': { status: 'stub', message: 'Dev stub — replies are placeholder text, not model output. Register AIServicePlugin from @objectstack/service-ai for real completions.' }, - 'data': { status: 'stub', message: 'Dev stub — find() always returns [], insert() mints an id and stores nothing. Register ObjectQLPlugin for a real engine.' }, - 'auth': { status: 'stub', message: 'Dev stub — verify() accepts EVERY request as a fixed dev admin. Never use outside local development; register plugin-auth for real authentication.' }, -}; - -/** Self-description for a slot with no factory at all (see the registration loop). */ -const SHAPELESS_STUB_SELF_INFO = { - status: 'stub' as const, - handlerReady: false, - message: 'Dev placeholder with no implementation — the slot is occupied so lookups do not throw, and nothing else.', -}; - -/** - * Attach the D12 self-description, without ever overwriting one the - * implementation already carries: the kernel fallbacks this plugin wraps - * (`createMemoryCache` & co.) describe themselves, and their own account of - * what they do beats anything this table could restate. - */ -function applySelfInfo(svc: Record, info: Record): Record { - if (SERVICE_SELF_INFO_KEY in svc) return svc; - return Object.assign(svc, { [SERVICE_SELF_INFO_KEY]: info }); -} - -/** - * Core service slots that deliberately get NO dev stub — not even the - * shapeless placeholder the registration loop uses for slots - * without a factory. - * - * `analytics` (#4000): #3891/#3989 retired the degraded analytics shim, making - * an unoccupied slot the honest signal — `/analytics/*` is not mounted, the - * request 404s, and discovery reports `unavailable`. Registering a dev stub - * re-filled that slot and re-created the retired shape one layer down: the - * dispatcher gates on service presence, so a stub was called like a real - * engine and answered 200 with fabricated rows. Dev mode is explicit opt-in - * and the fake was empty, so this was never the security hole the shim was — - * but "the capability is present" must mean the same thing in dev as in - * production. To use analytics locally, install the real engine: - * `@objectstack/service-analytics` runs an InMemory strategy. - */ -const NO_DEV_STUB_SERVICES = new Set(['analytics']); - -/** - * Escape hatch for {@link assertNotProduction} — deliberately ungrouped and - * scary-looking per the `OS_ALLOW_{X}` convention (AGENTS.md Prime Directive #9). - */ -const ALLOW_IN_PRODUCTION_ENV = 'OS_ALLOW_DEV_PLUGIN' as const; - -/** - * [#4093] Refuse to initialize under `NODE_ENV=production`. - * - * This plugin's whole purpose is to make a local stack work without installing - * anything: it fills unclaimed service slots with fakes. Several of those - * fabricate their answers (`data` discards writes and reports success), and it - * ships as a published package with no environment check of its own — so an - * `objectstack.config.ts` that carries `new DevPlugin()` into a production - * deploy got the whole fake slate silently, with only a boot log to say so. - * - * Failing the boot is the right response rather than degrading quietly: a - * production process that reaches this line is misconfigured in a way no - * runtime behaviour can make safe, and the fakes are exactly the kind of thing - * that looks like it works. `OS_ALLOW_DEV_PLUGIN=1` overrides it for the - * deliberate cases (a staging box mimicking prod, a smoke test that pins - * `NODE_ENV`), and says at the call site that someone chose this. - */ -function assertNotProduction(): void { - if (process.env.NODE_ENV !== 'production') return; - if (process.env[ALLOW_IN_PRODUCTION_ENV] === '1') return; - throw new Error( - '@objectstack/plugin-dev refuses to initialize with NODE_ENV=production. ' - + 'It registers development fakes for every unclaimed core service slot — including ones that ' - + 'report success for work they never did — so a production process must not load it. ' - + 'Remove DevPlugin from this deployment\'s plugin list and install the real services ' - + `(the boot log names each fake it would have registered), or set ${ALLOW_IN_PRODUCTION_ENV}=1 ` - + 'if you deliberately want the dev slate under a production NODE_ENV.', - ); -} /** * Dev Plugin Options @@ -442,12 +42,15 @@ export interface DevPluginOptions { verbose?: boolean; /** - * Override which services to enable. By default all core services are enabled. - * Set a service name to `false` to skip it. + * Override which parts of the assembly to enable. By default everything + * this plugin can wire is enabled. Set a name to `false` to skip it. + * + * Available toggles: 'objectql', 'driver', 'auth', 'server', 'rest', + * 'dispatcher', 'security', 'i18n', 'file-storage', 'realtime'. * - * Available services: 'objectql', 'driver', 'auth', 'server', 'rest', - * 'dispatcher', 'security', plus any of the 17 CoreServiceName values - * (e.g. 'cache', 'queue', 'job', 'ui', 'automation', 'workflow', …). + * Toggles for the retired dev stubs (ADR-0115 — 'cache', 'queue', 'ai', + * 'automation', …) are accepted and ignored: those slots are no longer + * filled by this plugin at all. */ services?: Partial>; @@ -477,12 +80,38 @@ export interface DevPluginOptions { } /** - * Development Mode Plugin for ObjectStack + * Escape hatch for {@link assertNotProduction} — deliberately ungrouped and + * scary-looking per the `OS_ALLOW_{X}` convention (AGENTS.md Prime Directive #9). + */ +const ALLOW_IN_PRODUCTION_ENV = 'OS_ALLOW_DEV_PLUGIN' as const; + +/** + * [ADR-0115 D6] Refuse to initialize under `NODE_ENV=production`. * - * A convenience plugin that auto-configures the **entire** platform stack - * for local development, simulating **all 17+ kernel services** so developers - * can work in a full-featured API environment without external dependencies. + * The stack this plugin assembles is built around a well-known default auth + * secret and a seeded dev admin; nothing about it belongs in production, and + * failing the boot beats degrading quietly — a production process that reaches + * this line is misconfigured in a way no runtime behaviour can make safe. + * The escape hatch covers the deliberate cases (a staging box mimicking prod, + * a smoke test that pins `NODE_ENV`), and says at the call site that someone + * chose this. + */ +function assertNotProduction(): void { + if (process.env.NODE_ENV !== 'production') return; + if (process.env[ALLOW_IN_PRODUCTION_ENV] === '1') return; + throw new Error( + '@objectstack/plugin-dev refuses to initialize with NODE_ENV=production. ' + + 'It assembles a development stack around a well-known default auth secret and a seeded ' + + 'dev admin, so a production process must not load it. Remove DevPlugin from this ' + + `deployment's plugin list, or set ${ALLOW_IN_PRODUCTION_ENV}=1 if you deliberately want ` + + 'the dev assembly under a production NODE_ENV.', + ); +} + +/** + * Development Assembly Plugin for ObjectStack * + * One plugin that wires the **real** platform stack for local development. * Instead of manually wiring: * * ```ts @@ -504,7 +133,7 @@ export interface DevPluginOptions { * plugins: [new DevPlugin()] * ``` * - * ## Core services (real implementations) + * ## What it assembles (all real implementations) * * | Service | Package | Description | * |--------------|-----------------------------------|-------------------------------------------| @@ -514,24 +143,34 @@ export interface DevPluginOptions { * | Security | `@objectstack/plugin-security` | RBAC, RLS, field-level masking | * | HTTP Server | `@objectstack/plugin-hono-server` | HTTP server on configured port | * | REST API | `@objectstack/rest` | Auto-generated CRUD + metadata endpoints | - * | Dispatcher | `@objectstack/runtime` | Auth, GraphQL, analytics, packages, etc. | + * | Dispatcher | `@objectstack/runtime` | Auth, GraphQL, packages, storage, etc. | * | App/Metadata | `@objectstack/runtime` | Project metadata (objects, views, apps) | + * | Storage | `@objectstack/service-storage` | file-storage service (local-disk adapter) | + * | Realtime | `@objectstack/service-realtime` | realtime service (in-memory adapter) | + * | I18n | `@objectstack/service-i18n` | When the stack declares translations | + * + * Every part is loaded via dynamic import and skipped (with a log line) when + * its package is not installed, and can be disabled via `options.services`. * - * ## Stub services (contract-compliant in-memory implementations) + * ## Empty slots stay empty (ADR-0115) * - * Any core service not provided by a real plugin is automatically registered - * as a contract-compliant dev stub that implements the interface from - * `packages/spec/src/contracts/`. Each stub returns correct types: + * This plugin registers **no service implementations of its own**. A + * capability whose plugin is not installed is absent, exactly as in + * production: its routes answer 404/501 and discovery reports it + * `unavailable`. The retired stub table used to fill every empty slot with a + * fabricated implementation — allow-all security answers, success reports + * for work that never ran — which made "the capability is present" mean + * different things in dev and production, and twice shipped answers a + * consumer trusted (ADR-0076 D12). To use a capability locally, install its + * real service (e.g. `@objectstack/service-analytics` for `/analytics` — it + * runs an InMemory strategy). * - * `cache` (Map-backed), `queue` (in-memory pub/sub), `job` (no-op scheduler), - * `file-storage` (Map-backed), `search` (in-memory text search), - * `automation` (no-op flows), `analytics` (empty results), - * `realtime` (in-memory pub/sub), `notification` (log), `ai` (placeholder), - * `i18n` (Map-backed translations), `ui` (Map-backed views/dashboards), - * `workflow` (Map-backed state machine) + * ## Production guard (ADR-0115 D6) * - * All services can be individually disabled via `options.services`. - * Peer packages are loaded via dynamic import and silently skipped if missing. + * `init()` refuses to run when `NODE_ENV === 'production'`: the assembly is + * built around a well-known default auth secret and a seeded dev admin. + * Escape hatch for the rare deliberate case: + * `OS_ALLOW_DEV_PLUGIN=1`. */ export class DevPlugin implements Plugin { name = 'com.objectstack.plugin.dev'; @@ -564,7 +203,8 @@ export class DevPlugin implements Plugin { */ async init(ctx: PluginContext): Promise { assertNotProduction(); - ctx.logger.info('🚀 DevPlugin initializing — auto-configuring all services for development'); + + ctx.logger.info('🚀 DevPlugin initializing — assembling the development stack'); const enabled = (name: string) => this.options.services?.[name] !== false; @@ -641,6 +281,32 @@ export class DevPlugin implements Plugin { // registers the static SETUP_APP from @objectstack/platform-objects/apps // as part of its manifest), so no separate child plugin is needed. + // 3d. Optional capability services (ADR-0115 D4) — the slots the retired + // dev stubs used to fake are filled by the REAL service packages when + // they are installed, following the same auto-detect pattern as 3b: + // `service-storage` registers `file-storage` (local-disk adapter, real + // files under ./storage), `service-realtime` registers `realtime` + // (its default in-memory adapter). Not installed → the slot stays + // empty, exactly as in production. + if (enabled('file-storage')) { + try { + const { StorageServicePlugin } = await import('@objectstack/service-storage') as any; + this.childPlugins.push(new StorageServicePlugin()); + ctx.logger.info(' ✔ Storage service enabled (@objectstack/service-storage, local adapter)'); + } catch { + ctx.logger.info(' ℹ @objectstack/service-storage not installed — the file-storage slot stays empty'); + } + } + if (enabled('realtime')) { + try { + const { RealtimeServicePlugin } = await import('@objectstack/service-realtime') as any; + this.childPlugins.push(new RealtimeServicePlugin()); + ctx.logger.info(' ✔ Realtime service enabled (@objectstack/service-realtime, in-memory adapter)'); + } catch { + ctx.logger.info(' ℹ @objectstack/service-realtime not installed — the realtime slot stays empty'); + } + } + // 4. Auth Plugin let authMounted = false; if (enabled('auth')) { @@ -773,49 +439,20 @@ export class DevPlugin implements Plugin { } } - // ── Register contract-compliant dev stubs for remaining services ──── - // The kernel defines 17 core services + 3 security services. - // Real plugins (ObjectQL, Auth, Security, etc.) already registered some. - // For any service NOT yet registered, we create a contract-compliant - // dev stub (implementing the interface from packages/spec/src/contracts/) - // so that the full kernel service map is populated and downstream code - // receives correct return types (arrays, booleans, objects — not undefined). - // Exception: NO_DEV_STUB_SERVICES slots stay empty on purpose (#4000). - // - // Each registered implementation carries its D12 self-description from - // DEV_STUB_SELF_INFO (#4058), so discovery reports what it actually is — - // `degraded` for the ones that really work, `stub` for the ones that - // fabricate — instead of one blanket `_dev: true` for all of them. - - const stubNames: string[] = []; - - /** Build + self-describe one slot's dev implementation. */ - const makeStub = (svc: string) => { - const factory = DEV_STUB_FACTORIES[svc]; - const info = DEV_STUB_SELF_INFO[svc] ?? SHAPELESS_STUB_SELF_INFO; - return applySelfInfo(factory ? factory() : { _serviceName: svc }, info); - }; - - for (const svc of CORE_SERVICE_NAMES) { - if (!enabled(svc)) continue; - if (NO_DEV_STUB_SERVICES.has(svc)) continue; - try { - ctx.getService(svc); - // Already registered by a real plugin — skip - } catch { - ctx.registerService(svc, makeStub(svc)); - stubNames.push(svc); - } - } - - // Security sub-services get NO stub (#4093, see SECURITY_SERVICE_NAMES): - // faking an authorization decision is the one thing ADR-0076 D12 forbids a - // fallback to do, and all three former stubs decided "allowed". An empty - // slot is what production has without SecurityPlugin. Say so once, loudly, - // because "no RBAC/RLS/masking is being enforced" is worth a line in the - // boot log of a stack that expected them. + // ── No stub registration (ADR-0115 D1) ────────────────────────────── + // The stub table that used to fill every remaining slot is retired. A + // slot no child plugin filled stays EMPTY — the honest production + // semantic: routes answer 404/501, discovery reports `unavailable`, and + // in-process consumers handle absence exactly as they already must in + // production. To use a capability locally, install its real service. + + // The security slots deserve one loud line when empty (#4126): faking an + // authorization decision is the one thing ADR-0076 D12 forbids a fallback + // to do, so the slots stay empty rather than stubbed — but "no RBAC/RLS/ + // masking is being enforced" is worth saying in the boot log of a stack + // that expected them. if (enabled('security')) { - const missing = SECURITY_SERVICE_NAMES.filter((svc) => { + const missing = ['security.permissions', 'security.rls', 'security.fieldMasker'].filter((svc) => { try { ctx.getService(svc); return false; } catch { return true; } }); if (missing.length > 0) { @@ -828,11 +465,7 @@ export class DevPlugin implements Plugin { } } - if (stubNames.length > 0) { - ctx.logger.info(` ✔ Contract-compliant dev stubs registered for: ${stubNames.join(', ')}`); - } - - ctx.logger.info(`DevPlugin initialized ${this.childPlugins.length} plugin(s) + ${stubNames.length} dev stub(s)`); + ctx.logger.info(`DevPlugin initialized ${this.childPlugins.length} plugin(s)`); } /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4fcd35d620..d60efea766 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1387,6 +1387,12 @@ importers: '@objectstack/service-i18n': specifier: workspace:^ version: link:../../services/service-i18n + '@objectstack/service-realtime': + specifier: workspace:^ + version: link:../../services/service-realtime + '@objectstack/service-storage': + specifier: workspace:^ + version: link:../../services/service-storage '@objectstack/setup': specifier: workspace:^ version: link:../../apps/setup