Skip to content

Commit ea6179d

Browse files
committed
wip(#30120): image durability refusal, boot block, admin deployment notice routes
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WV4cyhB3ePno23ywn1PQd
1 parent 47c457b commit ea6179d

10 files changed

Lines changed: 810 additions & 28 deletions

‎docker/Dockerfile‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,9 +104,18 @@ WORKDIR /srv/app
104104
RUN chown node:node /srv/app
105105
USER node
106106

107+
# OS_DEPLOYMENT_DURABILITY_CHECK_ENABLED makes `os start` refuse to boot unless
108+
# OS_DATABASE_URL, OS_AUTH_SECRET and OS_SECRET_KEY are set explicitly, naming
109+
# the missing ones. A container's disk is what gets replaced: without them the
110+
# data would be a SQLite file in the container layer and both secrets would be
111+
# generated there, all lost with the container. An explicit
112+
# OS_DATABASE_URL=file:/path/on/a/volume/app.db passes, so SQLite on a mounted
113+
# volume stays a choice. Outside this image `os start` keeps booting and prints
114+
# a warning block instead (packages/cli/src/commands/start.ts).
107115
ENV NODE_ENV=production \
108116
OS_ARTIFACT_PATH=/srv/app/objectstack.json \
109-
OS_PORT=8080
117+
OS_PORT=8080 \
118+
OS_DEPLOYMENT_DURABILITY_CHECK_ENABLED=true
110119
EXPOSE 8080
111120

112121
# Liveness: /api/v1/health. For orchestrated readiness use /api/v1/ready.
@@ -115,5 +124,6 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=15s \
115124
CMD node -e "fetch('http://localhost:'+(process.env.OS_PORT||8080)+'/api/v1/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"
116125

117126
# OS_DATABASE_URL, OS_AUTH_SECRET, and OS_SECRET_KEY are injected at runtime —
118-
# never bake them into an image.
127+
# never bake them into an image. Without them `os start` refuses to boot (see
128+
# OS_DEPLOYMENT_DURABILITY_CHECK_ENABLED above).
119129
CMD ["os", "start"]

‎packages/cli/src/commands/start.ts‎

Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,44 @@ export default class Start extends Command {
321321
// (`utils/internal-auth-secret-channel.ts`).
322322
const operatorAuthSecret = flags['auth-secret']
323323
?? readEnvWithDeprecation('OS_AUTH_SECRET', ['AUTH_SECRET', 'BETTER_AUTH_SECRET'], { silent: true });
324+
325+
// ── The official image's durability check (ruling A3, record 6634240777) ──
326+
// The image (`docker/Dockerfile`) sets `OS_DEPLOYMENT_DURABILITY_CHECK_ENABLED`,
327+
// because a container's disk is the thing that gets replaced: a SQLite file
328+
// in its writable layer and secrets minted there are lost with it. Under the
329+
// signal this command refuses unless the database and both secrets are set
330+
// explicitly, and names the missing ones. Asked HERE, after the database is
331+
// resolved and before anything is generated: the refusal writes no secret
332+
// and opens no database. ⛔ Not keyed on `NODE_ENV`: this command forces
333+
// `production` when it is unset (below), so the zero-config trial is itself
334+
// production mode and a mode-keyed refusal would end it.
335+
const durabilityCheck = readDurabilityCheckSignal(process.env);
336+
if (durabilityCheck === 'invalid') {
337+
printError(
338+
`${DURABILITY_CHECK_ENV}="${process.env[DURABILITY_CHECK_ENV]}" is not a switch value — set it to true or false.`,
339+
);
340+
process.exit(1);
341+
}
342+
if (durabilityCheck === 'on') {
343+
const missing = missingDurableVariables({
344+
databaseSource: resolvedDb.source,
345+
authSecretSet: operatorAuthSecret !== undefined && operatorAuthSecret.trim().length > 0,
346+
secretKeySet: (process.env.OS_SECRET_KEY ?? '').trim().length > 0,
347+
});
348+
if (missing.length > 0) {
349+
const keyFile = await persistedDataKeyFile({
350+
env: process.env, cwd, hasProjectConfig, homeFlag: flags.home,
351+
});
352+
process.stderr.write(`${formatDurabilityRefusal(missing, {
353+
authSecretFile: path.join(homeDir, 'auth-secret'),
354+
dataKeyFile: keyFile,
355+
defaultDatabaseFile: sqliteFileOf(resolvedDb.url),
356+
exists: (p) => fs.existsSync(p),
357+
})}\n`);
358+
process.exit(1);
359+
}
360+
}
361+
324362
const persistedAuthSecret = operatorAuthSecret === undefined ? readOrCreateAuthSecret(homeDir) : undefined;
325363
const authSecret = persistedAuthSecret ? persistedAuthSecret.secret : operatorAuthSecret;
326364

@@ -503,6 +541,26 @@ export default class Start extends Command {
503541
});
504542
if (keyFileNotice) console.log(chalk.yellow(` ⚠ ${keyFileNotice}`));
505543

544+
// ── What a production boot keeps on this machine's disk (ruling A3) ────
545+
// Every `os start` outside the image keeps booting, and says so: one block
546+
// for the deployer, naming the database file and the two secrets nobody set,
547+
// with what is lost if this disk is not kept. The same facts go to the
548+
// `serve` child on the CLI's internal channel, where the settings service
549+
// serves them to administrators as the Console's deployment notice (the
550+
// data key's file it reads from its own channel, which the line above set).
551+
const { KEY_FILE_CHANNEL_ENV } = await import('@objectstack/service-settings');
552+
const dataKeyFile = localEnv[KEY_FILE_CHANNEL_ENV];
553+
const findings: DeploymentBootFindings | undefined = localEnv.NODE_ENV === 'production'
554+
? {
555+
...(isDefaultedDatabase(resolvedDb.source) ? { databaseFile: sqliteFileOf(databaseUrl) ?? databaseUrl } : {}),
556+
...(persistedAuthSecret ? { authSecretFile: persistedAuthSecret.file ?? null } : {}),
557+
...(dataKeyFile ? { dataKeyFile } : {}),
558+
}
559+
: undefined;
560+
writeDeploymentFindingsChannel(localEnv, findings);
561+
const durabilityBlock = findings ? formatDurabilityBootBlock(findings) : undefined;
562+
if (durabilityBlock) console.log(chalk.yellow(durabilityBlock));
563+
506564
const binPath = process.argv[1];
507565

508566
// ── The serve child's lifecycle: `os dev`'s mechanism, not a copy (#21114)
@@ -625,3 +683,223 @@ function readOrCreateAuthSecret(homeDir: string): { secret: string; file?: strin
625683
}
626684
return { secret };
627685
}
686+
687+
// ── Deployment durability (ruling A3, record 6634240777) ─────────────────────
688+
//
689+
// Two audiences, one set of facts. The official image refuses to start unless
690+
// the database and both secrets are set explicitly; every other `os start`
691+
// boots and prints a block for the deployer; and the same facts reach the
692+
// `serve` child, whose settings service serves them to administrators as the
693+
// Console's deployment notice. The facts are this command's own decisions: it
694+
// resolved the database, it generated the auth secret, and it opted the data
695+
// key into a persisted file. Nothing below reads `NODE_ENV` to decide the
696+
// refusal — see the comment at the call site.
697+
698+
/**
699+
* The official image's signal (`docker/Dockerfile` sets it to `true`): under
700+
* it `os start` refuses to boot unless `OS_DATABASE_URL`, `OS_AUTH_SECRET` and
701+
* `OS_SECRET_KEY` are set explicitly. Default off, so a local `os start` keeps
702+
* its zero-config boot.
703+
*/
704+
export const DURABILITY_CHECK_ENV = 'OS_DEPLOYMENT_DURABILITY_CHECK_ENABLED';
705+
706+
/**
707+
* The CLI's INTERNAL channel to its `serve` child: the boot facts the
708+
* administrator notice states, as JSON ({@link DeploymentBootFindings}). Same
709+
* naming and ownership rule as `OS_INTERNAL_AUTH_SECRET_FILE`
710+
* (`utils/internal-auth-secret-channel.ts`): set by this command alone, deleted
711+
* when it has nothing to say, so an inherited copy never speaks for this run,
712+
* and not an operator setting. The reader is `@objectstack/service-settings`
713+
* (`deployment-notice.ts`), which spells the same name: the settings package
714+
* publishes no constant for it, and the end-to-end pin
715+
* (`test/start-deployment-durability.integration.test.ts`) reads the notice a
716+
* real `os start` serves, so a renamed side fails there.
717+
*/
718+
export const INTERNAL_DEPLOYMENT_FINDINGS_ENV = 'OS_INTERNAL_DEPLOYMENT_FINDINGS';
719+
720+
/** What a production boot keeps on this machine's disk because nobody set it. */
721+
export interface DeploymentBootFindings {
722+
/** The database is a SQLite file no setting chose: the path, or the URL when it names no file. */
723+
databaseFile?: string;
724+
/** The auth secret was generated: the file it is kept in, `null` when only this process holds it. */
725+
authSecretFile?: string | null;
726+
/** The data key is read from (and on a first boot minted into) this file: `OS_SECRET_KEY` is unset. */
727+
dataKeyFile?: string;
728+
}
729+
730+
/** A variable the image requires, in the order the refusal names them. */
731+
export type DurableVariable = 'OS_DATABASE_URL' | 'OS_AUTH_SECRET' | 'OS_SECRET_KEY';
732+
733+
/**
734+
* Read {@link DURABILITY_CHECK_ENV}. `invalid` for a value that is neither a
735+
* yes nor a no: a typo must not quietly switch a safety check off.
736+
*/
737+
export function readDurabilityCheckSignal(env: NodeJS.ProcessEnv): 'on' | 'off' | 'invalid' {
738+
const raw = env[DURABILITY_CHECK_ENV];
739+
if (raw === undefined || raw.trim() === '') return 'off';
740+
const v = raw.trim().toLowerCase();
741+
if (v === 'true' || v === '1') return 'on';
742+
if (v === 'false' || v === '0') return 'off';
743+
return 'invalid';
744+
}
745+
746+
/**
747+
* Whether the database was chosen by nobody: the unified default file, or the
748+
* legacy file read for compatibility. A URL from `--database`, the environment
749+
* (`OS_DATABASE_URL` and its legacy names) or the project's declared default
750+
* datasource is a choice, an explicit `file:` URL included.
751+
*/
752+
export function isDefaultedDatabase(source: ResolvedProjectDatabaseUrl['source']): boolean {
753+
return source === 'unified-default' || source === 'legacy-file';
754+
}
755+
756+
/** The variables the image requires that this boot did not get. */
757+
export function missingDurableVariables(facts: {
758+
databaseSource: ResolvedProjectDatabaseUrl['source'];
759+
authSecretSet: boolean;
760+
secretKeySet: boolean;
761+
}): DurableVariable[] {
762+
const missing: DurableVariable[] = [];
763+
if (isDefaultedDatabase(facts.databaseSource)) missing.push('OS_DATABASE_URL');
764+
if (!facts.authSecretSet) missing.push('OS_AUTH_SECRET');
765+
if (!facts.secretKeySet) missing.push('OS_SECRET_KEY');
766+
return missing;
767+
}
768+
769+
/** The file a `file:` SQLite URL names, or `undefined` for any other URL. */
770+
export function sqliteFileOf(url: string): string | undefined {
771+
if (!url.startsWith('file:')) return undefined;
772+
const rest = url.slice('file:'.length).replace(/^\/\/(?=\/)/, '');
773+
return rest.length > 0 ? rest : undefined;
774+
}
775+
776+
function joinNames(names: readonly string[]): string {
777+
if (names.length <= 1) return names.join('');
778+
return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`;
779+
}
780+
781+
/**
782+
* The image's refusal: which variables are missing, what is lost without
783+
* them, how to set them, and how a deployment that already ran on values the
784+
* image generated carries those values over first.
785+
*/
786+
export function formatDurabilityRefusal(
787+
missing: readonly DurableVariable[],
788+
where: {
789+
/** Where this command keeps a generated auth secret. */
790+
authSecretFile: string;
791+
/** Where the data key file is (`undefined` when an env key would answer). */
792+
dataKeyFile?: string;
793+
/** The default SQLite file, when the database fell back to it. */
794+
defaultDatabaseFile?: string;
795+
exists: (file: string) => boolean;
796+
},
797+
): string {
798+
const set = new Set(missing);
799+
const found = (file: string): string => (where.exists(file) ? ' (present in this container)' : '');
800+
const lines: string[] = [
801+
`✗ This image does not start without its database and secrets set explicitly: ${joinNames(missing)} ${missing.length === 1 ? 'is' : 'are'} not set.`,
802+
' Without them the data would be a SQLite file inside the container, and the sign-in secret and the key that',
803+
' encrypts saved passwords and API keys would be generated on its disk. Replacing the container loses all of it.',
804+
'',
805+
' Set them when you run the container:',
806+
];
807+
if (set.has('OS_DATABASE_URL')) {
808+
lines.push(
809+
' OS_DATABASE_URL your database, e.g. postgres://user:pass@db-host:5432/app',
810+
' (or file:/path/on/a/mounted/volume/app.db to keep SQLite on a volume)',
811+
);
812+
}
813+
if (set.has('OS_AUTH_SECRET')) {
814+
lines.push(' OS_AUTH_SECRET a long random value, the same on every restart, e.g. $(openssl rand -hex 32)');
815+
}
816+
if (set.has('OS_SECRET_KEY')) {
817+
lines.push(' OS_SECRET_KEY 32 random bytes, the same on every restart, e.g. $(openssl rand -hex 32)');
818+
}
819+
lines.push(
820+
'',
821+
' Already running on values this image generated? Carry them over before you set new ones, or every saved',
822+
' credential becomes unreadable and everyone is signed out. Read them from the old container',
823+
' (docker exec <container> cat <file>) or from its volume:',
824+
);
825+
if (set.has('OS_AUTH_SECRET')) {
826+
lines.push(` OS_AUTH_SECRET the contents of ${where.authSecretFile}${found(where.authSecretFile)}`);
827+
}
828+
if (set.has('OS_SECRET_KEY') && where.dataKeyFile) {
829+
lines.push(` OS_SECRET_KEY the contents of ${where.dataKeyFile}${found(where.dataKeyFile)}`);
830+
}
831+
if (set.has('OS_DATABASE_URL') && where.defaultDatabaseFile) {
832+
lines.push(
833+
` OS_DATABASE_URL file:${where.defaultDatabaseFile}${found(where.defaultDatabaseFile)}, if your data is that file on a kept volume`,
834+
);
835+
}
836+
return lines.join('\n');
837+
}
838+
839+
/**
840+
* The deployer's block for a production boot outside the image, or
841+
* `undefined` when this boot keeps nothing on its disk by default.
842+
*/
843+
export function formatDurabilityBootBlock(findings: DeploymentBootFindings): string | undefined {
844+
const rows: string[] = [];
845+
const vars: DurableVariable[] = [];
846+
if (findings.databaseFile !== undefined) {
847+
rows.push(` · Database: a SQLite file, ${findings.databaseFile} (OS_DATABASE_URL is not set)`);
848+
vars.push('OS_DATABASE_URL');
849+
}
850+
if (findings.authSecretFile !== undefined) {
851+
rows.push(
852+
findings.authSecretFile
853+
? ` · Sign-in secret: generated and kept in ${findings.authSecretFile} (OS_AUTH_SECRET is not set)`
854+
: ' · Sign-in secret: generated for this process only (OS_AUTH_SECRET is not set)',
855+
);
856+
vars.push('OS_AUTH_SECRET');
857+
}
858+
if (findings.dataKeyFile !== undefined) {
859+
rows.push(
860+
` · Data key, which encrypts saved passwords and API keys: kept in ${findings.dataKeyFile} (OS_SECRET_KEY is not set)`,
861+
);
862+
vars.push('OS_SECRET_KEY');
863+
}
864+
if (rows.length === 0) return undefined;
865+
return [
866+
' ⚠ Production mode, and this server keeps these on its own disk:',
867+
...rows,
868+
' If this disk is not kept (a container replaced, a machine rebuilt), they are lost: the data, every',
869+
' sign-in and every saved credential.',
870+
` To move them off this disk, set ${joinNames(vars)}, carrying the current values over from the files above.`,
871+
' Administrators see this notice in the Console too.',
872+
].join('\n');
873+
}
874+
875+
/**
876+
* Write {@link INTERNAL_DEPLOYMENT_FINDINGS_ENV} into the child env: the
877+
* findings as JSON when there are any, otherwise no variable at all.
878+
*/
879+
export function writeDeploymentFindingsChannel(
880+
env: NodeJS.ProcessEnv,
881+
findings: DeploymentBootFindings | undefined,
882+
): void {
883+
if (findings && Object.keys(findings).length > 0) {
884+
env[INTERNAL_DEPLOYMENT_FINDINGS_ENV] = JSON.stringify(findings);
885+
} else {
886+
delete env[INTERNAL_DEPLOYMENT_FINDINGS_ENV];
887+
}
888+
}
889+
890+
/**
891+
* The data key file a boot of this command would read, through the ONE
892+
* resolution the child is handed (`applyCryptoKeyFileChannel`), applied to a
893+
* scratch env so nothing of the real child env is touched.
894+
*/
895+
async function persistedDataKeyFile(opts: {
896+
env: NodeJS.ProcessEnv;
897+
cwd: string;
898+
hasProjectConfig: boolean;
899+
homeFlag?: string;
900+
}): Promise<string | undefined> {
901+
const { KEY_FILE_CHANNEL_ENV } = await import('@objectstack/service-settings');
902+
const scratch: NodeJS.ProcessEnv = {};
903+
await applyCryptoKeyFileChannel(scratch, opts);
904+
return scratch[KEY_FILE_CHANNEL_ENV];
905+
}

‎packages/services/service-settings/src/config-change-audit.ts‎

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ type ConfigChangeLogger = SettingsDiagnosticsLogger & {
191191
* no (AGENTS.md → *Startup registry reads*). A settings write is an admin-rate
192192
* operation, so the per-call registry lookup costs nothing worth memoizing.
193193
*/
194-
function ledgerIsMounted(engine: IDataEngine): boolean | undefined {
194+
export function ledgerIsMounted(engine: IDataEngine): boolean | undefined {
195195
const getSchema = (engine as any)?.getSchema;
196196
if (typeof getSchema !== 'function') return undefined;
197197
try {
@@ -204,6 +204,33 @@ function ledgerIsMounted(engine: IDataEngine): boolean | undefined {
204204
}
205205
}
206206

207+
/**
208+
* The ONE insert this module makes onto the platform audit ledger, shared by
209+
* the settings write sink below and the deployment notice's hide
210+
* ({@link recordDeploymentConfigChange}), so both rows are written the same
211+
* way, under the same system context.
212+
*/
213+
async function insertLedgerRow(eng: any, row: Record<string, unknown>): Promise<void> {
214+
await eng.insert('sys_audit_log', row, { context: SYSTEM_CTX });
215+
}
216+
217+
/**
218+
* Record one deployment-level `config_change` row whose caller must KNOW it
219+
* landed: the deployment notice's hide (ruling A3, record 6634240777), where
220+
* the row is part of the act. Unlike the settings sink below it is not
221+
* best-effort: `no-ledger` when this deployment mounts no `sys_audit_log`
222+
* (asked first, exactly as the sink asks), and a failed insert throws to the
223+
* caller instead of being reported once and swallowed.
224+
*/
225+
export async function recordDeploymentConfigChange(
226+
engine: IDataEngine,
227+
row: Record<string, unknown>,
228+
): Promise<'recorded' | 'no-ledger'> {
229+
if (ledgerIsMounted(engine) === false) return 'no-ledger';
230+
await insertLedgerRow(engine, { ...row, action: CONFIG_CHANGE_ACTION });
231+
return 'recorded';
232+
}
233+
207234
function safeStringify(value: unknown): string {
208235
try {
209236
return JSON.stringify(value) ?? String(value);
@@ -297,7 +324,7 @@ export function buildConfigChangeAuditSink(
297324
}),
298325
};
299326

300-
await eng.insert('sys_audit_log', row, { context: SYSTEM_CTX });
327+
await insertLedgerRow(eng, row);
301328
} catch (err: any) {
302329
// Reported once per process, not once per settings write: a failure here
303330
// is systemic (the ledger is mounted but unreachable — DDL never ran,

0 commit comments

Comments
 (0)