@celilo/cli 0.11.0-alpha.0 → 0.12.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 (121) hide show
  1. package/AGENTS.md +7 -4
  2. package/CELILO_CORE_MODULES.md +7 -6
  3. package/CELILO_SUBSYSTEMS.md +47 -5
  4. package/MODULE_PRIMITIVES.md +164 -0
  5. package/README.md +5 -5
  6. package/drizzle/0015_port_forwards.sql +12 -0
  7. package/drizzle/meta/_journal.json +7 -0
  8. package/package.json +8 -8
  9. package/schemas/system_config.json +1 -1
  10. package/src/ansible/inventory.test.ts +1 -1
  11. package/src/ansible/inventory.ts +3 -3
  12. package/src/capabilities/public-web-publish.test.ts +209 -0
  13. package/src/capabilities/validation.test.ts +1 -1
  14. package/src/capabilities/well-known.test.ts +1 -1
  15. package/src/capabilities/well-known.ts +2 -2
  16. package/src/cli/commands/api.ts +1 -1
  17. package/src/cli/commands/backup-pull.ts +1 -1
  18. package/src/cli/commands/events.ts +1 -1
  19. package/src/cli/commands/module-changeset.ts +1 -1
  20. package/src/cli/commands/module-import.ts +1 -1
  21. package/src/cli/commands/module-publish.ts +1 -1
  22. package/src/cli/commands/module-remove.ts +26 -1
  23. package/src/cli/commands/module-types.ts +1 -1
  24. package/src/cli/commands/module-upgrade.ts +1 -1
  25. package/src/cli/commands/module-version.ts +1 -1
  26. package/src/cli/commands/module-where.test.ts +26 -0
  27. package/src/cli/commands/module-where.ts +130 -0
  28. package/src/cli/commands/proxmox-node-list.ts +1 -1
  29. package/src/cli/commands/publish/alpha.test.ts +1 -1
  30. package/src/cli/commands/publish/alpha.ts +3 -3
  31. package/src/cli/commands/publish/changesets.ts +1 -1
  32. package/src/cli/commands/publish/global-install.ts +6 -2
  33. package/src/cli/commands/publish/helpers.ts +21 -14
  34. package/src/cli/commands/publish/index.ts +4 -4
  35. package/src/cli/commands/publish/plan.ts +3 -3
  36. package/src/cli/commands/publish/preflight.ts +2 -2
  37. package/src/cli/commands/publish/types.ts +2 -2
  38. package/src/cli/commands/publish/workspace.test.ts +1 -1
  39. package/src/cli/commands/publish/workspace.ts +2 -2
  40. package/src/cli/commands/registry-owner.test.ts +166 -0
  41. package/src/cli/commands/registry-owner.ts +124 -0
  42. package/src/cli/commands/registry-token.test.ts +109 -0
  43. package/src/cli/commands/registry-token.ts +194 -0
  44. package/src/cli/commands/restore.ts +1 -1
  45. package/src/cli/commands/service-add-proxmox.ts +1 -1
  46. package/src/cli/commands/subscribers-list.ts +1 -1
  47. package/src/cli/commands/system-apply-config-equivalence.test.ts +1 -1
  48. package/src/cli/commands/system-apply-config.ts +1 -1
  49. package/src/cli/commands/system-init-deprecation.test.ts +1 -1
  50. package/src/cli/commands/system-init.ts +2 -2
  51. package/src/cli/commands/token.test.ts +26 -0
  52. package/src/cli/commands/token.ts +160 -0
  53. package/src/cli/completion.ts +19 -0
  54. package/src/cli/index.ts +155 -4
  55. package/src/cli/restore-command.test.ts +1 -1
  56. package/src/db/client.ts +1 -1
  57. package/src/db/schema.ts +50 -7
  58. package/src/hooks/capability-loader-firewall.test.ts +4 -3
  59. package/src/hooks/capability-loader.ts +100 -10
  60. package/src/hooks/define-hook.test.ts +24 -0
  61. package/src/hooks/executor.test.ts +1 -1
  62. package/src/hooks/executor.ts +2 -2
  63. package/src/hooks/types.ts +1 -1
  64. package/src/manifest/contracts/v1.ts +2 -2
  65. package/src/manifest/schema.ts +13 -13
  66. package/src/manifest/template-validator.ts +1 -1
  67. package/src/module/packaging/build.ts +19 -0
  68. package/src/module/packaging/workspace-deps.test.ts +94 -0
  69. package/src/module/packaging/workspace-deps.ts +185 -0
  70. package/src/module/versioning/changeset-version.ts +1 -1
  71. package/src/policy/no-hand-built-ssh.test.ts +90 -0
  72. package/src/registry/client.test.ts +86 -0
  73. package/src/registry/client.ts +67 -1
  74. package/src/services/api-access.ts +1 -1
  75. package/src/services/aspect-approvals.ts +1 -1
  76. package/src/services/aspect-runner.ts +3 -3
  77. package/src/services/aspect-template-resolver.test.ts +1 -1
  78. package/src/services/aspect-template-resolver.ts +1 -1
  79. package/src/services/build-bus/delivery-events.ts +1 -1
  80. package/src/services/build-bus/fan-out.ts +1 -1
  81. package/src/services/build-bus/hook-dispatch.ts +1 -1
  82. package/src/services/build-bus/receiver-server.ts +1 -1
  83. package/src/services/build-bus/status.test.ts +1 -1
  84. package/src/services/build-bus/status.ts +1 -1
  85. package/src/services/build-bus/subscriber-store.ts +1 -1
  86. package/src/services/bus-interview.ts +1 -1
  87. package/src/services/celilo-events.ts +1 -1
  88. package/src/services/celilo-mgmt-hooks.test.ts +1 -1
  89. package/src/services/cross-module-read.ts +1 -1
  90. package/src/services/deploy-posture.ts +1 -1
  91. package/src/services/deployed-systems.test.ts +1 -1
  92. package/src/services/deployed-systems.ts +4 -4
  93. package/src/services/dns-provider-backfill.ts +2 -2
  94. package/src/services/events-daemon.ts +1 -1
  95. package/src/services/machine-pool.ts +3 -3
  96. package/src/services/module-deploy.ts +93 -8
  97. package/src/services/module-subscriptions.ts +1 -1
  98. package/src/services/module-types-generator.ts +1 -1
  99. package/src/services/module-validator/git-hygiene.ts +1 -1
  100. package/src/services/port-forwards.test.ts +92 -0
  101. package/src/services/port-forwards.ts +86 -0
  102. package/src/services/proxmox-reconcile.ts +2 -2
  103. package/src/services/public-web-republish.test.ts +189 -0
  104. package/src/services/public-web-republish.ts +84 -0
  105. package/src/services/restore-from-file.ts +1 -1
  106. package/src/services/restore-preflight.ts +1 -1
  107. package/src/services/system-identity.ts +1 -1
  108. package/src/services/system-init.test.ts +2 -2
  109. package/src/services/system-init.ts +2 -2
  110. package/src/services/web-route-cleanup.test.ts +250 -0
  111. package/src/services/web-route-cleanup.ts +144 -0
  112. package/src/templates/generator.ts +2 -2
  113. package/src/types/infrastructure.ts +1 -1
  114. package/src/variables/computed/computed-integration.test.ts +1 -1
  115. package/src/variables/computed/evaluate.ts +1 -1
  116. package/src/variables/computed/parse.ts +1 -1
  117. package/src/variables/context.test.ts +1 -1
  118. package/src/variables/context.ts +5 -5
  119. package/src/variables/lxc-nameserver.test.ts +1 -1
  120. package/src/variables/resolver.ts +2 -2
  121. package/src/variables/types.ts +1 -1
package/src/cli/index.ts CHANGED
@@ -77,11 +77,11 @@ import { handleModuleUpdate } from './commands/module-update';
77
77
  import { handleModuleUpgrade } from './commands/module-upgrade';
78
78
  import { moduleVerify } from './commands/module-verify';
79
79
  import { handleModuleVersion } from './commands/module-version';
80
+ import { handleModuleWhere } from './commands/module-where';
80
81
  import { handlePackage } from './commands/package';
81
82
  import { handleProxmoxInstanceList } from './commands/proxmox-instance-list';
82
83
  import { handleProxmoxInstanceResize } from './commands/proxmox-instance-resize';
83
84
  import { handleProxmoxNodeList } from './commands/proxmox-node-list';
84
- import { main as runPublish } from './commands/publish';
85
85
  import { handleSecretList } from './commands/secret-list';
86
86
  import { handleSecretSet } from './commands/secret-set';
87
87
  import { handleServiceAddDigitalOcean } from './commands/service-add-digitalocean';
@@ -193,6 +193,8 @@ Commands:
193
193
  ipam Manage IP address and VMID allocations and reservations
194
194
  proxmox Proxmox cluster introspection (proxmox node list)
195
195
  publish Publish workspace packages to npm and modules to celilo.computer
196
+ registry Administer the module registry (append-safe publish-token management)
197
+ token Manage contributor identity tokens (idp-issued per-user publish tokens)
196
198
  subscribers Manage build-bus subscribers (cross-machine publish-event delivery)
197
199
  api Manage remote-API access (principals, grants, authorized_keys)
198
200
  completion Generate shell completion scripts (bash/zsh)
@@ -301,7 +303,7 @@ Description:
301
303
  Modules declare \`subscriptions:\` in their manifests; \`celilo module deploy\`
302
304
  emits lifecycle events that subscribers react to.
303
305
 
304
- See infra/design/SQLITE_EVENT_BUS.md for the full design.
306
+ See infra/openspec/specs/event-bus/spec.md for the full design.
305
307
 
306
308
  Examples:
307
309
  celilo events run # foreground dispatcher
@@ -340,7 +342,7 @@ Description:
340
342
  package name glob). The publisher signs every event with the
341
343
  subscriber's secret; the receiver verifies before reacting.
342
344
 
343
- This is the static-config first cut of v2/BUILD_BUS.md — operators
345
+ This is the static-config first cut of openspec/changes/build-bus-poll-cd/proposal.md — operators
344
346
  manage subscribers explicitly. Auto-discovery via the celilo
345
347
  registry switchboard is a follow-on.
346
348
 
@@ -433,6 +435,79 @@ Examples:
433
435
  return { success: true, message: helpText };
434
436
  }
435
437
 
438
+ function displayRegistryHelp(): CommandResult {
439
+ const helpText = `
440
+ Celilo - Module Registry Administration
441
+
442
+ Usage:
443
+ celilo registry <subcommand> [args...]
444
+
445
+ Subcommands:
446
+ token add <token> Append a publish token (append-safe; never clobbers others)
447
+ token rm <token> Remove one publish token, leaving the rest intact
448
+ owner list List the module-owner table (who may publish what)
449
+ owner show <module> Show the owner of one module name
450
+ owner set <module> <sub> Reassign a module name to a new owner (admin)
451
+
452
+ Description:
453
+ The celilo-registry module's publish_tokens secret is a newline-separated list
454
+ of bootstrap/admin publish tokens. Editing it with 'module secret set' OVERWRITES
455
+ the whole list, clobbering every other holder. These commands read-modify-write
456
+ the list instead.
457
+
458
+ The module-owner table (owner *) gates which module NAMES a verified publisher
459
+ may publish: the first verified publisher of an unclaimed name owns it
460
+ (first-publish-claims); thereafter only that owner or an admin may publish it.
461
+ 'owner set' is the admin reassignment. These read the admin token from the local
462
+ publish_tokens list; pass --registry to target a non-default registry.
463
+
464
+ Run on the host where celilo-registry is deployed (where the master key lives).
465
+
466
+ Examples:
467
+ celilo registry token add ci-token-abc123
468
+ celilo registry owner list
469
+ celilo registry owner show homebridge
470
+ celilo registry owner set homebridge alice
471
+ `;
472
+
473
+ return { success: true, message: helpText.trim() };
474
+ }
475
+
476
+ function displayTokenHelp(): CommandResult {
477
+ const helpText = `
478
+ Celilo - Contributor Identity Tokens (idp-issued, per-user)
479
+
480
+ Usage:
481
+ celilo token <subcommand> [args...]
482
+
483
+ Subcommands:
484
+ obtain --user <username> Mint (or return) a per-user API token for publishing
485
+ list --user <username> List a user's API tokens (metadata only, no secret)
486
+ revoke <identifier> Revoke a token by identifier; next publish denied
487
+
488
+ Options:
489
+ --user <username> The idp user the token is bound to
490
+ --identifier <id> Token identifier (default: celilo-publish-<user>)
491
+ --description <text> Human-readable description (obtain only)
492
+
493
+ Description:
494
+ Issues per-user API tokens from the idp (authentik) so a module contributor
495
+ authenticates publishes AS THEMSELVES — no admin/shared token on their machine.
496
+ The token is consumed by 'celilo author init'. Shown once at mint; the idp
497
+ stores it hashed and celilo persists nothing.
498
+
499
+ Distinct from 'celilo registry token add/rm', which manages the raw bootstrap
500
+ publish_tokens list. Run on the host where the idp is deployed (celilo-mgr).
501
+
502
+ Examples:
503
+ celilo token obtain --user alice
504
+ celilo token list --user alice
505
+ celilo token revoke celilo-publish-alice
506
+ `;
507
+
508
+ return { success: true, message: helpText.trim() };
509
+ }
510
+
436
511
  function displayCapabilityHelp(): CommandResult {
437
512
  const helpText = `
438
513
  Celilo - Capability Management
@@ -1206,6 +1281,71 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
1206
1281
  };
1207
1282
  }
1208
1283
 
1284
+ if (parsed.command === 'registry') {
1285
+ if (parsed.flags.help || parsed.flags.h || !parsed.subcommand) {
1286
+ return displayRegistryHelp();
1287
+ }
1288
+
1289
+ const registryFlagError = checkFlags('registry', parsed.subcommand, parsed.flags, parsed.args);
1290
+ if (registryFlagError) return registryFlagError;
1291
+
1292
+ if (parsed.subcommand === 'token') {
1293
+ const action = parsed.args[0];
1294
+ const tokenArgs = parsed.args.slice(1);
1295
+ const { handleRegistryTokenAdd, handleRegistryTokenRm } = await import(
1296
+ './commands/registry-token'
1297
+ );
1298
+ if (action === 'add') return handleRegistryTokenAdd(tokenArgs);
1299
+ if (action === 'rm') return handleRegistryTokenRm(tokenArgs);
1300
+ return {
1301
+ success: false,
1302
+ error:
1303
+ 'Token action required (add or rm)\n\nUsage:\n celilo registry token add <token>\n celilo registry token rm <token>',
1304
+ };
1305
+ }
1306
+
1307
+ if (parsed.subcommand === 'owner') {
1308
+ const action = parsed.args[0];
1309
+ const ownerArgs = parsed.args.slice(1);
1310
+ const { handleRegistryOwnerList, handleRegistryOwnerShow, handleRegistryOwnerSet } =
1311
+ await import('./commands/registry-owner');
1312
+ if (action === 'list') return handleRegistryOwnerList(parsed.flags);
1313
+ if (action === 'show') return handleRegistryOwnerShow(ownerArgs, parsed.flags);
1314
+ if (action === 'set') return handleRegistryOwnerSet(ownerArgs, parsed.flags);
1315
+ return {
1316
+ success: false,
1317
+ error:
1318
+ 'Owner action required (list, show, or set)\n\nUsage:\n celilo registry owner list\n celilo registry owner show <module>\n celilo registry owner set <module> <owner-sub>',
1319
+ };
1320
+ }
1321
+
1322
+ return {
1323
+ success: false,
1324
+ error: `Unknown registry subcommand: ${parsed.subcommand}\n\nRun "celilo registry --help" for usage`,
1325
+ };
1326
+ }
1327
+
1328
+ if (parsed.command === 'token') {
1329
+ if (parsed.flags.help || parsed.flags.h || !parsed.subcommand) {
1330
+ return displayTokenHelp();
1331
+ }
1332
+
1333
+ const tokenFlagError = checkFlags('token', parsed.subcommand, parsed.flags, parsed.args);
1334
+ if (tokenFlagError) return tokenFlagError;
1335
+
1336
+ const { handleTokenObtain, handleTokenList, handleTokenRevoke } = await import(
1337
+ './commands/token'
1338
+ );
1339
+ if (parsed.subcommand === 'obtain') return handleTokenObtain(parsed.args, parsed.flags);
1340
+ if (parsed.subcommand === 'list') return handleTokenList(parsed.args, parsed.flags);
1341
+ if (parsed.subcommand === 'revoke') return handleTokenRevoke(parsed.args, parsed.flags);
1342
+
1343
+ return {
1344
+ success: false,
1345
+ error: `Unknown token subcommand: ${parsed.subcommand}\n\nRun "celilo token --help" for usage`,
1346
+ };
1347
+ }
1348
+
1209
1349
  if (parsed.command === 'publish') {
1210
1350
  if (parsed.flags.help || parsed.flags.h) {
1211
1351
  return displayPublishHelp();
@@ -1218,6 +1358,15 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
1218
1358
  // flag identity since the parser canonicalises those.
1219
1359
  const publishIdx = process.argv.indexOf('publish');
1220
1360
  const publishArgv = publishIdx >= 0 ? process.argv.slice(publishIdx + 1) : [...parsed.args];
1361
+ // Lazy-load the publish command: it's monorepo-only release tooling whose
1362
+ // transitive import chain reaches scripts/workspace-graph.ts, which does NOT
1363
+ // ship in @celilo/cli's files[]. A static top-level import pulled that into
1364
+ // EVERY command's load graph, so `celilo --version` in a consumer install
1365
+ // crashed on the unresolvable module (WORKSPACE_PACKAGE_GRAPH.md — the D3
1366
+ // failure, deeper than the module-load throw). Loading it only when the
1367
+ // operator actually runs `celilo publish` (which only happens in the
1368
+ // monorepo) keeps every other command free of the dependency.
1369
+ const { main: runPublish } = await import('./commands/publish');
1221
1370
  await runPublish(publishArgv);
1222
1371
  // runPublish handles its own console output (multi-phase, multi-line);
1223
1372
  // returning an empty success message tells the outer CLI loop to skip
@@ -1306,6 +1455,8 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
1306
1455
  return handleModuleList(parsed.flags);
1307
1456
  case 'status':
1308
1457
  return handleModuleStatus(parsed.args);
1458
+ case 'where':
1459
+ return handleModuleWhere(parsed.args, parsed.flags);
1309
1460
  case 'logs':
1310
1461
  return handleModuleLogs(parsed.args, parsed.flags);
1311
1462
  case 'health':
@@ -1697,7 +1848,7 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
1697
1848
  }
1698
1849
 
1699
1850
  if (parsed.command === 'restore') {
1700
- // Phase 4 of v2/SYSTEM_BACKUP_TERRAFORM_STATE.md — top-level
1851
+ // Phase 4 of openspec/specs/management-server-backup/spec.md — top-level
1701
1852
  // fresh-bootstrap restore. Distinct from `celilo backup restore
1702
1853
  // <id>` (which restores from a configured storage destination
1703
1854
  // using an existing backup record); this one takes a local file
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Tests for the `celilo restore` top-level command (Phase 4 of
3
- * v2/SYSTEM_BACKUP_TERRAFORM_STATE.md).
3
+ * openspec/specs/management-server-backup/spec.md).
4
4
  *
5
5
  * Covers the CLI surface: routing, argument parsing, pre-flight
6
6
  * gating, error propagation. The actual file-direct restore +
package/src/db/client.ts CHANGED
@@ -92,7 +92,7 @@ export function createDbClient(config?: Partial<DatabaseConfig>) {
92
92
  }
93
93
 
94
94
  // One-time upgrade backfill for the target_ip → module_systems refactor
95
- // (v2/MODULE_SYSTEMS_ADDRESSING.md). migrate() above has ensured the
95
+ // (openspec/specs/module-systems-addressing/spec.md). migrate() above has ensured the
96
96
  // module_systems table exists (migration 0007). A deployment created before
97
97
  // the refactor has its host data only in module_configs /
98
98
  // ip_allocations / module_infrastructure and an EMPTY module_systems, so its
package/src/db/schema.ts CHANGED
@@ -318,7 +318,7 @@ export const machines = sqliteTable('machines', {
318
318
  * Appliance machines (e.g., greenwave = ISP modem) where celilo
319
319
  * has no shell-level access — only API calls. Base-module aspects
320
320
  * cannot Ansible to these systems, so the aspect runner skips
321
- * them. See v2/CELILO_BASE.md D8.
321
+ * them. See openspec/specs/base-module-aspects/spec.md D8.
322
322
  */
323
323
  apiOnly: integer('api_only', { mode: 'boolean' }).notNull().default(false),
324
324
  createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
@@ -345,7 +345,7 @@ export const moduleInfrastructure = sqliteTable('module_infrastructure', {
345
345
  * driver registers an entry that has no SSH surface (rare today,
346
346
  * but exists for future API-only providers). Aspect runner reads
347
347
  * this to decide whether the system is reachable via Ansible.
348
- * See v2/CELILO_BASE.md D8.
348
+ * See openspec/specs/base-module-aspects/spec.md D8.
349
349
  */
350
350
  apiOnly: integer('api_only', { mode: 'boolean' }).notNull().default(false),
351
351
  createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
@@ -358,7 +358,7 @@ export const moduleInfrastructure = sqliteTable('module_infrastructure', {
358
358
  * deployment-STATE counterpart to module_configs (declared inputs): it records,
359
359
  * per deployed system, the hostname + IPv4 + zone + where it lives. Replaces the
360
360
  * old scalar `target_ip`/`vmid` rows in module_configs, which baked in the
361
- * one-module-one-host assumption. See v2/MODULE_SYSTEMS_ADDRESSING.md.
361
+ * one-module-one-host assumption. See openspec/specs/module-systems-addressing/spec.md.
362
362
  *
363
363
  * - 0 rows: API-only modules (e.g. namecheap — no host).
364
364
  * - 1 row: the common case (technitium, homebridge, …).
@@ -368,7 +368,7 @@ export const moduleInfrastructure = sqliteTable('module_infrastructure', {
368
368
  * the manifest's `requires.systems[].name` — what templates reference via
369
369
  * `$infra:<name>.…`. `hostname` is the runtime DNS hostname (often == name, but
370
370
  * user/well-known-assignable), used by DNS and events. See
371
- * v2/MODULE_SYSTEMS_ADDRESSING.md.
371
+ * openspec/specs/module-systems-addressing/spec.md.
372
372
  */
373
373
  export const moduleSystems = sqliteTable(
374
374
  'module_systems',
@@ -443,6 +443,49 @@ export const webRoutes = sqliteTable(
443
443
  }),
444
444
  );
445
445
 
446
+ /**
447
+ * Port-forward registry — the desired-state store for the `firewall` capability
448
+ * (openspec/changes/unified-management-no-ssh/proposal.md). One row per exposed forward. `exposeService`
449
+ * upserts, `unexposeService` deletes, and the firewall provider's converge
450
+ * renders the COMPLETE ruleset for a firewall from these rows and applies it
451
+ * atomically via `iptables-restore` — replacing the old per-rule `iptables -A`
452
+ * (non-idempotent) + the "read the box back with iptables -L" source of truth.
453
+ *
454
+ * Keyed by `firewall_ip` (the converge target) so multiple firewalls each render
455
+ * their own set. Shared-core (not a per-module JSON file) so any firewall
456
+ * provider reconciles against the one canonical store.
457
+ */
458
+ export const portForwards = sqliteTable(
459
+ 'port_forwards',
460
+ {
461
+ id: integer('id').primaryKey({ autoIncrement: true }),
462
+ /** The firewall host this forward is installed on (config.firewallIp). */
463
+ firewallIp: text('firewall_ip').notNull(),
464
+ /** Backend IP the forward targets. */
465
+ internalIp: text('internal_ip').notNull(),
466
+ /** Port — external == internal (no translation today). */
467
+ port: integer('port').notNull(),
468
+ protocol: text('protocol').$type<'TCP' | 'UDP'>().notNull(),
469
+ /** Dedicated INTERNAL ingress IP (ISS-0156); NULL for the normal public path. */
470
+ ingressIp: text('ingress_ip'),
471
+ description: text('description').notNull().default(''),
472
+ createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
473
+ },
474
+ (table) => ({
475
+ // One row per (firewall, backend, port, protocol, ingress). The store also
476
+ // delete-then-inserts on this tuple so a re-expose is an idempotent upsert
477
+ // (NULL ingress_ip is compared with `is null` there, since SQLite treats
478
+ // NULLs as distinct in a unique index).
479
+ forwardUnique: uniqueIndex('port_forwards_unique_idx').on(
480
+ table.firewallIp,
481
+ table.internalIp,
482
+ table.port,
483
+ table.protocol,
484
+ table.ingressIp,
485
+ ),
486
+ }),
487
+ );
488
+
446
489
  /**
447
490
  * DNS registration ledger — one row per (provider, fqdn) the framework
448
491
  * has successfully registered via dns_registrar.registerHost. Written
@@ -509,7 +552,7 @@ export const dnsInternalRecords = sqliteTable(
509
552
  /**
510
553
  * In-zone split-horizon answer (caddy's zone-routable IP), when this is a
511
554
  * caddy-fronted hostname that needs source-based views (ISS-0156,
512
- * v2/INTERNAL_DNS_ZONE_VIEWS.md). NULL for records with no zone override
555
+ * openspec/specs/internal-dns-zone-views/spec.md). NULL for records with no zone override
513
556
  * (per-system identity, plain A records). This column is the durable
514
557
  * desired-state the resolver's view config is reconciled from.
515
558
  */
@@ -621,7 +664,7 @@ export const moduleOperations = sqliteTable('module_operations', {
621
664
  * per module version. Cascade delete on module removal so stale
622
665
  * approvals don't linger.
623
666
  *
624
- * See v2/CELILO_BASE.md D2 + D7.
667
+ * See openspec/specs/base-module-aspects/spec.md D2 + D7.
625
668
  */
626
669
  export const aspectApprovals = sqliteTable(
627
670
  'aspect_approvals',
@@ -655,7 +698,7 @@ export const aspectApprovals = sqliteTable(
655
698
  );
656
699
 
657
700
  /**
658
- * Remote API principals (see v2/API_COMMUNICATION.md).
701
+ * Remote API principals (see openspec/changes/replace-ssh-cli-api/proposal.md).
659
702
  *
660
703
  * Each row is one API identity: a name, one SSH public key, and the set of
661
704
  * operations it may run. celilo renders these into the API account's
@@ -47,12 +47,13 @@ export default defineCapabilityFunction({
47
47
  `;
48
48
 
49
49
  // Mock iptables firewall factory — kept on the legacy
50
- // `createFirewall(config, upstreamFirewall)` shape because its
50
+ // `createFirewall(config, store, upstreamFirewall, logger)` shape because its
51
51
  // upstream-injection signature doesn't fit the
52
52
  // defineCapabilityFunction `{ config, secrets, logger }` context.
53
- // buildFirewallChain detects this and wires the upstream manually.
53
+ // buildFirewallChain injects the port-forward store (2nd arg) and wires the
54
+ // upstream (3rd arg) manually. The mock ignores store (no converge here).
54
55
  const MOCK_IPTABLES_MODULE = `
55
- export function createFirewall(config, upstreamFirewall) {
56
+ export function createFirewall(config, store, upstreamFirewall, logger) {
56
57
  return {
57
58
  exposeService: async (opts) => {
58
59
  if (!upstreamFirewall) {
@@ -21,18 +21,20 @@ import type {
21
21
  DnsInternalCapability,
22
22
  DnsRegistrarCapability,
23
23
  HookLogger,
24
+ PortForwardStore,
24
25
  RouteOps,
25
26
  RouteReadView,
26
27
  } from '@celilo/capabilities';
27
28
  import { and, eq } from 'drizzle-orm';
28
29
  import type { DbClient } from '../db/client';
29
- import { capabilities, modules, secrets, webRoutes } from '../db/schema';
30
+ import { capabilities, modules, secrets, systemConfig, webRoutes } from '../db/schema';
30
31
  import { decryptSecret } from '../secrets/encryption';
31
32
  import { getOrCreateMasterKey } from '../secrets/master-key';
32
33
  import { emitWebRoutesChangedAndWait } from '../services/celilo-events';
33
34
  import { getModuleSystems } from '../services/deployed-systems';
34
35
  import { withDnsInternalLedger } from '../services/dns-internal-records';
35
36
  import { withDnsRegistrationLedger } from '../services/dns-registrations';
37
+ import { buildPortForwardStore } from '../services/port-forwards';
36
38
  import { resolveComputedFields } from '../variables/computed/evaluate';
37
39
  import { containsComputedMarker } from '../variables/computed/marker';
38
40
  import { buildProviderLookup } from '../variables/computed/provider-lookup';
@@ -288,6 +290,10 @@ export async function loadCapabilityFunctions(
288
290
  exported,
289
291
  providerConfig,
290
292
  providerSecrets,
293
+ // Single firewall provider (no upstream chain) still needs the injected
294
+ // port-forward store — the chain path isn't taken when there's one provider.
295
+ capName === 'firewall' ? buildPortForwardStore(db) : undefined,
296
+ capName === 'firewall' ? loadFirewallZones(db) : undefined,
291
297
  );
292
298
 
293
299
  if (capabilityInterface) {
@@ -326,6 +332,17 @@ export async function loadCapabilityFunctions(
326
332
  debugLog(`public_web: using firewall natIp ${firewallNatIp} for internal DNS`);
327
333
  }
328
334
 
335
+ // Caddy's external (WAN) IP for the D1 public A record (M1 / #328). Caddy
336
+ // learns it from its own firewall.exposeService at install and persists it
337
+ // as the `public_ip` hook output (stored as a caddy secret). register_route
338
+ // points a new hostname's PUBLIC record here, so it's reachable from one
339
+ // deploy — no per-hostname DNAT (shared :443 ingress).
340
+ const caddyExternalIp =
341
+ typeof providerSecrets.public_ip === 'string' ? providerSecrets.public_ip : undefined;
342
+ if (caddyExternalIp) {
343
+ debugLog(`public_web: using caddy externalIp ${caddyExternalIp} for public DNS`);
344
+ }
345
+
329
346
  // Caddy's configured hostnames — public_web rejects routes for any
330
347
  // hostname not in this list, throwing a structured error that runs
331
348
  // caddy's `managed_hostname` ensure interview. Empty list means
@@ -437,6 +454,8 @@ export async function loadCapabilityFunctions(
437
454
  routeOps,
438
455
  dnsInternal: result.dns_internal as DnsInternalCapability | undefined,
439
456
  firewallNatIp,
457
+ dnsRegistrar: result.dns_registrar as DnsRegistrarCapability | undefined,
458
+ externalIp: caddyExternalIp,
440
459
  hostnames: caddyHostnames,
441
460
  caddyModuleId: provider.moduleId,
442
461
  dnsManagedDomains,
@@ -525,16 +544,28 @@ function buildCapabilityInterface(
525
544
  factory: (...args: unknown[]) => unknown,
526
545
  config: Record<string, unknown>,
527
546
  _secrets: Record<string, string>,
547
+ store?: PortForwardStore,
548
+ zones?: FirewallZones,
528
549
  ): unknown {
529
550
  if (capabilityName === 'firewall') {
530
- // iptables firewall factory: SSH access + NAT config (upstream is
531
- // injected by buildFirewallChain when this module is downstream of
532
- // another firewall provider).
551
+ // iptables firewall factory: NAT config + the injected port-forward store
552
+ // (createFirewall(config, store, upstream?, logger?)). Upstream is injected
553
+ // by buildFirewallChain when this module is downstream of another provider.
554
+ // The firewall is only built through buildFirewallChain, which always passes
555
+ // a store — guard so a mis-route surfaces loudly rather than as store=undefined.
533
556
  if (config.firewall_ip && config.nat_ip) {
534
- return factory({
535
- firewallIp: config.firewall_ip as string,
536
- natIp: config.nat_ip as string,
537
- });
557
+ if (!store) {
558
+ throw new Error('firewall capability requires an injected port-forward store');
559
+ }
560
+ return factory(
561
+ {
562
+ firewallIp: config.firewall_ip as string,
563
+ natIp: config.nat_ip as string,
564
+ zoneTiers: zones?.zoneTiers ?? [],
565
+ trustedSubnets: zones?.trustedSubnets ?? [],
566
+ },
567
+ store,
568
+ );
538
569
  }
539
570
  return null;
540
571
  }
@@ -624,6 +655,40 @@ async function resolveRegistrarDomainList(
624
655
  * on the legacy `createFirewall(config, upstream)` path because its
625
656
  * upstream-injection signature doesn't fit the new shape.
626
657
  */
658
+ /** Most-exposed → most-protected SEGMENTED tiers for the firewall default-DROP matrix. */
659
+ const ZONE_TIER_ORDER = ['dmz', 'app', 'secure'] as const;
660
+
661
+ interface FirewallZones {
662
+ /** Ordered segmented tiers (dmz→app→secure adjacency) for the data-plane matrix. */
663
+ zoneTiers: Array<{ name: string; subnet: string }>;
664
+ /** Trusted LAN subnets (internal) that reach every tier — celilo's control plane. */
665
+ trustedSubnets: string[];
666
+ }
667
+
668
+ function readZoneSubnet(db: DbClient, zone: string): string | undefined {
669
+ const row = db
670
+ .select()
671
+ .from(systemConfig)
672
+ .where(eq(systemConfig.key, `network.${zone}.subnet`))
673
+ .get();
674
+ return row?.value ?? undefined;
675
+ }
676
+
677
+ /**
678
+ * Read the firewall zone matrix inputs from system config (network.<zone>.subnet):
679
+ * the segmented tiers [dmz, app, secure] and the trusted internal LAN. Zones with
680
+ * no configured subnet are omitted — their traffic stays denied (fail-closed).
681
+ */
682
+ function loadFirewallZones(db: DbClient): FirewallZones {
683
+ const zoneTiers: Array<{ name: string; subnet: string }> = [];
684
+ for (const zone of ZONE_TIER_ORDER) {
685
+ const subnet = readZoneSubnet(db, zone);
686
+ if (subnet) zoneTiers.push({ name: zone, subnet });
687
+ }
688
+ const internal = readZoneSubnet(db, 'internal');
689
+ return { zoneTiers, trustedSubnets: internal ? [internal] : [] };
690
+ }
691
+
627
692
  async function buildFirewallChain(
628
693
  allProviders: Array<{
629
694
  id: number;
@@ -638,6 +703,13 @@ async function buildFirewallChain(
638
703
  logger: HookLogger,
639
704
  debugLog: (msg: string) => void,
640
705
  ): Promise<unknown> {
706
+ // The shared-core port-forward registry, injected into every firewall provider
707
+ // in the chain so exposeService/converge reconcile against the one canonical
708
+ // store (openspec/changes/unified-management-no-ssh/proposal.md).
709
+ const store = buildPortForwardStore(db);
710
+ // Zone matrix for the default-DROP posture — same set for every layer in the chain.
711
+ const zones = loadFirewallZones(db);
712
+
641
713
  // Find the provider with has_external (the leaf — has direct internet access)
642
714
  const hasExternal = allProviders.find((p) => {
643
715
  const data = (typeof p.data === 'string' ? JSON.parse(p.data) : p.data) as Record<
@@ -686,7 +758,14 @@ async function buildFirewallChain(
686
758
  );
687
759
  } else {
688
760
  // Legacy factory path
689
- leafFirewall = buildCapabilityInterface('firewall', leafExported, leafConfig, leafSecrets);
761
+ leafFirewall = buildCapabilityInterface(
762
+ 'firewall',
763
+ leafExported,
764
+ leafConfig,
765
+ leafSecrets,
766
+ store,
767
+ zones,
768
+ );
690
769
  if (leafFirewall) {
691
770
  leafFirewall = wrapWithLogging(leafFirewall as object, logger, 'firewall');
692
771
  }
@@ -741,7 +820,18 @@ async function buildFirewallChain(
741
820
  // celilo's ProgressDisplay instead of dumping to stderr. Older
742
821
  // iptables modules ignore it — the factory's signature is
743
822
  // backward-compatible.
744
- const downstreamFirewall = provFactory({ firewallIp, natIp, dryRun }, currentUpstream, logger);
823
+ const downstreamFirewall = provFactory(
824
+ {
825
+ firewallIp,
826
+ natIp,
827
+ dryRun,
828
+ zoneTiers: zones.zoneTiers,
829
+ trustedSubnets: zones.trustedSubnets,
830
+ },
831
+ store,
832
+ currentUpstream,
833
+ logger,
834
+ );
745
835
 
746
836
  debugLog(`firewall chain: wired ${provider.moduleId} → ${hasExternal.moduleId}`);
747
837
  // Wrap each downstream layer with auto-logging.
@@ -95,6 +95,12 @@ const fakeIdp: IdpCapability = {
95
95
  async create_token() {
96
96
  return { token: 'fake-token', created: true };
97
97
  },
98
+ async list_tokens() {
99
+ return [];
100
+ },
101
+ async revoke_token() {
102
+ return { revoked: true };
103
+ },
98
104
  };
99
105
 
100
106
  describe('defineHook', () => {
@@ -260,6 +266,12 @@ describe('defineCapabilityFunction', () => {
260
266
  async create_token() {
261
267
  return { token: 'fake-token', created: true };
262
268
  },
269
+ async list_tokens() {
270
+ return [];
271
+ },
272
+ async revoke_token() {
273
+ return { revoked: true };
274
+ },
263
275
  }),
264
276
  });
265
277
 
@@ -323,6 +335,12 @@ describe('defineCapabilityFunction', () => {
323
335
  async create_token(): Promise<{ token: string; created: boolean }> {
324
336
  return { token: 'tok', created: true };
325
337
  },
338
+ async list_tokens() {
339
+ return [];
340
+ },
341
+ async revoke_token(): Promise<{ revoked: boolean }> {
342
+ return { revoked: true };
343
+ },
326
344
  };
327
345
  },
328
346
  });
@@ -459,6 +477,12 @@ void defineCapabilityFunction({
459
477
  async create_token() {
460
478
  return { token: 'x', created: true };
461
479
  },
480
+ async list_tokens() {
481
+ return [];
482
+ },
483
+ async revoke_token() {
484
+ return { revoked: true };
485
+ },
462
486
  }),
463
487
  });
464
488
 
@@ -469,7 +469,7 @@ describe('Hook Executor', () => {
469
469
  // cross_module_read is a privilege granted by the framework to the
470
470
  // hooks that use it (backup/restore), not a provider-loaded
471
471
  // capability. It must not block other hooks like on_install
472
- // (regression: celilo-mgmt deploy, v2/NETWORK_CONFIG_TO_FIREWALL.md).
472
+ // (regression: celilo-mgmt deploy, openspec/specs/progressive-zone-disclosure/spec.md).
473
473
  expect(checkRequiredCapabilities('on_install', ['cross_module_read'], {})).toBeNull();
474
474
  // Real missing providers still flagged alongside a privilege.
475
475
  const mixed = checkRequiredCapabilities('on_install', ['cross_module_read', 'idp'], {});
@@ -202,7 +202,7 @@ async function runScript(
202
202
 
203
203
  if (!isCompiledHook(module.default)) {
204
204
  throw new Error(
205
- `Hook script ${scriptPath} does not use defineHook(). As of HOOK_API_V2 Phase 8, all hook scripts must wrap their handler with defineHook from @celilo/capabilities so the executor can verify the brand and apply pre-flight checks. See design/MODULE_DEVELOPMENT_GUIDE.md "Hooks" section for the migration pattern.`,
205
+ `Hook script ${scriptPath} does not use defineHook(). As of HOOK_API_V2 Phase 8, all hook scripts must wrap their handler with defineHook from @celilo/capabilities so the executor can verify the brand and apply pre-flight checks. See reference/MODULE_DEVELOPMENT_GUIDE.md "Hooks" section for the migration pattern.`,
206
206
  );
207
207
  }
208
208
 
@@ -263,7 +263,7 @@ export interface InvokeHookOptions {
263
263
  capabilities?: Record<string, unknown>;
264
264
  /**
265
265
  * The module's 0..N deployed systems, injected as `ctx.systems`
266
- * (v2/MODULE_SYSTEMS_ADDRESSING.md). Callers load these from the DB via
266
+ * (openspec/specs/module-systems-addressing/spec.md). Callers load these from the DB via
267
267
  * `getModuleSystems` — the executor stays decoupled from the database, the
268
268
  * same way `capabilities` is loaded by the caller. Defaults to `[]`.
269
269
  */
@@ -55,7 +55,7 @@ export interface HookContext {
55
55
  /** Module secret values (decrypted) */
56
56
  secrets: Record<string, string>;
57
57
  /**
58
- * The 0..N systems this module has deployed onto (v2/MODULE_SYSTEMS_ADDRESSING.md).
58
+ * The 0..N systems this module has deployed onto (openspec/specs/module-systems-addressing/spec.md).
59
59
  * Always an array — no singular convenience, so the 0/1/N reality stays visible.
60
60
  */
61
61
  systems: DeployedSystem[];
@@ -145,7 +145,7 @@ export const V1_HOOKS: ContractHooks = {
145
145
  },
146
146
  },
147
147
  /**
148
- * Per-system DNS lifecycle hook (v2/INTERNAL_DNS_DHCP_AND_SPLIT_HORIZON.md D5).
148
+ * Per-system DNS lifecycle hook (openspec/specs/internal-dns-split-horizon/spec.md D5).
149
149
  *
150
150
  * A dns_internal provider declares this hook; celilo's internal-dns bridge
151
151
  * invokes it once per host when a system.created/destroyed event fires,
@@ -198,7 +198,7 @@ export const V1_HOOKS: ContractHooks = {
198
198
  * Build-bus upstream publish hook. The executor passes the
199
199
  * PublishEvent fields as env vars (CELILO_EVENT_PAYLOAD,
200
200
  * CELILO_EVENT_PACKAGE_NAME, etc.) rather than as named inputs
201
- * here — see v2/BUILD_BUS.md Phase 4 + the hook-dispatch executor.
201
+ * here — see openspec/changes/build-bus-poll-cd/proposal.md Phase 4 + the hook-dispatch executor.
202
202
  * No structured outputs; hook reports success via exit code.
203
203
  */
204
204
  on_upstream_publish: {