tina4-nodejs 3.13.39 → 3.13.40

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 CHANGED
@@ -1,4 +1,4 @@
1
- # CLAUDE.md AI Developer Guide for tina4-nodejs (v3.13.37)
1
+ # CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.40)
2
2
 
3
3
  > This file helps AI assistants (Claude, Copilot, Cursor, etc.) understand and work on this codebase effectively.
4
4
 
@@ -117,6 +117,10 @@ The HTTP foundation. Handles request/response lifecycle, route matching, middlew
117
117
  - `fakeData.ts` — Core fake data generator (names, emails, addresses, UUIDs, etc.)
118
118
  - `constants.ts` — HTTP status codes (`HTTP_OK`, `HTTP_NOT_FOUND`, etc.) and content types (`APPLICATION_JSON`, `TEXT_HTML`, etc.)
119
119
  - `devAdmin.ts` — Dev toolbar (fixed bottom bar injected into HTML pages) and admin dashboard at `/_dev/`
120
+ - `mcp.ts` - Model Context Protocol server (mounted by `devAdmin.ts` at `/__dev/mcp`) for live AI access to project tools. **MCP environment (read by `mcp.ts` / `devAdmin.ts`):**
121
+ - `TINA4_MCP` / `TINA4_DEBUG` - capability gate (whether MCP is enabled at all). Explicit `TINA4_MCP` true/false wins on any host; else `TINA4_DEBUG=true` enables it.
122
+ - `TINA4_MCP_TOKEN` - bearer token authorising a REMOTE MCP request (fallback `TINA4_API_KEY`). Accepted as `Authorization: Bearer`, `X-MCP-Token`, or `X-Api-Key`. With no token configured a remote caller is always denied. Loopback callers never need it.
123
+ - `TINA4_MCP_REMOTE` - set `true` to allow non-loopback MCP callers at all (still requires a valid token).
120
124
  - `auth.ts` — Authentication helpers
121
125
  - `cache.ts` — In-memory caching
122
126
  - `session.ts` — Session management with pluggable handlers. `TINA4_SESSION_SAMESITE` env var (default: Lax)
@@ -234,6 +238,30 @@ db.getError(): string | null
234
238
  db.cacheStats(): { enabled, size, ttl }
235
239
  ```
236
240
 
241
+ ### DocStore — pymongo-style document store (zero-config SQLite fallback)
242
+
243
+ `getCollection(name)` (from `@tina4/orm`) returns a Mongo-style collection. When a Mongo URI is configured it is a real Mongo collection (resolved lazily, returns a Promise); otherwise it is a `SqliteCollection` backed by a local SQLite file (`node:sqlite`, JSON1) and is synchronous. The call sites are identical either way — only the backend differs — so you develop against a zero-dependency local store and switch to MongoDB in production by setting one env var. Because `node:sqlite` is synchronous, `getCollection` is sync in the serverless path and returns a Promise only on the real-Mongo path.
244
+
245
+ ```typescript
246
+ import { getCollection, isServerless, ObjectId } from "@tina4/orm";
247
+
248
+ const orders = getCollection("orders") as any; // SqliteCollection in serverless mode
249
+ const res = orders.insertOne({ customer_id: 1, total: 9.99, status: "new" });
250
+ orders.findOne({ _id: res.insertedId });
251
+ orders.updateOne({ _id: res.insertedId }, { $set: { status: "shipped" } });
252
+ for (const doc of orders.find({ total: { $gt: 5 } }).sort("total", -1).limit(10)) {
253
+ // ...
254
+ }
255
+ orders.countDocuments({ status: "shipped" });
256
+ isServerless(); // true when running on the SQLite fallback
257
+ ```
258
+
259
+ Filter operators: equality, `$in`, `$nin`, `$gt`, `$gte`, `$lt`, `$lte`, `$ne`, `$exists`, `$regex`, implicit AND, `$or`, `$and`, and dotted nested keys (`addr.city`). Updates: `$set`, `$unset`, `$inc`, replace, upsert. Cursors: `sort`, `limit`, `skip`, projection. Values round-trip (Date to/from ISO-8601, `ObjectId` to/from 24-hex) and stay queryable via `json_extract`. Non-goals: aggregation pipelines, `$elemMatch`, geo queries.
260
+
261
+ Selection and configuration:
262
+ - `TINA4_MONGO_URI` — app-wide Mongo URI. Falls back to `TINA4_SESSION_MONGO_URI`, then the legacy `TINA4_SESSION_MONGO_URL`. When one is set, `getCollection` returns a real Mongo collection.
263
+ - `TINA4_DOC_STORE_PATH` — SQLite file for the fallback store (default `data/tina4_docstore.db`).
264
+
237
265
  ### Request extras
238
266
 
239
267
  ```typescript
@@ -258,12 +286,20 @@ queue.consume(topic?, id?, pollInterval=1000): AsyncGenerator<QueueJob>
258
286
  ```
259
287
 
260
288
  ### @tina4/swagger (`packages/swagger/`)
261
- Auto-generates OpenAPI 3.0 docs.
289
+ Auto-generates OpenAPI 3.0.3 docs.
262
290
 
263
291
  **Key files:**
264
292
  - `generator.ts` — Produces OpenAPI spec from route table + model definitions
265
293
  - `ui.ts` — Serves Swagger UI HTML (CDN-based) at `/swagger` and spec at `/swagger/openapi.json`
266
294
 
295
+ **3.13.40 spec behaviour:** ORM models become reusable `components.schemas` entries referenced by `$ref` (no more inlined duplicate shapes); a secured route emits a `bearerAuth` security requirement; the spec is OpenAPI 3.0.3.
296
+
297
+ **Environment (read by `generator.ts` / `ui.ts`):**
298
+ - `TINA4_SWAGGER_ENABLED` - turns the `/swagger` UI + `/swagger/openapi.json` endpoints on/off (`ui.ts`). Explicit `true`/`false` wins; unset falls back to `TINA4_DEBUG`. Set `false` to DISABLE swagger in ANY environment (including dev); set `true` to expose it in production. This is the documented production on/off switch (wired for real in 3.13.40 - previously ignored). **This is how you disable swagger.**
299
+ - `TINA4_SWAGGER_SERVERS` - comma-separated list of server URLs for the OpenAPI `servers[]` block (multi-server / multi-environment). Falls back to `SWAGGER_DEV_URL`, else the framework default.
300
+ - `TINA4_SWAGGER_UI_CDN` - base URL for the Swagger UI assets (`swagger-ui.css` + `swagger-ui-bundle.js`). Defaults to the public CDN (`https://unpkg.com/swagger-ui-dist@5`); point it at a self-hosted mirror for air-gapped deployments.
301
+ - Info block: `TINA4_SWAGGER_TITLE`, `TINA4_SWAGGER_VERSION`, `TINA4_SWAGGER_DESCRIPTION`, `TINA4_SWAGGER_CONTACT_EMAIL`, `TINA4_SWAGGER_CONTACT_TEAM`, `TINA4_SWAGGER_CONTACT_URL`, `TINA4_SWAGGER_LICENSE`.
302
+
267
303
  ### @tina4/frond (`packages/frond/`)
268
304
  Built-in zero-dependency Twig-compatible template engine (the only template engine; there is no `twig` npm dependency).
269
305
 
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
 
5
5
 
6
- "version": "3.13.39",
6
+ "version": "3.13.40",
7
7
 
8
8
  "type": "module",
9
9
  "description": "Tina4 for Node.js/TypeScript \u2014 54 built-in features, zero dependencies",
@@ -29,7 +29,10 @@ import { isTruthy } from "./dotenv.js";
29
29
  /** Actionable blank-secret warning — emitted from both the bootstrap (CI/prod) and the lazy resolvers. */
30
30
  const BLANK_SECRET_WARNING =
31
31
  "Auth: TINA4_SECRET is not set — JWT signing is insecure. Set TINA4_SECRET to a random " +
32
- "value (e.g. `openssl rand -hex 32`) in your environment or .env before serving traffic.";
32
+ "value (e.g. `openssl rand -hex 32`) in your environment or .env before serving traffic. " +
33
+ "For LOCAL DEV, set TINA4_DEBUG=true and a per-machine secret is generated automatically " +
34
+ "into .env.local (gitignored). Seeing this warning means the run was NOT detected as dev — " +
35
+ "typically a container or CI without TINA4_DEBUG set, or TINA4_ENV=production.";
33
36
 
34
37
  /** True when running under CI — the de-facto `CI` env var (set by every major CI). */
35
38
  function _isCi(): boolean {
@@ -14,12 +14,13 @@ import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync, copyFi
14
14
  import { join, dirname, resolve, relative } from "node:path";
15
15
  import { fileURLToPath } from "node:url";
16
16
  import type { Router } from "./router.js";
17
- import type { RouteHandler } from "./types.js";
17
+ import type { RouteHandler, Tina4Request } from "./types.js";
18
18
  import { DevMailbox } from "./devMailbox.js";
19
19
  import { isTruthy } from "./dotenv.js";
20
20
  import { quickMetrics, fullAnalysis, fileDetail } from "./metrics.js";
21
21
  import { registerFeedbackRoutes } from "./feedback.js";
22
- import { getDefaultDevServer, mcpEnabled } from "./mcp.js";
22
+ import { getDefaultDevServer, mcpEnabled, isRequestAllowed } from "./mcp.js";
23
+ import { timingSafeEqual } from "node:crypto";
23
24
 
24
25
  const cpuCount = osCpus().length;
25
26
 
@@ -2151,7 +2152,47 @@ const handleGitStatus: RouteHandler = async (_req, res) => {
2151
2152
  }
2152
2153
  };
2153
2154
 
2154
- const handleMcpTools: RouteHandler = async (_req, res) => {
2155
+ /** Constant-time string compare (length-guarded so timingSafeEqual never throws). */
2156
+ function mcpSecureEqual(expected: string, provided: string): boolean {
2157
+ const a = Buffer.from(expected);
2158
+ const b = Buffer.from(provided);
2159
+ if (a.length !== b.length) return false;
2160
+ return timingSafeEqual(a, b);
2161
+ }
2162
+
2163
+ /**
2164
+ * Whether the request carried a token matching TINA4_MCP_TOKEN (fallback
2165
+ * TINA4_API_KEY). Transports: Authorization Bearer / X-MCP-Token / X-Api-Key.
2166
+ * No configured token ⇒ a remote caller can never present a valid one.
2167
+ */
2168
+ function mcpTokenOk(req: Tina4Request): boolean {
2169
+ let expected = process.env.TINA4_MCP_TOKEN;
2170
+ if (!expected) expected = process.env.TINA4_API_KEY;
2171
+ if (!expected) return false;
2172
+ let provided = "";
2173
+ const auth = req.header("authorization") ?? "";
2174
+ if (auth.toLowerCase().startsWith("bearer ")) provided = auth.slice(7).trim();
2175
+ if (!provided) provided = req.header("x-mcp-token") ?? "";
2176
+ if (!provided) provided = req.header("x-api-key") ?? "";
2177
+ if (!provided) return false;
2178
+ return mcpSecureEqual(expected, provided);
2179
+ }
2180
+
2181
+ /**
2182
+ * Per-request MCP authorisation using the RAW socket peer (never X-Forwarded-For,
2183
+ * which is spoofable). Loopback is always allowed; a remote caller needs
2184
+ * TINA4_MCP_REMOTE=true plus a valid token. Mirrors the Python/PHP/Ruby gate.
2185
+ */
2186
+ function mcpRequestAllowed(req: Tina4Request): boolean {
2187
+ const peer = (req as unknown as { socket?: { remoteAddress?: string } }).socket?.remoteAddress ?? "";
2188
+ return isRequestAllowed(peer, mcpTokenOk(req));
2189
+ }
2190
+
2191
+ const handleMcpTools: RouteHandler = async (req, res) => {
2192
+ if (!mcpRequestAllowed(req)) {
2193
+ res.json({ tools: [], error: "MCP forbidden" }, 404);
2194
+ return;
2195
+ }
2155
2196
  try {
2156
2197
  // Ensure the default /__dev/mcp server exists with its dev tools registered,
2157
2198
  // then enumerate every registered MCP server instance (app-defined servers
@@ -2172,6 +2213,10 @@ const handleMcpTools: RouteHandler = async (_req, res) => {
2172
2213
  };
2173
2214
 
2174
2215
  const handleMcpCall: RouteHandler = async (req, res) => {
2216
+ if (!mcpRequestAllowed(req)) {
2217
+ res.json({ error: "MCP forbidden" }, 404);
2218
+ return;
2219
+ }
2175
2220
  const body = (req.body as Record<string, unknown>) || {};
2176
2221
  const name = (body.name as string) || "";
2177
2222
  const args = (body.arguments as Record<string, unknown>) || {};
@@ -2203,6 +2248,10 @@ const handleMcpCall: RouteHandler = async (req, res) => {
2203
2248
  * clients connect without a token.
2204
2249
  */
2205
2250
  const handleMcpMessage: RouteHandler = async (req, res) => {
2251
+ if (!mcpRequestAllowed(req)) {
2252
+ res.json({ error: "MCP forbidden" }, 404);
2253
+ return;
2254
+ }
2206
2255
  try {
2207
2256
  const { getDefaultDevServer } = await import("./mcp.js");
2208
2257
  const server = getDefaultDevServer();
@@ -2233,6 +2282,10 @@ const handleMcpMessage: RouteHandler = async (req, res) => {
2233
2282
  * the Python v3 fix. Content-Type text/event-stream, status 200.
2234
2283
  */
2235
2284
  const handleMcpSse: RouteHandler = async (req, res) => {
2285
+ if (!mcpRequestAllowed(req)) {
2286
+ res.json({ error: "MCP forbidden" }, 404);
2287
+ return;
2288
+ }
2236
2289
  // req.path is the path only (no query); turn /__dev/mcp/sse into the message
2237
2290
  // endpoint /__dev/mcp/message that the client should POST to.
2238
2291
  const reqPath = req.path || "/__dev/mcp/sse";
@@ -135,7 +135,7 @@ export type {
135
135
  export {
136
136
  McpServer, mcpTool, mcpResource, registerDevTools, getDefaultDevServer,
137
137
  encodeResponse, encodeError, encodeNotification, decodeRequest,
138
- schemaFromParams, isLocalhost, mcpEnabled, mcpPort,
138
+ schemaFromParams, isLocalhost, isLoopback, mcpEnabled, isRequestAllowed, mcpPort,
139
139
  PARSE_ERROR, INVALID_REQUEST, METHOD_NOT_FOUND, INVALID_PARAMS, INTERNAL_ERROR,
140
140
  } from "./mcp.js";
141
141
  export type { JsonRpcMessage, McpToolDefinition, McpResourceDefinition, JsonSchema, McpToolParam } from "./mcp.js";
@@ -187,6 +187,14 @@ export function schemaFromParams(params: McpToolParam[]): JsonSchema {
187
187
 
188
188
  // ── Localhost detection ──────────────────────────────────────
189
189
 
190
+ /**
191
+ * Informational only — whether the CONFIGURED host looks local.
192
+ *
193
+ * NOT the security gate. Reads `TINA4_HOST_NAME` (the configured bind address),
194
+ * which on a 0.0.0.0 bind looks "local" while still accepting remote clients.
195
+ * Trust decisions use {@link isRequestAllowed} with the RAW socket peer instead.
196
+ * Kept for diagnostics / back-compat.
197
+ */
190
198
  export function isLocalhost(): boolean {
191
199
  const hostEnv = process.env.TINA4_HOST_NAME || "localhost:7148";
192
200
  const host = hostEnv.split(":")[0];
@@ -202,35 +210,61 @@ function envTruthy(val: string | undefined): boolean {
202
210
  }
203
211
 
204
212
  /**
205
- * Whether the built-in MCP dev tools / `/__dev/mcp` endpoint should be enabled.
213
+ * Whether an address is a loopback (in-process / same-host) peer.
214
+ *
215
+ * Operates on the RAW socket peer, never X-Forwarded-For. Empty/undefined means
216
+ * an in-process / synthetic request (no socket) and is trusted. The `::ffff:`
217
+ * IPv4-mapped prefix is stripped. NOTE: 0.0.0.0 is a BIND address, never a
218
+ * client address, so it is deliberately NOT loopback.
219
+ *
220
+ * Python master parity: tina4_python.mcp.is_loopback.
221
+ */
222
+ export function isLoopback(ip: string | undefined | null): boolean {
223
+ if (ip == null || ip === "") return true;
224
+ let addr = ip.trim().toLowerCase();
225
+ if (addr.startsWith("::ffff:")) addr = addr.slice(7);
226
+ return addr === "::1" || addr === "localhost" || addr.startsWith("127.");
227
+ }
228
+
229
+ /**
230
+ * Capability gate — whether MCP may run at all.
206
231
  *
207
- * Resolution order (highest priority first), matching Python master
208
- * `tina4_python.mcp.is_enabled()`:
209
- * 1. `TINA4_MCP` explicit on/off override, honoured on ANY host. An
210
- * explicit truthy value opts a remote / debug-disabled deployment in
211
- * (e.g. for a remote AI assistant); an explicit falsy value force-disables
212
- * it everywhere.
213
- * 2. `TINA4_DEBUG=true` — implicit on for dev, but LOCALHOST-ONLY unless
214
- * `TINA4_MCP_REMOTE=true`. The MCP dev tools expose powerful operations
215
- * (DB query, file read/WRITE, route listing), so they never auto-expose on
216
- * a non-localhost host without an explicit opt-in.
232
+ * Pure capability, host-INDEPENDENT (Python master parity):
233
+ * 1. `TINA4_MCP` explicit on/off override (sysadmin, any host).
234
+ * 2. Else `TINA4_DEBUG=true` MCP is a capability of this deployment.
217
235
  * 3. Otherwise off.
218
236
  *
219
- * Wired in v3.13.39: previously this was `TINA4_MCP` else `TINA4_DEBUG`, with
220
- * `isLocalhost()` unused for the gate and `TINA4_MCP_REMOTE` read by zero code
221
- * — so the documented localhost guard was not actually enforced and a
222
- * non-localhost `TINA4_DEBUG=true` deployment auto-exposed the dev tools.
237
+ * This NO LONGER consults the host. A debug box bound to 0.0.0.0 still "has"
238
+ * the capability, but {@link isRequestAllowed} decides whether a given CALLER
239
+ * may use it loopback always, remote only with an explicit opt-in plus a
240
+ * valid token. Splitting capability from per-request authorisation closes the
241
+ * hole where a 0.0.0.0 bind auto-exposed DB/file tools to remote
242
+ * unauthenticated callers (the pre-3.13.40 isLocalhost() treated 0.0.0.0 local).
223
243
  */
224
244
  export function mcpEnabled(): boolean {
225
245
  const explicit = process.env.TINA4_MCP;
226
246
  if (explicit !== undefined && explicit.trim() !== "") {
227
247
  return envTruthy(explicit);
228
248
  }
229
- if (!envTruthy(process.env.TINA4_DEBUG)) {
230
- return false;
231
- }
232
- // Dev auto-enable: localhost only, unless explicitly opted into remote.
233
- return isLocalhost() || envTruthy(process.env.TINA4_MCP_REMOTE);
249
+ return envTruthy(process.env.TINA4_DEBUG);
250
+ }
251
+
252
+ /**
253
+ * Per-request authorisation — whether THIS caller may use MCP.
254
+ *
255
+ * @param remoteIp Raw socket peer (`req.socket.remoteAddress`), never XFF.
256
+ * @param hasValidToken True when the request carried a token matching TINA4_MCP_TOKEN.
257
+ *
258
+ * Rules (Python master parity, tina4_python.mcp.is_request_allowed):
259
+ * - Capability off ({@link mcpEnabled} false) → deny.
260
+ * - Loopback peer → allow.
261
+ * - Remote peer → only when TINA4_MCP_REMOTE is truthy AND a valid token was
262
+ * presented. No configured token ⇒ remote can never pass.
263
+ */
264
+ export function isRequestAllowed(remoteIp: string | undefined | null, hasValidToken = false): boolean {
265
+ if (!mcpEnabled()) return false;
266
+ if (isLoopback(remoteIp)) return true;
267
+ return envTruthy(process.env.TINA4_MCP_REMOTE) && hasValidToken;
234
268
  }
235
269
 
236
270
  /**
@@ -620,9 +654,30 @@ export function mcpResource(
620
654
  */
621
655
  function safePath(projectRoot: string, relPath: string): string {
622
656
  const resolved = path.resolve(projectRoot, relPath);
623
- if (!resolved.startsWith(projectRoot)) {
657
+ // Compare against root + separator, not a bare prefix: a plain
658
+ // startsWith(projectRoot) also accepts a sibling like "<root>-evil".
659
+ // path.resolve collapses ".." so a climb-out lands outside root.
660
+ const rootPrefix = projectRoot.endsWith(path.sep) ? projectRoot : projectRoot + path.sep;
661
+ if (resolved !== projectRoot && !resolved.startsWith(rootPrefix)) {
624
662
  throw new Error(`Path escapes project directory: ${relPath}`);
625
663
  }
664
+ // Belt-and-braces against symlink escapes: if the path exists, canonicalise
665
+ // it and re-check containment. A symlink inside the tree pointing outside
666
+ // would otherwise slip past the textual check. New paths (parent not yet
667
+ // created) have no realpath and rely on the resolve() containment above.
668
+ let real: string | null = null;
669
+ try {
670
+ real = fs.realpathSync(resolved);
671
+ } catch {
672
+ real = null; // not created yet — textual guard above holds
673
+ }
674
+ if (real !== null) {
675
+ const realRoot = fs.realpathSync(projectRoot);
676
+ const realPrefix = realRoot.endsWith(path.sep) ? realRoot : realRoot + path.sep;
677
+ if (real !== realRoot && !real.startsWith(realPrefix)) {
678
+ throw new Error(`Path escapes project directory: ${relPath}`);
679
+ }
680
+ }
626
681
  return resolved;
627
682
  }
628
683
 
@@ -865,8 +920,22 @@ export function registerDevTools(server: McpServer): void {
865
920
  try {
866
921
  const db = (globalThis as any).__tina4_db;
867
922
  if (!db) return { error: "No database connection" };
868
- const params = typeof args.params === "string" ? JSON.parse(args.params as string) : (args.params || []);
869
- const result = await db.fetch(args.sql as string, params);
923
+ let params = typeof args.params === "string" ? JSON.parse(args.params as string) : (args.params || []);
924
+ if (!Array.isArray(params)) params = [];
925
+ // Defense-in-depth: this tool is read-only. Strip comments, reject
926
+ // multiple statements, and require a leading SELECT/WITH so it can never
927
+ // mutate data even if reached (database_execute is the write surface,
928
+ // gated separately). Mirrors the Python master.
929
+ const cleaned = (args.sql as string)
930
+ .replace(/--[^\r\n]*/g, " ")
931
+ .replace(/\/\*[\s\S]*?\*\//g, " ")
932
+ .trim()
933
+ .replace(/[;\s]+$/, "");
934
+ if (cleaned.includes(";")) return { error: "database_query rejects multiple statements" };
935
+ if (!/^(select|with)\b/i.test(cleaned)) {
936
+ return { error: "database_query is read-only (SELECT/WITH only)" };
937
+ }
938
+ const result = await db.fetch(cleaned, params);
870
939
  return { records: result.records || [], count: result.count || 0 };
871
940
  } catch (e) {
872
941
  return { error: (e as Error).message };
@@ -921,7 +990,14 @@ export function registerDevTools(server: McpServer): void {
921
990
  try {
922
991
  const db = (globalThis as any).__tina4_db;
923
992
  if (!db) return { error: "No database connection" };
924
- return (await db.getColumns?.(args.table as string)) ?? [];
993
+ // Constrain the table name to a safe identifier (optionally
994
+ // schema-qualified) — defense-in-depth so it can never be abused for
995
+ // injection even if an adapter interpolates it. Parity with Python/PHP.
996
+ const table = String(args.table ?? "");
997
+ if (!/^[A-Za-z_][A-Za-z0-9_$]*(\.[A-Za-z_][A-Za-z0-9_$]*)?$/.test(table)) {
998
+ return { error: "Invalid table name" };
999
+ }
1000
+ return (await db.getColumns?.(table)) ?? [];
925
1001
  } catch (e) {
926
1002
  return { error: (e as Error).message };
927
1003
  }
@@ -183,8 +183,13 @@ export function _checkLegacyEnvVars(): void {
183
183
  }
184
184
  lines.push(
185
185
  "",
186
- "Run `tina4 env --migrate` to rewrite your .env automatically,",
187
- "or rename manually. See https://tina4.com/release/3.12.0",
186
+ "Note: these may come from a .env file loaded by dotenv, not just",
187
+ "the runtime environment check your image / build context (a .env",
188
+ "baked into a Docker image is loaded at startup) as well as k8s/CI env.",
189
+ "",
190
+ "FIX: run `tina4 env --migrate` to rewrite your .env automatically",
191
+ "(it renames every legacy name to its TINA4_ form in place).",
192
+ "Or rename manually. See https://tina4.com/release/3.12.0",
188
193
  "Set TINA4_ALLOW_LEGACY_ENV=true to bypass during migration.",
189
194
  bar,
190
195
  "",
@@ -66,8 +66,10 @@ export class MongoSessionHandler implements SessionHandler {
66
66
  ?? (process.env.TINA4_SESSION_MONGO_PORT
67
67
  ? parseInt(process.env.TINA4_SESSION_MONGO_PORT, 10)
68
68
  : 27017);
69
+ // Canonical TINA4_SESSION_MONGO_URI; TINA4_SESSION_MONGO_URL is a legacy alias.
69
70
  this.uri = config?.uri
70
71
  ?? process.env.TINA4_SESSION_MONGO_URI
72
+ ?? process.env.TINA4_SESSION_MONGO_URL
71
73
  ?? "";
72
74
  this.username = config?.username
73
75
  ?? process.env.TINA4_SESSION_MONGO_USERNAME
@@ -134,6 +134,10 @@ export interface RouteMeta {
134
134
  description?: string;
135
135
  tags?: string[];
136
136
  responses?: Record<string, { description: string }>;
137
+ /** Request-body example surfaced in the OpenAPI requestBody. */
138
+ example?: unknown;
139
+ /** Marks the operation deprecated in the spec. */
140
+ deprecated?: boolean;
137
141
  }
138
142
 
139
143
  export interface Tina4Config {