tina4-nodejs 3.13.104 → 3.13.108
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 -2
- package/README.md +3 -4
- package/package.json +1 -1
- package/packages/cli/dist/bin.js +233 -43
- package/packages/core/dist/index.js +233 -43
- package/packages/core/public/js/tina4js.min.js +3 -3
- package/packages/core/src/authGate.ts +61 -1
- package/packages/core/src/queue.ts +75 -18
- package/packages/core/src/queueBackends/liteBackend.ts +17 -1
- package/packages/core/src/queueBackends/mongoBackend.ts +80 -14
- package/packages/core/src/router.ts +38 -0
- package/packages/core/src/server.ts +29 -1
- package/packages/core/src/types.ts +4 -0
- package/packages/orm/dist/index.js +233 -43
- package/packages/orm/src/baseModel.ts +18 -5
- package/packages/orm/src/migration.ts +14 -5
- package/types/core/src/authGate.d.ts +3 -0
- package/types/core/src/queue.d.ts +41 -4
- package/types/core/src/queueBackends/liteBackend.d.ts +7 -1
- package/types/core/src/queueBackends/mongoBackend.d.ts +7 -2
- package/types/core/src/router.d.ts +16 -0
- package/types/core/src/types.d.ts +4 -0
- package/types/orm/src/baseModel.d.ts +10 -5
|
@@ -16,6 +16,9 @@ import type { Tina4Request, Tina4Response } from "./types.js";
|
|
|
16
16
|
export interface AuthGateRoute {
|
|
17
17
|
secure?: boolean;
|
|
18
18
|
noAuth?: boolean;
|
|
19
|
+
/** RBAC guard groups (Feature 138): OR within a group, AND across groups. */
|
|
20
|
+
requiredRoles?: string[][];
|
|
21
|
+
requiredPerms?: string[][];
|
|
19
22
|
}
|
|
20
23
|
|
|
21
24
|
/**
|
|
@@ -68,7 +71,8 @@ export function enforceRouteAuth(
|
|
|
68
71
|
const identity = sso?.identity;
|
|
69
72
|
if (identity?.issuer && identity?.subject) {
|
|
70
73
|
req.user = identity;
|
|
71
|
-
|
|
74
|
+
// RBAC guards apply to the SSO identity too (Feature 138).
|
|
75
|
+
return rbacForbidden(match, identity, res);
|
|
72
76
|
}
|
|
73
77
|
const sessionToken = (req as any).session?.get?.("token") as string | undefined;
|
|
74
78
|
if (sessionToken && validToken(sessionToken)) {
|
|
@@ -93,5 +97,61 @@ export function enforceRouteAuth(
|
|
|
93
97
|
}
|
|
94
98
|
}
|
|
95
99
|
|
|
100
|
+
// ── RBAC guards (Feature 138): authorization AFTER authentication ──
|
|
101
|
+
// Auth has passed (401 ruled out above). If the route carries role/permission
|
|
102
|
+
// guards, the verified payload must satisfy them, else 403.
|
|
103
|
+
return rbacForbidden(match, req.user, res);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Read a claim as a list of strings; coerce a legacy singular string. */
|
|
107
|
+
function rbacClaimList(subject: Record<string, unknown>, key: string, legacy?: string): string[] {
|
|
108
|
+
const coerce = (v: unknown): string[] => {
|
|
109
|
+
if (typeof v === "string") return v === "" ? [] : [v];
|
|
110
|
+
if (Array.isArray(v)) return v.map((x) => String(x)).filter((x) => x !== "");
|
|
111
|
+
return [];
|
|
112
|
+
};
|
|
113
|
+
let out = coerce(subject[key]);
|
|
114
|
+
if (out.length === 0 && legacy) out = coerce(subject[legacy]);
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* True if any GRANTED permission satisfies the concrete REQUIRED one.
|
|
120
|
+
* `*` grants everything; `posts.*` grants `posts.<...>` on the dot boundary.
|
|
121
|
+
*/
|
|
122
|
+
function rbacPermGranted(granted: string[], required: string): boolean {
|
|
123
|
+
return granted.some(
|
|
124
|
+
(g) => g === "*" || g === required || (g.endsWith(".*") && required.startsWith(g.slice(0, -1))),
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Write a 403 and return `true` when a route's RBAC guards are not satisfied by
|
|
130
|
+
* the verified payload; return `false` (no write) when authorised or unguarded.
|
|
131
|
+
* AND across guard groups, OR within a group. Feature 138 / ADR-0058.
|
|
132
|
+
*/
|
|
133
|
+
function rbacForbidden(match: AuthGateRoute, payload: unknown, res: Tina4Response): boolean {
|
|
134
|
+
const requiredRoles = match.requiredRoles ?? [];
|
|
135
|
+
const requiredPerms = match.requiredPerms ?? [];
|
|
136
|
+
if (requiredRoles.length === 0 && requiredPerms.length === 0) {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
const subject =
|
|
140
|
+
payload && typeof payload === "object" ? (payload as Record<string, unknown>) : {};
|
|
141
|
+
|
|
142
|
+
const roles = rbacClaimList(subject, "roles", "role");
|
|
143
|
+
for (const group of requiredRoles) {
|
|
144
|
+
if (!group.some((r) => roles.includes(r))) return writeForbidden(res);
|
|
145
|
+
}
|
|
146
|
+
const perms = rbacClaimList(subject, "permissions");
|
|
147
|
+
for (const group of requiredPerms) {
|
|
148
|
+
if (!group.some((p) => rbacPermGranted(perms, p))) return writeForbidden(res);
|
|
149
|
+
}
|
|
96
150
|
return false;
|
|
97
151
|
}
|
|
152
|
+
|
|
153
|
+
function writeForbidden(res: Tina4Response): boolean {
|
|
154
|
+
res.raw.writeHead(403, { "Content-Type": "application/json" });
|
|
155
|
+
res.raw.end(JSON.stringify({ error: "Forbidden" }));
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
@@ -144,7 +144,13 @@ export interface QueueBackendInterface {
|
|
|
144
144
|
// persistent-connection rewrite lands.
|
|
145
145
|
complete?(queue: string, id: string): void;
|
|
146
146
|
fail?(queue: string, id: string, error: string, maxRetries: number, retryBackoff: number): void;
|
|
147
|
-
|
|
147
|
+
/**
|
|
148
|
+
* Explicit manual re-queue: returns true if the id was found and revived,
|
|
149
|
+
* false otherwise (parity with Python's backend.retry_job()). A backend may
|
|
150
|
+
* legacy-return void; callers coerce a void return to true so nothing that
|
|
151
|
+
* used to be reported as success silently flips to failure.
|
|
152
|
+
*/
|
|
153
|
+
retry?(queue: string, id: string, delaySeconds?: number): boolean | void;
|
|
148
154
|
deadLetters?(queue: string, maxRetries?: number): QueueJob[];
|
|
149
155
|
failed?(queue: string, maxRetries?: number): QueueJob[];
|
|
150
156
|
retryFailed?(queue: string, maxRetries?: number): number;
|
|
@@ -400,7 +406,19 @@ export class Queue {
|
|
|
400
406
|
}
|
|
401
407
|
|
|
402
408
|
/**
|
|
403
|
-
* Count jobs
|
|
409
|
+
* Count jobs by status. Defaults to "pending".
|
|
410
|
+
*
|
|
411
|
+
* ``"pending"`` counts jobs waiting to be popped -- INCLUDES retryable-
|
|
412
|
+
* but-attempted ones, because they live in the pending queue under the
|
|
413
|
+
* auto-retry lifecycle (see failed()).
|
|
414
|
+
* ``"reserved"`` counts jobs a consumer has popped but not yet
|
|
415
|
+
* completed/failed (in-flight against the visibility timeout).
|
|
416
|
+
* ``"completed"`` counts jobs the consumer has finished successfully.
|
|
417
|
+
* ``"failed"``, ``"dead"``, ``"dead_letter"`` are ALIASES that all count
|
|
418
|
+
* the dead-letter store -- jobs whose attempts >= maxRetries and that
|
|
419
|
+
* have given up. Use deadLetters() to list them. Retryable-but-attempted
|
|
420
|
+
* jobs are NOT counted by size("failed"); use failed() to list them or
|
|
421
|
+
* size("pending") to include them in a total.
|
|
404
422
|
*/
|
|
405
423
|
size(status: string = "pending"): number {
|
|
406
424
|
const q = this.topic;
|
|
@@ -455,13 +473,21 @@ export class Queue {
|
|
|
455
473
|
/**
|
|
456
474
|
* Get jobs that failed at least once but are still being retried
|
|
457
475
|
* (0 < attempts < maxRetries). These live in the pending queue under the
|
|
458
|
-
* auto-retry lifecycle
|
|
476
|
+
* auto-retry lifecycle (fail() re-queues them with an incremented attempts
|
|
477
|
+
* count and a retryBackoff delay) so pop() picks them up again. They are
|
|
478
|
+
* NOT counted by size("failed") -- that alias counts the dead-letter store,
|
|
479
|
+
* matching deadLetters(). To include retryable-failed jobs in a total, use
|
|
480
|
+
* size("pending"). Terminal failures are returned by deadLetters().
|
|
459
481
|
*/
|
|
460
482
|
failed(): QueueJob[] {
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
483
|
+
const raw = this.externalBackend?.failed
|
|
484
|
+
? this.externalBackend.failed(this.topic, this._maxRetries)
|
|
485
|
+
: this.liteBackend.failed(this.topic, this._maxRetries);
|
|
486
|
+
// Wrap so callers get the full Job lifecycle (parity with deadLetters()
|
|
487
|
+
// and Python's failed()).
|
|
488
|
+
return raw.map((data) =>
|
|
489
|
+
createJob({ ...(data as JobData), topic: (data as JobData).topic ?? this.topic }, this),
|
|
490
|
+
);
|
|
465
491
|
}
|
|
466
492
|
|
|
467
493
|
/**
|
|
@@ -473,21 +499,31 @@ export class Queue {
|
|
|
473
499
|
*/
|
|
474
500
|
retry(jobId?: string, delaySeconds?: number): boolean {
|
|
475
501
|
if (jobId) {
|
|
476
|
-
// Retry a specific job by ID
|
|
502
|
+
// Retry a specific job by ID. Honour whatever the external backend
|
|
503
|
+
// returns (a boolean) so an unknown id reports false; only coerce a
|
|
504
|
+
// legacy void return to true to preserve the pre-3.13.105 contract on
|
|
505
|
+
// a backend that hasn't been updated (LiteBackend already returns bool).
|
|
477
506
|
if (this.externalBackend?.retry) {
|
|
478
|
-
this.externalBackend.retry(this.topic, jobId, delaySeconds);
|
|
479
|
-
return true;
|
|
507
|
+
const result = this.externalBackend.retry(this.topic, jobId, delaySeconds);
|
|
508
|
+
return result === undefined ? true : Boolean(result);
|
|
480
509
|
}
|
|
481
510
|
return this.liteBackend.retry(this.topic, jobId, delaySeconds);
|
|
482
511
|
}
|
|
483
|
-
// Retry
|
|
512
|
+
// Retry ALL dead-letter jobs -- an explicit for...of iterates every
|
|
513
|
+
// entry rather than a reducer like .some() that would short-circuit on
|
|
514
|
+
// the first truthy result (PY-12-04 parity: Python's generator-inside-
|
|
515
|
+
// any() had exactly that bug pre-3.13.105 and left the remaining
|
|
516
|
+
// dead letters silently in the store).
|
|
484
517
|
const deadJobs = this.deadLetters();
|
|
485
518
|
if (deadJobs.length === 0) return false;
|
|
486
519
|
let retried = false;
|
|
487
520
|
for (const job of deadJobs) {
|
|
488
521
|
if (this.externalBackend?.retry) {
|
|
489
|
-
this.externalBackend.retry(this.topic, job.id, delaySeconds);
|
|
490
|
-
|
|
522
|
+
const result = this.externalBackend.retry(this.topic, job.id, delaySeconds);
|
|
523
|
+
// A modern backend returns bool; a legacy backend returns void which
|
|
524
|
+
// we optimistically treat as revived (parity with the pre-3.13.105
|
|
525
|
+
// pathway that never had a way to know otherwise).
|
|
526
|
+
if (result === undefined || Boolean(result)) retried = true;
|
|
491
527
|
} else if (this.liteBackend.retry(this.topic, job.id, delaySeconds)) {
|
|
492
528
|
retried = true;
|
|
493
529
|
}
|
|
@@ -496,13 +532,34 @@ export class Queue {
|
|
|
496
532
|
}
|
|
497
533
|
|
|
498
534
|
/**
|
|
499
|
-
* Get
|
|
535
|
+
* Get jobs that exceeded max_retries -- terminal failures.
|
|
536
|
+
*
|
|
537
|
+
* Same set counted by size("failed") / size("dead") / size("dead_letter")
|
|
538
|
+
* (three aliases for the dead-letter store). To LIST retryable-but-
|
|
539
|
+
* attempted jobs (attempts > 0 AND attempts < maxRetries) that are still
|
|
540
|
+
* being auto-retried, use failed() -- those live in the pending queue and
|
|
541
|
+
* are NOT dead letters.
|
|
542
|
+
*
|
|
543
|
+
* Returns Job objects with the failure reason on ``.error`` (not raw dicts)
|
|
544
|
+
* so callers can iterate uniformly with the rest of the queue API and, in
|
|
545
|
+
* particular, call ``.retry()`` on each to manually revive it:
|
|
546
|
+
*
|
|
547
|
+
* for (const job of queue.deadLetters()) {
|
|
548
|
+
* Log.warn(`revived ${job.id}: ${job.error}`);
|
|
549
|
+
* job.retry();
|
|
550
|
+
* }
|
|
500
551
|
*/
|
|
501
552
|
deadLetters(maxRetries?: number): QueueJob[] {
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
553
|
+
const raw = this.externalBackend?.deadLetters
|
|
554
|
+
? this.externalBackend.deadLetters(this.topic, maxRetries ?? this._maxRetries)
|
|
555
|
+
: this.liteBackend.deadLetters(this.topic, maxRetries ?? this._maxRetries);
|
|
556
|
+
// Wrap so ``job.retry()`` / ``job.fail()`` / ``job.complete()`` work
|
|
557
|
+
// uniformly (parity with pop() and the Python master). Preserves the
|
|
558
|
+
// job's own topic so the lifecycle methods route back to THIS queue's
|
|
559
|
+
// backend even on a job that dead-lettered on a different topic.
|
|
560
|
+
return raw.map((data) =>
|
|
561
|
+
createJob({ ...(data as JobData), topic: (data as JobData).topic ?? this.topic }, this),
|
|
562
|
+
);
|
|
506
563
|
}
|
|
507
564
|
|
|
508
565
|
/**
|
|
@@ -677,11 +677,27 @@ export class LiteBackend {
|
|
|
677
677
|
* Explicit re-queue requested by the caller (job.retry()).
|
|
678
678
|
*
|
|
679
679
|
* Always re-enqueues regardless of the retry limit — manual override,
|
|
680
|
-
* distinct from the automatic failJob() path.
|
|
680
|
+
* distinct from the automatic failJob() path. Cleans up BOTH the
|
|
681
|
+
* reservation record AND any dead-letter file for this id, so a caller
|
|
682
|
+
* that iterates deadLetters() and calls .retry() on each doesn't leave
|
|
683
|
+
* the failed/ directory carrying duplicates (PY-12-05, 3.13.105).
|
|
684
|
+
* Aligns with retry(queue, jobId) which had always unlinked the
|
|
685
|
+
* dead-letter file -- two spellings of the same intent that previously
|
|
686
|
+
* diverged.
|
|
681
687
|
*/
|
|
682
688
|
retryJob(queue: string, job: QueueJob, delaySeconds?: number): void {
|
|
683
689
|
// Clear the reservation — the consumer acknowledged (with an explicit retry).
|
|
684
690
|
this.clearReservation(queue, job.id);
|
|
691
|
+
// Drop any dead-letter file for this id BEFORE the re-queue -- if this
|
|
692
|
+
// job came from deadLetters() it lives in failed/ and would otherwise
|
|
693
|
+
// stay on disk while a fresh pending file appears in the queue dir, so
|
|
694
|
+
// the next deadLetters() call reports the job again and a consumer
|
|
695
|
+
// processes it twice.
|
|
696
|
+
try {
|
|
697
|
+
unlinkSync(join(this.ensureFailedDir(queue), `${job.id}.queue-data`));
|
|
698
|
+
} catch {
|
|
699
|
+
// ENOENT is fine (the job never dead-lettered or was already cleared).
|
|
700
|
+
}
|
|
685
701
|
job.attempts = (job.attempts || 0) + 1;
|
|
686
702
|
job.error = undefined;
|
|
687
703
|
this.requeue(queue, job, delaySeconds ?? 0, undefined);
|
|
@@ -343,17 +343,67 @@ export class MongoBackend implements QueueBackend {
|
|
|
343
343
|
process.stdout.write("__OK__");
|
|
344
344
|
}
|
|
345
345
|
else if (operation === "retry") {
|
|
346
|
-
// Explicit manual re-queue (
|
|
347
|
-
//
|
|
346
|
+
// Explicit manual re-queue. Serves BOTH Queue.retry(id) (revive
|
|
347
|
+
// a dead-letter job) AND job.retry() (manual re-queue of a live
|
|
348
|
+
// reserved/pending job) so the Mongo backend matches
|
|
349
|
+
// LiteBackend's dual behaviour.
|
|
350
|
+
//
|
|
351
|
+
// 1) DL revival (Queue.retry(id) after fail exhausted retries).
|
|
352
|
+
// Pre-3.13.105 this branch was BROKEN: the search filter was
|
|
353
|
+
// { queue: queueName, id, status: "failed" } -- three separate
|
|
354
|
+
// reasons it could never match. dead_letter() inserts under
|
|
355
|
+
// queueName + ".dead_letter" (not queueName), carries
|
|
356
|
+
// status "dead" (not "failed"), and the original under
|
|
357
|
+
// queueName was already acked to "completed" by the time the
|
|
358
|
+
// DL was written. Now we look up in the DL namespace by id,
|
|
359
|
+
// delete the DL doc first (so an interrupted retry never
|
|
360
|
+
// leaves both a DL and a fresh pending doc), and upsert the
|
|
361
|
+
// original back to pending -- re-hydrating if the original
|
|
362
|
+
// was purged (housekeeping) so a retry always works.
|
|
363
|
+
// 2) Live-doc manual re-queue (job.retry() on a job the caller
|
|
364
|
+
// just popped and wants back in pending). The live-doc path
|
|
365
|
+
// is preserved from before 3.13.105.
|
|
366
|
+
//
|
|
367
|
+
// Returns __OK__ when either path acted; __NOT_FOUND__ when
|
|
368
|
+
// neither the DL nor the live doc existed, so Queue.retry(id)
|
|
369
|
+
// can now report the pre-3.13.105 blanket-true as false for
|
|
370
|
+
// unknown ids. data = JSON { id, delaySeconds }.
|
|
348
371
|
const info = JSON.parse(data);
|
|
372
|
+
const dlTopic = queueName + ".dead_letter";
|
|
373
|
+
const now = new Date().toISOString();
|
|
349
374
|
const avail = info.delaySeconds > 0
|
|
350
375
|
? new Date(Date.now() + info.delaySeconds * 1000).toISOString()
|
|
351
|
-
:
|
|
352
|
-
await col.
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
376
|
+
: now;
|
|
377
|
+
const dlDoc = await col.findOne({ queue: dlTopic, id: info.id });
|
|
378
|
+
if (dlDoc !== null) {
|
|
379
|
+
await col.deleteOne({ _id: dlDoc._id });
|
|
380
|
+
const payload = dlDoc.payload ?? {};
|
|
381
|
+
const priority = dlDoc.priority ?? 0;
|
|
382
|
+
await col.updateOne(
|
|
383
|
+
{ queue: queueName, id: info.id },
|
|
384
|
+
{
|
|
385
|
+
$set: {
|
|
386
|
+
status: "pending",
|
|
387
|
+
availableAt: avail,
|
|
388
|
+
reservedAt: null,
|
|
389
|
+
error: null,
|
|
390
|
+
payload,
|
|
391
|
+
priority,
|
|
392
|
+
id: info.id,
|
|
393
|
+
createdAt: dlDoc.createdAt ?? now,
|
|
394
|
+
},
|
|
395
|
+
$inc: { attempts: 1 },
|
|
396
|
+
},
|
|
397
|
+
{ upsert: true },
|
|
398
|
+
);
|
|
399
|
+
process.stdout.write("__OK__");
|
|
400
|
+
} else {
|
|
401
|
+
const result = await col.updateOne(
|
|
402
|
+
{ queue: queueName, id: info.id },
|
|
403
|
+
{ $set: { status: "pending", availableAt: avail, reservedAt: null }, $inc: { attempts: 1 } },
|
|
404
|
+
);
|
|
405
|
+
process.stdout.write(result.matchedCount > 0 ? "__OK__" : "__NOT_FOUND__");
|
|
406
|
+
}
|
|
357
407
|
}
|
|
358
408
|
else if (operation === "deadLetters") {
|
|
359
409
|
const docs = await col.find({ queue: queueName + ".dead_letter" }).toArray();
|
|
@@ -398,10 +448,20 @@ export class MongoBackend implements QueueBackend {
|
|
|
398
448
|
process.stdout.write(String(revived));
|
|
399
449
|
}
|
|
400
450
|
else if (operation === "purge") {
|
|
401
|
-
// Delete docs by status (default:
|
|
451
|
+
// Delete docs by status (default: every doc for the topic).
|
|
452
|
+
// Pre-3.13.105 this filtered by { queue: queueName, status } for
|
|
453
|
+
// EVERY status -- correct for pending/reserved/completed, wrong
|
|
454
|
+
// for the dead-letter states (dead/failed/dead_letter) which
|
|
455
|
+
// live under queueName + ".dead_letter" and carry status "dead".
|
|
456
|
+
// A purge("dead") therefore deleted nothing and returned 0.
|
|
457
|
+
// data = JSON { status }.
|
|
402
458
|
const info = data ? JSON.parse(data) : {};
|
|
403
|
-
const
|
|
404
|
-
|
|
459
|
+
const isDead = info.status && ["dead", "failed", "dead_letter"].includes(info.status);
|
|
460
|
+
const filter = isDead
|
|
461
|
+
? { queue: queueName + ".dead_letter" }
|
|
462
|
+
: (info.status
|
|
463
|
+
? { queue: queueName, status: info.status }
|
|
464
|
+
: { queue: queueName });
|
|
405
465
|
const res = await col.deleteMany(filter);
|
|
406
466
|
process.stdout.write(String(res.deletedCount || 0));
|
|
407
467
|
}
|
|
@@ -513,9 +573,15 @@ export class MongoBackend implements QueueBackend {
|
|
|
513
573
|
this.execSync("fail", queue, JSON.stringify({ id, error, maxRetries, retryBackoff }));
|
|
514
574
|
}
|
|
515
575
|
|
|
516
|
-
/**
|
|
517
|
-
|
|
518
|
-
|
|
576
|
+
/**
|
|
577
|
+
* Revive a specific dead-letter job by id. Returns true if the DL was found
|
|
578
|
+
* and revived, false otherwise (parity with LiteBackend.retry(queue, id)
|
|
579
|
+
* and Python's mongo_backend.retry_job()). Pre-3.13.105 this returned void
|
|
580
|
+
* and Queue.retry(id) reported success for every call, even for unknown ids.
|
|
581
|
+
*/
|
|
582
|
+
retry(queue: string, id: string, delaySeconds: number = 0): boolean {
|
|
583
|
+
const out = this.execSync("retry", queue, JSON.stringify({ id, delaySeconds }));
|
|
584
|
+
return out.includes("__OK__");
|
|
519
585
|
}
|
|
520
586
|
|
|
521
587
|
/** Jobs that exceeded max retries (the `<queue>.dead_letter` collection topic). */
|
|
@@ -69,6 +69,8 @@ interface MatchResult {
|
|
|
69
69
|
secure?: boolean;
|
|
70
70
|
cached?: boolean;
|
|
71
71
|
noAuth?: boolean;
|
|
72
|
+
requiredRoles?: string[][];
|
|
73
|
+
requiredPerms?: string[][];
|
|
72
74
|
}
|
|
73
75
|
|
|
74
76
|
interface CompiledRoute {
|
|
@@ -86,6 +88,9 @@ interface CompiledRoute {
|
|
|
86
88
|
cacheStore?: Map<string, { data: unknown; expires: number }>;
|
|
87
89
|
cacheTtl?: number;
|
|
88
90
|
template?: string;
|
|
91
|
+
/** RBAC guard groups (Feature 138): OR within a group, AND across groups. */
|
|
92
|
+
requiredRoles?: string[][];
|
|
93
|
+
requiredPerms?: string[][];
|
|
89
94
|
}
|
|
90
95
|
|
|
91
96
|
/**
|
|
@@ -126,6 +131,33 @@ export class RouteRef {
|
|
|
126
131
|
return this;
|
|
127
132
|
}
|
|
128
133
|
|
|
134
|
+
/**
|
|
135
|
+
* RBAC: require ONE of the named roles (OR). Reads the verified JWT `roles`
|
|
136
|
+
* claim. Chain .role()/.can() for AND. Implies auth. Feature 138 / ADR-0058.
|
|
137
|
+
*/
|
|
138
|
+
role(...names: string[]): this {
|
|
139
|
+
const clean = names.filter((n) => n !== "");
|
|
140
|
+
if (clean.length > 0) {
|
|
141
|
+
(this.route.requiredRoles ??= []).push(clean);
|
|
142
|
+
this.route.secure = true;
|
|
143
|
+
}
|
|
144
|
+
return this;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* RBAC: require ONE of the named permissions (OR). Reads the verified JWT
|
|
149
|
+
* `permissions` claim; granted-side wildcards (`posts.*`, `*`) satisfy a
|
|
150
|
+
* concrete requirement. Chain for AND. Implies auth. Feature 138.
|
|
151
|
+
*/
|
|
152
|
+
can(...permissions: string[]): this {
|
|
153
|
+
const clean = permissions.filter((p) => p !== "");
|
|
154
|
+
if (clean.length > 0) {
|
|
155
|
+
(this.route.requiredPerms ??= []).push(clean);
|
|
156
|
+
this.route.secure = true;
|
|
157
|
+
}
|
|
158
|
+
return this;
|
|
159
|
+
}
|
|
160
|
+
|
|
129
161
|
/** Mark this route's response as cacheable. */
|
|
130
162
|
cache(): this {
|
|
131
163
|
this.route.cached = true;
|
|
@@ -220,6 +252,8 @@ export class Router {
|
|
|
220
252
|
cached: definition.cached,
|
|
221
253
|
noAuth: definition.noAuth,
|
|
222
254
|
template: definition.template,
|
|
255
|
+
requiredRoles: definition.requiredRoles,
|
|
256
|
+
requiredPerms: definition.requiredPerms,
|
|
223
257
|
};
|
|
224
258
|
routes.push(compiled);
|
|
225
259
|
return new RouteRef(compiled);
|
|
@@ -393,6 +427,8 @@ export class Router {
|
|
|
393
427
|
secure: route.secure,
|
|
394
428
|
cached: route.cached,
|
|
395
429
|
noAuth: route.noAuth,
|
|
430
|
+
requiredRoles: route.requiredRoles,
|
|
431
|
+
requiredPerms: route.requiredPerms,
|
|
396
432
|
};
|
|
397
433
|
}
|
|
398
434
|
}
|
|
@@ -417,6 +453,8 @@ export class Router {
|
|
|
417
453
|
secure: route.secure,
|
|
418
454
|
cached: route.cached,
|
|
419
455
|
noAuth: route.noAuth,
|
|
456
|
+
requiredRoles: route.requiredRoles,
|
|
457
|
+
requiredPerms: route.requiredPerms,
|
|
420
458
|
});
|
|
421
459
|
}
|
|
422
460
|
}
|
|
@@ -1210,6 +1210,30 @@ function asHtmlString(chunk: unknown): string | null {
|
|
|
1210
1210
|
return null;
|
|
1211
1211
|
}
|
|
1212
1212
|
|
|
1213
|
+
/**
|
|
1214
|
+
* Whether this response's body can still have HTML spliced into it.
|
|
1215
|
+
*
|
|
1216
|
+
* `text/html` is NOT enough on its own. A static-file response (static.ts) gzips
|
|
1217
|
+
* itself and sets Content-Encoding BEFORE it calls `res.raw.end()` - and that
|
|
1218
|
+
* `end()` is the intercepted one below, so the chunk arriving there is
|
|
1219
|
+
* COMPRESSED BYTES, not markup. Reading them back as UTF-8 to inject a toolbar
|
|
1220
|
+
* replaces every byte outside ASCII with U+FFFD, and the browser is handed a
|
|
1221
|
+
* gzip stream whose header is `1f ef bf bd` instead of `1f 8b`. Chrome answers
|
|
1222
|
+
* ERR_CONTENT_DECODING_FAILED and the page does not load at all.
|
|
1223
|
+
*
|
|
1224
|
+
* That is not a corner case: in dev mode it corrupted EVERY static .html file
|
|
1225
|
+
* over the 1024-byte compression threshold, which is most real pages, so
|
|
1226
|
+
* `tina4 serve` served an unloadable page while curl (which asks for no
|
|
1227
|
+
* encoding by default) looked perfectly healthy.
|
|
1228
|
+
*
|
|
1229
|
+
* dispatchPipeline.ts already guards the sibling half of this - it refuses to
|
|
1230
|
+
* gzip a body some earlier stage has already encoded - with the same test. This
|
|
1231
|
+
* is the other half: do not TEXT-EDIT a body some earlier stage has encoded.
|
|
1232
|
+
*/
|
|
1233
|
+
function isInjectableHtml(res: Tina4Response): boolean {
|
|
1234
|
+
return isHtmlResponse(res) && !res.raw.getHeader("content-encoding");
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1213
1237
|
/**
|
|
1214
1238
|
* Inject the dev toolbar (dev mode only) and the feedback widget into an HTML body.
|
|
1215
1239
|
*
|
|
@@ -1269,7 +1293,11 @@ function wrapResponseEnd(ctx: ResponseWrapContext): void {
|
|
|
1269
1293
|
);
|
|
1270
1294
|
}
|
|
1271
1295
|
|
|
1272
|
-
|
|
1296
|
+
// An ENCODED body is passed straight through, untouched and with its
|
|
1297
|
+
// Content-Length intact: the length static.ts set describes the compressed
|
|
1298
|
+
// bytes and is correct, and there is nothing here we could inject into
|
|
1299
|
+
// without destroying them. See isInjectableHtml.
|
|
1300
|
+
if (isInjectableHtml(res)) {
|
|
1273
1301
|
const html = asHtmlString(chunk);
|
|
1274
1302
|
if (html !== null) chunk = injectIntoHtml(ctx, devToolbar, html);
|
|
1275
1303
|
// Dropped for ANY html response, not only one carrying a body: that is
|
|
@@ -161,6 +161,10 @@ export interface RouteDefinition {
|
|
|
161
161
|
cached?: boolean;
|
|
162
162
|
/** Opt out of secure-by-default auth on write routes */
|
|
163
163
|
noAuth?: boolean;
|
|
164
|
+
/** RBAC role guard groups (Feature 138): OR within a group, AND across groups */
|
|
165
|
+
requiredRoles?: string[][];
|
|
166
|
+
/** RBAC permission guard groups (Feature 138) */
|
|
167
|
+
requiredPerms?: string[][];
|
|
164
168
|
}
|
|
165
169
|
|
|
166
170
|
export interface RouteMeta {
|