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 +26 -10
- package/SKILL.md +8 -6
- package/package.json +5 -1
- package/src/cli.js +162 -32
- package/src/client.d.ts +46 -3
- package/src/client.js +145 -17
- package/src/config.d.ts +22 -3
- package/src/config.js +56 -4
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
|
-
|
|
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
|
-
##
|
|
19
|
+
## Verified signup
|
|
20
20
|
|
|
21
21
|
```bash
|
|
22
|
-
shopstack signup
|
|
22
|
+
shopstack signup
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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.
|
|
30
|
-
the
|
|
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
|
|
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.
|
|
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
|
|
45
|
-
shopstack signup developer --email developer@example.com
|
|
44
|
+
shopstack signup
|
|
46
45
|
```
|
|
47
46
|
|
|
48
|
-
The
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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.
|
|
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.
|
|
199
|
-
pending.
|
|
200
|
-
pending.
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
|
216
|
-
|
|
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
|
|
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 ??
|
|
227
|
-
|
|
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<
|
|
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
|
-
|
|
101
|
+
_startAccountSignup({ accountType, email, idempotencyKey: key, recoveryKey }) {
|
|
92
102
|
return this.request("/accounts", {
|
|
93
103
|
body: { account_type: accountType, email },
|
|
94
|
-
idempotencyKey: key
|
|
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
|
-
|
|
146
|
+
onProgress,
|
|
127
147
|
onVerificationRequired,
|
|
148
|
+
persistence,
|
|
128
149
|
pollIntervalMs = 2_000,
|
|
129
150
|
timeoutMs = 15 * 60 * 1_000,
|
|
130
151
|
} = {}) {
|
|
131
|
-
const
|
|
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 (
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
|
12
|
-
id
|
|
13
|
-
|
|
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
|
-
|
|
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[
|
|
93
|
+
return config.pendingSignups[key];
|
|
89
94
|
}
|
|
90
95
|
|
|
91
96
|
async pendingSignup(signupId) {
|
|
92
97
|
const config = await this.load();
|
|
93
|
-
return
|
|
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
|
-
|
|
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
|
}
|