tina4-nodejs 3.13.91 → 3.13.94

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 (135) hide show
  1. package/CLAUDE.md +16 -3
  2. package/README.md +1 -1
  3. package/package.json +12 -9
  4. package/packages/cli/dist/bin.js +1312 -987
  5. package/packages/core/dist/index.js +1312 -987
  6. package/packages/core/src/auth.ts +141 -21
  7. package/packages/core/src/devMailbox.ts +20 -44
  8. package/packages/core/src/index.ts +2 -2
  9. package/packages/core/src/messenger.ts +72 -0
  10. package/packages/core/src/queueBackends/kafkaBackend.ts +108 -12
  11. package/packages/core/src/sessionHandlers/childError.ts +72 -0
  12. package/packages/core/src/sessionHandlers/mongoClient.ts +9 -3
  13. package/packages/core/src/sessionHandlers/redisHandler.ts +18 -5
  14. package/packages/core/src/sessionHandlers/respClient.ts +5 -1
  15. package/packages/frond/dist/index.js +74 -31
  16. package/packages/frond/src/engine.ts +99 -33
  17. package/packages/orm/dist/index.js +3102 -2777
  18. package/packages/orm/src/adapters/sqlite.ts +4 -1
  19. package/packages/orm/src/database.ts +108 -8
  20. package/types/cli/src/bin.d.ts +92 -0
  21. package/types/cli/src/commands/build.d.ts +2 -0
  22. package/types/cli/src/commands/generate.d.ts +47 -0
  23. package/types/cli/src/commands/init.d.ts +1 -0
  24. package/types/cli/src/commands/metrics.d.ts +6 -0
  25. package/types/cli/src/commands/migrate.d.ts +1 -0
  26. package/types/cli/src/commands/migrateCreate.d.ts +1 -0
  27. package/types/cli/src/commands/migrateRollback.d.ts +1 -0
  28. package/types/cli/src/commands/migrateStatus.d.ts +1 -0
  29. package/types/cli/src/commands/queue.d.ts +20 -0
  30. package/types/cli/src/commands/routes.d.ts +1 -0
  31. package/types/cli/src/commands/seed.d.ts +1 -0
  32. package/types/cli/src/commands/serve.d.ts +6 -0
  33. package/types/cli/src/commands/test.d.ts +1 -0
  34. package/types/core/src/ai.d.ts +64 -0
  35. package/types/core/src/api.d.ts +262 -0
  36. package/types/core/src/auth.d.ts +154 -0
  37. package/types/core/src/authGate.d.ts +20 -0
  38. package/types/core/src/background.d.ts +34 -0
  39. package/types/core/src/cache.d.ts +160 -0
  40. package/types/core/src/constants.d.ts +38 -0
  41. package/types/core/src/container.d.ts +44 -0
  42. package/types/core/src/context/chunker.d.ts +31 -0
  43. package/types/core/src/context/index.d.ts +93 -0
  44. package/types/core/src/devAdmin.d.ts +179 -0
  45. package/types/core/src/devMailbox.d.ts +54 -0
  46. package/types/core/src/docs.d.ts +141 -0
  47. package/types/core/src/docsAutoDiscovery.d.ts +6 -0
  48. package/types/core/src/dotenv.d.ts +65 -0
  49. package/types/core/src/env.d.ts +28 -0
  50. package/types/core/src/errorOverlay.d.ts +36 -0
  51. package/types/core/src/events.d.ts +75 -0
  52. package/types/core/src/fakeData.d.ts +55 -0
  53. package/types/core/src/feedback.d.ts +90 -0
  54. package/types/core/src/graphql.d.ts +207 -0
  55. package/types/core/src/health.d.ts +22 -0
  56. package/types/core/src/htmlElement.d.ts +75 -0
  57. package/types/core/src/i18n.d.ts +37 -0
  58. package/types/core/src/index.d.ts +93 -0
  59. package/types/core/src/job.d.ts +39 -0
  60. package/types/core/src/logger.d.ts +123 -0
  61. package/types/core/src/mcp.d.ts +248 -0
  62. package/types/core/src/messenger.d.ts +191 -0
  63. package/types/core/src/metrics.d.ts +77 -0
  64. package/types/core/src/middleware.d.ts +207 -0
  65. package/types/core/src/mqtt.d.ts +257 -0
  66. package/types/core/src/mqttMessage.d.ts +67 -0
  67. package/types/core/src/plan.d.ts +96 -0
  68. package/types/core/src/projectIndex.d.ts +56 -0
  69. package/types/core/src/queue.d.ts +219 -0
  70. package/types/core/src/queueBackends/kafkaBackend.d.ts +117 -0
  71. package/types/core/src/queueBackends/liteBackend.d.ts +119 -0
  72. package/types/core/src/queueBackends/mongoBackend.d.ts +97 -0
  73. package/types/core/src/queueBackends/rabbitmqBackend.d.ts +55 -0
  74. package/types/core/src/rateLimiter.d.ts +49 -0
  75. package/types/core/src/request.d.ts +25 -0
  76. package/types/core/src/response.d.ts +28 -0
  77. package/types/core/src/routeDiscovery.d.ts +12 -0
  78. package/types/core/src/router.d.ts +355 -0
  79. package/types/core/src/scss.d.ts +19 -0
  80. package/types/core/src/server.d.ts +131 -0
  81. package/types/core/src/service.d.ts +115 -0
  82. package/types/core/src/session.d.ts +256 -0
  83. package/types/core/src/sessionHandlers/childError.d.ts +34 -0
  84. package/types/core/src/sessionHandlers/databaseHandler.d.ts +42 -0
  85. package/types/core/src/sessionHandlers/mongoClient.d.ts +24 -0
  86. package/types/core/src/sessionHandlers/mongoHandler.d.ts +61 -0
  87. package/types/core/src/sessionHandlers/redisHandler.d.ts +60 -0
  88. package/types/core/src/sessionHandlers/respClient.d.ts +22 -0
  89. package/types/core/src/sessionHandlers/valkeyHandler.d.ts +65 -0
  90. package/types/core/src/static.d.ts +2 -0
  91. package/types/core/src/test.d.ts +94 -0
  92. package/types/core/src/testClient.d.ts +36 -0
  93. package/types/core/src/testing.d.ts +58 -0
  94. package/types/core/src/types.d.ts +219 -0
  95. package/types/core/src/validator.d.ts +52 -0
  96. package/types/core/src/websocket.d.ts +376 -0
  97. package/types/core/src/websocketBackplane.d.ts +166 -0
  98. package/types/core/src/websocketConnection.d.ts +54 -0
  99. package/types/core/src/wsdl.d.ts +101 -0
  100. package/types/frond/src/engine.d.ts +263 -0
  101. package/types/frond/src/index.d.ts +2 -0
  102. package/types/orm/src/adapters/firebird.d.ts +138 -0
  103. package/types/orm/src/adapters/mongodb.d.ts +81 -0
  104. package/types/orm/src/adapters/mssql.d.ts +70 -0
  105. package/types/orm/src/adapters/mysql.d.ts +66 -0
  106. package/types/orm/src/adapters/odbc.d.ts +97 -0
  107. package/types/orm/src/adapters/postgres.d.ts +85 -0
  108. package/types/orm/src/adapters/sqlite.d.ts +56 -0
  109. package/types/orm/src/autoCrud.d.ts +73 -0
  110. package/types/orm/src/baseModel.d.ts +391 -0
  111. package/types/orm/src/cachedDatabase.d.ts +177 -0
  112. package/types/orm/src/database.d.ts +609 -0
  113. package/types/orm/src/databaseResult.d.ts +85 -0
  114. package/types/orm/src/docstore.d.ts +182 -0
  115. package/types/orm/src/fakeData.d.ts +22 -0
  116. package/types/orm/src/index.d.ts +40 -0
  117. package/types/orm/src/migration.d.ts +275 -0
  118. package/types/orm/src/model.d.ts +7 -0
  119. package/types/orm/src/query.d.ts +14 -0
  120. package/types/orm/src/queryBuilder.d.ts +173 -0
  121. package/types/orm/src/realtime/index.d.ts +7 -0
  122. package/types/orm/src/realtime/models/attachment.d.ts +43 -0
  123. package/types/orm/src/realtime/models/channel.d.ts +32 -0
  124. package/types/orm/src/realtime/models/channelMember.d.ts +32 -0
  125. package/types/orm/src/realtime/models/message.d.ts +36 -0
  126. package/types/orm/src/realtime/models/workspace.d.ts +26 -0
  127. package/types/orm/src/realtime/realtime.d.ts +24 -0
  128. package/types/orm/src/realtime/storage.d.ts +61 -0
  129. package/types/orm/src/seeder.d.ts +118 -0
  130. package/types/orm/src/sqlTranslator.d.ts +134 -0
  131. package/types/orm/src/types.d.ts +138 -0
  132. package/types/orm/src/validation.d.ts +6 -0
  133. package/types/swagger/src/generator.d.ts +46 -0
  134. package/types/swagger/src/index.d.ts +2 -0
  135. package/types/swagger/src/ui.d.ts +11 -0
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Tina4 session handlers — turn a failed `execFileSync` child into a readable cause.
3
+ *
4
+ * The session-handler interface is synchronous but every backend client is async,
5
+ * so each command runs in a short-lived `node -e` child. When that child fails,
6
+ * `execFileSync` throws an error whose `.message` begins "Command failed:" and
7
+ * then embeds THE ENTIRE GENERATED SCRIPT — kilobytes of source with the real
8
+ * reason nowhere in it. Every handler used to throw exactly that, so an
9
+ * operator debugging a Redis outage got a wall of JavaScript instead of
10
+ * "connect ECONNREFUSED 127.0.0.1:6379".
11
+ *
12
+ * The children already write the real reason to stderr; `execFileSync` captures
13
+ * it on `err.stderr`. This module is the ONE place that prefers it, so the three
14
+ * call sites (respClient, mongoClient, redisHandler's npm path) cannot drift.
15
+ */
16
+
17
+ /** Longest fallback we will pass through when there is no usable stderr. */
18
+ const MAX_FALLBACK = 200;
19
+
20
+ /**
21
+ * Extract the most useful one-line cause from a thrown `execFileSync` error.
22
+ *
23
+ * Order of preference:
24
+ * 1. the child's own stderr — what it actually reported;
25
+ * 2. a timeout, named as such (a SIGTERM kill leaves stderr empty, so without
26
+ * this the caller would see the useless generic message);
27
+ * 3. a non-zero exit code with no output at all;
28
+ * 4. the error's own message, first line only and length-capped, so the
29
+ * generated script can never be dumped into a log.
30
+ */
31
+ export function childFailureReason(err: unknown): string {
32
+ const e = (err ?? {}) as {
33
+ stderr?: Buffer | string;
34
+ message?: string;
35
+ signal?: string | null;
36
+ status?: number | null;
37
+ code?: string;
38
+ };
39
+
40
+ const stderr = String(e.stderr ?? "").trim();
41
+ if (stderr !== "") {
42
+ return firstLine(stderr);
43
+ }
44
+
45
+ // execFileSync's `timeout` option kills the child with a signal, so it exits
46
+ // with NOTHING on stderr. Say "timed out" rather than "Command failed".
47
+ if (e.code === "ETIMEDOUT" || e.signal) {
48
+ return `timed out or was killed (${e.code ?? e.signal})`;
49
+ }
50
+
51
+ if (typeof e.status === "number" && e.status !== 0) {
52
+ return `child exited with code ${e.status} and no output`;
53
+ }
54
+
55
+ return firstLine(String(e.message ?? "unknown error"));
56
+ }
57
+
58
+ /** First line of `text`, capped at MAX_FALLBACK characters. */
59
+ function firstLine(text: string): string {
60
+ const line = text.split("\n", 1)[0] ?? "";
61
+ return line.length > MAX_FALLBACK ? `${line.slice(0, MAX_FALLBACK)}...` : line;
62
+ }
63
+
64
+ /**
65
+ * Build the Error a session handler throws when its child command failed.
66
+ *
67
+ * `label` names the backend ("Redis", "Valkey", "MongoDB") so the message says
68
+ * which one broke; the wording is shared so all three read alike.
69
+ */
70
+ export function childFailureError(label: string, err: unknown): Error {
71
+ return new Error(`${label} command failed: ${childFailureReason(err)}`);
72
+ }
@@ -22,6 +22,7 @@
22
22
  * writes).
23
23
  */
24
24
  import { execFileSync } from "node:child_process";
25
+ import { childFailureError } from "./childError.js";
25
26
 
26
27
  export interface MongoTarget {
27
28
  host: string;
@@ -90,8 +91,10 @@ export function mongoCommandSync(
90
91
  process.stdout.write(out, () => process.exit(0));
91
92
  } catch (err) {
92
93
  try { if (client) await client.close(); } catch (e) {}
93
- process.stderr.write(String((err && err.message) || err));
94
- process.exit(1);
94
+ // Exit from the write CALLBACK: stderr to a pipe is an async write and
95
+ // a bare process.exit() truncates it, which left the parent with an
96
+ // empty stderr and nothing but execFileSync's script-dump message.
97
+ process.stderr.write(String((err && err.message) || err), () => process.exit(1));
95
98
  }
96
99
  })();
97
100
  } else {
@@ -233,6 +236,9 @@ export function mongoCommandSync(
233
236
  stdio: ["pipe", "pipe", "pipe"],
234
237
  });
235
238
  } catch (err) {
236
- throw new Error(`${label} command failed: ${(err as Error).message}`);
239
+ // The child's stderr carries the real reason (connection refused, auth
240
+ // failure, timeout); execFileSync's message carries the whole generated
241
+ // script. Prefer the former.
242
+ throw childFailureError(label, err);
237
243
  }
238
244
  }
@@ -17,6 +17,7 @@
17
17
  * TINA4_SESSION_REDIS_DB (default: 0)
18
18
  */
19
19
  import { execFileSync } from "node:child_process";
20
+ import { childFailureError } from "./childError.js";
20
21
  import { createRequire } from "node:module";
21
22
  import type { SessionHandler } from "../session.js";
22
23
  import { respCommandSync } from "./respClient.js";
@@ -142,9 +143,17 @@ export class RedisNpmSessionHandler implements SessionHandler {
142
143
  (async () => {
143
144
  try {
144
145
  const redis = require("redis");
146
+ // reconnectStrategy: false — this child runs ONE command and exits, so
147
+ // retrying inside it is pointless: the handler is called again on the
148
+ // next request anyway. With the driver's default strategy a refused
149
+ // connection never rejects, the child hangs until execFileSync's 5s
150
+ // timeout kills it, and the caller is told "timed out" when the truth
151
+ // is "connection refused". Off, connect() rejects in ~5ms with the real
152
+ // reason -- a better message AND no 5s stall per request when Redis is
153
+ // down.
145
154
  const clientOpts = useUrl
146
- ? { url }
147
- : { socket: { host, port }, password: password || undefined, database: db };
155
+ ? { url, socket: { reconnectStrategy: false } }
156
+ : { socket: { host, port, reconnectStrategy: false }, password: password || undefined, database: db };
148
157
  const client = redis.createClient(clientOpts);
149
158
  client.on("error", () => {});
150
159
  await client.connect();
@@ -158,8 +167,10 @@ export class RedisNpmSessionHandler implements SessionHandler {
158
167
  const out = (result === null || result === undefined) ? "__NULL__" : String(result);
159
168
  process.stdout.write(out, () => process.exit(0));
160
169
  } catch (err) {
161
- process.stderr.write(String((err && err.message) || err));
162
- process.exit(1);
170
+ // Exit from the write CALLBACK: stderr to a pipe is an async write and
171
+ // a bare process.exit() truncates it, which left the parent with an
172
+ // empty stderr and nothing but execFileSync's script-dump message.
173
+ process.stderr.write(String((err && err.message) || err), () => process.exit(1));
163
174
  }
164
175
  })();
165
176
  `;
@@ -171,7 +182,9 @@ export class RedisNpmSessionHandler implements SessionHandler {
171
182
  stdio: ["pipe", "pipe", "pipe"],
172
183
  });
173
184
  } catch (err) {
174
- throw new Error(`Redis command failed: ${(err as Error).message}`);
185
+ // The child's stderr carries the driver's real reason; execFileSync's
186
+ // message carries the whole generated script. Prefer the former.
187
+ throw childFailureError("Redis", err);
175
188
  }
176
189
  if (result === "__NULL__") return ""; // genuine key miss
177
190
  return result;
@@ -16,6 +16,7 @@
16
16
  * then returns the LAST (command) reply.
17
17
  */
18
18
  import { execFileSync } from "node:child_process";
19
+ import { childFailureError } from "./childError.js";
19
20
 
20
21
  export interface RespTarget {
21
22
  host: string;
@@ -161,7 +162,10 @@ export function respCommandSync(target: RespTarget, args: string[], label = "Red
161
162
  // Non-zero exit = socket error / timeout / closed connection: a transport
162
163
  // FAILURE, not a key miss. Surface it so the Session boundary logs + degrades
163
164
  // (or re-throws under strict mode).
164
- throw new Error(`${label} command failed: ${(err as Error).message}`);
165
+ //
166
+ // Report the CHILD's stderr, not execFileSync's message -- that message
167
+ // embeds the whole generated script and buries the actual reason.
168
+ throw childFailureError(label, err);
165
169
  }
166
170
  if (result === "__NULL__") return ""; // genuine key miss
167
171
  if (result.startsWith("__ERR__")) {
@@ -41,6 +41,29 @@ var TERMINATOR_TAGS = /* @__PURE__ */ new Set([
41
41
  "endset",
42
42
  "endspaceless"
43
43
  ]);
44
+ var GATEABLE_TAGS = /* @__PURE__ */ new Set([
45
+ "autoescape",
46
+ "cache",
47
+ "for",
48
+ "from",
49
+ "if",
50
+ "import",
51
+ "include",
52
+ "live",
53
+ "macro",
54
+ "set",
55
+ "spaceless"
56
+ ]);
57
+ var BLOCK_TAG_ENDS = {
58
+ autoescape: "endautoescape",
59
+ cache: "endcache",
60
+ for: "endfor",
61
+ if: "endif",
62
+ live: "endlive",
63
+ macro: "endmacro",
64
+ set: "endset",
65
+ spaceless: "endspaceless"
66
+ };
44
67
  function jsonText(value) {
45
68
  try {
46
69
  const text = JSON.stringify(value);
@@ -1680,42 +1703,27 @@ var Frond = class _Frond {
1680
1703
  if (stripA && i + 1 < tokens.length && tokens[i + 1][0] === "TEXT") {
1681
1704
  tokens[i + 1] = ["TEXT", tokens[i + 1][1].replace(LEADING_WS_RE, "")];
1682
1705
  }
1683
- if (tag === "if") {
1684
- if (this._sandbox && this._allowedTags !== null && !this._allowedTags.has("if")) {
1685
- const skip = this.skipBlock(tokens, i, "if", "endif");
1686
- i = skip;
1687
- } else {
1688
- const [result, skip] = this.handleIf(tokens, i, context);
1689
- output.push(result);
1690
- i = skip;
1691
- }
1706
+ if (!this.tagPermitted(tag)) {
1707
+ i = this.skipDeniedTag(tokens, i, tag, content);
1708
+ } else if (tag === "if") {
1709
+ const [result, skip] = this.handleIf(tokens, i, context);
1710
+ output.push(result);
1711
+ i = skip;
1692
1712
  } else if (tag === "for") {
1693
- if (this._sandbox && this._allowedTags !== null && !this._allowedTags.has("for")) {
1694
- const skip = this.skipBlock(tokens, i, "for", "endfor");
1695
- i = skip;
1696
- } else {
1697
- const [result, skip] = this.handleFor(tokens, i, context);
1698
- output.push(result);
1699
- i = skip;
1700
- }
1713
+ const [result, skip] = this.handleFor(tokens, i, context);
1714
+ output.push(result);
1715
+ i = skip;
1701
1716
  } else if (tag === "set") {
1702
- const isBlockSet = !content.includes("=");
1703
- if (this._sandbox && this._allowedTags !== null && !this._allowedTags.has("set")) {
1704
- i = isBlockSet ? this.skipBlock(tokens, i, "set", "endset") : i + 1;
1705
- } else if (isBlockSet) {
1717
+ if (!content.includes("=")) {
1706
1718
  i = this.handleSetBlock(tokens, i, context);
1707
1719
  } else {
1708
1720
  this.handleSet(content, context);
1709
1721
  i++;
1710
1722
  }
1711
1723
  } else if (tag === "include") {
1712
- if (this._sandbox && this._allowedTags !== null && !this._allowedTags.has("include")) {
1713
- i++;
1714
- } else {
1715
- const result = this.handleInclude(content, context);
1716
- output.push(result);
1717
- i++;
1718
- }
1724
+ const result = this.handleInclude(content, context);
1725
+ output.push(result);
1726
+ i++;
1719
1727
  } else if (tag === "macro") {
1720
1728
  const skip = this.handleMacro(tokens, i, context);
1721
1729
  i = skip;
@@ -1760,6 +1768,41 @@ var Frond = class _Frond {
1760
1768
  }
1761
1769
  return output.join("");
1762
1770
  }
1771
+ /**
1772
+ * May this filter RUN under the current sandbox?
1773
+ *
1774
+ * The escaping decision has to ask this rather than read the filter name out of
1775
+ * the source. Node carries safety as a FLAG rather than as a value-level marker
1776
+ * (Python and Ruby return a SafeString, PHP prepends a RAW_MARKER -- all three
1777
+ * produced only by actually running the filter), so here the name alone was
1778
+ * enough to suppress auto-escaping even when the filter was denied and skipped.
1779
+ */
1780
+ filterPermitted(name) {
1781
+ if (!this._sandbox || this._allowedFilters === null) return true;
1782
+ return this._allowedFilters.has(name);
1783
+ }
1784
+ /**
1785
+ * May this tag run under the current sandbox?
1786
+ *
1787
+ * One gate for every tag, so the allow-list governs the whole tag vocabulary
1788
+ * instead of the four names that happened to be checked individually.
1789
+ */
1790
+ tagPermitted(tag) {
1791
+ if (!this._sandbox || this._allowedTags === null) return true;
1792
+ if (!GATEABLE_TAGS.has(tag)) return true;
1793
+ return this._allowedTags.has(tag);
1794
+ }
1795
+ /**
1796
+ * Consume a denied tag WITHOUT running it, returning the index past its body.
1797
+ *
1798
+ * Advancing a single token past a body-owning tag would leave the body's tokens
1799
+ * to render at the TOP level, leaking exactly the content the sandbox denied.
1800
+ */
1801
+ skipDeniedTag(tokens, start, tag, content) {
1802
+ const closeTag = BLOCK_TAG_ENDS[tag];
1803
+ if (closeTag === void 0 || tag === "set" && content.includes("=")) return start + 1;
1804
+ return this.skipBlock(tokens, start, tag, closeTag);
1805
+ }
1763
1806
  skipBlock(tokens, start, openTag, closeTag) {
1764
1807
  let depth = 0;
1765
1808
  let i = start + 1;
@@ -1767,7 +1810,7 @@ var Frond = class _Frond {
1767
1810
  if (tokens[i][0] === "BLOCK") {
1768
1811
  const [content] = stripTag(tokens[i][1]);
1769
1812
  const tag = content.split(/\s+/)[0] || "";
1770
- if (tag === openTag) depth++;
1813
+ if (tag === openTag && !(openTag === "set" && content.includes("="))) depth++;
1771
1814
  else if (tag === closeTag) {
1772
1815
  if (depth === 0) return i + 1;
1773
1816
  depth--;
@@ -1952,11 +1995,11 @@ var Frond = class _Frond {
1952
1995
  for (const [fname, rawArgs] of filters) {
1953
1996
  const args = rawArgs.map((a) => a instanceof VarRef ? evalExpr(a.name, context) : a);
1954
1997
  if (fname === "raw" || fname === "safe") {
1955
- isSafe = true;
1998
+ if (this.filterPermitted(fname)) isSafe = true;
1956
1999
  continue;
1957
2000
  }
1958
2001
  if (fname === "escape" || fname === "e") {
1959
- isSafe = true;
2002
+ if (this.filterPermitted(fname)) isSafe = true;
1960
2003
  }
1961
2004
  if (this._sandbox && this._allowedFilters !== null) {
1962
2005
  if (!this._allowedFilters.has(fname)) {
@@ -59,6 +59,35 @@ const TERMINATOR_TAGS = new Set([
59
59
  "endif", "endlive", "endmacro", "endraw", "endset", "endspaceless",
60
60
  ]);
61
61
 
62
+ /**
63
+ * Author-written tags the sandbox allow-list governs. Mirrors Python's
64
+ * _GATEABLE_TAGS, PHP's GATEABLE_TAGS and Ruby's GATEABLE_TAGS. A tag absent from
65
+ * this set is structural, not an author capability, and is never gated -- `block`
66
+ * and `extends` are template inheritance, and `raw` is consumed by the tokenizer.
67
+ * Both spellings of set ({% set x = 1 %} and {% set x %}...{% endset %}) dispatch
68
+ * under "set", so one entry covers the pair.
69
+ */
70
+ const GATEABLE_TAGS = new Set([
71
+ "autoescape", "cache", "for", "from", "if", "import", "include", "live",
72
+ "macro", "set", "spaceless",
73
+ ]);
74
+
75
+ /**
76
+ * Gateable tags that OWN A BODY, mapped to the terminator closing it. A denied tag
77
+ * has to consume its body or the body's tokens render at the top level, leaking
78
+ * exactly the content the sandbox denied.
79
+ */
80
+ const BLOCK_TAG_ENDS: Record<string, string> = {
81
+ autoescape: "endautoescape",
82
+ cache: "endcache",
83
+ for: "endfor",
84
+ if: "endif",
85
+ live: "endlive",
86
+ macro: "endmacro",
87
+ set: "endset",
88
+ spaceless: "endspaceless",
89
+ };
90
+
62
91
  /**
63
92
  * Serialize a value to compact JSON text that is always valid JSON.
64
93
  *
@@ -2082,48 +2111,36 @@ export class Frond {
2082
2111
  tokens[i + 1] = ["TEXT", tokens[i + 1][1].replace(LEADING_WS_RE, "")];
2083
2112
  }
2084
2113
 
2085
- if (tag === "if") {
2086
- // Sandbox check
2087
- if (this._sandbox && this._allowedTags !== null && !this._allowedTags.has("if")) {
2088
- const skip = this.skipBlock(tokens, i, "if", "endif");
2089
- i = skip;
2090
- } else {
2091
- const [result, skip] = this.handleIf(tokens, i, context);
2092
- output.push(result);
2093
- i = skip;
2094
- }
2114
+ if (!this.tagPermitted(tag)) {
2115
+ // ONE sandbox gate for the whole tag vocabulary. Previously only if, for,
2116
+ // set and include were checked, so every other tag ignored the allow-list
2117
+ // -- {% autoescape false %} could switch escaping off from inside a
2118
+ // sandbox whose tags were restricted to something else entirely.
2119
+ i = this.skipDeniedTag(tokens, i, tag, content);
2120
+ } else if (tag === "if") {
2121
+ const [result, skip] = this.handleIf(tokens, i, context);
2122
+ output.push(result);
2123
+ i = skip;
2095
2124
  } else if (tag === "for") {
2096
- if (this._sandbox && this._allowedTags !== null && !this._allowedTags.has("for")) {
2097
- const skip = this.skipBlock(tokens, i, "for", "endfor");
2098
- i = skip;
2099
- } else {
2100
- const [result, skip] = this.handleFor(tokens, i, context);
2101
- output.push(result);
2102
- i = skip;
2103
- }
2125
+ const [result, skip] = this.handleFor(tokens, i, context);
2126
+ output.push(result);
2127
+ i = skip;
2104
2128
  } else if (tag === "set") {
2105
2129
  // An assignment has an "="; without one this is the BLOCK form,
2106
2130
  // {% set name %}...{% endset %}, which captures its rendered body. A
2107
2131
  // bare includes() is exact here, not a shortcut: the block form's tag
2108
2132
  // content is only ever "set <name>", so an "=" anywhere -- even inside
2109
2133
  // a quoted value like {% set m = "a = b" %} -- means assignment.
2110
- const isBlockSet = !content.includes("=");
2111
- if (this._sandbox && this._allowedTags !== null && !this._allowedTags.has("set")) {
2112
- i = isBlockSet ? this.skipBlock(tokens, i, "set", "endset") : i + 1;
2113
- } else if (isBlockSet) {
2134
+ if (!content.includes("=")) {
2114
2135
  i = this.handleSetBlock(tokens, i, context);
2115
2136
  } else {
2116
2137
  this.handleSet(content, context);
2117
2138
  i++;
2118
2139
  }
2119
2140
  } else if (tag === "include") {
2120
- if (this._sandbox && this._allowedTags !== null && !this._allowedTags.has("include")) {
2121
- i++;
2122
- } else {
2123
- const result = this.handleInclude(content, context);
2124
- output.push(result);
2125
- i++;
2126
- }
2141
+ const result = this.handleInclude(content, context);
2142
+ output.push(result);
2143
+ i++;
2127
2144
  } else if (tag === "macro") {
2128
2145
  const skip = this.handleMacro(tokens, i, context);
2129
2146
  i = skip;
@@ -2176,6 +2193,46 @@ export class Frond {
2176
2193
  return output.join("");
2177
2194
  }
2178
2195
 
2196
+ /**
2197
+ * May this filter RUN under the current sandbox?
2198
+ *
2199
+ * The escaping decision has to ask this rather than read the filter name out of
2200
+ * the source. Node carries safety as a FLAG rather than as a value-level marker
2201
+ * (Python and Ruby return a SafeString, PHP prepends a RAW_MARKER -- all three
2202
+ * produced only by actually running the filter), so here the name alone was
2203
+ * enough to suppress auto-escaping even when the filter was denied and skipped.
2204
+ */
2205
+ private filterPermitted(name: string): boolean {
2206
+ if (!this._sandbox || this._allowedFilters === null) return true;
2207
+ return this._allowedFilters.has(name);
2208
+ }
2209
+
2210
+ /**
2211
+ * May this tag run under the current sandbox?
2212
+ *
2213
+ * One gate for every tag, so the allow-list governs the whole tag vocabulary
2214
+ * instead of the four names that happened to be checked individually.
2215
+ */
2216
+ private tagPermitted(tag: string): boolean {
2217
+ if (!this._sandbox || this._allowedTags === null) return true;
2218
+ if (!GATEABLE_TAGS.has(tag)) return true; // structural, not an author capability
2219
+ return this._allowedTags.has(tag);
2220
+ }
2221
+
2222
+ /**
2223
+ * Consume a denied tag WITHOUT running it, returning the index past its body.
2224
+ *
2225
+ * Advancing a single token past a body-owning tag would leave the body's tokens
2226
+ * to render at the TOP level, leaking exactly the content the sandbox denied.
2227
+ */
2228
+ private skipDeniedTag(tokens: Token[], start: number, tag: string, content: string): number {
2229
+ const closeTag = BLOCK_TAG_ENDS[tag];
2230
+ // {% set x = 1 %} is an assignment and owns no body; {% set x %}...{% endset %}
2231
+ // captures one. Same exact-"=" test the dispatch uses.
2232
+ if (closeTag === undefined || (tag === "set" && content.includes("="))) return start + 1;
2233
+ return this.skipBlock(tokens, start, tag, closeTag);
2234
+ }
2235
+
2179
2236
  private skipBlock(tokens: Token[], start: number, openTag: string, closeTag: string): number {
2180
2237
  let depth = 0;
2181
2238
  let i = start + 1;
@@ -2183,7 +2240,9 @@ export class Frond {
2183
2240
  if (tokens[i][0] === "BLOCK") {
2184
2241
  const [content] = stripTag(tokens[i][1]);
2185
2242
  const tag = content.split(/\s+/)[0] || "";
2186
- if (tag === openTag) depth++;
2243
+ // A nested assignment-form set opens no body, so it must not nest -- counting
2244
+ // it would consume past the real {% endset %} and swallow trailing content.
2245
+ if (tag === openTag && !(openTag === "set" && content.includes("="))) depth++;
2187
2246
  else if (tag === closeTag) {
2188
2247
  if (depth === 0) return i + 1;
2189
2248
  depth--;
@@ -2388,12 +2447,19 @@ export class Frond {
2388
2447
  for (const [fname, rawArgs] of filters) {
2389
2448
  const args = rawArgs.map((a) => (a instanceof VarRef ? evalExpr(a.name, context) : a));
2390
2449
  if (fname === "raw" || fname === "safe") {
2391
- isSafe = true;
2450
+ // Decide from what was permitted to RUN, not from what the source asked
2451
+ // for. Marking the value safe here regardless meant a DENIED raw produced
2452
+ // byte-identical output to an allowed one -- the allow-list entry that
2453
+ // governs XSS escaping did nothing at all.
2454
+ if (this.filterPermitted(fname)) isSafe = true;
2392
2455
  continue;
2393
2456
  }
2394
- // escape/e filter marks output as safe (already escaped)
2457
+ // escape/e filter marks output as safe (already escaped) -- but ONLY when it
2458
+ // is permitted to run. Node's escape returns a plain string, so this flag is
2459
+ // what suppresses auto-escaping; setting it for a DENIED escape emitted the
2460
+ // value unescaped, having never escaped it.
2395
2461
  if (fname === "escape" || fname === "e") {
2396
- isSafe = true;
2462
+ if (this.filterPermitted(fname)) isSafe = true;
2397
2463
  }
2398
2464
 
2399
2465
  // Sandbox: check filter access