@pellux/goodvibes-daemon 1.28.20 → 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.
@@ -0,0 +1,526 @@
1
+ /**
2
+ * checkout-handlers.ts, `payments.checkout.begin` / `payments.checkout.fillCard`.
3
+ *
4
+ * Split out of register.ts for the 800-line file cap, the same reason
5
+ * daemon-handler-composition.ts/ci-watch-composition.ts exist as their own
6
+ * modules; no behavioural change from being here rather than there.
7
+ *
8
+ * ── Why these two verbs are local wrappers, not the SDK's own route ───────
9
+ *
10
+ * `registerPaymentsGatewayMethods`'s own `createPaymentsCheckoutBeginHandler`/
11
+ * `createPaymentsCheckoutFillCardHandler` call
12
+ * `service.beginCheckout(input)`/`service.fillCardIntoCheckout(input)` with no
13
+ * invocation context at all. This daemon's whole "approving a purchase is a
14
+ * distinct act" ruling (see `checkoutBeginHandler` below) needs
15
+ * `context.explicitUserRequest`, which only reaches a handler attached through
16
+ * this daemon's own `registerCatalogHandlers` (register.ts). So both verbs are
17
+ * attached there as local wrappers, reading and shaping the SAME wire shapes
18
+ * `routes/payments.ts` does (ported here rather than imported, since the SDK
19
+ * does not publish those parsing functions on their own), and calling into the
20
+ * ONE `PaymentsGatewayServiceImpl` a registration's checkout pair shares for
21
+ * its whole life (see `CheckoutServiceHolder` below for why one, not one per
22
+ * call).
23
+ */
24
+ import {
25
+ checkAddress,
26
+ CheckoutRegistryError,
27
+ PaymentsGatewayServiceImpl,
28
+ readPaymentsEnabled,
29
+ readPaymentsServiceConfig,
30
+ SHIPPING_TIERS,
31
+ } from '@pellux/goodvibes-sdk/platform/payments';
32
+ import type {
33
+ AddressStore,
34
+ CheckoutJournal,
35
+ MerchantJudgePort,
36
+ PaymentNotifier,
37
+ ShippingTier,
38
+ } from '@pellux/goodvibes-sdk/platform/payments';
39
+ import type { UntrustedContentLedger } from '@pellux/goodvibes-sdk/platform/security';
40
+ import type { BrowserCheckoutSeam } from '../contracts.ts';
41
+ import { HandlerError } from '../errors.ts';
42
+ import type { TypedHandler } from '../register.ts';
43
+ import type { DaemonCardStore } from './card-store.ts';
44
+ import type { PaymentsHandlerDeps } from './register.ts';
45
+
46
+ /**
47
+ * Everything the checkout pair needs beyond what the other five verbs use.
48
+ *
49
+ * Built by runtime/payments-composition.ts and handed here as one bundle
50
+ * because every field is checkout-only: nothing else in register.ts reads an
51
+ * address, sends a notice, judges a merchant, or reads the untrusted-content
52
+ * ledger.
53
+ */
54
+ export interface CheckoutComposition {
55
+ /**
56
+ * The browser-checkout seam, once `onBrowserCheckout` has fired.
57
+ *
58
+ * A GETTER, not a value: this composition is built and register.ts's
59
+ * handlers are registered before the browser composition runs (see
60
+ * runtime/browser-checkout-seam-holder.ts), so the seam is not there yet at
61
+ * REGISTRATION time and must be read fresh at CALL time. `undefined` means
62
+ * either "this daemon never builds a browser" (no home directory) or "not
63
+ * wired yet"; by the time any real invocation reaches this handler the
64
+ * daemon has finished booting and it is the former or nothing, and the
65
+ * handler refuses honestly either way.
66
+ */
67
+ readonly seam: () => BrowserCheckoutSeam | undefined;
68
+ readonly addresses: AddressStore;
69
+ readonly notifier: PaymentNotifier;
70
+ readonly merchantJudge: MerchantJudgePort;
71
+ /** The process-wide ledger; see routes/browser-composition.ts's header for why it must be shared, not private. */
72
+ readonly untrusted: UntrustedContentLedger;
73
+ }
74
+
75
+ function invalid(field: string, requirement: string): HandlerError {
76
+ return new HandlerError(`${field} ${requirement}`, 'INVALID_ARGUMENT', 400);
77
+ }
78
+
79
+ function asRecord(body: unknown): Record<string, unknown> {
80
+ return typeof body === 'object' && body !== null && !Array.isArray(body)
81
+ ? (body as Record<string, unknown>)
82
+ : {};
83
+ }
84
+
85
+ function requireString(value: unknown, field: string): string {
86
+ if (typeof value !== 'string' || value.trim().length === 0) throw invalid(field, 'is required.');
87
+ return value.trim();
88
+ }
89
+
90
+ function requireWholeNumber(value: unknown, field: string): number {
91
+ if (typeof value !== 'number' || !Number.isInteger(value)) throw invalid(field, 'must be a whole number.');
92
+ return value;
93
+ }
94
+
95
+ function optionalNonEmptyString(value: unknown): string | undefined {
96
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
97
+ }
98
+
99
+ /**
100
+ * Deliberately STRICTER than the sdk's own route (routes/payments.ts), which
101
+ * passes any non-empty `preferredTier` string through unvalidated. This is a
102
+ * deliberate pin, not an oversight relative to that route: a value outside
103
+ * `SHIPPING_TIERS` could not have come from a tier this daemon actually
104
+ * offers, so it is read as "not specified" rather than forwarded, and
105
+ * `beginCheckout` falls back to the configured preferred tier when this is
106
+ * undefined (payments-gateway-service.ts), the same safe default an absent
107
+ * field already gets.
108
+ */
109
+ function optionalShippingTier(value: unknown): ShippingTier | undefined {
110
+ return typeof value === 'string' && (SHIPPING_TIERS as readonly string[]).includes(value)
111
+ ? (value as ShippingTier)
112
+ : undefined;
113
+ }
114
+
115
+ function readObjectRows(value: unknown, field: string, required: boolean): Record<string, unknown>[] {
116
+ if (value === undefined && !required) return [];
117
+ if (!Array.isArray(value) || (required && value.length === 0)) {
118
+ throw invalid(field, 'is required and must be a non-empty array.');
119
+ }
120
+ return value.map((entry, index) => {
121
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
122
+ throw invalid(`${field}[${String(index)}]`, 'must be an object.');
123
+ }
124
+ return entry as Record<string, unknown>;
125
+ });
126
+ }
127
+
128
+ function readStringRows(value: unknown, field: string): string[] {
129
+ if (value === undefined) return [];
130
+ if (!Array.isArray(value)) throw invalid(field, 'must be an array of strings.');
131
+ return value.map((entry, index) => requireString(entry, `${field}[${String(index)}]`));
132
+ }
133
+
134
+ /** The exact shape `PaymentsGatewayServiceImpl.beginCheckout` wants, read off the wire. */
135
+ function parseBeginCheckoutInput(params: Record<string, unknown>): Parameters<PaymentsGatewayServiceImpl['beginCheckout']>[0] {
136
+ const requestedLines = readObjectRows(params['requestedLines'], 'requestedLines', true).map((entry, index) => ({
137
+ label: requireString(entry['label'], `requestedLines[${String(index)}].label`),
138
+ quantity: requireWholeNumber(entry['quantity'], `requestedLines[${String(index)}].quantity`),
139
+ }));
140
+ const lines = readObjectRows(params['lines'], 'lines', true).map((entry, index) => ({
141
+ label: requireString(entry['label'], `lines[${String(index)}].label`),
142
+ quantity: requireString(entry['quantity'], `lines[${String(index)}].quantity`),
143
+ unitPrice: requireString(entry['unitPrice'], `lines[${String(index)}].unitPrice`),
144
+ }));
145
+ const fees = readObjectRows(params['fees'], 'fees', false).map((entry, index) => ({
146
+ label: requireString(entry['label'], `fees[${String(index)}].label`),
147
+ amount: requireString(entry['amount'], `fees[${String(index)}].amount`),
148
+ }));
149
+ const shippingOptions = readObjectRows(params['shippingOptions'], 'shippingOptions', true).map((entry, index) => ({
150
+ label: requireString(entry['label'], `shippingOptions[${String(index)}].label`),
151
+ cost: requireString(entry['cost'], `shippingOptions[${String(index)}].cost`),
152
+ }));
153
+ const cardFields = readObjectRows(params['cardFields'], 'cardFields', true).map((entry, index) => ({
154
+ field: requireString(entry['field'], `cardFields[${String(index)}].field`),
155
+ ref: requireString(entry['ref'], `cardFields[${String(index)}].ref`),
156
+ }));
157
+ const addressFields = readObjectRows(params['addressFields'], 'addressFields', false).map((entry, index) => ({
158
+ kind: requireString(entry['kind'], `addressFields[${String(index)}].kind`),
159
+ field: requireString(entry['field'], `addressFields[${String(index)}].field`),
160
+ ref: requireString(entry['ref'], `addressFields[${String(index)}].ref`),
161
+ }));
162
+ const twoDigit = params['twoDigitYear'];
163
+ return {
164
+ sessionId: requireString(params['sessionId'], 'sessionId'),
165
+ pageId: requireString(params['pageId'], 'pageId'),
166
+ merchantDomain: requireString(params['merchantDomain'], 'merchantDomain'),
167
+ checkoutUrl: requireString(params['checkoutUrl'], 'checkoutUrl'),
168
+ item: requireString(params['item'], 'item'),
169
+ cardId: requireString(params['cardId'], 'cardId'),
170
+ requestedLines,
171
+ reading: {
172
+ lines,
173
+ tax: optionalNonEmptyString(params['tax']) ?? null,
174
+ fees,
175
+ shippingOptions,
176
+ statedTotal: optionalNonEmptyString(params['statedTotal']) ?? null,
177
+ currency: optionalNonEmptyString(params['currency']) ?? null,
178
+ orderSummaryText: typeof params['orderSummaryText'] === 'string' ? params['orderSummaryText'] : '',
179
+ },
180
+ controls: {
181
+ cardFields,
182
+ addressFields,
183
+ shippingTargets: readStringRows(params['shippingTargets'], 'shippingTargets'),
184
+ placeOrderTarget: requireString(params['placeOrderTarget'], 'placeOrderTarget'),
185
+ expirySeparator: optionalNonEmptyString(params['expirySeparator']),
186
+ twoDigitYear: typeof twoDigit === 'boolean' ? twoDigit : undefined,
187
+ },
188
+ preferredTier: optionalShippingTier(params['preferredTier']),
189
+ requestedMax: optionalNonEmptyString(params['requestedMax']),
190
+ // The sdk's own route (routes/payments.ts) never reads this field off the
191
+ // wire at all: `PaymentBeginCheckoutInput` has no `merchantDiscovered`
192
+ // property, and `service.beginCheckout` is called with it simply absent,
193
+ // which `checkout-flow.ts` then defaults to false. This daemon matches
194
+ // that byte for byte rather than trusting a caller-supplied flag:
195
+ // `merchantDiscovered` skips the taint check on the merchant and the
196
+ // checkout url (taint-gate.ts), and nothing on this wire path can attest
197
+ // that a page was actually browsed to rather than simply named by
198
+ // whoever is calling. A future attested-discovery flow may reintroduce
199
+ // this deliberately, with its own provenance, not as a bare wire field.
200
+ merchantDiscovered: false,
201
+ };
202
+ }
203
+
204
+ /** The exact shape `PaymentsGatewayServiceImpl.fillCardIntoCheckout` wants, read off the wire. */
205
+ function parseFillCardInput(params: Record<string, unknown>): Parameters<PaymentsGatewayServiceImpl['fillCardIntoCheckout']>[0] {
206
+ const rawTargets = params['targets'];
207
+ if (!Array.isArray(rawTargets) || rawTargets.length === 0) {
208
+ throw invalid('targets', 'is required: name each card field you found and the ref to type it into.');
209
+ }
210
+ const targets = rawTargets.map((entry, index) => {
211
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
212
+ throw invalid(`targets[${String(index)}]`, 'must be an object.');
213
+ }
214
+ const record = entry as Record<string, unknown>;
215
+ return {
216
+ field: requireString(record['field'], `targets[${String(index)}].field`),
217
+ ref: requireString(record['ref'], `targets[${String(index)}].ref`),
218
+ };
219
+ });
220
+ // Deliberately a plain `typeof` check, not `optionalNonEmptyString`: the
221
+ // sdk's own fillCard route (routes/payments.ts) keeps an EMPTY separator
222
+ // distinct from an ABSENT one (a caller that says "" is asking for the
223
+ // digits run together, `0729`, and one that says nothing gets the default
224
+ // `/`), and `optionalNonEmptyString` would collapse both to `undefined`.
225
+ // `parseBeginCheckoutInput`'s own `expirySeparator` field stays on
226
+ // `optionalNonEmptyString`, matching the sdk's begin route instead, which
227
+ // does the same collapse there.
228
+ const separator = params['expirySeparator'];
229
+ return {
230
+ sessionId: requireString(params['sessionId'], 'sessionId'),
231
+ pageId: requireString(params['pageId'], 'pageId'),
232
+ targets,
233
+ expirySeparator: typeof separator === 'string' ? separator : undefined,
234
+ twoDigitYear: typeof params['twoDigitYear'] === 'boolean' ? params['twoDigitYear'] : undefined,
235
+ };
236
+ }
237
+
238
+ /**
239
+ * Allowlisted, the same reason `sanitizeFillResult` exists in the SDK's own
240
+ * route module: a service bug becomes a missing field here, never a leaked
241
+ * one, and this wrapper bypasses that route entirely, so it is this file's job
242
+ * to keep the property.
243
+ */
244
+ function fillCardResultView(result: Awaited<ReturnType<PaymentsGatewayServiceImpl['fillCardIntoCheckout']>>): Record<string, unknown> {
245
+ return {
246
+ ok: result.ok === true,
247
+ filled: (result.filled ?? []).map((field) => String(field)),
248
+ failedField: result.failedField === null || result.failedField === undefined ? null : String(result.failedField),
249
+ reason: result.reason === null || result.reason === undefined ? null : String(result.reason),
250
+ };
251
+ }
252
+
253
+ /**
254
+ * Whether the named card can actually be charged: configured, and its
255
+ * material present in the secret store. Computed here, outside
256
+ * `PaymentsGatewayServiceImpl`, because `GateInput` is a plain synchronous
257
+ * record and this daemon's card-material check is async.
258
+ */
259
+ async function hasUsableCard(cards: DaemonCardStore, cardId: string): Promise<boolean> {
260
+ if (cardId.length === 0) return false;
261
+ const metadata = await cards.metadata(cardId);
262
+ if (metadata === null) return false;
263
+ return cards.materialComplete(cardId);
264
+ }
265
+
266
+ /** Same reasoning as `hasUsableCard`: an async read, resolved before `GateInput` is built. */
267
+ async function hasShippingAddress(addresses: AddressStore): Promise<boolean> {
268
+ const stored = await addresses.read('shipping');
269
+ return checkAddress(stored, 'shipping').ok;
270
+ }
271
+
272
+ /**
273
+ * The per-invocation gate facts the shared checkout service's `gates()`
274
+ * closure reads. A mutable CELL, not a value passed at construction: the
275
+ * service is built ONCE (see `CheckoutServiceHolder` below) and its `gates()`
276
+ * closure is called synchronously, on every `beginCheckout` call, from deep
277
+ * inside that one shared instance, so the only way for it to see THIS call's
278
+ * facts is to read them from somewhere written just before this call and nowhere
279
+ * held between calls.
280
+ *
281
+ * Safe under concurrent calls despite being shared, mutable state, because of
282
+ * WHEN it is read: `checkoutBeginHandler` writes it and then, in the same
283
+ * synchronous span with no `await` between the write and the call, invokes
284
+ * `service.beginCheckout(input)`. `beginCheckout`'s own body runs synchronously
285
+ * up to its first internal `await` (see payments-gateway-service.ts), and
286
+ * `gates()` is invoked inside that synchronous prefix, so the value it reads is
287
+ * always the one THIS call just wrote, captured into a plain `GateInput` object
288
+ * before control ever returns to the event loop. A second call writing the cell
289
+ * later cannot land between the write and the read of an earlier one; only
290
+ * between two DIFFERENT calls' write-then-read pairs, which never interleave
291
+ * with each other's.
292
+ */
293
+ interface CheckoutGateInputsCell {
294
+ current: { readonly hasUsableCard: boolean; readonly hasShippingAddress: boolean; readonly isOwnerDirectRequest: boolean };
295
+ }
296
+
297
+ const NO_GATE_INPUTS_YET = { hasUsableCard: false, hasShippingAddress: false, isOwnerDirectRequest: false };
298
+
299
+ /**
300
+ * Build the ONE `PaymentsGatewayServiceImpl` a registration's checkout pair
301
+ * shares for its whole life. See `CheckoutServiceHolder` for why one, not one
302
+ * per call.
303
+ */
304
+ function buildCheckoutService(
305
+ deps: PaymentsHandlerDeps,
306
+ seam: BrowserCheckoutSeam,
307
+ journal: CheckoutJournal,
308
+ gateInputs: CheckoutGateInputsCell,
309
+ ): PaymentsGatewayServiceImpl {
310
+ return new PaymentsGatewayServiceImpl({
311
+ cards: deps.cards,
312
+ addresses: deps.checkout.addresses,
313
+ ledger: deps.budget,
314
+ purchases: deps.purchases,
315
+ notifier: deps.checkout.notifier,
316
+ untrusted: deps.checkout.untrusted,
317
+ journal,
318
+ merchantJudge: deps.checkout.merchantJudge,
319
+ driverFor: seam.driverFor,
320
+ cardFieldGuard: seam.cardFieldGuard,
321
+ gates: () => ({
322
+ enabled: readPaymentsEnabled(deps.config),
323
+ isPaymentsLeader: deps.isPaymentsLeader(),
324
+ ...gateInputs.current,
325
+ }),
326
+ config: () => readPaymentsServiceConfig(deps.config),
327
+ ...(deps.now ? { now: deps.now } : {}),
328
+ });
329
+ }
330
+
331
+ /**
332
+ * Holds the ONE `PaymentsGatewayServiceImpl` a registration's checkout pair
333
+ * shares for the life of the registration, and the gate-input cell its
334
+ * `gates()` closure reads.
335
+ *
336
+ * ── Why one instance, not one per call ─────────────────────────────────────
337
+ *
338
+ * `PaymentsGatewayServiceImpl` builds its own `CheckoutRegistry` in its
339
+ * constructor (`this.registry = new CheckoutRegistry(deps.journal)`,
340
+ * payments-gateway-service.ts), and that registry's live "which page has a
341
+ * purchase open" map (`byPage`) is IN-MEMORY, per-instance state, not
342
+ * recovered from the journal at construction. The sdk's own header names the
343
+ * property this holder exists to keep: "`begin` opens a checkout and
344
+ * `fillCard` completes one, and they are separate verbs arriving as separate
345
+ * control-plane calls. The in-flight registry has to outlive both, so it
346
+ * lives here for the life of the service rather than being constructed per
347
+ * call." A fresh service (and therefore a fresh, empty registry) built on
348
+ * every call cannot keep that promise: two `begin` calls on the same page each
349
+ * get their own empty map, so the registry's own duplicate guard
350
+ * (`CheckoutRegistry.open` refuses a second open on a page already running
351
+ * one) never fires, and a `fillCard` call afterward finds an empty map too, so
352
+ * it always refuses "no purchase decision is in flight" even for a page that
353
+ * genuinely has one, exactly the honest-sounding but wrong refusal a real
354
+ * caller and a nonexistent one would both get.
355
+ *
356
+ * ── Why memoized on first use, not built at registration ───────────────────
357
+ *
358
+ * `PaymentsGatewayServiceImpl` needs a concrete `BrowserCheckoutSeam` (for
359
+ * `driverFor` and `cardFieldGuard`) to construct, and `deps.checkout.seam()`
360
+ * may still return `undefined` at the moment `registerPaymentsMethods` runs
361
+ * (see `CheckoutComposition.seam`'s own doc comment: the browser composition
362
+ * that fills it runs AFTER this daemon's handlers are registered). So the
363
+ * instance is built lazily, on the first call that finds a real seam, and
364
+ * cached from then on. Safe to cache permanently: `onBrowserCheckout` fires at
365
+ * most once per daemon process (browser-checkout-seam-holder.ts), so once a
366
+ * real seam has been seen it is THE seam for the rest of this registration's
367
+ * life.
368
+ */
369
+ export class CheckoutServiceHolder {
370
+ private service: PaymentsGatewayServiceImpl | null = null;
371
+ readonly gateInputs: CheckoutGateInputsCell = { current: NO_GATE_INPUTS_YET };
372
+
373
+ constructor(
374
+ private readonly deps: PaymentsHandlerDeps,
375
+ private readonly journal: CheckoutJournal,
376
+ ) {}
377
+
378
+ serviceFor(seam: BrowserCheckoutSeam): PaymentsGatewayServiceImpl {
379
+ this.service ??= buildCheckoutService(this.deps, seam, this.journal, this.gateInputs);
380
+ return this.service;
381
+ }
382
+ }
383
+
384
+ const CHECKOUT_UNAVAILABLE_MESSAGE =
385
+ 'Checkout is not available on this daemon right now: no browser is composed for it (no home directory '
386
+ + 'configured, or the browser composition has not finished starting). Retry once the daemon has finished '
387
+ + 'booting; if this persists, the daemon was started without a home directory to keep browser profiles in.';
388
+
389
+ /** The sdk's own explicit nine-field projection (routes/payments.ts's `createPaymentsCheckoutBeginHandler`), not a spread. */
390
+ function beginResultView(result: Awaited<ReturnType<PaymentsGatewayServiceImpl['beginCheckout']>>): Record<string, unknown> {
391
+ return {
392
+ outcome: String(result.outcome),
393
+ purchaseId: result.purchaseId ?? null,
394
+ reason: result.reason ?? null,
395
+ merchantOrderId: result.merchantOrderId ?? null,
396
+ totalMinorUnits: result.totalMinorUnits ?? null,
397
+ currency: result.currency ?? null,
398
+ shippingTierUsed: result.shippingTierUsed ?? null,
399
+ steppedDown: result.steppedDown === true,
400
+ challengeStep: result.challengeStep ?? null,
401
+ };
402
+ }
403
+
404
+ /**
405
+ * `payments.checkout.begin`.
406
+ *
407
+ * ── What actually gates a purchase here ────────────────────────────────────
408
+ *
409
+ * `context.explicitUserRequest` is a caller-set header/frame field
410
+ * (`x-goodvibes-explicit-user-request`; see the sdk's
411
+ * `routes/explicit-user-request.ts` and `normalizeContext` in
412
+ * `daemon/handlers/register.ts`), no stronger a claim than `confirm: true` on
413
+ * any other confirmation-gated verb in this daemon. It gates ENTRY to this
414
+ * verb, `isOwnerDirectRequest` in the `GateInput` `checkPaymentGates` reads
415
+ * (gates.ts), and nothing about the submit itself. It also resets the
416
+ * untrusted-content watermark the taint gates read
417
+ * (`security/turn-boundary.ts`), a second, separate effect of the same
418
+ * caller-set claim, not something this handler arranges.
419
+ *
420
+ * This composition used to also arm `seam.armSubmitApproval` here, minting an
421
+ * `OwnerApproval` for a `'payments.checkout.submit'` action whenever
422
+ * `explicitUserRequest` was true. That mechanism has been deleted: it never
423
+ * actually did anything. The browser engine only ever checks an armed
424
+ * approval's action against the literal string `'browser.submit'`
425
+ * (browser-engine.ts), so an approval minted for `'payments.checkout.submit'`
426
+ * never matched it and the check always fell through to `different-action`.
427
+ * Even with the names made to agree, an approval minted here with no
428
+ * `content` argument carries `contentFingerprint: null`, the WEAK form
429
+ * (`owner-approval.ts`), which clears only a refusal that was itself made
430
+ * without content and never a content-derivation finding, so repairing the
431
+ * name would still not have cleared anything real. A distinct, genuine
432
+ * owner-approval record for a purchase, minted from a separate interactive
433
+ * act and bound to the exact payload approved, is real future work (see
434
+ * `.goodvibes/memory/decisions.json`), and a mechanism that LOOKED like that
435
+ * record while clearing nothing was worse than having none.
436
+ *
437
+ * The real money controls, downstream of this gate, are the sdk's own
438
+ * checkout ladder: the budget ledger (RESERVE, step 5), the purchase notices
439
+ * and their approval/veto decision windows (NOTICE + WINDOW, step 6), and the
440
+ * card-material guard (`cardFieldGuard`, armed only immediately before
441
+ * typing, never before). See `checkout-flow.ts`'s own header for the full
442
+ * order.
443
+ */
444
+ export function checkoutBeginHandler(deps: PaymentsHandlerDeps, holder: CheckoutServiceHolder): TypedHandler<unknown, Record<string, unknown>> {
445
+ return async ({ body, context }) => {
446
+ const params = asRecord(body);
447
+ const input = parseBeginCheckoutInput(params);
448
+
449
+ const seam = deps.checkout.seam();
450
+ if (seam === undefined) {
451
+ throw new HandlerError(CHECKOUT_UNAVAILABLE_MESSAGE, 'FAILED_PRECONDITION', 409);
452
+ }
453
+
454
+ const [usableCard, shippingAddress] = await Promise.all([
455
+ hasUsableCard(deps.cards, input.cardId),
456
+ hasShippingAddress(deps.checkout.addresses),
457
+ ]);
458
+
459
+ // Written immediately before the call it applies to, with no `await`
460
+ // between: see `CheckoutGateInputsCell`'s own doc comment for why that
461
+ // ordering is what keeps this safe under concurrent calls.
462
+ holder.gateInputs.current = {
463
+ hasUsableCard: usableCard,
464
+ hasShippingAddress: shippingAddress,
465
+ isOwnerDirectRequest: context.explicitUserRequest,
466
+ };
467
+ const service = holder.serviceFor(seam);
468
+
469
+ try {
470
+ const result = await service.beginCheckout(input);
471
+ return beginResultView(result);
472
+ } catch (error) {
473
+ // `CheckoutRegistryError` (a second `begin` finding one already in
474
+ // flight on this page) is the owner's business and carries no card
475
+ // material, so it is forwarded, the same containment shape
476
+ // `checkoutFillCardHandler` gives `FillCardRefusal` below. Anything else
477
+ // is discarded: the failing call had the card in its arguments, and an
478
+ // error string is a read path like any other.
479
+ if (error instanceof CheckoutRegistryError) {
480
+ throw new HandlerError(error.message, 'FAILED_PRECONDITION', 409);
481
+ }
482
+ void error;
483
+ throw new HandlerError('Beginning this checkout failed. Nothing was submitted.', 'INTERNAL_ERROR', 500);
484
+ }
485
+ };
486
+ }
487
+
488
+ /**
489
+ * `payments.checkout.fillCard`.
490
+ *
491
+ * No submit approval and no `gates()` reasoning: `fillCardIntoCheckout` never
492
+ * consults either (it types into fields, it does not click a submit control),
493
+ * so this handler's only checkout-specific concern is the same seam
494
+ * availability check `checkoutBeginHandler` makes. It never writes
495
+ * `holder.gateInputs`: whatever a prior `begin` call on this same holder left
496
+ * there (or the `NO_GATE_INPUTS_YET` default, if none ever ran) is simply
497
+ * never read by a fill.
498
+ */
499
+ export function checkoutFillCardHandler(deps: PaymentsHandlerDeps, holder: CheckoutServiceHolder): TypedHandler<unknown, Record<string, unknown>> {
500
+ return async ({ body }) => {
501
+ const params = asRecord(body);
502
+ const input = parseFillCardInput(params);
503
+
504
+ const seam = deps.checkout.seam();
505
+ if (seam === undefined) {
506
+ throw new HandlerError(CHECKOUT_UNAVAILABLE_MESSAGE, 'FAILED_PRECONDITION', 409);
507
+ }
508
+
509
+ const service = holder.serviceFor(seam);
510
+
511
+ try {
512
+ const result = await service.fillCardIntoCheckout(input);
513
+ return fillCardResultView(result);
514
+ } catch (error) {
515
+ // A `FillCardRefusal` is the owner's business and carries no material
516
+ // (fill-card.ts's own contract), so it is forwarded; anything else is
517
+ // discarded, the failing call had the card in its stack, and an error
518
+ // string is a read path like any other.
519
+ if (error instanceof Error && error.name === 'FillCardRefusal') {
520
+ throw new HandlerError(error.message, 'INVALID_ARGUMENT', 400);
521
+ }
522
+ void error;
523
+ throw new HandlerError('Filling the card into this checkout failed. Nothing was submitted.', 'INTERNAL_ERROR', 500);
524
+ }
525
+ };
526
+ }
@@ -24,9 +24,15 @@ export type {
24
24
  export { DaemonPurchaseLedger, MAX_PURCHASE_LIST_LIMIT } from './purchase-ledger.ts';
25
25
  export type { DaemonPurchaseLedgerOptions, PurchaseListQuery, StoredPurchase } from './purchase-ledger.ts';
26
26
 
27
+ export { DurableBudgetLedger } from './budget-store.ts';
28
+
29
+ export { configBackedAddressStore } from './address-store.ts';
30
+ export { channelBackedPaymentNotifier } from './notifier.ts';
31
+ export { createProviderBackedMerchantJudgeModel } from './merchant-judge.ts';
32
+
27
33
  export {
28
34
  ATTACHED_PAYMENTS_METHOD_IDS,
29
35
  UNATTACHED_PAYMENTS_METHOD_IDS,
30
36
  registerPaymentsMethods,
31
37
  } from './register.ts';
32
- export type { PaymentsHandlerDeps } from './register.ts';
38
+ export type { CheckoutComposition, PaymentsHandlerDeps } from './register.ts';
@@ -0,0 +1,57 @@
1
+ /**
2
+ * merchant-judge.ts, the merchant-recourse judgement, answered by this
3
+ * daemon's own configured model.
4
+ *
5
+ * `createModelMerchantJudge` (platform/payments) wants a `MerchantJudgeModel`,
6
+ * one method, `chat(task, prompt, options)`. This adapts the daemon's
7
+ * `ProviderRegistry` to that shape, the same pattern
8
+ * `createProviderBackedCheckinJudge` (platform/checkin) already uses for the
9
+ * proactive check-in judge: resolve the currently configured model, ask its
10
+ * provider, and treat any failure as "no judgement available" rather than as a
11
+ * thrown error, because `createModelMerchantJudge` already reads a null/failed
12
+ * chat as an honest "I could not judge this merchant" verdict (unqualified,
13
+ * unconfident), never as a reason to fail the purchase in some OTHER way.
14
+ */
15
+ import type { ProviderRegistry } from '@pellux/goodvibes-sdk/platform/providers';
16
+ import type { MerchantJudgeModel } from '@pellux/goodvibes-sdk/platform/payments';
17
+ import { logger } from '@pellux/goodvibes-sdk/platform/utils';
18
+
19
+ /** How long the merchant judge waits for the model before giving up. */
20
+ const MERCHANT_JUDGE_TIMEOUT_MS = 20_000;
21
+
22
+ export function createProviderBackedMerchantJudgeModel(
23
+ providerRegistry: Pick<ProviderRegistry, 'getCurrentModel' | 'getForModel'>,
24
+ ): MerchantJudgeModel {
25
+ return {
26
+ async chat(task, prompt, options) {
27
+ const controller = new AbortController();
28
+ const timer = setTimeout(() => controller.abort(), MERCHANT_JUDGE_TIMEOUT_MS);
29
+ timer.unref?.();
30
+ try {
31
+ const current = providerRegistry.getCurrentModel();
32
+ const provider = providerRegistry.getForModel(current.registryKey, current.provider);
33
+ const response = await provider.chat({
34
+ model: current.id,
35
+ messages: [{ role: 'user', content: prompt }],
36
+ ...(options.systemPrompt !== undefined ? { systemPrompt: options.systemPrompt } : {}),
37
+ ...(options.maxTokens !== undefined ? { maxTokens: options.maxTokens } : {}),
38
+ reasoningEffort: 'low',
39
+ signal: controller.signal,
40
+ });
41
+ return response.content ?? null;
42
+ } catch (error) {
43
+ // `createModelMerchantJudge` treats a null answer as "I could not judge
44
+ // this merchant", the safe direction (see merchant-judge-model.ts's
45
+ // header: an unjudgeable domain must never make spending MORE
46
+ // automatic). The task name rides along only for the operator log.
47
+ logger.warn('Merchant judge model call failed; the purchase proceeds as unjudged', {
48
+ task,
49
+ error: error instanceof Error ? error.message : String(error),
50
+ });
51
+ return null;
52
+ } finally {
53
+ clearTimeout(timer);
54
+ }
55
+ },
56
+ };
57
+ }