@celilo/cli 2.1.0 → 2.2.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 (179) hide show
  1. package/drizzle/0031_module_config_source.sql +20 -0
  2. package/drizzle/meta/_journal.json +8 -1
  3. package/package.json +3 -3
  4. package/schemas/system_config.json +7 -1
  5. package/src/ansible/inventory.test.ts +2 -1
  6. package/src/api/sessions.test.ts +2 -1
  7. package/src/capabilities/public-web-publish.test.ts +61 -0
  8. package/src/cli/backup-rename.test.ts +2 -1
  9. package/src/cli/cli.test.ts +2 -1
  10. package/src/cli/commands/console-get-chain.test.ts +2 -1
  11. package/src/cli/commands/firewall-interface-list.test.ts +158 -8
  12. package/src/cli/commands/firewall-interface-list.ts +73 -7
  13. package/src/cli/commands/machine-add.ts +12 -55
  14. package/src/cli/commands/module-config.test.ts +22 -2
  15. package/src/cli/commands/module-deploy.ts +8 -2
  16. package/src/cli/commands/module-generate.test.ts +53 -0
  17. package/src/cli/commands/module-generate.ts +31 -26
  18. package/src/cli/commands/module-import-aspect.test.ts +2 -1
  19. package/src/cli/commands/module-import-registry.test.ts +2 -1
  20. package/src/cli/commands/module-import.ts +1 -1
  21. package/src/cli/commands/module-operations.test.ts +2 -1
  22. package/src/cli/commands/module-publish.test.ts +5 -12
  23. package/src/cli/commands/module-update.test.ts +87 -4
  24. package/src/cli/commands/module-update.ts +14 -4
  25. package/src/cli/commands/module-upgrade.test.ts +15 -0
  26. package/src/cli/commands/module-upgrade.ts +54 -2
  27. package/src/cli/commands/module-verify.test.ts +2 -3
  28. package/src/cli/commands/module-verify.ts +0 -1
  29. package/src/cli/commands/monitor.ts +2 -10
  30. package/src/cli/commands/notify-config.test.ts +5 -3
  31. package/src/cli/commands/registry-owner.test.ts +2 -1
  32. package/src/cli/commands/registry-token.test.ts +2 -1
  33. package/src/cli/commands/restore.ts +16 -6
  34. package/src/cli/commands/system-apply-config-equivalence.test.ts +5 -7
  35. package/src/cli/commands/system-config.test.ts +148 -0
  36. package/src/cli/commands/system-config.ts +26 -1
  37. package/src/cli/commands/system-doctor.test.ts +71 -0
  38. package/src/cli/commands/system-doctor.ts +110 -24
  39. package/src/cli/commands/system-init-deprecation.test.ts +6 -3
  40. package/src/cli/commands/system-migrate.test.ts +2 -1
  41. package/src/cli/generate-zsh-completion.ts +1 -1
  42. package/src/cli/index.ts +6 -4
  43. package/src/cli/restore-command.test.ts +2 -1
  44. package/src/cli/restore-migration-failure.test.ts +160 -0
  45. package/src/config/paths.test.ts +3 -3
  46. package/src/db/client.ts +5 -0
  47. package/src/db/migrate.test.ts +62 -135
  48. package/src/db/migrate.ts +17 -12
  49. package/src/db/schema.ts +10 -0
  50. package/src/hooks/broker.test.ts +106 -2
  51. package/src/hooks/broker.ts +91 -1
  52. package/src/hooks/capability-loader-firewall.test.ts +37 -0
  53. package/src/hooks/capability-loader.test.ts +78 -0
  54. package/src/hooks/capability-loader.ts +52 -3
  55. package/src/hooks/define-hook.test.ts +4 -3
  56. package/src/hooks/executor.test.ts +106 -19
  57. package/src/hooks/executor.ts +120 -13
  58. package/src/hooks/hook-jail-toolchain-reach.test.ts +3 -2
  59. package/src/hooks/hook-jail-unreachability.test.ts +4 -3
  60. package/src/hooks/hook-protocol.ts +46 -1
  61. package/src/hooks/hook-runner.ts +36 -0
  62. package/src/hooks/hook-store-proxy.test.ts +109 -0
  63. package/src/hooks/hook-store-proxy.ts +85 -0
  64. package/src/hooks/hook-store.test.ts +168 -0
  65. package/src/hooks/hook-store.ts +290 -0
  66. package/src/hooks/hook-timeout.test.ts +3 -2
  67. package/src/hooks/hook-trespass.test.ts +39 -5
  68. package/src/hooks/jail.test.ts +62 -3
  69. package/src/hooks/jail.ts +67 -8
  70. package/src/hooks/mount-set.test.ts +208 -0
  71. package/src/hooks/mount-set.ts +62 -14
  72. package/src/hooks/run-named-hook.ts +2 -0
  73. package/src/hooks/test-fixtures/jail-probe-hook.ts +1 -1
  74. package/src/hooks/test-fixtures/on-restore-staging-hook.ts +26 -0
  75. package/src/hooks/test-fixtures/store-backed.ts +47 -0
  76. package/src/hooks/test-fixtures/store-writing-hook.ts +63 -0
  77. package/src/hooks/unjailed-lint.test.ts +22 -6
  78. package/src/manifest/schema.ts +1 -0
  79. package/src/module/packaging/audit.ts +9 -26
  80. package/src/module/packaging/build-paths.test.ts +127 -0
  81. package/src/module/packaging/build-paths.ts +175 -0
  82. package/src/module/packaging/build.test.ts +71 -1
  83. package/src/module/packaging/build.ts +130 -2
  84. package/src/module/packaging/extract.ts +1 -5
  85. package/src/module/web-root.ts +17 -1
  86. package/src/policy/fixture-capability-coverage.test.ts +322 -0
  87. package/src/policy/module-script-scan.test.ts +42 -1
  88. package/src/policy/module-script-scan.ts +235 -5
  89. package/src/policy/no-hand-built-ssh.test.ts +34 -1
  90. package/src/policy/no-swallowed-refusal.test.ts +264 -0
  91. package/src/policy/no-tar-shell-out-in-services.test.ts +43 -0
  92. package/src/registry/client.test.ts +149 -0
  93. package/src/registry/client.ts +203 -11
  94. package/src/services/alerting/ack.test.ts +2 -1
  95. package/src/services/alerting/cadence-migration.test.ts +3 -2
  96. package/src/services/alerting/coverage-source.test.ts +2 -1
  97. package/src/services/alerting/deferral.test.ts +2 -1
  98. package/src/services/alerting/delivery-loop.test.ts +2 -1
  99. package/src/services/alerting/deploy-hooks.test.ts +2 -1
  100. package/src/services/alerting/format.test.ts +57 -0
  101. package/src/services/alerting/format.ts +24 -0
  102. package/src/services/alerting/inbound-poller.test.ts +2 -1
  103. package/src/services/alerting/inbound.test.ts +2 -1
  104. package/src/services/alerting/notification-responder.test.ts +2 -1
  105. package/src/services/alerting/run-monitor.test.ts +2 -1
  106. package/src/services/alerting/run-monitor.ts +2 -2
  107. package/src/services/alerting/store.test.ts +2 -1
  108. package/src/services/alerting/sweep-runner.test.ts +2 -1
  109. package/src/services/alerting/tokens.test.ts +2 -1
  110. package/src/services/aspect-approvals.test.ts +2 -1
  111. package/src/services/aspect-reconcile.test.ts +4 -3
  112. package/src/services/aspect-runner.test.ts +2 -1
  113. package/src/services/audit/module-integrity.test.ts +0 -21
  114. package/src/services/audit/module-integrity.ts +0 -14
  115. package/src/services/backup-age-agreement.test.ts +2 -1
  116. package/src/services/backup-create.ts +7 -7
  117. package/src/services/backup-envelope-roundtrip.test.ts +47 -3
  118. package/src/services/backup-in-flight-refusal.test.ts +2 -1
  119. package/src/services/backup-restore.ts +8 -4
  120. package/src/services/bus-ensure-flow.test.ts +2 -1
  121. package/src/services/bus-interview-park.test.ts +2 -1
  122. package/src/services/bus-interview.ts +37 -14
  123. package/src/services/bus-secret-flow.test.ts +2 -1
  124. package/src/services/capability-table-rows.test.ts +2 -1
  125. package/src/services/config-provenance.ts +4 -0
  126. package/src/services/consumer-cleanup.test.ts +3 -2
  127. package/src/services/container-service.test.ts +2 -1
  128. package/src/services/control-plane-bootstrap.test.ts +123 -2
  129. package/src/services/control-plane-bootstrap.ts +51 -4
  130. package/src/services/cross-module-read.test.ts +2 -1
  131. package/src/services/deploy-preflight.ts +8 -2
  132. package/src/services/deploy-validation.test.ts +25 -2
  133. package/src/services/deploy-validation.ts +8 -0
  134. package/src/services/dns-discovery.test.ts +54 -0
  135. package/src/services/dns-discovery.ts +47 -5
  136. package/src/services/dns-internal-records.test.ts +3 -2
  137. package/src/services/dns-provider-backfill.test.ts +2 -1
  138. package/src/services/dns-registrations.test.ts +2 -1
  139. package/src/services/ensure-interview.test.ts +3 -2
  140. package/src/services/fleet-checks.test.ts +3 -2
  141. package/src/services/fleet-key.test.ts +68 -3
  142. package/src/services/fleet-key.ts +54 -0
  143. package/src/services/health-runner.ts +2 -0
  144. package/src/services/infrastructure-selector.test.ts +2 -1
  145. package/src/services/infrastructure-variable-resolver.test.ts +2 -1
  146. package/src/services/machine-pool.test.ts +2 -1
  147. package/src/services/module-config.test.ts +2 -1
  148. package/src/services/module-config.ts +20 -2
  149. package/src/services/module-deploy.dns-repoint.test.ts +188 -0
  150. package/src/services/module-deploy.ts +199 -21
  151. package/src/services/module-operations.test.ts +2 -1
  152. package/src/services/module-subscriptions.test.ts +2 -1
  153. package/src/services/module-validator/git-hygiene.test.ts +122 -3
  154. package/src/services/module-validator/git-hygiene.ts +83 -14
  155. package/src/services/port-forwards.test.ts +2 -1
  156. package/src/services/programmatic-responder.aspect.test.ts +2 -1
  157. package/src/services/proxmox-reconcile.test.ts +2 -1
  158. package/src/services/restore-from-file.test.ts +23 -2
  159. package/src/services/restore-from-file.ts +21 -6
  160. package/src/services/restore-preflight.test.ts +2 -1
  161. package/src/services/secret-schema-loader.test.ts +2 -1
  162. package/src/services/ssh-key-manager.test.ts +2 -1
  163. package/src/services/static-content-converge.test.ts +144 -5
  164. package/src/services/static-content-converge.ts +87 -17
  165. package/src/services/system-config-schema-types.ts +1 -1
  166. package/src/services/system-config-validator.test.ts +36 -0
  167. package/src/services/system-config-validator.ts +11 -0
  168. package/src/services/system-state-stage.test.ts +2 -1
  169. package/src/services/trusted-sources.test.ts +33 -2
  170. package/src/services/trusted-sources.ts +47 -10
  171. package/src/services/zone-detector.test.ts +2 -1
  172. package/src/templates/generator.ts +9 -2
  173. package/src/test-utils/bus-responder.ts +5 -3
  174. package/src/test-utils/db-path.ts +25 -0
  175. package/src/test-utils/integration.ts +7 -0
  176. package/src/test-utils/module-fixtures.ts +5 -6
  177. package/src/variables/context.ts +16 -5
  178. package/src/module/packaging/generated-plane.test.ts +0 -79
  179. package/src/module/packaging/generated-plane.ts +0 -134
@@ -18,7 +18,9 @@ import { join } from 'node:path';
18
18
  import { MissingProviderInputError } from '@celilo/capabilities';
19
19
  import { capabilityShape } from './broker';
20
20
  import { executeHookScript } from './executor';
21
+ import type { HookStoreBackend, HookStores } from './hook-store';
21
22
  import { createCapturingLogger } from './logger';
23
+ import { configStore, secretStore } from './test-fixtures/store-backed';
22
24
  import type { HookContext } from './types';
23
25
 
24
26
  const FIXTURES = join(__dirname, 'test-fixtures');
@@ -49,8 +51,8 @@ async function runCapabilityHook(): Promise<Record<string, unknown>> {
49
51
  const dir = mkdtempSync(join(tmpdir(), 'celilo-broker-'));
50
52
  try {
51
53
  const context: HookContext = {
52
- config: {},
53
- secrets: {},
54
+ config: configStore(),
55
+ secrets: secretStore(),
54
56
  systems: [],
55
57
  logger: createCapturingLogger().logger,
56
58
  debug: false,
@@ -67,6 +69,84 @@ async function runCapabilityHook(): Promise<Record<string, unknown>> {
67
69
  }
68
70
  }
69
71
 
72
+ /**
73
+ * In-memory stand-ins for the broker-side stores. The storage mechanics have
74
+ * their own suite (`hook-store.test.ts`, real DB); what this layer must prove
75
+ * is the ROUND TRIP — a write in the hook's process landing in the broker's
76
+ * store and the read coming back.
77
+ */
78
+ function memoryStores(): HookStores {
79
+ const makeBackend = (
80
+ kind: 'secret' | 'hook-owned config',
81
+ declared: string[],
82
+ ): HookStoreBackend => {
83
+ const rows = new Map<string, string>();
84
+ const assertDeclared = (name: string) => {
85
+ if (!declared.includes(name)) {
86
+ throw new Error(
87
+ `Module 'test-module' has no declared ${kind} '${name}'. Declared ${kind} names: ${declared.join(', ')}.`,
88
+ );
89
+ }
90
+ };
91
+ return {
92
+ get: (name) => {
93
+ assertDeclared(name);
94
+ return Promise.resolve(rows.get(name));
95
+ },
96
+ set: (name, value) => {
97
+ assertDeclared(name);
98
+ rows.set(name, value);
99
+ return Promise.resolve();
100
+ },
101
+ delete: (name) => {
102
+ assertDeclared(name);
103
+ rows.delete(name);
104
+ return Promise.resolve();
105
+ },
106
+ applyTransaction: (ops) => {
107
+ for (const entry of ops) {
108
+ assertDeclared(entry.name);
109
+ if (entry.op === 'set') rows.set(entry.name, entry.value ?? '');
110
+ else rows.delete(entry.name);
111
+ }
112
+ return Promise.resolve();
113
+ },
114
+ };
115
+ };
116
+ return {
117
+ secrets: makeBackend('secret', ['bot_token', 'api_key']),
118
+ config: makeBackend('hook-owned config', ['public_ip']),
119
+ declaredSecretNames: ['bot_token', 'api_key'],
120
+ declaredConfigNames: ['public_ip'],
121
+ };
122
+ }
123
+
124
+ async function runStoreHook(
125
+ contextData: Record<string, unknown>,
126
+ ): Promise<Record<string, unknown>> {
127
+ const dir = mkdtempSync(join(tmpdir(), 'celilo-broker-store-'));
128
+ try {
129
+ const context = {
130
+ config: { mapOnlyValue: 'from-the-context-frame' },
131
+ secrets: {},
132
+ systems: [],
133
+ logger: createCapturingLogger().logger,
134
+ debug: false,
135
+ screenshotDir: dir,
136
+ stateDir: dir,
137
+ capabilities: {},
138
+ ...contextData,
139
+ } as unknown as HookContext;
140
+ return await executeHookScript(join(FIXTURES, 'store-writing-hook.ts'), context, {
141
+ timeoutMs: 30_000,
142
+ idleTimeoutMs: 30_000,
143
+ hookStores: () => Promise.resolve(memoryStores()),
144
+ });
145
+ } finally {
146
+ rmSync(dir, { recursive: true, force: true });
147
+ }
148
+ }
149
+
70
150
  describe('capabilityShape', () => {
71
151
  test('splits functions from data', () => {
72
152
  const shape = capabilityShape(demoCapabilities());
@@ -149,3 +229,27 @@ describe('capability calls across the boundary', () => {
149
229
  expect(orphans).toEqual([]);
150
230
  }, 30_000);
151
231
  });
232
+
233
+ describe('hook-owned-state store calls across the boundary', () => {
234
+ test('every store operation survives the round trip', async () => {
235
+ const outputs = await runStoreHook({});
236
+
237
+ expect(outputs.secretRoundTrip).toBe('token-value');
238
+ expect(outputs.configRoundTrip).toBe('203.0.113.7');
239
+ // The old map surface still answers beside the new methods.
240
+ expect(outputs.mapRead).toBe('from-the-context-frame');
241
+ expect(outputs.deletedIsGone).toBeUndefined();
242
+
243
+ // D4: plain sets land immediately; a transaction discards on throw.
244
+ expect(outputs.transactionA).toBe('committed-a');
245
+ expect(outputs.transactionB).toBe('committed-b');
246
+ expect(outputs.discarded).toBe('hook failed midway');
247
+ expect(outputs.afterDiscard).toBe('committed-a');
248
+
249
+ // D2: the undeclared name is an ERROR naming the module and the set. The
250
+ // message text itself is pinned by hook-store.test.ts; here it only has
251
+ // to have survived the wire intact.
252
+ expect(outputs.undeclaredSecret).toContain('not_declared');
253
+ expect(outputs.undeclaredSecret).toContain('bot_token');
254
+ }, 30_000);
255
+ });
@@ -23,7 +23,10 @@ import { type Server, type Socket, createServer } from 'node:net';
23
23
  import { tmpdir } from 'node:os';
24
24
  import { join } from 'node:path';
25
25
  import {
26
+ type BufferedStoreOp,
26
27
  type CapabilityShape,
28
+ type ChildFrame,
29
+ HOOK_PROTOCOL_VERSION,
27
30
  type HookError,
28
31
  createLineReader,
29
32
  encodeFrame,
@@ -31,8 +34,22 @@ import {
31
34
  serializeError,
32
35
  versionMismatch,
33
36
  } from './hook-protocol';
37
+ import type { HookStores } from './hook-store';
34
38
  import type { HookLogger } from './types';
35
39
 
40
+ /** The answering half's view of one store frame. */
41
+ type StoreCallFrame = Extract<ChildFrame, { type: 'store' }>;
42
+
43
+ /**
44
+ * A lazy provider of the module's two hook state stores.
45
+ *
46
+ * Lazy because building the stores reads the module row and its manifest, and
47
+ * because the secrets store touches the master key only when an operation
48
+ * actually needs it. A hook that never calls `context.secrets` should pay
49
+ * none of that.
50
+ */
51
+ export type HookStoresProvider = () => Promise<HookStores>;
52
+
36
53
  /**
37
54
  * How long to wait after the child exits for its last frame to arrive. Short,
38
55
  * because by this point the writer is already gone and the bytes are either in
@@ -56,6 +73,13 @@ export interface BrokerOptions {
56
73
  logger: HookLogger;
57
74
  /** Called on every frame, so the idle timer measures the whole channel. */
58
75
  onActivity: () => void;
76
+ /**
77
+ * The module's hook-owned-state stores (hook-owned-state task 3.5). Absent
78
+ * means this run has none wired, and a `store` frame from the hook is
79
+ * REFUSED with an error naming the gap — a write that vanishes silently is
80
+ * the exact defect the accessor exists to remove.
81
+ */
82
+ stores?: HookStoresProvider;
59
83
  }
60
84
 
61
85
  export interface Broker {
@@ -163,6 +187,8 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
163
187
  let connection: Socket | undefined;
164
188
  // Already resolved: no connection means nothing left to deliver.
165
189
  let connectionClosed: Promise<void> = Promise.resolve();
190
+ // The module's stores, resolved from the provider on first use.
191
+ let storesCache: HookStores | undefined;
166
192
 
167
193
  const server: Server = createServer((socket) => {
168
194
  if (connection) {
@@ -200,7 +226,7 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
200
226
  }
201
227
  send({
202
228
  type: 'context',
203
- protocolVersion: 1,
229
+ protocolVersion: HOOK_PROTOCOL_VERSION,
204
230
  scriptPath: options.scriptPath,
205
231
  context: options.context,
206
232
  });
@@ -216,6 +242,10 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
216
242
  void dispatch(frame.id, frame.capability, frame.method, frame.args, send);
217
243
  return;
218
244
 
245
+ case 'store':
246
+ void dispatchStore(frame, send);
247
+ return;
248
+
219
249
  case 'result':
220
250
  outcome = { ok: true, outputs: frame.outputs };
221
251
  return;
@@ -285,6 +315,66 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
285
315
  server.listen(socketPath, resolve);
286
316
  });
287
317
 
318
+ /**
319
+ * Answer one hook-owned-state store call.
320
+ *
321
+ * The stores resolve on first use and stay cached for the run. Everything
322
+ * that can fail — an undeclared name, a missing module row, a store that was
323
+ * never wired — answers as a `throw` frame rather than crashing the broker,
324
+ * so the hook's own `try`/`catch` sees it the way an in-process throw would
325
+ * behave.
326
+ */
327
+ async function dispatchStore(
328
+ frame: StoreCallFrame,
329
+ send: (frame: Parameters<typeof encodeFrame>[0]) => void,
330
+ ): Promise<void> {
331
+ if (stopped) {
332
+ send({
333
+ type: 'throw',
334
+ id: frame.id,
335
+ error: {
336
+ name: 'Error',
337
+ message: `Hook run has ended; refusing context.${frame.store}.${frame.method}.`,
338
+ },
339
+ });
340
+ return;
341
+ }
342
+
343
+ try {
344
+ if (!options.stores) {
345
+ throw new Error(
346
+ `No hook state stores were wired for this run, so context.${frame.store} is unavailable. This is a celilo bug: every hook invocation must receive both stores (hook-owned-state task 3.5).`,
347
+ );
348
+ }
349
+ storesCache ??= await options.stores();
350
+ const backend = storesCache[frame.store];
351
+
352
+ let value: unknown;
353
+ switch (frame.method) {
354
+ case 'get':
355
+ value = await backend.get(frame.args[0] as string);
356
+ break;
357
+ case 'set':
358
+ await backend.set(frame.args[0] as string, frame.args[1] as string);
359
+ value = null;
360
+ break;
361
+ case 'delete':
362
+ await backend.delete(frame.args[0] as string);
363
+ value = null;
364
+ break;
365
+ case 'transaction':
366
+ await backend.applyTransaction(frame.args[0] as BufferedStoreOp[]);
367
+ value = null;
368
+ break;
369
+ }
370
+ options.onActivity();
371
+ send({ type: 'return', id: frame.id, value });
372
+ } catch (error) {
373
+ options.onActivity();
374
+ send({ type: 'throw', id: frame.id, error: serializeError(error) });
375
+ }
376
+ }
377
+
288
378
  return {
289
379
  socketPath,
290
380
  drained: () =>
@@ -302,6 +302,39 @@ describe('Firewall Chain Building', () => {
302
302
  expect(config.isolateTransitNetwork).toBe(true);
303
303
  });
304
304
 
305
+ test('single provider: the recorded interface_baseline is forwarded as a parsed list', async () => {
306
+ // The recorded baseline is what turns an undeclared interface from a
307
+ // refusal into an isolation. The single-provider iptables firewall (the
308
+ // production shape: the ISP router is not a celilo module) is built by
309
+ // buildCapabilityInterface, and THIS is the construction site that
310
+ // dropped the field — the baseline was written to module config and
311
+ // never read back, so the module converged in onboarding mode forever
312
+ // (ce-qzxm).
313
+ installReporter('iptables', '{"has_external":true}', '["dmz","app","secure"]');
314
+ upsertModuleConfig(db, 'iptables', 'firewall_ip', '192.168.0.254');
315
+ upsertModuleConfig(db, 'iptables', 'nat_ip', '192.168.0.253');
316
+ upsertModuleConfig(db, 'iptables', 'interface_baseline', 'eth0, eth1, eth2, eth3');
317
+
318
+ const config = await loadedConfig();
319
+ // A parsed array of NAMES, as the module's D5 split consumes it — not
320
+ // the raw comma string, and not with the write-side padding intact.
321
+ expect(config.interfaceBaseline).toEqual(['eth0', 'eth1', 'eth2', 'eth3']);
322
+ });
323
+
324
+ test('single provider: an empty interface_baseline arrives undefined, meaning onboarding', async () => {
325
+ // Empty and absent are the same thing, and both mean "never converged
326
+ // cleanly": treating an empty string as a baseline of nothing would make
327
+ // every interface new. The loader normalizes this so the module sees
328
+ // one shape for one meaning.
329
+ installReporter('iptables', '{"has_external":true}', '["dmz"]');
330
+ upsertModuleConfig(db, 'iptables', 'firewall_ip', '192.168.0.254');
331
+ upsertModuleConfig(db, 'iptables', 'nat_ip', '192.168.0.253');
332
+ upsertModuleConfig(db, 'iptables', 'interface_baseline', '');
333
+
334
+ const config = await loadedConfig();
335
+ expect(config.interfaceBaseline).toBeUndefined();
336
+ });
337
+
305
338
  test('chained provider: the downstream layer gets them too', async () => {
306
339
  // greenwave owns the WAN, so iptables is built through buildFirewallChain
307
340
  // rather than the single-provider path. That is a SECOND construction
@@ -324,10 +357,14 @@ describe('Firewall Chain Building', () => {
324
357
  upsertModuleConfig(db, 'iptables', 'nat_ip', '192.168.0.253');
325
358
  upsertModuleConfig(db, 'iptables', 'default_route_zone', 'internal');
326
359
  upsertModuleConfig(db, 'iptables', 'isolate_transit_network', true);
360
+ upsertModuleConfig(db, 'iptables', 'interface_baseline', 'eth0,eth1');
327
361
 
328
362
  const config = await loadedConfig();
329
363
  expect(config.defaultRouteZone).toBe('internal');
330
364
  expect(config.isolateTransitNetwork).toBe(true);
365
+ // The downstream construction site already forwarded this; pinned so the
366
+ // two sites cannot drift apart again.
367
+ expect(config.interfaceBaseline).toEqual(['eth0', 'eth1']);
331
368
  });
332
369
 
333
370
  test('unset settings arrive undefined, not as a wrong default', async () => {
@@ -7,7 +7,9 @@ import { mkdirSync, writeFileSync } from 'node:fs';
7
7
  import { tmpdir } from 'node:os';
8
8
  import { join } from 'node:path';
9
9
  import type { HookLogger, RouteReadView } from '@celilo/capabilities';
10
+ import { and, eq } from 'drizzle-orm';
10
11
  import type { DbClient } from '../db/client';
12
+ import { moduleConfigs } from '../db/schema';
11
13
  import { upsertModuleConfig } from '../services/module-config';
12
14
  import { cleanupTestDatabase, setupTestDatabase } from '../test-utils/database';
13
15
  import { loadCapabilityFunctions } from './capability-loader';
@@ -40,6 +42,36 @@ export default function createDhcpServer(context) {
40
42
  }
41
43
  `;
42
44
 
45
+ /**
46
+ * A factory that WRITES through the hook-owned-state accessor, so the loader's
47
+ * store view is proven end to end: the method's write lands as a hook-owned
48
+ * row, and an undeclared name throws instead of landing. Branded
49
+ * (defineCapabilityFunction, the Phase 8 pattern) — the branded path is the
50
+ * one the loader hands the store views to. The temp dir the fixture is
51
+ * dropped into has no node_modules link, so the import uses the workspace
52
+ * package by absolute path, the same trick capability-loader-firewall uses.
53
+ */
54
+ const TEST_STORE_WRITING_DHCP_MODULE = `
55
+ import { defineCapabilityFunction } from '${join(__dirname, '../../../..', 'packages/capabilities/src/index.ts')}';
56
+
57
+ export default defineCapabilityFunction({
58
+ capability: 'dhcp_server',
59
+ handler: (context) => ({
60
+ async setDnsServers() {},
61
+ async getDnsServers() { return [context.config.marker]; },
62
+ async setDomainName() {},
63
+ async getDomainName() { return context.config.marker; },
64
+ async claimIp() {
65
+ await context.config.set('public_ip', '203.0.113.7');
66
+ return 'claimed';
67
+ },
68
+ async claimUndeclared() {
69
+ await context.config.set('no_such_key', 'x');
70
+ },
71
+ }),
72
+ });
73
+ `;
74
+
43
75
  describe('Capability Loader', () => {
44
76
  let db: DbClient;
45
77
  let tempDir: string;
@@ -134,6 +166,52 @@ describe('Capability Loader', () => {
134
166
  expect(await dhcp.getDomainName()).toBe('internal-dhcp');
135
167
  });
136
168
 
169
+ test('a provider factory writes hook-owned config through the injected store view', async () => {
170
+ // The store view must do more than carry the plain record a factory reads.
171
+ // A method writing `context.config.set` lands a hook-owned row (validated
172
+ // against the PROVIDER's manifest, source column 'hook'), and an
173
+ // undeclared name throws instead of writing a phantom row.
174
+ const modulePath = join(tempDir, 'storewriting-vpn');
175
+ const scriptsDir = join(modulePath, 'scripts');
176
+ mkdirSync(scriptsDir, { recursive: true });
177
+ writeFileSync(join(scriptsDir, 'dhcp-server-functions.ts'), TEST_STORE_WRITING_DHCP_MODULE);
178
+ const manifest = JSON.stringify({
179
+ variables: { owns: [{ name: 'public_ip', type: 'string', source: 'hook' }] },
180
+ });
181
+ db.$client.run(
182
+ `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('storewriting-vpn', 'StoreWritingVpn', '1.0.0', '${modulePath}', '${manifest.replace(/'/g, "''")}')`,
183
+ );
184
+ db.$client.run(
185
+ `INSERT INTO capabilities (module_id, capability_name, version, data, registered_at) VALUES ('storewriting-vpn', 'dhcp_server', '1.0.0', '{}', unixepoch())`,
186
+ );
187
+ upsertModuleConfig(db, 'storewriting-vpn', 'marker', 'storewriting-vpn');
188
+
189
+ const result = await loadCapabilityFunctions('store-consumer', db, noopLogger);
190
+ const dhcp = result.dhcp_server as {
191
+ claimIp(): Promise<string>;
192
+ claimUndeclared(): Promise<void>;
193
+ };
194
+
195
+ expect(await dhcp.claimIp()).toBe('claimed');
196
+ const row = db
197
+ .select()
198
+ .from(moduleConfigs)
199
+ .where(
200
+ and(eq(moduleConfigs.moduleId, 'storewriting-vpn'), eq(moduleConfigs.key, 'public_ip')),
201
+ )
202
+ .get();
203
+ expect(row?.valueJson).toBe('"203.0.113.7"');
204
+ expect(row?.source).toBe('hook');
205
+
206
+ // The store round-trips text, so the map read the NEXT load takes sees the
207
+ // same string the write sent.
208
+ const reloaded = await loadCapabilityFunctions('store-consumer', db, noopLogger);
209
+ const dhcpAgain = reloaded.dhcp_server as { getDnsServers(): Promise<string[]> };
210
+ expect(await dhcpAgain.getDnsServers()).toEqual(['storewriting-vpn']);
211
+
212
+ await expect(dhcp.claimUndeclared()).rejects.toThrow(/no declared hook-owned config/);
213
+ });
214
+
137
215
  test('falls back to a zone-agnostic provider when no explicit zone provider exists', async () => {
138
216
  const modulePath = join(tempDir, 'upstream-dhcp');
139
217
  const scriptsDir = join(modulePath, 'scripts');
@@ -39,7 +39,7 @@ import {
39
39
  systemConfig,
40
40
  webRoutes,
41
41
  } from '../db/schema';
42
- import { resolveModuleWebRoot } from '../module/web-root';
42
+ import { resolveModuleStateWebRoot, resolveModuleWebRoot } from '../module/web-root';
43
43
  import { decryptSecret } from '../secrets/encryption';
44
44
  import { getOrCreateMasterKey } from '../secrets/master-key';
45
45
  import { buildControlPlaneApi } from '../services/api-principal-enrolment';
@@ -60,6 +60,9 @@ import {
60
60
  import { resolveComputedFields } from '../variables/computed/evaluate';
61
61
  import { containsComputedMarker } from '../variables/computed/marker';
62
62
  import { buildProviderLookup } from '../variables/computed/provider-lookup';
63
+ import type { BufferedStoreOp } from './hook-protocol';
64
+ import { type HookStores, createHookStores } from './hook-store';
65
+ import { type StoreTransport, buildStoreView } from './hook-store-proxy';
63
66
  import { loadHookConfigMap } from './load-hook-config';
64
67
 
65
68
  /**
@@ -260,6 +263,13 @@ export async function loadCapabilityFunctions(
260
263
  // This allows modules to optionally consume capabilities at runtime
261
264
  // (e.g., caddy uses dns_registrar if available, falls back if not).
262
265
  const verbose = process.env.CELILO_DEBUG === '1';
266
+
267
+ // The broker-side stores, resolved from the PROVIDER on the first store
268
+ // call this consumer load makes, and shared across every capability it
269
+ // builds. Nothing pays for the manifest read until a factory actually
270
+ // writes or reads through the accessor. See the storeTransport construction
271
+ // at the factory-invocation site below.
272
+ let storesPromise: Promise<HookStores> | undefined;
263
273
  const debugLog = verbose
264
274
  ? (msg: string) => process.stderr.write(`[capability-loader] ${msg}\n`)
265
275
  : (_msg: string) => {};
@@ -392,10 +402,35 @@ export async function loadCapabilityFunctions(
392
402
  // Phase 8 path: branded defineCapabilityFunction factory. Call with
393
403
  // the canonical { config, secrets, logger } context. The factory
394
404
  // applies wrapWithLogging internally so we don't double-wrap.
405
+ //
406
+ // Both maps arrive as the hook-owned-state view (buildStoreView): the
407
+ // plain record the factory reads, PLUS get/set/delete/transaction that
408
+ // reach the broker-side stores (createHookStores), validated against
409
+ // the PROVIDER module's own manifest. The transport is in-process — a
410
+ // capability factory runs here in celilo's process, not in the jail —
411
+ // but the view is the same object a jailed hook sees, so a provider's
412
+ // self-config write goes through the same validated door regardless of
413
+ // which process it runs in (hook-owned-state tasks 0.7 and 5.5 shape).
395
414
  if (isCompiledCapabilityFactory(exported)) {
415
+ const storeTransport: StoreTransport = (store, method, args) => {
416
+ storesPromise ??= createHookStores(db, capability.moduleId);
417
+ return storesPromise.then((backend: HookStores): Promise<unknown> => {
418
+ const one = backend[store];
419
+ switch (method) {
420
+ case 'transaction':
421
+ return one.applyTransaction(args[0] as readonly BufferedStoreOp[]);
422
+ case 'set':
423
+ return one.set(args[0] as string, args[1] as string);
424
+ case 'delete':
425
+ return one.delete(args[0] as string);
426
+ default:
427
+ return one.get(args[0] as string);
428
+ }
429
+ });
430
+ };
396
431
  const capabilityInterface = exported({
397
- config: providerConfig,
398
- secrets: providerSecrets,
432
+ config: buildStoreView('config', providerConfig, storeTransport),
433
+ secrets: buildStoreView('secrets', providerSecrets, storeTransport),
399
434
  systems: getModuleSystems(capability.moduleId, db),
400
435
  logger,
401
436
  // WHO IS CALLING, so a provider can scope per-consumer state to them.
@@ -408,6 +443,10 @@ export async function loadCapabilityFunctions(
408
443
  // without this they could not find the caller's web root now that
409
444
  // `sourceDir` has left the request (D10 amendment).
410
445
  consumerWebRoot: resolveModuleWebRoot(consumingModuleId, db),
446
+ // WHERE THE CALLER'S GENERATED SITE FILES ARE (celilo#1265), by the
447
+ // same seam. A jailed hook cannot write into its own web root, so a
448
+ // module-provided web provider needs the state overlay to honor it.
449
+ consumerStateWebRoot: resolveModuleStateWebRoot(consumingModuleId, db),
411
450
  });
412
451
  // Stamp here too, not only on the legacy path: a consumer that cannot
413
452
  // get what it needs must be able to name WHICH provider could not give
@@ -594,6 +633,7 @@ export async function loadCapabilityFunctions(
594
633
  // Resolved here, not in the capability: on a converge there is no
595
634
  // consumer process to ask (D10 amendment).
596
635
  webRoot: resolveModuleWebRoot(consumingModuleId, db),
636
+ stateWebRoot: resolveModuleStateWebRoot(consumingModuleId, db),
597
637
  logger,
598
638
  config: providerConfig,
599
639
  secrets: providerSecrets,
@@ -789,6 +829,15 @@ function buildCapabilityInterface(
789
829
  trustedSubnets: zones?.trustedSubnets ?? [],
790
830
  controlPlaneSubnet: zones?.controlPlaneSubnet,
791
831
  frontedSubnets: zones?.frontedSubnets ?? [],
832
+ // The recorded baseline (D12), from this firewall's own module config.
833
+ // Absent means the box has never converged cleanly, so an interface
834
+ // celilo cannot attribute refuses rather than being disabled. The
835
+ // downstream-chain site below forwards this too; the single-provider
836
+ // firewall (an iptables box with the WAN held by a non-celilo ISP
837
+ // router) is built HERE, and this is the site that dropped it — the
838
+ // baseline was recorded but never read back, so every converge stayed
839
+ // in onboarding mode and refused instead of isolating (ce-qzxm).
840
+ interfaceBaseline: parseInterfaceBaseline(config.interface_baseline),
792
841
  // Operator settings, forwarded verbatim. `parseStoredConfigValue`
793
842
  // preserves each manifest-declared type, so the boolean arrives as a
794
843
  // boolean and needs no coercion here.
@@ -30,6 +30,7 @@ import {
30
30
  isCompiledCapabilityFactory,
31
31
  isCompiledHook,
32
32
  } from '@celilo/capabilities';
33
+ import { configStore, secretStore } from './test-fixtures/store-backed';
33
34
 
34
35
  function makeLogger(): HookLogger {
35
36
  return {
@@ -42,8 +43,8 @@ function makeLogger(): HookLogger {
42
43
 
43
44
  function makeContext(overrides: Partial<HookContext> = {}): HookContext {
44
45
  return {
45
- config: {},
46
- secrets: {},
46
+ config: configStore(),
47
+ secrets: secretStore(),
47
48
  systems: [],
48
49
  logger: makeLogger(),
49
50
  consumerModuleId: 'test-consumer',
@@ -152,7 +153,7 @@ describe('defineHook', () => {
152
153
  },
153
154
  });
154
155
 
155
- await hook(makeContext({ config: { foo: 'bar' } }));
156
+ await hook(makeContext({ config: configStore({ foo: 'bar' }) }));
156
157
 
157
158
  expect(seenConfig).toEqual({ foo: 'bar' });
158
159
  });