@celilo/cli 0.11.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 (137) hide show
  1. package/AGENTS.md +7 -4
  2. package/CELILO_CORE_MODULES.md +7 -6
  3. package/CELILO_SUBSYSTEMS.md +52 -6
  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 +14 -6
  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/api/protocol.test.ts +1 -1
  13. package/src/api/remote-client.test.ts +1 -1
  14. package/src/api/serve.ts +5 -5
  15. package/src/capabilities/public-web-publish.test.ts +209 -0
  16. package/src/capabilities/validation.test.ts +1 -1
  17. package/src/capabilities/well-known.test.ts +1 -1
  18. package/src/capabilities/well-known.ts +2 -2
  19. package/src/cli/command-tree-parser.ts +3 -1
  20. package/src/cli/commands/api.ts +1 -1
  21. package/src/cli/commands/apt-upgrade.test.ts +33 -0
  22. package/src/cli/commands/apt-upgrade.ts +63 -0
  23. package/src/cli/commands/backup-pull.ts +1 -1
  24. package/src/cli/commands/commands-json.ts +29 -0
  25. package/src/cli/commands/completion.ts +1 -1
  26. package/src/cli/commands/events.ts +1 -1
  27. package/src/cli/commands/module-changeset.ts +1 -1
  28. package/src/cli/commands/module-import.ts +1 -1
  29. package/src/cli/commands/module-list.ts +16 -2
  30. package/src/cli/commands/module-publish.ts +1 -1
  31. package/src/cli/commands/module-remove.ts +26 -1
  32. package/src/cli/commands/module-types.ts +1 -1
  33. package/src/cli/commands/module-upgrade.ts +1 -1
  34. package/src/cli/commands/module-version.ts +1 -1
  35. package/src/cli/commands/module-where.test.ts +26 -0
  36. package/src/cli/commands/module-where.ts +130 -0
  37. package/src/cli/commands/proxmox-node-list.ts +1 -1
  38. package/src/cli/commands/publish/alpha.test.ts +1 -1
  39. package/src/cli/commands/publish/alpha.ts +3 -3
  40. package/src/cli/commands/publish/changesets.ts +1 -1
  41. package/src/cli/commands/publish/global-install.ts +6 -2
  42. package/src/cli/commands/publish/helpers.ts +21 -14
  43. package/src/cli/commands/publish/index.ts +4 -4
  44. package/src/cli/commands/publish/plan.ts +3 -3
  45. package/src/cli/commands/publish/preflight.ts +2 -2
  46. package/src/cli/commands/publish/types.ts +2 -2
  47. package/src/cli/commands/publish/workspace.test.ts +1 -1
  48. package/src/cli/commands/publish/workspace.ts +2 -2
  49. package/src/cli/commands/registry-owner.test.ts +166 -0
  50. package/src/cli/commands/registry-owner.ts +124 -0
  51. package/src/cli/commands/registry-token.test.ts +109 -0
  52. package/src/cli/commands/registry-token.ts +194 -0
  53. package/src/cli/commands/restore.ts +1 -1
  54. package/src/cli/commands/service-add-proxmox.ts +1 -1
  55. package/src/cli/commands/service-list.ts +15 -2
  56. package/src/cli/commands/subscribers-list.ts +1 -1
  57. package/src/cli/commands/system-apply-config-equivalence.test.ts +1 -1
  58. package/src/cli/commands/system-apply-config.ts +1 -1
  59. package/src/cli/commands/system-init-deprecation.test.ts +1 -1
  60. package/src/cli/commands/system-init.ts +2 -2
  61. package/src/cli/commands/token.test.ts +26 -0
  62. package/src/cli/commands/token.ts +160 -0
  63. package/src/cli/completion.ts +21 -0
  64. package/src/cli/generate-zsh-completion.test.ts +22 -4
  65. package/src/cli/generate-zsh-completion.ts +7 -3
  66. package/src/cli/index.ts +170 -7
  67. package/src/cli/parser.ts +1 -1
  68. package/src/cli/restore-command.test.ts +1 -1
  69. package/src/db/client.ts +1 -1
  70. package/src/db/schema.ts +50 -7
  71. package/src/hooks/capability-loader-firewall.test.ts +4 -3
  72. package/src/hooks/capability-loader.ts +100 -10
  73. package/src/hooks/define-hook.test.ts +24 -0
  74. package/src/hooks/executor.test.ts +1 -1
  75. package/src/hooks/executor.ts +2 -2
  76. package/src/hooks/types.ts +1 -1
  77. package/src/manifest/contracts/v1.ts +2 -2
  78. package/src/manifest/schema.ts +13 -13
  79. package/src/manifest/template-validator.ts +1 -1
  80. package/src/module/packaging/build.ts +19 -0
  81. package/src/module/packaging/workspace-deps.test.ts +94 -0
  82. package/src/module/packaging/workspace-deps.ts +185 -0
  83. package/src/module/versioning/changeset-version.ts +1 -1
  84. package/src/policy/no-hand-built-ssh.test.ts +90 -0
  85. package/src/registry/client.test.ts +86 -0
  86. package/src/registry/client.ts +67 -1
  87. package/src/services/api-access.ts +1 -1
  88. package/src/services/aspect-approvals.ts +1 -1
  89. package/src/services/aspect-runner.ts +3 -3
  90. package/src/services/aspect-template-resolver.test.ts +1 -1
  91. package/src/services/aspect-template-resolver.ts +1 -1
  92. package/src/services/build-bus/delivery-events.ts +1 -1
  93. package/src/services/build-bus/fan-out.ts +1 -1
  94. package/src/services/build-bus/hook-dispatch.ts +1 -1
  95. package/src/services/build-bus/receiver-server.ts +1 -1
  96. package/src/services/build-bus/status.test.ts +1 -1
  97. package/src/services/build-bus/status.ts +1 -1
  98. package/src/services/build-bus/subscriber-store.ts +1 -1
  99. package/src/services/bus-interview.ts +1 -1
  100. package/src/services/celilo-events.ts +1 -1
  101. package/src/services/celilo-mgmt-hooks.test.ts +1 -1
  102. package/src/services/cross-module-read.ts +1 -1
  103. package/src/services/deploy-posture.ts +1 -1
  104. package/src/services/deployed-systems.test.ts +1 -1
  105. package/src/services/deployed-systems.ts +4 -4
  106. package/src/services/dns-provider-backfill.ts +2 -2
  107. package/src/services/events-daemon.ts +1 -1
  108. package/src/services/machine-pool.ts +3 -3
  109. package/src/services/module-deploy.ts +93 -8
  110. package/src/services/module-subscriptions.ts +1 -1
  111. package/src/services/module-types-generator.ts +1 -1
  112. package/src/services/module-validator/git-hygiene.ts +1 -1
  113. package/src/services/port-forwards.test.ts +92 -0
  114. package/src/services/port-forwards.ts +86 -0
  115. package/src/services/proxmox-reconcile.ts +2 -2
  116. package/src/services/public-web-republish.test.ts +189 -0
  117. package/src/services/public-web-republish.ts +84 -0
  118. package/src/services/restore-from-file.ts +1 -1
  119. package/src/services/restore-preflight.ts +1 -1
  120. package/src/services/system-identity.ts +1 -1
  121. package/src/services/system-init.test.ts +2 -2
  122. package/src/services/system-init.ts +2 -2
  123. package/src/services/web-route-cleanup.test.ts +250 -0
  124. package/src/services/web-route-cleanup.ts +144 -0
  125. package/src/templates/generator.ts +2 -2
  126. package/src/types/infrastructure.ts +1 -1
  127. package/src/variables/computed/computed-integration.test.ts +1 -1
  128. package/src/variables/computed/evaluate.ts +1 -1
  129. package/src/variables/computed/parse.ts +1 -1
  130. package/src/variables/context.test.ts +1 -1
  131. package/src/variables/context.ts +5 -5
  132. package/src/variables/lxc-nameserver.test.ts +1 -1
  133. package/src/variables/resolver.ts +2 -2
  134. package/src/variables/types.ts +1 -1
  135. package/src/api/protocol.ts +0 -159
  136. package/src/api/remote-client.ts +0 -218
  137. package/src/cli/command-registry.ts +0 -1488
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: {
@@ -214,7 +214,7 @@ export const EnsureSchema = z.object({
214
214
  export type Ensure = z.infer<typeof EnsureSchema>;
215
215
 
216
216
  /**
217
- * A computed capability field (v2/INTERNAL_DNS_DHCP_AND_SPLIT_HORIZON.md D1).
217
+ * A computed capability field (openspec/specs/internal-dns-split-horizon/spec.md D1).
218
218
  *
219
219
  * Declared alongside `data`, but derived ON ACCESS from other values via the
220
220
  * `value:` DSL (see src/variables/computed/) and never persisted. The result
@@ -275,7 +275,7 @@ export const LifecycleHookSchema = z.object({
275
275
  /**
276
276
  * Build-bus upstream-publish hook. Fires when a publish event lands
277
277
  * on the local event bus from the receiver daemon
278
- * ([[v2/BUILD_BUS.md]] Phase 4). Each module can declare multiple
278
+ * ([[openspec/changes/build-bus-poll-cd/proposal.md]] Phase 4). Each module can declare multiple
279
279
  * entries, each with its own match rule + action script.
280
280
  *
281
281
  * The action script runs with `CELILO_EVENT_PAYLOAD` in its env (the
@@ -326,7 +326,7 @@ export const SystemResourceSchema = z.object({
326
326
  });
327
327
 
328
328
  /**
329
- * One system a module deploys (v2/MODULE_SYSTEMS_ADDRESSING.md). A module
329
+ * One system a module deploys (openspec/specs/module-systems-addressing/spec.md). A module
330
330
  * declares 0..N of these under `requires.systems`. `name` is the stable
331
331
  * authoring-time handle — referenced in templates via `$infra:<name>.…` and the
332
332
  * per-system key in `module_systems`. `resources` carries the per-system machine
@@ -390,7 +390,7 @@ export const ModuleSubscriptionSchema = z
390
390
  handler: z.string().min(1).optional(),
391
391
  /**
392
392
  * Name of one of THIS module's own hooks to invoke when a matching event
393
- * fires (v2/EVENT_DRIVEN_HOOK_SUBSCRIPTIONS.md). The framework synthesizes
393
+ * fires (openspec/specs/event-driven-hook-subscriptions/spec.md). The framework synthesizes
394
394
  * a `celilo events run-hook` handler that runs the hook in a
395
395
  * fault-isolated subprocess with backend access (DB, capabilities,
396
396
  * secrets). Mutually exclusive with `handler`.
@@ -445,7 +445,7 @@ export const ModuleManifestSchema = z
445
445
 
446
446
  /**
447
447
  * How `manifest.yml#version` (the PAYLOAD version) is determined — see
448
- * v2/MODULE_VERSIONING.md / ISS-0151. The capability *contract* version
448
+ * openspec/changes/module-version-semantics/proposal.md / ISS-0151. The capability *contract* version
449
449
  * lives in `provides.capabilities[].version` and is unrelated to this.
450
450
  *
451
451
  * - `changeset` — first-party apps/content (lunacycle): the version is
@@ -471,7 +471,7 @@ export const ModuleManifestSchema = z
471
471
  .object({
472
472
  capabilities: z.array(CapabilityRequirementSchema).default([]),
473
473
  /**
474
- * The 0..N systems this module deploys (v2/MODULE_SYSTEMS_ADDRESSING.md).
474
+ * The 0..N systems this module deploys (openspec/specs/module-systems-addressing/spec.md).
475
475
  * Preferred over the singular `system`. A module declaring
476
476
  * `systems` gets one host per entry, each addressable as `$infra:<name>`.
477
477
  */
@@ -545,7 +545,7 @@ export const ModuleManifestSchema = z
545
545
  * to (de)register a single host's A records when celilo's bridge
546
546
  * delivers a system.created/destroyed event. Inputs (hostname,
547
547
  * target_ip, op) come from the contract — see contracts/v1.ts and
548
- * [[v2/INTERNAL_DNS_DHCP_AND_SPLIT_HORIZON.md]] D5.
548
+ * [[openspec/specs/internal-dns-split-horizon/spec.md]] D5.
549
549
  */
550
550
  on_system_event: LifecycleHookSchema.optional(),
551
551
  /**
@@ -553,7 +553,7 @@ export const ModuleManifestSchema = z
553
553
  * change event. The caddy `public_web` provider declares this to
554
554
  * re-render its Caddyfile from web_routes when a consumer registers or
555
555
  * unregisters a route (ISS-0035). See
556
- * [[v2/PUBLIC_WEB_PROVIDER_RECONCILE.md]].
556
+ * [[openspec/specs/public-web-provider-reconcile/spec.md]].
557
557
  */
558
558
  reconcile_routes: LifecycleHookSchema.optional(),
559
559
  /**
@@ -569,7 +569,7 @@ export const ModuleManifestSchema = z
569
569
  /**
570
570
  * Build-bus upstream publish hooks. Array (a module can react
571
571
  * to multiple upstream packages with different actions). See
572
- * [[v2/BUILD_BUS.md]] Phase 4.
572
+ * [[openspec/changes/build-bus-poll-cd/proposal.md]] Phase 4.
573
573
  */
574
574
  on_upstream_publish: z.array(UpstreamPublishHookSchema).optional(),
575
575
  })
@@ -645,7 +645,7 @@ export const ModuleManifestSchema = z
645
645
 
646
646
  /**
647
647
  * Event-bus subscriptions this module wants registered. See
648
- * `infra/design/SQLITE_EVENT_BUS.md`. Truly optional so existing
648
+ * `infra/openspec/specs/event-bus/spec.md`. Truly optional so existing
649
649
  * test fixtures and manifests don't need a churn-pass; readers
650
650
  * use `manifest.subscriptions ?? []`.
651
651
  */
@@ -654,7 +654,7 @@ export const ModuleManifestSchema = z
654
654
  /**
655
655
  * Optional base-module aspect — lightweight fleet-configuration
656
656
  * code applied to OTHER systems in declared zones, on top of
657
- * the module's primary deployment. See v2/CELILO_BASE.md.
657
+ * the module's primary deployment. See openspec/specs/base-module-aspects/spec.md.
658
658
  *
659
659
  * Modules without this block work exactly as today. When
660
660
  * declared, the operator approves the aspect's scope at
@@ -723,7 +723,7 @@ export const ModuleManifestSchema = z
723
723
  ansible_vars: z.record(z.string(), z.string()).optional(),
724
724
 
725
725
  /**
726
- * Optional Proxmox reconciliation (v2/CELILO_BASE.md D5).
726
+ * Optional Proxmox reconciliation (openspec/specs/base-module-aspects/spec.md D5).
727
727
  *
728
728
  * When an aspect manages a setting that Proxmox also owns
729
729
  * authoritatively (DNS resolver via `proxmox_lxc.nameserver`
@@ -779,7 +779,7 @@ export type BaseModuleAspect = NonNullable<ModuleManifest['base_module_aspect']>
779
779
  export type BaseModuleAspectTrigger = BaseModuleAspect['triggers'][number];
780
780
 
781
781
  /**
782
- * Normalize a manifest's declared systems (v2/MODULE_SYSTEMS_ADDRESSING.md).
782
+ * Normalize a manifest's declared systems (openspec/specs/module-systems-addressing/spec.md).
783
783
  * Returns `requires.systems` if present; else the singular `requires.system`
784
784
  * as one entry named `main`; else `[]` (config-only module like namecheap).
785
785
  * This is the single place the system→systems sugar lives, so the rest of the
@@ -72,7 +72,7 @@ const AUTO_ALLOCATED_VARIABLES = new Set([
72
72
  'vlan', // Auto-derived from zone configuration
73
73
  'gateway', // Auto-derived from zone configuration
74
74
  'target_node', // Can be auto-derived from system config
75
- 'lxc_nameserver', // Composed at generate time from dns_internal + dns.primary (v2/LXC_INTERNAL_DNS.md)
75
+ 'lxc_nameserver', // Composed at generate time from dns_internal + dns.primary (openspec/specs/lxc-dns-at-birth/spec.md)
76
76
  // Instance sizing (ISS-0150): the instance Terraform reads $self:{cores,memory,
77
77
  // disk,storage}, which are injected during resolution from the module_systems
78
78
  // table (falling back to requires.system.*) — see variables/context.ts. Like
@@ -10,6 +10,7 @@ import { validateModuleDirectory } from '../import';
10
10
  import { computeFileChecksum } from './checksum';
11
11
  import { includeNodeModulesPath } from './package-rules';
12
12
  import { signChecksums } from './signature';
13
+ import { rewriteWorkspaceDeps } from './workspace-deps';
13
14
 
14
15
  /**
15
16
  * Checksums data structure
@@ -222,6 +223,24 @@ export async function buildModule(options: ModuleBuildOptions): Promise<ModuleBu
222
223
  const manifest = parseYaml(manifestContent);
223
224
  if (manifest.build?.command || manifest.build?.script) {
224
225
  log.info(`Building module (${manifest.build.command ? 'command' : 'script'})...`);
226
+
227
+ // Resolve `workspace:` build deps (ISS-0147 / celilo#216). The staged
228
+ // buildDir has no workspace root, so a sibling monorepo package pulled in
229
+ // via `workspace:^` won't resolve during the build's `bun install`.
230
+ // Rewrite those specs to `file:` paths pointing at the live monorepo
231
+ // members — always-fresh, no npm-pin lag, no hand-rolled
232
+ // CELILO_MODULE_SOURCE_DIR dance. Fails loudly naming any unresolvable dep.
233
+ try {
234
+ for (const rewrite of rewriteWorkspaceDeps(buildDir, sourceDir)) {
235
+ log.info(`Resolved workspace dep: ${rewrite}`);
236
+ }
237
+ } catch (rewriteError) {
238
+ return {
239
+ success: false,
240
+ error: rewriteError instanceof Error ? rewriteError.message : String(rewriteError),
241
+ };
242
+ }
243
+
225
244
  // The build runs in a staged copy of the module (buildDir), so
226
245
  // relative paths that reach outside the module (e.g. sibling packages
227
246
  // in a monorepo) don't resolve. Expose the ORIGINAL unstaged source