tina4-nodejs 3.13.149 → 3.13.151

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 (60) hide show
  1. package/CLAUDE.md +2 -2
  2. package/package.json +5 -9
  3. package/packages/cli/dist/bin.js +1074 -521
  4. package/packages/core/dist/index.js +1071 -516
  5. package/packages/core/public/bundled-component-provenance.json +2 -2
  6. package/packages/core/public/js/tina4-dev-admin-split.html +202 -0
  7. package/packages/core/public/js/tina4-dev-admin.min.js +147 -138
  8. package/packages/core/src/aiClient.ts +2 -1
  9. package/packages/core/src/api.ts +2 -1
  10. package/packages/core/src/auth.ts +1 -1
  11. package/packages/core/src/cache.ts +6 -1
  12. package/packages/core/src/devAdmin.ts +209 -123
  13. package/packages/core/src/dispatchPipeline.ts +4 -0
  14. package/packages/core/src/docs.ts +5 -3
  15. package/packages/core/src/errorOverlay.ts +18 -9
  16. package/packages/core/src/index.ts +2 -1
  17. package/packages/core/src/mcp.ts +53 -16
  18. package/packages/core/src/messenger.ts +22 -30
  19. package/packages/core/src/metrics.ts +5 -1
  20. package/packages/core/src/middleware.ts +8 -1
  21. package/packages/core/src/optionalPackage.ts +2 -2
  22. package/packages/core/src/plan.ts +4 -4
  23. package/packages/core/src/push.ts +1 -1
  24. package/packages/core/src/request.ts +185 -25
  25. package/packages/core/src/router.ts +3 -2
  26. package/packages/core/src/server.ts +32 -10
  27. package/packages/core/src/session.ts +44 -0
  28. package/packages/core/src/slashTrim.ts +39 -0
  29. package/packages/core/src/wsdl.ts +66 -48
  30. package/packages/core/templates/crud/form.twig +5 -5
  31. package/packages/core/templates/crud/table.twig +2 -2
  32. package/packages/core/templates/errors/302.twig +4 -3
  33. package/packages/frond/dist/index.js +206 -64
  34. package/packages/frond/src/engine.ts +278 -69
  35. package/packages/frond/src/index.ts +1 -1
  36. package/packages/orm/dist/index.js +1055 -502
  37. package/packages/orm/src/crudRender.ts +5 -5
  38. package/packages/orm/src/crudTable.ts +1 -1
  39. package/packages/orm/src/database.ts +15 -29
  40. package/packages/orm/src/databaseResult.ts +11 -1
  41. package/packages/orm/src/realtime/index.ts +1 -0
  42. package/packages/orm/src/realtime/realtime.ts +2 -1
  43. package/packages/orm/src/realtime/s3Client.ts +276 -0
  44. package/packages/orm/src/realtime/storage.ts +19 -60
  45. package/packages/orm/src/sqlTranslator.ts +58 -42
  46. package/packages/swagger/dist/index.js +25 -4
  47. package/packages/swagger/src/generator.ts +7 -4
  48. package/packages/swagger/src/ui.ts +2 -1
  49. package/types/core/src/devAdmin.d.ts +40 -0
  50. package/types/core/src/index.d.ts +2 -1
  51. package/types/core/src/middleware.d.ts +1 -0
  52. package/types/core/src/optionalPackage.d.ts +2 -2
  53. package/types/core/src/request.d.ts +5 -1
  54. package/types/core/src/session.d.ts +25 -0
  55. package/types/core/src/slashTrim.d.ts +6 -0
  56. package/types/frond/src/engine.d.ts +11 -0
  57. package/types/frond/src/index.d.ts +1 -1
  58. package/types/orm/src/realtime/index.d.ts +1 -0
  59. package/types/orm/src/realtime/s3Client.d.ts +48 -0
  60. package/types/orm/src/realtime/storage.d.ts +9 -7
@@ -283,6 +283,15 @@ const FILTER_WITH_ARGS_RE = /^(\w+)\s*\(([\s\S]*)\)$/;
283
283
  const FILTER_COMPARISON_RE = /^(\w+)\s*(!=|==|>=|<=|>|<)\s*(.+)$/;
284
284
  const TITLE_WORD_RE = /\b\w/g;
285
285
  const STRIP_TAGS_RE = /<[^>]+>/g;
286
+
287
+ // Strip tags to a fixed point: a single pass leaves a tag that only appears once
288
+ // the surrounding one is removed (`<<b>b>` -> `<b>`), which CodeQL flags as
289
+ // incomplete multi-character sanitization. Repeat until nothing more matches.
290
+ function stripTags(input: string): string {
291
+ let s = input, prev: string;
292
+ do { prev = s; s = s.replace(STRIP_TAGS_RE, ""); } while (s !== prev);
293
+ return s;
294
+ }
286
295
  const FAST_FILTERS: Record<string, (value: unknown) => unknown> = {
287
296
  upper: (value) => String(value).toUpperCase(),
288
297
  lower: (value) => String(value).toLowerCase(),
@@ -296,7 +305,7 @@ const FAST_FILTERS: Record<string, (value: unknown) => unknown> = {
296
305
  int: (value) => value ? parseInt(String(value), 10) || 0 : 0,
297
306
  float: (value) => value ? parseFloat(String(value)) || 0.0 : 0.0,
298
307
  abs: (value) => typeof value === "number" ? Math.abs(value) : value,
299
- striptags: (value) => String(value).replace(STRIP_TAGS_RE, ""),
308
+ striptags: (value) => stripTags(String(value)),
300
309
  first: (value) => Array.isArray(value) ? value[0] ?? null : null,
301
310
  last: (value) => Array.isArray(value) ? value[value.length - 1] ?? null : null,
302
311
  keys: (value) => typeof value === "object" && value !== null && !Array.isArray(value) ? Object.keys(value) : [],
@@ -313,17 +322,20 @@ const FAST_FILTERS: Record<string, (value: unknown) => unknown> = {
313
322
  // common types. Matches PHP sprintf / Python % / Ruby format so the `format`
314
323
  // filter renders e.g. `{{ '%.2f' | format(n) }}` as "3.14" across all engines.
315
324
  const FORMAT_RE = /%%|%([-+ 0]*)(\d+)?(?:\.(\d+))?([sdifFeEgGxXob])/g;
316
- const LEADING_WS_RE = /^\s+/;
317
- const TRAILING_WS_RE = /\s+$/;
318
325
  const THOUSANDS_RE = /\B(?=(\d{3})+(?!\d))/g;
319
326
  // {% live "name" poll N | sse | ws "path" [src "url"] %}
320
327
  const LIVE_RE = /^live\s+["']([^"']+)["']([\s\S]*)$/;
321
328
  const LIVE_WS_RE = /ws\s+["']([^"']+)["']/;
322
329
  const LIVE_SRC_RE = /src\s+["']([^"']+)["']/;
323
- const EXTENDS_RE = /\{%[-\s]*extends\s+["'](.+?)["']\s*[-]?%\}/;
330
+ // The captured path is a single negated class ([^"']+), not .+?, for the same
331
+ // reason LIVE_RE above uses one: V8 backtracks a lazy dot catastrophically when
332
+ // the closing quote never arrives, so `{% extends "` + 100KB of junk would hang
333
+ // the render (js/polynomial-redos). A template path never carries a quote, so
334
+ // the class loses nothing and the match is linear.
335
+ const EXTENDS_RE = /\{%[-\s]*extends\s+["']([^"']+)["']\s*[-]?%\}/;
324
336
  // Global-flag twin of EXTENDS_RE purely for counting every occurrence (a
325
337
  // non-global RegExp's .exec()/.match() only ever reports the first match).
326
- const EXTENDS_RE_GLOBAL = /\{%[-\s]*extends\s+["'](.+?)["']\s*[-]?%\}/g;
338
+ const EXTENDS_RE_GLOBAL = /\{%[-\s]*extends\s+["']([^"']+)["']\s*[-]?%\}/g;
327
339
 
328
340
  /** Escape a value for a live-marker HTML attribute. Byte-identical order to
329
341
  * the Python master / PHP liveAttr / Ruby live_attr so the emitted marker
@@ -420,47 +432,90 @@ function sweepExpiredCache(cache: Map<string, [string, number]>): void {
420
432
  }
421
433
 
422
434
  // ── Lexer ──────────────────────────────────────────────────────
435
+ //
436
+ // ReDoS note: this lexer is an INDEX-BASED forward scan, not a backtracking
437
+ // regex. The old `/( \{%…[\s\S]*?…%\} )|…/g` + `/\{%…raw…([\s\S]*?)…endraw…/g`
438
+ // pair ran O(n^2) on V8 for a crafted "many openers, no closer" input (a lazy
439
+ // body re-scanned from every opener), so an ~8 KB template value could hang the
440
+ // event loop for tens of seconds. `indexOf` never backtracks; the per-kind
441
+ // "no more closers" cache below keeps even the pathological case linear.
442
+
443
+ // Raw-block delimiters. These are BOUNDED (fixed keyword, no lazy body), so they
444
+ // cannot backtrack — the ReDoS lived only in the `[\s\S]*?` body, which is gone.
445
+ const RAW_OPEN_RE = /\{%-?\s*raw\s*-?%\}/g;
446
+ const RAW_CLOSE_RE = /\{%-?\s*endraw\s*-?%\}/g;
447
+
448
+ // Extract {% raw %}…{% endraw %} blocks, replacing each with a NUL-wrapped
449
+ // placeholder so its body is never tokenized. First opener → first endraw after
450
+ // it (matches the old non-greedy body). An opener with no endraw anywhere is left
451
+ // in place (it tokenizes as an ordinary {% raw %} BLOCK, exactly as before).
452
+ function extractRawBlocks(source: string): { source: string; rawBlocks: string[] } {
453
+ const rawBlocks: string[] = [];
454
+ let out = "";
455
+ let pos = 0;
456
+ RAW_OPEN_RE.lastIndex = 0;
457
+ let om: RegExpExecArray | null;
458
+ while ((om = RAW_OPEN_RE.exec(source)) !== null) {
459
+ const openStart = om.index;
460
+ const openEnd = RAW_OPEN_RE.lastIndex;
461
+ RAW_CLOSE_RE.lastIndex = openEnd;
462
+ const cm = RAW_CLOSE_RE.exec(source);
463
+ if (cm === null) break; // no endraw after this opener → none after any later one
464
+ const idx = rawBlocks.length;
465
+ rawBlocks.push(source.slice(openEnd, cm.index));
466
+ out += source.slice(pos, openStart) + `\x00RAW_${idx}\x00`;
467
+ pos = RAW_CLOSE_RE.lastIndex;
468
+ RAW_OPEN_RE.lastIndex = pos; // continue after the endraw
469
+ }
470
+ out += source.slice(pos);
471
+ return { source: out, rawBlocks };
472
+ }
423
473
 
424
- const TOKEN_RE = /(\{%-?\s*[\s\S]*?\s*-?%\})|(\{\{-?\s*[\s\S]*?\s*-?\}\})|(\{#[\s\S]*?#\})/g;
425
-
426
- // Regex to extract {% raw %}...{% endraw %} blocks before tokenizing
427
- const RAW_BLOCK_RE = /\{%-?\s*raw\s*-?%\}([\s\S]*?)\{%-?\s*endraw\s*-?%\}/g;
474
+ const TAG_KINDS: Record<string, { close: string; type: TokenType }> = {
475
+ "%": { close: "%}", type: "BLOCK" },
476
+ "{": { close: "}}", type: "VAR" },
477
+ "#": { close: "#}", type: "COMMENT" },
478
+ };
428
479
 
429
- function tokenize(source: string): Token[] {
480
+ function tokenize(rawSource: string): Token[] {
430
481
  // 1. Extract raw blocks and replace with placeholders
431
- const rawBlocks: string[] = [];
432
- source = source.replace(RAW_BLOCK_RE, (_match, content) => {
433
- const idx = rawBlocks.length;
434
- rawBlocks.push(content);
435
- return `\x00RAW_${idx}\x00`;
436
- });
482
+ const { source, rawBlocks } = extractRawBlocks(rawSource);
437
483
 
438
- // 2. Normal tokenization
484
+ // 2. Forward index scan — find each '{', classify it, jump to its close.
439
485
  const tokens: Token[] = [];
440
- let pos = 0;
441
-
442
- TOKEN_RE.lastIndex = 0;
443
- let m: RegExpExecArray | null;
444
- while ((m = TOKEN_RE.exec(source)) !== null) {
445
- const start = m.index;
446
- if (start > pos) {
447
- tokens.push(["TEXT", source.slice(pos, start)]);
486
+ const n = source.length;
487
+ let pos = 0; // start of the current TEXT run
488
+ let scan = 0; // where to look for the next '{'
489
+ // Once a close delimiter has no occurrence beyond some point it never will
490
+ // (indexOf is monotonic as `scan` only advances), so remember it and skip that
491
+ // kind in O(1) instead of re-scanning to EOF for every dead opener.
492
+ const exhausted: Record<string, boolean> = { "%}": false, "}}": false, "#}": false };
493
+
494
+ while (scan < n) {
495
+ const brace = source.indexOf("{", scan);
496
+ if (brace === -1) break;
497
+ const kind = TAG_KINDS[source[brace + 1]];
498
+ if (!kind) {
499
+ scan = brace + 1; // a '{' that starts no tag — part of TEXT
500
+ continue;
448
501
  }
449
-
450
- const raw = m[0];
451
- if (raw.startsWith("{#")) {
452
- tokens.push(["COMMENT", raw]);
453
- } else if (raw.startsWith("{{")) {
454
- tokens.push(["VAR", raw]);
455
- } else if (raw.startsWith("{%")) {
456
- tokens.push(["BLOCK", raw]);
502
+ if (exhausted[kind.close]) {
503
+ scan = brace + 1; // no matching close remains — treat as TEXT, keep scanning
504
+ continue;
505
+ }
506
+ const close = source.indexOf(kind.close, brace + 2);
507
+ if (close === -1) {
508
+ exhausted[kind.close] = true;
509
+ scan = brace + 1;
510
+ continue;
457
511
  }
458
- pos = m.index + raw.length;
512
+ if (brace > pos) tokens.push(["TEXT", source.slice(pos, brace)]);
513
+ tokens.push([kind.type, source.slice(brace, close + 2)]);
514
+ pos = close + 2;
515
+ scan = close + 2;
459
516
  }
460
517
 
461
- if (pos < source.length) {
462
- tokens.push(["TEXT", source.slice(pos)]);
463
- }
518
+ if (pos < n) tokens.push(["TEXT", source.slice(pos)]);
464
519
 
465
520
  // 3. Restore raw block placeholders as literal TEXT
466
521
  if (rawBlocks.length > 0) {
@@ -844,26 +899,163 @@ const ARITHMETIC_OPERATIONS: Record<string, (left: number, right: number) => num
844
899
  "**": (left, right) => left ** right,
845
900
  };
846
901
 
902
+ /** Arithmetic precedence levels, loosest first. `**` is right-associative; the rest are left-associative. */
903
+ const ARITHMETIC_LEVELS: string[][] = [["+", "-"], ["*", "//", "/", "%"], ["**"]];
904
+
905
+ /** Every position in expr outside quotes and brackets. */
906
+ function topLevelPositions(expr: string): number[] {
907
+ const positions: number[] = [];
908
+ let inQuote: string | null = null;
909
+ let depth = 0;
910
+ for (let pos = 0; pos < expr.length; pos++) {
911
+ const ch = expr[pos];
912
+ if (inQuote) {
913
+ if (ch === inQuote) inQuote = null;
914
+ } else if (ch === '"' || ch === "'") {
915
+ inQuote = ch;
916
+ } else if ("([{".includes(ch)) {
917
+ depth++;
918
+ } else if (")]}".includes(ch)) {
919
+ depth--;
920
+ } else if (depth === 0) {
921
+ positions.push(pos);
922
+ }
923
+ }
924
+ return positions;
925
+ }
926
+
927
+ /** True when the text before pos ends in an operand, so an operator there is binary; the `-` in `n * -1` or `-7` is a sign. */
928
+ function followsOperand(expr: string, pos: number): boolean {
929
+ return /[\w)\]'"]$/.test(expr.slice(0, pos).trimEnd());
930
+ }
931
+
932
+ /** [position, operator] for every binary arithmetic operator outside quotes and brackets (`**` and `//` are two characters). */
933
+ function topLevelArithmeticOperators(expr: string): [number, string][] {
934
+ const operators: [number, string][] = [];
935
+ let skipUntil = -1;
936
+ for (const pos of topLevelPositions(expr)) {
937
+ if (pos < skipUntil || !"+-*/%".includes(expr[pos])) continue;
938
+ const pair = expr.slice(pos, pos + 2);
939
+ const op = pair === "**" || pair === "//" ? pair : expr[pos];
940
+ skipUntil = pos + op.length;
941
+ if (followsOperand(expr, pos)) operators.push([pos, op]);
942
+ }
943
+ return operators;
944
+ }
945
+
946
+ /**
947
+ * Pick the operator that binds loosest, so it is evaluated last. Within a
948
+ * left-associative level the RIGHTMOST operator is the split point
949
+ * (`10 - 2 - 3` is `(10 - 2) - 3`); `**` splits at the leftmost.
950
+ */
951
+ function splitArithmetic(expr: string): [number, string] | null {
952
+ const operators = topLevelArithmeticOperators(expr);
953
+ for (const level of ARITHMETIC_LEVELS) {
954
+ const matches = operators.filter(([, op]) => level.includes(op));
955
+ if (matches.length > 0) return level[0] === "**" ? matches[0] : matches[matches.length - 1];
956
+ }
957
+ return null;
958
+ }
959
+
847
960
  function evalArithmeticExpression(expr: string, context: Record<string, unknown>): ExprResult {
848
- for (const op of [" + ", " - ", " * ", " // ", " / ", " % ", " ** "]) {
849
- const pos = findOutsideQuotes(expr, op);
850
- if (pos >= 0) {
851
- const left = expr.slice(0, pos).trim();
852
- const right = expr.slice(pos + op.length).trim();
853
- const lVal = evalExpr(left, context);
854
- const rVal = evalExpr(right, context);
855
- try {
856
- let lNum = lVal != null ? Number(lVal) : 0;
857
- let rNum = rVal != null ? Number(rVal) : 0;
858
- if (isNaN(lNum)) lNum = 0;
859
- if (isNaN(rNum)) rNum = 0;
860
- return ARITHMETIC_OPERATIONS[op.trim()](lNum, rNum);
861
- } catch {
862
- return null;
863
- }
961
+ const split = splitArithmetic(expr);
962
+ if (!split) return EXPR_NOT_MATCHED;
963
+ const [pos, op] = split;
964
+ const lVal = evalExpr(expr.slice(0, pos).trim(), context);
965
+ const rVal = evalExpr(expr.slice(pos + op.length).trim(), context);
966
+ let lNum = lVal != null ? Number(lVal) : 0;
967
+ let rNum = rVal != null ? Number(rVal) : 0;
968
+ if (isNaN(lNum)) lNum = 0;
969
+ if (isNaN(rNum)) rNum = 0;
970
+ return ARITHMETIC_OPERATIONS[op](lNum, rNum);
971
+ }
972
+
973
+ /**
974
+ * Integers from start to end inclusive, counting down when start is greater
975
+ * than end. Backs both range() and `a..b`; [] for a bound that is not a
976
+ * number or a zero step.
977
+ */
978
+ export function frondRange(start: unknown, end: unknown, step: unknown = 1): number[] {
979
+ const numeric = (value: unknown) => typeof value === "number" || (typeof value === "string" && NUMERIC_RE.test(value));
980
+ if (!numeric(start) || !numeric(end) || !numeric(step)) return [];
981
+ const first = Math.trunc(Number(start));
982
+ const last = Math.trunc(Number(end));
983
+ const stride = Math.abs(Math.trunc(Number(step)));
984
+ if (stride === 0) return [];
985
+ const values: number[] = [];
986
+ if (first <= last) for (let value = first; value <= last; value += stride) values.push(value);
987
+ else for (let value = first; value >= last; value -= stride) values.push(value);
988
+ return values;
989
+ }
990
+
991
+ /**
992
+ * Position of a top-level `..` (not part of `...`), or -1. A pipe after the
993
+ * range (`1..4|join(",")`) applies to the whole range, so it is left to the
994
+ * filter step.
995
+ */
996
+ function findRangeOperator(expr: string): number {
997
+ let pos = findOutsideQuotes(expr, "..");
998
+ while (pos >= 0) {
999
+ const partOfEllipsis = expr.startsWith("...", pos) || (pos > 0 && expr[pos - 1] === ".");
1000
+ if (pos > 0 && !partOfEllipsis) return findOutsideQuotes(expr.slice(pos + 2), "|") >= 0 ? -1 : pos;
1001
+ const next = findOutsideQuotes(expr.slice(pos + 2), "..");
1002
+ pos = next >= 0 ? pos + 2 + next : -1;
1003
+ }
1004
+ return -1;
1005
+ }
1006
+
1007
+ function evalRangeExpression(expr: string, context: Record<string, unknown>): ExprResult {
1008
+ const pos = findRangeOperator(expr);
1009
+ if (pos < 0) return EXPR_NOT_MATCHED;
1010
+ return frondRange(evalExpr(expr.slice(0, pos).trim(), context), evalExpr(expr.slice(pos + 2).trim(), context));
1011
+ }
1012
+
1013
+ /** Offset of the bracket closing the one that opens expr, skipping quotes; -1 when unbalanced. */
1014
+ function closingBracketIndex(expr: string): number {
1015
+ let depth = 0;
1016
+ let inQuote: string | null = null;
1017
+ for (let i = 0; i < expr.length; i++) {
1018
+ const ch = expr[i];
1019
+ if (inQuote) {
1020
+ if (ch === inQuote) inQuote = null;
1021
+ } else if (ch === '"' || ch === "'") {
1022
+ inQuote = ch;
1023
+ } else if ("([{".includes(ch)) {
1024
+ depth++;
1025
+ } else if (")]}".includes(ch) && --depth === 0) {
1026
+ return i;
864
1027
  }
865
1028
  }
866
- return EXPR_NOT_MATCHED;
1029
+ return -1;
1030
+ }
1031
+
1032
+ const BARE_KEY_RE = /^[A-Za-z_]\w*$/;
1033
+
1034
+ /** A bare map key is a string, as in Twig; a quoted key is its text; a parenthesised key is evaluated: `{(name): 1}`. */
1035
+ function mapLiteralKey(rawKey: string, context: Record<string, unknown>): string {
1036
+ const quoted = rawKey.length >= 2 && (rawKey[0] === '"' || rawKey[0] === "'") && rawKey.endsWith(rawKey[0]);
1037
+ if (quoted) return rawKey.slice(1, -1);
1038
+ return BARE_KEY_RE.test(rawKey) ? rawKey : String(evalExpr(rawKey, context));
1039
+ }
1040
+
1041
+ function evalMapLiteral(inner: string, context: Record<string, unknown>): Record<string, unknown> {
1042
+ const map: Record<string, unknown> = {};
1043
+ for (const pair of inner === "" ? [] : splitArgs(inner)) {
1044
+ const colon = findOutsideQuotes(pair, ":");
1045
+ if (colon > 0) map[mapLiteralKey(pair.slice(0, colon).trim(), context)] = evalExpr(pair.slice(colon + 1).trim(), context);
1046
+ }
1047
+ return map;
1048
+ }
1049
+
1050
+ /**
1051
+ * A whole `[...]` or `{...}` literal is one value, so operators inside its
1052
+ * members (`{a: "x" ~ name}`) never split it.
1053
+ */
1054
+ function evalCollectionLiteral(expr: string, context: Record<string, unknown>): ExprResult {
1055
+ if ((expr[0] !== "[" && expr[0] !== "{") || closingBracketIndex(expr) !== expr.length - 1) return EXPR_NOT_MATCHED;
1056
+ const inner = expr.slice(1, -1).trim();
1057
+ if (expr[0] === "{") return evalMapLiteral(inner, context);
1058
+ return inner === "" ? [] : splitArgs(inner).map(item => evalExpr(item.trim(), context));
867
1059
  }
868
1060
 
869
1061
  function evalFilterExpression(expr: string, context: Record<string, unknown>): ExprResult {
@@ -928,9 +1120,11 @@ function evalFunctionExpression(expr: string, context: Record<string, unknown>):
928
1120
  }
929
1121
 
930
1122
  const EXPR_EVALUATORS = [
1123
+ evalCollectionLiteral,
931
1124
  evalPrimary,
932
1125
  evalConditional,
933
1126
  evalConcatOrComparison,
1127
+ evalRangeExpression,
934
1128
  evalArithmeticExpression,
935
1129
  evalFilterExpression,
936
1130
  evalFunctionExpression,
@@ -1357,8 +1551,8 @@ function splitArgs(raw: string): string[] {
1357
1551
  current += ch;
1358
1552
  continue;
1359
1553
  }
1360
- if (ch === "(" || ch === "[") { depth++; current += ch; continue; }
1361
- if (ch === ")" || ch === "]") { depth--; current += ch; continue; }
1554
+ if (ch === "(" || ch === "[" || ch === "{") { depth++; current += ch; continue; }
1555
+ if (ch === ")" || ch === "]" || ch === "}") { depth--; current += ch; continue; }
1362
1556
  if (ch === "," && depth === 0) {
1363
1557
  args.push(current.trim());
1364
1558
  current = "";
@@ -1610,8 +1804,8 @@ const BUILTIN_FILTERS: Record<string, FilterFn> = {
1610
1804
  capitalize: (v) => { const s = String(v); return s.charAt(0).toUpperCase() + s.slice(1).toLowerCase(); },
1611
1805
  title: (v) => String(v).replace(TITLE_WORD_RE, c => c.toUpperCase()),
1612
1806
  trim: (v) => String(v).trim(),
1613
- ltrim: (v) => String(v).replace(LEADING_WS_RE, ""),
1614
- rtrim: (v) => String(v).replace(TRAILING_WS_RE, ""),
1807
+ ltrim: (v) => String(v).trimStart(),
1808
+ rtrim: (v) => String(v).trimEnd(),
1615
1809
  length: (v) => {
1616
1810
  if (Array.isArray(v)) return v.length;
1617
1811
  if (typeof v === "string") return v.length;
@@ -1652,7 +1846,7 @@ const BUILTIN_FILTERS: Record<string, FilterFn> = {
1652
1846
  safe: (v) => v,
1653
1847
  escape: (v, ...a) => escapeStrategy(v, (a[0] as string) ?? "html"),
1654
1848
  e: (v, ...a) => escapeStrategy(v, (a[0] as string) ?? "html"),
1655
- striptags: (v) => String(v).replace(STRIP_TAGS_RE, ""),
1849
+ striptags: (v) => stripTags(String(v)),
1656
1850
  nl2br: (v) => new SafeString(htmlEscape(String(v)).replace(/\n/g, "<br />\n")),
1657
1851
  abs: (v) => typeof v === "number" ? Math.abs(v) : v,
1658
1852
  round: (v, decimals) => {
@@ -1768,7 +1962,7 @@ const BUILTIN_FILTERS: Record<string, FilterFn> = {
1768
1962
  // ── Form Token ────────────────────────────────────────────────
1769
1963
 
1770
1964
  function _b64url(data: Buffer): string {
1771
- return data.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
1965
+ return data.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/={1,2}$/, "");
1772
1966
  }
1773
1967
 
1774
1968
  /**
@@ -1794,6 +1988,19 @@ export function setFormTokenSessionId(sessionId: string): void {
1794
1988
  _formTokenSessionId = sessionId || "";
1795
1989
  }
1796
1990
 
1991
+ // Form-token session provider. Core registers its request-scoped session id
1992
+ // source here, for the same reason as the CSP nonce provider below. Default
1993
+ // returns "" for standalone Frond use.
1994
+ let _formTokenSessionProvider: () => string = () => "";
1995
+
1996
+ /**
1997
+ * Register the function that gives form_token() the current request's session
1998
+ * id, so each token is bound to the session that rendered it (CSRF).
1999
+ */
2000
+ export function setFormTokenSessionProvider(fn: () => string): void {
2001
+ _formTokenSessionProvider = fn;
2002
+ }
2003
+
1797
2004
  // CSP nonce provider (ADR-0088). Frond never imports @tina4/core (that would be
1798
2005
  // circular — core depends on frond), so core injects the per-response nonce
1799
2006
  // source through this setter. Default returns "" for standalone Frond use.
@@ -1844,8 +2051,9 @@ function _buildFormTokenJwt(descriptor: string = ""): string {
1844
2051
  }
1845
2052
 
1846
2053
  // Include session_id for CSRF session binding
1847
- if (_formTokenSessionId) {
1848
- payload.session_id = _formTokenSessionId;
2054
+ const sessionId = _formTokenSessionProvider() || _formTokenSessionId;
2055
+ if (sessionId) {
2056
+ payload.session_id = sessionId;
1849
2057
  }
1850
2058
 
1851
2059
  const h = _b64url(Buffer.from(JSON.stringify(header)));
@@ -2032,6 +2240,7 @@ export class Frond {
2032
2240
  this.globals.form_token = (descriptor?: string) => _generateFormToken(descriptor || "");
2033
2241
  this.globals.formTokenValue = (descriptor?: string) => _generateFormTokenValue(descriptor || "");
2034
2242
  this.globals.form_token_value = (descriptor?: string) => _generateFormTokenValue(descriptor || "");
2243
+ this.globals.range = frondRange;
2035
2244
 
2036
2245
  // Debug helper: {{ dump(x) }} — gated on TINA4_DEBUG, see renderDump().
2037
2246
  // Available alongside the |dump filter so both styles work:
@@ -2479,21 +2688,21 @@ export class Frond {
2479
2688
 
2480
2689
  private renderVarToken(tokens: Token[], index: number, context: Record<string, unknown>, output: string[]): number {
2481
2690
  const [content, stripB, stripA] = stripTag(tokens[index][1]);
2482
- if (stripB && output.length > 0) output[output.length - 1] = output[output.length - 1].replace(TRAILING_WS_RE, "");
2691
+ if (stripB && output.length > 0) output[output.length - 1] = output[output.length - 1].trimEnd();
2483
2692
  const result = this.evalVar(content, context);
2484
2693
  output.push(result !== null && result !== undefined ? String(result) : "");
2485
2694
  if (stripA && index + 1 < tokens.length && tokens[index + 1][0] === "TEXT") {
2486
- tokens[index + 1] = ["TEXT", tokens[index + 1][1].replace(LEADING_WS_RE, "")];
2695
+ tokens[index + 1] = ["TEXT", tokens[index + 1][1].trimStart()];
2487
2696
  }
2488
2697
  return index + 1;
2489
2698
  }
2490
2699
 
2491
2700
  private renderBlockToken(tokens: Token[], index: number, context: Record<string, unknown>, output: string[]): number {
2492
2701
  const [content, stripB, stripA] = stripTag(tokens[index][1]);
2493
- if (stripB && output.length > 0) output[output.length - 1] = output[output.length - 1].replace(TRAILING_WS_RE, "");
2702
+ if (stripB && output.length > 0) output[output.length - 1] = output[output.length - 1].trimEnd();
2494
2703
  const tag = content.split(/\s+/)[0] || "";
2495
2704
  if (stripA && index + 1 < tokens.length && tokens[index + 1][0] === "TEXT") {
2496
- tokens[index + 1] = ["TEXT", tokens[index + 1][1].replace(LEADING_WS_RE, "")];
2705
+ tokens[index + 1] = ["TEXT", tokens[index + 1][1].trimStart()];
2497
2706
  }
2498
2707
  let next: number;
2499
2708
  if (tag === "if" && this.tagPermitted(tag)) {
@@ -2510,7 +2719,7 @@ export class Frond {
2510
2719
  next = block.next;
2511
2720
  }
2512
2721
  if (stripA && next < tokens.length && tokens[next][0] === "TEXT") {
2513
- tokens[next] = ["TEXT", tokens[next][1].replace(LEADING_WS_RE, "")];
2722
+ tokens[next] = ["TEXT", tokens[next][1].trimStart()];
2514
2723
  }
2515
2724
  return next;
2516
2725
  }
@@ -2839,7 +3048,7 @@ export class Frond {
2839
3048
  ): void {
2840
3049
  if (stripBefore && branchTokens.length > 0 && branchTokens[branchTokens.length - 1][0] === "TEXT") {
2841
3050
  const last = branchTokens[branchTokens.length - 1];
2842
- branchTokens[branchTokens.length - 1] = ["TEXT", last[1].replace(TRAILING_WS_RE, "")];
3051
+ branchTokens[branchTokens.length - 1] = ["TEXT", last[1].trimEnd()];
2843
3052
  }
2844
3053
  branches.push([condition, branchTokens]);
2845
3054
  }
@@ -2872,7 +3081,7 @@ export class Frond {
2872
3081
  } else if (tag === "endif") {
2873
3082
  this.pushIfBranch(branches, currentCond, currentTokens, tagStripB);
2874
3083
  if (tagStripA && i + 1 < tokens.length && tokens[i + 1][0] === "TEXT") {
2875
- tokens[i + 1] = ["TEXT", tokens[i + 1][1].replace(LEADING_WS_RE, "")];
3084
+ tokens[i + 1] = ["TEXT", tokens[i + 1][1].trimStart()];
2876
3085
  }
2877
3086
  return { branches, next: i + 1 };
2878
3087
  } else if ((tag === "elseif" || tag === "elif") && depth === 0) {
@@ -6,5 +6,5 @@ License, v. 2.0. If a copy of the MPL was not distributed with this
6
6
  file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
7
  */
8
8
 
9
- export { Frond, escapeHtml, setCspNonceProvider } from "./engine.js";
9
+ export { Frond, escapeHtml, setCspNonceProvider, setFormTokenSessionProvider } from "./engine.js";
10
10
  export type { FilterFn, TestFn, LiveProvider, LiveRequest, LiveResponse, LiveBroadcaster } from "./engine.js";