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.
- package/CLAUDE.md +16 -3
- package/README.md +1 -1
- package/package.json +12 -9
- package/packages/cli/dist/bin.js +1312 -987
- package/packages/core/dist/index.js +1312 -987
- package/packages/core/src/auth.ts +141 -21
- package/packages/core/src/devMailbox.ts +20 -44
- package/packages/core/src/index.ts +2 -2
- package/packages/core/src/messenger.ts +72 -0
- package/packages/core/src/queueBackends/kafkaBackend.ts +108 -12
- package/packages/core/src/sessionHandlers/childError.ts +72 -0
- package/packages/core/src/sessionHandlers/mongoClient.ts +9 -3
- package/packages/core/src/sessionHandlers/redisHandler.ts +18 -5
- package/packages/core/src/sessionHandlers/respClient.ts +5 -1
- package/packages/frond/dist/index.js +74 -31
- package/packages/frond/src/engine.ts +99 -33
- package/packages/orm/dist/index.js +3102 -2777
- package/packages/orm/src/adapters/sqlite.ts +4 -1
- package/packages/orm/src/database.ts +108 -8
- package/types/cli/src/bin.d.ts +92 -0
- package/types/cli/src/commands/build.d.ts +2 -0
- package/types/cli/src/commands/generate.d.ts +47 -0
- package/types/cli/src/commands/init.d.ts +1 -0
- package/types/cli/src/commands/metrics.d.ts +6 -0
- package/types/cli/src/commands/migrate.d.ts +1 -0
- package/types/cli/src/commands/migrateCreate.d.ts +1 -0
- package/types/cli/src/commands/migrateRollback.d.ts +1 -0
- package/types/cli/src/commands/migrateStatus.d.ts +1 -0
- package/types/cli/src/commands/queue.d.ts +20 -0
- package/types/cli/src/commands/routes.d.ts +1 -0
- package/types/cli/src/commands/seed.d.ts +1 -0
- package/types/cli/src/commands/serve.d.ts +6 -0
- package/types/cli/src/commands/test.d.ts +1 -0
- package/types/core/src/ai.d.ts +64 -0
- package/types/core/src/api.d.ts +262 -0
- package/types/core/src/auth.d.ts +154 -0
- package/types/core/src/authGate.d.ts +20 -0
- package/types/core/src/background.d.ts +34 -0
- package/types/core/src/cache.d.ts +160 -0
- package/types/core/src/constants.d.ts +38 -0
- package/types/core/src/container.d.ts +44 -0
- package/types/core/src/context/chunker.d.ts +31 -0
- package/types/core/src/context/index.d.ts +93 -0
- package/types/core/src/devAdmin.d.ts +179 -0
- package/types/core/src/devMailbox.d.ts +54 -0
- package/types/core/src/docs.d.ts +141 -0
- package/types/core/src/docsAutoDiscovery.d.ts +6 -0
- package/types/core/src/dotenv.d.ts +65 -0
- package/types/core/src/env.d.ts +28 -0
- package/types/core/src/errorOverlay.d.ts +36 -0
- package/types/core/src/events.d.ts +75 -0
- package/types/core/src/fakeData.d.ts +55 -0
- package/types/core/src/feedback.d.ts +90 -0
- package/types/core/src/graphql.d.ts +207 -0
- package/types/core/src/health.d.ts +22 -0
- package/types/core/src/htmlElement.d.ts +75 -0
- package/types/core/src/i18n.d.ts +37 -0
- package/types/core/src/index.d.ts +93 -0
- package/types/core/src/job.d.ts +39 -0
- package/types/core/src/logger.d.ts +123 -0
- package/types/core/src/mcp.d.ts +248 -0
- package/types/core/src/messenger.d.ts +191 -0
- package/types/core/src/metrics.d.ts +77 -0
- package/types/core/src/middleware.d.ts +207 -0
- package/types/core/src/mqtt.d.ts +257 -0
- package/types/core/src/mqttMessage.d.ts +67 -0
- package/types/core/src/plan.d.ts +96 -0
- package/types/core/src/projectIndex.d.ts +56 -0
- package/types/core/src/queue.d.ts +219 -0
- package/types/core/src/queueBackends/kafkaBackend.d.ts +117 -0
- package/types/core/src/queueBackends/liteBackend.d.ts +119 -0
- package/types/core/src/queueBackends/mongoBackend.d.ts +97 -0
- package/types/core/src/queueBackends/rabbitmqBackend.d.ts +55 -0
- package/types/core/src/rateLimiter.d.ts +49 -0
- package/types/core/src/request.d.ts +25 -0
- package/types/core/src/response.d.ts +28 -0
- package/types/core/src/routeDiscovery.d.ts +12 -0
- package/types/core/src/router.d.ts +355 -0
- package/types/core/src/scss.d.ts +19 -0
- package/types/core/src/server.d.ts +131 -0
- package/types/core/src/service.d.ts +115 -0
- package/types/core/src/session.d.ts +256 -0
- package/types/core/src/sessionHandlers/childError.d.ts +34 -0
- package/types/core/src/sessionHandlers/databaseHandler.d.ts +42 -0
- package/types/core/src/sessionHandlers/mongoClient.d.ts +24 -0
- package/types/core/src/sessionHandlers/mongoHandler.d.ts +61 -0
- package/types/core/src/sessionHandlers/redisHandler.d.ts +60 -0
- package/types/core/src/sessionHandlers/respClient.d.ts +22 -0
- package/types/core/src/sessionHandlers/valkeyHandler.d.ts +65 -0
- package/types/core/src/static.d.ts +2 -0
- package/types/core/src/test.d.ts +94 -0
- package/types/core/src/testClient.d.ts +36 -0
- package/types/core/src/testing.d.ts +58 -0
- package/types/core/src/types.d.ts +219 -0
- package/types/core/src/validator.d.ts +52 -0
- package/types/core/src/websocket.d.ts +376 -0
- package/types/core/src/websocketBackplane.d.ts +166 -0
- package/types/core/src/websocketConnection.d.ts +54 -0
- package/types/core/src/wsdl.d.ts +101 -0
- package/types/frond/src/engine.d.ts +263 -0
- package/types/frond/src/index.d.ts +2 -0
- package/types/orm/src/adapters/firebird.d.ts +138 -0
- package/types/orm/src/adapters/mongodb.d.ts +81 -0
- package/types/orm/src/adapters/mssql.d.ts +70 -0
- package/types/orm/src/adapters/mysql.d.ts +66 -0
- package/types/orm/src/adapters/odbc.d.ts +97 -0
- package/types/orm/src/adapters/postgres.d.ts +85 -0
- package/types/orm/src/adapters/sqlite.d.ts +56 -0
- package/types/orm/src/autoCrud.d.ts +73 -0
- package/types/orm/src/baseModel.d.ts +391 -0
- package/types/orm/src/cachedDatabase.d.ts +177 -0
- package/types/orm/src/database.d.ts +609 -0
- package/types/orm/src/databaseResult.d.ts +85 -0
- package/types/orm/src/docstore.d.ts +182 -0
- package/types/orm/src/fakeData.d.ts +22 -0
- package/types/orm/src/index.d.ts +40 -0
- package/types/orm/src/migration.d.ts +275 -0
- package/types/orm/src/model.d.ts +7 -0
- package/types/orm/src/query.d.ts +14 -0
- package/types/orm/src/queryBuilder.d.ts +173 -0
- package/types/orm/src/realtime/index.d.ts +7 -0
- package/types/orm/src/realtime/models/attachment.d.ts +43 -0
- package/types/orm/src/realtime/models/channel.d.ts +32 -0
- package/types/orm/src/realtime/models/channelMember.d.ts +32 -0
- package/types/orm/src/realtime/models/message.d.ts +36 -0
- package/types/orm/src/realtime/models/workspace.d.ts +26 -0
- package/types/orm/src/realtime/realtime.d.ts +24 -0
- package/types/orm/src/realtime/storage.d.ts +61 -0
- package/types/orm/src/seeder.d.ts +118 -0
- package/types/orm/src/sqlTranslator.d.ts +134 -0
- package/types/orm/src/types.d.ts +138 -0
- package/types/orm/src/validation.d.ts +6 -0
- package/types/swagger/src/generator.d.ts +46 -0
- package/types/swagger/src/index.d.ts +2 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
258
|
-
|
|
366
|
+
const rsaAlgorithm = RSA_SIGN_ALGORITHMS.get(algorithm);
|
|
367
|
+
if (rsaAlgorithm) {
|
|
368
|
+
const signer = createSign(rsaAlgorithm);
|
|
259
369
|
signer.update(input);
|
|
260
|
-
|
|
261
|
-
return base64urlEncode(sig);
|
|
370
|
+
return base64urlEncode(signer.sign(secret));
|
|
262
371
|
}
|
|
263
|
-
throw
|
|
372
|
+
throw unsupportedAlgorithmError(algorithm);
|
|
264
373
|
}
|
|
265
374
|
|
|
266
375
|
function verifySignature(input: string, sig: string, secret: string, algorithm: string): boolean {
|
|
267
|
-
if (algorithm
|
|
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
|
-
|
|
276
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
407
|
-
|
|
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
|
-
|
|
48
|
-
|
|
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
|
|
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("
|
|
504
|
+
finish("__PRODUCEERROR__" + errCode, 0);
|
|
502
505
|
}
|
|
503
506
|
} catch (e) {
|
|
504
507
|
process.stderr.write("produce parse: " + e.message);
|
|
505
|
-
finish("
|
|
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("
|
|
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
|
-
|
|
548
|
-
finish("", 1);
|
|
570
|
+
finish("__TRANSPORTERROR__" + err.message, 0);
|
|
549
571
|
});
|
|
550
572
|
|
|
551
|
-
var timer = setTimeout(() => {
|
|
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
|
-
|
|
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
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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
|
-
|
|
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 {
|