7ec223c3528a

answer Kepr 2's moved URLs with 301s and its retired ones with 410 Gone, refuse to sync bucket repos named like Kepr's own routes, and check the route list both ways (v3.0.42)

time
2026-09-26 20:45 UTC · 2 weeks ago
machine
Junes-MacBook-Pro.local
agent
claude-opus-5-5
session
kepr 3 implementation
parent
b73f541c166b
7 files package.json+1 −1src/bucket/sync.ts+10 −5src/pages/kepr2.ts+52 −0src/server/app.ts+2 −0src/server/reserved.ts+24 −0tests/kepr2.test.ts+102 −0tests/server.test.ts+18 −8
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.0.41",3+	"version": "3.0.42",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

src/bucket/sync.ts+10 −5
@@ -10,6 +10,7 @@1010 	type StoredObject,1111 } from "../mirror/store.ts";1212 import { recordSync, registerRepo } from "../server/db.ts";13+import { RESERVED_NAMES } from "../server/reserved.ts";1314 import { dropTarballs } from "../tarball/cache.ts";1415 import type { BucketReader } from "./reader.ts";1516 import {@@ -58,11 +59,15 @@5859 	for (const repo of await listRepos(target.bucket)) {5960 		registerRepo(target.kepr, repo);6061 		let error: string | null = null;61-		try {62-			const events = await syncRepo(target.bucket, openMirror(target.mirrorDir, repo), repo);63-			if (events.some((event) => event.kind === "strip")) dropTarballs(target.tarballDir, repo);64-		} catch (cause) {65-			error = cause instanceof Error ? cause.message : String(cause);62+		if (RESERVED_NAMES.has(repo)) {63+			error = `not synced: the name "${repo}" clashes with Kepr's own /${repo} route; rename the repo`;64+		} else {65+			try {66+				const events = await syncRepo(target.bucket, openMirror(target.mirrorDir, repo), repo);67+				if (events.some((event) => event.kind === "strip")) dropTarballs(target.tarballDir, repo);68+			} catch (cause) {69+				error = cause instanceof Error ? cause.message : String(cause);70+			}6671 		}6772 		recordSync(target.kepr, repo, Math.floor(Date.now() / 1000), error);6873 	}

view file

src/pages/kepr2.ts+52 −0
@@ -0,0 +1,52 @@1+import type { Hono } from "hono";2+import { isSafePath, isVersion } from "../bucket/repo.ts";3+import { notFound, type PageContext, pathUrl } from "./layout.ts";4+5+// §28: Kepr 2's addresses. Every move is a 301: none of them will ever point anywhere else.6+const PANELS = new Map([7+	["issues", "/issues"],8+	["builds", "/releases"],9+	["archives", "/archives"],10+	["trust", ""],11+]);12+// Accounts, tickets, drafts, feeds, and Koh 2's offer and protocol endpoints (Kepr 2's `protocol.zig`).13+const GONE = [14+	"/tickets/*",15+	"/drafts/*",16+	"/join",17+	"/cli-auth",18+	"/api/cli-auth/*",19+	"/api/repos/*",20+	"/api/releases/upload",21+	"/protocol/*",22+	"/feed",23+	"/:repo/feed",24+];25+// §22.1's own routes under `/<repo>/releases/`, which a version would otherwise shadow.26+const RELEASE_ROUTES = new Set(["latest", "latest.json", "history.json"]);27+28+/** Registered ahead of the repo page, so `/source` and the gone paths are never taken for repos. */29+export function registerKepr2Routes(app: Hono, ctx: PageContext): void {30+	for (const path of GONE) app.all(path, (c) => c.text("Gone", 410));31+	app.get("/source", (c) => c.redirect("/kepr", 301));32+	app.get("/:repo", async (c, next) => {33+		const panel = PANELS.get(c.req.query("panel") ?? "");34+		if (panel === undefined) return next();35+		return c.redirect(`/${encodeURIComponent(c.req.param("repo"))}${panel}`, 301);36+	});37+	app.get("/:repo/releases/:version", async (c, next) => {38+		const version = c.req.param("version");39+		if (RELEASE_ROUTES.has(version)) return next();40+		if (!isVersion(version)) return notFound(c, ctx);41+		return c.redirect(`/${encodeURIComponent(c.req.param("repo"))}/releases#${version}`, 301);42+	});43+	app.get("/:repo/file", (c) => {44+		const path = c.req.query("path") ?? "";45+		if (!isSafePath(path)) return notFound(c, ctx);46+		return c.redirect(`/${encodeURIComponent(c.req.param("repo"))}/files/${pathUrl(path)}`, 301);47+	});48+	app.get("/:repo/bundle", (c) => c.redirect(headTarball(c.req.param("repo")), 301));49+	app.get("/:repo/bundle.blake3", (c) => c.redirect(headTarball(c.req.param("repo")), 301));50+}51+52+const headTarball = (repo: string) => `/${encodeURIComponent(repo)}/archive/HEAD.tar.gz`;

view file

src/server/app.ts+2 −0
@@ -16,6 +16,7 @@1616 import { registerFileRoutes } from "../pages/files.ts";1717 import { registerFrontPages } from "../pages/front.ts";1818 import { registerHousekeeping } from "../pages/housekeeping.ts";19+import { registerKepr2Routes } from "../pages/kepr2.ts";1920 import { registerProfilePage } from "../pages/profile.ts";2021 import { registerReleasesPage } from "../pages/releases.ts";2122 import { registerRepoPage } from "../pages/repo.ts";@@ -84,6 +85,7 @@8485 	registerFrontPages(app, ctx);8586 	registerDocsPages(app, ctx);8687 	registerProfilePage(app, ctx);88+	registerKepr2Routes(app, ctx);8789 	registerRepoPage(app, ctx);8890 	registerFileRoutes(app, ctx);8991 	registerSavePage(app, ctx);

view file

src/server/reserved.ts+24 −0
@@ -0,0 +1,24 @@1+/**2+ * First path segments Kepr's own routes take (`docs/routes.txt`, §28's old paths included). A bucket3+ * repo of the same name would be shadowed by them, so the sync refuses it. A test holds this list to4+ * the app's registered routes.5+ */6+export const RESERVED_NAMES = new Set([7+	"admin",8+	"api",9+	"browse",10+	"cli-auth",11+	"docs",12+	"drafts",13+	"favicon.svg",14+	"feed",15+	"join",16+	"protocol",17+	"robots.txt",18+	"sign-in",19+	"sitemap.xml",20+	"source",21+	"static",22+	"support",23+	"tickets",24+]);

view file

tests/kepr2.test.ts+102 −0
@@ -0,0 +1,102 @@1+import { expect, test } from "bun:test";2+import { cp, mkdtemp } from "node:fs/promises";3+import { tmpdir } from "node:os";4+import { join } from "node:path";5+import { createApp } from "../src/server/app.ts";6+import { openKeprDb } from "../src/server/db.ts";7+import { Browser, instance } from "./browser.ts";8+import { makeKohBucket } from "./fixture.ts";9+10+async function app() {11+	const dataDir = await mkdtemp(join(tmpdir(), "kepr-kepr2-"));12+	const config = {13+		domain: "kepr.example",14+		dataDir,15+		adminPasswordHash: "$argon2id$x",16+		bucket: { kind: "folder" as const, path: dataDir },17+	};18+	return createApp(config, openKeprDb(dataDir));19+}20+21+test("§28: Kepr 2's moved addresses answer 301 with the new one", async () => {22+	const kepr = await app();23+	const moves: [string, string][] = [24+		["/demo?panel=issues", "/demo/issues"],25+		["/demo?panel=builds", "/demo/releases"],26+		["/demo?panel=archives", "/demo/archives"],27+		["/demo?panel=trust", "/demo"],28+		["/demo/releases/2.1.0", "/demo/releases#2.1.0"],29+		["/demo/file?path=src/main.ts", "/demo/files/src/main.ts"],30+		["/demo/file?path=docs/a%20b.md", "/demo/files/docs/a%20b.md"],31+		["/demo/bundle", "/demo/archive/HEAD.tar.gz"],32+		["/demo/bundle.blake3", "/demo/archive/HEAD.tar.gz"],33+		["/source", "/kepr"],34+	];35+	for (const [from, to] of moves) {36+		const response = await kepr.request(from);37+		expect([from, response.status, response.headers.get("location")]).toEqual([from, 301, to]);38+	}39+});40+41+test("§28: Kepr 2's retired addresses answer 410 Gone, for any method", async () => {42+	const kepr = await app();43+	const gone = [44+		"/tickets",45+		"/tickets/new",46+		"/tickets/3",47+		"/drafts",48+		"/drafts/abc",49+		"/join",50+		"/cli-auth",51+		"/api/cli-auth/exchange",52+		"/api/cli-auth/session",53+		"/api/repos",54+		"/api/repos/demo/offer/negotiate",55+		"/api/repos/demo/offer/complete",56+		"/api/repos/demo/snapshot",57+		"/api/releases/upload",58+		"/protocol/v1/protocol.zig",59+		"/feed",60+		"/demo/feed",61+	];62+	for (const path of gone) {63+		expect([path, (await kepr.request(path)).status]).toEqual([path, 410]);64+		const post = await kepr.request(path, { method: "POST" });65+		expect([path, post.status]).toEqual([path, 410]);66+	}67+});68+69+test("the redirects shadow none of §22.1's routes, and refuse unsafe input", async () => {70+	const kepr = await app();71+	for (const path of [72+		"/demo/releases/latest",73+		"/demo/releases/latest.json",74+		"/demo/releases/history.json",75+		"/demo",76+		"/demo?panel=nope",77+		"/demo/file",78+		"/demo/file?path=../etc/passwd",79+		"/demo/file?path=a//b",80+		"/demo/releases/bad_version",81+	]) {82+		expect([path, (await kepr.request(path)).status]).toEqual([path, 404]);83+	}84+});85+86+test("a bucket repo named like one of Kepr's own routes is not synced, and the console says why", async () => {87+	const fx = await makeKohBucket();88+	await cp(join(fx.bucketDir, fx.repo), join(fx.bucketDir, "browse"), { recursive: true });89+	const inst = await instance(fx.bucketDir);90+	const rows = inst.kepr.query("SELECT name, last_sync_error FROM repos ORDER BY name").all();91+	const clash = `not synced: the name "browse" clashes with Kepr's own /browse route; rename the repo`;92+	expect(rows).toEqual([93+		{ name: "browse", last_sync_error: clash },94+		{ name: "demo", last_sync_error: null },95+	]);96+	expect(await Bun.file(join(inst.dataDir, "mirror/browse.db")).exists()).toBe(false);97+	const browser = new Browser(inst.app);98+	await browser.signIn();99+	expect(await (await browser.get("/admin/repos")).text()).toContain(100+		`sync error: ${clash.replaceAll('"', "&quot;").replaceAll("'", "&#39;")}`,101+	);102+});

view file

tests/server.test.ts+18 −8
@@ -2,6 +2,7 @@22 import { expect, test } from "bun:test";33 import { createApp } from "../src/server/app.ts";44 import type { Config } from "../src/server/config.ts";5+import { RESERVED_NAMES } from "../src/server/reserved.ts";56 67 const config: Config = {78 	domain: "kepr.example",@@ -17,16 +18,25 @@1718 	},1819 };1920 20-test("every route the app registers is listed in docs/routes.txt", async () => {21-	const listed = new Set(22-		(await Bun.file("docs/routes.txt").text())23-			.split("\n")24-			.filter((line) => line.trim() !== "" && !line.startsWith("#")),25-	);26-	const registered = createApp(config, new Database(":memory:"))21+const registered = () =>22+	createApp(config, new Database(":memory:"))2723 		.routes.filter((route) => !(route.method === "ALL" && route.path === "/*"))2824 		.map((route) => `${route.method} ${route.path}`);29-	expect(registered.filter((route) => !listed.has(route))).toEqual([]);25+26+test("the app registers exactly the routes in docs/routes.txt, both ways (§26)", async () => {27+	const listed = (await Bun.file("docs/routes.txt").text())28+		.split("\n")29+		.filter((line) => line.trim() !== "" && !line.startsWith("#"));30+	const routes = new Set(registered());31+	expect(registered().filter((route) => !listed.includes(route))).toEqual([]);32+	expect(listed.filter((route) => !routes.has(route))).toEqual([]);33+});34+35+test("every literal first segment of a route is a reserved repo name", () => {36+	const firsts = registered()37+		.map((route) => route.split(" ")[1]?.split("/")[1] ?? "")38+		.filter((segment) => segment !== "" && !segment.startsWith(":"));39+	expect([...new Set(firsts)].sort()).toEqual([...RESERVED_NAMES].sort());3040 });3141 3242 test("responses carry the §25 content security policy", async () => {

view file