tina4ruby 3.13.56 → 3.13.57

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5c7293feabb257a4fc0cb5baedff6ba94989d1e7b5813be320142eba00019130
4
- data.tar.gz: 58ab8e8d19a95fb653e0fbba10c75aa00971dc528c0e4206c2f2f899df8ba1a4
3
+ metadata.gz: 4bb69292bcdb7035a62af766ea15bd53024a66cd81876e20759e63406438e778
4
+ data.tar.gz: 1febc84f01f5e99f383242fee3b1354bb285350b34369c33d88fba00ac05ea32
5
5
  SHA512:
6
- metadata.gz: 2d83bececec8a011b7d0334e1d2cc6ef1f6e06fad16cc0b416e5eaa1d3302999c0de85fe487de980815cadd13f5d25a6c587905c4d0356e964412ac1a4e8dbc2
7
- data.tar.gz: eeda312cf55b0df2b5ef9361bbf6e29a8f511aab8fd276a17151bf54b7b759df250df211de78651a6818b9a4431533fb5f64bcd07bcfc54d94941b3112622860
6
+ metadata.gz: 2ea7ac0c108326d7d4fed075b8750190cc3e3ce48dad0536766452cab6651a1c83da5007de05c894825366e444bdf55225e528f30eb43ce4822fbf09f7a3744e
7
+ data.tar.gz: d5b933129a85f7c9a281a8a5adaebc0ac9e1f6ac3fbefa57d0142d6ccb3e935374d4f041de2a1175c11376c63af31b83eaa3f60526375396a5f75086ce6cd065
data/lib/tina4/orm.rb CHANGED
@@ -462,7 +462,12 @@ module Tina4
462
462
  # a dict/list default is an application-level concern, applied per
463
463
  # instance, not a portable SQL literal (PG needs a ::jsonb cast, MySQL
464
464
  # an expression default). The instance still gets its default at new.
465
- if opts[:default] && !opts[:auto_increment] && opts[:type] != :json
465
+ # A callable default (e.g. `datetime_field :created_at, default: -> { Time.now }`)
466
+ # is resolved per-row at instance creation; it must NOT reach the DDL, where
467
+ # default_literal would stringify the Proc to `DEFAULT #<Proc:...>` — invalid
468
+ # SQL that silently fails table creation (parity with the Python master, #61).
469
+ callable_default = opts[:default].respond_to?(:call) && !opts[:default].is_a?(Class)
470
+ if opts[:default] && !opts[:auto_increment] && opts[:type] != :json && !callable_default
466
471
  parts << "DEFAULT #{default_literal(opts[:default], opts[:type], bool_sql)}"
467
472
  end
468
473
  col_defs << parts.join(" ")
@@ -1,4 +1,4 @@
1
- "use strict";var Tina4=(()=>{var z=Object.defineProperty;var Ue=Object.getOwnPropertyDescriptor;var Je=Object.getOwnPropertyNames;var ze=Object.prototype.hasOwnProperty;var Ge=(e,n)=>{for(var t in n)z(e,t,{get:n[t],enumerable:!0})},Ve=(e,n,t,r)=>{if(n&&typeof n=="object"||typeof n=="function")for(let o of Je(n))!ze.call(e,o)&&o!==t&&z(e,o,{get:()=>n[o],enumerable:!(r=Ue(n,o))||r.enumerable});return e};var Be=e=>Ve(z({},"__esModule",{value:!0}),e);var ht={};Ge(ht,{Tina4Element:()=>A,api:()=>Te,batch:()=>U,clearPersistedKeys:()=>De,computed:()=>de,createI18n:()=>ne,effect:()=>k,html:()=>pe,i18n:()=>qe,isSignal:()=>x,navigate:()=>J,persist:()=>Pe,pwa:()=>Re,route:()=>be,router:()=>Se,signal:()=>S,sse:()=>Ce,ws:()=>_e});var M=null,D=null,P=null,K=null;function _(e){K=e}function W(){return K}var le=null,ce=null,ue=[],Ze=512;var j=0,G=new Set;function S(e,n){let t=e,r=new Set,o={_t4:!0,get value(){if(M&&(r.add(M),D)){let s=M;D.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++,ce&&ce(o,i,s),j>0)for(let a of r)G.add(a);else{let a;for(let c of[...r])try{c()}catch(u){a===void 0&&(a=u)}if(a!==void 0)throw a}},_subscribe(s){return r.add(s),()=>{r.delete(s)}},peek(){return t}};return le?(o._debugInfo={label:n,createdAt:Date.now(),updateCount:0,subs:r},le(o,n)):ue.length<Ze&&ue.push({ref:new WeakRef(o),label:n,createdAt:Date.now(),subs:r}),o}function de(e){let n=S(void 0);return k(()=>{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 k(e){let n=!1,t=[],r=[],o=()=>{for(let a of r)a();r=[]},s=()=>{if(n)return;for(let m of t)m();t=[],o();let a=M,c=D,u=P;M=s,D=t,P=r;try{e()}finally{M=a,D=c,P=u}};s();let i=()=>{n=!0;for(let a of t)a();t=[],o()};return P&&P.push(i),K&&K.push(i),i}function U(e){j++;try{e()}finally{if(j--,j===0){let n=[...G];G.clear();let t;for(let r of n)try{r()}catch(o){t===void 0&&(t=o)}if(t!==void 0)throw t}}}function x(e){return e!==null&&typeof e=="object"&&e._t4===!0}var fe=new WeakMap,V="t4:";function pe(e,...n){let t=fe.get(e);if(!t){t=document.createElement("template");let i="";for(let a=0;a<e.length;a++)i+=e[a],a<n.length&&(tt(i)?i+=`__t4_${a}__`:i+=`<!--${V}${a}-->`);t.innerHTML=i,fe.set(e,t)}let r=t.content.cloneNode(!0),o=Qe(r);for(let{marker:i,index:a}of o)Ye(i,n[a]);let s=Xe(r);for(let i of s)et(i,n);return r}function Qe(e){let n=[];return Z(e,t=>{if(t.nodeType===8){let r=t.data;if(r&&r.startsWith(V)){let o=parseInt(r.slice(V.length),10);n.push({marker:t,index:o})}}}),n}function Xe(e){let n=[];return Z(e,t=>{t.nodeType===1&&n.push(t)}),n}function Z(e,n){let t=e.childNodes;for(let r=0;r<t.length;r++){let o=t[r];n(o),Z(o,n)}}function Ye(e,n){let t=e.parentNode;if(t)if(x(n)){let r=document.createTextNode("");t.replaceChild(r,e),k(()=>{r.data=String(n.value??"")})}else if(typeof n=="function"){let r=document.createComment("");t.replaceChild(r,e);let o=[],s=[];k(()=>{for(let y of s)y();s=[];let i=[],a=W();_(i);let c=n();_(a),s=i;for(let y of o)y.parentNode?.removeChild(y);o=[];let u=B(c),m=r.parentNode;if(m)for(let y of u)m.insertBefore(y,r),o.push(y)})}else if(ge(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=B(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 et(e,n){let t=[];for(let r of Array.from(e.attributes)){let o=r.name,s=r.value;if(o.startsWith("@")){let a=o.slice(1),c=s.match(/__t4_(\d+)__/);if(c){let u=n[parseInt(c[1],10)];typeof u=="function"&&e.addEventListener(a,m=>U(()=>u(m)))}t.push(o);continue}if(o.startsWith("?")){let a=o.slice(1),c=s.match(/__t4_(\d+)__/);if(c){let u=n[parseInt(c[1],10)];if(x(u)){let m=u;k(()=>{m.value?e.setAttribute(a,""):e.removeAttribute(a)})}else typeof u=="function"?k(()=>{u()?e.setAttribute(a,""):e.removeAttribute(a)}):u&&e.setAttribute(a,"")}t.push(o);continue}if(o.startsWith(".")){let a=o.slice(1),c=s.match(/__t4_(\d+)__/);if(c){let u=n[parseInt(c[1],10)];x(u)?k(()=>{e[a]=u.value}):typeof u=="function"?k(()=>{e[a]=u()??""}):e[a]=u}t.push(o);continue}let i=s.match(/__t4_(\d+)__/);if(i){let a=n[parseInt(i[1],10)];if(x(a)){let c=a;k(()=>{e.setAttribute(o,String(c.value??""))})}else typeof a=="function"?k(()=>{e.setAttribute(o,String(a()??""))}):e.setAttribute(o,String(a??""))}}for(let r of t)e.removeAttribute(r)}function B(e){if(e==null||e===!1)return[];if(ge(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(...B(t));return n}return[document.createTextNode(String(e))]}function ge(e){return e!=null&&typeof e=="object"&&e.nodeType===11}function tt(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 me=null,he=null;var A=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]=S(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=k(()=>{this._innerDisposers.splice(0).forEach(c=>c());let o=[],s=W();_(o);let i=this.render();_(s),this._innerDisposers=o;let a=Array.from(this._root.childNodes);for(let c of a)c!==r&&this._root.removeChild(c);i&&this._root.appendChild(i)}),this.onMount(),me&&me(this)}disconnectedCallback(){this._disposeRender&&(this._disposeRender(),this._disposeRender=null),this._innerDisposers.splice(0).forEach(t=>t()),this.onUnmount(),he&&he(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??""}};A.props={},A.styles="",A.shadow=!0;var X=[],N=null,F="history",nt=!1,q=[],Q=[],ve=0;function be(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"?X.push({pattern:e,regex:o,paramNames:t,handler:n}):X.push({pattern:e,regex:o,paramNames:t,handler:n.handler,guard:n.guard})}function J(e,n){if(F==="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(!N)return;let e=performance.now(),n=++ve,t=F==="hash"?location.hash.slice(1)||"/":location.pathname;for(let r of X){let o=t.match(r.regex);if(!o)continue;let s={};if(r.paramNames.forEach((c,u)=>{s[c]=decodeURIComponent(o[u+1])}),r.guard){let c=r.guard();if(c===!1)return;if(typeof c=="string"){J(c,{replace:!0});return}}Q.splice(0).forEach(c=>c()),N.innerHTML="";let i=[];_(i);let a=r.handler(s);if(a instanceof Promise)a.then(c=>{if(_(null),n!==ve){for(let m of i)m();return}ye(N,c),Q=i;let u=performance.now()-e;for(let m of q)m({path:t,params:s,pattern:r.pattern,durationMs:u})});else{_(null),ye(N,a),Q=i;let c=performance.now()-e;for(let u of q)u({path:t,params:s,pattern:r.pattern,durationMs:c})}return}}function ye(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 Se={start(e){if(N=document.querySelector(e.target),!N)throw new Error(`[tina4] Router target '${e.target}' not found in DOM`);F=e.mode??"history",nt=!0,window.addEventListener("popstate",H),F==="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=F==="hash"?t.getAttribute("href"):t.pathname;J(r)}),H()},on(e,n){return q.push(n),()=>{let t=q.indexOf(n);t>=0&&q.splice(t,1)}}};var E={baseUrl:"",auth:!1,tokenKey:"tina4_token",headers:{}},Y=[],ee=[],rt=0;function te(){try{return localStorage.getItem(E.tokenKey)}catch{return null}}function ot(e){try{localStorage.setItem(E.tokenKey,e)}catch{}}function we(e,n){let t=Object.entries(n).map(([r,o])=>`${encodeURIComponent(r)}=${encodeURIComponent(String(o))}`).join("&");return e+(e.includes("?")?"&":"?")+t}async function ke(e,n){e._url=n,e._requestId=++rt;for(let a of Y){let c=a(e);c&&(e=c)}let t=await fetch(n,e),r=t.headers.get("FreshToken");r&&ot(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 a of ee){let c=a(i);c&&(i=c)}if(!t.ok)throw i;return i.data}async function L(e,n,t,r){let o={method:e,credentials:"same-origin",headers:{"Content-Type":"application/json",...E.headers}};if(E.auth){let s=te();s&&(o.headers.Authorization=`Bearer ${s}`)}if(t!==void 0&&e!=="GET"){let s=typeof t=="object"&&t!==null?{...t}:t;if(E.auth&&typeof s=="object"&&s!==null){let i=te();i&&(s.formToken=i)}o.body=JSON.stringify(s)}return r?.headers&&Object.assign(o.headers,r.headers),r?.params&&(n=we(n,r.params)),ke(o,E.baseUrl+n)}var Te={configure(e){Object.assign(E,e)},get(e,n){return L("GET",e,void 0,n)},post(e,n,t){return L("POST",e,n,t)},put(e,n,t){return L("PUT",e,n,t)},patch(e,n,t){return L("PATCH",e,n,t)},delete(e,n){return L("DELETE",e,void 0,n)},async graphql(e,n,t,r){return L("POST",e,{query:n,variables:t||{}},r)},async upload(e,n,t){let r={method:"POST",headers:{...E.headers},body:n};if(delete r.headers["Content-Type"],delete r.headers["content-type"],E.auth){let o=te();o&&(r.headers.Authorization=`Bearer ${o}`)}return t?.headers&&Object.assign(r.headers,t.headers),t?.params&&(e=we(e,t.params)),ke(r,E.baseUrl+e)},intercept(e,n){e==="request"?Y.push(n):ee.push(n)},_reset(){E.baseUrl="",E.auth=!1,E.tokenKey="tina4_token",E.headers={},Y.length=0,ee.length=0}};function st(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 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`
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 Ee(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 Re={register(e){let n=Ee(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 st(e)},generateManifest(e){return Ee(e)}};var it={reconnect:!0,reconnectDelay:1e3,reconnectMaxDelay:3e4,reconnectAttempts:1/0,protocols:[],token:""};function at(e){let n=Array.isArray(e.protocols)?e.protocols:e.protocols?[e.protocols]:[];return e.token?["bearer",e.token,...n]:e.protocols}function lt(e,n={}){let t={...it,...n},r=S("connecting"),o=S(!1),s=S(null),i=S(null),a=S(0),c={message:[],open:[],close:[],error:[]},u=null,m=!1,y=t.reconnectDelay,f=null,l=0;function p(g){if(typeof g!="string")return g;try{return JSON.parse(g)}catch{return g}}function d(){r.value=l>0?"reconnecting":"connecting";try{u=new WebSocket(e,at(t))}catch{r.value="closed",o.value=!1;return}u.onopen=()=>{r.value="open",o.value=!0,i.value=null,l=0,y=t.reconnectDelay,a.value=0;for(let g of c.open)g()},u.onmessage=g=>{let b=p(g.data);s.value=b;for(let R of c.message)R(b)},u.onclose=g=>{r.value="closed",o.value=!1;for(let b of c.close)b(g.code,g.reason);!m&&t.reconnect&&l<t.reconnectAttempts&&v()},u.onerror=g=>{i.value=g;for(let b of c.error)b(g)}}function v(){l++,a.value=l,r.value="reconnecting",f=setTimeout(()=>{f=null,d()},y),y=Math.min(y*2,t.reconnectMaxDelay)}let T={status:r,connected:o,lastMessage:s,error:i,reconnectCount:a,send(g){if(!u||u.readyState!==WebSocket.OPEN)throw new Error("[tina4] WebSocket is not connected");let b=typeof g=="string"?g:JSON.stringify(g);u.send(b)},on(g,b){return c[g].push(b),()=>{let R=c[g],I=R.indexOf(b);I>=0&&R.splice(I,1)}},pipe(g,b){let R=I=>{g.value=b(I,g.value)};return T.on("message",R)},close(g,b){m=!0,f&&(clearTimeout(f),f=null),u&&u.close(g??1e3,b??""),r.value="closed",o.value=!1}};return d(),T}var _e={connect:lt};var ct={mode:"eventsource",method:"GET",headers:{},body:void 0,reconnect:!0,reconnectDelay:1e3,reconnectMaxDelay:3e4,reconnectAttempts:1/0,events:[],json:!0};function ut(e,n={}){let t={...ct,...n},r=S("connecting"),o=S(!1),s=S(null),i=S(null),a=S(null),c=S(0),u={message:[],open:[],close:[],error:[]},m=null,y=null,f=!1,l=t.reconnectDelay,p=null,d=0;function v(h){if(!t.json||typeof h!="string")return h;try{return JSON.parse(h)}catch{return h}}function T(h,w){s.value=h,i.value=w;for(let C of u.message)C(h,w??void 0)}function g(){r.value="open",o.value=!0,a.value=null,d=0,l=t.reconnectDelay,c.value=0;for(let h of u.open)h()}function b(){r.value="closed",o.value=!1;for(let h of u.close)h();!f&&t.reconnect&&d<t.reconnectAttempts&&$e()}function R(h){a.value=h;for(let w of u.error)w(h)}function I(){r.value=d>0?"reconnecting":"connecting";try{m=new EventSource(e)}catch{r.value="closed",o.value=!1;return}m.onopen=()=>g(),m.onmessage=h=>{T(v(h.data),null)};for(let h of t.events)m.addEventListener(h,w=>{T(v(w.data),h)});m.onerror=h=>{R(h),m&&m.readyState===2&&(m=null,b())}}function He(){r.value=d>0?"reconnecting":"connecting",y=new AbortController;let h={method:t.method,headers:t.headers,signal:y.signal};t.body!==void 0&&(h.body=typeof t.body=="string"?t.body:JSON.stringify(t.body)),fetch(e,h).then(async w=>{if(!w.ok){R(new Error(`[tina4] SSE fetch ${w.status}`)),b();return}g();let C=w.body.getReader(),O=new TextDecoder,$="";for(;;){let{done:je,value:Ke}=await C.read();if(je)break;$+=O.decode(Ke,{stream:!0});let ie=$.split(`
48
- `);$=ie.pop();for(let We of ie){let ae=We.trim();ae&&T(v(ae),null)}}let se=$.trim();se&&T(v(se),null),y=null,b()}).catch(w=>{w.name!=="AbortError"&&(y=null,R(w),b())})}function $e(){d++,c.value=d,r.value="reconnecting",p=setTimeout(()=>{p=null,re()},l),l=Math.min(l*2,t.reconnectMaxDelay)}function re(){t.mode==="fetch"?He():I()}let oe={status:r,connected:o,lastMessage:s,lastEvent:i,error:a,reconnectCount:c,on(h,w){return u[h].push(w),()=>{let C=u[h],O=C.indexOf(w);O>=0&&C.splice(O,1)}},pipe(h,w){let C=O=>{h.value=w(O,h.value)};return oe.on("message",C)},close(){f=!0,p&&(clearTimeout(p),p=null),m&&(m.close(),m=null),y&&(y.abort(),y=null),r.value="closed",o.value=!1}};return re(),oe}var Ce={connect:ut};var xe={read:e=>JSON.parse(e),write:e=>JSON.stringify(e)},Ae=/(token|password|passwd|secret|api[_-]?key|apikey|auth(?!or)|credential|jwt|bearer|otp|seed|private[_-]?key|session[_-]?id)/i,dt=/^[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}$/,ft=/^[A-Za-z0-9+/_=-]{40,}$/,Ie=new Set;function Oe(e,n){if(Ae.test(e))return`key name "${e}" looks like a credential`;if(typeof n=="string"){if(dt.test(n))return"value looks like a JWT";if(n.length>=40&&ft.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(Ae.test(t))return`object contains a credential-shape field "${t}"`}return null}function Me(e,n){Ie.has(n)||(Ie.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 Le(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 Pe(e,n){let{key:t,storage:r="local",serializer:o=xe,version:s=1,migrate:i,syncTabs:a=!1,silenceCredentialWarning:c=!1}=n;if(!t||typeof t!="string")throw new Error("[tina4 persist] options.key is required and must be a string");let u=o===xe,m=Le(r);if(!m)return Ne(e,()=>{},()=>{});try{let l=m.getItem(t);if(l!==null){let p,d;try{let v=JSON.parse(l);v&&typeof v=="object"&&"value"in v?(p=v.v,d=v.value):d=v}catch{d=u?l:o.read(l)}if(p===s||p===void 0){let v=u?d:o.read(typeof d=="string"?d:JSON.stringify(d));e.value=v}else if(i)try{e.value=i(d,p)}catch(v){console.warn(`[tina4 persist] migrate() threw for key "${t}":`,v)}else console.warn(`[tina4 persist] stored version ${p} does not match current ${s} for key "${t}", and no migrate() was provided. Discarding the stored value.`)}}catch(l){console.warn(`[tina4 persist] failed to read key "${t}":`,l)}if(!c){let l=Oe(t,e.peek());l&&Me(l,t)}let y=k(()=>{let l=e.value;if(!c){let p=Oe(t,l);p&&Me(p,t)}try{let d=JSON.stringify(u?{v:s,value:l}:{v:s,value:o.write(l)});m.setItem(t,d)}catch(p){console.warn(`[tina4 persist] failed to write key "${t}":`,p)}}),f=null;if(a&&typeof globalThis<"u"&&"addEventListener"in globalThis){let l=p=>{let d=p;if(d.storageArea===m&&d.key===t&&d.newValue!==null)try{let v=JSON.parse(d.newValue),T=v&&typeof v=="object"&&"v"in v?v.v:void 0,g=T!==void 0?v.value:v;T!==void 0&&T!==s&&i?e.value=i(g,T):(T===s||T===void 0)&&(e.value=u?g:o.read(typeof g=="string"?g:JSON.stringify(g)))}catch(v){console.warn(`[tina4 persist] failed to parse storage event for key "${t}":`,v)}};globalThis.addEventListener?.("storage",l),f=()=>{globalThis.removeEventListener?.("storage",l)}}return Ne(e,()=>{try{m.removeItem(t)}catch(l){console.warn(`[tina4 persist] failed to clear key "${t}":`,l)}},()=>{y(),f&&f()})}function Ne(e,n,t){return Object.assign(e,{clear:n,dispose:t})}function De(e,n="local"){let t=Le(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 pt=["ar","he","fa","ur","ps","dv","syr","ckb","yi"];function gt(){return globalThis.navigator?.language||"en"}function Fe(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))Fe(o,s,t);else{let i=String(o);t[s]=i,r in t||(t[r]=i)}}return t}function mt(e,n){return e.replace(/\{(\w+)\}/g,(t,r)=>Object.prototype.hasOwnProperty.call(n,r)?String(n[r]):t)}function ne(e={}){let n=e.locale||gt(),t=e.fallbackLocale||n,r=new Set([...pt,...e.rtlLocales||[]]),o=S(n,"i18n.locale"),s=new Map,i=new Map;function a(f,l){let p=Fe(l),d=s.get(f);s.set(f,d?{...d,...p}:p)}if(e.messages)for(let[f,l]of Object.entries(e.messages))a(f,l);function c(f,l){return s.get(f)?.[l]}function u(f,l){let p=`n|${f}|${JSON.stringify(l||{})}`,d=i.get(p);return d||(d=new Intl.NumberFormat(f,l),i.set(p,d)),d}function m(f,l){let p=`d|${f}|${JSON.stringify(l||{})}`,d=i.get(p);return d||(d=new Intl.DateTimeFormat(f,l),i.set(p,d)),d}function y(f,l){let p=`r|${f}|${JSON.stringify(l||{})}`,d=i.get(p);return d||(d=new Intl.RelativeTimeFormat(f,l),i.set(p,d)),d}return{locale:o,t(f,l){let p=o.value,d=c(p,f);return d===void 0&&t!==p&&(d=c(t,f)),d===void 0&&(d=f),l?mt(d,l):d},setLocale(f){o.value=f},getLocale(){return o.value},addMessages:a,hasLocale(f){return s.has(f)},availableLocales(){return[...s.keys()].sort()},async loadMessages(f,l){let p=await fetch(l);if(!p.ok)throw new Error(`[tina4 i18n] failed to load "${f}" from ${l}: ${p.status}`);a(f,await p.json())},number(f,l){return u(o.value,l).format(f)},currency(f,l,p){return u(o.value,{style:"currency",currency:l,...p}).format(f)},date(f,l){let p=f instanceof Date?f:new Date(f);return m(o.value,l).format(p)},relativeTime(f,l,p){return y(o.value,p||{numeric:"auto"}).format(f,l)},isRTL(){return r.has(o.value.split("-")[0].toLowerCase())},dir(){return this.isRTL()?"rtl":"ltr"}}}var qe=ne();return Be(ht);})();
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);})();
@@ -298,56 +298,12 @@ module Tina4
298
298
  return handle_403(env["PATH_INFO"] || "/") unless auth_result
299
299
  end
300
300
 
301
- # Secure-by-default: enforce bearer-token auth on write routes
302
- if route.auth_required
303
- token = nil
304
- token_source = nil # :header, :body, :session
305
-
306
- # Priority 1: Authorization Bearer header
307
- auth_header = env["HTTP_AUTHORIZATION"] || ""
308
- if auth_header =~ /\ABearer\s+(.+)\z/i
309
- token = Regexp.last_match(1)
310
- token_source = :header
311
- end
312
-
313
- # Priority 2: formToken from request body (for frond.js saveForm with {{ form_token() }})
314
- if token.nil?
315
- body_str = _read_rack_body(env)
316
- form_token = _extract_form_token(body_str, env)
317
- if form_token && !form_token.empty?
318
- token = form_token
319
- token_source = :body
320
- end
321
- end
322
-
323
- # Priority 3: Session token (for secured GET routes after login)
324
- if token.nil?
325
- session = Tina4::Session.new(env)
326
- session_token = session.get("token")
327
- if session_token && !session_token.empty?
328
- token = session_token
329
- token_source = :session
330
- end
331
- end
332
-
333
- # API_KEY bypass — matches tina4_python behavior
334
- api_key = ENV["TINA4_API_KEY"]
335
- if api_key && !api_key.empty? && token == api_key
336
- env["tina4.auth_payload"] = { "api_key" => true }
337
- elsif token
338
- unless Tina4::Auth.valid_token(token)
339
- return [401, { "content-type" => "application/json" }, [JSON.generate({ error: "Unauthorized" })]]
340
- end
341
- env["tina4.auth_payload"] = Tina4::Auth.get_payload(token)
342
-
343
- # When body formToken validates, store a refreshed token for the FreshToken response header
344
- if token_source == :body
345
- env["tina4.fresh_token"] = Tina4::Auth.refresh_token(token)
346
- end
347
- else
348
- return [401, { "content-type" => "application/json" }, [JSON.generate({ error: "Unauthorized" })]]
349
- end
350
- end
301
+ # Secure-by-default: enforce bearer-token auth on write routes.
302
+ # Extracted into a class method so the in-process TestClient enforces the
303
+ # EXACT same gate (parity with Python #PY2 — a tokenless write must 401 in
304
+ # tests too, or a green test hides a live 401 and the verification lies).
305
+ unauthorized = self.class.enforce_route_auth(env, route)
306
+ return unauthorized if unauthorized
351
307
 
352
308
  request = Tina4::Request.new(env, path_params)
353
309
  request.user = env["tina4.auth_payload"] if env["tina4.auth_payload"]
@@ -1158,7 +1114,70 @@ module Tina4
1158
1114
 
1159
1115
 
1160
1116
  # Read and rewind the Rack input body. Returns the raw body string.
1161
- def _read_rack_body(env)
1117
+ # Enforce the secure-by-default write-route auth gate.
1118
+ #
1119
+ # Returns nil when the route is public OR a valid token is present (and, as a
1120
+ # side effect, sets env["tina4.auth_payload"] and, for a body formToken,
1121
+ # env["tina4.fresh_token"]). Returns a Rack 401 tuple when an auth-required
1122
+ # route has no valid token.
1123
+ #
1124
+ # A CLASS method on purpose: both the live RackApp#handle_route and the
1125
+ # in-process TestClient call it, so the test surface enforces the identical
1126
+ # gate as production (Python #PY2 parity). Instantiating a RackApp just to
1127
+ # reach the check would run full boot/route-discovery — this needs none of it.
1128
+ def self.enforce_route_auth(env, route)
1129
+ return nil unless route.auth_required
1130
+
1131
+ token = nil
1132
+ token_source = nil # :header, :body, :session
1133
+
1134
+ # Priority 1: Authorization Bearer header
1135
+ auth_header = env["HTTP_AUTHORIZATION"] || ""
1136
+ if auth_header =~ /\ABearer\s+(.+)\z/i
1137
+ token = Regexp.last_match(1)
1138
+ token_source = :header
1139
+ end
1140
+
1141
+ # Priority 2: formToken from request body (for frond.js saveForm with {{ form_token() }})
1142
+ if token.nil?
1143
+ body_str = _read_rack_body(env)
1144
+ form_token = _extract_form_token(body_str, env)
1145
+ if form_token && !form_token.empty?
1146
+ token = form_token
1147
+ token_source = :body
1148
+ end
1149
+ end
1150
+
1151
+ # Priority 3: Session token (for secured GET routes after login)
1152
+ if token.nil?
1153
+ session = Tina4::Session.new(env)
1154
+ session_token = session.get("token")
1155
+ if session_token && !session_token.empty?
1156
+ token = session_token
1157
+ token_source = :session
1158
+ end
1159
+ end
1160
+
1161
+ # API_KEY bypass — matches tina4_python behavior
1162
+ api_key = ENV["TINA4_API_KEY"]
1163
+ if api_key && !api_key.empty? && token == api_key
1164
+ env["tina4.auth_payload"] = { "api_key" => true }
1165
+ elsif token
1166
+ unless Tina4::Auth.valid_token(token)
1167
+ return [401, { "content-type" => "application/json" }, [JSON.generate({ error: "Unauthorized" })]]
1168
+ end
1169
+ env["tina4.auth_payload"] = Tina4::Auth.get_payload(token)
1170
+
1171
+ # When body formToken validates, store a refreshed token for the FreshToken response header
1172
+ env["tina4.fresh_token"] = Tina4::Auth.refresh_token(token) if token_source == :body
1173
+ else
1174
+ return [401, { "content-type" => "application/json" }, [JSON.generate({ error: "Unauthorized" })]]
1175
+ end
1176
+
1177
+ nil
1178
+ end
1179
+
1180
+ def self._read_rack_body(env)
1162
1181
  input = env["rack.input"]
1163
1182
  return "" unless input
1164
1183
  input.rewind if input.respond_to?(:rewind)
@@ -1169,7 +1188,7 @@ module Tina4
1169
1188
 
1170
1189
  # Extract a formToken from the request body.
1171
1190
  # Supports JSON body ({ "formToken": "..." }) and URL-encoded form data (formToken=...).
1172
- def _extract_form_token(body_str, env)
1191
+ def self._extract_form_token(body_str, env)
1173
1192
  return nil if body_str.nil? || body_str.empty?
1174
1193
 
1175
1194
  content_type = env["CONTENT_TYPE"] || env["HTTP_CONTENT_TYPE"] || ""
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tina4
4
+ module Realtime
5
+ # Attachment - a file linked to a channel (and optionally a message).
6
+ # channel_id scopes the file for permission checks; message_id is nil until
7
+ # the file is attached to a posted message. storage_key is the StorageBackend
8
+ # key; the row carries only metadata, never the blob.
9
+ class Attachment < Tina4::ORM
10
+ table_name "tina4_rt_attachments"
11
+
12
+ integer_field :id, primary_key: true, auto_increment: true
13
+ integer_field :channel_id
14
+ integer_field :message_id
15
+ string_field :storage_key, nullable: false, length: 255
16
+ string_field :filename, length: 255
17
+ string_field :mime, length: 128
18
+ integer_field :size
19
+ string_field :thumb_key, length: 255
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tina4
4
+ module Realtime
5
+ # Channel - a conversation stream inside a workspace.
6
+ # kind is one of public | private | dm. workspace_id is a plain integer FK
7
+ # column (the realtime handlers query it directly; no relationship wiring
8
+ # is needed for the control plane).
9
+ class Channel < Tina4::ORM
10
+ table_name "tina4_rt_channels"
11
+
12
+ integer_field :id, primary_key: true, auto_increment: true
13
+ integer_field :workspace_id
14
+ string_field :name, nullable: false, length: 200
15
+ string_field :kind, default: "public", length: 20
16
+ datetime_field :created_at
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tina4
4
+ module Realtime
5
+ # ChannelMember - a user's membership of a channel plus their read cursor.
6
+ # user_id is a string so it holds any identity shape the app puts in the JWT
7
+ # (an integer id, a UUID, an email). last_read_at is the read-receipt cursor.
8
+ class ChannelMember < Tina4::ORM
9
+ table_name "tina4_rt_channel_members"
10
+
11
+ integer_field :id, primary_key: true, auto_increment: true
12
+ integer_field :channel_id
13
+ string_field :user_id, nullable: false, length: 128
14
+ string_field :role, default: "member", length: 20
15
+ datetime_field :last_read_at
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Tina4
6
+ module Realtime
7
+ # Zero-dependency filesystem store. The default backend.
8
+ class LocalStorage < StorageBackend
9
+ def initialize(directory = nil)
10
+ super()
11
+ @directory = File.expand_path(directory || ENV["TINA4_STORAGE_DIR"] || "data/rt_storage")
12
+ FileUtils.mkdir_p(@directory)
13
+ end
14
+
15
+ def put(key, data, _mime = "application/octet-stream")
16
+ File.binwrite(path_for(key), data)
17
+ nil
18
+ end
19
+
20
+ def get(key)
21
+ File.binread(path_for(key))
22
+ rescue Errno::ENOENT, ArgumentError
23
+ nil
24
+ end
25
+
26
+ # No direct URL: the permissioned app download route serves local files.
27
+ def url(_key, _ttl = 3600)
28
+ nil
29
+ end
30
+
31
+ def delete(key)
32
+ File.delete(path_for(key))
33
+ nil
34
+ rescue Errno::ENOENT, ArgumentError
35
+ nil
36
+ end
37
+
38
+ def exists?(key)
39
+ File.file?(path_for(key))
40
+ rescue ArgumentError
41
+ false
42
+ end
43
+
44
+ private
45
+
46
+ # Resolve inside the root and reject any traversal attempt.
47
+ def path_for(key)
48
+ target = File.expand_path(File.join(@directory, key.to_s))
49
+ unless target == @directory || target.start_with?(@directory + File::SEPARATOR)
50
+ raise ArgumentError, "unsafe storage key: #{key.inspect}"
51
+ end
52
+
53
+ target
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tina4
4
+ module Realtime
5
+ # Message - one posted message in a channel.
6
+ # thread_id is nil for a top-level message, or the id of the parent message
7
+ # for a threaded reply. edited_at is nil until an edit.
8
+ class Message < Tina4::ORM
9
+ table_name "tina4_rt_messages"
10
+
11
+ integer_field :id, primary_key: true, auto_increment: true
12
+ integer_field :channel_id
13
+ string_field :user_id, nullable: false, length: 128
14
+ text_field :body
15
+ integer_field :thread_id
16
+ datetime_field :created_at
17
+ datetime_field :edited_at
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tina4
4
+ module Realtime
5
+ # S3-compatible store (AWS S3, MinIO, ...). Opt-in; needs the aws-sdk-s3 gem.
6
+ #
7
+ # Presigned GET URLs let clients fetch large blobs straight from object
8
+ # storage instead of streaming through the app.
9
+ class S3Storage < StorageBackend
10
+ def initialize(endpoint: nil, key: nil, secret: nil, bucket: nil, region: nil)
11
+ super()
12
+ require "aws-sdk-s3" # optional dependency, loaded lazily
13
+
14
+ @bucket = bucket || ENV["TINA4_STORAGE_BUCKET"]
15
+ raise ArgumentError, "S3Storage requires TINA4_STORAGE_BUCKET" if @bucket.nil? || @bucket.empty?
16
+
17
+ opts = {
18
+ access_key_id: key || ENV["TINA4_STORAGE_KEY"],
19
+ secret_access_key: secret || ENV["TINA4_STORAGE_SECRET"],
20
+ region: region || ENV["TINA4_STORAGE_REGION"] || "us-east-1",
21
+ force_path_style: true
22
+ }
23
+ ep = endpoint || ENV["TINA4_STORAGE_URL"]
24
+ opts[:endpoint] = ep if ep && !ep.empty?
25
+ @client = Aws::S3::Client.new(**opts)
26
+ end
27
+
28
+ def put(key, data, mime = "application/octet-stream")
29
+ @client.put_object(bucket: @bucket, key: key, body: data, content_type: mime)
30
+ nil
31
+ end
32
+
33
+ def get(key)
34
+ @client.get_object(bucket: @bucket, key: key).body.read
35
+ rescue StandardError
36
+ nil
37
+ end
38
+
39
+ def url(key, ttl = 3600)
40
+ Aws::S3::Presigner.new(client: @client)
41
+ .presigned_url(:get_object, bucket: @bucket, key: key, expires_in: ttl)
42
+ end
43
+
44
+ def delete(key)
45
+ @client.delete_object(bucket: @bucket, key: key)
46
+ nil
47
+ rescue StandardError => e
48
+ Tina4::Log.error("S3Storage delete failed for #{key}: #{e.message}")
49
+ nil
50
+ end
51
+
52
+ def exists?(key)
53
+ @client.head_object(bucket: @bucket, key: key)
54
+ true
55
+ rescue StandardError
56
+ false
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Tina4
6
+ module Realtime
7
+ # Storage backend selection + key generation for the realtime "files"
8
+ # feature. Mirrors the cache/session/queue backend pattern:
9
+ # TINA4_STORAGE_BACKEND (local default | s3) plus per-backend env vars, with
10
+ # a graceful fallback to local if an s3 backend cannot be built (missing gem
11
+ # or incomplete config) - a real persistent store, never a silent no-op.
12
+ module Storage
13
+ UNSAFE = /[^A-Za-z0-9]/
14
+
15
+ # Generate an opaque, collision-free storage key, preserving the extension.
16
+ # The key carries no user-controlled path segment, so it can never traverse
17
+ # outside the storage root.
18
+ def self.key(filename = "")
19
+ ext = ""
20
+ if filename && filename.include?(".")
21
+ raw = filename.rpartition(".").last
22
+ clean = raw.gsub(UNSAFE, "")[0, 12]
23
+ ext = ".#{clean}" unless clean.nil? || clean.empty?
24
+ end
25
+ "#{SecureRandom.hex(16)}#{ext}"
26
+ end
27
+
28
+ # Resolve the storage backend from an explicit instance or the environment.
29
+ def self.select(storage = nil)
30
+ return storage unless storage.nil?
31
+
32
+ name = (ENV["TINA4_STORAGE_BACKEND"] || "local").downcase
33
+ if name == "s3"
34
+ begin
35
+ return S3Storage.new
36
+ # LoadError (missing aws-sdk-s3 gem) is a ScriptError, NOT a
37
+ # StandardError, so it must be rescued explicitly or the "graceful
38
+ # fallback when the driver is absent" contract breaks (the whole mount
39
+ # would crash instead of degrading to local).
40
+ rescue StandardError, LoadError => e
41
+ Tina4::Log.warning(
42
+ "realtime files: S3 storage unavailable (#{e.message}); " \
43
+ "falling back to local filesystem storage."
44
+ )
45
+ return LocalStorage.new
46
+ end
47
+ end
48
+ LocalStorage.new
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tina4
4
+ module Realtime
5
+ # Blob store interface for the realtime "files" feature.
6
+ #
7
+ # put writes bytes, get reads them back, url returns a directly fetchable
8
+ # URL when the backend supports one (else nil - serve via the app download
9
+ # route), delete removes, exists? checks presence.
10
+ class StorageBackend
11
+ def put(_key, _data, _mime = "application/octet-stream")
12
+ raise NotImplementedError
13
+ end
14
+
15
+ def get(_key)
16
+ raise NotImplementedError
17
+ end
18
+
19
+ def url(_key, _ttl = 3600)
20
+ nil
21
+ end
22
+
23
+ def delete(_key)
24
+ raise NotImplementedError
25
+ end
26
+
27
+ def exists?(_key)
28
+ raise NotImplementedError
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tina4
4
+ module Realtime
5
+ # Workspace - the top-level container for channels (a "team" / "org").
6
+ # Framework-owned table: the tina4_rt_ prefix keeps it clear of an app's own
7
+ # domain tables (mirrors tina4_migration / tina4_sequences + the Python
8
+ # master's tina4_rt_* tables). Ruby is snake_case end to end, so the columns,
9
+ # attributes, and JSON keys are all snake_case with no mapping layer.
10
+ class Workspace < Tina4::ORM
11
+ table_name "tina4_rt_workspaces"
12
+
13
+ integer_field :id, primary_key: true, auto_increment: true
14
+ string_field :name, nullable: false, length: 200
15
+ datetime_field :created_at
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,400 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+ require "openssl"
6
+ require "base64"
7
+
8
+ require_relative "realtime/workspace"
9
+ require_relative "realtime/channel"
10
+ require_relative "realtime/channel_member"
11
+ require_relative "realtime/message"
12
+ require_relative "realtime/attachment"
13
+ require_relative "realtime/storage_backend"
14
+ require_relative "realtime/local_storage"
15
+ require_relative "realtime/s3_storage"
16
+ require_relative "realtime/storage"
17
+
18
+ module Tina4
19
+ # Real-time collaboration mount for Tina4 (Ruby), parity with the Python
20
+ # master's tina4_python.realtime. A zero-dependency control plane for building
21
+ # Slack/Teams-class tools:
22
+ #
23
+ # - calls: a WebRTC signalling relay + self-describing ICE-config endpoint.
24
+ # Media is peer-to-peer (mesh); Tina4 carries no media, it only relays the
25
+ # offer/answer/ICE handshake. An SFU drops in later with no route changes.
26
+ # - chat: persistent channels + messages (framework-owned ORM models), a
27
+ # secured chat WebSocket with live presence / typing / read receipts, and a
28
+ # history endpoint for catch-up-on-reconnect.
29
+ # - files: uploads/downloads through a pluggable StorageBackend.
30
+ #
31
+ # Paths are convention defaults, overridable via +prefix+, and the client
32
+ # discovers the resolved paths from the config endpoint so client and server
33
+ # never drift.
34
+ #
35
+ # Tina4::Realtime.mount # calls only
36
+ # Tina4::Realtime.mount(features: %w[calls chat]) # add persistent chat
37
+ # Tina4::Realtime.mount(prefix: "/api/collab", features: %w[calls chat files])
38
+ #
39
+ # Env: TINA4_RTC_BACKEND (default mesh), TINA4_RTC_STUN_URLS,
40
+ # TINA4_RTC_TURN_URL + TINA4_RTC_TURN_SECRET (ephemeral coturn creds),
41
+ # TINA4_RTC_TURN_TTL, plus the TINA4_STORAGE_* set for files.
42
+ module Realtime
43
+ DEFAULT_STUN = "stun:stun.l.google.com:19302"
44
+
45
+ module_function
46
+
47
+ # Build the ICE server list from the environment. Always includes STUN. Adds
48
+ # a TURN entry with time-limited credentials (coturn use-auth-secret scheme:
49
+ # username = <expiry-epoch>, credential = base64(HMAC-SHA1(secret, username)))
50
+ # when both TINA4_RTC_TURN_URL and TINA4_RTC_TURN_SECRET are set.
51
+ def ice_servers
52
+ stun = ENV["TINA4_RTC_STUN_URLS"] || DEFAULT_STUN
53
+ servers = [{ "urls" => split_urls(stun) }]
54
+
55
+ turn_url = ENV["TINA4_RTC_TURN_URL"]
56
+ secret = ENV["TINA4_RTC_TURN_SECRET"]
57
+ if turn_url && !turn_url.empty? && secret && !secret.empty?
58
+ ttl = (ENV["TINA4_RTC_TURN_TTL"] || "3600").to_i
59
+ username = (Time.now.to_i + ttl).to_s
60
+ credential = Base64.strict_encode64(OpenSSL::HMAC.digest("SHA1", secret, username))
61
+ servers << { "urls" => split_urls(turn_url), "username" => username, "credential" => credential }
62
+ end
63
+ servers
64
+ end
65
+
66
+ # Mount the realtime surface and return the resolved path map (also served
67
+ # from the config endpoint so the client can discover it).
68
+ #
69
+ # @param prefix [String] mount the whole surface under this path
70
+ # @param authorize [Proc] membership guard authorize.(identity, channel_id)
71
+ # for chat/files; defaults to a ChannelMember check
72
+ # @param storage [StorageBackend] for the files feature
73
+ # @param features [Array<String>] any of "calls", "chat", "files"
74
+ def mount(prefix: "", authorize: nil, storage: nil, features: nil)
75
+ features = features || ["calls"]
76
+ p = prefix.to_s.gsub(%r{\A/+|/+\z}, "")
77
+ p = p.empty? ? "" : "/#{p}"
78
+ @authorize = authorize
79
+ @storage = storage
80
+
81
+ # Chat and files both persist through the framework-owned chat models.
82
+ ensure_chat_tables if features.include?("chat") || features.include?("files")
83
+
84
+ paths = { "backend" => "mesh" }
85
+ if features.include?("calls")
86
+ paths["config"] = "#{p}/api/rtc/config"
87
+ paths["signalling"] = "#{p}/ws/rtc"
88
+ end
89
+ if features.include?("chat")
90
+ paths["config"] ||= "#{p}/api/rtc/config"
91
+ paths["chat"] = "#{p}/ws/chat"
92
+ paths["messages"] = "#{p}/api/channels"
93
+ end
94
+ if features.include?("files")
95
+ paths["config"] ||= "#{p}/api/rtc/config"
96
+ paths["files"] = "#{p}/api/files"
97
+ end
98
+
99
+ register_config(paths, features) if paths.key?("config")
100
+ register_calls(paths) if features.include?("calls")
101
+ register_chat(paths) if features.include?("chat")
102
+ register_files(paths, storage) if features.include?("files")
103
+
104
+ paths
105
+ end
106
+
107
+ # ----- route registration -------------------------------------------------
108
+
109
+ def register_config(paths, features)
110
+ Tina4::Router.get(paths["config"]) do |_request, response|
111
+ body = { "backend" => "mesh" }
112
+ if features.include?("calls")
113
+ body["iceServers"] = Tina4::Realtime.ice_servers
114
+ body["signalling"] = "#{paths['signalling']}/{room}"
115
+ end
116
+ if features.include?("chat")
117
+ body["chat"] = "#{paths['chat']}/{channel}"
118
+ body["messages"] = "#{paths['messages']}/{id}/messages"
119
+ end
120
+ body["files"] = paths["files"] if features.include?("files")
121
+ response.json(body, Tina4::HTTP_OK)
122
+ end
123
+ end
124
+
125
+ def register_calls(paths)
126
+ # WebRTC signalling relay (mesh). Tina4 never parses the SDP; peers filter
127
+ # by `to`. The signalling room is public - the media is E2E between peers.
128
+ Tina4::Router.websocket("#{paths['signalling']}/{room}") do |connection, event, data|
129
+ room = connection.params[:room].to_s
130
+ next if room.empty?
131
+
132
+ key = "rtc:#{room}"
133
+ case event
134
+ when :open
135
+ connection.join_room(key)
136
+ when :message
137
+ connection.broadcast_to_room(key, data, exclude_self: true)
138
+ end
139
+ end
140
+ end
141
+
142
+ def register_chat(paths)
143
+ Tina4::Router.websocket("#{paths['chat']}/{channel}") { |c, e, d| chat_handler(c, e, d) }.secure
144
+
145
+ Tina4::Router.get("#{paths['messages']}/{id}/messages") do |request, response|
146
+ identity = Tina4::Realtime.identity(request.user)
147
+ channel_id = request.params[:id].to_i
148
+ if channel_id <= 0
149
+ response.json({ "error" => "invalid channel id" }, Tina4::HTTP_BAD_REQUEST)
150
+ elsif !Tina4::Realtime.authorized?(identity, channel_id)
151
+ response.json({ "error" => "forbidden" }, Tina4::HTTP_FORBIDDEN)
152
+ else
153
+ response.json(Tina4::Realtime.history(channel_id, request.query || {}), Tina4::HTTP_OK)
154
+ end
155
+ end.secure
156
+ end
157
+
158
+ def register_files(paths, storage)
159
+ store = Tina4::Realtime::Storage.select(storage)
160
+
161
+ # POST is auth-required by default (mirrors GET being public).
162
+ Tina4::Router.post(paths["files"]) do |request, response|
163
+ identity = Tina4::Realtime.identity(request.user)
164
+ channel_id = Tina4::Realtime.form_value(request, "channel_id").to_i
165
+ if channel_id <= 0
166
+ response.json({ "error" => "channel_id is required" }, Tina4::HTTP_BAD_REQUEST)
167
+ elsif !Tina4::Realtime.authorized?(identity, channel_id)
168
+ response.json({ "error" => "forbidden" }, Tina4::HTTP_FORBIDDEN)
169
+ else
170
+ upload = request.files && request.files["file"]
171
+ if upload.nil?
172
+ response.json({ "error" => "no file uploaded (field 'file')" }, Tina4::HTTP_BAD_REQUEST)
173
+ else
174
+ saved = Tina4::Realtime.store_upload(store, channel_id, upload)
175
+ saved["url"] = store.url(saved["key"]) || "#{paths['files']}/#{saved['key']}"
176
+ response.json(saved, Tina4::HTTP_CREATED)
177
+ end
178
+ end
179
+ end
180
+
181
+ Tina4::Router.get("#{paths['files']}/{key}") do |request, response|
182
+ identity = Tina4::Realtime.identity(request.user)
183
+ key = request.params[:key].to_s
184
+ att = Attachment.where("storage_key = ?", [key]).first
185
+ if att.nil?
186
+ response.json({ "error" => "not found" }, Tina4::HTTP_NOT_FOUND)
187
+ elsif !Tina4::Realtime.authorized?(identity, att.channel_id.to_i)
188
+ response.json({ "error" => "forbidden" }, Tina4::HTTP_FORBIDDEN)
189
+ else
190
+ direct = store.url(key)
191
+ if direct
192
+ response.redirect(direct, 302)
193
+ else
194
+ data = store.get(key)
195
+ if data.nil?
196
+ response.json({ "error" => "not found" }, Tina4::HTTP_NOT_FOUND)
197
+ else
198
+ response.header("Content-Disposition", "inline; filename=\"#{att.filename || key}\"")
199
+ response.call(data, Tina4::HTTP_OK, att.mime || "application/octet-stream")
200
+ end
201
+ end
202
+ end
203
+ end.secure
204
+ end
205
+
206
+ # ----- WebSocket chat handler ---------------------------------------------
207
+
208
+ # Secured chat WebSocket. Ruby fires (connection, event, data); event is a
209
+ # Symbol (:open / :message / :close).
210
+ def chat_handler(connection, event, data)
211
+ raw = connection.params[:channel].to_s
212
+ return unless raw.match?(/\A\d+\z/) # framework channels are addressed by integer id
213
+
214
+ channel_id = raw.to_i
215
+ identity = identity(connection.auth)
216
+ key = "chat:#{channel_id}"
217
+
218
+ case event
219
+ when :open
220
+ unless authorized?(identity, channel_id)
221
+ connection.send_json({ "type" => "error", "error" => "not a member of this channel" })
222
+ connection.close
223
+ return
224
+ end
225
+ connection.join_room(key)
226
+ connection.send_json({ "type" => "presence", "event" => "roster", "users" => roster(key) })
227
+ connection.broadcast_to_room(
228
+ key, { "type" => "presence", "event" => "join", "user_id" => identity }.to_json, exclude_self: true
229
+ )
230
+ when :close
231
+ connection.broadcast_to_room(
232
+ key, { "type" => "presence", "event" => "leave", "user_id" => identity }.to_json, exclude_self: true
233
+ )
234
+ when :message
235
+ # Re-check membership on every inbound frame: it can be revoked
236
+ # mid-session, and we never trust a payload's identity.
237
+ return unless authorized?(identity, channel_id)
238
+
239
+ payload = parse_json(data)
240
+ return unless payload.is_a?(Hash)
241
+
242
+ kind = payload["type"] || "message"
243
+ case kind
244
+ when "typing"
245
+ connection.broadcast_to_room(
246
+ key, { "type" => "typing", "user_id" => identity }.to_json, exclude_self: true
247
+ )
248
+ when "read"
249
+ mark_read(channel_id, identity)
250
+ connection.broadcast_to_room(
251
+ key, { "type" => "read", "user_id" => identity, "at" => now_iso }.to_json, exclude_self: true
252
+ )
253
+ when "message"
254
+ body = (payload["body"] || "").to_s.strip
255
+ return if body.empty?
256
+
257
+ saved = persist_message(channel_id, identity, body, payload["thread_id"])
258
+ # Deliver to everyone INCLUDING the sender so the sender's optimistic
259
+ # message is reconciled with its server id + timestamp.
260
+ connection.broadcast_to_room(key, { "type" => "message", "message" => saved }.to_json) if saved
261
+ end
262
+ end
263
+ end
264
+
265
+ # ----- helpers ------------------------------------------------------------
266
+
267
+ # Extract a stable user identity from a verified JWT payload. Chat expects
268
+ # one of user_id / sub / id. Returns a string, or nil when unauthenticated.
269
+ def identity(auth)
270
+ return nil unless auth.is_a?(Hash)
271
+
272
+ %w[user_id sub id].each do |claim|
273
+ val = auth[claim] || auth[claim.to_sym]
274
+ return val.to_s unless val.nil?
275
+ end
276
+ nil
277
+ end
278
+
279
+ # Shared membership guard for chat channels and file access.
280
+ def authorized?(identity, channel_id)
281
+ return false if identity.nil?
282
+ return !!@authorize.call(identity, channel_id) unless @authorize.nil?
283
+
284
+ ChannelMember.count("channel_id = ? AND user_id = ?", [channel_id, identity]).positive?
285
+ rescue StandardError => e
286
+ Tina4::Log.error("realtime chat authorize check failed: #{e.message}")
287
+ false
288
+ end
289
+
290
+ # Presence roster: the distinct authenticated identities currently in a room,
291
+ # as strings (sorted). Collected from the live connections on the manager.
292
+ def roster(key)
293
+ mgr = Tina4::WebSocket.current
294
+ return [] if mgr.nil?
295
+
296
+ seen = mgr.get_room_connections(key).map { |c| identity(c.auth) }.compact
297
+ seen.uniq.sort
298
+ end
299
+
300
+ def form_value(request, name)
301
+ body = request.body
302
+ return body[name] if body.is_a?(Hash) && body.key?(name)
303
+
304
+ (request.query && request.query[name]) || (request.params && request.params[name.to_sym])
305
+ end
306
+
307
+ def parse_json(data)
308
+ return data if data.is_a?(Hash)
309
+
310
+ JSON.parse(data.to_s)
311
+ rescue JSON::ParserError, TypeError
312
+ nil
313
+ end
314
+
315
+ def now_iso
316
+ Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
317
+ end
318
+
319
+ def persist_message(channel_id, identity, body, thread_id)
320
+ thread = thread_id.nil? || thread_id == "" ? nil : thread_id.to_i
321
+ created_at = now_iso
322
+ msg = Message.new(
323
+ channel_id: channel_id, user_id: identity.to_s, body: body,
324
+ thread_id: thread, created_at: created_at
325
+ )
326
+ if msg.save == false
327
+ Tina4::Log.error("realtime chat: failed to persist message in channel #{channel_id}")
328
+ return nil
329
+ end
330
+ {
331
+ "id" => msg.id, "channel_id" => channel_id, "user_id" => identity.to_s,
332
+ "body" => body, "thread_id" => thread, "created_at" => created_at
333
+ }
334
+ end
335
+
336
+ def mark_read(channel_id, identity)
337
+ member = ChannelMember.where("channel_id = ? AND user_id = ?", [channel_id, identity]).first
338
+ return if member.nil?
339
+
340
+ member.last_read_at = now_iso
341
+ member.save
342
+ rescue StandardError => e
343
+ Tina4::Log.error("realtime chat: read-receipt update failed: #{e.message}")
344
+ end
345
+
346
+ # Messages for a channel, newest-first, paged by a `before` cursor. The
347
+ # standard infinite-scroll-backwards shape a chat client pages with.
348
+ def history(channel_id, query)
349
+ limit = (query["limit"] || query[:limit] || 50).to_i
350
+ limit = 50 if limit <= 0
351
+ limit = [[limit, 1].max, 200].min
352
+ before = query["before"] || query[:before]
353
+
354
+ clause = "channel_id = ?"
355
+ params = [channel_id]
356
+ unless before.nil? || before.to_s.empty?
357
+ clause += " AND id < ?"
358
+ params << before.to_i
359
+ end
360
+
361
+ rows = Message.where(clause, params)
362
+ rows = rows.sort_by { |m| -(m.id || 0) }.first(limit)
363
+ rows.map do |m|
364
+ {
365
+ "id" => m.id, "channel_id" => m.channel_id, "user_id" => m.user_id,
366
+ "body" => m.body, "thread_id" => m.thread_id, "created_at" => m.created_at
367
+ }
368
+ end
369
+ end
370
+
371
+ def store_upload(store, channel_id, upload)
372
+ filename = upload["filename"] || upload[:filename] || "file"
373
+ mime = upload["type"] || upload[:type] || "application/octet-stream"
374
+ content = upload["content"] || upload[:content] || ""
375
+ key = Tina4::Realtime::Storage.key(filename)
376
+ store.put(key, content, mime)
377
+ att = Attachment.new(
378
+ channel_id: channel_id, storage_key: key, filename: filename,
379
+ mime: mime, size: content.bytesize
380
+ )
381
+ att.save
382
+ { "id" => att.id, "key" => key, "filename" => filename, "mime" => mime, "size" => content.bytesize }
383
+ end
384
+
385
+ def ensure_chat_tables
386
+ [Workspace, Channel, ChannelMember, Message, Attachment].each(&:create_table)
387
+ true
388
+ rescue StandardError => e
389
+ Tina4::Log.error(
390
+ "realtime chat could not create its tables (#{e.message}). Chat needs a bound " \
391
+ "database - call Tina4.bind_database(db) or set TINA4_DATABASE_URL before mount."
392
+ )
393
+ false
394
+ end
395
+
396
+ def split_urls(csv)
397
+ csv.split(",").map(&:strip).reject(&:empty?)
398
+ end
399
+ end
400
+ end
data/lib/tina4/request.rb CHANGED
@@ -298,6 +298,30 @@ module Tina4
298
298
  data
299
299
  end
300
300
 
301
+ # Resolve the parsed multipart form fields (+ file entries) for this request.
302
+ #
303
+ # A live Rack server (Puma/WEBrick) does NOT parse the request body for us -
304
+ # it hands the app the raw `rack.input` stream. `Rack::Request#POST` parses
305
+ # a multipart (or urlencoded) body and caches the result into the standard
306
+ # `rack.request.form_hash` env key, which both #parse_body and #extract_files
307
+ # then read. We only invoke it when the key isn't already populated, so a
308
+ # spec that injects `rack.request.form_hash` directly still short-circuits
309
+ # here (and we never re-parse a body twice). Without this, live multipart
310
+ # uploads arrived empty (fields AND files) - the parse only ran in specs.
311
+ def multipart_form_hash
312
+ existing = @env["rack.request.form_hash"] rescue nil
313
+ return existing if existing
314
+
315
+ parsed = begin
316
+ require "rack"
317
+ Rack::Request.new(@env).POST
318
+ rescue StandardError => e
319
+ Tina4::Log.warning("multipart parse failed: #{e.message}") if defined?(Tina4::Log)
320
+ nil
321
+ end
322
+ @env["rack.request.form_hash"] || parsed
323
+ end
324
+
301
325
  def parse_body
302
326
  if @content_type.include?("application/json")
303
327
  json_body
@@ -307,7 +331,7 @@ module Tina4
307
331
  # Extract form fields from Rack's parsed multipart data.
308
332
  # Files are handled separately by extract_files.
309
333
  result = {}
310
- form_hash = @env["rack.request.form_hash"] rescue nil
334
+ form_hash = multipart_form_hash
311
335
  if form_hash
312
336
  form_hash.each do |key, value|
313
337
  # Skip file entries (handled by extract_files)
@@ -349,7 +373,7 @@ module Tina4
349
373
  result = {}
350
374
  return result unless @content_type.include?("multipart/form-data")
351
375
  begin
352
- form_hash = @env["rack.request.form_hash"]
376
+ form_hash = multipart_form_hash
353
377
  if form_hash
354
378
  form_hash.each do |key, value|
355
379
  if value.is_a?(Hash) && value[:tempfile]
@@ -122,6 +122,16 @@ module Tina4
122
122
 
123
123
  route, path_params = result
124
124
 
125
+ # Route through the REAL auth gate (parity with the live server). A write
126
+ # to an auth-required route (POST/PUT/PATCH/DELETE by default, or any
127
+ # secured route) with no valid token / API key / formToken 401s here
128
+ # exactly as it would in production. The TestClient used to skip this and
129
+ # run the handler directly, so a green test could hide a live 401 — the
130
+ # verification layer lied. A public route (GET, or a write marked
131
+ # .no_auth) has auth_required false, so this is a no-op. (#PY2 parity)
132
+ unauthorized = Tina4::RackApp.enforce_route_auth(env, route)
133
+ return TestResponse.new(unauthorized) if unauthorized
134
+
125
135
  # Create request and response
126
136
  req = Tina4::Request.new(env, path_params || {})
127
137
  res = Tina4::Response.new
data/lib/tina4/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tina4
4
- VERSION = "3.13.56"
4
+ VERSION = "3.13.57"
5
5
  end
@@ -7,7 +7,13 @@ require "set"
7
7
  require "securerandom"
8
8
 
9
9
  module Tina4
10
- WEBSOCKET_GUID = "258EAFA5-E914-47DA-95CA-5AB5DC11AD37"
10
+ # RFC 6455 section 1.3 magic value. MUST be exactly this string - the client
11
+ # concatenates it to its Sec-WebSocket-Key, SHA-1s, base64s, and compares to
12
+ # our Sec-WebSocket-Accept. A wrong GUID yields a wrong Accept: raw clients
13
+ # that skip the check still connect, but a browser validates it per spec and
14
+ # refuses the upgrade (silent failure). Verified against the RFC test vector:
15
+ # key "dGhlIHNhbXBsZSBub25jZQ==" -> accept "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=".
16
+ WEBSOCKET_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
11
17
 
12
18
  # Shared pub/sub channel name + envelope shape for the WebSocket backplane.
13
19
  # MUST stay byte-identical across all four frameworks (Python/PHP/Ruby/Node)
data/lib/tina4.rb CHANGED
@@ -52,6 +52,7 @@ require_relative "tina4/test"
52
52
  require_relative "tina4/docs"
53
53
  require_relative "tina4/docstore"
54
54
  require_relative "tina4/mcp"
55
+ require_relative "tina4/realtime"
55
56
 
56
57
  module Tina4
57
58
  # ── Lazy-loaded: database drivers ─────────────────────────────────────
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tina4ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.13.56
4
+ version: 3.13.57
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tina4 Team
@@ -372,6 +372,16 @@ files:
372
372
  - lib/tina4/queue_backends/rabbitmq_backend.rb
373
373
  - lib/tina4/rack_app.rb
374
374
  - lib/tina4/rate_limiter.rb
375
+ - lib/tina4/realtime.rb
376
+ - lib/tina4/realtime/attachment.rb
377
+ - lib/tina4/realtime/channel.rb
378
+ - lib/tina4/realtime/channel_member.rb
379
+ - lib/tina4/realtime/local_storage.rb
380
+ - lib/tina4/realtime/message.rb
381
+ - lib/tina4/realtime/s3_storage.rb
382
+ - lib/tina4/realtime/storage.rb
383
+ - lib/tina4/realtime/storage_backend.rb
384
+ - lib/tina4/realtime/workspace.rb
375
385
  - lib/tina4/request.rb
376
386
  - lib/tina4/response.rb
377
387
  - lib/tina4/response_cache.rb