@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/setup.mjs
CHANGED
|
@@ -63,6 +63,12 @@ var state = globalSingleton("state", () => ({
|
|
|
63
63
|
stripePaymentIntents: /* @__PURE__ */ new Map(),
|
|
64
64
|
stripePaymentIntentCounter: 0,
|
|
65
65
|
stripeIdempotentIntents: /* @__PURE__ */ new Map(),
|
|
66
|
+
stripeRefunds: /* @__PURE__ */ new Map(),
|
|
67
|
+
stripeRefundCounter: 0,
|
|
68
|
+
stripeIdempotentRefunds: /* @__PURE__ */ new Map(),
|
|
69
|
+
beamRefunds: /* @__PURE__ */ new Map(),
|
|
70
|
+
beamRefundCounter: 0,
|
|
71
|
+
beamIdempotentRefunds: /* @__PURE__ */ new Map(),
|
|
66
72
|
stripeAllowedMethods: ["card", "promptpay"],
|
|
67
73
|
stripeChargesEnabled: true,
|
|
68
74
|
uploads: [],
|
|
@@ -84,6 +90,14 @@ var state = globalSingleton("state", () => ({
|
|
|
84
90
|
starlensCalls: [],
|
|
85
91
|
starlensReplies: [],
|
|
86
92
|
starlensDefaultReply: null,
|
|
93
|
+
controls: {
|
|
94
|
+
seed: {},
|
|
95
|
+
values: {},
|
|
96
|
+
unavailable: /* @__PURE__ */ new Set(),
|
|
97
|
+
lastUsage: null,
|
|
98
|
+
writes: [],
|
|
99
|
+
refusal: null
|
|
100
|
+
},
|
|
87
101
|
allowNetwork: false
|
|
88
102
|
}));
|
|
89
103
|
function requireDb() {
|
|
@@ -1521,7 +1535,11 @@ var requestScopeStorage = globalSingleton(
|
|
|
1521
1535
|
var SIM_STRIPE_PUBLISHABLE_KEY = "pk_test_sim_stripe";
|
|
1522
1536
|
var SIM_STRIPE_DEFAULT_ACCOUNT = "acct_sim_default";
|
|
1523
1537
|
var METHODS = ["card", "promptpay"];
|
|
1524
|
-
var RESERVED_METADATA_KEYS = [
|
|
1538
|
+
var RESERVED_METADATA_KEYS = [
|
|
1539
|
+
"projectId",
|
|
1540
|
+
"stardeckReferenceId",
|
|
1541
|
+
"stardeckStagingDeploymentId"
|
|
1542
|
+
];
|
|
1525
1543
|
var PROMPTPAY_QR_TTL_SECONDS = 15 * 60;
|
|
1526
1544
|
var PATH_RE = /^\/api\/store\/(?!beam\/)([^/]+)\/(payment-intents(?:\/([^/]+)(?:\/(cancel))?)?|elements-config|readiness)$/;
|
|
1527
1545
|
function err(error, status, code) {
|
|
@@ -1792,6 +1810,350 @@ async function handleStripePaymentIntentRequest(request, url, projectId) {
|
|
|
1792
1810
|
return null;
|
|
1793
1811
|
}
|
|
1794
1812
|
|
|
1813
|
+
// src/simulator/refunds.ts
|
|
1814
|
+
var REFUND_ROW_KEY = "refundRowId";
|
|
1815
|
+
var REFUND_METADATA_MAX_KEYS = 50 - RESERVED_METADATA_KEYS.length - 1;
|
|
1816
|
+
var REFUND_REASONS = ["duplicate", "fraudulent", "requested_by_customer"];
|
|
1817
|
+
var PROMPTPAY_BANK_DETAILS_WINDOW_SECONDS = 7 * 24 * 60 * 60;
|
|
1818
|
+
var BEAM_PARTIAL_METHODS = ["CARD", "CARD_TOKEN"];
|
|
1819
|
+
function refundFailure(plan) {
|
|
1820
|
+
return err(plan.message, plan.status, plan.code);
|
|
1821
|
+
}
|
|
1822
|
+
function isPositiveInt(value) {
|
|
1823
|
+
return typeof value === "number" && Number.isInteger(value) && value > 0;
|
|
1824
|
+
}
|
|
1825
|
+
function hasValidKey(value) {
|
|
1826
|
+
return typeof value === "string" && value.length >= 1 && value.length <= 255;
|
|
1827
|
+
}
|
|
1828
|
+
function isValidDeploymentId(value) {
|
|
1829
|
+
return typeof value === "string" && UUID_RE2.test(value);
|
|
1830
|
+
}
|
|
1831
|
+
function isValidRefundMetadata(value) {
|
|
1832
|
+
if (!isStringRecord(value)) return false;
|
|
1833
|
+
const entries = Object.entries(value);
|
|
1834
|
+
return entries.length <= REFUND_METADATA_MAX_KEYS && entries.every(
|
|
1835
|
+
([k, v]) => k.length >= 1 && k.length <= 40 && v.length <= 500 && !RESERVED_METADATA_KEYS.includes(k) && k !== REFUND_ROW_KEY
|
|
1836
|
+
);
|
|
1837
|
+
}
|
|
1838
|
+
var COUNTED_STRIPE_STATUSES = [
|
|
1839
|
+
"pending",
|
|
1840
|
+
"requires_action",
|
|
1841
|
+
"succeeded"
|
|
1842
|
+
];
|
|
1843
|
+
function refundedAmount(intentId) {
|
|
1844
|
+
let total = 0;
|
|
1845
|
+
for (const refund of state.stripeRefunds.values()) {
|
|
1846
|
+
if (refund.paymentIntentId === intentId && COUNTED_STRIPE_STATUSES.includes(refund.status)) {
|
|
1847
|
+
total += refund.amount;
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
return total;
|
|
1851
|
+
}
|
|
1852
|
+
function syncAmountRefunded(intent) {
|
|
1853
|
+
intent.amountRefunded = refundedAmount(intent.id);
|
|
1854
|
+
}
|
|
1855
|
+
function validateStripeRefund(body) {
|
|
1856
|
+
const checks = [
|
|
1857
|
+
[isValidDeploymentId(body.deploymentId), "Invalid deployment ID"],
|
|
1858
|
+
[hasValidKey(body.idempotencyKey), "idempotencyKey is required (1 to 255 characters)"],
|
|
1859
|
+
[isOptional(body.amount, isPositiveInt), "amount must be a positive integer in minor units"],
|
|
1860
|
+
[
|
|
1861
|
+
isOptional(body.reason, (v) => typeof v === "string" && REFUND_REASONS.includes(v)),
|
|
1862
|
+
`reason must be one of: ${REFUND_REASONS.join(", ")}`
|
|
1863
|
+
],
|
|
1864
|
+
[isOptional(body.instructionsEmail, isEmail), "instructionsEmail must be an email address"],
|
|
1865
|
+
[
|
|
1866
|
+
isOptional(
|
|
1867
|
+
body.refundRowId,
|
|
1868
|
+
(v) => typeof v === "string" && v.length >= 1 && v.length <= 500
|
|
1869
|
+
),
|
|
1870
|
+
"refundRowId must be 1 to 500 characters"
|
|
1871
|
+
],
|
|
1872
|
+
[
|
|
1873
|
+
isOptional(body.metadata, isValidRefundMetadata),
|
|
1874
|
+
`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)`
|
|
1875
|
+
]
|
|
1876
|
+
];
|
|
1877
|
+
const failed = checks.find(([ok]) => !ok);
|
|
1878
|
+
return failed ? failed[1] : null;
|
|
1879
|
+
}
|
|
1880
|
+
function planStripeRefund(intent, requested) {
|
|
1881
|
+
if (intent.status !== "succeeded") {
|
|
1882
|
+
return {
|
|
1883
|
+
ok: false,
|
|
1884
|
+
status: 409,
|
|
1885
|
+
code: "payment_not_refundable",
|
|
1886
|
+
message: "This payment hasn't succeeded, so there is nothing to refund"
|
|
1887
|
+
};
|
|
1888
|
+
}
|
|
1889
|
+
const remaining = intent.amount - refundedAmount(intent.id);
|
|
1890
|
+
const amount = requested ?? remaining;
|
|
1891
|
+
if (remaining <= 0 || amount > remaining) {
|
|
1892
|
+
return {
|
|
1893
|
+
ok: false,
|
|
1894
|
+
status: 409,
|
|
1895
|
+
code: "refund_exceeds_remaining",
|
|
1896
|
+
message: `Only ${remaining} can still be refunded on this payment`
|
|
1897
|
+
};
|
|
1898
|
+
}
|
|
1899
|
+
return { ok: true, amount };
|
|
1900
|
+
}
|
|
1901
|
+
function toStripeRefundSummary(refund) {
|
|
1902
|
+
const intent = state.stripePaymentIntents.get(refund.paymentIntentId);
|
|
1903
|
+
return {
|
|
1904
|
+
id: refund.id,
|
|
1905
|
+
paymentIntentId: refund.paymentIntentId,
|
|
1906
|
+
amount: refund.amount,
|
|
1907
|
+
currency: refund.currency,
|
|
1908
|
+
status: refund.status,
|
|
1909
|
+
failureReason: refund.failureReason,
|
|
1910
|
+
pendingReason: refund.pendingReason,
|
|
1911
|
+
nextActionExpiresAt: refund.nextActionExpiresAt,
|
|
1912
|
+
refundRowId: refund.refundRowId,
|
|
1913
|
+
metadata: { ...refund.appMetadata },
|
|
1914
|
+
created: refund.created,
|
|
1915
|
+
livemode: false,
|
|
1916
|
+
playground: intent?.playground ?? false
|
|
1917
|
+
};
|
|
1918
|
+
}
|
|
1919
|
+
function newStripeRefund(input) {
|
|
1920
|
+
state.stripeRefundCounter += 1;
|
|
1921
|
+
const refund = {
|
|
1922
|
+
id: `re_sim_${state.stripeRefundCounter}`,
|
|
1923
|
+
paymentIntentId: input.intent.id,
|
|
1924
|
+
projectId: input.intent.projectId,
|
|
1925
|
+
amount: input.amount,
|
|
1926
|
+
currency: input.intent.currency,
|
|
1927
|
+
// A refund made in the Stripe dashboard is already done; the app's waits for `settleRefund`.
|
|
1928
|
+
status: input.origin === "dashboard" ? "succeeded" : "pending",
|
|
1929
|
+
failureReason: null,
|
|
1930
|
+
pendingReason: null,
|
|
1931
|
+
nextActionExpiresAt: null,
|
|
1932
|
+
refundRowId: input.refundRowId ?? null,
|
|
1933
|
+
appMetadata: input.appMetadata ?? {},
|
|
1934
|
+
reason: input.reason ?? null,
|
|
1935
|
+
origin: input.origin,
|
|
1936
|
+
revision: 0,
|
|
1937
|
+
created: Math.floor(Date.now() / 1e3)
|
|
1938
|
+
};
|
|
1939
|
+
state.stripeRefunds.set(refund.id, refund);
|
|
1940
|
+
syncAmountRefunded(input.intent);
|
|
1941
|
+
console.log(
|
|
1942
|
+
`[stardeck-testing] stripe refund ${refund.id} created payment=${refund.paymentIntentId} amount=${refund.amount} origin=${refund.origin}`
|
|
1943
|
+
);
|
|
1944
|
+
return refund;
|
|
1945
|
+
}
|
|
1946
|
+
function createStripeRefund(body, intent, projectId) {
|
|
1947
|
+
const scopedKey = JSON.stringify(["refund", projectId, intent.account, body.idempotencyKey]);
|
|
1948
|
+
const fingerprint = stableStringify({
|
|
1949
|
+
paymentIntentId: intent.id,
|
|
1950
|
+
amount: body.amount,
|
|
1951
|
+
reason: body.reason,
|
|
1952
|
+
instructionsEmail: body.instructionsEmail,
|
|
1953
|
+
refundRowId: body.refundRowId,
|
|
1954
|
+
metadata: body.metadata
|
|
1955
|
+
});
|
|
1956
|
+
const saved = state.stripeIdempotentRefunds.get(scopedKey);
|
|
1957
|
+
if (saved) {
|
|
1958
|
+
if (saved.fingerprint !== fingerprint) {
|
|
1959
|
+
return err(
|
|
1960
|
+
"This idempotency key was already used with different parameters. Use a new key for a different refund.",
|
|
1961
|
+
409,
|
|
1962
|
+
"idempotency_conflict"
|
|
1963
|
+
);
|
|
1964
|
+
}
|
|
1965
|
+
return json({ refund: toStripeRefundSummary(state.stripeRefunds.get(saved.id)) });
|
|
1966
|
+
}
|
|
1967
|
+
const plan = planStripeRefund(intent, body.amount);
|
|
1968
|
+
if (!plan.ok) return refundFailure(plan);
|
|
1969
|
+
const refund = newStripeRefund({
|
|
1970
|
+
intent,
|
|
1971
|
+
amount: plan.amount,
|
|
1972
|
+
origin: "app",
|
|
1973
|
+
refundRowId: typeof body.refundRowId === "string" ? body.refundRowId : null,
|
|
1974
|
+
appMetadata: appMetadata(body.metadata),
|
|
1975
|
+
reason: typeof body.reason === "string" ? body.reason : null
|
|
1976
|
+
});
|
|
1977
|
+
state.stripeIdempotentRefunds.set(scopedKey, { fingerprint, id: refund.id });
|
|
1978
|
+
return json({ refund: toStripeRefundSummary(refund) });
|
|
1979
|
+
}
|
|
1980
|
+
var STRIPE_REFUND_PATH = /^\/api\/store\/(?!beam\/)[^/]+\/(?:payment-intents\/([^/]+)\/refunds|refunds\/([^/]+))$/;
|
|
1981
|
+
async function handleStripeRefundRequest(request, url, projectId) {
|
|
1982
|
+
const match = url.pathname.match(STRIPE_REFUND_PATH);
|
|
1983
|
+
if (!match) return null;
|
|
1984
|
+
const [, intentId, refundId] = match;
|
|
1985
|
+
const isCreate = request.method === "POST" && intentId !== void 0;
|
|
1986
|
+
const isRead = request.method === "GET" && refundId !== void 0;
|
|
1987
|
+
if (!isCreate && !isRead) return null;
|
|
1988
|
+
if (!projectId) return err("Missing authentication", 401, "unauthorized");
|
|
1989
|
+
if (isRead) {
|
|
1990
|
+
if (!url.searchParams.get("deploymentId")) {
|
|
1991
|
+
return err("deploymentId is required", 400, "invalid_request");
|
|
1992
|
+
}
|
|
1993
|
+
const refund = state.stripeRefunds.get(decode(refundId));
|
|
1994
|
+
if (!refund || refund.projectId !== projectId) {
|
|
1995
|
+
return err("Refund not found", 404, "refund_not_found");
|
|
1996
|
+
}
|
|
1997
|
+
return json({ refund: toStripeRefundSummary(refund) });
|
|
1998
|
+
}
|
|
1999
|
+
const body = await readJsonBody(request);
|
|
2000
|
+
const problem = validateStripeRefund(body);
|
|
2001
|
+
if (problem) return err(problem, 400, "invalid_request");
|
|
2002
|
+
const intent = ownedIntent(decode(intentId), projectId);
|
|
2003
|
+
if (!intent) return err("No such payment for this app", 404, "payment_not_owned");
|
|
2004
|
+
return createStripeRefund(body, intent, projectId);
|
|
2005
|
+
}
|
|
2006
|
+
function refundsOfCharge(chargeId) {
|
|
2007
|
+
return [...state.beamRefunds.values()].filter((r) => r.chargeId === chargeId);
|
|
2008
|
+
}
|
|
2009
|
+
function planBeamRefund(charge, requested) {
|
|
2010
|
+
if (charge.status !== "SUCCEEDED") {
|
|
2011
|
+
return {
|
|
2012
|
+
ok: false,
|
|
2013
|
+
status: 409,
|
|
2014
|
+
code: "payment_not_refundable",
|
|
2015
|
+
message: "This charge hasn't succeeded, so there is nothing to refund"
|
|
2016
|
+
};
|
|
2017
|
+
}
|
|
2018
|
+
const refunds = refundsOfCharge(charge.id);
|
|
2019
|
+
if (refunds.some((r) => r.status === "PENDING")) {
|
|
2020
|
+
return {
|
|
2021
|
+
ok: false,
|
|
2022
|
+
status: 409,
|
|
2023
|
+
code: "refund_in_progress",
|
|
2024
|
+
message: "This charge already has a refund in progress. Wait for it to finish."
|
|
2025
|
+
};
|
|
2026
|
+
}
|
|
2027
|
+
const refunded = refunds.filter((r) => r.status === "SUCCEEDED").reduce((n, r) => n + r.amount, 0);
|
|
2028
|
+
const remaining = charge.amount - refunded;
|
|
2029
|
+
const amount = requested ?? remaining;
|
|
2030
|
+
if (remaining <= 0 || amount > remaining) {
|
|
2031
|
+
return {
|
|
2032
|
+
ok: false,
|
|
2033
|
+
status: 409,
|
|
2034
|
+
code: "refund_exceeds_remaining",
|
|
2035
|
+
message: `Only ${remaining} can still be refunded on this charge`
|
|
2036
|
+
};
|
|
2037
|
+
}
|
|
2038
|
+
if (amount < charge.amount && !BEAM_PARTIAL_METHODS.includes(charge.paymentMethodType)) {
|
|
2039
|
+
return {
|
|
2040
|
+
ok: false,
|
|
2041
|
+
status: 409,
|
|
2042
|
+
code: "refund_partial_not_allowed",
|
|
2043
|
+
message: `${charge.paymentMethodType} charges can only be refunded in full`
|
|
2044
|
+
};
|
|
2045
|
+
}
|
|
2046
|
+
return { ok: true, amount };
|
|
2047
|
+
}
|
|
2048
|
+
function toBeamRefundSummary(refund) {
|
|
2049
|
+
return {
|
|
2050
|
+
refundId: refund.refundId,
|
|
2051
|
+
chargeId: refund.chargeId,
|
|
2052
|
+
amount: refund.amount,
|
|
2053
|
+
currency: refund.currency,
|
|
2054
|
+
status: refund.status,
|
|
2055
|
+
failureCode: refund.failureCode,
|
|
2056
|
+
reason: refund.reason,
|
|
2057
|
+
createdAt: refund.createdAt
|
|
2058
|
+
};
|
|
2059
|
+
}
|
|
2060
|
+
function newBeamRefund(input) {
|
|
2061
|
+
state.beamRefundCounter += 1;
|
|
2062
|
+
const refund = {
|
|
2063
|
+
refundId: `rf_sim_${state.beamRefundCounter}`,
|
|
2064
|
+
chargeId: input.charge.id,
|
|
2065
|
+
amount: input.amount,
|
|
2066
|
+
currency: input.charge.currency,
|
|
2067
|
+
status: input.origin === "dashboard" ? "SUCCEEDED" : "PENDING",
|
|
2068
|
+
failureCode: null,
|
|
2069
|
+
reason: input.reason ?? null,
|
|
2070
|
+
origin: input.origin,
|
|
2071
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2072
|
+
};
|
|
2073
|
+
state.beamRefunds.set(refund.refundId, refund);
|
|
2074
|
+
console.log(
|
|
2075
|
+
`[stardeck-testing] beam refund ${refund.refundId} created charge=${refund.chargeId} amount=${refund.amount} origin=${refund.origin}`
|
|
2076
|
+
);
|
|
2077
|
+
return refund;
|
|
2078
|
+
}
|
|
2079
|
+
function validateBeamRefund(body) {
|
|
2080
|
+
const checks = [
|
|
2081
|
+
[isValidDeploymentId(body.deploymentId), "Invalid deployment ID"],
|
|
2082
|
+
[hasValidKey(body.idempotencyKey), "idempotencyKey is required (1 to 255 characters)"],
|
|
2083
|
+
[isOptional(body.amount, isPositiveInt), "amount must be a positive integer in satang"],
|
|
2084
|
+
[
|
|
2085
|
+
isOptional(body.reason, (v) => typeof v === "string" && v.length <= 500),
|
|
2086
|
+
"reason must be at most 500 characters"
|
|
2087
|
+
]
|
|
2088
|
+
];
|
|
2089
|
+
const failed = checks.find(([ok]) => !ok);
|
|
2090
|
+
return failed ? failed[1] : null;
|
|
2091
|
+
}
|
|
2092
|
+
function createBeamRefund(body, charge, merchant) {
|
|
2093
|
+
const scopedKey = JSON.stringify(["beam-refund", merchant, body.idempotencyKey]);
|
|
2094
|
+
const fingerprint = stableStringify({
|
|
2095
|
+
chargeId: charge.id,
|
|
2096
|
+
amount: body.amount,
|
|
2097
|
+
reason: body.reason
|
|
2098
|
+
});
|
|
2099
|
+
const saved = state.beamIdempotentRefunds.get(scopedKey);
|
|
2100
|
+
if (saved) {
|
|
2101
|
+
if (saved.fingerprint !== fingerprint) {
|
|
2102
|
+
return err(
|
|
2103
|
+
"This idempotency key was already used with different parameters. Use a new key for a different refund.",
|
|
2104
|
+
409,
|
|
2105
|
+
"idempotency_conflict"
|
|
2106
|
+
);
|
|
2107
|
+
}
|
|
2108
|
+
return json({ refund: toBeamRefundSummary(state.beamRefunds.get(saved.id)) });
|
|
2109
|
+
}
|
|
2110
|
+
const plan = planBeamRefund(charge, body.amount);
|
|
2111
|
+
if (!plan.ok) return refundFailure(plan);
|
|
2112
|
+
const refund = newBeamRefund({
|
|
2113
|
+
charge,
|
|
2114
|
+
amount: plan.amount,
|
|
2115
|
+
origin: "app",
|
|
2116
|
+
reason: typeof body.reason === "string" ? body.reason : null
|
|
2117
|
+
});
|
|
2118
|
+
state.beamIdempotentRefunds.set(scopedKey, { fingerprint, id: refund.refundId });
|
|
2119
|
+
return json({ refund: toBeamRefundSummary(refund) });
|
|
2120
|
+
}
|
|
2121
|
+
var BEAM_REFUND_PATH = /^\/api\/store\/beam\/([^/]+)\/(?:charges\/([^/]+)\/refunds|refunds\/([^/]+))$/;
|
|
2122
|
+
async function handleBeamRefundRequest(request, url) {
|
|
2123
|
+
const match = url.pathname.match(BEAM_REFUND_PATH);
|
|
2124
|
+
if (!match) return null;
|
|
2125
|
+
const [, merchant, chargeId, refundId] = match;
|
|
2126
|
+
const isCreate = request.method === "POST" && chargeId !== void 0;
|
|
2127
|
+
const isRead = request.method === "GET" && refundId !== void 0;
|
|
2128
|
+
if (!isCreate && !isRead) return null;
|
|
2129
|
+
if (isRead) {
|
|
2130
|
+
if (!url.searchParams.get("deploymentId")) {
|
|
2131
|
+
return err("deploymentId is required", 400, "invalid_request");
|
|
2132
|
+
}
|
|
2133
|
+
const refund = state.beamRefunds.get(decode(refundId));
|
|
2134
|
+
if (!refund) return err("Refund not found", 404, "refund_not_found");
|
|
2135
|
+
return json({ refund: toBeamRefundSummary(refund) });
|
|
2136
|
+
}
|
|
2137
|
+
const body = await readJsonBody(request);
|
|
2138
|
+
const problem = validateBeamRefund(body);
|
|
2139
|
+
if (problem) return err(problem, 400, "invalid_request");
|
|
2140
|
+
const charge = state.directCharges.get(decode(chargeId));
|
|
2141
|
+
if (!charge) return err("No such charge for this app", 404, "payment_not_owned");
|
|
2142
|
+
return createBeamRefund(body, charge, decode(merchant));
|
|
2143
|
+
}
|
|
2144
|
+
function findCheckoutSessionByIntent(url, projectId) {
|
|
2145
|
+
if (!projectId) return err("Missing authentication", 401, "unauthorized");
|
|
2146
|
+
if (!url.searchParams.get("deploymentId")) {
|
|
2147
|
+
return err("deploymentId is required", 400, "invalid_request");
|
|
2148
|
+
}
|
|
2149
|
+
const intentId = url.searchParams.get("paymentIntentId");
|
|
2150
|
+
if (!intentId) return err("paymentIntentId is required", 400, "invalid_request");
|
|
2151
|
+
const intent = state.stripePaymentIntents.get(intentId);
|
|
2152
|
+
if (intent && intent.projectId !== projectId) return json({ session: null });
|
|
2153
|
+
const session = [...state.sessionStatuses.values()].find((s) => s.paymentIntentId === intentId);
|
|
2154
|
+
return json({ session: session ?? null });
|
|
2155
|
+
}
|
|
2156
|
+
|
|
1795
2157
|
// src/simulator/webhook-signing.ts
|
|
1796
2158
|
import crypto4 from "crypto";
|
|
1797
2159
|
|
|
@@ -1837,6 +2199,15 @@ function paymentOperationForRequest(request, pathname) {
|
|
|
1837
2199
|
if (request.method === "GET" && /^\/api\/store\/(?!beam\/)[^/]+\/checkout-sessions\/[^/]+$/.test(pathname)) {
|
|
1838
2200
|
return "stripe.getCheckoutSessionStatus";
|
|
1839
2201
|
}
|
|
2202
|
+
if (request.method === "POST" && /^\/api\/store\/beam\/[^/]+\/charges\/[^/]+\/refunds$/.test(pathname)) {
|
|
2203
|
+
return "beam.refundCharge";
|
|
2204
|
+
}
|
|
2205
|
+
if (request.method === "GET" && /^\/api\/store\/beam\/[^/]+\/refunds\/[^/]+$/.test(pathname)) {
|
|
2206
|
+
return "beam.getRefund";
|
|
2207
|
+
}
|
|
2208
|
+
if (request.method === "GET" && /^\/api\/store\/(?!beam\/)[^/]+\/checkout-sessions$/.test(pathname)) {
|
|
2209
|
+
return "stripe.findCheckoutSessionByIntent";
|
|
2210
|
+
}
|
|
1840
2211
|
return stripeInlineOperation(request.method, pathname);
|
|
1841
2212
|
}
|
|
1842
2213
|
var STRIPE_INLINE_OPERATIONS = [
|
|
@@ -1848,6 +2219,12 @@ var STRIPE_INLINE_OPERATIONS = [
|
|
|
1848
2219
|
/^\/api\/store\/(?!beam\/)[^/]+\/payment-intents\/[^/]+\/cancel$/,
|
|
1849
2220
|
"stripe.cancelPaymentIntent"
|
|
1850
2221
|
],
|
|
2222
|
+
[
|
|
2223
|
+
"POST",
|
|
2224
|
+
/^\/api\/store\/(?!beam\/)[^/]+\/payment-intents\/[^/]+\/refunds$/,
|
|
2225
|
+
"stripe.refundPaymentIntent"
|
|
2226
|
+
],
|
|
2227
|
+
["GET", /^\/api\/store\/(?!beam\/)[^/]+\/refunds\/[^/]+$/, "stripe.getRefund"],
|
|
1851
2228
|
["GET", /^\/api\/store\/(?!beam\/)[^/]+\/elements-config$/, "stripe.getElementsConfig"],
|
|
1852
2229
|
["GET", /^\/api\/store\/(?!beam\/)[^/]+\/readiness$/, "stripe.getReadiness"]
|
|
1853
2230
|
];
|
|
@@ -1928,7 +2305,8 @@ function seedStripeSession(id, body, expiresAt) {
|
|
|
1928
2305
|
currency,
|
|
1929
2306
|
customerEmail: body.customerEmail ? String(body.customerEmail) : null,
|
|
1930
2307
|
metadata: body.metadata ?? {},
|
|
1931
|
-
expiresAt
|
|
2308
|
+
expiresAt,
|
|
2309
|
+
paymentIntentId: null
|
|
1932
2310
|
});
|
|
1933
2311
|
}
|
|
1934
2312
|
function seedBeamLink(id, body, merchantId) {
|
|
@@ -2003,6 +2381,10 @@ async function handlePaymentsRequest(request, url, signedProjectId = null) {
|
|
|
2003
2381
|
signedProjectId
|
|
2004
2382
|
);
|
|
2005
2383
|
if (inlineStripeResponse) return inlineStripeResponse;
|
|
2384
|
+
const stripeRefundResponse = await handleStripeRefundRequest(request, url, signedProjectId);
|
|
2385
|
+
if (stripeRefundResponse) return stripeRefundResponse;
|
|
2386
|
+
const beamRefundResponse = await handleBeamRefundRequest(request, url);
|
|
2387
|
+
if (beamRefundResponse) return beamRefundResponse;
|
|
2006
2388
|
const directChargeResponse = await handleDirectChargeRequest(request, pathname);
|
|
2007
2389
|
if (directChargeResponse) return directChargeResponse;
|
|
2008
2390
|
if (/\/billing-portal$/.test(pathname)) {
|
|
@@ -2324,6 +2706,9 @@ async function handlePaymentsRequest(request, url, signedProjectId = null) {
|
|
|
2324
2706
|
if (scopedKey) state.idempotentCheckouts.set(scopedKey, { fingerprint, response });
|
|
2325
2707
|
return json(response);
|
|
2326
2708
|
}
|
|
2709
|
+
if (subPath === "checkout-sessions" && request.method === "GET") {
|
|
2710
|
+
return findCheckoutSessionByIntent(url, signedProjectId);
|
|
2711
|
+
}
|
|
2327
2712
|
const sessionMatch = subPath.match(/^checkout-sessions\/([^/]+)$/);
|
|
2328
2713
|
if (sessionMatch && request.method === "GET") {
|
|
2329
2714
|
const session = state.sessionStatuses.get(sessionMatch[1]);
|
|
@@ -2986,6 +3371,91 @@ async function handleEdgeRequest(request, subPath) {
|
|
|
2986
3371
|
return edgeFailure(`No edge simulator for ${method} .../edge${subPath}`, 404);
|
|
2987
3372
|
}
|
|
2988
3373
|
|
|
3374
|
+
// src/simulator/controls.ts
|
|
3375
|
+
var USER_TOKEN_HEADER = "x-stardeck-user-token";
|
|
3376
|
+
var REFUSAL_STATUS = {
|
|
3377
|
+
NOT_WRITABLE: 400,
|
|
3378
|
+
INVALID_VALUE: 400,
|
|
3379
|
+
UNAUTHENTICATED: 401,
|
|
3380
|
+
FORBIDDEN: 403,
|
|
3381
|
+
WRITE_NOT_ALLOWED_HERE: 403,
|
|
3382
|
+
PROVIDER_NOT_READY: 409,
|
|
3383
|
+
UNAVAILABLE: 503
|
|
3384
|
+
};
|
|
3385
|
+
var controlsModule = null;
|
|
3386
|
+
function loadControls() {
|
|
3387
|
+
controlsModule ??= import("@stardeck-customer-apps/controls");
|
|
3388
|
+
return controlsModule;
|
|
3389
|
+
}
|
|
3390
|
+
function currentControlsBody() {
|
|
3391
|
+
const controls = {};
|
|
3392
|
+
for (const [key, value] of Object.entries(state.controls.values)) {
|
|
3393
|
+
controls[key] = state.controls.unavailable.has(key) ? { ok: false, reason: "unavailable (simulated)" } : { ok: true, value };
|
|
3394
|
+
}
|
|
3395
|
+
return controls;
|
|
3396
|
+
}
|
|
3397
|
+
function refusalResponse(refusal) {
|
|
3398
|
+
return json({ error: refusal }, REFUSAL_STATUS[refusal.code]);
|
|
3399
|
+
}
|
|
3400
|
+
function handleSync(body) {
|
|
3401
|
+
const usage = body.usage;
|
|
3402
|
+
if (typeof usage === "object" && usage !== null) {
|
|
3403
|
+
const locations = usage.locations;
|
|
3404
|
+
state.controls.lastUsage = typeof locations === "number" && Number.isInteger(locations) && locations >= 0 ? { locations } : {};
|
|
3405
|
+
}
|
|
3406
|
+
return json({ controls: currentControlsBody() });
|
|
3407
|
+
}
|
|
3408
|
+
async function handleWrite(request, key) {
|
|
3409
|
+
const { CONTROLS, isWritableControl } = await loadControls();
|
|
3410
|
+
if (!isWritableControl(key)) {
|
|
3411
|
+
return refusalResponse({
|
|
3412
|
+
code: "NOT_WRITABLE",
|
|
3413
|
+
message: `${key} cannot be changed from the app`
|
|
3414
|
+
});
|
|
3415
|
+
}
|
|
3416
|
+
const userToken = request.headers.get(USER_TOKEN_HEADER);
|
|
3417
|
+
if (!userToken) {
|
|
3418
|
+
return refusalResponse({
|
|
3419
|
+
code: "UNAUTHENTICATED",
|
|
3420
|
+
message: "A signed-in person is required (X-Stardeck-User-Token)"
|
|
3421
|
+
});
|
|
3422
|
+
}
|
|
3423
|
+
const body = await readJsonBody(request);
|
|
3424
|
+
const parsed = CONTROLS[key].schema.safeParse(body.value);
|
|
3425
|
+
if (!parsed.success) {
|
|
3426
|
+
return refusalResponse({
|
|
3427
|
+
code: "INVALID_VALUE",
|
|
3428
|
+
message: parsed.error.issues[0]?.message ?? "Invalid value"
|
|
3429
|
+
});
|
|
3430
|
+
}
|
|
3431
|
+
const refusal = state.controls.refusal;
|
|
3432
|
+
if (refusal) {
|
|
3433
|
+
state.controls.refusal = null;
|
|
3434
|
+
return refusalResponse(refusal);
|
|
3435
|
+
}
|
|
3436
|
+
state.controls.values[key] = parsed.data;
|
|
3437
|
+
state.controls.unavailable.delete(key);
|
|
3438
|
+
state.controls.writes.push({ key, value: parsed.data, userToken });
|
|
3439
|
+
return json({ controls: currentControlsBody() });
|
|
3440
|
+
}
|
|
3441
|
+
var CONTROLS_PATH = /^\/api\/deployments\/[^/]+\/controls(?:\/([^/]+))?$/;
|
|
3442
|
+
async function handleControlsRequest(request, url) {
|
|
3443
|
+
const match = url.pathname.match(CONTROLS_PATH);
|
|
3444
|
+
const key = match?.[1] ? decodeURIComponent(match[1]) : null;
|
|
3445
|
+
if (!key && request.method === "POST") {
|
|
3446
|
+
return handleSync(await readJsonBody(request));
|
|
3447
|
+
}
|
|
3448
|
+
if (key && request.method === "PUT") {
|
|
3449
|
+
return handleWrite(request, key);
|
|
3450
|
+
}
|
|
3451
|
+
return json(
|
|
3452
|
+
{
|
|
3453
|
+
error: { code: "UNAVAILABLE", message: `No simulator for ${request.method} ${url.pathname}` }
|
|
3454
|
+
},
|
|
3455
|
+
404
|
|
3456
|
+
);
|
|
3457
|
+
}
|
|
3458
|
+
|
|
2989
3459
|
// src/simulator/schedules.ts
|
|
2990
3460
|
async function handleScheduleWake(request, scheduleKey) {
|
|
2991
3461
|
if (request.method !== "POST") return failure(`Method ${request.method} not allowed`, 405);
|
|
@@ -3134,8 +3604,10 @@ function requiresDeploymentHmac(request, url) {
|
|
|
3134
3604
|
const edgeMatch = url.pathname.match(/^\/api\/deployments\/[^/]+\/edge(\/.*)?$/);
|
|
3135
3605
|
const isStarlens = url.pathname === "/api/starlens/extract";
|
|
3136
3606
|
const scheduleWakeMatch = url.pathname.match(SCHEDULE_WAKE_PATH);
|
|
3137
|
-
|
|
3607
|
+
const controlsMatch = url.pathname.match(CONTROLS_PATH2);
|
|
3608
|
+
return !!(controlsMatch || isStarlens || scheduleWakeMatch || dataStoreMatch || isEmail2 || identitiesMatch || storeMatch || messagingMatch || edgeMatch);
|
|
3138
3609
|
}
|
|
3610
|
+
var CONTROLS_PATH2 = /^\/api\/deployments\/[^/]+\/controls(\/[^/]+)?$/;
|
|
3139
3611
|
var SCHEDULE_WAKE_PATH = /^\/api\/deployments\/[^/]+\/schedules\/([^/]+)\/wake$/;
|
|
3140
3612
|
function deploymentIdFromPath(url) {
|
|
3141
3613
|
const match = url.pathname.match(/\/api\/deployments\/([^/]+)(?:\/|$)/);
|
|
@@ -3203,6 +3675,9 @@ async function handleSimulatedRequest(request, url) {
|
|
|
3203
3675
|
if (edgeMatch) {
|
|
3204
3676
|
return handleEdgeRequest(request, edgeMatch[1] ?? "");
|
|
3205
3677
|
}
|
|
3678
|
+
if (CONTROLS_PATH2.test(url.pathname)) {
|
|
3679
|
+
return handleControlsRequest(request, url);
|
|
3680
|
+
}
|
|
3206
3681
|
const scheduleWakeMatch = url.pathname.match(SCHEDULE_WAKE_PATH);
|
|
3207
3682
|
if (scheduleWakeMatch) {
|
|
3208
3683
|
return handleScheduleWake(request, decodeURIComponent(scheduleWakeMatch[1]));
|
|
@@ -3231,7 +3706,7 @@ async function handleSimulatedRequest(request, url) {
|
|
|
3231
3706
|
}
|
|
3232
3707
|
}
|
|
3233
3708
|
return failure(
|
|
3234
|
-
`[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.`,
|
|
3709
|
+
`[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.`,
|
|
3235
3710
|
404
|
|
3236
3711
|
);
|
|
3237
3712
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stardeck-customer-apps/testing",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.29.0",
|
|
4
4
|
"description": "Vitest test harness for Stardeck customer apps — in-process Postgres (PGlite) plus a control-plane simulator so the real Stardeck SDKs run unmodified in tests",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"module": "dist/index.mjs",
|
|
@@ -65,6 +65,7 @@
|
|
|
65
65
|
"@electric-sql/pglite": "^0.3.0"
|
|
66
66
|
},
|
|
67
67
|
"peerDependencies": {
|
|
68
|
+
"@stardeck-customer-apps/controls": ">=0.1.0",
|
|
68
69
|
"@stardeck-customer-apps/edge-sdk": ">=1.4.0",
|
|
69
70
|
"@stardeck-customer-apps/integrations-sdk": ">=1.16.0",
|
|
70
71
|
"@stardeck-customer-apps/payments-sdk": ">=0.1.0",
|
|
@@ -74,6 +75,9 @@
|
|
|
74
75
|
"@stardeck-customer-apps/starlens": ">=0.1.0"
|
|
75
76
|
},
|
|
76
77
|
"peerDependenciesMeta": {
|
|
78
|
+
"@stardeck-customer-apps/controls": {
|
|
79
|
+
"optional": true
|
|
80
|
+
},
|
|
77
81
|
"@stardeck-customer-apps/integrations-sdk": {
|
|
78
82
|
"optional": true
|
|
79
83
|
},
|
|
@@ -95,6 +99,7 @@
|
|
|
95
99
|
},
|
|
96
100
|
"devDependencies": {
|
|
97
101
|
"@neondatabase/serverless": "^1.0.0",
|
|
102
|
+
"@stardeck-customer-apps/controls": "*",
|
|
98
103
|
"@stardeck-customer-apps/data-store-sdk": "*",
|
|
99
104
|
"@stardeck-customer-apps/edge-sdk": "*",
|
|
100
105
|
"@stardeck-customer-apps/email-sdk": "*",
|