@celilo/cli 4.1.0 → 5.0.1

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 (81) hide show
  1. package/AGENTS.md +2 -2
  2. package/CELILO_SUBSYSTEMS.md +2 -0
  3. package/MODULE_PRIMITIVES.md +9 -0
  4. package/drizzle/0034_private_routes.sql +41 -0
  5. package/drizzle/meta/_journal.json +8 -1
  6. package/package.json +2 -2
  7. package/src/api/sessions.test.ts +1 -1
  8. package/src/capabilities/registration.test.ts +11 -11
  9. package/src/capabilities/secrets.test.ts +18 -18
  10. package/src/capabilities/validation.test.ts +24 -24
  11. package/src/cli/cli.test.ts +3 -3
  12. package/src/cli/commands/module-changeset.test.ts +1 -1
  13. package/src/cli/commands/module-generate.test.ts +2 -2
  14. package/src/cli/commands/module-import-aspect.test.ts +2 -2
  15. package/src/cli/commands/module-update.test.ts +14 -14
  16. package/src/cli/commands/module-upgrade-gate.test.ts +26 -1
  17. package/src/cli/commands/module-upgrade.test.ts +3 -3
  18. package/src/cli/commands/module-upgrade.ts +12 -0
  19. package/src/cli/commands/module-version.test.ts +1 -1
  20. package/src/cli/index.ts +22 -2
  21. package/src/cli/restore-migration-failure.test.ts +1 -1
  22. package/src/cli/stdout-pipe-flush.test.ts +35 -0
  23. package/src/db/private-routes.test.ts +159 -0
  24. package/src/db/schema.ts +49 -0
  25. package/src/hooks/capability-loader.ts +179 -12
  26. package/src/hooks/executor.test.ts +13 -13
  27. package/src/hooks/hook-state-dir.test.ts +2 -2
  28. package/src/hooks/mount-set.test.ts +28 -0
  29. package/src/hooks/mount-set.ts +33 -0
  30. package/src/hooks/test-fixtures/store-backed.ts +5 -3
  31. package/src/manifest/contracts/index.ts +28 -7
  32. package/src/manifest/contracts/v2.ts +32 -0
  33. package/src/manifest/icon-schema.test.ts +1 -1
  34. package/src/manifest/schema.ts +27 -2
  35. package/src/manifest/template-validator.test.ts +1 -1
  36. package/src/manifest/validate-privileged.test.ts +1 -1
  37. package/src/manifest/validate.test.ts +68 -29
  38. package/src/manifest/validate.ts +14 -1
  39. package/src/module/import.test.ts +7 -7
  40. package/src/module/packaging/build.test.ts +1 -1
  41. package/src/module/packaging/build.ts +16 -2
  42. package/src/packaging/nfpm-depends.test.ts +42 -0
  43. package/src/policy/capability-shape-baseline.ts +7 -1
  44. package/src/policy/fixture-capability-coverage.test.ts +5 -5
  45. package/src/policy/module-business-baseline.ts +33 -4
  46. package/src/registry/client.test.ts +5 -4
  47. package/src/services/aspect-approvals.test.ts +2 -2
  48. package/src/services/aspect-reconcile.test.ts +2 -2
  49. package/src/services/aspect-runner.test.ts +4 -4
  50. package/src/services/audit/backups.test.ts +1 -1
  51. package/src/services/audit/capability-abi.test.ts +1 -1
  52. package/src/services/audit/module-configs.test.ts +1 -1
  53. package/src/services/build-bus/hook-dispatch-executor.test.ts +3 -3
  54. package/src/services/bus-interview-park.test.ts +1 -1
  55. package/src/services/bus-secret-flow.test.ts +1 -1
  56. package/src/services/capability-table-rows.test.ts +20 -2
  57. package/src/services/celilo-events.test.ts +80 -6
  58. package/src/services/celilo-events.ts +115 -12
  59. package/src/services/celilo-mgmt-hooks.test.ts +6 -3
  60. package/src/services/cross-module-read.test.ts +2 -2
  61. package/src/services/deploy-validation.test.ts +6 -6
  62. package/src/services/dns-provider-backfill.test.ts +2 -2
  63. package/src/services/dns-registrations.test.ts +1 -1
  64. package/src/services/fleet-checks.test.ts +2 -2
  65. package/src/services/health-runner.test.ts +1 -1
  66. package/src/services/infrastructure-variable-resolver.test.ts +11 -11
  67. package/src/services/instance-ops.test.ts +2 -2
  68. package/src/services/module-deploy.dns-repoint.test.ts +1 -1
  69. package/src/services/module-pause-quiescence.test.ts +1 -1
  70. package/src/services/module-pause.test.ts +1 -1
  71. package/src/services/module-subscriptions.test.ts +2 -2
  72. package/src/services/module-types-generator.test.ts +1 -1
  73. package/src/services/module-validator/contract-version.test.ts +3 -3
  74. package/src/services/network-ensure.test.ts +1 -1
  75. package/src/services/proxmox-reconcile.test.ts +2 -2
  76. package/src/services/remove-guard.test.ts +1 -1
  77. package/src/services/update/dep-graph.test.ts +1 -1
  78. package/src/services/update/orchestrator.test.ts +1 -1
  79. package/src/variables/capability-self-ref.test.ts +2 -2
  80. package/src/variables/computed/computed-integration.test.ts +1 -1
  81. package/src/variables/declarative-derivation.test.ts +19 -19
@@ -9,7 +9,7 @@ const DECLARED = join('ansible', 'roles', 'fixture', 'files', 'binary');
9
9
 
10
10
  function manifestWithArtifacts(artifacts: string[]): ModuleManifest {
11
11
  return {
12
- celilo_contract: '1.0',
12
+ celilo_contract: '2.0',
13
13
  id: 'fixture',
14
14
  name: 'Fixture',
15
15
  version: '1.0.0',
@@ -42,7 +42,7 @@ describe('missingBuildOutputs', () => {
42
42
  test('reports nothing for a module with no build block', () => {
43
43
  expect(
44
44
  missingBuildOutputs(payload, {
45
- celilo_contract: '1.0',
45
+ celilo_contract: '2.0',
46
46
  id: 'fixture',
47
47
  name: 'Fixture',
48
48
  version: '1.0.0',
@@ -32,7 +32,7 @@ function insertAspectModule(id: string, version: string): void {
32
32
  id,
33
33
  name: id,
34
34
  version,
35
- celilo_contract: '1.0',
35
+ celilo_contract: '2.0',
36
36
  base_module_aspect: aspect,
37
37
  },
38
38
  sourcePath: `/tmp/${id}`,
@@ -102,7 +102,7 @@ describe('handleAspectApprovalAfterImport (non-interactive)', () => {
102
102
  id: 'caddy',
103
103
  name: 'caddy',
104
104
  version: '1.0.0',
105
- manifestData: { id: 'caddy', name: 'caddy', version: '1.0.0', celilo_contract: '1.0' },
105
+ manifestData: { id: 'caddy', name: 'caddy', version: '1.0.0', celilo_contract: '2.0' },
106
106
  sourcePath: '/tmp/caddy',
107
107
  })
108
108
  .run();
@@ -95,7 +95,7 @@ describe('updateOne — displayVersion and quiet', () => {
95
95
  mkdirSync(srcDir, { recursive: true });
96
96
  writeFileSync(
97
97
  join(srcDir, 'manifest.yml'),
98
- `celilo_contract: "1.0"
98
+ `celilo_contract: "2.0"
99
99
  id: testmod
100
100
  name: Test Module
101
101
  version: 1.0.0
@@ -117,7 +117,7 @@ description: fixture
117
117
  version: '1.0.0+5',
118
118
  updatedAt: new Date('2026-04-25T16:26:31Z'),
119
119
  manifestData: {
120
- celilo_contract: '1.0',
120
+ celilo_contract: '2.0',
121
121
  id: 'testmod',
122
122
  name: 'Test Module',
123
123
  version: '1.0.0',
@@ -195,7 +195,7 @@ description: fixture
195
195
  // row's manifestData should reflect the removal.
196
196
  writeFileSync(
197
197
  join(srcDir, 'manifest.yml'),
198
- `celilo_contract: "1.0"
198
+ `celilo_contract: "2.0"
199
199
  id: testmod
200
200
  name: Test Module Renamed
201
201
  version: 2.0.0
@@ -210,7 +210,7 @@ variables:
210
210
  db.update(modules)
211
211
  .set({
212
212
  manifestData: {
213
- celilo_contract: '1.0',
213
+ celilo_contract: '2.0',
214
214
  id: 'testmod',
215
215
  name: 'Test Module',
216
216
  version: '1.0.0',
@@ -249,7 +249,7 @@ variables:
249
249
  try {
250
250
  writeFileSync(
251
251
  join(srcDir, 'manifest.yml'),
252
- `celilo_contract: "1.0"
252
+ `celilo_contract: "2.0"
253
253
  id: testmod
254
254
  name: Test Module
255
255
  version: 1.1.0
@@ -302,7 +302,7 @@ describe('registry sweep — an unanswered breaking update is not a decline', ()
302
302
  sourcePath: join(tempDir, 'installed'),
303
303
  version: '1.0.2+9',
304
304
  manifestData: {
305
- celilo_contract: '1.0',
305
+ celilo_contract: '2.0',
306
306
  id: 'iptables',
307
307
  name: 'iptables',
308
308
  version: '1.0.2',
@@ -376,7 +376,7 @@ describe("updateOne — pointed at the module's own installed path", () => {
376
376
  mkdirSync(installedDir, { recursive: true });
377
377
  writeFileSync(
378
378
  join(installedDir, 'manifest.yml'),
379
- `celilo_contract: "1.0"
379
+ `celilo_contract: "2.0"
380
380
  id: selfmod
381
381
  name: Self Module
382
382
  version: 1.0.0
@@ -392,7 +392,7 @@ description: fixture
392
392
  sourcePath: installedDir,
393
393
  version: '1.0.0',
394
394
  manifestData: {
395
- celilo_contract: '1.0',
395
+ celilo_contract: '2.0',
396
396
  id: 'selfmod',
397
397
  name: 'Self Module',
398
398
  version: '1.0.0',
@@ -480,7 +480,7 @@ describe('updateOne — an update that fails partway leaves the install untouche
480
480
  mkdirSync(join(installedDir, 'generated'), { recursive: true });
481
481
  writeFileSync(
482
482
  join(installedDir, 'manifest.yml'),
483
- 'celilo_contract: "1.0"\nid: testmod\nname: Test Module\nversion: 1.0.0\ndescription: fixture\n',
483
+ 'celilo_contract: "2.0"\nid: testmod\nname: Test Module\nversion: 1.0.0\ndescription: fixture\n',
484
484
  );
485
485
  writeFileSync(join(installedDir, 'scripts', 'on_install.ts'), 'export const OLD = 1;\n');
486
486
  // `derived` — celilo's own output, must survive an update either way.
@@ -490,7 +490,7 @@ describe('updateOne — an update that fails partway leaves the install untouche
490
490
  mkdirSync(join(srcDir, 'scripts'), { recursive: true });
491
491
  writeFileSync(
492
492
  join(srcDir, 'manifest.yml'),
493
- 'celilo_contract: "1.0"\nid: testmod\nname: Test Module\nversion: 2.0.0\ndescription: fixture\n',
493
+ 'celilo_contract: "2.0"\nid: testmod\nname: Test Module\nversion: 2.0.0\ndescription: fixture\n',
494
494
  );
495
495
  writeFileSync(join(srcDir, 'scripts', 'on_install.ts'), 'export const NEW = 2;\n');
496
496
  // cpSync refuses a FIFO with ENOTSUP. Sorts last, so the real files copy first.
@@ -504,7 +504,7 @@ describe('updateOne — an update that fails partway leaves the install untouche
504
504
  sourcePath: installedDir,
505
505
  version: '1.0.0',
506
506
  manifestData: {
507
- celilo_contract: '1.0',
507
+ celilo_contract: '2.0',
508
508
  id: 'testmod',
509
509
  name: 'Test Module',
510
510
  version: '1.0.0',
@@ -588,7 +588,7 @@ describe('updateOne — a .netapp package source', () => {
588
588
  mkdirSync(installedDir, { recursive: true });
589
589
  writeFileSync(
590
590
  join(installedDir, 'manifest.yml'),
591
- 'celilo_contract: "1.0"\nid: testmod\nname: Test Module\nversion: 1.0.0\ndescription: fixture\n',
591
+ 'celilo_contract: "2.0"\nid: testmod\nname: Test Module\nversion: 1.0.0\ndescription: fixture\n',
592
592
  );
593
593
 
594
594
  db = getDb();
@@ -599,7 +599,7 @@ describe('updateOne — a .netapp package source', () => {
599
599
  sourcePath: installedDir,
600
600
  version: '1.0.0+5',
601
601
  manifestData: {
602
- celilo_contract: '1.0',
602
+ celilo_contract: '2.0',
603
603
  id: 'testmod',
604
604
  name: 'Test Module',
605
605
  version: '1.0.0',
@@ -626,7 +626,7 @@ describe('updateOne — a .netapp package source', () => {
626
626
  mkdirSync(srcDir, { recursive: true });
627
627
  writeFileSync(
628
628
  join(srcDir, 'manifest.yml'),
629
- 'celilo_contract: "1.0"\nid: testmod\nname: Test Module\nversion: 2.0.0\ndescription: fixture v2\n',
629
+ 'celilo_contract: "2.0"\nid: testmod\nname: Test Module\nversion: 2.0.0\ndescription: fixture v2\n',
630
630
  );
631
631
  const pkgPath = join(tempDir, 'testmod-2.0.0.netapp');
632
632
  await tarCreate({ file: pkgPath, cwd: srcDir }, ['manifest.yml']);
@@ -45,7 +45,7 @@ async function packageWithManifest(manifestYaml: string): Promise<ArrayBuffer> {
45
45
  }
46
46
 
47
47
  const MANIFEST = (publicWebVersion: string) => `
48
- celilo_contract: "1.0"
48
+ celilo_contract: "2.0"
49
49
  id: celilo-website
50
50
  name: Celilo Website
51
51
  version: 2.0.0
@@ -152,3 +152,28 @@ describe('handleModuleUpgrade — the capability gate (celilo#1361)', () => {
152
152
  }
153
153
  });
154
154
  });
155
+
156
+ describe('handleModuleUpgrade — provider AHEAD is terminal (celilo#1414)', () => {
157
+ test('fails, does not defer, when every blocker is major_mismatch_lower', async () => {
158
+ // Consumer requires public_web@3.1.0; the deployed caddy serves 4.0.0.
159
+ // No retry can fix that — the consumer's source has to move.
160
+ seedFleet('4.0.0');
161
+ downloadSpy.mockImplementation(async () => packageWithManifest(MANIFEST('3.1.0')));
162
+
163
+ const result = await handleModuleUpgrade(['celilo-website'], {});
164
+
165
+ expect(result.success).toBe(false);
166
+ if (!result.success) {
167
+ expect(result.deferred).toBeUndefined();
168
+ expect(result.error).not.toContain('retries automatically');
169
+ expect(result.error).toContain('public_web');
170
+ }
171
+ // Still no state change: a terminal blocker must not record the version.
172
+ const row = getDb()
173
+ .select()
174
+ .from(modules)
175
+ .all()
176
+ .find((m) => m.id === 'celilo-website');
177
+ expect(row?.version).toBe('1.0.5');
178
+ });
179
+ });
@@ -180,7 +180,7 @@ describe('upgradeOneModule — a failed deploy records the failure in the row (c
180
180
  mkdirSync(installedDir, { recursive: true });
181
181
  writeFileSync(
182
182
  join(installedDir, 'manifest.yml'),
183
- 'celilo_contract: "1.0"\nid: testmod\nname: Test Module\nversion: 1.0.0\ndescription: fixture\n',
183
+ 'celilo_contract: "2.0"\nid: testmod\nname: Test Module\nversion: 1.0.0\ndescription: fixture\n',
184
184
  );
185
185
 
186
186
  db = getDb();
@@ -193,7 +193,7 @@ describe('upgradeOneModule — a failed deploy records the failure in the row (c
193
193
  state: 'VERIFIED',
194
194
  updatedAt: FROZEN,
195
195
  manifestData: {
196
- celilo_contract: '1.0',
196
+ celilo_contract: '2.0',
197
197
  id: 'testmod',
198
198
  name: 'Test Module',
199
199
  version: '1.0.0',
@@ -208,7 +208,7 @@ describe('upgradeOneModule — a failed deploy records the failure in the row (c
208
208
  mkdirSync(srcDir, { recursive: true });
209
209
  writeFileSync(
210
210
  join(srcDir, 'manifest.yml'),
211
- 'celilo_contract: "1.0"\nid: testmod\nname: Test Module\nversion: 1.0.0\ndescription: fixture v6\n',
211
+ 'celilo_contract: "2.0"\nid: testmod\nname: Test Module\nversion: 1.0.0\ndescription: fixture v6\n',
212
212
  );
213
213
  const pkgPath = join(tempDir, 'testmod-1.0.0.netapp');
214
214
  await tarCreate({ file: pkgPath, cwd: srcDir }, ['manifest.yml']);
@@ -194,6 +194,18 @@ export async function upgradeOneModule(
194
194
  const blockers = capabilityBlockersForManifest(db, targetManifestCheck.manifest);
195
195
  if (blockers.length > 0) {
196
196
  const why = blockers.map((b) => b.message).join('; ');
197
+ // A provider AHEAD of what the consumer requires is TERMINAL (celilo#1414):
198
+ // no retry can fix it, the consumer's source must move to the provider's
199
+ // current major and republish. Only a provider BEHIND (major_mismatch_higher,
200
+ // the celilo#1361 case) is transient. Deferring the terminal case retried it
201
+ // forever, reported success, and left four consumers silently stuck.
202
+ if (blockers.every((b) => b.reason === 'major_mismatch_lower')) {
203
+ return {
204
+ success: false,
205
+ error: `${moduleId} upgrade to ${targetVersion} cannot proceed: the deployed providers are AHEAD of its capability requirements: ${why}. This will not resolve on retry — update ${moduleId}'s source to the provider's current major and republish it.`,
206
+ details: blockers,
207
+ };
208
+ }
197
209
  return {
198
210
  success: false,
199
211
  deferred: true,
@@ -8,7 +8,7 @@ let dir: string;
8
8
 
9
9
  const MANIFEST = (kind: string | null) =>
10
10
  [
11
- 'celilo_contract: "1.0"',
11
+ 'celilo_contract: "2.0"',
12
12
  'id: lunacycle',
13
13
  'name: LunaCycle',
14
14
  'version: 1.0.1 # keep this comment',
package/src/cli/index.ts CHANGED
@@ -2578,6 +2578,26 @@ function parseCommand(command: string): string[] {
2578
2578
  return args;
2579
2579
  }
2580
2580
 
2581
+ /**
2582
+ * Write to stdout and WAIT for it to reach the far end before returning.
2583
+ *
2584
+ * `process.stdout.write()` followed by `process.exit()` silently drops
2585
+ * everything past the pipe buffer (64 KiB on macOS) whenever stdout is a pipe
2586
+ * rather than a TTY — so `celilo completion zsh > file` was whole while
2587
+ * `GENERATED=$(celilo completion zsh)` lost its tail mid-line, with exit 0 and
2588
+ * no diagnostic anywhere. install-zsh-completion.sh captured exactly that way
2589
+ * and installed a truncated, unparseable `_celilo`.
2590
+ *
2591
+ * Awaiting the stream's own write callback is the whole fix: it fires once the
2592
+ * bytes have been accepted downstream, so the following `process.exit()` can no
2593
+ * longer race the flush.
2594
+ */
2595
+ async function writeStdout(text: string): Promise<void> {
2596
+ await new Promise<void>((resolve, reject) => {
2597
+ process.stdout.write(text, (err) => (err ? reject(err) : resolve()));
2598
+ });
2599
+ }
2600
+
2581
2601
  async function serverMode(): Promise<void> {
2582
2602
  const readline = await import('node:readline');
2583
2603
 
@@ -2699,7 +2719,7 @@ export async function main(): Promise<void> {
2699
2719
  if (result.success) {
2700
2720
  // For completion mode, output raw text without formatting
2701
2721
  if (process.argv.includes('--get-completions') || process.argv.includes('completion')) {
2702
- console.log(result.message);
2722
+ await writeStdout(`${result.message}\n`);
2703
2723
  process.exit(0);
2704
2724
  }
2705
2725
 
@@ -2722,7 +2742,7 @@ export async function main(): Promise<void> {
2722
2742
  // `line.startsWith('<module-id> ')` over `celilo module list` match
2723
2743
  // nothing — read in celilo#695 as a missing module rather than as a
2724
2744
  // parse failure, at the cost of a full e2e run.
2725
- process.stdout.write(`${result.message}\n`);
2745
+ await writeStdout(`${result.message}\n`);
2726
2746
  process.exit(0);
2727
2747
  }
2728
2748
 
@@ -37,7 +37,7 @@ const MODULE_ID = 'restore-migration-test';
37
37
 
38
38
  /** The manifest the module row carries: one on_restore hook, the fixture. */
39
39
  const MODULE_MANIFEST = {
40
- celilo_contract: '1.0',
40
+ celilo_contract: '2.0',
41
41
  id: MODULE_ID,
42
42
  name: 'Restore Migration Test',
43
43
  version: '0.0.1',
@@ -0,0 +1,35 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ // `process.stdout.write()` followed by `process.exit()` drops everything past
4
+ // the 64 KiB OS pipe buffer when stdout is a pipe. The zsh completion script is
5
+ // the CLI's largest stdout payload (~70 KB), so it is the cheapest witness.
6
+ //
7
+ // The capture has to go through a real shell: a Bun.spawn pipe is not an OS
8
+ // pipe and never truncates, so a test written that way passes with the defect
9
+ // present. `$(...)` is how install-zsh-completion.sh reads the generator, and
10
+ // it read 65536 bytes of a 70092-byte script — exit 0, no diagnostic, and a
11
+ // `_celilo` cut off mid-line installed into the operator's fpath.
12
+ //
13
+ // The invariant is that a capture and a redirect of the same command agree to
14
+ // within the trailing newlines `$(...)` strips. Both are measured in the same
15
+ // shell, so nothing here depends on the generator's exact size.
16
+ describe('CLI stdout survives an OS pipe', () => {
17
+ test('completion zsh is captured whole, not truncated at 64 KiB', async () => {
18
+ const cli = `bun run ${import.meta.dir}/index.ts completion zsh`;
19
+ const proc = Bun.spawn(
20
+ [
21
+ 'bash',
22
+ '-c',
23
+ `f=$(mktemp); ${cli} > "$f"; out=$(${cli}); printf %s "$out" | wc -c; wc -c < "$f"; rm -f "$f"`,
24
+ ],
25
+ { stdout: 'pipe', stderr: 'ignore' },
26
+ );
27
+ const [captured, redirected] = (await new Response(proc.stdout).text())
28
+ .trim()
29
+ .split('\n')
30
+ .map((n) => Number(n.trim()));
31
+
32
+ expect(redirected).toBeGreaterThan(65536); // below the buffer this proves nothing
33
+ expect(redirected - captured).toBeLessThanOrEqual(2); // only stripped newlines
34
+ }, 60_000);
35
+ });
@@ -0,0 +1,159 @@
1
+ /**
2
+ * `private_routes` — the internal ingress's own declared rows (slice 2 of
3
+ * providers-converge-declared-state, task 2.1).
4
+ *
5
+ * Same shape as `web_routes`, separate table on purpose. caddy derives the
6
+ * hostnames it SERVES from every row of `web_routes`, so a private route stored
7
+ * there would be picked up and served by the PUBLIC ingress — the exact leak
8
+ * caddy-internal exists to prevent (celilo#846). The isolation is therefore a
9
+ * property worth asserting rather than assuming: a table boundary is only as
10
+ * good as the queries that respect it.
11
+ */
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 { eq } from 'drizzle-orm';
18
+ import { setupTestDatabaseAt } from '../test-utils/database';
19
+ import { resetTestDbPath } from '../test-utils/db-path';
20
+ import type { DbClient } from './client';
21
+ import { modules, privateRoutes, webRoutes } from './schema';
22
+
23
+ describe('private_routes', () => {
24
+ let dir: string;
25
+ let db: DbClient;
26
+
27
+ beforeEach(async () => {
28
+ dir = mkdtempSync(join(tmpdir(), 'private-routes-'));
29
+ const dbPath = join(dir, 'celilo.db');
30
+ process.env.CELILO_DB_PATH = dbPath;
31
+ db = await setupTestDatabaseAt(dbPath);
32
+
33
+ for (const id of ['wireguard-manager', 'hello-foo']) {
34
+ db.insert(modules)
35
+ .values({
36
+ id,
37
+ name: id,
38
+ version: '1.0.0',
39
+ manifestData: {},
40
+ sourcePath: join(dir, 'modules', id),
41
+ state: 'VERIFIED',
42
+ })
43
+ .onConflictDoNothing()
44
+ .run();
45
+ }
46
+ });
47
+
48
+ afterEach(() => {
49
+ db.$client.close();
50
+ resetTestDbPath();
51
+ try {
52
+ rmSync(dir, { recursive: true, force: true });
53
+ } catch {
54
+ /* ignore */
55
+ }
56
+ });
57
+
58
+ function insertPrivate(overrides: Record<string, unknown> = {}) {
59
+ db.insert(privateRoutes)
60
+ .values({
61
+ slug: 'vpn-manager',
62
+ moduleId: 'wireguard-manager',
63
+ type: 'reverse_proxy',
64
+ path: '/',
65
+ hostname: 'vpn-manager.celilo.computer',
66
+ targetHost: '10.0.20.15',
67
+ targetPort: 3000,
68
+ websocket: false,
69
+ ...overrides,
70
+ })
71
+ .run();
72
+ }
73
+
74
+ test('carries web_routes columns, so one renderer shape serves both', () => {
75
+ insertPrivate();
76
+ const row = db.select().from(privateRoutes).get();
77
+
78
+ // Named individually rather than compared as a set: the point is that a
79
+ // renderer written against WebRoute reads this row unchanged.
80
+ expect(row?.slug).toBe('vpn-manager');
81
+ expect(row?.moduleId).toBe('wireguard-manager');
82
+ expect(row?.type).toBe('reverse_proxy');
83
+ expect(row?.path).toBe('/');
84
+ expect(row?.hostname).toBe('vpn-manager.celilo.computer');
85
+ expect(row?.targetHost).toBe('10.0.20.15');
86
+ expect(row?.targetPort).toBe(3000);
87
+ expect(row?.websocket).toBe(false);
88
+ expect(row?.contentHash ?? null).toBeNull();
89
+ expect(row?.createdAt).toBeInstanceOf(Date);
90
+ expect(row?.updatedAt).toBeInstanceOf(Date);
91
+ });
92
+
93
+ test('a private route is unreachable from the public provider query (celilo#846)', () => {
94
+ insertPrivate();
95
+
96
+ // This is the query the public provider runs — buildRouteOps.getAllRoutes,
97
+ // which caddy's renderer turns into served hostnames. It must not see a
98
+ // private row, or the private hostname gets a public site block and a
99
+ // public certificate.
100
+ const publicRows = db.select().from(webRoutes).all();
101
+ expect(publicRows).toEqual([]);
102
+
103
+ // And the converse, so the assertion cannot pass merely because nothing
104
+ // was written anywhere.
105
+ expect(db.select().from(privateRoutes).all()).toHaveLength(1);
106
+ });
107
+
108
+ test('a public and a private route may share a hostname and path without colliding', () => {
109
+ // Separate tables means separate unique indexes. Same name served by both
110
+ // ingresses is a legitimate split-horizon arrangement, and a shared table
111
+ // would have rejected the second row.
112
+ insertPrivate({ hostname: 'shared.celilo.computer' });
113
+ db.insert(webRoutes)
114
+ .values({
115
+ slug: 'shared',
116
+ moduleId: 'hello-foo',
117
+ type: 'reverse_proxy',
118
+ path: '/',
119
+ hostname: 'shared.celilo.computer',
120
+ targetHost: '10.0.10.9',
121
+ targetPort: 8080,
122
+ websocket: false,
123
+ })
124
+ .run();
125
+
126
+ expect(db.select().from(privateRoutes).all()).toHaveLength(1);
127
+ expect(db.select().from(webRoutes).all()).toHaveLength(1);
128
+ });
129
+
130
+ test('uniqueness is scoped to (hostname, path), as web_routes is', () => {
131
+ insertPrivate();
132
+ expect(() => insertPrivate({ moduleId: 'hello-foo', slug: 'other' })).toThrow();
133
+ });
134
+
135
+ test('removing the consumer removes its routes (consumer cascade)', () => {
136
+ insertPrivate();
137
+ db.insert(privateRoutes)
138
+ .values({
139
+ slug: 'other',
140
+ moduleId: 'hello-foo',
141
+ type: 'reverse_proxy',
142
+ path: '/other',
143
+ hostname: 'vpn-manager.celilo.computer',
144
+ targetHost: '10.0.20.16',
145
+ targetPort: 3000,
146
+ websocket: false,
147
+ })
148
+ .run();
149
+
150
+ db.delete(modules).where(eq(modules.id, 'wireguard-manager')).run();
151
+
152
+ // The departing consumer's row goes; the other consumer's stays. A private
153
+ // route outliving its module would render a site block pointing at a
154
+ // backend nothing owns.
155
+ const left = db.select().from(privateRoutes).all();
156
+ expect(left).toHaveLength(1);
157
+ expect(left[0]?.moduleId).toBe('hello-foo');
158
+ });
159
+ });
package/src/db/schema.ts CHANGED
@@ -816,6 +816,55 @@ export const webRoutes = sqliteTable(
816
816
  }),
817
817
  );
818
818
 
819
+ /**
820
+ * Private-route registry — the desired-state store for the `private_web`
821
+ * capability (openspec/changes/providers-converge-declared-state, slice 2).
822
+ *
823
+ * `web_routes`' columns, its own table. The shape is shared so ONE renderer
824
+ * shape serves both ingresses and the plumbing around them is the same
825
+ * (design D1); the table is separate because the PUBLIC provider derives the
826
+ * hostnames it serves from every row of `web_routes`, so a private route
827
+ * stored there would be picked up and served publicly — the exact leak
828
+ * caddy-internal exists to prevent (celilo#846). Two tables is what makes that
829
+ * impossible rather than merely intended.
830
+ *
831
+ * This replaces caddy-internal's `routes` module-config key, which held the
832
+ * same rows as module business inside one module's config. No data migration
833
+ * (design D9): the table starts empty, and when caddy-internal deploys,
834
+ * provider arrival re-registers its consumers, whose hooks write their routes
835
+ * into it inside that same deploy.
836
+ * @owner capability:private_web — one row is one route on the internal ingress
837
+ */
838
+ export const privateRoutes = sqliteTable(
839
+ 'private_routes',
840
+ {
841
+ id: integer('id').primaryKey({ autoIncrement: true }),
842
+ slug: text('slug').notNull(),
843
+ moduleId: text('module_id')
844
+ .notNull()
845
+ .references(() => modules.id, { onDelete: 'cascade' }),
846
+ type: text('type').$type<'static' | 'reverse_proxy'>().notNull(),
847
+ path: text('path').notNull(),
848
+ /** FQDN this route serves under, in the INTERNAL DNS view only. */
849
+ hostname: text('hostname').notNull(),
850
+ targetHost: text('target_host'),
851
+ targetPort: integer('target_port'),
852
+ websocket: integer('websocket', { mode: 'boolean' }).notNull().default(false),
853
+ contentHash: text('content_hash'),
854
+ createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
855
+ updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
856
+ },
857
+ (table) => ({
858
+ // Scoped to hostname exactly as web_routes is, and scoped to THIS table:
859
+ // the same hostname and path may exist on both ingresses, which is a
860
+ // legitimate split-horizon arrangement.
861
+ hostnamePathUnique: uniqueIndex('private_routes_hostname_path_idx').on(
862
+ table.hostname,
863
+ table.path,
864
+ ),
865
+ }),
866
+ );
867
+
819
868
  /**
820
869
  * Port-forward registry — the desired-state store for the `firewall` capability
821
870
  * (openspec/changes/unified-management-no-ssh/proposal.md). One row per exposed forward. `exposeService`