tina4-nodejs 3.13.137 → 3.13.139

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 (255) hide show
  1. package/CLAUDE.md +33 -3
  2. package/COMMERCIAL-LICENSE.md +8 -0
  3. package/LICENSE +373 -0
  4. package/NOTICE +11 -0
  5. package/README.md +24 -2
  6. package/package.json +44 -6
  7. package/packages/cli/dist/bin.js +19979 -18102
  8. package/packages/cli/src/bin.ts +8 -0
  9. package/packages/cli/src/commands/build.ts +8 -0
  10. package/packages/cli/src/commands/generate.ts +8 -0
  11. package/packages/cli/src/commands/init.ts +8 -0
  12. package/packages/cli/src/commands/lint.ts +8 -0
  13. package/packages/cli/src/commands/migrate.ts +8 -0
  14. package/packages/cli/src/commands/migrateCreate.ts +8 -0
  15. package/packages/cli/src/commands/migrateRollback.ts +8 -0
  16. package/packages/cli/src/commands/migrateStatus.ts +8 -0
  17. package/packages/cli/src/commands/queue.ts +8 -0
  18. package/packages/cli/src/commands/routes.ts +8 -0
  19. package/packages/cli/src/commands/seed.ts +8 -0
  20. package/packages/cli/src/commands/serve.ts +11 -0
  21. package/packages/cli/src/commands/test.ts +8 -0
  22. package/packages/core/dist/index.js +19976 -18097
  23. package/packages/core/gallery/auth/src/routes/api/gallery/auth/login/post.ts +8 -0
  24. package/packages/core/gallery/auth/src/routes/api/gallery/auth/verify/get.ts +8 -0
  25. package/packages/core/gallery/auth/src/routes/gallery/auth/get.ts +8 -0
  26. package/packages/core/gallery/database/src/routes/api/gallery/db/notes/get.ts +8 -0
  27. package/packages/core/gallery/database/src/routes/api/gallery/db/notes/post.ts +8 -0
  28. package/packages/core/gallery/database/src/routes/api/gallery/db/tables/get.ts +8 -0
  29. package/packages/core/gallery/error-overlay/src/routes/api/gallery/crash/get.ts +8 -0
  30. package/packages/core/gallery/orm/src/routes/api/gallery/products/get.ts +8 -0
  31. package/packages/core/gallery/orm/src/routes/api/gallery/products/post.ts +8 -0
  32. package/packages/core/gallery/queue/src/lib/queueDb.ts +8 -0
  33. package/packages/core/gallery/queue/src/routes/api/gallery/queue/consume/post.ts +8 -0
  34. package/packages/core/gallery/queue/src/routes/api/gallery/queue/fail/post.ts +8 -0
  35. package/packages/core/gallery/queue/src/routes/api/gallery/queue/produce/post.ts +8 -0
  36. package/packages/core/gallery/queue/src/routes/api/gallery/queue/retry/post.ts +8 -0
  37. package/packages/core/gallery/queue/src/routes/api/gallery/queue/status/get.ts +8 -0
  38. package/packages/core/gallery/queue/src/routes/gallery/queue/get.ts +8 -0
  39. package/packages/core/gallery/rest-api/src/routes/api/gallery/hello/get.ts +8 -0
  40. package/packages/core/gallery/rest-api/src/routes/api/gallery/hello/post.ts +8 -0
  41. package/packages/core/gallery/templates/src/routes/gallery/page/get.ts +8 -0
  42. package/packages/core/gallery/templates/src/templates/gallery_page.twig +8 -0
  43. package/packages/core/public/THIRD-PARTY-NOTICES.md +55 -0
  44. package/packages/core/public/bundled-component-provenance.json +48 -0
  45. package/packages/core/public/js/tina4js.min.js +13 -3
  46. package/packages/core/public/licenses/swagger-ui-3.10.0-original-notice.txt +11 -0
  47. package/packages/core/public/licenses/swagger-ui-5.17.14-NOTICE.txt +2 -0
  48. package/packages/core/public/licenses/swagger-ui-Apache-2.0.txt +202 -0
  49. package/packages/core/public/licenses/tina4-css-MIT.txt +21 -0
  50. package/packages/core/public/licenses/tina4-dev-admin-components.json +240 -0
  51. package/packages/core/public/licenses/tina4-dev-admin-third-party.txt +919 -0
  52. package/packages/core/public/licenses/tina4-js-MIT.txt +20 -0
  53. package/packages/core/src/__feedback/widget.js +8 -0
  54. package/packages/core/src/_missing.ts +8 -0
  55. package/packages/core/src/ai.ts +8 -0
  56. package/packages/core/src/aiClient.ts +8 -0
  57. package/packages/core/src/api.ts +48 -7
  58. package/packages/core/src/auth.ts +94 -14
  59. package/packages/core/src/authGate.ts +35 -20
  60. package/packages/core/src/background.ts +8 -0
  61. package/packages/core/src/cache.ts +43 -4
  62. package/packages/core/src/constants.ts +8 -0
  63. package/packages/core/src/container.ts +8 -0
  64. package/packages/core/src/context/chunker.ts +8 -0
  65. package/packages/core/src/context/index.ts +8 -0
  66. package/packages/core/src/devAdmin.ts +185 -59
  67. package/packages/core/src/devMailbox.ts +8 -0
  68. package/packages/core/src/dispatchPipeline.ts +8 -0
  69. package/packages/core/src/docs.ts +8 -0
  70. package/packages/core/src/docsAutoDiscovery.ts +8 -0
  71. package/packages/core/src/docsParser.ts +8 -0
  72. package/packages/core/src/docsScanner.ts +44 -0
  73. package/packages/core/src/docsSignatures.ts +8 -0
  74. package/packages/core/src/dotenv.ts +8 -0
  75. package/packages/core/src/env.ts +8 -0
  76. package/packages/core/src/errorOverlay.ts +8 -0
  77. package/packages/core/src/events.ts +8 -0
  78. package/packages/core/src/fakeData.ts +8 -0
  79. package/packages/core/src/feedback.ts +8 -0
  80. package/packages/core/src/graphql.ts +108 -5
  81. package/packages/core/src/health.ts +8 -0
  82. package/packages/core/src/htmlElement.ts +8 -0
  83. package/packages/core/src/i18n.ts +8 -0
  84. package/packages/core/src/index.ts +9 -0
  85. package/packages/core/src/job.ts +16 -1
  86. package/packages/core/src/logger.ts +8 -0
  87. package/packages/core/src/mcp.test.ts +8 -0
  88. package/packages/core/src/mcp.ts +11 -1
  89. package/packages/core/src/messenger.ts +143 -86
  90. package/packages/core/src/metrics.ts +13 -0
  91. package/packages/core/src/middleware.ts +1181 -1097
  92. package/packages/core/src/mqtt.ts +8 -0
  93. package/packages/core/src/mqttMessage.ts +8 -0
  94. package/packages/core/src/optionalPackage.ts +48 -0
  95. package/packages/core/src/plan.ts +8 -0
  96. package/packages/core/src/portTakeover.ts +8 -0
  97. package/packages/core/src/projectIndex.ts +8 -0
  98. package/packages/core/src/projectIndexExtractors.ts +8 -0
  99. package/packages/core/src/projectIndexStorage.ts +8 -0
  100. package/packages/core/src/push.ts +24 -0
  101. package/packages/core/src/queue.ts +23 -2
  102. package/packages/core/src/queueBackends/kafkaBackend.ts +8 -0
  103. package/packages/core/src/queueBackends/liteBackend.ts +23 -0
  104. package/packages/core/src/queueBackends/mongoBackend.ts +61 -14
  105. package/packages/core/src/queueBackends/rabbitmqBackend.ts +8 -0
  106. package/packages/core/src/rateLimiter.ts +8 -0
  107. package/packages/core/src/request.ts +59 -10
  108. package/packages/core/src/response.ts +79 -5
  109. package/packages/core/src/routeDiscovery.ts +8 -0
  110. package/packages/core/src/router.ts +8 -0
  111. package/packages/core/src/server.ts +79 -25
  112. package/packages/core/src/service.ts +8 -0
  113. package/packages/core/src/session.ts +8 -0
  114. package/packages/core/src/sessionHandlers/childError.ts +8 -0
  115. package/packages/core/src/sessionHandlers/databaseHandler.ts +68 -3
  116. package/packages/core/src/sessionHandlers/memcachedHandler.ts +8 -0
  117. package/packages/core/src/sessionHandlers/mongoClient.ts +8 -0
  118. package/packages/core/src/sessionHandlers/mongoHandler.ts +8 -0
  119. package/packages/core/src/sessionHandlers/respClient.ts +8 -0
  120. package/packages/core/src/sessionHandlers/sqlClient.ts +8 -0
  121. package/packages/core/src/sessionHandlers/syncBridge.ts +8 -0
  122. package/packages/core/src/sessionHandlers/syncSocket.ts +8 -0
  123. package/packages/core/src/sessionHandlers/valkeyHandler.ts +8 -0
  124. package/packages/core/src/sso.ts +20 -4
  125. package/packages/core/src/ssoIdentity.ts +19 -0
  126. package/packages/core/src/ssrf.ts +221 -0
  127. package/packages/core/src/static.ts +8 -0
  128. package/packages/core/src/test.ts +8 -0
  129. package/packages/core/src/testClient.ts +11 -0
  130. package/packages/core/src/testing.ts +8 -0
  131. package/packages/core/src/transport.ts +369 -0
  132. package/packages/core/src/trustedProxy.ts +8 -0
  133. package/packages/core/src/types.ts +13 -0
  134. package/packages/core/src/validator.ts +8 -0
  135. package/packages/core/src/version.ts +8 -0
  136. package/packages/core/src/websocket.ts +12 -2
  137. package/packages/core/src/websocketBackplane.ts +49 -32
  138. package/packages/core/src/websocketConnection.ts +8 -0
  139. package/packages/core/src/wsdl.ts +197 -115
  140. package/packages/core/templates/errors/302.twig +8 -0
  141. package/packages/core/templates/errors/401.twig +8 -0
  142. package/packages/core/templates/errors/403.twig +8 -0
  143. package/packages/core/templates/errors/404.twig +8 -0
  144. package/packages/core/templates/errors/500.twig +8 -0
  145. package/packages/core/templates/errors/502.twig +8 -0
  146. package/packages/core/templates/errors/503.twig +8 -0
  147. package/packages/core/templates/errors/base.twig +8 -0
  148. package/packages/frond/dist/index.js +157 -17
  149. package/packages/frond/src/engine.ts +186 -17
  150. package/packages/frond/src/index.ts +8 -0
  151. package/packages/orm/dist/index.js +8641 -6765
  152. package/packages/orm/src/adapters/firebird.ts +50 -3
  153. package/packages/orm/src/adapters/mongodb.ts +8 -0
  154. package/packages/orm/src/adapters/mssql.ts +29 -7
  155. package/packages/orm/src/adapters/mysql.ts +17 -1
  156. package/packages/orm/src/adapters/odbc.ts +11 -0
  157. package/packages/orm/src/adapters/pg-types.d.ts +8 -0
  158. package/packages/orm/src/adapters/postgres.ts +34 -8
  159. package/packages/orm/src/adapters/sqlDialect.ts +57 -0
  160. package/packages/orm/src/adapters/sqlite.ts +26 -0
  161. package/packages/orm/src/autoCrud.ts +78 -36
  162. package/packages/orm/src/baseModel.ts +61 -29
  163. package/packages/orm/src/cachedDatabase.ts +67 -1
  164. package/packages/orm/src/connectTimeout.ts +8 -0
  165. package/packages/orm/src/database.ts +649 -401
  166. package/packages/orm/src/databaseResult.ts +8 -0
  167. package/packages/orm/src/databaseUrl.ts +8 -0
  168. package/packages/orm/src/docstore.ts +23 -1
  169. package/packages/orm/src/fakeData.ts +8 -0
  170. package/packages/orm/src/graph/adapters/arango.ts +8 -0
  171. package/packages/orm/src/graph/adapters/bolt.ts +8 -0
  172. package/packages/orm/src/graph/adapters/ultipa.ts +8 -0
  173. package/packages/orm/src/graph/connectTimeout.ts +8 -0
  174. package/packages/orm/src/graph/errors.ts +8 -0
  175. package/packages/orm/src/graph/graphAdapter.ts +8 -0
  176. package/packages/orm/src/graph/graphDatabase.ts +8 -0
  177. package/packages/orm/src/graph/graphUrl.ts +10 -1
  178. package/packages/orm/src/graph/shapes.ts +8 -0
  179. package/packages/orm/src/index.ts +147 -139
  180. package/packages/orm/src/migration.ts +8 -0
  181. package/packages/orm/src/model.ts +8 -0
  182. package/packages/orm/src/modelCollection.ts +8 -0
  183. package/packages/orm/src/point.ts +8 -0
  184. package/packages/orm/src/query.ts +149 -33
  185. package/packages/orm/src/queryBuilder.ts +8 -0
  186. package/packages/orm/src/realtime/index.ts +8 -0
  187. package/packages/orm/src/realtime/models/attachment.ts +8 -0
  188. package/packages/orm/src/realtime/models/channel.ts +8 -0
  189. package/packages/orm/src/realtime/models/channelMember.ts +8 -0
  190. package/packages/orm/src/realtime/models/message.ts +8 -0
  191. package/packages/orm/src/realtime/models/workspace.ts +8 -0
  192. package/packages/orm/src/realtime/realtime.ts +8 -0
  193. package/packages/orm/src/realtime/storage.ts +43 -12
  194. package/packages/orm/src/seeder.ts +8 -0
  195. package/packages/orm/src/seederTable.ts +8 -0
  196. package/packages/orm/src/seederTypes.ts +8 -0
  197. package/packages/orm/src/sqlTranslator.ts +185 -8
  198. package/packages/orm/src/types.ts +15 -0
  199. package/packages/orm/src/validation.ts +8 -0
  200. package/packages/swagger/src/generator.ts +8 -0
  201. package/packages/swagger/src/index.ts +8 -0
  202. package/packages/swagger/src/ui.ts +8 -0
  203. package/types/core/src/api.d.ts +6 -0
  204. package/types/core/src/auth.d.ts +30 -0
  205. package/types/core/src/background.d.ts +0 -16
  206. package/types/core/src/cache.d.ts +6 -0
  207. package/types/core/src/devAdmin.d.ts +18 -12
  208. package/types/core/src/docs.d.ts +0 -17
  209. package/types/core/src/errorOverlay.d.ts +0 -24
  210. package/types/core/src/events.d.ts +0 -12
  211. package/types/core/src/feedback.d.ts +0 -23
  212. package/types/core/src/graphql.d.ts +25 -21
  213. package/types/core/src/htmlElement.d.ts +0 -15
  214. package/types/core/src/index.d.ts +1 -0
  215. package/types/core/src/job.d.ts +1 -0
  216. package/types/core/src/messenger.d.ts +12 -1
  217. package/types/core/src/metrics.d.ts +2 -0
  218. package/types/core/src/middleware.d.ts +45 -1
  219. package/types/core/src/mqtt.d.ts +0 -33
  220. package/types/core/src/optionalPackage.d.ts +25 -0
  221. package/types/core/src/plan.d.ts +0 -10
  222. package/types/core/src/projectIndex.d.ts +0 -10
  223. package/types/core/src/push.d.ts +5 -0
  224. package/types/core/src/queue.d.ts +7 -1
  225. package/types/core/src/queueBackends/liteBackend.d.ts +9 -0
  226. package/types/core/src/queueBackends/mongoBackend.d.ts +10 -1
  227. package/types/core/src/request.d.ts +12 -0
  228. package/types/core/src/response.d.ts +13 -0
  229. package/types/core/src/server.d.ts +19 -0
  230. package/types/core/src/sessionHandlers/childError.d.ts +0 -15
  231. package/types/core/src/sessionHandlers/databaseHandler.d.ts +25 -1
  232. package/types/core/src/sso.d.ts +1 -0
  233. package/types/core/src/ssoIdentity.d.ts +10 -0
  234. package/types/core/src/ssrf.d.ts +19 -0
  235. package/types/core/src/transport.d.ts +28 -0
  236. package/types/core/src/trustedProxy.d.ts +0 -16
  237. package/types/core/src/types.d.ts +5 -0
  238. package/types/core/src/websocketBackplane.d.ts +1 -0
  239. package/types/core/src/wsdl.d.ts +8 -22
  240. package/types/frond/src/engine.d.ts +7 -1
  241. package/types/orm/src/adapters/firebird.d.ts +12 -0
  242. package/types/orm/src/adapters/mssql.d.ts +11 -0
  243. package/types/orm/src/adapters/mysql.d.ts +2 -0
  244. package/types/orm/src/adapters/postgres.d.ts +11 -0
  245. package/types/orm/src/adapters/sqlDialect.d.ts +25 -0
  246. package/types/orm/src/adapters/sqlite.d.ts +7 -0
  247. package/types/orm/src/baseModel.d.ts +5 -0
  248. package/types/orm/src/cachedDatabase.d.ts +27 -0
  249. package/types/orm/src/database.d.ts +46 -7
  250. package/types/orm/src/graph/graphUrl.d.ts +0 -8
  251. package/types/orm/src/index.d.ts +1 -1
  252. package/types/orm/src/query.d.ts +48 -2
  253. package/types/orm/src/realtime/storage.d.ts +2 -1
  254. package/types/orm/src/sqlTranslator.d.ts +70 -18
  255. package/types/orm/src/types.d.ts +6 -0
@@ -6,6 +6,7 @@ var SafeString = class {
6
6
  constructor(value) {
7
7
  this.value = value;
8
8
  }
9
+ value;
9
10
  toString() {
10
11
  return this.value;
11
12
  }
@@ -365,6 +366,9 @@ function resolveMethodPart(value, part, context) {
365
366
  if (!methodMatch || typeof value !== "object" || value === null) return { matched: false, value };
366
367
  const methodName = methodMatch[1];
367
368
  const rawArgs = methodMatch[2] || "";
369
+ if (unsafeKey(methodName) || !Object.prototype.hasOwnProperty.call(value, methodName)) {
370
+ return { matched: false, value };
371
+ }
368
372
  const fn = value[methodName];
369
373
  if (typeof fn !== "function") return { matched: false, value };
370
374
  const args = rawArgs.trim() ? splitArgs(rawArgs).map((a) => evalExpr(a.trim(), context)) : [];
@@ -384,7 +388,7 @@ function resolvePathKey(part, isBracket, context) {
384
388
  function resolvePathPart(value, key) {
385
389
  if (typeof value !== "object" || value === null) return { found: false, value: null };
386
390
  if (Array.isArray(value) && typeof key === "number") return { found: true, value: value[key] };
387
- if (!(key in value)) return { found: false, value: null };
391
+ if (unsafeKey(key) || !Object.prototype.hasOwnProperty.call(value, key)) return { found: false, value: null };
388
392
  const member = value[key];
389
393
  return { found: true, value: typeof member === "function" ? member.call(value) : member };
390
394
  }
@@ -504,7 +508,7 @@ function splitOutsideQuotes(expr, sep2) {
504
508
  parts.push(expr.slice(currentStart));
505
509
  return parts;
506
510
  }
507
- var EXPR_NOT_MATCHED = Symbol("frond-expression-not-matched");
511
+ var EXPR_NOT_MATCHED = /* @__PURE__ */ Symbol("frond-expression-not-matched");
508
512
  function parenthesizedInner(expr) {
509
513
  if (expr.length < 2 || expr[0] !== "(" || !expr.endsWith(")")) return null;
510
514
  let depth = 0;
@@ -634,7 +638,7 @@ function evalDottedFunction(name, rawArgs, context) {
634
638
  const lastDot = name.lastIndexOf(".");
635
639
  const owner = resolveVar(name.slice(0, lastDot), context);
636
640
  const member = name.slice(lastDot + 1);
637
- if (!owner || typeof owner !== "object" || !(member in owner)) {
641
+ if (!owner || typeof owner !== "object" || unsafeKey(member) || !Object.prototype.hasOwnProperty.call(owner, member)) {
638
642
  return EXPR_NOT_MATCHED;
639
643
  }
640
644
  const method = owner[member];
@@ -960,6 +964,7 @@ var VarRef = class {
960
964
  constructor(name) {
961
965
  this.name = name;
962
966
  }
967
+ name;
963
968
  };
964
969
  function coerceArg(t) {
965
970
  if (/^-?\d+$/.test(t)) return parseInt(t, 10);
@@ -1063,6 +1068,113 @@ function splitArgs(raw) {
1063
1068
  function htmlEscape(str) {
1064
1069
  return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#x27;");
1065
1070
  }
1071
+ var JS_SAFE_RE = /[A-Za-z0-9,._]/;
1072
+ function jsEscape(value) {
1073
+ let out = "";
1074
+ for (const ch of String(value)) {
1075
+ if (JS_SAFE_RE.test(ch) && ch.length === 1) {
1076
+ out += ch;
1077
+ continue;
1078
+ }
1079
+ let code = ch.codePointAt(0);
1080
+ if (code < 128) {
1081
+ out += "\\x" + code.toString(16).toUpperCase().padStart(2, "0");
1082
+ } else if (code <= 65535) {
1083
+ out += "\\u" + code.toString(16).toUpperCase().padStart(4, "0");
1084
+ } else {
1085
+ code -= 65536;
1086
+ out += "\\u" + (55296 + (code >> 10)).toString(16).toUpperCase().padStart(4, "0");
1087
+ out += "\\u" + (56320 + (code & 1023)).toString(16).toUpperCase().padStart(4, "0");
1088
+ }
1089
+ }
1090
+ return out;
1091
+ }
1092
+ function cssEscape(value) {
1093
+ let out = "";
1094
+ for (const ch of String(value)) {
1095
+ out += /[A-Za-z0-9]/.test(ch) ? ch : "\\" + ch.codePointAt(0).toString(16).toUpperCase().padStart(6, "0") + " ";
1096
+ }
1097
+ return out;
1098
+ }
1099
+ function htmlAttrEscape(value) {
1100
+ let out = "";
1101
+ for (const ch of String(value)) {
1102
+ out += /[A-Za-z0-9,.\-_]/.test(ch) ? ch : "&#x" + ch.codePointAt(0).toString(16).toUpperCase().padStart(2, "0") + ";";
1103
+ }
1104
+ return out;
1105
+ }
1106
+ function urlEscape(value) {
1107
+ return encodeURIComponent(String(value)).replace(
1108
+ /[!'()*]/g,
1109
+ (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase()
1110
+ );
1111
+ }
1112
+ function escapeStrategy(value, strategy = "html") {
1113
+ const s = String(strategy).trim().replace(/^['"]|['"]$/g, "");
1114
+ switch (s) {
1115
+ case "":
1116
+ case "html":
1117
+ return new SafeString(htmlEscape(String(value)));
1118
+ case "js":
1119
+ return new SafeString(jsEscape(value));
1120
+ case "url":
1121
+ return new SafeString(urlEscape(value));
1122
+ case "css":
1123
+ return new SafeString(cssEscape(value));
1124
+ case "html_attr":
1125
+ case "attr":
1126
+ return new SafeString(htmlAttrEscape(value));
1127
+ default:
1128
+ throw new Error(`Unknown escape strategy: ${strategy}`);
1129
+ }
1130
+ }
1131
+ var MEDIA_TYPE_RE = /^[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*\/[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*$/;
1132
+ function sanitiseMediaType(type) {
1133
+ const t = String(type).trim();
1134
+ return MEDIA_TYPE_RE.test(t) ? t : "application/octet-stream";
1135
+ }
1136
+ var SANDBOX_EXPR_KEYWORDS = /* @__PURE__ */ new Set([
1137
+ "true",
1138
+ "false",
1139
+ "none",
1140
+ "null",
1141
+ "nil",
1142
+ "and",
1143
+ "or",
1144
+ "not",
1145
+ "in",
1146
+ "is",
1147
+ "loop",
1148
+ "if",
1149
+ "else",
1150
+ "empty",
1151
+ "starts",
1152
+ "ends",
1153
+ "with",
1154
+ "matches"
1155
+ ]);
1156
+ var SANDBOX_IDENT_RE = /(?<![.\w'"])([A-Za-z_][A-Za-z0-9_]*)/g;
1157
+ var SANDBOX_STRING_RE = /'[^']*'|"[^"]*"/g;
1158
+ var UNSAFE_KEYS = /* @__PURE__ */ new Set([
1159
+ "constructor",
1160
+ "__proto__",
1161
+ "prototype",
1162
+ "__defineGetter__",
1163
+ "__defineSetter__",
1164
+ "__lookupGetter__",
1165
+ "__lookupSetter__",
1166
+ "__defineProperty__",
1167
+ "valueOf"
1168
+ ]);
1169
+ function unsafeKey(key) {
1170
+ return UNSAFE_KEYS.has(String(key));
1171
+ }
1172
+ function isEscapableStructured(value) {
1173
+ return value !== null && typeof value === "object" && !(value instanceof SafeString);
1174
+ }
1175
+ function toOutputString(value) {
1176
+ return String(value);
1177
+ }
1066
1178
  function dateFilter(value, fmt) {
1067
1179
  let dt;
1068
1180
  if (value instanceof Date) {
@@ -1183,8 +1295,8 @@ var BUILTIN_FILTERS = {
1183
1295
  default: (v, fallback) => v !== null && v !== void 0 && v !== "" ? v : fallback !== void 0 ? fallback : "",
1184
1296
  raw: (v) => v,
1185
1297
  safe: (v) => v,
1186
- escape: (v) => htmlEscape(String(v)),
1187
- e: (v) => htmlEscape(String(v)),
1298
+ escape: (v, ...a) => escapeStrategy(v, a[0] ?? "html"),
1299
+ e: (v, ...a) => escapeStrategy(v, a[0] ?? "html"),
1188
1300
  striptags: (v) => String(v).replace(STRIP_TAGS_RE, ""),
1189
1301
  nl2br: (v) => new SafeString(htmlEscape(String(v)).replace(/\n/g, "<br />\n")),
1190
1302
  abs: (v) => typeof v === "number" ? Math.abs(v) : v,
@@ -1273,9 +1385,9 @@ var BUILTIN_FILTERS = {
1273
1385
  base64decode: (v) => Buffer.from(String(v), "base64").toString("utf-8"),
1274
1386
  data_uri: (v) => {
1275
1387
  if (v && typeof v === "object" && "content" in v) {
1276
- const ct = v.type ?? "application/octet-stream";
1388
+ const ct = sanitiseMediaType(String(v.type ?? "application/octet-stream"));
1277
1389
  const raw = Buffer.isBuffer(v.content) ? v.content : Buffer.from(String(v.content));
1278
- return `data:${ct};base64,${raw.toString("base64")}`;
1390
+ return new SafeString(`data:${ct};base64,${raw.toString("base64")}`);
1279
1391
  }
1280
1392
  return String(v);
1281
1393
  },
@@ -1295,7 +1407,7 @@ var BUILTIN_FILTERS = {
1295
1407
  // broke byte-parity for the one filter whose whole job is a wire format.
1296
1408
  tojson: (v) => jsonSafe(v),
1297
1409
  to_json: (v) => jsonSafe(v),
1298
- js_escape: (v) => new SafeString(String(v).replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t"))
1410
+ js_escape: (v) => new SafeString(jsEscape(v))
1299
1411
  };
1300
1412
  function _b64url(data) {
1301
1413
  return data.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
@@ -1303,6 +1415,12 @@ function _b64url(data) {
1303
1415
  var _formTokenSessionId = "";
1304
1416
  function _buildFormTokenJwt(descriptor = "") {
1305
1417
  const secret = process.env.TINA4_SECRET ?? "";
1418
+ const secretBytes = Buffer.byteLength(secret, "utf8");
1419
+ if (secretBytes < 32) {
1420
+ throw new Error(
1421
+ `Auth: TINA4_SECRET ${secretBytes === 0 ? "is not set" : `is ${secretBytes} bytes`}; an HMAC JWT secret must be at least 32 bytes. Generate one with \`openssl rand -hex 32\` and set TINA4_SECRET in your environment or .env.`
1422
+ );
1423
+ }
1306
1424
  const ttlMinutes = parseInt(process.env.TINA4_TOKEN_LIMIT || "60", 10);
1307
1425
  const header = { alg: "HS256", typ: "JWT" };
1308
1426
  const now = Math.floor(Date.now() / 1e3);
@@ -2002,6 +2120,7 @@ var Frond = class _Frond {
2002
2120
  return this.evalVarInner(expr, context);
2003
2121
  }
2004
2122
  evalVarRaw(expr, context) {
2123
+ if (this._sandbox && !this.sandboxExprOk(expr)) return null;
2005
2124
  const [varName, filters] = parseFilterChain(expr);
2006
2125
  let value = evalExpr(varName, context);
2007
2126
  for (const [fname, rawArgs] of filters) {
@@ -2044,16 +2163,33 @@ var Frond = class _Frond {
2044
2163
  const fn = this.filters[fname];
2045
2164
  return fn ? fn(value, ...args) : value;
2046
2165
  }
2047
- variablePermitted(varName) {
2048
- if (!this._sandbox || this._allowedVars === null) return true;
2049
- const rootVar = varName.split(".")[0].split("[")[0].trim();
2050
- return !rootVar || rootVar === "loop" || this._allowedVars.has(rootVar);
2166
+ /**
2167
+ * Sandbox gate for a WHOLE expression (F6/ADR-0077): refuse a dunder walk or
2168
+ * a root variable outside the allow-list. Applied to output, if conditions,
2169
+ * set right-hand sides and for iterables so none can smuggle a blocked
2170
+ * variable past the per-output gate.
2171
+ */
2172
+ sandboxExprOk(expr) {
2173
+ if (!this._sandbox) return true;
2174
+ if (expr.includes("__")) return false;
2175
+ if (this._allowedVars === null) return true;
2176
+ const stripped = expr.replace(SANDBOX_STRING_RE, "");
2177
+ let m;
2178
+ SANDBOX_IDENT_RE.lastIndex = 0;
2179
+ while ((m = SANDBOX_IDENT_RE.exec(stripped)) !== null) {
2180
+ const ident = m[1];
2181
+ if (SANDBOX_EXPR_KEYWORDS.has(ident.toLowerCase())) continue;
2182
+ if (ident in this.filters) continue;
2183
+ if (!this._allowedVars.has(ident)) return false;
2184
+ }
2185
+ return true;
2051
2186
  }
2052
2187
  resolveConcatenation(expr, context) {
2053
2188
  if (findOutsideQuotes(expr, "~") < 0) return { handled: false, value: void 0 };
2054
2189
  let value = evalExpr(expr, context);
2055
2190
  if (value instanceof SafeString) return { handled: true, value: value.value };
2056
2191
  if (this._autoEscape && typeof value === "string") value = htmlEscape(value);
2192
+ else if (this._autoEscape && isEscapableStructured(value)) value = htmlEscape(toOutputString(value));
2057
2193
  return { handled: true, value };
2058
2194
  }
2059
2195
  applyRenderedFilters(value, filters, context) {
@@ -2064,7 +2200,6 @@ var Frond = class _Frond {
2064
2200
  if (this.filterPermitted(fname)) safe = true;
2065
2201
  continue;
2066
2202
  }
2067
- if ((fname === "escape" || fname === "e") && this.filterPermitted(fname)) safe = true;
2068
2203
  if (!this.filterPermitted(fname)) continue;
2069
2204
  value = this.applyRenderedFilter(value, fname, args);
2070
2205
  }
@@ -2072,7 +2207,7 @@ var Frond = class _Frond {
2072
2207
  }
2073
2208
  evalVarInner(expr, context) {
2074
2209
  const [varName, filters] = parseFilterChain(expr);
2075
- if (!this.variablePermitted(varName)) return "";
2210
+ if (this._sandbox && !this.sandboxExprOk(expr)) return "";
2076
2211
  const concatenated = this.resolveConcatenation(expr, context);
2077
2212
  if (concatenated.handled) return concatenated.value;
2078
2213
  const applied = this.applyRenderedFilters(evalExpr(varName, context), filters, context);
@@ -2080,8 +2215,12 @@ var Frond = class _Frond {
2080
2215
  if (value instanceof SafeString) {
2081
2216
  return value.value;
2082
2217
  }
2083
- if (!applied.safe && this._autoEscape && typeof value === "string") {
2084
- value = htmlEscape(value);
2218
+ if (!applied.safe && this._autoEscape) {
2219
+ if (typeof value === "string") {
2220
+ value = htmlEscape(value);
2221
+ } else if (isEscapableStructured(value)) {
2222
+ value = new SafeString(htmlEscape(toOutputString(value)));
2223
+ }
2085
2224
  }
2086
2225
  return value;
2087
2226
  }
@@ -2188,7 +2327,8 @@ var Frond = class _Frond {
2188
2327
  const var1 = forMatch[1];
2189
2328
  const var2 = forMatch[2] || null;
2190
2329
  const { bodyTokens, elseTokens, next: i } = this.collectForTokens(tokens, start);
2191
- const iterable = evalExpr(forMatch[3].trim(), context);
2330
+ const iterableExpr = forMatch[3].trim();
2331
+ const iterable = this._sandbox && !this.sandboxExprOk(iterableExpr) ? null : evalExpr(iterableExpr, context);
2192
2332
  const { items, isDict } = this.forItems(iterable);
2193
2333
  if (items.length === 0) return [elseTokens.length ? this.renderTokens([...elseTokens], context) : "", i];
2194
2334
  const output = [];
@@ -1,3 +1,11 @@
1
+ /*
2
+ Copyright (c) 2026 Code Infinity
3
+ SPDX-License-Identifier: MPL-2.0
4
+ This Source Code Form is subject to the terms of the Mozilla Public
5
+ License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+ */
8
+
1
9
  /**
2
10
  * Tina4 Frond Engine — Lexer, parser, and runtime.
3
11
  * Zero-dependency Twig-like template engine.
@@ -570,6 +578,11 @@ function resolveMethodPart(value: unknown, part: string, context: Record<string,
570
578
  if (!methodMatch || typeof value !== "object" || value === null) return { matched: false, value };
571
579
  const methodName = methodMatch[1];
572
580
  const rawArgs = methodMatch[2] || "";
581
+ // F6/ADR-0077: never traverse a prototype/constructor/dunder key -- that is
582
+ // the Function-constructor and prototype-pollution escape.
583
+ if (unsafeKey(methodName) || !Object.prototype.hasOwnProperty.call(value, methodName)) {
584
+ return { matched: false, value };
585
+ }
573
586
  const fn = (value as Record<string, unknown>)[methodName];
574
587
  if (typeof fn !== "function") return { matched: false, value };
575
588
  const args = rawArgs.trim() ? splitArgs(rawArgs).map(a => evalExpr(a.trim(), context)) : [];
@@ -592,7 +605,9 @@ function resolvePathKey(part: string, isBracket: boolean, context: Record<string
592
605
  function resolvePathPart(value: unknown, key: string | number): { found: boolean; value: unknown } {
593
606
  if (typeof value !== "object" || value === null) return { found: false, value: null };
594
607
  if (Array.isArray(value) && typeof key === "number") return { found: true, value: value[key] };
595
- if (!(key in (value as Record<string, unknown>))) return { found: false, value: null };
608
+ // F6/ADR-0077: only own, non-dunder properties are reachable, so an
609
+ // inherited prototype method (constructor, __proto__, ...) cannot be called.
610
+ if (unsafeKey(key) || !Object.prototype.hasOwnProperty.call(value, key)) return { found: false, value: null };
596
611
  const member = (value as Record<string, unknown>)[key as string];
597
612
  return { found: true, value: typeof member === "function" ? member.call(value) : member };
598
613
  }
@@ -889,7 +904,10 @@ function evalDottedFunction(name: string, rawArgs: string, context: Record<strin
889
904
  const lastDot = name.lastIndexOf(".");
890
905
  const owner = resolveVar(name.slice(0, lastDot), context);
891
906
  const member = name.slice(lastDot + 1);
892
- if (!owner || typeof owner !== "object" || !(member in (owner as Record<string, unknown>))) {
907
+ // F6/ADR-0077: never call through a prototype/constructor/dunder key or an
908
+ // inherited method -- that is the Function-constructor and prototype escape.
909
+ if (!owner || typeof owner !== "object" || unsafeKey(member) ||
910
+ !Object.prototype.hasOwnProperty.call(owner, member)) {
893
911
  return EXPR_NOT_MATCHED;
894
912
  }
895
913
  const method = (owner as Record<string, unknown>)[member];
@@ -1361,6 +1379,115 @@ function htmlEscape(str: string): string {
1361
1379
  .replace(/'/g, "&#x27;");
1362
1380
  }
1363
1381
 
1382
+ // -- Escape strategies (ADR-0077) -------------------------------------------
1383
+ // Shared by js_escape and e(strategy); byte-identical to the Python master.
1384
+ // Twig-compatible. Iterates code points so astral characters emit a surrogate
1385
+ // pair for the js strategy.
1386
+ const JS_SAFE_RE = /[A-Za-z0-9,._]/;
1387
+ function jsEscape(value: unknown): string {
1388
+ let out = "";
1389
+ for (const ch of String(value)) {
1390
+ if (JS_SAFE_RE.test(ch) && ch.length === 1) {
1391
+ out += ch;
1392
+ continue;
1393
+ }
1394
+ let code = ch.codePointAt(0)!;
1395
+ if (code < 0x80) {
1396
+ out += "\\x" + code.toString(16).toUpperCase().padStart(2, "0");
1397
+ } else if (code <= 0xffff) {
1398
+ out += "\\u" + code.toString(16).toUpperCase().padStart(4, "0");
1399
+ } else {
1400
+ code -= 0x10000;
1401
+ out += "\\u" + (0xd800 + (code >> 10)).toString(16).toUpperCase().padStart(4, "0");
1402
+ out += "\\u" + (0xdc00 + (code & 0x3ff)).toString(16).toUpperCase().padStart(4, "0");
1403
+ }
1404
+ }
1405
+ return out;
1406
+ }
1407
+
1408
+ function cssEscape(value: unknown): string {
1409
+ let out = "";
1410
+ for (const ch of String(value)) {
1411
+ out += /[A-Za-z0-9]/.test(ch)
1412
+ ? ch
1413
+ : "\\" + ch.codePointAt(0)!.toString(16).toUpperCase().padStart(6, "0") + " ";
1414
+ }
1415
+ return out;
1416
+ }
1417
+
1418
+ function htmlAttrEscape(value: unknown): string {
1419
+ let out = "";
1420
+ for (const ch of String(value)) {
1421
+ out += /[A-Za-z0-9,.\-_]/.test(ch)
1422
+ ? ch
1423
+ : "&#x" + ch.codePointAt(0)!.toString(16).toUpperCase().padStart(2, "0") + ";";
1424
+ }
1425
+ return out;
1426
+ }
1427
+
1428
+ function urlEscape(value: unknown): string {
1429
+ return encodeURIComponent(String(value)).replace(
1430
+ /[!'()*]/g,
1431
+ (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase(),
1432
+ );
1433
+ }
1434
+
1435
+ // Dispatch e(strategy)/escape(strategy). Unknown strategy throws so a typo can
1436
+ // never silently pass the value through unescaped.
1437
+ function escapeStrategy(value: unknown, strategy: string = "html"): SafeString {
1438
+ const s = String(strategy).trim().replace(/^['"]|['"]$/g, "");
1439
+ switch (s) {
1440
+ case "":
1441
+ case "html":
1442
+ return new SafeString(htmlEscape(String(value)));
1443
+ case "js":
1444
+ return new SafeString(jsEscape(value));
1445
+ case "url":
1446
+ return new SafeString(urlEscape(value));
1447
+ case "css":
1448
+ return new SafeString(cssEscape(value));
1449
+ case "html_attr":
1450
+ case "attr":
1451
+ return new SafeString(htmlAttrEscape(value));
1452
+ default:
1453
+ throw new Error(`Unknown escape strategy: ${strategy}`);
1454
+ }
1455
+ }
1456
+
1457
+ // Confine a client-supplied media type to type/subtype (F3/ADR-0077).
1458
+ const MEDIA_TYPE_RE = /^[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*\/[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*$/;
1459
+ function sanitiseMediaType(type: string): string {
1460
+ const t = String(type).trim();
1461
+ return MEDIA_TYPE_RE.test(t) ? t : "application/octet-stream";
1462
+ }
1463
+
1464
+ // Property keys that must never be traversed/invoked through {{ obj.x }} —
1465
+ // prototype pollution and the Function constructor RCE (F6/ADR-0077).
1466
+ const SANDBOX_EXPR_KEYWORDS = new Set([
1467
+ "true","false","none","null","nil","and","or","not","in","is","loop",
1468
+ "if","else","empty","starts","ends","with","matches",
1469
+ ]);
1470
+ const SANDBOX_IDENT_RE = /(?<![.\w'"])([A-Za-z_][A-Za-z0-9_]*)/g;
1471
+ const SANDBOX_STRING_RE = /'[^']*'|"[^"]*"/g;
1472
+ const UNSAFE_KEYS = new Set([
1473
+ "constructor", "__proto__", "prototype", "__defineGetter__",
1474
+ "__defineSetter__", "__lookupGetter__", "__lookupSetter__",
1475
+ "__defineProperty__", "valueOf",
1476
+ ]);
1477
+ function unsafeKey(key: string | number): boolean {
1478
+ return UNSAFE_KEYS.has(String(key));
1479
+ }
1480
+
1481
+ // A value whose rendered string form must be auto-escaped: an array/object
1482
+ // (not a SafeString). Strings, numbers, booleans and null are handled by the
1483
+ // caller's own branches. (F4/ADR-0077)
1484
+ function isEscapableStructured(value: unknown): boolean {
1485
+ return value !== null && typeof value === "object" && !(value instanceof SafeString);
1486
+ }
1487
+ function toOutputString(value: unknown): string {
1488
+ return String(value);
1489
+ }
1490
+
1364
1491
  function dateFilter(value: unknown, fmt: string): string {
1365
1492
  let dt: Date;
1366
1493
  if (value instanceof Date) {
@@ -1514,8 +1641,8 @@ const BUILTIN_FILTERS: Record<string, FilterFn> = {
1514
1641
  default: (v, fallback) => (v !== null && v !== undefined && v !== "") ? v : (fallback !== undefined ? fallback : ""),
1515
1642
  raw: (v) => v,
1516
1643
  safe: (v) => v,
1517
- escape: (v) => htmlEscape(String(v)),
1518
- e: (v) => htmlEscape(String(v)),
1644
+ escape: (v, ...a) => escapeStrategy(v, (a[0] as string) ?? "html"),
1645
+ e: (v, ...a) => escapeStrategy(v, (a[0] as string) ?? "html"),
1519
1646
  striptags: (v) => String(v).replace(STRIP_TAGS_RE, ""),
1520
1647
  nl2br: (v) => new SafeString(htmlEscape(String(v)).replace(/\n/g, "<br />\n")),
1521
1648
  abs: (v) => typeof v === "number" ? Math.abs(v) : v,
@@ -1604,9 +1731,9 @@ const BUILTIN_FILTERS: Record<string, FilterFn> = {
1604
1731
  base64decode: (v) => Buffer.from(String(v), "base64").toString("utf-8"),
1605
1732
  data_uri: (v) => {
1606
1733
  if (v && typeof v === "object" && "content" in v) {
1607
- const ct = (v as any).type ?? "application/octet-stream";
1734
+ const ct = sanitiseMediaType(String((v as any).type ?? "application/octet-stream"));
1608
1735
  const raw = Buffer.isBuffer((v as any).content) ? (v as any).content : Buffer.from(String((v as any).content));
1609
- return `data:${ct};base64,${raw.toString("base64")}`;
1736
+ return new SafeString(`data:${ct};base64,${raw.toString("base64")}`);
1610
1737
  }
1611
1738
  return String(v);
1612
1739
  },
@@ -1626,7 +1753,7 @@ const BUILTIN_FILTERS: Record<string, FilterFn> = {
1626
1753
  // broke byte-parity for the one filter whose whole job is a wire format.
1627
1754
  tojson: (v) => jsonSafe(v),
1628
1755
  to_json: (v) => jsonSafe(v),
1629
- js_escape: (v) => new SafeString(String(v).replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t")),
1756
+ js_escape: (v) => new SafeString(jsEscape(v)),
1630
1757
  };
1631
1758
 
1632
1759
  // ── Form Token ────────────────────────────────────────────────
@@ -1667,6 +1794,15 @@ function _buildFormTokenJwt(descriptor: string = ""): string {
1667
1794
  // AND a ''-forged token was accepted (CSRF-NODE-SECRET-SPLIT, feature 37).
1668
1795
  // The generator and the validator MUST resolve the same secret.
1669
1796
  const secret = process.env.TINA4_SECRET ?? "";
1797
+ // Same rule as core auth (ADR-0079 s2): never sign with a blank or short key,
1798
+ // which would make every form token reproducible by anyone.
1799
+ const secretBytes = Buffer.byteLength(secret, "utf8");
1800
+ if (secretBytes < 32) {
1801
+ throw new Error(
1802
+ `Auth: TINA4_SECRET ${secretBytes === 0 ? "is not set" : `is ${secretBytes} bytes`}; an HMAC JWT secret must be at least 32 bytes. ` +
1803
+ "Generate one with `openssl rand -hex 32` and set TINA4_SECRET in your environment or .env.",
1804
+ );
1805
+ }
1670
1806
  const ttlMinutes = parseInt(process.env.TINA4_TOKEN_LIMIT || "60", 10);
1671
1807
 
1672
1808
  const header = { alg: "HS256", typ: "JWT" };
@@ -2526,6 +2662,9 @@ export class Frond {
2526
2662
  }
2527
2663
 
2528
2664
  private evalVarRaw(expr: string, context: Record<string, unknown>): unknown {
2665
+ // F6/ADR-0077: gate the whole condition/RHS so a comparison or iterable
2666
+ // cannot leak a blocked variable.
2667
+ if (this._sandbox && !this.sandboxExprOk(expr)) return null;
2529
2668
  const [varName, filters] = parseFilterChain(expr);
2530
2669
  let value = evalExpr(varName, context);
2531
2670
  for (const [fname, rawArgs] of filters) {
@@ -2576,10 +2715,26 @@ export class Frond {
2576
2715
  return fn ? fn(value, ...args) : value;
2577
2716
  }
2578
2717
 
2579
- private variablePermitted(varName: string): boolean {
2580
- if (!this._sandbox || this._allowedVars === null) return true;
2581
- const rootVar = varName.split(".")[0].split("[")[0].trim();
2582
- return !rootVar || rootVar === "loop" || this._allowedVars.has(rootVar);
2718
+ /**
2719
+ * Sandbox gate for a WHOLE expression (F6/ADR-0077): refuse a dunder walk or
2720
+ * a root variable outside the allow-list. Applied to output, if conditions,
2721
+ * set right-hand sides and for iterables so none can smuggle a blocked
2722
+ * variable past the per-output gate.
2723
+ */
2724
+ private sandboxExprOk(expr: string): boolean {
2725
+ if (!this._sandbox) return true;
2726
+ if (expr.includes("__")) return false;
2727
+ if (this._allowedVars === null) return true;
2728
+ const stripped = expr.replace(SANDBOX_STRING_RE, "");
2729
+ let m: RegExpExecArray | null;
2730
+ SANDBOX_IDENT_RE.lastIndex = 0;
2731
+ while ((m = SANDBOX_IDENT_RE.exec(stripped)) !== null) {
2732
+ const ident = m[1];
2733
+ if (SANDBOX_EXPR_KEYWORDS.has(ident.toLowerCase())) continue;
2734
+ if (ident in this.filters) continue;
2735
+ if (!this._allowedVars.has(ident)) return false;
2736
+ }
2737
+ return true;
2583
2738
  }
2584
2739
 
2585
2740
  private resolveConcatenation(
@@ -2590,6 +2745,7 @@ export class Frond {
2590
2745
  let value = evalExpr(expr, context);
2591
2746
  if (value instanceof SafeString) return { handled: true, value: value.value };
2592
2747
  if (this._autoEscape && typeof value === "string") value = htmlEscape(value);
2748
+ else if (this._autoEscape && isEscapableStructured(value)) value = htmlEscape(toOutputString(value));
2593
2749
  return { handled: true, value };
2594
2750
  }
2595
2751
 
@@ -2605,7 +2761,10 @@ export class Frond {
2605
2761
  if (this.filterPermitted(fname)) safe = true;
2606
2762
  continue;
2607
2763
  }
2608
- if ((fname === "escape" || fname === "e") && this.filterPermitted(fname)) safe = true;
2764
+ // F2/ADR-0077: do NOT keep a persistent "safe" flag for escape/e. The
2765
+ // filter returns a SafeString; if a later filter (replace/format/join)
2766
+ // transforms it, the result is a plain string and is re-escaped at
2767
+ // output. Keeping a safe flag here let post-escape filters emit raw.
2609
2768
  if (!this.filterPermitted(fname)) continue;
2610
2769
  value = this.applyRenderedFilter(value, fname, args);
2611
2770
  }
@@ -2614,7 +2773,7 @@ export class Frond {
2614
2773
 
2615
2774
  private evalVarInner(expr: string, context: Record<string, unknown>): unknown {
2616
2775
  const [varName, filters] = parseFilterChain(expr);
2617
- if (!this.variablePermitted(varName)) return "";
2776
+ if (this._sandbox && !this.sandboxExprOk(expr)) return "";
2618
2777
  const concatenated = this.resolveConcatenation(expr, context);
2619
2778
  if (concatenated.handled) return concatenated.value;
2620
2779
  const applied = this.applyRenderedFilters(evalExpr(varName, context), filters, context);
@@ -2625,9 +2784,16 @@ export class Frond {
2625
2784
  return value.value;
2626
2785
  }
2627
2786
 
2628
- // Auto-escape HTML unless marked safe or auto-escape is disabled
2629
- if (!applied.safe && this._autoEscape && typeof value === "string") {
2630
- value = htmlEscape(value);
2787
+ // Auto-escape HTML unless marked safe or auto-escape is disabled. A plain
2788
+ // string is escaped directly; an array/object is escaped on its rendered
2789
+ // string form (F4/ADR-0077) -- before this, only string values were
2790
+ // escaped, so {{ items }} holding markup emitted it raw.
2791
+ if (!applied.safe && this._autoEscape) {
2792
+ if (typeof value === "string") {
2793
+ value = htmlEscape(value);
2794
+ } else if (isEscapableStructured(value)) {
2795
+ value = new SafeString(htmlEscape(toOutputString(value)));
2796
+ }
2631
2797
  }
2632
2798
 
2633
2799
  return value;
@@ -2756,7 +2922,10 @@ export class Frond {
2756
2922
  const { bodyTokens, elseTokens, next: i } = this.collectForTokens(tokens, start);
2757
2923
 
2758
2924
  // Evaluate iterable
2759
- const iterable = evalExpr(forMatch[3].trim(), context);
2925
+ const iterableExpr = forMatch[3].trim();
2926
+ const iterable = (this._sandbox && !this.sandboxExprOk(iterableExpr))
2927
+ ? null
2928
+ : evalExpr(iterableExpr, context);
2760
2929
  const { items, isDict } = this.forItems(iterable);
2761
2930
  if (items.length === 0) return [elseTokens.length ? this.renderTokens([...elseTokens], context) : "", i];
2762
2931
 
@@ -1,2 +1,10 @@
1
+ /*
2
+ Copyright (c) 2026 Code Infinity
3
+ SPDX-License-Identifier: MPL-2.0
4
+ This Source Code Form is subject to the terms of the Mozilla Public
5
+ License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+ */
8
+
1
9
  export { Frond } from "./engine.js";
2
10
  export type { FilterFn, TestFn, LiveProvider, LiveRequest, LiveResponse, LiveBroadcaster } from "./engine.js";