@stardeck-customer-apps/testing 0.27.0 → 0.29.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 +61 -2
- package/dist/index.d.mts +122 -1
- package/dist/index.d.ts +122 -1
- package/dist/index.js +737 -6
- package/dist/index.mjs +737 -6
- package/dist/next/headers-shim.js +14 -0
- package/dist/next/headers-shim.mjs +14 -0
- package/dist/setup.js +479 -4
- package/dist/setup.mjs +479 -4
- package/package.json +6 -1
package/dist/index.mjs
CHANGED
|
@@ -321,6 +321,12 @@ var state = globalSingleton("state", () => ({
|
|
|
321
321
|
stripePaymentIntents: /* @__PURE__ */ new Map(),
|
|
322
322
|
stripePaymentIntentCounter: 0,
|
|
323
323
|
stripeIdempotentIntents: /* @__PURE__ */ new Map(),
|
|
324
|
+
stripeRefunds: /* @__PURE__ */ new Map(),
|
|
325
|
+
stripeRefundCounter: 0,
|
|
326
|
+
stripeIdempotentRefunds: /* @__PURE__ */ new Map(),
|
|
327
|
+
beamRefunds: /* @__PURE__ */ new Map(),
|
|
328
|
+
beamRefundCounter: 0,
|
|
329
|
+
beamIdempotentRefunds: /* @__PURE__ */ new Map(),
|
|
324
330
|
stripeAllowedMethods: ["card", "promptpay"],
|
|
325
331
|
stripeChargesEnabled: true,
|
|
326
332
|
uploads: [],
|
|
@@ -342,6 +348,14 @@ var state = globalSingleton("state", () => ({
|
|
|
342
348
|
starlensCalls: [],
|
|
343
349
|
starlensReplies: [],
|
|
344
350
|
starlensDefaultReply: null,
|
|
351
|
+
controls: {
|
|
352
|
+
seed: {},
|
|
353
|
+
values: {},
|
|
354
|
+
unavailable: /* @__PURE__ */ new Set(),
|
|
355
|
+
lastUsage: null,
|
|
356
|
+
writes: [],
|
|
357
|
+
refusal: null
|
|
358
|
+
},
|
|
345
359
|
allowNetwork: false
|
|
346
360
|
}));
|
|
347
361
|
function requireDb() {
|
|
@@ -2087,7 +2101,11 @@ function decodeNextRedirect(error) {
|
|
|
2087
2101
|
var SIM_STRIPE_PUBLISHABLE_KEY = "pk_test_sim_stripe";
|
|
2088
2102
|
var SIM_STRIPE_DEFAULT_ACCOUNT = "acct_sim_default";
|
|
2089
2103
|
var METHODS = ["card", "promptpay"];
|
|
2090
|
-
var RESERVED_METADATA_KEYS = [
|
|
2104
|
+
var RESERVED_METADATA_KEYS = [
|
|
2105
|
+
"projectId",
|
|
2106
|
+
"stardeckReferenceId",
|
|
2107
|
+
"stardeckStagingDeploymentId"
|
|
2108
|
+
];
|
|
2091
2109
|
var PROMPTPAY_QR_TTL_SECONDS = 15 * 60;
|
|
2092
2110
|
var PATH_RE = /^\/api\/store\/(?!beam\/)([^/]+)\/(payment-intents(?:\/([^/]+)(?:\/(cancel))?)?|elements-config|readiness)$/;
|
|
2093
2111
|
function err(error, status, code) {
|
|
@@ -2288,6 +2306,36 @@ function createIntent(body, account, projectId) {
|
|
|
2288
2306
|
);
|
|
2289
2307
|
return json({ paymentIntent: toStripePaymentIntentSummary(intent) }, 201);
|
|
2290
2308
|
}
|
|
2309
|
+
function createHostedPaymentIntent(input) {
|
|
2310
|
+
state.stripePaymentIntentCounter += 1;
|
|
2311
|
+
const id = `pi_sim_${state.stripePaymentIntentCounter}`;
|
|
2312
|
+
const metadata = appMetadata(input.metadata);
|
|
2313
|
+
const intent = {
|
|
2314
|
+
id,
|
|
2315
|
+
projectId: input.projectId,
|
|
2316
|
+
account: "default",
|
|
2317
|
+
connectedAccountId: resolveAccount("default"),
|
|
2318
|
+
playground: false,
|
|
2319
|
+
status: "succeeded",
|
|
2320
|
+
amount: input.amount,
|
|
2321
|
+
currency: input.currency.toLowerCase(),
|
|
2322
|
+
method: "card",
|
|
2323
|
+
referenceId: input.sessionId,
|
|
2324
|
+
appMetadata: metadata,
|
|
2325
|
+
metadata: { ...metadata, projectId: input.projectId },
|
|
2326
|
+
description: null,
|
|
2327
|
+
receiptEmail: null,
|
|
2328
|
+
returnUrl: null,
|
|
2329
|
+
clientSecret: `${id}_secret_sim`,
|
|
2330
|
+
qr: null,
|
|
2331
|
+
latestChargeId: `ch_sim_${id}`,
|
|
2332
|
+
amountRefunded: 0,
|
|
2333
|
+
lastPaymentError: null,
|
|
2334
|
+
created: Math.floor(Date.now() / 1e3)
|
|
2335
|
+
};
|
|
2336
|
+
state.stripePaymentIntents.set(id, intent);
|
|
2337
|
+
return intent;
|
|
2338
|
+
}
|
|
2291
2339
|
function ownedIntent(id, projectId) {
|
|
2292
2340
|
const intent = state.stripePaymentIntents.get(id);
|
|
2293
2341
|
return intent && intent.projectId === projectId ? intent : void 0;
|
|
@@ -2425,6 +2473,485 @@ function stripePaymentIntentEvent(id) {
|
|
|
2425
2473
|
};
|
|
2426
2474
|
}
|
|
2427
2475
|
|
|
2476
|
+
// src/simulator/refunds.ts
|
|
2477
|
+
var REFUND_ROW_KEY = "refundRowId";
|
|
2478
|
+
var REFUND_METADATA_MAX_KEYS = 50 - RESERVED_METADATA_KEYS.length - 1;
|
|
2479
|
+
var REFUND_REASONS = ["duplicate", "fraudulent", "requested_by_customer"];
|
|
2480
|
+
var PROMPTPAY_BANK_DETAILS_WINDOW_SECONDS = 7 * 24 * 60 * 60;
|
|
2481
|
+
var BEAM_PARTIAL_METHODS = ["CARD", "CARD_TOKEN"];
|
|
2482
|
+
function refundFailure(plan) {
|
|
2483
|
+
return err(plan.message, plan.status, plan.code);
|
|
2484
|
+
}
|
|
2485
|
+
function isPositiveInt(value) {
|
|
2486
|
+
return typeof value === "number" && Number.isInteger(value) && value > 0;
|
|
2487
|
+
}
|
|
2488
|
+
function hasValidKey(value) {
|
|
2489
|
+
return typeof value === "string" && value.length >= 1 && value.length <= 255;
|
|
2490
|
+
}
|
|
2491
|
+
function isValidDeploymentId(value) {
|
|
2492
|
+
return typeof value === "string" && UUID_RE2.test(value);
|
|
2493
|
+
}
|
|
2494
|
+
function isValidRefundMetadata(value) {
|
|
2495
|
+
if (!isStringRecord(value)) return false;
|
|
2496
|
+
const entries = Object.entries(value);
|
|
2497
|
+
return entries.length <= REFUND_METADATA_MAX_KEYS && entries.every(
|
|
2498
|
+
([k, v]) => k.length >= 1 && k.length <= 40 && v.length <= 500 && !RESERVED_METADATA_KEYS.includes(k) && k !== REFUND_ROW_KEY
|
|
2499
|
+
);
|
|
2500
|
+
}
|
|
2501
|
+
var COUNTED_STRIPE_STATUSES = [
|
|
2502
|
+
"pending",
|
|
2503
|
+
"requires_action",
|
|
2504
|
+
"succeeded"
|
|
2505
|
+
];
|
|
2506
|
+
function refundedAmount(intentId) {
|
|
2507
|
+
let total = 0;
|
|
2508
|
+
for (const refund of state.stripeRefunds.values()) {
|
|
2509
|
+
if (refund.paymentIntentId === intentId && COUNTED_STRIPE_STATUSES.includes(refund.status)) {
|
|
2510
|
+
total += refund.amount;
|
|
2511
|
+
}
|
|
2512
|
+
}
|
|
2513
|
+
return total;
|
|
2514
|
+
}
|
|
2515
|
+
function syncAmountRefunded(intent) {
|
|
2516
|
+
intent.amountRefunded = refundedAmount(intent.id);
|
|
2517
|
+
}
|
|
2518
|
+
function validateStripeRefund(body) {
|
|
2519
|
+
const checks = [
|
|
2520
|
+
[isValidDeploymentId(body.deploymentId), "Invalid deployment ID"],
|
|
2521
|
+
[hasValidKey(body.idempotencyKey), "idempotencyKey is required (1 to 255 characters)"],
|
|
2522
|
+
[isOptional(body.amount, isPositiveInt), "amount must be a positive integer in minor units"],
|
|
2523
|
+
[
|
|
2524
|
+
isOptional(body.reason, (v) => typeof v === "string" && REFUND_REASONS.includes(v)),
|
|
2525
|
+
`reason must be one of: ${REFUND_REASONS.join(", ")}`
|
|
2526
|
+
],
|
|
2527
|
+
[isOptional(body.instructionsEmail, isEmail), "instructionsEmail must be an email address"],
|
|
2528
|
+
[
|
|
2529
|
+
isOptional(
|
|
2530
|
+
body.refundRowId,
|
|
2531
|
+
(v) => typeof v === "string" && v.length >= 1 && v.length <= 500
|
|
2532
|
+
),
|
|
2533
|
+
"refundRowId must be 1 to 500 characters"
|
|
2534
|
+
],
|
|
2535
|
+
[
|
|
2536
|
+
isOptional(body.metadata, isValidRefundMetadata),
|
|
2537
|
+
`metadata must map keys (1-40 chars) to strings (max 500), at most ${REFUND_METADATA_MAX_KEYS} keys, without the keys the platform reserves (use refundRowId instead)`
|
|
2538
|
+
]
|
|
2539
|
+
];
|
|
2540
|
+
const failed = checks.find(([ok]) => !ok);
|
|
2541
|
+
return failed ? failed[1] : null;
|
|
2542
|
+
}
|
|
2543
|
+
function planStripeRefund(intent, requested) {
|
|
2544
|
+
if (intent.status !== "succeeded") {
|
|
2545
|
+
return {
|
|
2546
|
+
ok: false,
|
|
2547
|
+
status: 409,
|
|
2548
|
+
code: "payment_not_refundable",
|
|
2549
|
+
message: "This payment hasn't succeeded, so there is nothing to refund"
|
|
2550
|
+
};
|
|
2551
|
+
}
|
|
2552
|
+
const remaining = intent.amount - refundedAmount(intent.id);
|
|
2553
|
+
const amount = requested ?? remaining;
|
|
2554
|
+
if (remaining <= 0 || amount > remaining) {
|
|
2555
|
+
return {
|
|
2556
|
+
ok: false,
|
|
2557
|
+
status: 409,
|
|
2558
|
+
code: "refund_exceeds_remaining",
|
|
2559
|
+
message: `Only ${remaining} can still be refunded on this payment`
|
|
2560
|
+
};
|
|
2561
|
+
}
|
|
2562
|
+
return { ok: true, amount };
|
|
2563
|
+
}
|
|
2564
|
+
function toStripeRefundSummary(refund) {
|
|
2565
|
+
const intent = state.stripePaymentIntents.get(refund.paymentIntentId);
|
|
2566
|
+
return {
|
|
2567
|
+
id: refund.id,
|
|
2568
|
+
paymentIntentId: refund.paymentIntentId,
|
|
2569
|
+
amount: refund.amount,
|
|
2570
|
+
currency: refund.currency,
|
|
2571
|
+
status: refund.status,
|
|
2572
|
+
failureReason: refund.failureReason,
|
|
2573
|
+
pendingReason: refund.pendingReason,
|
|
2574
|
+
nextActionExpiresAt: refund.nextActionExpiresAt,
|
|
2575
|
+
refundRowId: refund.refundRowId,
|
|
2576
|
+
metadata: { ...refund.appMetadata },
|
|
2577
|
+
created: refund.created,
|
|
2578
|
+
livemode: false,
|
|
2579
|
+
playground: intent?.playground ?? false
|
|
2580
|
+
};
|
|
2581
|
+
}
|
|
2582
|
+
function newStripeRefund(input) {
|
|
2583
|
+
state.stripeRefundCounter += 1;
|
|
2584
|
+
const refund = {
|
|
2585
|
+
id: `re_sim_${state.stripeRefundCounter}`,
|
|
2586
|
+
paymentIntentId: input.intent.id,
|
|
2587
|
+
projectId: input.intent.projectId,
|
|
2588
|
+
amount: input.amount,
|
|
2589
|
+
currency: input.intent.currency,
|
|
2590
|
+
// A refund made in the Stripe dashboard is already done; the app's waits for `settleRefund`.
|
|
2591
|
+
status: input.origin === "dashboard" ? "succeeded" : "pending",
|
|
2592
|
+
failureReason: null,
|
|
2593
|
+
pendingReason: null,
|
|
2594
|
+
nextActionExpiresAt: null,
|
|
2595
|
+
refundRowId: input.refundRowId ?? null,
|
|
2596
|
+
appMetadata: input.appMetadata ?? {},
|
|
2597
|
+
reason: input.reason ?? null,
|
|
2598
|
+
origin: input.origin,
|
|
2599
|
+
revision: 0,
|
|
2600
|
+
created: Math.floor(Date.now() / 1e3)
|
|
2601
|
+
};
|
|
2602
|
+
state.stripeRefunds.set(refund.id, refund);
|
|
2603
|
+
syncAmountRefunded(input.intent);
|
|
2604
|
+
console.log(
|
|
2605
|
+
`[stardeck-testing] stripe refund ${refund.id} created payment=${refund.paymentIntentId} amount=${refund.amount} origin=${refund.origin}`
|
|
2606
|
+
);
|
|
2607
|
+
return refund;
|
|
2608
|
+
}
|
|
2609
|
+
function createStripeRefund(body, intent, projectId) {
|
|
2610
|
+
const scopedKey = JSON.stringify(["refund", projectId, intent.account, body.idempotencyKey]);
|
|
2611
|
+
const fingerprint = stableStringify({
|
|
2612
|
+
paymentIntentId: intent.id,
|
|
2613
|
+
amount: body.amount,
|
|
2614
|
+
reason: body.reason,
|
|
2615
|
+
instructionsEmail: body.instructionsEmail,
|
|
2616
|
+
refundRowId: body.refundRowId,
|
|
2617
|
+
metadata: body.metadata
|
|
2618
|
+
});
|
|
2619
|
+
const saved = state.stripeIdempotentRefunds.get(scopedKey);
|
|
2620
|
+
if (saved) {
|
|
2621
|
+
if (saved.fingerprint !== fingerprint) {
|
|
2622
|
+
return err(
|
|
2623
|
+
"This idempotency key was already used with different parameters. Use a new key for a different refund.",
|
|
2624
|
+
409,
|
|
2625
|
+
"idempotency_conflict"
|
|
2626
|
+
);
|
|
2627
|
+
}
|
|
2628
|
+
return json({ refund: toStripeRefundSummary(state.stripeRefunds.get(saved.id)) });
|
|
2629
|
+
}
|
|
2630
|
+
const plan = planStripeRefund(intent, body.amount);
|
|
2631
|
+
if (!plan.ok) return refundFailure(plan);
|
|
2632
|
+
const refund = newStripeRefund({
|
|
2633
|
+
intent,
|
|
2634
|
+
amount: plan.amount,
|
|
2635
|
+
origin: "app",
|
|
2636
|
+
refundRowId: typeof body.refundRowId === "string" ? body.refundRowId : null,
|
|
2637
|
+
appMetadata: appMetadata(body.metadata),
|
|
2638
|
+
reason: typeof body.reason === "string" ? body.reason : null
|
|
2639
|
+
});
|
|
2640
|
+
state.stripeIdempotentRefunds.set(scopedKey, { fingerprint, id: refund.id });
|
|
2641
|
+
return json({ refund: toStripeRefundSummary(refund) });
|
|
2642
|
+
}
|
|
2643
|
+
var STRIPE_REFUND_PATH = /^\/api\/store\/(?!beam\/)[^/]+\/(?:payment-intents\/([^/]+)\/refunds|refunds\/([^/]+))$/;
|
|
2644
|
+
async function handleStripeRefundRequest(request, url, projectId) {
|
|
2645
|
+
const match = url.pathname.match(STRIPE_REFUND_PATH);
|
|
2646
|
+
if (!match) return null;
|
|
2647
|
+
const [, intentId, refundId] = match;
|
|
2648
|
+
const isCreate = request.method === "POST" && intentId !== void 0;
|
|
2649
|
+
const isRead = request.method === "GET" && refundId !== void 0;
|
|
2650
|
+
if (!isCreate && !isRead) return null;
|
|
2651
|
+
if (!projectId) return err("Missing authentication", 401, "unauthorized");
|
|
2652
|
+
if (isRead) {
|
|
2653
|
+
if (!url.searchParams.get("deploymentId")) {
|
|
2654
|
+
return err("deploymentId is required", 400, "invalid_request");
|
|
2655
|
+
}
|
|
2656
|
+
const refund = state.stripeRefunds.get(decode(refundId));
|
|
2657
|
+
if (!refund || refund.projectId !== projectId) {
|
|
2658
|
+
return err("Refund not found", 404, "refund_not_found");
|
|
2659
|
+
}
|
|
2660
|
+
return json({ refund: toStripeRefundSummary(refund) });
|
|
2661
|
+
}
|
|
2662
|
+
const body = await readJsonBody(request);
|
|
2663
|
+
const problem = validateStripeRefund(body);
|
|
2664
|
+
if (problem) return err(problem, 400, "invalid_request");
|
|
2665
|
+
const intent = ownedIntent(decode(intentId), projectId);
|
|
2666
|
+
if (!intent) return err("No such payment for this app", 404, "payment_not_owned");
|
|
2667
|
+
return createStripeRefund(body, intent, projectId);
|
|
2668
|
+
}
|
|
2669
|
+
function requireStripeRefund(id) {
|
|
2670
|
+
const refund = state.stripeRefunds.get(id);
|
|
2671
|
+
if (!refund) throw new Error(`[stardeck-testing] Unknown Stripe refund id: ${id}`);
|
|
2672
|
+
return refund;
|
|
2673
|
+
}
|
|
2674
|
+
function settleStripeRefund(refund, outcome, failureReason) {
|
|
2675
|
+
const intent = state.stripePaymentIntents.get(refund.paymentIntentId);
|
|
2676
|
+
const settled = refund.status === "failed" || refund.status === "canceled";
|
|
2677
|
+
if (settled || outcome !== "failed" && refund.status === "succeeded") {
|
|
2678
|
+
throw new Error(
|
|
2679
|
+
`[stardeck-testing] Can't settle Stripe refund ${refund.id} as ${outcome}: it is ${refund.status}`
|
|
2680
|
+
);
|
|
2681
|
+
}
|
|
2682
|
+
if (outcome === "needs_customer") {
|
|
2683
|
+
if (intent.method !== "promptpay") {
|
|
2684
|
+
throw new Error(
|
|
2685
|
+
`[stardeck-testing] Stripe refund ${refund.id} is on a ${intent.method} payment; only PromptPay refunds wait for the customer's bank details`
|
|
2686
|
+
);
|
|
2687
|
+
}
|
|
2688
|
+
refund.status = "requires_action";
|
|
2689
|
+
refund.nextActionExpiresAt = refund.created + PROMPTPAY_BANK_DETAILS_WINDOW_SECONDS;
|
|
2690
|
+
} else {
|
|
2691
|
+
refund.status = outcome;
|
|
2692
|
+
refund.nextActionExpiresAt = null;
|
|
2693
|
+
refund.pendingReason = null;
|
|
2694
|
+
refund.failureReason = outcome === "failed" ? failureReason ?? "unknown" : null;
|
|
2695
|
+
}
|
|
2696
|
+
refund.revision += 1;
|
|
2697
|
+
syncAmountRefunded(intent);
|
|
2698
|
+
return refund;
|
|
2699
|
+
}
|
|
2700
|
+
function stripeRefundEvent(id, type) {
|
|
2701
|
+
const refund = requireStripeRefund(id);
|
|
2702
|
+
const intent = state.stripePaymentIntents.get(refund.paymentIntentId);
|
|
2703
|
+
let eventType = type;
|
|
2704
|
+
if (!eventType) {
|
|
2705
|
+
if (refund.status === "failed") eventType = "refund.failed";
|
|
2706
|
+
else eventType = refund.revision === 0 ? "refund.created" : "refund.updated";
|
|
2707
|
+
}
|
|
2708
|
+
const metadata = { ...refund.appMetadata };
|
|
2709
|
+
if (refund.origin === "app") metadata.projectId = refund.projectId;
|
|
2710
|
+
if (refund.refundRowId) metadata[REFUND_ROW_KEY] = refund.refundRowId;
|
|
2711
|
+
return {
|
|
2712
|
+
type: eventType,
|
|
2713
|
+
accountId: intent.connectedAccountId ?? "acct_stardeck_playground",
|
|
2714
|
+
data: {
|
|
2715
|
+
id: refund.id,
|
|
2716
|
+
object: "refund",
|
|
2717
|
+
amount: refund.amount,
|
|
2718
|
+
currency: refund.currency,
|
|
2719
|
+
payment_intent: refund.paymentIntentId,
|
|
2720
|
+
charge: intent.latestChargeId,
|
|
2721
|
+
status: refund.status,
|
|
2722
|
+
reason: refund.reason,
|
|
2723
|
+
failure_reason: refund.failureReason,
|
|
2724
|
+
pending_reason: refund.pendingReason,
|
|
2725
|
+
next_action: refund.status === "requires_action" ? {
|
|
2726
|
+
type: "display_details",
|
|
2727
|
+
display_details: { expires_at: refund.nextActionExpiresAt }
|
|
2728
|
+
} : null,
|
|
2729
|
+
metadata,
|
|
2730
|
+
created: refund.created,
|
|
2731
|
+
livemode: false
|
|
2732
|
+
}
|
|
2733
|
+
};
|
|
2734
|
+
}
|
|
2735
|
+
function refundsOfCharge(chargeId) {
|
|
2736
|
+
return [...state.beamRefunds.values()].filter((r) => r.chargeId === chargeId);
|
|
2737
|
+
}
|
|
2738
|
+
function planBeamRefund(charge, requested) {
|
|
2739
|
+
if (charge.status !== "SUCCEEDED") {
|
|
2740
|
+
return {
|
|
2741
|
+
ok: false,
|
|
2742
|
+
status: 409,
|
|
2743
|
+
code: "payment_not_refundable",
|
|
2744
|
+
message: "This charge hasn't succeeded, so there is nothing to refund"
|
|
2745
|
+
};
|
|
2746
|
+
}
|
|
2747
|
+
const refunds = refundsOfCharge(charge.id);
|
|
2748
|
+
if (refunds.some((r) => r.status === "PENDING")) {
|
|
2749
|
+
return {
|
|
2750
|
+
ok: false,
|
|
2751
|
+
status: 409,
|
|
2752
|
+
code: "refund_in_progress",
|
|
2753
|
+
message: "This charge already has a refund in progress. Wait for it to finish."
|
|
2754
|
+
};
|
|
2755
|
+
}
|
|
2756
|
+
const refunded = refunds.filter((r) => r.status === "SUCCEEDED").reduce((n, r) => n + r.amount, 0);
|
|
2757
|
+
const remaining = charge.amount - refunded;
|
|
2758
|
+
const amount = requested ?? remaining;
|
|
2759
|
+
if (remaining <= 0 || amount > remaining) {
|
|
2760
|
+
return {
|
|
2761
|
+
ok: false,
|
|
2762
|
+
status: 409,
|
|
2763
|
+
code: "refund_exceeds_remaining",
|
|
2764
|
+
message: `Only ${remaining} can still be refunded on this charge`
|
|
2765
|
+
};
|
|
2766
|
+
}
|
|
2767
|
+
if (amount < charge.amount && !BEAM_PARTIAL_METHODS.includes(charge.paymentMethodType)) {
|
|
2768
|
+
return {
|
|
2769
|
+
ok: false,
|
|
2770
|
+
status: 409,
|
|
2771
|
+
code: "refund_partial_not_allowed",
|
|
2772
|
+
message: `${charge.paymentMethodType} charges can only be refunded in full`
|
|
2773
|
+
};
|
|
2774
|
+
}
|
|
2775
|
+
return { ok: true, amount };
|
|
2776
|
+
}
|
|
2777
|
+
function toBeamRefundSummary(refund) {
|
|
2778
|
+
return {
|
|
2779
|
+
refundId: refund.refundId,
|
|
2780
|
+
chargeId: refund.chargeId,
|
|
2781
|
+
amount: refund.amount,
|
|
2782
|
+
currency: refund.currency,
|
|
2783
|
+
status: refund.status,
|
|
2784
|
+
failureCode: refund.failureCode,
|
|
2785
|
+
reason: refund.reason,
|
|
2786
|
+
createdAt: refund.createdAt
|
|
2787
|
+
};
|
|
2788
|
+
}
|
|
2789
|
+
function newBeamRefund(input) {
|
|
2790
|
+
state.beamRefundCounter += 1;
|
|
2791
|
+
const refund = {
|
|
2792
|
+
refundId: `rf_sim_${state.beamRefundCounter}`,
|
|
2793
|
+
chargeId: input.charge.id,
|
|
2794
|
+
amount: input.amount,
|
|
2795
|
+
currency: input.charge.currency,
|
|
2796
|
+
status: input.origin === "dashboard" ? "SUCCEEDED" : "PENDING",
|
|
2797
|
+
failureCode: null,
|
|
2798
|
+
reason: input.reason ?? null,
|
|
2799
|
+
origin: input.origin,
|
|
2800
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2801
|
+
};
|
|
2802
|
+
state.beamRefunds.set(refund.refundId, refund);
|
|
2803
|
+
console.log(
|
|
2804
|
+
`[stardeck-testing] beam refund ${refund.refundId} created charge=${refund.chargeId} amount=${refund.amount} origin=${refund.origin}`
|
|
2805
|
+
);
|
|
2806
|
+
return refund;
|
|
2807
|
+
}
|
|
2808
|
+
function validateBeamRefund(body) {
|
|
2809
|
+
const checks = [
|
|
2810
|
+
[isValidDeploymentId(body.deploymentId), "Invalid deployment ID"],
|
|
2811
|
+
[hasValidKey(body.idempotencyKey), "idempotencyKey is required (1 to 255 characters)"],
|
|
2812
|
+
[isOptional(body.amount, isPositiveInt), "amount must be a positive integer in satang"],
|
|
2813
|
+
[
|
|
2814
|
+
isOptional(body.reason, (v) => typeof v === "string" && v.length <= 500),
|
|
2815
|
+
"reason must be at most 500 characters"
|
|
2816
|
+
]
|
|
2817
|
+
];
|
|
2818
|
+
const failed = checks.find(([ok]) => !ok);
|
|
2819
|
+
return failed ? failed[1] : null;
|
|
2820
|
+
}
|
|
2821
|
+
function createBeamRefund(body, charge, merchant) {
|
|
2822
|
+
const scopedKey = JSON.stringify(["beam-refund", merchant, body.idempotencyKey]);
|
|
2823
|
+
const fingerprint = stableStringify({
|
|
2824
|
+
chargeId: charge.id,
|
|
2825
|
+
amount: body.amount,
|
|
2826
|
+
reason: body.reason
|
|
2827
|
+
});
|
|
2828
|
+
const saved = state.beamIdempotentRefunds.get(scopedKey);
|
|
2829
|
+
if (saved) {
|
|
2830
|
+
if (saved.fingerprint !== fingerprint) {
|
|
2831
|
+
return err(
|
|
2832
|
+
"This idempotency key was already used with different parameters. Use a new key for a different refund.",
|
|
2833
|
+
409,
|
|
2834
|
+
"idempotency_conflict"
|
|
2835
|
+
);
|
|
2836
|
+
}
|
|
2837
|
+
return json({ refund: toBeamRefundSummary(state.beamRefunds.get(saved.id)) });
|
|
2838
|
+
}
|
|
2839
|
+
const plan = planBeamRefund(charge, body.amount);
|
|
2840
|
+
if (!plan.ok) return refundFailure(plan);
|
|
2841
|
+
const refund = newBeamRefund({
|
|
2842
|
+
charge,
|
|
2843
|
+
amount: plan.amount,
|
|
2844
|
+
origin: "app",
|
|
2845
|
+
reason: typeof body.reason === "string" ? body.reason : null
|
|
2846
|
+
});
|
|
2847
|
+
state.beamIdempotentRefunds.set(scopedKey, { fingerprint, id: refund.refundId });
|
|
2848
|
+
return json({ refund: toBeamRefundSummary(refund) });
|
|
2849
|
+
}
|
|
2850
|
+
var BEAM_REFUND_PATH = /^\/api\/store\/beam\/([^/]+)\/(?:charges\/([^/]+)\/refunds|refunds\/([^/]+))$/;
|
|
2851
|
+
async function handleBeamRefundRequest(request, url) {
|
|
2852
|
+
const match = url.pathname.match(BEAM_REFUND_PATH);
|
|
2853
|
+
if (!match) return null;
|
|
2854
|
+
const [, merchant, chargeId, refundId] = match;
|
|
2855
|
+
const isCreate = request.method === "POST" && chargeId !== void 0;
|
|
2856
|
+
const isRead = request.method === "GET" && refundId !== void 0;
|
|
2857
|
+
if (!isCreate && !isRead) return null;
|
|
2858
|
+
if (isRead) {
|
|
2859
|
+
if (!url.searchParams.get("deploymentId")) {
|
|
2860
|
+
return err("deploymentId is required", 400, "invalid_request");
|
|
2861
|
+
}
|
|
2862
|
+
const refund = state.beamRefunds.get(decode(refundId));
|
|
2863
|
+
if (!refund) return err("Refund not found", 404, "refund_not_found");
|
|
2864
|
+
return json({ refund: toBeamRefundSummary(refund) });
|
|
2865
|
+
}
|
|
2866
|
+
const body = await readJsonBody(request);
|
|
2867
|
+
const problem = validateBeamRefund(body);
|
|
2868
|
+
if (problem) return err(problem, 400, "invalid_request");
|
|
2869
|
+
const charge = state.directCharges.get(decode(chargeId));
|
|
2870
|
+
if (!charge) return err("No such charge for this app", 404, "payment_not_owned");
|
|
2871
|
+
return createBeamRefund(body, charge, decode(merchant));
|
|
2872
|
+
}
|
|
2873
|
+
function requireBeamRefund(id) {
|
|
2874
|
+
const refund = state.beamRefunds.get(id);
|
|
2875
|
+
if (!refund) throw new Error(`[stardeck-testing] Unknown Beam refund id: ${id}`);
|
|
2876
|
+
return refund;
|
|
2877
|
+
}
|
|
2878
|
+
function settleBeamRefund(refund, outcome, failureCode) {
|
|
2879
|
+
if (outcome === "needs_customer") {
|
|
2880
|
+
throw new Error(
|
|
2881
|
+
`[stardeck-testing] Beam refund ${refund.refundId} can't need the customer: Beam refunds have no customer step (only Stripe PromptPay does)`
|
|
2882
|
+
);
|
|
2883
|
+
}
|
|
2884
|
+
if (refund.status !== "PENDING") {
|
|
2885
|
+
throw new Error(
|
|
2886
|
+
`[stardeck-testing] Can't settle Beam refund ${refund.refundId} as ${outcome}: it is ${refund.status}, and Beam refund statuses are final`
|
|
2887
|
+
);
|
|
2888
|
+
}
|
|
2889
|
+
refund.status = outcome === "succeeded" ? "SUCCEEDED" : "FAILED";
|
|
2890
|
+
refund.failureCode = outcome === "failed" ? failureCode ?? "REFUND_FAILED" : null;
|
|
2891
|
+
return refund;
|
|
2892
|
+
}
|
|
2893
|
+
function beamRefundEvent(id) {
|
|
2894
|
+
const refund = requireBeamRefund(id);
|
|
2895
|
+
if (refund.status === "PENDING") {
|
|
2896
|
+
throw new Error(
|
|
2897
|
+
`[stardeck-testing] Beam refund ${id} is PENDING and has no event yet; call settleRefund first`
|
|
2898
|
+
);
|
|
2899
|
+
}
|
|
2900
|
+
return {
|
|
2901
|
+
type: refund.status === "SUCCEEDED" ? "refund.succeeded" : "refund.failed",
|
|
2902
|
+
payload: {
|
|
2903
|
+
refundId: refund.refundId,
|
|
2904
|
+
chargeId: refund.chargeId,
|
|
2905
|
+
merchantId: SIM_BEAM_MERCHANT_ID,
|
|
2906
|
+
status: refund.status,
|
|
2907
|
+
amount: refund.amount,
|
|
2908
|
+
currency: refund.currency,
|
|
2909
|
+
...refund.failureCode ? { failureCode: refund.failureCode } : {},
|
|
2910
|
+
...refund.reason ? { reason: refund.reason } : {},
|
|
2911
|
+
createdAt: refund.createdAt
|
|
2912
|
+
}
|
|
2913
|
+
};
|
|
2914
|
+
}
|
|
2915
|
+
function settleRefund(refundId, outcome, options) {
|
|
2916
|
+
const stripe = state.stripeRefunds.get(refundId);
|
|
2917
|
+
if (stripe) settleStripeRefund(stripe, outcome, options?.failureReason);
|
|
2918
|
+
else settleBeamRefund(requireBeamRefund(refundId), outcome, options?.failureReason);
|
|
2919
|
+
}
|
|
2920
|
+
function dashboardRefund(paymentRef, amount) {
|
|
2921
|
+
const intent = state.stripePaymentIntents.get(paymentRef);
|
|
2922
|
+
if (intent) {
|
|
2923
|
+
const plan = planStripeRefund(intent, amount);
|
|
2924
|
+
if (!plan.ok) throw dashboardRefused(paymentRef, plan);
|
|
2925
|
+
return newStripeRefund({ intent, amount: plan.amount, origin: "dashboard" }).id;
|
|
2926
|
+
}
|
|
2927
|
+
const charge = state.directCharges.get(paymentRef);
|
|
2928
|
+
if (charge) {
|
|
2929
|
+
const plan = planBeamRefund(charge, amount);
|
|
2930
|
+
if (!plan.ok) throw dashboardRefused(paymentRef, plan);
|
|
2931
|
+
return newBeamRefund({ charge, amount: plan.amount, origin: "dashboard" }).refundId;
|
|
2932
|
+
}
|
|
2933
|
+
throw new Error(
|
|
2934
|
+
`[stardeck-testing] dashboardRefund: unknown payment ${paymentRef}; pass a Stripe PaymentIntent id or a Beam direct charge id`
|
|
2935
|
+
);
|
|
2936
|
+
}
|
|
2937
|
+
function dashboardRefused(paymentRef, plan) {
|
|
2938
|
+
return new Error(
|
|
2939
|
+
`[stardeck-testing] dashboardRefund of ${paymentRef} refused (${plan.code}): ${plan.message}`
|
|
2940
|
+
);
|
|
2941
|
+
}
|
|
2942
|
+
function findCheckoutSessionByIntent(url, projectId) {
|
|
2943
|
+
if (!projectId) return err("Missing authentication", 401, "unauthorized");
|
|
2944
|
+
if (!url.searchParams.get("deploymentId")) {
|
|
2945
|
+
return err("deploymentId is required", 400, "invalid_request");
|
|
2946
|
+
}
|
|
2947
|
+
const intentId = url.searchParams.get("paymentIntentId");
|
|
2948
|
+
if (!intentId) return err("paymentIntentId is required", 400, "invalid_request");
|
|
2949
|
+
const intent = state.stripePaymentIntents.get(intentId);
|
|
2950
|
+
if (intent && intent.projectId !== projectId) return json({ session: null });
|
|
2951
|
+
const session = [...state.sessionStatuses.values()].find((s) => s.paymentIntentId === intentId);
|
|
2952
|
+
return json({ session: session ?? null });
|
|
2953
|
+
}
|
|
2954
|
+
|
|
2428
2955
|
// src/simulator/webhook-signing.ts
|
|
2429
2956
|
import crypto4 from "crypto";
|
|
2430
2957
|
function signEventDelivery(secret, context, rawBody) {
|
|
@@ -2484,6 +3011,15 @@ function paymentOperationForRequest(request, pathname) {
|
|
|
2484
3011
|
if (request.method === "GET" && /^\/api\/store\/(?!beam\/)[^/]+\/checkout-sessions\/[^/]+$/.test(pathname)) {
|
|
2485
3012
|
return "stripe.getCheckoutSessionStatus";
|
|
2486
3013
|
}
|
|
3014
|
+
if (request.method === "POST" && /^\/api\/store\/beam\/[^/]+\/charges\/[^/]+\/refunds$/.test(pathname)) {
|
|
3015
|
+
return "beam.refundCharge";
|
|
3016
|
+
}
|
|
3017
|
+
if (request.method === "GET" && /^\/api\/store\/beam\/[^/]+\/refunds\/[^/]+$/.test(pathname)) {
|
|
3018
|
+
return "beam.getRefund";
|
|
3019
|
+
}
|
|
3020
|
+
if (request.method === "GET" && /^\/api\/store\/(?!beam\/)[^/]+\/checkout-sessions$/.test(pathname)) {
|
|
3021
|
+
return "stripe.findCheckoutSessionByIntent";
|
|
3022
|
+
}
|
|
2487
3023
|
return stripeInlineOperation(request.method, pathname);
|
|
2488
3024
|
}
|
|
2489
3025
|
var STRIPE_INLINE_OPERATIONS = [
|
|
@@ -2495,6 +3031,12 @@ var STRIPE_INLINE_OPERATIONS = [
|
|
|
2495
3031
|
/^\/api\/store\/(?!beam\/)[^/]+\/payment-intents\/[^/]+\/cancel$/,
|
|
2496
3032
|
"stripe.cancelPaymentIntent"
|
|
2497
3033
|
],
|
|
3034
|
+
[
|
|
3035
|
+
"POST",
|
|
3036
|
+
/^\/api\/store\/(?!beam\/)[^/]+\/payment-intents\/[^/]+\/refunds$/,
|
|
3037
|
+
"stripe.refundPaymentIntent"
|
|
3038
|
+
],
|
|
3039
|
+
["GET", /^\/api\/store\/(?!beam\/)[^/]+\/refunds\/[^/]+$/, "stripe.getRefund"],
|
|
2498
3040
|
["GET", /^\/api\/store\/(?!beam\/)[^/]+\/elements-config$/, "stripe.getElementsConfig"],
|
|
2499
3041
|
["GET", /^\/api\/store\/(?!beam\/)[^/]+\/readiness$/, "stripe.getReadiness"]
|
|
2500
3042
|
];
|
|
@@ -2579,7 +3121,8 @@ function seedStripeSession(id, body, expiresAt) {
|
|
|
2579
3121
|
currency,
|
|
2580
3122
|
customerEmail: body.customerEmail ? String(body.customerEmail) : null,
|
|
2581
3123
|
metadata: body.metadata ?? {},
|
|
2582
|
-
expiresAt
|
|
3124
|
+
expiresAt,
|
|
3125
|
+
paymentIntentId: null
|
|
2583
3126
|
});
|
|
2584
3127
|
}
|
|
2585
3128
|
function seedBeamLink(id, body, merchantId) {
|
|
@@ -2685,6 +3228,10 @@ async function handlePaymentsRequest(request, url, signedProjectId = null) {
|
|
|
2685
3228
|
signedProjectId
|
|
2686
3229
|
);
|
|
2687
3230
|
if (inlineStripeResponse) return inlineStripeResponse;
|
|
3231
|
+
const stripeRefundResponse = await handleStripeRefundRequest(request, url, signedProjectId);
|
|
3232
|
+
if (stripeRefundResponse) return stripeRefundResponse;
|
|
3233
|
+
const beamRefundResponse = await handleBeamRefundRequest(request, url);
|
|
3234
|
+
if (beamRefundResponse) return beamRefundResponse;
|
|
2688
3235
|
const directChargeResponse = await handleDirectChargeRequest(request, pathname);
|
|
2689
3236
|
if (directChargeResponse) return directChargeResponse;
|
|
2690
3237
|
if (/\/billing-portal$/.test(pathname)) {
|
|
@@ -3006,6 +3553,9 @@ async function handlePaymentsRequest(request, url, signedProjectId = null) {
|
|
|
3006
3553
|
if (scopedKey) state.idempotentCheckouts.set(scopedKey, { fingerprint, response });
|
|
3007
3554
|
return json(response);
|
|
3008
3555
|
}
|
|
3556
|
+
if (subPath === "checkout-sessions" && request.method === "GET") {
|
|
3557
|
+
return findCheckoutSessionByIntent(url, signedProjectId);
|
|
3558
|
+
}
|
|
3009
3559
|
const sessionMatch = subPath.match(/^checkout-sessions\/([^/]+)$/);
|
|
3010
3560
|
if (sessionMatch && request.method === "GET") {
|
|
3011
3561
|
const session = state.sessionStatuses.get(sessionMatch[1]);
|
|
@@ -3015,6 +3565,17 @@ async function handlePaymentsRequest(request, url, signedProjectId = null) {
|
|
|
3015
3565
|
}
|
|
3016
3566
|
return payErr(`No payments simulator for ${request.method} ${pathname}`, 404);
|
|
3017
3567
|
}
|
|
3568
|
+
function attachHostedPayment(session) {
|
|
3569
|
+
if (session.paymentIntentId || session.mode !== "payment") return;
|
|
3570
|
+
if (session.amountTotal === null || session.currency === null) return;
|
|
3571
|
+
session.paymentIntentId = createHostedPaymentIntent({
|
|
3572
|
+
projectId: TEST_ENV_DEFAULTS.PROJECT_ID,
|
|
3573
|
+
sessionId: session.id,
|
|
3574
|
+
amount: session.amountTotal,
|
|
3575
|
+
currency: session.currency,
|
|
3576
|
+
metadata: session.metadata
|
|
3577
|
+
}).id;
|
|
3578
|
+
}
|
|
3018
3579
|
function requirePendingDirectCharge(chargeId) {
|
|
3019
3580
|
const charge = state.directCharges.get(chargeId);
|
|
3020
3581
|
if (!charge) {
|
|
@@ -3050,6 +3611,7 @@ function createPayments() {
|
|
|
3050
3611
|
if (session) {
|
|
3051
3612
|
session.status = "complete";
|
|
3052
3613
|
session.paymentStatus = "paid";
|
|
3614
|
+
attachHostedPayment(session);
|
|
3053
3615
|
return;
|
|
3054
3616
|
}
|
|
3055
3617
|
const link = state.paymentLinks.get(id);
|
|
@@ -3092,7 +3654,10 @@ function createPayments() {
|
|
|
3092
3654
|
state.stripeChargesEnabled = enabled;
|
|
3093
3655
|
},
|
|
3094
3656
|
async deliverStripeEvent(handler, input, options) {
|
|
3095
|
-
|
|
3657
|
+
let event;
|
|
3658
|
+
if ("paymentIntentId" in input) event = stripePaymentIntentEvent(input.paymentIntentId);
|
|
3659
|
+
else if ("refundId" in input) event = stripeRefundEvent(input.refundId, input.type);
|
|
3660
|
+
else event = input;
|
|
3096
3661
|
const envelope = {
|
|
3097
3662
|
id: `evt_test_${crypto5.randomUUID()}`,
|
|
3098
3663
|
kind: "stripe_webhook",
|
|
@@ -3105,7 +3670,8 @@ function createPayments() {
|
|
|
3105
3670
|
};
|
|
3106
3671
|
return deliverEvent(envelope, handler, options);
|
|
3107
3672
|
},
|
|
3108
|
-
async deliverBeamEvent(handler,
|
|
3673
|
+
async deliverBeamEvent(handler, input, options) {
|
|
3674
|
+
const event = "refundId" in input ? beamRefundEvent(input.refundId) : input;
|
|
3109
3675
|
const envelope = {
|
|
3110
3676
|
id: `beam_evt_test_${crypto5.randomUUID()}`,
|
|
3111
3677
|
kind: "beam_webhook",
|
|
@@ -3117,6 +3683,20 @@ function createPayments() {
|
|
|
3117
3683
|
};
|
|
3118
3684
|
return deliverEvent(envelope, handler, options);
|
|
3119
3685
|
},
|
|
3686
|
+
get stripeRefunds() {
|
|
3687
|
+
return [...state.stripeRefunds.values()];
|
|
3688
|
+
},
|
|
3689
|
+
getStripeRefund(refundId) {
|
|
3690
|
+
return state.stripeRefunds.get(refundId);
|
|
3691
|
+
},
|
|
3692
|
+
get beamRefunds() {
|
|
3693
|
+
return [...state.beamRefunds.values()];
|
|
3694
|
+
},
|
|
3695
|
+
getBeamRefund(refundId) {
|
|
3696
|
+
return state.beamRefunds.get(refundId);
|
|
3697
|
+
},
|
|
3698
|
+
settleRefund,
|
|
3699
|
+
dashboardRefund,
|
|
3120
3700
|
approveBoltIntent(intentId, options) {
|
|
3121
3701
|
const intent = requirePendingBoltIntent(intentId);
|
|
3122
3702
|
const chargeId = options?.chargeId ?? nextBoltChargeId();
|
|
@@ -3211,6 +3791,12 @@ function createPayments() {
|
|
|
3211
3791
|
state.stripePaymentIntents.clear();
|
|
3212
3792
|
state.stripePaymentIntentCounter = 0;
|
|
3213
3793
|
state.stripeIdempotentIntents.clear();
|
|
3794
|
+
state.stripeRefunds.clear();
|
|
3795
|
+
state.stripeRefundCounter = 0;
|
|
3796
|
+
state.stripeIdempotentRefunds.clear();
|
|
3797
|
+
state.beamRefunds.clear();
|
|
3798
|
+
state.beamRefundCounter = 0;
|
|
3799
|
+
state.beamIdempotentRefunds.clear();
|
|
3214
3800
|
state.stripeAllowedMethods = ["card", "promptpay"];
|
|
3215
3801
|
state.stripeChargesEnabled = true;
|
|
3216
3802
|
},
|
|
@@ -3985,6 +4571,141 @@ function createEdge() {
|
|
|
3985
4571
|
};
|
|
3986
4572
|
}
|
|
3987
4573
|
|
|
4574
|
+
// src/simulator/controls.ts
|
|
4575
|
+
var CONTROLS_STATE_SYMBOL = /* @__PURE__ */ Symbol.for("stardeck.controls.state");
|
|
4576
|
+
var USER_TOKEN_HEADER = "x-stardeck-user-token";
|
|
4577
|
+
var DEFAULTS = {
|
|
4578
|
+
"flag.paymentsSetupStep": false,
|
|
4579
|
+
"plan.locations": { included: 3, max: null, extraMonthly: { amount: 990, currency: "THB" } },
|
|
4580
|
+
"plan.multiEntity": true,
|
|
4581
|
+
"payments.provider": "beam",
|
|
4582
|
+
"payments.methods": { card: true, promptPay: true }
|
|
4583
|
+
};
|
|
4584
|
+
var REFUSAL_STATUS = {
|
|
4585
|
+
NOT_WRITABLE: 400,
|
|
4586
|
+
INVALID_VALUE: 400,
|
|
4587
|
+
UNAUTHENTICATED: 401,
|
|
4588
|
+
FORBIDDEN: 403,
|
|
4589
|
+
WRITE_NOT_ALLOWED_HERE: 403,
|
|
4590
|
+
PROVIDER_NOT_READY: 409,
|
|
4591
|
+
UNAVAILABLE: 503
|
|
4592
|
+
};
|
|
4593
|
+
var controlsModule = null;
|
|
4594
|
+
function loadControls() {
|
|
4595
|
+
controlsModule ??= import("@stardeck-customer-apps/controls");
|
|
4596
|
+
return controlsModule;
|
|
4597
|
+
}
|
|
4598
|
+
function resetControlsClientCache() {
|
|
4599
|
+
const clientState = globalThis[CONTROLS_STATE_SYMBOL];
|
|
4600
|
+
clientState?.reset?.();
|
|
4601
|
+
}
|
|
4602
|
+
function initControls(seed) {
|
|
4603
|
+
state.controls.seed = { ...seed };
|
|
4604
|
+
restoreControls();
|
|
4605
|
+
}
|
|
4606
|
+
function restoreControls() {
|
|
4607
|
+
state.controls.values = { ...DEFAULTS, ...state.controls.seed };
|
|
4608
|
+
state.controls.unavailable = /* @__PURE__ */ new Set();
|
|
4609
|
+
state.controls.lastUsage = null;
|
|
4610
|
+
state.controls.writes = [];
|
|
4611
|
+
state.controls.refusal = null;
|
|
4612
|
+
resetControlsClientCache();
|
|
4613
|
+
}
|
|
4614
|
+
function clearControls() {
|
|
4615
|
+
restoreControls();
|
|
4616
|
+
}
|
|
4617
|
+
function currentControlsBody() {
|
|
4618
|
+
const controls = {};
|
|
4619
|
+
for (const [key, value] of Object.entries(state.controls.values)) {
|
|
4620
|
+
controls[key] = state.controls.unavailable.has(key) ? { ok: false, reason: "unavailable (simulated)" } : { ok: true, value };
|
|
4621
|
+
}
|
|
4622
|
+
return controls;
|
|
4623
|
+
}
|
|
4624
|
+
function refusalResponse(refusal) {
|
|
4625
|
+
return json({ error: refusal }, REFUSAL_STATUS[refusal.code]);
|
|
4626
|
+
}
|
|
4627
|
+
function handleSync(body) {
|
|
4628
|
+
const usage = body.usage;
|
|
4629
|
+
if (typeof usage === "object" && usage !== null) {
|
|
4630
|
+
const locations = usage.locations;
|
|
4631
|
+
state.controls.lastUsage = typeof locations === "number" && Number.isInteger(locations) && locations >= 0 ? { locations } : {};
|
|
4632
|
+
}
|
|
4633
|
+
return json({ controls: currentControlsBody() });
|
|
4634
|
+
}
|
|
4635
|
+
async function handleWrite(request, key) {
|
|
4636
|
+
const { CONTROLS, isWritableControl } = await loadControls();
|
|
4637
|
+
if (!isWritableControl(key)) {
|
|
4638
|
+
return refusalResponse({
|
|
4639
|
+
code: "NOT_WRITABLE",
|
|
4640
|
+
message: `${key} cannot be changed from the app`
|
|
4641
|
+
});
|
|
4642
|
+
}
|
|
4643
|
+
const userToken = request.headers.get(USER_TOKEN_HEADER);
|
|
4644
|
+
if (!userToken) {
|
|
4645
|
+
return refusalResponse({
|
|
4646
|
+
code: "UNAUTHENTICATED",
|
|
4647
|
+
message: "A signed-in person is required (X-Stardeck-User-Token)"
|
|
4648
|
+
});
|
|
4649
|
+
}
|
|
4650
|
+
const body = await readJsonBody(request);
|
|
4651
|
+
const parsed = CONTROLS[key].schema.safeParse(body.value);
|
|
4652
|
+
if (!parsed.success) {
|
|
4653
|
+
return refusalResponse({
|
|
4654
|
+
code: "INVALID_VALUE",
|
|
4655
|
+
message: parsed.error.issues[0]?.message ?? "Invalid value"
|
|
4656
|
+
});
|
|
4657
|
+
}
|
|
4658
|
+
const refusal = state.controls.refusal;
|
|
4659
|
+
if (refusal) {
|
|
4660
|
+
state.controls.refusal = null;
|
|
4661
|
+
return refusalResponse(refusal);
|
|
4662
|
+
}
|
|
4663
|
+
state.controls.values[key] = parsed.data;
|
|
4664
|
+
state.controls.unavailable.delete(key);
|
|
4665
|
+
state.controls.writes.push({ key, value: parsed.data, userToken });
|
|
4666
|
+
return json({ controls: currentControlsBody() });
|
|
4667
|
+
}
|
|
4668
|
+
var CONTROLS_PATH = /^\/api\/deployments\/[^/]+\/controls(?:\/([^/]+))?$/;
|
|
4669
|
+
async function handleControlsRequest(request, url) {
|
|
4670
|
+
const match = url.pathname.match(CONTROLS_PATH);
|
|
4671
|
+
const key = match?.[1] ? decodeURIComponent(match[1]) : null;
|
|
4672
|
+
if (!key && request.method === "POST") {
|
|
4673
|
+
return handleSync(await readJsonBody(request));
|
|
4674
|
+
}
|
|
4675
|
+
if (key && request.method === "PUT") {
|
|
4676
|
+
return handleWrite(request, key);
|
|
4677
|
+
}
|
|
4678
|
+
return json(
|
|
4679
|
+
{
|
|
4680
|
+
error: { code: "UNAVAILABLE", message: `No simulator for ${request.method} ${url.pathname}` }
|
|
4681
|
+
},
|
|
4682
|
+
404
|
|
4683
|
+
);
|
|
4684
|
+
}
|
|
4685
|
+
function createControls() {
|
|
4686
|
+
return {
|
|
4687
|
+
set(key, value) {
|
|
4688
|
+
state.controls.values[key] = value;
|
|
4689
|
+
state.controls.unavailable.delete(key);
|
|
4690
|
+
resetControlsClientCache();
|
|
4691
|
+
},
|
|
4692
|
+
setUnavailable(key) {
|
|
4693
|
+
state.controls.unavailable.add(key);
|
|
4694
|
+
resetControlsClientCache();
|
|
4695
|
+
},
|
|
4696
|
+
lastUsage() {
|
|
4697
|
+
return state.controls.lastUsage;
|
|
4698
|
+
},
|
|
4699
|
+
writes() {
|
|
4700
|
+
return state.controls.writes;
|
|
4701
|
+
},
|
|
4702
|
+
refuseNextWrite(refusal) {
|
|
4703
|
+
state.controls.refusal = refusal;
|
|
4704
|
+
},
|
|
4705
|
+
clear: restoreControls
|
|
4706
|
+
};
|
|
4707
|
+
}
|
|
4708
|
+
|
|
3988
4709
|
// src/simulator/schedules.ts
|
|
3989
4710
|
async function handleScheduleWake(request, scheduleKey) {
|
|
3990
4711
|
if (request.method !== "POST") return failure(`Method ${request.method} not allowed`, 405);
|
|
@@ -4176,8 +4897,10 @@ function requiresDeploymentHmac(request, url) {
|
|
|
4176
4897
|
const edgeMatch = url.pathname.match(/^\/api\/deployments\/[^/]+\/edge(\/.*)?$/);
|
|
4177
4898
|
const isStarlens = url.pathname === "/api/starlens/extract";
|
|
4178
4899
|
const scheduleWakeMatch = url.pathname.match(SCHEDULE_WAKE_PATH);
|
|
4179
|
-
|
|
4900
|
+
const controlsMatch = url.pathname.match(CONTROLS_PATH2);
|
|
4901
|
+
return !!(controlsMatch || isStarlens || scheduleWakeMatch || dataStoreMatch || isEmail2 || identitiesMatch || storeMatch || messagingMatch || edgeMatch);
|
|
4180
4902
|
}
|
|
4903
|
+
var CONTROLS_PATH2 = /^\/api\/deployments\/[^/]+\/controls(\/[^/]+)?$/;
|
|
4181
4904
|
var SCHEDULE_WAKE_PATH = /^\/api\/deployments\/[^/]+\/schedules\/([^/]+)\/wake$/;
|
|
4182
4905
|
function deploymentIdFromPath(url) {
|
|
4183
4906
|
const match = url.pathname.match(/\/api\/deployments\/([^/]+)(?:\/|$)/);
|
|
@@ -4245,6 +4968,9 @@ async function handleSimulatedRequest(request, url) {
|
|
|
4245
4968
|
if (edgeMatch) {
|
|
4246
4969
|
return handleEdgeRequest(request, edgeMatch[1] ?? "");
|
|
4247
4970
|
}
|
|
4971
|
+
if (CONTROLS_PATH2.test(url.pathname)) {
|
|
4972
|
+
return handleControlsRequest(request, url);
|
|
4973
|
+
}
|
|
4248
4974
|
const scheduleWakeMatch = url.pathname.match(SCHEDULE_WAKE_PATH);
|
|
4249
4975
|
if (scheduleWakeMatch) {
|
|
4250
4976
|
return handleScheduleWake(request, decodeURIComponent(scheduleWakeMatch[1]));
|
|
@@ -4273,7 +4999,7 @@ async function handleSimulatedRequest(request, url) {
|
|
|
4273
4999
|
}
|
|
4274
5000
|
}
|
|
4275
5001
|
return failure(
|
|
4276
|
-
`[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.`,
|
|
5002
|
+
`[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.`,
|
|
4277
5003
|
404
|
|
4278
5004
|
);
|
|
4279
5005
|
}
|
|
@@ -4422,6 +5148,7 @@ async function createTestApp(options = {}) {
|
|
|
4422
5148
|
const initialLiffId = options.liffId ?? null;
|
|
4423
5149
|
state.liffId = initialLiffId;
|
|
4424
5150
|
installFetchRouter();
|
|
5151
|
+
initControls(options.controls);
|
|
4425
5152
|
const schemaSql = loadSchemaSql(options) ?? "";
|
|
4426
5153
|
const db = await bootWithSchema(schemaSql);
|
|
4427
5154
|
state.db = db;
|
|
@@ -4453,6 +5180,7 @@ async function createTestApp(options = {}) {
|
|
|
4453
5180
|
const edge = createEdge();
|
|
4454
5181
|
const starlens = createStarlens();
|
|
4455
5182
|
const schedules = createSchedules();
|
|
5183
|
+
const controls = createControls();
|
|
4456
5184
|
const app = {
|
|
4457
5185
|
db,
|
|
4458
5186
|
inbox,
|
|
@@ -4463,6 +5191,7 @@ async function createTestApp(options = {}) {
|
|
|
4463
5191
|
edge,
|
|
4464
5192
|
starlens,
|
|
4465
5193
|
schedules,
|
|
5194
|
+
controls,
|
|
4466
5195
|
async query(sql, params = []) {
|
|
4467
5196
|
const result = await db.query(sql, params);
|
|
4468
5197
|
return result.rows;
|
|
@@ -4512,6 +5241,7 @@ async function createTestApp(options = {}) {
|
|
|
4512
5241
|
edge.clear();
|
|
4513
5242
|
starlens.clear();
|
|
4514
5243
|
schedules.clear();
|
|
5244
|
+
clearControls();
|
|
4515
5245
|
},
|
|
4516
5246
|
async close() {
|
|
4517
5247
|
state.db = null;
|
|
@@ -4528,6 +5258,7 @@ async function createTestApp(options = {}) {
|
|
|
4528
5258
|
edge.clear();
|
|
4529
5259
|
starlens.clear();
|
|
4530
5260
|
schedules.clear();
|
|
5261
|
+
clearControls();
|
|
4531
5262
|
uninstallFetchRouter();
|
|
4532
5263
|
await db.close();
|
|
4533
5264
|
}
|