@pellux/goodvibes-daemon 1.28.19 → 1.28.21

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 (114) hide show
  1. package/CHANGELOG.md +119 -63
  2. package/README.md +24 -19
  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 +19 -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/address-store.ts +54 -0
  40. package/src/daemon/handlers/payments/budget-store.ts +356 -0
  41. package/src/daemon/handlers/payments/card-store.ts +486 -0
  42. package/src/daemon/handlers/payments/checkout-handlers.ts +526 -0
  43. package/src/daemon/handlers/payments/index.ts +38 -0
  44. package/src/daemon/handlers/payments/merchant-judge.ts +57 -0
  45. package/src/daemon/handlers/payments/notifier.ts +112 -0
  46. package/src/daemon/handlers/payments/purchase-ledger.ts +108 -0
  47. package/src/daemon/handlers/payments/register.ts +518 -0
  48. package/src/daemon/handlers/register.ts +3 -3
  49. package/src/daemon/handlers/remote/backends/cloud-terminal.ts +9 -1
  50. package/src/daemon/handlers/remote/backends/process-runner.ts +1 -1
  51. package/src/daemon/handlers/remote/backends/ssh.ts +9 -1
  52. package/src/daemon/handlers/remote/backends/types.ts +2 -2
  53. package/src/daemon/handlers/remote/dispatcher.ts +3 -3
  54. package/src/daemon/handlers/remote/index.ts +1 -1
  55. package/src/daemon/handlers/remote/peer-registry.ts +62 -13
  56. package/src/daemon/handlers/routing/inbox-bridge.ts +5 -5
  57. package/src/daemon/handlers/routing/index.ts +1 -1
  58. package/src/daemon/handlers/routing/route-store.ts +1 -1
  59. package/src/daemon/handlers/routing/routing-resolver.ts +3 -3
  60. package/src/daemon/handlers/sqlite-store.ts +9 -9
  61. package/src/daemon/handlers/triage/index.ts +1 -1
  62. package/src/daemon/handlers/triage/integration.ts +3 -3
  63. package/src/daemon/handlers/triage/pipeline.ts +2 -2
  64. package/src/daemon/handlers/triage/scorer.ts +2 -2
  65. package/src/daemon/handlers/triage/tagger/discord.ts +3 -3
  66. package/src/daemon/handlers/triage/tagger/imap.ts +7 -7
  67. package/src/daemon/handlers/triage/tagger/index.ts +1 -1
  68. package/src/daemon/handlers/triage/tagger/shared.ts +3 -3
  69. package/src/daemon/handlers/triage/tagger/slack.ts +1 -1
  70. package/src/daemon/handlers/triage/types.ts +2 -2
  71. package/src/daemon/lifecycle.ts +5 -5
  72. package/src/daemon/local-daemon-state.ts +7 -7
  73. package/src/daemon/pair-command.ts +14 -14
  74. package/src/daemon/provision-wake-model.ts +5 -5
  75. package/src/daemon/send/channels.ts +7 -7
  76. package/src/daemon/send/command.ts +11 -11
  77. package/src/daemon/send/composition.ts +5 -5
  78. package/src/daemon/send/failure-text.ts +6 -6
  79. package/src/daemon/send/inert-text.ts +18 -18
  80. package/src/daemon/send/stdin.ts +3 -3
  81. package/src/daemon/service-commands.ts +32 -32
  82. package/src/daemon/sessions-command.ts +7 -7
  83. package/src/daemon/status-command.ts +22 -22
  84. package/src/daemon/webui-command.ts +14 -14
  85. package/src/runtime/boot-tasks.ts +1 -1
  86. package/src/runtime/browser-checkout-seam-holder.ts +55 -0
  87. package/src/runtime/cluster-composition.ts +9 -9
  88. package/src/runtime/cluster-group-composition.ts +7 -7
  89. package/src/runtime/conversation-rewind-port.ts +8 -8
  90. package/src/runtime/credential-composition.ts +2 -2
  91. package/src/runtime/daemon-handler-composition.ts +61 -4
  92. package/src/runtime/device-posture-composition.ts +10 -10
  93. package/src/runtime/disposal-wiring.ts +8 -8
  94. package/src/runtime/fleet-needs-input-push.ts +4 -4
  95. package/src/runtime/fleet-services.ts +1 -1
  96. package/src/runtime/hosted-session-composition.ts +13 -13
  97. package/src/runtime/index.ts +1 -1
  98. package/src/runtime/knowledge-services.ts +2 -2
  99. package/src/runtime/legacy-daemon-migration.ts +43 -43
  100. package/src/runtime/legacy-daemon-reconcile.ts +30 -30
  101. package/src/runtime/mail-composition.ts +6 -6
  102. package/src/runtime/notification-dispatch.ts +7 -7
  103. package/src/runtime/payments-composition.ts +187 -0
  104. package/src/runtime/plugin-composition.ts +7 -7
  105. package/src/runtime/runtime-services-types.ts +9 -9
  106. package/src/runtime/services.ts +41 -32
  107. package/src/runtime/trigger-services.ts +1 -1
  108. package/src/runtime/trust/checkpoint-eligibility.ts +5 -5
  109. package/src/runtime/trust/trust-gated-approvals.ts +9 -9
  110. package/src/runtime/update-check.ts +4 -4
  111. package/src/runtime/workspace-checkpointing.ts +6 -6
  112. package/src/testing/daemon-fixture.ts +11 -11
  113. package/src/testing/hosted-session-failures.ts +4 -4
  114. package/src/version.ts +2 -2
@@ -0,0 +1,518 @@
1
+ /**
2
+ * register.ts, the `payments.*` handlers this daemon attaches.
3
+ *
4
+ * ── The SDK now owns most of these bodies ──────────────────────────────────
5
+ *
6
+ * `registerPaymentsGatewayMethods` (platform/control-plane, exported from the
7
+ * barrel as of sdk 2.0.18) ships real handler bodies for all seven `payments.*`
8
+ * verbs, built over a `PaymentsGatewayService` seam. `budgetStatus`, `listCards`
9
+ * and `deleteCard` are answered from that seam directly below and attached
10
+ * through the SDK's registrar; the local handler bodies that used to duplicate
11
+ * them are gone.
12
+ *
13
+ * Two verbs stay LOCAL rather than going through the SDK's own route handlers
14
+ * for it, and both are deliberate, not oversights:
15
+ *
16
+ * - `payments.cards.create`: the SDK's `createPaymentsCardsCreateHandler`
17
+ * wraps the whole call to `service.createCard(...)` in one try/catch that
18
+ * replaces ANY thrown error, whatever its shape, with a fixed 500
19
+ * "Storing the card failed. Nothing was saved." This daemon's contract is
20
+ * narrower than the published input schema (a card number has to contain
21
+ * enough digits to be one, a CVV has to be three or four digits, an expiry
22
+ * month has to be 1-12, see the field readers below) and reports each of
23
+ * those with an honest 400 naming the field. Delegating create to the SDK
24
+ * handler would still validate the fields, but every refusal would answer
25
+ * 500 instead of 400, a real wire-behaviour regression, not merely an
26
+ * implementation detail. So this verb keeps its own thin wrapper, which does
27
+ * the narrowing and then calls the same `service.createCard` the SDK
28
+ * handler would have, for the store write and the response shape.
29
+ * - `payments.purchases.list`: the SDK's `createPaymentsPurchasesListHandler`
30
+ * only accepts `limit` when it arrives already typed as a JS number. A GET
31
+ * request's query string never is, `?limit=5` arrives as the string `"5"`,
32
+ * so every caller of the real REST route would silently lose the ability to
33
+ * bound the page size and always get the handler's own default. This
34
+ * daemon's contract reads a numeric-looking string the same way it reads a
35
+ * number (`optionalCount` below), so this verb also keeps its own thin
36
+ * wrapper, which does that reading and then calls `service.listPurchases`.
37
+ *
38
+ * `payments.checkout.begin` and `payments.checkout.fillCard` are now attached
39
+ * too, over the sdk 2.0.19 browser-checkout seam
40
+ * (`platform/control-plane`'s `composeDaemonBrowser`/`onBrowserCheckout`/
41
+ * `BrowserCheckoutSeam`). They do NOT go through
42
+ * `registerPaymentsGatewayMethods`'s own route handlers, for the same reason
43
+ * `payments.cards.create`/`payments.purchases.list` do not: those handlers
44
+ * call `service.beginCheckout(input)`/`service.fillCardIntoCheckout(input)`
45
+ * with no invocation context at all, and this composition's whole
46
+ * "approving a purchase is a distinct act" ruling (see `registerPaymentsMethods`
47
+ * below) needs `context.explicitUserRequest`, which only reaches a handler
48
+ * attached through this daemon's own `registerCatalogHandlers`. So both verbs
49
+ * are attached locally, alongside `cardsCreate`/`purchasesList`, reading and
50
+ * shaping the SAME wire shapes `routes/payments.ts` does (ported here rather
51
+ * than imported, since the SDK does not publish those parsing functions on
52
+ * their own), and calling into the ONE `PaymentsGatewayServiceImpl` this
53
+ * registration's checkout pair shares for its whole life (see
54
+ * checkout-handlers.ts's `CheckoutServiceHolder` for why one, not one per
55
+ * call: its own `CheckoutRegistry` is in-memory, per-instance state, and
56
+ * `begin` and `fillCard` are separate control-plane calls that both need to
57
+ * see it).
58
+ *
59
+ * `deps.checkout` (a `CheckoutComposition`, see below) is REQUIRED, not
60
+ * optional: in the real daemon it is always supplied
61
+ * (runtime/payments-composition.ts), and its own `seam()` getter is what may
62
+ * legitimately be absent, either because this composition never builds a
63
+ * browser at all (no `homeDirectory`, a narrow embed) or because
64
+ * `onBrowserCheckout` has not fired yet (see
65
+ * runtime/browser-checkout-seam-holder.ts for why that race is benign). Either
66
+ * way `payments.checkout.begin`/`.fillCard` answer an honest refusal rather
67
+ * than 501 NOT_INVOKABLE or a crash; see `checkoutBegin`/`checkoutFillCard`
68
+ * below.
69
+ *
70
+ * ── Containment ───────────────────────────────────────────────────────────
71
+ *
72
+ * Every response below is BUILT from named fields rather than spread from a
73
+ * store record, for the reason the SDK's own route module gives: an allowlist
74
+ * silently drops a field a later change adds, a denylist silently ships it, and
75
+ * for anything on a card's code path that is the correct direction to fail.
76
+ * No handler here reads card material, and no failure path forwards a message
77
+ * from a call that had material in its arguments.
78
+ */
79
+ import type { BudgetLedger, PaymentsConfigReader } from '@pellux/goodvibes-sdk/platform/payments';
80
+ import { MemoryCheckoutJournal, readDefaultCardId, readPaymentsEnabled, readPaymentsServiceConfig } from '@pellux/goodvibes-sdk/platform/payments';
81
+ import type { CardMetadata, CheckoutJournal } from '@pellux/goodvibes-sdk/platform/payments';
82
+ import {
83
+ registerPaymentsGatewayMethods,
84
+ type GatewayMethodCatalog,
85
+ type GatewayMethodDescriptor,
86
+ type PaymentPurchaseView,
87
+ type PaymentsGatewayService,
88
+ } from '../contracts.ts';
89
+ import { HandlerError } from '../errors.ts';
90
+ import { registerCatalogHandlers, type TypedHandler, type Unregister } from '../register.ts';
91
+ import { CheckoutServiceHolder, checkoutBeginHandler, checkoutFillCardHandler, type CheckoutComposition } from './checkout-handlers.ts';
92
+ import { CardStoreUnreadableError, type DaemonCardStore } from './card-store.ts';
93
+ import { MAX_PURCHASE_LIST_LIMIT, type DaemonPurchaseLedger, type StoredPurchase } from './purchase-ledger.ts';
94
+
95
+ export type { CheckoutComposition } from './checkout-handlers.ts';
96
+
97
+ /** The verbs this module attaches. Named so a test can assert the exact set. */
98
+ export const ATTACHED_PAYMENTS_METHOD_IDS: readonly string[] = [
99
+ 'payments.budget.status',
100
+ 'payments.cards.list',
101
+ 'payments.cards.create',
102
+ 'payments.cards.delete',
103
+ 'payments.purchases.list',
104
+ 'payments.checkout.begin',
105
+ 'payments.checkout.fillCard',
106
+ ];
107
+
108
+ /**
109
+ * Kept, empty, rather than deleted: `gateway-payments-verbs.test.ts` and
110
+ * `register.test.ts` iterate this to assert the unattached set, and an empty
111
+ * array keeps that assertion meaningful (a future verb added here without a
112
+ * handler still gets caught) instead of forcing every caller to delete the
113
+ * loop. Nothing in this module's registration reads it any more.
114
+ */
115
+ export const UNATTACHED_PAYMENTS_METHOD_IDS: readonly { readonly id: string; readonly reason: string }[] = [];
116
+
117
+ const DEFAULT_PURCHASE_LIST_LIMIT = 100;
118
+
119
+ export interface PaymentsHandlerDeps {
120
+ readonly cards: DaemonCardStore;
121
+ readonly purchases: DaemonPurchaseLedger;
122
+ /** Today's pools. The checkout flow is the sole writer; the composition root is responsible for making this durable. */
123
+ readonly budget: BudgetLedger;
124
+ readonly config: PaymentsConfigReader;
125
+ /**
126
+ * Whether this node is the one allowed to spend.
127
+ *
128
+ * Reported, never defaulted, see the SDK's gates.ts: on a clustered install a
129
+ * wrong answer here is a double-spend. The composition root supplies the
130
+ * coordinator's own answer.
131
+ */
132
+ readonly isPaymentsLeader: () => boolean;
133
+ readonly now?: (() => number) | undefined;
134
+ readonly checkout: CheckoutComposition;
135
+ }
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // Input readers
139
+ //
140
+ // Each names the FIELD and never the value, the property the SDK's own route
141
+ // module enforces: an error string is a read path like any other.
142
+ // ---------------------------------------------------------------------------
143
+
144
+ function invalid(field: string, requirement: string): HandlerError {
145
+ return new HandlerError(`${field} ${requirement}`, 'INVALID_ARGUMENT', 400);
146
+ }
147
+
148
+ /**
149
+ * Run a card-store call and refuse in the caller's terms.
150
+ *
151
+ * Two outcomes, and the difference is what the caller is allowed to be told:
152
+ *
153
+ * - `CardStoreUnreadableError` is a message this codebase WROTE, naming the
154
+ * file and what to do about it. It is forwarded verbatim because the operator
155
+ * cannot fix a damaged card file they are not told about, and 409 says the
156
+ * honest thing: nothing is wrong with the request, the store is not in a
157
+ * state that can serve it.
158
+ * - Anything else came out of the secret store, and its message can name the
159
+ * store path, the key, or the value it was handling. It is DISCARDED and
160
+ * replaced here. Without this, `registerCatalogHandler`'s generic wrapper
161
+ * forwards the original as a 500 body, which put a store path in front of any
162
+ * caller holding read:payments.
163
+ */
164
+ async function overStore<T>(what: string, run: () => Promise<T>): Promise<T> {
165
+ try {
166
+ return await run();
167
+ } catch (error) {
168
+ if (error instanceof CardStoreUnreadableError) {
169
+ throw new HandlerError(error.message, 'FAILED_PRECONDITION', 409);
170
+ }
171
+ void error;
172
+ throw new HandlerError(`${what} failed.`, 'INTERNAL_ERROR', 500);
173
+ }
174
+ }
175
+
176
+ function readString(source: Record<string, unknown>, field: string): string {
177
+ const value = source[field];
178
+ if (typeof value !== 'string' || value.trim().length === 0) {
179
+ throw invalid(field, 'is required.');
180
+ }
181
+ return value.trim();
182
+ }
183
+
184
+ function readInteger(source: Record<string, unknown>, field: string, min: number, max: number): number {
185
+ const value = source[field];
186
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < min || value > max) {
187
+ throw invalid(field, `must be a whole number between ${String(min)} and ${String(max)}.`);
188
+ }
189
+ return value;
190
+ }
191
+
192
+ function asRecord(body: unknown): Record<string, unknown> {
193
+ return typeof body === 'object' && body !== null && !Array.isArray(body)
194
+ ? (body as Record<string, unknown>)
195
+ : {};
196
+ }
197
+
198
+ /** A number that arrived as a query string (a GET) or as JSON (an invoke). */
199
+ function optionalCount(raw: unknown): number | undefined {
200
+ if (typeof raw === 'number' && Number.isInteger(raw) && raw > 0) return raw;
201
+ if (typeof raw === 'string' && /^[0-9]+$/.test(raw.trim())) {
202
+ const parsed = Number.parseInt(raw.trim(), 10);
203
+ if (parsed > 0) return parsed;
204
+ }
205
+ return undefined;
206
+ }
207
+
208
+ // ---------------------------------------------------------------------------
209
+ // Response builders (allowlists, never spreads)
210
+ // ---------------------------------------------------------------------------
211
+
212
+ interface CardView {
213
+ readonly id: string;
214
+ readonly label: string;
215
+ readonly brand: string;
216
+ readonly last4: string;
217
+ readonly kind: 'virtual' | 'real';
218
+ readonly expiryMonth: number;
219
+ readonly expiryYear: number;
220
+ readonly issuerCapMinorUnits: number | null;
221
+ readonly addedAt: string;
222
+ readonly materialComplete: boolean;
223
+ }
224
+
225
+ function cardView(card: CardMetadata, materialComplete: boolean): CardView {
226
+ return {
227
+ id: card.id,
228
+ label: card.label,
229
+ brand: card.brand,
230
+ last4: card.last4,
231
+ kind: card.kind,
232
+ expiryMonth: card.expiryMonth,
233
+ expiryYear: card.expiryYear,
234
+ issuerCapMinorUnits: card.issuerCapMinorUnits,
235
+ addedAt: card.addedAt,
236
+ materialComplete,
237
+ };
238
+ }
239
+
240
+ function purchaseView(row: StoredPurchase): PaymentPurchaseView {
241
+ return {
242
+ purchaseId: row.purchaseId,
243
+ atUtc: row.atUtc,
244
+ dayKey: row.dayKey,
245
+ timezone: row.timezone,
246
+ merchantDomain: row.merchantDomain,
247
+ item: String(row.item),
248
+ currency: String(row.currency),
249
+ itemMinorUnits: row.itemMinorUnits,
250
+ taxMinorUnits: row.taxMinorUnits,
251
+ feesMinorUnits: row.feesMinorUnits,
252
+ shippingMinorUnits: row.shippingMinorUnits,
253
+ totalMinorUnits: row.totalMinorUnits,
254
+ shippingTierRequested: row.shippingTierRequested,
255
+ shippingTierUsed: row.shippingTierUsed,
256
+ steppedDown: row.steppedDown === true,
257
+ itemPoolDraw: row.itemPoolDraw,
258
+ overagePoolDraw: row.overagePoolDraw,
259
+ tolerancePoolDraw: row.tolerancePoolDraw,
260
+ cardLast4: row.cardLast4,
261
+ windowKind: row.windowKind,
262
+ windowOutcome: row.windowOutcome,
263
+ answeredBy: row.answeredBy ?? null,
264
+ outcome: row.outcome,
265
+ refusalReason: row.refusalReason ?? null,
266
+ merchantOrderId: row.merchantOrderId ?? null,
267
+ refundedAt: row.refundedAt ?? null,
268
+ merchantRecognised: row.merchantRecognised === true,
269
+ merchantQualifier: row.merchantQualifier ?? null,
270
+ merchantDiscovered: row.merchantDiscovered === true,
271
+ };
272
+ }
273
+
274
+ /**
275
+ * A checkout verb reached through the service seam despite neither local
276
+ * checkout handler below ever calling it: both call into the ONE
277
+ * `PaymentsGatewayServiceImpl` this registration's checkout pair shares for
278
+ * its whole life, held by `CheckoutServiceHolder` (checkout-handlers.ts), not
279
+ * a fresh instance built per call. Only `begin` needs anything per-invocation,
280
+ * the gate-input cell (`CheckoutGateInputsCell`) it writes just before each
281
+ * call, since the shared service's `gates()` closure has no other way to see a
282
+ * given call's `context.explicitUserRequest` or card/address facts; `fillCard`
283
+ * reads nothing per-invocation at all, it types into fields the prior `begin`
284
+ * already found. Either way, `PaymentsGatewayService`'s plain
285
+ * `beginCheckout(input)`/`fillCardIntoCheckout(input)` shape has no room for
286
+ * that context, which is the actual reason these two verbs are attached as
287
+ * local wrappers rather than through this service (see this file's header).
288
+ * The stub below exists only so `PaymentsGatewayService` stays fully
289
+ * implemented for `registerPaymentsGatewayMethods`'s throwaway first
290
+ * attachment, immediately replaced by `registerPaymentsMethods`.
291
+ */
292
+ function checkoutNotWired(methodId: string): Error {
293
+ return new Error(
294
+ `${methodId} is served by this daemon's own local handler, never through this service. `
295
+ + 'See registerPaymentsMethods in register.ts.',
296
+ );
297
+ }
298
+
299
+ // ---------------------------------------------------------------------------
300
+
301
+ /**
302
+ * The `PaymentsGatewayService` this daemon hands the SDK's registrar.
303
+ *
304
+ * `createCard` and `listPurchases` are real, not stubs: `payments.cards.create`
305
+ * and `payments.purchases.list` keep their own thin local wrappers (below) for
306
+ * the field-shape validation and the string-tolerant query reading the SDK's
307
+ * generic route handlers do not do, and both wrappers call straight into these
308
+ * same two methods for the store write and the response shape, so there is
309
+ * exactly one place that talks to `DaemonCardStore.create` and to
310
+ * `DaemonPurchaseLedger.list`.
311
+ */
312
+ function buildPaymentsGatewayService(deps: PaymentsHandlerDeps): PaymentsGatewayService {
313
+ const now = deps.now ?? Date.now;
314
+
315
+ return {
316
+ async budgetStatus() {
317
+ const config = readPaymentsServiceConfig(deps.config);
318
+ const nowMs = now();
319
+ const pools = deps.budget.snapshot(config.limits, nowMs, config.timezone);
320
+ const live = deps.budget.state().reservations.filter((entry) => entry.expiresAtMs > nowMs);
321
+ return {
322
+ enabled: readPaymentsEnabled(deps.config),
323
+ currency: String(config.budgetCurrency),
324
+ pools,
325
+ reservationCount: live.length,
326
+ isPaymentsLeader: deps.isPaymentsLeader(),
327
+ };
328
+ },
329
+
330
+ async listCards() {
331
+ return overStore('Listing the stored cards', async () => {
332
+ const built: CardView[] = [];
333
+ for (const card of deps.cards.list()) {
334
+ built.push(cardView(card, await deps.cards.materialComplete(card.id)));
335
+ }
336
+ return { cards: built, defaultCardId: readDefaultCardId(deps.config) };
337
+ });
338
+ },
339
+
340
+ async createCard(input) {
341
+ let card: CardMetadata;
342
+ try {
343
+ card = await deps.cards.create(input);
344
+ } catch (error) {
345
+ // A damaged card file is the operator's to fix and its message says how,
346
+ // so it is forwarded; see overStore. Everything else is discarded, because
347
+ // the failing call had the card in its arguments.
348
+ if (error instanceof CardStoreUnreadableError) {
349
+ throw new HandlerError(error.message, 'FAILED_PRECONDITION', 409);
350
+ }
351
+ void error;
352
+ throw new HandlerError('Storing the card failed. Nothing was saved.', 'INTERNAL_ERROR', 500);
353
+ }
354
+ return cardView(card, await overStore('Reading the card back', () => deps.cards.materialComplete(card.id)));
355
+ },
356
+
357
+ async deleteCard(id) {
358
+ return overStore('Deleting the card', () => deps.cards.remove(id));
359
+ },
360
+
361
+ async beginCheckout() {
362
+ throw checkoutNotWired('payments.checkout.begin');
363
+ },
364
+
365
+ async fillCardIntoCheckout() {
366
+ throw checkoutNotWired('payments.checkout.fillCard');
367
+ },
368
+
369
+ async listPurchases(input) {
370
+ const result = deps.purchases.list(input);
371
+ return { purchases: result.purchases.map(purchaseView), total: result.total };
372
+ },
373
+ };
374
+ }
375
+
376
+ // ---------------------------------------------------------------------------
377
+ // Registration
378
+ // ---------------------------------------------------------------------------
379
+
380
+ /**
381
+ * Attach the seven `payments.*` handlers to the descriptors the SDK catalog
382
+ * already holds. Returns the teardown.
383
+ *
384
+ * NOT gated on `payments.enabled`. That key defaults to false, and
385
+ * `payments.cards.*` is how a surface CONFIGURES the capability, so gating
386
+ * registration on it would leave the configuration surface unreachable until the
387
+ * capability was already configured, which is the shape of the defect this
388
+ * module exists to fix. The setting is reported live by `budget.status` instead,
389
+ * and it is `checkPaymentGates` at purchase time that stops a disabled daemon
390
+ * from spending.
391
+ */
392
+ export function registerPaymentsMethods(
393
+ catalog: GatewayMethodCatalog,
394
+ deps: PaymentsHandlerDeps,
395
+ ): Unregister {
396
+ // Every descriptor this module attaches to, captured BEFORE any
397
+ // registration runs. `registerCatalogHandlers`' own teardown (used below for
398
+ // four of these) removes the DESCRIPTOR from the catalog entirely rather
399
+ // than merely clearing its handler slot (`GatewayMethodCatalog.register`'s
400
+ // returned teardown calls `unregister`, a `Map.delete`, not a handler
401
+ // reset), so restoring a handler-less descriptor after THIS module's own
402
+ // teardown, matching what the SDK's three descriptors are restored to below,
403
+ // needs the descriptor object captured here rather than re-fetched from the
404
+ // catalog afterward, when it may no longer be there to fetch.
405
+ const descriptors = new Map<string, GatewayMethodDescriptor>();
406
+ for (const id of ATTACHED_PAYMENTS_METHOD_IDS) {
407
+ const descriptor = catalog.get(id);
408
+ if (descriptor) descriptors.set(id, descriptor);
409
+ }
410
+
411
+ const service = buildPaymentsGatewayService(deps);
412
+
413
+ // Attaches all seven `payments.*` descriptors. budget/list/delete stay
414
+ // attached through this; create/purchases-list/checkout-begin/checkout-
415
+ // fillCard are all transiently attached here and immediately replaced below
416
+ // with this daemon's own local handlers. See the header comment for why
417
+ // each group needs its own wrapper.
418
+ registerPaymentsGatewayMethods(catalog, service);
419
+
420
+ // The journal backing this registration's checkout pair's in-flight
421
+ // registry (the SDK's own `CheckoutRegistry`, built inside
422
+ // `PaymentsGatewayServiceImpl`'s constructor from whatever `CheckoutJournal`
423
+ // it is handed, see checkout-handlers.ts's `buildCheckoutService`; this
424
+ // value here is the journal, not the registry itself). `MemoryCheckoutJournal`
425
+ // (the sdk's own, checkout-registry.ts, documented "durable across nothing")
426
+ // is a deliberate choice for THIS pass, not an oversight: a durable journal
427
+ // is real work (a file or store write on every `registry.advance`, including
428
+ // the `submit-pending` flush checkout-flow.ts's step 9 makes right before the
429
+ // merchant submit) that has not been done yet. The gap it leaves is exactly
430
+ // the one that flush exists to close: if this process crashes between that
431
+ // `submit-pending` write and seeing the merchant's response, a restart with a
432
+ // durable journal could tell the owner "this purchase may already have been
433
+ // submitted, do not resubmit it"; with this in-memory journal, that record is
434
+ // gone the moment the process is, and a restart has no way to know the
435
+ // purchase was ever in flight at all. Recorded as future work in
436
+ // `.goodvibes/memory/decisions.json`, not implied to be solved here.
437
+ const checkoutJournal: CheckoutJournal = new MemoryCheckoutJournal();
438
+ // The ONE checkout service instance this registration's begin/fillCard pair
439
+ // share for their whole life; see checkout-handlers.ts's own header.
440
+ const checkoutServiceHolder = new CheckoutServiceHolder(deps, checkoutJournal);
441
+
442
+ const cardsCreate: TypedHandler<unknown, Record<string, unknown>> = async ({ body }) => {
443
+ const params = asRecord(body);
444
+ const kind = readString(params, 'kind');
445
+ if (kind !== 'virtual' && kind !== 'real') {
446
+ throw invalid('kind', "must be 'virtual' or 'real'.");
447
+ }
448
+ const label = readString(params, 'label');
449
+ const number = readString(params, 'number');
450
+ // Narrower than the published input schema, which types these as a plain
451
+ // string and a plain number. Each check below is a property of BEING a card
452
+ // rather than a policy about one: a value that fails it could not be
453
+ // charged, and storing it would produce a card the surface offers and the
454
+ // checkout can never fill. None of them names anything but the field.
455
+ if (number.replace(/\D/g, '').length < 12) {
456
+ throw invalid('number', 'does not contain enough digits to be a card number.');
457
+ }
458
+ const expiryMonth = readInteger(params, 'expiryMonth', 1, 12);
459
+ // A full four-digit year: `cardFieldValue` derives the two-digit form by
460
+ // slicing this one, so a year stored as 29 would type as "29" in a
461
+ // four-digit field and as "29" in a two-digit one, and only one of those is
462
+ // right.
463
+ const expiryYear = readInteger(params, 'expiryYear', 1000, 9999);
464
+ const cvv = readString(params, 'cvv');
465
+ if (!/^[0-9]{3,4}$/.test(cvv)) {
466
+ throw invalid('cvv', 'must be the three or four digit code printed on the card.');
467
+ }
468
+ const cardholderName = readString(params, 'cardholderName');
469
+ const rawCap = params['issuerCapMinorUnits'];
470
+
471
+ const card = await service.createCard({
472
+ label,
473
+ kind,
474
+ number,
475
+ expiryMonth,
476
+ expiryYear,
477
+ cvv,
478
+ cardholderName,
479
+ issuerCapMinorUnits: typeof rawCap === 'number' && Number.isInteger(rawCap) ? rawCap : null,
480
+ });
481
+ return { card };
482
+ };
483
+
484
+ const purchasesList: TypedHandler<unknown, Record<string, unknown>> = async ({ body, query }) => {
485
+ const params = { ...query, ...asRecord(body) };
486
+ const requested = optionalCount(params['limit']);
487
+ const rawDay = params['dayKey'];
488
+ const dayKey = typeof rawDay === 'string' && rawDay.trim().length > 0 ? rawDay.trim() : undefined;
489
+ return service.listPurchases({
490
+ limit: Math.min(requested ?? DEFAULT_PURCHASE_LIST_LIMIT, MAX_PURCHASE_LIST_LIMIT),
491
+ dayKey,
492
+ });
493
+ };
494
+
495
+ const localTeardown = registerCatalogHandlers(catalog, [
496
+ { id: 'payments.cards.create', handler: cardsCreate as TypedHandler<unknown, unknown> },
497
+ { id: 'payments.purchases.list', handler: purchasesList as TypedHandler<unknown, unknown> },
498
+ { id: 'payments.checkout.begin', handler: checkoutBeginHandler(deps, checkoutServiceHolder) as TypedHandler<unknown, unknown> },
499
+ { id: 'payments.checkout.fillCard', handler: checkoutFillCardHandler(deps, checkoutServiceHolder) as TypedHandler<unknown, unknown> },
500
+ ]);
501
+
502
+ return () => {
503
+ localTeardown();
504
+ // Restores EVERY descriptor this module attached to, handler-less, not
505
+ // only the three `registerPaymentsGatewayMethods` still holds a live
506
+ // handler on. The other four had their descriptor removed outright by
507
+ // `localTeardown()` above (see the `descriptors` capture at the top of
508
+ // this function for why), so without this a re-registration on the SAME
509
+ // catalog (a second `registerPaymentsMethods` call, as a restart-without-
510
+ // recompose test does) would find those four ids gone from the catalog
511
+ // and throw `METHOD_NOT_FOUND` trying to attach to them, rather than
512
+ // finding the SDK's own builtin descriptor there to replace, exactly as
513
+ // it would on a catalog this module had never touched.
514
+ for (const [, descriptor] of descriptors) {
515
+ catalog.register(descriptor, undefined, { replace: true });
516
+ }
517
+ };
518
+ }
@@ -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