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 CHANGED
@@ -1,10 +1,10 @@
1
- # CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.105)
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.105 - The Intelligent Native Application 4ramework. A convention-over-configuration structural paradigm. The developer writes TypeScript; Tina4 is invisible infrastructure.
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">98 built-in features. Zero dependencies. One import, everything works.</p>
6
+ <p align="center">Zero dependencies. One import, everything works.</p>
7
7
  <p align="center">
8
- <a href="https://www.npmjs.com/package/@tina4/core"><img src="https://img.shields.io/npm/v/@tina4/core?color=7b1fa2&label=npm" alt="npm"></a>
9
- <img src="https://img.shields.io/badge/tests-2%2C897%20passing-brightgreen" alt="Tests">
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tina4-nodejs",
3
- "version": "3.13.105",
3
+ "version": "3.13.109",
4
4
  "type": "module",
5
5
  "description": "Tina4 for Node.js/TypeScript - native TypeScript conventions and shared Tina4 contracts",
6
6
  "keywords": [
@@ -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 mayStore(req2, vary, cacheControl) {
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
- if (requestHeader(req2, "authorization") === void 0) return true;
17773
- const cc = String(cacheControl ?? "").toLowerCase();
17774
- return SHARED_CACHE_DIRECTIVES.some((directive) => cc.includes(directive));
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 false;
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 (isHtmlResponse(res)) {
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 mayStore(req2, vary, cacheControl) {
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
- if (requestHeader(req2, "authorization") === void 0) return true;
17772
- const cc = String(cacheControl ?? "").toLowerCase();
17773
- return SHARED_CACHE_DIRECTIVES.some((directive) => cc.includes(directive));
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 false;
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 (isHtmlResponse(res)) {
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 xe(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 Me={register(e){let n=xe(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 lt(e)},generateManifest(e){return xe(e)}};var ct={reconnect:!0,reconnectDelay:1e3,reconnectMaxDelay:3e4,reconnectAttempts:1/0,protocols:[],token:""};function ut(e){let n=Array.isArray(e.protocols)?e.protocols:e.protocols?[e.protocols]:[];return e.token?["bearer",e.token,...n]:e.protocols}function dt(e,n={}){let t={...ct,...n},r=k("connecting"),o=k(!1),s=k(null),i=k(null),l=k(0),d={message:[],open:[],close:[],error:[]},f=null,h=!1,w=t.reconnectDelay,u=null,a=0;function c(m){if(typeof m!="string")return m;try{return JSON.parse(m)}catch{return m}}function g(){r.value=a>0?"reconnecting":"connecting";try{f=new WebSocket(e,ut(t))}catch{r.value="closed",o.value=!1;return}f.onopen=()=>{r.value="open",o.value=!0,i.value=null,a=0,w=t.reconnectDelay,l.value=0;for(let m of d.open)m()},f.onmessage=m=>{let T=c(m.data);s.value=T;for(let _ of d.message)_(T)},f.onclose=m=>{r.value="closed",o.value=!1;for(let T of d.close)T(m.code,m.reason);!h&&t.reconnect&&a<t.reconnectAttempts&&y()},f.onerror=m=>{i.value=m;for(let T of d.error)T(m)}}function y(){a++,l.value=a,r.value="reconnecting",u=setTimeout(()=>{u=null,g()},w),w=Math.min(w*2,t.reconnectMaxDelay)}let C={status:r,connected:o,lastMessage:s,error:i,reconnectCount:l,send(m){if(!f||f.readyState!==WebSocket.OPEN)throw new Error("[tina4] WebSocket is not connected");let T=typeof m=="string"?m:JSON.stringify(m);f.send(T)},on(m,T){return d[m].push(T),()=>{let _=d[m],A=_.indexOf(T);A>=0&&_.splice(A,1)}},pipe(m,T){let _=A=>{m.value=T(A,m.value)};return C.on("message",_)},close(m,T){h=!0,u&&(clearTimeout(u),u=null),f&&f.close(m??1e3,T??""),r.value="closed",o.value=!1}};return g(),C}var W={connect:dt};var ft={mode:"eventsource",method:"GET",headers:{},body:void 0,reconnect:!0,reconnectDelay:1e3,reconnectMaxDelay:3e4,reconnectAttempts:1/0,events:[],json:!0};function gt(e,n={}){let t={...ft,...n},r=k("connecting"),o=k(!1),s=k(null),i=k(null),l=k(null),d=k(0),f={message:[],open:[],close:[],error:[]},h=null,w=null,u=!1,a=t.reconnectDelay,c=null,g=0;function y(p){if(!t.json||typeof p!="string")return p;try{return JSON.parse(p)}catch{return p}}function C(p,b){s.value=p,i.value=b;for(let E of f.message)E(p,b??void 0)}function m(){r.value="open",o.value=!0,l.value=null,g=0,a=t.reconnectDelay,d.value=0;for(let p of f.open)p()}function T(){r.value="closed",o.value=!1;for(let p of f.close)p();!u&&t.reconnect&&g<t.reconnectAttempts&&Q()}function _(p){l.value=p;for(let b of f.error)b(p)}function A(){r.value=g>0?"reconnecting":"connecting";try{h=new EventSource(e)}catch{r.value="closed",o.value=!1;return}h.onopen=()=>m(),h.onmessage=p=>{C(y(p.data),null)};for(let p of t.events)h.addEventListener(p,b=>{C(y(b.data),p)});h.onerror=p=>{_(p),h&&h.readyState===2&&(h=null,T())}}function K(){r.value=g>0?"reconnecting":"connecting",w=new AbortController;let p={method:t.method,headers:t.headers,signal:w.signal};t.body!==void 0&&(p.body=typeof t.body=="string"?t.body:JSON.stringify(t.body)),fetch(e,p).then(async b=>{if(!b.ok){_(new Error(`[tina4] SSE fetch ${b.status}`)),T();return}m();let E=b.body.getReader(),M=new TextDecoder,N="";for(;;){let{done:Ke,value:ze}=await E.read();if(Ke)break;N+=M.decode(ze,{stream:!0});let ue=N.split(`
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
- return false;
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
- * May a SHARED cache store this response? (RFC 9111 s3, s4.1)
1661
+ * The lower-cased Cache-Control directive NAMES on a header value, as a set.
1654
1662
  *
1655
- * s3 "if the cache is shared: the Authorization header field is not present
1656
- * in the request ... or a response directive is present that explicitly allows
1657
- * shared caching". The key is method + URL only, and on Node the cache answers
1658
- * BEFORE the auth gate, so without this an anonymous caller was served an
1659
- * authenticated caller's body with a 200.
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(req: { headers?: Record<string, unknown> }, vary: string[], cacheControl: unknown): boolean {
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
- if (requestHeader(req, "authorization") === undefined) return true;
1667
- const cc = String(cacheControl ?? "").toLowerCase();
1668
- return SHARED_CACHE_DIRECTIVES.some((directive) => cc.includes(directive));
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
- if (isHtmlResponse(res)) {
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 mayStore(req2, vary, cacheControl) {
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
- if (requestHeader(req2, "authorization") === void 0) return true;
6915
- const cc = String(cacheControl ?? "").toLowerCase();
6916
- return SHARED_CACHE_DIRECTIVES.some((directive) => cc.includes(directive));
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 false;
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 (isHtmlResponse(res)) {
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;