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/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 ?? generateDefaultSecret(),
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);