@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.
- package/CHANGELOG.md +34 -0
- package/README.md +11 -6
- package/package.json +4 -4
- 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/budget-store.ts +356 -0
- package/src/daemon/handlers/payments/checkout-handlers.ts +526 -0
- package/src/daemon/handlers/payments/index.ts +7 -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 +261 -134
- package/src/runtime/browser-checkout-seam-holder.ts +55 -0
- package/src/runtime/daemon-handler-composition.ts +28 -11
- package/src/runtime/payments-composition.ts +70 -26
- package/src/runtime/services.ts +13 -6
|
@@ -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
|
+
}
|