@celilo/cli 0.5.0-alpha.0 → 0.5.0-alpha.2

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.
package/src/cli/index.ts CHANGED
@@ -10,6 +10,7 @@ import { COMMANDS, type CommandDef } from './command-registry';
10
10
  import { handleCapabilityInfo } from './commands/capability-info';
11
11
  import { handleCapabilityList } from './commands/capability-list';
12
12
  import { handleCompletion } from './commands/completion';
13
+ import { handleDnsRegistrations } from './commands/dns';
13
14
  import {
14
15
  handleEventsAck,
15
16
  handleEventsDrain,
@@ -162,6 +163,7 @@ Commands:
162
163
  audit Top-level alias for 'system audit'
163
164
  events SQLite event-bus operations (status, tail, run dispatcher, etc.)
164
165
  capability View registered module capabilities
166
+ dns View DNS bookkeeping (registrations ledger)
165
167
  package Create distributable .netapp packages from module source
166
168
  module Manage modules (import, list, configure, build, generate)
167
169
  service Manage container services (Proxmox, Digital Ocean)
@@ -265,9 +267,9 @@ Subcommands:
265
267
  repair Crash-recovery sweep without starting the dispatcher
266
268
  resume Alias for repair (acknowledges halt-on-recovery)
267
269
  respond Run the terminal responder; answer deploy prompts from another shell
268
- install-daemon Write a systemd/launchd user unit for the dispatcher
269
- uninstall-daemon Remove the installed supervisor unit
270
- show-daemon Print the currently installed unit file
270
+ install-daemon [--system] Write a systemd/launchd unit for the dispatcher (--system: management-plane scope)
271
+ uninstall-daemon [--system] Remove the installed supervisor unit
272
+ show-daemon [--system] Print the currently installed unit file
271
273
 
272
274
  Description:
273
275
  The event bus is a SQLite-backed pub/sub layer for celilo modules.
@@ -380,6 +382,32 @@ Related Commands:
380
382
  };
381
383
  }
382
384
 
385
+ function displayDnsHelp(): CommandResult {
386
+ const helpText = `
387
+ Celilo - DNS Bookkeeping
388
+
389
+ Usage:
390
+ celilo dns <subcommand> [options]
391
+
392
+ Subcommands:
393
+ registrations List the DNS registration ledger
394
+
395
+ Description:
396
+ Every successful dns_registrar.registerHost (e.g. a deploy registering
397
+ its public hostname with namecheap) is recorded in the registration
398
+ ledger. The provider's refresh_registrations hook re-asserts these on
399
+ a timer; REFRESHED shows when that last succeeded.
400
+
401
+ Options:
402
+ --provider <module-id> Only show registrations owned by one provider
403
+
404
+ Examples:
405
+ celilo dns registrations
406
+ celilo dns registrations --provider namecheap
407
+ `;
408
+ return { success: true, message: helpText };
409
+ }
410
+
383
411
  function displayCapabilityHelp(): CommandResult {
384
412
  const helpText = `
385
413
  Celilo - Capability Management
@@ -1075,6 +1103,28 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
1075
1103
  };
1076
1104
  }
1077
1105
 
1106
+ if (parsed.command === 'dns') {
1107
+ if (parsed.flags.help || parsed.flags.h) {
1108
+ return displayDnsHelp();
1109
+ }
1110
+
1111
+ if (!parsed.subcommand) {
1112
+ return {
1113
+ success: false,
1114
+ error: 'DNS subcommand required\n\nRun "celilo dns --help" for usage',
1115
+ };
1116
+ }
1117
+
1118
+ if (parsed.subcommand === 'registrations') {
1119
+ return handleDnsRegistrations(parsed.args, parsed.flags);
1120
+ }
1121
+
1122
+ return {
1123
+ success: false,
1124
+ error: `Unknown dns subcommand: ${parsed.subcommand}\n\nRun "celilo dns --help" for usage`,
1125
+ };
1126
+ }
1127
+
1078
1128
  if (parsed.command === 'capability') {
1079
1129
  if (parsed.flags.help || parsed.flags.h) {
1080
1130
  return displayCapabilityHelp();
@@ -1168,9 +1218,9 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
1168
1218
  case 'install-daemon':
1169
1219
  return handleEventsInstallDaemon(parsed.args, parsed.flags);
1170
1220
  case 'uninstall-daemon':
1171
- return handleEventsUninstallDaemon();
1221
+ return handleEventsUninstallDaemon(parsed.args, parsed.flags);
1172
1222
  case 'show-daemon':
1173
- return handleEventsShowDaemon();
1223
+ return handleEventsShowDaemon(parsed.args, parsed.flags);
1174
1224
  default:
1175
1225
  return {
1176
1226
  success: false,
package/src/db/schema.ts CHANGED
@@ -432,6 +432,42 @@ export const webRoutes = sqliteTable(
432
432
  }),
433
433
  );
434
434
 
435
+ /**
436
+ * DNS registration ledger — one row per (provider, fqdn) the framework
437
+ * has successfully registered via dns_registrar.registerHost. Written
438
+ * framework-side by the capability loader (the only layer that knows
439
+ * both consumer and provider), read back to drive the provider's
440
+ * periodic refresh_registrations hook and the `celilo dns
441
+ * registrations` view. Rows die with either module via FK cascade; the
442
+ * remote DNS record itself stays (Namecheap DDNS has no delete API).
443
+ * See designs/DISPATCHER_DAEMON_AND_TIMER_EVENTS.md (B2).
444
+ */
445
+ export const dnsRegistrations = sqliteTable(
446
+ 'dns_registrations',
447
+ {
448
+ id: integer('id').primaryKey({ autoIncrement: true }),
449
+ providerModuleId: text('provider_module_id')
450
+ .notNull()
451
+ .references(() => modules.id, { onDelete: 'cascade' }),
452
+ consumerModuleId: text('consumer_module_id')
453
+ .notNull()
454
+ .references(() => modules.id, { onDelete: 'cascade' }),
455
+ fqdn: text('fqdn').notNull(),
456
+ /** null = provider auto-detected the request's source IP (Namecheap-style). */
457
+ ip: text('ip'),
458
+ registeredAt: integer('registered_at', { mode: 'timestamp' })
459
+ .notNull()
460
+ .default(sql`(unixepoch())`),
461
+ refreshedAt: integer('refreshed_at', { mode: 'timestamp' }),
462
+ },
463
+ (table) => ({
464
+ providerFqdnUnique: uniqueIndex('dns_registrations_provider_fqdn_idx').on(
465
+ table.providerModuleId,
466
+ table.fqdn,
467
+ ),
468
+ }),
469
+ );
470
+
435
471
  /**
436
472
  * Backup storage providers - destinations for backup archives
437
473
  * Supports local filesystem and S3-compatible storage (AWS S3, MinIO, Backblaze B2, Wasabi)
@@ -19,6 +19,7 @@ import {
19
19
  } from '@celilo/capabilities';
20
20
  import type {
21
21
  DnsInternalCapability,
22
+ DnsRegistrarCapability,
22
23
  HookLogger,
23
24
  RouteOps,
24
25
  RouteReadView,
@@ -30,6 +31,7 @@ import { decryptSecret } from '../secrets/encryption';
30
31
  import { getOrCreateMasterKey } from '../secrets/master-key';
31
32
  import { emitWebRoutesChangedAndWait } from '../services/celilo-events';
32
33
  import { getModuleSystems } from '../services/deployed-systems';
34
+ import { withDnsRegistrationLedger } from '../services/dns-registrations';
33
35
  import { loadHookConfigMap } from './load-hook-config';
34
36
 
35
37
  /**
@@ -194,6 +196,20 @@ export async function loadCapabilityFunctions(
194
196
  const providerConfig = await loadModuleConfig(capability.moduleId, db);
195
197
  const providerSecrets = await loadModuleSecrets(capability.moduleId, masterKey, db);
196
198
 
199
+ // dns_registrar interfaces get the registration-ledger wrapper:
200
+ // every successful registerHost is recorded in dns_registrations so
201
+ // the provider's refresh_registrations hook can re-assert it later
202
+ // (designs/DISPATCHER_DAEMON_AND_TIMER_EVENTS.md B2). The loader is
203
+ // the one layer that knows both provider and consumer.
204
+ const withLedgerIfDnsRegistrar = (iface: unknown): unknown =>
205
+ capName === 'dns_registrar'
206
+ ? withDnsRegistrationLedger(iface as DnsRegistrarCapability, {
207
+ db,
208
+ providerModuleId: capability.moduleId,
209
+ consumerModuleId: consumingModuleId,
210
+ })
211
+ : iface;
212
+
197
213
  try {
198
214
  // Dynamically import the capability module. Try the default export
199
215
  // first (the HOOK_API_V2 Phase 8 pattern), fall back to the legacy
@@ -217,7 +233,7 @@ export async function loadCapabilityFunctions(
217
233
  systems: getModuleSystems(capability.moduleId, db),
218
234
  logger,
219
235
  });
220
- result[capName] = capabilityInterface;
236
+ result[capName] = withLedgerIfDnsRegistrar(capabilityInterface);
221
237
  debugLog(`${capName}: loaded via defineCapabilityFunction`);
222
238
  continue;
223
239
  }
@@ -233,7 +249,9 @@ export async function loadCapabilityFunctions(
233
249
  );
234
250
 
235
251
  if (capabilityInterface) {
236
- result[capName] = wrapWithLogging(capabilityInterface as object, logger, capName);
252
+ result[capName] = withLedgerIfDnsRegistrar(
253
+ wrapWithLogging(capabilityInterface as object, logger, capName),
254
+ );
237
255
  debugLog(`${capName}: loaded via legacy factory`);
238
256
  }
239
257
  } catch (error) {
@@ -388,6 +406,16 @@ export async function loadCapabilityFunctions(
388
406
  `public_web reconcile for ${consumingModuleId}: ${reconcile.failed} delivery(ies) failed — caddy could not apply the route, so the hostname is not served.`,
389
407
  );
390
408
  }
409
+ // ISS-0087: a route WAS registered and the dispatcher IS alive, yet ZERO
410
+ // providers reconciled it (no subscriber consumed routes_changed). That
411
+ // registers a route nobody applied and would otherwise report success.
412
+ // `events === 0` means no routes changed (benign); `events > 0 &&
413
+ // succeeded === 0` means the route changed but nobody served it — a failure.
414
+ if (reconcile.events > 0 && reconcile.succeeded === 0) {
415
+ throw new Error(
416
+ `public_web route for ${consumingModuleId} changed (${reconcile.events} event(s)) but NO provider reconciled it — caddy has no reconcile_routes subscription on the bus, so the route is persisted yet never served (no site block, no cert). Ensure a public_web provider (caddy) is deployed and subscribed.`,
417
+ );
418
+ }
391
419
  },
392
420
  });
393
421
  debugLog(`public_web: loaded via framework implementation for ${consumingModuleId}`);
@@ -19,6 +19,10 @@ import type { ModuleManifest } from '../manifest/schema';
19
19
  import { decryptSecret } from '../secrets/encryption';
20
20
  import { getOrCreateMasterKey } from '../secrets/master-key';
21
21
  import { getModuleSystems } from '../services/deployed-systems';
22
+ import {
23
+ listDnsRegistrations,
24
+ stampDnsRegistrationsRefreshed,
25
+ } from '../services/dns-registrations';
22
26
  import { loadCapabilityFunctions } from './capability-loader';
23
27
  import { invokeHook } from './executor';
24
28
  import { loadHookConfigMap } from './load-hook-config';
@@ -125,12 +129,26 @@ export async function runNamedHook(
125
129
  : [];
126
130
  const capabilityFunctions = await loadCapabilityFunctions(moduleId, db, logger);
127
131
 
128
- return invokeHook(
132
+ // refresh_registrations is fed by the framework: module scripts can't
133
+ // read the celilo DB, so the dns_registrations ledger rows for THIS
134
+ // provider are injected as the contract's `registrations` input
135
+ // (designs/DISPATCHER_DAEMON_AND_TIMER_EVENTS.md B3). Authoritative —
136
+ // overrides any caller-supplied value.
137
+ let inputs = options.inputs ?? {};
138
+ if (hookName === 'refresh_registrations') {
139
+ const registrations = listDnsRegistrations(db, { providerModuleId: moduleId }).map((r) => ({
140
+ fqdn: r.fqdn,
141
+ ip: r.ip,
142
+ }));
143
+ inputs = { ...inputs, registrations };
144
+ }
145
+
146
+ const result = await invokeHook(
129
147
  module.sourcePath,
130
148
  hookName,
131
149
  manifest.celilo_contract,
132
150
  hookDef,
133
- options.inputs ?? {},
151
+ inputs,
134
152
  configMap,
135
153
  secretMap,
136
154
  logger,
@@ -141,4 +159,12 @@ export async function runNamedHook(
141
159
  systems: getModuleSystems(moduleId, db),
142
160
  },
143
161
  );
162
+
163
+ // A fully successful refresh stamps the ledger so `celilo dns
164
+ // registrations` shows when each provider last re-asserted.
165
+ if (hookName === 'refresh_registrations' && result.success) {
166
+ stampDnsRegistrationsRefreshed(db, moduleId);
167
+ }
168
+
169
+ return result;
144
170
  }
@@ -119,7 +119,8 @@ export type HookName =
119
119
  | 'on_backup'
120
120
  | 'on_backup_analyze'
121
121
  | 'on_restore'
122
- | 'on_system_event';
122
+ | 'on_system_event'
123
+ | 'refresh_registrations';
123
124
 
124
125
  /**
125
126
  * Hook manifest section - maps hook names to definitions
@@ -178,6 +178,22 @@ export const V1_HOOKS: ContractHooks = {
178
178
  inputs: {},
179
179
  outputs: {},
180
180
  },
181
+ /**
182
+ * Periodic re-assertion of a dns_registrar provider's registered
183
+ * records (designs/DISPATCHER_DAEMON_AND_TIMER_EVENTS.md B3). The
184
+ * framework populates `registrations` from the dns_registrations
185
+ * ledger (module scripts cannot read the celilo DB); the hook
186
+ * re-sends each {fqdn, ip} to the underlying DNS API and fails —
187
+ * loudly — if ANY record cannot be re-asserted. Typically driven by
188
+ * a `timer.tick.15m` subscription.
189
+ */
190
+ refresh_registrations: {
191
+ inputs: {
192
+ /** Array<{ fqdn: string; ip: string | null }>, framework-injected. */
193
+ registrations: { required: true },
194
+ },
195
+ outputs: {},
196
+ },
181
197
  /**
182
198
  * Build-bus upstream publish hook. The executor passes the
183
199
  * PublishEvent fields as env vars (CELILO_EVENT_PAYLOAD,
@@ -523,6 +523,16 @@ export const ModuleManifestSchema = z
523
523
  * [[v2/PUBLIC_WEB_PROVIDER_RECONCILE.md]].
524
524
  */
525
525
  reconcile_routes: LifecycleHookSchema.optional(),
526
+ /**
527
+ * Periodic re-assertion of a dns_registrar provider's registered
528
+ * records. The framework injects the provider's dns_registrations
529
+ * ledger rows as the `registrations` input; the hook re-sends each
530
+ * one to the underlying DNS API and fails loudly if any cannot be
531
+ * re-asserted. Providers subscribe it to a `timer.tick.*` event.
532
+ * Part of the dns_registrar capability contract — see
533
+ * designs/DISPATCHER_DAEMON_AND_TIMER_EVENTS.md (B3).
534
+ */
535
+ refresh_registrations: LifecycleHookSchema.optional(),
526
536
  /**
527
537
  * Build-bus upstream publish hooks. Array (a module can react
528
538
  * to multiple upstream packages with different actions). See
@@ -15,7 +15,7 @@
15
15
  */
16
16
 
17
17
  import type { DnsInternalCapability, HookLogger } from '@celilo/capabilities';
18
- import { and, eq } from 'drizzle-orm';
18
+ import { and, eq, inArray, or } from 'drizzle-orm';
19
19
  import type { DbClient } from '../db/client';
20
20
  import { capabilities as capabilitiesTable, modules, webRoutes } from '../db/schema';
21
21
  import { loadCapabilityFunctions, resolveFirewallNatIp } from '../hooks/capability-loader';
@@ -54,7 +54,19 @@ export async function backfillProviderDns(
54
54
  ): Promise<void> {
55
55
  logger.info(`dns_internal provider '${moduleId}' deployed — backfilling DNS for all systems`);
56
56
 
57
- const deployed = db.select().from(modules).all();
57
+ // ISS-0009: register only DEPLOYED systems, never IMPORTED-but-undeployed
58
+ // modules (those have a hostname/target_ip configured but nothing serving at
59
+ // that address — registering an A record for them is a phantom record). A
60
+ // deployed system is INSTALLED or VERIFIED. The provider itself is already
61
+ // INSTALLED by the time backfill runs (module-deploy transitions state before
62
+ // calling this), but we union its id explicitly so the provider's own
63
+ // self-record is registered even if that transition order ever changes —
64
+ // critical in daemonless contexts where no dispatcher delivers system.created.
65
+ const deployed = db
66
+ .select()
67
+ .from(modules)
68
+ .where(or(inArray(modules.state, ['INSTALLED', 'VERIFIED']), eq(modules.id, moduleId)))
69
+ .all();
58
70
  const failures: string[] = [];
59
71
 
60
72
  // One register per deployed system across all modules — a module with N
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Unit tests for the DNS registration ledger
3
+ * (designs/DISPATCHER_DAEMON_AND_TIMER_EVENTS.md B2): upsert semantics,
4
+ * the registerHost recording wrapper (success-only), refresh stamping,
5
+ * and FK-cascade cleanup when a module is removed.
6
+ */
7
+
8
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
9
+ import { mkdtempSync, rmSync } from 'node:fs';
10
+ import { tmpdir } from 'node:os';
11
+ import { join } from 'node:path';
12
+ import type { DnsRegistrarCapability } from '@celilo/capabilities';
13
+ import { eq } from 'drizzle-orm';
14
+ import { type DbClient, getDb } from '../db/client';
15
+ import { modules } from '../db/schema';
16
+ import {
17
+ listDnsRegistrations,
18
+ recordDnsRegistration,
19
+ stampDnsRegistrationsRefreshed,
20
+ withDnsRegistrationLedger,
21
+ } from './dns-registrations';
22
+
23
+ describe('dns_registrations ledger', () => {
24
+ let tempDir: string;
25
+ let db: DbClient;
26
+
27
+ beforeEach(() => {
28
+ tempDir = mkdtempSync(join(tmpdir(), 'celilo-dnsreg-'));
29
+ process.env.CELILO_DB_PATH = join(tempDir, 'test.db');
30
+ db = getDb();
31
+ for (const id of ['namecheap', 'caddy']) {
32
+ db.insert(modules)
33
+ .values({
34
+ id,
35
+ name: id,
36
+ sourcePath: join(tempDir, id),
37
+ version: '1.0.0',
38
+ manifestData: { celilo_contract: '1.0', id, name: id, version: '1.0.0' },
39
+ })
40
+ .run();
41
+ }
42
+ });
43
+
44
+ afterEach(() => {
45
+ rmSync(tempDir, { recursive: true, force: true });
46
+ process.env.CELILO_DB_PATH = undefined;
47
+ });
48
+
49
+ test('record + list roundtrip; upsert replaces ip and consumer', () => {
50
+ recordDnsRegistration(db, {
51
+ providerModuleId: 'namecheap',
52
+ consumerModuleId: 'caddy',
53
+ fqdn: 'www.lunacycle.net',
54
+ ip: '198.51.100.7',
55
+ });
56
+ recordDnsRegistration(db, {
57
+ providerModuleId: 'namecheap',
58
+ consumerModuleId: 'caddy',
59
+ fqdn: 'www.lunacycle.net',
60
+ ip: '198.51.100.8',
61
+ });
62
+
63
+ const rows = listDnsRegistrations(db, { providerModuleId: 'namecheap' });
64
+ expect(rows.length).toBe(1);
65
+ expect(rows[0].fqdn).toBe('www.lunacycle.net');
66
+ expect(rows[0].ip).toBe('198.51.100.8');
67
+ expect(rows[0].consumerModuleId).toBe('caddy');
68
+ expect(rows[0].refreshedAt).toBeNull();
69
+ });
70
+
71
+ test('stampDnsRegistrationsRefreshed sets refreshedAt for the provider', () => {
72
+ recordDnsRegistration(db, {
73
+ providerModuleId: 'namecheap',
74
+ consumerModuleId: 'caddy',
75
+ fqdn: 'git.lunacycle.net',
76
+ ip: null,
77
+ });
78
+ stampDnsRegistrationsRefreshed(db, 'namecheap');
79
+ const [row] = listDnsRegistrations(db, { providerModuleId: 'namecheap' });
80
+ expect(row.refreshedAt).not.toBeNull();
81
+ });
82
+
83
+ test('rows die with the consumer module (FK cascade)', () => {
84
+ recordDnsRegistration(db, {
85
+ providerModuleId: 'namecheap',
86
+ consumerModuleId: 'caddy',
87
+ fqdn: 'www.lunacycle.net',
88
+ ip: '198.51.100.7',
89
+ });
90
+ db.delete(modules).where(eq(modules.id, 'caddy')).run();
91
+ expect(listDnsRegistrations(db).length).toBe(0);
92
+ });
93
+
94
+ test('withDnsRegistrationLedger records successes only', async () => {
95
+ const calls: string[] = [];
96
+ const fake: DnsRegistrarCapability = {
97
+ async registerHost(request) {
98
+ calls.push(request.fqdn);
99
+ const success = !request.fqdn.startsWith('fail.');
100
+ return { success, outputs: {}, duration: 1 };
101
+ },
102
+ };
103
+ const wrapped = withDnsRegistrationLedger(fake, {
104
+ db,
105
+ providerModuleId: 'namecheap',
106
+ consumerModuleId: 'caddy',
107
+ });
108
+
109
+ await wrapped.registerHost({ fqdn: 'www.lunacycle.net', ip: '198.51.100.7' });
110
+ await wrapped.registerHost({ fqdn: 'fail.lunacycle.net', ip: '198.51.100.7' });
111
+ await wrapped.registerHost({ fqdn: 'auto.lunacycle.net' });
112
+
113
+ expect(calls.length).toBe(3);
114
+ const rows = listDnsRegistrations(db);
115
+ const fqdns = rows.map((r) => r.fqdn).sort();
116
+ expect(fqdns).toEqual(['auto.lunacycle.net', 'www.lunacycle.net']);
117
+ const auto = rows.find((r) => r.fqdn === 'auto.lunacycle.net');
118
+ expect(auto?.ip).toBeNull();
119
+ });
120
+ });
@@ -0,0 +1,108 @@
1
+ /**
2
+ * DNS registration ledger operations
3
+ * (designs/DISPATCHER_DAEMON_AND_TIMER_EVENTS.md B2/B3).
4
+ *
5
+ * The capability loader records every successful
6
+ * dns_registrar.registerHost here; the run-hook path reads the ledger
7
+ * back to feed a provider's `refresh_registrations` hook, and `celilo
8
+ * dns registrations` lists it for the operator. Row lifecycle is FK
9
+ * cascade — registrations die with their provider or consumer module.
10
+ */
11
+
12
+ import type { DnsRegistrarCapability, HookResult } from '@celilo/capabilities';
13
+ import { eq } from 'drizzle-orm';
14
+ import type { DbClient } from '../db/client';
15
+ import { dnsRegistrations } from '../db/schema';
16
+
17
+ export interface DnsRegistrationRow {
18
+ fqdn: string;
19
+ /** null = the provider auto-detected the request's source IP. */
20
+ ip: string | null;
21
+ providerModuleId: string;
22
+ consumerModuleId: string;
23
+ registeredAt: Date;
24
+ refreshedAt: Date | null;
25
+ }
26
+
27
+ export function listDnsRegistrations(
28
+ db: DbClient,
29
+ options: { providerModuleId?: string } = {},
30
+ ): DnsRegistrationRow[] {
31
+ const query = db
32
+ .select({
33
+ fqdn: dnsRegistrations.fqdn,
34
+ ip: dnsRegistrations.ip,
35
+ providerModuleId: dnsRegistrations.providerModuleId,
36
+ consumerModuleId: dnsRegistrations.consumerModuleId,
37
+ registeredAt: dnsRegistrations.registeredAt,
38
+ refreshedAt: dnsRegistrations.refreshedAt,
39
+ })
40
+ .from(dnsRegistrations);
41
+ const rows = options.providerModuleId
42
+ ? query.where(eq(dnsRegistrations.providerModuleId, options.providerModuleId)).all()
43
+ : query.all();
44
+ return rows;
45
+ }
46
+
47
+ export function recordDnsRegistration(
48
+ db: DbClient,
49
+ registration: {
50
+ providerModuleId: string;
51
+ consumerModuleId: string;
52
+ fqdn: string;
53
+ ip: string | null;
54
+ },
55
+ ): void {
56
+ db.insert(dnsRegistrations)
57
+ .values({
58
+ providerModuleId: registration.providerModuleId,
59
+ consumerModuleId: registration.consumerModuleId,
60
+ fqdn: registration.fqdn,
61
+ ip: registration.ip,
62
+ registeredAt: new Date(),
63
+ })
64
+ .onConflictDoUpdate({
65
+ target: [dnsRegistrations.providerModuleId, dnsRegistrations.fqdn],
66
+ set: {
67
+ consumerModuleId: registration.consumerModuleId,
68
+ ip: registration.ip,
69
+ registeredAt: new Date(),
70
+ },
71
+ })
72
+ .run();
73
+ }
74
+
75
+ /** Stamp every row of a provider as refreshed (successful refresh hook). */
76
+ export function stampDnsRegistrationsRefreshed(db: DbClient, providerModuleId: string): void {
77
+ db.update(dnsRegistrations)
78
+ .set({ refreshedAt: new Date() })
79
+ .where(eq(dnsRegistrations.providerModuleId, providerModuleId))
80
+ .run();
81
+ }
82
+
83
+ /**
84
+ * Wrap a dns_registrar interface so successful registerHost calls are
85
+ * recorded in the ledger. Failures and MissingProviderInputError
86
+ * interview throws pass through untouched — only a confirmed
87
+ * registration earns a row.
88
+ */
89
+ export function withDnsRegistrationLedger(
90
+ registrar: DnsRegistrarCapability,
91
+ ctx: { db: DbClient; providerModuleId: string; consumerModuleId: string },
92
+ ): DnsRegistrarCapability {
93
+ return {
94
+ ...registrar,
95
+ async registerHost(request): Promise<HookResult> {
96
+ const result = await registrar.registerHost(request);
97
+ if (result.success && request.fqdn) {
98
+ recordDnsRegistration(ctx.db, {
99
+ providerModuleId: ctx.providerModuleId,
100
+ consumerModuleId: ctx.consumerModuleId,
101
+ fqdn: request.fqdn,
102
+ ip: request.ip ?? null,
103
+ });
104
+ }
105
+ return result;
106
+ },
107
+ };
108
+ }