@stardeck-customer-apps/testing 0.26.0 → 0.28.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/dist/index.js CHANGED
@@ -357,6 +357,11 @@ var state = globalSingleton("state", () => ({
357
357
  cardTokens: /* @__PURE__ */ new Map(),
358
358
  cardTokenCounter: 0,
359
359
  cardTokenizationEnabled: true,
360
+ stripePaymentIntents: /* @__PURE__ */ new Map(),
361
+ stripePaymentIntentCounter: 0,
362
+ stripeIdempotentIntents: /* @__PURE__ */ new Map(),
363
+ stripeAllowedMethods: ["card", "promptpay"],
364
+ stripeChargesEnabled: true,
360
365
  uploads: [],
361
366
  uploadCounter: 0,
362
367
  storageFiles: /* @__PURE__ */ new Map(),
@@ -376,6 +381,14 @@ var state = globalSingleton("state", () => ({
376
381
  starlensCalls: [],
377
382
  starlensReplies: [],
378
383
  starlensDefaultReply: null,
384
+ controls: {
385
+ seed: {},
386
+ values: {},
387
+ unavailable: /* @__PURE__ */ new Set(),
388
+ lastUsage: null,
389
+ writes: [],
390
+ refusal: null
391
+ },
379
392
  allowNetwork: false
380
393
  }));
381
394
  function requireDb() {
@@ -823,8 +836,8 @@ async function handleNeonSql(db, request) {
823
836
  rowAsArray: arrayMode
824
837
  });
825
838
  } catch (error) {
826
- const err = error;
827
- return json({ message: err.message, code: err.code }, 400);
839
+ const err2 = error;
840
+ return json({ message: err2.message, code: err2.code }, 400);
828
841
  }
829
842
  }
830
843
 
@@ -2117,6 +2130,348 @@ function decodeNextRedirect(error) {
2117
2130
  return new Response(null, { status, headers: { location } });
2118
2131
  }
2119
2132
 
2133
+ // src/simulator/stripe-payment-intents.ts
2134
+ var SIM_STRIPE_PUBLISHABLE_KEY = "pk_test_sim_stripe";
2135
+ var SIM_STRIPE_DEFAULT_ACCOUNT = "acct_sim_default";
2136
+ var METHODS = ["card", "promptpay"];
2137
+ var RESERVED_METADATA_KEYS = ["projectId", "stardeckReferenceId", "stardeckStagingDeploymentId"];
2138
+ var PROMPTPAY_QR_TTL_SECONDS = 15 * 60;
2139
+ var PATH_RE = /^\/api\/store\/(?!beam\/)([^/]+)\/(payment-intents(?:\/([^/]+)(?:\/(cancel))?)?|elements-config|readiness)$/;
2140
+ function err(error, status, code) {
2141
+ return json({ error, code }, status);
2142
+ }
2143
+ function decode(segment) {
2144
+ try {
2145
+ return decodeURIComponent(segment);
2146
+ } catch {
2147
+ return segment;
2148
+ }
2149
+ }
2150
+ function isPlayground(account) {
2151
+ return account === "playground";
2152
+ }
2153
+ function resolveAccount(account) {
2154
+ if (isPlayground(account)) return null;
2155
+ if (account === "default") {
2156
+ return process.env.STRIPE_CONNECT_ACCOUNT_ID || SIM_STRIPE_DEFAULT_ACCOUNT;
2157
+ }
2158
+ return account;
2159
+ }
2160
+ var UUID_RE2 = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
2161
+ var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
2162
+ var APP_METADATA_MAX_KEYS = 50 - RESERVED_METADATA_KEYS.length;
2163
+ function isStringRecord(value) {
2164
+ return !!value && typeof value === "object" && !Array.isArray(value) && Object.values(value).every((v) => typeof v === "string");
2165
+ }
2166
+ function isValidMetadata(value) {
2167
+ if (!isStringRecord(value)) return false;
2168
+ const entries = Object.entries(value);
2169
+ return entries.length <= APP_METADATA_MAX_KEYS && entries.every(([k, v]) => k.length >= 1 && k.length <= 40 && v.length <= 500);
2170
+ }
2171
+ function isOptional(value, test) {
2172
+ return value === void 0 || test(value);
2173
+ }
2174
+ var isEmail = (v) => typeof v === "string" && EMAIL_RE.test(v);
2175
+ function validateCreate(body) {
2176
+ const { amount, currency, method, referenceId, idempotencyKey } = body;
2177
+ const checks = [
2178
+ [
2179
+ typeof amount === "number" && Number.isInteger(amount) && amount > 0,
2180
+ "amount must be a positive integer in minor units"
2181
+ ],
2182
+ [typeof currency === "string" && currency.length === 3, "currency must be a 3-letter ISO code"],
2183
+ [
2184
+ typeof method === "string" && METHODS.includes(method),
2185
+ `method must be one of: ${METHODS.join(", ")}`
2186
+ ],
2187
+ [
2188
+ typeof referenceId === "string" && referenceId.length >= 1 && referenceId.length <= 200,
2189
+ "referenceId must be 1 to 200 characters"
2190
+ ],
2191
+ [
2192
+ typeof idempotencyKey === "string" && idempotencyKey.length >= 1 && idempotencyKey.length <= 200,
2193
+ "idempotencyKey is required (1 to 200 characters)"
2194
+ ],
2195
+ [
2196
+ isOptional(body.description, (v) => typeof v === "string" && v.length <= 1e3),
2197
+ "description must be at most 1000 characters"
2198
+ ],
2199
+ [isOptional(body.receiptEmail, isEmail), "receiptEmail must be an email address"],
2200
+ [isOptional(body.billingEmail, isEmail), "billingEmail must be an email address"],
2201
+ [
2202
+ isOptional(body.returnUrl, (v) => typeof v === "string" && URL.canParse(v)),
2203
+ "returnUrl must be an absolute URL"
2204
+ ],
2205
+ [
2206
+ isOptional(body.metadata, isValidMetadata),
2207
+ `metadata must map keys (1-40 chars) to strings (max 500), at most ${APP_METADATA_MAX_KEYS} keys`
2208
+ ],
2209
+ [
2210
+ typeof body.deploymentId === "string" && UUID_RE2.test(body.deploymentId),
2211
+ "Invalid deployment ID"
2212
+ ]
2213
+ ];
2214
+ const failed = checks.find(([ok]) => !ok);
2215
+ return failed ? failed[1] : null;
2216
+ }
2217
+ function appMetadata(raw) {
2218
+ if (!isStringRecord(raw)) return {};
2219
+ const out = {};
2220
+ for (const [key, value] of Object.entries(raw)) {
2221
+ if (!RESERVED_METADATA_KEYS.includes(key)) out[key] = value;
2222
+ }
2223
+ return out;
2224
+ }
2225
+ function stableStringify(value) {
2226
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
2227
+ if (value && typeof value === "object") {
2228
+ const entries = Object.entries(value).filter(([, v]) => v !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
2229
+ return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`).join(",")}}`;
2230
+ }
2231
+ return JSON.stringify(value);
2232
+ }
2233
+ function toStripePaymentIntentSummary(intent) {
2234
+ const open = intent.status !== "succeeded" && intent.status !== "canceled";
2235
+ let actionRequired = "NONE";
2236
+ if (open && intent.method === "card") actionRequired = "CLIENT_CONFIRM";
2237
+ if (open && intent.method === "promptpay" && intent.status === "requires_action") {
2238
+ actionRequired = "ENCODED_IMAGE";
2239
+ }
2240
+ return {
2241
+ id: intent.id,
2242
+ status: intent.status,
2243
+ amount: intent.amount,
2244
+ currency: intent.currency,
2245
+ method: intent.method,
2246
+ referenceId: intent.referenceId,
2247
+ metadata: { ...intent.appMetadata },
2248
+ actionRequired,
2249
+ clientSecret: intent.method === "card" ? intent.clientSecret : null,
2250
+ qr: actionRequired === "ENCODED_IMAGE" ? { ...intent.qr } : null,
2251
+ latestChargeId: intent.latestChargeId,
2252
+ amountRefunded: intent.amountRefunded,
2253
+ lastPaymentError: intent.lastPaymentError ? { ...intent.lastPaymentError } : null,
2254
+ created: intent.created,
2255
+ livemode: false,
2256
+ playground: intent.playground
2257
+ };
2258
+ }
2259
+ function promptPayQr(id, created) {
2260
+ return {
2261
+ imageUrlPng: `https://qr.stripe.test/${id}.png`,
2262
+ imageUrlSvg: `https://qr.stripe.test/${id}.svg`,
2263
+ data: `00020101021230SIM${id}`,
2264
+ hostedInstructionsUrl: `https://payments.stripe.test/promptpay/${id}`,
2265
+ expiresAt: created + PROMPTPAY_QR_TTL_SECONDS
2266
+ };
2267
+ }
2268
+ function createIntent(body, account, projectId) {
2269
+ const problem = validateCreate(body);
2270
+ if (problem) return err(problem, 400, "invalid_request");
2271
+ const method = body.method;
2272
+ const currency = body.currency.toLowerCase();
2273
+ if (!state.stripeAllowedMethods.includes(method)) {
2274
+ return err(
2275
+ `The ${method} payment method is turned off for this account`,
2276
+ 422,
2277
+ "method_not_allowed"
2278
+ );
2279
+ }
2280
+ if (method === "promptpay" && currency !== "thb") {
2281
+ return err("PromptPay only takes THB", 422, "currency_not_supported");
2282
+ }
2283
+ if (!state.stripeChargesEnabled) {
2284
+ return err("This Stripe account can't take payments yet", 503, "account_not_ready");
2285
+ }
2286
+ const scopedKey = JSON.stringify(["pi", projectId, account, body.idempotencyKey]);
2287
+ const comparable = { ...body };
2288
+ delete comparable.deploymentId;
2289
+ delete comparable.idempotencyKey;
2290
+ const fingerprint = stableStringify(comparable);
2291
+ const saved = state.stripeIdempotentIntents.get(scopedKey);
2292
+ if (saved) {
2293
+ if (saved.fingerprint !== fingerprint) {
2294
+ return err(
2295
+ "This idempotency key was already used with different parameters. Use a new key for a different payment.",
2296
+ 409,
2297
+ "idempotency_conflict"
2298
+ );
2299
+ }
2300
+ const existing = state.stripePaymentIntents.get(saved.id);
2301
+ return json({ paymentIntent: toStripePaymentIntentSummary(existing) }, 201);
2302
+ }
2303
+ state.stripePaymentIntentCounter += 1;
2304
+ const id = `pi_sim_${state.stripePaymentIntentCounter}`;
2305
+ const created = Math.floor(Date.now() / 1e3);
2306
+ const referenceId = body.referenceId;
2307
+ const metadata = appMetadata(body.metadata);
2308
+ const intent = {
2309
+ id,
2310
+ projectId,
2311
+ account,
2312
+ connectedAccountId: resolveAccount(account),
2313
+ playground: isPlayground(account),
2314
+ status: method === "card" ? "requires_payment_method" : "requires_action",
2315
+ amount: body.amount,
2316
+ currency,
2317
+ method,
2318
+ referenceId,
2319
+ appMetadata: metadata,
2320
+ metadata: { ...metadata, projectId, stardeckReferenceId: referenceId },
2321
+ description: typeof body.description === "string" ? body.description : null,
2322
+ receiptEmail: typeof body.receiptEmail === "string" ? body.receiptEmail : null,
2323
+ returnUrl: typeof body.returnUrl === "string" ? body.returnUrl : null,
2324
+ clientSecret: `${id}_secret_sim`,
2325
+ qr: method === "promptpay" ? promptPayQr(id, created) : null,
2326
+ latestChargeId: null,
2327
+ amountRefunded: 0,
2328
+ lastPaymentError: null,
2329
+ created
2330
+ };
2331
+ state.stripePaymentIntents.set(id, intent);
2332
+ state.stripeIdempotentIntents.set(scopedKey, { fingerprint, id });
2333
+ console.log(
2334
+ `[stardeck-testing] stripe payment intent ${id} created method=${method} amount=${intent.amount} ${currency}`
2335
+ );
2336
+ return json({ paymentIntent: toStripePaymentIntentSummary(intent) }, 201);
2337
+ }
2338
+ function ownedIntent(id, projectId) {
2339
+ const intent = state.stripePaymentIntents.get(id);
2340
+ return intent && intent.projectId === projectId ? intent : void 0;
2341
+ }
2342
+ function cancelIntent(intent) {
2343
+ if (intent.status === "succeeded") {
2344
+ return err("This payment already succeeded and can't be canceled", 409, "already_succeeded");
2345
+ }
2346
+ intent.status = "canceled";
2347
+ return json({ paymentIntent: toStripePaymentIntentSummary(intent) });
2348
+ }
2349
+ function handleIntents(request, url, account, intentId, action, projectId) {
2350
+ if (!intentId && request.method === "POST") {
2351
+ return readJsonBody(request).then((body) => createIntent(body, account, projectId));
2352
+ }
2353
+ if (!intentId && request.method === "GET") {
2354
+ const referenceId = url.searchParams.get("referenceId");
2355
+ if (!referenceId) return err("referenceId is required", 400, "invalid_request");
2356
+ const paymentIntents = [...state.stripePaymentIntents.values()].filter((i) => i.projectId === projectId && i.referenceId === referenceId).map(toStripePaymentIntentSummary);
2357
+ return json({ paymentIntents });
2358
+ }
2359
+ if (!intentId) return null;
2360
+ const intent = ownedIntent(decode(intentId), projectId);
2361
+ if (!action && request.method === "GET") {
2362
+ if (!intent) return err("Payment intent not found", 404, "not_found");
2363
+ return json({ paymentIntent: toStripePaymentIntentSummary(intent) });
2364
+ }
2365
+ if (action === "cancel" && request.method === "POST") {
2366
+ return readJsonBody(request).then((body) => {
2367
+ if (typeof body.deploymentId !== "string" || !UUID_RE2.test(body.deploymentId)) {
2368
+ return err("deploymentId is required", 400, "invalid_request");
2369
+ }
2370
+ if (!intent) return err("Payment intent not found", 404, "not_found");
2371
+ return cancelIntent(intent);
2372
+ });
2373
+ }
2374
+ return null;
2375
+ }
2376
+ async function handleStripePaymentIntentRequest(request, url, projectId) {
2377
+ const match = url.pathname.match(PATH_RE);
2378
+ if (!match) return null;
2379
+ if (!projectId) return err("Missing authentication", 401, "unauthorized");
2380
+ const account = decode(match[1]);
2381
+ const resource = match[2];
2382
+ if (request.method === "GET" && !url.searchParams.get("deploymentId")) {
2383
+ return err("deploymentId is required", 400, "invalid_request");
2384
+ }
2385
+ if (resource === "elements-config" && request.method === "GET") {
2386
+ return json({
2387
+ publishableKey: SIM_STRIPE_PUBLISHABLE_KEY,
2388
+ stripeAccount: resolveAccount(account),
2389
+ playground: isPlayground(account)
2390
+ });
2391
+ }
2392
+ if (resource === "readiness" && request.method === "GET") {
2393
+ const methods = [...state.stripeAllowedMethods];
2394
+ return json({
2395
+ provider: "stripe",
2396
+ ready: state.stripeChargesEnabled && methods.length > 0,
2397
+ chargesEnabled: state.stripeChargesEnabled,
2398
+ methods,
2399
+ playground: isPlayground(account)
2400
+ });
2401
+ }
2402
+ if (resource.startsWith("payment-intents")) {
2403
+ return handleIntents(request, url, account, match[3], match[4], projectId);
2404
+ }
2405
+ return null;
2406
+ }
2407
+ function requireIntent(id) {
2408
+ const intent = state.stripePaymentIntents.get(id);
2409
+ if (!intent) throw new Error(`[stardeck-testing] Unknown payment intent id: ${id}`);
2410
+ return intent;
2411
+ }
2412
+ function requireOpenIntent(id, verb) {
2413
+ const intent = requireIntent(id);
2414
+ if (intent.status === "succeeded" || intent.status === "canceled") {
2415
+ throw new Error(
2416
+ `[stardeck-testing] Can't ${verb} payment intent ${id}: it is ${intent.status}`
2417
+ );
2418
+ }
2419
+ return intent;
2420
+ }
2421
+ function succeedStripePaymentIntent(id) {
2422
+ const intent = requireOpenIntent(id, "mark paid");
2423
+ intent.status = "succeeded";
2424
+ intent.latestChargeId = `ch_sim_${id}`;
2425
+ intent.lastPaymentError = null;
2426
+ }
2427
+ function failStripePaymentIntent(id, code) {
2428
+ const intent = requireOpenIntent(id, "fail");
2429
+ intent.status = "requires_payment_method";
2430
+ intent.latestChargeId = `ch_sim_${id}_failed`;
2431
+ intent.lastPaymentError = {
2432
+ code,
2433
+ declineCode: code === "card_declined" ? "generic_decline" : null,
2434
+ message: `Simulated payment failure: ${code}`
2435
+ };
2436
+ }
2437
+ function cancelStripePaymentIntentById(id) {
2438
+ const intent = requireOpenIntent(id, "cancel");
2439
+ intent.status = "canceled";
2440
+ }
2441
+ function stripePaymentIntentEvent(id) {
2442
+ const intent = requireIntent(id);
2443
+ let type;
2444
+ if (intent.status === "succeeded") type = "payment_intent.succeeded";
2445
+ else if (intent.status === "canceled") type = "payment_intent.canceled";
2446
+ else if (intent.lastPaymentError) type = "payment_intent.payment_failed";
2447
+ else {
2448
+ throw new Error(
2449
+ `[stardeck-testing] Payment intent ${id} is ${intent.status}; call markPaid, failPayment or cancelPayment first`
2450
+ );
2451
+ }
2452
+ const error = intent.lastPaymentError;
2453
+ return {
2454
+ type,
2455
+ accountId: intent.connectedAccountId ?? "acct_stardeck_playground",
2456
+ data: {
2457
+ id: intent.id,
2458
+ object: "payment_intent",
2459
+ amount: intent.amount,
2460
+ amount_received: intent.status === "succeeded" ? intent.amount : 0,
2461
+ currency: intent.currency,
2462
+ status: intent.status,
2463
+ metadata: { ...intent.metadata },
2464
+ payment_method_types: [intent.method],
2465
+ latest_charge: intent.latestChargeId,
2466
+ last_payment_error: error ? { code: error.code, decline_code: error.declineCode, message: error.message } : null,
2467
+ description: intent.description,
2468
+ receipt_email: intent.receiptEmail,
2469
+ created: intent.created,
2470
+ livemode: false
2471
+ }
2472
+ };
2473
+ }
2474
+
2120
2475
  // src/simulator/webhook-signing.ts
2121
2476
  var import_node_crypto5 = __toESM(require("crypto"));
2122
2477
  function signEventDelivery(secret, context, rawBody) {
@@ -2150,7 +2505,7 @@ var BEAM_BOLT_PAYMENT_METHODS = [
2150
2505
  "SPAY_LATER"
2151
2506
  ];
2152
2507
  var BEAM_BOLT_INTENT_STATUSES = ["PENDING", "PAID", "FAILED", "CANCELED", "EXPIRED"];
2153
- var UUID_RE2 = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
2508
+ var UUID_RE3 = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
2154
2509
  function payErr(error, status = 400, code, details) {
2155
2510
  const body = { error };
2156
2511
  if (code) body.code = code;
@@ -2176,7 +2531,23 @@ function paymentOperationForRequest(request, pathname) {
2176
2531
  if (request.method === "GET" && /^\/api\/store\/(?!beam\/)[^/]+\/checkout-sessions\/[^/]+$/.test(pathname)) {
2177
2532
  return "stripe.getCheckoutSessionStatus";
2178
2533
  }
2179
- return null;
2534
+ return stripeInlineOperation(request.method, pathname);
2535
+ }
2536
+ var STRIPE_INLINE_OPERATIONS = [
2537
+ ["POST", /^\/api\/store\/(?!beam\/)[^/]+\/payment-intents$/, "stripe.createPaymentIntent"],
2538
+ ["GET", /^\/api\/store\/(?!beam\/)[^/]+\/payment-intents$/, "stripe.findPaymentIntents"],
2539
+ ["GET", /^\/api\/store\/(?!beam\/)[^/]+\/payment-intents\/[^/]+$/, "stripe.getPaymentIntent"],
2540
+ [
2541
+ "POST",
2542
+ /^\/api\/store\/(?!beam\/)[^/]+\/payment-intents\/[^/]+\/cancel$/,
2543
+ "stripe.cancelPaymentIntent"
2544
+ ],
2545
+ ["GET", /^\/api\/store\/(?!beam\/)[^/]+\/elements-config$/, "stripe.getElementsConfig"],
2546
+ ["GET", /^\/api\/store\/(?!beam\/)[^/]+\/readiness$/, "stripe.getReadiness"]
2547
+ ];
2548
+ function stripeInlineOperation(method, pathname) {
2549
+ const hit = STRIPE_INLINE_OPERATIONS.find(([m, re]) => m === method && re.test(pathname));
2550
+ return hit ? hit[2] : null;
2180
2551
  }
2181
2552
  function consumePaymentFailure(request, pathname) {
2182
2553
  const operation = paymentOperationForRequest(request, pathname);
@@ -2206,11 +2577,11 @@ function isBoltPaymentMethod(value) {
2206
2577
  function isBoltIntentStatus(value) {
2207
2578
  return BEAM_BOLT_INTENT_STATUSES.includes(value);
2208
2579
  }
2209
- function stableStringify(value) {
2210
- if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
2580
+ function stableStringify2(value) {
2581
+ if (Array.isArray(value)) return `[${value.map(stableStringify2).join(",")}]`;
2211
2582
  if (value && typeof value === "object") {
2212
2583
  const entries = Object.entries(value).filter(([, v]) => v !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
2213
- return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`).join(",")}}`;
2584
+ return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stableStringify2(v)}`).join(",")}}`;
2214
2585
  }
2215
2586
  return JSON.stringify(value);
2216
2587
  }
@@ -2351,10 +2722,16 @@ async function deliverEvent(envelope, handler, options) {
2351
2722
  body: envelope
2352
2723
  });
2353
2724
  }
2354
- async function handlePaymentsRequest(request, url) {
2725
+ async function handlePaymentsRequest(request, url, signedProjectId = null) {
2355
2726
  const pathname = url.pathname;
2356
2727
  const injectedFailure = consumePaymentFailure(request, pathname);
2357
2728
  if (injectedFailure) return injectedFailure;
2729
+ const inlineStripeResponse = await handleStripePaymentIntentRequest(
2730
+ request,
2731
+ url,
2732
+ signedProjectId
2733
+ );
2734
+ if (inlineStripeResponse) return inlineStripeResponse;
2358
2735
  const directChargeResponse = await handleDirectChargeRequest(request, pathname);
2359
2736
  if (directChargeResponse) return directChargeResponse;
2360
2737
  if (/\/billing-portal$/.test(pathname)) {
@@ -2453,7 +2830,7 @@ async function handlePaymentsRequest(request, url) {
2453
2830
  code: "too_small"
2454
2831
  });
2455
2832
  }
2456
- if (typeof body.deploymentId !== "string" || !UUID_RE2.test(body.deploymentId)) {
2833
+ if (typeof body.deploymentId !== "string" || !UUID_RE3.test(body.deploymentId)) {
2457
2834
  issues.push({
2458
2835
  path: ["deploymentId"],
2459
2836
  message: "Invalid uuid",
@@ -2637,7 +3014,7 @@ async function handlePaymentsRequest(request, url) {
2637
3014
  const comparable = { ...body };
2638
3015
  delete comparable.deploymentId;
2639
3016
  delete comparable.idempotencyKey;
2640
- const fingerprint = stableStringify(comparable);
3017
+ const fingerprint = stableStringify2(comparable);
2641
3018
  if (scopedKey) {
2642
3019
  const saved = state.idempotentCheckouts.get(scopedKey);
2643
3020
  if (saved) {
@@ -2712,6 +3089,10 @@ function createPayments() {
2712
3089
  state.paymentFailures.push({ operation, failure: failure2 });
2713
3090
  },
2714
3091
  markPaid(id) {
3092
+ if (state.stripePaymentIntents.has(id)) {
3093
+ succeedStripePaymentIntent(id);
3094
+ return;
3095
+ }
2715
3096
  const session = state.sessionStatuses.get(id);
2716
3097
  if (session) {
2717
3098
  session.status = "complete";
@@ -2739,7 +3120,26 @@ function createPayments() {
2739
3120
  }
2740
3121
  link.status = status;
2741
3122
  },
2742
- async deliverStripeEvent(handler, event, options) {
3123
+ failPayment(paymentIntentId, code = "card_declined") {
3124
+ failStripePaymentIntent(paymentIntentId, code);
3125
+ },
3126
+ cancelPayment(paymentIntentId) {
3127
+ cancelStripePaymentIntentById(paymentIntentId);
3128
+ },
3129
+ get stripePaymentIntents() {
3130
+ return [...state.stripePaymentIntents.values()];
3131
+ },
3132
+ getStripePaymentIntent(paymentIntentId) {
3133
+ return state.stripePaymentIntents.get(paymentIntentId);
3134
+ },
3135
+ setStripePaymentMethods(methods) {
3136
+ state.stripeAllowedMethods = [...methods];
3137
+ },
3138
+ setStripeChargesEnabled(enabled) {
3139
+ state.stripeChargesEnabled = enabled;
3140
+ },
3141
+ async deliverStripeEvent(handler, input, options) {
3142
+ const event = "paymentIntentId" in input ? stripePaymentIntentEvent(input.paymentIntentId) : input;
2743
3143
  const envelope = {
2744
3144
  id: `evt_test_${import_node_crypto6.default.randomUUID()}`,
2745
3145
  kind: "stripe_webhook",
@@ -2855,6 +3255,11 @@ function createPayments() {
2855
3255
  state.cardTokens.clear();
2856
3256
  state.cardTokenCounter = 0;
2857
3257
  state.cardTokenizationEnabled = true;
3258
+ state.stripePaymentIntents.clear();
3259
+ state.stripePaymentIntentCounter = 0;
3260
+ state.stripeIdempotentIntents.clear();
3261
+ state.stripeAllowedMethods = ["card", "promptpay"];
3262
+ state.stripeChargesEnabled = true;
2858
3263
  },
2859
3264
  get count() {
2860
3265
  return state.checkouts.length;
@@ -3627,6 +4032,141 @@ function createEdge() {
3627
4032
  };
3628
4033
  }
3629
4034
 
4035
+ // src/simulator/controls.ts
4036
+ var CONTROLS_STATE_SYMBOL = /* @__PURE__ */ Symbol.for("stardeck.controls.state");
4037
+ var USER_TOKEN_HEADER = "x-stardeck-user-token";
4038
+ var DEFAULTS = {
4039
+ "flag.paymentsSetupStep": false,
4040
+ "plan.locations": { included: 3, max: null, extraMonthly: { amount: 990, currency: "THB" } },
4041
+ "plan.multiEntity": true,
4042
+ "payments.provider": "beam",
4043
+ "payments.methods": { card: true, promptPay: true }
4044
+ };
4045
+ var REFUSAL_STATUS = {
4046
+ NOT_WRITABLE: 400,
4047
+ INVALID_VALUE: 400,
4048
+ UNAUTHENTICATED: 401,
4049
+ FORBIDDEN: 403,
4050
+ WRITE_NOT_ALLOWED_HERE: 403,
4051
+ PROVIDER_NOT_READY: 409,
4052
+ UNAVAILABLE: 503
4053
+ };
4054
+ var controlsModule = null;
4055
+ function loadControls() {
4056
+ controlsModule ??= import("@stardeck-customer-apps/controls");
4057
+ return controlsModule;
4058
+ }
4059
+ function resetControlsClientCache() {
4060
+ const clientState = globalThis[CONTROLS_STATE_SYMBOL];
4061
+ clientState?.reset?.();
4062
+ }
4063
+ function initControls(seed) {
4064
+ state.controls.seed = { ...seed };
4065
+ restoreControls();
4066
+ }
4067
+ function restoreControls() {
4068
+ state.controls.values = { ...DEFAULTS, ...state.controls.seed };
4069
+ state.controls.unavailable = /* @__PURE__ */ new Set();
4070
+ state.controls.lastUsage = null;
4071
+ state.controls.writes = [];
4072
+ state.controls.refusal = null;
4073
+ resetControlsClientCache();
4074
+ }
4075
+ function clearControls() {
4076
+ restoreControls();
4077
+ }
4078
+ function currentControlsBody() {
4079
+ const controls = {};
4080
+ for (const [key, value] of Object.entries(state.controls.values)) {
4081
+ controls[key] = state.controls.unavailable.has(key) ? { ok: false, reason: "unavailable (simulated)" } : { ok: true, value };
4082
+ }
4083
+ return controls;
4084
+ }
4085
+ function refusalResponse(refusal) {
4086
+ return json({ error: refusal }, REFUSAL_STATUS[refusal.code]);
4087
+ }
4088
+ function handleSync(body) {
4089
+ const usage = body.usage;
4090
+ if (typeof usage === "object" && usage !== null) {
4091
+ const locations = usage.locations;
4092
+ state.controls.lastUsage = typeof locations === "number" && Number.isInteger(locations) && locations >= 0 ? { locations } : {};
4093
+ }
4094
+ return json({ controls: currentControlsBody() });
4095
+ }
4096
+ async function handleWrite(request, key) {
4097
+ const { CONTROLS, isWritableControl } = await loadControls();
4098
+ if (!isWritableControl(key)) {
4099
+ return refusalResponse({
4100
+ code: "NOT_WRITABLE",
4101
+ message: `${key} cannot be changed from the app`
4102
+ });
4103
+ }
4104
+ const userToken = request.headers.get(USER_TOKEN_HEADER);
4105
+ if (!userToken) {
4106
+ return refusalResponse({
4107
+ code: "UNAUTHENTICATED",
4108
+ message: "A signed-in person is required (X-Stardeck-User-Token)"
4109
+ });
4110
+ }
4111
+ const body = await readJsonBody(request);
4112
+ const parsed = CONTROLS[key].schema.safeParse(body.value);
4113
+ if (!parsed.success) {
4114
+ return refusalResponse({
4115
+ code: "INVALID_VALUE",
4116
+ message: parsed.error.issues[0]?.message ?? "Invalid value"
4117
+ });
4118
+ }
4119
+ const refusal = state.controls.refusal;
4120
+ if (refusal) {
4121
+ state.controls.refusal = null;
4122
+ return refusalResponse(refusal);
4123
+ }
4124
+ state.controls.values[key] = parsed.data;
4125
+ state.controls.unavailable.delete(key);
4126
+ state.controls.writes.push({ key, value: parsed.data, userToken });
4127
+ return json({ controls: currentControlsBody() });
4128
+ }
4129
+ var CONTROLS_PATH = /^\/api\/deployments\/[^/]+\/controls(?:\/([^/]+))?$/;
4130
+ async function handleControlsRequest(request, url) {
4131
+ const match = url.pathname.match(CONTROLS_PATH);
4132
+ const key = match?.[1] ? decodeURIComponent(match[1]) : null;
4133
+ if (!key && request.method === "POST") {
4134
+ return handleSync(await readJsonBody(request));
4135
+ }
4136
+ if (key && request.method === "PUT") {
4137
+ return handleWrite(request, key);
4138
+ }
4139
+ return json(
4140
+ {
4141
+ error: { code: "UNAVAILABLE", message: `No simulator for ${request.method} ${url.pathname}` }
4142
+ },
4143
+ 404
4144
+ );
4145
+ }
4146
+ function createControls() {
4147
+ return {
4148
+ set(key, value) {
4149
+ state.controls.values[key] = value;
4150
+ state.controls.unavailable.delete(key);
4151
+ resetControlsClientCache();
4152
+ },
4153
+ setUnavailable(key) {
4154
+ state.controls.unavailable.add(key);
4155
+ resetControlsClientCache();
4156
+ },
4157
+ lastUsage() {
4158
+ return state.controls.lastUsage;
4159
+ },
4160
+ writes() {
4161
+ return state.controls.writes;
4162
+ },
4163
+ refuseNextWrite(refusal) {
4164
+ state.controls.refusal = refusal;
4165
+ },
4166
+ clear: restoreControls
4167
+ };
4168
+ }
4169
+
3630
4170
  // src/simulator/schedules.ts
3631
4171
  async function handleScheduleWake(request, scheduleKey) {
3632
4172
  if (request.method !== "POST") return failure(`Method ${request.method} not allowed`, 405);
@@ -3809,7 +4349,7 @@ function requiresDeploymentHmac(request, url) {
3809
4349
  return !isPresignedPut;
3810
4350
  }
3811
4351
  const dataStoreMatch = url.pathname.match(/^\/api\/data-stores\/[^/]+(\/.*)?$/);
3812
- const isEmail = url.pathname === "/api/email/send";
4352
+ const isEmail2 = url.pathname === "/api/email/send";
3813
4353
  const identitiesMatch = url.pathname.match(/^\/api\/deployments\/[^/]+\/identities(\/.*)?$/);
3814
4354
  const storeMatch = url.pathname.match(/^\/api\/store\//);
3815
4355
  const messagingMatch = url.pathname.match(
@@ -3818,8 +4358,10 @@ function requiresDeploymentHmac(request, url) {
3818
4358
  const edgeMatch = url.pathname.match(/^\/api\/deployments\/[^/]+\/edge(\/.*)?$/);
3819
4359
  const isStarlens = url.pathname === "/api/starlens/extract";
3820
4360
  const scheduleWakeMatch = url.pathname.match(SCHEDULE_WAKE_PATH);
3821
- return !!(isStarlens || scheduleWakeMatch || dataStoreMatch || isEmail || identitiesMatch || storeMatch || messagingMatch || edgeMatch);
4361
+ const controlsMatch = url.pathname.match(CONTROLS_PATH2);
4362
+ return !!(controlsMatch || isStarlens || scheduleWakeMatch || dataStoreMatch || isEmail2 || identitiesMatch || storeMatch || messagingMatch || edgeMatch);
3822
4363
  }
4364
+ var CONTROLS_PATH2 = /^\/api\/deployments\/[^/]+\/controls(\/[^/]+)?$/;
3823
4365
  var SCHEDULE_WAKE_PATH = /^\/api\/deployments\/[^/]+\/schedules\/([^/]+)\/wake$/;
3824
4366
  function deploymentIdFromPath(url) {
3825
4367
  const match = url.pathname.match(/\/api\/deployments\/([^/]+)(?:\/|$)/);
@@ -3841,7 +4383,7 @@ async function handleSimulatedRequest(request, url) {
3841
4383
  return authMatch[1].endsWith("verify") ? handleAuthVerify(request) : handleAuthRefresh(request);
3842
4384
  }
3843
4385
  const dataStoreMatch = url.pathname.match(/^\/api\/data-stores\/[^/]+(\/.*)?$/);
3844
- const isEmail = url.pathname === "/api/email/send";
4386
+ const isEmail2 = url.pathname === "/api/email/send";
3845
4387
  const identitiesMatch = url.pathname.match(/^\/api\/deployments\/[^/]+\/identities(\/.*)?$/);
3846
4388
  const storeMatch = url.pathname.match(/^\/api\/store\//);
3847
4389
  const messagingMatch = url.pathname.match(
@@ -3850,6 +4392,7 @@ async function handleSimulatedRequest(request, url) {
3850
4392
  const edgeMatch = url.pathname.match(/^\/api\/deployments\/[^/]+\/edge(\/.*)?$/);
3851
4393
  const isStorageHost = url.hostname === STORAGE_TEST_HOST;
3852
4394
  let signedDeploymentId = null;
4395
+ let signedProjectId = null;
3853
4396
  if (requiresDeploymentHmac(request, url)) {
3854
4397
  const authHeader = request.headers.get("X-Stardeck-Auth");
3855
4398
  if (!authHeader) {
@@ -3862,6 +4405,7 @@ async function handleSimulatedRequest(request, url) {
3862
4405
  return failure("Invalid authentication", 401);
3863
4406
  }
3864
4407
  signedDeploymentId = authPayload.deploymentId;
4408
+ signedProjectId = authPayload.projectId;
3865
4409
  }
3866
4410
  if (isStorageHost) {
3867
4411
  return handleStorageRequest(request, url);
@@ -3869,14 +4413,14 @@ async function handleSimulatedRequest(request, url) {
3869
4413
  if (url.hostname === SIM_BEAM_HOST) {
3870
4414
  return handleCardTokenRequest(request, url);
3871
4415
  }
3872
- if (isEmail && request.method === "POST") {
4416
+ if (isEmail2 && request.method === "POST") {
3873
4417
  return handleEmailSend(request);
3874
4418
  }
3875
4419
  if (identitiesMatch) {
3876
4420
  return handleIdentitiesRequest(request, identitiesMatch[1] ?? "");
3877
4421
  }
3878
4422
  if (storeMatch) {
3879
- return handlePaymentsRequest(request, url);
4423
+ return handlePaymentsRequest(request, url, signedProjectId);
3880
4424
  }
3881
4425
  if (messagingMatch) {
3882
4426
  const channel = messagingMatch[1];
@@ -3885,6 +4429,9 @@ async function handleSimulatedRequest(request, url) {
3885
4429
  if (edgeMatch) {
3886
4430
  return handleEdgeRequest(request, edgeMatch[1] ?? "");
3887
4431
  }
4432
+ if (CONTROLS_PATH2.test(url.pathname)) {
4433
+ return handleControlsRequest(request, url);
4434
+ }
3888
4435
  const scheduleWakeMatch = url.pathname.match(SCHEDULE_WAKE_PATH);
3889
4436
  if (scheduleWakeMatch) {
3890
4437
  return handleScheduleWake(request, decodeURIComponent(scheduleWakeMatch[1]));
@@ -3913,7 +4460,7 @@ async function handleSimulatedRequest(request, url) {
3913
4460
  }
3914
4461
  }
3915
4462
  return failure(
3916
- `[stardeck-testing] No simulator for ${request.method} ${url.pathname}. Supported: data-store query/mutate/schema, email send, identities CRUD, payments store/checkout, storage upload/files, messaging send, edge print/display/bindings, starlens extract, schedule wakes, auth verify/refresh, Neon /sql.`,
4463
+ `[stardeck-testing] No simulator for ${request.method} ${url.pathname}. Supported: data-store query/mutate/schema, email send, identities CRUD, payments store/checkout, storage upload/files, messaging send, edge print/display/bindings, app controls sync/write, starlens extract, schedule wakes, auth verify/refresh, Neon /sql.`,
3917
4464
  404
3918
4465
  );
3919
4466
  }
@@ -4062,6 +4609,7 @@ async function createTestApp(options = {}) {
4062
4609
  const initialLiffId = options.liffId ?? null;
4063
4610
  state.liffId = initialLiffId;
4064
4611
  installFetchRouter();
4612
+ initControls(options.controls);
4065
4613
  const schemaSql = loadSchemaSql(options) ?? "";
4066
4614
  const db = await bootWithSchema(schemaSql);
4067
4615
  state.db = db;
@@ -4093,6 +4641,7 @@ async function createTestApp(options = {}) {
4093
4641
  const edge = createEdge();
4094
4642
  const starlens = createStarlens();
4095
4643
  const schedules = createSchedules();
4644
+ const controls = createControls();
4096
4645
  const app = {
4097
4646
  db,
4098
4647
  inbox,
@@ -4103,6 +4652,7 @@ async function createTestApp(options = {}) {
4103
4652
  edge,
4104
4653
  starlens,
4105
4654
  schedules,
4655
+ controls,
4106
4656
  async query(sql, params = []) {
4107
4657
  const result = await db.query(sql, params);
4108
4658
  return result.rows;
@@ -4152,6 +4702,7 @@ async function createTestApp(options = {}) {
4152
4702
  edge.clear();
4153
4703
  starlens.clear();
4154
4704
  schedules.clear();
4705
+ clearControls();
4155
4706
  },
4156
4707
  async close() {
4157
4708
  state.db = null;
@@ -4168,6 +4719,7 @@ async function createTestApp(options = {}) {
4168
4719
  edge.clear();
4169
4720
  starlens.clear();
4170
4721
  schedules.clear();
4722
+ clearControls();
4171
4723
  uninstallFetchRouter();
4172
4724
  await db.close();
4173
4725
  }