tina4-nodejs 3.13.138 → 3.13.140

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CLAUDE.md +2 -2
  2. package/package.json +1 -1
  3. package/packages/cli/dist/bin.js +683 -329
  4. package/packages/cli/src/commands/generate.ts +7 -13
  5. package/packages/core/dist/index.js +688 -329
  6. package/packages/core/src/api.ts +33 -4
  7. package/packages/core/src/auth.ts +73 -6
  8. package/packages/core/src/authGate.ts +27 -20
  9. package/packages/core/src/graphql.ts +92 -0
  10. package/packages/core/src/index.ts +1 -0
  11. package/packages/core/src/job.ts +8 -1
  12. package/packages/core/src/mcp.ts +3 -1
  13. package/packages/core/src/middleware.ts +4 -2
  14. package/packages/core/src/push.ts +16 -0
  15. package/packages/core/src/queue.ts +15 -2
  16. package/packages/core/src/queueBackends/liteBackend.ts +15 -0
  17. package/packages/core/src/queueBackends/mongoBackend.ts +40 -6
  18. package/packages/core/src/server.ts +6 -1
  19. package/packages/core/src/sessionHandlers/databaseHandler.ts +60 -3
  20. package/packages/core/src/sso.ts +12 -4
  21. package/packages/core/src/ssoIdentity.ts +19 -0
  22. package/packages/core/src/ssrf.ts +221 -0
  23. package/packages/core/src/testClient.ts +3 -0
  24. package/packages/core/src/websocket.ts +4 -2
  25. package/packages/frond/dist/index.js +6 -0
  26. package/packages/frond/src/engine.ts +9 -0
  27. package/packages/orm/dist/index.js +675 -320
  28. package/packages/orm/src/adapters/firebird.ts +14 -45
  29. package/packages/orm/src/adapters/mssql.ts +21 -58
  30. package/packages/orm/src/adapters/mysql.ts +10 -49
  31. package/packages/orm/src/adapters/odbc.ts +11 -42
  32. package/packages/orm/src/adapters/postgres.ts +20 -60
  33. package/packages/orm/src/adapters/sqlCrud.ts +178 -0
  34. package/packages/orm/src/adapters/sqlite.ts +46 -66
  35. package/packages/orm/src/index.ts +1 -1
  36. package/types/core/src/api.d.ts +6 -0
  37. package/types/core/src/auth.d.ts +30 -0
  38. package/types/core/src/graphql.d.ts +25 -0
  39. package/types/core/src/index.d.ts +1 -0
  40. package/types/core/src/job.d.ts +1 -0
  41. package/types/core/src/push.d.ts +5 -0
  42. package/types/core/src/queue.d.ts +7 -1
  43. package/types/core/src/queueBackends/liteBackend.d.ts +9 -0
  44. package/types/core/src/queueBackends/mongoBackend.d.ts +8 -1
  45. package/types/core/src/sessionHandlers/databaseHandler.d.ts +25 -1
  46. package/types/core/src/sso.d.ts +1 -0
  47. package/types/core/src/ssoIdentity.d.ts +10 -0
  48. package/types/core/src/ssrf.d.ts +19 -0
  49. package/types/orm/src/adapters/sqlCrud.d.ts +90 -0
  50. package/types/orm/src/adapters/sqlite.d.ts +17 -0
  51. package/types/orm/src/index.d.ts +1 -1
@@ -307,12 +307,36 @@ export class MongoBackend implements QueueBackend {
307
307
  }
308
308
  }
309
309
  else if (operation === "size") {
310
- const count = await col.countDocuments({
311
- queue: queueName,
312
- status: "pending",
313
- });
310
+ // ADR-0022 dec 7: size(status) must not answer a different question.
311
+ // The dead aliases count the .dead_letter store (== deadLetters().length);
312
+ // reserved/completed count by status; else pending. data = status.
313
+ const status = data || "pending";
314
+ let count: number;
315
+ if (status === "dead" || status === "failed" || status === "dead_letter") {
316
+ count = await col.countDocuments({ queue: queueName + ".dead_letter" });
317
+ } else if (status === "reserved" || status === "completed") {
318
+ count = await col.countDocuments({ queue: queueName, status });
319
+ } else {
320
+ count = await col.countDocuments({ queue: queueName, status: "pending" });
321
+ }
314
322
  process.stdout.write(String(count));
315
323
  }
324
+ else if (operation === "reject") {
325
+ // Dead-letter NOW, no retry (ADR-0023). Floor attempts at maxRetries
326
+ // so it reads as exhausted (parity with fail()'s dead-letter branch
327
+ // and size("dead")). data = JSON { id, reason, maxRetries }.
328
+ const info = JSON.parse(data);
329
+ const doc = await col.findOne({ queue: queueName, id: info.id });
330
+ if (doc) {
331
+ const attempts = Math.max((doc.attempts || 0) + 1, info.maxRetries);
332
+ await col.insertOne({
333
+ ...doc, _id: undefined, attempts,
334
+ status: "dead", queue: queueName + ".dead_letter", error: info.reason,
335
+ });
336
+ await col.deleteOne({ _id: doc._id, queue: queueName });
337
+ }
338
+ process.stdout.write("__OK__");
339
+ }
316
340
  else if (operation === "clear") {
317
341
  await col.deleteMany({ queue: queueName });
318
342
  process.stdout.write("__CLEARED__");
@@ -559,12 +583,22 @@ export class MongoBackend implements QueueBackend {
559
583
  }
560
584
  }
561
585
 
562
- size(queue: string): number {
563
- const result = this.execSync("size", queue);
586
+ size(queue: string, status: string = "pending"): number {
587
+ const result = this.execSync("size", queue, status);
564
588
  const num = parseInt(result, 10);
565
589
  return isNaN(num) ? 0 : num;
566
590
  }
567
591
 
592
+ /**
593
+ * Dead-letter a job immediately — no retry (ADR-0023 reject()). Distinct from
594
+ * fail(): fail() requeues until maxRetries is spent. attempts is floored at
595
+ * maxRetries so deadLetters()/size("dead") (which filter/scope to the
596
+ * dead-letter store) see it.
597
+ */
598
+ reject(queue: string, id: string, reason: string, maxRetries: number): void {
599
+ this.execSync("reject", queue, JSON.stringify({ id, reason, maxRetries }));
600
+ }
601
+
568
602
  clear(queue: string): void {
569
603
  this.execSync("clear", queue);
570
604
  }
@@ -2056,9 +2056,14 @@ export async function startServer(config?: Tina4Config): Promise<{
2056
2056
  // Auto-generate a per-machine dev secret to a gitignored .env.local when one
2057
2057
  // is missing (dev only, never CI/prod). Must run after env load and before
2058
2058
  // any auth use. Local import avoids a load-time cycle through auth.
2059
- const { ensureDevSecret } = await import("./auth.js");
2059
+ const { ensureDevSecret, requireBootSecret } = await import("./auth.js");
2060
2060
  ensureDevSecret();
2061
2061
 
2062
+ // Refuse to serve with a secret anyone can reproduce: blank outside dev, or
2063
+ // set but shorter than 32 bytes in any mode (ADR-0079 s2). Throws the
2064
+ // actionable message naming TINA4_SECRET.
2065
+ requireBootSecret();
2066
+
2062
2067
  // Refuse to boot with pre-3.12 un-prefixed env vars set.
2063
2068
  _checkLegacyEnvVars();
2064
2069
 
@@ -160,6 +160,16 @@ const CREATE_TABLE: Record<string, string> = {
160
160
  `,
161
161
  };
162
162
 
163
+ /** How many times ensureTable() tries the CREATE before a failure is real. */
164
+ const CREATE_ATTEMPTS = 5;
165
+ /** Base pause between tries; it grows linearly (50, 100, 150, 200 ms). */
166
+ const CREATE_RETRY_DELAY_MS = 50;
167
+
168
+ /** Block this thread for `ms` - the SessionHandler interface is synchronous. */
169
+ function sleepSync(ms: number): void {
170
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
171
+ }
172
+
163
173
  /**
164
174
  * Read a column out of a result row, case-insensitively.
165
175
  *
@@ -223,6 +233,12 @@ export class DatabaseSessionHandler implements SessionHandler {
223
233
  private get sqlite(): any {
224
234
  if (this.sqliteHandle === null) {
225
235
  this.sqliteHandle = new DatabaseSync(this.dbPath as string);
236
+ // Wait up to 5 s for another process's write lock instead of failing at
237
+ // once with "database is locked" - node:sqlite's default is NO wait, so
238
+ // racing processes collided on the first overlap (five of six died,
239
+ // measured). 5000 ms matches PHP's busyTimeout(5000) and Python's sqlite3
240
+ // default. Set BEFORE the WAL pragma so that write waits too.
241
+ this.sqliteHandle.exec("PRAGMA busy_timeout = 5000");
226
242
  this.sqliteHandle.exec("PRAGMA journal_mode = WAL");
227
243
  }
228
244
  return this.sqliteHandle;
@@ -342,16 +358,57 @@ export class DatabaseSessionHandler implements SessionHandler {
342
358
  }
343
359
 
344
360
  /**
345
- * Ensure the session table exists (called once on first use).
361
+ * Ensure the session table exists (called once on first use) - safely when
362
+ * several processes race to create it.
363
+ *
364
+ * Every per-engine statement above is check-then-act, so two processes can
365
+ * both find the table absent and both CREATE; the loser errors, and every
366
+ * engine spells that differently (a pg_type unique violation on PostgreSQL,
367
+ * Msg 2714 on SQL Server, an RDB$RELATIONS unique violation on Firebird). So
368
+ * a failure is judged by RE-CHECKING whether the table now exists, never by
369
+ * parsing the message - the rescue the Firebird note above has always
370
+ * assumed, and which was not here. MEASURED on the lab before it: six real
371
+ * processes racing lost one to five of them on PostgreSQL, SQLite and
372
+ * Firebird.
373
+ *
374
+ * The re-check is RETRIED because the loser can fail BEFORE the winner has
375
+ * committed. On MySQL, CREATE TABLE IF NOT EXISTS takes a shared metadata
376
+ * lock, checks, then upgrades it to exclusive; two upgrades deadlock, and when
377
+ * MySQL cannot back the victim off silently it answers 1213 "Deadlock found"
378
+ * while the winner's table is still invisible (tina4-php CI run 35972320442).
379
+ * Bounded, so a genuine failure (no CREATE grant) still throws. Same attempts
380
+ * and delays in all four frameworks.
346
381
  */
347
382
  private ensureTable(): void {
348
383
  if (this.initialized) return;
349
384
  const ddl = CREATE_TABLE[this.engine];
350
- if (this.target === null) this.sqlite.exec(ddl);
351
- else sqlCommandSync(this.target, ddl, []);
385
+ for (let attempt = 1; ; attempt++) {
386
+ try {
387
+ if (this.target === null) this.sqlite.exec(ddl);
388
+ else sqlCommandSync(this.target, ddl, []);
389
+ break;
390
+ } catch (error) {
391
+ if (this.tableExists()) break;
392
+ if (attempt >= CREATE_ATTEMPTS) throw error;
393
+ sleepSync(CREATE_RETRY_DELAY_MS * attempt);
394
+ }
395
+ }
352
396
  this.initialized = true;
353
397
  }
354
398
 
399
+ /**
400
+ * Whether tina4_session exists, asked the one way every engine answers alike:
401
+ * a query against it that returns no rows succeeds only if it is there.
402
+ */
403
+ private tableExists(): boolean {
404
+ try {
405
+ this.query("SELECT 1 FROM tina4_session WHERE 1 = 0", []);
406
+ return true;
407
+ } catch {
408
+ return false;
409
+ }
410
+ }
411
+
355
412
  read(sessionId: string): SessionData | null {
356
413
  this.ensureTable();
357
414
 
@@ -13,6 +13,15 @@ import type { Router } from "./router.js";
13
13
  export class SsoError extends Error {}
14
14
 
15
15
  type Json = Record<string, any>;
16
+
17
+ export { liveSessionIdentity } from "./ssoIdentity.js";
18
+ import { liveSessionIdentity } from "./ssoIdentity.js";
19
+
20
+ /** When the provider's access token lapses; 0 when it gave no lifetime (never "now"). */
21
+ function expiresAt(tokens: Json): number {
22
+ const lifetime = Number(tokens.expires_in ?? 0);
23
+ return lifetime > 0 ? Math.floor(Date.now() / 1000) + lifetime : 0;
24
+ }
16
25
  type SessionLike = {
17
26
  get(key: string): unknown;
18
27
  set(key: string, value: unknown): void;
@@ -225,14 +234,13 @@ export class Sso {
225
234
  session!.regenerate();
226
235
  session!.set(Sso.SESSION_KEY, {
227
236
  version: 1, identity, access_token: tokens.access_token, refresh_token: tokens.refresh_token,
228
- id_token: tokens.id_token, expires_at: Math.floor(Date.now() / 1000) + Number(tokens.expires_in ?? 0),
237
+ id_token: tokens.id_token, expires_at: expiresAt(tokens),
229
238
  });
230
239
  return { identity, return_to: Sso.safeReturn(pending.return_to) };
231
240
  }
232
241
 
233
242
  identity(requestOrSession: any): Json | null {
234
- const stored = this.session(requestOrSession)?.get(Sso.SESSION_KEY) as Json | undefined;
235
- const identity = stored?.identity ?? null;
243
+ const identity = liveSessionIdentity(this.session(requestOrSession)?.get(Sso.SESSION_KEY));
236
244
  if (identity && requestOrSession?.session) requestOrSession.user = identity;
237
245
  return identity;
238
246
  }
@@ -251,7 +259,7 @@ export class Sso {
251
259
  const identity = this.normalize(claims);
252
260
  session!.set(Sso.SESSION_KEY, { ...stored, identity, access_token: tokens.access_token,
253
261
  refresh_token: tokens.refresh_token ?? stored.refresh_token, id_token: tokens.id_token ?? stored.id_token,
254
- expires_at: Math.floor(Date.now() / 1000) + Number(tokens.expires_in ?? 0) });
262
+ expires_at: expiresAt(tokens) });
255
263
  return identity;
256
264
  } catch (error) { session?.delete(Sso.SESSION_KEY); throw error; }
257
265
  }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The stored SSO identity, only while it is live (ADR-0079 s5). `stored` is the
3
+ * reserved _tina4_sso session value. The identity needs an issuer and a
4
+ * subject, and a numeric expires_at that is 0 (the provider gave no lifetime)
5
+ * or still in the future.
6
+ *
7
+ * Its own dependency-free module, so the auth gate can use it without
8
+ * importing the OIDC client (and everything the client imports).
9
+ */
10
+ export function liveSessionIdentity(stored: unknown): Record<string, any> | null {
11
+ if (!stored || typeof stored !== "object") return null;
12
+ const identity = (stored as Record<string, any>).identity;
13
+ if (!identity || typeof identity !== "object" || !identity.issuer || !identity.subject) return null;
14
+ const expiresAt = (stored as Record<string, any>).expires_at ?? 0;
15
+ if (typeof expiresAt !== "number") return null;
16
+ if (expiresAt > 0 && Math.floor(Date.now() / 1000) >= expiresAt) return null;
17
+ return identity;
18
+ }
19
+
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Outbound SSRF guard (ADR-0084).
3
+ *
4
+ * The Api client and Web Push refuse, by default, to connect to a private or
5
+ * internal address. Before each connection - the initial URL and every redirect
6
+ * hop the Api client follows - the host is resolved to its IP address(es) and
7
+ * the request is refused if any resolved address is loopback, private,
8
+ * link-local (including the cloud metadata address 169.254.169.254), unspecified
9
+ * or CGNAT. A non-http(s) scheme is refused.
10
+ *
11
+ * Off by default; TINA4_ALLOW_PRIVATE_REQUESTS (truthy) or an explicit allow-list
12
+ * of hosts / host:port / CIDRs opts out. Zero external dependencies - node:net +
13
+ * node:dns.
14
+ */
15
+ import { isIP } from "node:net";
16
+ import { lookup } from "node:dns/promises";
17
+
18
+ export const ALLOW_PRIVATE_ENV = "TINA4_ALLOW_PRIVATE_REQUESTS";
19
+
20
+ /** Truthiness identical to ADR-0070's set (trimmed, lower-cased). */
21
+ const TRUTHY: ReadonlySet<string> = new Set(["1", "true", "yes", "on"]);
22
+
23
+ /** Raised when the SSRF guard refuses an outbound request. */
24
+ export class SsrfError extends Error {
25
+ constructor(message: string) {
26
+ super(message);
27
+ this.name = "SsrfError";
28
+ }
29
+ }
30
+
31
+ interface Cidr {
32
+ net: Uint8Array;
33
+ bits: number;
34
+ }
35
+
36
+ /**
37
+ * The blocked address space, one definition shared with the contract fixture.
38
+ * 0.0.0.0/8 covers the unspecified address and the "this network" block.
39
+ */
40
+ const BLOCKED_CIDRS: readonly string[] = [
41
+ "0.0.0.0/8", "10.0.0.0/8", "127.0.0.0/8", "169.254.0.0/16",
42
+ "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10",
43
+ "::1/128", "::/128", "fc00::/7", "fe80::/10",
44
+ ];
45
+
46
+ function v4ToBytes(ip: string): Uint8Array | null {
47
+ const parts = ip.split(".");
48
+ if (parts.length !== 4) return null;
49
+ const bytes = new Uint8Array(4);
50
+ for (let i = 0; i < 4; i++) {
51
+ const n = Number(parts[i]);
52
+ if (!Number.isInteger(n) || n < 0 || n > 255 || !/^\d+$/.test(parts[i])) return null;
53
+ bytes[i] = n;
54
+ }
55
+ return bytes;
56
+ }
57
+
58
+ function v6ToBytes(input: string): Uint8Array | null {
59
+ let ip = input.split("%")[0];
60
+ // Embedded IPv4 tail (::ffff:1.2.3.4).
61
+ const lastColon = ip.lastIndexOf(":");
62
+ const tail = ip.slice(lastColon + 1);
63
+ if (tail.includes(".")) {
64
+ const v4 = v4ToBytes(tail);
65
+ if (!v4) return null;
66
+ const hextetA = ((v4[0] << 8) | v4[1]).toString(16);
67
+ const hextetB = ((v4[2] << 8) | v4[3]).toString(16);
68
+ ip = ip.slice(0, lastColon + 1) + hextetA + ":" + hextetB;
69
+ }
70
+ const halves = ip.split("::");
71
+ if (halves.length > 2) return null;
72
+ const head = halves[0] ? halves[0].split(":") : [];
73
+ const back = halves.length === 2 ? (halves[1] ? halves[1].split(":") : []) : [];
74
+ let groups: string[];
75
+ if (halves.length === 1) {
76
+ if (head.length !== 8) return null;
77
+ groups = head;
78
+ } else {
79
+ const missing = 8 - (head.length + back.length);
80
+ if (missing < 0) return null;
81
+ groups = [...head, ...Array(missing).fill("0"), ...back];
82
+ }
83
+ if (groups.length !== 8) return null;
84
+ const bytes = new Uint8Array(16);
85
+ for (let i = 0; i < 8; i++) {
86
+ if (!/^[0-9a-fA-F]{1,4}$/.test(groups[i])) return null;
87
+ const g = parseInt(groups[i], 16);
88
+ bytes[i * 2] = (g >> 8) & 0xff;
89
+ bytes[i * 2 + 1] = g & 0xff;
90
+ }
91
+ return bytes;
92
+ }
93
+
94
+ /** Parse an IP string (v4 or v6, IPv4-mapped included) to its packed bytes. */
95
+ function ipToBytes(ip: string): Uint8Array | null {
96
+ const cleaned = ip.split("%")[0];
97
+ const family = isIP(cleaned);
98
+ if (family === 4) return v4ToBytes(cleaned);
99
+ if (family === 6) {
100
+ const bytes = v6ToBytes(cleaned);
101
+ if (!bytes) return null;
102
+ // Unwrap IPv4-mapped IPv6 (::ffff:a.b.c.d) to its 4-byte IPv4.
103
+ const isMapped = bytes.subarray(0, 10).every((b) => b === 0)
104
+ && bytes[10] === 0xff && bytes[11] === 0xff;
105
+ return isMapped ? bytes.subarray(12) : bytes;
106
+ }
107
+ return null;
108
+ }
109
+
110
+ function parseCidr(cidr: string): Cidr | null {
111
+ const [netStr, bitsStr] = cidr.split("/");
112
+ const net = ipToBytes(netStr);
113
+ if (!net) return null;
114
+ return { net, bits: Number(bitsStr) };
115
+ }
116
+
117
+ const BLOCKED: readonly Cidr[] = BLOCKED_CIDRS
118
+ .map(parseCidr)
119
+ .filter((c): c is Cidr => c !== null);
120
+
121
+ function inCidr(packed: Uint8Array, cidr: Cidr): boolean {
122
+ if (packed.length !== cidr.net.length) return false;
123
+ const whole = Math.floor(cidr.bits / 8);
124
+ const remainder = cidr.bits % 8;
125
+ for (let i = 0; i < whole; i++) {
126
+ if (packed[i] !== cidr.net[i]) return false;
127
+ }
128
+ if (remainder === 0) return true;
129
+ const mask = (0xff << (8 - remainder)) & 0xff;
130
+ return (packed[whole] & mask) === (cidr.net[whole] & mask);
131
+ }
132
+
133
+ /** True when TINA4_ALLOW_PRIVATE_REQUESTS opts out of the guard. */
134
+ export function allowPrivateRequests(): boolean {
135
+ return TRUTHY.has((process.env[ALLOW_PRIVATE_ENV] ?? "").trim().toLowerCase());
136
+ }
137
+
138
+ /**
139
+ * Classify one IP string: true = private/internal, refuse it. An IPv4-mapped
140
+ * IPv6 address is classified by its embedded IPv4. An unparseable value is
141
+ * treated as blocked - the guard refuses what it cannot classify.
142
+ */
143
+ export function isBlockedAddress(ip: string): boolean {
144
+ const packed = ipToBytes(ip);
145
+ if (!packed) return true;
146
+ return BLOCKED.some((cidr) => inCidr(packed, cidr));
147
+ }
148
+
149
+ function matchesAllowList(host: string, port: number, resolved: string[], allowHosts?: string[]): boolean {
150
+ const hostLower = host.toLowerCase();
151
+ for (const raw of allowHosts ?? []) {
152
+ const entry = String(raw).trim().toLowerCase();
153
+ if (!entry) continue;
154
+ if (entry === hostLower || entry === `${hostLower}:${port}`) return true;
155
+ if (entry.includes("/")) {
156
+ const cidr = parseCidr(entry);
157
+ if (cidr && resolved.some((ip) => {
158
+ const packed = ipToBytes(ip);
159
+ return packed !== null && inCidr(packed, cidr);
160
+ })) {
161
+ return true;
162
+ }
163
+ }
164
+ }
165
+ return false;
166
+ }
167
+
168
+ function logBlock(host: string, ip: string): void {
169
+ // Log lazily to avoid a hard dependency cycle with the logger.
170
+ void import("./logger.js")
171
+ .then((mod) => mod.Log?.warning?.(`SSRF guard blocked outbound request to ${host} (${ip})`))
172
+ .catch(() => undefined);
173
+ }
174
+
175
+ /**
176
+ * Refuse `url` when it targets a private/internal address. Throws SsrfError for a
177
+ * non-http(s) scheme, an unresolvable host, or any resolved address in the
178
+ * blocked space. `allowHosts` is a list of hosts / host:port / CIDRs to permit.
179
+ */
180
+ export async function guardUrl(url: string, allowHosts?: string[]): Promise<void> {
181
+ let parsed: URL;
182
+ try {
183
+ parsed = new URL(url);
184
+ } catch {
185
+ throw new SsrfError(`Blocked request: '${url}' is not a valid URL`);
186
+ }
187
+ const scheme = parsed.protocol.replace(/:$/, "").toLowerCase();
188
+ if (scheme !== "http" && scheme !== "https") {
189
+ throw new SsrfError(`Blocked request: URL scheme '${scheme || "(none)"}' is not http or https`);
190
+ }
191
+ const host = parsed.hostname.replace(/^\[|\]$/g, "");
192
+ if (!host) {
193
+ throw new SsrfError("Blocked request: URL has no host");
194
+ }
195
+ const port = parsed.port ? Number(parsed.port) : (scheme === "https" ? 443 : 80);
196
+
197
+ if (allowPrivateRequests()) return;
198
+
199
+ let resolved: string[];
200
+ try {
201
+ const results = await lookup(host, { all: true });
202
+ resolved = results.map((r) => r.address);
203
+ } catch {
204
+ throw new SsrfError(`Blocked request to ${host}: cannot resolve host`);
205
+ }
206
+ if (resolved.length === 0) {
207
+ throw new SsrfError(`Blocked request to ${host}: cannot resolve host`);
208
+ }
209
+
210
+ if (matchesAllowList(host, port, resolved, allowHosts)) return;
211
+
212
+ for (const ip of resolved) {
213
+ if (isBlockedAddress(ip)) {
214
+ logBlock(host, ip);
215
+ throw new SsrfError(
216
+ `Blocked request to private/internal address ${ip} (host ${host}): ` +
217
+ `set ${ALLOW_PRIVATE_ENV}=true to allow, or pass an allow-list.`,
218
+ );
219
+ }
220
+ }
221
+ }
@@ -207,6 +207,9 @@ export class TestClient {
207
207
 
208
208
  // Create a mock IncomingMessage
209
209
  const socket = new Socket();
210
+ // The in-process client is a local caller and says so explicitly: an
211
+ // unknown (empty) peer is not loopback (ADR-0079 s4).
212
+ Object.defineProperty(socket, "remoteAddress", { value: "127.0.0.1" });
210
213
  const rawReq = new IncomingMessage(socket);
211
214
  rawReq.method = method.toUpperCase();
212
215
  rawReq.url = path;
@@ -38,7 +38,7 @@ import type { WebSocketConnection } from "./websocketConnection.js";
38
38
  import type { WebSocketRouteHandler } from "./types.js";
39
39
  import { Router } from "./router.js";
40
40
  import { Log } from "./logger.js";
41
- import { validToken } from "./auth.js";
41
+ import { validToken, isIdentityPayload } from "./auth.js";
42
42
  import { WsBackplaneManager, type WsEnvelope } from "./websocketBackplane.js";
43
43
 
44
44
  // ── Constants ────────────────────────────────────────────────
@@ -211,7 +211,9 @@ export function wsAuthorized(
211
211
  const token = wsToken(headers, queryString, subprotocol);
212
212
  if (!token) return [null, false];
213
213
  const payload = validToken(token);
214
- return [payload, payload !== null];
214
+ // A form token is not an identity (ADR-0079 s1).
215
+ if (!isIdentityPayload(payload)) return [null, false];
216
+ return [payload, true];
215
217
  }
216
218
 
217
219
  /**
@@ -1415,6 +1415,12 @@ function _b64url(data) {
1415
1415
  var _formTokenSessionId = "";
1416
1416
  function _buildFormTokenJwt(descriptor = "") {
1417
1417
  const secret = process.env.TINA4_SECRET ?? "";
1418
+ const secretBytes = Buffer.byteLength(secret, "utf8");
1419
+ if (secretBytes < 32) {
1420
+ throw new Error(
1421
+ `Auth: TINA4_SECRET ${secretBytes === 0 ? "is not set" : `is ${secretBytes} bytes`}; an HMAC JWT secret must be at least 32 bytes. Generate one with \`openssl rand -hex 32\` and set TINA4_SECRET in your environment or .env.`
1422
+ );
1423
+ }
1418
1424
  const ttlMinutes = parseInt(process.env.TINA4_TOKEN_LIMIT || "60", 10);
1419
1425
  const header = { alg: "HS256", typ: "JWT" };
1420
1426
  const now = Math.floor(Date.now() / 1e3);
@@ -1794,6 +1794,15 @@ function _buildFormTokenJwt(descriptor: string = ""): string {
1794
1794
  // AND a ''-forged token was accepted (CSRF-NODE-SECRET-SPLIT, feature 37).
1795
1795
  // The generator and the validator MUST resolve the same secret.
1796
1796
  const secret = process.env.TINA4_SECRET ?? "";
1797
+ // Same rule as core auth (ADR-0079 s2): never sign with a blank or short key,
1798
+ // which would make every form token reproducible by anyone.
1799
+ const secretBytes = Buffer.byteLength(secret, "utf8");
1800
+ if (secretBytes < 32) {
1801
+ throw new Error(
1802
+ `Auth: TINA4_SECRET ${secretBytes === 0 ? "is not set" : `is ${secretBytes} bytes`}; an HMAC JWT secret must be at least 32 bytes. ` +
1803
+ "Generate one with `openssl rand -hex 32` and set TINA4_SECRET in your environment or .env.",
1804
+ );
1805
+ }
1797
1806
  const ttlMinutes = parseInt(process.env.TINA4_TOKEN_LIMIT || "60", 10);
1798
1807
 
1799
1808
  const header = { alg: "HS256", typ: "JWT" };