@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.mjs CHANGED
@@ -318,6 +318,11 @@ var state = globalSingleton("state", () => ({
318
318
  cardTokens: /* @__PURE__ */ new Map(),
319
319
  cardTokenCounter: 0,
320
320
  cardTokenizationEnabled: true,
321
+ stripePaymentIntents: /* @__PURE__ */ new Map(),
322
+ stripePaymentIntentCounter: 0,
323
+ stripeIdempotentIntents: /* @__PURE__ */ new Map(),
324
+ stripeAllowedMethods: ["card", "promptpay"],
325
+ stripeChargesEnabled: true,
321
326
  uploads: [],
322
327
  uploadCounter: 0,
323
328
  storageFiles: /* @__PURE__ */ new Map(),
@@ -337,6 +342,14 @@ var state = globalSingleton("state", () => ({
337
342
  starlensCalls: [],
338
343
  starlensReplies: [],
339
344
  starlensDefaultReply: null,
345
+ controls: {
346
+ seed: {},
347
+ values: {},
348
+ unavailable: /* @__PURE__ */ new Set(),
349
+ lastUsage: null,
350
+ writes: [],
351
+ refusal: null
352
+ },
340
353
  allowNetwork: false
341
354
  }));
342
355
  function requireDb() {
@@ -784,8 +797,8 @@ async function handleNeonSql(db, request) {
784
797
  rowAsArray: arrayMode
785
798
  });
786
799
  } catch (error) {
787
- const err = error;
788
- return json({ message: err.message, code: err.code }, 400);
800
+ const err2 = error;
801
+ return json({ message: err2.message, code: err2.code }, 400);
789
802
  }
790
803
  }
791
804
 
@@ -2078,6 +2091,348 @@ function decodeNextRedirect(error) {
2078
2091
  return new Response(null, { status, headers: { location } });
2079
2092
  }
2080
2093
 
2094
+ // src/simulator/stripe-payment-intents.ts
2095
+ var SIM_STRIPE_PUBLISHABLE_KEY = "pk_test_sim_stripe";
2096
+ var SIM_STRIPE_DEFAULT_ACCOUNT = "acct_sim_default";
2097
+ var METHODS = ["card", "promptpay"];
2098
+ var RESERVED_METADATA_KEYS = ["projectId", "stardeckReferenceId", "stardeckStagingDeploymentId"];
2099
+ var PROMPTPAY_QR_TTL_SECONDS = 15 * 60;
2100
+ var PATH_RE = /^\/api\/store\/(?!beam\/)([^/]+)\/(payment-intents(?:\/([^/]+)(?:\/(cancel))?)?|elements-config|readiness)$/;
2101
+ function err(error, status, code) {
2102
+ return json({ error, code }, status);
2103
+ }
2104
+ function decode(segment) {
2105
+ try {
2106
+ return decodeURIComponent(segment);
2107
+ } catch {
2108
+ return segment;
2109
+ }
2110
+ }
2111
+ function isPlayground(account) {
2112
+ return account === "playground";
2113
+ }
2114
+ function resolveAccount(account) {
2115
+ if (isPlayground(account)) return null;
2116
+ if (account === "default") {
2117
+ return process.env.STRIPE_CONNECT_ACCOUNT_ID || SIM_STRIPE_DEFAULT_ACCOUNT;
2118
+ }
2119
+ return account;
2120
+ }
2121
+ 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;
2122
+ var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
2123
+ var APP_METADATA_MAX_KEYS = 50 - RESERVED_METADATA_KEYS.length;
2124
+ function isStringRecord(value) {
2125
+ return !!value && typeof value === "object" && !Array.isArray(value) && Object.values(value).every((v) => typeof v === "string");
2126
+ }
2127
+ function isValidMetadata(value) {
2128
+ if (!isStringRecord(value)) return false;
2129
+ const entries = Object.entries(value);
2130
+ return entries.length <= APP_METADATA_MAX_KEYS && entries.every(([k, v]) => k.length >= 1 && k.length <= 40 && v.length <= 500);
2131
+ }
2132
+ function isOptional(value, test) {
2133
+ return value === void 0 || test(value);
2134
+ }
2135
+ var isEmail = (v) => typeof v === "string" && EMAIL_RE.test(v);
2136
+ function validateCreate(body) {
2137
+ const { amount, currency, method, referenceId, idempotencyKey } = body;
2138
+ const checks = [
2139
+ [
2140
+ typeof amount === "number" && Number.isInteger(amount) && amount > 0,
2141
+ "amount must be a positive integer in minor units"
2142
+ ],
2143
+ [typeof currency === "string" && currency.length === 3, "currency must be a 3-letter ISO code"],
2144
+ [
2145
+ typeof method === "string" && METHODS.includes(method),
2146
+ `method must be one of: ${METHODS.join(", ")}`
2147
+ ],
2148
+ [
2149
+ typeof referenceId === "string" && referenceId.length >= 1 && referenceId.length <= 200,
2150
+ "referenceId must be 1 to 200 characters"
2151
+ ],
2152
+ [
2153
+ typeof idempotencyKey === "string" && idempotencyKey.length >= 1 && idempotencyKey.length <= 200,
2154
+ "idempotencyKey is required (1 to 200 characters)"
2155
+ ],
2156
+ [
2157
+ isOptional(body.description, (v) => typeof v === "string" && v.length <= 1e3),
2158
+ "description must be at most 1000 characters"
2159
+ ],
2160
+ [isOptional(body.receiptEmail, isEmail), "receiptEmail must be an email address"],
2161
+ [isOptional(body.billingEmail, isEmail), "billingEmail must be an email address"],
2162
+ [
2163
+ isOptional(body.returnUrl, (v) => typeof v === "string" && URL.canParse(v)),
2164
+ "returnUrl must be an absolute URL"
2165
+ ],
2166
+ [
2167
+ isOptional(body.metadata, isValidMetadata),
2168
+ `metadata must map keys (1-40 chars) to strings (max 500), at most ${APP_METADATA_MAX_KEYS} keys`
2169
+ ],
2170
+ [
2171
+ typeof body.deploymentId === "string" && UUID_RE2.test(body.deploymentId),
2172
+ "Invalid deployment ID"
2173
+ ]
2174
+ ];
2175
+ const failed = checks.find(([ok]) => !ok);
2176
+ return failed ? failed[1] : null;
2177
+ }
2178
+ function appMetadata(raw) {
2179
+ if (!isStringRecord(raw)) return {};
2180
+ const out = {};
2181
+ for (const [key, value] of Object.entries(raw)) {
2182
+ if (!RESERVED_METADATA_KEYS.includes(key)) out[key] = value;
2183
+ }
2184
+ return out;
2185
+ }
2186
+ function stableStringify(value) {
2187
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
2188
+ if (value && typeof value === "object") {
2189
+ const entries = Object.entries(value).filter(([, v]) => v !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
2190
+ return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`).join(",")}}`;
2191
+ }
2192
+ return JSON.stringify(value);
2193
+ }
2194
+ function toStripePaymentIntentSummary(intent) {
2195
+ const open = intent.status !== "succeeded" && intent.status !== "canceled";
2196
+ let actionRequired = "NONE";
2197
+ if (open && intent.method === "card") actionRequired = "CLIENT_CONFIRM";
2198
+ if (open && intent.method === "promptpay" && intent.status === "requires_action") {
2199
+ actionRequired = "ENCODED_IMAGE";
2200
+ }
2201
+ return {
2202
+ id: intent.id,
2203
+ status: intent.status,
2204
+ amount: intent.amount,
2205
+ currency: intent.currency,
2206
+ method: intent.method,
2207
+ referenceId: intent.referenceId,
2208
+ metadata: { ...intent.appMetadata },
2209
+ actionRequired,
2210
+ clientSecret: intent.method === "card" ? intent.clientSecret : null,
2211
+ qr: actionRequired === "ENCODED_IMAGE" ? { ...intent.qr } : null,
2212
+ latestChargeId: intent.latestChargeId,
2213
+ amountRefunded: intent.amountRefunded,
2214
+ lastPaymentError: intent.lastPaymentError ? { ...intent.lastPaymentError } : null,
2215
+ created: intent.created,
2216
+ livemode: false,
2217
+ playground: intent.playground
2218
+ };
2219
+ }
2220
+ function promptPayQr(id, created) {
2221
+ return {
2222
+ imageUrlPng: `https://qr.stripe.test/${id}.png`,
2223
+ imageUrlSvg: `https://qr.stripe.test/${id}.svg`,
2224
+ data: `00020101021230SIM${id}`,
2225
+ hostedInstructionsUrl: `https://payments.stripe.test/promptpay/${id}`,
2226
+ expiresAt: created + PROMPTPAY_QR_TTL_SECONDS
2227
+ };
2228
+ }
2229
+ function createIntent(body, account, projectId) {
2230
+ const problem = validateCreate(body);
2231
+ if (problem) return err(problem, 400, "invalid_request");
2232
+ const method = body.method;
2233
+ const currency = body.currency.toLowerCase();
2234
+ if (!state.stripeAllowedMethods.includes(method)) {
2235
+ return err(
2236
+ `The ${method} payment method is turned off for this account`,
2237
+ 422,
2238
+ "method_not_allowed"
2239
+ );
2240
+ }
2241
+ if (method === "promptpay" && currency !== "thb") {
2242
+ return err("PromptPay only takes THB", 422, "currency_not_supported");
2243
+ }
2244
+ if (!state.stripeChargesEnabled) {
2245
+ return err("This Stripe account can't take payments yet", 503, "account_not_ready");
2246
+ }
2247
+ const scopedKey = JSON.stringify(["pi", projectId, account, body.idempotencyKey]);
2248
+ const comparable = { ...body };
2249
+ delete comparable.deploymentId;
2250
+ delete comparable.idempotencyKey;
2251
+ const fingerprint = stableStringify(comparable);
2252
+ const saved = state.stripeIdempotentIntents.get(scopedKey);
2253
+ if (saved) {
2254
+ if (saved.fingerprint !== fingerprint) {
2255
+ return err(
2256
+ "This idempotency key was already used with different parameters. Use a new key for a different payment.",
2257
+ 409,
2258
+ "idempotency_conflict"
2259
+ );
2260
+ }
2261
+ const existing = state.stripePaymentIntents.get(saved.id);
2262
+ return json({ paymentIntent: toStripePaymentIntentSummary(existing) }, 201);
2263
+ }
2264
+ state.stripePaymentIntentCounter += 1;
2265
+ const id = `pi_sim_${state.stripePaymentIntentCounter}`;
2266
+ const created = Math.floor(Date.now() / 1e3);
2267
+ const referenceId = body.referenceId;
2268
+ const metadata = appMetadata(body.metadata);
2269
+ const intent = {
2270
+ id,
2271
+ projectId,
2272
+ account,
2273
+ connectedAccountId: resolveAccount(account),
2274
+ playground: isPlayground(account),
2275
+ status: method === "card" ? "requires_payment_method" : "requires_action",
2276
+ amount: body.amount,
2277
+ currency,
2278
+ method,
2279
+ referenceId,
2280
+ appMetadata: metadata,
2281
+ metadata: { ...metadata, projectId, stardeckReferenceId: referenceId },
2282
+ description: typeof body.description === "string" ? body.description : null,
2283
+ receiptEmail: typeof body.receiptEmail === "string" ? body.receiptEmail : null,
2284
+ returnUrl: typeof body.returnUrl === "string" ? body.returnUrl : null,
2285
+ clientSecret: `${id}_secret_sim`,
2286
+ qr: method === "promptpay" ? promptPayQr(id, created) : null,
2287
+ latestChargeId: null,
2288
+ amountRefunded: 0,
2289
+ lastPaymentError: null,
2290
+ created
2291
+ };
2292
+ state.stripePaymentIntents.set(id, intent);
2293
+ state.stripeIdempotentIntents.set(scopedKey, { fingerprint, id });
2294
+ console.log(
2295
+ `[stardeck-testing] stripe payment intent ${id} created method=${method} amount=${intent.amount} ${currency}`
2296
+ );
2297
+ return json({ paymentIntent: toStripePaymentIntentSummary(intent) }, 201);
2298
+ }
2299
+ function ownedIntent(id, projectId) {
2300
+ const intent = state.stripePaymentIntents.get(id);
2301
+ return intent && intent.projectId === projectId ? intent : void 0;
2302
+ }
2303
+ function cancelIntent(intent) {
2304
+ if (intent.status === "succeeded") {
2305
+ return err("This payment already succeeded and can't be canceled", 409, "already_succeeded");
2306
+ }
2307
+ intent.status = "canceled";
2308
+ return json({ paymentIntent: toStripePaymentIntentSummary(intent) });
2309
+ }
2310
+ function handleIntents(request, url, account, intentId, action, projectId) {
2311
+ if (!intentId && request.method === "POST") {
2312
+ return readJsonBody(request).then((body) => createIntent(body, account, projectId));
2313
+ }
2314
+ if (!intentId && request.method === "GET") {
2315
+ const referenceId = url.searchParams.get("referenceId");
2316
+ if (!referenceId) return err("referenceId is required", 400, "invalid_request");
2317
+ const paymentIntents = [...state.stripePaymentIntents.values()].filter((i) => i.projectId === projectId && i.referenceId === referenceId).map(toStripePaymentIntentSummary);
2318
+ return json({ paymentIntents });
2319
+ }
2320
+ if (!intentId) return null;
2321
+ const intent = ownedIntent(decode(intentId), projectId);
2322
+ if (!action && request.method === "GET") {
2323
+ if (!intent) return err("Payment intent not found", 404, "not_found");
2324
+ return json({ paymentIntent: toStripePaymentIntentSummary(intent) });
2325
+ }
2326
+ if (action === "cancel" && request.method === "POST") {
2327
+ return readJsonBody(request).then((body) => {
2328
+ if (typeof body.deploymentId !== "string" || !UUID_RE2.test(body.deploymentId)) {
2329
+ return err("deploymentId is required", 400, "invalid_request");
2330
+ }
2331
+ if (!intent) return err("Payment intent not found", 404, "not_found");
2332
+ return cancelIntent(intent);
2333
+ });
2334
+ }
2335
+ return null;
2336
+ }
2337
+ async function handleStripePaymentIntentRequest(request, url, projectId) {
2338
+ const match = url.pathname.match(PATH_RE);
2339
+ if (!match) return null;
2340
+ if (!projectId) return err("Missing authentication", 401, "unauthorized");
2341
+ const account = decode(match[1]);
2342
+ const resource = match[2];
2343
+ if (request.method === "GET" && !url.searchParams.get("deploymentId")) {
2344
+ return err("deploymentId is required", 400, "invalid_request");
2345
+ }
2346
+ if (resource === "elements-config" && request.method === "GET") {
2347
+ return json({
2348
+ publishableKey: SIM_STRIPE_PUBLISHABLE_KEY,
2349
+ stripeAccount: resolveAccount(account),
2350
+ playground: isPlayground(account)
2351
+ });
2352
+ }
2353
+ if (resource === "readiness" && request.method === "GET") {
2354
+ const methods = [...state.stripeAllowedMethods];
2355
+ return json({
2356
+ provider: "stripe",
2357
+ ready: state.stripeChargesEnabled && methods.length > 0,
2358
+ chargesEnabled: state.stripeChargesEnabled,
2359
+ methods,
2360
+ playground: isPlayground(account)
2361
+ });
2362
+ }
2363
+ if (resource.startsWith("payment-intents")) {
2364
+ return handleIntents(request, url, account, match[3], match[4], projectId);
2365
+ }
2366
+ return null;
2367
+ }
2368
+ function requireIntent(id) {
2369
+ const intent = state.stripePaymentIntents.get(id);
2370
+ if (!intent) throw new Error(`[stardeck-testing] Unknown payment intent id: ${id}`);
2371
+ return intent;
2372
+ }
2373
+ function requireOpenIntent(id, verb) {
2374
+ const intent = requireIntent(id);
2375
+ if (intent.status === "succeeded" || intent.status === "canceled") {
2376
+ throw new Error(
2377
+ `[stardeck-testing] Can't ${verb} payment intent ${id}: it is ${intent.status}`
2378
+ );
2379
+ }
2380
+ return intent;
2381
+ }
2382
+ function succeedStripePaymentIntent(id) {
2383
+ const intent = requireOpenIntent(id, "mark paid");
2384
+ intent.status = "succeeded";
2385
+ intent.latestChargeId = `ch_sim_${id}`;
2386
+ intent.lastPaymentError = null;
2387
+ }
2388
+ function failStripePaymentIntent(id, code) {
2389
+ const intent = requireOpenIntent(id, "fail");
2390
+ intent.status = "requires_payment_method";
2391
+ intent.latestChargeId = `ch_sim_${id}_failed`;
2392
+ intent.lastPaymentError = {
2393
+ code,
2394
+ declineCode: code === "card_declined" ? "generic_decline" : null,
2395
+ message: `Simulated payment failure: ${code}`
2396
+ };
2397
+ }
2398
+ function cancelStripePaymentIntentById(id) {
2399
+ const intent = requireOpenIntent(id, "cancel");
2400
+ intent.status = "canceled";
2401
+ }
2402
+ function stripePaymentIntentEvent(id) {
2403
+ const intent = requireIntent(id);
2404
+ let type;
2405
+ if (intent.status === "succeeded") type = "payment_intent.succeeded";
2406
+ else if (intent.status === "canceled") type = "payment_intent.canceled";
2407
+ else if (intent.lastPaymentError) type = "payment_intent.payment_failed";
2408
+ else {
2409
+ throw new Error(
2410
+ `[stardeck-testing] Payment intent ${id} is ${intent.status}; call markPaid, failPayment or cancelPayment first`
2411
+ );
2412
+ }
2413
+ const error = intent.lastPaymentError;
2414
+ return {
2415
+ type,
2416
+ accountId: intent.connectedAccountId ?? "acct_stardeck_playground",
2417
+ data: {
2418
+ id: intent.id,
2419
+ object: "payment_intent",
2420
+ amount: intent.amount,
2421
+ amount_received: intent.status === "succeeded" ? intent.amount : 0,
2422
+ currency: intent.currency,
2423
+ status: intent.status,
2424
+ metadata: { ...intent.metadata },
2425
+ payment_method_types: [intent.method],
2426
+ latest_charge: intent.latestChargeId,
2427
+ last_payment_error: error ? { code: error.code, decline_code: error.declineCode, message: error.message } : null,
2428
+ description: intent.description,
2429
+ receipt_email: intent.receiptEmail,
2430
+ created: intent.created,
2431
+ livemode: false
2432
+ }
2433
+ };
2434
+ }
2435
+
2081
2436
  // src/simulator/webhook-signing.ts
2082
2437
  import crypto4 from "crypto";
2083
2438
  function signEventDelivery(secret, context, rawBody) {
@@ -2111,7 +2466,7 @@ var BEAM_BOLT_PAYMENT_METHODS = [
2111
2466
  "SPAY_LATER"
2112
2467
  ];
2113
2468
  var BEAM_BOLT_INTENT_STATUSES = ["PENDING", "PAID", "FAILED", "CANCELED", "EXPIRED"];
2114
- 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;
2469
+ 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;
2115
2470
  function payErr(error, status = 400, code, details) {
2116
2471
  const body = { error };
2117
2472
  if (code) body.code = code;
@@ -2137,7 +2492,23 @@ function paymentOperationForRequest(request, pathname) {
2137
2492
  if (request.method === "GET" && /^\/api\/store\/(?!beam\/)[^/]+\/checkout-sessions\/[^/]+$/.test(pathname)) {
2138
2493
  return "stripe.getCheckoutSessionStatus";
2139
2494
  }
2140
- return null;
2495
+ return stripeInlineOperation(request.method, pathname);
2496
+ }
2497
+ var STRIPE_INLINE_OPERATIONS = [
2498
+ ["POST", /^\/api\/store\/(?!beam\/)[^/]+\/payment-intents$/, "stripe.createPaymentIntent"],
2499
+ ["GET", /^\/api\/store\/(?!beam\/)[^/]+\/payment-intents$/, "stripe.findPaymentIntents"],
2500
+ ["GET", /^\/api\/store\/(?!beam\/)[^/]+\/payment-intents\/[^/]+$/, "stripe.getPaymentIntent"],
2501
+ [
2502
+ "POST",
2503
+ /^\/api\/store\/(?!beam\/)[^/]+\/payment-intents\/[^/]+\/cancel$/,
2504
+ "stripe.cancelPaymentIntent"
2505
+ ],
2506
+ ["GET", /^\/api\/store\/(?!beam\/)[^/]+\/elements-config$/, "stripe.getElementsConfig"],
2507
+ ["GET", /^\/api\/store\/(?!beam\/)[^/]+\/readiness$/, "stripe.getReadiness"]
2508
+ ];
2509
+ function stripeInlineOperation(method, pathname) {
2510
+ const hit = STRIPE_INLINE_OPERATIONS.find(([m, re]) => m === method && re.test(pathname));
2511
+ return hit ? hit[2] : null;
2141
2512
  }
2142
2513
  function consumePaymentFailure(request, pathname) {
2143
2514
  const operation = paymentOperationForRequest(request, pathname);
@@ -2167,11 +2538,11 @@ function isBoltPaymentMethod(value) {
2167
2538
  function isBoltIntentStatus(value) {
2168
2539
  return BEAM_BOLT_INTENT_STATUSES.includes(value);
2169
2540
  }
2170
- function stableStringify(value) {
2171
- if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
2541
+ function stableStringify2(value) {
2542
+ if (Array.isArray(value)) return `[${value.map(stableStringify2).join(",")}]`;
2172
2543
  if (value && typeof value === "object") {
2173
2544
  const entries = Object.entries(value).filter(([, v]) => v !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
2174
- return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`).join(",")}}`;
2545
+ return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stableStringify2(v)}`).join(",")}}`;
2175
2546
  }
2176
2547
  return JSON.stringify(value);
2177
2548
  }
@@ -2312,10 +2683,16 @@ async function deliverEvent(envelope, handler, options) {
2312
2683
  body: envelope
2313
2684
  });
2314
2685
  }
2315
- async function handlePaymentsRequest(request, url) {
2686
+ async function handlePaymentsRequest(request, url, signedProjectId = null) {
2316
2687
  const pathname = url.pathname;
2317
2688
  const injectedFailure = consumePaymentFailure(request, pathname);
2318
2689
  if (injectedFailure) return injectedFailure;
2690
+ const inlineStripeResponse = await handleStripePaymentIntentRequest(
2691
+ request,
2692
+ url,
2693
+ signedProjectId
2694
+ );
2695
+ if (inlineStripeResponse) return inlineStripeResponse;
2319
2696
  const directChargeResponse = await handleDirectChargeRequest(request, pathname);
2320
2697
  if (directChargeResponse) return directChargeResponse;
2321
2698
  if (/\/billing-portal$/.test(pathname)) {
@@ -2414,7 +2791,7 @@ async function handlePaymentsRequest(request, url) {
2414
2791
  code: "too_small"
2415
2792
  });
2416
2793
  }
2417
- if (typeof body.deploymentId !== "string" || !UUID_RE2.test(body.deploymentId)) {
2794
+ if (typeof body.deploymentId !== "string" || !UUID_RE3.test(body.deploymentId)) {
2418
2795
  issues.push({
2419
2796
  path: ["deploymentId"],
2420
2797
  message: "Invalid uuid",
@@ -2598,7 +2975,7 @@ async function handlePaymentsRequest(request, url) {
2598
2975
  const comparable = { ...body };
2599
2976
  delete comparable.deploymentId;
2600
2977
  delete comparable.idempotencyKey;
2601
- const fingerprint = stableStringify(comparable);
2978
+ const fingerprint = stableStringify2(comparable);
2602
2979
  if (scopedKey) {
2603
2980
  const saved = state.idempotentCheckouts.get(scopedKey);
2604
2981
  if (saved) {
@@ -2673,6 +3050,10 @@ function createPayments() {
2673
3050
  state.paymentFailures.push({ operation, failure: failure2 });
2674
3051
  },
2675
3052
  markPaid(id) {
3053
+ if (state.stripePaymentIntents.has(id)) {
3054
+ succeedStripePaymentIntent(id);
3055
+ return;
3056
+ }
2676
3057
  const session = state.sessionStatuses.get(id);
2677
3058
  if (session) {
2678
3059
  session.status = "complete";
@@ -2700,7 +3081,26 @@ function createPayments() {
2700
3081
  }
2701
3082
  link.status = status;
2702
3083
  },
2703
- async deliverStripeEvent(handler, event, options) {
3084
+ failPayment(paymentIntentId, code = "card_declined") {
3085
+ failStripePaymentIntent(paymentIntentId, code);
3086
+ },
3087
+ cancelPayment(paymentIntentId) {
3088
+ cancelStripePaymentIntentById(paymentIntentId);
3089
+ },
3090
+ get stripePaymentIntents() {
3091
+ return [...state.stripePaymentIntents.values()];
3092
+ },
3093
+ getStripePaymentIntent(paymentIntentId) {
3094
+ return state.stripePaymentIntents.get(paymentIntentId);
3095
+ },
3096
+ setStripePaymentMethods(methods) {
3097
+ state.stripeAllowedMethods = [...methods];
3098
+ },
3099
+ setStripeChargesEnabled(enabled) {
3100
+ state.stripeChargesEnabled = enabled;
3101
+ },
3102
+ async deliverStripeEvent(handler, input, options) {
3103
+ const event = "paymentIntentId" in input ? stripePaymentIntentEvent(input.paymentIntentId) : input;
2704
3104
  const envelope = {
2705
3105
  id: `evt_test_${crypto5.randomUUID()}`,
2706
3106
  kind: "stripe_webhook",
@@ -2816,6 +3216,11 @@ function createPayments() {
2816
3216
  state.cardTokens.clear();
2817
3217
  state.cardTokenCounter = 0;
2818
3218
  state.cardTokenizationEnabled = true;
3219
+ state.stripePaymentIntents.clear();
3220
+ state.stripePaymentIntentCounter = 0;
3221
+ state.stripeIdempotentIntents.clear();
3222
+ state.stripeAllowedMethods = ["card", "promptpay"];
3223
+ state.stripeChargesEnabled = true;
2819
3224
  },
2820
3225
  get count() {
2821
3226
  return state.checkouts.length;
@@ -3588,6 +3993,141 @@ function createEdge() {
3588
3993
  };
3589
3994
  }
3590
3995
 
3996
+ // src/simulator/controls.ts
3997
+ var CONTROLS_STATE_SYMBOL = /* @__PURE__ */ Symbol.for("stardeck.controls.state");
3998
+ var USER_TOKEN_HEADER = "x-stardeck-user-token";
3999
+ var DEFAULTS = {
4000
+ "flag.paymentsSetupStep": false,
4001
+ "plan.locations": { included: 3, max: null, extraMonthly: { amount: 990, currency: "THB" } },
4002
+ "plan.multiEntity": true,
4003
+ "payments.provider": "beam",
4004
+ "payments.methods": { card: true, promptPay: true }
4005
+ };
4006
+ var REFUSAL_STATUS = {
4007
+ NOT_WRITABLE: 400,
4008
+ INVALID_VALUE: 400,
4009
+ UNAUTHENTICATED: 401,
4010
+ FORBIDDEN: 403,
4011
+ WRITE_NOT_ALLOWED_HERE: 403,
4012
+ PROVIDER_NOT_READY: 409,
4013
+ UNAVAILABLE: 503
4014
+ };
4015
+ var controlsModule = null;
4016
+ function loadControls() {
4017
+ controlsModule ??= import("@stardeck-customer-apps/controls");
4018
+ return controlsModule;
4019
+ }
4020
+ function resetControlsClientCache() {
4021
+ const clientState = globalThis[CONTROLS_STATE_SYMBOL];
4022
+ clientState?.reset?.();
4023
+ }
4024
+ function initControls(seed) {
4025
+ state.controls.seed = { ...seed };
4026
+ restoreControls();
4027
+ }
4028
+ function restoreControls() {
4029
+ state.controls.values = { ...DEFAULTS, ...state.controls.seed };
4030
+ state.controls.unavailable = /* @__PURE__ */ new Set();
4031
+ state.controls.lastUsage = null;
4032
+ state.controls.writes = [];
4033
+ state.controls.refusal = null;
4034
+ resetControlsClientCache();
4035
+ }
4036
+ function clearControls() {
4037
+ restoreControls();
4038
+ }
4039
+ function currentControlsBody() {
4040
+ const controls = {};
4041
+ for (const [key, value] of Object.entries(state.controls.values)) {
4042
+ controls[key] = state.controls.unavailable.has(key) ? { ok: false, reason: "unavailable (simulated)" } : { ok: true, value };
4043
+ }
4044
+ return controls;
4045
+ }
4046
+ function refusalResponse(refusal) {
4047
+ return json({ error: refusal }, REFUSAL_STATUS[refusal.code]);
4048
+ }
4049
+ function handleSync(body) {
4050
+ const usage = body.usage;
4051
+ if (typeof usage === "object" && usage !== null) {
4052
+ const locations = usage.locations;
4053
+ state.controls.lastUsage = typeof locations === "number" && Number.isInteger(locations) && locations >= 0 ? { locations } : {};
4054
+ }
4055
+ return json({ controls: currentControlsBody() });
4056
+ }
4057
+ async function handleWrite(request, key) {
4058
+ const { CONTROLS, isWritableControl } = await loadControls();
4059
+ if (!isWritableControl(key)) {
4060
+ return refusalResponse({
4061
+ code: "NOT_WRITABLE",
4062
+ message: `${key} cannot be changed from the app`
4063
+ });
4064
+ }
4065
+ const userToken = request.headers.get(USER_TOKEN_HEADER);
4066
+ if (!userToken) {
4067
+ return refusalResponse({
4068
+ code: "UNAUTHENTICATED",
4069
+ message: "A signed-in person is required (X-Stardeck-User-Token)"
4070
+ });
4071
+ }
4072
+ const body = await readJsonBody(request);
4073
+ const parsed = CONTROLS[key].schema.safeParse(body.value);
4074
+ if (!parsed.success) {
4075
+ return refusalResponse({
4076
+ code: "INVALID_VALUE",
4077
+ message: parsed.error.issues[0]?.message ?? "Invalid value"
4078
+ });
4079
+ }
4080
+ const refusal = state.controls.refusal;
4081
+ if (refusal) {
4082
+ state.controls.refusal = null;
4083
+ return refusalResponse(refusal);
4084
+ }
4085
+ state.controls.values[key] = parsed.data;
4086
+ state.controls.unavailable.delete(key);
4087
+ state.controls.writes.push({ key, value: parsed.data, userToken });
4088
+ return json({ controls: currentControlsBody() });
4089
+ }
4090
+ var CONTROLS_PATH = /^\/api\/deployments\/[^/]+\/controls(?:\/([^/]+))?$/;
4091
+ async function handleControlsRequest(request, url) {
4092
+ const match = url.pathname.match(CONTROLS_PATH);
4093
+ const key = match?.[1] ? decodeURIComponent(match[1]) : null;
4094
+ if (!key && request.method === "POST") {
4095
+ return handleSync(await readJsonBody(request));
4096
+ }
4097
+ if (key && request.method === "PUT") {
4098
+ return handleWrite(request, key);
4099
+ }
4100
+ return json(
4101
+ {
4102
+ error: { code: "UNAVAILABLE", message: `No simulator for ${request.method} ${url.pathname}` }
4103
+ },
4104
+ 404
4105
+ );
4106
+ }
4107
+ function createControls() {
4108
+ return {
4109
+ set(key, value) {
4110
+ state.controls.values[key] = value;
4111
+ state.controls.unavailable.delete(key);
4112
+ resetControlsClientCache();
4113
+ },
4114
+ setUnavailable(key) {
4115
+ state.controls.unavailable.add(key);
4116
+ resetControlsClientCache();
4117
+ },
4118
+ lastUsage() {
4119
+ return state.controls.lastUsage;
4120
+ },
4121
+ writes() {
4122
+ return state.controls.writes;
4123
+ },
4124
+ refuseNextWrite(refusal) {
4125
+ state.controls.refusal = refusal;
4126
+ },
4127
+ clear: restoreControls
4128
+ };
4129
+ }
4130
+
3591
4131
  // src/simulator/schedules.ts
3592
4132
  async function handleScheduleWake(request, scheduleKey) {
3593
4133
  if (request.method !== "POST") return failure(`Method ${request.method} not allowed`, 405);
@@ -3770,7 +4310,7 @@ function requiresDeploymentHmac(request, url) {
3770
4310
  return !isPresignedPut;
3771
4311
  }
3772
4312
  const dataStoreMatch = url.pathname.match(/^\/api\/data-stores\/[^/]+(\/.*)?$/);
3773
- const isEmail = url.pathname === "/api/email/send";
4313
+ const isEmail2 = url.pathname === "/api/email/send";
3774
4314
  const identitiesMatch = url.pathname.match(/^\/api\/deployments\/[^/]+\/identities(\/.*)?$/);
3775
4315
  const storeMatch = url.pathname.match(/^\/api\/store\//);
3776
4316
  const messagingMatch = url.pathname.match(
@@ -3779,8 +4319,10 @@ function requiresDeploymentHmac(request, url) {
3779
4319
  const edgeMatch = url.pathname.match(/^\/api\/deployments\/[^/]+\/edge(\/.*)?$/);
3780
4320
  const isStarlens = url.pathname === "/api/starlens/extract";
3781
4321
  const scheduleWakeMatch = url.pathname.match(SCHEDULE_WAKE_PATH);
3782
- return !!(isStarlens || scheduleWakeMatch || dataStoreMatch || isEmail || identitiesMatch || storeMatch || messagingMatch || edgeMatch);
4322
+ const controlsMatch = url.pathname.match(CONTROLS_PATH2);
4323
+ return !!(controlsMatch || isStarlens || scheduleWakeMatch || dataStoreMatch || isEmail2 || identitiesMatch || storeMatch || messagingMatch || edgeMatch);
3783
4324
  }
4325
+ var CONTROLS_PATH2 = /^\/api\/deployments\/[^/]+\/controls(\/[^/]+)?$/;
3784
4326
  var SCHEDULE_WAKE_PATH = /^\/api\/deployments\/[^/]+\/schedules\/([^/]+)\/wake$/;
3785
4327
  function deploymentIdFromPath(url) {
3786
4328
  const match = url.pathname.match(/\/api\/deployments\/([^/]+)(?:\/|$)/);
@@ -3802,7 +4344,7 @@ async function handleSimulatedRequest(request, url) {
3802
4344
  return authMatch[1].endsWith("verify") ? handleAuthVerify(request) : handleAuthRefresh(request);
3803
4345
  }
3804
4346
  const dataStoreMatch = url.pathname.match(/^\/api\/data-stores\/[^/]+(\/.*)?$/);
3805
- const isEmail = url.pathname === "/api/email/send";
4347
+ const isEmail2 = url.pathname === "/api/email/send";
3806
4348
  const identitiesMatch = url.pathname.match(/^\/api\/deployments\/[^/]+\/identities(\/.*)?$/);
3807
4349
  const storeMatch = url.pathname.match(/^\/api\/store\//);
3808
4350
  const messagingMatch = url.pathname.match(
@@ -3811,6 +4353,7 @@ async function handleSimulatedRequest(request, url) {
3811
4353
  const edgeMatch = url.pathname.match(/^\/api\/deployments\/[^/]+\/edge(\/.*)?$/);
3812
4354
  const isStorageHost = url.hostname === STORAGE_TEST_HOST;
3813
4355
  let signedDeploymentId = null;
4356
+ let signedProjectId = null;
3814
4357
  if (requiresDeploymentHmac(request, url)) {
3815
4358
  const authHeader = request.headers.get("X-Stardeck-Auth");
3816
4359
  if (!authHeader) {
@@ -3823,6 +4366,7 @@ async function handleSimulatedRequest(request, url) {
3823
4366
  return failure("Invalid authentication", 401);
3824
4367
  }
3825
4368
  signedDeploymentId = authPayload.deploymentId;
4369
+ signedProjectId = authPayload.projectId;
3826
4370
  }
3827
4371
  if (isStorageHost) {
3828
4372
  return handleStorageRequest(request, url);
@@ -3830,14 +4374,14 @@ async function handleSimulatedRequest(request, url) {
3830
4374
  if (url.hostname === SIM_BEAM_HOST) {
3831
4375
  return handleCardTokenRequest(request, url);
3832
4376
  }
3833
- if (isEmail && request.method === "POST") {
4377
+ if (isEmail2 && request.method === "POST") {
3834
4378
  return handleEmailSend(request);
3835
4379
  }
3836
4380
  if (identitiesMatch) {
3837
4381
  return handleIdentitiesRequest(request, identitiesMatch[1] ?? "");
3838
4382
  }
3839
4383
  if (storeMatch) {
3840
- return handlePaymentsRequest(request, url);
4384
+ return handlePaymentsRequest(request, url, signedProjectId);
3841
4385
  }
3842
4386
  if (messagingMatch) {
3843
4387
  const channel = messagingMatch[1];
@@ -3846,6 +4390,9 @@ async function handleSimulatedRequest(request, url) {
3846
4390
  if (edgeMatch) {
3847
4391
  return handleEdgeRequest(request, edgeMatch[1] ?? "");
3848
4392
  }
4393
+ if (CONTROLS_PATH2.test(url.pathname)) {
4394
+ return handleControlsRequest(request, url);
4395
+ }
3849
4396
  const scheduleWakeMatch = url.pathname.match(SCHEDULE_WAKE_PATH);
3850
4397
  if (scheduleWakeMatch) {
3851
4398
  return handleScheduleWake(request, decodeURIComponent(scheduleWakeMatch[1]));
@@ -3874,7 +4421,7 @@ async function handleSimulatedRequest(request, url) {
3874
4421
  }
3875
4422
  }
3876
4423
  return failure(
3877
- `[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.`,
4424
+ `[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.`,
3878
4425
  404
3879
4426
  );
3880
4427
  }
@@ -4023,6 +4570,7 @@ async function createTestApp(options = {}) {
4023
4570
  const initialLiffId = options.liffId ?? null;
4024
4571
  state.liffId = initialLiffId;
4025
4572
  installFetchRouter();
4573
+ initControls(options.controls);
4026
4574
  const schemaSql = loadSchemaSql(options) ?? "";
4027
4575
  const db = await bootWithSchema(schemaSql);
4028
4576
  state.db = db;
@@ -4054,6 +4602,7 @@ async function createTestApp(options = {}) {
4054
4602
  const edge = createEdge();
4055
4603
  const starlens = createStarlens();
4056
4604
  const schedules = createSchedules();
4605
+ const controls = createControls();
4057
4606
  const app = {
4058
4607
  db,
4059
4608
  inbox,
@@ -4064,6 +4613,7 @@ async function createTestApp(options = {}) {
4064
4613
  edge,
4065
4614
  starlens,
4066
4615
  schedules,
4616
+ controls,
4067
4617
  async query(sql, params = []) {
4068
4618
  const result = await db.query(sql, params);
4069
4619
  return result.rows;
@@ -4113,6 +4663,7 @@ async function createTestApp(options = {}) {
4113
4663
  edge.clear();
4114
4664
  starlens.clear();
4115
4665
  schedules.clear();
4666
+ clearControls();
4116
4667
  },
4117
4668
  async close() {
4118
4669
  state.db = null;
@@ -4129,6 +4680,7 @@ async function createTestApp(options = {}) {
4129
4680
  edge.clear();
4130
4681
  starlens.clear();
4131
4682
  schedules.clear();
4683
+ clearControls();
4132
4684
  uninstallFetchRouter();
4133
4685
  await db.close();
4134
4686
  }