conductor

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

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:
Dassets/style.css | 210-------------------------------------------------------------------------------
Dassets/vendor/htmx-LICENSE.txt | 13-------------
Dassets/vendor/htmx.min.js | 2--
Msrc/conductor/app.js | 18+++++++++---------
Asrc/conductor/auth/index.js | 248+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/conductor/auth/login.css | 92+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/conductor/auth/login.html | 25+++++++++++++++++++++++++
Asrc/conductor/routes/agent.js | 324+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/conductor/routes/api.js | 12++++++++++--
Asrc/conductor/routes/guard.js | 34++++++++++++++++++++++++++++++++++
Msrc/conductor/routes/jobs.js | 129++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Asrc/conductor/routes/projects.js | 130+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/conductor/routes/session.js | 31+++++++++++++++++++++++++++++++
Asrc/conductor/routes/users.js | 108+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/conductor/routes/workers.js | 356+++++++++++++------------------------------------------------------------------
Dsrc/conductor/ui/html.js | 68--------------------------------------------------------------------
Dsrc/conductor/ui/layout.js | 137-------------------------------------------------------------------------------
Dsrc/conductor/ui/pages.js | 637-------------------------------------------------------------------------------
Dsrc/conductor/ui/routes.js | 824-------------------------------------------------------------------------------
Msrc/lib/auth/index.js | 118+++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
Dsrc/lib/auth/state.js | 58----------------------------------------------------------
Msrc/lib/auth/token.js | 63+++++++++++++++------------------------------------------------
Msrc/lib/errors.js | 17+++++++++++++++++
Msrc/lib/page.js | 72+++++++++---------------------------------------------------------------
Msrc/lib/projects.js | 18++++++++++--------
Msrc/lib/static.js | 116++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
Msrc/lib/users.js | 9+++++----
Msrc/lib/workers.js | 11++++++-----
Mtest/api.test.js | 8++++----
Mtest/auth.test.js | 72+++++++++++++++++++++++++++++++-----------------------------------------
Mtest/conductor.test.js | 62+++++++++++++++++++++++++++++++++++++++++++-------------------
Mtest/dialects.test.js | 14+++++---------
Mtest/helpers/harness.js | 54+++++++++++++++++++++++++++++++++++++++++++++---------
Mtest/helpers/oidc.js | 24++++++++++++++++++++----
Mtest/oidc.test.js | 197+++++++++++++++++++++++++++++++------------------------------------------------
Mtest/pagination.test.js | 229++++++++++++++++++++++++-------------------------------------------------------
Atest/provider.test.js | 292+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dtest/ui.test.js | 603-------------------------------------------------------------------------------
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 = { - '&': '&amp;', - '<': '&lt;', - '>': '&gt;', - '"': '&quot;', - "'": '&#39;', -}; - -export function esc(value) { - if (value === null || value === undefined || value === false) return ''; - return String(value).replace(/[&<>"']/g, (c) => ESCAPES[c]); -} - -function render(value) { - if (value === null || value === undefined || value === false || value === true) return ''; - if (isRaw(value)) return value[RAW]; - if (Array.isArray(value)) return value.map(render).join(''); - return esc(value); -} - -export function html(strings, ...values) { - let out = strings[0]; - for (let i = 0; i < values.length; i += 1) { - out += render(values[i]) + strings[i + 1]; - } - return raw(out); -} - -// Renders a completed template to a string for sending. -export function toHtml(value) { - return render(value); -} - -// Attribute helper for optional attributes, so a page does not have to -// build strings by hand. -export function attrs(map) { - const parts = []; - for (const [key, value] of Object.entries(map)) { - if (value === null || value === undefined || value === false) continue; - if (value === true) parts.push(esc(key)); - else parts.push(`${esc(key)}="${esc(value)}"`); - } - return raw(parts.join(' ')); -} diff --git a/src/conductor/ui/layout.js b/src/conductor/ui/layout.js @@ -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}&amp;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>&lt;script&gt;alert(1)&lt;/script&gt;</td>'); -}); - -test('quotes and ampersands are escaped inside attributes', () => { - const out = toHtml(html`<input value="${'a" onload="evil() & more'}">`); - assert.ok(!out.includes('onload="evil')); - assert.ok(out.includes('&quot;')); - assert.ok(out.includes('&amp;')); -}); - -test('nested templates and arrays are kept, raw is passed through', () => { - const rows = ['a', 'b'].map((x) => html`<li>${x}</li>`); - assert.equal(toHtml(html`<ul>${rows}</ul>`), '<ul><li>a</li><li>b</li></ul>'); - assert.equal(toHtml(html`<p>${raw('<b>bold</b>')}</p>`), '<p><b>bold</b></p>'); -}); - -test('null, undefined and false render as nothing', () => { - assert.equal(toHtml(html`<p>${null}${undefined}${false}</p>`), '<p></p>'); - assert.equal(esc(null), ''); -}); - -test('attrs omits absent values and renders bare booleans', () => { - assert.equal(toHtml(attrs({ id: 'x', hidden: true, skip: false, gone: null })), 'id="x" hidden'); -}); - -// --- anonymous --- - -test('an anonymous visitor sees public 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, /&lt;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, /&lt;img src=x/); - }); -}); - -// --- csrf --- - -test('a mutation without the htmx header is refused', async () => { - await withUi({}, async (h) => { - const session = await signIn(h); - - // A cross site form post carries the cookie but cannot set a header. - const res = await h.app.inject({ - method: 'POST', - url: '/projects', - headers: { ...session, 'content-type': 'application/x-www-form-urlencoded' }, - payload: new URLSearchParams({ id: 'csrf', repo_url: 'https://evil.example/x.git' }).toString(), - }); - assert.equal(res.statusCode, 400); - assert.equal(await h.services.projects.get('csrf'), undefined); - }); -}); - -// --- layout details --- - -test('the 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); - } - }); -});