shopstack 0.2.1 → 0.2.3

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 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
- Dependency-free JavaScript client and CLI for Shopstack's asynchronous checkout API.
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 0.2.1 source candidate defaults to Shopstack's currently deployed public staging API at
16
- `https://shopstack-staging.shopstack.workers.dev/v1`. Set
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
@@ -63,12 +63,14 @@ Configure any stdio MCP client to run:
63
63
 
64
64
  The MCP server can start and poll verified signup, manage local profiles,
65
65
  create developer-owned users, list/connect Link, and create/poll/message/cancel
66
- checkouts. It exposes status, activity, and the latest bounded mechanical intent
67
- phase. It deliberately has no card-input or payment-approval tool.
66
+ checkouts. Checkout creation returns the exact live-view URL as a clickable MCP
67
+ resource; `get_live_view` replaces a lost one-time link for an active checkout.
68
+ It exposes status, activity, and the latest bounded mechanical intent phase. It
69
+ deliberately has no card-input or payment-approval tool.
68
70
 
69
71
  The canonical agent skill ships as `SKILL.md` in this package. After
70
72
  publication it is available at
71
- `https://unpkg.com/shopstack@0.2.1/SKILL.md` with the release-pinned package.
73
+ `https://unpkg.com/shopstack@0.2.3/SKILL.md` with the release-pinned package.
72
74
 
73
75
  ## Connections
74
76
 
@@ -113,6 +115,16 @@ Then run:
113
115
  shopstack checkout run --file checkout.json
114
116
  ```
115
117
 
118
+ The CLI prints the private owner live-view URL as soon as checkout creation
119
+ succeeds. Do not redirect it into shared logs or expose it to a model. The page
120
+ is view-only unless the model explicitly requests human assistance; while that
121
+ request is active, one owner viewer can use **Take control** and **Return
122
+ control**. **Stop** is always available and never approves payment.
123
+
124
+ Treat the printed URL as opaque and open it exactly as returned, including its
125
+ `#token=...` fragment. If that fragment is lost while the checkout is active,
126
+ run `shopstack checkout view CHECKOUT_ID` to receive a replacement URL. The
127
+ replacement disconnects an older viewer session but does not restart checkout.
116
128
  When no provider is selected, the checkout runs normally until the payment
117
129
  form, then asks for card details through a no-echo terminal prompt. Card data is
118
130
  sent only to the protected payment-details endpoint. The CLI separately shows
@@ -154,7 +166,11 @@ restart recovery and durable credential storage; never use browser storage.
154
166
 
155
167
  `runCheckout` returns at a required input if its corresponding callback is
156
168
  omitted. Final payment approval is never inferred from a message or from
157
- supplying a card.
169
+ supplying a card. While the checkout is active, the CLI and client use a one-time
170
+ checkout-scoped WebSocket capability for change notification and always fetches
171
+ the canonical resource after a signal. It falls back to bounded revision-aware
172
+ HTTP waiting, then to fixed polling when either newer transport is unavailable.
173
+ Neither the API key nor WebSocket capability is placed in the socket URL.
158
174
 
159
175
  Set `SHOPSTACK_API_URL` to target a different Shopstack API and
160
176
  `SHOPSTACK_CONFIG_FILE` to relocate CLI profiles.
package/SKILL.md CHANGED
@@ -109,12 +109,33 @@ 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
- Poll every two seconds for `queued`, `started`, and `submitting`; every five seconds for `help_required` and `approval_required`; stop at `complete`, `failed`, or `cancelled`.
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.
133
+
134
+ Present `live_view_url` exactly as returned, including its fragment; never
135
+ construct a viewer URL from the checkout ID. MCP returns it as a clickable
136
+ resource link. If it is lost while the checkout is active, call
137
+ `get_live_view` (or `shopstack checkout view CHECKOUT_ID`) once and present the
138
+ replacement verbatim.
118
139
 
119
140
  ## Respond at typed boundaries
120
141
 
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(`${error instanceof Error ? error.message : String(error)}\n`);
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.1",
4
- "description": "Shopstack API client and command-line checkout tools.",
3
+ "version": "0.2.3",
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,52 @@ import { createInterface } from "node:readline/promises";
4
4
  import { ShopstackClient } from "./client.js";
5
5
  import { ConfigStore } from "./config.js";
6
6
 
7
- const HELP = `Shopstack
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.
43
+ shopstack checkout view CHECKOUT_ID
44
+ # Replace and return the active checkout's private owner live-view URL.
26
45
  shopstack checkout cancel CHECKOUT_ID
46
+ # Cancel a checkout that has not reached a terminal state.
47
+
48
+ Options:
49
+ -h, --help
50
+ # Show this command reference.
51
+ -v, --version
52
+ # Print the installed Shopstack CLI version.
27
53
  `;
28
54
 
29
55
  function parseOptions(args, allowed) {
@@ -164,11 +190,7 @@ function signupPersistence(configStore, profileName) {
164
190
  async completePending(state, result) {
165
191
  const pendingId = state.attemptId ?? state.signupId ?? state.id;
166
192
  if (typeof configStore.completePendingSignup === "function") {
167
- await configStore.completePendingSignup(
168
- pendingId,
169
- profileName,
170
- result,
171
- );
193
+ await configStore.completePendingSignup(pendingId, profileName, result);
172
194
  return;
173
195
  }
174
196
  await saveVerifiedSignup(result, profileName, configStore);
@@ -201,13 +223,17 @@ async function reportSignupProgress(progress, stream) {
201
223
  stream.write("Signup expired. Start again to receive a new email.\n");
202
224
  return;
203
225
  case "rate_limited":
204
- stream.write("Signup is rate-limited. Retry after the indicated delay.\n");
226
+ stream.write(
227
+ "Signup is rate-limited. Retry after the indicated delay.\n",
228
+ );
205
229
  return;
206
230
  case "conflict":
207
231
  stream.write("Signup retry state conflicted and was cleared.\n");
208
232
  return;
209
233
  case "retryable_failure":
210
- stream.write("Signup paused after a retryable failure. Run signup again to resume.\n");
234
+ stream.write(
235
+ "Signup paused after a retryable failure. Run signup again to resume.\n",
236
+ );
211
237
  return;
212
238
  default:
213
239
  return;
@@ -216,7 +242,7 @@ async function reportSignupProgress(progress, stream) {
216
242
 
217
243
  async function activeClient(dependencies, requiredKind = "user") {
218
244
  const profile = await dependencies.configStore.activeProfile();
219
- const environmentKey = process.env.SHOPSTACK_API_KEY;
245
+ const environmentKey = dependencies.env.SHOPSTACK_API_KEY;
220
246
  const apiKey = environmentKey || profile?.apiKey;
221
247
  const keyType = environmentKey ? requiredKind : profile?.keyType;
222
248
  if (!apiKey)
@@ -227,7 +253,7 @@ async function activeClient(dependencies, requiredKind = "user") {
227
253
  return {
228
254
  client: dependencies.clientFactory({
229
255
  apiKey,
230
- baseUrl: process.env.SHOPSTACK_API_URL,
256
+ baseUrl: dependencies.env.SHOPSTACK_API_URL,
231
257
  }),
232
258
  profile,
233
259
  };
@@ -244,13 +270,23 @@ export async function runCli(args, supplied = {}) {
244
270
  stderr: process.stderr,
245
271
  stdin: process.stdin,
246
272
  stdout: process.stdout,
273
+ env: process.env,
247
274
  ...supplied,
248
275
  };
249
276
  const [group, action, ...rest] = args;
250
- if (group === undefined || group === "help" || group === "--help") {
277
+ if (
278
+ group === undefined ||
279
+ group === "help" ||
280
+ group === "--help" ||
281
+ group === "-h"
282
+ ) {
251
283
  dependencies.stdout.write(HELP);
252
284
  return;
253
285
  }
286
+ if (group === "--version" || group === "-v") {
287
+ dependencies.stdout.write(`${VERSION}\n`);
288
+ return;
289
+ }
254
290
 
255
291
  if (group === "signup") {
256
292
  if (action === "resume") {
@@ -259,10 +295,12 @@ export async function runCli(args, supplied = {}) {
259
295
  }
260
296
  const pending = await dependencies.configStore.pendingSignup(rest[0]);
261
297
  if (pending === undefined) {
262
- throw new Error("That signup is not present in the local profile store.");
298
+ throw new Error(
299
+ "That signup is not present in the local profile store.",
300
+ );
263
301
  }
264
302
  const client = dependencies.clientFactory({
265
- baseUrl: process.env.SHOPSTACK_API_URL,
303
+ baseUrl: dependencies.env.SHOPSTACK_API_URL,
266
304
  });
267
305
  const result = await client.signup({
268
306
  accountType: pending.accountType,
@@ -286,14 +324,18 @@ export async function runCli(args, supplied = {}) {
286
324
  );
287
325
  }
288
326
  const signupArgs = action === undefined ? [] : rest;
289
- const { options, positional } = parseOptions(signupArgs, new Set(["email", "profile"]));
327
+ const { options, positional } = parseOptions(
328
+ signupArgs,
329
+ new Set(["email", "profile"]),
330
+ );
290
331
  if (positional.length > 0) throw new Error("Unexpected signup argument.");
291
332
  const pendingCandidates =
292
333
  action === undefined &&
293
334
  typeof dependencies.configStore.pendingSignups === "function"
294
335
  ? await dependencies.configStore.pendingSignups()
295
336
  : [];
296
- const resumable = pendingCandidates.length === 1 ? pendingCandidates[0] : undefined;
337
+ const resumable =
338
+ pendingCandidates.length === 1 ? pendingCandidates[0] : undefined;
297
339
  let accountType;
298
340
  let email;
299
341
  if (resumable !== undefined) {
@@ -301,12 +343,13 @@ export async function runCli(args, supplied = {}) {
301
343
  email = resumable.email;
302
344
  dependencies.stderr.write("Resuming pending signup.\n");
303
345
  } else if (action === undefined) {
304
- email = String(
305
- await visiblePrompt("Email: ", dependencies),
306
- ).trim();
346
+ email = String(await visiblePrompt("Email: ", dependencies)).trim();
307
347
  if (email.length === 0) throw new Error("Email is required.");
308
348
  const selected = String(
309
- await visiblePrompt("Account type (Personal / Developer): ", dependencies),
349
+ await visiblePrompt(
350
+ "Account type (Personal / Developer): ",
351
+ dependencies,
352
+ ),
310
353
  )
311
354
  .trim()
312
355
  .toLowerCase();
@@ -322,7 +365,7 @@ export async function runCli(args, supplied = {}) {
322
365
  email = required(options, "email");
323
366
  }
324
367
  const client = dependencies.clientFactory({
325
- baseUrl: process.env.SHOPSTACK_API_URL,
368
+ baseUrl: dependencies.env.SHOPSTACK_API_URL,
326
369
  });
327
370
  const profileName =
328
371
  options.profile ??
@@ -373,10 +416,7 @@ export async function runCli(args, supplied = {}) {
373
416
  },
374
417
  { activate: true },
375
418
  );
376
- writeJson(
377
- dependencies.stdout,
378
- sanitizedUserResult(user, externalId),
379
- );
419
+ writeJson(dependencies.stdout, sanitizedUserResult(user, externalId));
380
420
  }
381
421
  }
382
422
  return;
@@ -454,6 +494,11 @@ export async function runCli(args, supplied = {}) {
454
494
  const { client } = await activeClient(dependencies, "user");
455
495
  const request = await dependencies.readJsonFile(required(options, "file"));
456
496
  const result = await client.runCheckout(request, {
497
+ onCreated: (checkout) => {
498
+ if (typeof checkout.live_view_url === "string") {
499
+ dependencies.stderr.write(`Live view: ${checkout.live_view_url}\n`);
500
+ }
501
+ },
457
502
  onProgress: (checkout) => {
458
503
  const intent =
459
504
  checkout.intent?.name === undefined
@@ -499,13 +544,18 @@ export async function runCli(args, supplied = {}) {
499
544
  return;
500
545
  }
501
546
 
502
- if (group === "checkout" && (action === "get" || action === "cancel")) {
547
+ if (
548
+ group === "checkout" &&
549
+ (action === "get" || action === "view" || action === "cancel")
550
+ ) {
503
551
  if (rest.length !== 1) throw new Error("A checkout ID is required.");
504
552
  const { client } = await activeClient(dependencies, "user");
505
553
  const result =
506
554
  action === "get"
507
555
  ? await client.getCheckout(rest[0])
508
- : await client.cancelCheckout(rest[0]);
556
+ : action === "view"
557
+ ? await client.createLiveView(rest[0])
558
+ : await client.cancelCheckout(rest[0]);
509
559
  writeJson(dependencies.stdout, result);
510
560
  return;
511
561
  }
@@ -513,4 +563,4 @@ export async function runCli(args, supplied = {}) {
513
563
  throw new Error("Unknown command. Run `shopstack help`.");
514
564
  }
515
565
 
516
- export { HELP };
566
+ 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,18 @@ 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
+
81
+ export interface LiveViewCapability {
82
+ live_view_url: string;
83
+ }
84
+
69
85
  export interface AccountSignupStarted {
70
86
  id: string;
71
87
  account_type: "personal" | "developer";
@@ -93,9 +109,9 @@ export interface SignupOptions {
93
109
  timeoutMs?: number;
94
110
  persistence?: SignupPersistence;
95
111
  onProgress?(progress: SignupProgress): void | Promise<void>;
96
- onVerificationRequired?(signup: Omit<AccountSignupStarted, "poll_token">):
97
- | void
98
- | Promise<void>;
112
+ onVerificationRequired?(
113
+ signup: Omit<AccountSignupStarted, "poll_token">,
114
+ ): void | Promise<void>;
99
115
  }
100
116
 
101
117
  export interface PendingSignupState {
@@ -156,9 +172,7 @@ export class ShopstackClient {
156
172
  pollAccountSignup(
157
173
  signupId: string,
158
174
  pollToken: string,
159
- ): Promise<
160
- Omit<AccountSignupStarted, "poll_token"> | VerifiedAccountSignup
161
- >;
175
+ ): Promise<Omit<AccountSignupStarted, "poll_token"> | VerifiedAccountSignup>;
162
176
  waitForAccountSignup(
163
177
  signupId: string,
164
178
  pollToken: string,
@@ -190,6 +204,18 @@ export class ShopstackClient {
190
204
  options?: { idempotencyKey?: string },
191
205
  ): Promise<Checkout>;
192
206
  getCheckout(checkoutId: string): Promise<Checkout>;
207
+ createLiveView(
208
+ checkoutId: string,
209
+ options?: { idempotencyKey?: string },
210
+ ): Promise<LiveViewCapability>;
211
+ createCheckoutUpdateSubscription(
212
+ checkoutId: string,
213
+ ): Promise<CheckoutUpdateSubscription>;
214
+ waitForCheckoutUpdate(
215
+ checkoutId: string,
216
+ afterRevision: number,
217
+ waitSeconds?: number,
218
+ ): Promise<Checkout | undefined>;
193
219
  cancelCheckout(
194
220
  checkoutId: string,
195
221
  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://shopstack-staging.shopstack.workers.dev/v1",
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({ accountType, email, idempotencyKey: key, recoveryKey }) {
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") await onProgress({ state, ...details });
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,32 @@ export class ShopstackClient {
331
444
  return this.request(`/checkout/${encodeURIComponent(checkoutId)}`);
332
445
  }
333
446
 
447
+ createLiveView(checkoutId, { idempotencyKey: key } = {}) {
448
+ return this.request(
449
+ `/checkout/${encodeURIComponent(checkoutId)}/live-view`,
450
+ {
451
+ idempotencyKey: key ?? idempotencyKey("live-view"),
452
+ method: "POST",
453
+ },
454
+ );
455
+ }
456
+
457
+ createCheckoutUpdateSubscription(checkoutId) {
458
+ return this.request(`/checkout/${encodeURIComponent(checkoutId)}/updates`, {
459
+ method: "POST",
460
+ });
461
+ }
462
+
463
+ waitForCheckoutUpdate(checkoutId, afterRevision, waitSeconds = 25) {
464
+ const query = new URLSearchParams({
465
+ after: String(afterRevision),
466
+ wait: String(waitSeconds),
467
+ });
468
+ return this.request(
469
+ `/checkout/${encodeURIComponent(checkoutId)}/updates?${query.toString()}`,
470
+ );
471
+ }
472
+
334
473
  cancelCheckout(checkoutId, { idempotencyKey: key } = {}) {
335
474
  return this.request(`/checkout/${encodeURIComponent(checkoutId)}/cancel`, {
336
475
  idempotencyKey: key ?? idempotencyKey("cancel"),
@@ -396,12 +535,16 @@ export class ShopstackClient {
396
535
  let checkout = await this.createCheckout(checkoutRequest, {
397
536
  idempotencyKey: options.idempotencyKey,
398
537
  });
538
+ if (typeof options.onCreated === "function") {
539
+ await options.onCreated(checkout);
540
+ }
399
541
  let progressFingerprint;
400
542
  const publishProgress = async (current) => {
401
543
  if (typeof options.onProgress !== "function") return;
402
544
  const fingerprint = JSON.stringify([
403
545
  current.status,
404
546
  current.revision,
547
+ current.presentation_revision,
405
548
  current.activity,
406
549
  current.intent?.name,
407
550
  current.intent?.phase,
@@ -413,74 +556,88 @@ export class ShopstackClient {
413
556
  progressFingerprint = fingerprint;
414
557
  await options.onProgress(current);
415
558
  };
416
- await publishProgress(checkout);
559
+ const watcher = new CheckoutUpdateWatcher(this, checkout.id);
417
560
  let handledPaymentRevision;
418
561
  let handledApprovalId;
419
562
  let handledMessageRevision;
420
- while (!TERMINAL_STATUSES.has(checkout.status)) {
421
- if (Date.now() - startedAt >= timeoutMs) {
422
- throw new ShopstackApiError("Checkout monitoring timed out.", {
423
- code: "checkout_timeout",
424
- });
425
- }
426
- checkout = await this.getCheckout(checkout.id);
427
- await publishProgress(checkout);
428
- if (
429
- checkout.required_input?.type === "payment_card" &&
430
- handledPaymentRevision !== checkout.revision
431
- ) {
432
- if (typeof options.paymentDetails !== "function") return checkout;
433
- handledPaymentRevision = checkout.revision;
434
- const card = await options.paymentDetails(checkout);
435
- if (card === undefined) return checkout;
436
- await this.providePaymentDetails(checkout.id, card, {
437
- idempotencyKey: `payment-details-${checkout.id}-${checkout.revision}`,
438
- });
439
- continue;
440
- }
441
- if (
442
- checkout.status === "approval_required" &&
443
- checkout.approval &&
444
- handledApprovalId !== checkout.approval.id
445
- ) {
446
- if (typeof options.approve !== "function") return checkout;
447
- handledApprovalId = checkout.approval.id;
448
- const approved = await options.approve(checkout.approval, checkout);
449
- if (typeof approved !== "boolean") return checkout;
450
- await this.decidePaymentApproval(
451
- checkout.id,
452
- checkout.approval.id,
453
- approved,
454
- { idempotencyKey: `approval-${checkout.approval.id}` },
563
+ try {
564
+ while (true) {
565
+ await publishProgress(checkout);
566
+ if (TERMINAL_STATUSES.has(checkout.status)) return checkout;
567
+ const elapsed = Date.now() - startedAt;
568
+ if (elapsed >= timeoutMs) {
569
+ throw new ShopstackApiError("Checkout monitoring timed out.", {
570
+ code: "checkout_timeout",
571
+ });
572
+ }
573
+ if (
574
+ checkout.required_input?.type === "payment_card" &&
575
+ handledPaymentRevision !== checkout.revision
576
+ ) {
577
+ if (typeof options.paymentDetails !== "function") return checkout;
578
+ handledPaymentRevision = checkout.revision;
579
+ const card = await options.paymentDetails(checkout);
580
+ if (card === undefined) return checkout;
581
+ checkout = await this.providePaymentDetails(checkout.id, card, {
582
+ idempotencyKey: `payment-details-${checkout.id}-${checkout.revision}`,
583
+ });
584
+ handledMessageRevision = checkout.revision;
585
+ continue;
586
+ }
587
+ if (
588
+ checkout.status === "approval_required" &&
589
+ checkout.approval &&
590
+ handledApprovalId !== checkout.approval.id
591
+ ) {
592
+ if (typeof options.approve !== "function") return checkout;
593
+ handledApprovalId = checkout.approval.id;
594
+ const approved = await options.approve(checkout.approval, checkout);
595
+ if (typeof approved !== "boolean") return checkout;
596
+ checkout = await this.decidePaymentApproval(
597
+ checkout.id,
598
+ checkout.approval.id,
599
+ approved,
600
+ { idempotencyKey: `approval-${checkout.approval.id}` },
601
+ );
602
+ if (!approved) return checkout;
603
+ continue;
604
+ }
605
+ if (
606
+ checkout.status === "help_required" &&
607
+ checkout.required_input === undefined &&
608
+ handledMessageRevision !== checkout.revision
609
+ ) {
610
+ if (typeof options.message !== "function") return checkout;
611
+ handledMessageRevision = checkout.revision;
612
+ const content = await options.message(checkout);
613
+ if (typeof content !== "string" || content.length === 0)
614
+ return checkout;
615
+ await this.sendMessage(checkout.id, content, {
616
+ idempotencyKey: `message-${checkout.id}-${checkout.revision}`,
617
+ });
618
+ continue;
619
+ }
620
+ const remainingMs = timeoutMs - (Date.now() - startedAt);
621
+ const update = await watcher.wait(
622
+ presentationRevision(checkout),
623
+ remainingMs,
455
624
  );
456
- if (!approved) return this.getCheckout(checkout.id);
457
- continue;
458
- }
459
- if (
460
- checkout.status === "help_required" &&
461
- checkout.required_input === undefined &&
462
- handledMessageRevision !== checkout.revision
463
- ) {
464
- if (typeof options.message !== "function") return checkout;
465
- handledMessageRevision = checkout.revision;
466
- const content = await options.message(checkout);
467
- if (typeof content !== "string" || content.length === 0)
468
- return checkout;
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);
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));
633
+ }
634
+ checkout =
635
+ update.kind === "checkout"
636
+ ? update.checkout
637
+ : await this.getCheckout(checkout.id);
482
638
  }
639
+ } finally {
640
+ watcher.close();
483
641
  }
484
- return checkout;
485
642
  }
486
643
  }