Skip to content

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

Draft
objectstack-fleet[bot] wants to merge 9 commits into
mainfrom
claude/project-thread-dj7jk6
Draft

objectstack-fleet[bot] wants to merge 9 commits into
mainfrom
claude/project-thread-dj7jk6

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Elon · project thread

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 照常启动,只是在生产模式下把「放在本机磁盘上的东西」打印在启动日志里;管理员在控制台首页和「设置 › 系统概览」顶部看到一条常驻提示(可一键复制要设的变量,可隐藏,隐藏写进审计日志),普通成员看不到。
  • 为什么改:[Decision] os start in production mode runs on a local SQLite file and self-generated secrets, with no warning #30120(裁决 A3)。镜像文档说这三项必须注入,但代码从不检查:容器里的业务数据是一个 SQLite 文件,登录密钥和加密密钥也在容器磁盘上自动生成,换一次容器就全部丢失,而管理员平时只看控制台、不看日志。
  • 风险与回滚:已经在用镜像且没设这三项的部署,升级后会启动失败——拒绝信息逐项写明原值在旧容器的哪个文件里(真实容器里验证过取出原值后能照常启动、不会重新生成密钥),也可设 OS_DEPLOYMENT_DURABILITY_CHECK_ENABLED=false 暂时回到旧行为;提示内容只经设置服务的管理员入口给出,公共配置里没有。另外,控制台首屏英文包本 head 实测 60,540 / 60,000 gz(提示文案 +633 字节),要等 perf(i18n, app-shell)!: the marketplace pages' strings leave the eager language packs for a lazy marketplace namespace #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」并列出从旧容器取值的三个文件。
  • 需要你做的:ci(scaffold-e2e): give the image run an explicit database (Refs #30120) #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 Forgot password says the reset link was sent on a deployment with no email service #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):
A1–A9
# 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

Acceptance notes

  • Needs ci(scaffold-e2e): give the image run an explicit database (Refs #30120) #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 ci(scaffold-e2e): give the image run an explicit database (Refs #30120) #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 (Console first load: the English language pack has 63 bytes left under its size ceiling, so any card that adds English console text fails CI #30588). The ceiling is not raised here: perf(i18n, app-shell)!: the marketplace pages' strings leave the eager language packs for a lazy marketplace namespace #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.

Container readings
  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.

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 [Decision] os start in production mode runs on a local SQLite file and self-generated secrets, with no warning #30120.

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic. (packages/spec untouched.)
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes.
  • 4. One issue, one branch off an up-to-date main, one pull request. (The governed workflow line is ci(scaffold-e2e): give the image run an explicit database (Refs #30120) #30714, Refs #30120, at the seat's direction.)
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: its generated artifacts are regenerated and committed. (Not touched.)
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English.
  • The Before Submitting list in CONTRIBUTING.md is done.

🤖 Generated with Claude Code

https://claude.ai/code/session_016WV4cyhB3ePno23ywn1PQd

@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 5, 2026
@github-actions

github-actions Bot commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/cli, @objectstack/service-settings, touching 60 documentable anchor(s).

54 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 447226b222467709ed74ca99eaf808c178656b33.

⛔ 17 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: PERMISSION_DENIED (literal, 51 pages)
  • 11 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 63 of 221 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 103 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 41 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 447226b222467709ed74ca99eaf808c178656b33 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 834735e288ae701eaf02f35b1c1bf75760b3e0ce — the merge of head ea323f08882bce9ad174e9ea701bceb179cefd6f into base 447226b222467709ed74ca99eaf808c178656b33, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 834735e288ae701eaf02f35b1c1bf75760b3e0ce && git checkout 834735e288ae701eaf02f35b1c1bf75760b3e0ce
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 447226b222467709ed74ca99eaf808c178656b33 ea323f08882bce9ad174e9ea701bceb179cefd6f && git checkout -B drift-repro 447226b222467709ed74ca99eaf808c178656b33 && git merge --no-ff ea323f08882bce9ad174e9ea701bceb179cefd6f

node scripts/docs-audit/affected-docs.mjs --json 447226b222467709ed74ca99eaf808c178656b33

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 447226b222467709ed74ca99eaf808c178656b33 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…er source the page reads

The administrator notice on the Console home reads its own state from the
server, so the Home tests that render an admin reached a real socket and
the network-escape guard failed them. Each one now stubs the notice module;
its fetch, gate and copy stay pinned in DeploymentNotice.test.tsx.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WV4cyhB3ePno23ywn1PQd

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Decision] os start in production mode runs on a local SQLite file and self-generated secrets, with no warning

1 participant