@@ -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+ }
0 commit comments