@celilo/cli 1.12.0 → 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CELILO_CORE_MODULES.md +2 -1
  2. package/CELILO_SUBSYSTEMS.md +21 -2
  3. package/package.json +3 -3
  4. package/src/capabilities/public-web-helpers.test.ts +12 -6
  5. package/src/capabilities/public-web-publish.test.ts +24 -13
  6. package/src/capabilities/validation.test.ts +31 -0
  7. package/src/cli/commands/alerts-list.ts +16 -1
  8. package/src/cli/commands/backup-list.test.ts +82 -1
  9. package/src/cli/commands/backup-list.ts +113 -4
  10. package/src/cli/commands/console-get-chain.test.ts +96 -0
  11. package/src/cli/commands/console.ts +130 -0
  12. package/src/cli/commands/module-list.ts +3 -41
  13. package/src/cli/commands/notify-config.test.ts +79 -0
  14. package/src/cli/commands/notify-config.ts +13 -2
  15. package/src/cli/commands/system-ensure-fleet-key.ts +52 -0
  16. package/src/cli/completion.ts +6 -0
  17. package/src/cli/index.ts +31 -1
  18. package/src/console/closure.test.ts +322 -0
  19. package/src/console/closure.ts +294 -0
  20. package/src/console/control-plane-boundary.test.ts +75 -0
  21. package/src/console/projection.test.ts +293 -0
  22. package/src/console/projection.ts +364 -0
  23. package/src/db/schema.ts +19 -14
  24. package/src/hooks/broker.test.ts +4 -6
  25. package/src/hooks/capability-loader-control-plane-api.test.ts +124 -0
  26. package/src/hooks/capability-loader.ts +67 -10
  27. package/src/hooks/executor.test.ts +85 -4
  28. package/src/hooks/executor.ts +164 -9
  29. package/src/hooks/hook-jail-unreachability.test.ts +173 -0
  30. package/src/hooks/hook-state-dir.test.ts +14 -2
  31. package/src/hooks/hook-timeout.test.ts +2 -4
  32. package/src/hooks/hook-trespass.test.ts +50 -5
  33. package/src/hooks/jail.test.ts +370 -0
  34. package/src/hooks/jail.ts +491 -0
  35. package/src/hooks/mount-set.ts +24 -0
  36. package/src/hooks/test-fixtures/jail-probe-hook.ts +59 -0
  37. package/src/manifest/contracts/v1.ts +22 -1
  38. package/src/manifest/schema.ts +35 -0
  39. package/src/manifest/validate.test.ts +142 -0
  40. package/src/manifest/validate.ts +126 -4
  41. package/src/module/import.test.ts +116 -0
  42. package/src/module/import.ts +73 -1
  43. package/src/module/packaging/audit.ts +103 -1
  44. package/src/module/packaging/classify-module-path.test.ts +36 -0
  45. package/src/module/packaging/package-rules.ts +18 -0
  46. package/src/module/web-root.ts +35 -0
  47. package/src/policy/capability-shape-baseline.ts +8 -0
  48. package/src/policy/module-business-baseline.ts +26 -2
  49. package/src/policy/module-script-scan.test.ts +22 -0
  50. package/src/policy/module-script-scan.ts +32 -0
  51. package/src/services/alerting/observed-health.ts +71 -0
  52. package/src/services/api-principal-enrolment.test.ts +252 -0
  53. package/src/services/api-principal-enrolment.ts +158 -0
  54. package/src/services/audit/backups.ts +10 -1
  55. package/src/services/backup-create.ts +33 -7
  56. package/src/services/backup-metadata.ts +19 -11
  57. package/src/services/celilo-mgmt-hooks.test.ts +38 -79
  58. package/src/services/consumer-cleanup.ts +31 -5
  59. package/src/services/fleet-key.test.ts +47 -0
  60. package/src/services/fleet-key.ts +75 -0
  61. package/src/services/instance-ops.test.ts +302 -0
  62. package/src/services/instance-ops.ts +292 -0
  63. package/src/services/module-instances.test.ts +428 -42
  64. package/src/services/module-instances.ts +219 -26
  65. package/src/services/restore-from-file.ts +6 -5
  66. package/src/services/system-state-stage.test.ts +165 -0
  67. package/src/services/system-state-stage.ts +196 -0
@@ -55,10 +55,11 @@ Each entry: `module id` — what it is — **provides** / **requires** capabilit
55
55
  ## Celilo's own infrastructure (self-hosted)
56
56
 
57
57
  - **celilo-mgmt** — the celilo management server itself, deployed as a module (replaces install.sh + `system init`; ships daemon, runs migrations, self-registers). **provides:** `celilo_event_bus`, `celilo_module_deploy_worker`. **requires:** `cross_module_read`. See `openspec/specs/management-as-module/spec.md`.
58
- - **celilo-registry** — module registry server (Cargo sparse protocol); stores `.netapp` files, serves index + search/download API. On install it provisions a confidential introspection OIDC client via `idp.create_oidc_client` (SECURE_MODULE_PUBLISH.md §5[D-A]) and converges its issuer + introspection endpoint + creds onto the box for RFC 7662 token verification. **provides:** `registry_publish`. **requires:** `public_web`, `dns_registrar`, `idp`.
58
+ - **celilo-registry** — module registry server (Cargo sparse protocol); stores `.netapp` files, serves index + search/download API. On install it provisions a confidential introspection OIDC client via `idp.create_oidc_client` (SECURE_MODULE_PUBLISH.md §5[D-A]) and converges its issuer + introspection endpoint + creds onto the box for RFC 7662 token verification. Its `sweep_revisions` hook is the store's ONLY delete path — `yank` flips a boolean in the index entry and frees nothing, so before it existed every build revision ever published was retained forever and a `+N` revision is auto-assigned on every publish. The production store reached 18 GB of which ~93% was superseded history, filled a 20 GB disk and took the release pipeline down. The sweep keeps the newest `sweep_keep_build_revisions` (default 1) of EVERY release, so no release can disappear and a manual rollback still works — that is what makes the hourly `timer.tick.1h` subscription defensible. It removes the index line BEFORE the payload, because the download route reads the payload file directly and never consults the index: that order leaves an interrupted sweep with an unlisted-but-served version rather than a listed one that 404s, and the orphan it leaves is reclaimed by the next run. On demand with `celilo module run-hook celilo-registry sweep_revisions` (add `dry_run=true` to see the plan). **provides:** `registry_publish`. **requires:** `public_web`, `dns_registrar`, `idp`.
59
59
  - **celilo-apt-repo** — Debian apt repository (reprepro + Bun HTTP server) serving the celilo `.deb` at apt.celilo.computer. **provides:** `apt_publish`. **requires:** `public_web`, `dns_registrar`.
60
60
  - **signal** — bidirectional Signal transport for alerts and deploy-interview questions; runs signal-cli in daemon mode with its JSON-RPC socket bound to the host's own address (never public) and **`--receive-mode=manual`**, which is load-bearing: signal-cli's default (`on-start`) leaves the daemon permanently receiving, so it drains every reply into an SSE stream nothing is attached to and REFUSES celilo's `receive` call — replies arrive and are unreadable, while `send` and every health check keep passing. Enrolled as a SECONDARY DEVICE of an existing Signal account rather than registering its own number — Signal blocks most VOIP ranges and bans bot-ish registrations. Recipient addresses live on celilo routes, not in module config, so adding a person never requires a redeploy. Runs on x86_64 and aarch64. `libsignal-client` ships no linux-aarch64 native, so celilo builds one (`modules/signal/build/`) and installs it as a `libsignal-jni` .deb on ARM hosts; x86_64 uses the JAR's bundled native. **provides:** `notification` (`send`, `receive`). **requires:** no capabilities — a transport that depended on the proxy, registrar or firewall could not tell you those were broken — and a system in the **`secure-mgmt`** zone: it holds a linked Signal account (the operator's own messaging identity and keys), and its job is to observe every tier while depending on none, which is what the control-plane zone is for. See `openspec/changes/add-alerting/`.
61
61
  - **celilo-website** — public docs site (static Astro) served via Caddy on celilo.computer. **requires:** `public_web`, `dns_registrar`.
62
+ - **celilo-web-console** — the read-mostly operator console: the fleet drawn by zone, a live module roster, and the alert and backup state that says what needs attention. **Not yet deployed** — the manifest, the SPA, the console server, the read verbs and the `control_plane_api` capability exist; the `on_install` that enrols the principal, and the e2e suite, do not. It holds **no database handle**: every fact it displays arrives over the SSH remote API as a principal whose grants derive from the `COMMANDS` registry's read-only classifier, because an in-process console would hold the whole database including the encrypted secret store and be constrained only by its own code. **⚠️ `secure-mgmt`, and the placement is a security boundary rather than a preference:** celilo reaches into every data-plane zone by trust and no data-plane zone reaches back, so the ONLY client path in is the control-plane VPN. It registers no web route, requests no port forward, takes no `natIp` or ingress address, and gets no DNS record resolvable from a data-plane zone. `apps/celilo/src/console/control-plane-boundary.test.ts` asserts that on every pull request rather than only in e2e, because the way it erodes is a one-line manifest edit adding `private_web` so someone can reach it without bringing up the VPN. It offers no deploy, uninstall, pause, restore or config control: those can raise an interview question, a browser has no responder, and they are ABSENT rather than disabled. **provides:** nothing. **requires:** `idp`, `control_plane_api`. See `openspec/changes/web-ui-console/`.
62
63
  - **celilo-canary** — a deliberately minimal nginx serving one static page in **`dmz`**, permanently deployed, whose health check IS the assertion that the deploy path still works. Nothing about it is interesting except that it is *always there*: modules already deployed keep running when the pipeline breaks, so without a canary a regression in IPAM allocation, Terraform provisioning, Ansible convergence or capability wiring stays invisible until the next real deploy — which is exactly when nobody wants to discover it. One deploy exercises all four, plus a live cross-module capability call. **Fleet-only, and the absence is the design**: it registers one route through `private_web` and requires nothing that reaches the perimeter, so there is no public record, no ACME certificate and no port forward. Its `health_check` probes nginx over systemd and its `/healthz` endpoint with **`probeHttp`, from the management server** rather than by SSHing in and running `curl` — the production `ubuntu-22.04-standard` LXC ships no curl, so the old form could not tell a missing binary from a dead service, and reaching the canary at its own address additionally catches a service bound only to loopback. **requires:** `private_web`.
63
64
 
64
65
  ## Git forge & CI pipeline
@@ -45,7 +45,7 @@ see `openspec/specs/`. Companion doc: [CELILO_CORE_MODULES.md](./CELILO_CORE_MOD
45
45
  ## Capability system (cross-module data & functions)
46
46
 
47
47
  - **Define a capability function** — `packages/capabilities/src/define-capability-function.ts` — `defineCapabilityFunction`.
48
- - **Canonical capability registry** — `packages/capabilities/src/capability-registry.ts` — `KNOWN_CAPABILITY_NAMES` (the authoritative list), `CapabilityRegistry` type. Public surface: `packages/capabilities/src/index.ts`.
48
+ - **Canonical capability registry** — `packages/capabilities/src/capability-registry.ts` — `KNOWN_CAPABILITY_NAMES` (the authoritative list), `CapabilityRegistry` type. Also `PROVIDER_VIEW_CAPABILITIES` / `isProviderView` / `ProviderViewCapabilities`: the registry entries (`web_routes`, `firewall_registry`) that are framework-injected PROVIDER VIEWS rather than capabilities a module can declare — in the registry so audits and type checks reach them, named separately so author-facing surfaces leave them out. `ProviderViewCapabilities` is what puts them on a hook's `capabilities` object. Public surface: `packages/capabilities/src/index.ts`.
49
49
  - **Loader (wires provider factories into hook contexts)** — `apps/celilo/src/hooks/capability-loader.ts` — `loadCapabilityFunctions`, `resolveFirewallNatIp`.
50
50
  - **Which provider a consumer actually resolved to (`capability_bindings`)** — `apps/celilo/src/services/capability-bindings.ts` — `recordCapabilityBinding`, `listCapabilityBindings`, `withBindingRecord`, written from the single return of `loadCapabilityFunctions`. The `capabilities` table is provider-side only, so celilo could answer "who COULD provide this" and never "who does this module actually use"; every caller needing the second question rebuilt the same approximation (the consumer's `requires` + `optional` crossed with `capabilities`), which names every provider a module MIGHT have bound to. `tango-nexus` declares four optional capabilities, is deployed against one off-fleet host, and that approximation carries three edges that do not exist. **The CALL is the binding, not the resolution** — the loader injects every registered capability regardless of what the consumer declared (its own comment: "not just required ones"), so recording what it resolves is a SUPERSET of even the permissive set. A consumer's hook invoking a method is the only event that separates the optional it uses from the ones it merely declares, which is why this matters most for the capabilities that mint nothing (`external_web`, `idp`, `source_forge`, `registry_publish`, `control_plane_vpn`, `apt_publish`) and therefore leave no artefact anywhere else. Recorded on ATTEMPT rather than success: a consumer that called a provider and got an error is standing on that provider, and hiding the edge exactly when the call fails hides it when it matters most. Unique on (consumer, capability) so a redeploy re-asserts and a provider swap rewrites in place; `bound_at` is LAST-SEEN, so a binding last exercised forty deploys ago is a queryable fact rather than a silent lie. Cascades on the consumer. It runs BESIDE `planConsumerCleanup`, never narrowing it — cleanup must still reach every provider that might hold minted state, including one whose binding row was never written. See celilo#1072.
51
51
  - **Ledger wrappers (stateful capabilities)** — `apps/celilo/src/services/dns-registrations.ts` (`withDnsRegistrationLedger`), `apps/celilo/src/services/dns-internal-records.ts` (`withDnsInternalLedger`).
@@ -180,7 +180,8 @@ runner seam (`execRunner` real / `createMockRunner` for tests) lives in
180
180
  ## Hooks & deploy
181
181
 
182
182
  - **Hook executor / ABI** — `apps/celilo/src/hooks/executor.ts` (`invokeHook`, `executeHookScript`, `checkRequiredCapabilities`), types in `apps/celilo/src/hooks/types.ts` (`HookContext`, `HookDefinition`, `HookName`). Named-hook runner: `apps/celilo/src/hooks/run-named-hook.ts`. Manifest hook config: `apps/celilo/src/hooks/load-hook-config.ts`.
183
- - **Hook process boundary (a hook is a program celilo RUNS)** — `apps/celilo/src/hooks/hook-protocol.ts` (the NDJSON frame union, `HOOK_PROTOCOL_VERSION`, `serializeError`/`deserializeError`), `apps/celilo/src/hooks/broker.ts` (`startBroker`, `capabilityShape`), `apps/celilo/src/hooks/hook-runner.ts` (the spawned shim — the ONLY thing that `import()`s module code). `executeHookScript` spawns `bun hook-runner.ts` over a Unix socket instead of importing; the nine `invokeHook` call sites and `defineHook` are unchanged. **The broker does not know what a capability is**: it sends a shape descriptor built by the same own-string-key walk `wrapWithLogging` does (functions → `methods`, everything else → `data`, which is where `stampProvider`'s `providerModuleId` lives), and the shim rebuilds forwarding proxies from it — so an optional method a provider did not implement is absent rather than present-and-throwing, and `if (cap.registerTrustedSource)` keeps answering correctly. A socket rather than stdout because module scripts spawn subprocesses and a grandchild writing to fd 1 would corrupt the frame stream. Two consequences worth knowing: the child's environment is an **allow-list** (`hookChildEnv` — `PATH`, `HOME`, `LANG`, `TZ`, `TMPDIR`, `CELILO_HOOK_*`, `CELILO_DEBUG`), so a hook reading any other operator variable now gets `undefined`; and a timeout is a real SIGTERM-then-SIGKILL with the broker refusing further capability calls, replacing a `Promise.race` that cancelled nothing and let a "timed out" hook go on writing DNS and firewall state (celilo#1003). Capability PROVIDER factories still load in-process — they ARE the broker's implementation. Stage 1 of `openspec/changes/hook-process-boundary`; it claims the environment and the process, not the filesystem (a hook can still compute the default master-key path) and not the network.
183
+ - **Hook process boundary (a hook is a program celilo RUNS)** — `apps/celilo/src/hooks/hook-protocol.ts` (the NDJSON frame union, `HOOK_PROTOCOL_VERSION`, `serializeError`/`deserializeError`), `apps/celilo/src/hooks/broker.ts` (`startBroker`, `capabilityShape`), `apps/celilo/src/hooks/hook-runner.ts` (the spawned shim — the ONLY thing that `import()`s module code). `executeHookScript` spawns `bun hook-runner.ts` over a Unix socket instead of importing; the nine `invokeHook` call sites and `defineHook` are unchanged. **The broker does not know what a capability is**: it sends a shape descriptor built by the same own-string-key walk `wrapWithLogging` does (functions → `methods`, everything else → `data`, which is where `stampProvider`'s `providerModuleId` lives), and the shim rebuilds forwarding proxies from it — so an optional method a provider did not implement is absent rather than present-and-throwing, and `if (cap.registerTrustedSource)` keeps answering correctly. A socket rather than stdout because module scripts spawn subprocesses and a grandchild writing to fd 1 would corrupt the frame stream. Two consequences worth knowing: the child's environment is an **allow-list** (`hookChildEnv` — `PATH`, `HOME`, `LANG`, `TZ`, `TMPDIR`, `CELILO_HOOK_*`, `CELILO_DEBUG`), so a hook reading any other operator variable now gets `undefined`; and a timeout is a real SIGTERM-then-SIGKILL with the broker refusing further capability calls, replacing a `Promise.race` that cancelled nothing and let a "timed out" hook go on writing DNS and firewall state (celilo#1003). Capability PROVIDER factories still load in-process — they ARE the broker's implementation. Stages 1 and 2 of `openspec/changes/hook-process-boundary`; the filesystem is claimed by the jail below, the network is not claimed at all (stage 3, D12).
184
+ - **Hook jail (a hook sees the paths it was given, and nothing else)** — `apps/celilo/src/hooks/mount-set.ts` (`deriveMountSet`, `toBwrapArgs`, `forbiddenPaths` — PURE, computes a filesystem view and touches nothing) and `apps/celilo/src/hooks/jail.ts` (`detectJailBackend`, `planJailedSpawn`, `realpathRequest`, `runtimeModulePathsFor`, `recordJailMode` — the half that touches the machine). `executeHookScript` spawns the shim under `bwrap` with the module's tree read-only, `state/` + `generated/` + this run's `screenshots/<run>` read-write on top of it, each contract-declared path input at its declared access, the broker's socket directory, the runtime and the `node_modules` the shim resolves through, `~/.ssh` read-only (stage 2 only — stage 3 drops it, D12), and `/tmp` a fresh tmpfs FIRST so it cannot erase the socket or a staged input. **The acceptance criterion is absence, not a check**: the module store and the data directory are simply not bound, so `master.key` and a sibling module give `ENOENT`. The set is DERIVED — a module cannot ask for more — and `bwrap` itself is never in it, because the AppArmor profile grants `userns` to `/usr/bin/bwrap` for anyone on the box (design D9). Backend detection RUNS bubblewrap rather than looking for it (four different denials all leave the binary in place), and the resulting mode is written to `hook-jail-mode.json` beside celilo's other per-machine state rather than logged, so a host that stops jailing is readable. `CELILO_HOOK_JAIL` = `auto` (default) / `required` / `off`. No backend on macOS yet (task 4.8), and the hook then runs unjailed with the mode recorded.
184
185
  - **Deploy pipeline** — `apps/celilo/src/services/module-deploy.ts`.
185
186
  - **DNS provider backfill** (re-emit registrations when a provider deploys) — `apps/celilo/src/services/dns-provider-backfill.ts` — `isDnsInternalProvider`, `backfillProviderDns`.
186
187
  - **Base-module aspects (fan-out across the fleet)** — `apps/celilo/src/services/aspect-runner.ts` — `planAspectFanOut`, `runAspectFanOut`, `maybeRunAspectForTrigger`. Aspect content lives in `modules/<m>/base-module-aspect/` (e.g. knot-unbound-internal, technitium). Two directions, with DELIBERATELY OPPOSITE failure semantics:
@@ -289,6 +290,8 @@ SUGGESTS a `backup.schedule`; the operator's override decides; celilo runs it on
289
290
  the resolved cadence and alerts when it stops.
290
291
 
291
292
  - **Creation** — `apps/celilo/src/services/backup-create.ts` — `createModuleBackup` (invokes the module's `on_backup` hook into an encrypted envelope), `createSystemStateBackup` (celilo.db), `findBackupEligibleModules` (returns each module's operator config alongside its manifest, because every caller has to resolve a policy out of the two together), `isBackupDue`. Storage destinations: `backup-storage.ts`. Restore: `backup-restore.ts`.
293
+ - **Staging celilo's own state** — `apps/celilo/src/services/system-state-stage.ts` — `stageSystemState(rootDir)` and `snapshotDatabase(src, dest)`. Before invoking `on_backup` on a `cross_module_read` module, celilo COPIES its own state (`celilo.db` snapshot, `master.key`, the fleet `ssh/`, and `module_src/<id>/` for every module's lean source) into a directory it creates, and passes the path as the contract input `system_state_root`. ⚠️ **This is what lets celilo back ITSELF up without exempting celilo-mgmt from the hook jail** (`openspec/changes/hook-process-boundary`, design D9b): the hook never READ those bytes, it copied them into `backup_dir`, so the framework does the copying and celilo's data directory is in no hook's mount set. Same allow-list as `cross_module_root` — one privilege, one list to audit. ⚠️ **`snapshotDatabase` uses a readonly connection + `serialize()`, never `copyFileSync`**: celilo runs the DB in WAL mode, so the main file is routinely one near-empty page while all the real data sits in `celilo.db-wal` (measured: 4 KB main against 832 KB WAL), and a plain copy produces a snapshot that opens cleanly, contains NOTHING, and is installed by restore. Gate: `services/system-state-stage.test.ts` writes 200 uncheckpointed rows and asserts they survive.
294
+ - **The fleet SSH key (one accessor)** — `apps/celilo/src/services/fleet-key.ts` — `ensureFleetKey()` (idempotent mint, returns the public half) and `getFleetSshDir()`. Surfaced as `celilo system ensure-fleet-key`, which also records `ssh.public_key`. celilo-mgmt's `on_install` used to mint the keypair itself inside celilo's data directory — a WRITE into the one directory the jail exists to keep out of the mount set (design D9b), which staging does not cover because staging covers copies OUT. ⚠️ **Never re-key**: an existing key is reused, because regenerating strands every machine whose `authorized_keys` holds the old public half, and a redeploy calls this every time. `getFleetSshDir()` follows the DB (`dirname(getDbPath())/.ssh`), not `getDataDir()` — the same directory on a deb install and different when `CELILO_DB_PATH` is overridden; both the mint and `restore-from-file.ts`'s laydown read that one helper so they cannot drift apart.
292
295
  - **Cadence (one accessor)** — `apps/celilo/src/services/backup-schedule.ts` — `effectiveBackupSchedule(manifest, override)`. The manifest SUGGESTS; the operator's `backup_schedule` row in `module_configs` decides; absent from both means `daily`, NOT `manual` (opting out takes an explicit `manual`). Resolution happens at READ time — nothing is materialised at install or deploy — so a corrected manifest reaches every install that has not overridden. The one-argument form was DELETED rather than kept as an overload (Rule 3.9): it would let an un-updated reader compile clean while silently ignoring overrides. Both the freshness audit and the backup sweep must read cadence through this one function, or a module can be alerted-on but never backed up.
293
296
  - **The cadence type** — `apps/celilo/src/services/cadence.ts` — `Cadence` (`{minutes}` | `'manual'`), `parseCadence` / `formatCadence` / `cadenceMs`, and `cadenceSchema({floorMinutes})`. One spelling set for every cadence in celilo: a named period (`hourly`/`daily`/`weekly`/`monthly`), a duration (`6h`, `90m`, `3d`), or `manual`. ⚠️ **The floors are DERIVED from the sweep ticks, never written down**: this file owns `BACKUP_SWEEP_PATTERN` / `ALERTING_SWEEP_PATTERN` and computes `BACKUP_CADENCE_FLOOR_MINUTES` / `MONITOR_INTERVAL_FLOOR_MINUTES` from them (the sweeps import their pattern from here), so changing a tick moves what it can serve in the same edit. A cadence finer than its sweep's tick is REFUSED, not coerced — accepting it leaves the operator believing they configured something that can silently never happen.
294
297
  - **Retention (one accessor)** — `apps/celilo/src/services/backup-retention.ts` — `effectiveBackupRetention(manifest, configs)`, `prunesNothing`, `identifyExpiredBackups`, `pruneBackupsForModule`. Two INDEPENDENT dimensions (copies, age), each resolved override → manifest → **unbounded**. ⚠️ **An unset dimension is unbounded, never a default bound.** `backup.retention` is an optional block, so a manifest omitting it prunes nothing at all and its inner `count: 7` / `max_age_days: 30` defaults never apply; if setting one dimension let the other fall back to those, an operator asking to keep 3 copies would silently arm a 30-day deletion on a module that had been keeping everything. Unbounded is `Infinity`, which `identifyExpiredBackups` needs no special case for. Gate: `services/backup-retention.test.ts`. The four sites that used to read `manifest.backup.retention` directly (`cli/commands/backup-sweep.ts`, `backup-create.ts`, `backup-prune.ts` twice) all go through the accessor — that duplication is what let the schedule readers drift.
@@ -345,12 +348,28 @@ Run any celilo command on celilo-mgr over SSH instead of screen-scraping `ssh <h
345
348
  - **Server** — `apps/celilo/src/api/serve.ts` (`apiServeMode`); the `celilo api-serve --principal=<id>` sshd forced-command entry point (dispatched in `apps/celilo/src/cli/index.ts`). Authorizes per principal, runs the command as a protocol-mode child, streams output, audits to stderr.
346
349
  - **Client** — `packages/core/src/remote-client.ts` (`@celilo/core`) — `resolveRemote` (`--remote <dest>` / `CELILO_REMOTE`), `runRemoteClient` (`ssh -T`, renders progress via the local ProgressDisplay, answers interviews via the `@celilo/cli-display` prompts). Refuses to prompt on a non-TTY stdin, replying `unanswerable` rather than submitting a default as if a human had chosen it.
347
350
  - **Access control** — `apps/celilo/src/services/api-access.ts` — `grantPrincipal`, `isAuthorized` (deny-by-default, `command:subcommand` grants), `renderAuthorizedKeys`. Table: `api_principals` (`apps/celilo/src/db/schema.ts`). CLI: `apps/celilo/src/cli/commands/api.ts` (`api grant|list|revoke|authorized-keys|key new`).
351
+ - **Principal enrolment for a module (`control_plane_api`)** — `apps/celilo/src/services/api-principal-enrolment.ts` — `enrolControlPlanePrincipal`, `revokeControlPlanePrincipal`, and `buildControlPlaneApi`, the method table a consuming module's hooks receive. The consumer generates an ed25519 pair on its own system and presents the public half; nothing here accepts a private key. Grants are DERIVED from `readOnlyGrants(COMMANDS)` and are not a parameter, so a caller cannot ask for more, and a write verb is never granted however it is named. **Framework-granted, so no module provides it** — enrolment writes celilo's own `api_principals` row and a module script may import nothing but `@celilo/capabilities`, which rules out celilo-mgmt as much as anyone else (`web-ui-console` D7b). Injected by `capability-loader.ts` ONLY for a module whose stored manifest declares it under `requires`/`optional`, unlike every other capability the loader hands out, and scoped to that module: a caller may not name a neighbour's principal. Contract: `packages/capabilities/src/control-plane-api.ts`.
348
352
  - **Mid-run interview bridge (`kind:daemon` responder)** — `apps/celilo/src/services/remote-responder.ts` — `startRemoteResponder` bridges bus `interview.required.*` ↔ wire.
349
353
  - **Server provisioning** — the `celilo-bootstrap` deb (`packaging/celilo-bootstrap/scripts/postinst`) creates the non-root `celilo-api` landing account + sshd; membership in the `celilo` group + `/etc/sudoers.d/celilo` (`!use_pty`) gives api-serve DB access via the wrapper's sudo-drop.
350
354
  - **Self-upgrade (apt)** — `celilo apt-upgrade` (`apps/celilo/src/cli/commands/apt-upgrade.ts`) upgrades the deb-installed `celilo`/`celilo-bootstrap` packages (`apt-get update` → `--only-upgrade install`) then spawns a fresh `celilo system migrate` (ISS-0100), then `celilo events restart-daemon` so the dispatcher actually runs the code just installed — a failure there fails the whole command and names which steps DID complete, because "upgraded" while the dispatcher serves stale code is the silent state celilo#604 documents. It's the RW target behind the MCP's registry-derived `celilo_apt_upgrade` tool; the celilo user's two apt invocations are scoped-sudo'd by `/etc/sudoers.d/celilo-apt-upgrade`, shipped by `celilo-bootstrap`. **This upgrades celilo ITSELF — not the modules it manages. For those, see Module auto-upgrade below; the two are routinely confused.**
351
355
  - **Module auto-upgrade (registry-poll CD)** — the *pull* half of continuous deployment: celilo-mgr polls the registry and upgrades opted-in modules unattended. Spec: `openspec/specs/module-auto-upgrade/spec.md`. Entry points: `apps/celilo/src/cli/commands/module-upgrade.ts` — `runRegistryPoll` (the `--poll` path), `selectPollTargets` (pure: `autoUpgrade && latest && change ∉ {up-to-date, ahead}`), `upgradeOneModule` (update → backup → deploy → verify), `needsPreUpgradeBackup`, `pickAutoUpgrade`/`pickUpgradePolicy` (both fail closed/safe); `classifyVersionChange` in `module-update.ts` (treats a registry `+N` revision as a patch); `resolveDeployPosture` in `apps/celilo/src/services/deploy-posture.ts`. Trigger: celilo-mgmt's `registry-poll` subscription (`modules/celilo-mgmt/manifest.yml`) on `timer.tick.15m` with handler **`celilo module upgrade --poll`** — the flag is REQUIRED, since the dispatcher appends the event id positionally and a bare handler would consume it as the optional module name (silent: 3108 deliveries, 0 successes). Operator controls are framework config keys settable on ANY module (`FRAMEWORK_CONFIG_KEYS` in `module-config.ts`): `auto_upgrade` (opt-in, default false) and `upgrade_policy` (`by-semver`|`always-safe`|`always-fast`), validated at set time because both readers fail open. ⚠️ `always-safe` guarantees safe *posture*, NOT a backup — `needsPreUpgradeBackup` also requires the TARGET manifest to declare an `on_backup` hook, else it warns and proceeds. Confirm a data-bearing module declares `on_backup` before enabling `auto_upgrade` on it. The *build* half (app CI publishing a `.netapp` on merge) is not yet shipped — `openspec/changes/build-bus-poll-cd`.
352
356
  - **MCP service (`@celilo/mcp`)** — `packages/mcp/src/` — an operator-facing stdio MCP server (official `@modelcontextprotocol/sdk`, bin `celilo-mcp`) that drives a remote celilo server over the Remote API for an AI client. Two-item config (`config.ts`: `server` + `defaultUser`, env or `~/.config/celilo-mcp/config.json`). Dual-principal auth (`auth.ts`: `celilo-mcp auth setup` enrolls read-only `celilo-mcp-ro` + full `celilo-mcp-rw` ed25519 keypairs, prints the exact `celilo api grant` lines the operator runs server-side). Transport (`transport.ts`): reuses `@celilo/core` `runRemoteClient`, selecting the principal by `ssh -i <key>` and capturing structured output. Tool surface is generated LIVE from the server's command registry — `registry-fetch.ts` fetches `celilo commands --json` (+ `service list --json` for configured providers) over the RO principal on connect; `tools-from-registry.ts` (pure) projects that into one tool per runnable leaf, grouped by top-level command (`celilo_module_*`, `celilo_proxmox_*`, …), each with a Zod input schema from the leaf's args/flags and a read/write tag → RO/RW routing, plus a generic `celilo_run` escape hatch. Auto-detect hides provider-gated groups (e.g. `celilo_proxmox_*` until a Proxmox service is configured) and re-detects on a timer, emitting `notifications/tools/list_changed` when the surface changes. Coverage gate (`tests/coverage.test.ts`) asserts every registry leaf maps to a tool. Composite RO troubleshooting tools (`troubleshoot.ts` pure correlation + `troubleshoot-tools.ts` thin adapters): `celilo_assess_module <id>` and `celilo_fleet_status` correlate `celilo audit --json` (the drift backbone) with the `module list --json` roster into a per-module / fleet-wide verdict. Design: `openspec/changes/celilo-mcp-service/proposal.md`. (Distinct from the dev/ops `@celilo/mcp-server` below.)
353
357
 
358
+ ## Web console (read-mostly operator UI)
359
+
360
+ The fleet drawn by zone, a live module roster, and the alert and backup state that says what needs attention. Design: `openspec/changes/web-ui-console/`. **Not yet deployed** — the SPA, its server, the console read verbs, the acknowledgement path and the `control_plane_api` capability exist; the module's `on_install` that CALLS that capability, and the e2e suite, do not. The capability issues only the derived read-only grants and has no parameter that could widen them (task 6.3b settled that deliberately), so acknowledgement renders a denial until an operator grants `alerts:ack` by hand.
361
+
362
+ - **Console read verbs** — `apps/celilo/src/cli/commands/console.ts` — `celilo console status` (the dashboard's single poll: zone order, per-module systems, observed health, backup freshness) and `celilo console get <module-id> [--depth N]` (one module's bounded capability closure). Both classify **read-only** under `readOnlyGrants()`, so the console's principal covers them with no hand-maintained list. The verbs are named from the read-verb vocabulary (`status`, `get`) rather than for prose, because the API authorises at two levels and classifies a leaf by its SUBCOMMAND token — `console roster` would read better and classify as a WRITE.
363
+ - **Console projection** — `apps/celilo/src/console/projection.ts` — the narrow payload, deliberately WITHOUT `manifestData`: `module list --json` returns 156 KB for 23 modules because it embeds every manifest blob, which is the wrong payload for a poll loop. Distinguishes *not deployed* from *not observed*; a module with no system is carried, not omitted.
364
+ - **Bounded capability closure** — `apps/celilo/src/console/closure.ts` — `computeClosure()` wraps `planConsumerCleanup()`'s edge rather than forking it, adding distance, optionality and cycle termination. Takes a bindings map (`capability_bindings`, celilo#1072) so the walk follows what a module has ACTUALLY called into; without one it follows declarations, which answers "what could this reach" rather than "what does this stand on".
365
+ - **Read-only classifier** — `packages/core/src/read-only-classifier.ts` — `READ_VERBS`, `isReadOnlyPath`, `opOf`, `flattenLeaves`, `readOnlyGrants()`. Lives beside `COMMANDS` because it is a property of the registry, and both the MCP server's `RO_GRANTS` and the console's principal derive from it. A new read verb is covered automatically; a new write verb is not.
366
+ - **SSH transport** — `packages/core/src/ssh-transport.ts` — `childProcessTransport`, `sshArgs`, `sshTransportWith`, and the exit reaper. Shared rather than copied because the reaper exists after 656 orphaned ssh clients threw celilo-mgr into MaxStartups throttling (celilo#921), and the console server has the same restart-freely lifecycle.
367
+ - **Console server** — `apps/console-server/src/upstream.ts` — holds **no database handle**; every fact arrives over the remote API as a principal granted the derived read ops plus `alerts:ack`. An interview is a FAILURE (a browser has no responder), and an unavailable read is reported with a reason (`denied` / `unknown-verb` / `unreachable` / `interview` / `malformed`) rather than returned as an empty result. Failures are not cached, so fixing a grant recovers on the next poll. `Upstream.run` is the one non-read: uncached, unparsed (the ack answers with a sentence), and it drops every cached read afterwards.
368
+ - **Console acknowledgement** — `apps/console-server/src/verbs.ts` (`readSession`, `ackAlert`), `apps/celilo/src/cli/commands/notify-config.ts` (`celilo person list --json`) — the console's ONLY write. `celilo alerts ack` falls back to `people[0]` when `--as` is absent, which in a browser would credit every acknowledgement to whoever sorts first, so `ackAlert` takes a required `person` and there is no path through it that omits the flag. The person comes from `readSession`, which resolves the identity provider's subject against `person list --json` (display name, then the `sub` claim, case-insensitive); a subject matching nobody resolves to null and the console draws no control. `ackedAt` is re-read from celilo's row rather than stamped by the console server, which is a different machine. Gates: `apps/console-server/tests/ack.test.ts` records the argv, so "refused but written anyway" is visible.
369
+ - **Protocol** — `packages/console-protocol/` — tsrpc definitions plus the generated `serviceProto`, shared by server and SPA. The repo's single documented exception to ESM-everywhere: no `type` field, because `tsrpc-cli proto` `require()`s the protocol sources. Gated by a test in an ESM directory that round-trips a call.
370
+ - **SPA** — `apps/console/src/` — Vite + React 19 + atom.io + tsrpc-browser. Three routes (`dashboard`, `alerts`, `backups`); a module's detail is a panel, not a fourth route. Visual constraints live in `shell.css` and four are asserted against the source by `tests/constraints.test.ts`, including "no control for any operation that can raise an interview". Every read goes through one `Panel` that separates *unavailable* from *empty*.
371
+ - **Zone topology renderer** — `packages/visualizer/src/gen/zone-topology.ts`, `topology-theme.ts`, `column-assignment.ts`, `render/topology-svg.ts` — bands by zone in trust order, columns assigned so a dependency edge falls as a vertical drop, orthogonal routing checked against every module rather than assumed clear, and a report naming anything it could not place. Tuning playground at `bun run dev` in `packages/visualizer`, route `/topology`; it exports a `TopologyTheme` JSON the console commits.
372
+
354
373
  ## E2E simulation
355
374
 
356
375
  - **cele2e harness** — `packages/e2e/src/` — `runner.ts`, `container-manager.ts` (`startNetwork`, `reconnectNetwork`), `network-builder.ts` (`NetworkBuilder`).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "1.12.0",
3
+ "version": "1.14.0",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -58,9 +58,9 @@
58
58
  "dependencies": {
59
59
  "@aws-sdk/client-s3": "^3.1109.0",
60
60
  "@aws-sdk/lib-storage": "^3.1101.0",
61
- "@celilo/capabilities": "^3.2.0",
61
+ "@celilo/capabilities": "^3.4.0",
62
62
  "@celilo/cli-display": "^0.2.0",
63
- "@celilo/core": "^0.9.1",
63
+ "@celilo/core": "^0.10.0",
64
64
  "@celilo/event-bus": "^0.6.0",
65
65
  "ajv": "^8.18.0",
66
66
  "drizzle-orm": "^0.36.4",
@@ -89,7 +89,6 @@ function basePublishRequest(
89
89
  ): PublishStaticSiteRequest {
90
90
  return {
91
91
  path: '/lunacycle',
92
- sourceDir: '/tmp/build/dist',
93
92
  ...overrides,
94
93
  };
95
94
  }
@@ -134,17 +133,24 @@ describe('validatePublishStaticSiteRequest', () => {
134
133
  expect(result.valid).toBe(true);
135
134
  });
136
135
 
137
- test('rejects empty sourceDir', () => {
138
- const result = validatePublishStaticSiteRequest(basePublishRequest({ sourceDir: '' }));
136
+ test('rejects a stale sourceDir, and says what to do instead', () => {
137
+ // Replaces 'rejects empty sourceDir'. The field is gone (D10 amendment), so
138
+ // the failure mode inverted: supplying one is now the error, and the
139
+ // message has to carry the directory move because that is not guessable
140
+ // from "unknown key".
141
+ const result = validatePublishStaticSiteRequest({
142
+ path: '/lunacycle',
143
+ sourceDir: '/tmp/site',
144
+ } as unknown as PublishStaticSiteRequest);
139
145
  expect(result.valid).toBe(false);
140
- expect(result.errors).toContain('sourceDir is required');
146
+ expect(result.errors.join(' ')).toContain('site/dist');
141
147
  });
142
148
 
143
149
  test('reports multiple errors at once', () => {
144
150
  const result = validatePublishStaticSiteRequest({
145
151
  path: '',
146
- sourceDir: '',
147
- });
152
+ sourceDir: '/tmp/site',
153
+ } as unknown as PublishStaticSiteRequest);
148
154
  expect(result.valid).toBe(false);
149
155
  expect(result.errors.length).toBeGreaterThanOrEqual(2);
150
156
  });
@@ -106,6 +106,7 @@ describe('publishStaticSite — clientConfig injection', () => {
106
106
  test('writes config.js into sourceDir before upload when clientConfig is provided', async () => {
107
107
  const { ops } = makeRouteOps();
108
108
  const cap = createPublicWeb({
109
+ webRoot: sourceDir,
109
110
  moduleId: 'lunacycle',
110
111
  logger: noopLogger,
111
112
  config: {
@@ -123,7 +124,6 @@ describe('publishStaticSite — clientConfig injection', () => {
123
124
 
124
125
  const result = await cap.publishStaticSite({
125
126
  path: '/lunacycle',
126
- sourceDir,
127
127
  clientConfig: {
128
128
  AUTHENTIK_URL: 'https://auth.example.com/application/o',
129
129
  CLIENT_ID: 'lunacycle-web',
@@ -150,6 +150,7 @@ describe('publishStaticSite — clientConfig injection', () => {
150
150
  test('does not write config.js when clientConfig is omitted', async () => {
151
151
  const { ops } = makeRouteOps();
152
152
  const cap = createPublicWeb({
153
+ webRoot: sourceDir,
153
154
  moduleId: 'lunacycle',
154
155
  logger: noopLogger,
155
156
  config: {
@@ -167,7 +168,6 @@ describe('publishStaticSite — clientConfig injection', () => {
167
168
 
168
169
  await cap.publishStaticSite({
169
170
  path: '/lunacycle',
170
- sourceDir,
171
171
  // no clientConfig
172
172
  });
173
173
 
@@ -177,6 +177,7 @@ describe('publishStaticSite — clientConfig injection', () => {
177
177
  test('registers the route as static and records moduleId/path', async () => {
178
178
  const { ops, routes } = makeRouteOps();
179
179
  const cap = createPublicWeb({
180
+ webRoot: sourceDir,
180
181
  moduleId: 'lunacycle',
181
182
  logger: noopLogger,
182
183
  config: {
@@ -194,7 +195,6 @@ describe('publishStaticSite — clientConfig injection', () => {
194
195
 
195
196
  await cap.publishStaticSite({
196
197
  path: '/lunacycle',
197
- sourceDir,
198
198
  });
199
199
 
200
200
  const stored = routes.find((r) => r.path === '/lunacycle');
@@ -204,9 +204,16 @@ describe('publishStaticSite — clientConfig injection', () => {
204
204
  expect(stored?.slug).toBe('lunacycle');
205
205
  });
206
206
 
207
- test('fails fast when sourceDir does not exist', async () => {
207
+ test('fails fast, and names the path to ship, when the module has no web root', async () => {
208
+ // The publish path's one unrecoverable input. It used to arrive on the
209
+ // request as `sourceDir`; core now resolves it (D10 amendment), so the
210
+ // missing-directory case moves onto the capability's construction.
211
+ //
212
+ // It must throw rather than upload nothing: a zero-file upload succeeds,
213
+ // writes a content hash over an empty release, and serves a blank page.
208
214
  const { ops } = makeRouteOps();
209
215
  const cap = createPublicWeb({
216
+ webRoot: '/nonexistent/path/that/should/not/exist',
210
217
  moduleId: 'lunacycle',
211
218
  logger: noopLogger,
212
219
  config: {
@@ -222,18 +229,19 @@ describe('publishStaticSite — clientConfig injection', () => {
222
229
  dnsManagedDomains: ['www.example.com'],
223
230
  });
224
231
 
225
- await expect(
226
- cap.publishStaticSite({
227
- path: '/lunacycle',
228
- sourceDir: '/nonexistent/path/that/should/not/exist',
229
- clientConfig: { FOO: 'bar' },
230
- }),
231
- ).rejects.toThrow('sourceDir does not exist');
232
+ const attempt = cap.publishStaticSite({
233
+ path: '/lunacycle',
234
+ clientConfig: { FOO: 'bar' },
235
+ });
236
+ await expect(attempt).rejects.toThrow('no built site at');
237
+ // The operator's next move is a directory move, so the error has to name it.
238
+ await expect(attempt).rejects.toThrow('site/dist');
232
239
  });
233
240
 
234
241
  test('fails validation before touching the filesystem', async () => {
235
242
  const { ops } = makeRouteOps();
236
243
  const cap = createPublicWeb({
244
+ webRoot: sourceDir,
237
245
  moduleId: 'lunacycle',
238
246
  logger: noopLogger,
239
247
  config: {
@@ -252,7 +260,6 @@ describe('publishStaticSite — clientConfig injection', () => {
252
260
  await expect(
253
261
  cap.publishStaticSite({
254
262
  path: '', // bad — caught by validator before any side effects
255
- sourceDir,
256
263
  }),
257
264
  ).rejects.toThrow('Invalid publishStaticSite request');
258
265
 
@@ -355,6 +362,7 @@ describe('auto-logging — end-to-end through createPublicWeb', () => {
355
362
  const { ops } = makeRouteOps();
356
363
 
357
364
  const cap = createPublicWeb({
365
+ webRoot: sourceDir,
358
366
  moduleId: 'lunacycle',
359
367
  logger,
360
368
  config: {
@@ -389,6 +397,7 @@ describe('auto-logging — end-to-end through createPublicWeb', () => {
389
397
  const { ops } = makeRouteOps();
390
398
 
391
399
  const cap = createPublicWeb({
400
+ webRoot: sourceDir,
392
401
  moduleId: 'lunacycle',
393
402
  logger,
394
403
  config: {
@@ -424,6 +433,7 @@ describe('auto-logging — end-to-end through createPublicWeb', () => {
424
433
  const { ops } = makeRouteOps();
425
434
 
426
435
  const cap = createPublicWeb({
436
+ webRoot: sourceDir,
427
437
  moduleId: 'lunacycle',
428
438
  logger,
429
439
  config: {
@@ -439,7 +449,7 @@ describe('auto-logging — end-to-end through createPublicWeb', () => {
439
449
  dnsManagedDomains: ['www.example.com'],
440
450
  });
441
451
 
442
- await cap.publishStaticSite({ path: '/lunacycle', sourceDir });
452
+ await cap.publishStaticSite({ path: '/lunacycle' });
443
453
 
444
454
  const messageTexts = messages.map((m) => m.message);
445
455
  // The high-level call itself logs.
@@ -459,6 +469,7 @@ describe('auto-logging — end-to-end through createPublicWeb', () => {
459
469
  const { ops } = makeRouteOps();
460
470
 
461
471
  const cap = createPublicWeb({
472
+ webRoot: sourceDir,
462
473
  moduleId: 'lunacycle',
463
474
  logger,
464
475
  config: {
@@ -228,6 +228,37 @@ describe('Capability Access Validation', () => {
228
228
  expect(result.success).toBe(true);
229
229
  });
230
230
 
231
+ test('a framework-granted capability needs no providing module', async () => {
232
+ // This is the breakage, not a hypothetical. `control_plane_api` has no
233
+ // provider module and never will — celilo grants it (web-ui-console D7b) —
234
+ // so the console's manifest, already merged, hit the refusal below and
235
+ // could not be imported at all. Note the db here is the SAME one that
236
+ // fails the test after this one: the difference is entirely which
237
+ // capability is asked for.
238
+ const manifest: ModuleManifest = {
239
+ celilo_contract: '1.0',
240
+ id: 'celilo-web-console',
241
+ name: 'Celilo Web Console',
242
+ version: '1.0.0',
243
+ description: 'Console',
244
+ requires: {
245
+ capabilities: [{ name: 'control_plane_api', version: '1.0.0' }],
246
+ },
247
+ provides: { capabilities: [] },
248
+ variables: { owns: [], imports: [] },
249
+ };
250
+
251
+ const noProviderDb = {
252
+ prepare: () => ({
253
+ get: () => undefined,
254
+ }),
255
+ } as unknown as Database;
256
+
257
+ const result = await validateCapabilityAccess(manifest, noProviderDb);
258
+
259
+ expect(result.success).toBe(true);
260
+ });
261
+
231
262
  test('should return error when required capability not found', async () => {
232
263
  const manifest: ModuleManifest = {
233
264
  celilo_contract: '1.0',
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import { getDb } from '../../db/client';
10
+ import { people } from '../../db/schema';
10
11
  import { renderAlertTable, toAlertRow } from '../../services/alerting/format';
11
12
  import { listMonitors } from '../../services/alerting/monitors';
12
13
  import { policyForAlert } from '../../services/alerting/notify-deps';
@@ -23,10 +24,21 @@ export async function handleAlertsList(
23
24
 
24
25
  if (flags.json) {
25
26
  const monitorsById = new Map(listMonitors(db).map((m) => [m.id, m.target]));
27
+ const peopleById = new Map(
28
+ db
29
+ .select()
30
+ .from(people)
31
+ .all()
32
+ .map((p) => [p.id, p.name]),
33
+ );
26
34
  return {
27
35
  success: true,
28
36
  message: JSON.stringify(
29
37
  live.map((alert) => ({
38
+ // The row's own identifier, needed by anything that acts on ONE
39
+ // alert. `key` identifies the condition and is stable across
40
+ // restarts; `id` identifies this occurrence of it.
41
+ id: alert.id,
30
42
  key: alert.key,
31
43
  state: alert.state,
32
44
  severity: alert.severity,
@@ -43,7 +55,10 @@ export async function handleAlertsList(
43
55
  suppressedByAlertId: alert.suppressedByAlertId,
44
56
  suppressedByWindowId: alert.suppressedByWindowId,
45
57
  awaitingConfirmation: alert.awaitingConfirmation,
46
- ackedBy: alert.ackedBy,
58
+ // Resolved to a name. The column is a `people` foreign key, and a
59
+ // bare id tells a reader nothing about who acknowledged their alert.
60
+ ackedBy: alert.ackedBy ? (peopleById.get(alert.ackedBy) ?? alert.ackedBy) : null,
61
+ ackedAt: alert.ackedAt,
47
62
  silencedUntil: alert.silencedUntil,
48
63
  message: alert.message,
49
64
  })),
@@ -9,7 +9,7 @@
9
9
 
10
10
  import { describe, expect, test } from 'bun:test';
11
11
  import type { Backup } from '../../db/schema';
12
- import { backedUpModuleIds, lastSuccessSummaryLines } from './backup-list';
12
+ import { backedUpModuleIds, backupListJson, lastSuccessSummaryLines } from './backup-list';
13
13
 
14
14
  const NOW = Date.UTC(2026, 7, 13, 12, 0, 0);
15
15
  const ago = (ms: number) => new Date(NOW - ms);
@@ -81,3 +81,84 @@ describe('lastSuccessSummaryLines', () => {
81
81
  expect(line).not.toContain('attempts');
82
82
  });
83
83
  });
84
+
85
+ /**
86
+ * `--json`, which exists because the human output cannot be parsed back.
87
+ *
88
+ * `formatRelativeDate` buckets everything past six days — "last week", then
89
+ * "2 weeks ago", then "last month". A person reading a screen wants that. The
90
+ * web console draws one square per module per day and cannot place a row it
91
+ * cannot date, and a row it leaves out renders as a day on which no backup ran.
92
+ * Inventing a gap in somebody's backup coverage is the one thing that page must
93
+ * never do, so these pin the exactness rather than the formatting.
94
+ */
95
+ describe('backupListJson', () => {
96
+ const AT = Date.UTC(2026, 6, 14, 18, 13, 6);
97
+
98
+ function row(partial: Partial<Backup>): Backup {
99
+ return backup({
100
+ id: 'da8522ea-4319-45fb-ae06-a6d3525f4b9f',
101
+ storageId: 'st-1',
102
+ storagePath: '2026-07-14/forgejo.backup',
103
+ status: 'completed',
104
+ sizeBytes: 2_900_000_000,
105
+ startedAt: new Date(AT),
106
+ completedAt: new Date(AT + 1000),
107
+ ...partial,
108
+ });
109
+ }
110
+
111
+ const noDatabase = {
112
+ storageNameOf: () => 'aws-backups',
113
+ historyOf: () => ({
114
+ lastSuccessAt: new Date(AT),
115
+ lastAttemptAt: new Date(AT),
116
+ consecutiveFailures: 0,
117
+ }),
118
+ };
119
+
120
+ function parse(backups: Backup[], windowDays: number | null = null) {
121
+ const result = backupListJson(backups, windowDays, noDatabase);
122
+ // Narrowed rather than asserted: `message` lives only on the success arm,
123
+ // and a failure here should say what went wrong, not read as empty JSON.
124
+ if (!result.success) throw new Error(result.error);
125
+ return JSON.parse(result.message ?? '{}');
126
+ }
127
+
128
+ test('a timestamp survives as an exact instant, not a bucket', () => {
129
+ // The same row the human path renders as "last month".
130
+ const [only] = parse([row({})].map((b) => b)).backups;
131
+ expect(only.startedAt).toBe(AT);
132
+ expect(only.completedAt).toBe(AT + 1000);
133
+ });
134
+
135
+ test('the short id is offered so a caller need not slice a UUID', () => {
136
+ expect(parse([row({})]).backups[0].shortId).toBe('da8522ea');
137
+ });
138
+
139
+ test('a failed attempt reports no size and no completion', () => {
140
+ // Not zero and not "now". A failure kept nothing and never finished, and
141
+ // both of those are facts a caller renders differently from a small backup.
142
+ const [only] = parse([row({ status: 'failed', sizeBytes: null, completedAt: null })]).backups;
143
+ expect(only.sizeBytes).toBeNull();
144
+ expect(only.completedAt).toBeNull();
145
+ });
146
+
147
+ test('the failure cause is carried, because one red square is not one problem', () => {
148
+ const [only] = parse([row({ status: 'failed', errorMessage: 'ENOSPC' })]).backups;
149
+ expect(only.error).toBe('ENOSPC');
150
+ });
151
+
152
+ test('windowDays is echoed, so a caller can tell "nothing ran" from "not asked"', () => {
153
+ // Null means unbounded. A number means the rows are complete only that far
154
+ // back, and an empty day beyond it asserts nothing.
155
+ expect(parse([row({})], 30).windowDays).toBe(30);
156
+ expect(parse([row({})], null).windowDays).toBeNull();
157
+ });
158
+
159
+ test('an empty listing is a valid answer, not an error', () => {
160
+ const empty = parse([]);
161
+ expect(empty.backups).toEqual([]);
162
+ expect(empty.modules).toEqual([]);
163
+ });
164
+ });