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
@@ -8,6 +8,7 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
8
8
 
9
9
  /** Zero-dependency app-facing AI client (ADR-0053 + ADR-0060). */
10
10
  import http, { type IncomingMessage } from "node:http";
11
+ import { rtrimSlashes } from "./slashTrim.js";
11
12
  import https from "node:https";
12
13
  import { parseLineStream, parseSseStream, type SseEvent } from "./api.js";
13
14
 
@@ -319,7 +320,7 @@ export class Ai {
319
320
  let url: URL;
320
321
  try { url = new URL(value); } catch { throw new AiConfigError("AI URL must be an http or https URL"); }
321
322
  if (url.protocol !== "http:" && url.protocol !== "https:") throw new AiConfigError("AI URL must be an http or https URL");
322
- const path = url.pathname.replace(/\/+$/, "");
323
+ const path = rtrimSlashes(url.pathname);
323
324
  // "/v1beta/openai" is Gemini's OpenAI-compatible base; append the suffix onto it exactly as
324
325
  // onto a bare host / "/v1" / "/api", so the default gemini base resolves to
325
326
  // .../v1beta/openai/chat/completions (or /embeddings). A full endpoint URL the caller supplies
@@ -22,6 +22,7 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
22
22
  * zero-dependency node:http / node:https core.
23
23
  */
24
24
  import http from "node:http";
25
+ import { rtrimSlashes } from "./slashTrim.js";
25
26
  import https from "node:https";
26
27
  import { URL } from "node:url";
27
28
  import { randomBytes } from "node:crypto";
@@ -494,7 +495,7 @@ export class Api {
494
495
  authHeaderOrOptions: string | ApiOptions = "",
495
496
  timeout: number = 30
496
497
  ) {
497
- this.baseUrl = baseUrl.replace(/\/+$/, "");
498
+ this.baseUrl = rtrimSlashes(baseUrl);
498
499
  this.headers = {};
499
500
  // Retry defaults: off (0) so existing callers are unaffected.
500
501
  this.maxRetries = 0;
@@ -394,7 +394,7 @@ export function resolveAlgorithm(algorithm?: string): string {
394
394
  // ── Base64url helpers (RFC 7515) ──────────────────────────────────
395
395
 
396
396
  function base64urlEncode(data: Buffer): string {
397
- return data.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
397
+ return data.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/={1,2}$/, "");
398
398
  }
399
399
 
400
400
  function base64urlDecode(str: string): Buffer {
@@ -1746,7 +1746,7 @@ function hasSetCookie(raw: unknown): boolean {
1746
1746
  * genuinely public page cacheable for cookie-bearing browsers.
1747
1747
  */
1748
1748
  function mayStore(
1749
- req: { headers?: Record<string, unknown> },
1749
+ req: { headers?: Record<string, unknown>; session?: { isFresh?: () => boolean } },
1750
1750
  vary: string[],
1751
1751
  cacheControl: unknown,
1752
1752
  setCookie: unknown,
@@ -1757,6 +1757,11 @@ function mayStore(
1757
1757
  if (requestHeader(req, "authorization") !== undefined) return sharedCacheAllowed(cacheControl);
1758
1758
  if (requestHeader(req, "cookie") !== undefined) return sharedCacheAllowed(cacheControl);
1759
1759
  if (hasSetCookie(setCookie)) return sharedCacheAllowed(cacheControl);
1760
+ // The session cookie is added after this check, so a response that is about
1761
+ // to install one carries no Set-Cookie yet: a request that came without a
1762
+ // cookie and leaves its session holding data (a form token bound to it marks
1763
+ // it) is that case.
1764
+ if (req.session?.isFresh && !req.session.isFresh()) return sharedCacheAllowed(cacheControl);
1760
1765
  return true;
1761
1766
  }
1762
1767
 
@@ -18,6 +18,7 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
18
18
  */
19
19
 
20
20
  import { cpus as osCpus } from "node:os";
21
+ import { rtrimSlashes } from "./slashTrim.js";
21
22
  import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync, copyFileSync, statSync, openSync, closeSync, fstatSync, fchmodSync, ftruncateSync, constants, realpathSync, lstatSync } from "node:fs";
22
23
  import { join, dirname, resolve, relative, sep, basename } from "node:path";
23
24
  import { fileURLToPath } from "node:url";
@@ -830,6 +831,7 @@ export class DevAdmin {
830
831
  { method: "GET", pattern: "/__dev/api/docs/.well-known.json", handler: handleDocsWellKnown },
831
832
  // JS asset
832
833
  { method: "GET", pattern: "/__dev/js/tina4-dev-admin.min.js", handler: handleDevAdminJs },
834
+ { method: "GET", pattern: "/__dev/split", handler: handleDevAdminSplit },
833
835
  // Dev-toolbar assets — served as external CSS/JS so the injected toolbar is
834
836
  // CSP-clean (no inline style=, onclick=, or <script>) under a strict
835
837
  // `default-src 'self'`. Parity with PHP's /__dev/toolbar.css + toolbar.js.
@@ -1429,18 +1431,74 @@ const handleTables: RouteHandler = async (_req, res) => {
1429
1431
  }
1430
1432
  };
1431
1433
 
1432
- const handleSeed: RouteHandler = async (req, res) => {
1433
- const url = new URL(req.url ?? "/", "http://localhost");
1434
- const body = (req as any).body ?? {};
1435
- const table = url.searchParams.get("table") ?? body?.table ?? "";
1436
- const count = parseInt(String(url.searchParams.get("count") ?? body?.count ?? "10"), 10) || 10;
1437
- // P4b — accept seed/clear/strict; drop the previous hard-coded behaviour.
1438
- const seedRaw = url.searchParams.get("seed") ?? body?.seed;
1434
+ /** A column as the DB adapter's getColumns() reports it, for seeding. */
1435
+ interface SeedColumn { name: string; type: string; primaryKey?: boolean }
1436
+
1437
+ /** The fake-data surface buildSeedFieldMap draws from (orm's FakeData). */
1438
+ interface SeedFake {
1439
+ email(): string; name(): string; phone(): string; address(): string; url(): string;
1440
+ integer(min: number, max: number): number; numeric(min: number, max: number, dp: number): number;
1441
+ boolean(): boolean; date(): unknown; sentence(words: number): string;
1442
+ }
1443
+
1444
+ /**
1445
+ * Resolve the seed request's options from the query string and JSON body (the
1446
+ * query string wins). Exported so the parsing is tested directly. P4b accepts
1447
+ * seed/clear/strict; an unparseable `seed` becomes undefined (not reproducible).
1448
+ */
1449
+ export function parseSeedRequest(url: URL, body: Record<string, unknown> | null | undefined): {
1450
+ table: string; count: number; seed: number | undefined; clear: boolean; strict: boolean;
1451
+ } {
1452
+ const b = body ?? {};
1453
+ const table = String(url.searchParams.get("table") ?? b.table ?? "");
1454
+ const count = parseInt(String(url.searchParams.get("count") ?? b.count ?? "10"), 10) || 10;
1455
+ const seedRaw = url.searchParams.get("seed") ?? b.seed;
1439
1456
  const seed = seedRaw !== undefined && seedRaw !== null && String(seedRaw) !== ""
1440
1457
  ? (Number.isNaN(parseInt(String(seedRaw), 10)) ? undefined : parseInt(String(seedRaw), 10))
1441
1458
  : undefined;
1442
- const clear = String(url.searchParams.get("clear") ?? body?.clear ?? "") === "true" || body?.clear === true;
1443
- const strict = String(url.searchParams.get("strict") ?? body?.strict ?? "") === "true" || body?.strict === true;
1459
+ const clear = String(url.searchParams.get("clear") ?? b.clear ?? "") === "true" || b.clear === true;
1460
+ const strict = String(url.searchParams.get("strict") ?? b.strict ?? "") === "true" || b.strict === true;
1461
+ return { table, count, seed, clear, strict };
1462
+ }
1463
+
1464
+ /**
1465
+ * Build a column -> fake-value generator map from the table's columns, keyed on
1466
+ * the column NAME first (email/name/phone/address/url) then its SQL TYPE
1467
+ * (int/float/bool/date), falling back to a short sentence. id / primary-key
1468
+ * columns are skipped (the engine assigns them). Exported so the heuristic is
1469
+ * tested directly. `fake` is seeded by the caller so a run is reproducible; its
1470
+ * generators are closed over by every entry.
1471
+ */
1472
+ export function buildSeedFieldMap(columns: SeedColumn[], fake: SeedFake): Record<string, () => unknown> {
1473
+ // Ordered rules, first match wins: column NAME first, then SQL TYPE. Data-driven
1474
+ // so the dispatch stays flat (one scan, no deep if/else ladder) and a new rule is
1475
+ // one row. The order is exactly the old if/else order, so the mapping is unchanged.
1476
+ const rules: Array<[(name: string, type: string) => boolean, () => unknown]> = [
1477
+ [(n) => n.includes("email"), () => fake.email()],
1478
+ [(n) => n.includes("name"), () => fake.name()],
1479
+ [(n) => n.includes("phone"), () => fake.phone()],
1480
+ [(n) => n.includes("address") || n.includes("city") || n.includes("country"), () => fake.address()],
1481
+ [(n) => n.includes("url") || n.includes("website"), () => fake.url()],
1482
+ [(_n, t) => t.includes("int"), () => fake.integer(1, 1000)],
1483
+ [(_n, t) => /real|float|double|numeric|decimal/.test(t), () => fake.numeric(0, 1000, 2)],
1484
+ [(_n, t) => t.includes("bool"), () => fake.boolean()],
1485
+ [(_n, t) => t.includes("date") || t.includes("time"), () => fake.date()],
1486
+ ];
1487
+ const sentence = () => fake.sentence(3); // fallback for an unmatched column
1488
+ const fieldMap: Record<string, () => unknown> = {};
1489
+ for (const col of columns) {
1490
+ if (col.name.toLowerCase() === "id" || col.primaryKey === true) continue; // engine assigns the PK
1491
+ const name = col.name.toLowerCase();
1492
+ const type = col.type.toLowerCase();
1493
+ const rule = rules.find(([matches]) => matches(name, type));
1494
+ fieldMap[col.name] = rule ? rule[1] : sentence;
1495
+ }
1496
+ return fieldMap;
1497
+ }
1498
+
1499
+ const handleSeed: RouteHandler = async (req, res) => {
1500
+ const url = new URL(req.url ?? "/", "http://localhost");
1501
+ const { table, count, seed, clear, strict } = parseSeedRequest(url, (req as any).body);
1444
1502
  if (!table) {
1445
1503
  res.json({ error: "Missing table parameter" });
1446
1504
  return;
@@ -1456,28 +1514,10 @@ const handleSeed: RouteHandler = async (req, res) => {
1456
1514
  res.json({ error: `Table '${table}' not found or has no columns` });
1457
1515
  return;
1458
1516
  }
1459
- // Build a field map based on column info (skip auto-increment/id PKs).
1460
- const fieldMap: Record<string, () => unknown> = {};
1461
- for (const col of columns) {
1462
- const name = col.name.toLowerCase();
1463
- const type = col.type.toLowerCase();
1464
- if (name === "id" || (col as any).primaryKey === true) continue; // skip primary key
1465
- if (name.includes("email")) { fieldMap[col.name] = () => fake.email(); }
1466
- else if (name.includes("name")) { fieldMap[col.name] = () => fake.name(); }
1467
- else if (name.includes("phone")) { fieldMap[col.name] = () => fake.phone(); }
1468
- else if (name.includes("address") || name.includes("city") || name.includes("country")) { fieldMap[col.name] = () => fake.address(); }
1469
- else if (name.includes("url") || name.includes("website")) { fieldMap[col.name] = () => fake.url(); }
1470
- else if (type.includes("int")) { fieldMap[col.name] = () => fake.integer(1, 1000); }
1471
- else if (type.includes("real") || type.includes("float") || type.includes("double") || type.includes("numeric") || type.includes("decimal")) { fieldMap[col.name] = () => fake.numeric(0, 1000, 2); }
1472
- else if (type.includes("bool")) { fieldMap[col.name] = () => fake.boolean(); }
1473
- else if (type.includes("date") || type.includes("time")) { fieldMap[col.name] = () => fake.date(); }
1474
- else { fieldMap[col.name] = () => fake.sentence(3); }
1475
- }
1476
- // P1 — delegate to the shared seedTable so each row is wrapped (no
1477
- // unhandled failure can crash the endpoint) and we get a summary back.
1478
- // SEED-TABLE-SEED-INERT: seedTable no longer takes opts.seed (it throws
1479
- // if supplied) — reproducibility already comes from the seeded `fake`
1480
- // built above and closed over by every entry in fieldMap.
1517
+ // SEED-TABLE-SEED-INERT: seedTable no longer takes opts.seed; reproducibility
1518
+ // comes from the seeded `fake` closed over by every fieldMap entry. P1 —
1519
+ // seedTable wraps each row so no row failure can crash the endpoint.
1520
+ const fieldMap = buildSeedFieldMap(columns as SeedColumn[], fake as unknown as SeedFake);
1481
1521
  const summary = await seedTable(db, table, count, fieldMap, undefined, { clear, strict });
1482
1522
  res.json({ seeded: summary.seeded, failed: summary.failed, errors: summary.errors, table });
1483
1523
  } catch (e) {
@@ -1635,7 +1675,7 @@ const handleTool: RouteHandler = (req, res) => {
1635
1675
  * 4. Fallback `http://127.0.0.1:9145` — matches standalone `tina4 agent`.
1636
1676
  */
1637
1677
  export function supervisorBaseUrl(): string {
1638
- const explicit = (process.env.TINA4_SUPERVISOR_URL ?? "").replace(/\/+$/, "");
1678
+ const explicit = rtrimSlashes(process.env.TINA4_SUPERVISOR_URL ?? "");
1639
1679
  if (explicit) return explicit;
1640
1680
  const agentPort = (process.env.TINA4_AGENT_PORT ?? "").trim();
1641
1681
  if (/^\d+$/.test(agentPort)) return `http://127.0.0.1:${parseInt(agentPort, 10)}`;
@@ -1654,6 +1694,56 @@ export function supervisorBaseUrl(): string {
1654
1694
  * run completes. When the agent is unreachable we respond with 503 and a
1655
1695
  * hint so the SPA can show a useful error.
1656
1696
  */
1697
+ /**
1698
+ * Build the proxied request body for a body-bearing method (POST/PUT/PATCH/
1699
+ * DELETE); other methods and a null/non-serialisable body carry none. A string
1700
+ * body passes through; an object is JSON-encoded, and a bare `/execute`
1701
+ * `plan_file` filename is rewritten to a project-relative `plan/` path (matches
1702
+ * the Python dev_admin's SPA→agent convention).
1703
+ */
1704
+ function buildSupervisorBody(method: string, body: unknown): string | undefined {
1705
+ if (method !== "POST" && method !== "PUT" && method !== "PATCH" && method !== "DELETE") return undefined;
1706
+ if (body === undefined || body === null) return undefined;
1707
+ if (typeof body === "string") return body;
1708
+ if (typeof body !== "object") return undefined;
1709
+ let outBody: any = body;
1710
+ if (!Array.isArray(body)) {
1711
+ const pf = (body as any).plan_file;
1712
+ if (typeof pf === "string" && pf && !pf.includes("/")) outBody = { ...body, plan_file: `plan/${pf}` };
1713
+ }
1714
+ return JSON.stringify(outBody);
1715
+ }
1716
+
1717
+ /**
1718
+ * Relay an upstream text/event-stream to the client chunk by chunk as it
1719
+ * arrives, always clearing the request timer and ending the response when the
1720
+ * stream finishes (or the upstream carried no body).
1721
+ */
1722
+ async function streamEventStream(res: any, upstream: Response, timer: ReturnType<typeof setTimeout>): Promise<void> {
1723
+ res.raw.writeHead(upstream.status || 200, {
1724
+ "Content-Type": upstream.headers.get("content-type") ?? "text/event-stream",
1725
+ "Cache-Control": "no-cache",
1726
+ Connection: "keep-alive",
1727
+ });
1728
+ if (typeof (res.raw as any).flushHeaders === "function") (res.raw as any).flushHeaders();
1729
+ if (!upstream.body) {
1730
+ res.raw.end();
1731
+ clearTimeout(timer);
1732
+ return;
1733
+ }
1734
+ const reader = upstream.body.getReader();
1735
+ try {
1736
+ while (true) {
1737
+ const { done, value } = await reader.read();
1738
+ if (done) break;
1739
+ if (value) res.raw.write(Buffer.from(value));
1740
+ }
1741
+ } finally {
1742
+ clearTimeout(timer);
1743
+ res.raw.end();
1744
+ }
1745
+ }
1746
+
1657
1747
  async function proxyToSupervisor(
1658
1748
  req: any,
1659
1749
  res: any,
@@ -1670,29 +1760,7 @@ async function proxyToSupervisor(
1670
1760
  const target = `${base}${downstreamPath}${qs}`;
1671
1761
 
1672
1762
  const method = (req.method ?? "GET").toUpperCase();
1673
-
1674
- // Build the body for methods that carry one
1675
- let bodyText: string | undefined;
1676
- if (method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE") {
1677
- const body = (req as any).body;
1678
- if (body !== undefined && body !== null) {
1679
- if (typeof body === "string") {
1680
- bodyText = body;
1681
- } else if (typeof body === "object") {
1682
- // SPA→agent convention fixup (matches Python): `/execute` sends
1683
- // plan_file as a bare filename but the rust agent expects a
1684
- // project-relative path. Prepend `plan/` when no slash is present.
1685
- let outBody: any = body;
1686
- if (!Array.isArray(body)) {
1687
- const pf = (body as any).plan_file;
1688
- if (typeof pf === "string" && pf && !pf.includes("/")) {
1689
- outBody = { ...body, plan_file: `plan/${pf}` };
1690
- }
1691
- }
1692
- bodyText = JSON.stringify(outBody);
1693
- }
1694
- }
1695
- }
1763
+ const bodyText = buildSupervisorBody(method, (req as any).body);
1696
1764
 
1697
1765
  // Heavy multi-agent endpoints get a generous timeout; metadata-only
1698
1766
  // /supervise/* and /threads/* calls return fast.
@@ -1725,30 +1793,7 @@ async function proxyToSupervisor(
1725
1793
 
1726
1794
  // SSE / event-stream — stream chunks through as they arrive.
1727
1795
  if (ct.includes("text/event-stream")) {
1728
- res.raw.writeHead(upstream.status || 200, {
1729
- "Content-Type": upstream.headers.get("content-type") ?? "text/event-stream",
1730
- "Cache-Control": "no-cache",
1731
- Connection: "keep-alive",
1732
- });
1733
- if (typeof (res.raw as any).flushHeaders === "function") {
1734
- (res.raw as any).flushHeaders();
1735
- }
1736
- if (!upstream.body) {
1737
- res.raw.end();
1738
- clearTimeout(timer);
1739
- return;
1740
- }
1741
- const reader = upstream.body.getReader();
1742
- try {
1743
- while (true) {
1744
- const { done, value } = await reader.read();
1745
- if (done) break;
1746
- if (value) res.raw.write(Buffer.from(value));
1747
- }
1748
- } finally {
1749
- clearTimeout(timer);
1750
- res.raw.end();
1751
- }
1796
+ await streamEventStream(res, upstream, timer);
1752
1797
  return;
1753
1798
  }
1754
1799
 
@@ -2362,7 +2407,7 @@ async function devGitInfo(root: string): Promise<{ branch: string; gitRoot: stri
2362
2407
  const branch = (git(["rev-parse", "--abbrev-ref", "HEAD"]) ?? "").trim();
2363
2408
  const topRaw = git(["rev-parse", "--show-toplevel"]);
2364
2409
  const gitRoot = topRaw && topRaw.trim() !== ""
2365
- ? topRaw.trim().replace(/\\/g, "/").replace(/\/+$/, "")
2410
+ ? rtrimSlashes(topRaw.trim().replace(/\\/g, "/"))
2366
2411
  : null;
2367
2412
  const status = new Map<string, string>();
2368
2413
  const porcelain = git(["status", "--porcelain", "-uall"]);
@@ -2383,6 +2428,57 @@ async function devGitInfo(root: string): Promise<{ branch: string; gitRoot: stri
2383
2428
  }
2384
2429
  }
2385
2430
 
2431
+ /**
2432
+ * The git_status label for one entry: its own porcelain code if git reported
2433
+ * one, else a directory inherits the status of any dirty child, else "clean".
2434
+ */
2435
+ function devGitLabel(gitPath: string, isDir: boolean, gitStatus: Map<string, string>): string {
2436
+ const code = gitStatus.get(gitPath);
2437
+ if (code !== undefined) return devGitStatusLabel(code);
2438
+ if (isDir) {
2439
+ const prefix = gitPath + "/"; // propagate dirty status from any child
2440
+ for (const [gf, gc] of gitStatus) {
2441
+ if (gf.startsWith(prefix)) return gc === "??" ? "untracked" : "modified";
2442
+ }
2443
+ }
2444
+ return "clean";
2445
+ }
2446
+
2447
+ /** True when a directory holds at least one entry the listing would show. */
2448
+ function devDirHasVisibleChild(full: string): boolean {
2449
+ try {
2450
+ for (const c of readdirSync(full)) {
2451
+ if (!devFilesHidden(c)) return true;
2452
+ }
2453
+ } catch { /* unreadable dir */ }
2454
+ return false;
2455
+ }
2456
+
2457
+ /**
2458
+ * One file/dir entry in the dev-admin tree payload: size for a file,
2459
+ * has_children for a directory, git_status for both. An unreadable entry is
2460
+ * reported as a zero-size file rather than dropped.
2461
+ */
2462
+ function buildFileEntry(
2463
+ name: string, full: string, entryRel: string, cwdInGit: string, gitStatus: Map<string, string>,
2464
+ ): Record<string, unknown> {
2465
+ let isDir = false;
2466
+ let size: number | null = null;
2467
+ try {
2468
+ const st = statSync(full);
2469
+ isDir = st.isDirectory();
2470
+ if (!isDir) size = st.size;
2471
+ } catch { /* unreadable entry */ }
2472
+ return {
2473
+ name,
2474
+ path: entryRel,
2475
+ is_dir: isDir,
2476
+ has_children: isDir ? devDirHasVisibleChild(full) : null,
2477
+ git_status: devGitLabel(cwdInGit + entryRel, isDir, gitStatus),
2478
+ size,
2479
+ };
2480
+ }
2481
+
2386
2482
  const handleFiles: RouteHandler = async (req, res) => {
2387
2483
  // Response shape matches tina4-python / tina4-php 1:1 so the dev-admin SPA
2388
2484
  // works against every framework with no branching: each entry carries
@@ -2420,49 +2516,7 @@ const handleFiles: RouteHandler = async (req, res) => {
2420
2516
  // DEVADMIN-DEC-03: never surface secrets in the listing (.env, keys, .git/,
2421
2517
  // secrets/). The .env.example template is safe and stays visible.
2422
2518
  if (isSecretPath(entryRel)) continue;
2423
-
2424
- let isDir = false;
2425
- let size: number | null = null;
2426
- try {
2427
- const st = statSync(full);
2428
- isDir = st.isDirectory();
2429
- if (!isDir) size = st.size;
2430
- } catch { /* unreadable entry */ }
2431
-
2432
- // git status for this entry (same mapping PHP/Python use)
2433
- const gitPath = cwdInGit + entryRel;
2434
- let gitLabel = "clean";
2435
- const code = gitStatus.get(gitPath);
2436
- if (code !== undefined) {
2437
- gitLabel = devGitStatusLabel(code);
2438
- } else if (isDir) {
2439
- const prefix = gitPath + "/"; // propagate dirty status from any child
2440
- for (const [gf, gc] of gitStatus) {
2441
- if (gf.startsWith(prefix)) { gitLabel = gc === "??" ? "untracked" : "modified"; break; }
2442
- }
2443
- }
2444
-
2445
- // has_children: does the dir contain anything visible?
2446
- let hasChildren: boolean | null = null;
2447
- if (isDir) {
2448
- hasChildren = false;
2449
- try {
2450
- for (const c of readdirSync(full)) {
2451
- if (devFilesHidden(c)) continue;
2452
- hasChildren = true;
2453
- break;
2454
- }
2455
- } catch { /* ignore */ }
2456
- }
2457
-
2458
- entries.push({
2459
- name,
2460
- path: entryRel,
2461
- is_dir: isDir,
2462
- has_children: hasChildren,
2463
- git_status: gitLabel,
2464
- size,
2465
- });
2519
+ entries.push(buildFileEntry(name, full, entryRel, cwdInGit, gitStatus));
2466
2520
  }
2467
2521
 
2468
2522
  res.json({ path: relative(root, target).replace(/\\/g, "/") || ".", branch, entries });
@@ -3139,6 +3193,38 @@ const handleDevAdminJs: RouteHandler = async (_req, res) => {
3139
3193
  res.raw.end("tina4-dev-admin.min.js not found");
3140
3194
  };
3141
3195
 
3196
+ // ---------------------------------------------------------------------------
3197
+ // Dev Admin split view — the shared asset (same file python/php/ruby serve)
3198
+ // ---------------------------------------------------------------------------
3199
+
3200
+ const handleDevAdminSplit: RouteHandler = async (_req, res) => {
3201
+ const { readFileSync, existsSync } = await import("node:fs");
3202
+ const { dirname, join, resolve } = await import("node:path");
3203
+ const { fileURLToPath } = await import("node:url");
3204
+ const dir = dirname(fileURLToPath(import.meta.url));
3205
+
3206
+ const candidates = [
3207
+ join(dir, "..", "public", "js", "tina4-dev-admin-split.html"),
3208
+ join(dir, "..", "..", "public", "js", "tina4-dev-admin-split.html"),
3209
+ resolve(process.cwd(), "node_modules", "tina4-nodejs", "packages", "core", "public", "js", "tina4-dev-admin-split.html"),
3210
+ resolve(process.cwd(), "public", "js", "tina4-dev-admin-split.html"),
3211
+ ];
3212
+
3213
+ for (const path of candidates) {
3214
+ if (existsSync(path)) {
3215
+ try {
3216
+ const content = readFileSync(path, "utf-8");
3217
+ res.raw.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-cache" });
3218
+ res.raw.end(content);
3219
+ return;
3220
+ } catch { /* try next */ }
3221
+ }
3222
+ }
3223
+
3224
+ res.raw.writeHead(404, { "Content-Type": "text/plain" });
3225
+ res.raw.end("tina4-dev-admin-split.html not found");
3226
+ };
3227
+
3142
3228
  // ---------------------------------------------------------------------------
3143
3229
  // Overlay script — floating Tina4 button
3144
3230
  // ---------------------------------------------------------------------------
@@ -41,9 +41,11 @@ import { gzipSync } from "node:zlib";
41
41
  import { createHash } from "node:crypto";
42
42
  import type { Tina4Request } from "./types.js";
43
43
  import type { Session as SessionInstance } from "./session.js";
44
+ import { setFormTokenSession } from "./session.js";
44
45
  import { Log } from "./logger.js";
45
46
  import { etagMatches } from "./etag.js";
46
47
 
48
+
47
49
  /**
48
50
  * The prologue, in order. Exported as DATA so the pipeline can be asserted and
49
51
  * compared across frameworks without reading an implementation.
@@ -405,6 +407,8 @@ export async function sessionAutoStart(
405
407
  return;
406
408
  }
407
409
  (req as any).session = sess;
410
+ // form_token() binds each token to this request's session (CSRF).
411
+ setFormTokenSession(sess);
408
412
 
409
413
  const origEnd = rawRes.end.bind(rawRes);
410
414
  // Finalise EXACTLY once. Under strict mode the save below re-throws, and the
@@ -199,12 +199,14 @@ function relativePath(absPath: string, projectRoot: string, frameworkRoots: stri
199
199
  return norm;
200
200
  }
201
201
 
202
- const CAMEL_SPLIT = /([a-z0-9])([A-Z])|([A-Z]+)([A-Z][a-z])/g;
203
-
202
+ // ReDoS-safe camel-split. The old `([A-Z]+)([A-Z][a-z])` let `[A-Z]+` and the
203
+ // following `[A-Z]` both consume uppercase, so a long run of capitals backtracked
204
+ // O(n^2). A zero-width lookahead marks the acronym->word boundary in one pass.
204
205
  function tokenise(text: string): string[] {
205
206
  if (!text) return [];
206
207
  const expanded = text
207
- .replace(CAMEL_SPLIT, (_m, a, b, c, d) => (a ? `${a} ${b}` : `${c} ${d}`))
208
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2") // fooBar -> foo Bar
209
+ .replace(/([A-Z])(?=[A-Z][a-z])/g, "$1 ") // HTTPSConn -> HTTPS Conn
208
210
  .toLowerCase();
209
211
  return expanded
210
212
  .split(/[\s_\-./:,;()\[\]{}\\]+/)
@@ -87,16 +87,25 @@ function parseStack(stack: string): StackFrame[] {
87
87
  const frames: StackFrame[] = [];
88
88
  const lines = stack.split("\n");
89
89
  for (const line of lines) {
90
- // Match: " at functionName (file:line:col)"
91
- let match = line.match(/^\s*at\s+(.+?)\s+\((.+?):(\d+):(\d+)\)/);
92
- if (match) {
93
- frames.push({ func: match[1], file: match[2], line: parseInt(match[3], 10), column: parseInt(match[4], 10) });
94
- continue;
90
+ // ReDoS-safe parse by string ops: a V8 frame is "at <func> (<loc>)" or
91
+ // "at <loc>", and <loc> ends in ":line:col". Splitting on the last " (" and
92
+ // matching ":(\d+):(\d+)$" (digits and ':' are disjoint, anchored at the end)
93
+ // has no ambiguous quantifier for CodeQL to flag, unlike the old /(.+?)\s+\(/.
94
+ const t = line.trim();
95
+ if (!t.startsWith("at ")) continue;
96
+ const rest = t.slice(3);
97
+ let func = "{anonymous}";
98
+ let loc = rest;
99
+ if (rest.endsWith(")")) {
100
+ const open = rest.lastIndexOf(" (");
101
+ if (open >= 0) {
102
+ func = rest.slice(0, open);
103
+ loc = rest.slice(open + 2, -1);
104
+ }
95
105
  }
96
- // Match: " at file:line:col"
97
- match = line.match(/^\s*at\s+(.+?):(\d+):(\d+)/);
98
- if (match) {
99
- frames.push({ func: "{anonymous}", file: match[1], line: parseInt(match[2], 10), column: parseInt(match[3], 10) });
106
+ const lc = loc.match(/:(\d+):(\d+)$/);
107
+ if (lc && lc.index !== undefined) {
108
+ frames.push({ func, file: loc.slice(0, lc.index), line: parseInt(lc[1], 10), column: parseInt(lc[2], 10) });
100
109
  }
101
110
  }
102
111
  return frames;
@@ -31,6 +31,7 @@ export { discoverRoutes } from "./routeDiscovery.js";
31
31
  export { MiddlewareChain, MiddlewareRunner, cors, requestLogger, CorsMiddleware, RateLimiterMiddleware, RequestLogger, SecurityHeadersMiddleware, CsrfMiddleware, attachCsrfFromEnv } from "./middleware.js";
32
32
  export type { CorsConfig } from "./middleware.js";
33
33
  export { createRequest, makeCaseInsensitiveHeaders, parseMultipart, saveUpload } from "./request.js";
34
+ export type { FormValue } from "./request.js";
34
35
  export {
35
36
  createResponse, errorResponse, setDefaultTemplatesDir, getFrond, setFrond, getFrameworkFrond,
36
37
  acceptPrefersJson, wantsJson, negotiatedErrorBody,
@@ -95,7 +96,7 @@ export type { ApiResult, ApiOptions, ApiTransport, DownloadResult, UploadOptions
95
96
  export { Context, defaultContext, existingContext, fts5Supported, _sharedContexts } from "./context/index.js";
96
97
  export type { SearchHit } from "./context/index.js";
97
98
  export { Events } from "./events.js";
98
- export { DevAdmin, MessageLog, RequestInspector, ErrorTracker, DevMailboxStore, DevQueue, WsTracker, supervisorBaseUrl, devAdminLanguage } from "./devAdmin.js";
99
+ export { DevAdmin, MessageLog, RequestInspector, ErrorTracker, DevMailboxStore, DevQueue, WsTracker, supervisorBaseUrl, devAdminLanguage, parseSeedRequest, buildSeedFieldMap } from "./devAdmin.js";
99
100
  export {
100
101
  feedbackEnabled,
101
102
  feedbackWhitelist,