"),gr=p(""),hr=p(" ");function se(e){return(()=>{var t=fr();return u(t,()=>e.children),L(()=>X(t,`overflow-auto rounded-md border border-border ${e.class||""}`)),t})()}function ie(e){return(()=>{var t=pr();return u(t,()=>e.children),L(()=>X(t,`min-w-full divide-y divide-border text-sm ${e.class||""}`)),t})()}function C(e){return(()=>{var t=gr();return u(t,()=>e.children),L(()=>X(t,`bg-secondary/60 px-3 py-2 text-left text-xs uppercase tracking-wide text-muted-foreground ${e.class||""}`)),t})()}function P(e){return(()=>{var t=hr();return u(t,()=>e.children),L(()=>X(t,`px-3 py-2 align-top ${e.class||""}`)),t})()}var mr=p('Add Service'),br=p('Protected Services'),yr=p(""),$r=p(" "),vr=p(''),xr=p("
"),wr=p('
'),_r=p(""),Sr=p('');function Cr(){const[e,{refetch:t}]=F(Rt),[r,n]=y(""),[s,i]=y(""),[o,a]=y(""),[h,m]=y(!1);async function d(){if(!(!r().trim()||!s().trim()||!o().trim())){m(!0);try{await Dt({name:r().trim(),upstreamUrl:s().trim(),routePrefix:o().trim()}),n(""),i(""),a(""),await t()}finally{m(!1)}}}return(()=>{var l=vr();return u(l,c(A,{class:"space-y-3",get children(){return[mr(),c(D,{placeholder:"Billing API",get value(){return r()},onInput:f=>n(f.currentTarget.value)}),c(D,{placeholder:"http://localhost:8080",get value(){return s()},onInput:f=>i(f.currentTarget.value)}),c(D,{placeholder:"/v1/billing",get value(){return o()},onInput:f=>a(f.currentTarget.value)}),c(E,{onClick:()=>{d()},get disabled(){return h()},get children(){return h()?"Creating...":"Create Service"}})]}}),null),u(l,c(A,{get children(){return[br(),c(se,{get children(){return c(ie,{get children(){return[(()=>{var f=yr(),g=f.firstChild;return u(g,c(C,{children:"Name"}),null),u(g,c(C,{children:"Route"}),null),u(g,c(C,{children:"Status"}),null),u(g,c(C,{children:"Actions"}),null),f})(),(()=>{var f=$r();return u(f,c(K,{get each(){return e()||[]},children:g=>(()=>{var b=Sr();return u(b,c(P,{get children(){return[(()=>{var x=xr();return u(x,()=>g.name),x})(),(()=>{var x=wr();return u(x,()=>g.upstreamUrl),x})()]}}),null),u(b,c(P,{get children(){var x=_r();return u(x,()=>g.routePrefix),x}}),null),u(b,c(P,{get children(){return c(le,{get label(){return g.lastValidationStatus||"unknown"},get tone(){return Q(()=>g.lastValidationStatus==="healthy")()?"success":g.lastValidationStatus==="failed"?"danger":"default"}})}}),null),u(b,c(P,{class:"space-x-2",get children(){return[c(E,{variant:"outline",onClick:()=>{Mt(g.id).then(()=>t())},children:"Validate"}),c(E,{variant:"secondary",onClick:()=>{Ut(g.id,{enabled:!g.enabled}).then(()=>t())},get children(){return g.enabled?"Disable":"Enable"}})]}}),null),b})()})),f})()]}})}})]}}),null),l})()}var Pr=p("");function Te(e){return(()=>{var t=Pr();return $e(t,be(e,{get class(){return`w-full rounded-md border border-input bg-card px-3 py-2 text-sm text-foreground outline-none focus:border-primary ${e.class||""}`}}),!1,!0),u(t,()=>e.children),t})()}var Ar=p('Add Database Connector'),kr=p("SQLite"),Or=p(" Postgres"),Tr=p(" MySQL"),Er=p('Database Connectors'),Ir=p(""),Nr=p(" "),Lr=p(''),Rr=p("
"),Dr=p('
'),Ur=p(""),Mr=p('');function jr(){const[e,{refetch:t}]=F(jt),[r,n]=y(""),[s,i]=y("sqlite"),[o,a]=y("");async function h(){!r().trim()||!o().trim()||(await Vt({name:r().trim(),provider:s(),connectionUrl:o().trim()}),n(""),a(""),await t())}return(()=>{var m=Lr();return u(m,c(A,{class:"space-y-3",get children(){return[Ar(),c(D,{placeholder:"Primary Database",get value(){return r()},onInput:d=>n(d.currentTarget.value)}),c(Te,{get value(){return s()},onChange:d=>i(d.currentTarget.value),get children(){return[kr(),Or(),Tr()]}}),c(D,{get placeholder(){return Q(()=>s()==="sqlite")()?"./data/app.sqlite":s()==="postgres"?"postgres://user:pass@host:5432/db":"mysql://user:pass@host:3306/db"},get value(){return o()},onInput:d=>a(d.currentTarget.value)}),c(E,{onClick:()=>{h()},children:"Create Connector"})]}}),null),u(m,c(A,{get children(){return[Er(),c(se,{get children(){return c(ie,{get children(){return[(()=>{var d=Ir(),l=d.firstChild;return u(l,c(C,{children:"Name"}),null),u(l,c(C,{children:"Provider"}),null),u(l,c(C,{children:"Target"}),null),u(l,c(C,{children:"Status"}),null),u(l,c(C,{children:"Actions"}),null),d})(),(()=>{var d=Nr();return u(d,c(K,{get each(){return e()||[]},children:l=>(()=>{var f=Mr();return u(f,c(P,{get children(){return[(()=>{var g=Rr();return u(g,()=>l.name),g})(),(()=>{var g=Dr();return u(g,()=>l.maskedConnectionUrl),g})()]}}),null),u(f,c(P,{get children(){return l.provider}}),null),u(f,c(P,{get children(){var g=Ur();return u(g,()=>l.target),g}}),null),u(f,c(P,{get children(){return c(le,{get label(){return l.lastValidationStatus||"unknown"},get tone(){return Q(()=>l.lastValidationStatus==="healthy")()?"success":l.lastValidationStatus==="failed"?"danger":"default"}})}}),null),u(f,c(P,{class:"space-x-2",get children(){return[c(E,{variant:"outline",onClick:()=>{qt(l.id).then(()=>t())},children:"Validate"}),c(E,{variant:"secondary",onClick:()=>{Bt(l.id,{enabled:!l.enabled}).then(()=>t())},get children(){return l.enabled?"Disable":"Enable"}})]}}),null),f})()})),d})()]}})}})]}}),null),m})()}var Vr=p('Create API Key'),Br=p("Free"),qr=p(" Pro"),Fr=p(" Business"),Kr=p(" Enterprise"),Jr=p('API Keys'),Hr=p(""),Gr=p(" "),Wr=p(''),Qr=p('
Generated once
'),Xr=p("
"),Yr=p('
Last used: '),zr=p(""),Zr=p('');function en(){const[e,{refetch:t}]=F(Ft),[r,n]=y(""),[s,i]=y("pro"),[o,a]=y(null);async function h(){if(!r().trim())return;const m=await Kt({name:r().trim(),plan:s()});a(m.data.key),n(""),await t()}return(()=>{var m=Wr();return u(m,c(A,{class:"space-y-3",get children(){return[Vr(),c(D,{placeholder:"production-client",get value(){return r()},onInput:d=>n(d.currentTarget.value)}),c(Te,{get value(){return s()},onChange:d=>i(d.currentTarget.value),get children(){return[Br(),qr(),Fr(),Kr()]}}),c(E,{onClick:()=>{h()},children:"Create Key"}),c(ye,{get when(){return o()},children:d=>(()=>{var l=Qr(),f=l.firstChild,g=f.nextSibling;return u(g,d),l})()})]}}),null),u(m,c(A,{get children(){return[Jr(),c(se,{get children(){return c(ie,{get children(){return[(()=>{var d=Hr(),l=d.firstChild;return u(l,c(C,{children:"Name"}),null),u(l,c(C,{children:"Plan"}),null),u(l,c(C,{children:"Prefix"}),null),u(l,c(C,{children:"Status"}),null),u(l,c(C,{children:"Action"}),null),d})(),(()=>{var d=Gr();return u(d,c(K,{get each(){return e()||[]},children:l=>(()=>{var f=Zr();return u(f,c(P,{get children(){return[(()=>{var g=Xr();return u(g,()=>l.name),g})(),(()=>{var g=Yr();return g.firstChild,u(g,()=>l.lastUsedAt||"never",null),g})()]}}),null),u(f,c(P,{get children(){return l.plan}}),null),u(f,c(P,{get children(){var g=zr();return u(g,()=>l.keyPrefix),g}}),null),u(f,c(P,{get children(){return c(le,{get label(){return l.enabled?"enabled":"disabled"},get tone(){return l.enabled?"success":"danger"}})}}),null),u(f,c(P,{get children(){return c(E,{variant:"secondary",onClick:()=>{Jt(l.id,{enabled:!l.enabled}).then(()=>t())},get children(){return l.enabled?"Disable":"Enable"}})}}),null),f})()})),d})()]}})}})]}}),null),m})()}var tn=p(""),rn=p('
'),nn=p('
Users'),oe=p('
'),ln=p('
Services'),sn=p('
DB Connectors'),an=p('
Monthly Requests'),on=p('
Hourly Request Trend (24h)'),cn=p('Incidents By Severity'),un=p('Traffic (Umami + Client Events)'),dn=p(''),fn=p('Top Client Event Paths'),pn=p('
Ops + Traffic Analytics Refresh
'),gn=p('
Umami API not configured or unavailable. Showing local client events.');function _e(e){const t=Math.max(1,...e.values.map(r=>r.value));return(()=>{var r=tn();return u(r,c(K,{get each(){return e.values},children:n=>(()=>{var s=rn(),i=s.firstChild,o=i.firstChild,a=o.nextSibling,h=i.nextSibling,m=h.firstChild;return u(o,()=>n.label),u(a,()=>n.value),L(d=>Ct(m,"width",`${n.value/t*100}%`)),s})()})),r})()}function hn(){const[e,{refetch:t}]=F(()=>Yt()),[r,{refetch:n}]=F(()=>zt()),s=async()=>{await t(),await n()};return(()=>{var i=pn(),o=i.firstChild,a=o.firstChild,h=a.nextSibling,m=o.nextSibling,d=m.nextSibling;return h.$$click=()=>{s()},u(m,c(A,{get children(){return[nn(),(()=>{var l=oe();return u(l,()=>e()?.data?.counts?.users??0),l})()]}}),null),u(m,c(A,{get children(){return[ln(),(()=>{var l=oe();return u(l,()=>e()?.data?.counts?.services??0),l})()]}}),null),u(m,c(A,{get children(){return[sn(),(()=>{var l=oe();return u(l,()=>e()?.data?.counts?.databases??0),l})()]}}),null),u(m,c(A,{get children(){return[an(),(()=>{var l=oe();return u(l,()=>e()?.data?.requests?.total??0),l})()]}}),null),u(d,c(A,{class:"space-y-3",get children(){return[on(),c(_e,{get values(){return(e()?.data?.requests?.hourly||[]).map(l=>({label:String(l.bucket).slice(11,16),value:Number(l.value||0)}))}})]}}),null),u(d,c(A,{class:"space-y-3",get children(){return[cn(),c(_e,{get values(){return Object.entries(e()?.data?.incidentsBySeverity||{}).map(([l,f])=>({label:l,value:Number(f)}))}})]}}),null),u(i,c(A,{class:"space-y-3",get children(){return[un(),c(ye,{get when(){return r()?.data?.umami?.enabled},get fallback(){return gn()},get children(){var l=dn();return u(l,()=>JSON.stringify(r()?.data?.umami||{},null,2)),l}}),(()=>{var l=fn();return l.firstChild,u(l,c(_e,{get values(){return(r()?.data?.clientEvents||[]).map(f=>({label:String(f.path),value:Number(f.count||0)}))}}),null),l})()]}}),null),i})()}Oe(["click"]);var mn=p('
Create User Invite'),bn=p("Viewer (default)"),yn=p('Users'),$n=p(""),vn=p(" "),xn=p(''),wn=p("
"),_n=p('Invite created
Invite token:
Temporary password: '),Sn=p(""),Cn=p('
'),Pn=p('
');function An(){const[e,{refetch:t}]=F(Ht),[r]=F(He),[n,s]=y(""),[i,o]=y(""),[a,h]=y(null);async function m(){if(!n().trim())return;const d=await Gt({email:n().trim(),roleIds:i()?[i()]:[]});h(d),s(""),await t()}return(()=>{var d=xn();return u(d,c(A,{class:"space-y-3",get children(){return[mn(),c(D,{placeholder:"teammate@example.com",get value(){return n()},onInput:l=>s(l.currentTarget.value)}),c(Te,{get value(){return i()},onChange:l=>o(l.currentTarget.value),get children(){return[bn(),c(K,{get each(){return r()||[]},children:l=>(()=>{var f=wn();return u(f,()=>l.name),L(()=>f.value=l.id),f})()})]}}),c(E,{onClick:()=>{m()},children:"Create User"}),c(ye,{get when(){return a()},children:l=>(()=>{var f=_n(),g=f.firstChild,b=g.nextSibling,x=b.firstChild,V=x.nextSibling,U=b.nextSibling,T=U.firstChild,O=T.nextSibling;return u(V,()=>l().data?.inviteToken),u(O,()=>l().data?.temporaryPass),f})()})]}}),null),u(d,c(A,{get children(){return[yn(),c(se,{get children(){return c(ie,{get children(){return[(()=>{var l=$n(),f=l.firstChild;return u(f,c(C,{children:"Email"}),null),u(f,c(C,{children:"Roles"}),null),u(f,c(C,{children:"Status"}),null),u(f,c(C,{children:"Actions"}),null),l})(),(()=>{var l=vn();return u(l,c(K,{get each(){return e()||[]},children:f=>(()=>{var g=Pn();return u(g,c(P,{get children(){return[(()=>{var b=Sn();return u(b,()=>f.email),b})(),(()=>{var b=Cn();return u(b,()=>f.id),b})()]}}),null),u(g,c(P,{get children(){return f.roles.join(", ")||"-"}}),null),u(g,c(P,{get children(){return c(le,{get label(){return f.enabled?"enabled":"disabled"},get tone(){return f.enabled?"success":"danger"}})}}),null),u(g,c(P,{class:"space-x-2",get children(){return[c(E,{variant:"secondary",onClick:()=>{Me(f.id,{enabled:!f.enabled}).then(()=>t())},get children(){return f.enabled?"Disable":"Enable"}}),c(E,{variant:"outline",onClick:()=>{Me(f.id,{resetPassword:!0}).then(()=>t())},children:"Reset Pass"})]}}),null),g})()})),l})()]}})}})]}}),null),d})()}var kn=p('Create Role'),On=p(''),Tn=p('
Roles'),En=p(""),In=p(" "),Nn=p(''),Ln=p('
'),Rn=p(""),Dn=p('
'),Un=p(""),Mn=p(''),jn=p('system');function Vn(){const[e,{refetch:t}]=F(He),[r]=F(Xt),[n,s]=y(""),[i,o]=y([]);function a(m){o(d=>d.includes(m)?d.filter(l=>l!==m):[...d,m])}async function h(){n().trim()&&(await Wt({name:n().trim(),permissionCodes:i()}),s(""),o([]),await t())}return(()=>{var m=Nn();return u(m,c(A,{class:"space-y-3",get children(){return[kn(),c(D,{placeholder:"Ops Manager",get value(){return n()},onInput:d=>s(d.currentTarget.value)}),(()=>{var d=On();return u(d,c(K,{get each(){return r()||[]},children:l=>(()=>{var f=Ln(),g=f.firstChild,b=g.nextSibling;return g.addEventListener("change",()=>a(l.code)),u(b,()=>l.code),L(()=>g.checked=i().includes(l.code)),f})()})),d})(),c(E,{onClick:()=>{h()},children:"Create Role"})]}}),null),u(m,c(A,{get children(){return[Tn(),c(se,{get children(){return c(ie,{get children(){return[(()=>{var d=En(),l=d.firstChild;return u(l,c(C,{children:"Name"}),null),u(l,c(C,{children:"Slug"}),null),u(l,c(C,{children:"Permissions"}),null),u(l,c(C,{children:"Status"}),null),u(l,c(C,{children:"Action"}),null),d})(),(()=>{var d=In();return u(d,c(K,{get each(){return e()||[]},children:l=>(()=>{var f=Mn();return u(f,c(P,{get children(){return[(()=>{var g=Rn();return u(g,()=>l.name),g})(),(()=>{var g=Dn();return u(g,()=>l.description),g})()]}}),null),u(f,c(P,{get children(){return[(()=>{var g=Un();return u(g,()=>l.slug),g})(),Q(()=>Q(()=>!!l.isSystem)()&&jn())]}}),null),u(f,c(P,{class:"max-w-[320px] text-xs text-muted-foreground",get children(){return l.permissionCodes.join(", ")||"-"}}),null),u(f,c(P,{get children(){return c(le,{get label(){return l.enabled?"enabled":"disabled"},get tone(){return l.enabled?"success":"danger"}})}}),null),u(f,c(P,{get children(){return c(E,{variant:"secondary",onClick:()=>{Qt(l.id,{enabled:!l.enabled}).then(()=>t())},get children(){return l.enabled?"Disable":"Enable"}})}}),null),f})()})),d})()]}})}})]}}),null),m})()}var Bn=p('Runtime'),qn=p(' APwhy uses a Go backend with SQLite control-plane storage and modular SolidJS frontend.'),Fn=p('
Use Docker + Traefik + Umami stack from `deploy/docker-compose.yml` for production self-hosting.'),Kn=p('
Security'),Jn=p(' Owner bootstrap is one-time. After first user exists, registration is closed and login-only flow is enforced.'),Hn=p('
Role permissions control access to services, keys, users, roles, and analytics APIs.'),Gn=p('
');function Wn(){return(()=>{var e=Gn();return u(e,c(A,{class:"space-y-2",get children(){return[Bn(),qn(),Fn()]}}),null),u(e,c(A,{class:"space-y-2",get children(){return[Kn(),Jn(),Hn()]}}),null),e})()}var Qn=p('
Loading APwhy...'),Xn=p('
Password reset required'),Yn=p(' Your account must set a new password before using all dashboard actions.'),zn=p('
'),Zn=p('
APwhy
API Routing + Management Hub Green-focused, self-hosted control plane with auth bootstrap, RBAC, gateway, and analytics.
'),el=p("");function tl(){const[e,t]=y(!0),[r,n]=y(!1),[s,i]=y(null),[o,a]=y("analytics"),[h,m]=y(""),d=[{id:"analytics",label:"Analytics"},{id:"services",label:"Services"},{id:"databases",label:"Databases"},{id:"keys",label:"API Keys"},{id:"users",label:"Users"},{id:"roles",label:"Roles"},{id:"settings",label:"Settings"}];async function l(){t(!0);try{const b=await Tt();if(n(b.registrationOpen),!b.registrationOpen)try{const x=await Ue();i(x)}catch{i(null)}}finally{t(!1)}}Ee(()=>{l()}),Ee(()=>{s()&&Zt("dashboard_tab",o(),{tab:o()}).catch(()=>{})});async function f(){await Nt(),i(null)}async function g(){if(!h().trim())return;await Lt(h().trim());const b=await Ue();i(b),m("")}return e()?Qn():r()?c(ur,{onComplete:l}):s()?(()=>{var b=Zn(),x=b.firstChild,V=x.firstChild,U=V.firstChild,T=U.firstChild,O=T.nextSibling,k=O.firstChild,M=k.firstChild,G=M.nextSibling,Z=U.nextSibling,ee=V.nextSibling;return u(M,()=>s()?.email),u(G,()=>s()?.roles.join(", ")),u(O,c(E,{variant:"outline",onClick:()=>{f()},children:"Sign Out"}),null),u(Z,c(K,{each:d,children:w=>(()=>{var $=el();return $.$$click=()=>a(w.id),u($,()=>w.label),L(()=>X($,`rounded-md border px-3 py-1.5 text-xs font-semibold ${o()===w.id?"border-primary bg-primary/20 text-primary":"border-border bg-secondary/40 text-foreground"}`)),$})()})),u(x,c(ye,{get when(){return s()?.forcePasswordReset},get children(){return c(A,{class:"mt-4 space-y-3 border-amber-500/45 bg-amber-500/10",get children(){return[Xn(),Yn(),(()=>{var w=zn();return u(w,c(D,{type:"password",placeholder:"New password",get value(){return h()},onInput:$=>m($.currentTarget.value)}),null),u(w,c(E,{onClick:()=>{g()},children:"Save"}),null),w})()]}})}}),ee),u(ee,c(dt,{get children(){return[c(W,{get when(){return o()==="analytics"},get children(){return c(hn,{})}}),c(W,{get when(){return o()==="services"},get children(){return c(Cr,{})}}),c(W,{get when(){return o()==="databases"},get children(){return c(jr,{})}}),c(W,{get when(){return o()==="keys"},get children(){return c(en,{})}}),c(W,{get when(){return o()==="users"},get children(){return c(An,{})}}),c(W,{get when(){return o()==="roles"},get children(){return c(Vn,{})}}),c(W,{get when(){return o()==="settings"},get children(){return c(Wn,{})}})]}})),b})():c(ir,{onLoggedIn:async()=>l()})}Oe(["click"]);vt(()=>c(tl,{}),document.getElementById("root"));
diff --git a/app/backend/internal/api/static/assets/manrope-cyrillic-400-normal-BMzJvInZ.woff2 b/app/backend/internal/api/static/assets/manrope-cyrillic-400-normal-BMzJvInZ.woff2
deleted file mode 100644
index 642e90f..0000000
Binary files a/app/backend/internal/api/static/assets/manrope-cyrillic-400-normal-BMzJvInZ.woff2 and /dev/null differ
diff --git a/app/backend/internal/api/static/assets/manrope-cyrillic-400-normal-Dvx59UGC.woff b/app/backend/internal/api/static/assets/manrope-cyrillic-400-normal-Dvx59UGC.woff
deleted file mode 100644
index 10cee4d..0000000
Binary files a/app/backend/internal/api/static/assets/manrope-cyrillic-400-normal-Dvx59UGC.woff and /dev/null differ
diff --git a/app/backend/internal/api/static/assets/manrope-cyrillic-600-normal-DvRl3Mj-.woff2 b/app/backend/internal/api/static/assets/manrope-cyrillic-600-normal-DvRl3Mj-.woff2
deleted file mode 100644
index 6b4ac49..0000000
Binary files a/app/backend/internal/api/static/assets/manrope-cyrillic-600-normal-DvRl3Mj-.woff2 and /dev/null differ
diff --git a/app/backend/internal/api/static/assets/manrope-cyrillic-600-normal-It4mZcQk.woff b/app/backend/internal/api/static/assets/manrope-cyrillic-600-normal-It4mZcQk.woff
deleted file mode 100644
index 10ebb9f..0000000
Binary files a/app/backend/internal/api/static/assets/manrope-cyrillic-600-normal-It4mZcQk.woff and /dev/null differ
diff --git a/app/backend/internal/api/static/assets/manrope-cyrillic-700-normal-7JNVKxyl.woff b/app/backend/internal/api/static/assets/manrope-cyrillic-700-normal-7JNVKxyl.woff
deleted file mode 100644
index 8f29ea1..0000000
Binary files a/app/backend/internal/api/static/assets/manrope-cyrillic-700-normal-7JNVKxyl.woff and /dev/null differ
diff --git a/app/backend/internal/api/static/assets/manrope-cyrillic-700-normal-Dw_fZAg2.woff2 b/app/backend/internal/api/static/assets/manrope-cyrillic-700-normal-Dw_fZAg2.woff2
deleted file mode 100644
index 3156c54..0000000
Binary files a/app/backend/internal/api/static/assets/manrope-cyrillic-700-normal-Dw_fZAg2.woff2 and /dev/null differ
diff --git a/app/backend/internal/api/static/assets/manrope-greek-400-normal-CM4qok81.woff2 b/app/backend/internal/api/static/assets/manrope-greek-400-normal-CM4qok81.woff2
deleted file mode 100644
index c7f3d55..0000000
Binary files a/app/backend/internal/api/static/assets/manrope-greek-400-normal-CM4qok81.woff2 and /dev/null differ
diff --git a/app/backend/internal/api/static/assets/manrope-greek-400-normal-DuX9RsAR.woff b/app/backend/internal/api/static/assets/manrope-greek-400-normal-DuX9RsAR.woff
deleted file mode 100644
index 517d2b3..0000000
Binary files a/app/backend/internal/api/static/assets/manrope-greek-400-normal-DuX9RsAR.woff and /dev/null differ
diff --git a/app/backend/internal/api/static/assets/manrope-greek-600-normal-BoRV6lzK.woff2 b/app/backend/internal/api/static/assets/manrope-greek-600-normal-BoRV6lzK.woff2
deleted file mode 100644
index 4e80f6b..0000000
Binary files a/app/backend/internal/api/static/assets/manrope-greek-600-normal-BoRV6lzK.woff2 and /dev/null differ
diff --git a/app/backend/internal/api/static/assets/manrope-greek-600-normal-CF2i9ZRY.woff b/app/backend/internal/api/static/assets/manrope-greek-600-normal-CF2i9ZRY.woff
deleted file mode 100644
index 022a8bf..0000000
Binary files a/app/backend/internal/api/static/assets/manrope-greek-600-normal-CF2i9ZRY.woff and /dev/null differ
diff --git a/app/backend/internal/api/static/assets/manrope-greek-700-normal-CHUG9PD8.woff2 b/app/backend/internal/api/static/assets/manrope-greek-700-normal-CHUG9PD8.woff2
deleted file mode 100644
index 8f704ea..0000000
Binary files a/app/backend/internal/api/static/assets/manrope-greek-700-normal-CHUG9PD8.woff2 and /dev/null differ
diff --git a/app/backend/internal/api/static/assets/manrope-greek-700-normal-DyfsrCpP.woff b/app/backend/internal/api/static/assets/manrope-greek-700-normal-DyfsrCpP.woff
deleted file mode 100644
index c220933..0000000
Binary files a/app/backend/internal/api/static/assets/manrope-greek-700-normal-DyfsrCpP.woff and /dev/null differ
diff --git a/app/backend/internal/api/static/assets/manrope-latin-400-normal-8tf8FM3T.woff b/app/backend/internal/api/static/assets/manrope-latin-400-normal-8tf8FM3T.woff
deleted file mode 100644
index f312fbb..0000000
Binary files a/app/backend/internal/api/static/assets/manrope-latin-400-normal-8tf8FM3T.woff and /dev/null differ
diff --git a/app/backend/internal/api/static/assets/manrope-latin-400-normal-PaqtzbVb.woff2 b/app/backend/internal/api/static/assets/manrope-latin-400-normal-PaqtzbVb.woff2
deleted file mode 100644
index 7eaaa95..0000000
Binary files a/app/backend/internal/api/static/assets/manrope-latin-400-normal-PaqtzbVb.woff2 and /dev/null differ
diff --git a/app/backend/internal/api/static/assets/manrope-latin-600-normal-4f0koTD-.woff2 b/app/backend/internal/api/static/assets/manrope-latin-600-normal-4f0koTD-.woff2
deleted file mode 100644
index 7cc09a3..0000000
Binary files a/app/backend/internal/api/static/assets/manrope-latin-600-normal-4f0koTD-.woff2 and /dev/null differ
diff --git a/app/backend/internal/api/static/assets/manrope-latin-600-normal-BqgrALkZ.woff b/app/backend/internal/api/static/assets/manrope-latin-600-normal-BqgrALkZ.woff
deleted file mode 100644
index 3fe76a6..0000000
Binary files a/app/backend/internal/api/static/assets/manrope-latin-600-normal-BqgrALkZ.woff and /dev/null differ
diff --git a/app/backend/internal/api/static/assets/manrope-latin-700-normal-BZp_XxE4.woff2 b/app/backend/internal/api/static/assets/manrope-latin-700-normal-BZp_XxE4.woff2
deleted file mode 100644
index 84f7af5..0000000
Binary files a/app/backend/internal/api/static/assets/manrope-latin-700-normal-BZp_XxE4.woff2 and /dev/null differ
diff --git a/app/backend/internal/api/static/assets/manrope-latin-700-normal-DGRFkw-m.woff b/app/backend/internal/api/static/assets/manrope-latin-700-normal-DGRFkw-m.woff
deleted file mode 100644
index 6000ec7..0000000
Binary files a/app/backend/internal/api/static/assets/manrope-latin-700-normal-DGRFkw-m.woff and /dev/null differ
diff --git a/app/backend/internal/api/static/assets/manrope-latin-ext-400-normal-C-X6QNXX.woff b/app/backend/internal/api/static/assets/manrope-latin-ext-400-normal-C-X6QNXX.woff
deleted file mode 100644
index add2e3c..0000000
Binary files a/app/backend/internal/api/static/assets/manrope-latin-ext-400-normal-C-X6QNXX.woff and /dev/null differ
diff --git a/app/backend/internal/api/static/assets/manrope-latin-ext-400-normal-CMDvPJRp.woff2 b/app/backend/internal/api/static/assets/manrope-latin-ext-400-normal-CMDvPJRp.woff2
deleted file mode 100644
index a56d446..0000000
Binary files a/app/backend/internal/api/static/assets/manrope-latin-ext-400-normal-CMDvPJRp.woff2 and /dev/null differ
diff --git a/app/backend/internal/api/static/assets/manrope-latin-ext-600-normal-_gBojHdJ.woff2 b/app/backend/internal/api/static/assets/manrope-latin-ext-600-normal-_gBojHdJ.woff2
deleted file mode 100644
index 9ceb22a..0000000
Binary files a/app/backend/internal/api/static/assets/manrope-latin-ext-600-normal-_gBojHdJ.woff2 and /dev/null differ
diff --git a/app/backend/internal/api/static/assets/manrope-latin-ext-600-normal-u5Pl7hTU.woff b/app/backend/internal/api/static/assets/manrope-latin-ext-600-normal-u5Pl7hTU.woff
deleted file mode 100644
index 4ba5d43..0000000
Binary files a/app/backend/internal/api/static/assets/manrope-latin-ext-600-normal-u5Pl7hTU.woff and /dev/null differ
diff --git a/app/backend/internal/api/static/assets/manrope-latin-ext-700-normal-DYOwVNan.woff2 b/app/backend/internal/api/static/assets/manrope-latin-ext-700-normal-DYOwVNan.woff2
deleted file mode 100644
index 5e6b5ae..0000000
Binary files a/app/backend/internal/api/static/assets/manrope-latin-ext-700-normal-DYOwVNan.woff2 and /dev/null differ
diff --git a/app/backend/internal/api/static/assets/manrope-latin-ext-700-normal-eVCcYqtJ.woff b/app/backend/internal/api/static/assets/manrope-latin-ext-700-normal-eVCcYqtJ.woff
deleted file mode 100644
index 655902d..0000000
Binary files a/app/backend/internal/api/static/assets/manrope-latin-ext-700-normal-eVCcYqtJ.woff and /dev/null differ
diff --git a/app/backend/internal/api/static/assets/manrope-vietnamese-400-normal-D7E_mLGF.woff b/app/backend/internal/api/static/assets/manrope-vietnamese-400-normal-D7E_mLGF.woff
deleted file mode 100644
index 7f9ff87..0000000
Binary files a/app/backend/internal/api/static/assets/manrope-vietnamese-400-normal-D7E_mLGF.woff and /dev/null differ
diff --git a/app/backend/internal/api/static/assets/manrope-vietnamese-400-normal-DHb3EETF.woff2 b/app/backend/internal/api/static/assets/manrope-vietnamese-400-normal-DHb3EETF.woff2
deleted file mode 100644
index 3b2938e..0000000
Binary files a/app/backend/internal/api/static/assets/manrope-vietnamese-400-normal-DHb3EETF.woff2 and /dev/null differ
diff --git a/app/backend/internal/api/static/assets/manrope-vietnamese-600-normal-C1J5PCl_.woff2 b/app/backend/internal/api/static/assets/manrope-vietnamese-600-normal-C1J5PCl_.woff2
deleted file mode 100644
index 8714f1d..0000000
Binary files a/app/backend/internal/api/static/assets/manrope-vietnamese-600-normal-C1J5PCl_.woff2 and /dev/null differ
diff --git a/app/backend/internal/api/static/assets/manrope-vietnamese-600-normal-lA7a_7Ok.woff b/app/backend/internal/api/static/assets/manrope-vietnamese-600-normal-lA7a_7Ok.woff
deleted file mode 100644
index 84df44d..0000000
Binary files a/app/backend/internal/api/static/assets/manrope-vietnamese-600-normal-lA7a_7Ok.woff and /dev/null differ
diff --git a/app/backend/internal/api/static/assets/manrope-vietnamese-700-normal-CUqMx5-1.woff2 b/app/backend/internal/api/static/assets/manrope-vietnamese-700-normal-CUqMx5-1.woff2
deleted file mode 100644
index 0d36914..0000000
Binary files a/app/backend/internal/api/static/assets/manrope-vietnamese-700-normal-CUqMx5-1.woff2 and /dev/null differ
diff --git a/app/backend/internal/api/static/assets/manrope-vietnamese-700-normal-pt65Fn2Z.woff b/app/backend/internal/api/static/assets/manrope-vietnamese-700-normal-pt65Fn2Z.woff
deleted file mode 100644
index e43daa0..0000000
Binary files a/app/backend/internal/api/static/assets/manrope-vietnamese-700-normal-pt65Fn2Z.woff and /dev/null differ
diff --git a/app/backend/internal/api/static/index.html b/app/backend/internal/api/static/index.html
deleted file mode 100644
index 360fca5..0000000
--- a/app/backend/internal/api/static/index.html
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
-
-
- APwhy
-
-
-
-
-
-
-
diff --git a/app/backend/internal/api/system.go b/app/backend/internal/api/system.go
new file mode 100644
index 0000000..fac4de0
--- /dev/null
+++ b/app/backend/internal/api/system.go
@@ -0,0 +1,321 @@
+package api
+
+import (
+ "bufio"
+ "context"
+ "io"
+ "net/http"
+ "os"
+ "runtime"
+ "strconv"
+ "strings"
+ "syscall"
+ "time"
+
+ "containr/internal/docker"
+
+ "github.com/docker/docker/api/types/registry"
+ "github.com/gin-gonic/gin"
+)
+
+type upgradeStatusResponse struct {
+ ImageRef string `json:"image_ref"`
+ Registry string `json:"registry"`
+ DockerAvailable bool `json:"docker_available"`
+ Installed bool `json:"installed"`
+ Digest string `json:"digest,omitempty"`
+ Size int64 `json:"size,omitempty"`
+ AuthConfigured bool `json:"auth_configured"`
+ Message string `json:"message"`
+}
+
+type hostMonitoringResponse struct {
+ Hostname string `json:"hostname"`
+ OS string `json:"os"`
+ Architecture string `json:"architecture"`
+ CPU hostCPU `json:"cpu"`
+ Memory hostMemory `json:"memory"`
+ Storage hostStorage `json:"storage"`
+ Load SystemLoad `json:"load"`
+ UptimeSeconds int64 `json:"uptime_seconds"`
+ DockerAvailable bool `json:"docker_available"`
+ Docker map[string]any `json:"docker,omitempty"`
+ CollectedAt string `json:"collected_at"`
+}
+
+type hostCPU struct {
+ Cores int `json:"cores"`
+}
+
+type hostMemory struct {
+ Total uint64 `json:"total"`
+ Used uint64 `json:"used"`
+ Available uint64 `json:"available"`
+ UsagePct float64 `json:"usage_percent"`
+}
+
+type hostStorage struct {
+ Path string `json:"path"`
+ Total uint64 `json:"total"`
+ Used uint64 `json:"used"`
+ Available uint64 `json:"available"`
+ UsagePct float64 `json:"usage_percent"`
+}
+
+func handleGetUpgradeStatus(c *gin.Context) {
+ imageRef := configuredUpgradeImageRef()
+ dockerClient, _ := c.Get("docker_client")
+ client, _ := dockerClient.(*docker.Client)
+
+ response := upgradeStatusResponse{
+ ImageRef: imageRef,
+ Registry: registryHost(imageRef),
+ DockerAvailable: client != nil,
+ AuthConfigured: upgradeRegistryAuthConfigured(),
+ Message: "Upgrade image auto-detected",
+ }
+
+ if imageRef == "" {
+ response.Message = "Set CONTAINR_UPGRADE_IMAGE_REF or CONTAINR_IMAGE_REF to enable image pulls"
+ c.JSON(http.StatusOK, response)
+ return
+ }
+
+ if client == nil {
+ response.Message = "Docker client unavailable"
+ c.JSON(http.StatusOK, response)
+ return
+ }
+
+ ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
+ defer cancel()
+
+ info, err := client.GetImageInfo(ctx, imageRef)
+ if err != nil {
+ response.Message = "Image not installed locally"
+ c.JSON(http.StatusOK, response)
+ return
+ }
+
+ response.Installed = true
+ response.Digest = info.Digest
+ response.Size = info.Size
+ response.Message = "Image installed locally"
+ c.JSON(http.StatusOK, response)
+}
+
+func handlePullUpgradeImage(c *gin.Context) {
+ imageRef := configuredUpgradeImageRef()
+ if imageRef == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Set CONTAINR_UPGRADE_IMAGE_REF or CONTAINR_IMAGE_REF before pulling"})
+ return
+ }
+
+ dockerClient, _ := c.Get("docker_client")
+ client, ok := dockerClient.(*docker.Client)
+ if !ok || client == nil {
+ c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Docker client unavailable"})
+ return
+ }
+
+ ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Minute)
+ defer cancel()
+
+ reader, err := client.PullImage(ctx, imageRef, upgradeRegistryAuth())
+ if err != nil {
+ c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
+ return
+ }
+ defer reader.Close()
+ _, _ = io.Copy(io.Discard, reader)
+
+ info, infoErr := client.GetImageInfo(ctx, imageRef)
+ response := gin.H{
+ "image_ref": imageRef,
+ "registry": registryHost(imageRef),
+ "message": "Image pull completed",
+ }
+ if infoErr == nil {
+ response["digest"] = info.Digest
+ response["size"] = info.Size
+ }
+
+ c.JSON(http.StatusOK, response)
+}
+
+func handleGetHostMonitoring(c *gin.Context) {
+ hostname, _ := os.Hostname()
+ memory := readHostMemory()
+ storage := readHostStorage("/")
+ load := readSystemLoad()
+ uptime := readSystemUptime()
+
+ response := hostMonitoringResponse{
+ Hostname: hostname,
+ OS: runtime.GOOS,
+ Architecture: runtime.GOARCH,
+ CPU: hostCPU{Cores: runtime.NumCPU()},
+ Memory: memory,
+ Storage: storage,
+ Load: load,
+ UptimeSeconds: uptime,
+ CollectedAt: time.Now().UTC().Format(time.RFC3339),
+ }
+
+ if dockerClient, ok := c.Get("docker_client"); ok {
+ if client, ok := dockerClient.(*docker.Client); ok && client != nil {
+ ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
+ defer cancel()
+ if info, err := client.GetSystemInfo(ctx); err == nil {
+ response.DockerAvailable = true
+ response.Docker = map[string]any{
+ "containers": info.Containers,
+ "images": info.Images,
+ "driver": info.Driver,
+ "server": info.ServerVersion,
+ }
+ }
+ }
+ }
+
+ c.JSON(http.StatusOK, response)
+}
+
+func configuredUpgradeImageRef() string {
+ for _, key := range []string{"CONTAINR_UPGRADE_IMAGE_REF", "CONTAINR_IMAGE_REF", "DOCKER_IMAGE_REF"} {
+ if value := strings.TrimSpace(os.Getenv(key)); value != "" {
+ return value
+ }
+ }
+ return ""
+}
+
+func registryHost(imageRef string) string {
+ parts := strings.Split(imageRef, "/")
+ if len(parts) > 1 && (strings.Contains(parts[0], ".") || strings.Contains(parts[0], ":")) {
+ return parts[0]
+ }
+ if strings.HasPrefix(imageRef, "ghcr.io/") {
+ return "ghcr.io"
+ }
+ return "docker.io"
+}
+
+func upgradeRegistryAuthConfigured() bool {
+ auth := upgradeRegistryAuth()
+ return strings.TrimSpace(auth.Username) != "" || strings.TrimSpace(auth.Password) != "" || strings.TrimSpace(auth.Auth) != ""
+}
+
+func upgradeRegistryAuth() registry.AuthConfig {
+ username := firstConfigured("GITHUB_CONTAINER_REGISTRY_USER", "GHCR_USERNAME", "DOCKER_REGISTRY_USERNAME")
+ password := firstConfigured("GITHUB_CONTAINER_REGISTRY_TOKEN", "GHCR_TOKEN", "DOCKER_REGISTRY_PASSWORD")
+ return registry.AuthConfig{
+ Username: username,
+ Password: password,
+ }
+}
+
+func firstConfigured(keys ...string) string {
+ for _, key := range keys {
+ if value := strings.TrimSpace(os.Getenv(key)); value != "" {
+ return value
+ }
+ }
+ return ""
+}
+
+func readHostMemory() hostMemory {
+ values := map[string]uint64{}
+ file, err := os.Open("/proc/meminfo")
+ if err != nil {
+ return hostMemory{}
+ }
+ defer file.Close()
+
+ scanner := bufio.NewScanner(file)
+ for scanner.Scan() {
+ fields := strings.Fields(scanner.Text())
+ if len(fields) < 2 {
+ continue
+ }
+ key := strings.TrimSuffix(fields[0], ":")
+ value, parseErr := strconv.ParseUint(fields[1], 10, 64)
+ if parseErr != nil {
+ continue
+ }
+ values[key] = value * 1024
+ }
+
+ total := values["MemTotal"]
+ available := values["MemAvailable"]
+ used := uint64(0)
+ if total > available {
+ used = total - available
+ }
+
+ usage := 0.0
+ if total > 0 {
+ usage = float64(used) / float64(total) * 100
+ }
+
+ return hostMemory{
+ Total: total,
+ Used: used,
+ Available: available,
+ UsagePct: usage,
+ }
+}
+
+func readHostStorage(path string) hostStorage {
+ var stat syscall.Statfs_t
+ if err := syscall.Statfs(path, &stat); err != nil {
+ return hostStorage{Path: path}
+ }
+
+ total := stat.Blocks * uint64(stat.Bsize)
+ available := stat.Bavail * uint64(stat.Bsize)
+ used := total - available
+ usage := 0.0
+ if total > 0 {
+ usage = float64(used) / float64(total) * 100
+ }
+
+ return hostStorage{
+ Path: path,
+ Total: total,
+ Used: used,
+ Available: available,
+ UsagePct: usage,
+ }
+}
+
+func readSystemLoad() SystemLoad {
+ data, err := os.ReadFile("/proc/loadavg")
+ if err != nil {
+ return SystemLoad{}
+ }
+ fields := strings.Fields(string(data))
+ if len(fields) < 3 {
+ return SystemLoad{}
+ }
+ load1, _ := strconv.ParseFloat(fields[0], 64)
+ load5, _ := strconv.ParseFloat(fields[1], 64)
+ load15, _ := strconv.ParseFloat(fields[2], 64)
+ return SystemLoad{Load1M: load1, Load5M: load5, Load15M: load15}
+}
+
+func readSystemUptime() int64 {
+ data, err := os.ReadFile("/proc/uptime")
+ if err != nil {
+ return 0
+ }
+ fields := strings.Fields(string(data))
+ if len(fields) == 0 {
+ return 0
+ }
+ uptime, err := strconv.ParseFloat(fields[0], 64)
+ if err != nil {
+ return 0
+ }
+ return int64(uptime)
+}
diff --git a/app/backend/internal/api/templates.go b/app/backend/internal/api/templates.go
index c1fa5e9..ea91ddb 100644
--- a/app/backend/internal/api/templates.go
+++ b/app/backend/internal/api/templates.go
@@ -279,9 +279,16 @@ func handleCreateFromTemplate(c *gin.Context) {
serviceImage := resolveTemplateRuntimeImage(config.Runtime)
serviceCommand := strings.TrimSpace(config.StartCommand)
+ buildCommand := strings.TrimSpace(config.BuildCommand)
cpu, memory := defaultTemplateResources(serviceType)
serviceEnvironment := "production"
+ environmentID, err := getProjectEnvironmentID(db, projectID, serviceEnvironment)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to resolve service environment"})
+ return
+ }
+
serviceID := uuid.New()
now := time.Now()
@@ -294,18 +301,24 @@ func handleCreateFromTemplate(c *gin.Context) {
txQueries := queries.WithTx(tx)
err = txQueries.CreateServiceFromTemplate(ctx, sqlcdb.CreateServiceFromTemplateParams{
- ID: serviceID,
- ProjectID: projectID,
- Name: req.Name,
- Type: serviceType,
- Status: "stopped",
- Image: sql.NullString{String: serviceImage, Valid: true},
- Command: sql.NullString{String: serviceCommand, Valid: true},
- Environment: sql.NullString{String: serviceEnvironment, Valid: true},
- Cpu: sql.NullString{String: cpu, Valid: true},
- Memory: sql.NullString{String: memory, Valid: true},
- CreatedAt: sql.NullTime{Time: now, Valid: true},
- UpdatedAt: sql.NullTime{Time: now, Valid: true},
+ ID: serviceID,
+ ProjectID: projectID,
+ Name: req.Name,
+ EnvironmentID: environmentID,
+ ServiceType: serviceType,
+ SourceType: "template",
+ ImageName: sql.NullString{String: serviceImage, Valid: serviceImage != ""},
+ BuildCommand: sql.NullString{String: buildCommand, Valid: buildCommand != ""},
+ StartCommand: sql.NullString{String: serviceCommand, Valid: serviceCommand != ""},
+ Type: sql.NullString{String: serviceType, Valid: true},
+ Status: sql.NullString{String: "stopped", Valid: true},
+ Image: sql.NullString{String: serviceImage, Valid: serviceImage != ""},
+ Command: sql.NullString{String: serviceCommand, Valid: serviceCommand != ""},
+ Environment: sql.NullString{String: serviceEnvironment, Valid: true},
+ Cpu: sql.NullString{String: cpu, Valid: true},
+ Memory: sql.NullString{String: memory, Valid: true},
+ CreatedAt: sql.NullTime{Time: now, Valid: true},
+ UpdatedAt: sql.NullTime{Time: now, Valid: true},
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create service from template"})
diff --git a/app/backend/internal/api/variables.go b/app/backend/internal/api/variables.go
index 32a169b..8a53dd9 100644
--- a/app/backend/internal/api/variables.go
+++ b/app/backend/internal/api/variables.go
@@ -9,6 +9,10 @@ import (
"github.com/google/uuid"
)
+// maskedSecretValue is the placeholder returned for secret values; on update
+// it means "keep the stored value" rather than overwriting with asterisks.
+const maskedSecretValue = "********"
+
type EnvironmentVariable struct {
ID uuid.UUID `json:"id" db:"id"`
ServiceID uuid.UUID `json:"service_id" db:"service_id"`
@@ -91,7 +95,7 @@ func handleGetVariables(c *gin.Context) {
return
}
if v.IsSecret {
- v.Value = "********"
+ v.Value = maskedSecretValue
}
variables = append(variables, v)
}
@@ -143,6 +147,25 @@ func handleUpdateVariables(c *gin.Context) {
return
}
+ // Snapshot stored secrets so a client echoing back the masked placeholder
+ // does not destroy the real value.
+ storedSecrets := map[string]string{}
+ secretRows, err := db.(*database.DB).Query(
+ `SELECT key, value FROM environment_variables WHERE service_id = $1 AND is_secret = TRUE`,
+ serviceID,
+ )
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load existing variables"})
+ return
+ }
+ for secretRows.Next() {
+ var k, v string
+ if err := secretRows.Scan(&k, &v); err == nil {
+ storedSecrets[k] = v
+ }
+ }
+ secretRows.Close()
+
tx, err := db.(*database.DB).Begin()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to begin transaction"})
@@ -158,11 +181,17 @@ func handleUpdateVariables(c *gin.Context) {
now := time.Now()
for _, v := range req.Variables {
+ value := v.Value
+ if v.IsSecret && value == maskedSecretValue {
+ if stored, ok := storedSecrets[v.Key]; ok {
+ value = stored
+ }
+ }
varID := uuid.New()
_, err = tx.Exec(
`INSERT INTO environment_variables (id, service_id, key, value, is_secret, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
- varID, serviceID, v.Key, v.Value, v.IsSecret, now, now,
+ varID, serviceID, v.Key, value, v.IsSecret, now, now,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to insert variable: " + v.Key})
@@ -198,7 +227,7 @@ func handleUpdateVariables(c *gin.Context) {
continue
}
if v.IsSecret {
- v.Value = "********"
+ v.Value = maskedSecretValue
}
variables = append(variables, v)
}
diff --git a/app/backend/internal/apwhy/analytics/umami.go b/app/backend/internal/apwhy/analytics/umami.go
deleted file mode 100644
index 5ed8d56..0000000
--- a/app/backend/internal/apwhy/analytics/umami.go
+++ /dev/null
@@ -1,74 +0,0 @@
-package analytics
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "net/http"
- "net/url"
- "time"
-)
-
-type Client struct {
- BaseURL string
- APIKey string
- WebsiteID string
- HTTP *http.Client
-}
-
-func NewClient(baseURL, apiKey, websiteID string) *Client {
- return &Client{
- BaseURL: baseURL,
- APIKey: apiKey,
- WebsiteID: websiteID,
- HTTP: &http.Client{
- Timeout: 10 * time.Second,
- },
- }
-}
-
-func (c *Client) Enabled() bool {
- return c.BaseURL != "" && c.APIKey != "" && c.WebsiteID != ""
-}
-
-func (c *Client) FetchTraffic(ctx context.Context, from, to time.Time) (map[string]any, error) {
- if !c.Enabled() {
- return map[string]any{
- "enabled": false,
- }, nil
- }
-
- u, err := url.Parse(fmt.Sprintf("%s/api/websites/%s/stats", c.BaseURL, c.WebsiteID))
- if err != nil {
- return nil, err
- }
-
- query := u.Query()
- query.Set("startAt", fmt.Sprintf("%d", from.UnixMilli()))
- query.Set("endAt", fmt.Sprintf("%d", to.UnixMilli()))
- u.RawQuery = query.Encode()
-
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
- if err != nil {
- return nil, err
- }
- req.Header.Set("Authorization", "Bearer "+c.APIKey)
- req.Header.Set("Accept", "application/json")
-
- res, err := c.HTTP.Do(req)
- if err != nil {
- return nil, err
- }
- defer res.Body.Close()
-
- if res.StatusCode >= 400 {
- return nil, fmt.Errorf("umami returned %d", res.StatusCode)
- }
-
- var payload map[string]any
- if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
- return nil, err
- }
- payload["enabled"] = true
- return payload, nil
-}
diff --git a/app/backend/internal/apwhy/api/server.go b/app/backend/internal/apwhy/api/server.go
deleted file mode 100644
index eea5286..0000000
--- a/app/backend/internal/apwhy/api/server.go
+++ /dev/null
@@ -1,2694 +0,0 @@
-package api
-
-import (
- "context"
- "database/sql"
- "embed"
- "encoding/json"
- "errors"
- "fmt"
- "io"
- "io/fs"
- "mime"
- "net/http"
- "net/url"
- "os"
- "path"
- "path/filepath"
- "sort"
- "strconv"
- "strings"
- "sync"
- "time"
-
- "containr/internal/apwhy/analytics"
- "containr/internal/apwhy/auth"
- "containr/internal/apwhy/config"
- "containr/internal/apwhy/gateway"
- "containr/internal/apwhy/storage"
-)
-
-//go:embed static/*
-var staticFS embed.FS
-
-type Server struct {
- Store *storage.Store
- Cfg config.Config
- Umami *analytics.Client
- proxyClient *http.Client
-
- rpmMu sync.Mutex
- rpmUsage map[string]rpmWindow
-
- loginMu sync.Mutex
- loginAttempts map[string]loginWindow
-}
-
-type rpmWindow struct {
- WindowStart time.Time
- Count int
-}
-
-type loginWindow struct {
- WindowStart time.Time
- Count int
-}
-
-type authContext struct {
- UserID string
- Email string
- Enabled bool
- ForcePasswordReset bool
- Roles []string
- Permissions map[string]bool
- SessionID string
-}
-
-func NewServer(store *storage.Store, cfg config.Config) *Server {
- return &Server{
- Store: store,
- Cfg: cfg,
- Umami: analytics.NewClient(cfg.UmamiBaseURL, cfg.UmamiAPIKey, cfg.UmamiWebsiteID),
- proxyClient: &http.Client{
- Timeout: cfg.DefaultServiceTimeout,
- },
- rpmUsage: map[string]rpmWindow{},
- loginAttempts: map[string]loginWindow{},
- }
-}
-
-func (s *Server) Handler() http.Handler {
- mux := http.NewServeMux()
-
- mux.HandleFunc("GET /health", s.handleHealth)
-
- mux.HandleFunc("GET /api/v1/bootstrap/status", s.handleBootstrapStatus)
- mux.HandleFunc("POST /api/v1/bootstrap/register-owner", s.handleBootstrapRegisterOwner)
-
- mux.HandleFunc("POST /api/v1/auth/login", s.handleAuthLogin)
- mux.HandleFunc("POST /api/v1/auth/logout", s.handleAuthLogout)
- mux.HandleFunc("POST /api/v1/auth/refresh", s.handleAuthRefresh)
- mux.HandleFunc("GET /api/v1/auth/me", s.withAuth("", true, s.handleAuthMe))
- mux.HandleFunc("POST /api/v1/auth/reset-password", s.withAuth("", true, s.handleAuthResetPassword))
-
- mux.HandleFunc("GET /api/v1/users", s.withAuth("users.read", false, s.handleUsersList))
- mux.HandleFunc("POST /api/v1/users", s.withAuth("users.write", false, s.handleUsersCreate))
- mux.HandleFunc("PATCH /api/v1/users/{id}", s.withAuth("users.write", false, s.handleUsersPatch))
-
- mux.HandleFunc("GET /api/v1/roles", s.withAuth("roles.read", false, s.handleRolesList))
- mux.HandleFunc("POST /api/v1/roles", s.withAuth("roles.write", false, s.handleRolesCreate))
- mux.HandleFunc("PATCH /api/v1/roles/{id}", s.withAuth("roles.write", false, s.handleRolesPatch))
- mux.HandleFunc("GET /api/v1/permissions", s.withAuth("roles.read", false, s.handlePermissionsList))
-
- mux.HandleFunc("GET /api/v1/services", s.withAuth("services.read", false, s.handleServicesList))
- mux.HandleFunc("POST /api/v1/services", s.withAuth("services.write", false, s.handleServicesCreate))
- mux.HandleFunc("PATCH /api/v1/services/{id}", s.withAuth("services.write", false, s.handleServicesPatch))
- mux.HandleFunc("POST /api/v1/services/{id}/validate", s.withAuth("services.write", false, s.handleServicesValidate))
-
- mux.HandleFunc("GET /api/v1/databases", s.withAuth("databases.read", false, s.handleDatabasesList))
- mux.HandleFunc("POST /api/v1/databases", s.withAuth("databases.write", false, s.handleDatabasesCreate))
- mux.HandleFunc("PATCH /api/v1/databases/{id}", s.withAuth("databases.write", false, s.handleDatabasesPatch))
- mux.HandleFunc("POST /api/v1/databases/{id}/validate", s.withAuth("databases.write", false, s.handleDatabasesValidate))
-
- mux.HandleFunc("GET /api/v1/keys", s.withAuth("keys.read", false, s.handleKeysList))
- mux.HandleFunc("POST /api/v1/keys", s.withAuth("keys.write", false, s.handleKeysCreate))
- mux.HandleFunc("PATCH /api/v1/keys/{id}", s.withAuth("keys.write", false, s.handleKeysPatch))
-
- mux.HandleFunc("GET /api/v1/analytics/ops", s.withAuth("analytics.read", false, s.handleAnalyticsOps))
- mux.HandleFunc("GET /api/v1/analytics/traffic", s.withAuth("analytics.read", false, s.handleAnalyticsTraffic))
- mux.HandleFunc("POST /api/v1/analytics/events", s.withAuth("", true, s.handleAnalyticsEvents))
-
- mux.HandleFunc("/", s.handleGatewayOrUI)
-
- return s.withSecurityHeaders(s.withLogging(mux))
-}
-
-func (s *Server) withSecurityHeaders(next http.Handler) http.Handler {
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("X-Frame-Options", "DENY")
- w.Header().Set("X-Content-Type-Options", "nosniff")
- w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
- next.ServeHTTP(w, r)
- })
-}
-
-func (s *Server) withLogging(next http.Handler) http.Handler {
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- start := time.Now()
- next.ServeHTTP(w, r)
- _ = s.logMetric("http_request", 1, map[string]any{
- "method": r.Method,
- "path": r.URL.Path,
- "ms": time.Since(start).Milliseconds(),
- })
- })
-}
-
-func (s *Server) withAuth(requiredPermission string, allowForceReset bool, handler func(http.ResponseWriter, *http.Request, *authContext)) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- ctx, err := s.resolveAuth(r)
- if err != nil {
- writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "Authentication required")
- return
- }
- if !ctx.Enabled {
- writeError(w, http.StatusForbidden, "USER_DISABLED", "User account is disabled")
- return
- }
- if ctx.ForcePasswordReset && !allowForceReset {
- writeError(w, http.StatusPreconditionRequired, "PASSWORD_RESET_REQUIRED", "Password reset required")
- return
- }
- if requiredPermission != "" && !ctx.Permissions[requiredPermission] {
- writeError(w, http.StatusForbidden, "FORBIDDEN", "Insufficient permissions")
- return
- }
- handler(w, r, ctx)
- }
-}
-
-func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
- writeJSON(w, http.StatusOK, map[string]any{
- "ok": true,
- "data": map[string]any{
- "status": "ok",
- "name": "APwhy",
- "database": "sqlite",
- "generatedAt": time.Now().UTC().Format(time.RFC3339),
- },
- })
-}
-
-func (s *Server) handleBootstrapStatus(w http.ResponseWriter, r *http.Request) {
- hasUsers := s.hasUsers(r.Context())
- writeJSON(w, http.StatusOK, map[string]any{
- "ok": true,
- "data": map[string]any{
- "hasUsers": hasUsers,
- "registrationOpen": !hasUsers,
- },
- })
-}
-
-func (s *Server) handleBootstrapRegisterOwner(w http.ResponseWriter, r *http.Request) {
- if s.hasUsers(r.Context()) {
- writeError(w, http.StatusForbidden, "BOOTSTRAP_CLOSED", "Owner registration is closed")
- return
- }
-
- var input struct {
- Email string `json:"email"`
- Password string `json:"password"`
- }
- if err := decodeJSON(r.Body, &input); err != nil {
- writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error())
- return
- }
-
- input.Email = strings.ToLower(strings.TrimSpace(input.Email))
- if input.Email == "" || !strings.Contains(input.Email, "@") {
- writeError(w, http.StatusBadRequest, "INVALID_EMAIL", "Valid email is required")
- return
- }
-
- hash, err := auth.HashPassword(input.Password)
- if err != nil {
- writeError(w, http.StatusBadRequest, "INVALID_PASSWORD", err.Error())
- return
- }
-
- now := storage.NowISO()
- userID, _ := auth.RandomID("usr")
-
- tx, err := s.Store.DB.BeginTx(r.Context(), nil)
- if err != nil {
- writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to start transaction")
- return
- }
- defer tx.Rollback()
-
- _, err = tx.ExecContext(r.Context(), `
- INSERT INTO users (id, email, password_hash, enabled, force_password_reset, created_at, updated_at)
- VALUES (?, ?, ?, 1, 0, ?, ?)
- `, userID, input.Email, hash, now, now)
- if err != nil {
- writeError(w, http.StatusBadRequest, "USER_CREATE_FAILED", "Email already exists")
- return
- }
-
- ownerRoleID := ""
- if err := tx.QueryRowContext(r.Context(), `SELECT id FROM roles WHERE slug = 'owner'`).Scan(&ownerRoleID); err != nil {
- writeError(w, http.StatusInternalServerError, "ROLE_MISSING", "Owner role not found")
- return
- }
-
- _, err = tx.ExecContext(r.Context(), `INSERT INTO user_roles (user_id, role_id, created_at) VALUES (?, ?, ?)`, userID, ownerRoleID, now)
- if err != nil {
- writeError(w, http.StatusInternalServerError, "ROLE_ASSIGN_FAILED", "Failed to assign owner role")
- return
- }
-
- if err := tx.Commit(); err != nil {
- writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to commit owner registration")
- return
- }
-
- s.audit(r.Context(), userID, "owner.registered", "user", userID, map[string]any{"email": input.Email})
-
- writeJSON(w, http.StatusCreated, map[string]any{
- "ok": true,
- "data": map[string]any{
- "id": userID,
- "email": input.Email,
- "roles": []string{"owner"},
- },
- })
-}
-
-func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) {
- var input struct {
- Email string `json:"email"`
- Password string `json:"password"`
- }
- if err := decodeJSON(r.Body, &input); err != nil {
- writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error())
- return
- }
-
- email := strings.ToLower(strings.TrimSpace(input.Email))
- if email == "" || input.Password == "" {
- writeError(w, http.StatusBadRequest, "INVALID_CREDENTIALS", "Email and password are required")
- return
- }
-
- if s.isLoginRateLimited(r, email) {
- writeError(w, http.StatusTooManyRequests, "LOGIN_RATE_LIMITED", "Too many login attempts")
- return
- }
-
- var user struct {
- ID string
- Email string
- PasswordHash string
- Enabled int
- ForcePasswordReset int
- }
- err := s.Store.DB.QueryRowContext(r.Context(), `
- SELECT id, email, password_hash, enabled, force_password_reset
- FROM users
- WHERE email = ?
- `, email).Scan(&user.ID, &user.Email, &user.PasswordHash, &user.Enabled, &user.ForcePasswordReset)
- if err != nil {
- s.markLoginAttempt(r, email)
- writeError(w, http.StatusUnauthorized, "INVALID_CREDENTIALS", "Invalid credentials")
- return
- }
- if user.Enabled != 1 {
- writeError(w, http.StatusForbidden, "USER_DISABLED", "User account is disabled")
- return
- }
-
- ok, err := auth.VerifyPassword(user.PasswordHash, input.Password)
- if err != nil || !ok {
- s.markLoginAttempt(r, email)
- writeError(w, http.StatusUnauthorized, "INVALID_CREDENTIALS", "Invalid credentials")
- return
- }
-
- s.clearLoginAttempts(r, email)
-
- sessionID, accessToken, refreshToken, err := s.createSession(r.Context(), user.ID)
- if err != nil {
- writeError(w, http.StatusInternalServerError, "SESSION_CREATE_FAILED", "Failed to create login session")
- return
- }
-
- now := storage.NowISO()
- _, _ = s.Store.DB.ExecContext(r.Context(), `UPDATE users SET last_login_at = ?, updated_at = ? WHERE id = ?`, now, now, user.ID)
- _ = sessionID
-
- s.setAuthCookies(w, accessToken, refreshToken)
-
- ctx, _ := s.resolveAuthFromTokenHash(r.Context(), auth.HashToken(accessToken))
- if ctx == nil {
- writeError(w, http.StatusInternalServerError, "SESSION_RESOLVE_FAILED", "Failed to load user profile")
- return
- }
-
- s.audit(r.Context(), user.ID, "auth.login", "session", sessionID, map[string]any{"email": user.Email})
-
- writeJSON(w, http.StatusOK, map[string]any{
- "ok": true,
- "data": map[string]any{
- "user": map[string]any{
- "id": ctx.UserID,
- "email": ctx.Email,
- "roles": ctx.Roles,
- "permissions": keysOfMap(ctx.Permissions),
- "forcePasswordReset": ctx.ForcePasswordReset,
- },
- },
- })
-}
-
-func (s *Server) handleAuthLogout(w http.ResponseWriter, r *http.Request) {
- accessCookie, _ := r.Cookie(s.Cfg.SessionAccessCookie)
- refreshCookie, _ := r.Cookie(s.Cfg.SessionRefreshCookie)
-
- now := storage.NowISO()
- if accessCookie != nil {
- _, _ = s.Store.DB.ExecContext(r.Context(), `
- UPDATE sessions SET revoked_at = ?, updated_at = ? WHERE access_token_hash = ?
- `, now, now, auth.HashToken(accessCookie.Value))
- }
- if refreshCookie != nil {
- _, _ = s.Store.DB.ExecContext(r.Context(), `
- UPDATE sessions SET revoked_at = ?, updated_at = ? WHERE refresh_token_hash = ?
- `, now, now, auth.HashToken(refreshCookie.Value))
- }
-
- s.clearAuthCookies(w)
- writeJSON(w, http.StatusOK, map[string]any{"ok": true})
-}
-
-func (s *Server) handleAuthRefresh(w http.ResponseWriter, r *http.Request) {
- refreshCookie, err := r.Cookie(s.Cfg.SessionRefreshCookie)
- if err != nil || strings.TrimSpace(refreshCookie.Value) == "" {
- writeError(w, http.StatusUnauthorized, "REFRESH_MISSING", "Refresh token is missing")
- return
- }
-
- refreshHash := auth.HashToken(refreshCookie.Value)
-
- var session struct {
- ID string
- UserID string
- ExpiresAt string
- RevokedAt sql.NullString
- }
- err = s.Store.DB.QueryRowContext(r.Context(), `
- SELECT id, user_id, refresh_expires_at, revoked_at
- FROM sessions
- WHERE refresh_token_hash = ?
- `, refreshHash).Scan(&session.ID, &session.UserID, &session.ExpiresAt, &session.RevokedAt)
- if err != nil {
- writeError(w, http.StatusUnauthorized, "INVALID_REFRESH", "Refresh token is invalid")
- return
- }
- if session.RevokedAt.Valid {
- writeError(w, http.StatusUnauthorized, "INVALID_REFRESH", "Refresh token is revoked")
- return
- }
- expiresAt, _ := time.Parse(time.RFC3339, session.ExpiresAt)
- if time.Now().After(expiresAt) {
- writeError(w, http.StatusUnauthorized, "REFRESH_EXPIRED", "Refresh token has expired")
- return
- }
-
- accessToken, _ := auth.RandomToken(32)
- newRefreshToken, _ := auth.RandomToken(48)
- accessHash := auth.HashToken(accessToken)
- refreshHashNew := auth.HashToken(newRefreshToken)
-
- accessExp := time.Now().Add(s.Cfg.AccessTokenTTL).UTC().Format(time.RFC3339)
- refreshExp := time.Now().Add(s.Cfg.RefreshTokenTTL).UTC().Format(time.RFC3339)
-
- _, err = s.Store.DB.ExecContext(r.Context(), `
- UPDATE sessions
- SET access_token_hash = ?, refresh_token_hash = ?, access_expires_at = ?, refresh_expires_at = ?, updated_at = ?
- WHERE id = ?
- `, accessHash, refreshHashNew, accessExp, refreshExp, storage.NowISO(), session.ID)
- if err != nil {
- writeError(w, http.StatusInternalServerError, "REFRESH_FAILED", "Failed to refresh session")
- return
- }
-
- s.setAuthCookies(w, accessToken, newRefreshToken)
- writeJSON(w, http.StatusOK, map[string]any{"ok": true})
-}
-
-func (s *Server) handleAuthMe(w http.ResponseWriter, r *http.Request, ctx *authContext) {
- writeJSON(w, http.StatusOK, map[string]any{
- "ok": true,
- "data": map[string]any{
- "id": ctx.UserID,
- "email": ctx.Email,
- "roles": ctx.Roles,
- "permissions": keysOfMap(ctx.Permissions),
- "forcePasswordReset": ctx.ForcePasswordReset,
- },
- })
-}
-
-func (s *Server) handleAuthResetPassword(w http.ResponseWriter, r *http.Request, ctx *authContext) {
- var input struct {
- NewPassword string `json:"newPassword"`
- }
- if err := decodeJSON(r.Body, &input); err != nil {
- writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error())
- return
- }
- hash, err := auth.HashPassword(input.NewPassword)
- if err != nil {
- writeError(w, http.StatusBadRequest, "INVALID_PASSWORD", err.Error())
- return
- }
-
- now := storage.NowISO()
- _, err = s.Store.DB.ExecContext(r.Context(), `
- UPDATE users SET password_hash = ?, force_password_reset = 0, updated_at = ? WHERE id = ?
- `, hash, now, ctx.UserID)
- if err != nil {
- writeError(w, http.StatusInternalServerError, "PASSWORD_UPDATE_FAILED", "Failed to update password")
- return
- }
-
- s.audit(r.Context(), ctx.UserID, "auth.reset_password", "user", ctx.UserID, nil)
- writeJSON(w, http.StatusOK, map[string]any{"ok": true})
-}
-
-func (s *Server) handleUsersList(w http.ResponseWriter, r *http.Request, ctx *authContext) {
- rows, err := s.Store.DB.QueryContext(r.Context(), `
- SELECT u.id, u.email, u.enabled, u.force_password_reset, u.created_at, u.updated_at,
- COALESCE(GROUP_CONCAT(r.slug), '') AS role_slugs
- FROM users u
- LEFT JOIN user_roles ur ON ur.user_id = u.id
- LEFT JOIN roles r ON r.id = ur.role_id
- GROUP BY u.id
- ORDER BY u.created_at DESC
- `)
- if err != nil {
- writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to list users")
- return
- }
- defer rows.Close()
-
- items := make([]map[string]any, 0)
- for rows.Next() {
- var id, email, createdAt, updatedAt, roleSlugs string
- var enabled, forceReset int
- if err := rows.Scan(&id, &email, &enabled, &forceReset, &createdAt, &updatedAt, &roleSlugs); err != nil {
- continue
- }
- roles := []string{}
- if strings.TrimSpace(roleSlugs) != "" {
- roles = strings.Split(roleSlugs, ",")
- }
- items = append(items, map[string]any{
- "id": id,
- "email": email,
- "enabled": enabled == 1,
- "forcePasswordReset": forceReset == 1,
- "roles": roles,
- "createdAt": createdAt,
- "updatedAt": updatedAt,
- })
- }
- writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": items})
- _ = ctx
-}
-
-func (s *Server) handleUsersCreate(w http.ResponseWriter, r *http.Request, ctx *authContext) {
- var input struct {
- Email string `json:"email"`
- RoleIDs []string `json:"roleIds"`
- RoleSlugs []string `json:"roleSlugs"`
- }
- if err := decodeJSON(r.Body, &input); err != nil {
- writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error())
- return
- }
- email := strings.ToLower(strings.TrimSpace(input.Email))
- if email == "" || !strings.Contains(email, "@") {
- writeError(w, http.StatusBadRequest, "INVALID_EMAIL", "Valid email is required")
- return
- }
-
- roleIDs, err := s.resolveRoleIDs(r.Context(), input.RoleIDs, input.RoleSlugs)
- if err != nil {
- writeError(w, http.StatusBadRequest, "INVALID_ROLE", err.Error())
- return
- }
- if len(roleIDs) == 0 {
- viewerID, err := s.roleIDBySlug(r.Context(), "viewer")
- if err == nil {
- roleIDs = []string{viewerID}
- }
- }
-
- tempPassword, _ := auth.RandomPassword(16)
- passwordHash, err := auth.HashPassword(tempPassword)
- if err != nil {
- writeError(w, http.StatusInternalServerError, "PASSWORD_ERROR", "Failed to generate temporary password")
- return
- }
-
- inviteToken, _ := auth.RandomToken(32)
- inviteHash := auth.HashToken(inviteToken)
- now := storage.NowISO()
- expiresAt := time.Now().Add(72 * time.Hour).UTC().Format(time.RFC3339)
-
- userID, _ := auth.RandomID("usr")
- inviteID, _ := auth.RandomID("inv")
-
- tx, err := s.Store.DB.BeginTx(r.Context(), nil)
- if err != nil {
- writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to start transaction")
- return
- }
- defer tx.Rollback()
-
- _, err = tx.ExecContext(r.Context(), `
- INSERT INTO users (id, email, password_hash, enabled, force_password_reset, created_at, updated_at)
- VALUES (?, ?, ?, 1, 1, ?, ?)
- `, userID, email, passwordHash, now, now)
- if err != nil {
- writeError(w, http.StatusBadRequest, "USER_CREATE_FAILED", "Email already exists")
- return
- }
-
- for _, roleID := range roleIDs {
- _, err = tx.ExecContext(r.Context(), `INSERT INTO user_roles (user_id, role_id, created_at) VALUES (?, ?, ?)`, userID, roleID, now)
- if err != nil {
- writeError(w, http.StatusInternalServerError, "ROLE_ASSIGN_FAILED", "Failed to assign roles")
- return
- }
- }
-
- _, err = tx.ExecContext(r.Context(), `
- INSERT INTO invites (id, user_id, email, token_hash, expires_at, created_by, created_at)
- VALUES (?, ?, ?, ?, ?, ?, ?)
- `, inviteID, userID, email, inviteHash, expiresAt, ctx.UserID, now)
- if err != nil {
- writeError(w, http.StatusInternalServerError, "INVITE_FAILED", "Failed to create invite")
- return
- }
-
- if err := tx.Commit(); err != nil {
- writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to commit user creation")
- return
- }
-
- s.audit(r.Context(), ctx.UserID, "users.create", "user", userID, map[string]any{"email": email, "roleCount": len(roleIDs)})
-
- writeJSON(w, http.StatusCreated, map[string]any{
- "ok": true,
- "data": map[string]any{
- "id": userID,
- "email": email,
- "inviteToken": inviteToken,
- "temporaryPass": tempPassword,
- "expiresAt": expiresAt,
- },
- })
-}
-
-func (s *Server) handleUsersPatch(w http.ResponseWriter, r *http.Request, ctx *authContext) {
- id := r.PathValue("id")
- if id == "" {
- writeError(w, http.StatusBadRequest, "INVALID_ID", "User ID is required")
- return
- }
-
- var input struct {
- Enabled *bool `json:"enabled"`
- RoleIDs []string `json:"roleIds"`
- RoleSlugs []string `json:"roleSlugs"`
- ResetPassword bool `json:"resetPassword"`
- }
- if err := decodeJSON(r.Body, &input); err != nil {
- writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error())
- return
- }
-
- now := storage.NowISO()
- tx, err := s.Store.DB.BeginTx(r.Context(), nil)
- if err != nil {
- writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to start transaction")
- return
- }
- defer tx.Rollback()
-
- if input.Enabled != nil {
- _, err = tx.ExecContext(r.Context(), `UPDATE users SET enabled = ?, updated_at = ? WHERE id = ?`, boolToInt(*input.Enabled), now, id)
- if err != nil {
- writeError(w, http.StatusInternalServerError, "USER_UPDATE_FAILED", "Failed to update user")
- return
- }
- }
-
- if len(input.RoleIDs) > 0 || len(input.RoleSlugs) > 0 {
- roleIDs, err := s.resolveRoleIDs(r.Context(), input.RoleIDs, input.RoleSlugs)
- if err != nil {
- writeError(w, http.StatusBadRequest, "INVALID_ROLE", err.Error())
- return
- }
- _, err = tx.ExecContext(r.Context(), `DELETE FROM user_roles WHERE user_id = ?`, id)
- if err != nil {
- writeError(w, http.StatusInternalServerError, "ROLE_UPDATE_FAILED", "Failed to update user roles")
- return
- }
- for _, roleID := range roleIDs {
- _, err = tx.ExecContext(r.Context(), `INSERT INTO user_roles (user_id, role_id, created_at) VALUES (?, ?, ?)`, id, roleID, now)
- if err != nil {
- writeError(w, http.StatusInternalServerError, "ROLE_UPDATE_FAILED", "Failed to assign role")
- return
- }
- }
- }
-
- response := map[string]any{}
- if input.ResetPassword {
- tempPassword, _ := auth.RandomPassword(16)
- hash, err := auth.HashPassword(tempPassword)
- if err != nil {
- writeError(w, http.StatusInternalServerError, "PASSWORD_ERROR", "Failed to generate password")
- return
- }
- _, err = tx.ExecContext(r.Context(), `
- UPDATE users SET password_hash = ?, force_password_reset = 1, updated_at = ? WHERE id = ?
- `, hash, now, id)
- if err != nil {
- writeError(w, http.StatusInternalServerError, "PASSWORD_RESET_FAILED", "Failed to reset password")
- return
- }
- response["temporaryPass"] = tempPassword
- }
-
- if err := tx.Commit(); err != nil {
- writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to commit user patch")
- return
- }
-
- s.audit(r.Context(), ctx.UserID, "users.patch", "user", id, input)
- writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": response})
-}
-
-func (s *Server) handleRolesList(w http.ResponseWriter, r *http.Request, ctx *authContext) {
- rows, err := s.Store.DB.QueryContext(r.Context(), `
- SELECT r.id, r.name, r.slug, r.description, r.is_system, r.enabled,
- COALESCE(GROUP_CONCAT(p.code), '') AS permission_codes,
- r.created_at, r.updated_at
- FROM roles r
- LEFT JOIN role_permissions rp ON rp.role_id = r.id
- LEFT JOIN permissions p ON p.id = rp.permission_id
- GROUP BY r.id
- ORDER BY r.created_at ASC
- `)
- if err != nil {
- writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to list roles")
- return
- }
- defer rows.Close()
-
- roles := []map[string]any{}
- for rows.Next() {
- var id, name, slug, description, permissionCodes, createdAt, updatedAt string
- var isSystem, enabled int
- if err := rows.Scan(&id, &name, &slug, &description, &isSystem, &enabled, &permissionCodes, &createdAt, &updatedAt); err != nil {
- continue
- }
- codes := []string{}
- if strings.TrimSpace(permissionCodes) != "" {
- codes = strings.Split(permissionCodes, ",")
- sort.Strings(codes)
- }
- roles = append(roles, map[string]any{
- "id": id,
- "name": name,
- "slug": slug,
- "description": description,
- "isSystem": isSystem == 1,
- "enabled": enabled == 1,
- "permissionCodes": codes,
- "createdAt": createdAt,
- "updatedAt": updatedAt,
- })
- }
- writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": roles})
- _ = ctx
-}
-
-func (s *Server) handleRolesCreate(w http.ResponseWriter, r *http.Request, ctx *authContext) {
- var input struct {
- Name string `json:"name"`
- Slug string `json:"slug"`
- Description string `json:"description"`
- PermissionCodes []string `json:"permissionCodes"`
- }
- if err := decodeJSON(r.Body, &input); err != nil {
- writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error())
- return
- }
-
- name := strings.TrimSpace(input.Name)
- if name == "" {
- writeError(w, http.StatusBadRequest, "INVALID_NAME", "Role name is required")
- return
- }
- slug := slugify(firstNonEmpty(input.Slug, input.Name), "role")
- id, _ := auth.RandomID("role")
- now := storage.NowISO()
-
- tx, err := s.Store.DB.BeginTx(r.Context(), nil)
- if err != nil {
- writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to start transaction")
- return
- }
- defer tx.Rollback()
-
- _, err = tx.ExecContext(r.Context(), `
- INSERT INTO roles (id, name, slug, description, is_system, enabled, created_at, updated_at)
- VALUES (?, ?, ?, ?, 0, 1, ?, ?)
- `, id, name, slug, strings.TrimSpace(input.Description), now, now)
- if err != nil {
- writeError(w, http.StatusBadRequest, "ROLE_CREATE_FAILED", "Role slug already exists")
- return
- }
-
- if err := s.replaceRolePermissionsTx(r.Context(), tx, id, input.PermissionCodes); err != nil {
- writeError(w, http.StatusBadRequest, "PERMISSION_UPDATE_FAILED", err.Error())
- return
- }
-
- if err := tx.Commit(); err != nil {
- writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to commit role creation")
- return
- }
-
- s.audit(r.Context(), ctx.UserID, "roles.create", "role", id, input)
- writeJSON(w, http.StatusCreated, map[string]any{"ok": true, "data": map[string]any{"id": id, "slug": slug}})
-}
-
-func (s *Server) handleRolesPatch(w http.ResponseWriter, r *http.Request, ctx *authContext) {
- id := r.PathValue("id")
- if id == "" {
- writeError(w, http.StatusBadRequest, "INVALID_ID", "Role ID is required")
- return
- }
-
- var input struct {
- Name *string `json:"name"`
- Description *string `json:"description"`
- Enabled *bool `json:"enabled"`
- PermissionCodes []string `json:"permissionCodes"`
- }
- if err := decodeJSON(r.Body, &input); err != nil {
- writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error())
- return
- }
-
- isSystem := 0
- if err := s.Store.DB.QueryRowContext(r.Context(), `SELECT is_system FROM roles WHERE id = ?`, id).Scan(&isSystem); err != nil {
- writeError(w, http.StatusNotFound, "ROLE_NOT_FOUND", "Role not found")
- return
- }
-
- now := storage.NowISO()
- tx, err := s.Store.DB.BeginTx(r.Context(), nil)
- if err != nil {
- writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to start transaction")
- return
- }
- defer tx.Rollback()
-
- if input.Name != nil {
- _, err = tx.ExecContext(r.Context(), `UPDATE roles SET name = ?, updated_at = ? WHERE id = ?`, strings.TrimSpace(*input.Name), now, id)
- if err != nil {
- writeError(w, http.StatusInternalServerError, "ROLE_UPDATE_FAILED", "Failed to update role name")
- return
- }
- }
- if input.Description != nil {
- _, err = tx.ExecContext(r.Context(), `UPDATE roles SET description = ?, updated_at = ? WHERE id = ?`, strings.TrimSpace(*input.Description), now, id)
- if err != nil {
- writeError(w, http.StatusInternalServerError, "ROLE_UPDATE_FAILED", "Failed to update role description")
- return
- }
- }
- if input.Enabled != nil {
- _, err = tx.ExecContext(r.Context(), `UPDATE roles SET enabled = ?, updated_at = ? WHERE id = ?`, boolToInt(*input.Enabled), now, id)
- if err != nil {
- writeError(w, http.StatusInternalServerError, "ROLE_UPDATE_FAILED", "Failed to update role status")
- return
- }
- }
-
- if input.PermissionCodes != nil {
- if isSystem == 1 {
- writeError(w, http.StatusBadRequest, "SYSTEM_ROLE_LOCKED", "System role permissions are locked")
- return
- }
- if err := s.replaceRolePermissionsTx(r.Context(), tx, id, input.PermissionCodes); err != nil {
- writeError(w, http.StatusBadRequest, "PERMISSION_UPDATE_FAILED", err.Error())
- return
- }
- }
-
- if err := tx.Commit(); err != nil {
- writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to commit role patch")
- return
- }
-
- s.audit(r.Context(), ctx.UserID, "roles.patch", "role", id, input)
- writeJSON(w, http.StatusOK, map[string]any{"ok": true})
-}
-
-func (s *Server) handlePermissionsList(w http.ResponseWriter, r *http.Request, ctx *authContext) {
- rows, err := s.Store.DB.QueryContext(r.Context(), `SELECT id, code, name, description FROM permissions ORDER BY code ASC`)
- if err != nil {
- writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to list permissions")
- return
- }
- defer rows.Close()
-
- items := []map[string]any{}
- for rows.Next() {
- var id, code, name, description string
- if err := rows.Scan(&id, &code, &name, &description); err != nil {
- continue
- }
- items = append(items, map[string]any{"id": id, "code": code, "name": name, "description": description})
- }
- writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": items})
- _ = ctx
-}
-
-func (s *Server) handleServicesList(w http.ResponseWriter, r *http.Request, ctx *authContext) {
- rows, err := s.Store.DB.QueryContext(r.Context(), `SELECT id, name, slug, upstream_url, route_prefix, health_path, upstream_auth_header, upstream_auth_value, internal_token, enabled, rpm_limit, monthly_quota, request_timeout_ms, last_validation_at, last_validation_status, last_validation_message, created_at, updated_at FROM services ORDER BY created_at DESC`)
- if err != nil {
- writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to list services")
- return
- }
- defer rows.Close()
- items := []map[string]any{}
- for rows.Next() {
- item, err := scanService(rows)
- if err == nil {
- items = append(items, item)
- }
- }
- writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": items})
- _ = ctx
-}
-
-func (s *Server) handleServicesCreate(w http.ResponseWriter, r *http.Request, ctx *authContext) {
- var input serviceInput
- if err := decodeJSON(r.Body, &input); err != nil {
- writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error())
- return
- }
- if strings.TrimSpace(input.Name) == "" {
- writeError(w, http.StatusBadRequest, "INVALID_NAME", "Service name is required")
- return
- }
- if strings.TrimSpace(input.UpstreamURL) == "" {
- writeError(w, http.StatusBadRequest, "INVALID_UPSTREAM", "Service upstream URL is required")
- return
- }
- routePrefix := normalizePathPrefix(input.RoutePrefix, "/"+slugify(input.Name, "service"))
- if routePrefix == "/" && !s.Cfg.AllowRootRoutePrefix {
- writeError(w, http.StatusBadRequest, "ROOT_ROUTE_DISABLED", "Route prefix '/' is disabled")
- return
- }
- if strings.HasPrefix(routePrefix, "/api/") {
- writeError(w, http.StatusBadRequest, "ROUTE_CONFLICT", "Route prefix conflicts with internal API")
- return
- }
-
- now := storage.NowISO()
- id, _ := auth.RandomID("svc")
- slug := slugify(firstNonEmpty(input.Slug, input.Name), "service")
-
- _, err := s.Store.DB.ExecContext(r.Context(), `
- INSERT INTO services (
- id, name, slug, upstream_url, route_prefix, health_path,
- upstream_auth_header, upstream_auth_value, internal_token,
- enabled, rpm_limit, monthly_quota, request_timeout_ms,
- created_at, updated_at
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
- `,
- id,
- strings.TrimSpace(input.Name),
- slug,
- strings.TrimSpace(input.UpstreamURL),
- routePrefix,
- normalizePathPrefix(input.HealthPath, "/health"),
- nullIfEmpty(input.UpstreamAuthHeader),
- nullIfEmpty(input.UpstreamAuthValue),
- nullIfEmpty(input.InternalToken),
- boolToInt(defaultBool(input.Enabled, true)),
- nullInt(input.RPMLimit),
- nullInt(input.MonthlyQuota),
- nullInt(input.RequestTimeoutMS),
- now,
- now,
- )
- if err != nil {
- writeError(w, http.StatusBadRequest, "SERVICE_CREATE_FAILED", "Service slug or route already exists")
- return
- }
-
- s.audit(r.Context(), ctx.UserID, "services.create", "service", id, input)
- writeJSON(w, http.StatusCreated, map[string]any{"ok": true, "data": map[string]any{"id": id, "slug": slug}})
-}
-
-func (s *Server) handleServicesPatch(w http.ResponseWriter, r *http.Request, ctx *authContext) {
- id := r.PathValue("id")
- if id == "" {
- writeError(w, http.StatusBadRequest, "INVALID_ID", "Service ID is required")
- return
- }
-
- current, err := s.getServiceByID(r.Context(), id)
- if err != nil {
- writeError(w, http.StatusNotFound, "SERVICE_NOT_FOUND", "Service not found")
- return
- }
-
- var patch serviceInput
- if err := decodeJSON(r.Body, &patch); err != nil {
- writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error())
- return
- }
-
- name := firstNonEmpty(patch.Name, asString(current["name"]))
- routePrefix := normalizePathPrefix(firstNonEmpty(patch.RoutePrefix, asString(current["routePrefix"])), "/")
- if routePrefix == "/" && !s.Cfg.AllowRootRoutePrefix {
- writeError(w, http.StatusBadRequest, "ROOT_ROUTE_DISABLED", "Route prefix '/' is disabled")
- return
- }
-
- _, err = s.Store.DB.ExecContext(r.Context(), `
- UPDATE services SET
- name = ?, slug = ?, upstream_url = ?, route_prefix = ?, health_path = ?,
- upstream_auth_header = ?, upstream_auth_value = ?, internal_token = ?, enabled = ?,
- rpm_limit = ?, monthly_quota = ?, request_timeout_ms = ?, updated_at = ?
- WHERE id = ?
- `,
- name,
- slugify(firstNonEmpty(patch.Slug, asString(current["slug"])), "service"),
- firstNonEmpty(patch.UpstreamURL, asString(current["upstreamUrl"])),
- routePrefix,
- normalizePathPrefix(firstNonEmpty(patch.HealthPath, asString(current["healthPath"])), "/health"),
- nullIfEmpty(firstNonEmpty(patch.UpstreamAuthHeader, asString(current["upstreamAuthHeader"]))),
- nullIfEmpty(firstNonEmpty(patch.UpstreamAuthValue, asString(current["upstreamAuthValue"]))),
- nullIfEmpty(firstNonEmpty(patch.InternalToken, asString(current["internalToken"]))),
- boolToInt(defaultBool(patch.Enabled, asBool(current["enabled"]))),
- nullInt(patch.RPMLimitOr(current["rpmLimit"])),
- nullInt(patch.MonthlyQuotaOr(current["monthlyQuota"])),
- nullInt(patch.TimeoutOr(current["requestTimeoutMs"])),
- storage.NowISO(),
- id,
- )
- if err != nil {
- writeError(w, http.StatusBadRequest, "SERVICE_UPDATE_FAILED", "Failed to update service")
- return
- }
- writeJSON(w, http.StatusOK, map[string]any{"ok": true})
- _ = ctx
-}
-
-func (s *Server) handleServicesValidate(w http.ResponseWriter, r *http.Request, ctx *authContext) {
- id := r.PathValue("id")
- service, err := s.getServiceByID(r.Context(), id)
- if err != nil {
- writeError(w, http.StatusNotFound, "SERVICE_NOT_FOUND", "Service not found")
- return
- }
-
- timeoutMS := intFromAny(service["requestTimeoutMs"], int(s.Cfg.DefaultServiceTimeout.Milliseconds()))
- client := &http.Client{Timeout: time.Duration(timeoutMS) * time.Millisecond}
- target := strings.TrimRight(asString(service["upstreamUrl"]), "/") + normalizePathPrefix(asString(service["healthPath"]), "/health")
-
- req, _ := http.NewRequestWithContext(r.Context(), http.MethodGet, target, nil)
- if header := asString(service["upstreamAuthHeader"]); header != "" {
- req.Header.Set(header, asString(service["upstreamAuthValue"]))
- }
- if token := asString(service["internalToken"]); token != "" {
- req.Header.Set(s.Cfg.ServiceTokenHeader, token)
- }
- res, err := client.Do(req)
- status := 0
- message := ""
- ok := false
- if err != nil {
- status = http.StatusBadGateway
- message = err.Error()
- } else {
- defer res.Body.Close()
- status = res.StatusCode
- message = fmt.Sprintf("HTTP %d", res.StatusCode)
- ok = res.StatusCode >= 200 && res.StatusCode < 300
- }
-
- validationStatus := "failed"
- if ok {
- validationStatus = "healthy"
- }
-
- _, _ = s.Store.DB.ExecContext(r.Context(), `
- UPDATE services
- SET last_validation_at = ?, last_validation_status = ?, last_validation_message = ?, updated_at = ?
- WHERE id = ?
- `, storage.NowISO(), validationStatus, message, storage.NowISO(), id)
-
- if !ok {
- s.logIncident(r.Context(), sql.NullString{String: id, Valid: true}, sql.NullString{}, "SERVICE_VALIDATION_FAILED", message, "medium", sql.NullInt64{Int64: int64(status), Valid: true})
- }
-
- writeJSON(w, http.StatusOK, map[string]any{"ok": true, "validation": map[string]any{"ok": ok, "status": status, "message": message}})
- _ = ctx
-}
-
-func (s *Server) handleDatabasesList(w http.ResponseWriter, r *http.Request, ctx *authContext) {
- rows, err := s.Store.DB.QueryContext(r.Context(), `
- SELECT id, name, slug, provider, connection_url, enabled, last_validation_at, last_validation_status, last_validation_message, created_at, updated_at
- FROM database_connections
- ORDER BY created_at DESC
- `)
- if err != nil {
- writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to list database connections")
- return
- }
- defer rows.Close()
-
- items := []map[string]any{}
- for rows.Next() {
- var id, name, slug, provider, connURL, createdAt, updatedAt string
- var enabled int
- var lastAt, lastStatus, lastMessage sql.NullString
- if err := rows.Scan(&id, &name, &slug, &provider, &connURL, &enabled, &lastAt, &lastStatus, &lastMessage, &createdAt, &updatedAt); err != nil {
- continue
- }
- items = append(items, map[string]any{
- "id": id,
- "name": name,
- "slug": slug,
- "provider": provider,
- "target": extractDatabaseTarget(provider, connURL),
- "maskedConnectionUrl": maskDatabaseURL(provider, connURL),
- "enabled": enabled == 1,
- "lastValidationAt": nullString(lastAt),
- "lastValidationStatus": nullString(lastStatus),
- "lastValidationMessage": nullString(lastMessage),
- "createdAt": createdAt,
- "updatedAt": updatedAt,
- })
- }
- writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": items})
- _ = ctx
-}
-
-func (s *Server) handleDatabasesCreate(w http.ResponseWriter, r *http.Request, ctx *authContext) {
- var input struct {
- Name string `json:"name"`
- Slug string `json:"slug"`
- Provider string `json:"provider"`
- ConnectionURL string `json:"connectionUrl"`
- Enabled *bool `json:"enabled"`
- }
- if err := decodeJSON(r.Body, &input); err != nil {
- writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error())
- return
- }
-
- provider := normalizeProvider(input.Provider)
- if provider == "" {
- writeError(w, http.StatusBadRequest, "INVALID_PROVIDER", "Provider must be sqlite, postgres, or mysql")
- return
- }
- if strings.TrimSpace(input.ConnectionURL) == "" {
- writeError(w, http.StatusBadRequest, "INVALID_CONNECTION", "Connection URL is required")
- return
- }
-
- now := storage.NowISO()
- id, _ := auth.RandomID("dbc")
- slug := slugify(firstNonEmpty(input.Slug, input.Name+"-"+provider), "database")
-
- _, err := s.Store.DB.ExecContext(r.Context(), `
- INSERT INTO database_connections (id, name, slug, provider, connection_url, enabled, created_at, updated_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
- `, id, strings.TrimSpace(input.Name), slug, provider, strings.TrimSpace(input.ConnectionURL), boolToInt(defaultBool(input.Enabled, true)), now, now)
- if err != nil {
- writeError(w, http.StatusBadRequest, "DATABASE_CREATE_FAILED", "Database connection slug already exists")
- return
- }
- writeJSON(w, http.StatusCreated, map[string]any{"ok": true, "data": map[string]any{"id": id, "slug": slug}})
- _ = ctx
-}
-
-func (s *Server) handleDatabasesPatch(w http.ResponseWriter, r *http.Request, ctx *authContext) {
- id := r.PathValue("id")
- if id == "" {
- writeError(w, http.StatusBadRequest, "INVALID_ID", "Database connection ID is required")
- return
- }
-
- var input struct {
- Name *string `json:"name"`
- Slug *string `json:"slug"`
- Provider *string `json:"provider"`
- ConnectionURL *string `json:"connectionUrl"`
- Enabled *bool `json:"enabled"`
- }
- if err := decodeJSON(r.Body, &input); err != nil {
- writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error())
- return
- }
-
- current, err := s.getDatabaseByID(r.Context(), id)
- if err != nil {
- writeError(w, http.StatusNotFound, "DATABASE_NOT_FOUND", "Database connection not found")
- return
- }
-
- provider := asString(current["provider"])
- if input.Provider != nil {
- norm := normalizeProvider(*input.Provider)
- if norm == "" {
- writeError(w, http.StatusBadRequest, "INVALID_PROVIDER", "Provider must be sqlite, postgres, or mysql")
- return
- }
- provider = norm
- }
-
- _, err = s.Store.DB.ExecContext(r.Context(), `
- UPDATE database_connections
- SET name = ?, slug = ?, provider = ?, connection_url = ?, enabled = ?, updated_at = ?
- WHERE id = ?
- `,
- firstNonEmptyPtr(input.Name, asString(current["name"])),
- slugify(firstNonEmptyPtr(input.Slug, asString(current["slug"])), "database"),
- provider,
- firstNonEmptyPtr(input.ConnectionURL, asString(current["connectionUrl"])),
- boolToInt(defaultBool(input.Enabled, asBool(current["enabled"]))),
- storage.NowISO(),
- id,
- )
- if err != nil {
- writeError(w, http.StatusBadRequest, "DATABASE_UPDATE_FAILED", "Failed to update database connection")
- return
- }
-
- writeJSON(w, http.StatusOK, map[string]any{"ok": true})
- _ = ctx
-}
-
-func (s *Server) handleDatabasesValidate(w http.ResponseWriter, r *http.Request, ctx *authContext) {
- id := r.PathValue("id")
- database, err := s.getDatabaseByID(r.Context(), id)
- if err != nil {
- writeError(w, http.StatusNotFound, "DATABASE_NOT_FOUND", "Database connection not found")
- return
- }
-
- provider := asString(database["provider"])
- connectionURL := asString(database["connectionUrl"])
- ok, status, message := s.validateDatabaseConnection(r.Context(), provider, connectionURL)
- state := "failed"
- if ok {
- state = "healthy"
- }
- _, _ = s.Store.DB.ExecContext(r.Context(), `
- UPDATE database_connections
- SET last_validation_at = ?, last_validation_status = ?, last_validation_message = ?, updated_at = ?
- WHERE id = ?
- `, storage.NowISO(), state, message, storage.NowISO(), id)
-
- if !ok {
- s.logIncident(r.Context(), sql.NullString{}, sql.NullString{}, "DATABASE_VALIDATION_FAILED", message, "medium", sql.NullInt64{Int64: int64(status), Valid: true})
- }
-
- writeJSON(w, http.StatusOK, map[string]any{"ok": true, "validation": map[string]any{"ok": ok, "status": status, "message": message}})
- _ = ctx
-}
-
-func (s *Server) handleKeysList(w http.ResponseWriter, r *http.Request, ctx *authContext) {
- rows, err := s.Store.DB.QueryContext(r.Context(), `
- SELECT id, name, key_prefix, plan, allowed_service_ids, enabled, rpm_limit, monthly_quota, created_at, updated_at, last_used_at
- FROM api_keys
- ORDER BY created_at DESC
- `)
- if err != nil {
- writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to list API keys")
- return
- }
- defer rows.Close()
-
- items := []map[string]any{}
- for rows.Next() {
- var id, name, keyPrefix, plan, allowed, createdAt, updatedAt string
- var enabled int
- var rpm, monthly sql.NullInt64
- var lastUsed sql.NullString
- if err := rows.Scan(&id, &name, &keyPrefix, &plan, &allowed, &enabled, &rpm, &monthly, &createdAt, &updatedAt, &lastUsed); err != nil {
- continue
- }
- allowedServices := []string{}
- _ = json.Unmarshal([]byte(allowed), &allowedServices)
- items = append(items, map[string]any{
- "id": id,
- "name": name,
- "keyPrefix": keyPrefix,
- "plan": plan,
- "allowedServiceIds": allowedServices,
- "enabled": enabled == 1,
- "rpmLimit": nullIntValue(rpm),
- "monthlyQuota": nullIntValue(monthly),
- "createdAt": createdAt,
- "updatedAt": updatedAt,
- "lastUsedAt": nullString(lastUsed),
- })
- }
- writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": items})
- _ = ctx
-}
-
-func (s *Server) handleKeysCreate(w http.ResponseWriter, r *http.Request, ctx *authContext) {
- var input struct {
- Name string `json:"name"`
- Plan string `json:"plan"`
- AllowedServiceIDs []string `json:"allowedServiceIds"`
- RPMLimit *int `json:"rpmLimit"`
- MonthlyQuota *int `json:"monthlyQuota"`
- Enabled *bool `json:"enabled"`
- }
- if err := decodeJSON(r.Body, &input); err != nil {
- writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error())
- return
- }
- name := strings.TrimSpace(input.Name)
- if name == "" {
- writeError(w, http.StatusBadRequest, "INVALID_NAME", "API key name is required")
- return
- }
- plan := strings.ToLower(strings.TrimSpace(firstNonEmpty(input.Plan, "pro")))
- rpmLimit, monthlyQuota := s.planDefaults(plan)
- if input.RPMLimit != nil {
- rpmLimit = input.RPMLimit
- }
- if input.MonthlyQuota != nil {
- monthlyQuota = input.MonthlyQuota
- }
-
- rawKey, _ := auth.RandomToken(28)
- rawKey = "apy_" + rawKey
- keyHash := auth.HashToken(rawKey)
- id, _ := auth.RandomID("key")
- now := storage.NowISO()
-
- _, err := s.Store.DB.ExecContext(r.Context(), `
- INSERT INTO api_keys (id, name, key_hash, key_prefix, plan, allowed_service_ids, enabled, rpm_limit, monthly_quota, created_at, updated_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
- `,
- id,
- name,
- keyHash,
- rawKey[:12],
- plan,
- mustJSON(input.AllowedServiceIDs),
- boolToInt(defaultBool(input.Enabled, true)),
- nullInt(rpmLimit),
- nullInt(monthlyQuota),
- now,
- now,
- )
- if err != nil {
- writeError(w, http.StatusBadRequest, "KEY_CREATE_FAILED", "Failed to create API key")
- return
- }
-
- writeJSON(w, http.StatusCreated, map[string]any{
- "ok": true,
- "data": map[string]any{
- "key": rawKey,
- "item": map[string]any{"id": id, "name": name, "plan": plan},
- },
- })
- _ = ctx
-}
-
-func (s *Server) handleKeysPatch(w http.ResponseWriter, r *http.Request, ctx *authContext) {
- id := r.PathValue("id")
- if id == "" {
- writeError(w, http.StatusBadRequest, "INVALID_ID", "API key ID is required")
- return
- }
- var input struct {
- Name *string `json:"name"`
- Plan *string `json:"plan"`
- AllowedServiceIDs []string `json:"allowedServiceIds"`
- RPMLimit *int `json:"rpmLimit"`
- MonthlyQuota *int `json:"monthlyQuota"`
- Enabled *bool `json:"enabled"`
- }
- if err := decodeJSON(r.Body, &input); err != nil {
- writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error())
- return
- }
-
- current := struct {
- Name string
- Plan string
- Allowed string
- Enabled int
- RPM sql.NullInt64
- Monthly sql.NullInt64
- }{}
- err := s.Store.DB.QueryRowContext(r.Context(), `
- SELECT name, plan, allowed_service_ids, enabled, rpm_limit, monthly_quota
- FROM api_keys WHERE id = ?
- `, id).Scan(¤t.Name, ¤t.Plan, ¤t.Allowed, ¤t.Enabled, ¤t.RPM, ¤t.Monthly)
- if err != nil {
- writeError(w, http.StatusNotFound, "KEY_NOT_FOUND", "API key not found")
- return
- }
-
- plan := current.Plan
- if input.Plan != nil {
- plan = strings.ToLower(strings.TrimSpace(*input.Plan))
- }
-
- allowed := current.Allowed
- if input.AllowedServiceIDs != nil {
- allowed = mustJSON(input.AllowedServiceIDs)
- }
-
- _, err = s.Store.DB.ExecContext(r.Context(), `
- UPDATE api_keys
- SET name = ?, plan = ?, allowed_service_ids = ?, enabled = ?, rpm_limit = ?, monthly_quota = ?, updated_at = ?
- WHERE id = ?
- `,
- firstNonEmptyPtr(input.Name, current.Name),
- plan,
- allowed,
- boolToInt(defaultBool(input.Enabled, current.Enabled == 1)),
- nullInt(coalesceIntPtr(input.RPMLimit, nullIntValue(current.RPM))),
- nullInt(coalesceIntPtr(input.MonthlyQuota, nullIntValue(current.Monthly))),
- storage.NowISO(),
- id,
- )
- if err != nil {
- writeError(w, http.StatusBadRequest, "KEY_UPDATE_FAILED", "Failed to update API key")
- return
- }
-
- writeJSON(w, http.StatusOK, map[string]any{"ok": true})
- _ = ctx
-}
-
-func (s *Server) handleAnalyticsOps(w http.ResponseWriter, r *http.Request, ctx *authContext) {
- counts := map[string]int{}
- for _, table := range []string{"users", "services", "database_connections", "api_keys"} {
- var count int
- _ = s.Store.DB.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM `+table).Scan(&count)
- counts[table] = count
- }
-
- period := time.Now().UTC().Format("2006-01")
- var monthlyRequests int
- _ = s.Store.DB.QueryRowContext(r.Context(), `SELECT COALESCE(SUM(request_count), 0) FROM usage_counters WHERE period_month = ?`, period).Scan(&monthlyRequests)
-
- severityRows, _ := s.Store.DB.QueryContext(r.Context(), `
- SELECT severity, COUNT(*) FROM incident_events
- WHERE occurred_at >= datetime('now', '-24 hours')
- GROUP BY severity
- `)
- incidentsBySeverity := map[string]int{}
- if severityRows != nil {
- defer severityRows.Close()
- for severityRows.Next() {
- var severity string
- var count int
- if err := severityRows.Scan(&severity, &count); err == nil {
- incidentsBySeverity[severity] = count
- }
- }
- }
-
- healthRows, _ := s.Store.DB.QueryContext(r.Context(), `
- SELECT COALESCE(last_validation_status, 'unknown') AS status, COUNT(*)
- FROM services
- GROUP BY status
- `)
- healthCounts := map[string]int{}
- if healthRows != nil {
- defer healthRows.Close()
- for healthRows.Next() {
- var status string
- var count int
- if err := healthRows.Scan(&status, &count); err == nil {
- healthCounts[status] = count
- }
- }
- }
-
- tsRows, _ := s.Store.DB.QueryContext(r.Context(), `
- SELECT strftime('%Y-%m-%dT%H:00:00Z', occurred_at) AS bucket, COUNT(*)
- FROM metrics_timeseries
- WHERE metric = 'request_total' AND occurred_at >= datetime('now', '-24 hours')
- GROUP BY bucket
- ORDER BY bucket ASC
- `)
- hourly := []map[string]any{}
- if tsRows != nil {
- defer tsRows.Close()
- for tsRows.Next() {
- var bucket string
- var count int
- if err := tsRows.Scan(&bucket, &count); err == nil {
- hourly = append(hourly, map[string]any{"bucket": bucket, "value": count})
- }
- }
- }
-
- writeJSON(w, http.StatusOK, map[string]any{
- "ok": true,
- "data": map[string]any{
- "counts": map[string]any{
- "users": counts["users"],
- "services": counts["services"],
- "databases": counts["database_connections"],
- "keys": counts["api_keys"],
- },
- "requests": map[string]any{
- "period": period,
- "total": monthlyRequests,
- "hourly": hourly,
- },
- "incidentsBySeverity": incidentsBySeverity,
- "serviceHealth": healthCounts,
- },
- })
- _ = ctx
-}
-
-func (s *Server) handleAnalyticsTraffic(w http.ResponseWriter, r *http.Request, ctx *authContext) {
- from := time.Now().Add(-7 * 24 * time.Hour)
- to := time.Now()
- if value := strings.TrimSpace(r.URL.Query().Get("from")); value != "" {
- if parsed, err := time.Parse(time.RFC3339, value); err == nil {
- from = parsed
- }
- }
- if value := strings.TrimSpace(r.URL.Query().Get("to")); value != "" {
- if parsed, err := time.Parse(time.RFC3339, value); err == nil {
- to = parsed
- }
- }
-
- traffic, err := s.Umami.FetchTraffic(r.Context(), from, to)
- if err == nil {
- _, _ = s.Store.DB.ExecContext(r.Context(), `
- INSERT INTO umami_sync_cache (cache_key, payload_json, updated_at)
- VALUES ('traffic', ?, ?)
- ON CONFLICT(cache_key) DO UPDATE SET payload_json = excluded.payload_json, updated_at = excluded.updated_at
- `, mustJSON(traffic), storage.NowISO())
- } else {
- var payload string
- _ = s.Store.DB.QueryRowContext(r.Context(), `SELECT payload_json FROM umami_sync_cache WHERE cache_key = 'traffic'`).Scan(&payload)
- if payload != "" {
- _ = json.Unmarshal([]byte(payload), &traffic)
- }
- if traffic == nil {
- traffic = map[string]any{"enabled": false, "error": err.Error()}
- }
- }
-
- eventRows, _ := s.Store.DB.QueryContext(r.Context(), `
- SELECT COALESCE(json_extract(labels_json, '$.path'), 'unknown') AS path, COUNT(*)
- FROM metrics_timeseries
- WHERE metric = 'client_event' AND occurred_at >= datetime('now', '-7 days')
- GROUP BY path
- ORDER BY COUNT(*) DESC
- LIMIT 20
- `)
- events := []map[string]any{}
- if eventRows != nil {
- defer eventRows.Close()
- for eventRows.Next() {
- var page string
- var count int
- if err := eventRows.Scan(&page, &count); err == nil {
- events = append(events, map[string]any{"path": page, "count": count})
- }
- }
- }
-
- writeJSON(w, http.StatusOK, map[string]any{
- "ok": true,
- "data": map[string]any{
- "umami": traffic,
- "clientEvents": events,
- },
- })
- _ = ctx
-}
-
-func (s *Server) handleAnalyticsEvents(w http.ResponseWriter, r *http.Request, ctx *authContext) {
- var input struct {
- Event string `json:"event"`
- Path string `json:"path"`
- Meta map[string]any `json:"meta"`
- }
- if err := decodeJSON(r.Body, &input); err != nil {
- writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error())
- return
- }
- labels := map[string]any{
- "event": firstNonEmpty(input.Event, "event"),
- "path": firstNonEmpty(input.Path, "unknown"),
- "meta": input.Meta,
- }
- _ = s.logMetric("client_event", 1, labels)
- writeJSON(w, http.StatusCreated, map[string]any{"ok": true})
- _ = ctx
-}
-
-func (s *Server) handleGatewayOrUI(w http.ResponseWriter, r *http.Request) {
- if strings.HasPrefix(r.URL.Path, "/api/") {
- writeError(w, http.StatusNotFound, "NOT_FOUND", "Route not found")
- return
- }
-
- uiBasePath := normalizePathPrefix(s.Cfg.DashboardUIBasePath, "/")
- uiRequestPath, uiPathMatched := webPathForRequest(r.URL.Path, uiBasePath)
- if uiBasePath != "/" && uiPathMatched {
- s.serveWeb(w, uiRequestPath)
- return
- }
-
- service, ok := s.matchService(r.Context(), r.URL.Path)
- if ok {
- s.handleProxy(w, r, service)
- return
- }
-
- if uiBasePath == "/" {
- s.serveWeb(w, uiRequestPath)
- return
- }
-
- writeError(w, http.StatusNotFound, "NOT_FOUND", "Route not found")
-}
-
-func (s *Server) serveWeb(w http.ResponseWriter, requestPath string) {
- sub, err := fs.Sub(staticFS, "static")
- if err != nil {
- writeError(w, http.StatusNotFound, "FRONTEND_NOT_BUILT", "Frontend build is not available")
- return
- }
-
- reqPath := strings.TrimPrefix(path.Clean("/"+strings.TrimSpace(requestPath)), "/")
- if reqPath == "." {
- reqPath = ""
- }
- if reqPath == "" {
- reqPath = "index.html"
- }
- if _, err := fs.Stat(sub, reqPath); err != nil {
- reqPath = "index.html"
- }
-
- data, err := fs.ReadFile(sub, reqPath)
- if err != nil {
- writeError(w, http.StatusNotFound, "FRONTEND_NOT_BUILT", "Frontend build is not available")
- return
- }
-
- if contentType := mime.TypeByExtension(path.Ext(reqPath)); contentType != "" {
- w.Header().Set("Content-Type", contentType)
- }
- _, _ = w.Write(data)
-}
-
-func (s *Server) handleProxy(w http.ResponseWriter, r *http.Request, service map[string]any) {
- secret := strings.TrimSpace(r.Header.Get(s.Cfg.APIKeyHeader))
- if secret == "" {
- writeError(w, http.StatusUnauthorized, "API_KEY_MISSING", fmt.Sprintf("Missing API key header '%s'", s.Cfg.APIKeyHeader))
- return
- }
-
- apiKey, err := s.getAPIKeyBySecret(r.Context(), secret)
- if err != nil {
- writeError(w, http.StatusUnauthorized, "API_KEY_INVALID", "Invalid API key")
- _ = s.logIncident(r.Context(), nullStringValue(service["id"]), sql.NullString{}, "API_KEY_INVALID", "Invalid API key", "medium", sql.NullInt64{Int64: 401, Valid: true})
- return
- }
- if !asBool(apiKey["enabled"]) {
- writeError(w, http.StatusForbidden, "API_KEY_DISABLED", "API key is disabled")
- return
- }
- if !asBool(service["enabled"]) {
- writeError(w, http.StatusServiceUnavailable, "SERVICE_DISABLED", "Service is disabled")
- return
- }
-
- if !serviceAllowed(apiKey, asString(service["id"]), asString(service["slug"]), asString(service["routePrefix"])) {
- writeError(w, http.StatusForbidden, "SERVICE_SCOPE_DENIED", "API key is not allowed for this service")
- _ = s.logIncident(r.Context(), nullStringValue(service["id"]), nullStringValue(apiKey["id"]), "SERVICE_SCOPE_DENIED", "API key attempted unauthorized service access", "medium", sql.NullInt64{Int64: 403, Valid: true})
- return
- }
-
- period := time.Now().UTC().Format("2006-01")
- serviceLimitRPM := nullIntValueFromAny(service["rpmLimit"])
- keyLimitRPM := nullIntValueFromAny(apiKey["rpmLimit"])
- serviceLimitMonthly := nullIntValueFromAny(service["monthlyQuota"])
- keyLimitMonthly := nullIntValueFromAny(apiKey["monthlyQuota"])
-
- effectiveRPM := minPositive(serviceLimitRPM, keyLimitRPM)
- effectiveMonthly := minPositive(serviceLimitMonthly, keyLimitMonthly)
-
- if effectiveMonthly != nil {
- used := s.usageCount(r.Context(), asString(apiKey["id"]), asString(service["id"]), period)
- if used >= *effectiveMonthly {
- writeError(w, http.StatusTooManyRequests, "MONTHLY_QUOTA_EXCEEDED", "Monthly quota exceeded")
- _ = s.logIncident(r.Context(), nullStringValue(service["id"]), nullStringValue(apiKey["id"]), "MONTHLY_QUOTA_EXCEEDED", "Monthly quota exceeded", "high", sql.NullInt64{Int64: 429, Valid: true})
- return
- }
- }
-
- if effectiveRPM != nil {
- if ok, retryAfter := s.allowRPM(asString(apiKey["id"])+":"+asString(service["id"]), *effectiveRPM); !ok {
- w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
- writeError(w, http.StatusTooManyRequests, "RATE_LIMIT_EXCEEDED", "Rate limit exceeded")
- _ = s.logIncident(r.Context(), nullStringValue(service["id"]), nullStringValue(apiKey["id"]), "RATE_LIMIT_EXCEEDED", "Rate limit exceeded", "medium", sql.NullInt64{Int64: 429, Valid: true})
- return
- }
- }
-
- targetURL, err := gateway.BuildTargetURL(asString(service["upstreamUrl"]), asString(service["routePrefix"]), r.URL.Path, r.URL.RawQuery)
- if err != nil {
- writeError(w, http.StatusBadGateway, "UPSTREAM_INVALID", "Invalid upstream URL")
- return
- }
-
- headers := map[string]string{}
- if upstreamHeader := asString(service["upstreamAuthHeader"]); upstreamHeader != "" {
- headers[upstreamHeader] = asString(service["upstreamAuthValue"])
- }
- if token := asString(service["internalToken"]); token != "" {
- headers[s.Cfg.ServiceTokenHeader] = token
- }
-
- r.Header.Del(s.Cfg.APIKeyHeader)
-
- timeout := time.Duration(intFromAny(service["requestTimeoutMs"], int(s.Cfg.DefaultServiceTimeout.Milliseconds()))) * time.Millisecond
- client := &http.Client{Timeout: timeout}
-
- statusCode, _, proxyErr := gateway.ProxyRequest(client, w, r, targetURL, headers)
- if proxyErr != nil {
- status := http.StatusBadGateway
- if errors.Is(proxyErr, context.DeadlineExceeded) || strings.Contains(strings.ToLower(proxyErr.Error()), "timeout") {
- status = http.StatusGatewayTimeout
- }
- writeError(w, status, "UPSTREAM_ERROR", proxyErr.Error())
- _ = s.logIncident(r.Context(), nullStringValue(service["id"]), nullStringValue(apiKey["id"]), "UPSTREAM_ERROR", proxyErr.Error(), "high", sql.NullInt64{Int64: int64(status), Valid: true})
- return
- }
-
- s.incrementUsage(r.Context(), asString(apiKey["id"]), asString(service["id"]), period)
- _, _ = s.Store.DB.ExecContext(r.Context(), `UPDATE api_keys SET last_used_at = ?, updated_at = ? WHERE id = ?`, storage.NowISO(), storage.NowISO(), asString(apiKey["id"]))
- _ = s.logMetric("request_total", 1, map[string]any{"service": asString(service["slug"]), "status": statusCode})
-
- if statusCode >= 500 {
- _ = s.logIncident(r.Context(), nullStringValue(service["id"]), nullStringValue(apiKey["id"]), "UPSTREAM_SERVER_ERROR", fmt.Sprintf("Upstream returned %d", statusCode), "high", sql.NullInt64{Int64: int64(statusCode), Valid: true})
- }
-}
-
-func (s *Server) resolveAuth(r *http.Request) (*authContext, error) {
- accessCookie, err := r.Cookie(s.Cfg.SessionAccessCookie)
- if err != nil || strings.TrimSpace(accessCookie.Value) == "" {
- return nil, errors.New("access cookie missing")
- }
- return s.resolveAuthFromTokenHash(r.Context(), auth.HashToken(accessCookie.Value))
-}
-
-func (s *Server) resolveAuthFromTokenHash(ctx context.Context, accessTokenHash string) (*authContext, error) {
- var user struct {
- ID string
- Email string
- Enabled int
- ForcePasswordReset int
- SessionID string
- }
- err := s.Store.DB.QueryRowContext(ctx, `
- SELECT u.id, u.email, u.enabled, u.force_password_reset, sess.id
- FROM sessions sess
- JOIN users u ON u.id = sess.user_id
- WHERE sess.access_token_hash = ?
- AND sess.revoked_at IS NULL
- AND sess.access_expires_at > ?
- `, accessTokenHash, storage.NowISO()).Scan(&user.ID, &user.Email, &user.Enabled, &user.ForcePasswordReset, &user.SessionID)
- if err != nil {
- return nil, err
- }
-
- roles, permissions, err := s.userAccess(ctx, user.ID)
- if err != nil {
- return nil, err
- }
-
- return &authContext{
- UserID: user.ID,
- Email: user.Email,
- Enabled: user.Enabled == 1,
- ForcePasswordReset: user.ForcePasswordReset == 1,
- Roles: roles,
- Permissions: permissions,
- SessionID: user.SessionID,
- }, nil
-}
-
-func (s *Server) userAccess(ctx context.Context, userID string) ([]string, map[string]bool, error) {
- roles := []string{}
- roleRows, err := s.Store.DB.QueryContext(ctx, `
- SELECT r.slug
- FROM user_roles ur
- JOIN roles r ON r.id = ur.role_id
- WHERE ur.user_id = ? AND r.enabled = 1
- `, userID)
- if err != nil {
- return nil, nil, err
- }
- for roleRows.Next() {
- var slug string
- if err := roleRows.Scan(&slug); err == nil {
- roles = append(roles, slug)
- }
- }
- roleRows.Close()
-
- permissions := map[string]bool{}
- permRows, err := s.Store.DB.QueryContext(ctx, `
- SELECT DISTINCT p.code
- FROM user_roles ur
- JOIN role_permissions rp ON rp.role_id = ur.role_id
- JOIN permissions p ON p.id = rp.permission_id
- JOIN roles r ON r.id = ur.role_id
- WHERE ur.user_id = ? AND r.enabled = 1
- `, userID)
- if err != nil {
- return roles, permissions, err
- }
- for permRows.Next() {
- var code string
- if err := permRows.Scan(&code); err == nil {
- permissions[code] = true
- }
- }
- permRows.Close()
-
- return roles, permissions, nil
-}
-
-func (s *Server) createSession(ctx context.Context, userID string) (string, string, string, error) {
- sessionID, _ := auth.RandomID("ses")
- accessToken, _ := auth.RandomToken(32)
- refreshToken, _ := auth.RandomToken(48)
- accessHash := auth.HashToken(accessToken)
- refreshHash := auth.HashToken(refreshToken)
-
- now := storage.NowISO()
- accessExp := time.Now().Add(s.Cfg.AccessTokenTTL).UTC().Format(time.RFC3339)
- refreshExp := time.Now().Add(s.Cfg.RefreshTokenTTL).UTC().Format(time.RFC3339)
-
- _, err := s.Store.DB.ExecContext(ctx, `
- INSERT INTO sessions (id, user_id, access_token_hash, refresh_token_hash, access_expires_at, refresh_expires_at, created_at, updated_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
- `, sessionID, userID, accessHash, refreshHash, accessExp, refreshExp, now, now)
- if err != nil {
- return "", "", "", err
- }
-
- return sessionID, accessToken, refreshToken, nil
-}
-
-func (s *Server) setAuthCookies(w http.ResponseWriter, accessToken, refreshToken string) {
- accessCookie := &http.Cookie{
- Name: s.Cfg.SessionAccessCookie,
- Value: accessToken,
- Path: "/",
- HttpOnly: true,
- Secure: s.Cfg.CookieSecure,
- SameSite: http.SameSiteLaxMode,
- Expires: time.Now().Add(s.Cfg.AccessTokenTTL),
- }
- if s.Cfg.CookieDomain != "" {
- accessCookie.Domain = s.Cfg.CookieDomain
- }
-
- refreshCookie := &http.Cookie{
- Name: s.Cfg.SessionRefreshCookie,
- Value: refreshToken,
- Path: "/",
- HttpOnly: true,
- Secure: s.Cfg.CookieSecure,
- SameSite: http.SameSiteLaxMode,
- Expires: time.Now().Add(s.Cfg.RefreshTokenTTL),
- }
- if s.Cfg.CookieDomain != "" {
- refreshCookie.Domain = s.Cfg.CookieDomain
- }
-
- http.SetCookie(w, accessCookie)
- http.SetCookie(w, refreshCookie)
-}
-
-func (s *Server) clearAuthCookies(w http.ResponseWriter) {
- expired := time.Unix(0, 0)
- for _, cookieName := range []string{s.Cfg.SessionAccessCookie, s.Cfg.SessionRefreshCookie} {
- cookie := &http.Cookie{
- Name: cookieName,
- Value: "",
- Path: "/",
- HttpOnly: true,
- Secure: s.Cfg.CookieSecure,
- SameSite: http.SameSiteLaxMode,
- Expires: expired,
- MaxAge: -1,
- }
- if s.Cfg.CookieDomain != "" {
- cookie.Domain = s.Cfg.CookieDomain
- }
- http.SetCookie(w, cookie)
- }
-}
-
-func (s *Server) hasUsers(ctx context.Context) bool {
- var count int
- _ = s.Store.DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&count)
- return count > 0
-}
-
-func (s *Server) isLoginRateLimited(r *http.Request, email string) bool {
- key := r.RemoteAddr + ":" + email
- s.loginMu.Lock()
- defer s.loginMu.Unlock()
- entry := s.loginAttempts[key]
- now := time.Now()
- if now.Sub(entry.WindowStart) > 15*time.Minute {
- entry = loginWindow{WindowStart: now, Count: 0}
- }
- return entry.Count >= 10
-}
-
-func (s *Server) markLoginAttempt(r *http.Request, email string) {
- key := r.RemoteAddr + ":" + email
- s.loginMu.Lock()
- defer s.loginMu.Unlock()
- entry := s.loginAttempts[key]
- now := time.Now()
- if now.Sub(entry.WindowStart) > 15*time.Minute {
- entry = loginWindow{WindowStart: now, Count: 0}
- }
- entry.Count++
- s.loginAttempts[key] = entry
-}
-
-func (s *Server) clearLoginAttempts(r *http.Request, email string) {
- key := r.RemoteAddr + ":" + email
- s.loginMu.Lock()
- defer s.loginMu.Unlock()
- delete(s.loginAttempts, key)
-}
-
-func (s *Server) resolveRoleIDs(ctx context.Context, roleIDs []string, roleSlugs []string) ([]string, error) {
- resolved := []string{}
- if len(roleIDs) > 0 {
- for _, roleID := range roleIDs {
- id := strings.TrimSpace(roleID)
- if id == "" {
- continue
- }
- var exists int
- if err := s.Store.DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM roles WHERE id = ?`, id).Scan(&exists); err != nil || exists == 0 {
- return nil, fmt.Errorf("role %s not found", id)
- }
- resolved = append(resolved, id)
- }
- return uniqueStrings(resolved), nil
- }
- for _, slug := range roleSlugs {
- id, err := s.roleIDBySlug(ctx, slug)
- if err != nil {
- return nil, err
- }
- resolved = append(resolved, id)
- }
- return uniqueStrings(resolved), nil
-}
-
-func (s *Server) roleIDBySlug(ctx context.Context, slug string) (string, error) {
- id := ""
- err := s.Store.DB.QueryRowContext(ctx, `SELECT id FROM roles WHERE slug = ?`, strings.ToLower(strings.TrimSpace(slug))).Scan(&id)
- if err != nil {
- return "", fmt.Errorf("role %s not found", slug)
- }
- return id, nil
-}
-
-func (s *Server) replaceRolePermissionsTx(ctx context.Context, tx *sql.Tx, roleID string, permissionCodes []string) error {
- if _, err := tx.ExecContext(ctx, `DELETE FROM role_permissions WHERE role_id = ?`, roleID); err != nil {
- return err
- }
- now := storage.NowISO()
- for _, code := range permissionCodes {
- permCode := strings.TrimSpace(code)
- if permCode == "" {
- continue
- }
- permID := ""
- if err := tx.QueryRowContext(ctx, `SELECT id FROM permissions WHERE code = ?`, permCode).Scan(&permID); err != nil {
- return fmt.Errorf("permission not found: %s", permCode)
- }
- if _, err := tx.ExecContext(ctx, `INSERT INTO role_permissions (role_id, permission_id, created_at) VALUES (?, ?, ?)`, roleID, permID, now); err != nil {
- return err
- }
- }
- return nil
-}
-
-func (s *Server) getServiceByID(ctx context.Context, id string) (map[string]any, error) {
- row := s.Store.DB.QueryRowContext(ctx, `
- SELECT id, name, slug, upstream_url, route_prefix, health_path, upstream_auth_header, upstream_auth_value, internal_token, enabled, rpm_limit, monthly_quota, request_timeout_ms, last_validation_at, last_validation_status, last_validation_message, created_at, updated_at
- FROM services WHERE id = ?
- `, id)
- return scanServiceRow(row)
-}
-
-func (s *Server) getDatabaseByID(ctx context.Context, id string) (map[string]any, error) {
- var item struct {
- ID, Name, Slug, Provider, ConnectionURL, CreatedAt, UpdatedAt string
- Enabled int
- LastAt, LastStatus, LastMessage sql.NullString
- }
- err := s.Store.DB.QueryRowContext(ctx, `
- SELECT id, name, slug, provider, connection_url, enabled, last_validation_at, last_validation_status, last_validation_message, created_at, updated_at
- FROM database_connections
- WHERE id = ?
- `, id).Scan(&item.ID, &item.Name, &item.Slug, &item.Provider, &item.ConnectionURL, &item.Enabled, &item.LastAt, &item.LastStatus, &item.LastMessage, &item.CreatedAt, &item.UpdatedAt)
- if err != nil {
- return nil, err
- }
- return map[string]any{
- "id": item.ID,
- "name": item.Name,
- "slug": item.Slug,
- "provider": item.Provider,
- "connectionUrl": item.ConnectionURL,
- "target": extractDatabaseTarget(item.Provider, item.ConnectionURL),
- "maskedConnectionUrl": maskDatabaseURL(item.Provider, item.ConnectionURL),
- "enabled": item.Enabled == 1,
- "lastValidationAt": nullString(item.LastAt),
- "lastValidationStatus": nullString(item.LastStatus),
- "lastValidationMessage": nullString(item.LastMessage),
- "createdAt": item.CreatedAt,
- "updatedAt": item.UpdatedAt,
- }, nil
-}
-
-func (s *Server) validateDatabaseConnection(ctx context.Context, provider, connectionURL string) (bool, int, string) {
- provider = normalizeProvider(provider)
- if provider == "" {
- return false, http.StatusBadRequest, "Unsupported database provider"
- }
-
- timeoutCtx, cancel := context.WithTimeout(ctx, s.Cfg.DefaultServiceTimeout)
- defer cancel()
-
- sqlDriver := ""
- driverConn := connectionURL
- switch provider {
- case "sqlite":
- sqlDriver = "sqlite"
- if !strings.HasPrefix(driverConn, "file:") && driverConn != ":memory:" {
- if abs, err := pathAbs(driverConn); err == nil {
- driverConn = "file:" + abs
- }
- }
- case "postgres":
- sqlDriver = "pgx"
- case "mysql":
- sqlDriver = "mysql"
- }
-
- db, err := sql.Open(sqlDriver, driverConn)
- if err != nil {
- return false, http.StatusBadGateway, err.Error()
- }
- defer db.Close()
-
- db.SetConnMaxLifetime(2 * time.Minute)
- db.SetMaxOpenConns(1)
- db.SetMaxIdleConns(1)
-
- if err := db.PingContext(timeoutCtx); err != nil {
- return false, http.StatusBadGateway, err.Error()
- }
- return true, http.StatusOK, fmt.Sprintf("%s connection validated", strings.Title(provider))
-}
-
-func (s *Server) getAPIKeyBySecret(ctx context.Context, secret string) (map[string]any, error) {
- hash := auth.HashToken(secret)
- var item struct {
- ID, Name, KeyPrefix, Plan, AllowedServiceIDs, CreatedAt, UpdatedAt string
- Enabled int
- RPMLimit, MonthlyQuota sql.NullInt64
- }
- err := s.Store.DB.QueryRowContext(ctx, `
- SELECT id, name, key_prefix, plan, allowed_service_ids, enabled, rpm_limit, monthly_quota, created_at, updated_at
- FROM api_keys
- WHERE key_hash = ?
- `, hash).Scan(&item.ID, &item.Name, &item.KeyPrefix, &item.Plan, &item.AllowedServiceIDs, &item.Enabled, &item.RPMLimit, &item.MonthlyQuota, &item.CreatedAt, &item.UpdatedAt)
- if err != nil {
- return nil, err
- }
- allowed := []string{}
- _ = json.Unmarshal([]byte(item.AllowedServiceIDs), &allowed)
- return map[string]any{
- "id": item.ID,
- "name": item.Name,
- "keyPrefix": item.KeyPrefix,
- "plan": item.Plan,
- "allowedServiceIds": allowed,
- "enabled": item.Enabled == 1,
- "rpmLimit": nullIntValue(item.RPMLimit),
- "monthlyQuota": nullIntValue(item.MonthlyQuota),
- }, nil
-}
-
-func (s *Server) planDefaults(plan string) (*int, *int) {
- p := strings.ToLower(strings.TrimSpace(plan))
- switch p {
- case "free":
- return intPtr(s.Cfg.FreeRPM), intPtr(s.Cfg.FreeMonthlyQuota)
- case "business":
- return intPtr(s.Cfg.BusinessRPM), intPtr(s.Cfg.BusinessMonthlyQuota)
- case "enterprise":
- return nil, nil
- default:
- return intPtr(s.Cfg.ProRPM), intPtr(s.Cfg.ProMonthlyQuota)
- }
-}
-
-func (s *Server) matchService(ctx context.Context, requestPath string) (map[string]any, bool) {
- rows, err := s.Store.DB.QueryContext(ctx, `
- SELECT id, name, slug, upstream_url, route_prefix, health_path, upstream_auth_header, upstream_auth_value, internal_token, enabled, rpm_limit, monthly_quota, request_timeout_ms, last_validation_at, last_validation_status, last_validation_message, created_at, updated_at
- FROM services
- WHERE enabled = 1
- ORDER BY LENGTH(route_prefix) DESC, route_prefix DESC
- `)
- if err != nil {
- return nil, false
- }
- defer rows.Close()
-
- for rows.Next() {
- item, err := scanService(rows)
- if err != nil {
- continue
- }
- prefix := asString(item["routePrefix"])
- if prefix == "/" || requestPath == prefix || strings.HasPrefix(requestPath, prefix+"/") {
- return item, true
- }
- }
- return nil, false
-}
-
-func (s *Server) usageCount(ctx context.Context, apiKeyID, serviceID, period string) int {
- var count int
- _ = s.Store.DB.QueryRowContext(ctx, `
- SELECT COALESCE(request_count, 0)
- FROM usage_counters
- WHERE api_key_id = ? AND service_id = ? AND period_month = ?
- `, apiKeyID, serviceID, period).Scan(&count)
- return count
-}
-
-func (s *Server) incrementUsage(ctx context.Context, apiKeyID, serviceID, period string) {
- _, _ = s.Store.DB.ExecContext(ctx, `
- INSERT INTO usage_counters (api_key_id, service_id, period_month, request_count, updated_at)
- VALUES (?, ?, ?, 1, ?)
- ON CONFLICT(api_key_id, service_id, period_month)
- DO UPDATE SET request_count = request_count + 1, updated_at = excluded.updated_at
- `, apiKeyID, serviceID, period, storage.NowISO())
-}
-
-func (s *Server) allowRPM(bucket string, limit int) (bool, int) {
- if limit <= 0 {
- return true, 0
- }
- now := time.Now().UTC()
- windowStart := now.Truncate(time.Minute)
- key := bucket + ":" + windowStart.Format(time.RFC3339)
-
- s.rpmMu.Lock()
- defer s.rpmMu.Unlock()
-
- for k, v := range s.rpmUsage {
- if now.Sub(v.WindowStart) > 2*time.Minute {
- delete(s.rpmUsage, k)
- }
- }
-
- entry := s.rpmUsage[key]
- if entry.WindowStart.IsZero() {
- entry.WindowStart = windowStart
- }
- if entry.Count >= limit {
- retry := int(windowStart.Add(time.Minute).Sub(now).Seconds())
- if retry < 1 {
- retry = 1
- }
- return false, retry
- }
- entry.Count++
- s.rpmUsage[key] = entry
- return true, 0
-}
-
-func (s *Server) logIncident(ctx context.Context, serviceID, apiKeyID sql.NullString, code, message, severity string, httpStatus sql.NullInt64) error {
- _, err := s.Store.DB.ExecContext(ctx, `
- INSERT INTO incident_events (service_id, api_key_id, code, message, severity, http_status, count, occurred_at)
- VALUES (?, ?, ?, ?, ?, ?, 1, ?)
- `, serviceID, apiKeyID, code, message, severity, httpStatus, storage.NowISO())
- return err
-}
-
-func (s *Server) logMetric(metric string, value float64, labels map[string]any) error {
- _, err := s.Store.DB.Exec(`
- INSERT INTO metrics_timeseries (metric, value, labels_json, occurred_at)
- VALUES (?, ?, ?, ?)
- `, metric, value, mustJSON(labels), storage.NowISO())
- return err
-}
-
-func (s *Server) audit(ctx context.Context, actorUserID, action, targetType, targetID string, payload any) {
- _, _ = s.Store.DB.ExecContext(ctx, `
- INSERT INTO audit_log (actor_user_id, action, target_type, target_id, payload_json, occurred_at)
- VALUES (?, ?, ?, ?, ?, ?)
- `, nullIfEmpty(actorUserID), action, nullIfEmpty(targetType), nullIfEmpty(targetID), mustJSON(payload), storage.NowISO())
-}
-
-func scanService(rows *sql.Rows) (map[string]any, error) {
- var id, name, slug, upstreamURL, routePrefix, healthPath, createdAt, updatedAt string
- var upstreamAuthHeader, upstreamAuthValue, internalToken, lastValidationAt, lastValidationStatus, lastValidationMessage sql.NullString
- var enabled int
- var rpmLimit, monthlyQuota, requestTimeoutMS sql.NullInt64
- if err := rows.Scan(
- &id, &name, &slug, &upstreamURL, &routePrefix, &healthPath, &upstreamAuthHeader, &upstreamAuthValue, &internalToken,
- &enabled, &rpmLimit, &monthlyQuota, &requestTimeoutMS,
- &lastValidationAt, &lastValidationStatus, &lastValidationMessage,
- &createdAt, &updatedAt,
- ); err != nil {
- return nil, err
- }
- return map[string]any{
- "id": id,
- "name": name,
- "slug": slug,
- "upstreamUrl": upstreamURL,
- "routePrefix": routePrefix,
- "healthPath": healthPath,
- "upstreamAuthHeader": nullString(upstreamAuthHeader),
- "upstreamAuthValue": nullString(upstreamAuthValue),
- "internalToken": nullString(internalToken),
- "enabled": enabled == 1,
- "rpmLimit": nullIntValue(rpmLimit),
- "monthlyQuota": nullIntValue(monthlyQuota),
- "requestTimeoutMs": nullIntValue(requestTimeoutMS),
- "lastValidationAt": nullString(upstreamOr(lastValidationAt, sql.NullString{})),
- "lastValidationStatus": nullString(lastValidationStatus),
- "lastValidationMessage": nullString(lastValidationMessage),
- "createdAt": createdAt,
- "updatedAt": updatedAt,
- }, nil
-}
-
-func scanServiceRow(row *sql.Row) (map[string]any, error) {
- var id, name, slug, upstreamURL, routePrefix, healthPath, createdAt, updatedAt string
- var upstreamAuthHeader, upstreamAuthValue, internalToken, lastValidationAt, lastValidationStatus, lastValidationMessage sql.NullString
- var enabled int
- var rpmLimit, monthlyQuota, requestTimeoutMS sql.NullInt64
- if err := row.Scan(
- &id, &name, &slug, &upstreamURL, &routePrefix, &healthPath, &upstreamAuthHeader, &upstreamAuthValue, &internalToken,
- &enabled, &rpmLimit, &monthlyQuota, &requestTimeoutMS,
- &lastValidationAt, &lastValidationStatus, &lastValidationMessage,
- &createdAt, &updatedAt,
- ); err != nil {
- return nil, err
- }
- return map[string]any{
- "id": id,
- "name": name,
- "slug": slug,
- "upstreamUrl": upstreamURL,
- "routePrefix": routePrefix,
- "healthPath": healthPath,
- "upstreamAuthHeader": nullString(upstreamAuthHeader),
- "upstreamAuthValue": nullString(upstreamAuthValue),
- "internalToken": nullString(internalToken),
- "enabled": enabled == 1,
- "rpmLimit": nullIntValue(rpmLimit),
- "monthlyQuota": nullIntValue(monthlyQuota),
- "requestTimeoutMs": nullIntValue(requestTimeoutMS),
- "lastValidationAt": nullString(lastValidationAt),
- "lastValidationStatus": nullString(lastValidationStatus),
- "lastValidationMessage": nullString(lastValidationMessage),
- "createdAt": createdAt,
- "updatedAt": updatedAt,
- }, nil
-}
-
-func serviceAllowed(apiKey map[string]any, serviceID, slug, prefix string) bool {
- allowed, ok := apiKey["allowedServiceIds"].([]string)
- if !ok || len(allowed) == 0 {
- return true
- }
- for _, candidate := range allowed {
- if candidate == serviceID || candidate == slug || candidate == prefix {
- return true
- }
- }
- return false
-}
-
-func writeJSON(w http.ResponseWriter, status int, payload map[string]any) {
- w.Header().Set("Content-Type", "application/json")
- w.WriteHeader(status)
- _ = json.NewEncoder(w).Encode(payload)
-}
-
-func writeError(w http.ResponseWriter, status int, code, message string) {
- writeJSON(w, status, map[string]any{
- "ok": false,
- "error": map[string]any{
- "code": code,
- "message": message,
- },
- })
-}
-
-func decodeJSON(body io.Reader, target any) error {
- decoder := json.NewDecoder(io.LimitReader(body, 1<<20))
- decoder.DisallowUnknownFields()
- if err := decoder.Decode(target); err != nil {
- return err
- }
- return nil
-}
-
-func mustJSON(value any) string {
- bytes, _ := json.Marshal(value)
- return string(bytes)
-}
-
-func boolToInt(value bool) int {
- if value {
- return 1
- }
- return 0
-}
-
-func intPtr(value int) *int {
- v := value
- return &v
-}
-
-func nullInt(value *int) any {
- if value == nil || *value <= 0 {
- return nil
- }
- return *value
-}
-
-func nullIntValue(value sql.NullInt64) *int {
- if !value.Valid {
- return nil
- }
- v := int(value.Int64)
- return &v
-}
-
-func nullIntValueFromAny(value any) *int {
- if value == nil {
- return nil
- }
- switch typed := value.(type) {
- case *int:
- return typed
- case int:
- v := typed
- return &v
- case int64:
- v := int(typed)
- return &v
- case float64:
- v := int(typed)
- return &v
- default:
- return nil
- }
-}
-
-func nullString(value sql.NullString) any {
- if !value.Valid {
- return nil
- }
- return value.String
-}
-
-func nullStringValue(value any) sql.NullString {
- if value == nil {
- return sql.NullString{}
- }
- text := fmt.Sprintf("%v", value)
- if strings.TrimSpace(text) == "" {
- return sql.NullString{}
- }
- return sql.NullString{String: text, Valid: true}
-}
-
-func nullIfEmpty(value string) any {
- v := strings.TrimSpace(value)
- if v == "" {
- return nil
- }
- return v
-}
-
-func coalesceIntPtr(value *int, fallback *int) *int {
- if value != nil {
- return value
- }
- return fallback
-}
-
-func normalizePathPrefix(value, fallback string) string {
- v := strings.TrimSpace(value)
- if v == "" {
- v = fallback
- }
- if !strings.HasPrefix(v, "/") {
- v = "/" + v
- }
- if len(v) > 1 && strings.HasSuffix(v, "/") {
- v = strings.TrimSuffix(v, "/")
- }
- return v
-}
-
-func slugify(value, fallback string) string {
- v := strings.ToLower(strings.TrimSpace(value))
- if v == "" {
- v = fallback
- }
- out := strings.Builder{}
- lastDash := false
- for _, r := range v {
- if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
- out.WriteRune(r)
- lastDash = false
- continue
- }
- if !lastDash {
- out.WriteRune('-')
- lastDash = true
- }
- }
- result := strings.Trim(out.String(), "-")
- if result == "" {
- return fallback
- }
- return result
-}
-
-func firstNonEmpty(values ...string) string {
- for _, value := range values {
- if strings.TrimSpace(value) != "" {
- return strings.TrimSpace(value)
- }
- }
- return ""
-}
-
-func firstNonEmptyPtr(value *string, fallback string) string {
- if value != nil {
- if strings.TrimSpace(*value) == "" {
- return fallback
- }
- return strings.TrimSpace(*value)
- }
- return fallback
-}
-
-func defaultBool(value *bool, fallback bool) bool {
- if value == nil {
- return fallback
- }
- return *value
-}
-
-func asString(value any) string {
- if value == nil {
- return ""
- }
- if v, ok := value.(string); ok {
- return v
- }
- return fmt.Sprintf("%v", value)
-}
-
-func asBool(value any) bool {
- if value == nil {
- return false
- }
- switch v := value.(type) {
- case bool:
- return v
- case int:
- return v == 1
- case int64:
- return v == 1
- case float64:
- return int(v) == 1
- default:
- return false
- }
-}
-
-func intFromAny(value any, fallback int) int {
- if value == nil {
- return fallback
- }
- switch v := value.(type) {
- case int:
- return v
- case int64:
- return int(v)
- case float64:
- return int(v)
- case *int:
- if v == nil {
- return fallback
- }
- return *v
- default:
- return fallback
- }
-}
-
-func minPositive(a, b *int) *int {
- if a == nil && b == nil {
- return nil
- }
- if a == nil {
- return b
- }
- if b == nil {
- return a
- }
- if *a < *b {
- return a
- }
- return b
-}
-
-func keysOfMap(values map[string]bool) []string {
- keys := make([]string, 0, len(values))
- for key, enabled := range values {
- if enabled {
- keys = append(keys, key)
- }
- }
- sort.Strings(keys)
- return keys
-}
-
-func uniqueStrings(values []string) []string {
- seen := map[string]bool{}
- output := []string{}
- for _, value := range values {
- v := strings.TrimSpace(value)
- if v == "" || seen[v] {
- continue
- }
- seen[v] = true
- output = append(output, v)
- }
- return output
-}
-
-func normalizeProvider(value string) string {
- v := strings.ToLower(strings.TrimSpace(value))
- switch v {
- case "sqlite", "sqlite3", "file":
- return "sqlite"
- case "postgres", "postgresql", "pg":
- return "postgres"
- case "mysql", "mariadb":
- return "mysql"
- default:
- return ""
- }
-}
-
-func maskDatabaseURL(provider, raw string) string {
- if normalizeProvider(provider) == "sqlite" {
- return raw
- }
- u, err := netUrlParse(raw)
- if err != nil {
- if len(raw) <= 8 {
- return "***"
- }
- return raw[:4] + "***" + raw[len(raw)-2:]
- }
- if u.User != nil {
- username := u.User.Username()
- password, hasPassword := u.User.Password()
- if username != "" {
- if len(username) > 2 {
- username = username[:2] + "***"
- } else {
- username = "***"
- }
- }
- if hasPassword && password != "" {
- u.User = url.UserPassword(username, "***")
- } else {
- u.User = url.User(username)
- }
- }
- q := u.Query()
- for _, key := range []string{"password", "pass", "pwd", "token", "secret"} {
- if q.Has(key) {
- q.Set(key, "***")
- }
- }
- u.RawQuery = q.Encode()
- return u.String()
-}
-
-func extractDatabaseTarget(provider, raw string) string {
- if normalizeProvider(provider) == "sqlite" {
- return raw
- }
- u, err := netUrlParse(raw)
- if err != nil {
- return "-"
- }
- name := strings.TrimPrefix(u.Path, "/")
- if name == "" {
- name = "-"
- }
- return name
-}
-
-func pathAbs(value string) (string, error) {
- if strings.HasPrefix(value, "file:") {
- return strings.TrimPrefix(value, "file:"), nil
- }
- if filepath.IsAbs(value) {
- return value, nil
- }
- wd, err := os.Getwd()
- if err != nil {
- return "", err
- }
- return filepath.Join(wd, value), nil
-}
-
-func netUrlParse(value string) (*url.URL, error) {
- return url.Parse(value)
-}
-
-func webPathForRequest(requestPath string, uiBasePath string) (string, bool) {
- reqPath := path.Clean("/" + strings.TrimSpace(requestPath))
- if reqPath == "." {
- reqPath = "/"
- }
-
- base := normalizePathPrefix(uiBasePath, "/")
- if base == "/" {
- return strings.TrimPrefix(reqPath, "/"), true
- }
-
- if reqPath == base {
- return "", true
- }
-
- prefix := base + "/"
- if strings.HasPrefix(reqPath, prefix) {
- return strings.TrimPrefix(reqPath, prefix), true
- }
-
- return "", false
-}
-
-func upstreamOr(a, b sql.NullString) sql.NullString {
- if a.Valid {
- return a
- }
- return b
-}
-
-type serviceInput struct {
- Name string `json:"name"`
- Slug string `json:"slug"`
- UpstreamURL string `json:"upstreamUrl"`
- RoutePrefix string `json:"routePrefix"`
- HealthPath string `json:"healthPath"`
- UpstreamAuthHeader string `json:"upstreamAuthHeader"`
- UpstreamAuthValue string `json:"upstreamAuthValue"`
- InternalToken string `json:"internalToken"`
- Enabled *bool `json:"enabled"`
- RPMLimit *int `json:"rpmLimit"`
- MonthlyQuota *int `json:"monthlyQuota"`
- RequestTimeoutMS *int `json:"requestTimeoutMs"`
-}
-
-func (s serviceInput) RPMLimitOr(value any) *int {
- if s.RPMLimit != nil {
- return s.RPMLimit
- }
- return nullIntValueFromAny(value)
-}
-
-func (s serviceInput) MonthlyQuotaOr(value any) *int {
- if s.MonthlyQuota != nil {
- return s.MonthlyQuota
- }
- return nullIntValueFromAny(value)
-}
-
-func (s serviceInput) TimeoutOr(value any) *int {
- if s.RequestTimeoutMS != nil {
- return s.RequestTimeoutMS
- }
- return nullIntValueFromAny(value)
-}
diff --git a/app/backend/internal/apwhy/api/server_integration_test.go b/app/backend/internal/apwhy/api/server_integration_test.go
deleted file mode 100644
index 2d4769c..0000000
--- a/app/backend/internal/apwhy/api/server_integration_test.go
+++ /dev/null
@@ -1,321 +0,0 @@
-package api
-
-import (
- "bytes"
- "encoding/json"
- "io"
- "net/http"
- "net/http/cookiejar"
- "net/http/httptest"
- "strings"
- "testing"
-
- "containr/internal/apwhy/config"
- "containr/internal/apwhy/storage"
-)
-
-type apiErrorPayload struct {
- Code string `json:"code"`
- Message string `json:"message"`
-}
-
-type apiResponsePayload struct {
- OK bool `json:"ok"`
- Data any `json:"data"`
- Error apiErrorPayload `json:"error"`
-}
-
-func newTestServer(t *testing.T) (*httptest.Server, *http.Client) {
- t.Helper()
-
- cfg := config.Load()
- cfg.SQLitePath = ":memory:"
- cfg.CookieSecure = false
-
- store, err := storage.Open(cfg)
- if err != nil {
- t.Fatalf("failed to open test storage: %v", err)
- }
-
- server := NewServer(store, cfg)
- ts := httptest.NewServer(server.Handler())
-
- t.Cleanup(func() {
- ts.Close()
- _ = store.Close()
- })
-
- jar, err := cookiejar.New(nil)
- if err != nil {
- t.Fatalf("failed to create cookie jar: %v", err)
- }
-
- return ts, &http.Client{Jar: jar}
-}
-
-func requestJSON(t *testing.T, client *http.Client, method string, url string, body string) (int, apiResponsePayload) {
- t.Helper()
-
- var bodyReader *bytes.Reader
- if body == "" {
- bodyReader = bytes.NewReader(nil)
- } else {
- bodyReader = bytes.NewReader([]byte(body))
- }
-
- req, err := http.NewRequest(method, url, bodyReader)
- if err != nil {
- t.Fatalf("failed to create request: %v", err)
- }
- if body != "" {
- req.Header.Set("Content-Type", "application/json")
- }
-
- resp, err := client.Do(req)
- if err != nil {
- t.Fatalf("request failed: %v", err)
- }
- defer resp.Body.Close()
-
- var payload apiResponsePayload
- if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
- t.Fatalf("failed to decode response body: %v", err)
- }
-
- return resp.StatusCode, payload
-}
-
-func bootstrapAndLoginOwner(t *testing.T, client *http.Client, baseURL string) {
- t.Helper()
-
- status, payload := requestJSON(
- t,
- client,
- http.MethodPost,
- baseURL+"/api/v1/bootstrap/register-owner",
- `{"email":"owner@example.com","password":"owner-pass-123"}`,
- )
- if status != http.StatusCreated || !payload.OK {
- t.Fatalf("owner bootstrap failed: status=%d payload=%+v", status, payload)
- }
-
- status, payload = requestJSON(
- t,
- client,
- http.MethodPost,
- baseURL+"/api/v1/auth/login",
- `{"email":"owner@example.com","password":"owner-pass-123"}`,
- )
- if status != http.StatusOK || !payload.OK {
- t.Fatalf("owner login failed: status=%d payload=%+v", status, payload)
- }
-}
-
-func TestServerBootstrapLoginAndProtectedRoutes(t *testing.T) {
- ts, authClient := newTestServer(t)
- baseURL := ts.URL
-
- // Bootstrap should be open before any user exists.
- status, payload := requestJSON(t, authClient, http.MethodGet, baseURL+"/api/v1/bootstrap/status", "")
- if status != http.StatusOK || !payload.OK {
- t.Fatalf("unexpected bootstrap status response: status=%d payload=%+v", status, payload)
- }
- bootstrapData, ok := payload.Data.(map[string]any)
- if !ok {
- t.Fatalf("expected object payload for bootstrap status, got %#v", payload.Data)
- }
- if hasUsers, _ := bootstrapData["hasUsers"].(bool); hasUsers {
- t.Fatalf("expected hasUsers=false before owner bootstrap")
- }
- if registrationOpen, _ := bootstrapData["registrationOpen"].(bool); !registrationOpen {
- t.Fatalf("expected registrationOpen=true before owner bootstrap")
- }
-
- // Protected route without auth should fail.
- anonClient := &http.Client{}
- status, payload = requestJSON(t, anonClient, http.MethodGet, baseURL+"/api/v1/auth/me", "")
- if status != http.StatusUnauthorized || payload.OK {
- t.Fatalf("expected unauthorized /auth/me response, got status=%d payload=%+v", status, payload)
- }
-
- // Register owner and login.
- bootstrapAndLoginOwner(t, authClient, baseURL)
-
- // Bootstrap should close once owner exists.
- status, payload = requestJSON(t, authClient, http.MethodGet, baseURL+"/api/v1/bootstrap/status", "")
- if status != http.StatusOK || !payload.OK {
- t.Fatalf("unexpected bootstrap status after owner creation: status=%d payload=%+v", status, payload)
- }
- bootstrapData, ok = payload.Data.(map[string]any)
- if !ok {
- t.Fatalf("expected object payload for bootstrap status, got %#v", payload.Data)
- }
- if hasUsers, _ := bootstrapData["hasUsers"].(bool); !hasUsers {
- t.Fatalf("expected hasUsers=true after owner bootstrap")
- }
- if registrationOpen, _ := bootstrapData["registrationOpen"].(bool); registrationOpen {
- t.Fatalf("expected registrationOpen=false after owner bootstrap")
- }
-
- // Owner session cookie should authorize /auth/me.
- status, payload = requestJSON(t, authClient, http.MethodGet, baseURL+"/api/v1/auth/me", "")
- if status != http.StatusOK || !payload.OK {
- t.Fatalf("expected successful /auth/me response, got status=%d payload=%+v", status, payload)
- }
- meData, ok := payload.Data.(map[string]any)
- if !ok {
- t.Fatalf("expected object payload for auth/me, got %#v", payload.Data)
- }
- if email, _ := meData["email"].(string); email != "owner@example.com" {
- t.Fatalf("unexpected /auth/me email: %q", email)
- }
-
- // Logout should revoke auth session.
- status, payload = requestJSON(t, authClient, http.MethodPost, baseURL+"/api/v1/auth/logout", "")
- if status != http.StatusOK || !payload.OK {
- t.Fatalf("expected successful logout response, got status=%d payload=%+v", status, payload)
- }
- status, payload = requestJSON(t, authClient, http.MethodGet, baseURL+"/api/v1/auth/me", "")
- if status != http.StatusUnauthorized || payload.OK {
- t.Fatalf("expected unauthorized /auth/me after logout, got status=%d payload=%+v", status, payload)
- }
-}
-
-func TestServerServicesCreateValidationRules(t *testing.T) {
- ts, client := newTestServer(t)
- baseURL := ts.URL
-
- bootstrapAndLoginOwner(t, client, baseURL)
-
- // Route prefix under /api must be rejected.
- status, payload := requestJSON(
- t,
- client,
- http.MethodPost,
- baseURL+"/api/v1/services",
- `{"name":"Bad Service","upstreamUrl":"http://example.com","routePrefix":"/api/conflict"}`,
- )
- if status != http.StatusBadRequest || payload.OK {
- t.Fatalf("expected route conflict validation failure, got status=%d payload=%+v", status, payload)
- }
- if payload.Error.Code != "ROUTE_CONFLICT" {
- t.Fatalf("expected ROUTE_CONFLICT error code, got %q", payload.Error.Code)
- }
-
- // Valid service should be created successfully.
- status, payload = requestJSON(
- t,
- client,
- http.MethodPost,
- baseURL+"/api/v1/services",
- `{"name":"Web Frontend","upstreamUrl":"http://example.com","routePrefix":"/web"}`,
- )
- if status != http.StatusCreated || !payload.OK {
- t.Fatalf("expected successful service creation, got status=%d payload=%+v", status, payload)
- }
- createData, ok := payload.Data.(map[string]any)
- if !ok {
- t.Fatalf("expected object payload for service creation, got %#v", payload.Data)
- }
- if _, ok := createData["id"].(string); !ok {
- t.Fatalf("expected created service id in response")
- }
- if slug, _ := createData["slug"].(string); !strings.Contains(slug, "web-frontend") {
- t.Fatalf("expected normalized service slug, got %q", slug)
- }
-
- // List should contain the newly created service.
- status, payload = requestJSON(t, client, http.MethodGet, baseURL+"/api/v1/services", "")
- if status != http.StatusOK || !payload.OK {
- t.Fatalf("expected successful services list response, got status=%d payload=%+v", status, payload)
- }
- rows, ok := payload.Data.([]any)
- if !ok || len(rows) == 0 {
- t.Fatalf("expected non-empty services list, got %#v", payload.Data)
- }
-}
-
-func TestServerProxyWithAPIKey(t *testing.T) {
- ts, authClient := newTestServer(t)
- baseURL := ts.URL
-
- upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.Header.Get("x-api-key") != "" {
- w.WriteHeader(http.StatusBadRequest)
- _, _ = io.WriteString(w, "unexpected-api-key-header")
- return
- }
- _, _ = io.WriteString(w, "ok:"+r.URL.Path+"?"+r.URL.RawQuery)
- }))
- t.Cleanup(upstream.Close)
-
- bootstrapAndLoginOwner(t, authClient, baseURL)
-
- // Create service exposed at /web/* and proxied to the upstream test server.
- status, payload := requestJSON(
- t,
- authClient,
- http.MethodPost,
- baseURL+"/api/v1/services",
- `{"name":"Web Proxy","upstreamUrl":"`+upstream.URL+`","routePrefix":"/web"}`,
- )
- if status != http.StatusCreated || !payload.OK {
- t.Fatalf("expected successful service creation, got status=%d payload=%+v", status, payload)
- }
-
- // Mint API key for gateway access.
- status, payload = requestJSON(
- t,
- authClient,
- http.MethodPost,
- baseURL+"/api/v1/keys",
- `{"name":"integration-key","plan":"free"}`,
- )
- if status != http.StatusCreated || !payload.OK {
- t.Fatalf("expected successful key creation, got status=%d payload=%+v", status, payload)
- }
- keyData, ok := payload.Data.(map[string]any)
- if !ok {
- t.Fatalf("expected object payload for key creation, got %#v", payload.Data)
- }
- rawKey, _ := keyData["key"].(string)
- if strings.TrimSpace(rawKey) == "" {
- t.Fatalf("expected non-empty raw API key")
- }
-
- // No API key should be rejected.
- anonClient := &http.Client{}
- status, payload = requestJSON(t, anonClient, http.MethodGet, baseURL+"/web/ping?x=1", "")
- if status != http.StatusUnauthorized || payload.OK {
- t.Fatalf("expected unauthorized proxy request without key, got status=%d payload=%+v", status, payload)
- }
- if payload.Error.Code != "API_KEY_MISSING" {
- t.Fatalf("expected API_KEY_MISSING error code, got %q", payload.Error.Code)
- }
-
- // Valid API key should proxy request and strip the key before forwarding.
- req, err := http.NewRequest(http.MethodGet, baseURL+"/web/ping?x=1", nil)
- if err != nil {
- t.Fatalf("failed to create proxy request: %v", err)
- }
- req.Header.Set("x-api-key", rawKey)
-
- resp, err := anonClient.Do(req)
- if err != nil {
- t.Fatalf("proxy request failed: %v", err)
- }
- defer resp.Body.Close()
-
- bodyBytes, err := io.ReadAll(resp.Body)
- if err != nil {
- t.Fatalf("failed to read proxy response body: %v", err)
- }
- body := string(bodyBytes)
-
- if resp.StatusCode != http.StatusOK {
- t.Fatalf("expected proxy status 200, got %d body=%q", resp.StatusCode, body)
- }
- if body != "ok:/ping?x=1" {
- t.Fatalf("unexpected proxy body: %q", body)
- }
-}
diff --git a/app/backend/internal/apwhy/api/static/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 b/app/backend/internal/apwhy/api/static/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2
deleted file mode 100644
index 20e72b0..0000000
Binary files a/app/backend/internal/apwhy/api/static/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 and /dev/null differ
diff --git a/app/backend/internal/apwhy/api/static/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff b/app/backend/internal/apwhy/api/static/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff
deleted file mode 100644
index 1487d93..0000000
Binary files a/app/backend/internal/apwhy/api/static/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff and /dev/null differ
diff --git a/app/backend/internal/apwhy/api/static/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff b/app/backend/internal/apwhy/api/static/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff
deleted file mode 100644
index eff6edd..0000000
Binary files a/app/backend/internal/apwhy/api/static/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff and /dev/null differ
diff --git a/app/backend/internal/apwhy/api/static/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 b/app/backend/internal/apwhy/api/static/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2
deleted file mode 100644
index b54e556..0000000
Binary files a/app/backend/internal/apwhy/api/static/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 and /dev/null differ
diff --git a/app/backend/internal/apwhy/api/static/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff b/app/backend/internal/apwhy/api/static/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff
deleted file mode 100644
index 8c83fdc..0000000
Binary files a/app/backend/internal/apwhy/api/static/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff and /dev/null differ
diff --git a/app/backend/internal/apwhy/api/static/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 b/app/backend/internal/apwhy/api/static/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2
deleted file mode 100644
index 0804aaf..0000000
Binary files a/app/backend/internal/apwhy/api/static/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 and /dev/null differ
diff --git a/app/backend/internal/apwhy/api/static/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 b/app/backend/internal/apwhy/api/static/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2
deleted file mode 100644
index 732c64d..0000000
Binary files a/app/backend/internal/apwhy/api/static/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 and /dev/null differ
diff --git a/app/backend/internal/apwhy/api/static/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff b/app/backend/internal/apwhy/api/static/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff
deleted file mode 100644
index 338fc76..0000000
Binary files a/app/backend/internal/apwhy/api/static/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff and /dev/null differ
diff --git a/app/backend/internal/apwhy/api/static/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 b/app/backend/internal/apwhy/api/static/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2
deleted file mode 100644
index 21612f4..0000000
Binary files a/app/backend/internal/apwhy/api/static/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 and /dev/null differ
diff --git a/app/backend/internal/apwhy/api/static/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff b/app/backend/internal/apwhy/api/static/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff
deleted file mode 100644
index dfa68b0..0000000
Binary files a/app/backend/internal/apwhy/api/static/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff and /dev/null differ
diff --git a/app/backend/internal/apwhy/api/static/assets/index-DRUelTBf.css b/app/backend/internal/apwhy/api/static/assets/index-DRUelTBf.css
deleted file mode 100644
index 63387b5..0000000
--- a/app/backend/internal/apwhy/api/static/assets/index-DRUelTBf.css
+++ /dev/null
@@ -1 +0,0 @@
-@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:400;src:url(data:font/woff2;base64,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) format("woff2"),url(data:font/woff;base64,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) format("woff");unicode-range:U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:400;src:url(/assets/manrope-cyrillic-400-normal-BMzJvInZ.woff2) format("woff2"),url(/assets/manrope-cyrillic-400-normal-Dvx59UGC.woff) format("woff");unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:400;src:url(/assets/manrope-greek-400-normal-CM4qok81.woff2) format("woff2"),url(/assets/manrope-greek-400-normal-DuX9RsAR.woff) format("woff");unicode-range:U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF}@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:400;src:url(/assets/manrope-vietnamese-400-normal-DHb3EETF.woff2) format("woff2"),url(/assets/manrope-vietnamese-400-normal-D7E_mLGF.woff) format("woff");unicode-range:U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:400;src:url(/assets/manrope-latin-ext-400-normal-CMDvPJRp.woff2) format("woff2"),url(/assets/manrope-latin-ext-400-normal-C-X6QNXX.woff) format("woff");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:400;src:url(/assets/manrope-latin-400-normal-PaqtzbVb.woff2) format("woff2"),url(/assets/manrope-latin-400-normal-8tf8FM3T.woff) format("woff");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:600;src:url(data:font/woff2;base64,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) format("woff2"),url(data:font/woff;base64,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) format("woff");unicode-range:U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:600;src:url(/assets/manrope-cyrillic-600-normal-DvRl3Mj-.woff2) format("woff2"),url(/assets/manrope-cyrillic-600-normal-It4mZcQk.woff) format("woff");unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:600;src:url(/assets/manrope-greek-600-normal-BoRV6lzK.woff2) format("woff2"),url(/assets/manrope-greek-600-normal-CF2i9ZRY.woff) format("woff");unicode-range:U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF}@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:600;src:url(/assets/manrope-vietnamese-600-normal-C1J5PCl_.woff2) format("woff2"),url(/assets/manrope-vietnamese-600-normal-lA7a_7Ok.woff) format("woff");unicode-range:U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:600;src:url(/assets/manrope-latin-ext-600-normal-_gBojHdJ.woff2) format("woff2"),url(/assets/manrope-latin-ext-600-normal-u5Pl7hTU.woff) format("woff");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:600;src:url(/assets/manrope-latin-600-normal-4f0koTD-.woff2) format("woff2"),url(/assets/manrope-latin-600-normal-BqgrALkZ.woff) format("woff");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:700;src:url(data:font/woff2;base64,d09GMgABAAAAAAYgAA8AAAAAC6wAAAXGAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAG4F8HC4GYD9TVEFUKgCBChEICok0h0cLIAABNgIkAzwEIAWEQAeBDgwHG5cJCB6FcZsTTpEclTZ1ofCYsgigtf/N7skX04SpVaInlfSIVkUjP2GSIHRN3jxyP4+n/+cmKTSyb2AG30HZWweyUJo+QKzeh1mF/xdjrs6zSTMtYbWZ/NyiSXJLWxqQIFKSe1ofxF0ne+ShzqSmdMSp6nFvLQIqjYKRSxSVjeCxeyKrAY+9fZwOPFDwTdTtGWJ1IKRmSEUFRpbDQcJrb8IFyJUDxecUvR3IkY7xO77kfFAsKE4Yev2ygnvUvOJSrKX4r+JPAtpuVgnKBVEikytTrlIQVEvy15AhyqWCMmXCdFUc0CNBugWUMWh5nxiwDWrZjela4gpkZM3UCeAqVup2T4oLAMjGMiC6cPOKqjLYATX9Z4SN/x4A6JVEqxzQNUA6g7cD6EcJywvRzF/fjTg8MsohQBQg5MQVMAQkBAtJXJ1yCGjlgpSr0a/QDngdInWcukx92X9mMR167jHqEvVFdfF1caPQLNylKD7tAtQGxKrVqndSjUy5XIWyza8vqgMI2Xv8EAhrAWIC5J2ADtJRIiGaAlZHAMinJmGMcLGLfD2/i8g/+CRlkRQVKUHuMCm4SHJcNlE8STKhAznD8coOQYf8kJLNhqpmWCUX7cWcxWJzQU02SaZUyyZQkynmtd2yzGqFWEnOtIEHiUuWnBZKPFmNXGG09NFb6OzbHxeHffgGzjg+aLba7cj4Sc02iwXOWK1D5CTJxMmSCcRk4nFr3qatKQSFBixziDo8wmDJZrKbqnjQ0rw4UBiqM4PWKFBus8UFDevXeztbQFXlhRtw5mTmxRmUFa0/7i/ZYaG1JnbhiG1+p6d2vyp2824081TMoa4S9IEjPmIV5Pfjr3tpjS+wFByJesBitmjGilpci+hcOS6nswqsYHAMZvA5OIfOYozp4xJxIoOlMR2IwTEaQhgOw8ux/BDOD/tBVPfQjr7i3//he1c6zdcspTI+/OVDmDgt+/xG43ncctTsilG5X+PCutoFjVcSGSaRx6801i3wd6bP+dE24se6oN31vuW5Zbm+Qavrq8H7844ZXTsfphnPOZZ2/7ln0y+15S7LMk0TxirNuyrpwAkf/gTZdci3//DpD/+ZHSlX9tS9/76nwBGHI1Wm4AfaP1oBr872fr1tW+8X2L4CSdEvkT2Xwf2RUZ85+CeXn51dSecfk/cdBZ8tWD1lWVD1/ntrA5ZN4LegMnf+L3xvouGga81AWVepJlzz008Ybj6/2nj1yNu7flw5B+4dmv//j8u375Yh5m7qgXnwc8XOD1csvr5h69JLy/qmNGdvGt667BIc7KlYG9bdlTp8SVmWNntkzPj/pcWx+pj+MLepkXGx3/toGtxD83vSm67xk/9IjFS9w4zYlVv/u1pxdezOELjy++FfNIzSyoSGVOmvKz0iJ4wcqRpDZ9enGfjBGf/xCpMmxuU13ugwdB7I2bdXsbMxM8wU0nsRYg4pKSB9k12HgPQsn16Z/qbHOedXISl4Ae8e7JsD+NBXtx3838DbLjgKQS6CwAsnnCeAbEEAmuuyB95mcyEqZab3OSRbAUv4JMxJ63WHzYNLlMWGsAJwgnTkdz5bC6HONjIVwhBa+W2VTiGFVARcRyEyXjDsP15U7h8vscgZXmqcebxMJw0vN0YBt9YUUhkEqgF+18dtN0RpxCiuqYhYBlkSDjMZwFIy4HxCPJNBg9+MHmtEghoKlar00WHpGQwpNgGH1aey8+viMPQ0BjUbwnZXrKcjl8r3S5MotZgt93Dydvpnl61BqSplsiekxYMPD0VWyxOx/vHJEiXJJNdotIOuHUKth1TBADFUyLzRoN+ImFzUpltMcWiE0jJg1u+lB+hpDyM9qg87SGBQn2ZSO+AfRiOAYotJwNrqMOxhiDAnkIhCPMRHAiSEJIiQGEn4hf3s0LghUdPfX07iBiYKzDplYipTiPcqxiKzBZaBTF6SWJhIFNWnRyUnMqkX0P9GqKU=) format("woff2"),url(data:font/woff;base64,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) format("woff");unicode-range:U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:700;src:url(/assets/manrope-cyrillic-700-normal-Dw_fZAg2.woff2) format("woff2"),url(/assets/manrope-cyrillic-700-normal-7JNVKxyl.woff) format("woff");unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:700;src:url(/assets/manrope-greek-700-normal-CHUG9PD8.woff2) format("woff2"),url(/assets/manrope-greek-700-normal-DyfsrCpP.woff) format("woff");unicode-range:U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF}@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:700;src:url(/assets/manrope-vietnamese-700-normal-CUqMx5-1.woff2) format("woff2"),url(/assets/manrope-vietnamese-700-normal-pt65Fn2Z.woff) format("woff");unicode-range:U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:700;src:url(/assets/manrope-latin-ext-700-normal-DYOwVNan.woff2) format("woff2"),url(/assets/manrope-latin-ext-700-normal-eVCcYqtJ.woff) format("woff");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:700;src:url(/assets/manrope-latin-700-normal-BZp_XxE4.woff2) format("woff2"),url(/assets/manrope-latin-700-normal-DGRFkw-m.woff) format("woff");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:400;src:url(/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2) format("woff2"),url(/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff) format("woff");unicode-range:U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:400;src:url(/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2) format("woff2"),url(/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff) format("woff");unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:400;src:url(/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2) format("woff2"),url(/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff) format("woff");unicode-range:U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:400;src:url(/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2) format("woff2"),url(/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff) format("woff");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:400;src:url(/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2) format("woff2"),url(/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff) format("woff");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}*,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:Manrope,ui-sans-serif,system-ui,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:IBM Plex Mono,ui-monospace,SFMono-Regular,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.mx-auto{margin-left:auto;margin-right:auto}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.ml-2{margin-left:.5rem}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-20{margin-top:5rem}.mt-4{margin-top:1rem}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.h-2{height:.5rem}.max-h-56{max-height:14rem}.min-h-screen{min-height:100vh}.w-full{width:100%}.min-w-full{min-width:100%}.max-w-\[1500px\]{max-width:1500px}.max-w-\[320px\]{max-width:320px}.max-w-md{max-width:28rem}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-2{gap:.5rem}.gap-4{gap:1rem}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse: 0;margin-right:calc(.5rem * var(--tw-space-x-reverse));margin-left:calc(.5rem * calc(1 - var(--tw-space-x-reverse)))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem * var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem * var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.75rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem * var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse: 0;border-top-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px * var(--tw-divide-y-reverse))}.divide-border>:not([hidden])~:not([hidden]){border-color:hsl(var(--border))}.overflow-auto{overflow:auto}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-md{border-radius:.6rem}.rounded-xl{border-radius:.75rem}.border{border-width:1px}.border-t{border-top-width:1px}.border-amber-500\/45{border-color:#f59e0b73}.border-amber-500\/50{border-color:#f59e0b80}.border-border{border-color:hsl(var(--border))}.border-destructive{border-color:hsl(var(--destructive))}.border-emerald-500\/50{border-color:#10b98180}.border-input{border-color:hsl(var(--input))}.border-primary{border-color:hsl(var(--primary))}.border-primary\/40{border-color:hsl(var(--primary) / .4)}.border-primary\/50{border-color:hsl(var(--primary) / .5)}.border-rose-500\/50{border-color:#f43f5e80}.bg-amber-500\/10{background-color:#f59e0b1a}.bg-amber-500\/15{background-color:#f59e0b26}.bg-background{background-color:hsl(var(--background))}.bg-card{background-color:hsl(var(--card))}.bg-card\/90{background-color:hsl(var(--card) / .9)}.bg-destructive{background-color:hsl(var(--destructive))}.bg-emerald-500\/15{background-color:#10b98126}.bg-primary{background-color:hsl(var(--primary))}.bg-primary\/10{background-color:hsl(var(--primary) / .1)}.bg-primary\/15{background-color:hsl(var(--primary) / .15)}.bg-primary\/20{background-color:hsl(var(--primary) / .2)}.bg-rose-500\/10{background-color:#f43f5e1a}.bg-rose-500\/15{background-color:#f43f5e26}.bg-secondary{background-color:hsl(var(--secondary))}.bg-secondary\/35{background-color:hsl(var(--secondary) / .35)}.bg-secondary\/40{background-color:hsl(var(--secondary) / .4)}.bg-secondary\/60{background-color:hsl(var(--secondary) / .6)}.bg-secondary\/70{background-color:hsl(var(--secondary) / .7)}.bg-transparent{background-color:transparent}.p-10{padding:2.5rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-top{vertical-align:top}.font-mono{font-family:IBM Plex Mono,ui-monospace,SFMono-Regular,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.tracking-\[0\.18em\]{letter-spacing:.18em}.tracking-\[0\.2em\]{letter-spacing:.2em}.tracking-wide{letter-spacing:.025em}.text-amber-100{--tw-text-opacity: 1;color:rgb(254 243 199 / var(--tw-text-opacity, 1))}.text-amber-200{--tw-text-opacity: 1;color:rgb(253 230 138 / var(--tw-text-opacity, 1))}.text-amber-200\/90{color:#fde68ae6}.text-destructive-foreground{color:hsl(var(--destructive-foreground))}.text-emerald-200{--tw-text-opacity: 1;color:rgb(167 243 208 / var(--tw-text-opacity, 1))}.text-foreground{color:hsl(var(--foreground))}.text-muted-foreground{color:hsl(var(--muted-foreground))}.text-primary{color:hsl(var(--primary))}.text-primary-foreground{color:hsl(var(--primary-foreground))}.text-rose-200{--tw-text-opacity: 1;color:rgb(254 205 211 / var(--tw-text-opacity, 1))}.text-secondary-foreground{color:hsl(var(--secondary-foreground))}.shadow-\[0_8px_24px_rgba\(0\,0\,0\,0\.24\)\]{--tw-shadow: 0 8px 24px rgba(0,0,0,.24);--tw-shadow-colored: 0 8px 24px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.outline-none{outline:2px solid transparent;outline-offset:2px}.outline{outline-style:solid}.ring-0{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}:root{--background: 147 32% 9%;--foreground: 152 28% 94%;--card: 148 30% 12%;--card-foreground: 152 28% 93%;--primary: 142 73% 45%;--primary-foreground: 144 35% 8%;--secondary: 145 32% 18%;--secondary-foreground: 145 28% 92%;--muted: 146 26% 17%;--muted-foreground: 148 18% 74%;--accent: 136 66% 40%;--accent-foreground: 150 38% 95%;--destructive: 1 74% 56%;--destructive-foreground: 0 0% 98%;--border: 146 24% 24%;--input: 147 25% 20%;--ring: 142 73% 45%}*{border-color:hsl(var(--border))}body{background-color:hsl(var(--background));color:hsl(var(--foreground));-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;font-family:Manrope,ui-sans-serif,system-ui,sans-serif}code,pre{font-family:IBM Plex Mono,ui-monospace,SFMono-Regular,monospace}.placeholder\:text-muted-foreground::-moz-placeholder{color:hsl(var(--muted-foreground))}.placeholder\:text-muted-foreground::placeholder{color:hsl(var(--muted-foreground))}.hover\:bg-secondary:hover{background-color:hsl(var(--secondary))}.hover\:opacity-95:hover{opacity:.95}.focus\:border-primary:focus{border-color:hsl(var(--primary))}@media(min-width:768px){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:items-center{align-items:center}.md\:justify-between{justify-content:space-between}.md\:p-6{padding:1.5rem}}@media(min-width:1024px){.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:p-8{padding:2rem}}@media(min-width:1280px){.xl\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.xl\:grid-cols-\[360px_1fr\]{grid-template-columns:360px 1fr}.xl\:grid-cols-\[420px_1fr\]{grid-template-columns:420px 1fr}}
diff --git a/app/backend/internal/apwhy/api/static/assets/index-DwfYiTMH.js b/app/backend/internal/apwhy/api/static/assets/index-DwfYiTMH.js
deleted file mode 100644
index 8bab5af..0000000
--- a/app/backend/internal/apwhy/api/static/assets/index-DwfYiTMH.js
+++ /dev/null
@@ -1 +0,0 @@
-(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const s of document.querySelectorAll('link[rel="modulepreload"]'))n(s);new MutationObserver(s=>{for(const i of s)if(i.type==="childList")for(const o of i.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&n(o)}).observe(document,{childList:!0,subtree:!0});function r(s){const i={};return s.integrity&&(i.integrity=s.integrity),s.referrerPolicy&&(i.referrerPolicy=s.referrerPolicy),s.crossOrigin==="use-credentials"?i.credentials="include":s.crossOrigin==="anonymous"?i.credentials="omit":i.credentials="same-origin",i}function n(s){if(s.ep)return;s.ep=!0;const i=r(s);fetch(s.href,i)}})();const Ge=!1,We=(e,t)=>e===t,Se=Symbol("solid-proxy"),Qe=typeof Proxy=="function",Xe=Symbol("solid-track"),de={equals:We};let je=Fe;const J=1,fe=2,Ve={owned:null,cleanups:null,context:null,owner:null},ve={};var S=null;let xe=null,Ye=null,_=null,I=null,q=null,he=0;function ce(e,t){const r=_,n=S,s=e.length===0,i=t===void 0?n:t,o=s?Ve:{owned:null,cleanups:null,context:i?i.context:null,owner:i},a=s?e:()=>e(()=>j(()=>re(o)));S=o,_=null;try{return H(a,!0)}finally{_=r,S=n}}function y(e,t){t=t?Object.assign({},de,t):de;const r={value:e,observers:null,observerSlots:null,comparator:t.equals||void 0},n=s=>(typeof s=="function"&&(s=s(r.value)),qe(r,s));return[Be.bind(r),n]}function ze(e,t,r){const n=me(e,t,!0,J);z(n)}function L(e,t,r){const n=me(e,t,!1,J);z(n)}function Ee(e,t,r){je=it;const n=me(e,t,!1,J);n.user=!0,q?q.push(n):z(n)}function R(e,t,r){r=r?Object.assign({},de,r):de;const n=me(e,t,!0,0);return n.observers=null,n.observerSlots=null,n.comparator=r.equals||void 0,z(n),Be.bind(n)}function Ze(e){return e&&typeof e=="object"&&"then"in e}function F(e,t,r){let n,s,i;n=!0,s=e,i={};let o=null,a=ve,h=!1,m="initialValue"in i,d=typeof n=="function"&&R(n);const l=new Set,[f,g]=(i.storage||y)(i.initialValue),[b,x]=y(void 0),[V,U]=y(void 0,{equals:!1}),[T,O]=y(m?"ready":"unresolved");function k(w,$,B,N){return o===w&&(o=null,N!==void 0&&(m=!0),(w===a||$===a)&&i.onHydrated&&queueMicrotask(()=>i.onHydrated(N,{value:$})),a=ve,M($,B)),$}function M(w,$){H(()=>{$===void 0&&g(()=>w),O($!==void 0?"errored":m?"ready":"unresolved"),x($);for(const B of l.keys())B.decrement();l.clear()},!1)}function G(){const w=nt,$=f(),B=b();if(B!==void 0&&!o)throw B;return _&&_.user,$}function Z(w=!0){if(w!==!1&&h)return;h=!1;const $=d?d():n;if($==null||$===!1){k(o,j(f));return}let B;const N=a!==ve?a:j(()=>{try{return s($,{value:f(),refetching:w})}catch(te){B=te}});if(B!==void 0){k(o,void 0,ue(B),$);return}else if(!Ze(N))return k(o,N,void 0,$),N;return o=N,"v"in N?(N.s===1?k(o,N.v,void 0,$):k(o,void 0,ue(N.v),$),N):(h=!0,queueMicrotask(()=>h=!1),H(()=>{O(m?"refreshing":"pending"),U()},!1),N.then(te=>k(N,te,void 0,$),te=>k(N,void 0,ue(te),$)))}Object.defineProperties(G,{state:{get:()=>T()},error:{get:()=>b()},loading:{get(){const w=T();return w==="pending"||w==="refreshing"}},latest:{get(){if(!m)return G();const w=b();if(w&&!o)throw w;return f()}}});let ee=S;return d?ze(()=>(ee=S,Z(!1))):Z(!1),[G,{refetch:w=>tt(ee,()=>Z(w)),mutate:g}]}function j(e){if(_===null)return e();const t=_;_=null;try{return e()}finally{_=t}}function et(e){return S===null||(S.cleanups===null?S.cleanups=[e]:S.cleanups.push(e)),e}function tt(e,t){const r=S,n=_;S=e,_=null;try{return H(t,!0)}catch(s){ke(s)}finally{S=r,_=n}}const[rl,nl]=y(!1);function rt(e){const t=R(e),r=R(()=>Ce(t()));return r.toArray=()=>{const n=r();return Array.isArray(n)?n:n!=null?[n]:[]},r}let nt;function Be(){if(this.sources&&this.state)if(this.state===J)z(this);else{const e=I;I=null,H(()=>ge(this),!1),I=e}if(_){const e=this.observers?this.observers.length:0;_.sources?(_.sources.push(this),_.sourceSlots.push(e)):(_.sources=[this],_.sourceSlots=[e]),this.observers?(this.observers.push(_),this.observerSlots.push(_.sources.length-1)):(this.observers=[_],this.observerSlots=[_.sources.length-1])}return this.value}function qe(e,t,r){let n=e.value;return(!e.comparator||!e.comparator(n,t))&&(e.value=t,e.observers&&e.observers.length&&H(()=>{for(let s=0;s1e6)throw I=[],new Error},!1)),t}function z(e){if(!e.fn)return;re(e);const t=he;lt(e,e.value,t)}function lt(e,t,r){let n;const s=S,i=_;_=S=e;try{n=e.fn(t)}catch(o){return e.pure&&(e.state=J,e.owned&&e.owned.forEach(re),e.owned=null),e.updatedAt=r+1,ke(o)}finally{_=i,S=s}(!e.updatedAt||e.updatedAt<=r)&&(e.updatedAt!=null&&"observers"in e?qe(e,n):e.value=n,e.updatedAt=r)}function me(e,t,r,n=J,s){const i={fn:e,state:n,updatedAt:null,owned:null,sources:null,sourceSlots:null,cleanups:null,value:t,owner:S,context:S?S.context:null,pure:r};return S===null||S!==Ve&&(S.owned?S.owned.push(i):S.owned=[i]),i}function pe(e){if(e.state===0)return;if(e.state===fe)return ge(e);if(e.suspense&&j(e.suspense.inFallback))return e.suspense.effects.push(e);const t=[e];for(;(e=e.owner)&&(!e.updatedAt||e.updatedAt=0;r--)if(e=t[r],e.state===J)z(e);else if(e.state===fe){const n=I;I=null,H(()=>ge(e,t[0]),!1),I=n}}function H(e,t){if(I)return e();let r=!1;t||(I=[]),q?r=!0:q=[],he++;try{const n=e();return st(r),n}catch(n){r||(q=null),I=null,ke(n)}}function st(e){if(I&&(Fe(I),I=null),e)return;const t=q;q=null,t.length&&H(()=>je(t),!1)}function Fe(e){for(let t=0;t=0;t--)re(e.tOwned[t]);delete e.tOwned}if(e.owned){for(t=e.owned.length-1;t>=0;t--)re(e.owned[t]);e.owned=null}if(e.cleanups){for(t=e.cleanups.length-1;t>=0;t--)e.cleanups[t]();e.cleanups=null}e.state=0}function ue(e){return e instanceof Error?e:new Error(typeof e=="string"?e:"Unknown error",{cause:e})}function ke(e,t=S){throw ue(e)}function Ce(e){if(typeof e=="function"&&!e.length)return Ce(e());if(Array.isArray(e)){const t=[];for(let r=0;r1?[]:null;return et(()=>Ie(i)),()=>{let h=e()||[],m=h.length,d,l;return h[Xe],j(()=>{let g,b,x,V,U,T,O,k,M;if(m===0)o!==0&&(Ie(i),i=[],n=[],s=[],o=0,a&&(a=[])),r.fallback&&(n=[at],s[0]=ce(G=>(i[0]=G,r.fallback())),o=1);else if(o===0){for(s=new Array(m),l=0;l=T&&k>=T&&n[O]===h[k];O--,k--)x[k]=s[O],V[k]=i[O],a&&(U[k]=a[O]);for(g=new Map,b=new Array(k+1),l=k;l>=T;l--)M=h[l],d=g.get(M),b[l]=d===void 0?-1:d,g.set(M,l);for(d=T;d<=O;d++)M=n[d],l=g.get(M),l!==void 0&&l!==-1?(x[l]=s[d],V[l]=i[d],a&&(U[l]=a[d]),l=b[l],g.set(M,l)):i[d]();for(l=T;le(t||{}))}function ae(){return!0}const ct={get(e,t,r){return t===Se?r:e.get(t)},has(e,t){return t===Se?!0:e.has(t)},set:ae,deleteProperty:ae,getOwnPropertyDescriptor(e,t){return{configurable:!0,enumerable:!0,get(){return e.get(t)},set:ae,deleteProperty:ae}},ownKeys(e){return e.keys()}};function we(e){return(e=typeof e=="function"?e():e)?e:{}}function ut(){for(let e=0,t=this.length;e=0;a--){const h=we(e[a])[o];if(h!==void 0)return h}},has(o){for(let a=e.length-1;a>=0;a--)if(o in we(e[a]))return!0;return!1},keys(){const o=[];for(let a=0;a=0;o--){const a=e[o];if(!a)continue;const h=Object.getOwnPropertyNames(a);for(let m=h.length-1;m>=0;m--){const d=h[m];if(d==="__proto__"||d==="constructor")continue;const l=Object.getOwnPropertyDescriptor(a,d);if(!n[d])n[d]=l.get?{enumerable:!0,configurable:!0,get:ut.bind(r[d]=[l.get.bind(a)])}:l.value!==void 0?l:void 0;else{const f=r[d];f&&(l.get?f.push(l.get.bind(a)):l.value!==void 0&&f.push(()=>l.value))}}}const s={},i=Object.keys(n);for(let o=i.length-1;o>=0;o--){const a=i[o],h=n[a];h&&h.get?Object.defineProperty(s,a,h):s[a]=h?h.value:void 0}return s}const Je=e=>`Stale read from <${e}>.`;function K(e){const t="fallback"in e&&{fallback:()=>e.fallback};return R(ot(()=>e.each,e.children,t||void 0))}function ye(e){const t=e.keyed,r=R(()=>e.when,void 0,void 0),n=t?r:R(r,void 0,{equals:(s,i)=>!s==!i});return R(()=>{const s=n();if(s){const i=e.children;return typeof i=="function"&&i.length>0?j(()=>i(t?s:()=>{if(!j(n))throw Je("Show");return r()})):i}return e.fallback},void 0,void 0)}function dt(e){const t=rt(()=>e.children),r=R(()=>{const n=t(),s=Array.isArray(n)?n:[n];let i=()=>{};for(let o=0;om()?void 0:h.when,void 0,void 0),l=h.keyed?d:R(d,void 0,{equals:(f,g)=>!f==!g});i=()=>m()||(l()?[a,d,h]:void 0)}return i});return R(()=>{const n=r()();if(!n)return e.fallback;const[s,i,o]=n,a=o.children;return typeof a=="function"&&a.length>0?j(()=>a(o.keyed?i():()=>{if(j(r)()?.[0]!==s)throw Je("Match");return i()})):a},void 0,void 0)}function W(e){return e}const ft=["allowfullscreen","async","alpha","autofocus","autoplay","checked","controls","default","disabled","formnovalidate","hidden","indeterminate","inert","ismap","loop","multiple","muted","nomodule","novalidate","open","playsinline","readonly","required","reversed","seamless","selected","adauctionheaders","browsingtopics","credentialless","defaultchecked","defaultmuted","defaultselected","defer","disablepictureinpicture","disableremoteplayback","preservespitch","shadowrootclonable","shadowrootcustomelementregistry","shadowrootdelegatesfocus","shadowrootserializable","sharedstoragewritable"],pt=new Set(["className","value","readOnly","noValidate","formNoValidate","isMap","noModule","playsInline","adAuctionHeaders","allowFullscreen","browsingTopics","defaultChecked","defaultMuted","defaultSelected","disablePictureInPicture","disableRemotePlayback","preservesPitch","shadowRootClonable","shadowRootCustomElementRegistry","shadowRootDelegatesFocus","shadowRootSerializable","sharedStorageWritable",...ft]),gt=new Set(["innerHTML","textContent","innerText","children"]),ht=Object.assign(Object.create(null),{className:"class",htmlFor:"for"}),mt=Object.assign(Object.create(null),{class:"className",novalidate:{$:"noValidate",FORM:1},formnovalidate:{$:"formNoValidate",BUTTON:1,INPUT:1},ismap:{$:"isMap",IMG:1},nomodule:{$:"noModule",SCRIPT:1},playsinline:{$:"playsInline",VIDEO:1},readonly:{$:"readOnly",INPUT:1,TEXTAREA:1},adauctionheaders:{$:"adAuctionHeaders",IFRAME:1},allowfullscreen:{$:"allowFullscreen",IFRAME:1},browsingtopics:{$:"browsingTopics",IMG:1},defaultchecked:{$:"defaultChecked",INPUT:1},defaultmuted:{$:"defaultMuted",AUDIO:1,VIDEO:1},defaultselected:{$:"defaultSelected",OPTION:1},disablepictureinpicture:{$:"disablePictureInPicture",VIDEO:1},disableremoteplayback:{$:"disableRemotePlayback",AUDIO:1,VIDEO:1},preservespitch:{$:"preservesPitch",AUDIO:1,VIDEO:1},shadowrootclonable:{$:"shadowRootClonable",TEMPLATE:1},shadowrootdelegatesfocus:{$:"shadowRootDelegatesFocus",TEMPLATE:1},shadowrootserializable:{$:"shadowRootSerializable",TEMPLATE:1},sharedstoragewritable:{$:"sharedStorageWritable",IFRAME:1,IMG:1}});function bt(e,t){const r=mt[e];return typeof r=="object"?r[t]?r.$:void 0:r}const yt=new Set(["beforeinput","click","dblclick","contextmenu","focusin","focusout","input","keydown","keyup","mousedown","mousemove","mouseout","mouseover","mouseup","pointerdown","pointermove","pointerout","pointerover","pointerup","touchend","touchmove","touchstart"]),Q=e=>R(()=>e());function $t(e,t,r){let n=r.length,s=t.length,i=n,o=0,a=0,h=t[s-1].nextSibling,m=null;for(;od-a){const b=t[o];for(;a{s=i,t===document?e():u(t,e(),t.firstChild?null:void 0,r)},n.owner),()=>{s(),t.textContent=""}}function p(e,t,r,n){let s;const i=()=>{const a=document.createElement("template");return a.innerHTML=e,a.content.firstChild},o=()=>(s||(s=i())).cloneNode(!0);return o.cloneNode=o,o}function Oe(e,t=window.document){const r=t[Ne]||(t[Ne]=new Set);for(let n=0,s=e.length;ns.call(e,r[1],i))}else e.addEventListener(t,r,typeof r!="function"&&r)}function _t(e,t,r={}){const n=Object.keys(t||{}),s=Object.keys(r);let i,o;for(i=0,o=s.length;is.children=ne(e,t.children,s.children)),L(()=>typeof t.ref=="function"&&Pt(t.ref,e)),L(()=>At(e,t,r,!0,s,!0)),s}function Pt(e,t,r){return j(()=>e(t,r))}function u(e,t,r,n){if(r!==void 0&&!n&&(n=[]),typeof t!="function")return ne(e,t,n,r);L(s=>ne(e,t(),s,r),n)}function At(e,t,r,n,s={},i=!1){t||(t={});for(const o in s)if(!(o in t)){if(o==="children")continue;s[o]=Re(e,o,null,s[o],r,i,t)}for(const o in t){if(o==="children")continue;const a=t[o];s[o]=Re(e,o,a,s[o],r,i,t)}}function kt(e){return e.toLowerCase().replace(/-([a-z])/g,(t,r)=>r.toUpperCase())}function Le(e,t,r){const n=t.trim().split(/\s+/);for(let s=0,i=n.length;sObject.defineProperty(e,"target",{configurable:!0,value:h}),o=()=>{const h=t[r];if(h&&!t.disabled){const m=t[`${r}Data`];if(m!==void 0?h.call(t,m,e):h.call(t,e),e.cancelBubble)return}return t.host&&typeof t.host!="string"&&!t.host._$host&&t.contains(e.target)&&i(t.host),!0},a=()=>{for(;o()&&(t=t._$host||t.parentNode||t.host););};if(Object.defineProperty(e,"currentTarget",{configurable:!0,get(){return t||document}}),e.composedPath){const h=e.composedPath();i(h[0]);for(let m=0;m{let a=t();for(;typeof a=="function";)a=a();r=ne(e,a,r,n)}),()=>r;if(Array.isArray(t)){const a=[],h=r&&Array.isArray(r);if(Ae(a,t,r,s))return L(()=>r=ne(e,a,r,n,!0)),()=>r;if(a.length===0){if(r=Y(e,r,n),o)return r}else h?r.length===0?De(e,a,n):$t(e,r,a):(r&&Y(e),De(e,a));r=a}else if(t.nodeType){if(Array.isArray(r)){if(o)return r=Y(e,r,n,t);Y(e,r,null,t)}else r==null||r===""||!e.firstChild?e.appendChild(t):e.replaceChild(t,e.firstChild);r=t}}return r}function Ae(e,t,r,n){let s=!1;for(let i=0,o=t.length;i=0;o--){const a=t[o];if(s!==a){const h=a.parentNode===e;!i&&!o?h?e.replaceChild(s,a):e.insertBefore(s,r):h&&a.remove()}else i=!0}}else e.insertBefore(s,r);return[s]}async function v(e,t){const r=await fetch(e,{credentials:"include",headers:{"Content-Type":"application/json",...t?.headers||{}},...t}),n=await r.json().catch(()=>({}));if(!r.ok)throw new Error(n?.error?.message||`Request failed (${r.status})`);return n}async function Tt(){return(await v("/api/v1/bootstrap/status")).data}async function Et(e,t){return v("/api/v1/bootstrap/register-owner",{method:"POST",body:JSON.stringify({email:e,password:t})})}async function It(e,t){return(await v("/api/v1/auth/login",{method:"POST",body:JSON.stringify({email:e,password:t})})).data.user}async function Ue(){return(await v("/api/v1/auth/me")).data}async function Nt(){return v("/api/v1/auth/logout",{method:"POST"})}async function Lt(e){return v("/api/v1/auth/reset-password",{method:"POST",body:JSON.stringify({newPassword:e})})}async function Rt(){return(await v("/api/v1/services")).data}async function Dt(e){return v("/api/v1/services",{method:"POST",body:JSON.stringify(e)})}async function Ut(e,t){return v(`/api/v1/services/${e}`,{method:"PATCH",body:JSON.stringify(t)})}async function Mt(e){return v(`/api/v1/services/${e}/validate`,{method:"POST"})}async function jt(){return(await v("/api/v1/databases")).data}async function Vt(e){return v("/api/v1/databases",{method:"POST",body:JSON.stringify(e)})}async function Bt(e,t){return v(`/api/v1/databases/${e}`,{method:"PATCH",body:JSON.stringify(t)})}async function qt(e){return v(`/api/v1/databases/${e}/validate`,{method:"POST"})}async function Ft(){return(await v("/api/v1/keys")).data}async function Kt(e){return v("/api/v1/keys",{method:"POST",body:JSON.stringify(e)})}async function Jt(e,t){return v(`/api/v1/keys/${e}`,{method:"PATCH",body:JSON.stringify(t)})}async function Ht(){return(await v("/api/v1/users")).data}async function Gt(e){return v("/api/v1/users",{method:"POST",body:JSON.stringify(e)})}async function Me(e,t){return v(`/api/v1/users/${e}`,{method:"PATCH",body:JSON.stringify(t)})}async function He(){return(await v("/api/v1/roles")).data}async function Wt(e){return v("/api/v1/roles",{method:"POST",body:JSON.stringify(e)})}async function Qt(e,t){return v(`/api/v1/roles/${e}`,{method:"PATCH",body:JSON.stringify(t)})}async function Xt(){return(await v("/api/v1/permissions")).data}async function Yt(){return v("/api/v1/analytics/ops")}async function zt(){return v("/api/v1/analytics/traffic")}async function Zt(e,t,r){return v("/api/v1/analytics/events",{method:"POST",body:JSON.stringify({event:e,path:t,meta:r})})}var er=p("");function E(e){const t=()=>e.variant||"default",r=()=>t()==="outline"?"border border-border bg-transparent text-foreground hover:bg-secondary":t()==="secondary"?"border border-border bg-secondary text-secondary-foreground hover:opacity-95":t()==="danger"?"border border-destructive bg-destructive text-destructive-foreground hover:opacity-95":"border border-primary bg-primary text-primary-foreground hover:opacity-95";return(()=>{var n=er();return $e(n,be(e,{get class(){return`inline-flex items-center justify-center rounded-md px-3 py-2 text-sm font-semibold transition ${r()} ${e.class||""}`}}),!1,!0),u(n,()=>e.children),n})()}var tr=p("");function A(e){return(()=>{var t=tr();return $e(t,be(e,{get class(){return`rounded-xl border border-border bg-card/90 p-4 shadow-[0_8px_24px_rgba(0,0,0,0.24)] ${e.class||""}`}}),!1,!0),u(t,()=>e.children),t})()}var rr=p("
");function D(e){return(()=>{var t=rr();return $e(t,be(e,{get class(){return`w-full rounded-md border border-input bg-card px-3 py-2 text-sm text-foreground outline-none ring-0 placeholder:text-muted-foreground focus:border-primary ${e.class||""}`}}),!1,!1),t})()}var nr=p('
APwhy
Sign in Registration is disabled once the owner account exists.'),lr=p('