@celilo/cli 1.13.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 (34) hide show
  1. package/CELILO_CORE_MODULES.md +1 -1
  2. package/CELILO_SUBSYSTEMS.md +6 -2
  3. package/package.json +2 -2
  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/console-get-chain.test.ts +96 -0
  8. package/src/cli/commands/console.ts +13 -5
  9. package/src/cli/commands/notify-config.test.ts +79 -0
  10. package/src/cli/commands/notify-config.ts +13 -2
  11. package/src/cli/commands/system-ensure-fleet-key.ts +52 -0
  12. package/src/cli/completion.ts +1 -0
  13. package/src/cli/index.ts +6 -0
  14. package/src/console/closure.test.ts +76 -0
  15. package/src/console/closure.ts +87 -1
  16. package/src/console/projection.test.ts +63 -1
  17. package/src/console/projection.ts +39 -2
  18. package/src/hooks/capability-loader-control-plane-api.test.ts +124 -0
  19. package/src/hooks/capability-loader.ts +67 -10
  20. package/src/manifest/contracts/v1.ts +22 -1
  21. package/src/manifest/validate.ts +25 -4
  22. package/src/module/web-root.ts +35 -0
  23. package/src/policy/module-business-baseline.ts +14 -2
  24. package/src/policy/module-script-scan.test.ts +22 -0
  25. package/src/policy/module-script-scan.ts +32 -0
  26. package/src/services/api-principal-enrolment.test.ts +73 -0
  27. package/src/services/api-principal-enrolment.ts +55 -0
  28. package/src/services/backup-create.ts +33 -7
  29. package/src/services/celilo-mgmt-hooks.test.ts +38 -79
  30. package/src/services/fleet-key.test.ts +47 -0
  31. package/src/services/fleet-key.ts +75 -0
  32. package/src/services/restore-from-file.ts +6 -5
  33. package/src/services/system-state-stage.test.ts +165 -0
  34. package/src/services/system-state-stage.ts +196 -0
package/src/cli/index.ts CHANGED
@@ -124,6 +124,7 @@ import { handleSystemAudit } from './commands/system-audit';
124
124
  import { handleSystemConfigGet, handleSystemConfigSet } from './commands/system-config';
125
125
  import { handleSystemDiscoverNetwork } from './commands/system-discover-network';
126
126
  import { handleSystemDoctor } from './commands/system-doctor';
127
+ import { handleSystemEnsureFleetKey } from './commands/system-ensure-fleet-key';
127
128
  import { handleSystemInit } from './commands/system-init';
128
129
  import { handleSystemMigrate } from './commands/system-migrate';
129
130
  import { handleSystemSecretGet } from './commands/system-secret-get';
@@ -1122,6 +1123,7 @@ Subcommands:
1122
1123
  to define, not a module's.
1123
1124
 
1124
1125
  discover-network Record the network this box sits on, from its own routing table (idempotent)
1126
+ ensure-fleet-key Mint celilo's fleet SSH key if absent, record ssh.public_key, print the public half
1125
1127
 
1126
1128
  config set <key> <value> Set system-wide configuration value
1127
1129
  config get [key] Get system configuration value(s)
@@ -2221,6 +2223,10 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
2221
2223
  return handleSystemDiscoverNetwork();
2222
2224
  }
2223
2225
 
2226
+ if (parsed.subcommand === 'ensure-fleet-key') {
2227
+ return handleSystemEnsureFleetKey();
2228
+ }
2229
+
2224
2230
  if (parsed.subcommand === 'config') {
2225
2231
  const configSubcommand = parsed.args[0];
2226
2232
  if (!configSubcommand) {
@@ -232,6 +232,82 @@ describe('the walk follows real bindings, not declarations', () => {
232
232
  expect(result.nodes.map((n) => n.moduleId)).toEqual(['iptables']);
233
233
  });
234
234
 
235
+ test('a delegated provider is reached, though no manifest names it', () => {
236
+ // The case the whole chain exists for. `app` is BOUND to iptables, so the
237
+ // manifest walk stops there and the ISP router the packet actually leaves
238
+ // through never appears — even though iptables cannot reach the internet
239
+ // without it. celilo wires that edge at hook time; nothing declares it.
240
+ const multi: ProviderRow[] = [
241
+ { moduleId: 'iptables', capabilityName: 'firewall' },
242
+ { moduleId: 'axon', capabilityName: 'firewall' },
243
+ ];
244
+ const result = computeClosure({
245
+ rootModuleId: 'app',
246
+ manifests: new Map([
247
+ ['app', manifest({ requires: ['firewall'] })],
248
+ ['iptables', manifest({})],
249
+ ['axon', manifest({})],
250
+ ]),
251
+ providerRows: multi,
252
+ providerStates: deployed('iptables', 'axon'),
253
+ bindings: new Map([['app', new Map([['firewall', 'iptables']])]]),
254
+ chains: [{ capability: 'firewall', moduleIds: ['iptables', 'axon'] }],
255
+ });
256
+
257
+ expect(result.nodes.map((n) => n.moduleId)).toEqual(['iptables', 'axon']);
258
+ // Hop 2, because it is reached THROUGH iptables. Not a second hop-1
259
+ // dependency of the selection, which is what a fan would say.
260
+ expect(result.nodes.find((n) => n.moduleId === 'axon')?.hop).toBe(2);
261
+ // A packet has no alternative route to the internet.
262
+ expect(result.nodes.find((n) => n.moduleId === 'axon')?.optional).toBe(false);
263
+ expect(result.chains).toEqual([{ capability: 'firewall', moduleIds: ['iptables', 'axon'] }]);
264
+ });
265
+
266
+ test('a chain the walk never touched is not reported', () => {
267
+ // The chain is a fleet fact and the closure is about one module. A selection
268
+ // that consumes no firewall stands on none of it.
269
+ const result = computeClosure({
270
+ rootModuleId: 'site',
271
+ manifests,
272
+ providerRows: rows,
273
+ providerStates: deployed('cpanel', 'caddy', 'forgejo'),
274
+ chains: [{ capability: 'firewall', moduleIds: ['iptables', 'axon'] }],
275
+ });
276
+
277
+ expect(result.chains).toEqual([]);
278
+ });
279
+
280
+ test('the depth bound cuts a delegated provider like any other', () => {
281
+ // Three layers, bounded at two, so the LAST link is the one the bound cuts.
282
+ // The delegation edges are walked inside the same loop as the declared ones
283
+ // and get the same bound; a rule applied after the walk would smuggle the
284
+ // whole chain into a two-hop answer.
285
+ //
286
+ // A two-layer chain would not test this. `iptables` is the only declared
287
+ // dependency, so the walk exhausts at hop 2 whether or not the delegation
288
+ // is followed, and the assertion would hold for the wrong reason.
289
+ const result = computeClosure({
290
+ rootModuleId: 'app',
291
+ manifests: new Map([
292
+ ['app', manifest({ requires: ['firewall'] })],
293
+ ['iptables', manifest({})],
294
+ ['mid', manifest({})],
295
+ ['axon', manifest({})],
296
+ ]),
297
+ providerRows: [{ moduleId: 'iptables', capabilityName: 'firewall' }],
298
+ providerStates: deployed('iptables', 'mid', 'axon'),
299
+ depth: 2,
300
+ chains: [{ capability: 'firewall', moduleIds: ['iptables', 'mid', 'axon'] }],
301
+ });
302
+
303
+ expect(result.nodes.map((n) => n.moduleId)).toEqual(['iptables', 'mid']);
304
+ expect(result.truncated).toBe(true);
305
+ // Still reported WHOLE, including the member past the bound. Half a path is
306
+ // not a path, and the operator asked to see less, not to be told the packet
307
+ // stops at `mid`.
308
+ expect(result.chains[0]?.moduleIds).toEqual(['iptables', 'mid', 'axon']);
309
+ });
310
+
235
311
  test('a module with no bindings at all depends on nothing', () => {
236
312
  const result = computeClosure({
237
313
  rootModuleId: 'site',
@@ -57,6 +57,32 @@ export interface ClosureRequest {
57
57
  * without a database, not so the distinction is optional.
58
58
  */
59
59
  bindings?: ReadonlyMap<string, ReadonlyMap<string, string>>;
60
+ /**
61
+ * Ordered provider chains, most downstream first.
62
+ *
63
+ * These are edges NO MANIFEST DECLARES. celilo wires the `firewall`
64
+ * providers into a chain at hook time from `has_external`, so `iptables`
65
+ * genuinely stands on `axon` while neither manifest says a word about the
66
+ * other. A walk over manifests alone stops at `iptables`, and the box the
67
+ * packet actually leaves through never appears in the answer.
68
+ *
69
+ * Supplied by the caller rather than derived here so this stays pure. The CLI
70
+ * reads them with `loadFirewallChain`.
71
+ */
72
+ chains?: readonly DelegationChain[];
73
+ }
74
+
75
+ /**
76
+ * One capability's providers in delegation order.
77
+ *
78
+ * Ordered because for `firewall` they are not alternatives: the order is the
79
+ * packet's path. Same shape as the console protocol's `DelegationChain`, so the
80
+ * server hands it on rather than translating it.
81
+ */
82
+ export interface DelegationChain {
83
+ capability: string;
84
+ /** Module ids, most downstream first. */
85
+ moduleIds: string[];
60
86
  }
61
87
 
62
88
  export interface ClosureNode {
@@ -78,6 +104,15 @@ export interface ClosureNode {
78
104
 
79
105
  export interface ClosureResult {
80
106
  nodes: ClosureNode[];
107
+ /**
108
+ * The delegation chains this closure touched, whole.
109
+ *
110
+ * A chain is emitted when the walk reached ANY of its members, and it carries
111
+ * every member rather than only the reached ones, because half a path is not
112
+ * a path. Empty means the closure touched no chain — which, now that
113
+ * something computes them, is an answer rather than a gap.
114
+ */
115
+ chains: DelegationChain[];
81
116
  /** Hops actually walked. Echoes the request so the console can say what it bounded. */
82
117
  depth: number;
83
118
  /**
@@ -107,10 +142,35 @@ function optionalCapabilityNames(manifest: ConsumedCapabilities): Set<string> {
107
142
  );
108
143
  }
109
144
 
145
+ /**
146
+ * A chain's consecutive pairs, as edges out of each member.
147
+ *
148
+ * `[iptables, axon]` becomes one edge `iptables -> axon`. The last member has
149
+ * no edge out of it: it is the internet-facing end and delegates to nothing.
150
+ */
151
+ function delegationEdgesByModule(
152
+ chains: readonly DelegationChain[],
153
+ ): Map<string, { to: string; capability: string }[]> {
154
+ const byModule = new Map<string, { to: string; capability: string }[]>();
155
+ for (const chain of chains) {
156
+ for (let i = 0; i + 1 < chain.moduleIds.length; i += 1) {
157
+ const from = chain.moduleIds[i];
158
+ const to = chain.moduleIds[i + 1];
159
+ if (from === undefined || to === undefined) continue;
160
+ const out = byModule.get(from) ?? [];
161
+ out.push({ to, capability: chain.capability });
162
+ byModule.set(from, out);
163
+ }
164
+ }
165
+ return byModule;
166
+ }
167
+
110
168
  export function computeClosure(request: ClosureRequest): ClosureResult {
111
169
  const depth = request.depth ?? DEFAULT_CLOSURE_DEPTH;
112
170
  const providerRows = [...request.providerRows];
113
171
  const providerStates = [...request.providerStates];
172
+ const chains = request.chains ?? [];
173
+ const delegationsOut = delegationEdgesByModule(chains);
114
174
 
115
175
  const edges: Edge[] = [];
116
176
  // The root counts as visited from the start, so a cycle back to it terminates
@@ -165,10 +225,36 @@ export function computeClosure(request: ClosureRequest): ClosureResult {
165
225
  }
166
226
  }
167
227
  }
228
+
229
+ // The edges the manifests do not carry. Followed here, inside the same loop,
230
+ // so a delegated provider gets its hop, its cycle check and its depth bound
231
+ // from the one walk rather than from a second rule applied afterwards.
232
+ //
233
+ // Never optional: a packet has no alternative route to the internet, so a
234
+ // delegation is the strongest kind of dependency there is.
235
+ for (const moduleId of frontier) {
236
+ for (const edge of delegationsOut.get(moduleId) ?? []) {
237
+ if (edge.to === request.rootModuleId) continue;
238
+ edges.push({ to: edge.to, hop, capability: edge.capability, optional: false });
239
+ if (!visited.has(edge.to)) {
240
+ visited.add(edge.to);
241
+ next.push(edge.to);
242
+ }
243
+ }
244
+ }
245
+
168
246
  frontier = next;
169
247
  }
170
248
 
171
- return { nodes: collectNodes(edges), depth, truncated };
249
+ const nodes = collectNodes(edges);
250
+ const reached = new Set([request.rootModuleId, ...nodes.map((node) => node.moduleId)]);
251
+
252
+ return {
253
+ nodes,
254
+ chains: chains.filter((chain) => chain.moduleIds.some((id) => reached.has(id))),
255
+ depth,
256
+ truncated,
257
+ };
172
258
  }
173
259
 
174
260
  /**
@@ -8,7 +8,7 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
8
8
  import type { DbClient } from '../db/client';
9
9
  import { NETWORK_ZONES } from '../db/schema';
10
10
  import { cleanupTestDatabase, setupTestDatabase } from '../test-utils/database';
11
- import { consoleStatus } from './projection';
11
+ import { consoleStatus, loadClosureInputs } from './projection';
12
12
 
13
13
  /** A manifest big enough to notice if it ever leaked into the payload. */
14
14
  const FAT_MANIFEST = JSON.stringify({
@@ -229,3 +229,65 @@ describe('consoleStatus derived facts', () => {
229
229
  expect(moduleOf('forgejo').backupStale).toBe(true);
230
230
  });
231
231
  });
232
+
233
+ /**
234
+ * The reach probe for the chain (CLAUDE.md, "a check that cannot reach the
235
+ * thing it is checking").
236
+ *
237
+ * `orderFirewallChain` is unit-tested against hand-built rows. That says
238
+ * nothing about whether the closure's loader queries the right table with the
239
+ * right filter, and a wrong query here returns a well-formed empty list that
240
+ * reads exactly like a fleet with one firewall. So this drives the REAL loader
241
+ * against real `capabilities` rows.
242
+ */
243
+ describe('loadClosureInputs, and the chain it carries', () => {
244
+ let db: DbClient;
245
+
246
+ beforeEach(async () => {
247
+ db = await setupTestDatabase();
248
+ for (const id of ['iptables', 'axon', 'caddy']) {
249
+ db.$client.run(
250
+ `INSERT INTO modules (id, name, version, source_path, manifest_data, state)
251
+ VALUES ('${id}', '${id}', '1.0.0', '/path', '{}', 'VERIFIED')`,
252
+ );
253
+ }
254
+ });
255
+
256
+ afterEach(async () => {
257
+ await cleanupTestDatabase(db);
258
+ });
259
+
260
+ function firewallProvider(moduleId: string, data: string) {
261
+ db.$client.run(
262
+ `INSERT INTO capabilities (module_id, capability_name, version, data)
263
+ VALUES ('${moduleId}', 'firewall', '1.0.0', '${data}')`,
264
+ );
265
+ }
266
+
267
+ test('reads the live registration and orders it, iptables first', () => {
268
+ firewallProvider('iptables', '{"firewall_ip":"192.168.0.254","nat_ip":"192.168.0.253"}');
269
+ firewallProvider('axon', '{"has_external":true,"router_ip":"192.168.0.1"}');
270
+
271
+ expect(loadClosureInputs(db).chains).toEqual([
272
+ { capability: 'firewall', moduleIds: ['iptables', 'axon'] },
273
+ ]);
274
+ });
275
+
276
+ test('a non-firewall capability is not swept into the chain', () => {
277
+ // `axon` provides dhcp_server too. A loader that read every row would put a
278
+ // module in the packet's path on the strength of an unrelated capability.
279
+ firewallProvider('iptables', '{"firewall_ip":"192.168.0.254","nat_ip":"192.168.0.253"}');
280
+ firewallProvider('axon', '{"has_external":true}');
281
+ db.$client.run(
282
+ `INSERT INTO capabilities (module_id, capability_name, version, data)
283
+ VALUES ('caddy', 'public_web', '1.0.0', '{}')`,
284
+ );
285
+
286
+ expect(loadClosureInputs(db).chains[0]?.moduleIds).toEqual(['iptables', 'axon']);
287
+ });
288
+
289
+ test('a fleet with one firewall has no chain', () => {
290
+ firewallProvider('axon', '{"has_external":true}');
291
+ expect(loadClosureInputs(db).chains).toEqual([]);
292
+ });
293
+ });
@@ -14,16 +14,25 @@
14
14
  * succeed quietly.
15
15
  */
16
16
 
17
+ import { FIREWALL_CAPABILITY_NAME, orderFirewallChain } from '@celilo/capabilities';
17
18
  import { desc, eq } from 'drizzle-orm';
18
19
  import { z } from 'zod';
19
20
  import type { DbClient } from '../db/client';
20
- import { NETWORK_ZONES, backups, moduleSystems, modules, monitors } from '../db/schema';
21
+ import {
22
+ NETWORK_ZONES,
23
+ backups,
24
+ capabilities,
25
+ moduleSystems,
26
+ modules,
27
+ monitors,
28
+ } from '../db/schema';
21
29
  import { loadObservedHealthDetail } from '../services/alerting/observed-health';
22
30
  import { loadBackupAuditInfo } from '../services/audit/backup-source';
23
31
  import { backupStaleThresholdMs, moduleHasBackupHook } from '../services/audit/backups';
24
32
  import { effectiveBackupSchedule } from '../services/backup-schedule';
25
33
  import { listCapabilityBindings } from '../services/capability-bindings';
26
34
  import type { ConsumedCapabilities } from '../services/consumer-cleanup';
35
+ import type { DelegationChain } from './closure';
27
36
 
28
37
  /**
29
38
  * Just the capability lists, validated at the boundary.
@@ -302,10 +311,18 @@ export function loadBindings(db: DbClient, moduleId: string): Map<string, string
302
311
  );
303
312
  }
304
313
 
305
- /** Everything the closure walk needs, read in one place. */
314
+ /**
315
+ * Everything the closure walk needs, read in ONE place.
316
+ *
317
+ * The chains are in here rather than beside it deliberately. A separate loader
318
+ * a caller has to remember is a caller that forgets, and the answer when it
319
+ * forgets is an empty chain list that reads exactly like a fleet with no
320
+ * delegation. There is one call site, and it cannot get a partial set.
321
+ */
306
322
  export function loadClosureInputs(db: DbClient): {
307
323
  manifests: Map<string, ConsumedCapabilities>;
308
324
  providerStates: { moduleId: string; state: string }[];
325
+ chains: DelegationChain[];
309
326
  } {
310
327
  return {
311
328
  manifests: new Map(
@@ -316,9 +333,29 @@ export function loadClosureInputs(db: DbClient): {
316
333
  .map((row) => [row.id, CONSUMED_CAPABILITIES.parse(row.manifestData)] as const),
317
334
  ),
318
335
  providerStates: db.select({ moduleId: modules.id, state: modules.state }).from(modules).all(),
336
+ chains: loadFirewallChain(db),
319
337
  };
320
338
  }
321
339
 
340
+ /**
341
+ * The `firewall` providers in delegation order, as at most one chain.
342
+ *
343
+ * At most one because `firewall` is the only capability celilo chains, and
344
+ * `orderFirewallChain` — the SAME function the hook loader wires the live
345
+ * providers with — is what decides the order. A fleet with one firewall, or
346
+ * with none that reaches the internet, yields no chain at all.
347
+ */
348
+ export function loadFirewallChain(db: DbClient): DelegationChain[] {
349
+ const providers = db
350
+ .select({ moduleId: capabilities.moduleId, data: capabilities.data })
351
+ .from(capabilities)
352
+ .where(eq(capabilities.capabilityName, FIREWALL_CAPABILITY_NAME))
353
+ .all();
354
+
355
+ const ordered = orderFirewallChain(providers).map((provider) => provider.moduleId);
356
+ return ordered.length === 0 ? [] : [{ capability: FIREWALL_CAPABILITY_NAME, moduleIds: ordered }];
357
+ }
358
+
322
359
  /** A module's row, or undefined. Used to reject a closure request for a module that is gone. */
323
360
  export function moduleExists(db: DbClient, moduleId: string): boolean {
324
361
  return (
@@ -0,0 +1,124 @@
1
+ /**
2
+ * `control_plane_api` reaches a consuming module, and only a consuming module.
3
+ *
4
+ * The rest of `loadCapabilityFunctions` hands over every capability it can
5
+ * build, declared or not — its own comment says "not just required ones", and
6
+ * `capability-loader-bindings.test.ts` asserts that shape deliberately. That is
7
+ * fine for a capability whose worst outcome is an unused OIDC client.
8
+ *
9
+ * This one mints an SSH principal into celilo's own control plane, so it is
10
+ * gated on the `requires` line instead. These pin both halves, because the
11
+ * failure that matters is silent in both directions: a console that declared it
12
+ * and got nothing fails at install with "no provider is loaded" for a capability
13
+ * no module ever provides, and a module that declared nothing and got it anyway
14
+ * holds control-plane read that no manifest review would have shown.
15
+ */
16
+
17
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
18
+ import type { HookLogger } from '@celilo/capabilities';
19
+ import type { DbClient } from '../db/client';
20
+ import { cleanupTestDatabase, setupTestDatabase } from '../test-utils/database';
21
+ import { loadCapabilityFunctions } from './capability-loader';
22
+
23
+ const noopLogger: HookLogger = {
24
+ info() {},
25
+ warn() {},
26
+ error() {},
27
+ success() {},
28
+ };
29
+
30
+ function installModule(db: DbClient, moduleId: string, manifest: Record<string, unknown>): void {
31
+ db.$client.run(
32
+ `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES (?, ?, '1.0.0', '/tmp/${moduleId}', ?)`,
33
+ [moduleId, moduleId, JSON.stringify(manifest)],
34
+ );
35
+ }
36
+
37
+ const DECLARES = {
38
+ requires: { capabilities: [{ name: 'control_plane_api', version: '1.0.0' }] },
39
+ };
40
+
41
+ describe('control_plane_api injection', () => {
42
+ let db: DbClient;
43
+
44
+ beforeEach(async () => {
45
+ db = await setupTestDatabase();
46
+ });
47
+
48
+ afterEach(async () => {
49
+ await cleanupTestDatabase(db);
50
+ });
51
+
52
+ test('a module that requires it gets it, with no provider module deployed', async () => {
53
+ // The point of the whole decision: nothing provides this capability, and it
54
+ // is still there. A test that first deployed a provider would be measuring
55
+ // the ordinary path.
56
+ installModule(db, 'celilo-web-console', DECLARES);
57
+
58
+ const capabilities = await loadCapabilityFunctions('celilo-web-console', db, noopLogger);
59
+
60
+ expect(capabilities.control_plane_api).toBeTruthy();
61
+ const api = capabilities.control_plane_api as Record<string, unknown>;
62
+ expect(typeof api.enrol_principal).toBe('function');
63
+ expect(typeof api.revoke_principal).toBe('function');
64
+ });
65
+
66
+ test('a module that declares it as optional gets it too', async () => {
67
+ // `optional` is a declaration a reviewer reads on the same line of the same
68
+ // file. Treating it as no declaration would fail the module at runtime for a
69
+ // capability its manifest names.
70
+ installModule(db, 'someday-console', {
71
+ optional: { capabilities: [{ name: 'control_plane_api', version: '1.0.0' }] },
72
+ });
73
+
74
+ const capabilities = await loadCapabilityFunctions('someday-console', db, noopLogger);
75
+
76
+ expect(capabilities.control_plane_api).toBeTruthy();
77
+ });
78
+
79
+ test('a module that declares nothing does NOT get it', async () => {
80
+ installModule(db, 'hello-foo', { requires: { capabilities: [] } });
81
+
82
+ const capabilities = await loadCapabilityFunctions('hello-foo', db, noopLogger);
83
+
84
+ expect(capabilities.control_plane_api).toBeUndefined();
85
+ });
86
+
87
+ test('a module that requires something ELSE does NOT get it', async () => {
88
+ // The near miss: a manifest with a populated `requires` block is the shape a
89
+ // too-loose check ("does this module require anything?") would wave through.
90
+ installModule(db, 'hello-bar', {
91
+ requires: { capabilities: [{ name: 'idp', version: '1.0.0' }] },
92
+ });
93
+
94
+ const capabilities = await loadCapabilityFunctions('hello-bar', db, noopLogger);
95
+
96
+ expect(capabilities.control_plane_api).toBeUndefined();
97
+ });
98
+
99
+ test('a module celilo has no record of does NOT get it', async () => {
100
+ // A hook can run for a module id with no row (a removal path, a typo). The
101
+ // manifest lookup returns nothing, and "no manifest" must read as "declared
102
+ // nothing" rather than throwing or defaulting open.
103
+ const capabilities = await loadCapabilityFunctions('never-imported', db, noopLogger);
104
+
105
+ expect(capabilities.control_plane_api).toBeUndefined();
106
+ });
107
+
108
+ test('the injected object is scoped to the module it was built for', async () => {
109
+ // Scoping is what stops a module rotating a neighbour's key. It is asserted
110
+ // through the loader rather than only on `buildControlPlaneApi`, because the
111
+ // loader is where the wrong argument (a provider id, a hook name) would be
112
+ // passed.
113
+ installModule(db, 'hello-foo', DECLARES);
114
+
115
+ const capabilities = await loadCapabilityFunctions('hello-foo', db, noopLogger);
116
+ const api = capabilities.control_plane_api as {
117
+ revoke_principal(request: { name: string }): Promise<unknown>;
118
+ };
119
+
120
+ await expect(api.revoke_principal({ name: 'celilo-web-console' })).rejects.toThrow(
121
+ /only revoke its own principal/,
122
+ );
123
+ });
124
+ });
@@ -15,6 +15,7 @@ import { join } from 'node:path';
15
15
  import {
16
16
  createPublicWeb,
17
17
  isCompiledCapabilityFactory,
18
+ orderFirewallChain,
18
19
  wrapWithLogging,
19
20
  } from '@celilo/capabilities';
20
21
  import type {
@@ -38,8 +39,10 @@ import {
38
39
  systemConfig,
39
40
  webRoutes,
40
41
  } from '../db/schema';
42
+ import { resolveModuleWebRoot } from '../module/web-root';
41
43
  import { decryptSecret } from '../secrets/encryption';
42
44
  import { getOrCreateMasterKey } from '../secrets/master-key';
45
+ import { buildControlPlaneApi } from '../services/api-principal-enrolment';
43
46
  import { recordCapabilityBinding, withBindingRecord } from '../services/capability-bindings';
44
47
  import { emitWebRoutesChangedAndWait } from '../services/celilo-events';
45
48
  import { getModuleSystems } from '../services/deployed-systems';
@@ -219,6 +222,26 @@ export async function resolveCaddyZoneIp(db: DbClient): Promise<string | undefin
219
222
  return undefined;
220
223
  }
221
224
 
225
+ /**
226
+ * Does this module's manifest declare `capabilityName`, under `requires` or
227
+ * `optional`?
228
+ *
229
+ * Reads the manifest stored at import rather than the one on disk, so the
230
+ * answer is the one the operator's `module import` actually validated.
231
+ */
232
+ function consumerDeclares(db: DbClient, moduleId: string, capabilityName: string): boolean {
233
+ const row = db.select().from(modules).where(eq(modules.id, moduleId)).get();
234
+ const manifest = row?.manifestData;
235
+ if (!manifest) return false;
236
+
237
+ type Declarations = { capabilities?: { name?: string }[] } | undefined;
238
+ const declared = [
239
+ ...((manifest.requires as Declarations)?.capabilities ?? []),
240
+ ...((manifest.optional as Declarations)?.capabilities ?? []),
241
+ ];
242
+ return declared.some((cap) => cap?.name === capabilityName);
243
+ }
244
+
222
245
  export async function loadCapabilityFunctions(
223
246
  consumingModuleId: string,
224
247
  db: DbClient,
@@ -380,6 +403,11 @@ export async function loadCapabilityFunctions(
380
403
  // which made a module-provided capability structurally unable to
381
404
  // offer `unregisterRoutes()`-style methods.
382
405
  consumerModuleId: consumingModuleId,
406
+ // WHERE THE CALLER'S BYTES ARE, for the same reason and by the same
407
+ // seam. `private_web` and `external_web` publish static sites too, and
408
+ // without this they could not find the caller's web root now that
409
+ // `sourceDir` has left the request (D10 amendment).
410
+ consumerWebRoot: resolveModuleWebRoot(consumingModuleId, db),
383
411
  });
384
412
  // Stamp here too, not only on the legacy path: a consumer that cannot
385
413
  // get what it needs must be able to name WHICH provider could not give
@@ -563,6 +591,9 @@ export async function loadCapabilityFunctions(
563
591
  providerByCapability.set(provider.capabilityName, provider.moduleId);
564
592
  result.public_web = createPublicWeb({
565
593
  moduleId: consumingModuleId,
594
+ // Resolved here, not in the capability: on a converge there is no
595
+ // consumer process to ask (D10 amendment).
596
+ webRoot: resolveModuleWebRoot(consumingModuleId, db),
566
597
  logger,
567
598
  config: providerConfig,
568
599
  secrets: providerSecrets,
@@ -641,6 +672,30 @@ export async function loadCapabilityFunctions(
641
672
  debugLog('public_web: not registered in DB, skipping');
642
673
  }
643
674
 
675
+ // Framework-granted, so unlike everything above there is no provider row to
676
+ // look up and no script to import — celilo IS the management server whose
677
+ // principals these are (web-ui-console D7b).
678
+ //
679
+ // Gated on the DECLARATION, which the rest of this function deliberately is
680
+ // not: the loop injects every capability it can build "not just required
681
+ // ones", so a module that never asked still gets `idp` and `firewall`. That is
682
+ // fine for a capability whose worst outcome is an unused OIDC client. This one
683
+ // mints an SSH principal into celilo's own control plane, so the `requires`
684
+ // line is the authorization and a module that did not write one does not get
685
+ // the object at all.
686
+ if (consumerDeclares(db, consumingModuleId, 'control_plane_api')) {
687
+ // Wrapped like every other capability, so an enrolment that fails mid-deploy
688
+ // leaves a `✗ control_plane_api.enrol_principal` in the hook log rather than
689
+ // only a thrown error further up. The `defineCapabilityFunction` path wraps
690
+ // itself; a framework-built table has to be wrapped here.
691
+ result.control_plane_api = wrapWithLogging(
692
+ buildControlPlaneApi(consumingModuleId),
693
+ logger,
694
+ 'control_plane_api',
695
+ );
696
+ debugLog(`control_plane_api: framework-granted, injected for ${consumingModuleId}`);
697
+ }
698
+
644
699
  // celilo#1072: the CALL is the binding, not the resolution. Everything above
645
700
  // is injected whether or not the consumer declared it — the loop's own
646
701
  // comment says "not just required ones" — so recording what was resolved
@@ -1104,20 +1159,19 @@ async function buildFirewallChain(
1104
1159
  // attributable. Only the layers that render their own ruleset receive it.
1105
1160
  const trustedSourceStore = buildTrustedSourceStore(db, consumingModuleId);
1106
1161
 
1107
- // Find the provider with has_external (the leaf — has direct internet access)
1108
- const hasExternal = allProviders.find((p) => {
1109
- const data = (typeof p.data === 'string' ? JSON.parse(p.data) : p.data) as Record<
1110
- string,
1111
- unknown
1112
- >;
1113
- return data.has_external === true;
1114
- });
1162
+ // The delegation order, most downstream first. Shared with the console's
1163
+ // closure so the wiring and the picture cannot disagree about which firewall
1164
+ // stands on which — see `orderFirewallChain`.
1165
+ const chainOrder = orderFirewallChain(allProviders);
1115
1166
 
1116
- if (!hasExternal) {
1167
+ if (chainOrder.length === 0) {
1117
1168
  debugLog('firewall chain: no provider with external interface found');
1118
1169
  return { chain: null, self: null };
1119
1170
  }
1120
1171
 
1172
+ // The leaf is the upstream end: the provider with the direct internet leg.
1173
+ const hasExternal = chainOrder[chainOrder.length - 1] as (typeof chainOrder)[number];
1174
+
1121
1175
  // Build the leaf (external) firewall first
1122
1176
  const leafModule = db.select().from(modules).where(eq(modules.id, hasExternal.moduleId)).get();
1123
1177
  if (!leafModule) return { chain: null, self: null };
@@ -1173,7 +1227,10 @@ async function buildFirewallChain(
1173
1227
  // Build downstream providers, wiring each to the upstream. iptables
1174
1228
  // remains a legacy factory because its second arg (upstreamFirewall)
1175
1229
  // doesn't fit defineCapabilityFunction's single-context shape.
1176
- const downstream = allProviders.filter((p) => p.moduleId !== hasExternal.moduleId);
1230
+ // Back into wiring order: each layer is built taking the previous one as its
1231
+ // upstream, so the build runs from the leaf outwards while the chain reads
1232
+ // from the consumer inwards.
1233
+ const downstream = chainOrder.slice(0, -1).reverse();
1177
1234
 
1178
1235
  // Each layer as its OWN provider sees it, so `on_consumer_removed` converges
1179
1236
  // the firewall that declares the hook rather than whichever layer a consumer