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.
- package/CLAUDE.md +2 -2
- package/package.json +5 -9
- package/packages/cli/dist/bin.js +1074 -521
- package/packages/core/dist/index.js +1071 -516
- package/packages/core/public/bundled-component-provenance.json +2 -2
- package/packages/core/public/js/tina4-dev-admin-split.html +202 -0
- package/packages/core/public/js/tina4-dev-admin.min.js +147 -138
- 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 +209 -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/metrics.ts +5 -1
- package/packages/core/src/middleware.ts +8 -1
- package/packages/core/src/optionalPackage.ts +2 -2
- package/packages/core/src/plan.ts +4 -4
- 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 +1055 -502
- 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";
|
|
@@ -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
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
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") ??
|
|
1443
|
-
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);
|
|
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
|
-
//
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
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 ?? "")
|
|
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
|
|
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, "/")
|
|
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
|
-
|
|
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,
|