Vectrum
Sign In

Let runner tokens expire

Registering a runner asks when its token expires (30 days to 1 year, or
never; 6 months by default). Expired runners can't claim jobs and are
marked in the runner list. Existing runners never expire.

The Docker image moves to Node 24, which the SSH server needs for the
post-quantum key exchange.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
JjohannSocommitted 1 parent 8cc88bbcommit812d374

Showing 9 changed files with 81 additions and 27 deletions.

+2−2DockerfileView File
@@ -1,7 +1,7 @@
11 # syntax=docker/dockerfile:1
22
33 # ---- build: install dependencies (which also downloads PocketBase into bin/) and build Next.js
4- FROM node:22-bookworm-slim AS build
4+ FROM node:24-bookworm-slim AS build
55 WORKDIR /app
66 COPY --from=oven/bun:1.4.2 /usr/local/bin/bun /usr/local/bin/bun
77 RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates unzip && rm -rf /var/lib/apt/lists/*
@@ -15,7 +15,7 @@ ENV NEXT_TELEMETRY_DISABLED=1 NEXT_OUTPUT=standalone
1515 RUN bun run build
1616
1717 # ---- runtime: the standalone server, PocketBase, git
18- FROM node:22-bookworm-slim
18+ FROM node:24-bookworm-slim
1919 RUN apt-get update && apt-get install -y --no-install-recommends git tini ca-certificates && rm -rf /var/lib/apt/lists/*
2020 WORKDIR /app
2121
+6−1app/actions/ci.tsView File
@@ -21,7 +21,12 @@ async function runnerScope(repo: { owner: string; name: string } | null): Promis
2121 export async function createRunnerAction(repo: { owner: string; name: string } | null, _prev: FormState, formData: FormData) {
2222 const { scope, path } = await runnerScope(repo);
2323 try {
24- const token = await createRunner(scope, { name: String(formData.get("name") ?? ""), labels: String(formData.get("labels") ?? "") });
24+ const expiry = String(formData.get("expiry") ?? "");
25+ const token = await createRunner(scope, {
26+ name: String(formData.get("name") ?? ""),
27+ labels: String(formData.get("labels") ?? ""),
28+ expiresInDays: expiry === "never" ? null : Number(expiry),
29+ });
2530 revalidatePath(path);
2631 return { token };
2732 } catch (e) {
+2−16app/settings/tokens/page.tsxView File
@@ -3,33 +3,19 @@ import { headers } from "next/headers";
33 import { Label as MuLabel } from "@unlocalhosted/metalui";
44 import { deleteTokenAction } from "@/app/actions/settings";
55 import { ConfirmButton } from "@/components/confirm-button";
6+ import { Expiry } from "@/components/expiry";
67 import { ShieldLockIcon } from "@/components/icons";
78 import { SettingsSection } from "@/components/metal";
89 import { ListRow, PlateSection, RowGlyph } from "@/components/settings-plate";
910 import { Blankslate, Label, PageTitle, RefChip } from "@/components/ui";
1011 import { requireViewer } from "@/lib/auth";
11- import { formatDate, timeAgo } from "@/lib/format";
12+ import { timeAgo } from "@/lib/format";
1213 import { canWrite, listReposForUser, repoAccess } from "@/lib/repos";
1314 import { listTokens, TOKEN_PREFIX } from "@/lib/tokens";
1415 import { NewTokenForm } from "./form";
1516
1617 export const metadata: Metadata = { title: "API tokens" };
1718
18- function Expiry({ expiresAt, expired }: { expiresAt: number | null; expired: boolean }) {
19- if (!expiresAt) return <b>Never expires</b>;
20- if (expired)
21- return (
22- <>
23- Expired <b>{timeAgo(expiresAt)}</b>
24- </>
25- );
26- return (
27- <>
28- Expires <b>{formatDate(expiresAt)}</b>
29- </>
30- );
31- }
32-
3319 export default async function TokensPage() {
3420 const viewer = await requireViewer();
3521 const tokens = await listTokens(viewer.id);
+14−0components/ci/new-runner-form.tsxView File
@@ -1,12 +1,21 @@
11 "use client";
22
33 import { useActionState } from "react";
4+ import { FormField, Select } from "@unlocalhosted/metalui";
45 import type { FormState } from "@/app/actions/types";
56 import { PlusIcon, ServerIcon, TagIcon } from "@/components/icons";
67 import { CopyIconButton, TextField } from "@/components/settings-fields";
78 import { PlateBody, PlateFooter } from "@/components/settings-plate";
89 import { Alert, Button } from "@/components/ui";
910
11+ const expiries = [
12+ { value: "30", label: "30 days" },
13+ { value: "90", label: "90 days" },
14+ { value: "180", label: "6 months" },
15+ { value: "365", label: "1 year" },
16+ { value: "never", label: "No expiration" },
17+ ];
18+
1019 function Command({ children }: { children: string }) {
1120 return (
1221 <div className="flex items-start gap-2 rounded-row well py-1.5 pr-1.5 pl-3.5">
@@ -79,6 +88,11 @@ export function NewRunnerForm({
7988 </>
8089 }
8190 />
91+ <FormField>
92+ <FormField.Label>Token expiration</FormField.Label>
93+ <Select name="expiry" defaultValue="180" options={expiries} aria-label="Token expiration" className="w-44" />
94+ <FormField.Description>Once it expires, the runner stops picking up jobs and has to be registered again.</FormField.Description>
95+ </FormField>
8296 {state?.error && <Alert>{state.error}</Alert>}
8397 </PlateBody>
8498 <PlateFooter>
+7−3components/ci/runner-list.tsxView File
@@ -1,9 +1,10 @@
11 import { Label as MuLabel, StatusBadge } from "@unlocalhosted/metalui";
22 import { ConfirmButton } from "@/components/confirm-button";
3+ import { Expiry } from "@/components/expiry";
34 import { ServerIcon } from "@/components/icons";
45 import { SettingsSection } from "@/components/metal";
56 import { ListRow, PlateSection, RowGlyph } from "@/components/settings-plate";
6- import { Blankslate, RefChip } from "@/components/ui";
7+ import { Blankslate, Label, RefChip } from "@/components/ui";
78 import { timeAgo } from "@/lib/format";
89 import { RUNNER_TOKEN_PREFIX, type Runner } from "@/lib/runners";
910
@@ -31,7 +32,9 @@ export function RunnerList({ runners, deleteAction }: { runners: Runner[]; delet
3132 title={
3233 <>
3334 <span className="truncate">{r.name}</span>
34- {r.busy ? (
35+ {r.expired ? (
36+ <Label tone="danger">Expired</Label>
37+ ) : r.busy ? (
3538 <StatusBadge led="waiting">Busy</StatusBadge>
3639 ) : r.online ? (
3740 <StatusBadge led="live">Idle</StatusBadge>
@@ -69,7 +72,8 @@ export function RunnerList({ runners, deleteAction }: { runners: Runner[]; delet
6972 </>
7073 ) : (
7174 "Never connected"
72- )}
75+ )}{" "}
76+ · <Expiry expiresAt={r.expiresAt} expired={r.expired} />
7377 </MuLabel>
7478 </>
7579 }
+17−0components/expiry.tsxaddedView File
@@ -0,0 +1,17 @@
1+ import { formatDate, timeAgo } from "@/lib/format";
2+
3+ /** "Never expires", "Expires Oct 2, 2026" or "Expired 3 days ago", for tokens and runners. */
4+ export function Expiry({ expiresAt, expired }: { expiresAt: number | null; expired: boolean }) {
5+ if (!expiresAt) return <b>Never expires</b>;
6+ if (expired)
7+ return (
8+ <>
9+ Expired <b>{timeAgo(expiresAt)}</b>
10+ </>
11+ );
12+ return (
13+ <>
14+ Expires <b>{formatDate(expiresAt)}</b>
15+ </>
16+ );
17+ }
+1−1lib/runner-api.tsView File
@@ -14,7 +14,7 @@ export const json = (body: unknown, status = 200) => Response.json(body, { statu
1414 export async function runnerFromRequest(request: Request): Promise<RunnerIdentity | Response> {
1515 const header = request.headers.get("authorization") ?? "";
1616 const runner = header.startsWith("Bearer ") ? await authenticateRunner(header.slice(7).trim()) : null;
17- if (!runner) return json({ error: "Unknown runner token. Register the runner again in Settings → Runners." }, 401);
17+ if (!runner) return json({ error: "Unknown or expired runner token. Register the runner again in Settings → Runners." }, 401);
1818 return runner;
1919 }
2020
+18−4lib/runners.tsView File
@@ -26,6 +26,8 @@ export type Runner = {
2626 version: string | null;
2727 createdAt: number;
2828 lastSeenAt: number | null;
29+ expiresAt: number | null;
30+ expired: boolean;
2931 online: boolean;
3032 /** The job it is running, if any. */
3133 busy: { runNumber: number; jobName: string; repo: string } | null;
@@ -40,6 +42,7 @@ export function systemLabels(row: { os: string | null; arch: string | null }) {
4042 }
4143
4244 const isOnline = (lastSeenAt: number) => !!lastSeenAt && Date.now() - lastSeenAt < ONLINE_WINDOW_MS;
45+ const isExpired = (expiresAt: number) => !!expiresAt && expiresAt < Date.now();
4346
4447 /** The job each of these runners is running, if any. */
4548 async function busyJobs(runnerIds: string[]) {
@@ -74,6 +77,8 @@ function toRunner(row: RecordModel, busy: Map<string, NonNullable<Runner["busy"]
7477 version: orNull(row.version),
7578 createdAt: row.created_at,
7679 lastSeenAt: orNull(row.last_seen_at),
80+ expiresAt: orNull(row.expires_at),
81+ expired: isExpired(row.expires_at),
7782 online: isOnline(row.last_seen_at),
7883 busy: busy.get(row.id) ?? null,
7984 };
@@ -92,11 +97,19 @@ export function parseLabels(input: string) {
9297 }
9398
9499 /** Registers a runner and returns its secret token; only its hash is stored. */
95- export async function createRunner(scope: RunnerScope, input: { name: string; labels: string }) {
100+ export async function createRunner(
101+ scope: RunnerScope,
102+ input: { name: string; labels: string; expiresInDays: number | null },
103+ ) {
96104 const name = input.name.trim().slice(0, 100);
97105 if (!name) throw new RunnerError("Give the runner a name, e.g. the machine it runs on.");
98106 const labels = parseLabels(input.labels);
107+ const days = input.expiresInDays;
108+ if (days !== null && !(Number.isInteger(days) && days > 0 && days <= 3650)) {
109+ throw new RunnerError("Choose when the runner’s token expires.");
110+ }
99111 const secret = `${RUNNER_TOKEN_PREFIX}${randomBytes(30).toString("base64url")}`;
112+ const now = Date.now();
100113 await (await db()).collection("runners").create({
101114 repo: "repoId" in scope ? scope.repoId : "",
102115 user: "userId" in scope ? scope.userId : "",
@@ -104,7 +117,8 @@ export async function createRunner(scope: RunnerScope, input: { name: string; la
104117 labels,
105118 token_hash: sha256(secret),
106119 hint: secret.slice(-4),
107- created_at: Date.now(),
120+ created_at: now,
121+ expires_at: days ? now + days * 86400e3 : 0,
108122 });
109123 return secret;
110124 }
@@ -122,11 +136,11 @@ export async function deleteRunner(scope: RunnerScope, runnerId: string) {
122136
123137 export type RunnerIdentity = { id: string; name: string; repoId: string | null; userId: string | null; labels: string[] };
124138
125- /** Resolves a runner token from the runner API, or null. */
139+ /** Resolves a runner token from the runner API, or null if unknown or expired. */
126140 export async function authenticateRunner(secret: string): Promise<RunnerIdentity | null> {
127141 if (!secret.startsWith(RUNNER_TOKEN_PREFIX)) return null;
128142 const row = await findFirst("runners", filter("token_hash = {:hash}", { hash: sha256(secret) }));
129- if (!row) return null;
143+ if (!row || isExpired(row.expires_at)) return null;
130144 return {
131145 id: row.id,
132146 name: row.name,
+14−0pb_migrations/1790954144_runner_expiry.jsaddedView File
@@ -0,0 +1,14 @@
1+ // Runner tokens can expire, like API tokens. 0 means never, so existing runners keep working.
2+
3+ migrate(
4+ (app) => {
5+ const runners = app.findCollectionByNameOrId("runners");
6+ runners.fields.add(new NumberField({ name: "expires_at", onlyInt: true, min: 0 }));
7+ app.save(runners);
8+ },
9+ (app) => {
10+ const runners = app.findCollectionByNameOrId("runners");
11+ runners.fields.removeByName("expires_at");
12+ app.save(runners);
13+ },
14+ );