diff --git a/.changeset/sys-secret-store-platform-infra.md b/.changeset/sys-secret-store-platform-infra.md new file mode 100644 index 0000000000..08b5b86943 --- /dev/null +++ b/.changeset/sys-secret-store-platform-infra.md @@ -0,0 +1,42 @@ +--- +"@objectstack/platform-objects": minor +"@objectstack/service-settings": patch +"@objectstack/verify": patch +--- + +feat(platform-objects,service-settings,verify): `sys_secret` is platform infrastructure — registered by `PlatformObjectsPlugin`, not by the settings service (#4270) + +The environment's encrypted-secret store (`sys_secret`, ADR-0066 D2/④) was +registered by `@objectstack/service-settings`, but it has three producer +classes and only one of them is settings: the settings service's encrypted +specifiers, the ObjectQL engine's own `secret`-field encryption +(`encryptSecretFields`/`resolveSecret` — the generic write path of ANY +business object carrying a `Field.secret()`), and the datasource credential +binder. Unlike the `sys_migration` precedent (#4243), the failure posture is +fail-CLOSED: on a kernel composed without settings, every insert/update of an +object with a secret field threw — with an error message that told the +operator to "Ensure the platform-objects (sys_secret) are registered", naming +a package that did not register it. + +The registration now lives in `PlatformObjectsPlugin` +(`@objectstack/platform-objects/plugin`) — the plugin `os serve` already +auto-injects into every served kernel — so the store exists with the +platform, independent of which optional services are composed, and the +engine's fail-closed error message is true. Definition ownership is unchanged +(`sys_secret` stays in `@objectstack/platform-objects` and in +`PLATFORM_OBJECTS_BY_PACKAGE`); the settings service remains a producer and +consumer through its `sys_secret`-backed secret store. + +Consequences: + +- `@objectstack/service-settings` no longer contributes `sys_secret` to the + manifest (`settingsObjects` is now `[SysSetting, SysSettingAudit]`). An + embedder composing `SettingsServicePlugin` on a hand-built kernel that + relied on it for the `sys_secret` table must compose + `PlatformObjectsPlugin` (the plugin every supported assembly path already + includes). The move REPLACES the registration — nothing registers the + object twice. +- `@objectstack/verify`'s boot harness now composes `PlatformObjectsPlugin`, + mirroring `os serve`'s auto-inject — which also means harness kernels now + carry the `sys_migration` ledger + fresh-datastore attestation (#4243) the + served assembly always had. diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 337da3f518..5ecb472a7f 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -1494,7 +1494,10 @@ export default class Serve extends Command { // infrastructure every served kernel needs: the `sys_migration` // data-migration flag ledger + fresh-datastore attestation (#4243 — // without it the engine's deployment gates read "no ledger" and fall - // back to the lax legacy posture), and the platform-default + // back to the lax legacy posture), the `sys_secret` cipher store + // (#4270 — the engine's secret-field write path and the datasource + // credential binder fail CLOSED without it; the crypto wiring below + // assumes the table exists), and the platform-default // translation bundles (Setup App + metadata-type configuration // forms). Without the latter, Setup nav labels and metadata-admin // form labels fall back to English literals even when diff --git a/packages/platform-objects/src/plugin.test.ts b/packages/platform-objects/src/plugin.test.ts index 892d94f025..0fb409f0da 100644 --- a/packages/platform-objects/src/plugin.test.ts +++ b/packages/platform-objects/src/plugin.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'; import { PlatformObjectsPlugin } from './plugin.js'; -import { SysMigration } from './system/index.js'; +import { SysMigration, SysSecret } from './system/index.js'; /** * Hand-rolled fake PluginContext (mirrors the service-plugin tests) — this @@ -33,8 +33,8 @@ function makeCtx() { return ctx; } -describe('PlatformObjectsPlugin: sys_migration ledger registration (#4243)', () => { - it('registers SysMigration through the manifest service', async () => { +describe('PlatformObjectsPlugin: platform-infrastructure object registration (#4243, #4270)', () => { + it('registers SysMigration and SysSecret through the manifest service', async () => { const ctx = makeCtx(); const manifests: any[] = []; ctx.registerService('manifest', { register: (m: any) => manifests.push(m) }); @@ -44,8 +44,27 @@ describe('PlatformObjectsPlugin: sys_migration ledger registration (#4243)', () expect(manifests).toHaveLength(1); expect(manifests[0].id).toBe('com.objectstack.platform-objects'); expect(manifests[0].scope).toBe('system'); - expect(manifests[0].objects).toEqual([SysMigration]); - expect(manifests[0].objects[0].name).toBe('sys_migration'); + expect(manifests[0].objects).toEqual([SysMigration, SysSecret]); + expect(manifests[0].objects.map((o: any) => o.name)).toEqual(['sys_migration', 'sys_secret']); + }); + + /** + * #4270 pin: the engine's secret-field write path is fail-CLOSED — with no + * `sys_secret` store it throws, and its error message tells the operator to + * "Ensure the platform-objects (sys_secret) are registered". This plugin is + * what makes that sentence true; dropping SysSecret from the registration + * would fail any kernel composed without service-settings (and even with + * it — settings no longer registers the object either). + */ + it('sys_secret is registered HERE, so the engine fail-closed error names the right package', async () => { + const ctx = makeCtx(); + const manifests: any[] = []; + ctx.registerService('manifest', { register: (m: any) => manifests.push(m) }); + + await new PlatformObjectsPlugin().init(ctx); + + const names = manifests.flatMap((m: any) => m.objects.map((o: any) => o.name)); + expect(names).toContain('sys_secret'); }); it('degrades silently without a manifest service (lean/i18n-only kernels)', async () => { diff --git a/packages/platform-objects/src/plugin.ts b/packages/platform-objects/src/plugin.ts index 822f90f719..698f04eccf 100644 --- a/packages/platform-objects/src/plugin.ts +++ b/packages/platform-objects/src/plugin.ts @@ -3,6 +3,7 @@ import { SetupAppTranslations } from './apps/translations/index.js'; import { MetadataFormsTranslations } from './metadata-translations/index.js'; import { SysMigration } from './system/sys-migration.object.js'; +import { SysSecret } from './system/sys-secret.object.js'; import { attestFreshDatastore } from './system/migration-flag.js'; /** @@ -18,6 +19,17 @@ import { attestFreshDatastore } from './system/migration-flag.js'; * file collection, the engine's strict value-shape gates (#3438/#4235) * and the migration CLI all read the same ledger, and none of them * should have to drag an unrelated service into the boot to find it. + * - **`sys_secret`** — the environment's encrypted-secret store + * (ADR-0066 D2/④). Same shape as the ledger, sharper stakes (#4270): + * its producers span domains — the settings service's encrypted + * specifiers, the engine's own `secret`-field encryption + * (`encryptSecretFields`/`resolveSecret`, a generic write path any + * business object with a `Field.secret()` hits), and the datasource + * credential binder — and the engine is fail-CLOSED about it: writing + * a secret field with no reachable `sys_secret` store throws. While + * service-settings registered it, a kernel composed without settings + * hard-failed every secret-field write with an error that blamed + * platform-objects. Registered here, that error message is true. * - **Fresh-datastore attestation** (#3438, ADR-0104 2026-07-30) — * travels with the ledger registration: a store this boot created from * empty is attested at `kernel:ready`, whichever services are composed. @@ -31,8 +43,9 @@ import { attestFreshDatastore } from './system/migration-flag.js'; * * Gracefully degrades on lean kernels: without a manifest service the * ledger is simply absent — every flag reader answers "not verified", the - * safe posture — and without an i18n service the UI falls back to the - * inline literals carried on each App / Dashboard / `*.form.ts` schema. + * safe posture — secret-field writes fail closed (throw, never cleartext), + * and without an i18n service the UI falls back to the inline literals + * carried on each App / Dashboard / `*.form.ts` schema. * * Structurally typed against `@objectstack/core`'s `Plugin` contract so * this package does not need to depend on the kernel at compile time. @@ -58,11 +71,12 @@ export class PlatformObjectsPlugin { version: this.version, type: 'plugin', scope: 'system', - objects: [SysMigration], + objects: [SysMigration, SysSecret], }); } catch { // No manifest service (lean / i18n-only kernels) — the ledger stays - // unregistered and every flag reader answers "not verified". + // unregistered (every flag reader answers "not verified") and the + // secret store stays unregistered (secret-field writes fail closed). } } diff --git a/packages/platform-objects/src/system/sys-secret.object.ts b/packages/platform-objects/src/system/sys-secret.object.ts index 27ff164e9b..9f61348f5d 100644 --- a/packages/platform-objects/src/system/sys-secret.object.ts +++ b/packages/platform-objects/src/system/sys-secret.object.ts @@ -25,7 +25,13 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; * reads of e.g. feature flags. * * managedBy: 'engine-owned' — never edited from a generic Object grid. All - * writes flow through `SettingsService` and an `ICryptoProvider`. + * writes flow through an `ICryptoProvider` on one of three privileged + * producer paths (#4270): `SettingsService` (encrypted settings specifiers), + * the engine's own `secret`-field encryption (`encryptSecretFields` — the + * generic write path of any object carrying a `Field.secret()`), and the + * datasource credential binder. Because the producers span domains and the + * engine fails CLOSED without this store, it is registered by + * `PlatformObjectsPlugin` (platform infrastructure), not by service-settings. * * @namespace sys */ diff --git a/packages/services/service-settings/src/manifest.test.ts b/packages/services/service-settings/src/manifest.test.ts new file mode 100644 index 0000000000..920a47ca0c --- /dev/null +++ b/packages/services/service-settings/src/manifest.test.ts @@ -0,0 +1,23 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { settingsObjects } from './manifest.js'; + +describe('service-settings manifest objects (#4270)', () => { + it('owns exactly the K/V store and its audit trail', () => { + expect(settingsObjects.map((o: any) => o.name)).toEqual(['sys_setting', 'sys_setting_audit']); + }); + + /** + * #4270 pin against double registration: `sys_secret` moved to + * `PlatformObjectsPlugin` (its producers span settings, the engine's + * `secret`-field encryption, and the datasource credential binder — and the + * engine fails closed without the store). #4236 flagged "same object + * registered twice" as behaviour to avoid; the move REPLACED this + * registration, it did not add a second one. Re-adding SysSecret here would + * recreate exactly that conflict. + */ + it('does not (re-)register sys_secret — that is PlatformObjectsPlugin material', () => { + expect(settingsObjects.map((o: any) => o.name)).not.toContain('sys_secret'); + }); +}); diff --git a/packages/services/service-settings/src/manifest.ts b/packages/services/service-settings/src/manifest.ts index 81550ab6e0..49651ab89e 100644 --- a/packages/services/service-settings/src/manifest.ts +++ b/packages/services/service-settings/src/manifest.ts @@ -1,12 +1,21 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { SysSetting, SysSecret, SysSettingAudit } from '@objectstack/platform-objects/system'; +import { SysSetting, SysSettingAudit } from '@objectstack/platform-objects/system'; export const SETTINGS_PLUGIN_ID = 'com.objectstack.service.settings'; export const SETTINGS_PLUGIN_VERSION = '0.1.0'; -/** Objects owned by service-settings. Currently just the K/V store. */ -export const settingsObjects: any[] = [SysSetting, SysSecret, SysSettingAudit]; +/** + * Objects owned by service-settings: the K/V store and its audit trail. + * + * `sys_secret` is deliberately NOT here (#4270): its producers span domains + * (encrypted settings here, the engine's `secret`-field encryption, the + * datasource credential binder) and the engine fails closed when the store + * is missing — so it is registered by `PlatformObjectsPlugin` as platform + * infrastructure, present with or without this service (cf. `sys_migration`, + * #4243). This service remains a producer/consumer via its secret store. + */ +export const settingsObjects: any[] = [SysSetting, SysSettingAudit]; /** Manifest header shared by compile-time config and runtime registration. */ export const settingsPluginManifestHeader = { diff --git a/packages/verify/package.json b/packages/verify/package.json index 5effc7e6b4..cb8a987774 100644 --- a/packages/verify/package.json +++ b/packages/verify/package.json @@ -22,6 +22,7 @@ "dependencies": { "@objectstack/core": "workspace:*", "@objectstack/objectql": "workspace:*", + "@objectstack/platform-objects": "workspace:*", "@objectstack/plugin-auth": "workspace:*", "@objectstack/plugin-hono-server": "workspace:*", "@objectstack/plugin-security": "workspace:*", diff --git a/packages/verify/src/harness.ts b/packages/verify/src/harness.ts index 2d74dc1e19..1a615da6ab 100644 --- a/packages/verify/src/harness.ts +++ b/packages/verify/src/harness.ts @@ -29,6 +29,7 @@ import { SecurityPlugin } from '@objectstack/plugin-security'; import { SharingServicePlugin } from '@objectstack/plugin-sharing'; import { SettingsServicePlugin, LocalCryptoProvider } from '@objectstack/service-settings'; import { AnalyticsServicePlugin } from '@objectstack/service-analytics'; +import { PlatformObjectsPlugin } from '@objectstack/platform-objects/plugin'; /** A Hono app exposes `.request(path, init)` returning a standard `Response`. */ interface InjectableApp { @@ -172,6 +173,12 @@ export async function bootStack( // The app under test (objects, datasets, cubes, flows, seed data). await kernel.use(new AppPlugin(config)); + // Platform infrastructure `os serve` auto-injects into every served kernel: + // the `sys_migration` flag ledger (#4243) and the `sys_secret` cipher store + // (#4270) — the latter is what the LocalCryptoProvider wiring below writes + // into, and the engine fails CLOSED on secret-field writes without it. + await kernel.use(new PlatformObjectsPlugin()); + // Service plugins `objectstack dev` auto-loads for an app of this shape. await kernel.use(new SettingsServicePlugin()); await kernel.use(opts.analytics ?? new AnalyticsServicePlugin()); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e9cbf52dc4..7aa6816e3c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2352,6 +2352,9 @@ importers: '@objectstack/objectql': specifier: workspace:* version: link:../objectql + '@objectstack/platform-objects': + specifier: workspace:* + version: link:../platform-objects '@objectstack/plugin-auth': specifier: workspace:* version: link:../plugins/plugin-auth