conductor

CI task system
git clone git://git.finwo.net/app/conductor
Log | Files | Refs | README | LICENSE

commit e2474744346f085d1e1fa01f78fbc874bf83aa25
parent d7ba2dbf620b4a0890b9079134588b75146b2dfe
Author: finwo <finwo@pm.me>
Date:   Sat, 19 Sep 2026 02:59:54 +0200

Server-rendered UI, project ownership and run visibility

Diffstat:
MREADME.md | 45+++++++++++++++++++++++++++++----------------
Aassets/style.css | 182+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aassets/vendor/htmx-LICENSE.txt | 13+++++++++++++
Aassets/vendor/htmx.min.js | 2++
Mconductor.example.yaml | 4----
Ddashboard/app.js | 440-------------------------------------------------------------------------------
Ddashboard/index.html | 26--------------------------
Ddashboard/style.css | 122-------------------------------------------------------------------------------
Mdocs/pipeline.md | 29++++++++++++++++++++++++-----
Amigrations/mysql/002_ownership.sql | 18++++++++++++++++++
Amigrations/postgres/002_ownership.sql | 17+++++++++++++++++
Amigrations/sqlite/002_ownership.sql | 32++++++++++++++++++++++++++++++++
Mpackage.json | 1-
Msrc/conductor/app.js | 10+++++++---
Msrc/conductor/index.js | 8++++----
Msrc/conductor/routes/admin.js | 242+++++++++++++------------------------------------------------------------------
Asrc/conductor/routes/manage.js | 288+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/conductor/routes/runs.js | 133+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
Msrc/conductor/routes/workers.js | 2++
Msrc/conductor/scheduler.js | 21+++++++++++++++++----
Asrc/conductor/ui/html.js | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/conductor/ui/layout.js | 106+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/conductor/ui/pages.js | 430+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/conductor/ui/routes.js | 576+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/lib/config.js | 14+++-----------
Msrc/lib/db/sqlite.js | 2+-
Msrc/lib/pipeline/index.js | 9+++++++--
Msrc/lib/pipeline/parse.js | 18++++++++++++++++--
Msrc/lib/projects.js | 79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Msrc/lib/static.js | 22+++++++++++-----------
Msrc/lib/storage/index.js | 2+-
Msrc/lib/storage/s3.js | 2+-
Msrc/lib/users.js | 10++++++++++
Msrc/lib/workers.js | 44++++++++++++++++++++++++++++++++++++--------
Dsrc/read-api/app.js | 67-------------------------------------------------------------------
Dsrc/read-api/index.js | 29-----------------------------
Mtest/admin.test.js | 42++++++++++++++++++++++++------------------
Mtest/ascii.test.js | 5++++-
Mtest/db.test.js | 7++++---
Mtest/helpers/harness.js | 5+++++
Dtest/read-api.test.js | 200-------------------------------------------------------------------------------
Atest/ui.test.js | 392+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/visibility.test.js | 319+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
43 files changed, 2845 insertions(+), 1238 deletions(-)

diff --git a/README.md b/README.md @@ -31,8 +31,8 @@ Under construction. Working today: - pipeline parsing, matrix and architecture expansion, dependency graphs - triggers, run scheduling, the worker API, live logs, artifacts, reaping - the worker: containerised jobs, services, features, caches, cleanup - - accounts, OIDC, the admin API, and project variables - - the read-api and the dashboard + - accounts, OIDC, project ownership, visibility and project variables + - the web interface, server rendered with htmx Not yet built: container images and deployment manifests. @@ -48,17 +48,7 @@ npm start ``` On first start an administrator account is created and its generated -password written to the log once. The dashboard is on the same port. - -Run the public read surface separately when the write surface should not be -exposed: - -```sh -npm run read-api -``` - -It serves the dashboard and the read API only. The trigger, worker and admin -routes are not registered on it at all, so no credential can reach them. +password written to the log once. Open the port and sign in. `mysql2` and `pg` are optional and only needed when `database.url` points at one of those servers. @@ -110,6 +100,30 @@ node src/worker/agent.js --config worker.json See [docs/worker.md](docs/worker.md), and `examples/worker.json`. +Who sees what +------------- + +Everything is private by default. A run is visible to an anonymous visitor +only when the project, or the pipeline at that commit, says it is public: + +```yaml +version: 1 +visibility: public +``` + +Because it is recorded per run, making a repository private stops exposing +future runs without rewriting the history of past ones. + +Users register their own projects and their own workers. A worker someone +registers is only ever offered jobs from that person's projects, which is +what makes it safe to accept build capacity from people you do not +otherwise trust. A worker created by an administrator with no owner is +shared, and runs anything. + +Administrators see and manage everything, and manage accounts. Deleting an +account deletes the projects and workers it owned, along with their run +history: leaving them behind would turn a personal worker into a shared one. + Pipelines --------- @@ -169,9 +183,8 @@ git push -> post-receive -> conductor -> job graph in the database source tarball, container, log stream, artifacts ``` - - `src/conductor` the write side: triggers, scheduling, the worker API - - `src/read-api` the public read side, deployable separately - - `src/worker` the agent that runs jobs + - `src/conductor` triggers, scheduling, the worker API, the interface + - `src/worker` the agent that runs jobs, with no npm dependencies - `src/lib` configuration, database, storage, pipelines, git Queries are written once against all three databases using named `{key}` diff --git a/assets/style.css b/assets/style.css @@ -0,0 +1,182 @@ +/* dashboard/style.css - the whole interface, one stylesheet */ + +:root { + --bg: #101214; + --panel: #171a1d; + --border: #272b30; + --text: #dfe3e6; + --muted: #8b949e; + --link: #6cb6ff; + --queued: #3a4046; + --running: #b58900; + --success: #2e7d32; + --failed: #c62828; + --skipped: #4a5058; + --cancelled: #6a4a8a; + --danger: #a33; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + background: var(--bg); + color: var(--text); + font: 14px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; +} + +a { color: var(--link); text-decoration: none; } +a:hover { text-decoration: underline; } + +code, pre, .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } + +header { + display: flex; + align-items: center; + gap: 1.25rem; + padding: .7rem 1.25rem; + border-bottom: 1px solid var(--border); + background: var(--panel); +} + +header h1 { font-size: 1rem; margin: 0; font-weight: 600; } +header h1 a { color: var(--text); } +header nav { flex: 1; display: flex; gap: 1rem; } +header nav a { color: var(--muted); } +header nav a.on { color: var(--text); border-bottom: 2px solid var(--link); } +header .session { display: flex; align-items: center; gap: .6rem; } +header .session form { margin: 0; } + +.role { + font-size: .7rem; + text-transform: uppercase; + letter-spacing: .04em; + color: var(--muted); + border: 1px solid var(--border); + border-radius: .7rem; + padding: 0 .4rem; +} + +main { padding: 1.25rem; max-width: 1100px; margin: 0 auto; } + +h2 { font-size: 1.15rem; margin: 0 0 .75rem; } +h3 { font-size: .95rem; margin: 0 0 .5rem; } + +.muted { color: var(--muted); } +.error { color: #ff8b8b; } +.ok { color: #7ddc8a; } + +table { width: 100%; border-collapse: collapse; margin: .5rem 0; } +th, td { text-align: left; padding: .45rem .6rem; border-bottom: 1px solid var(--border); vertical-align: middle; } +th { color: var(--muted); font-weight: 500; font-size: .75rem; text-transform: uppercase; letter-spacing: .04em; } +tbody tr:hover { background: #1b1f23; } + +.badge { + display: inline-block; + padding: .05rem .45rem; + border-radius: .75rem; + font-size: .72rem; + background: var(--queued); + color: #fff; +} +.badge.running { background: var(--running); color: #1a1a1a; } +.badge.success { background: var(--success); } +.badge.failed { background: var(--failed); } +.badge.skipped { background: var(--skipped); } +.badge.cancelled { background: var(--cancelled); } +.badge.pending, .badge.queued { background: var(--queued); } + +.panel { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 6px; + padding: .85rem 1rem; + margin-bottom: 1rem; +} +.panel.narrow { max-width: 24rem; width: 100%; } +.panel.secret { border-color: var(--running); } + +/* Centres a single panel, such as the sign in form, rather than leaving it + stranded against the left edge of a wide page. */ +.center { display: flex; justify-content: center; padding-top: 2.5rem; } + +.row { display: flex; gap: 1.5rem; flex-wrap: wrap; margin-bottom: .5rem; } +.row > div { min-width: 7rem; } +.label { color: var(--muted); font-size: .72rem; display: block; text-transform: uppercase; letter-spacing: .04em; } + +.stage { margin: 1rem 0; } +.stage h3 { color: var(--muted); font-weight: 500; font-size: .75rem; text-transform: uppercase; letter-spacing: .04em; } + +.logpane { max-height: 32rem; overflow: auto; border: 1px solid var(--border); border-radius: 6px; background: #0b0d0f; } +pre.log { + margin: 0; + padding: .75rem 1rem; + white-space: pre-wrap; + word-break: break-word; + font-size: .82rem; +} + +form { margin: 0; } +.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr)); gap: .75rem; } +label { display: block; color: var(--muted); font-size: .75rem; text-transform: uppercase; letter-spacing: .04em; } + +input, select { + display: block; + width: 100%; + margin-top: .2rem; + background: #0b0d0f; + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text); + padding: .35rem .5rem; + font-size: .9rem; + text-transform: none; + letter-spacing: normal; +} +input.wide { width: 100%; } + +button { + background: #22262b; + color: var(--text); + border: 1px solid var(--border); + border-radius: 4px; + padding: .3rem .7rem; + cursor: pointer; + font-size: .85rem; +} +button:hover { background: #2b3036; } +button.danger { border-color: var(--danger); color: #ff9c9c; } +/* A button that has to sit on the same line as ordinary text. Inheriting + the font and line height is what keeps a row with actions exactly as + tall as a row without them; a button's own metrics are otherwise larger + than the surrounding text and stretch the line box. */ +button.link { + background: none; + border: none; + color: var(--link); + padding: 0; + margin: 0; + font: inherit; + line-height: inherit; + vertical-align: baseline; +} +button.link:hover { text-decoration: underline; background: none; } +button.link.danger { color: #ff9c9c; } + +a.button { + display: inline-block; + background: #22262b; + border: 1px solid var(--border); + border-radius: 4px; + padding: .3rem .7rem; + color: var(--text); +} +a.button:hover { text-decoration: none; background: #2b3036; } + +.actions { display: flex; gap: .5rem; align-items: center; margin-top: .75rem; flex-wrap: wrap; } + +/* Actions inside a table row stay an ordinary table cell. Making them a + flex container would take them out of the row's baseline alignment and + give the row a different height from every other one. */ +td.row-actions { white-space: nowrap; text-align: right; } +td.row-actions button.link + button.link { margin-left: .9rem; } diff --git a/assets/vendor/htmx-LICENSE.txt b/assets/vendor/htmx-LICENSE.txt @@ -0,0 +1,13 @@ +Zero-Clause BSD +============= + +Permission to use, copy, modify, and/or distribute this software for +any purpose with or without fee is hereby granted. + +THE SOFTWARE IS PROVIDED “AS IS” AND THE AUTHOR DISCLAIMS ALL +WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES +OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE +FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY +DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN +AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT +OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/assets/vendor/htmx.min.js b/assets/vendor/htmx.min.js @@ -0,0 +1 @@ +var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=dn(e,t||"post");return n.values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:true,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null,disableInheritance:false,responseHandling:[{code:"204",swap:false},{code:"[23]..",swap:true},{code:"[45]..",swap:false,error:true}],allowNestedOobSwaps:true,historyRestoreAsHxRequest:true,reportValidityOfForms:false},parseInterval:null,location:location,_:null,version:"2.0.10"};Q.onLoad=j;Q.process=Ft;Q.on=ye;Q.off=xe;Q.trigger=ae;Q.ajax=Nn;Q.find=f;Q.findAll=y;Q.closest=g;Q.remove=z;Q.addClass=w;Q.removeClass=b;Q.toggleClass=G;Q.takeClass=W;Q.swap=_e;Q.defineExtension=_n;Q.removeExtension=zn;Q.logAll=$;Q.logNone=_;Q.parseInterval=d;Q._=e;const n={addTriggerHandler:St,bodyContains:se,canAccessLocalStorage:U,findThisElement:we,filterValues:yn,swap:_e,hasAttribute:s,getAttributeValue:a,getClosestAttributeValue:ne,getClosestMatch:A,getExpressionVars:Rn,getHeaders:mn,getInputValues:dn,getInternalData:oe,getSwapSpecification:bn,getTriggerSpecs:st,getTarget:Se,makeFragment:P,mergeObjects:le,makeSettleInfo:Sn,oobSwap:He,querySelectorExt:ce,settleImmediately:Yt,shouldCancel:ht,triggerEvent:ae,triggerErrorEvent:fe,withExtensions:Vt};const de=["get","post","put","delete","patch"];const R=de.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e instanceof Element&&e.getAttribute(t)}function s(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function a(e,t){return ee(e,t)||ee(e,"data-"+t)}function c(e){const t=e.parentElement;if(!t&&e.parentNode instanceof ShadowRoot)return e.parentNode;return t}function te(){return document}function q(e,t){return e.getRootNode?e.getRootNode({composed:t}):te()}function A(e,t){while(e&&!t(e)){e=c(e)}return e||null}function o(e,t,n){const r=a(t,n);const o=a(t,"hx-disinherit");var i=a(t,"hx-inherit");if(e!==t){if(Q.config.disableInheritance){if(i&&(i==="*"||i.split(" ").indexOf(n)>=0)){return r}else{return null}}if(o&&(o==="*"||o.split(" ").indexOf(n)>=0)){return"unset"}}return r}function ne(t,n){let r=null;A(t,function(e){return!!(r=o(t,ue(e),n))});if(r!=="unset"){return r}}function h(e,t){return e instanceof Element&&e.matches(t)}function N(e){const t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;const n=t.exec(e);if(n){return n[1].toLowerCase()}else{return""}}function I(e){if("parseHTMLUnsafe"in Document){return Document.parseHTMLUnsafe(e)}const t=new DOMParser;return t.parseFromString(e,"text/html")}function L(e,t){while(t.childNodes.length>0){e.append(t.childNodes[0])}}function r(e){const t=te().createElement("script");ie(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}return t}function i(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function D(e){Array.from(e.querySelectorAll("script")).forEach(e=>{if(i(e)){const t=r(e);const n=e.parentNode;try{n.insertBefore(t,e)}catch(e){H(e)}finally{e.remove()}}})}function P(e){const t=e.replace(/<head(\s[^>]*)?>[\s\S]*?<\/head>/i,"");const n=N(t);let r;if(n==="html"){r=new DocumentFragment;const i=I(e);L(r,i.body);r.title=i.title}else if(n==="body"){r=new DocumentFragment;const i=I(t);L(r,i.body);r.title=i.title}else{const i=I('<body><template class="internal-htmx-wrapper">'+t+"</template></body>");r=i.querySelector("template").content;r.title=i.title;var o=r.querySelector("title");if(o&&o.parentNode===r){o.remove();r.title=o.innerText}}if(r){if(Q.config.allowScriptTags){D(r)}else{r.querySelectorAll("script").forEach(e=>e.remove())}}return r}function re(e){if(e){e()}}function t(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function k(e){return typeof e==="function"}function M(e){return t(e,"Object")}function oe(e){const t="htmx-internal-data";let n=e[t];if(!n){n=e[t]={}}return n}function F(t){const n=[];if(t){for(let e=0;e<t.length;e++){n.push(t[e])}}return n}function ie(t,n){if(t){for(let e=0;e<t.length;e++){n(t[e])}}}function B(e){const t=e.getBoundingClientRect();const n=t.top;const r=t.bottom;return n<window.innerHeight&&r>=0}function se(e){return e.getRootNode({composed:true})===document}function X(e){return e.trim().split(/\s+/)}function le(e,t){for(const n in t){if(t.hasOwnProperty(n)){e[n]=t[n]}}return e}function v(e){try{return JSON.parse(e)}catch(e){H(e);return null}}function U(){const e="htmx:sessionStorageTest";try{sessionStorage.setItem(e,e);sessionStorage.removeItem(e);return true}catch(e){return false}}function V(e){try{const t=new URL(e,window.location.href);e=t.pathname+t.search}catch(e){}if(e!="/"){e=e.replace(/\/+$/,"")}return e}function e(e){return On(te().body,function(){return eval(e)})}function j(t){const e=Q.on("htmx:load",function(e){t(e.detail.elt)});return e}function $(){Q.logger=function(e,t,n){if(console){console.log(t,e,n)}}}function _(){Q.logger=null}function f(e,t){if(typeof e!=="string"){return e.querySelector(t)}else{return f(te(),e)}}function y(e,t){if(typeof e!=="string"){return e.querySelectorAll(t)}else{return y(te(),e)}}function x(){return window}function z(e,t){e=S(e);if(t){x().setTimeout(function(){z(e);e=null},t)}else{c(e).removeChild(e)}}function ue(e){return e instanceof Element?e:null}function J(e){return e instanceof HTMLElement?e:null}function K(e){return typeof e==="string"?e:null}function p(e){return e instanceof Element||e instanceof Document||e instanceof DocumentFragment?e:null}function w(e,t,n){e=ue(S(e));if(!e){return}if(n){x().setTimeout(function(){w(e,t);e=null},n)}else{e.classList&&e.classList.add(t)}}function b(e,t,n){let r=ue(S(e));if(!r){return}if(n){x().setTimeout(function(){b(r,t);r=null},n)}else{if(r.classList){r.classList.remove(t);if(r.classList.length===0){r.removeAttribute("class")}}}}function G(e,t){e=S(e);e.classList.toggle(t)}function W(e,t){e=S(e);ie(e.parentElement.children,function(e){b(e,t)});w(ue(e),t)}function g(e,t){e=ue(S(e));if(e){return e.closest(t)}return null}function l(e,t){return e.substring(0,t.length)===t}function Z(e,t){return e.substring(e.length-t.length)===t}function Y(e){const t=e.trim();if(l(t,"<")&&Z(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function m(t,r,n){if(r.indexOf("global ")===0){return m(t,r.slice(7),true)}t=S(t);const o=[];{let t=0;let n=0;for(let e=0;e<r.length;e++){const l=r[e];if(l===","&&t===0){o.push(r.substring(n,e));n=e+1;continue}if(l==="<"){t++}else if(l==="/"&&e<r.length-1&&r[e+1]===">"){t--}}if(n<r.length){o.push(r.substring(n))}}const i=[];const s=[];while(o.length>0){const r=Y(o.shift());let e;if(r.indexOf("closest ")===0){e=g(ue(t),Y(r.slice(8)))}else if(r.indexOf("find ")===0){e=f(p(t),Y(r.slice(5)))}else if(r==="next"||r==="nextElementSibling"){e=ue(t).nextElementSibling}else if(r.indexOf("next ")===0){e=pe(t,Y(r.slice(5)),!!n)}else if(r==="previous"||r==="previousElementSibling"){e=ue(t).previousElementSibling}else if(r.indexOf("previous ")===0){e=ge(t,Y(r.slice(9)),!!n)}else if(r==="document"){e=document}else if(r==="window"){e=window}else if(r==="body"){e=document.body}else if(r==="root"){e=q(t,!!n)}else if(r==="host"){e=t.getRootNode().host}else{s.push(r)}if(e){i.push(e)}}if(s.length>0){const e=s.join(",");const u=p(q(t,!!n));i.push(...F(u.querySelectorAll(e)))}return i}var pe=function(t,e,n){const r=p(q(t,n)).querySelectorAll(e);for(let e=0;e<r.length;e++){const o=r[e];if(o.compareDocumentPosition(t)===Node.DOCUMENT_POSITION_PRECEDING){return o}}};var ge=function(t,e,n){const r=p(q(t,n)).querySelectorAll(e);for(let e=r.length-1;e>=0;e--){const o=r[e];if(o.compareDocumentPosition(t)===Node.DOCUMENT_POSITION_FOLLOWING){return o}}};function ce(e,t){if(typeof e!=="string"){return m(e,t)[0]}else{return m(te().body,e)[0]}}function S(e,t){if(typeof e==="string"){return f(p(t)||document,e)}else{return e}}function me(e,t,n,r){if(k(t)){return{target:te().body,event:K(e),listener:t,options:n}}else{return{target:S(e),event:K(t),listener:n,options:r}}}function ye(t,n,r,o){Gn(function(){const e=me(t,n,r,o);e.target.addEventListener(e.event,e.listener,e.options)});const e=k(n);return e?n:r}function xe(t,n,r){Gn(function(){const e=me(t,n,r);e.target.removeEventListener(e.event,e.listener)});return k(n)?n:r}const be=te().createElement("output");function ve(t,n){const e=ne(t,n);if(e){if(e==="this"){return[we(t,n)]}else{const r=m(t,e);const o=/(^|,)(\s*)inherit(\s*)($|,)/.test(e);if(o){const i=ue(A(t,function(e){return e!==t&&s(ue(e),n)}));if(i){r.push(...ve(i,n))}}if(r.length===0){H('The selector "'+e+'" on '+n+" returned no matches!");return[be]}else{return r}}}}function we(e,t){return ue(A(e,function(e){return a(ue(e),t)!=null}))}function Se(e){const t=ne(e,"hx-target");if(t){if(t==="this"){return we(e,"hx-target")}else{return ce(e,t)}}else{const n=oe(e);if(n.boosted){return te().body}else{return e}}}function Ee(e){return Q.config.attributesToSettle.includes(e)}function Ce(t,n){ie(Array.from(t.attributes),function(e){if(!n.hasAttribute(e.name)&&Ee(e.name)){t.removeAttribute(e.name)}});ie(n.attributes,function(e){if(Ee(e.name)){t.setAttribute(e.name,e.value)}})}function Oe(t,e){const n=Jn(e);for(let e=0;e<n.length;e++){const r=n[e];try{if(r.isInlineSwap(t)){return true}}catch(e){H(e)}}return t==="outerHTML"}function He(e,o,i,t){t=t||te();let n="#"+CSS.escape(ee(o,"id"));let s="outerHTML";if(e==="true"){}else if(e.indexOf(":")>0){s=e.substring(0,e.indexOf(":"));n=e.substring(e.indexOf(":")+1)}else{s=e}o.removeAttribute("hx-swap-oob");o.removeAttribute("data-hx-swap-oob");const r=m(t,n,false);if(r.length){ie(r,function(e){let t;const n=o.cloneNode(true);t=te().createDocumentFragment();t.appendChild(n);if(!Oe(s,e)){t=p(n)}const r={shouldSwap:true,target:e,fragment:t};if(!ae(e,"htmx:oobBeforeSwap",r))return;e=r.target;if(r.shouldSwap){Re(t);je(s,e,e,t,i);Te()}ie(i.elts,function(e){ae(e,"htmx:oobAfterSwap",r)})});o.parentNode.removeChild(o)}else{o.parentNode.removeChild(o);fe(te().body,"htmx:oobErrorNoTarget",{content:o,target:n})}return e}function Te(){const e=f("#--htmx-preserve-pantry--");if(e){for(const t of[...e.children]){const n=f("#"+t.id);n.parentNode.moveBefore(t,n);n.remove()}e.remove()}}function Re(e){ie(y(e,"[hx-preserve], [data-hx-preserve]"),function(e){const t=a(e,"id");const n=te().getElementById(t);if(n!=null){if(e.moveBefore){let e=f("#--htmx-preserve-pantry--");if(e==null){te().body.insertAdjacentHTML("afterend","<div id='--htmx-preserve-pantry--'></div>");e=f("#--htmx-preserve-pantry--")}e.moveBefore(n,null)}else{e.parentNode.replaceChild(n,e)}}})}function qe(i,e,s){ie(e.querySelectorAll("[id]"),function(t){const n=ee(t,"id");if(n&&n.length>0){const e=p(i);const r=e&&e.querySelector(CSS.escape(t.tagName)+"#"+CSS.escape(n));if(r&&r!==e){const o=t.cloneNode();Ce(t,r);s.tasks.push(function(){Ce(t,o)})}}})}function Ae(e){return function(){b(e,Q.config.addedClass);Ft(ue(e));Ne(p(e));ae(e,"htmx:load")}}function Ne(e){const t="[autofocus]";const n=J(h(e,t)?e:e.querySelector(t));if(n!=null){n.focus()}}function u(e,t,n,r){qe(e,n,r);while(n.childNodes.length>0){const o=n.firstChild;w(ue(o),Q.config.addedClass);e.insertBefore(o,t);if(o.nodeType!==Node.TEXT_NODE&&o.nodeType!==Node.COMMENT_NODE){r.tasks.push(Ae(o))}}}function Ie(e,t){let n=0;while(n<e.length){t=(t<<5)-t+e.charCodeAt(n++)|0}return t}function Le(t){let n=0;for(let e=0;e<t.attributes.length;e++){const r=t.attributes[e];if(r.value){n=Ie(r.name,n);n=Ie(r.value,n)}}return n}function De(t){const n=oe(t);if(n.onHandlers){for(let e=0;e<n.onHandlers.length;e++){const r=n.onHandlers[e];xe(t,r.event,r.listener)}delete n.onHandlers}}function Pe(e){const t=oe(e);if(t.timeout){clearTimeout(t.timeout)}if(t.listenerInfos){ie(t.listenerInfos,function(e){if(e.on){xe(e.on,e.trigger,e.listener)}})}De(e);ie(Object.keys(t),function(e){if(e!=="firstInitCompleted")delete t[e]})}function E(e){ae(e,"htmx:beforeCleanupElement");Pe(e);ie(e.children,function(e){E(e)})}function ke(t,e,n){if(t.tagName==="BODY"){return Ve(t,e,n)}let r;const o=t.previousSibling;const i=c(t);if(!i){return}u(i,t,e,n);if(o==null){r=i.firstChild}else{r=o.nextSibling}n.elts=n.elts.filter(function(e){return e!==t});while(r&&r!==t){if(r instanceof Element){n.elts.push(r)}r=r.nextSibling}E(t);t.remove()}function Me(e,t,n){return u(e,e.firstChild,t,n)}function Fe(e,t,n){return u(c(e),e,t,n)}function Be(e,t,n){return u(e,null,t,n)}function Xe(e,t,n){return u(c(e),e.nextSibling,t,n)}function Ue(e){E(e);const t=c(e);if(t){return t.removeChild(e)}}function Ve(e,t,n){const r=e.firstChild;u(e,r,t,n);if(r){while(r.nextSibling){E(r.nextSibling);e.removeChild(r.nextSibling)}E(r);e.removeChild(r)}}function je(t,e,n,r,o){switch(t){case"none":return;case"outerHTML":ke(n,r,o);return;case"afterbegin":Me(n,r,o);return;case"beforebegin":Fe(n,r,o);return;case"beforeend":Be(n,r,o);return;case"afterend":Xe(n,r,o);return;case"delete":Ue(n);return;default:var i=Jn(e);for(let e=0;e<i.length;e++){const s=i[e];try{const l=s.handleSwap(t,n,r,o);if(l){if(Array.isArray(l)){for(let e=0;e<l.length;e++){const u=l[e];if(u.nodeType!==Node.TEXT_NODE&&u.nodeType!==Node.COMMENT_NODE){o.tasks.push(Ae(u))}}}return}}catch(e){H(e)}}if(t==="innerHTML"){Ve(n,r,o)}else{je(Q.config.defaultSwapStyle,e,n,r,o)}}}function $e(e,n,r){var t=y(e,"[hx-swap-oob], [data-hx-swap-oob]");ie(t,function(e){if(Q.config.allowNestedOobSwaps||e.parentElement===null){const t=a(e,"hx-swap-oob");if(t!=null){He(t,e,n,r)}}else{e.removeAttribute("hx-swap-oob");e.removeAttribute("data-hx-swap-oob")}});return t.length>0}function _e(h,d,p,g){if(!g){g={}}let m=null;let n=null;let e=function(){re(g.beforeSwapCallback);h=S(h);const r=g.contextElement?q(g.contextElement,false):te();const e=document.activeElement;let t={};t={elt:e,start:e?e.selectionStart:null,end:e?e.selectionEnd:null};const o=Sn(h);if(p.swapStyle==="textContent"){h.textContent=d}else{let n=P(d);o.title=g.title||n.title;if(g.historyRequest){n=n.querySelector("[hx-history-elt],[data-hx-history-elt]")||n}if(g.selectOOB){const i=g.selectOOB.split(",");for(let t=0;t<i.length;t++){const s=i[t].split(":",2);let e=s[0].trim();if(e.indexOf("#")===0){e=e.substring(1)}const l=s[1]||"true";const u=n.querySelector("#"+e);if(u){He(l,u,o,r)}}}$e(n,o,r);ie(y(n,"template"),function(e){if(e.content&&$e(e.content,o,r)){e.remove()}});if(g.select){const c=te().createDocumentFragment();ie(n.querySelectorAll(g.select),function(e){c.appendChild(e)});n=c}Re(n);je(p.swapStyle,g.contextElement,h,n,o);Te()}if(t.elt&&!se(t.elt)&&ee(t.elt,"id")){const f=document.getElementById(ee(t.elt,"id"));const a={preventScroll:p.focusScroll!==undefined?!p.focusScroll:!Q.config.defaultFocusScroll};if(f){if(t.start&&f.setSelectionRange){try{f.setSelectionRange(t.start,t.end)}catch(e){}}f.focus(a)}}b(h,Q.config.swappingClass);ie(o.elts,function(e){if(e.classList){w(e,Q.config.settlingClass)}ae(e,"htmx:afterSwap",g.eventInfo)});re(g.afterSwapCallback);if(!p.ignoreTitle){Xn(o.title)}const n=function(){ie(o.tasks,function(e){e.call()});ie(o.elts,function(e){if(e.classList){b(e,Q.config.settlingClass)}ae(e,"htmx:afterSettle",g.eventInfo)});if(g.anchor){const e=ue(S("#"+g.anchor));if(e){e.scrollIntoView({block:"start",behavior:"auto"})}}En(o.elts,p);re(g.afterSettleCallback);re(m)};if(p.settleDelay>0){x().setTimeout(n,p.settleDelay)}else{n()}};let t=Q.config.globalViewTransitions;if(p.hasOwnProperty("transition")){t=p.transition}const r=g.contextElement||te();if(t&&ae(r,"htmx:beforeTransition",g.eventInfo)&&typeof Promise!=="undefined"&&document.startViewTransition){const o=new Promise(function(e,t){m=e;n=t});const i=e;e=function(){document.startViewTransition(function(){i();return o})}}try{if(p?.swapDelay&&p.swapDelay>0){x().setTimeout(e,p.swapDelay)}else{e()}}catch(e){fe(r,"htmx:swapError",g.eventInfo);re(n);throw e}}function ze(e,t,n){const r=e.getResponseHeader(t);if(r.indexOf("{")===0){const o=v(r);for(const i in o){if(o.hasOwnProperty(i)){let e=o[i];if(M(e)){n=e.target!==undefined?e.target:n}else{e={value:e}}ae(n,i,e)}}}else{const s=r.split(",");for(let e=0;e<s.length;e++){ae(n,s[e].trim(),[])}}}const Je=/\s/;const C=/[\s,]/;const Ke=/[_$a-zA-Z]/;const Ge=/[_$a-zA-Z0-9]/;const We=['"',"'","/"];const Ze=/[^\s]/;const Ye=/[{(]/;const Qe=/[})]/;function et(e){const t=[];let n=0;while(n<e.length){if(Ke.exec(e.charAt(n))){var r=n;while(Ge.exec(e.charAt(n+1))){n++}t.push(e.substring(r,n+1))}else if(We.indexOf(e.charAt(n))!==-1){const o=e.charAt(n);var r=n;n++;while(n<e.length&&e.charAt(n)!==o){if(e.charAt(n)==="\\"){n++}n++}t.push(e.substring(r,n+1))}else{const i=e.charAt(n);t.push(i)}n++}return t}function tt(e,t,n){return Ke.exec(e.charAt(0))&&e!=="true"&&e!=="false"&&e!=="this"&&e!==n&&t!=="."}function nt(r,o,i){if(o[0]==="["){o.shift();let e=1;let t=" return (function("+i+"){ return (";let n=null;while(o.length>0){const s=o[0];if(s==="]"){e--;if(e===0){if(n===null){t=t+"true"}o.shift();t+=")})";try{const l=On(r,function(){return Function(t)()},function(){return true});l.source=t;return l}catch(e){fe(te().body,"htmx:syntax:error",{error:e,source:t});return null}}}else if(s==="["){e++}if(tt(s,n,i)){t+="(("+i+"."+s+") ? ("+i+"."+s+") : (window."+s+"))"}else{t=t+s}n=o.shift()}}}function O(e,t){let n="";while(e.length>0&&!t.test(e[0])){n+=e.shift()}return n}function rt(e){let t;if(e.length>0&&Ye.test(e[0])){e.shift();t=O(e,Qe).trim();e.shift()}else{t=O(e,C)}return t}const ot="input, textarea, select";function it(e,t,n){const r=[];const o=et(t);do{O(o,Ze);const l=o.length;const u=O(o,/[,\[\s]/);if(u!==""){if(u==="every"){const c={trigger:"every"};O(o,Ze);c.pollInterval=d(O(o,/[,\[\s]/));O(o,Ze);var i=nt(e,o,"event");if(i){c.eventFilter=i}r.push(c)}else{const f={trigger:u};var i=nt(e,o,"event");if(i){f.eventFilter=i}O(o,Ze);while(o.length>0&&o[0]!==","){const a=o.shift();if(a==="changed"){f.changed=true}else if(a==="once"){f.once=true}else if(a==="consume"){f.consume=true}else if(a==="delay"&&o[0]===":"){o.shift();f.delay=d(O(o,C))}else if(a==="from"&&o[0]===":"){o.shift();if(Ye.test(o[0])){var s=rt(o)}else{var s=O(o,C);if(s==="closest"||s==="find"||s==="next"||s==="previous"){o.shift();const h=rt(o);if(h.length>0){s+=" "+h}}}f.from=s}else if(a==="target"&&o[0]===":"){o.shift();f.target=rt(o)}else if(a==="throttle"&&o[0]===":"){o.shift();f.throttle=d(O(o,C))}else if(a==="queue"&&o[0]===":"){o.shift();f.queue=O(o,C)}else if(a==="root"&&o[0]===":"){o.shift();f[a]=rt(o)}else if(a==="threshold"&&o[0]===":"){o.shift();f[a]=O(o,C)}else{fe(e,"htmx:syntax:error",{token:o.shift()})}O(o,Ze)}r.push(f)}}if(o.length===l){fe(e,"htmx:syntax:error",{token:o.shift()})}O(o,Ze)}while(o[0]===","&&o.shift());if(n){n[t]=r}return r}function st(e){const t=a(e,"hx-trigger");let n=[];if(t){const r=Q.config.triggerSpecsCache;n=r&&r[t]||it(e,t,r)}if(n.length>0){return n}else if(h(e,"form")){return[{trigger:"submit"}]}else if(h(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(h(e,ot)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function lt(e){oe(e).cancelled=true}function ut(e,t,n){const r=oe(e);r.timeout=x().setTimeout(function(){if(se(e)&&r.cancelled!==true){if(!pt(n,e,Xt("hx:poll:trigger",{triggerSpec:n,target:e}))){t(e)}ut(e,t,n)}},n.pollInterval)}function ct(e){return location.hostname===e.hostname&&ee(e,"href")&&ee(e,"href").indexOf("#")!==0}function ft(e){return g(e,Q.config.disableSelector)}function at(t,n,e){if(t instanceof HTMLAnchorElement&&ct(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"&&String(ee(t,"method")).toLowerCase()!=="dialog"){n.boosted=true;let r,o;if(t.tagName==="A"){r="get";o=ee(t,"href")}else{const i=ee(t,"method");r=i?i.toLowerCase():"get";o=ee(t,"action");if(o==null||o===""){o=location.href}if(r==="get"&&o.includes("?")){o=o.replace(/\?[^#]+/,"")}}e.forEach(function(e){gt(t,function(e,t){const n=ue(e);if(ft(n)){E(n);return}he(r,o,n,t)},n,e,true)})}}function ht(e,t){if(e.type==="submit"&&t.tagName==="FORM"){return true}else if(e.type==="click"){const n=t.closest('input[type="submit"], button');if(n&&n.form&&n.type==="submit"){return true}const r=t.closest("a");const o=/^#.+/;if(r&&r.href&&!o.test(r.getAttribute("href"))){return true}}return false}function dt(e,t){return oe(e).boosted&&e instanceof HTMLAnchorElement&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function pt(e,t,n){const r=e.eventFilter;if(r){try{return r.call(t,n)!==true}catch(e){const o=r.source;fe(te().body,"htmx:eventFilter:error",{error:e,source:o});return true}}return false}function gt(l,u,e,c,f){const a=oe(l);let t;if(c.from){t=m(l,c.from)}else{t=[l]}if(c.changed){if(!("lastValue"in a)){a.lastValue=new WeakMap}t.forEach(function(e){if(!a.lastValue.has(c)){a.lastValue.set(c,new WeakMap)}a.lastValue.get(c).set(e,e.value)})}ie(t,function(i){const s=function(e){if(!se(l)){i.removeEventListener(c.trigger,s);return}if(dt(l,e)){return}if(f||ht(e,i)){e.preventDefault()}if(pt(c,l,e)){return}const t=oe(e);t.triggerSpec=c;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(l)<0){t.handledFor.push(l);if(c.consume){e.stopPropagation()}if(c.target&&e.target){if(!h(ue(e.target),c.target)){return}}if(c.once){if(a.triggeredOnce){return}else{a.triggeredOnce=true}}if(c.changed){const n=e.target;const r=n.value;const o=a.lastValue.get(c);if(o.has(n)&&o.get(n)===r){return}o.set(n,r)}if(a.delayed){clearTimeout(a.delayed)}if(a.throttle){return}if(c.throttle>0){if(!a.throttle){ae(l,"htmx:trigger");u(l,e);a.throttle=x().setTimeout(function(){a.throttle=null},c.throttle)}}else if(c.delay>0){a.delayed=x().setTimeout(function(){ae(l,"htmx:trigger");u(l,e)},c.delay)}else{ae(l,"htmx:trigger");u(l,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:c.trigger,listener:s,on:i});i.addEventListener(c.trigger,s)})}let mt=false;let yt=null;function xt(){if(!yt){yt=function(){mt=true};window.addEventListener("scroll",yt);window.addEventListener("resize",yt);setInterval(function(){if(mt){mt=false;ie(te().querySelectorAll("[hx-trigger*='revealed'],[data-hx-trigger*='revealed']"),function(e){bt(e)})}},200)}}function bt(e){if(!s(e,"data-hx-revealed")&&B(e)){e.setAttribute("data-hx-revealed","true");const t=oe(e);if(t.initHash){ae(e,"revealed")}else{e.addEventListener("htmx:afterProcessNode",function(){ae(e,"revealed")},{once:true})}}}function vt(e,t,n,r){const o=function(){if(!n.loaded){n.loaded=true;ae(e,"htmx:trigger");t(e)}};if(r>0){x().setTimeout(o,r)}else{o()}}function wt(t,n,e){let i=false;ie(de,function(r){if(s(t,"hx-"+r)){const o=a(t,"hx-"+r);i=true;n.path=o;n.verb=r;e.forEach(function(e){St(t,e,n,function(e,t){const n=ue(e);if(ft(n)){E(n);return}he(r,o,n,t)})})}});return i}function St(r,e,t,n){if(e.trigger==="revealed"){xt();gt(r,n,t,e);bt(ue(r))}else if(e.trigger==="intersect"){const o={};if(e.root){o.root=ce(r,e.root)}if(e.threshold){o.threshold=parseFloat(e.threshold)}const i=new IntersectionObserver(function(t){for(let e=0;e<t.length;e++){const n=t[e];if(n.isIntersecting){ae(r,"intersect");break}}},o);i.observe(ue(r));gt(ue(r),n,t,e)}else if(!t.firstInitCompleted&&e.trigger==="load"){if(!pt(e,r,Xt("load",{elt:r}))){vt(ue(r),n,t,e.delay)}}else if(e.pollInterval>0){t.polling=true;ut(ue(r),n,e)}else{gt(r,n,t,e)}}function Et(e){const t=ue(e);if(!t){return false}const n=t.attributes;for(let e=0;e<n.length;e++){const r=n[e].name;if(l(r,"hx-on:")||l(r,"data-hx-on:")||l(r,"hx-on-")||l(r,"data-hx-on-")){return true}}return false}const Ct=(new XPathEvaluator).createExpression('.//*[@*[ starts-with(name(), "hx-on:") or starts-with(name(), "data-hx-on:") or'+' starts-with(name(), "hx-on-") or starts-with(name(), "data-hx-on-") ]]');function Ot(e,t){if(Et(e)){t.push(ue(e))}const n=Ct.evaluate(e);let r=null;while(r=n.iterateNext())t.push(ue(r))}function Ht(e){const t=[];if(e instanceof DocumentFragment){for(const n of e.childNodes){Ot(n,t)}}else{Ot(e,t)}return t}function Tt(e){if(e.querySelectorAll){const n=", [hx-boost] a, [data-hx-boost] a, a[hx-boost], a[data-hx-boost]";const r=[];for(const i in jn){const s=jn[i];if(s.getSelectors){var t=s.getSelectors();if(t){r.push(t)}}}const o=e.querySelectorAll(R+n+", form, [type='submit'],"+" [hx-ext], [data-hx-ext], [hx-trigger], [data-hx-trigger]"+r.flat().map(e=>", "+e).join(""));return o}else{return[]}}function Rt(e){const t=At(e.target);const n=It(e);if(n){n.lastButtonClicked=t}}function qt(e){const t=It(e);if(t){t.lastButtonClicked=null}}function At(e){return g(ue(e),"button, input[type='submit']")}function Nt(e){return e.form||g(e,"form")}function It(e){const t=At(e.target);if(!t){return}const n=Nt(t);if(!n){return}return oe(n)}function Lt(e){e.addEventListener("click",Rt);e.addEventListener("focusin",Rt);e.addEventListener("focusout",qt)}function Dt(t,e,n){const r=oe(t);if(!Array.isArray(r.onHandlers)){r.onHandlers=[]}let o;const i=function(e){On(t,function(){if(ft(t)){return}if(!o){o=new Function("event",n)}o.call(t,e)})};t.addEventListener(e,i);r.onHandlers.push({event:e,listener:i})}function Pt(t){De(t);for(let e=0;e<t.attributes.length;e++){const n=t.attributes[e].name;const r=t.attributes[e].value;if(l(n,"hx-on")||l(n,"data-hx-on")){const o=n.indexOf("-on")+3;const i=n.slice(o,o+1);if(i==="-"||i===":"){let e=n.slice(o+1);if(l(e,":")){e="htmx"+e}else if(l(e,"-")){e="htmx:"+e.slice(1)}else if(l(e,"htmx-")){e="htmx:"+e.slice(5)}Dt(t,e,r)}}}}function kt(t){ae(t,"htmx:beforeProcessNode");const n=oe(t);const e=st(t);const r=wt(t,n,e);if(!r){if(ne(t,"hx-boost")==="true"){at(t,n,e)}else if(s(t,"hx-trigger")){e.forEach(function(e){St(t,e,n,function(){})})}}if(t.tagName==="FORM"||ee(t,"type")==="submit"&&s(t,"form")){Lt(t)}n.firstInitCompleted=true;ae(t,"htmx:afterProcessNode")}function Mt(e){if(!(e instanceof Element)){return false}const t=oe(e);const n=Le(e);if(t.initHash!==n){Pe(e);t.initHash=n;return true}return false}function Ft(e){e=S(e);if(ft(e)){E(e);return}const t=[];if(Mt(e)){t.push(e)}ie(Tt(e),function(e){if(ft(e)){E(e);return}if(Mt(e)){t.push(e)}});ie(Ht(e),Pt);ie(t,kt)}function Bt(e){return e.replace(/([a-z0-9])([A-Z])/g,"$1-$2").toLowerCase()}function Xt(e,t){return new CustomEvent(e,{bubbles:true,cancelable:true,composed:true,detail:t})}function fe(e,t,n){ae(e,t,le({error:t},n))}function Ut(e){return e==="htmx:afterProcessNode"}function Vt(e,t,n){ie(Jn(e,[],n),function(e){try{t(e)}catch(e){H(e)}})}function H(e){console.error(e)}function ae(e,t,n){e=S(e);if(n==null){n={}}n.elt=e;const r=Xt(t,n);if(Q.logger&&!Ut(t)){Q.logger(e,t,n)}if(n.error){H(n.error+(n.target?", "+n.target:""));ae(e,"htmx:error",{errorInfo:n})}let o=e.dispatchEvent(r);const i=Bt(t);if(o&&i!==t){const s=Xt(i,r.detail);o=o&&e.dispatchEvent(s)}Vt(ue(e),function(e){o=o&&(e.onEvent(t,r)!==false&&!r.defaultPrevented)});return o}let jt;function $t(e){jt=e;if(U()){sessionStorage.setItem("htmx-current-path-for-history",e)}}$t(location.pathname+location.search);function _t(){const e=te().querySelector("[hx-history-elt],[data-hx-history-elt]");return e||te().body}function zt(t,e){if(!U()){return}const n=Kt(e);const r=te().title;const o=window.scrollY;if(Q.config.historyCacheSize<=0){sessionStorage.removeItem("htmx-history-cache");return}t=V(t);const i=v(sessionStorage.getItem("htmx-history-cache"))||[];for(let e=0;e<i.length;e++){if(i[e].url===t){i.splice(e,1);break}}const s={url:t,content:n,title:r,scroll:o};ae(te().body,"htmx:historyItemCreated",{item:s,cache:i});i.push(s);while(i.length>Q.config.historyCacheSize){i.shift()}while(i.length>0){try{sessionStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){fe(te().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function Jt(t){if(!U()){return null}t=V(t);const n=v(sessionStorage.getItem("htmx-history-cache"))||[];for(let e=0;e<n.length;e++){if(n[e].url===t){return n[e]}}return null}function Kt(e){const t=Q.config.requestClass;const n=e.cloneNode(true);ie(y(n,"."+t),function(e){b(e,t)});ie(y(n,"[data-disabled-by-htmx]"),function(e){e.removeAttribute("disabled")});return n.innerHTML}function Gt(){const e=_t();let t=jt;if(U()){t=sessionStorage.getItem("htmx-current-path-for-history")}t=t||location.pathname+location.search;const n=te().querySelector('[hx-history="false" i],[data-hx-history="false" i]');if(!n){ae(te().body,"htmx:beforeHistorySave",{path:t,historyElt:e});zt(t,e)}if(Q.config.historyEnabled)history.replaceState({htmx:true},te().title,location.href)}function Wt(e){if(Q.config.getCacheBusterParam){e=e.replace(/org\.htmx\.cache-buster=[^&]*&?/,"");if(Z(e,"&")||Z(e,"?")){e=e.slice(0,-1)}}if(Q.config.historyEnabled){history.pushState({htmx:true},"",e)}$t(e)}function Zt(e){if(Q.config.historyEnabled)history.replaceState({htmx:true},"",e);$t(e)}function Yt(e){ie(e,function(e){e.call(undefined)})}function Qt(e){const t=new XMLHttpRequest;const n={swapStyle:"innerHTML",swapDelay:0,settleDelay:0};const r={path:e,xhr:t,historyElt:_t(),swapSpec:n};t.open("GET",e,true);if(Q.config.historyRestoreAsHxRequest){t.setRequestHeader("HX-Request","true")}t.setRequestHeader("HX-History-Restore-Request","true");t.setRequestHeader("HX-Current-URL",location.href);t.onload=function(){if(this.status>=200&&this.status<400){r.response=this.response;ae(te().body,"htmx:historyCacheMissLoad",r);_e(r.historyElt,r.response,n,{contextElement:r.historyElt,historyRequest:true});$t(r.path);ae(te().body,"htmx:historyRestore",{path:e,cacheMiss:true,serverResponse:r.response})}else{fe(te().body,"htmx:historyCacheMissLoadError",r)}};if(ae(te().body,"htmx:historyCacheMiss",r)){t.send()}}function en(e){Gt();e=e||location.pathname+location.search;const t=Jt(e);if(t){const n={swapStyle:"innerHTML",swapDelay:0,settleDelay:0,scroll:t.scroll};const r={path:e,item:t,historyElt:_t(),swapSpec:n};if(ae(te().body,"htmx:historyCacheHit",r)){_e(r.historyElt,t.content,n,{contextElement:r.historyElt,title:t.title});$t(r.path);ae(te().body,"htmx:historyRestore",r)}}else{if(Q.config.refreshOnHistoryMiss){Q.location.reload(true)}else{Qt(e)}}}function tn(e){let t=ve(e,"hx-indicator");if(t==null){t=[e]}ie(t,function(e){const t=oe(e);t.requestCount=(t.requestCount||0)+1;w(e,Q.config.requestClass)});return t}function nn(e){let t=ve(e,"hx-disabled-elt");if(t==null){t=[]}ie(t,function(e){const t=oe(e);t.requestCount=(t.requestCount||0)+1;if(!e.hasAttribute("disabled")){e.setAttribute("disabled","");e.setAttribute("data-disabled-by-htmx","")}});return t}function rn(e,t){ie(e.concat(t),function(e){const t=oe(e);t.requestCount=(t.requestCount||1)-1});ie(e,function(e){const t=oe(e);if(t.requestCount===0){b(e,Q.config.requestClass)}});ie(t,function(e){const t=oe(e);if(t.requestCount===0&&e.hasAttribute("data-disabled-by-htmx")){e.removeAttribute("disabled");e.removeAttribute("data-disabled-by-htmx")}})}function on(t,n){for(let e=0;e<t.length;e++){const r=t[e];if(r.isSameNode(n)){return true}}return false}function sn(e){const t=e;if(t.name===""||t.name==null||t.disabled||g(t,"fieldset[disabled]")){return false}if(t.type==="button"||t.type==="submit"||t.tagName==="image"||t.tagName==="reset"||t.tagName==="file"){return false}if(t.type==="checkbox"||t.type==="radio"){return t.checked}return true}function ln(t,e,n){if(t!=null&&e!=null){if(Array.isArray(e)){e.forEach(function(e){n.append(t,e)})}else{n.append(t,e)}}}function un(t,n,r){if(t!=null&&n!=null){let e=r.getAll(t);if(Array.isArray(n)){e=e.filter(e=>n.indexOf(e)<0)}else{e=e.filter(e=>e!==n)}r.delete(t);ie(e,e=>r.append(t,e))}}function cn(e){if(e instanceof HTMLSelectElement&&e.multiple){return F(e.querySelectorAll("option:checked")).map(function(e){return e.value})}if(e instanceof HTMLInputElement&&e.files){return F(e.files)}return e.value}function fn(t,n,r,e,o){if(e==null||on(t,e)){return}else{t.push(e)}if(sn(e)){const i=ee(e,"name");ln(i,cn(e),n);if(o){an(e,r)}}if(e instanceof HTMLFormElement){ie(e.elements,function(e){if(t.indexOf(e)>=0){un(e.name,cn(e),n)}else{t.push(e)}if(o){an(e,r)}});new FormData(e).forEach(function(e,t){if(e instanceof File&&e.name===""){return}ln(t,e,n)})}}function an(e,t){const n=e;if(n.willValidate){ae(n,"htmx:validation:validate");if(!n.checkValidity()){if(ae(n,"htmx:validation:failed",{message:n.validationMessage,validity:n.validity})&&!t.length&&Q.config.reportValidityOfForms){n.reportValidity()}t.push({elt:n,message:n.validationMessage,validity:n.validity})}}}function hn(n,e){for(const t of e.keys()){n.delete(t)}e.forEach(function(e,t){n.append(t,e)});return n}function dn(e,t){const n=[];const r=new FormData;const o=new FormData;const i=[];const s=oe(e);if(s.lastButtonClicked&&!se(s.lastButtonClicked)){s.lastButtonClicked=null}let l=e instanceof HTMLFormElement&&e.noValidate!==true||a(e,"hx-validate")==="true";if(s.lastButtonClicked){l=l&&s.lastButtonClicked.formNoValidate!==true}if(t!=="get"){fn(n,o,i,Nt(e),l)}fn(n,r,i,e,l);if(s.lastButtonClicked||e.tagName==="BUTTON"||e.tagName==="INPUT"&&ee(e,"type")==="submit"){const c=s.lastButtonClicked||e;const f=ee(c,"name");ln(f,c.value,o)}const u=ve(e,"hx-include");ie(u,function(e){fn(n,r,i,ue(e),l);if(!h(e,"form")){ie(p(e).querySelectorAll(ot),function(e){fn(n,r,i,e,l)})}});hn(r,o);return{errors:i,formData:r,values:kn(r)}}function pn(e,t,n){if(e!==""){e+="&"}if(String(n)==="[object Object]"){n=JSON.stringify(n)}const r=encodeURIComponent(n);e+=encodeURIComponent(t)+"="+r;return e}function gn(e){e=Dn(e);let n="";e.forEach(function(e,t){n=pn(n,t,e)});return n}function mn(e,t,n){const r={"HX-Request":"true","HX-Trigger":ee(e,"id"),"HX-Trigger-Name":ee(e,"name"),"HX-Target":a(t,"id"),"HX-Current-URL":location.href};Cn(e,"hx-headers",false,r);if(n!==undefined){r["HX-Prompt"]=n}if(oe(e).boosted){r["HX-Boosted"]="true"}return r}function yn(n,e){const t=ne(e,"hx-params");if(t){if(t==="none"){return new FormData}else if(t==="*"){return n}else if(t.indexOf("not ")===0){ie(t.slice(4).split(","),function(e){e=e.trim();n.delete(e)});return n}else{const r=new FormData;ie(t.split(","),function(t){t=t.trim();if(n.has(t)){n.getAll(t).forEach(function(e){r.append(t,e)})}});return r}}else{return n}}function xn(e){return!!ee(e,"href")&&ee(e,"href").indexOf("#")>=0}function bn(e,t){const n=t||ne(e,"hx-swap");const r={swapStyle:oe(e).boosted?"innerHTML":Q.config.defaultSwapStyle,swapDelay:Q.config.defaultSwapDelay,settleDelay:Q.config.defaultSettleDelay};if(Q.config.scrollIntoViewOnBoost&&oe(e).boosted&&!xn(e)){r.show="top"}if(n){const s=X(n);if(s.length>0){for(let e=0;e<s.length;e++){const l=s[e];if(l.indexOf("swap:")===0){r.swapDelay=d(l.slice(5))}else if(l.indexOf("settle:")===0){r.settleDelay=d(l.slice(7))}else if(l.indexOf("transition:")===0){r.transition=l.slice(11)==="true"}else if(l.indexOf("ignoreTitle:")===0){r.ignoreTitle=l.slice(12)==="true"}else if(l.indexOf("scroll:")===0){const u=l.slice(7);var o=u.split(":");const c=o.pop();var i=o.length>0?o.join(":"):null;r.scroll=c;r.scrollTarget=i}else if(l.indexOf("show:")===0){const f=l.slice(5);var o=f.split(":");const a=o.pop();var i=o.length>0?o.join(":"):null;r.show=a;r.showTarget=i}else if(l.indexOf("focus-scroll:")===0){const h=l.slice("focus-scroll:".length);r.focusScroll=h=="true"}else if(e==0){r.swapStyle=l}else{H("Unknown modifier in hx-swap: "+l)}}}}return r}function vn(e){return ne(e,"hx-encoding")==="multipart/form-data"||h(e,"form")&&ee(e,"enctype")==="multipart/form-data"}function wn(t,n,r){let o=null;Vt(n,function(e){if(o==null){o=e.encodeParameters(t,r,n)}});if(o!=null){return o}else{if(vn(n)){return hn(new FormData,Dn(r))}else{return gn(r)}}}function Sn(e){return{tasks:[],elts:[e]}}function En(e,t){const n=e[0];const r=e[e.length-1];if(t.scroll){var o=null;if(t.scrollTarget){o=ue(ce(n,t.scrollTarget))}if(t.scroll==="top"&&(n||o)){o=o||n;o.scrollTop=0}if(t.scroll==="bottom"&&(r||o)){o=o||r;o.scrollTop=o.scrollHeight}if(typeof t.scroll==="number"){x().setTimeout(function(){window.scrollTo(0,t.scroll)},0)}}if(t.show){var o=null;if(t.showTarget){let e=t.showTarget;if(t.showTarget==="window"){e="body"}o=ue(ce(n,e))}if(t.show==="top"&&(n||o)){o=o||n;o.scrollIntoView({block:"start",behavior:Q.config.scrollBehavior})}if(t.show==="bottom"&&(r||o)){o=o||r;o.scrollIntoView({block:"end",behavior:Q.config.scrollBehavior})}}}function Cn(r,e,o,i,s){if(i==null){i={}}if(r==null){return i}const l=a(r,e);if(l){let e=l.trim();let t=o;if(e==="unset"){return null}if(e.indexOf("javascript:")===0){e=e.slice(11);t=true}else if(e.indexOf("js:")===0){e=e.slice(3);t=true}if(e.indexOf("{")!==0){e="{"+e+"}"}let n;if(t){n=On(r,function(){if(s){return Function("event","return ("+e+")").call(r,s)}else{return Function("return ("+e+")").call(r)}},{})}else{n=v(e)}for(const u in n){if(n.hasOwnProperty(u)){if(i[u]==null){i[u]=n[u]}}}}return Cn(ue(c(r)),e,o,i,s)}function On(e,t,n){if(Q.config.allowEval){return t()}else{fe(e,"htmx:evalDisallowedError");return n}}function Hn(e,t,n){return Cn(e,"hx-vars",true,n,t)}function Tn(e,t,n){return Cn(e,"hx-vals",false,n,t)}function Rn(e,t){return le(Hn(e,t),Tn(e,t))}function qn(t,n,r){if(r!==null){try{t.setRequestHeader(n,r)}catch(e){t.setRequestHeader(n,encodeURIComponent(r));t.setRequestHeader(n+"-URI-AutoEncoded","true")}}}function An(t){if(t.responseURL){try{const e=new URL(t.responseURL);return e.pathname+e.search}catch(e){fe(te().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function T(e,t){return t.test(e.getAllResponseHeaders())}function Nn(t,n,r){t=t.toLowerCase();if(r){if(r instanceof Element||typeof r==="string"){return he(t,n,null,null,{targetOverride:S(r)||be,returnPromise:true})}else{let e=S(r.target);if(r.target&&!e||r.source&&!e&&!S(r.source)){e=be}return he(t,n,S(r.source),r.event,{handler:r.handler,headers:r.headers,values:r.values,targetOverride:e,swapOverride:r.swap,select:r.select,returnPromise:true,push:r.push,replace:r.replace,selectOOB:r.selectOOB})}}else{return he(t,n,null,null,{returnPromise:true})}}function In(e){const t=[];while(e){t.push(e);e=e.parentElement}return t}function Ln(e,t,n){const r=new URL(t,location.protocol!=="about:"?location.href:window.origin);const o=location.protocol!=="about:"?location.origin:window.origin;const i=o===r.origin;if(Q.config.selfRequestsOnly){if(!i){return false}}return ae(e,"htmx:validateUrl",le({url:r,sameHost:i},n))}function Dn(e){if(e instanceof FormData)return e;const t=new FormData;for(const n in e){if(e.hasOwnProperty(n)){if(e[n]&&typeof e[n].forEach==="function"){e[n].forEach(function(e){t.append(n,e)})}else if(typeof e[n]==="object"&&!(e[n]instanceof Blob)){t.append(n,JSON.stringify(e[n]))}else{t.append(n,e[n])}}}return t}function Pn(r,o,e){return new Proxy(e,{get:function(t,e){if(typeof e==="number")return t[e];if(e==="length")return t.length;if(e==="push"){return function(e){t.push(e);r.append(o,e)}}if(typeof t[e]==="function"){return function(){t[e].apply(t,arguments);r.delete(o);t.forEach(function(e){r.append(o,e)})}}if(t[e]&&t[e].length===1){return t[e][0]}else{return t[e]}},set:function(e,t,n){e[t]=n;r.delete(o);e.forEach(function(e){r.append(o,e)});return true}})}function kn(o){return new Proxy(o,{get:function(e,t){if(typeof t==="symbol"){const r=Reflect.get(e,t);if(typeof r==="function"){return function(){return r.apply(o,arguments)}}else{return r}}if(t==="toJSON"){return()=>Object.fromEntries(o)}if(t in e){if(typeof e[t]==="function"){return function(){return o[t].apply(o,arguments)}}}const n=o.getAll(t);if(n.length===0){return undefined}else if(n.length===1){return n[0]}else{return Pn(e,t,n)}},set:function(t,n,e){if(typeof n!=="string"){return false}t.delete(n);if(e&&typeof e.forEach==="function"){e.forEach(function(e){t.append(n,e)})}else if(typeof e==="object"&&!(e instanceof Blob)){t.append(n,JSON.stringify(e))}else{t.append(n,e)}return true},deleteProperty:function(e,t){if(typeof t==="string"){e.delete(t)}return true},ownKeys:function(e){return Reflect.ownKeys(Object.fromEntries(e))},getOwnPropertyDescriptor:function(e,t){return Reflect.getOwnPropertyDescriptor(Object.fromEntries(e),t)}})}function he(t,n,r,o,i,k){let s=null;let l=null;i=i!=null?i:{};if(i.returnPromise&&typeof Promise!=="undefined"){var e=new Promise(function(e,t){s=e;l=t})}if(r==null){r=te().body}const M=i.handler||Vn;const F=i.select||null;if(!se(r)){re(s);return e}const u=i.targetOverride||ue(Se(r));if(u==null||u==be){fe(r,"htmx:targetError",{target:ne(r,"hx-target")});re(l);return e}let c=oe(r);const f=c.lastButtonClicked;if(f){const A=ee(f,"formaction");if(A!=null){n=A}const N=ee(f,"formmethod");if(N!=null){if(de.includes(N.toLowerCase())){t=N}else{re(s);return e}}}const a=ne(r,"hx-confirm");if(k===undefined){const K=function(e){return he(t,n,r,o,i,!!e)};const G={target:u,elt:r,path:n,verb:t,triggeringEvent:o,etc:i,issueRequest:K,question:a};if(ae(r,"htmx:confirm",G)===false){re(s);return e}}let h=r;let d=ne(r,"hx-sync");let p=null;let B=false;if(d){const I=d.split(":");const L=I[0].trim();if(L==="this"){h=we(r,"hx-sync")}else{h=ue(ce(r,L))}d=(I[1]||"drop").trim();c=oe(h);if(d==="drop"&&c.xhr&&c.abortable!==true){re(s);return e}else if(d==="abort"){if(c.xhr){re(s);return e}else{B=true}}else if(d==="replace"){ae(h,"htmx:abort")}else if(d.indexOf("queue")===0){const W=d.split(" ");p=(W[1]||"last").trim()}}if(c.xhr){if(c.abortable){ae(h,"htmx:abort")}else{if(p==null){if(o){const D=oe(o);if(D&&D.triggerSpec&&D.triggerSpec.queue){p=D.triggerSpec.queue}}if(p==null){p="last"}}if(c.queuedRequests==null){c.queuedRequests=[]}if(p==="first"&&c.queuedRequests.length===0){c.queuedRequests.push(function(){he(t,n,r,o,i)})}else if(p==="all"){c.queuedRequests.push(function(){he(t,n,r,o,i)})}else if(p==="last"){c.queuedRequests=[];c.queuedRequests.push(function(){he(t,n,r,o,i)})}re(s);return e}}const g=new XMLHttpRequest;c.xhr=g;c.abortable=B;const m=function(){c.xhr=null;c.abortable=false;if(c.queuedRequests!=null&&c.queuedRequests.length>0){const e=c.queuedRequests.shift();e()}};const X=ne(r,"hx-prompt");if(X){var y=prompt(X);if(y===null||!ae(r,"htmx:prompt",{prompt:y,target:u})){re(s);m();return e}}if(a&&!k){if(!confirm(a)){re(s);m();return e}}let x=mn(r,u,y);if(t!=="get"&&!vn(r)){x["Content-Type"]="application/x-www-form-urlencoded"}if(i.headers){x=le(x,i.headers)}const U=dn(r,t);let b=U.errors;const V=U.formData;if(i.values){hn(V,Dn(i.values))}const j=Dn(Rn(r,o));const v=hn(V,j);let w=yn(v,r);if(Q.config.getCacheBusterParam&&t==="get"){w.set("org.htmx.cache-buster",ee(u,"id")||"true")}if(n==null||n===""){n=location.href}const S=Cn(r,"hx-request");const $=oe(r).boosted;let E=Q.config.methodsThatUseUrlParams.indexOf(t)>=0;const C={boosted:$,useUrlParams:E,formData:w,parameters:kn(w),unfilteredFormData:v,unfilteredParameters:kn(v),headers:x,elt:r,target:u,verb:t,errors:b,withCredentials:i.credentials||S.credentials||Q.config.withCredentials,timeout:i.timeout||S.timeout||Q.config.timeout,path:n,triggeringEvent:o};if(!ae(r,"htmx:configRequest",C)){re(s);m();return e}n=C.path;t=C.verb;x=C.headers;w=Dn(C.parameters);b=C.errors;E=C.useUrlParams;if(b&&b.length>0){ae(r,"htmx:validation:halted",C);re(s);m();return e}const _=n.split("#");const z=_[0];const O=_[1];let H=n;if(E){H=z;const Z=!w.keys().next().done;if(Z){if(H.indexOf("?")<0){H+="?"}else{H+="&"}H+=gn(w);if(O){H+="#"+O}}}if(!Ln(r,H,C)){fe(r,"htmx:invalidPath",C);re(l);m();return e}g.open(t.toUpperCase(),H,true);g.overrideMimeType("text/html");g.withCredentials=C.withCredentials;g.timeout=C.timeout;if(S.noHeaders){}else{for(const P in x){if(x.hasOwnProperty(P)){const Y=x[P];qn(g,P,Y)}}}const T={xhr:g,target:u,requestConfig:C,etc:i,boosted:$,select:F,pathInfo:{requestPath:n,finalRequestPath:H,responsePath:null,anchor:O}};g.onload=function(){try{const t=In(r);T.pathInfo.responsePath=An(g);M(r,T);if(T.keepIndicators!==true){rn(R,q)}ae(r,"htmx:afterRequest",T);ae(r,"htmx:afterOnLoad",T);if(!se(r)){let e=null;while(t.length>0&&e==null){const n=t.shift();if(se(n)){e=n}}if(e){ae(e,"htmx:afterRequest",T);ae(e,"htmx:afterOnLoad",T)}}re(s)}catch(e){fe(r,"htmx:onLoadError",le({error:e},T));throw e}finally{m()}};g.onerror=function(){rn(R,q);fe(r,"htmx:afterRequest",T);fe(r,"htmx:sendError",T);re(l);m()};g.onabort=function(){rn(R,q);fe(r,"htmx:afterRequest",T);fe(r,"htmx:sendAbort",T);re(l);m()};g.ontimeout=function(){rn(R,q);fe(r,"htmx:afterRequest",T);fe(r,"htmx:timeout",T);re(l);m()};if(!ae(r,"htmx:beforeRequest",T)){re(s);m();return e}var R=tn(r);var q=nn(r);ie(["loadstart","loadend","progress","abort"],function(t){ie([g,g.upload],function(e){e.addEventListener(t,function(e){ae(r,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});ae(r,"htmx:beforeSend",T);const J=E?null:wn(g,r,w);g.send(J);return e}function Mn(e,t){const n=t.xhr;let r=null;let o=null;if(T(n,/HX-Push:/i)){r=n.getResponseHeader("HX-Push");o="push"}else if(T(n,/HX-Push-Url:/i)){r=n.getResponseHeader("HX-Push-Url");o="push"}else if(T(n,/HX-Replace-Url:/i)){r=n.getResponseHeader("HX-Replace-Url");o="replace"}if(r){if(r==="false"){return{}}else{return{type:o,path:r}}}const i=t.pathInfo.finalRequestPath;const s=t.pathInfo.responsePath;let l=t.etc.push||ne(e,"hx-push-url");let u=t.etc.replace||ne(e,"hx-replace-url");if(l==="false")l=null;if(u==="false")u=null;const c=oe(e).boosted;let f=null;let a=null;if(l){f="push";a=l}else if(u){f="replace";a=u}else if(c){f="push";a=s||i}if(a){if(a==="true"){a=s||i}if(t.pathInfo.anchor&&a.indexOf("#")===-1){a=a+"#"+t.pathInfo.anchor}return{type:f,path:a}}else{return{}}}function Fn(e,t){var n=new RegExp(e.code);return n.test(t.toString(10))}function Bn(e){for(var t=0;t<Q.config.responseHandling.length;t++){var n=Q.config.responseHandling[t];if(Fn(n,e.status)){return n}}return{swap:false}}function Xn(e){if(e){const t=f("title");if(t){t.textContent=e}else{window.document.title=e}}}function Un(e,t){if(t==="this"){return e}const n=ue(ce(e,t));if(n==null){fe(e,"htmx:targetError",{target:t});throw new Error(`Invalid re-target ${t}`)}return n}function Vn(t,e){const n=e.xhr;let r=e.target;const o=e.etc;const i=e.select;if(!ae(t,"htmx:beforeOnLoad",e))return;if(T(n,/HX-Trigger:/i)){ze(n,"HX-Trigger",t)}if(T(n,/HX-Location:/i)){let e=n.getResponseHeader("HX-Location");var s={};if(e.indexOf("{")===0){s=v(e);e=s.path;delete s.path}s.push=s.push??"true";Nn("get",e,s);return}const l=T(n,/HX-Refresh:/i)&&n.getResponseHeader("HX-Refresh")==="true";if(T(n,/HX-Redirect:/i)){e.keepIndicators=true;Q.location.href=n.getResponseHeader("HX-Redirect");l&&Q.location.reload();return}if(l){e.keepIndicators=true;Q.location.reload();return}const u=Mn(t,e);const c=Bn(n);const f=c.swap;let a=!!c.error;let h=Q.config.ignoreTitle||c.ignoreTitle;let d=c.select;if(c.target){e.target=Un(t,c.target)}var p=o.swapOverride;if(p==null&&c.swapOverride){p=c.swapOverride}if(T(n,/HX-Retarget:/i)){e.target=Un(t,n.getResponseHeader("HX-Retarget"))}if(T(n,/HX-Reswap:/i)){p=n.getResponseHeader("HX-Reswap")}var g=n.response;var m=le({shouldSwap:f,serverResponse:g,isError:a,ignoreTitle:h,selectOverride:d,swapOverride:p},e);if(c.event&&!ae(r,c.event,m))return;if(!ae(r,"htmx:beforeSwap",m))return;r=m.target;g=m.serverResponse;a=m.isError;h=m.ignoreTitle;d=m.selectOverride;p=m.swapOverride;e.target=r;e.failed=a;e.successful=!a;if(m.shouldSwap){if(n.status===286){lt(t)}Vt(t,function(e){g=e.transformResponse(g,n,t)});if(u.type){Gt()}var y=bn(t,p);if(!y.hasOwnProperty("ignoreTitle")){y.ignoreTitle=h}w(r,Q.config.swappingClass);if(i){d=i}if(T(n,/HX-Reselect:/i)){d=n.getResponseHeader("HX-Reselect")}const x=o.selectOOB||ne(t,"hx-select-oob");const b=ne(t,"hx-select");_e(r,g,y,{select:d==="unset"?null:d||b,selectOOB:x,eventInfo:e,anchor:e.pathInfo.anchor,contextElement:t,afterSwapCallback:function(){if(T(n,/HX-Trigger-After-Swap:/i)){let e=t;if(!se(t)){e=te().body}ze(n,"HX-Trigger-After-Swap",e)}},afterSettleCallback:function(){if(T(n,/HX-Trigger-After-Settle:/i)){let e=t;if(!se(t)){e=te().body}ze(n,"HX-Trigger-After-Settle",e)}},beforeSwapCallback:function(){if(u.type){ae(te().body,"htmx:beforeHistoryUpdate",le({history:u},e));if(u.type==="push"){Wt(u.path);ae(te().body,"htmx:pushedIntoHistory",{path:u.path})}else{Zt(u.path);ae(te().body,"htmx:replacedInHistory",{path:u.path})}}}})}if(a){fe(t,"htmx:responseError",le({error:"Response Status Error Code "+n.status+" from "+e.pathInfo.requestPath},e))}}const jn={};function $n(){return{init:function(e){return null},getSelectors:function(){return null},onEvent:function(e,t){return true},transformResponse:function(e,t,n){return e},isInlineSwap:function(e){return false},handleSwap:function(e,t,n,r){return false},encodeParameters:function(e,t,n){return null}}}function _n(e,t){if(t.init){t.init(n)}jn[e]=le($n(),t)}function zn(e){delete jn[e]}function Jn(e,n,r){if(n==undefined){n=[]}if(e==undefined){return n}if(r==undefined){r=[]}const t=a(e,"hx-ext");if(t){ie(t.split(","),function(e){e=e.replace(/ /g,"");if(e.slice(0,7)=="ignore:"){r.push(e.slice(7));return}if(r.indexOf(e)<0){const t=jn[e];if(t&&n.indexOf(t)<0){n.push(t)}}})}return Jn(ue(c(e)),n,r)}var Kn=false;te().addEventListener("DOMContentLoaded",function(){Kn=true});function Gn(e){if(Kn||te().readyState==="complete"){e()}else{te().addEventListener("DOMContentLoaded",e)}}function Wn(){if(Q.config.includeIndicatorStyles!==false){const e=Q.config.inlineStyleNonce?` nonce="${Q.config.inlineStyleNonce}"`:"";const t=Q.config.indicatorClass;const n=Q.config.requestClass;te().head.insertAdjacentHTML("beforeend",`<style${e}>`+`.${t}{opacity:0;visibility: hidden} `+`.${n} .${t}, .${n}.${t}{opacity:1;visibility: visible;transition: opacity 200ms ease-in}`+"</style>")}}function Zn(){const e=te().querySelector('meta[name="htmx-config"]');if(e){return v(e.content)}else{return null}}function Yn(){const e=Zn();if(e){Q.config=le(Q.config,e)}}Gn(function(){Yn();Wn();let e=te().body;Ft(e);const t=te().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){const t=e.detail.elt||e.target;const n=oe(t);if(n&&n.xhr){n.xhr.abort()}});const n=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){en();ie(t,function(e){ae(e,"htmx:restored",{document:te(),triggerEvent:ae})})}else{if(n){n(e)}}};x().setTimeout(function(){ae(e,"htmx:load",{});e=null},0)});return Q}(); +\ No newline at end of file diff --git a/conductor.example.yaml b/conductor.example.yaml @@ -16,10 +16,6 @@ server: # URLs derived from it, so it must be correct behind a reverse proxy. public_url: http://127.0.0.1:8080 -read_api: - host: 0.0.0.0 - port: 8081 - database: # Leave url unset for sqlite. Otherwise the scheme picks the dialect: # mysql://user:pass@host:3306/conductor diff --git a/dashboard/app.js b/dashboard/app.js @@ -1,440 +0,0 @@ -// dashboard/app.js - the conductor dashboard -// -// Plain ES modules, no framework and no build step. The whole thing is a -// run list, a run view and a log tail, which does not justify a dependency -// or a toolchain. -// -// All requests are relative, so the same files work whether they are served -// by the conductor or by the read-api. The admin controls appear only when -// the write surface answers, which it does not on the read-api. - -const view = document.getElementById('view'); -const crumbs = document.getElementById('crumbs'); -const session = document.getElementById('session'); -const statusLine = document.getElementById('status'); - -const state = { - admin: null, // null until probed, then true or false - user: null, - token: sessionStorage.getItem('conductor_token'), - timers: [], -}; - -// --- tiny dom helper --- - -function el(tag, attrs = {}, ...children) { - const node = document.createElement(tag); - for (const [key, value] of Object.entries(attrs)) { - if (value === null || value === undefined || value === false) continue; - if (key === 'class') node.className = value; - else if (key === 'text') node.textContent = value; - else if (key.startsWith('on')) node.addEventListener(key.slice(2), value); - else node.setAttribute(key, value); - } - for (const child of children.flat()) { - if (child === null || child === undefined || child === false) continue; - node.append(child instanceof Node ? child : document.createTextNode(String(child))); - } - return node; -} - -function render(...nodes) { - view.replaceChildren(...nodes.flat().filter(Boolean)); -} - -function clearTimers() { - for (const timer of state.timers) clearInterval(timer); - state.timers = []; -} - -function every(ms, fn) { - state.timers.push(setInterval(fn, ms)); -} - -// --- formatting --- - -const badge = (s) => el('span', { class: `badge ${s}`, text: s }); - -function ago(ms) { - if (!ms) return ''; - const seconds = Math.round((Date.now() - ms) / 1000); - if (seconds < 60) return `${seconds}s ago`; - if (seconds < 3600) return `${Math.round(seconds / 60)}m ago`; - if (seconds < 86400) return `${Math.round(seconds / 3600)}h ago`; - return new Date(ms).toISOString().slice(0, 10); -} - -function duration(from, to) { - if (!from) return ''; - const seconds = Math.round(((to ?? Date.now()) - from) / 1000); - if (seconds < 60) return `${seconds}s`; - return `${Math.floor(seconds / 60)}m ${seconds % 60}s`; -} - -const short = (sha) => (sha ? String(sha).slice(0, 8) : ''); - -// --- api --- - -async function api(path, options = {}) { - const headers = { ...(options.headers ?? {}) }; - if (state.token) headers.authorization = `Bearer ${state.token}`; - if (options.body) headers['content-type'] = 'application/json'; - - const res = await fetch(path, { ...options, headers }); - if (!res.ok) { - let detail = ''; - try { - detail = (await res.json()).error ?? ''; - } catch { - // Not JSON; the status is enough. - } - const error = new Error(detail || `${res.status} ${res.statusText}`); - error.status = res.status; - throw error; - } - return res.status === 204 ? null : res.json(); -} - -function note(message, isError = false) { - statusLine.textContent = message; - statusLine.className = isError ? 'error' : 'muted'; -} - -// --- session --- - -async function probeAdmin() { - try { - const mode = await api('/api/auth/mode'); - state.admin = true; - state.localLogin = mode.local_login; - } catch { - // The read-api does not mount the auth routes at all. - state.admin = false; - } - if (state.admin && state.token) { - try { - state.user = (await api('/api/auth/me')).user; - } catch { - state.token = null; - sessionStorage.removeItem('conductor_token'); - } - } - renderSession(); -} - -function renderSession() { - if (!state.admin) { - session.replaceChildren(el('span', { class: 'muted', text: 'read only' })); - return; - } - if (state.user) { - session.replaceChildren( - el('span', { class: 'muted', text: `${state.user.username} (${state.user.role}) ` }), - el('button', { onclick: logout, text: 'sign out' }) - ); - return; - } - session.replaceChildren(el('button', { onclick: showLogin, text: 'sign in' })); -} - -function showLogin() { - const username = el('input', { placeholder: 'username', autocomplete: 'username' }); - const password = el('input', { type: 'password', placeholder: 'password', autocomplete: 'current-password' }); - const message = el('p', { class: 'error' }); - - const submit = async () => { - try { - const result = await api('/api/auth/login', { - method: 'POST', - body: JSON.stringify({ username: username.value, password: password.value }), - }); - state.token = result.token; - state.user = result.user; - sessionStorage.setItem('conductor_token', result.token); - renderSession(); - route(); - } catch (e) { - message.textContent = e.message; - } - }; - - clearTimers(); - render(el('div', { class: 'panel' }, - el('h2', { text: 'Sign in' }), - el('div', { class: 'actions' }, username, password, el('button', { onclick: submit, text: 'sign in' })), - message)); - - password.addEventListener('keydown', (e) => { if (e.key === 'Enter') submit(); }); - username.focus(); -} - -function logout() { - state.token = null; - state.user = null; - sessionStorage.removeItem('conductor_token'); - api('/api/auth/logout', { method: 'POST' }).catch(() => {}); - renderSession(); - route(); -} - -// --- runs --- - -async function showRuns() { - const body = el('tbody'); - const table = el('table', - {}, - el('thead', {}, el('tr', {}, - el('th', { text: 'run' }), - el('th', { text: 'project' }), - el('th', { text: 'ref' }), - el('th', { text: 'commit' }), - el('th', { text: 'state' }), - el('th', { text: 'started' }))), - body); - - render(el('h2', { text: 'Runs' }), table); - - async function refresh() { - try { - const { runs } = await api('/api/runs?limit=50'); - note(`${runs.length} run(s), updated ${new Date().toLocaleTimeString()}`); - body.replaceChildren(...runs.map((run) => el('tr', { class: 'clickable', onclick: () => { location.hash = `#/run/${run.id}`; } }, - el('td', {}, el('a', { href: `#/run/${run.id}`, text: `#${run.number}` })), - el('td', { text: run.project_id }), - el('td', { class: 'muted', text: (run.ref ?? '').replace('refs/heads/', '') }), - el('td', { class: 'mono muted', text: short(run.head_sha) }), - el('td', {}, badge(run.state)), - el('td', { class: 'muted', text: ago(run.created_at) })))); - - if (runs.length === 0) { - body.replaceChildren(el('tr', {}, el('td', { colspan: '6', class: 'muted', text: 'No runs yet.' }))); - } - } catch (e) { - note(e.message, true); - } - } - - await refresh(); - every(5000, refresh); -} - -async function showRun(runId) { - const header = el('div', { class: 'panel' }); - const stages = el('div'); - render(header, stages); - - async function refresh() { - let data; - try { - data = await api(`/api/runs/${encodeURIComponent(runId)}`); - } catch (e) { - note(e.message, true); - render(el('p', { class: 'error', text: `Could not load run: ${e.message}` })); - clearTimers(); - return; - } - - const { run, jobs } = data; - crumbs.replaceChildren(el('a', { href: '#/', text: 'runs' }), document.createTextNode(` / #${run.number}`)); - - const info = el('div', { class: 'row' }, - field('project', run.project_id), - field('state', null, badge(run.state)), - field('ref', (run.ref ?? '').replace('refs/heads/', '') || '-'), - field('commit', short(run.head_sha)), - field('trigger', `${run.trigger_type}${run.actor ? ` by ${run.actor}` : ''}`), - field('duration', duration(run.started_at, run.finished_at))); - - header.replaceChildren( - el('h2', { text: run.title || `Run #${run.number}` }), - info, - adminActions(run)); - - // Jobs are grouped by graph depth, which is how the pipeline reads. - const byDepth = new Map(); - for (const job of jobs) { - const depth = job.spec_depth ?? depthOf(job, jobs); - if (!byDepth.has(depth)) byDepth.set(depth, []); - byDepth.get(depth).push(job); - } - - stages.replaceChildren(...[...byDepth.keys()].sort((a, b) => a - b).map((depth) => { - const rows = byDepth.get(depth).sort((a, b) => a.name.localeCompare(b.name)); - return el('div', { class: 'stage' }, - el('h3', { text: `stage ${depth + 1}` }), - el('table', {}, el('tbody', {}, ...rows.map(jobRow)))); - })); - - note(`updated ${new Date().toLocaleTimeString()}`); - if (['success', 'failed', 'cancelled'].includes(run.state)) clearTimers(); - } - - await refresh(); - every(3000, refresh); -} - -function jobRow(job) { - return el('tr', {}, - el('td', {}, el('a', { href: `#/job/${encodeURIComponent(job.id)}`, text: job.name })), - el('td', {}, badge(job.state)), - el('td', { class: 'muted', text: job.arch ?? '' }), - el('td', { class: 'muted', text: job.worker_name ?? '' }), - el('td', { class: 'muted', text: duration(job.started_at, job.finished_at) }), - el('td', { class: 'muted', text: job.exit_code === null ? '' : `exit ${job.exit_code}` })); -} - -// Depth is not stored on the row, so it is recomputed from the edges. -function depthOf(job, jobs) { - const byId = new Map(jobs.map((j) => [j.id, j])); - const seen = new Set(); - const walk = (current) => { - if (seen.has(current.id)) return 0; - seen.add(current.id); - const deps = (current.needs ?? []).map((id) => byId.get(id)).filter(Boolean); - return deps.length === 0 ? 0 : Math.max(...deps.map((d) => walk(d) + 1)); - }; - return walk(job); -} - -function field(label, value, node) { - return el('div', {}, el('span', { class: 'label', text: label }), node ?? el('span', { text: value ?? '' })); -} - -function adminActions(run) { - if (!state.admin || !state.user || state.user.role !== 'admin') return null; - - const actions = el('div', { class: 'actions' }); - if (run.state === 'running') { - actions.append(el('button', { - text: 'cancel', - onclick: async () => { - try { - await api(`/api/admin/runs/${encodeURIComponent(run.id)}/cancel`, { method: 'POST', body: '{}' }); - note('run cancelled'); - } catch (e) { - note(e.message, true); - } - }, - })); - } - actions.append(el('button', { - text: 'run again', - onclick: async () => { - try { - const result = await api(`/api/admin/runs/${encodeURIComponent(run.id)}/retry`, { method: 'POST', body: '{}' }); - location.hash = `#/run/${result.run_id}`; - } catch (e) { - note(e.message, true); - } - }, - })); - return actions; -} - -// --- job and log --- - -async function showJob(jobId) { - const header = el('div', { class: 'panel' }); - const logEl = el('pre', { class: 'log', text: '' }); - const artifactsEl = el('div'); - render(header, el('h3', { text: 'log' }), logEl, artifactsEl); - - let offset = 0; - let complete = false; - let following = true; - - logEl.addEventListener('scroll', () => { - following = logEl.scrollTop + logEl.clientHeight >= logEl.scrollHeight - 24; - }); - - async function refreshHeader() { - const { job, artifacts } = await api(`/api/jobs/${encodeURIComponent(jobId)}`); - crumbs.replaceChildren( - el('a', { href: '#/', text: 'runs' }), - document.createTextNode(' / '), - el('a', { href: `#/run/${job.run_id}`, text: 'run' }), - document.createTextNode(` / ${job.name}`)); - - header.replaceChildren( - el('h2', { text: job.name }), - el('div', { class: 'row' }, - field('state', null, badge(job.state)), - field('image', job.image), - field('arch', job.arch ?? '-'), - field('attempt', `${job.attempt} of ${job.max_attempts}`), - field('worker', job.worker_name ?? '-'), - field('duration', duration(job.started_at, job.finished_at))), - job.error ? el('p', { class: 'error', text: job.error }) : null); - - if (artifacts.length > 0) { - artifactsEl.replaceChildren( - el('h3', { text: 'artifacts' }), - el('table', {}, el('tbody', {}, ...artifacts.map((a) => el('tr', {}, - el('td', {}, el('a', { href: `/api/artifacts/${a.id}`, text: a.path })), - el('td', { class: 'muted', text: `${a.size} bytes` }), - el('td', { class: 'mono muted', text: a.sha256.slice(0, 12) })))))); - } else { - artifactsEl.replaceChildren(); - } - - return job; - } - - async function tail() { - const res = await fetch(`/api/jobs/${encodeURIComponent(jobId)}/log?offset=${offset}`); - if (!res.ok) return; - const text = await res.text(); - if (text.length > 0) { - logEl.append(document.createTextNode(text)); - if (following) logEl.scrollTop = logEl.scrollHeight; - } - offset = Number(res.headers.get('x-log-offset') ?? offset) + new TextEncoder().encode(text).length; - complete = res.headers.get('x-log-complete') === 'true'; - } - - try { - const job = await refreshHeader(); - await tail(); - if (complete && ['success', 'failed', 'skipped', 'cancelled'].includes(job.state)) { - note('job finished'); - return; - } - } catch (e) { - note(e.message, true); - render(el('p', { class: 'error', text: `Could not load job: ${e.message}` })); - return; - } - - every(1500, async () => { - try { - const job = await refreshHeader(); - await tail(); - if (complete && ['success', 'failed', 'skipped', 'cancelled'].includes(job.state)) { - clearTimers(); - note('job finished'); - } - } catch (e) { - note(e.message, true); - } - }); -} - -// --- routing --- - -function route() { - clearTimers(); - crumbs.replaceChildren(); - const hash = location.hash.replace(/^#/, '') || '/'; - - const runMatch = /^\/run\/(.+)$/.exec(hash); - const jobMatch = /^\/job\/(.+)$/.exec(hash); - - if (jobMatch) showJob(decodeURIComponent(jobMatch[1])); - else if (runMatch) showRun(decodeURIComponent(runMatch[1])); - else showRuns(); -} - -window.addEventListener('hashchange', route); - -await probeAdmin(); -route(); diff --git a/dashboard/index.html b/dashboard/index.html @@ -1,26 +0,0 @@ -<!doctype html> -<html lang="en"> -<head> -<meta charset="utf-8"> -<meta name="viewport" content="width=device-width, initial-scale=1"> -<title>conductor</title> -<link rel="stylesheet" href="style.css"> -</head> -<body> -<header> - <h1><a href="#/">conductor</a></h1> - <nav id="crumbs"></nav> - <div id="session"></div> -</header> - -<main id="view"> - <p class="muted">Loading.</p> -</main> - -<footer> - <span id="status" class="muted"></span> -</footer> - -<script src="app.js" type="module"></script> -</body> -</html> diff --git a/dashboard/style.css b/dashboard/style.css @@ -1,122 +0,0 @@ -/* dashboard/style.css - one stylesheet, no framework */ - -:root { - --bg: #101214; - --panel: #171a1d; - --border: #272b30; - --text: #dfe3e6; - --muted: #8b949e; - --link: #6cb6ff; - --queued: #3a4046; - --running: #b58900; - --success: #2e7d32; - --failed: #c62828; - --skipped: #4a5058; - --cancelled: #6a4a8a; -} - -* { box-sizing: border-box; } - -body { - margin: 0; - background: var(--bg); - color: var(--text); - font: 14px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; -} - -a { color: var(--link); text-decoration: none; } -a:hover { text-decoration: underline; } - -code, pre, .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } - -header { - display: flex; - align-items: center; - gap: 1rem; - padding: .75rem 1.25rem; - border-bottom: 1px solid var(--border); - background: var(--panel); -} - -header h1 { font-size: 1rem; margin: 0; font-weight: 600; } -header h1 a { color: var(--text); } -header nav { flex: 1; color: var(--muted); font-size: .85rem; } -header nav a { color: var(--muted); } - -main { padding: 1.25rem; max-width: 1100px; margin: 0 auto; } -footer { padding: .5rem 1.25rem; font-size: .8rem; } - -.muted { color: var(--muted); } -.error { color: #ff8b8b; } - -table { width: 100%; border-collapse: collapse; margin-top: .5rem; } -th, td { text-align: left; padding: .45rem .6rem; border-bottom: 1px solid var(--border); } -th { color: var(--muted); font-weight: 500; font-size: .8rem; text-transform: uppercase; letter-spacing: .04em; } -tbody tr:hover { background: #1b1f23; } - -.badge { - display: inline-block; - padding: .05rem .45rem; - border-radius: .75rem; - font-size: .75rem; - background: var(--queued); - color: #fff; -} -.badge.running { background: var(--running); color: #1a1a1a; } -.badge.success { background: var(--success); } -.badge.failed { background: var(--failed); } -.badge.skipped { background: var(--skipped); } -.badge.cancelled { background: var(--cancelled); } -.badge.pending { background: var(--queued); } - -.stage { margin: 1rem 0; } -.stage h3 { font-size: .8rem; color: var(--muted); margin: 0 0 .35rem; font-weight: 500; } - -.panel { - background: var(--panel); - border: 1px solid var(--border); - border-radius: 6px; - padding: .75rem 1rem; - margin-bottom: 1rem; -} - -.row { display: flex; gap: 1.5rem; flex-wrap: wrap; } -.row > div { min-width: 8rem; } -.row .label { color: var(--muted); font-size: .75rem; display: block; } - -pre.log { - background: #0b0d0f; - border: 1px solid var(--border); - border-radius: 6px; - padding: .75rem 1rem; - max-height: 32rem; - overflow: auto; - white-space: pre-wrap; - word-break: break-word; - font-size: .82rem; - margin: 0; -} - -button { - background: #22262b; - color: var(--text); - border: 1px solid var(--border); - border-radius: 4px; - padding: .3rem .7rem; - cursor: pointer; - font-size: .85rem; -} -button:hover { background: #2b3036; } -button:disabled { opacity: .5; cursor: default; } - -input { - background: #0b0d0f; - border: 1px solid var(--border); - border-radius: 4px; - color: var(--text); - padding: .3rem .5rem; - font-size: .85rem; -} - -.actions { display: flex; gap: .5rem; align-items: center; margin-top: .75rem; } -.clickable { cursor: pointer; } diff --git a/docs/pipeline.md b/docs/pipeline.md @@ -11,11 +11,12 @@ file are reported together. Top level --------- -| Key | Required | Description | -| ---------- | -------- | ------------------------------------ | -| `version` | yes | Must be `1`. | -| `defaults` | no | Values inherited by every job. | -| `jobs` | yes | Mapping of job name to job. | +| Key | Required | Description | +| ------------ | -------- | -------------------------------------------- | +| `version` | yes | Must be `1`. | +| `visibility` | no | `public` or `private`. Overrides the project. | +| `defaults` | no | Values inherited by every job. | +| `jobs` | yes | Mapping of job name to job. | Job names may contain letters, digits, underscore, dot and hyphen, and must start with a letter or digit. @@ -29,6 +30,24 @@ jobs: script: [make test] ``` +Visibility +---------- + +Runs are private unless something says otherwise, and the repository has the +final word on its own results: + +```yaml +version: 1 +visibility: public +``` + +Unset means the project's setting stands. The value is recorded on each run +as it is created, so turning a repository private stops exposing future runs +without retroactively hiding, or revealing, earlier ones. + +A public run, its logs and its artifacts are readable by anyone. A private +run is readable by the project's owner and by administrators. + Jobs ---- diff --git a/migrations/mysql/002_ownership.sql b/migrations/mysql/002_ownership.sql @@ -0,0 +1,18 @@ +-- 002_ownership.sql - project ownership and run visibility (mysql and tidb) +-- +-- See migrations/sqlite/002_ownership.sql for the reasoning. + +ALTER TABLE projects + ADD COLUMN owner_id VARCHAR(64) NULL, + ADD COLUMN visibility VARCHAR(16) NOT NULL DEFAULT 'private', + ADD KEY idx_projects_owner (owner_id), + ADD CONSTRAINT fk_projects_owner FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE; + +ALTER TABLE worker_tokens + ADD COLUMN owner_id VARCHAR(64) NULL, + ADD KEY idx_worker_tokens_owner (owner_id), + ADD CONSTRAINT fk_worker_tokens_owner FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE; + +ALTER TABLE runs + ADD COLUMN visibility VARCHAR(16) NOT NULL DEFAULT 'private', + ADD KEY idx_runs_visibility (visibility); diff --git a/migrations/postgres/002_ownership.sql b/migrations/postgres/002_ownership.sql @@ -0,0 +1,17 @@ +-- 002_ownership.sql - project ownership and run visibility (postgres) +-- +-- See migrations/sqlite/002_ownership.sql for the reasoning. + +ALTER TABLE projects + ADD COLUMN owner_id TEXT REFERENCES users(id) ON DELETE CASCADE, + ADD COLUMN visibility TEXT NOT NULL DEFAULT 'private'; + +ALTER TABLE worker_tokens + ADD COLUMN owner_id TEXT REFERENCES users(id) ON DELETE CASCADE; + +ALTER TABLE runs + ADD COLUMN visibility TEXT NOT NULL DEFAULT 'private'; + +CREATE INDEX idx_projects_owner ON projects (owner_id); +CREATE INDEX idx_worker_tokens_owner ON worker_tokens (owner_id); +CREATE INDEX idx_runs_visibility ON runs (visibility); diff --git a/migrations/sqlite/002_ownership.sql b/migrations/sqlite/002_ownership.sql @@ -0,0 +1,32 @@ +-- 002_ownership.sql - project ownership and run visibility (sqlite) +-- +-- Two changes that go together: +-- +-- Ownership. A user may register their own projects and their own +-- workers. A worker that has an owner will only ever be offered jobs +-- belonging to that owner's projects, so lending someone build capacity +-- does not expose anything else. A worker with no owner is shared and +-- can run any project, which is how an administrator provides general +-- capacity. +-- +-- Visibility. A run is private unless it says otherwise. The value comes +-- from the project, and the pipeline at the built commit may override it, +-- so a repository decides whether its own results are public. +-- +-- Deleting a user deletes what they owned, cascading on to their runs, +-- jobs and artifacts. Leaving the rows behind unowned would be worse than +-- losing them: an unowned project is administered centrally, and an +-- unowned worker token is shared capacity that accepts every project's +-- jobs, so a deleted account would quietly widen a worker's reach rather +-- than removing it. + +ALTER TABLE projects ADD COLUMN owner_id TEXT REFERENCES users(id) ON DELETE CASCADE; +ALTER TABLE projects ADD COLUMN visibility TEXT NOT NULL DEFAULT 'private'; + +ALTER TABLE worker_tokens ADD COLUMN owner_id TEXT REFERENCES users(id) ON DELETE CASCADE; + +ALTER TABLE runs ADD COLUMN visibility TEXT NOT NULL DEFAULT 'private'; + +CREATE INDEX idx_projects_owner ON projects (owner_id); +CREATE INDEX idx_worker_tokens_owner ON worker_tokens (owner_id); +CREATE INDEX idx_runs_visibility ON runs (visibility); diff --git a/package.json b/package.json @@ -7,7 +7,6 @@ "scripts": { "start": "node src/conductor/index.js", "dev": "node --watch src/conductor/index.js", - "read-api": "node src/read-api/index.js", "worker": "node src/worker/agent.js", "migrate": "node src/lib/db/migrate-cli.js", "test": "node --test \"test/**/*.test.js\"" diff --git a/src/conductor/app.js b/src/conductor/app.js @@ -24,6 +24,8 @@ import triggerRoutes from './routes/trigger.js'; import runRoutes from './routes/runs.js'; import authRoutes from './routes/auth.js'; import adminRoutes from './routes/admin.js'; +import manageRoutes from './routes/manage.js'; +import uiRoutes from './ui/routes.js'; import staticRoutes from '../lib/static.js'; export async function createServices(cfg, options = {}) { @@ -84,12 +86,14 @@ export async function buildServer(services, options = {}) { await fastify.register(workerRoutes, { ...services, prefix: '/api/workers' }); await fastify.register(authRoutes, { ...services, prefix: '/api/auth' }); await fastify.register(adminRoutes, { ...services, prefix: '/api/admin' }); + await fastify.register(manageRoutes, { ...services, prefix: '/api' }); await fastify.register(runRoutes, { ...services, prefix: '/api' }); - // Served here as well as by the read-api, so a single process install - // has a working interface without deploying both. - if (options.dashboard !== false) { + // Signing in, managing projects and registering workers all need the + // write surface, so the interface lives with it. + if (options.ui !== false) { await fastify.register(staticRoutes, {}); + await fastify.register(uiRoutes, { ...services }); } return fastify; diff --git a/src/conductor/index.js b/src/conductor/index.js @@ -1,8 +1,8 @@ -// src/conductor/index.js - conductor entry point +// src/conductor/index.js - entry point // -// The write side: accepts triggers, schedules runs, and serves the worker -// API. Run the read-api separately when the public read surface should not -// sit on the same port as the write surface. +// One service: it accepts triggers, schedules runs, serves the worker API, +// and renders the interface. Anonymous visitors see whatever the projects +// have declared public, and nothing else. import { loadConfig } from '../lib/config.js'; import { createServices, buildServer, startReaper } from './app.js'; diff --git a/src/conductor/routes/admin.js b/src/conductor/routes/admin.js @@ -1,179 +1,17 @@ -// src/conductor/routes/admin.js - administration +// src/conductor/routes/admin.js - installation wide administration // -// Every route here requires the admin role. Secrets are write only: a -// trigger secret or a variable can be set and replaced, but never read -// back, and a worker token is returned exactly once when it is created. +// What is left here is genuinely administrator only: user accounts. Project +// and worker management moved to routes/manage.js, where any signed in user +// can act on what they own. -import crypto from 'node:crypto'; import { requireAdmin } from '../../lib/auth/index.js'; -import { SOURCE_MODES } from '../../lib/projects.js'; import { ROLES } from '../../lib/users.js'; -import { PipelineError } from '../../lib/pipeline/index.js'; export default async function adminRoutes(fastify, services) { - const { cfg, db, auth, users, projects, workerTokens, variables, scheduler } = services; + const { db, auth, users } = services; fastify.addHook('preHandler', requireAdmin(auth)); - const triggerUrl = (id) => `${cfg.server.public_url.replace(/\/+$/, '')}/api/trigger/${id}`; - - // --- projects --- - - fastify.get('/projects', async (req, reply) => { - const rows = await projects.list(); - return reply.send({ - projects: rows.map((p) => ({ - id: p.id, - name: p.name, - repo_url: p.repo_url, - default_branch: p.default_branch, - config_path: p.config_path, - source_mode: p.source_mode, - enabled: p.enabled === 1, - // Whether a secret is set, never the secret itself. - has_trigger_secret: Boolean(p.trigger_secret), - run_count: p.run_counter, - created_at: p.created_at, - })), - }); - }); - - fastify.post('/projects', async (req, reply) => { - const body = req.body ?? {}; - if (typeof body.repo_url !== 'string' || body.repo_url.length === 0) { - return reply.code(400).send({ error: 'repo_url is required' }); - } - if (body.source_mode && !SOURCE_MODES.includes(body.source_mode)) { - return reply.code(400).send({ error: `source_mode must be one of ${SOURCE_MODES.join(', ')}` }); - } - - // Generated when not supplied, because an unauthenticated trigger - // endpoint is rarely what anyone actually wants. - const secret = typeof body.trigger_secret === 'string' && body.trigger_secret.length > 0 - ? body.trigger_secret - : crypto.randomBytes(24).toString('hex'); - - try { - const project = await projects.create({ ...body, trigger_secret: secret }); - return reply.code(201).send({ - project: { id: project.id, name: project.name, repo_url: project.repo_url }, - trigger_url: triggerUrl(project.id), - // Shown once. - trigger_secret: secret, - }); - } catch (e) { - return reply.code(400).send({ error: e.message }); - } - }); - - fastify.patch('/projects/:id', async (req, reply) => { - const project = await projects.get(req.params.id); - if (!project) return reply.code(404).send({ error: 'unknown project' }); - - const body = req.body ?? {}; - if (typeof body.enabled === 'boolean') await projects.setEnabled(project.id, body.enabled); - return reply.send({ project: { ...(await projects.get(project.id)), trigger_secret: undefined } }); - }); - - fastify.post('/projects/:id/trigger-secret', async (req, reply) => { - const project = await projects.get(req.params.id); - if (!project) return reply.code(404).send({ error: 'unknown project' }); - - const secret = typeof req.body?.secret === 'string' && req.body.secret.length > 0 - ? req.body.secret - : crypto.randomBytes(24).toString('hex'); - - await projects.setTriggerSecret(project.id, secret); - return reply.send({ trigger_url: triggerUrl(project.id), trigger_secret: secret }); - }); - - fastify.delete('/projects/:id', async (req, reply) => { - const project = await projects.get(req.params.id); - if (!project) return reply.code(404).send({ error: 'unknown project' }); - // Runs, jobs and artifacts cascade with the project. - await projects.remove(project.id); - return reply.send({ deleted: project.id }); - }); - - // --- project variables --- - - fastify.get('/projects/:id/variables', async (req, reply) => { - if (!(await projects.get(req.params.id))) return reply.code(404).send({ error: 'unknown project' }); - return reply.send({ variables: await variables.list(req.params.id) }); - }); - - fastify.put('/projects/:id/variables/:name', async (req, reply) => { - if (!(await projects.get(req.params.id))) return reply.code(404).send({ error: 'unknown project' }); - if (!cfg.secrets.encryption_key) { - req.log.warn('storing a project variable without secrets.encryption_key; it is kept in the clear'); - } - - const value = req.body?.value; - if (typeof value !== 'string') return reply.code(400).send({ error: 'value must be a string' }); - - try { - const result = await variables.set(req.params.id, req.params.name, value, { - masked: req.body?.masked !== false, - }); - return reply.send({ variable: result }); - } catch (e) { - return reply.code(400).send({ error: e.message }); - } - }); - - fastify.delete('/projects/:id/variables/:name', async (req, reply) => { - const removed = await variables.remove(req.params.id, req.params.name); - if (!removed) return reply.code(404).send({ error: 'unknown variable' }); - return reply.send({ deleted: req.params.name }); - }); - - // --- worker tokens --- - - fastify.get('/worker-tokens', async (req, reply) => { - const rows = await workerTokens.list(); - return reply.send({ - worker_tokens: rows.map((t) => ({ - id: t.id, - name: t.name, - enabled: t.enabled === 1, - created_at: t.created_at, - last_seen_at: t.last_seen_at, - last_ip: t.last_ip, - })), - }); - }); - - fastify.post('/worker-tokens', async (req, reply) => { - const name = req.body?.name; - if (typeof name !== 'string' || name.length === 0) { - return reply.code(400).send({ error: 'name is required' }); - } - const created = await workerTokens.create(name); - return reply.code(201).send({ - worker_token: { id: created.id, name: created.name }, - // Only time the plaintext exists outside the worker. - token: created.token, - note: 'store this now; it cannot be shown again', - }); - }); - - fastify.patch('/worker-tokens/:id', async (req, reply) => { - if (typeof req.body?.enabled !== 'boolean') { - return reply.code(400).send({ error: 'enabled must be a boolean' }); - } - const ok = await workerTokens.setEnabled(req.params.id, req.body.enabled); - if (!ok) return reply.code(404).send({ error: 'unknown worker token' }); - return reply.send({ id: req.params.id, enabled: req.body.enabled }); - }); - - fastify.delete('/worker-tokens/:id', async (req, reply) => { - const ok = await workerTokens.remove(req.params.id); - if (!ok) return reply.code(404).send({ error: 'unknown worker token' }); - return reply.send({ deleted: req.params.id }); - }); - - // --- users --- - fastify.get('/users', async (req, reply) => { const rows = await users.list(); return reply.send({ @@ -185,8 +23,7 @@ export default async function adminRoutes(fastify, services) { fastify.post('/users', async (req, reply) => { try { - const user = await users.create(req.body ?? {}); - return reply.code(201).send({ user }); + return reply.code(201).send({ user: await users.create(req.body ?? {}) }); } catch (e) { return reply.code(400).send({ error: e.message }); } @@ -199,6 +36,7 @@ export default async function adminRoutes(fastify, services) { try { if (typeof body.password === 'string') await users.setPassword(user.id, body.password); + if (typeof body.role === 'string') { if (!ROLES.includes(body.role)) throw new Error(`role must be one of ${ROLES.join(', ')}`); // Refuse to remove the last administrator, which would lock @@ -208,6 +46,7 @@ export default async function adminRoutes(fastify, services) { } await users.setRole(user.id, body.role); } + if (typeof body.disabled === 'boolean') { if (body.disabled && user.role === 'admin' && await lastAdmin(user.id)) { throw new Error('this is the only administrator; promote another account first'); @@ -221,49 +60,46 @@ export default async function adminRoutes(fastify, services) { return reply.send({ user: await users.get(user.id) }); }); + // Reports what removing an account would destroy, so a caller can warn + // before doing it. + fastify.get('/users/:id/impact', async (req, reply) => { + const user = await users.get(req.params.id); + if (!user) return reply.code(404).send({ error: 'unknown user' }); + return reply.send({ user, ...(await impactOf(user.id)) }); + }); + fastify.delete('/users/:id', async (req, reply) => { const user = await users.get(req.params.id); if (!user) return reply.code(404).send({ error: 'unknown user' }); if (user.role === 'admin' && await lastAdmin(user.id)) { return reply.code(400).send({ error: 'this is the only administrator; promote another account first' }); } - await users.remove(user.id); - return reply.send({ deleted: user.id }); - }); - - // --- runs --- - fastify.post('/runs/:id/cancel', async (req, reply) => { - const result = await scheduler.cancelRun(req.params.id, `cancelled by ${req.user.username}`); - if (!result.ok) return reply.code(409).send({ error: result.reason }); - return reply.send({ cancelled: req.params.id }); + // Everything they owned goes with them: projects cascade on to their + // runs, jobs and artifacts, and their worker tokens are removed. + // Leaving either behind would be worse than losing it, since an + // unowned worker token is shared capacity that accepts any project. + const impact = await impactOf(user.id); + await users.remove(user.id); + return reply.send({ deleted: user.id, ...impact }); }); - // Re-runs the same commit as a new run, rather than mutating history. - fastify.post('/runs/:id/retry', async (req, reply) => { - const run = await db.get('SELECT id, project_id, ref, base_sha, head_sha FROM runs WHERE id = {id}', - { id: req.params.id }); - if (!run) return reply.code(404).send({ error: 'unknown run' }); - - const project = await projects.get(run.project_id); - if (!project) return reply.code(404).send({ error: 'unknown project' }); - - try { - const created = await scheduler.createRun(project, { - ref: run.ref, - baseSha: run.base_sha, - headSha: run.head_sha, - trigger: 'manual', - actor: req.user.username, - }); - return reply.code(201).send({ run_id: created.runId, jobs: created.jobCount }); - } catch (e) { - if (e instanceof PipelineError) { - return reply.code(422).send({ error: 'invalid pipeline', detail: e.message, problems: e.errors }); - } - return reply.code(500).send({ error: String(e.message ?? e) }); - } - }); + async function impactOf(userId) { + const projects = await db.all('SELECT id FROM projects WHERE owner_id = {id}', { id: userId }); + const runs = await db.get( + `SELECT COUNT(*) AS c FROM runs r + JOIN projects p ON p.id = r.project_id WHERE p.owner_id = {id}`, + { id: userId } + ); + const tokens = await db.get( + 'SELECT COUNT(*) AS c FROM worker_tokens WHERE owner_id = {id}', { id: userId } + ); + return { + projects: projects.map((p) => p.id), + runs_deleted: runs.c, + worker_tokens_deleted: tokens.c, + }; + } async function lastAdmin(exceptId) { const row = await db.get( diff --git a/src/conductor/routes/manage.js b/src/conductor/routes/manage.js @@ -0,0 +1,288 @@ +// src/conductor/routes/manage.js - projects and workers, scoped by owner +// +// Any signed in user may register their own projects and their own workers. +// They see and act on what they own; administrators see and act on +// everything. A project with no owner belongs to the installation and is +// administrable only by an administrator. +// +// Secrets are write only throughout: a trigger secret, a worker token and a +// variable can each be set, but are returned exactly once, at the moment +// they are created. + +import crypto from 'node:crypto'; +import { requireUser } from '../../lib/auth/index.js'; +import { SOURCE_MODES, VISIBILITIES, canManageProject } from '../../lib/projects.js'; +import { canManageWorker } from '../../lib/workers.js'; +import { PipelineError } from '../../lib/pipeline/index.js'; + +export default async function manageRoutes(fastify, services) { + const { cfg, auth, projects, workerTokens, variables } = services; + + fastify.addHook('preHandler', requireUser(auth)); + + const triggerUrl = (id) => `${cfg.server.public_url.replace(/\/+$/, '')}/api/trigger/${id}`; + + const asPublic = (p) => ({ + id: p.id, + name: p.name, + repo_url: p.repo_url, + default_branch: p.default_branch, + config_path: p.config_path, + source_mode: p.source_mode, + visibility: p.visibility, + enabled: p.enabled === 1, + owner_id: p.owner_id, + has_trigger_secret: Boolean(p.trigger_secret), + run_count: p.run_counter, + created_at: p.created_at, + }); + + // Loads a project and confirms the caller may act on it. Replies and + // returns null when they may not. + async function manageable(req, reply) { + const project = await projects.get(req.params.id); + if (!project) { + reply.code(404).send({ error: 'unknown project' }); + return null; + } + if (!canManageProject(req.user, project)) { + reply.code(403).send({ error: 'not yours to manage' }); + return null; + } + return project; + } + + // --- projects --- + + fastify.get('/projects', async (req, reply) => { + const rows = req.user.role === 'admin' + ? await projects.list() + : await projects.listOwnedBy(req.user.id); + return reply.send({ projects: rows.map(asPublic) }); + }); + + fastify.post('/projects', async (req, reply) => { + const body = req.body ?? {}; + if (typeof body.repo_url !== 'string' || body.repo_url.length === 0) { + return reply.code(400).send({ error: 'repo_url is required' }); + } + if (body.source_mode && !SOURCE_MODES.includes(body.source_mode)) { + return reply.code(400).send({ error: `source_mode must be one of ${SOURCE_MODES.join(', ')}` }); + } + if (body.visibility && !VISIBILITIES.includes(body.visibility)) { + return reply.code(400).send({ error: `visibility must be one of ${VISIBILITIES.join(', ')}` }); + } + + // Generated when not supplied, because an unauthenticated trigger + // endpoint is rarely what anyone actually wants. + const secret = typeof body.trigger_secret === 'string' && body.trigger_secret.length > 0 + ? body.trigger_secret + : crypto.randomBytes(24).toString('hex'); + + // Only an administrator may hand a project to someone else, or create + // one that belongs to the installation rather than a person. + let owner = req.user.id; + if (req.user.role === 'admin' && Object.hasOwn(body, 'owner_id')) owner = body.owner_id ?? null; + + try { + const project = await projects.create({ ...body, owner_id: owner, trigger_secret: secret }); + return reply.code(201).send({ + project: asPublic(project), + trigger_url: triggerUrl(project.id), + trigger_secret: secret, + }); + } catch (e) { + return reply.code(400).send({ error: e.message }); + } + }); + + fastify.get('/projects/:id', async (req, reply) => { + const project = await manageable(req, reply); + if (!project) return reply; + return reply.send({ project: asPublic(project), trigger_url: triggerUrl(project.id) }); + }); + + fastify.patch('/projects/:id', async (req, reply) => { + const project = await manageable(req, reply); + if (!project) return reply; + const body = req.body ?? {}; + + try { + if (typeof body.enabled === 'boolean') await projects.setEnabled(project.id, body.enabled); + if (typeof body.visibility === 'string') await projects.setVisibility(project.id, body.visibility); + if (Object.hasOwn(body, 'owner_id')) { + if (req.user.role !== 'admin') throw new Error('only an administrator may change the owner'); + await projects.setOwner(project.id, body.owner_id ?? null); + } + } catch (e) { + return reply.code(400).send({ error: e.message }); + } + + return reply.send({ project: asPublic(await projects.get(project.id)) }); + }); + + fastify.post('/projects/:id/trigger-secret', async (req, reply) => { + const project = await manageable(req, reply); + if (!project) return reply; + + const secret = typeof req.body?.secret === 'string' && req.body.secret.length > 0 + ? req.body.secret + : crypto.randomBytes(24).toString('hex'); + + await projects.setTriggerSecret(project.id, secret); + return reply.send({ trigger_url: triggerUrl(project.id), trigger_secret: secret }); + }); + + fastify.delete('/projects/:id', async (req, reply) => { + const project = await manageable(req, reply); + if (!project) return reply; + // Runs, jobs and artifacts cascade with the project. + await projects.remove(project.id); + return reply.send({ deleted: project.id }); + }); + + // --- project variables --- + + fastify.get('/projects/:id/variables', async (req, reply) => { + const project = await manageable(req, reply); + if (!project) return reply; + return reply.send({ variables: await variables.list(project.id) }); + }); + + fastify.put('/projects/:id/variables/:name', async (req, reply) => { + const project = await manageable(req, reply); + if (!project) return reply; + if (!cfg.secrets.encryption_key) { + req.log.warn('storing a project variable without secrets.encryption_key; it is kept in the clear'); + } + + const value = req.body?.value; + if (typeof value !== 'string') return reply.code(400).send({ error: 'value must be a string' }); + + try { + const result = await variables.set(project.id, req.params.name, value, { + masked: req.body?.masked !== false, + }); + return reply.send({ variable: result }); + } catch (e) { + return reply.code(400).send({ error: e.message }); + } + }); + + fastify.delete('/projects/:id/variables/:name', async (req, reply) => { + const project = await manageable(req, reply); + if (!project) return reply; + const removed = await variables.remove(project.id, req.params.name); + if (!removed) return reply.code(404).send({ error: 'unknown variable' }); + return reply.send({ deleted: req.params.name }); + }); + + // --- worker tokens --- + + fastify.get('/worker-tokens', async (req, reply) => { + const rows = await workerTokens.listVisible(req.user); + return reply.send({ + worker_tokens: rows.map((t) => ({ + id: t.id, + name: t.name, + enabled: t.enabled === 1, + owner_id: t.owner_id, + shared: t.owner_id === null, + created_at: t.created_at, + last_seen_at: t.last_seen_at, + last_ip: t.last_ip, + })), + }); + }); + + fastify.post('/worker-tokens', async (req, reply) => { + const name = req.body?.name; + if (typeof name !== 'string' || name.length === 0) { + return reply.code(400).send({ error: 'name is required' }); + } + + // A worker belongs to whoever registered it. Only an administrator may + // create shared capacity, which runs any project. + let owner = req.user.id; + if (req.user.role === 'admin' && req.body?.shared === true) owner = null; + + const created = await workerTokens.create(name, { ownerId: owner }); + return reply.code(201).send({ + worker_token: { id: created.id, name: created.name, owner_id: owner, shared: owner === null }, + token: created.token, + note: 'store this now; it cannot be shown again', + }); + }); + + fastify.patch('/worker-tokens/:id', async (req, reply) => { + const token = await workerTokens.get(req.params.id); + if (!token) return reply.code(404).send({ error: 'unknown worker token' }); + if (!canManageWorker(req.user, token)) return reply.code(403).send({ error: 'not yours to manage' }); + if (typeof req.body?.enabled !== 'boolean') { + return reply.code(400).send({ error: 'enabled must be a boolean' }); + } + + await workerTokens.setEnabled(token.id, req.body.enabled); + return reply.send({ id: token.id, enabled: req.body.enabled }); + }); + + fastify.delete('/worker-tokens/:id', async (req, reply) => { + const token = await workerTokens.get(req.params.id); + if (!token) return reply.code(404).send({ error: 'unknown worker token' }); + if (!canManageWorker(req.user, token)) return reply.code(403).send({ error: 'not yours to manage' }); + + await workerTokens.remove(token.id); + return reply.send({ deleted: token.id }); + }); + + // --- runs --- + + // Loads a run whose project the caller may manage. + async function manageableRun(req, reply) { + const run = await services.db.get( + 'SELECT id, project_id, ref, base_sha, head_sha FROM runs WHERE id = {id}', + { id: req.params.id } + ); + if (!run) { + reply.code(404).send({ error: 'unknown run' }); + return null; + } + const project = await projects.get(run.project_id); + if (!canManageProject(req.user, project)) { + reply.code(403).send({ error: 'not yours to manage' }); + return null; + } + return { run, project }; + } + + fastify.post('/runs/:id/cancel', async (req, reply) => { + const found = await manageableRun(req, reply); + if (!found) return reply; + + const result = await services.scheduler.cancelRun(found.run.id, `cancelled by ${req.user.username}`); + if (!result.ok) return reply.code(409).send({ error: result.reason }); + return reply.send({ cancelled: found.run.id }); + }); + + // Re-runs the same commit as a new run, rather than mutating history. + fastify.post('/runs/:id/retry', async (req, reply) => { + const found = await manageableRun(req, reply); + if (!found) return reply; + + try { + const created = await services.scheduler.createRun(found.project, { + ref: found.run.ref, + baseSha: found.run.base_sha, + headSha: found.run.head_sha, + trigger: 'manual', + actor: req.user.username, + }); + return reply.code(201).send({ run_id: created.runId, jobs: created.jobCount }); + } catch (e) { + if (e instanceof PipelineError) { + return reply.code(422).send({ error: 'invalid pipeline', detail: e.message, problems: e.errors }); + } + return reply.code(500).send({ error: String(e.message ?? e) }); + } + }); +} diff --git a/src/conductor/routes/runs.js b/src/conductor/routes/runs.js @@ -1,43 +1,74 @@ // src/conductor/routes/runs.js - read access to runs, jobs and logs // -// These routes are read only and carry no secrets, so the read-api service -// will mount the same handlers in phase 6. Anything that mutates state lives -// under the admin routes instead. +// Read only, and carrying no secrets, so the same routes serve an +// anonymous visitor and an administrator. +// +// Visibility is enforced here rather than at the edge. A caller sees +// public runs, plus the runs of projects they own, plus everything if they +// are an administrator. With no auth service mounted, only public runs +// exist. import { StorageNotFound } from '../../lib/storage/index.js'; const MAX_TAIL = 1024 * 1024; -export default async function runRoutes(fastify, { db, logs, storage }) { +export default async function runRoutes(fastify, { db, logs, storage, auth = null }) { + // Anonymous when no auth service is mounted. + async function viewer(req) { + if (!auth) return null; + try { + return await auth.identify(req); + } catch { + return null; + } + } + + // A SQL fragment and its parameters restricting rows to what the caller + // may see. Applied to every route below, including the log and artifact + // endpoints, so there is no way in through a guessed identifier. + function scope(user, alias = 'r') { + if (user && user.role === 'admin') return { sql: '1 = 1', params: {} }; + if (user) { + return { + sql: `(${alias}.visibility = 'public' OR p.owner_id = {viewerId})`, + params: { viewerId: user.id }, + }; + } + return { sql: `${alias}.visibility = 'public'`, params: {} }; + } + fastify.get('/runs', async (req, reply) => { const limit = clamp(req.query.limit, 1, 200, 50); const project = typeof req.query.project === 'string' ? req.query.project : null; - - const rows = project - ? await db.all( - `SELECT id, project_id, number, ref, base_sha, head_sha, trigger_type, actor, - title, state, created_at, started_at, finished_at - FROM runs WHERE project_id = {project} - ORDER BY created_at DESC LIMIT {limit}`, - { project, limit } - ) - : await db.all( - `SELECT id, project_id, number, ref, base_sha, head_sha, trigger_type, actor, - title, state, created_at, started_at, finished_at - FROM runs ORDER BY created_at DESC LIMIT {limit}`, - { limit } - ); + const visible = scope(await viewer(req)); + + const rows = await db.all( + `SELECT r.id, r.project_id, r.number, r.ref, r.base_sha, r.head_sha, r.trigger_type, + r.actor, r.title, r.state, r.visibility, r.created_at, r.started_at, r.finished_at + FROM runs r + JOIN projects p ON p.id = r.project_id + WHERE ${visible.sql} + ${project === null ? '' : 'AND r.project_id = {project}'} + ORDER BY r.created_at DESC + LIMIT {limit}`, + { ...visible.params, ...(project === null ? {} : { project }), limit } + ); return reply.send({ runs: rows }); }); fastify.get('/runs/:id', async (req, reply) => { + const visible = scope(await viewer(req)); const run = await db.get( - `SELECT id, project_id, number, ref, base_sha, head_sha, trigger_type, actor, - title, state, error, created_at, started_at, finished_at - FROM runs WHERE id = {id}`, - { id: req.params.id } + `SELECT r.id, r.project_id, r.number, r.ref, r.base_sha, r.head_sha, r.trigger_type, + r.actor, r.title, r.state, r.visibility, r.error, + r.created_at, r.started_at, r.finished_at + FROM runs r + JOIN projects p ON p.id = r.project_id + WHERE r.id = {id} AND ${visible.sql}`, + { id: req.params.id, ...visible.params } ); + // Deliberately the same answer as a run that does not exist. if (!run) return reply.code(404).send({ error: 'unknown run' }); const jobs = await db.all( @@ -57,20 +88,28 @@ export default async function runRoutes(fastify, { db, logs, storage }) { const needs = new Map(jobs.map((j) => [j.id, []])); for (const d of deps) needs.get(d.job_id)?.push(d.depends_on_id); - return reply.send({ - run, - jobs: jobs.map((j) => ({ ...j, needs: needs.get(j.id) ?? [] })), - }); + return reply.send({ run, jobs: jobs.map((j) => ({ ...j, needs: needs.get(j.id) ?? [] })) }); }); - fastify.get('/jobs/:id', async (req, reply) => { - const job = await db.get( - `SELECT id, run_id, name, base_name, arch, image, requires, spec, state, allow_failure, - attempt, max_attempts, exit_code, error, log_size, worker_name, timeout, - created_at, started_at, finished_at - FROM jobs WHERE id = {id}`, - { id: req.params.id } + // Loads a job the caller is allowed to see, or null. + async function visibleJob(req, columns) { + const visible = scope(await viewer(req)); + return db.get( + `SELECT ${columns} + FROM jobs j + JOIN runs r ON r.id = j.run_id + JOIN projects p ON p.id = r.project_id + WHERE j.id = {id} AND ${visible.sql}`, + { id: req.params.id, ...visible.params } ); + } + + fastify.get('/jobs/:id', async (req, reply) => { + const job = await visibleJob(req, ` + j.id, j.run_id, j.name, j.base_name, j.arch, j.image, j.requires, j.spec, j.state, + j.allow_failure, j.attempt, j.max_attempts, j.exit_code, j.error, j.log_size, + j.worker_name, j.timeout, j.created_at, j.started_at, j.finished_at + `); if (!job) return reply.code(404).send({ error: 'unknown job' }); const artifacts = await db.all( @@ -84,10 +123,7 @@ export default async function runRoutes(fastify, { db, logs, storage }) { // Incremental log tail. While a job runs the bytes come from the local // spool; once it has finished they come from object storage. fastify.get('/jobs/:id/log', async (req, reply) => { - const job = await db.get( - 'SELECT id, run_id, state, log_key, log_size FROM jobs WHERE id = {id}', - { id: req.params.id } - ); + const job = await visibleJob(req, 'j.id, j.run_id, j.state, j.log_key, j.log_size'); if (!job) return reply.code(404).send({ error: 'unknown job' }); const offset = clamp(req.query.offset, 0, Number.MAX_SAFE_INTEGER, 0); @@ -104,8 +140,7 @@ export default async function runRoutes(fastify, { db, logs, storage }) { } try { - const end = offset + limit - 1; - const object = await storage.get(job.log_key, { range: { start: offset, end } }); + const object = await storage.get(job.log_key, { range: { start: offset, end: offset + limit - 1 } }); reply.header('content-type', 'text/plain; charset=utf-8'); reply.header('x-log-offset', String(offset)); reply.header('x-log-size', String(job.log_size)); @@ -118,9 +153,14 @@ export default async function runRoutes(fastify, { db, logs, storage }) { }); fastify.get('/artifacts/:id', async (req, reply) => { + const visible = scope(await viewer(req)); const artifact = await db.get( - 'SELECT id, job_id, run_id, path, storage_key, size, sha256 FROM artifacts WHERE id = {id}', - { id: req.params.id } + `SELECT a.id, a.job_id, a.run_id, a.path, a.storage_key, a.size, a.sha256 + FROM artifacts a + JOIN runs r ON r.id = a.run_id + JOIN projects p ON p.id = r.project_id + WHERE a.id = {id} AND ${visible.sql}`, + { id: req.params.id, ...visible.params } ); if (!artifact) return reply.code(404).send({ error: 'unknown artifact' }); @@ -142,11 +182,10 @@ export default async function runRoutes(fastify, { db, logs, storage }) { }); } -// These routes are mounted by the read-api, which is meant to be exposed -// publicly, so the job environment is never returned. It is the one field -// that carries project variables once a job is dispatched, and there is no -// use for it here that is worth the risk. Everything else in the spec -// already appears in the log or the pipeline file. +// The job environment is never returned. It is the one field that carries +// project variables once a job is dispatched, and there is no use for it +// here worth the risk. Everything else already appears in the log or the +// pipeline file. function publicJob(job) { const spec = safeJson(job.spec, {}); const { env, ...rest } = job; diff --git a/src/conductor/routes/workers.js b/src/conductor/routes/workers.js @@ -65,6 +65,8 @@ export default async function workerRoutes(fastify, { cfg, db, git, logs, storag features, tokenId: req.worker.id, workerName: name, + // A worker registered by a user only ever sees that user's projects. + ownerId: req.worker.owner_id ?? null, }); if (!job) return reply.code(204).send(); diff --git a/src/conductor/scheduler.js b/src/conductor/scheduler.js @@ -37,6 +37,7 @@ export function createScheduler({ cfg, db, git, logs, storage, projects, variabl r.head_sha, r.project_id, r.ref, r.number FROM jobs j JOIN runs r ON r.id = j.run_id + JOIN projects p ON p.id = r.project_id WHERE j.state = 'queued' AND r.state = 'running' AND NOT EXISTS ( @@ -118,6 +119,11 @@ export function createScheduler({ cfg, db, git, logs, storage, projects, variabl const runId = newRunId(); const now = Date.now(); + // The repository may declare its own visibility; otherwise the + // project's setting stands. Stored per run, because the answer can + // legitimately change from one commit to the next. + const visibility = pipeline.visibility ?? project.visibility ?? 'private'; + await db.transaction(async (tx) => { const number = await projects.nextRunNumber(tx, project.id); const empty = pipeline.jobs.length === 0; @@ -125,11 +131,12 @@ export function createScheduler({ cfg, db, git, logs, storage, projects, variabl await tx.run( `INSERT INTO runs (id, project_id, number, ref, base_sha, head_sha, trigger_type, actor, - title, state, pipeline, created_at, started_at, finished_at) + title, state, pipeline, visibility, created_at, started_at, finished_at) VALUES ({id}, {project}, {number}, {ref}, {base}, {head}, {trigger}, {actor}, - {title}, {state}, {pipeline}, {now}, {now}, {finished})`, + {title}, {state}, {pipeline}, {visibility}, {now}, {now}, {finished})`, { + visibility, id: runId, project: project.id, number, @@ -198,7 +205,12 @@ export function createScheduler({ cfg, db, git, logs, storage, projects, variabl // Hands one job to a worker. The worker advertises what it can run; // jobs asking for anything it lacks are passed over. - async claim({ arches = [], features = [], tokenId, workerName }) { + // + // ownerId scopes the worker to one user's projects. That is the whole + // basis of letting people contribute their own hardware: a worker + // someone registers is never offered anybody else's work. A worker + // with no owner is shared capacity and sees everything. + async claim({ arches = [], features = [], tokenId, workerName, ownerId = null }) { const archFilter = inClause('arch', arches); const featureSet = new Set(features); @@ -207,9 +219,10 @@ export function createScheduler({ cfg, db, git, logs, storage, projects, variabl const candidates = await db.all( `${ELIGIBLE} AND (j.arch IS NULL${archFilter.empty ? '' : ` OR j.arch IN (${archFilter.sql})`}) + ${ownerId === null ? '' : 'AND p.owner_id = {owner}'} ORDER BY r.created_at ASC, j.created_at ASC LIMIT 100`, - { ...archFilter.params } + { ...archFilter.params, ...(ownerId === null ? {} : { owner: ownerId }) } ); for (const candidate of candidates) { diff --git a/src/conductor/ui/html.js b/src/conductor/ui/html.js @@ -0,0 +1,68 @@ +// src/conductor/ui/html.js - HTML templating +// +// A tagged template that escapes every interpolated value unless it is +// explicitly marked safe. Repository names, commit subjects, job output and +// user names all reach these pages, so escaping is the default and opting +// out has to be written down. +// +// html`<td>${project.name}</td>` escaped +// html`<tbody>${rows.map(renderRow)}</tbody>` nested templates kept +// html`<div>${raw(trustedMarkup)}</div>` passed through +// +// There is no dependency here on purpose: the interface is a few pages of +// tables, which does not justify a template engine or a build step. + +const RAW = Symbol('raw'); + +export function raw(value) { + return { [RAW]: String(value) }; +} + +export function isRaw(value) { + return value !== null && typeof value === 'object' && Object.hasOwn(value, RAW); +} + +const ESCAPES = { + '&': '&amp;', + '<': '&lt;', + '>': '&gt;', + '"': '&quot;', + "'": '&#39;', +}; + +export function esc(value) { + if (value === null || value === undefined || value === false) return ''; + return String(value).replace(/[&<>"']/g, (c) => ESCAPES[c]); +} + +function render(value) { + if (value === null || value === undefined || value === false || value === true) return ''; + if (isRaw(value)) return value[RAW]; + if (Array.isArray(value)) return value.map(render).join(''); + return esc(value); +} + +export function html(strings, ...values) { + let out = strings[0]; + for (let i = 0; i < values.length; i += 1) { + out += render(values[i]) + strings[i + 1]; + } + return raw(out); +} + +// Renders a completed template to a string for sending. +export function toHtml(value) { + return render(value); +} + +// Attribute helper for optional attributes, so a page does not have to +// build strings by hand. +export function attrs(map) { + const parts = []; + for (const [key, value] of Object.entries(map)) { + if (value === null || value === undefined || value === false) continue; + if (value === true) parts.push(esc(key)); + else parts.push(`${esc(key)}="${esc(value)}"`); + } + return raw(parts.join(' ')); +} diff --git a/src/conductor/ui/layout.js b/src/conductor/ui/layout.js @@ -0,0 +1,106 @@ +// src/conductor/ui/layout.js - the page shell and shared fragments +// +// Pages are rendered whole on a normal request and as fragments when htmx +// asks for one, so everything works without javascript and gets live +// updates with it. + +import { html, raw, esc } from './html.js'; + +export function layout({ title, user, body, active = '' }) { + const nav = [ + { href: '/', label: 'runs', key: 'runs' }, + user ? { href: '/projects', label: 'projects', key: 'projects' } : null, + user ? { href: '/workers', label: 'workers', key: 'workers' } : null, + user && user.role === 'admin' ? { href: '/users', label: 'users', key: 'users' } : null, + ].filter(Boolean); + + return html`<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>${title ? `${title} - conductor` : 'conductor'}</title> +<link rel="stylesheet" href="/style.css"> +<script src="/vendor/htmx.min.js" defer></script> +</head> +<body> +<header> + <h1><a href="/">conductor</a></h1> + <nav> + ${nav.map((item) => html`<a class="${item.key === active ? 'on' : ''}" href="${item.href}">${item.label}</a>`)} + </nav> + <div class="session"> + ${user + ? html`<span class="muted">${user.username}</span> + <span class="role">${user.role}</span> + <form method="post" action="/logout" hx-post="/logout" hx-swap="none"> + <button type="submit">sign out</button> + </form>` + : html`<a class="button" href="/login">sign in</a>`} + </div> +</header> + +<main> +${body} +</main> + +<script> +// Keep a log pane pinned to the bottom unless the reader has scrolled up. +document.body.addEventListener('htmx:beforeSwap', (e) => { + const pane = e.detail.target.closest ? e.detail.target.closest('.logpane') : null; + if (pane) pane.dataset.follow = String(pane.scrollTop + pane.clientHeight >= pane.scrollHeight - 24); +}); +document.body.addEventListener('htmx:afterSwap', (e) => { + const pane = e.detail.target.closest ? e.detail.target.closest('.logpane') : null; + if (pane && pane.dataset.follow !== 'false') pane.scrollTop = pane.scrollHeight; +}); +// A redirect asked for by the server after a form post. +document.body.addEventListener('htmx:afterRequest', (e) => { + const to = e.detail.xhr && e.detail.xhr.getResponseHeader('HX-Redirect'); + if (to) window.location = to; +}); +</script> +</body> +</html>`; +} + +export function badge(state) { + return html`<span class="badge ${esc(state)}">${state}</span>`; +} + +export function notice(message, kind = 'error') { + if (!message) return raw(''); + return html`<p class="${kind === 'error' ? 'error' : 'ok'}">${message}</p>`; +} + +// A value shown once and never again, such as a token or a secret. +export function oneTimeSecret(label, value, help) { + return html`<div class="panel secret"> + <h3>${label}</h3> + <p class="muted">${help}</p> + <input type="text" readonly value="${value}" onclick="this.select()" class="wide mono"> + </div>`; +} + +export function shortSha(sha) { + return sha ? String(sha).slice(0, 8) : ''; +} + +export function ago(ms) { + if (!ms) return ''; + const seconds = Math.round((Date.now() - ms) / 1000); + if (seconds < 60) return `${seconds}s ago`; + if (seconds < 3600) return `${Math.round(seconds / 60)}m ago`; + if (seconds < 86400) return `${Math.round(seconds / 3600)}h ago`; + return new Date(ms).toISOString().slice(0, 10); +} + +export function duration(from, to) { + if (!from) return ''; + const seconds = Math.round(((to ?? Date.now()) - from) / 1000); + if (seconds < 60) return `${seconds}s`; + return `${Math.floor(seconds / 60)}m ${seconds % 60}s`; +} + +export const ACTIVE_RUN_STATES = ['pending', 'running']; +export const ACTIVE_JOB_STATES = ['queued', 'running']; diff --git a/src/conductor/ui/pages.js b/src/conductor/ui/pages.js @@ -0,0 +1,430 @@ +// src/conductor/ui/pages.js - the pages and the fragments they poll +// +// Each live region follows the same htmx pattern: the fragment carries its +// own polling attribute only while there is something left to happen. When +// the run or job finishes, the replacement fragment has no trigger, so +// polling stops by itself rather than needing to be cancelled. + +import { html, raw } from './html.js'; +import { badge, notice, oneTimeSecret, shortSha, ago, duration, ACTIVE_RUN_STATES, ACTIVE_JOB_STATES } from './layout.js'; + +const poll = (url, seconds = 3) => raw(`hx-get="${url}" hx-trigger="every ${seconds}s" hx-swap="outerHTML"`); + +// --- runs --- + +export function runsPage({ runs, user, anonymous }) { + return html` + <h2>Runs</h2> + ${anonymous ? html`<p class="muted">Showing public runs. <a href="/login">Sign in</a> to see your own.</p>` : ''} + ${runsTable(runs)} + `; +} + +export function runsTable(runs) { + const live = runs.some((r) => ACTIVE_RUN_STATES.includes(r.state)); + return html`<div id="runs" ${live ? poll('/partials/runs', 4) : ''}> + <table> + <thead><tr> + <th>run</th><th>project</th><th>ref</th><th>commit</th><th>state</th><th>started</th> + </tr></thead> + <tbody> + ${runs.length === 0 + ? html`<tr><td colspan="6" class="muted">No runs to show.</td></tr>` + : runs.map((run) => html`<tr> + <td><a href="/runs/${run.id}">#${run.number}</a></td> + <td>${run.project_id}</td> + <td class="muted">${(run.ref ?? '').replace('refs/heads/', '')}</td> + <td class="mono muted">${shortSha(run.head_sha)}</td> + <td>${badge(run.state)}</td> + <td class="muted">${ago(run.created_at)}</td> + </tr>`)} + </tbody> + </table> + </div>`; +} + +export function runPage({ run, jobs, canManage }) { + return html` + <div class="panel"> + <h2>${run.title || `Run #${run.number}`}</h2> + <div class="row"> + ${field('project', html`<a href="/runs?project=${run.project_id}">${run.project_id}</a>`)} + ${field('state', badge(run.state))} + ${field('ref', (run.ref ?? '').replace('refs/heads/', '') || '-')} + ${field('commit', html`<span class="mono">${shortSha(run.head_sha)}</span>`)} + ${field('trigger', `${run.trigger_type}${run.actor ? ` by ${run.actor}` : ''}`)} + ${field('visibility', run.visibility)} + ${field('duration', duration(run.started_at, run.finished_at))} + </div> + ${canManage ? html`<div class="actions"> + ${run.state === 'running' + ? html`<button hx-post="/runs/${run.id}/cancel" hx-target="#jobs" hx-swap="outerHTML">cancel</button>` + : ''} + <button hx-post="/runs/${run.id}/retry" hx-swap="none">run again</button> + </div>` : ''} + </div> + ${jobsTable(run, jobs)} + `; +} + +export function jobsTable(run, jobs) { + const live = ACTIVE_RUN_STATES.includes(run.state); + + // Grouped by graph depth, which is how the pipeline reads. + const byId = new Map(jobs.map((j) => [j.id, j])); + const depthOf = (job, seen = new Set()) => { + if (seen.has(job.id)) return 0; + seen.add(job.id); + const deps = (job.needs ?? []).map((id) => byId.get(id)).filter(Boolean); + return deps.length === 0 ? 0 : Math.max(...deps.map((d) => depthOf(d, seen) + 1)); + }; + + const stages = new Map(); + for (const job of jobs) { + const depth = depthOf(job); + if (!stages.has(depth)) stages.set(depth, []); + stages.get(depth).push(job); + } + + return html`<div id="jobs" ${live ? poll(`/partials/runs/${run.id}/jobs`, 3) : ''}> + ${[...stages.keys()].sort((a, b) => a - b).map((depth) => html` + <div class="stage"> + <h3>stage ${depth + 1}</h3> + <table><tbody> + ${stages.get(depth).sort((a, b) => a.name.localeCompare(b.name)).map((job) => html`<tr> + <td><a href="/jobs/${job.id}">${job.name}</a></td> + <td>${badge(job.state)}</td> + <td class="muted">${job.arch ?? ''}</td> + <td class="muted">${job.worker_name ?? ''}</td> + <td class="muted">${duration(job.started_at, job.finished_at)}</td> + <td class="muted">${job.exit_code === null ? '' : `exit ${job.exit_code}`}</td> + </tr>`)} + </tbody></table> + </div>`)} + </div>`; +} + +// --- jobs --- + +export function jobPage({ job, artifacts, log }) { + return html` + <div class="panel"> + <h2>${job.name}</h2> + <div class="row"> + ${field('state', badge(job.state))} + ${field('run', html`<a href="/runs/${job.run_id}">back to run</a>`)} + ${field('image', html`<span class="mono">${job.image}</span>`)} + ${field('arch', job.arch ?? '-')} + ${field('attempt', `${job.attempt} of ${job.max_attempts}`)} + ${field('worker', job.worker_name ?? '-')} + ${field('duration', duration(job.started_at, job.finished_at))} + </div> + ${job.error ? notice(job.error) : ''} + </div> + + <h3>log</h3> + <div class="logpane">${jobLog(job, log)}</div> + + ${artifacts.length === 0 ? '' : html` + <h3>artifacts</h3> + <table><tbody> + ${artifacts.map((a) => html`<tr> + <td><a href="/api/artifacts/${a.id}">${a.path}</a></td> + <td class="muted">${a.size} bytes</td> + <td class="mono muted">${a.sha256.slice(0, 12)}</td> + </tr>`)} + </tbody></table>`} + `; +} + +export function jobLog(job, log) { + const live = ACTIVE_JOB_STATES.includes(job.state); + return html`<pre id="log" class="log" ${live ? poll(`/partials/jobs/${job.id}/log`, 2) : ''}>${ + log || (live ? 'Waiting for output.' : 'No output.') + }</pre>`; +} + +// --- login --- + +export function loginPage({ error, localLogin, issuer }) { + if (!localLogin) { + return html`<div class="center"> + <div class="panel narrow"> + <h2>Sign in</h2> + <p>This conductor authenticates through your identity provider.</p> + <p class="muted mono">${issuer}</p> + <p class="muted">Obtain a token from the provider and send it as a bearer token.</p> + </div> + </div>`; + } + + return html`<div class="center"> + <div class="panel narrow"> + <h2>Sign in</h2> + ${notice(error)} + <form hx-post="/login" hx-target="body" hx-swap="none"> + <label>username<input name="username" autocomplete="username" required autofocus></label> + <label>password<input name="password" type="password" autocomplete="current-password" required></label> + <div class="actions"><button type="submit">sign in</button></div> + </form> + </div> + </div>`; +} + +// --- projects --- + +export function projectsPage({ projects, user }) { + return html` + <h2>Projects</h2> + + <div class="panel"> + <h3>Add a project</h3> + <form hx-post="/projects" hx-target="body" hx-swap="none"> + <div class="grid"> + <label>id<input name="id" placeholder="my-project" required></label> + <label>repository<input name="repo_url" placeholder="https://git.example.com/repo.git" required></label> + <label>pipeline file<input name="config_path" value=".conductor.yml"></label> + <label>visibility + <select name="visibility"> + <option value="private">private</option> + <option value="public">public</option> + </select> + </label> + </div> + <div class="actions"><button type="submit">create</button></div> + </form> + </div> + + ${projectsTable(projects, user)} + `; +} + +export function projectsTable(projects, user) { + return html`<table id="projects"> + <thead><tr><th>project</th><th>repository</th><th>visibility</th><th>owner</th><th>runs</th><th></th></tr></thead> + <tbody> + ${projects.length === 0 + ? html`<tr><td colspan="6" class="muted">No projects yet.</td></tr>` + : projects.map((p) => html`<tr> + <td><a href="/projects/${p.id}">${p.id}</a></td> + <td class="muted mono">${p.repo_url}</td> + <td>${badge(p.visibility === 'public' ? 'success' : 'skipped')}<span class="muted"> ${p.visibility}</span></td> + <td class="muted">${p.owner_id === null ? 'shared' : (p.owner_id === user.id ? 'you' : p.owner_id)}</td> + <td class="muted">${p.run_counter}</td> + <td><a href="/projects/${p.id}">manage</a></td> + </tr>`)} + </tbody> + </table>`; +} + +export function projectPage({ project, variables, triggerUrl, secret, user }) { + return html` + <h2>${project.id}</h2> + ${secret ? oneTimeSecret( + 'Trigger secret', + secret, + 'Set this as CONDUCTOR_SECRET in the post-receive hook. It is shown here once.' + ) : ''} + + <div class="panel"> + <div class="row"> + ${field('repository', html`<span class="mono">${project.repo_url}</span>`)} + ${field('pipeline', html`<span class="mono">${project.config_path}</span>`)} + ${field('source mode', project.source_mode)} + ${field('owner', project.owner_id === null ? 'shared' : (project.owner_id === user.id ? 'you' : project.owner_id))} + ${field('runs', project.run_counter)} + </div> + <p class="muted">Trigger endpoint</p> + <input class="wide mono" type="text" readonly value="${triggerUrl}" onclick="this.select()"> + </div> + + <div class="panel"> + <h3>Settings</h3> + <form hx-patch="/projects/${project.id}" hx-target="body" hx-swap="none"> + <div class="grid"> + <label>visibility + <select name="visibility"> + <option value="private" ${project.visibility === 'private' ? 'selected' : ''}>private</option> + <option value="public" ${project.visibility === 'public' ? 'selected' : ''}>public</option> + </select> + </label> + <label>enabled + <select name="enabled"> + <option value="true" ${project.enabled === 1 ? 'selected' : ''}>yes</option> + <option value="false" ${project.enabled === 1 ? '' : 'selected'}>no</option> + </select> + </label> + </div> + <p class="muted">A pipeline may override visibility per commit with a top level visibility key.</p> + <div class="actions"><button type="submit">save</button></div> + </form> + </div> + + <div class="panel"> + <h3>Variables</h3> + <p class="muted">Injected into every job. Masked values are redacted from logs.</p> + ${variablesTable(project, variables)} + <form hx-put="/projects/${project.id}/variables" hx-target="#variables" hx-swap="outerHTML"> + <div class="grid"> + <label>name<input name="name" placeholder="DEPLOY_TOKEN" required></label> + <label>value<input name="value" type="password" required></label> + <label>masked + <select name="masked"><option value="true">yes</option><option value="false">no</option></select> + </label> + </div> + <div class="actions"><button type="submit">set</button></div> + </form> + </div> + + <div class="panel"> + <h3>Danger</h3> + <div class="actions"> + <button hx-post="/projects/${project.id}/trigger-secret" hx-target="body" hx-swap="none"> + rotate trigger secret + </button> + <button class="danger" + hx-delete="/projects/${project.id}" + hx-confirm="Delete ${project.id} and all of its runs?" + hx-swap="none">delete project</button> + </div> + </div> + `; +} + +export function variablesTable(project, variables) { + return html`<table id="variables"><tbody> + ${variables.length === 0 + ? html`<tr><td class="muted">None.</td></tr>` + : variables.map((v) => html`<tr> + <td class="mono">${v.name}</td> + <td class="muted">${v.masked ? 'masked' : 'visible'}</td> + <td class="muted">${v.plaintext_at_rest ? 'stored in the clear' : 'encrypted'}</td> + <td><button class="link" + hx-delete="/projects/${project.id}/variables/${encodeURIComponent(v.name)}" + hx-target="#variables" hx-swap="outerHTML">remove</button></td> + </tr>`)} + </tbody></table>`; +} + +// --- workers --- + +export function workersPage({ tokens, user, created }) { + return html` + <h2>Workers</h2> + <p class="muted"> + A worker you register only ever receives jobs from your own projects. + ${user.role === 'admin' ? 'A shared worker receives jobs from any project.' : ''} + </p> + ${created ? oneTimeSecret( + `Worker token for ${created.name}`, + created.token, + 'Put this in the worker configuration. It is shown once and stored only as a hash.' + ) : ''} + + <div class="panel"> + <h3>Register a worker</h3> + <form hx-post="/workers" hx-target="body" hx-swap="none"> + <div class="grid"> + <label>name<input name="name" placeholder="my-laptop" required></label> + ${user.role === 'admin' ? html`<label>shared + <select name="shared"><option value="false">no, mine</option><option value="true">yes, any project</option></select> + </label>` : ''} + </div> + <div class="actions"><button type="submit">create token</button></div> + </form> + </div> + + ${workersTable(tokens, user)} + `; +} + +export function workersTable(tokens, user) { + return html`<table id="workers"> + <thead><tr><th>name</th><th>scope</th><th>state</th><th>last seen</th><th>address</th><th></th></tr></thead> + <tbody> + ${tokens.length === 0 + ? html`<tr><td colspan="6" class="muted">No workers registered.</td></tr>` + : tokens.map((t) => html`<tr> + <td>${t.name}</td> + <td class="muted">${t.owner_id === null ? 'shared' : (t.owner_id === user.id ? 'yours' : t.owner_id)}</td> + <td>${t.enabled === 1 ? badge('success') : badge('skipped')}</td> + <td class="muted">${t.last_seen_at ? ago(t.last_seen_at) : 'never'}</td> + <td class="muted mono">${t.last_ip ?? ''}</td> + <td class="row-actions"> + <button class="link" hx-patch="/workers/${t.id}" hx-vals='{"enabled": ${t.enabled === 1 ? 'false' : 'true'}}' + hx-target="#workers" hx-swap="outerHTML">${t.enabled === 1 ? 'disable' : 'enable'}</button> + <button class="link danger" hx-delete="/workers/${t.id}" + hx-confirm="Remove ${t.name}?" hx-target="#workers" hx-swap="outerHTML">remove</button> + </td> + </tr>`)} + </tbody> + </table>`; +} + +// --- users --- + +export function usersPage({ users, localLogin }) { + return html` + <h2>Users</h2> + ${localLogin ? '' : html`<p class="muted"> + Authentication goes through the identity provider, so these accounts are inactive. + </p>`} + + <div class="panel"> + <h3>Add a user</h3> + <form hx-post="/users" hx-target="body" hx-swap="none"> + <div class="grid"> + <label>username<input name="username" required></label> + <label>password<input name="password" type="password" required></label> + <label>role + <select name="role"><option value="viewer">viewer</option><option value="admin">admin</option></select> + </label> + </div> + <div class="actions"><button type="submit">create</button></div> + </form> + </div> + + ${usersTable(users)} + `; +} + +export function usersTable(users) { + return html`<table id="users"> + <thead><tr> + <th>username</th><th>role</th><th>state</th><th>owns</th><th>last login</th><th></th> + </tr></thead> + <tbody> + ${users.map((u) => html`<tr> + <td>${u.username}</td> + <td>${u.role}</td> + <td>${u.disabled === 1 ? badge('skipped') : badge('success')}</td> + <td class="muted">${u.project_count} project(s), ${u.worker_count} worker(s)</td> + <td class="muted">${u.last_login_at ? ago(u.last_login_at) : 'never'}</td> + <td class="row-actions"> + <button class="link" hx-patch="/users/${u.id}" hx-vals='{"disabled": ${u.disabled === 1 ? 'false' : 'true'}}' + hx-target="#users" hx-swap="outerHTML">${u.disabled === 1 ? 'enable' : 'disable'}</button> + <button class="link danger" hx-delete="/users/${u.id}" + hx-confirm="${deleteWarning(u)}" hx-target="#users" hx-swap="outerHTML">remove</button> + </td> + </tr>`)} + </tbody> + </table>`; +} + +// Removing an account takes everything it owns with it, so the +// confirmation says so plainly rather than asking a vague question. +function deleteWarning(user) { + if (user.project_count === 0 && user.worker_count === 0) { + return `Remove ${user.username}? They own nothing.`; + } + const parts = []; + if (user.project_count > 0) { + parts.push(`${user.project_count} project(s) and all of their run history`); + } + if (user.worker_count > 0) parts.push(`${user.worker_count} worker token(s)`); + return `Remove ${user.username}? This also deletes ${parts.join(' and ')}. This cannot be undone.`; +} + +function field(label, value) { + return html`<div><span class="label">${label}</span><span>${value}</span></div>`; +} diff --git a/src/conductor/ui/routes.js b/src/conductor/ui/routes.js @@ -0,0 +1,576 @@ +// src/conductor/ui/routes.js - the server rendered interface +// +// Pages are rendered here rather than in a client application, so the +// interface has the same view of who you are and what you may see as the +// rest of the conductor. There is one visibility rule, in one place. +// +// Mutations require the HX-Request header, which htmx sets on every request +// it makes and which a cross site form post cannot set. Together with the +// SameSite cookie that is the CSRF defence. + +import crypto from 'node:crypto'; +import { toHtml, html } from './html.js'; +import { layout } from './layout.js'; +import { + runsPage, runsTable, runPage, jobsTable, jobPage, jobLog, loginPage, + projectsPage, projectPage, variablesTable, workersPage, workersTable, + usersPage, usersTable, +} from './pages.js'; +import { canManageProject, canViewProject, VISIBILITIES } from '../../lib/projects.js'; +import { canManageWorker } from '../../lib/workers.js'; +import { issueToken, sessionCookie, clearedCookie } from '../../lib/auth/index.js'; +import { PipelineError } from '../../lib/pipeline/index.js'; + +const LOG_TAIL_BYTES = 256 * 1024; + +export default async function uiRoutes(fastify, services) { + const { cfg, db, auth, users, projects, workerTokens, variables, logs, scheduler } = services; + const secure = cfg.server.public_url.startsWith('https://'); + + // Forms post urlencoded bodies; htmx hx-vals posts json. + fastify.addContentTypeParser( + 'application/x-www-form-urlencoded', + { parseAs: 'string' }, + (req, body, done) => { + const out = {}; + for (const [key, value] of new URLSearchParams(body)) out[key] = value; + done(null, out); + } + ); + + function send(reply, node) { + reply.header('content-type', 'text/html; charset=utf-8'); + return reply.send(toHtml(node)); + } + + function page(reply, { title, user, body, active }) { + return send(reply, layout({ title, user, body, active })); + } + + const viewer = (req) => auth.identify(req).catch(() => null); + + // A signed in user, or a redirect to the sign in page. + async function required(req, reply) { + const user = await viewer(req); + if (user) return user; + if (req.headers['hx-request']) reply.header('hx-redirect', '/login').code(204).send(); + else reply.redirect('/login', 302); + return null; + } + + // State changing requests must come from our own interface. + function fromUi(req, reply) { + if (req.headers['hx-request']) return true; + reply.code(400).send('this endpoint is for the conductor interface'); + return false; + } + + // Tells htmx to reload, which is the simplest correct thing after a + // change that affects more of the page than the triggering element. + function refresh(reply, to = null) { + if (to) reply.header('hx-redirect', to); + else reply.header('hx-refresh', 'true'); + return reply.code(204).send(); + } + + function fail(reply, message, code = 400) { + return reply.code(code).header('content-type', 'text/html; charset=utf-8') + .send(toHtml(html`<p class="error">${message}</p>`)); + } + + // --- runs --- + + async function visibleRuns(user, projectId = null) { + const scope = user && user.role === 'admin' + ? { sql: '1 = 1', params: {} } + : user + ? { sql: "(r.visibility = 'public' OR p.owner_id = {viewerId})", params: { viewerId: user.id } } + : { sql: "r.visibility = 'public'", params: {} }; + + return db.all( + `SELECT r.id, r.project_id, r.number, r.ref, r.head_sha, r.state, r.visibility, + r.title, r.created_at, r.started_at, r.finished_at + FROM runs r JOIN projects p ON p.id = r.project_id + WHERE ${scope.sql} ${projectId ? 'AND r.project_id = {project}' : ''} + ORDER BY r.created_at DESC LIMIT 50`, + { ...scope.params, ...(projectId ? { project: projectId } : {}) } + ); + } + + fastify.get('/', async (req, reply) => { + const user = await viewer(req); + const runs = await visibleRuns(user, typeof req.query.project === 'string' ? req.query.project : null); + return page(reply, { + title: 'runs', + user, + active: 'runs', + body: runsPage({ runs, user, anonymous: !user }), + }); + }); + + fastify.get('/runs', async (req, reply) => { + const user = await viewer(req); + const runs = await visibleRuns(user, typeof req.query.project === 'string' ? req.query.project : null); + return page(reply, { title: 'runs', user, active: 'runs', body: runsPage({ runs, user, anonymous: !user }) }); + }); + + fastify.get('/partials/runs', async (req, reply) => { + const user = await viewer(req); + return send(reply, runsTable(await visibleRuns(user))); + }); + + // Loads a run the caller may see, along with whether they may act on it. + async function runFor(req, reply) { + const user = await viewer(req); + const run = await db.get('SELECT * FROM runs WHERE id = {id}', { id: req.params.id }); + if (!run) { + reply.code(404); + return { user, run: null }; + } + const project = await projects.get(run.project_id); + if (run.visibility !== 'public' && !canManageProject(user, project)) { + reply.code(404); + return { user, run: null }; + } + return { user, run, project, canManage: canManageProject(user, project) }; + } + + async function jobsOf(runId) { + const jobs = await db.all( + `SELECT id, name, arch, state, worker_name, exit_code, started_at, finished_at + FROM jobs WHERE run_id = {run} ORDER BY name`, + { run: runId } + ); + const deps = await db.all( + `SELECT d.job_id, d.depends_on_id FROM job_deps d + JOIN jobs j ON j.id = d.job_id WHERE j.run_id = {run}`, + { run: runId } + ); + const needs = new Map(jobs.map((j) => [j.id, []])); + for (const d of deps) needs.get(d.job_id)?.push(d.depends_on_id); + return jobs.map((j) => ({ ...j, needs: needs.get(j.id) ?? [] })); + } + + fastify.get('/runs/:id', async (req, reply) => { + const { user, run, canManage } = await runFor(req, reply); + if (!run) return page(reply, { title: 'not found', user, body: html`<p class="error">No such run.</p>` }); + + return page(reply, { + title: `run #${run.number}`, + user, + active: 'runs', + body: runPage({ run, jobs: await jobsOf(run.id), canManage }), + }); + }); + + fastify.get('/partials/runs/:id/jobs', async (req, reply) => { + const { run } = await runFor(req, reply); + if (!run) return reply.send(''); + return send(reply, jobsTable(run, await jobsOf(run.id))); + }); + + fastify.post('/runs/:id/cancel', async (req, reply) => { + if (!fromUi(req, reply)) return reply; + const user = await required(req, reply); + if (!user) return reply; + + const { run, canManage } = await runFor(req, reply); + if (!run || !canManage) return fail(reply, 'Not yours to manage.', 403); + + await scheduler.cancelRun(run.id, `cancelled by ${user.username}`); + const fresh = await db.get('SELECT * FROM runs WHERE id = {id}', { id: run.id }); + return send(reply, jobsTable(fresh, await jobsOf(run.id))); + }); + + fastify.post('/runs/:id/retry', async (req, reply) => { + if (!fromUi(req, reply)) return reply; + const user = await required(req, reply); + if (!user) return reply; + + const { run, project, canManage } = await runFor(req, reply); + if (!run || !canManage) return fail(reply, 'Not yours to manage.', 403); + + try { + const created = await scheduler.createRun(project, { + ref: run.ref, + baseSha: run.base_sha, + headSha: run.head_sha, + trigger: 'manual', + actor: user.username, + }); + return refresh(reply, `/runs/${created.runId}`); + } catch (e) { + return fail(reply, e instanceof PipelineError ? e.message : String(e.message ?? e), 422); + } + }); + + // --- jobs --- + + fastify.get('/jobs/:id', async (req, reply) => { + const user = await viewer(req); + const job = await db.get( + `SELECT j.*, r.visibility, r.project_id FROM jobs j + JOIN runs r ON r.id = j.run_id WHERE j.id = {id}`, + { id: req.params.id } + ); + if (!job) return page(reply, { title: 'not found', user, body: html`<p class="error">No such job.</p>` }); + + const project = await projects.get(job.project_id); + if (job.visibility !== 'public' && !canManageProject(user, project)) { + return page(reply, { title: 'not found', user, body: html`<p class="error">No such job.</p>` }); + } + + const artifacts = await db.all( + 'SELECT id, path, size, sha256 FROM artifacts WHERE job_id = {job} ORDER BY path', + { job: job.id } + ); + + return page(reply, { + title: job.name, + user, + active: 'runs', + body: jobPage({ job, artifacts, log: await tailOf(job) }), + }); + }); + + fastify.get('/partials/jobs/:id/log', async (req, reply) => { + const user = await viewer(req); + const job = await db.get( + `SELECT j.*, r.visibility, r.project_id FROM jobs j + JOIN runs r ON r.id = j.run_id WHERE j.id = {id}`, + { id: req.params.id } + ); + if (!job) return reply.send(''); + + const project = await projects.get(job.project_id); + if (job.visibility !== 'public' && !canManageProject(user, project)) return reply.send(''); + + return send(reply, jobLog(job, await tailOf(job))); + }); + + // The last window of output. Replacing the whole pane each poll keeps the + // fragment self contained, and a cap keeps a runaway log from being + // re-sent in full every two seconds. + async function tailOf(job) { + if (!job.log_key) { + const size = await logs.size(job.run_id, job.id); + const offset = Math.max(0, size - LOG_TAIL_BYTES); + const chunk = await logs.read(job.run_id, job.id, { offset, limit: LOG_TAIL_BYTES }); + return chunk.data.toString('utf8'); + } + try { + const start = Math.max(0, (job.log_size ?? 0) - LOG_TAIL_BYTES); + const object = await services.storage.get(job.log_key, { range: { start } }); + const parts = []; + for await (const part of object.stream) parts.push(part); + return Buffer.concat(parts).toString('utf8'); + } catch { + return ''; + } + } + + // --- session --- + + fastify.get('/login', async (req, reply) => { + const user = await viewer(req); + if (user) return reply.redirect('/', 302); + return page(reply, { + title: 'sign in', + user: null, + body: loginPage({ localLogin: auth.localLogin, issuer: cfg.auth.oidc.issuer }), + }); + }); + + fastify.post('/login', async (req, reply) => { + if (!fromUi(req, reply)) return reply; + if (!auth.localLogin) return fail(reply, 'This conductor authenticates through its identity provider.'); + + const { username, password } = req.body ?? {}; + const user = await users.authenticate(String(username ?? ''), String(password ?? '')); + if (!user) { + return reply.code(401).header('content-type', 'text/html; charset=utf-8') + .send(toHtml(loginPage({ error: 'Invalid username or password.', localLogin: true }))); + } + + const ttl = cfg.auth.session_ttl; + const token = issueToken(cfg.auth.session_secret, { sub: user.id, name: user.username, role: user.role }, { ttl }); + reply.header('set-cookie', sessionCookie(token, { ttl, secure })); + return refresh(reply, '/'); + }); + + fastify.post('/logout', async (req, reply) => { + reply.header('set-cookie', clearedCookie()); + return refresh(reply, '/'); + }); + + // --- projects --- + + fastify.get('/projects', async (req, reply) => { + const user = await required(req, reply); + if (!user) return reply; + const rows = user.role === 'admin' ? await projects.list() : await projects.listOwnedBy(user.id); + return page(reply, { title: 'projects', user, active: 'projects', body: projectsPage({ projects: rows, user }) }); + }); + + fastify.post('/projects', async (req, reply) => { + if (!fromUi(req, reply)) return reply; + const user = await required(req, reply); + if (!user) return reply; + + const body = req.body ?? {}; + if (!body.repo_url) return fail(reply, 'A repository URL is required.'); + if (body.visibility && !VISIBILITIES.includes(body.visibility)) return fail(reply, 'Invalid visibility.'); + + const secret = crypto.randomUUID().replace(/-/g, ''); + try { + const project = await projects.create({ + id: body.id, + name: body.name || body.id, + repo_url: body.repo_url, + config_path: body.config_path || '.conductor.yml', + visibility: body.visibility || 'private', + owner_id: user.id, + trigger_secret: secret, + }); + // The secret is shown once, on the page that follows. + return refresh(reply, `/projects/${project.id}?created=1`); + } catch (e) { + return fail(reply, e.message); + } + }); + + async function projectFor(req, reply) { + const user = await required(req, reply); + if (!user) return null; + const project = await projects.get(req.params.id); + if (!project || !canManageProject(user, project)) { + fail(reply, 'No such project.', 404); + return null; + } + return { user, project }; + } + + fastify.get('/projects/:id', async (req, reply) => { + const user = await required(req, reply); + if (!user) return reply; + const project = await projects.get(req.params.id); + if (!project || !canManageProject(user, project)) { + return page(reply, { title: 'not found', user, body: html`<p class="error">No such project.</p>` }); + } + + // Revealed only on the redirect that follows creating or rotating it, + // so an ordinary visit to the page never shows the value. + const secret = req.query.created === '1' ? projects.triggerSecret(project) : null; + + return page(reply, { + title: project.id, + user, + active: 'projects', + body: projectPage({ + project, + user, + variables: await variables.list(project.id), + triggerUrl: `${cfg.server.public_url.replace(/\/+$/, '')}/api/trigger/${project.id}`, + secret, + }), + }); + }); + + fastify.patch('/projects/:id', async (req, reply) => { + if (!fromUi(req, reply)) return reply; + const found = await projectFor(req, reply); + if (!found) return reply; + + const body = req.body ?? {}; + try { + if (body.visibility) await projects.setVisibility(found.project.id, body.visibility); + if (body.enabled !== undefined) await projects.setEnabled(found.project.id, body.enabled === 'true' || body.enabled === true); + } catch (e) { + return fail(reply, e.message); + } + return refresh(reply); + }); + + fastify.post('/projects/:id/trigger-secret', async (req, reply) => { + if (!fromUi(req, reply)) return reply; + const found = await projectFor(req, reply); + if (!found) return reply; + + await projects.setTriggerSecret(found.project.id, crypto.randomUUID().replace(/-/g, '')); + return refresh(reply, `/projects/${found.project.id}?created=1`); + }); + + fastify.delete('/projects/:id', async (req, reply) => { + if (!fromUi(req, reply)) return reply; + const found = await projectFor(req, reply); + if (!found) return reply; + + await projects.remove(found.project.id); + return refresh(reply, '/projects'); + }); + + fastify.put('/projects/:id/variables', async (req, reply) => { + if (!fromUi(req, reply)) return reply; + const found = await projectFor(req, reply); + if (!found) return reply; + + const { name, value, masked } = req.body ?? {}; + try { + await variables.set(found.project.id, String(name ?? ''), String(value ?? ''), { + masked: masked !== 'false' && masked !== false, + }); + } catch (e) { + return fail(reply, e.message); + } + return send(reply, variablesTable(found.project, await variables.list(found.project.id))); + }); + + fastify.delete('/projects/:id/variables/:name', async (req, reply) => { + if (!fromUi(req, reply)) return reply; + const found = await projectFor(req, reply); + if (!found) return reply; + + await variables.remove(found.project.id, req.params.name); + return send(reply, variablesTable(found.project, await variables.list(found.project.id))); + }); + + // --- workers --- + + fastify.get('/workers', async (req, reply) => { + const user = await required(req, reply); + if (!user) return reply; + return page(reply, { + title: 'workers', + user, + active: 'workers', + body: workersPage({ tokens: await workerTokens.listVisible(user), user }), + }); + }); + + fastify.post('/workers', async (req, reply) => { + if (!fromUi(req, reply)) return reply; + const user = await required(req, reply); + if (!user) return reply; + + const name = String(req.body?.name ?? '').trim(); + if (!name) return fail(reply, 'A name is required.'); + + // Only an administrator can create capacity that runs anyone's work. + const shared = user.role === 'admin' && req.body?.shared === 'true'; + const created = await workerTokens.create(name, { ownerId: shared ? null : user.id }); + + return page(reply, { + title: 'workers', + user, + active: 'workers', + body: workersPage({ + tokens: await workerTokens.listVisible(user), + user, + created: { name: created.name, token: created.token }, + }), + }); + }); + + fastify.patch('/workers/:id', async (req, reply) => { + if (!fromUi(req, reply)) return reply; + const user = await required(req, reply); + if (!user) return reply; + + const token = await workerTokens.get(req.params.id); + if (!token || !canManageWorker(user, token)) return fail(reply, 'No such worker.', 404); + + await workerTokens.setEnabled(token.id, req.body?.enabled === true || req.body?.enabled === 'true'); + return send(reply, workersTable(await workerTokens.listVisible(user), user)); + }); + + fastify.delete('/workers/:id', async (req, reply) => { + if (!fromUi(req, reply)) return reply; + const user = await required(req, reply); + if (!user) return reply; + + const token = await workerTokens.get(req.params.id); + if (!token || !canManageWorker(user, token)) return fail(reply, 'No such worker.', 404); + + await workerTokens.remove(token.id); + return send(reply, workersTable(await workerTokens.listVisible(user), user)); + }); + + // --- users --- + + async function adminOnly(req, reply) { + const user = await required(req, reply); + if (!user) return null; + if (user.role !== 'admin') { + fail(reply, 'Administrator role required.', 403); + return null; + } + return user; + } + + fastify.get('/users', async (req, reply) => { + const user = await required(req, reply); + if (!user) return reply; + if (user.role !== 'admin') { + return page(reply, { title: 'users', user, body: html`<p class="error">Administrator role required.</p>` }); + } + return page(reply, { + title: 'users', + user, + active: 'users', + body: usersPage({ users: await users.listWithHoldings(), localLogin: auth.localLogin }), + }); + }); + + fastify.post('/users', async (req, reply) => { + if (!fromUi(req, reply)) return reply; + if (!(await adminOnly(req, reply))) return reply; + + try { + await users.create({ + username: String(req.body?.username ?? ''), + password: String(req.body?.password ?? ''), + role: req.body?.role === 'admin' ? 'admin' : 'viewer', + }); + } catch (e) { + return fail(reply, e.message); + } + return refresh(reply); + }); + + fastify.patch('/users/:id', async (req, reply) => { + if (!fromUi(req, reply)) return reply; + if (!(await adminOnly(req, reply))) return reply; + + const target = await users.get(req.params.id); + if (!target) return fail(reply, 'No such user.', 404); + + const disabled = req.body?.disabled === true || req.body?.disabled === 'true'; + if (disabled && target.role === 'admin' && await lastAdmin(target.id)) { + return fail(reply, 'This is the only administrator.'); + } + await users.setDisabled(target.id, disabled); + return send(reply, usersTable(await users.listWithHoldings())); + }); + + fastify.delete('/users/:id', async (req, reply) => { + if (!fromUi(req, reply)) return reply; + if (!(await adminOnly(req, reply))) return reply; + + const target = await users.get(req.params.id); + if (!target) return fail(reply, 'No such user.', 404); + if (target.role === 'admin' && await lastAdmin(target.id)) { + return fail(reply, 'This is the only administrator.'); + } + + await users.remove(target.id); + return send(reply, usersTable(await users.listWithHoldings())); + }); + + async function lastAdmin(exceptId) { + const row = await db.get( + "SELECT COUNT(*) AS c FROM users WHERE role = 'admin' AND disabled = 0 AND id <> {id}", + { id: exceptId } + ); + return row.c === 0; + } +} diff --git a/src/lib/config.js b/src/lib/config.js @@ -24,10 +24,6 @@ const DEFAULTS = { // callback URLs, so it must be correct behind a reverse proxy. public_url: 'http://127.0.0.1:8080', }, - read_api: { - host: '0.0.0.0', - port: 8081, - }, database: { // Unset means sqlite at database.path. Otherwise the scheme selects the // dialect: mysql:// or postgres:// (postgresql:// is accepted too). @@ -99,8 +95,6 @@ const ENV_MAP = [ ['CONDUCTOR_HOST', 'server.host', String], ['CONDUCTOR_PORT', 'server.port', toInt], ['CONDUCTOR_PUBLIC_URL', 'server.public_url', String], - ['CONDUCTOR_READ_API_HOST', 'read_api.host', String], - ['CONDUCTOR_READ_API_PORT', 'read_api.port', toInt], ['CONDUCTOR_DATABASE_URL', 'database.url', String], ['CONDUCTOR_DATABASE_PATH', 'database.path', String], ['CONDUCTOR_STORAGE_PATH', 'storage.path', String], @@ -191,11 +185,9 @@ function validate(cfg) { // Port 0 asks the operating system for a free port, which is useful in // tests and for ephemeral instances. - for (const key of ['server.port', 'read_api.port']) { - const port = getPath(cfg, key); - if (!Number.isInteger(port) || port < 0 || port > 65535) { - errors.push(`${key} must be between 0 and 65535, got ${port}`); - } + const port = cfg.server.port; + if (!Number.isInteger(port) || port < 0 || port > 65535) { + errors.push(`server.port must be between 0 and 65535, got ${port}`); } if (cfg.database.url && dialectFromUrl(cfg.database.url) === null) { errors.push( diff --git a/src/lib/db/sqlite.js b/src/lib/db/sqlite.js @@ -12,7 +12,7 @@ import { compileCached, bindParams } from './query.js'; export function openSqlite(cfg) { const db = new DatabaseSync(cfg.database.path); - // WAL lets the read-api read while the conductor writes. + // WAL keeps readers from blocking the writer. db.exec('PRAGMA journal_mode = WAL'); db.exec('PRAGMA foreign_keys = ON'); db.exec('PRAGMA busy_timeout = 5000'); diff --git a/src/lib/pipeline/index.js b/src/lib/pipeline/index.js @@ -14,7 +14,7 @@ export { parsePipeline } from './parse.js'; export { expandPipeline, interpolate } from './expand.js'; export { topologicalOrder, depths, transitiveDependents, runnable, CycleError } from './dag.js'; export { PipelineError } from './schema.js'; -export { SUPPORTED_VERSION } from './parse.js'; +export { SUPPORTED_VERSION, VISIBILITIES } from './parse.js'; export function compilePipeline(text, options = {}) { const pipeline = parsePipeline(text, options); @@ -32,5 +32,10 @@ export function compilePipeline(text, options = {}) { const depth = depths(jobs); for (const job of jobs) job.depth = depth.get(job.name); - return { version: pipeline.version, source: pipeline.source, jobs }; + return { + version: pipeline.version, + source: pipeline.source, + visibility: pipeline.visibility, + jobs, + }; } diff --git a/src/lib/pipeline/parse.js b/src/lib/pipeline/parse.js @@ -22,7 +22,11 @@ import { NAME_PATTERN, } from './schema.js'; -const TOP_KEYS = ['version', 'defaults', 'jobs']; +const TOP_KEYS = ['version', 'visibility', 'defaults', 'jobs']; + +// A repository decides whether its own build results are readable without +// signing in. Unset means the project's setting stands. +export const VISIBILITIES = ['public', 'private']; const JOB_KEYS = [ 'image', 'script', 'needs', 'arch', 'matrix', 'requires', 'services', @@ -68,6 +72,16 @@ export function parsePipeline(text, options = {}) { problems.add('version', `unsupported version ${JSON.stringify(doc.version)}, expected ${SUPPORTED_VERSION}`); } + let visibility = null; + if (doc.visibility !== undefined) { + const value = asString(problems, 'visibility', doc.visibility); + if (value !== undefined && !VISIBILITIES.includes(value)) { + problems.add('visibility', `expected one of ${VISIBILITIES.join(', ')}, got ${JSON.stringify(value)}`); + } else if (value !== undefined) { + visibility = value; + } + } + const defaults = parseDefaults(problems, doc.defaults); if (doc.jobs === undefined) { @@ -95,7 +109,7 @@ export function parsePipeline(text, options = {}) { } problems.throwIfAny(source); - return { version: SUPPORTED_VERSION, defaults, jobs, source }; + return { version: SUPPORTED_VERSION, visibility, defaults, jobs, source }; } function parseDefaults(problems, raw) { diff --git a/src/lib/projects.js b/src/lib/projects.js @@ -1,17 +1,26 @@ // src/lib/projects.js - project records // // A project pairs a repository with the settings needed to turn a push into -// a run. The trigger secret is the only sensitive field, and it has to be +// a run, and with an owner. +// +// Ownership is what makes it safe for people to bring their own hardware. A +// user registers their own projects and their own workers; their workers are +// only ever offered their own projects. A project with no owner belongs to +// the installation and is administered centrally. +// +// The trigger secret is the only sensitive field, and it has to be // recoverable rather than hashed because HMAC verification needs the // original value, so it is sealed with the secret box. import { newProjectId, slugify } from './ids.js'; export const SOURCE_MODES = ['archive', 'clone']; +export const VISIBILITIES = ['public', 'private']; const COLUMNS = ` id, name, repo_url, default_branch, config_path, source_mode, - trigger_secret, enabled, run_counter, created_at, updated_at + trigger_secret, enabled, run_counter, owner_id, visibility, + created_at, updated_at `; export function createProjects({ db, secrets }) { @@ -28,6 +37,25 @@ export function createProjects({ db, secrets }) { return db.all(`SELECT ${COLUMNS} FROM projects ORDER BY name`); }, + // What a given user is allowed to see. Administrators see everything; + // everyone else sees what they own plus anything public. + async listVisible(user) { + if (user && user.role === 'admin') return this.list(); + if (user) { + return db.all( + `SELECT ${COLUMNS} FROM projects + WHERE owner_id = {owner} OR visibility = 'public' + ORDER BY name`, + { owner: user.id } + ); + } + return db.all(`SELECT ${COLUMNS} FROM projects WHERE visibility = 'public' ORDER BY name`); + }, + + async listOwnedBy(ownerId) { + return db.all(`SELECT ${COLUMNS} FROM projects WHERE owner_id = {owner} ORDER BY name`, { owner: ownerId }); + }, + async create(input) { const id = input.id ? slugify(input.id) : (slugify(input.name) || newProjectId()); const now = Date.now(); @@ -35,18 +63,22 @@ export function createProjects({ db, secrets }) { if (input.source_mode && !SOURCE_MODES.includes(input.source_mode)) { throw new Error(`source_mode must be one of ${SOURCE_MODES.join(', ')}`); } + if (input.visibility && !VISIBILITIES.includes(input.visibility)) { + throw new Error(`visibility must be one of ${VISIBILITIES.join(', ')}`); + } if (!input.repo_url) throw new Error('repo_url is required'); - const existing = await this.get(id); - if (existing) throw new Error(`project ${id} already exists`); + if (await this.get(id)) throw new Error(`project ${id} already exists`); await db.run( `INSERT INTO projects (id, name, repo_url, default_branch, config_path, source_mode, - trigger_secret, enabled, run_counter, created_at, updated_at) + trigger_secret, enabled, run_counter, owner_id, visibility, + created_at, updated_at) VALUES ({id}, {name}, {repo_url}, {branch}, {config_path}, {source_mode}, - {secret}, {enabled}, 0, {now}, {now})`, + {secret}, {enabled}, 0, {owner}, {visibility}, + {now}, {now})`, { id, name: input.name || id, @@ -56,6 +88,8 @@ export function createProjects({ db, secrets }) { source_mode: input.source_mode || 'archive', secret: input.trigger_secret ? secrets.seal(input.trigger_secret, aad(id)) : null, enabled: input.enabled === undefined ? 1 : (input.enabled ? 1 : 0), + owner: input.owner_id ?? null, + visibility: input.visibility || 'private', now, } ); @@ -63,8 +97,6 @@ export function createProjects({ db, secrets }) { return this.get(id); }, - // Returns the plaintext trigger secret, or null when the project does - // not require signed triggers. triggerSecret(project) { if (!project.trigger_secret) return null; return secrets.open(project.trigger_secret, aad(project.id)); @@ -84,6 +116,23 @@ export function createProjects({ db, secrets }) { ); }, + async setVisibility(id, visibility) { + if (!VISIBILITIES.includes(visibility)) { + throw new Error(`visibility must be one of ${VISIBILITIES.join(', ')}`); + } + await db.run( + 'UPDATE projects SET visibility = {visibility}, updated_at = {now} WHERE id = {id}', + { id, visibility, now: Date.now() } + ); + }, + + async setOwner(id, ownerId) { + await db.run( + 'UPDATE projects SET owner_id = {owner}, updated_at = {now} WHERE id = {id}', + { id, owner: ownerId ?? null, now: Date.now() } + ); + }, + async remove(id) { await db.run('DELETE FROM projects WHERE id = {id}', { id }); }, @@ -98,3 +147,17 @@ export function createProjects({ db, secrets }) { }, }; } + +// An unowned project is administered centrally; an owned one is the +// owner's, and administrators may act on either. +export function canManageProject(user, project) { + if (!user || !project) return false; + if (user.role === 'admin') return true; + return project.owner_id !== null && project.owner_id === user.id; +} + +export function canViewProject(user, project) { + if (!project) return false; + if (project.visibility === 'public') return true; + return canManageProject(user, project); +} diff --git a/src/lib/static.js b/src/lib/static.js @@ -1,9 +1,10 @@ -// src/lib/static.js - serving the dashboard +// src/lib/static.js - the interface's static assets // -// The dashboard is three files, so an allowlist is used rather than a -// general static file server. Nothing can be requested that is not named -// here, which makes path traversal impossible by construction instead of by -// careful escaping. +// Pages are rendered by the server, so the only static files are the +// stylesheet and the vendored htmx build. An allowlist is used rather than +// a general static file server: nothing can be requested that is not named +// here, which makes path traversal impossible by construction rather than +// by careful escaping. import fs from 'node:fs/promises'; import path from 'node:path'; @@ -11,16 +12,15 @@ import crypto from 'node:crypto'; import { fileURLToPath } from 'node:url'; const HERE = path.dirname(fileURLToPath(import.meta.url)); -export const DASHBOARD_DIR = path.resolve(HERE, '../../dashboard'); +export const ASSET_DIR = path.resolve(HERE, '../../assets'); const FILES = { - '/': { file: 'index.html', type: 'text/html; charset=utf-8' }, - '/index.html': { file: 'index.html', type: 'text/html; charset=utf-8' }, - '/app.js': { file: 'app.js', type: 'text/javascript; charset=utf-8' }, '/style.css': { file: 'style.css', type: 'text/css; charset=utf-8' }, + '/vendor/htmx.min.js': { file: 'vendor/htmx.min.js', type: 'text/javascript; charset=utf-8' }, + '/vendor/htmx-LICENSE.txt': { file: 'vendor/htmx-LICENSE.txt', type: 'text/plain; charset=utf-8' }, }; -export default async function staticRoutes(fastify, { dir = DASHBOARD_DIR } = {}) { +export default async function staticRoutes(fastify, { dir = ASSET_DIR } = {}) { // Read once at startup. These are small and never change at runtime. const cache = new Map(); @@ -29,7 +29,7 @@ export default async function staticRoutes(fastify, { dir = DASHBOARD_DIR } = {} try { body = await fs.readFile(path.join(dir, entry.file)); } catch { - // A deployment that does not ship the dashboard simply has no UI. + // A deployment that does not ship the assets simply has no styling. continue; } const etag = `"${crypto.createHash('sha256').update(body).digest('hex').slice(0, 32)}"`; diff --git a/src/lib/storage/index.js b/src/lib/storage/index.js @@ -21,7 +21,7 @@ export function createStorage(cfg) { return cfg.storage.driver === 's3' ? createS3Storage(cfg) : createLocalStorage(cfg); } -// Key layout, kept in one place so the read-api and the conductor agree. +// Key layout, kept in one place so every caller agrees. export const keys = { artifact: (runId, jobId, relPath) => `artifacts/${runId}/${jobId}/${relPath}`, log: (runId, jobId) => `logs/${runId}/${jobId}.log`, diff --git a/src/lib/storage/s3.js b/src/lib/storage/s3.js @@ -148,7 +148,7 @@ export function createS3Storage(cfg) { await res.arrayBuffer(); }, - // Lets the read-api redirect a client straight at the object store + // Lets a download redirect straight at the object store // instead of proxying the bytes. async presign(key, opts = {}) { return presignUrl({ diff --git a/src/lib/users.js b/src/lib/users.js @@ -27,6 +27,16 @@ export function createUsers({ db, logger = console }) { return db.all(`SELECT ${PUBLIC_COLUMNS} FROM users ORDER BY username`); }, + // With what each account owns, since removing one takes those with it. + async listWithHoldings() { + return db.all( + `SELECT u.id, u.username, u.role, u.disabled, u.created_at, u.last_login_at, + (SELECT COUNT(*) FROM projects p WHERE p.owner_id = u.id) AS project_count, + (SELECT COUNT(*) FROM worker_tokens w WHERE w.owner_id = u.id) AS worker_count + FROM users u ORDER BY u.username` + ); + }, + async count() { return (await db.get('SELECT COUNT(*) AS c FROM users')).c; }, diff --git a/src/lib/workers.js b/src/lib/workers.js @@ -3,22 +3,34 @@ // A worker authenticates with a bearer token. Only the SHA-256 of the token // is stored, so the database never holds a usable credential; the plaintext // is shown once at creation and cannot be recovered afterwards. +// +// A token may belong to a user, in which case it is only ever offered jobs +// from that user's projects. A token with no owner is shared and can run +// anything, which is how an administrator provides general capacity. That +// distinction is enforced in the scheduler, not here. import { newToken, hashToken, newWorkerTokenId } from './ids.js'; export function createWorkerTokens({ db }) { return { - async create(name) { + async create(name, { ownerId = null } = {}) { if (!name || typeof name !== 'string') throw new Error('worker token needs a name'); const token = newToken(); const id = newWorkerTokenId(); await db.run( - `INSERT INTO worker_tokens (id, name, token_hash, enabled, created_at) - VALUES ({id}, {name}, {hash}, 1, {now})`, - { id, name, hash: hashToken(token), now: Date.now() } + `INSERT INTO worker_tokens (id, name, token_hash, enabled, owner_id, created_at) + VALUES ({id}, {name}, {hash}, 1, {owner}, {now})`, + { id, name, hash: hashToken(token), owner: ownerId, now: Date.now() } ); // The only time the plaintext exists outside the worker. - return { id, name, token }; + return { id, name, token, owner_id: ownerId }; + }, + + async get(id) { + return db.get( + 'SELECT id, name, enabled, owner_id, created_at, last_seen_at, last_ip FROM worker_tokens WHERE id = {id}', + { id } + ); }, // Returns the token record, or null. Lookup is by hash, so a timing @@ -27,7 +39,7 @@ export function createWorkerTokens({ db }) { if (typeof presented !== 'string' || presented.length === 0) return null; const hash = hashToken(presented); const row = await db.get( - 'SELECT id, name, enabled FROM worker_tokens WHERE token_hash = {hash}', + 'SELECT id, name, enabled, owner_id FROM worker_tokens WHERE token_hash = {hash}', { hash } ); if (!row || row.enabled !== 1) return null; @@ -36,16 +48,26 @@ export function createWorkerTokens({ db }) { 'UPDATE worker_tokens SET last_seen_at = {now}, last_ip = {ip} WHERE id = {id}', { id: row.id, now: Date.now(), ip } ); - return { id: row.id, name: row.name }; + return { id: row.id, name: row.name, owner_id: row.owner_id }; }, async list() { return db.all( - `SELECT id, name, enabled, created_at, last_seen_at, last_ip + `SELECT id, name, enabled, owner_id, created_at, last_seen_at, last_ip FROM worker_tokens ORDER BY created_at DESC` ); }, + async listVisible(user) { + if (user && user.role === 'admin') return this.list(); + if (!user) return []; + return db.all( + `SELECT id, name, enabled, owner_id, created_at, last_seen_at, last_ip + FROM worker_tokens WHERE owner_id = {owner} ORDER BY created_at DESC`, + { owner: user.id } + ); + }, + async setEnabled(id, enabled) { const res = await db.run( 'UPDATE worker_tokens SET enabled = {enabled} WHERE id = {id}', @@ -60,3 +82,9 @@ export function createWorkerTokens({ db }) { }, }; } + +export function canManageWorker(user, token) { + if (!user || !token) return false; + if (user.role === 'admin') return true; + return token.owner_id !== null && token.owner_id === user.id; +} diff --git a/src/read-api/app.js b/src/read-api/app.js @@ -1,67 +0,0 @@ -// src/read-api/app.js - the public read surface -// -// Deliberately a separate service. It opens the same database but mounts -// only the read routes, so the trigger, worker and admin surfaces are not -// reachable on a port exposed to the internet. Nothing here can change -// state, which is a property of what is mounted rather than of a check that -// somebody has to remember to write. -// -// The schema belongs to the conductor, so this never runs migrations. - -import Fastify from 'fastify'; -import cors from '@fastify/cors'; - -import { openDatabase } from '../lib/db/index.js'; -import { createStorage } from '../lib/storage/index.js'; -import { createLogStore } from '../lib/log.js'; -import runRoutes from '../conductor/routes/runs.js'; -import staticRoutes from '../lib/static.js'; - -export async function createReadServices(cfg) { - const db = await openDatabase(cfg); - - // Fail at startup with something actionable rather than on the first - // request with a bare SQL error. - try { - await db.get('SELECT 1 FROM runs LIMIT 1'); - } catch (e) { - await db.close(); - throw new Error( - 'the conductor schema is missing or unreadable. Start the conductor first, ' + - `or run npm run migrate.\n ${e.message}`, - { cause: e } - ); - } - - return { - cfg, - db, - storage: createStorage(cfg), - logs: createLogStore(cfg), - }; -} - -export async function buildReadServer(services, options = {}) { - const { cfg } = services; - const fastify = Fastify({ - logger: options.logger ?? { level: process.env.LOG_LEVEL || 'info' }, - trustProxy: options.trustProxy ?? true, - }); - - await fastify.register(cors, { origin: true }); - - fastify.get('/health', async () => ({ - ok: true, - service: 'read-api', - database: services.db.dialect, - storage: services.storage.driver, - })); - - await fastify.register(runRoutes, { ...services, prefix: '/api' }); - - if (options.dashboard !== false) { - await fastify.register(staticRoutes, {}); - } - - return fastify; -} diff --git a/src/read-api/index.js b/src/read-api/index.js @@ -1,29 +0,0 @@ -// src/read-api/index.js - read-api entry point -// -// Serves the public read surface and the dashboard. Safe to expose without -// a reverse proxy in front of the write surface, because the write routes -// are not registered here at all. - -import { loadConfig } from '../lib/config.js'; -import { createReadServices, buildReadServer } from './app.js'; - -const cfg = loadConfig(); -const services = await createReadServices(cfg); -const fastify = await buildReadServer(services); - -async function shutdown(signal) { - fastify.log.info(`${signal} received, shutting down`); - try { - await fastify.close(); - await services.db.close(); - } catch (e) { - fastify.log.error({ err: e }, 'shutdown failed'); - process.exitCode = 1; - } -} - -for (const signal of ['SIGINT', 'SIGTERM']) { - process.once(signal, () => { shutdown(signal); }); -} - -await fastify.listen({ port: cfg.read_api.port, host: cfg.read_api.host }); diff --git a/test/admin.test.js b/test/admin.test.js @@ -74,9 +74,9 @@ test('the session cookie authenticates as well as the bearer token', async () => }); }); -test('admin routes reject anonymous and non-admin callers', async () => { +test('management needs a session, and user administration needs the admin role', async () => { await withAdmin({}, async (h, admin) => { - const anonymous = await h.app.inject({ method: 'GET', url: '/api/admin/projects' }); + const anonymous = await h.app.inject({ method: 'GET', url: '/api/projects' }); assert.equal(anonymous.statusCode, 401); await h.app.inject({ @@ -94,7 +94,13 @@ test('admin routes reject anonymous and non-admin callers', async () => { }); const viewer = { authorization: `Bearer ${login.json().token}` }; - const forbidden = await h.app.inject({ method: 'GET', url: '/api/admin/projects', headers: viewer }); + // An ordinary user manages their own things, and owns nothing yet. + const own = await h.app.inject({ method: 'GET', url: '/api/projects', headers: viewer }); + assert.equal(own.statusCode, 200); + assert.deepEqual(own.json().projects, []); + + // User administration stays administrator only. + const forbidden = await h.app.inject({ method: 'GET', url: '/api/admin/users', headers: viewer }); assert.equal(forbidden.statusCode, 403); }); }); @@ -152,7 +158,7 @@ test('projects can be created, listed and removed, and the secret is shown once' await withAdmin({}, async (h, admin) => { const created = await h.app.inject({ method: 'POST', - url: '/api/admin/projects', + url: '/api/projects', headers: json(admin), payload: JSON.stringify({ id: 'newproj', name: 'New', repo_url: 'https://git.example.com/new.git' }), }); @@ -160,14 +166,14 @@ test('projects can be created, listed and removed, and the secret is shown once' const secret = created.json().trigger_secret; assert.ok(secret && secret.length >= 32); - const listed = await h.app.inject({ method: 'GET', url: '/api/admin/projects', headers: admin }); + const listed = await h.app.inject({ method: 'GET', url: '/api/projects', headers: admin }); const project = listed.json().projects.find((p) => p.id === 'newproj'); assert.equal(project.has_trigger_secret, true); // Listing must never return the value itself. assert.equal(project.trigger_secret, undefined); assert.ok(!JSON.stringify(listed.json()).includes(secret)); - const deleted = await h.app.inject({ method: 'DELETE', url: '/api/admin/projects/newproj', headers: admin }); + const deleted = await h.app.inject({ method: 'DELETE', url: '/api/projects/newproj', headers: admin }); assert.equal(deleted.statusCode, 200); }); }); @@ -176,7 +182,7 @@ test('a rotated trigger secret actually signs triggers', async () => { await withAdmin({}, async (h, admin) => { const rotated = await h.app.inject({ method: 'POST', - url: '/api/admin/projects/demo/trigger-secret', + url: '/api/projects/demo/trigger-secret', headers: json(admin), payload: JSON.stringify({ secret: 'rotated-secret' }), }); @@ -195,7 +201,7 @@ test('worker tokens are issued once and can be revoked', async () => { await withAdmin({}, async (h, admin) => { const created = await h.app.inject({ method: 'POST', - url: '/api/admin/worker-tokens', + url: '/api/worker-tokens', headers: json(admin), payload: JSON.stringify({ name: 'builder-2' }), }); @@ -209,13 +215,13 @@ test('worker tokens are issued once and can be revoked', async () => { assert.notEqual(poll.statusCode, 401); // It is never listed again. - const listed = await h.app.inject({ method: 'GET', url: '/api/admin/worker-tokens', headers: admin }); + const listed = await h.app.inject({ method: 'GET', url: '/api/worker-tokens', headers: admin }); assert.ok(!JSON.stringify(listed.json()).includes(token)); // Disabling it takes effect at once. await h.app.inject({ method: 'PATCH', - url: `/api/admin/worker-tokens/${record.id}`, + url: `/api/worker-tokens/${record.id}`, headers: json(admin), payload: JSON.stringify({ enabled: false }), }); @@ -230,14 +236,14 @@ test('project variables reach a job environment and are never listed', async () await withAdmin({}, async (h, admin) => { const set = await h.app.inject({ method: 'PUT', - url: '/api/admin/projects/demo/variables/DEPLOY_TOKEN', + url: '/api/projects/demo/variables/DEPLOY_TOKEN', headers: json(admin), payload: JSON.stringify({ value: 'super-secret-value', masked: true }), }); assert.equal(set.statusCode, 200); const listed = await h.app.inject({ - method: 'GET', url: '/api/admin/projects/demo/variables', headers: admin, + method: 'GET', url: '/api/projects/demo/variables', headers: admin, }); const listing = listed.json().variables; assert.equal(listing[0].name, 'DEPLOY_TOKEN'); @@ -258,7 +264,7 @@ test('a variable is encrypted at rest and bound to its project and name', async await withAdmin({}, async (h, admin) => { await h.app.inject({ method: 'PUT', - url: '/api/admin/projects/demo/variables/TOKEN', + url: '/api/projects/demo/variables/TOKEN', headers: json(admin), payload: JSON.stringify({ value: 'rest-secret-value' }), }); @@ -295,7 +301,7 @@ jobs: await withAdmin({ pipeline }, async (h, admin) => { await h.app.inject({ method: 'PUT', - url: '/api/admin/projects/demo/variables/SHARED', + url: '/api/projects/demo/variables/SHARED', headers: json(admin), payload: JSON.stringify({ value: 'from-variable' }), }); @@ -310,7 +316,7 @@ test('a masked variable is redacted from ingested logs', async () => { await withAdmin({}, async (h, admin) => { await h.app.inject({ method: 'PUT', - url: '/api/admin/projects/demo/variables/LEAKY', + url: '/api/projects/demo/variables/LEAKY', headers: json(admin), payload: JSON.stringify({ value: 'leaked-secret-value', masked: true }), }); @@ -337,7 +343,7 @@ test('a run can be cancelled and retried through the api', async () => { const run = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().run_id; const cancelled = await h.app.inject({ - method: 'POST', url: `/api/admin/runs/${run}/cancel`, headers: json(admin), payload: '{}', + method: 'POST', url: `/api/runs/${run}/cancel`, headers: json(admin), payload: '{}', }); assert.equal(cancelled.statusCode, 200); @@ -345,7 +351,7 @@ test('a run can be cancelled and retried through the api', async () => { assert.equal(detail.json().run.state, 'cancelled'); const retried = await h.app.inject({ - method: 'POST', url: `/api/admin/runs/${run}/retry`, headers: json(admin), payload: '{}', + method: 'POST', url: `/api/runs/${run}/retry`, headers: json(admin), payload: '{}', }); assert.equal(retried.statusCode, 201); assert.notEqual(retried.json().run_id, run); @@ -385,7 +391,7 @@ test('local login can be kept as a break-glass account alongside oidc', async () }); try { const headers = await h.login(); - const res = await h.app.inject({ method: 'GET', url: '/api/admin/projects', headers }); + const res = await h.app.inject({ method: 'GET', url: '/api/projects', headers }); assert.equal(res.statusCode, 200); } finally { await h.stop(); diff --git a/test/ascii.test.js b/test/ascii.test.js @@ -13,7 +13,10 @@ import { fileURLToPath } from 'node:url'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); -const SKIP_DIRS = new Set(['.git', 'node_modules', 'data', 'tmp']); +// Vendored third party files are excluded. The rule is about code we +// write; rewriting someone else's minified build or their licence text to +// satisfy our own house style would be both pointless and wrong. +const SKIP_DIRS = new Set(['.git', 'node_modules', 'data', 'tmp', 'vendor']); const CHECK_EXT = new Set([ '.js', '.mjs', '.cjs', '.json', '.md', '.sql', '.yml', '.yaml', '.html', '.css', '.sh', '.txt', diff --git a/test/db.test.js b/test/db.test.js @@ -51,11 +51,12 @@ test('migrations apply once and are idempotent', async () => { const { db, cleanup } = await tempDb(); try { const first = await runMigrations(db, { logger: quiet }); - assert.equal(first.applied.length, 1); + assert.ok(first.applied.length > 0, 'expected the schema to be created'); + assert.equal(first.applied.length, first.total, 'a fresh database applies every migration'); const second = await runMigrations(db, { logger: quiet }); - assert.equal(second.applied.length, 0); - assert.equal(second.total, 1); + assert.equal(second.applied.length, 0, 'a second run must do nothing'); + assert.equal(second.total, first.total); } finally { await cleanup(); } diff --git a/test/helpers/harness.js b/test/helpers/harness.js @@ -116,6 +116,11 @@ export async function startHarness(options = {}) { trigger_secret: 'test-secret', config_path: options.configPath ?? '.conductor.yml', source_mode: options.sourceMode ?? 'archive', + // Public by default so that tests about scheduling can read runs back + // without signing in. Visibility itself is covered by its own suite, + // which creates private projects explicitly. + visibility: options.visibility ?? 'public', + owner_id: options.ownerId ?? null, }); const worker = await services.workerTokens.create('test-worker'); diff --git a/test/read-api.test.js b/test/read-api.test.js @@ -1,200 +0,0 @@ -// test/read-api.test.js - the public read surface -// -// The point of running this separately is that the write surface is not -// reachable on it. That is asserted here rather than assumed, because it is -// the only thing making the service safe to expose. - -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { startHarness } from './helpers/harness.js'; -import { createReadServices, buildReadServer } from '../src/read-api/app.js'; - -// Brings up a read-api against the same database the harness conductor uses. -async function withBoth(options, fn) { - const h = await startHarness(options); - const services = await createReadServices(h.cfg); - const read = await buildReadServer(services, { logger: false }); - try { - return await fn(h, read); - } finally { - await read.close(); - await services.db.close(); - await h.stop(); - } -} - -test('the read-api reports its drivers', async () => { - await withBoth({}, async (h, read) => { - const res = await read.inject({ method: 'GET', url: '/health' }); - assert.equal(res.statusCode, 200); - assert.equal(res.json().service, 'read-api'); - }); -}); - -test('the write surface is not mounted at all', async () => { - await withBoth({}, async (h, read) => { - const writes = [ - ['POST', '/api/trigger/demo'], - ['GET', '/api/workers/poll'], - ['GET', '/api/admin/projects'], - ['POST', '/api/admin/worker-tokens'], - ['POST', '/api/auth/login'], - ['GET', '/api/auth/me'], - ]; - - for (const [method, url] of writes) { - const res = await read.inject({ method, url }); - // 404 rather than 401: the route does not exist here, so no - // credential could ever reach it. - assert.equal(res.statusCode, 404, `${method} ${url} should not exist on the read-api`); - } - }); -}); - -test('runs, jobs and logs are readable', async () => { - await withBoth({}, async (h, read) => { - const run = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().run_id; - - const list = await read.inject({ method: 'GET', url: '/api/runs' }); - assert.equal(list.statusCode, 200); - assert.equal(list.json().runs.length, 1); - - const detail = await read.inject({ method: 'GET', url: `/api/runs/${run}` }); - assert.equal(detail.statusCode, 200); - assert.equal(detail.json().jobs.length, 6); - // Edges are exposed so a client can draw the graph. - assert.ok(detail.json().jobs.every((j) => Array.isArray(j.needs))); - }); -}); - -test('a job environment is never exposed', async () => { - await withBoth({}, async (h, read) => { - await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); - const job = (await h.poll({ arches: 'x86_64' })).json().job; - - // The job really does carry an environment when dispatched. - assert.ok(Object.keys(job.env).length > 0); - - const res = await read.inject({ method: 'GET', url: `/api/jobs/${encodeURIComponent(job.id)}` }); - assert.equal(res.statusCode, 200); - - const body = res.json(); - assert.equal(body.job.env, undefined); - assert.equal(body.job.spec.env, undefined); - // The useful parts are still there. - assert.ok(Array.isArray(body.job.spec.script)); - assert.ok(Array.isArray(body.job.spec.needs)); - }); -}); - -test('a project variable cannot be read back through the read-api', async () => { - await withBoth({ bootstrap: true }, async (h, read) => { - await h.services.variables.set('demo', 'SECRET_TOKEN', 'do-not-expose-this'); - - await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); - const job = (await h.poll({})).json().job; - assert.equal(job.env.SECRET_TOKEN, 'do-not-expose-this'); - - for (const url of ['/api/runs', `/api/runs/${job.run_id}`, `/api/jobs/${encodeURIComponent(job.id)}`]) { - const res = await read.inject({ method: 'GET', url }); - assert.ok(!res.body.includes('do-not-expose-this'), `${url} leaked a variable`); - } - }); -}); - -test('logs and artifacts are served', async () => { - await withBoth({}, async (h, read) => { - await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); - const job = (await h.poll({ arches: 'x86_64' })).json().job; - - await h.app.inject({ - method: 'POST', - url: `/api/workers/jobs/${encodeURIComponent(job.id)}/log`, - headers: { ...h.auth, 'content-type': 'application/octet-stream' }, - payload: Buffer.from('building\ndone\n'), - }); - await h.app.inject({ - method: 'POST', - url: `/api/workers/jobs/${encodeURIComponent(job.id)}/artifact`, - headers: { ...h.auth, 'content-type': 'application/octet-stream', 'x-artifact-path': 'dist/out.bin' }, - payload: Buffer.from('artifact bytes'), - }); - - const log = await read.inject({ method: 'GET', url: `/api/jobs/${encodeURIComponent(job.id)}/log` }); - assert.equal(log.body, 'building\ndone\n'); - assert.equal(log.headers['x-log-complete'], 'false'); - - const detail = await read.inject({ method: 'GET', url: `/api/jobs/${encodeURIComponent(job.id)}` }); - const artifact = detail.json().artifacts[0]; - const download = await read.inject({ method: 'GET', url: `/api/artifacts/${artifact.id}` }); - assert.equal(download.body, 'artifact bytes'); - }); -}); - -test('an incremental log tail returns only what is new', async () => { - await withBoth({}, async (h, read) => { - await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); - const job = (await h.poll({ arches: 'x86_64' })).json().job; - const url = `/api/workers/jobs/${encodeURIComponent(job.id)}/log`; - const headers = { ...h.auth, 'content-type': 'application/octet-stream' }; - - await h.app.inject({ method: 'POST', url, headers, payload: Buffer.from('first\n') }); - const first = await read.inject({ method: 'GET', url: `/api/jobs/${encodeURIComponent(job.id)}/log` }); - assert.equal(first.body, 'first\n'); - const offset = Number(first.headers['x-log-size']); - - await h.app.inject({ method: 'POST', url, headers, payload: Buffer.from('second\n') }); - const next = await read.inject({ - method: 'GET', url: `/api/jobs/${encodeURIComponent(job.id)}/log?offset=${offset}`, - }); - assert.equal(next.body, 'second\n'); - }); -}); - -test('the dashboard is served, and only its own files', async () => { - await withBoth({}, async (h, read) => { - const index = await read.inject({ method: 'GET', url: '/' }); - assert.equal(index.statusCode, 200); - assert.match(index.headers['content-type'], /text\/html/); - assert.match(index.body, /<title>conductor<\/title>/); - - for (const path of ['/app.js', '/style.css']) { - const res = await read.inject({ method: 'GET', url: path }); - assert.equal(res.statusCode, 200); - } - - // Nothing outside the allowlist exists, so traversal has nothing to hit. - for (const path of ['/../package.json', '/conductor.yaml', '/index.js']) { - assert.equal((await read.inject({ method: 'GET', url: path })).statusCode, 404); - } - }); -}); - -test('the dashboard is also served by the conductor', async () => { - await withBoth({}, async (h) => { - const res = await h.app.inject({ method: 'GET', url: '/' }); - assert.equal(res.statusCode, 200); - assert.match(res.body, /conductor/); - }); -}); - -test('a cached dashboard file is not resent', async () => { - await withBoth({}, async (h, read) => { - const first = await read.inject({ method: 'GET', url: '/app.js' }); - const etag = first.headers.etag; - assert.ok(etag); - - const second = await read.inject({ method: 'GET', url: '/app.js', headers: { 'if-none-match': etag } }); - assert.equal(second.statusCode, 304); - }); -}); - -test('the read-api refuses to start against a database with no schema', async () => { - const h = await startHarness({}); - try { - await h.services.db.exec('DROP TABLE runs'); - await assert.rejects(createReadServices(h.cfg), /schema is missing/); - } finally { - await h.stop(); - } -}); diff --git a/test/ui.test.js b/test/ui.test.js @@ -0,0 +1,392 @@ +// test/ui.test.js - the server rendered interface +// +// The interface is rendered from the same data and the same visibility +// rules as the API, so these check what a page actually contains for a +// given caller rather than that a route merely answers. + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { startHarness } from './helpers/harness.js'; +import { html, esc, raw, toHtml, attrs } from '../src/conductor/ui/html.js'; + +async function withUi(options, fn) { + const h = await startHarness({ bootstrap: true, ...options }); + try { + return await fn(h); + } finally { + await h.stop(); + } +} + +const get = (h, url, headers = {}) => h.app.inject({ method: 'GET', url, headers }); + +// htmx sets this on every request it makes; mutations require it. +const hx = (headers = {}) => ({ ...headers, 'hx-request': 'true' }); + +function form(headers, fields) { + return { + headers: { ...hx(headers), 'content-type': 'application/x-www-form-urlencoded' }, + payload: new URLSearchParams(fields).toString(), + }; +} + +// --- templating --- + +test('interpolated values are escaped by default', () => { + const evil = '<script>alert(1)</script>'; + const out = toHtml(html`<td>${evil}</td>`); + assert.equal(out, '<td>&lt;script&gt;alert(1)&lt;/script&gt;</td>'); +}); + +test('quotes and ampersands are escaped inside attributes', () => { + const out = toHtml(html`<input value="${'a" onload="evil() & more'}">`); + assert.ok(!out.includes('onload="evil')); + assert.ok(out.includes('&quot;')); + assert.ok(out.includes('&amp;')); +}); + +test('nested templates and arrays are kept, raw is passed through', () => { + const rows = ['a', 'b'].map((x) => html`<li>${x}</li>`); + assert.equal(toHtml(html`<ul>${rows}</ul>`), '<ul><li>a</li><li>b</li></ul>'); + assert.equal(toHtml(html`<p>${raw('<b>bold</b>')}</p>`), '<p><b>bold</b></p>'); +}); + +test('null, undefined and false render as nothing', () => { + assert.equal(toHtml(html`<p>${null}${undefined}${false}</p>`), '<p></p>'); + assert.equal(esc(null), ''); +}); + +test('attrs omits absent values and renders bare booleans', () => { + assert.equal(toHtml(attrs({ id: 'x', hidden: true, skip: false, gone: null })), 'id="x" hidden'); +}); + +// --- anonymous --- + +test('an anonymous visitor sees public runs and an invitation to sign in', async () => { + await withUi({ visibility: 'public' }, async (h) => { + await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); + + const res = await get(h, '/'); + assert.equal(res.statusCode, 200); + assert.match(res.headers['content-type'], /text\/html/); + assert.match(res.body, /Sign in/); + assert.match(res.body, /#1<\/a>/); + // No management links without a session. + assert.ok(!res.body.includes('href="/projects"')); + assert.ok(!res.body.includes('href="/users"')); + }); +}); + +test('an anonymous visitor cannot see a private run', async () => { + await withUi({ visibility: 'private' }, async (h) => { + const run = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().run_id; + + const list = await get(h, '/'); + assert.ok(!list.body.includes('#1</a>')); + + const detail = await get(h, `/runs/${run}`); + assert.match(detail.body, /No such run/); + }); +}); + +test('management pages redirect an anonymous visitor to sign in', async () => { + await withUi({}, async (h) => { + for (const url of ['/projects', '/workers', '/users']) { + const res = await get(h, url); + assert.equal(res.statusCode, 302, `${url} should redirect`); + assert.equal(res.headers.location, '/login'); + } + }); +}); + +// --- session --- + +test('signing in through the form sets a session and reveals the nav', async () => { + await withUi({}, async (h) => { + const bad = await h.app.inject({ + method: 'POST', + url: '/login', + ...form({}, { username: 'admin', password: 'wrong' }), + }); + assert.equal(bad.statusCode, 401); + assert.match(bad.body, /Invalid username or password/); + + const good = await h.app.inject({ + method: 'POST', + url: '/login', + ...form({}, { username: 'admin', password: 'bootstrap-password' }), + }); + assert.equal(good.statusCode, 204); + assert.match(good.headers['set-cookie'], /conductor_session=/); + + const cookie = good.headers['set-cookie'].split(';')[0]; + const home = await get(h, '/', { cookie }); + assert.match(home.body, /href="\/projects"/); + assert.match(home.body, /href="\/users"/); + assert.match(home.body, /sign out/); + }); +}); + +// Signs in and returns a cookie header. +async function signIn(h, username = 'admin', password = 'bootstrap-password') { + const res = await h.app.inject({ method: 'POST', url: '/login', ...form({}, { username, password }) }); + if (res.statusCode !== 204) throw new Error(`login failed: ${res.statusCode} ${res.body}`); + return { cookie: res.headers['set-cookie'].split(';')[0] }; +} + +// --- projects --- + +test('a user creates a project through the form and is shown the secret once', async () => { + await withUi({}, async (h) => { + const session = await signIn(h); + + const created = await h.app.inject({ + method: 'POST', + url: '/projects', + ...form(session, { id: 'from-ui', repo_url: 'https://git.example.com/ui.git', visibility: 'private' }), + }); + assert.equal(created.statusCode, 204); + assert.equal(created.headers['hx-redirect'], '/projects/from-ui?created=1'); + + const page = await get(h, '/projects/from-ui?created=1', session); + assert.match(page.body, /Trigger secret/); + assert.match(page.body, /api\/trigger\/from-ui/); + + // The secret is only revealed on that one visit. + const again = await get(h, '/projects/from-ui', session); + assert.ok(!again.body.includes('Trigger secret')); + }); +}); + +test('a project page shows variables and never their values', async () => { + await withUi({}, async (h) => { + const session = await signIn(h); + await h.services.variables.set('demo', 'DEPLOY_TOKEN', 'ui-secret-value'); + + const page = await get(h, '/projects/demo', session); + assert.match(page.body, /DEPLOY_TOKEN/); + assert.match(page.body, /masked/); + assert.ok(!page.body.includes('ui-secret-value'), 'a variable value must never be rendered'); + }); +}); + +test('setting and removing a variable returns the updated table', async () => { + await withUi({}, async (h) => { + const session = await signIn(h); + + const set = await h.app.inject({ + method: 'PUT', + url: '/projects/demo/variables', + ...form(session, { name: 'TOKEN', value: 'abc12345', masked: 'true' }), + }); + assert.equal(set.statusCode, 200); + assert.match(set.body, /TOKEN/); + assert.ok(!set.body.includes('abc12345')); + + const removed = await h.app.inject({ + method: 'DELETE', url: '/projects/demo/variables/TOKEN', headers: hx(session), + }); + assert.equal(removed.statusCode, 200); + assert.ok(!removed.body.includes('TOKEN')); + }); +}); + +test('a user cannot open or delete a project they do not own', async () => { + await withUi({}, async (h) => { + const admin = await signIn(h); + await h.app.inject({ + method: 'POST', url: '/users', ...form(admin, { username: 'mallory', password: 'mallory-password', role: 'viewer' }), + }); + const other = await signIn(h, 'mallory', 'mallory-password'); + + const page = await get(h, '/projects/demo', other); + assert.match(page.body, /No such project/); + + const deleted = await h.app.inject({ method: 'DELETE', url: '/projects/demo', headers: hx(other) }); + assert.equal(deleted.statusCode, 404); + assert.ok(await h.services.projects.get('demo'), 'the project must still exist'); + }); +}); + +// --- workers --- + +test('registering a worker shows the token once and lists it', async () => { + await withUi({}, async (h) => { + const session = await signIn(h); + + const created = await h.app.inject({ + method: 'POST', url: '/workers', ...form(session, { name: 'my-laptop' }), + }); + assert.equal(created.statusCode, 200); + assert.match(created.body, /Worker token for my-laptop/); + + // The token appears exactly once, in the reveal panel. + const token = /value="([0-9a-f]{64})"/.exec(created.body); + assert.ok(token, 'expected the token to be shown'); + + const list = await get(h, '/workers', session); + assert.match(list.body, /my-laptop/); + assert.ok(!list.body.includes(token[1]), 'the token must not be listed again'); + }); +}); + +test('an ordinary user cannot create shared capacity from the form', async () => { + await withUi({}, async (h) => { + const admin = await signIn(h); + await h.app.inject({ + method: 'POST', url: '/users', ...form(admin, { username: 'carol', password: 'carol-password', role: 'viewer' }), + }); + const carol = await signIn(h, 'carol', 'carol-password'); + + await h.app.inject({ method: 'POST', url: '/workers', ...form(carol, { name: 'sneaky', shared: 'true' }) }); + + const rows = await h.services.db.all('SELECT name, owner_id FROM worker_tokens WHERE name = {n}', { n: 'sneaky' }); + assert.equal(rows.length, 1); + assert.notEqual(rows[0].owner_id, null, 'an ordinary user must not create a shared worker'); + }); +}); + +// --- users --- + +test('the users page is administrator only and warns about what deletion destroys', async () => { + await withUi({}, async (h) => { + const admin = await signIn(h); + await h.app.inject({ + method: 'POST', url: '/users', ...form(admin, { username: 'dave', password: 'dave-password', role: 'viewer' }), + }); + const dave = await h.services.users.byUsername('dave'); + await h.services.projects.setOwner('demo', dave.id); + + const page = await get(h, '/users', admin); + assert.match(page.body, /dave/); + // The confirmation has to say what goes with the account. + assert.match(page.body, /also deletes 1 project\(s\) and all of their run history/); + + const asViewer = await signIn(h, 'dave', 'dave-password'); + const denied = await get(h, '/users', asViewer); + assert.match(denied.body, /Administrator role required/); + }); +}); + +// --- live regions --- + +test('a running run polls, and a finished one stops', async () => { + await withUi({ visibility: 'public' }, async (h) => { + const run = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().run_id; + + const running = await get(h, `/runs/${run}`); + assert.match(running.body, /hx-get="\/partials\/runs\/[^"]+\/jobs"/); + assert.match(running.body, /hx-trigger="every 3s"/); + + await h.services.scheduler.cancelRun(run); + + const finished = await get(h, `/runs/${run}`); + assert.ok(!finished.body.includes('/jobs" hx-trigger'), 'a settled run must stop polling'); + }); +}); + +test('a job page streams the log and stops polling once it completes', async () => { + await withUi({ visibility: 'public' }, async (h) => { + await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); + const job = (await h.poll({})).json().job; + + await h.app.inject({ + method: 'POST', + url: `/api/workers/jobs/${encodeURIComponent(job.id)}/log`, + headers: { ...h.auth, 'content-type': 'application/octet-stream' }, + payload: Buffer.from('compiling the thing\n'), + }); + + const live = await get(h, `/jobs/${encodeURIComponent(job.id)}`); + assert.match(live.body, /compiling the thing/); + assert.match(live.body, /hx-trigger="every 2s"/); + + await h.app.inject({ + method: 'POST', + url: `/api/workers/jobs/${encodeURIComponent(job.id)}/done`, + headers: { ...h.auth, 'content-type': 'application/json' }, + payload: JSON.stringify({ success: true, exit_code: 0 }), + }); + + const done = await get(h, `/jobs/${encodeURIComponent(job.id)}`); + assert.match(done.body, /compiling the thing/); + assert.ok(!done.body.includes('hx-trigger="every 2s"'), 'a finished job must stop polling'); + }); +}); + +test('log output is escaped, so a job cannot inject markup into the page', async () => { + await withUi({ visibility: 'public' }, async (h) => { + await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); + const job = (await h.poll({})).json().job; + + await h.app.inject({ + method: 'POST', + url: `/api/workers/jobs/${encodeURIComponent(job.id)}/log`, + headers: { ...h.auth, 'content-type': 'application/octet-stream' }, + payload: Buffer.from('<img src=x onerror="alert(1)">\n'), + }); + + const page = await get(h, `/jobs/${encodeURIComponent(job.id)}`); + assert.ok(!page.body.includes('<img src=x'), 'job output must not become markup'); + assert.match(page.body, /&lt;img src=x/); + }); +}); + +// --- csrf --- + +test('a mutation without the htmx header is refused', async () => { + await withUi({}, async (h) => { + const session = await signIn(h); + + // A cross site form post carries the cookie but cannot set a header. + const res = await h.app.inject({ + method: 'POST', + url: '/projects', + headers: { ...session, 'content-type': 'application/x-www-form-urlencoded' }, + payload: new URLSearchParams({ id: 'csrf', repo_url: 'https://evil.example/x.git' }).toString(), + }); + assert.equal(res.statusCode, 400); + assert.equal(await h.services.projects.get('csrf'), undefined); + }); +}); + +// --- layout details --- + +test('the sign in form is centred rather than stranded at the left edge', async () => { + await withUi({}, async (h) => { + const res = await get(h, '/login'); + assert.equal(res.statusCode, 200); + assert.match(res.body, /<div class="center">\s*<div class="panel narrow">/); + }); +}); + +test('row actions stay ordinary table cells, so rows keep one height', async () => { + await withUi({}, async (h) => { + const session = await signIn(h); + await h.app.inject({ method: 'POST', url: '/workers', ...form(session, { name: 'metrics-check' }) }); + + for (const url of ['/workers', '/users']) { + const body = (await get(h, url, session)).body; + assert.match(body, /<td class="row-actions">/, `${url} should use row-actions`); + // The flex container used for button bars would break baseline + // alignment inside a row. + assert.ok(!body.includes('<td class="actions">'), `${url} must not make a cell a flex container`); + } + }); +}); + +// --- assets --- + +test('the stylesheet and htmx are served, and nothing else is', async () => { + await withUi({}, async (h) => { + assert.equal((await get(h, '/style.css')).statusCode, 200); + + const htmx = await get(h, '/vendor/htmx.min.js'); + assert.equal(htmx.statusCode, 200); + assert.match(htmx.headers['content-type'], /javascript/); + + for (const url of ['/vendor/../package.json', '/app.js', '/index.html']) { + assert.equal((await get(h, url)).statusCode, 404, `${url} must not be served`); + } + }); +}); diff --git a/test/visibility.test.js b/test/visibility.test.js @@ -0,0 +1,319 @@ +// test/visibility.test.js - who can see and run what +// +// Two rules are enforced here, and both matter for letting strangers use +// the same installation: +// +// A run is private unless its project or its pipeline says otherwise. +// An anonymous visitor sees only public runs; a user additionally sees +// their own; an administrator sees everything. +// +// A worker registered by a user is only ever offered that user's jobs. + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { startHarness } from './helpers/harness.js'; + +async function withHarness(options, fn) { + const h = await startHarness({ bootstrap: true, ...options }); + try { + return await fn(h); + } finally { + await h.stop(); + } +} + +// Creates a user and returns their credentials and identity. +async function addUser(h, admin, username, role = 'viewer') { + const created = await h.app.inject({ + method: 'POST', + url: '/api/admin/users', + headers: { ...admin, 'content-type': 'application/json' }, + payload: JSON.stringify({ username, password: `${username}-password`, role }), + }); + const user = created.json().user; + + const login = await h.app.inject({ + method: 'POST', + url: '/api/auth/login', + headers: { 'content-type': 'application/json' }, + payload: JSON.stringify({ username, password: `${username}-password` }), + }); + return { user, headers: { authorization: `Bearer ${login.json().token}` } }; +} + +const runIds = (res) => res.json().runs.map((r) => r.id); + +test('a private run is invisible to anonymous callers', async () => { + await withHarness({ visibility: 'private' }, async (h) => { + const run = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().run_id; + + assert.deepEqual(runIds(await h.app.inject({ method: 'GET', url: '/api/runs' })), []); + // The same answer as a run that does not exist, so nothing is leaked + // by the status code. + assert.equal((await h.app.inject({ method: 'GET', url: `/api/runs/${run}` })).statusCode, 404); + }); +}); + +test('a public run is visible to anonymous callers', async () => { + await withHarness({ visibility: 'public' }, async (h) => { + const run = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().run_id; + + assert.deepEqual(runIds(await h.app.inject({ method: 'GET', url: '/api/runs' })), [run]); + assert.equal((await h.app.inject({ method: 'GET', url: `/api/runs/${run}` })).statusCode, 200); + }); +}); + +test('the pipeline may override the project visibility', async () => { + const pipeline = ` +version: 1 +visibility: public +jobs: + a: + image: alpine + script: ['true'] +`; + await withHarness({ pipeline, visibility: 'private' }, async (h) => { + const run = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().run_id; + + // The project is private, but this commit declared itself public. + const detail = await h.app.inject({ method: 'GET', url: `/api/runs/${run}` }); + assert.equal(detail.statusCode, 200); + assert.equal(detail.json().run.visibility, 'public'); + }); +}); + +test('visibility is recorded per run, so a later commit can change it', async () => { + const open = "version: 1\nvisibility: public\njobs:\n a: { image: alpine, script: ['true'] }\n"; + await withHarness({ pipeline: open, visibility: 'private' }, async (h) => { + const first = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().run_id; + + const closed = "version: 1\nvisibility: private\njobs:\n a: { image: alpine, script: ['true'] }\n"; + const sha2 = await h.commit({ '.conductor.yml': closed }, 'go private'); + const second = (await h.trigger({ sha: sha2, ref: 'refs/heads/main' })).json().run_id; + + // The old run stays public; the new one does not. + assert.deepEqual(runIds(await h.app.inject({ method: 'GET', url: '/api/runs' })), [first]); + assert.equal((await h.app.inject({ method: 'GET', url: `/api/runs/${second}` })).statusCode, 404); + }); +}); + +test('an owner sees their private runs, a stranger does not', async () => { + await withHarness({ visibility: 'private' }, async (h) => { + const admin = await h.login(); + const owner = await addUser(h, admin, 'owner'); + const stranger = await addUser(h, admin, 'stranger'); + + await h.services.projects.setOwner('demo', owner.user.id); + const run = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().run_id; + + assert.deepEqual(runIds(await h.app.inject({ method: 'GET', url: '/api/runs', headers: owner.headers })), [run]); + assert.deepEqual(runIds(await h.app.inject({ method: 'GET', url: '/api/runs', headers: stranger.headers })), []); + // An administrator sees everything. + assert.deepEqual(runIds(await h.app.inject({ method: 'GET', url: '/api/runs', headers: admin })), [run]); + }); +}); + +test('logs and artifacts of a private run are not readable by a stranger', async () => { + await withHarness({ visibility: 'private' }, async (h) => { + const admin = await h.login(); + const owner = await addUser(h, admin, 'owner'); + await h.services.projects.setOwner('demo', owner.user.id); + + await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); + const job = (await h.poll({})).json().job; + + await h.app.inject({ + method: 'POST', + url: `/api/workers/jobs/${encodeURIComponent(job.id)}/log`, + headers: { ...h.auth, 'content-type': 'application/octet-stream' }, + payload: Buffer.from('private build output\n'), + }); + await h.app.inject({ + method: 'POST', + url: `/api/workers/jobs/${encodeURIComponent(job.id)}/artifact`, + headers: { ...h.auth, 'content-type': 'application/octet-stream', 'x-artifact-path': 'secret.bin' }, + payload: Buffer.from('private artifact'), + }); + + const jobUrl = `/api/jobs/${encodeURIComponent(job.id)}`; + assert.equal((await h.app.inject({ method: 'GET', url: jobUrl })).statusCode, 404); + assert.equal((await h.app.inject({ method: 'GET', url: `${jobUrl}/log` })).statusCode, 404); + + const asOwner = await h.app.inject({ method: 'GET', url: jobUrl, headers: owner.headers }); + assert.equal(asOwner.statusCode, 200); + + const artifactId = asOwner.json().artifacts[0].id; + assert.equal((await h.app.inject({ method: 'GET', url: `/api/artifacts/${artifactId}` })).statusCode, 404); + const download = await h.app.inject({ method: 'GET', url: `/api/artifacts/${artifactId}`, headers: owner.headers }); + assert.equal(download.body, 'private artifact'); + }); +}); + +test("a worker belonging to a user only receives that owner's projects", async () => { + await withHarness({ visibility: 'private' }, async (h) => { + const admin = await h.login(); + const alice = await addUser(h, admin, 'alice'); + const bob = await addUser(h, admin, 'bob'); + + // demo belongs to alice. + await h.services.projects.setOwner('demo', alice.user.id); + + const aliceWorker = await h.services.workerTokens.create('alice-pi', { ownerId: alice.user.id }); + const bobWorker = await h.services.workerTokens.create('bob-pi', { ownerId: bob.user.id }); + + await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); + + // Bob's worker must never see alice's work. + const forBob = await h.app.inject({ + method: 'GET', url: '/api/workers/poll', headers: { authorization: `Bearer ${bobWorker.token}` }, + }); + assert.equal(forBob.statusCode, 204); + + const forAlice = await h.app.inject({ + method: 'GET', url: '/api/workers/poll', headers: { authorization: `Bearer ${aliceWorker.token}` }, + }); + assert.equal(forAlice.statusCode, 200); + assert.equal(forAlice.json().job.project_id, 'demo'); + }); +}); + +test('a shared worker receives work from any project', async () => { + await withHarness({ visibility: 'private' }, async (h) => { + const admin = await h.login(); + const alice = await addUser(h, admin, 'alice'); + await h.services.projects.setOwner('demo', alice.user.id); + + // No owner means shared capacity. + const shared = await h.services.workerTokens.create('shared', { ownerId: null }); + await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); + + const res = await h.app.inject({ + method: 'GET', url: '/api/workers/poll', headers: { authorization: `Bearer ${shared.token}` }, + }); + assert.equal(res.statusCode, 200); + }); +}); + +test("a user registers their own project and worker, and cannot touch another's", async () => { + await withHarness({}, async (h) => { + const admin = await h.login(); + const alice = await addUser(h, admin, 'alice'); + const bob = await addUser(h, admin, 'bob'); + const json = (headers) => ({ ...headers, 'content-type': 'application/json' }); + + const created = await h.app.inject({ + method: 'POST', + url: '/api/projects', + headers: json(alice.headers), + payload: JSON.stringify({ id: 'alice-app', repo_url: 'https://git.example.com/a.git' }), + }); + assert.equal(created.statusCode, 201); + assert.equal(created.json().project.owner_id, alice.user.id); + + // Bob cannot see or manage it. + const bobList = await h.app.inject({ method: 'GET', url: '/api/projects', headers: bob.headers }); + assert.deepEqual(bobList.json().projects.map((p) => p.id), []); + assert.equal((await h.app.inject({ + method: 'DELETE', url: '/api/projects/alice-app', headers: bob.headers, + })).statusCode, 403); + + // A worker bob registers belongs to bob. + const token = await h.app.inject({ + method: 'POST', + url: '/api/worker-tokens', + headers: json(bob.headers), + payload: JSON.stringify({ name: 'bob-laptop' }), + }); + assert.equal(token.json().worker_token.owner_id, bob.user.id); + assert.equal(token.json().worker_token.shared, false); + + // And alice cannot see it. + const aliceWorkers = await h.app.inject({ method: 'GET', url: '/api/worker-tokens', headers: alice.headers }); + assert.deepEqual(aliceWorkers.json().worker_tokens.map((t) => t.name), []); + }); +}); + +test('only an administrator can create shared capacity or reassign a project', async () => { + await withHarness({}, async (h) => { + const admin = await h.login(); + const alice = await addUser(h, admin, 'alice'); + const json = (headers) => ({ ...headers, 'content-type': 'application/json' }); + + // Asking for a shared worker as an ordinary user gets a personal one. + const attempt = await h.app.inject({ + method: 'POST', + url: '/api/worker-tokens', + headers: json(alice.headers), + payload: JSON.stringify({ name: 'sneaky', shared: true }), + }); + assert.equal(attempt.json().worker_token.shared, false); + assert.equal(attempt.json().worker_token.owner_id, alice.user.id); + + const asAdmin = await h.app.inject({ + method: 'POST', + url: '/api/worker-tokens', + headers: json(admin), + payload: JSON.stringify({ name: 'pool', shared: true }), + }); + assert.equal(asAdmin.json().worker_token.shared, true); + + // Reassigning an owner is administrator only. + await h.services.projects.setOwner('demo', alice.user.id); + const reassign = await h.app.inject({ + method: 'PATCH', + url: '/api/projects/demo', + headers: json(alice.headers), + payload: JSON.stringify({ owner_id: null }), + }); + assert.equal(reassign.statusCode, 400); + }); +}); + +test('deleting a user deletes what they owned, leaving nothing orphaned', async () => { + await withHarness({}, async (h) => { + const admin = await h.login(); + const alice = await addUser(h, admin, 'alice'); + await h.services.projects.setOwner('demo', alice.user.id); + const token = await h.services.workerTokens.create('alice-pi', { ownerId: alice.user.id }); + const run = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().run_id; + + // The caller is told what will go before it goes. + const impact = await h.app.inject({ + method: 'GET', url: `/api/admin/users/${alice.user.id}/impact`, headers: admin, + }); + assert.deepEqual(impact.json().projects, ['demo']); + assert.equal(impact.json().worker_tokens_deleted, 1); + assert.ok(impact.json().runs_deleted > 0); + + const removed = await h.app.inject({ + method: 'DELETE', url: `/api/admin/users/${alice.user.id}`, headers: admin, + }); + assert.equal(removed.statusCode, 200); + assert.deepEqual(removed.json().projects, ['demo']); + + // Nothing of theirs is left behind. + assert.equal(await h.services.projects.get('demo'), undefined); + assert.equal(await h.services.db.get('SELECT id FROM runs WHERE id = {id}', { id: run }), undefined); + assert.equal(await h.services.workerTokens.get(token.id), undefined); + }); +}); + +test('an orphaned worker token cannot appear and quietly become shared', async () => { + await withHarness({}, async (h) => { + const admin = await h.login(); + const alice = await addUser(h, admin, 'alice'); + const token = await h.services.workerTokens.create('alice-pi', { ownerId: alice.user.id }); + + await h.app.inject({ method: 'DELETE', url: `/api/admin/users/${alice.user.id}`, headers: admin }); + + // Were the row merely unowned, this token would now accept any + // project's jobs instead of none. + const rows = await h.services.db.all('SELECT id, owner_id FROM worker_tokens WHERE id = {id}', { id: token.id }); + assert.deepEqual(rows, []); + + const poll = await h.app.inject({ + method: 'GET', url: '/api/workers/poll', headers: { authorization: `Bearer ${token.token}` }, + }); + assert.equal(poll.statusCode, 401); + }); +});