@celilo/cli 1.12.0 → 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CELILO_CORE_MODULES.md +2 -1
  2. package/CELILO_SUBSYSTEMS.md +21 -2
  3. package/package.json +3 -3
  4. package/src/capabilities/public-web-helpers.test.ts +12 -6
  5. package/src/capabilities/public-web-publish.test.ts +24 -13
  6. package/src/capabilities/validation.test.ts +31 -0
  7. package/src/cli/commands/alerts-list.ts +16 -1
  8. package/src/cli/commands/backup-list.test.ts +82 -1
  9. package/src/cli/commands/backup-list.ts +113 -4
  10. package/src/cli/commands/console-get-chain.test.ts +96 -0
  11. package/src/cli/commands/console.ts +130 -0
  12. package/src/cli/commands/module-list.ts +3 -41
  13. package/src/cli/commands/notify-config.test.ts +79 -0
  14. package/src/cli/commands/notify-config.ts +13 -2
  15. package/src/cli/commands/system-ensure-fleet-key.ts +52 -0
  16. package/src/cli/completion.ts +6 -0
  17. package/src/cli/index.ts +31 -1
  18. package/src/console/closure.test.ts +322 -0
  19. package/src/console/closure.ts +294 -0
  20. package/src/console/control-plane-boundary.test.ts +75 -0
  21. package/src/console/projection.test.ts +293 -0
  22. package/src/console/projection.ts +364 -0
  23. package/src/db/schema.ts +19 -14
  24. package/src/hooks/broker.test.ts +4 -6
  25. package/src/hooks/capability-loader-control-plane-api.test.ts +124 -0
  26. package/src/hooks/capability-loader.ts +67 -10
  27. package/src/hooks/executor.test.ts +85 -4
  28. package/src/hooks/executor.ts +164 -9
  29. package/src/hooks/hook-jail-unreachability.test.ts +173 -0
  30. package/src/hooks/hook-state-dir.test.ts +14 -2
  31. package/src/hooks/hook-timeout.test.ts +2 -4
  32. package/src/hooks/hook-trespass.test.ts +50 -5
  33. package/src/hooks/jail.test.ts +370 -0
  34. package/src/hooks/jail.ts +491 -0
  35. package/src/hooks/mount-set.ts +24 -0
  36. package/src/hooks/test-fixtures/jail-probe-hook.ts +59 -0
  37. package/src/manifest/contracts/v1.ts +22 -1
  38. package/src/manifest/schema.ts +35 -0
  39. package/src/manifest/validate.test.ts +142 -0
  40. package/src/manifest/validate.ts +126 -4
  41. package/src/module/import.test.ts +116 -0
  42. package/src/module/import.ts +73 -1
  43. package/src/module/packaging/audit.ts +103 -1
  44. package/src/module/packaging/classify-module-path.test.ts +36 -0
  45. package/src/module/packaging/package-rules.ts +18 -0
  46. package/src/module/web-root.ts +35 -0
  47. package/src/policy/capability-shape-baseline.ts +8 -0
  48. package/src/policy/module-business-baseline.ts +26 -2
  49. package/src/policy/module-script-scan.test.ts +22 -0
  50. package/src/policy/module-script-scan.ts +32 -0
  51. package/src/services/alerting/observed-health.ts +71 -0
  52. package/src/services/api-principal-enrolment.test.ts +252 -0
  53. package/src/services/api-principal-enrolment.ts +158 -0
  54. package/src/services/audit/backups.ts +10 -1
  55. package/src/services/backup-create.ts +33 -7
  56. package/src/services/backup-metadata.ts +19 -11
  57. package/src/services/celilo-mgmt-hooks.test.ts +38 -79
  58. package/src/services/consumer-cleanup.ts +31 -5
  59. package/src/services/fleet-key.test.ts +47 -0
  60. package/src/services/fleet-key.ts +75 -0
  61. package/src/services/instance-ops.test.ts +302 -0
  62. package/src/services/instance-ops.ts +292 -0
  63. package/src/services/module-instances.test.ts +428 -42
  64. package/src/services/module-instances.ts +219 -26
  65. package/src/services/restore-from-file.ts +6 -5
  66. package/src/services/system-state-stage.test.ts +165 -0
  67. package/src/services/system-state-stage.ts +196 -0
@@ -0,0 +1,364 @@
1
+ /**
2
+ * The narrow reads the web console polls.
3
+ *
4
+ * Separate from the CLI's own reads for a measured reason: `celilo module list
5
+ * --json` returns 156 KB for 23 modules because it embeds every module's
6
+ * `manifestData` blob. That is the right payload for a human debugging one
7
+ * module and the wrong one for a loop that runs every few seconds, so the
8
+ * console gets a projection with the manifest left out and the two derived
9
+ * facts it actually renders (observed health, backup freshness) already
10
+ * computed.
11
+ *
12
+ * Reads only. Nothing here writes, and the console's API principal is granted
13
+ * only read ops, so a write added here would fail at the boundary rather than
14
+ * succeed quietly.
15
+ */
16
+
17
+ import { FIREWALL_CAPABILITY_NAME, orderFirewallChain } from '@celilo/capabilities';
18
+ import { desc, eq } from 'drizzle-orm';
19
+ import { z } from 'zod';
20
+ import type { DbClient } from '../db/client';
21
+ import {
22
+ NETWORK_ZONES,
23
+ backups,
24
+ capabilities,
25
+ moduleSystems,
26
+ modules,
27
+ monitors,
28
+ } from '../db/schema';
29
+ import { loadObservedHealthDetail } from '../services/alerting/observed-health';
30
+ import { loadBackupAuditInfo } from '../services/audit/backup-source';
31
+ import { backupStaleThresholdMs, moduleHasBackupHook } from '../services/audit/backups';
32
+ import { effectiveBackupSchedule } from '../services/backup-schedule';
33
+ import { listCapabilityBindings } from '../services/capability-bindings';
34
+ import type { ConsumedCapabilities } from '../services/consumer-cleanup';
35
+ import type { DelegationChain } from './closure';
36
+
37
+ /**
38
+ * Just the capability lists, validated at the boundary.
39
+ *
40
+ * `modules.manifestData` is opaque JSON in the database, which is a trust
41
+ * boundary even though celilo wrote it: rows outlive the code that wrote them,
42
+ * and an older celilo's row is exactly the case a clean-database test can never
43
+ * produce. `.catch` rather than `.parse` because one unreadable manifest must
44
+ * end that branch of the walk, not blank the whole topology.
45
+ */
46
+ const CONSUMED_CAPABILITIES = z
47
+ .object({
48
+ requires: z
49
+ .object({ capabilities: z.array(z.object({ name: z.string() })).optional() })
50
+ .optional(),
51
+ optional: z
52
+ .object({ capabilities: z.array(z.object({ name: z.string() })).optional() })
53
+ .optional(),
54
+ })
55
+ .catch({});
56
+
57
+ export interface ConsoleSystem {
58
+ hostname: string;
59
+ address: string;
60
+ zone: string;
61
+ }
62
+
63
+ export interface ConsoleModule {
64
+ id: string;
65
+ version: string;
66
+ state: string;
67
+ health: { cell: string; monitored: boolean; firingCount: number; suppressed: boolean };
68
+ systems: ConsoleSystem[];
69
+ /**
70
+ * Epoch MILLISECONDS of the newest COMPLETED backup, or null if never.
71
+ *
72
+ * Milliseconds because every other instant that crosses this boundary is in
73
+ * milliseconds, and a single field in seconds is a factor of a thousand that
74
+ * no type catches. The console renders ages, so the failure mode is a
75
+ * plausible wrong number rather than a crash: the alerts route computed one
76
+ * age in the wrong unit and rendered every alert as `0m`, a fleet where
77
+ * nothing had been wrong for over a minute.
78
+ */
79
+ lastBackupAt: number | null;
80
+ lastBackupFailed: boolean;
81
+ /**
82
+ * Nothing is checking this module.
83
+ *
84
+ * No enabled monitor, or one whose interval is manual, or one that has never
85
+ * run. Distinct from healthy: an unwatched module's silence is not evidence
86
+ * that it is fine, and `ok` and `unwatched` look identical on a dashboard
87
+ * that only tracks health.
88
+ */
89
+ unwatched: boolean;
90
+ /**
91
+ * It IS checked, on a schedule, and its monitor has no escalation policy.
92
+ *
93
+ * The alert is raised and reaches nobody. Worse than unwatched in one
94
+ * respect, because it looks monitored, so it is reported separately rather
95
+ * than folded into the health cell.
96
+ */
97
+ pagesNobody: boolean;
98
+ /**
99
+ * The newest successful backup is older than this module's cadence allows.
100
+ *
101
+ * Resolved HERE, through the same accessor the backup sweep and the drift
102
+ * audit use, so the three cannot disagree about what a module's cadence is.
103
+ * False for a module with no `on_backup` hook: there is nothing to run, so it
104
+ * is not overdue.
105
+ */
106
+ backupStale: boolean;
107
+ }
108
+
109
+ export interface ConsoleStatus {
110
+ /**
111
+ * The canonical zone order, most exposed first. Served rather than compiled
112
+ * into the console so a zone added to `NETWORK_ZONES` gets a band without a
113
+ * console release.
114
+ */
115
+ zones: string[];
116
+ modules: ConsoleModule[];
117
+ }
118
+
119
+ /**
120
+ * The dashboard's single poll: the zone order, and every module with the two
121
+ * derived columns the roster shows.
122
+ *
123
+ * One call rather than three because a poll that changes nothing should cost
124
+ * one round trip, and because the topology, the roster and the backup column
125
+ * all read the same rows.
126
+ */
127
+ export function consoleStatus(db: DbClient): ConsoleStatus {
128
+ const health = loadObservedHealthDetail(db);
129
+ const systemsByModule = new Map<string, ConsoleSystem[]>();
130
+
131
+ for (const row of db.select().from(moduleSystems).all()) {
132
+ const list = systemsByModule.get(row.moduleId) ?? [];
133
+ list.push({ hostname: row.hostname, address: row.ipv4Address, zone: row.zone });
134
+ systemsByModule.set(row.moduleId, list);
135
+ }
136
+
137
+ const backupByModule = latestBackupByModule(db);
138
+ const watch = watchFactsByModule(db);
139
+ const overdue = staleBackupModules(db);
140
+
141
+ const rows = db
142
+ .select({ id: modules.id, version: modules.version, state: modules.state })
143
+ .from(modules)
144
+ .all();
145
+
146
+ return {
147
+ zones: [...NETWORK_ZONES],
148
+ modules: rows
149
+ .map((row): ConsoleModule => {
150
+ const backup = backupByModule.get(row.id);
151
+ return {
152
+ id: row.id,
153
+ version: row.version,
154
+ state: row.state,
155
+ health: health.get(row.id) ?? {
156
+ // A module that is not deployed has nothing to observe. Saying
157
+ // "not observed" here would report undeployed modules as a finding.
158
+ cell: 'not deployed',
159
+ monitored: false,
160
+ firingCount: 0,
161
+ suppressed: false,
162
+ },
163
+ systems: systemsByModule.get(row.id) ?? [],
164
+ lastBackupAt: backup?.lastSuccessAt ?? null,
165
+ lastBackupFailed: backup?.lastFailed ?? false,
166
+ // Absent from the map means no enabled monitor at all, which is the
167
+ // loudest form of unwatched rather than the quietest.
168
+ unwatched: watch.get(row.id)?.unwatched ?? true,
169
+ pagesNobody: watch.get(row.id)?.pagesNobody ?? false,
170
+ backupStale: overdue.has(row.id),
171
+ };
172
+ })
173
+ .sort((a, b) => a.id.localeCompare(b.id)),
174
+ };
175
+ }
176
+
177
+ /**
178
+ * What celilo is doing to watch each module, if anything.
179
+ *
180
+ * Two facts, kept apart because an operator acts differently on each. A module
181
+ * nothing checks is invisible. A module that IS checked on a schedule and whose
182
+ * monitor routes to no escalation policy is worse in one respect: it looks
183
+ * monitored, the alert gets raised on time, and it reaches nobody.
184
+ *
185
+ * `interval manual` and `never run` both count as unwatched. A monitor that
186
+ * exists and has not executed produces exactly as much evidence as one that
187
+ * does not exist, and the console must not report the first as coverage.
188
+ */
189
+ interface WatchFacts {
190
+ unwatched: boolean;
191
+ pagesNobody: boolean;
192
+ }
193
+
194
+ function watchFactsByModule(db: DbClient): Map<string, WatchFacts> {
195
+ const result = new Map<string, WatchFacts>();
196
+ for (const monitor of db.select().from(monitors).where(eq(monitors.enabled, true)).all()) {
197
+ const running = monitor.intervalMinutes > 0 && monitor.lastRunAt !== null;
198
+ const existing = result.get(monitor.target);
199
+ // A module can carry more than one monitor. It is watched if ANY of them
200
+ // runs, and pages nobody only if every running one lacks a policy.
201
+ result.set(monitor.target, {
202
+ unwatched: (existing?.unwatched ?? true) && !running,
203
+ pagesNobody:
204
+ (existing?.pagesNobody ?? false) || (running && monitor.escalationPolicyId === null),
205
+ });
206
+ }
207
+ return result;
208
+ }
209
+
210
+ /**
211
+ * Modules whose newest successful backup is older than their cadence allows.
212
+ *
213
+ * Resolved through `effectiveBackupSchedule` and `backupStaleThresholdMs`, the
214
+ * same two functions the drift audit uses. The cadence in force comes from the
215
+ * operator's override and then the manifest and defaults to daily when neither
216
+ * says anything, so a console that worked it out again would be a second
217
+ * definition of "overdue" and would disagree the first time an override was
218
+ * set.
219
+ *
220
+ * A module with no `on_backup` hook is never stale. There is nothing to run.
221
+ */
222
+ function staleBackupModules(db: DbClient): Set<string> {
223
+ const stale = new Set<string>();
224
+ const now = Date.now();
225
+ for (const info of loadBackupAuditInfo(db)) {
226
+ if (!moduleHasBackupHook(info.manifest)) continue;
227
+ const cadence = effectiveBackupSchedule(info.manifest, info.scheduleOverride);
228
+ const threshold = backupStaleThresholdMs(cadence);
229
+ // `manual` has no threshold. Opting out is a decision, not a fault.
230
+ if (threshold === null) continue;
231
+ const last = info.lastSuccessfulBackupAt;
232
+ if (last === null || now - last > threshold) stale.add(info.id);
233
+ }
234
+ return stale;
235
+ }
236
+
237
+ interface BackupFacts {
238
+ lastSuccessAt: number | null;
239
+ lastFailed: boolean;
240
+ }
241
+
242
+ /**
243
+ * Newest completed backup per module, and whether the most recent ATTEMPT
244
+ * failed.
245
+ *
246
+ * Both, because they answer different questions and the console shows both. A
247
+ * module can hold a fresh successful backup and still have failed last night,
248
+ * and a roster that showed only the success date would call that healthy.
249
+ */
250
+ function latestBackupByModule(db: DbClient): Map<string, BackupFacts> {
251
+ const result = new Map<string, BackupFacts>();
252
+ const rows = db
253
+ .select({
254
+ moduleId: backups.moduleId,
255
+ status: backups.status,
256
+ startedAt: backups.startedAt,
257
+ completedAt: backups.completedAt,
258
+ })
259
+ .from(backups)
260
+ .orderBy(desc(backups.startedAt))
261
+ .all();
262
+
263
+ for (const row of rows) {
264
+ if (!row.moduleId) continue; // a system-state backup belongs to no module
265
+ const existing = result.get(row.moduleId);
266
+ if (!existing) {
267
+ result.set(row.moduleId, {
268
+ lastSuccessAt:
269
+ row.status === 'completed' && row.completedAt ? epochMillis(row.completedAt) : null,
270
+ lastFailed: row.status === 'failed',
271
+ });
272
+ continue;
273
+ }
274
+ if (existing.lastSuccessAt === null && row.status === 'completed' && row.completedAt) {
275
+ existing.lastSuccessAt = epochMillis(row.completedAt);
276
+ }
277
+ }
278
+ return result;
279
+ }
280
+
281
+ /**
282
+ * Milliseconds, deliberately, and named so nobody has to guess.
283
+ *
284
+ * This was `epochSeconds`, and the protocol on the other side of the boundary
285
+ * documents the same field as milliseconds. The two never met because the
286
+ * console server does not exist yet, so the factor of a thousand sat there
287
+ * looking like working code. It is the exact shape of the bug that rendered
288
+ * every alert as `0m`.
289
+ */
290
+ function epochMillis(value: Date): number {
291
+ return value.getTime();
292
+ }
293
+
294
+ /**
295
+ * Which providers a module has actually called into, by capability.
296
+ *
297
+ * The difference between a manifest and a fleet. A manifest says what a module
298
+ * CAN consume; this says what it resolved to and reached for. `tango-nexus`
299
+ * declares four optional capabilities and is bound to one, and until
300
+ * celilo#1072 landed there was no way to tell those apart.
301
+ *
302
+ * The console previously inferred this: a REQUIRED capability of a deployed
303
+ * module must have bound, because the deploy would have failed otherwise. That
304
+ * was the only sound inference available and it is now an approximation, so it
305
+ * is gone rather than kept as a fallback (Rule 3.9). A module with no rows here
306
+ * has called into nothing, which is a real answer and not a missing one.
307
+ */
308
+ export function loadBindings(db: DbClient, moduleId: string): Map<string, string> {
309
+ return new Map(
310
+ listCapabilityBindings(db, moduleId).map((b) => [b.capabilityName, b.providerModuleId]),
311
+ );
312
+ }
313
+
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
+ */
322
+ export function loadClosureInputs(db: DbClient): {
323
+ manifests: Map<string, ConsumedCapabilities>;
324
+ providerStates: { moduleId: string; state: string }[];
325
+ chains: DelegationChain[];
326
+ } {
327
+ return {
328
+ manifests: new Map(
329
+ db
330
+ .select({ id: modules.id, manifestData: modules.manifestData })
331
+ .from(modules)
332
+ .all()
333
+ .map((row) => [row.id, CONSUMED_CAPABILITIES.parse(row.manifestData)] as const),
334
+ ),
335
+ providerStates: db.select({ moduleId: modules.id, state: modules.state }).from(modules).all(),
336
+ chains: loadFirewallChain(db),
337
+ };
338
+ }
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
+
359
+ /** A module's row, or undefined. Used to reject a closure request for a module that is gone. */
360
+ export function moduleExists(db: DbClient, moduleId: string): boolean {
361
+ return (
362
+ db.select({ id: modules.id }).from(modules).where(eq(modules.id, moduleId)).all().length > 0
363
+ );
364
+ }
package/src/db/schema.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { INSTANCE_STATES, type InstanceState } from '@celilo/capabilities';
1
2
  import { sql } from 'drizzle-orm';
2
3
  import {
3
4
  index,
@@ -300,22 +301,14 @@ export const moduleIntegrity = sqliteTable('module_integrity', {
300
301
  });
301
302
 
302
303
  /**
303
- * Lifecycle states an instance moves through, reported by the deploy worker's
304
- * `list` so a parent can compare desired against observed (design D6).
304
+ * Lifecycle states an instance moves through.
305
305
  *
306
- * `failed` and `pending` must stay distinguishable: a parent that cannot tell
307
- * "has not started" from "will never work" either retries a bug forever or
308
- * gives up on work that was merely queued.
306
+ * Re-exported from `@celilo/capabilities` rather than redefined here. `list`
307
+ * PROMISES these to a caller's reconcile loop, so the vocabulary belongs to the
308
+ * contract; a second copy in the schema is how a column and an interface come
309
+ * to disagree about what `failed` means.
309
310
  */
310
- export const INSTANCE_STATES = [
311
- 'pending',
312
- 'provisioning',
313
- 'ready',
314
- 'failed',
315
- 'destroying',
316
- ] as const;
317
-
318
- export type InstanceState = (typeof INSTANCE_STATES)[number];
311
+ export { INSTANCE_STATES, type InstanceState };
319
312
 
320
313
  /**
321
314
  * Instances of a submodule (openspec/changes/submodules, D2 and D4).
@@ -492,6 +485,18 @@ export const moduleBuilds = sqliteTable('module_builds', {
492
485
  * that is someone else's cloud, this is a network the fleet's own firewall
493
486
  * holds a leg on and must translate for.
494
487
  */
488
+ /**
489
+ * ⚠️ ADDING A ZONE ALSO MEANS TOUCHING THE WEB CONSOLE.
490
+ *
491
+ * A zone comes into being here and nowhere else. How it is DRAWN — its accent
492
+ * colour and the one-line description a person reads under its name — lives in
493
+ * `apps/console/src/zones.ts`, because that is presentation and this is not.
494
+ *
495
+ * A zone added here and missed there draws grey with "no description yet",
496
+ * which is legible and wrong. `apps/console/tests/zones.test.ts` fails until
497
+ * the entry exists, so the pull request that adds the zone is the one that
498
+ * finds out, rather than the release that ships it.
499
+ */
495
500
  export const NETWORK_ZONES = [
496
501
  'isp-transit',
497
502
  'internal',
@@ -58,12 +58,10 @@ async function runCapabilityHook(): Promise<Record<string, unknown>> {
58
58
  stateDir: dir,
59
59
  capabilities: demoCapabilities(),
60
60
  };
61
- return await executeHookScript(
62
- join(FIXTURES, 'capability-calling-hook.ts'),
63
- context,
64
- 30_000,
65
- 30_000,
66
- );
61
+ return await executeHookScript(join(FIXTURES, 'capability-calling-hook.ts'), context, {
62
+ timeoutMs: 30_000,
63
+ idleTimeoutMs: 30_000,
64
+ });
67
65
  } finally {
68
66
  rmSync(dir, { recursive: true, force: true });
69
67
  }
@@ -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
+ });