tina4-nodejs 3.13.91 → 3.13.94

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 (135) hide show
  1. package/CLAUDE.md +16 -3
  2. package/README.md +1 -1
  3. package/package.json +12 -9
  4. package/packages/cli/dist/bin.js +1312 -987
  5. package/packages/core/dist/index.js +1312 -987
  6. package/packages/core/src/auth.ts +141 -21
  7. package/packages/core/src/devMailbox.ts +20 -44
  8. package/packages/core/src/index.ts +2 -2
  9. package/packages/core/src/messenger.ts +72 -0
  10. package/packages/core/src/queueBackends/kafkaBackend.ts +108 -12
  11. package/packages/core/src/sessionHandlers/childError.ts +72 -0
  12. package/packages/core/src/sessionHandlers/mongoClient.ts +9 -3
  13. package/packages/core/src/sessionHandlers/redisHandler.ts +18 -5
  14. package/packages/core/src/sessionHandlers/respClient.ts +5 -1
  15. package/packages/frond/dist/index.js +74 -31
  16. package/packages/frond/src/engine.ts +99 -33
  17. package/packages/orm/dist/index.js +3102 -2777
  18. package/packages/orm/src/adapters/sqlite.ts +4 -1
  19. package/packages/orm/src/database.ts +108 -8
  20. package/types/cli/src/bin.d.ts +92 -0
  21. package/types/cli/src/commands/build.d.ts +2 -0
  22. package/types/cli/src/commands/generate.d.ts +47 -0
  23. package/types/cli/src/commands/init.d.ts +1 -0
  24. package/types/cli/src/commands/metrics.d.ts +6 -0
  25. package/types/cli/src/commands/migrate.d.ts +1 -0
  26. package/types/cli/src/commands/migrateCreate.d.ts +1 -0
  27. package/types/cli/src/commands/migrateRollback.d.ts +1 -0
  28. package/types/cli/src/commands/migrateStatus.d.ts +1 -0
  29. package/types/cli/src/commands/queue.d.ts +20 -0
  30. package/types/cli/src/commands/routes.d.ts +1 -0
  31. package/types/cli/src/commands/seed.d.ts +1 -0
  32. package/types/cli/src/commands/serve.d.ts +6 -0
  33. package/types/cli/src/commands/test.d.ts +1 -0
  34. package/types/core/src/ai.d.ts +64 -0
  35. package/types/core/src/api.d.ts +262 -0
  36. package/types/core/src/auth.d.ts +154 -0
  37. package/types/core/src/authGate.d.ts +20 -0
  38. package/types/core/src/background.d.ts +34 -0
  39. package/types/core/src/cache.d.ts +160 -0
  40. package/types/core/src/constants.d.ts +38 -0
  41. package/types/core/src/container.d.ts +44 -0
  42. package/types/core/src/context/chunker.d.ts +31 -0
  43. package/types/core/src/context/index.d.ts +93 -0
  44. package/types/core/src/devAdmin.d.ts +179 -0
  45. package/types/core/src/devMailbox.d.ts +54 -0
  46. package/types/core/src/docs.d.ts +141 -0
  47. package/types/core/src/docsAutoDiscovery.d.ts +6 -0
  48. package/types/core/src/dotenv.d.ts +65 -0
  49. package/types/core/src/env.d.ts +28 -0
  50. package/types/core/src/errorOverlay.d.ts +36 -0
  51. package/types/core/src/events.d.ts +75 -0
  52. package/types/core/src/fakeData.d.ts +55 -0
  53. package/types/core/src/feedback.d.ts +90 -0
  54. package/types/core/src/graphql.d.ts +207 -0
  55. package/types/core/src/health.d.ts +22 -0
  56. package/types/core/src/htmlElement.d.ts +75 -0
  57. package/types/core/src/i18n.d.ts +37 -0
  58. package/types/core/src/index.d.ts +93 -0
  59. package/types/core/src/job.d.ts +39 -0
  60. package/types/core/src/logger.d.ts +123 -0
  61. package/types/core/src/mcp.d.ts +248 -0
  62. package/types/core/src/messenger.d.ts +191 -0
  63. package/types/core/src/metrics.d.ts +77 -0
  64. package/types/core/src/middleware.d.ts +207 -0
  65. package/types/core/src/mqtt.d.ts +257 -0
  66. package/types/core/src/mqttMessage.d.ts +67 -0
  67. package/types/core/src/plan.d.ts +96 -0
  68. package/types/core/src/projectIndex.d.ts +56 -0
  69. package/types/core/src/queue.d.ts +219 -0
  70. package/types/core/src/queueBackends/kafkaBackend.d.ts +117 -0
  71. package/types/core/src/queueBackends/liteBackend.d.ts +119 -0
  72. package/types/core/src/queueBackends/mongoBackend.d.ts +97 -0
  73. package/types/core/src/queueBackends/rabbitmqBackend.d.ts +55 -0
  74. package/types/core/src/rateLimiter.d.ts +49 -0
  75. package/types/core/src/request.d.ts +25 -0
  76. package/types/core/src/response.d.ts +28 -0
  77. package/types/core/src/routeDiscovery.d.ts +12 -0
  78. package/types/core/src/router.d.ts +355 -0
  79. package/types/core/src/scss.d.ts +19 -0
  80. package/types/core/src/server.d.ts +131 -0
  81. package/types/core/src/service.d.ts +115 -0
  82. package/types/core/src/session.d.ts +256 -0
  83. package/types/core/src/sessionHandlers/childError.d.ts +34 -0
  84. package/types/core/src/sessionHandlers/databaseHandler.d.ts +42 -0
  85. package/types/core/src/sessionHandlers/mongoClient.d.ts +24 -0
  86. package/types/core/src/sessionHandlers/mongoHandler.d.ts +61 -0
  87. package/types/core/src/sessionHandlers/redisHandler.d.ts +60 -0
  88. package/types/core/src/sessionHandlers/respClient.d.ts +22 -0
  89. package/types/core/src/sessionHandlers/valkeyHandler.d.ts +65 -0
  90. package/types/core/src/static.d.ts +2 -0
  91. package/types/core/src/test.d.ts +94 -0
  92. package/types/core/src/testClient.d.ts +36 -0
  93. package/types/core/src/testing.d.ts +58 -0
  94. package/types/core/src/types.d.ts +219 -0
  95. package/types/core/src/validator.d.ts +52 -0
  96. package/types/core/src/websocket.d.ts +376 -0
  97. package/types/core/src/websocketBackplane.d.ts +166 -0
  98. package/types/core/src/websocketConnection.d.ts +54 -0
  99. package/types/core/src/wsdl.d.ts +101 -0
  100. package/types/frond/src/engine.d.ts +263 -0
  101. package/types/frond/src/index.d.ts +2 -0
  102. package/types/orm/src/adapters/firebird.d.ts +138 -0
  103. package/types/orm/src/adapters/mongodb.d.ts +81 -0
  104. package/types/orm/src/adapters/mssql.d.ts +70 -0
  105. package/types/orm/src/adapters/mysql.d.ts +66 -0
  106. package/types/orm/src/adapters/odbc.d.ts +97 -0
  107. package/types/orm/src/adapters/postgres.d.ts +85 -0
  108. package/types/orm/src/adapters/sqlite.d.ts +56 -0
  109. package/types/orm/src/autoCrud.d.ts +73 -0
  110. package/types/orm/src/baseModel.d.ts +391 -0
  111. package/types/orm/src/cachedDatabase.d.ts +177 -0
  112. package/types/orm/src/database.d.ts +609 -0
  113. package/types/orm/src/databaseResult.d.ts +85 -0
  114. package/types/orm/src/docstore.d.ts +182 -0
  115. package/types/orm/src/fakeData.d.ts +22 -0
  116. package/types/orm/src/index.d.ts +40 -0
  117. package/types/orm/src/migration.d.ts +275 -0
  118. package/types/orm/src/model.d.ts +7 -0
  119. package/types/orm/src/query.d.ts +14 -0
  120. package/types/orm/src/queryBuilder.d.ts +173 -0
  121. package/types/orm/src/realtime/index.d.ts +7 -0
  122. package/types/orm/src/realtime/models/attachment.d.ts +43 -0
  123. package/types/orm/src/realtime/models/channel.d.ts +32 -0
  124. package/types/orm/src/realtime/models/channelMember.d.ts +32 -0
  125. package/types/orm/src/realtime/models/message.d.ts +36 -0
  126. package/types/orm/src/realtime/models/workspace.d.ts +26 -0
  127. package/types/orm/src/realtime/realtime.d.ts +24 -0
  128. package/types/orm/src/realtime/storage.d.ts +61 -0
  129. package/types/orm/src/seeder.d.ts +118 -0
  130. package/types/orm/src/sqlTranslator.d.ts +134 -0
  131. package/types/orm/src/types.d.ts +138 -0
  132. package/types/orm/src/validation.d.ts +6 -0
  133. package/types/swagger/src/generator.d.ts +46 -0
  134. package/types/swagger/src/index.d.ts +2 -0
  135. package/types/swagger/src/ui.d.ts +11 -0
@@ -127,6 +127,72 @@ export function ensureDevSecret(cwd?: string): string | null {
127
127
  return newSecret;
128
128
  }
129
129
 
130
+ // ── JWT algorithms ────────────────────────────────────────────────
131
+ //
132
+ // Mirrors the Python master (tina4_python/auth/__init__.py) — the digest is
133
+ // LOOKED UP from the configured algorithm rather than hardcoded, so the "alg"
134
+ // advertised in the header is always the one that actually produced the
135
+ // signature (python#105). TINA4_JWT_ALGORITHM is read for real, and an
136
+ // algorithm we cannot sign fails loudly instead of silently downgrading to
137
+ // HS256 (python#106).
138
+
139
+ /** HMAC algorithms → their node:crypto digest name. All in node:crypto — zero dependencies. */
140
+ const HMAC_DIGESTS = new Map<string, string>([
141
+ ["HS256", "sha256"],
142
+ ["HS384", "sha384"],
143
+ ["HS512", "sha512"],
144
+ ]);
145
+
146
+ /**
147
+ * RSA algorithms → their node:crypto sign/verify algorithm name.
148
+ *
149
+ * Node ships `node:crypto`, so RS256 is legitimately available here at zero
150
+ * dependency cost. Python and Ruby cannot do RS256 without a third-party
151
+ * package, so RS256 is a documented PHP/Node-only EXTRA, not a parity
152
+ * requirement — the HMAC family is the cross-framework contract.
153
+ */
154
+ const RSA_SIGN_ALGORITHMS = new Map<string, string>([["RS256", "RSA-SHA256"]]);
155
+
156
+ /** Every algorithm Tina4 for Node can sign and verify, in the order we advertise them. */
157
+ const SUPPORTED_ALGORITHMS: readonly string[] = [
158
+ ...HMAC_DIGESTS.keys(),
159
+ ...RSA_SIGN_ALGORITHMS.keys(),
160
+ ];
161
+
162
+ /**
163
+ * Seconds of clock skew tolerated on the "nbf" (not-before) claim.
164
+ *
165
+ * Without this, a token minted on one host and validated on another a second
166
+ * behind is rejected for no real reason; RFC 7519 explicitly allows "a small
167
+ * leeway". Same value as the Python master's `_JWT_LEEWAY_SECONDS`.
168
+ */
169
+ export const JWT_LEEWAY_SECONDS = 60;
170
+
171
+ /** The loud, actionable failure for an algorithm we cannot sign — names the supported set. */
172
+ function unsupportedAlgorithmError(algorithm: string): Error {
173
+ return new Error(
174
+ `Unsupported JWT algorithm "${algorithm}". Tina4 signs with ` +
175
+ `${SUPPORTED_ALGORITHMS.join(", ")} (HMAC via node:crypto; RS256 needs a PEM key pair). ` +
176
+ `Set TINA4_JWT_ALGORITHM to one of those.`,
177
+ );
178
+ }
179
+
180
+ /**
181
+ * Pick the JWT algorithm: explicit argument, else TINA4_JWT_ALGORITHM, else HS256.
182
+ *
183
+ * Throws (naming the supported set and the env var) when asked for an algorithm
184
+ * we cannot sign — a silent downgrade to HS256 is the whole bug in python#106.
185
+ *
186
+ * @param algorithm - Explicit algorithm; wins over the environment when given.
187
+ */
188
+ export function resolveAlgorithm(algorithm?: string): string {
189
+ const chosen = (algorithm || process.env.TINA4_JWT_ALGORITHM || "HS256").trim();
190
+ if (!HMAC_DIGESTS.has(chosen) && !RSA_SIGN_ALGORITHMS.has(chosen)) {
191
+ throw unsupportedAlgorithmError(chosen);
192
+ }
193
+ return chosen;
194
+ }
195
+
130
196
  // ── Base64url helpers (RFC 7515) ──────────────────────────────────
131
197
 
132
198
  function base64urlEncode(data: Buffer): string {
@@ -146,12 +212,20 @@ function base64urlDecode(str: string): Buffer {
146
212
  * Create a signed JWT token.
147
213
  *
148
214
  * Secret is always read from `process.env.TINA4_SECRET`.
149
- * Algorithm is read from `process.env.TINA4_JWT_ALGORITHM` (default "HS256").
215
+ * Algorithm is read from `process.env.TINA4_JWT_ALGORITHM` (default "HS256");
216
+ * HS256 / HS384 / HS512 / RS256 are supported and anything else throws.
217
+ *
218
+ * The header's `alg` is always the algorithm that actually signed the token.
219
+ *
220
+ * No `nbf` (not-before) claim is stamped — parity with Python and PHP. Pass your
221
+ * own `nbf` in the payload to post-date a token; `validToken` enforces it.
150
222
  *
151
223
  * @param payload - Claims to encode (e.g. `{ userId: 1, role: "admin" }`)
152
224
  * @param secretOrExpiresIn - Signing secret string, OR expiresIn number in MINUTES (back-compat with old 2-arg form)
153
225
  * @param expiresIn - Lifetime in MINUTES (default 60). `0` ⇒ no `exp` claim (non-expiring). Only used when secret is a string.
226
+ * @param algorithm - Overrides TINA4_JWT_ALGORITHM for this call.
154
227
  * @returns Signed JWT string: header.payload.signature
228
+ * @throws When the resolved algorithm is not one Tina4 can sign.
155
229
  */
156
230
  export function getToken(
157
231
  payload: Record<string, unknown>,
@@ -173,7 +247,8 @@ export function getToken(
173
247
  if (!resolvedSecret) {
174
248
  _warnBlankSecret();
175
249
  }
176
- const resolvedAlgorithm = algorithm ?? process.env.TINA4_JWT_ALGORITHM ?? "HS256";
250
+ // Throws on an algorithm we cannot sign — never silently downgrades to HS256.
251
+ const resolvedAlgorithm = resolveAlgorithm(algorithm);
177
252
 
178
253
  const header = { alg: resolvedAlgorithm, typ: "JWT" };
179
254
  const now = Math.floor(Date.now() / 1000);
@@ -204,30 +279,62 @@ export function getToken(
204
279
  *
205
280
  * Secret is read from `process.env.TINA4_SECRET` when not passed explicitly.
206
281
  * Algorithm is read from `process.env.TINA4_JWT_ALGORITHM` (default "HS256").
282
+ *
283
+ * Checks, in order: the header's `alg` must BE the expected algorithm (blocks alg
284
+ * substitution, including `alg: "none"`, before any signature work), then the
285
+ * signature, then `exp`, then `nbf` (with `JWT_LEEWAY_SECONDS` of clock skew).
207
286
  */
208
287
  export function validToken(token: string, secret?: string, algorithm?: string): Record<string, unknown> | null {
209
288
  const resolvedSecret = secret ?? process.env.TINA4_SECRET ?? "";
210
289
  if (!resolvedSecret) {
211
290
  _warnBlankSecret();
212
291
  }
213
- const resolvedAlgorithm = algorithm ?? process.env.TINA4_JWT_ALGORITHM ?? "HS256";
292
+ // Resolved OUTSIDE the try so a bad TINA4_JWT_ALGORITHM throws rather than
293
+ // being swallowed into a null. A misconfigured algorithm is a deployment
294
+ // error, not a bad token: swallowing it turns one actionable message into a
295
+ // silent 401 on every request, which is far harder to diagnose. It also could
296
+ // not hide the fault anyway - getToken() already throws on the same value, so
297
+ // a typo surfaces at login regardless; swallowing here only made the two paths
298
+ // disagree. Python (master, raises in the constructor) and PHP both throw.
299
+ const resolvedAlgorithm = resolveAlgorithm(algorithm);
300
+
214
301
  try {
215
302
  const parts = token.split(".");
216
303
  if (parts.length !== 3) return null;
217
304
 
218
305
  const [h, p, sig] = parts;
219
- const signingInput = `${h}.${p}`;
220
306
 
307
+ // Pin the algorithm to OUR configured one instead of trusting the token's
308
+ // header. A token asking to be verified as anything else — "none", a weaker
309
+ // HMAC, or an RSA alg when we sign HMAC — is rejected BEFORE any signature
310
+ // work, which is what blocks alg substitution. Matches the Python master.
311
+ const header = JSON.parse(base64urlDecode(h).toString()) as Record<string, unknown>;
312
+ if (header.alg !== resolvedAlgorithm) return null;
313
+
314
+ const signingInput = `${h}.${p}`;
221
315
  if (!verifySignature(signingInput, sig, resolvedSecret, resolvedAlgorithm)) {
222
316
  return null;
223
317
  }
224
318
 
225
319
  const payload = JSON.parse(base64urlDecode(p).toString()) as Record<string, unknown>;
226
320
 
227
- if (typeof payload.exp === "number" && Date.now() / 1000 > payload.exp) {
321
+ const now = Date.now() / 1000;
322
+ if (typeof payload.exp === "number" && now > payload.exp) {
228
323
  return null;
229
324
  }
230
325
 
326
+ // "nbf" (not-before): a post-dated token is not valid YET. Was honoured only
327
+ // by Ruby, so Python/PHP/Node accepted tokens their issuer had explicitly
328
+ // marked as not-yet-usable (nodejs#39 / python#107). A token with no nbf is
329
+ // unaffected — that is what keeps this non-breaking. A PRESENT but
330
+ // non-numeric nbf is rejected (Python raises a TypeError there and returns
331
+ // None; a malformed not-before must never read as "no constraint").
332
+ if (Object.hasOwn(payload, "nbf")) {
333
+ const notBefore = payload.nbf;
334
+ if (typeof notBefore !== "number" || !Number.isFinite(notBefore)) return null;
335
+ if (now + JWT_LEEWAY_SECONDS < notBefore) return null;
336
+ }
337
+
231
338
  return payload;
232
339
  } catch {
233
340
  return null;
@@ -250,21 +357,23 @@ export function getPayload(token: string): Record<string, unknown> | null {
250
357
  // ── Signing helpers ───────────────────────────────────────────────
251
358
 
252
359
  function sign(input: string, secret: string, algorithm: string): string {
253
- if (algorithm === "HS256") {
254
- const sig = createHmac("sha256", secret).update(input).digest();
255
- return base64urlEncode(sig);
360
+ // The digest comes from the configured algorithm, so the "alg" we advertise in
361
+ // the header is the one that actually produced this signature.
362
+ const digest = HMAC_DIGESTS.get(algorithm);
363
+ if (digest) {
364
+ return base64urlEncode(createHmac(digest, secret).update(input).digest());
256
365
  }
257
- if (algorithm === "RS256") {
258
- const signer = createSign("RSA-SHA256");
366
+ const rsaAlgorithm = RSA_SIGN_ALGORITHMS.get(algorithm);
367
+ if (rsaAlgorithm) {
368
+ const signer = createSign(rsaAlgorithm);
259
369
  signer.update(input);
260
- const sig = signer.sign(secret);
261
- return base64urlEncode(sig);
370
+ return base64urlEncode(signer.sign(secret));
262
371
  }
263
- throw new Error(`Unsupported algorithm: ${algorithm}`);
372
+ throw unsupportedAlgorithmError(algorithm);
264
373
  }
265
374
 
266
375
  function verifySignature(input: string, sig: string, secret: string, algorithm: string): boolean {
267
- if (algorithm === "HS256") {
376
+ if (HMAC_DIGESTS.has(algorithm)) {
268
377
  const expected = sign(input, secret, algorithm);
269
378
  // Constant-time comparison
270
379
  const a = Buffer.from(sig);
@@ -272,12 +381,13 @@ function verifySignature(input: string, sig: string, secret: string, algorithm:
272
381
  if (a.length !== b.length) return false;
273
382
  return timingSafeEqual(a, b);
274
383
  }
275
- if (algorithm === "RS256") {
276
- const verifier = createVerify("RSA-SHA256");
384
+ const rsaAlgorithm = RSA_SIGN_ALGORITHMS.get(algorithm);
385
+ if (rsaAlgorithm) {
386
+ const verifier = createVerify(rsaAlgorithm);
277
387
  verifier.update(input);
278
388
  return verifier.verify(secret, base64urlDecode(sig));
279
389
  }
280
- throw new Error(`Unsupported algorithm: ${algorithm}`);
390
+ throw unsupportedAlgorithmError(algorithm);
281
391
  }
282
392
 
283
393
  // ── Password Hashing (PBKDF2) ────────────────────────────────────
@@ -334,8 +444,14 @@ export function checkPassword(password: string, hash: string): boolean {
334
444
  * Auth middleware that extracts and verifies a Bearer JWT from the
335
445
  * Authorization header. On success, attaches the decoded payload to
336
446
  * `(request as any).auth`. On failure, sends a 401 JSON response.
447
+ *
448
+ * @param secret - Signing secret / PEM public key (default: TINA4_SECRET env var).
449
+ * @param algorithm - JWT algorithm. Omit it to honour TINA4_JWT_ALGORITHM (then
450
+ * HS256). It used to default to the literal "HS256", which SHADOWED the env
451
+ * var: an app on TINA4_JWT_ALGORITHM=HS512 minted HS512 tokens and this
452
+ * middleware verified them as HS256, rejecting every valid token.
337
453
  */
338
- export function authMiddleware(secret?: string, algorithm: string = "HS256"): Middleware {
454
+ export function authMiddleware(secret?: string, algorithm?: string): Middleware {
339
455
  return (req: Tina4Request, res: Tina4Response, next: () => void): void => {
340
456
  const authHeader = req.headers.authorization ?? "";
341
457
 
@@ -394,7 +510,7 @@ export function refreshToken(
394
510
  export function authenticateRequest(
395
511
  headers: Record<string, string | string[] | undefined>,
396
512
  secret?: string,
397
- algorithm: string = "HS256",
513
+ algorithm?: string,
398
514
  ): Record<string, unknown> | null {
399
515
  const authHeader =
400
516
  (headers.authorization ?? headers.Authorization ?? "") as string;
@@ -403,8 +519,12 @@ export function authenticateRequest(
403
519
 
404
520
  const token = authHeader.slice(7);
405
521
 
406
- // Try JWT first (secret/algorithm params kept for backward compat but validToken reads from env)
407
- if (validToken(token)) return getPayload(token);
522
+ // Both overrides are FORWARDED. They used to be accepted and dropped - the
523
+ // body called bare validToken(token), so a caller passing secret= or
524
+ // algorithm= silently got the env values instead, and `algorithm` defaulted to
525
+ // the literal "HS256" which additionally shadowed TINA4_JWT_ALGORITHM. Python,
526
+ // PHP and Ruby all honour these; Node was the last one that did not.
527
+ if (validToken(token, secret, algorithm)) return getPayload(token);
408
528
 
409
529
  // Fallback: treat Bearer value as API key
410
530
  if (validateApiKey(token)) {
@@ -15,8 +15,6 @@ import { join } from "node:path";
15
15
  import { randomUUID } from "node:crypto";
16
16
 
17
17
  import type { SendResult, EmailMessage } from "./messenger.js";
18
- import { Messenger } from "./messenger.js";
19
- import { isTruthy } from "./dotenv.js";
20
18
 
21
19
  // ── DevMailbox ───────────────────────────────────────────────
22
20
 
@@ -38,20 +36,35 @@ export class DevMailbox {
38
36
 
39
37
  /**
40
38
  * Capture an email to the dev mailbox instead of sending it.
39
+ *
40
+ * The parameter order MATCHES Messenger.send() on purpose. It did not before:
41
+ * send()'s 5th positional was `text` and capture()'s was `cc`, so the same call
42
+ * meant different things depending on which door it came through -- that mismatch
43
+ * IS nodejs#42.
44
+ *
45
+ * BREAKING: `text` is now the 5th positional. A caller passing cc positionally
46
+ * must move it. Aligning the two signatures is the fix; leaving them apart would
47
+ * preserve the bug.
41
48
  */
42
49
  capture(
43
50
  to: string | string[],
44
51
  subject: string,
45
52
  body: string,
46
53
  html: boolean = false,
47
- cc: string[] = [],
48
- bcc: string[] = [],
54
+ text?: string,
55
+ cc: string | string[] = [],
56
+ bcc: string | string[] = [],
49
57
  replyTo?: string,
50
58
  attachments: string[] = [],
51
59
  from?: string,
52
60
  ): SendResult {
53
61
  const id = randomUUID();
54
62
  const toList = Array.isArray(to) ? to : [to];
63
+ // Normalised HERE, at the boundary, so a message is well formed however it
64
+ // arrived. A dev mailbox that stores a malformed message and reports success
65
+ // defeats its own purpose -- it exists to show you what you WOULD have sent.
66
+ const ccList = Array.isArray(cc) ? cc : (cc ? [cc] : []);
67
+ const bccList = Array.isArray(bcc) ? bcc : (bcc ? [bcc] : []);
55
68
  const now = new Date().toISOString();
56
69
 
57
70
  const message: EmailMessage = {
@@ -59,11 +72,12 @@ export class DevMailbox {
59
72
  type: "outbox",
60
73
  from: from ?? process.env.TINA4_MAIL_FROM ?? "dev@localhost",
61
74
  to: toList,
62
- cc,
63
- bcc,
75
+ cc: ccList,
76
+ bcc: bccList,
64
77
  reply_to: replyTo,
65
78
  subject,
66
79
  body,
80
+ text,
67
81
  html,
68
82
  attachments,
69
83
  date: now,
@@ -280,41 +294,3 @@ export class DevMailbox {
280
294
  }
281
295
 
282
296
  // ── Factory ──────────────────────────────────────────────────
283
-
284
- /**
285
- * Create a Messenger or DevMailbox based on the environment.
286
- *
287
- * Returns DevMailbox when:
288
- * - TINA4_DEBUG is "true", OR
289
- * - No TINA4_MAIL_HOST is configured
290
- *
291
- * Returns a real Messenger otherwise (SMTP configured + not debug mode).
292
- *
293
- * This follows the factory pattern from PHP's MessengerFactory.
294
- */
295
- export function createMessenger(): Messenger | DevMailbox {
296
- const debug = process.env.TINA4_DEBUG;
297
- const smtpHost = process.env.TINA4_MAIL_HOST;
298
-
299
- // Production = NOT debug mode AND NODE_ENV is "production".
300
- // Derived here (was previously referenced undefined → ReferenceError).
301
- const isProd = !isTruthy(debug) && process.env.NODE_ENV === "production";
302
-
303
- // Force dev mode when TINA4_DEBUG is truthy
304
- if (isTruthy(debug)) {
305
- return new DevMailbox();
306
- }
307
-
308
- // No SMTP configured — must use dev mailbox
309
- if (!smtpHost) {
310
- return new DevMailbox();
311
- }
312
-
313
- // Non-production environment — use dev mailbox
314
- if (!isProd) {
315
- return new DevMailbox();
316
- }
317
-
318
- // Production with SMTP configured — use real Messenger
319
- return new Messenger();
320
- }
@@ -94,9 +94,9 @@ export {
94
94
  handleFeedbackWidgetJs,
95
95
  registerFeedbackRoutes,
96
96
  } from "./feedback.js";
97
- export { Messenger, MessengerConnectionError } from "./messenger.js";
97
+ export { Messenger, MessengerConnectionError, createMessenger } from "./messenger.js";
98
98
  export type { SendResult, EmailMessage } from "./messenger.js";
99
- export { DevMailbox, createMessenger } from "./devMailbox.js";
99
+ export { DevMailbox } from "./devMailbox.js";
100
100
  export { WSDLService, WSDLOperation } from "./wsdl.js";
101
101
  export type { WSDLOperationMeta } from "./wsdl.js";
102
102
  export { HtmlElement, htmlElement, addHtmlHelpers, Raw, SafeString } from "./htmlElement.js";
@@ -29,6 +29,7 @@ import { readFileSync } from "node:fs";
29
29
  import { basename } from "node:path";
30
30
  import { randomUUID } from "node:crypto";
31
31
  import { isTruthy } from "./dotenv.js";
32
+ import { DevMailbox } from "./devMailbox.js";
32
33
  import { Log } from "./logger.js";
33
34
 
34
35
  /**
@@ -77,6 +78,10 @@ export interface EmailMessage {
77
78
  reply_to?: string;
78
79
  subject: string;
79
80
  body: string;
81
+ /** Plain-text alternative. Carried on the dev path too, so the captured message
82
+ * is the message: a mailbox that shows you something other than what you wrote
83
+ * is worse than no mailbox. */
84
+ text?: string;
80
85
  html: boolean;
81
86
  attachments: string[];
82
87
  date: string;
@@ -328,6 +333,10 @@ export class Messenger {
328
333
  private fromName: string;
329
334
  private encryption: string;
330
335
  private useTls: boolean;
336
+ /** Whether an SMTP host was actually configured (see the constructor). */
337
+ private smtpConfigured: boolean = false;
338
+ /** The local mailbox, present only when this messenger captures. */
339
+ public devMailbox: DevMailbox | null = null;
331
340
  private imapHost: string;
332
341
  private imapPort: number;
333
342
  private imapUser: string;
@@ -337,6 +346,11 @@ export class Messenger {
337
346
  constructor(options?: MessengerOptions) {
338
347
  // Priority: constructor > TINA4_MAIL_* > sensible default.
339
348
  // Legacy SMTP_*/IMAP_* env vars were removed in v3.12 — boot guard rejects them.
349
+ // Whether a host was actually CONFIGURED, which is not the same as this.host
350
+ // being set: it falls back to "localhost", so it is never empty and cannot
351
+ // answer "can this messenger send?". The capture gate needs that answer, so
352
+ // record it here while the real inputs are still in scope.
353
+ this.smtpConfigured = Boolean(options?.host ?? process.env.TINA4_MAIL_HOST);
340
354
  this.host = options?.host
341
355
  ?? process.env.TINA4_MAIL_HOST
342
356
  ?? "localhost";
@@ -399,6 +413,31 @@ export class Messenger {
399
413
  /**
400
414
  * Send an email via SMTP.
401
415
  */
416
+ /**
417
+ * Should send() capture locally instead of talking to SMTP?
418
+ *
419
+ * Availability decides, not verbosity. With no SMTP host configured sending is
420
+ * impossible, so simulate it into a folder rather than failing -- that is what
421
+ * makes a laptop with no mail server usable. TINA4_MAIL_CAPTURE forces capture
422
+ * even when a host IS configured.
423
+ *
424
+ * TINA4_DEBUG deliberately does NOT gate this, and neither does NODE_ENV. Debug
425
+ * must still be able to send, and the old `NODE_ENV !== "production"` clause
426
+ * silently swallowed every staging email.
427
+ */
428
+ private shouldCapture(): boolean {
429
+ if (isTruthy(process.env.TINA4_MAIL_CAPTURE)) return true;
430
+ return !this.smtpConfigured;
431
+ }
432
+
433
+ /** The local mailbox, created on first capture and reused after. */
434
+ private getDevMailbox(): DevMailbox {
435
+ if (this.devMailbox === null) {
436
+ this.devMailbox = new DevMailbox();
437
+ }
438
+ return this.devMailbox;
439
+ }
440
+
402
441
  async send(
403
442
  to: string | string[],
404
443
  subject: string,
@@ -416,6 +455,17 @@ export class Messenger {
416
455
  const ccList = Array.isArray(options.cc) ? options.cc : (options.cc ? [options.cc] : []);
417
456
  const bccList = Array.isArray(options.bcc) ? options.bcc : (options.bcc ? [options.bcc] : []);
418
457
  const allRecipients = [...toList, ...ccList, ...bccList];
458
+
459
+ // Dev capture is a BRANCH here, not a different object returned by the factory.
460
+ // createMessenger() used to hand back a DevMailbox, which has capture() and no
461
+ // send(), so the documented call threw TypeError (nodejs#41).
462
+ if (this.shouldCapture()) {
463
+ return this.getDevMailbox().capture(
464
+ to, subject, body, html, text, ccList, bccList, replyTo,
465
+ attachments, this.fromAddress || undefined,
466
+ );
467
+ }
468
+
419
469
  const messageId = `${randomUUID()}@${this.host}`;
420
470
 
421
471
  if (allRecipients.length === 0) {
@@ -1069,3 +1119,25 @@ function parseFullMessage(uid: string, response: string): ImapFullMessage {
1069
1119
  headers,
1070
1120
  };
1071
1121
  }
1122
+
1123
+ /**
1124
+ * Create a Messenger configured for the current environment.
1125
+ *
1126
+ * Returns ONE concrete type, always. It used to return `Messenger | DevMailbox`,
1127
+ * and those two shared NO sending method -- DevMailbox has capture(), Messenger has
1128
+ * send() -- so the documented call threw TypeError whenever the dev branch was
1129
+ * taken. That is nodejs#41. Capture is now a branch inside Messenger.send(), so the
1130
+ * object you get back has one send() with one signature either way.
1131
+ *
1132
+ * The gate is availability, not verbosity:
1133
+ * - no TINA4_MAIL_HOST -> capture (sending is impossible, so simulate it)
1134
+ * - TINA4_MAIL_CAPTURE truthy -> capture even with SMTP configured
1135
+ * - otherwise -> send, EVEN WITH TINA4_DEBUG ON
1136
+ *
1137
+ * TINA4_DEBUG no longer forces capture: debug must still be able to send real mail.
1138
+ * The `NODE_ENV !== "production"` clause is also gone -- it captured even with SMTP
1139
+ * configured and debug off, which silently ate every staging email.
1140
+ */
1141
+ export function createMessenger(): Messenger {
1142
+ return new Messenger();
1143
+ }
@@ -497,12 +497,15 @@ export class KafkaBackend implements QueueBackend {
497
497
  if (errCode === 0) {
498
498
  finish("__PUBLISHED__", 0);
499
499
  } else {
500
+ // Report the CODE, not just "it failed" — the caller decides
501
+ // whether it is retriable (3/5, the async topic-creation race)
502
+ // or fatal (e.g. 29 TOPIC_AUTHORIZATION_FAILED).
500
503
  process.stderr.write("Produce error code " + errCode);
501
- finish("__ERROR__" + errCode, 0);
504
+ finish("__PRODUCEERROR__" + errCode, 0);
502
505
  }
503
506
  } catch (e) {
504
507
  process.stderr.write("produce parse: " + e.message);
505
- finish("__ERROR__", 0);
508
+ finish("__PARSEERROR__produce: " + e.message, 0);
506
509
  }
507
510
  return;
508
511
  } else if (operation === "get") {
@@ -516,6 +519,7 @@ export class KafkaBackend implements QueueBackend {
516
519
  pos += 4; // throttleTimeMs (v1+)
517
520
  const topicCount = buffer.readInt32BE(pos); pos += 4;
518
521
  let out = "__EMPTY__";
522
+ let fatalCode = 0;
519
523
  for (let t = 0; t < topicCount; t++) {
520
524
  const tl = buffer.readInt16BE(pos); pos += 2 + tl;
521
525
  const pc = buffer.readInt32BE(pos); pos += 4;
@@ -527,6 +531,14 @@ export class KafkaBackend implements QueueBackend {
527
531
  const abortedCount = buffer.readInt32BE(pos); pos += 4;
528
532
  if (abortedCount > 0) pos += abortedCount * 16; // (-1 => none, skip)
529
533
  const recSetSize = buffer.readInt32BE(pos); pos += 4;
534
+ // 3 = UNKNOWN_TOPIC_OR_PARTITION, 5 = LEADER_NOT_AVAILABLE:
535
+ // "nothing to read here yet", which a consumer that starts
536
+ // before its producer hits on every cold start. Any OTHER code
537
+ // (29 TOPIC_AUTHORIZATION_FAILED, 13 STALE_CONTROLLER_EPOCH, …)
538
+ // is a real failure and must NOT be reported as an empty queue.
539
+ if (errCode !== 0 && errCode !== 3 && errCode !== 5) {
540
+ fatalCode = errCode;
541
+ }
530
542
  if (errCode === 0 && recSetSize > 0) {
531
543
  const val = firstRecordValue(buffer, pos, pos + recSetSize);
532
544
  if (val !== null) out = val;
@@ -534,21 +546,33 @@ export class KafkaBackend implements QueueBackend {
534
546
  pos += recSetSize > 0 ? recSetSize : 0;
535
547
  }
536
548
  }
549
+ if (fatalCode !== 0) {
550
+ process.stderr.write("Fetch error code " + fatalCode);
551
+ finish("__FETCHERROR__" + fatalCode, 0);
552
+ return;
553
+ }
537
554
  finish(out, 0);
538
555
  } catch (e) {
556
+ // A parse failure is NOT an empty queue either — say so.
539
557
  process.stderr.write("fetch parse: " + e.message);
540
- finish("__EMPTY__", 0);
558
+ finish("__PARSEERROR__fetch: " + e.message, 0);
541
559
  }
542
560
  return;
543
561
  }
544
562
  });
545
563
 
564
+ // Report the reason on STDOUT and exit 0. Writing it to stderr and
565
+ // exiting non-zero LOST it: stderr to a pipe is an async write and
566
+ // process.exit() truncates it, so the parent saw an empty stderr and fell
567
+ // back to execFileSync's message -- which embeds this entire script.
568
+ // stdout is flushed by finish()'s write callback, so it survives.
546
569
  sock.on("error", (err) => {
547
- process.stderr.write(err.message);
548
- finish("", 1);
570
+ finish("__TRANSPORTERROR__" + err.message, 0);
549
571
  });
550
572
 
551
- var timer = setTimeout(() => { finish("", 1); }, 10000);
573
+ var timer = setTimeout(() => {
574
+ finish("__TRANSPORTERROR__timed out after 10s talking to " + host + ":" + port, 0);
575
+ }, 10000);
552
576
  `;
553
577
 
554
578
  try {
@@ -558,8 +582,59 @@ export class KafkaBackend implements QueueBackend {
558
582
  stdio: ["pipe", "pipe", "pipe"],
559
583
  });
560
584
  return result;
561
- } catch {
562
- return "";
585
+ } catch (err) {
586
+ // Reached only when the child itself could not run (spawn failure, killed,
587
+ // the outer 15s timeout). The socket-level reasons come back through
588
+ // stdout as __TRANSPORTERROR__ instead. Swallowing this to "" made every
589
+ // failure indistinguishable from an empty queue.
590
+ //
591
+ // execFileSync's own message embeds the ENTIRE generated script, so it is
592
+ // truncated here -- a 20KB error that buries the cause is barely better
593
+ // than no error at all.
594
+ const e = err as { stderr?: Buffer | string; message?: string };
595
+ const reason = String(e.stderr ?? "").trim() || e.message || "unknown error";
596
+ const firstLine = reason.split("\n", 1)[0]!.slice(0, 200);
597
+ return "__TRANSPORTERROR__" + firstLine;
598
+ }
599
+ }
600
+
601
+ /**
602
+ * Sleep synchronously between produce retries.
603
+ *
604
+ * `push()` is synchronous (the whole backend drives its socket through a child
605
+ * process), so there is no event loop to await on. `Atomics.wait` on a
606
+ * SharedArrayBuffer is the stdlib way to block a thread for a fixed time --
607
+ * no dependency, no busy-wait burning CPU.
608
+ */
609
+ private static sleepSync(ms: number): void {
610
+ const shared = new Int32Array(new SharedArrayBuffer(4));
611
+ Atomics.wait(shared, 0, 0, ms);
612
+ }
613
+
614
+ /**
615
+ * Turn a sentinel from the protocol child into a thrown error, or return.
616
+ *
617
+ * The wording matches the Python and PHP backends exactly -- the parity rule
618
+ * covers user-visible error messages, not just behaviour.
619
+ */
620
+ private static assertNoError(result: string, operation: string, topic: string): void {
621
+ const fatal = /^__(PRODUCEERROR|FETCHERROR)__(\d+)/.exec(result);
622
+ if (fatal) {
623
+ throw new Error(
624
+ `Kafka rejected the ${operation} for topic ${topic}: error code ${fatal[2]}`,
625
+ );
626
+ }
627
+ if (result.startsWith("__TRANSPORTERROR__")) {
628
+ throw new Error(
629
+ `Kafka ${operation} for topic ${topic} failed: ` +
630
+ result.slice("__TRANSPORTERROR__".length),
631
+ );
632
+ }
633
+ if (result.startsWith("__PARSEERROR__")) {
634
+ throw new Error(
635
+ `Kafka ${operation} for topic ${topic} returned an unreadable response: ` +
636
+ result.slice("__PARSEERROR__".length),
637
+ );
563
638
  }
564
639
  }
565
640
 
@@ -576,15 +651,36 @@ export class KafkaBackend implements QueueBackend {
576
651
  delayUntil: null,
577
652
  };
578
653
 
579
- const result = this.execSync("publish", queue, JSON.stringify(job));
580
- if (!result.includes("__PUBLISHED__")) {
581
- throw new Error("Kafka publish failed");
654
+ // Topic auto-creation is ASYNCHRONOUS, so a brand-new topic answers
655
+ // UNKNOWN_TOPIC_OR_PARTITION (3) or LEADER_NOT_AVAILABLE (5) on the first
656
+ // attempt while the controller is still electing a leader. Retry those
657
+ // (same 10 attempts / 200ms as the Python and PHP backends) instead of
658
+ // failing a cold-start push; every other code throws immediately.
659
+ const body = JSON.stringify(job);
660
+ let result = "";
661
+ for (let attempt = 1; attempt <= 10; attempt++) {
662
+ result = this.execSync("publish", queue, body);
663
+ if (result.includes("__PUBLISHED__")) {
664
+ return id;
665
+ }
666
+ const retriable = /^__PRODUCEERROR__(3|5)\b/.test(result);
667
+ if (!retriable || attempt === 10) {
668
+ break;
669
+ }
670
+ KafkaBackend.sleepSync(200);
582
671
  }
583
- return id;
672
+
673
+ KafkaBackend.assertNoError(result, "produce", queue);
674
+ throw new Error(`Kafka publish failed for topic ${queue}: ${result || "no response"}`);
584
675
  }
585
676
 
586
677
  pop(queue: string): QueueJob | null {
587
678
  const result = this.execSync("get", queue);
679
+
680
+ // A real failure must NOT read as an empty queue: a mis-permissioned
681
+ // consumer would otherwise poll an "idle" topic forever.
682
+ KafkaBackend.assertNoError(result, "fetch", queue);
683
+
588
684
  if (!result || result === "__EMPTY__" || result === "__UNSUPPORTED__") return null;
589
685
 
590
686
  try {