@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.js
CHANGED
|
@@ -360,6 +360,12 @@ var state = globalSingleton("state", () => ({
|
|
|
360
360
|
stripePaymentIntents: /* @__PURE__ */ new Map(),
|
|
361
361
|
stripePaymentIntentCounter: 0,
|
|
362
362
|
stripeIdempotentIntents: /* @__PURE__ */ new Map(),
|
|
363
|
+
stripeRefunds: /* @__PURE__ */ new Map(),
|
|
364
|
+
stripeRefundCounter: 0,
|
|
365
|
+
stripeIdempotentRefunds: /* @__PURE__ */ new Map(),
|
|
366
|
+
beamRefunds: /* @__PURE__ */ new Map(),
|
|
367
|
+
beamRefundCounter: 0,
|
|
368
|
+
beamIdempotentRefunds: /* @__PURE__ */ new Map(),
|
|
363
369
|
stripeAllowedMethods: ["card", "promptpay"],
|
|
364
370
|
stripeChargesEnabled: true,
|
|
365
371
|
uploads: [],
|
|
@@ -381,6 +387,14 @@ var state = globalSingleton("state", () => ({
|
|
|
381
387
|
starlensCalls: [],
|
|
382
388
|
starlensReplies: [],
|
|
383
389
|
starlensDefaultReply: null,
|
|
390
|
+
controls: {
|
|
391
|
+
seed: {},
|
|
392
|
+
values: {},
|
|
393
|
+
unavailable: /* @__PURE__ */ new Set(),
|
|
394
|
+
lastUsage: null,
|
|
395
|
+
writes: [],
|
|
396
|
+
refusal: null
|
|
397
|
+
},
|
|
384
398
|
allowNetwork: false
|
|
385
399
|
}));
|
|
386
400
|
function requireDb() {
|
|
@@ -2126,7 +2140,11 @@ function decodeNextRedirect(error) {
|
|
|
2126
2140
|
var SIM_STRIPE_PUBLISHABLE_KEY = "pk_test_sim_stripe";
|
|
2127
2141
|
var SIM_STRIPE_DEFAULT_ACCOUNT = "acct_sim_default";
|
|
2128
2142
|
var METHODS = ["card", "promptpay"];
|
|
2129
|
-
var RESERVED_METADATA_KEYS = [
|
|
2143
|
+
var RESERVED_METADATA_KEYS = [
|
|
2144
|
+
"projectId",
|
|
2145
|
+
"stardeckReferenceId",
|
|
2146
|
+
"stardeckStagingDeploymentId"
|
|
2147
|
+
];
|
|
2130
2148
|
var PROMPTPAY_QR_TTL_SECONDS = 15 * 60;
|
|
2131
2149
|
var PATH_RE = /^\/api\/store\/(?!beam\/)([^/]+)\/(payment-intents(?:\/([^/]+)(?:\/(cancel))?)?|elements-config|readiness)$/;
|
|
2132
2150
|
function err(error, status, code) {
|
|
@@ -2327,6 +2345,36 @@ function createIntent(body, account, projectId) {
|
|
|
2327
2345
|
);
|
|
2328
2346
|
return json({ paymentIntent: toStripePaymentIntentSummary(intent) }, 201);
|
|
2329
2347
|
}
|
|
2348
|
+
function createHostedPaymentIntent(input) {
|
|
2349
|
+
state.stripePaymentIntentCounter += 1;
|
|
2350
|
+
const id = `pi_sim_${state.stripePaymentIntentCounter}`;
|
|
2351
|
+
const metadata = appMetadata(input.metadata);
|
|
2352
|
+
const intent = {
|
|
2353
|
+
id,
|
|
2354
|
+
projectId: input.projectId,
|
|
2355
|
+
account: "default",
|
|
2356
|
+
connectedAccountId: resolveAccount("default"),
|
|
2357
|
+
playground: false,
|
|
2358
|
+
status: "succeeded",
|
|
2359
|
+
amount: input.amount,
|
|
2360
|
+
currency: input.currency.toLowerCase(),
|
|
2361
|
+
method: "card",
|
|
2362
|
+
referenceId: input.sessionId,
|
|
2363
|
+
appMetadata: metadata,
|
|
2364
|
+
metadata: { ...metadata, projectId: input.projectId },
|
|
2365
|
+
description: null,
|
|
2366
|
+
receiptEmail: null,
|
|
2367
|
+
returnUrl: null,
|
|
2368
|
+
clientSecret: `${id}_secret_sim`,
|
|
2369
|
+
qr: null,
|
|
2370
|
+
latestChargeId: `ch_sim_${id}`,
|
|
2371
|
+
amountRefunded: 0,
|
|
2372
|
+
lastPaymentError: null,
|
|
2373
|
+
created: Math.floor(Date.now() / 1e3)
|
|
2374
|
+
};
|
|
2375
|
+
state.stripePaymentIntents.set(id, intent);
|
|
2376
|
+
return intent;
|
|
2377
|
+
}
|
|
2330
2378
|
function ownedIntent(id, projectId) {
|
|
2331
2379
|
const intent = state.stripePaymentIntents.get(id);
|
|
2332
2380
|
return intent && intent.projectId === projectId ? intent : void 0;
|
|
@@ -2464,6 +2512,485 @@ function stripePaymentIntentEvent(id) {
|
|
|
2464
2512
|
};
|
|
2465
2513
|
}
|
|
2466
2514
|
|
|
2515
|
+
// src/simulator/refunds.ts
|
|
2516
|
+
var REFUND_ROW_KEY = "refundRowId";
|
|
2517
|
+
var REFUND_METADATA_MAX_KEYS = 50 - RESERVED_METADATA_KEYS.length - 1;
|
|
2518
|
+
var REFUND_REASONS = ["duplicate", "fraudulent", "requested_by_customer"];
|
|
2519
|
+
var PROMPTPAY_BANK_DETAILS_WINDOW_SECONDS = 7 * 24 * 60 * 60;
|
|
2520
|
+
var BEAM_PARTIAL_METHODS = ["CARD", "CARD_TOKEN"];
|
|
2521
|
+
function refundFailure(plan) {
|
|
2522
|
+
return err(plan.message, plan.status, plan.code);
|
|
2523
|
+
}
|
|
2524
|
+
function isPositiveInt(value) {
|
|
2525
|
+
return typeof value === "number" && Number.isInteger(value) && value > 0;
|
|
2526
|
+
}
|
|
2527
|
+
function hasValidKey(value) {
|
|
2528
|
+
return typeof value === "string" && value.length >= 1 && value.length <= 255;
|
|
2529
|
+
}
|
|
2530
|
+
function isValidDeploymentId(value) {
|
|
2531
|
+
return typeof value === "string" && UUID_RE2.test(value);
|
|
2532
|
+
}
|
|
2533
|
+
function isValidRefundMetadata(value) {
|
|
2534
|
+
if (!isStringRecord(value)) return false;
|
|
2535
|
+
const entries = Object.entries(value);
|
|
2536
|
+
return entries.length <= REFUND_METADATA_MAX_KEYS && entries.every(
|
|
2537
|
+
([k, v]) => k.length >= 1 && k.length <= 40 && v.length <= 500 && !RESERVED_METADATA_KEYS.includes(k) && k !== REFUND_ROW_KEY
|
|
2538
|
+
);
|
|
2539
|
+
}
|
|
2540
|
+
var COUNTED_STRIPE_STATUSES = [
|
|
2541
|
+
"pending",
|
|
2542
|
+
"requires_action",
|
|
2543
|
+
"succeeded"
|
|
2544
|
+
];
|
|
2545
|
+
function refundedAmount(intentId) {
|
|
2546
|
+
let total = 0;
|
|
2547
|
+
for (const refund of state.stripeRefunds.values()) {
|
|
2548
|
+
if (refund.paymentIntentId === intentId && COUNTED_STRIPE_STATUSES.includes(refund.status)) {
|
|
2549
|
+
total += refund.amount;
|
|
2550
|
+
}
|
|
2551
|
+
}
|
|
2552
|
+
return total;
|
|
2553
|
+
}
|
|
2554
|
+
function syncAmountRefunded(intent) {
|
|
2555
|
+
intent.amountRefunded = refundedAmount(intent.id);
|
|
2556
|
+
}
|
|
2557
|
+
function validateStripeRefund(body) {
|
|
2558
|
+
const checks = [
|
|
2559
|
+
[isValidDeploymentId(body.deploymentId), "Invalid deployment ID"],
|
|
2560
|
+
[hasValidKey(body.idempotencyKey), "idempotencyKey is required (1 to 255 characters)"],
|
|
2561
|
+
[isOptional(body.amount, isPositiveInt), "amount must be a positive integer in minor units"],
|
|
2562
|
+
[
|
|
2563
|
+
isOptional(body.reason, (v) => typeof v === "string" && REFUND_REASONS.includes(v)),
|
|
2564
|
+
`reason must be one of: ${REFUND_REASONS.join(", ")}`
|
|
2565
|
+
],
|
|
2566
|
+
[isOptional(body.instructionsEmail, isEmail), "instructionsEmail must be an email address"],
|
|
2567
|
+
[
|
|
2568
|
+
isOptional(
|
|
2569
|
+
body.refundRowId,
|
|
2570
|
+
(v) => typeof v === "string" && v.length >= 1 && v.length <= 500
|
|
2571
|
+
),
|
|
2572
|
+
"refundRowId must be 1 to 500 characters"
|
|
2573
|
+
],
|
|
2574
|
+
[
|
|
2575
|
+
isOptional(body.metadata, isValidRefundMetadata),
|
|
2576
|
+
`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)`
|
|
2577
|
+
]
|
|
2578
|
+
];
|
|
2579
|
+
const failed = checks.find(([ok]) => !ok);
|
|
2580
|
+
return failed ? failed[1] : null;
|
|
2581
|
+
}
|
|
2582
|
+
function planStripeRefund(intent, requested) {
|
|
2583
|
+
if (intent.status !== "succeeded") {
|
|
2584
|
+
return {
|
|
2585
|
+
ok: false,
|
|
2586
|
+
status: 409,
|
|
2587
|
+
code: "payment_not_refundable",
|
|
2588
|
+
message: "This payment hasn't succeeded, so there is nothing to refund"
|
|
2589
|
+
};
|
|
2590
|
+
}
|
|
2591
|
+
const remaining = intent.amount - refundedAmount(intent.id);
|
|
2592
|
+
const amount = requested ?? remaining;
|
|
2593
|
+
if (remaining <= 0 || amount > remaining) {
|
|
2594
|
+
return {
|
|
2595
|
+
ok: false,
|
|
2596
|
+
status: 409,
|
|
2597
|
+
code: "refund_exceeds_remaining",
|
|
2598
|
+
message: `Only ${remaining} can still be refunded on this payment`
|
|
2599
|
+
};
|
|
2600
|
+
}
|
|
2601
|
+
return { ok: true, amount };
|
|
2602
|
+
}
|
|
2603
|
+
function toStripeRefundSummary(refund) {
|
|
2604
|
+
const intent = state.stripePaymentIntents.get(refund.paymentIntentId);
|
|
2605
|
+
return {
|
|
2606
|
+
id: refund.id,
|
|
2607
|
+
paymentIntentId: refund.paymentIntentId,
|
|
2608
|
+
amount: refund.amount,
|
|
2609
|
+
currency: refund.currency,
|
|
2610
|
+
status: refund.status,
|
|
2611
|
+
failureReason: refund.failureReason,
|
|
2612
|
+
pendingReason: refund.pendingReason,
|
|
2613
|
+
nextActionExpiresAt: refund.nextActionExpiresAt,
|
|
2614
|
+
refundRowId: refund.refundRowId,
|
|
2615
|
+
metadata: { ...refund.appMetadata },
|
|
2616
|
+
created: refund.created,
|
|
2617
|
+
livemode: false,
|
|
2618
|
+
playground: intent?.playground ?? false
|
|
2619
|
+
};
|
|
2620
|
+
}
|
|
2621
|
+
function newStripeRefund(input) {
|
|
2622
|
+
state.stripeRefundCounter += 1;
|
|
2623
|
+
const refund = {
|
|
2624
|
+
id: `re_sim_${state.stripeRefundCounter}`,
|
|
2625
|
+
paymentIntentId: input.intent.id,
|
|
2626
|
+
projectId: input.intent.projectId,
|
|
2627
|
+
amount: input.amount,
|
|
2628
|
+
currency: input.intent.currency,
|
|
2629
|
+
// A refund made in the Stripe dashboard is already done; the app's waits for `settleRefund`.
|
|
2630
|
+
status: input.origin === "dashboard" ? "succeeded" : "pending",
|
|
2631
|
+
failureReason: null,
|
|
2632
|
+
pendingReason: null,
|
|
2633
|
+
nextActionExpiresAt: null,
|
|
2634
|
+
refundRowId: input.refundRowId ?? null,
|
|
2635
|
+
appMetadata: input.appMetadata ?? {},
|
|
2636
|
+
reason: input.reason ?? null,
|
|
2637
|
+
origin: input.origin,
|
|
2638
|
+
revision: 0,
|
|
2639
|
+
created: Math.floor(Date.now() / 1e3)
|
|
2640
|
+
};
|
|
2641
|
+
state.stripeRefunds.set(refund.id, refund);
|
|
2642
|
+
syncAmountRefunded(input.intent);
|
|
2643
|
+
console.log(
|
|
2644
|
+
`[stardeck-testing] stripe refund ${refund.id} created payment=${refund.paymentIntentId} amount=${refund.amount} origin=${refund.origin}`
|
|
2645
|
+
);
|
|
2646
|
+
return refund;
|
|
2647
|
+
}
|
|
2648
|
+
function createStripeRefund(body, intent, projectId) {
|
|
2649
|
+
const scopedKey = JSON.stringify(["refund", projectId, intent.account, body.idempotencyKey]);
|
|
2650
|
+
const fingerprint = stableStringify({
|
|
2651
|
+
paymentIntentId: intent.id,
|
|
2652
|
+
amount: body.amount,
|
|
2653
|
+
reason: body.reason,
|
|
2654
|
+
instructionsEmail: body.instructionsEmail,
|
|
2655
|
+
refundRowId: body.refundRowId,
|
|
2656
|
+
metadata: body.metadata
|
|
2657
|
+
});
|
|
2658
|
+
const saved = state.stripeIdempotentRefunds.get(scopedKey);
|
|
2659
|
+
if (saved) {
|
|
2660
|
+
if (saved.fingerprint !== fingerprint) {
|
|
2661
|
+
return err(
|
|
2662
|
+
"This idempotency key was already used with different parameters. Use a new key for a different refund.",
|
|
2663
|
+
409,
|
|
2664
|
+
"idempotency_conflict"
|
|
2665
|
+
);
|
|
2666
|
+
}
|
|
2667
|
+
return json({ refund: toStripeRefundSummary(state.stripeRefunds.get(saved.id)) });
|
|
2668
|
+
}
|
|
2669
|
+
const plan = planStripeRefund(intent, body.amount);
|
|
2670
|
+
if (!plan.ok) return refundFailure(plan);
|
|
2671
|
+
const refund = newStripeRefund({
|
|
2672
|
+
intent,
|
|
2673
|
+
amount: plan.amount,
|
|
2674
|
+
origin: "app",
|
|
2675
|
+
refundRowId: typeof body.refundRowId === "string" ? body.refundRowId : null,
|
|
2676
|
+
appMetadata: appMetadata(body.metadata),
|
|
2677
|
+
reason: typeof body.reason === "string" ? body.reason : null
|
|
2678
|
+
});
|
|
2679
|
+
state.stripeIdempotentRefunds.set(scopedKey, { fingerprint, id: refund.id });
|
|
2680
|
+
return json({ refund: toStripeRefundSummary(refund) });
|
|
2681
|
+
}
|
|
2682
|
+
var STRIPE_REFUND_PATH = /^\/api\/store\/(?!beam\/)[^/]+\/(?:payment-intents\/([^/]+)\/refunds|refunds\/([^/]+))$/;
|
|
2683
|
+
async function handleStripeRefundRequest(request, url, projectId) {
|
|
2684
|
+
const match = url.pathname.match(STRIPE_REFUND_PATH);
|
|
2685
|
+
if (!match) return null;
|
|
2686
|
+
const [, intentId, refundId] = match;
|
|
2687
|
+
const isCreate = request.method === "POST" && intentId !== void 0;
|
|
2688
|
+
const isRead = request.method === "GET" && refundId !== void 0;
|
|
2689
|
+
if (!isCreate && !isRead) return null;
|
|
2690
|
+
if (!projectId) return err("Missing authentication", 401, "unauthorized");
|
|
2691
|
+
if (isRead) {
|
|
2692
|
+
if (!url.searchParams.get("deploymentId")) {
|
|
2693
|
+
return err("deploymentId is required", 400, "invalid_request");
|
|
2694
|
+
}
|
|
2695
|
+
const refund = state.stripeRefunds.get(decode(refundId));
|
|
2696
|
+
if (!refund || refund.projectId !== projectId) {
|
|
2697
|
+
return err("Refund not found", 404, "refund_not_found");
|
|
2698
|
+
}
|
|
2699
|
+
return json({ refund: toStripeRefundSummary(refund) });
|
|
2700
|
+
}
|
|
2701
|
+
const body = await readJsonBody(request);
|
|
2702
|
+
const problem = validateStripeRefund(body);
|
|
2703
|
+
if (problem) return err(problem, 400, "invalid_request");
|
|
2704
|
+
const intent = ownedIntent(decode(intentId), projectId);
|
|
2705
|
+
if (!intent) return err("No such payment for this app", 404, "payment_not_owned");
|
|
2706
|
+
return createStripeRefund(body, intent, projectId);
|
|
2707
|
+
}
|
|
2708
|
+
function requireStripeRefund(id) {
|
|
2709
|
+
const refund = state.stripeRefunds.get(id);
|
|
2710
|
+
if (!refund) throw new Error(`[stardeck-testing] Unknown Stripe refund id: ${id}`);
|
|
2711
|
+
return refund;
|
|
2712
|
+
}
|
|
2713
|
+
function settleStripeRefund(refund, outcome, failureReason) {
|
|
2714
|
+
const intent = state.stripePaymentIntents.get(refund.paymentIntentId);
|
|
2715
|
+
const settled = refund.status === "failed" || refund.status === "canceled";
|
|
2716
|
+
if (settled || outcome !== "failed" && refund.status === "succeeded") {
|
|
2717
|
+
throw new Error(
|
|
2718
|
+
`[stardeck-testing] Can't settle Stripe refund ${refund.id} as ${outcome}: it is ${refund.status}`
|
|
2719
|
+
);
|
|
2720
|
+
}
|
|
2721
|
+
if (outcome === "needs_customer") {
|
|
2722
|
+
if (intent.method !== "promptpay") {
|
|
2723
|
+
throw new Error(
|
|
2724
|
+
`[stardeck-testing] Stripe refund ${refund.id} is on a ${intent.method} payment; only PromptPay refunds wait for the customer's bank details`
|
|
2725
|
+
);
|
|
2726
|
+
}
|
|
2727
|
+
refund.status = "requires_action";
|
|
2728
|
+
refund.nextActionExpiresAt = refund.created + PROMPTPAY_BANK_DETAILS_WINDOW_SECONDS;
|
|
2729
|
+
} else {
|
|
2730
|
+
refund.status = outcome;
|
|
2731
|
+
refund.nextActionExpiresAt = null;
|
|
2732
|
+
refund.pendingReason = null;
|
|
2733
|
+
refund.failureReason = outcome === "failed" ? failureReason ?? "unknown" : null;
|
|
2734
|
+
}
|
|
2735
|
+
refund.revision += 1;
|
|
2736
|
+
syncAmountRefunded(intent);
|
|
2737
|
+
return refund;
|
|
2738
|
+
}
|
|
2739
|
+
function stripeRefundEvent(id, type) {
|
|
2740
|
+
const refund = requireStripeRefund(id);
|
|
2741
|
+
const intent = state.stripePaymentIntents.get(refund.paymentIntentId);
|
|
2742
|
+
let eventType = type;
|
|
2743
|
+
if (!eventType) {
|
|
2744
|
+
if (refund.status === "failed") eventType = "refund.failed";
|
|
2745
|
+
else eventType = refund.revision === 0 ? "refund.created" : "refund.updated";
|
|
2746
|
+
}
|
|
2747
|
+
const metadata = { ...refund.appMetadata };
|
|
2748
|
+
if (refund.origin === "app") metadata.projectId = refund.projectId;
|
|
2749
|
+
if (refund.refundRowId) metadata[REFUND_ROW_KEY] = refund.refundRowId;
|
|
2750
|
+
return {
|
|
2751
|
+
type: eventType,
|
|
2752
|
+
accountId: intent.connectedAccountId ?? "acct_stardeck_playground",
|
|
2753
|
+
data: {
|
|
2754
|
+
id: refund.id,
|
|
2755
|
+
object: "refund",
|
|
2756
|
+
amount: refund.amount,
|
|
2757
|
+
currency: refund.currency,
|
|
2758
|
+
payment_intent: refund.paymentIntentId,
|
|
2759
|
+
charge: intent.latestChargeId,
|
|
2760
|
+
status: refund.status,
|
|
2761
|
+
reason: refund.reason,
|
|
2762
|
+
failure_reason: refund.failureReason,
|
|
2763
|
+
pending_reason: refund.pendingReason,
|
|
2764
|
+
next_action: refund.status === "requires_action" ? {
|
|
2765
|
+
type: "display_details",
|
|
2766
|
+
display_details: { expires_at: refund.nextActionExpiresAt }
|
|
2767
|
+
} : null,
|
|
2768
|
+
metadata,
|
|
2769
|
+
created: refund.created,
|
|
2770
|
+
livemode: false
|
|
2771
|
+
}
|
|
2772
|
+
};
|
|
2773
|
+
}
|
|
2774
|
+
function refundsOfCharge(chargeId) {
|
|
2775
|
+
return [...state.beamRefunds.values()].filter((r) => r.chargeId === chargeId);
|
|
2776
|
+
}
|
|
2777
|
+
function planBeamRefund(charge, requested) {
|
|
2778
|
+
if (charge.status !== "SUCCEEDED") {
|
|
2779
|
+
return {
|
|
2780
|
+
ok: false,
|
|
2781
|
+
status: 409,
|
|
2782
|
+
code: "payment_not_refundable",
|
|
2783
|
+
message: "This charge hasn't succeeded, so there is nothing to refund"
|
|
2784
|
+
};
|
|
2785
|
+
}
|
|
2786
|
+
const refunds = refundsOfCharge(charge.id);
|
|
2787
|
+
if (refunds.some((r) => r.status === "PENDING")) {
|
|
2788
|
+
return {
|
|
2789
|
+
ok: false,
|
|
2790
|
+
status: 409,
|
|
2791
|
+
code: "refund_in_progress",
|
|
2792
|
+
message: "This charge already has a refund in progress. Wait for it to finish."
|
|
2793
|
+
};
|
|
2794
|
+
}
|
|
2795
|
+
const refunded = refunds.filter((r) => r.status === "SUCCEEDED").reduce((n, r) => n + r.amount, 0);
|
|
2796
|
+
const remaining = charge.amount - refunded;
|
|
2797
|
+
const amount = requested ?? remaining;
|
|
2798
|
+
if (remaining <= 0 || amount > remaining) {
|
|
2799
|
+
return {
|
|
2800
|
+
ok: false,
|
|
2801
|
+
status: 409,
|
|
2802
|
+
code: "refund_exceeds_remaining",
|
|
2803
|
+
message: `Only ${remaining} can still be refunded on this charge`
|
|
2804
|
+
};
|
|
2805
|
+
}
|
|
2806
|
+
if (amount < charge.amount && !BEAM_PARTIAL_METHODS.includes(charge.paymentMethodType)) {
|
|
2807
|
+
return {
|
|
2808
|
+
ok: false,
|
|
2809
|
+
status: 409,
|
|
2810
|
+
code: "refund_partial_not_allowed",
|
|
2811
|
+
message: `${charge.paymentMethodType} charges can only be refunded in full`
|
|
2812
|
+
};
|
|
2813
|
+
}
|
|
2814
|
+
return { ok: true, amount };
|
|
2815
|
+
}
|
|
2816
|
+
function toBeamRefundSummary(refund) {
|
|
2817
|
+
return {
|
|
2818
|
+
refundId: refund.refundId,
|
|
2819
|
+
chargeId: refund.chargeId,
|
|
2820
|
+
amount: refund.amount,
|
|
2821
|
+
currency: refund.currency,
|
|
2822
|
+
status: refund.status,
|
|
2823
|
+
failureCode: refund.failureCode,
|
|
2824
|
+
reason: refund.reason,
|
|
2825
|
+
createdAt: refund.createdAt
|
|
2826
|
+
};
|
|
2827
|
+
}
|
|
2828
|
+
function newBeamRefund(input) {
|
|
2829
|
+
state.beamRefundCounter += 1;
|
|
2830
|
+
const refund = {
|
|
2831
|
+
refundId: `rf_sim_${state.beamRefundCounter}`,
|
|
2832
|
+
chargeId: input.charge.id,
|
|
2833
|
+
amount: input.amount,
|
|
2834
|
+
currency: input.charge.currency,
|
|
2835
|
+
status: input.origin === "dashboard" ? "SUCCEEDED" : "PENDING",
|
|
2836
|
+
failureCode: null,
|
|
2837
|
+
reason: input.reason ?? null,
|
|
2838
|
+
origin: input.origin,
|
|
2839
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2840
|
+
};
|
|
2841
|
+
state.beamRefunds.set(refund.refundId, refund);
|
|
2842
|
+
console.log(
|
|
2843
|
+
`[stardeck-testing] beam refund ${refund.refundId} created charge=${refund.chargeId} amount=${refund.amount} origin=${refund.origin}`
|
|
2844
|
+
);
|
|
2845
|
+
return refund;
|
|
2846
|
+
}
|
|
2847
|
+
function validateBeamRefund(body) {
|
|
2848
|
+
const checks = [
|
|
2849
|
+
[isValidDeploymentId(body.deploymentId), "Invalid deployment ID"],
|
|
2850
|
+
[hasValidKey(body.idempotencyKey), "idempotencyKey is required (1 to 255 characters)"],
|
|
2851
|
+
[isOptional(body.amount, isPositiveInt), "amount must be a positive integer in satang"],
|
|
2852
|
+
[
|
|
2853
|
+
isOptional(body.reason, (v) => typeof v === "string" && v.length <= 500),
|
|
2854
|
+
"reason must be at most 500 characters"
|
|
2855
|
+
]
|
|
2856
|
+
];
|
|
2857
|
+
const failed = checks.find(([ok]) => !ok);
|
|
2858
|
+
return failed ? failed[1] : null;
|
|
2859
|
+
}
|
|
2860
|
+
function createBeamRefund(body, charge, merchant) {
|
|
2861
|
+
const scopedKey = JSON.stringify(["beam-refund", merchant, body.idempotencyKey]);
|
|
2862
|
+
const fingerprint = stableStringify({
|
|
2863
|
+
chargeId: charge.id,
|
|
2864
|
+
amount: body.amount,
|
|
2865
|
+
reason: body.reason
|
|
2866
|
+
});
|
|
2867
|
+
const saved = state.beamIdempotentRefunds.get(scopedKey);
|
|
2868
|
+
if (saved) {
|
|
2869
|
+
if (saved.fingerprint !== fingerprint) {
|
|
2870
|
+
return err(
|
|
2871
|
+
"This idempotency key was already used with different parameters. Use a new key for a different refund.",
|
|
2872
|
+
409,
|
|
2873
|
+
"idempotency_conflict"
|
|
2874
|
+
);
|
|
2875
|
+
}
|
|
2876
|
+
return json({ refund: toBeamRefundSummary(state.beamRefunds.get(saved.id)) });
|
|
2877
|
+
}
|
|
2878
|
+
const plan = planBeamRefund(charge, body.amount);
|
|
2879
|
+
if (!plan.ok) return refundFailure(plan);
|
|
2880
|
+
const refund = newBeamRefund({
|
|
2881
|
+
charge,
|
|
2882
|
+
amount: plan.amount,
|
|
2883
|
+
origin: "app",
|
|
2884
|
+
reason: typeof body.reason === "string" ? body.reason : null
|
|
2885
|
+
});
|
|
2886
|
+
state.beamIdempotentRefunds.set(scopedKey, { fingerprint, id: refund.refundId });
|
|
2887
|
+
return json({ refund: toBeamRefundSummary(refund) });
|
|
2888
|
+
}
|
|
2889
|
+
var BEAM_REFUND_PATH = /^\/api\/store\/beam\/([^/]+)\/(?:charges\/([^/]+)\/refunds|refunds\/([^/]+))$/;
|
|
2890
|
+
async function handleBeamRefundRequest(request, url) {
|
|
2891
|
+
const match = url.pathname.match(BEAM_REFUND_PATH);
|
|
2892
|
+
if (!match) return null;
|
|
2893
|
+
const [, merchant, chargeId, refundId] = match;
|
|
2894
|
+
const isCreate = request.method === "POST" && chargeId !== void 0;
|
|
2895
|
+
const isRead = request.method === "GET" && refundId !== void 0;
|
|
2896
|
+
if (!isCreate && !isRead) return null;
|
|
2897
|
+
if (isRead) {
|
|
2898
|
+
if (!url.searchParams.get("deploymentId")) {
|
|
2899
|
+
return err("deploymentId is required", 400, "invalid_request");
|
|
2900
|
+
}
|
|
2901
|
+
const refund = state.beamRefunds.get(decode(refundId));
|
|
2902
|
+
if (!refund) return err("Refund not found", 404, "refund_not_found");
|
|
2903
|
+
return json({ refund: toBeamRefundSummary(refund) });
|
|
2904
|
+
}
|
|
2905
|
+
const body = await readJsonBody(request);
|
|
2906
|
+
const problem = validateBeamRefund(body);
|
|
2907
|
+
if (problem) return err(problem, 400, "invalid_request");
|
|
2908
|
+
const charge = state.directCharges.get(decode(chargeId));
|
|
2909
|
+
if (!charge) return err("No such charge for this app", 404, "payment_not_owned");
|
|
2910
|
+
return createBeamRefund(body, charge, decode(merchant));
|
|
2911
|
+
}
|
|
2912
|
+
function requireBeamRefund(id) {
|
|
2913
|
+
const refund = state.beamRefunds.get(id);
|
|
2914
|
+
if (!refund) throw new Error(`[stardeck-testing] Unknown Beam refund id: ${id}`);
|
|
2915
|
+
return refund;
|
|
2916
|
+
}
|
|
2917
|
+
function settleBeamRefund(refund, outcome, failureCode) {
|
|
2918
|
+
if (outcome === "needs_customer") {
|
|
2919
|
+
throw new Error(
|
|
2920
|
+
`[stardeck-testing] Beam refund ${refund.refundId} can't need the customer: Beam refunds have no customer step (only Stripe PromptPay does)`
|
|
2921
|
+
);
|
|
2922
|
+
}
|
|
2923
|
+
if (refund.status !== "PENDING") {
|
|
2924
|
+
throw new Error(
|
|
2925
|
+
`[stardeck-testing] Can't settle Beam refund ${refund.refundId} as ${outcome}: it is ${refund.status}, and Beam refund statuses are final`
|
|
2926
|
+
);
|
|
2927
|
+
}
|
|
2928
|
+
refund.status = outcome === "succeeded" ? "SUCCEEDED" : "FAILED";
|
|
2929
|
+
refund.failureCode = outcome === "failed" ? failureCode ?? "REFUND_FAILED" : null;
|
|
2930
|
+
return refund;
|
|
2931
|
+
}
|
|
2932
|
+
function beamRefundEvent(id) {
|
|
2933
|
+
const refund = requireBeamRefund(id);
|
|
2934
|
+
if (refund.status === "PENDING") {
|
|
2935
|
+
throw new Error(
|
|
2936
|
+
`[stardeck-testing] Beam refund ${id} is PENDING and has no event yet; call settleRefund first`
|
|
2937
|
+
);
|
|
2938
|
+
}
|
|
2939
|
+
return {
|
|
2940
|
+
type: refund.status === "SUCCEEDED" ? "refund.succeeded" : "refund.failed",
|
|
2941
|
+
payload: {
|
|
2942
|
+
refundId: refund.refundId,
|
|
2943
|
+
chargeId: refund.chargeId,
|
|
2944
|
+
merchantId: SIM_BEAM_MERCHANT_ID,
|
|
2945
|
+
status: refund.status,
|
|
2946
|
+
amount: refund.amount,
|
|
2947
|
+
currency: refund.currency,
|
|
2948
|
+
...refund.failureCode ? { failureCode: refund.failureCode } : {},
|
|
2949
|
+
...refund.reason ? { reason: refund.reason } : {},
|
|
2950
|
+
createdAt: refund.createdAt
|
|
2951
|
+
}
|
|
2952
|
+
};
|
|
2953
|
+
}
|
|
2954
|
+
function settleRefund(refundId, outcome, options) {
|
|
2955
|
+
const stripe = state.stripeRefunds.get(refundId);
|
|
2956
|
+
if (stripe) settleStripeRefund(stripe, outcome, options?.failureReason);
|
|
2957
|
+
else settleBeamRefund(requireBeamRefund(refundId), outcome, options?.failureReason);
|
|
2958
|
+
}
|
|
2959
|
+
function dashboardRefund(paymentRef, amount) {
|
|
2960
|
+
const intent = state.stripePaymentIntents.get(paymentRef);
|
|
2961
|
+
if (intent) {
|
|
2962
|
+
const plan = planStripeRefund(intent, amount);
|
|
2963
|
+
if (!plan.ok) throw dashboardRefused(paymentRef, plan);
|
|
2964
|
+
return newStripeRefund({ intent, amount: plan.amount, origin: "dashboard" }).id;
|
|
2965
|
+
}
|
|
2966
|
+
const charge = state.directCharges.get(paymentRef);
|
|
2967
|
+
if (charge) {
|
|
2968
|
+
const plan = planBeamRefund(charge, amount);
|
|
2969
|
+
if (!plan.ok) throw dashboardRefused(paymentRef, plan);
|
|
2970
|
+
return newBeamRefund({ charge, amount: plan.amount, origin: "dashboard" }).refundId;
|
|
2971
|
+
}
|
|
2972
|
+
throw new Error(
|
|
2973
|
+
`[stardeck-testing] dashboardRefund: unknown payment ${paymentRef}; pass a Stripe PaymentIntent id or a Beam direct charge id`
|
|
2974
|
+
);
|
|
2975
|
+
}
|
|
2976
|
+
function dashboardRefused(paymentRef, plan) {
|
|
2977
|
+
return new Error(
|
|
2978
|
+
`[stardeck-testing] dashboardRefund of ${paymentRef} refused (${plan.code}): ${plan.message}`
|
|
2979
|
+
);
|
|
2980
|
+
}
|
|
2981
|
+
function findCheckoutSessionByIntent(url, projectId) {
|
|
2982
|
+
if (!projectId) return err("Missing authentication", 401, "unauthorized");
|
|
2983
|
+
if (!url.searchParams.get("deploymentId")) {
|
|
2984
|
+
return err("deploymentId is required", 400, "invalid_request");
|
|
2985
|
+
}
|
|
2986
|
+
const intentId = url.searchParams.get("paymentIntentId");
|
|
2987
|
+
if (!intentId) return err("paymentIntentId is required", 400, "invalid_request");
|
|
2988
|
+
const intent = state.stripePaymentIntents.get(intentId);
|
|
2989
|
+
if (intent && intent.projectId !== projectId) return json({ session: null });
|
|
2990
|
+
const session = [...state.sessionStatuses.values()].find((s) => s.paymentIntentId === intentId);
|
|
2991
|
+
return json({ session: session ?? null });
|
|
2992
|
+
}
|
|
2993
|
+
|
|
2467
2994
|
// src/simulator/webhook-signing.ts
|
|
2468
2995
|
var import_node_crypto5 = __toESM(require("crypto"));
|
|
2469
2996
|
function signEventDelivery(secret, context, rawBody) {
|
|
@@ -2523,6 +3050,15 @@ function paymentOperationForRequest(request, pathname) {
|
|
|
2523
3050
|
if (request.method === "GET" && /^\/api\/store\/(?!beam\/)[^/]+\/checkout-sessions\/[^/]+$/.test(pathname)) {
|
|
2524
3051
|
return "stripe.getCheckoutSessionStatus";
|
|
2525
3052
|
}
|
|
3053
|
+
if (request.method === "POST" && /^\/api\/store\/beam\/[^/]+\/charges\/[^/]+\/refunds$/.test(pathname)) {
|
|
3054
|
+
return "beam.refundCharge";
|
|
3055
|
+
}
|
|
3056
|
+
if (request.method === "GET" && /^\/api\/store\/beam\/[^/]+\/refunds\/[^/]+$/.test(pathname)) {
|
|
3057
|
+
return "beam.getRefund";
|
|
3058
|
+
}
|
|
3059
|
+
if (request.method === "GET" && /^\/api\/store\/(?!beam\/)[^/]+\/checkout-sessions$/.test(pathname)) {
|
|
3060
|
+
return "stripe.findCheckoutSessionByIntent";
|
|
3061
|
+
}
|
|
2526
3062
|
return stripeInlineOperation(request.method, pathname);
|
|
2527
3063
|
}
|
|
2528
3064
|
var STRIPE_INLINE_OPERATIONS = [
|
|
@@ -2534,6 +3070,12 @@ var STRIPE_INLINE_OPERATIONS = [
|
|
|
2534
3070
|
/^\/api\/store\/(?!beam\/)[^/]+\/payment-intents\/[^/]+\/cancel$/,
|
|
2535
3071
|
"stripe.cancelPaymentIntent"
|
|
2536
3072
|
],
|
|
3073
|
+
[
|
|
3074
|
+
"POST",
|
|
3075
|
+
/^\/api\/store\/(?!beam\/)[^/]+\/payment-intents\/[^/]+\/refunds$/,
|
|
3076
|
+
"stripe.refundPaymentIntent"
|
|
3077
|
+
],
|
|
3078
|
+
["GET", /^\/api\/store\/(?!beam\/)[^/]+\/refunds\/[^/]+$/, "stripe.getRefund"],
|
|
2537
3079
|
["GET", /^\/api\/store\/(?!beam\/)[^/]+\/elements-config$/, "stripe.getElementsConfig"],
|
|
2538
3080
|
["GET", /^\/api\/store\/(?!beam\/)[^/]+\/readiness$/, "stripe.getReadiness"]
|
|
2539
3081
|
];
|
|
@@ -2618,7 +3160,8 @@ function seedStripeSession(id, body, expiresAt) {
|
|
|
2618
3160
|
currency,
|
|
2619
3161
|
customerEmail: body.customerEmail ? String(body.customerEmail) : null,
|
|
2620
3162
|
metadata: body.metadata ?? {},
|
|
2621
|
-
expiresAt
|
|
3163
|
+
expiresAt,
|
|
3164
|
+
paymentIntentId: null
|
|
2622
3165
|
});
|
|
2623
3166
|
}
|
|
2624
3167
|
function seedBeamLink(id, body, merchantId) {
|
|
@@ -2724,6 +3267,10 @@ async function handlePaymentsRequest(request, url, signedProjectId = null) {
|
|
|
2724
3267
|
signedProjectId
|
|
2725
3268
|
);
|
|
2726
3269
|
if (inlineStripeResponse) return inlineStripeResponse;
|
|
3270
|
+
const stripeRefundResponse = await handleStripeRefundRequest(request, url, signedProjectId);
|
|
3271
|
+
if (stripeRefundResponse) return stripeRefundResponse;
|
|
3272
|
+
const beamRefundResponse = await handleBeamRefundRequest(request, url);
|
|
3273
|
+
if (beamRefundResponse) return beamRefundResponse;
|
|
2727
3274
|
const directChargeResponse = await handleDirectChargeRequest(request, pathname);
|
|
2728
3275
|
if (directChargeResponse) return directChargeResponse;
|
|
2729
3276
|
if (/\/billing-portal$/.test(pathname)) {
|
|
@@ -3045,6 +3592,9 @@ async function handlePaymentsRequest(request, url, signedProjectId = null) {
|
|
|
3045
3592
|
if (scopedKey) state.idempotentCheckouts.set(scopedKey, { fingerprint, response });
|
|
3046
3593
|
return json(response);
|
|
3047
3594
|
}
|
|
3595
|
+
if (subPath === "checkout-sessions" && request.method === "GET") {
|
|
3596
|
+
return findCheckoutSessionByIntent(url, signedProjectId);
|
|
3597
|
+
}
|
|
3048
3598
|
const sessionMatch = subPath.match(/^checkout-sessions\/([^/]+)$/);
|
|
3049
3599
|
if (sessionMatch && request.method === "GET") {
|
|
3050
3600
|
const session = state.sessionStatuses.get(sessionMatch[1]);
|
|
@@ -3054,6 +3604,17 @@ async function handlePaymentsRequest(request, url, signedProjectId = null) {
|
|
|
3054
3604
|
}
|
|
3055
3605
|
return payErr(`No payments simulator for ${request.method} ${pathname}`, 404);
|
|
3056
3606
|
}
|
|
3607
|
+
function attachHostedPayment(session) {
|
|
3608
|
+
if (session.paymentIntentId || session.mode !== "payment") return;
|
|
3609
|
+
if (session.amountTotal === null || session.currency === null) return;
|
|
3610
|
+
session.paymentIntentId = createHostedPaymentIntent({
|
|
3611
|
+
projectId: TEST_ENV_DEFAULTS.PROJECT_ID,
|
|
3612
|
+
sessionId: session.id,
|
|
3613
|
+
amount: session.amountTotal,
|
|
3614
|
+
currency: session.currency,
|
|
3615
|
+
metadata: session.metadata
|
|
3616
|
+
}).id;
|
|
3617
|
+
}
|
|
3057
3618
|
function requirePendingDirectCharge(chargeId) {
|
|
3058
3619
|
const charge = state.directCharges.get(chargeId);
|
|
3059
3620
|
if (!charge) {
|
|
@@ -3089,6 +3650,7 @@ function createPayments() {
|
|
|
3089
3650
|
if (session) {
|
|
3090
3651
|
session.status = "complete";
|
|
3091
3652
|
session.paymentStatus = "paid";
|
|
3653
|
+
attachHostedPayment(session);
|
|
3092
3654
|
return;
|
|
3093
3655
|
}
|
|
3094
3656
|
const link = state.paymentLinks.get(id);
|
|
@@ -3131,7 +3693,10 @@ function createPayments() {
|
|
|
3131
3693
|
state.stripeChargesEnabled = enabled;
|
|
3132
3694
|
},
|
|
3133
3695
|
async deliverStripeEvent(handler, input, options) {
|
|
3134
|
-
|
|
3696
|
+
let event;
|
|
3697
|
+
if ("paymentIntentId" in input) event = stripePaymentIntentEvent(input.paymentIntentId);
|
|
3698
|
+
else if ("refundId" in input) event = stripeRefundEvent(input.refundId, input.type);
|
|
3699
|
+
else event = input;
|
|
3135
3700
|
const envelope = {
|
|
3136
3701
|
id: `evt_test_${import_node_crypto6.default.randomUUID()}`,
|
|
3137
3702
|
kind: "stripe_webhook",
|
|
@@ -3144,7 +3709,8 @@ function createPayments() {
|
|
|
3144
3709
|
};
|
|
3145
3710
|
return deliverEvent(envelope, handler, options);
|
|
3146
3711
|
},
|
|
3147
|
-
async deliverBeamEvent(handler,
|
|
3712
|
+
async deliverBeamEvent(handler, input, options) {
|
|
3713
|
+
const event = "refundId" in input ? beamRefundEvent(input.refundId) : input;
|
|
3148
3714
|
const envelope = {
|
|
3149
3715
|
id: `beam_evt_test_${import_node_crypto6.default.randomUUID()}`,
|
|
3150
3716
|
kind: "beam_webhook",
|
|
@@ -3156,6 +3722,20 @@ function createPayments() {
|
|
|
3156
3722
|
};
|
|
3157
3723
|
return deliverEvent(envelope, handler, options);
|
|
3158
3724
|
},
|
|
3725
|
+
get stripeRefunds() {
|
|
3726
|
+
return [...state.stripeRefunds.values()];
|
|
3727
|
+
},
|
|
3728
|
+
getStripeRefund(refundId) {
|
|
3729
|
+
return state.stripeRefunds.get(refundId);
|
|
3730
|
+
},
|
|
3731
|
+
get beamRefunds() {
|
|
3732
|
+
return [...state.beamRefunds.values()];
|
|
3733
|
+
},
|
|
3734
|
+
getBeamRefund(refundId) {
|
|
3735
|
+
return state.beamRefunds.get(refundId);
|
|
3736
|
+
},
|
|
3737
|
+
settleRefund,
|
|
3738
|
+
dashboardRefund,
|
|
3159
3739
|
approveBoltIntent(intentId, options) {
|
|
3160
3740
|
const intent = requirePendingBoltIntent(intentId);
|
|
3161
3741
|
const chargeId = options?.chargeId ?? nextBoltChargeId();
|
|
@@ -3250,6 +3830,12 @@ function createPayments() {
|
|
|
3250
3830
|
state.stripePaymentIntents.clear();
|
|
3251
3831
|
state.stripePaymentIntentCounter = 0;
|
|
3252
3832
|
state.stripeIdempotentIntents.clear();
|
|
3833
|
+
state.stripeRefunds.clear();
|
|
3834
|
+
state.stripeRefundCounter = 0;
|
|
3835
|
+
state.stripeIdempotentRefunds.clear();
|
|
3836
|
+
state.beamRefunds.clear();
|
|
3837
|
+
state.beamRefundCounter = 0;
|
|
3838
|
+
state.beamIdempotentRefunds.clear();
|
|
3253
3839
|
state.stripeAllowedMethods = ["card", "promptpay"];
|
|
3254
3840
|
state.stripeChargesEnabled = true;
|
|
3255
3841
|
},
|
|
@@ -4024,6 +4610,141 @@ function createEdge() {
|
|
|
4024
4610
|
};
|
|
4025
4611
|
}
|
|
4026
4612
|
|
|
4613
|
+
// src/simulator/controls.ts
|
|
4614
|
+
var CONTROLS_STATE_SYMBOL = /* @__PURE__ */ Symbol.for("stardeck.controls.state");
|
|
4615
|
+
var USER_TOKEN_HEADER = "x-stardeck-user-token";
|
|
4616
|
+
var DEFAULTS = {
|
|
4617
|
+
"flag.paymentsSetupStep": false,
|
|
4618
|
+
"plan.locations": { included: 3, max: null, extraMonthly: { amount: 990, currency: "THB" } },
|
|
4619
|
+
"plan.multiEntity": true,
|
|
4620
|
+
"payments.provider": "beam",
|
|
4621
|
+
"payments.methods": { card: true, promptPay: true }
|
|
4622
|
+
};
|
|
4623
|
+
var REFUSAL_STATUS = {
|
|
4624
|
+
NOT_WRITABLE: 400,
|
|
4625
|
+
INVALID_VALUE: 400,
|
|
4626
|
+
UNAUTHENTICATED: 401,
|
|
4627
|
+
FORBIDDEN: 403,
|
|
4628
|
+
WRITE_NOT_ALLOWED_HERE: 403,
|
|
4629
|
+
PROVIDER_NOT_READY: 409,
|
|
4630
|
+
UNAVAILABLE: 503
|
|
4631
|
+
};
|
|
4632
|
+
var controlsModule = null;
|
|
4633
|
+
function loadControls() {
|
|
4634
|
+
controlsModule ??= import("@stardeck-customer-apps/controls");
|
|
4635
|
+
return controlsModule;
|
|
4636
|
+
}
|
|
4637
|
+
function resetControlsClientCache() {
|
|
4638
|
+
const clientState = globalThis[CONTROLS_STATE_SYMBOL];
|
|
4639
|
+
clientState?.reset?.();
|
|
4640
|
+
}
|
|
4641
|
+
function initControls(seed) {
|
|
4642
|
+
state.controls.seed = { ...seed };
|
|
4643
|
+
restoreControls();
|
|
4644
|
+
}
|
|
4645
|
+
function restoreControls() {
|
|
4646
|
+
state.controls.values = { ...DEFAULTS, ...state.controls.seed };
|
|
4647
|
+
state.controls.unavailable = /* @__PURE__ */ new Set();
|
|
4648
|
+
state.controls.lastUsage = null;
|
|
4649
|
+
state.controls.writes = [];
|
|
4650
|
+
state.controls.refusal = null;
|
|
4651
|
+
resetControlsClientCache();
|
|
4652
|
+
}
|
|
4653
|
+
function clearControls() {
|
|
4654
|
+
restoreControls();
|
|
4655
|
+
}
|
|
4656
|
+
function currentControlsBody() {
|
|
4657
|
+
const controls = {};
|
|
4658
|
+
for (const [key, value] of Object.entries(state.controls.values)) {
|
|
4659
|
+
controls[key] = state.controls.unavailable.has(key) ? { ok: false, reason: "unavailable (simulated)" } : { ok: true, value };
|
|
4660
|
+
}
|
|
4661
|
+
return controls;
|
|
4662
|
+
}
|
|
4663
|
+
function refusalResponse(refusal) {
|
|
4664
|
+
return json({ error: refusal }, REFUSAL_STATUS[refusal.code]);
|
|
4665
|
+
}
|
|
4666
|
+
function handleSync(body) {
|
|
4667
|
+
const usage = body.usage;
|
|
4668
|
+
if (typeof usage === "object" && usage !== null) {
|
|
4669
|
+
const locations = usage.locations;
|
|
4670
|
+
state.controls.lastUsage = typeof locations === "number" && Number.isInteger(locations) && locations >= 0 ? { locations } : {};
|
|
4671
|
+
}
|
|
4672
|
+
return json({ controls: currentControlsBody() });
|
|
4673
|
+
}
|
|
4674
|
+
async function handleWrite(request, key) {
|
|
4675
|
+
const { CONTROLS, isWritableControl } = await loadControls();
|
|
4676
|
+
if (!isWritableControl(key)) {
|
|
4677
|
+
return refusalResponse({
|
|
4678
|
+
code: "NOT_WRITABLE",
|
|
4679
|
+
message: `${key} cannot be changed from the app`
|
|
4680
|
+
});
|
|
4681
|
+
}
|
|
4682
|
+
const userToken = request.headers.get(USER_TOKEN_HEADER);
|
|
4683
|
+
if (!userToken) {
|
|
4684
|
+
return refusalResponse({
|
|
4685
|
+
code: "UNAUTHENTICATED",
|
|
4686
|
+
message: "A signed-in person is required (X-Stardeck-User-Token)"
|
|
4687
|
+
});
|
|
4688
|
+
}
|
|
4689
|
+
const body = await readJsonBody(request);
|
|
4690
|
+
const parsed = CONTROLS[key].schema.safeParse(body.value);
|
|
4691
|
+
if (!parsed.success) {
|
|
4692
|
+
return refusalResponse({
|
|
4693
|
+
code: "INVALID_VALUE",
|
|
4694
|
+
message: parsed.error.issues[0]?.message ?? "Invalid value"
|
|
4695
|
+
});
|
|
4696
|
+
}
|
|
4697
|
+
const refusal = state.controls.refusal;
|
|
4698
|
+
if (refusal) {
|
|
4699
|
+
state.controls.refusal = null;
|
|
4700
|
+
return refusalResponse(refusal);
|
|
4701
|
+
}
|
|
4702
|
+
state.controls.values[key] = parsed.data;
|
|
4703
|
+
state.controls.unavailable.delete(key);
|
|
4704
|
+
state.controls.writes.push({ key, value: parsed.data, userToken });
|
|
4705
|
+
return json({ controls: currentControlsBody() });
|
|
4706
|
+
}
|
|
4707
|
+
var CONTROLS_PATH = /^\/api\/deployments\/[^/]+\/controls(?:\/([^/]+))?$/;
|
|
4708
|
+
async function handleControlsRequest(request, url) {
|
|
4709
|
+
const match = url.pathname.match(CONTROLS_PATH);
|
|
4710
|
+
const key = match?.[1] ? decodeURIComponent(match[1]) : null;
|
|
4711
|
+
if (!key && request.method === "POST") {
|
|
4712
|
+
return handleSync(await readJsonBody(request));
|
|
4713
|
+
}
|
|
4714
|
+
if (key && request.method === "PUT") {
|
|
4715
|
+
return handleWrite(request, key);
|
|
4716
|
+
}
|
|
4717
|
+
return json(
|
|
4718
|
+
{
|
|
4719
|
+
error: { code: "UNAVAILABLE", message: `No simulator for ${request.method} ${url.pathname}` }
|
|
4720
|
+
},
|
|
4721
|
+
404
|
|
4722
|
+
);
|
|
4723
|
+
}
|
|
4724
|
+
function createControls() {
|
|
4725
|
+
return {
|
|
4726
|
+
set(key, value) {
|
|
4727
|
+
state.controls.values[key] = value;
|
|
4728
|
+
state.controls.unavailable.delete(key);
|
|
4729
|
+
resetControlsClientCache();
|
|
4730
|
+
},
|
|
4731
|
+
setUnavailable(key) {
|
|
4732
|
+
state.controls.unavailable.add(key);
|
|
4733
|
+
resetControlsClientCache();
|
|
4734
|
+
},
|
|
4735
|
+
lastUsage() {
|
|
4736
|
+
return state.controls.lastUsage;
|
|
4737
|
+
},
|
|
4738
|
+
writes() {
|
|
4739
|
+
return state.controls.writes;
|
|
4740
|
+
},
|
|
4741
|
+
refuseNextWrite(refusal) {
|
|
4742
|
+
state.controls.refusal = refusal;
|
|
4743
|
+
},
|
|
4744
|
+
clear: restoreControls
|
|
4745
|
+
};
|
|
4746
|
+
}
|
|
4747
|
+
|
|
4027
4748
|
// src/simulator/schedules.ts
|
|
4028
4749
|
async function handleScheduleWake(request, scheduleKey) {
|
|
4029
4750
|
if (request.method !== "POST") return failure(`Method ${request.method} not allowed`, 405);
|
|
@@ -4215,8 +4936,10 @@ function requiresDeploymentHmac(request, url) {
|
|
|
4215
4936
|
const edgeMatch = url.pathname.match(/^\/api\/deployments\/[^/]+\/edge(\/.*)?$/);
|
|
4216
4937
|
const isStarlens = url.pathname === "/api/starlens/extract";
|
|
4217
4938
|
const scheduleWakeMatch = url.pathname.match(SCHEDULE_WAKE_PATH);
|
|
4218
|
-
|
|
4939
|
+
const controlsMatch = url.pathname.match(CONTROLS_PATH2);
|
|
4940
|
+
return !!(controlsMatch || isStarlens || scheduleWakeMatch || dataStoreMatch || isEmail2 || identitiesMatch || storeMatch || messagingMatch || edgeMatch);
|
|
4219
4941
|
}
|
|
4942
|
+
var CONTROLS_PATH2 = /^\/api\/deployments\/[^/]+\/controls(\/[^/]+)?$/;
|
|
4220
4943
|
var SCHEDULE_WAKE_PATH = /^\/api\/deployments\/[^/]+\/schedules\/([^/]+)\/wake$/;
|
|
4221
4944
|
function deploymentIdFromPath(url) {
|
|
4222
4945
|
const match = url.pathname.match(/\/api\/deployments\/([^/]+)(?:\/|$)/);
|
|
@@ -4284,6 +5007,9 @@ async function handleSimulatedRequest(request, url) {
|
|
|
4284
5007
|
if (edgeMatch) {
|
|
4285
5008
|
return handleEdgeRequest(request, edgeMatch[1] ?? "");
|
|
4286
5009
|
}
|
|
5010
|
+
if (CONTROLS_PATH2.test(url.pathname)) {
|
|
5011
|
+
return handleControlsRequest(request, url);
|
|
5012
|
+
}
|
|
4287
5013
|
const scheduleWakeMatch = url.pathname.match(SCHEDULE_WAKE_PATH);
|
|
4288
5014
|
if (scheduleWakeMatch) {
|
|
4289
5015
|
return handleScheduleWake(request, decodeURIComponent(scheduleWakeMatch[1]));
|
|
@@ -4312,7 +5038,7 @@ async function handleSimulatedRequest(request, url) {
|
|
|
4312
5038
|
}
|
|
4313
5039
|
}
|
|
4314
5040
|
return failure(
|
|
4315
|
-
`[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.`,
|
|
5041
|
+
`[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.`,
|
|
4316
5042
|
404
|
|
4317
5043
|
);
|
|
4318
5044
|
}
|
|
@@ -4461,6 +5187,7 @@ async function createTestApp(options = {}) {
|
|
|
4461
5187
|
const initialLiffId = options.liffId ?? null;
|
|
4462
5188
|
state.liffId = initialLiffId;
|
|
4463
5189
|
installFetchRouter();
|
|
5190
|
+
initControls(options.controls);
|
|
4464
5191
|
const schemaSql = loadSchemaSql(options) ?? "";
|
|
4465
5192
|
const db = await bootWithSchema(schemaSql);
|
|
4466
5193
|
state.db = db;
|
|
@@ -4492,6 +5219,7 @@ async function createTestApp(options = {}) {
|
|
|
4492
5219
|
const edge = createEdge();
|
|
4493
5220
|
const starlens = createStarlens();
|
|
4494
5221
|
const schedules = createSchedules();
|
|
5222
|
+
const controls = createControls();
|
|
4495
5223
|
const app = {
|
|
4496
5224
|
db,
|
|
4497
5225
|
inbox,
|
|
@@ -4502,6 +5230,7 @@ async function createTestApp(options = {}) {
|
|
|
4502
5230
|
edge,
|
|
4503
5231
|
starlens,
|
|
4504
5232
|
schedules,
|
|
5233
|
+
controls,
|
|
4505
5234
|
async query(sql, params = []) {
|
|
4506
5235
|
const result = await db.query(sql, params);
|
|
4507
5236
|
return result.rows;
|
|
@@ -4551,6 +5280,7 @@ async function createTestApp(options = {}) {
|
|
|
4551
5280
|
edge.clear();
|
|
4552
5281
|
starlens.clear();
|
|
4553
5282
|
schedules.clear();
|
|
5283
|
+
clearControls();
|
|
4554
5284
|
},
|
|
4555
5285
|
async close() {
|
|
4556
5286
|
state.db = null;
|
|
@@ -4567,6 +5297,7 @@ async function createTestApp(options = {}) {
|
|
|
4567
5297
|
edge.clear();
|
|
4568
5298
|
starlens.clear();
|
|
4569
5299
|
schedules.clear();
|
|
5300
|
+
clearControls();
|
|
4570
5301
|
uninstallFetchRouter();
|
|
4571
5302
|
await db.close();
|
|
4572
5303
|
}
|