@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 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: Math.floor(Date.now() / 1e3) + 3600
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({ id, url: checkoutUrl });
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
- seedStripeSession(id, body);
2611
- return json({ id, url: checkoutUrl });
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: Math.floor(Date.now() / 1e3) + 3600
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({ id, url: checkoutUrl });
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
- seedStripeSession(id, body);
2572
- return json({ id, url: checkoutUrl });
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: Math.floor(Date.now() / 1e3) + 3600
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({ id, url: checkoutUrl });
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
- seedStripeSession(id, body);
1985
- return json({ id, url: checkoutUrl });
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: Math.floor(Date.now() / 1e3) + 3600
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({ id, url: checkoutUrl });
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
- seedStripeSession(id, body);
1961
- return json({ id, url: checkoutUrl });
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.22.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",