@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
@@ -2,9 +2,15 @@ import { existsSync } from 'node:fs';
2
2
  import { readdir } from 'node:fs/promises';
3
3
  import { join, relative } from 'node:path';
4
4
  import { eq } from 'drizzle-orm';
5
- import { getDb } from '../../db/client';
5
+ import { type DbClient, getDb } from '../../db/client';
6
6
  import { moduleIntegrity, modules } from '../../db/schema';
7
7
  import type { ModuleManifest } from '../../manifest/schema';
8
+ import {
9
+ type InstanceRecord,
10
+ loadInstance,
11
+ submoduleSourcePath,
12
+ verifyInstanceLinks,
13
+ } from '../../services/module-instances';
8
14
  import { computeFileChecksum } from './checksum';
9
15
  import type { IntegrityViolation } from './extract';
10
16
  import { compareVerbatimRoleAssets, readVerbatimRoleAssets } from './generated-plane';
@@ -49,6 +55,21 @@ async function scanDirectory(dir: string, baseDir: string): Promise<string[]> {
49
55
  const fullPath = join(dir, entry.name);
50
56
  const relativePath = relative(baseDir, fullPath);
51
57
 
58
+ // A symlink is SKIPPED, deliberately and in writing.
59
+ //
60
+ // An instance's authored source is symlinked in from its parent's
61
+ // `submodules/` tree (openspec/changes/submodules D4), so those bytes are
62
+ // the PARENT's and are covered by the parent's baseline. This module has no
63
+ // claim to make over them, and following the link would have it claim
64
+ // integrity over bytes whose checksums belong to somebody else.
65
+ //
66
+ // This already happened, by accident: `Dirent.isDirectory()` and
67
+ // `isFile()` are BOTH false for a symlink, so links fell through both
68
+ // branches below and vanished. Right answer, no reason. Stating it here
69
+ // means a later change that makes the walk follow links has to decide
70
+ // about instances on purpose rather than reverse this silently.
71
+ if (entry.isSymbolicLink()) continue;
72
+
52
73
  if (entry.isDirectory()) {
53
74
  // Prune whole derived subtrees rather than walking them. `generated/`
54
75
  // alone carries terraform provider binaries.
@@ -91,6 +112,18 @@ export async function auditModule(
91
112
  };
92
113
  }
93
114
 
115
+ // An instance has no integrity row of its own and never will
116
+ // (openspec/changes/submodules D4). Its authored bytes are its
117
+ // submodule's, its submodule's are its parent's, and the parent already
118
+ // carries a baseline covering `submodules/**`. So it resolves THROUGH to
119
+ // that rather than being excluded: excluding it would report nothing for
120
+ // forty rows, and erroring would report `No integrity data found` forty
121
+ // times for a fleet that is entirely healthy.
122
+ const instance = loadInstance(moduleId, db);
123
+ if (instance) {
124
+ return await auditInstance(instance, module.sourcePath, db, options);
125
+ }
126
+
94
127
  // Get integrity data
95
128
  const integrity = db
96
129
  .select()
@@ -242,3 +275,72 @@ export async function auditModule(
242
275
  };
243
276
  }
244
277
  }
278
+
279
+ /**
280
+ * Audit an instance.
281
+ *
282
+ * Two questions, and only the second is the instance's own.
283
+ *
284
+ * Its BYTES belong to its parent, so the integrity verdict is the parent's:
285
+ * `submodules/**` classifies `package`, so the parent's baseline already covers
286
+ * every file an instance runs. Re-checksumming them here would be asking the
287
+ * same question a second time and inventing a second answer that can disagree.
288
+ *
289
+ * Its LINKS are its own, and nothing else in celilo checks them. A link
290
+ * repointed at another module's source is an instance running somebody else's
291
+ * code while every checksum in the fleet still reconciles, which is exactly the
292
+ * kind of quiet wrong answer the integrity plane exists to prevent.
293
+ */
294
+ async function auditInstance(
295
+ instance: InstanceRecord,
296
+ instancePath: string,
297
+ db: DbClient,
298
+ options: AuditOptions,
299
+ ): Promise<AuditResult> {
300
+ const violations: IntegrityViolation[] = [];
301
+
302
+ const parent = db.select().from(modules).where(eq(modules.id, instance.parentId)).get();
303
+ if (!parent) {
304
+ // The FK makes this unreachable through SQL, so reaching it means the row
305
+ // was written around the schema. Reported rather than assumed away.
306
+ return {
307
+ success: false,
308
+ moduleId: instance.moduleId,
309
+ violations,
310
+ error: `Instance '${instance.moduleId}' names parent '${instance.parentId}', which does not exist.`,
311
+ };
312
+ }
313
+
314
+ const submodulePath = submoduleSourcePath(parent.sourcePath, instance.submodule);
315
+ for (const link of await verifyInstanceLinks({ instancePath, submodulePath })) {
316
+ violations.push({
317
+ type: 'modified',
318
+ path: link.entry,
319
+ message:
320
+ link.actual === null
321
+ ? `Link '${link.entry}' is missing or is not a link. It should point at ${link.expected}. Redeploy this instance to rebuild its links.`
322
+ : `Link '${link.entry}' points at ${link.actual}, not at ${link.expected}. This instance is running source it does not own.`,
323
+ });
324
+ }
325
+
326
+ // The parent's verdict, carried rather than recomputed. A parent whose
327
+ // baseline is stale or whose files are modified means every instance under it
328
+ // is running unverified code, and saying so here is what stops an operator
329
+ // reading a clean instance row as evidence.
330
+ const parentResult = await auditModule(instance.parentId, db, options);
331
+ if (!parentResult.success) {
332
+ violations.push({
333
+ type: 'stale-baseline',
334
+ path: `${instance.parentId}`,
335
+ message: `The bytes this instance runs belong to '${instance.parentId}', whose own audit is not clean (${parentResult.violations.length} violation(s)${parentResult.error ? `: ${parentResult.error}` : ''}). Fix the parent; this instance cannot be verified independently.`,
336
+ });
337
+ }
338
+
339
+ return {
340
+ success: violations.length === 0,
341
+ moduleId: instance.moduleId,
342
+ violations,
343
+ moduleVersion: parent.version,
344
+ baselineVersion: parentResult.baselineVersion,
345
+ };
346
+ }
@@ -102,3 +102,39 @@ describe('classifyModulePath: composes with includeNodeModulesPath', () => {
102
102
  expect(classifyModulePath('node_modules/@celilo/cli/index.js')).toBe('unknown');
103
103
  });
104
104
  });
105
+
106
+ describe('a submodule tree obeys the same rules as a module tree', () => {
107
+ // openspec/changes/submodules D1: a submodule declares everything an ordinary
108
+ // module declares, so it must be CLASSIFIED like one too. Every rule keys on
109
+ // segments[0], so without the recursion a nested tree gets different answers
110
+ // than the same file one level up.
111
+ test('authored source under a submodule is package', () => {
112
+ expect(classifyModulePath('submodules/runner/manifest.yml')).toBe('package');
113
+ expect(classifyModulePath('submodules/runner/ansible/site.yml')).toBe('package');
114
+ expect(classifyModulePath('submodules/runner/scripts/hook.ts')).toBe('package');
115
+ });
116
+
117
+ test("a submodule's e2e tree is excluded, exactly as a module's is", () => {
118
+ expect(classifyModulePath('e2e/thing.ts')).toBe('unknown');
119
+ expect(classifyModulePath('submodules/runner/e2e/thing.ts')).toBe('unknown');
120
+ });
121
+
122
+ test("a submodule's derived paths are derived", () => {
123
+ expect(classifyModulePath('submodules/runner/generated/terraform/x.tfstate')).toBe('derived');
124
+ expect(classifyModulePath('submodules/runner/celilo/types.d.ts')).toBe('derived');
125
+ expect(classifyModulePath('submodules/runner/cookies.json')).toBe('derived');
126
+ expect(classifyModulePath('submodules/runner/screenshots/a.png')).toBe('derived');
127
+ });
128
+
129
+ test('source-tree noise under a submodule is still a finding', () => {
130
+ expect(classifyModulePath('submodules/runner/.DS_Store')).toBe('unknown');
131
+ expect(classifyModulePath('submodules/runner/tsconfig.json')).toBe('unknown');
132
+ expect(classifyModulePath('submodules/runner/a.test.ts')).toBe('unknown');
133
+ });
134
+
135
+ // The directories themselves are shipped bytes and belong in the baseline.
136
+ test('the submodules directory itself is package', () => {
137
+ expect(classifyModulePath('submodules')).toBe('package');
138
+ expect(classifyModulePath('submodules/runner')).toBe('package');
139
+ });
140
+ });
@@ -69,10 +69,28 @@ export type ModulePathClass = 'package' | 'derived' | 'unknown';
69
69
  * composes this function with `includeNodeModulesPath` rather than restating
70
70
  * either rule.
71
71
  */
72
+ export const SUBMODULES_DIR = 'submodules';
73
+
72
74
  export function classifyModulePath(relPath: string): ModulePathClass {
73
75
  const segments = relPath.split('/');
74
76
  const name = segments[segments.length - 1] ?? '';
75
77
 
78
+ // A submodule's tree obeys exactly the same rules as a module's tree
79
+ // (openspec/changes/submodules D1: a submodule declares everything an
80
+ // ordinary module declares). Every rule below keys on `segments[0]`, so
81
+ // without this a nested tree silently gets different answers: a submodule's
82
+ // own `e2e/` would be checksummed where a module's is excluded, because
83
+ // `segments[0]` reads `submodules` rather than `e2e`.
84
+ //
85
+ // Recursing on the remainder means one set of rules rather than two that
86
+ // drift, and every rule applies at both levels: a submodule's `e2e/` is
87
+ // excluded, its `celilo/types.d.ts` and `cookies.json` are derived, its
88
+ // `.DS_Store` is a finding. `submodules/` and `submodules/<name>` themselves
89
+ // stay `package`, which is right: the directories ARE shipped bytes.
90
+ if (segments[0] === SUBMODULES_DIR && segments.length > 2) {
91
+ return classifyModulePath(segments.slice(2).join('/'));
92
+ }
93
+
76
94
  // The module's own e2e/ tree is tests plus their deps, including a
77
95
  // node_modules of its own. Excluded whole, before anything below.
78
96
  if (segments[0] === 'e2e') return 'unknown';
@@ -77,6 +77,10 @@ export const CAPABILITY_SHAPE_BASELINE: Readonly<Record<string, CapabilityShape>
77
77
  version: '1.0.0',
78
78
  hash: '2f7f81d0b3ecb2ab6822dbc9bf837a933b25e0e157eb94d0b9b8e801417f8f14',
79
79
  },
80
+ control_plane_api: {
81
+ version: '1.0.0',
82
+ hash: '2c5d41d37a9a828a8b9a6dc633a2d48f24670d0831d879ec18426f0f9f8e5be5',
83
+ },
80
84
  private_web: {
81
85
  version: '1.0.0',
82
86
  hash: '0eddb82f821fe3c7fbd503d55a24c3d5a22369af8b22de06570a371d5f485c03',
@@ -85,6 +89,10 @@ export const CAPABILITY_SHAPE_BASELINE: Readonly<Record<string, CapabilityShape>
85
89
  version: '1.0.0',
86
90
  hash: 'e152f0738c88a7105b6057037f350ce0bbcbfc5c4ccbccc3af60f1cf3f1904af',
87
91
  },
92
+ celilo_module_deploy_worker: {
93
+ version: '1.0.0',
94
+ hash: '0b0e1f26aab0ebcf5d28283b9c5ba711f6d182f8f7ad474379c4ab02c354a2b8',
95
+ },
88
96
  web_routes: {
89
97
  version: '1.0.0',
90
98
  hash: 'bbf6435528a799e205270681e0ffc34f4007efbf04240138d4b69594a2515dbb',
@@ -111,6 +111,12 @@ export const CAPABILITY_NAME_BASELINE: readonly CapabilityNameRow[] = [
111
111
  count: 1,
112
112
  why: 'PERMANENT — the public_web implementation naming its own table; the capability package doing its job',
113
113
  },
114
+ {
115
+ file: 'packages/capabilities/src/capability-contract.ts',
116
+ capability: 'control_plane_api',
117
+ count: 1,
118
+ why: 'PERMANENT — a contract declaration with no implementation; this is the package doing its job',
119
+ },
114
120
  {
115
121
  file: 'packages/capabilities/src/capability-contract.ts',
116
122
  capability: 'web_routes',
@@ -327,6 +333,12 @@ export const CAPABILITY_NAME_BASELINE: readonly CapabilityNameRow[] = [
327
333
  count: 1,
328
334
  why: 'PERMANENT — the name-keyed aggregate of capability TABLE declarations, same shape and same justification as capability-contract.ts: a declaration with no implementation. Naming the capability IS the mapping; core reads it to avoid naming any (openspec/changes/capability-owned-tables D2)',
329
335
  },
336
+ {
337
+ file: 'packages/capabilities/src/capability-contract.ts',
338
+ capability: 'celilo_module_deploy_worker',
339
+ count: 1,
340
+ why: 'PERMANENT — X10, a contract declaration with no implementation; this is the package doing its job',
341
+ },
330
342
  {
331
343
  file: 'packages/capabilities/src/capability-contract.ts',
332
344
  capability: 'control_plane_vpn',
@@ -28,6 +28,8 @@ export interface SearchResult {
28
28
  name: string;
29
29
  max_version: string;
30
30
  description: string;
31
+ /** The module's declared glyph, absent when it declared none. */
32
+ icon?: string;
31
33
  }
32
34
 
33
35
  export interface SearchResponse {
@@ -130,6 +132,12 @@ export class RegistryClient {
130
132
  * apps/celilo/designs/REGISTRY_BROWSE_UI.md (Phase 2 step 0).
131
133
  */
132
134
  description?: string;
135
+ /**
136
+ * The module's `manifest.yml#icon`, when it declares one. Optional and
137
+ * server-tolerated in its absence, exactly like `description`
138
+ * (openspec/changes/module-icons, D4).
139
+ */
140
+ icon?: string;
133
141
  }): Promise<{ ok: boolean; name: string; vers: string }> {
134
142
  const fileData = await readFile(opts.netappPath);
135
143
  const cksum = `sha256:${createHash('sha256').update(fileData).digest('hex')}`;
@@ -140,6 +148,7 @@ export class RegistryClient {
140
148
  deps: [],
141
149
  cksum,
142
150
  ...(opts.description ? { description: opts.description } : {}),
151
+ ...(opts.icon ? { icon: opts.icon } : {}),
143
152
  });
144
153
  const metaBuf = Buffer.from(meta, 'utf-8');
145
154
 
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Observed health per module, derived from live alerts and enabled monitors.
3
+ *
4
+ * Extracted from `module list` because it now has a second consumer. The web
5
+ * console shows the same column, and celilo has already decided how this is
6
+ * computed: `nextModuleState()` documents why observation must NOT drive a
7
+ * module's lifecycle state, since letting a recurring check write it would
8
+ * redefine VERIFIED from "someone verified this" to "it was up recently" and
9
+ * churn it on every transient failure.
10
+ *
11
+ * So this stays a derivation and never a stored field, and both consumers read
12
+ * it here rather than each growing a copy.
13
+ */
14
+
15
+ import { eq } from 'drizzle-orm';
16
+ import type { DbClient } from '../../db/client';
17
+ import { modules as modulesTable, monitors } from '../../db/schema';
18
+ import { moduleHealthCell } from './format';
19
+ import { loadAllLiveAlerts, summariseByModule } from './store';
20
+
21
+ /** The structured form. `cell` is the display string the CLI column prints. */
22
+ export interface ObservedHealth {
23
+ cell: string;
24
+ monitored: boolean;
25
+ firingCount: number;
26
+ suppressed: boolean;
27
+ }
28
+
29
+ /**
30
+ * Observed health per module: `ok`, `N firing`, `suppressed`, or
31
+ * `not observed`.
32
+ *
33
+ * Only computed for DEPLOYED modules — a module still being imported has
34
+ * nothing to observe, and reporting it as unwatched would be noise rather
35
+ * than a finding.
36
+ */
37
+ export function loadObservedHealth(db: DbClient): Map<string, string> {
38
+ return new Map([...loadObservedHealthDetail(db)].map(([id, h]) => [id, h.cell]));
39
+ }
40
+
41
+ /**
42
+ * The same derivation, with the inputs kept rather than collapsed into a
43
+ * string. The console needs the firing COUNT and the not-observed case as
44
+ * separate facts: it renders them differently, and parsing them back out of a
45
+ * terminal column would be a second, worse implementation.
46
+ */
47
+ export function loadObservedHealthDetail(db: DbClient): Map<string, ObservedHealth> {
48
+ const monitored = new Set(
49
+ db
50
+ .select({ target: monitors.target })
51
+ .from(monitors)
52
+ .where(eq(monitors.enabled, true))
53
+ .all()
54
+ .map((m) => m.target),
55
+ );
56
+ const byModule = summariseByModule(loadAllLiveAlerts(db));
57
+
58
+ const result = new Map<string, ObservedHealth>();
59
+ for (const module of db.select().from(modulesTable).all()) {
60
+ if (module.state !== 'INSTALLED' && module.state !== 'VERIFIED') continue;
61
+
62
+ const summary = byModule.get(module.id) ?? [];
63
+ const input = {
64
+ monitored: monitored.has(module.id),
65
+ firingCount: summary.filter((s) => !s.suppressed).length,
66
+ suppressed: summary.length > 0 && summary.every((s) => s.suppressed),
67
+ };
68
+ result.set(module.id, { ...input, cell: moduleHealthCell(input) });
69
+ }
70
+ return result;
71
+ }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * `control_plane_api`, and the one property that has to hold forever.
3
+ *
4
+ * The console reads celilo's own state. What it must never acquire is the
5
+ * ability to change it — and the way that would happen is not somebody adding
6
+ * `module:deploy` on purpose. It is a new verb appearing in the registry and
7
+ * quietly falling on the wrong side of a grant list nobody re-reads.
8
+ *
9
+ * So the grants are DERIVED, and these assert the derivation rather than a
10
+ * snapshot of its output. A test that pinned today's list would pass forever
11
+ * while the thing it protects rotted.
12
+ */
13
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
14
+ import { mkdtempSync, rmSync } from 'node:fs';
15
+ import { tmpdir } from 'node:os';
16
+ import { join } from 'node:path';
17
+ import { COMMANDS, readOnlyGrants } from '@celilo/core';
18
+ import { closeDb } from '../db/client';
19
+ import { getPrincipalByName, isAuthorized } from './api-access';
20
+ import {
21
+ controlPlaneReadGrants,
22
+ enrolControlPlanePrincipal,
23
+ revokeControlPlanePrincipal,
24
+ } from './api-principal-enrolment';
25
+
26
+ /** A syntactically real ed25519 public key. Nothing here ever sees a private one. */
27
+ const PUBLIC_KEY =
28
+ 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIB7cWlXqPn+aGaNKm2Rt1cwj6cVy0hL0hVXHhWJKtLZ9 celilo-web-console';
29
+ const OTHER_KEY =
30
+ 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILlqL4YQ1MDe0J0X2ZQ4pXFqNbXK9y7dQe2Vd1kZ8sWq rotated';
31
+
32
+ describe('the grant set', () => {
33
+ test('is exactly the read-only ops the classifier derives', () => {
34
+ // Not a snapshot. If this were a literal list it would pass forever while a
35
+ // newer celilo added verbs it no longer covered.
36
+ expect(controlPlaneReadGrants()).toEqual(readOnlyGrants(COMMANDS));
37
+ });
38
+
39
+ test('is not empty, so the derivation is doing something', () => {
40
+ // The failure mode a derived list has that a literal one does not: a
41
+ // classifier change makes it return nothing and every read starts failing
42
+ // closed. That is safe and completely broken, so it is asserted.
43
+ expect(controlPlaneReadGrants().length).toBeGreaterThan(10);
44
+ });
45
+
46
+ test('contains no obviously mutating op', () => {
47
+ // A coarse net on purpose. The classifier is the real gate; this catches the
48
+ // case where the classifier itself is changed to admit writes, which no test
49
+ // of the classifier's own output would notice.
50
+ const mutating = /:(deploy|remove|delete|create|set|add|uninstall|restore|grant|revoke|pause)$/;
51
+ expect(controlPlaneReadGrants().filter((op) => mutating.test(op))).toEqual([]);
52
+ });
53
+ });
54
+
55
+ describe('enrolment', () => {
56
+ let dir: string;
57
+
58
+ beforeEach(async () => {
59
+ // The service reaches for the global `getDb()`, so isolation is by
60
+ // CELILO_DB_PATH rather than by passing a client around.
61
+ dir = mkdtempSync(join(tmpdir(), 'celilo-cpa-'));
62
+ process.env.CELILO_DB_PATH = join(dir, 'test.db');
63
+ closeDb();
64
+ });
65
+
66
+ afterEach(() => {
67
+ closeDb();
68
+ rmSync(dir, { recursive: true, force: true });
69
+ });
70
+
71
+ test('creates a principal carrying the derived grants', async () => {
72
+ const result = await enrolControlPlanePrincipal({
73
+ name: 'celilo-web-console',
74
+ publicKey: PUBLIC_KEY,
75
+ });
76
+ expect(result.created).toBe(true);
77
+ expect(result.grants).toEqual(controlPlaneReadGrants());
78
+
79
+ const stored = await getPrincipalByName('celilo-web-console');
80
+ expect(stored?.publicKey).toBe(PUBLIC_KEY);
81
+ });
82
+
83
+ test('is idempotent, because a redeploy presents the same name again', async () => {
84
+ await enrolControlPlanePrincipal({ name: 'celilo-web-console', publicKey: PUBLIC_KEY });
85
+ const second = await enrolControlPlanePrincipal({
86
+ name: 'celilo-web-console',
87
+ publicKey: PUBLIC_KEY,
88
+ });
89
+ expect(second.created).toBe(false);
90
+ });
91
+
92
+ test('a new key for an existing name ROTATES rather than duplicating', async () => {
93
+ // What a module that regenerated its keypair needs. Refusing it would leave
94
+ // a console that cannot authenticate and no way to fix it from the module.
95
+ await enrolControlPlanePrincipal({ name: 'celilo-web-console', publicKey: PUBLIC_KEY });
96
+ await enrolControlPlanePrincipal({ name: 'celilo-web-console', publicKey: OTHER_KEY });
97
+ expect((await getPrincipalByName('celilo-web-console'))?.publicKey).toBe(OTHER_KEY);
98
+ });
99
+
100
+ test('a malformed public key is refused here, not at login', async () => {
101
+ // Otherwise it lands in authorized_keys and fails later with a message
102
+ // about SSH rather than about the thing that was wrong.
103
+ await expect(
104
+ enrolControlPlanePrincipal({ name: 'celilo-web-console', publicKey: 'not-a-key' }),
105
+ ).rejects.toThrow();
106
+ });
107
+ });
108
+
109
+ describe('what the enrolled principal may actually do', () => {
110
+ let dir: string;
111
+
112
+ beforeEach(async () => {
113
+ // The service reaches for the global `getDb()`, so isolation is by
114
+ // CELILO_DB_PATH rather than by passing a client around.
115
+ dir = mkdtempSync(join(tmpdir(), 'celilo-cpa-'));
116
+ process.env.CELILO_DB_PATH = join(dir, 'test.db');
117
+ closeDb();
118
+ await enrolControlPlanePrincipal({ name: 'celilo-web-console', publicKey: PUBLIC_KEY });
119
+ });
120
+
121
+ afterEach(() => {
122
+ closeDb();
123
+ rmSync(dir, { recursive: true, force: true });
124
+ });
125
+
126
+ test('may run the console reads it exists for', async () => {
127
+ expect(await isAuthorized('celilo-web-console', 'console', 'status')).toBe(true);
128
+ expect(await isAuthorized('celilo-web-console', 'console', 'get')).toBe(true);
129
+ });
130
+
131
+ test('may run the other reads the console polls', async () => {
132
+ // `alerts:list`, not bare `alerts`. The API authorises command:subcommand,
133
+ // so a caller that omits the subcommand is refused — which is what the
134
+ // console server did until this test caught it.
135
+ expect(await isAuthorized('celilo-web-console', 'alerts', 'list')).toBe(true);
136
+ expect(await isAuthorized('celilo-web-console', 'alerts')).toBe(false);
137
+ expect(await isAuthorized('celilo-web-console', 'backup', 'list')).toBe(true);
138
+ });
139
+
140
+ test('may NOT deploy a module', async () => {
141
+ // The whole point. Everything above is convenience; this is the boundary.
142
+ expect(await isAuthorized('celilo-web-console', 'module', 'deploy')).toBe(false);
143
+ });
144
+
145
+ test('may NOT uninstall, restore, or grant', async () => {
146
+ expect(await isAuthorized('celilo-web-console', 'module', 'remove')).toBe(false);
147
+ expect(await isAuthorized('celilo-web-console', 'backup', 'restore')).toBe(false);
148
+ expect(await isAuthorized('celilo-web-console', 'api', 'grant')).toBe(false);
149
+ });
150
+ });
151
+
152
+ describe('revocation', () => {
153
+ let dir: string;
154
+
155
+ beforeEach(async () => {
156
+ // The service reaches for the global `getDb()`, so isolation is by
157
+ // CELILO_DB_PATH rather than by passing a client around.
158
+ dir = mkdtempSync(join(tmpdir(), 'celilo-cpa-'));
159
+ process.env.CELILO_DB_PATH = join(dir, 'test.db');
160
+ closeDb();
161
+ });
162
+
163
+ afterEach(() => {
164
+ closeDb();
165
+ rmSync(dir, { recursive: true, force: true });
166
+ });
167
+
168
+ test('removes the principal, so an uninstalled console leaves no working key', async () => {
169
+ await enrolControlPlanePrincipal({ name: 'celilo-web-console', publicKey: PUBLIC_KEY });
170
+ expect(await revokeControlPlanePrincipal('celilo-web-console')).toBe(true);
171
+ expect(await getPrincipalByName('celilo-web-console')).toBeNull();
172
+ });
173
+
174
+ test('revoking one that is already gone is false, not a throw', async () => {
175
+ // An uninstall that fails on tidy-up is worse than one finding nothing to
176
+ // tidy, and a hook that throws here leaves the module half-removed.
177
+ expect(await revokeControlPlanePrincipal('never-existed')).toBe(false);
178
+ });
179
+ });
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Enrol a module's read-only principal on the remote API.
3
+ *
4
+ * Named for the MECHANISM, not for the capability it backs. A core service
5
+ * named after one capability is core doing that provider's work, which is the
6
+ * pattern `no-module-business-in-core` Scan C exists to stop — and it caught
7
+ * this file under its first name. Enrolling an API principal is celilo's own
8
+ * job: the row lands in celilo's `api_principals` table and the grants come
9
+ * from celilo's command registry. `control_plane_api` is one caller of it.
10
+ *
11
+ * The consuming module generates an ed25519 keypair on its own system and
12
+ * presents the public half here. The private half never crosses this boundary,
13
+ * which is what makes "the console holds no credential celilo issued it"
14
+ * checkable rather than merely intended.
15
+ *
16
+ * ## The grant set is derived, never passed in
17
+ *
18
+ * `readOnlyGrants(COMMANDS)` is the same classifier that decides whether a CLI
19
+ * leaf is a read. Two consequences, both wanted:
20
+ *
21
+ * A new read verb is picked up WITHOUT anyone editing a list — but only at the
22
+ * next enrolment. The grants are MATERIALISED into the principal row here, so a
23
+ * celilo-mgr that is upgraded without the consumer being redeployed keeps
24
+ * serving the grant set that existed when the consumer last installed. Measured:
25
+ * 27 literal ops in the row, and a read verb celilo gains afterwards is denied.
26
+ *
27
+ * That is a limitation rather than a hole. It fails CLOSED, and it heals on the
28
+ * next redeploy. Making it dynamic means a `ro:*` token resolved at check time,
29
+ * which is a change to the authorization core and a real trade — a principal's
30
+ * authority would then move whenever celilo reclassifies a verb, with nobody
31
+ * re-granting anything. celilo#1153 carries that decision.
32
+ *
33
+ * A new WRITE verb is never granted, however it is named, because the
34
+ * classifier reads the verb token rather than a list somebody maintains. A
35
+ * hand-written list would drift in exactly one direction — wider — because
36
+ * widening it is what unblocks whoever is stuck.
37
+ *
38
+ * There is deliberately no `grants` parameter. The widest thing this can issue
39
+ * is the widest thing it should ever issue, and an argument would turn that
40
+ * into a negotiation with the caller.
41
+ */
42
+
43
+ import { COMMANDS, readOnlyGrants } from '@celilo/core';
44
+ import { grantPrincipal, revokePrincipal } from './api-access';
45
+
46
+ export interface EnrolResult {
47
+ name: string;
48
+ grants: string[];
49
+ created: boolean;
50
+ }
51
+
52
+ /**
53
+ * Every read-only op, sorted.
54
+ *
55
+ * Computed on each call rather than cached at module load. It is derived from a
56
+ * static registry so the answer does not change within a process, but a cached
57
+ * copy is one more thing that can be stale in a long-lived server and the
58
+ * computation is a filter over a few hundred leaves.
59
+ */
60
+ export function controlPlaneReadGrants(): string[] {
61
+ return readOnlyGrants(COMMANDS);
62
+ }
63
+
64
+ /**
65
+ * Enrol a consuming module's principal. Idempotent on `name`.
66
+ *
67
+ * Re-presenting the same name and key is a no-op reporting `created: false`,
68
+ * which is what a redeploy does. Presenting a NEW key for an existing name
69
+ * rotates it — the case a module that regenerated its keypair needs, and one a
70
+ * module that did not will never hit.
71
+ *
72
+ * `grantPrincipal` validates the name and the key, so a malformed public key is
73
+ * refused here rather than written into `authorized_keys` and failing at login
74
+ * with a message about the wrong thing.
75
+ */
76
+ export async function enrolControlPlanePrincipal(params: {
77
+ name: string;
78
+ publicKey: string;
79
+ }): Promise<EnrolResult> {
80
+ const grants = controlPlaneReadGrants();
81
+ const { principal, created } = await grantPrincipal({
82
+ name: params.name,
83
+ publicKey: params.publicKey,
84
+ grants,
85
+ });
86
+
87
+ // `grantPrincipal` already reports false for a principal that existed, and a
88
+ // re-grant that rotates a key is correctly not-a-creation. Re-deriving that
89
+ // here with a second lookup would be two sources for one answer.
90
+ return { name: principal.name, grants, created };
91
+ }
92
+
93
+ /**
94
+ * Remove a principal. Idempotent.
95
+ *
96
+ * Called from a consumer's `on_uninstall` so an uninstalled module does not
97
+ * leave a working key behind. Revoking one that is already gone returns false
98
+ * rather than throwing: an uninstall that fails on tidy-up is worse than one
99
+ * that finds nothing to tidy.
100
+ */
101
+ export async function revokeControlPlanePrincipal(name: string): Promise<boolean> {
102
+ return revokePrincipal(name);
103
+ }
@@ -65,7 +65,16 @@ export function backupStaleThresholdMs(cadence: Cadence): number | null {
65
65
  return interval + Math.max(HOUR, interval * 0.1);
66
66
  }
67
67
 
68
- function moduleHasBackupHook(manifest: ModuleManifest): boolean {
68
+ /**
69
+ * Whether celilo can back this module up at all.
70
+ *
71
+ * Exported because it is the gate `backup-create.ts` actually applies, and the
72
+ * console has to draw the same distinction. On the live fleet five of 23
73
+ * deployed modules declare the hook: the other eighteen are not overdue and not
74
+ * failing, there is simply nothing to run (celilo#1131). A second copy of this
75
+ * test elsewhere would eventually disagree about which eighteen.
76
+ */
77
+ export function moduleHasBackupHook(manifest: ModuleManifest): boolean {
69
78
  return Boolean(manifest.hooks?.on_backup);
70
79
  }
71
80