@celilo/cli 1.11.0 → 1.13.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 (50) hide show
  1. package/CELILO_CORE_MODULES.md +2 -1
  2. package/CELILO_SUBSYSTEMS.md +17 -2
  3. package/package.json +3 -3
  4. package/src/cli/commands/alerts-list.ts +16 -1
  5. package/src/cli/commands/backup-list.test.ts +82 -1
  6. package/src/cli/commands/backup-list.ts +113 -4
  7. package/src/cli/commands/console.ts +122 -0
  8. package/src/cli/commands/module-list.ts +3 -41
  9. package/src/cli/commands/module-publish.ts +2 -0
  10. package/src/cli/completion.ts +5 -0
  11. package/src/cli/index.ts +25 -1
  12. package/src/console/closure.test.ts +246 -0
  13. package/src/console/closure.ts +208 -0
  14. package/src/console/control-plane-boundary.test.ts +75 -0
  15. package/src/console/projection.test.ts +231 -0
  16. package/src/console/projection.ts +327 -0
  17. package/src/db/schema.ts +19 -14
  18. package/src/hooks/broker.test.ts +4 -6
  19. package/src/hooks/executor.test.ts +85 -4
  20. package/src/hooks/executor.ts +164 -9
  21. package/src/hooks/hook-jail-unreachability.test.ts +173 -0
  22. package/src/hooks/hook-state-dir.test.ts +14 -2
  23. package/src/hooks/hook-timeout.test.ts +2 -4
  24. package/src/hooks/hook-trespass.test.ts +50 -5
  25. package/src/hooks/jail.test.ts +370 -0
  26. package/src/hooks/jail.ts +491 -0
  27. package/src/hooks/mount-set.ts +24 -0
  28. package/src/hooks/test-fixtures/jail-probe-hook.ts +59 -0
  29. package/src/manifest/icon-schema.test.ts +48 -0
  30. package/src/manifest/schema.ts +92 -0
  31. package/src/manifest/validate.test.ts +142 -0
  32. package/src/manifest/validate.ts +101 -0
  33. package/src/module/import.test.ts +116 -0
  34. package/src/module/import.ts +73 -1
  35. package/src/module/packaging/audit.ts +103 -1
  36. package/src/module/packaging/classify-module-path.test.ts +36 -0
  37. package/src/module/packaging/package-rules.ts +18 -0
  38. package/src/policy/capability-shape-baseline.ts +8 -0
  39. package/src/policy/module-business-baseline.ts +12 -0
  40. package/src/registry/client.ts +9 -0
  41. package/src/services/alerting/observed-health.ts +71 -0
  42. package/src/services/api-principal-enrolment.test.ts +179 -0
  43. package/src/services/api-principal-enrolment.ts +103 -0
  44. package/src/services/audit/backups.ts +10 -1
  45. package/src/services/backup-metadata.ts +19 -11
  46. package/src/services/consumer-cleanup.ts +31 -5
  47. package/src/services/instance-ops.test.ts +302 -0
  48. package/src/services/instance-ops.ts +292 -0
  49. package/src/services/module-instances.test.ts +428 -42
  50. package/src/services/module-instances.ts +219 -26
package/src/cli/index.ts CHANGED
@@ -25,6 +25,7 @@ import { handleCapabilityInfo } from './commands/capability-info';
25
25
  import { handleCapabilityList } from './commands/capability-list';
26
26
  import { handleCommands } from './commands/commands-json';
27
27
  import { handleCompletion } from './commands/completion';
28
+ import { handleConsoleGet, handleConsoleStatus } from './commands/console';
28
29
  import { handleDnsRegistrations } from './commands/dns';
29
30
  import {
30
31
  handleEventsAck,
@@ -225,6 +226,7 @@ Commands:
225
226
  api Manage remote-API access (principals, grants, authorized_keys)
226
227
  completion Generate shell completion scripts (bash/zsh)
227
228
  commands Print the CLI command registry as JSON (drives @celilo/mcp)
229
+ console Narrow read-only projections for the web console
228
230
 
229
231
  help, --help, -h Show this help message
230
232
 
@@ -803,7 +805,11 @@ Subcommands:
803
805
 
804
806
  list [module-id] List available backups
805
807
  Options:
806
- --limit <n> Number of backups to show (default: 20)
808
+ --limit <n> Number of backups to show (default: 20; 5000 with --json)
809
+ --json Emit the listing as JSON with EXACT timestamps.
810
+ The human output buckets ages past six days into
811
+ "last week", so it cannot be parsed back into days.
812
+ --since <days> Only attempts from the last N days
807
813
 
808
814
  restore <backup-id> Restore from a backup
809
815
  Options:
@@ -1275,6 +1281,24 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
1275
1281
  return handleCommands(parsed.args, parsed.flags);
1276
1282
  }
1277
1283
 
1284
+ // The web console's narrow reads.
1285
+ if (parsed.command === 'console') {
1286
+ const flagError = checkFlags('console', parsed.subcommand, parsed.flags, parsed.args);
1287
+ if (flagError) return flagError;
1288
+
1289
+ switch (parsed.subcommand) {
1290
+ case 'status':
1291
+ return handleConsoleStatus(parsed.flags);
1292
+ case 'get':
1293
+ return handleConsoleGet(parsed.args, parsed.flags);
1294
+ default:
1295
+ return {
1296
+ success: false,
1297
+ error: 'Console subcommand required: status | get',
1298
+ };
1299
+ }
1300
+ }
1301
+
1278
1302
  // Top-level alias: `celilo audit` → `celilo system audit`
1279
1303
  if (parsed.command === 'audit') {
1280
1304
  return handleSystemAudit(parsed.args, parsed.flags);
@@ -0,0 +1,246 @@
1
+ /**
2
+ * The six graph shapes the plan names, five of which the live fleet produced.
3
+ *
4
+ * They are here rather than in an e2e suite because every one of them is a
5
+ * property of the walk, and a walk is testable without a fleet.
6
+ */
7
+ import { describe, expect, test } from 'bun:test';
8
+ import type { ModuleManifest } from '../manifest/schema';
9
+ import type { ProviderRow, ProviderState } from '../services/consumer-cleanup';
10
+ import { DEFAULT_CLOSURE_DEPTH, UNBOUNDED_CLOSURE_DEPTH, computeClosure } from './closure';
11
+
12
+ /** Only the two capability lists matter here; the rest of a manifest does not. */
13
+ function manifest(spec: { requires?: string[]; optional?: string[] }): ModuleManifest {
14
+ return {
15
+ requires: { capabilities: (spec.requires ?? []).map((name) => ({ name })) },
16
+ optional: { capabilities: (spec.optional ?? []).map((name) => ({ name })) },
17
+ } as unknown as ModuleManifest;
18
+ }
19
+
20
+ function deployed(...moduleIds: string[]): ProviderState[] {
21
+ return moduleIds.map((moduleId) => ({ moduleId, state: 'VERIFIED' }));
22
+ }
23
+
24
+ describe('computeClosure', () => {
25
+ test('follows an optional-only edge, and marks it optional', () => {
26
+ // The live case: a module reaches the internal resolver ONLY optionally.
27
+ // Following `requires` alone drops the resolver while looking complete.
28
+ const rows: ProviderRow[] = [{ moduleId: 'technitium', capabilityName: 'dns_internal' }];
29
+ const result = computeClosure({
30
+ rootModuleId: 'wireguard-manager',
31
+ manifests: new Map([['wireguard-manager', manifest({ optional: ['dns_internal'] })]]),
32
+ providerRows: rows,
33
+ providerStates: deployed('technitium'),
34
+ });
35
+
36
+ expect(result.nodes).toHaveLength(1);
37
+ expect(result.nodes[0]?.moduleId).toBe('technitium');
38
+ expect(result.nodes[0]?.optional).toBe(true);
39
+ });
40
+
41
+ test('a module reached both ways is not optional', () => {
42
+ const rows: ProviderRow[] = [
43
+ { moduleId: 'caddy', capabilityName: 'private_web' },
44
+ { moduleId: 'caddy', capabilityName: 'public_web' },
45
+ ];
46
+ const result = computeClosure({
47
+ rootModuleId: 'app',
48
+ manifests: new Map([
49
+ ['app', manifest({ requires: ['private_web'], optional: ['public_web'] })],
50
+ ]),
51
+ providerRows: rows,
52
+ providerStates: deployed('caddy'),
53
+ });
54
+
55
+ expect(result.nodes[0]?.optional).toBe(false);
56
+ expect(result.nodes[0]?.via).toEqual(['private_web', 'public_web']);
57
+ });
58
+
59
+ test('the default depth excludes a three-hop dependency', () => {
60
+ const rows: ProviderRow[] = [
61
+ { moduleId: 'b', capabilityName: 'cap_b' },
62
+ { moduleId: 'c', capabilityName: 'cap_c' },
63
+ { moduleId: 'd', capabilityName: 'cap_d' },
64
+ ];
65
+ const manifests = new Map([
66
+ ['a', manifest({ requires: ['cap_b'] })],
67
+ ['b', manifest({ requires: ['cap_c'] })],
68
+ ['c', manifest({ requires: ['cap_d'] })],
69
+ ['d', manifest({})],
70
+ ]);
71
+ const result = computeClosure({
72
+ rootModuleId: 'a',
73
+ manifests,
74
+ providerRows: rows,
75
+ providerStates: deployed('b', 'c', 'd'),
76
+ });
77
+
78
+ expect(result.depth).toBe(DEFAULT_CLOSURE_DEPTH);
79
+ expect(result.nodes.map((n) => n.moduleId)).toEqual(['b', 'c']);
80
+ expect(result.truncated).toBe(true);
81
+ });
82
+
83
+ test('an unbounded walk terminates on a cycle, listing each module once', () => {
84
+ // caddy requires authentik, authentik requires caddy. The real graph.
85
+ const rows: ProviderRow[] = [
86
+ { moduleId: 'authentik', capabilityName: 'idp' },
87
+ { moduleId: 'caddy', capabilityName: 'private_web' },
88
+ ];
89
+ const manifests = new Map([
90
+ ['caddy', manifest({ requires: ['idp'] })],
91
+ ['authentik', manifest({ requires: ['private_web'] })],
92
+ ]);
93
+ const result = computeClosure({
94
+ rootModuleId: 'caddy',
95
+ manifests,
96
+ providerRows: rows,
97
+ providerStates: deployed('caddy', 'authentik'),
98
+ depth: UNBOUNDED_CLOSURE_DEPTH,
99
+ });
100
+
101
+ expect(result.nodes.map((n) => n.moduleId)).toEqual(['authentik']);
102
+ expect(result.truncated).toBe(false);
103
+ });
104
+
105
+ test('a capability with several providers highlights every one', () => {
106
+ // firewall is deliberately multi-provider: an edge provider plus inner layers.
107
+ const rows: ProviderRow[] = [
108
+ { moduleId: 'iptables', capabilityName: 'firewall' },
109
+ { moduleId: 'opnsense', capabilityName: 'firewall' },
110
+ ];
111
+ const result = computeClosure({
112
+ rootModuleId: 'app',
113
+ manifests: new Map([['app', manifest({ requires: ['firewall'] })]]),
114
+ providerRows: rows,
115
+ providerStates: deployed('iptables', 'opnsense'),
116
+ });
117
+
118
+ expect(result.nodes.map((n) => n.moduleId)).toEqual(['iptables', 'opnsense']);
119
+ });
120
+
121
+ test('a module that depends on nothing returns an empty closure, not a failure', () => {
122
+ const result = computeClosure({
123
+ rootModuleId: 'standalone',
124
+ manifests: new Map([['standalone', manifest({})]]),
125
+ providerRows: [],
126
+ providerStates: [],
127
+ });
128
+
129
+ expect(result.nodes).toEqual([]);
130
+ expect(result.truncated).toBe(false);
131
+ });
132
+
133
+ test('a module that provides what it consumes is not its own dependency', () => {
134
+ const rows: ProviderRow[] = [
135
+ { moduleId: 'caddy', capabilityName: 'private_web' },
136
+ { moduleId: 'other', capabilityName: 'private_web' },
137
+ ];
138
+ const result = computeClosure({
139
+ rootModuleId: 'caddy',
140
+ manifests: new Map([['caddy', manifest({ requires: ['private_web'] })]]),
141
+ providerRows: rows,
142
+ providerStates: deployed('caddy', 'other'),
143
+ });
144
+
145
+ expect(result.nodes.map((n) => n.moduleId)).toEqual(['other']);
146
+ });
147
+
148
+ test('the shortest hop wins when a module is reachable two ways', () => {
149
+ const rows: ProviderRow[] = [
150
+ { moduleId: 'b', capabilityName: 'cap_b' },
151
+ { moduleId: 'shared', capabilityName: 'cap_shared' },
152
+ { moduleId: 'shared', capabilityName: 'cap_deep' },
153
+ ];
154
+ const manifests = new Map([
155
+ ['a', manifest({ requires: ['cap_b', 'cap_shared'] })],
156
+ ['b', manifest({ requires: ['cap_deep'] })],
157
+ ['shared', manifest({})],
158
+ ]);
159
+ const result = computeClosure({
160
+ rootModuleId: 'a',
161
+ manifests,
162
+ providerRows: rows,
163
+ providerStates: deployed('b', 'shared'),
164
+ });
165
+
166
+ const shared = result.nodes.find((n) => n.moduleId === 'shared');
167
+ expect(shared?.hop).toBe(1);
168
+ });
169
+ });
170
+
171
+ describe('the walk follows real bindings, not declarations', () => {
172
+ const rows: ProviderRow[] = [
173
+ { moduleId: 'cpanel', capabilityName: 'external_web' },
174
+ { moduleId: 'caddy', capabilityName: 'public_web' },
175
+ { moduleId: 'forgejo', capabilityName: 'source_forge' },
176
+ ];
177
+ const manifests = new Map([
178
+ ['site', manifest({ optional: ['external_web', 'public_web', 'source_forge'] })],
179
+ ['cpanel', manifest({})],
180
+ ['caddy', manifest({})],
181
+ ['forgejo', manifest({})],
182
+ ]);
183
+
184
+ test('a declared capability the module never called into is NOT a dependency', () => {
185
+ // The tango-nexus case, which is what celilo#1072 was filed for: four
186
+ // optional declarations, one actual binding. Following the declarations
187
+ // claims the module is standing on three things it has never touched.
188
+ const result = computeClosure({
189
+ rootModuleId: 'site',
190
+ manifests,
191
+ providerRows: rows,
192
+ providerStates: deployed('cpanel', 'caddy', 'forgejo'),
193
+ bindings: new Map([['site', new Map([['external_web', 'cpanel']])]]),
194
+ });
195
+
196
+ expect(result.nodes.map((n) => n.moduleId)).toEqual(['cpanel']);
197
+ });
198
+
199
+ test('without bindings the walk falls back to declarations, and says more', () => {
200
+ // Not a silent fallback: the caller decides. A walk with no binding data
201
+ // answers "what could this reach", which is a different and still useful
202
+ // question, and it must not be mistaken for the other one.
203
+ const result = computeClosure({
204
+ rootModuleId: 'site',
205
+ manifests,
206
+ providerRows: rows,
207
+ providerStates: deployed('cpanel', 'caddy', 'forgejo'),
208
+ });
209
+
210
+ expect(result.nodes.map((n) => n.moduleId).sort()).toEqual(['caddy', 'cpanel', 'forgejo']);
211
+ });
212
+
213
+ test('a binding to a DIFFERENT provider of the same capability is not followed', () => {
214
+ // firewall has several live providers. A consumer bound to one of them is
215
+ // not thereby standing on the others.
216
+ const multi: ProviderRow[] = [
217
+ { moduleId: 'iptables', capabilityName: 'firewall' },
218
+ { moduleId: 'axon', capabilityName: 'firewall' },
219
+ ];
220
+ const result = computeClosure({
221
+ rootModuleId: 'app',
222
+ manifests: new Map([
223
+ ['app', manifest({ requires: ['firewall'] })],
224
+ ['iptables', manifest({})],
225
+ ['axon', manifest({})],
226
+ ]),
227
+ providerRows: multi,
228
+ providerStates: deployed('iptables', 'axon'),
229
+ bindings: new Map([['app', new Map([['firewall', 'iptables']])]]),
230
+ });
231
+
232
+ expect(result.nodes.map((n) => n.moduleId)).toEqual(['iptables']);
233
+ });
234
+
235
+ test('a module with no bindings at all depends on nothing', () => {
236
+ const result = computeClosure({
237
+ rootModuleId: 'site',
238
+ manifests,
239
+ providerRows: rows,
240
+ providerStates: deployed('cpanel', 'caddy', 'forgejo'),
241
+ bindings: new Map([['site', new Map()]]),
242
+ });
243
+
244
+ expect(result.nodes).toEqual([]);
245
+ });
246
+ });
@@ -0,0 +1,208 @@
1
+ /**
2
+ * The bounded capability closure: which modules a selected module stands on.
3
+ *
4
+ * Pure, so the console's central question is answerable without a database and
5
+ * the awkward graph shapes below are testable directly.
6
+ *
7
+ * The edge is `planConsumerCleanup()`'s edge, called rather than copied. That
8
+ * function already answers "which providers does this module reach", already
9
+ * counts `requires` AND `optional` as dependency edges, and already handles the
10
+ * two cases a fresh implementation gets wrong: a capability with several
11
+ * providers (firewall has an edge provider plus inner layers, all of them real)
12
+ * and a module that both provides and consumes a capability, which must not
13
+ * become its own dependency. A second walk would drift from it.
14
+ *
15
+ * What this adds is the part a one-hop cleanup plan has no use for: distance,
16
+ * optionality, and termination.
17
+ */
18
+
19
+ import {
20
+ type ConsumedCapabilities,
21
+ type ProviderRow,
22
+ type ProviderState,
23
+ planConsumerCleanup,
24
+ } from '../services/consumer-cleanup';
25
+
26
+ /**
27
+ * Hops to walk when the caller does not say.
28
+ *
29
+ * Two, because the fleet's useful answer is "what this stands on, and what that
30
+ * stands on". Deliberately a default and not a law: `depth: UNBOUNDED` walks the
31
+ * whole graph, and the walk terminates on cycles either way.
32
+ */
33
+ export const DEFAULT_CLOSURE_DEPTH = 2;
34
+
35
+ /** Walk until the graph runs out rather than until a hop count does. */
36
+ export const UNBOUNDED_CLOSURE_DEPTH = 0;
37
+
38
+ export interface ClosureRequest {
39
+ rootModuleId: string;
40
+ /**
41
+ * Each module's consumed capabilities, by module id. A module with no entry
42
+ * ends that branch of the walk rather than failing the whole thing: a manifest
43
+ * celilo cannot parse is a real state, and one bad row must not blank the
44
+ * picture.
45
+ */
46
+ manifests: ReadonlyMap<string, ConsumedCapabilities>;
47
+ providerRows: readonly ProviderRow[];
48
+ providerStates: readonly ProviderState[];
49
+ depth?: number;
50
+ /**
51
+ * Which providers each module has actually called into, keyed by module id
52
+ * then capability name.
53
+ *
54
+ * When supplied, the walk follows only real bindings. Without it the walk
55
+ * follows every declared capability, which answers "what could this reach"
56
+ * rather than "what is this standing on". Optional so the walk stays testable
57
+ * without a database, not so the distinction is optional.
58
+ */
59
+ bindings?: ReadonlyMap<string, ReadonlyMap<string, string>>;
60
+ }
61
+
62
+ export interface ClosureNode {
63
+ moduleId: string;
64
+ /** 1 is a direct dependency of the root. */
65
+ hop: number;
66
+ /**
67
+ * True when every edge that reaches this module is an `optional.capabilities`
68
+ * edge.
69
+ *
70
+ * Load-bearing rather than cosmetic: on a real fleet the internal DNS resolver
71
+ * is reached only optionally, so a walk that followed `requires` alone drops
72
+ * the resolver out of the picture entirely while looking complete.
73
+ */
74
+ optional: boolean;
75
+ /** Capability names by which the walk reached this module, sorted. */
76
+ via: string[];
77
+ }
78
+
79
+ export interface ClosureResult {
80
+ nodes: ClosureNode[];
81
+ /** Hops actually walked. Echoes the request so the console can say what it bounded. */
82
+ depth: number;
83
+ /**
84
+ * True when the walk stopped because it hit the bound and the graph continued.
85
+ * Distinct from an exhausted graph, which is a complete answer.
86
+ */
87
+ truncated: boolean;
88
+ }
89
+
90
+ interface Edge {
91
+ to: string;
92
+ hop: number;
93
+ capability: string;
94
+ optional: boolean;
95
+ }
96
+
97
+ /**
98
+ * Which of a module's consumed capabilities it declared as optional.
99
+ *
100
+ * A name in both sets counts as required: `requires` is the stronger claim, and
101
+ * a module that declares a capability both ways still fails without it.
102
+ */
103
+ function optionalCapabilityNames(manifest: ConsumedCapabilities): Set<string> {
104
+ const required = new Set((manifest.requires?.capabilities ?? []).map((c) => c.name));
105
+ return new Set(
106
+ (manifest.optional?.capabilities ?? []).map((c) => c.name).filter((n) => !required.has(n)),
107
+ );
108
+ }
109
+
110
+ export function computeClosure(request: ClosureRequest): ClosureResult {
111
+ const depth = request.depth ?? DEFAULT_CLOSURE_DEPTH;
112
+ const providerRows = [...request.providerRows];
113
+ const providerStates = [...request.providerStates];
114
+
115
+ const edges: Edge[] = [];
116
+ // The root counts as visited from the start, so a cycle back to it terminates
117
+ // rather than re-entering, and the selection never lists itself.
118
+ const visited = new Set([request.rootModuleId]);
119
+ let frontier = [request.rootModuleId];
120
+ let hop = 0;
121
+ let truncated = false;
122
+
123
+ while (frontier.length > 0) {
124
+ hop += 1;
125
+ if (depth !== UNBOUNDED_CLOSURE_DEPTH && hop > depth) {
126
+ // Something was still reachable when the bound stopped us. That is a
127
+ // different answer from an exhausted graph and the console says so.
128
+ truncated = true;
129
+ break;
130
+ }
131
+
132
+ const next: string[] = [];
133
+ for (const moduleId of frontier) {
134
+ const manifest = request.manifests.get(moduleId);
135
+ if (!manifest) continue;
136
+
137
+ const optionalNames = optionalCapabilityNames(manifest);
138
+ const bound = request.bindings?.get(moduleId);
139
+
140
+ for (const target of planConsumerCleanup(moduleId, manifest, providerRows, providerStates)) {
141
+ // A cycle walks back to the selection. Terminating the WALK there is not
142
+ // enough: the edge is still real, and recording it would list the
143
+ // selected module as one of its own dependencies. caddy requires
144
+ // authentik and authentik requires caddy, so this is the ordinary case
145
+ // on a real fleet rather than a pathological one.
146
+ if (target.providerId === request.rootModuleId) continue;
147
+
148
+ for (const capability of target.capabilityNames) {
149
+ // A capability the consumer declared and never called into is not a
150
+ // dependency. Following it would put a provider inside the closure on
151
+ // the strength of a manifest line, which is a claim that the module is
152
+ // standing on something it has never touched.
153
+ if (bound && bound.get(capability) !== target.providerId) continue;
154
+
155
+ edges.push({
156
+ to: target.providerId,
157
+ hop,
158
+ capability,
159
+ optional: optionalNames.has(capability),
160
+ });
161
+ }
162
+ if (!visited.has(target.providerId)) {
163
+ visited.add(target.providerId);
164
+ next.push(target.providerId);
165
+ }
166
+ }
167
+ }
168
+ frontier = next;
169
+ }
170
+
171
+ return { nodes: collectNodes(edges), depth, truncated };
172
+ }
173
+
174
+ /**
175
+ * Fold the edge list into one node per module.
176
+ *
177
+ * A module is recorded at the SHORTEST hop that reached it, and is optional only
178
+ * if no edge anywhere in the walk reached it by a required capability. Deciding
179
+ * either from the first edge seen would make the answer depend on iteration
180
+ * order.
181
+ */
182
+ function collectNodes(edges: readonly Edge[]): ClosureNode[] {
183
+ const byModule = new Map<string, { hop: number; via: Set<string>; anyRequired: boolean }>();
184
+
185
+ for (const edge of edges) {
186
+ const existing = byModule.get(edge.to);
187
+ if (!existing) {
188
+ byModule.set(edge.to, {
189
+ hop: edge.hop,
190
+ via: new Set([edge.capability]),
191
+ anyRequired: !edge.optional,
192
+ });
193
+ continue;
194
+ }
195
+ existing.hop = Math.min(existing.hop, edge.hop);
196
+ existing.via.add(edge.capability);
197
+ existing.anyRequired = existing.anyRequired || !edge.optional;
198
+ }
199
+
200
+ return [...byModule.entries()]
201
+ .map(([moduleId, v]) => ({
202
+ moduleId,
203
+ hop: v.hop,
204
+ optional: !v.anyRequired,
205
+ via: [...v.via].sort(),
206
+ }))
207
+ .sort((a, b) => a.hop - b.hop || a.moduleId.localeCompare(b.moduleId));
208
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * The console must not be reachable from the data plane.
3
+ *
4
+ * This is asserted here, cheaply, on every pull request. The e2e suite asserts
5
+ * the same thing for real by failing to reach the console from a data-plane
6
+ * zone, but that suite is slow, needs Docker, and does not run on every change.
7
+ * The way this boundary actually erodes is a one-line manifest edit that adds
8
+ * `private_web` because someone wanted to reach the console from a laptop
9
+ * without bringing up the VPN, and a gate that only fires in e2e would not catch
10
+ * that until much later.
11
+ *
12
+ * The history is why this is a test and not a comment. The change's second draft
13
+ * DID publish the console through caddy-internal, the operator refused it, and
14
+ * the final verification step still said "the route resolves through
15
+ * caddy-internal" for a further round after that. Prose did not hold this.
16
+ */
17
+ import { describe, expect, test } from 'bun:test';
18
+ import { parse } from 'yaml';
19
+
20
+ const MANIFEST_PATH = new URL(
21
+ '../../../../modules/celilo-web-console/manifest.yml',
22
+ import.meta.url,
23
+ ).pathname;
24
+
25
+ /**
26
+ * Capabilities that give something a way in from the data plane.
27
+ *
28
+ * `private_web` and `public_web` publish a route through a reverse proxy.
29
+ * `firewall` is how a module asks for a port forward or an ingress address.
30
+ * Any of the three would put a data-plane client on a path to the control
31
+ * plane.
32
+ */
33
+ const INGRESS_CAPABILITIES = ['private_web', 'public_web', 'firewall'];
34
+
35
+ interface CapabilityRef {
36
+ name: string;
37
+ }
38
+ interface Manifest {
39
+ id: string;
40
+ requires?: { capabilities?: CapabilityRef[]; system?: { zone?: string } };
41
+ optional?: { capabilities?: CapabilityRef[] };
42
+ provides?: { capabilities?: CapabilityRef[] };
43
+ }
44
+
45
+ async function loadManifest(): Promise<Manifest> {
46
+ return parse(await Bun.file(MANIFEST_PATH).text()) as Manifest;
47
+ }
48
+
49
+ describe('celilo-web-console stays in the control plane', () => {
50
+ test('lands in secure-mgmt and nowhere else', async () => {
51
+ const manifest = await loadManifest();
52
+ expect(manifest.requires?.system?.zone).toBe('secure-mgmt');
53
+ });
54
+
55
+ test('requires no capability that could give it data-plane ingress', async () => {
56
+ const manifest = await loadManifest();
57
+ const required = (manifest.requires?.capabilities ?? []).map((c) => c.name);
58
+ const found = required.filter((name) => INGRESS_CAPABILITIES.includes(name));
59
+ expect(found).toEqual([]);
60
+ });
61
+
62
+ test('does not pick up ingress through an OPTIONAL capability either', async () => {
63
+ // Optional is the likelier hole. It reads as harmless, and the closure walk
64
+ // in this same directory exists because optional edges are real edges.
65
+ const manifest = await loadManifest();
66
+ const optional = (manifest.optional?.capabilities ?? []).map((c) => c.name);
67
+ const found = optional.filter((name) => INGRESS_CAPABILITIES.includes(name));
68
+ expect(found).toEqual([]);
69
+ });
70
+
71
+ test('provides nothing, so nothing can bind to it as a dependency', async () => {
72
+ const manifest = await loadManifest();
73
+ expect(manifest.provides?.capabilities ?? []).toEqual([]);
74
+ });
75
+ });