@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.
- package/CHANGELOG.md +64 -0
- package/README.md +34 -13
- package/package.json +4 -4
- package/src/cli/command-catalog.ts +6 -5
- package/src/daemon/cli.ts +3 -3
- package/src/daemon/handlers/contracts.ts +15 -0
- package/src/daemon/handlers/index.ts +1 -1
- package/src/daemon/handlers/payments/address-store.ts +54 -0
- package/src/daemon/handlers/payments/approval-store.ts +275 -0
- package/src/daemon/handlers/payments/budget-store.ts +357 -0
- package/src/daemon/handlers/payments/checkout-handlers.ts +678 -0
- package/src/daemon/handlers/payments/checkout-journal-store.ts +162 -0
- package/src/daemon/handlers/payments/index.ts +14 -1
- package/src/daemon/handlers/payments/merchant-judge.ts +57 -0
- package/src/daemon/handlers/payments/notifier.ts +112 -0
- package/src/daemon/handlers/payments/register.ts +376 -136
- package/src/runtime/browser-checkout-seam-holder.ts +55 -0
- package/src/runtime/daemon-handler-composition.ts +39 -13
- package/src/runtime/legacy-daemon-migration.ts +1 -1
- package/src/runtime/payments-composition.ts +95 -28
- package/src/runtime/services.ts +13 -6
|
@@ -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
|
+
}
|