conductor

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

commit 9579ff5645feda574df51e04a0b89ec45c0049fd
parent 34f69b1a8d20ba703a2c90762bb1a18326780df6
Author: finwo <finwo@pm.me>
Date:   Sat, 26 Sep 2026 22:47:23 +0200

Let anonymous readers browse the public parts of the application

Diffstat:
Mtest/api.test.js | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Mweb/src/component/layout.tsx | 19++++++++++++++-----
Mweb/src/main.tsx | 24++++++++++++------------
Mweb/src/screen/job.tsx | 16+++++++++-------
Mweb/src/screen/project.tsx | 91+++++++++++++++++++++++++++++++++++++++++++-------------------------------------
Mweb/src/screen/projects.tsx | 4++--
Mweb/src/util/auth.ts | 27++++++++++++++++++++++++---
7 files changed, 160 insertions(+), 71 deletions(-)

diff --git a/test/api.test.js b/test/api.test.js @@ -198,6 +198,56 @@ test('a task reports its artifacts as a page', async () => { }); }); +// --- what an anonymous reader sees --- + +test('the lists an anonymous reader lands on hold only public rows', async () => { + await withApi({ visibility: 'public' }, async (h) => { + await h.services.projects.create({ + id: 'hidden', + name: 'Hidden', + repo_url: h.repoDir, + visibility: 'private', + owner_id: null, + }); + const job = data(await h.trigger({ sha: h.sha, ref: 'refs/heads/main' })).job_id; + await h.waitForJob(job); + + const projects = await h.app.inject({ method: 'GET', url: '/api/v1/projects' }); + assert.equal(projects.statusCode, 200, projects.body); + assert.deepEqual(data(projects).map((p) => p.id), ['demo']); + + const jobs = await h.app.inject({ method: 'GET', url: '/api/v1/jobs' }); + assert.equal(jobs.statusCode, 200, jobs.body); + assert.ok(data(jobs).every((j) => j.project_id === 'demo')); + assert.ok(data(jobs).some((j) => j.id === job)); + }); +}); + +test('a private project is absent rather than refused', async () => { + await withApi({ visibility: 'public' }, async (h) => { + await h.services.projects.create({ + id: 'hidden', + name: 'Hidden', + repo_url: h.repoDir, + visibility: 'private', + owner_id: null, + }); + + const res = await h.app.inject({ method: 'GET', url: '/api/v1/projects/hidden' }); + assert.equal(res.statusCode, 404, res.body); + }); +}); + +test('the endpoints behind an account say so, rather than emptying out', async () => { + await withApi({ visibility: 'public' }, async (h) => { + for (const url of ['/api/v1/workers', '/api/v1/users/me', '/api/v1/sessions']) { + const res = await h.app.inject({ method: 'GET', url }); + assert.equal(res.statusCode, 401, `${url}: ${res.body}`); + assert.equal(apiError(res).name, 'NotAuthenticated', url); + } + }); +}); + // --- the helpers underneath --- test('a limit is clamped to the cap, and a missing one defaults', () => { diff --git a/web/src/component/layout.tsx b/web/src/component/layout.tsx @@ -9,9 +9,18 @@ export const session = { user: null as { id: string; username: string; role: string } | null, }; -const Link = (href: string, label: string) => ( - <m.route.Link href={href} class={m.route.get().startsWith(href) ? 'active' : ''}>{label}</m.route.Link> -); +// Mirrors canManageProject on the server. +export function manages(project: { owner_id?: string | null } | null): boolean { + const user = session.user; + if (!user || !project) return false; + if (user.role === 'admin') return true; + return project.owner_id != null && project.owner_id === user.id; +} + +const Link = (href: string, label: string) => { + const at = m.route.get() ?? ''; + return <m.route.Link href={href} class={at.startsWith(href) ? 'active' : ''}>{label}</m.route.Link>; +}; export const Chrome: m.Component = { view(vnode) { @@ -21,9 +30,9 @@ export const Chrome: m.Component = { <span class="brand">conductor</span> {Link('/jobs', 'jobs')} {Link('/projects', 'projects')} - {Link('/workers', 'workers')} + {session.user ? Link('/workers', 'workers') : null} {session.user?.role === 'admin' ? Link('/users', 'users') : null} - {Link('/sessions', 'sessions')} + {session.user ? Link('/sessions', 'sessions') : null} </nav> <div class="who"> {session.user diff --git a/web/src/main.tsx b/web/src/main.tsx @@ -2,7 +2,7 @@ import m from 'mithril'; import * as auth from './util/auth'; -import { get } from './util/api'; +import { ApiError, get } from './util/api'; import { Chrome, Failed, session } from './component/layout'; import { Jobs } from './screen/jobs'; import { Job } from './screen/job'; @@ -44,17 +44,17 @@ async function start() { return; } - if (!auth.signedIn()) { - await auth.signIn(); - return; - } - - try { - session.user = await get('/users/me'); - } catch (e) { - auth.forget(); - fail(e); - return; + if (auth.signedIn()) { + try { + session.user = await get('/users/me'); + } catch (e) { + if ((e as ApiError).status === 401) auth.forget(); + // Still holding a credential means the conductor itself is unhappy. + if (auth.signedIn()) { + fail(e); + return; + } + } } m.route.prefix = '#!'; diff --git a/web/src/screen/job.tsx b/web/src/screen/job.tsx @@ -4,7 +4,7 @@ import m from 'mithril'; import { get, post, ApiError } from '../util/api'; import { poll } from '../util/poll'; import { settled, duration, shortSha, stamp } from '../util/format'; -import { Badge, Failed, Gone, Loading, When } from '../component/layout'; +import { Badge, Failed, Gone, Loading, When, session } from '../component/layout'; type Task = { id: string; name: string; base_name: string; state: string; arch: string | null; @@ -112,12 +112,14 @@ export const Job: m.Component = { job.error ? <div class="error"><p>{job.error}</p></div> : null, - <div class="actions"> - {job.state === 'running' - ? <button disabled={vnode.state.busy} onclick={() => act('cancel')}>cancel</button> - : null} - <button disabled={vnode.state.busy} onclick={() => act('retry')}>retry</button> - </div>, + session.user ? ( + <div class="actions"> + {job.state === 'running' + ? <button disabled={vnode.state.busy} onclick={() => act('cancel')}>cancel</button> + : null} + <button disabled={vnode.state.busy} onclick={() => act('retry')}>retry</button> + </div> + ) : null, vnode.state.stages.map((stage: Task[], at: number) => ( <section class="stage"> diff --git a/web/src/screen/project.tsx b/web/src/screen/project.tsx @@ -2,7 +2,7 @@ import m from 'mithril'; import { get, page, patch, post, put, del, ApiError } from '../util/api'; -import { Badge, Empty, Failed, Gone, Loading, When } from '../component/layout'; +import { Badge, Empty, Failed, Gone, Loading, When, manages } from '../component/layout'; import { duration, shortSha } from '../util/format'; // Empty means follow the server default, zero means keep forever. They are @@ -28,12 +28,9 @@ export const Project: m.Component = { try { vnode.state.project = await get<any>(`/projects/${id}`); vnode.state.jobs = (await page<any>(`/projects/${id}/jobs`, { query: { limit: 10 } })).rows; - try { - vnode.state.variables = await get<any[]>(`/projects/${id}/variables`); - } catch { - // Readable without being manageable, which is not an error here. - vnode.state.variables = null; - } + vnode.state.variables = manages(vnode.state.project) + ? await get<any[]>(`/projects/${id}/variables`) + : null; vnode.state.error = null; } catch (e) { if ((e as ApiError).status === 404) vnode.state.gone = true; @@ -92,6 +89,8 @@ export const Project: m.Component = { </label> ); + const mine = manages(project); + return [ <h1>{project.name}</h1>, error ? <Failed error={error} /> : null, @@ -103,44 +102,52 @@ export const Project: m.Component = { <dt>branch</dt><dd class="ref">{project.default_branch}</dd> <dt>config</dt><dd class="mono">{project.config_path}</dd> </dl> - <label> - <span>visibility</span> - <select value={project.visibility} onchange={(e: any) => save({ visibility: e.target.value })}> - <option value="private">private</option> - <option value="public">public</option> - </select> - </label> - <label class="check"> - <input - type="checkbox" - checked={project.enabled === 1} - onchange={(e: any) => save({ enabled: e.target.checked })} - /> - <span>enabled</span> - </label> + {mine ? ( + <label> + <span>visibility</span> + <select value={project.visibility} onchange={(e: any) => save({ visibility: e.target.value })}> + <option value="private">private</option> + <option value="public">public</option> + </select> + </label> + ) : null} + {mine ? ( + <label class="check"> + <input + type="checkbox" + checked={project.enabled === 1} + onchange={(e: any) => save({ enabled: e.target.checked })} + /> + <span>enabled</span> + </label> + ) : null} </section>, - <section class="panel"> - <h2>retention</h2> - <p class="muted">Empty follows the server default. Zero keeps forever.</p> - {retention('artifact_keep_jobs', 'artifact jobs')} - {retention('artifact_keep_days', 'artifact days')} - {retention('log_keep_days', 'log days')} - </section>, + mine ? ( + <section class="panel"> + <h2>retention</h2> + <p class="muted">Empty follows the server default. Zero keeps forever.</p> + {retention('artifact_keep_jobs', 'artifact jobs')} + {retention('artifact_keep_days', 'artifact days')} + {retention('log_keep_days', 'log days')} + </section> + ) : null, - <section class="panel"> - <h2>trigger secret</h2> - {vnode.state.secret - ? ( - <div class="reveal"> - <p class="muted">Copy it now. It cannot be shown again.</p> - <code class="mono">{vnode.state.secret}</code> - <button onclick={() => { vnode.state.secret = null; }}>done</button> - </div> - ) - : <p class="muted">{project.has_trigger_secret ? 'A secret is set.' : 'No secret set.'}</p>} - <button onclick={rotate}>generate a new secret</button> - </section>, + mine ? ( + <section class="panel"> + <h2>trigger secret</h2> + {vnode.state.secret + ? ( + <div class="reveal"> + <p class="muted">Copy it now. It cannot be shown again.</p> + <code class="mono">{vnode.state.secret}</code> + <button onclick={() => { vnode.state.secret = null; }}>done</button> + </div> + ) + : <p class="muted">{project.has_trigger_secret ? 'A secret is set.' : 'No secret set.'}</p>} + <button onclick={rotate}>generate a new secret</button> + </section> + ) : null, vnode.state.variables === null ? null : ( <section class="panel"> diff --git a/web/src/screen/projects.tsx b/web/src/screen/projects.tsx @@ -2,7 +2,7 @@ import m from 'mithril'; import { page, post, Page } from '../util/api'; -import { Empty, Failed, Loading, Pager, When } from '../component/layout'; +import { Empty, Failed, Loading, Pager, When, session } from '../component/layout'; const LIMIT = 25; @@ -78,7 +78,7 @@ export const Projects: m.Component = { ) : null, - vnode.state.creating + !session.user ? null : vnode.state.creating ? ( <form class="panel" onsubmit={create}> {field('id', 'id')} diff --git a/web/src/util/auth.ts b/web/src/util/auth.ts @@ -102,6 +102,15 @@ export async function completeSignIn(): Promise<string | null> { return pending.at || null; } +export class GrantError extends Error { + status: number; + + constructor(status: number, message: string) { + super(message); + this.status = status; + } +} + async function exchange(doc: Discovery, body: Record<string, string>): Promise<void> { const res = await fetch(doc.token_endpoint, { method: 'POST', @@ -114,7 +123,7 @@ async function exchange(doc: Discovery, body: Record<string, string>): Promise<v const payload = await res.json(); detail = payload.error_description || payload.error || detail; } catch { /* the status is all there is */ } - throw new Error(detail); + throw new GrantError(res.status, detail); } const grant = await res.json(); @@ -138,16 +147,28 @@ export async function refresh(): Promise<void> { grant_type: 'refresh_token', refresh_token: token, client_id: doc.client_id, + }).catch((e) => { + // A refused grant is final, unlike a provider that is merely unwell. + if (e instanceof GrantError && e.status < 500) forget(); + throw e; }).finally(() => { refreshing = null; }); return refreshing; } // Renewed a little early, so a request is not sent with a token that dies -// on the way. +// on the way. A credential refused along the way leaves the caller +// anonymous rather than stuck. export async function bearer(): Promise<string | null> { if (!refreshToken()) return null; - if (!accessToken || Date.now() > expiresAt - 10_000) await refresh(); + if (!accessToken || Date.now() > expiresAt - 10_000) { + try { + await refresh(); + } catch (e) { + if (refreshToken()) throw e; + return null; + } + } return accessToken; }