commit 24f70da111d0df55b42d34576093b00659ba8a43
parent 1aad5175bf9bd46971983f2e0856a87f2ec7c5c4
Author: finwo <finwo@pm.me>
Date: Sat, 26 Sep 2026 02:46:44 +0200
Turn the conductor into an API with a login page in front of it
Diffstat:
38 files changed, 1973 insertions(+), 3462 deletions(-)
diff --git a/assets/style.css b/assets/style.css
@@ -1,210 +0,0 @@
-/* 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); }
-.badge.online { background: var(--success); }
-.badge.offline { background: var(--skipped); }
-
-.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; }
-
-/* Stages are row groups inside the one task table rather than tables of
- their own, so every stage shares a single set of column widths. The
- label is a header row, so it already reads as one. */
-.stage-label th { padding-top: 1.2rem; border-bottom-color: transparent; }
-tbody.stage:first-of-type .stage-label th { padding-top: .45rem; }
-tbody.stage tr.stage-label:hover { background: none; }
-
-/* Durations are read down the column, not across the row, so they are
- right aligned and set in figures of one width. */
-td.num { text-align: right; font-variant-numeric: tabular-nums; }
-table.tasks thead th:last-child { text-align: right; }
-
-.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; }
-
-/* Page links sit under their table. The count is centred between the two
- steps so it does not shift as the labels change. */
-.pager { display: flex; gap: 1rem; align-items: center; margin-top: .75rem; font-size: .8rem; }
-.pager > span { flex: 1; text-align: center; }
-
-dialog {
- border: 1px solid var(--border);
- border-radius: 6px;
- background: var(--panel);
- color: var(--text);
- padding: 1.25rem;
- max-width: 28rem;
- width: 90%;
-}
-dialog::backdrop { background: rgba(0, 0, 0, 0.6); }
-dialog .actions { justify-content: flex-end; margin-top: 1rem; }
diff --git a/assets/vendor/htmx-LICENSE.txt b/assets/vendor/htmx-LICENSE.txt
@@ -1,13 +0,0 @@
-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
@@ -1 +0,0 @@
-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/src/conductor/app.js b/src/conductor/app.js
@@ -17,12 +17,13 @@ import { createWorkerTokens } from '../lib/workers.js';
import { createUsers } from '../lib/users.js';
import { createVariables } from '../lib/variables.js';
import { createAuth } from '../lib/auth/index.js';
+import { createRefreshTokens } from '../lib/auth/refresh.js';
import { createScheduler } from './scheduler.js';
import { createRetention } from './retention.js';
import apiRoutes from './routes/api.js';
import { ok } from './routes/envelope.js';
-import uiRoutes from './ui/routes.js';
+import authRoutes from './auth/index.js';
import staticRoutes from '../lib/static.js';
export async function createServices(cfg, options = {}) {
@@ -43,20 +44,21 @@ export async function createServices(cfg, options = {}) {
const workerTokens = createWorkerTokens({ db });
const variables = createVariables({ db, secrets });
const users = createUsers({ db, logger });
+ const refreshTokens = createRefreshTokens({ db });
const auth = createAuth({ cfg, users, logger });
const scheduler = createScheduler({ cfg, db, git, logs, storage, projects, secrets, variables, logger });
const retention = createRetention({ cfg, db, storage, logs, logger });
// Without a first administrator there is no way into the admin surface.
- // Only done for local accounts; with OIDC the provider owns identity.
+ // Only done for local accounts; with a provider it owns identity.
if (options.bootstrap !== false && auth.localLogin) {
await users.bootstrap(cfg);
}
return {
cfg, db, secrets, storage, logs, git,
- projects, workerTokens, variables, users, auth, scheduler, retention, logger,
+ projects, workerTokens, variables, users, auth, refreshTokens, scheduler, retention, logger,
};
}
@@ -82,16 +84,14 @@ export async function buildServer(services, options = {}) {
auth: cfg.auth.mode,
}));
- // The only HTTP surface besides the interface: creating and reading jobs,
- // reading tasks back, the edge workers poll, and artifact downloads.
- // Everything else is the UI.
await fastify.register(apiRoutes, { ...services, prefix: '/api/v1' });
- // Signing in, managing projects and registering workers all happen in the
- // interface; there is no separate management API.
+ // Discovery is answered in both modes, so the app finds its provider the
+ // same way either way.
+ await fastify.register(authRoutes, { ...services });
+
if (options.ui !== false) {
await fastify.register(staticRoutes, {});
- await fastify.register(uiRoutes, { ...services });
}
return fastify;
diff --git a/src/conductor/auth/index.js b/src/conductor/auth/index.js
@@ -0,0 +1,248 @@
+// src/conductor/auth/index.js - the built-in login provider
+//
+// A plain authorization code flow with PKCE, so the app drives the built-in
+// provider and an external one through exactly the same code path.
+
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import crypto from 'node:crypto';
+import { fileURLToPath } from 'node:url';
+import { signToken, verifyToken } from '../../lib/auth/token.js';
+import { newToken, randomId } from '../../lib/ids.js';
+
+const HERE = path.dirname(fileURLToPath(import.meta.url));
+const CODE_TTL = 60;
+
+// The row id is derived from the code, so redeeming a code twice collides on
+// the primary key instead of needing a table of spent codes.
+function sessionIdFor(jti) {
+ return crypto.createHash('sha256').update(`code:${jti}`).digest('hex').slice(0, 32);
+}
+
+function challengeFor(verifier) {
+ return crypto.createHash('sha256').update(verifier).digest('base64url');
+}
+
+function sameOrigin(a, b) {
+ try {
+ return new URL(a).origin === new URL(b).origin;
+ } catch {
+ return false;
+ }
+}
+
+export default async function authRoutes(fastify, { cfg, auth, users, refreshTokens }) {
+ const issuer = auth.issuer;
+
+ fastify.get('/.well-known/openid-configuration', async (req, reply) => {
+ // The document carries no client id of its own, but the app has to learn
+ // one somewhere and this saves it a second request.
+ if (auth.oidc) {
+ const document = await auth.oidc.metadata();
+ return reply.send({ ...document, client_id: auth.clientId, scopes: auth.oidc.scopes });
+ }
+
+ return reply.send({
+ issuer,
+ authorization_endpoint: `${issuer}/auth/authorize`,
+ token_endpoint: `${issuer}/auth/token`,
+ revocation_endpoint: `${issuer}/auth/revoke`,
+ end_session_endpoint: `${issuer}/auth/logout`,
+ jwks_uri: `${issuer}/auth/jwks`,
+ response_types_supported: ['code'],
+ grant_types_supported: ['authorization_code', 'refresh_token'],
+ code_challenge_methods_supported: ['S256'],
+ token_endpoint_auth_methods_supported: ['none'],
+ scopes_supported: ['openid'],
+ client_id: auth.clientId,
+ scopes: 'openid',
+ });
+ });
+
+ if (!auth.localLogin) return;
+
+ const page = await fs.readFile(path.join(HERE, 'login.html'), 'utf8');
+ const style = await fs.readFile(path.join(HERE, 'login.css'), 'utf8');
+ const failedPage = page.replace('id="error" hidden', 'id="error"');
+
+ 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);
+ });
+
+ const allowed = (uri) => cfg.auth.local.redirect_uris.some((entry) => sameOrigin(uri, entry));
+
+ function oauthError(reply, status, error, description) {
+ return reply.code(status).send({ error, error_description: description });
+ }
+
+ // Returns the validated request, or answers and returns null. A bad
+ // redirect_uri is never redirected to, or this would be an open redirect.
+ function authorizationRequest(req, reply) {
+ const { client_id: clientId, redirect_uri: redirectUri, response_type: responseType } = req.query;
+ const { code_challenge: challenge, code_challenge_method: method, state } = req.query;
+
+ if (typeof redirectUri !== 'string' || !allowed(redirectUri)) {
+ reply.code(400).type('text/plain; charset=utf-8')
+ .send('redirect_uri is not registered for this conductor');
+ return null;
+ }
+
+ const back = (error, description) => {
+ const url = new URL(redirectUri);
+ url.searchParams.set('error', error);
+ url.searchParams.set('error_description', description);
+ if (typeof state === 'string') url.searchParams.set('state', state);
+ reply.redirect(url.toString(), 303);
+ return null;
+ };
+
+ if (clientId !== auth.clientId) return back('unauthorized_client', 'unknown client_id');
+ if (responseType !== 'code') return back('unsupported_response_type', 'only code is supported');
+ if (method !== 'S256') return back('invalid_request', 'code_challenge_method must be S256');
+ if (typeof challenge !== 'string' || challenge.length < 43) {
+ return back('invalid_request', 'a code_challenge is required');
+ }
+
+ return { redirectUri, challenge, state };
+ }
+
+ fastify.get('/auth/login.css', async (req, reply) => reply.type('text/css; charset=utf-8').send(style));
+
+ fastify.get('/auth/authorize', async (req, reply) => {
+ if (!authorizationRequest(req, reply)) return reply;
+ return reply.type('text/html; charset=utf-8').send(page);
+ });
+
+ fastify.post('/auth/authorize', async (req, reply) => {
+ const request = authorizationRequest(req, reply);
+ if (!request) return reply;
+
+ const { username, password } = req.body ?? {};
+ const user = await users.authenticate(String(username ?? ''), String(password ?? ''));
+ if (!user) {
+ return reply.code(401).type('text/html; charset=utf-8').send(failedPage);
+ }
+
+ const code = signToken(cfg.auth.session_secret, {
+ use: 'code',
+ iss: issuer,
+ aud: auth.clientId,
+ sub: user.id,
+ ru: request.redirectUri,
+ cc: request.challenge,
+ jti: randomId(16),
+ ttl: CODE_TTL,
+ });
+
+ const url = new URL(request.redirectUri);
+ url.searchParams.set('code', code);
+ if (typeof request.state === 'string') url.searchParams.set('state', request.state);
+ return reply.redirect(url.toString(), 303);
+ });
+
+ fastify.post('/auth/token', async (req, reply) => {
+ const body = req.body ?? {};
+ if (body.client_id !== undefined && body.client_id !== auth.clientId) {
+ return oauthError(reply, 401, 'invalid_client', 'unknown client_id');
+ }
+
+ if (body.grant_type === 'authorization_code') return exchangeCode(req, reply, body);
+ if (body.grant_type === 'refresh_token') return refresh(req, reply, body);
+ return oauthError(reply, 400, 'unsupported_grant_type', 'expected authorization_code or refresh_token');
+ });
+
+ async function exchangeCode(req, reply, body) {
+ const claims = verifyToken(cfg.auth.session_secret, body.code, {
+ use: 'code',
+ iss: issuer,
+ aud: auth.clientId,
+ });
+ if (!claims) return oauthError(reply, 400, 'invalid_grant', 'the code is unknown or expired');
+ if (claims.ru !== body.redirect_uri) {
+ return oauthError(reply, 400, 'invalid_grant', 'redirect_uri does not match the one the code was issued for');
+ }
+
+ const verifier = typeof body.code_verifier === 'string' ? body.code_verifier : '';
+ const given = Buffer.from(challengeFor(verifier));
+ const want = Buffer.from(String(claims.cc));
+ if (given.length !== want.length || !crypto.timingSafeEqual(given, want)) {
+ return oauthError(reply, 400, 'invalid_grant', 'the code_verifier does not match the challenge');
+ }
+
+ const user = await users.get(claims.sub);
+ if (!user || user.disabled === 1) return oauthError(reply, 400, 'invalid_grant', 'the account is unavailable');
+
+ const now = Date.now();
+ const token = newToken();
+ try {
+ await refreshTokens.issue({
+ id: sessionIdFor(claims.jti),
+ userId: user.id,
+ token,
+ expiresAt: now + cfg.auth.refresh_ttl * 1000,
+ userAgent: req.headers['user-agent'] ?? null,
+ ip: req.ip,
+ now,
+ });
+ } catch {
+ return oauthError(reply, 400, 'invalid_grant', 'the code has already been redeemed');
+ }
+
+ return reply.send(grant(user, token, now));
+ }
+
+ async function refresh(req, reply, body) {
+ const now = Date.now();
+ const row = await refreshTokens.claim(body.refresh_token, { now });
+ if (!row) return oauthError(reply, 400, 'invalid_grant', 'the refresh token is unknown or expired');
+
+ const user = await users.get(row.user_id);
+ if (!user || user.disabled === 1) return oauthError(reply, 400, 'invalid_grant', 'the account is unavailable');
+
+ const alive = await refreshTokens.touch({
+ id: row.id,
+ expiresAt: cfg.auth.refresh_sliding ? now + cfg.auth.refresh_ttl * 1000 : null,
+ userAgent: req.headers['user-agent'] ?? null,
+ ip: req.ip,
+ now,
+ });
+ if (!alive) return oauthError(reply, 400, 'invalid_grant', 'the session was revoked');
+
+ return reply.send(grant(user, body.refresh_token, now));
+ }
+
+ function grant(user, refreshToken, now) {
+ return {
+ access_token: auth.accessToken(user, { now }),
+ refresh_token: refreshToken,
+ token_type: 'Bearer',
+ expires_in: cfg.auth.access_ttl,
+ };
+ }
+
+ // RFC 7009: revoking an unknown token is a success, so nothing can be
+ // learned by presenting one.
+ fastify.post('/auth/revoke', async (req, reply) => {
+ const row = await refreshTokens.claim(req.body?.token);
+ if (row) await refreshTokens.revoke(row.id);
+ return reply.code(200).send();
+ });
+
+ fastify.get('/auth/logout', async (req, reply) => {
+ const target = req.query.post_logout_redirect_uri;
+ if (typeof target === 'string' && target.length > 0) {
+ if (!allowed(target)) {
+ return reply.code(400).type('text/plain; charset=utf-8')
+ .send('post_logout_redirect_uri is not registered for this conductor');
+ }
+ return reply.redirect(target, 303);
+ }
+ return reply.redirect(issuer, 303);
+ });
+
+ // The built-in tokens are HS256 and nothing outside this conductor verifies
+ // them, so the set is empty. It is advertised for shape.
+ fastify.get('/auth/jwks', async (req, reply) => reply.send({ keys: [] }));
+}
diff --git a/src/conductor/auth/login.css b/src/conductor/auth/login.css
@@ -0,0 +1,92 @@
+/* src/conductor/auth/login.css - the built-in login page */
+
+:root {
+ color-scheme: light dark;
+ --bg: #f6f7f9;
+ --fg: #1c1f23;
+ --card: #ffffff;
+ --line: #d6dae0;
+ --accent: #2f6feb;
+ --bad: #b42318;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --bg: #16181c;
+ --fg: #e7e9ea;
+ --card: #1e2126;
+ --line: #333940;
+ --accent: #4d8bf5;
+ --bad: #f97066;
+ }
+}
+
+body {
+ margin: 0;
+ min-height: 100vh;
+ display: grid;
+ place-items: center;
+ background: var(--bg);
+ color: var(--fg);
+ font: 15px/1.5 system-ui, sans-serif;
+}
+
+main {
+ width: min(22rem, calc(100vw - 2rem));
+ padding: 2rem;
+ background: var(--card);
+ border: 1px solid var(--line);
+ border-radius: 10px;
+}
+
+h1 {
+ margin: 0 0 1.5rem;
+ font-size: 1.25rem;
+}
+
+label {
+ display: block;
+ margin-bottom: 0.35rem;
+ font-weight: 500;
+}
+
+input {
+ width: 100%;
+ box-sizing: border-box;
+ margin-bottom: 1rem;
+ padding: 0.55rem 0.7rem;
+ background: var(--bg);
+ color: inherit;
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ font: inherit;
+}
+
+input:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 1px;
+}
+
+button {
+ width: 100%;
+ padding: 0.6rem;
+ background: var(--accent);
+ color: #fff;
+ border: 0;
+ border-radius: 6px;
+ font: inherit;
+ font-weight: 600;
+ cursor: pointer;
+}
+
+.error {
+ margin: 0 0 1rem;
+ padding: 0.6rem 0.75rem;
+ border: 1px solid var(--bad);
+ border-radius: 6px;
+ color: var(--bad);
+}
+
+[hidden] {
+ display: none;
+}
diff --git a/src/conductor/auth/login.html b/src/conductor/auth/login.html
@@ -0,0 +1,25 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>Sign in</title>
+ <link rel="stylesheet" href="/auth/login.css">
+ </head>
+ <body>
+ <main>
+ <h1>Sign in</h1>
+ <p class="error" id="error" hidden>That username and password did not match.</p>
+ <!-- No action, so the authorization parameters stay in the query string. -->
+ <form method="post">
+ <label for="username">Username</label>
+ <input id="username" name="username" autocomplete="username" autofocus required>
+
+ <label for="password">Password</label>
+ <input id="password" name="password" type="password" autocomplete="current-password" required>
+
+ <button type="submit">Sign in</button>
+ </form>
+ </main>
+ </body>
+</html>
diff --git a/src/conductor/routes/agent.js b/src/conductor/routes/agent.js
@@ -0,0 +1,324 @@
+// src/conductor/routes/agent.js - the API workers poll and report to
+//
+// Named for who calls it rather than what it addresses. The paths are under
+// /api/v1/tasks because a task is what a worker deals in, but everything in
+// this file is the agent protocol, and routes/tasks.js next door is the
+// task resource as anyone else reads it.
+//
+// Every route here requires a worker token: the preHandler below covers the
+// whole plugin, so nothing can be added to this file that is not behind it.
+// A worker may only touch a task it currently holds, which is checked
+// against worker_token_id rather than trusting the task id it sends.
+//
+// A worker is handed a context and a script and nothing else. It is never
+// told which project or job a task belongs to, and the task id it receives
+// carries no structure it could read that out of. The identifiers a build
+// legitimately wants are in the task's environment, where the worker treats
+// them as opaque strings on their way into the container.
+
+import { pipeline as streamPipeline } from 'node:stream/promises';
+import { LogOffsetError } from '../../lib/log.js';
+import { hashingTransform } from '../../lib/stream.js';
+import { sanitizeRelativePath, keys as storageKeys } from '../../lib/storage/index.js';
+import { maskBuffer } from '../../lib/variables.js';
+import { readOutputList } from '../../lib/outputs.js';
+import { newArtifactId } from '../../lib/ids.js';
+import { ok } from './envelope.js';
+import {
+ ArtifactSizeMismatch, ContentLengthRequired, InvalidArtifactPath, InvalidLogOffset,
+ InvalidWorkerToken, LogChunkTooLarge, LogOffsetConflict, TaskNotHeld, TaskNotRunning,
+ UnknownProject, UnknownTask,
+} from '../../lib/errors.js';
+
+// Log chunks are small and frequent; artifacts are streamed, so this only
+// bounds a single log append.
+const LOG_CHUNK_LIMIT = 1024 * 1024;
+
+const COMPLETE_FAILURES = {
+ 'unknown-task': UnknownTask,
+ 'not-held': TaskNotHeld,
+ 'not-running': TaskNotRunning,
+};
+
+const TASK_OUTCOMES = ['success', 'failed', 'skipped'];
+
+export default async function agentRoutes(fastify, { cfg, db, git, logs, storage, projects, scheduler, workerTokens, variables }) {
+ // Raw bodies: log chunks and artifacts arrive as octet streams and must
+ // not be parsed.
+ fastify.addContentTypeParser('application/octet-stream', (req, payload, done) => done(null, payload));
+
+ async function requireWorker(req) {
+ const header = req.headers.authorization || '';
+ const presented = header.startsWith('Bearer ') ? header.slice(7).trim() : '';
+ const token = await workerTokens.verify(presented, { ip: req.ip });
+ if (!token) throw new InvalidWorkerToken();
+ req.worker = token;
+ }
+
+ // Confirms the task exists and is held by the calling worker.
+ async function heldTask(req) {
+ const task = await db.get(
+ `SELECT t.id, t.job_id, t.name, t.state, t.worker_token_id, t.log_size, t.spec,
+ j.project_id, j.head_sha
+ FROM tasks t JOIN jobs j ON j.id = t.job_id
+ WHERE t.id = {id}`,
+ { id: req.params.task }
+ );
+ if (!task) throw new UnknownTask();
+ if (task.worker_token_id !== req.worker.id) throw new TaskNotHeld();
+ return task;
+ }
+
+ fastify.addHook('preHandler', requireWorker);
+
+ // Ask for work. The worker describes what it can run; the scheduler
+ // decides. Returns 204 when there is nothing to do.
+ fastify.post('/tasks/claim', async (req, reply) => {
+ const body = req.body ?? {};
+ const arches = splitList(body.arches);
+ const features = splitList(body.features);
+ const name = typeof body.name === 'string' ? body.name.slice(0, 255) : req.worker.name;
+
+ // Recorded even when idle, and never allowed to fail a claim.
+ await workerTokens
+ .recordAgent({ tokenId: req.worker.id, name, arches, features, ip: req.ip })
+ .catch((e) => req.log.warn(`could not record worker agent: ${e.message}`));
+
+ const task = await scheduler.claim({
+ arches,
+ 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 (!task) return reply.code(204).send();
+
+ const base = cfg.server.public_url.replace(/\/+$/, '');
+ const at = (suffix) => `${base}/api/v1/tasks/${task.id}/${suffix}`;
+
+ return ok(reply, {
+ task: {
+ id: task.id,
+ name: task.name,
+ arch: task.arch,
+ image: task.image,
+ // The worker maps these onto its own local mounts, environment and
+ // privileges. Without them no feature is applied at all.
+ requires: task.requires,
+ script: task.script,
+ env: task.env,
+ services: task.services,
+ artifacts: task.artifacts,
+ // Where the tree is unpacked and the script runs. Resolved when
+ // the job was created, so it does not shift under a retry.
+ workdir: task.workdir,
+ timeout: task.timeout,
+ attempt: task.attempt,
+ sha: task.sha,
+ ref: task.ref,
+ // Values the worker must redact from the log stream. Populated
+ // once project variables exist; masking also happens on ingest.
+ masked: task.masked ?? [],
+ // Derived from the server's own timeout so a worker never has to
+ // guess how often it is expected to check in.
+ heartbeat_interval: Math.max(5, Math.floor(cfg.scheduler.heartbeat_timeout / 4)),
+ endpoints: {
+ // The tree comes from the conductor, so a worker never needs
+ // credentials for the repository and cannot reach any commit
+ // other than the one it was given work for.
+ source: at('source.tar.gz'),
+ log: at('log'),
+ artifact: at('artifacts'),
+ heartbeat: at('heartbeat'),
+ done: at('complete'),
+ },
+ },
+ });
+ });
+
+ // The tree at the task's commit, as a gzipped tar.
+ fastify.get('/tasks/:task/source.tar.gz', async (req, reply) => {
+ const task = await heldTask(req);
+
+ const project = await projects.get(task.project_id);
+ if (!project) throw new UnknownProject();
+
+ // The tree rooted at the archive, with no wrapping directory: a
+ // worker extracts it at whatever path the task is to run in, and
+ // docker creates that path on the way.
+ reply.header('content-type', 'application/gzip');
+ reply.header('content-disposition', `attachment; filename="${task.head_sha.slice(0, 12)}.tar.gz"`);
+ return reply.send(git.archiveStream(project, task.head_sha));
+ });
+
+ // Append to the live log. X-Log-Offset makes a retry after a dropped
+ // connection safe.
+ fastify.post('/tasks/:task/log', { bodyLimit: LOG_CHUNK_LIMIT }, async (req, reply) => {
+ const task = await heldTask(req);
+
+ const chunks = [];
+ let total = 0;
+ for await (const chunk of req.body) {
+ total += chunk.length;
+ if (total > LOG_CHUNK_LIMIT) throw new LogChunkTooLarge();
+ chunks.push(chunk);
+ }
+
+ const rawOffset = req.headers['x-log-offset'];
+ const offset = rawOffset === undefined ? undefined : Number(rawOffset);
+ if (offset !== undefined && !Number.isInteger(offset)) throw new InvalidLogOffset();
+
+ // The worker masks before sending; this repeats it on ingest so a
+ // worker that does not still cannot write a secret to disk. It works
+ // within a chunk only, which is why the worker holds back a boundary.
+ let payload = Buffer.concat(chunks);
+ if (variables) {
+ try {
+ payload = maskBuffer(payload, await variables.maskedValues(task.project_id));
+ } catch (e) {
+ req.log.warn({ err: e }, `could not mask log output for ${task.id}`);
+ }
+ }
+
+ try {
+ const result = await logs.append(task.job_id, task.id, payload, offset);
+ await db.run('UPDATE tasks SET log_size = {size}, heartbeat_at = {now} WHERE id = {id}',
+ { id: task.id, size: result.size, now: Date.now() });
+ return ok(reply, result);
+ } catch (e) {
+ // Tell the worker where to resume from.
+ if (e instanceof LogOffsetError) {
+ throw new LogOffsetConflict(e.message, { details: { expected_offset: e.expected } });
+ }
+ throw e;
+ }
+ });
+
+ // artifacts.expire in the pipeline, as an absolute time. Returns null
+ // when the task said nothing, leaving the artifact to the project policy.
+ function artifactExpiry(specJson, now) {
+ if (!specJson) return null;
+ try {
+ const spec = typeof specJson === 'string' ? JSON.parse(specJson) : specJson;
+ const seconds = spec?.artifacts?.expire;
+ if (!Number.isFinite(seconds) || seconds <= 0) return null;
+ return now + Math.round(seconds * 1000);
+ } catch {
+ // A spec that will not parse is a problem, but not this request's
+ // problem: the artifact is already stored.
+ return null;
+ }
+ }
+
+ // Upload one artifact. The path is worker supplied, so it is sanitized
+ // before it can influence a storage key.
+ fastify.post('/tasks/:task/artifacts', async (req, reply) => {
+ const task = await heldTask(req);
+
+ const relPath = sanitizeRelativePath(req.headers['x-artifact-path']);
+ if (!relPath) throw new InvalidArtifactPath();
+
+ const declared = Number(req.headers['content-length']);
+ if (!Number.isInteger(declared) || declared < 0) throw new ContentLengthRequired();
+
+ // Hash on the way past, so the digest costs nothing extra even when the
+ // backend cannot report one.
+ const hasher = hashingTransform();
+ const key = storageKeys.artifact(task.job_id, task.id, relPath);
+
+ let stored;
+ try {
+ [, stored] = await Promise.all([
+ streamPipeline(req.body, hasher),
+ storage.put(key, hasher, { size: declared }),
+ ]);
+ } catch (e) {
+ // A body shorter or longer than content-length is the worker's
+ // mistake, not a server fault.
+ if (/size mismatch/.test(e.message)) throw new ArtifactSizeMismatch(e.message);
+ throw e;
+ }
+
+ const digest = hasher.digest();
+ const now = Date.now();
+
+ await db.run(
+ `INSERT INTO artifacts (id, task_id, job_id, path, storage_key, size, sha256, created_at, expires_at)
+ VALUES ({id}, {task}, {job}, {path}, {key}, {size}, {sha}, {now}, {expires})`,
+ {
+ id: newArtifactId(),
+ task: task.id,
+ job: task.job_id,
+ path: relPath,
+ key,
+ size: stored.size ?? declared,
+ sha: digest,
+ now,
+ // An explicit deadline from the pipeline. Absent, the project's
+ // retention policy decides, which is the usual case.
+ expires: artifactExpiry(task.spec, now),
+ }
+ );
+
+ return ok(reply, { path: relPath, size: stored.size ?? declared, sha256: digest });
+ });
+
+ // Keeps the task alive, and is how a worker learns it should stop.
+ fastify.post('/tasks/:task/heartbeat', async (req, reply) => {
+ const result = await scheduler.heartbeat(req.params.task, req.worker.id);
+ if (!result.known) throw new UnknownTask('No such task for this worker');
+ return ok(reply, { cancelled: result.cancelled, state: result.state });
+ });
+
+ // Report the outcome. The log is copied to storage here, whatever the
+ // result, so a failed task keeps its output.
+ fastify.post('/tasks/:task/complete', async (req, reply) => {
+ const task = await heldTask(req);
+
+ const body = req.body ?? {};
+ const success = body.success === true;
+ // A worker too old to send a state still says whether it succeeded.
+ const state = TASK_OUTCOMES.includes(body.state)
+ ? body.state
+ : (success ? 'success' : 'failed');
+ const exitCode = Number.isInteger(body.exit_code) ? body.exit_code : null;
+ const error = typeof body.error === 'string' ? body.error.slice(0, 4096) : null;
+
+ const outcome = await scheduler.complete(task.id, {
+ state,
+ exitCode,
+ error,
+ outputs: readOutputList(body.outputs),
+ tokenId: req.worker.id,
+ });
+ // All 409: by this point heldTask has already answered for a task that
+ // does not exist or is not ours, so these are races rather than mistakes.
+ if (!outcome.ok) {
+ const Failure = COMPLETE_FAILURES[outcome.cause] ?? TaskNotRunning;
+ throw new Failure(outcome.reason, { code: 409 });
+ }
+
+ // A retry keeps writing to the same log, so only archive it once the
+ // task has actually stopped.
+ if (outcome.state !== 'queued') {
+ try {
+ await scheduler.finalizeLog(task.job_id, task.id);
+ } catch (e) {
+ req.log.error({ err: e }, `failed to archive log for ${task.id}`);
+ }
+ }
+
+ return ok(reply, {
+ state: outcome.state,
+ retry: outcome.retry === true,
+ skipped: outcome.skipped ?? [],
+ job_state: outcome.jobState ?? null,
+ });
+ });
+}
+
+function splitList(value) {
+ const items = Array.isArray(value) ? value : typeof value === 'string' ? value.split(',') : [];
+ return items.map((s) => String(s).trim()).filter(Boolean).slice(0, 64);
+}
diff --git a/src/conductor/routes/api.js b/src/conductor/routes/api.js
@@ -1,13 +1,17 @@
// src/conductor/routes/api.js - everything served under /api/v1
//
// Fastify allows one not-found handler per prefix, so something has to own
-// the scope these four share. workerRoutes and taskRoutes overlap on
+// the scope these share. workerRoutes and taskRoutes overlap on
// /api/v1/tasks deliberately: the worker token hook covers its own plugin
// entirely, and reading a task back is not behind it.
+import agentRoutes from './agent.js';
import artifactRoutes from './artifacts.js';
import jobRoutes from './jobs.js';
+import projectRoutes from './projects.js';
+import sessionRoutes from './session.js';
import taskRoutes from './tasks.js';
+import userRoutes from './users.js';
import workerRoutes from './workers.js';
import { envelopeFailures } from './envelope.js';
@@ -17,7 +21,11 @@ export default async function apiRoutes(fastify, { prefix, ...services }) {
envelopeFailures(fastify);
await fastify.register(jobRoutes, services);
- await fastify.register(workerRoutes, services);
+ await fastify.register(agentRoutes, services);
await fastify.register(taskRoutes, services);
await fastify.register(artifactRoutes, services);
+ await fastify.register(sessionRoutes, services);
+ await fastify.register(projectRoutes, services);
+ await fastify.register(workerRoutes, services);
+ await fastify.register(userRoutes, services);
}
diff --git a/src/conductor/routes/guard.js b/src/conductor/routes/guard.js
@@ -0,0 +1,34 @@
+// src/conductor/routes/guard.js - who is asking, and what they may see
+
+import { NotAuthenticated, NotPermitted } from '../../lib/errors.js';
+
+// Anonymous is not an error: public projects read without signing in.
+export function identifies(fastify, auth) {
+ fastify.addHook('preHandler', async (req) => {
+ req.user = auth ? await auth.identify(req) : null;
+ });
+}
+
+export function requireUser(req) {
+ if (!req.user) throw new NotAuthenticated();
+ return req.user;
+}
+
+export function requireAdmin(req) {
+ const user = requireUser(req);
+ if (user.role !== 'admin') throw new NotPermitted();
+ return user;
+}
+
+// In SQL rather than in javascript, so a count and its rows cannot disagree.
+export function visible(user, { project = 'p', job = null } = {}) {
+ const column = job ? `${job}.visibility` : `${project}.visibility`;
+ if (user?.role === 'admin') return { sql: '1 = 1', params: {} };
+ if (user) {
+ return {
+ sql: `(${column} = 'public' OR ${project}.owner_id = {viewerId})`,
+ params: { viewerId: user.id },
+ };
+ }
+ return { sql: `${column} = 'public'`, params: {} };
+}
diff --git a/src/conductor/routes/jobs.js b/src/conductor/routes/jobs.js
@@ -22,9 +22,10 @@ import { SHA_PATTERN } from '../../lib/git.js';
import { canManageProject } from '../../lib/projects.js';
import { verifySignature } from '../../lib/signature.js';
import { ok } from './envelope.js';
+import { visible } from './guard.js';
import {
- InvalidSha, InvalidSignature, MalformedJson, NoCommitInPayload,
- ProjectDisabled, ShaRequired, UnknownJob, UnknownProject,
+ Conflict, InvalidSha, InvalidSignature, MalformedJson, NoCommitInPayload,
+ NotPermitted, ProjectDisabled, ShaRequired, UnknownJob, UnknownProject,
} from '../../lib/errors.js';
import { limitOf, offsetOfQuery, pageBlock, paginateOffset } from '../../lib/page.js';
@@ -113,32 +114,80 @@ export default async function jobRoutes(fastify, { cfg, auth, db, projects, sche
});
});
- // Read one back. A caller that can start a job should be able to see how
- // it went, and scraping the interface for that is no kind of answer.
- //
- // Three ways to be allowed: the job is public, the caller holds the
- // project's trigger secret, or the caller is signed in and may manage the
- // project. Refusal is a 404 either way, so this cannot be used to probe
- // which jobs exist.
- fastify.get('/projects/:project/jobs/:job', async (req, reply) => {
+ const JOB_COLUMNS = `j.id, j.project_id, j.number, j.ref, j.base_sha, j.head_sha,
+ j.trigger_type, j.actor, j.title, j.state, j.visibility, j.error,
+ j.created_at, j.started_at, j.finished_at`;
+
+ // Holding the trigger secret is a third way in, so whatever starts a job
+ // can see how it went without also being a signed in user.
+ async function mayRead(req, project) {
+ const secret = projects.triggerSecret(project);
+ if (secret && verifySignature(req, secret)) return true;
+
+ const user = await auth.identify(req).catch(() => null);
+ return canManageProject(user, project);
+ }
+
+ fastify.get('/projects/:project/jobs', async (req, reply) => {
const project = await projects.get(req.params.project);
- if (!project) throw new UnknownJob();
+ if (!project) throw new UnknownProject();
+
+ const user = await auth.identify(req).catch(() => null);
+ const scope = await mayRead(req, project)
+ ? { sql: '1 = 1', params: {} }
+ : visible(user, { project: 'p', job: 'j' });
+
+ const filters = [scope.sql, 'j.project_id = {project}'];
+ const params = { ...scope.params, project: project.id };
+ if (typeof req.query.state === 'string') {
+ filters.push('j.state = {state}');
+ params.state = req.query.state;
+ }
+
+ const window = await paginateOffset(db, {
+ select: JOB_COLUMNS,
+ from: 'jobs j JOIN projects p ON p.id = j.project_id',
+ where: filters.join(' AND '),
+ order: 'j.created_at DESC',
+ params,
+ offset: offsetOfQuery(req.query),
+ limit: limitOf(req.query),
+ });
+
+ return ok(reply, window.rows, pageBlock({ ...window, url: base + req.url }));
+ });
+ // A job id is unique on its own, so it is addressable with or without its
+ // project. Spelling the project out asserts it rather than decorating it.
+ function both(method, suffix, handler) {
+ fastify[method](`/jobs/:job${suffix}`, handler);
+ fastify[method](`/projects/:project/jobs/:job${suffix}`, handler);
+ }
+
+ // Missing rather than refused, so an id cannot be probed by status code.
+ async function readableJob(req) {
+ const user = await auth.identify(req).catch(() => null);
const job = await db.get(
- `SELECT id, project_id, number, ref, head_sha, trigger_type, actor, title,
- state, visibility, error, created_at, started_at, finished_at
- FROM jobs
- WHERE id = {id} AND project_id = {project}`,
- { id: req.params.job, project: project.id }
+ `SELECT ${JOB_COLUMNS} FROM jobs j WHERE j.id = {id}`,
+ { id: req.params.job }
);
if (!job) throw new UnknownJob();
+ if (req.params.project !== undefined && req.params.project !== job.project_id) throw new UnknownJob();
- if (job.visibility !== 'public' && !(await mayRead(req, project))) {
- throw new UnknownJob();
- }
+ const project = await projects.get(job.project_id);
+ if (job.visibility !== 'public' && !(await mayRead(req, project))) throw new UnknownJob();
+ return { job, project, user };
+ }
+
+ both('get', '', async (req, reply) => ok(reply, (await readableJob(req)).job));
+
+ // The edges come with the tasks: the stage graph cannot be drawn without
+ // them, and nothing else exposes task_deps.
+ both('get', '/tasks', async (req, reply) => {
+ const { job } = await readableJob(req);
const window = await paginateOffset(db, {
- select: `id, name, state, arch, attempt, exit_code, error,
+ select: `id, name, base_name, state, arch, attempt, exit_code, error,
worker_name, created_at, started_at, finished_at`,
from: 'tasks',
where: 'job_id = {job}',
@@ -148,16 +197,42 @@ export default async function jobRoutes(fastify, { cfg, auth, db, projects, sche
limit: limitOf(req.query),
});
- return ok(reply, { job, tasks: window.rows }, pageBlock({ ...window, url: base + req.url }));
- });
+ const deps = await db.all(
+ `SELECT d.task_id, d.depends_on_id, d.kind
+ FROM task_deps d JOIN tasks t ON t.id = d.task_id
+ WHERE t.job_id = {job}`,
+ { job: job.id }
+ );
- async function mayRead(req, project) {
- const secret = projects.triggerSecret(project);
- if (secret && verifySignature(req, secret)) return true;
+ return ok(reply, { tasks: window.rows, deps }, pageBlock({ ...window, url: base + req.url }));
+ });
- const user = await auth.identify(req).catch(() => null);
- return canManageProject(user, project);
+ async function manageableJob(req) {
+ const { job, project, user } = await readableJob(req);
+ if (!canManageProject(user, project)) throw new NotPermitted();
+ return { job, project, user };
}
+
+ both('post', '/cancel', async (req, reply) => {
+ const { job, user } = await manageableJob(req);
+ const outcome = await scheduler.cancelJob(job.id, `cancelled by ${user.username}`);
+ if (!outcome.ok) throw new Conflict(outcome.reason);
+ return ok(reply, { id: job.id, cancelled: true });
+ });
+
+ // A retry is a new job at the same commit, so the original stays readable.
+ both('post', '/retry', async (req, reply) => {
+ const { job, project, user } = await manageableJob(req);
+
+ const created = await scheduler.createJob(project, {
+ ref: job.ref,
+ baseSha: job.base_sha,
+ headSha: job.head_sha,
+ trigger: 'manual',
+ actor: user.username,
+ });
+ return ok(reply, { status: 'created', job_id: created.jobId });
+ });
}
// Reduces the supported payload shapes to { sha, base, ref, actor }.
diff --git a/src/conductor/routes/projects.js b/src/conductor/routes/projects.js
@@ -0,0 +1,130 @@
+// src/conductor/routes/projects.js - managing projects and their variables
+
+import { ok } from './envelope.js';
+import { identifies, requireUser, requireAdmin } from './guard.js';
+import { BadRequest, Conflict, NotFound, NotPermitted } from '../../lib/errors.js';
+import { canManageProject, canViewProject, VISIBILITIES } from '../../lib/projects.js';
+import { limitOf, offsetOfQuery, pageBlock } from '../../lib/page.js';
+import { newToken } from '../../lib/ids.js';
+
+const RETENTION = ['artifact_keep_jobs', 'artifact_keep_days', 'log_keep_days'];
+
+function present(project) {
+ const { trigger_secret: secret, ...rest } = project;
+ return { ...rest, has_trigger_secret: secret !== null };
+}
+
+export default async function projectRoutes(fastify, { cfg, auth, projects, variables }) {
+ const base = cfg.server.public_url.replace(/\/+$/, '');
+ identifies(fastify, auth);
+
+ // Missing rather than refused, so an id cannot be probed by status code.
+ async function readable(req) {
+ const project = await projects.get(req.params.id);
+ if (!project || !canViewProject(req.user, project)) throw new NotFound();
+ return project;
+ }
+
+ async function manageable(req) {
+ const project = await projects.get(req.params.id);
+ if (!project || !canViewProject(req.user, project)) throw new NotFound();
+ if (!canManageProject(req.user, project)) throw new NotPermitted();
+ return project;
+ }
+
+ fastify.get('/projects', async (req, reply) => {
+ const window = await projects.pageVisible(req.user, {
+ offset: offsetOfQuery(req.query),
+ limit: limitOf(req.query),
+ });
+ return ok(reply, window.rows.map(present), pageBlock({ ...window, url: base + req.url }));
+ });
+
+ // The trigger secret is shown here and never again.
+ fastify.post('/projects', async (req, reply) => {
+ const user = requireUser(req);
+ const body = req.body ?? {};
+
+ const owner = user.role === 'admin' && Object.hasOwn(body, 'owner_id') ? body.owner_id : user.id;
+ const secret = body.trigger_secret === null ? null : (body.trigger_secret || newToken());
+
+ let project;
+ try {
+ project = await projects.create({ ...body, owner_id: owner, trigger_secret: secret });
+ } catch (e) {
+ throw /already exists/.test(e.message) ? new Conflict(e.message) : new BadRequest(e.message);
+ }
+
+ return reply.code(201).send({ ok: true, data: { ...present(project), trigger_secret: secret } });
+ });
+
+ fastify.get('/projects/:id', async (req, reply) => ok(reply, present(await readable(req))));
+
+ fastify.patch('/projects/:id', async (req, reply) => {
+ const project = await manageable(req);
+ const body = req.body ?? {};
+
+ try {
+ if (Object.hasOwn(body, 'visibility')) {
+ if (!VISIBILITIES.includes(body.visibility)) {
+ throw new BadRequest(`visibility must be one of ${VISIBILITIES.join(', ')}`);
+ }
+ await projects.setVisibility(project.id, body.visibility);
+ }
+ if (Object.hasOwn(body, 'enabled')) await projects.setEnabled(project.id, Boolean(body.enabled));
+ if (Object.hasOwn(body, 'workdir')) await projects.setWorkdir(project.id, body.workdir);
+
+ // Null follows the server default, zero keeps forever. Not the same.
+ const retention = {};
+ for (const key of RETENTION) if (Object.hasOwn(body, key)) retention[key] = body[key];
+ if (Object.keys(retention).length > 0) await projects.setRetention(project.id, retention);
+
+ if (Object.hasOwn(body, 'owner_id')) {
+ requireAdmin(req);
+ await projects.setOwner(project.id, body.owner_id);
+ }
+ } catch (e) {
+ if (e.name === 'BadRequest' || e.name === 'NotPermitted') throw e;
+ throw new BadRequest(e.message);
+ }
+
+ return ok(reply, present(await projects.get(project.id)));
+ });
+
+ fastify.delete('/projects/:id', async (req, reply) => {
+ const project = await manageable(req);
+ await projects.remove(project.id);
+ return ok(reply, { id: project.id, removed: true });
+ });
+
+ fastify.post('/projects/:id/trigger-secret', async (req, reply) => {
+ const project = await manageable(req);
+ const secret = newToken();
+ await projects.setTriggerSecret(project.id, secret);
+ return ok(reply, { id: project.id, trigger_secret: secret });
+ });
+
+ fastify.get('/projects/:id/variables', async (req, reply) => {
+ const project = await manageable(req);
+ return ok(reply, await variables.list(project.id));
+ });
+
+ fastify.put('/projects/:id/variables/:name', async (req, reply) => {
+ const project = await manageable(req);
+ const body = req.body ?? {};
+ try {
+ const saved = await variables.set(project.id, req.params.name, body.value, {
+ masked: body.masked !== false,
+ });
+ return ok(reply, saved);
+ } catch (e) {
+ throw new BadRequest(e.message);
+ }
+ });
+
+ fastify.delete('/projects/:id/variables/:name', async (req, reply) => {
+ const project = await manageable(req);
+ if (!await variables.remove(project.id, req.params.name)) throw new NotFound();
+ return ok(reply, { name: req.params.name, removed: true });
+ });
+}
diff --git a/src/conductor/routes/session.js b/src/conductor/routes/session.js
@@ -0,0 +1,31 @@
+// src/conductor/routes/session.js - who am I, and where am I signed in
+
+import { ok } from './envelope.js';
+import { identifies, requireUser } from './guard.js';
+import { NotFound } from '../../lib/errors.js';
+import { limitOf, offsetOfQuery, pageBlock } from '../../lib/page.js';
+
+export default async function sessionRoutes(fastify, { cfg, auth, refreshTokens }) {
+ const base = cfg.server.public_url.replace(/\/+$/, '');
+ identifies(fastify, auth);
+
+ fastify.get('/sessions', async (req, reply) => {
+ const user = requireUser(req);
+ const window = await refreshTokens.page({
+ userId: user.role === 'admin' ? null : user.id,
+ offset: offsetOfQuery(req.query),
+ limit: limitOf(req.query),
+ });
+
+ return ok(reply, window.rows, pageBlock({ ...window, url: base + req.url }));
+ });
+
+ fastify.delete('/sessions/:id', async (req, reply) => {
+ const user = requireUser(req);
+ const row = await refreshTokens.get(req.params.id);
+ if (!row || (user.role !== 'admin' && row.user_id !== user.id)) throw new NotFound();
+
+ await refreshTokens.revoke(row.id);
+ return ok(reply, { id: row.id, revoked: true });
+ });
+}
diff --git a/src/conductor/routes/users.js b/src/conductor/routes/users.js
@@ -0,0 +1,108 @@
+// src/conductor/routes/users.js - accounts
+
+import { ok } from './envelope.js';
+import { identifies, requireUser, requireAdmin } from './guard.js';
+import { BadRequest, Conflict, NotFound, NotPermitted } from '../../lib/errors.js';
+import { validatePassword } from '../../lib/auth/password.js';
+import { ROLES } from '../../lib/users.js';
+import { limitOf, offsetOfQuery, pageBlock } from '../../lib/page.js';
+
+const present = (user) => ({ ...user, source: user.external_id ? 'oidc' : 'local' });
+
+export default async function userRoutes(fastify, { cfg, auth, users, refreshTokens }) {
+ const base = cfg.server.public_url.replace(/\/+$/, '');
+ identifies(fastify, auth);
+
+ // "me" is an ordinary id that happens to resolve to the caller.
+ function idIn(req, caller) {
+ return req.params.id === 'me' ? caller.id : req.params.id;
+ }
+
+ async function reachable(req) {
+ const caller = requireUser(req);
+ const id = idIn(req, caller);
+ if (caller.role !== 'admin' && id !== caller.id) throw new NotFound();
+
+ const user = await users.get(id);
+ if (!user) throw new NotFound();
+ return { caller, user };
+ }
+
+ fastify.get('/users', async (req, reply) => {
+ const caller = requireUser(req);
+ if (caller.role !== 'admin') {
+ return ok(reply, [present(await users.get(caller.id))], { offset: 0, total: 1 });
+ }
+
+ const window = await users.pageWithHoldings({
+ offset: offsetOfQuery(req.query),
+ limit: limitOf(req.query),
+ });
+ return ok(reply, window.rows.map(present), pageBlock({ ...window, url: base + req.url }));
+ });
+
+ fastify.post('/users', async (req, reply) => {
+ requireAdmin(req);
+ if (!auth.localLogin) throw new NotPermitted('accounts come from the identity provider');
+
+ const { username, password, role } = req.body ?? {};
+ const bad = validatePassword(String(password ?? ''));
+ if (bad) throw new BadRequest(bad);
+ if (role !== undefined && !ROLES.includes(role)) {
+ throw new BadRequest(`role must be one of ${ROLES.join(', ')}`);
+ }
+
+ try {
+ return reply.code(201).send({ ok: true, data: await users.create({ username, password, role }) });
+ } catch (e) {
+ throw /already (exists|taken)|UNIQUE/i.test(e.message) ? new Conflict(e.message) : new BadRequest(e.message);
+ }
+ });
+
+ fastify.get('/users/:id', async (req, reply) => ok(reply, present((await reachable(req)).user)));
+
+ fastify.patch('/users/:id', async (req, reply) => {
+ const { caller, user } = await reachable(req);
+ const body = req.body ?? {};
+
+ // Not on themselves, so the last way in cannot be locked by accident.
+ for (const key of ['role', 'disabled']) {
+ if (!Object.hasOwn(body, key)) continue;
+ requireAdmin(req);
+ if (user.id === caller.id) throw new NotPermitted(`you cannot change your own ${key}`);
+ }
+
+ if (Object.hasOwn(body, 'role')) {
+ if (!ROLES.includes(body.role)) throw new BadRequest(`role must be one of ${ROLES.join(', ')}`);
+ await users.setRole(user.id, body.role);
+ }
+
+ if (Object.hasOwn(body, 'disabled')) {
+ await users.setDisabled(user.id, Boolean(body.disabled));
+ // A disabled account keeps working until its sessions are gone.
+ if (body.disabled) await refreshTokens.revokeAllFor(user.id);
+ }
+
+ if (Object.hasOwn(body, 'password')) {
+ const bad = validatePassword(String(body.password ?? ''));
+ if (bad) throw new BadRequest(bad);
+ await users.setPassword(user.id, body.password);
+ await refreshTokens.revokeAllFor(user.id);
+ }
+
+ return ok(reply, present(await users.get(user.id)));
+ });
+
+ fastify.delete('/users/:id', async (req, reply) => {
+ const caller = requireAdmin(req);
+ const id = idIn(req, caller);
+ if (id === caller.id) throw new NotPermitted('you cannot remove your own account');
+
+ const user = await users.get(id);
+ if (!user) throw new NotFound();
+
+ await refreshTokens.revokeAllFor(user.id);
+ await users.remove(user.id);
+ return ok(reply, { id: user.id, removed: true });
+ });
+}
diff --git a/src/conductor/routes/workers.js b/src/conductor/routes/workers.js
@@ -1,324 +1,78 @@
-// src/conductor/routes/workers.js - the API workers poll and report to
+// src/conductor/routes/workers.js - registering and inspecting workers
//
-// Named for who calls it rather than what it addresses. The paths are under
-// /api/v1/tasks because a task is what a worker deals in, but everything in
-// this file is the worker protocol, and routes/tasks.js next door is the
-// task resource as anyone else reads it.
-//
-// Every route here requires a worker token: the preHandler below covers the
-// whole plugin, so nothing can be added to this file that is not behind it.
-// A worker may only touch a task it currently holds, which is checked
-// against worker_token_id rather than trusting the task id it sends.
-//
-// A worker is handed a context and a script and nothing else. It is never
-// told which project or job a task belongs to, and the task id it receives
-// carries no structure it could read that out of. The identifiers a build
-// legitimately wants are in the task's environment, where the worker treats
-// them as opaque strings on their way into the container.
+// routes/agent.js next door is the protocol the agents themselves speak.
-import { pipeline as streamPipeline } from 'node:stream/promises';
-import { LogOffsetError } from '../../lib/log.js';
-import { hashingTransform } from '../../lib/stream.js';
-import { sanitizeRelativePath, keys as storageKeys } from '../../lib/storage/index.js';
-import { maskBuffer } from '../../lib/variables.js';
-import { readOutputList } from '../../lib/outputs.js';
-import { newArtifactId } from '../../lib/ids.js';
import { ok } from './envelope.js';
-import {
- ArtifactSizeMismatch, ContentLengthRequired, InvalidArtifactPath, InvalidLogOffset,
- InvalidWorkerToken, LogChunkTooLarge, LogOffsetConflict, TaskNotHeld, TaskNotRunning,
- UnknownProject, UnknownTask,
-} from '../../lib/errors.js';
-
-// Log chunks are small and frequent; artifacts are streamed, so this only
-// bounds a single log append.
-const LOG_CHUNK_LIMIT = 1024 * 1024;
-
-const COMPLETE_FAILURES = {
- 'unknown-task': UnknownTask,
- 'not-held': TaskNotHeld,
- 'not-running': TaskNotRunning,
-};
-
-const TASK_OUTCOMES = ['success', 'failed', 'skipped'];
-
-export default async function workerRoutes(fastify, { cfg, db, git, logs, storage, projects, scheduler, workerTokens, variables }) {
- // Raw bodies: log chunks and artifacts arrive as octet streams and must
- // not be parsed.
- fastify.addContentTypeParser('application/octet-stream', (req, payload, done) => done(null, payload));
-
- async function requireWorker(req) {
- const header = req.headers.authorization || '';
- const presented = header.startsWith('Bearer ') ? header.slice(7).trim() : '';
- const token = await workerTokens.verify(presented, { ip: req.ip });
- if (!token) throw new InvalidWorkerToken();
- req.worker = token;
+import { identifies, requireUser } from './guard.js';
+import { BadRequest, NotFound } from '../../lib/errors.js';
+import { limitOf, offsetOfQuery, pageBlock, paginateOffset } from '../../lib/page.js';
+
+export default async function workerRoutes(fastify, { cfg, auth, db, workerTokens }) {
+ const base = cfg.server.public_url.replace(/\/+$/, '');
+ identifies(fastify, auth);
+
+ async function ownedWorker(req) {
+ const user = requireUser(req);
+ const worker = await workerTokens.get(req.params.id);
+ if (!worker) throw new NotFound();
+ if (user.role !== 'admin' && worker.owner_id !== user.id) throw new NotFound();
+ return worker;
}
- // Confirms the task exists and is held by the calling worker.
- async function heldTask(req) {
- const task = await db.get(
- `SELECT t.id, t.job_id, t.name, t.state, t.worker_token_id, t.log_size, t.spec,
- j.project_id, j.head_sha
- FROM tasks t JOIN jobs j ON j.id = t.job_id
- WHERE t.id = {id}`,
- { id: req.params.task }
- );
- if (!task) throw new UnknownTask();
- if (task.worker_token_id !== req.worker.id) throw new TaskNotHeld();
- return task;
- }
-
- fastify.addHook('preHandler', requireWorker);
-
- // Ask for work. The worker describes what it can run; the scheduler
- // decides. Returns 204 when there is nothing to do.
- fastify.post('/tasks/claim', async (req, reply) => {
- const body = req.body ?? {};
- const arches = splitList(body.arches);
- const features = splitList(body.features);
- const name = typeof body.name === 'string' ? body.name.slice(0, 255) : req.worker.name;
-
- // Recorded even when idle, and never allowed to fail a claim.
- await workerTokens
- .recordAgent({ tokenId: req.worker.id, name, arches, features, ip: req.ip })
- .catch((e) => req.log.warn(`could not record worker agent: ${e.message}`));
-
- const task = await scheduler.claim({
- arches,
- 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 (!task) return reply.code(204).send();
-
- const base = cfg.server.public_url.replace(/\/+$/, '');
- const at = (suffix) => `${base}/api/v1/tasks/${task.id}/${suffix}`;
-
- return ok(reply, {
- task: {
- id: task.id,
- name: task.name,
- arch: task.arch,
- image: task.image,
- // The worker maps these onto its own local mounts, environment and
- // privileges. Without them no feature is applied at all.
- requires: task.requires,
- script: task.script,
- env: task.env,
- services: task.services,
- artifacts: task.artifacts,
- // Where the tree is unpacked and the script runs. Resolved when
- // the job was created, so it does not shift under a retry.
- workdir: task.workdir,
- timeout: task.timeout,
- attempt: task.attempt,
- sha: task.sha,
- ref: task.ref,
- // Values the worker must redact from the log stream. Populated
- // once project variables exist; masking also happens on ingest.
- masked: task.masked ?? [],
- // Derived from the server's own timeout so a worker never has to
- // guess how often it is expected to check in.
- heartbeat_interval: Math.max(5, Math.floor(cfg.scheduler.heartbeat_timeout / 4)),
- endpoints: {
- // The tree comes from the conductor, so a worker never needs
- // credentials for the repository and cannot reach any commit
- // other than the one it was given work for.
- source: at('source.tar.gz'),
- log: at('log'),
- artifact: at('artifacts'),
- heartbeat: at('heartbeat'),
- done: at('complete'),
- },
- },
+ fastify.get('/workers', async (req, reply) => {
+ const user = requireUser(req);
+ const window = await workerTokens.pageVisible(user, {
+ offset: offsetOfQuery(req.query),
+ limit: limitOf(req.query),
});
+ return ok(reply, window.rows, pageBlock({ ...window, url: base + req.url }));
});
- // The tree at the task's commit, as a gzipped tar.
- fastify.get('/tasks/:task/source.tar.gz', async (req, reply) => {
- const task = await heldTask(req);
+ // The token is shown here and never again.
+ fastify.post('/workers', async (req, reply) => {
+ const user = requireUser(req);
+ const { name, owner_id: ownerId } = req.body ?? {};
+ if (typeof name !== 'string' || name.trim().length === 0) throw new BadRequest('a worker needs a name');
- const project = await projects.get(task.project_id);
- if (!project) throw new UnknownProject();
-
- // The tree rooted at the archive, with no wrapping directory: a
- // worker extracts it at whatever path the task is to run in, and
- // docker creates that path on the way.
- reply.header('content-type', 'application/gzip');
- reply.header('content-disposition', `attachment; filename="${task.head_sha.slice(0, 12)}.tar.gz"`);
- return reply.send(git.archiveStream(project, task.head_sha));
+ const owner = user.role === 'admin' && ownerId !== undefined ? ownerId : user.id;
+ const worker = await workerTokens.create(name.trim(), { ownerId: owner });
+ return reply.code(201).send({ ok: true, data: worker });
});
- // Append to the live log. X-Log-Offset makes a retry after a dropped
- // connection safe.
- fastify.post('/tasks/:task/log', { bodyLimit: LOG_CHUNK_LIMIT }, async (req, reply) => {
- const task = await heldTask(req);
-
- const chunks = [];
- let total = 0;
- for await (const chunk of req.body) {
- total += chunk.length;
- if (total > LOG_CHUNK_LIMIT) throw new LogChunkTooLarge();
- chunks.push(chunk);
- }
-
- const rawOffset = req.headers['x-log-offset'];
- const offset = rawOffset === undefined ? undefined : Number(rawOffset);
- if (offset !== undefined && !Number.isInteger(offset)) throw new InvalidLogOffset();
-
- // The worker masks before sending; this repeats it on ingest so a
- // worker that does not still cannot write a secret to disk. It works
- // within a chunk only, which is why the worker holds back a boundary.
- let payload = Buffer.concat(chunks);
- if (variables) {
- try {
- payload = maskBuffer(payload, await variables.maskedValues(task.project_id));
- } catch (e) {
- req.log.warn({ err: e }, `could not mask log output for ${task.id}`);
- }
- }
+ fastify.get('/workers/:id', async (req, reply) => ok(reply, await ownedWorker(req)));
- try {
- const result = await logs.append(task.job_id, task.id, payload, offset);
- await db.run('UPDATE tasks SET log_size = {size}, heartbeat_at = {now} WHERE id = {id}',
- { id: task.id, size: result.size, now: Date.now() });
- return ok(reply, result);
- } catch (e) {
- // Tell the worker where to resume from.
- if (e instanceof LogOffsetError) {
- throw new LogOffsetConflict(e.message, { details: { expected_offset: e.expected } });
- }
- throw e;
+ fastify.patch('/workers/:id', async (req, reply) => {
+ const worker = await ownedWorker(req);
+ if (Object.hasOwn(req.body ?? {}, 'enabled')) {
+ await workerTokens.setEnabled(worker.id, Boolean(req.body.enabled));
}
+ return ok(reply, await workerTokens.get(worker.id));
});
- // artifacts.expire in the pipeline, as an absolute time. Returns null
- // when the task said nothing, leaving the artifact to the project policy.
- function artifactExpiry(specJson, now) {
- if (!specJson) return null;
- try {
- const spec = typeof specJson === 'string' ? JSON.parse(specJson) : specJson;
- const seconds = spec?.artifacts?.expire;
- if (!Number.isFinite(seconds) || seconds <= 0) return null;
- return now + Math.round(seconds * 1000);
- } catch {
- // A spec that will not parse is a problem, but not this request's
- // problem: the artifact is already stored.
- return null;
- }
- }
-
- // Upload one artifact. The path is worker supplied, so it is sanitized
- // before it can influence a storage key.
- fastify.post('/tasks/:task/artifacts', async (req, reply) => {
- const task = await heldTask(req);
-
- const relPath = sanitizeRelativePath(req.headers['x-artifact-path']);
- if (!relPath) throw new InvalidArtifactPath();
-
- const declared = Number(req.headers['content-length']);
- if (!Number.isInteger(declared) || declared < 0) throw new ContentLengthRequired();
-
- // Hash on the way past, so the digest costs nothing extra even when the
- // backend cannot report one.
- const hasher = hashingTransform();
- const key = storageKeys.artifact(task.job_id, task.id, relPath);
-
- let stored;
- try {
- [, stored] = await Promise.all([
- streamPipeline(req.body, hasher),
- storage.put(key, hasher, { size: declared }),
- ]);
- } catch (e) {
- // A body shorter or longer than content-length is the worker's
- // mistake, not a server fault.
- if (/size mismatch/.test(e.message)) throw new ArtifactSizeMismatch(e.message);
- throw e;
- }
-
- const digest = hasher.digest();
- const now = Date.now();
-
- await db.run(
- `INSERT INTO artifacts (id, task_id, job_id, path, storage_key, size, sha256, created_at, expires_at)
- VALUES ({id}, {task}, {job}, {path}, {key}, {size}, {sha}, {now}, {expires})`,
- {
- id: newArtifactId(),
- task: task.id,
- job: task.job_id,
- path: relPath,
- key,
- size: stored.size ?? declared,
- sha: digest,
- now,
- // An explicit deadline from the pipeline. Absent, the project's
- // retention policy decides, which is the usual case.
- expires: artifactExpiry(task.spec, now),
- }
- );
-
- return ok(reply, { path: relPath, size: stored.size ?? declared, sha256: digest });
+ fastify.delete('/workers/:id', async (req, reply) => {
+ const worker = await ownedWorker(req);
+ await workerTokens.remove(worker.id);
+ return ok(reply, { id: worker.id, removed: true });
});
- // Keeps the task alive, and is how a worker learns it should stop.
- fastify.post('/tasks/:task/heartbeat', async (req, reply) => {
- const result = await scheduler.heartbeat(req.params.task, req.worker.id);
- if (!result.known) throw new UnknownTask('No such task for this worker');
- return ok(reply, { cancelled: result.cancelled, state: result.state });
+ fastify.get('/workers/:id/agents', async (req, reply) => {
+ const worker = await ownedWorker(req);
+ return ok(reply, await workerTokens.agentsFor(worker.id));
});
- // Report the outcome. The log is copied to storage here, whatever the
- // result, so a failed task keeps its output.
- fastify.post('/tasks/:task/complete', async (req, reply) => {
- const task = await heldTask(req);
-
- const body = req.body ?? {};
- const success = body.success === true;
- // A worker too old to send a state still says whether it succeeded.
- const state = TASK_OUTCOMES.includes(body.state)
- ? body.state
- : (success ? 'success' : 'failed');
- const exitCode = Number.isInteger(body.exit_code) ? body.exit_code : null;
- const error = typeof body.error === 'string' ? body.error.slice(0, 4096) : null;
-
- const outcome = await scheduler.complete(task.id, {
- state,
- exitCode,
- error,
- outputs: readOutputList(body.outputs),
- tokenId: req.worker.id,
+ fastify.get('/workers/:id/tasks', async (req, reply) => {
+ const worker = await ownedWorker(req);
+ const window = await paginateOffset(db, {
+ select: `t.id, t.name, t.state, t.arch, t.attempt, t.exit_code, t.worker_name,
+ t.created_at, t.started_at, t.finished_at, t.job_id, j.project_id`,
+ from: 'tasks t JOIN jobs j ON j.id = t.job_id',
+ where: 't.worker_token_id = {worker}',
+ order: 't.created_at DESC',
+ params: { worker: worker.id },
+ offset: offsetOfQuery(req.query),
+ limit: limitOf(req.query),
});
- // All 409: by this point heldTask has already answered for a task that
- // does not exist or is not ours, so these are races rather than mistakes.
- if (!outcome.ok) {
- const Failure = COMPLETE_FAILURES[outcome.cause] ?? TaskNotRunning;
- throw new Failure(outcome.reason, { code: 409 });
- }
- // A retry keeps writing to the same log, so only archive it once the
- // task has actually stopped.
- if (outcome.state !== 'queued') {
- try {
- await scheduler.finalizeLog(task.job_id, task.id);
- } catch (e) {
- req.log.error({ err: e }, `failed to archive log for ${task.id}`);
- }
- }
-
- return ok(reply, {
- state: outcome.state,
- retry: outcome.retry === true,
- skipped: outcome.skipped ?? [],
- job_state: outcome.jobState ?? null,
- });
+ return ok(reply, window.rows, pageBlock({ ...window, url: base + req.url }));
});
}
-
-function splitList(value) {
- const items = Array.isArray(value) ? value : typeof value === 'string' ? value.split(',') : [];
- return items.map((s) => String(s).trim()).filter(Boolean).slice(0, 64);
-}
diff --git a/src/conductor/ui/html.js b/src/conductor/ui/html.js
@@ -1,68 +0,0 @@
-// 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
@@ -1,137 +0,0 @@
-// 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 = '', localLogin = true }) {
- const nav = [
- { href: '/', label: 'jobs', key: 'jobs' },
- user ? { href: '/projects', label: 'projects', key: 'projects' } : null,
- user ? { href: '/workers', label: 'workers', key: 'workers' } : null,
- // Accounts cannot sign in while the provider owns identity, so the page
- // is kept off the nav but remains reachable by anyone who needs it.
- user && user.role === 'admin' && localLogin ? { 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_JOB_STATES = ['pending', 'running'];
-export const ACTIVE_TASK_STATES = ['queued', 'running'];
-
-// A link to another page of the same table, keeping whatever else was
-// being asked for, so paging does not quietly drop a filter.
-export function pageHref(base, query = {}, page = 1) {
- const params = new URLSearchParams();
- for (const [key, value] of Object.entries(query)) {
- if (key === 'page' || typeof value !== 'string' || value === '') continue;
- params.set(key, value);
- }
- if (page > 1) params.set('page', String(page));
- const search = params.toString();
- return search ? `${base}?${search}` : base;
-}
-
-// Page links for a table, rendered only when there is more than one page
-// so short tables stay quiet.
-export function pager(window, href) {
- if (window.pages <= 1) return '';
-
- const step = (to, label) => (to >= 1 && to <= window.pages
- ? html`<a href="${href(to)}">${label}</a>`
- : html`<span class="muted">${label}</span>`);
-
- return html`<nav class="pager">
- ${step(window.page - 1, 'previous')}
- <span class="muted">${window.first} to ${window.last} of ${window.total}</span>
- ${step(window.page + 1, 'next')}
- </nav>`;
-}
diff --git a/src/conductor/ui/pages.js b/src/conductor/ui/pages.js
@@ -1,637 +0,0 @@
-// 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 job or task 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, pager, pageHref,
- ACTIVE_JOB_STATES, ACTIVE_TASK_STATES,
-} from './layout.js';
-
-const poll = (url, seconds = 3) => raw(`hx-get="${url}" hx-trigger="every ${seconds}s" hx-swap="outerHTML"`);
-
-// --- jobs ---
-
-export function jobsPage({ jobs, user, anonymous, query = {} }) {
- return html`
- <h2>Jobs</h2>
- ${anonymous ? html`<p class="muted">Showing public jobs. <a href="/login">Sign in</a> to see your own.</p>` : ''}
- ${jobsTable(jobs, query)}
- `;
-}
-
-export function jobsTable(jobs, query = {}) {
- const live = jobs.rows.some((r) => ACTIVE_JOB_STATES.includes(r.state));
- // The fragment asks for the page that is on screen, filter and all, or a
- // refresh would silently throw the reader back to the first page.
- return html`<div id="jobs" ${live ? poll(pageHref('/partials/jobs', query, jobs.page), 4) : ''}>
- <table>
- <thead><tr>
- <th>job</th><th>project</th><th>ref</th><th>commit</th><th>state</th><th>started</th>
- </tr></thead>
- <tbody>
- ${jobs.rows.length === 0
- ? html`<tr><td colspan="6" class="muted">No jobs to show.</td></tr>`
- : jobs.rows.map((job) => html`<tr>
- <td><a href="/jobs/${job.id}">#${job.number}</a></td>
- <td>${job.project_id}</td>
- <td class="muted">${(job.ref ?? '').replace('refs/heads/', '')}</td>
- <td class="mono muted">${shortSha(job.head_sha)}</td>
- <td>${badge(job.state)}</td>
- <td class="muted">${ago(job.created_at)}</td>
- </tr>`)}
- </tbody>
- </table>
- ${pager(jobs, (to) => pageHref('/jobs', query, to))}
- </div>`;
-}
-
-export function jobPage({ job, tasks, canManage }) {
- return html`
- <div class="panel">
- <h2>${job.title || `Job #${job.number}`}</h2>
- <div class="row">
- ${field('project', html`<a href="/jobs?project=${job.project_id}">${job.project_id}</a>`)}
- ${field('state', badge(job.state))}
- ${field('ref', (job.ref ?? '').replace('refs/heads/', '') || '-')}
- ${field('commit', html`<span class="mono">${shortSha(job.head_sha)}</span>`)}
- ${field('trigger', `${job.trigger_type}${job.actor ? ` by ${job.actor}` : ''}`)}
- ${field('visibility', job.visibility)}
- ${field('duration', duration(job.started_at, job.finished_at))}
- </div>
- ${canManage ? html`<div class="actions">
- ${job.state === 'running'
- ? html`<button hx-post="/jobs/${job.id}/cancel" hx-target="#tasks" hx-swap="outerHTML">cancel</button>`
- : ''}
- <button hx-post="/jobs/${job.id}/retry" hx-swap="none">run again</button>
- </div>` : ''}
- </div>
- ${tasksTable(job, tasks)}
- `;
-}
-
-export function tasksTable(job, tasks) {
- const live = ACTIVE_JOB_STATES.includes(job.state);
-
- // Grouped by graph depth, which is how the pipeline reads. Both kinds of
- // edge count: an after task runs later even though it waits for less, and
- // showing it alongside what it waits for would read as running beside it.
- const byId = new Map(tasks.map((j) => [j.id, j]));
- const depthOf = (task, seen = new Set()) => {
- if (seen.has(task.id)) return 0;
- seen.add(task.id);
- const deps = [...(task.needs ?? []), ...(task.after ?? [])].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 task of tasks) {
- const depth = depthOf(task);
- if (!stages.has(depth)) stages.set(depth, []);
- stages.get(depth).push(task);
- }
-
- // One table for the whole graph. A table per stage sizes its own
- // columns, so the same field landed in a different place in every
- // stage and the eye had nothing to follow down the page.
- return html`<div id="tasks" ${live ? poll(`/partials/jobs/${job.id}/tasks`, 3) : ''}>
- <table class="tasks">
- <thead><tr>
- <th>task</th><th>state</th><th>arch</th><th>worker</th><th>duration</th>
- </tr></thead>
- ${[...stages.keys()].sort((a, b) => a - b).map((depth) => html`<tbody class="stage">
- <tr class="stage-label"><th colspan="5">stage ${depth + 1}</th></tr>
- ${stages.get(depth).sort((a, b) => a.name.localeCompare(b.name)).map((task) => html`<tr>
- <td><a href="/tasks/${task.id}">${task.name}</a></td>
- <td>${badge(task.state)}</td>
- <td class="muted">${task.arch ?? ''}</td>
- <td class="muted">${task.worker_name ?? ''}</td>
- <td class="muted num">${duration(task.started_at, task.finished_at)}</td>
- </tr>`)}
- </tbody>`)}
- </table>
- </div>`;
-}
-
-// --- tasks ---
-
-export function taskPage({ task, artifacts, outputs = [], log, query = {} }) {
- return html`
- <div class="panel">
- <h2>${task.name}</h2>
- <div class="row">
- ${field('state', badge(task.state))}
- ${field('job', html`<a href="/jobs/${task.job_id}">back to job</a>`)}
- ${field('image', html`<span class="mono">${task.image}</span>`)}
- ${field('arch', task.arch ?? '-')}
- ${field('attempt', `${task.attempt} of ${task.max_attempts}`)}
- ${field('worker', task.worker_name ?? '-')}
- ${field('duration', duration(task.started_at, task.finished_at))}
- </div>
- ${task.error ? notice(task.error) : ''}
- </div>
-
- <h3>log</h3>
- <div class="logpane">${taskLog(task, log)}</div>
-
- ${outputs.length === 0 ? '' : html`
- <h3>outputs</h3>
- <table id="outputs"><tbody>
- ${outputs.map((o) => html`<tr>
- <td class="mono">${o.name}</td>
- <td class="muted">${o.masked ? '[masked]' : o.value}</td>
- </tr>`)}
- </tbody></table>`}
-
- ${artifacts.rows.length === 0 ? '' : html`
- <h3>artifacts</h3>
- <table><tbody>
- ${artifacts.rows.map((a) => html`<tr>
- <td><a href="/api/v1/projects/${task.project_id}/jobs/${task.job_id}/tasks/${task.id}/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>
- ${pager(artifacts, (to) => pageHref(`/tasks/${task.id}`, query, to))}`}
- `;
-}
-
-export function taskLog(task, log) {
- const live = ACTIVE_TASK_STATES.includes(task.state);
- return html`<pre id="log" class="log" ${live ? poll(`/partials/tasks/${task.id}/log`, 2) : ''}>${
- log || (live ? 'Waiting for output.' : 'No output.')
- }</pre>`;
-}
-
-// --- login ---
-
-export function loginPage({ error }) {
- return html`<div class="center">
- <div class="panel narrow">
- <h2>Sign in</h2>
- ${notice(error)}
- <form hx-post="/login" hx-target="body" hx-swap="outerHTML">
- <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, query = {} }) {
- 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, query)}
- `;
-}
-
-export function projectsTable(projects, user, query = {}) {
- return html`<div id="projects">
- <table>
- <thead><tr><th>project</th><th>repository</th><th>visibility</th><th>owner</th><th>jobs</th><th></th></tr></thead>
- <tbody>
- ${projects.rows.length === 0
- ? html`<tr><td colspan="6" class="muted">No projects yet.</td></tr>`
- : projects.rows.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>
- ${pager(projects, (to) => pageHref('/projects', query, to))}
- </div>`;
-}
-
-export function projectPage({ project, variables, triggerUrl, secret, user, retention }) {
- 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('owner', project.owner_id === null ? 'shared' : (project.owner_id === user.id ? 'you' : project.owner_id))}
- ${field('jobs', 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>
- <label>working directory
- <input name="workdir" class="mono" placeholder="${retention.workdir}"
- value="${project.workdir ?? ''}">
- </label>
- </div>
- <p class="muted">
- A pipeline may override visibility per commit with a top level
- visibility key, and the working directory with a workdir key.
- Left empty, tasks run in ${retention.workdir}.
- </p>
- <div class="actions"><button type="submit">save</button></div>
- </form>
- </div>
-
- <div class="panel">
- <h3>Retention</h3>
- <p class="muted">
- Leave a field empty to follow the server default, shown as the
- placeholder. Zero keeps things forever.
- </p>
- <form hx-patch="/projects/${project.id}/retention" hx-target="body" hx-swap="none">
- <div class="grid">
- <label>keep artifacts for jobs
- <input name="artifact_keep_jobs" type="number" min="0" inputmode="numeric"
- placeholder="${retention.artifact_keep_jobs}"
- value="${project.artifact_keep_jobs ?? ''}">
- </label>
- <label>keep artifacts for days
- <input name="artifact_keep_days" type="number" min="0" inputmode="numeric"
- placeholder="${retention.artifact_keep_days}"
- value="${project.artifact_keep_days ?? ''}">
- </label>
- <label>keep logs for days
- <input name="log_keep_days" type="number" min="0" inputmode="numeric"
- placeholder="${retention.log_keep_days}"
- value="${project.log_keep_days ?? ''}">
- </label>
- </div>
- <p class="muted">
- An artifact is kept if either rule wants it, so the last
- ${project.artifact_keep_jobs ?? retention.artifact_keep_jobs} jobs survive
- whatever their age. The most recent successful job is always kept. A task
- that sets artifacts.expire overrides all of this.
- </p>
- <div class="actions"><button type="submit">save</button></div>
- </form>
- </div>
-
- <div class="panel">
- <h3>Variables</h3>
- <p class="muted">Injected into every task. 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" onclick="setupDeleteProjectDialog('${project.id}')">delete project</button>
- </div>
- </div>
-
- <dialog id="deleteProjectDialog">
- <p>Delete "<span id="deleteProjectId"></span>" and all of its jobs?</p>
- <div class="actions">
- <button type="button" id="deleteProjectConfirm" class="danger">delete project</button>
- <button type="button" onclick="this.closest('dialog').close()">cancel</button>
- </div>
- </dialog>
-
- <script>
- function setupDeleteProjectDialog(id) {
- document.getElementById('deleteProjectId').textContent = id;
- document.getElementById('deleteProjectConfirm').onclick = function() {
- htmx.ajax('DELETE', '/projects/' + id, { target: 'body', swap: 'none' });
- document.getElementById('deleteProjectDialog').close();
- };
- document.getElementById('deleteProjectDialog').showModal();
- }
- </script>
- `;
-}
-
-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, query = {} }) {
- return html`
- <h2>Workers</h2>
- <p class="muted">
- A worker you register only ever receives tasks from your own projects.
- ${user.role === 'admin' ? 'A shared worker receives tasks 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="outerHTML">
- <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, query)}
-
- <dialog id="disableDialog">
- <p>Disable worker "<span id="disableWorkerName"></span>"?</p>
- <div class="actions">
- <button type="button" id="disableConfirm" class="danger">disable</button>
- <button type="button" onclick="this.closest('dialog').close()">cancel</button>
- </div>
- </dialog>
-
- <dialog id="removeDialog">
- <p>Remove worker "<span id="removeWorkerName"></span>"?</p>
- <div class="actions">
- <button type="button" id="removeConfirm" class="danger">remove</button>
- <button type="button" onclick="this.closest('dialog').close()">cancel</button>
- </div>
- </dialog>
-
- <script>
- function setupDisableDialog(id, name) {
- document.getElementById('disableWorkerName').textContent = name;
- document.getElementById('disableConfirm').onclick = function() {
- htmx.ajax('PATCH', '/workers/' + id, { target: '#workers', swap: 'outerHTML', values: { enabled: false } });
- document.getElementById('disableDialog').close();
- };
- document.getElementById('disableDialog').showModal();
- }
- function setupRemoveDialog(id, name) {
- document.getElementById('removeWorkerName').textContent = name;
- document.getElementById('removeConfirm').onclick = function() {
- htmx.ajax('DELETE', '/workers/' + id, { target: '#workers', swap: 'outerHTML' });
- document.getElementById('removeDialog').close();
- };
- document.getElementById('removeDialog').showModal();
- }
- </script>
- `;
-}
-
-export function workersTable(tokens, user, query = {}) {
- // The id sits on the wrapper so a swap replaces the pager along with the
- // rows; on the table alone it would be left behind, describing a page
- // that is no longer there.
- return html`<div id="workers">
- <table>
- <thead><tr><th>name</th><th>scope</th><th>state</th><th>last seen</th><th>address</th><th></th></tr></thead>
- <tbody>
- ${tokens.rows.length === 0
- ? html`<tr><td colspan="6" class="muted">No workers registered.</td></tr>`
- : tokens.rows.map((t) => html`<tr>
- <td><a href="/workers/${t.id}">${t.name}</a></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">
- ${t.enabled === 1
- ? html`<button class="link" data-worker-id="${t.id}" data-worker-name="${t.name}" onclick="setupDisableDialog(this.dataset.workerId, this.dataset.workerName)">disable</button>`
- : html`<button class="link" hx-patch="/workers/${t.id}" hx-vals='{"enabled": true}' hx-target="#workers" hx-swap="outerHTML">enable</button>`}
- <button class="link danger" data-worker-id="${t.id}" data-worker-name="${t.name}" onclick="setupRemoveDialog(this.dataset.workerId, this.dataset.workerName)">remove</button>
- </td>
- </tr>`)}
- </tbody>
- </table>
- ${pager(tokens, (to) => pageHref('/workers', query, to))}
- </div>`;
-}
-
-// A token is a credential, not a machine, so the page lists both.
-export function workerPage({ token, agents, tasks, user, staleAfter, query = {} }) {
- const scope = token.owner_id === null
- ? 'shared'
- : (token.owner_id === user.id ? 'yours' : token.owner_id);
-
- return html`
- <div class="panel">
- <h2>${token.name}</h2>
- <div class="row">
- ${field('state', token.enabled === 1 ? badge('success') : badge('skipped'))}
- ${field('scope', scope)}
- ${field('machines', `${agents.length}`)}
- ${field('last seen', token.last_seen_at ? ago(token.last_seen_at) : 'never')}
- ${field('address', html`<span class="mono">${token.last_ip ?? '-'}</span>`)}
- ${field('registered', ago(token.created_at))}
- </div>
- </div>
-
- <h3>machines</h3>
- ${workerAgentsTable(agents, staleAfter)}
-
- <h3>recent tasks</h3>
- ${workerTasksTable(token, tasks, query)}
- `;
-}
-
-function workerAgentsTable(agents, staleAfter) {
- const now = Date.now();
- return html`<table>
- <thead><tr>
- <th>machine</th><th>state</th><th>features</th><th>arches</th><th>last claim</th><th>address</th>
- </tr></thead>
- <tbody>
- ${agents.length === 0
- ? html`<tr><td colspan="6" class="muted">No machine has claimed with this token yet.</td></tr>`
- : agents.map((a) => html`<tr>
- <td>${a.name}</td>
- <td>${now - a.last_seen_at > staleAfter ? badge('offline') : badge('online')}</td>
- <td class="muted">${a.features.length === 0 ? 'none' : a.features.join(', ')}</td>
- <td class="muted">${a.arches.length === 0 ? 'any' : a.arches.join(', ')}</td>
- <td class="muted">${ago(a.last_seen_at)}</td>
- <td class="muted mono">${a.last_ip ?? ''}</td>
- </tr>`)}
- </tbody>
- </table>`;
-}
-
-// Final attempts only: a requeue clears the worker off the task.
-function workerTasksTable(token, tasks, query) {
- return html`<div id="worker-tasks">
- <table class="tasks">
- <thead><tr>
- <th>task</th><th>state</th><th>machine</th><th>arch</th><th>claimed</th><th>duration</th>
- </tr></thead>
- <tbody>
- ${tasks.rows.length === 0
- ? html`<tr><td colspan="6" class="muted">This worker has not run anything yet.</td></tr>`
- : tasks.rows.map((t) => html`<tr>
- <td><a href="/tasks/${t.id}">${t.name}</a></td>
- <td>${badge(t.state)}</td>
- <td class="muted">${t.worker_name ?? ''}</td>
- <td class="muted">${t.arch ?? ''}</td>
- <td class="muted">${t.claimed_at ? ago(t.claimed_at) : ''}</td>
- <td class="muted num">${duration(t.started_at, t.finished_at)}</td>
- </tr>`)}
- </tbody>
- </table>
- ${pager(tasks, (to) => pageHref(`/workers/${token.id}`, query, to))}
- </div>`;
-}
-
-// --- users ---
-
-export function usersPage({ users, localLogin, error, query = {} }) {
- return html`
- <h2>Users</h2>
- ${localLogin ? '' : html`<p class="muted">
- Authentication goes through the identity provider, so these accounts are inactive.
- </p>`}
- ${error ? notice(error) : ''}
-
- <div class="panel">
- <h3>Add a user</h3>
- <form hx-post="/users" hx-target="body" hx-swap="outerHTML">
- <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="user">user</option><option value="admin">admin</option></select>
- </label>
- </div>
- <div class="actions"><button type="submit">create</button></div>
- </form>
- </div>
-
- ${usersTable(users, query)}
-
- <dialog id="removeUserDialog">
- <p id="removeUserMessage"></p>
- <div class="actions">
- <button type="button" id="removeUserConfirm" class="danger">remove</button>
- <button type="button" onclick="this.closest('dialog').close()">cancel</button>
- </div>
- </dialog>
-
- <script>
- function setupRemoveUserDialog(id, message) {
- document.getElementById('removeUserMessage').textContent = message;
- document.getElementById('removeUserConfirm').onclick = function() {
- htmx.ajax('DELETE', '/users/' + id, { target: '#users', swap: 'outerHTML' });
- document.getElementById('removeUserDialog').close();
- };
- document.getElementById('removeUserDialog').showModal();
- }
- </script>
- `;
-}
-
-export function usersTable(users, query = {}) {
- return html`<div id="users">
- <table>
- <thead><tr>
- <th>username</th><th>role</th><th>state</th><th>owns</th><th>last login</th><th></th>
- </tr></thead>
- <tbody>
- ${users.rows.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" onclick="setupRemoveUserDialog('${u.id}', ${JSON.stringify(deleteWarning(u))})">remove</button>
- </td>
- </tr>`)}
- </tbody>
- </table>
- ${pager(users, (to) => pageHref('/users', query, to))}
- </div>`;
-}
-
-// 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 job 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
@@ -1,824 +0,0 @@
-// 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 {
- jobsPage, jobsTable, jobPage, tasksTable, taskPage, taskLog, loginPage,
- projectsPage, projectPage, variablesTable, workersPage, workersTable, workerPage,
- usersPage, usersTable,
-} from './pages.js';
-import { canManageProject, canViewProject, VISIBILITIES } from '../../lib/projects.js';
-import { canManageWorker } from '../../lib/workers.js';
-import { paginate, pageOf, windowOfAll } from '../../lib/page.js';
-import { issueToken, sessionCookie, clearedCookie } from '../../lib/auth/index.js';
-import { createState, verifyState } from '../../lib/auth/state.js';
-import { PipelineError, DEFAULT_WORKDIR } from '../../lib/pipeline/index.js';
-import { outputAad } from '../../lib/outputs.js';
-
-const LOG_TAIL_BYTES = 256 * 1024;
-
-export default async function uiRoutes(fastify, services) {
- const { cfg, db, auth, users, projects, workerTokens, variables, secrets, logs, scheduler } = services;
- const secure = cfg.server.public_url.startsWith('https://');
- const oidcRedirectUri = `${cfg.server.public_url.replace(/\/+$/, '')}/oidc/callback`;
-
- // 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, localLogin: auth.localLogin }));
- }
-
- const currentUser = (req) => auth.identify(req).catch(() => null);
-
- // Only a path on this conductor, so a crafted link cannot bounce someone
- // to another site after signing in.
- function safeReturnTo(value) {
- if (typeof value !== 'string' || !value.startsWith('/') || value.startsWith('//')) return '/';
- return value;
- }
-
- // Where to send the browser to sign in, with the page to come back to
- // carried in the signed state.
- function beginOidcLogin(returnTo) {
- const state = createState(cfg.auth.session_secret, safeReturnTo(returnTo));
- return auth.oidc.authorizationUrl({ redirectUri: oidcRedirectUri, state });
- }
-
- function signInError(reply, message) {
- return page(reply, {
- title: 'sign in',
- user: null,
- body: html`<div class="center"><div class="panel narrow">
- <h2>Sign in</h2>
- <p class="error">${message}</p>
- <p><a href="/login">Try again</a></p>
- </div></div>`,
- });
- }
-
- // A signed in user, or a redirect to sign in. With OIDC that means the
- // provider; otherwise the built-in sign in page.
- async function required(req, reply) {
- const user = await currentUser(req);
- if (user) return user;
-
- if (auth.oidc) {
- let url;
- try {
- url = await beginOidcLogin(req.url);
- } catch {
- reply.code(502);
- signInError(reply, 'The identity provider could not be reached. Try again shortly.');
- return null;
- }
- if (req.headers['hx-request']) reply.header('hx-redirect', url).code(204).send();
- else reply.redirect(url, 302);
- return null;
- }
-
- 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>`));
- }
-
- // --- jobs ---
-
- async function visibleJobs(user, { projectId = null, page: pageNo = 1 } = {}) {
- const scope = user && user.role === 'admin'
- ? { sql: '1 = 1', params: {} }
- : user
- ? { sql: "(j.visibility = 'public' OR p.owner_id = {viewerId})", params: { viewerId: user.id } }
- : { sql: "j.visibility = 'public'", params: {} };
-
- return paginate(db, {
- select: `j.id, j.project_id, j.number, j.ref, j.head_sha, j.state, j.visibility,
- j.title, j.created_at, j.started_at, j.finished_at`,
- from: 'jobs j JOIN projects p ON p.id = j.project_id',
- where: `${scope.sql}${projectId ? ' AND j.project_id = {project}' : ''}`,
- order: 'j.created_at DESC',
- params: { ...scope.params, ...(projectId ? { project: projectId } : {}) },
- page: pageNo,
- });
- }
-
- // The project filter and the page both live in the query string, so the
- // polling fragment can be asked for exactly what is on screen.
- function jobsQuery(req) {
- return {
- projectId: typeof req.query.project === 'string' ? req.query.project : null,
- page: pageOf(req.query),
- };
- }
-
- async function jobsView(req, reply) {
- const user = await currentUser(req);
- const jobs = await visibleJobs(user, jobsQuery(req));
- return page(reply, {
- title: 'jobs',
- user,
- active: 'jobs',
- body: jobsPage({ jobs, user, anonymous: !user, query: req.query }),
- });
- }
-
- fastify.get('/', jobsView);
- fastify.get('/jobs', jobsView);
-
- fastify.get('/partials/jobs', async (req, reply) => {
- const user = await currentUser(req);
- return send(reply, jobsTable(await visibleJobs(user, jobsQuery(req)), req.query));
- });
-
- // Loads a job the caller may see, along with whether they may act on it.
- async function jobFor(req, reply) {
- const user = await currentUser(req);
- const job = await db.get('SELECT * FROM jobs WHERE id = {id}', { id: req.params.id });
- if (!job) {
- reply.code(404);
- return { user, job: null };
- }
- const project = await projects.get(job.project_id);
- if (job.visibility !== 'public' && !canManageProject(user, project)) {
- reply.code(404);
- return { user, job: null };
- }
- return { user, job, project, canManage: canManageProject(user, project) };
- }
-
- // Masked values are never unsealed here: the page shows that one exists.
- async function outputsOf(taskId) {
- const rows = await db.all(
- 'SELECT task_id, name, value, masked FROM task_outputs WHERE task_id = {task} ORDER BY name',
- { task: taskId }
- );
- return rows.map((row) => {
- const masked = row.masked === 1;
- if (masked) return { name: row.name, masked, value: null };
- try {
- return { name: row.name, masked, value: secrets.open(row.value, outputAad(row.task_id, row.name)) };
- } catch {
- return { name: row.name, masked, value: null };
- }
- });
- }
-
- async function tasksOf(jobId) {
- const tasks = await db.all(
- `SELECT id, name, arch, state, worker_name, started_at, finished_at
- FROM tasks WHERE job_id = {job} ORDER BY name`,
- { job: jobId }
- );
- const deps = await db.all(
- `SELECT d.task_id, d.depends_on_id, d.kind FROM task_deps d
- JOIN tasks t ON t.id = d.task_id WHERE t.job_id = {job}`,
- { job: jobId }
- );
- const edges = new Map(tasks.map((t) => [t.id, { needs: [], after: [] }]));
- for (const d of deps) edges.get(d.task_id)?.[d.kind === 'after' ? 'after' : 'needs'].push(d.depends_on_id);
- return tasks.map((j) => ({ ...j, ...(edges.get(j.id) ?? { needs: [], after: [] }) }));
- }
-
- fastify.get('/jobs/:id', async (req, reply) => {
- const { user, job, canManage } = await jobFor(req, reply);
- if (!job) return page(reply, { title: 'not found', user, body: html`<p class="error">No such job.</p>` });
-
- return page(reply, {
- title: `job #${job.number}`,
- user,
- active: 'jobs',
- body: jobPage({ job, tasks: await tasksOf(job.id), canManage }),
- });
- });
-
- fastify.get('/partials/jobs/:id/tasks', async (req, reply) => {
- const { job } = await jobFor(req, reply);
- if (!job) return reply.send('');
- return send(reply, tasksTable(job, await tasksOf(job.id)));
- });
-
- fastify.post('/jobs/:id/cancel', async (req, reply) => {
- if (!fromUi(req, reply)) return reply;
- const user = await required(req, reply);
- if (!user) return reply;
-
- const { job, canManage } = await jobFor(req, reply);
- if (!job || !canManage) return fail(reply, 'Not yours to manage.', 403);
-
- await scheduler.cancelJob(job.id, `cancelled by ${user.username}`);
- const fresh = await db.get('SELECT * FROM jobs WHERE id = {id}', { id: job.id });
- return send(reply, tasksTable(fresh, await tasksOf(job.id)));
- });
-
- fastify.post('/jobs/:id/retry', async (req, reply) => {
- if (!fromUi(req, reply)) return reply;
- const user = await required(req, reply);
- if (!user) return reply;
-
- const { job, project, canManage } = await jobFor(req, reply);
- if (!job || !canManage) return fail(reply, 'Not yours to manage.', 403);
-
- try {
- const created = await scheduler.createJob(project, {
- ref: job.ref,
- baseSha: job.base_sha,
- headSha: job.head_sha,
- trigger: 'manual',
- actor: user.username,
- });
- return refresh(reply, `/jobs/${created.jobId}`);
- } catch (e) {
- return fail(reply, e instanceof PipelineError ? e.message : String(e.message ?? e), 422);
- }
- });
-
- // --- tasks ---
-
- fastify.get('/tasks/:id', async (req, reply) => {
- const user = await currentUser(req);
- const task = await db.get(
- `SELECT t.*, j.visibility, j.project_id FROM tasks t
- JOIN jobs j ON j.id = t.job_id WHERE t.id = {id}`,
- { id: req.params.id }
- );
- if (!task) return page(reply, { title: 'not found', user, body: html`<p class="error">No such task.</p>` });
-
- const project = await projects.get(task.project_id);
- if (task.visibility !== 'public' && !canManageProject(user, project)) {
- return page(reply, { title: 'not found', user, body: html`<p class="error">No such task.</p>` });
- }
-
- const artifacts = await paginate(db, {
- select: 'id, path, size, sha256',
- from: 'artifacts',
- where: 'task_id = {task}',
- order: 'path',
- params: { task: task.id },
- page: pageOf(req.query),
- });
-
- return page(reply, {
- title: task.name,
- user,
- active: 'jobs',
- body: taskPage({
- task,
- artifacts,
- outputs: await outputsOf(task.id),
- log: await tailOf(task),
- query: req.query,
- }),
- });
- });
-
- fastify.get('/partials/tasks/:id/log', async (req, reply) => {
- const user = await currentUser(req);
- const task = await db.get(
- `SELECT t.*, j.visibility, j.project_id FROM tasks t
- JOIN jobs j ON j.id = t.job_id WHERE t.id = {id}`,
- { id: req.params.id }
- );
- if (!task) return reply.send('');
-
- const project = await projects.get(task.project_id);
- if (task.visibility !== 'public' && !canManageProject(user, project)) return reply.send('');
-
- return send(reply, taskLog(task, await tailOf(task)));
- });
-
- // 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(task) {
- if (!task.log_key) {
- const size = await logs.size(task.job_id, task.id);
- const offset = Math.max(0, size - LOG_TAIL_BYTES);
- const chunk = await logs.read(task.job_id, task.id, { offset, limit: LOG_TAIL_BYTES });
- return chunk.data.toString('utf8');
- }
- try {
- const start = Math.max(0, (task.log_size ?? 0) - LOG_TAIL_BYTES);
- const object = await services.storage.get(task.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 currentUser(req);
- if (user) return reply.redirect('/', 302);
-
- // With the provider in charge, asking for the sign in page is asking to
- // sign in there.
- if (auth.oidc) {
- try {
- const to = typeof req.query.redirect === 'string' ? req.query.redirect : '/';
- return reply.redirect(await beginOidcLogin(to), 302);
- } catch {
- reply.code(502);
- return signInError(reply, 'The identity provider could not be reached. Try again shortly.');
- }
- }
-
- return page(reply, {
- title: 'sign in',
- user: null,
- body: loginPage({}),
- });
- });
-
- // The provider sends the browser back here with an authorization code.
- fastify.get('/oidc/callback', async (req, reply) => {
- if (typeof req.query.error === 'string') {
- reply.code(400);
- return signInError(reply, `The provider refused the sign in: ${req.query.error}`);
- }
-
- const state = auth.oidc ? verifyState(cfg.auth.session_secret, String(req.query.state ?? '')) : null;
- if (!state) {
- reply.code(400);
- return signInError(reply, 'This sign in did not start here, or took too long. Try again.');
- }
-
- try {
- const tokens = await auth.oidc.exchangeCode({
- code: String(req.query.code ?? ''),
- redirectUri: oidcRedirectUri,
- });
- if (typeof tokens.id_token !== 'string') {
- throw new Error('the token response contained no id_token');
- }
-
- const profile = await auth.oidc.verify(tokens.id_token);
- const account = await users.upsertExternal({
- externalId: `${profile.issuer}|${profile.subject}`,
- username: profile.username,
- role: profile.role,
- });
- if (account.disabled === 1) {
- reply.code(403);
- return signInError(reply, 'This account is disabled.');
- }
-
- // The id_token is the session. Every later request re-verifies it, so
- // a role revoked at the provider takes effect at once.
- const ttl = Number.isInteger(tokens.expires_in) && tokens.expires_in > 0
- ? tokens.expires_in
- : cfg.auth.session_ttl;
- reply.header('set-cookie', sessionCookie(tokens.id_token, { ttl, secure }));
- return reply.redirect(state.url, 302);
- } catch (e) {
- req.log.error?.(`oidc callback failed: ${e.message}`);
- reply.code(400);
- return signInError(reply, 'Sign in failed. Please try again.');
- }
- });
-
- 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 page(reply, {
- title: 'sign in',
- user: null,
- body: loginPage({ error: 'Invalid username or password.' }),
- });
- }
-
- 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());
-
- // End the provider session too, so signing out is not immediately
- // undone by a silent re-login on the next visit.
- if (auth.oidc) {
- try {
- const url = await auth.oidc.endSessionUrl({
- postLogoutRedirectUri: cfg.server.public_url.replace(/\/+$/, ''),
- });
- if (url) return refresh(reply, url);
- } catch {
- // The provider is unreachable; the local session is still gone.
- }
- }
- return refresh(reply, '/');
- });
-
- // --- projects ---
-
- fastify.get('/projects', async (req, reply) => {
- const user = await required(req, reply);
- if (!user) return reply;
- const rows = await projects.pageManagedBy(user, pageOf(req.query));
- return page(reply, {
- title: 'projects',
- user,
- active: 'projects',
- body: projectsPage({ projects: rows, user, query: req.query }),
- });
- });
-
- 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/v1/projects/${project.id}/trigger`,
- secret,
- // What an empty field falls back to, so the form can say so
- // rather than looking unset.
- // Defaults the form shows as placeholders, so an empty field
- // reads as "inherited" rather than "unset".
- retention: { ...cfg.retention, workdir: DEFAULT_WORKDIR },
- }),
- });
- });
-
- 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);
- // An empty field means the project has no opinion, which is a null
- // rather than an empty string.
- if (Object.hasOwn(body, 'workdir')) {
- await projects.setWorkdir(found.project.id, String(body.workdir ?? '').trim() || null);
- }
- } catch (e) {
- return fail(reply, e.message);
- }
- return refresh(reply);
- });
-
- fastify.patch('/projects/:id/retention', async (req, reply) => {
- if (!fromUi(req, reply)) return reply;
- const found = await projectFor(req, reply);
- if (!found) return reply;
-
- const body = req.body ?? {};
- const values = {};
- for (const key of ['artifact_keep_jobs', 'artifact_keep_days', 'log_keep_days']) {
- if (!Object.hasOwn(body, key)) continue;
- const raw = String(body[key] ?? '').trim();
- // An empty field means "follow the server default", which is a null
- // rather than a zero. A form cannot express that difference on its
- // own, so it is decided here.
- if (raw === '') {
- values[key] = null;
- continue;
- }
- const parsed = Number(raw);
- if (!Number.isInteger(parsed) || parsed < 0) {
- return fail(reply, `${key} must be a whole number of zero or more, or empty to follow the default`);
- }
- values[key] = parsed;
- }
-
- try {
- await projects.setRetention(found.project.id, values);
- } 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.pageVisible(user, pageOf(req.query)), user, query: req.query }),
- });
- });
-
- fastify.get('/workers/:id', async (req, reply) => {
- const user = await required(req, reply);
- if (!user) return reply;
-
- const token = await workerTokens.get(req.params.id);
- if (!token || !canManageWorker(user, token)) {
- reply.code(404);
- return page(reply, { title: 'not found', user, active: 'workers', body: html`<p class="error">No such worker.</p>` });
- }
-
- const tasks = await paginate(db, {
- select: 'id, name, state, arch, worker_name, claimed_at, started_at, finished_at',
- from: 'tasks',
- where: 'worker_token_id = {worker}',
- order: 'claimed_at DESC',
- params: { worker: token.id },
- page: pageOf(req.query),
- });
-
- return page(reply, {
- title: token.name,
- user,
- active: 'workers',
- body: workerPage({
- token,
- agents: await workerTokens.agentsFor(token.id),
- tasks,
- user,
- staleAfter: cfg.scheduler.heartbeat_timeout * 1000,
- query: req.query,
- }),
- });
- });
-
- 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 jobs 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.pageVisible(user, pageOf(req.query)),
- user,
- query: req.query,
- 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.pageVisible(user, pageOf(req.query)), user, req.query));
- });
-
- 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.pageVisible(user, pageOf(req.query)), user, req.query));
- });
-
- // --- 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.pageWithHoldings(pageOf(req.query)), localLogin: auth.localLogin, query: req.query }),
- });
- });
-
- 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' : 'user',
- });
- } catch (e) {
- return page(reply, {
- title: 'users',
- user: await currentUser(req),
- active: 'users',
- body: usersPage({
- users: await users.pageWithHoldings(pageOf(req.query)),
- localLogin: auth.localLogin,
- query: req.query,
- error: 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.pageWithHoldings(pageOf(req.query)), req.query));
- });
-
- 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.pageWithHoldings(pageOf(req.query)), req.query));
- });
-
- 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/auth/index.js b/src/lib/auth/index.js
@@ -1,72 +1,94 @@
// src/lib/auth/index.js - request authentication
//
-// OIDC is used when auth.oidc.discovery_url is configured, built-in accounts
-// otherwise. The two do not mix: with OIDC the provider owns identity and
-// local accounts cannot sign in at all.
+// One provider at a time: the external one when auth.oidc.discovery_url is
+// configured, the built-in one otherwise. Access tokens arrive as bearer
+// credentials and nothing is stored between requests.
-import { verifyToken, parseCookies, SESSION_COOKIE } from './token.js';
+import { signToken, verifyToken } from './token.js';
import { createOidc } from './oidc.js';
-export { issueToken, verifyToken, sessionCookie, clearedCookie, SESSION_COOKIE } from './token.js';
+export { signToken, verifyToken } from './token.js';
export { hashPassword, verifyPassword, generatePassword, validatePassword } from './password.js';
+// The built-in provider has no registration step, so the app is named here.
+export const LOCAL_CLIENT_ID = 'conductor';
+
export function createAuth({ cfg, users, logger = console }) {
const oidc = cfg.auth.mode === 'oidc' ? createOidc(cfg) : null;
- const localLogin = cfg.auth.mode !== 'oidc';
+ const localLogin = !oidc;
+ const issuer = cfg.server.public_url.replace(/\/+$/, '');
+ const clientId = oidc ? cfg.auth.oidc.client_id : LOCAL_CLIENT_ID;
- // The session cookie is the only credential the conductor itself accepts.
- // Workers and triggers authenticate on their own routes.
- function credentialFrom(req) {
- const cookies = parseCookies(req.headers.cookie);
- return cookies[SESSION_COOKIE] ?? null;
+ // A worker token is 64 hex characters and never parses as a JWT, so the
+ // two meanings of this header cannot be confused.
+ function bearerFrom(req) {
+ const header = req.headers.authorization || '';
+ return header.startsWith('Bearer ') ? header.slice(7).trim() : null;
+ }
+
+ async function verifyAccessToken(token) {
+ if (oidc) {
+ const profile = await oidc.verify(token);
+
+ // The provider vouched for them, but they still need a local row to
+ // own anything, since projects and worker tokens reference users(id).
+ const account = await users.upsertExternal({
+ externalId: `${profile.issuer}|${profile.subject}`,
+ username: profile.username,
+ role: profile.role,
+ });
+ if (account.disabled === 1) return null;
+ return { id: account.id, username: account.username, role: account.role, source: 'oidc' };
+ }
+
+ const claims = verifyToken(cfg.auth.session_secret, token, {
+ use: 'access',
+ iss: issuer,
+ aud: clientId,
+ });
+ if (!claims) return null;
+
+ // Re-read the account, so disabling a user takes effect immediately
+ // rather than when their token happens to expire.
+ const user = await users.get(claims.sub);
+ if (!user || user.disabled === 1) return null;
+
+ return { id: user.id, username: user.username, role: user.role, source: 'local' };
}
return {
mode: cfg.auth.mode,
localLogin,
- // The provider client, or null for built-in accounts. The interface
- // uses it to start the browser login and to end the provider session.
oidc,
+ issuer,
+ clientId,
+
+ accessToken(user, { now = Date.now() } = {}) {
+ return signToken(cfg.auth.session_secret, {
+ use: 'access',
+ iss: issuer,
+ aud: clientId,
+ sub: user.id,
+ name: user.username,
+ role: user.role,
+ ttl: cfg.auth.access_ttl,
+ now,
+ });
+ },
+
+ verifyAccessToken,
// Resolves a request to a user, or null. Never throws: an unparseable
// credential is simply not authenticated.
async identify(req) {
- const credential = credentialFrom(req);
- if (!credential) return null;
-
- if (oidc) {
- try {
- const profile = await oidc.verify(credential);
-
- // The provider vouched for them, but they still need a local row
- // to own anything, since projects and worker tokens reference
- // users(id). Created on first sight, refreshed after that.
- const account = await users.upsertExternal({
- externalId: `${profile.issuer}|${profile.subject}`,
- username: profile.username,
- role: profile.role,
- });
-
- // An administrator can still lock out an account the provider
- // would happily keep admitting.
- if (account.disabled === 1) return null;
-
- return { id: account.id, username: account.username, role: account.role, source: 'oidc' };
- } catch (e) {
- logger.debug?.(`oidc verification failed: ${e.message}`);
- return null;
- }
+ const token = bearerFrom(req);
+ if (!token) return null;
+ try {
+ return await verifyAccessToken(token);
+ } catch (e) {
+ logger.debug?.(`access token rejected: ${e.message}`);
+ return null;
}
-
- const claims = verifyToken(cfg.auth.session_secret, credential);
- if (!claims) return null;
-
- // Re-read the account, so disabling a user takes effect immediately
- // rather than when their token happens to expire.
- const user = await users.get(claims.sub);
- if (!user || user.disabled === 1) return null;
-
- return { id: user.id, username: user.username, role: user.role, source: 'local' };
},
};
}
diff --git a/src/lib/auth/state.js b/src/lib/auth/state.js
@@ -1,58 +0,0 @@
-// src/lib/auth/state.js - the OIDC login state
-//
-// A short signed value that travels to the provider as the state parameter
-// and comes back on the callback. It carries when the login started and,
-// when there is somewhere to return to, that path. It is verified on the
-// way back, so nothing has to be stored between the two requests.
-//
-// body = base64url(JSON.stringify({ iat, url }))
-// sign = base64url(hmacSha256(secret, body))
-// state = body + "." + sign
-
-import crypto from 'node:crypto';
-
-const MAX_AGE = 300;
-
-function sign(secret, body) {
- return crypto.createHmac('sha256', secret).update(body).digest('base64url');
-}
-
-export function createState(secret, url, { now = Date.now() } = {}) {
- const claims = { iat: Math.floor(now / 1000) };
- if (url) claims.url = url;
- const body = Buffer.from(JSON.stringify(claims)).toString('base64url');
- return `${body}.${sign(secret, body)}`;
-}
-
-// Returns { iat, url }, or null when the value is malformed, forged or
-// older than five minutes. A missing url means the root.
-export function verifyState(secret, state, { now = Date.now() } = {}) {
- if (typeof state !== 'string') return null;
-
- const [body, given, ...rest] = state.split('.');
- if (!body || !given || rest.length > 0) return null;
-
- const expected = sign(secret, body);
- const a = Buffer.from(given);
- const b = Buffer.from(expected);
- if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null;
-
- let claims;
- try {
- claims = JSON.parse(Buffer.from(body, 'base64url').toString('utf8'));
- } catch {
- return null;
- }
-
- const seconds = Math.floor(now / 1000);
- if (!Number.isInteger(claims?.iat) || claims.iat > seconds || claims.iat < seconds - MAX_AGE) return null;
-
- const url = claims.url === undefined
- ? '/'
- : typeof claims.url === 'string' && claims.url.startsWith('/') && !claims.url.startsWith('//')
- ? claims.url
- : null;
- if (url === null) return null;
-
- return { iat: claims.iat, url };
-}
diff --git a/src/lib/auth/token.js b/src/lib/auth/token.js
@@ -1,13 +1,12 @@
-// src/lib/auth/token.js - session tokens
+// src/lib/auth/token.js - access tokens and authorization codes
//
-// Compact HS256 tokens in the JWT shape, so they can be inspected with any
-// ordinary tool, signed with node:crypto rather than a library. The jose
-// dependency exists for OIDC, where the token is issued elsewhere and the
-// verification rules genuinely are complicated. Signing our own does not
-// need it.
+// Compact HS256 tokens in the JWT shape, signed with node:crypto rather than
+// a library. The jose dependency exists for the external provider, where the
+// verification rules genuinely are complicated.
//
-// A token carries only the subject, name and role. Anything more would go
-// stale, since it is not re-read from the database on every request.
+// Both kinds are signed with the same secret and are otherwise the same
+// shape, so _tu separates them and is checked on every path. Without it an
+// authorization code would pass wherever an access token is expected.
import crypto from 'node:crypto';
@@ -17,12 +16,14 @@ function sign(secret, data) {
return crypto.createHmac('sha256', secret).update(data).digest('base64url');
}
-export function issueToken(secret, { sub, name, role }, { ttl = 43200, now = Date.now() } = {}) {
+export function signToken(secret, { use, iss, aud, sub, ttl, now = Date.now(), ...rest }) {
const issued = Math.floor(now / 1000);
const payload = Buffer.from(JSON.stringify({
+ ...rest,
+ iss,
+ aud,
sub,
- name,
- role,
+ _tu: use,
iat: issued,
exp: issued + ttl,
})).toString('base64url');
@@ -33,7 +34,7 @@ export function issueToken(secret, { sub, name, role }, { ttl = 43200, now = Dat
// Returns the claims, or null. Never throws, so a malformed token from a
// browser is an ordinary 401 rather than a 500.
-export function verifyToken(secret, token, { now = Date.now() } = {}) {
+export function verifyToken(secret, token, { use, iss, aud, now = Date.now() }) {
if (typeof token !== 'string') return null;
const parts = token.split('.');
@@ -53,43 +54,9 @@ export function verifyToken(secret, token, { now = Date.now() } = {}) {
}
if (typeof claims !== 'object' || claims === null) return null;
+ if (claims._tu !== use) return null;
+ if (claims.iss !== iss || claims.aud !== aud) return null;
if (typeof claims.exp !== 'number' || claims.exp * 1000 <= now) return null;
return claims;
}
-
-export const SESSION_COOKIE = 'conductor_session';
-
-// Minimal cookie parsing, to avoid a dependency for one header.
-export function parseCookies(header) {
- const out = {};
- if (typeof header !== 'string') return out;
- for (const part of header.split(';')) {
- const index = part.indexOf('=');
- if (index === -1) continue;
- const key = part.slice(0, index).trim();
- if (!key) continue;
- try {
- out[key] = decodeURIComponent(part.slice(index + 1).trim());
- } catch {
- out[key] = part.slice(index + 1).trim();
- }
- }
- return out;
-}
-
-export function sessionCookie(token, { ttl = 43200, secure = false } = {}) {
- const attributes = [
- `${SESSION_COOKIE}=${encodeURIComponent(token)}`,
- 'Path=/',
- 'HttpOnly',
- 'SameSite=Lax',
- `Max-Age=${ttl}`,
- ];
- if (secure) attributes.push('Secure');
- return attributes.join('; ');
-}
-
-export function clearedCookie() {
- return `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
-}
diff --git a/src/lib/errors.js b/src/lib/errors.js
@@ -71,6 +71,23 @@ export class InvalidWorkerToken extends ApiError {
static defaultMessage = 'The worker token is missing or wrong';
}
+export class NotAuthenticated extends ApiError {
+ static code = 401;
+ static defaultMessage = 'This endpoint needs an access token';
+}
+
+// Only for something the caller is allowed to know exists. Anything else is
+// a 404, so identifiers cannot be probed.
+export class NotPermitted extends ApiError {
+ static code = 403;
+ static defaultMessage = 'That is not yours to do';
+}
+
+export class Conflict extends ApiError {
+ static code = 409;
+ static defaultMessage = 'That conflicts with something that already exists';
+}
+
// 403 reaching for a task it never held, 409 losing one mid-report.
export class TaskNotHeld extends ApiError {
static code = 403;
diff --git a/src/lib/page.js b/src/lib/page.js
@@ -7,80 +7,26 @@
// LIMIT and OFFSET are bound rather than pasted in. All three dialects
// accept that, and retention already relies on it.
-export const PER_PAGE = 25;
-
-// A page number out of a query string. Anything unusable reads as the
-// first page: a mistyped url should still answer with a table.
-export function pageOf(query, perPage = PER_PAGE) {
- const raw = query?.page;
- const n = Number.parseInt(typeof raw === 'string' ? raw : '', 10);
- if (!Number.isFinite(n) || n < 1) return 1;
- // Far enough out that the offset stays a sane integer.
- return Math.min(n, Math.ceil(Number.MAX_SAFE_INTEGER / perPage));
-}
-
-export function offsetOf(page, perPage = PER_PAGE) {
- return (page - 1) * perPage;
-}
-
-// Describes a page of rows for whoever renders it.
-export function windowOf({ rows, total, page, perPage = PER_PAGE }) {
- const from = offsetOf(page, perPage);
- return {
- rows,
- total,
- page,
- perPage,
- pages: Math.max(1, Math.ceil(total / perPage)),
- // 1 based and inclusive, for "11 to 20 of 57".
- first: rows.length === 0 ? 0 : from + 1,
- last: from + rows.length,
- };
-}
-
-// Runs the rows and their count as one window. Sharing from and where
-// between the two is the whole point; they cannot drift apart.
-export async function paginate(db, {
- select, from, where = '1 = 1', order, params = {}, page, perPage = PER_PAGE,
-}) {
- const rows = await db.all(
- `SELECT ${select} FROM ${from} WHERE ${where} ORDER BY ${order}
- LIMIT {pageSize} OFFSET {pageFrom}`,
- { ...params, pageSize: perPage, pageFrom: offsetOf(page, perPage) }
- );
-
- const counted = await db.get(
- `SELECT COUNT(*) AS total FROM ${from} WHERE ${where}`,
- params
- );
-
- return windowOf({ rows, total: Number(counted?.total ?? 0), page, perPage });
-}
-
-// For a list already in memory, where the source is not a single query.
-export function windowOfAll(all, page, perPage = PER_PAGE) {
- const from = offsetOf(page, perPage);
- return windowOf({ rows: all.slice(from, from + perPage), total: all.length, page, perPage });
-}
-
-// --- offset windows, for the API ---
-//
-// The interface pages by number because a person reads "page 3 of 7". A
-// program asks from an offset and follows the url it is handed.
-
export const API_LIMIT = 50;
export const API_MAX_LIMIT = 200;
+// A query string gives strings, a json body gives numbers, and both are
+// accepted so the two ways of asking cannot disagree.
+function asInt(value) {
+ if (typeof value === 'number') return Number.isInteger(value) ? value : NaN;
+ return Number.parseInt(typeof value === 'string' ? value : '', 10);
+}
+
// Anything unusable reads as the default, and the cap applies silently: a
// client asking for everything gets a page, not an error to learn about.
export function limitOf(query, fallback = API_LIMIT, max = API_MAX_LIMIT) {
- const n = Number.parseInt(typeof query?.limit === 'string' ? query.limit : '', 10);
+ const n = asInt(query?.limit);
if (!Number.isFinite(n) || n < 1) return fallback;
return Math.min(n, max);
}
export function offsetOfQuery(query, max = API_MAX_LIMIT) {
- const n = Number.parseInt(typeof query?.offset === 'string' ? query.offset : '', 10);
+ const n = asInt(query?.offset);
if (!Number.isFinite(n) || n < 0) return 0;
// Far enough out that offset + limit stays a safe integer.
return Math.min(n, Number.MAX_SAFE_INTEGER - max);
diff --git a/src/lib/projects.js b/src/lib/projects.js
@@ -13,7 +13,7 @@
// original value, so it is sealed with the secret box.
import { newProjectId, slugify } from './ids.js';
-import { paginate } from './page.js';
+import { paginateOffset } from './page.js';
import { parseWorkdir } from './pipeline/parse.js';
import { Problems } from './pipeline/schema.js';
@@ -70,17 +70,19 @@ export function createProjects({ db, secrets }) {
return db.all(`SELECT ${COLUMNS} FROM projects WHERE owner_id = {owner} ORDER BY name`, { owner: ownerId });
},
- // One page of what this user manages: everything for an administrator,
- // their own otherwise. Same rule as the two lists above, counted.
- async pageManagedBy(user, page) {
+ // One page of what this user may see: everything for an administrator,
+ // their own plus anything public otherwise.
+ async pageVisible(user, { offset, limit }) {
const admin = user && user.role === 'admin';
- return paginate(db, {
+ const mine = user ? "(owner_id = {owner} OR visibility = 'public')" : "visibility = 'public'";
+ return paginateOffset(db, {
select: COLUMNS,
from: 'projects',
- where: admin ? '1 = 1' : 'owner_id = {owner}',
+ where: admin ? '1 = 1' : mine,
order: 'name',
- params: admin ? {} : { owner: user.id },
- page,
+ params: user && !admin ? { owner: user.id } : {},
+ offset,
+ limit,
});
},
diff --git a/src/lib/static.js b/src/lib/static.js
@@ -1,10 +1,8 @@
-// src/lib/static.js - the interface's static assets
+// src/lib/static.js - the built application bundle
//
-// 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.
+// The directory is read once at startup and the routes are built from what
+// was actually found, so nothing can be requested that is not on disk and
+// path traversal is impossible by construction rather than by escaping.
import fs from 'node:fs/promises';
import path from 'node:path';
@@ -12,37 +10,95 @@ import crypto from 'node:crypto';
import { fileURLToPath } from 'node:url';
const HERE = path.dirname(fileURLToPath(import.meta.url));
-export const ASSET_DIR = path.resolve(HERE, '../../assets');
+export const WEB_DIR = path.resolve(HERE, '../../web/dist');
-const FILES = {
- '/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' },
+const TYPES = {
+ '.css': 'text/css; charset=utf-8',
+ '.html': 'text/html; charset=utf-8',
+ '.ico': 'image/x-icon',
+ '.js': 'text/javascript; charset=utf-8',
+ '.json': 'application/json; charset=utf-8',
+ '.map': 'application/json; charset=utf-8',
+ '.png': 'image/png',
+ '.svg': 'image/svg+xml',
+ '.txt': 'text/plain; charset=utf-8',
+ '.webmanifest': 'application/manifest+json',
+ '.woff': 'font/woff',
+ '.woff2': 'font/woff2',
};
-export default async function staticRoutes(fastify, { dir = ASSET_DIR } = {}) {
- // Read once at startup. These are small and never change at runtime.
+// Nothing in the bundle is inline, which is what makes this possible, and it
+// is what keeps a refresh token in local storage from being readable by an
+// injected script.
+export function contentSecurityPolicy(connect = []) {
+ return [
+ "default-src 'self'",
+ "script-src 'self'",
+ "style-src 'self'",
+ "img-src 'self' data:",
+ "font-src 'self'",
+ `connect-src 'self'${connect.length > 0 ? ` ${connect.join(' ')}` : ''}`,
+ "frame-ancestors 'none'",
+ "base-uri 'none'",
+ "form-action 'self'",
+ ].join('; ');
+}
+
+async function collect(dir, base = '') {
+ const out = [];
+ let entries;
+ try {
+ entries = await fs.readdir(dir, { withFileTypes: true });
+ } catch {
+ // A tree with no build simply serves nothing.
+ return out;
+ }
+
+ for (const entry of entries) {
+ const route = `${base}/${entry.name}`;
+ if (entry.isDirectory()) out.push(...await collect(path.join(dir, entry.name), route));
+ else if (entry.isFile()) out.push({ route, file: path.join(dir, entry.name) });
+ }
+ return out;
+}
+
+export default async function staticRoutes(fastify, { dir = WEB_DIR, csp = contentSecurityPolicy() } = {}) {
const cache = new Map();
- for (const [route, entry] of Object.entries(FILES)) {
- let body;
- try {
- body = await fs.readFile(path.join(dir, entry.file));
- } catch {
- // 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)}"`;
- cache.set(route, { body, type: entry.type, etag });
+ for (const { route, file } of await collect(dir)) {
+ const body = await fs.readFile(file);
+ cache.set(route, {
+ body,
+ type: TYPES[path.extname(file)] ?? 'application/octet-stream',
+ etag: `"${crypto.createHash('sha256').update(body).digest('hex').slice(0, 32)}"`,
+ });
+ }
+
+ const index = cache.get('/index.html');
+
+ function serve(reply, entry, req) {
+ if (req.headers['if-none-match'] === entry.etag) return reply.code(304).send();
+ reply.header('content-type', entry.type);
+ reply.header('etag', entry.etag);
+ reply.header('cache-control', 'no-cache');
+ if (entry === index) reply.header('content-security-policy', csp);
+ return reply.send(entry.body);
}
for (const [route, entry] of cache) {
- fastify.get(route, async (req, reply) => {
- if (req.headers['if-none-match'] === entry.etag) return reply.code(304).send();
- reply.header('content-type', entry.type);
- reply.header('etag', entry.etag);
- reply.header('cache-control', 'no-cache');
- return reply.send(entry.body);
- });
+ fastify.get(route, async (req, reply) => serve(reply, entry, req));
+ if (route === '/index.html') fastify.get('/', async (req, reply) => serve(reply, entry, req));
}
+
+ if (!index) return;
+
+ // The application routes client side, so a deep link is a page request
+ // rather than a missing file. The API answers its own 404s inside its own
+ // prefix, so those are unaffected.
+ fastify.setNotFoundHandler((req, reply) => {
+ if (req.method !== 'GET' || !(req.headers.accept ?? '').includes('text/html')) {
+ return reply.code(404).send({ ok: false, error: { name: 'NotFound', message: 'not found' } });
+ }
+ return serve(reply, index, req);
+ });
}
diff --git a/src/lib/users.js b/src/lib/users.js
@@ -4,7 +4,7 @@
// and never recoverable; a forgotten password is reset, not read.
import { newUserId } from './ids.js';
-import { paginate } from './page.js';
+import { paginateOffset } from './page.js';
import { hashPassword, verifyPassword, generatePassword, validatePassword } from './auth/password.js';
export const ROLES = ['admin', 'user'];
@@ -103,14 +103,15 @@ export function createUsers({ db, logger = console }) {
);
},
- async pageWithHoldings(page) {
- return paginate(db, {
+ async pageWithHoldings({ offset, limit }) {
+ return paginateOffset(db, {
select: `u.id, u.username, u.role, u.disabled, u.external_id, 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: 'u.username',
- page,
+ offset,
+ limit,
});
},
diff --git a/src/lib/workers.js b/src/lib/workers.js
@@ -13,7 +13,7 @@
// advertises is kept per reported name in worker_agents.
import { newToken, hashToken, newWorkerTokenId, newWorkerAgentId } from './ids.js';
-import { paginate, windowOf } from './page.js';
+import { paginateOffset } from './page.js';
function parseList(value) {
try {
@@ -127,16 +127,17 @@ export function createWorkerTokens({ db }) {
// One page of listVisible. An anonymous caller sees nothing, so the
// window is empty rather than a query nobody needs.
- async pageVisible(user, page) {
- if (!user) return windowOf({ rows: [], total: 0, page });
+ async pageVisible(user, { offset, limit }) {
+ if (!user) return { rows: [], total: 0, offset, limit };
const admin = user.role === 'admin';
- return paginate(db, {
+ return paginateOffset(db, {
select: 'id, name, enabled, owner_id, created_at, last_seen_at, last_ip',
from: 'worker_tokens',
where: admin ? '1 = 1' : 'owner_id = {owner}',
order: 'created_at DESC',
params: admin ? {} : { owner: user.id },
- page,
+ offset,
+ limit,
});
},
diff --git a/test/api.test.js b/test/api.test.js
@@ -117,7 +117,7 @@ test('a page reports where it sits and how many there are', async () => {
const job = data(await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).job_id;
await h.waitForJob(job);
- const res = await h.app.inject({ method: 'GET', url: `/api/v1/projects/demo/jobs/${job}?limit=2` });
+ const res = await h.app.inject({ method: 'GET', url: `/api/v1/jobs/${job}/tasks?limit=2` });
assert.equal(res.statusCode, 200, res.body);
const body = res.json();
@@ -135,7 +135,7 @@ test('following next walks the whole collection exactly once', async () => {
await h.waitForJob(job);
const seen = [];
- let url = `/api/v1/projects/demo/jobs/${job}?limit=2`;
+ let url = `/api/v1/jobs/${job}/tasks?limit=2`;
for (let i = 0; i < 20 && url; i += 1) {
const res = await h.app.inject({ method: 'GET', url });
const body = res.json();
@@ -156,7 +156,7 @@ test('next is absent on the last page', async () => {
const job = data(await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).job_id;
await h.waitForJob(job);
- const res = await h.app.inject({ method: 'GET', url: `/api/v1/projects/demo/jobs/${job}` });
+ const res = await h.app.inject({ method: 'GET', url: `/api/v1/jobs/${job}/tasks` });
const { pagination } = res.json();
assert.equal(pagination.offset, 0);
@@ -171,7 +171,7 @@ test('an unusable offset or limit falls back rather than failing', async () => {
const res = await h.app.inject({
method: 'GET',
- url: `/api/v1/projects/demo/jobs/${job}?offset=-5&limit=banana`,
+ url: `/api/v1/jobs/${job}/tasks?offset=-5&limit=banana`,
});
assert.equal(res.statusCode, 200, res.body);
diff --git a/test/auth.test.js b/test/auth.test.js
@@ -1,10 +1,9 @@
-// test/auth.test.js - password hashing, session tokens and role mapping
+// test/auth.test.js - password hashing, access tokens and role mapping
import test from 'node:test';
import assert from 'node:assert/strict';
import { hashPassword, verifyPassword, validatePassword, generatePassword } from '../src/lib/auth/password.js';
-import { issueToken, verifyToken, parseCookies, sessionCookie, SESSION_COOKIE } from '../src/lib/auth/token.js';
-import { createState, verifyState } from '../src/lib/auth/state.js';
+import { signToken, verifyToken } from '../src/lib/auth/token.js';
import { collectRoles } from '../src/lib/auth/oidc.js';
import { maskBuffer } from '../src/lib/variables.js';
@@ -48,75 +47,66 @@ test('password length is bounded', () => {
assert.ok(generatePassword().length >= 20);
});
-test('a session token round trips its claims', () => {
- const token = issueToken('secret', { sub: 'u1', name: 'alice', role: 'admin' });
- const claims = verifyToken('secret', token);
+const ACCESS = { use: 'access', iss: 'https://ci.example', aud: 'conductor' };
+
+test('an access token round trips its claims', () => {
+ const token = signToken('secret', { ...ACCESS, sub: 'u1', name: 'alice', role: 'admin', ttl: 900 });
+ const claims = verifyToken('secret', token, ACCESS);
assert.equal(claims.sub, 'u1');
assert.equal(claims.name, 'alice');
assert.equal(claims.role, 'admin');
});
test('a token signed with another secret is refused', () => {
- const token = issueToken('secret', { sub: 'u1', name: 'a', role: 'admin' });
- assert.equal(verifyToken('other-secret', token), null);
+ const token = signToken('secret', { ...ACCESS, sub: 'u1', ttl: 900 });
+ assert.equal(verifyToken('other-secret', token, ACCESS), null);
});
test('a tampered payload is refused', () => {
- const token = issueToken('secret', { sub: 'u1', name: 'a', role: 'user' });
+ const token = signToken('secret', { ...ACCESS, sub: 'u1', role: 'user', ttl: 900 });
const [header, payload, signature] = token.split('.');
const forged = Buffer.from(JSON.stringify({
...JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')),
role: 'admin',
})).toString('base64url');
- assert.equal(verifyToken('secret', `${header}.${forged}.${signature}`), null);
+ assert.equal(verifyToken('secret', `${header}.${forged}.${signature}`, ACCESS), null);
});
test('an expired token is refused', () => {
- const token = issueToken('secret', { sub: 'u1', name: 'a', role: 'admin' }, { ttl: 60 });
- assert.ok(verifyToken('secret', token));
- assert.equal(verifyToken('secret', token, { now: Date.now() + 61_000 }), null);
+ const token = signToken('secret', { ...ACCESS, sub: 'u1', ttl: 60 });
+ assert.ok(verifyToken('secret', token, ACCESS));
+ assert.equal(verifyToken('secret', token, { ...ACCESS, now: Date.now() + 61_000 }), null);
});
test('a malformed token is refused rather than throwing', () => {
for (const bad of ['', 'a.b', 'a.b.c.d', 'not-a-token', null, undefined, 12]) {
- assert.equal(verifyToken('secret', bad), null);
+ assert.equal(verifyToken('secret', bad, ACCESS), null);
}
});
-test('cookies are parsed and issued', () => {
- const cookies = parseCookies(`a=1; ${SESSION_COOKIE}=abc%20def; b=2`);
- assert.equal(cookies[SESSION_COOKIE], 'abc def');
- assert.deepEqual(parseCookies(undefined), {});
-
- const header = sessionCookie('tok', { ttl: 60, secure: true });
- assert.ok(header.includes('HttpOnly'));
- assert.ok(header.includes('SameSite=Lax'));
- assert.ok(header.includes('Secure'));
- assert.ok(header.includes('Max-Age=60'));
- assert.ok(!sessionCookie('tok', { secure: false }).includes('Secure'));
+// A worker token shares the Authorization header with these.
+test('an opaque worker token never parses as an access token', () => {
+ assert.equal(verifyToken('secret', 'a'.repeat(64), ACCESS), null);
});
-test('the oidc login state round trips with its return path', () => {
- const now = 1_700_000_000_000;
- const state = createState('secret', '/projects?tab=1', { now });
- assert.deepEqual(verifyState('secret', state, { now }), { iat: 1_700_000_000, url: '/projects?tab=1' });
-});
+// Both are signed with the same secret, so only _tu keeps them apart.
+test('an authorization code cannot be spent as an access token', () => {
+ const code = signToken('secret', { ...ACCESS, use: 'code', sub: 'u1', ttl: 60 });
-test('a missing return path means the root', () => {
- const state = createState('secret', undefined, { now: 1000 });
- assert.deepEqual(verifyState('secret', state, { now: 1000 }), { iat: 1, url: '/' });
+ assert.ok(verifyToken('secret', code, { ...ACCESS, use: 'code' }));
+ assert.equal(verifyToken('secret', code, ACCESS), null);
});
-test('the oidc login state is refused when forged, stale or off-site', () => {
- const now = 1_700_000_000_000;
- const state = createState('secret', '/', { now });
+test('an access token cannot be redeemed as an authorization code', () => {
+ const token = signToken('secret', { ...ACCESS, sub: 'u1', ttl: 900 });
+ assert.equal(verifyToken('secret', token, { ...ACCESS, use: 'code' }), null);
+});
- assert.equal(verifyState('other-secret', state, { now }), null, 'a wrong secret');
- assert.equal(verifyState('secret', `${state}x`, { now }), null, 'a tampered signature');
- assert.equal(verifyState('secret', state, { now: now + 301_000 }), null, 'older than five minutes');
- assert.equal(verifyState('secret', 'garbage', { now }), null, 'malformed');
- assert.equal(verifyState('secret', createState('secret', '//evil.example', { now }), { now }), null, 'off-site');
+test('a token minted for another issuer or client is refused', () => {
+ const token = signToken('secret', { ...ACCESS, sub: 'u1', ttl: 900 });
+ assert.equal(verifyToken('secret', token, { ...ACCESS, iss: 'https://evil.example' }), null);
+ assert.equal(verifyToken('secret', token, { ...ACCESS, aud: 'other-client' }), null);
});
test('oidc roles are collected from the usual claim shapes', () => {
diff --git a/test/conductor.test.js b/test/conductor.test.js
@@ -272,16 +272,19 @@ test('a job can be read back, with the state of every task', async () => {
await h.waitForJob(jobId);
const signature = h.sign('');
+ const headers = { 'content-type': 'application/json', 'x-hub-signature-256': signature };
+
const signed = await h.app.inject({
method: 'GET',
url: `/api/v1/projects/demo/jobs/${jobId}`,
- headers: { 'content-type': 'application/json', 'x-hub-signature-256': signature },
+ headers,
});
assert.equal(signed.statusCode, 200, signed.body);
+ assert.equal(data(signed).id, jobId);
+ assert.equal(data(signed).state, 'running');
- const body = data(signed);
- assert.equal(body.job.id, jobId);
- assert.equal(body.job.state, 'running');
+ const tasks = await h.app.inject({ method: 'GET', url: `/api/v1/jobs/${jobId}/tasks`, headers });
+ const body = data(tasks);
assert.equal(body.tasks.length, 6);
assert.ok(body.tasks.every((t) => t.state === 'queued'));
assert.ok(body.tasks.some((t) => t.name === 'build:arch=x86_64'));
@@ -295,7 +298,7 @@ test('a public job is readable without any credential at all', async () => {
const res = await h.app.inject({ method: 'GET', url: `/api/v1/projects/demo/jobs/${jobId}` });
assert.equal(res.statusCode, 200, res.body);
- assert.equal(res.json().data.job.visibility, 'public');
+ assert.equal(res.json().data.visibility, 'public');
});
});
@@ -314,7 +317,7 @@ test('a private job needs the trigger secret, and is otherwise absent', async ()
headers: { 'content-type': 'application/json', 'x-hub-signature-256': h.sign('') },
});
assert.equal(signed.statusCode, 200, signed.body);
- assert.equal(signed.json().data.job.visibility, 'private');
+ assert.equal(signed.json().data.visibility, 'private');
});
});
@@ -508,10 +511,10 @@ test('a private task is readable by an administrator, by either path', async ()
const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
await h.waitForJob(res.json().data.job_id);
const task = await claimNamed(h, 'lint', {});
- const { cookie } = await h.login();
+ const { headers } = await h.login();
for (const url of taskUrls(task.id)) {
- const res = await h.app.inject({ method: 'GET', url, headers: { cookie } });
+ const res = await h.app.inject({ method: 'GET', url, headers });
assert.equal(res.statusCode, 200, `${url}: ${res.body}`);
assert.equal(res.json().data.task.id, task.id);
}
@@ -1098,24 +1101,45 @@ test('a second push produces an independent, numbered job', async () => {
});
});
-test('jobs can be listed and filtered by project', apiGone, async () => {
- await withHarness({}, async (h) => {
- await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
+test('a jobs list belongs to its project', async () => {
+ await withHarness({ visibility: 'public' }, async (h) => {
+ const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
+ await h.waitForJob(res.json().data.job_id);
+
+ const mine = await h.app.inject({ method: 'GET', url: '/api/v1/projects/demo/jobs' });
+ assert.equal(mine.json().data.length, 1);
+
+ const other = await h.app.inject({ method: 'GET', url: '/api/v1/projects/absent/jobs' });
+ assert.equal(other.statusCode, 404);
+ });
+});
- const all = await h.app.inject({ method: 'GET', url: '/api/jobs' });
- assert.equal(all.json().data.jobs.length, 1);
+test('a job is addressable with or without its project', async () => {
+ await withHarness({ visibility: 'public' }, async (h) => {
+ const jobId = (await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).json().data.job_id;
+ await h.waitForJob(jobId);
- const mine = await h.app.inject({ method: 'GET', url: '/api/jobs?project=demo' });
- assert.equal(mine.json().data.jobs.length, 1);
+ for (const url of [`/api/v1/jobs/${jobId}`, `/api/v1/projects/demo/jobs/${jobId}`]) {
+ const res = await h.app.inject({ method: 'GET', url });
+ assert.equal(res.statusCode, 200, `${url}: ${res.body}`);
+ assert.equal(res.json().data.id, jobId);
+ }
- const other = await h.app.inject({ method: 'GET', url: '/api/jobs?project=absent' });
- assert.equal(other.json().data.jobs.length, 0);
+ // Naming the wrong project asserts it rather than being ignored.
+ const wrong = await h.app.inject({ method: 'GET', url: `/api/v1/projects/absent/jobs/${jobId}` });
+ assert.equal(wrong.statusCode, 404);
});
});
-test('unknown jobs, tasks and artifacts return 404', apiGone, async () => {
+test('unknown jobs, tasks and artifacts return 404', async () => {
await withHarness({}, async (h) => {
- for (const url of ['/api/jobs/nope', '/api/tasks/nope', '/api/tasks/nope/log', '/api/artifacts/nope']) {
+ const urls = [
+ '/api/v1/jobs/nope',
+ '/api/v1/jobs/nope/tasks',
+ '/api/v1/tasks/nope',
+ '/api/v1/projects/nope',
+ ];
+ for (const url of urls) {
const res = await h.app.inject({ method: 'GET', url });
assert.equal(res.statusCode, 404, `${url} should be 404`);
}
diff --git a/test/dialects.test.js b/test/dialects.test.js
@@ -18,7 +18,7 @@ import path from 'node:path';
import { startDatabase, dialectAvailable } from './helpers/database.js';
import { openDatabase, runMigrations, dialectFromUrl } from '../src/lib/db/index.js';
-import { paginate, paginateOffset, pageBlock } from '../src/lib/page.js';
+import { paginateOffset, pageBlock } from '../src/lib/page.js';
import { createStorage } from '../src/lib/storage/index.js';
import { createLogStore } from '../src/lib/log.js';
import { createRetention } from '../src/conductor/retention.js';
@@ -106,22 +106,18 @@ for (const dialect of ['postgres', 'mysql']) {
// driver would otherwise hand over as a string and turn the page
// arithmetic into concatenation. Checked before the sweep, while
// all five jobs are still there.
- const firstPage = await paginate(db, {
- select: 'id', from: 'jobs', order: 'number', page: 1, perPage: 2,
+ const firstPage = await paginateOffset(db, {
+ select: 'id', from: 'jobs', order: 'number', offset: 0, limit: 2,
});
assert.equal(firstPage.rows.length, 2);
assert.equal(typeof firstPage.total, 'number', 'COUNT came back as a string');
assert.equal(firstPage.total, 5);
- assert.equal(firstPage.pages, 3);
- const lastPage = await paginate(db, {
- select: 'id', from: 'jobs', order: 'number', page: 3, perPage: 2,
+ const lastPage = await paginateOffset(db, {
+ select: 'id', from: 'jobs', order: 'number', offset: 4, limit: 2,
});
assert.deepEqual(lastPage.rows.map((r) => r.id), ['r5'], 'the offset landed wrong');
- // The API pages by offset over the same binding, and its total feeds
- // the decision to send a next url at all, so it has to be a number
- // here for the same reason.
const window = await paginateOffset(db, {
select: 'id', from: 'jobs', order: 'number', offset: 2, limit: 2,
});
diff --git a/test/helpers/harness.js b/test/helpers/harness.js
@@ -113,7 +113,6 @@ export async function startHarness(options = {}) {
' oidc:',
` discovery_url: ${options.oidcDiscoveryUrl}`,
` client_id: ${options.oidcClientId ?? 'conductor'}`,
- ...(options.oidcClientSecret ? [` client_secret: ${options.oidcClientSecret}`] : []),
...(options.oidcScopes ? [` scopes: ${options.oidcScopes}`] : []),
...(options.oidcAdminRole ? [` admin_role: ${options.oidcAdminRole}`] : []),
]
@@ -226,19 +225,56 @@ export async function startHarness(options = {}) {
});
},
- // Signs in through the interface, which is the only way in, and returns
- // the session cookie. The interface's login form is htmx only.
+ // Drives the built-in provider's authorization code flow the way the
+ // application does, and returns the grant plus a ready bearer header.
async login(username = options.adminUsername ?? 'admin', password = options.adminPassword ?? 'bootstrap-password') {
- const res = await app.inject({
+ const redirectUri = `${cfg.server.public_url}/callback`;
+ const verifier = crypto.randomBytes(32).toString('base64url');
+ const query = new URLSearchParams({
+ client_id: 'conductor',
+ redirect_uri: redirectUri,
+ response_type: 'code',
+ code_challenge: crypto.createHash('sha256').update(verifier).digest('base64url'),
+ code_challenge_method: 'S256',
+ state: 'harness',
+ });
+
+ const authorized = await app.inject({
method: 'POST',
- url: '/login',
- headers: { 'hx-request': 'true', 'content-type': 'application/x-www-form-urlencoded' },
+ url: `/auth/authorize?${query}`,
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
payload: new URLSearchParams({ username, password }).toString(),
});
- if (res.statusCode !== 204) {
- throw new Error(`login failed: ${res.statusCode} ${res.body}`);
+ if (authorized.statusCode !== 303) {
+ throw new Error(`authorize failed: ${authorized.statusCode} ${authorized.body.slice(0, 200)}`);
}
- return { cookie: res.headers['set-cookie'].split(';')[0] };
+
+ const code = new URL(authorized.headers.location).searchParams.get('code');
+ const granted = await this.token({
+ grant_type: 'authorization_code',
+ code,
+ redirect_uri: redirectUri,
+ code_verifier: verifier,
+ });
+ if (granted.statusCode !== 200) {
+ throw new Error(`token exchange failed: ${granted.statusCode} ${granted.body.slice(0, 200)}`);
+ }
+
+ const grant = granted.json();
+ return { ...grant, headers: { authorization: `Bearer ${grant.access_token}` } };
+ },
+
+ async token(body) {
+ return app.inject({
+ method: 'POST',
+ url: '/auth/token',
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
+ payload: new URLSearchParams(body).toString(),
+ });
+ },
+
+ async discovery() {
+ return app.inject({ method: 'GET', url: '/.well-known/openid-configuration' });
},
async stop() {
diff --git a/test/helpers/oidc.js b/test/helpers/oidc.js
@@ -12,7 +12,6 @@ import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import crypto from 'node:crypto';
import { reapStale } from './containers.js';
-import { SESSION_COOKIE } from '../../src/lib/auth/token.js';
const execFileAsync = promisify(execFile);
@@ -76,6 +75,23 @@ const PERSONAS = {
aud: ['conductor'],
exp: 1000000000,
},
+ // What Keycloak actually issues: the client in azp, the resource server
+ // in aud. Every persona above names the client in aud, which a broken
+ // audience rule would pass.
+ 'persona-azp': {
+ sub: 'heidi-sub',
+ preferred_username: 'heidi',
+ realm_access: { roles: ['conductor-admin'] },
+ aud: ['account'],
+ azp: 'conductor',
+ },
+ 'persona-other-client': {
+ sub: 'ivan-sub',
+ preferred_username: 'ivan',
+ realm_access: { roles: ['conductor-admin'] },
+ aud: ['account'],
+ azp: 'some-other-client',
+ },
};
export async function dockerAvailable() {
@@ -183,10 +199,10 @@ export async function startOidc() {
return (await res.json()).access_token;
},
- // A session cookie for a persona, as the browser would carry after
- // signing in. The conductor accepts no other credential.
+ // The bearer header for a persona, as the application would send it
+ // after running the code flow against this provider directly.
async headers(persona) {
- return { cookie: `${SESSION_COOKIE}=${await this.token(persona)}` };
+ return { authorization: `Bearer ${await this.token(persona)}` };
},
// Completes the authorization code flow for a URL the conductor built,
diff --git a/test/oidc.test.js b/test/oidc.test.js
@@ -105,45 +105,49 @@ test('discovery reports an unreachable provider and a bad status', async () => {
);
});
-test('a token carrying the admin role authenticates as an administrator', apiGone, async () => {
+test('a token carrying the admin role authenticates as an administrator', opts, async () => {
await withOidcConductor({}, async (h) => {
const headers = await oidc.headers('persona-admin');
- const me = await h.app.inject({ method: 'GET', url: '/api/auth/me', headers });
+ const me = await h.app.inject({ method: 'GET', url: '/api/v1/users/me', headers });
assert.equal(me.statusCode, 200);
- assert.equal(me.json().user.username, 'alice');
- assert.equal(me.json().user.role, 'admin');
- assert.equal(me.json().user.source, 'oidc');
+ assert.equal(me.json().data.username, 'alice');
+ assert.equal(me.json().data.role, 'admin');
+ assert.equal(me.json().data.source, 'oidc');
- // And can reach the administrator only surface.
- assert.equal((await h.app.inject({ method: 'GET', url: '/api/admin/users', headers })).statusCode, 200);
+ const users = await h.app.inject({ method: 'GET', url: '/api/v1/users', headers });
+ assert.equal(users.statusCode, 200);
+ assert.ok(users.json().data.length >= 1);
});
});
-test('a token without the admin role is an ordinary user', apiGone, async () => {
+test('a token without the admin role is an ordinary user', opts, async () => {
await withOidcConductor({}, async (h) => {
const headers = await oidc.headers('persona-viewer');
- const me = await h.app.inject({ method: 'GET', url: '/api/auth/me', headers });
- assert.equal(me.json().user.username, 'bob');
- assert.equal(me.json().user.role, 'user');
+ const me = await h.app.inject({ method: 'GET', url: '/api/v1/users/me', headers });
+ assert.equal(me.json().data.username, 'bob');
+ assert.equal(me.json().data.role, 'user');
- assert.equal((await h.app.inject({ method: 'GET', url: '/api/admin/users', headers })).statusCode, 403);
+ // Listing accounts returns only their own rather than refusing.
+ const users = await h.app.inject({ method: 'GET', url: '/api/v1/users', headers });
+ assert.equal(users.statusCode, 200);
+ assert.deepEqual(users.json().data.map((u) => u.username), ['bob']);
});
});
-test('roles are recognised in the groups and resource_access shapes', apiGone, async () => {
+test('roles are recognised in the groups and resource_access shapes', opts, async () => {
await withOidcConductor({}, async (h) => {
for (const persona of ['persona-groups', 'persona-resource']) {
const me = await h.app.inject({
- method: 'GET', url: '/api/auth/me', headers: await oidc.headers(persona),
+ method: 'GET', url: '/api/v1/users/me', headers: await oidc.headers(persona),
});
- assert.equal(me.json().user.role, 'admin', `${persona} should map to admin`);
+ assert.equal(me.json().data.role, 'admin', `${persona} should map to admin`);
}
});
});
-test('an OIDC user gets a local account and can own a project', apiGone, async () => {
+test('an OIDC user gets a local account and can own a project', opts, async () => {
await withOidcConductor({}, async (h) => {
const headers = await oidc.headers('persona-viewer');
@@ -152,7 +156,7 @@ test('an OIDC user gets a local account and can own a project', apiGone, async (
const created = await h.app.inject({
method: 'POST',
- url: '/api/projects',
+ url: '/api/v1/projects',
headers: json(headers),
payload: JSON.stringify({ id: 'bobs-app', repo_url: 'https://git.example.com/bob.git' }),
});
@@ -161,7 +165,7 @@ test('an OIDC user gets a local account and can own a project', apiGone, async (
const account = await h.services.users.byUsername('bob');
assert.ok(account, 'a local account should have been provisioned');
- assert.equal(created.json().project.owner_id, account.id);
+ assert.equal(created.json().data.owner_id, account.id);
// The account cannot be signed into with a password.
assert.equal(await h.services.users.authenticate('bob', 'external'), null);
@@ -169,22 +173,22 @@ test('an OIDC user gets a local account and can own a project', apiGone, async (
});
});
-test('the same subject keeps one account even when the display name changes', apiGone, async () => {
+test('the same subject keeps one account even when the display name changes', opts, async () => {
await withOidcConductor({}, async (h) => {
const first = await h.app.inject({
- method: 'GET', url: '/api/auth/me', headers: await oidc.headers('persona-admin'),
+ method: 'GET', url: '/api/v1/users/me', headers: await oidc.headers('persona-admin'),
});
const second = await h.app.inject({
- method: 'GET', url: '/api/auth/me', headers: await oidc.headers('persona-admin-renamed'),
+ method: 'GET', url: '/api/v1/users/me', headers: await oidc.headers('persona-admin-renamed'),
});
// Identity is the subject, not the label the provider shows.
- assert.equal(second.json().user.id, first.json().user.id);
+ assert.equal(second.json().data.id, first.json().data.id);
assert.equal((await h.services.users.list()).filter((u) => u.external_id).length, 1);
});
});
-test('a display name colliding with a local account gets its own username', apiGone, async () => {
+test('a display name colliding with a local account gets its own username', opts, async () => {
await withOidcConductor({}, async (h) => {
// A local account that predates OIDC already holds the name 'admin'.
const local = await h.services.users.create({
@@ -192,21 +196,21 @@ test('a display name colliding with a local account gets its own username', apiG
});
const me = await h.app.inject({
- method: 'GET', url: '/api/auth/me', headers: await oidc.headers('persona-collision'),
+ method: 'GET', url: '/api/v1/users/me', headers: await oidc.headers('persona-collision'),
});
assert.equal(me.statusCode, 200);
- assert.notEqual(me.json().user.id, local.id, 'the local account must not be taken over');
- assert.notEqual(me.json().user.username, 'admin');
- assert.match(me.json().user.username, /^admin-\d+$/);
+ assert.notEqual(me.json().data.id, local.id, 'the local account must not be taken over');
+ assert.notEqual(me.json().data.username, 'admin');
+ assert.match(me.json().data.username, /^admin-\d+$/);
});
});
-test('a role revoked at the provider is lost on the next request', apiGone, async () => {
+test('a role revoked at the provider is lost on the next request', opts, async () => {
await withOidcConductor({}, async (h) => {
const asAdmin = await h.app.inject({
- method: 'GET', url: '/api/auth/me', headers: await oidc.headers('persona-admin'),
+ method: 'GET', url: '/api/v1/users/me', headers: await oidc.headers('persona-admin'),
});
- assert.equal(asAdmin.json().user.role, 'admin');
+ assert.equal(asAdmin.json().data.role, 'admin');
// The provider stops asserting the role for that subject.
const account = await h.services.users.byExternalId(`${oidc.issuer}|alice-sub`);
@@ -218,167 +222,122 @@ test('a role revoked at the provider is lost on the next request', apiGone, asyn
});
});
-test('an account disabled locally is refused despite a valid token', apiGone, async () => {
+test('an account disabled locally is refused despite a valid token', opts, async () => {
await withOidcConductor({}, async (h) => {
const headers = await oidc.headers('persona-admin');
- assert.equal((await h.app.inject({ method: 'GET', url: '/api/auth/me', headers })).statusCode, 200);
+ assert.equal((await h.app.inject({ method: 'GET', url: '/api/v1/users/me', headers })).statusCode, 200);
const account = await h.services.users.byExternalId(`${oidc.issuer}|alice-sub`);
await h.services.users.setDisabled(account.id, true);
// The provider would still admit them; the conductor does not.
- assert.equal((await h.app.inject({ method: 'GET', url: '/api/auth/me', headers })).statusCode, 401);
+ assert.equal((await h.app.inject({ method: 'GET', url: '/api/v1/users/me', headers })).statusCode, 401);
});
});
-test('a forged signature is refused', apiGone, async () => {
+test('a forged signature is refused', opts, async () => {
await withOidcConductor({}, async (h) => {
const token = await forgedToken(oidc.issuer);
const res = await h.app.inject({
- method: 'GET', url: '/api/auth/me', headers: { cookie: `conductor_session=${token}` },
+ method: 'GET', url: '/api/v1/users/me', headers: { authorization: `Bearer ${token}` },
});
assert.equal(res.statusCode, 401);
assert.equal(await h.services.users.byUsername('mallory'), undefined, 'no account should be created');
});
});
-test('an expired token is refused', apiGone, async () => {
+test('an expired token is refused', opts, async () => {
await withOidcConductor({}, async (h) => {
const res = await h.app.inject({
- method: 'GET', url: '/api/auth/me', headers: await oidc.headers('persona-expired'),
+ method: 'GET', url: '/api/v1/users/me', headers: await oidc.headers('persona-expired'),
});
assert.equal(res.statusCode, 401);
});
});
-test('a token from a different issuer is refused', apiGone, async () => {
+test('a token from a different issuer is refused', opts, async () => {
// The conductor trusts the issuer in its discovery document; a token
// minted under another path of the same server must not be accepted.
await withOidcConductor({
oidcDiscoveryUrl: `${oidc.issuer.replace(/\/conductor$/, '/other')}/.well-known/openid-configuration`,
}, async (h) => {
const res = await h.app.inject({
- method: 'GET', url: '/api/auth/me', headers: await oidc.headers('persona-admin'),
+ method: 'GET', url: '/api/v1/users/me', headers: await oidc.headers('persona-admin'),
});
assert.equal(res.statusCode, 401);
});
});
-test('an anonymous visitor asking for a protected page is sent to the provider', opts, async () => {
+// Keycloak puts the client in azp and leaves aud as the resource server, so
+// asserting aud alone rejects every real access token.
+test('a token naming the client only in azp is accepted', opts, async () => {
await withOidcConductor({}, async (h) => {
- const res = await h.app.inject({ method: 'GET', url: '/projects' });
- assert.equal(res.statusCode, 302);
- assert.ok(res.headers.location.startsWith(`${oidc.issuer}/authorize`), res.headers.location);
+ const res = await h.app.inject({
+ method: 'GET', url: '/api/v1/users/me', headers: await oidc.headers('persona-azp'),
+ });
+ assert.equal(res.statusCode, 200, res.body);
+ assert.equal(res.json().data.role, 'admin');
});
});
-test('a browser signs in at the provider and comes back with a session', opts, async () => {
+test('a token issued to another client is refused', opts, async () => {
await withOidcConductor({}, async (h) => {
- // Asking for the login page is a redirect to the provider, built from
- // the endpoint its discovery document advertised.
- const login = await h.app.inject({ method: 'GET', url: '/login' });
- assert.equal(login.statusCode, 302);
- const authorization = new URL(login.headers.location);
- assert.equal(authorization.origin + authorization.pathname, `${oidc.issuer}/authorize`);
- assert.equal(authorization.searchParams.get('client_id'), 'conductor');
- assert.equal(authorization.searchParams.get('redirect_uri'), 'http://conductor.test/oidc/callback');
- assert.ok(authorization.searchParams.get('state'));
-
- // Sign in at the provider, which sends the browser back with a code.
- const callback = await oidc.signIn(login.headers.location, {
- username: 'alice',
- claims: {
- sub: 'alice-sub',
- preferred_username: 'alice',
- realm_access: { roles: ['conductor-admin'] },
- },
- });
- const returned = new URL(callback);
- assert.equal(returned.origin + returned.pathname, 'http://conductor.test/oidc/callback');
-
- const done = await h.app.inject({
- method: 'GET',
- url: returned.pathname + returned.search,
+ const res = await h.app.inject({
+ method: 'GET', url: '/api/v1/users/me', headers: await oidc.headers('persona-other-client'),
});
- assert.equal(done.statusCode, 302, done.body);
- assert.equal(done.headers.location, '/');
-
- const session = done.headers['set-cookie'].split(';')[0];
- const home = await h.app.inject({ method: 'GET', url: '/', headers: { cookie: session } });
- assert.match(home.body, /href="\/projects"/, 'the signed in administrator sees the projects link');
+ assert.equal(res.statusCode, 401);
});
});
-test('a callback with a forged state is refused', opts, async () => {
+test('discovery passes the provider through and names the client', opts, async () => {
await withOidcConductor({}, async (h) => {
- const res = await h.app.inject({ method: 'GET', url: '/oidc/callback?code=x&state=forged.signature' });
- assert.equal(res.statusCode, 400);
- assert.match(res.body, /did not start here/);
- });
-});
+ const res = await h.discovery();
+ assert.equal(res.statusCode, 200);
-test('local passwords are refused while OIDC is in charge', apiGone, async () => {
- await withOidcConductor({ bootstrap: true }, async (h) => {
- const res = await h.app.inject({
- method: 'POST',
- url: '/api/auth/login',
- headers: { 'content-type': 'application/json' },
- payload: JSON.stringify({ username: 'admin', password: 'bootstrap-password' }),
- });
- assert.equal(res.statusCode, 400);
- assert.match(res.json().error, /OIDC/);
+ const doc = res.json();
+ assert.equal(doc.issuer, oidc.issuer);
+ assert.equal(doc.authorization_endpoint, `${oidc.issuer}/authorize`);
+ assert.equal(doc.client_id, 'conductor');
});
});
-test('an OIDC administrator can drive the interface', opts, async () => {
+// Only one provider is ever advertised, so only one may answer.
+test('the built-in provider is not mounted while a provider is configured', opts, async () => {
await withOidcConductor({}, async (h) => {
- const headers = await oidc.headers('persona-admin');
-
- const page = await h.app.inject({ method: 'GET', url: '/', headers });
- assert.equal(page.statusCode, 200);
- assert.match(page.body, /href="\/projects"/);
- // Accounts cannot sign in while the provider owns identity, so the users
- // page is kept off the nav even for an administrator.
- assert.ok(!page.body.includes('href="/users"'), 'the users link should be hidden');
-
- // It remains reachable directly, for preparing to move off OIDC.
- const users = await h.app.inject({ method: 'GET', url: '/users', headers });
- assert.equal(users.statusCode, 200);
- assert.match(users.body, /alice/);
+ for (const url of ['/auth/authorize', '/auth/token', '/auth/jwks']) {
+ assert.equal((await h.app.inject({ method: 'GET', url })).statusCode, 404, url);
+ }
});
});
-test('worker ownership follows the OIDC identity', apiGone, async () => {
+test('worker ownership follows the OIDC identity', opts, async () => {
await withOidcConductor({ visibility: 'private' }, async (h) => {
const alice = await oidc.headers('persona-admin');
const bob = await oidc.headers('persona-viewer');
- // Bob owns a project and registers a worker for it.
const project = await h.app.inject({
method: 'POST',
- url: '/api/projects',
+ url: '/api/v1/projects',
headers: json(bob),
payload: JSON.stringify({ id: 'bob-proj', repo_url: h.repoDir }),
});
- assert.equal(project.statusCode, 201);
+ assert.equal(project.statusCode, 201, project.body);
const bobToken = await h.app.inject({
- method: 'POST', url: '/api/worker-tokens', headers: json(bob), payload: JSON.stringify({ name: 'bob-pi' }),
+ method: 'POST', url: '/api/v1/workers', headers: json(bob), payload: JSON.stringify({ name: 'bob-pi' }),
});
const aliceToken = await h.app.inject({
- method: 'POST', url: '/api/worker-tokens', headers: json(alice), payload: JSON.stringify({ name: 'alice-pi' }),
+ method: 'POST', url: '/api/v1/workers', headers: json(alice), payload: JSON.stringify({ name: 'alice-pi' }),
});
- // Alice cannot see bob's worker, even as an administrator listing her
- // own, and bob cannot see the project he does not own.
- const aliceList = await h.app.inject({ method: 'GET', url: '/api/worker-tokens', headers: alice });
- const names = aliceList.json().worker_tokens.map((t) => t.name);
+ const aliceList = await h.app.inject({ method: 'GET', url: '/api/v1/workers', headers: alice });
+ const names = aliceList.json().data.map((t) => t.name);
assert.ok(names.includes('bob-pi'), 'an administrator sees every worker');
- const bobList = await h.app.inject({ method: 'GET', url: '/api/worker-tokens', headers: bob });
- assert.deepEqual(bobList.json().worker_tokens.map((t) => t.name), ['bob-pi']);
+ const bobList = await h.app.inject({ method: 'GET', url: '/api/v1/workers', headers: bob });
+ assert.deepEqual(bobList.json().data.map((t) => t.name), ['bob-pi']);
- assert.ok(bobToken.json().token);
- assert.ok(aliceToken.json().token);
+ assert.ok(bobToken.json().data.token);
+ assert.ok(aliceToken.json().data.token);
});
});
diff --git a/test/pagination.test.js b/test/pagination.test.js
@@ -1,16 +1,13 @@
// test/pagination.test.js - windows over the long tables
//
-// The pure helpers are checked directly, and the pages through a real
-// server, because the parts that go wrong are the ones that join up: a
-// count taken under different conditions than the rows, a filter dropped
-// from a page link, or a polling fragment that asks for page one forever.
+// Driven through a real server, because the parts that go wrong are the
+// ones that join up: a count taken under different conditions than the
+// rows, or a next url that drops the filter it was asked for.
import test from 'node:test';
import assert from 'node:assert/strict';
import { startHarness } from './helpers/harness.js';
-import { pageOf, offsetOf, windowOf, windowOfAll, paginate, PER_PAGE } from '../src/lib/page.js';
-import { pageHref, pager } from '../src/conductor/ui/layout.js';
-import { toHtml } from '../src/conductor/ui/html.js';
+import { paginateOffset } from '../src/lib/page.js';
async function withUi(options, fn) {
const h = await startHarness({ bootstrap: true, ...options });
@@ -74,228 +71,140 @@ async function seedWorkerTasks(h, count) {
}
}
-// --- the helpers ---
-
-test('a page number is read from the query, and nonsense reads as the first', () => {
- assert.equal(pageOf({ page: '3' }), 3);
- assert.equal(pageOf({ page: '1' }), 1);
- assert.equal(pageOf({}), 1);
- assert.equal(pageOf({ page: '' }), 1);
- assert.equal(pageOf({ page: 'banana' }), 1);
- assert.equal(pageOf({ page: '0' }), 1);
- assert.equal(pageOf({ page: '-4' }), 1);
- // Arrays arrive when a parameter is repeated in the query string.
- assert.equal(pageOf({ page: ['2', '5'] }), 1);
-});
-
-test('an absurd page number cannot produce an unusable offset', () => {
- const offset = offsetOf(pageOf({ page: '999999999999999999999' }));
- assert.ok(Number.isSafeInteger(offset), `offset ${offset} is not a safe integer`);
-});
-
-test('a window describes the slice it holds', () => {
- const win = windowOf({ rows: new Array(25).fill(0), total: 57, page: 1 });
- assert.equal(win.pages, 3);
- assert.equal(win.first, 1);
- assert.equal(win.last, 25);
-
- const middle = windowOf({ rows: new Array(25).fill(0), total: 57, page: 2 });
- assert.equal(middle.first, 26);
- assert.equal(middle.last, 50);
-
- const last = windowOf({ rows: new Array(7).fill(0), total: 57, page: 3 });
- assert.equal(last.first, 51);
- assert.equal(last.last, 57);
-});
-
-test('an empty table is one page, and counts from zero', () => {
- const win = windowOf({ rows: [], total: 0, page: 1 });
- assert.equal(win.pages, 1);
- assert.equal(win.first, 0);
- assert.equal(win.last, 0);
-});
-
-test('windowOfAll slices a list that is already in memory', () => {
- const all = Array.from({ length: 30 }, (_, i) => i);
- const second = windowOfAll(all, 2);
- assert.equal(second.rows.length, 5);
- assert.equal(second.rows[0], 25);
- assert.equal(second.total, 30);
-});
-
-// --- the links ---
-
-test('a page link keeps the other query parameters', () => {
- assert.equal(pageHref('/jobs', { project: 'demo' }, 2), '/jobs?project=demo&page=2');
- // Page one is the bare url rather than an explicit page=1.
- assert.equal(pageHref('/jobs', { project: 'demo' }, 1), '/jobs?project=demo');
- assert.equal(pageHref('/jobs', {}, 1), '/jobs');
- // The incoming page is replaced, not repeated.
- assert.equal(pageHref('/jobs', { page: '4' }, 2), '/jobs?page=2');
-});
-
-test('a pager is not rendered when everything fits on one page', () => {
- const win = windowOf({ rows: new Array(3).fill(0), total: 3, page: 1 });
- assert.equal(toHtml(pager(win, (p) => `/x?page=${p}`)), '');
-});
-
-test('a pager offers only the steps that exist', () => {
- const first = toHtml(pager(windowOf({ rows: new Array(25).fill(0), total: 57, page: 1 }), (p) => `/x?page=${p}`));
- assert.match(first, /1 to 25 of 57/);
- assert.match(first, /href="\/x\?page=2"/);
- assert.doesNotMatch(first, /href="\/x\?page=0"/);
-
- const last = toHtml(pager(windowOf({ rows: new Array(7).fill(0), total: 57, page: 3 }), (p) => `/x?page=${p}`));
- assert.match(last, /href="\/x\?page=2"/);
- assert.doesNotMatch(last, /href="\/x\?page=4"/);
-});
-
// --- against a real database ---
-test('paginate counts under the same conditions as it selects', async () => {
+test('a window counts under the same conditions as it selects', async () => {
await withUi({}, async (h) => {
await seedJobs(h, 10, { visibility: 'public' });
await seedJobs(h, 4, { visibility: 'private' });
- const win = await paginate(h.services.db, {
+ const win = await paginateOffset(h.services.db, {
select: 'id',
from: 'jobs',
where: "visibility = 'public'",
order: 'created_at DESC',
- page: 1,
- perPage: 5,
+ offset: 0,
+ limit: 5,
});
assert.equal(win.rows.length, 5);
// The private rows must not be counted, or the reader is offered a
// page that turns out to be empty.
assert.equal(win.total, 10);
- assert.equal(win.pages, 2);
});
});
-test('successive pages do not repeat or skip a row', async () => {
+test('successive windows do not repeat or skip a row', async () => {
await withUi({}, async (h) => {
await seedJobs(h, 12);
const seen = [];
- for (let p = 1; p <= 3; p += 1) {
- const win = await paginate(h.services.db, {
+ for (let offset = 0; offset < 15; offset += 5) {
+ const win = await paginateOffset(h.services.db, {
select: 'id',
from: 'jobs',
order: 'created_at DESC',
- page: p,
- perPage: 5,
+ offset,
+ limit: 5,
});
seen.push(...win.rows.map((r) => r.id));
}
assert.equal(seen.length, 12);
- assert.equal(new Set(seen).size, 12, 'a row appeared on more than one page');
+ assert.equal(new Set(seen).size, 12, 'a row appeared in more than one window');
});
});
-// --- the pages ---
+// --- the endpoints ---
-test('the job list is capped at a page and offers the next one', async () => {
+test('a list is capped at the default limit and hands out a next url', async () => {
await withUi({}, async (h) => {
- await seedJobs(h, PER_PAGE + 5);
+ await seedJobs(h, 55);
+ const { headers } = await h.login();
- const first = await get(h, '/jobs');
+ const first = await get(h, `/api/v1/projects/${h.project.id}/jobs`, headers);
assert.equal(first.statusCode, 200);
- assert.equal((first.body.match(/href="\/jobs\/seed/g) ?? []).length, PER_PAGE);
- assert.match(first.body, /href="\/jobs\?page=2"/);
- const second = await get(h, '/jobs?page=2');
- assert.equal((second.body.match(/href="\/jobs\/seed/g) ?? []).length, 5);
- // Nothing beyond the last page is offered.
- assert.doesNotMatch(second.body, /href="\/jobs\?page=3"/);
+ const body = first.json();
+ assert.equal(body.data.length, 50);
+ assert.equal(body.pagination.offset, 0);
+ assert.equal(body.pagination.total, 55);
+ assert.ok(body.pagination.next);
+
+ const second = await get(h, new URL(body.pagination.next).pathname + new URL(body.pagination.next).search, headers);
+ const rest = second.json();
+ assert.equal(rest.data.length, 5);
+ assert.equal(rest.pagination.offset, 50);
+ assert.equal(rest.pagination.next, undefined);
});
});
-test('paging past the end is an empty table rather than an error', async () => {
+test('following next walks every row exactly once', async () => {
await withUi({}, async (h) => {
- await seedJobs(h, 3);
- const res = await get(h, '/jobs?page=99');
- assert.equal(res.statusCode, 200);
- assert.match(res.body, /No jobs to show/);
- });
-});
+ await seedJobs(h, 23);
+ const { headers } = await h.login();
-test('a page link keeps the project filter, and so does the live fragment', async () => {
- await withUi({}, async (h) => {
- await seedJobs(h, PER_PAGE + 2);
+ const seen = [];
+ let url = `/api/v1/projects/${h.project.id}/jobs?limit=5`;
+ while (url) {
+ const body = (await get(h, url, headers)).json();
+ seen.push(...body.data.map((row) => row.id));
+ url = body.pagination.next ? new URL(body.pagination.next).pathname + new URL(body.pagination.next).search : null;
+ }
- const res = await get(h, `/jobs?project=${h.project.id}`);
- assert.match(res.body, new RegExp(`href="/jobs\\?project=${h.project.id}&page=2"`));
+ assert.equal(seen.length, 23);
+ assert.equal(new Set(seen).size, 23, 'a row appeared in more than one window');
});
});
-test('the polling fragment asks for the page being viewed, not the first', async () => {
+test('reading past the end is an empty page rather than an error', async () => {
await withUi({}, async (h) => {
- // A running job is what makes the fragment poll at all.
- await seedJobs(h, PER_PAGE + 2);
- await h.services.db.run(
- "UPDATE jobs SET state = 'running' WHERE id = {id}",
- { id: `seed${String(PER_PAGE).padStart(4, '0')}` }
- );
-
- const second = await get(h, '/jobs?page=2');
- assert.match(second.body, /hx-get="\/partials\/jobs\?page=2"/);
+ await seedJobs(h, 3);
+ const { headers } = await h.login();
- // And the fragment itself honours the page it is asked for.
- const partial = await get(h, '/partials/jobs?page=2');
- assert.equal(partial.statusCode, 200);
- assert.equal((partial.body.match(/href="\/jobs\/seed/g) ?? []).length, 2);
+ const res = await get(h, `/api/v1/projects/${h.project.id}/jobs?offset=999`, headers);
+ assert.equal(res.statusCode, 200);
+ assert.deepEqual(res.json().data, []);
+ assert.equal(res.json().pagination.total, 3);
});
});
-test('the count behind the pager respects who is asking', async () => {
+test('the total behind a page respects who is asking', async () => {
await withUi({}, async (h) => {
await seedJobs(h, 4, { visibility: 'public' });
await seedJobs(h, 40, { visibility: 'private' });
- // Anonymous sees only the public rows, so there is nothing to page.
- const anon = await get(h, '/jobs');
- assert.match(anon.body, /No jobs to show|seed/);
- assert.doesNotMatch(anon.body, /href="\/jobs\?page=2"/);
+ const anon = await get(h, `/api/v1/projects/${h.project.id}/jobs`);
+ assert.equal(anon.json().pagination.total, 4);
- // The administrator sees everything, so there is.
- const { cookie } = await h.login();
- const admin = await get(h, '/jobs', { cookie });
- assert.match(admin.body, /href="\/jobs\?page=2"/);
+ const { headers } = await h.login();
+ const admin = await get(h, `/api/v1/projects/${h.project.id}/jobs`, headers);
+ assert.equal(admin.json().pagination.total, 44);
});
});
-test('a worker page pages its task history', async () => {
+test('a worker pages its task history', async () => {
await withUi({}, async (h) => {
- const { cookie } = await h.login();
- await seedWorkerTasks(h, PER_PAGE + 3);
+ const { headers } = await h.login();
+ await seedWorkerTasks(h, 28);
- const first = await get(h, `/workers/${h.worker.id}`, { cookie });
+ const first = await get(h, `/api/v1/workers/${h.worker.id}/tasks?limit=25`, headers);
assert.equal(first.statusCode, 200);
- assert.equal((first.body.match(/href="\/tasks\/wtask/g) ?? []).length, PER_PAGE);
- assert.match(first.body, new RegExp(`href="/workers/${h.worker.id}\\?page=2"`));
+ assert.equal(first.json().data.length, 25);
+ assert.equal(first.json().pagination.total, 28);
- const second = await get(h, `/workers/${h.worker.id}?page=2`, { cookie });
- assert.equal((second.body.match(/href="\/tasks\/wtask/g) ?? []).length, 3);
+ const second = await get(h, `/api/v1/workers/${h.worker.id}/tasks?limit=25&offset=25`, headers);
+ assert.equal(second.json().data.length, 3);
});
});
-test('the users and workers tables keep their swap target on the wrapper', async () => {
+test('a limit above the cap is clamped rather than refused', async () => {
await withUi({}, async (h) => {
- const { cookie } = await h.login();
-
- const users = await get(h, '/users', { cookie });
- assert.equal(users.statusCode, 200);
- // The id has to survive on the element htmx replaces, or a later swap
- // has nothing to aim at.
- assert.match(users.body, /<div id="users">/);
-
- const workers = await get(h, '/workers', { cookie });
- assert.match(workers.body, /<div id="workers">/);
+ await seedJobs(h, 3);
+ const { headers } = await h.login();
- const projects = await get(h, '/projects', { cookie });
- assert.match(projects.body, /<div id="projects">/);
+ const res = await get(h, `/api/v1/projects/${h.project.id}/jobs?limit=9999`, headers);
+ assert.equal(res.statusCode, 200);
+ assert.equal(res.json().data.length, 3);
});
});
diff --git a/test/provider.test.js b/test/provider.test.js
@@ -0,0 +1,292 @@
+// test/provider.test.js - the built-in login provider
+
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import crypto from 'node:crypto';
+import { startHarness } from './helpers/harness.js';
+
+const PUBLIC_URL = 'http://conductor.test';
+const REDIRECT = `${PUBLIC_URL}/callback`;
+
+function pkce() {
+ const verifier = crypto.randomBytes(32).toString('base64url');
+ return { verifier, challenge: crypto.createHash('sha256').update(verifier).digest('base64url') };
+}
+
+function authorizeQuery(overrides = {}) {
+ return new URLSearchParams({
+ client_id: 'conductor',
+ redirect_uri: REDIRECT,
+ response_type: 'code',
+ code_challenge: pkce().challenge,
+ code_challenge_method: 'S256',
+ state: 'xyz',
+ ...overrides,
+ });
+}
+
+async function withConductor(fn) {
+ const c = await startHarness({ bootstrap: true, publicUrl: PUBLIC_URL });
+ try {
+ return await fn(c);
+ } finally {
+ await c.stop();
+ }
+}
+
+async function authorize(c, query, credentials = { username: 'admin', password: 'bootstrap-password' }) {
+ return c.app.inject({
+ method: 'POST',
+ url: `/auth/authorize?${query}`,
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
+ payload: new URLSearchParams(credentials).toString(),
+ });
+}
+
+test('discovery advertises the built-in endpoints and the client id', async () => {
+ await withConductor(async (c) => {
+ const res = await c.discovery();
+ assert.equal(res.statusCode, 200);
+
+ const doc = res.json();
+ assert.equal(doc.issuer, PUBLIC_URL);
+ assert.equal(doc.authorization_endpoint, `${PUBLIC_URL}/auth/authorize`);
+ assert.equal(doc.token_endpoint, `${PUBLIC_URL}/auth/token`);
+ assert.equal(doc.end_session_endpoint, `${PUBLIC_URL}/auth/logout`);
+ assert.deepEqual(doc.code_challenge_methods_supported, ['S256']);
+ assert.equal(doc.client_id, 'conductor');
+ });
+});
+
+test('the login page is served for a valid authorization request', async () => {
+ await withConductor(async (c) => {
+ const res = await c.app.inject({ method: 'GET', url: `/auth/authorize?${authorizeQuery()}` });
+ assert.equal(res.statusCode, 200);
+ assert.match(res.headers['content-type'], /text\/html/);
+ assert.match(res.body, /name="password"/);
+ // The query string carries the parameters, so the form needs no action.
+ assert.ok(!res.body.includes('action='));
+ });
+});
+
+test('the whole code flow yields a working access token', async () => {
+ await withConductor(async (c) => {
+ const grant = await c.login();
+ assert.equal(grant.token_type, 'Bearer');
+ assert.equal(grant.expires_in, 900);
+ assert.ok(grant.access_token);
+ assert.ok(grant.refresh_token);
+
+ const me = await c.app.inject({ method: 'GET', url: '/api/v1/users/me', headers: grant.headers });
+ assert.equal(me.statusCode, 200);
+ assert.equal(me.json().data.username, 'admin');
+ });
+});
+
+test('a code is single use', async () => {
+ await withConductor(async (c) => {
+ const { verifier, challenge } = pkce();
+ const res = await authorize(c, authorizeQuery({ code_challenge: challenge }));
+ const code = new URL(res.headers.location).searchParams.get('code');
+
+ const body = {
+ grant_type: 'authorization_code', code, redirect_uri: REDIRECT, code_verifier: verifier,
+ };
+ assert.equal((await c.token(body)).statusCode, 200);
+
+ const replay = await c.token(body);
+ assert.equal(replay.statusCode, 400);
+ assert.equal(replay.json().error, 'invalid_grant');
+ });
+});
+
+test('a code will not redeem without the matching verifier', async () => {
+ await withConductor(async (c) => {
+ const { challenge } = pkce();
+ const res = await authorize(c, authorizeQuery({ code_challenge: challenge }));
+ const code = new URL(res.headers.location).searchParams.get('code');
+
+ const wrong = await c.token({
+ grant_type: 'authorization_code',
+ code,
+ redirect_uri: REDIRECT,
+ code_verifier: crypto.randomBytes(32).toString('base64url'),
+ });
+ assert.equal(wrong.statusCode, 400);
+ assert.equal(wrong.json().error, 'invalid_grant');
+ });
+});
+
+test('a code will not redeem against a different redirect uri', async () => {
+ await withConductor(async (c) => {
+ const { verifier, challenge } = pkce();
+ const res = await authorize(c, authorizeQuery({ code_challenge: challenge }));
+ const code = new URL(res.headers.location).searchParams.get('code');
+
+ const moved = await c.token({
+ grant_type: 'authorization_code',
+ code,
+ redirect_uri: `${PUBLIC_URL}/elsewhere`,
+ code_verifier: verifier,
+ });
+ assert.equal(moved.statusCode, 400);
+ });
+});
+
+// Redirecting to an unregistered uri would make this an open redirect.
+test('an unregistered redirect uri is refused without redirecting', async () => {
+ await withConductor(async (c) => {
+ const query = authorizeQuery({ redirect_uri: 'https://evil.example/steal', code_challenge: pkce().challenge });
+
+ const shown = await c.app.inject({ method: 'GET', url: `/auth/authorize?${query}` });
+ assert.equal(shown.statusCode, 400);
+ assert.equal(shown.headers.location, undefined);
+
+ const posted = await authorize(c, query);
+ assert.equal(posted.statusCode, 400);
+ assert.equal(posted.headers.location, undefined);
+ });
+});
+
+test('a bad request with a good redirect uri comes back as an oauth error', async () => {
+ await withConductor(async (c) => {
+ for (const [override, error] of [
+ [{ response_type: 'token' }, 'unsupported_response_type'],
+ [{ code_challenge_method: 'plain' }, 'invalid_request'],
+ [{ client_id: 'someone-else' }, 'unauthorized_client'],
+ ]) {
+ const query = authorizeQuery({ code_challenge: pkce().challenge, ...override });
+ const res = await c.app.inject({ method: 'GET', url: `/auth/authorize?${query}` });
+
+ assert.equal(res.statusCode, 303);
+ const url = new URL(res.headers.location);
+ assert.equal(url.origin + url.pathname, REDIRECT);
+ assert.equal(url.searchParams.get('error'), error);
+ assert.equal(url.searchParams.get('state'), 'xyz');
+ }
+ });
+});
+
+test('a request with no code challenge at all is refused', async () => {
+ await withConductor(async (c) => {
+ const query = authorizeQuery();
+ query.delete('code_challenge');
+ const res = await c.app.inject({ method: 'GET', url: `/auth/authorize?${query}` });
+
+ assert.equal(res.statusCode, 303);
+ assert.equal(new URL(res.headers.location).searchParams.get('error'), 'invalid_request');
+ });
+});
+
+test('wrong credentials redisplay the form rather than issuing a code', async () => {
+ await withConductor(async (c) => {
+ const res = await authorize(c, authorizeQuery({ code_challenge: pkce().challenge }), {
+ username: 'admin', password: 'not-the-password',
+ });
+
+ assert.equal(res.statusCode, 401);
+ assert.equal(res.headers.location, undefined);
+ assert.match(res.body, /did not match/);
+ });
+});
+
+test('the state is handed back so the app can resume', async () => {
+ await withConductor(async (c) => {
+ const res = await authorize(c, authorizeQuery({ code_challenge: pkce().challenge, state: 'resume-me' }));
+ assert.equal(new URL(res.headers.location).searchParams.get('state'), 'resume-me');
+ });
+});
+
+test('refreshing returns a new access token and keeps the refresh token', async () => {
+ await withConductor(async (c) => {
+ const grant = await c.login();
+
+ const res = await c.token({ grant_type: 'refresh_token', refresh_token: grant.refresh_token });
+ assert.equal(res.statusCode, 200);
+
+ const next = res.json();
+ assert.equal(next.refresh_token, grant.refresh_token);
+ assert.ok(next.access_token);
+
+ const me = await c.app.inject({
+ method: 'GET',
+ url: '/api/v1/users/me',
+ headers: { authorization: `Bearer ${next.access_token}` },
+ });
+ assert.equal(me.statusCode, 200);
+ });
+});
+
+test('an unknown grant type or refresh token is refused', async () => {
+ await withConductor(async (c) => {
+ const bad = await c.token({ grant_type: 'password', username: 'admin', password: 'bootstrap-password' });
+ assert.equal(bad.statusCode, 400);
+ assert.equal(bad.json().error, 'unsupported_grant_type');
+
+ const unknown = await c.token({ grant_type: 'refresh_token', refresh_token: 'a'.repeat(64) });
+ assert.equal(unknown.statusCode, 400);
+ assert.equal(unknown.json().error, 'invalid_grant');
+ });
+});
+
+test('revoking ends the session and reports nothing about unknown tokens', async () => {
+ await withConductor(async (c) => {
+ const grant = await c.login();
+
+ const unknown = await c.app.inject({
+ method: 'POST',
+ url: '/auth/revoke',
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
+ payload: new URLSearchParams({ token: 'b'.repeat(64) }).toString(),
+ });
+ assert.equal(unknown.statusCode, 200);
+
+ const revoked = await c.app.inject({
+ method: 'POST',
+ url: '/auth/revoke',
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
+ payload: new URLSearchParams({ token: grant.refresh_token }).toString(),
+ });
+ assert.equal(revoked.statusCode, 200);
+
+ const after = await c.token({ grant_type: 'refresh_token', refresh_token: grant.refresh_token });
+ assert.equal(after.statusCode, 400);
+ });
+});
+
+test('logout redirects only to a registered uri', async () => {
+ await withConductor(async (c) => {
+ const ok = await c.app.inject({
+ method: 'GET',
+ url: `/auth/logout?post_logout_redirect_uri=${encodeURIComponent(PUBLIC_URL)}`,
+ });
+ assert.equal(ok.statusCode, 303);
+ assert.equal(ok.headers.location, PUBLIC_URL);
+
+ const bad = await c.app.inject({
+ method: 'GET',
+ url: '/auth/logout?post_logout_redirect_uri=https%3A%2F%2Fevil.example',
+ });
+ assert.equal(bad.statusCode, 400);
+ });
+});
+
+test('the key set is advertised and empty', async () => {
+ await withConductor(async (c) => {
+ const res = await c.app.inject({ method: 'GET', url: '/auth/jwks' });
+ assert.equal(res.statusCode, 200);
+ assert.deepEqual(res.json(), { keys: [] });
+ });
+});
+
+test('a disabled account cannot refresh an existing session', async () => {
+ await withConductor(async (c) => {
+ const grant = await c.login();
+ const admin = await c.services.users.byUsername('admin');
+ await c.services.users.setDisabled(admin.id, true);
+
+ const res = await c.token({ grant_type: 'refresh_token', refresh_token: grant.refresh_token });
+ assert.equal(res.statusCode, 400);
+ assert.equal(res.json().error, 'invalid_grant');
+ });
+});
diff --git a/test/ui.test.js b/test/ui.test.js
@@ -1,603 +0,0 @@
-// 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';
-import { tasksTable } from '../src/conductor/ui/pages.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 jobs and an invitation to sign in', async () => {
- await withUi({ visibility: 'public' }, async (h) => {
- const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
- await h.waitForJob(res.json().data.job_id);
-
- const page = await get(h, '/');
- assert.equal(page.statusCode, 200);
- assert.match(page.headers['content-type'], /text\/html/);
- assert.match(page.body, /Sign in/);
- assert.match(page.body, /#1<\/a>/);
- // No management links without a session.
- assert.ok(!page.body.includes('href="/projects"'));
- assert.ok(!page.body.includes('href="/users"'));
- });
-});
-
-test('an anonymous visitor cannot see a private job', async () => {
- await withUi({ visibility: 'private' }, async (h) => {
- const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
- const job = res.json().data.job_id;
- await h.waitForJob(job);
-
- const list = await get(h, '/');
- assert.ok(!list.body.includes('#1</a>'));
-
- const detail = await get(h, `/jobs/${job}`);
- assert.match(detail.body, /No such job/);
- });
-});
-
-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, 200);
- 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\/v1\/projects\/from-ui\/trigger/);
-
- // 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: 'user' }),
- });
- 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('the worker page lists what each machine advertises', async () => {
- await withUi({}, async (h) => {
- const session = await signIn(h);
- await h.claim({ name: 'builder-01', arches: ['x86_64'], features: ['unos-sign-keys', 'dind'] });
-
- const page = await get(h, `/workers/${h.worker.id}`, session);
- assert.equal(page.statusCode, 200);
- assert.match(page.body, /builder-01/);
- assert.match(page.body, /unos-sign-keys, dind/);
- assert.match(page.body, /x86_64/);
- });
-});
-
-test('machines sharing a token are listed separately', async () => {
- await withUi({}, async (h) => {
- const session = await signIn(h);
- await h.claim({ name: 'builder-01', features: ['unos-sign-keys'] });
- await h.claim({ name: 'builder-02', features: [] });
-
- const page = await get(h, `/workers/${h.worker.id}`, session);
- assert.match(page.body, /builder-01/);
- assert.match(page.body, /builder-02/);
- assert.match(page.body, /machines/);
- });
-});
-
-test('the workers list links to the worker page', async () => {
- await withUi({}, async (h) => {
- const session = await signIn(h);
- const list = await get(h, '/workers', session);
- assert.match(list.body, new RegExp(`href="/workers/${h.worker.id}"`));
- });
-});
-
-test('a worker page is not served to someone who cannot manage it', async () => {
- await withUi({}, async (h) => {
- const admin = await signIn(h);
- await h.app.inject({
- method: 'POST', url: '/users', ...form(admin, { username: 'erin', password: 'erin-password', role: 'user' }),
- });
- const erin = await signIn(h, 'erin', 'erin-password');
-
- const page = await get(h, `/workers/${h.worker.id}`, erin);
- assert.equal(page.statusCode, 404);
- assert.match(page.body, /No such worker/);
- });
-});
-
-test('a feature name is escaped rather than rendered', async () => {
- await withUi({}, async (h) => {
- const session = await signIn(h);
- await h.claim({ name: 'builder-01', features: ['<img src=x onerror=alert(1)>'] });
-
- const page = await get(h, `/workers/${h.worker.id}`, session);
- assert.ok(!page.body.includes('<img src=x'), 'a feature name must not reach the page as markup');
- assert.match(page.body, /<img src=x/);
- });
-});
-
-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: 'user' }),
- });
- 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: 'user' }),
- });
- 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 job 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 job polls, and a finished one stops', async () => {
- await withUi({ visibility: 'public' }, async (h) => {
- const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
- const job = res.json().data.job_id;
- await h.waitForJob(job);
-
- const running = await get(h, `/jobs/${job}`);
- assert.match(running.body, /hx-get="\/partials\/jobs\/[^"]+\/tasks"/);
- assert.match(running.body, /hx-trigger="every 3s"/);
-
- await h.services.scheduler.cancelJob(job);
-
- const finished = await get(h, `/jobs/${job}`);
- assert.ok(!finished.body.includes('/tasks" hx-trigger'), 'a settled job must stop polling');
- });
-});
-
-test('a task page streams the log and stops polling once it completes', async () => {
- await withUi({ visibility: 'public' }, async (h) => {
- const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
- await h.waitForJob(res.json().data.job_id);
- const task = (await h.claim({})).json().data.task;
-
- await h.app.inject({
- method: 'POST',
- url: `/api/v1/tasks/${task.id}/log`,
- headers: { ...h.auth, 'content-type': 'application/octet-stream' },
- payload: Buffer.from('compiling the thing\n'),
- });
-
- const live = await get(h, `/tasks/${encodeURIComponent(task.id)}`);
- assert.match(live.body, /compiling the thing/);
- assert.match(live.body, /hx-trigger="every 2s"/);
-
- await h.app.inject({
- method: 'POST',
- url: `/api/v1/tasks/${task.id}/complete`,
- headers: { ...h.auth, 'content-type': 'application/json' },
- payload: JSON.stringify({ success: true, exit_code: 0 }),
- });
-
- const done = await get(h, `/tasks/${encodeURIComponent(task.id)}`);
- assert.match(done.body, /compiling the thing/);
- assert.ok(!done.body.includes('hx-trigger="every 2s"'), 'a finished task must stop polling');
- });
-});
-
-test('log output is escaped, so a task cannot inject markup into the page', async () => {
- await withUi({ visibility: 'public' }, async (h) => {
- const res = await h.trigger({ sha: h.sha, ref: 'refs/heads/main' });
- await h.waitForJob(res.json().data.job_id);
- const task = (await h.claim({})).json().data.task;
-
- await h.app.inject({
- method: 'POST',
- url: `/api/v1/tasks/${task.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, `/tasks/${encodeURIComponent(task.id)}`);
- assert.ok(!page.body.includes('<img src=x'), 'task 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 task graph is one table, so columns line up across stages', () => {
- const job = { id: 'j1', state: 'running' };
- const tasks = [
- { id: 't1', name: 'style', state: 'success', arch: 'x86-64', worker_name: 'w1', started_at: 1, finished_at: 2, needs: [] },
- { id: 't2', name: 'test', state: 'success', arch: null, worker_name: 'w2', started_at: 1, finished_at: 2, needs: [] },
- { id: 't3', name: 'images', state: 'queued', arch: null, worker_name: null, started_at: null, finished_at: null, needs: ['t1', 't2'] },
- { id: 't4', name: 'publish', state: 'queued', arch: null, worker_name: null, started_at: null, finished_at: null, needs: ['t3'] },
- ];
-
- const out = toHtml(tasksTable(job, tasks));
-
- // Three stages, and one table holding all of them. A table per stage
- // sizes its own columns, which is what put the same field in a
- // different place in every stage.
- assert.equal((out.match(/<tbody/g) ?? []).length, 3, 'expected one row group per stage');
- assert.equal((out.match(/<table/g) ?? []).length, 1, 'stages must share a single table');
- assert.match(out, /stage 1/);
- assert.match(out, /stage 3/);
-
- // Every task row carries the same five cells, so a column means the same
- // thing all the way down.
- for (const row of out.match(/<tr>\s*<td>.*?<\/tr>/gs) ?? []) {
- assert.equal((row.match(/<td/g) ?? []).length, 5, `row has the wrong cell count: ${row}`);
- }
-
- // The stage label spans the row, so it must track the column count.
- assert.match(out, /colspan="5"/);
- assert.doesNotMatch(out, /<th>exit<\/th>/, 'the exit code is no longer a column');
-});
-
-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`);
- }
- });
-});
-
-// --- retention ---
-
-test('the project page shows retention, with server defaults as placeholders', async () => {
- await withUi({
- retention: { artifact_keep_jobs: 8, artifact_keep_days: 25, log_keep_days: 11 },
- }, async (h) => {
- const session = await signIn(h);
- const page = await get(h, `/projects/${h.project.id}`, session);
-
- assert.equal(page.statusCode, 200);
- assert.match(page.body, /Retention/);
-
- // An unset field shows the default it inherits rather than an empty
- // box that looks like nothing is configured.
- assert.match(page.body, /name="artifact_keep_jobs"[^>]*placeholder="8"/);
- assert.match(page.body, /name="artifact_keep_days"[^>]*placeholder="25"/);
- assert.match(page.body, /name="log_keep_days"[^>]*placeholder="11"/);
- });
-});
-
-test('saving retention through the form stores it, and empty means inherit', async () => {
- await withUi({
- retention: { artifact_keep_jobs: 8, artifact_keep_days: 25, log_keep_days: 11 },
- }, async (h) => {
- const session = await signIn(h);
-
- const saved = await h.app.inject({
- method: 'PATCH',
- url: `/projects/${h.project.id}/retention`,
- ...form(session, { artifact_keep_jobs: '3', artifact_keep_days: '0', log_keep_days: '' }),
- });
- assert.ok(saved.statusCode < 400, `unexpected ${saved.statusCode}: ${saved.body}`);
-
- const project = await h.services.projects.get(h.project.id);
- assert.equal(project.artifact_keep_jobs, 3);
- // Zero and empty have to end up different, or a project cannot say
- // "keep forever" as distinct from "use the default".
- assert.equal(project.artifact_keep_days, 0);
- assert.equal(project.log_keep_days, null);
-
- const page = await get(h, `/projects/${h.project.id}`, session);
- assert.match(page.body, /name="artifact_keep_days"[^>]*value="0"/);
- assert.match(page.body, /name="log_keep_days"[^>]*value=""/);
- });
-});
-
-test('a negative retention value is refused by the form', async () => {
- await withUi({}, async (h) => {
- const session = await signIn(h);
-
- const res = await h.app.inject({
- method: 'PATCH',
- url: `/projects/${h.project.id}/retention`,
- ...form(session, { log_keep_days: '-3' }),
- });
-
- assert.ok(res.statusCode >= 400, 'a negative value must not be stored');
- const project = await h.services.projects.get(h.project.id);
- assert.equal(project.log_keep_days, null);
- });
-});
-
-// --- working directory ---
-
-test('the project page offers a working directory, defaulting to the server one', async () => {
- await withUi({}, async (h) => {
- const session = await signIn(h);
- const page = await get(h, `/projects/${h.project.id}`, session);
-
- assert.equal(page.statusCode, 200);
- assert.match(page.body, /name="workdir"[^>]*placeholder="\/work"/);
- assert.match(page.body, /name="workdir"[^>]*value=""/, 'unset means inherited');
- });
-});
-
-test('saving a working directory stores it, and empty clears it', async () => {
- await withUi({}, async (h) => {
- const session = await signIn(h);
-
- const saved = await h.app.inject({
- method: 'PATCH',
- url: `/projects/${h.project.id}`,
- ...form(session, { workdir: '/usr/src/app' }),
- });
- assert.ok(saved.statusCode < 400, `unexpected ${saved.statusCode}: ${saved.body}`);
- assert.equal((await h.services.projects.get(h.project.id)).workdir, '/usr/src/app');
-
- const cleared = await h.app.inject({
- method: 'PATCH',
- url: `/projects/${h.project.id}`,
- ...form(session, { workdir: ' ' }),
- });
- assert.ok(cleared.statusCode < 400);
- assert.equal((await h.services.projects.get(h.project.id)).workdir, null,
- 'blank means follow the repository, not a directory named blank');
- });
-});
-
-test('an unusable working directory is refused by the form', async () => {
- await withUi({}, async (h) => {
- const session = await signIn(h);
-
- for (const workdir of ['relative', '/', '/a/../b']) {
- const res = await h.app.inject({
- method: 'PATCH',
- url: `/projects/${h.project.id}`,
- ...form(session, { workdir }),
- });
- assert.ok(res.statusCode >= 400, `expected ${JSON.stringify(workdir)} to be refused`);
- assert.equal((await h.services.projects.get(h.project.id)).workdir, null);
- }
- });
-});