tina4-nodejs 3.13.105 → 3.13.109
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 +2 -2
- package/README.md +3 -4
- package/package.json +1 -1
- package/packages/cli/dist/bin.js +104 -11
- package/packages/core/dist/index.js +104 -11
- package/packages/core/public/js/tina4js.min.js +3 -3
- package/packages/core/src/authGate.ts +61 -1
- package/packages/core/src/cache.ts +64 -11
- 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 +104 -11
- package/types/core/src/authGate.d.ts +3 -0
- package/types/core/src/router.d.ts +16 -0
- package/types/core/src/types.d.ts +4 -0
package/CLAUDE.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
# CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.
|
|
1
|
+
# CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.109)
|
|
2
2
|
|
|
3
3
|
> This file helps AI assistants (Claude, Copilot, Cursor, etc.) understand and work on this codebase effectively.
|
|
4
4
|
|
|
5
5
|
## What This Project Is
|
|
6
6
|
|
|
7
|
-
Tina4 for Node.js/TypeScript v3.13.
|
|
7
|
+
Tina4 for Node.js/TypeScript v3.13.109 - The Intelligent Native Application 4ramework. A convention-over-configuration structural paradigm. The developer writes TypeScript; Tina4 is invisible infrastructure.
|
|
8
8
|
|
|
9
9
|
The philosophy: zero ceremony, batteries included, file system as source of truth.
|
|
10
10
|
|
package/README.md
CHANGED
|
@@ -3,11 +3,10 @@
|
|
|
3
3
|
</p>
|
|
4
4
|
<h1 align="center">Tina4 Node.js</h1>
|
|
5
5
|
<h3 align="center">The Intelligent Native Application 4ramework</h3>
|
|
6
|
-
<p align="center">
|
|
6
|
+
<p align="center">Zero dependencies. One import, everything works.</p>
|
|
7
7
|
<p align="center">
|
|
8
|
-
<a href="https://
|
|
9
|
-
<img src="https://img.shields.io/
|
|
10
|
-
<img src="https://img.shields.io/badge/features-98-blue" alt="Features">
|
|
8
|
+
<a href="https://github.com/tina4stack/tina4-nodejs/releases"><img src="https://img.shields.io/github/v/tag/tina4stack/tina4-nodejs?color=7b1fa2&label=version&sort=semver" alt="version"></a>
|
|
9
|
+
<a href="https://github.com/tina4stack/tina4-nodejs/actions/workflows/test.yml"><img src="https://img.shields.io/github/actions/workflow/status/tina4stack/tina4-nodejs/test.yml?label=tests" alt="Tests"></a>
|
|
11
10
|
<img src="https://img.shields.io/badge/dependencies-0-brightgreen" alt="Zero Deps">
|
|
12
11
|
<a href="https://tina4.com"><img src="https://img.shields.io/badge/docs-tina4.com-7b1fa2" alt="Docs"></a>
|
|
13
12
|
</p>
|
package/package.json
CHANGED
package/packages/cli/dist/bin.js
CHANGED
|
@@ -17767,11 +17767,32 @@ function varyFields(raw) {
|
|
|
17767
17767
|
const text = Array.isArray(raw) ? raw.join(",") : String(raw);
|
|
17768
17768
|
return text.split(",").map((f) => f.trim().toLowerCase()).filter((f) => f !== "");
|
|
17769
17769
|
}
|
|
17770
|
-
function
|
|
17770
|
+
function cacheControlTokens(raw) {
|
|
17771
|
+
const text = Array.isArray(raw) ? raw.join(",") : String(raw ?? "");
|
|
17772
|
+
const tokens = /* @__PURE__ */ new Set();
|
|
17773
|
+
for (const token of text.split(",")) {
|
|
17774
|
+
const name = token.split("=")[0].trim().toLowerCase();
|
|
17775
|
+
if (name !== "") tokens.add(name);
|
|
17776
|
+
}
|
|
17777
|
+
return tokens;
|
|
17778
|
+
}
|
|
17779
|
+
function sharedCacheAllowed(cacheControl) {
|
|
17780
|
+
const directives = cacheControlTokens(cacheControl);
|
|
17781
|
+
return SHARED_CACHE_DIRECTIVES.some((directive) => directives.has(directive));
|
|
17782
|
+
}
|
|
17783
|
+
function hasSetCookie(raw) {
|
|
17784
|
+
if (raw === void 0 || raw === null) return false;
|
|
17785
|
+
if (Array.isArray(raw)) return raw.length > 0;
|
|
17786
|
+
return String(raw) !== "";
|
|
17787
|
+
}
|
|
17788
|
+
function mayStore(req2, vary, cacheControl, setCookie) {
|
|
17771
17789
|
if (vary.includes("*")) return false;
|
|
17772
|
-
|
|
17773
|
-
|
|
17774
|
-
|
|
17790
|
+
const directives = cacheControlTokens(cacheControl);
|
|
17791
|
+
if (NO_STORE_DIRECTIVES.some((directive) => directives.has(directive))) return false;
|
|
17792
|
+
if (requestHeader(req2, "authorization") !== void 0) return sharedCacheAllowed(cacheControl);
|
|
17793
|
+
if (requestHeader(req2, "cookie") !== void 0) return sharedCacheAllowed(cacheControl);
|
|
17794
|
+
if (hasSetCookie(setCookie)) return sharedCacheAllowed(cacheControl);
|
|
17795
|
+
return true;
|
|
17775
17796
|
}
|
|
17776
17797
|
function varyMatches(entry, req2) {
|
|
17777
17798
|
const vary = entry.vary ?? [];
|
|
@@ -17804,7 +17825,7 @@ function responseCache(config) {
|
|
|
17804
17825
|
let captured = false;
|
|
17805
17826
|
res.raw.end = function(chunk, ...args) {
|
|
17806
17827
|
const vary = varyFields(res.raw.getHeader("Vary"));
|
|
17807
|
-
if (!captured && allowedCodes.has(res.raw.statusCode) && mayStore(req2, vary, res.raw.getHeader("Cache-Control"))) {
|
|
17828
|
+
if (!captured && allowedCodes.has(res.raw.statusCode) && mayStore(req2, vary, res.raw.getHeader("Cache-Control"), res.raw.getHeader("Set-Cookie"))) {
|
|
17808
17829
|
captured = true;
|
|
17809
17830
|
const body = typeof chunk === "string" ? chunk : chunk?.toString() ?? "";
|
|
17810
17831
|
const contentType = String(res.raw.getHeader("Content-Type") ?? "application/octet-stream");
|
|
@@ -17891,7 +17912,7 @@ function _resetBackend() {
|
|
|
17891
17912
|
_explicitResponseBackends.clear();
|
|
17892
17913
|
_defaultTtl = null;
|
|
17893
17914
|
}
|
|
17894
|
-
var MemoryBackend, RespClient, RedisBackend, ValkeyBackend, FileBackend, MemcachedClient, MemcachedBackend, MongoBackend, DatabaseBackend, _responseBackend, _responseBackendPromise, _explicitResponseBackends, SHARED_CACHE_DIRECTIVES, _defaultBackend, _defaultBackendPromise, _defaultTtl;
|
|
17915
|
+
var MemoryBackend, RespClient, RedisBackend, ValkeyBackend, FileBackend, MemcachedClient, MemcachedBackend, MongoBackend, DatabaseBackend, _responseBackend, _responseBackendPromise, _explicitResponseBackends, SHARED_CACHE_DIRECTIVES, NO_STORE_DIRECTIVES, _defaultBackend, _defaultBackendPromise, _defaultTtl;
|
|
17895
17916
|
var init_cache = __esm({
|
|
17896
17917
|
"../core/src/cache.ts"() {
|
|
17897
17918
|
"use strict";
|
|
@@ -19057,6 +19078,7 @@ ${data}\r
|
|
|
19057
19078
|
_responseBackendPromise = null;
|
|
19058
19079
|
_explicitResponseBackends = /* @__PURE__ */ new Map();
|
|
19059
19080
|
SHARED_CACHE_DIRECTIVES = ["public", "s-maxage", "must-revalidate"];
|
|
19081
|
+
NO_STORE_DIRECTIVES = ["no-store", "private", "no-cache"];
|
|
19060
19082
|
_defaultBackend = null;
|
|
19061
19083
|
_defaultBackendPromise = null;
|
|
19062
19084
|
_defaultTtl = null;
|
|
@@ -19195,6 +19217,31 @@ var init_router = __esm({
|
|
|
19195
19217
|
this.route.noAuth = true;
|
|
19196
19218
|
return this;
|
|
19197
19219
|
}
|
|
19220
|
+
/**
|
|
19221
|
+
* RBAC: require ONE of the named roles (OR). Reads the verified JWT `roles`
|
|
19222
|
+
* claim. Chain .role()/.can() for AND. Implies auth. Feature 138 / ADR-0058.
|
|
19223
|
+
*/
|
|
19224
|
+
role(...names) {
|
|
19225
|
+
const clean = names.filter((n) => n !== "");
|
|
19226
|
+
if (clean.length > 0) {
|
|
19227
|
+
(this.route.requiredRoles ??= []).push(clean);
|
|
19228
|
+
this.route.secure = true;
|
|
19229
|
+
}
|
|
19230
|
+
return this;
|
|
19231
|
+
}
|
|
19232
|
+
/**
|
|
19233
|
+
* RBAC: require ONE of the named permissions (OR). Reads the verified JWT
|
|
19234
|
+
* `permissions` claim; granted-side wildcards (`posts.*`, `*`) satisfy a
|
|
19235
|
+
* concrete requirement. Chain for AND. Implies auth. Feature 138.
|
|
19236
|
+
*/
|
|
19237
|
+
can(...permissions) {
|
|
19238
|
+
const clean = permissions.filter((p) => p !== "");
|
|
19239
|
+
if (clean.length > 0) {
|
|
19240
|
+
(this.route.requiredPerms ??= []).push(clean);
|
|
19241
|
+
this.route.secure = true;
|
|
19242
|
+
}
|
|
19243
|
+
return this;
|
|
19244
|
+
}
|
|
19198
19245
|
/** Mark this route's response as cacheable. */
|
|
19199
19246
|
cache() {
|
|
19200
19247
|
this.route.cached = true;
|
|
@@ -19262,7 +19309,9 @@ var init_router = __esm({
|
|
|
19262
19309
|
secure: secureDefault,
|
|
19263
19310
|
cached: definition.cached,
|
|
19264
19311
|
noAuth: definition.noAuth,
|
|
19265
|
-
template: definition.template
|
|
19312
|
+
template: definition.template,
|
|
19313
|
+
requiredRoles: definition.requiredRoles,
|
|
19314
|
+
requiredPerms: definition.requiredPerms
|
|
19266
19315
|
};
|
|
19267
19316
|
routes.push(compiled);
|
|
19268
19317
|
return new RouteRef(compiled);
|
|
@@ -19409,7 +19458,9 @@ var init_router = __esm({
|
|
|
19409
19458
|
template: route.template,
|
|
19410
19459
|
secure: route.secure,
|
|
19411
19460
|
cached: route.cached,
|
|
19412
|
-
noAuth: route.noAuth
|
|
19461
|
+
noAuth: route.noAuth,
|
|
19462
|
+
requiredRoles: route.requiredRoles,
|
|
19463
|
+
requiredPerms: route.requiredPerms
|
|
19413
19464
|
};
|
|
19414
19465
|
}
|
|
19415
19466
|
}
|
|
@@ -19432,7 +19483,9 @@ var init_router = __esm({
|
|
|
19432
19483
|
template: route.template,
|
|
19433
19484
|
secure: route.secure,
|
|
19434
19485
|
cached: route.cached,
|
|
19435
|
-
noAuth: route.noAuth
|
|
19486
|
+
noAuth: route.noAuth,
|
|
19487
|
+
requiredRoles: route.requiredRoles,
|
|
19488
|
+
requiredPerms: route.requiredPerms
|
|
19436
19489
|
});
|
|
19437
19490
|
}
|
|
19438
19491
|
}
|
|
@@ -19794,7 +19847,7 @@ function enforceRouteAuth(req2, res, match, isDevAdmin) {
|
|
|
19794
19847
|
const identity = sso?.identity;
|
|
19795
19848
|
if (identity?.issuer && identity?.subject) {
|
|
19796
19849
|
req2.user = identity;
|
|
19797
|
-
return
|
|
19850
|
+
return rbacForbidden(match, identity, res);
|
|
19798
19851
|
}
|
|
19799
19852
|
const sessionToken = req2.session?.get?.("token");
|
|
19800
19853
|
if (sessionToken && validToken(sessionToken)) {
|
|
@@ -19814,8 +19867,45 @@ function enforceRouteAuth(req2, res, match, isDevAdmin) {
|
|
|
19814
19867
|
res.header("FreshToken", fresh);
|
|
19815
19868
|
}
|
|
19816
19869
|
}
|
|
19870
|
+
return rbacForbidden(match, req2.user, res);
|
|
19871
|
+
}
|
|
19872
|
+
function rbacClaimList(subject, key, legacy) {
|
|
19873
|
+
const coerce = (v) => {
|
|
19874
|
+
if (typeof v === "string") return v === "" ? [] : [v];
|
|
19875
|
+
if (Array.isArray(v)) return v.map((x) => String(x)).filter((x) => x !== "");
|
|
19876
|
+
return [];
|
|
19877
|
+
};
|
|
19878
|
+
let out = coerce(subject[key]);
|
|
19879
|
+
if (out.length === 0 && legacy) out = coerce(subject[legacy]);
|
|
19880
|
+
return out;
|
|
19881
|
+
}
|
|
19882
|
+
function rbacPermGranted(granted, required) {
|
|
19883
|
+
return granted.some(
|
|
19884
|
+
(g) => g === "*" || g === required || g.endsWith(".*") && required.startsWith(g.slice(0, -1))
|
|
19885
|
+
);
|
|
19886
|
+
}
|
|
19887
|
+
function rbacForbidden(match, payload, res) {
|
|
19888
|
+
const requiredRoles = match.requiredRoles ?? [];
|
|
19889
|
+
const requiredPerms = match.requiredPerms ?? [];
|
|
19890
|
+
if (requiredRoles.length === 0 && requiredPerms.length === 0) {
|
|
19891
|
+
return false;
|
|
19892
|
+
}
|
|
19893
|
+
const subject = payload && typeof payload === "object" ? payload : {};
|
|
19894
|
+
const roles = rbacClaimList(subject, "roles", "role");
|
|
19895
|
+
for (const group of requiredRoles) {
|
|
19896
|
+
if (!group.some((r) => roles.includes(r))) return writeForbidden(res);
|
|
19897
|
+
}
|
|
19898
|
+
const perms = rbacClaimList(subject, "permissions");
|
|
19899
|
+
for (const group of requiredPerms) {
|
|
19900
|
+
if (!group.some((p) => rbacPermGranted(perms, p))) return writeForbidden(res);
|
|
19901
|
+
}
|
|
19817
19902
|
return false;
|
|
19818
19903
|
}
|
|
19904
|
+
function writeForbidden(res) {
|
|
19905
|
+
res.raw.writeHead(403, { "Content-Type": "application/json" });
|
|
19906
|
+
res.raw.end(JSON.stringify({ error: "Forbidden" }));
|
|
19907
|
+
return true;
|
|
19908
|
+
}
|
|
19819
19909
|
var init_authGate = __esm({
|
|
19820
19910
|
"../core/src/authGate.ts"() {
|
|
19821
19911
|
"use strict";
|
|
@@ -35912,6 +36002,9 @@ function asHtmlString(chunk) {
|
|
|
35912
36002
|
if (Buffer.isBuffer(chunk)) return chunk.toString("utf-8");
|
|
35913
36003
|
return null;
|
|
35914
36004
|
}
|
|
36005
|
+
function isInjectableHtml(res) {
|
|
36006
|
+
return isHtmlResponse(res) && !res.raw.getHeader("content-encoding");
|
|
36007
|
+
}
|
|
35915
36008
|
function injectIntoHtml(ctx, devToolbar, html) {
|
|
35916
36009
|
if (!devToolbar) return injectFeedbackWidget(ctx.req, html);
|
|
35917
36010
|
const toolbarCtx = {
|
|
@@ -35939,7 +36032,7 @@ function wrapResponseEnd(ctx) {
|
|
|
35939
36032
|
Date.now() - ctx.reqStartTime
|
|
35940
36033
|
);
|
|
35941
36034
|
}
|
|
35942
|
-
if (
|
|
36035
|
+
if (isInjectableHtml(res)) {
|
|
35943
36036
|
const html = asHtmlString(chunk);
|
|
35944
36037
|
if (html !== null) chunk = injectIntoHtml(ctx, devToolbar, html);
|
|
35945
36038
|
if (!res.raw.headersSent) res.raw.removeHeader("content-length");
|
|
@@ -17766,11 +17766,32 @@ function varyFields(raw) {
|
|
|
17766
17766
|
const text = Array.isArray(raw) ? raw.join(",") : String(raw);
|
|
17767
17767
|
return text.split(",").map((f) => f.trim().toLowerCase()).filter((f) => f !== "");
|
|
17768
17768
|
}
|
|
17769
|
-
function
|
|
17769
|
+
function cacheControlTokens(raw) {
|
|
17770
|
+
const text = Array.isArray(raw) ? raw.join(",") : String(raw ?? "");
|
|
17771
|
+
const tokens = /* @__PURE__ */ new Set();
|
|
17772
|
+
for (const token of text.split(",")) {
|
|
17773
|
+
const name = token.split("=")[0].trim().toLowerCase();
|
|
17774
|
+
if (name !== "") tokens.add(name);
|
|
17775
|
+
}
|
|
17776
|
+
return tokens;
|
|
17777
|
+
}
|
|
17778
|
+
function sharedCacheAllowed(cacheControl) {
|
|
17779
|
+
const directives = cacheControlTokens(cacheControl);
|
|
17780
|
+
return SHARED_CACHE_DIRECTIVES.some((directive) => directives.has(directive));
|
|
17781
|
+
}
|
|
17782
|
+
function hasSetCookie(raw) {
|
|
17783
|
+
if (raw === void 0 || raw === null) return false;
|
|
17784
|
+
if (Array.isArray(raw)) return raw.length > 0;
|
|
17785
|
+
return String(raw) !== "";
|
|
17786
|
+
}
|
|
17787
|
+
function mayStore(req2, vary, cacheControl, setCookie) {
|
|
17770
17788
|
if (vary.includes("*")) return false;
|
|
17771
|
-
|
|
17772
|
-
|
|
17773
|
-
|
|
17789
|
+
const directives = cacheControlTokens(cacheControl);
|
|
17790
|
+
if (NO_STORE_DIRECTIVES.some((directive) => directives.has(directive))) return false;
|
|
17791
|
+
if (requestHeader(req2, "authorization") !== void 0) return sharedCacheAllowed(cacheControl);
|
|
17792
|
+
if (requestHeader(req2, "cookie") !== void 0) return sharedCacheAllowed(cacheControl);
|
|
17793
|
+
if (hasSetCookie(setCookie)) return sharedCacheAllowed(cacheControl);
|
|
17794
|
+
return true;
|
|
17774
17795
|
}
|
|
17775
17796
|
function varyMatches(entry, req2) {
|
|
17776
17797
|
const vary = entry.vary ?? [];
|
|
@@ -17803,7 +17824,7 @@ function responseCache(config) {
|
|
|
17803
17824
|
let captured = false;
|
|
17804
17825
|
res.raw.end = function(chunk, ...args) {
|
|
17805
17826
|
const vary = varyFields(res.raw.getHeader("Vary"));
|
|
17806
|
-
if (!captured && allowedCodes.has(res.raw.statusCode) && mayStore(req2, vary, res.raw.getHeader("Cache-Control"))) {
|
|
17827
|
+
if (!captured && allowedCodes.has(res.raw.statusCode) && mayStore(req2, vary, res.raw.getHeader("Cache-Control"), res.raw.getHeader("Set-Cookie"))) {
|
|
17807
17828
|
captured = true;
|
|
17808
17829
|
const body = typeof chunk === "string" ? chunk : chunk?.toString() ?? "";
|
|
17809
17830
|
const contentType = String(res.raw.getHeader("Content-Type") ?? "application/octet-stream");
|
|
@@ -17890,7 +17911,7 @@ function _resetBackend() {
|
|
|
17890
17911
|
_explicitResponseBackends.clear();
|
|
17891
17912
|
_defaultTtl = null;
|
|
17892
17913
|
}
|
|
17893
|
-
var MemoryBackend, RespClient, RedisBackend, ValkeyBackend, FileBackend, MemcachedClient, MemcachedBackend, MongoBackend, DatabaseBackend, _responseBackend, _responseBackendPromise, _explicitResponseBackends, SHARED_CACHE_DIRECTIVES, _defaultBackend, _defaultBackendPromise, _defaultTtl;
|
|
17914
|
+
var MemoryBackend, RespClient, RedisBackend, ValkeyBackend, FileBackend, MemcachedClient, MemcachedBackend, MongoBackend, DatabaseBackend, _responseBackend, _responseBackendPromise, _explicitResponseBackends, SHARED_CACHE_DIRECTIVES, NO_STORE_DIRECTIVES, _defaultBackend, _defaultBackendPromise, _defaultTtl;
|
|
17894
17915
|
var init_cache = __esm({
|
|
17895
17916
|
"src/cache.ts"() {
|
|
17896
17917
|
"use strict";
|
|
@@ -19056,6 +19077,7 @@ ${data}\r
|
|
|
19056
19077
|
_responseBackendPromise = null;
|
|
19057
19078
|
_explicitResponseBackends = /* @__PURE__ */ new Map();
|
|
19058
19079
|
SHARED_CACHE_DIRECTIVES = ["public", "s-maxage", "must-revalidate"];
|
|
19080
|
+
NO_STORE_DIRECTIVES = ["no-store", "private", "no-cache"];
|
|
19059
19081
|
_defaultBackend = null;
|
|
19060
19082
|
_defaultBackendPromise = null;
|
|
19061
19083
|
_defaultTtl = null;
|
|
@@ -19194,6 +19216,31 @@ var init_router = __esm({
|
|
|
19194
19216
|
this.route.noAuth = true;
|
|
19195
19217
|
return this;
|
|
19196
19218
|
}
|
|
19219
|
+
/**
|
|
19220
|
+
* RBAC: require ONE of the named roles (OR). Reads the verified JWT `roles`
|
|
19221
|
+
* claim. Chain .role()/.can() for AND. Implies auth. Feature 138 / ADR-0058.
|
|
19222
|
+
*/
|
|
19223
|
+
role(...names) {
|
|
19224
|
+
const clean = names.filter((n) => n !== "");
|
|
19225
|
+
if (clean.length > 0) {
|
|
19226
|
+
(this.route.requiredRoles ??= []).push(clean);
|
|
19227
|
+
this.route.secure = true;
|
|
19228
|
+
}
|
|
19229
|
+
return this;
|
|
19230
|
+
}
|
|
19231
|
+
/**
|
|
19232
|
+
* RBAC: require ONE of the named permissions (OR). Reads the verified JWT
|
|
19233
|
+
* `permissions` claim; granted-side wildcards (`posts.*`, `*`) satisfy a
|
|
19234
|
+
* concrete requirement. Chain for AND. Implies auth. Feature 138.
|
|
19235
|
+
*/
|
|
19236
|
+
can(...permissions) {
|
|
19237
|
+
const clean = permissions.filter((p) => p !== "");
|
|
19238
|
+
if (clean.length > 0) {
|
|
19239
|
+
(this.route.requiredPerms ??= []).push(clean);
|
|
19240
|
+
this.route.secure = true;
|
|
19241
|
+
}
|
|
19242
|
+
return this;
|
|
19243
|
+
}
|
|
19197
19244
|
/** Mark this route's response as cacheable. */
|
|
19198
19245
|
cache() {
|
|
19199
19246
|
this.route.cached = true;
|
|
@@ -19261,7 +19308,9 @@ var init_router = __esm({
|
|
|
19261
19308
|
secure: secureDefault,
|
|
19262
19309
|
cached: definition.cached,
|
|
19263
19310
|
noAuth: definition.noAuth,
|
|
19264
|
-
template: definition.template
|
|
19311
|
+
template: definition.template,
|
|
19312
|
+
requiredRoles: definition.requiredRoles,
|
|
19313
|
+
requiredPerms: definition.requiredPerms
|
|
19265
19314
|
};
|
|
19266
19315
|
routes.push(compiled);
|
|
19267
19316
|
return new RouteRef(compiled);
|
|
@@ -19408,7 +19457,9 @@ var init_router = __esm({
|
|
|
19408
19457
|
template: route.template,
|
|
19409
19458
|
secure: route.secure,
|
|
19410
19459
|
cached: route.cached,
|
|
19411
|
-
noAuth: route.noAuth
|
|
19460
|
+
noAuth: route.noAuth,
|
|
19461
|
+
requiredRoles: route.requiredRoles,
|
|
19462
|
+
requiredPerms: route.requiredPerms
|
|
19412
19463
|
};
|
|
19413
19464
|
}
|
|
19414
19465
|
}
|
|
@@ -19431,7 +19482,9 @@ var init_router = __esm({
|
|
|
19431
19482
|
template: route.template,
|
|
19432
19483
|
secure: route.secure,
|
|
19433
19484
|
cached: route.cached,
|
|
19434
|
-
noAuth: route.noAuth
|
|
19485
|
+
noAuth: route.noAuth,
|
|
19486
|
+
requiredRoles: route.requiredRoles,
|
|
19487
|
+
requiredPerms: route.requiredPerms
|
|
19435
19488
|
});
|
|
19436
19489
|
}
|
|
19437
19490
|
}
|
|
@@ -19793,7 +19846,7 @@ function enforceRouteAuth(req2, res, match, isDevAdmin) {
|
|
|
19793
19846
|
const identity = sso?.identity;
|
|
19794
19847
|
if (identity?.issuer && identity?.subject) {
|
|
19795
19848
|
req2.user = identity;
|
|
19796
|
-
return
|
|
19849
|
+
return rbacForbidden(match, identity, res);
|
|
19797
19850
|
}
|
|
19798
19851
|
const sessionToken = req2.session?.get?.("token");
|
|
19799
19852
|
if (sessionToken && validToken(sessionToken)) {
|
|
@@ -19813,8 +19866,45 @@ function enforceRouteAuth(req2, res, match, isDevAdmin) {
|
|
|
19813
19866
|
res.header("FreshToken", fresh);
|
|
19814
19867
|
}
|
|
19815
19868
|
}
|
|
19869
|
+
return rbacForbidden(match, req2.user, res);
|
|
19870
|
+
}
|
|
19871
|
+
function rbacClaimList(subject, key, legacy) {
|
|
19872
|
+
const coerce = (v) => {
|
|
19873
|
+
if (typeof v === "string") return v === "" ? [] : [v];
|
|
19874
|
+
if (Array.isArray(v)) return v.map((x) => String(x)).filter((x) => x !== "");
|
|
19875
|
+
return [];
|
|
19876
|
+
};
|
|
19877
|
+
let out = coerce(subject[key]);
|
|
19878
|
+
if (out.length === 0 && legacy) out = coerce(subject[legacy]);
|
|
19879
|
+
return out;
|
|
19880
|
+
}
|
|
19881
|
+
function rbacPermGranted(granted, required) {
|
|
19882
|
+
return granted.some(
|
|
19883
|
+
(g) => g === "*" || g === required || g.endsWith(".*") && required.startsWith(g.slice(0, -1))
|
|
19884
|
+
);
|
|
19885
|
+
}
|
|
19886
|
+
function rbacForbidden(match, payload, res) {
|
|
19887
|
+
const requiredRoles = match.requiredRoles ?? [];
|
|
19888
|
+
const requiredPerms = match.requiredPerms ?? [];
|
|
19889
|
+
if (requiredRoles.length === 0 && requiredPerms.length === 0) {
|
|
19890
|
+
return false;
|
|
19891
|
+
}
|
|
19892
|
+
const subject = payload && typeof payload === "object" ? payload : {};
|
|
19893
|
+
const roles = rbacClaimList(subject, "roles", "role");
|
|
19894
|
+
for (const group of requiredRoles) {
|
|
19895
|
+
if (!group.some((r) => roles.includes(r))) return writeForbidden(res);
|
|
19896
|
+
}
|
|
19897
|
+
const perms = rbacClaimList(subject, "permissions");
|
|
19898
|
+
for (const group of requiredPerms) {
|
|
19899
|
+
if (!group.some((p) => rbacPermGranted(perms, p))) return writeForbidden(res);
|
|
19900
|
+
}
|
|
19816
19901
|
return false;
|
|
19817
19902
|
}
|
|
19903
|
+
function writeForbidden(res) {
|
|
19904
|
+
res.raw.writeHead(403, { "Content-Type": "application/json" });
|
|
19905
|
+
res.raw.end(JSON.stringify({ error: "Forbidden" }));
|
|
19906
|
+
return true;
|
|
19907
|
+
}
|
|
19818
19908
|
var init_authGate = __esm({
|
|
19819
19909
|
"src/authGate.ts"() {
|
|
19820
19910
|
"use strict";
|
|
@@ -35891,6 +35981,9 @@ function asHtmlString(chunk) {
|
|
|
35891
35981
|
if (Buffer.isBuffer(chunk)) return chunk.toString("utf-8");
|
|
35892
35982
|
return null;
|
|
35893
35983
|
}
|
|
35984
|
+
function isInjectableHtml(res) {
|
|
35985
|
+
return isHtmlResponse(res) && !res.raw.getHeader("content-encoding");
|
|
35986
|
+
}
|
|
35894
35987
|
function injectIntoHtml(ctx, devToolbar, html) {
|
|
35895
35988
|
if (!devToolbar) return injectFeedbackWidget(ctx.req, html);
|
|
35896
35989
|
const toolbarCtx = {
|
|
@@ -35918,7 +36011,7 @@ function wrapResponseEnd(ctx) {
|
|
|
35918
36011
|
Date.now() - ctx.reqStartTime
|
|
35919
36012
|
);
|
|
35920
36013
|
}
|
|
35921
|
-
if (
|
|
36014
|
+
if (isInjectableHtml(res)) {
|
|
35922
36015
|
const html = asHtmlString(chunk);
|
|
35923
36016
|
if (html !== null) chunk = injectIntoHtml(ctx, devToolbar, html);
|
|
35924
36017
|
if (!res.raw.headersSent) res.raw.removeHeader("content-length");
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"use strict";var Tina4=(()=>{var X=Object.defineProperty;var Be=Object.getOwnPropertyDescriptor;var Ve=Object.getOwnPropertyNames;var Ge=Object.prototype.hasOwnProperty;var Ze=(e,n)=>{for(var t in n)X(e,t,{get:n[t],enumerable:!0})},Qe=(e,n,t,r)=>{if(n&&typeof n=="object"||typeof n=="function")for(let o of Ve(n))!Ge.call(e,o)&&o!==t&&X(e,o,{get:()=>n[o],enumerable:!(r=Be(n,o))||r.enumerable});return e};var Xe=e=>Qe(X({},"__esModule",{value:!0}),e);var Et={};Ze(Et,{Tina4Element:()=>P,api:()=>_e,batch:()=>V,clearPersistedKeys:()=>je,computed:()=>me,createI18n:()=>le,effect:()=>R,html:()=>ve,i18n:()=>We,isSignal:()=>I,navigate:()=>G,persist:()=>Ue,pwa:()=>Me,route:()=>Te,router:()=>Ce,rtc:()=>Ie,rtcConfig:()=>Z,signal:()=>k,sse:()=>Oe,ws:()=>W});var L=null,q=null,$=null,J=null;function O(e){J=e}function B(){return J}var fe=null,ge=null,pe=[],Ye=512;var z=0,Y=new Set;function k(e,n){let t=e,r=new Set,o={_t4:!0,get value(){if(L&&(r.add(L),q)){let s=L;q.push(()=>r.delete(s))}return t},set value(s){if(Object.is(s,t))return;let i=t;if(t=s,o._debugInfo&&o._debugInfo.updateCount++,ge&&ge(o,i,s),z>0)for(let l of r)Y.add(l);else{let l;for(let d of[...r])try{d()}catch(f){l===void 0&&(l=f)}if(l!==void 0)throw l}},_subscribe(s){return r.add(s),()=>{r.delete(s)}},peek(){return t}};return fe?(o._debugInfo={label:n,createdAt:Date.now(),updateCount:0,subs:r},fe(o,n)):pe.length<Ye&&pe.push({ref:new WeakRef(o),label:n,createdAt:Date.now(),subs:r}),o}function me(e){let n=k(void 0);return R(()=>{n.value=e()}),{_t4:!0,get value(){return n.value},set value(t){throw new Error("[tina4] computed signals are read-only")},_subscribe(t){return n._subscribe(t)},peek(){return n.peek()}}}function R(e){let n=!1,t=[],r=[],o=()=>{for(let l of r)l();r=[]},s=()=>{if(n)return;for(let h of t)h();t=[],o();let l=L,d=q,f=$;L=s,q=t,$=r;try{e()}finally{L=l,q=d,$=f}};s();let i=()=>{n=!0;for(let l of t)l();t=[],o()};return $&&$.push(i),J&&J.push(i),i}function V(e){z++;try{e()}finally{if(z--,z===0){let n=[...Y];Y.clear();let t;for(let r of n)try{r()}catch(o){t===void 0&&(t=o)}if(t!==void 0)throw t}}}function I(e){return e!==null&&typeof e=="object"&&e._t4===!0}var he=new WeakMap,ee="t4:";function ve(e,...n){let t=he.get(e);if(!t){t=document.createElement("template");let i="";for(let l=0;l<e.length;l++)i+=e[l],l<n.length&&(ot(i)?i+=`__t4_${l}__`:i+=`<!--${ee}${l}-->`);t.innerHTML=i,he.set(e,t)}let r=t.content.cloneNode(!0),o=et(r);for(let{marker:i,index:l}of o)nt(i,n[l]);let s=tt(r);for(let i of s)rt(i,n);return r}function et(e){let n=[];return ne(e,t=>{if(t.nodeType===8){let r=t.data;if(r&&r.startsWith(ee)){let o=parseInt(r.slice(ee.length),10);n.push({marker:t,index:o})}}}),n}function tt(e){let n=[];return ne(e,t=>{t.nodeType===1&&n.push(t)}),n}function ne(e,n){let t=e.childNodes;for(let r=0;r<t.length;r++){let o=t[r];n(o),ne(o,n)}}function nt(e,n){let t=e.parentNode;if(t)if(I(n)){let r=document.createTextNode("");t.replaceChild(r,e),R(()=>{r.data=String(n.value??"")})}else if(typeof n=="function"){let r=document.createComment("");t.replaceChild(r,e);let o=[],s=[];R(()=>{for(let w of s)w();s=[];let i=[],l=B();O(i);let d=n();O(l),s=i;for(let w of o)w.parentNode?.removeChild(w);o=[];let f=te(d),h=r.parentNode;if(h)for(let w of f)h.insertBefore(w,r),o.push(w)})}else if(ye(n))t.replaceChild(n,e);else if(n instanceof Node)t.replaceChild(n,e);else if(Array.isArray(n)){let r=document.createDocumentFragment();for(let o of n){let s=te(o);for(let i of s)r.appendChild(i)}t.replaceChild(r,e)}else{let r=document.createTextNode(String(n??""));t.replaceChild(r,e)}}function rt(e,n){let t=[];for(let r of Array.from(e.attributes)){let o=r.name,s=r.value;if(o.startsWith("@")){let l=o.slice(1),d=s.match(/__t4_(\d+)__/);if(d){let f=n[parseInt(d[1],10)];typeof f=="function"&&e.addEventListener(l,h=>V(()=>f(h)))}t.push(o);continue}if(o.startsWith("?")){let l=o.slice(1),d=s.match(/__t4_(\d+)__/);if(d){let f=n[parseInt(d[1],10)];if(I(f)){let h=f;R(()=>{h.value?e.setAttribute(l,""):e.removeAttribute(l)})}else typeof f=="function"?R(()=>{f()?e.setAttribute(l,""):e.removeAttribute(l)}):f&&e.setAttribute(l,"")}t.push(o);continue}if(o.startsWith(".")){let l=o.slice(1),d=s.match(/__t4_(\d+)__/);if(d){let f=n[parseInt(d[1],10)];I(f)?R(()=>{e[l]=f.value}):typeof f=="function"?R(()=>{e[l]=f()??""}):e[l]=f}t.push(o);continue}let i=s.match(/__t4_(\d+)__/);if(i){let l=n[parseInt(i[1],10)];if(I(l)){let d=l;R(()=>{e.setAttribute(o,String(d.value??""))})}else typeof l=="function"?R(()=>{e.setAttribute(o,String(l()??""))}):e.setAttribute(o,String(l??""))}}for(let r of t)e.removeAttribute(r)}function te(e){if(e==null||e===!1)return[];if(ye(e))return Array.from(e.childNodes);if(e instanceof Node)return[e];if(Array.isArray(e)){let n=[];for(let t of e)n.push(...te(t));return n}return[document.createTextNode(String(e))]}function ye(e){return e!=null&&typeof e=="object"&&e.nodeType===11}function ot(e){let n=!1,t=!1,r=!1;for(let o=0;o<e.length;o++){let s=e[o];s==="<"&&!n&&!t&&(r=!0),s===">"&&!n&&!t&&(r=!1),r&&(s==='"'&&!n&&(t=!t),s==="'"&&!t&&(n=!n))}return r}var be=null,Se=null;var P=class extends HTMLElement{constructor(){super();this._props={};this._rendered=!1;this._disposeRender=null;this._innerDisposers=[];let t=this.constructor;this._root=t.shadow?this.attachShadow({mode:"open"}):this;for(let[r,o]of Object.entries(t.props))this._props[r]=k(this._coerce(this.getAttribute(r),o))}static get observedAttributes(){return Object.keys(this.props)}connectedCallback(){if(this._rendered)return;this._rendered=!0;let t=this.constructor,r=null;if(t.styles&&t.shadow&&this._root instanceof ShadowRoot){let o=document.createElement("style");o.textContent=t.styles,this._root.appendChild(o),r=o}this._disposeRender=R(()=>{this._innerDisposers.splice(0).forEach(d=>d());let o=[],s=B();O(o);let i=this.render();O(s),this._innerDisposers=o;let l=Array.from(this._root.childNodes);for(let d of l)d!==r&&this._root.removeChild(d);i&&this._root.appendChild(i)}),this.onMount(),be&&be(this)}disconnectedCallback(){this._disposeRender&&(this._disposeRender(),this._disposeRender=null),this._innerDisposers.splice(0).forEach(t=>t()),this.onUnmount(),Se&&Se(this)}attributeChangedCallback(t,r,o){let i=this.constructor.props[t];i&&this._props[t]&&(this._props[t].value=this._coerce(o,i))}prop(t){if(!this._props[t])throw new Error(`[tina4] Prop '${t}' not declared in static props of <${this.tagName.toLowerCase()}>`);return this._props[t]}emit(t,r){this.dispatchEvent(new CustomEvent(t,{bubbles:!0,composed:!0,...r}))}onMount(){}onUnmount(){}_coerce(t,r){return r===Boolean?t!==null:r===Number?t!==null?Number(t):0:t??""}};P.props={},P.styles="",P.shadow=!0;var oe=[],D=null,U="history",st=!1,j=[],re=[],ke=0;function Te(e,n){let t=[],r;e==="*"?r=".*":r=e.replace(/\{(\w+)\}/g,(s,i)=>(t.push(i),"([^/]+)"));let o=new RegExp(`^${r}$`);typeof n=="function"?oe.push({pattern:e,regex:o,paramNames:t,handler:n}):oe.push({pattern:e,regex:o,paramNames:t,handler:n.handler,guard:n.guard})}function G(e,n){if(U==="hash")if(n?.replace){let t=new URL(location.href);t.hash="#"+e,history.replaceState(null,"",t.toString()),H()}else location.hash="#"+e;else n?.replace?history.replaceState(null,"",e):history.pushState(null,"",e),H()}function H(){if(!D)return;let e=performance.now(),n=++ke,t=U==="hash"?location.hash.slice(1)||"/":location.pathname;for(let r of oe){let o=t.match(r.regex);if(!o)continue;let s={};if(r.paramNames.forEach((d,f)=>{s[d]=decodeURIComponent(o[f+1])}),r.guard){let d=r.guard();if(d===!1)return;if(typeof d=="string"){G(d,{replace:!0});return}}re.splice(0).forEach(d=>d()),D.innerHTML="";let i=[];O(i);let l=r.handler(s);if(l instanceof Promise)l.then(d=>{if(O(null),n!==ke){for(let h of i)h();return}we(D,d),re=i;let f=performance.now()-e;for(let h of j)h({path:t,params:s,pattern:r.pattern,durationMs:f})});else{O(null),we(D,l),re=i;let d=performance.now()-e;for(let f of j)f({path:t,params:s,pattern:r.pattern,durationMs:d})}return}}function we(e,n){n instanceof DocumentFragment||n instanceof Node?e.replaceChildren(n):typeof n=="string"?e.innerHTML=n:n!=null&&e.replaceChildren(document.createTextNode(String(n)))}var Ce={start(e){if(D=document.querySelector(e.target),!D)throw new Error(`[tina4] Router target '${e.target}' not found in DOM`);U=e.mode??"history",st=!0,window.addEventListener("popstate",H),U==="hash"&&window.addEventListener("hashchange",H),document.addEventListener("click",n=>{if(n.metaKey||n.ctrlKey||n.shiftKey||n.altKey)return;let t=n.target.closest("a[href]");if(!t||t.origin!==location.origin||t.hasAttribute("target")||t.hasAttribute("download")||t.getAttribute("rel")?.includes("external"))return;n.preventDefault();let r=U==="hash"?t.getAttribute("href"):t.pathname;G(r)}),H()},on(e,n){return j.push(n),()=>{let t=j.indexOf(n);t>=0&&j.splice(t,1)}}};var x={baseUrl:"",auth:!1,tokenKey:"tina4_token",headers:{}},se=[],ie=[],it=0;function ae(){try{return localStorage.getItem(x.tokenKey)}catch{return null}}function at(e){try{localStorage.setItem(x.tokenKey,e)}catch{}}function Ee(e,n){let t=Object.entries(n).map(([r,o])=>`${encodeURIComponent(r)}=${encodeURIComponent(String(o))}`).join("&");return e+(e.includes("?")?"&":"?")+t}async function Re(e,n){e._url=n,e._requestId=++it;for(let l of se){let d=l(e);d&&(e=d)}let t=await fetch(n,e),r=t.headers.get("FreshToken");r&&at(r);let o=t.headers.get("Content-Type")??"",s;o.includes("json")?s=await t.json():s=await t.text();let i={status:t.status,data:s,ok:t.ok,headers:t.headers,_requestId:e._requestId};for(let l of ie){let d=l(i);d&&(i=d)}if(!t.ok)throw i;return i.data}async function F(e,n,t,r){let o={method:e,credentials:"same-origin",headers:{"Content-Type":"application/json",...x.headers}};if(x.auth){let s=ae();s&&(o.headers.Authorization=`Bearer ${s}`)}if(t!==void 0&&e!=="GET"){let s=typeof t=="object"&&t!==null?{...t}:t;if(x.auth&&typeof s=="object"&&s!==null){let i=ae();i&&(s.formToken=i)}o.body=JSON.stringify(s)}return r?.headers&&Object.assign(o.headers,r.headers),r?.params&&(n=Ee(n,r.params)),Re(o,x.baseUrl+n)}var _e={configure(e){Object.assign(x,e)},get(e,n){return F("GET",e,void 0,n)},post(e,n,t){return F("POST",e,n,t)},put(e,n,t){return F("PUT",e,n,t)},patch(e,n,t){return F("PATCH",e,n,t)},delete(e,n){return F("DELETE",e,void 0,n)},async graphql(e,n,t,r){return F("POST",e,{query:n,variables:t||{}},r)},async upload(e,n,t){let r={method:"POST",headers:{...x.headers},body:n};if(delete r.headers["Content-Type"],delete r.headers["content-type"],x.auth){let o=ae();o&&(r.headers.Authorization=`Bearer ${o}`)}return t?.headers&&Object.assign(r.headers,t.headers),t?.params&&(e=Ee(e,t.params)),Re(r,x.baseUrl+e)},intercept(e,n){e==="request"?se.push(n):ie.push(n)},_reset(){x.baseUrl="",x.auth=!1,x.tokenKey="tina4_token",x.headers={},se.length=0,ie.length=0}};function lt(e){let n=e.cacheStrategy??"network-first",t=JSON.stringify(e.precache??[]),r=e.offlineRoute?`'${e.offlineRoute}'`:"null";return`
|
|
1
|
+
"use strict";var Tina4=(()=>{var ee=Object.defineProperty;var Xe=Object.getOwnPropertyDescriptor;var Ye=Object.getOwnPropertyNames;var et=Object.prototype.hasOwnProperty;var tt=(e,n)=>{for(var t in n)ee(e,t,{get:n[t],enumerable:!0})},nt=(e,n,t,r)=>{if(n&&typeof n=="object"||typeof n=="function")for(let o of Ye(n))!et.call(e,o)&&o!==t&&ee(e,o,{get:()=>n[o],enumerable:!(r=Xe(n,o))||r.enumerable});return e};var rt=e=>nt(ee({},"__esModule",{value:!0}),e);var $t={};tt($t,{Tina4Element:()=>N,api:()=>Ae,batch:()=>G,clearPersistedKeys:()=>Ve,computed:()=>ye,createI18n:()=>de,effect:()=>E,html:()=>be,i18n:()=>Je,isSignal:()=>I,navigate:()=>Q,persist:()=>ze,pwa:()=>Ne,route:()=>_e,router:()=>xe,rtc:()=>De,rtcConfig:()=>X,signal:()=>w,sse:()=>Pe,ws:()=>K});var L=null,H=null,U=null,B=null;function O(e){B=e}function J(){return B}var me=null,he=null,ve=[],ot=512;var V=0,te=new Set;function w(e,n){let t=e,r=new Set,o={_t4:!0,get value(){if(L&&(r.add(L),H)){let s=L;H.push(()=>r.delete(s))}return t},set value(s){if(Object.is(s,t))return;let i=t;if(t=s,o._debugInfo&&o._debugInfo.updateCount++,he&&he(o,i,s),V>0)for(let c of r)te.add(c);else{let c;for(let a of[...r])try{a()}catch(l){c===void 0&&(c=l)}if(c!==void 0)throw c}},_subscribe(s){return r.add(s),()=>{r.delete(s)}},peek(){return t}};return me?(o._debugInfo={label:n,createdAt:Date.now(),updateCount:0,subs:r},me(o,n)):ve.length<ot&&ve.push({ref:new WeakRef(o),label:n,createdAt:Date.now(),subs:r}),o}function ye(e){let n=w(void 0);return E(()=>{n.value=e()}),{_t4:!0,get value(){return n.value},set value(t){throw new Error("[tina4] computed signals are read-only")},_subscribe(t){return n._subscribe(t)},peek(){return n.peek()}}}function E(e){let n=!1,t=[],r=[],o=()=>{for(let c of r)c();r=[]},s=()=>{if(n)return;for(let g of t)g();t=[],o();let c=L,a=H,l=U;L=s,H=t,U=r;try{e()}finally{L=c,H=a,U=l}};s();let i=()=>{n=!0;for(let c of t)c();t=[],o()};return U&&U.push(i),B&&B.push(i),i}function G(e){V++;try{e()}finally{if(V--,V===0){let n=[...te];te.clear();let t;for(let r of n)try{r()}catch(o){t===void 0&&(t=o)}if(t!==void 0)throw t}}}function I(e){return e!==null&&typeof e=="object"&&e._t4===!0}var Se=new WeakMap,ne="t4:";function be(e,...n){let t=Se.get(e);if(!t){let i=document.createElement("template"),c=new Map,a="";for(let l=0;l<e.length;l++)if(a+=e[l],l<n.length)if(dt(a)){let m=ut(e[l]);m&&c.set(l,m),a+=`__t4_${l}__`}else a+=`<!--${ne}${l}-->`;i.innerHTML=a,t={template:i,propertyNames:c},Se.set(e,t)}let r=t.template.content.cloneNode(!0),o=st(r);for(let{marker:i,index:c}of o)at(i,n[c]);let s=it(r);for(let i of s)ct(i,n,t.propertyNames);return r}function st(e){let n=[];return se(e,t=>{if(t.nodeType===8){let r=t.data;if(r&&r.startsWith(ne)){let o=parseInt(r.slice(ne.length),10);n.push({marker:t,index:o})}}}),n}function it(e){let n=[];return se(e,t=>{t.nodeType===1&&n.push(t)}),n}function se(e,n){let t=e.childNodes;for(let r=0;r<t.length;r++){let o=t[r];n(o),se(o,n)}}function at(e,n){let t=e.parentNode;if(t)if(I(n)){let r=document.createTextNode("");t.replaceChild(r,e),E(()=>{r.data=String(n.value??"")})}else if(typeof n=="function"){let r=document.createComment("");t.replaceChild(r,e);let o=[],s=[];E(()=>{for(let u of s)u();s=[];let i=[],c=J();O(i);let a=n();O(c),s=i;for(let u of o)u.parentNode?.removeChild(u);o=[];let l=oe(a),g=r.parentNode;if(!g)return;let m=Z(g);for(let u of l){let d=m?D(u,m):u;g.insertBefore(d,r),o.push(d)}})}else if(Te(n)){let r=Z(t);if(r){let o=document.createDocumentFragment();for(let s of Array.from(n.childNodes))o.appendChild(D(s,r));t.replaceChild(o,e)}else t.replaceChild(n,e)}else if(n instanceof Node){let r=Z(t);t.replaceChild(r?D(n,r):n,e)}else if(Array.isArray(n)){let r=Z(t),o=document.createDocumentFragment();for(let s of n){let i=oe(s);for(let c of i)o.appendChild(r?D(c,r):c)}t.replaceChild(o,e)}else{let r=document.createTextNode(String(n??""));t.replaceChild(r,e)}}function ct(e,n,t){let r=[];for(let o of Array.from(e.attributes)){let s=o.name,i=o.value;if(s.startsWith("@")){let a=s.slice(1),l=i.match(/__t4_(\d+)__/);if(l){let g=n[parseInt(l[1],10)];typeof g=="function"&&e.addEventListener(a,m=>G(()=>g(m)))}r.push(s);continue}if(s.startsWith("?")){let a=s.slice(1),l=i.match(/__t4_(\d+)__/);if(l){let g=n[parseInt(l[1],10)];if(I(g)){let m=g;E(()=>{m.value?e.setAttribute(a,""):e.removeAttribute(a)})}else typeof g=="function"?E(()=>{g()?e.setAttribute(a,""):e.removeAttribute(a)}):g&&e.setAttribute(a,"")}r.push(s);continue}if(s.startsWith(".")){let a=i.match(/__t4_(\d+)__/);if(a){let l=parseInt(a[1],10),g=t.get(l)??s.slice(1),m=n[l];I(m)?E(()=>{e[g]=m.value}):typeof m=="function"?E(()=>{e[g]=m()??""}):e[g]=m}r.push(s);continue}let c=i.match(/__t4_(\d+)__/);if(c){let a=n[parseInt(c[1],10)];if(I(a)){let l=a;E(()=>{e.setAttribute(s,String(l.value??""))})}else typeof a=="function"?E(()=>{e.setAttribute(s,String(a()??""))}):e.setAttribute(s,String(a??""))}}for(let o of r)e.removeAttribute(o)}var re="http://www.w3.org/2000/svg",lt="http://www.w3.org/1998/Math/MathML",we="http://www.w3.org/1999/xhtml";function Z(e){let n=e;for(;n&&n.nodeType===1;){let t=n,r=t.namespaceURI;if(r===re&&t.localName==="foreignObject")return null;if(r===re||r===lt)return r;if(r===we)return null;n=n.parentNode}return null}function D(e,n){if(e.nodeType!==1)return e;let t=e;if(t.namespaceURI===n){for(let s of Array.from(t.childNodes)){let i=D(s,n);i!==s&&t.replaceChild(i,s)}return t}let r=document.createElementNS(n,t.localName);for(let s of Array.from(t.attributes))r.setAttribute(s.name,s.value);let o=n===re&&t.localName==="foreignObject"?we:n;for(let s of Array.from(t.childNodes))r.appendChild(D(s,o));return r}function ut(e){return e.match(/\.([^\s"'<>/=]+)\s*=\s*["']?$/)?.[1]}function oe(e){if(e==null||e===!1)return[];if(Te(e))return Array.from(e.childNodes);if(e instanceof Node)return[e];if(Array.isArray(e)){let n=[];for(let t of e)n.push(...oe(t));return n}return[document.createTextNode(String(e))]}function Te(e){return e!=null&&typeof e=="object"&&e.nodeType===11}function dt(e){let n=!1,t=!1,r=!1;for(let o=0;o<e.length;o++){if(!r&&e.startsWith("<!--",o)){let i=e.indexOf("-->",o+4);if(i===-1)return!1;o=i+2;continue}let s=e[o];s==="<"&&!n&&!t&&(r=!0),s===">"&&!n&&!t&&(r=!1),r&&(s==='"'&&!n&&(t=!t),s==="'"&&!t&&(n=!n))}return r}var ke=null,Ce=null;var N=class extends HTMLElement{constructor(){super();this._props={};this._rendered=!1;this._disposeRender=null;this._innerDisposers=[];let t=this.constructor;this._root=t.shadow?this.attachShadow({mode:"open"}):this;for(let[r,o]of Object.entries(t.props))this._props[r]=w(this._coerce(this.getAttribute(r),o))}static get observedAttributes(){return Object.keys(this.props)}connectedCallback(){if(this._rendered)return;this._rendered=!0;let t=this.constructor,r=null;if(t.styles&&t.shadow&&this._root instanceof ShadowRoot){let o=document.createElement("style");o.textContent=t.styles,this._root.appendChild(o),r=o}this._disposeRender=E(()=>{this._innerDisposers.splice(0).forEach(a=>a());let o=[],s=J();O(o);let i=this.render();O(s),this._innerDisposers=o;let c=Array.from(this._root.childNodes);for(let a of c)a!==r&&this._root.removeChild(a);i&&this._root.appendChild(i)}),this.onMount(),ke&&ke(this)}disconnectedCallback(){this._disposeRender&&(this._disposeRender(),this._disposeRender=null),this._innerDisposers.splice(0).forEach(t=>t()),this.onUnmount(),Ce&&Ce(this)}attributeChangedCallback(t,r,o){let i=this.constructor.props[t];i&&this._props[t]&&(this._props[t].value=this._coerce(o,i))}prop(t){if(!this._props[t])throw new Error(`[tina4] Prop '${t}' not declared in static props of <${this.tagName.toLowerCase()}>`);return this._props[t]}emit(t,r){this.dispatchEvent(new CustomEvent(t,{bubbles:!0,composed:!0,...r}))}onMount(){}onUnmount(){}_coerce(t,r){return r===Boolean?t!==null:r===Number?t!==null?Number(t):0:t??""}};N.props={},N.styles="",N.shadow=!0;var ae=[],F=null,j="history",ft=!1,W=[],ie=[],Ee=0;function _e(e,n){let t=[],r;e==="*"?r=".*":r=e.replace(/\{(\w+)\}/g,(s,i)=>(t.push(i),"([^/]+)"));let o=new RegExp(`^${r}$`);typeof n=="function"?ae.push({pattern:e,regex:o,paramNames:t,handler:n}):ae.push({pattern:e,regex:o,paramNames:t,handler:n.handler,guard:n.guard})}function Q(e,n){if(j==="hash")if(n?.replace){let t=new URL(location.href);t.hash="#"+e,history.replaceState(null,"",t.toString()),$()}else{let t=new URL(location.href);t.hash="#"+e,history.pushState(null,"",t.toString()),$()}else n?.replace?history.replaceState(null,"",e):history.pushState(null,"",e),$()}function $(){if(!F)return;let e=performance.now(),n=++Ee,t=j==="hash"?location.hash.slice(1)||"/":location.pathname;for(let r of ae){let o=t.match(r.regex);if(!o)continue;let s={};if(r.paramNames.forEach((a,l)=>{s[a]=decodeURIComponent(o[l+1])}),r.guard){let a=r.guard();if(a===!1)return;if(typeof a=="string"){Q(a,{replace:!0});return}}ie.splice(0).forEach(a=>a()),F.innerHTML="";let i=[];O(i);let c=r.handler(s);if(c instanceof Promise)c.then(a=>{if(O(null),n!==Ee){for(let g of i)g();return}Re(F,a),ie=i;let l=performance.now()-e;for(let g of W)g({path:t,params:s,pattern:r.pattern,durationMs:l})});else{O(null),Re(F,c),ie=i;let a=performance.now()-e;for(let l of W)l({path:t,params:s,pattern:r.pattern,durationMs:a})}return}}function Re(e,n){n instanceof DocumentFragment||n instanceof Node?e.replaceChildren(n):typeof n=="string"?e.innerHTML=n:n!=null&&e.replaceChildren(document.createTextNode(String(n)))}var xe={start(e){if(F=document.querySelector(e.target),!F)throw new Error(`[tina4] Router target '${e.target}' not found in DOM`);j=e.mode??"history",ft=!0,window.addEventListener("popstate",$),j==="hash"&&window.addEventListener("hashchange",$),document.addEventListener("click",n=>{if(n.metaKey||n.ctrlKey||n.shiftKey||n.altKey)return;let t=n.target.closest("a[href]");if(!t||t.origin!==location.origin||t.hasAttribute("target")||t.hasAttribute("download")||t.getAttribute("rel")?.includes("external"))return;n.preventDefault();let r=j==="hash"?t.getAttribute("href"):t.pathname;Q(r)}),$()},on(e,n){return W.push(n),()=>{let t=W.indexOf(n);t>=0&&W.splice(t,1)}}};var _={baseUrl:"",auth:!1,tokenKey:"tina4_token",headers:{}},ce=[],le=[],gt=0;function ue(){try{return localStorage.getItem(_.tokenKey)}catch{return null}}function pt(e){try{localStorage.setItem(_.tokenKey,e)}catch{}}function Me(e,n){let t=Object.entries(n).map(([r,o])=>`${encodeURIComponent(r)}=${encodeURIComponent(String(o))}`).join("&");return e+(e.includes("?")?"&":"?")+t}async function Oe(e,n){e._url=n,e._requestId=++gt;for(let c of ce){let a=c(e);a&&(e=a)}let t=await fetch(n,e),r=t.headers.get("FreshToken");r&&pt(r);let o=t.headers.get("Content-Type")??"",s;o.includes("json")?s=await t.json():s=await t.text();let i={status:t.status,data:s,ok:t.ok,headers:t.headers,_requestId:e._requestId};for(let c of le){let a=c(i);a&&(i=a)}if(!t.ok)throw i;return i.data}async function q(e,n,t,r){let o={method:e,credentials:"same-origin",headers:{"Content-Type":"application/json",..._.headers}};if(_.auth){let s=ue();s&&(o.headers.Authorization=`Bearer ${s}`)}if(t!==void 0&&e!=="GET"){let s=typeof t=="object"&&t!==null?{...t}:t;if(_.auth&&typeof s=="object"&&s!==null){let i=ue();i&&(s.formToken=i)}o.body=JSON.stringify(s)}return r?.headers&&Object.assign(o.headers,r.headers),r?.params&&(n=Me(n,r.params)),Oe(o,_.baseUrl+n)}var Ae={configure(e){Object.assign(_,e)},get(e,n){return q("GET",e,void 0,n)},post(e,n,t){return q("POST",e,n,t)},put(e,n,t){return q("PUT",e,n,t)},patch(e,n,t){return q("PATCH",e,n,t)},delete(e,n){return q("DELETE",e,void 0,n)},async graphql(e,n,t,r){return q("POST",e,{query:n,variables:t||{}},r)},async upload(e,n,t){let r={method:"POST",headers:{..._.headers},body:n};if(delete r.headers["Content-Type"],delete r.headers["content-type"],_.auth){let o=ue();o&&(r.headers.Authorization=`Bearer ${o}`)}return t?.headers&&Object.assign(r.headers,t.headers),t?.params&&(e=Me(e,t.params)),Oe(r,_.baseUrl+e)},intercept(e,n){e==="request"?ce.push(n):le.push(n)},_reset(){_.baseUrl="",_.auth=!1,_.tokenKey="tina4_token",_.headers={},ce.length=0,le.length=0}};function mt(e){let n=e.cacheStrategy??"network-first",t=JSON.stringify(e.precache??[]),r=e.offlineRoute?`'${e.offlineRoute}'`:"null";return`
|
|
2
2
|
const CACHE = 'tina4-v1';
|
|
3
3
|
const PRECACHE = ${t};
|
|
4
4
|
const OFFLINE = ${r};
|
|
@@ -44,5 +44,5 @@ self.addEventListener('fetch', (e) => {
|
|
|
44
44
|
))
|
|
45
45
|
);`}
|
|
46
46
|
});
|
|
47
|
-
`.trim()}function
|
|
48
|
-
`);N=ue.pop();for(let Je of ue){let de=Je.trim();de&&C(y(de),null)}}let ce=N.trim();ce&&C(y(ce),null),w=null,T()}).catch(b=>{b.name!=="AbortError"&&(w=null,_(b),T())})}function Q(){g++,d.value=g,r.value="reconnecting",c=setTimeout(()=>{c=null,S()},a),a=Math.min(a*2,t.reconnectMaxDelay)}function S(){t.mode==="fetch"?K():A()}let v={status:r,connected:o,lastMessage:s,lastEvent:i,error:l,reconnectCount:d,on(p,b){return f[p].push(b),()=>{let E=f[p],M=E.indexOf(b);M>=0&&E.splice(M,1)}},pipe(p,b){let E=M=>{p.value=b(M,p.value)};return v.on("message",E)},close(){u=!0,c&&(clearTimeout(c),c=null),h&&(h.close(),h=null),w&&(w.abort(),w=null),r.value="closed",o.value=!1}};return S(),v}var Oe={connect:gt};async function Z(e="/api/rtc/config"){let n=await fetch(e);if(!n.ok)throw new Error(`[tina4] rtc config fetch failed: ${n.status}`);return n.json()}function pt(){let e=window.location;return`${e.protocol==="https:"?"wss:":"ws:"}//${e.host}`}function Ae(e){return/^wss?:\/\//.test(e)?e:pt()+(e.startsWith("/")?e:"/"+e)}function mt(){let e=globalThis.crypto;if(e&&"randomUUID"in e)return e.randomUUID().slice(0,8);let n="";for(let t=0;t<8;t++)n+=Math.floor(16*(.5+t)).toString(16);return n+Date.now().toString(16).slice(-4)}async function ht(e,n={}){let t=k("connecting"),r=k(null),o=k([]),s=k(!1),i=k(null),l=mt(),d=n.config??await Z(n.configUrl),f=n.iceServers??d.iceServers??[],h=n.signallingUrl??d.signalling??"/ws/rtc",w=Ae(h.includes("{room}")?h.replace("{room}",e):`${h}/${e}`),u=null;n.media instanceof MediaStream?u=n.media:n.media!==!1&&(u=await navigator.mediaDevices.getUserMedia(n.media??{audio:!0,video:!0})),r.value=u;let a=u?.getVideoTracks()[0]??null,c=new Map,g=W.connect(w);function y(){o.value=[...c.entries()].map(([S,v])=>({id:S,stream:v.stream}))}function C(S){try{g.send({...S,from:l})}catch{}}function m(S){let v=c.get(S);if(v)return v;let p=new RTCPeerConnection({iceServers:f}),b={pc:p,polite:l<S,makingOffer:!1,ignoreOffer:!1,stream:null};if(c.set(S,b),u)for(let E of u.getTracks())p.addTrack(E,u);return p.onnegotiationneeded=async()=>{try{b.makingOffer=!0,await p.setLocalDescription(),C({type:"desc",to:S,description:p.localDescription})}catch(E){i.value=E}finally{b.makingOffer=!1}},p.onicecandidate=({candidate:E})=>{E&&C({type:"ice",to:S,candidate:E})},p.ontrack=({streams:E})=>{b.stream=E[0]??null,y()},p.onconnectionstatechange=()=>{["failed","closed"].includes(p.connectionState)?T(S):p.connectionState==="connected"&&(t.value="connected")},y(),b}function T(S){let v=c.get(S);if(v){try{v.pc.close()}catch{}c.delete(S),y()}}async function _(S){let v=S,p=v.from;if(!p||p===l||v.to&&v.to!==l)return;if(v.type==="hello"){m(p),C({type:"welcome",to:p});return}if(v.type==="welcome"){m(p);return}if(v.type==="bye"){T(p);return}let b=m(p),E=b.pc;if(v.type==="desc"){let M=v.description,N=M.type==="offer"&&(b.makingOffer||E.signalingState!=="stable");if(b.ignoreOffer=!b.polite&&N,b.ignoreOffer)return;await E.setRemoteDescription(M),M.type==="offer"&&(await E.setLocalDescription(),C({type:"desc",to:p,description:E.localDescription}))}else if(v.type==="ice")try{await E.addIceCandidate(v.candidate)}catch(M){b.ignoreOffer||(i.value=M)}}g.on("message",S=>{_(S)}),g.on("open",()=>{C({type:"hello"})});async function A(S){if(S)for(let{pc:v}of c.values()){let p=v.getSenders().find(b=>b.track?.kind==="video");p&&await p.replaceTrack(S)}}async function K(){await A(a),s.value=!1}async function Q(){let v=(await navigator.mediaDevices.getDisplayMedia({video:!0})).getVideoTracks()[0];await A(v),v.onended=()=>{K()},s.value=!0}return{status:t,localStream:r,peers:o,screenSharing:s,error:i,id:l,shareScreen:Q,stopScreen:K,toggleAudio(S){let v=u?.getAudioTracks()[0];return v?(v.enabled=S??!v.enabled,v.enabled):!1},toggleVideo(S){let v=u?.getVideoTracks()[0];return v?(v.enabled=S??!v.enabled,v.enabled):!1},leave(){C({type:"bye"});for(let S of[...c.keys()])T(S);if(u)for(let S of u.getTracks())S.stop();g.close(),t.value="closed"}}}function vt(e,n={}){let t=k([]),r=k([]),o=k([]),s=new Map,i=n.typingTimeout??3e3,l=n.url??"/ws/chat",d=Ae(l.includes("{channel}")?l.replace("{channel}",String(e)):`${l}/${e}`),f=W.connect(d,{token:n.token});function h(a){o.value.includes(a)||(o.value=[...o.value,a]);let c=s.get(a);c&&clearTimeout(c),s.set(a,setTimeout(()=>{o.value=o.value.filter(g=>g!==a),s.delete(a)},i))}f.on("message",a=>{let c=a;switch(c.type){case"message":t.value=[...t.value,c.message];break;case"presence":c.event==="roster"?r.value=c.users??[]:c.event==="join"&&c.user_id?r.value=[...new Set([...r.value,c.user_id])]:c.event==="leave"&&(r.value=r.value.filter(g=>g!==c.user_id));break;case"typing":c.user_id&&h(c.user_id);break}});let w=n.apiBase??"",u=n.messagesPath??"/api/channels/{id}/messages";return{status:f.status,connected:f.connected,messages:t,presence:r,typing:o,send(a,c){f.send({type:"message",body:a,thread_id:c??null})},sendTyping(){f.send({type:"typing"})},markRead(){f.send({type:"read"})},async history(a,c=50){let g=u.replace("{id}",String(e)),y=new URLSearchParams({limit:String(c)});a&&y.set("before",String(a));let C={};n.token&&(C.Authorization=`Bearer ${n.token}`);let m=await fetch(`${w}${g}?${y}`,{headers:C});if(!m.ok)throw new Error(`[tina4] chat history failed: ${m.status}`);let T=await m.json(),_=[...T].reverse();return t.value=[..._,...t.value],T},close(){for(let a of s.values())clearTimeout(a);s.clear(),f.close()}}}async function yt(e,n,t={}){let r=t.filesPath??"/api/files",o=new FormData;o.append("channel_id",String(e)),o.append("file",n,n.name??"file");let s={};t.token&&(s.Authorization=`Bearer ${t.token}`);let i=await fetch(`${t.apiBase??""}${r}`,{method:"POST",body:o,headers:s});if(!i.ok)throw new Error(`[tina4] file upload failed: ${i.status}`);return i.json()}async function bt(e,n={}){let t=/^https?:\/\//.test(e)?e:`${n.apiBase??""}${n.filesPath??"/api/files"}/${e}`,r={};n.token&&(r.Authorization=`Bearer ${n.token}`);let o=await fetch(t,{headers:r});if(!o.ok)throw new Error(`[tina4] file fetch failed: ${o.status}`);return URL.createObjectURL(await o.blob())}var Ie={config:Z,call:ht,chat:vt,upload:yt,fetchBlob:bt};var Pe={read:e=>JSON.parse(e),write:e=>JSON.stringify(e)},Ne=/(token|password|passwd|secret|api[_-]?key|apikey|auth(?!or)|credential|jwt|bearer|otp|seed|private[_-]?key|session[_-]?id)/i,St=/^[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}$/,kt=/^[A-Za-z0-9+/_=-]{40,}$/,Le=new Set;function De(e,n){if(Ne.test(e))return`key name "${e}" looks like a credential`;if(typeof n=="string"){if(St.test(n))return"value looks like a JWT";if(n.length>=40&&kt.test(n))return"value looks like a long base64 / token"}if(n&&typeof n=="object"&&!Array.isArray(n)){for(let t of Object.keys(n))if(Ne.test(t))return`object contains a credential-shape field "${t}"`}return null}function Fe(e,n){Le.has(n)||(Le.add(n),console.warn(`[tina4 persist] ${e} (key: ${JSON.stringify(n)}). localStorage is XSS-readable and never appropriate for credentials, tokens, passwords, personal data, or secrets. See STORAGE.md.`))}function qe(e){if(typeof globalThis>"u")return null;try{let n=e==="local"?globalThis.localStorage:globalThis.sessionStorage;return!n||typeof n.getItem!="function"?null:n}catch{return null}}function Ue(e,n){let{key:t,storage:r="local",serializer:o=Pe,version:s=1,migrate:i,syncTabs:l=!1,silenceCredentialWarning:d=!1}=n;if(!t||typeof t!="string")throw new Error("[tina4 persist] options.key is required and must be a string");let f=o===Pe,h=qe(r);if(!h)return $e(e,()=>{},()=>{});try{let a=h.getItem(t);if(a!==null){let c,g;try{let y=JSON.parse(a);y&&typeof y=="object"&&"value"in y?(c=y.v,g=y.value):g=y}catch{g=f?a:o.read(a)}if(c===s||c===void 0){let y=f?g:o.read(typeof g=="string"?g:JSON.stringify(g));e.value=y}else if(i)try{e.value=i(g,c)}catch(y){console.warn(`[tina4 persist] migrate() threw for key "${t}":`,y)}else console.warn(`[tina4 persist] stored version ${c} does not match current ${s} for key "${t}", and no migrate() was provided. Discarding the stored value.`)}}catch(a){console.warn(`[tina4 persist] failed to read key "${t}":`,a)}if(!d){let a=De(t,e.peek());a&&Fe(a,t)}let w=R(()=>{let a=e.value;if(!d){let c=De(t,a);c&&Fe(c,t)}try{let g=JSON.stringify(f?{v:s,value:a}:{v:s,value:o.write(a)});h.setItem(t,g)}catch(c){console.warn(`[tina4 persist] failed to write key "${t}":`,c)}}),u=null;if(l&&typeof globalThis<"u"&&"addEventListener"in globalThis){let a=c=>{let g=c;if(g.storageArea===h&&g.key===t&&g.newValue!==null)try{let y=JSON.parse(g.newValue),C=y&&typeof y=="object"&&"v"in y?y.v:void 0,m=C!==void 0?y.value:y;C!==void 0&&C!==s&&i?e.value=i(m,C):(C===s||C===void 0)&&(e.value=f?m:o.read(typeof m=="string"?m:JSON.stringify(m)))}catch(y){console.warn(`[tina4 persist] failed to parse storage event for key "${t}":`,y)}};globalThis.addEventListener?.("storage",a),u=()=>{globalThis.removeEventListener?.("storage",a)}}return $e(e,()=>{try{h.removeItem(t)}catch(a){console.warn(`[tina4 persist] failed to clear key "${t}":`,a)}},()=>{w(),u&&u()})}function $e(e,n,t){return Object.assign(e,{clear:n,dispose:t})}function je(e,n="local"){let t=qe(n);if(t)for(let r of e)try{t.removeItem(r)}catch(o){console.warn(`[tina4 persist] failed to clear key "${r}":`,o)}}var wt=["ar","he","fa","ur","ps","dv","syr","ckb","yi"];function Tt(){return globalThis.navigator?.language||"en"}function He(e,n="",t={}){for(let[r,o]of Object.entries(e)){let s=n?`${n}.${r}`:r;if(o!==null&&typeof o=="object"&&!Array.isArray(o))He(o,s,t);else{let i=String(o);t[s]=i,r in t||(t[r]=i)}}return t}function Ct(e,n){return e.replace(/\{(\w+)\}/g,(t,r)=>Object.prototype.hasOwnProperty.call(n,r)?String(n[r]):t)}function le(e={}){let n=e.locale||Tt(),t=e.fallbackLocale||n,r=new Set([...wt,...e.rtlLocales||[]]),o=k(n,"i18n.locale"),s=new Map,i=new Map;function l(u,a){let c=He(a),g=s.get(u);s.set(u,g?{...g,...c}:c)}if(e.messages)for(let[u,a]of Object.entries(e.messages))l(u,a);function d(u,a){return s.get(u)?.[a]}function f(u,a){let c=`n|${u}|${JSON.stringify(a||{})}`,g=i.get(c);return g||(g=new Intl.NumberFormat(u,a),i.set(c,g)),g}function h(u,a){let c=`d|${u}|${JSON.stringify(a||{})}`,g=i.get(c);return g||(g=new Intl.DateTimeFormat(u,a),i.set(c,g)),g}function w(u,a){let c=`r|${u}|${JSON.stringify(a||{})}`,g=i.get(c);return g||(g=new Intl.RelativeTimeFormat(u,a),i.set(c,g)),g}return{locale:o,t(u,a){let c=o.value,g=d(c,u);return g===void 0&&t!==c&&(g=d(t,u)),g===void 0&&(g=u),a?Ct(g,a):g},setLocale(u){o.value=u},getLocale(){return o.value},addMessages:l,hasLocale(u){return s.has(u)},availableLocales(){return[...s.keys()].sort()},async loadMessages(u,a){let c=await fetch(a);if(!c.ok)throw new Error(`[tina4 i18n] failed to load "${u}" from ${a}: ${c.status}`);l(u,await c.json())},number(u,a){return f(o.value,a).format(u)},currency(u,a,c){return f(o.value,{style:"currency",currency:a,...c}).format(u)},date(u,a){let c=u instanceof Date?u:new Date(u);return h(o.value,a).format(c)},relativeTime(u,a,c){return w(o.value,c||{numeric:"auto"}).format(u,a)},isRTL(){return r.has(o.value.split("-")[0].toLowerCase())},dir(){return this.isRTL()?"rtl":"ltr"}}}var We=le();return Xe(Et);})();
|
|
47
|
+
`.trim()}function Ie(e){let n={name:e.name,short_name:e.shortName??e.name,start_url:"/",display:e.display??"standalone",background_color:e.backgroundColor??"#ffffff",theme_color:e.themeColor??"#000000"};return e.icon&&(n.icons=[{src:e.icon,sizes:"192x192",type:"image/png"},{src:e.icon,sizes:"512x512",type:"image/png"}]),n}var Ne={register(e){let n=Ie(e),t=new Blob([JSON.stringify(n)],{type:"application/json"}),r=document.createElement("link");r.rel="manifest",r.href=URL.createObjectURL(t),document.head.appendChild(r);let o=document.querySelector('meta[name="theme-color"]');o||(o=document.createElement("meta"),o.name="theme-color",document.head.appendChild(o)),o.content=e.themeColor??"#000000","serviceWorker"in navigator&&(e.swUrl?navigator.serviceWorker.register(e.swUrl).catch(s=>{console.warn("[tina4] Service worker registration failed:",s)}):navigator.serviceWorker.register("/sw.js").catch(()=>{console.info("[tina4] No service worker at /sw.js. Use pwa.generateServiceWorker() to create one, or pass swUrl in config.")}))},generateServiceWorker(e){return mt(e)},generateManifest(e){return Ie(e)}};var ht={reconnect:!0,reconnectDelay:1e3,reconnectMaxDelay:3e4,reconnectAttempts:1/0,protocols:[],token:""};function vt(e){let n=Array.isArray(e.protocols)?e.protocols:e.protocols?[e.protocols]:[];return e.token?["bearer",e.token,...n]:e.protocols}function yt(e,n={}){let t={...ht,...n},r=w("connecting"),o=w(!1),s=w(null),i=w(null),c=w(0),a={message:[],open:[],close:[],error:[]},l=null,g=!1,m=t.reconnectDelay,u=null,d=0;function f(v){if(typeof v!="string")return v;try{return JSON.parse(v)}catch{return v}}function h(){r.value=d>0?"reconnecting":"connecting";try{l=new WebSocket(e,vt(t))}catch{r.value="closed",o.value=!1;return}l.onopen=()=>{r.value="open",o.value=!0,i.value=null,d=0,m=t.reconnectDelay,c.value=0;for(let v of a.open)v()},l.onmessage=v=>{let T=f(v.data);s.value=T;for(let R of a.message)R(T)},l.onclose=v=>{r.value="closed",o.value=!1;for(let T of a.close)T(v.code,v.reason);!g&&t.reconnect&&d<t.reconnectAttempts&&x()},l.onerror=v=>{i.value=v;for(let T of a.error)T(v)}}function x(){d++,c.value=d,r.value="reconnecting",u=setTimeout(()=>{u=null,h()},m),m=Math.min(m*2,t.reconnectMaxDelay)}let C={status:r,connected:o,lastMessage:s,error:i,reconnectCount:c,send(v){if(!l||l.readyState!==WebSocket.OPEN)throw new Error("[tina4] WebSocket is not connected");let T=typeof v=="string"?v:JSON.stringify(v);l.send(T)},on(v,T){return a[v].push(T),()=>{let R=a[v],A=R.indexOf(T);A>=0&&R.splice(A,1)}},pipe(v,T){let R=A=>{v.value=T(A,v.value)};return C.on("message",R)},close(v,T){g=!0,u&&(clearTimeout(u),u=null),l&&l.close(v??1e3,T??""),r.value="closed",o.value=!1}};return h(),C}var K={connect:yt};var St={mode:"eventsource",method:"GET",headers:{},body:void 0,reconnect:!0,reconnectDelay:1e3,reconnectMaxDelay:3e4,reconnectAttempts:1/0,events:[],json:!0};function bt(e,n={}){let t={...St,...n},r=w("connecting"),o=w(!1),s=w(null),i=w(null),c=w(null),a=w(0),l={message:[],open:[],close:[],error:[]},g=null,m=null,u=!1,d=t.reconnectDelay,f=null,h=0;function x(p){if(!t.json||typeof p!="string")return p;try{return JSON.parse(p)}catch{return p}}function C(p,S){s.value=p,i.value=S;for(let k of l.message)k(p,S??void 0)}function v(){r.value="open",o.value=!0,c.value=null,h=0,d=t.reconnectDelay,a.value=0;for(let p of l.open)p()}function T(){r.value="closed",o.value=!1;for(let p of l.close)p();!u&&t.reconnect&&h<t.reconnectAttempts&&Y()}function R(p){c.value=p;for(let S of l.error)S(p)}function A(){r.value=h>0?"reconnecting":"connecting";try{g=new EventSource(e)}catch{r.value="closed",o.value=!1;return}g.onopen=()=>v(),g.onmessage=p=>{C(x(p.data),null)};for(let p of t.events)g.addEventListener(p,S=>{C(x(S.data),p)});g.onerror=p=>{R(p),g&&g.readyState===2&&(g=null,T())}}function z(){r.value=h>0?"reconnecting":"connecting",m=new AbortController;let p={method:t.method,headers:t.headers,signal:m.signal};t.body!==void 0&&(p.body=typeof t.body=="string"?t.body:JSON.stringify(t.body)),fetch(e,p).then(async S=>{if(!S.ok){R(new Error(`[tina4] SSE fetch ${S.status}`)),T();return}v();let k=S.body.getReader(),M=new TextDecoder,P="";for(;;){let{done:Ge,value:Ze}=await k.read();if(Ge)break;P+=M.decode(Ze,{stream:!0});let ge=P.split(`
|
|
48
|
+
`);P=ge.pop();for(let Qe of ge){let pe=Qe.trim();pe&&C(x(pe),null)}}let fe=P.trim();fe&&C(x(fe),null),m=null,T()}).catch(S=>{S.name!=="AbortError"&&(m=null,R(S),T())})}function Y(){h++,a.value=h,r.value="reconnecting",f=setTimeout(()=>{f=null,b()},d),d=Math.min(d*2,t.reconnectMaxDelay)}function b(){t.mode==="fetch"?z():A()}let y={status:r,connected:o,lastMessage:s,lastEvent:i,error:c,reconnectCount:a,on(p,S){return l[p].push(S),()=>{let k=l[p],M=k.indexOf(S);M>=0&&k.splice(M,1)}},pipe(p,S){let k=M=>{p.value=S(M,p.value)};return y.on("message",k)},close(){u=!0,f&&(clearTimeout(f),f=null),g&&(g.close(),g=null),m&&(m.abort(),m=null),r.value="closed",o.value=!1}};return b(),y}var Pe={connect:bt};async function X(e="/api/rtc/config"){let n=await fetch(e);if(!n.ok)throw new Error(`[tina4] rtc config fetch failed: ${n.status}`);return n.json()}function wt(){let e=window.location;return`${e.protocol==="https:"?"wss:":"ws:"}//${e.host}`}function Le(e){return/^wss?:\/\//.test(e)?e:wt()+(e.startsWith("/")?e:"/"+e)}function Tt(){let e=globalThis.crypto;if(e&&"randomUUID"in e)return e.randomUUID().slice(0,8);let n="";for(let t=0;t<8;t++)n+=Math.floor(16*(.5+t)).toString(16);return n+Date.now().toString(16).slice(-4)}async function kt(e,n={}){let t=w("connecting"),r=w(null),o=w([]),s=w(!1),i=w(null),c=Tt(),a=n.config??await X(n.configUrl),l=n.iceServers??a.iceServers??[],g=n.signallingUrl??a.signalling??"/ws/rtc",m=Le(g.includes("{room}")?g.replace("{room}",e):`${g}/${e}`),u=null;n.media instanceof MediaStream?u=n.media:n.media!==!1&&(u=await navigator.mediaDevices.getUserMedia(n.media??{audio:!0,video:!0})),r.value=u;let d=u?.getVideoTracks()[0]??null,f=new Map,h=K.connect(m);function x(){o.value=[...f.entries()].map(([b,y])=>({id:b,stream:y.stream}))}function C(b){try{h.send({...b,from:c})}catch{}}function v(b){let y=f.get(b);if(y)return y;let p=new RTCPeerConnection({iceServers:l}),S={pc:p,polite:c<b,makingOffer:!1,ignoreOffer:!1,stream:null};if(f.set(b,S),u)for(let k of u.getTracks())p.addTrack(k,u);return p.onnegotiationneeded=async()=>{try{S.makingOffer=!0,await p.setLocalDescription(),C({type:"desc",to:b,description:p.localDescription})}catch(k){i.value=k}finally{S.makingOffer=!1}},p.onicecandidate=({candidate:k})=>{k&&C({type:"ice",to:b,candidate:k})},p.ontrack=({streams:k})=>{S.stream=k[0]??null,x()},p.onconnectionstatechange=()=>{["failed","closed"].includes(p.connectionState)?T(b):p.connectionState==="connected"&&(t.value="connected")},x(),S}function T(b){let y=f.get(b);if(y){try{y.pc.close()}catch{}f.delete(b),x()}}async function R(b){let y=b,p=y.from;if(!p||p===c||y.to&&y.to!==c)return;if(y.type==="hello"){v(p),C({type:"welcome",to:p});return}if(y.type==="welcome"){v(p);return}if(y.type==="bye"){T(p);return}let S=v(p),k=S.pc;if(y.type==="desc"){let M=y.description,P=M.type==="offer"&&(S.makingOffer||k.signalingState!=="stable");if(S.ignoreOffer=!S.polite&&P,S.ignoreOffer)return;await k.setRemoteDescription(M),M.type==="offer"&&(await k.setLocalDescription(),C({type:"desc",to:p,description:k.localDescription}))}else if(y.type==="ice")try{await k.addIceCandidate(y.candidate)}catch(M){S.ignoreOffer||(i.value=M)}}h.on("message",b=>{R(b)}),h.on("open",()=>{C({type:"hello"})});async function A(b){if(b)for(let{pc:y}of f.values()){let p=y.getSenders().find(S=>S.track?.kind==="video");p&&await p.replaceTrack(b)}}async function z(){await A(d),s.value=!1}async function Y(){let y=(await navigator.mediaDevices.getDisplayMedia({video:!0})).getVideoTracks()[0];await A(y),y.onended=()=>{z()},s.value=!0}return{status:t,localStream:r,peers:o,screenSharing:s,error:i,id:c,shareScreen:Y,stopScreen:z,toggleAudio(b){let y=u?.getAudioTracks()[0];return y?(y.enabled=b??!y.enabled,y.enabled):!1},toggleVideo(b){let y=u?.getVideoTracks()[0];return y?(y.enabled=b??!y.enabled,y.enabled):!1},leave(){C({type:"bye"});for(let b of[...f.keys()])T(b);if(u)for(let b of u.getTracks())b.stop();h.close(),t.value="closed"}}}function Ct(e,n={}){let t=w([]),r=w([]),o=w([]),s=new Map,i=n.typingTimeout??3e3,c=n.url??"/ws/chat",a=Le(c.includes("{channel}")?c.replace("{channel}",String(e)):`${c}/${e}`),l=K.connect(a,{token:n.token});function g(d){o.value.includes(d)||(o.value=[...o.value,d]);let f=s.get(d);f&&clearTimeout(f),s.set(d,setTimeout(()=>{o.value=o.value.filter(h=>h!==d),s.delete(d)},i))}l.on("message",d=>{let f=d;switch(f.type){case"message":t.value=[...t.value,f.message];break;case"presence":f.event==="roster"?r.value=f.users??[]:f.event==="join"&&f.user_id?r.value=[...new Set([...r.value,f.user_id])]:f.event==="leave"&&(r.value=r.value.filter(h=>h!==f.user_id));break;case"typing":f.user_id&&g(f.user_id);break}});let m=n.apiBase??"",u=n.messagesPath??"/api/channels/{id}/messages";return{status:l.status,connected:l.connected,messages:t,presence:r,typing:o,send(d,f){l.send({type:"message",body:d,thread_id:f??null})},sendTyping(){l.send({type:"typing"})},markRead(){l.send({type:"read"})},async history(d,f=50){let h=u.replace("{id}",String(e)),x=new URLSearchParams({limit:String(f)});d&&x.set("before",String(d));let C={};n.token&&(C.Authorization=`Bearer ${n.token}`);let v=await fetch(`${m}${h}?${x}`,{headers:C});if(!v.ok)throw new Error(`[tina4] chat history failed: ${v.status}`);let T=await v.json(),R=[...T].reverse();return t.value=[...R,...t.value],T},close(){for(let d of s.values())clearTimeout(d);s.clear(),l.close()}}}async function Et(e,n,t={}){let r=t.filesPath??"/api/files",o=new FormData;o.append("channel_id",String(e)),o.append("file",n,n.name??"file");let s={};t.token&&(s.Authorization=`Bearer ${t.token}`);let i=await fetch(`${t.apiBase??""}${r}`,{method:"POST",body:o,headers:s});if(!i.ok)throw new Error(`[tina4] file upload failed: ${i.status}`);return i.json()}async function Rt(e,n={}){let t=/^https?:\/\//.test(e)?e:`${n.apiBase??""}${n.filesPath??"/api/files"}/${e}`,r={};n.token&&(r.Authorization=`Bearer ${n.token}`);let o=await fetch(t,{headers:r});if(!o.ok)throw new Error(`[tina4] file fetch failed: ${o.status}`);return URL.createObjectURL(await o.blob())}var De={config:X,call:kt,chat:Ct,upload:Et,fetchBlob:Rt};var Fe={read:e=>JSON.parse(e),write:e=>JSON.stringify(e)},$e=/(token|password|passwd|secret|api[_-]?key|apikey|auth(?!or)|credential|jwt|bearer|otp|seed|private[_-]?key|session[_-]?id)/i,_t=/^[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}$/,xt=/^[A-Za-z0-9+/_=-]{40,}$/,qe=new Set;function Mt(e,n){if($e.test(e))return`key name "${e}" looks like a credential`;if(typeof n=="string"){if(_t.test(n))return"value looks like a JWT";if(n.length>=40&&xt.test(n))return"value looks like a long base64 / token"}if(n&&typeof n=="object"&&!Array.isArray(n)){for(let t of Object.keys(n))if($e.test(t))return`object contains a credential-shape field "${t}"`}return null}function Ot(e,n){qe.has(n)||(qe.add(n),console.warn(`[tina4 persist] ${e} (key: ${JSON.stringify(n)}). localStorage is XSS-readable and never appropriate for credentials, tokens, passwords, personal data, or secrets. See STORAGE.md.`))}function He(e){if(typeof globalThis>"u")return null;try{let n=e==="local"?globalThis.localStorage:globalThis.sessionStorage;return!n||typeof n.getItem!="function"?null:n}catch{return null}}function je(e,n,t){try{let r=JSON.parse(e);return r&&typeof r=="object"&&"value"in r?{version:r.v,payload:r.value}:{version:void 0,payload:r}}catch{return{version:void 0,payload:t?e:n.read(e)}}}function We(e,n,t){return t?e:n.read(typeof e=="string"?e:JSON.stringify(e))}function At(e,n,t,r,o,s,i){try{let c=n.getItem(t);if(c===null)return;let a=je(c,o,s);if(a.version===r||a.version===void 0){e.value=We(a.payload,o,s);return}if(i){try{e.value=i(a.payload,a.version)}catch(l){console.warn(`[tina4 persist] migrate() threw for key "${t}":`,l)}return}console.warn(`[tina4 persist] stored version ${a.version} does not match current ${r} for key "${t}", and no migrate() was provided. Discarding the stored value.`)}catch(c){console.warn(`[tina4 persist] failed to read key "${t}":`,c)}}function Ke(e,n,t){if(t)return;let r=Mt(e,n);r&&Ot(r,e)}function It(e,n,t,r,o,s,i){return E(()=>{let c=e.value;Ke(t,c,i);try{let a=JSON.stringify(s?{v:r,value:c}:{v:r,value:o.write(c)});n.setItem(t,a)}catch(a){console.warn(`[tina4 persist] failed to write key "${t}":`,a)}})}function Nt(e,n,t,r,o,s,i,c){if(!c||typeof globalThis>"u"||!("addEventListener"in globalThis))return null;let a=l=>{let g=l;if(!(g.storageArea!==n||g.key!==t||g.newValue===null))try{let m=je(g.newValue,o,s);m.version!==void 0&&m.version!==r&&i?e.value=i(m.payload,m.version):(m.version===r||m.version===void 0)&&(e.value=We(m.payload,o,s))}catch(m){console.warn(`[tina4 persist] failed to parse storage event for key "${t}":`,m)}};return globalThis.addEventListener?.("storage",a),()=>{globalThis.removeEventListener?.("storage",a)}}function Pt(e,n){try{e.removeItem(n)}catch(t){console.warn(`[tina4 persist] failed to clear key "${n}":`,t)}}function ze(e,n){let{key:t,storage:r="local",serializer:o=Fe,version:s=1,migrate:i,syncTabs:c=!1,silenceCredentialWarning:a=!1}=n;if(!t||typeof t!="string")throw new Error("[tina4 persist] options.key is required and must be a string");let l=o===Fe,g=He(r);if(!g)return Ue(e,()=>{},()=>{});At(e,g,t,s,o,l,i),Ke(t,e.peek(),a);let m=It(e,g,t,s,o,l,a),u=Nt(e,g,t,s,o,l,i,c);return Ue(e,()=>Pt(g,t),()=>{m(),u&&u()})}function Ue(e,n,t){return Object.assign(e,{clear:n,dispose:t})}function Ve(e,n="local"){let t=He(n);if(t)for(let r of e)try{t.removeItem(r)}catch(o){console.warn(`[tina4 persist] failed to clear key "${r}":`,o)}}var Lt=["ar","he","fa","ur","ps","dv","syr","ckb","yi"];function Dt(){return globalThis.navigator?.language||"en"}function Be(e,n="",t={}){for(let[r,o]of Object.entries(e)){let s=n?`${n}.${r}`:r;if(o!==null&&typeof o=="object"&&!Array.isArray(o))Be(o,s,t);else{let i=String(o);t[s]=i,r in t||(t[r]=i)}}return t}function Ft(e,n){return e.replace(/\{(\w+)\}/g,(t,r)=>Object.prototype.hasOwnProperty.call(n,r)?String(n[r]):t)}function de(e={}){let n=e.locale||Dt(),t=e.fallbackLocale||n,r=new Set([...Lt,...e.rtlLocales||[]]),o=w(n,"i18n.locale"),s=new Map,i=new Map;function c(u,d){let f=Be(d),h=s.get(u);s.set(u,h?{...h,...f}:f)}if(e.messages)for(let[u,d]of Object.entries(e.messages))c(u,d);function a(u,d){return s.get(u)?.[d]}function l(u,d){let f=`n|${u}|${JSON.stringify(d||{})}`,h=i.get(f);return h||(h=new Intl.NumberFormat(u,d),i.set(f,h)),h}function g(u,d){let f=`d|${u}|${JSON.stringify(d||{})}`,h=i.get(f);return h||(h=new Intl.DateTimeFormat(u,d),i.set(f,h)),h}function m(u,d){let f=`r|${u}|${JSON.stringify(d||{})}`,h=i.get(f);return h||(h=new Intl.RelativeTimeFormat(u,d),i.set(f,h)),h}return{locale:o,t(u,d){let f=o.value,h=a(f,u);return h===void 0&&t!==f&&(h=a(t,u)),h===void 0&&(h=u),d?Ft(h,d):h},setLocale(u){o.value=u},getLocale(){return o.value},addMessages:c,hasLocale(u){return s.has(u)},availableLocales(){return[...s.keys()].sort()},async loadMessages(u,d){let f=await fetch(d);if(!f.ok)throw new Error(`[tina4 i18n] failed to load "${u}" from ${d}: ${f.status}`);c(u,await f.json())},number(u,d){return l(o.value,d).format(u)},currency(u,d,f){return l(o.value,{style:"currency",currency:d,...f}).format(u)},date(u,d){let f=u instanceof Date?u:new Date(u);return g(o.value,d).format(f)},relativeTime(u,d,f){return m(o.value,f||{numeric:"auto"}).format(u,d)},isRTL(){return r.has(o.value.split("-")[0].toLowerCase())},dir(){return this.isRTL()?"rtl":"ltr"}}}var Je=de();return rt($t);})();
|
|
@@ -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
|
+
}
|
|
@@ -1629,6 +1629,14 @@ export function _getResponseBackend(config?: ResponseCacheConfig): Promise<Cache
|
|
|
1629
1629
|
*/
|
|
1630
1630
|
const SHARED_CACHE_DIRECTIVES = ["public", "s-maxage", "must-revalidate"];
|
|
1631
1631
|
|
|
1632
|
+
/**
|
|
1633
|
+
* Response directives that forbid storing the response here (RFC 9111 s3):
|
|
1634
|
+
* `no-store` forbids storage in any cache, `private` and `no-cache` in a shared
|
|
1635
|
+
* one. Before this was honoured a handler had no way to keep a response out of
|
|
1636
|
+
* the cache — setting the correct standard header did nothing.
|
|
1637
|
+
*/
|
|
1638
|
+
const NO_STORE_DIRECTIVES = ["no-store", "private", "no-cache"];
|
|
1639
|
+
|
|
1632
1640
|
/** Case-insensitive request header lookup. */
|
|
1633
1641
|
function requestHeader(req: { headers?: Record<string, unknown> }, name: string): string | undefined {
|
|
1634
1642
|
const headers = req?.headers;
|
|
@@ -1650,22 +1658,67 @@ function varyFields(raw: unknown): string[] {
|
|
|
1650
1658
|
}
|
|
1651
1659
|
|
|
1652
1660
|
/**
|
|
1653
|
-
*
|
|
1661
|
+
* The lower-cased Cache-Control directive NAMES on a header value, as a set.
|
|
1654
1662
|
*
|
|
1655
|
-
*
|
|
1656
|
-
*
|
|
1657
|
-
*
|
|
1658
|
-
|
|
1659
|
-
|
|
1663
|
+
* Parsed as comma-separated tokens with any `=value` stripped, rather than by
|
|
1664
|
+
* substring search, so `no-cache="Set-Cookie"` is recognised as `no-cache` and
|
|
1665
|
+
* a directive name never matches as a fragment of a longer one.
|
|
1666
|
+
*/
|
|
1667
|
+
function cacheControlTokens(raw: unknown): Set<string> {
|
|
1668
|
+
const text = Array.isArray(raw) ? raw.join(",") : String(raw ?? "");
|
|
1669
|
+
const tokens = new Set<string>();
|
|
1670
|
+
for (const token of text.split(",")) {
|
|
1671
|
+
const name = token.split("=")[0].trim().toLowerCase();
|
|
1672
|
+
if (name !== "") tokens.add(name);
|
|
1673
|
+
}
|
|
1674
|
+
return tokens;
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
/** Does the response Cache-Control carry a directive that lets a SHARED cache store it? */
|
|
1678
|
+
function sharedCacheAllowed(cacheControl: unknown): boolean {
|
|
1679
|
+
const directives = cacheControlTokens(cacheControl);
|
|
1680
|
+
return SHARED_CACHE_DIRECTIVES.some((directive) => directives.has(directive));
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
/** Is a Set-Cookie response header present? Any non-empty value (it may be an array). */
|
|
1684
|
+
function hasSetCookie(raw: unknown): boolean {
|
|
1685
|
+
if (raw === undefined || raw === null) return false;
|
|
1686
|
+
if (Array.isArray(raw)) return raw.length > 0;
|
|
1687
|
+
return String(raw) !== "";
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
/**
|
|
1691
|
+
* May a SHARED cache store this response? (RFC 9111 s3, s4.1)
|
|
1660
1692
|
*
|
|
1661
1693
|
* s4.1 — a stored response whose Vary contains "*" "always fails to match", so
|
|
1662
1694
|
* storing one is pointless.
|
|
1695
|
+
*
|
|
1696
|
+
* s3 — a shared cache may store a response only when it is not marked
|
|
1697
|
+
* un-storable and is not built for one specific caller:
|
|
1698
|
+
*
|
|
1699
|
+
* - `no-store`/`private`/`no-cache` on the response forbid storing it here, so a
|
|
1700
|
+
* handler can always opt a body out of the cache with the standard header.
|
|
1701
|
+
* - The key is method + URL only, and on Node the cache answers BEFORE the auth
|
|
1702
|
+
* gate, so a response built for one caller replays to whoever asks for that URL
|
|
1703
|
+
* next. Authorization marks such a caller — and so does a session Cookie on the
|
|
1704
|
+
* request (Tina4's own session mechanism IS a cookie), and a Set-Cookie on the
|
|
1705
|
+
* response (it installs a per-caller session). All three are storable only when
|
|
1706
|
+
* the response opts in with an explicit shared-cache directive, which keeps a
|
|
1707
|
+
* genuinely public page cacheable for cookie-bearing browsers.
|
|
1663
1708
|
*/
|
|
1664
|
-
function mayStore(
|
|
1709
|
+
function mayStore(
|
|
1710
|
+
req: { headers?: Record<string, unknown> },
|
|
1711
|
+
vary: string[],
|
|
1712
|
+
cacheControl: unknown,
|
|
1713
|
+
setCookie: unknown,
|
|
1714
|
+
): boolean {
|
|
1665
1715
|
if (vary.includes("*")) return false;
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1716
|
+
const directives = cacheControlTokens(cacheControl);
|
|
1717
|
+
if (NO_STORE_DIRECTIVES.some((directive) => directives.has(directive))) return false;
|
|
1718
|
+
if (requestHeader(req, "authorization") !== undefined) return sharedCacheAllowed(cacheControl);
|
|
1719
|
+
if (requestHeader(req, "cookie") !== undefined) return sharedCacheAllowed(cacheControl);
|
|
1720
|
+
if (hasSetCookie(setCookie)) return sharedCacheAllowed(cacheControl);
|
|
1721
|
+
return true;
|
|
1669
1722
|
}
|
|
1670
1723
|
|
|
1671
1724
|
/**
|
|
@@ -1722,7 +1775,7 @@ export function responseCache(config?: ResponseCacheConfig): Middleware {
|
|
|
1722
1775
|
res.raw.end = function (chunk?: any, ...args: any[]) {
|
|
1723
1776
|
const vary = varyFields(res.raw.getHeader("Vary"));
|
|
1724
1777
|
if (!captured && allowedCodes.has(res.raw.statusCode)
|
|
1725
|
-
&& mayStore(req as any, vary, res.raw.getHeader("Cache-Control"))) {
|
|
1778
|
+
&& mayStore(req as any, vary, res.raw.getHeader("Cache-Control"), res.raw.getHeader("Set-Cookie"))) {
|
|
1726
1779
|
captured = true;
|
|
1727
1780
|
const body = typeof chunk === "string" ? chunk : chunk?.toString() ?? "";
|
|
1728
1781
|
const contentType = String(res.raw.getHeader("Content-Type") ?? "application/octet-stream");
|
|
@@ -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 {
|
|
@@ -6909,11 +6909,32 @@ function varyFields(raw) {
|
|
|
6909
6909
|
const text = Array.isArray(raw) ? raw.join(",") : String(raw);
|
|
6910
6910
|
return text.split(",").map((f) => f.trim().toLowerCase()).filter((f) => f !== "");
|
|
6911
6911
|
}
|
|
6912
|
-
function
|
|
6912
|
+
function cacheControlTokens(raw) {
|
|
6913
|
+
const text = Array.isArray(raw) ? raw.join(",") : String(raw ?? "");
|
|
6914
|
+
const tokens = /* @__PURE__ */ new Set();
|
|
6915
|
+
for (const token of text.split(",")) {
|
|
6916
|
+
const name = token.split("=")[0].trim().toLowerCase();
|
|
6917
|
+
if (name !== "") tokens.add(name);
|
|
6918
|
+
}
|
|
6919
|
+
return tokens;
|
|
6920
|
+
}
|
|
6921
|
+
function sharedCacheAllowed(cacheControl) {
|
|
6922
|
+
const directives = cacheControlTokens(cacheControl);
|
|
6923
|
+
return SHARED_CACHE_DIRECTIVES.some((directive) => directives.has(directive));
|
|
6924
|
+
}
|
|
6925
|
+
function hasSetCookie(raw) {
|
|
6926
|
+
if (raw === void 0 || raw === null) return false;
|
|
6927
|
+
if (Array.isArray(raw)) return raw.length > 0;
|
|
6928
|
+
return String(raw) !== "";
|
|
6929
|
+
}
|
|
6930
|
+
function mayStore(req2, vary, cacheControl, setCookie) {
|
|
6913
6931
|
if (vary.includes("*")) return false;
|
|
6914
|
-
|
|
6915
|
-
|
|
6916
|
-
|
|
6932
|
+
const directives = cacheControlTokens(cacheControl);
|
|
6933
|
+
if (NO_STORE_DIRECTIVES.some((directive) => directives.has(directive))) return false;
|
|
6934
|
+
if (requestHeader(req2, "authorization") !== void 0) return sharedCacheAllowed(cacheControl);
|
|
6935
|
+
if (requestHeader(req2, "cookie") !== void 0) return sharedCacheAllowed(cacheControl);
|
|
6936
|
+
if (hasSetCookie(setCookie)) return sharedCacheAllowed(cacheControl);
|
|
6937
|
+
return true;
|
|
6917
6938
|
}
|
|
6918
6939
|
function varyMatches(entry, req2) {
|
|
6919
6940
|
const vary = entry.vary ?? [];
|
|
@@ -6946,7 +6967,7 @@ function responseCache(config) {
|
|
|
6946
6967
|
let captured = false;
|
|
6947
6968
|
res.raw.end = function(chunk, ...args) {
|
|
6948
6969
|
const vary = varyFields(res.raw.getHeader("Vary"));
|
|
6949
|
-
if (!captured && allowedCodes.has(res.raw.statusCode) && mayStore(req2, vary, res.raw.getHeader("Cache-Control"))) {
|
|
6970
|
+
if (!captured && allowedCodes.has(res.raw.statusCode) && mayStore(req2, vary, res.raw.getHeader("Cache-Control"), res.raw.getHeader("Set-Cookie"))) {
|
|
6950
6971
|
captured = true;
|
|
6951
6972
|
const body = typeof chunk === "string" ? chunk : chunk?.toString() ?? "";
|
|
6952
6973
|
const contentType = String(res.raw.getHeader("Content-Type") ?? "application/octet-stream");
|
|
@@ -7033,7 +7054,7 @@ function _resetBackend() {
|
|
|
7033
7054
|
_explicitResponseBackends.clear();
|
|
7034
7055
|
_defaultTtl = null;
|
|
7035
7056
|
}
|
|
7036
|
-
var MemoryBackend, RespClient, RedisBackend, ValkeyBackend, FileBackend, MemcachedClient, MemcachedBackend, MongoBackend, DatabaseBackend, _responseBackend, _responseBackendPromise, _explicitResponseBackends, SHARED_CACHE_DIRECTIVES, _defaultBackend, _defaultBackendPromise, _defaultTtl;
|
|
7057
|
+
var MemoryBackend, RespClient, RedisBackend, ValkeyBackend, FileBackend, MemcachedClient, MemcachedBackend, MongoBackend, DatabaseBackend, _responseBackend, _responseBackendPromise, _explicitResponseBackends, SHARED_CACHE_DIRECTIVES, NO_STORE_DIRECTIVES, _defaultBackend, _defaultBackendPromise, _defaultTtl;
|
|
7037
7058
|
var init_cache = __esm({
|
|
7038
7059
|
"../core/src/cache.ts"() {
|
|
7039
7060
|
"use strict";
|
|
@@ -8199,6 +8220,7 @@ ${data}\r
|
|
|
8199
8220
|
_responseBackendPromise = null;
|
|
8200
8221
|
_explicitResponseBackends = /* @__PURE__ */ new Map();
|
|
8201
8222
|
SHARED_CACHE_DIRECTIVES = ["public", "s-maxage", "must-revalidate"];
|
|
8223
|
+
NO_STORE_DIRECTIVES = ["no-store", "private", "no-cache"];
|
|
8202
8224
|
_defaultBackend = null;
|
|
8203
8225
|
_defaultBackendPromise = null;
|
|
8204
8226
|
_defaultTtl = null;
|
|
@@ -8337,6 +8359,31 @@ var init_router = __esm({
|
|
|
8337
8359
|
this.route.noAuth = true;
|
|
8338
8360
|
return this;
|
|
8339
8361
|
}
|
|
8362
|
+
/**
|
|
8363
|
+
* RBAC: require ONE of the named roles (OR). Reads the verified JWT `roles`
|
|
8364
|
+
* claim. Chain .role()/.can() for AND. Implies auth. Feature 138 / ADR-0058.
|
|
8365
|
+
*/
|
|
8366
|
+
role(...names) {
|
|
8367
|
+
const clean = names.filter((n) => n !== "");
|
|
8368
|
+
if (clean.length > 0) {
|
|
8369
|
+
(this.route.requiredRoles ??= []).push(clean);
|
|
8370
|
+
this.route.secure = true;
|
|
8371
|
+
}
|
|
8372
|
+
return this;
|
|
8373
|
+
}
|
|
8374
|
+
/**
|
|
8375
|
+
* RBAC: require ONE of the named permissions (OR). Reads the verified JWT
|
|
8376
|
+
* `permissions` claim; granted-side wildcards (`posts.*`, `*`) satisfy a
|
|
8377
|
+
* concrete requirement. Chain for AND. Implies auth. Feature 138.
|
|
8378
|
+
*/
|
|
8379
|
+
can(...permissions) {
|
|
8380
|
+
const clean = permissions.filter((p) => p !== "");
|
|
8381
|
+
if (clean.length > 0) {
|
|
8382
|
+
(this.route.requiredPerms ??= []).push(clean);
|
|
8383
|
+
this.route.secure = true;
|
|
8384
|
+
}
|
|
8385
|
+
return this;
|
|
8386
|
+
}
|
|
8340
8387
|
/** Mark this route's response as cacheable. */
|
|
8341
8388
|
cache() {
|
|
8342
8389
|
this.route.cached = true;
|
|
@@ -8404,7 +8451,9 @@ var init_router = __esm({
|
|
|
8404
8451
|
secure: secureDefault,
|
|
8405
8452
|
cached: definition.cached,
|
|
8406
8453
|
noAuth: definition.noAuth,
|
|
8407
|
-
template: definition.template
|
|
8454
|
+
template: definition.template,
|
|
8455
|
+
requiredRoles: definition.requiredRoles,
|
|
8456
|
+
requiredPerms: definition.requiredPerms
|
|
8408
8457
|
};
|
|
8409
8458
|
routes.push(compiled);
|
|
8410
8459
|
return new RouteRef(compiled);
|
|
@@ -8551,7 +8600,9 @@ var init_router = __esm({
|
|
|
8551
8600
|
template: route.template,
|
|
8552
8601
|
secure: route.secure,
|
|
8553
8602
|
cached: route.cached,
|
|
8554
|
-
noAuth: route.noAuth
|
|
8603
|
+
noAuth: route.noAuth,
|
|
8604
|
+
requiredRoles: route.requiredRoles,
|
|
8605
|
+
requiredPerms: route.requiredPerms
|
|
8555
8606
|
};
|
|
8556
8607
|
}
|
|
8557
8608
|
}
|
|
@@ -8574,7 +8625,9 @@ var init_router = __esm({
|
|
|
8574
8625
|
template: route.template,
|
|
8575
8626
|
secure: route.secure,
|
|
8576
8627
|
cached: route.cached,
|
|
8577
|
-
noAuth: route.noAuth
|
|
8628
|
+
noAuth: route.noAuth,
|
|
8629
|
+
requiredRoles: route.requiredRoles,
|
|
8630
|
+
requiredPerms: route.requiredPerms
|
|
8578
8631
|
});
|
|
8579
8632
|
}
|
|
8580
8633
|
}
|
|
@@ -8936,7 +8989,7 @@ function enforceRouteAuth(req2, res, match, isDevAdmin) {
|
|
|
8936
8989
|
const identity = sso?.identity;
|
|
8937
8990
|
if (identity?.issuer && identity?.subject) {
|
|
8938
8991
|
req2.user = identity;
|
|
8939
|
-
return
|
|
8992
|
+
return rbacForbidden(match, identity, res);
|
|
8940
8993
|
}
|
|
8941
8994
|
const sessionToken = req2.session?.get?.("token");
|
|
8942
8995
|
if (sessionToken && validToken(sessionToken)) {
|
|
@@ -8956,8 +9009,45 @@ function enforceRouteAuth(req2, res, match, isDevAdmin) {
|
|
|
8956
9009
|
res.header("FreshToken", fresh);
|
|
8957
9010
|
}
|
|
8958
9011
|
}
|
|
9012
|
+
return rbacForbidden(match, req2.user, res);
|
|
9013
|
+
}
|
|
9014
|
+
function rbacClaimList(subject, key, legacy) {
|
|
9015
|
+
const coerce = (v) => {
|
|
9016
|
+
if (typeof v === "string") return v === "" ? [] : [v];
|
|
9017
|
+
if (Array.isArray(v)) return v.map((x) => String(x)).filter((x) => x !== "");
|
|
9018
|
+
return [];
|
|
9019
|
+
};
|
|
9020
|
+
let out = coerce(subject[key]);
|
|
9021
|
+
if (out.length === 0 && legacy) out = coerce(subject[legacy]);
|
|
9022
|
+
return out;
|
|
9023
|
+
}
|
|
9024
|
+
function rbacPermGranted(granted, required) {
|
|
9025
|
+
return granted.some(
|
|
9026
|
+
(g) => g === "*" || g === required || g.endsWith(".*") && required.startsWith(g.slice(0, -1))
|
|
9027
|
+
);
|
|
9028
|
+
}
|
|
9029
|
+
function rbacForbidden(match, payload, res) {
|
|
9030
|
+
const requiredRoles = match.requiredRoles ?? [];
|
|
9031
|
+
const requiredPerms = match.requiredPerms ?? [];
|
|
9032
|
+
if (requiredRoles.length === 0 && requiredPerms.length === 0) {
|
|
9033
|
+
return false;
|
|
9034
|
+
}
|
|
9035
|
+
const subject = payload && typeof payload === "object" ? payload : {};
|
|
9036
|
+
const roles = rbacClaimList(subject, "roles", "role");
|
|
9037
|
+
for (const group of requiredRoles) {
|
|
9038
|
+
if (!group.some((r) => roles.includes(r))) return writeForbidden(res);
|
|
9039
|
+
}
|
|
9040
|
+
const perms = rbacClaimList(subject, "permissions");
|
|
9041
|
+
for (const group of requiredPerms) {
|
|
9042
|
+
if (!group.some((p) => rbacPermGranted(perms, p))) return writeForbidden(res);
|
|
9043
|
+
}
|
|
8959
9044
|
return false;
|
|
8960
9045
|
}
|
|
9046
|
+
function writeForbidden(res) {
|
|
9047
|
+
res.raw.writeHead(403, { "Content-Type": "application/json" });
|
|
9048
|
+
res.raw.end(JSON.stringify({ error: "Forbidden" }));
|
|
9049
|
+
return true;
|
|
9050
|
+
}
|
|
8961
9051
|
var init_authGate = __esm({
|
|
8962
9052
|
"../core/src/authGate.ts"() {
|
|
8963
9053
|
"use strict";
|
|
@@ -25034,6 +25124,9 @@ function asHtmlString(chunk) {
|
|
|
25034
25124
|
if (Buffer.isBuffer(chunk)) return chunk.toString("utf-8");
|
|
25035
25125
|
return null;
|
|
25036
25126
|
}
|
|
25127
|
+
function isInjectableHtml(res) {
|
|
25128
|
+
return isHtmlResponse(res) && !res.raw.getHeader("content-encoding");
|
|
25129
|
+
}
|
|
25037
25130
|
function injectIntoHtml(ctx, devToolbar, html) {
|
|
25038
25131
|
if (!devToolbar) return injectFeedbackWidget(ctx.req, html);
|
|
25039
25132
|
const toolbarCtx = {
|
|
@@ -25061,7 +25154,7 @@ function wrapResponseEnd(ctx) {
|
|
|
25061
25154
|
Date.now() - ctx.reqStartTime
|
|
25062
25155
|
);
|
|
25063
25156
|
}
|
|
25064
|
-
if (
|
|
25157
|
+
if (isInjectableHtml(res)) {
|
|
25065
25158
|
const html = asHtmlString(chunk);
|
|
25066
25159
|
if (html !== null) chunk = injectIntoHtml(ctx, devToolbar, html);
|
|
25067
25160
|
if (!res.raw.headersSent) res.raw.removeHeader("content-length");
|
|
@@ -3,6 +3,9 @@ import type { Tina4Request, Tina4Response } from "./types.js";
|
|
|
3
3
|
export interface AuthGateRoute {
|
|
4
4
|
secure?: boolean;
|
|
5
5
|
noAuth?: boolean;
|
|
6
|
+
/** RBAC guard groups (Feature 138): OR within a group, AND across groups. */
|
|
7
|
+
requiredRoles?: string[][];
|
|
8
|
+
requiredPerms?: string[][];
|
|
6
9
|
}
|
|
7
10
|
/**
|
|
8
11
|
* Enforce auth for a matched route.
|
|
@@ -17,6 +17,8 @@ interface MatchResult {
|
|
|
17
17
|
secure?: boolean;
|
|
18
18
|
cached?: boolean;
|
|
19
19
|
noAuth?: boolean;
|
|
20
|
+
requiredRoles?: string[][];
|
|
21
|
+
requiredPerms?: string[][];
|
|
20
22
|
}
|
|
21
23
|
interface CompiledRoute {
|
|
22
24
|
pattern: string;
|
|
@@ -36,6 +38,9 @@ interface CompiledRoute {
|
|
|
36
38
|
}>;
|
|
37
39
|
cacheTtl?: number;
|
|
38
40
|
template?: string;
|
|
41
|
+
/** RBAC guard groups (Feature 138): OR within a group, AND across groups. */
|
|
42
|
+
requiredRoles?: string[][];
|
|
43
|
+
requiredPerms?: string[][];
|
|
39
44
|
}
|
|
40
45
|
/**
|
|
41
46
|
* Thin reference to a registered WebSocket route, enabling chained modifiers
|
|
@@ -63,6 +68,17 @@ export declare class RouteRef {
|
|
|
63
68
|
secure(): this;
|
|
64
69
|
/** Opt out of secure-by-default auth (for public write routes). */
|
|
65
70
|
noAuth(): this;
|
|
71
|
+
/**
|
|
72
|
+
* RBAC: require ONE of the named roles (OR). Reads the verified JWT `roles`
|
|
73
|
+
* claim. Chain .role()/.can() for AND. Implies auth. Feature 138 / ADR-0058.
|
|
74
|
+
*/
|
|
75
|
+
role(...names: string[]): this;
|
|
76
|
+
/**
|
|
77
|
+
* RBAC: require ONE of the named permissions (OR). Reads the verified JWT
|
|
78
|
+
* `permissions` claim; granted-side wildcards (`posts.*`, `*`) satisfy a
|
|
79
|
+
* concrete requirement. Chain for AND. Implies auth. Feature 138.
|
|
80
|
+
*/
|
|
81
|
+
can(...permissions: string[]): this;
|
|
66
82
|
/** Mark this route's response as cacheable. */
|
|
67
83
|
cache(): this;
|
|
68
84
|
/**
|
|
@@ -146,6 +146,10 @@ export interface RouteDefinition {
|
|
|
146
146
|
cached?: boolean;
|
|
147
147
|
/** Opt out of secure-by-default auth on write routes */
|
|
148
148
|
noAuth?: boolean;
|
|
149
|
+
/** RBAC role guard groups (Feature 138): OR within a group, AND across groups */
|
|
150
|
+
requiredRoles?: string[][];
|
|
151
|
+
/** RBAC permission guard groups (Feature 138) */
|
|
152
|
+
requiredPerms?: string[][];
|
|
149
153
|
}
|
|
150
154
|
export interface RouteMeta {
|
|
151
155
|
summary?: string;
|