Skip to content
Merged
16 changes: 16 additions & 0 deletions .changeset/31600-multi-replica-auth-secret-refusal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@objectstack/cli': patch
---

feat(cli)!: a declared multi-replica `os start` on the redis cluster driver refuses to boot without `OS_AUTH_SECRET`, instead of minting a sign-in secret of its own on each replica

Clause-②: no (narrowing)
Metadata-protocol: none

<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and no stored row changes shape: what moves is whether `os start` generates a sign-in secret at boot for one deployment topology, and the refusal is a boot-time error that names its own remedy. Nothing at rest records the choice, so there is nothing for a migration to rewrite. -->

**BREAKING** for a deployment that declares several replicas (`OS_EXPECT_MULTI_NODE=true` or `OS_CLUSTER_REPLICAS` above 1) over the redis cluster driver (`OS_CLUSTER_DRIVER=redis`) and sets no `OS_AUTH_SECRET`. `os start` used to generate a sign-in secret into each replica's own `<home>/auth-secret`, so a session signed on one replica was refused on the others. It now exits 1 before generating anything, with one line naming `OS_AUTH_SECRET` and how to generate it.

The fix: generate one value and set the same `OS_AUTH_SECRET` on every replica, e.g. `OS_AUTH_SECRET=$(openssl rand -hex 32)`. A deployment that ran on a generated secret keeps its users' sessions by setting `OS_AUTH_SECRET` to the contents of the `auth-secret` file one replica already holds.

Unchanged: a single node (no replica declaration) keeps generating and reusing `<home>/auth-secret` exactly as before, on any cluster driver; a replica declaration over any other cluster driver keeps its current boot; and an `OS_AUTH_SECRET` (or its legacy names, or `--auth-secret`) set on every replica boots as before.
9 changes: 7 additions & 2 deletions content/docs/deployment/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -566,7 +566,7 @@ os start
| `-d, --database <url>` | `OS_DATABASE_URL` | `file:…` / `:memory:` / `libsql://` / `postgres://` / `mongodb://` |
| `--database-driver <kind>` | `OS_DATABASE_DRIVER` | Force `sqlite` \| `sqlite-wasm` \| `turso` \| `postgres` \| `mysql` \| `mongodb` when the URL is ambiguous |
| `--database-auth-token <token>` | `OS_DATABASE_AUTH_TOKEN` | Auth token for libsql/Turso |
| `--auth-secret <secret>` | `OS_AUTH_SECRET` / `AUTH_SECRET` | Secret for `@objectstack/plugin-auth`. If neither the flag nor the env var is set, `os start` **auto-generates one** and persists it at `<home>/auth-secret` |
| `--auth-secret <secret>` | `OS_AUTH_SECRET` / `AUTH_SECRET` | Secret for `@objectstack/plugin-auth`. If neither the flag nor the env var is set, `os start` **auto-generates one** and persists it at `<home>/auth-secret` — except on a deployment that declares several replicas (`OS_EXPECT_MULTI_NODE=true` / `OS_CLUSTER_REPLICAS` > 1) over the redis cluster driver, where it refuses to start and names the variable |
| `--home <dir>` | `OS_HOME` | Home directory for persistent state (default `<cwd>/.objectstack` when an `objectstack.config.ts` is present, otherwise `~/.objectstack`) |
| `--environment-id <id>` | `OS_ENVIRONMENT_ID` | Environment identifier (default `env_local`) |
| `-p, --port <port>` | `OS_PORT` / `PORT` | Listen port (default `3000`). **Production fails loudly if the port is busy** — see note below. |
Expand Down Expand Up @@ -618,7 +618,12 @@ as the project's **default** datasource it is refused at boot the same way.
`AUTH_SECRET` env > a secret **auto-generated and persisted** at `<home>/auth-secret`
on first run — so `/api/v1/auth/*` (login/register) and the Console's login flow work
out of the box, without any manual secret provisioning. Set the env var (or flag)
explicitly when you deploy across multiple nodes or want to rotate the secret.
explicitly when you deploy across multiple nodes or want to rotate the secret: the same
value on every replica. A start that declares several replicas
(`OS_EXPECT_MULTI_NODE=true` / `OS_CLUSTER_REPLICAS` > 1) over the redis cluster driver
does not generate one: without the variable it exits 1 on one line naming
`OS_AUTH_SECRET` and how to generate it (`openssl rand -hex 32`), because each replica
would otherwise mint its own and a session signed on one would be refused on the others.

<Callout type="info">
**`os start` vs `os serve`:** `os serve` boots from `objectstack.config.ts` (TypeScript source).
Expand Down
2 changes: 1 addition & 1 deletion content/docs/deployment/self-hosting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ workable default:
| Variable | Why it must be set |
|:---|:---|
| `OS_DATABASE_URL` | Without it, data lands in a SQLite file under the ObjectStack home directory (`~/.objectstack`, or `<cwd>/.objectstack` next to a project config) — fine for one box, wrong for containers. Use `postgres://…`, `mysql://…`, `mongodb://…`, `libsql://…`, or a mounted `file:…` path (`postgres://…`'s driver is an **optional** package too — `npm install pg`, or the boot fails loudly rather than degrading to SQLite; `libsql://` / Turso is inferred, but its driver is an **optional** package — `npm install @objectstack/driver-turso`, or the boot fails loudly rather than degrading to SQLite; see [Drivers](/docs/data-modeling/drivers)). `mysql://…` is a supported deployment target that carries three dialect caveats — two of them integrity guarantees MySQL cannot enforce at the database — see [Drivers → MySQL dialect caveats](/docs/data-modeling/drivers#mysql-dialect-caveats) before choosing it; its driver is likewise an **optional** package — `npm install mysql2`, or the boot fails loudly rather than degrading to SQLite. `mongodb://…` is **single-tenant only**: the MongoDB driver has no row-level tenant isolation and refuses to boot unless the tenancy posture is `single` — see [Drivers → Multi-tenancy](/docs/data-modeling/drivers#multi-tenancy-not-supported). |
| `OS_AUTH_SECRET` | Session secret for the auth plugin (`AUTH_SECRET` is the legacy alias). Without it, `os start` generates one and keeps it in `auth-secret` under the ObjectStack home directory, reused on restart: lose that file (a replaced container) and everyone is signed out. |
| `OS_AUTH_SECRET` | Session secret for the auth plugin (`AUTH_SECRET` is the legacy alias). Without it, `os start` generates one and keeps it in `auth-secret` under the ObjectStack home directory, reused on restart: lose that file (a replaced container) and everyone is signed out. Several replicas declared over the redis cluster driver need one value set on every replica: there `os start` refuses to generate one. |
| `OS_SECRET_KEY` | 32-byte master key encrypting every stored secret (`openssl rand -hex 32`). On a container's ephemeral filesystem the auto-minted key is **lost on restart**, making previously-encrypted secrets undecryptable. |
| `OS_PORT` | `os start` **fails loudly** if the port is busy (it never auto-shifts like `os dev`). Pin it and keep your reverse-proxy upstream in sync. |

Expand Down
34 changes: 34 additions & 0 deletions packages/cli/src/commands/start-deployment-durability.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,21 @@
*/

import { describe, expect, it } from 'vitest';
// Paid during collection, never inside a case's clock: `start.ts` loads these
// lazily, and `check:test-source-alias` reads every one it can reach.
import '@objectstack/cloud-connection';
import '@objectstack/runtime';
import '@objectstack/service-settings';
import {
DURABILITY_CHECK_ENV,
INTERNAL_DEPLOYMENT_FINDINGS_ENV,
MULTI_REPLICA_AUTH_SECRET_REFUSAL,
formatDurabilityBootBlock,
formatDurabilityRefusal,
isDefaultedDatabase,
missingDurableVariables,
readDurabilityCheckSignal,
declaresReplicasSharingState,
sqliteFileOf,
writeDeploymentFindingsChannel,
} from './start.js';
Expand Down Expand Up @@ -137,3 +144,30 @@ describe('the channel to the server process', () => {
expect(INTERNAL_DEPLOYMENT_FINDINGS_ENV in env).toBe(false);
});
});

describe('a declared multi-replica start on the redis cluster driver mints no sign-in secret of its own', () => {
const asks = (env: Record<string, string | undefined>) => declaresReplicasSharingState(env as NodeJS.ProcessEnv);

it('refuses when several replicas are declared over the redis driver, by either declaration', async () => {
expect(await asks({ OS_CLUSTER_REPLICAS: '3', OS_CLUSTER_DRIVER: 'redis' })).toBe(true);
expect(await asks({ OS_EXPECT_MULTI_NODE: 'true', OS_CLUSTER_DRIVER: ' redis ' })).toBe(true);
});

it('CONTROL: a single node keeps minting, on the redis driver too', async () => {
expect(await asks({})).toBe(false);
expect(await asks({ OS_CLUSTER_DRIVER: 'redis' })).toBe(false);
expect(await asks({ OS_CLUSTER_REPLICAS: '1', OS_CLUSTER_DRIVER: 'redis' })).toBe(false);
});

it('CONTROL: replicas declared over the memory driver, or none named, keep the boot they had', async () => {
expect(await asks({ OS_CLUSTER_REPLICAS: '3', OS_CLUSTER_DRIVER: 'memory' })).toBe(false);
expect(await asks({ OS_CLUSTER_REPLICAS: '3' })).toBe(false);
});

it('the refusal is one line naming OS_AUTH_SECRET and how to generate it, and carries no value', () => {
expect(MULTI_REPLICA_AUTH_SECRET_REFUSAL).not.toContain('\n');
expect(MULTI_REPLICA_AUTH_SECRET_REFUSAL).toMatch(/^\[auth\] Refusing to start: several replicas are declared/);
expect(MULTI_REPLICA_AUTH_SECRET_REFUSAL).toContain('but OS_AUTH_SECRET is not set');
expect(MULTI_REPLICA_AUTH_SECRET_REFUSAL).toContain('set the same value on every replica, e.g. OS_AUTH_SECRET=$(openssl rand -hex 32)');
});
});
46 changes: 46 additions & 0 deletions packages/cli/src/commands/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,19 @@ export default class Start extends Command {
}
}

// ── A declared multi-replica deployment shares ONE sign-in secret ──────
// Minting one below is right for a single node and wrong for replicas:
// each mints its own into its own home, and a session signed on one is
// refused on the others. Asked only when the operator set none, and before
// anything is generated, so the refusal writes no secret.
if (
(operatorAuthSecret === undefined || operatorAuthSecret.trim().length === 0)
&& await declaresReplicasSharingState(process.env)
) {
process.stderr.write(`${MULTI_REPLICA_AUTH_SECRET_REFUSAL}\n`);
process.exit(1);
}

const persistedAuthSecret = operatorAuthSecret === undefined ? readOrCreateAuthSecret(homeDir) : undefined;
const authSecret = persistedAuthSecret ? persistedAuthSecret.secret : operatorAuthSecret;

Expand Down Expand Up @@ -674,6 +687,39 @@ export async function resolveStartDatabase(opts: {
});
}

/**
* Whether this deployment runs replicas that share state, so must share one
* sign-in secret too: several replicas declared (`OS_EXPECT_MULTI_NODE=true` /
* `OS_CLUSTER_REPLICAS` > 1, the split-brain guard's `declaresMultiNode`) over
* the redis cluster driver. Both are required, the rule every multi-replica
* change keeps (cloud#3814 R3): a single node keeps minting into
* `<home>/auth-secret`, and a declaration over any other driver keeps the boot
* it had.
*
* ⛔ Not a second copy of that rule. It is asked of the one pre-boot form that
* exists, `sharesCloudBindingCredential` (`OS_CLUSTER_DRIVER` in place of the
* cluster service's driver, trimmed as the `serve` child trims it before
* composing that service), from a package this command already declares. The
* CLI declares no cluster package of its own: those ship with the host app
* (`serve-cluster-host-resolution.test.ts` says why). Lazy, so oclif's
* import-every-command startup does not pay for it.
*/
export async function declaresReplicasSharingState(env: NodeJS.ProcessEnv): Promise<boolean> {
const { sharesCloudBindingCredential } = await import('@objectstack/cloud-connection');
return sharesCloudBindingCredential(env, env.OS_CLUSTER_DRIVER);
}

/**
* The refusal, one line in the data key's shape (`LocalCryptoProvider`): what
* is missing, what minting one would break, and how to generate the value.
* It names the variable and never a value.
*/
export const MULTI_REPLICA_AUTH_SECRET_REFUSAL =
'[auth] Refusing to start: several replicas are declared (OS_EXPECT_MULTI_NODE / OS_CLUSTER_REPLICAS>1) ' +
'over the redis cluster driver, but OS_AUTH_SECRET is not set — each replica would mint its own sign-in ' +
'secret, and a session signed on one would be refused on the others. Fix: generate one and set the same ' +
'value on every replica, e.g. OS_AUTH_SECRET=$(openssl rand -hex 32)';

/**
* Read the persisted AUTH_SECRET from `<home>/auth-secret`, or generate
* one on first run and persist it so subsequent restarts keep existing
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
// test-budget: 40 — three refusals that exit before any server starts, then two real `os start` boots (one on explicit settings; one on the defaults, which also signs up an owner, creates a member and reads the notice as each); the file read 27.6 s on a shared local box (no CI reading yet) x 1.25, rounded up to the next 10 s
// test-budget: 40 — six starts that exit before any server answers (three image refusals; the multi-replica refusal, its pass and its memory-driver control), then two real `os start` boots (one on explicit settings; one on the defaults, which also signs up an owner, creates a member and reads the notice as each); the file read 27.6 s on a shared local box (no CI reading yet) x 1.25, rounded up to the next 10 s, and 26.9 s with the three multi-replica starts added

/**
* #30120, ruling A3 (record 6634240777) — the official image refuses to start
Expand Down Expand Up @@ -29,6 +29,12 @@
* member is refused `403`, and neither the public auth config nor the
* runtime config carries any of it.
*
* And, beside the image's refusal (#31600): a start that declares several
* replicas over the redis cluster driver and sets no `OS_AUTH_SECRET` exits 1
* on one line naming it, before it generates a secret; with the secret set it
* passes, and replicas declared over the memory driver still generate one.
* The single-node default keeps generating its own: leg 3 reads that file.
*
* The hide's audit row and the route's other refusals are pinned beside the
* routes (`service-settings` `deployment-notice.routes.test.ts`).
*
Expand Down Expand Up @@ -106,6 +112,10 @@ function baseEnv(home: string, over: Record<string, string | undefined> = {}): R
AUTH_SECRET: undefined,
BETTER_AUTH_SECRET: undefined,
OS_CLUSTER_DRIVER: undefined,
OS_EXPECT_MULTI_NODE: undefined,
OS_CLUSTER_REPLICAS: undefined,
OS_ALLOW_MEMORY_CLUSTER_MULTINODE: undefined,
OS_REDIS_URL: undefined,
OS_MODE: undefined,
OS_PORT: undefined,
PORT: undefined,
Expand Down Expand Up @@ -292,6 +302,59 @@ describe('#30120 — with the image switch on, a start missing its settings refu
});
});

// ---------------------------------------------------------------------------
// 1b — several replicas declared over the redis cluster driver: no secret of its own
// ---------------------------------------------------------------------------

describe('#31600 — a declared multi-replica start on the redis cluster driver mints no sign-in secret of its own', () => {
// The data key is set on every leg, so the only thing each leg varies is the
// sign-in secret and the topology. No leg needs a Redis: the door is asked in
// `os start` itself, before the `serve` child composes any cluster driver.
// Each leg is one synchronous `os start`, bounded by the spawn's own 120 s;
// the two that reach the `serve` child read ~4 s locally, past vitest's
// default 5 s per test on a loaded runner, so the leg carries the spawn's bound.
const SPAWN_TIMEOUT_MS = 120_000;
const LEG_TIMEOUT_MS = SPAWN_TIMEOUT_MS + 10_000;
const start = (over: Record<string, string | undefined>) => {
const home = tmp('os-31600-home-');
const cwd = tmp('os-31600-cwd-');
const run = spawnSync(process.execPath, [CLI, 'start', '--port', KERNEL_ASSIGNED_PORT], {
cwd,
env: childEnv({ ...baseEnv(home, { OS_SECRET_KEY: secret(), ...over }), NODE_ENV: undefined }),
encoding: 'utf8',
timeout: SPAWN_TIMEOUT_MS,
});
const minted = [...filesUnder(home), ...filesUnder(cwd)].filter((f) => /(^|\/)auth-secret$/.test(f));
return { run, minted, out: `${run.stdout}\n${run.stderr}` };
};
const REFUSAL = /^\[auth\] Refusing to start: several replicas are declared \(OS_EXPECT_MULTI_NODE \/ OS_CLUSTER_REPLICAS>1\) over the redis cluster driver, but OS_AUTH_SECRET is not set/m;

it('OS_AUTH_SECRET unset: exit 1 on the one line, before any secret is written', () => {
const { run, minted, out } = start({ OS_CLUSTER_REPLICAS: '3', OS_CLUSTER_DRIVER: 'redis' });
expect(run.status, out).toBe(1);
expect(run.stderr).toMatch(REFUSAL);
expect(run.stderr).toContain('e.g. OS_AUTH_SECRET=$(openssl rand -hex 32)');
expect(out).not.toMatch(READY);
expect(minted, out).toEqual([]);
}, LEG_TIMEOUT_MS);

it('OS_AUTH_SECRET set: this door passes, and nothing is minted', () => {
const { minted, out } = start({ OS_EXPECT_MULTI_NODE: 'true', OS_CLUSTER_DRIVER: 'redis', OS_AUTH_SECRET: secret() });
expect(out).not.toMatch(REFUSAL);
// The banner is printed after the door: the start went on to its child.
expect(out).toMatch(/Database/);
expect(minted, out).toEqual([]);
}, LEG_TIMEOUT_MS);

it('CONTROL: replicas declared over the memory driver mint a secret exactly as before', () => {
// The child then meets the split-brain guard, today's refusal for this
// topology; the secret is minted in `os start` before that, as it always was.
const { minted, out } = start({ OS_CLUSTER_REPLICAS: '3', OS_CLUSTER_DRIVER: 'memory' });
expect(out).not.toMatch(REFUSAL);
expect(minted, out).toEqual([join('.objectstack', 'auth-secret')]);
}, LEG_TIMEOUT_MS);
});

// ---------------------------------------------------------------------------
// 2 — an explicit file: passes
// ---------------------------------------------------------------------------
Expand Down
Loading