From 94155472553514bc2c4f6129d0fcac3c34554d0c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 10:52:22 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(plugin-dev)!:=20retire=20the=20stub=20?= =?UTF-8?q?table=20=E2=80=94=20DevPlugin=20assembles=20real=20plugins,=20e?= =?UTF-8?q?mpty=20slots=20stay=20empty=20(ADR-0115,=20#4104)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Executes ADR-0115 in full. The fill-every-slot stub table is deleted: the fabricators (data/auth/security.*/ui, plus the ai/automation/ notification trio #4086 left gated-but-registered), the plugin-dev-only in-memory tier (file-storage/search/realtime/workflow), and the duplicate metadata copy and createMemory* wrappers (the kernel pre-injects core's CORE_FALLBACK_FACTORIES for empty core slots, so behavior is unchanged there). New: init() refuses NODE_ENV=production (escape hatch OS_ALLOW_DEV_PLUGIN_IN_PRODUCTION=1, PD #9), and the assembly auto-wires @objectstack/service-storage (local-disk adapter) and @objectstack/service-realtime (in-memory adapter) when installed — the 3b i18n auto-detect pattern, so zero-config dev keeps working storage/realtime through real implementations. Docs converge on reality (packages.mdx entry, README, class doc, services-checklist i18n rows); breaking changeset carries the full FROM→TO table per retired slot. ADR-0115 gains the post-#4086 reconciliation note (three gated fabricators join Tier A; #4089 closed at source by #4082; executed as one PR at the maintainer's direction). Verified: plugin-dev 10/10, runtime 924/924, core 415/415, eslint clean; end-to-end boot shows retired slots empty (404 / handled=false, /ai/agents keeps its 200 empty-list courtesy), file-storage resolving to SwappableStorageService and realtime to InMemoryRealtimeAdapter, and the kernel's own fallback pre-injection intact. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BEM82RNKyyyTNPYHK1VJJS --- .changeset/retire-the-dev-stub-table.md | 33 ++ content/docs/kernel/services-checklist.mdx | 16 +- content/docs/plugins/packages.mdx | 6 +- ...0115-plugin-dev-assembly-not-stub-table.md | 8 + packages/plugins/plugin-dev/README.md | 36 +- packages/plugins/plugin-dev/package.json | 4 +- .../plugins/plugin-dev/src/dev-plugin.test.ts | 361 ++++--------- packages/plugins/plugin-dev/src/dev-plugin.ts | 508 +++--------------- pnpm-lock.yaml | 6 + 9 files changed, 259 insertions(+), 719 deletions(-) create mode 100644 .changeset/retire-the-dev-stub-table.md diff --git a/.changeset/retire-the-dev-stub-table.md b/.changeset/retire-the-dev-stub-table.md new file mode 100644 index 0000000000..8ef684f579 --- /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**: `DevPlugin.init()` now 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_IN_PRODUCTION=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 40236889f7..05b44187f6 100644 --- a/content/docs/kernel/services-checklist.mdx +++ b/content/docs/kernel/services-checklist.mdx @@ -306,30 +306,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 392bc78e4d..ef12f57b43 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 -**Developer Tools Plugin** — Development-time utilities. +**Development Assembly Plugin** — one plugin that wires the real platform stack for zero-config local development. -- **Features**: Metadata validation, schema introspection, debugging tools -- **When to use**: Development and debugging +- **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_IN_PRODUCTION` 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..b1cdf60c48 100644 --- a/docs/adr/0115-plugin-dev-assembly-not-stub-table.md +++ b/docs/adr/0115-plugin-dev-assembly-not-stub-table.md @@ -108,6 +108,14 @@ 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. + ## 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 6820322510..404e2219e6 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,15 +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 | +| 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 | + +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`. + +## Empty slots stay empty -### Dev stubs (in-memory / no-op) +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. -Any core kernel service not provided by a real plugin is automatically registered as a dev stub. This ensures the **full kernel service map** is populated and features like UI permissions, automation, etc. don't crash: +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. -`cache`, `queue`, `job`, `file-storage`, `search`, `automation`, `graphql`, `analytics`, `realtime`, `notification`, `ai`, `i18n`, `ui`, `workflow`, `security.permissions`, `security.rls`, `security.fieldMasker` +## Production guard -All services are **optional** — if a peer package isn't installed, it is silently skipped and 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_IN_PRODUCTION=1`. ## API Endpoints (when all services enabled) @@ -108,7 +108,7 @@ All services are **optional** — if a peer package isn't installed, it is silen | `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 5fe4a28a81..599b3dfc20 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,242 +75,92 @@ 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); - - // ── 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. + expect(stubLog).toBeUndefined(); }); - // [#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(), - }; + describe('production guard (ADR-0115 D6)', () => { + const savedNodeEnv = process.env.NODE_ENV; + const savedAllow = process.env.OS_ALLOW_DEV_PLUGIN_IN_PRODUCTION; - // 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); + 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_IN_PRODUCTION; + else process.env.OS_ALLOW_DEV_PLUGIN_IN_PRODUCTION = savedAllow; + }); - // 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. - const STUB = ['automation', 'notification', 'ai', 'data', 'auth', - 'security.permissions', 'security.rls', 'security.fieldMasker']; + it('refuses to init when NODE_ENV=production', async () => { + process.env.NODE_ENV = 'production'; + delete process.env.OS_ALLOW_DEV_PLUGIN_IN_PRODUCTION; - 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(); - } + 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(); + }); - // No HTTP/WS surface is mounted for these, so no handler can be ready — - // independent of the implementation being real (ADR-0076 D12, realtime). - for (const name of ['cache', 'queue', 'job', 'realtime']) { - expect(readServiceSelfInfo(registeredServices.get(name))?.handlerReady, `${name} handlerReady`).toBe(false); - } + it('OS_ALLOW_DEV_PLUGIN_IN_PRODUCTION=1 overrides the guard, deliberately', async () => { + process.env.NODE_ENV = 'production'; + process.env.OS_ALLOW_DEV_PLUGIN_IN_PRODUCTION = '1'; - // `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); + const { ctx } = mockCtx(); + await expect(new DevPlugin({ seedAdminUser: false }).init(ctx)).resolves.not.toThrow(); + }); + }); - // The retired analytics slot stays empty (#4000). - expect(registeredServices.has('analytics')).toBe(false); + // [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, @@ -316,35 +173,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 9e466f2a3e..036f9ad20f 100644 --- a/packages/plugins/plugin-dev/src/dev-plugin.ts +++ b/packages/plugins/plugin-dev/src/dev-plugin.ts @@ -1,357 +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. - */ -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 []; }, - }; -} - -/** Security sub-service stubs (PermissionEvaluator, RLSCompiler, FieldMasker) */ -function createSecurityPermissionsStub() { - return { - _serviceName: 'security.permissions', - resolvePermissionSets() { return []; }, - checkObjectPermission() { return true; }, - getFieldPermissions() { return {}; }, - }; -} -function createSecurityRLSStub() { - return { - _serviceName: 'security.rls', - compileFilter() { return null; }, - getApplicablePolicies() { return []; }, - }; -} -function createSecurityFieldMaskerStub() { - return { - _serviceName: 'security.fieldMasker', - maskResults(results: any) { return results; }, - }; -} - -/** - * 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, - // Security sub-services - 'security.permissions': createSecurityPermissionsStub, - 'security.rls': createSecurityRLSStub, - 'security.fieldMasker': createSecurityFieldMaskerStub, -}; - -/** - * 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). - * - * 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', message: 'Dev in-memory file storage — really stores and returns bytes, but nothing survives a restart. Register a storage plugin for durable files.' }, - '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.' }, - 'security.permissions': { status: 'stub', message: 'Dev stub — every object permission check returns allowed. Register SecurityPlugin for real permission evaluation.' }, - 'security.rls': { status: 'stub', message: 'Dev stub — compiles no row-level filter, so NO RLS predicate is applied. Register SecurityPlugin for real row-level security.' }, - 'security.fieldMasker': { status: 'stub', message: 'Dev stub — returns results unmasked. Register SecurityPlugin for real field-level masking.' }, -}; - -/** 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']); /** * Dev Plugin Options @@ -392,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>; @@ -427,12 +80,9 @@ export interface DevPluginOptions { } /** - * Development Mode Plugin for ObjectStack - * - * 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. + * Development Assembly Plugin for ObjectStack * + * One plugin that wires the **real** platform stack for local development. * Instead of manually wiring: * * ```ts @@ -454,7 +104,7 @@ export interface DevPluginOptions { * plugins: [new DevPlugin()] * ``` * - * ## Core services (real implementations) + * ## What it assembles (all real implementations) * * | Service | Package | Description | * |--------------|-----------------------------------|-------------------------------------------| @@ -464,24 +114,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 | * - * ## Stub services (contract-compliant in-memory implementations) + * 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`. * - * 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: + * ## Empty slots stay empty (ADR-0115) * - * `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) + * 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). * - * All services can be individually disabled via `options.services`. - * Peer packages are loaded via dynamic import and silently skipped if missing. + * ## Production guard (ADR-0115 D6) + * + * `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_IN_PRODUCTION=1`. */ export class DevPlugin implements Plugin { name = 'com.objectstack.plugin.dev'; @@ -513,7 +173,19 @@ export class DevPlugin implements Plugin { * if a package isn't installed the service is silently skipped. */ async init(ctx: PluginContext): Promise { - ctx.logger.info('🚀 DevPlugin initializing — auto-configuring all services for development'); + // ── ADR-0115 D6: refuse to boot a dev assembly in production ── + // The stack this plugin wires is built around a well-known default auth + // secret and a seeded dev admin; nothing about it belongs in production. + if (process.env.NODE_ENV === 'production' && process.env.OS_ALLOW_DEV_PLUGIN_IN_PRODUCTION !== '1') { + throw new Error( + '[dev] DevPlugin refuses to start with NODE_ENV=production: it assembles a development ' + + 'stack around a well-known default auth secret and a seeded dev admin (ADR-0115 D6). ' + + 'Use the real production assembly instead, or set OS_ALLOW_DEV_PLUGIN_IN_PRODUCTION=1 ' + + 'if this is deliberate.', + ); + } + + ctx.logger.info('🚀 DevPlugin initializing — assembling the development stack'); const enabled = (name: string) => this.options.services?.[name] !== false; @@ -590,6 +262,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')) { @@ -722,58 +420,14 @@ 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 (if SecurityPlugin wasn't loaded) - if (enabled('security')) { - for (const svc of SECURITY_SERVICE_NAMES) { - try { - ctx.getService(svc); - } catch { - ctx.registerService(svc, makeStub(svc)); - stubNames.push(svc); - } - } - } - - if (stubNames.length > 0) { - ctx.logger.info(` ✔ Contract-compliant dev stubs registered for: ${stubNames.join(', ')}`); - } + // ── 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. - 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 From 76a088a12b00892aea3a9c890e2378274a14ae91 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 11:14:22 +0000 Subject: [PATCH 2/2] =?UTF-8?q?chore:=20retrigger=20CI=20=E2=80=94=20the?= =?UTF-8?q?=20pull=5Frequest=20workflows=20never=20fired=20for=20the=20pre?= =?UTF-8?q?vious=20head?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BEM82RNKyyyTNPYHK1VJJS