shopstack 0.2.5 → 0.3.0
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 +117 -39
- package/SKILL.md +45 -32
- package/bin/shopstack +1 -3
- package/package.json +13 -23
- package/specs/openapi.json +3293 -0
- package/src/checkout-monitor.js +126 -0
- package/src/cli.js +232 -142
- package/src/client.d.ts +258 -26
- package/src/client.js +188 -173
- package/src/config.d.ts +7 -0
- package/src/config.js +12 -0
- package/src/reservation-progress.js +132 -0
- package/LICENSE +0 -21
package/src/client.js
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
import { randomBytes, randomUUID } from "node:crypto";
|
|
2
|
-
import
|
|
2
|
+
import { readReservationProgress } from "./reservation-progress.js";
|
|
3
|
+
import { resolveProfileBaseUrl } from "./config.js";
|
|
4
|
+
import { checkoutMonitor } from "./checkout-monitor.js";
|
|
3
5
|
|
|
4
6
|
const TERMINAL_STATUSES = new Set(["complete", "failed", "cancelled"]);
|
|
7
|
+
const TERMINAL_RESERVATION_STATUSES = new Set([
|
|
8
|
+
"confirmed",
|
|
9
|
+
"cancelled",
|
|
10
|
+
"booking_unknown",
|
|
11
|
+
"cancellation_unknown",
|
|
12
|
+
]);
|
|
5
13
|
|
|
6
14
|
export class ShopstackApiError extends Error {
|
|
7
15
|
constructor(message, { code = "api_error", requestId, status } = {}) {
|
|
@@ -31,122 +39,17 @@ function delay(milliseconds) {
|
|
|
31
39
|
: new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
32
40
|
}
|
|
33
41
|
|
|
34
|
-
function presentationRevision(checkout) {
|
|
35
|
-
return Number.isInteger(checkout?.presentation_revision)
|
|
36
|
-
? checkout.presentation_revision
|
|
37
|
-
: checkout.revision;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
class CheckoutUpdateWatcher {
|
|
41
|
-
constructor(client, checkoutId) {
|
|
42
|
-
this.client = client;
|
|
43
|
-
this.checkoutId = checkoutId;
|
|
44
|
-
this.latestRevision = 0;
|
|
45
|
-
this.waiter = undefined;
|
|
46
|
-
this.socket = undefined;
|
|
47
|
-
this.unavailable = typeof client.WebSocket !== "function";
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
async connect() {
|
|
51
|
-
if (this.socket || this.unavailable) return;
|
|
52
|
-
try {
|
|
53
|
-
const subscription = await this.client.createCheckoutUpdateSubscription(
|
|
54
|
-
this.checkoutId,
|
|
55
|
-
);
|
|
56
|
-
const socket = new this.client.WebSocket(subscription.socket_url, [
|
|
57
|
-
subscription.protocol,
|
|
58
|
-
subscription.token,
|
|
59
|
-
]);
|
|
60
|
-
this.socket = socket;
|
|
61
|
-
socket.addEventListener("message", (event) => {
|
|
62
|
-
let payload;
|
|
63
|
-
try {
|
|
64
|
-
payload = JSON.parse(String(event.data));
|
|
65
|
-
} catch {
|
|
66
|
-
return;
|
|
67
|
-
}
|
|
68
|
-
if (
|
|
69
|
-
payload?.type !== "checkout_changed" ||
|
|
70
|
-
!Number.isInteger(payload.presentation_revision) ||
|
|
71
|
-
payload.presentation_revision < 1 ||
|
|
72
|
-
typeof payload.terminal !== "boolean"
|
|
73
|
-
) {
|
|
74
|
-
return;
|
|
75
|
-
}
|
|
76
|
-
this.latestRevision = Math.max(
|
|
77
|
-
this.latestRevision,
|
|
78
|
-
payload.presentation_revision,
|
|
79
|
-
);
|
|
80
|
-
this.waiter?.();
|
|
81
|
-
});
|
|
82
|
-
const unavailable = () => {
|
|
83
|
-
this.unavailable = true;
|
|
84
|
-
this.waiter?.();
|
|
85
|
-
};
|
|
86
|
-
socket.addEventListener("error", unavailable);
|
|
87
|
-
socket.addEventListener("close", unavailable);
|
|
88
|
-
} catch {
|
|
89
|
-
this.unavailable = true;
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
async wait(afterRevision, timeoutMs) {
|
|
94
|
-
await this.connect();
|
|
95
|
-
if (this.latestRevision > afterRevision) return { kind: "changed" };
|
|
96
|
-
if (!this.unavailable) {
|
|
97
|
-
const changed = await new Promise((resolve) => {
|
|
98
|
-
let settled = false;
|
|
99
|
-
const settle = (value) => {
|
|
100
|
-
if (settled) return;
|
|
101
|
-
settled = true;
|
|
102
|
-
this.waiter = undefined;
|
|
103
|
-
clearTimeout(timer);
|
|
104
|
-
resolve(value);
|
|
105
|
-
};
|
|
106
|
-
this.waiter = () =>
|
|
107
|
-
settle(
|
|
108
|
-
this.latestRevision > afterRevision ? "changed" : "unavailable",
|
|
109
|
-
);
|
|
110
|
-
const timer = setTimeout(
|
|
111
|
-
() => settle("timeout"),
|
|
112
|
-
Math.max(1, Math.min(timeoutMs, 25_000)),
|
|
113
|
-
);
|
|
114
|
-
});
|
|
115
|
-
if (changed === "changed") return { kind: "changed" };
|
|
116
|
-
if (changed === "timeout") return { kind: "refresh" };
|
|
117
|
-
}
|
|
118
|
-
try {
|
|
119
|
-
const checkout = await this.client.waitForCheckoutUpdate(
|
|
120
|
-
this.checkoutId,
|
|
121
|
-
afterRevision,
|
|
122
|
-
Math.max(1, Math.min(25, Math.ceil(timeoutMs / 1_000))),
|
|
123
|
-
);
|
|
124
|
-
return checkout === undefined
|
|
125
|
-
? { kind: "refresh" }
|
|
126
|
-
: { checkout, kind: "checkout" };
|
|
127
|
-
} catch {
|
|
128
|
-
return { kind: "unavailable" };
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
close() {
|
|
133
|
-
try {
|
|
134
|
-
this.socket?.close(1000, "Checkout monitoring complete");
|
|
135
|
-
} catch {
|
|
136
|
-
// Monitoring teardown must not alter the checkout result.
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
42
|
export class ShopstackClient {
|
|
142
43
|
constructor({
|
|
143
44
|
apiKey,
|
|
144
|
-
baseUrl =
|
|
45
|
+
baseUrl = process.env.SHOPSTACK_API_URL,
|
|
145
46
|
fetch: fetchImplementation = globalThis.fetch,
|
|
146
|
-
webSocket
|
|
47
|
+
webSocket = globalThis.WebSocket,
|
|
147
48
|
} = {}) {
|
|
148
49
|
if (typeof baseUrl !== "string" || !/^https?:\/\//u.test(baseUrl)) {
|
|
149
|
-
throw new TypeError(
|
|
50
|
+
throw new TypeError(
|
|
51
|
+
"Set baseUrl or SHOPSTACK_API_URL to the intended Shopstack HTTP(S) API endpoint.",
|
|
52
|
+
);
|
|
150
53
|
}
|
|
151
54
|
if (typeof fetchImplementation !== "function") {
|
|
152
55
|
throw new TypeError("a fetch implementation is required");
|
|
@@ -154,7 +57,7 @@ export class ShopstackClient {
|
|
|
154
57
|
this.apiKey = apiKey;
|
|
155
58
|
this.baseUrl = baseUrl.replace(/\/+$/u, "");
|
|
156
59
|
this.fetch = fetchImplementation;
|
|
157
|
-
this.
|
|
60
|
+
this.webSocket = webSocket;
|
|
158
61
|
}
|
|
159
62
|
|
|
160
63
|
async request(
|
|
@@ -164,10 +67,17 @@ export class ShopstackClient {
|
|
|
164
67
|
body,
|
|
165
68
|
idempotencyKey: mutationKey,
|
|
166
69
|
method = "GET",
|
|
70
|
+
onProgress,
|
|
167
71
|
signupRecoveryKey: recoveryKey,
|
|
72
|
+
signal,
|
|
168
73
|
} = {},
|
|
169
74
|
) {
|
|
170
75
|
const headers = new Headers({ Accept: "application/json" });
|
|
76
|
+
const progress =
|
|
77
|
+
typeof onProgress === "function" &&
|
|
78
|
+
method === "POST" &&
|
|
79
|
+
/^\/reservations(?:\/|$)/u.test(path);
|
|
80
|
+
if (progress) headers.set("Accept", "application/x-ndjson");
|
|
171
81
|
const authorization = bearerToken ?? this.apiKey;
|
|
172
82
|
if (authorization) {
|
|
173
83
|
headers.set("Authorization", `Bearer ${authorization}`);
|
|
@@ -179,29 +89,56 @@ export class ShopstackClient {
|
|
|
179
89
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
180
90
|
headers,
|
|
181
91
|
method,
|
|
92
|
+
signal,
|
|
182
93
|
});
|
|
183
|
-
const raw = await response.text();
|
|
184
94
|
let payload;
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
95
|
+
let status = response.status;
|
|
96
|
+
if (
|
|
97
|
+
progress &&
|
|
98
|
+
response.headers.get("content-type")?.split(";")[0] ===
|
|
99
|
+
"application/x-ndjson"
|
|
100
|
+
) {
|
|
101
|
+
try {
|
|
102
|
+
({ payload, status } = await readReservationProgress(
|
|
103
|
+
response,
|
|
104
|
+
onProgress,
|
|
105
|
+
));
|
|
106
|
+
} catch {
|
|
107
|
+
const requestId = response.headers.get("x-request-id");
|
|
108
|
+
throw new ShopstackApiError(
|
|
109
|
+
"The reservation response was interrupted or invalid. Check the reservation state before retrying; booking may already have occurred.",
|
|
110
|
+
{
|
|
111
|
+
code: "reservation_response_incomplete",
|
|
112
|
+
requestId: /^req_[a-f0-9]{32}$/u.test(requestId ?? "")
|
|
113
|
+
? requestId
|
|
114
|
+
: undefined,
|
|
115
|
+
status: 502,
|
|
116
|
+
},
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
} else {
|
|
120
|
+
const raw = await response.text();
|
|
121
|
+
try {
|
|
122
|
+
payload = raw ? JSON.parse(raw) : undefined;
|
|
123
|
+
} catch {
|
|
124
|
+
throw new ShopstackApiError("Shopstack returned invalid JSON.", {
|
|
125
|
+
status: response.status,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
191
128
|
}
|
|
192
|
-
if (
|
|
129
|
+
if (status < 200 || status >= 300) {
|
|
193
130
|
const error = payload?.error;
|
|
194
131
|
throw new ShopstackApiError(
|
|
195
132
|
typeof error?.message === "string"
|
|
196
133
|
? error.message
|
|
197
|
-
: `Shopstack request failed with status ${
|
|
134
|
+
: `Shopstack request failed with status ${status}.`,
|
|
198
135
|
{
|
|
199
136
|
code: typeof error?.code === "string" ? error.code : "api_error",
|
|
200
137
|
requestId:
|
|
201
138
|
typeof error?.request_id === "string"
|
|
202
139
|
? error.request_id
|
|
203
140
|
: undefined,
|
|
204
|
-
status
|
|
141
|
+
status,
|
|
205
142
|
},
|
|
206
143
|
);
|
|
207
144
|
}
|
|
@@ -286,6 +223,7 @@ export class ShopstackClient {
|
|
|
286
223
|
accountType,
|
|
287
224
|
email: normalizedEmail,
|
|
288
225
|
});
|
|
226
|
+
resolveProfileBaseUrl(pending, this.baseUrl);
|
|
289
227
|
if (pending?.expiresAt && Date.parse(pending.expiresAt) <= Date.now()) {
|
|
290
228
|
await storage.deletePending(pending);
|
|
291
229
|
await publish("expired", {
|
|
@@ -296,6 +234,7 @@ export class ShopstackClient {
|
|
|
296
234
|
}
|
|
297
235
|
pending ??= {
|
|
298
236
|
accountType,
|
|
237
|
+
baseUrl: this.baseUrl,
|
|
299
238
|
attemptId: `attempt-${randomUUID()}`,
|
|
300
239
|
email: normalizedEmail,
|
|
301
240
|
idempotencyKey: idempotencyKey("account"),
|
|
@@ -432,6 +371,96 @@ export class ShopstackClient {
|
|
|
432
371
|
});
|
|
433
372
|
}
|
|
434
373
|
|
|
374
|
+
createReservation(reservation, { idempotencyKey: key, onProgress } = {}) {
|
|
375
|
+
return this.request("/reservations", {
|
|
376
|
+
body: reservation,
|
|
377
|
+
idempotencyKey: key ?? idempotencyKey("reservation"),
|
|
378
|
+
onProgress,
|
|
379
|
+
method: "POST",
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
getReservation(reservationId) {
|
|
384
|
+
return this.request(`/reservations/${encodeURIComponent(reservationId)}`);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
listReservationOptions(reservationId, { limit = 6, offset = 0 } = {}) {
|
|
388
|
+
const query = new URLSearchParams({
|
|
389
|
+
offset: String(offset),
|
|
390
|
+
limit: String(limit),
|
|
391
|
+
});
|
|
392
|
+
return this.request(
|
|
393
|
+
`/reservations/${encodeURIComponent(reservationId)}/options?${query.toString()}`,
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
getReservationOption(reservationId, optionId) {
|
|
398
|
+
return this.request(
|
|
399
|
+
`/reservations/${encodeURIComponent(reservationId)}/options/${encodeURIComponent(optionId)}`,
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
sendReservationMessage(
|
|
404
|
+
reservationId,
|
|
405
|
+
content,
|
|
406
|
+
expectedRevision,
|
|
407
|
+
{ idempotencyKey: key, onProgress } = {},
|
|
408
|
+
) {
|
|
409
|
+
return this.request(
|
|
410
|
+
`/reservations/${encodeURIComponent(reservationId)}/messages`,
|
|
411
|
+
{
|
|
412
|
+
body: { content, expected_revision: expectedRevision },
|
|
413
|
+
idempotencyKey: key ?? idempotencyKey("reservation-message"),
|
|
414
|
+
onProgress,
|
|
415
|
+
method: "POST",
|
|
416
|
+
},
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
cancelReservation(
|
|
421
|
+
reservationId,
|
|
422
|
+
expectedRevision,
|
|
423
|
+
{ idempotencyKey: key, onProgress } = {},
|
|
424
|
+
) {
|
|
425
|
+
return this.request(
|
|
426
|
+
`/reservations/${encodeURIComponent(reservationId)}/cancel`,
|
|
427
|
+
{
|
|
428
|
+
body: { expected_revision: expectedRevision },
|
|
429
|
+
idempotencyKey: key ?? idempotencyKey("reservation-cancel"),
|
|
430
|
+
onProgress,
|
|
431
|
+
method: "POST",
|
|
432
|
+
},
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
async runReservation(reservationRequest, options = {}) {
|
|
437
|
+
let reservation = await this.createReservation(reservationRequest, {
|
|
438
|
+
idempotencyKey: options.idempotencyKey,
|
|
439
|
+
});
|
|
440
|
+
while (!TERMINAL_RESERVATION_STATUSES.has(reservation.status)) {
|
|
441
|
+
if (typeof options.onTurn === "function") {
|
|
442
|
+
await options.onTurn(reservation);
|
|
443
|
+
}
|
|
444
|
+
if (typeof options.respond !== "function") return reservation;
|
|
445
|
+
const content = await options.respond(reservation);
|
|
446
|
+
if (typeof content !== "string" || content.trim().length === 0) {
|
|
447
|
+
return reservation;
|
|
448
|
+
}
|
|
449
|
+
reservation = await this.sendReservationMessage(
|
|
450
|
+
reservation.id,
|
|
451
|
+
content.trim(),
|
|
452
|
+
reservation.revision,
|
|
453
|
+
{
|
|
454
|
+
idempotencyKey: `reservation-message-${reservation.id}-${reservation.revision}`,
|
|
455
|
+
},
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
if (typeof options.onTurn === "function") {
|
|
459
|
+
await options.onTurn(reservation);
|
|
460
|
+
}
|
|
461
|
+
return reservation;
|
|
462
|
+
}
|
|
463
|
+
|
|
435
464
|
createCheckout(checkout, { idempotencyKey: key } = {}) {
|
|
436
465
|
return this.request("/checkout", {
|
|
437
466
|
body: checkout,
|
|
@@ -444,29 +473,37 @@ export class ShopstackClient {
|
|
|
444
473
|
return this.request(`/checkout/${encodeURIComponent(checkoutId)}`);
|
|
445
474
|
}
|
|
446
475
|
|
|
447
|
-
|
|
476
|
+
waitForCheckoutUpdate(checkoutId, { after, wait = 25, signal } = {}) {
|
|
477
|
+
if (!Number.isSafeInteger(after) || after < 0) {
|
|
478
|
+
throw new TypeError("after must be a non-negative safe integer.");
|
|
479
|
+
}
|
|
480
|
+
if (!Number.isSafeInteger(wait) || wait < 1 || wait > 25) {
|
|
481
|
+
throw new TypeError("wait must be an integer from 1 through 25.");
|
|
482
|
+
}
|
|
483
|
+
const query = new URLSearchParams({
|
|
484
|
+
after: String(after),
|
|
485
|
+
wait: String(wait),
|
|
486
|
+
});
|
|
448
487
|
return this.request(
|
|
449
|
-
`/checkout/${encodeURIComponent(checkoutId)}/
|
|
450
|
-
{
|
|
451
|
-
idempotencyKey: key ?? idempotencyKey("live-view"),
|
|
452
|
-
method: "POST",
|
|
453
|
-
},
|
|
488
|
+
`/checkout/${encodeURIComponent(checkoutId)}/updates?${query}`,
|
|
489
|
+
{ signal },
|
|
454
490
|
);
|
|
455
491
|
}
|
|
456
492
|
|
|
457
|
-
|
|
493
|
+
subscribeToCheckoutUpdates(checkoutId, { signal } = {}) {
|
|
458
494
|
return this.request(`/checkout/${encodeURIComponent(checkoutId)}/updates`, {
|
|
459
495
|
method: "POST",
|
|
496
|
+
signal,
|
|
460
497
|
});
|
|
461
498
|
}
|
|
462
499
|
|
|
463
|
-
|
|
464
|
-
const query = new URLSearchParams({
|
|
465
|
-
after: String(afterRevision),
|
|
466
|
-
wait: String(waitSeconds),
|
|
467
|
-
});
|
|
500
|
+
createLiveView(checkoutId, { idempotencyKey: key } = {}) {
|
|
468
501
|
return this.request(
|
|
469
|
-
`/checkout/${encodeURIComponent(checkoutId)}/
|
|
502
|
+
`/checkout/${encodeURIComponent(checkoutId)}/live-view`,
|
|
503
|
+
{
|
|
504
|
+
idempotencyKey: key ?? idempotencyKey("live-view"),
|
|
505
|
+
method: "POST",
|
|
506
|
+
},
|
|
470
507
|
);
|
|
471
508
|
}
|
|
472
509
|
|
|
@@ -496,12 +533,6 @@ export class ShopstackClient {
|
|
|
496
533
|
return this.request(`/checkout/${encodeURIComponent(checkoutId)}/events`);
|
|
497
534
|
}
|
|
498
535
|
|
|
499
|
-
listArtifacts(checkoutId) {
|
|
500
|
-
return this.request(
|
|
501
|
-
`/checkout/${encodeURIComponent(checkoutId)}/artifacts`,
|
|
502
|
-
);
|
|
503
|
-
}
|
|
504
|
-
|
|
505
536
|
providePaymentDetails(checkoutId, card, { idempotencyKey: key } = {}) {
|
|
506
537
|
return this.request(
|
|
507
538
|
`/checkout/${encodeURIComponent(checkoutId)}/payment-details`,
|
|
@@ -535,16 +566,12 @@ export class ShopstackClient {
|
|
|
535
566
|
let checkout = await this.createCheckout(checkoutRequest, {
|
|
536
567
|
idempotencyKey: options.idempotencyKey,
|
|
537
568
|
});
|
|
538
|
-
if (typeof options.onCreated === "function") {
|
|
539
|
-
await options.onCreated(checkout);
|
|
540
|
-
}
|
|
541
569
|
let progressFingerprint;
|
|
542
570
|
const publishProgress = async (current) => {
|
|
543
571
|
if (typeof options.onProgress !== "function") return;
|
|
544
572
|
const fingerprint = JSON.stringify([
|
|
545
573
|
current.status,
|
|
546
574
|
current.revision,
|
|
547
|
-
current.presentation_revision,
|
|
548
575
|
current.activity,
|
|
549
576
|
current.intent?.name,
|
|
550
577
|
current.intent?.phase,
|
|
@@ -556,20 +583,20 @@ export class ShopstackClient {
|
|
|
556
583
|
progressFingerprint = fingerprint;
|
|
557
584
|
await options.onProgress(current);
|
|
558
585
|
};
|
|
559
|
-
|
|
586
|
+
await publishProgress(checkout);
|
|
560
587
|
let handledPaymentRevision;
|
|
561
588
|
let handledApprovalId;
|
|
562
589
|
let handledMessageRevision;
|
|
590
|
+
const monitor = checkoutMonitor(this, checkout.id, this.webSocket);
|
|
563
591
|
try {
|
|
564
|
-
while (
|
|
565
|
-
|
|
566
|
-
if (TERMINAL_STATUSES.has(checkout.status)) return checkout;
|
|
567
|
-
const elapsed = Date.now() - startedAt;
|
|
568
|
-
if (elapsed >= timeoutMs) {
|
|
592
|
+
while (!TERMINAL_STATUSES.has(checkout.status)) {
|
|
593
|
+
if (Date.now() - startedAt >= timeoutMs) {
|
|
569
594
|
throw new ShopstackApiError("Checkout monitoring timed out.", {
|
|
570
595
|
code: "checkout_timeout",
|
|
571
596
|
});
|
|
572
597
|
}
|
|
598
|
+
checkout = await this.getCheckout(checkout.id);
|
|
599
|
+
await publishProgress(checkout);
|
|
573
600
|
if (
|
|
574
601
|
checkout.required_input?.type === "payment_card" &&
|
|
575
602
|
handledPaymentRevision !== checkout.revision
|
|
@@ -578,10 +605,9 @@ export class ShopstackClient {
|
|
|
578
605
|
handledPaymentRevision = checkout.revision;
|
|
579
606
|
const card = await options.paymentDetails(checkout);
|
|
580
607
|
if (card === undefined) return checkout;
|
|
581
|
-
|
|
608
|
+
await this.providePaymentDetails(checkout.id, card, {
|
|
582
609
|
idempotencyKey: `payment-details-${checkout.id}-${checkout.revision}`,
|
|
583
610
|
});
|
|
584
|
-
handledMessageRevision = checkout.revision;
|
|
585
611
|
continue;
|
|
586
612
|
}
|
|
587
613
|
if (
|
|
@@ -593,13 +619,13 @@ export class ShopstackClient {
|
|
|
593
619
|
handledApprovalId = checkout.approval.id;
|
|
594
620
|
const approved = await options.approve(checkout.approval, checkout);
|
|
595
621
|
if (typeof approved !== "boolean") return checkout;
|
|
596
|
-
|
|
622
|
+
await this.decidePaymentApproval(
|
|
597
623
|
checkout.id,
|
|
598
624
|
checkout.approval.id,
|
|
599
625
|
approved,
|
|
600
626
|
{ idempotencyKey: `approval-${checkout.approval.id}` },
|
|
601
627
|
);
|
|
602
|
-
if (!approved) return checkout;
|
|
628
|
+
if (!approved) return this.getCheckout(checkout.id);
|
|
603
629
|
continue;
|
|
604
630
|
}
|
|
605
631
|
if (
|
|
@@ -617,27 +643,16 @@ export class ShopstackClient {
|
|
|
617
643
|
});
|
|
618
644
|
continue;
|
|
619
645
|
}
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
if (update.kind === "unavailable") {
|
|
626
|
-
const pollIntervalMs =
|
|
627
|
-
options.pollIntervalMs ??
|
|
628
|
-
(checkout.status === "help_required" ||
|
|
629
|
-
checkout.status === "approval_required"
|
|
630
|
-
? 5_000
|
|
631
|
-
: 2_000);
|
|
632
|
-
await delay(Math.min(pollIntervalMs, remainingMs));
|
|
646
|
+
if (!TERMINAL_STATUSES.has(checkout.status)) {
|
|
647
|
+
await monitor.wait(
|
|
648
|
+
checkout.presentation_revision ?? 0,
|
|
649
|
+
timeoutMs - (Date.now() - startedAt),
|
|
650
|
+
);
|
|
633
651
|
}
|
|
634
|
-
checkout =
|
|
635
|
-
update.kind === "checkout"
|
|
636
|
-
? update.checkout
|
|
637
|
-
: await this.getCheckout(checkout.id);
|
|
638
652
|
}
|
|
653
|
+
return checkout;
|
|
639
654
|
} finally {
|
|
640
|
-
|
|
655
|
+
monitor.close();
|
|
641
656
|
}
|
|
642
657
|
}
|
|
643
658
|
}
|
package/src/config.d.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
export interface ShopstackProfile {
|
|
2
2
|
accountId: string;
|
|
3
3
|
apiKey: string;
|
|
4
|
+
baseUrl?: string;
|
|
4
5
|
keyType: "developer" | "user";
|
|
5
6
|
userId?: string;
|
|
6
7
|
}
|
|
7
8
|
|
|
8
9
|
export interface PendingSignup {
|
|
9
10
|
accountType: "developer" | "personal";
|
|
11
|
+
baseUrl?: string;
|
|
10
12
|
attemptId?: string;
|
|
11
13
|
email: string;
|
|
12
14
|
expiresAt?: string;
|
|
@@ -53,3 +55,8 @@ export class ConfigStore {
|
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
export function defaultConfigPath(): string;
|
|
58
|
+
|
|
59
|
+
export function resolveProfileBaseUrl(
|
|
60
|
+
profile?: { baseUrl?: string },
|
|
61
|
+
selectedBaseUrl?: string,
|
|
62
|
+
): string | undefined;
|
package/src/config.js
CHANGED
|
@@ -2,6 +2,17 @@ import { chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
|
|
5
|
+
export function resolveProfileBaseUrl(profile, selectedBaseUrl) {
|
|
6
|
+
const saved = profile?.baseUrl?.replace(/\/+$/u, "");
|
|
7
|
+
const selected = selectedBaseUrl?.replace(/\/+$/u, "");
|
|
8
|
+
if (saved !== undefined && selected !== undefined && saved !== selected) {
|
|
9
|
+
throw new Error(
|
|
10
|
+
"This profile belongs to a different Shopstack API. Select its endpoint or use a separate profile store.",
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
return saved ?? selected;
|
|
14
|
+
}
|
|
15
|
+
|
|
5
16
|
export function defaultConfigPath() {
|
|
6
17
|
return (
|
|
7
18
|
process.env.SHOPSTACK_CONFIG_FILE ??
|
|
@@ -131,6 +142,7 @@ export class ConfigStore {
|
|
|
131
142
|
accountId: result.account.id,
|
|
132
143
|
apiKey: result.api_key,
|
|
133
144
|
keyType: result.key_type,
|
|
145
|
+
...(entry[1].baseUrl === undefined ? {} : { baseUrl: entry[1].baseUrl }),
|
|
134
146
|
...(result.user?.id === undefined ? {} : { userId: result.user.id }),
|
|
135
147
|
};
|
|
136
148
|
config.active = profileName;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
const operations = new Set([
|
|
2
|
+
"resolve_location",
|
|
3
|
+
"get_search_filters",
|
|
4
|
+
"search_restaurants",
|
|
5
|
+
"prepare_booking",
|
|
6
|
+
"confirm_booking",
|
|
7
|
+
"decline_booking",
|
|
8
|
+
"ask_user",
|
|
9
|
+
"reply",
|
|
10
|
+
"request",
|
|
11
|
+
"model_decision",
|
|
12
|
+
"model_request",
|
|
13
|
+
"model_retry_wait",
|
|
14
|
+
"restaurant_request",
|
|
15
|
+
"provider_session",
|
|
16
|
+
"location_lookup",
|
|
17
|
+
"availability_recheck",
|
|
18
|
+
"slot_lock",
|
|
19
|
+
"slot_unlock",
|
|
20
|
+
"guest_profile",
|
|
21
|
+
"booking_submit",
|
|
22
|
+
"booking_status",
|
|
23
|
+
"booking_cancel",
|
|
24
|
+
"guest_inbox",
|
|
25
|
+
]);
|
|
26
|
+
const phases = new Set(["started", "completed", "failed", "selected"]);
|
|
27
|
+
const failures = new Set([
|
|
28
|
+
"ETIMEDOUT",
|
|
29
|
+
"ECONNRESET",
|
|
30
|
+
"ECONNREFUSED",
|
|
31
|
+
"ENOTFOUND",
|
|
32
|
+
"EAI_AGAIN",
|
|
33
|
+
"UND_ERR_CONNECT_TIMEOUT",
|
|
34
|
+
"UND_ERR_HEADERS_TIMEOUT",
|
|
35
|
+
"UND_ERR_BODY_TIMEOUT",
|
|
36
|
+
"UND_ERR_SOCKET",
|
|
37
|
+
"TimeoutError",
|
|
38
|
+
"AbortError",
|
|
39
|
+
"model_invalid_response",
|
|
40
|
+
"operation_failed",
|
|
41
|
+
]);
|
|
42
|
+
const nonnegative = (value) => Number.isSafeInteger(value) && value >= 0;
|
|
43
|
+
|
|
44
|
+
// Reconstruct the closed display shape. Provider bodies and arbitrary fields never pass through.
|
|
45
|
+
function publicProgress(frame, previous, requestId) {
|
|
46
|
+
if (
|
|
47
|
+
frame.request_id !== requestId ||
|
|
48
|
+
!nonnegative(frame.sequence) ||
|
|
49
|
+
frame.sequence <= previous ||
|
|
50
|
+
frame.sequence > 512 ||
|
|
51
|
+
!nonnegative(frame.call_id) ||
|
|
52
|
+
!nonnegative(frame.elapsed_ms) ||
|
|
53
|
+
!operations.has(frame.operation) ||
|
|
54
|
+
!phases.has(frame.phase)
|
|
55
|
+
)
|
|
56
|
+
throw new Error("Invalid progress frame.");
|
|
57
|
+
const event = Object.fromEntries(
|
|
58
|
+
[
|
|
59
|
+
"type",
|
|
60
|
+
"request_id",
|
|
61
|
+
"sequence",
|
|
62
|
+
"call_id",
|
|
63
|
+
"operation",
|
|
64
|
+
"phase",
|
|
65
|
+
"elapsed_ms",
|
|
66
|
+
].map((key) => [key, frame[key]]),
|
|
67
|
+
);
|
|
68
|
+
if (nonnegative(frame.duration_ms)) event.duration_ms = frame.duration_ms;
|
|
69
|
+
if (
|
|
70
|
+
Number.isInteger(frame.http_status) &&
|
|
71
|
+
frame.http_status >= 100 &&
|
|
72
|
+
frame.http_status <= 599
|
|
73
|
+
)
|
|
74
|
+
event.http_status = frame.http_status;
|
|
75
|
+
if (failures.has(frame.failure)) event.failure = frame.failure;
|
|
76
|
+
return event;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function readReservationProgress(response, onProgress) {
|
|
80
|
+
const requestId = response.headers.get("x-request-id");
|
|
81
|
+
if (!/^req_[a-f0-9]{32}$/u.test(requestId ?? "") || !response.body)
|
|
82
|
+
throw new Error("Invalid progress stream.");
|
|
83
|
+
const reader = response.body.getReader();
|
|
84
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
85
|
+
let text = "";
|
|
86
|
+
let bytes = 0;
|
|
87
|
+
let sequence = 0;
|
|
88
|
+
let result;
|
|
89
|
+
try {
|
|
90
|
+
for (;;) {
|
|
91
|
+
const chunk = await reader.read();
|
|
92
|
+
if (chunk.done) break;
|
|
93
|
+
bytes += chunk.value.byteLength;
|
|
94
|
+
if (bytes > 17 * 1024 * 1024)
|
|
95
|
+
throw new Error("Progress stream limit exceeded.");
|
|
96
|
+
text += decoder.decode(chunk.value, { stream: true });
|
|
97
|
+
for (;;) {
|
|
98
|
+
const end = text.indexOf("\n");
|
|
99
|
+
if (end < 0) break;
|
|
100
|
+
const frame = JSON.parse(text.slice(0, end));
|
|
101
|
+
text = text.slice(end + 1);
|
|
102
|
+
if (result || !frame || typeof frame !== "object")
|
|
103
|
+
throw new Error("Invalid stream order.");
|
|
104
|
+
if (frame.type === "progress") {
|
|
105
|
+
const event = publicProgress(frame, sequence, requestId);
|
|
106
|
+
sequence = event.sequence;
|
|
107
|
+
try {
|
|
108
|
+
Promise.resolve(onProgress?.(event)).catch(() => {});
|
|
109
|
+
} catch {
|
|
110
|
+
/* Display failure cannot retry a mutation. */
|
|
111
|
+
}
|
|
112
|
+
} else if (
|
|
113
|
+
frame.type === "result" &&
|
|
114
|
+
frame.request_id === requestId &&
|
|
115
|
+
Number.isInteger(frame.status) &&
|
|
116
|
+
frame.status >= 200 &&
|
|
117
|
+
frame.status <= 599 &&
|
|
118
|
+
Object.hasOwn(frame, "body")
|
|
119
|
+
) {
|
|
120
|
+
result = { status: frame.status, payload: frame.body };
|
|
121
|
+
} else throw new Error("Invalid result frame.");
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
text += decoder.decode();
|
|
125
|
+
if (!result || text.length) throw new Error("Incomplete progress stream.");
|
|
126
|
+
return result;
|
|
127
|
+
} finally {
|
|
128
|
+
// Also release failed/truncated streams. No automatic request retry.
|
|
129
|
+
await reader.cancel().catch(() => {});
|
|
130
|
+
reader.releaseLock();
|
|
131
|
+
}
|
|
132
|
+
}
|