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
@@ -29,6 +29,7 @@ import { promises as fsp, createWriteStream } from "node:fs";
29
29
  import { basename } from "node:path";
30
30
  import { pipeline } from "node:stream/promises";
31
31
  import { TINA4_VERSION } from "./version.js";
32
+ import { guardUrl } from "./ssrf.js";
32
33
 
33
34
  export interface ApiResult {
34
35
  http_code: number | null;
@@ -229,6 +230,11 @@ export interface ApiOptions {
229
230
  * persisted; scoped to this instance.
230
231
  */
231
232
  cookies?: boolean;
233
+ /**
234
+ * SSRF guard (ADR-0084): an explicit allow-list of hosts / host:port / CIDRs
235
+ * that bypass the private-address refusal even with the opt-out off.
236
+ */
237
+ allowHosts?: string[];
232
238
  }
233
239
 
234
240
  /** True when two URLs share scheme + host + (effective) port. */
@@ -430,6 +436,7 @@ export class Api {
430
436
  private transportFn?: ApiTransport;
431
437
  private cookiesEnabled: boolean;
432
438
  private cookies: Record<string, string>;
439
+ private allowHosts: string[];
433
440
 
434
441
  /**
435
442
  * Construct an Api client.
@@ -476,6 +483,7 @@ export class Api {
476
483
  this.transportFn = undefined;
477
484
  this.cookiesEnabled = false;
478
485
  this.cookies = {};
486
+ this.allowHosts = [];
479
487
 
480
488
  // Options-bag form — second arg is an object literal
481
489
  if (typeof authHeaderOrOptions === "object" && authHeaderOrOptions !== null) {
@@ -487,6 +495,7 @@ export class Api {
487
495
  this.retryBackoff = opts.retryBackoff ?? 0.5;
488
496
  this.transportFn = opts.transport;
489
497
  this.cookiesEnabled = opts.cookies ?? false;
498
+ this.allowHosts = opts.allowHosts ?? [];
490
499
 
491
500
  // Bearer wins over basic-auth when both are passed
492
501
  if (opts.bearerToken != null) {
@@ -842,13 +851,19 @@ export class Api {
842
851
  * body-phase timeout is applied by the caller (streamBytes) via
843
852
  * `res.destroy()`.
844
853
  */
845
- private openStreamRequest(
854
+ private async openStreamRequest(
846
855
  method: string,
847
856
  url: string,
848
857
  headers: Record<string, string>,
849
858
  data: Buffer | undefined,
850
859
  connectSec: number,
851
860
  ): Promise<{ res: http.IncomingMessage }> {
861
+ // SSRF guard (ADR-0084): refuse a private/internal target before connect.
862
+ try {
863
+ await guardUrl(url, this.allowHosts);
864
+ } catch (err) {
865
+ throw new ApiStreamError(err instanceof Error ? err.message : String(err), null);
866
+ }
852
867
  return new Promise((resolve, reject) => {
853
868
  let parsed: URL;
854
869
  try {
@@ -915,12 +930,18 @@ export class Api {
915
930
  targetUrl?: string,
916
931
  ): { headers: Record<string, string>; data: Buffer | undefined } {
917
932
  const headers: Record<string, string> = { "User-Agent": `Tina4/${TINA4_VERSION}`, ...this.headers };
918
- if (this.authHeader) {
933
+ // Attach the configured Authorization / Cookie ONLY when the request
934
+ // target is same-origin as the configured base. A path that is itself
935
+ // an absolute off-origin URL (e.g. get("http://evil/x")) otherwise
936
+ // leaks the bearer token / session cookie to an attacker-chosen host —
937
+ // the same cross-origin strip already applied to followed redirects.
938
+ const sameOriginAsBase = targetUrl === undefined || sameOrigin(targetUrl, this.baseUrl);
939
+ if (this.authHeader && sameOriginAsBase) {
919
940
  headers["Authorization"] = this.authHeader;
920
941
  }
921
942
 
922
943
  // Cookie jar: attach the accumulated Cookie header when enabled.
923
- if (this.cookiesEnabled) {
944
+ if (this.cookiesEnabled && sameOriginAsBase) {
924
945
  const cookieHeader = this.cookieHeader();
925
946
  if (cookieHeader) {
926
947
  headers["Cookie"] = cookieHeader;
@@ -1049,13 +1070,21 @@ export class Api {
1049
1070
  * bearer token / session cookie never leaks to a host you didn't
1050
1071
  * authenticate to.
1051
1072
  */
1052
- private performRequest(
1073
+ private async performRequest(
1053
1074
  method: string,
1054
1075
  url: string,
1055
1076
  headers: Record<string, string>,
1056
1077
  data: Buffer | undefined,
1057
1078
  redirectsLeft: number,
1058
1079
  ): Promise<NetworkResult> {
1080
+ // SSRF guard (ADR-0084): re-validated per hop (this method recurses on a
1081
+ // redirect), so a hop to a private/internal address is refused even when
1082
+ // the initial URL was public.
1083
+ try {
1084
+ await guardUrl(url, this.allowHosts);
1085
+ } catch (err) {
1086
+ return { kind: "error", error: err instanceof Error ? err.message : String(err) };
1087
+ }
1059
1088
  return new Promise<NetworkResult>((resolve) => {
1060
1089
  let parsed: URL;
1061
1090
  try {
@@ -82,6 +82,58 @@ function _warnBlankSecret(): void {
82
82
  void _logWarning(BLANK_SECRET_WARNING);
83
83
  }
84
84
 
85
+ // ── Auth-token rules (ADR-0079) ────────────────────────────────────
86
+
87
+ /**
88
+ * Token purposes that are never an identity (ADR-0079 s1). Frond's formToken()
89
+ * signs a JWT with the same TINA4_SECRET as an auth token, marked
90
+ * `type: "form"`. A form token proves where a write came from, never who the
91
+ * caller is, so every identity gate refuses these purposes. validToken itself
92
+ * stays purpose-neutral: the CSRF middleware needs it for form tokens.
93
+ */
94
+ export const NON_IDENTITY_TOKEN_TYPES: readonly string[] = ["form"];
95
+
96
+ /**
97
+ * Minimum HMAC key length in bytes (ADR-0079 s2): the HS256 output size, RFC
98
+ * 7518 s3.2. A blank key is the shortest case - anyone can reproduce it.
99
+ */
100
+ export const MIN_SECRET_BYTES = 32;
101
+
102
+ /**
103
+ * True when a VERIFIED payload may stand for a caller's identity. A payload
104
+ * whose `type` is a reserved non-identity purpose (a Frond form token) is
105
+ * refused; application payloads are otherwise untouched.
106
+ */
107
+ export function isIdentityPayload(payload: Record<string, unknown> | null | undefined): payload is Record<string, unknown> {
108
+ return payload != null && !NON_IDENTITY_TOKEN_TYPES.includes(payload.type as string);
109
+ }
110
+
111
+ /** The actionable error for a weak HMAC key, or null when the key is usable. */
112
+ export function insecureSecretMessage(secret: string | undefined | null): string | null {
113
+ const length = Buffer.byteLength(secret ?? "", "utf8");
114
+ if (length >= MIN_SECRET_BYTES) return null;
115
+ const what = length === 0 ? "is not set" : `is ${length} bytes`;
116
+ return `Auth: TINA4_SECRET ${what}; an HMAC JWT secret must be at least ${MIN_SECRET_BYTES} bytes. ` +
117
+ "Generate one with `openssl rand -hex 32` and set TINA4_SECRET in your environment or .env.";
118
+ }
119
+
120
+ /**
121
+ * Refuse to boot with a secret that makes tokens forgeable (ADR-0079 s2).
122
+ * Outside dev a blank TINA4_SECRET is refused; in any mode a set-but-short one
123
+ * is refused. Dev with a blank secret has already had one minted into
124
+ * .env.local by ensureDevSecret(). RS256 keys are PEMs and are not measured.
125
+ *
126
+ * @throws Error with the actionable message naming TINA4_SECRET.
127
+ */
128
+ export function requireBootSecret(): void {
129
+ const algorithm = (process.env.TINA4_JWT_ALGORITHM ?? "").trim() || "HS256";
130
+ if (RSA_SIGN_ALGORITHMS.has(algorithm)) return;
131
+ const secret = process.env.TINA4_SECRET ?? "";
132
+ if (secret === "" && _isDev()) return;
133
+ const message = insecureSecretMessage(secret);
134
+ if (message) throw new Error(message);
135
+ }
136
+
85
137
  /**
86
138
  * Ensure a usable TINA4_SECRET exists. Run ONCE at server boot, after env load
87
139
  * and before auth is used. Mirrors Python's `ensure_dev_secret()`.
@@ -460,6 +512,14 @@ export function validToken(token: string, secret?: string, algorithm?: string):
460
512
  const header = JSON.parse(base64urlDecode(h).toString()) as Record<string, unknown>;
461
513
  if (header.alg !== resolvedAlgorithm) return null;
462
514
 
515
+ // A blank or short HMAC key verifies tokens anyone can mint, so the token is
516
+ // REJECTED (fail closed with a 401, never a 500) and the operator is told
517
+ // why. ADR-0079 s2.
518
+ if (HMAC_DIGESTS.has(resolvedAlgorithm)) {
519
+ const weak = insecureSecretMessage(resolvedSecret);
520
+ if (weak) { void _logWarning(weak); return null; }
521
+ }
522
+
463
523
  const signingInput = `${h}.${p}`;
464
524
  if (!verifySignature(signingInput, sig, resolvedSecret, resolvedAlgorithm)) {
465
525
  return null;
@@ -522,6 +582,8 @@ function sign(input: string, secret: string, algorithm: string): string {
522
582
  // the header is the one that actually produced this signature.
523
583
  const digest = HMAC_DIGESTS.get(algorithm);
524
584
  if (digest) {
585
+ const weak = insecureSecretMessage(secret);
586
+ if (weak) throw new Error(weak);
525
587
  return base64urlEncode(createHmac(digest, secret).update(input).digest());
526
588
  }
527
589
  const rsaAlgorithm = RSA_SIGN_ALGORITHMS.get(algorithm);
@@ -622,12 +684,14 @@ export function authMiddleware(secret?: string, algorithm?: string): Middleware
622
684
  }
623
685
 
624
686
  const token = authHeader.slice(7);
625
- if (!validToken(token, secret, algorithm)) {
687
+ const payload = validToken(token, secret, algorithm);
688
+ // Invalid, expired, or a form token (not an identity, ADR-0079 s1).
689
+ if (!isIdentityPayload(payload)) {
626
690
  res({ error: "Unauthorized" }, 401);
627
691
  return;
628
692
  }
629
693
 
630
- (req as any).auth = getPayload(token);
694
+ (req as any).auth = payload;
631
695
  next();
632
696
  };
633
697
  }
@@ -648,11 +712,12 @@ export function refreshToken(
648
712
  token: string,
649
713
  expiresIn: number = 60,
650
714
  ): string | null {
651
- if (!validToken(token)) return null;
652
-
653
- const payload = getPayload(token);
715
+ const payload = validToken(token);
654
716
  if (!payload) return null;
655
717
 
718
+ // Refresh preserves PURPOSE: a form token comes back as a form token (CSRF
719
+ // rotation) and so can still never pass an identity gate. The route gate
720
+ // issues a FreshToken only for an identity token. ADR-0079 s1.
656
721
  // Strip standard timing claims so getToken sets fresh ones
657
722
  const { iat: _iat, exp: _exp, ...claims } = payload;
658
723
  return getToken(claims, expiresIn);
@@ -686,7 +751,9 @@ export function authenticateRequest(
686
751
  // algorithm= silently got the env values instead, and `algorithm` defaulted to
687
752
  // the literal "HS256" which additionally shadowed TINA4_JWT_ALGORITHM. Python,
688
753
  // PHP and Ruby all honour these; Node was the last one that did not.
689
- if (validToken(token, secret, algorithm)) return getPayload(token);
754
+ const payload = validToken(token, secret, algorithm);
755
+ // A form token is not an identity (ADR-0079 s1).
756
+ if (payload) return isIdentityPayload(payload) ? payload : null;
690
757
 
691
758
  // Fallback: treat Bearer value as API key
692
759
  if (validateApiKey(token)) {
@@ -17,7 +17,8 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
17
17
  * verification layer lied. This is the same defect fixed in tina4-python under
18
18
  * #PY2; extracting the gate here lets both callers share one implementation.
19
19
  */
20
- import { validToken, getPayload, refreshToken } from "./auth.js";
20
+ import { validToken, refreshToken, isIdentityPayload } from "./auth.js";
21
+ import { liveSessionIdentity } from "./ssoIdentity.js";
21
22
  import type { Tina4Request, Tina4Response } from "./types.js";
22
23
 
23
24
  /** Just the auth-relevant fields of a matched route. */
@@ -52,52 +53,58 @@ export function enforceRouteAuth(
52
53
  return false;
53
54
  }
54
55
 
56
+ // Token slots in priority order: Bearer header, body formToken (frond.js
57
+ // puts the auth token it received as a FreshToken there), session token.
58
+ // Each slot is accepted only when it carries an IDENTITY token: a Frond
59
+ // form token (`type: "form"`) proves where a write came from, not who sent
60
+ // it, so it is skipped and the search moves on (ADR-0079 s1).
55
61
  const authHeader = req.headers.authorization ?? "";
56
62
  const headerToken = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : "";
57
63
 
58
- let resolvedToken = "";
64
+ let payload: Record<string, unknown> | null = null;
59
65
  let tokenSource: "header" | "body" | "session" | "" = "";
66
+ let resolvedToken = "";
60
67
 
61
68
  // Priority 1: Authorization Bearer header
62
- if (headerToken && validToken(headerToken)) {
63
- resolvedToken = headerToken;
64
- tokenSource = "header";
69
+ if (headerToken) {
70
+ const candidate = validToken(headerToken);
71
+ if (isIdentityPayload(candidate)) { payload = candidate; tokenSource = "header"; resolvedToken = headerToken; }
65
72
  }
66
73
 
67
74
  // Priority 2: formToken from request body
68
- if (!resolvedToken) {
75
+ if (!payload) {
69
76
  const bodyToken = (req.body as Record<string, unknown>)?.formToken as string | undefined;
70
- if (bodyToken && validToken(bodyToken)) {
71
- resolvedToken = bodyToken;
72
- tokenSource = "body";
77
+ if (bodyToken) {
78
+ const candidate = validToken(bodyToken);
79
+ if (isIdentityPayload(candidate)) { payload = candidate; tokenSource = "body"; resolvedToken = bodyToken; }
73
80
  }
74
81
  }
75
82
 
76
- // Priority 3: Session token
77
- if (!resolvedToken) {
78
- const sso = (req as any).session?.get?.("_tina4_sso") as Record<string, any> | undefined;
79
- const identity = sso?.identity;
80
- if (identity?.issuer && identity?.subject) {
83
+ // Priority 3: Session — a provider-verified SSO identity while it is live
84
+ // (ADR-0079 s5), then the session token.
85
+ if (!payload) {
86
+ const identity = liveSessionIdentity((req as any).session?.get?.("_tina4_sso"));
87
+ if (identity) {
81
88
  req.user = identity;
82
89
  // RBAC guards apply to the SSO identity too (Feature 138).
83
90
  return rbacForbidden(match, identity, res);
84
91
  }
85
92
  const sessionToken = (req as any).session?.get?.("token") as string | undefined;
86
- if (sessionToken && validToken(sessionToken)) {
87
- resolvedToken = sessionToken;
88
- tokenSource = "session";
93
+ if (sessionToken) {
94
+ const candidate = validToken(sessionToken);
95
+ if (isIdentityPayload(candidate)) { payload = candidate; tokenSource = "session"; resolvedToken = sessionToken; }
89
96
  }
90
97
  }
91
98
 
92
- if (!resolvedToken) {
99
+ if (!payload) {
93
100
  res.raw.writeHead(401, { "Content-Type": "application/json" });
94
101
  res.raw.end(JSON.stringify({ error: "Unauthorized" }));
95
102
  return true;
96
103
  }
97
104
 
98
- req.user = getPayload(resolvedToken) ?? {};
105
+ req.user = payload;
99
106
 
100
- // When a body formToken validates, return a FreshToken header with a refreshed JWT
107
+ // When a body token authenticates, return a FreshToken header with a refreshed JWT
101
108
  if (tokenSource === "body") {
102
109
  const fresh = refreshToken(resolvedToken);
103
110
  if (fresh) {
@@ -175,10 +175,17 @@ type ParsedDefinition = ParsedOperation | ParsedFragment;
175
175
  class Parser {
176
176
  private tokens: Token[];
177
177
  private pos: number;
178
+ // F2: bound the parser's own recursion. A deeply nested query
179
+ // ("{a{a{a...}}}") otherwise recurses in parseSelectionSet until the call
180
+ // stack overflows — BEFORE the execution-time depth guard can run. Reusing
181
+ // TINA4_GRAPHQL_MAX_DEPTH keeps parse and execution on one bound.
182
+ private depth = 0;
183
+ private readonly maxDepth: number;
178
184
 
179
185
  constructor(tokens: Token[]) {
180
186
  this.tokens = tokens;
181
187
  this.pos = 0;
188
+ this.maxDepth = graphqlMaxDepth();
182
189
  }
183
190
 
184
191
  peek(): Token | null {
@@ -272,6 +279,18 @@ class Parser {
272
279
  }
273
280
 
274
281
  private parseSelectionSet(): ParsedSelection[] {
282
+ this.depth++;
283
+ if (this.maxDepth > 0 && this.depth > this.maxDepth) {
284
+ throw new Error(`Query exceeds maximum depth of ${this.maxDepth}`);
285
+ }
286
+ try {
287
+ return this.parseSelectionSetInner();
288
+ } finally {
289
+ this.depth--;
290
+ }
291
+ }
292
+
293
+ private parseSelectionSetInner(): ParsedSelection[] {
275
294
  this.expect("LBRACE");
276
295
  const selections: ParsedSelection[] = [];
277
296
  while (!this.match("RBRACE")) {
@@ -488,6 +507,24 @@ export function graphqlMaxDepth(): number {
488
507
  return Number.isNaN(n) ? 50 : n;
489
508
  }
490
509
 
510
+ /**
511
+ * F2: maximum number of expanded selection nodes a single query may request.
512
+ * The depth guard bounds NESTING but not WIDTH: a fragment that spreads another
513
+ * fragment many times (a "fragment bomb") stays shallow while expanding to
514
+ * millions of fields, and a query with thousands of aliases of one field is not
515
+ * deep at all. This budget bounds the expanded selection tree (fragments
516
+ * expanded, aliases counted) so both are rejected before any resolver runs.
517
+ * `TINA4_GRAPHQL_MAX_NODES` overrides the default (1000); `<= 0` disables it.
518
+ */
519
+ export function graphqlMaxNodes(): number {
520
+ const raw = (process.env.TINA4_GRAPHQL_MAX_NODES ?? "1000").trim();
521
+ const n = parseInt(raw, 10);
522
+ return Number.isNaN(n) ? 1000 : n;
523
+ }
524
+
525
+ /** Thrown when a query's expanded selection count exceeds the node budget (F2). */
526
+ class ComplexityError extends Error {}
527
+
491
528
  // ── GraphQL Engine ───────────────────────────────────────────
492
529
 
493
530
  export class GraphQL {
@@ -515,6 +552,13 @@ export class GraphQL {
515
552
  */
516
553
  public maxDepth: number = graphqlMaxDepth();
517
554
 
555
+ /**
556
+ * Maximum expanded selection nodes per query (from `TINA4_GRAPHQL_MAX_NODES`,
557
+ * default 1000; `<= 0` disables). Bounds query WIDTH — fragment bombs and
558
+ * alias explosions — which the depth guard does not. See {@link graphqlMaxNodes}.
559
+ */
560
+ public maxNodes: number = graphqlMaxNodes();
561
+
518
562
  /**
519
563
  * Decorator-style resolver registration.
520
564
  *
@@ -692,6 +736,20 @@ export class GraphQL {
692
736
  }
693
737
 
694
738
  const op = operations[0];
739
+
740
+ // F2: reject an over-complex query (fragment bomb / alias explosion) before
741
+ // any resolver runs.
742
+ if (this.maxNodes > 0) {
743
+ try {
744
+ this.assertComplexity(op.selections, fragments);
745
+ } catch (e: unknown) {
746
+ if (e instanceof ComplexityError) {
747
+ return { data: null, errors: [{ message: e.message }] };
748
+ }
749
+ throw e;
750
+ }
751
+ }
752
+
695
753
  const resolvers = op.operation === "query" ? this.queries : this.mutations;
696
754
 
697
755
  // Apply variable defaults
@@ -723,6 +781,40 @@ export class GraphQL {
723
781
  * instead of recursing until the interpreter stack overflows. Top-level
724
782
  * starts at depth 1; `maxDepth <= 0` disables the guard.
725
783
  */
784
+ /**
785
+ * Count the expanded selection nodes, throwing {@link ComplexityError} past
786
+ * `maxNodes` (F2). Fragment spreads are expanded (so a fragment bomb is
787
+ * counted, not the small unexpanded document) and every field — including
788
+ * each alias — counts once. The count is capped by `maxNodes` and the walk by
789
+ * `maxDepth`, so a circular fragment trips the budget instead of looping and
790
+ * the check itself can never cost more than the budget.
791
+ */
792
+ private assertComplexity(
793
+ selections: ParsedSelection[],
794
+ fragments: Map<string, ParsedFragment>,
795
+ ): void {
796
+ const depthCap = this.maxDepth > 0 ? this.maxDepth : 1000;
797
+ let count = 0;
798
+ const walk = (sels: ParsedSelection[], depth: number): void => {
799
+ if (depth > depthCap) return;
800
+ for (const sel of sels) {
801
+ count++;
802
+ if (count > this.maxNodes) {
803
+ throw new ComplexityError(`Query exceeds maximum complexity of ${this.maxNodes} nodes`);
804
+ }
805
+ if (sel.kind === "fragment_spread") {
806
+ const frag = fragments.get(sel.name);
807
+ if (frag) walk(frag.selections, depth + 1);
808
+ } else if (sel.kind === "inline_fragment") {
809
+ walk(sel.selections, depth + 1);
810
+ } else if (sel.kind === "field" && sel.selections) {
811
+ walk(sel.selections, depth + 1);
812
+ }
813
+ }
814
+ };
815
+ walk(selections, 1);
816
+ }
817
+
726
818
  private async resolveSelectionsInto(
727
819
  selections: ParsedSelection[],
728
820
  resolvers: Map<string, QueryConfig>,
@@ -90,6 +90,7 @@ export type { ServiceOptions, ServiceContext, ServiceHandler, ServiceInfo } from
90
90
  export { responseCache, clearCache, cacheStats, cacheGet, cacheSet, cacheDelete, cacheClear, cacheBackendStats, sweep, createBackend, _resetBackend } from "./cache.js";
91
91
  export type { ResponseCacheConfig, CacheBackend } from "./cache.js";
92
92
  export { Api, ApiStreamError, parseLineStream, parseSseStream } from "./api.js";
93
+ export { guardUrl, isBlockedAddress, allowPrivateRequests, SsrfError, ALLOW_PRIVATE_ENV } from "./ssrf.js";
93
94
  export type { ApiResult, ApiOptions, ApiTransport, DownloadResult, UploadOptions, StreamOptions, SseEvent } from "./api.js";
94
95
  export { Context, defaultContext, existingContext, fts5Supported, _sharedContexts } from "./context/index.js";
95
96
  export type { SearchHit } from "./context/index.js";
@@ -47,6 +47,7 @@ export type QueueJob = JobData & JobLifecycle;
47
47
 
48
48
  export interface JobQueueBridge {
49
49
  _failJob(topic: string, job: QueueJob, reason: string, maxRetries: number): void;
50
+ _rejectJob(topic: string, job: QueueJob, reason: string, maxRetries: number): void;
50
51
  _retryJob(topic: string, job: QueueJob, delaySeconds?: number): void;
51
52
  _completeJob(topic: string, job: QueueJob): void;
52
53
  getMaxRetries(): number;
@@ -74,7 +75,13 @@ export function createJob(data: JobData, queue: JobQueueBridge): QueueJob {
74
75
  queue._failJob(job.topic, job, reason, queue.getMaxRetries());
75
76
  },
76
77
  reject(reason = "") {
77
- job.fail(reason);
78
+ // Reject permanently — dead-letter NOW, no retry (ADR-0023). Distinct
79
+ // from fail(): fail() retries until maxRetries is spent; reject() is for
80
+ // a message the consumer KNOWS is poison and sends it straight to the
81
+ // dead-letter store. Was a literal alias for fail() before 3.13.139.
82
+ job.status = "dead";
83
+ job.error = reason;
84
+ queue._rejectJob(job.topic, job, reason, queue.getMaxRetries());
78
85
  },
79
86
  retry(delaySeconds?: number) {
80
87
  queue._retryJob(job.topic, job, delaySeconds);
@@ -238,7 +238,9 @@ function envTruthy(val: string | undefined): boolean {
238
238
  * Python master parity: tina4_python.mcp.is_loopback.
239
239
  */
240
240
  export function isLoopback(ip: string | undefined | null): boolean {
241
- if (ip == null || ip === "") return true;
241
+ // An empty or missing peer is UNKNOWN and is not loopback: a runtime path
242
+ // that lost its socket address must fail closed (ADR-0079 s4).
243
+ if (ip == null || ip === "") return false;
242
244
  let addr = ip.trim().toLowerCase();
243
245
  if (addr.startsWith("::ffff:")) addr = addr.slice(7);
244
246
  return addr === "::1" || addr === "localhost" || addr.startsWith("127.");
@@ -8,7 +8,7 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
8
8
 
9
9
  import type { Tina4Request, Tina4Response, Middleware } from "./types.js";
10
10
  import { HTTP_OK, HTTP_FORBIDDEN } from "./constants.js";
11
- import { validToken, getPayload } from "./auth.js";
11
+ import { validToken, getPayload, isIdentityPayload } from "./auth.js";
12
12
  import { Log } from "./logger.js";
13
13
  import { isTruthy } from "./dotenv.js";
14
14
  import { defaultRouter, type Router } from "./router.js";
@@ -1060,7 +1060,9 @@ function csrfBearerIsValid(req: Tina4Request, secret: string): boolean {
1060
1060
  const authHeader = req.headers.authorization ?? "";
1061
1061
  if (!authHeader.startsWith("Bearer ")) return false;
1062
1062
  const bearerToken = authHeader.slice(7).trim();
1063
- return Boolean(bearerToken && validToken(bearerToken, secret));
1063
+ // Only an IDENTITY token marks an API client; a form token in the Bearer
1064
+ // slot is not one and does not skip the check (ADR-0079 s1).
1065
+ return Boolean(bearerToken && isIdentityPayload(validToken(bearerToken, secret)));
1064
1066
  }
1065
1067
 
1066
1068
  function csrfRequestToken(req: Tina4Request): { token: string; fromQuery: boolean } {
@@ -21,6 +21,7 @@ import {
21
21
  createSign,
22
22
  randomBytes,
23
23
  } from "node:crypto";
24
+ import { guardUrl, SsrfError } from "./ssrf.js";
24
25
 
25
26
  const CURVE = "prime256v1";
26
27
  const RECORD_SIZE = 4096;
@@ -40,6 +41,11 @@ export interface PushOptions {
40
41
  privateKey?: string;
41
42
  ttl?: number;
42
43
  urgency?: "very-low" | "low" | "normal" | "high";
44
+ /**
45
+ * SSRF guard (ADR-0084): an explicit allow-list of hosts / host:port / CIDRs
46
+ * that bypass the private-endpoint refusal even with the opt-out off.
47
+ */
48
+ allowHosts?: string[];
43
49
  }
44
50
 
45
51
  export interface PushResult {
@@ -267,10 +273,20 @@ export class Push {
267
273
  }
268
274
 
269
275
  private async deliver(endpoint: URL, config: { subject: string; publicKey: string; privateKey: string }, keys: { rawPublic: Buffer; rawPrivate: Buffer }, body: Buffer): Promise<PushResult> {
276
+ // SSRF guard (ADR-0084): refuse a private/internal push endpoint.
277
+ try {
278
+ await guardUrl(endpoint.toString(), this.options.allowHosts);
279
+ } catch (error) {
280
+ if (error instanceof SsrfError) throw new PushError(error.message);
281
+ throw error;
282
+ }
270
283
  let response: Response;
271
284
  try {
272
285
  response = await fetch(endpoint, {
273
286
  method: "POST",
287
+ // A real push service answers the POST directly; a redirect from a push
288
+ // endpoint is not followed to a private address (ADR-0084).
289
+ redirect: "manual",
274
290
  headers: {
275
291
  Authorization: `vapid t=${vapidToken(endpoint.toString(), config.subject, keys.rawPrivate, keys.rawPublic)}, k=${config.publicKey}`,
276
292
  "Content-Encoding": "aes128gcm",
@@ -127,7 +127,7 @@ export interface ConsumeOptions {
127
127
  export interface QueueBackendInterface {
128
128
  push(queue: string, payload: unknown, delay?: number, priority?: number): string;
129
129
  pop(queue: string): QueueJob | null;
130
- size(queue: string): number;
130
+ size(queue: string, status?: string): number;
131
131
  clear(queue: string): void;
132
132
  /**
133
133
  * Release whatever connection the backend holds, and be safe to call twice.
@@ -152,6 +152,7 @@ export interface QueueBackendInterface {
152
152
  // persistent-connection rewrite lands.
153
153
  complete?(queue: string, id: string): void;
154
154
  fail?(queue: string, id: string, error: string, maxRetries: number, retryBackoff: number): void;
155
+ reject?(queue: string, id: string, reason: string, maxRetries: number): void;
155
156
  /**
156
157
  * Explicit manual re-queue: returns true if the id was found and revived,
157
158
  * false otherwise (parity with Python's backend.retry_job()). A backend may
@@ -432,7 +433,7 @@ export class Queue {
432
433
  const q = this.topic;
433
434
 
434
435
  if (this.externalBackend) {
435
- return this.externalBackend.size(q);
436
+ return this.externalBackend.size(q, status);
436
437
  }
437
438
  return this.liteBackend.size(q, status);
438
439
  }
@@ -758,6 +759,18 @@ export class Queue {
758
759
  this.liteBackend.failJob(queue, job, error, maxRetries, this._retryBackoff);
759
760
  }
760
761
 
762
+ /**
763
+ * Dead-letter a job immediately — no retry (ADR-0023 reject()). Distinct from
764
+ * _failJob, which retries until maxRetries is spent.
765
+ */
766
+ _rejectJob(queue: string, job: QueueJob, reason: string, maxRetries: number): void {
767
+ if (this.externalBackend?.reject) {
768
+ this.externalBackend.reject(queue, job.id, reason, maxRetries);
769
+ return;
770
+ }
771
+ this.liteBackend.rejectJob(queue, job, reason, maxRetries);
772
+ }
773
+
761
774
  /**
762
775
  * Re-queue a job back to the main queue directory with incremented attempts.
763
776
  */
@@ -693,6 +693,21 @@ export class LiteBackend {
693
693
  * dead-letter file -- two spellings of the same intent that previously
694
694
  * diverged.
695
695
  */
696
+ /**
697
+ * Reject a job permanently — dead-letter it NOW, no retry (ADR-0023).
698
+ *
699
+ * Unlike failJob(), which requeues until maxRetries is spent, rejectJob()
700
+ * moves a known-poison job straight to the dead-letter store. attempts is
701
+ * floored at maxRetries so deadLetters() (attempts >= maxRetries) returns it
702
+ * and size("dead") agrees.
703
+ */
704
+ rejectJob(queue: string, job: QueueJob, reason: string, maxRetries: number): void {
705
+ this.clearReservation(queue, job.id);
706
+ job.attempts = Math.max((job.attempts || 0) + 1, maxRetries);
707
+ job.error = reason;
708
+ this.deadLetter(queue, job, reason);
709
+ }
710
+
696
711
  retryJob(queue: string, job: QueueJob, delaySeconds?: number): void {
697
712
  // Clear the reservation — the consumer acknowledged (with an explicit retry).
698
713
  this.clearReservation(queue, job.id);