tina4-nodejs 3.13.148 → 3.13.150
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 +4 -3
- package/package.json +1 -1
- package/packages/cli/dist/bin.js +10898 -9844
- package/packages/cli/src/commands/generate.ts +184 -25
- package/packages/core/dist/index.js +10864 -9810
- 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/devAdmin.ts +105 -1
- package/packages/core/src/index.ts +3 -0
- package/packages/core/src/messenger.ts +57 -1
- package/packages/core/src/metrics.ts +5 -1
- package/packages/core/src/plan.ts +1 -1
- package/packages/core/src/router.ts +11 -1
- package/packages/core/src/server.ts +5 -2
- package/packages/core/templates/crud/form.twig +26 -0
- package/packages/core/templates/crud/modals.twig +31 -0
- package/packages/core/templates/crud/page.twig +401 -0
- package/packages/core/templates/crud/table.twig +69 -0
- package/packages/frond/dist/index.js +9 -1
- package/packages/frond/src/engine.ts +9 -0
- package/packages/frond/src/index.ts +1 -1
- package/packages/orm/dist/index.js +1283 -219
- package/packages/orm/src/adapters/mongodb.ts +209 -34
- package/packages/orm/src/autoCrud.ts +9 -0
- package/packages/orm/src/crud.ts +273 -0
- package/packages/orm/src/crudHelpers.ts +194 -0
- package/packages/orm/src/crudRender.ts +202 -0
- package/packages/orm/src/crudTable.ts +171 -0
- package/packages/orm/src/index.ts +7 -1
- package/packages/orm/src/migration.ts +167 -4
- package/packages/orm/src/query.ts +99 -40
- package/packages/orm/src/sqlTranslator.ts +8 -1
- package/packages/orm/src/types.ts +19 -0
- package/types/core/src/devAdmin.d.ts +23 -0
- package/types/core/src/messenger.d.ts +4 -0
- package/types/core/src/router.d.ts +0 -5
- package/types/frond/src/engine.d.ts +6 -0
- package/types/frond/src/index.d.ts +1 -1
- package/types/orm/src/crud.d.ts +82 -0
- package/types/orm/src/crudHelpers.d.ts +63 -0
- package/types/orm/src/crudRender.d.ts +38 -0
- package/types/orm/src/crudTable.d.ts +45 -0
- package/types/orm/src/index.d.ts +3 -1
- package/types/orm/src/migration.d.ts +0 -18
- package/types/orm/src/query.d.ts +0 -12
- package/types/orm/src/types.d.ts +19 -0
|
@@ -30,6 +30,7 @@ import { fullAnalysis, fileDetail, MetricsEngineError, metricsScanRoot } from ".
|
|
|
30
30
|
import { registerFeedbackRoutes } from "./feedback.js";
|
|
31
31
|
import { getDefaultDevServer, mcpEnabled, isRequestAllowed, isLoopback } from "./mcp.js";
|
|
32
32
|
import { timingSafeEqual } from "node:crypto";
|
|
33
|
+
import net from "node:net";
|
|
33
34
|
// VERSION-DEC-01 (feature 130): the dashboard reads the SAME resolved version
|
|
34
35
|
// as server.ts's banner/health and mcp.ts's default dev server -- one shared
|
|
35
36
|
// walk-up resolver in ./version.ts, not this file's own two-fixed-path reader
|
|
@@ -55,6 +56,11 @@ export const DEV_SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
|
|
55
56
|
*/
|
|
56
57
|
const DEV_MCP_PREFIXES = ["/__dev/api/mcp", "/__dev/mcp"];
|
|
57
58
|
|
|
59
|
+
// Static dev-toolbar assets that carry no secrets and expose no actions, so the
|
|
60
|
+
// peer gate does not cover them: wherever the toolbar is injected its stylesheet
|
|
61
|
+
// and script must load (issue #279). Host + same-origin still apply.
|
|
62
|
+
const DEV_PUBLIC_ASSETS = new Set(["/__dev/toolbar.css", "/__dev/toolbar.js"]);
|
|
63
|
+
|
|
58
64
|
/** Private-key / credential basenames the file endpoints must never serve. */
|
|
59
65
|
const DEV_SECRET_BASENAMES = new Set([
|
|
60
66
|
".env", ".envrc", "id_rsa", "id_dsa", "id_ecdsa", "id_ed25519",
|
|
@@ -139,6 +145,65 @@ export function devHostAllowed(hostHeader: string | undefined | null): boolean {
|
|
|
139
145
|
return allowed.has(name);
|
|
140
146
|
}
|
|
141
147
|
|
|
148
|
+
/**
|
|
149
|
+
* True when raw peer `ip` falls inside `entry` (a bare IP or a CIDR). IPv4 and
|
|
150
|
+
* IPv6 via node:net's BlockList; an IPv4-mapped IPv6 peer (::ffff:a.b.c.d) is
|
|
151
|
+
* matched as IPv4. `ip` is the real socket peer, `entry` is operator config -
|
|
152
|
+
* never a forwarded header.
|
|
153
|
+
*/
|
|
154
|
+
export function ipInCidr(ip: string, entry: string): boolean {
|
|
155
|
+
try {
|
|
156
|
+
ip = (ip ?? "").trim();
|
|
157
|
+
if (ip.toLowerCase().startsWith("::ffff:")) ip = ip.slice(7); // mapped -> IPv4
|
|
158
|
+
const ipFam = net.isIP(ip);
|
|
159
|
+
if (!ipFam) return false;
|
|
160
|
+
entry = (entry ?? "").trim();
|
|
161
|
+
const bl = new net.BlockList();
|
|
162
|
+
if (entry.includes("/")) {
|
|
163
|
+
const [addr, bitsStr] = entry.split("/");
|
|
164
|
+
const bits = Number(bitsStr);
|
|
165
|
+
const fam = net.isIP(addr);
|
|
166
|
+
if (!fam || !Number.isInteger(bits)) return false;
|
|
167
|
+
bl.addSubnet(addr, bits, fam === 4 ? "ipv4" : "ipv6");
|
|
168
|
+
} else {
|
|
169
|
+
const fam = net.isIP(entry);
|
|
170
|
+
if (!fam) return false;
|
|
171
|
+
bl.addAddress(entry, fam === 4 ? "ipv4" : "ipv6");
|
|
172
|
+
}
|
|
173
|
+
return bl.check(ip, ipFam === 4 ? "ipv4" : "ipv6");
|
|
174
|
+
} catch {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* True when the RAW socket peer may reach /__dev: loopback always, plus any
|
|
181
|
+
* IP/CIDR in TINA4_DEV_ALLOWED_PEERS (comma-separated, opt-in, default none).
|
|
182
|
+
* Reads the real socket peer ONLY, never a forwarded header, so it cannot be
|
|
183
|
+
* spoofed by X-Forwarded-For. The documented way to reach the dev dashboard from
|
|
184
|
+
* a Docker dev box, where requests arrive from the container-network gateway
|
|
185
|
+
* rather than loopback (issue #279).
|
|
186
|
+
*/
|
|
187
|
+
export function devPeerAllowed(remoteIp: string | undefined | null): boolean {
|
|
188
|
+
const ip = (remoteIp ?? "").trim();
|
|
189
|
+
if (isLoopback(ip)) return true;
|
|
190
|
+
const configured = (process.env.TINA4_DEV_ALLOWED_PEERS ?? "").trim();
|
|
191
|
+
if (!configured) return false;
|
|
192
|
+
return configured.split(",").some((entry) => entry.trim() !== "" && ipInCidr(ip, entry));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* True when the toolbar may be injected for this viewer - the Host allow-list
|
|
197
|
+
* and the raw-peer gate both pass. A viewer the /__dev gate would refuse gets NO
|
|
198
|
+
* toolbar markup, so its stylesheet and script are never requested only to 403
|
|
199
|
+
* (issue #279).
|
|
200
|
+
*/
|
|
201
|
+
export function devToolbarAllowed(req: Tina4Request): boolean {
|
|
202
|
+
if (!devHostAllowed(req.header("host"))) return false;
|
|
203
|
+
const peer = (req as unknown as { socket?: { remoteAddress?: string } }).socket?.remoteAddress ?? "";
|
|
204
|
+
return devPeerAllowed(peer);
|
|
205
|
+
}
|
|
206
|
+
|
|
142
207
|
/**
|
|
143
208
|
* Return `{status, error}` to REFUSE any /__dev request (read or write), or
|
|
144
209
|
* `null` to allow (ADR-0082): Host allow-list, then same-origin, then the
|
|
@@ -155,9 +220,15 @@ export function devRequestDenial(req: Tina4Request): { status: number; error: st
|
|
|
155
220
|
if (!devSameOriginOk(req)) {
|
|
156
221
|
return { status: 403, error: "dev-admin: refused (cross-origin request)" };
|
|
157
222
|
}
|
|
223
|
+
// The static toolbar assets are exempt from the peer gate (#279): they carry
|
|
224
|
+
// no secrets and the toolbar needs them wherever it is injected. Host +
|
|
225
|
+
// same-origin above still apply.
|
|
226
|
+
if (DEV_PUBLIC_ASSETS.has(path)) {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
158
229
|
if (!isMcp) {
|
|
159
230
|
const peer = (req as unknown as { socket?: { remoteAddress?: string } }).socket?.remoteAddress ?? "";
|
|
160
|
-
if (!(
|
|
231
|
+
if (!(devPeerAllowed(peer) || mcpTokenOk(req, true))) {
|
|
161
232
|
return { status: 403, error: "dev-admin: refused (non-loopback peer)" };
|
|
162
233
|
}
|
|
163
234
|
}
|
|
@@ -759,6 +830,7 @@ export class DevAdmin {
|
|
|
759
830
|
{ method: "GET", pattern: "/__dev/api/docs/.well-known.json", handler: handleDocsWellKnown },
|
|
760
831
|
// JS asset
|
|
761
832
|
{ method: "GET", pattern: "/__dev/js/tina4-dev-admin.min.js", handler: handleDevAdminJs },
|
|
833
|
+
{ method: "GET", pattern: "/__dev/split", handler: handleDevAdminSplit },
|
|
762
834
|
// Dev-toolbar assets — served as external CSS/JS so the injected toolbar is
|
|
763
835
|
// CSP-clean (no inline style=, onclick=, or <script>) under a strict
|
|
764
836
|
// `default-src 'self'`. Parity with PHP's /__dev/toolbar.css + toolbar.js.
|
|
@@ -3068,6 +3140,38 @@ const handleDevAdminJs: RouteHandler = async (_req, res) => {
|
|
|
3068
3140
|
res.raw.end("tina4-dev-admin.min.js not found");
|
|
3069
3141
|
};
|
|
3070
3142
|
|
|
3143
|
+
// ---------------------------------------------------------------------------
|
|
3144
|
+
// Dev Admin split view — the shared asset (same file python/php/ruby serve)
|
|
3145
|
+
// ---------------------------------------------------------------------------
|
|
3146
|
+
|
|
3147
|
+
const handleDevAdminSplit: RouteHandler = async (_req, res) => {
|
|
3148
|
+
const { readFileSync, existsSync } = await import("node:fs");
|
|
3149
|
+
const { dirname, join, resolve } = await import("node:path");
|
|
3150
|
+
const { fileURLToPath } = await import("node:url");
|
|
3151
|
+
const dir = dirname(fileURLToPath(import.meta.url));
|
|
3152
|
+
|
|
3153
|
+
const candidates = [
|
|
3154
|
+
join(dir, "..", "public", "js", "tina4-dev-admin-split.html"),
|
|
3155
|
+
join(dir, "..", "..", "public", "js", "tina4-dev-admin-split.html"),
|
|
3156
|
+
resolve(process.cwd(), "node_modules", "tina4-nodejs", "packages", "core", "public", "js", "tina4-dev-admin-split.html"),
|
|
3157
|
+
resolve(process.cwd(), "public", "js", "tina4-dev-admin-split.html"),
|
|
3158
|
+
];
|
|
3159
|
+
|
|
3160
|
+
for (const path of candidates) {
|
|
3161
|
+
if (existsSync(path)) {
|
|
3162
|
+
try {
|
|
3163
|
+
const content = readFileSync(path, "utf-8");
|
|
3164
|
+
res.raw.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-cache" });
|
|
3165
|
+
res.raw.end(content);
|
|
3166
|
+
return;
|
|
3167
|
+
} catch { /* try next */ }
|
|
3168
|
+
}
|
|
3169
|
+
}
|
|
3170
|
+
|
|
3171
|
+
res.raw.writeHead(404, { "Content-Type": "text/plain" });
|
|
3172
|
+
res.raw.end("tina4-dev-admin-split.html not found");
|
|
3173
|
+
};
|
|
3174
|
+
|
|
3071
3175
|
// ---------------------------------------------------------------------------
|
|
3072
3176
|
// Overlay script — floating Tina4 button
|
|
3073
3177
|
// ---------------------------------------------------------------------------
|
|
@@ -113,6 +113,9 @@ export { DevMailbox } from "./devMailbox.js";
|
|
|
113
113
|
export { WSDLService, WSDLOperation } from "./wsdl.js";
|
|
114
114
|
export type { WSDLOperationMeta } from "./wsdl.js";
|
|
115
115
|
export { HtmlElement, htmlElement, addHtmlHelpers, Raw, SafeString } from "./htmlElement.js";
|
|
116
|
+
// Crud (ADR-0094) lives in @tina4/orm, not core: it is an opt-in feature, not on
|
|
117
|
+
// every request path, so it must not enter core's eager import graph
|
|
118
|
+
// (lazyFeatureLoading). Import it from "tina4-nodejs/orm".
|
|
116
119
|
export { renderErrorOverlay, isDebugMode } from "./errorOverlay.js";
|
|
117
120
|
export {
|
|
118
121
|
cspNonce,
|
|
@@ -70,6 +70,52 @@ type SmtpTransport = "implicit_tls" | "starttls" | "plain";
|
|
|
70
70
|
const SMTP_SEND_TIMEOUT_MS = 30_000;
|
|
71
71
|
const SMTP_CHECK_TIMEOUT_MS = 10_000;
|
|
72
72
|
|
|
73
|
+
/** Raw TINA4_MAIL_TIMEOUT values already warned about, so a bad one is said once. */
|
|
74
|
+
const _timeoutWarned = new Set<string>();
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Resolve the SMTP send timeout in MILLISECONDS (issue #278). Priority:
|
|
78
|
+
* constructor (seconds) > TINA4_MAIL_TIMEOUT (seconds) > 30. Whole seconds, at
|
|
79
|
+
* least 1. An explicit constructor value is the caller's own instruction, so one
|
|
80
|
+
* that is not a whole second >= 1 is a programming error and THROWS. A bad env
|
|
81
|
+
* value is a misconfiguration: warn once and use the default. Zero and negative
|
|
82
|
+
* are garbage, not an opt-out. Mirrors the PHP master (Messenger::resolveTimeout,
|
|
83
|
+
* tina4-php#286); the SMTP_TIMEOUT fallback was dropped for parity.
|
|
84
|
+
*/
|
|
85
|
+
const DEFAULT_TIMEOUT_SECONDS = 30;
|
|
86
|
+
|
|
87
|
+
function resolveSendTimeoutMs(optionTimeout?: number): number {
|
|
88
|
+
if (optionTimeout === undefined || optionTimeout === null) {
|
|
89
|
+
return resolveEnvTimeoutSeconds(process.env.TINA4_MAIL_TIMEOUT) * 1000;
|
|
90
|
+
}
|
|
91
|
+
if (!Number.isInteger(optionTimeout) || optionTimeout < 1) {
|
|
92
|
+
throw new Error(
|
|
93
|
+
`Messenger timeout must be a whole number of seconds and at least 1, got ${optionTimeout}.`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
return optionTimeout * 1000;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** TINA4_MAIL_TIMEOUT in whole seconds; a blank value, or garbage/< 1 (warned once), is the default. */
|
|
100
|
+
function resolveEnvTimeoutSeconds(raw?: string): number {
|
|
101
|
+
if (!raw || raw.trim() === "") return DEFAULT_TIMEOUT_SECONDS;
|
|
102
|
+
const trimmed = raw.trim();
|
|
103
|
+
const seconds = /^\d+$/.test(trimmed) ? Number(trimmed) : NaN;
|
|
104
|
+
if (Number.isInteger(seconds) && seconds >= 1) return seconds;
|
|
105
|
+
warnBadMailTimeout(raw);
|
|
106
|
+
return DEFAULT_TIMEOUT_SECONDS;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Warn once per distinct bad TINA4_MAIL_TIMEOUT value. */
|
|
110
|
+
function warnBadMailTimeout(raw: string): void {
|
|
111
|
+
if (_timeoutWarned.has(raw)) return;
|
|
112
|
+
_timeoutWarned.add(raw);
|
|
113
|
+
Log.warning(
|
|
114
|
+
`TINA4_MAIL_TIMEOUT must be a whole number of seconds and at least 1, got ${JSON.stringify(raw)} ` +
|
|
115
|
+
`- using the default of ${DEFAULT_TIMEOUT_SECONDS} seconds`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
73
119
|
/**
|
|
74
120
|
* Give a socket an idle timeout that fails it with a clear error. Without one a
|
|
75
121
|
* client that waits for a greeting the server never sends (a plaintext client
|
|
@@ -194,6 +240,8 @@ interface MessengerOptions {
|
|
|
194
240
|
imapPass?: string;
|
|
195
241
|
/** IMAP transport security: "tls" (default), "starttls", or "none". */
|
|
196
242
|
imapEncryption?: string;
|
|
243
|
+
/** SMTP send timeout in whole SECONDS, at least 1 (default 30; env TINA4_MAIL_TIMEOUT). */
|
|
244
|
+
timeout?: number;
|
|
197
245
|
}
|
|
198
246
|
|
|
199
247
|
export interface ImapMessage {
|
|
@@ -466,6 +514,8 @@ export class Messenger {
|
|
|
466
514
|
private imapUser: string;
|
|
467
515
|
private imapPass: string;
|
|
468
516
|
private imapEncryption: string;
|
|
517
|
+
/** Resolved SMTP send timeout, in milliseconds (see the constructor). */
|
|
518
|
+
public readonly timeoutMs: number;
|
|
469
519
|
|
|
470
520
|
constructor(options?: MessengerOptions) {
|
|
471
521
|
// Priority: constructor > TINA4_MAIL_* > sensible default.
|
|
@@ -527,6 +577,12 @@ export class Messenger {
|
|
|
527
577
|
this.imapEncryption = normaliseEncryption(options?.imapEncryption
|
|
528
578
|
?? process.env.TINA4_MAIL_IMAP_ENCRYPTION
|
|
529
579
|
?? "tls", "IMAP");
|
|
580
|
+
|
|
581
|
+
// SMTP send timeout (whole seconds, at least 1): constructor >
|
|
582
|
+
// TINA4_MAIL_TIMEOUT > 30. An explicit sub-second value throws; a bad env
|
|
583
|
+
// value warns once and uses the default. Stored as ms, the unit
|
|
584
|
+
// openSmtpSession/failOnIdle want. Parity with the PHP/Python/Ruby master.
|
|
585
|
+
this.timeoutMs = resolveSendTimeoutMs(options?.timeout);
|
|
530
586
|
}
|
|
531
587
|
|
|
532
588
|
/**
|
|
@@ -671,7 +727,7 @@ export class Messenger {
|
|
|
671
727
|
private async sendSmtp(options: SendOptions, recipients: MailRecipients, messageId: string): Promise<SendResult> {
|
|
672
728
|
let socket: SmtpSocket | null = null;
|
|
673
729
|
try {
|
|
674
|
-
socket = await this.openSmtpSession();
|
|
730
|
+
socket = await this.openSmtpSession(this.timeoutMs);
|
|
675
731
|
await this.authenticateSmtp(socket);
|
|
676
732
|
await this.sendSmtpEnvelope(socket, recipients.allRecipients);
|
|
677
733
|
await this.sendSmtpMessage(socket, options, recipients, messageId);
|
|
@@ -69,7 +69,11 @@ export function enginePath(): string | null {
|
|
|
69
69
|
function runEngine(target: string): Record<string, any> {
|
|
70
70
|
const binary = enginePath();
|
|
71
71
|
if (!binary) throw new MetricsEngineError(`tina4 not found on PATH - ${INSTALL_HINT}`);
|
|
72
|
-
|
|
72
|
+
// --no-history: a dashboard READ must never write .tina4-metrics.json.
|
|
73
|
+
// Without it every scan records history, the file watcher sees the write and
|
|
74
|
+
// reloads the page, which re-scans - a self-inflicted ~2-second reload loop
|
|
75
|
+
// that makes the dashboard unusable.
|
|
76
|
+
const processResult = spawnSync(binary, ["metrics", "--path", target, "--json", "--no-history"], {
|
|
73
77
|
encoding: "utf8",
|
|
74
78
|
timeout: 60_000,
|
|
75
79
|
maxBuffer: 64 * 1024 * 1024,
|
|
@@ -533,7 +533,7 @@ export const Plan = {
|
|
|
533
533
|
const title = currentPlan.title || target;
|
|
534
534
|
const goal = currentPlan.goal || "";
|
|
535
535
|
|
|
536
|
-
const aiUrl = process.env.TINA4_AI_URL || "http://localhost:
|
|
536
|
+
const aiUrl = process.env.TINA4_AI_URL || "http://localhost:11435/api/chat";
|
|
537
537
|
const aiModel = process.env.TINA4_AI_MODEL || "qwen2.5-coder:14b";
|
|
538
538
|
const prompts = fleshPrompt(title, goal, existing, prompt);
|
|
539
539
|
const fetched = await fetchFleshReply(aiUrl, aiModel, prompts);
|
|
@@ -956,8 +956,18 @@ export async function runRouteMiddlewares(
|
|
|
956
956
|
* Default global router instance.
|
|
957
957
|
* Top-level get(), post(), etc. register routes here.
|
|
958
958
|
* The server merges these routes on startup.
|
|
959
|
+
*
|
|
960
|
+
* Anchored on globalThis so it is a true single instance across bundles. The
|
|
961
|
+
* published package ships each workspace as its own esbuild bundle, so
|
|
962
|
+
* @tina4/orm carries an inlined copy of this module; without this, orm code
|
|
963
|
+
* (e.g. Crud.registerBackend, ADR-0094) would add AutoCrud routes to a second,
|
|
964
|
+
* private Router that the core server never serves. One globalThis slot makes
|
|
965
|
+
* every copy resolve to the same instance.
|
|
959
966
|
*/
|
|
960
|
-
|
|
967
|
+
const TINA4_DEFAULT_ROUTER = Symbol.for("tina4.defaultRouter");
|
|
968
|
+
const globalRouterHost = globalThis as unknown as { [TINA4_DEFAULT_ROUTER]?: Router };
|
|
969
|
+
export const defaultRouter: Router =
|
|
970
|
+
globalRouterHost[TINA4_DEFAULT_ROUTER] ?? (globalRouterHost[TINA4_DEFAULT_ROUTER] = new Router());
|
|
961
971
|
|
|
962
972
|
/**
|
|
963
973
|
* Top-level route registration functions — mirrors Python's decorator pattern.
|
|
@@ -46,7 +46,7 @@ import { generateNonce, runWithCspNonce, currentCspNonce } from "./csp.js";
|
|
|
46
46
|
import { createHealthRoutes } from "./health.js";
|
|
47
47
|
import { rateLimiter } from "./rateLimiter.js";
|
|
48
48
|
import { Log } from "./logger.js";
|
|
49
|
-
import { DevAdmin, RequestInspector, WsTracker, devRequestDenial, devHostAllowed } from "./devAdmin.js";
|
|
49
|
+
import { DevAdmin, RequestInspector, WsTracker, devRequestDenial, devHostAllowed, devToolbarAllowed } from "./devAdmin.js";
|
|
50
50
|
import { CLOSE_GOING_AWAY, devReloadWs, serveWebSocketRoute, wsRouteManager } from "./websocket.js";
|
|
51
51
|
import { feedbackEnabled, injectFeedbackWidget } from "./feedback.js";
|
|
52
52
|
import { I18n } from "./i18n.js";
|
|
@@ -1311,7 +1311,10 @@ function isInjectableHtml(res: Tina4Response): boolean {
|
|
|
1311
1311
|
* so calling it unconditionally is cheap when it no-ops.
|
|
1312
1312
|
*/
|
|
1313
1313
|
function injectIntoHtml(ctx: ResponseWrapContext, devToolbar: boolean, html: string): string {
|
|
1314
|
-
|
|
1314
|
+
// Do not inject the toolbar for a viewer the /__dev gate would refuse (#279):
|
|
1315
|
+
// otherwise the page carries toolbar markup whose stylesheet and script 403.
|
|
1316
|
+
// The feedback widget still runs (it re-checks its own gate).
|
|
1317
|
+
if (!devToolbar || !devToolbarAllowed(ctx.req)) return injectFeedbackWidget(ctx.req, html);
|
|
1315
1318
|
|
|
1316
1319
|
const toolbarCtx: DevToolbarContext = {
|
|
1317
1320
|
version: TINA4_VERSION,
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{#
|
|
2
|
+
crud/form.twig — the create/edit field inputs (and the public generateForm).
|
|
3
|
+
|
|
4
|
+
Override: drop your own src/templates/crud/form.twig to change how inputs render.
|
|
5
|
+
|
|
6
|
+
fields - one per input: { id, name, label, value, placeholder, required_attr,
|
|
7
|
+
and exactly ONE control flag: input (with type) | textarea | checkbox
|
|
8
|
+
(checked_attr) | select (options_html) }.
|
|
9
|
+
wrap - render the <form> wrapper; form_id_attr/action/form_method set it
|
|
10
|
+
edit - add the hidden primary-key input (edit modal)
|
|
11
|
+
modal_footer- render the Cancel/Save modal buttons; submit_button - a plain Submit
|
|
12
|
+
|
|
13
|
+
No inline styles or on*= handlers: the modal buttons carry data-crud-action and
|
|
14
|
+
are wired by the page script's delegated listener (ADR-0088).
|
|
15
|
+
#}
|
|
16
|
+
{% if wrap %}<form{{ form_id_attr|raw }} action="{{ action }}" method="{{ form_method }}" class="needs-validation" data-crud-form="1">{% endif %}
|
|
17
|
+
{% if method_override %}<input type="hidden" name="_method" value="{{ method_override }}">{% endif %}
|
|
18
|
+
{% if edit %}<input type="hidden" id="{{ mode }}-pk-value" name="{{ pk }}">{% endif %}
|
|
19
|
+
{% if modal_footer %}<div class="crud-errors" data-crud-errors hidden></div>{% endif %}
|
|
20
|
+
{% for field in fields %}<div class="mb-3">
|
|
21
|
+
<label for="{{ field.id }}" class="form-label">{{ field.label }}</label>
|
|
22
|
+
{% if field.textarea %}<textarea class="form-control" id="{{ field.id }}" name="{{ field.name }}"{{ field.required_attr|raw }}>{{ field.value }}</textarea>{% endif %}{% if field.checkbox %}<div class="form-check"><input class="form-check-input" type="checkbox" id="{{ field.id }}" name="{{ field.name }}"{{ field.checked_attr|raw }}></div>{% endif %}{% if field.select %}<select class="form-select" id="{{ field.id }}" name="{{ field.name }}"{{ field.required_attr|raw }}>{{ field.options_html|raw }}</select>{% endif %}{% if field.input %}<input type="{{ field.type }}" class="form-control" id="{{ field.id }}" name="{{ field.name }}" value="{{ field.value }}" placeholder="{{ field.placeholder }}"{{ field.required_attr|raw }}>{% endif %}
|
|
23
|
+
</div>
|
|
24
|
+
{% endfor %}
|
|
25
|
+
{% if modal_footer %}<div class="modal-footer"><button type="button" class="btn btn-secondary" data-crud-action="close" data-crud-modal="{{ mode }}">Cancel</button><button type="button" class="btn btn-primary" data-crud-action="save" data-crud-mode="{{ mode }}">Save</button></div>{% endif %}{% if submit_button %}<button type="submit" class="btn btn-primary">Submit</button>{% endif %}
|
|
26
|
+
{% if wrap %}</form>{% endif %}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{#
|
|
2
|
+
crud/modals.twig — the create, edit and delete-confirm dialogs.
|
|
3
|
+
|
|
4
|
+
Override: drop your own src/templates/crud/modals.twig to restyle the dialogs.
|
|
5
|
+
create_form / edit_form are the crud/form.twig renders injected here. Buttons
|
|
6
|
+
carry data-crud-action (close / confirm-delete) and are wired by the page
|
|
7
|
+
script — no inline on*= handlers (ADR-0088).
|
|
8
|
+
#}
|
|
9
|
+
<div class="modal-overlay" id="modal-create">
|
|
10
|
+
<div class="modal-box">
|
|
11
|
+
<h3>Create New Record</h3>
|
|
12
|
+
{{ create_form|raw }}
|
|
13
|
+
</div>
|
|
14
|
+
</div>
|
|
15
|
+
<div class="modal-overlay" id="modal-edit">
|
|
16
|
+
<div class="modal-box">
|
|
17
|
+
<h3>Edit Record</h3>
|
|
18
|
+
{{ edit_form|raw }}
|
|
19
|
+
</div>
|
|
20
|
+
</div>
|
|
21
|
+
<div class="modal-overlay" id="modal-delete">
|
|
22
|
+
<div class="modal-box">
|
|
23
|
+
<h3>Confirm Delete</h3>
|
|
24
|
+
<p>Are you sure you want to delete this record? This action cannot be undone.</p>
|
|
25
|
+
<input type="hidden" id="delete-pk-value">
|
|
26
|
+
<div class="modal-footer">
|
|
27
|
+
<button class="btn btn-secondary" data-crud-action="close" data-crud-modal="delete">Cancel</button>
|
|
28
|
+
<button class="btn btn-danger" data-crud-action="confirm-delete">Delete</button>
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|