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:
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;
}