@pellux/goodvibes-daemon 1.28.20 → 1.28.22

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,678 @@
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 { DaemonApprovalStore } from './approval-store.ts';
44
+ import type { DaemonCardStore } from './card-store.ts';
45
+ import type { PaymentsHandlerDeps } from './register.ts';
46
+
47
+ /**
48
+ * Everything the checkout pair needs beyond what the other five verbs use.
49
+ *
50
+ * Built by runtime/payments-composition.ts and handed here as one bundle
51
+ * because every field is checkout-only: nothing else in register.ts reads an
52
+ * address, sends a notice, judges a merchant, or reads the untrusted-content
53
+ * ledger.
54
+ */
55
+ export interface CheckoutComposition {
56
+ /**
57
+ * The browser-checkout seam, once `onBrowserCheckout` has fired.
58
+ *
59
+ * A GETTER, not a value: this composition is built and register.ts's
60
+ * handlers are registered before the browser composition runs (see
61
+ * runtime/browser-checkout-seam-holder.ts), so the seam is not there yet at
62
+ * REGISTRATION time and must be read fresh at CALL time. `undefined` means
63
+ * either "this daemon never builds a browser" (no home directory) or "not
64
+ * wired yet"; by the time any real invocation reaches this handler the
65
+ * daemon has finished booting and it is the former or nothing, and the
66
+ * handler refuses honestly either way.
67
+ */
68
+ readonly seam: () => BrowserCheckoutSeam | undefined;
69
+ readonly addresses: AddressStore;
70
+ readonly notifier: PaymentNotifier;
71
+ readonly merchantJudge: MerchantJudgePort;
72
+ /** The process-wide ledger; see routes/browser-composition.ts's header for why it must be shared, not private. */
73
+ readonly untrusted: UntrustedContentLedger;
74
+ /**
75
+ * The persisted, single-use approvals `payments.checkout.approve` mints and
76
+ * `payments.checkout.begin` spends. See approval-store.ts for the four
77
+ * properties the store keeps, and `checkoutBeginHandler` below for where
78
+ * one is consumed.
79
+ */
80
+ readonly approvals: DaemonApprovalStore;
81
+ /**
82
+ * The journal the shared service's in-flight registry writes through. The
83
+ * real daemon composes `DurableCheckoutJournal`
84
+ * (checkout-journal-store.ts) so a `submit-pending` record survives a
85
+ * restart; tests may compose the SDK's `MemoryCheckoutJournal`.
86
+ */
87
+ readonly journal: CheckoutJournal;
88
+ }
89
+
90
+ function invalid(field: string, requirement: string): HandlerError {
91
+ return new HandlerError(`${field} ${requirement}`, 'INVALID_ARGUMENT', 400);
92
+ }
93
+
94
+ function asRecord(body: unknown): Record<string, unknown> {
95
+ return typeof body === 'object' && body !== null && !Array.isArray(body)
96
+ ? (body as Record<string, unknown>)
97
+ : {};
98
+ }
99
+
100
+ function requireString(value: unknown, field: string): string {
101
+ if (typeof value !== 'string' || value.trim().length === 0) throw invalid(field, 'is required.');
102
+ return value.trim();
103
+ }
104
+
105
+ function requireWholeNumber(value: unknown, field: string): number {
106
+ if (typeof value !== 'number' || !Number.isInteger(value)) throw invalid(field, 'must be a whole number.');
107
+ return value;
108
+ }
109
+
110
+ function optionalNonEmptyString(value: unknown): string | undefined {
111
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
112
+ }
113
+
114
+ /**
115
+ * Deliberately STRICTER than the sdk's own route (routes/payments.ts), which
116
+ * passes any non-empty `preferredTier` string through unvalidated. This is a
117
+ * deliberate pin, not an oversight relative to that route: a value outside
118
+ * `SHIPPING_TIERS` could not have come from a tier this daemon actually
119
+ * offers, so it is read as "not specified" rather than forwarded, and
120
+ * `beginCheckout` falls back to the configured preferred tier when this is
121
+ * undefined (payments-gateway-service.ts), the same safe default an absent
122
+ * field already gets.
123
+ */
124
+ function optionalShippingTier(value: unknown): ShippingTier | undefined {
125
+ return typeof value === 'string' && (SHIPPING_TIERS as readonly string[]).includes(value)
126
+ ? (value as ShippingTier)
127
+ : undefined;
128
+ }
129
+
130
+ function readObjectRows(value: unknown, field: string, required: boolean): Record<string, unknown>[] {
131
+ if (value === undefined && !required) return [];
132
+ if (!Array.isArray(value) || (required && value.length === 0)) {
133
+ throw invalid(field, 'is required and must be a non-empty array.');
134
+ }
135
+ return value.map((entry, index) => {
136
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
137
+ throw invalid(`${field}[${String(index)}]`, 'must be an object.');
138
+ }
139
+ return entry as Record<string, unknown>;
140
+ });
141
+ }
142
+
143
+ function readStringRows(value: unknown, field: string): string[] {
144
+ if (value === undefined) return [];
145
+ if (!Array.isArray(value)) throw invalid(field, 'must be an array of strings.');
146
+ return value.map((entry, index) => requireString(entry, `${field}[${String(index)}]`));
147
+ }
148
+
149
+ /** The exact shape `PaymentsGatewayServiceImpl.beginCheckout` wants, read off the wire. */
150
+ function parseBeginCheckoutInput(params: Record<string, unknown>): Parameters<PaymentsGatewayServiceImpl['beginCheckout']>[0] {
151
+ const requestedLines = readObjectRows(params['requestedLines'], 'requestedLines', true).map((entry, index) => ({
152
+ label: requireString(entry['label'], `requestedLines[${String(index)}].label`),
153
+ quantity: requireWholeNumber(entry['quantity'], `requestedLines[${String(index)}].quantity`),
154
+ }));
155
+ const lines = readObjectRows(params['lines'], 'lines', true).map((entry, index) => ({
156
+ label: requireString(entry['label'], `lines[${String(index)}].label`),
157
+ quantity: requireString(entry['quantity'], `lines[${String(index)}].quantity`),
158
+ unitPrice: requireString(entry['unitPrice'], `lines[${String(index)}].unitPrice`),
159
+ }));
160
+ const fees = readObjectRows(params['fees'], 'fees', false).map((entry, index) => ({
161
+ label: requireString(entry['label'], `fees[${String(index)}].label`),
162
+ amount: requireString(entry['amount'], `fees[${String(index)}].amount`),
163
+ }));
164
+ const shippingOptions = readObjectRows(params['shippingOptions'], 'shippingOptions', true).map((entry, index) => ({
165
+ label: requireString(entry['label'], `shippingOptions[${String(index)}].label`),
166
+ cost: requireString(entry['cost'], `shippingOptions[${String(index)}].cost`),
167
+ }));
168
+ const cardFields = readObjectRows(params['cardFields'], 'cardFields', true).map((entry, index) => ({
169
+ field: requireString(entry['field'], `cardFields[${String(index)}].field`),
170
+ ref: requireString(entry['ref'], `cardFields[${String(index)}].ref`),
171
+ }));
172
+ const addressFields = readObjectRows(params['addressFields'], 'addressFields', false).map((entry, index) => ({
173
+ kind: requireString(entry['kind'], `addressFields[${String(index)}].kind`),
174
+ field: requireString(entry['field'], `addressFields[${String(index)}].field`),
175
+ ref: requireString(entry['ref'], `addressFields[${String(index)}].ref`),
176
+ }));
177
+ const twoDigit = params['twoDigitYear'];
178
+ return {
179
+ sessionId: requireString(params['sessionId'], 'sessionId'),
180
+ pageId: requireString(params['pageId'], 'pageId'),
181
+ merchantDomain: requireString(params['merchantDomain'], 'merchantDomain'),
182
+ checkoutUrl: requireString(params['checkoutUrl'], 'checkoutUrl'),
183
+ item: requireString(params['item'], 'item'),
184
+ cardId: requireString(params['cardId'], 'cardId'),
185
+ requestedLines,
186
+ reading: {
187
+ lines,
188
+ tax: optionalNonEmptyString(params['tax']) ?? null,
189
+ fees,
190
+ shippingOptions,
191
+ statedTotal: optionalNonEmptyString(params['statedTotal']) ?? null,
192
+ currency: optionalNonEmptyString(params['currency']) ?? null,
193
+ orderSummaryText: typeof params['orderSummaryText'] === 'string' ? params['orderSummaryText'] : '',
194
+ },
195
+ controls: {
196
+ cardFields,
197
+ addressFields,
198
+ shippingTargets: readStringRows(params['shippingTargets'], 'shippingTargets'),
199
+ placeOrderTarget: requireString(params['placeOrderTarget'], 'placeOrderTarget'),
200
+ expirySeparator: optionalNonEmptyString(params['expirySeparator']),
201
+ twoDigitYear: typeof twoDigit === 'boolean' ? twoDigit : undefined,
202
+ },
203
+ preferredTier: optionalShippingTier(params['preferredTier']),
204
+ requestedMax: optionalNonEmptyString(params['requestedMax']),
205
+ // The sdk's own route (routes/payments.ts) never reads this field off the
206
+ // wire at all: `PaymentBeginCheckoutInput` has no `merchantDiscovered`
207
+ // property, and `service.beginCheckout` is called with it simply absent,
208
+ // which `checkout-flow.ts` then defaults to false. This daemon matches
209
+ // that byte for byte rather than trusting a caller-supplied flag:
210
+ // `merchantDiscovered` skips the taint check on the merchant and the
211
+ // checkout url (taint-gate.ts), and nothing on this wire path can attest
212
+ // that a page was actually browsed to rather than simply named by
213
+ // whoever is calling. A future attested-discovery flow may reintroduce
214
+ // this deliberately, with its own provenance, not as a bare wire field.
215
+ merchantDiscovered: false,
216
+ };
217
+ }
218
+
219
+ /** The exact shape `PaymentsGatewayServiceImpl.fillCardIntoCheckout` wants, read off the wire. */
220
+ function parseFillCardInput(params: Record<string, unknown>): Parameters<PaymentsGatewayServiceImpl['fillCardIntoCheckout']>[0] {
221
+ const rawTargets = params['targets'];
222
+ if (!Array.isArray(rawTargets) || rawTargets.length === 0) {
223
+ throw invalid('targets', 'is required: name each card field you found and the ref to type it into.');
224
+ }
225
+ const targets = rawTargets.map((entry, index) => {
226
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
227
+ throw invalid(`targets[${String(index)}]`, 'must be an object.');
228
+ }
229
+ const record = entry as Record<string, unknown>;
230
+ return {
231
+ field: requireString(record['field'], `targets[${String(index)}].field`),
232
+ ref: requireString(record['ref'], `targets[${String(index)}].ref`),
233
+ };
234
+ });
235
+ // Deliberately a plain `typeof` check, not `optionalNonEmptyString`: the
236
+ // sdk's own fillCard route (routes/payments.ts) keeps an EMPTY separator
237
+ // distinct from an ABSENT one (a caller that says "" is asking for the
238
+ // digits run together, `0729`, and one that says nothing gets the default
239
+ // `/`), and `optionalNonEmptyString` would collapse both to `undefined`.
240
+ // `parseBeginCheckoutInput`'s own `expirySeparator` field stays on
241
+ // `optionalNonEmptyString`, matching the sdk's begin route instead, which
242
+ // does the same collapse there.
243
+ const separator = params['expirySeparator'];
244
+ return {
245
+ sessionId: requireString(params['sessionId'], 'sessionId'),
246
+ pageId: requireString(params['pageId'], 'pageId'),
247
+ targets,
248
+ expirySeparator: typeof separator === 'string' ? separator : undefined,
249
+ twoDigitYear: typeof params['twoDigitYear'] === 'boolean' ? params['twoDigitYear'] : undefined,
250
+ };
251
+ }
252
+
253
+ /**
254
+ * Allowlisted, the same reason `sanitizeFillResult` exists in the SDK's own
255
+ * route module: a service bug becomes a missing field here, never a leaked
256
+ * one, and this wrapper bypasses that route entirely, so it is this file's job
257
+ * to keep the property.
258
+ */
259
+ function fillCardResultView(result: Awaited<ReturnType<PaymentsGatewayServiceImpl['fillCardIntoCheckout']>>): Record<string, unknown> {
260
+ return {
261
+ ok: result.ok === true,
262
+ filled: (result.filled ?? []).map((field) => String(field)),
263
+ failedField: result.failedField === null || result.failedField === undefined ? null : String(result.failedField),
264
+ reason: result.reason === null || result.reason === undefined ? null : String(result.reason),
265
+ };
266
+ }
267
+
268
+ /**
269
+ * Whether the named card can actually be charged: configured, and its
270
+ * material present in the secret store. Computed here, outside
271
+ * `PaymentsGatewayServiceImpl`, because `GateInput` is a plain synchronous
272
+ * record and this daemon's card-material check is async.
273
+ */
274
+ async function hasUsableCard(cards: DaemonCardStore, cardId: string): Promise<boolean> {
275
+ if (cardId.length === 0) return false;
276
+ const metadata = await cards.metadata(cardId);
277
+ if (metadata === null) return false;
278
+ return cards.materialComplete(cardId);
279
+ }
280
+
281
+ /** Same reasoning as `hasUsableCard`: an async read, resolved before `GateInput` is built. */
282
+ async function hasShippingAddress(addresses: AddressStore): Promise<boolean> {
283
+ const stored = await addresses.read('shipping');
284
+ return checkAddress(stored, 'shipping').ok;
285
+ }
286
+
287
+ /**
288
+ * The per-invocation gate facts the shared checkout service's `gates()`
289
+ * closure reads. A mutable CELL, not a value passed at construction: the
290
+ * service is built ONCE (see `CheckoutServiceHolder` below) and its `gates()`
291
+ * closure is called synchronously, on every `beginCheckout` call, from deep
292
+ * inside that one shared instance, so the only way for it to see THIS call's
293
+ * facts is to read them from somewhere written just before this call and nowhere
294
+ * held between calls.
295
+ *
296
+ * Safe under concurrent calls despite being shared, mutable state, because of
297
+ * WHEN it is read: `checkoutBeginHandler` writes it and then, in the same
298
+ * synchronous span with no `await` between the write and the call, invokes
299
+ * `service.beginCheckout(input)`. `beginCheckout`'s own body runs synchronously
300
+ * up to its first internal `await` (see payments-gateway-service.ts), and
301
+ * `gates()` is invoked inside that synchronous prefix, so the value it reads is
302
+ * always the one THIS call just wrote, captured into a plain `GateInput` object
303
+ * before control ever returns to the event loop. A second call writing the cell
304
+ * later cannot land between the write and the read of an earlier one; only
305
+ * between two DIFFERENT calls' write-then-read pairs, which never interleave
306
+ * with each other's.
307
+ */
308
+ interface CheckoutGateInputsCell {
309
+ current: { readonly hasUsableCard: boolean; readonly hasShippingAddress: boolean; readonly isOwnerDirectRequest: boolean };
310
+ }
311
+
312
+ const NO_GATE_INPUTS_YET = { hasUsableCard: false, hasShippingAddress: false, isOwnerDirectRequest: false };
313
+
314
+ /**
315
+ * Build the ONE `PaymentsGatewayServiceImpl` a registration's checkout pair
316
+ * shares for its whole life. See `CheckoutServiceHolder` for why one, not one
317
+ * per call.
318
+ */
319
+ function buildCheckoutService(
320
+ deps: PaymentsHandlerDeps,
321
+ seam: BrowserCheckoutSeam,
322
+ journal: CheckoutJournal,
323
+ gateInputs: CheckoutGateInputsCell,
324
+ ): PaymentsGatewayServiceImpl {
325
+ return new PaymentsGatewayServiceImpl({
326
+ cards: deps.cards,
327
+ addresses: deps.checkout.addresses,
328
+ ledger: deps.budget,
329
+ purchases: deps.purchases,
330
+ notifier: deps.checkout.notifier,
331
+ untrusted: deps.checkout.untrusted,
332
+ journal,
333
+ merchantJudge: deps.checkout.merchantJudge,
334
+ driverFor: seam.driverFor,
335
+ cardFieldGuard: seam.cardFieldGuard,
336
+ gates: () => ({
337
+ enabled: readPaymentsEnabled(deps.config),
338
+ isPaymentsLeader: deps.isPaymentsLeader(),
339
+ ...gateInputs.current,
340
+ }),
341
+ config: () => readPaymentsServiceConfig(deps.config),
342
+ ...(deps.now ? { now: deps.now } : {}),
343
+ });
344
+ }
345
+
346
+ /**
347
+ * Holds the ONE `PaymentsGatewayServiceImpl` a registration's checkout pair
348
+ * shares for the life of the registration, and the gate-input cell its
349
+ * `gates()` closure reads.
350
+ *
351
+ * ── Why one instance, not one per call ─────────────────────────────────────
352
+ *
353
+ * `PaymentsGatewayServiceImpl` builds its own `CheckoutRegistry` in its
354
+ * constructor (`this.registry = new CheckoutRegistry(deps.journal)`,
355
+ * payments-gateway-service.ts), and that registry's live "which page has a
356
+ * purchase open" map (`byPage`) is IN-MEMORY, per-instance state, not
357
+ * recovered from the journal at construction. The sdk's own header names the
358
+ * property this holder exists to keep: "`begin` opens a checkout and
359
+ * `fillCard` completes one, and they are separate verbs arriving as separate
360
+ * control-plane calls. The in-flight registry has to outlive both, so it
361
+ * lives here for the life of the service rather than being constructed per
362
+ * call." A fresh service (and therefore a fresh, empty registry) built on
363
+ * every call cannot keep that promise: two `begin` calls on the same page each
364
+ * get their own empty map, so the registry's own duplicate guard
365
+ * (`CheckoutRegistry.open` refuses a second open on a page already running
366
+ * one) never fires, and a `fillCard` call afterward finds an empty map too, so
367
+ * it always refuses "no purchase decision is in flight" even for a page that
368
+ * genuinely has one, exactly the honest-sounding but wrong refusal a real
369
+ * caller and a nonexistent one would both get.
370
+ *
371
+ * ── Why memoized on first use, not built at registration ───────────────────
372
+ *
373
+ * `PaymentsGatewayServiceImpl` needs a concrete `BrowserCheckoutSeam` (for
374
+ * `driverFor` and `cardFieldGuard`) to construct, and `deps.checkout.seam()`
375
+ * may still return `undefined` at the moment `registerPaymentsMethods` runs
376
+ * (see `CheckoutComposition.seam`'s own doc comment: the browser composition
377
+ * that fills it runs AFTER this daemon's handlers are registered). So the
378
+ * instance is built lazily, on the first call that finds a real seam, and
379
+ * cached from then on. Safe to cache permanently: `onBrowserCheckout` fires at
380
+ * most once per daemon process (browser-checkout-seam-holder.ts), so once a
381
+ * real seam has been seen it is THE seam for the rest of this registration's
382
+ * life.
383
+ */
384
+ export class CheckoutServiceHolder {
385
+ private service: PaymentsGatewayServiceImpl | null = null;
386
+ readonly gateInputs: CheckoutGateInputsCell = { current: NO_GATE_INPUTS_YET };
387
+
388
+ constructor(
389
+ private readonly deps: PaymentsHandlerDeps,
390
+ private readonly journal: CheckoutJournal,
391
+ ) {}
392
+
393
+ serviceFor(seam: BrowserCheckoutSeam): PaymentsGatewayServiceImpl {
394
+ this.service ??= buildCheckoutService(this.deps, seam, this.journal, this.gateInputs);
395
+ return this.service;
396
+ }
397
+ }
398
+
399
+ const CHECKOUT_UNAVAILABLE_MESSAGE =
400
+ 'Checkout is not available on this daemon right now: no browser is composed for it (no home directory '
401
+ + 'configured, or the browser composition has not finished starting). Retry once the daemon has finished '
402
+ + 'booting; if this persists, the daemon was started without a home directory to keep browser profiles in.';
403
+
404
+ /**
405
+ * The action an owner approval authorizes: one `payments.checkout.begin`.
406
+ * The approve verb mints against this constant and `begin` spends against it,
407
+ * so the two can never drift into approving one verb and spending on another.
408
+ */
409
+ export const CHECKOUT_APPROVAL_ACTION = 'payments.checkout.begin';
410
+
411
+ /**
412
+ * The exact fields an approval binds, built the same way on both sides.
413
+ *
414
+ * On the approve side the values are what the owner typed; on the begin side
415
+ * they are read from the begin call itself (`merchantDomain`, `item`,
416
+ * `requestedMax`). One builder for both is what makes the fingerprint a
417
+ * comparison of the deed rather than two modules' ideas of it.
418
+ */
419
+ export function checkoutApprovalContent(input: {
420
+ readonly merchantDomain: string;
421
+ readonly item: string;
422
+ readonly amount: string | undefined;
423
+ }): Readonly<Record<string, string | undefined>> {
424
+ return { merchant: input.merchantDomain, item: input.item, amount: input.amount };
425
+ }
426
+
427
+ /** How a begin call names the approve verb when it refuses, per mismatch. */
428
+ function approvalRefusalMessage(mismatch: string): string {
429
+ if (mismatch === 'expired') {
430
+ return 'The owner approval for this purchase has expired. Approvals last five minutes: call '
431
+ + 'payments.checkout.approve again with the same merchantDomain, item and amount, then begin promptly.';
432
+ }
433
+ if (mismatch === 'different-content' || mismatch === 'no-content-binding') {
434
+ return 'The owner approval on file was for a different purchase: its merchant, item or amount does not '
435
+ + 'match this begin call (the amount is compared against requestedMax). Call payments.checkout.approve '
436
+ + 'with exactly what this begin call names, then begin again.';
437
+ }
438
+ return 'This purchase has no owner approval on file. A human approves it first, out of band from this '
439
+ + 'call: invoke payments.checkout.approve with this purchase\'s merchantDomain, item and amount (the '
440
+ + 'begin call\'s requestedMax), then begin within five minutes.';
441
+ }
442
+
443
+ /**
444
+ * Spend the one approval matching this begin call, or refuse naming the
445
+ * approve verb. Consuming before the service runs is the sdk store's own
446
+ * safe direction: an approval taken for a begin that then refuses on a later
447
+ * gate is spent, never silently reusable.
448
+ */
449
+ function consumeCheckoutApproval(
450
+ approvals: DaemonApprovalStore,
451
+ input: { readonly merchantDomain: string; readonly item: string; readonly requestedMax?: string | undefined },
452
+ ): void {
453
+ let taken: ReturnType<DaemonApprovalStore['take']>;
454
+ try {
455
+ taken = approvals.take({
456
+ action: CHECKOUT_APPROVAL_ACTION,
457
+ content: checkoutApprovalContent({
458
+ merchantDomain: input.merchantDomain,
459
+ item: input.item,
460
+ amount: input.requestedMax,
461
+ }),
462
+ });
463
+ } catch (error) {
464
+ // A store that could not persist the removal rolled it back and threw
465
+ // (approval-store.ts): the approval is still on file and nothing was
466
+ // submitted. Contained: the raw error can name the store path.
467
+ void error;
468
+ throw new HandlerError(
469
+ 'Recording the spent approval failed. The approval was not consumed and nothing was submitted.',
470
+ 'INTERNAL_ERROR',
471
+ 500,
472
+ );
473
+ }
474
+ if (taken.approval === null) {
475
+ throw new HandlerError(approvalRefusalMessage(taken.mismatch), 'OWNER_APPROVAL_REQUIRED', 403);
476
+ }
477
+ }
478
+
479
+ /**
480
+ * `payments.checkout.approve`.
481
+ *
482
+ * The distinct act the sdk's owner-approval ruling requires: a HUMAN, on a
483
+ * surface with command authority, names one purchase and approves it. The
484
+ * registration (register.ts) puts this handler behind the same confirmation
485
+ * gate every destructive verb in this daemon uses (`confirm: true` in the
486
+ * body AND the explicit-user-request context flag), and the handler passes
487
+ * `surface: 'owner-direct'` from its own code path, never from an argument,
488
+ * which is the property `grantOwnerApproval` exists to enforce.
489
+ *
490
+ * The minted record is persisted (approval-store.ts), single-use, bound to
491
+ * the exact merchant + item + amount fields named here, and expires in five
492
+ * minutes. `payments.checkout.begin` spends it; see `checkoutBeginHandler`.
493
+ */
494
+ export function checkoutApproveHandler(deps: PaymentsHandlerDeps): TypedHandler<unknown, Record<string, unknown>> {
495
+ return async ({ body }) => {
496
+ const params = asRecord(body);
497
+ const merchantDomain = requireString(params['merchantDomain'], 'merchantDomain');
498
+ const item = requireString(params['item'], 'item');
499
+ const amount = requireString(params['amount'], 'amount');
500
+ let approval;
501
+ try {
502
+ approval = deps.checkout.approvals.grant({
503
+ action: CHECKOUT_APPROVAL_ACTION,
504
+ content: checkoutApprovalContent({ merchantDomain, item, amount }),
505
+ });
506
+ } catch (error) {
507
+ // Contained for the same reason as `consumeCheckoutApproval`: the raw
508
+ // write failure can name the store path, and an approval that never
509
+ // reached disk was deliberately rolled back rather than left spendable.
510
+ void error;
511
+ throw new HandlerError('Recording the approval failed. Nothing was approved.', 'INTERNAL_ERROR', 500);
512
+ }
513
+ // Named fields, never a spread, the same containment rule as every other
514
+ // response in this family.
515
+ return {
516
+ approved: true,
517
+ action: CHECKOUT_APPROVAL_ACTION,
518
+ merchantDomain,
519
+ item,
520
+ amount,
521
+ expiresAt: approval.expiresAt,
522
+ };
523
+ };
524
+ }
525
+
526
+ /** The sdk's own explicit nine-field projection (routes/payments.ts's `createPaymentsCheckoutBeginHandler`), not a spread. */
527
+ function beginResultView(result: Awaited<ReturnType<PaymentsGatewayServiceImpl['beginCheckout']>>): Record<string, unknown> {
528
+ return {
529
+ outcome: String(result.outcome),
530
+ purchaseId: result.purchaseId ?? null,
531
+ reason: result.reason ?? null,
532
+ merchantOrderId: result.merchantOrderId ?? null,
533
+ totalMinorUnits: result.totalMinorUnits ?? null,
534
+ currency: result.currency ?? null,
535
+ shippingTierUsed: result.shippingTierUsed ?? null,
536
+ steppedDown: result.steppedDown === true,
537
+ challengeStep: result.challengeStep ?? null,
538
+ };
539
+ }
540
+
541
+ /**
542
+ * `payments.checkout.begin`.
543
+ *
544
+ * ── What actually gates a purchase here ────────────────────────────────────
545
+ *
546
+ * `context.explicitUserRequest` is a caller-set header/frame field
547
+ * (`x-goodvibes-explicit-user-request`; see the sdk's
548
+ * `routes/explicit-user-request.ts` and `normalizeContext` in
549
+ * `daemon/handlers/register.ts`), no stronger a claim than `confirm: true` on
550
+ * any other confirmation-gated verb in this daemon. It gates ENTRY to this
551
+ * verb, `isOwnerDirectRequest` in the `GateInput` `checkPaymentGates` reads
552
+ * (gates.ts), and nothing about the submit itself. It also resets the
553
+ * untrusted-content watermark the taint gates read
554
+ * (`security/turn-boundary.ts`), a second, separate effect of the same
555
+ * caller-set claim, not something this handler arranges.
556
+ *
557
+ * ── The genuine owner-approval record, consumed here ───────────────────────
558
+ *
559
+ * Behind that outer gate sits the distinct-act approval the sdk's
560
+ * owner-approval ruling describes (platform/security/owner-approval.ts): a
561
+ * persisted record that a human called `payments.checkout.approve`, out of
562
+ * band from whatever conversation produced this begin call, naming ONE
563
+ * purchase by merchant, item and amount. This handler spends exactly one
564
+ * matching record per begin (`DaemonApprovalStore.take`, approval-store.ts:
565
+ * single-use, content-bound via the sdk's own fingerprint, five-minute TTL)
566
+ * and refuses, naming the approve verb, when none matches. The record is
567
+ * consumed BEFORE the service runs, which is the sdk store's own safe
568
+ * direction: a taken approval whose begin then refuses on a later gate is
569
+ * spent, never quietly retried.
570
+ *
571
+ * The binding fields are `merchantDomain`, `item` and `requestedMax`, read
572
+ * from THIS begin call and fingerprinted the same way the approve verb
573
+ * fingerprinted what the owner typed, so a begin whose merchant, item or
574
+ * amount differs from what was approved is `different-content`, not a match.
575
+ * An earlier mechanism that armed `seam.armSubmitApproval` with a
576
+ * content-free approval was deleted rather than shipped, because an approval
577
+ * with no content binding clears nothing real; this record is the strong
578
+ * form, minted with the exact fields.
579
+ *
580
+ * The money controls downstream of both gates are unchanged: the budget
581
+ * ledger (RESERVE, step 5), the purchase notices and their approval/veto
582
+ * decision windows (NOTICE + WINDOW, step 6), and the card-material guard
583
+ * (`cardFieldGuard`, armed only immediately before typing, never before).
584
+ * See `checkout-flow.ts`'s own header for the full order.
585
+ */
586
+ export function checkoutBeginHandler(deps: PaymentsHandlerDeps, holder: CheckoutServiceHolder): TypedHandler<unknown, Record<string, unknown>> {
587
+ return async ({ body, context }) => {
588
+ const params = asRecord(body);
589
+ const input = parseBeginCheckoutInput(params);
590
+
591
+ const seam = deps.checkout.seam();
592
+ if (seam === undefined) {
593
+ throw new HandlerError(CHECKOUT_UNAVAILABLE_MESSAGE, 'FAILED_PRECONDITION', 409);
594
+ }
595
+
596
+ // The approval is only consulted INSIDE the outer explicitUserRequest
597
+ // layer: a call that never claimed to be owner-direct falls through to
598
+ // the service, whose own gate refuses it `refused:not-owner-request`
599
+ // exactly as before this record existed. Consuming an approval for a
600
+ // call that outer layer was always going to refuse would spend the
601
+ // owner's answer on nothing.
602
+ if (context.explicitUserRequest) {
603
+ consumeCheckoutApproval(deps.checkout.approvals, input);
604
+ }
605
+
606
+ const [usableCard, shippingAddress] = await Promise.all([
607
+ hasUsableCard(deps.cards, input.cardId),
608
+ hasShippingAddress(deps.checkout.addresses),
609
+ ]);
610
+
611
+ // Written immediately before the call it applies to, with no `await`
612
+ // between: see `CheckoutGateInputsCell`'s own doc comment for why that
613
+ // ordering is what keeps this safe under concurrent calls.
614
+ holder.gateInputs.current = {
615
+ hasUsableCard: usableCard,
616
+ hasShippingAddress: shippingAddress,
617
+ isOwnerDirectRequest: context.explicitUserRequest,
618
+ };
619
+ const service = holder.serviceFor(seam);
620
+
621
+ try {
622
+ const result = await service.beginCheckout(input);
623
+ return beginResultView(result);
624
+ } catch (error) {
625
+ // `CheckoutRegistryError` (a second `begin` finding one already in
626
+ // flight on this page) is the owner's business and carries no card
627
+ // material, so it is forwarded, the same containment shape
628
+ // `checkoutFillCardHandler` gives `FillCardRefusal` below. Anything else
629
+ // is discarded: the failing call had the card in its arguments, and an
630
+ // error string is a read path like any other.
631
+ if (error instanceof CheckoutRegistryError) {
632
+ throw new HandlerError(error.message, 'FAILED_PRECONDITION', 409);
633
+ }
634
+ void error;
635
+ throw new HandlerError('Beginning this checkout failed. Nothing was submitted.', 'INTERNAL_ERROR', 500);
636
+ }
637
+ };
638
+ }
639
+
640
+ /**
641
+ * `payments.checkout.fillCard`.
642
+ *
643
+ * No submit approval and no `gates()` reasoning: `fillCardIntoCheckout` never
644
+ * consults either (it types into fields, it does not click a submit control),
645
+ * so this handler's only checkout-specific concern is the same seam
646
+ * availability check `checkoutBeginHandler` makes. It never writes
647
+ * `holder.gateInputs`: whatever a prior `begin` call on this same holder left
648
+ * there (or the `NO_GATE_INPUTS_YET` default, if none ever ran) is simply
649
+ * never read by a fill.
650
+ */
651
+ export function checkoutFillCardHandler(deps: PaymentsHandlerDeps, holder: CheckoutServiceHolder): TypedHandler<unknown, Record<string, unknown>> {
652
+ return async ({ body }) => {
653
+ const params = asRecord(body);
654
+ const input = parseFillCardInput(params);
655
+
656
+ const seam = deps.checkout.seam();
657
+ if (seam === undefined) {
658
+ throw new HandlerError(CHECKOUT_UNAVAILABLE_MESSAGE, 'FAILED_PRECONDITION', 409);
659
+ }
660
+
661
+ const service = holder.serviceFor(seam);
662
+
663
+ try {
664
+ const result = await service.fillCardIntoCheckout(input);
665
+ return fillCardResultView(result);
666
+ } catch (error) {
667
+ // A `FillCardRefusal` is the owner's business and carries no material
668
+ // (fill-card.ts's own contract), so it is forwarded; anything else is
669
+ // discarded, the failing call had the card in its stack, and an error
670
+ // string is a read path like any other.
671
+ if (error instanceof Error && error.name === 'FillCardRefusal') {
672
+ throw new HandlerError(error.message, 'INVALID_ARGUMENT', 400);
673
+ }
674
+ void error;
675
+ throw new HandlerError('Filling the card into this checkout failed. Nothing was submitted.', 'INTERNAL_ERROR', 500);
676
+ }
677
+ };
678
+ }