Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .changeset/sys-secret-store-platform-infra.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 4 additions & 1 deletion packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 24 additions & 5 deletions packages/platform-objects/src/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) });
Expand All @@ -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 () => {
Expand Down
22 changes: 18 additions & 4 deletions packages/platform-objects/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand All @@ -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.
Expand All @@ -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.
Expand All @@ -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).
}
}

Expand Down
8 changes: 7 additions & 1 deletion packages/platform-objects/src/system/sys-secret.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
23 changes: 23 additions & 0 deletions packages/services/service-settings/src/manifest.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
15 changes: 12 additions & 3 deletions packages/services/service-settings/src/manifest.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down
1 change: 1 addition & 0 deletions packages/verify/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
7 changes: 7 additions & 0 deletions packages/verify/src/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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());
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading