shopstack 0.2.1 → 0.2.2
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/LICENSE +21 -0
- package/README.md +15 -5
- package/SKILL.md +16 -1
- package/bin/shopstack +3 -1
- package/package.json +25 -5
- package/src/cli.js +68 -25
- package/src/client.d.ts +24 -6
- package/src/client.js +217 -70
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Shopstack
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# shopstack
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
JavaScript client and CLI for Shopstack's asynchronous checkout API.
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
@@ -12,8 +12,8 @@ npm install -g shopstack
|
|
|
12
12
|
|
|
13
13
|
Node.js 18 or newer is required.
|
|
14
14
|
|
|
15
|
-
The
|
|
16
|
-
`https://
|
|
15
|
+
The CLI and client default to Shopstack's production API at
|
|
16
|
+
`https://api.shopstack.ai/v1`. Set
|
|
17
17
|
`SHOPSTACK_API_URL` only when targeting a different Shopstack environment.
|
|
18
18
|
|
|
19
19
|
## Verified signup
|
|
@@ -68,7 +68,7 @@ phase. It deliberately has no card-input or payment-approval tool.
|
|
|
68
68
|
|
|
69
69
|
The canonical agent skill ships as `SKILL.md` in this package. After
|
|
70
70
|
publication it is available at
|
|
71
|
-
`https://unpkg.com/shopstack@0.2.
|
|
71
|
+
`https://unpkg.com/shopstack@0.2.2/SKILL.md` with the release-pinned package.
|
|
72
72
|
|
|
73
73
|
## Connections
|
|
74
74
|
|
|
@@ -113,6 +113,12 @@ Then run:
|
|
|
113
113
|
shopstack checkout run --file checkout.json
|
|
114
114
|
```
|
|
115
115
|
|
|
116
|
+
The CLI prints the private owner live-view URL as soon as checkout creation
|
|
117
|
+
succeeds. Do not redirect it into shared logs or expose it to a model. The page
|
|
118
|
+
is view-only unless the model explicitly requests human assistance; while that
|
|
119
|
+
request is active, one owner viewer can use **Take control** and **Return
|
|
120
|
+
control**. **Stop** is always available and never approves payment.
|
|
121
|
+
|
|
116
122
|
When no provider is selected, the checkout runs normally until the payment
|
|
117
123
|
form, then asks for card details through a no-echo terminal prompt. Card data is
|
|
118
124
|
sent only to the protected payment-details endpoint. The CLI separately shows
|
|
@@ -154,7 +160,11 @@ restart recovery and durable credential storage; never use browser storage.
|
|
|
154
160
|
|
|
155
161
|
`runCheckout` returns at a required input if its corresponding callback is
|
|
156
162
|
omitted. Final payment approval is never inferred from a message or from
|
|
157
|
-
supplying a card.
|
|
163
|
+
supplying a card. While the checkout is active, the CLI and client use a one-time
|
|
164
|
+
checkout-scoped WebSocket capability for change notification and always fetches
|
|
165
|
+
the canonical resource after a signal. It falls back to bounded revision-aware
|
|
166
|
+
HTTP waiting, then to fixed polling when either newer transport is unavailable.
|
|
167
|
+
Neither the API key nor WebSocket capability is placed in the socket URL.
|
|
158
168
|
|
|
159
169
|
Set `SHOPSTACK_API_URL` to target a different Shopstack API and
|
|
160
170
|
`SHOPSTACK_CONFIG_FILE` to relocate CLI profiles.
|
package/SKILL.md
CHANGED
|
@@ -109,12 +109,27 @@ shopstack checkout run --file checkout.json
|
|
|
109
109
|
Or create with MCP, then call `poll_checkout`. Display these authoritative fields to the user when they change:
|
|
110
110
|
|
|
111
111
|
- `status`: one of exactly `queued`, `started`, `help_required`, `approval_required`, `submitting`, `complete`, `failed`, `cancelled`;
|
|
112
|
+
- `presentation_revision`: the monotonic revision for user-visible checkout state;
|
|
112
113
|
- `activity`: bounded present-tense display text;
|
|
113
114
|
- `intent.name`, `intent.phase`, and `intent.updated_at`: bounded mechanical action progress with no arguments or reasoning;
|
|
114
115
|
- `required_input`: a typed handoff such as protected payment-card input;
|
|
115
116
|
- `approval`: the exact amount-bound approval request when present.
|
|
116
117
|
|
|
117
|
-
|
|
118
|
+
The CLI and JavaScript client subscribe with a one-time checkout-scoped
|
|
119
|
+
WebSocket capability. Each valid notification means the presentation revision
|
|
120
|
+
changed; the client then fetches the canonical checkout before displaying or
|
|
121
|
+
acting on it. The notification is not checkout state truth and never grants
|
|
122
|
+
payment authority. The capability belongs in the WebSocket subprotocol, never
|
|
123
|
+
the URL, logs, model context, or messages. If WebSocket delivery is unavailable,
|
|
124
|
+
the client falls back to bounded HTTP waiting and then fixed polling.
|
|
125
|
+
|
|
126
|
+
MCP and manual integrations poll every two seconds for `queued`, `started`, and
|
|
127
|
+
`submitting`; every five seconds for `help_required` and `approval_required`;
|
|
128
|
+
stop at `complete`, `failed`, or `cancelled`.
|
|
129
|
+
|
|
130
|
+
The optional `live_view_url` is a private owner-viewer capability. Show it only
|
|
131
|
+
to the initiating user. Do not store it in browser storage, analytics, logs,
|
|
132
|
+
model context, or ordinary messages.
|
|
118
133
|
|
|
119
134
|
## Respond at typed boundaries
|
|
120
135
|
|
package/bin/shopstack
CHANGED
|
@@ -5,6 +5,8 @@ import { runCli } from "../src/cli.js";
|
|
|
5
5
|
try {
|
|
6
6
|
await runCli(process.argv.slice(2));
|
|
7
7
|
} catch (error) {
|
|
8
|
-
process.stderr.write(
|
|
8
|
+
process.stderr.write(
|
|
9
|
+
`${error instanceof Error ? error.message : String(error)}\n`,
|
|
10
|
+
);
|
|
9
11
|
process.exitCode = 1;
|
|
10
12
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shopstack",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "Shopstack
|
|
3
|
+
"version": "0.2.2",
|
|
4
|
+
"description": "Production Shopstack SDK and CLI for agentic checkout.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": {
|
|
@@ -23,8 +23,12 @@
|
|
|
23
23
|
"shopstack": "bin/shopstack"
|
|
24
24
|
},
|
|
25
25
|
"scripts": {
|
|
26
|
+
"check": "npm run format:check && npm run test:coverage",
|
|
27
|
+
"format:check": "prettier --check .",
|
|
26
28
|
"shopstack": "node ./bin/shopstack",
|
|
27
|
-
"test": "node --test"
|
|
29
|
+
"test": "node --test test",
|
|
30
|
+
"test:coverage": "node --test --experimental-test-coverage test",
|
|
31
|
+
"prepublishOnly": "npm run check"
|
|
28
32
|
},
|
|
29
33
|
"engines": {
|
|
30
34
|
"node": ">=18"
|
|
@@ -33,11 +37,27 @@
|
|
|
33
37
|
"type": "git",
|
|
34
38
|
"url": "git+https://github.com/jimbo132/shopstack-cli.git"
|
|
35
39
|
},
|
|
40
|
+
"homepage": "https://github.com/jimbo132/shopstack-cli#readme",
|
|
41
|
+
"bugs": {
|
|
42
|
+
"url": "https://github.com/jimbo132/shopstack-cli/issues"
|
|
43
|
+
},
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public"
|
|
46
|
+
},
|
|
47
|
+
"sideEffects": false,
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"ws": "8.21.3"
|
|
50
|
+
},
|
|
36
51
|
"license": "MIT",
|
|
37
52
|
"keywords": [
|
|
38
53
|
"shopstack",
|
|
39
54
|
"cli",
|
|
40
55
|
"checkout",
|
|
41
|
-
"payments"
|
|
42
|
-
|
|
56
|
+
"payments",
|
|
57
|
+
"agentic-commerce",
|
|
58
|
+
"agents"
|
|
59
|
+
],
|
|
60
|
+
"devDependencies": {
|
|
61
|
+
"prettier": "3.6.2"
|
|
62
|
+
}
|
|
43
63
|
}
|
package/src/cli.js
CHANGED
|
@@ -4,26 +4,50 @@ import { createInterface } from "node:readline/promises";
|
|
|
4
4
|
import { ShopstackClient } from "./client.js";
|
|
5
5
|
import { ConfigStore } from "./config.js";
|
|
6
6
|
|
|
7
|
-
const
|
|
7
|
+
const VERSION = "0.2.2";
|
|
8
|
+
|
|
9
|
+
const HELP = `Shopstack CLI ${VERSION}
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
shopstack <command> [options]
|
|
8
13
|
|
|
9
14
|
Account setup:
|
|
10
15
|
shopstack signup
|
|
16
|
+
# Create or resume an email-verified Personal or Developer profile.
|
|
11
17
|
shopstack signup user --email EMAIL
|
|
18
|
+
# Create a Personal profile without interactive account-type prompts.
|
|
12
19
|
shopstack signup developer --email EMAIL
|
|
20
|
+
# Create a Developer management profile without interactive prompts.
|
|
13
21
|
shopstack signup resume SIGNUP_ID
|
|
22
|
+
# Resume a known pending email-verification flow.
|
|
14
23
|
shopstack users create --external-id ID [--profile NAME]
|
|
24
|
+
# Create an independently scoped user from a Developer profile.
|
|
15
25
|
shopstack profiles list
|
|
26
|
+
# List local profiles without printing their credentials.
|
|
16
27
|
shopstack profiles use NAME
|
|
28
|
+
# Select the profile used by later commands.
|
|
17
29
|
|
|
18
30
|
Payment connection:
|
|
19
31
|
shopstack connect list
|
|
32
|
+
# Inspect payment connections for the active user profile.
|
|
20
33
|
shopstack connect link
|
|
34
|
+
# Connect Link Agentic Wallet in the trusted browser flow.
|
|
21
35
|
|
|
22
36
|
Checkout:
|
|
23
37
|
shopstack checkout create --file checkout.json
|
|
38
|
+
# Create a checkout and return control immediately.
|
|
24
39
|
shopstack checkout run --file checkout.json
|
|
40
|
+
# Create, monitor, and complete a checkout interactively.
|
|
25
41
|
shopstack checkout get CHECKOUT_ID
|
|
42
|
+
# Read the canonical current checkout state.
|
|
26
43
|
shopstack checkout cancel CHECKOUT_ID
|
|
44
|
+
# Cancel a checkout that has not reached a terminal state.
|
|
45
|
+
|
|
46
|
+
Options:
|
|
47
|
+
-h, --help
|
|
48
|
+
# Show this command reference.
|
|
49
|
+
-v, --version
|
|
50
|
+
# Print the installed Shopstack CLI version.
|
|
27
51
|
`;
|
|
28
52
|
|
|
29
53
|
function parseOptions(args, allowed) {
|
|
@@ -164,11 +188,7 @@ function signupPersistence(configStore, profileName) {
|
|
|
164
188
|
async completePending(state, result) {
|
|
165
189
|
const pendingId = state.attemptId ?? state.signupId ?? state.id;
|
|
166
190
|
if (typeof configStore.completePendingSignup === "function") {
|
|
167
|
-
await configStore.completePendingSignup(
|
|
168
|
-
pendingId,
|
|
169
|
-
profileName,
|
|
170
|
-
result,
|
|
171
|
-
);
|
|
191
|
+
await configStore.completePendingSignup(pendingId, profileName, result);
|
|
172
192
|
return;
|
|
173
193
|
}
|
|
174
194
|
await saveVerifiedSignup(result, profileName, configStore);
|
|
@@ -201,13 +221,17 @@ async function reportSignupProgress(progress, stream) {
|
|
|
201
221
|
stream.write("Signup expired. Start again to receive a new email.\n");
|
|
202
222
|
return;
|
|
203
223
|
case "rate_limited":
|
|
204
|
-
stream.write(
|
|
224
|
+
stream.write(
|
|
225
|
+
"Signup is rate-limited. Retry after the indicated delay.\n",
|
|
226
|
+
);
|
|
205
227
|
return;
|
|
206
228
|
case "conflict":
|
|
207
229
|
stream.write("Signup retry state conflicted and was cleared.\n");
|
|
208
230
|
return;
|
|
209
231
|
case "retryable_failure":
|
|
210
|
-
stream.write(
|
|
232
|
+
stream.write(
|
|
233
|
+
"Signup paused after a retryable failure. Run signup again to resume.\n",
|
|
234
|
+
);
|
|
211
235
|
return;
|
|
212
236
|
default:
|
|
213
237
|
return;
|
|
@@ -216,7 +240,7 @@ async function reportSignupProgress(progress, stream) {
|
|
|
216
240
|
|
|
217
241
|
async function activeClient(dependencies, requiredKind = "user") {
|
|
218
242
|
const profile = await dependencies.configStore.activeProfile();
|
|
219
|
-
const environmentKey =
|
|
243
|
+
const environmentKey = dependencies.env.SHOPSTACK_API_KEY;
|
|
220
244
|
const apiKey = environmentKey || profile?.apiKey;
|
|
221
245
|
const keyType = environmentKey ? requiredKind : profile?.keyType;
|
|
222
246
|
if (!apiKey)
|
|
@@ -227,7 +251,7 @@ async function activeClient(dependencies, requiredKind = "user") {
|
|
|
227
251
|
return {
|
|
228
252
|
client: dependencies.clientFactory({
|
|
229
253
|
apiKey,
|
|
230
|
-
baseUrl:
|
|
254
|
+
baseUrl: dependencies.env.SHOPSTACK_API_URL,
|
|
231
255
|
}),
|
|
232
256
|
profile,
|
|
233
257
|
};
|
|
@@ -244,13 +268,23 @@ export async function runCli(args, supplied = {}) {
|
|
|
244
268
|
stderr: process.stderr,
|
|
245
269
|
stdin: process.stdin,
|
|
246
270
|
stdout: process.stdout,
|
|
271
|
+
env: process.env,
|
|
247
272
|
...supplied,
|
|
248
273
|
};
|
|
249
274
|
const [group, action, ...rest] = args;
|
|
250
|
-
if (
|
|
275
|
+
if (
|
|
276
|
+
group === undefined ||
|
|
277
|
+
group === "help" ||
|
|
278
|
+
group === "--help" ||
|
|
279
|
+
group === "-h"
|
|
280
|
+
) {
|
|
251
281
|
dependencies.stdout.write(HELP);
|
|
252
282
|
return;
|
|
253
283
|
}
|
|
284
|
+
if (group === "--version" || group === "-v") {
|
|
285
|
+
dependencies.stdout.write(`${VERSION}\n`);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
254
288
|
|
|
255
289
|
if (group === "signup") {
|
|
256
290
|
if (action === "resume") {
|
|
@@ -259,10 +293,12 @@ export async function runCli(args, supplied = {}) {
|
|
|
259
293
|
}
|
|
260
294
|
const pending = await dependencies.configStore.pendingSignup(rest[0]);
|
|
261
295
|
if (pending === undefined) {
|
|
262
|
-
throw new Error(
|
|
296
|
+
throw new Error(
|
|
297
|
+
"That signup is not present in the local profile store.",
|
|
298
|
+
);
|
|
263
299
|
}
|
|
264
300
|
const client = dependencies.clientFactory({
|
|
265
|
-
baseUrl:
|
|
301
|
+
baseUrl: dependencies.env.SHOPSTACK_API_URL,
|
|
266
302
|
});
|
|
267
303
|
const result = await client.signup({
|
|
268
304
|
accountType: pending.accountType,
|
|
@@ -286,14 +322,18 @@ export async function runCli(args, supplied = {}) {
|
|
|
286
322
|
);
|
|
287
323
|
}
|
|
288
324
|
const signupArgs = action === undefined ? [] : rest;
|
|
289
|
-
const { options, positional } = parseOptions(
|
|
325
|
+
const { options, positional } = parseOptions(
|
|
326
|
+
signupArgs,
|
|
327
|
+
new Set(["email", "profile"]),
|
|
328
|
+
);
|
|
290
329
|
if (positional.length > 0) throw new Error("Unexpected signup argument.");
|
|
291
330
|
const pendingCandidates =
|
|
292
331
|
action === undefined &&
|
|
293
332
|
typeof dependencies.configStore.pendingSignups === "function"
|
|
294
333
|
? await dependencies.configStore.pendingSignups()
|
|
295
334
|
: [];
|
|
296
|
-
const resumable =
|
|
335
|
+
const resumable =
|
|
336
|
+
pendingCandidates.length === 1 ? pendingCandidates[0] : undefined;
|
|
297
337
|
let accountType;
|
|
298
338
|
let email;
|
|
299
339
|
if (resumable !== undefined) {
|
|
@@ -301,12 +341,13 @@ export async function runCli(args, supplied = {}) {
|
|
|
301
341
|
email = resumable.email;
|
|
302
342
|
dependencies.stderr.write("Resuming pending signup.\n");
|
|
303
343
|
} else if (action === undefined) {
|
|
304
|
-
email = String(
|
|
305
|
-
await visiblePrompt("Email: ", dependencies),
|
|
306
|
-
).trim();
|
|
344
|
+
email = String(await visiblePrompt("Email: ", dependencies)).trim();
|
|
307
345
|
if (email.length === 0) throw new Error("Email is required.");
|
|
308
346
|
const selected = String(
|
|
309
|
-
await visiblePrompt(
|
|
347
|
+
await visiblePrompt(
|
|
348
|
+
"Account type (Personal / Developer): ",
|
|
349
|
+
dependencies,
|
|
350
|
+
),
|
|
310
351
|
)
|
|
311
352
|
.trim()
|
|
312
353
|
.toLowerCase();
|
|
@@ -322,7 +363,7 @@ export async function runCli(args, supplied = {}) {
|
|
|
322
363
|
email = required(options, "email");
|
|
323
364
|
}
|
|
324
365
|
const client = dependencies.clientFactory({
|
|
325
|
-
baseUrl:
|
|
366
|
+
baseUrl: dependencies.env.SHOPSTACK_API_URL,
|
|
326
367
|
});
|
|
327
368
|
const profileName =
|
|
328
369
|
options.profile ??
|
|
@@ -373,10 +414,7 @@ export async function runCli(args, supplied = {}) {
|
|
|
373
414
|
},
|
|
374
415
|
{ activate: true },
|
|
375
416
|
);
|
|
376
|
-
writeJson(
|
|
377
|
-
dependencies.stdout,
|
|
378
|
-
sanitizedUserResult(user, externalId),
|
|
379
|
-
);
|
|
417
|
+
writeJson(dependencies.stdout, sanitizedUserResult(user, externalId));
|
|
380
418
|
}
|
|
381
419
|
}
|
|
382
420
|
return;
|
|
@@ -454,6 +492,11 @@ export async function runCli(args, supplied = {}) {
|
|
|
454
492
|
const { client } = await activeClient(dependencies, "user");
|
|
455
493
|
const request = await dependencies.readJsonFile(required(options, "file"));
|
|
456
494
|
const result = await client.runCheckout(request, {
|
|
495
|
+
onCreated: (checkout) => {
|
|
496
|
+
if (typeof checkout.live_view_url === "string") {
|
|
497
|
+
dependencies.stderr.write(`Live view: ${checkout.live_view_url}\n`);
|
|
498
|
+
}
|
|
499
|
+
},
|
|
457
500
|
onProgress: (checkout) => {
|
|
458
501
|
const intent =
|
|
459
502
|
checkout.intent?.name === undefined
|
|
@@ -513,4 +556,4 @@ export async function runCli(args, supplied = {}) {
|
|
|
513
556
|
throw new Error("Unknown command. Run `shopstack help`.");
|
|
514
557
|
}
|
|
515
558
|
|
|
516
|
-
export { HELP };
|
|
559
|
+
export { HELP, VERSION };
|
package/src/client.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ export interface ShopstackClientOptions {
|
|
|
2
2
|
apiKey?: string;
|
|
3
3
|
baseUrl?: string;
|
|
4
4
|
fetch?: typeof fetch;
|
|
5
|
+
webSocket?: typeof WebSocket;
|
|
5
6
|
}
|
|
6
7
|
|
|
7
8
|
export interface PaymentCard {
|
|
@@ -33,6 +34,7 @@ export interface Checkout {
|
|
|
33
34
|
| "failed"
|
|
34
35
|
| "cancelled";
|
|
35
36
|
revision: number;
|
|
37
|
+
presentation_revision: number;
|
|
36
38
|
activity: string;
|
|
37
39
|
intent?: {
|
|
38
40
|
name: string;
|
|
@@ -40,6 +42,7 @@ export interface Checkout {
|
|
|
40
42
|
updated_at: string;
|
|
41
43
|
};
|
|
42
44
|
item_url: string;
|
|
45
|
+
live_view_url?: string;
|
|
43
46
|
required_input?: { type: "payment_card" };
|
|
44
47
|
approval?: PaymentApproval;
|
|
45
48
|
result?: Record<string, unknown>;
|
|
@@ -51,6 +54,7 @@ export interface Checkout {
|
|
|
51
54
|
|
|
52
55
|
export interface RunCheckoutOptions {
|
|
53
56
|
idempotencyKey?: string;
|
|
57
|
+
onCreated?(checkout: Checkout): void | Promise<void>;
|
|
54
58
|
pollIntervalMs?: number;
|
|
55
59
|
timeoutMs?: number;
|
|
56
60
|
onProgress?(checkout: Checkout): void | Promise<void>;
|
|
@@ -66,6 +70,14 @@ export interface RunCheckoutOptions {
|
|
|
66
70
|
): string | Promise<string | undefined> | undefined;
|
|
67
71
|
}
|
|
68
72
|
|
|
73
|
+
export interface CheckoutUpdateSubscription {
|
|
74
|
+
socket_url: string;
|
|
75
|
+
protocol: "shopstack.v1";
|
|
76
|
+
token: string;
|
|
77
|
+
expires_at: string;
|
|
78
|
+
presentation_revision: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
69
81
|
export interface AccountSignupStarted {
|
|
70
82
|
id: string;
|
|
71
83
|
account_type: "personal" | "developer";
|
|
@@ -93,9 +105,9 @@ export interface SignupOptions {
|
|
|
93
105
|
timeoutMs?: number;
|
|
94
106
|
persistence?: SignupPersistence;
|
|
95
107
|
onProgress?(progress: SignupProgress): void | Promise<void>;
|
|
96
|
-
onVerificationRequired?(
|
|
97
|
-
|
|
98
|
-
|
|
108
|
+
onVerificationRequired?(
|
|
109
|
+
signup: Omit<AccountSignupStarted, "poll_token">,
|
|
110
|
+
): void | Promise<void>;
|
|
99
111
|
}
|
|
100
112
|
|
|
101
113
|
export interface PendingSignupState {
|
|
@@ -156,9 +168,7 @@ export class ShopstackClient {
|
|
|
156
168
|
pollAccountSignup(
|
|
157
169
|
signupId: string,
|
|
158
170
|
pollToken: string,
|
|
159
|
-
): Promise<
|
|
160
|
-
Omit<AccountSignupStarted, "poll_token"> | VerifiedAccountSignup
|
|
161
|
-
>;
|
|
171
|
+
): Promise<Omit<AccountSignupStarted, "poll_token"> | VerifiedAccountSignup>;
|
|
162
172
|
waitForAccountSignup(
|
|
163
173
|
signupId: string,
|
|
164
174
|
pollToken: string,
|
|
@@ -190,6 +200,14 @@ export class ShopstackClient {
|
|
|
190
200
|
options?: { idempotencyKey?: string },
|
|
191
201
|
): Promise<Checkout>;
|
|
192
202
|
getCheckout(checkoutId: string): Promise<Checkout>;
|
|
203
|
+
createCheckoutUpdateSubscription(
|
|
204
|
+
checkoutId: string,
|
|
205
|
+
): Promise<CheckoutUpdateSubscription>;
|
|
206
|
+
waitForCheckoutUpdate(
|
|
207
|
+
checkoutId: string,
|
|
208
|
+
afterRevision: number,
|
|
209
|
+
waitSeconds?: number,
|
|
210
|
+
): Promise<Checkout | undefined>;
|
|
193
211
|
cancelCheckout(
|
|
194
212
|
checkoutId: string,
|
|
195
213
|
options?: { idempotencyKey?: string },
|
package/src/client.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomBytes, randomUUID } from "node:crypto";
|
|
2
|
+
import WebSocket from "ws";
|
|
2
3
|
|
|
3
4
|
const TERMINAL_STATUSES = new Set(["complete", "failed", "cancelled"]);
|
|
4
5
|
|
|
@@ -30,11 +31,119 @@ function delay(milliseconds) {
|
|
|
30
31
|
: new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
31
32
|
}
|
|
32
33
|
|
|
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
|
+
|
|
33
141
|
export class ShopstackClient {
|
|
34
142
|
constructor({
|
|
35
143
|
apiKey,
|
|
36
|
-
baseUrl = "https://
|
|
144
|
+
baseUrl = "https://api.shopstack.ai/v1",
|
|
37
145
|
fetch: fetchImplementation = globalThis.fetch,
|
|
146
|
+
webSocket: webSocketImplementation = WebSocket,
|
|
38
147
|
} = {}) {
|
|
39
148
|
if (typeof baseUrl !== "string" || !/^https?:\/\//u.test(baseUrl)) {
|
|
40
149
|
throw new TypeError("baseUrl must be an HTTP(S) URL");
|
|
@@ -45,6 +154,7 @@ export class ShopstackClient {
|
|
|
45
154
|
this.apiKey = apiKey;
|
|
46
155
|
this.baseUrl = baseUrl.replace(/\/+$/u, "");
|
|
47
156
|
this.fetch = fetchImplementation;
|
|
157
|
+
this.WebSocket = webSocketImplementation;
|
|
48
158
|
}
|
|
49
159
|
|
|
50
160
|
async request(
|
|
@@ -98,7 +208,12 @@ export class ShopstackClient {
|
|
|
98
208
|
return payload;
|
|
99
209
|
}
|
|
100
210
|
|
|
101
|
-
_startAccountSignup({
|
|
211
|
+
_startAccountSignup({
|
|
212
|
+
accountType,
|
|
213
|
+
email,
|
|
214
|
+
idempotencyKey: key,
|
|
215
|
+
recoveryKey,
|
|
216
|
+
}) {
|
|
102
217
|
return this.request("/accounts", {
|
|
103
218
|
body: { account_type: accountType, email },
|
|
104
219
|
idempotencyKey: key,
|
|
@@ -164,16 +279,14 @@ export class ShopstackClient {
|
|
|
164
279
|
},
|
|
165
280
|
};
|
|
166
281
|
const publish = async (state, details = {}) => {
|
|
167
|
-
if (typeof onProgress === "function")
|
|
282
|
+
if (typeof onProgress === "function")
|
|
283
|
+
await onProgress({ state, ...details });
|
|
168
284
|
};
|
|
169
285
|
let pending = await storage.loadPending({
|
|
170
286
|
accountType,
|
|
171
287
|
email: normalizedEmail,
|
|
172
288
|
});
|
|
173
|
-
if (
|
|
174
|
-
pending?.expiresAt &&
|
|
175
|
-
Date.parse(pending.expiresAt) <= Date.now()
|
|
176
|
-
) {
|
|
289
|
+
if (pending?.expiresAt && Date.parse(pending.expiresAt) <= Date.now()) {
|
|
177
290
|
await storage.deletePending(pending);
|
|
178
291
|
await publish("expired", {
|
|
179
292
|
account_type: accountType,
|
|
@@ -331,6 +444,22 @@ export class ShopstackClient {
|
|
|
331
444
|
return this.request(`/checkout/${encodeURIComponent(checkoutId)}`);
|
|
332
445
|
}
|
|
333
446
|
|
|
447
|
+
createCheckoutUpdateSubscription(checkoutId) {
|
|
448
|
+
return this.request(`/checkout/${encodeURIComponent(checkoutId)}/updates`, {
|
|
449
|
+
method: "POST",
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
waitForCheckoutUpdate(checkoutId, afterRevision, waitSeconds = 25) {
|
|
454
|
+
const query = new URLSearchParams({
|
|
455
|
+
after: String(afterRevision),
|
|
456
|
+
wait: String(waitSeconds),
|
|
457
|
+
});
|
|
458
|
+
return this.request(
|
|
459
|
+
`/checkout/${encodeURIComponent(checkoutId)}/updates?${query.toString()}`,
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
|
|
334
463
|
cancelCheckout(checkoutId, { idempotencyKey: key } = {}) {
|
|
335
464
|
return this.request(`/checkout/${encodeURIComponent(checkoutId)}/cancel`, {
|
|
336
465
|
idempotencyKey: key ?? idempotencyKey("cancel"),
|
|
@@ -396,12 +525,16 @@ export class ShopstackClient {
|
|
|
396
525
|
let checkout = await this.createCheckout(checkoutRequest, {
|
|
397
526
|
idempotencyKey: options.idempotencyKey,
|
|
398
527
|
});
|
|
528
|
+
if (typeof options.onCreated === "function") {
|
|
529
|
+
await options.onCreated(checkout);
|
|
530
|
+
}
|
|
399
531
|
let progressFingerprint;
|
|
400
532
|
const publishProgress = async (current) => {
|
|
401
533
|
if (typeof options.onProgress !== "function") return;
|
|
402
534
|
const fingerprint = JSON.stringify([
|
|
403
535
|
current.status,
|
|
404
536
|
current.revision,
|
|
537
|
+
current.presentation_revision,
|
|
405
538
|
current.activity,
|
|
406
539
|
current.intent?.name,
|
|
407
540
|
current.intent?.phase,
|
|
@@ -413,74 +546,88 @@ export class ShopstackClient {
|
|
|
413
546
|
progressFingerprint = fingerprint;
|
|
414
547
|
await options.onProgress(current);
|
|
415
548
|
};
|
|
416
|
-
|
|
549
|
+
const watcher = new CheckoutUpdateWatcher(this, checkout.id);
|
|
417
550
|
let handledPaymentRevision;
|
|
418
551
|
let handledApprovalId;
|
|
419
552
|
let handledMessageRevision;
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
checkout.
|
|
452
|
-
checkout
|
|
453
|
-
|
|
454
|
-
|
|
553
|
+
try {
|
|
554
|
+
while (true) {
|
|
555
|
+
await publishProgress(checkout);
|
|
556
|
+
if (TERMINAL_STATUSES.has(checkout.status)) return checkout;
|
|
557
|
+
const elapsed = Date.now() - startedAt;
|
|
558
|
+
if (elapsed >= timeoutMs) {
|
|
559
|
+
throw new ShopstackApiError("Checkout monitoring timed out.", {
|
|
560
|
+
code: "checkout_timeout",
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
if (
|
|
564
|
+
checkout.required_input?.type === "payment_card" &&
|
|
565
|
+
handledPaymentRevision !== checkout.revision
|
|
566
|
+
) {
|
|
567
|
+
if (typeof options.paymentDetails !== "function") return checkout;
|
|
568
|
+
handledPaymentRevision = checkout.revision;
|
|
569
|
+
const card = await options.paymentDetails(checkout);
|
|
570
|
+
if (card === undefined) return checkout;
|
|
571
|
+
checkout = await this.providePaymentDetails(checkout.id, card, {
|
|
572
|
+
idempotencyKey: `payment-details-${checkout.id}-${checkout.revision}`,
|
|
573
|
+
});
|
|
574
|
+
handledMessageRevision = checkout.revision;
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
if (
|
|
578
|
+
checkout.status === "approval_required" &&
|
|
579
|
+
checkout.approval &&
|
|
580
|
+
handledApprovalId !== checkout.approval.id
|
|
581
|
+
) {
|
|
582
|
+
if (typeof options.approve !== "function") return checkout;
|
|
583
|
+
handledApprovalId = checkout.approval.id;
|
|
584
|
+
const approved = await options.approve(checkout.approval, checkout);
|
|
585
|
+
if (typeof approved !== "boolean") return checkout;
|
|
586
|
+
checkout = await this.decidePaymentApproval(
|
|
587
|
+
checkout.id,
|
|
588
|
+
checkout.approval.id,
|
|
589
|
+
approved,
|
|
590
|
+
{ idempotencyKey: `approval-${checkout.approval.id}` },
|
|
591
|
+
);
|
|
592
|
+
if (!approved) return checkout;
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
if (
|
|
596
|
+
checkout.status === "help_required" &&
|
|
597
|
+
checkout.required_input === undefined &&
|
|
598
|
+
handledMessageRevision !== checkout.revision
|
|
599
|
+
) {
|
|
600
|
+
if (typeof options.message !== "function") return checkout;
|
|
601
|
+
handledMessageRevision = checkout.revision;
|
|
602
|
+
const content = await options.message(checkout);
|
|
603
|
+
if (typeof content !== "string" || content.length === 0)
|
|
604
|
+
return checkout;
|
|
605
|
+
await this.sendMessage(checkout.id, content, {
|
|
606
|
+
idempotencyKey: `message-${checkout.id}-${checkout.revision}`,
|
|
607
|
+
});
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
const remainingMs = timeoutMs - (Date.now() - startedAt);
|
|
611
|
+
const update = await watcher.wait(
|
|
612
|
+
presentationRevision(checkout),
|
|
613
|
+
remainingMs,
|
|
455
614
|
);
|
|
456
|
-
if (
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
await this.sendMessage(checkout.id, content, {
|
|
470
|
-
idempotencyKey: `message-${checkout.id}-${checkout.revision}`,
|
|
471
|
-
});
|
|
472
|
-
continue;
|
|
473
|
-
}
|
|
474
|
-
if (!TERMINAL_STATUSES.has(checkout.status)) {
|
|
475
|
-
const pollIntervalMs =
|
|
476
|
-
options.pollIntervalMs ??
|
|
477
|
-
(checkout.status === "help_required" ||
|
|
478
|
-
checkout.status === "approval_required"
|
|
479
|
-
? 5_000
|
|
480
|
-
: 2_000);
|
|
481
|
-
await delay(pollIntervalMs);
|
|
615
|
+
if (update.kind === "unavailable") {
|
|
616
|
+
const pollIntervalMs =
|
|
617
|
+
options.pollIntervalMs ??
|
|
618
|
+
(checkout.status === "help_required" ||
|
|
619
|
+
checkout.status === "approval_required"
|
|
620
|
+
? 5_000
|
|
621
|
+
: 2_000);
|
|
622
|
+
await delay(Math.min(pollIntervalMs, remainingMs));
|
|
623
|
+
}
|
|
624
|
+
checkout =
|
|
625
|
+
update.kind === "checkout"
|
|
626
|
+
? update.checkout
|
|
627
|
+
: await this.getCheckout(checkout.id);
|
|
482
628
|
}
|
|
629
|
+
} finally {
|
|
630
|
+
watcher.close();
|
|
483
631
|
}
|
|
484
|
-
return checkout;
|
|
485
632
|
}
|
|
486
633
|
}
|