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
↓ kepr-7ec223c3.tar.gzatlas at this save
files changed7 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 −8package.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": {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 }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`;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);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+]);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('"', """).replaceAll("'", "'")}`,101+ );102+});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 () => {