shopstack 0.2.0 → 0.2.1

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 CHANGED
@@ -12,22 +12,25 @@ npm install -g shopstack
12
12
 
13
13
  Node.js 18 or newer is required.
14
14
 
15
- Version 0.2.0 defaults to Shopstack's currently deployed public staging API at
15
+ The 0.2.1 source candidate defaults to Shopstack's currently deployed public staging API at
16
16
  `https://shopstack-staging.shopstack.workers.dev/v1`. Set
17
17
  `SHOPSTACK_API_URL` only when targeting a different Shopstack environment.
18
18
 
19
- ## Personal account
19
+ ## Verified signup
20
20
 
21
21
  ```bash
22
- shopstack signup user --email you@example.com
22
+ shopstack signup
23
23
  ```
24
24
 
25
- This starts a 15-minute signup and waits while you open the verification email.
26
- No account or API key exists until the link is consumed. The CLI then retrieves
27
- the first key with its private polling capability and stores it in
25
+ The command asks for email and Personal or Developer account type. It starts a
26
+ 15-minute signup and waits while you open the verification email. No account or
27
+ API key exists until the link is consumed. The CLI generates and privately
28
+ persists all retry and polling capabilities before the first request, then
29
+ retrieves the first key after verification and stores it in
28
30
  `~/.config/shopstack/config.json` with file mode `0600`; it never prints the
29
- key or polling capability. It checkpoints the pending signup before polling. If
30
- the command is interrupted, resume it with:
31
+ key, recovery capability, or polling capability. If the command is interrupted,
32
+ rerun `shopstack signup`; the matching pending attempt resumes automatically.
33
+ The explicit helper also remains available:
31
34
 
32
35
  ```bash
33
36
  shopstack signup resume sup_...
@@ -36,7 +39,7 @@ shopstack signup resume sup_...
36
39
  ## Developer account and users
37
40
 
38
41
  ```bash
39
- shopstack signup developer --email developer@example.com
42
+ shopstack signup
40
43
  shopstack users create --external-id customer-123 --profile customer-123
41
44
  ```
42
45
 
@@ -65,7 +68,7 @@ phase. It deliberately has no card-input or payment-approval tool.
65
68
 
66
69
  The canonical agent skill ships as `SKILL.md` in this package. After
67
70
  publication it is available at
68
- `https://unpkg.com/shopstack@0.2.0/SKILL.md` with the release-pinned package.
71
+ `https://unpkg.com/shopstack@0.2.1/SKILL.md` with the release-pinned package.
69
72
 
70
73
  ## Connections
71
74
 
@@ -125,6 +128,14 @@ Card values are never accepted as command-line flags.
125
128
  ```js
126
129
  import { ShopstackClient } from "shopstack";
127
130
 
131
+ const onboarding = new ShopstackClient();
132
+ const account = await onboarding.signup({
133
+ accountType: "developer",
134
+ email: "developer@example.com",
135
+ onProgress: ({ state }) => console.log(state),
136
+ persistence: secureSignupPersistence,
137
+ });
138
+
128
139
  const shopstack = new ShopstackClient({
129
140
  apiKey: process.env.SHOPSTACK_API_KEY,
130
141
  });
@@ -136,6 +147,11 @@ const result = await shopstack.runCheckout(checkoutRequest, {
136
147
  });
137
148
  ```
138
149
 
150
+ `signup` generates retry state internally and returns the completed account
151
+ identity only after the one-time credential is stored. It never returns the API
152
+ key, recovery key, or polling token. Pass a secure persistence adapter for
153
+ restart recovery and durable credential storage; never use browser storage.
154
+
139
155
  `runCheckout` returns at a required input if its corresponding callback is
140
156
  omitted. Final payment approval is never inferred from a message or from
141
157
  supplying a card.
package/SKILL.md CHANGED
@@ -41,14 +41,16 @@ Never ask the model to print or relay a Shopstack API key. The CLI and MCP serve
41
41
  Start verified CLI signup:
42
42
 
43
43
  ```bash
44
- shopstack signup user --email user@example.com
45
- shopstack signup developer --email developer@example.com
44
+ shopstack signup
46
45
  ```
47
46
 
48
- The user must open the time-limited verification link. No account or API key exists before verification. The CLI polls the signup, saves the verified key with mode `0600`, and does not print it.
49
- It checkpoints the pending polling capability before waiting. If the process is
50
- interrupted, use `shopstack signup resume SIGNUP_ID`; the capability remains
51
- private in the same local profile store.
47
+ The command prompts for email and Personal or Developer account type. The user
48
+ must open the time-limited verification link. No account or API key exists
49
+ before verification. The CLI generates and privately stores its retry and
50
+ polling capabilities before the request, polls signup, saves the verified key
51
+ with mode `0600`, and prints none of those credentials. Rerunning
52
+ `shopstack signup` automatically resumes the matching pending attempt after an
53
+ interruption; `shopstack signup resume SIGNUP_ID` remains an explicit helper.
52
54
 
53
55
  After developer signup, create an end user:
54
56
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shopstack",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Shopstack API client and command-line checkout tools.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -29,6 +29,10 @@
29
29
  "engines": {
30
30
  "node": ">=18"
31
31
  },
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/jimbo132/shopstack-cli.git"
35
+ },
32
36
  "license": "MIT",
33
37
  "keywords": [
34
38
  "shopstack",
package/src/cli.js CHANGED
@@ -7,6 +7,7 @@ import { ConfigStore } from "./config.js";
7
7
  const HELP = `Shopstack
8
8
 
9
9
  Account setup:
10
+ shopstack signup
10
11
  shopstack signup user --email EMAIL
11
12
  shopstack signup developer --email EMAIL
12
13
  shopstack signup resume SIGNUP_ID
@@ -54,6 +55,11 @@ function required(options, name) {
54
55
  return value;
55
56
  }
56
57
 
58
+ function isAffirmative(value) {
59
+ const answer = String(value).trim().toLowerCase();
60
+ return answer === "y" || answer === "yes";
61
+ }
62
+
57
63
  function writeJson(stream, value) {
58
64
  stream.write(`${JSON.stringify(value, null, 2)}\n`);
59
65
  }
@@ -145,6 +151,69 @@ async function saveVerifiedSignup(result, profileName, configStore) {
145
151
  );
146
152
  }
147
153
 
154
+ function signupPersistence(configStore, profileName) {
155
+ return {
156
+ async loadPending(input) {
157
+ return typeof configStore.findPendingSignup === "function"
158
+ ? configStore.findPendingSignup(input)
159
+ : undefined;
160
+ },
161
+ async savePending(state) {
162
+ await configStore.savePendingSignup({ ...state, profile: profileName });
163
+ },
164
+ async completePending(state, result) {
165
+ const pendingId = state.attemptId ?? state.signupId ?? state.id;
166
+ if (typeof configStore.completePendingSignup === "function") {
167
+ await configStore.completePendingSignup(
168
+ pendingId,
169
+ profileName,
170
+ result,
171
+ );
172
+ return;
173
+ }
174
+ await saveVerifiedSignup(result, profileName, configStore);
175
+ await configStore.deletePendingSignup(pendingId);
176
+ },
177
+ async deletePending(state) {
178
+ await configStore.deletePendingSignup(
179
+ state.attemptId ?? state.signupId ?? state.id,
180
+ );
181
+ },
182
+ };
183
+ }
184
+
185
+ async function reportSignupProgress(progress, stream) {
186
+ switch (progress.state) {
187
+ case "email_sent":
188
+ stream.write("Verification email sent.\n");
189
+ return;
190
+ case "waiting":
191
+ stream.write("Waiting for verification...\n");
192
+ return;
193
+ case "verified":
194
+ stream.write("✓ Email verified\n");
195
+ return;
196
+ case "complete":
197
+ stream.write("✓ Shopstack account created\n");
198
+ stream.write("✓ Credentials saved securely\n");
199
+ return;
200
+ case "expired":
201
+ stream.write("Signup expired. Start again to receive a new email.\n");
202
+ return;
203
+ case "rate_limited":
204
+ stream.write("Signup is rate-limited. Retry after the indicated delay.\n");
205
+ return;
206
+ case "conflict":
207
+ stream.write("Signup retry state conflicted and was cleared.\n");
208
+ return;
209
+ case "retryable_failure":
210
+ stream.write("Signup paused after a retryable failure. Run signup again to resume.\n");
211
+ return;
212
+ default:
213
+ return;
214
+ }
215
+ }
216
+
148
217
  async function activeClient(dependencies, requiredKind = "user") {
149
218
  const profile = await dependencies.configStore.activeProfile();
150
219
  const environmentKey = process.env.SHOPSTACK_API_KEY;
@@ -195,60 +264,121 @@ export async function runCli(args, supplied = {}) {
195
264
  const client = dependencies.clientFactory({
196
265
  baseUrl: process.env.SHOPSTACK_API_URL,
197
266
  });
198
- const result = await client.waitForAccountSignup(
199
- pending.id,
200
- pending.pollToken,
201
- );
202
- await saveVerifiedSignup(result, pending.profile, dependencies.configStore);
203
- await dependencies.configStore.deletePendingSignup(pending.id);
267
+ const result = await client.signup({
268
+ accountType: pending.accountType,
269
+ email: pending.email,
270
+ onProgress: (progress) =>
271
+ reportSignupProgress(progress, dependencies.stderr),
272
+ persistence: signupPersistence(
273
+ dependencies.configStore,
274
+ pending.profile,
275
+ ),
276
+ });
204
277
  writeJson(dependencies.stdout, {
205
278
  ...sanitizedAccountResult(result),
206
279
  profile: pending.profile,
207
280
  });
208
281
  return;
209
282
  }
210
- if (action !== "user" && action !== "developer") {
283
+ if (action !== undefined && action !== "user" && action !== "developer") {
211
284
  throw new Error(
212
285
  "Use `shopstack signup user` or `shopstack signup developer`.",
213
286
  );
214
287
  }
215
- const { options, positional } = parseOptions(
216
- rest,
217
- new Set(["email", "profile"]),
218
- );
288
+ const signupArgs = action === undefined ? [] : rest;
289
+ const { options, positional } = parseOptions(signupArgs, new Set(["email", "profile"]));
219
290
  if (positional.length > 0) throw new Error("Unexpected signup argument.");
220
- const email = required(options, "email");
291
+ const pendingCandidates =
292
+ action === undefined &&
293
+ typeof dependencies.configStore.pendingSignups === "function"
294
+ ? await dependencies.configStore.pendingSignups()
295
+ : [];
296
+ const resumable = pendingCandidates.length === 1 ? pendingCandidates[0] : undefined;
297
+ let accountType;
298
+ let email;
299
+ if (resumable !== undefined) {
300
+ accountType = resumable.accountType;
301
+ email = resumable.email;
302
+ dependencies.stderr.write("Resuming pending signup.\n");
303
+ } else if (action === undefined) {
304
+ email = String(
305
+ await visiblePrompt("Email: ", dependencies),
306
+ ).trim();
307
+ if (email.length === 0) throw new Error("Email is required.");
308
+ const selected = String(
309
+ await visiblePrompt("Account type (Personal / Developer): ", dependencies),
310
+ )
311
+ .trim()
312
+ .toLowerCase();
313
+ if (selected === "personal" || selected === "user") {
314
+ accountType = "personal";
315
+ } else if (selected === "developer") {
316
+ accountType = "developer";
317
+ } else {
318
+ throw new Error("Account type must be Personal or Developer.");
319
+ }
320
+ } else {
321
+ accountType = action === "user" ? "personal" : "developer";
322
+ email = required(options, "email");
323
+ }
221
324
  const client = dependencies.clientFactory({
222
325
  baseUrl: process.env.SHOPSTACK_API_URL,
223
326
  });
224
- const accountType = action === "user" ? "personal" : "developer";
225
327
  const profileName =
226
- options.profile ?? (action === "developer" ? "developer" : "default");
227
- const signup = await client.startAccountSignup({
328
+ options.profile ??
329
+ resumable?.profile ??
330
+ (accountType === "developer" ? "developer" : "default");
331
+ const result = await client.signup({
228
332
  accountType,
229
333
  email,
334
+ onProgress: (progress) =>
335
+ reportSignupProgress(progress, dependencies.stderr),
336
+ persistence: signupPersistence(dependencies.configStore, profileName),
230
337
  });
231
- await dependencies.configStore.savePendingSignup({
232
- accountType: signup.account_type,
233
- email: signup.email,
234
- expiresAt: signup.expires_at,
235
- id: signup.id,
236
- pollToken: signup.poll_token,
237
- profile: profileName,
238
- });
239
- dependencies.stderr.write(
240
- `Verification email sent to ${signup.email}. Open the link before ${signup.expires_at}. If interrupted, resume signup ${signup.id}.\n`,
241
- );
242
- const result = await client.waitForAccountSignup(
243
- signup.id,
244
- signup.poll_token,
245
- );
246
- await saveVerifiedSignup(result, profileName, dependencies.configStore);
247
- await dependencies.configStore.deletePendingSignup(signup.id);
248
338
  writeJson(dependencies.stdout, {
249
339
  ...sanitizedAccountResult(result),
250
340
  profile: profileName,
251
341
  });
342
+ if (accountType === "developer") {
343
+ dependencies.stderr.write(
344
+ "The developer management credential cannot run user checkouts. Create a user profile with `shopstack users create --external-id ID`.\n",
345
+ );
346
+ if (
347
+ action === undefined &&
348
+ isAffirmative(
349
+ await visiblePrompt(
350
+ "Create the first developer-owned user now? (y/N): ",
351
+ dependencies,
352
+ ),
353
+ )
354
+ ) {
355
+ const externalId = String(
356
+ await visiblePrompt("User external ID: ", dependencies),
357
+ ).trim();
358
+ if (externalId.length === 0) {
359
+ throw new Error("User external ID is required.");
360
+ }
361
+ const { client: developerClient, profile } = await activeClient(
362
+ dependencies,
363
+ "developer",
364
+ );
365
+ const user = await developerClient.createUser({ externalId });
366
+ await dependencies.configStore.saveProfile(
367
+ externalId,
368
+ {
369
+ accountId: profile.accountId,
370
+ apiKey: user.api_key,
371
+ keyType: "user",
372
+ userId: user.id,
373
+ },
374
+ { activate: true },
375
+ );
376
+ writeJson(
377
+ dependencies.stdout,
378
+ sanitizedUserResult(user, externalId),
379
+ );
380
+ }
381
+ }
252
382
  return;
253
383
  }
254
384
 
package/src/client.d.ts CHANGED
@@ -89,14 +89,58 @@ export interface VerifiedAccountSignup {
89
89
  export interface SignupOptions {
90
90
  accountType: "personal" | "developer";
91
91
  email: string;
92
- idempotencyKey?: string;
93
92
  pollIntervalMs?: number;
94
93
  timeoutMs?: number;
94
+ persistence?: SignupPersistence;
95
+ onProgress?(progress: SignupProgress): void | Promise<void>;
95
96
  onVerificationRequired?(signup: Omit<AccountSignupStarted, "poll_token">):
96
97
  | void
97
98
  | Promise<void>;
98
99
  }
99
100
 
101
+ export interface PendingSignupState {
102
+ accountType: "personal" | "developer";
103
+ attemptId: string;
104
+ email: string;
105
+ expiresAt?: string;
106
+ idempotencyKey: string;
107
+ pollToken?: string;
108
+ recoveryKey: string;
109
+ signupId?: string;
110
+ }
111
+
112
+ export interface SignupPersistence {
113
+ loadPending(input: {
114
+ accountType: "personal" | "developer";
115
+ email: string;
116
+ }): Promise<PendingSignupState | undefined>;
117
+ savePending(state: PendingSignupState): Promise<void>;
118
+ completePending(
119
+ state: PendingSignupState,
120
+ result: VerifiedAccountSignup,
121
+ ): Promise<void>;
122
+ deletePending(state: PendingSignupState): Promise<void>;
123
+ }
124
+
125
+ export interface SignupProgress {
126
+ state:
127
+ | "pending"
128
+ | "email_sent"
129
+ | "waiting"
130
+ | "verified"
131
+ | "expired"
132
+ | "rate_limited"
133
+ | "conflict"
134
+ | "retryable_failure"
135
+ | "complete";
136
+ account_type?: "personal" | "developer";
137
+ email?: string;
138
+ expires_at?: string;
139
+ key_type?: "user" | "developer";
140
+ }
141
+
142
+ export type CompletedAccountSignup = Omit<VerifiedAccountSignup, "api_key">;
143
+
100
144
  export class ShopstackApiError extends Error {
101
145
  code: string;
102
146
  requestId?: string;
@@ -108,7 +152,6 @@ export class ShopstackClient {
108
152
  startAccountSignup(input: {
109
153
  accountType: "personal" | "developer";
110
154
  email: string;
111
- idempotencyKey?: string;
112
155
  }): Promise<AccountSignupStarted>;
113
156
  pollAccountSignup(
114
157
  signupId: string,
@@ -121,7 +164,7 @@ export class ShopstackClient {
121
164
  pollToken: string,
122
165
  options?: { pollIntervalMs?: number; timeoutMs?: number },
123
166
  ): Promise<VerifiedAccountSignup>;
124
- signup(input: SignupOptions): Promise<VerifiedAccountSignup>;
167
+ signup(input: SignupOptions): Promise<CompletedAccountSignup>;
125
168
  createUser(input: {
126
169
  externalId: string;
127
170
  idempotencyKey?: string;
package/src/client.js CHANGED
@@ -1,4 +1,4 @@
1
- import { randomUUID } from "node:crypto";
1
+ import { randomBytes, randomUUID } from "node:crypto";
2
2
 
3
3
  const TERMINAL_STATUSES = new Set(["complete", "failed", "cancelled"]);
4
4
 
@@ -16,6 +16,14 @@ function idempotencyKey(prefix) {
16
16
  return `${prefix}-${randomUUID()}`;
17
17
  }
18
18
 
19
+ function signupRecoveryKey() {
20
+ return `signup_recovery_${randomBytes(32).toString("base64url")}`;
21
+ }
22
+
23
+ function signupAttemptKey(accountType, email) {
24
+ return `${accountType}\u0000${email.trim().toLowerCase()}`;
25
+ }
26
+
19
27
  function delay(milliseconds) {
20
28
  return milliseconds <= 0
21
29
  ? Promise.resolve()
@@ -46,6 +54,7 @@ export class ShopstackClient {
46
54
  body,
47
55
  idempotencyKey: mutationKey,
48
56
  method = "GET",
57
+ signupRecoveryKey: recoveryKey,
49
58
  } = {},
50
59
  ) {
51
60
  const headers = new Headers({ Accept: "application/json" });
@@ -55,6 +64,7 @@ export class ShopstackClient {
55
64
  }
56
65
  if (body !== undefined) headers.set("Content-Type", "application/json");
57
66
  if (mutationKey) headers.set("Idempotency-Key", mutationKey);
67
+ if (recoveryKey) headers.set("Signup-Recovery-Key", recoveryKey);
58
68
  const response = await this.fetch(`${this.baseUrl}${path}`, {
59
69
  body: body === undefined ? undefined : JSON.stringify(body),
60
70
  headers,
@@ -88,11 +98,21 @@ export class ShopstackClient {
88
98
  return payload;
89
99
  }
90
100
 
91
- startAccountSignup({ accountType, email, idempotencyKey: key } = {}) {
101
+ _startAccountSignup({ accountType, email, idempotencyKey: key, recoveryKey }) {
92
102
  return this.request("/accounts", {
93
103
  body: { account_type: accountType, email },
94
- idempotencyKey: key ?? idempotencyKey("account"),
104
+ idempotencyKey: key,
95
105
  method: "POST",
106
+ signupRecoveryKey: recoveryKey,
107
+ });
108
+ }
109
+
110
+ startAccountSignup({ accountType, email } = {}) {
111
+ return this._startAccountSignup({
112
+ accountType,
113
+ email,
114
+ idempotencyKey: idempotencyKey("account"),
115
+ recoveryKey: signupRecoveryKey(),
96
116
  });
97
117
  }
98
118
 
@@ -123,29 +143,137 @@ export class ShopstackClient {
123
143
  async signup({
124
144
  accountType,
125
145
  email,
126
- idempotencyKey: key,
146
+ onProgress,
127
147
  onVerificationRequired,
148
+ persistence,
128
149
  pollIntervalMs = 2_000,
129
150
  timeoutMs = 15 * 60 * 1_000,
130
151
  } = {}) {
131
- const signup = await this.startAccountSignup({
152
+ const normalizedEmail = email.trim().toLowerCase();
153
+ const memoryKey = signupAttemptKey(accountType, normalizedEmail);
154
+ this._pendingSignups ??= new Map();
155
+ const storage = persistence ?? {
156
+ completePending: async (state, result) => {
157
+ this.apiKey = result.api_key;
158
+ this._pendingSignups.delete(memoryKey);
159
+ },
160
+ deletePending: async () => this._pendingSignups.delete(memoryKey),
161
+ loadPending: async () => this._pendingSignups.get(memoryKey),
162
+ savePending: async (state) => {
163
+ this._pendingSignups.set(memoryKey, structuredClone(state));
164
+ },
165
+ };
166
+ const publish = async (state, details = {}) => {
167
+ if (typeof onProgress === "function") await onProgress({ state, ...details });
168
+ };
169
+ let pending = await storage.loadPending({
132
170
  accountType,
133
- email,
134
- idempotencyKey: key,
171
+ email: normalizedEmail,
135
172
  });
136
- if (typeof onVerificationRequired === "function") {
137
- await onVerificationRequired({
138
- account_type: signup.account_type,
139
- email: signup.email,
140
- expires_at: signup.expires_at,
141
- id: signup.id,
142
- status: signup.status,
173
+ if (
174
+ pending?.expiresAt &&
175
+ Date.parse(pending.expiresAt) <= Date.now()
176
+ ) {
177
+ await storage.deletePending(pending);
178
+ await publish("expired", {
179
+ account_type: accountType,
180
+ email: normalizedEmail,
143
181
  });
182
+ pending = undefined;
144
183
  }
145
- return this.waitForAccountSignup(signup.id, signup.poll_token, {
146
- pollIntervalMs,
147
- timeoutMs,
184
+ pending ??= {
185
+ accountType,
186
+ attemptId: `attempt-${randomUUID()}`,
187
+ email: normalizedEmail,
188
+ idempotencyKey: idempotencyKey("account"),
189
+ recoveryKey: signupRecoveryKey(),
190
+ };
191
+ await storage.savePending(pending);
192
+ await publish("pending", {
193
+ account_type: accountType,
194
+ email: normalizedEmail,
148
195
  });
196
+ try {
197
+ if (!pending.signupId || !pending.pollToken) {
198
+ const started = await this._startAccountSignup({
199
+ accountType,
200
+ email: normalizedEmail,
201
+ idempotencyKey: pending.idempotencyKey,
202
+ recoveryKey: pending.recoveryKey,
203
+ });
204
+ pending = {
205
+ ...pending,
206
+ expiresAt: started.expires_at,
207
+ pollToken: started.poll_token,
208
+ signupId: started.id,
209
+ };
210
+ await storage.savePending(pending);
211
+ if (typeof onVerificationRequired === "function") {
212
+ await onVerificationRequired({
213
+ account_type: started.account_type,
214
+ email: started.email,
215
+ expires_at: started.expires_at,
216
+ id: started.id,
217
+ status: started.status,
218
+ });
219
+ }
220
+ await publish("email_sent", {
221
+ account_type: accountType,
222
+ email: normalizedEmail,
223
+ expires_at: started.expires_at,
224
+ });
225
+ }
226
+ await publish("waiting", {
227
+ account_type: accountType,
228
+ email: normalizedEmail,
229
+ expires_at: pending.expiresAt,
230
+ });
231
+ const result = await this.waitForAccountSignup(
232
+ pending.signupId,
233
+ pending.pollToken,
234
+ { pollIntervalMs, timeoutMs },
235
+ );
236
+ await storage.completePending(pending, result);
237
+ await publish("verified", {
238
+ account_type: accountType,
239
+ email: normalizedEmail,
240
+ });
241
+ await publish("complete", {
242
+ account_type: accountType,
243
+ email: normalizedEmail,
244
+ key_type: result.key_type,
245
+ });
246
+ const { api_key: _credential, ...completed } = result;
247
+ return completed;
248
+ } catch (error) {
249
+ if (error?.code === "signup_expired") {
250
+ await storage.deletePending(pending);
251
+ await publish("expired", {
252
+ account_type: accountType,
253
+ email: normalizedEmail,
254
+ });
255
+ } else if (error?.code === "rate_limited") {
256
+ await publish("rate_limited", {
257
+ account_type: accountType,
258
+ email: normalizedEmail,
259
+ });
260
+ } else if (
261
+ error?.code === "idempotency_conflict" ||
262
+ error?.code === "invalid_signup_recovery"
263
+ ) {
264
+ await storage.deletePending(pending);
265
+ await publish("conflict", {
266
+ account_type: accountType,
267
+ email: normalizedEmail,
268
+ });
269
+ } else {
270
+ await publish("retryable_failure", {
271
+ account_type: accountType,
272
+ email: normalizedEmail,
273
+ });
274
+ }
275
+ throw error;
276
+ }
149
277
  }
150
278
 
151
279
  createUser({ externalId, idempotencyKey: key } = {}) {
package/src/config.d.ts CHANGED
@@ -7,11 +7,15 @@ export interface ShopstackProfile {
7
7
 
8
8
  export interface PendingSignup {
9
9
  accountType: "developer" | "personal";
10
+ attemptId?: string;
10
11
  email: string;
11
- expiresAt: string;
12
- id: string;
13
- pollToken: string;
12
+ expiresAt?: string;
13
+ id?: string;
14
+ idempotencyKey?: string;
15
+ pollToken?: string;
14
16
  profile: string;
17
+ recoveryKey?: string;
18
+ signupId?: string;
15
19
  }
16
20
 
17
21
  export class ConfigStore {
@@ -25,6 +29,21 @@ export class ConfigStore {
25
29
  useProfile(name: string): Promise<ShopstackProfile>;
26
30
  savePendingSignup(signup: PendingSignup): Promise<PendingSignup>;
27
31
  pendingSignup(signupId: string): Promise<PendingSignup | undefined>;
32
+ pendingSignups(): Promise<PendingSignup[]>;
33
+ findPendingSignup(input: {
34
+ accountType: "developer" | "personal";
35
+ email: string;
36
+ }): Promise<PendingSignup | undefined>;
37
+ completePendingSignup(
38
+ attemptId: string,
39
+ profileName: string,
40
+ result: {
41
+ account: { id: string };
42
+ api_key: string;
43
+ key_type: "developer" | "user";
44
+ user?: { id?: string };
45
+ },
46
+ ): Promise<ShopstackProfile>;
28
47
  deletePendingSignup(signupId: string): Promise<void>;
29
48
  load(): Promise<{
30
49
  active: string | null;
package/src/config.js CHANGED
@@ -46,6 +46,7 @@ export class ConfigStore {
46
46
  async write(config) {
47
47
  const directory = dirname(this.path);
48
48
  await mkdir(directory, { mode: 0o700, recursive: true });
49
+ await chmod(directory, 0o700);
49
50
  const temporary = `${this.path}.${process.pid}.tmp`;
50
51
  await writeFile(temporary, `${JSON.stringify(config, null, 2)}\n`, {
51
52
  mode: 0o600,
@@ -83,19 +84,70 @@ export class ConfigStore {
83
84
 
84
85
  async savePendingSignup(signup) {
85
86
  const config = await this.load();
86
- config.pendingSignups[signup.id] = { ...signup };
87
+ const key = signup.attemptId ?? signup.id;
88
+ if (typeof key !== "string" || key.length === 0) {
89
+ throw new Error("Pending signup identity is invalid.");
90
+ }
91
+ config.pendingSignups[key] = { ...signup };
87
92
  await this.write(config);
88
- return config.pendingSignups[signup.id];
93
+ return config.pendingSignups[key];
89
94
  }
90
95
 
91
96
  async pendingSignup(signupId) {
92
97
  const config = await this.load();
93
- return config.pendingSignups[signupId];
98
+ return (
99
+ config.pendingSignups[signupId] ??
100
+ Object.values(config.pendingSignups).find(
101
+ (pending) => pending.id === signupId || pending.signupId === signupId,
102
+ )
103
+ );
104
+ }
105
+
106
+ async pendingSignups() {
107
+ return Object.values((await this.load()).pendingSignups);
108
+ }
109
+
110
+ async findPendingSignup({ accountType, email }) {
111
+ const normalizedEmail = email.trim().toLowerCase();
112
+ return Object.values((await this.load()).pendingSignups).find(
113
+ (pending) =>
114
+ pending.accountType === accountType &&
115
+ pending.email.trim().toLowerCase() === normalizedEmail,
116
+ );
117
+ }
118
+
119
+ async completePendingSignup(attemptId, profileName, result) {
120
+ const config = await this.load();
121
+ const entry = Object.entries(config.pendingSignups).find(
122
+ ([key, pending]) =>
123
+ key === attemptId ||
124
+ pending.id === attemptId ||
125
+ pending.signupId === attemptId,
126
+ );
127
+ if (entry === undefined) {
128
+ throw new Error("Pending signup is unavailable.");
129
+ }
130
+ config.profiles[profileName] = {
131
+ accountId: result.account.id,
132
+ apiKey: result.api_key,
133
+ keyType: result.key_type,
134
+ ...(result.user?.id === undefined ? {} : { userId: result.user.id }),
135
+ };
136
+ config.active = profileName;
137
+ delete config.pendingSignups[entry[0]];
138
+ await this.write(config);
139
+ return config.profiles[profileName];
94
140
  }
95
141
 
96
142
  async deletePendingSignup(signupId) {
97
143
  const config = await this.load();
98
- delete config.pendingSignups[signupId];
144
+ const entry = Object.entries(config.pendingSignups).find(
145
+ ([key, pending]) =>
146
+ key === signupId ||
147
+ pending.id === signupId ||
148
+ pending.signupId === signupId,
149
+ );
150
+ if (entry !== undefined) delete config.pendingSignups[entry[0]];
99
151
  await this.write(config);
100
152
  }
101
153
  }