@pellux/goodvibes-daemon 1.28.19 → 1.28.20

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 (108) hide show
  1. package/CHANGELOG.md +85 -63
  2. package/README.md +13 -13
  3. package/bin/launcher-support.js +5 -5
  4. package/package.json +6 -4
  5. package/scripts/postinstall.js +8 -8
  6. package/src/cli/command-catalog.ts +22 -22
  7. package/src/cli/completion.ts +4 -4
  8. package/src/cli/help.ts +5 -5
  9. package/src/cli/index.ts +3 -3
  10. package/src/cli/parser.ts +2 -2
  11. package/src/cli/surface-catalog.ts +1 -1
  12. package/src/cli/types.ts +2 -2
  13. package/src/cluster/daemon-ws-call.ts +5 -5
  14. package/src/cluster/raw-reply-route.ts +5 -5
  15. package/src/config/checkpoint-settings.ts +7 -7
  16. package/src/config/config-key-guard.ts +22 -0
  17. package/src/config/run-daemon-config-migration.ts +3 -3
  18. package/src/config/secret-config.ts +7 -7
  19. package/src/config/surface.ts +3 -3
  20. package/src/core/pairing-banner.ts +5 -5
  21. package/src/daemon/cli.ts +45 -43
  22. package/src/daemon/config-command.ts +15 -15
  23. package/src/daemon/handlers/context.ts +1 -1
  24. package/src/daemon/handlers/contracts.ts +4 -4
  25. package/src/daemon/handlers/credentials.ts +1 -1
  26. package/src/daemon/handlers/drafts/draft-store.ts +3 -3
  27. package/src/daemon/handlers/drafts/register.ts +4 -4
  28. package/src/daemon/handlers/inbox/aggregator.ts +8 -8
  29. package/src/daemon/handlers/inbox/cursor-store.ts +10 -10
  30. package/src/daemon/handlers/inbox/index.ts +7 -7
  31. package/src/daemon/handlers/inbox/mapping.ts +2 -2
  32. package/src/daemon/handlers/inbox/poller.ts +5 -5
  33. package/src/daemon/handlers/inbox/provider-adapter.ts +8 -8
  34. package/src/daemon/handlers/inbox/providers/discord.ts +6 -6
  35. package/src/daemon/handlers/inbox/providers/email.ts +3 -3
  36. package/src/daemon/handlers/inbox/providers/imap-client.ts +1 -1
  37. package/src/daemon/handlers/inbox/providers/slack.ts +4 -4
  38. package/src/daemon/handlers/index.ts +18 -8
  39. package/src/daemon/handlers/payments/card-store.ts +486 -0
  40. package/src/daemon/handlers/payments/index.ts +32 -0
  41. package/src/daemon/handlers/payments/purchase-ledger.ts +108 -0
  42. package/src/daemon/handlers/payments/register.ts +391 -0
  43. package/src/daemon/handlers/register.ts +3 -3
  44. package/src/daemon/handlers/remote/backends/cloud-terminal.ts +9 -1
  45. package/src/daemon/handlers/remote/backends/process-runner.ts +1 -1
  46. package/src/daemon/handlers/remote/backends/ssh.ts +9 -1
  47. package/src/daemon/handlers/remote/backends/types.ts +2 -2
  48. package/src/daemon/handlers/remote/dispatcher.ts +3 -3
  49. package/src/daemon/handlers/remote/index.ts +1 -1
  50. package/src/daemon/handlers/remote/peer-registry.ts +62 -13
  51. package/src/daemon/handlers/routing/inbox-bridge.ts +5 -5
  52. package/src/daemon/handlers/routing/index.ts +1 -1
  53. package/src/daemon/handlers/routing/route-store.ts +1 -1
  54. package/src/daemon/handlers/routing/routing-resolver.ts +3 -3
  55. package/src/daemon/handlers/sqlite-store.ts +9 -9
  56. package/src/daemon/handlers/triage/index.ts +1 -1
  57. package/src/daemon/handlers/triage/integration.ts +3 -3
  58. package/src/daemon/handlers/triage/pipeline.ts +2 -2
  59. package/src/daemon/handlers/triage/scorer.ts +2 -2
  60. package/src/daemon/handlers/triage/tagger/discord.ts +3 -3
  61. package/src/daemon/handlers/triage/tagger/imap.ts +7 -7
  62. package/src/daemon/handlers/triage/tagger/index.ts +1 -1
  63. package/src/daemon/handlers/triage/tagger/shared.ts +3 -3
  64. package/src/daemon/handlers/triage/tagger/slack.ts +1 -1
  65. package/src/daemon/handlers/triage/types.ts +2 -2
  66. package/src/daemon/lifecycle.ts +5 -5
  67. package/src/daemon/local-daemon-state.ts +7 -7
  68. package/src/daemon/pair-command.ts +14 -14
  69. package/src/daemon/provision-wake-model.ts +5 -5
  70. package/src/daemon/send/channels.ts +7 -7
  71. package/src/daemon/send/command.ts +11 -11
  72. package/src/daemon/send/composition.ts +5 -5
  73. package/src/daemon/send/failure-text.ts +6 -6
  74. package/src/daemon/send/inert-text.ts +18 -18
  75. package/src/daemon/send/stdin.ts +3 -3
  76. package/src/daemon/service-commands.ts +32 -32
  77. package/src/daemon/sessions-command.ts +7 -7
  78. package/src/daemon/status-command.ts +22 -22
  79. package/src/daemon/webui-command.ts +14 -14
  80. package/src/runtime/boot-tasks.ts +1 -1
  81. package/src/runtime/cluster-composition.ts +9 -9
  82. package/src/runtime/cluster-group-composition.ts +7 -7
  83. package/src/runtime/conversation-rewind-port.ts +8 -8
  84. package/src/runtime/credential-composition.ts +2 -2
  85. package/src/runtime/daemon-handler-composition.ts +44 -4
  86. package/src/runtime/device-posture-composition.ts +10 -10
  87. package/src/runtime/disposal-wiring.ts +8 -8
  88. package/src/runtime/fleet-needs-input-push.ts +4 -4
  89. package/src/runtime/fleet-services.ts +1 -1
  90. package/src/runtime/hosted-session-composition.ts +13 -13
  91. package/src/runtime/index.ts +1 -1
  92. package/src/runtime/knowledge-services.ts +2 -2
  93. package/src/runtime/legacy-daemon-migration.ts +43 -43
  94. package/src/runtime/legacy-daemon-reconcile.ts +30 -30
  95. package/src/runtime/mail-composition.ts +6 -6
  96. package/src/runtime/notification-dispatch.ts +7 -7
  97. package/src/runtime/payments-composition.ts +143 -0
  98. package/src/runtime/plugin-composition.ts +7 -7
  99. package/src/runtime/runtime-services-types.ts +9 -9
  100. package/src/runtime/services.ts +29 -27
  101. package/src/runtime/trigger-services.ts +1 -1
  102. package/src/runtime/trust/checkpoint-eligibility.ts +5 -5
  103. package/src/runtime/trust/trust-gated-approvals.ts +9 -9
  104. package/src/runtime/update-check.ts +4 -4
  105. package/src/runtime/workspace-checkpointing.ts +6 -6
  106. package/src/testing/daemon-fixture.ts +11 -11
  107. package/src/testing/hosted-session-failures.ts +4 -4
  108. package/src/version.ts +2 -2
@@ -0,0 +1,391 @@
1
+ /**
2
+ * register.ts, the `payments.*` handlers this daemon attaches.
3
+ *
4
+ * ── Why the handlers are written here and not imported ────────────────────
5
+ *
6
+ * The SDK has these handlers already: `registerPaymentsGatewayMethods` in
7
+ * `platform/control-plane/routes/payments.ts`. It cannot be called from here.
8
+ * That module is not re-exported by `platform/control-plane/index.ts`, and the
9
+ * SDK package's `exports` map publishes only the barrel, so the symbol exists in
10
+ * the installed `dist` and no import path reaches it. `registerGatewayVerbGroups`
11
+ * (the SDK's own composition entry, which the terminal-shell wrapper calls from
12
+ * runtime/services.ts) carries no payments dependency either, so there is no
13
+ * argument this daemon can pass that would make the SDK attach them.
14
+ *
15
+ * So this follows the idiom this repository already uses for every other family
16
+ * it serves, stated at the top of handlers/index.ts and implemented by
17
+ * `registerCatalogHandler`: the SDK owns the id, the descriptor, the schemas,
18
+ * the scopes and the access level, and only the BEHAVIOUR is ours. Nothing
19
+ * below authors a descriptor.
20
+ *
21
+ * The right long-term fix is one line in the SDK's control-plane barrel. Until
22
+ * that lands, the choice is these handlers or a 501 on every payments verb, and
23
+ * a 501 is what the webui and the desktop app have been getting.
24
+ *
25
+ * ── What is attached, and what deliberately is not ────────────────────────
26
+ *
27
+ * Attached: budget.status, cards.list, cards.create, cards.delete,
28
+ * purchases.list. Every one of them is answerable from stores this daemon owns.
29
+ *
30
+ * NOT attached: checkout.begin and checkout.fillCard. Both need a
31
+ * `CheckoutPageDriver` bound to an open browser page, and this composition
32
+ * cannot produce one: `createDaemonBrowserGatewayService` builds the engine
33
+ * inside `registerGatewayVerbGroups` and returns only the `BrowserGatewayService`
34
+ * slice, which exposes no page handle and no `fillSecret`; and that engine is
35
+ * constructed with no `cardFieldGuard`, so its secret-fill path refuses by
36
+ * design. Both verbs therefore keep answering 501 NOT_INVOKABLE, which is the
37
+ * honest answer for a capability nothing here can perform, and a better one than
38
+ * a handler that accepts the call and fails inside.
39
+ *
40
+ * ── Containment ───────────────────────────────────────────────────────────
41
+ *
42
+ * Every response below is BUILT from named fields rather than spread from a
43
+ * store record, for the reason the SDK's own route module gives: an allowlist
44
+ * silently drops a field a later change adds, a denylist silently ships it, and
45
+ * for anything on a card's code path that is the correct direction to fail.
46
+ * No handler here reads card material, and no failure path forwards a message
47
+ * from a call that had material in its arguments.
48
+ */
49
+ import type { BudgetLedger, PaymentsConfigReader } from '@pellux/goodvibes-sdk/platform/payments';
50
+ import {
51
+ readDefaultCardId,
52
+ readPaymentsEnabled,
53
+ readPaymentsServiceConfig,
54
+ } from '@pellux/goodvibes-sdk/platform/payments';
55
+ import type { CardMetadata } from '@pellux/goodvibes-sdk/platform/payments';
56
+ import type { GatewayMethodCatalog } from '../contracts.ts';
57
+ import { HandlerError } from '../errors.ts';
58
+ import { registerCatalogHandlers, type TypedHandler, type Unregister } from '../register.ts';
59
+ import { CardStoreUnreadableError, type DaemonCardStore } from './card-store.ts';
60
+ import { MAX_PURCHASE_LIST_LIMIT, type DaemonPurchaseLedger, type StoredPurchase } from './purchase-ledger.ts';
61
+
62
+ /** The verbs this module attaches. Named so a test can assert the exact set. */
63
+ export const ATTACHED_PAYMENTS_METHOD_IDS: readonly string[] = [
64
+ 'payments.budget.status',
65
+ 'payments.cards.list',
66
+ 'payments.cards.create',
67
+ 'payments.cards.delete',
68
+ 'payments.purchases.list',
69
+ ];
70
+
71
+ /**
72
+ * The verbs this composition leaves unattached, and the reason each one is.
73
+ *
74
+ * Exported so the refusal is testable: a change that wires a page driver has to
75
+ * delete the entry, and a change that attaches one of these without wiring a
76
+ * driver fails the same assertion.
77
+ */
78
+ export const UNATTACHED_PAYMENTS_METHOD_IDS: readonly { readonly id: string; readonly reason: string }[] = [
79
+ {
80
+ id: 'payments.checkout.begin',
81
+ reason:
82
+ 'Needs a CheckoutPageDriver for an open browser page. The SDK builds the browser engine inside '
83
+ + 'registerGatewayVerbGroups and hands back only BrowserGatewayService, which exposes no page handle, '
84
+ + 'and builds it with no cardFieldGuard, so its secret-fill path refuses by design.',
85
+ },
86
+ {
87
+ id: 'payments.checkout.fillCard',
88
+ reason: 'Same missing page driver; this is the verb that types the card into it.',
89
+ },
90
+ ];
91
+
92
+ const DEFAULT_PURCHASE_LIST_LIMIT = 100;
93
+
94
+ export interface PaymentsHandlerDeps {
95
+ readonly cards: DaemonCardStore;
96
+ readonly purchases: DaemonPurchaseLedger;
97
+ /**
98
+ * Today's pools. Read-only in this composition: the only writer of a spend
99
+ * record is the checkout flow, which is not attached, so this ledger reports
100
+ * limits from live config against an empty spend history. Wiring checkout must
101
+ * also make this ledger DURABLE, a ledger rebuilt at every boot would hand
102
+ * back a daily budget that was already spent.
103
+ */
104
+ readonly budget: BudgetLedger;
105
+ readonly config: PaymentsConfigReader;
106
+ /**
107
+ * Whether this node is the one allowed to spend.
108
+ *
109
+ * Reported, never defaulted, see the SDK's gates.ts: on a clustered install a
110
+ * wrong answer here is a double-spend. The composition root supplies the
111
+ * coordinator's own answer.
112
+ */
113
+ readonly isPaymentsLeader: () => boolean;
114
+ readonly now?: (() => number) | undefined;
115
+ }
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // Input readers
119
+ //
120
+ // Each names the FIELD and never the value, the property the SDK's own route
121
+ // module enforces: an error string is a read path like any other.
122
+ // ---------------------------------------------------------------------------
123
+
124
+ function invalid(field: string, requirement: string): HandlerError {
125
+ return new HandlerError(`${field} ${requirement}`, 'INVALID_ARGUMENT', 400);
126
+ }
127
+
128
+ /**
129
+ * Run a card-store call and refuse in the caller's terms.
130
+ *
131
+ * Two outcomes, and the difference is what the caller is allowed to be told:
132
+ *
133
+ * - `CardStoreUnreadableError` is a message this codebase WROTE, naming the
134
+ * file and what to do about it. It is forwarded verbatim because the operator
135
+ * cannot fix a damaged card file they are not told about, and 409 says the
136
+ * honest thing: nothing is wrong with the request, the store is not in a
137
+ * state that can serve it.
138
+ * - Anything else came out of the secret store, and its message can name the
139
+ * store path, the key, or the value it was handling. It is DISCARDED and
140
+ * replaced here. Without this, `registerCatalogHandler`'s generic wrapper
141
+ * forwards the original as a 500 body, which put a store path in front of any
142
+ * caller holding read:payments.
143
+ */
144
+ async function overStore<T>(what: string, run: () => Promise<T>): Promise<T> {
145
+ try {
146
+ return await run();
147
+ } catch (error) {
148
+ if (error instanceof CardStoreUnreadableError) {
149
+ throw new HandlerError(error.message, 'FAILED_PRECONDITION', 409);
150
+ }
151
+ void error;
152
+ throw new HandlerError(`${what} failed.`, 'INTERNAL_ERROR', 500);
153
+ }
154
+ }
155
+
156
+ function readString(source: Record<string, unknown>, field: string): string {
157
+ const value = source[field];
158
+ if (typeof value !== 'string' || value.trim().length === 0) {
159
+ throw invalid(field, 'is required.');
160
+ }
161
+ return value.trim();
162
+ }
163
+
164
+ function readInteger(source: Record<string, unknown>, field: string, min: number, max: number): number {
165
+ const value = source[field];
166
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < min || value > max) {
167
+ throw invalid(field, `must be a whole number between ${String(min)} and ${String(max)}.`);
168
+ }
169
+ return value;
170
+ }
171
+
172
+ function asRecord(body: unknown): Record<string, unknown> {
173
+ return typeof body === 'object' && body !== null && !Array.isArray(body)
174
+ ? (body as Record<string, unknown>)
175
+ : {};
176
+ }
177
+
178
+ /** A number that arrived as a query string (a GET) or as JSON (an invoke). */
179
+ function optionalCount(raw: unknown): number | undefined {
180
+ if (typeof raw === 'number' && Number.isInteger(raw) && raw > 0) return raw;
181
+ if (typeof raw === 'string' && /^[0-9]+$/.test(raw.trim())) {
182
+ const parsed = Number.parseInt(raw.trim(), 10);
183
+ if (parsed > 0) return parsed;
184
+ }
185
+ return undefined;
186
+ }
187
+
188
+ // ---------------------------------------------------------------------------
189
+ // Response builders (allowlists, never spreads)
190
+ // ---------------------------------------------------------------------------
191
+
192
+ interface CardView {
193
+ readonly id: string;
194
+ readonly label: string;
195
+ readonly brand: string;
196
+ readonly last4: string;
197
+ readonly kind: 'virtual' | 'real';
198
+ readonly expiryMonth: number;
199
+ readonly expiryYear: number;
200
+ readonly issuerCapMinorUnits: number | null;
201
+ readonly addedAt: string;
202
+ readonly materialComplete: boolean;
203
+ }
204
+
205
+ function cardView(card: CardMetadata, materialComplete: boolean): CardView {
206
+ return {
207
+ id: card.id,
208
+ label: card.label,
209
+ brand: card.brand,
210
+ last4: card.last4,
211
+ kind: card.kind,
212
+ expiryMonth: card.expiryMonth,
213
+ expiryYear: card.expiryYear,
214
+ issuerCapMinorUnits: card.issuerCapMinorUnits,
215
+ addedAt: card.addedAt,
216
+ materialComplete,
217
+ };
218
+ }
219
+
220
+ function purchaseView(row: StoredPurchase): Record<string, unknown> {
221
+ return {
222
+ purchaseId: row.purchaseId,
223
+ atUtc: row.atUtc,
224
+ dayKey: row.dayKey,
225
+ timezone: row.timezone,
226
+ merchantDomain: row.merchantDomain,
227
+ item: String(row.item),
228
+ currency: String(row.currency),
229
+ itemMinorUnits: row.itemMinorUnits,
230
+ taxMinorUnits: row.taxMinorUnits,
231
+ feesMinorUnits: row.feesMinorUnits,
232
+ shippingMinorUnits: row.shippingMinorUnits,
233
+ totalMinorUnits: row.totalMinorUnits,
234
+ shippingTierRequested: row.shippingTierRequested,
235
+ shippingTierUsed: row.shippingTierUsed,
236
+ steppedDown: row.steppedDown === true,
237
+ itemPoolDraw: row.itemPoolDraw,
238
+ overagePoolDraw: row.overagePoolDraw,
239
+ tolerancePoolDraw: row.tolerancePoolDraw,
240
+ cardLast4: row.cardLast4,
241
+ windowKind: row.windowKind,
242
+ windowOutcome: row.windowOutcome,
243
+ answeredBy: row.answeredBy ?? null,
244
+ outcome: row.outcome,
245
+ refusalReason: row.refusalReason ?? null,
246
+ merchantOrderId: row.merchantOrderId ?? null,
247
+ refundedAt: row.refundedAt ?? null,
248
+ merchantRecognised: row.merchantRecognised === true,
249
+ merchantQualifier: row.merchantQualifier ?? null,
250
+ merchantDiscovered: row.merchantDiscovered === true,
251
+ };
252
+ }
253
+
254
+ // ---------------------------------------------------------------------------
255
+ // Registration
256
+ // ---------------------------------------------------------------------------
257
+
258
+ /**
259
+ * Attach the five answerable `payments.*` handlers to the descriptors the SDK
260
+ * catalog already holds. Returns the teardown, reverse order, like every other
261
+ * surface in this layer.
262
+ *
263
+ * NOT gated on `payments.enabled`. That key defaults to false, and
264
+ * `payments.cards.*` is how a surface CONFIGURES the capability, so gating
265
+ * registration on it would leave the configuration surface unreachable until the
266
+ * capability was already configured, which is the shape of the defect this
267
+ * module exists to fix. The setting is reported live by `budget.status` instead,
268
+ * and it is `checkPaymentGates` at purchase time that stops a disabled daemon
269
+ * from spending.
270
+ */
271
+ export function registerPaymentsMethods(
272
+ catalog: GatewayMethodCatalog,
273
+ deps: PaymentsHandlerDeps,
274
+ ): Unregister {
275
+ const now = deps.now ?? Date.now;
276
+
277
+ const budgetStatus: TypedHandler<unknown, Record<string, unknown>> = async () => {
278
+ const config = readPaymentsServiceConfig(deps.config);
279
+ const nowMs = now();
280
+ const pools = deps.budget.snapshot(config.limits, nowMs, config.timezone);
281
+ const live = deps.budget.state().reservations.filter((entry) => entry.expiresAtMs > nowMs);
282
+ return {
283
+ enabled: readPaymentsEnabled(deps.config),
284
+ dayKey: String(pools.dayKey),
285
+ timezone: pools.timezone,
286
+ currency: String(config.budgetCurrency),
287
+ item: { ...pools.item },
288
+ overage: { ...pools.overage },
289
+ tolerance: { ...pools.tolerance },
290
+ reservationCount: live.length,
291
+ isPaymentsLeader: deps.isPaymentsLeader(),
292
+ };
293
+ };
294
+
295
+ const cardsList: TypedHandler<unknown, Record<string, unknown>> = async () => {
296
+ const views = await overStore('Listing the stored cards', async () => {
297
+ const built: CardView[] = [];
298
+ for (const card of deps.cards.list()) {
299
+ built.push(cardView(card, await deps.cards.materialComplete(card.id)));
300
+ }
301
+ return built;
302
+ });
303
+ return { cards: views, defaultCardId: readDefaultCardId(deps.config) };
304
+ };
305
+
306
+ const cardsCreate: TypedHandler<unknown, Record<string, unknown>> = async ({ body }) => {
307
+ const params = asRecord(body);
308
+ const kind = readString(params, 'kind');
309
+ if (kind !== 'virtual' && kind !== 'real') {
310
+ throw invalid('kind', "must be 'virtual' or 'real'.");
311
+ }
312
+ const label = readString(params, 'label');
313
+ const number = readString(params, 'number');
314
+ // Narrower than the published input schema, which types these as a plain
315
+ // string and a plain number. Each check below is a property of BEING a card
316
+ // rather than a policy about one: a value that fails it could not be
317
+ // charged, and storing it would produce a card the surface offers and the
318
+ // checkout can never fill. None of them names anything but the field.
319
+ if (number.replace(/\D/g, '').length < 12) {
320
+ throw invalid('number', 'does not contain enough digits to be a card number.');
321
+ }
322
+ const expiryMonth = readInteger(params, 'expiryMonth', 1, 12);
323
+ // A full four-digit year: `cardFieldValue` derives the two-digit form by
324
+ // slicing this one, so a year stored as 29 would type as "29" in a
325
+ // four-digit field and as "29" in a two-digit one, and only one of those is
326
+ // right.
327
+ const expiryYear = readInteger(params, 'expiryYear', 1000, 9999);
328
+ const cvv = readString(params, 'cvv');
329
+ if (!/^[0-9]{3,4}$/.test(cvv)) {
330
+ throw invalid('cvv', 'must be the three or four digit code printed on the card.');
331
+ }
332
+ const cardholderName = readString(params, 'cardholderName');
333
+ const rawCap = params['issuerCapMinorUnits'];
334
+
335
+ let card: CardMetadata;
336
+ try {
337
+ card = await deps.cards.create({
338
+ label,
339
+ kind,
340
+ number,
341
+ expiryMonth,
342
+ expiryYear,
343
+ cvv,
344
+ cardholderName,
345
+ issuerCapMinorUnits: typeof rawCap === 'number' && Number.isInteger(rawCap) ? rawCap : null,
346
+ });
347
+ } catch (error) {
348
+ // A damaged card file is the operator's to fix and its message says how,
349
+ // so it is forwarded; see overStore. Everything else is discarded, because
350
+ // the failing call had the card in its arguments.
351
+ if (error instanceof CardStoreUnreadableError) {
352
+ throw new HandlerError(error.message, 'FAILED_PRECONDITION', 409);
353
+ }
354
+ void error;
355
+ throw new HandlerError('Storing the card failed. Nothing was saved.', 'INTERNAL_ERROR', 500);
356
+ }
357
+ return {
358
+ card: cardView(card, await overStore('Reading the card back', () => deps.cards.materialComplete(card.id))),
359
+ };
360
+ };
361
+
362
+ const cardsDelete: TypedHandler<unknown, Record<string, unknown>> = async ({ body, query }) => {
363
+ // The REST path is `/api/payments/cards/{id}`, whose path parameter the
364
+ // dispatcher folds into BOTH query and body; the methodId-invoke endpoint
365
+ // carries it in the body only. Reading both is what makes the two paths the
366
+ // same verb rather than two.
367
+ const id = readString({ ...query, ...asRecord(body) }, 'id');
368
+ const result = await overStore('Deleting the card', () => deps.cards.remove(id));
369
+ return { id, deleted: result.deleted, secretsCleared: result.secretsCleared };
370
+ };
371
+
372
+ const purchasesList: TypedHandler<unknown, Record<string, unknown>> = async ({ body, query }) => {
373
+ const params = { ...query, ...asRecord(body) };
374
+ const requested = optionalCount(params['limit']);
375
+ const rawDay = params['dayKey'];
376
+ const dayKey = typeof rawDay === 'string' && rawDay.trim().length > 0 ? rawDay.trim() : undefined;
377
+ const result = deps.purchases.list({
378
+ limit: Math.min(requested ?? DEFAULT_PURCHASE_LIST_LIMIT, MAX_PURCHASE_LIST_LIMIT),
379
+ dayKey,
380
+ });
381
+ return { purchases: result.purchases.map(purchaseView), total: result.total };
382
+ };
383
+
384
+ return registerCatalogHandlers(catalog, [
385
+ { id: 'payments.budget.status', handler: budgetStatus as TypedHandler<unknown, unknown> },
386
+ { id: 'payments.cards.list', handler: cardsList as TypedHandler<unknown, unknown> },
387
+ { id: 'payments.cards.create', handler: cardsCreate as TypedHandler<unknown, unknown> },
388
+ { id: 'payments.cards.delete', handler: cardsDelete as TypedHandler<unknown, unknown> },
389
+ { id: 'payments.purchases.list', handler: purchasesList as TypedHandler<unknown, unknown> },
390
+ ]);
391
+ }
@@ -60,8 +60,8 @@ function errorMessage(error: unknown): string {
60
60
  *
61
61
  * An ABSENT context is accepted and read as the empty one: no principal, no
62
62
  * scopes, not admin, nobody claiming a person asked. The type says a context is
63
- * always there and at runtime it is not always an in-process invoke that
64
- * builds the invocation by hand can omit it and reading `.metadata` off
63
+ * always there and at runtime it is not always, an in-process invoke that
64
+ * builds the invocation by hand can omit it, and reading `.metadata` off
65
65
  * `undefined` turned that into a TypeError thrown out of the handler wrapper
66
66
  * instead of the refusal every caller can act on. Defaulting to the least
67
67
  * privilege is the only safe reading: it can cost a caller an authorization it
@@ -124,7 +124,7 @@ export function registerCatalogHandler<TBody, TResult>(
124
124
  const descriptor = catalog.get(methodId);
125
125
  if (!descriptor) {
126
126
  // 'METHOD_NOT_FOUND' (not the old locally-coined 'UNKNOWN_METHOD') so this lines
127
- // up byte-for-byte with SDKErrorCodes.METHOD_NOT_FOUND the code the SDK's own
127
+ // up byte-for-byte with SDKErrorCodes.METHOD_NOT_FOUND, the code the SDK's own
128
128
  // uncataloged-method 404 now carries (method-catalog.ts's GatewayMethodCatalog
129
129
  // .invoke(), daemon/control-plane.ts's invokeGatewayMethodCall, and daemon-sdk's
130
130
  // control-routes.ts getGatewayMethod/invokeGatewayMethod). A literal string, not
@@ -20,12 +20,20 @@ import { GOODVIBES_DAEMON_SURFACE_ROOT } from '../../../../config/surface.ts';
20
20
  * Cloud-terminal backend: executes a command in a managed cloud shell / VM via
21
21
  * the provider CLI (gcloud / aws / az). The provider credential is resolved from
22
22
  * the daemon credential store and supplied to the CLI via a 0600 credentials
23
- * file or a provider-specific env var never as an argv token, never logged.
23
+ * file or a provider-specific env var, never as an argv token, never logged.
24
24
  */
25
25
  export function createCloudTerminalBackend(ctx: BackendContext): Backend {
26
26
  const credDir = join(ctx.homeDirectory, '.goodvibes', GOODVIBES_DAEMON_SURFACE_ROOT, 'operator', 'cloud-creds');
27
+ // Crash-window sweep: dispatch's finally block only removes a credential
28
+ // file on an orderly return; a hard crash mid-dispatch (SIGKILL, OOM-kill,
29
+ // power loss) skips both that and teardown(), leaving the file behind for
30
+ // every daemon restart until now. Sweep at construction, before any
31
+ // dispatch on this instance can have written anything, so a crash converges
32
+ // to the same clean state a graceful shutdown already produces.
33
+ const credDirSwept = rm(credDir, { recursive: true, force: true }).catch(() => {});
27
34
 
28
35
  async function writeCredentialFile(peerId: string, value: string): Promise<string> {
36
+ await credDirSwept;
29
37
  await mkdir(credDir, { recursive: true });
30
38
  await chmod(credDir, 0o700).catch(() => {});
31
39
  const suffix = randomBytes(4).toString('hex');
@@ -1,6 +1,6 @@
1
1
  // Shared subprocess runner built on Bun.spawn. Captures stdout/stderr/exit code
2
2
  // with a hard timeout. Used by every backend that shells out (docker/ssh/cloud/
3
- // local-process). No credentials are ever passed as argv callers pass key
3
+ // local-process). No credentials are ever passed as argv, callers pass key
4
4
  // material via files or the `env` overlay.
5
5
 
6
6
  export interface RunOptions {
@@ -20,7 +20,7 @@ import { GOODVIBES_DAEMON_SURFACE_ROOT } from '../../../../config/surface.ts';
20
20
  * Persistent-key material is written to {homeDirectory}/.goodvibes/tui/operator/
21
21
  * ssh-keys/{peerId}.key with 0600 permissions and reused across invocations
22
22
  * (connection pooling via the OpenSSH ControlMaster multiplexer). The key value
23
- * itself comes only from the daemon credential store never argv, never logs.
23
+ * itself comes only from the daemon credential store, never argv, never logs.
24
24
  */
25
25
  interface PooledIdentity {
26
26
  keyPath: string;
@@ -31,6 +31,13 @@ interface PooledIdentity {
31
31
  export function createSshBackend(ctx: BackendContext): Backend {
32
32
  const pool = new Map<string, PooledIdentity>();
33
33
  const keyDir = join(ctx.homeDirectory, '.goodvibes', GOODVIBES_DAEMON_SURFACE_ROOT, 'operator', 'ssh-keys');
34
+ // Crash-window sweep: an abrupt daemon exit (SIGKILL, OOM-kill, power loss)
35
+ // skips teardown() and can leave a previous process's key files behind. The
36
+ // pool above starts empty regardless, so those leftovers are unreachable
37
+ // dead weight; clear them here so a crash converges to the same clean state
38
+ // a graceful shutdown already produces. Every write below waits on this
39
+ // first so the sweep can never race a key this instance just wrote.
40
+ const keyDirSwept = rm(keyDir, { recursive: true, force: true }).catch(() => {});
34
41
 
35
42
  async function ensureIdentity(
36
43
  peer: PeerRecord,
@@ -40,6 +47,7 @@ export function createSshBackend(ctx: BackendContext): Backend {
40
47
  if (existing && existing.identityRef === config.identityRef) {
41
48
  return existing;
42
49
  }
50
+ await keyDirSwept;
43
51
  const key = await ctx.credentials.resolveRef(config.identityRef);
44
52
  if (!key || key.length === 0) {
45
53
  throw new BackendDispatchError(
@@ -29,7 +29,7 @@ export interface BackendDispatchResult {
29
29
  export interface BackendContext {
30
30
  credentials: DaemonCredentialStore;
31
31
  logger: HandlerLogger;
32
- /** Daemon home dir used for ephemeral key material under a 0700 subdir. */
32
+ /** Daemon home dir, used for ephemeral key material under a 0700 subdir. */
33
33
  homeDirectory: string;
34
34
  }
35
35
 
@@ -84,7 +84,7 @@ export class BackendDispatchError extends Error {
84
84
  * REMOTE-SHELL SEMANTICS (intentional, documented asymmetry vs local-process):
85
85
  * positional `payload.args` are joined onto the command with a single space and
86
86
  * are NOT shell-escaped, because these backends hand a single command STRING to
87
- * a remote shell the operator's `command` may itself contain pipes, redirects,
87
+ * a remote shell, the operator's `command` may itself contain pipes, redirects,
88
88
  * globs, or quoting that must survive the hop verbatim. The local-process
89
89
  * backend, by contrast, never invokes a shell and passes args as discrete argv.
90
90
  *
@@ -17,7 +17,7 @@ function sha256First(input: string, hexChars: number): string {
17
17
  }
18
18
 
19
19
  // ---------------------------------------------------------------------------
20
- // Work-item hook long-running invocations are enqueued as work items visible
20
+ // Work-item hook, long-running invocations are enqueued as work items visible
21
21
  // in remote.work.list. The dispatcher does not own the distributed runtime; the
22
22
  // integrator wires this hook to the DistributedRuntimeManager work queue.
23
23
  // ---------------------------------------------------------------------------
@@ -36,7 +36,7 @@ export interface RemoteWorkEnqueuer {
36
36
  }
37
37
 
38
38
  // ---------------------------------------------------------------------------
39
- // Invoke result returned to the agent through remote.peers.invoke. Includes
39
+ // Invoke result, returned to the agent through remote.peers.invoke. Includes
40
40
  // stdoutDigest (sha256 of FULL stdout, 64 hex chars) per the receipt contract.
41
41
  // The agent may receive only a truncated stdout preview.
42
42
  // ---------------------------------------------------------------------------
@@ -168,7 +168,7 @@ export class RemoteDispatcher {
168
168
  /**
169
169
  * Best-effort teardown: invoke every backend's optional teardown so ephemeral
170
170
  * key/credential material (ssh-keys/, cloud-creds/) is swept from disk and
171
- * does not outlive the daemon. Failures are swallowed teardown must never
171
+ * does not outlive the daemon. Failures are swallowed, teardown must never
172
172
  * throw during surface shutdown.
173
173
  */
174
174
  async teardown(): Promise<void> {
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Remote handler surface the host backend for `remote.peers.*`.
2
+ * Remote handler surface, the host backend for `remote.peers.*`.
3
3
  *
4
4
  * `remote.peers.invoke` is NOT a catalog method: the SDK publishes it as an HTTP
5
5
  * route and injects a `DistributedRuntimeRouteService` (the host's
@@ -27,7 +27,7 @@ export interface SshBackendConfig {
27
27
  sshHost: string;
28
28
  sshPort?: number;
29
29
  sshUser: string;
30
- /** goodvibes://secrets/ reference to the private key never the raw key. */
30
+ /** goodvibes://secrets/ reference to the private key, never the raw key. */
31
31
  identityRef: string;
32
32
  }
33
33
 
@@ -71,7 +71,7 @@ export interface PeerRegistrationInput {
71
71
  }
72
72
 
73
73
  // ---------------------------------------------------------------------------
74
- // Validation helpers backendConfig must hold ONLY secret refs for any
74
+ // Validation helpers, backendConfig must hold ONLY secret refs for any
75
75
  // credential-bearing field. Raw secrets are rejected outright.
76
76
  // ---------------------------------------------------------------------------
77
77
 
@@ -111,9 +111,9 @@ function requireSecretRef(value: unknown, field: string): string {
111
111
  function assertDockerHostSafe(value: string | undefined, field: string): void {
112
112
  // A dockerHost is accepted in exactly two shapes, mirroring how docker.ts
113
113
  // resolves it (docker.ts: `startsWith('goodvibes://') ? resolveRef(...) : raw`):
114
- // 1. A goodvibes://secrets/ reference resolved from the credential store.
114
+ // 1. A goodvibes://secrets/ reference, resolved from the credential store.
115
115
  // 2. A credential-free local/plain address (unix:// socket, or a bare
116
- // tcp/host with no embedded userinfo) used verbatim.
116
+ // tcp/host with no embedded userinfo), used verbatim.
117
117
  // Enforcement here must match that resolution so no credential-bearing or
118
118
  // unresolvable value slips through to docker.ts.
119
119
  if (value === undefined) return;
@@ -123,7 +123,7 @@ function assertDockerHostSafe(value: string | undefined, field: string): void {
123
123
 
124
124
  // A `goodvibes://` value that is NOT a well-formed secret ref would be handed
125
125
  // to credentials.resolveRef() and fail opaquely (REMOTE_BACKEND_CREDENTIAL_MISSING)
126
- // or, worse, a near-miss could be treated as a literal host. Reject it at
126
+ //, or, worse, a near-miss could be treated as a literal host. Reject it at
127
127
  // registration so the misconfiguration surfaces immediately.
128
128
  if (isMalformedGoodVibesSecretReferenceValue(value)) {
129
129
  throw new PeerRegistryValidationError(
@@ -132,7 +132,7 @@ function assertDockerHostSafe(value: string | undefined, field: string): void {
132
132
  }
133
133
 
134
134
  // Embedded userinfo credentials (e.g. tcp://user:pass@host) must never be
135
- // stored raw docker.ts would pass them verbatim as DOCKER_HOST.
135
+ // stored raw, docker.ts would pass them verbatim as DOCKER_HOST.
136
136
  if (value.includes('@')) {
137
137
  throw new PeerRegistryValidationError(
138
138
  `Field '${field}' appears to embed credentials; pass a goodvibes://secrets/ reference instead.`,
@@ -226,7 +226,7 @@ export function normalizeBackendConfig(
226
226
  }
227
227
 
228
228
  // ---------------------------------------------------------------------------
229
- // Peer registry persisted via HandlerSqliteStore (peer-registry.sqlite)
229
+ // Peer registry, persisted via HandlerSqliteStore (peer-registry.sqlite)
230
230
  // ---------------------------------------------------------------------------
231
231
 
232
232
  const PEER_REGISTRY_FILE = 'peer-registry.sqlite';
@@ -247,21 +247,62 @@ interface PeerRow {
247
247
  backendConfig: string;
248
248
  }
249
249
 
250
- const VALID_BACKEND_KINDS: ReadonlySet<BackendKind> = new Set([
250
+ // Typed ReadonlySet<string> (not ReadonlySet<BackendKind>) so isBackendKind
251
+ // below can call .has() with a plain string and let the function's own `value
252
+ // is BackendKind` signature do the narrowing, instead of casting the set.
253
+ const VALID_BACKEND_KINDS: ReadonlySet<string> = new Set<BackendKind>([
251
254
  'docker',
252
255
  'ssh',
253
256
  'cloud-terminal',
254
257
  'local-process',
255
258
  ]);
256
259
 
260
+ /** True when `value` is one of the four known backend kinds; narrows to BackendKind. */
261
+ function isBackendKind(value: string): value is BackendKind {
262
+ return VALID_BACKEND_KINDS.has(value);
263
+ }
264
+
265
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
266
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
267
+ }
268
+
269
+ /**
270
+ * Turn a stored row back into a typed PeerRecord. backendConfig is written
271
+ * only by register() through normalizeBackendConfig(), but a row read back
272
+ * from disk is untrusted the same way a fresh registration's raw input is: a
273
+ * hand-edited database file, a row left over from a schema this file no
274
+ * longer writes, or on-disk corruption can all put something here that was
275
+ * never actually normalized. Re-running it through normalizeBackendConfig
276
+ * catches that at the read boundary instead of handing a malformed object to
277
+ * a caller that assumes register()'s guarantees already hold.
278
+ */
257
279
  function rowToRecord(row: PeerRow): PeerRecord {
258
- const backendKind = row.backendKind as BackendKind;
259
- const parsed = JSON.parse(row.backendConfig) as BackendConfig;
280
+ if (!isBackendKind(row.backendKind)) {
281
+ throw new PeerRegistryValidationError(
282
+ `Peer '${row.peerId}' has an unknown backendKind '${row.backendKind}'; the row is corrupt or from an unsupported version.`,
283
+ );
284
+ }
285
+ const backendKind = row.backendKind;
286
+
287
+ let raw: unknown;
288
+ try {
289
+ raw = JSON.parse(row.backendConfig);
290
+ } catch {
291
+ throw new PeerRegistryValidationError(
292
+ `Peer '${row.peerId}' has a backendConfig that is not valid JSON; the row is corrupt.`,
293
+ );
294
+ }
295
+ if (!isPlainObject(raw)) {
296
+ throw new PeerRegistryValidationError(
297
+ `Peer '${row.peerId}' has a backendConfig that is not an object; the row is corrupt.`,
298
+ );
299
+ }
300
+
260
301
  return {
261
302
  peerId: row.peerId,
262
303
  displayName: row.displayName,
263
304
  backendKind,
264
- backendConfig: parsed,
305
+ backendConfig: normalizeBackendConfig(backendKind, raw),
265
306
  };
266
307
  }
267
308
 
@@ -335,10 +376,18 @@ export class PeerRegistry {
335
376
  return rows.map(rowToRecord);
336
377
  }
337
378
 
338
- /** Remove a peer. Returns true when a row was deleted. */
379
+ /**
380
+ * Remove a peer. Returns true when a row was deleted. Existence is checked
381
+ * without row validation so a corrupt row (which get() rejects) can still
382
+ * be removed.
383
+ */
339
384
  async remove(peerId: string): Promise<boolean> {
340
385
  this.requireInit();
341
- const existed = this.get(peerId) !== null;
386
+ const row = this.store.all<{ peerId: string }>(
387
+ 'SELECT peerId FROM peers WHERE peerId = ?',
388
+ [peerId],
389
+ );
390
+ const existed = row.length > 0;
342
391
  if (existed) {
343
392
  this.store.run('DELETE FROM peers WHERE peerId = ?', [peerId]);
344
393
  await this.store.save();