0750fb5f4f0a

add the admin repos section: every repo the sync found with its visibility switch, category, homepage, card order, three install lines, last sync time and error, and sync now, tested to 404 every public surface and drop a private repo from every list and count (v3.0.35)

time
2026-09-26 20:20 UTC · 2 weeks ago
machine
Junes-MacBook-Pro.local
agent
claude-opus-5-5
session
kepr 3 implementation
parent
3917c91388df
8 files package.json+1 −1src/admin/console.ts+13 −0src/admin/repos.ts+81 −0src/admin/store.ts+41 −0src/server/app.ts+2 −0tests/admin.test.ts+1 −80tests/browser.ts+81 −0tests/console.test.ts+150 −0
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.0.34",3+	"version": "3.0.35",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

src/admin/console.ts+13 −0
@@ -20,6 +20,19 @@2020 	return html`<input type="hidden" name="form" value="${formToken(c)}">`;2121 }2222 23+/** A posted form's text fields; the guard has already parsed and checked the body. */24+export async function textFields(c: Context): Promise<Record<string, string>> {25+	const fields: Record<string, string> = {};26+	for (const [name, value] of Object.entries(await c.req.parseBody())) {27+		if (typeof value === "string") fields[name] = value;28+	}29+	return fields;30+}31+32+export function notice(error: string): Html | "" {33+	return error && html`<p class="issues-error mono" role="alert">${error}</p>`;34+}35+2336 /** An admin page: the site's own look, with the console's sections and sign-out on top. */2437 export function adminPage(c: Context, ctx: AdminContext, title: string, body: Html): string {2538 	const tab = ([href, name]: (typeof SECTIONS)[number]) =>

view file

src/admin/repos.ts+81 −0
@@ -0,0 +1,81 @@1+import type { Context, Hono } from "hono";2+import { syncAll } from "../bucket/sync.ts";3+import { html } from "../pages/html.ts";4+import { formatDate } from "../pages/layout.ts";5+import { type AdminContext, adminPage, formField, notice, textFields } from "./console.ts";6+import { type AdminRepo, allRepos, findRepo, type RepoEdit, updateRepo } from "./store.ts";7+8+const MAX_FIELD = 500;9+const HTTP_URL = /^https?:\/\/[^\s]+$/i;10+const SORT_ORDER = /^-?\d{1,6}$/;11+12+/** §24's Repos section: visibility (§20), card settings, sync status and "sync now". */13+export function registerRepos(app: Hono, ctx: AdminContext): void {14+	app.get("/admin/repos", (c) => c.html(reposPage(c, ctx, "")));15+16+	app.post("/admin/sync", async (c) => {17+		await syncAll(ctx.sync);18+		return c.redirect("/admin/repos", 303);19+	});20+21+	app.post("/admin/repos/:repo", async (c) => {22+		const repo = findRepo(ctx.kepr, c.req.param("repo"));23+		if (!repo) return c.notFound();24+		const fields = await textFields(c);25+		const text = (name: string) => (fields[name] ?? "").trim();26+		const edit: RepoEdit = {27+			public: fields.public === "1" ? 1 : 0,28+			category: text("category"),29+			homepage: text("homepage"),30+			sort_order: Number(text("sort_order") || "0"),31+			install1: text("install1"),32+			install2: text("install2"),33+			install3: text("install3"),34+		};35+		const refuse = (error: string) => c.html(reposPage(c, ctx, `${repo.name}: ${error}`), 400);36+		if (Object.values(fields).some((value) => value.length > MAX_FIELD)) {37+			return refuse(`keep each field under ${MAX_FIELD} characters.`);38+		}39+		if (edit.homepage !== "" && !HTTP_URL.test(edit.homepage)) {40+			return refuse("the homepage must be an http or https address.");41+		}42+		if (!SORT_ORDER.test(text("sort_order") || "0")) return refuse("the order must be a number.");43+		if ([edit.install1, edit.install2, edit.install3].some((line) => /[\r\n]/.test(line))) {44+			return refuse("an install line is one line.");45+		}46+		updateRepo(ctx.kepr, repo.name, edit);47+		return c.redirect(`/admin/repos#repo-${repo.name}`, 303);48+	});49+}50+51+function reposPage(c: Context, ctx: AdminContext, error: string): string {52+	const repos = allRepos(ctx.kepr);53+	const body = html`${notice(error)}54+<form method="post" action="/admin/sync">${formField(c)}<button class="issues-submit" type="submit">sync now</button></form>55+${repos.length === 0 && html`<p class="muted mono save-empty">no repos found in the bucket yet</p>`}56+${repos.map((repo) => repoForm(c, repo))}`;57+	return adminPage(c, ctx, "repos", body);58+}59+60+function repoForm(c: Context, repo: AdminRepo) {61+	const input = (name: keyof RepoEdit, label: string) =>62+		html`<label class="issues-label" for="${repo.name}-${name}">${label}</label>63+<input class="issues-input" id="${repo.name}-${name}" name="${name}" maxlength="${MAX_FIELD}" value="${String(repo[name])}">`;64+	const synced =65+		repo.last_sync_at === null ? "never synced" : `last sync ${formatDate(repo.last_sync_at)} UTC`;66+	return html`<article class="issue-post" id="repo-${repo.name}">67+<p class="issue-post-head mono"><span class="issue-author">${repo.public ? html`<a href="/${repo.name}">${repo.name}</a>` : repo.name}</span><span>${repo.public ? "public" : "private"}</span><span class="history-when">${synced}</span></p>68+${repo.last_sync_error && html`<p class="issues-error mono">sync error: ${repo.last_sync_error}</p>`}69+<form class="issues-form" method="post" action="/admin/repos/${repo.name}">70+${formField(c)}71+<label class="issues-label"><input type="checkbox" name="public" value="1"${repo.public ? html` checked` : ""}> public</label>72+${input("category", "category")}73+${input("homepage", "homepage")}74+${input("sort_order", "card order")}75+${input("install1", "install line 1")}76+${input("install2", "install line 2")}77+${input("install3", "install line 3")}78+<button class="issues-submit" type="submit">save ${repo.name}</button>79+</form>80+</article>`;81+}

view file

src/admin/store.ts+41 −0
@@ -0,0 +1,41 @@1+import type { Database } from "bun:sqlite";2+3+/** A repo row as the console edits it (§24): every repo the sync found, public or not. */4+export type AdminRepo = {5+	name: string;6+	public: number;7+	category: string;8+	homepage: string;9+	sort_order: number;10+	install1: string;11+	install2: string;12+	install3: string;13+	last_sync_at: number | null;14+	last_sync_error: string | null;15+};16+17+export type RepoEdit = Omit<AdminRepo, "name" | "last_sync_at" | "last_sync_error">;18+19+export function allRepos(db: Database): AdminRepo[] {20+	return db.query<AdminRepo, []>("SELECT * FROM repos ORDER BY sort_order, name").all();21+}22+23+export function findRepo(db: Database, name: string): AdminRepo | null {24+	return db.query<AdminRepo, [string]>("SELECT * FROM repos WHERE name = ?").get(name);25+}26+27+export function updateRepo(db: Database, name: string, edit: RepoEdit): void {28+	db.query(29+		`UPDATE repos SET public = ?, category = ?, homepage = ?, sort_order = ?, install1 = ?,30+		install2 = ?, install3 = ? WHERE name = ?`,31+	).run(32+		edit.public,33+		edit.category,34+		edit.homepage,35+		edit.sort_order,36+		edit.install1,37+		edit.install2,38+		edit.install3,39+		name,40+	);41+}

view file

src/server/app.ts+2 −0
@@ -4,6 +4,7 @@44 import { bodyLimit } from "hono/body-limit";55 import { secureHeaders } from "hono/secure-headers";66 import { registerStatus } from "../admin/console.ts";7+import { registerRepos } from "../admin/repos.ts";78 import { openBucket } from "../bucket/reader.ts";89 import { isSafePath } from "../bucket/repo.ts";910 import { registerIssuePages } from "../issues/pages.ts";@@ -72,6 +73,7 @@7273 		sync: { bucket, kepr, mirrorDir: ctx.mirrorDir, tarballDir: join(config.dataDir, "tarballs") },7374 	};7475 	registerStatus(app, admin);76+	registerRepos(app, admin);7577 	registerHousekeeping(app, ctx);7678 	registerFrontPages(app, ctx);7779 	registerDocsPages(app, ctx);

view file

tests/admin.test.ts+1 −80
@@ -1,86 +1,7 @@1-import type { Database } from "bun:sqlite";21 import { afterAll, beforeAll, describe, expect, setSystemTime, test } from "bun:test";3-import { mkdtemp } from "node:fs/promises";4-import { tmpdir } from "node:os";5-import { join } from "node:path";62 import type { Hono } from "hono";7-import { openBucket } from "../src/bucket/reader.ts";8-import { syncAll } from "../src/bucket/sync.ts";9-import { createApp } from "../src/server/app.ts";10-import type { Config } from "../src/server/config.ts";11-import { openKeprDb } from "../src/server/db.ts";3+import { Browser, type Instance, instance, PASSWORD } from "./browser.ts";124 import { makeRepos, writeRelease } from "./fixture.ts";13-14-const ORIGIN = "https://kepr.example";15-const PASSWORD = "correct horse battery staple";16-// The env Bun.serve passes: its requestIP gives the socket peer.17-const peer = (address: string) => ({ requestIP: () => ({ address }) });18-19-/** A browser against one app: a cookie jar, and the form token of the last admin page read. */20-class Browser {21-	cookies = new Map<string, string>();22-	form = "";23-	constructor(24-		readonly app: Hono,25-		readonly address = "203.0.113.5",26-	) {}27-28-	async request(path: string, init: RequestInit = {}): Promise<Response> {29-		const headers = new Headers(init.headers);30-		headers.set("cookie", [...this.cookies].map(([k, v]) => `${k}=${v}`).join("; "));31-		const response = await this.app.request(path, { ...init, headers }, peer(this.address));32-		for (const line of response.headers.getSetCookie()) {33-			const [pair = "", ...attributes] = line.split(";");34-			const [name = "", value = ""] = pair.split("=");35-			if (attributes.some((a) => /max-age=0/i.test(a.trim())) || value === "") {36-				this.cookies.delete(name);37-			} else this.cookies.set(name, value);38-		}39-		return response;40-	}41-42-	async get(path: string): Promise<Response> {43-		const response = await this.request(path);44-		const text = await response.clone().text();45-		this.form = /name="form" value="([^"]+)"/.exec(text)?.[1] ?? this.form;46-		return response;47-	}48-49-	post(path: string, fields: Record<string, string> | FormData, origin = ORIGIN) {50-		const body = fields instanceof FormData ? fields : new URLSearchParams(fields);51-		if (!(fields instanceof FormData) && !("form" in fields)) body.set("form", this.form);52-		else if (fields instanceof FormData && !fields.has("form")) body.set("form", this.form);53-		return this.request(path, { method: "POST", headers: origin ? { origin } : {}, body });54-	}55-56-	async signIn(password = PASSWORD): Promise<Response> {57-		await this.get("/sign-in");58-		const response = await this.post("/sign-in", { password });59-		if (response.status === 303) await this.get("/admin");60-		return response;61-	}62-}63-64-type Instance = { app: Hono; kepr: Database; dataDir: string; config: Config };65-66-async function instance(bucketDir: string, dataDir?: string): Promise<Instance> {67-	const dir = dataDir ?? (await mkdtemp(join(tmpdir(), "kepr-admin-")));68-	const kepr = openKeprDb(dir);69-	const bucket = { kind: "folder" as const, path: bucketDir };70-	await syncAll({71-		bucket: openBucket(bucket),72-		kepr,73-		mirrorDir: join(dir, "mirror"),74-		tarballDir: join(dir, "tarballs"),75-	});76-	const config: Config = {77-		domain: "kepr.example",78-		dataDir: dir,79-		adminPasswordHash: await Bun.password.hash(PASSWORD, "argon2id"),80-		bucket,81-	};82-	return { app: createApp(config, kepr), kepr, dataDir: dir, config };83-}845 856 // Every admin route with sample parameters, from the app's own route table.867 function adminRoutes(app: Hono): { method: string; path: string }[] {

view file

tests/browser.ts+81 −0
@@ -0,0 +1,81 @@1+import type { Database } from "bun:sqlite";2+import { mkdtemp } from "node:fs/promises";3+import { tmpdir } from "node:os";4+import { join } from "node:path";5+import type { Hono } from "hono";6+import { openBucket } from "../src/bucket/reader.ts";7+import { syncAll } from "../src/bucket/sync.ts";8+import { createApp } from "../src/server/app.ts";9+import type { Config } from "../src/server/config.ts";10+import { openKeprDb } from "../src/server/db.ts";11+12+const ORIGIN = "https://kepr.example";13+export const PASSWORD = "correct horse battery staple";14+// The env Bun.serve passes: its requestIP gives the socket peer.15+const peer = (address: string) => ({ requestIP: () => ({ address }) });16+17+/** A browser against one app: a cookie jar, and the form token of the last admin page read. */18+export class Browser {19+	cookies = new Map<string, string>();20+	form = "";21+	constructor(22+		readonly app: Hono,23+		readonly address = "203.0.113.5",24+	) {}25+26+	async request(path: string, init: RequestInit = {}): Promise<Response> {27+		const headers = new Headers(init.headers);28+		headers.set("cookie", [...this.cookies].map(([k, v]) => `${k}=${v}`).join("; "));29+		const response = await this.app.request(path, { ...init, headers }, peer(this.address));30+		for (const line of response.headers.getSetCookie()) {31+			const [pair = "", ...attributes] = line.split(";");32+			const [name = "", value = ""] = pair.split("=");33+			if (attributes.some((a) => /max-age=0/i.test(a.trim())) || value === "") {34+				this.cookies.delete(name);35+			} else this.cookies.set(name, value);36+		}37+		return response;38+	}39+40+	async get(path: string): Promise<Response> {41+		const response = await this.request(path);42+		const text = await response.clone().text();43+		this.form = /name="form" value="([^"]+)"/.exec(text)?.[1] ?? this.form;44+		return response;45+	}46+47+	post(path: string, fields: Record<string, string> | FormData, origin = ORIGIN) {48+		const body = fields instanceof FormData ? fields : new URLSearchParams(fields);49+		if (!(fields instanceof FormData) && !("form" in fields)) body.set("form", this.form);50+		else if (fields instanceof FormData && !fields.has("form")) body.set("form", this.form);51+		return this.request(path, { method: "POST", headers: origin ? { origin } : {}, body });52+	}53+54+	async signIn(password = PASSWORD): Promise<Response> {55+		await this.get("/sign-in");56+		const response = await this.post("/sign-in", { password });57+		if (response.status === 303) await this.get("/admin");58+		return response;59+	}60+}61+62+export type Instance = { app: Hono; kepr: Database; dataDir: string; config: Config };63+64+export async function instance(bucketDir: string, dataDir?: string): Promise<Instance> {65+	const dir = dataDir ?? (await mkdtemp(join(tmpdir(), "kepr-admin-")));66+	const kepr = openKeprDb(dir);67+	const bucket = { kind: "folder" as const, path: bucketDir };68+	await syncAll({69+		bucket: openBucket(bucket),70+		kepr,71+		mirrorDir: join(dir, "mirror"),72+		tarballDir: join(dir, "tarballs"),73+	});74+	const config: Config = {75+		domain: "kepr.example",76+		dataDir: dir,77+		adminPasswordHash: await Bun.password.hash(PASSWORD, "argon2id"),78+		bucket,79+	};80+	return { app: createApp(config, kepr), kepr, dataDir: dir, config };81+}

view file

tests/console.test.ts+150 −0
@@ -0,0 +1,150 @@1+import { afterAll, beforeAll, describe, expect, setSystemTime, test } from "bun:test";2+import { fileIssue } from "../src/issues/store.ts";3+import { Browser, type Instance, instance } from "./browser.ts";4+import { makeRepos, writeRelease } from "./fixture.ts";5+6+let clock = Date.parse("2026-09-26T12:00:00Z");7+const tick = (seconds: number) => {8+	clock += seconds * 1000;9+	setSystemTime(new Date(clock));10+};11+12+describe("repos and visibility", () => {13+	let inst: Instance;14+	let browser: Browser;15+	let bucketDir: string;16+	let save: string;17+	const publicPaths = () => [18+		"/app",19+		"/app/files/README.md",20+		"/app/raw/README.md",21+		`/app/saves/${save}`,22+		`/app/archive/${save}.tar.gz`,23+		"/app/archive/HEAD.tar.gz",24+		"/app/archives",25+		"/app/releases",26+		"/app/releases/latest",27+		"/app/releases/latest.json",28+		"/app/releases/history.json",29+		"/app/releases/latest/version.txt",30+		"/app/releases/latest/linux-x86_64",31+		"/app/releases/1.0.0/checksums.txt",32+		"/app/releases/1.0.0/linux-x86_64",33+		"/app/install.sh",34+		"/api/app/releases/latest",35+		"/api/app/releases/history",36+		"/app/update-info",37+		"/app/meta",38+		"/app/issues",39+		"/app/issues/new",40+		"/app/issues/1",41+	];42+	const statuses = async () => {43+		const out: string[] = [];44+		for (const path of publicPaths()) out.push(`${path} ${(await inst.app.request(path)).status}`);45+		return out;46+	};47+	const text = async (path: string) => (await inst.app.request(path)).text();48+	const publish = (repo: string, fields: Record<string, string>) =>49+		browser.post(`/admin/repos/${repo}`, { sort_order: "0", ...fields });50+51+	beforeAll(async () => {52+		setSystemTime(new Date(clock));53+		const repos = await makeRepos(["app", "other"]);54+		bucketDir = repos.bucketDir;55+		save = repos.saves.app as string;56+		await writeRelease(bucketDir, "app", {57+			version: "1.0.0",58+			save,59+			files: [{ platform: "linux-x86_64", name: "app", bytes: new Uint8Array([1]) }],60+		});61+		inst = await instance(bucketDir);62+		inst.kepr.run("INSERT INTO settings (key, value) VALUES ('handle', 'june')");63+		fileIssue(inst.kepr, {64+			repo: "app",65+			title: "It broke",66+			body: "b",67+			type: "bug",68+			version: "",69+			reporterName: "",70+			contactEmail: "",71+			ipHash: "x",72+			filedAt: 1,73+		});74+		browser = new Browser(inst.app);75+		await browser.signIn();76+	});77+	afterAll(() => setSystemTime());78+79+	test("every repo the sync found is listed, private, with its last sync", async () => {80+		const page = await (await browser.get("/admin/repos")).text();81+		expect(page).toContain('id="repo-app"');82+		expect(page).toContain('id="repo-other"');83+		expect(page.match(/<span>private<\/span>/g)?.length).toBe(2);84+		expect(page).toContain("last sync 2026-09-26 12:00 UTC");85+		expect((await statuses()).every((line) => line.endsWith(" 404"))).toBe(true);86+	});87+88+	test("bad settings are refused and change nothing", async () => {89+		for (const fields of <Record<string, string>[]>[90+			{ homepage: "javascript:alert(1)" },91+			{ sort_order: "first" },92+			{ install1: "one\ntwo" },93+			{ category: "x".repeat(501) },94+		]) {95+			expect((await publish("app", { public: "1", ...fields })).status).toBe(400);96+		}97+		expect((await inst.app.request("/app")).status).toBe(404);98+		expect((await browser.post("/admin/repos/nowhere", { public: "1" })).status).toBe(404);99+	});100+101+	test("publishing opens every public surface, with the settings shown", async () => {102+		await publish("other", { public: "1" });103+		const response = await publish("app", {104+			public: "1",105+			category: "tools",106+			homepage: "https://app.example",107+			install1: "nix profile add app",108+		});109+		expect(response.status).toBe(303);110+		const lines = await statuses();111+		expect(lines.filter((line) => line.endsWith(" 404"))).toEqual([]);112+		expect(await text("/app")).toContain("https://app.example");113+		expect(await text("/app")).toContain("nix profile add app");114+		expect(await text("/")).toContain('href="/app"');115+		expect(await text("/browse")).toContain('href="/app"');116+		expect(await text("/@june")).toContain('href="/app"');117+		expect(await text("/sitemap.xml")).toContain("/app</loc>");118+	});119+120+	test("flipping a repo private 404s every surface and drops it from lists and counts", async () => {121+		const stat = async () =>122+			[...(await text("/@june")).matchAll(/profile-stat-number">([^<]*)</g)].map((m) => m[1]);123+		// Both download routes above counted; the issue is the repo's one.124+		expect(await stat()).toEqual(["2", "2", "1", "2"]);125+		await publish("app", { category: "tools" });126+		expect((await statuses()).filter((line) => !line.endsWith(" 404"))).toEqual([]);127+		for (const path of ["/", "/browse", "/@june"]) expect(await text(path)).not.toContain("/app");128+		expect(await text("/sitemap.xml")).not.toContain("/app<");129+		expect(await text("/")).not.toContain("tools");130+		expect(await stat()).toEqual(["1", "1", "0", "0"]);131+		// Settings survive the flip.132+		expect(await (await browser.get("/admin/repos")).text()).toContain('value="tools"');133+	});134+135+	test("sync now reads the bucket at once and records the time", async () => {136+		await writeRelease(bucketDir, "other", {137+			version: "2.0.0",138+			save: save,139+			files: [{ platform: "linux-x86_64", name: "other", bytes: new Uint8Array([2]) }],140+		});141+		tick(60);142+		expect((await inst.app.request("/other/releases/latest/version.txt")).status).toBe(404);143+		const response = await browser.post("/admin/sync", {});144+		expect(response.status).toBe(303);145+		expect(await text("/other/releases/latest/version.txt")).toBe("2.0.0");146+		expect(await (await browser.get("/admin/repos")).text()).toContain(147+			"last sync 2026-09-26 12:01 UTC",148+		);149+	});150+});

view file