@json-to-office/jto 0.20.0 → 0.22.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.
Files changed (26) hide show
  1. package/README.md +2 -2
  2. package/dist/cli.js +700 -129
  3. package/dist/cli.js.map +1 -1
  4. package/dist/client/assets/{HomePage-DePp4KBa.js → HomePage-BW2AGCC2.js} +3 -3
  5. package/dist/client/assets/{HomePage-DePp4KBa.js.map → HomePage-BW2AGCC2.js.map} +1 -1
  6. package/dist/client/assets/{JsonEditorPage-WCCH_wWH.js → JsonEditorPage-Bclb98Ti.js} +3 -3
  7. package/dist/client/assets/{JsonEditorPage-WCCH_wWH.js.map → JsonEditorPage-Bclb98Ti.js.map} +1 -1
  8. package/dist/client/assets/{MonacoPluginProvider-CMj42yVI.js → MonacoPluginProvider-BbjmAGrJ.js} +3 -3
  9. package/dist/client/assets/{MonacoPluginProvider-CMj42yVI.js.map → MonacoPluginProvider-BbjmAGrJ.js.map} +1 -1
  10. package/dist/client/assets/{button-BxE8yzur.js → button-CSrOcD5F.js} +2 -2
  11. package/dist/client/assets/{button-BxE8yzur.js.map → button-CSrOcD5F.js.map} +1 -1
  12. package/dist/client/assets/{editor-BpCI3VcW.js → editor-CGjdiUtd.js} +2 -2
  13. package/dist/client/assets/{editor-BpCI3VcW.js.map → editor-CGjdiUtd.js.map} +1 -1
  14. package/dist/client/assets/{editor-monaco-json-Bj2PTnna.js → editor-monaco-json-Bw2M2Xcf.js} +2 -2
  15. package/dist/client/assets/{editor-monaco-json-Bj2PTnna.js.map → editor-monaco-json-Bw2M2Xcf.js.map} +1 -1
  16. package/dist/client/assets/index-Bn9d6cEh.js +5 -0
  17. package/dist/client/assets/index-Bn9d6cEh.js.map +1 -0
  18. package/dist/client/assets/{preview-WFz8KjYy.js → preview-C51sEB99.js} +2 -2
  19. package/dist/client/assets/{preview-WFz8KjYy.js.map → preview-C51sEB99.js.map} +1 -1
  20. package/dist/client/index.html +1 -1
  21. package/dist/render-server.d.ts +39 -1
  22. package/dist/render-server.js +848 -75
  23. package/dist/render-server.js.map +1 -1
  24. package/package.json +10 -14
  25. package/dist/client/assets/index-DTfptUZM.js +0 -5
  26. package/dist/client/assets/index-DTfptUZM.js.map +0 -1
package/dist/cli.js CHANGED
@@ -20,24 +20,61 @@ var init_esm_shims = __esm({
20
20
 
21
21
  // src/server/config/index.ts
22
22
  import dotenv from "dotenv";
23
+ function positiveInteger(value, fallback) {
24
+ const parsed = Number.parseInt(value ?? "", 10);
25
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
26
+ }
27
+ function parseAuthMode(value, nodeEnv) {
28
+ if (value === "auto" || value === "required" || value === "disabled") {
29
+ return value;
30
+ }
31
+ return nodeEnv === "production" ? "required" : "auto";
32
+ }
33
+ function parseOutboundSourceMode(value, nodeEnv) {
34
+ if (value === "development" || value === "safe") return value;
35
+ return nodeEnv === "production" ? "safe" : "development";
36
+ }
37
+ function normalizeNodeEnv(value) {
38
+ return value === "development" || value === "test" ? value : "production";
39
+ }
23
40
  function parseEnv(env) {
41
+ const nodeEnv = normalizeNodeEnv(env.NODE_ENV || "development");
24
42
  return {
25
- NODE_ENV: env.NODE_ENV || "development",
26
- PORT: parseInt(env.PORT || "3003", 10),
43
+ NODE_ENV: nodeEnv,
44
+ // No PORT here: the listener is opened by the dev server from the CLI
45
+ // config (`-p` > `server.port` > `PORT` env > format default), so a second
46
+ // copy of it in this module would be silently ignored.
27
47
  CORS_ORIGIN: env.CORS_ORIGIN || "*",
28
48
  API_KEY: env.API_KEY,
29
49
  API_KEY_HEADER: env.API_KEY_HEADER || "x-api-key",
30
- RATE_LIMIT_WINDOW_MS: parseInt(env.RATE_LIMIT_WINDOW_MS || "900000", 10),
31
- RATE_LIMIT_MAX: parseInt(env.RATE_LIMIT_MAX || "100", 10),
32
- MAX_FILE_SIZE: parseInt(env.MAX_FILE_SIZE || "10485760", 10),
33
- UPLOAD_DIR: env.UPLOAD_DIR || "uploads",
50
+ API_AUTH_MODE: parseAuthMode(env.API_AUTH_MODE, nodeEnv),
51
+ RATE_LIMIT_WINDOW_MS: positiveInteger(env.RATE_LIMIT_WINDOW_MS, 9e5),
52
+ RATE_LIMIT_MAX: positiveInteger(
53
+ env.RATE_LIMIT_MAX,
54
+ nodeEnv === "production" ? 100 : 1e3
55
+ ),
56
+ TRUST_PROXY_HEADERS: env.TRUST_PROXY_HEADERS === "true",
57
+ MAX_FILE_SIZE: positiveInteger(env.MAX_FILE_SIZE, 10 * 1024 * 1024),
58
+ MAX_REQUEST_BODY_SIZE: positiveInteger(
59
+ env.MAX_REQUEST_BODY_SIZE,
60
+ 32 * 1024 * 1024
61
+ ),
62
+ MAX_CONCURRENT_REQUESTS: positiveInteger(
63
+ env.MAX_CONCURRENT_REQUESTS,
64
+ nodeEnv === "production" ? 8 : 64
65
+ ),
66
+ OUTBOUND_SOURCE_MODE: parseOutboundSourceMode(
67
+ env.OUTBOUND_SOURCE_MODE,
68
+ nodeEnv
69
+ ),
70
+ OUTBOUND_HOST_ALLOWLIST: (env.OUTBOUND_HOST_ALLOWLIST || "").split(",").map((host) => host.trim().toLowerCase()).filter(Boolean),
34
71
  LIBREOFFICE_PATH: env.LIBREOFFICE_PATH,
35
- LIBREOFFICE_TIMEOUT_MS: env.LIBREOFFICE_TIMEOUT_MS ? parseInt(env.LIBREOFFICE_TIMEOUT_MS, 10) : 3e4,
72
+ LIBREOFFICE_TIMEOUT_MS: env.LIBREOFFICE_TIMEOUT_MS ? positiveInteger(env.LIBREOFFICE_TIMEOUT_MS, 3e4) : 3e4,
36
73
  LOG_LEVEL: env.LOG_LEVEL || "info",
37
74
  CACHE_ENABLED: env.CACHE_ENABLED !== "false",
38
- CACHE_MAX_SIZE_MB: parseInt(env.CACHE_MAX_SIZE_MB || "100", 10),
39
- CACHE_TTL_SECONDS: parseInt(env.CACHE_TTL_SECONDS || "3600", 10),
40
- CACHE_MAX_ITEMS: parseInt(env.CACHE_MAX_ITEMS || "1000", 10)
75
+ CACHE_MAX_SIZE_MB: positiveInteger(env.CACHE_MAX_SIZE_MB, 100),
76
+ CACHE_TTL_SECONDS: positiveInteger(env.CACHE_TTL_SECONDS, 3600),
77
+ CACHE_MAX_ITEMS: positiveInteger(env.CACHE_MAX_ITEMS, 1e3)
41
78
  };
42
79
  }
43
80
  var parsedEnv, config;
@@ -53,7 +90,7 @@ var init_config = __esm({
53
90
  isProduction: parsedEnv.NODE_ENV === "production",
54
91
  isTest: parsedEnv.NODE_ENV === "test",
55
92
  features: {
56
- apiKey: Boolean(parsedEnv.API_KEY),
93
+ apiKey: parsedEnv.API_AUTH_MODE !== "disabled",
57
94
  cache: parsedEnv.CACHE_ENABLED
58
95
  },
59
96
  cors: {
@@ -62,7 +99,17 @@ var init_config = __esm({
62
99
  },
63
100
  rateLimit: {
64
101
  windowMs: parsedEnv.RATE_LIMIT_WINDOW_MS,
65
- max: parsedEnv.RATE_LIMIT_MAX
102
+ max: parsedEnv.RATE_LIMIT_MAX,
103
+ trustProxy: parsedEnv.TRUST_PROXY_HEADERS
104
+ },
105
+ requestLimits: {
106
+ maxBodySize: parsedEnv.MAX_REQUEST_BODY_SIZE,
107
+ maxConcurrent: parsedEnv.MAX_CONCURRENT_REQUESTS,
108
+ maxFileSize: parsedEnv.MAX_FILE_SIZE
109
+ },
110
+ outboundSources: {
111
+ mode: parsedEnv.OUTBOUND_SOURCE_MODE,
112
+ allowedHosts: parsedEnv.OUTBOUND_HOST_ALLOWLIST
66
113
  },
67
114
  cache: {
68
115
  enabled: parsedEnv.CACHE_ENABLED,
@@ -187,10 +234,10 @@ function collectReferencedNames(config2, customThemes, adapterName) {
187
234
  }
188
235
  function collectReferencedWeights(config2, customThemes) {
189
236
  const weights = /* @__PURE__ */ new Set();
190
- const visit = (node) => {
237
+ const visit2 = (node) => {
191
238
  if (node == null) return;
192
239
  if (Array.isArray(node)) {
193
- for (const item of node) visit(item);
240
+ for (const item of node) visit2(item);
194
241
  return;
195
242
  }
196
243
  if (typeof node === "object") {
@@ -198,21 +245,21 @@ function collectReferencedWeights(config2, customThemes) {
198
245
  if (k === "fontWeight" && typeof v === "number" && v >= 100 && v <= 900) {
199
246
  weights.add(v);
200
247
  } else {
201
- visit(v);
248
+ visit2(v);
202
249
  }
203
250
  }
204
251
  }
205
252
  };
206
- visit(config2);
207
- for (const theme of Object.values(customThemes ?? {})) visit(theme);
253
+ visit2(config2);
254
+ for (const theme of Object.values(customThemes ?? {})) visit2(theme);
208
255
  return weights;
209
256
  }
210
257
  function collectReferencedItalic(config2, customThemes) {
211
258
  let found = false;
212
- const visit = (node) => {
259
+ const visit2 = (node) => {
213
260
  if (found || node == null) return;
214
261
  if (Array.isArray(node)) {
215
- for (const item of node) visit(item);
262
+ for (const item of node) visit2(item);
216
263
  return;
217
264
  }
218
265
  if (typeof node === "object") {
@@ -221,14 +268,14 @@ function collectReferencedItalic(config2, customThemes) {
221
268
  found = true;
222
269
  return;
223
270
  }
224
- visit(v);
271
+ visit2(v);
225
272
  }
226
273
  }
227
274
  };
228
- visit(config2);
275
+ visit2(config2);
229
276
  if (!found) {
230
277
  for (const theme of Object.values(customThemes ?? {})) {
231
- visit(theme);
278
+ visit2(theme);
232
279
  if (found) break;
233
280
  }
234
281
  }
@@ -1467,29 +1514,46 @@ var init_typebox_validator = __esm({
1467
1514
 
1468
1515
  // src/server/middleware/hono/rate-limit.ts
1469
1516
  import { HTTPException as HTTPException2 } from "hono/http-exception";
1470
- var rateLimitStore, rateLimiter;
1517
+ function clientAddress(c, trustProxy) {
1518
+ if (trustProxy) {
1519
+ const forwarded = c.req.header("X-Real-IP") || c.req.header("X-Forwarded-For")?.split(",")[0]?.trim();
1520
+ if (forwarded) return forwarded;
1521
+ }
1522
+ const incoming = c.env?.incoming || c.env?.req;
1523
+ return incoming?.socket?.remoteAddress || "anonymous";
1524
+ }
1525
+ var rateLimiter;
1471
1526
  var init_rate_limit = __esm({
1472
1527
  "src/server/middleware/hono/rate-limit.ts"() {
1473
1528
  "use strict";
1474
1529
  init_esm_shims();
1475
- rateLimitStore = /* @__PURE__ */ new Map();
1476
1530
  rateLimiter = (options) => {
1477
1531
  const { limit, window, keyGenerator } = options;
1532
+ const maxEntries = options.maxEntries ?? 1e4;
1533
+ const rateLimitStore = /* @__PURE__ */ new Map();
1534
+ let lastCleanup = 0;
1478
1535
  return async (c, next) => {
1479
- const key = keyGenerator ? keyGenerator(c) : c.req.header("X-Real-IP") || c.req.header("X-Forwarded-For")?.split(",").pop()?.trim() || "anonymous";
1536
+ const clientKey = keyGenerator ? keyGenerator(c) : clientAddress(c, options.trustProxy === true);
1537
+ const namespace = typeof options.namespace === "function" ? options.namespace(c) : options.namespace || `${c.req.method}:${c.req.path}`;
1538
+ const key = `${namespace}:${String(clientKey).slice(0, 256)}`;
1480
1539
  const now = Date.now();
1481
- for (const [k, v] of rateLimitStore.entries()) {
1482
- if (v.resetTime < now) {
1483
- rateLimitStore.delete(k);
1540
+ if (now - lastCleanup >= Math.min(window, 6e4)) {
1541
+ for (const [storedKey, value] of rateLimitStore.entries()) {
1542
+ if (value.resetTime <= now) rateLimitStore.delete(storedKey);
1484
1543
  }
1544
+ lastCleanup = now;
1485
1545
  }
1486
1546
  const record = rateLimitStore.get(key);
1487
1547
  if (!record) {
1548
+ if (rateLimitStore.size >= maxEntries) {
1549
+ const oldestKey = rateLimitStore.keys().next().value;
1550
+ if (oldestKey) rateLimitStore.delete(oldestKey);
1551
+ }
1488
1552
  rateLimitStore.set(key, {
1489
1553
  count: 1,
1490
1554
  resetTime: now + window
1491
1555
  });
1492
- } else if (record.resetTime < now) {
1556
+ } else if (record.resetTime <= now) {
1493
1557
  record.count = 1;
1494
1558
  record.resetTime = now + window;
1495
1559
  } else if (record.count >= limit) {
@@ -1517,6 +1581,316 @@ var init_rate_limit = __esm({
1517
1581
  }
1518
1582
  });
1519
1583
 
1584
+ // src/server/security/outbound-source-policy.ts
1585
+ function isRecord(value) {
1586
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1587
+ }
1588
+ function hostMatches(host, pattern) {
1589
+ const normalized = pattern.toLowerCase().replace(/\.$/, "");
1590
+ if (normalized.startsWith("*.")) {
1591
+ const suffix = normalized.slice(1);
1592
+ return host.endsWith(suffix) && host.length > suffix.length;
1593
+ }
1594
+ return host === normalized;
1595
+ }
1596
+ function isPrivateIpv4(host) {
1597
+ const parts = host.split(".");
1598
+ if (parts.length !== 4) return false;
1599
+ const octets = parts.map(Number);
1600
+ if (octets.some(
1601
+ (part, index) => !Number.isInteger(part) || part < 0 || part > 255 || String(part) !== parts[index]
1602
+ )) {
1603
+ return false;
1604
+ }
1605
+ const [a, b] = octets;
1606
+ return a === 0 || a === 10 || a === 127 || a === 100 && b >= 64 && b <= 127 || a === 169 && b === 254 || a === 172 && b >= 16 && b <= 31 || a === 192 && (b === 0 || b === 168) || a === 198 && (b === 18 || b === 19) || a >= 224;
1607
+ }
1608
+ function isPrivateHost(host) {
1609
+ const normalized = host.toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, "");
1610
+ if (normalized === "localhost" || normalized.endsWith(".localhost") || normalized === "::" || normalized === "::1") {
1611
+ return true;
1612
+ }
1613
+ if (isPrivateIpv4(normalized)) return true;
1614
+ if (normalized.startsWith("::ffff:")) {
1615
+ const mapped = normalized.slice("::ffff:".length);
1616
+ if (isPrivateIpv4(mapped)) return true;
1617
+ const groups = mapped.match(/^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i);
1618
+ if (groups) {
1619
+ const value = Number.parseInt(groups[1], 16) * 65536 + Number.parseInt(groups[2], 16);
1620
+ return isPrivateIpv4(
1621
+ [
1622
+ value >>> 24,
1623
+ value >>> 16 & 255,
1624
+ value >>> 8 & 255,
1625
+ value & 255
1626
+ ].join(".")
1627
+ );
1628
+ }
1629
+ }
1630
+ const firstIpv6Group = Number.parseInt(normalized.split(":")[0] || "0", 16);
1631
+ return firstIpv6Group >= 64512 && firstIpv6Group <= 65023 || firstIpv6Group >= 65152 && firstIpv6Group <= 65215;
1632
+ }
1633
+ function assertAllowedUrl(source, path6, allowedHosts) {
1634
+ if (source.startsWith("data:")) return;
1635
+ let url;
1636
+ try {
1637
+ url = new URL(source);
1638
+ } catch {
1639
+ throw new UnsafeOutboundSourceError(
1640
+ path6,
1641
+ "local and relative file paths are disabled for HTTP requests"
1642
+ );
1643
+ }
1644
+ if (url.protocol !== "https:") {
1645
+ throw new UnsafeOutboundSourceError(path6, "only HTTPS URLs are allowed");
1646
+ }
1647
+ if (url.username || url.password) {
1648
+ throw new UnsafeOutboundSourceError(
1649
+ path6,
1650
+ "URLs containing credentials are not allowed"
1651
+ );
1652
+ }
1653
+ const host = url.hostname.toLowerCase().replace(/\.$/, "");
1654
+ if (isPrivateHost(host)) {
1655
+ throw new UnsafeOutboundSourceError(
1656
+ path6,
1657
+ `private or local host "${host}" is not allowed`
1658
+ );
1659
+ }
1660
+ if (!allowedHosts.some((pattern) => hostMatches(host, pattern))) {
1661
+ throw new UnsafeOutboundSourceError(
1662
+ path6,
1663
+ `host "${host}" is not in OUTBOUND_HOST_ALLOWLIST`
1664
+ );
1665
+ }
1666
+ }
1667
+ function extractCssReferences(input) {
1668
+ const references = [];
1669
+ const urlPattern = /url\(\s*(['"]?)([^)'"\s]+)\1\s*\)/gi;
1670
+ const importPattern = /@import\s+(?:url\(\s*)?(['"])([^'"]+)\1/gi;
1671
+ for (const pattern of [urlPattern, importPattern]) {
1672
+ let match;
1673
+ while (match = pattern.exec(input)) references.push(match[2]);
1674
+ }
1675
+ return references;
1676
+ }
1677
+ function extractAbsoluteUrls(input) {
1678
+ return input.match(/(?:https?|file):\/\/[^\s'"<>)}\]]+/gi) ?? [];
1679
+ }
1680
+ function assertSafeSvg(svg, path6, allowedHosts) {
1681
+ if (/<\s*script\b/i.test(svg) || /<\s*(?:foreignObject|iframe|object|embed)\b/i.test(svg) || /\bon[a-z]+\s*=/i.test(svg)) {
1682
+ throw new UnsafeOutboundSourceError(path6, "active SVG content is disabled");
1683
+ }
1684
+ if (/<!\s*(?:DOCTYPE|ENTITY)\b/i.test(svg)) {
1685
+ throw new UnsafeOutboundSourceError(
1686
+ path6,
1687
+ "SVG document types and entities are disabled"
1688
+ );
1689
+ }
1690
+ const refPattern = /\b(?:href|src)\s*=\s*(['"])(.*?)\1/gi;
1691
+ let match;
1692
+ while (match = refPattern.exec(svg)) {
1693
+ const ref = match[2].trim();
1694
+ if (!ref || ref.startsWith("#") || ref.startsWith("data:")) continue;
1695
+ assertAllowedUrl(ref, path6, allowedHosts);
1696
+ }
1697
+ for (const ref of extractCssReferences(svg)) {
1698
+ if (ref.startsWith("#") || ref.startsWith("data:")) continue;
1699
+ assertAllowedUrl(ref, path6, allowedHosts);
1700
+ }
1701
+ }
1702
+ function assertSafeEncodedImage(source, path6, allowedHosts) {
1703
+ let candidate = source.trim();
1704
+ let declaredSvg = false;
1705
+ if (candidate.startsWith("data:")) {
1706
+ const comma = candidate.indexOf(",");
1707
+ if (comma < 0) {
1708
+ throw new UnsafeOutboundSourceError(path6, "malformed image data URL");
1709
+ }
1710
+ const metadata = candidate.slice(5, comma);
1711
+ declaredSvg = metadata.split(";")[0].toLowerCase() === "image/svg+xml";
1712
+ if (!declaredSvg) return;
1713
+ const payload = candidate.slice(comma + 1);
1714
+ try {
1715
+ candidate = /(?:^|;)base64(?:;|$)/i.test(metadata) ? Buffer.from(payload, "base64").toString("utf8") : decodeURIComponent(payload);
1716
+ } catch {
1717
+ throw new UnsafeOutboundSourceError(path6, "malformed SVG data URL");
1718
+ }
1719
+ } else if (!/<\s*svg\b/i.test(candidate)) {
1720
+ candidate = Buffer.from(candidate, "base64").toString("utf8");
1721
+ }
1722
+ const svgStart = candidate.search(/<\s*svg\b/i);
1723
+ if (svgStart < 0) {
1724
+ if (declaredSvg) {
1725
+ throw new UnsafeOutboundSourceError(path6, "malformed SVG image data");
1726
+ }
1727
+ return;
1728
+ }
1729
+ assertSafeSvg(candidate, path6, allowedHosts);
1730
+ }
1731
+ function assertSafeResources(resources, path6, allowedHosts) {
1732
+ if (typeof resources.js === "string" && resources.js.trim()) {
1733
+ throw new UnsafeOutboundSourceError(
1734
+ `${path6}.js`,
1735
+ "remote renderer JavaScript resources are disabled"
1736
+ );
1737
+ }
1738
+ if (typeof resources.css === "string") {
1739
+ for (const ref of extractCssReferences(resources.css)) {
1740
+ if (ref.startsWith("data:")) continue;
1741
+ assertAllowedUrl(ref, `${path6}.css`, allowedHosts);
1742
+ }
1743
+ }
1744
+ if (Array.isArray(resources.files) && resources.files.length > 0) {
1745
+ throw new UnsafeOutboundSourceError(
1746
+ `${path6}.files`,
1747
+ "remote renderer JavaScript resources are disabled"
1748
+ );
1749
+ }
1750
+ }
1751
+ function assertNoRemoteReferences(value, path6, allowedHosts, seen) {
1752
+ if (typeof value === "string") {
1753
+ if (/\b(?:fetch|XMLHttpRequest|WebSocket|EventSource)\s*\(/i.test(value) || /\b(?:javascript|file):/i.test(value)) {
1754
+ throw new UnsafeOutboundSourceError(
1755
+ path6,
1756
+ "network-capable JavaScript and file URLs are disabled"
1757
+ );
1758
+ }
1759
+ for (const url of [
1760
+ ...extractAbsoluteUrls(value),
1761
+ ...extractCssReferences(value)
1762
+ ]) {
1763
+ if (url.startsWith("data:")) continue;
1764
+ assertAllowedUrl(url, path6, allowedHosts);
1765
+ }
1766
+ if (/^\/\//.test(value.trim())) {
1767
+ throw new UnsafeOutboundSourceError(
1768
+ path6,
1769
+ "protocol-relative URLs are disabled"
1770
+ );
1771
+ }
1772
+ return;
1773
+ }
1774
+ if (!value || typeof value !== "object" || seen.has(value)) return;
1775
+ seen.add(value);
1776
+ if (Array.isArray(value)) {
1777
+ value.forEach(
1778
+ (entry, index) => assertNoRemoteReferences(entry, `${path6}[${index}]`, allowedHosts, seen)
1779
+ );
1780
+ return;
1781
+ }
1782
+ for (const [key, child] of Object.entries(value)) {
1783
+ assertNoRemoteReferences(child, `${path6}.${key}`, allowedHosts, seen);
1784
+ }
1785
+ }
1786
+ function visit(value, path6, containerKey, allowedHosts, seen) {
1787
+ if (!value || typeof value !== "object" || seen.has(value)) return;
1788
+ seen.add(value);
1789
+ if (Array.isArray(value)) {
1790
+ value.forEach(
1791
+ (entry, index) => visit(entry, `${path6}[${index}]`, containerKey, allowedHosts, seen)
1792
+ );
1793
+ return;
1794
+ }
1795
+ const record = value;
1796
+ for (const key of Object.keys(record)) {
1797
+ if (DANGEROUS_KEYS.has(key)) {
1798
+ throw new UnsafeOutboundSourceError(
1799
+ `${path6}.${key}`,
1800
+ "prototype mutation keys are disabled"
1801
+ );
1802
+ }
1803
+ }
1804
+ const kind = typeof record.kind === "string" ? record.kind : void 0;
1805
+ if (kind === "file" && typeof record.path === "string") {
1806
+ throw new UnsafeOutboundSourceError(
1807
+ `${path6}.path`,
1808
+ "local file sources are disabled for HTTP requests"
1809
+ );
1810
+ }
1811
+ if ((kind === "url" || kind === "variable") && typeof record.url === "string") {
1812
+ assertAllowedUrl(record.url, `${path6}.url`, allowedHosts);
1813
+ }
1814
+ const componentName = typeof record.name === "string" ? record.name.toLowerCase() : void 0;
1815
+ const props = isRecord(record.props) ? record.props : void 0;
1816
+ if (componentName && props) {
1817
+ if (COMPONENT_SOURCE_NAMES.has(componentName) && typeof props.path === "string") {
1818
+ assertAllowedUrl(props.path, `${path6}.props.path`, allowedHosts);
1819
+ assertSafeEncodedImage(props.path, `${path6}.props.path`, allowedHosts);
1820
+ }
1821
+ if (COMPONENT_SOURCE_NAMES.has(componentName) && typeof props.base64 === "string") {
1822
+ assertSafeEncodedImage(
1823
+ props.base64,
1824
+ `${path6}.props.base64`,
1825
+ allowedHosts
1826
+ );
1827
+ }
1828
+ if ((componentName === "highcharts" || componentName === "visual") && typeof props.serverUrl === "string") {
1829
+ assertAllowedUrl(
1830
+ props.serverUrl,
1831
+ `${path6}.props.serverUrl`,
1832
+ allowedHosts
1833
+ );
1834
+ }
1835
+ if (componentName === "highcharts") {
1836
+ if (isRecord(props.resources)) {
1837
+ assertSafeResources(
1838
+ props.resources,
1839
+ `${path6}.props.resources`,
1840
+ allowedHosts
1841
+ );
1842
+ }
1843
+ assertNoRemoteReferences(
1844
+ props.options,
1845
+ `${path6}.props.options`,
1846
+ allowedHosts,
1847
+ /* @__PURE__ */ new WeakSet()
1848
+ );
1849
+ }
1850
+ }
1851
+ if (containerKey === "image" && typeof record.path === "string") {
1852
+ assertAllowedUrl(record.path, `${path6}.path`, allowedHosts);
1853
+ assertSafeEncodedImage(record.path, `${path6}.path`, allowedHosts);
1854
+ }
1855
+ if (typeof record.svg === "string") {
1856
+ assertSafeSvg(record.svg, `${path6}.svg`, allowedHosts);
1857
+ }
1858
+ if (containerKey === "resources") {
1859
+ assertSafeResources(record, path6, allowedHosts);
1860
+ }
1861
+ for (const [key, child] of Object.entries(record)) {
1862
+ visit(child, `${path6}.${key}`, key, allowedHosts, seen);
1863
+ }
1864
+ }
1865
+ function assertSafeOutboundSources(value, policy, rootPath = "request") {
1866
+ if (policy.mode === "development") return;
1867
+ let parsed = value;
1868
+ if (typeof value === "string") {
1869
+ try {
1870
+ parsed = JSON.parse(value);
1871
+ } catch {
1872
+ return;
1873
+ }
1874
+ }
1875
+ visit(parsed, rootPath, void 0, policy.allowedHosts, /* @__PURE__ */ new WeakSet());
1876
+ }
1877
+ var UnsafeOutboundSourceError, DANGEROUS_KEYS, COMPONENT_SOURCE_NAMES;
1878
+ var init_outbound_source_policy = __esm({
1879
+ "src/server/security/outbound-source-policy.ts"() {
1880
+ "use strict";
1881
+ init_esm_shims();
1882
+ UnsafeOutboundSourceError = class extends Error {
1883
+ constructor(path6, reason) {
1884
+ super(`Unsafe outbound source at ${path6}: ${reason}`);
1885
+ this.path = path6;
1886
+ this.name = "UnsafeOutboundSourceError";
1887
+ }
1888
+ };
1889
+ DANGEROUS_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
1890
+ COMPONENT_SOURCE_NAMES = /* @__PURE__ */ new Set(["image"]);
1891
+ }
1892
+ });
1893
+
1520
1894
  // src/server/rasterize-route.ts
1521
1895
  import { Type as Type2 } from "@sinclair/typebox";
1522
1896
  import { bodyLimit } from "hono/body-limit";
@@ -1550,6 +1924,13 @@ function registerRasterizeRoute(router, options = {}) {
1550
1924
  async (c) => {
1551
1925
  const { presentation, dpi } = getValidated(c, "json");
1552
1926
  try {
1927
+ if (options.sourcePolicy) {
1928
+ assertSafeOutboundSources(
1929
+ presentation,
1930
+ options.sourcePolicy,
1931
+ "presentation"
1932
+ );
1933
+ }
1553
1934
  const result = await getRasterizer()({
1554
1935
  presentation,
1555
1936
  dpi: clampVisualDpi(dpi ?? DEFAULT_VISUAL_DPI)
@@ -1558,6 +1939,9 @@ function registerRasterizeRoute(router, options = {}) {
1558
1939
  } catch (error) {
1559
1940
  options.onError?.(error);
1560
1941
  if (error instanceof HTTPException3) throw error;
1942
+ if (error instanceof UnsafeOutboundSourceError) {
1943
+ throw new HTTPException3(400, { message: error.message });
1944
+ }
1561
1945
  const msg = error instanceof Error ? error.message.toLowerCase() : String(error);
1562
1946
  if (msg.includes("not found") || msg.includes("rasterization needs")) {
1563
1947
  throw new HTTPException3(503, { message: error.message });
@@ -1578,6 +1962,7 @@ var init_rasterize_route = __esm({
1578
1962
  "use strict";
1579
1963
  init_esm_shims();
1580
1964
  init_typebox_validator();
1965
+ init_outbound_source_policy();
1581
1966
  RasterizeRequestSchema = Type2.Object(
1582
1967
  {
1583
1968
  presentation: Type2.Object({}, { additionalProperties: true }),
@@ -1606,6 +1991,16 @@ import { bodyLimit as bodyLimit2 } from "hono/body-limit";
1606
1991
  import { PluginRegistry as PluginRegistry2 } from "@json-to-office/jto-cli";
1607
1992
  function createFormatRouter(adapter) {
1608
1993
  const router = new Hono2();
1994
+ const assertRequestSources = (value, path6) => {
1995
+ try {
1996
+ assertSafeOutboundSources(value, config.outboundSources, path6);
1997
+ } catch (error) {
1998
+ if (error instanceof UnsafeOutboundSourceError) {
1999
+ throw new HTTPException4(400, { message: error.message });
2000
+ }
2001
+ throw error;
2002
+ }
2003
+ };
1609
2004
  const contentTypeMw = async (c, next) => {
1610
2005
  const contentType = c.req.header("content-type");
1611
2006
  if (!contentType || !contentType.includes("application/json")) {
@@ -1620,7 +2015,7 @@ function createFormatRouter(adapter) {
1620
2015
  rateLimiter({
1621
2016
  limit: process.env.NODE_ENV === "production" ? 10 : 1e3,
1622
2017
  window: 15 * 60 * 1e3,
1623
- keyGenerator: (c) => c.req.header("X-Real-IP") || c.req.header("X-Forwarded-For")?.split(",").pop()?.trim() || "anonymous"
2018
+ trustProxy: config.rateLimit.trustProxy
1624
2019
  }),
1625
2020
  contentTypeMw,
1626
2021
  tbValidator(LooseDocumentGenerationRequestSchema),
@@ -1629,6 +2024,9 @@ function createFormatRouter(adapter) {
1629
2024
  const { jsonDefinition, customThemes, options } = getValidated(c, "json");
1630
2025
  const requestId = c.get("requestId");
1631
2026
  try {
2027
+ assertRequestSources(jsonDefinition, "jsonDefinition");
2028
+ assertRequestSources(customThemes, "customThemes");
2029
+ assertRequestSources(options, "options");
1632
2030
  const bypassCache = c.req.header("X-Bypass-Cache") === "true" || c.req.query("bypass-cache") === "true" || options?.bypassCache === true;
1633
2031
  let sanitizedFonts;
1634
2032
  const rawFonts = options?.fonts;
@@ -1721,7 +2119,7 @@ function createFormatRouter(adapter) {
1721
2119
  rateLimiter({
1722
2120
  limit: process.env.NODE_ENV === "production" ? 30 : 1e3,
1723
2121
  window: 15 * 60 * 1e3,
1724
- keyGenerator: (c) => c.req.header("X-Real-IP") || c.req.header("X-Forwarded-For")?.split(",").pop()?.trim() || "anonymous"
2122
+ trustProxy: config.rateLimit.trustProxy
1725
2123
  }),
1726
2124
  contentTypeMw,
1727
2125
  tbValidator(LooseDocumentDiffRequestSchema),
@@ -1796,10 +2194,17 @@ function createFormatRouter(adapter) {
1796
2194
  }
1797
2195
  router.post(
1798
2196
  "/preview/libreoffice",
2197
+ bodyLimit2({
2198
+ // Multipart framing adds a small amount around the configured file cap.
2199
+ maxSize: config.requestLimits.maxFileSize + 64 * 1024,
2200
+ onError: () => {
2201
+ throw new HTTPException4(413, { message: "Request body too large" });
2202
+ }
2203
+ }),
1799
2204
  rateLimiter({
1800
2205
  limit: process.env.NODE_ENV === "production" ? 20 : 1e3,
1801
2206
  window: 15 * 60 * 1e3,
1802
- keyGenerator: (c) => c.req.header("X-Real-IP") || c.req.header("X-Forwarded-For")?.split(",").pop()?.trim() || "anonymous"
2207
+ trustProxy: config.rateLimit.trustProxy
1803
2208
  }),
1804
2209
  async (c) => {
1805
2210
  const requestId = c.get("requestId");
@@ -1819,13 +2224,30 @@ function createFormatRouter(adapter) {
1819
2224
  message: `${adapter.name.toUpperCase()} file is empty`
1820
2225
  });
1821
2226
  }
2227
+ if (file.size > config.requestLimits.maxFileSize) {
2228
+ throw new HTTPException4(413, {
2229
+ message: `File exceeds ${config.requestLimits.maxFileSize} bytes`
2230
+ });
2231
+ }
2232
+ const expectedExtension = `.${adapter.name}`;
2233
+ const originalName = file.name || `preview${expectedExtension}`;
2234
+ if (!originalName.toLowerCase().endsWith(expectedExtension)) {
2235
+ throw new HTTPException4(400, {
2236
+ message: `Expected a ${adapter.name.toUpperCase()} file`
2237
+ });
2238
+ }
1822
2239
  const arrayBuffer = await file.arrayBuffer();
1823
2240
  const inputBuffer = Buffer.from(arrayBuffer);
2241
+ if (inputBuffer.length < 4 || inputBuffer[0] !== 80 || inputBuffer[1] !== 75) {
2242
+ throw new HTTPException4(400, {
2243
+ message: `Invalid ${adapter.name.toUpperCase()} file`
2244
+ });
2245
+ }
1824
2246
  const pdfBuffer = await libreOfficeService.convertToPdf(
1825
2247
  inputBuffer,
1826
- file.name
2248
+ originalName
1827
2249
  );
1828
- const pdfName = (file.name || "preview").replace(/\.[^.]+$/i, "") + ".pdf";
2250
+ const pdfName = originalName.replace(/\.[^.]+$/i, "").replace(/[^a-zA-Z0-9._-]/g, "_") + ".pdf";
1829
2251
  c.header("Content-Type", "application/pdf");
1830
2252
  c.header("Content-Disposition", `inline; filename="${pdfName}"`);
1831
2253
  c.header("Content-Length", String(pdfBuffer.length));
@@ -1866,7 +2288,7 @@ function createFormatRouter(adapter) {
1866
2288
  rateLimiter({
1867
2289
  limit: process.env.NODE_ENV === "production" ? 20 : 1e3,
1868
2290
  window: 15 * 60 * 1e3,
1869
- keyGenerator: (c) => c.req.header("X-Real-IP") || c.req.header("X-Forwarded-For")?.split(",").pop()?.trim() || "anonymous"
2291
+ trustProxy: config.rateLimit.trustProxy
1870
2292
  }),
1871
2293
  contentTypeMw,
1872
2294
  tbValidator(LooseDocumentGenerationRequestSchema),
@@ -1878,6 +2300,8 @@ function createFormatRouter(adapter) {
1878
2300
  );
1879
2301
  const { jsonDefinition, customThemes } = getValidated(c, "json");
1880
2302
  try {
2303
+ assertRequestSources(jsonDefinition, "jsonDefinition");
2304
+ assertRequestSources(customThemes, "customThemes");
1881
2305
  const generated = await generatorService.generate({
1882
2306
  jsonDefinition,
1883
2307
  customThemes,
@@ -1923,6 +2347,8 @@ function createFormatRouter(adapter) {
1923
2347
  const { jsonDefinition, customThemes } = getValidated(c, "json");
1924
2348
  const requestId = c.get("requestId");
1925
2349
  try {
2350
+ assertRequestSources(jsonDefinition, "jsonDefinition");
2351
+ assertRequestSources(customThemes, "customThemes");
1926
2352
  const config2 = typeof jsonDefinition === "string" ? JSON.parse(jsonDefinition) : jsonDefinition;
1927
2353
  const registry = PluginRegistry2.getInstance();
1928
2354
  if (registry.hasPlugins()) {
@@ -2026,9 +2452,10 @@ function createFormatRouter(adapter) {
2026
2452
  rateLimiter({
2027
2453
  limit: process.env.NODE_ENV === "production" ? 10 : 1e3,
2028
2454
  window: 15 * 60 * 1e3,
2029
- keyGenerator: (c) => c.req.header("X-Real-IP") || c.req.header("X-Forwarded-For")?.split(",").pop()?.trim() || "anonymous"
2455
+ trustProxy: config.rateLimit.trustProxy
2030
2456
  })
2031
2457
  ],
2458
+ sourcePolicy: config.outboundSources,
2032
2459
  onError: (error) => logger.error("Visual rasterization failed", { error })
2033
2460
  });
2034
2461
  return router;
@@ -2043,6 +2470,8 @@ var init_format = __esm({
2043
2470
  init_logger();
2044
2471
  init_rate_limit();
2045
2472
  init_rasterize_route();
2473
+ init_config();
2474
+ init_outbound_source_policy();
2046
2475
  init_libreoffice_converter();
2047
2476
  }
2048
2477
  });
@@ -2578,7 +3007,7 @@ function createAiRouter() {
2578
3007
  rateLimiter({
2579
3008
  limit: process.env.NODE_ENV === "production" ? 30 : 1e3,
2580
3009
  window: 15 * 60 * 1e3,
2581
- keyGenerator: (c) => c.req.header("X-Real-IP") || c.req.header("X-Forwarded-For")?.split(",").pop()?.trim() || "anonymous"
3010
+ trustProxy: config.rateLimit.trustProxy
2582
3011
  }),
2583
3012
  async (c) => {
2584
3013
  try {
@@ -2793,6 +3222,7 @@ var init_ai = __esm({
2793
3222
  init_prompt_loader();
2794
3223
  init_logger();
2795
3224
  init_rate_limit();
3225
+ init_config();
2796
3226
  TEXT_MIME_TYPES = /* @__PURE__ */ new Set([
2797
3227
  "text/plain",
2798
3228
  "text/markdown",
@@ -2818,6 +3248,7 @@ var init_fonts = __esm({
2818
3248
  "use strict";
2819
3249
  init_esm_shims();
2820
3250
  init_rate_limit();
3251
+ init_config();
2821
3252
  fontsRouter = new Hono5();
2822
3253
  MATERIALIZE_FAMILY_MAX = 64;
2823
3254
  MATERIALIZE_WEIGHTS_MAX = 9;
@@ -2850,7 +3281,7 @@ var init_fonts = __esm({
2850
3281
  rateLimiter({
2851
3282
  limit: process.env.NODE_ENV === "production" ? 20 : 1e3,
2852
3283
  window: 15 * 60 * 1e3,
2853
- keyGenerator: (c) => c.req.header("X-Real-IP") || c.req.header("X-Forwarded-For")?.split(",").pop()?.trim() || "anonymous"
3284
+ trustProxy: config.rateLimit.trustProxy
2854
3285
  }),
2855
3286
  async (c) => {
2856
3287
  let body;
@@ -2923,39 +3354,78 @@ var init_request_id = __esm({
2923
3354
  });
2924
3355
 
2925
3356
  // src/server/middleware/hono/auth.ts
3357
+ import { createHash as createHash2, timingSafeEqual } from "crypto";
3358
+ function keysEqual(received, expected) {
3359
+ const receivedDigest = createHash2("sha256").update(received).digest();
3360
+ const expectedDigest = createHash2("sha256").update(expected).digest();
3361
+ return timingSafeEqual(receivedDigest, expectedDigest);
3362
+ }
3363
+ function readCredential(headers, headerName) {
3364
+ const direct = headers.get(headerName)?.trim();
3365
+ if (direct) {
3366
+ if (headerName.toLowerCase() === "authorization" && direct.toLowerCase().startsWith("bearer ")) {
3367
+ return direct.slice(7).trim() || void 0;
3368
+ }
3369
+ return direct;
3370
+ }
3371
+ const authorization = headers.get("authorization")?.trim();
3372
+ if (authorization?.toLowerCase().startsWith("bearer ")) {
3373
+ return authorization.slice(7).trim() || void 0;
3374
+ }
3375
+ return void 0;
3376
+ }
3377
+ function createApiKeyAuthMiddleware(options) {
3378
+ const headerName = options.headerName || "x-api-key";
3379
+ return async (c, next) => {
3380
+ if (c.req.method === "OPTIONS" || options.mode === "disabled") {
3381
+ return next();
3382
+ }
3383
+ if (!options.apiKey) {
3384
+ if (options.mode === "auto") return next();
3385
+ return c.json(
3386
+ {
3387
+ success: false,
3388
+ error: "API authentication is not configured",
3389
+ code: "AUTH_CONFIGURATION_ERROR"
3390
+ },
3391
+ 503
3392
+ );
3393
+ }
3394
+ const apiKey = readCredential(c.req.raw.headers, headerName);
3395
+ if (!apiKey) {
3396
+ return c.json(
3397
+ {
3398
+ success: false,
3399
+ error: "API key required",
3400
+ code: "UNAUTHORIZED"
3401
+ },
3402
+ 401
3403
+ );
3404
+ }
3405
+ if (!keysEqual(apiKey, options.apiKey)) {
3406
+ return c.json(
3407
+ {
3408
+ success: false,
3409
+ error: "Invalid API key",
3410
+ code: "UNAUTHORIZED"
3411
+ },
3412
+ 401
3413
+ );
3414
+ }
3415
+ await next();
3416
+ };
3417
+ }
2926
3418
  var apiKeyAuthMiddleware;
2927
3419
  var init_auth = __esm({
2928
3420
  "src/server/middleware/hono/auth.ts"() {
2929
3421
  "use strict";
2930
3422
  init_esm_shims();
2931
3423
  init_config();
2932
- apiKeyAuthMiddleware = async (c, next) => {
2933
- if (!config.features.apiKey || !config.API_KEY) {
2934
- return next();
2935
- }
2936
- const apiKey = c.req.header(config.API_KEY_HEADER);
2937
- if (!apiKey) {
2938
- return c.json(
2939
- {
2940
- success: false,
2941
- error: "API key required",
2942
- code: "UNAUTHORIZED"
2943
- },
2944
- 401
2945
- );
2946
- }
2947
- if (apiKey !== config.API_KEY) {
2948
- return c.json(
2949
- {
2950
- success: false,
2951
- error: "Invalid API key",
2952
- code: "UNAUTHORIZED"
2953
- },
2954
- 401
2955
- );
2956
- }
2957
- await next();
2958
- };
3424
+ apiKeyAuthMiddleware = createApiKeyAuthMiddleware({
3425
+ mode: config.API_AUTH_MODE,
3426
+ apiKey: config.API_KEY,
3427
+ headerName: config.API_KEY_HEADER
3428
+ });
2959
3429
  }
2960
3430
  });
2961
3431
 
@@ -3137,12 +3607,48 @@ var init_request_logger = __esm({
3137
3607
  }
3138
3608
  });
3139
3609
 
3610
+ // src/server/middleware/hono/concurrency-limit.ts
3611
+ function concurrencyLimiter(options) {
3612
+ const limit = Math.max(1, Math.floor(options.limit));
3613
+ const retryAfterSeconds = options.retryAfterSeconds ?? 1;
3614
+ let active = 0;
3615
+ return async (c, next) => {
3616
+ c.header("X-Concurrency-Limit", String(limit));
3617
+ if (active >= limit) {
3618
+ c.header("Retry-After", String(retryAfterSeconds));
3619
+ return c.json(
3620
+ {
3621
+ success: false,
3622
+ error: "Server is at capacity",
3623
+ code: "CONCURRENCY_LIMIT_EXCEEDED"
3624
+ },
3625
+ 503
3626
+ );
3627
+ }
3628
+ active += 1;
3629
+ c.header("X-Concurrency-Remaining", String(Math.max(0, limit - active)));
3630
+ try {
3631
+ await next();
3632
+ } finally {
3633
+ active -= 1;
3634
+ }
3635
+ };
3636
+ }
3637
+ var init_concurrency_limit = __esm({
3638
+ "src/server/middleware/hono/concurrency-limit.ts"() {
3639
+ "use strict";
3640
+ init_esm_shims();
3641
+ }
3642
+ });
3643
+
3140
3644
  // src/server/app.ts
3141
3645
  import { Hono as Hono6 } from "hono";
3142
3646
  import { cors } from "hono/cors";
3143
3647
  import { secureHeaders } from "hono/secure-headers";
3144
3648
  import { logger as honoLogger } from "hono/logger";
3145
3649
  import { timing } from "hono/timing";
3650
+ import { bodyLimit as bodyLimit4 } from "hono/body-limit";
3651
+ import { HTTPException as HTTPException7 } from "hono/http-exception";
3146
3652
  function createAPIApp(adapter) {
3147
3653
  Container.initialize(adapter);
3148
3654
  const honoApp = new Hono6();
@@ -3152,7 +3658,7 @@ function createAPIApp(adapter) {
3152
3658
  "*",
3153
3659
  cors({
3154
3660
  origin: config.cors.origin,
3155
- credentials: config.cors.credentials,
3661
+ credentials: config.cors.origin === "*" ? false : config.cors.credentials,
3156
3662
  allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
3157
3663
  allowHeaders: ["Content-Type", "Authorization", config.API_KEY_HEADER],
3158
3664
  exposeHeaders: ["X-Request-Id", "X-File-Id"],
@@ -3164,6 +3670,28 @@ function createAPIApp(adapter) {
3164
3670
  honoApp.use("*", errorRecoveryMiddleware);
3165
3671
  honoApp.use("*", requestLoggerMiddleware);
3166
3672
  honoApp.use("*", securityMiddleware);
3673
+ honoApp.use(
3674
+ "/api/*",
3675
+ bodyLimit4({
3676
+ maxSize: config.requestLimits.maxBodySize,
3677
+ onError: () => {
3678
+ throw new HTTPException7(413, { message: "Request body too large" });
3679
+ }
3680
+ })
3681
+ );
3682
+ honoApp.use(
3683
+ "/api/*",
3684
+ rateLimiter({
3685
+ limit: config.rateLimit.max,
3686
+ window: config.rateLimit.windowMs,
3687
+ namespace: (c) => `${c.req.method}:${c.req.path}`,
3688
+ trustProxy: config.rateLimit.trustProxy
3689
+ })
3690
+ );
3691
+ honoApp.use(
3692
+ "/api/*",
3693
+ concurrencyLimiter({ limit: config.requestLimits.maxConcurrent })
3694
+ );
3167
3695
  if (config.features.apiKey) {
3168
3696
  honoApp.use("/api/*", apiKeyAuthMiddleware);
3169
3697
  }
@@ -3217,6 +3745,8 @@ var init_app = __esm({
3217
3745
  init_security();
3218
3746
  init_error_recovery();
3219
3747
  init_request_logger();
3748
+ init_rate_limit();
3749
+ init_concurrency_limit();
3220
3750
  init_container();
3221
3751
  }
3222
3752
  });
@@ -3273,7 +3803,7 @@ var init_unified_server = __esm({
3273
3803
  );
3274
3804
  const apiApp = createAPIApp(this.adapter);
3275
3805
  this.app.get("/api", async (c) => {
3276
- return await apiApp.fetch(c.req.raw);
3806
+ return await apiApp.fetch(c.req.raw, c.env);
3277
3807
  });
3278
3808
  this.app.use("*", async (c, next) => {
3279
3809
  const path6 = c.req.path;
@@ -3281,7 +3811,7 @@ var init_unified_server = __esm({
3281
3811
  if (/\.(ts|tsx|js|jsx|css|map)$/.test(path6)) {
3282
3812
  return next();
3283
3813
  }
3284
- const response = await apiApp.fetch(c.req.raw);
3814
+ const response = await apiApp.fetch(c.req.raw, c.env);
3285
3815
  if (response.status !== 404) {
3286
3816
  return response;
3287
3817
  }
@@ -3478,20 +4008,53 @@ var init_unified_server = __esm({
3478
4008
  // src/cli.ts
3479
4009
  init_esm_shims();
3480
4010
  import { Command as Command2 } from "commander";
3481
- import chalk2 from "chalk";
3482
- import { registerCoreCommands } from "@json-to-office/jto-cli";
4011
+ import chalk from "chalk";
4012
+ import { registerCoreCommands, renderLines as renderLines2 } from "@json-to-office/jto-cli";
3483
4013
 
3484
4014
  // src/commands/dev.ts
3485
4015
  init_esm_shims();
3486
4016
  import { Command } from "commander";
3487
- import chalk from "chalk";
3488
- import ora from "ora";
3489
- import boxen from "boxen";
3490
4017
  import {
3491
4018
  loadConfig,
4019
+ parsePort,
3492
4020
  formatError,
4021
+ renderLines,
4022
+ runTask,
3493
4023
  EXIT_CODES
3494
4024
  } from "@json-to-office/jto-cli";
4025
+ function installShutdownHandlers(server) {
4026
+ let shutdownPromise;
4027
+ const removeHandlers = () => {
4028
+ process.off("SIGINT", handleShutdown);
4029
+ process.off("SIGTERM", handleShutdown);
4030
+ };
4031
+ const handleShutdown = () => {
4032
+ shutdownPromise ??= (async () => {
4033
+ let exitCode = EXIT_CODES.OK;
4034
+ try {
4035
+ await runTask("Shutting down...", () => server.stop(), {
4036
+ success: "Server stopped",
4037
+ failure: "Failed to stop server"
4038
+ });
4039
+ } catch (error) {
4040
+ exitCode = EXIT_CODES.FAIL;
4041
+ await formatError(error);
4042
+ } finally {
4043
+ removeHandlers();
4044
+ }
4045
+ process.exit(exitCode);
4046
+ })();
4047
+ };
4048
+ process.once("SIGINT", handleShutdown);
4049
+ process.once("SIGTERM", handleShutdown);
4050
+ }
4051
+ async function openBrowser(url) {
4052
+ const { execFile: execFile2 } = await import("child_process");
4053
+ const [command, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
4054
+ await new Promise((resolve2, reject) => {
4055
+ execFile2(command, args, (error) => error ? reject(error) : resolve2());
4056
+ });
4057
+ }
3495
4058
  function createDevCommand(adapter) {
3496
4059
  const dev = new Command("dev");
3497
4060
  return dev.description("Start development server with web UI").option(
@@ -3499,64 +4062,69 @@ function createDevCommand(adapter) {
3499
4062
  "Port to run server on",
3500
4063
  String(adapter.defaultPort)
3501
4064
  ).option("-H, --host <host>", "Host to bind to", "localhost").option("-o, --open", "Open browser automatically").option("-c, --config <path>", "Path to config file").action(async (options) => {
3502
- const spinner = ora(
3503
- `Starting ${adapter.name.toUpperCase()} dev server...`
3504
- ).start();
3505
4065
  try {
3506
- const config2 = await loadConfig(options.config);
3507
- const portSource = dev.getOptionValueSource("port");
3508
- const hostSource = dev.getOptionValueSource("host");
3509
- if (portSource === "cli") {
3510
- config2.server.port = parseInt(options.port, 10);
3511
- } else if (config2.server.port === 3003 && adapter.defaultPort !== 3003) {
3512
- config2.server.port = adapter.defaultPort;
3513
- }
3514
- if (hostSource === "cli") {
3515
- config2.server.host = options.host;
3516
- }
3517
- const { UnifiedServer: UnifiedServer2 } = await Promise.resolve().then(() => (init_unified_server(), unified_server_exports));
3518
- const server = new UnifiedServer2(adapter, config2);
3519
- await server.start();
3520
- const url = `http://${config2.server.host}:${config2.server.port}`;
3521
- spinner.succeed("Server ready");
3522
- console.log(
3523
- boxen(
3524
- chalk.bold(`${adapter.name.toUpperCase()} Dev Server
3525
-
3526
- `) + `${chalk.cyan("Local:")} ${chalk.bold(url)}
3527
- ${chalk.cyan("API:")} ${url}/api/${adapter.name}/generate
3528
- ${chalk.cyan("Health:")} ${url}/health
3529
-
3530
- ` + chalk.gray("Press Ctrl+C to stop"),
3531
- {
3532
- padding: 1,
3533
- borderColor: "green",
3534
- borderStyle: "round"
4066
+ const { server, url } = await runTask(
4067
+ `Starting ${adapter.name.toUpperCase()} dev server...`,
4068
+ async () => {
4069
+ const config2 = await loadConfig(options.config, {
4070
+ defaultPort: adapter.defaultPort
4071
+ });
4072
+ const portSource = dev.getOptionValueSource("port");
4073
+ const hostSource = dev.getOptionValueSource("host");
4074
+ if (portSource === "cli") {
4075
+ const port = parsePort(options.port);
4076
+ if (port === void 0) {
4077
+ throw new Error(
4078
+ `Invalid --port "${options.port}": expected an integer between 0 and 65535`
4079
+ );
4080
+ }
4081
+ config2.server.port = port;
3535
4082
  }
3536
- )
4083
+ if (hostSource === "cli") {
4084
+ config2.server.host = options.host;
4085
+ }
4086
+ const { UnifiedServer: UnifiedServer2 } = await Promise.resolve().then(() => (init_unified_server(), unified_server_exports));
4087
+ const server2 = new UnifiedServer2(adapter, config2);
4088
+ await server2.start();
4089
+ return {
4090
+ server: server2,
4091
+ url: `http://${config2.server.host}:${config2.server.port}`
4092
+ };
4093
+ },
4094
+ { success: "Server ready", failure: "Failed to start dev server" }
3537
4095
  );
4096
+ installShutdownHandlers(server);
4097
+ await renderLines([
4098
+ {
4099
+ text: `${adapter.name.toUpperCase()} Dev Server`,
4100
+ tone: "success"
4101
+ },
4102
+ { text: `Local: ${url}` },
4103
+ { text: `API: ${url}/api/${adapter.name}/generate` },
4104
+ { text: `Health: ${url}/health` },
4105
+ { text: "Press Ctrl+C to stop", tone: "muted" }
4106
+ ]);
3538
4107
  if (options.open) {
3539
- const { execFile: execFile2 } = await import("child_process");
3540
- const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
3541
- execFile2(cmd, [url]);
3542
- }
3543
- const shutdown = async () => {
3544
- console.log(chalk.yellow("\nShutting down..."));
3545
- await server.stop();
3546
- process.exit(EXIT_CODES.OK);
3547
- };
3548
- process.on("SIGINT", shutdown);
3549
- process.on("SIGTERM", shutdown);
4108
+ try {
4109
+ await openBrowser(url);
4110
+ } catch (error) {
4111
+ await renderLines([
4112
+ {
4113
+ text: `Could not open browser: ${error instanceof Error ? error.message : String(error)}`,
4114
+ tone: "warning"
4115
+ }
4116
+ ]);
4117
+ }
4118
+ }
3550
4119
  } catch (error) {
3551
- spinner.fail("Failed to start dev server");
3552
- formatError(error);
4120
+ await formatError(error);
3553
4121
  process.exit(EXIT_CODES.FAIL);
3554
4122
  }
3555
4123
  });
3556
4124
  }
3557
4125
 
3558
4126
  // src/cli.ts
3559
- var PACKAGE_VERSION = true ? "0.20.0" : "dev-mode";
4127
+ var PACKAGE_VERSION = true ? "0.22.0" : "dev-mode";
3560
4128
  var program = new Command2();
3561
4129
  program.name("jto").description("JSON to Office CLI - Generate .docx and .pptx from JSON").version(PACKAGE_VERSION);
3562
4130
  registerCoreCommands(program, {
@@ -3565,14 +4133,14 @@ registerCoreCommands(program, {
3565
4133
  program.addHelpText(
3566
4134
  "after",
3567
4135
  `
3568
- ${chalk2.gray("Examples:")}
3569
- $ jto docx generate doc.json ${chalk2.dim("# Generate DOCX from JSON")}
3570
- $ jto pptx generate slides.json ${chalk2.dim("# Generate PPTX from JSON")}
3571
- $ jto docx dev ${chalk2.dim("# Start DOCX dev server")}
3572
- $ jto pptx validate slides.json ${chalk2.dim("# Validate PPTX JSON")}
3573
- $ jto docx schemas ${chalk2.dim("# Export DOCX JSON schemas")}
3574
- $ jto pptx discover ${chalk2.dim("# Discover PPTX plugins")}
3575
- $ jto docx fonts install Inter ${chalk2.dim("# Download a Google Font into ./fonts")}
4136
+ ${chalk.gray("Examples:")}
4137
+ $ jto docx generate doc.json ${chalk.dim("# Generate DOCX from JSON")}
4138
+ $ jto pptx generate slides.json ${chalk.dim("# Generate PPTX from JSON")}
4139
+ $ jto docx dev ${chalk.dim("# Start DOCX dev server")}
4140
+ $ jto pptx validate slides.json ${chalk.dim("# Validate PPTX JSON")}
4141
+ $ jto docx schemas ${chalk.dim("# Export DOCX JSON schemas")}
4142
+ $ jto pptx discover ${chalk.dim("# Discover PPTX plugins")}
4143
+ $ jto docx fonts install Inter ${chalk.dim("# Download a Google Font into ./fonts")}
3576
4144
  `
3577
4145
  );
3578
4146
  program.exitOverride();
@@ -3586,7 +4154,10 @@ program.exitOverride();
3586
4154
  if (error.code === "commander.executeSubCommandAsync") {
3587
4155
  process.exit(error.exitCode);
3588
4156
  }
3589
- console.error(chalk2.red("Error:"), error.message);
4157
+ await renderLines2(
4158
+ [{ text: `Error: ${error.message}`, tone: "error" }],
4159
+ process.stderr
4160
+ );
3590
4161
  process.exit(1);
3591
4162
  }
3592
4163
  })();