@stardeck-customer-apps/testing 0.22.0 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/SKILL.md +41 -1
- package/dist/index.d.mts +17 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +77 -6
- package/dist/index.mjs +77 -6
- package/dist/next/headers-shim.js +9 -5
- package/dist/next/headers-shim.mjs +9 -5
- package/dist/setup.js +65 -6
- package/dist/setup.mjs +65 -6
- package/package.json +1 -1
package/SKILL.md
CHANGED
|
@@ -208,6 +208,14 @@ app.payments.failNext("stripe.getCheckoutSessionStatus", {
|
|
|
208
208
|
message: "connection reset",
|
|
209
209
|
});
|
|
210
210
|
|
|
211
|
+
// Stripe createCheckout idempotency + expiry (payments-sdk >= 4.11.0) is simulated like the
|
|
212
|
+
// platform: same idempotencyKey + same payload replays the same session (one captured
|
|
213
|
+
// checkout); same key + different payload -> PaymentError "idempotency_conflict" (409); keys
|
|
214
|
+
// are scoped per account; expiresAt (unix seconds) must be 30 min - 24 h out or
|
|
215
|
+
// "expiry_out_of_range" (400); the default is 24 h and session.expiresAt is returned.
|
|
216
|
+
// Beam payment links reject idempotencyKey ("idempotency_unsupported") and echo expiresAt.
|
|
217
|
+
// The simulator does not model Stripe's 24 h key retention.
|
|
218
|
+
|
|
211
219
|
// File upload
|
|
212
220
|
const storage = new StorageClient();
|
|
213
221
|
await storage.upload(new File(["hi"], "note.txt", { type: "text/plain" }));
|
|
@@ -218,6 +226,13 @@ const integrations = createIntegrationsClient();
|
|
|
218
226
|
await integrations.line.push("U123", { type: "text", text: "Your order shipped" });
|
|
219
227
|
expect(app.messages.channel("line").to("U123")[0].body.text).toMatch(/shipped/i);
|
|
220
228
|
|
|
229
|
+
// Signed in through LINE (a shop's LINE MINI App): the session carries the
|
|
230
|
+
// person's shop-provider LINE user ID. Push to the ID on the SESSION.
|
|
231
|
+
app.asUser({ id: "buyer-1", lineUserId: "Ushop-provider-1" });
|
|
232
|
+
// route under test: const s = await getSession(); await integrations.line.push(s.user.lineUserId, ...)
|
|
233
|
+
await callRoute(POST);
|
|
234
|
+
expect(app.messages.channel("line").to("Ushop-provider-1")).toHaveLength(1);
|
|
235
|
+
|
|
221
236
|
// LINE Flex card — the simulator preserves the complete LINE-native container.
|
|
222
237
|
const card = {
|
|
223
238
|
type: "bubble" as const,
|
|
@@ -322,7 +337,18 @@ expect(app.edge.latestDisplay()?.action).toBe("show");
|
|
|
322
337
|
or the platform's test-run deadline.
|
|
323
338
|
|
|
324
339
|
- `app.asUser(partial?)` — sign in a test user; `getSession()` in app code
|
|
325
|
-
resolves to it. `app.signOut()` clears.
|
|
340
|
+
resolves to it. `app.signOut()` clears. Pass `lineUserId` to sign in as a
|
|
341
|
+
person who came through LINE sign-in inside the shop's LINE MINI App; it is
|
|
342
|
+
the same field production sets, so app code reads `session.user.lineUserId`.
|
|
343
|
+
`issueSession({ lineUserId })` carries it through cookie auth and the real
|
|
344
|
+
`/api/auth/me` route too.
|
|
345
|
+
- `app.setLiffId(id | null)` / `createTestApp({ liffId })` — the LIFF ID the
|
|
346
|
+
platform forwards to the app (`null` by default = no LINE MINI App). The real
|
|
347
|
+
project-auth `/api/auth/me` returns it as `liffId` on every response, signed
|
|
348
|
+
in or not, which is what makes `useLiff()` start LIFF. `reset()` restores the
|
|
349
|
+
`createTestApp` value. The simulator does not run LIFF sign-in itself
|
|
350
|
+
(`POST /api/auth/liff` needs a real LINE ID token), so seed the signed-in
|
|
351
|
+
state with `asUser`/`issueSession` instead.
|
|
326
352
|
- `app.issueSession(partial?)` — mint access/refresh tokens for cookie-based
|
|
327
353
|
auth flows (`__stardeck:sess`).
|
|
328
354
|
- `app.inbox` — emails sent through the email-sdk: `.latest(addr?)`,
|
|
@@ -619,6 +645,20 @@ per-field validation (unit-test the route), login/logout (platform-provided).
|
|
|
619
645
|
never `waitForTimeout` (web-first assertions auto-wait), every test
|
|
620
646
|
independent of other tests' data and ordering.
|
|
621
647
|
|
|
648
|
+
## Testing LINE flows
|
|
649
|
+
|
|
650
|
+
- Sign in as a LINE person with `app.asUser({ lineUserId: "U..." })` and call
|
|
651
|
+
the route with `callRoute`; assert the push with
|
|
652
|
+
`app.messages.channel("line").to("U...")`. Cover the person with no LINE
|
|
653
|
+
identity too (`asUser()` without `lineUserId`): the route must send nothing.
|
|
654
|
+
- The recipient of a LINE push comes from `session.user.lineUserId` on the
|
|
655
|
+
server, never from a request body, query string or client-side value. A
|
|
656
|
+
`lineUserId` a browser sends can be forged to message any friend of the shop's
|
|
657
|
+
LINE Official Account. Do not write a route that takes one, and do not write a
|
|
658
|
+
test that posts one to make a push happen; sign in with `lineUserId` instead.
|
|
659
|
+
- Whether the person is a friend of the shop's OA is a client concern
|
|
660
|
+
(`useLiff().isFriend`); a server route checks only that `lineUserId` exists.
|
|
661
|
+
|
|
622
662
|
## Rules
|
|
623
663
|
|
|
624
664
|
- Test **business logic and route handlers**, not framework plumbing. Focus on
|
package/dist/index.d.mts
CHANGED
|
@@ -38,6 +38,12 @@ interface TestUser {
|
|
|
38
38
|
projectId?: string | null;
|
|
39
39
|
/** Cross-app customer ownership key returned by the platform identity resolver. */
|
|
40
40
|
identityId?: string | null;
|
|
41
|
+
/**
|
|
42
|
+
* The person's LINE user ID under the shop's own LINE provider. Set it to sign in
|
|
43
|
+
* as someone who came through LINE sign-in inside the shop's LINE MINI App; app
|
|
44
|
+
* code reads it from `session.user.lineUserId` and pushes to it.
|
|
45
|
+
*/
|
|
46
|
+
lineUserId?: string | null;
|
|
41
47
|
}
|
|
42
48
|
interface CapturedEmail {
|
|
43
49
|
resendId: string;
|
|
@@ -615,6 +621,12 @@ interface TestStarlens {
|
|
|
615
621
|
get count(): number;
|
|
616
622
|
}
|
|
617
623
|
interface TestAppOptions {
|
|
624
|
+
/**
|
|
625
|
+
* The LIFF ID the platform forwards to the app for a LINE MINI App shop. It is
|
|
626
|
+
* what `GET /api/auth/me` returns as `liffId` and what makes `useLiff()` start
|
|
627
|
+
* LIFF. Defaults to `null` (the app has no LINE MINI App). Restored by `reset()`.
|
|
628
|
+
*/
|
|
629
|
+
liffId?: string | null;
|
|
618
630
|
/** Data stores exposed through the production-shaped STARDECK_DATA_STORES manifest. */
|
|
619
631
|
dataStores?: TestDataStoreInput[];
|
|
620
632
|
/**
|
|
@@ -669,6 +681,11 @@ interface TestApp {
|
|
|
669
681
|
asUser(user?: Partial<TestUser>): TestUser;
|
|
670
682
|
/** Clear the active test user. */
|
|
671
683
|
signOut(): void;
|
|
684
|
+
/**
|
|
685
|
+
* Set (or clear with `null`) the LIFF ID the platform forwards to the app, for
|
|
686
|
+
* the rest of the test. The real project-auth `me` route returns it as `liffId`.
|
|
687
|
+
*/
|
|
688
|
+
setLiffId(liffId: string | null): void;
|
|
672
689
|
/**
|
|
673
690
|
* Mint access/refresh tokens for cookie-based auth flows. The simulated
|
|
674
691
|
* control-plane verify endpoint resolves these tokens back to the user.
|
package/dist/index.d.ts
CHANGED
|
@@ -38,6 +38,12 @@ interface TestUser {
|
|
|
38
38
|
projectId?: string | null;
|
|
39
39
|
/** Cross-app customer ownership key returned by the platform identity resolver. */
|
|
40
40
|
identityId?: string | null;
|
|
41
|
+
/**
|
|
42
|
+
* The person's LINE user ID under the shop's own LINE provider. Set it to sign in
|
|
43
|
+
* as someone who came through LINE sign-in inside the shop's LINE MINI App; app
|
|
44
|
+
* code reads it from `session.user.lineUserId` and pushes to it.
|
|
45
|
+
*/
|
|
46
|
+
lineUserId?: string | null;
|
|
41
47
|
}
|
|
42
48
|
interface CapturedEmail {
|
|
43
49
|
resendId: string;
|
|
@@ -615,6 +621,12 @@ interface TestStarlens {
|
|
|
615
621
|
get count(): number;
|
|
616
622
|
}
|
|
617
623
|
interface TestAppOptions {
|
|
624
|
+
/**
|
|
625
|
+
* The LIFF ID the platform forwards to the app for a LINE MINI App shop. It is
|
|
626
|
+
* what `GET /api/auth/me` returns as `liffId` and what makes `useLiff()` start
|
|
627
|
+
* LIFF. Defaults to `null` (the app has no LINE MINI App). Restored by `reset()`.
|
|
628
|
+
*/
|
|
629
|
+
liffId?: string | null;
|
|
618
630
|
/** Data stores exposed through the production-shaped STARDECK_DATA_STORES manifest. */
|
|
619
631
|
dataStores?: TestDataStoreInput[];
|
|
620
632
|
/**
|
|
@@ -669,6 +681,11 @@ interface TestApp {
|
|
|
669
681
|
asUser(user?: Partial<TestUser>): TestUser;
|
|
670
682
|
/** Clear the active test user. */
|
|
671
683
|
signOut(): void;
|
|
684
|
+
/**
|
|
685
|
+
* Set (or clear with `null`) the LIFF ID the platform forwards to the app, for
|
|
686
|
+
* the rest of the test. The real project-auth `me` route returns it as `liffId`.
|
|
687
|
+
*/
|
|
688
|
+
setLiffId(liffId: string | null): void;
|
|
672
689
|
/**
|
|
673
690
|
* Mint access/refresh tokens for cookie-based auth flows. The simulated
|
|
674
691
|
* control-plane verify endpoint resolves these tokens back to the user.
|
package/dist/index.js
CHANGED
|
@@ -310,6 +310,7 @@ var TEST_ENV_DEFAULTS = {
|
|
|
310
310
|
DEPLOYMENT_ID: "00000000-0000-4000-8000-00000000000c",
|
|
311
311
|
STORAGE_URL: STORAGE_TEST_URL
|
|
312
312
|
};
|
|
313
|
+
var LIFF_ID_HEADER = "x-stardeck-liff-id";
|
|
313
314
|
var DEFAULT_TEST_USER = {
|
|
314
315
|
id: "test-user-1",
|
|
315
316
|
email: "test-user@example.com",
|
|
@@ -324,6 +325,7 @@ var state = globalSingleton("state", () => ({
|
|
|
324
325
|
db: null,
|
|
325
326
|
lastHarnessDataStoreManifest: null,
|
|
326
327
|
currentUser: null,
|
|
328
|
+
liffId: null,
|
|
327
329
|
sessions: /* @__PURE__ */ new Map(),
|
|
328
330
|
refreshSessions: /* @__PURE__ */ new Map(),
|
|
329
331
|
emails: [],
|
|
@@ -338,6 +340,7 @@ var state = globalSingleton("state", () => ({
|
|
|
338
340
|
checkouts: [],
|
|
339
341
|
checkoutCounter: 0,
|
|
340
342
|
sessionStatuses: /* @__PURE__ */ new Map(),
|
|
343
|
+
idempotentCheckouts: /* @__PURE__ */ new Map(),
|
|
341
344
|
paymentLinks: /* @__PURE__ */ new Map(),
|
|
342
345
|
paymentFailures: [],
|
|
343
346
|
products: [],
|
|
@@ -2066,6 +2069,9 @@ async function callRoute(handler, options = {}) {
|
|
|
2066
2069
|
if (user && !headers.has("x-stardeck-user")) {
|
|
2067
2070
|
headers.set("x-stardeck-user", JSON.stringify(user));
|
|
2068
2071
|
}
|
|
2072
|
+
if (state.liffId && !headers.has(LIFF_ID_HEADER)) {
|
|
2073
|
+
headers.set(LIFF_ID_HEADER, state.liffId);
|
|
2074
|
+
}
|
|
2069
2075
|
if (options.body !== void 0 && !headers.has("Content-Type")) {
|
|
2070
2076
|
headers.set("Content-Type", "application/json");
|
|
2071
2077
|
}
|
|
@@ -2126,6 +2132,8 @@ function signEventDelivery(secret, context, rawBody) {
|
|
|
2126
2132
|
}
|
|
2127
2133
|
|
|
2128
2134
|
// src/simulator/payments.ts
|
|
2135
|
+
var STRIPE_MIN_EXPIRY_SECONDS = 30 * 60;
|
|
2136
|
+
var STRIPE_MAX_EXPIRY_SECONDS = 24 * 60 * 60;
|
|
2129
2137
|
var BEAM_BOLT_PAYMENT_METHODS = [
|
|
2130
2138
|
"CARD",
|
|
2131
2139
|
"CARD_INSTALLMENTS",
|
|
@@ -2195,6 +2203,15 @@ function isBoltPaymentMethod(value) {
|
|
|
2195
2203
|
function isBoltIntentStatus(value) {
|
|
2196
2204
|
return BEAM_BOLT_INTENT_STATUSES.includes(value);
|
|
2197
2205
|
}
|
|
2206
|
+
function stableStringify(value) {
|
|
2207
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
|
|
2208
|
+
if (value && typeof value === "object") {
|
|
2209
|
+
const entries = Object.entries(value).filter(([, v]) => v !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
2210
|
+
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`).join(",")}}`;
|
|
2211
|
+
}
|
|
2212
|
+
return JSON.stringify(value);
|
|
2213
|
+
}
|
|
2214
|
+
var ISO_8601_WITH_ZONE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})$/;
|
|
2198
2215
|
function nextCheckoutId() {
|
|
2199
2216
|
state.checkoutCounter += 1;
|
|
2200
2217
|
return `cs_test_${state.checkoutCounter}`;
|
|
@@ -2215,7 +2232,7 @@ function nextBoltChargeId() {
|
|
|
2215
2232
|
state.boltChargeCounter += 1;
|
|
2216
2233
|
return `chrg_${state.boltChargeCounter}`;
|
|
2217
2234
|
}
|
|
2218
|
-
function seedStripeSession(id, body) {
|
|
2235
|
+
function seedStripeSession(id, body, expiresAt) {
|
|
2219
2236
|
const lineItems = body.lineItems ?? [];
|
|
2220
2237
|
let amountTotal = null;
|
|
2221
2238
|
let currency = null;
|
|
@@ -2235,7 +2252,7 @@ function seedStripeSession(id, body) {
|
|
|
2235
2252
|
currency,
|
|
2236
2253
|
customerEmail: body.customerEmail ? String(body.customerEmail) : null,
|
|
2237
2254
|
metadata: body.metadata ?? {},
|
|
2238
|
-
expiresAt
|
|
2255
|
+
expiresAt
|
|
2239
2256
|
});
|
|
2240
2257
|
}
|
|
2241
2258
|
function seedBeamLink(id, body, merchantId) {
|
|
@@ -2255,7 +2272,8 @@ function seedBeamLink(id, body, merchantId) {
|
|
|
2255
2272
|
},
|
|
2256
2273
|
redirectUrl: body.redirectUrl ? String(body.redirectUrl) : void 0,
|
|
2257
2274
|
linkSettings: body.linkSettings,
|
|
2258
|
-
collectDeliveryAddress: body.collectDeliveryAddress === true
|
|
2275
|
+
collectDeliveryAddress: body.collectDeliveryAddress === true,
|
|
2276
|
+
...typeof body.expiresAt === "string" ? { expiresAt: body.expiresAt } : {}
|
|
2259
2277
|
});
|
|
2260
2278
|
}
|
|
2261
2279
|
function deriveBoltIntentStatus(storedStatus, expiresAt, now3 = /* @__PURE__ */ new Date()) {
|
|
@@ -2557,6 +2575,16 @@ async function handlePaymentsRequest(request, url) {
|
|
|
2557
2575
|
const linkId = beamProductsMatch[2];
|
|
2558
2576
|
if (!linkId && request.method === "POST") {
|
|
2559
2577
|
const body = await readJsonBody(request);
|
|
2578
|
+
if ("idempotencyKey" in body) {
|
|
2579
|
+
return payErr(
|
|
2580
|
+
"idempotencyKey is not supported for Beam payment links.",
|
|
2581
|
+
400,
|
|
2582
|
+
"idempotency_unsupported"
|
|
2583
|
+
);
|
|
2584
|
+
}
|
|
2585
|
+
if (typeof body.expiresAt === "string" && (!ISO_8601_WITH_ZONE.test(body.expiresAt) || Number.isNaN(Date.parse(body.expiresAt)))) {
|
|
2586
|
+
return payErr("expiresAt must be an ISO 8601 timestamp", 400);
|
|
2587
|
+
}
|
|
2560
2588
|
const id = nextPaymentLinkId();
|
|
2561
2589
|
const checkoutUrl = `https://beam.test/pay/${id}`;
|
|
2562
2590
|
const captured = {
|
|
@@ -2569,7 +2597,11 @@ async function handlePaymentsRequest(request, url) {
|
|
|
2569
2597
|
};
|
|
2570
2598
|
state.checkouts.push(captured);
|
|
2571
2599
|
seedBeamLink(id, body, merchantId);
|
|
2572
|
-
return json({
|
|
2600
|
+
return json({
|
|
2601
|
+
id,
|
|
2602
|
+
url: checkoutUrl,
|
|
2603
|
+
...typeof body.expiresAt === "string" ? { expiresAt: body.expiresAt } : {}
|
|
2604
|
+
});
|
|
2573
2605
|
}
|
|
2574
2606
|
if (linkId && request.method === "GET") {
|
|
2575
2607
|
const link = state.paymentLinks.get(linkId);
|
|
@@ -2595,6 +2627,34 @@ async function handlePaymentsRequest(request, url) {
|
|
|
2595
2627
|
}
|
|
2596
2628
|
if (subPath === "checkout" && request.method === "POST") {
|
|
2597
2629
|
const body = await readJsonBody(request);
|
|
2630
|
+
const nowSeconds = Math.floor(Date.now() / 1e3);
|
|
2631
|
+
const requestedExpiry = typeof body.expiresAt === "number" ? body.expiresAt : void 0;
|
|
2632
|
+
const callerKey = typeof body.idempotencyKey === "string" ? body.idempotencyKey : void 0;
|
|
2633
|
+
const scopedKey = callerKey ? JSON.stringify(["stripe", accountId, callerKey]) : void 0;
|
|
2634
|
+
const comparable = { ...body };
|
|
2635
|
+
delete comparable.deploymentId;
|
|
2636
|
+
delete comparable.idempotencyKey;
|
|
2637
|
+
const fingerprint = stableStringify(comparable);
|
|
2638
|
+
if (scopedKey) {
|
|
2639
|
+
const saved = state.idempotentCheckouts.get(scopedKey);
|
|
2640
|
+
if (saved) {
|
|
2641
|
+
if (saved.fingerprint !== fingerprint) {
|
|
2642
|
+
return payErr(
|
|
2643
|
+
"This idempotency key was already used with different checkout parameters. Use a new key for a different checkout.",
|
|
2644
|
+
409,
|
|
2645
|
+
"idempotency_conflict"
|
|
2646
|
+
);
|
|
2647
|
+
}
|
|
2648
|
+
return json(saved.response);
|
|
2649
|
+
}
|
|
2650
|
+
}
|
|
2651
|
+
if (requestedExpiry !== void 0 && (requestedExpiry < nowSeconds + STRIPE_MIN_EXPIRY_SECONDS || requestedExpiry > nowSeconds + STRIPE_MAX_EXPIRY_SECONDS)) {
|
|
2652
|
+
return payErr(
|
|
2653
|
+
"expiresAt must be between 30 minutes and 24 hours after the checkout is created (Unix seconds).",
|
|
2654
|
+
400,
|
|
2655
|
+
"expiry_out_of_range"
|
|
2656
|
+
);
|
|
2657
|
+
}
|
|
2598
2658
|
const id = nextCheckoutId();
|
|
2599
2659
|
const checkoutUrl = `https://checkout.stripe.test/c/pay/${id}`;
|
|
2600
2660
|
const captured = {
|
|
@@ -2607,8 +2667,11 @@ async function handlePaymentsRequest(request, url) {
|
|
|
2607
2667
|
createdAt: /* @__PURE__ */ new Date()
|
|
2608
2668
|
};
|
|
2609
2669
|
state.checkouts.push(captured);
|
|
2610
|
-
|
|
2611
|
-
|
|
2670
|
+
const expiresAt = requestedExpiry ?? nowSeconds + STRIPE_MAX_EXPIRY_SECONDS;
|
|
2671
|
+
seedStripeSession(id, body, expiresAt);
|
|
2672
|
+
const response = { id, url: checkoutUrl, expiresAt };
|
|
2673
|
+
if (scopedKey) state.idempotentCheckouts.set(scopedKey, { fingerprint, response });
|
|
2674
|
+
return json(response);
|
|
2612
2675
|
}
|
|
2613
2676
|
const sessionMatch = subPath.match(/^checkout-sessions\/([^/]+)$/);
|
|
2614
2677
|
if (sessionMatch && request.method === "GET") {
|
|
@@ -2773,6 +2836,7 @@ function createPayments() {
|
|
|
2773
2836
|
state.checkouts = [];
|
|
2774
2837
|
state.checkoutCounter = 0;
|
|
2775
2838
|
state.sessionStatuses.clear();
|
|
2839
|
+
state.idempotentCheckouts.clear();
|
|
2776
2840
|
state.paymentLinks.clear();
|
|
2777
2841
|
state.paymentFailures = [];
|
|
2778
2842
|
state.products = [];
|
|
@@ -3856,6 +3920,8 @@ async function createTestApp(options = {}) {
|
|
|
3856
3920
|
}
|
|
3857
3921
|
applyTestEnv(options);
|
|
3858
3922
|
state.allowNetwork = options.allowNetwork ?? false;
|
|
3923
|
+
const initialLiffId = options.liffId ?? null;
|
|
3924
|
+
state.liffId = initialLiffId;
|
|
3859
3925
|
installFetchRouter();
|
|
3860
3926
|
const schemaSql = loadSchemaSql(options) ?? "";
|
|
3861
3927
|
const db = await bootWithSchema(schemaSql);
|
|
@@ -3908,6 +3974,9 @@ async function createTestApp(options = {}) {
|
|
|
3908
3974
|
signOut() {
|
|
3909
3975
|
state.currentUser = null;
|
|
3910
3976
|
},
|
|
3977
|
+
setLiffId(liffId) {
|
|
3978
|
+
state.liffId = liffId;
|
|
3979
|
+
},
|
|
3911
3980
|
issueSession(user) {
|
|
3912
3981
|
const fullUser = buildUser(user);
|
|
3913
3982
|
const tokens = {
|
|
@@ -3930,6 +3999,7 @@ async function createTestApp(options = {}) {
|
|
|
3930
3999
|
}
|
|
3931
4000
|
}
|
|
3932
4001
|
state.currentUser = null;
|
|
4002
|
+
state.liffId = initialLiffId;
|
|
3933
4003
|
state.sessions.clear();
|
|
3934
4004
|
state.refreshSessions.clear();
|
|
3935
4005
|
state.emails = [];
|
|
@@ -3944,6 +4014,7 @@ async function createTestApp(options = {}) {
|
|
|
3944
4014
|
async close() {
|
|
3945
4015
|
state.db = null;
|
|
3946
4016
|
state.currentUser = null;
|
|
4017
|
+
state.liffId = null;
|
|
3947
4018
|
state.sessions.clear();
|
|
3948
4019
|
state.refreshSessions.clear();
|
|
3949
4020
|
state.emails = [];
|
package/dist/index.mjs
CHANGED
|
@@ -271,6 +271,7 @@ var TEST_ENV_DEFAULTS = {
|
|
|
271
271
|
DEPLOYMENT_ID: "00000000-0000-4000-8000-00000000000c",
|
|
272
272
|
STORAGE_URL: STORAGE_TEST_URL
|
|
273
273
|
};
|
|
274
|
+
var LIFF_ID_HEADER = "x-stardeck-liff-id";
|
|
274
275
|
var DEFAULT_TEST_USER = {
|
|
275
276
|
id: "test-user-1",
|
|
276
277
|
email: "test-user@example.com",
|
|
@@ -285,6 +286,7 @@ var state = globalSingleton("state", () => ({
|
|
|
285
286
|
db: null,
|
|
286
287
|
lastHarnessDataStoreManifest: null,
|
|
287
288
|
currentUser: null,
|
|
289
|
+
liffId: null,
|
|
288
290
|
sessions: /* @__PURE__ */ new Map(),
|
|
289
291
|
refreshSessions: /* @__PURE__ */ new Map(),
|
|
290
292
|
emails: [],
|
|
@@ -299,6 +301,7 @@ var state = globalSingleton("state", () => ({
|
|
|
299
301
|
checkouts: [],
|
|
300
302
|
checkoutCounter: 0,
|
|
301
303
|
sessionStatuses: /* @__PURE__ */ new Map(),
|
|
304
|
+
idempotentCheckouts: /* @__PURE__ */ new Map(),
|
|
302
305
|
paymentLinks: /* @__PURE__ */ new Map(),
|
|
303
306
|
paymentFailures: [],
|
|
304
307
|
products: [],
|
|
@@ -2027,6 +2030,9 @@ async function callRoute(handler, options = {}) {
|
|
|
2027
2030
|
if (user && !headers.has("x-stardeck-user")) {
|
|
2028
2031
|
headers.set("x-stardeck-user", JSON.stringify(user));
|
|
2029
2032
|
}
|
|
2033
|
+
if (state.liffId && !headers.has(LIFF_ID_HEADER)) {
|
|
2034
|
+
headers.set(LIFF_ID_HEADER, state.liffId);
|
|
2035
|
+
}
|
|
2030
2036
|
if (options.body !== void 0 && !headers.has("Content-Type")) {
|
|
2031
2037
|
headers.set("Content-Type", "application/json");
|
|
2032
2038
|
}
|
|
@@ -2087,6 +2093,8 @@ function signEventDelivery(secret, context, rawBody) {
|
|
|
2087
2093
|
}
|
|
2088
2094
|
|
|
2089
2095
|
// src/simulator/payments.ts
|
|
2096
|
+
var STRIPE_MIN_EXPIRY_SECONDS = 30 * 60;
|
|
2097
|
+
var STRIPE_MAX_EXPIRY_SECONDS = 24 * 60 * 60;
|
|
2090
2098
|
var BEAM_BOLT_PAYMENT_METHODS = [
|
|
2091
2099
|
"CARD",
|
|
2092
2100
|
"CARD_INSTALLMENTS",
|
|
@@ -2156,6 +2164,15 @@ function isBoltPaymentMethod(value) {
|
|
|
2156
2164
|
function isBoltIntentStatus(value) {
|
|
2157
2165
|
return BEAM_BOLT_INTENT_STATUSES.includes(value);
|
|
2158
2166
|
}
|
|
2167
|
+
function stableStringify(value) {
|
|
2168
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
|
|
2169
|
+
if (value && typeof value === "object") {
|
|
2170
|
+
const entries = Object.entries(value).filter(([, v]) => v !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
2171
|
+
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`).join(",")}}`;
|
|
2172
|
+
}
|
|
2173
|
+
return JSON.stringify(value);
|
|
2174
|
+
}
|
|
2175
|
+
var ISO_8601_WITH_ZONE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})$/;
|
|
2159
2176
|
function nextCheckoutId() {
|
|
2160
2177
|
state.checkoutCounter += 1;
|
|
2161
2178
|
return `cs_test_${state.checkoutCounter}`;
|
|
@@ -2176,7 +2193,7 @@ function nextBoltChargeId() {
|
|
|
2176
2193
|
state.boltChargeCounter += 1;
|
|
2177
2194
|
return `chrg_${state.boltChargeCounter}`;
|
|
2178
2195
|
}
|
|
2179
|
-
function seedStripeSession(id, body) {
|
|
2196
|
+
function seedStripeSession(id, body, expiresAt) {
|
|
2180
2197
|
const lineItems = body.lineItems ?? [];
|
|
2181
2198
|
let amountTotal = null;
|
|
2182
2199
|
let currency = null;
|
|
@@ -2196,7 +2213,7 @@ function seedStripeSession(id, body) {
|
|
|
2196
2213
|
currency,
|
|
2197
2214
|
customerEmail: body.customerEmail ? String(body.customerEmail) : null,
|
|
2198
2215
|
metadata: body.metadata ?? {},
|
|
2199
|
-
expiresAt
|
|
2216
|
+
expiresAt
|
|
2200
2217
|
});
|
|
2201
2218
|
}
|
|
2202
2219
|
function seedBeamLink(id, body, merchantId) {
|
|
@@ -2216,7 +2233,8 @@ function seedBeamLink(id, body, merchantId) {
|
|
|
2216
2233
|
},
|
|
2217
2234
|
redirectUrl: body.redirectUrl ? String(body.redirectUrl) : void 0,
|
|
2218
2235
|
linkSettings: body.linkSettings,
|
|
2219
|
-
collectDeliveryAddress: body.collectDeliveryAddress === true
|
|
2236
|
+
collectDeliveryAddress: body.collectDeliveryAddress === true,
|
|
2237
|
+
...typeof body.expiresAt === "string" ? { expiresAt: body.expiresAt } : {}
|
|
2220
2238
|
});
|
|
2221
2239
|
}
|
|
2222
2240
|
function deriveBoltIntentStatus(storedStatus, expiresAt, now3 = /* @__PURE__ */ new Date()) {
|
|
@@ -2518,6 +2536,16 @@ async function handlePaymentsRequest(request, url) {
|
|
|
2518
2536
|
const linkId = beamProductsMatch[2];
|
|
2519
2537
|
if (!linkId && request.method === "POST") {
|
|
2520
2538
|
const body = await readJsonBody(request);
|
|
2539
|
+
if ("idempotencyKey" in body) {
|
|
2540
|
+
return payErr(
|
|
2541
|
+
"idempotencyKey is not supported for Beam payment links.",
|
|
2542
|
+
400,
|
|
2543
|
+
"idempotency_unsupported"
|
|
2544
|
+
);
|
|
2545
|
+
}
|
|
2546
|
+
if (typeof body.expiresAt === "string" && (!ISO_8601_WITH_ZONE.test(body.expiresAt) || Number.isNaN(Date.parse(body.expiresAt)))) {
|
|
2547
|
+
return payErr("expiresAt must be an ISO 8601 timestamp", 400);
|
|
2548
|
+
}
|
|
2521
2549
|
const id = nextPaymentLinkId();
|
|
2522
2550
|
const checkoutUrl = `https://beam.test/pay/${id}`;
|
|
2523
2551
|
const captured = {
|
|
@@ -2530,7 +2558,11 @@ async function handlePaymentsRequest(request, url) {
|
|
|
2530
2558
|
};
|
|
2531
2559
|
state.checkouts.push(captured);
|
|
2532
2560
|
seedBeamLink(id, body, merchantId);
|
|
2533
|
-
return json({
|
|
2561
|
+
return json({
|
|
2562
|
+
id,
|
|
2563
|
+
url: checkoutUrl,
|
|
2564
|
+
...typeof body.expiresAt === "string" ? { expiresAt: body.expiresAt } : {}
|
|
2565
|
+
});
|
|
2534
2566
|
}
|
|
2535
2567
|
if (linkId && request.method === "GET") {
|
|
2536
2568
|
const link = state.paymentLinks.get(linkId);
|
|
@@ -2556,6 +2588,34 @@ async function handlePaymentsRequest(request, url) {
|
|
|
2556
2588
|
}
|
|
2557
2589
|
if (subPath === "checkout" && request.method === "POST") {
|
|
2558
2590
|
const body = await readJsonBody(request);
|
|
2591
|
+
const nowSeconds = Math.floor(Date.now() / 1e3);
|
|
2592
|
+
const requestedExpiry = typeof body.expiresAt === "number" ? body.expiresAt : void 0;
|
|
2593
|
+
const callerKey = typeof body.idempotencyKey === "string" ? body.idempotencyKey : void 0;
|
|
2594
|
+
const scopedKey = callerKey ? JSON.stringify(["stripe", accountId, callerKey]) : void 0;
|
|
2595
|
+
const comparable = { ...body };
|
|
2596
|
+
delete comparable.deploymentId;
|
|
2597
|
+
delete comparable.idempotencyKey;
|
|
2598
|
+
const fingerprint = stableStringify(comparable);
|
|
2599
|
+
if (scopedKey) {
|
|
2600
|
+
const saved = state.idempotentCheckouts.get(scopedKey);
|
|
2601
|
+
if (saved) {
|
|
2602
|
+
if (saved.fingerprint !== fingerprint) {
|
|
2603
|
+
return payErr(
|
|
2604
|
+
"This idempotency key was already used with different checkout parameters. Use a new key for a different checkout.",
|
|
2605
|
+
409,
|
|
2606
|
+
"idempotency_conflict"
|
|
2607
|
+
);
|
|
2608
|
+
}
|
|
2609
|
+
return json(saved.response);
|
|
2610
|
+
}
|
|
2611
|
+
}
|
|
2612
|
+
if (requestedExpiry !== void 0 && (requestedExpiry < nowSeconds + STRIPE_MIN_EXPIRY_SECONDS || requestedExpiry > nowSeconds + STRIPE_MAX_EXPIRY_SECONDS)) {
|
|
2613
|
+
return payErr(
|
|
2614
|
+
"expiresAt must be between 30 minutes and 24 hours after the checkout is created (Unix seconds).",
|
|
2615
|
+
400,
|
|
2616
|
+
"expiry_out_of_range"
|
|
2617
|
+
);
|
|
2618
|
+
}
|
|
2559
2619
|
const id = nextCheckoutId();
|
|
2560
2620
|
const checkoutUrl = `https://checkout.stripe.test/c/pay/${id}`;
|
|
2561
2621
|
const captured = {
|
|
@@ -2568,8 +2628,11 @@ async function handlePaymentsRequest(request, url) {
|
|
|
2568
2628
|
createdAt: /* @__PURE__ */ new Date()
|
|
2569
2629
|
};
|
|
2570
2630
|
state.checkouts.push(captured);
|
|
2571
|
-
|
|
2572
|
-
|
|
2631
|
+
const expiresAt = requestedExpiry ?? nowSeconds + STRIPE_MAX_EXPIRY_SECONDS;
|
|
2632
|
+
seedStripeSession(id, body, expiresAt);
|
|
2633
|
+
const response = { id, url: checkoutUrl, expiresAt };
|
|
2634
|
+
if (scopedKey) state.idempotentCheckouts.set(scopedKey, { fingerprint, response });
|
|
2635
|
+
return json(response);
|
|
2573
2636
|
}
|
|
2574
2637
|
const sessionMatch = subPath.match(/^checkout-sessions\/([^/]+)$/);
|
|
2575
2638
|
if (sessionMatch && request.method === "GET") {
|
|
@@ -2734,6 +2797,7 @@ function createPayments() {
|
|
|
2734
2797
|
state.checkouts = [];
|
|
2735
2798
|
state.checkoutCounter = 0;
|
|
2736
2799
|
state.sessionStatuses.clear();
|
|
2800
|
+
state.idempotentCheckouts.clear();
|
|
2737
2801
|
state.paymentLinks.clear();
|
|
2738
2802
|
state.paymentFailures = [];
|
|
2739
2803
|
state.products = [];
|
|
@@ -3817,6 +3881,8 @@ async function createTestApp(options = {}) {
|
|
|
3817
3881
|
}
|
|
3818
3882
|
applyTestEnv(options);
|
|
3819
3883
|
state.allowNetwork = options.allowNetwork ?? false;
|
|
3884
|
+
const initialLiffId = options.liffId ?? null;
|
|
3885
|
+
state.liffId = initialLiffId;
|
|
3820
3886
|
installFetchRouter();
|
|
3821
3887
|
const schemaSql = loadSchemaSql(options) ?? "";
|
|
3822
3888
|
const db = await bootWithSchema(schemaSql);
|
|
@@ -3869,6 +3935,9 @@ async function createTestApp(options = {}) {
|
|
|
3869
3935
|
signOut() {
|
|
3870
3936
|
state.currentUser = null;
|
|
3871
3937
|
},
|
|
3938
|
+
setLiffId(liffId) {
|
|
3939
|
+
state.liffId = liffId;
|
|
3940
|
+
},
|
|
3872
3941
|
issueSession(user) {
|
|
3873
3942
|
const fullUser = buildUser(user);
|
|
3874
3943
|
const tokens = {
|
|
@@ -3891,6 +3960,7 @@ async function createTestApp(options = {}) {
|
|
|
3891
3960
|
}
|
|
3892
3961
|
}
|
|
3893
3962
|
state.currentUser = null;
|
|
3963
|
+
state.liffId = initialLiffId;
|
|
3894
3964
|
state.sessions.clear();
|
|
3895
3965
|
state.refreshSessions.clear();
|
|
3896
3966
|
state.emails = [];
|
|
@@ -3905,6 +3975,7 @@ async function createTestApp(options = {}) {
|
|
|
3905
3975
|
async close() {
|
|
3906
3976
|
state.db = null;
|
|
3907
3977
|
state.currentUser = null;
|
|
3978
|
+
state.liffId = null;
|
|
3908
3979
|
state.sessions.clear();
|
|
3909
3980
|
state.refreshSessions.clear();
|
|
3910
3981
|
state.emails = [];
|
|
@@ -28,6 +28,12 @@ __export(headers_shim_exports, {
|
|
|
28
28
|
module.exports = __toCommonJS(headers_shim_exports);
|
|
29
29
|
var import_node_async_hooks = require("async_hooks");
|
|
30
30
|
|
|
31
|
+
// src/constants.ts
|
|
32
|
+
var DATA_STORE_TEST_HOST = "db.stardeck.test";
|
|
33
|
+
var DATA_STORE_TEST_URL = `postgresql://test:test@${DATA_STORE_TEST_HOST}/main`;
|
|
34
|
+
var APP_TEST_ORIGIN = "http://localhost:3333";
|
|
35
|
+
var LIFF_ID_HEADER = "x-stardeck-liff-id";
|
|
36
|
+
|
|
31
37
|
// src/global-singleton.ts
|
|
32
38
|
function globalSingleton(key, create) {
|
|
33
39
|
const symbol = /* @__PURE__ */ Symbol.for(`stardeck-testing.${key}`);
|
|
@@ -36,16 +42,12 @@ function globalSingleton(key, create) {
|
|
|
36
42
|
return holder[symbol];
|
|
37
43
|
}
|
|
38
44
|
|
|
39
|
-
// src/constants.ts
|
|
40
|
-
var DATA_STORE_TEST_HOST = "db.stardeck.test";
|
|
41
|
-
var DATA_STORE_TEST_URL = `postgresql://test:test@${DATA_STORE_TEST_HOST}/main`;
|
|
42
|
-
var APP_TEST_ORIGIN = "http://localhost:3333";
|
|
43
|
-
|
|
44
45
|
// src/state.ts
|
|
45
46
|
var state = globalSingleton("state", () => ({
|
|
46
47
|
db: null,
|
|
47
48
|
lastHarnessDataStoreManifest: null,
|
|
48
49
|
currentUser: null,
|
|
50
|
+
liffId: null,
|
|
49
51
|
sessions: /* @__PURE__ */ new Map(),
|
|
50
52
|
refreshSessions: /* @__PURE__ */ new Map(),
|
|
51
53
|
emails: [],
|
|
@@ -60,6 +62,7 @@ var state = globalSingleton("state", () => ({
|
|
|
60
62
|
checkouts: [],
|
|
61
63
|
checkoutCounter: 0,
|
|
62
64
|
sessionStatuses: /* @__PURE__ */ new Map(),
|
|
65
|
+
idempotentCheckouts: /* @__PURE__ */ new Map(),
|
|
63
66
|
paymentLinks: /* @__PURE__ */ new Map(),
|
|
64
67
|
paymentFailures: [],
|
|
65
68
|
products: [],
|
|
@@ -105,6 +108,7 @@ function ambientScope() {
|
|
|
105
108
|
if (state.currentUser) {
|
|
106
109
|
headers2.set("x-stardeck-user", JSON.stringify(state.currentUser));
|
|
107
110
|
}
|
|
111
|
+
if (state.liffId) headers2.set(LIFF_ID_HEADER, state.liffId);
|
|
108
112
|
return { headers: headers2, cookies: /* @__PURE__ */ new Map() };
|
|
109
113
|
}
|
|
110
114
|
function currentScope() {
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
// src/next/headers-shim.ts
|
|
2
2
|
import { AsyncLocalStorage } from "async_hooks";
|
|
3
3
|
|
|
4
|
+
// src/constants.ts
|
|
5
|
+
var DATA_STORE_TEST_HOST = "db.stardeck.test";
|
|
6
|
+
var DATA_STORE_TEST_URL = `postgresql://test:test@${DATA_STORE_TEST_HOST}/main`;
|
|
7
|
+
var APP_TEST_ORIGIN = "http://localhost:3333";
|
|
8
|
+
var LIFF_ID_HEADER = "x-stardeck-liff-id";
|
|
9
|
+
|
|
4
10
|
// src/global-singleton.ts
|
|
5
11
|
function globalSingleton(key, create) {
|
|
6
12
|
const symbol = /* @__PURE__ */ Symbol.for(`stardeck-testing.${key}`);
|
|
@@ -9,16 +15,12 @@ function globalSingleton(key, create) {
|
|
|
9
15
|
return holder[symbol];
|
|
10
16
|
}
|
|
11
17
|
|
|
12
|
-
// src/constants.ts
|
|
13
|
-
var DATA_STORE_TEST_HOST = "db.stardeck.test";
|
|
14
|
-
var DATA_STORE_TEST_URL = `postgresql://test:test@${DATA_STORE_TEST_HOST}/main`;
|
|
15
|
-
var APP_TEST_ORIGIN = "http://localhost:3333";
|
|
16
|
-
|
|
17
18
|
// src/state.ts
|
|
18
19
|
var state = globalSingleton("state", () => ({
|
|
19
20
|
db: null,
|
|
20
21
|
lastHarnessDataStoreManifest: null,
|
|
21
22
|
currentUser: null,
|
|
23
|
+
liffId: null,
|
|
22
24
|
sessions: /* @__PURE__ */ new Map(),
|
|
23
25
|
refreshSessions: /* @__PURE__ */ new Map(),
|
|
24
26
|
emails: [],
|
|
@@ -33,6 +35,7 @@ var state = globalSingleton("state", () => ({
|
|
|
33
35
|
checkouts: [],
|
|
34
36
|
checkoutCounter: 0,
|
|
35
37
|
sessionStatuses: /* @__PURE__ */ new Map(),
|
|
38
|
+
idempotentCheckouts: /* @__PURE__ */ new Map(),
|
|
36
39
|
paymentLinks: /* @__PURE__ */ new Map(),
|
|
37
40
|
paymentFailures: [],
|
|
38
41
|
products: [],
|
|
@@ -78,6 +81,7 @@ function ambientScope() {
|
|
|
78
81
|
if (state.currentUser) {
|
|
79
82
|
headers2.set("x-stardeck-user", JSON.stringify(state.currentUser));
|
|
80
83
|
}
|
|
84
|
+
if (state.liffId) headers2.set(LIFF_ID_HEADER, state.liffId);
|
|
81
85
|
return { headers: headers2, cookies: /* @__PURE__ */ new Map() };
|
|
82
86
|
}
|
|
83
87
|
function currentScope() {
|
package/dist/setup.js
CHANGED
|
@@ -52,6 +52,7 @@ var state = globalSingleton("state", () => ({
|
|
|
52
52
|
db: null,
|
|
53
53
|
lastHarnessDataStoreManifest: null,
|
|
54
54
|
currentUser: null,
|
|
55
|
+
liffId: null,
|
|
55
56
|
sessions: /* @__PURE__ */ new Map(),
|
|
56
57
|
refreshSessions: /* @__PURE__ */ new Map(),
|
|
57
58
|
emails: [],
|
|
@@ -66,6 +67,7 @@ var state = globalSingleton("state", () => ({
|
|
|
66
67
|
checkouts: [],
|
|
67
68
|
checkoutCounter: 0,
|
|
68
69
|
sessionStatuses: /* @__PURE__ */ new Map(),
|
|
70
|
+
idempotentCheckouts: /* @__PURE__ */ new Map(),
|
|
69
71
|
paymentLinks: /* @__PURE__ */ new Map(),
|
|
70
72
|
paymentFailures: [],
|
|
71
73
|
products: [],
|
|
@@ -1535,6 +1537,8 @@ var requestScopeStorage = globalSingleton(
|
|
|
1535
1537
|
var import_node_crypto4 = __toESM(require("crypto"));
|
|
1536
1538
|
|
|
1537
1539
|
// src/simulator/payments.ts
|
|
1540
|
+
var STRIPE_MIN_EXPIRY_SECONDS = 30 * 60;
|
|
1541
|
+
var STRIPE_MAX_EXPIRY_SECONDS = 24 * 60 * 60;
|
|
1538
1542
|
var BEAM_BOLT_PAYMENT_METHODS = [
|
|
1539
1543
|
"CARD",
|
|
1540
1544
|
"CARD_INSTALLMENTS",
|
|
@@ -1604,6 +1608,15 @@ function isBoltPaymentMethod(value) {
|
|
|
1604
1608
|
function isBoltIntentStatus(value) {
|
|
1605
1609
|
return BEAM_BOLT_INTENT_STATUSES.includes(value);
|
|
1606
1610
|
}
|
|
1611
|
+
function stableStringify(value) {
|
|
1612
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
|
|
1613
|
+
if (value && typeof value === "object") {
|
|
1614
|
+
const entries = Object.entries(value).filter(([, v]) => v !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
1615
|
+
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`).join(",")}}`;
|
|
1616
|
+
}
|
|
1617
|
+
return JSON.stringify(value);
|
|
1618
|
+
}
|
|
1619
|
+
var ISO_8601_WITH_ZONE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})$/;
|
|
1607
1620
|
function nextCheckoutId() {
|
|
1608
1621
|
state.checkoutCounter += 1;
|
|
1609
1622
|
return `cs_test_${state.checkoutCounter}`;
|
|
@@ -1620,7 +1633,7 @@ function nextBoltIntentId() {
|
|
|
1620
1633
|
state.boltIntentCounter += 1;
|
|
1621
1634
|
return `bolti_${state.boltIntentCounter}`;
|
|
1622
1635
|
}
|
|
1623
|
-
function seedStripeSession(id, body) {
|
|
1636
|
+
function seedStripeSession(id, body, expiresAt) {
|
|
1624
1637
|
const lineItems = body.lineItems ?? [];
|
|
1625
1638
|
let amountTotal = null;
|
|
1626
1639
|
let currency = null;
|
|
@@ -1640,7 +1653,7 @@ function seedStripeSession(id, body) {
|
|
|
1640
1653
|
currency,
|
|
1641
1654
|
customerEmail: body.customerEmail ? String(body.customerEmail) : null,
|
|
1642
1655
|
metadata: body.metadata ?? {},
|
|
1643
|
-
expiresAt
|
|
1656
|
+
expiresAt
|
|
1644
1657
|
});
|
|
1645
1658
|
}
|
|
1646
1659
|
function seedBeamLink(id, body, merchantId) {
|
|
@@ -1660,7 +1673,8 @@ function seedBeamLink(id, body, merchantId) {
|
|
|
1660
1673
|
},
|
|
1661
1674
|
redirectUrl: body.redirectUrl ? String(body.redirectUrl) : void 0,
|
|
1662
1675
|
linkSettings: body.linkSettings,
|
|
1663
|
-
collectDeliveryAddress: body.collectDeliveryAddress === true
|
|
1676
|
+
collectDeliveryAddress: body.collectDeliveryAddress === true,
|
|
1677
|
+
...typeof body.expiresAt === "string" ? { expiresAt: body.expiresAt } : {}
|
|
1664
1678
|
});
|
|
1665
1679
|
}
|
|
1666
1680
|
function deriveBoltIntentStatus(storedStatus, expiresAt, now3 = /* @__PURE__ */ new Date()) {
|
|
@@ -1931,6 +1945,16 @@ async function handlePaymentsRequest(request, url) {
|
|
|
1931
1945
|
const linkId = beamProductsMatch[2];
|
|
1932
1946
|
if (!linkId && request.method === "POST") {
|
|
1933
1947
|
const body = await readJsonBody(request);
|
|
1948
|
+
if ("idempotencyKey" in body) {
|
|
1949
|
+
return payErr(
|
|
1950
|
+
"idempotencyKey is not supported for Beam payment links.",
|
|
1951
|
+
400,
|
|
1952
|
+
"idempotency_unsupported"
|
|
1953
|
+
);
|
|
1954
|
+
}
|
|
1955
|
+
if (typeof body.expiresAt === "string" && (!ISO_8601_WITH_ZONE.test(body.expiresAt) || Number.isNaN(Date.parse(body.expiresAt)))) {
|
|
1956
|
+
return payErr("expiresAt must be an ISO 8601 timestamp", 400);
|
|
1957
|
+
}
|
|
1934
1958
|
const id = nextPaymentLinkId();
|
|
1935
1959
|
const checkoutUrl = `https://beam.test/pay/${id}`;
|
|
1936
1960
|
const captured = {
|
|
@@ -1943,7 +1967,11 @@ async function handlePaymentsRequest(request, url) {
|
|
|
1943
1967
|
};
|
|
1944
1968
|
state.checkouts.push(captured);
|
|
1945
1969
|
seedBeamLink(id, body, merchantId);
|
|
1946
|
-
return json({
|
|
1970
|
+
return json({
|
|
1971
|
+
id,
|
|
1972
|
+
url: checkoutUrl,
|
|
1973
|
+
...typeof body.expiresAt === "string" ? { expiresAt: body.expiresAt } : {}
|
|
1974
|
+
});
|
|
1947
1975
|
}
|
|
1948
1976
|
if (linkId && request.method === "GET") {
|
|
1949
1977
|
const link = state.paymentLinks.get(linkId);
|
|
@@ -1969,6 +1997,34 @@ async function handlePaymentsRequest(request, url) {
|
|
|
1969
1997
|
}
|
|
1970
1998
|
if (subPath === "checkout" && request.method === "POST") {
|
|
1971
1999
|
const body = await readJsonBody(request);
|
|
2000
|
+
const nowSeconds = Math.floor(Date.now() / 1e3);
|
|
2001
|
+
const requestedExpiry = typeof body.expiresAt === "number" ? body.expiresAt : void 0;
|
|
2002
|
+
const callerKey = typeof body.idempotencyKey === "string" ? body.idempotencyKey : void 0;
|
|
2003
|
+
const scopedKey = callerKey ? JSON.stringify(["stripe", accountId, callerKey]) : void 0;
|
|
2004
|
+
const comparable = { ...body };
|
|
2005
|
+
delete comparable.deploymentId;
|
|
2006
|
+
delete comparable.idempotencyKey;
|
|
2007
|
+
const fingerprint = stableStringify(comparable);
|
|
2008
|
+
if (scopedKey) {
|
|
2009
|
+
const saved = state.idempotentCheckouts.get(scopedKey);
|
|
2010
|
+
if (saved) {
|
|
2011
|
+
if (saved.fingerprint !== fingerprint) {
|
|
2012
|
+
return payErr(
|
|
2013
|
+
"This idempotency key was already used with different checkout parameters. Use a new key for a different checkout.",
|
|
2014
|
+
409,
|
|
2015
|
+
"idempotency_conflict"
|
|
2016
|
+
);
|
|
2017
|
+
}
|
|
2018
|
+
return json(saved.response);
|
|
2019
|
+
}
|
|
2020
|
+
}
|
|
2021
|
+
if (requestedExpiry !== void 0 && (requestedExpiry < nowSeconds + STRIPE_MIN_EXPIRY_SECONDS || requestedExpiry > nowSeconds + STRIPE_MAX_EXPIRY_SECONDS)) {
|
|
2022
|
+
return payErr(
|
|
2023
|
+
"expiresAt must be between 30 minutes and 24 hours after the checkout is created (Unix seconds).",
|
|
2024
|
+
400,
|
|
2025
|
+
"expiry_out_of_range"
|
|
2026
|
+
);
|
|
2027
|
+
}
|
|
1972
2028
|
const id = nextCheckoutId();
|
|
1973
2029
|
const checkoutUrl = `https://checkout.stripe.test/c/pay/${id}`;
|
|
1974
2030
|
const captured = {
|
|
@@ -1981,8 +2037,11 @@ async function handlePaymentsRequest(request, url) {
|
|
|
1981
2037
|
createdAt: /* @__PURE__ */ new Date()
|
|
1982
2038
|
};
|
|
1983
2039
|
state.checkouts.push(captured);
|
|
1984
|
-
|
|
1985
|
-
|
|
2040
|
+
const expiresAt = requestedExpiry ?? nowSeconds + STRIPE_MAX_EXPIRY_SECONDS;
|
|
2041
|
+
seedStripeSession(id, body, expiresAt);
|
|
2042
|
+
const response = { id, url: checkoutUrl, expiresAt };
|
|
2043
|
+
if (scopedKey) state.idempotentCheckouts.set(scopedKey, { fingerprint, response });
|
|
2044
|
+
return json(response);
|
|
1986
2045
|
}
|
|
1987
2046
|
const sessionMatch = subPath.match(/^checkout-sessions\/([^/]+)$/);
|
|
1988
2047
|
if (sessionMatch && request.method === "GET") {
|
package/dist/setup.mjs
CHANGED
|
@@ -28,6 +28,7 @@ var state = globalSingleton("state", () => ({
|
|
|
28
28
|
db: null,
|
|
29
29
|
lastHarnessDataStoreManifest: null,
|
|
30
30
|
currentUser: null,
|
|
31
|
+
liffId: null,
|
|
31
32
|
sessions: /* @__PURE__ */ new Map(),
|
|
32
33
|
refreshSessions: /* @__PURE__ */ new Map(),
|
|
33
34
|
emails: [],
|
|
@@ -42,6 +43,7 @@ var state = globalSingleton("state", () => ({
|
|
|
42
43
|
checkouts: [],
|
|
43
44
|
checkoutCounter: 0,
|
|
44
45
|
sessionStatuses: /* @__PURE__ */ new Map(),
|
|
46
|
+
idempotentCheckouts: /* @__PURE__ */ new Map(),
|
|
45
47
|
paymentLinks: /* @__PURE__ */ new Map(),
|
|
46
48
|
paymentFailures: [],
|
|
47
49
|
products: [],
|
|
@@ -1511,6 +1513,8 @@ var requestScopeStorage = globalSingleton(
|
|
|
1511
1513
|
import crypto4 from "crypto";
|
|
1512
1514
|
|
|
1513
1515
|
// src/simulator/payments.ts
|
|
1516
|
+
var STRIPE_MIN_EXPIRY_SECONDS = 30 * 60;
|
|
1517
|
+
var STRIPE_MAX_EXPIRY_SECONDS = 24 * 60 * 60;
|
|
1514
1518
|
var BEAM_BOLT_PAYMENT_METHODS = [
|
|
1515
1519
|
"CARD",
|
|
1516
1520
|
"CARD_INSTALLMENTS",
|
|
@@ -1580,6 +1584,15 @@ function isBoltPaymentMethod(value) {
|
|
|
1580
1584
|
function isBoltIntentStatus(value) {
|
|
1581
1585
|
return BEAM_BOLT_INTENT_STATUSES.includes(value);
|
|
1582
1586
|
}
|
|
1587
|
+
function stableStringify(value) {
|
|
1588
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
|
|
1589
|
+
if (value && typeof value === "object") {
|
|
1590
|
+
const entries = Object.entries(value).filter(([, v]) => v !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
1591
|
+
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`).join(",")}}`;
|
|
1592
|
+
}
|
|
1593
|
+
return JSON.stringify(value);
|
|
1594
|
+
}
|
|
1595
|
+
var ISO_8601_WITH_ZONE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})$/;
|
|
1583
1596
|
function nextCheckoutId() {
|
|
1584
1597
|
state.checkoutCounter += 1;
|
|
1585
1598
|
return `cs_test_${state.checkoutCounter}`;
|
|
@@ -1596,7 +1609,7 @@ function nextBoltIntentId() {
|
|
|
1596
1609
|
state.boltIntentCounter += 1;
|
|
1597
1610
|
return `bolti_${state.boltIntentCounter}`;
|
|
1598
1611
|
}
|
|
1599
|
-
function seedStripeSession(id, body) {
|
|
1612
|
+
function seedStripeSession(id, body, expiresAt) {
|
|
1600
1613
|
const lineItems = body.lineItems ?? [];
|
|
1601
1614
|
let amountTotal = null;
|
|
1602
1615
|
let currency = null;
|
|
@@ -1616,7 +1629,7 @@ function seedStripeSession(id, body) {
|
|
|
1616
1629
|
currency,
|
|
1617
1630
|
customerEmail: body.customerEmail ? String(body.customerEmail) : null,
|
|
1618
1631
|
metadata: body.metadata ?? {},
|
|
1619
|
-
expiresAt
|
|
1632
|
+
expiresAt
|
|
1620
1633
|
});
|
|
1621
1634
|
}
|
|
1622
1635
|
function seedBeamLink(id, body, merchantId) {
|
|
@@ -1636,7 +1649,8 @@ function seedBeamLink(id, body, merchantId) {
|
|
|
1636
1649
|
},
|
|
1637
1650
|
redirectUrl: body.redirectUrl ? String(body.redirectUrl) : void 0,
|
|
1638
1651
|
linkSettings: body.linkSettings,
|
|
1639
|
-
collectDeliveryAddress: body.collectDeliveryAddress === true
|
|
1652
|
+
collectDeliveryAddress: body.collectDeliveryAddress === true,
|
|
1653
|
+
...typeof body.expiresAt === "string" ? { expiresAt: body.expiresAt } : {}
|
|
1640
1654
|
});
|
|
1641
1655
|
}
|
|
1642
1656
|
function deriveBoltIntentStatus(storedStatus, expiresAt, now3 = /* @__PURE__ */ new Date()) {
|
|
@@ -1907,6 +1921,16 @@ async function handlePaymentsRequest(request, url) {
|
|
|
1907
1921
|
const linkId = beamProductsMatch[2];
|
|
1908
1922
|
if (!linkId && request.method === "POST") {
|
|
1909
1923
|
const body = await readJsonBody(request);
|
|
1924
|
+
if ("idempotencyKey" in body) {
|
|
1925
|
+
return payErr(
|
|
1926
|
+
"idempotencyKey is not supported for Beam payment links.",
|
|
1927
|
+
400,
|
|
1928
|
+
"idempotency_unsupported"
|
|
1929
|
+
);
|
|
1930
|
+
}
|
|
1931
|
+
if (typeof body.expiresAt === "string" && (!ISO_8601_WITH_ZONE.test(body.expiresAt) || Number.isNaN(Date.parse(body.expiresAt)))) {
|
|
1932
|
+
return payErr("expiresAt must be an ISO 8601 timestamp", 400);
|
|
1933
|
+
}
|
|
1910
1934
|
const id = nextPaymentLinkId();
|
|
1911
1935
|
const checkoutUrl = `https://beam.test/pay/${id}`;
|
|
1912
1936
|
const captured = {
|
|
@@ -1919,7 +1943,11 @@ async function handlePaymentsRequest(request, url) {
|
|
|
1919
1943
|
};
|
|
1920
1944
|
state.checkouts.push(captured);
|
|
1921
1945
|
seedBeamLink(id, body, merchantId);
|
|
1922
|
-
return json({
|
|
1946
|
+
return json({
|
|
1947
|
+
id,
|
|
1948
|
+
url: checkoutUrl,
|
|
1949
|
+
...typeof body.expiresAt === "string" ? { expiresAt: body.expiresAt } : {}
|
|
1950
|
+
});
|
|
1923
1951
|
}
|
|
1924
1952
|
if (linkId && request.method === "GET") {
|
|
1925
1953
|
const link = state.paymentLinks.get(linkId);
|
|
@@ -1945,6 +1973,34 @@ async function handlePaymentsRequest(request, url) {
|
|
|
1945
1973
|
}
|
|
1946
1974
|
if (subPath === "checkout" && request.method === "POST") {
|
|
1947
1975
|
const body = await readJsonBody(request);
|
|
1976
|
+
const nowSeconds = Math.floor(Date.now() / 1e3);
|
|
1977
|
+
const requestedExpiry = typeof body.expiresAt === "number" ? body.expiresAt : void 0;
|
|
1978
|
+
const callerKey = typeof body.idempotencyKey === "string" ? body.idempotencyKey : void 0;
|
|
1979
|
+
const scopedKey = callerKey ? JSON.stringify(["stripe", accountId, callerKey]) : void 0;
|
|
1980
|
+
const comparable = { ...body };
|
|
1981
|
+
delete comparable.deploymentId;
|
|
1982
|
+
delete comparable.idempotencyKey;
|
|
1983
|
+
const fingerprint = stableStringify(comparable);
|
|
1984
|
+
if (scopedKey) {
|
|
1985
|
+
const saved = state.idempotentCheckouts.get(scopedKey);
|
|
1986
|
+
if (saved) {
|
|
1987
|
+
if (saved.fingerprint !== fingerprint) {
|
|
1988
|
+
return payErr(
|
|
1989
|
+
"This idempotency key was already used with different checkout parameters. Use a new key for a different checkout.",
|
|
1990
|
+
409,
|
|
1991
|
+
"idempotency_conflict"
|
|
1992
|
+
);
|
|
1993
|
+
}
|
|
1994
|
+
return json(saved.response);
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
if (requestedExpiry !== void 0 && (requestedExpiry < nowSeconds + STRIPE_MIN_EXPIRY_SECONDS || requestedExpiry > nowSeconds + STRIPE_MAX_EXPIRY_SECONDS)) {
|
|
1998
|
+
return payErr(
|
|
1999
|
+
"expiresAt must be between 30 minutes and 24 hours after the checkout is created (Unix seconds).",
|
|
2000
|
+
400,
|
|
2001
|
+
"expiry_out_of_range"
|
|
2002
|
+
);
|
|
2003
|
+
}
|
|
1948
2004
|
const id = nextCheckoutId();
|
|
1949
2005
|
const checkoutUrl = `https://checkout.stripe.test/c/pay/${id}`;
|
|
1950
2006
|
const captured = {
|
|
@@ -1957,8 +2013,11 @@ async function handlePaymentsRequest(request, url) {
|
|
|
1957
2013
|
createdAt: /* @__PURE__ */ new Date()
|
|
1958
2014
|
};
|
|
1959
2015
|
state.checkouts.push(captured);
|
|
1960
|
-
|
|
1961
|
-
|
|
2016
|
+
const expiresAt = requestedExpiry ?? nowSeconds + STRIPE_MAX_EXPIRY_SECONDS;
|
|
2017
|
+
seedStripeSession(id, body, expiresAt);
|
|
2018
|
+
const response = { id, url: checkoutUrl, expiresAt };
|
|
2019
|
+
if (scopedKey) state.idempotentCheckouts.set(scopedKey, { fingerprint, response });
|
|
2020
|
+
return json(response);
|
|
1962
2021
|
}
|
|
1963
2022
|
const sessionMatch = subPath.match(/^checkout-sessions\/([^/]+)$/);
|
|
1964
2023
|
if (sessionMatch && request.method === "GET") {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stardeck-customer-apps/testing",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.0",
|
|
4
4
|
"description": "Vitest test harness for Stardeck customer apps — in-process Postgres (PGlite) plus a control-plane simulator so the real Stardeck SDKs run unmodified in tests",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"module": "dist/index.mjs",
|