@visa/cli 4.1.0-rc.25 → 4.1.0-rc.251
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/README.md +273 -227
- package/dist/checkout-engine/adapters/generic.d.ts +69 -0
- package/dist/checkout-engine/adapters/generic.js +383 -58
- package/dist/checkout-engine/adapters/index.d.ts +4 -1
- package/dist/checkout-engine/adapters/index.js +10 -3
- package/dist/checkout-engine/adapters/shopify.d.ts +80 -0
- package/dist/checkout-engine/adapters/shopify.js +688 -0
- package/dist/checkout-engine/amount.d.ts +15 -0
- package/dist/checkout-engine/amount.js +72 -0
- package/dist/checkout-engine/browser-launch.d.ts +9 -4
- package/dist/checkout-engine/browser-launch.js +19 -4
- package/dist/checkout-engine/browserbase-browser.d.ts +24 -0
- package/dist/checkout-engine/browserbase-browser.js +186 -0
- package/dist/checkout-engine/cli-engine.d.ts +297 -4
- package/dist/checkout-engine/cli-engine.js +942 -48
- package/dist/checkout-engine/confirmed-merchants.d.ts +31 -0
- package/dist/checkout-engine/confirmed-merchants.js +165 -0
- package/dist/checkout-engine/detect.d.ts +1 -1
- package/dist/checkout-engine/detect.js +26 -0
- package/dist/checkout-engine/evidence.d.ts +4 -1
- package/dist/checkout-engine/evidence.js +51 -6
- package/dist/checkout-engine/executor.d.ts +64 -4
- package/dist/checkout-engine/executor.js +625 -146
- package/dist/checkout-engine/hosted-approval.d.ts +124 -7
- package/dist/checkout-engine/hosted-approval.js +384 -54
- package/dist/checkout-engine/index.d.ts +9 -2
- package/dist/checkout-engine/index.js +8 -1
- package/dist/checkout-engine/instrument.d.ts +7 -0
- package/dist/checkout-engine/instrument.js +4 -0
- package/dist/checkout-engine/known-merchants.d.ts +10 -0
- package/dist/checkout-engine/known-merchants.js +38 -0
- package/dist/checkout-engine/live-fill-approval.d.ts +5 -20
- package/dist/checkout-engine/live-fill-approval.js +20 -51
- package/dist/checkout-engine/mandate/card-mandate.d.ts +121 -0
- package/dist/checkout-engine/mandate/card-mandate.js +226 -0
- package/dist/checkout-engine/mandate/mandate-ledger.d.ts +174 -0
- package/dist/checkout-engine/mandate/mandate-ledger.js +410 -0
- package/dist/checkout-engine/outcome.d.ts +2 -2
- package/dist/checkout-engine/outcome.js +36 -1
- package/dist/checkout-engine/owner-only-file.d.ts +9 -0
- package/dist/checkout-engine/owner-only-file.js +20 -1
- package/dist/checkout-engine/receipt-dir.d.ts +6 -0
- package/dist/checkout-engine/receipt-dir.js +8 -0
- package/dist/checkout-engine/receipt.d.ts +56 -2
- package/dist/checkout-engine/receipt.js +55 -16
- package/dist/checkout-engine/shopify-primary-domain.d.ts +25 -0
- package/dist/checkout-engine/shopify-primary-domain.js +96 -0
- package/dist/checkout-engine/trace-handles.d.ts +8 -0
- package/dist/checkout-engine/trace-handles.js +12 -0
- package/dist/checkout-engine/types.d.ts +28 -2
- package/dist/checkout-engine/unresolved-charges.d.ts +34 -0
- package/dist/checkout-engine/unresolved-charges.js +134 -0
- package/dist/checkout-engine/vgs-gateway/server-mint-client.d.ts +53 -1
- package/dist/checkout-engine/vgs-gateway/server-mint-client.js +78 -10
- package/dist/checkout-engine/vgs-live-instrument.d.ts +38 -35
- package/dist/checkout-engine/vgs-live-instrument.js +51 -74
- package/dist/checkout-engine/vic-confirmation.d.ts +18 -0
- package/dist/checkout-engine/vic-confirmation.js +9 -3
- package/dist/checkout-engine/web-bot-auth.d.ts +98 -0
- package/dist/checkout-engine/web-bot-auth.js +218 -0
- package/dist/cli.js +905 -505
- package/dist/mcp-apps/ucp-checkout.html +280 -0
- package/dist/mcp-server/index.js +744 -175
- package/dist/merchant-ucp-mcp/index.js +6 -0
- package/dist/skills/pair-visa-agent/RUNTIMES.md +122 -79
- package/dist/skills/pair-visa-agent/SKILL.md +436 -279
- package/dist/skills/pair-visa-agent/scripts/__tests__/setup.test.mjs +407 -0
- package/dist/skills/pair-visa-agent/scripts/setup.mjs +310 -30
- package/dist/skills/visa-shopify-checkout/SKILL.md +122 -0
- package/dist/skills/visa-shopify-checkout/references/evidence-and-states.md +37 -0
- package/dist/skills/visa-ucp-shopping/SKILL.md +86 -0
- package/dist/subway-direct.mjs +1 -0
- package/install.ps1 +9 -46
- package/install.sh +5 -37
- package/native/bin/win32-x64/visa-keychain-win.exe +0 -0
- package/package.json +32 -28
- package/server.json +4 -4
- package/dist/checkout-engine/inline-target.d.ts +0 -13
- package/dist/checkout-engine/inline-target.js +0 -37
- package/dist/checkout-engine/pay-args.d.ts +0 -14
- package/dist/checkout-engine/pay-args.js +0 -44
- package/dist/checkout-engine/pay.d.ts +0 -1
- package/dist/checkout-engine/pay.js +0 -13
- package/dist/checkout-engine/repo-env.d.ts +0 -11
- package/dist/checkout-engine/repo-env.js +0 -23
- package/dist/checkout-engine/run-live-fill.d.ts +0 -1
- package/dist/checkout-engine/run-live-fill.js +0 -443
- package/dist/checkout-engine/vgs-gateway/fetch-credential.d.mts +0 -74
- package/dist/checkout-engine/vgs-gateway/fetch-credential.mjs +0 -248
|
@@ -1,25 +1,94 @@
|
|
|
1
1
|
import { createHash, randomBytes } from 'node:crypto';
|
|
2
2
|
import { exec } from 'node:child_process';
|
|
3
3
|
import { assuranceFromCeremony } from './ceremony.js';
|
|
4
|
+
/** One short sentence — must match the relay's cap (agent-approval.ts). */
|
|
5
|
+
export const APPROVAL_INTENT_MAX_CHARS = 200;
|
|
6
|
+
/**
|
|
7
|
+
* Sanitize an agent-supplied intent to what the relay will accept and the page
|
|
8
|
+
* will display: control chars (and JS line separators) stripped, trimmed,
|
|
9
|
+
* TRUNCATED to the cap (the runner is the agent's own side, so truncating here
|
|
10
|
+
* beats a failed registration; the server still rejects an over-cap value).
|
|
11
|
+
* Returns undefined when nothing displayable remains.
|
|
12
|
+
*/
|
|
13
|
+
export function sanitizeApprovalIntent(value) {
|
|
14
|
+
if (value === undefined)
|
|
15
|
+
return undefined;
|
|
16
|
+
const cleaned = value
|
|
17
|
+
.replace(/[\p{Cc}\u2028\u2029]/gu, '')
|
|
18
|
+
.trim()
|
|
19
|
+
.slice(0, APPROVAL_INTENT_MAX_CHARS)
|
|
20
|
+
.trim();
|
|
21
|
+
return cleaned.length > 0 ? cleaned : undefined;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The operator saw the request and said NO. Terminal and non-retryable: the
|
|
25
|
+
* relay entry is consumed, re-running would only re-ask a human who already
|
|
26
|
+
* refused. Callers must not classify this as transient.
|
|
27
|
+
*/
|
|
28
|
+
export class HostedApprovalDeclinedError extends Error {
|
|
29
|
+
constructor() {
|
|
30
|
+
super('The approver declined this request — the checkout was cancelled and nothing was charged');
|
|
31
|
+
this.name = 'HostedApprovalDeclinedError';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
4
34
|
export const HOSTED_APPROVAL_TIMEOUT_MS = 4 * 60 * 1000;
|
|
5
35
|
export const HOSTED_APPROVAL_POLL_MS = 3_000;
|
|
36
|
+
/** Emit a "still waiting" heartbeat roughly every this-many ms during the poll. */
|
|
37
|
+
export const HOSTED_APPROVAL_HEARTBEAT_MS = 30_000;
|
|
38
|
+
/**
|
|
39
|
+
* Resolve the approval timeout: `CHECKOUT_APPROVAL_TIMEOUT_MS` (seconds*1000, an
|
|
40
|
+
* integer ms) overrides the default when it parses to a positive integer.
|
|
41
|
+
* Lets an operator widen the window for separate-device / headless approval
|
|
42
|
+
* without threading a flag through every caller. Invalid values fall back.
|
|
43
|
+
*/
|
|
44
|
+
export function resolveApprovalTimeoutMs() {
|
|
45
|
+
const raw = process.env.CHECKOUT_APPROVAL_TIMEOUT_MS;
|
|
46
|
+
if (raw !== undefined) {
|
|
47
|
+
const n = Number(raw);
|
|
48
|
+
if (Number.isSafeInteger(n) && n > 0)
|
|
49
|
+
return n;
|
|
50
|
+
}
|
|
51
|
+
return HOSTED_APPROVAL_TIMEOUT_MS;
|
|
52
|
+
}
|
|
6
53
|
/** Unref'd so a raced-and-abandoned deadline timer can never hold the process open. */
|
|
7
54
|
const defaultSleep = (ms) => new Promise((r) => {
|
|
8
55
|
const t = setTimeout(r, ms);
|
|
9
56
|
t.unref?.();
|
|
10
57
|
});
|
|
11
58
|
function defaultOpenUrl(url) {
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
|
|
59
|
+
// Best-effort convenience only — the URL is always surfaced via `log`/
|
|
60
|
+
// `onApprovalUrl`, so this must never throw or hang if there is no browser.
|
|
61
|
+
// `CHECKOUT_SKIP_BROWSER_OPEN=1` or `VISA_SUPPRESS_BROWSER=true|1` disables it
|
|
62
|
+
// (headless/agent hosts where launching a browser on the WRONG machine is pointless or noisy).
|
|
63
|
+
if (process.env.CHECKOUT_SKIP_BROWSER_OPEN === '1' ||
|
|
64
|
+
process.env.CHECKOUT_SKIP_BROWSER_OPEN === 'true' ||
|
|
65
|
+
process.env.VISA_SUPPRESS_BROWSER === '1' ||
|
|
66
|
+
process.env.VISA_SUPPRESS_BROWSER === 'true' ||
|
|
67
|
+
process.env.VISA_CLI_NO_BROWSER === '1' ||
|
|
68
|
+
process.env.VISA_CLI_NO_BROWSER === 'true')
|
|
69
|
+
return;
|
|
70
|
+
// Platform-appropriate opener; unknown platforms just skip (the URL is logged).
|
|
71
|
+
const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start ""' : 'xdg-open';
|
|
72
|
+
if (process.platform !== 'darwin' && process.platform !== 'win32' && process.platform !== 'linux')
|
|
73
|
+
return;
|
|
74
|
+
const quoted = `'${url.replaceAll("'", "'\\''")}'`;
|
|
75
|
+
try {
|
|
76
|
+
// exec is async and fire-and-forget; swallow the callback error so a missing
|
|
77
|
+
// opener binary (common on headless Linux) can never surface as a failure.
|
|
78
|
+
exec(`${opener} ${quoted}`, () => { });
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
// Spawn failure (no shell, sandboxed) — ignore; the printed URL is the path.
|
|
82
|
+
}
|
|
15
83
|
}
|
|
16
84
|
/**
|
|
17
|
-
* The
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
85
|
+
* The staging apps/web deploy (which now hosts the /agent/enroll/approve page
|
|
86
|
+
* after the v4-verify-web GA graduation) — hosted approval is the NORMAL path
|
|
87
|
+
* (no local certs, no vendored SDK, no localhost anywhere), so its origin is a
|
|
88
|
+
* built-in default rather than per-machine config. Not a secret: it's the
|
|
89
|
+
* public page the operator's browser opens anyway.
|
|
21
90
|
*/
|
|
22
|
-
export const DEFAULT_APPROVAL_BASE_URL = 'https://
|
|
91
|
+
export const DEFAULT_APPROVAL_BASE_URL = 'https://web-visa-code-preview.up.railway.app';
|
|
23
92
|
/**
|
|
24
93
|
* Flag > environment > built-in default. An explicitly EMPTY value
|
|
25
94
|
* (CHECKOUT_APPROVAL_BASE_URL='') opts out of hosted approval entirely —
|
|
@@ -64,16 +133,36 @@ function stripTrailingSlashes(value) {
|
|
|
64
133
|
* rather than falling back to a client-held secret.
|
|
65
134
|
*/
|
|
66
135
|
export async function runHostedApproval(opts) {
|
|
67
|
-
const { baseUrl, tokenId, target, consumerEmail,
|
|
136
|
+
const { baseUrl, tokenId, target, consumerEmail, agentJkt, maxDraws, perTransaction, intent,
|
|
137
|
+
// Default to stderr, NOT a no-op: the approval URL is the one thing a remote /
|
|
138
|
+
// SSH / headless-terminal human needs to proceed, and swallowing it (the old
|
|
139
|
+
// `() => {}` default) left it emitted nowhere readable. Tests inject their own.
|
|
140
|
+
log = (line) => void process.stderr.write(`${line}\n`), onApprovalUrl, openUrl = defaultOpenUrl, fetchImpl = fetch, sleep = defaultSleep, now = Date.now, pollIntervalMs = HOSTED_APPROVAL_POLL_MS,
|
|
141
|
+
// A human approving on a SEPARATE device (open the link, sign in, tap the
|
|
142
|
+
// passkey) needs more than the interactive 4-minute window; allow an env
|
|
143
|
+
// override without threading a flag through every caller.
|
|
144
|
+
timeoutMs = resolveApprovalTimeoutMs(), } = opts;
|
|
145
|
+
const canonicalJkt = typeof agentJkt === 'string' && /^[A-Za-z0-9_-]{43}$/.test(agentJkt);
|
|
146
|
+
if (!canonicalJkt) {
|
|
147
|
+
throw new Error('a budget approval requires the exact current agent request-key JKT');
|
|
148
|
+
}
|
|
68
149
|
const base = stripTrailingSlashes(assertApprovalBaseUrl(baseUrl));
|
|
69
150
|
// The server's currency map is uppercase ISO 4217; the loader normalizes the
|
|
70
151
|
// target once, but normalize here too so a direct caller with a lowercase
|
|
71
152
|
// code registers (and verifies) the same value the relay stores.
|
|
72
153
|
const currency = target.transactionCurrencyCode.toUpperCase();
|
|
154
|
+
// Sanitize once, register + verify the SAME value: the relay stores the
|
|
155
|
+
// sanitized form, so equality below must compare against it, not the raw input.
|
|
156
|
+
const intentSanitized = sanitizeApprovalIntent(intent);
|
|
73
157
|
const verifier = randomBytes(32).toString('base64url');
|
|
74
158
|
const challenge = createHash('sha256').update(verifier, 'utf8').digest('base64url');
|
|
75
159
|
const deadline = now() + timeoutMs;
|
|
76
|
-
|
|
160
|
+
// Verification-neutral: what the page asks the human for is a per-card
|
|
161
|
+
// property (passkey ceremony, issuer one-time code, or nothing beyond the
|
|
162
|
+
// signed-in approval) that this runner never learns. Naming a passkey here
|
|
163
|
+
// told operators on code-verified cards to wait for a device prompt that was
|
|
164
|
+
// never coming, and then blamed the timeout on the step they never saw.
|
|
165
|
+
const timeoutError = () => new Error(`no approval within ${Math.round(timeoutMs / 1000)}s — ` +
|
|
77
166
|
'the checkout was cancelled; run again to retry');
|
|
78
167
|
// Bound EVERY request by the remaining deadline: race the fetch against the
|
|
79
168
|
// (injectable) sleep and abort the request when the deadline wins, so a
|
|
@@ -107,6 +196,11 @@ export async function runHostedApproval(opts) {
|
|
|
107
196
|
amount: target.transactionAmount,
|
|
108
197
|
currency,
|
|
109
198
|
...(consumerEmail ? { consumerEmail } : {}),
|
|
199
|
+
budget: true,
|
|
200
|
+
agentJkt,
|
|
201
|
+
maxDraws,
|
|
202
|
+
...(perTransaction !== undefined ? { perTransaction } : {}),
|
|
203
|
+
...(intentSanitized !== undefined ? { intent: intentSanitized } : {}),
|
|
110
204
|
},
|
|
111
205
|
}),
|
|
112
206
|
});
|
|
@@ -115,57 +209,293 @@ export async function runHostedApproval(opts) {
|
|
|
115
209
|
throw new Error(`could not register the hosted approval (${registerRes.status})` +
|
|
116
210
|
(body.error ? `: ${body.error}` : ''));
|
|
117
211
|
}
|
|
118
|
-
|
|
212
|
+
// GA graduation: the approval page moved into apps/web under /agent/enroll
|
|
213
|
+
// (the /api/vgs/** routes above kept their paths verbatim).
|
|
214
|
+
const approveUrl = `${base}/agent/enroll/approve?req=${challenge}`;
|
|
215
|
+
// Surface the URL as DATA first (headless relay), then as a log line + best-
|
|
216
|
+
// effort browser open for the interactive case.
|
|
217
|
+
onApprovalUrl?.(approveUrl);
|
|
119
218
|
log(`approve the purchase in your browser: ${approveUrl}`);
|
|
219
|
+
log(`budget recipient request key: ${agentJkt.slice(0, 8)}…${agentJkt.slice(-6)}`);
|
|
120
220
|
openUrl(approveUrl);
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
221
|
+
let lastHeartbeat = now();
|
|
222
|
+
// Keep the event loop alive for the whole poll wait. `defaultSleep` unref()'s
|
|
223
|
+
// its timer (so the deadline race can't hang the process past the timeout), but
|
|
224
|
+
// that means the ONLY pending work between polls is an unref'd timer — in a bare
|
|
225
|
+
// CLI invocation (no stdin/other handles) node would exit 0 mid-poll, after the
|
|
226
|
+
// first `pending` claim and before the operator finishes the passkey, so the
|
|
227
|
+
// minted token is parked but never claimed. A ref'd keepalive, cleared on every
|
|
228
|
+
// exit, holds the process open until the poll loop returns/throws.
|
|
229
|
+
const keepAlive = setInterval(() => { }, 60_000);
|
|
230
|
+
try {
|
|
231
|
+
for (;;) {
|
|
232
|
+
const res = await fetchWithDeadline(`${base}/api/vgs/agent-approval/claim`, {
|
|
233
|
+
method: 'POST',
|
|
234
|
+
headers: { 'content-type': 'application/json' },
|
|
235
|
+
body: JSON.stringify({ verifier }),
|
|
236
|
+
});
|
|
237
|
+
if (res.ok) {
|
|
238
|
+
const doc = (await res.json().catch(() => null));
|
|
239
|
+
// The operator refused — terminal and immediate. Exit the wait now
|
|
240
|
+
// rather than polling out the timeout; retrying cannot help.
|
|
241
|
+
if (doc?.status === 'declined')
|
|
242
|
+
throw new HostedApprovalDeclinedError();
|
|
243
|
+
if (doc?.status === 'completed') {
|
|
244
|
+
// A passkey-exempt token ('otp' cardholder ID&V / 'none') runs no
|
|
245
|
+
// ceremony and produces no assurance — the approval server marks the
|
|
246
|
+
// claim explicitly (derived from the owner's verified card record at
|
|
247
|
+
// complete-time). Only that marker excuses a null assuranceData; a
|
|
248
|
+
// silently dropped payload still fails exactly as before.
|
|
249
|
+
const assuranceExempt = doc.cardholderVerification === 'otp' || doc.cardholderVerification === 'none';
|
|
250
|
+
if ((doc.assuranceData === undefined || doc.assuranceData === null) && !assuranceExempt) {
|
|
251
|
+
throw new Error('hosted approval completed but carried no assuranceData');
|
|
252
|
+
}
|
|
253
|
+
// The context the approval was COMPLETED against must be exactly this
|
|
254
|
+
// run's checkout target — a divergent field means the relay entry was
|
|
255
|
+
// not ours (corruption, or a mutated registration) and the assurance
|
|
256
|
+
// is scoped to something else. Fail naming the field; no secrets here
|
|
257
|
+
// (merchant facts only).
|
|
258
|
+
const expected = {
|
|
259
|
+
tokenId,
|
|
260
|
+
merchantName: target.merchantName,
|
|
261
|
+
merchantUrl: target.merchantUrl,
|
|
262
|
+
merchantCountryCode: target.merchantCountryCode,
|
|
263
|
+
amount: target.transactionAmount,
|
|
264
|
+
currency,
|
|
265
|
+
};
|
|
266
|
+
for (const [field, want] of Object.entries(expected)) {
|
|
267
|
+
if (doc.context?.[field] !== want) {
|
|
268
|
+
throw new Error(`hosted approval context mismatch on ${field} — the approval was not for this ` +
|
|
269
|
+
'exact purchase; run the checkout again for a fresh link');
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
// A budget approval must complete AS a budget approval — a relay that
|
|
273
|
+
// dropped the flag did not complete OUR registration. Compare the
|
|
274
|
+
// boolean explicitly (it is not a string, so it lives outside the
|
|
275
|
+
// string-map loop above).
|
|
276
|
+
if (doc.context?.budget !== true) {
|
|
277
|
+
throw new Error('hosted approval context mismatch on budget — the approval was not for this ' +
|
|
149
278
|
'exact purchase; run the checkout again for a fresh link');
|
|
150
279
|
}
|
|
280
|
+
if (doc.context?.agentJkt !== agentJkt) {
|
|
281
|
+
throw new Error('hosted approval context mismatch on agentJkt — the approval was not for this ' +
|
|
282
|
+
'exact request key; run the checkout again for a fresh link');
|
|
283
|
+
}
|
|
284
|
+
// The draw ceiling must survive the relay intact too — a relay that
|
|
285
|
+
// dropped or altered maxDraws would mint a budget token whose draw
|
|
286
|
+
// count no longer matches what the operator approved. Compare exactly,
|
|
287
|
+
// the omitted/undefined case included, so a silently dropped value is
|
|
288
|
+
// caught the same as a mutated one.
|
|
289
|
+
if (doc.context?.maxDraws !== maxDraws) {
|
|
290
|
+
throw new Error('hosted approval context mismatch on maxDraws — the approval was not for this ' +
|
|
291
|
+
'exact purchase; run the checkout again for a fresh link');
|
|
292
|
+
}
|
|
293
|
+
// Same drop-or-mutate rule for the per-purchase cap: the term the
|
|
294
|
+
// operator read must be the term the token enforces.
|
|
295
|
+
if (doc.context?.perTransaction !== perTransaction) {
|
|
296
|
+
throw new Error('hosted approval context mismatch on perTransaction — the approval was not for ' +
|
|
297
|
+
'this exact purchase; run the checkout again for a fresh link');
|
|
298
|
+
}
|
|
299
|
+
// The intent is display-only, but a relay that altered it showed the
|
|
300
|
+
// operator different words than the agent sent — refuse, either way.
|
|
301
|
+
if (doc.context?.intent !== intentSanitized) {
|
|
302
|
+
throw new Error('hosted approval context mismatch on intent — the approval was not for this ' +
|
|
303
|
+
'exact purchase; run the checkout again for a fresh link');
|
|
304
|
+
}
|
|
305
|
+
if (!Number.isSafeInteger(doc.validUntil) ||
|
|
306
|
+
doc.validUntil <= Math.floor(now() / 1000)) {
|
|
307
|
+
throw new Error('hosted budget approval completed without a valid future expiry');
|
|
308
|
+
}
|
|
309
|
+
log(assuranceExempt
|
|
310
|
+
? 'approval received from the hosted page (code-verified card, no passkey).'
|
|
311
|
+
: 'approval received from the hosted page (passkey-verified card).');
|
|
312
|
+
return {
|
|
313
|
+
...assuranceFromCeremony(target, doc.assuranceData ?? null),
|
|
314
|
+
...(assuranceExempt ? { assuranceExempt: true } : {}),
|
|
315
|
+
...(typeof doc.mintToken === 'string' && doc.mintToken
|
|
316
|
+
? { mintToken: doc.mintToken }
|
|
317
|
+
: {}),
|
|
318
|
+
validUntil: doc.validUntil,
|
|
319
|
+
};
|
|
151
320
|
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
: {}),
|
|
158
|
-
};
|
|
321
|
+
// status 'pending' — the operator is still signing in / tapping.
|
|
322
|
+
}
|
|
323
|
+
else if (res.status === 404) {
|
|
324
|
+
// Registered moments ago, so absent now means expired or already claimed.
|
|
325
|
+
throw new Error('the hosted approval expired or was already used — run the checkout again for a fresh link');
|
|
159
326
|
}
|
|
160
|
-
//
|
|
327
|
+
// Any other status (429 rate bucket, transient 5xx) polls through.
|
|
328
|
+
if (now() >= deadline)
|
|
329
|
+
throw timeoutError();
|
|
330
|
+
// Heartbeat so a human staring at a terminal (or an agent tailing logs)
|
|
331
|
+
// knows the wait is live and where to approve — the loop is otherwise silent
|
|
332
|
+
// for up to the full timeout between the open and the completed claim.
|
|
333
|
+
if (now() - lastHeartbeat >= HOSTED_APPROVAL_HEARTBEAT_MS) {
|
|
334
|
+
lastHeartbeat = now();
|
|
335
|
+
const leftS = Math.max(0, Math.round((deadline - now()) / 1000));
|
|
336
|
+
log(`still waiting for approval (~${leftS}s left) — approve at ${approveUrl}`);
|
|
337
|
+
}
|
|
338
|
+
await sleep(pollIntervalMs);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
finally {
|
|
342
|
+
clearInterval(keepAlive);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
const PICKUP_CODE_SHAPE = /^[A-Za-z0-9_-]{43}$/;
|
|
346
|
+
const DECIMAL_AMOUNT_SHAPE = /^[0-9]+(\.[0-9]{1,2})?$/;
|
|
347
|
+
function pickupMismatch(field, detail) {
|
|
348
|
+
return new Error(`mandate pickup code mismatch on ${field} — ${detail}. Ask the owner to start a fresh ` +
|
|
349
|
+
'mandate in the panel for this agent');
|
|
350
|
+
}
|
|
351
|
+
export async function claimMandatePickup(opts) {
|
|
352
|
+
const { baseUrl, pickupCode, agentJkt, expectedHandoffId, tokenId, fetchImpl = fetch, sleep = defaultSleep, now = Date.now, attempts = 3, retryDelayMs = 1_000, } = opts;
|
|
353
|
+
// Both producers (runHostedApproval and the panel initiate route) mint the
|
|
354
|
+
// verifier as randomBytes(32).base64url = 43 chars; refuse anything else
|
|
355
|
+
// before it travels.
|
|
356
|
+
if (!PICKUP_CODE_SHAPE.test(pickupCode)) {
|
|
357
|
+
throw new Error('that does not look like a mandate pickup code (43 URL-safe characters) — ' +
|
|
358
|
+
'paste the code exactly as the panel displayed it');
|
|
359
|
+
}
|
|
360
|
+
if (!/^[A-Za-z0-9_-]{43}$/.test(agentJkt)) {
|
|
361
|
+
throw new Error('claiming a mandate pickup requires the exact current agent request-key JKT');
|
|
362
|
+
}
|
|
363
|
+
if (!tokenId.trim()) {
|
|
364
|
+
throw new Error('claiming a mandate pickup requires the provisioned card token id');
|
|
365
|
+
}
|
|
366
|
+
const base = stripTrailingSlashes(assertApprovalBaseUrl(baseUrl));
|
|
367
|
+
let res = null;
|
|
368
|
+
for (let attempt = 1;; attempt++) {
|
|
369
|
+
res = await fetchImpl(`${base}/api/vgs/agent-approval/claim`, {
|
|
370
|
+
method: 'POST',
|
|
371
|
+
headers: { 'content-type': 'application/json' },
|
|
372
|
+
body: JSON.stringify({ verifier: pickupCode }),
|
|
373
|
+
});
|
|
374
|
+
// 4xx other than 404/429 is a terminal shape/validation answer; 404 is the
|
|
375
|
+
// single-use/TTL answer. Only rate-limit and 5xx are worth a bounded retry —
|
|
376
|
+
// the entry is durable until claimed or expired.
|
|
377
|
+
if (res.ok || res.status === 404 || (res.status < 500 && res.status !== 429))
|
|
378
|
+
break;
|
|
379
|
+
if (attempt >= attempts)
|
|
380
|
+
break;
|
|
381
|
+
await sleep(retryDelayMs);
|
|
382
|
+
}
|
|
383
|
+
if (res.status === 404) {
|
|
384
|
+
throw new Error('this pickup code has expired or was already used — codes are single-use and live ' +
|
|
385
|
+
'about 10 minutes. Ask the owner to start a fresh mandate in the panel');
|
|
386
|
+
}
|
|
387
|
+
if (!res.ok) {
|
|
388
|
+
const body = (await res.json().catch(() => ({})));
|
|
389
|
+
throw new Error(`could not redeem the pickup code (${res.status})` + (body.error ? `: ${body.error}` : ''));
|
|
390
|
+
}
|
|
391
|
+
const doc = (await res.json().catch(() => null));
|
|
392
|
+
if (doc?.status === 'declined')
|
|
393
|
+
throw new HostedApprovalDeclinedError();
|
|
394
|
+
if (doc?.status !== 'completed') {
|
|
395
|
+
// A panel-issued pickup code is parked already-completed (the owner approved
|
|
396
|
+
// in the panel before the code existed). `pending` means this verifier came
|
|
397
|
+
// from an in-flight runner ceremony, not a mandate handoff — redeeming it
|
|
398
|
+
// here would race the runner that owns it.
|
|
399
|
+
throw new Error('this code is not a completed mandate handoff — it looks like an in-progress approval. ' +
|
|
400
|
+
'Use the pickup code the panel displayed after the owner approved the mandate');
|
|
401
|
+
}
|
|
402
|
+
const ctx = doc.context ?? {};
|
|
403
|
+
if (ctx.budget !== true) {
|
|
404
|
+
throw pickupMismatch('budget', 'the approval is a one-purchase approval, not a spend budget');
|
|
405
|
+
}
|
|
406
|
+
// A budget names exactly ONE agent, by one of two pins.
|
|
407
|
+
//
|
|
408
|
+
// - `agentJkt` — the panel flow: the owner picked an agent that already
|
|
409
|
+
// existed, so the approval names its key and this runtime compares keys.
|
|
410
|
+
// - `handoffId` — the Add-agent dialog: the budget was minted before any
|
|
411
|
+
// agent existed, so it names the handoff the agent would go on to redeem.
|
|
412
|
+
// This runtime redeemed one moments ago and knows which, so the comparison
|
|
413
|
+
// is just as concrete — it is simply a different identifier.
|
|
414
|
+
//
|
|
415
|
+
// EXACTLY one, enforced before either is compared. Neither pin is an unbound
|
|
416
|
+
// budget, and "no pin" must never be the quiet way past a binding check.
|
|
417
|
+
// BOTH pins is worse: it names two agents, and checking whichever one happens
|
|
418
|
+
// to match would let the weaker claim decide. The signer refuses to mint such
|
|
419
|
+
// a context and the approval store refuses to hold one, so this is the third
|
|
420
|
+
// and last place to say it — the one that runs on material already in hand.
|
|
421
|
+
const hasHandoffPin = typeof ctx.handoffId === 'string';
|
|
422
|
+
const hasAgentPin = typeof ctx.agentJkt === 'string';
|
|
423
|
+
if (hasHandoffPin === hasAgentPin) {
|
|
424
|
+
throw pickupMismatch('agentJkt', hasHandoffPin
|
|
425
|
+
? 'this budget names two different agents at once — it binds to a handoff or to a key, never both'
|
|
426
|
+
: 'this budget names no agent at all, so nothing can prove it belongs to this runtime');
|
|
427
|
+
}
|
|
428
|
+
if (hasHandoffPin) {
|
|
429
|
+
if (!expectedHandoffId || ctx.handoffId !== expectedHandoffId) {
|
|
430
|
+
throw pickupMismatch('handoffId', 'this budget belongs to a different agent handoff than the one this runtime just claimed');
|
|
161
431
|
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
432
|
+
}
|
|
433
|
+
else if (ctx.agentJkt !== agentJkt) {
|
|
434
|
+
throw pickupMismatch('agentJkt', 'the owner approved this mandate for a different agent key than this runtime holds');
|
|
435
|
+
}
|
|
436
|
+
if (ctx.tokenId !== tokenId) {
|
|
437
|
+
throw pickupMismatch('tokenId', "the mandate was approved against a different enrolled card than this runtime's card " +
|
|
438
|
+
'authority points at (the owner may have re-enrolled a card — re-run grant-card first)');
|
|
439
|
+
}
|
|
440
|
+
const ceiling = ctx.amount;
|
|
441
|
+
if (typeof ceiling !== 'string' || !DECIMAL_AMOUNT_SHAPE.test(ceiling) || Number(ceiling) <= 0) {
|
|
442
|
+
throw pickupMismatch('amount', 'the approved ceiling is missing or malformed');
|
|
443
|
+
}
|
|
444
|
+
const currency = ctx.currency;
|
|
445
|
+
if (typeof currency !== 'string' || !/^[A-Z]{3}$/.test(currency)) {
|
|
446
|
+
throw pickupMismatch('currency', 'the approved currency is missing or malformed');
|
|
447
|
+
}
|
|
448
|
+
const maxDraws = ctx.maxDraws;
|
|
449
|
+
if (!Number.isSafeInteger(maxDraws) || maxDraws < 1) {
|
|
450
|
+
throw pickupMismatch('maxDraws', 'the approved purchase count is missing or malformed');
|
|
451
|
+
}
|
|
452
|
+
const perTransaction = ctx.perTransaction;
|
|
453
|
+
if (perTransaction !== undefined) {
|
|
454
|
+
if (typeof perTransaction !== 'string' ||
|
|
455
|
+
!DECIMAL_AMOUNT_SHAPE.test(perTransaction) ||
|
|
456
|
+
Number(perTransaction) <= 0 ||
|
|
457
|
+
Number(perTransaction) > Number(ceiling)) {
|
|
458
|
+
throw pickupMismatch('perTransaction', 'the approved per-purchase cap is malformed');
|
|
165
459
|
}
|
|
166
|
-
// Any other status (429 rate bucket, transient 5xx) polls through.
|
|
167
|
-
if (now() >= deadline)
|
|
168
|
-
throw timeoutError();
|
|
169
|
-
await sleep(pollIntervalMs);
|
|
170
460
|
}
|
|
461
|
+
const merchantName = ctx.merchantName;
|
|
462
|
+
const merchantUrl = ctx.merchantUrl;
|
|
463
|
+
const merchantCountryCode = ctx.merchantCountryCode;
|
|
464
|
+
if (typeof merchantName !== 'string' ||
|
|
465
|
+
!merchantName.trim() ||
|
|
466
|
+
typeof merchantUrl !== 'string' ||
|
|
467
|
+
!merchantUrl.trim() ||
|
|
468
|
+
typeof merchantCountryCode !== 'string' ||
|
|
469
|
+
!merchantCountryCode.trim()) {
|
|
470
|
+
throw pickupMismatch('merchant', 'the approved budget target is missing or malformed');
|
|
471
|
+
}
|
|
472
|
+
if (typeof doc.mintToken !== 'string' || !doc.mintToken) {
|
|
473
|
+
throw new Error('the mandate handoff carried no mint token — the deployment that parked it ran the ' +
|
|
474
|
+
'dev-auth stub, which cannot mint draw authority. Re-initiate against a real deployment');
|
|
475
|
+
}
|
|
476
|
+
if (!Number.isSafeInteger(doc.validUntil) ||
|
|
477
|
+
doc.validUntil <= Math.floor(now() / 1000)) {
|
|
478
|
+
throw new Error('the mandate handoff carried no valid future expiry — ask the owner to re-initiate');
|
|
479
|
+
}
|
|
480
|
+
// Same exemption contract as runHostedApproval: only the server's explicit
|
|
481
|
+
// code-verified-card marker excuses a missing assurance payload.
|
|
482
|
+
const assuranceExempt = doc.cardholderVerification === 'otp' || doc.cardholderVerification === 'none';
|
|
483
|
+
if ((doc.assuranceData === undefined || doc.assuranceData === null) && !assuranceExempt) {
|
|
484
|
+
throw new Error('the mandate handoff completed but carried no assuranceData');
|
|
485
|
+
}
|
|
486
|
+
return {
|
|
487
|
+
assuranceData: doc.assuranceData ?? null,
|
|
488
|
+
assuranceExempt,
|
|
489
|
+
mintToken: doc.mintToken,
|
|
490
|
+
validUntil: doc.validUntil,
|
|
491
|
+
ceiling,
|
|
492
|
+
currency,
|
|
493
|
+
maxDraws: maxDraws,
|
|
494
|
+
...(perTransaction !== undefined ? { perTransaction: perTransaction } : {}),
|
|
495
|
+
merchant: {
|
|
496
|
+
name: merchantName,
|
|
497
|
+
url: merchantUrl,
|
|
498
|
+
countryCode: merchantCountryCode,
|
|
499
|
+
},
|
|
500
|
+
};
|
|
171
501
|
}
|
|
@@ -1,3 +1,10 @@
|
|
|
1
|
-
export { createCliCheckoutEngine, type CliReviewInput, type CliReviewFacts, type CliPayInput, type CliReceiptFacts, } from './cli-engine.js';
|
|
1
|
+
export { createCliCheckoutEngine, type CliReviewInput, type CliReviewFacts, type CliPayInput, type CliReceiptFacts, type CliStartMandateInput, type CliMandateFacts, type CliEngineDeps, type ReceiptWriteObservation, CheckoutReviewRefusedError, } from './cli-engine.js';
|
|
2
2
|
export { prepareCheckout, submitApprovedCheckout, runCheckout, InMemoryPreparedCheckoutStore, } from './executor.js';
|
|
3
|
-
export type { CheckoutResult, CheckoutReview, CheckoutOutcome } from './executor.js';
|
|
3
|
+
export type { CheckoutResult, CheckoutReview, CheckoutOutcome, CheckoutFailureCode, CheckoutRoute, } from './executor.js';
|
|
4
|
+
export { readConfirmedMerchants, type ConfirmedMerchant, type ConfirmedCharge, } from './confirmed-merchants.js';
|
|
5
|
+
export { KNOWN_MERCHANT_IDENTITIES, type MerchantIdentity } from './known-merchants.js';
|
|
6
|
+
export { RECEIPT_DIR } from './receipt-dir.js';
|
|
7
|
+
export { HostedApprovalDeclinedError, sanitizeApprovalIntent, APPROVAL_INTENT_MAX_CHARS, } from './hosted-approval.js';
|
|
8
|
+
export { createCardMandate, drawFromMandate, MandateDrawDeclinedError, DEFAULT_MANDATE_MAX_DRAWS, type CreateCardMandateInput, type CreateCardMandateDeps, type CardMandateFacts, type DrawFromMandateInput, type DrawFromMandateDeps, type DrawResult, type CardMandateMerchant, } from './mandate/card-mandate.js';
|
|
9
|
+
export { MandateLedger, remainingMinor, defaultLedgerPath, CARD_MANDATE_LEDGER_VERSION, type CardMandateRecord, type CardMandateLedgerFile, type CardMandateDraw, type CardMandateReservation, } from './mandate/mandate-ledger.js';
|
|
10
|
+
export { WEB_BOT_AUTH_MAX_TTL_SECONDS, WEB_BOT_AUTH_DEFAULT_TTL_SECONDS, WEB_BOT_AUTH_SIGNATURE_LABEL, WEB_BOT_AUTH_TAG, WEB_BOT_AUTH_COVERED_COMPONENTS, buildWebBotAuthHeaders, buildWebBotAuthHeadersAsync, webBotAuthHeadersOrNone, webBotAuthHeadersOrNoneAsync, resolveWebBotAuthConfig, type WebBotAuthPrivateJwk, type WebBotAuthSigningKey, type WebBotAuthConfig, type WebBotAuthHeaders, type BuildHeadersParams, } from './web-bot-auth.js';
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
// Public API of @visa/checkout-engine. The pay_merchant tool in @visa/cli
|
|
2
2
|
// consumes createCliCheckoutEngine() through a structural seam; the core engine
|
|
3
3
|
// primitives are re-exported for direct/embedded use.
|
|
4
|
-
export { createCliCheckoutEngine, } from './cli-engine.js';
|
|
4
|
+
export { createCliCheckoutEngine, CheckoutReviewRefusedError, } from './cli-engine.js';
|
|
5
5
|
export { prepareCheckout, submitApprovedCheckout, runCheckout, InMemoryPreparedCheckoutStore, } from './executor.js';
|
|
6
|
+
export { readConfirmedMerchants, } from './confirmed-merchants.js';
|
|
7
|
+
export { KNOWN_MERCHANT_IDENTITIES } from './known-merchants.js';
|
|
8
|
+
export { RECEIPT_DIR } from './receipt-dir.js';
|
|
9
|
+
export { HostedApprovalDeclinedError, sanitizeApprovalIntent, APPROVAL_INTENT_MAX_CHARS, } from './hosted-approval.js';
|
|
10
|
+
export { createCardMandate, drawFromMandate, MandateDrawDeclinedError, DEFAULT_MANDATE_MAX_DRAWS, } from './mandate/card-mandate.js';
|
|
11
|
+
export { MandateLedger, remainingMinor, defaultLedgerPath, CARD_MANDATE_LEDGER_VERSION, } from './mandate/mandate-ledger.js';
|
|
12
|
+
export { WEB_BOT_AUTH_MAX_TTL_SECONDS, WEB_BOT_AUTH_DEFAULT_TTL_SECONDS, WEB_BOT_AUTH_SIGNATURE_LABEL, WEB_BOT_AUTH_TAG, WEB_BOT_AUTH_COVERED_COMPONENTS, buildWebBotAuthHeaders, buildWebBotAuthHeadersAsync, webBotAuthHeadersOrNone, webBotAuthHeadersOrNoneAsync, resolveWebBotAuthConfig, } from './web-bot-auth.js';
|
|
@@ -6,6 +6,12 @@ export type CardCredential = {
|
|
|
6
6
|
cardholderName: string;
|
|
7
7
|
/** Dynamic payment credential expiry, distinct from the card expiration. */
|
|
8
8
|
credentialExpiresAt?: string;
|
|
9
|
+
/**
|
|
10
|
+
* Non-sensitive, bounded request-correlation handles returned by the mint
|
|
11
|
+
* route. These are evidence pointers, not card credential material.
|
|
12
|
+
*/
|
|
13
|
+
vgsTraceId?: string;
|
|
14
|
+
networkCorrelationId?: string;
|
|
9
15
|
};
|
|
10
16
|
export type InstrumentContext = {
|
|
11
17
|
merchantHost: string;
|
|
@@ -24,6 +30,7 @@ export declare const OFFICIAL_TEST_PANS: {
|
|
|
24
30
|
readonly visaSlowConfirm: "4000000000000069";
|
|
25
31
|
readonly visaUnknownOutcome: "4000000000000044";
|
|
26
32
|
readonly visaChallenge: "4000000000000010";
|
|
33
|
+
readonly visaEmailOtp: "4000000000000077";
|
|
27
34
|
};
|
|
28
35
|
export type TestCardOptions = {
|
|
29
36
|
pan?: string;
|
|
@@ -22,6 +22,10 @@ export const OFFICIAL_TEST_PANS = {
|
|
|
22
22
|
// page embedding an ACS challenge iframe that never completes — exercises
|
|
23
23
|
// the observer's action-required (issuer challenge) path.
|
|
24
24
|
visaChallenge: '4000000000000010',
|
|
25
|
+
// Ending 0077: the fixture acquirer emails a one-time code to the agent
|
|
26
|
+
// inbox and shows a 'check your email for a code' page with a one-time-code
|
|
27
|
+
// field — exercises the executor's agent-resolvable email-OTP subroutine.
|
|
28
|
+
visaEmailOtp: '4000000000000077',
|
|
25
29
|
};
|
|
26
30
|
function futureExpiry() {
|
|
27
31
|
const now = new Date();
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type MerchantIdentity = {
|
|
2
|
+
/** Human-recognizable merchant name. */
|
|
3
|
+
name: string;
|
|
4
|
+
/** What the merchant is, e.g. "charity (animal rescue)". */
|
|
5
|
+
category: string;
|
|
6
|
+
/** The merchant's own site, when known — not the checkout URL. */
|
|
7
|
+
website?: string;
|
|
8
|
+
};
|
|
9
|
+
/** Checkout URL → who is actually behind it. Confirmed live 2026-07/2026-08. */
|
|
10
|
+
export declare const KNOWN_MERCHANT_IDENTITIES: Readonly<Record<string, MerchantIdentity>>;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Curated identities for checkout URLs the registry cannot name on its own.
|
|
2
|
+
//
|
|
3
|
+
// Hosted payment links are anonymous by construction: the page host is the
|
|
4
|
+
// PSP's (donate.stripe.com, buy.stripe.com), the path is an opaque token, and
|
|
5
|
+
// the receipt records nothing else. So a registry row for a completed payment
|
|
6
|
+
// link answers "where can I use my card?" with a string no human recognizes.
|
|
7
|
+
// This map carries the once-per-link human identification, keyed by the exact
|
|
8
|
+
// checkout URL the receipts group on.
|
|
9
|
+
//
|
|
10
|
+
// Curation rule: an entry is added only after a checkout at that URL completed
|
|
11
|
+
// for real (engine outcome 'confirmed' + VIC APPROVED — the same admission rule
|
|
12
|
+
// confirmed-merchants.ts enforces) and the merchant behind the link was
|
|
13
|
+
// identified by a person. The map never creates registry rows; it only names
|
|
14
|
+
// rows the device's own receipts already earned. A URL missing here still
|
|
15
|
+
// lists — it just shows as its host.
|
|
16
|
+
/** Checkout URL → who is actually behind it. Confirmed live 2026-07/2026-08. */
|
|
17
|
+
export const KNOWN_MERCHANT_IDENTITIES = {
|
|
18
|
+
'https://donate.stripe.com/3cscPcaqY20H8ta4gh': {
|
|
19
|
+
name: 'NC Seaside Animal Rescue',
|
|
20
|
+
category: 'charity (animal rescue, NC nonprofit)',
|
|
21
|
+
website: 'https://www.ncseasideanimalrescue.org/make-a-donation',
|
|
22
|
+
},
|
|
23
|
+
'https://donate.stripe.com/14k3ei9TYgwFclq145': {
|
|
24
|
+
name: 'FreeCAD',
|
|
25
|
+
category: 'open-source project donation (CAD software)',
|
|
26
|
+
website: 'https://www.freecad.org',
|
|
27
|
+
},
|
|
28
|
+
'https://buy.stripe.com/dR6eWGaK41MX2YgaEF': {
|
|
29
|
+
name: 'scribepod',
|
|
30
|
+
category: 'indie creator tip jar (AI podcast project)',
|
|
31
|
+
website: 'https://github.com/yacineMTB/scribepod',
|
|
32
|
+
},
|
|
33
|
+
'https://donate.stripe.com/7sIcNj9Gs4WO6d29AA': {
|
|
34
|
+
name: 'n0bleKing GRAFFITI',
|
|
35
|
+
category: 'indie creator tip jar (pixel art and fonts)',
|
|
36
|
+
website: 'https://nobleking-graffiti.itch.io',
|
|
37
|
+
},
|
|
38
|
+
};
|