@stardeck-customer-apps/testing 0.26.0 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/SKILL.md +51 -1
- package/dist/index.d.mts +117 -2
- package/dist/index.d.ts +117 -2
- package/dist/index.js +569 -17
- package/dist/index.mjs +569 -17
- package/dist/next/headers-shim.js +13 -0
- package/dist/next/headers-shim.mjs +13 -0
- package/dist/setup.js +418 -16
- package/dist/setup.mjs +418 -16
- package/package.json +10 -3
package/dist/setup.mjs
CHANGED
|
@@ -60,6 +60,11 @@ var state = globalSingleton("state", () => ({
|
|
|
60
60
|
cardTokens: /* @__PURE__ */ new Map(),
|
|
61
61
|
cardTokenCounter: 0,
|
|
62
62
|
cardTokenizationEnabled: true,
|
|
63
|
+
stripePaymentIntents: /* @__PURE__ */ new Map(),
|
|
64
|
+
stripePaymentIntentCounter: 0,
|
|
65
|
+
stripeIdempotentIntents: /* @__PURE__ */ new Map(),
|
|
66
|
+
stripeAllowedMethods: ["card", "promptpay"],
|
|
67
|
+
stripeChargesEnabled: true,
|
|
63
68
|
uploads: [],
|
|
64
69
|
uploadCounter: 0,
|
|
65
70
|
storageFiles: /* @__PURE__ */ new Map(),
|
|
@@ -79,6 +84,14 @@ var state = globalSingleton("state", () => ({
|
|
|
79
84
|
starlensCalls: [],
|
|
80
85
|
starlensReplies: [],
|
|
81
86
|
starlensDefaultReply: null,
|
|
87
|
+
controls: {
|
|
88
|
+
seed: {},
|
|
89
|
+
values: {},
|
|
90
|
+
unavailable: /* @__PURE__ */ new Set(),
|
|
91
|
+
lastUsage: null,
|
|
92
|
+
writes: [],
|
|
93
|
+
refusal: null
|
|
94
|
+
},
|
|
82
95
|
allowNetwork: false
|
|
83
96
|
}));
|
|
84
97
|
function requireDb() {
|
|
@@ -546,8 +559,8 @@ async function handleNeonSql(db, request) {
|
|
|
546
559
|
rowAsArray: arrayMode
|
|
547
560
|
});
|
|
548
561
|
} catch (error) {
|
|
549
|
-
const
|
|
550
|
-
return json({ message:
|
|
562
|
+
const err2 = error;
|
|
563
|
+
return json({ message: err2.message, code: err2.code }, 400);
|
|
551
564
|
}
|
|
552
565
|
}
|
|
553
566
|
|
|
@@ -1512,6 +1525,281 @@ var requestScopeStorage = globalSingleton(
|
|
|
1512
1525
|
() => new AsyncLocalStorage()
|
|
1513
1526
|
);
|
|
1514
1527
|
|
|
1528
|
+
// src/simulator/stripe-payment-intents.ts
|
|
1529
|
+
var SIM_STRIPE_PUBLISHABLE_KEY = "pk_test_sim_stripe";
|
|
1530
|
+
var SIM_STRIPE_DEFAULT_ACCOUNT = "acct_sim_default";
|
|
1531
|
+
var METHODS = ["card", "promptpay"];
|
|
1532
|
+
var RESERVED_METADATA_KEYS = ["projectId", "stardeckReferenceId", "stardeckStagingDeploymentId"];
|
|
1533
|
+
var PROMPTPAY_QR_TTL_SECONDS = 15 * 60;
|
|
1534
|
+
var PATH_RE = /^\/api\/store\/(?!beam\/)([^/]+)\/(payment-intents(?:\/([^/]+)(?:\/(cancel))?)?|elements-config|readiness)$/;
|
|
1535
|
+
function err(error, status, code) {
|
|
1536
|
+
return json({ error, code }, status);
|
|
1537
|
+
}
|
|
1538
|
+
function decode(segment) {
|
|
1539
|
+
try {
|
|
1540
|
+
return decodeURIComponent(segment);
|
|
1541
|
+
} catch {
|
|
1542
|
+
return segment;
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
function isPlayground(account) {
|
|
1546
|
+
return account === "playground";
|
|
1547
|
+
}
|
|
1548
|
+
function resolveAccount(account) {
|
|
1549
|
+
if (isPlayground(account)) return null;
|
|
1550
|
+
if (account === "default") {
|
|
1551
|
+
return process.env.STRIPE_CONNECT_ACCOUNT_ID || SIM_STRIPE_DEFAULT_ACCOUNT;
|
|
1552
|
+
}
|
|
1553
|
+
return account;
|
|
1554
|
+
}
|
|
1555
|
+
var UUID_RE2 = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
1556
|
+
var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
1557
|
+
var APP_METADATA_MAX_KEYS = 50 - RESERVED_METADATA_KEYS.length;
|
|
1558
|
+
function isStringRecord(value) {
|
|
1559
|
+
return !!value && typeof value === "object" && !Array.isArray(value) && Object.values(value).every((v) => typeof v === "string");
|
|
1560
|
+
}
|
|
1561
|
+
function isValidMetadata(value) {
|
|
1562
|
+
if (!isStringRecord(value)) return false;
|
|
1563
|
+
const entries = Object.entries(value);
|
|
1564
|
+
return entries.length <= APP_METADATA_MAX_KEYS && entries.every(([k, v]) => k.length >= 1 && k.length <= 40 && v.length <= 500);
|
|
1565
|
+
}
|
|
1566
|
+
function isOptional(value, test) {
|
|
1567
|
+
return value === void 0 || test(value);
|
|
1568
|
+
}
|
|
1569
|
+
var isEmail = (v) => typeof v === "string" && EMAIL_RE.test(v);
|
|
1570
|
+
function validateCreate(body) {
|
|
1571
|
+
const { amount, currency, method, referenceId, idempotencyKey } = body;
|
|
1572
|
+
const checks = [
|
|
1573
|
+
[
|
|
1574
|
+
typeof amount === "number" && Number.isInteger(amount) && amount > 0,
|
|
1575
|
+
"amount must be a positive integer in minor units"
|
|
1576
|
+
],
|
|
1577
|
+
[typeof currency === "string" && currency.length === 3, "currency must be a 3-letter ISO code"],
|
|
1578
|
+
[
|
|
1579
|
+
typeof method === "string" && METHODS.includes(method),
|
|
1580
|
+
`method must be one of: ${METHODS.join(", ")}`
|
|
1581
|
+
],
|
|
1582
|
+
[
|
|
1583
|
+
typeof referenceId === "string" && referenceId.length >= 1 && referenceId.length <= 200,
|
|
1584
|
+
"referenceId must be 1 to 200 characters"
|
|
1585
|
+
],
|
|
1586
|
+
[
|
|
1587
|
+
typeof idempotencyKey === "string" && idempotencyKey.length >= 1 && idempotencyKey.length <= 200,
|
|
1588
|
+
"idempotencyKey is required (1 to 200 characters)"
|
|
1589
|
+
],
|
|
1590
|
+
[
|
|
1591
|
+
isOptional(body.description, (v) => typeof v === "string" && v.length <= 1e3),
|
|
1592
|
+
"description must be at most 1000 characters"
|
|
1593
|
+
],
|
|
1594
|
+
[isOptional(body.receiptEmail, isEmail), "receiptEmail must be an email address"],
|
|
1595
|
+
[isOptional(body.billingEmail, isEmail), "billingEmail must be an email address"],
|
|
1596
|
+
[
|
|
1597
|
+
isOptional(body.returnUrl, (v) => typeof v === "string" && URL.canParse(v)),
|
|
1598
|
+
"returnUrl must be an absolute URL"
|
|
1599
|
+
],
|
|
1600
|
+
[
|
|
1601
|
+
isOptional(body.metadata, isValidMetadata),
|
|
1602
|
+
`metadata must map keys (1-40 chars) to strings (max 500), at most ${APP_METADATA_MAX_KEYS} keys`
|
|
1603
|
+
],
|
|
1604
|
+
[
|
|
1605
|
+
typeof body.deploymentId === "string" && UUID_RE2.test(body.deploymentId),
|
|
1606
|
+
"Invalid deployment ID"
|
|
1607
|
+
]
|
|
1608
|
+
];
|
|
1609
|
+
const failed = checks.find(([ok]) => !ok);
|
|
1610
|
+
return failed ? failed[1] : null;
|
|
1611
|
+
}
|
|
1612
|
+
function appMetadata(raw) {
|
|
1613
|
+
if (!isStringRecord(raw)) return {};
|
|
1614
|
+
const out = {};
|
|
1615
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
1616
|
+
if (!RESERVED_METADATA_KEYS.includes(key)) out[key] = value;
|
|
1617
|
+
}
|
|
1618
|
+
return out;
|
|
1619
|
+
}
|
|
1620
|
+
function stableStringify(value) {
|
|
1621
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
|
|
1622
|
+
if (value && typeof value === "object") {
|
|
1623
|
+
const entries = Object.entries(value).filter(([, v]) => v !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
1624
|
+
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`).join(",")}}`;
|
|
1625
|
+
}
|
|
1626
|
+
return JSON.stringify(value);
|
|
1627
|
+
}
|
|
1628
|
+
function toStripePaymentIntentSummary(intent) {
|
|
1629
|
+
const open = intent.status !== "succeeded" && intent.status !== "canceled";
|
|
1630
|
+
let actionRequired = "NONE";
|
|
1631
|
+
if (open && intent.method === "card") actionRequired = "CLIENT_CONFIRM";
|
|
1632
|
+
if (open && intent.method === "promptpay" && intent.status === "requires_action") {
|
|
1633
|
+
actionRequired = "ENCODED_IMAGE";
|
|
1634
|
+
}
|
|
1635
|
+
return {
|
|
1636
|
+
id: intent.id,
|
|
1637
|
+
status: intent.status,
|
|
1638
|
+
amount: intent.amount,
|
|
1639
|
+
currency: intent.currency,
|
|
1640
|
+
method: intent.method,
|
|
1641
|
+
referenceId: intent.referenceId,
|
|
1642
|
+
metadata: { ...intent.appMetadata },
|
|
1643
|
+
actionRequired,
|
|
1644
|
+
clientSecret: intent.method === "card" ? intent.clientSecret : null,
|
|
1645
|
+
qr: actionRequired === "ENCODED_IMAGE" ? { ...intent.qr } : null,
|
|
1646
|
+
latestChargeId: intent.latestChargeId,
|
|
1647
|
+
amountRefunded: intent.amountRefunded,
|
|
1648
|
+
lastPaymentError: intent.lastPaymentError ? { ...intent.lastPaymentError } : null,
|
|
1649
|
+
created: intent.created,
|
|
1650
|
+
livemode: false,
|
|
1651
|
+
playground: intent.playground
|
|
1652
|
+
};
|
|
1653
|
+
}
|
|
1654
|
+
function promptPayQr(id, created) {
|
|
1655
|
+
return {
|
|
1656
|
+
imageUrlPng: `https://qr.stripe.test/${id}.png`,
|
|
1657
|
+
imageUrlSvg: `https://qr.stripe.test/${id}.svg`,
|
|
1658
|
+
data: `00020101021230SIM${id}`,
|
|
1659
|
+
hostedInstructionsUrl: `https://payments.stripe.test/promptpay/${id}`,
|
|
1660
|
+
expiresAt: created + PROMPTPAY_QR_TTL_SECONDS
|
|
1661
|
+
};
|
|
1662
|
+
}
|
|
1663
|
+
function createIntent(body, account, projectId) {
|
|
1664
|
+
const problem = validateCreate(body);
|
|
1665
|
+
if (problem) return err(problem, 400, "invalid_request");
|
|
1666
|
+
const method = body.method;
|
|
1667
|
+
const currency = body.currency.toLowerCase();
|
|
1668
|
+
if (!state.stripeAllowedMethods.includes(method)) {
|
|
1669
|
+
return err(
|
|
1670
|
+
`The ${method} payment method is turned off for this account`,
|
|
1671
|
+
422,
|
|
1672
|
+
"method_not_allowed"
|
|
1673
|
+
);
|
|
1674
|
+
}
|
|
1675
|
+
if (method === "promptpay" && currency !== "thb") {
|
|
1676
|
+
return err("PromptPay only takes THB", 422, "currency_not_supported");
|
|
1677
|
+
}
|
|
1678
|
+
if (!state.stripeChargesEnabled) {
|
|
1679
|
+
return err("This Stripe account can't take payments yet", 503, "account_not_ready");
|
|
1680
|
+
}
|
|
1681
|
+
const scopedKey = JSON.stringify(["pi", projectId, account, body.idempotencyKey]);
|
|
1682
|
+
const comparable = { ...body };
|
|
1683
|
+
delete comparable.deploymentId;
|
|
1684
|
+
delete comparable.idempotencyKey;
|
|
1685
|
+
const fingerprint = stableStringify(comparable);
|
|
1686
|
+
const saved = state.stripeIdempotentIntents.get(scopedKey);
|
|
1687
|
+
if (saved) {
|
|
1688
|
+
if (saved.fingerprint !== fingerprint) {
|
|
1689
|
+
return err(
|
|
1690
|
+
"This idempotency key was already used with different parameters. Use a new key for a different payment.",
|
|
1691
|
+
409,
|
|
1692
|
+
"idempotency_conflict"
|
|
1693
|
+
);
|
|
1694
|
+
}
|
|
1695
|
+
const existing = state.stripePaymentIntents.get(saved.id);
|
|
1696
|
+
return json({ paymentIntent: toStripePaymentIntentSummary(existing) }, 201);
|
|
1697
|
+
}
|
|
1698
|
+
state.stripePaymentIntentCounter += 1;
|
|
1699
|
+
const id = `pi_sim_${state.stripePaymentIntentCounter}`;
|
|
1700
|
+
const created = Math.floor(Date.now() / 1e3);
|
|
1701
|
+
const referenceId = body.referenceId;
|
|
1702
|
+
const metadata = appMetadata(body.metadata);
|
|
1703
|
+
const intent = {
|
|
1704
|
+
id,
|
|
1705
|
+
projectId,
|
|
1706
|
+
account,
|
|
1707
|
+
connectedAccountId: resolveAccount(account),
|
|
1708
|
+
playground: isPlayground(account),
|
|
1709
|
+
status: method === "card" ? "requires_payment_method" : "requires_action",
|
|
1710
|
+
amount: body.amount,
|
|
1711
|
+
currency,
|
|
1712
|
+
method,
|
|
1713
|
+
referenceId,
|
|
1714
|
+
appMetadata: metadata,
|
|
1715
|
+
metadata: { ...metadata, projectId, stardeckReferenceId: referenceId },
|
|
1716
|
+
description: typeof body.description === "string" ? body.description : null,
|
|
1717
|
+
receiptEmail: typeof body.receiptEmail === "string" ? body.receiptEmail : null,
|
|
1718
|
+
returnUrl: typeof body.returnUrl === "string" ? body.returnUrl : null,
|
|
1719
|
+
clientSecret: `${id}_secret_sim`,
|
|
1720
|
+
qr: method === "promptpay" ? promptPayQr(id, created) : null,
|
|
1721
|
+
latestChargeId: null,
|
|
1722
|
+
amountRefunded: 0,
|
|
1723
|
+
lastPaymentError: null,
|
|
1724
|
+
created
|
|
1725
|
+
};
|
|
1726
|
+
state.stripePaymentIntents.set(id, intent);
|
|
1727
|
+
state.stripeIdempotentIntents.set(scopedKey, { fingerprint, id });
|
|
1728
|
+
console.log(
|
|
1729
|
+
`[stardeck-testing] stripe payment intent ${id} created method=${method} amount=${intent.amount} ${currency}`
|
|
1730
|
+
);
|
|
1731
|
+
return json({ paymentIntent: toStripePaymentIntentSummary(intent) }, 201);
|
|
1732
|
+
}
|
|
1733
|
+
function ownedIntent(id, projectId) {
|
|
1734
|
+
const intent = state.stripePaymentIntents.get(id);
|
|
1735
|
+
return intent && intent.projectId === projectId ? intent : void 0;
|
|
1736
|
+
}
|
|
1737
|
+
function cancelIntent(intent) {
|
|
1738
|
+
if (intent.status === "succeeded") {
|
|
1739
|
+
return err("This payment already succeeded and can't be canceled", 409, "already_succeeded");
|
|
1740
|
+
}
|
|
1741
|
+
intent.status = "canceled";
|
|
1742
|
+
return json({ paymentIntent: toStripePaymentIntentSummary(intent) });
|
|
1743
|
+
}
|
|
1744
|
+
function handleIntents(request, url, account, intentId, action, projectId) {
|
|
1745
|
+
if (!intentId && request.method === "POST") {
|
|
1746
|
+
return readJsonBody(request).then((body) => createIntent(body, account, projectId));
|
|
1747
|
+
}
|
|
1748
|
+
if (!intentId && request.method === "GET") {
|
|
1749
|
+
const referenceId = url.searchParams.get("referenceId");
|
|
1750
|
+
if (!referenceId) return err("referenceId is required", 400, "invalid_request");
|
|
1751
|
+
const paymentIntents = [...state.stripePaymentIntents.values()].filter((i) => i.projectId === projectId && i.referenceId === referenceId).map(toStripePaymentIntentSummary);
|
|
1752
|
+
return json({ paymentIntents });
|
|
1753
|
+
}
|
|
1754
|
+
if (!intentId) return null;
|
|
1755
|
+
const intent = ownedIntent(decode(intentId), projectId);
|
|
1756
|
+
if (!action && request.method === "GET") {
|
|
1757
|
+
if (!intent) return err("Payment intent not found", 404, "not_found");
|
|
1758
|
+
return json({ paymentIntent: toStripePaymentIntentSummary(intent) });
|
|
1759
|
+
}
|
|
1760
|
+
if (action === "cancel" && request.method === "POST") {
|
|
1761
|
+
return readJsonBody(request).then((body) => {
|
|
1762
|
+
if (typeof body.deploymentId !== "string" || !UUID_RE2.test(body.deploymentId)) {
|
|
1763
|
+
return err("deploymentId is required", 400, "invalid_request");
|
|
1764
|
+
}
|
|
1765
|
+
if (!intent) return err("Payment intent not found", 404, "not_found");
|
|
1766
|
+
return cancelIntent(intent);
|
|
1767
|
+
});
|
|
1768
|
+
}
|
|
1769
|
+
return null;
|
|
1770
|
+
}
|
|
1771
|
+
async function handleStripePaymentIntentRequest(request, url, projectId) {
|
|
1772
|
+
const match = url.pathname.match(PATH_RE);
|
|
1773
|
+
if (!match) return null;
|
|
1774
|
+
if (!projectId) return err("Missing authentication", 401, "unauthorized");
|
|
1775
|
+
const account = decode(match[1]);
|
|
1776
|
+
const resource = match[2];
|
|
1777
|
+
if (request.method === "GET" && !url.searchParams.get("deploymentId")) {
|
|
1778
|
+
return err("deploymentId is required", 400, "invalid_request");
|
|
1779
|
+
}
|
|
1780
|
+
if (resource === "elements-config" && request.method === "GET") {
|
|
1781
|
+
return json({
|
|
1782
|
+
publishableKey: SIM_STRIPE_PUBLISHABLE_KEY,
|
|
1783
|
+
stripeAccount: resolveAccount(account),
|
|
1784
|
+
playground: isPlayground(account)
|
|
1785
|
+
});
|
|
1786
|
+
}
|
|
1787
|
+
if (resource === "readiness" && request.method === "GET") {
|
|
1788
|
+
const methods = [...state.stripeAllowedMethods];
|
|
1789
|
+
return json({
|
|
1790
|
+
provider: "stripe",
|
|
1791
|
+
ready: state.stripeChargesEnabled && methods.length > 0,
|
|
1792
|
+
chargesEnabled: state.stripeChargesEnabled,
|
|
1793
|
+
methods,
|
|
1794
|
+
playground: isPlayground(account)
|
|
1795
|
+
});
|
|
1796
|
+
}
|
|
1797
|
+
if (resource.startsWith("payment-intents")) {
|
|
1798
|
+
return handleIntents(request, url, account, match[3], match[4], projectId);
|
|
1799
|
+
}
|
|
1800
|
+
return null;
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1515
1803
|
// src/simulator/webhook-signing.ts
|
|
1516
1804
|
import crypto4 from "crypto";
|
|
1517
1805
|
|
|
@@ -1531,7 +1819,7 @@ var BEAM_BOLT_PAYMENT_METHODS = [
|
|
|
1531
1819
|
"SPAY_LATER"
|
|
1532
1820
|
];
|
|
1533
1821
|
var BEAM_BOLT_INTENT_STATUSES = ["PENDING", "PAID", "FAILED", "CANCELED", "EXPIRED"];
|
|
1534
|
-
var
|
|
1822
|
+
var UUID_RE3 = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
1535
1823
|
function payErr(error, status = 400, code, details) {
|
|
1536
1824
|
const body = { error };
|
|
1537
1825
|
if (code) body.code = code;
|
|
@@ -1557,7 +1845,23 @@ function paymentOperationForRequest(request, pathname) {
|
|
|
1557
1845
|
if (request.method === "GET" && /^\/api\/store\/(?!beam\/)[^/]+\/checkout-sessions\/[^/]+$/.test(pathname)) {
|
|
1558
1846
|
return "stripe.getCheckoutSessionStatus";
|
|
1559
1847
|
}
|
|
1560
|
-
return
|
|
1848
|
+
return stripeInlineOperation(request.method, pathname);
|
|
1849
|
+
}
|
|
1850
|
+
var STRIPE_INLINE_OPERATIONS = [
|
|
1851
|
+
["POST", /^\/api\/store\/(?!beam\/)[^/]+\/payment-intents$/, "stripe.createPaymentIntent"],
|
|
1852
|
+
["GET", /^\/api\/store\/(?!beam\/)[^/]+\/payment-intents$/, "stripe.findPaymentIntents"],
|
|
1853
|
+
["GET", /^\/api\/store\/(?!beam\/)[^/]+\/payment-intents\/[^/]+$/, "stripe.getPaymentIntent"],
|
|
1854
|
+
[
|
|
1855
|
+
"POST",
|
|
1856
|
+
/^\/api\/store\/(?!beam\/)[^/]+\/payment-intents\/[^/]+\/cancel$/,
|
|
1857
|
+
"stripe.cancelPaymentIntent"
|
|
1858
|
+
],
|
|
1859
|
+
["GET", /^\/api\/store\/(?!beam\/)[^/]+\/elements-config$/, "stripe.getElementsConfig"],
|
|
1860
|
+
["GET", /^\/api\/store\/(?!beam\/)[^/]+\/readiness$/, "stripe.getReadiness"]
|
|
1861
|
+
];
|
|
1862
|
+
function stripeInlineOperation(method, pathname) {
|
|
1863
|
+
const hit = STRIPE_INLINE_OPERATIONS.find(([m, re]) => m === method && re.test(pathname));
|
|
1864
|
+
return hit ? hit[2] : null;
|
|
1561
1865
|
}
|
|
1562
1866
|
function consumePaymentFailure(request, pathname) {
|
|
1563
1867
|
const operation = paymentOperationForRequest(request, pathname);
|
|
@@ -1587,11 +1891,11 @@ function isBoltPaymentMethod(value) {
|
|
|
1587
1891
|
function isBoltIntentStatus(value) {
|
|
1588
1892
|
return BEAM_BOLT_INTENT_STATUSES.includes(value);
|
|
1589
1893
|
}
|
|
1590
|
-
function
|
|
1591
|
-
if (Array.isArray(value)) return `[${value.map(
|
|
1894
|
+
function stableStringify2(value) {
|
|
1895
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify2).join(",")}]`;
|
|
1592
1896
|
if (value && typeof value === "object") {
|
|
1593
1897
|
const entries = Object.entries(value).filter(([, v]) => v !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
1594
|
-
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${
|
|
1898
|
+
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stableStringify2(v)}`).join(",")}}`;
|
|
1595
1899
|
}
|
|
1596
1900
|
return JSON.stringify(value);
|
|
1597
1901
|
}
|
|
@@ -1697,10 +2001,16 @@ function storedStatusesForFilter(statuses) {
|
|
|
1697
2001
|
}
|
|
1698
2002
|
return [...stored];
|
|
1699
2003
|
}
|
|
1700
|
-
async function handlePaymentsRequest(request, url) {
|
|
2004
|
+
async function handlePaymentsRequest(request, url, signedProjectId = null) {
|
|
1701
2005
|
const pathname = url.pathname;
|
|
1702
2006
|
const injectedFailure = consumePaymentFailure(request, pathname);
|
|
1703
2007
|
if (injectedFailure) return injectedFailure;
|
|
2008
|
+
const inlineStripeResponse = await handleStripePaymentIntentRequest(
|
|
2009
|
+
request,
|
|
2010
|
+
url,
|
|
2011
|
+
signedProjectId
|
|
2012
|
+
);
|
|
2013
|
+
if (inlineStripeResponse) return inlineStripeResponse;
|
|
1704
2014
|
const directChargeResponse = await handleDirectChargeRequest(request, pathname);
|
|
1705
2015
|
if (directChargeResponse) return directChargeResponse;
|
|
1706
2016
|
if (/\/billing-portal$/.test(pathname)) {
|
|
@@ -1799,7 +2109,7 @@ async function handlePaymentsRequest(request, url) {
|
|
|
1799
2109
|
code: "too_small"
|
|
1800
2110
|
});
|
|
1801
2111
|
}
|
|
1802
|
-
if (typeof body.deploymentId !== "string" || !
|
|
2112
|
+
if (typeof body.deploymentId !== "string" || !UUID_RE3.test(body.deploymentId)) {
|
|
1803
2113
|
issues.push({
|
|
1804
2114
|
path: ["deploymentId"],
|
|
1805
2115
|
message: "Invalid uuid",
|
|
@@ -1983,7 +2293,7 @@ async function handlePaymentsRequest(request, url) {
|
|
|
1983
2293
|
const comparable = { ...body };
|
|
1984
2294
|
delete comparable.deploymentId;
|
|
1985
2295
|
delete comparable.idempotencyKey;
|
|
1986
|
-
const fingerprint =
|
|
2296
|
+
const fingerprint = stableStringify2(comparable);
|
|
1987
2297
|
if (scopedKey) {
|
|
1988
2298
|
const saved = state.idempotentCheckouts.get(scopedKey);
|
|
1989
2299
|
if (saved) {
|
|
@@ -2684,6 +2994,91 @@ async function handleEdgeRequest(request, subPath) {
|
|
|
2684
2994
|
return edgeFailure(`No edge simulator for ${method} .../edge${subPath}`, 404);
|
|
2685
2995
|
}
|
|
2686
2996
|
|
|
2997
|
+
// src/simulator/controls.ts
|
|
2998
|
+
var USER_TOKEN_HEADER = "x-stardeck-user-token";
|
|
2999
|
+
var REFUSAL_STATUS = {
|
|
3000
|
+
NOT_WRITABLE: 400,
|
|
3001
|
+
INVALID_VALUE: 400,
|
|
3002
|
+
UNAUTHENTICATED: 401,
|
|
3003
|
+
FORBIDDEN: 403,
|
|
3004
|
+
WRITE_NOT_ALLOWED_HERE: 403,
|
|
3005
|
+
PROVIDER_NOT_READY: 409,
|
|
3006
|
+
UNAVAILABLE: 503
|
|
3007
|
+
};
|
|
3008
|
+
var controlsModule = null;
|
|
3009
|
+
function loadControls() {
|
|
3010
|
+
controlsModule ??= import("@stardeck-customer-apps/controls");
|
|
3011
|
+
return controlsModule;
|
|
3012
|
+
}
|
|
3013
|
+
function currentControlsBody() {
|
|
3014
|
+
const controls = {};
|
|
3015
|
+
for (const [key, value] of Object.entries(state.controls.values)) {
|
|
3016
|
+
controls[key] = state.controls.unavailable.has(key) ? { ok: false, reason: "unavailable (simulated)" } : { ok: true, value };
|
|
3017
|
+
}
|
|
3018
|
+
return controls;
|
|
3019
|
+
}
|
|
3020
|
+
function refusalResponse(refusal) {
|
|
3021
|
+
return json({ error: refusal }, REFUSAL_STATUS[refusal.code]);
|
|
3022
|
+
}
|
|
3023
|
+
function handleSync(body) {
|
|
3024
|
+
const usage = body.usage;
|
|
3025
|
+
if (typeof usage === "object" && usage !== null) {
|
|
3026
|
+
const locations = usage.locations;
|
|
3027
|
+
state.controls.lastUsage = typeof locations === "number" && Number.isInteger(locations) && locations >= 0 ? { locations } : {};
|
|
3028
|
+
}
|
|
3029
|
+
return json({ controls: currentControlsBody() });
|
|
3030
|
+
}
|
|
3031
|
+
async function handleWrite(request, key) {
|
|
3032
|
+
const { CONTROLS, isWritableControl } = await loadControls();
|
|
3033
|
+
if (!isWritableControl(key)) {
|
|
3034
|
+
return refusalResponse({
|
|
3035
|
+
code: "NOT_WRITABLE",
|
|
3036
|
+
message: `${key} cannot be changed from the app`
|
|
3037
|
+
});
|
|
3038
|
+
}
|
|
3039
|
+
const userToken = request.headers.get(USER_TOKEN_HEADER);
|
|
3040
|
+
if (!userToken) {
|
|
3041
|
+
return refusalResponse({
|
|
3042
|
+
code: "UNAUTHENTICATED",
|
|
3043
|
+
message: "A signed-in person is required (X-Stardeck-User-Token)"
|
|
3044
|
+
});
|
|
3045
|
+
}
|
|
3046
|
+
const body = await readJsonBody(request);
|
|
3047
|
+
const parsed = CONTROLS[key].schema.safeParse(body.value);
|
|
3048
|
+
if (!parsed.success) {
|
|
3049
|
+
return refusalResponse({
|
|
3050
|
+
code: "INVALID_VALUE",
|
|
3051
|
+
message: parsed.error.issues[0]?.message ?? "Invalid value"
|
|
3052
|
+
});
|
|
3053
|
+
}
|
|
3054
|
+
const refusal = state.controls.refusal;
|
|
3055
|
+
if (refusal) {
|
|
3056
|
+
state.controls.refusal = null;
|
|
3057
|
+
return refusalResponse(refusal);
|
|
3058
|
+
}
|
|
3059
|
+
state.controls.values[key] = parsed.data;
|
|
3060
|
+
state.controls.unavailable.delete(key);
|
|
3061
|
+
state.controls.writes.push({ key, value: parsed.data, userToken });
|
|
3062
|
+
return json({ controls: currentControlsBody() });
|
|
3063
|
+
}
|
|
3064
|
+
var CONTROLS_PATH = /^\/api\/deployments\/[^/]+\/controls(?:\/([^/]+))?$/;
|
|
3065
|
+
async function handleControlsRequest(request, url) {
|
|
3066
|
+
const match = url.pathname.match(CONTROLS_PATH);
|
|
3067
|
+
const key = match?.[1] ? decodeURIComponent(match[1]) : null;
|
|
3068
|
+
if (!key && request.method === "POST") {
|
|
3069
|
+
return handleSync(await readJsonBody(request));
|
|
3070
|
+
}
|
|
3071
|
+
if (key && request.method === "PUT") {
|
|
3072
|
+
return handleWrite(request, key);
|
|
3073
|
+
}
|
|
3074
|
+
return json(
|
|
3075
|
+
{
|
|
3076
|
+
error: { code: "UNAVAILABLE", message: `No simulator for ${request.method} ${url.pathname}` }
|
|
3077
|
+
},
|
|
3078
|
+
404
|
|
3079
|
+
);
|
|
3080
|
+
}
|
|
3081
|
+
|
|
2687
3082
|
// src/simulator/schedules.ts
|
|
2688
3083
|
async function handleScheduleWake(request, scheduleKey) {
|
|
2689
3084
|
if (request.method !== "POST") return failure(`Method ${request.method} not allowed`, 405);
|
|
@@ -2823,7 +3218,7 @@ function requiresDeploymentHmac(request, url) {
|
|
|
2823
3218
|
return !isPresignedPut;
|
|
2824
3219
|
}
|
|
2825
3220
|
const dataStoreMatch = url.pathname.match(/^\/api\/data-stores\/[^/]+(\/.*)?$/);
|
|
2826
|
-
const
|
|
3221
|
+
const isEmail2 = url.pathname === "/api/email/send";
|
|
2827
3222
|
const identitiesMatch = url.pathname.match(/^\/api\/deployments\/[^/]+\/identities(\/.*)?$/);
|
|
2828
3223
|
const storeMatch = url.pathname.match(/^\/api\/store\//);
|
|
2829
3224
|
const messagingMatch = url.pathname.match(
|
|
@@ -2832,8 +3227,10 @@ function requiresDeploymentHmac(request, url) {
|
|
|
2832
3227
|
const edgeMatch = url.pathname.match(/^\/api\/deployments\/[^/]+\/edge(\/.*)?$/);
|
|
2833
3228
|
const isStarlens = url.pathname === "/api/starlens/extract";
|
|
2834
3229
|
const scheduleWakeMatch = url.pathname.match(SCHEDULE_WAKE_PATH);
|
|
2835
|
-
|
|
3230
|
+
const controlsMatch = url.pathname.match(CONTROLS_PATH2);
|
|
3231
|
+
return !!(controlsMatch || isStarlens || scheduleWakeMatch || dataStoreMatch || isEmail2 || identitiesMatch || storeMatch || messagingMatch || edgeMatch);
|
|
2836
3232
|
}
|
|
3233
|
+
var CONTROLS_PATH2 = /^\/api\/deployments\/[^/]+\/controls(\/[^/]+)?$/;
|
|
2837
3234
|
var SCHEDULE_WAKE_PATH = /^\/api\/deployments\/[^/]+\/schedules\/([^/]+)\/wake$/;
|
|
2838
3235
|
function deploymentIdFromPath(url) {
|
|
2839
3236
|
const match = url.pathname.match(/\/api\/deployments\/([^/]+)(?:\/|$)/);
|
|
@@ -2855,7 +3252,7 @@ async function handleSimulatedRequest(request, url) {
|
|
|
2855
3252
|
return authMatch[1].endsWith("verify") ? handleAuthVerify(request) : handleAuthRefresh(request);
|
|
2856
3253
|
}
|
|
2857
3254
|
const dataStoreMatch = url.pathname.match(/^\/api\/data-stores\/[^/]+(\/.*)?$/);
|
|
2858
|
-
const
|
|
3255
|
+
const isEmail2 = url.pathname === "/api/email/send";
|
|
2859
3256
|
const identitiesMatch = url.pathname.match(/^\/api\/deployments\/[^/]+\/identities(\/.*)?$/);
|
|
2860
3257
|
const storeMatch = url.pathname.match(/^\/api\/store\//);
|
|
2861
3258
|
const messagingMatch = url.pathname.match(
|
|
@@ -2864,6 +3261,7 @@ async function handleSimulatedRequest(request, url) {
|
|
|
2864
3261
|
const edgeMatch = url.pathname.match(/^\/api\/deployments\/[^/]+\/edge(\/.*)?$/);
|
|
2865
3262
|
const isStorageHost = url.hostname === STORAGE_TEST_HOST;
|
|
2866
3263
|
let signedDeploymentId = null;
|
|
3264
|
+
let signedProjectId = null;
|
|
2867
3265
|
if (requiresDeploymentHmac(request, url)) {
|
|
2868
3266
|
const authHeader = request.headers.get("X-Stardeck-Auth");
|
|
2869
3267
|
if (!authHeader) {
|
|
@@ -2876,6 +3274,7 @@ async function handleSimulatedRequest(request, url) {
|
|
|
2876
3274
|
return failure("Invalid authentication", 401);
|
|
2877
3275
|
}
|
|
2878
3276
|
signedDeploymentId = authPayload.deploymentId;
|
|
3277
|
+
signedProjectId = authPayload.projectId;
|
|
2879
3278
|
}
|
|
2880
3279
|
if (isStorageHost) {
|
|
2881
3280
|
return handleStorageRequest(request, url);
|
|
@@ -2883,14 +3282,14 @@ async function handleSimulatedRequest(request, url) {
|
|
|
2883
3282
|
if (url.hostname === SIM_BEAM_HOST) {
|
|
2884
3283
|
return handleCardTokenRequest(request, url);
|
|
2885
3284
|
}
|
|
2886
|
-
if (
|
|
3285
|
+
if (isEmail2 && request.method === "POST") {
|
|
2887
3286
|
return handleEmailSend(request);
|
|
2888
3287
|
}
|
|
2889
3288
|
if (identitiesMatch) {
|
|
2890
3289
|
return handleIdentitiesRequest(request, identitiesMatch[1] ?? "");
|
|
2891
3290
|
}
|
|
2892
3291
|
if (storeMatch) {
|
|
2893
|
-
return handlePaymentsRequest(request, url);
|
|
3292
|
+
return handlePaymentsRequest(request, url, signedProjectId);
|
|
2894
3293
|
}
|
|
2895
3294
|
if (messagingMatch) {
|
|
2896
3295
|
const channel = messagingMatch[1];
|
|
@@ -2899,6 +3298,9 @@ async function handleSimulatedRequest(request, url) {
|
|
|
2899
3298
|
if (edgeMatch) {
|
|
2900
3299
|
return handleEdgeRequest(request, edgeMatch[1] ?? "");
|
|
2901
3300
|
}
|
|
3301
|
+
if (CONTROLS_PATH2.test(url.pathname)) {
|
|
3302
|
+
return handleControlsRequest(request, url);
|
|
3303
|
+
}
|
|
2902
3304
|
const scheduleWakeMatch = url.pathname.match(SCHEDULE_WAKE_PATH);
|
|
2903
3305
|
if (scheduleWakeMatch) {
|
|
2904
3306
|
return handleScheduleWake(request, decodeURIComponent(scheduleWakeMatch[1]));
|
|
@@ -2927,7 +3329,7 @@ async function handleSimulatedRequest(request, url) {
|
|
|
2927
3329
|
}
|
|
2928
3330
|
}
|
|
2929
3331
|
return failure(
|
|
2930
|
-
`[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.`,
|
|
3332
|
+
`[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.`,
|
|
2931
3333
|
404
|
|
2932
3334
|
);
|
|
2933
3335
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stardeck-customer-apps/testing",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.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",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"build": "tsup src/index.ts src/config.ts src/setup.ts src/next/headers-shim.ts src/server-only-shim.ts --format cjs,esm --dts --no-splitting",
|
|
49
49
|
"dev": "tsup src/index.ts src/config.ts src/setup.ts src/next/headers-shim.ts src/server-only-shim.ts --format cjs,esm --dts --no-splitting --watch",
|
|
50
50
|
"format": "prettier --write . && oxlint . --fix",
|
|
51
|
-
"typecheck": "
|
|
51
|
+
"typecheck": "node ../../scripts/typecheck.mjs --noEmit && node ../../scripts/typecheck.mjs -p tsconfig.test.json",
|
|
52
52
|
"lint": "oxlint src/",
|
|
53
53
|
"test": "vitest run",
|
|
54
54
|
"test:watch": "vitest"
|
|
@@ -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": "*",
|
|
@@ -111,6 +116,8 @@
|
|
|
111
116
|
"typescript": "^5.0.0",
|
|
112
117
|
"vitest": "^3.2.4",
|
|
113
118
|
"@stardeck-customer-apps/starlens": "*",
|
|
114
|
-
"zod": "^4.3.5"
|
|
119
|
+
"zod": "^4.3.5",
|
|
120
|
+
"react": "19.2.8",
|
|
121
|
+
"react-dom": "19.2.8"
|
|
115
122
|
}
|
|
116
123
|
}
|