@celilo/cli 1.6.0 → 1.7.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 (46) hide show
  1. package/CELILO_CORE_MODULES.md +2 -1
  2. package/CELILO_SUBSYSTEMS.md +2 -0
  3. package/MODULE_PRIMITIVES.md +6 -1
  4. package/package.json +3 -3
  5. package/src/capabilities/lookup.ts +39 -29
  6. package/src/capabilities/secret-ref.test.ts +24 -0
  7. package/src/capabilities/secret-validation.ts +50 -0
  8. package/src/capabilities/validation.test.ts +187 -2
  9. package/src/capabilities/validation.ts +53 -1
  10. package/src/cli/commands/alerts-sweep.ts +18 -0
  11. package/src/cli/commands/module-remove.ts +34 -2
  12. package/src/cli/commands/module-update.test.ts +149 -2
  13. package/src/cli/commands/module-update.ts +113 -25
  14. package/src/cli/commands/service-set-credentials.test.ts +108 -0
  15. package/src/cli/commands/service-set-credentials.ts +115 -0
  16. package/src/cli/commands/system-migrate.ts +6 -4
  17. package/src/cli/completion.ts +16 -1
  18. package/src/cli/index.ts +9 -0
  19. package/src/db/client.ts +10 -8
  20. package/src/db/migrate.test.ts +147 -0
  21. package/src/db/migrate.ts +69 -1
  22. package/src/hooks/capability-loader.test.ts +55 -0
  23. package/src/hooks/capability-loader.ts +16 -1
  24. package/src/module/import.ts +20 -5
  25. package/src/policy/module-business-baseline.ts +0 -11
  26. package/src/services/alerting/monitors.ts +54 -2
  27. package/src/services/alerting/sweep-runner.ts +38 -1
  28. package/src/services/consumer-cleanup.ts +5 -3
  29. package/src/services/container-service.test.ts +34 -0
  30. package/src/services/container-service.ts +44 -0
  31. package/src/services/deployed-systems.test.ts +101 -0
  32. package/src/services/deployed-systems.ts +43 -11
  33. package/src/services/dns-provider-backfill.ts +30 -0
  34. package/src/services/fleet-checks.test.ts +26 -0
  35. package/src/services/fleet-checks.ts +11 -1
  36. package/src/services/module-deploy.ts +88 -41
  37. package/src/services/provider-arrival.test.ts +241 -0
  38. package/src/services/provider-arrival.ts +213 -0
  39. package/src/templates/generator.test.ts +35 -0
  40. package/src/templates/generator.ts +29 -1
  41. package/src/variables/context.test.ts +63 -0
  42. package/src/variables/context.ts +10 -2
  43. package/src/variables/declarative-derivation.test.ts +47 -8
  44. package/src/variables/declarative-derivation.ts +6 -4
  45. package/src/services/public-web-republish.test.ts +0 -189
  46. package/src/services/public-web-republish.ts +0 -84
@@ -46,7 +46,7 @@ Each entry: `module id` — what it is — **provides** / **requires** capabilit
46
46
  ## Public edge (ingress / identity)
47
47
 
48
48
  - **caddy** — reverse proxy with automatic HTTPS (HTTP-01 ACME); the standard HTTPS ingress. Emits a 301 redirect block for each served name's `www`/apex companion (kept out of the served-hostname set, so a companion never blocks the ACME wait). Its `health_check`'s DNS item is `internal_dns_resolution` — it digs from the management host behind the split-horizon resolver and is evidence about the in-fleet view only; public reachability is the framework's `public_dns` check. **provides:** `public_web`. **requires:** `dns_registrar`, `firewall`.
49
- - **caddy-internal** — a SECOND Caddy that serves the fleet and nothing else, and is a sibling of **caddy** rather than a mode of it. It never exposes a port on the firewall's EXTERNAL interface, never publishes a public record, and takes its certificates from Caddy's own local CA (`tls internal`) — those absences ARE the capability, and `public_web` cannot express them because it treats an unreachable route as a deploy failure and publishes a public A record to prevent one (design D10). Lives in the **`dmz`** zone, in front of the things it fronts, mirroring the public `caddy` (celilo#879). LAN devices reach it through an IPAM-allocated `internal`-subnet `ingress_ip` that `on_install` passes to `firewall.exposeService({ ingressIp })` — one DNAT on the firewall's INTERNAL side and nothing external, the same mechanism the dmz-resident `dns_internal` resolver has used for `:53` since ISS-0156. Systems already inside `dmz`/`app`/`secure` use its dmz address instead (carried as `zoneRoutableValue` on the internal record), and VPN clients arrive as a registered trusted source. It previously lived in `internal` on the claim that a dmz ingress could not be reached from a LAN without a public port-forward; that conflated a public forward with an internal-side ingress IP, and cost it the ability to serve a browser inside a segmented zone at all. **`getCaCertificate()` is why the contract has a method `public_web` does not** — an internally-issued cert means clients must trust a CA celilo runs, and here that is nearly free because the people who must trust it are the ones who just downloaded a bundle from the service behind it, so the anchor ships with it. **The route table is this module's OWN config (`routes`), never celilo's `web_routes`** (celilo#846): caddy derives its served hostnames from every row of that table, so a private route stored there would be picked up and served PUBLICLY — storage is the privacy boundary, not policy. Route changes reconcile synchronously in the capability call (there is no `routes_changed` event for private routes, and inventing one would only add delay). **Every proxied route strips a client-supplied `X-Forwarded-User`** (`header_up -X-Forwarded-User`): this ingress routes and does not authenticate, so a backend believing that header would believe whatever the client sent — which `wireguard-manager` shipped doing. That module verifies a signed token now, so the strip protects the NEXT backend written against the same assumption. It is defence in depth, NOT an authenticating proxy: adding `forward_auth` to `private_web` is a capability change and deliberately not bundled with it. A consumer cannot register a route on a hostname the ingress is not already configured for: a capability factory gets no capabilities of its own, so it cannot add the internal DNS record a new name would need, and it refuses rather than serving an unresolvable site block. **provides:** `private_web`. **requires:** `dns_internal` — declared under `optional.capabilities` ONLY to route around celilo#854 (the import-time secret gate refuses a consumer over a secret it never reads); `on_install` throws without it.
49
+ - **caddy-internal** — a SECOND Caddy that serves the fleet and nothing else, and is a sibling of **caddy** rather than a mode of it. It never exposes a port on the firewall's EXTERNAL interface, never publishes a public record, and takes its certificates from Caddy's own local CA (`tls internal`) — those absences ARE the capability, and `public_web` cannot express them because it treats an unreachable route as a deploy failure and publishes a public A record to prevent one (design D10). Lives in the **`dmz`** zone, in front of the things it fronts, mirroring the public `caddy` (celilo#879). LAN devices reach it through an IPAM-allocated `internal`-subnet `ingress_ip` that `on_install` passes to `firewall.exposeService({ ingressIp })` — one DNAT on the firewall's INTERNAL side and nothing external, the same mechanism the dmz-resident `dns_internal` resolver has used for `:53` since ISS-0156. Systems already inside `dmz`/`app`/`secure` use its dmz address instead (carried as `zoneRoutableValue` on the internal record), and VPN clients arrive as a registered trusted source. It previously lived in `internal` on the claim that a dmz ingress could not be reached from a LAN without a public port-forward; that conflated a public forward with an internal-side ingress IP, and cost it the ability to serve a browser inside a segmented zone at all. **`getCaCertificate()` is why the contract has a method `public_web` does not** — an internally-issued cert means clients must trust a CA celilo runs, and here that is nearly free because the people who must trust it are the ones who just downloaded a bundle from the service behind it, so the anchor ships with it. **The route table is this module's OWN config (`routes`), never celilo's `web_routes`** (celilo#846): caddy derives its served hostnames from every row of that table, so a private route stored there would be picked up and served PUBLICLY — storage is the privacy boundary, not policy. Route changes reconcile synchronously in the capability call (there is no `routes_changed` event for private routes, and inventing one would only add delay). **Every proxied route strips a client-supplied `X-Forwarded-User`** (`header_up -X-Forwarded-User`): this ingress routes and does not authenticate, so a backend believing that header would believe whatever the client sent — which `wireguard-manager` shipped doing. That module verifies a signed token now, so the strip protects the NEXT backend written against the same assumption. It is defence in depth, NOT an authenticating proxy: adding `forward_auth` to `private_web` is a capability change and deliberately not bundled with it. A consumer cannot register a route on a hostname the ingress is not already configured for: a capability factory gets no capabilities of its own, so it cannot add the internal DNS record a new name would need, and it refuses rather than serving an unresolvable site block. **provides:** `private_web`. **requires:** `dns_internal` — a hard requirement, since `on_install` throws without it. It sat under `optional.capabilities` until celilo#854 was fixed, because the import-time secret gate refused a consumer over a secret it never reads.
50
50
  - **generic-cpanel-hosting-provider** — publishes static sites into a subfolder of a domain on a cPanel/SSH web host celilo does **not** govern (the host owns the domain, DNS, TLS and web server). Systemless like **namecheap** — no `requires.system`, no zone, no IPAM; it holds credentials for an external party. One provider serves many accounts, resolved by hostname; onboarding takes the account password once, `ssh-copy-id`s celilo's key, then discards it. **provides:** `external_web`.
51
51
  - **authentik** — Authentik identity provider with OIDC (Docker Compose: server, worker, Postgres, Redis). **provides:** `idp`. **requires:** `public_web`, `dns_registrar`, `firewall`.
52
52
 
@@ -57,6 +57,7 @@ Each entry: `module id` — what it is — **provides** / **requires** capabilit
57
57
  - **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`.
58
58
  - **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/`.
59
59
  - **celilo-website** — public docs site (static Astro) served via Caddy on celilo.computer. **requires:** `public_web`, `dns_registrar`.
60
+ - **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`.
60
61
 
61
62
  ## Git forge & CI pipeline
62
63
 
@@ -185,6 +185,7 @@ runner seam (`execRunner` real / `createMockRunner` for tests) lives in
185
185
  - **Module pause / unpause (control-plane quiescence)** — `apps/celilo/src/services/module-pause.ts` — pure `planPause`/`planUnpause` producing an ordered plan, `executePause`/`executeUnpause` performing it, plus `listPausedModules`/`pausedAmong`/`formatPausedDuration`/`describePausedModule` (the ONE place an age is formatted). CLI: `apps/celilo/src/cli/commands/module-pause.ts` (`celilo module pause|unpause <id> [--cascade] [--stop-infra] [--reason] [--dry-run] [--yes]`). Pausing takes a module out of the CONTROL plane — no dispatched events, no timer hooks, no health checks, alerts suppressed — while leaving the DATA plane running, because capability consumption is deploy-time: every consumer calls `firewall`/`dhcp_server` from `on_install` and nothing calls it while serving. Config, secrets, IPAM/VMID and placement are preserved; `on_uninstall` does NOT run. Quiescence is enforced in two places: pausing drops the module's bus subscriptions (`unregisterModuleSubscriptions`), and `run-named-hook.ts` refuses any non-lifecycle hook for a PAUSED module (`skippedPaused`), which catches the paths that skip the bus — `events resync-subscriptions`, a restore that starts events.db empty, aspect fan-out, public-web republish. `on_install`/`on_uninstall` are exempt by hook NAME (not a caller flag): unpause redeploys through `on_install`, and removing a paused provider needs `on_uninstall`. Unpause always REDEPLOYS (`deployModule`) — that is what rebinds a consumer to a replacement provider and recreates provider-local state from the consumers that own it — and re-registers subscriptions, which a plain deploy does not do. A failed unpause restores `PAUSED` rather than leaving the module live and mis-bound. Cascade order reuses `services/update/dep-graph.ts` unchanged (pause = consumers first, unpause = providers first) and is computed from the GRAPH, never from which modules are currently paused, so a cascade walks THROUGH already-done members and is resumable. See `openspec/specs/module-pause/spec.md`.
186
186
  - **Provider-removal guard** — `apps/celilo/src/services/remove-guard.ts` — `findRemovalBlockers`/`describeRemovalRefusal`, called from `apps/celilo/src/cli/commands/module-remove.ts`. A PAUSED module is not a dependent (unpause cannot return it to service without a redeploy, and a redeploy re-resolves capabilities), which is what makes a provider swap possible at all. A dependent is one declaring the capability under `requires` **or** `optional` — the same relation `dep-graph.ts` uses, so the guard and the cascade agree on the set; the guard previously read `requires` alone, which let a removal silently orphan `technitium`'s `optional` `dhcp_server`. Refusals name each blocker AND which declaration makes it one. It deliberately does NOT exempt a dependent because another provider of the same capability exists (celilo#683).
187
187
  - **Consumer-removal cleanup (every provider is told)** — `apps/celilo/src/services/consumer-cleanup.ts` — pure `planConsumerCleanup` + `loadConsumerCleanupPlan` + `runConsumerCleanup`, called from `apps/celilo/src/cli/commands/module-remove.ts` after `on_uninstall` and before `terraform destroy`. A capability is two-sided: the consumer asks, the provider mints something in ITS world (a caddy site block, a DNAT rule, an OIDC client at authentik, a registered CI runner), and removal only ever touched one side — the FK cascade dropped celilo's row, so the provider's next converge had no way to learn the thing existed. This dispatches the `on_consumer_removed` hook to every provider of every capability the departing module declared under `requires` OR `optional` (the same relation `remove-guard.ts` counts as a dependency edge), **once per provider** rather than once per capability, sorted by provider id. The hook receives one input, `consumer`, and NOTHING else: a provider that cannot answer "what do I hold for this module" without being told has a different defect — the consumer's id was never recorded at mint time. It replaces `services/web-route-cleanup.ts`, which did the same job for exactly one capability, by name, from core. Semantics that are easy to get backwards: **a failed withdrawal never blocks the removal** — the consumer goes and the failing PROVIDER is marked `ERROR` with the departing consumer named in `error_message` (surfaced as a `blocked` finding by `services/audit/undeployed-modules.ts`), because the hook is a full converge and after a failure the provider's state is unknown rather than "one thing missed". **Dispatch continues past a failure**, so one broken provider cannot leave the others holding state. A PAUSED provider is skipped with a warning naming what it keeps (`run-named-hook.ts` refuses non-lifecycle hooks on a paused module, and `on_consumer_removed` must NOT join `LIFECYCLE_HOOKS`), and a never-deployed one is skipped silently. Providers implementing it: caddy, caddy-internal, iptables, greenwave, axon, authentik, forgejo, generic-cpanel-hosting-provider. See `openspec/changes/consumer-removal-cleanup/`.
188
+ - **Provider-arrival backfill (every consumer is re-run)** — `apps/celilo/src/services/provider-arrival.ts` — pure `planProviderBackfill` + `loadProviderBackfillPlan` + `runProviderBackfill`, called from `module-deploy.ts` at the end of BOTH deploy paths (config-only and full). The mirror of consumer-removal cleanup, and it exists because celilo handled one side of a capability generically and the other by hand: a provider arriving had three hand-written pieces covering two capabilities, and `firewall` — provided by `axon`, `greenwave` AND `iptables` — had none, so its consumers inherited nothing (celilo#1011). When a module deploys, every already-installed module declaring one of its capabilities under `requires` OR `optional` has its `on_install` re-run. **PULL, never push** (design D7): the consumer re-registers through the path that worked the first time, rather than core replaying history into the provider by calling the provider's own hooks on a consumer's behalf — the push shape is a second implementation with its own bug surface, and `backfillWebRouteDns` had already drifted into one. Never fatal to the provider's own deploy: a failed consumer is named with `celilo module deploy <id>` to retry, and a PAUSED or never-deployed consumer is reported as skipped rather than silently passed over. Shares `PRE_DEPLOY_STATES` with `consumer-cleanup.ts` — one definition, also read by `module-remove.ts`. It replaced `services/public-web-republish.ts` (deleted) and deliberately did NOT replace `services/dns-provider-backfill.ts`, whose docblock records the two reasons: one half replays celilo's HOST inventory rather than a capability set, and the other covers FQDNs published through `public_web` by modules that declare `public_web` and never `dns_internal`. See `openspec/changes/capability-owned-tables/` stage 1.
188
189
  - **Firewall registry ownership** — `apps/celilo/src/services/port-forwards.ts` + `apps/celilo/src/services/trusted-sources.ts`. Both stores are bound to the CONSUMING module and stamp `registered_by` themselves; a caller cannot supply it, so a registration is never attributed to the wrong module. Both writes are **declarative**: `replace()` states a consumer's COMPLETE set for a target, so a port or subnet it previously registered and now omits is withdrawn (celilo#855 — before this, a module that exposed `:8080` and redeployed exposing `:9090` kept both, forever). The owner is IN both unique indexes, not merely beside them: two consumers wanting the same forward are two ROWS, so one leaving cannot delete a rule the other still needs; `modules/iptables/scripts/ruleset-renderer.ts` dedupes on the rule tuple so the pair renders once. Neither column is a FK, so `runConsumerCleanup` deletes these rows explicitly after every provider has converged without them.
189
190
  - **Backup artifact encryption** — `apps/celilo/src/services/backup-cipher.ts` — `encryptFileToFile`/`decryptFileToFile`, file-in/file-out and streamed, used by every backup writer (`backup-create.ts`) and reader (`backup-restore.ts`, `restore-from-file.ts`). Do NOT route artifacts through `secrets/encryption.ts`: that API is string-in/string-out for short DB values, and feeding it a tar cost base64 (1.33x) then hex (2x) then `JSON.stringify` — ~9x the artifact in memory, which OOM-killed forgejo's 774 MB backup, and a hard ~805 MB ceiling from the max string length that no amount of RAM raises. On-disk format is `magic "CELILOBK" (8) | version (1) | iv (16) | ciphertext | GCM tag (16)`; the tag is a trailer because it does not exist until the last byte is encrypted. `decryptFileToFile` still reads the pre-2026-07 JSON-envelope artifacts, discriminating on the magic bytes — the envelope's own `schemaVersion` cannot serve, as it lives inside the encrypted tar.
190
191
 
@@ -305,6 +306,7 @@ upgrade controls — as opposed to how the module configures itself.
305
306
  ## Persistence
306
307
 
307
308
  - **DB schema** — `apps/celilo/src/db/schema.ts`. Client: `apps/celilo/src/db/client.ts`. Migration runner: `apps/celilo/src/db/migrate.ts`. Migrations: `apps/celilo/drizzle/`.
309
+ - **Frozen-watermark repair** — `runMigrationsOn` (`db/migrate.ts`) is what `createDbClient` calls on open, not drizzle's migrator directly. drizzle is watermark-only, so a DB from the imperative hand-list era — schema applied, `__drizzle_migrations` never told — makes it re-run migrations and die on `duplicate column name`. That throw is inside the OPEN, so every celilo command on that box fails, `celilo system migrate` included: it reaches its own repair through `getDb()`. The repair engages only after the stock migrator has failed AND only when `findSchemaDrift` reports the declared schema entirely present, and it corrects the LEDGER without running SQL — replaying is not an option, since `drizzle/0021_dns_registration_consumers.sql` rebuilds `dns_registrations` by dropping the original and renaming a copy over it. A PARTIALLY applied schema (celilo-mgr's own pre-remediation state) still fails, naming the hand remediation. Gate: `db/migrate.test.ts`. Both entrypoints run `celilo system migrate` — the `.deb` postinst and the `celilo-mgmt` Ansible role, before the dispatcher starts (celilo#169).
308
310
 
309
311
  ## Events
310
312
 
@@ -92,7 +92,7 @@ for liveness use `probe`.
92
92
  serviceCtl(sys, 'caddy', 'reload', run);
93
93
  ```
94
94
 
95
- ### `applyRenderedConfig({ target, path, content, validate, apply, runner?, timeoutMs? }) → RunResult`
95
+ ### `applyRenderedConfig({ target, path, content, validate, apply, statePath?, runner?, timeoutMs? }) → RunResult`
96
96
  **The converge primitive.** For config whose *content* your hook computes at
97
97
  deploy-time from celilo's DB (caddy's Caddyfile from `web_routes`, knot's views
98
98
  from the DNS ledger, the firewall ruleset from the port-forward registry) —
@@ -100,6 +100,11 @@ content a static Ansible render can't know. One atomic on-box script, one SSH
100
100
  round-trip: back up → write (`content` rides **stdin**, newline-safe) → `validate`
101
101
  (rolled back + not activated if it fails) → `apply` (rolled back if it fails).
102
102
  `{path}` is substituted into `validate`/`apply`.
103
+
104
+ By default, rollback and last-render state are retained beside `path` as
105
+ `.celilo-bak` and `.celilo-prev`. Set `statePath` to a prefix outside daemon
106
+ include directories (such as `/etc/dnsmasq.d`) when the daemon parses every
107
+ neighboring file.
103
108
  ```ts
104
109
  applyRenderedConfig({
105
110
  target: sys, path: '/etc/caddy/Caddyfile', content: rendered,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "1.6.0",
3
+ "version": "1.7.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": "^2.3.0",
61
+ "@celilo/capabilities": "^2.4.0",
62
62
  "@celilo/cli-display": "^0.2.0",
63
- "@celilo/core": "^0.9.0",
63
+ "@celilo/core": "^0.9.1",
64
64
  "@celilo/event-bus": "^0.6.0",
65
65
  "ajv": "^8.18.0",
66
66
  "drizzle-orm": "^0.36.4",
@@ -18,6 +18,43 @@ export interface CapabilityProviderInfo {
18
18
  zones: string[] | null;
19
19
  }
20
20
 
21
+ type CapabilityProviderRow = {
22
+ id: number;
23
+ moduleId: string;
24
+ capabilityName: string;
25
+ version: string;
26
+ data: Record<string, unknown>;
27
+ zones: string[] | null;
28
+ };
29
+
30
+ /**
31
+ * Select a provider from an already-loaded candidate set.
32
+ *
33
+ * Keeping this policy separate from the database query lets runtime consumers
34
+ * that already loaded every provider (notably the hook capability loader) use
35
+ * exactly the same explicit-zone-first semantics as direct lookups.
36
+ */
37
+ export function selectCapabilityProvider(
38
+ all: CapabilityProviderRow[],
39
+ zone?: string,
40
+ ): CapabilityProviderInfo | null {
41
+ if (all.length === 0) return null;
42
+
43
+ if (zone) {
44
+ const zoneMatch = all.find((candidate) => candidate.zones?.includes(zone));
45
+ if (zoneMatch) return toInfo(zoneMatch);
46
+
47
+ const agnostic = all.find(
48
+ (candidate) => candidate.zones === null || candidate.zones === undefined,
49
+ );
50
+ if (agnostic) return toInfo(agnostic);
51
+
52
+ return null;
53
+ }
54
+
55
+ return toInfo(all[0]);
56
+ }
57
+
21
58
  /**
22
59
  * Find a capability provider, optionally filtered by zone.
23
60
  *
@@ -35,27 +72,7 @@ export function findCapabilityProvider(
35
72
  zone?: string,
36
73
  ): CapabilityProviderInfo | null {
37
74
  const all = db.select().from(capabilities).where(eq(capabilities.capabilityName, name)).all();
38
-
39
- if (all.length === 0) return null;
40
-
41
- if (zone) {
42
- // First: try to find a provider that explicitly covers this zone
43
- const zoneMatch = all.find((c) => {
44
- const zones = c.zones as string[] | null;
45
- return zones?.includes(zone);
46
- });
47
- if (zoneMatch) return toInfo(zoneMatch);
48
-
49
- // Second: fall back to zone-agnostic provider (zones is null)
50
- const agnostic = all.find((c) => c.zones === null || c.zones === undefined);
51
- if (agnostic) return toInfo(agnostic);
52
-
53
- // No match for this zone
54
- return null;
55
- }
56
-
57
- // No zone specified: return first provider
58
- return toInfo(all[0]);
75
+ return selectCapabilityProvider(all, zone);
59
76
  }
60
77
 
61
78
  /**
@@ -70,14 +87,7 @@ export function findAllCapabilityProviders(name: string, db: DbClient): Capabili
70
87
  .map(toInfo);
71
88
  }
72
89
 
73
- function toInfo(row: {
74
- id: number;
75
- moduleId: string;
76
- capabilityName: string;
77
- version: string;
78
- data: Record<string, unknown>;
79
- zones: string[] | null;
80
- }): CapabilityProviderInfo {
90
+ function toInfo(row: CapabilityProviderRow): CapabilityProviderInfo {
81
91
  return {
82
92
  id: row.id,
83
93
  moduleId: row.moduleId,
@@ -10,6 +10,7 @@ import { join } from 'node:path';
10
10
  import { createDbClient } from '@/db/client';
11
11
  import { encryptSecret } from '@/secrets/encryption';
12
12
  import { getOrCreateMasterKey } from '@/secrets/master-key';
13
+ import { validateCapabilitySecrets } from './secret-validation';
13
14
  import { getCapabilitySecret } from './secrets';
14
15
 
15
16
  let testDirs: string[] = [];
@@ -108,6 +109,11 @@ describe('Capability secret_ref resolution', () => {
108
109
  )
109
110
  .run(capabilityId.id, 'tsig', 'TSIG secret for DNS updates');
110
111
 
112
+ // A configured secret_ref satisfies generation validation without
113
+ // duplicating the secret into capability_secrets.
114
+ const validation = await validateCapabilitySecrets('dns-external', db.$client);
115
+ expect(validation).toEqual({ success: true });
116
+
111
117
  // Test: Resolve capability secret via secret_ref
112
118
  const result = await getCapabilitySecret('dns_external', 'tsig', db.$client);
113
119
 
@@ -172,6 +178,17 @@ describe('Capability secret_ref resolution', () => {
172
178
  )
173
179
  .run(capabilityId.id, 'tsig');
174
180
 
181
+ const validation = await validateCapabilitySecrets('dns-external', db.$client);
182
+ expect(validation.success).toBe(false);
183
+ expect(validation.missingSecrets).toEqual([
184
+ {
185
+ capabilityId: capabilityId.id,
186
+ capabilityName: 'dns_external',
187
+ secretName: 'tsig',
188
+ description: null,
189
+ },
190
+ ]);
191
+
175
192
  // Test: Should throw error about missing module secret
176
193
  await expect(getCapabilitySecret('dns_external', 'tsig', db.$client)).rejects.toThrow(
177
194
  "Module secret 'nonexistent_secret' not found",
@@ -241,6 +258,9 @@ describe('Capability secret_ref resolution', () => {
241
258
  )
242
259
  .run(capabilityId.id, 'api_key', encrypted.encryptedValue, encrypted.iv, encrypted.authTag);
243
260
 
261
+ const validation = await validateCapabilitySecrets('test-module', db.$client);
262
+ expect(validation).toEqual({ success: true });
263
+
244
264
  // Test: Should read from capability_secrets table
245
265
  const result = await getCapabilitySecret('test_capability', 'api_key', db.$client);
246
266
 
@@ -305,6 +325,10 @@ describe('Capability secret_ref resolution', () => {
305
325
  )
306
326
  .run(capabilityId.id, 'api_key');
307
327
 
328
+ const validation = await validateCapabilitySecrets('test-module', db.$client);
329
+ expect(validation.success).toBe(false);
330
+ expect(validation.missingSecrets?.map((secret) => secret.secretName)).toEqual(['api_key']);
331
+
308
332
  // Test: Should throw helpful error
309
333
  await expect(getCapabilitySecret('test_capability', 'api_key', db.$client)).rejects.toThrow(
310
334
  "Secret 'api_key' in capability 'test_capability' has not been set",
@@ -5,6 +5,7 @@
5
5
 
6
6
  import type { Database } from 'bun:sqlite';
7
7
  import { celiloIntro, promptPassword } from '../cli/prompts';
8
+ import type { ModuleManifest } from '../manifest/schema';
8
9
  import { encryptSecret } from '../secrets/encryption';
9
10
  import { getOrCreateMasterKey } from '../secrets/master-key';
10
11
 
@@ -21,6 +22,46 @@ export interface SecretValidationResult {
21
22
  missingSecrets?: MissingCapabilitySecret[];
22
23
  }
23
24
 
25
+ /**
26
+ * Check whether an unset capability-secret row is backed by a configured
27
+ * provider module secret through `secret_ref`.
28
+ *
29
+ * Capability registration deliberately stores metadata-only rows even when
30
+ * the manifest delegates storage to `$secret:<name>`. In that case the NULL
31
+ * capability value is expected and must not trigger a second secret prompt.
32
+ */
33
+ function hasConfiguredSecretRef(
34
+ moduleId: string,
35
+ manifest: ModuleManifest | null,
36
+ capabilityName: string,
37
+ secretName: string,
38
+ db: Database,
39
+ ): boolean {
40
+ const capability = manifest?.provides?.capabilities?.find(
41
+ (candidate) => candidate.name === capabilityName,
42
+ );
43
+ const secret = capability?.secrets?.find((candidate) => candidate.name === secretName);
44
+ const match = secret?.secret_ref?.match(/^\$secret:(.+)$/);
45
+
46
+ if (!match) {
47
+ return false;
48
+ }
49
+
50
+ const referencedSecret = db
51
+ .prepare(
52
+ `SELECT 1
53
+ FROM secrets
54
+ WHERE module_id = ? AND name = ?
55
+ AND encrypted_value IS NOT NULL
56
+ AND iv IS NOT NULL
57
+ AND auth_tag IS NOT NULL
58
+ LIMIT 1`,
59
+ )
60
+ .get(moduleId, match[1]);
61
+
62
+ return referencedSecret !== null && referencedSecret !== undefined;
63
+ }
64
+
24
65
  /**
25
66
  * Check if module has any missing capability secrets
26
67
  *
@@ -43,6 +84,11 @@ export async function validateCapabilitySecrets(
43
84
  return { success: true }; // No capabilities = no secrets needed
44
85
  }
45
86
 
87
+ const moduleResult = db.prepare('SELECT manifest_data FROM modules WHERE id = ?').get(moduleId) as
88
+ | { manifest_data: string }
89
+ | undefined;
90
+ const manifest = moduleResult ? (JSON.parse(moduleResult.manifest_data) as ModuleManifest) : null;
91
+
46
92
  // Check for secrets with NULL encrypted_value
47
93
  const missingSecrets: MissingCapabilitySecret[] = [];
48
94
 
@@ -56,6 +102,10 @@ export async function validateCapabilitySecrets(
56
102
  .all(capability.id) as Array<{ name: string; description: string | null }>;
57
103
 
58
104
  for (const secret of secrets) {
105
+ if (hasConfiguredSecretRef(moduleId, manifest, capability.capability_name, secret.name, db)) {
106
+ continue;
107
+ }
108
+
59
109
  missingSecrets.push({
60
110
  capabilityId: capability.id,
61
111
  capabilityName: capability.capability_name,
@@ -3,6 +3,20 @@ import { describe, expect, test } from 'bun:test';
3
3
  import type { ModuleManifest } from '../manifest/schema';
4
4
  import { checkAllowlist, getProviderManifest, validateCapabilityAccess } from './validation';
5
5
 
6
+ /**
7
+ * A consumer's reference to `dns_external`'s restricted `tsig` secret.
8
+ *
9
+ * The access gate fires only for a secret the consumer actually names
10
+ * (celilo#854), so every fixture that expects a REFUSAL has to name one.
11
+ */
12
+ const TSIG_REFERENCE = {
13
+ name: 'tsig',
14
+ type: 'string' as const,
15
+ required: false,
16
+ source: 'capability' as const,
17
+ derive_from: '$capability:dns_external.tsig',
18
+ };
19
+
6
20
  describe('Capability Access Validation', () => {
7
21
  describe('checkAllowlist', () => {
8
22
  test('should return true when consumer provides capability in allowlist', () => {
@@ -452,7 +466,7 @@ describe('Capability Access Validation', () => {
452
466
  },
453
467
  ],
454
468
  },
455
- variables: { owns: [], imports: [] },
469
+ variables: { owns: [TSIG_REFERENCE], imports: [] },
456
470
  };
457
471
 
458
472
  const providerManifest: ModuleManifest = {
@@ -510,7 +524,7 @@ describe('Capability Access Validation', () => {
510
524
  capabilities: [{ name: 'dns_external', version: '1.0.0' }],
511
525
  },
512
526
  provides: { capabilities: [] }, // No provides section - empty
513
- variables: { owns: [], imports: [] },
527
+ variables: { owns: [TSIG_REFERENCE], imports: [] },
514
528
  };
515
529
 
516
530
  const providerManifest: ModuleManifest = {
@@ -640,4 +654,175 @@ describe('Capability Access Validation', () => {
640
654
  expect(callCount).toBe(2); // Should query both capabilities
641
655
  });
642
656
  });
657
+
658
+ // celilo#854 — the gate is scoped to secrets the consumer actually names.
659
+ //
660
+ // knot-unbound-internal declares dns_internal's `tsig_key` with
661
+ // `readable_by: ["dns_internal"]`. Before this was fixed, ANY module listing
662
+ // dns_internal under `requires.capabilities` and not itself PROVIDING
663
+ // dns_internal was refused at import over a value it never references.
664
+ // caddy-internal was the first module to hit it, and it declared the
665
+ // capability under `optional` to get past the gate — a lie about the
666
+ // dependency graph that six core services read.
667
+ describe('celilo#854 — secret access is gated on reference, not on declaration', () => {
668
+ const knotManifest: ModuleManifest = {
669
+ celilo_contract: '1.0',
670
+ id: 'knot-unbound-internal',
671
+ name: 'Knot + Unbound',
672
+ version: '1.0.0',
673
+ description: 'Test',
674
+ requires: { capabilities: [] },
675
+ provides: {
676
+ capabilities: [
677
+ {
678
+ name: 'dns_internal',
679
+ version: '1.0.0',
680
+ data: {},
681
+ secrets: [
682
+ {
683
+ name: 'tsig_key',
684
+ type: 'string',
685
+ readable_by: ['dns_internal'],
686
+ },
687
+ ],
688
+ },
689
+ ],
690
+ },
691
+ variables: { owns: [], imports: [] },
692
+ };
693
+
694
+ const knotDb = {
695
+ prepare: () => ({
696
+ get: () => ({ manifest_data: JSON.stringify(knotManifest) }),
697
+ }),
698
+ } as unknown as Database;
699
+
700
+ test('a consumer that requires the capability but never names the secret imports', async () => {
701
+ const manifest: ModuleManifest = {
702
+ celilo_contract: '1.0',
703
+ id: 'caddy-internal',
704
+ name: 'Caddy (fleet-only ingress)',
705
+ version: '1.0.0',
706
+ description: 'Test',
707
+ requires: {
708
+ capabilities: [{ name: 'dns_internal', version: '1.0.0' }],
709
+ },
710
+ provides: {
711
+ capabilities: [{ name: 'private_web', version: '1.0.0', data: {} }],
712
+ },
713
+ variables: { owns: [], imports: [] },
714
+ };
715
+
716
+ const result = await validateCapabilityAccess(manifest, knotDb);
717
+
718
+ expect(result.success).toBe(true);
719
+ expect(result.error).toBeUndefined();
720
+ });
721
+
722
+ test('a consumer that DOES name the restricted secret is still refused', async () => {
723
+ const manifest: ModuleManifest = {
724
+ celilo_contract: '1.0',
725
+ id: 'nosy-app',
726
+ name: 'Nosy App',
727
+ version: '1.0.0',
728
+ description: 'Test',
729
+ requires: {
730
+ capabilities: [{ name: 'dns_internal', version: '1.0.0' }],
731
+ },
732
+ provides: {
733
+ capabilities: [{ name: 'private_web', version: '1.0.0', data: {} }],
734
+ },
735
+ variables: {
736
+ owns: [
737
+ {
738
+ name: 'stolen_key',
739
+ type: 'string',
740
+ required: false,
741
+ source: 'capability',
742
+ derive_from: '$capability:dns_internal.tsig_key',
743
+ },
744
+ ],
745
+ imports: [],
746
+ },
747
+ };
748
+
749
+ const result = await validateCapabilityAccess(manifest, knotDb);
750
+
751
+ expect(result.success).toBe(false);
752
+ expect(result.error).toContain("Module 'nosy-app' cannot access secret 'tsig_key'");
753
+ expect(result.error).toContain("capability 'dns_internal'");
754
+ });
755
+
756
+ /**
757
+ * The gate is per-SECRET, not per-capability. A provider that declares two
758
+ * restricted secrets must not put a consumer on the hook for the second
759
+ * one's allow-list just because it named the first — that would move the
760
+ * over-refusal from "requires the capability" down one level to "reads any
761
+ * of its secrets", which is the same conflation wearing a smaller hat.
762
+ */
763
+ const twoSecretManifest: ModuleManifest = {
764
+ ...knotManifest,
765
+ provides: {
766
+ capabilities: [
767
+ {
768
+ name: 'dns_internal',
769
+ version: '1.0.0',
770
+ data: {},
771
+ secrets: [
772
+ { name: 'tsig_key', type: 'string', readable_by: ['dns_internal'] },
773
+ { name: 'api_token', type: 'string', readable_by: ['private_web'] },
774
+ ],
775
+ },
776
+ ],
777
+ },
778
+ };
779
+
780
+ const twoSecretDb = {
781
+ prepare: () => ({
782
+ get: () => ({ manifest_data: JSON.stringify(twoSecretManifest) }),
783
+ }),
784
+ } as unknown as Database;
785
+
786
+ test('naming one secret enforces that secret allow-list and no other', async () => {
787
+ function consumer(deriveFrom: string): ModuleManifest {
788
+ return {
789
+ celilo_contract: '1.0',
790
+ id: 'caddy-internal',
791
+ name: 'Caddy (fleet-only ingress)',
792
+ version: '1.0.0',
793
+ description: 'Test',
794
+ requires: { capabilities: [{ name: 'dns_internal', version: '1.0.0' }] },
795
+ provides: { capabilities: [{ name: 'private_web', version: '1.0.0', data: {} }] },
796
+ variables: {
797
+ owns: [
798
+ {
799
+ name: 'borrowed',
800
+ type: 'string',
801
+ required: false,
802
+ source: 'capability',
803
+ derive_from: deriveFrom,
804
+ },
805
+ ],
806
+ imports: [],
807
+ },
808
+ };
809
+ }
810
+
811
+ // `api_token` is readable by `private_web`, which this consumer provides.
812
+ // The unnamed `tsig_key`, which it does not satisfy, must not interfere.
813
+ const allowed = await validateCapabilityAccess(
814
+ consumer('$capability:dns_internal.api_token'),
815
+ twoSecretDb,
816
+ );
817
+ expect(allowed.success).toBe(true);
818
+
819
+ // Naming `tsig_key` is still refused, by name.
820
+ const refused = await validateCapabilityAccess(
821
+ consumer('$capability:dns_internal.tsig_key'),
822
+ twoSecretDb,
823
+ );
824
+ expect(refused.success).toBe(false);
825
+ expect(refused.error).toContain('tsig_key');
826
+ });
827
+ });
643
828
  });
@@ -6,6 +6,7 @@
6
6
  import type { Database } from 'bun:sqlite';
7
7
  import type { ModuleManifest } from '../manifest/schema';
8
8
  import { isPrivilegedCapability } from '../manifest/validate';
9
+ import { parseVariables } from '../variables/parser';
9
10
 
10
11
  export interface ValidationResult {
11
12
  success: boolean;
@@ -34,6 +35,8 @@ export async function validateCapabilityAccess(
34
35
  // Get list of capabilities this module provides
35
36
  const consumerCapabilities = (manifest.provides?.capabilities || []).map((cap) => cap.name);
36
37
 
38
+ const references = collectCapabilityReferences(manifest);
39
+
37
40
  // Check each required capability
38
41
  for (const requiredCapability of manifest.requires.capabilities) {
39
42
  // Framework-granted privileges (e.g. cross_module_read) are not
@@ -63,8 +66,12 @@ export async function validateCapabilityAccess(
63
66
  continue;
64
67
  }
65
68
 
66
- // Check allowlist for each secret
69
+ // Check allowlist for each secret the consumer actually names (celilo#854).
67
70
  for (const secret of capabilityDef.secrets) {
71
+ if (!referencesSecret(references, requiredCapability.name, secret.name)) {
72
+ continue;
73
+ }
74
+
68
75
  if (secret.readable_by && secret.readable_by.length > 0) {
69
76
  // Check if consumer provides any capability in the allowlist
70
77
  const hasAccess = checkAllowlist(consumerCapabilities, secret.readable_by);
@@ -89,6 +96,51 @@ export async function validateCapabilityAccess(
89
96
  return { success: true };
90
97
  }
91
98
 
99
+ /**
100
+ * Every `$capability:<name>.<path>` reference the consumer's manifest makes.
101
+ *
102
+ * Policy function (Rule 10.1) - parses only, no I/O.
103
+ *
104
+ * Serializing the manifest and parsing the result finds a reference wherever it
105
+ * lives — a `variables.owns[].derive_from`, a default, a capability data block —
106
+ * without this having to track which fields may hold one.
107
+ *
108
+ * References made from a module's TEMPLATES are deliberately not collected here.
109
+ * `src/variables/resolver.ts` checks access at the point of use, which is the
110
+ * only place a template reference can be seen, and it refuses there.
111
+ */
112
+ function collectCapabilityReferences(manifest: ModuleManifest): Set<string> {
113
+ return new Set(
114
+ parseVariables(JSON.stringify(manifest))
115
+ .filter((variable) => variable.type === 'capability')
116
+ .map((variable) => variable.path),
117
+ );
118
+ }
119
+
120
+ /**
121
+ * Does the consumer name this capability secret?
122
+ *
123
+ * Policy function (Rule 10.1) - pure logic, no I/O.
124
+ *
125
+ * A reference of `dns_internal.tsig_key` names the `tsig_key` secret, and so
126
+ * does `dns_internal.tsig_key.value` if the secret ever holds a structure.
127
+ * Requiring the capability alone names nothing (celilo#854): a module refused
128
+ * over a secret it never reads has to lie about its dependency graph to deploy.
129
+ */
130
+ function referencesSecret(
131
+ references: Set<string>,
132
+ capabilityName: string,
133
+ secretName: string,
134
+ ): boolean {
135
+ const base = `${capabilityName}.${secretName}`;
136
+ for (const reference of references) {
137
+ if (reference === base || reference.startsWith(`${base}.`)) {
138
+ return true;
139
+ }
140
+ }
141
+ return false;
142
+ }
143
+
92
144
  /**
93
145
  * Check if consumer capabilities match provider allowlist
94
146
  *
@@ -89,6 +89,12 @@ export async function handleAlertsSweep(): Promise<CommandResult> {
89
89
  if (report.deferredDelivered > 0) parts.push(`${report.deferredDelivered} deferred-delivered`);
90
90
  if (report.failed > 0) parts.push(`${report.failed} FAILED`);
91
91
  if (report.noPolicy.length > 0) parts.push(`${report.noPolicy.length} no-policy`);
92
+ // A monitor whose module is gone is dropped here rather than left to sit
93
+ // unschedulable forever (celilo#1029).
94
+ if (report.strandedDropped.length > 0) {
95
+ const names = report.strandedDropped.map((d) => d.moduleId).join(', ');
96
+ parts.push(`${report.strandedDropped.length} stranded-dropped (${names})`);
97
+ }
92
98
 
93
99
  const lines = [`alert sweep: ${parts.join(', ')}`];
94
100
 
@@ -109,6 +115,18 @@ export async function handleAlertsSweep(): Promise<CommandResult> {
109
115
  lines.push(` ${alertKey} (${reason})`);
110
116
  }
111
117
  }
118
+ // A dropped monitor's alerts are deleted with it, not resolved — nothing else
119
+ // anywhere records that they existed. Name each one and what it said: the
120
+ // failure it was reporting is real and is now unwatched, which is the fact an
121
+ // operator has to act on and the one a module name alone does not carry.
122
+ for (const { moduleId, alerts: dropped } of report.strandedDropped) {
123
+ if (dropped.length === 0) continue;
124
+ lines.push(` ${moduleId} is gone; its monitor was holding ${dropped.length} live alert(s):`);
125
+ for (const alert of dropped) {
126
+ lines.push(` ${alert.key}: ${alert.message}`);
127
+ }
128
+ lines.push(` nothing checks ${moduleId} any more, so these will not be reported again.`);
129
+ }
112
130
  // The error itself, not just a count: the transport is loaded lazily inside
113
131
  // the send, so a capability that will not load produces no other record
114
132
  // anywhere — nothing ever reaches the transport's own logs.