Skip to content

Commit a77c2e9

Browse files
fix(cli, service-settings, console): the official image refuses to start without its database and secrets; administrators see what a production server keeps on its own disk (#30720)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DUcNL9fhimxNPTrujEyeSTy)_ Fixes #30120 Clause-②: no Metadata-protocol: none Skills-docs: owed as a follow-up, not edited here — `skills/objectstack-platform/references/operations.md` (the Docker/Kubernetes row and "Common ops pitfalls") does not say the official image now refuses to start without `OS_DATABASE_URL`, `OS_AUTH_SECRET` and `OS_SECRET_KEY`; every `docker run` example under `content/docs/**` already sets all three, and `content/docs/deployment/self-hosting.mdx` + `docker/README.md` are updated here ## 维护者速读 - 改了什么:官方容器镜像在没有显式设置数据库地址、登录密钥、数据加密密钥这三项时拒绝启动,点名缺哪几项,并写明已经在用自动生成值的部署怎样先把原值从旧容器里取出来;其他方式的 `os start` 照常启动,只是在生产模式下把「放在本机磁盘上的东西」打印在启动日志里;管理员在控制台首页和「设置 › 系统概览」顶部看到一条常驻提示(可一键复制要设的变量,可隐藏,隐藏写进审计日志),普通成员看不到。 - 为什么改:#30120(裁决 A3)。镜像文档说这三项必须注入,但代码从不检查:容器里的业务数据是一个 SQLite 文件,登录密钥和加密密钥也在容器磁盘上自动生成,换一次容器就全部丢失,而管理员平时只看控制台、不看日志。 - 风险与回滚:已经在用镜像且没设这三项的部署,升级后会启动失败——拒绝信息逐项写明原值在旧容器的哪个文件里(真实容器里验证过取出原值后能照常启动、不会重新生成密钥),也可设 `OS_DEPLOYMENT_DURABILITY_CHECK_ENABLED=false` 暂时回到旧行为;提示内容只经设置服务的管理员入口给出,公共配置里没有。另外,控制台首屏英文包本 head 实测 60,540 / 60,000 gz(提示文案 +633 字节),要等 #30640 把应用市场文案移出首屏后再合并 main 才会转绿,上限不抬。回滚即还原本 PR。 - 先看:`packages/cli/src/commands/start.ts` 的拒启分支(`durabilityCheck === 'on'`)和写给服务进程的 `OS_INTERNAL_DEPLOYMENT_FINDINGS`;`packages/services/service-settings/src/deployment-notice.ts` 的 `hide`(指纹比对后才写审计行);`settings-routes.ts` 两条新路由的能力校验 `refuseNoticeCaller`。 - 两分钟验证:先 `pnpm --filter @objectstack/cli build`,再在空目录里运行 `OS_DEPLOYMENT_DURABILITY_CHECK_ENABLED=true HOME=$(mktemp -d) node <repo>/packages/cli/bin/run.js start`,预期退出码 1,提示「OS_DATABASE_URL, OS_AUTH_SECRET and OS_SECRET_KEY are not set」并列出从旧容器取值的三个文件。 - 需要你做的:#30714(受管的工作流一行改动)需要 os-zhuang 或 hotlong 批准,并先于本 PR 合入。 ## What changes **The official image refuses a start it would lose (`packages/cli`, `docker/Dockerfile`).** - `docker/Dockerfile` sets `OS_DEPLOYMENT_DURABILITY_CHECK_ENABLED=true` in its `ENV`. `NODE_ENV` cannot be the signal: `os start` sets `production` itself on every boot, including the zero-config laptop trial (measured, see Verification). - With the switch on, `os start` exits 1 before it opens a database or writes a secret, unless `OS_DATABASE_URL`, `OS_AUTH_SECRET` and `OS_SECRET_KEY` are all set explicitly. It names exactly the missing ones and says how to set each. An explicit `OS_DATABASE_URL=file:/path/on/a/volume/app.db` passes, so SQLite on a kept volume stays a choice. - The refusal also says how a deployment that already ran on generated values carries them over: `docker exec <container> cat <file>` for `auth-secret`, `dev-crypto-key` and `data/objectstack.db` under the home directory, marking which of those files this container holds. - A switch value other than `true`/`false`/`1`/`0` (or empty) is refused, not read as off. **Every other `os start` keeps booting, and says what it keeps on its own disk.** - In production mode, when the database is the defaulted SQLite file, or the sign-in secret or the data key was generated on this disk, the boot prints one block naming each file and the variable that moves it, and says administrators see the same in the Console. - The same facts go to the server process on an internal, CLI-owned environment channel (`OS_INTERNAL_DEPLOYMENT_FINDINGS`, JSON). `service-settings` spells the name in its own module: no package entry gains an export. **The administrator's notice (`packages/services/service-settings`).** - `GET /api/settings/deployment/notice` and `POST /api/settings/deployment/notice/hide`, mounted through the settings family door (`mountFamilyRoute`: held-session gate, anonymous floor `401`, authz outage `503`), for callers holding `manage_platform_settings`. A member gets `403 PERMISSION_DENIED` and the door never reads the process. Both rows are in `settings-route-ledger.ts`. - The state: `production`, the findings (`database_local_file`, `auth_secret_generated`, `data_key_file` at high severity with their variable and file; `file_storage_local` and `email_not_configured` at low severity) and a `fingerprint` (the finding ids). Outside production mode the list is empty. The email line reads `IEmailService.isConfigured()`, the signal #30107 added; storage reads the `storage.adapter` setting. - The hide names the fingerprint the administrator read. It writes one `config_change` row on `sys_audit_log` through the same single insert site the deployment config audit uses (`config-change-audit.ts`), with `metadata.event = 'deployment_notice.hide'`, the fingerprint and the findings. It is refused `409 RESOURCE_CONFLICT` when the notice changed since it was read (the answer carries the current state), when there is nothing to hide, and when no audit log is mounted (`details.reason: 'no_audit_log'`): a hide that cannot be audited does not happen. - The state is not on `/api/v1/auth/config` or `/api/v1/runtime/config` (pinned end to end). **The Console (`ui/packages/app-shell`, `ui/packages/i18n`).** - `DeploymentNotice` on the home page (both the empty and the populated layout) and at the top of Setup's System Overview dashboard. It is drawn for a resolved workspace administrator only; a member's browser never sends the request. No dialog. - "Copy the settings" puts an env-file block on the clipboard: each high finding's variable, with a comment saying what value it takes, and for a generated value, which file to carry the current value over from. - "Hide" posts the fingerprint; on success the hide is remembered per user in this browser (`localStorage`, keyed by the fingerprint, so a new finding brings the notice back). The home page then draws nothing; System Overview keeps one small line with "Show again". A deployment with no audit log says the notice cannot be hidden. - 18 keys under `deploymentNotice` in all ten language packs. **Docs.** `content/docs/deployment/self-hosting.mdx` gets a "When the three are not set" section (the refusal, the `file:` pass, carrying values over, the boot block, the notice and its hide) and corrects the `OS_AUTH_SECRET` row (`os start` generates it into `auth-secret` under the home directory). `docker/README.md` states the refusal, the carry-over and the switch. ## Why `docker/README.md` declared the three variables required and nothing enforced it: the unchanged image boots with none of them, keeps the business data in a SQLite file in the container layer and mints both secrets on the container's disk (measured below). Replacing the container loses the data, every sign-in and every saved credential. The ruling (A3, 6634240777) refuses that in the image, keeps every other `os start` booting, and tells the administrator, who reads the Console rather than the log. ## Tests - `packages/cli/src/commands/start-deployment-durability.test.ts` (20): the switch's values, which variables count as missing and in what order, `file:` URL parsing, the refusal and boot-block text, the channel write and removal. - `packages/cli/test/start-deployment-durability.integration.test.ts` (8, real `os start` processes): refusal with none set; with only the database missing (names only `OS_DATABASE_URL`); an invalid switch; an explicit `file:` database plus both secrets boots with no block; the defaulted boot prints the block; the administrator reads the three high findings plus the two low ones; a member (created by the admin) is refused `403` with no server path in the answer; neither public config carries a finding, with a non-vacuity control. - `packages/services/service-settings/src/deployment-notice.routes.test.ts` (19): findings and their order, the CLI's key-file channel, a malformed channel, low-severity lines, outside production; admin `200`, member `403` for read and hide with no env read and no insert, anonymous `401`, no door `503`; the audit row's shape and system context, `409` with no ledger / no engine / a changed fingerprint / nothing to hide, `400` without a fingerprint, `500` when the insert throws. - Pins updated for two more routes: `settings-routes.anonymous-floor.pin.test.ts`, `settings-routes.auth-gate.pin.test.ts` (six door members), `settings-routes.authz-outage-relay.test.ts`. - `ui/packages/app-shell/src/console/deployment-notice/__tests__/DeploymentNotice.test.tsx` (12): admin draws, member never requests, production-only, copy text, hide paths (hidden, no audit log, changed, failed), the per-fingerprint hide, System Overview's line and "Show again". - **Ablations** (each restored the forbidden behaviour on disk, watched the pin go red, then restored; full output in `/mnt/project-files/server-lane-seat3/30120/ablations.md`): <details><summary>A1–A9</summary> | # | Mutation | Red | |---|---|---| | A1 | capability check removed from the notice routes | 2 / 19 (member read and hide) | | A2 | the hide writes no audit row | 2 / 19 | | A3 | the no-ledger refusal removed | 1 / 19 | | A4 | the notice speaks outside production | 1 / 19 | | A5 | the fingerprint check removed | 1 / 19 | | A6 | the Console's admin gate removed | 1 / 12 | | A7 | the hide not keyed by fingerprint | 1 / 12 | | A8 | the image refusal removed (dist rebuilt) | 2 / 8 integration | | A9 | every production start refuses, switch or not | 4 / 8 integration | </details> ## Acceptance notes - **Needs #30714 first.** `.github/workflows/scaffold-e2e.yml` runs the image built from the checkout with only `OS_SECRET_KEY` and `OS_AUTH_SECRET`; with the refusal it would go red. The one line (`-e OS_DATABASE_URL="file:/tmp/e2e.db"`) is governed, so it rides #30714 (`Refs #30120`), which lands before this PR. - **The eager `en` catalogue.** `check:eager-closure` measures `i18n-locale-en` at 60,540 gz on this head against its 60,000 ceiling (over by 540). The notice's 18 keys are +633 gz of that chunk (the built chunk with and without the block: 60,440 / 59,807 by `gzip -9`), and `main` had ~95 bytes left (#30588). The ceiling is not raised here: #30640 moves the marketplace copy into a lazy namespace (its reading 57,003), and this branch merges `main` after it lands. Every other chunk and the aggregate are inside their ceilings (aggregate 2,158.4 KB / 2,431.6 KB). - **What counts as explicit.** A database from `--database`, `OS_DATABASE_URL` or a datasource the project config declares; an auth secret from `--auth-secret` or `OS_AUTH_SECRET`; their legacy aliases too. `OS_DEV_CRYPTO_KEY` does not count for `OS_SECRET_KEY`: it is the dev key the refusal exists to keep out of a container. - **The image check runs only where the switch is on.** A custom image built `FROM node` that runs `os start` gets the boot block and the notice, not the refusal. - **The boot block names the three disk-bound facts only.** Local uploads and no email service are on the admin notice at low severity, and the existing storage warning already prints at boot. - **The hide is per administrator, per browser,** plus a mandatory audit row; there is no server-side "hidden" setting (a settings namespace would need a hub card and nav copy outside this claim). Another administrator, or the same one in another browser, still sees the notice. - **An un-enforced settings context is not capability-gated,** the same as every other settings route (`ctx.enforced`). - **The data key finding reads the local crypto provider's tiers** (the CLI's key-file channel first, then the persisted key file). A deployment that registers a non-local `ICryptoProvider` and also leaves `OS_SECRET_KEY` unset would still be told about the local file. - **Upgrade note.** The changeset tells an image deployment on defaults to carry `auth-secret`, `dev-crypto-key` and the database file over before upgrading; the refusal says the same at the moment it matters. No BREAKING marker: the published packages' surfaces are unchanged. ## Verification **Measured on `main` `47c457b2` first** (`/mnt/project-files/server-lane-seat3/30120/measure-main.md`): `os start` with `NODE_ENV=production` and none of the three set boots, writes `auth-secret`, `dev-crypto-key` and `data/objectstack.db` under the home directory, and answers health `200`. The official image built from `docker/Dockerfile` (published CLI 18.3.0) boots the same way with none set: the ruling's premise holds. **Real container, this branch** (`/mnt/project-files/server-lane-seat3/30120/container-verify.md`). Image `objectstack:30120-branch`: this branch's `docker/Dockerfile` built, then the published CLI replaced by this branch's CLI and its workspace dependencies (`pnpm --filter @objectstack/cli deploy --prod --legacy`), so `os start` in the container is the code under review. <details><summary>Container readings</summary> 1. None of the three set: exit 1, naming all three, with the carry-over lines. 2. Explicit `OS_DATABASE_URL=file:/data/app.db` on a mounted volume plus both secrets: health `200` after ~10 s, no block, `app.db` on the volume. 3. Upgrade: the published 18.3.0 image ran on a volume with none set (home holds `auth-secret`, `data/objectstack.db`, `dev-crypto-key`); this branch's image on the same volume refuses and marks all three files "(present in this container)"; with the carried-over values set it boots, prints no block, and the crypto provider mints no new key. 4. Switch `false`, none set: boots and prints the block; the first registrant's `GET /api/settings/deployment/notice` lists the five findings with their files; the hide answers `200`; anonymous `401`; `/api/v1/auth/config` names no finding. The hide's `sys_audit_log` row, read from the container's database: `action: config_change`, `user_id` = `actor` = the administrator, `object_name`/`record_id`/`tenant_id` null, `metadata.event: deployment_notice.hide` with the fingerprint and findings. Not verified in the container: the Console UI, because the deploy bundle carries no built Console (`ConsoleNotBuilt`); the component tests above cover it. </details> **Local commands** (exit codes captured before any pipe): - `pnpm --filter @objectstack/cli exec vitest run src/commands/start-deployment-durability.test.ts`: 20 passed. - `pnpm --filter @objectstack/cli exec vitest run --project integration test/start-deployment-durability.integration.test.ts`: 8 passed. - `pnpm --filter @objectstack/service-settings exec vitest run`: 70 files, 1227 tests passed (the notice routes and the three updated pins among them). - `pnpm lint`: exit 0. - `pnpm --dir ui exec vitest run packages/app-shell/src/console/deployment-notice`: 12 passed. - `pnpm --dir ui/apps/console exec vite build` then `pnpm --dir ui run check:eager-closure`: exit 1 on `i18n-locale-en` only (see Acceptance notes). - The derived gate battery (`node scripts/pm/dispatch-gates.mjs --commands`, plus `pnpm lint`) and its `--ran` reconciliation: in the os-dev report on #30120. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (`packages/spec` untouched.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. (The governed workflow line is #30714, `Refs #30120`, at the seat's direction.) - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (Not touched.) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016WV4cyhB3ePno23ywn1PQd --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 87fc795 commit a77c2e9

45 files changed

Lines changed: 2813 additions & 35 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/i18n': patch
4+
---
5+
6+
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
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
- **`@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.
12+
- **`@object-ui/i18n`.** A new `deploymentNotice` block (18 keys) in all ten packs.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/cli': patch
3+
'@objectstack/service-settings': patch
4+
---
5+
6+
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
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
- **`@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.
12+
- **`@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.
13+
- **`@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.

‎content/docs/deployment/self-hosting.mdx‎

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ workable default:
3434
| Variable | Why it must be set |
3535
|:---|:---|
3636
| `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). |
37-
| `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. |
37+
| `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. |
3838
| `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. |
3939
| `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. |
4040

@@ -45,6 +45,40 @@ OS_AUTH_SECRET=$(openssl rand -hex 32)
4545
OS_SECRET_KEY=$(openssl rand -hex 32)
4646
```
4747

48+
### When the three are not set
49+
50+
**The official image refuses to start** unless `OS_DATABASE_URL`,
51+
`OS_AUTH_SECRET` and `OS_SECRET_KEY` are all set explicitly. It exits naming
52+
the ones that are missing, before it writes a database file or a secret: a
53+
container's disk is the part that gets replaced, so booting on it would lose
54+
the data, sign everyone out and leave every saved credential unreadable. The
55+
image turns the check on with `OS_DEPLOYMENT_DURABILITY_CHECK_ENABLED=true` in
56+
its `ENV`.
57+
58+
- An explicit `OS_DATABASE_URL=file:/path/on/a/volume/app.db` passes: SQLite on
59+
a mounted volume stays a choice ([below](#docker-compose-with-postgres)).
60+
- **Already running on values the image generated?** Carry them over before you
61+
set new ones, or the saved credentials stop decrypting and every session
62+
ends. They are files in the old container's home, `/home/node/.objectstack`
63+
(`docker exec <container> cat <file>`, or read them from its volume):
64+
`OS_AUTH_SECRET` is the contents of `auth-secret`, `OS_SECRET_KEY` the
65+
contents of `dev-crypto-key`, and the data is `data/objectstack.db`
66+
(`OS_DATABASE_URL=file:…` on a kept volume, or migrate it to your database).
67+
The refusal prints these paths and says which of them the container holds.
68+
69+
**Every other `os start` keeps booting.** In production mode, when it keeps any
70+
of the three on its own disk, it prints a warning block naming each file and the
71+
variable that moves it off that disk. Administrators also see it in the Console:
72+
a standing notice on the home page and at the top of Setup's System Overview,
73+
listing the same files with a button that copies the variables to set, plus two
74+
lower-severity lines when uploads are stored on the local disk or no email
75+
service is set up. Members never see it, and it is not on the public auth
76+
config (it is read from `GET /api/settings/deployment/notice`, which needs
77+
`manage_platform_settings`). An administrator can hide it: the hide is written
78+
to the audit log as a `config_change` (and refused where no audit log is
79+
mounted), it lasts for that administrator in that browser until the findings
80+
change, and System Overview keeps one small line saying the notice is hidden.
81+
4882
The full catalog is in
4983
[Environment Variables](/docs/deployment/environment-variables).
5084

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

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

258294
## Health checks & orchestration

‎docker/Dockerfile‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,9 +104,18 @@ WORKDIR /srv/app
104104
RUN chown node:node /srv/app
105105
USER node
106106

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

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

117126
# OS_DATABASE_URL, OS_AUTH_SECRET, and OS_SECRET_KEY are injected at runtime —
118-
# never bake them into an image.
127+
# never bake them into an image. Without them `os start` refuses to boot (see
128+
# OS_DEPLOYMENT_DURABILITY_CHECK_ENABLED above).
119129
CMD ["os", "start"]

‎docker/README.md‎

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,15 +87,26 @@ package set or on a version range.
8787
## What the image presets
8888

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

9595
**You must inject at runtime:** `OS_DATABASE_URL`, `OS_AUTH_SECRET`,
96-
`OS_SECRET_KEY` — never bake them into an image. Full variable catalog and
97-
reverse-proxy / multi-node guidance:
98-
[Self-Hosted Deployment](https://objectstack.ai/docs/deployment/self-hosting).
96+
`OS_SECRET_KEY` — never bake them into an image. **The image refuses to start
97+
without all three** (`OS_DEPLOYMENT_DURABILITY_CHECK_ENABLED=true`): it exits
98+
naming the missing ones, because without them the data would be a SQLite file in
99+
the container layer and both secrets would be generated there, all lost when the
100+
container is replaced. An explicit `OS_DATABASE_URL=file:/path/on/a/volume/app.db`
101+
passes, so SQLite on a mounted volume still works.
102+
103+
Upgrading a container that ran on values the image generated? Carry them over
104+
first, from the old container's `/home/node/.objectstack`: `OS_AUTH_SECRET` is
105+
the contents of `auth-secret`, `OS_SECRET_KEY` the contents of `dev-crypto-key`,
106+
and the data is `data/objectstack.db`. The refusal prints these paths.
107+
108+
Full variable catalog and reverse-proxy / multi-node guidance:
109+
[Self-Hosted Deployment](https://objectstack.ai/docs/deployment/self-hosting#when-the-three-are-not-set).
99110

100111
## Local build of this image
101112

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #30120 (ruling A3) — the pieces `os start` decides the image refusal and the
5+
* boot block with. The real boots, refusal and block included, are pinned end
6+
* to end in `test/start-deployment-durability.integration.test.ts`.
7+
*/
8+
9+
import { describe, expect, it } from 'vitest';
10+
import {
11+
DURABILITY_CHECK_ENV,
12+
INTERNAL_DEPLOYMENT_FINDINGS_ENV,
13+
formatDurabilityBootBlock,
14+
formatDurabilityRefusal,
15+
isDefaultedDatabase,
16+
missingDurableVariables,
17+
readDurabilityCheckSignal,
18+
sqliteFileOf,
19+
writeDeploymentFindingsChannel,
20+
} from './start.js';
21+
22+
describe('the image switch', () => {
23+
it.each([
24+
[undefined, 'off'],
25+
['', 'off'],
26+
[' ', 'off'],
27+
['false', 'off'],
28+
['0', 'off'],
29+
['true', 'on'],
30+
['TRUE', 'on'],
31+
[' 1 ', 'on'],
32+
['yes', 'invalid'],
33+
['enabled', 'invalid'],
34+
])('%j reads %s', (raw, verdict) => {
35+
expect(readDurabilityCheckSignal({ [DURABILITY_CHECK_ENV]: raw })).toBe(verdict);
36+
});
37+
38+
it('is not NODE_ENV: os start sets production on every boot, so production alone never refuses', () => {
39+
expect(readDurabilityCheckSignal({ NODE_ENV: 'production' })).toBe('off');
40+
});
41+
});
42+
43+
describe('which variables the image requires and this boot did not get', () => {
44+
it('a database nobody chose is missing; one from the flag, the environment or the project config is not', () => {
45+
expect(isDefaultedDatabase('unified-default')).toBe(true);
46+
expect(isDefaultedDatabase('legacy-file')).toBe(true);
47+
for (const source of ['explicit', 'env', 'config-datasource'] as const) {
48+
expect(isDefaultedDatabase(source)).toBe(false);
49+
}
50+
});
51+
52+
it('names each one missing, in the order the refusal lists them', () => {
53+
expect(missingDurableVariables({ databaseSource: 'unified-default', authSecretSet: false, secretKeySet: false })).toEqual([
54+
'OS_DATABASE_URL',
55+
'OS_AUTH_SECRET',
56+
'OS_SECRET_KEY',
57+
]);
58+
expect(missingDurableVariables({ databaseSource: 'env', authSecretSet: true, secretKeySet: false })).toEqual(['OS_SECRET_KEY']);
59+
expect(missingDurableVariables({ databaseSource: 'env', authSecretSet: true, secretKeySet: true })).toEqual([]);
60+
});
61+
62+
it('reads the file of a file: URL, and nothing of any other', () => {
63+
expect(sqliteFileOf('file:/data/app.db')).toBe('/data/app.db');
64+
expect(sqliteFileOf('file:///data/app.db')).toBe('/data/app.db');
65+
expect(sqliteFileOf('file:')).toBeUndefined();
66+
expect(sqliteFileOf('postgres://u:p@h:5432/db')).toBeUndefined();
67+
});
68+
});
69+
70+
describe('the refusal', () => {
71+
const where = {
72+
authSecretFile: '/home/node/.objectstack/auth-secret',
73+
dataKeyFile: '/home/node/.objectstack/dev-crypto-key',
74+
defaultDatabaseFile: '/home/node/.objectstack/data/objectstack.db',
75+
exists: (f: string) => f.endsWith('auth-secret'),
76+
};
77+
78+
it('names exactly the missing variables, and says how to set each', () => {
79+
const all = formatDurabilityRefusal(['OS_DATABASE_URL', 'OS_AUTH_SECRET', 'OS_SECRET_KEY'], where);
80+
expect(all).toContain('OS_DATABASE_URL, OS_AUTH_SECRET and OS_SECRET_KEY are not set');
81+
expect(all).toMatch(/^ {4}OS_DATABASE_URL {2}your database/m);
82+
expect(all).toContain('file:/path/on/a/mounted/volume/app.db');
83+
expect(all).toMatch(/^ {4}OS_AUTH_SECRET {3}a long random value/m);
84+
expect(all).toMatch(/^ {4}OS_SECRET_KEY {4}32 random bytes/m);
85+
86+
const one = formatDurabilityRefusal(['OS_SECRET_KEY'], where);
87+
expect(one).toContain(': OS_SECRET_KEY is not set');
88+
expect(one).not.toContain('OS_DATABASE_URL');
89+
expect(one).not.toContain('OS_AUTH_SECRET');
90+
});
91+
92+
it('says how a deployment on generated values carries them over, marking what this container holds', () => {
93+
const text = formatDurabilityRefusal(['OS_DATABASE_URL', 'OS_AUTH_SECRET', 'OS_SECRET_KEY'], where);
94+
expect(text).toContain('Carry them over before you set new ones');
95+
expect(text).toContain('docker exec <container> cat <file>');
96+
expect(text).toContain('OS_AUTH_SECRET the contents of /home/node/.objectstack/auth-secret (present in this container)');
97+
expect(text).toContain('OS_SECRET_KEY the contents of /home/node/.objectstack/dev-crypto-key\n');
98+
expect(text).toContain('file:/home/node/.objectstack/data/objectstack.db, if your data is that file on a kept volume');
99+
});
100+
});
101+
102+
describe('the boot block', () => {
103+
it('lists each thing kept on this disk, with its file and the variable that moves it', () => {
104+
const block = formatDurabilityBootBlock({
105+
databaseFile: '/srv/.objectstack/data/objectstack.db',
106+
authSecretFile: '/srv/.objectstack/auth-secret',
107+
dataKeyFile: '/srv/.objectstack/dev-crypto-key',
108+
})!;
109+
expect(block).toContain('Production mode, and this server keeps these on its own disk');
110+
expect(block).toContain('Database: a SQLite file, /srv/.objectstack/data/objectstack.db (OS_DATABASE_URL is not set)');
111+
expect(block).toContain('Sign-in secret: generated and kept in /srv/.objectstack/auth-secret (OS_AUTH_SECRET is not set)');
112+
expect(block).toContain('kept in /srv/.objectstack/dev-crypto-key (OS_SECRET_KEY is not set)');
113+
expect(block).toContain('set OS_DATABASE_URL, OS_AUTH_SECRET and OS_SECRET_KEY');
114+
expect(block).toContain('Administrators see this notice in the Console too.');
115+
});
116+
117+
it('a secret generated for this process only says so', () => {
118+
expect(formatDurabilityBootBlock({ authSecretFile: null })).toContain('generated for this process only');
119+
});
120+
121+
it('a boot that keeps nothing on its disk by default prints nothing', () => {
122+
expect(formatDurabilityBootBlock({})).toBeUndefined();
123+
});
124+
});
125+
126+
describe('the channel to the server process', () => {
127+
// The reader (`service-settings` `deployment-notice.ts`) spells the name in
128+
// its own module: the end-to-end pin reads the findings a real boot hands it.
129+
it('carries the findings as JSON, and is removed when there are none', () => {
130+
const env: Record<string, string | undefined> = { [INTERNAL_DEPLOYMENT_FINDINGS_ENV]: 'stale' };
131+
writeDeploymentFindingsChannel(env, { databaseFile: '/d.db', authSecretFile: null });
132+
expect(JSON.parse(env[INTERNAL_DEPLOYMENT_FINDINGS_ENV]!)).toEqual({ databaseFile: '/d.db', authSecretFile: null });
133+
writeDeploymentFindingsChannel(env, {});
134+
expect(INTERNAL_DEPLOYMENT_FINDINGS_ENV in env).toBe(false);
135+
env[INTERNAL_DEPLOYMENT_FINDINGS_ENV] = 'inherited';
136+
writeDeploymentFindingsChannel(env, undefined);
137+
expect(INTERNAL_DEPLOYMENT_FINDINGS_ENV in env).toBe(false);
138+
});
139+
});

0 commit comments

Comments
 (0)