billsdk 0.3.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/index.d.ts +7 -1
- package/dist/client/index.js +52 -2
- package/dist/client/index.js.map +1 -1
- package/dist/client/react/index.js +52 -2
- package/dist/client/react/index.js.map +1 -1
- package/dist/index.js +299 -4
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
package/dist/index.js
CHANGED
|
@@ -9,6 +9,137 @@ import { z } from 'zod';
|
|
|
9
9
|
import { getBillingSchema, TABLES } from '@billsdk/core/db';
|
|
10
10
|
|
|
11
11
|
// src/index.ts
|
|
12
|
+
|
|
13
|
+
// src/utils/csrf.ts
|
|
14
|
+
var ALGORITHM = "HMAC";
|
|
15
|
+
var HASH = "SHA-256";
|
|
16
|
+
var SEPARATOR = ".";
|
|
17
|
+
var DEFAULT_TTL_SECONDS = 3600;
|
|
18
|
+
async function importKey(secret) {
|
|
19
|
+
const encoder = new TextEncoder();
|
|
20
|
+
return crypto.subtle.importKey(
|
|
21
|
+
"raw",
|
|
22
|
+
encoder.encode(secret),
|
|
23
|
+
{ name: ALGORITHM, hash: HASH },
|
|
24
|
+
false,
|
|
25
|
+
["sign", "verify"]
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
function bufferToHex(buffer) {
|
|
29
|
+
return Array.from(new Uint8Array(buffer)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
30
|
+
}
|
|
31
|
+
function hexToBuffer(hex) {
|
|
32
|
+
const bytes = new Uint8Array(hex.length / 2);
|
|
33
|
+
for (let i = 0; i < hex.length; i += 2) {
|
|
34
|
+
bytes[i / 2] = Number.parseInt(hex.slice(i, i + 2), 16);
|
|
35
|
+
}
|
|
36
|
+
return bytes;
|
|
37
|
+
}
|
|
38
|
+
function generateRandom() {
|
|
39
|
+
const bytes = new Uint8Array(32);
|
|
40
|
+
crypto.getRandomValues(bytes);
|
|
41
|
+
return bufferToHex(bytes.buffer);
|
|
42
|
+
}
|
|
43
|
+
async function generateCsrfToken(secret) {
|
|
44
|
+
const timestamp = Math.floor(Date.now() / 1e3).toString(16);
|
|
45
|
+
const random = generateRandom();
|
|
46
|
+
const payload = `${timestamp}${SEPARATOR}${random}`;
|
|
47
|
+
const key = await importKey(secret);
|
|
48
|
+
const encoder = new TextEncoder();
|
|
49
|
+
const signature = await crypto.subtle.sign(
|
|
50
|
+
ALGORITHM,
|
|
51
|
+
key,
|
|
52
|
+
encoder.encode(payload)
|
|
53
|
+
);
|
|
54
|
+
return `${payload}${SEPARATOR}${bufferToHex(signature)}`;
|
|
55
|
+
}
|
|
56
|
+
async function verifyCsrfToken(token, secret, ttlSeconds = DEFAULT_TTL_SECONDS) {
|
|
57
|
+
const lastSep = token.lastIndexOf(SEPARATOR);
|
|
58
|
+
if (lastSep === -1) return false;
|
|
59
|
+
const payload = token.slice(0, lastSep);
|
|
60
|
+
const signatureHex = token.slice(lastSep + 1);
|
|
61
|
+
if (!payload || !signatureHex) return false;
|
|
62
|
+
const firstSep = payload.indexOf(SEPARATOR);
|
|
63
|
+
if (firstSep === -1) return false;
|
|
64
|
+
const timestampHex = payload.slice(0, firstSep);
|
|
65
|
+
const timestamp = Number.parseInt(timestampHex, 16);
|
|
66
|
+
if (Number.isNaN(timestamp)) return false;
|
|
67
|
+
const nowSeconds = Math.floor(Date.now() / 1e3);
|
|
68
|
+
if (nowSeconds - timestamp > ttlSeconds) return false;
|
|
69
|
+
try {
|
|
70
|
+
const key = await importKey(secret);
|
|
71
|
+
const encoder = new TextEncoder();
|
|
72
|
+
const signatureBytes = hexToBuffer(signatureHex);
|
|
73
|
+
return crypto.subtle.verify(
|
|
74
|
+
ALGORITHM,
|
|
75
|
+
key,
|
|
76
|
+
signatureBytes,
|
|
77
|
+
encoder.encode(payload)
|
|
78
|
+
);
|
|
79
|
+
} catch {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function parseCsrfCookie(cookieHeader, cookieName) {
|
|
84
|
+
if (!cookieHeader) return null;
|
|
85
|
+
const match = cookieHeader.split(";").map((c) => c.trim()).find((c) => c.startsWith(`${cookieName}=`));
|
|
86
|
+
return match ? match.slice(cookieName.length + 1) : null;
|
|
87
|
+
}
|
|
88
|
+
function buildCsrfCookieHeader(cookieName, token, secure) {
|
|
89
|
+
const parts = [
|
|
90
|
+
`${cookieName}=${token}`,
|
|
91
|
+
"HttpOnly",
|
|
92
|
+
"SameSite=Lax",
|
|
93
|
+
"Path=/",
|
|
94
|
+
`Max-Age=${DEFAULT_TTL_SECONDS}`
|
|
95
|
+
];
|
|
96
|
+
if (secure) {
|
|
97
|
+
parts.push("Secure");
|
|
98
|
+
}
|
|
99
|
+
return parts.join("; ");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// src/utils/trusted-origins.ts
|
|
103
|
+
function getOrigin(url) {
|
|
104
|
+
try {
|
|
105
|
+
const parsed = new URL(url);
|
|
106
|
+
return parsed.origin;
|
|
107
|
+
} catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function getHost(url) {
|
|
112
|
+
try {
|
|
113
|
+
const parsed = new URL(url);
|
|
114
|
+
return parsed.host;
|
|
115
|
+
} catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function wildcardToRegExp(pattern) {
|
|
120
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
121
|
+
const withWildcards = escaped.replace(/\*\*/g, "{{GLOBSTAR}}").replace(/\*/g, "[^.]+").replace(/\{\{GLOBSTAR\}\}/g, ".*");
|
|
122
|
+
return new RegExp(`^${withWildcards}$`);
|
|
123
|
+
}
|
|
124
|
+
function matchesOriginPattern(url, pattern) {
|
|
125
|
+
if (!url || !pattern) return false;
|
|
126
|
+
const hasWildcard = pattern.includes("*");
|
|
127
|
+
if (!hasWildcard) {
|
|
128
|
+
const urlOrigin = getOrigin(url);
|
|
129
|
+
return urlOrigin ? pattern === urlOrigin : false;
|
|
130
|
+
}
|
|
131
|
+
if (pattern.includes("://")) {
|
|
132
|
+
const urlOrigin = getOrigin(url);
|
|
133
|
+
if (!urlOrigin) return false;
|
|
134
|
+
return wildcardToRegExp(pattern).test(urlOrigin);
|
|
135
|
+
}
|
|
136
|
+
const host = getHost(url);
|
|
137
|
+
if (!host) return false;
|
|
138
|
+
return wildcardToRegExp(pattern).test(host);
|
|
139
|
+
}
|
|
140
|
+
function matchesAnyOrigin(url, trustedOrigins) {
|
|
141
|
+
return trustedOrigins.some((pattern) => matchesOriginPattern(url, pattern));
|
|
142
|
+
}
|
|
12
143
|
var createCustomerSchema = z.object({
|
|
13
144
|
externalId: z.string().min(1),
|
|
14
145
|
email: z.string().email(),
|
|
@@ -1361,6 +1492,10 @@ var webhookEndpoints = {
|
|
|
1361
1492
|
};
|
|
1362
1493
|
|
|
1363
1494
|
// src/api/router.ts
|
|
1495
|
+
var CSRF_COOKIE_NAME = "__billsdk_csrf";
|
|
1496
|
+
var CSRF_HEADER_NAME = "x-billsdk-csrf";
|
|
1497
|
+
var SAFE_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "OPTIONS"]);
|
|
1498
|
+
var SECURITY_SKIP_PATHS = /* @__PURE__ */ new Set(["/webhook"]);
|
|
1364
1499
|
function getEndpoints(ctx) {
|
|
1365
1500
|
const baseEndpoints = {
|
|
1366
1501
|
...healthEndpoint,
|
|
@@ -1410,12 +1545,134 @@ function jsonResponse(data, status = 200) {
|
|
|
1410
1545
|
function errorResponse(code, message, status = 400) {
|
|
1411
1546
|
return jsonResponse({ error: { code, message } }, status);
|
|
1412
1547
|
}
|
|
1548
|
+
function checkOrigin(request, trustedOrigins) {
|
|
1549
|
+
if (trustedOrigins.length === 0) return null;
|
|
1550
|
+
const origin = request.headers.get("origin") || request.headers.get("referer") || "";
|
|
1551
|
+
if (!origin || origin === "null") {
|
|
1552
|
+
return {
|
|
1553
|
+
error: errorResponse(
|
|
1554
|
+
"INVALID_ORIGIN",
|
|
1555
|
+
"Missing or null Origin header. Add your app's URL to trustedOrigins.",
|
|
1556
|
+
403
|
|
1557
|
+
)
|
|
1558
|
+
};
|
|
1559
|
+
}
|
|
1560
|
+
if (!matchesAnyOrigin(origin, trustedOrigins)) {
|
|
1561
|
+
return {
|
|
1562
|
+
error: errorResponse(
|
|
1563
|
+
"INVALID_ORIGIN",
|
|
1564
|
+
"Origin is not in trustedOrigins. Add it to your billsdk config.",
|
|
1565
|
+
403
|
|
1566
|
+
)
|
|
1567
|
+
};
|
|
1568
|
+
}
|
|
1569
|
+
return null;
|
|
1570
|
+
}
|
|
1571
|
+
async function checkCsrf(request, secret) {
|
|
1572
|
+
const csrfHeader = request.headers.get(CSRF_HEADER_NAME);
|
|
1573
|
+
if (!csrfHeader) {
|
|
1574
|
+
return {
|
|
1575
|
+
error: errorResponse(
|
|
1576
|
+
"INVALID_CSRF_TOKEN",
|
|
1577
|
+
"Missing CSRF token. The BillSDK client handles this automatically.",
|
|
1578
|
+
403
|
|
1579
|
+
)
|
|
1580
|
+
};
|
|
1581
|
+
}
|
|
1582
|
+
const cookieHeader = request.headers.get("cookie");
|
|
1583
|
+
const csrfCookie = parseCsrfCookie(cookieHeader, CSRF_COOKIE_NAME);
|
|
1584
|
+
if (!csrfCookie) {
|
|
1585
|
+
return {
|
|
1586
|
+
error: errorResponse(
|
|
1587
|
+
"INVALID_CSRF_TOKEN",
|
|
1588
|
+
"Missing CSRF cookie. Ensure credentials are included in requests.",
|
|
1589
|
+
403
|
|
1590
|
+
)
|
|
1591
|
+
};
|
|
1592
|
+
}
|
|
1593
|
+
if (csrfHeader !== csrfCookie) {
|
|
1594
|
+
return {
|
|
1595
|
+
error: errorResponse("INVALID_CSRF_TOKEN", "CSRF token mismatch.", 403)
|
|
1596
|
+
};
|
|
1597
|
+
}
|
|
1598
|
+
const isValid = await verifyCsrfToken(csrfHeader, secret);
|
|
1599
|
+
if (!isValid) {
|
|
1600
|
+
return {
|
|
1601
|
+
error: errorResponse(
|
|
1602
|
+
"INVALID_CSRF_TOKEN",
|
|
1603
|
+
"CSRF token signature invalid.",
|
|
1604
|
+
403
|
|
1605
|
+
)
|
|
1606
|
+
};
|
|
1607
|
+
}
|
|
1608
|
+
return null;
|
|
1609
|
+
}
|
|
1610
|
+
async function timingSafeEqual(a, b) {
|
|
1611
|
+
const encoder = new TextEncoder();
|
|
1612
|
+
const key = await crypto.subtle.importKey(
|
|
1613
|
+
"raw",
|
|
1614
|
+
encoder.encode("billsdk-compare-key"),
|
|
1615
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
1616
|
+
false,
|
|
1617
|
+
["sign"]
|
|
1618
|
+
);
|
|
1619
|
+
const [macA, macB] = await Promise.all([
|
|
1620
|
+
crypto.subtle.sign("HMAC", key, encoder.encode(a)),
|
|
1621
|
+
crypto.subtle.sign("HMAC", key, encoder.encode(b))
|
|
1622
|
+
]);
|
|
1623
|
+
const viewA = new Uint8Array(macA);
|
|
1624
|
+
const viewB = new Uint8Array(macB);
|
|
1625
|
+
if (viewA.length !== viewB.length) return false;
|
|
1626
|
+
let diff = 0;
|
|
1627
|
+
for (let i = 0; i < viewA.length; i++) {
|
|
1628
|
+
diff |= viewA[i] ^ viewB[i];
|
|
1629
|
+
}
|
|
1630
|
+
return diff === 0;
|
|
1631
|
+
}
|
|
1632
|
+
async function checkBearerAuth(request, secret) {
|
|
1633
|
+
const authHeader = request.headers.get("authorization");
|
|
1634
|
+
if (!authHeader) return false;
|
|
1635
|
+
const parts = authHeader.trim().split(/\s+/);
|
|
1636
|
+
if (parts.length !== 2) return false;
|
|
1637
|
+
if (parts[0].toLowerCase() !== "bearer") return false;
|
|
1638
|
+
return timingSafeEqual(parts[1], secret);
|
|
1639
|
+
}
|
|
1640
|
+
async function handleCsrfTokenEndpoint(ctx) {
|
|
1641
|
+
const token = await generateCsrfToken(ctx.secret);
|
|
1642
|
+
const isSecure = typeof globalThis.process !== "undefined" && globalThis.process.env?.NODE_ENV === "production";
|
|
1643
|
+
const cookieHeader = buildCsrfCookieHeader(CSRF_COOKIE_NAME, token, isSecure);
|
|
1644
|
+
return new Response(JSON.stringify({ csrfToken: token }), {
|
|
1645
|
+
status: 200,
|
|
1646
|
+
headers: {
|
|
1647
|
+
"Content-Type": "application/json",
|
|
1648
|
+
"Set-Cookie": cookieHeader
|
|
1649
|
+
}
|
|
1650
|
+
});
|
|
1651
|
+
}
|
|
1413
1652
|
function createRouter(ctx) {
|
|
1414
1653
|
const endpoints = getEndpoints(ctx);
|
|
1415
1654
|
const handler = async (request) => {
|
|
1416
1655
|
const method = request.method.toUpperCase();
|
|
1417
1656
|
const { path, query } = parseUrl(request.url, ctx.basePath);
|
|
1418
1657
|
ctx.logger.debug(`${method} ${path}`);
|
|
1658
|
+
if (path === "/csrf-token" && method === "GET") {
|
|
1659
|
+
return handleCsrfTokenEndpoint(ctx);
|
|
1660
|
+
}
|
|
1661
|
+
if (!SAFE_METHODS.has(method) && !SECURITY_SKIP_PATHS.has(path)) {
|
|
1662
|
+
const isBearerAuth = await checkBearerAuth(request, ctx.secret);
|
|
1663
|
+
if (!isBearerAuth) {
|
|
1664
|
+
const originResult = checkOrigin(request, ctx.trustedOrigins);
|
|
1665
|
+
if (originResult) {
|
|
1666
|
+
ctx.logger.warn("Origin check failed", { path });
|
|
1667
|
+
return originResult.error;
|
|
1668
|
+
}
|
|
1669
|
+
const csrfResult = await checkCsrf(request, ctx.secret);
|
|
1670
|
+
if (csrfResult) {
|
|
1671
|
+
ctx.logger.warn("CSRF check failed", { path });
|
|
1672
|
+
return csrfResult.error;
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1419
1676
|
if (ctx.options.hooks.before) {
|
|
1420
1677
|
const result = await ctx.options.hooks.before({
|
|
1421
1678
|
request,
|
|
@@ -1855,12 +2112,47 @@ function createLogger(options) {
|
|
|
1855
2112
|
}
|
|
1856
2113
|
};
|
|
1857
2114
|
}
|
|
2115
|
+
function resolveTrustedOrigins(configOrigins) {
|
|
2116
|
+
const origins = [];
|
|
2117
|
+
if (configOrigins) {
|
|
2118
|
+
origins.push(...configOrigins);
|
|
2119
|
+
}
|
|
2120
|
+
const envOrigins = typeof globalThis.process !== "undefined" ? globalThis.process.env?.BILLSDK_TRUSTED_ORIGINS : void 0;
|
|
2121
|
+
if (envOrigins) {
|
|
2122
|
+
origins.push(
|
|
2123
|
+
...envOrigins.split(",").map((o) => o.trim()).filter(Boolean)
|
|
2124
|
+
);
|
|
2125
|
+
}
|
|
2126
|
+
return origins;
|
|
2127
|
+
}
|
|
2128
|
+
function resolveSecret(configSecret) {
|
|
2129
|
+
if (configSecret) return configSecret;
|
|
2130
|
+
const envSecret = typeof globalThis.process !== "undefined" ? globalThis.process.env?.BILLSDK_SECRET : void 0;
|
|
2131
|
+
if (envSecret) return envSecret;
|
|
2132
|
+
throw new Error(
|
|
2133
|
+
"[billsdk] Secret is required. Set BILLSDK_SECRET in your environment or pass `secret` to billsdk(). Generate one with: openssl rand -base64 32"
|
|
2134
|
+
);
|
|
2135
|
+
}
|
|
2136
|
+
function validateSecurity(secret, trustedOrigins, logger) {
|
|
2137
|
+
if (secret.length < 32) {
|
|
2138
|
+
logger.warn(
|
|
2139
|
+
"Secret should be at least 32 characters for adequate security. Generate one with: openssl rand -base64 32"
|
|
2140
|
+
);
|
|
2141
|
+
}
|
|
2142
|
+
const isProduction = typeof globalThis.process !== "undefined" && globalThis.process.env?.NODE_ENV === "production";
|
|
2143
|
+
if (isProduction && trustedOrigins.length === 0) {
|
|
2144
|
+
logger.warn(
|
|
2145
|
+
"No trustedOrigins configured. Set trustedOrigins in your config or BILLSDK_TRUSTED_ORIGINS env var to protect mutating endpoints from cross-site requests."
|
|
2146
|
+
);
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
1858
2149
|
function resolveOptions(options, adapter) {
|
|
1859
2150
|
return {
|
|
1860
2151
|
database: adapter,
|
|
1861
2152
|
payment: options.payment,
|
|
1862
2153
|
basePath: options.basePath ?? "/api/billing",
|
|
1863
|
-
secret: options.secret
|
|
2154
|
+
secret: resolveSecret(options.secret),
|
|
2155
|
+
trustedOrigins: resolveTrustedOrigins(options.trustedOrigins),
|
|
1864
2156
|
plans: options.plans,
|
|
1865
2157
|
features: options.features,
|
|
1866
2158
|
plugins: options.plugins ?? [],
|
|
@@ -1872,9 +2164,6 @@ function resolveOptions(options, adapter) {
|
|
|
1872
2164
|
}
|
|
1873
2165
|
};
|
|
1874
2166
|
}
|
|
1875
|
-
function generateDefaultSecret() {
|
|
1876
|
-
return "billsdk-development-secret-change-in-production";
|
|
1877
|
-
}
|
|
1878
2167
|
async function createBillingContext(adapter, options) {
|
|
1879
2168
|
const resolvedOptions = resolveOptions(options, adapter);
|
|
1880
2169
|
const logger = createLogger(options.logger);
|
|
@@ -1892,6 +2181,11 @@ async function createBillingContext(adapter, options) {
|
|
|
1892
2181
|
options.features ?? [],
|
|
1893
2182
|
getNow
|
|
1894
2183
|
);
|
|
2184
|
+
validateSecurity(
|
|
2185
|
+
resolvedOptions.secret,
|
|
2186
|
+
resolvedOptions.trustedOrigins,
|
|
2187
|
+
logger
|
|
2188
|
+
);
|
|
1895
2189
|
const context = {
|
|
1896
2190
|
options: resolvedOptions,
|
|
1897
2191
|
basePath: resolvedOptions.basePath,
|
|
@@ -1902,6 +2196,7 @@ async function createBillingContext(adapter, options) {
|
|
|
1902
2196
|
plugins,
|
|
1903
2197
|
logger,
|
|
1904
2198
|
secret: resolvedOptions.secret,
|
|
2199
|
+
trustedOrigins: resolvedOptions.trustedOrigins,
|
|
1905
2200
|
timeProvider: createDefaultTimeProvider(),
|
|
1906
2201
|
hasPlugin(id) {
|
|
1907
2202
|
return plugins.some((p) => p.id === id);
|