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:
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 = {
+ '&': '&',
+ '<': '<',
+ '>': '>',
+ '"': '"',
+ "'": ''',
+};
+
+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><script>alert(1)</script></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('"'));
+ assert.ok(out.includes('&'));
+});
+
+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, /<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);
+ });
+});