@@ -28,7 +28,9 @@ import { dirname, join } from 'node:path';
2828 * 4. Persisted file — `~/.objectstack/dev-crypto-key`
2929 * (mode 0600). In development it is
3030 * auto-created; in production it is
31- * only *read* (never minted).
31+ * only *read* unless `OS_CRYPTO_AUTOKEY`
32+ * opts the single-node self-host case
33+ * into minting + persisting it too.
3234 * 5. Ephemeral random key — development/test only.
3335 *
3436 * ## Fail-loud guarantee (the reason this class exists)
@@ -46,9 +48,13 @@ import { dirname, join } from 'node:path';
4648 * To turn that silent data-loss into a config error at boot, the provider
4749 * REFUSES to mint a key in production: when `mode === 'production'` and no
4850 * stable key source (env var or pre-existing key file) is available, the
49- * constructor throws an actionable error instead of generating one.
50- * Development and test keep the ergonomic fallback so local loops and unit
51- * tests stay frictionless.
51+ * constructor throws an actionable error instead of generating one. The one
52+ * exception is the `OS_CRYPTO_AUTOKEY` opt-in: a single-node self-host
53+ * (`os start` on a durable filesystem) may mint + *persist* a key so the
54+ * zero-config quickstart boots — but even then the ephemeral fallback stays
55+ * forbidden, so a non-writable / ephemeral FS still fails loud rather than
56+ * running under a key that won't survive a restart. Development and test keep
57+ * the ergonomic fallback so local loops and unit tests stay frictionless.
5258 *
5359 * `mode` is auto-detected from `NODE_ENV` (`production` → strict;
5460 * `test`/`VITEST` → ephemeral, no disk; otherwise `development`) and can be
@@ -77,6 +83,14 @@ import { dirname, join } from 'node:path';
7783const SECRET_KEY_ENV = 'OS_SECRET_KEY' ;
7884const DEV_KEY_ENV = 'OS_DEV_CRYPTO_KEY' ;
7985const DEV_KEY_LEGACY_ENV = 'OBJECTSTACK_DEV_CRYPTO_KEY' ;
86+ /**
87+ * Opt-in that lets the strict production path mint + PERSIST a key (but never
88+ * fall back to an ephemeral one). Set by `os start` for the single-node
89+ * self-host quickstart so the documented zero-config boot works out of the
90+ * box, while a real cluster deploy (which must provision `OS_SECRET_KEY`)
91+ * leaves it unset and keeps the fail-loud guarantee. See `commands/start.ts`.
92+ */
93+ const AUTOKEY_ENV = 'OS_CRYPTO_AUTOKEY' ;
8094
8195type EnvMap = Record < string , string | undefined > ;
8296
@@ -156,6 +170,12 @@ const parseKey = (raw: string | undefined): Buffer | undefined => {
156170 return undefined ;
157171} ;
158172
173+ /** Truthy env flag: `1` / `true` / `yes` (case-insensitive). */
174+ const parseBool = ( raw : string | undefined ) : boolean => {
175+ const v = raw ?. trim ( ) . toLowerCase ( ) ;
176+ return v === '1' || v === 'true' || v === 'yes' ;
177+ } ;
178+
159179/** Read an existing key file (no creation). Returns `undefined` on miss / IO error. */
160180const loadExistingKey = ( path : string ) : Buffer | undefined => {
161181 try {
@@ -255,9 +275,7 @@ function resolveDataKey(opts: LocalCryptoProviderOptions): ResolvedKey {
255275 const path = keyFilePath ( env ) ;
256276
257277 if ( mode === 'production' ) {
258- // Honour a pre-existing, operator-provisioned key file, but NEVER mint
259- // one here — auto-generation is the silent-data-loss footgun this guard
260- // exists to prevent.
278+ // Honour a pre-existing, operator-provisioned key file first.
261279 const existing = loadExistingKey ( path ) ;
262280 if ( existing ) {
263281 warn (
@@ -266,6 +284,30 @@ function resolveDataKey(opts: LocalCryptoProviderOptions): ResolvedKey {
266284 ) ;
267285 return { key : existing , source : 'file' } ;
268286 }
287+
288+ // Single-node self-host opt-in (`os start` on a durable filesystem): mint
289+ // a key AND persist it so the zero-config quickstart boots. We still
290+ // REFUSE the ephemeral fallback below — if the key cannot be written
291+ // (read-only / ephemeral FS), running anyway would silently lose every
292+ // sys_secret on the next restart, the exact footgun this guard prevents.
293+ // Multi-node deploys must NOT opt in (each node would mint a divergent
294+ // key); `os start` only sets the flag when no cluster driver is set.
295+ if ( parseBool ( env [ AUTOKEY_ENV ] ) ) {
296+ const persisted = loadOrCreateKey ( path ) ;
297+ if ( persisted ) {
298+ if ( persisted . generated ) {
299+ warn (
300+ `[LocalCryptoProvider] No ${ SECRET_KEY_ENV } set — minted a new AES-256-GCM key and ` +
301+ `persisted it to ${ path } (mode 0600). Restarts on this host reuse it automatically. ` +
302+ `For containers, CI, or multi-node, set ${ SECRET_KEY_ENV } so every node shares one key.` ,
303+ ) ;
304+ }
305+ return { key : persisted . key , source : persisted . generated ? 'generated-file' : 'file' } ;
306+ }
307+ // Persist failed → fall through to the hard error. Never run ephemeral
308+ // in production, even with the opt-in.
309+ }
310+
269311 throw new Error ( MISSING_PROD_KEY_MSG ( path ) ) ;
270312 }
271313
0 commit comments