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.
- package/CLAUDE.md +2 -2
- package/package.json +5 -9
- package/packages/cli/dist/bin.js +1045 -518
- package/packages/core/dist/index.js +1042 -513
- package/packages/core/src/aiClient.ts +2 -1
- package/packages/core/src/api.ts +2 -1
- package/packages/core/src/auth.ts +1 -1
- package/packages/core/src/cache.ts +6 -1
- package/packages/core/src/devAdmin.ts +176 -123
- package/packages/core/src/dispatchPipeline.ts +4 -0
- package/packages/core/src/docs.ts +5 -3
- package/packages/core/src/errorOverlay.ts +18 -9
- package/packages/core/src/index.ts +2 -1
- package/packages/core/src/mcp.ts +53 -16
- package/packages/core/src/messenger.ts +22 -30
- package/packages/core/src/middleware.ts +8 -1
- package/packages/core/src/optionalPackage.ts +2 -2
- package/packages/core/src/plan.ts +3 -3
- package/packages/core/src/push.ts +1 -1
- package/packages/core/src/request.ts +185 -25
- package/packages/core/src/router.ts +3 -2
- package/packages/core/src/server.ts +32 -10
- package/packages/core/src/session.ts +44 -0
- package/packages/core/src/slashTrim.ts +39 -0
- package/packages/core/src/wsdl.ts +66 -48
- package/packages/core/templates/crud/form.twig +5 -5
- package/packages/core/templates/crud/table.twig +2 -2
- package/packages/core/templates/errors/302.twig +4 -3
- package/packages/frond/dist/index.js +206 -64
- package/packages/frond/src/engine.ts +278 -69
- package/packages/frond/src/index.ts +1 -1
- package/packages/orm/dist/index.js +1026 -499
- package/packages/orm/src/crudRender.ts +5 -5
- package/packages/orm/src/crudTable.ts +1 -1
- package/packages/orm/src/database.ts +15 -29
- package/packages/orm/src/databaseResult.ts +11 -1
- package/packages/orm/src/realtime/index.ts +1 -0
- package/packages/orm/src/realtime/realtime.ts +2 -1
- package/packages/orm/src/realtime/s3Client.ts +276 -0
- package/packages/orm/src/realtime/storage.ts +19 -60
- package/packages/orm/src/sqlTranslator.ts +58 -42
- package/packages/swagger/dist/index.js +25 -4
- package/packages/swagger/src/generator.ts +7 -4
- package/packages/swagger/src/ui.ts +2 -1
- package/types/core/src/devAdmin.d.ts +40 -0
- package/types/core/src/index.d.ts +2 -1
- package/types/core/src/middleware.d.ts +1 -0
- package/types/core/src/optionalPackage.d.ts +2 -2
- package/types/core/src/request.d.ts +5 -1
- package/types/core/src/session.d.ts +25 -0
- package/types/core/src/slashTrim.d.ts +6 -0
- package/types/frond/src/engine.d.ts +11 -0
- package/types/frond/src/index.d.ts +1 -1
- package/types/orm/src/realtime/index.d.ts +1 -0
- package/types/orm/src/realtime/s3Client.d.ts +48 -0
- 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
|
|
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
|
package/packages/core/src/api.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
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") ??
|
|
1444
|
-
const strict = String(url.searchParams.get("strict") ??
|
|
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
|
-
//
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
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 ?? "")
|
|
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
|
|
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, "/")
|
|
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
|
-
|
|
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(
|
|
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
|
-
//
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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,
|
package/packages/core/src/mcp.ts
CHANGED
|
@@ -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
|
|
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:
|
|
869
|
-
//
|
|
870
|
-
//
|
|
871
|
-
|
|
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
|
-
|
|
874
|
+
realRoot = fs.realpathSync(projectRoot);
|
|
875
875
|
} catch {
|
|
876
|
-
|
|
876
|
+
return resolved; // a root that no longer exists holds nothing to link through
|
|
877
877
|
}
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
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
|
-
|
|
1962
|
-
|
|
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.
|