Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/30120-console-deployment-notice.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@object-ui/app-shell': patch
'@object-ui/i18n': patch
---

feat(console): administrators of a production deployment that keeps its data on its own disk see a standing notice on the home page and System Overview

Clause-②: no
Metadata-protocol: none

- **`@object-ui/app-shell`.** The home page and Setup's System Overview open with a notice, for workspace administrators the server also admits, when the deployment reports what it keeps on its own disk (`GET /api/settings/deployment/notice`): the database file, the generated sign-in secret and the data key file, each in business terms with its path and variable, then the lower-severity lines for local uploads and no email service. "Copy the settings" puts the variables to set on the clipboard, each with the value it takes (the file to carry the current value over from). "Hide" records the acknowledgement on the server's audit log and keeps the notice hidden for that administrator in that browser until the findings change; System Overview then keeps one small line with "Show again". No dialog; a member's browser never asks.
- **`@object-ui/i18n`.** A new `deploymentNotice` block (18 keys) in all ten packs.
13 changes: 13 additions & 0 deletions .changeset/30120-image-refuses-undurable-start.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@objectstack/cli': patch
'@objectstack/service-settings': patch
---

fix(cli): the official image refuses to start on a disk it would lose; every other production `os start` says what it keeps there, and administrators see it in the Console

Clause-②: no
Metadata-protocol: none

- **`@objectstack/cli` (the official image).** With `OS_DEPLOYMENT_DURABILITY_CHECK_ENABLED=true`, which the official image now sets in its `ENV`, `os start` exits 1 unless `OS_DATABASE_URL`, `OS_AUTH_SECRET` and `OS_SECRET_KEY` are all set explicitly (their legacy names count, and so do `--database` / `--auth-secret` and a database the project config declares). It names the missing ones before it writes a database file or a secret, and says how a deployment already running on generated values carries them over: the files in the container's home, and which of them the container holds. An explicit `OS_DATABASE_URL=file:/path/on/a/volume/app.db` passes. A value of the switch that is not `true`/`false`/`1`/`0` is refused rather than read as off. **Upgrading an image deployment that ran on its defaults:** read `auth-secret` and `dev-crypto-key` (and `data/objectstack.db`) out of the old container's `/home/node/.objectstack` and set them before you start the new one.
- **`@objectstack/cli` (every other `os start`).** Unchanged: it keeps booting. In production mode, when it keeps its database, its sign-in secret or its data key on its own disk because nobody set them, it prints a block naming each file and the variable that moves it off that disk, and hands the same facts to the server process on the internal `OS_INTERNAL_DEPLOYMENT_FINDINGS` channel.
- **`@objectstack/service-settings`.** Two routes behind the settings door, for callers holding `manage_platform_settings`: `GET /api/settings/deployment/notice` answers what this production deployment keeps on its own disk (the database file, the generated sign-in secret, the data key file) plus two lower-severity lines (uploads on the local disk; no email service that delivers), and `POST /api/settings/deployment/notice/hide` records an administrator's acknowledgement of exactly what they read as a `config_change` row on `sys_audit_log`. A member is refused `403`, the state is not on the public auth config, a hide is refused `409` where no audit log is mounted or the notice changed since it was read, and outside production mode the notice is empty. No package export is added.
40 changes: 38 additions & 2 deletions 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, `/api/v1/auth/*` is **silently skipped** — the server runs unauthenticated. |
| `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_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 All @@ -45,6 +45,40 @@ OS_AUTH_SECRET=$(openssl rand -hex 32)
OS_SECRET_KEY=$(openssl rand -hex 32)
```

### When the three are not set

**The official image refuses to start** unless `OS_DATABASE_URL`,
`OS_AUTH_SECRET` and `OS_SECRET_KEY` are all set explicitly. It exits naming
the ones that are missing, before it writes a database file or a secret: a
container's disk is the part that gets replaced, so booting on it would lose
the data, sign everyone out and leave every saved credential unreadable. The
image turns the check on with `OS_DEPLOYMENT_DURABILITY_CHECK_ENABLED=true` in
its `ENV`.

- An explicit `OS_DATABASE_URL=file:/path/on/a/volume/app.db` passes: SQLite on
a mounted volume stays a choice ([below](#docker-compose-with-postgres)).
- **Already running on values the image generated?** Carry them over before you
set new ones, or the saved credentials stop decrypting and every session
ends. They are files in the old container's home, `/home/node/.objectstack`
(`docker exec <container> cat <file>`, or read them from its volume):
`OS_AUTH_SECRET` is the contents of `auth-secret`, `OS_SECRET_KEY` the
contents of `dev-crypto-key`, and the data is `data/objectstack.db`
(`OS_DATABASE_URL=file:…` on a kept volume, or migrate it to your database).
The refusal prints these paths and says which of them the container holds.

**Every other `os start` keeps booting.** In production mode, when it keeps any
of the three on its own disk, it prints a warning block naming each file and the
variable that moves it off that disk. Administrators also see it in the Console:
a standing notice on the home page and at the top of Setup's System Overview,
listing the same files with a button that copies the variables to set, plus two
lower-severity lines when uploads are stored on the local disk or no email
service is set up. Members never see it, and it is not on the public auth
config (it is read from `GET /api/settings/deployment/notice`, which needs
`manage_platform_settings`). An administrator can hide it: the hide is written
to the audit log as a `config_change` (and refused where no audit log is
mounted), it lasts for that administrator in that browser until the findings
change, and System Overview keeps one small line saying the notice is hidden.

The full catalog is in
[Environment Variables](/docs/deployment/environment-variables).

Expand Down Expand Up @@ -252,7 +286,9 @@ curl -fsS http://localhost:8080/api/v1/health

Prefer SQLite on a single small host? Skip the `db` service, mount a volume,
and point `OS_DATABASE_URL` at it: `file:/srv/data/app.db` (with
`- appdata:/srv/data` on the app service). See
`- appdata:/srv/data` on the app service). Set it explicitly even then: the
official image refuses to start on its default database file, which lives in
the container layer ([When the three are not set](#when-the-three-are-not-set)). See
[Drivers](/docs/data-modeling/drivers) for when to reach for which database.

## Health checks & orchestration
Expand Down
14 changes: 12 additions & 2 deletions docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,18 @@ WORKDIR /srv/app
RUN chown node:node /srv/app
USER node

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

# Liveness: /api/v1/health. For orchestrated readiness use /api/v1/ready.
Expand All @@ -115,5 +124,6 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=15s \
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))"

# OS_DATABASE_URL, OS_AUTH_SECRET, and OS_SECRET_KEY are injected at runtime —
# never bake them into an image.
# never bake them into an image. Without them `os start` refuses to boot (see
# OS_DEPLOYMENT_DURABILITY_CHECK_ENABLED above).
CMD ["os", "start"]
19 changes: 15 additions & 4 deletions docker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,15 +87,26 @@ package set or on a version range.
## What the image presets

- `OS_ARTIFACT_PATH=/srv/app/objectstack.json`, `OS_PORT=8080`,
`NODE_ENV=production`
`NODE_ENV=production`, `OS_DEPLOYMENT_DURABILITY_CHECK_ENABLED=true`
- Runs as the non-root `node` user
- `HEALTHCHECK` on `/api/v1/health` (liveness); use `/api/v1/ready` as the
readiness probe in orchestrators

**You must inject at runtime:** `OS_DATABASE_URL`, `OS_AUTH_SECRET`,
`OS_SECRET_KEY` — never bake them into an image. Full variable catalog and
reverse-proxy / multi-node guidance:
[Self-Hosted Deployment](https://objectstack.ai/docs/deployment/self-hosting).
`OS_SECRET_KEY` — never bake them into an image. **The image refuses to start
without all three** (`OS_DEPLOYMENT_DURABILITY_CHECK_ENABLED=true`): it exits
naming the missing ones, because without them the data would be a SQLite file in
the container layer and both secrets would be generated there, all lost when the
container is replaced. An explicit `OS_DATABASE_URL=file:/path/on/a/volume/app.db`
passes, so SQLite on a mounted volume still works.

Upgrading a container that ran on values the image generated? Carry them over
first, from the old container's `/home/node/.objectstack`: `OS_AUTH_SECRET` is
the contents of `auth-secret`, `OS_SECRET_KEY` the contents of `dev-crypto-key`,
and the data is `data/objectstack.db`. The refusal prints these paths.

Full variable catalog and reverse-proxy / multi-node guidance:
[Self-Hosted Deployment](https://objectstack.ai/docs/deployment/self-hosting#when-the-three-are-not-set).

## Local build of this image

Expand Down
139 changes: 139 additions & 0 deletions packages/cli/src/commands/start-deployment-durability.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #30120 (ruling A3) — the pieces `os start` decides the image refusal and the
* boot block with. The real boots, refusal and block included, are pinned end
* to end in `test/start-deployment-durability.integration.test.ts`.
*/

import { describe, expect, it } from 'vitest';
import {
DURABILITY_CHECK_ENV,
INTERNAL_DEPLOYMENT_FINDINGS_ENV,
formatDurabilityBootBlock,
formatDurabilityRefusal,
isDefaultedDatabase,
missingDurableVariables,
readDurabilityCheckSignal,
sqliteFileOf,
writeDeploymentFindingsChannel,
} from './start.js';

describe('the image switch', () => {
it.each([
[undefined, 'off'],
['', 'off'],
[' ', 'off'],
['false', 'off'],
['0', 'off'],
['true', 'on'],
['TRUE', 'on'],
[' 1 ', 'on'],
['yes', 'invalid'],
['enabled', 'invalid'],
])('%j reads %s', (raw, verdict) => {
expect(readDurabilityCheckSignal({ [DURABILITY_CHECK_ENV]: raw })).toBe(verdict);
});

it('is not NODE_ENV: os start sets production on every boot, so production alone never refuses', () => {
expect(readDurabilityCheckSignal({ NODE_ENV: 'production' })).toBe('off');
});
});

describe('which variables the image requires and this boot did not get', () => {
it('a database nobody chose is missing; one from the flag, the environment or the project config is not', () => {
expect(isDefaultedDatabase('unified-default')).toBe(true);
expect(isDefaultedDatabase('legacy-file')).toBe(true);
for (const source of ['explicit', 'env', 'config-datasource'] as const) {
expect(isDefaultedDatabase(source)).toBe(false);
}
});

it('names each one missing, in the order the refusal lists them', () => {
expect(missingDurableVariables({ databaseSource: 'unified-default', authSecretSet: false, secretKeySet: false })).toEqual([
'OS_DATABASE_URL',
'OS_AUTH_SECRET',
'OS_SECRET_KEY',
]);
expect(missingDurableVariables({ databaseSource: 'env', authSecretSet: true, secretKeySet: false })).toEqual(['OS_SECRET_KEY']);
expect(missingDurableVariables({ databaseSource: 'env', authSecretSet: true, secretKeySet: true })).toEqual([]);
});

it('reads the file of a file: URL, and nothing of any other', () => {
expect(sqliteFileOf('file:/data/app.db')).toBe('/data/app.db');
expect(sqliteFileOf('file:///data/app.db')).toBe('/data/app.db');
expect(sqliteFileOf('file:')).toBeUndefined();
expect(sqliteFileOf('postgres://u:p@h:5432/db')).toBeUndefined();
});
});

describe('the refusal', () => {
const where = {
authSecretFile: '/home/node/.objectstack/auth-secret',
dataKeyFile: '/home/node/.objectstack/dev-crypto-key',
defaultDatabaseFile: '/home/node/.objectstack/data/objectstack.db',
exists: (f: string) => f.endsWith('auth-secret'),
};

it('names exactly the missing variables, and says how to set each', () => {
const all = formatDurabilityRefusal(['OS_DATABASE_URL', 'OS_AUTH_SECRET', 'OS_SECRET_KEY'], where);
expect(all).toContain('OS_DATABASE_URL, OS_AUTH_SECRET and OS_SECRET_KEY are not set');
expect(all).toMatch(/^ {4}OS_DATABASE_URL {2}your database/m);
expect(all).toContain('file:/path/on/a/mounted/volume/app.db');
expect(all).toMatch(/^ {4}OS_AUTH_SECRET {3}a long random value/m);
expect(all).toMatch(/^ {4}OS_SECRET_KEY {4}32 random bytes/m);

const one = formatDurabilityRefusal(['OS_SECRET_KEY'], where);
expect(one).toContain(': OS_SECRET_KEY is not set');
expect(one).not.toContain('OS_DATABASE_URL');
expect(one).not.toContain('OS_AUTH_SECRET');
});

it('says how a deployment on generated values carries them over, marking what this container holds', () => {
const text = formatDurabilityRefusal(['OS_DATABASE_URL', 'OS_AUTH_SECRET', 'OS_SECRET_KEY'], where);
expect(text).toContain('Carry them over before you set new ones');
expect(text).toContain('docker exec <container> cat <file>');
expect(text).toContain('OS_AUTH_SECRET the contents of /home/node/.objectstack/auth-secret (present in this container)');
expect(text).toContain('OS_SECRET_KEY the contents of /home/node/.objectstack/dev-crypto-key\n');
expect(text).toContain('file:/home/node/.objectstack/data/objectstack.db, if your data is that file on a kept volume');
});
});

describe('the boot block', () => {
it('lists each thing kept on this disk, with its file and the variable that moves it', () => {
const block = formatDurabilityBootBlock({
databaseFile: '/srv/.objectstack/data/objectstack.db',
authSecretFile: '/srv/.objectstack/auth-secret',
dataKeyFile: '/srv/.objectstack/dev-crypto-key',
})!;
expect(block).toContain('Production mode, and this server keeps these on its own disk');
expect(block).toContain('Database: a SQLite file, /srv/.objectstack/data/objectstack.db (OS_DATABASE_URL is not set)');
expect(block).toContain('Sign-in secret: generated and kept in /srv/.objectstack/auth-secret (OS_AUTH_SECRET is not set)');
expect(block).toContain('kept in /srv/.objectstack/dev-crypto-key (OS_SECRET_KEY is not set)');
expect(block).toContain('set OS_DATABASE_URL, OS_AUTH_SECRET and OS_SECRET_KEY');
expect(block).toContain('Administrators see this notice in the Console too.');
});

it('a secret generated for this process only says so', () => {
expect(formatDurabilityBootBlock({ authSecretFile: null })).toContain('generated for this process only');
});

it('a boot that keeps nothing on its disk by default prints nothing', () => {
expect(formatDurabilityBootBlock({})).toBeUndefined();
});
});

describe('the channel to the server process', () => {
// The reader (`service-settings` `deployment-notice.ts`) spells the name in
// its own module: the end-to-end pin reads the findings a real boot hands it.
it('carries the findings as JSON, and is removed when there are none', () => {
const env: Record<string, string | undefined> = { [INTERNAL_DEPLOYMENT_FINDINGS_ENV]: 'stale' };
writeDeploymentFindingsChannel(env, { databaseFile: '/d.db', authSecretFile: null });
expect(JSON.parse(env[INTERNAL_DEPLOYMENT_FINDINGS_ENV]!)).toEqual({ databaseFile: '/d.db', authSecretFile: null });
writeDeploymentFindingsChannel(env, {});
expect(INTERNAL_DEPLOYMENT_FINDINGS_ENV in env).toBe(false);
env[INTERNAL_DEPLOYMENT_FINDINGS_ENV] = 'inherited';
writeDeploymentFindingsChannel(env, undefined);
expect(INTERNAL_DEPLOYMENT_FINDINGS_ENV in env).toBe(false);
});
});
Loading
Loading