feat(secrets): add a 0600 file secret backend so production runs on Linux

The production composition hardwired the macOS Keychain (/usr/bin/security),
which made Linux deployment impossible. Now:

- SecretStore gains a stable provider identity persisted in
  secret_references.provider; services stop hardcoding 'macos-keychain'
- shared reference codec (secret-reference.ts) understands both
  keychain:// and secret-file:// references
- FileSecretStore: single 0600 JSON map under the data root, atomic
  temp+rename writes, serialized in-process, same validation envelope
- production composition picks the backend via
  MULTI_SIMADMIN_SECRET_BACKEND (default: Keychain on darwin, file store
  elsewhere) and readiness probes the matching backend
This commit is contained in:
chick
2026-09-07 01:32:07 +08:00
parent c57dc81e42
commit e4c4bce75b
26 changed files with 605 additions and 76 deletions
+15 -3
View File
@@ -10,7 +10,7 @@ import {
} from './application/system/console-update-service.js';
import { openDatabase } from './infrastructure/database/database.js';
import { migrateDatabase } from './infrastructure/database/migrations.js';
import { MacOSKeychainSecretStore } from './infrastructure/secrets/keychain-secret-store.js';
import { createDefaultSecretStore, resolveSecretBackend, defaultSecretFileMetadataCheck } from './infrastructure/secrets/secret-backend.js';
import type { SecretStore } from './infrastructure/secrets/secret-store.js';
import { createProductionUpstream } from './infrastructure/transport/production-upstream.js';
import { createProductionReadiness, defaultKeychainMetadataCheck } from './production-readiness.js';
@@ -21,6 +21,7 @@ export interface ProductionControlPlaneOptions {
readonly upstream?: SafeControlPlaneUpstream;
readonly gatewayToken?: string;
readonly keychainMetadataCheck?: () => boolean | Promise<boolean>;
readonly secretFileMetadataCheck?: () => boolean | Promise<boolean>;
readonly assetsCheck?: () => boolean | Promise<boolean>;
readonly runtimeVersion?: string;
readonly recoveryMarkersCheck?: () => number | Promise<number>;
@@ -44,8 +45,10 @@ export function buildProductionControlPlane(
const db = openDatabase(options.databasePath);
try {
migrateDatabase(db);
const store = options.store ?? new MacOSKeychainSecretStore();
const env = options.env ?? process.env;
const secretFilePath = join(dirname(options.databasePath), 'secrets.json');
const store = options.store ?? createDefaultSecretStore({ env, secretFilePath });
const secretBackend = resolveSecretBackend(env);
const updateRoot = env.MULTI_SIMADMIN_UPDATE_ROOT ?? process.cwd();
const updateRunner = createCommandRunner({ cwd: updateRoot, timeoutMs: 120_000 });
const app = buildControlPlaneApp({
@@ -71,7 +74,16 @@ export function buildProductionControlPlane(
readiness: createProductionReadiness({
db,
gatewayToken: options.gatewayToken ?? '',
keychainMetadataCheck: options.keychainMetadataCheck ?? defaultKeychainMetadataCheck,
...(secretBackend === 'macos-keychain'
? {
keychainMetadataCheck:
options.keychainMetadataCheck ?? defaultKeychainMetadataCheck,
}
: {
secretFileMetadataCheck:
options.secretFileMetadataCheck ??
(async () => defaultSecretFileMetadataCheck(secretFilePath)),
}),
...(options.assetsCheck ? { assetsCheck: options.assetsCheck } : {}),
...(options.recoveryMarkersCheck
? { recoveryMarkersCheck: options.recoveryMarkersCheck }