Skip to content

Commit c121d73

Browse files
authored
fix(cli): let single-node os start auto-mint a crypto key (#2236)
1 parent cd58de3 commit c121d73

5 files changed

Lines changed: 121 additions & 7 deletions

File tree

.changeset/os-start-autokey.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@objectstack/service-settings": patch
3+
"@objectstack/cli": patch
4+
---
5+
6+
fix(cli): let single-node `os start` auto-mint a crypto key
7+
8+
`os start` forces `NODE_ENV=production`, which made `LocalCryptoProvider` refuse
9+
to boot without `OS_SECRET_KEY` — breaking the documented zero-config quickstart
10+
(`npm i -g @objectstack/cli && os start`) on a clean machine.
11+
12+
`LocalCryptoProvider` now honours an `OS_CRYPTO_AUTOKEY` opt-in in production: it
13+
mints AND persists a key to `~/.objectstack/dev-crypto-key`. The ephemeral
14+
fallback stays forbidden, so a non-writable / ephemeral filesystem still fails
15+
loud rather than running under a key that won't survive a restart. `os start`
16+
sets the flag only for single-node deployments (no `OS_CLUSTER_DRIVER`, no
17+
`OS_SECRET_KEY`); multi-node still fails loud until `OS_SECRET_KEY` is provided.

content/docs/guides/cloud-deployment.mdx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,11 +130,35 @@ requests and do not run through a target environment kernel.
130130
| `OS_DATABASE_URL` | Local runtime business database URL. |
131131
| `OS_DATABASE_DRIVER` | Local runtime driver id. |
132132
| `OS_AUTH_SECRET` | Better Auth session secret for hosted runtimes. |
133+
| `OS_SECRET_KEY` | 32-byte master key for `sys_secret` encryption (encrypted settings, `secret` fields, datasource credentials). 64 hex chars or base64 of 32 bytes. |
134+
| `OS_CLUSTER_DRIVER` | Cluster coordination driver. When set, the runtime treats the deployment as multi-node. |
133135
| `OS_PORT` | HTTP listen port. |
134136
135137
Third-party provider variables such as `OPENAI_API_KEY`, `TURSO_*`,
136138
`SMTP_*`, `RESEND_API_KEY`, and OAuth client secrets keep their provider names.
137139
140+
<Callout type="warn">
141+
**Set `OS_SECRET_KEY` for any containerized or multi-node deployment.** The
142+
default `LocalCryptoProvider` encrypts every `sys_secret` value under one
143+
32-byte key. On a single host, `os start` mints and persists that key to
144+
`~/.objectstack/dev-crypto-key` automatically, so the zero-config quickstart
145+
just works. But on an **ephemeral filesystem (containers)** the minted key is
146+
lost on restart, and across **multiple nodes** each node would mint a
147+
*different* key — in both cases previously-encrypted secrets become
148+
undecryptable.
149+
150+
Provision one stable key shared by every node and every restart:
151+
152+
```bash
153+
OS_SECRET_KEY=$(openssl rand -hex 32)
154+
```
155+
156+
When `OS_CLUSTER_DRIVER` is set (multi-node), `os start` will **not** auto-mint
157+
a key — it fails loud at boot until `OS_SECRET_KEY` is provided, rather than
158+
silently running under a key that won't survive. For production, prefer a
159+
managed KMS / Vault provider behind the same `ICryptoProvider` seam.
160+
</Callout>
161+
138162
---
139163

140164
## Related

packages/cli/src/commands/start.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,17 @@ export default class Start extends Command {
237237
// Allows `NODE_ENV=development objectstack start` to work for debugging.
238238
if (!localEnv.NODE_ENV) localEnv.NODE_ENV = 'production';
239239

240+
// Single-node self-host quickstart: forcing production above would make
241+
// LocalCryptoProvider refuse to boot without OS_SECRET_KEY, breaking the
242+
// documented zero-config `os start`. Opt the crypto provider into minting
243+
// + persisting a key file (~/.objectstack/dev-crypto-key) so it works out
244+
// of the box. A multi-node deploy (OS_CLUSTER_DRIVER set) must provision a
245+
// shared OS_SECRET_KEY instead — each node minting its own key would
246+
// diverge — so we do NOT opt in there; the provider still fails loud.
247+
if (!localEnv.OS_CLUSTER_DRIVER && !localEnv.OS_SECRET_KEY) {
248+
localEnv.OS_CRYPTO_AUTOKEY = '1';
249+
}
250+
240251
const binPath = process.argv[1];
241252
const child = spawn(
242253
process.execPath,

packages/services/service-settings/src/local-crypto-provider.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,26 @@ describe('LocalCryptoProvider — key resolution', () => {
8080
expect(existsSync(keyPath)).toBe(false);
8181
});
8282

83+
it('mints + persists a key in production when OS_CRYPTO_AUTOKEY is set (os start quickstart)', async () => {
84+
const env = { NODE_ENV: 'production', HOME: home, OS_CRYPTO_AUTOKEY: '1' };
85+
const a = new LocalCryptoProvider({ env });
86+
expect(a.keySource).toBe('generated-file');
87+
const h = await a.encrypt('quickstart-secret', ctx);
88+
// A fresh instance reads the persisted file and decrypts prior ciphertext.
89+
const b = new LocalCryptoProvider({ env });
90+
expect(b.keySource).toBe('file');
91+
expect(await b.decrypt(h, ctx)).toBe('quickstart-secret');
92+
});
93+
94+
it('still fails loud with OS_CRYPTO_AUTOKEY when the key cannot be persisted', () => {
95+
// Point HOME at a path under a regular *file* so mkdir/write fails — the
96+
// opt-in must NOT degrade to an ephemeral key in production.
97+
const blocker = join(home, 'not-a-dir');
98+
writeFileSync(blocker, 'x');
99+
const env = { NODE_ENV: 'production', OS_HOME: join(blocker, 'nested'), OS_CRYPTO_AUTOKEY: '1' };
100+
expect(() => new LocalCryptoProvider({ env })).toThrow(/Refusing to start in production/);
101+
});
102+
83103
it('auto-creates + persists a key in development', async () => {
84104
const env = { NODE_ENV: 'development', HOME: home };
85105
const a = new LocalCryptoProvider({ env });

packages/services/service-settings/src/local-crypto-provider.ts

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -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';
7783
const SECRET_KEY_ENV = 'OS_SECRET_KEY';
7884
const DEV_KEY_ENV = 'OS_DEV_CRYPTO_KEY';
7985
const 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

8195
type 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. */
160180
const 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

Comments
 (0)