diff --git a/.changeset/30120-console-deployment-notice.md b/.changeset/30120-console-deployment-notice.md new file mode 100644 index 00000000000..0440cf67eb0 --- /dev/null +++ b/.changeset/30120-console-deployment-notice.md @@ -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. diff --git a/.changeset/30120-image-refuses-undurable-start.md b/.changeset/30120-image-refuses-undurable-start.md new file mode 100644 index 00000000000..424827d2977 --- /dev/null +++ b/.changeset/30120-image-refuses-undurable-start.md @@ -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. diff --git a/content/docs/deployment/self-hosting.mdx b/content/docs/deployment/self-hosting.mdx index 6fc7ddaf7a7..75ac9a1dabb 100644 --- a/content/docs/deployment/self-hosting.mdx +++ b/content/docs/deployment/self-hosting.mdx @@ -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 `/.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. | @@ -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 cat `, 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). @@ -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 diff --git a/docker/Dockerfile b/docker/Dockerfile index 26ff3696bfe..fc84b7d905d 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -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. @@ -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"] diff --git a/docker/README.md b/docker/README.md index 657d155e0b2..36ce808b933 100644 --- a/docker/README.md +++ b/docker/README.md @@ -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 diff --git a/packages/cli/src/commands/start-deployment-durability.test.ts b/packages/cli/src/commands/start-deployment-durability.test.ts new file mode 100644 index 00000000000..c8e8c4340c1 --- /dev/null +++ b/packages/cli/src/commands/start-deployment-durability.test.ts @@ -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 cat '); + 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 = { [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); + }); +}); diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 387f3e7b0ad..22f8c5cd37a 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -321,6 +321,44 @@ export default class Start extends Command { // (`utils/internal-auth-secret-channel.ts`). const operatorAuthSecret = flags['auth-secret'] ?? readEnvWithDeprecation('OS_AUTH_SECRET', ['AUTH_SECRET', 'BETTER_AUTH_SECRET'], { silent: true }); + + // ── The official image's durability check (ruling A3, record 6634240777) ── + // The image (`docker/Dockerfile`) sets `OS_DEPLOYMENT_DURABILITY_CHECK_ENABLED`, + // because a container's disk is the thing that gets replaced: a SQLite file + // in its writable layer and secrets minted there are lost with it. Under the + // signal this command refuses unless the database and both secrets are set + // explicitly, and names the missing ones. Asked HERE, after the database is + // resolved and before anything is generated: the refusal writes no secret + // and opens no database. ⛔ Not keyed on `NODE_ENV`: this command forces + // `production` when it is unset (below), so the zero-config trial is itself + // production mode and a mode-keyed refusal would end it. + const durabilityCheck = readDurabilityCheckSignal(process.env); + if (durabilityCheck === 'invalid') { + printError( + `${DURABILITY_CHECK_ENV}="${process.env[DURABILITY_CHECK_ENV]}" is not a switch value — set it to true or false.`, + ); + process.exit(1); + } + if (durabilityCheck === 'on') { + const missing = missingDurableVariables({ + databaseSource: resolvedDb.source, + authSecretSet: operatorAuthSecret !== undefined && operatorAuthSecret.trim().length > 0, + secretKeySet: (process.env.OS_SECRET_KEY ?? '').trim().length > 0, + }); + if (missing.length > 0) { + const keyFile = await persistedDataKeyFile({ + env: process.env, cwd, hasProjectConfig, homeFlag: flags.home, + }); + process.stderr.write(`${formatDurabilityRefusal(missing, { + authSecretFile: path.join(homeDir, 'auth-secret'), + dataKeyFile: keyFile, + defaultDatabaseFile: sqliteFileOf(resolvedDb.url), + exists: (p) => fs.existsSync(p), + })}\n`); + process.exit(1); + } + } + const persistedAuthSecret = operatorAuthSecret === undefined ? readOrCreateAuthSecret(homeDir) : undefined; const authSecret = persistedAuthSecret ? persistedAuthSecret.secret : operatorAuthSecret; @@ -503,6 +541,26 @@ export default class Start extends Command { }); if (keyFileNotice) console.log(chalk.yellow(` ⚠ ${keyFileNotice}`)); + // ── What a production boot keeps on this machine's disk (ruling A3) ──── + // Every `os start` outside the image keeps booting, and says so: one block + // for the deployer, naming the database file and the two secrets nobody set, + // with what is lost if this disk is not kept. The same facts go to the + // `serve` child on the CLI's internal channel, where the settings service + // serves them to administrators as the Console's deployment notice (the + // data key's file it reads from its own channel, which the line above set). + const { KEY_FILE_CHANNEL_ENV } = await import('@objectstack/service-settings'); + const dataKeyFile = localEnv[KEY_FILE_CHANNEL_ENV]; + const findings: DeploymentBootFindings | undefined = localEnv.NODE_ENV === 'production' + ? { + ...(isDefaultedDatabase(resolvedDb.source) ? { databaseFile: sqliteFileOf(databaseUrl) ?? databaseUrl } : {}), + ...(persistedAuthSecret ? { authSecretFile: persistedAuthSecret.file ?? null } : {}), + ...(dataKeyFile ? { dataKeyFile } : {}), + } + : undefined; + writeDeploymentFindingsChannel(localEnv, findings); + const durabilityBlock = findings ? formatDurabilityBootBlock(findings) : undefined; + if (durabilityBlock) console.log(chalk.yellow(durabilityBlock)); + const binPath = process.argv[1]; // ── The serve child's lifecycle: `os dev`'s mechanism, not a copy (#21114) @@ -625,3 +683,223 @@ function readOrCreateAuthSecret(homeDir: string): { secret: string; file?: strin } return { secret }; } + +// ── Deployment durability (ruling A3, record 6634240777) ───────────────────── +// +// Two audiences, one set of facts. The official image refuses to start unless +// the database and both secrets are set explicitly; every other `os start` +// boots and prints a block for the deployer; and the same facts reach the +// `serve` child, whose settings service serves them to administrators as the +// Console's deployment notice. The facts are this command's own decisions: it +// resolved the database, it generated the auth secret, and it opted the data +// key into a persisted file. Nothing below reads `NODE_ENV` to decide the +// refusal — see the comment at the call site. + +/** + * The official image's signal (`docker/Dockerfile` sets it to `true`): under + * it `os start` refuses to boot unless `OS_DATABASE_URL`, `OS_AUTH_SECRET` and + * `OS_SECRET_KEY` are set explicitly. Default off, so a local `os start` keeps + * its zero-config boot. + */ +export const DURABILITY_CHECK_ENV = 'OS_DEPLOYMENT_DURABILITY_CHECK_ENABLED'; + +/** + * The CLI's INTERNAL channel to its `serve` child: the boot facts the + * administrator notice states, as JSON ({@link DeploymentBootFindings}). Same + * naming and ownership rule as `OS_INTERNAL_AUTH_SECRET_FILE` + * (`utils/internal-auth-secret-channel.ts`): set by this command alone, deleted + * when it has nothing to say, so an inherited copy never speaks for this run, + * and not an operator setting. The reader is `@objectstack/service-settings` + * (`deployment-notice.ts`), which spells the same name: the settings package + * publishes no constant for it, and the end-to-end pin + * (`test/start-deployment-durability.integration.test.ts`) reads the notice a + * real `os start` serves, so a renamed side fails there. + */ +export const INTERNAL_DEPLOYMENT_FINDINGS_ENV = 'OS_INTERNAL_DEPLOYMENT_FINDINGS'; + +/** What a production boot keeps on this machine's disk because nobody set it. */ +export interface DeploymentBootFindings { + /** The database is a SQLite file no setting chose: the path, or the URL when it names no file. */ + databaseFile?: string; + /** The auth secret was generated: the file it is kept in, `null` when only this process holds it. */ + authSecretFile?: string | null; + /** The data key is read from (and on a first boot minted into) this file: `OS_SECRET_KEY` is unset. */ + dataKeyFile?: string; +} + +/** A variable the image requires, in the order the refusal names them. */ +export type DurableVariable = 'OS_DATABASE_URL' | 'OS_AUTH_SECRET' | 'OS_SECRET_KEY'; + +/** + * Read {@link DURABILITY_CHECK_ENV}. `invalid` for a value that is neither a + * yes nor a no: a typo must not quietly switch a safety check off. + */ +export function readDurabilityCheckSignal(env: NodeJS.ProcessEnv): 'on' | 'off' | 'invalid' { + const raw = env[DURABILITY_CHECK_ENV]; + if (raw === undefined || raw.trim() === '') return 'off'; + const v = raw.trim().toLowerCase(); + if (v === 'true' || v === '1') return 'on'; + if (v === 'false' || v === '0') return 'off'; + return 'invalid'; +} + +/** + * Whether the database was chosen by nobody: the unified default file, or the + * legacy file read for compatibility. A URL from `--database`, the environment + * (`OS_DATABASE_URL` and its legacy names) or the project's declared default + * datasource is a choice, an explicit `file:` URL included. + */ +export function isDefaultedDatabase(source: ResolvedProjectDatabaseUrl['source']): boolean { + return source === 'unified-default' || source === 'legacy-file'; +} + +/** The variables the image requires that this boot did not get. */ +export function missingDurableVariables(facts: { + databaseSource: ResolvedProjectDatabaseUrl['source']; + authSecretSet: boolean; + secretKeySet: boolean; +}): DurableVariable[] { + const missing: DurableVariable[] = []; + if (isDefaultedDatabase(facts.databaseSource)) missing.push('OS_DATABASE_URL'); + if (!facts.authSecretSet) missing.push('OS_AUTH_SECRET'); + if (!facts.secretKeySet) missing.push('OS_SECRET_KEY'); + return missing; +} + +/** The file a `file:` SQLite URL names, or `undefined` for any other URL. */ +export function sqliteFileOf(url: string): string | undefined { + if (!url.startsWith('file:')) return undefined; + const rest = url.slice('file:'.length).replace(/^\/\/(?=\/)/, ''); + return rest.length > 0 ? rest : undefined; +} + +function joinNames(names: readonly string[]): string { + if (names.length <= 1) return names.join(''); + return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`; +} + +/** + * The image's refusal: which variables are missing, what is lost without + * them, how to set them, and how a deployment that already ran on values the + * image generated carries those values over first. + */ +export function formatDurabilityRefusal( + missing: readonly DurableVariable[], + where: { + /** Where this command keeps a generated auth secret. */ + authSecretFile: string; + /** Where the data key file is (`undefined` when an env key would answer). */ + dataKeyFile?: string; + /** The default SQLite file, when the database fell back to it. */ + defaultDatabaseFile?: string; + exists: (file: string) => boolean; + }, +): string { + const set = new Set(missing); + const found = (file: string): string => (where.exists(file) ? ' (present in this container)' : ''); + const lines: string[] = [ + `✗ This image does not start without its database and secrets set explicitly: ${joinNames(missing)} ${missing.length === 1 ? 'is' : 'are'} not set.`, + ' Without them the data would be a SQLite file inside the container, and the sign-in secret and the key that', + ' encrypts saved passwords and API keys would be generated on its disk. Replacing the container loses all of it.', + '', + ' Set them when you run the container:', + ]; + if (set.has('OS_DATABASE_URL')) { + lines.push( + ' OS_DATABASE_URL your database, e.g. postgres://user:pass@db-host:5432/app', + ' (or file:/path/on/a/mounted/volume/app.db to keep SQLite on a volume)', + ); + } + if (set.has('OS_AUTH_SECRET')) { + lines.push(' OS_AUTH_SECRET a long random value, the same on every restart, e.g. $(openssl rand -hex 32)'); + } + if (set.has('OS_SECRET_KEY')) { + lines.push(' OS_SECRET_KEY 32 random bytes, the same on every restart, e.g. $(openssl rand -hex 32)'); + } + lines.push( + '', + ' Already running on values this image generated? Carry them over before you set new ones, or every saved', + ' credential becomes unreadable and everyone is signed out. Read them from the old container', + ' (docker exec cat ) or from its volume:', + ); + if (set.has('OS_AUTH_SECRET')) { + lines.push(` OS_AUTH_SECRET the contents of ${where.authSecretFile}${found(where.authSecretFile)}`); + } + if (set.has('OS_SECRET_KEY') && where.dataKeyFile) { + lines.push(` OS_SECRET_KEY the contents of ${where.dataKeyFile}${found(where.dataKeyFile)}`); + } + if (set.has('OS_DATABASE_URL') && where.defaultDatabaseFile) { + lines.push( + ` OS_DATABASE_URL file:${where.defaultDatabaseFile}${found(where.defaultDatabaseFile)}, if your data is that file on a kept volume`, + ); + } + return lines.join('\n'); +} + +/** + * The deployer's block for a production boot outside the image, or + * `undefined` when this boot keeps nothing on its disk by default. + */ +export function formatDurabilityBootBlock(findings: DeploymentBootFindings): string | undefined { + const rows: string[] = []; + const vars: DurableVariable[] = []; + if (findings.databaseFile !== undefined) { + rows.push(` · Database: a SQLite file, ${findings.databaseFile} (OS_DATABASE_URL is not set)`); + vars.push('OS_DATABASE_URL'); + } + if (findings.authSecretFile !== undefined) { + rows.push( + findings.authSecretFile + ? ` · Sign-in secret: generated and kept in ${findings.authSecretFile} (OS_AUTH_SECRET is not set)` + : ' · Sign-in secret: generated for this process only (OS_AUTH_SECRET is not set)', + ); + vars.push('OS_AUTH_SECRET'); + } + if (findings.dataKeyFile !== undefined) { + rows.push( + ` · Data key, which encrypts saved passwords and API keys: kept in ${findings.dataKeyFile} (OS_SECRET_KEY is not set)`, + ); + vars.push('OS_SECRET_KEY'); + } + if (rows.length === 0) return undefined; + return [ + ' ⚠ Production mode, and this server keeps these on its own disk:', + ...rows, + ' If this disk is not kept (a container replaced, a machine rebuilt), they are lost: the data, every', + ' sign-in and every saved credential.', + ` To move them off this disk, set ${joinNames(vars)}, carrying the current values over from the files above.`, + ' Administrators see this notice in the Console too.', + ].join('\n'); +} + +/** + * Write {@link INTERNAL_DEPLOYMENT_FINDINGS_ENV} into the child env: the + * findings as JSON when there are any, otherwise no variable at all. + */ +export function writeDeploymentFindingsChannel( + env: NodeJS.ProcessEnv, + findings: DeploymentBootFindings | undefined, +): void { + if (findings && Object.keys(findings).length > 0) { + env[INTERNAL_DEPLOYMENT_FINDINGS_ENV] = JSON.stringify(findings); + } else { + delete env[INTERNAL_DEPLOYMENT_FINDINGS_ENV]; + } +} + +/** + * The data key file a boot of this command would read, through the ONE + * resolution the child is handed (`applyCryptoKeyFileChannel`), applied to a + * scratch env so nothing of the real child env is touched. + */ +async function persistedDataKeyFile(opts: { + env: NodeJS.ProcessEnv; + cwd: string; + hasProjectConfig: boolean; + homeFlag?: string; +}): Promise { + const { KEY_FILE_CHANNEL_ENV } = await import('@objectstack/service-settings'); + const scratch: NodeJS.ProcessEnv = {}; + await applyCryptoKeyFileChannel(scratch, opts); + return scratch[KEY_FILE_CHANNEL_ENV]; +} diff --git a/packages/cli/test/start-deployment-durability.integration.test.ts b/packages/cli/test/start-deployment-durability.integration.test.ts new file mode 100644 index 00000000000..5f899f6da6c --- /dev/null +++ b/packages/cli/test/start-deployment-durability.integration.test.ts @@ -0,0 +1,429 @@ +// 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 + +/** + * #30120, ruling A3 (record 6634240777) — the official image refuses to start + * on a disk it would lose; every other `os start` keeps booting, says so in + * its log, and tells the administrators in the Console. + * + * ## The defect, measured on origin/main 47c457b2 and the published 18.3.0 image + * + * `os start` with none of `OS_DATABASE_URL`, `OS_AUTH_SECRET`, `OS_SECRET_KEY` + * booted in production mode on a SQLite file under its home and on two secrets + * it generated there, and the image did the same inside the container layer: + * replacing the container lost the data, signed everyone out, and left every + * saved credential unreadable. The boot log said nothing about it. + * + * ## What this pins, at the shipped entry (`bin/run.js` over `dist/`) + * + * 1. With the image's switch (`OS_DEPLOYMENT_DURABILITY_CHECK_ENABLED=true`, + * the Dockerfile's `ENV`), a start missing any of the three exits 1 and + * names exactly the missing ones, before it writes a secret, a key or a + * database file. + * 2. An explicit `OS_DATABASE_URL=file:…` with both secrets passes, and the + * boot keeps nothing on its disk by default, so it prints no block. + * 3. Without the switch, a production `os start` on its defaults boots, and + * prints the block naming the three files. + * 4. On that boot, an administrator reads the three findings from + * `GET /api/settings/deployment/notice` with the files the block named, a + * member is refused `403`, and neither the public auth config nor the + * runtime config carries any of it. + * + * The hide's audit row and the route's other refusals are pinned beside the + * routes (`service-settings` `deployment-notice.routes.test.ts`). + * + * ## Spawn shape + * + * As `dev-start-shared-data-key.integration.test.ts`: `bin/run.js` with + * `NODE_ENV` unset (`os start` sets production itself), in an empty project + * directory (the empty kernel), each boot in its own process group, on a + * kernel-assigned port read back out of the child's own banner. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { spawn, spawnSync, type ChildProcess } from 'node:child_process'; +import { existsSync, mkdtempSync, readdirSync, rmSync } from 'node:fs'; +import { randomBytes } from 'node:crypto'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + childEnv, + KERNEL_ASSIGNED_PORT, + portContentionError, + probeThroughChild, + readKernelAssignedPort, + requireBuiltCli, + RUN_JS_RESOLVES_FROM_DIST, +} from './helpers/serve-process.js'; + +const HERE = resolve(fileURLToPath(import.meta.url), '..'); +/** `bin/run.js` — the SHIPPED entrypoint. */ +const CLI = resolve(HERE, '../bin/run.js'); + +const READY = /Press Ctrl\+C to stop/; +const BOOT_TIMEOUT_MS = 180_000; +const SWITCH = 'OS_DEPLOYMENT_DURABILITY_CHECK_ENABLED'; + +const scratch: string[] = []; +const groups: ChildProcess[] = []; + +function tmp(prefix: string): string { + const d = mkdtempSync(join(tmpdir(), prefix)); + scratch.push(d); + return d; +} + +/** + * The overrides every run starts from: no variable of any spelling the subject + * reads. Each spawn hands them to `childEnv` itself, with `NODE_ENV` unset as + * the last word, so the child's posture is readable at the call + * (`check:cli-test-child-env`). + */ +function baseEnv(home: string, over: Record = {}): Record { + return { + NO_COLOR: '1', + HOME: home, + OS_CLOUD_URL: 'off', + OS_DISABLE_CONSOLE: '1', + OS_LOG_LEVEL: 'warn', + OS_TRUSTED_ORIGINS: 'http://localhost:*', + [SWITCH]: undefined, + OS_SECRET_KEY: undefined, + OS_DEV_CRYPTO_KEY: undefined, + OBJECTSTACK_DEV_CRYPTO_KEY: undefined, + OS_INTERNAL_CRYPTO_KEY_FILE: undefined, + OS_INTERNAL_CRYPTO_KEY_FILE_CANDIDATE: undefined, + OS_INTERNAL_DEPLOYMENT_FINDINGS: undefined, + OS_HOME: undefined, + OBJECTSTACK_HOME: undefined, + OS_DATABASE_URL: undefined, + DATABASE_URL: undefined, + TURSO_DATABASE_URL: undefined, + OS_ARTIFACT_URL: undefined, + OS_ARTIFACT_PATH: undefined, + OS_AUTH_SECRET: undefined, + AUTH_SECRET: undefined, + BETTER_AUTH_SECRET: undefined, + OS_CLUSTER_DRIVER: undefined, + OS_MODE: undefined, + OS_PORT: undefined, + PORT: undefined, + ...over, + }; +} + +/** Everything under `dir`, recursively, as relative paths. */ +function filesUnder(dir: string): string[] { + if (!existsSync(dir)) return []; + return readdirSync(dir, { recursive: true, withFileTypes: true }) + .filter((e) => e.isFile()) + .map((e) => join((e as any).parentPath ?? (e as any).path, e.name).slice(dir.length + 1)); +} + +interface Live { + child: ChildProcess; + base: string; + output: () => string; +} + +function boot(what: string, cwd: string, over: Record): Promise { + return new Promise((resolveBoot, rejectBoot) => { + const child = spawn(process.execPath, [CLI, 'start', '--port', KERNEL_ASSIGNED_PORT], { + cwd, + env: childEnv({ ...over, NODE_ENV: undefined }), + stdio: ['ignore', 'pipe', 'pipe'], + detached: true, + }); + groups.push(child); + let out = ''; + let settled = false; + const settle = (verdict: Error | number) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (verdict instanceof Error) rejectBoot(verdict); + else resolveBoot({ child, base: `http://localhost:${verdict}`, output: () => out }); + }; + const timer = setTimeout( + () => settle(new Error(`${what} never printed ${READY}\n--- output ---\n${out.slice(-4000)}`)), + BOOT_TIMEOUT_MS, + ); + const onData = (d: unknown) => { + out += String(d); + if (READY.test(out)) settle(readKernelAssignedPort(out, what)); + }; + child.stdout?.on('data', onData); + child.stderr?.on('data', onData); + child.on('exit', (code) => + settle( + portContentionError(out, what, KERNEL_ASSIGNED_PORT) + ?? new Error(`${what} exited ${String(code)} before ${READY}\n--- output ---\n${out.slice(-4000)}`), + ), + ); + }); +} + +async function stopGroup(child: ChildProcess): Promise { + if (child.pid === undefined || child.exitCode !== null || child.signalCode !== null) return; + await new Promise((done) => { + const give = setTimeout(() => { + try { process.kill(-child.pid!, 'SIGKILL'); } catch { /* group already gone */ } + done(); + }, 15_000); + child.once('exit', () => { clearTimeout(give); done(); }); + try { process.kill(-child.pid!, 'SIGTERM'); } catch { clearTimeout(give); done(); } + }); +} + +/** One exchange, attributed to the child if the transport fails. ⛔ No assertion inside it. */ +function http(live: Live, method: string, path: string, token = '', body?: unknown) { + return probeThroughChild( + { + child: live.child, + transcript: () => `\n--- child output ---\n${live.output().slice(-4000)}`, + label: 'start-deployment-durability', + what: `${method} ${path}`, + }, + async () => { + const r = await fetch(`${live.base}${path}`, { + method, + headers: { + origin: live.base, + ...(body !== undefined ? { 'content-type': 'application/json' } : {}), + ...(token ? { authorization: `Bearer ${token}` } : {}), + }, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + }); + const text = await r.text(); + let parsed: any = text; + try { parsed = JSON.parse(text); } catch { /* keep the text */ } + return { status: r.status, body: parsed, text }; + }, + ); +} + +const PASSWORD = 'Durable-30120-pass!'; + +function tokenOf(live: Live, what: string, r: { status: number; body: any; text: string }): string { + const token = r.body?.token; + if (r.status !== 200 || typeof token !== 'string') { + throw new Error(`${what} answered ${r.status}: ${r.text.slice(0, 1000)}\n--- output ---\n${live.output().slice(-3000)}`); + } + return token; +} + +/** The first registrant: under the single-tenant posture, the platform administrator. */ +async function signUpOwner(live: Live, email: string): Promise { + const r = await http(live, 'POST', '/api/v1/auth/sign-up/email', '', { email, password: PASSWORD, name: 'Owner' }); + return tokenOf(live, `sign-up of ${email}`, r); +} + +/** + * A member, created by the administrator (self-registration closes after the + * owner), with no password change pending, so the held-session gate is not + * what refuses them. + */ +async function createMember(live: Live, adminToken: string, email: string): Promise { + const created = await http(live, 'POST', '/api/v1/auth/admin/create-user', adminToken, { + email, + name: 'Member', + password: PASSWORD, + mustChangePassword: false, + }); + if (created.status !== 200) { + throw new Error(`create-user answered ${created.status}: ${created.text.slice(0, 1000)}`); + } + const r = await http(live, 'POST', '/api/v1/auth/sign-in/email', '', { email, password: PASSWORD }); + return tokenOf(live, `sign-in of ${email}`, r); +} + +const secret = () => randomBytes(32).toString('hex'); + +beforeAll(() => { + requireBuiltCli(RUN_JS_RESOLVES_FROM_DIST); +}); + +afterAll(async () => { + for (const child of groups) await stopGroup(child); + for (const d of scratch) { + try { rmSync(d, { recursive: true, force: true }); } catch { /* ignore */ } + } +}, 60_000); + +// --------------------------------------------------------------------------- +// 1 — the image's switch refuses +// --------------------------------------------------------------------------- + +describe('#30120 — with the image switch on, a start missing its settings refuses', () => { + const refuse = (over: Record) => { + const home = tmp('os-30120-home-'); + const cwd = tmp('os-30120-cwd-'); + const run = spawnSync(process.execPath, [CLI, 'start', '--port', KERNEL_ASSIGNED_PORT], { + cwd, + env: childEnv({ ...baseEnv(home, { [SWITCH]: 'true', ...over }), NODE_ENV: undefined }), + encoding: 'utf8', + timeout: 120_000, + }); + return { run, home, cwd, out: `${run.stdout}\n${run.stderr}` }; + }; + + it('none set: exit 1, naming all three, before anything is written', () => { + const { run, home, cwd, out } = refuse({}); + expect(run.status, out).toBe(1); + expect(out).toContain('OS_DATABASE_URL, OS_AUTH_SECRET and OS_SECRET_KEY are not set'); + expect(out).not.toMatch(READY); + // No secret minted, no key file, no database: the refusal comes first. + expect(filesUnder(home).filter((f) => /auth-secret|dev-crypto-key|\.db$/.test(f)), out).toEqual([]); + expect(filesUnder(cwd).filter((f) => /auth-secret|dev-crypto-key|\.db$/.test(f)), out).toEqual([]); + }); + + it('only the database missing: names only OS_DATABASE_URL', () => { + const { run, out } = refuse({ OS_AUTH_SECRET: secret(), OS_SECRET_KEY: secret() }); + expect(run.status, out).toBe(1); + expect(out).toContain('OS_DATABASE_URL is not set'); + expect(out).not.toMatch(/OS_AUTH_SECRET[^\n]*not set|OS_SECRET_KEY[^\n]*not set/); + }); + + it('a switch value that is not a switch is refused too, rather than read as off', () => { + const { run, out } = refuse({ [SWITCH]: 'yes please' }); + expect(run.status, out).toBe(1); + expect(out).toContain('is not a switch value'); + }); +}); + +// --------------------------------------------------------------------------- +// 2 — an explicit file: passes +// --------------------------------------------------------------------------- + +interface Boot { + output?: string; + health?: number; + error?: Error; +} + +describe('#30120 — with the image switch on, an explicit file: database and both secrets boot', () => { + let reading: Boot = {}; + beforeAll(async () => { + const home = tmp('os-30120-home-'); + const cwd = tmp('os-30120-cwd-'); + const volume = tmp('os-30120-volume-'); + let live: Live | undefined; + try { + live = await boot( + 'os start (explicit settings)', + cwd, + baseEnv(home, { + [SWITCH]: 'true', + OS_DATABASE_URL: `file:${join(volume, 'app.db')}`, + OS_AUTH_SECRET: secret(), + OS_SECRET_KEY: secret(), + }), + ); + const health = await http(live, 'GET', '/api/v1/health'); + reading = { output: live.output(), health: health.status }; + } catch (error) { + reading = { error: error instanceof Error ? error : new Error(String(error)) }; + } finally { + if (live) await stopGroup(live.child); + } + }, BOOT_TIMEOUT_MS + 30_000); + + it('boots and answers health, printing no durability block', () => { + if (reading.error) throw reading.error; + expect(reading.health, reading.output).toBe(200); + expect(reading.output).not.toContain('this server keeps these on its own disk'); + }); +}); + +// --------------------------------------------------------------------------- +// 3 + 4 — no switch: boots, says so in the log and to the administrators +// --------------------------------------------------------------------------- + +interface DefaultsBoot { + output?: string; + home?: string; + adminNotice?: { status: number; body: any; text: string }; + memberNotice?: { status: number; body: any; text: string }; + authConfig?: { status: number; text: string }; + runtimeConfig?: { status: number; text: string }; + error?: Error; +} + +describe('#30120 — without the switch, a production start on its defaults boots and says so', () => { + let reading: DefaultsBoot = {}; + beforeAll(async () => { + const home = tmp('os-30120-home-'); + const cwd = tmp('os-30120-cwd-'); + let live: Live | undefined; + try { + live = await boot('os start (defaults)', cwd, baseEnv(home)); + // Single-tenant posture: the first registrant is the platform administrator. + const adminToken = await signUpOwner(live, 'owner-30120@example.com'); + const memberToken = await createMember(live, adminToken, 'member-30120@example.com'); + const adminNotice = await http(live, 'GET', '/api/settings/deployment/notice', adminToken); + const memberNotice = await http(live, 'GET', '/api/settings/deployment/notice', memberToken); + const authConfig = await http(live, 'GET', '/api/v1/auth/config', adminToken); + const runtimeConfig = await http(live, 'GET', '/api/v1/runtime/config', adminToken); + reading = { output: live.output(), home, adminNotice, memberNotice, authConfig, runtimeConfig }; + } catch (error) { + reading = { error: error instanceof Error ? error : new Error(String(error)) }; + } finally { + if (live) await stopGroup(live.child); + } + }, BOOT_TIMEOUT_MS + 60_000); + + const recorded = (): Required> => { + if (reading.error) throw reading.error; + return reading as Required>; + }; + + it('prints the block naming the database file, the sign-in secret file and the data key file', () => { + const { output, home } = recorded(); + const osHome = join(home, '.objectstack'); + expect(output).toContain('Production mode, and this server keeps these on its own disk'); + expect(output).toContain(`Database: a SQLite file, ${join(osHome, 'data', 'objectstack.db')}`); + expect(output).toContain(`Sign-in secret: generated and kept in ${join(osHome, 'auth-secret')}`); + expect(output).toContain(`kept in ${join(osHome, 'dev-crypto-key')} (OS_SECRET_KEY is not set)`); + }); + + it('an administrator reads the three findings, with the files the block named', () => { + const { adminNotice, home, output } = recorded(); + const osHome = join(home, '.objectstack'); + expect(adminNotice.status, `${adminNotice.text}\n${output.slice(-3000)}`).toBe(200); + const high = adminNotice.body?.data?.findings?.filter((f: any) => f.severity === 'high'); + expect(high).toEqual([ + { id: 'database_local_file', severity: 'high', variable: 'OS_DATABASE_URL', file: join(osHome, 'data', 'objectstack.db') }, + { id: 'auth_secret_generated', severity: 'high', variable: 'OS_AUTH_SECRET', file: join(osHome, 'auth-secret') }, + { id: 'data_key_file', severity: 'high', variable: 'OS_SECRET_KEY', file: join(osHome, 'dev-crypto-key') }, + ]); + // The default boot stores uploads locally and sends no mail: the two + // lower-severity lines. + expect(adminNotice.body?.data?.findings?.filter((f: any) => f.severity === 'low').map((f: any) => f.id)).toEqual([ + 'file_storage_local', + 'email_not_configured', + ]); + }); + + it('a member is refused the notice 403, and the answer names no server path', () => { + const { memberNotice } = recorded(); + expect(memberNotice.status, memberNotice.text).toBe(403); + expect(memberNotice.body?.error?.code).toBe('PERMISSION_DENIED'); + expect(memberNotice.text).not.toContain('.objectstack'); + }); + + it('neither the public auth config nor the runtime config carries the notice', () => { + const { authConfig, runtimeConfig, adminNotice, home } = recorded(); + // Non-vacuity: the administrator's own read carries every string the + // check below looks for, so a leak of it could not pass unseen. + for (const leak of ['database_local_file', 'auth_secret_generated', 'data_key_file', home]) { + expect(adminNotice.text).toContain(leak); + } + for (const served of [authConfig, runtimeConfig]) { + expect(served.status, served.text.slice(0, 500)).toBe(200); + for (const leak of ['database_local_file', 'auth_secret_generated', 'data_key_file', 'deployment_notice', home]) { + expect(served.text).not.toContain(leak); + } + } + }); +}); diff --git a/packages/services/service-settings/src/config-change-audit.ts b/packages/services/service-settings/src/config-change-audit.ts index e52c6c71562..86b14816c8c 100644 --- a/packages/services/service-settings/src/config-change-audit.ts +++ b/packages/services/service-settings/src/config-change-audit.ts @@ -191,7 +191,7 @@ type ConfigChangeLogger = SettingsDiagnosticsLogger & { * no (AGENTS.md → *Startup registry reads*). A settings write is an admin-rate * operation, so the per-call registry lookup costs nothing worth memoizing. */ -function ledgerIsMounted(engine: IDataEngine): boolean | undefined { +export function ledgerIsMounted(engine: IDataEngine): boolean | undefined { const getSchema = (engine as any)?.getSchema; if (typeof getSchema !== 'function') return undefined; try { @@ -204,6 +204,33 @@ function ledgerIsMounted(engine: IDataEngine): boolean | undefined { } } +/** + * The ONE insert this module makes onto the platform audit ledger, shared by + * the settings write sink below and the deployment notice's hide + * ({@link recordDeploymentConfigChange}), so both rows are written the same + * way, under the same system context. + */ +async function insertLedgerRow(eng: any, row: Record): Promise { + await eng.insert('sys_audit_log', row, { context: SYSTEM_CTX }); +} + +/** + * Record one deployment-level `config_change` row whose caller must KNOW it + * landed: the deployment notice's hide (ruling A3, record 6634240777), where + * the row is part of the act. Unlike the settings sink below it is not + * best-effort: `no-ledger` when this deployment mounts no `sys_audit_log` + * (asked first, exactly as the sink asks), and a failed insert throws to the + * caller instead of being reported once and swallowed. + */ +export async function recordDeploymentConfigChange( + engine: IDataEngine, + row: Record, +): Promise<'recorded' | 'no-ledger'> { + if (ledgerIsMounted(engine) === false) return 'no-ledger'; + await insertLedgerRow(engine, { ...row, action: CONFIG_CHANGE_ACTION }); + return 'recorded'; +} + function safeStringify(value: unknown): string { try { return JSON.stringify(value) ?? String(value); @@ -297,7 +324,7 @@ export function buildConfigChangeAuditSink( }), }; - await eng.insert('sys_audit_log', row, { context: SYSTEM_CTX }); + await insertLedgerRow(eng, row); } catch (err: any) { // Reported once per process, not once per settings write: a failure here // is systemic (the ledger is mounted but unreachable — DDL never ran, diff --git a/packages/services/service-settings/src/deployment-notice.routes.test.ts b/packages/services/service-settings/src/deployment-notice.routes.test.ts new file mode 100644 index 00000000000..d9d5faeda72 --- /dev/null +++ b/packages/services/service-settings/src/deployment-notice.routes.test.ts @@ -0,0 +1,351 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#30120, ruling A3 (record 6634240777)] The deployment notice: what a + * production deployment keeps on its own disk because nobody set it, served + * to ADMINISTRATORS, and hidden only with a row on the audit log. + * + * Driven through the real `registerSettingsRoutes` with the real door + * (`buildDeploymentNoticeDoor`) over a recording engine, so each claim is the + * route's answer, not a helper's: + * + * §1 the findings, as `readDeploymentNotice` derives them from the process, + * production mode only; + * §2 who reads it — a member is refused 403 and the door is never asked, an + * anonymous caller gets the family's 401, an administrator reads it; + * §3 the hide — an audit row on `sys_audit_log` (`config_change`, by the + * administrator, naming what was acknowledged), refused with nothing + * recorded when no ledger is mounted, and refused when the notice moved + * on since it was read. + * + * The state's absence from the public auth config is pinned end to end, on a + * real `os start`, in `packages/cli/test/start-deployment-durability.integration.test.ts`. + */ + +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { IHttpRequest, IHttpResponse, RouteHandler } from '@objectstack/spec/contracts'; +import { registerSettingsRoutes } from './settings-routes.js'; +import { + buildDeploymentNoticeDoor, + readDeploymentNotice, + type DeploymentNoticeSources, +} from './deployment-notice.js'; +import type { SettingsContext } from './settings-service.types.js'; + +const CHANNEL = 'OS_INTERNAL_DEPLOYMENT_FINDINGS'; +const BASE = '/api/settings'; + +let home: string; +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'os-notice-')); +}); +afterEach(() => { + rmSync(home, { recursive: true, force: true }); +}); + +/** A production process `os start` booted on its defaults. */ +function productionEnv(over: Record = {}): Record { + return { + NODE_ENV: 'production', + OS_HOME: home, + [CHANNEL]: JSON.stringify({ databaseFile: '/srv/app/.objectstack/data/app.db', authSecretFile: '/srv/app/.objectstack/auth-secret' }), + ...over, + }; +} + +function sources(env: Record, extra: Partial = {}): DeploymentNoticeSources { + return { + env: () => env, + emailDeliveryLive: () => true, + storageAdapter: async () => 's3', + ...extra, + }; +} + +// --------------------------------------------------------------------------- +// §1 — the findings +// --------------------------------------------------------------------------- + +describe('§1 the findings a production process states', () => { + it('names the database file, the generated sign-in secret and the data key file, in that order', async () => { + const state = await readDeploymentNotice(sources(productionEnv())); + expect(state.production).toBe(true); + expect(state.findings).toEqual([ + { id: 'database_local_file', severity: 'high', variable: 'OS_DATABASE_URL', file: '/srv/app/.objectstack/data/app.db' }, + { id: 'auth_secret_generated', severity: 'high', variable: 'OS_AUTH_SECRET', file: '/srv/app/.objectstack/auth-secret' }, + { id: 'data_key_file', severity: 'high', variable: 'OS_SECRET_KEY', file: join(home, 'dev-crypto-key') }, + ]); + expect(state.fingerprint).toBe('database_local_file+auth_secret_generated+data_key_file'); + }); + + it('the data key file named is the one the CLI handed the provider, when it named one', async () => { + const state = await readDeploymentNotice( + sources(productionEnv({ OS_INTERNAL_CRYPTO_KEY_FILE: '/srv/app/.objectstack/dev-crypto-key' })), + ); + expect(state.findings.find((f) => f.id === 'data_key_file')?.file).toBe('/srv/app/.objectstack/dev-crypto-key'); + }); + + it('a set OS_SECRET_KEY, and a boot that did not come through os start, state none of the three', async () => { + const state = await readDeploymentNotice( + sources(productionEnv({ [CHANNEL]: undefined, OS_SECRET_KEY: 'a'.repeat(64) })), + ); + expect(state.findings).toEqual([]); + expect(state.fingerprint).toBe(''); + }); + + it('a channel value that does not parse states nothing from it, rather than a guess', async () => { + const state = await readDeploymentNotice(sources(productionEnv({ [CHANNEL]: '{not json', OS_SECRET_KEY: 'a'.repeat(64) }))); + expect(state.findings).toEqual([]); + }); + + it('local file storage and an email service that does not deliver are the two lower-severity lines', async () => { + const env = productionEnv({ [CHANNEL]: undefined, OS_SECRET_KEY: 'a'.repeat(64) }); + const local = await readDeploymentNotice(sources(env, { storageAdapter: async () => 'local', emailDeliveryLive: () => false })); + expect(local.findings).toEqual([ + { id: 'file_storage_local', severity: 'low' }, + { id: 'email_not_configured', severity: 'low' }, + ]); + // No email service composed at all is not a deliverable one either. + const none = await readDeploymentNotice(sources(env, { emailDeliveryLive: () => undefined })); + expect(none.findings.map((f) => f.id)).toEqual(['email_not_configured']); + // CONTROL: S3 and a live transport say nothing. + expect((await readDeploymentNotice(sources(env))).findings).toEqual([]); + }); + + it('outside production mode the notice has nothing to say, whatever the process keeps', async () => { + for (const NODE_ENV of ['development', 'test', undefined]) { + const state = await readDeploymentNotice( + sources(productionEnv({ NODE_ENV }), { storageAdapter: async () => 'local', emailDeliveryLive: () => false }), + ); + expect(state).toEqual({ production: false, findings: [], fingerprint: '' }); + } + }); +}); + +// --------------------------------------------------------------------------- +// The routes, over the real door and a recording engine +// --------------------------------------------------------------------------- + +interface Inserted { object: string; row: Record; options: any } + +function recordingEngine(opts: { ledger: boolean }) { + const inserted: Inserted[] = []; + const engine = { + getSchema: (name: string) => (name === 'sys_audit_log' && opts.ledger ? { name } : undefined), + insert: async (object: string, row: Record, options: any) => { + inserted.push({ object, row, options }); + return { id: `aud_${inserted.length}`, ...row }; + }, + }; + return { engine, inserted }; +} + +function mount(opts: { + context: SettingsContext; + env?: Record; + engine?: unknown; +}) { + const routes = new Map(); + const reads: string[] = []; + const http = { + get: (p: string, h: RouteHandler) => { routes.set(`GET ${p}`, h); }, + post: (p: string, h: RouteHandler) => { routes.set(`POST ${p}`, h); }, + put: (p: string, h: RouteHandler) => { routes.set(`PUT ${p}`, h); }, + delete: () => {}, + patch: () => {}, + use: () => {}, + listen: async () => {}, + close: async () => {}, + }; + const env = opts.env ?? productionEnv(); + const door = buildDeploymentNoticeDoor({ + sources: sources(env, { + env: () => { + reads.push('env'); + return env; + }, + }), + engine: () => (opts.engine ?? null) as any, + }); + registerSettingsRoutes(http as any, {} as any, { + basePath: BASE, + contextFromRequest: () => opts.context, + deploymentNotice: door, + }); + return { routes, reads }; +} + +async function call(routes: Map, key: string, body?: unknown) { + const handler = routes.get(key); + if (!handler) throw new Error(`fixture: no route ${key}`); + const answer: { status: number; body: any } = { status: 200, body: undefined }; + const res: any = { + json(data: unknown) { answer.body = data; return res; }, + send() { return res; }, + status(code: number) { answer.status = code; return res; }, + header() { return res; }, + }; + const [method, path] = key.split(' '); + await handler({ params: {}, query: {}, body, headers: {}, method, path } as unknown as IHttpRequest, res as IHttpResponse); + return answer; +} + +const ADMIN: SettingsContext = { enforced: true, userId: 'usr_admin', permissions: ['setup.access', 'manage_platform_settings'] }; +/** A signed-in member, `setup.access` included: Setup is not the capability. */ +const MEMBER: SettingsContext = { enforced: true, userId: 'usr_member', permissions: ['setup.access', 'setup.write'] }; +const READ = `GET ${BASE}/deployment/notice`; +const HIDE = `POST ${BASE}/deployment/notice/hide`; +const FINGERPRINT = 'database_local_file+auth_secret_generated+data_key_file'; + +// --------------------------------------------------------------------------- +// §2 — who reads it +// --------------------------------------------------------------------------- + +describe('§2 the notice is for administrators', () => { + it('an administrator reads the findings', async () => { + const { routes } = mount({ context: ADMIN }); + const answer = await call(routes, READ); + expect(answer.status).toBe(200); + expect(answer.body?.data?.fingerprint).toBe(FINGERPRINT); + expect(answer.body?.data?.findings.map((f: any) => f.id)).toEqual([ + 'database_local_file', + 'auth_secret_generated', + 'data_key_file', + ]); + }); + + it.each([ + ['read', READ, undefined], + ['hide', HIDE, { fingerprint: FINGERPRINT }], + ])('a member is refused the %s 403 PERMISSION_DENIED, and the door never reads the process', async (_n, key, body) => { + const { engine, inserted } = recordingEngine({ ledger: true }); + const { routes, reads } = mount({ context: MEMBER, engine }); + const answer = await call(routes, key, body); + expect(answer.status).toBe(403); + expect(answer.body?.error?.code).toBe('PERMISSION_DENIED'); + expect(answer.body?.data).toBeUndefined(); + expect(JSON.stringify(answer.body)).not.toContain('/srv/app'); + expect(reads).toEqual([]); + expect(inserted).toEqual([]); + }); + + it.each([ + ['read', READ, undefined], + ['hide', HIDE, { fingerprint: FINGERPRINT }], + ])('an anonymous caller is refused the %s with the family 401', async (_n, key, body) => { + const { routes, reads } = mount({ context: { enforced: true } }); + const answer = await call(routes, key, body); + expect(answer.status).toBe(401); + expect(answer.body?.error?.code).toBe('UNAUTHENTICATED'); + expect(reads).toEqual([]); + }); + + it('a host that wires no door answers 503, never a guess', async () => { + const routes = new Map(); + const http = { + get: (p: string, h: RouteHandler) => { routes.set(`GET ${p}`, h); }, + post: (p: string, h: RouteHandler) => { routes.set(`POST ${p}`, h); }, + put: () => {}, delete: () => {}, patch: () => {}, use: () => {}, listen: async () => {}, close: async () => {}, + }; + registerSettingsRoutes(http as any, {} as any, { basePath: BASE, contextFromRequest: () => ADMIN }); + const answer = await call(routes, READ); + expect(answer.status).toBe(503); + expect(answer.body?.error?.code).toBe('SERVICE_UNAVAILABLE'); + }); +}); + +// --------------------------------------------------------------------------- +// §3 — the hide +// --------------------------------------------------------------------------- + +describe('§3 a hide is a row on the audit log', () => { + it('writes one config_change row by the administrator, naming what was acknowledged', async () => { + const { engine, inserted } = recordingEngine({ ledger: true }); + const { routes } = mount({ context: ADMIN, engine }); + const answer = await call(routes, HIDE, { fingerprint: FINGERPRINT }); + expect(answer.status).toBe(200); + expect(answer.body?.data).toEqual({ hidden: true, fingerprint: FINGERPRINT }); + expect(inserted).toHaveLength(1); + const [{ object, row, options }] = inserted; + expect(object).toBe('sys_audit_log'); + expect(row).toMatchObject({ + action: 'config_change', + user_id: 'usr_admin', + actor: 'usr_admin', + object_name: null, + record_id: null, + tenant_id: null, + }); + expect(JSON.parse(row.metadata)).toEqual({ + event: 'deployment_notice.hide', + fingerprint: FINGERPRINT, + findings: ['database_local_file', 'auth_secret_generated', 'data_key_file'], + }); + expect(JSON.parse(row.new_value)).toMatchObject({ hidden: true, fingerprint: FINGERPRINT }); + expect(options?.context?.isSystem).toBe(true); + }); + + it('is refused, with nothing recorded, where no audit log is mounted', async () => { + const { engine, inserted } = recordingEngine({ ledger: false }); + const { routes } = mount({ context: ADMIN, engine }); + const answer = await call(routes, HIDE, { fingerprint: FINGERPRINT }); + expect(answer.status).toBe(409); + expect(answer.body?.error?.code).toBe('RESOURCE_CONFLICT'); + expect(answer.body?.error?.details).toEqual({ reason: 'no_audit_log' }); + expect(inserted).toEqual([]); + }); + + it('is refused where the composition has no data engine at all', async () => { + const { routes } = mount({ context: ADMIN }); + const answer = await call(routes, HIDE, { fingerprint: FINGERPRINT }); + expect(answer.status).toBe(409); + expect(answer.body?.error?.details).toEqual({ reason: 'no_audit_log' }); + }); + + it('is refused when the notice moved on since it was read, answering what it says now', async () => { + const { engine, inserted } = recordingEngine({ ledger: true }); + const { routes } = mount({ context: ADMIN, engine }); + const answer = await call(routes, HIDE, { fingerprint: 'database_local_file' }); + expect(answer.status).toBe(409); + expect(answer.body?.error?.details?.fingerprint).toBe(FINGERPRINT); + expect(inserted).toEqual([]); + }); + + it('is refused when there is nothing to hide', async () => { + const { engine, inserted } = recordingEngine({ ledger: true }); + const { routes } = mount({ + context: ADMIN, + engine, + env: productionEnv({ [CHANNEL]: undefined, OS_SECRET_KEY: 'a'.repeat(64) }), + }); + const answer = await call(routes, HIDE, { fingerprint: FINGERPRINT }); + expect(answer.status).toBe(409); + expect(answer.body?.error?.details?.findings).toEqual([]); + expect(inserted).toEqual([]); + }); + + it('names the notice it hides: a hide without a fingerprint is a 400', async () => { + const { engine, inserted } = recordingEngine({ ledger: true }); + const { routes } = mount({ context: ADMIN, engine }); + for (const body of [undefined, {}, { fingerprint: '' }, { fingerprint: 42 }]) { + const answer = await call(routes, HIDE, body); + expect(answer.status).toBe(400); + expect(answer.body?.error?.code).toBe('VALIDATION_ERROR'); + } + expect(inserted).toEqual([]); + }); + + it('a failed audit write is a fault, not a hide', async () => { + const { engine } = recordingEngine({ ledger: true }); + engine.insert = async () => { + throw new Error('disk full'); + }; + const { routes } = mount({ context: ADMIN, engine }); + const answer = await call(routes, HIDE, { fingerprint: FINGERPRINT }); + expect(answer.status).toBe(500); + expect(answer.body?.data).toBeUndefined(); + }); +}); diff --git a/packages/services/service-settings/src/deployment-notice.ts b/packages/services/service-settings/src/deployment-notice.ts new file mode 100644 index 00000000000..6f810b3151d --- /dev/null +++ b/packages/services/service-settings/src/deployment-notice.ts @@ -0,0 +1,232 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The deployment notice: what a production deployment keeps on its own disk + * because nobody set it, stated to ADMINISTRATORS (ruling A3, record + * 6634240777). + * + * ## Why administrators, and why here + * + * `os start` in production mode boots on a SQLite file under its home and on + * secrets it generates there, and the boot log says so — to whoever reads the + * terminal. The administrator who owns the data signs in to the Console, and + * may not be the person who deployed. So the same facts are served to them, + * and the Console draws them as a standing notice on its home page and at the + * top of Setup's System Overview, with the variables to hand to the deployer. + * + * The settings service serves it because it already owns every part the read + * needs: the deployment-configuration door (verified caller, anonymous floor, + * held-session gate, the `manage_platform_settings` capability every + * infrastructure namespace is gated on), the data key whose file it reads, the + * storage adapter setting, and the `config_change` row on the platform audit + * ledger. ⛔ Never on the public auth config: the state names server paths and + * is for administrators alone. + * + * ## The findings + * + * - `database_local_file` and `auth_secret_generated` — decided by `os start`, + * which resolved the database and generated the secret, and handed to this + * process on the CLI's internal channel {@link BOOT_FINDINGS_CHANNEL_ENV} + * (`packages/cli/src/commands/start.ts`). A boot that did not come through + * `os start` sets no channel and states neither. + * - `data_key_file` — this package's own fact: no `OS_SECRET_KEY` (nor a usable + * dev key) answers, so the local provider reads its key from a file on this + * server ({@link resolvePersistedKeyFile}; the file is the CLI's + * `OS_INTERNAL_CRYPTO_KEY_FILE` channel when set). Read off the local + * provider's own tiers: a host composing another `ICryptoProvider` (none + * ships) is not told apart. + * - `file_storage_local` (lower severity) — the live `storage.adapter` setting + * is `local`, on a composition that mounts the storage service. + * - `email_not_configured` (lower severity) — `IEmailService.isConfigured()` + * does not answer `true`: the deliverability signal the auth plugin's public + * `features.emailDelivery` reads too. + * + * Production mode only (`NODE_ENV=production`, the signal `os start` sets and + * the data key's own posture reads): outside it the notice has nothing to say. + */ + +import type { IDataEngine } from '@objectstack/spec/contracts'; +import { recordDeploymentConfigChange } from './config-change-audit.js'; +import { KEY_FILE_CHANNEL_ENV, resolvePersistedKeyFile } from './local-crypto-provider.js'; +import type { SettingsContext } from './settings-service.types.js'; + +/** + * The CLI's internal parent-to-child channel the two boot facts arrive on. + * Written by `os start` (`INTERNAL_DEPLOYMENT_FINDINGS_ENV` in + * `packages/cli/src/commands/start.ts`); spelled here rather than imported, + * because this package publishes no constant for a channel the CLI owns. The + * CLI's end-to-end pin reads the notice a real `os start` serves, so a rename + * on one side reds there. + */ +const BOOT_FINDINGS_CHANNEL_ENV = 'OS_INTERNAL_DEPLOYMENT_FINDINGS'; + +/** The capability that reads the notice and hides it — the infrastructure namespaces' gate. */ +export const DEPLOYMENT_NOTICE_CAPABILITY = 'manage_platform_settings'; + +/** One thing a production deployment keeps on its own disk, or cannot do, because nobody set it. */ +export type DeploymentFindingId = + | 'database_local_file' + | 'auth_secret_generated' + | 'data_key_file' + | 'file_storage_local' + | 'email_not_configured'; + +export interface DeploymentFinding { + id: DeploymentFindingId; + /** `high`: lost with the disk. `low`: works, but not the way a production deployment should. */ + severity: 'high' | 'low'; + /** The variable that removes the finding, when one does. */ + variable?: 'OS_DATABASE_URL' | 'OS_AUTH_SECRET' | 'OS_SECRET_KEY'; + /** The file on this server the finding is about, when there is one. */ + file?: string; +} + +/** `GET /api/settings/deployment/notice`'s answer. */ +export interface DeploymentNoticeState { + /** Whether the deployment runs in production mode; outside it `findings` is always empty. */ + production: boolean; + /** In display order: the three `high` findings first, then the two `low` ones. */ + findings: DeploymentFinding[]; + /** + * The findings' ids joined with `+`, `''` when there are none. A hide names + * the fingerprint it acknowledges, so a finding that appears later is not + * hidden by an acknowledgement of the earlier set. + */ + fingerprint: string; +} + +/** Where the notice reads its live facts. Each is asked on every read; none is cached. */ +export interface DeploymentNoticeSources { + /** The process environment. */ + env: () => Record; + /** `IEmailService.isConfigured()`, or `undefined` when no email service is composed. */ + emailDeliveryLive: () => boolean | undefined; + /** The live `storage.adapter` value, or `undefined` when no storage service is composed. */ + storageAdapter: () => Promise; +} + +interface BootFindings { + databaseFile?: string; + authSecretFile?: string | null; +} + +/** Read the CLI's channel. A value that does not parse states nothing, rather than a guess. */ +function readBootFindings(env: Record): BootFindings { + const raw = env[BOOT_FINDINGS_CHANNEL_ENV]; + if (!raw) return {}; + try { + const parsed: unknown = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object') return {}; + const p = parsed as Record; + return { + ...(typeof p.databaseFile === 'string' ? { databaseFile: p.databaseFile } : {}), + ...(typeof p.authSecretFile === 'string' || p.authSecretFile === null + ? { authSecretFile: p.authSecretFile as string | null } + : {}), + }; + } catch { + return {}; + } +} + +/** The notice as it stands now. */ +export async function readDeploymentNotice(sources: DeploymentNoticeSources): Promise { + const env = sources.env(); + const production = env.NODE_ENV === 'production'; + if (!production) return { production, findings: [], fingerprint: '' }; + + const findings: DeploymentFinding[] = []; + const boot = readBootFindings(env); + if (boot.databaseFile !== undefined) { + findings.push({ id: 'database_local_file', severity: 'high', variable: 'OS_DATABASE_URL', file: boot.databaseFile }); + } + if (boot.authSecretFile !== undefined) { + findings.push({ + id: 'auth_secret_generated', + severity: 'high', + variable: 'OS_AUTH_SECRET', + ...(boot.authSecretFile ? { file: boot.authSecretFile } : {}), + }); + } + // The file the provider's file tier reads: the CLI's channel when it named + // one (`os start` and `os dev` always do), else the canonical file. + const keyFile = resolvePersistedKeyFile({ env }); + if (keyFile) { + const file = env[KEY_FILE_CHANNEL_ENV]?.trim() || keyFile.path; + findings.push({ id: 'data_key_file', severity: 'high', variable: 'OS_SECRET_KEY', file }); + } + if ((await sources.storageAdapter()) === 'local') { + findings.push({ id: 'file_storage_local', severity: 'low' }); + } + if (sources.emailDeliveryLive() !== true) { + findings.push({ id: 'email_not_configured', severity: 'low' }); + } + return { production, findings, fingerprint: findings.map((f) => f.id).join('+') }; +} + +/** What a hide asked of the door came to. */ +export type DeploymentNoticeHideOutcome = + /** The row is on the audit ledger; `state` is what was acknowledged. */ + | { outcome: 'recorded'; state: DeploymentNoticeState } + /** There is nothing to hide: the notice states no finding. */ + | { outcome: 'nothing-to-hide'; state: DeploymentNoticeState } + /** The notice no longer has the fingerprint the caller read. */ + | { outcome: 'changed'; state: DeploymentNoticeState } + /** No audit ledger is mounted, so the hide cannot be recorded and is not made. */ + | { outcome: 'no-ledger' }; + +/** + * What the deployment notice's two routes call (`registerSettingsRoutes`), + * once per request, with the caller's verified context. The routes refuse a + * caller without {@link DEPLOYMENT_NOTICE_CAPABILITY} before the door is asked. + */ +export interface DeploymentNoticeDoor { + /** The notice as it stands now. */ + read(context: SettingsContext): Promise; + /** + * Hide the notice whose fingerprint the caller read: record it on the + * platform audit ledger, by `context.userId`. A failed write throws. + */ + hide(input: { context: SettingsContext; fingerprint: string }): Promise; +} + +/** + * The `sys_audit_log` row a hide writes: a deployment-level `config_change` + * (no organization, no record), by the administrator, naming the findings + * they acknowledged. The Console keeps the hide in the administrator's own + * browser, keyed by the fingerprint; this row is the record that they did. + */ +export function deploymentNoticeHideRow(input: { userId: string; state: DeploymentNoticeState }): Record { + const findings = input.state.findings.map((f) => f.id); + return { + user_id: input.userId, + actor: input.userId, + object_name: null, + record_id: null, + tenant_id: null, + new_value: JSON.stringify({ hidden: true, fingerprint: input.state.fingerprint, findings }), + metadata: JSON.stringify({ event: 'deployment_notice.hide', fingerprint: input.state.fingerprint, findings }), + }; +} + +/** The door `SettingsServicePlugin` hands the routes. */ +export function buildDeploymentNoticeDoor(deps: { + sources: DeploymentNoticeSources; + /** The data engine, read at the hide; `null` when the composition has none. */ + engine: () => IDataEngine | null; +}): DeploymentNoticeDoor { + return { + read: () => readDeploymentNotice(deps.sources), + async hide({ context, fingerprint }) { + const state = await readDeploymentNotice(deps.sources); + if (state.findings.length === 0) return { outcome: 'nothing-to-hide', state }; + if (state.fingerprint !== fingerprint) return { outcome: 'changed', state }; + const engine = deps.engine(); + // The routes admit no caller without a principal, so `userId` is set. + const recorded = engine + ? await recordDeploymentConfigChange(engine, deploymentNoticeHideRow({ userId: context.userId!, state })) + : 'no-ledger'; + return recorded === 'recorded' ? { outcome: 'recorded', state } : { outcome: 'no-ledger' }; + }, + }; +} diff --git a/packages/services/service-settings/src/settings-route-ledger.ts b/packages/services/service-settings/src/settings-route-ledger.ts index f2af1ae2ad6..75074fc310e 100644 --- a/packages/services/service-settings/src/settings-route-ledger.ts +++ b/packages/services/service-settings/src/settings-route-ledger.ts @@ -80,4 +80,8 @@ export const SETTINGS_ROUTE_LEDGER: readonly SettingsRouteLedgerEntry[] = [ note: 'batch upsert of one namespace, validated against its manifest; refuses locked keys' }, { route: 'POST /api/settings/:namespace/:actionId', family: 'settings', disposition: 'server-only', note: 'invoke an action the namespace manifest declares (test-connection probes and the like), not a data write' }, + { route: 'GET /api/settings/deployment/notice', family: 'settings', disposition: 'server-only', + note: 'what a production deployment keeps on its own disk by default, for administrators (manage_platform_settings) — the Console home and System Overview notice' }, + { route: 'POST /api/settings/deployment/notice/hide', family: 'settings', disposition: 'server-only', + note: 'an administrator acknowledges the current notice; writes a config_change row to sys_audit_log, refused where no audit log is mounted' }, ]; diff --git a/packages/services/service-settings/src/settings-routes.anonymous-floor.pin.test.ts b/packages/services/service-settings/src/settings-routes.anonymous-floor.pin.test.ts index 9494f7248d3..80a5e31cb41 100644 --- a/packages/services/service-settings/src/settings-routes.anonymous-floor.pin.test.ts +++ b/packages/services/service-settings/src/settings-routes.anonymous-floor.pin.test.ts @@ -47,9 +47,23 @@ import type { IHttpRequest, IHttpResponse, RouteHandler } from '@objectstack/spe import { registerSettingsRoutes, type SettingsRoutesOptions } from './settings-routes.js'; import { SETTINGS_ROUTE_LEDGER } from './settings-route-ledger.js'; import type { SettingsContext } from './settings-service.types.js'; +import type { DeploymentNoticeDoor, DeploymentNoticeState } from './deployment-notice.js'; const NS = 'branding'; +/** + * [#30120] The deployment notice's two routes call a door, not the service. + * Each fixture below carries its door under this key, so `capture` mounts + * the routes with it and a route reaching the door is recorded like a route + * reaching the service. + */ +const NOTICE_DOOR = Symbol('deploymentNoticeDoor'); +const NOTICE: DeploymentNoticeState = { + production: true, + findings: [{ id: 'database_local_file', severity: 'high', variable: 'OS_DATABASE_URL', file: '/srv/app/.objectstack/data/app.db' }], + fingerprint: 'database_local_file', +}; + interface Registered { verb: string; path: string; @@ -73,7 +87,8 @@ function capture(service: unknown, opts: SettingsRoutesOptions = {}) { listen: async () => {}, close: async () => {}, }; - registerSettingsRoutes(http as any, service as any, opts); + const deploymentNotice = (service as Record)[NOTICE_DOOR]; + registerSettingsRoutes(http as any, service as any, { deploymentNotice, ...opts }); return { routes, middleware }; } @@ -84,11 +99,22 @@ function capture(service: unknown, opts: SettingsRoutesOptions = {}) { */ function untouchableService() { const calls: string[] = []; + const door: DeploymentNoticeDoor = { + read: async () => { + calls.push('deploymentNotice.read'); + throw new Error('the deployment notice was read by an anonymous request'); + }, + hide: async () => { + calls.push('deploymentNotice.hide'); + throw new Error('the deployment notice was hidden by an anonymous request'); + }, + }; const service = new Proxy( {}, { get(_target, prop) { if (prop === 'then') return undefined; + if (prop === NOTICE_DOOR) return door; return (..._args: unknown[]) => { calls.push(String(prop)); throw new Error(`service.${String(prop)} was reached by an anonymous request`); @@ -117,6 +143,13 @@ function permissiveService() { seen.push({ method: 'runAction', ctx }); return { ok: true }; }, + [NOTICE_DOOR]: { + read: async (ctx: SettingsContext) => { seen.push({ method: 'deploymentNotice.read', ctx }); return NOTICE; }, + hide: async ({ context }: { context: SettingsContext }) => { + seen.push({ method: 'deploymentNotice.hide', ctx: context }); + return { outcome: 'recorded' as const, state: NOTICE }; + }, + } satisfies DeploymentNoticeDoor, }; return { service, seen }; } @@ -142,7 +175,8 @@ async function drive(route: Registered, headers: Record = {}) { { params: paramsFor(route.path), query: {}, - body: route.verb === 'GET' ? undefined : { workspace_name: 'anonymous-write' }, + // `fingerprint` is the hide route's one field; the other routes ignore it. + body: route.verb === 'GET' ? undefined : { workspace_name: 'anonymous-write', fingerprint: NOTICE.fingerprint }, headers, method: route.verb, path: route.path, @@ -179,10 +213,11 @@ const ANONYMOUS_CONTEXTS: Array<{ name: string; opts: SettingsRoutesOptions }> = ]; describe('the settings family census — read off the registration', () => { - it('registers exactly four routes, the ledgered ones, and no middleware past them', () => { + it('registers exactly six routes, the ledgered ones, and no middleware past them', () => { const { routes, middleware } = capture(untouchableService().service); - // ⭐ A count, on purpose: a fifth route has to come through this file. - expect(routes).toHaveLength(4); + // ⭐ A count, on purpose: a seventh route has to come through this file. + // [#30120] Five and six are the deployment notice's read and hide. + expect(routes).toHaveLength(6); expect(routes.map((r) => `${r.verb} ${r.path}`).sort()).toEqual( SETTINGS_ROUTE_LEDGER.map((row) => row.route).sort(), ); @@ -219,7 +254,7 @@ describe('ADR-0056 D2 — an anonymous caller is refused 401 on every route of t expect(isAuthGateAllowlisted(basePath, 'GET')).toBe(true); const { service, calls } = untouchableService(); const { routes } = capture(service, { basePath }); - expect(routes).toHaveLength(4); + expect(routes).toHaveLength(6); for (const route of routes) expectAnonymousRefusal(await drive(route)); expect(calls).toEqual([]); }); @@ -230,7 +265,12 @@ describe('CONTROL — a signed-in caller is served as before', () => { it.each(census)('%s · signed in → 200, and the service sees that caller', async (key) => { const { service, seen } = permissiveService(); - const signedIn: SettingsContext = { enforced: true, userId: 'usr_admin', permissions: ['setup.access', 'setup.write'] }; + // `manage_platform_settings` is the deployment notice's capability. + const signedIn: SettingsContext = { + enforced: true, + userId: 'usr_admin', + permissions: ['setup.access', 'setup.write', 'manage_platform_settings'], + }; const route = capture(service, { contextFromRequest: () => signedIn }).routes.find( (r) => `${r.verb} ${r.path}` === key, )!; diff --git a/packages/services/service-settings/src/settings-routes.auth-gate.pin.test.ts b/packages/services/service-settings/src/settings-routes.auth-gate.pin.test.ts index d6528844391..7a79c4f084a 100644 --- a/packages/services/service-settings/src/settings-routes.auth-gate.pin.test.ts +++ b/packages/services/service-settings/src/settings-routes.auth-gate.pin.test.ts @@ -38,8 +38,9 @@ * ⛔ Nothing here asserts `toThrow()`: the unrepaired family never threw — it * ANSWERED, with the ordinary signed-in answer. The claim is `status` + `code`. * - * Routes are named by the service member their own work calls, read off a - * served request below, never by a path typed here. + * Routes are named by the service member their own work calls (for the + * deployment notice's two, the door member), read off a served request below, + * never by a path typed here. */ import { describe, expect, it, vi } from 'vitest'; @@ -47,6 +48,7 @@ import { isAuthGateAllowlisted, normalizeAuthGate, ObjectKernel, type AuthGate } import type { IHttpRequest, IHttpResponse, IHttpServer, RouteHandler } from '@objectstack/spec/contracts'; import { SETTINGS_ROUTE_LEDGER } from './settings-route-ledger.js'; import type { SettingsContext } from './settings-service.types.js'; +import type { DeploymentNoticeDoor, DeploymentNoticeState } from './deployment-notice.js'; // The producer half (part 4) reads the context the plugin's resolver builds, // captured through a PASS-THROUGH of the real registration, as @@ -74,6 +76,19 @@ type SettingsRoutesOptions = NonNullable {}, close: async () => {}, }; - registerSettingsRoutes(http as any, service as any, opts); + const deploymentNotice = (service as Record)[NOTICE_DOOR]; + registerSettingsRoutes(http as any, service as any, { deploymentNotice, ...opts }); return { routes, middleware }; } @@ -124,11 +140,22 @@ function capture(service: unknown, opts: SettingsRoutesOptions = {}) { */ function untouchableService() { const calls: string[] = []; + const door: DeploymentNoticeDoor = { + read: async () => { + calls.push('deploymentNotice.read'); + throw new Error('the deployment notice was read by a held session'); + }, + hide: async () => { + calls.push('deploymentNotice.hide'); + throw new Error('the deployment notice was hidden by a held session'); + }, + }; const service = new Proxy( {}, { get(_target, prop) { if (prop === 'then') return undefined; + if (prop === NOTICE_DOOR) return door; return (..._args: unknown[]) => { calls.push(String(prop)); throw new Error(`service.${String(prop)} was reached by a held session`); @@ -164,6 +191,13 @@ function permissiveService() { seen.push({ member: 'runAction', ctx }); return { ok: true }; }, + [NOTICE_DOOR]: { + read: async (ctx: SettingsContext) => { seen.push({ member: 'deploymentNotice.read', ctx }); return NOTICE; }, + hide: async ({ context }: { context: SettingsContext }) => { + seen.push({ member: 'deploymentNotice.hide', ctx: context }); + return { outcome: 'recorded' as const, state: NOTICE }; + }, + } satisfies DeploymentNoticeDoor, }; return { service, seen }; } @@ -191,7 +225,8 @@ async function drive(route: Pick, heade { params: paramsFor(route.path), query: {}, - body: route.verb === 'GET' ? undefined : { workspace_name: 'held-write' }, + // `fingerprint` is the hide route's one field; the other routes ignore it. + body: route.verb === 'GET' ? undefined : { workspace_name: 'held-write', fingerprint: NOTICE.fingerprint }, headers, method: route.verb, path: route.path, @@ -237,15 +272,22 @@ function routeServedBy(member: string, service: unknown, opts: SettingsRoutesOpt } describe('the settings census — read off the registration', () => { - it('registers exactly four routes, the ledgered ones, each serving one distinct member', () => { + it('registers exactly six routes, the ledgered ones, each serving one distinct member', () => { const { routes, middleware } = capture(untouchableService().service); - // ⭐ A count, on purpose: a fifth route has to come through this file. - expect(routes).toHaveLength(4); + // ⭐ A count, on purpose: a seventh route has to come through this file. + expect(routes).toHaveLength(6); expect(routes.map((r) => `${r.verb} ${r.path}`).sort()).toEqual( SETTINGS_ROUTE_LEDGER.map((row) => row.route).sort(), ); - expect(new Set(MEMBERS).size).toBe(4); - expect([...MEMBERS].sort()).toEqual(['getNamespace', 'listManifests', 'runAction', 'setMany']); + expect(new Set(MEMBERS).size).toBe(6); + expect([...MEMBERS].sort()).toEqual([ + 'deploymentNotice.hide', + 'deploymentNotice.read', + 'getNamespace', + 'listManifests', + 'runAction', + 'setMany', + ]); // A preflight never reaches the door: no `OPTIONS` route, no middleware. expect(routes.every((r) => ['GET', 'PUT', 'POST'].includes(r.verb))).toBe(true); expect(middleware).toHaveLength(0); @@ -279,7 +321,7 @@ describe('ADR-0069 door — a held session is refused 403 with the gate code on const gate = POSTURES[1]!; const { service, calls } = untouchableService(); const { routes } = capture(service, { basePath, contextFromRequest: () => held(gate) }); - expect(routes).toHaveLength(4); + expect(routes).toHaveLength(6); for (const route of routes) expectGateRefusal(await drive(route), gate); expect(calls).toEqual([]); }); @@ -364,7 +406,7 @@ describe('ADR-0069 producer — SettingsServicePlugin carries the session postur for (const gate of POSTURES) { it(`a session held by ${gate.code} is refused 403 on every mounted route, one session read each`, async () => { const { http, reads } = await bootPlugin({ id: 'usr_held', authGate: gate }); - expect(http.routes).toHaveLength(4); + expect(http.routes).toHaveLength(6); for (const route of http.routes) { const before = reads.length; expectGateRefusal(await drive(route, { cookie: SESSION_COOKIE }), gate); diff --git a/packages/services/service-settings/src/settings-routes.authz-outage-relay.test.ts b/packages/services/service-settings/src/settings-routes.authz-outage-relay.test.ts index c9026a28f64..d82a2dc38c6 100644 --- a/packages/services/service-settings/src/settings-routes.authz-outage-relay.test.ts +++ b/packages/services/service-settings/src/settings-routes.authz-outage-relay.test.ts @@ -21,7 +21,8 @@ * swallow — this registrar has **four**: `GET /api/settings`, * `GET /api/settings/:namespace`, `PUT /api/settings/:namespace` and * `POST /api/settings/:namespace/:actionId`. All four are driven below, so the - * fourth cannot be the one nobody remembered. + * fourth cannot be the one nobody remembered. [#30120] The deployment notice's + * read and hide came later, through the same family door, and are driven too. * * ## RELAY, not re-raise * @@ -55,6 +56,7 @@ import type { IHttpRequest, IHttpResponse, RouteHandler } from '@objectstack/spe import { registerSettingsRoutes } from './settings-routes.js'; import { SettingsForbiddenError, UnknownNamespaceError } from './settings-service.types.js'; import type { SettingsContext } from './settings-service.types.js'; +import type { DeploymentNoticeDoor, DeploymentNoticeState } from './deployment-notice.js'; const BASE = '/api/settings'; const NS = 'mail'; @@ -81,6 +83,17 @@ function permissiveService() { } as any; } +/** The deployment notice's door, which would SUCCEED for both of its routes. */ +const NOTICE: DeploymentNoticeState = { + production: true, + findings: [{ id: 'auth_secret_generated', severity: 'high', variable: 'OS_AUTH_SECRET' }], + fingerprint: 'auth_secret_generated', +}; +const permissiveNoticeDoor: DeploymentNoticeDoor = { + read: async () => NOTICE, + hide: async () => ({ outcome: 'recorded', state: NOTICE }), +}; + type Ctx = (req: IHttpRequest) => SettingsContext | Promise; function mount(contextFromRequest: Ctx, service: any = permissiveService()) { @@ -95,11 +108,11 @@ function mount(contextFromRequest: Ctx, service: any = permissiveService()) { listen: async () => {}, close: async () => {}, }; - registerSettingsRoutes(http as any, service, { basePath: BASE, contextFromRequest }); + registerSettingsRoutes(http as any, service, { basePath: BASE, contextFromRequest, deploymentNotice: permissiveNoticeDoor }); return routes; } -/** The four doors this registrar mounts — the predicate-found census, not the card's count. */ +/** The six doors this registrar mounts — the predicate-found census, not the card's count. */ const DOORS = [ { name: 'GET /api/settings', key: `GET:${BASE}`, params: {}, body: undefined }, { name: 'GET /api/settings/:namespace', key: `GET:${BASE}/:namespace`, params: { namespace: NS }, body: undefined }, @@ -110,6 +123,13 @@ const DOORS = [ params: { namespace: NS, actionId: 'test' }, body: {}, }, + { name: 'GET /api/settings/deployment/notice', key: `GET:${BASE}/deployment/notice`, params: {}, body: undefined }, + { + name: 'POST /api/settings/deployment/notice/hide', + key: `POST:${BASE}/deployment/notice/hide`, + params: {}, + body: { fingerprint: NOTICE.fingerprint }, + }, ] as const; async function drive(routes: Map, door: (typeof DOORS)[number]): Promise { @@ -143,8 +163,8 @@ const RESOLVED: Ctx = () => ({ enforced: false, userId: 'usr_control' }); // §0 — The census control: the door table above IS the mounted surface. // --------------------------------------------------------------------------- -describe('[#15999] §0 — all four route catches exist and are the ones driven', () => { - it('the registrar mounts exactly the four doors this file drives', () => { +describe('[#15999] §0 — all six route catches exist and are the ones driven', () => { + it('the registrar mounts exactly the six doors this file drives', () => { const routes = mount(() => ({ enforced: false })); expect([...routes.keys()].sort()).toEqual(DOORS.map((d) => d.key).sort()); }); diff --git a/packages/services/service-settings/src/settings-routes.ts b/packages/services/service-settings/src/settings-routes.ts index 9f7ec8eecf9..0cc43364a31 100644 --- a/packages/services/service-settings/src/settings-routes.ts +++ b/packages/services/service-settings/src/settings-routes.ts @@ -59,6 +59,7 @@ import { SettingsService } from './settings-service.js'; // nothing that leaves over HTTP may carry it. See the module header for the // mask shape and why it mirrors ADR-0100's encrypted-field convention. import { dropEchoedSecretMasks, redactSecretValues } from './settings-secret-redaction.js'; +import { DEPLOYMENT_NOTICE_CAPABILITY, type DeploymentNoticeDoor } from './deployment-notice.js'; import { SettingsCryptoUnavailableError, SettingsForbiddenError, @@ -88,8 +89,17 @@ export interface SettingsRoutesOptions { * D2): a context with no `userId` is anonymous, whatever else it carries. */ contextFromRequest?: (req: IHttpRequest) => SettingsContext | Promise; + /** + * The deployment notice's work (`deployment-notice.ts`), served to + * administrators at `GET {base}/deployment/notice` and hidden through + * `POST {base}/deployment/notice/hide`. `SettingsServicePlugin` wires it; + * a host that mounts the family without it answers both routes `503`, + * naming what is missing. + */ + deploymentNotice?: DeploymentNoticeDoor; } + // [Finding-1] Secure default: anonymous + enforced. No identity is read from // request headers — a deployment that wants authenticated settings access must // wire a verified `contextFromRequest` (the plugin does). @@ -448,4 +458,89 @@ export function registerSettingsRoutes( } }, }); + + // ── The deployment notice (ruling A3, record 6634240777) ───────────────── + // What a production deployment keeps on its own disk because nobody set it, + // for administrators alone: the state names server paths, so it is served + // behind this family's door and the infrastructure capability, ⛔ never on + // the public auth config. Two segments under the base, so neither route + // shares a shape with a same-method `/:namespace` route above. + const noticeDoor = opts.deploymentNotice; + /** + * `true` when the caller was refused: no door wired, or an enforced caller + * without the capability. An un-enforced (trusted in-process) context is + * not gated, as no manifest gate of the service gates one. + */ + const refuseNoticeCaller = (res: IHttpResponse, ctx: SettingsContext): boolean => { + if (!noticeDoor) { + sendError( + res, + 503, + 'SERVICE_UNAVAILABLE', + 'The deployment notice is not composed on this host: SettingsServicePlugin wires it when it mounts these routes.', + ); + return true; + } + if (ctx.enforced && !(ctx.permissions ?? []).includes(DEPLOYMENT_NOTICE_CAPABILITY)) { + sendError( + res, + 403, + 'PERMISSION_DENIED', + `The deployment notice is for administrators: it needs the ${DEPLOYMENT_NOTICE_CAPABILITY} capability.`, + ); + return true; + } + return false; + }; + /** The family's arm for a refusal the context resolver raised, then the route's fault tail. */ + const refuseNotice = (message: string) => (res: IHttpResponse, err: any): void => { + if (err instanceof SettingsForbiddenError) { + sendError(res, 403, 'SETTINGS_FORBIDDEN', err.message, { details: { namespace: err.namespace } }); + } else { + sendServerFault(res, 500, 'INTERNAL_ERROR', message, err); + } + }; + + mountFamilyRoute('get', `${base}/deployment/notice`, { + async serve(_req, res, ctx) { + if (refuseNoticeCaller(res, ctx)) return; + sendOk(res, await noticeDoor!.read(ctx)); + }, + refuse: refuseNotice('Failed to read the deployment notice'), + }); + + mountFamilyRoute('post', `${base}/deployment/notice/hide`, { + async serve(req, res, ctx) { + if (refuseNoticeCaller(res, ctx)) return; + const fingerprint = (req.body as { fingerprint?: unknown } | undefined)?.fingerprint; + if (typeof fingerprint !== 'string' || fingerprint.length === 0) { + sendError(res, 400, 'VALIDATION_ERROR', 'Name the notice you hide: send { "fingerprint": "" }.'); + return; + } + const answer = await noticeDoor!.hide({ context: ctx, fingerprint }); + switch (answer.outcome) { + case 'recorded': + sendOk(res, { hidden: true, fingerprint: answer.state.fingerprint }); + return; + case 'nothing-to-hide': + sendError(res, 409, 'RESOURCE_CONFLICT', 'There is no deployment notice to hide: this deployment keeps nothing on its own disk by default.', { + details: answer.state, + }); + return; + case 'changed': + sendError(res, 409, 'RESOURCE_CONFLICT', 'The deployment notice changed since it was read: read it again and hide what it says now.', { + details: answer.state, + }); + return; + case 'no-ledger': + // The ruling makes the audit row part of a hide, so a hide this + // deployment cannot record is not one: the notice stays. + sendError(res, 409, 'RESOURCE_CONFLICT', 'This deployment keeps no audit log (@objectstack/plugin-audit is not mounted), so a hide cannot be recorded and the notice stays.', { + details: { reason: 'no_audit_log' }, + }); + return; + } + }, + refuse: refuseNotice('Failed to record the deployment notice hide'), + }); } diff --git a/packages/services/service-settings/src/settings-service-plugin.ts b/packages/services/service-settings/src/settings-service-plugin.ts index 359b43d5241..82be8503611 100644 --- a/packages/services/service-settings/src/settings-service-plugin.ts +++ b/packages/services/service-settings/src/settings-service-plugin.ts @@ -25,7 +25,7 @@ import { type TenancyServiceResolver, } from '@objectstack/core'; import { postureEnforcesWall, type TenancyPosture } from '@objectstack/spec/security'; -import type { IHttpServer, IDataEngine, IHttpRequest, IAuthService } from '@objectstack/spec/contracts'; +import type { IHttpServer, IDataEngine, IHttpRequest, IAuthService, IEmailService } from '@objectstack/spec/contracts'; import type { SettingsContext } from './settings-service.types.js'; import type { SettingsManifest } from '@objectstack/spec/system'; import { SettingsService } from './settings-service.js'; @@ -38,6 +38,7 @@ import { storedSecretReaderOverEngine, } from './local-crypto-provider.js'; import { buildConfigChangeAuditSink } from './config-change-audit.js'; +import { buildDeploymentNoticeDoor } from './deployment-notice.js'; import { registerLocalizationCurrencyLock, type CurrencyUsageEngine } from './localization-currency-lock.js'; import { registerLocalizationLocaleOptions, type LocaleListService } from './localization-locale-options.js'; import { USER_OBJECT, assertUserReferenceResolves, registeredLabel } from './actor-reference.js'; @@ -531,9 +532,50 @@ export class SettingsServicePlugin implements Plugin { } }; + // [#30120, ruling A3] What a production deployment keeps on its own + // disk because nobody set it, served to administrators behind this door + // (see `deployment-notice.ts`). Every source is read at the request. + const settings = this.service!; + const deploymentNotice = buildDeploymentNoticeDoor({ + sources: { + env: () => process.env, + emailDeliveryLive: () => { + let email: IEmailService | undefined; + try { + email = ctx.getService('email'); + } catch { + return undefined; + } + // The deliverability member is optional: absent or throwing is + // not live, as the auth plugin's own reader rules. + if (!email || typeof email.isConfigured !== 'function') return email ? false : undefined; + try { + return email.isConfigured() === true; + } catch { + return false; + } + }, + storageAdapter: async () => { + try { + if (!ctx.getService('storage')) return undefined; + } catch { + return undefined; + } + try { + const resolved = await settings.get('storage', 'adapter'); + return typeof resolved.value === 'string' ? resolved.value : undefined; + } catch { + return undefined; + } + }, + }, + engine: () => engine, + }); + registerSettingsRoutes(http, this.service!, { basePath: this.opts.basePath, contextFromRequest: verifiedContextFromRequest, + deploymentNotice, }); ctx.logger?.info?.( 'SettingsServicePlugin: REST routes registered at ' + (this.opts.basePath ?? '/api/settings'), diff --git a/scripts/check-cli-test-child-env.mjs b/scripts/check-cli-test-child-env.mjs index 725918cbc30..ba4a9c18646 100644 --- a/scripts/check-cli-test-child-env.mjs +++ b/scripts/check-cli-test-child-env.mjs @@ -2635,7 +2635,7 @@ export function selfTest() { .filter((abs) => builtEntrypointSpawns(abs, readFileSync(abs, 'utf8')).spawns > 0) .map((abs) => relative(REPO_ROOT, abs).split(sep).join('/')) .sort(); - t('the built-entrypoint population is exactly the nineteen files that spawn bin/run.js', + t('the built-entrypoint population is exactly the twenty files that spawn bin/run.js', JSON.stringify(builtFiles) === JSON.stringify([ // [#26130] Joined the population deliberately: its subject is a plain // `os dev` boot that compiles before it serves, measured on the BUILT @@ -2714,6 +2714,13 @@ export function selfTest() { // so the tsx shim every `runServe()` caller uses cannot reach it at all. 'packages/cli/test/serve-ready-degraded-boot.e2e.test.ts', 'packages/cli/test/serve-stdio-stdout-purity.e2e.test.ts', + // [#30120] Joined the population deliberately: its subject is the + // official image's refusal and the production boot block of a plain + // `os start` (start forces a production `serve` child unless `NODE_ENV` + // is set), measured on the BUILT packages, and `bin/run-dev.js` sets + // `NODE_ENV=development` before argv is parsed, where neither exists. + // Every spawn hands its child `NODE_ENV: undefined`. + 'packages/cli/test/start-deployment-durability.integration.test.ts', // [#24002] Joined the population deliberately: its subject is the // documented stdio invocation, `os start` in PRODUCTION posture (start // forces a production `serve` child unless `NODE_ENV` is set), and diff --git a/ui/packages/app-shell/src/console/deployment-notice/DeploymentNotice.tsx b/ui/packages/app-shell/src/console/deployment-notice/DeploymentNotice.tsx new file mode 100644 index 00000000000..8ae2f7a65b2 --- /dev/null +++ b/ui/packages/app-shell/src/console/deployment-notice/DeploymentNotice.tsx @@ -0,0 +1,245 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * DeploymentNotice — the standing notice a production deployment's + * administrators see when the server keeps their data and secrets on its own + * disk (objectstack#30120, ruling A3). + * + * Drawn on the Console home page and at the top of Setup's System Overview. + * No dialog: a banner that stays until the deployment is fixed or an + * administrator hides it. It names each finding in business terms, offers the + * variables to hand to whoever runs the server (the copy button), and can be + * hidden; the hide is written to the audit log by the server. + * + * Who sees it is the server's decision: the state is served behind the + * settings door and the `manage_platform_settings` capability, so a member's + * read is refused and draws nothing. The workspace-admin check here only + * spares a member's browser that refused request. + * + * After a hide, the home page draws nothing and System Overview keeps one + * small line, with a way to show the notice again in this browser. + * + * @module + */ + +import { useCallback, useEffect, useState } from 'react'; +import { useAuth, useWorkspaceAdminStatus } from '@object-ui/auth'; +import { Button } from '@object-ui/components'; +import { useObjectTranslation } from '@object-ui/i18n'; +import { Check, Copy, HardDrive, Info } from 'lucide-react'; +import { + deploymentVariablesText, + fetchDeploymentNotice, + forgetHidden, + hiddenFingerprint, + hideDeploymentNotice, + rememberHidden, + type DeploymentFinding, + type DeploymentNoticeState, + type HideAnswer, +} from './deploymentNotice.js'; + +export interface DeploymentNoticeProps { + /** `home`: nothing once hidden. `system-overview`: one small line once hidden. */ + placement: 'home' | 'system-overview'; +} + +function findingText(f: DeploymentFinding, t: (key: string, opts?: Record) => string): string { + switch (f.id) { + case 'database_local_file': + return t('deploymentNotice.finding.databaseLocalFile', { + defaultValue: 'Your business data is a file on this server ({{file}}). Replacing the server or its disk loses it.', + file: f.file ?? '', + }); + case 'auth_secret_generated': + return t('deploymentNotice.finding.authSecretGenerated', { + defaultValue: 'The sign-in secret was generated on this server. If it is lost, everyone is signed out.', + }); + case 'data_key_file': + return t('deploymentNotice.finding.dataKeyFile', { + defaultValue: + 'The key that encrypts saved passwords and API keys is a file on this server ({{file}}). If it is lost, they can no longer be read.', + file: f.file ?? '', + }); + case 'file_storage_local': + return t('deploymentNotice.finding.fileStorageLocal', { + defaultValue: "Uploaded files are kept on this server's disk.", + }); + case 'email_not_configured': + return t('deploymentNotice.finding.emailNotConfigured', { + defaultValue: 'No email service is set up, so invitations and password resets are not sent by email.', + }); + } +} + +export function DeploymentNotice({ placement }: DeploymentNoticeProps) { + const { t } = useObjectTranslation(); + const { user } = useAuth(); + const { isAdmin, isResolved } = useWorkspaceAdminStatus(); + const userId = typeof user?.id === 'string' ? user.id : undefined; + const [state, setState] = useState(null); + const [hidden, setHidden] = useState(null); + const [copied, setCopied] = useState(false); + const [hiding, setHiding] = useState(false); + const [hideAnswer, setHideAnswer] = useState(null); + + const load = useCallback(async () => { + const next = await fetchDeploymentNotice(); + setState(next); + }, []); + + useEffect(() => { + if (!isResolved || !isAdmin || !userId) return; + let cancelled = false; + setHidden(hiddenFingerprint(userId)); + fetchDeploymentNotice().then((next) => { + if (!cancelled) setState(next); + }); + return () => { + cancelled = true; + }; + }, [isResolved, isAdmin, userId]); + + useEffect(() => { + if (!copied) return; + const id = setTimeout(() => setCopied(false), 1500); + return () => clearTimeout(id); + }, [copied]); + + if (!isResolved || !isAdmin || !userId || !state || !state.production || state.findings.length === 0) return null; + + const isHidden = hidden === state.fingerprint; + if (isHidden) { + if (placement === 'home') return null; + return ( +
+

+

+
+ ); + } + + const high = state.findings.filter((f) => f.severity === 'high'); + const low = state.findings.filter((f) => f.severity === 'low'); + const variables = deploymentVariablesText(high, t); + + const copy = async () => { + try { + await navigator.clipboard.writeText(variables); + setCopied(true); + } catch { + // Denied or unavailable: the findings name every variable on screen. + } + }; + + const hide = async () => { + setHiding(true); + const answer = await hideDeploymentNotice(state.fingerprint); + setHiding(false); + setHideAnswer(answer); + if (answer === 'hidden') { + rememberHidden(userId, state.fingerprint); + setHidden(state.fingerprint); + } else if (answer === 'changed') { + void load(); + } + }; + + const tone = + high.length > 0 + ? 'border-amber-300/60 dark:border-amber-700/50 bg-amber-50 dark:bg-amber-950/30 text-amber-900 dark:text-amber-200' + : 'border-border bg-muted/40 text-foreground'; + + return ( +
+
+
+
+
+
+
+
+ ); +} diff --git a/ui/packages/app-shell/src/console/deployment-notice/__tests__/DeploymentNotice.test.tsx b/ui/packages/app-shell/src/console/deployment-notice/__tests__/DeploymentNotice.test.tsx new file mode 100644 index 00000000000..ba013c3eb34 --- /dev/null +++ b/ui/packages/app-shell/src/console/deployment-notice/__tests__/DeploymentNotice.test.tsx @@ -0,0 +1,219 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#30120 (ruling A3) — the deployment notice an administrator sees + * on a production deployment that keeps its data and secrets on its own disk. + * + * Driven through the real component over a stubbed `fetch`: what is drawn for + * each finding, the copy button's text, the hide (a POST, then remembered in + * this browser by fingerprint), the one small line System Overview keeps after + * a hide, and the refusals that draw nothing (a member, a server answering + * 403, outside production). Who may read the state is the server's ruling; + * its pins live in `@objectstack/service-settings` + * (`deployment-notice.routes.test.ts`). + */ + +import '@testing-library/jest-dom/vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; + +const admin = { isAdmin: true, isResolved: true }; +let adminStatus = admin; +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ user: { id: 'usr_admin' } }), + useWorkspaceAdminStatus: () => adminStatus, +})); + +import { DeploymentNotice } from '../DeploymentNotice'; +import { HIDDEN_KEY_PREFIX, deploymentVariablesText, parseDeploymentNotice } from '../deploymentNotice'; + +const NOTICE = { + production: true, + findings: [ + { id: 'database_local_file', severity: 'high', variable: 'OS_DATABASE_URL', file: '/srv/app/.objectstack/data/app.db' }, + { id: 'auth_secret_generated', severity: 'high', variable: 'OS_AUTH_SECRET', file: '/home/node/.objectstack/auth-secret' }, + { id: 'data_key_file', severity: 'high', variable: 'OS_SECRET_KEY', file: '/srv/app/.objectstack/secret-key' }, + { id: 'file_storage_local', severity: 'low' }, + { id: 'email_not_configured', severity: 'low' }, + ], + fingerprint: 'database_local_file+auth_secret_generated+data_key_file+file_storage_local+email_not_configured', +}; + +const envelope = (data: unknown) => ({ success: true, data }); +const json = (status: number, body: unknown) => + ({ ok: status >= 200 && status < 300, status, json: async () => body }) as unknown as Response; + +let fetchMock: ReturnType; + +async function settle() { + await act(async () => { + for (let i = 0; i < 5; i++) await Promise.resolve(); + }); +} + +const t = (key: string, opts?: Record) => { + const text = String(opts?.defaultValue ?? key); + return text.replace(/\{\{(\w+)\}\}/g, (_m, name) => String(opts?.[name] ?? '')); +}; + +describe('DeploymentNotice (objectstack#30120)', () => { + beforeEach(() => { + adminStatus = admin; + localStorage.clear(); + fetchMock = vi.fn(async (url: string, init?: RequestInit) => { + if (String(url).endsWith('/api/settings/deployment/notice/hide') && init?.method === 'POST') { + return json(200, envelope({ hidden: true, fingerprint: NOTICE.fingerprint })); + } + return json(200, envelope(NOTICE)); + }); + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + localStorage.clear(); + }); + + it('draws every finding for an administrator, the three that are lost with the disk first', async () => { + render(); + await settle(); + expect(fetchMock).toHaveBeenCalledWith( + expect.stringMatching(/\/api\/settings\/deployment\/notice$/), + expect.objectContaining({ credentials: 'include' }), + ); + const notice = screen.getByTestId('deployment-notice'); + expect(notice).toHaveAttribute('data-fingerprint', NOTICE.fingerprint); + const ids = [...notice.querySelectorAll('[data-finding]')].map((li) => li.getAttribute('data-finding')); + expect(ids).toEqual(NOTICE.findings.map((f) => f.id)); + expect(notice).toHaveTextContent('/srv/app/.objectstack/data/app.db'); + expect(notice).toHaveTextContent('OS_DATABASE_URL'); + expect(notice).toHaveTextContent('OS_AUTH_SECRET'); + expect(notice).toHaveTextContent('OS_SECRET_KEY'); + expect(screen.getByTestId('deployment-notice-copy')).toBeInTheDocument(); + }); + + it('the copy button hands over every variable, saying which current value to carry over', async () => { + const writeText = vi.fn(async (_text: string) => {}); + Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true }); + render(); + await settle(); + await act(async () => { + fireEvent.click(screen.getByTestId('deployment-notice-copy')); + }); + expect(writeText).toHaveBeenCalledTimes(1); + const text = writeText.mock.calls[0]![0]; + expect(text).toMatch(/^OS_DATABASE_URL=$/m); + expect(text).toMatch(/^OS_AUTH_SECRET=$/m); + expect(text).toMatch(/^OS_SECRET_KEY=$/m); + expect(text).toContain('the contents of /home/node/.objectstack/auth-secret'); + expect(text).toContain('the contents of /srv/app/.objectstack/secret-key'); + expect(text).toContain('Today the data is in /srv/app/.objectstack/data/app.db'); + }); + + it('hide posts the fingerprint, and the home page then draws nothing', async () => { + render(); + await settle(); + await act(async () => { + fireEvent.click(screen.getByTestId('deployment-notice-hide')); + }); + await settle(); + const hideCall = fetchMock.mock.calls.find(([url]) => String(url).endsWith('/hide'))!; + expect(hideCall[1]).toMatchObject({ method: 'POST' }); + expect(JSON.parse(String((hideCall[1] as RequestInit).body))).toEqual({ fingerprint: NOTICE.fingerprint }); + expect(localStorage.getItem(`${HIDDEN_KEY_PREFIX}usr_admin`)).toBe(NOTICE.fingerprint); + expect(screen.queryByTestId('deployment-notice')).toBeNull(); + expect(screen.queryByTestId('deployment-notice-hidden')).toBeNull(); + }); + + it('after a hide, System Overview keeps one small line, and can show the notice again', async () => { + localStorage.setItem(`${HIDDEN_KEY_PREFIX}usr_admin`, NOTICE.fingerprint); + render(); + await settle(); + expect(screen.queryByTestId('deployment-notice')).toBeNull(); + expect(screen.getByTestId('deployment-notice-hidden')).toBeInTheDocument(); + await act(async () => { + fireEvent.click(screen.getByTestId('deployment-notice-show')); + }); + expect(screen.getByTestId('deployment-notice')).toBeInTheDocument(); + expect(localStorage.getItem(`${HIDDEN_KEY_PREFIX}usr_admin`)).toBeNull(); + }); + + it('a finding that appears after the hide brings the notice back: the hide names its fingerprint', async () => { + localStorage.setItem(`${HIDDEN_KEY_PREFIX}usr_admin`, 'database_local_file'); + render(); + await settle(); + expect(screen.getByTestId('deployment-notice')).toBeInTheDocument(); + }); + + it('a deployment that keeps no audit log refuses the hide, and the notice stays', async () => { + fetchMock.mockImplementation(async (url: string, init?: RequestInit) => + String(url).endsWith('/hide') && init?.method === 'POST' + ? json(409, { success: false, error: { code: 'RESOURCE_CONFLICT', message: 'no audit log', details: { reason: 'no_audit_log' } } }) + : json(200, envelope(NOTICE)), + ); + render(); + await settle(); + await act(async () => { + fireEvent.click(screen.getByTestId('deployment-notice-hide')); + }); + await settle(); + expect(screen.getByTestId('deployment-notice')).toBeInTheDocument(); + expect(screen.getByRole('status')).toHaveTextContent('keeps no audit log'); + expect(localStorage.getItem(`${HIDDEN_KEY_PREFIX}usr_admin`)).toBeNull(); + }); + + it('a member is never sent the request, and draws nothing', async () => { + adminStatus = { isAdmin: false, isResolved: true }; + render(); + await settle(); + expect(fetchMock).not.toHaveBeenCalled(); + expect(screen.queryByTestId('deployment-notice')).toBeNull(); + }); + + it('an administrator the server refuses (403) draws nothing', async () => { + fetchMock.mockResolvedValue(json(403, { success: false, error: { code: 'PERMISSION_DENIED', message: 'no' } })); + render(); + await settle(); + expect(screen.queryByTestId('deployment-notice')).toBeNull(); + }); + + it('outside production mode, or with nothing to say, it draws nothing', async () => { + fetchMock.mockResolvedValue(json(200, envelope({ production: false, findings: [], fingerprint: '' }))); + render(); + await settle(); + expect(screen.queryByTestId('deployment-notice')).toBeNull(); + expect(screen.queryByTestId('deployment-notice-hidden')).toBeNull(); + }); +}); + +describe('deploymentNotice helpers', () => { + it('drops a finding this Console has no words for, and refuses a body that is not the declared shape', () => { + expect( + parseDeploymentNotice(envelope({ ...NOTICE, findings: [...NOTICE.findings, { id: 'something_new', severity: 'high' }] })) + ?.findings.map((f) => f.id), + ).toEqual(NOTICE.findings.map((f) => f.id)); + expect(parseDeploymentNotice({ success: false, error: {} })).toBeNull(); + expect(parseDeploymentNotice(envelope({ production: true }))).toBeNull(); + }); + + it('a sign-in secret kept by no file asks for a new value rather than a file to carry over', () => { + const text = deploymentVariablesText([{ id: 'auth_secret_generated', severity: 'high', variable: 'OS_AUTH_SECRET' }], t); + expect(text).toContain('openssl rand -hex 32'); + expect(text).not.toContain('the contents of'); + expect(text).toMatch(/^OS_AUTH_SECRET=$/m); + }); + + it('lower-severity findings put nothing on the clipboard', () => { + expect( + deploymentVariablesText( + [ + { id: 'file_storage_local', severity: 'low' }, + { id: 'email_not_configured', severity: 'low' }, + ], + t, + ), + ).toBe(''); + }); +}); diff --git a/ui/packages/app-shell/src/console/deployment-notice/deploymentNotice.ts b/ui/packages/app-shell/src/console/deployment-notice/deploymentNotice.ts new file mode 100644 index 00000000000..c602e7f3b56 --- /dev/null +++ b/ui/packages/app-shell/src/console/deployment-notice/deploymentNotice.ts @@ -0,0 +1,196 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The deployment notice's wire and storage (objectstack#30120, ruling A3). + * + * A production deployment that boots without `OS_DATABASE_URL`, + * `OS_AUTH_SECRET` or `OS_SECRET_KEY` keeps its data and both secrets on its + * own disk. The server states that to administrators at + * `GET /api/settings/deployment/notice` (`@objectstack/service-settings`, + * `deployment-notice.ts`), behind the settings door and the + * `manage_platform_settings` capability: a member is refused `403`, and the + * state is never on the public auth config. Outside production mode the + * server answers no findings. + * + * A hide is two things: the server's `POST …/hide` writes the acknowledgement + * to the audit log (and refuses when the deployment keeps none), and this + * module remembers it for this administrator in this browser, keyed by the + * fingerprint of what was acknowledged. A finding that appears later changes + * the fingerprint, so the notice comes back. + * + * @module + */ + +import { bearerAuthHeaders } from '../../utils/authToken.js'; + +export type DeploymentFindingId = + | 'database_local_file' + | 'auth_secret_generated' + | 'data_key_file' + | 'file_storage_local' + | 'email_not_configured'; + +export interface DeploymentFinding { + id: DeploymentFindingId; + severity: 'high' | 'low'; + variable?: 'OS_DATABASE_URL' | 'OS_AUTH_SECRET' | 'OS_SECRET_KEY'; + file?: string; +} + +export interface DeploymentNoticeState { + production: boolean; + findings: DeploymentFinding[]; + fingerprint: string; +} + +const serverUrl = (): string => (import.meta.env?.VITE_SERVER_URL || '').replace(/\/$/, ''); +const NOTICE_PATH = '/api/settings/deployment/notice'; + +const KNOWN_FINDINGS: ReadonlySet = new Set([ + 'database_local_file', + 'auth_secret_generated', + 'data_key_file', + 'file_storage_local', + 'email_not_configured', +]); + +/** + * The served state, or `null` when there is none to draw: a refusal (a + * member, a server without the route), a fault, or a body that is not the + * declared shape. The notice is an aside, so none of these is an error the + * page shows. + */ +export function parseDeploymentNotice(body: unknown): DeploymentNoticeState | null { + const data = + body && typeof body === 'object' && (body as { success?: unknown }).success === true + ? (body as { data?: unknown }).data + : undefined; + if (!data || typeof data !== 'object') return null; + const d = data as Record; + if (typeof d.production !== 'boolean' || typeof d.fingerprint !== 'string' || !Array.isArray(d.findings)) { + return null; + } + // A finding this Console has no words for is left out, not drawn blank. + const findings = d.findings.filter( + (f): f is DeploymentFinding => + !!f && + typeof f === 'object' && + KNOWN_FINDINGS.has((f as { id?: unknown }).id as string) && + ((f as { severity?: unknown }).severity === 'high' || (f as { severity?: unknown }).severity === 'low'), + ); + return { production: d.production, findings, fingerprint: d.fingerprint }; +} + +export async function fetchDeploymentNotice(): Promise { + try { + const res = await fetch(`${serverUrl()}${NOTICE_PATH}`, { + credentials: 'include', + headers: { Accept: 'application/json', ...bearerAuthHeaders() }, + }); + if (!res.ok) return null; + return parseDeploymentNotice(await res.json()); + } catch { + return null; + } +} + +/** What the server said to a hide. */ +export type HideAnswer = 'hidden' | 'no-audit-log' | 'changed' | 'failed'; + +export async function hideDeploymentNotice(fingerprint: string): Promise { + try { + const res = await fetch(`${serverUrl()}${NOTICE_PATH}/hide`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...bearerAuthHeaders() }, + body: JSON.stringify({ fingerprint }), + }); + if (res.ok) return 'hidden'; + if (res.status === 409) { + const body = (await res.json().catch(() => null)) as { error?: { details?: { reason?: unknown } } } | null; + // The deployment that keeps no audit log says so; every other conflict + // is a notice that moved on since it was read. + return body?.error?.details?.reason === 'no_audit_log' ? 'no-audit-log' : 'changed'; + } + return 'failed'; + } catch { + return 'failed'; + } +} + +/** Per administrator, per browser: the fingerprint they hid. */ +export const HIDDEN_KEY_PREFIX = 'os:deployment-notice-hidden:'; + +type HiddenStorage = Pick; + +function storage(): HiddenStorage | undefined { + try { + return typeof window !== 'undefined' ? window.localStorage : undefined; + } catch { + return undefined; + } +} + +export function hiddenFingerprint(userId: string, store: HiddenStorage | undefined = storage()): string | null { + try { + return store?.getItem(HIDDEN_KEY_PREFIX + userId) ?? null; + } catch { + return null; + } +} + +export function rememberHidden(userId: string, fingerprint: string, store: HiddenStorage | undefined = storage()): void { + try { + store?.setItem(HIDDEN_KEY_PREFIX + userId, fingerprint); + } catch { + /* the server's audit row still records the hide */ + } +} + +export function forgetHidden(userId: string, store: HiddenStorage | undefined = storage()): void { + try { + store?.removeItem(HIDDEN_KEY_PREFIX + userId); + } catch { + /* ignore */ + } +} + +type T = (key: string, opts?: Record) => string; + +/** + * The text the copy button puts on the clipboard: the variables to set, each + * preceded by a comment saying what value it takes. An env-file shape, so it + * pastes into a `.env`, a compose file's `environment:` or a platform's + * variables screen. + */ +export function deploymentVariablesText(findings: readonly DeploymentFinding[], t: T): string { + const lines: string[] = []; + for (const f of findings) { + if (f.severity !== 'high' || !f.variable) continue; + if (lines.length > 0) lines.push(''); + if (f.id === 'database_local_file') { + lines.push( + `# ${t('deploymentNotice.copy.database', { + defaultValue: + 'Your database, e.g. postgres://user:pass@db-host:5432/app, or file:/path/on/a/kept/volume/app.db. Today the data is in {{file}}: move it over before you switch.', + file: f.file ?? '', + })}`, + ); + } else if (f.file) { + lines.push( + `# ${t('deploymentNotice.copy.carryOver', { + defaultValue: 'Carry the current value over: the contents of {{file}}', + file: f.file, + })}`, + ); + } else { + lines.push( + `# ${t('deploymentNotice.copy.newValue', { + defaultValue: 'A long random value, the same on every restart, e.g. the output of: openssl rand -hex 32', + })}`, + ); + } + lines.push(`${f.variable}=`); + } + return lines.join('\n'); +} diff --git a/ui/packages/app-shell/src/console/home/HomePage.tsx b/ui/packages/app-shell/src/console/home/HomePage.tsx index 8b0bb8d5c65..793910bc76a 100644 --- a/ui/packages/app-shell/src/console/home/HomePage.tsx +++ b/ui/packages/app-shell/src/console/home/HomePage.tsx @@ -35,6 +35,7 @@ import { HomeAppsStrip } from './HomeAppsStrip.js'; import { appSegmentOf, lastOpenedByApp } from './appLastOpened.js'; import { recentItemObject, recentItemTypeLabel } from './recentItemTypeLabel.js'; import { RecoveryPasswordReminder } from './RecoveryPasswordReminder.js'; +import { DeploymentNotice } from '../deployment-notice/DeploymentNotice.js'; import { HomeActionCenter, HomeContinue, HomeActivity, HomePendingDraftsRow, type RecentItemContext } from './HomeRail.js'; import { resolveI18nLabel } from '@objectstack/spec/ui'; import { useHomeInbox } from '../../hooks/useHomeInbox.js'; @@ -569,6 +570,10 @@ export function HomePage() {
{draftsRow}
)} + {/* objectstack#30120 — administrators, production only: what this + server keeps on its own disk. Above the recovery nudge, which is + one person's; this is the deployment's. */} +
{/* @@ -654,6 +659,8 @@ export function HomePage() { return (
+ {/* objectstack#30120 — see the empty-state branch above. */} + {/* The content column (objectui#12601): 1120 px, centred, 28 px above the diff --git a/ui/packages/app-shell/src/console/home/__tests__/HomePage.aiStudioDisabled.test.tsx b/ui/packages/app-shell/src/console/home/__tests__/HomePage.aiStudioDisabled.test.tsx index f6397d9c377..82f91018015 100644 --- a/ui/packages/app-shell/src/console/home/__tests__/HomePage.aiStudioDisabled.test.tsx +++ b/ui/packages/app-shell/src/console/home/__tests__/HomePage.aiStudioDisabled.test.tsx @@ -132,6 +132,12 @@ import { initRuntimeConfig, resetRuntimeConfigForTesting } from '../../../runtim // (`useInboxBell`); this harness serves no inbox feed, so the write is stubbed. vi.mock('../../../hooks/useInboxBell', () => ({ useInboxBell: () => ({ markRead: vi.fn() }) })); +// The deployment notice reads its own state from the server for an administrator +// (objectstack#30120); its fetch, its gate and its copy are pinned in +// `deployment-notice/__tests__/DeploymentNotice.test.tsx`, so it is stubbed here +// like every other source this page reads. +vi.mock('../../deployment-notice/DeploymentNotice', () => ({ DeploymentNotice: () => null })); + import { HomePage } from '../HomePage'; /** diff --git a/ui/packages/app-shell/src/console/home/__tests__/HomePage.authoringCapabilityGate.test.tsx b/ui/packages/app-shell/src/console/home/__tests__/HomePage.authoringCapabilityGate.test.tsx index e688c5d4522..eacaf8d13b8 100644 --- a/ui/packages/app-shell/src/console/home/__tests__/HomePage.authoringCapabilityGate.test.tsx +++ b/ui/packages/app-shell/src/console/home/__tests__/HomePage.authoringCapabilityGate.test.tsx @@ -144,6 +144,12 @@ vi.mock('../../../runtime-config', () => ({ // (`useInboxBell`); this harness serves no inbox feed, so the write is stubbed. vi.mock('../../../hooks/useInboxBell', () => ({ useInboxBell: () => ({ markRead: vi.fn() }) })); +// The deployment notice reads its own state from the server for an administrator +// (objectstack#30120); its fetch, its gate and its copy are pinned in +// `deployment-notice/__tests__/DeploymentNotice.test.tsx`, so it is stubbed here +// like every other source this page reads. +vi.mock('../../deployment-notice/DeploymentNotice', () => ({ DeploymentNotice: () => null })); + import { HomePage } from '../HomePage'; /* ── The `_drafts` double (objectui#7307) ───────────────────────────────────── diff --git a/ui/packages/app-shell/src/console/home/__tests__/HomePage.greetingAndApps-12601.test.tsx b/ui/packages/app-shell/src/console/home/__tests__/HomePage.greetingAndApps-12601.test.tsx index 716a0ae4f59..15e43b98531 100644 --- a/ui/packages/app-shell/src/console/home/__tests__/HomePage.greetingAndApps-12601.test.tsx +++ b/ui/packages/app-shell/src/console/home/__tests__/HomePage.greetingAndApps-12601.test.tsx @@ -150,6 +150,11 @@ vi.mock('../../../preview/usePublishAllDrafts', () => ({ // Home marks an opened notification read through the bell's own write // (`useInboxBell`); this harness serves no inbox feed, so the write is stubbed. vi.mock('../../../hooks/useInboxBell', () => ({ useInboxBell: () => ({ markRead: vi.fn() }) })); +// The deployment notice reads its own state from the server for an administrator +// (objectstack#30120); its fetch, its gate and its copy are pinned in +// `deployment-notice/__tests__/DeploymentNotice.test.tsx`, so it is stubbed here +// like every other source this page reads. +vi.mock('../../deployment-notice/DeploymentNotice', () => ({ DeploymentNotice: () => null })); import { HomePage } from '../HomePage'; diff --git a/ui/packages/app-shell/src/console/home/__tests__/HomePage.inboxLinksTarget.test.tsx b/ui/packages/app-shell/src/console/home/__tests__/HomePage.inboxLinksTarget.test.tsx index 014adca8a49..eb54c9e9677 100644 --- a/ui/packages/app-shell/src/console/home/__tests__/HomePage.inboxLinksTarget.test.tsx +++ b/ui/packages/app-shell/src/console/home/__tests__/HomePage.inboxLinksTarget.test.tsx @@ -138,6 +138,12 @@ vi.mock('../../../runtime-config', () => ({ // (`useInboxBell`); this harness serves no inbox feed, so the write is stubbed. vi.mock('../../../hooks/useInboxBell', () => ({ useInboxBell: () => ({ markRead: vi.fn() }) })); +// The deployment notice reads its own state from the server for an administrator +// (objectstack#30120); its fetch, its gate and its copy are pinned in +// `deployment-notice/__tests__/DeploymentNotice.test.tsx`, so it is stubbed here +// like every other source this page reads. +vi.mock('../../deployment-notice/DeploymentNotice', () => ({ DeploymentNotice: () => null })); + import { HomePage } from '../HomePage'; /* ── The `_drafts` double (objectui#7307) ───────────────────────────────────── diff --git a/ui/packages/app-shell/src/console/home/__tests__/HomePage.launcherHiddenApps-26838.test.tsx b/ui/packages/app-shell/src/console/home/__tests__/HomePage.launcherHiddenApps-26838.test.tsx index 13b9306abdd..795beaae3e1 100644 --- a/ui/packages/app-shell/src/console/home/__tests__/HomePage.launcherHiddenApps-26838.test.tsx +++ b/ui/packages/app-shell/src/console/home/__tests__/HomePage.launcherHiddenApps-26838.test.tsx @@ -136,6 +136,11 @@ vi.mock('../../../preview/usePublishAllDrafts', () => ({ // Home marks an opened notification read through the bell's own write // (`useInboxBell`); this harness serves no inbox feed, so the write is stubbed. vi.mock('../../../hooks/useInboxBell', () => ({ useInboxBell: () => ({ markRead: vi.fn() }) })); +// The deployment notice reads its own state from the server for an administrator +// (objectstack#30120); its fetch, its gate and its copy are pinned in +// `deployment-notice/__tests__/DeploymentNotice.test.tsx`, so it is stubbed here +// like every other source this page reads. +vi.mock('../../deployment-notice/DeploymentNotice', () => ({ DeploymentNotice: () => null })); import { HomePage } from '../HomePage'; import { LauncherAppsProvider } from '../../../hooks/useLauncherApps'; diff --git a/ui/packages/app-shell/src/console/home/__tests__/HomePage.marketplaceDisabled.test.tsx b/ui/packages/app-shell/src/console/home/__tests__/HomePage.marketplaceDisabled.test.tsx index 10f5b4b8152..8db2a5ca452 100644 --- a/ui/packages/app-shell/src/console/home/__tests__/HomePage.marketplaceDisabled.test.tsx +++ b/ui/packages/app-shell/src/console/home/__tests__/HomePage.marketplaceDisabled.test.tsx @@ -111,6 +111,12 @@ import { initRuntimeConfig, resetRuntimeConfigForTesting } from '../../../runtim // (`useInboxBell`); this harness serves no inbox feed, so the write is stubbed. vi.mock('../../../hooks/useInboxBell', () => ({ useInboxBell: () => ({ markRead: vi.fn() }) })); +// The deployment notice reads its own state from the server for an administrator +// (objectstack#30120); its fetch, its gate and its copy are pinned in +// `deployment-notice/__tests__/DeploymentNotice.test.tsx`, so it is stubbed here +// like every other source this page reads. +vi.mock('../../deployment-notice/DeploymentNotice', () => ({ DeploymentNotice: () => null })); + import { HomePage } from '../HomePage'; /** A `GET /api/v1/runtime/config` answer, as the server sends it. */ diff --git a/ui/packages/app-shell/src/console/home/__tests__/HomePage.myWork-12602.test.tsx b/ui/packages/app-shell/src/console/home/__tests__/HomePage.myWork-12602.test.tsx index 4122ff11775..5cf24d69e1e 100644 --- a/ui/packages/app-shell/src/console/home/__tests__/HomePage.myWork-12602.test.tsx +++ b/ui/packages/app-shell/src/console/home/__tests__/HomePage.myWork-12602.test.tsx @@ -148,6 +148,11 @@ vi.mock('../../../views/studio-design/packages-io', async (importOriginal) => ({ // Home marks an opened notification read through the bell's own write // (`useInboxBell`); this harness serves no inbox feed, so the write is stubbed. vi.mock('../../../hooks/useInboxBell', () => ({ useInboxBell: () => ({ markRead: vi.fn() }) })); +// The deployment notice reads its own state from the server for an administrator +// (objectstack#30120); its fetch, its gate and its copy are pinned in +// `deployment-notice/__tests__/DeploymentNotice.test.tsx`, so it is stubbed here +// like every other source this page reads. +vi.mock('../../deployment-notice/DeploymentNotice', () => ({ DeploymentNotice: () => null })); import { HomePage } from '../HomePage'; diff --git a/ui/packages/app-shell/src/console/home/__tests__/HomePage.noAgentsBuildChip-26614.test.tsx b/ui/packages/app-shell/src/console/home/__tests__/HomePage.noAgentsBuildChip-26614.test.tsx index bad6d913caa..0aaaa3d6c78 100644 --- a/ui/packages/app-shell/src/console/home/__tests__/HomePage.noAgentsBuildChip-26614.test.tsx +++ b/ui/packages/app-shell/src/console/home/__tests__/HomePage.noAgentsBuildChip-26614.test.tsx @@ -138,6 +138,12 @@ vi.mock('../../../preview/usePublishAllDrafts', () => ({ // (`useInboxBell`); this harness serves no inbox feed, so the write is stubbed. vi.mock('../../../hooks/useInboxBell', () => ({ useInboxBell: () => ({ markRead: vi.fn() }) })); +// The deployment notice reads its own state from the server for an administrator +// (objectstack#30120); its fetch, its gate and its copy are pinned in +// `deployment-notice/__tests__/DeploymentNotice.test.tsx`, so it is stubbed here +// like every other source this page reads. +vi.mock('../../deployment-notice/DeploymentNotice', () => ({ DeploymentNotice: () => null })); + import { HomePage } from '../HomePage'; import { STUDIO_NEW_PACKAGE_PATH } from '../../../views/studio-design/studioNewPackage'; diff --git a/ui/packages/app-shell/src/console/home/__tests__/HomePage.pendingDraftsPackageName-13136.test.tsx b/ui/packages/app-shell/src/console/home/__tests__/HomePage.pendingDraftsPackageName-13136.test.tsx index e046f03e990..b6943db4360 100644 --- a/ui/packages/app-shell/src/console/home/__tests__/HomePage.pendingDraftsPackageName-13136.test.tsx +++ b/ui/packages/app-shell/src/console/home/__tests__/HomePage.pendingDraftsPackageName-13136.test.tsx @@ -120,6 +120,12 @@ vi.mock('../../../preview/usePublishAllDrafts', () => ({ // (`useInboxBell`); this harness serves no inbox feed, so the write is stubbed. vi.mock('../../../hooks/useInboxBell', () => ({ useInboxBell: () => ({ markRead: vi.fn() }) })); +// The deployment notice reads its own state from the server for an administrator +// (objectstack#30120); its fetch, its gate and its copy are pinned in +// `deployment-notice/__tests__/DeploymentNotice.test.tsx`, so it is stubbed here +// like every other source this page reads. +vi.mock('../../deployment-notice/DeploymentNotice', () => ({ DeploymentNotice: () => null })); + import { HomePage } from '../HomePage'; const DRAFTS_ROUTE = '/api/v1/meta/_drafts'; diff --git a/ui/packages/app-shell/src/console/home/__tests__/HomePage.phoneTapTargets-29147.test.tsx b/ui/packages/app-shell/src/console/home/__tests__/HomePage.phoneTapTargets-29147.test.tsx index 22420261b29..69d3c2c6814 100644 --- a/ui/packages/app-shell/src/console/home/__tests__/HomePage.phoneTapTargets-29147.test.tsx +++ b/ui/packages/app-shell/src/console/home/__tests__/HomePage.phoneTapTargets-29147.test.tsx @@ -133,6 +133,12 @@ vi.mock('../../../preview/usePublishAllDrafts', () => ({ vi.mock('../../../hooks/useInboxBell', () => ({ useInboxBell: () => ({ markRead: vi.fn() }) })); vi.mock('../../../layout/ActivityObjectLabel', () => ({ ActivityObjectLabel: () => null })); +// The deployment notice reads its own state from the server for an administrator +// (objectstack#30120); its fetch, its gate and its copy are pinned in +// `deployment-notice/__tests__/DeploymentNotice.test.tsx`, so it is stubbed here +// like every other source this page reads. +vi.mock('../../deployment-notice/DeploymentNotice', () => ({ DeploymentNotice: () => null })); + import { HomePage } from '../HomePage'; /** Enough apps that the strip folds the rest behind "More". */ diff --git a/ui/packages/app-shell/src/console/home/__tests__/HomePage.recentObjectReads-29121.test.tsx b/ui/packages/app-shell/src/console/home/__tests__/HomePage.recentObjectReads-29121.test.tsx index df446b27658..976912b1fdc 100644 --- a/ui/packages/app-shell/src/console/home/__tests__/HomePage.recentObjectReads-29121.test.tsx +++ b/ui/packages/app-shell/src/console/home/__tests__/HomePage.recentObjectReads-29121.test.tsx @@ -128,6 +128,12 @@ vi.mock('../../../preview/usePublishAllDrafts', () => ({ // (`useInboxBell`); this harness serves no inbox feed, so the write is stubbed. vi.mock('../../../hooks/useInboxBell', () => ({ useInboxBell: () => ({ markRead: vi.fn() }) })); +// The deployment notice reads its own state from the server for an administrator +// (objectstack#30120); its fetch, its gate and its copy are pinned in +// `deployment-notice/__tests__/DeploymentNotice.test.tsx`, so it is stubbed here +// like every other source this page reads. +vi.mock('../../deployment-notice/DeploymentNotice', () => ({ DeploymentNotice: () => null })); + import { HomePage } from '../HomePage'; const CRM = { name: 'crm', label: 'HotCRM', icon: 'Building2', _packageId: 'com.acme.crm', navigation: [] }; diff --git a/ui/packages/app-shell/src/console/home/__tests__/HomePage.recentlyAccessed-12604.test.tsx b/ui/packages/app-shell/src/console/home/__tests__/HomePage.recentlyAccessed-12604.test.tsx index a4acdfb8983..502232c7d38 100644 --- a/ui/packages/app-shell/src/console/home/__tests__/HomePage.recentlyAccessed-12604.test.tsx +++ b/ui/packages/app-shell/src/console/home/__tests__/HomePage.recentlyAccessed-12604.test.tsx @@ -137,6 +137,12 @@ vi.mock('../../../preview/usePublishAllDrafts', () => ({ // (`useInboxBell`); this harness serves no inbox feed, so the write is stubbed. vi.mock('../../../hooks/useInboxBell', () => ({ useInboxBell: () => ({ markRead: vi.fn() }) })); +// The deployment notice reads its own state from the server for an administrator +// (objectstack#30120); its fetch, its gate and its copy are pinned in +// `deployment-notice/__tests__/DeploymentNotice.test.tsx`, so it is stubbed here +// like every other source this page reads. +vi.mock('../../deployment-notice/DeploymentNotice', () => ({ DeploymentNotice: () => null })); + import { HomePage } from '../HomePage'; const CRM = { name: 'crm', label: 'HotCRM', icon: 'Building2', _packageId: 'com.acme.crm' }; diff --git a/ui/packages/app-shell/src/views/DashboardView.tsx b/ui/packages/app-shell/src/views/DashboardView.tsx index 382934df81e..c7ab02d4648 100644 --- a/ui/packages/app-shell/src/views/DashboardView.tsx +++ b/ui/packages/app-shell/src/views/DashboardView.tsx @@ -37,6 +37,8 @@ import { useAdapter } from '../providers/AdapterProvider.js'; import { useBundledItem } from '../providers/bundledReads.js'; import { readDashboardWidgetObjects } from '../console/appObjectScope.js'; import { useObjectTranslation, useSafeTranslate } from '@object-ui/i18n'; +import { DeploymentNotice } from '../console/deployment-notice/DeploymentNotice.js'; +import { useInSetupApp } from '../console/setupApp.js'; /** * One refresh of this dashboard's data: the `onRefresh` handed to @@ -99,6 +101,8 @@ export function DashboardView({ dataSource }: { dataSource?: any }) { // Drill "escape hatch": lets the drill drawers open an object's full list page. const openRecordList = useOpenRecordList(); const { dashboardName } = useParams<{ dashboardName: string }>(); + // Setup's own System Overview (`platform-objects`' `system_overview`). + const isSystemOverview = useInSetupApp() && dashboardName === 'system_overview'; const { showDebug } = useMetadataInspector(); const adapter = useAdapter(); const { t } = useObjectTranslation(); @@ -377,6 +381,10 @@ export function DashboardView({ dataSource }: { dataSource?: any }) { )} /> + {/* objectstack#30120 — Setup's System Overview opens on the deployment + notice: administrators, production only. */} + {isSystemOverview && } + {/* ── Main area ────────────────────────────────────────────── */}
diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index c7d4f69a3ed..9ec3533eaaf 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -4104,6 +4104,30 @@ const ar = { cloudWorkspaceTimezoneNotice: { seeded: "تم تعيين المنطقة الزمنية للبيئة إلى {{zone}} وفقًا لمتصفحك عند إنشائها. يمكنك تغييرها من الإعدادات → التوطين.", }, + deploymentNotice: { + title: "يحتفظ هذا الخادم ببياناتك وأسرارك على قرصه الخاص", + titleLow: "أكمل إعداد هذا النشر", + intro: "لم يحدد أحد مكان احتفاظ هذا النشر الإنتاجي بها. سلّم الإعدادات أدناه إلى من يدير الخادم، حتى لا يضيع شيء عند استبداله.", + finding: { + databaseLocalFile: "بيانات عملك ملف على هذا الخادم ({{file}}). استبدال الخادم أو قرصه يضيّعها.", + authSecretGenerated: "أُنشئ سر تسجيل الدخول على هذا الخادم. إذا ضاع، يُسجَّل خروج الجميع.", + dataKeyFile: "المفتاح الذي يشفّر كلمات المرور ومفاتيح API المحفوظة ملف على هذا الخادم ({{file}}). إذا ضاع، لا يمكن قراءتها بعد ذلك.", + fileStorageLocal: "تُحفظ الملفات المرفوعة على قرص هذا الخادم.", + emailNotConfigured: "لم تُعدّ أي خدمة بريد إلكتروني، لذلك لا تُرسل الدعوات وإعادة تعيين كلمات المرور بالبريد الإلكتروني.", + }, + copy: { + button: "نسخ الإعدادات", + database: "قاعدة بياناتك، مثل postgres://user:pass@db-host:5432/app، أو file:/path/on/a/kept/volume/app.db. البيانات حاليًا في {{file}}: انقلها قبل التبديل.", + carryOver: "احتفظ بالقيمة الحالية: محتوى {{file}}", + newValue: "قيمة عشوائية طويلة، تبقى نفسها عند كل إعادة تشغيل، مثل ناتج: openssl rand -hex 32", + }, + copied: "تم النسخ", + hide: "إخفاء", + hideNoAuditLog: "لا يحتفظ هذا النشر بسجل تدقيق، لذلك لا يمكن إخفاء الإشعار.", + hideFailed: "تعذّر إخفاء الإشعار. حاول مرة أخرى.", + hiddenLine: "إشعار النشر مخفي. ما يذكره لا يزال قائمًا.", + showAgain: "إظهار مرة أخرى", + }, aiApprovals: { title: "موافقات الذكاء الاصطناعي", description: "إجراءات اقترحها وكيل ذكاء اصطناعي وتحتاج إلى مراجعة بشرية قبل تنفيذها.", diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index 8eacefe52c8..dfc4ff9b646 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -3795,6 +3795,30 @@ const de = { cloudWorkspaceTimezoneNotice: { seeded: "Die Zeitzone der Umgebung wurde beim Erstellen anhand Ihres Browsers auf {{zone}} festgelegt. Sie können sie unter Einstellungen → Lokalisierung ändern.", }, + deploymentNotice: { + title: "Dieser Server speichert Ihre Daten und Geheimnisse auf seiner eigenen Festplatte", + titleLow: "Schließen Sie die Einrichtung dieser Bereitstellung ab", + intro: "Niemand hat festgelegt, wo diese Produktionsbereitstellung sie aufbewahrt. Geben Sie die folgenden Einstellungen an die Person weiter, die den Server betreibt, damit beim Ersetzen nichts verloren geht.", + finding: { + databaseLocalFile: "Ihre Geschäftsdaten sind eine Datei auf diesem Server ({{file}}). Wird der Server oder seine Festplatte ersetzt, gehen sie verloren.", + authSecretGenerated: "Das Anmeldegeheimnis wurde auf diesem Server erzeugt. Geht es verloren, werden alle abgemeldet.", + dataKeyFile: "Der Schlüssel, der gespeicherte Passwörter und API-Schlüssel verschlüsselt, ist eine Datei auf diesem Server ({{file}}). Geht er verloren, können sie nicht mehr gelesen werden.", + fileStorageLocal: "Hochgeladene Dateien liegen auf der Festplatte dieses Servers.", + emailNotConfigured: "Es ist kein E-Mail-Dienst eingerichtet, daher werden Einladungen und Passwort-Zurücksetzungen nicht per E-Mail versendet.", + }, + copy: { + button: "Einstellungen kopieren", + database: "Ihre Datenbank, z. B. postgres://user:pass@db-host:5432/app, oder file:/path/on/a/kept/volume/app.db. Derzeit liegen die Daten in {{file}}: Übertragen Sie sie vor dem Umstellen.", + carryOver: "Den aktuellen Wert übernehmen: den Inhalt von {{file}}", + newValue: "Ein langer Zufallswert, bei jedem Neustart derselbe, z. B. die Ausgabe von: openssl rand -hex 32", + }, + copied: "Kopiert", + hide: "Ausblenden", + hideNoAuditLog: "Diese Bereitstellung führt kein Audit-Protokoll, daher kann der Hinweis nicht ausgeblendet werden.", + hideFailed: "Der Hinweis konnte nicht ausgeblendet werden. Versuchen Sie es erneut.", + hiddenLine: "Der Bereitstellungshinweis ist ausgeblendet. Was er beschreibt, gilt weiterhin.", + showAgain: "Wieder anzeigen", + }, aiApprovals: { title: "KI-Genehmigungen", description: "Von einem KI-Agenten vorgeschlagene Aktionen, die vor der Ausführung eine menschliche Prüfung benötigen.", diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index 038308ea631..adc2ad853e8 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -4597,6 +4597,36 @@ const en = { cloudWorkspaceTimezoneNotice: { seeded: 'The environment timezone was set to {{zone}} from your browser when the environment was created. You can change it in Settings → Localization.', }, + // objectstack#30120 — the deployment notice: what a production deployment + // keeps on its own disk because nobody set OS_DATABASE_URL, OS_AUTH_SECRET or + // OS_SECRET_KEY, shown to administrators on the home page and at the top of + // Setup's System Overview (`app-shell` `DeploymentNotice`). `{{file}}` is a + // path on the server, printed verbatim. `copy.*` are comment lines of the + // variables the copy button puts on the clipboard. + deploymentNotice: { + title: "This server keeps your data and secrets on its own disk", + titleLow: "Finish setting up this deployment", + intro: "Nobody set where this production deployment keeps them. Hand the settings below to whoever runs the server, so that replacing it loses nothing.", + finding: { + databaseLocalFile: "Your business data is a file on this server ({{file}}). Replacing the server or its disk loses it.", + authSecretGenerated: "The sign-in secret was generated on this server. If it is lost, everyone is signed out.", + dataKeyFile: "The key that encrypts saved passwords and API keys is a file on this server ({{file}}). If it is lost, they can no longer be read.", + fileStorageLocal: "Uploaded files are kept on this server's disk.", + emailNotConfigured: "No email service is set up, so invitations and password resets are not sent by email.", + }, + copy: { + button: "Copy the settings", + database: "Your database, e.g. postgres://user:pass@db-host:5432/app, or file:/path/on/a/kept/volume/app.db. Today the data is in {{file}}: move it over before you switch.", + carryOver: "Carry the current value over: the contents of {{file}}", + newValue: "A long random value, the same on every restart, e.g. the output of: openssl rand -hex 32", + }, + copied: "Copied", + hide: "Hide", + hideNoAuditLog: "This deployment keeps no audit log, so the notice cannot be hidden.", + hideFailed: "The notice could not be hidden. Try again.", + hiddenLine: "The deployment notice is hidden. What it states still applies.", + showAgain: "Show again", + }, // The AI HITL approval inbox (`@object-ui/plugin-chatbot`'s // `AiPendingActionsInbox`) — objectui#7173. Its four relative-time phrases // are NOT here: it borrows `detail.justNow` / `minutesAgo` / `hoursAgo` / diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index 296e74383fe..f1eb870c340 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -3873,6 +3873,30 @@ const es = { cloudWorkspaceTimezoneNotice: { seeded: "La zona horaria del entorno se configuró en {{zone}} a partir de su navegador al crearlo. Puede cambiarla en Configuración → Localización.", }, + deploymentNotice: { + title: "Este servidor guarda sus datos y secretos en su propio disco", + titleLow: "Termine de configurar esta implementación", + intro: "Nadie definió dónde guarda estos elementos esta implementación de producción. Entregue la configuración siguiente a quien administre el servidor, para que reemplazarlo no haga perder nada.", + finding: { + databaseLocalFile: "Sus datos de negocio son un archivo en este servidor ({{file}}). Si se reemplaza el servidor o su disco, se pierden.", + authSecretGenerated: "El secreto de inicio de sesión se generó en este servidor. Si se pierde, se cierra la sesión de todos.", + dataKeyFile: "La clave que cifra las contraseñas y claves de API guardadas es un archivo en este servidor ({{file}}). Si se pierde, ya no se pueden leer.", + fileStorageLocal: "Los archivos subidos se guardan en el disco de este servidor.", + emailNotConfigured: "No hay ningún servicio de correo configurado, así que las invitaciones y los restablecimientos de contraseña no se envían por correo.", + }, + copy: { + button: "Copiar la configuración", + database: "Su base de datos, por ejemplo postgres://user:pass@db-host:5432/app, o file:/path/on/a/kept/volume/app.db. Hoy los datos están en {{file}}: tráigalos antes de cambiar.", + carryOver: "Conserve el valor actual: el contenido de {{file}}", + newValue: "Un valor aleatorio largo, el mismo en cada reinicio, por ejemplo la salida de: openssl rand -hex 32", + }, + copied: "Copiado", + hide: "Ocultar", + hideNoAuditLog: "Esta implementación no lleva registro de auditoría, así que el aviso no se puede ocultar.", + hideFailed: "No se pudo ocultar el aviso. Inténtelo de nuevo.", + hiddenLine: "El aviso de la implementación está oculto. Lo que indica sigue vigente.", + showAgain: "Mostrar de nuevo", + }, aiApprovals: { title: "Aprobaciones de IA", description: "Acciones propuestas por un agente de IA que necesitan revisión humana antes de ejecutarse.", diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index bfc8d5db061..af39080c2a0 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -3869,6 +3869,30 @@ const fr = { cloudWorkspaceTimezoneNotice: { seeded: "Le fuseau horaire de l’environnement a été défini sur {{zone}} d’après votre navigateur lors de sa création. Vous pouvez le modifier dans Paramètres → Localisation.", }, + deploymentNotice: { + title: "Ce serveur conserve vos données et vos secrets sur son propre disque", + titleLow: "Terminez la configuration de ce déploiement", + intro: "Personne n'a défini où ce déploiement de production les conserve. Transmettez les paramètres ci-dessous à la personne qui gère le serveur, afin que son remplacement ne fasse rien perdre.", + finding: { + databaseLocalFile: "Vos données métier sont un fichier sur ce serveur ({{file}}). Remplacer le serveur ou son disque les fait perdre.", + authSecretGenerated: "Le secret de connexion a été généré sur ce serveur. S'il est perdu, tout le monde est déconnecté.", + dataKeyFile: "La clé qui chiffre les mots de passe et les clés d'API enregistrés est un fichier sur ce serveur ({{file}}). Si elle est perdue, ils ne peuvent plus être lus.", + fileStorageLocal: "Les fichiers téléversés sont conservés sur le disque de ce serveur.", + emailNotConfigured: "Aucun service d'e-mail n'est configuré : les invitations et les réinitialisations de mot de passe ne sont pas envoyées par e-mail.", + }, + copy: { + button: "Copier les paramètres", + database: "Votre base de données, par exemple postgres://user:pass@db-host:5432/app, ou file:/path/on/a/kept/volume/app.db. Aujourd'hui, les données sont dans {{file}} : transférez-les avant de basculer.", + carryOver: "Reprendre la valeur actuelle : le contenu de {{file}}", + newValue: "Une longue valeur aléatoire, identique à chaque redémarrage, par exemple la sortie de : openssl rand -hex 32", + }, + copied: "Copié", + hide: "Masquer", + hideNoAuditLog: "Ce déploiement ne tient pas de journal d'audit, l'avis ne peut donc pas être masqué.", + hideFailed: "L'avis n'a pas pu être masqué. Réessayez.", + hiddenLine: "L'avis de déploiement est masqué. Ce qu'il indique s'applique toujours.", + showAgain: "Afficher à nouveau", + }, aiApprovals: { title: "Approbations IA", description: "Actions proposées par un agent IA qui nécessitent une validation humaine avant exécution.", diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index d5b0ac5b3ea..3c1c894fb8a 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -3797,6 +3797,30 @@ const ja = { cloudWorkspaceTimezoneNotice: { seeded: "環境のタイムゾーンは、作成時にブラウザーに合わせて {{zone}} に設定されました。「設定 → ローカリゼーション」で変更できます。", }, + deploymentNotice: { + title: "このサーバーは、データとシークレットを自身のディスクに保存しています", + titleLow: "このデプロイの設定を完了してください", + intro: "この本番デプロイでは、それらの保存先が誰にも設定されていません。下の設定をサーバーの運用担当者に渡してください。そうすれば、サーバーを置き換えても何も失われません。", + finding: { + databaseLocalFile: "業務データはこのサーバー上のファイル({{file}})です。サーバーやそのディスクを置き換えると失われます。", + authSecretGenerated: "サインイン用のシークレットはこのサーバー上で生成されました。失われると、全員がサインアウトされます。", + dataKeyFile: "保存されたパスワードと API キーを暗号化するキーは、このサーバー上のファイル({{file}})です。失われると、それらは読み取れなくなります。", + fileStorageLocal: "アップロードされたファイルはこのサーバーのディスクに保存されています。", + emailNotConfigured: "メールサービスが設定されていないため、招待やパスワードリセットはメールで送信されません。", + }, + copy: { + button: "設定をコピー", + database: "データベース。例: postgres://user:pass@db-host:5432/app、または file:/path/on/a/kept/volume/app.db。現在データは {{file}} にあります。切り替える前に移行してください。", + carryOver: "現在の値を引き継ぐ: {{file}} の内容", + newValue: "再起動しても変わらない長いランダムな値。例: openssl rand -hex 32 の出力", + }, + copied: "コピーしました", + hide: "非表示", + hideNoAuditLog: "このデプロイには監査ログがないため、この通知を非表示にできません。", + hideFailed: "通知を非表示にできませんでした。もう一度お試しください。", + hiddenLine: "デプロイの通知は非表示です。記載の内容は引き続き当てはまります。", + showAgain: "再表示", + }, aiApprovals: { title: "AI 承認", description: "AI エージェントが提案した操作です。実行前に人による確認が必要です。", diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index a2fe8011f8f..8677f7a22a7 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -3794,6 +3794,30 @@ const ko = { cloudWorkspaceTimezoneNotice: { seeded: "환경 시간대는 생성 시 브라우저에 맞춰 {{zone}}(으)로 설정되었습니다. 설정 → 현지화에서 변경할 수 있습니다.", }, + deploymentNotice: { + title: "이 서버는 데이터와 시크릿을 자체 디스크에 보관하고 있습니다", + titleLow: "이 배포의 설정을 완료하세요", + intro: "이 프로덕션 배포에서는 이것들을 어디에 보관할지 아무도 지정하지 않았습니다. 아래 설정을 서버를 운영하는 담당자에게 전달하면 서버를 교체해도 아무것도 잃지 않습니다.", + finding: { + databaseLocalFile: "업무 데이터가 이 서버의 파일({{file}})입니다. 서버나 디스크를 교체하면 잃게 됩니다.", + authSecretGenerated: "로그인 시크릿이 이 서버에서 생성되었습니다. 잃어버리면 모든 사용자가 로그아웃됩니다.", + dataKeyFile: "저장된 비밀번호와 API 키를 암호화하는 키가 이 서버의 파일({{file}})입니다. 잃어버리면 더 이상 읽을 수 없습니다.", + fileStorageLocal: "업로드된 파일이 이 서버의 디스크에 보관됩니다.", + emailNotConfigured: "이메일 서비스가 설정되지 않아 초대와 비밀번호 재설정이 이메일로 전송되지 않습니다.", + }, + copy: { + button: "설정 복사", + database: "데이터베이스. 예: postgres://user:pass@db-host:5432/app 또는 file:/path/on/a/kept/volume/app.db. 현재 데이터는 {{file}}에 있습니다. 전환하기 전에 옮기세요.", + carryOver: "현재 값 이어서 사용: {{file}}의 내용", + newValue: "재시작해도 동일하게 유지되는 긴 임의 값. 예: openssl rand -hex 32의 출력", + }, + copied: "복사됨", + hide: "숨기기", + hideNoAuditLog: "이 배포에는 감사 로그가 없어 알림을 숨길 수 없습니다.", + hideFailed: "알림을 숨기지 못했습니다. 다시 시도하세요.", + hiddenLine: "배포 알림이 숨겨져 있습니다. 알림의 내용은 여전히 해당됩니다.", + showAgain: "다시 표시", + }, aiApprovals: { title: "AI 승인", description: "AI 에이전트가 제안한 작업으로, 실행 전에 사람의 검토가 필요합니다.", diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt.ts index c50265a414e..fb30cd87751 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -3868,6 +3868,30 @@ const pt = { cloudWorkspaceTimezoneNotice: { seeded: "O fuso horário do ambiente foi definido como {{zone}} com base no seu navegador quando ele foi criado. Você pode alterá-lo em Configurações → Localização.", }, + deploymentNotice: { + title: "Este servidor guarda seus dados e segredos no próprio disco", + titleLow: "Conclua a configuração desta implantação", + intro: "Ninguém definiu onde esta implantação de produção os guarda. Entregue as configurações abaixo a quem administra o servidor, para que substituí-lo não faça perder nada.", + finding: { + databaseLocalFile: "Seus dados de negócio são um arquivo neste servidor ({{file}}). Substituir o servidor ou o disco dele faz perdê-los.", + authSecretGenerated: "O segredo de login foi gerado neste servidor. Se ele se perder, todos serão desconectados.", + dataKeyFile: "A chave que criptografa as senhas e chaves de API salvas é um arquivo neste servidor ({{file}}). Se ela se perder, elas não poderão mais ser lidas.", + fileStorageLocal: "Os arquivos enviados ficam no disco deste servidor.", + emailNotConfigured: "Nenhum serviço de e-mail está configurado, então convites e redefinições de senha não são enviados por e-mail.", + }, + copy: { + button: "Copiar as configurações", + database: "Seu banco de dados, por exemplo postgres://user:pass@db-host:5432/app, ou file:/path/on/a/kept/volume/app.db. Hoje os dados estão em {{file}}: transfira-os antes de trocar.", + carryOver: "Mantenha o valor atual: o conteúdo de {{file}}", + newValue: "Um valor aleatório longo, o mesmo a cada reinício, por exemplo a saída de: openssl rand -hex 32", + }, + copied: "Copiado", + hide: "Ocultar", + hideNoAuditLog: "Esta implantação não mantém log de auditoria, então o aviso não pode ser ocultado.", + hideFailed: "Não foi possível ocultar o aviso. Tente novamente.", + hiddenLine: "O aviso da implantação está oculto. O que ele informa continua valendo.", + showAgain: "Mostrar novamente", + }, aiApprovals: { title: "Aprovações de IA", description: "Ações propostas por um agente de IA que precisam de revisão humana antes da execução.", diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index 0cab7d5abad..7a4252c05db 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -3960,6 +3960,30 @@ const ru = { cloudWorkspaceTimezoneNotice: { seeded: "Часовой пояс окружения был задан по вашему браузеру при его создании: {{zone}}. Его можно изменить в разделе «Настройки → Локализация».", }, + deploymentNotice: { + title: "Этот сервер хранит ваши данные и секреты на собственном диске", + titleLow: "Завершите настройку этого развёртывания", + intro: "Никто не указал, где это рабочее развёртывание их хранит. Передайте настройки ниже тому, кто обслуживает сервер, чтобы при его замене ничего не потерялось.", + finding: { + databaseLocalFile: "Ваши рабочие данные — это файл на этом сервере ({{file}}). При замене сервера или его диска они будут потеряны.", + authSecretGenerated: "Секрет входа был создан на этом сервере. Если он потеряется, все пользователи выйдут из системы.", + dataKeyFile: "Ключ, которым шифруются сохранённые пароли и API-ключи, — это файл на этом сервере ({{file}}). Если он потеряется, их больше нельзя будет прочитать.", + fileStorageLocal: "Загруженные файлы хранятся на диске этого сервера.", + emailNotConfigured: "Почтовая служба не настроена, поэтому приглашения и сброс пароля не отправляются по почте.", + }, + copy: { + button: "Скопировать настройки", + database: "Ваша база данных, например postgres://user:pass@db-host:5432/app, или file:/path/on/a/kept/volume/app.db. Сейчас данные находятся в {{file}}: перенесите их до переключения.", + carryOver: "Сохраните текущее значение: содержимое {{file}}", + newValue: "Длинное случайное значение, одинаковое при каждом перезапуске, например вывод команды: openssl rand -hex 32", + }, + copied: "Скопировано", + hide: "Скрыть", + hideNoAuditLog: "Это развёртывание не ведёт журнал аудита, поэтому уведомление нельзя скрыть.", + hideFailed: "Не удалось скрыть уведомление. Попробуйте ещё раз.", + hiddenLine: "Уведомление о развёртывании скрыто. То, о чём в нём говорится, по-прежнему актуально.", + showAgain: "Показать снова", + }, aiApprovals: { title: "Согласования ИИ", description: "Действия, предложенные ИИ-агентом: перед выполнением их должен проверить человек.", diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index a91341eb7c9..cf8aea5598d 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -4001,6 +4001,30 @@ const zh = { cloudWorkspaceTimezoneNotice: { seeded: '环境时区在创建时已按你的浏览器设为 {{zone}},可在「设置 → 本地化」中修改。', }, + deploymentNotice: { + title: '这台服务器把你的数据和密钥存放在它自己的磁盘上', + titleLow: '完成此部署的设置', + intro: '没有人为此生产部署指定这些内容的存放位置。请把下面的设置交给负责运行服务器的人,这样更换服务器时不会丢失任何东西。', + finding: { + databaseLocalFile: '你的业务数据是这台服务器上的一个文件({{file}})。更换服务器或其磁盘会丢失这些数据。', + authSecretGenerated: '登录密钥是在这台服务器上生成的。一旦丢失,所有人都会被登出。', + dataKeyFile: '用于加密已保存密码和 API 密钥的密钥是这台服务器上的一个文件({{file}})。一旦丢失,这些内容将无法再读取。', + fileStorageLocal: '上传的文件保存在这台服务器的磁盘上。', + emailNotConfigured: '尚未设置邮件服务,因此邀请和密码重置不会通过邮件发送。', + }, + copy: { + button: '复制设置', + database: '你的数据库,例如 postgres://user:pass@db-host:5432/app,或 file:/path/on/a/kept/volume/app.db。目前数据在 {{file}} 中:切换前请先迁移过去。', + carryOver: '沿用当前值:{{file}} 的内容', + newValue: '一个较长的随机值,每次重启都保持不变,例如以下命令的输出:openssl rand -hex 32', + }, + copied: '已复制', + hide: '隐藏', + hideNoAuditLog: '此部署没有审计日志,因此无法隐藏该提示。', + hideFailed: '无法隐藏该提示,请重试。', + hiddenLine: '部署提示已隐藏,其所述情况仍然存在。', + showAgain: '重新显示', + }, aiApprovals: { title: 'AI 审批', description: 'AI 智能体提出的操作,需要人工审核后才会执行。',