tina4-nodejs 3.13.150 → 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 (56) hide show
  1. package/CLAUDE.md +2 -2
  2. package/package.json +5 -9
  3. package/packages/cli/dist/bin.js +1045 -518
  4. package/packages/core/dist/index.js +1042 -513
  5. package/packages/core/src/aiClient.ts +2 -1
  6. package/packages/core/src/api.ts +2 -1
  7. package/packages/core/src/auth.ts +1 -1
  8. package/packages/core/src/cache.ts +6 -1
  9. package/packages/core/src/devAdmin.ts +176 -123
  10. package/packages/core/src/dispatchPipeline.ts +4 -0
  11. package/packages/core/src/docs.ts +5 -3
  12. package/packages/core/src/errorOverlay.ts +18 -9
  13. package/packages/core/src/index.ts +2 -1
  14. package/packages/core/src/mcp.ts +53 -16
  15. package/packages/core/src/messenger.ts +22 -30
  16. package/packages/core/src/middleware.ts +8 -1
  17. package/packages/core/src/optionalPackage.ts +2 -2
  18. package/packages/core/src/plan.ts +3 -3
  19. package/packages/core/src/push.ts +1 -1
  20. package/packages/core/src/request.ts +185 -25
  21. package/packages/core/src/router.ts +3 -2
  22. package/packages/core/src/server.ts +32 -10
  23. package/packages/core/src/session.ts +44 -0
  24. package/packages/core/src/slashTrim.ts +39 -0
  25. package/packages/core/src/wsdl.ts +66 -48
  26. package/packages/core/templates/crud/form.twig +5 -5
  27. package/packages/core/templates/crud/table.twig +2 -2
  28. package/packages/core/templates/errors/302.twig +4 -3
  29. package/packages/frond/dist/index.js +206 -64
  30. package/packages/frond/src/engine.ts +278 -69
  31. package/packages/frond/src/index.ts +1 -1
  32. package/packages/orm/dist/index.js +1026 -499
  33. package/packages/orm/src/crudRender.ts +5 -5
  34. package/packages/orm/src/crudTable.ts +1 -1
  35. package/packages/orm/src/database.ts +15 -29
  36. package/packages/orm/src/databaseResult.ts +11 -1
  37. package/packages/orm/src/realtime/index.ts +1 -0
  38. package/packages/orm/src/realtime/realtime.ts +2 -1
  39. package/packages/orm/src/realtime/s3Client.ts +276 -0
  40. package/packages/orm/src/realtime/storage.ts +19 -60
  41. package/packages/orm/src/sqlTranslator.ts +58 -42
  42. package/packages/swagger/dist/index.js +25 -4
  43. package/packages/swagger/src/generator.ts +7 -4
  44. package/packages/swagger/src/ui.ts +2 -1
  45. package/types/core/src/devAdmin.d.ts +40 -0
  46. package/types/core/src/index.d.ts +2 -1
  47. package/types/core/src/middleware.d.ts +1 -0
  48. package/types/core/src/optionalPackage.d.ts +2 -2
  49. package/types/core/src/request.d.ts +5 -1
  50. package/types/core/src/session.d.ts +25 -0
  51. package/types/core/src/slashTrim.d.ts +6 -0
  52. package/types/frond/src/engine.d.ts +11 -0
  53. package/types/frond/src/index.d.ts +1 -1
  54. package/types/orm/src/realtime/index.d.ts +1 -0
  55. package/types/orm/src/realtime/s3Client.d.ts +48 -0
  56. 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";
@@ -1430,18 +1431,74 @@ const handleTables: RouteHandler = async (_req, res) => {
1430
1431
  }
1431
1432
  };
1432
1433
 
1433
- const handleSeed: RouteHandler = async (req, res) => {
1434
- const url = new URL(req.url ?? "/", "http://localhost");
1435
- const body = (req as any).body ?? {};
1436
- const table = url.searchParams.get("table") ?? body?.table ?? "";
1437
- const count = parseInt(String(url.searchParams.get("count") ?? body?.count ?? "10"), 10) || 10;
1438
- // P4b — accept seed/clear/strict; drop the previous hard-coded behaviour.
1439
- 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;
1440
1456
  const seed = seedRaw !== undefined && seedRaw !== null && String(seedRaw) !== ""
1441
1457
  ? (Number.isNaN(parseInt(String(seedRaw), 10)) ? undefined : parseInt(String(seedRaw), 10))
1442
1458
  : undefined;
1443
- const clear = String(url.searchParams.get("clear") ?? body?.clear ?? "") === "true" || body?.clear === true;
1444
- 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);
1445
1502
  if (!table) {
1446
1503
  res.json({ error: "Missing table parameter" });
1447
1504
  return;
@@ -1457,28 +1514,10 @@ const handleSeed: RouteHandler = async (req, res) => {
1457
1514
  res.json({ error: `Table '${table}' not found or has no columns` });
1458
1515
  return;
1459
1516
  }
1460
- // Build a field map based on column info (skip auto-increment/id PKs).
1461
- const fieldMap: Record<string, () => unknown> = {};
1462
- for (const col of columns) {
1463
- const name = col.name.toLowerCase();
1464
- const type = col.type.toLowerCase();
1465
- if (name === "id" || (col as any).primaryKey === true) continue; // skip primary key
1466
- if (name.includes("email")) { fieldMap[col.name] = () => fake.email(); }
1467
- else if (name.includes("name")) { fieldMap[col.name] = () => fake.name(); }
1468
- else if (name.includes("phone")) { fieldMap[col.name] = () => fake.phone(); }
1469
- else if (name.includes("address") || name.includes("city") || name.includes("country")) { fieldMap[col.name] = () => fake.address(); }
1470
- else if (name.includes("url") || name.includes("website")) { fieldMap[col.name] = () => fake.url(); }
1471
- else if (type.includes("int")) { fieldMap[col.name] = () => fake.integer(1, 1000); }
1472
- 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); }
1473
- else if (type.includes("bool")) { fieldMap[col.name] = () => fake.boolean(); }
1474
- else if (type.includes("date") || type.includes("time")) { fieldMap[col.name] = () => fake.date(); }
1475
- else { fieldMap[col.name] = () => fake.sentence(3); }
1476
- }
1477
- // P1 — delegate to the shared seedTable so each row is wrapped (no
1478
- // unhandled failure can crash the endpoint) and we get a summary back.
1479
- // SEED-TABLE-SEED-INERT: seedTable no longer takes opts.seed (it throws
1480
- // if supplied) — reproducibility already comes from the seeded `fake`
1481
- // 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);
1482
1521
  const summary = await seedTable(db, table, count, fieldMap, undefined, { clear, strict });
1483
1522
  res.json({ seeded: summary.seeded, failed: summary.failed, errors: summary.errors, table });
1484
1523
  } catch (e) {
@@ -1636,7 +1675,7 @@ const handleTool: RouteHandler = (req, res) => {
1636
1675
  * 4. Fallback `http://127.0.0.1:9145` — matches standalone `tina4 agent`.
1637
1676
  */
1638
1677
  export function supervisorBaseUrl(): string {
1639
- const explicit = (process.env.TINA4_SUPERVISOR_URL ?? "").replace(/\/+$/, "");
1678
+ const explicit = rtrimSlashes(process.env.TINA4_SUPERVISOR_URL ?? "");
1640
1679
  if (explicit) return explicit;
1641
1680
  const agentPort = (process.env.TINA4_AGENT_PORT ?? "").trim();
1642
1681
  if (/^\d+$/.test(agentPort)) return `http://127.0.0.1:${parseInt(agentPort, 10)}`;
@@ -1655,6 +1694,56 @@ export function supervisorBaseUrl(): string {
1655
1694
  * run completes. When the agent is unreachable we respond with 503 and a
1656
1695
  * hint so the SPA can show a useful error.
1657
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
+
1658
1747
  async function proxyToSupervisor(
1659
1748
  req: any,
1660
1749
  res: any,
@@ -1671,29 +1760,7 @@ async function proxyToSupervisor(
1671
1760
  const target = `${base}${downstreamPath}${qs}`;
1672
1761
 
1673
1762
  const method = (req.method ?? "GET").toUpperCase();
1674
-
1675
- // Build the body for methods that carry one
1676
- let bodyText: string | undefined;
1677
- if (method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE") {
1678
- const body = (req as any).body;
1679
- if (body !== undefined && body !== null) {
1680
- if (typeof body === "string") {
1681
- bodyText = body;
1682
- } else if (typeof body === "object") {
1683
- // SPA→agent convention fixup (matches Python): `/execute` sends
1684
- // plan_file as a bare filename but the rust agent expects a
1685
- // project-relative path. Prepend `plan/` when no slash is present.
1686
- let outBody: any = body;
1687
- if (!Array.isArray(body)) {
1688
- const pf = (body as any).plan_file;
1689
- if (typeof pf === "string" && pf && !pf.includes("/")) {
1690
- outBody = { ...body, plan_file: `plan/${pf}` };
1691
- }
1692
- }
1693
- bodyText = JSON.stringify(outBody);
1694
- }
1695
- }
1696
- }
1763
+ const bodyText = buildSupervisorBody(method, (req as any).body);
1697
1764
 
1698
1765
  // Heavy multi-agent endpoints get a generous timeout; metadata-only
1699
1766
  // /supervise/* and /threads/* calls return fast.
@@ -1726,30 +1793,7 @@ async function proxyToSupervisor(
1726
1793
 
1727
1794
  // SSE / event-stream — stream chunks through as they arrive.
1728
1795
  if (ct.includes("text/event-stream")) {
1729
- res.raw.writeHead(upstream.status || 200, {
1730
- "Content-Type": upstream.headers.get("content-type") ?? "text/event-stream",
1731
- "Cache-Control": "no-cache",
1732
- Connection: "keep-alive",
1733
- });
1734
- if (typeof (res.raw as any).flushHeaders === "function") {
1735
- (res.raw as any).flushHeaders();
1736
- }
1737
- if (!upstream.body) {
1738
- res.raw.end();
1739
- clearTimeout(timer);
1740
- return;
1741
- }
1742
- const reader = upstream.body.getReader();
1743
- try {
1744
- while (true) {
1745
- const { done, value } = await reader.read();
1746
- if (done) break;
1747
- if (value) res.raw.write(Buffer.from(value));
1748
- }
1749
- } finally {
1750
- clearTimeout(timer);
1751
- res.raw.end();
1752
- }
1796
+ await streamEventStream(res, upstream, timer);
1753
1797
  return;
1754
1798
  }
1755
1799
 
@@ -2363,7 +2407,7 @@ async function devGitInfo(root: string): Promise<{ branch: string; gitRoot: stri
2363
2407
  const branch = (git(["rev-parse", "--abbrev-ref", "HEAD"]) ?? "").trim();
2364
2408
  const topRaw = git(["rev-parse", "--show-toplevel"]);
2365
2409
  const gitRoot = topRaw && topRaw.trim() !== ""
2366
- ? topRaw.trim().replace(/\\/g, "/").replace(/\/+$/, "")
2410
+ ? rtrimSlashes(topRaw.trim().replace(/\\/g, "/"))
2367
2411
  : null;
2368
2412
  const status = new Map<string, string>();
2369
2413
  const porcelain = git(["status", "--porcelain", "-uall"]);
@@ -2384,6 +2428,57 @@ async function devGitInfo(root: string): Promise<{ branch: string; gitRoot: stri
2384
2428
  }
2385
2429
  }
2386
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
+
2387
2482
  const handleFiles: RouteHandler = async (req, res) => {
2388
2483
  // Response shape matches tina4-python / tina4-php 1:1 so the dev-admin SPA
2389
2484
  // works against every framework with no branching: each entry carries
@@ -2421,49 +2516,7 @@ const handleFiles: RouteHandler = async (req, res) => {
2421
2516
  // DEVADMIN-DEC-03: never surface secrets in the listing (.env, keys, .git/,
2422
2517
  // secrets/). The .env.example template is safe and stays visible.
2423
2518
  if (isSecretPath(entryRel)) continue;
2424
-
2425
- let isDir = false;
2426
- let size: number | null = null;
2427
- try {
2428
- const st = statSync(full);
2429
- isDir = st.isDirectory();
2430
- if (!isDir) size = st.size;
2431
- } catch { /* unreadable entry */ }
2432
-
2433
- // git status for this entry (same mapping PHP/Python use)
2434
- const gitPath = cwdInGit + entryRel;
2435
- let gitLabel = "clean";
2436
- const code = gitStatus.get(gitPath);
2437
- if (code !== undefined) {
2438
- gitLabel = devGitStatusLabel(code);
2439
- } else if (isDir) {
2440
- const prefix = gitPath + "/"; // propagate dirty status from any child
2441
- for (const [gf, gc] of gitStatus) {
2442
- if (gf.startsWith(prefix)) { gitLabel = gc === "??" ? "untracked" : "modified"; break; }
2443
- }
2444
- }
2445
-
2446
- // has_children: does the dir contain anything visible?
2447
- let hasChildren: boolean | null = null;
2448
- if (isDir) {
2449
- hasChildren = false;
2450
- try {
2451
- for (const c of readdirSync(full)) {
2452
- if (devFilesHidden(c)) continue;
2453
- hasChildren = true;
2454
- break;
2455
- }
2456
- } catch { /* ignore */ }
2457
- }
2458
-
2459
- entries.push({
2460
- name,
2461
- path: entryRel,
2462
- is_dir: isDir,
2463
- has_children: hasChildren,
2464
- git_status: gitLabel,
2465
- size,
2466
- });
2519
+ entries.push(buildFileEntry(name, full, entryRel, cwdInGit, gitStatus));
2467
2520
  }
2468
2521
 
2469
2522
  res.json({ path: relative(root, target).replace(/\\/g, "/") || ".", branch, entries });
@@ -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,
@@ -23,6 +23,7 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
23
23
  // Built-in dev tools auto-register when TINA4_DEBUG=true and running on localhost.
24
24
 
25
25
  import * as fs from "node:fs";
26
+ import { rtrimSlashes } from "./slashTrim.js";
26
27
  import * as path from "node:path";
27
28
  import * as os from "node:os";
28
29
  import { spawnSync } from "node:child_process";
@@ -356,7 +357,7 @@ export class McpServer {
356
357
  private _sseChannels: Map<string, { buffer: string[]; wake: (() => void) | null }> = new Map();
357
358
 
358
359
  constructor(mcpPath: string, name = "Tina4 MCP", version = "1.0.0") {
359
- this.path = mcpPath.replace(/\/+$/, "");
360
+ this.path = rtrimSlashes(mcpPath);
360
361
  this.name = name;
361
362
  this.version = version;
362
363
  McpServer._instances.push(this);
@@ -865,26 +866,60 @@ function safePath(projectRoot: string, relPath: string): string {
865
866
  if (resolved !== projectRoot && !resolved.startsWith(rootPrefix)) {
866
867
  throw new Error(`Path escapes project directory: ${relPath}`);
867
868
  }
868
- // Belt-and-braces against symlink escapes: if the path exists, canonicalise
869
- // it and re-check containment. A symlink inside the tree pointing outside
870
- // would otherwise slip past the textual check. New paths (parent not yet
871
- // created) have no realpath and rely on the resolve() containment above.
872
- let real: string | null = null;
869
+ // Belt-and-braces against symlink escapes: canonicalise the path and
870
+ // re-check containment. A symlink inside the tree pointing outside would
871
+ // otherwise slip past the textual check.
872
+ let realRoot: string;
873
873
  try {
874
- real = fs.realpathSync(resolved);
874
+ realRoot = fs.realpathSync(projectRoot);
875
875
  } catch {
876
- real = null; // not created yet — textual guard above holds
876
+ return resolved; // a root that no longer exists holds nothing to link through
877
877
  }
878
- if (real !== null) {
879
- const realRoot = fs.realpathSync(projectRoot);
880
- const realPrefix = realRoot.endsWith(path.sep) ? realRoot : realRoot + path.sep;
881
- if (real !== realRoot && !real.startsWith(realPrefix)) {
882
- throw new Error(`Path escapes project directory: ${relPath}`);
883
- }
878
+ const real = realPathOf(resolved);
879
+ const realPrefix = realRoot.endsWith(path.sep) ? realRoot : realRoot + path.sep;
880
+ if (real === null || (real !== realRoot && !real.startsWith(realPrefix))) {
881
+ throw new Error(`Path escapes project directory: ${relPath}`);
884
882
  }
885
883
  return resolved;
886
884
  }
887
885
 
886
+ /**
887
+ * Where a path will land once every symlink in it is followed, including the
888
+ * parts that do not exist yet: a NEW file is created through its deepest
889
+ * existing parent, and a dangling symlink through the place it names. Null when
890
+ * that cannot be worked out (a link loop, an unreadable link).
891
+ */
892
+ function realPathOf(p: string, hops = 0): string | null {
893
+ let probe = p;
894
+ while (!fs.existsSync(probe) && !isSymlink(probe) && path.dirname(probe) !== probe) {
895
+ probe = path.dirname(probe);
896
+ }
897
+ try {
898
+ // .native: the JS realpathSync collapses "link/.." as text first.
899
+ return fs.realpathSync.native(probe);
900
+ } catch {
901
+ // A dangling symlink. Its target is joined as text, not normalised, so a
902
+ // ".." after a linked component is resolved the way the OS will follow it.
903
+ if (hops >= 40) return null;
904
+ let target: string;
905
+ try {
906
+ target = fs.readlinkSync(probe);
907
+ } catch {
908
+ return null;
909
+ }
910
+ const base = path.isAbsolute(target) ? target : path.dirname(probe) + path.sep + target;
911
+ return realPathOf(base + p.slice(probe.length), hops + 1);
912
+ }
913
+ }
914
+
915
+ function isSymlink(p: string): boolean {
916
+ try {
917
+ return fs.lstatSync(p).isSymbolicLink();
918
+ } catch {
919
+ return false;
920
+ }
921
+ }
922
+
888
923
  /**
889
924
  * Redact sensitive environment variable values.
890
925
  */
@@ -1958,8 +1993,10 @@ export function registerDevTools(server: McpServer): void {
1958
1993
  // 2. Coder-path normalization.
1959
1994
  rel = normalizeCoderPath(projectRoot, rel);
1960
1995
  // 3. Safe path resolution (sandbox check).
1961
- const resolved = path.resolve(projectRoot, rel);
1962
- if (!resolved.startsWith(projectRoot)) {
1996
+ let resolved: string;
1997
+ try {
1998
+ resolved = safePath(projectRoot, rel);
1999
+ } catch {
1963
2000
  return { error: `Path escapes project directory: ${rel}` };
1964
2001
  }
1965
2002
  // 4. Existence check.