tina4-nodejs 3.13.105 → 3.13.108

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md 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.108)
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.108 - 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.108",
4
4
  "type": "module",
5
5
  "description": "Tina4 for Node.js/TypeScript - native TypeScript conventions and shared Tina4 contracts",
6
6
  "keywords": [
@@ -19195,6 +19195,31 @@ var init_router = __esm({
19195
19195
  this.route.noAuth = true;
19196
19196
  return this;
19197
19197
  }
19198
+ /**
19199
+ * RBAC: require ONE of the named roles (OR). Reads the verified JWT `roles`
19200
+ * claim. Chain .role()/.can() for AND. Implies auth. Feature 138 / ADR-0058.
19201
+ */
19202
+ role(...names) {
19203
+ const clean = names.filter((n) => n !== "");
19204
+ if (clean.length > 0) {
19205
+ (this.route.requiredRoles ??= []).push(clean);
19206
+ this.route.secure = true;
19207
+ }
19208
+ return this;
19209
+ }
19210
+ /**
19211
+ * RBAC: require ONE of the named permissions (OR). Reads the verified JWT
19212
+ * `permissions` claim; granted-side wildcards (`posts.*`, `*`) satisfy a
19213
+ * concrete requirement. Chain for AND. Implies auth. Feature 138.
19214
+ */
19215
+ can(...permissions) {
19216
+ const clean = permissions.filter((p) => p !== "");
19217
+ if (clean.length > 0) {
19218
+ (this.route.requiredPerms ??= []).push(clean);
19219
+ this.route.secure = true;
19220
+ }
19221
+ return this;
19222
+ }
19198
19223
  /** Mark this route's response as cacheable. */
19199
19224
  cache() {
19200
19225
  this.route.cached = true;
@@ -19262,7 +19287,9 @@ var init_router = __esm({
19262
19287
  secure: secureDefault,
19263
19288
  cached: definition.cached,
19264
19289
  noAuth: definition.noAuth,
19265
- template: definition.template
19290
+ template: definition.template,
19291
+ requiredRoles: definition.requiredRoles,
19292
+ requiredPerms: definition.requiredPerms
19266
19293
  };
19267
19294
  routes.push(compiled);
19268
19295
  return new RouteRef(compiled);
@@ -19409,7 +19436,9 @@ var init_router = __esm({
19409
19436
  template: route.template,
19410
19437
  secure: route.secure,
19411
19438
  cached: route.cached,
19412
- noAuth: route.noAuth
19439
+ noAuth: route.noAuth,
19440
+ requiredRoles: route.requiredRoles,
19441
+ requiredPerms: route.requiredPerms
19413
19442
  };
19414
19443
  }
19415
19444
  }
@@ -19432,7 +19461,9 @@ var init_router = __esm({
19432
19461
  template: route.template,
19433
19462
  secure: route.secure,
19434
19463
  cached: route.cached,
19435
- noAuth: route.noAuth
19464
+ noAuth: route.noAuth,
19465
+ requiredRoles: route.requiredRoles,
19466
+ requiredPerms: route.requiredPerms
19436
19467
  });
19437
19468
  }
19438
19469
  }
@@ -19794,7 +19825,7 @@ function enforceRouteAuth(req2, res, match, isDevAdmin) {
19794
19825
  const identity = sso?.identity;
19795
19826
  if (identity?.issuer && identity?.subject) {
19796
19827
  req2.user = identity;
19797
- return false;
19828
+ return rbacForbidden(match, identity, res);
19798
19829
  }
19799
19830
  const sessionToken = req2.session?.get?.("token");
19800
19831
  if (sessionToken && validToken(sessionToken)) {
@@ -19814,8 +19845,45 @@ function enforceRouteAuth(req2, res, match, isDevAdmin) {
19814
19845
  res.header("FreshToken", fresh);
19815
19846
  }
19816
19847
  }
19848
+ return rbacForbidden(match, req2.user, res);
19849
+ }
19850
+ function rbacClaimList(subject, key, legacy) {
19851
+ const coerce = (v) => {
19852
+ if (typeof v === "string") return v === "" ? [] : [v];
19853
+ if (Array.isArray(v)) return v.map((x) => String(x)).filter((x) => x !== "");
19854
+ return [];
19855
+ };
19856
+ let out = coerce(subject[key]);
19857
+ if (out.length === 0 && legacy) out = coerce(subject[legacy]);
19858
+ return out;
19859
+ }
19860
+ function rbacPermGranted(granted, required) {
19861
+ return granted.some(
19862
+ (g) => g === "*" || g === required || g.endsWith(".*") && required.startsWith(g.slice(0, -1))
19863
+ );
19864
+ }
19865
+ function rbacForbidden(match, payload, res) {
19866
+ const requiredRoles = match.requiredRoles ?? [];
19867
+ const requiredPerms = match.requiredPerms ?? [];
19868
+ if (requiredRoles.length === 0 && requiredPerms.length === 0) {
19869
+ return false;
19870
+ }
19871
+ const subject = payload && typeof payload === "object" ? payload : {};
19872
+ const roles = rbacClaimList(subject, "roles", "role");
19873
+ for (const group of requiredRoles) {
19874
+ if (!group.some((r) => roles.includes(r))) return writeForbidden(res);
19875
+ }
19876
+ const perms = rbacClaimList(subject, "permissions");
19877
+ for (const group of requiredPerms) {
19878
+ if (!group.some((p) => rbacPermGranted(perms, p))) return writeForbidden(res);
19879
+ }
19817
19880
  return false;
19818
19881
  }
19882
+ function writeForbidden(res) {
19883
+ res.raw.writeHead(403, { "Content-Type": "application/json" });
19884
+ res.raw.end(JSON.stringify({ error: "Forbidden" }));
19885
+ return true;
19886
+ }
19819
19887
  var init_authGate = __esm({
19820
19888
  "../core/src/authGate.ts"() {
19821
19889
  "use strict";
@@ -35912,6 +35980,9 @@ function asHtmlString(chunk) {
35912
35980
  if (Buffer.isBuffer(chunk)) return chunk.toString("utf-8");
35913
35981
  return null;
35914
35982
  }
35983
+ function isInjectableHtml(res) {
35984
+ return isHtmlResponse(res) && !res.raw.getHeader("content-encoding");
35985
+ }
35915
35986
  function injectIntoHtml(ctx, devToolbar, html) {
35916
35987
  if (!devToolbar) return injectFeedbackWidget(ctx.req, html);
35917
35988
  const toolbarCtx = {
@@ -35939,7 +36010,7 @@ function wrapResponseEnd(ctx) {
35939
36010
  Date.now() - ctx.reqStartTime
35940
36011
  );
35941
36012
  }
35942
- if (isHtmlResponse(res)) {
36013
+ if (isInjectableHtml(res)) {
35943
36014
  const html = asHtmlString(chunk);
35944
36015
  if (html !== null) chunk = injectIntoHtml(ctx, devToolbar, html);
35945
36016
  if (!res.raw.headersSent) res.raw.removeHeader("content-length");
@@ -19194,6 +19194,31 @@ var init_router = __esm({
19194
19194
  this.route.noAuth = true;
19195
19195
  return this;
19196
19196
  }
19197
+ /**
19198
+ * RBAC: require ONE of the named roles (OR). Reads the verified JWT `roles`
19199
+ * claim. Chain .role()/.can() for AND. Implies auth. Feature 138 / ADR-0058.
19200
+ */
19201
+ role(...names) {
19202
+ const clean = names.filter((n) => n !== "");
19203
+ if (clean.length > 0) {
19204
+ (this.route.requiredRoles ??= []).push(clean);
19205
+ this.route.secure = true;
19206
+ }
19207
+ return this;
19208
+ }
19209
+ /**
19210
+ * RBAC: require ONE of the named permissions (OR). Reads the verified JWT
19211
+ * `permissions` claim; granted-side wildcards (`posts.*`, `*`) satisfy a
19212
+ * concrete requirement. Chain for AND. Implies auth. Feature 138.
19213
+ */
19214
+ can(...permissions) {
19215
+ const clean = permissions.filter((p) => p !== "");
19216
+ if (clean.length > 0) {
19217
+ (this.route.requiredPerms ??= []).push(clean);
19218
+ this.route.secure = true;
19219
+ }
19220
+ return this;
19221
+ }
19197
19222
  /** Mark this route's response as cacheable. */
19198
19223
  cache() {
19199
19224
  this.route.cached = true;
@@ -19261,7 +19286,9 @@ var init_router = __esm({
19261
19286
  secure: secureDefault,
19262
19287
  cached: definition.cached,
19263
19288
  noAuth: definition.noAuth,
19264
- template: definition.template
19289
+ template: definition.template,
19290
+ requiredRoles: definition.requiredRoles,
19291
+ requiredPerms: definition.requiredPerms
19265
19292
  };
19266
19293
  routes.push(compiled);
19267
19294
  return new RouteRef(compiled);
@@ -19408,7 +19435,9 @@ var init_router = __esm({
19408
19435
  template: route.template,
19409
19436
  secure: route.secure,
19410
19437
  cached: route.cached,
19411
- noAuth: route.noAuth
19438
+ noAuth: route.noAuth,
19439
+ requiredRoles: route.requiredRoles,
19440
+ requiredPerms: route.requiredPerms
19412
19441
  };
19413
19442
  }
19414
19443
  }
@@ -19431,7 +19460,9 @@ var init_router = __esm({
19431
19460
  template: route.template,
19432
19461
  secure: route.secure,
19433
19462
  cached: route.cached,
19434
- noAuth: route.noAuth
19463
+ noAuth: route.noAuth,
19464
+ requiredRoles: route.requiredRoles,
19465
+ requiredPerms: route.requiredPerms
19435
19466
  });
19436
19467
  }
19437
19468
  }
@@ -19793,7 +19824,7 @@ function enforceRouteAuth(req2, res, match, isDevAdmin) {
19793
19824
  const identity = sso?.identity;
19794
19825
  if (identity?.issuer && identity?.subject) {
19795
19826
  req2.user = identity;
19796
- return false;
19827
+ return rbacForbidden(match, identity, res);
19797
19828
  }
19798
19829
  const sessionToken = req2.session?.get?.("token");
19799
19830
  if (sessionToken && validToken(sessionToken)) {
@@ -19813,8 +19844,45 @@ function enforceRouteAuth(req2, res, match, isDevAdmin) {
19813
19844
  res.header("FreshToken", fresh);
19814
19845
  }
19815
19846
  }
19847
+ return rbacForbidden(match, req2.user, res);
19848
+ }
19849
+ function rbacClaimList(subject, key, legacy) {
19850
+ const coerce = (v) => {
19851
+ if (typeof v === "string") return v === "" ? [] : [v];
19852
+ if (Array.isArray(v)) return v.map((x) => String(x)).filter((x) => x !== "");
19853
+ return [];
19854
+ };
19855
+ let out = coerce(subject[key]);
19856
+ if (out.length === 0 && legacy) out = coerce(subject[legacy]);
19857
+ return out;
19858
+ }
19859
+ function rbacPermGranted(granted, required) {
19860
+ return granted.some(
19861
+ (g) => g === "*" || g === required || g.endsWith(".*") && required.startsWith(g.slice(0, -1))
19862
+ );
19863
+ }
19864
+ function rbacForbidden(match, payload, res) {
19865
+ const requiredRoles = match.requiredRoles ?? [];
19866
+ const requiredPerms = match.requiredPerms ?? [];
19867
+ if (requiredRoles.length === 0 && requiredPerms.length === 0) {
19868
+ return false;
19869
+ }
19870
+ const subject = payload && typeof payload === "object" ? payload : {};
19871
+ const roles = rbacClaimList(subject, "roles", "role");
19872
+ for (const group of requiredRoles) {
19873
+ if (!group.some((r) => roles.includes(r))) return writeForbidden(res);
19874
+ }
19875
+ const perms = rbacClaimList(subject, "permissions");
19876
+ for (const group of requiredPerms) {
19877
+ if (!group.some((p) => rbacPermGranted(perms, p))) return writeForbidden(res);
19878
+ }
19816
19879
  return false;
19817
19880
  }
19881
+ function writeForbidden(res) {
19882
+ res.raw.writeHead(403, { "Content-Type": "application/json" });
19883
+ res.raw.end(JSON.stringify({ error: "Forbidden" }));
19884
+ return true;
19885
+ }
19818
19886
  var init_authGate = __esm({
19819
19887
  "src/authGate.ts"() {
19820
19888
  "use strict";
@@ -35891,6 +35959,9 @@ function asHtmlString(chunk) {
35891
35959
  if (Buffer.isBuffer(chunk)) return chunk.toString("utf-8");
35892
35960
  return null;
35893
35961
  }
35962
+ function isInjectableHtml(res) {
35963
+ return isHtmlResponse(res) && !res.raw.getHeader("content-encoding");
35964
+ }
35894
35965
  function injectIntoHtml(ctx, devToolbar, html) {
35895
35966
  if (!devToolbar) return injectFeedbackWidget(ctx.req, html);
35896
35967
  const toolbarCtx = {
@@ -35918,7 +35989,7 @@ function wrapResponseEnd(ctx) {
35918
35989
  Date.now() - ctx.reqStartTime
35919
35990
  );
35920
35991
  }
35921
- if (isHtmlResponse(res)) {
35992
+ if (isInjectableHtml(res)) {
35922
35993
  const html = asHtmlString(chunk);
35923
35994
  if (html !== null) chunk = injectIntoHtml(ctx, devToolbar, html);
35924
35995
  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
+ }
@@ -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 {
@@ -8337,6 +8337,31 @@ var init_router = __esm({
8337
8337
  this.route.noAuth = true;
8338
8338
  return this;
8339
8339
  }
8340
+ /**
8341
+ * RBAC: require ONE of the named roles (OR). Reads the verified JWT `roles`
8342
+ * claim. Chain .role()/.can() for AND. Implies auth. Feature 138 / ADR-0058.
8343
+ */
8344
+ role(...names) {
8345
+ const clean = names.filter((n) => n !== "");
8346
+ if (clean.length > 0) {
8347
+ (this.route.requiredRoles ??= []).push(clean);
8348
+ this.route.secure = true;
8349
+ }
8350
+ return this;
8351
+ }
8352
+ /**
8353
+ * RBAC: require ONE of the named permissions (OR). Reads the verified JWT
8354
+ * `permissions` claim; granted-side wildcards (`posts.*`, `*`) satisfy a
8355
+ * concrete requirement. Chain for AND. Implies auth. Feature 138.
8356
+ */
8357
+ can(...permissions) {
8358
+ const clean = permissions.filter((p) => p !== "");
8359
+ if (clean.length > 0) {
8360
+ (this.route.requiredPerms ??= []).push(clean);
8361
+ this.route.secure = true;
8362
+ }
8363
+ return this;
8364
+ }
8340
8365
  /** Mark this route's response as cacheable. */
8341
8366
  cache() {
8342
8367
  this.route.cached = true;
@@ -8404,7 +8429,9 @@ var init_router = __esm({
8404
8429
  secure: secureDefault,
8405
8430
  cached: definition.cached,
8406
8431
  noAuth: definition.noAuth,
8407
- template: definition.template
8432
+ template: definition.template,
8433
+ requiredRoles: definition.requiredRoles,
8434
+ requiredPerms: definition.requiredPerms
8408
8435
  };
8409
8436
  routes.push(compiled);
8410
8437
  return new RouteRef(compiled);
@@ -8551,7 +8578,9 @@ var init_router = __esm({
8551
8578
  template: route.template,
8552
8579
  secure: route.secure,
8553
8580
  cached: route.cached,
8554
- noAuth: route.noAuth
8581
+ noAuth: route.noAuth,
8582
+ requiredRoles: route.requiredRoles,
8583
+ requiredPerms: route.requiredPerms
8555
8584
  };
8556
8585
  }
8557
8586
  }
@@ -8574,7 +8603,9 @@ var init_router = __esm({
8574
8603
  template: route.template,
8575
8604
  secure: route.secure,
8576
8605
  cached: route.cached,
8577
- noAuth: route.noAuth
8606
+ noAuth: route.noAuth,
8607
+ requiredRoles: route.requiredRoles,
8608
+ requiredPerms: route.requiredPerms
8578
8609
  });
8579
8610
  }
8580
8611
  }
@@ -8936,7 +8967,7 @@ function enforceRouteAuth(req2, res, match, isDevAdmin) {
8936
8967
  const identity = sso?.identity;
8937
8968
  if (identity?.issuer && identity?.subject) {
8938
8969
  req2.user = identity;
8939
- return false;
8970
+ return rbacForbidden(match, identity, res);
8940
8971
  }
8941
8972
  const sessionToken = req2.session?.get?.("token");
8942
8973
  if (sessionToken && validToken(sessionToken)) {
@@ -8956,8 +8987,45 @@ function enforceRouteAuth(req2, res, match, isDevAdmin) {
8956
8987
  res.header("FreshToken", fresh);
8957
8988
  }
8958
8989
  }
8990
+ return rbacForbidden(match, req2.user, res);
8991
+ }
8992
+ function rbacClaimList(subject, key, legacy) {
8993
+ const coerce = (v) => {
8994
+ if (typeof v === "string") return v === "" ? [] : [v];
8995
+ if (Array.isArray(v)) return v.map((x) => String(x)).filter((x) => x !== "");
8996
+ return [];
8997
+ };
8998
+ let out = coerce(subject[key]);
8999
+ if (out.length === 0 && legacy) out = coerce(subject[legacy]);
9000
+ return out;
9001
+ }
9002
+ function rbacPermGranted(granted, required) {
9003
+ return granted.some(
9004
+ (g) => g === "*" || g === required || g.endsWith(".*") && required.startsWith(g.slice(0, -1))
9005
+ );
9006
+ }
9007
+ function rbacForbidden(match, payload, res) {
9008
+ const requiredRoles = match.requiredRoles ?? [];
9009
+ const requiredPerms = match.requiredPerms ?? [];
9010
+ if (requiredRoles.length === 0 && requiredPerms.length === 0) {
9011
+ return false;
9012
+ }
9013
+ const subject = payload && typeof payload === "object" ? payload : {};
9014
+ const roles = rbacClaimList(subject, "roles", "role");
9015
+ for (const group of requiredRoles) {
9016
+ if (!group.some((r) => roles.includes(r))) return writeForbidden(res);
9017
+ }
9018
+ const perms = rbacClaimList(subject, "permissions");
9019
+ for (const group of requiredPerms) {
9020
+ if (!group.some((p) => rbacPermGranted(perms, p))) return writeForbidden(res);
9021
+ }
8959
9022
  return false;
8960
9023
  }
9024
+ function writeForbidden(res) {
9025
+ res.raw.writeHead(403, { "Content-Type": "application/json" });
9026
+ res.raw.end(JSON.stringify({ error: "Forbidden" }));
9027
+ return true;
9028
+ }
8961
9029
  var init_authGate = __esm({
8962
9030
  "../core/src/authGate.ts"() {
8963
9031
  "use strict";
@@ -25034,6 +25102,9 @@ function asHtmlString(chunk) {
25034
25102
  if (Buffer.isBuffer(chunk)) return chunk.toString("utf-8");
25035
25103
  return null;
25036
25104
  }
25105
+ function isInjectableHtml(res) {
25106
+ return isHtmlResponse(res) && !res.raw.getHeader("content-encoding");
25107
+ }
25037
25108
  function injectIntoHtml(ctx, devToolbar, html) {
25038
25109
  if (!devToolbar) return injectFeedbackWidget(ctx.req, html);
25039
25110
  const toolbarCtx = {
@@ -25061,7 +25132,7 @@ function wrapResponseEnd(ctx) {
25061
25132
  Date.now() - ctx.reqStartTime
25062
25133
  );
25063
25134
  }
25064
- if (isHtmlResponse(res)) {
25135
+ if (isInjectableHtml(res)) {
25065
25136
  const html = asHtmlString(chunk);
25066
25137
  if (html !== null) chunk = injectIntoHtml(ctx, devToolbar, html);
25067
25138
  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;