Skip to content

Commit 5515efe

Browse files
os-zhuangclaude
andcommitted
feat(platform-objects,service-settings,verify): sys_secret 密文库改由平台基础设施注册 (#4270)
sys_secret(ADR-0066 D2/④ 的环境加密密钥库)此前由 service-settings 注册,但它有三类生产者且只有一类是 settings:SettingsService 的加密设置项、ObjectQL 引擎自身的 secret 字段加解密(encryptSecretFields/resolveSecret——任何带 Field.secret() 的业务对象的通用写路径)、service-datasource 的凭证 binder。与 #4243 不同,这里的失败姿态是 fail-closed:没这张表时任何 secret 字段写入直接抛错,且报错文案指认 platform-objects——一个此前并不注册它的包。 注册点改为 PlatformObjectsPlugin(与 #4258 对 SysMigration 的处理一致):init 经 manifest 服务注册 [SysMigration, SysSecret]。os serve 本就自动注入该插件,每个被服务的内核都带密文库,与是否装 settings 无关;引擎那句报错从此为真。settingsObjects 摘除 SysSecret(移除而非并存,#4236)——settings 仍是生产者/消费者,只是不再是注册者。定义归属不变(sys_secret 仍在 platform-objects 与 PLATFORM_OBJECTS_BY_PACKAGE)。 verify harness 补组 PlatformObjectsPlugin,对齐 serve 装配(该漂移自 #4258 起存在);harness 内核随之首次带上 sys_migration 账本 + 空库 attestation,dogfood 全套在该姿态下通过。同步修掉两处失真注释:sys-secret.object.ts 的「All writes flow through SettingsService」与 manifest.ts 的「Objects owned by service-settings」。 语义变化面:手工组装 SettingsServicePlugin 而不组 PlatformObjectsPlugin 的嵌入式内核不再获得 sys_secret 表(secret 写入 fail-closed 抛错,永不落明文);changeset 已写明迁移句。 Closes #4270 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 59b85c0 commit 5515efe

10 files changed

Lines changed: 141 additions & 14 deletions

File tree

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
"@objectstack/platform-objects": minor
3+
"@objectstack/service-settings": patch
4+
"@objectstack/verify": patch
5+
---
6+
7+
feat(platform-objects,service-settings,verify): `sys_secret` is platform infrastructure — registered by `PlatformObjectsPlugin`, not by the settings service (#4270)
8+
9+
The environment's encrypted-secret store (`sys_secret`, ADR-0066 D2/④) was
10+
registered by `@objectstack/service-settings`, but it has three producer
11+
classes and only one of them is settings: the settings service's encrypted
12+
specifiers, the ObjectQL engine's own `secret`-field encryption
13+
(`encryptSecretFields`/`resolveSecret` — the generic write path of ANY
14+
business object carrying a `Field.secret()`), and the datasource credential
15+
binder. Unlike the `sys_migration` precedent (#4243), the failure posture is
16+
fail-CLOSED: on a kernel composed without settings, every insert/update of an
17+
object with a secret field threw — with an error message that told the
18+
operator to "Ensure the platform-objects (sys_secret) are registered", naming
19+
a package that did not register it.
20+
21+
The registration now lives in `PlatformObjectsPlugin`
22+
(`@objectstack/platform-objects/plugin`) — the plugin `os serve` already
23+
auto-injects into every served kernel — so the store exists with the
24+
platform, independent of which optional services are composed, and the
25+
engine's fail-closed error message is true. Definition ownership is unchanged
26+
(`sys_secret` stays in `@objectstack/platform-objects` and in
27+
`PLATFORM_OBJECTS_BY_PACKAGE`); the settings service remains a producer and
28+
consumer through its `sys_secret`-backed secret store.
29+
30+
Consequences:
31+
32+
- `@objectstack/service-settings` no longer contributes `sys_secret` to the
33+
manifest (`settingsObjects` is now `[SysSetting, SysSettingAudit]`). An
34+
embedder composing `SettingsServicePlugin` on a hand-built kernel that
35+
relied on it for the `sys_secret` table must compose
36+
`PlatformObjectsPlugin` (the plugin every supported assembly path already
37+
includes). The move REPLACES the registration — nothing registers the
38+
object twice.
39+
- `@objectstack/verify`'s boot harness now composes `PlatformObjectsPlugin`,
40+
mirroring `os serve`'s auto-inject — which also means harness kernels now
41+
carry the `sys_migration` ledger + fresh-datastore attestation (#4243) the
42+
served assembly always had.

packages/cli/src/commands/serve.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1494,7 +1494,10 @@ export default class Serve extends Command {
14941494
// infrastructure every served kernel needs: the `sys_migration`
14951495
// data-migration flag ledger + fresh-datastore attestation (#4243 —
14961496
// without it the engine's deployment gates read "no ledger" and fall
1497-
// back to the lax legacy posture), and the platform-default
1497+
// back to the lax legacy posture), the `sys_secret` cipher store
1498+
// (#4270 — the engine's secret-field write path and the datasource
1499+
// credential binder fail CLOSED without it; the crypto wiring below
1500+
// assumes the table exists), and the platform-default
14981501
// translation bundles (Setup App + metadata-type configuration
14991502
// forms). Without the latter, Setup nav labels and metadata-admin
15001503
// form labels fall back to English literals even when

packages/platform-objects/src/plugin.test.ts

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { describe, it, expect } from 'vitest';
44
import { PlatformObjectsPlugin } from './plugin.js';
5-
import { SysMigration } from './system/index.js';
5+
import { SysMigration, SysSecret } from './system/index.js';
66

77
/**
88
* Hand-rolled fake PluginContext (mirrors the service-plugin tests) — this
@@ -33,8 +33,8 @@ function makeCtx() {
3333
return ctx;
3434
}
3535

36-
describe('PlatformObjectsPlugin: sys_migration ledger registration (#4243)', () => {
37-
it('registers SysMigration through the manifest service', async () => {
36+
describe('PlatformObjectsPlugin: platform-infrastructure object registration (#4243, #4270)', () => {
37+
it('registers SysMigration and SysSecret through the manifest service', async () => {
3838
const ctx = makeCtx();
3939
const manifests: any[] = [];
4040
ctx.registerService('manifest', { register: (m: any) => manifests.push(m) });
@@ -44,8 +44,27 @@ describe('PlatformObjectsPlugin: sys_migration ledger registration (#4243)', ()
4444
expect(manifests).toHaveLength(1);
4545
expect(manifests[0].id).toBe('com.objectstack.platform-objects');
4646
expect(manifests[0].scope).toBe('system');
47-
expect(manifests[0].objects).toEqual([SysMigration]);
48-
expect(manifests[0].objects[0].name).toBe('sys_migration');
47+
expect(manifests[0].objects).toEqual([SysMigration, SysSecret]);
48+
expect(manifests[0].objects.map((o: any) => o.name)).toEqual(['sys_migration', 'sys_secret']);
49+
});
50+
51+
/**
52+
* #4270 pin: the engine's secret-field write path is fail-CLOSED — with no
53+
* `sys_secret` store it throws, and its error message tells the operator to
54+
* "Ensure the platform-objects (sys_secret) are registered". This plugin is
55+
* what makes that sentence true; dropping SysSecret from the registration
56+
* would fail any kernel composed without service-settings (and even with
57+
* it — settings no longer registers the object either).
58+
*/
59+
it('sys_secret is registered HERE, so the engine fail-closed error names the right package', async () => {
60+
const ctx = makeCtx();
61+
const manifests: any[] = [];
62+
ctx.registerService('manifest', { register: (m: any) => manifests.push(m) });
63+
64+
await new PlatformObjectsPlugin().init(ctx);
65+
66+
const names = manifests.flatMap((m: any) => m.objects.map((o: any) => o.name));
67+
expect(names).toContain('sys_secret');
4968
});
5069

5170
it('degrades silently without a manifest service (lean/i18n-only kernels)', async () => {

packages/platform-objects/src/plugin.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { SetupAppTranslations } from './apps/translations/index.js';
44
import { MetadataFormsTranslations } from './metadata-translations/index.js';
55
import { SysMigration } from './system/sys-migration.object.js';
6+
import { SysSecret } from './system/sys-secret.object.js';
67
import { attestFreshDatastore } from './system/migration-flag.js';
78

89
/**
@@ -18,6 +19,17 @@ import { attestFreshDatastore } from './system/migration-flag.js';
1819
* file collection, the engine's strict value-shape gates (#3438/#4235)
1920
* and the migration CLI all read the same ledger, and none of them
2021
* should have to drag an unrelated service into the boot to find it.
22+
* - **`sys_secret`** — the environment's encrypted-secret store
23+
* (ADR-0066 D2/④). Same shape as the ledger, sharper stakes (#4270):
24+
* its producers span domains — the settings service's encrypted
25+
* specifiers, the engine's own `secret`-field encryption
26+
* (`encryptSecretFields`/`resolveSecret`, a generic write path any
27+
* business object with a `Field.secret()` hits), and the datasource
28+
* credential binder — and the engine is fail-CLOSED about it: writing
29+
* a secret field with no reachable `sys_secret` store throws. While
30+
* service-settings registered it, a kernel composed without settings
31+
* hard-failed every secret-field write with an error that blamed
32+
* platform-objects. Registered here, that error message is true.
2133
* - **Fresh-datastore attestation** (#3438, ADR-0104 2026-07-30) —
2234
* travels with the ledger registration: a store this boot created from
2335
* empty is attested at `kernel:ready`, whichever services are composed.
@@ -31,8 +43,9 @@ import { attestFreshDatastore } from './system/migration-flag.js';
3143
*
3244
* Gracefully degrades on lean kernels: without a manifest service the
3345
* ledger is simply absent — every flag reader answers "not verified", the
34-
* safe posture — and without an i18n service the UI falls back to the
35-
* inline literals carried on each App / Dashboard / `*.form.ts` schema.
46+
* safe posture — secret-field writes fail closed (throw, never cleartext),
47+
* and without an i18n service the UI falls back to the inline literals
48+
* carried on each App / Dashboard / `*.form.ts` schema.
3649
*
3750
* Structurally typed against `@objectstack/core`'s `Plugin` contract so
3851
* this package does not need to depend on the kernel at compile time.
@@ -58,11 +71,12 @@ export class PlatformObjectsPlugin {
5871
version: this.version,
5972
type: 'plugin',
6073
scope: 'system',
61-
objects: [SysMigration],
74+
objects: [SysMigration, SysSecret],
6275
});
6376
} catch {
6477
// No manifest service (lean / i18n-only kernels) — the ledger stays
65-
// unregistered and every flag reader answers "not verified".
78+
// unregistered (every flag reader answers "not verified") and the
79+
// secret store stays unregistered (secret-field writes fail closed).
6680
}
6781
}
6882

packages/platform-objects/src/system/sys-secret.object.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,13 @@ import { ObjectSchema, Field } from '@objectstack/spec/data';
2525
* reads of e.g. feature flags.
2626
*
2727
* managedBy: 'engine-owned' — never edited from a generic Object grid. All
28-
* writes flow through `SettingsService` and an `ICryptoProvider`.
28+
* writes flow through an `ICryptoProvider` on one of three privileged
29+
* producer paths (#4270): `SettingsService` (encrypted settings specifiers),
30+
* the engine's own `secret`-field encryption (`encryptSecretFields` — the
31+
* generic write path of any object carrying a `Field.secret()`), and the
32+
* datasource credential binder. Because the producers span domains and the
33+
* engine fails CLOSED without this store, it is registered by
34+
* `PlatformObjectsPlugin` (platform infrastructure), not by service-settings.
2935
*
3036
* @namespace sys
3137
*/
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import { settingsObjects } from './manifest.js';
5+
6+
describe('service-settings manifest objects (#4270)', () => {
7+
it('owns exactly the K/V store and its audit trail', () => {
8+
expect(settingsObjects.map((o: any) => o.name)).toEqual(['sys_setting', 'sys_setting_audit']);
9+
});
10+
11+
/**
12+
* #4270 pin against double registration: `sys_secret` moved to
13+
* `PlatformObjectsPlugin` (its producers span settings, the engine's
14+
* `secret`-field encryption, and the datasource credential binder — and the
15+
* engine fails closed without the store). #4236 flagged "same object
16+
* registered twice" as behaviour to avoid; the move REPLACED this
17+
* registration, it did not add a second one. Re-adding SysSecret here would
18+
* recreate exactly that conflict.
19+
*/
20+
it('does not (re-)register sys_secret — that is PlatformObjectsPlugin material', () => {
21+
expect(settingsObjects.map((o: any) => o.name)).not.toContain('sys_secret');
22+
});
23+
});

packages/services/service-settings/src/manifest.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,21 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

3-
import { SysSetting, SysSecret, SysSettingAudit } from '@objectstack/platform-objects/system';
3+
import { SysSetting, SysSettingAudit } from '@objectstack/platform-objects/system';
44

55
export const SETTINGS_PLUGIN_ID = 'com.objectstack.service.settings';
66
export const SETTINGS_PLUGIN_VERSION = '0.1.0';
77

8-
/** Objects owned by service-settings. Currently just the K/V store. */
9-
export const settingsObjects: any[] = [SysSetting, SysSecret, SysSettingAudit];
8+
/**
9+
* Objects owned by service-settings: the K/V store and its audit trail.
10+
*
11+
* `sys_secret` is deliberately NOT here (#4270): its producers span domains
12+
* (encrypted settings here, the engine's `secret`-field encryption, the
13+
* datasource credential binder) and the engine fails closed when the store
14+
* is missing — so it is registered by `PlatformObjectsPlugin` as platform
15+
* infrastructure, present with or without this service (cf. `sys_migration`,
16+
* #4243). This service remains a producer/consumer via its secret store.
17+
*/
18+
export const settingsObjects: any[] = [SysSetting, SysSettingAudit];
1019

1120
/** Manifest header shared by compile-time config and runtime registration. */
1221
export const settingsPluginManifestHeader = {

packages/verify/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"dependencies": {
2323
"@objectstack/core": "workspace:*",
2424
"@objectstack/objectql": "workspace:*",
25+
"@objectstack/platform-objects": "workspace:*",
2526
"@objectstack/plugin-auth": "workspace:*",
2627
"@objectstack/plugin-hono-server": "workspace:*",
2728
"@objectstack/plugin-security": "workspace:*",

packages/verify/src/harness.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { SecurityPlugin } from '@objectstack/plugin-security';
2929
import { SharingServicePlugin } from '@objectstack/plugin-sharing';
3030
import { SettingsServicePlugin, LocalCryptoProvider } from '@objectstack/service-settings';
3131
import { AnalyticsServicePlugin } from '@objectstack/service-analytics';
32+
import { PlatformObjectsPlugin } from '@objectstack/platform-objects/plugin';
3233

3334
/** A Hono app exposes `.request(path, init)` returning a standard `Response`. */
3435
interface InjectableApp {
@@ -172,6 +173,12 @@ export async function bootStack(
172173
// The app under test (objects, datasets, cubes, flows, seed data).
173174
await kernel.use(new AppPlugin(config));
174175

176+
// Platform infrastructure `os serve` auto-injects into every served kernel:
177+
// the `sys_migration` flag ledger (#4243) and the `sys_secret` cipher store
178+
// (#4270) — the latter is what the LocalCryptoProvider wiring below writes
179+
// into, and the engine fails CLOSED on secret-field writes without it.
180+
await kernel.use(new PlatformObjectsPlugin());
181+
175182
// Service plugins `objectstack dev` auto-loads for an app of this shape.
176183
await kernel.use(new SettingsServicePlugin());
177184
await kernel.use(opts.analytics ?? new AnalyticsServicePlugin());

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)