0f7e06f66cce

serve the release feeds, checksums and downloads of §22.1 and §22.4 with kepr 2's bytes and error bodies, count downloads before a five-minute presigned redirect, count nix tarball fetches as installs, and pin the koh 2 route on 2.1.16 (v3.0.22)

time
2026-09-26 18:52 UTC · 2 weeks ago
machine
Junes-MacBook-Pro.local
agent
claude-opus-5-5
session
kepr 3 implementation
parent
4622d4048bd6
10 files package.json+1 −1src/bucket/reader.ts+23 −1src/pages/repo.ts+11 −4src/releases/feeds.ts+117 −0src/releases/routes.ts+162 −0src/server/app.ts+3 −0src/server/db.ts+4 −2src/tarball/archive.ts+3 −0tests/bucket.test.ts+16 −3tests/releases.test.ts+180 −1
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.0.21",3+	"version": "3.0.22",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

src/bucket/reader.ts+23 −1
@@ -1,13 +1,21 @@11 import { readdir } from "node:fs/promises";22 import { join, relative } from "node:path";3+import { pathToFileURL } from "node:url";34 import type { BucketSource } from "../server/config.ts";45 5-/** Read-only view of a Koh 3 remote (§9.1): Kepr needs only these two of its five operations. */6+/**7+ * Read-only view of a Koh 3 remote (§9.1): Kepr needs only two of its five operations, plus a8+ * short-lived download URL so it never streams release files itself (§22.2).9+ */610 export type BucketReader = {711 	get(key: string): Promise<Uint8Array | null>;812 	list(prefix: string): Promise<string[]>;13+	/** A URL that serves `key` for five minutes, as an attachment with this filename and content type. */14+	downloadUrl(key: string, filename: string, type: string): string;915 };1016 17+const DOWNLOAD_SECONDS = 5 * 60;18+1119 export function openBucket(source: BucketSource): BucketReader {1220 	return source.kind === "s3" ? s3Reader(source) : folderReader(source.path);1321 }@@ -39,6 +47,15 @@3947 			} while (continuationToken);4048 			return keys;4149 		},50+		downloadUrl(key, filename, type) {51+			// Bun signs these as response-content-disposition and response-content-type (verified, v1.3.14).52+			return client.presign(key, {53+				method: "GET",54+				expiresIn: DOWNLOAD_SECONDS,55+				contentDisposition: `attachment; filename="${filename}"`,56+				type,57+			});58+		},4259 	};4360 }4461 @@ -56,6 +73,11 @@5673 				.map((entry) => relative(root, join(entry.parentPath, entry.name)))5774 				.filter((key) => key.startsWith(prefix))5875 				.sort();76+		},77+		// Development and tests only: a file:// URL can't carry a filename, a type or an expiry.78+		downloadUrl(key) {79+			if (key.split("/").includes("..")) throw new Error(`bucket key escapes the folder: ${key}`);80+			return pathToFileURL(join(root, key)).href;5981 		},6082 	};6183 }

view file

src/pages/repo.ts+11 −4
@@ -41,9 +41,7 @@4141 		const pageNumber = Math.max(1, Number.parseInt(c.req.query("page") ?? "1", 10) || 1);4242 		const readme = readmeAtHead(repo);4343 		const name = repo.settings.name;44-		const paragraph = readme === null ? null : describeFile("README.md", readme);45-		const intro =46-			paragraph === null ? null : renderParagraph(paragraph, { repo: name, dir: "", at: null });44+		const intro = readmeIntro(name, readme);4745 		cacheFor(c, false);4846 		return c.html(4947 			page(ctx, {@@ -58,7 +56,16 @@5856 	});5957 }6058 61-function readmeAtHead(repo: PublicRepo): string | null {59+/** The README's first paragraph (§21.4's description), as markup and as plain text. */60+export function readmeIntro(61+	repo: string,62+	readme: string | null,63+): { html: Html; text: string } | null {64+	const paragraph = readme === null ? null : describeFile("README.md", readme);65+	return paragraph === null ? null : renderParagraph(paragraph, { repo, dir: "", at: null });66+}67+68+export function readmeAtHead(repo: PublicRepo): string | null {6269 	const head = headId(repo.mirror);6370 	const path =6471 		head && filesAt(repo.mirror, head).find((f) => f.path.toLowerCase() === "readme.md")?.path;

view file

src/releases/feeds.ts+117 −0
@@ -0,0 +1,117 @@1+import type { ReleaseFile } from "../bucket/repo.ts";2+import type { Release } from "./releases.ts";3+4+// The machine-readable shapes of §22, written by hand rather than with JSON.stringify: clients in5+// the wild were built against Kepr 2's bytes, and Kepr 2 escaped `/`, `<` and `>` (its6+// `jsonEscapeStr`). URLs are assembled from validated repo names, versions and platforms, which need7+// no escaping, so their slashes stay bare, as Kepr 2 wrote them.8+9+const ESCAPES: Record<string, string> = {10+	'"': '\\"',11+	"\\": "\\\\",12+	"/": "\\/",13+	"<": "\\u003c",14+	">": "\\u003e",15+	"\n": "\\n",16+	"\r": "\\r",17+	"\t": "\\t",18+};19+20+// Kepr 2 escaped bytes, so of the control characters only ASCII's (below 0x20, and 0x7f) change.21+function jsonEscape(text: string): string {22+	return text.replace(/["\\/<>\p{Cc}]/gu, (ch) => {23+		const code = ch.charCodeAt(0);24+		if (code >= 0x80) return ch;25+		return ESCAPES[ch] ?? `\\u${code.toString(16).padStart(4, "0")}`;26+	});27+}28+29+function str(text: string): string {30+	return `"${jsonEscape(text)}"`;31+}32+33+function optional(text: string | null): string {34+	return text === null ? "null" : str(text);35+}36+37+function provenance(version: string, file: ReleaseFile): string {38+	return `{"version":${str(version)},"platform":${str(file.platform)},"checksum":${str(`blake3:${file.blake3}`)},"source":"koh-offer"}`;39+}40+41+// §22.4: notes are omitted entirely when empty, never sent as null or "".42+function head(release: Release): string {43+	const notes = release.notes === "" ? "" : `,"notes":${str(release.notes)}`;44+	return `{"version":${str(release.version)}${notes}`;45+}46+47+/** §22.4's release JSON shape, field for field and in Kepr 2's order. */48+export function releaseJson(domain: string, repo: string, release: Release): string {49+	const platforms = release.files.map(50+		(file) =>51+			`${str(file.platform)}:{"url":"https://${jsonEscape(domain)}/${repo}/releases/${release.version}/${file.platform}","blake3":${str(file.blake3)},"sha256":${str(file.sha256)},"provenance":${provenance(release.version, file)},"signature":""}`,52+	);53+	return `${head(release)},"platforms":{${platforms.join(",")}}}`;54+}55+56+/** Kepr 2's legacy per-platform shape, which only the frozen Koh 2 route serves (§22.4). */57+export function legacyReleaseJson(domain: string, repo: string, release: Release): string {58+	const platforms = release.files.map(59+		(file) =>60+			`${str(file.platform)}:{"url":"https://${jsonEscape(domain)}/api/${repo}/releases/${release.version}/${file.platform}","checksum":${str(`blake3:${file.blake3}`)},"provenance":${provenance(release.version, file)},"signature":""}`,61+	);62+	return `${head(release)},"platforms":{${platforms.join(",")}}}`;63+}64+65+/** `[{"version","notes"}]`, newest first; unlike the release shape, Kepr 2 always sent notes here. */66+export function historyJson(releases: Release[]): string {67+	const rows = releases.map((r) => `{"version":${str(r.version)},"notes":${str(r.notes)}}`);68+	return `[${rows.join(",")}]`;69+}70+71+export type UpdateHead = { id: string; version: string; createdAt: number };72+73+/** `/<repo>/update-info` (§22.4). File URLs take Kepr 2's `<repo>-<platform>` form here. */74+export function updateInfoJson(75+	domain: string,76+	repo: string,77+	saveHead: UpdateHead | null,78+	newest: Release | undefined,79+): string {80+	const headPart = saveHead81+		? `{"save_id":${str(saveHead.id)},"version":${str(saveHead.version)},"timestamp":${saveHead.createdAt}}`82+		: "null";83+	const platforms = newest?.files.map(84+		(file) =>85+			`${str(file.platform)}:{"url":"https://${jsonEscape(domain)}/${repo}/releases/${newest.version}/${repo}-${file.platform}","blake3":${str(file.blake3)}}`,86+	);87+	const build = newest88+		? `{"version":${str(newest.version)},"platforms":{${platforms?.join(",")}}}`89+		: "null";90+	return `{"head":${headPart},"latest_build":${build}}`;91+}92+93+export type RepoMeta = {94+	repo: string;95+	saveCount: number;96+	lineCount: number;97+	lastSaveAt: number;98+	latestRelease: string | null;99+	description: string | null;100+	language: string | null;101+	category: string | null;102+};103+104+/**105+ * `/<repo>/meta` (§22.4). `latest_version` is the newest release, as is `latest_release`; Kepr 2's106+ * `languages` and `categories` were single strings, so they stay strings here.107+ */108+export function metaJson(meta: RepoMeta): string {109+	return `{"repo":${str(meta.repo)},"save_count":${meta.saveCount},"line_count":${meta.lineCount},"last_save_at":${meta.lastSaveAt},"latest_release":${optional(meta.latestRelease)},"latest_version":${optional(meta.latestRelease)},"description":${optional(meta.description)},"languages":${optional(meta.language)},"categories":${optional(meta.category)}}`;110+}111+112+/** Kepr 2's checksums.txt: a header, the SHA-256 lines, then the Blake3 lines (§22.1). */113+export function checksumsTxt(repo: string, release: Release): string {114+	const lines = (algorithm: "sha256" | "blake3") =>115+		release.files.map((file) => `${algorithm}:${file[algorithm]}  ${repo}-${file.platform}\n`);116+	return `# ${repo} ${release.version}\n# built by kepr\n\n${[...lines("sha256"), ...lines("blake3")].join("")}`;117+}

view file

src/releases/routes.ts+162 −0
@@ -0,0 +1,162 @@1+import type { Context, Hono } from "hono";2+import type { BucketReader } from "../bucket/reader.ts";3+import { isPlatform, isVersion, type ReleaseFile } from "../bucket/repo.ts";4+import { history, repoStats } from "../mirror/queries.ts";5+import { notFound, openPublicRepo, type PageContext, type PublicRepo } from "../pages/layout.ts";6+import { readmeAtHead, readmeIntro } from "../pages/repo.ts";7+import {8+	checksumsTxt,9+	historyJson,10+	legacyReleaseJson,11+	metaJson,12+	releaseJson,13+	updateInfoJson,14+} from "./feeds.ts";15+import { countDownload, type Release, releaseContentType, visibleReleases } from "./releases.ts";16+17+/**18+ * §22.4: Koh 2's `koh update` reads `/api/koh/releases/latest`, built into every Koh 2 binary. The19+ * route stays on Koh 2's last release forever, never the repo's newest, so a Koh 2 install is never20+ * offered Koh 3.21+ */22+const FROZEN_KOH2 = { repo: "koh", version: "2.1.16" } as const;23+24+const JSON_TYPE = "application/json; charset=UTF-8";25+const TEXT_TYPE = "text/plain; charset=UTF-8";26+// Kepr 2's error bodies. A private repo answers exactly as an unknown one (§20).27+const REPO_NOT_FOUND = '{"error":"repo_not_found","message":"repo not found"}';28+const NO_RELEASES = '{"error":"not_found","message":"no releases"}';29+const RELEASE_NOT_FOUND = '{"error":"not_found","message":"release not found"}';30+31+type Feed = { repo: PublicRepo; releases: Release[] };32+33+export function registerReleaseRoutes(app: Hono, ctx: PageContext, bucket: BucketReader): void {34+	const send = (c: Context, status: 200 | 404, body: string, type = JSON_TYPE, cors = false) => {35+		if (cors) c.header("Access-Control-Allow-Origin", "*");36+		return c.body(body, status, { "Content-Type": type });37+	};38+	const feed = (name: string): Feed | null => {39+		const repo = openPublicRepo(ctx, name);40+		return repo && { repo, releases: visibleReleases(ctx.kepr, repo.mirror, name) };41+	};42+	// Counted before the redirect (§22.2); Kepr never streams a release file itself.43+	const download = (c: Context, repo: string, version: string, file: ReleaseFile) => {44+		countDownload(ctx.kepr, repo, version, file.platform);45+		const key = `${repo}/releases/${version}/${file.platform}/${file.name}`;46+		c.header("Cache-Control", "no-store");47+		return c.redirect(bucket.downloadUrl(key, file.name, releaseContentType(file.name)), 302);48+	};49+	const downloadAt = (c: Context, name: string, version: string, requested: string) => {50+		const f = feed(name);51+		if (!f) return send(c, 404, REPO_NOT_FOUND);52+		// Kepr 2's download links end in `<repo>-<platform>`, so `curl -O` saves a telling name.53+		const platform = requested.startsWith(`${name}-`)54+			? requested.slice(name.length + 1)55+			: requested;56+		const release = f.releases.find((r) => r.version === version);57+		const file = isPlatform(platform) && release?.files.find((x) => x.platform === platform);58+		return file ? download(c, name, version, file) : send(c, 404, RELEASE_NOT_FOUND);59+	};60+	// The newest visible release, or the response that says why there is none.61+	const newest = (c: Context, name: string): Release | Response => {62+		const f = feed(name);63+		if (!f) return send(c, 404, REPO_NOT_FOUND);64+		return f.releases[0] ?? send(c, 404, NO_RELEASES);65+	};66+67+	app.get("/api/koh/releases/latest", (c) => {68+		const f = feed(FROZEN_KOH2.repo);69+		const release = f?.releases.find((r) => r.version === FROZEN_KOH2.version);70+		if (!release) return send(c, 404, '{"error":"no_releases","message":"no releases available"}');71+		return send(c, 200, legacyReleaseJson(ctx.domain, FROZEN_KOH2.repo, release));72+	});73+	app.get("/api/koh/releases/:version/:platform", (c) =>74+		downloadAt(c, FROZEN_KOH2.repo, c.req.param("version"), c.req.param("platform")),75+	);76+	for (const [path, cors] of [77+		["/api/:repo/releases/latest", true],78+		["/:repo/releases/latest.json", false],79+	] as const) {80+		app.get(path, (c) => {81+			const release = newest(c, c.req.param("repo"));82+			if (release instanceof Response) return release;83+			return send(c, 200, releaseJson(ctx.domain, c.req.param("repo"), release), JSON_TYPE, cors);84+		});85+	}86+	for (const [path, cors] of [87+		["/api/:repo/releases/history", true],88+		["/:repo/releases/history.json", false],89+	] as const) {90+		app.get(path, (c) => {91+			const f = feed(c.req.param("repo"));92+			if (!f) return send(c, 404, REPO_NOT_FOUND);93+			if (f.releases.length === 0) return send(c, 404, NO_RELEASES);94+			return send(c, 200, historyJson(f.releases), JSON_TYPE, cors);95+		});96+	}97+	app.get("/:repo/releases/latest", (c) => {98+		const repo = openPublicRepo(ctx, c.req.param("repo"));99+		if (!repo) return notFound(c, ctx);100+		return c.redirect(`/${repo.settings.name}/releases`, 302);101+	});102+	app.get("/:repo/releases/latest/version.txt", (c) => {103+		const release = newest(c, c.req.param("repo"));104+		return release instanceof Response ? release : send(c, 200, release.version, TEXT_TYPE);105+	});106+	app.get("/:repo/releases/:version/checksums.txt", (c) => {107+		const name = c.req.param("repo");108+		const f = feed(name);109+		if (!f) return send(c, 404, REPO_NOT_FOUND);110+		const release = f.releases.find((r) => r.version === c.req.param("version"));111+		if (!release) return send(c, 404, RELEASE_NOT_FOUND);112+		return send(c, 200, checksumsTxt(name, release), TEXT_TYPE);113+	});114+	// §22.1: the newest file for that platform, from the newest release that has one.115+	app.get("/:repo/releases/latest/:platform", (c) => {116+		const name = c.req.param("repo");117+		const f = feed(name);118+		if (!f) return send(c, 404, REPO_NOT_FOUND);119+		const platform = c.req.param("platform");120+		for (const release of f.releases) {121+			const file = release.files.find((x) => x.platform === platform);122+			if (file) return download(c, name, release.version, file);123+		}124+		return send(c, 404, RELEASE_NOT_FOUND);125+	});126+	app.get("/:repo/releases/:version/:platform", (c) => {127+		const version = c.req.param("version");128+		if (!isVersion(version)) return send(c, 404, RELEASE_NOT_FOUND);129+		return downloadAt(c, c.req.param("repo"), version, c.req.param("platform"));130+	});131+	app.get("/:repo/update-info", (c) => {132+		const name = c.req.param("repo");133+		const f = feed(name);134+		if (!f) return send(c, 404, REPO_NOT_FOUND);135+		// Head is the newest save on the line; checkpoints are never shown (§21.4). Its version is the136+		// release built from it, else its ID, as in Kepr 2.137+		const save = history(f.repo.mirror, 1, 0)[0];138+		const saveHead = save && {139+			id: save.id,140+			version: f.releases.find((r) => r.save === save.id)?.version ?? save.id,141+			createdAt: save.created_at,142+		};143+		return send(c, 200, updateInfoJson(ctx.domain, name, saveHead ?? null, f.releases[0]));144+	});145+	app.get("/:repo/meta", (c) => {146+		const f = feed(c.req.param("repo"));147+		if (!f) return send(c, 404, REPO_NOT_FOUND);148+		const { name, category } = f.repo.settings;149+		const stats = repoStats(f.repo.mirror);150+		const meta = {151+			repo: name,152+			saveCount: stats.saves,153+			lineCount: stats.lines,154+			lastSaveAt: stats.updated ?? 0,155+			latestRelease: f.releases[0]?.version ?? null,156+			description: readmeIntro(name, readmeAtHead(f.repo))?.text ?? null,157+			language: stats.language || null,158+			category: category || null,159+		};160+		return send(c, 200, metaJson(meta));161+	});162+}

view file

src/server/app.ts+3 −0
@@ -3,12 +3,14 @@33 import { Hono } from "hono";44 import { bodyLimit } from "hono/body-limit";55 import { secureHeaders } from "hono/secure-headers";6+import { openBucket } from "../bucket/reader.ts";67 import { isSafePath } from "../bucket/repo.ts";78 import { registerArchivesPage } from "../pages/archives.ts";89 import { registerFileRoutes } from "../pages/files.ts";910 import { registerReleasesPage } from "../pages/releases.ts";1011 import { registerRepoPage } from "../pages/repo.ts";1112 import { registerSavePage } from "../pages/save.ts";13+import { registerReleaseRoutes } from "../releases/routes.ts";1214 import { registerTarballRoutes } from "../tarball/archive.ts";1315 import type { Config } from "./config.ts";1416 @@ -56,5 +58,6 @@5658 	registerArchivesPage(app, ctx);5759 	registerReleasesPage(app, ctx);5860 	registerTarballRoutes(app, ctx, join(config.dataDir, "tarballs"));61+	registerReleaseRoutes(app, ctx, openBucket(config.bucket));5962 	return app;6063 }

view file

src/server/db.ts+4 −2
@@ -44,12 +44,14 @@4444 4545 export type RepoSettings = {4646 	name: string;47+	category: string;4748 	homepage: string;4849 	installLines: string[];4950 };5051 5152 type RepoRow = {5253 	name: string;54+	category: string;5355 	homepage: string;5456 	install1: string;5557 	install2: string;@@ -89,10 +91,10 @@8991 export function publicRepo(db: Database, name: string): RepoSettings | null {9092 	const row = db9193 		.query<RepoRow, [string]>(92-			"SELECT name, homepage, install1, install2, install3 FROM repos WHERE name = ? AND public = 1",94+			"SELECT name, category, homepage, install1, install2, install3 FROM repos WHERE name = ? AND public = 1",9395 		)9496 		.get(name);9597 	if (!row) return null;9698 	const installLines = [row.install1, row.install2, row.install3].filter((line) => line !== "");97-	return { name: row.name, homepage: row.homepage, installLines };99+	return { name: row.name, category: row.category, homepage: row.homepage, installLines };98100 }

view file

src/tarball/archive.ts+3 −0
@@ -1,6 +1,7 @@11 import type { Hono } from "hono";22 import { headId, saveByPrefix } from "../mirror/queries.ts";33 import { notFound, openPublicRepo, type PageContext } from "../pages/layout.ts";4+import { countInstall } from "../releases/releases.ts";45 import { cachedTarball } from "./cache.ts";56 67 // Long-lived but never `immutable` (§18): a strip can change what a save's tarball holds.@@ -29,6 +30,8 @@2930 			return c.redirect(`/${name}/archive/${save.id}.tar.gz`, 302);3031 		}3132 		const bytes = cachedTarball(tarballDir, repo.mirror, name, save.id);33+		// §22.2: a Nix fetch is an install. Only the tarball itself counts, not the redirects to it.34+		if (c.req.header("User-Agent")?.includes("nix/")) countInstall(ctx.kepr, name);3235 		c.header("Content-Type", "application/gzip");3336 		c.header("Content-Disposition", `attachment; filename="${name}-${save.id.slice(0, 8)}.tar.gz"`);3437 		c.header("Cache-Control", TARBALL_CACHE);

view file

tests/bucket.test.ts+16 −3
@@ -89,6 +89,15 @@8989 		expect(await s3.list(`${fx.repo}/`)).toEqual(await bucket().list(`${fx.repo}/`));9090 		expect(await readHead(s3, fx.repo)).toBe(fx.saveIds[0] as string);9191 		expect(await s3.get(`${fx.repo}/format`)).toBeNull();92+		const url = new URL(93+			s3.downloadUrl("demo/releases/1.0/android-arm64/a b.apk", "a b.apk", "t/x"),94+		);95+		expect(url.pathname).toBe("/kohbucket/demo/releases/1.0/android-arm64/a%20b.apk");96+		expect(url.searchParams.get("X-Amz-Expires")).toBe("300");97+		expect(url.searchParams.get("response-content-disposition")).toBe(98+			'attachment; filename="a b.apk"',99+		);100+		expect(url.searchParams.get("response-content-type")).toBe("t/x");92101 	} finally {93102 		server.stop();94103 	}@@ -128,7 +137,11 @@128137 		const record = JSON.parse(new TextDecoder().decode(real as Uint8Array));129138 		change(record);130139 		const bytes = new TextEncoder().encode(JSON.stringify(record));131-		return readSave({ get: async () => bytes, list: async () => [] }, fx.repo, id);140+		return readSave(141+			{ get: async () => bytes, list: async () => [], downloadUrl: () => "" },142+			fx.repo,143+			id,144+		);132145 	}133146 	const withPath = (path: string) => (record: Record<string, unknown>) => {134147 		(record.files as { path: string }[])[0] = { ...(record.files as { path: string }[])[0], path };@@ -199,7 +212,7 @@199212 		]);200213 		const stripAll = { kind: "strip", save_id: "", path: "secret.txt" };201214 		const accepted = new TextEncoder().encode(JSON.stringify({ ...event, ...stripAll }));202-		const reader = { get: async () => accepted, list: async () => [] };215+		const reader = { get: async () => accepted, list: async () => [], downloadUrl: () => "" };203216 		expect((await readEvent(reader, fx.repo, event.id)).kind).toBe("strip");204217 		for (const change of [205218 			{ kind: "delete" },@@ -211,7 +224,7 @@211224 			{ kind: "strip", save_id: "", path: "" },212225 		]) {213226 			const bytes = new TextEncoder().encode(JSON.stringify({ ...event, ...change }));214-			const tampered = { get: async () => bytes, list: async () => [] };227+			const tampered = { get: async () => bytes, list: async () => [], downloadUrl: () => "" };215228 			await expect(readEvent(tampered, fx.repo, event.id)).rejects.toThrow("Koh 3 schema");216229 		}217230 	});

view file

tests/releases.test.ts+180 −1
@@ -3,16 +3,19 @@33 import { mkdtemp, writeFile } from "node:fs/promises";44 import { tmpdir } from "node:os";55 import { join } from "node:path";6+import { blake3 } from "@noble/hashes/blake3.js";7+import { bytesToHex } from "@noble/hashes/utils.js";68 import type { Hono } from "hono";79 import { openBucket } from "../src/bucket/reader.ts";810 import { type SyncTarget, syncAll } from "../src/bucket/sync.ts";911 import { openMirror } from "../src/mirror/store.ts";10-import { compareVersions, visibleReleases } from "../src/releases/releases.ts";12+import { compareVersions, releaseContentType, visibleReleases } from "../src/releases/releases.ts";1113 import { createApp } from "../src/server/app.ts";1214 import { openKeprDb } from "../src/server/db.ts";1315 import { makeRepos, writeRelease } from "./fixture.ts";1416 1517 const bytes = (text: string) => new TextEncoder().encode(text);18+const blake3Hex = (text: string) => bytesToHex(blake3(bytes(text)));1619 1720 describe("release reading in sync", () => {1821 	let bucketDir: string;@@ -189,6 +192,182 @@189192 	test("a private repo's builds are a 404", async () => {190193 		expect((await get("/secret/releases")).status).toBe(404);191194 	});195+});196+197+describe("the §22.1 release routes", () => {198+	let app: Hono;199+	let kepr: Database;200+	let bucketDir: string;201+	const get = (path: string, headers?: Record<string, string>) => app.request(path, { headers });202+	const downloads = () =>203+		kepr.query("SELECT version, platform, count FROM downloads ORDER BY version, platform").all();204+205+	beforeAll(async () => {206+		const repos = await makeRepos(["cli", "hush"]);207+		bucketDir = repos.bucketDir;208+		const save = repos.saves.cli as string;209+		await writeRelease(bucketDir, "cli", {210+			version: "1.0.0",211+			save,212+			notes: 'First </script> & "quoted" / slashed\nline — ok',213+			files: [214+				{ platform: "linux-x86_64", name: "cli", bytes: bytes("linux 1") },215+				{ platform: "android-arm64", name: "cli-1.0.0.apk", bytes: bytes("apk 1") },216+			],217+		});218+		await writeRelease(bucketDir, "cli", {219+			version: "1.1.0",220+			save,221+			files: [222+				{ platform: "macos-aarch64", name: "Cli.app.zip", bytes: bytes("mac") },223+				{ platform: "linux-x86_64", name: "cli", bytes: bytes("linux 2") },224+			],225+		});226+		await writeRelease(bucketDir, "hush", {227+			version: "1.0.0",228+			save: repos.saves.hush as string,229+			files: [{ platform: "linux-x86_64", name: "h", bytes: bytes("h") }],230+		});231+		app = await releaseApp(bucketDir, (db) => {232+			db.run("UPDATE repos SET public = 1 WHERE name = 'cli'");233+		});234+		kepr = releaseKepr.get(app) as Database;235+	});236+237+	test("latest.json is the release JSON shape, notes omitted when empty, urls on the configured domain", async () => {238+		const res = await get("/cli/releases/latest.json");239+		expect(res.status).toBe(200);240+		expect(res.headers.get("Content-Type")).toBe("application/json; charset=UTF-8");241+		const sha = new Bun.CryptoHasher("sha256").update("mac").digest("hex");242+		const body = await res.text();243+		expect(244+			body.startsWith(245+				'{"version":"1.1.0","platforms":{"linux-x86_64":{"url":"https://kepr.example/cli/releases/1.1.0/linux-x86_64","blake3":"',246+			),247+		).toBe(true);248+		expect(body).toContain(249+			`"macos-aarch64":{"url":"https://kepr.example/cli/releases/1.1.0/macos-aarch64",`,250+		);251+		expect(body).toContain(252+			`"sha256":"${sha}","provenance":{"version":"1.1.0","platform":"macos-aarch64","checksum":"blake3:`,253+		);254+		expect(body.endsWith('"source":"koh-offer"},"signature":""}}}')).toBe(true);255+	});256+257+	test("history.json lists every version with notes escaped as Kepr 2 escaped them", async () => {258+		const body = await (await get("/cli/releases/history.json")).text();259+		expect(body).toBe(260+			'[{"version":"1.1.0","notes":""},{"version":"1.0.0","notes":"First \\u003c\\/script\\u003e & \\"quoted\\" \\/ slashed\\nline — ok"}]',261+		);262+		expect(JSON.parse(body)[1].notes).toBe('First </script> & "quoted" / slashed\nline — ok');263+	});264+265+	test("version.txt and checksums.txt", async () => {266+		const version = await get("/cli/releases/latest/version.txt");267+		expect([version.headers.get("Content-Type"), await version.text()]).toEqual([268+			"text/plain; charset=UTF-8",269+			"1.1.0",270+		]);271+		const sums = await (await get("/cli/releases/1.0.0/checksums.txt")).text();272+		const sha = (text: string) => new Bun.CryptoHasher("sha256").update(text).digest("hex");273+		expect(sums).toBe(274+			`# cli 1.0.0\n# built by kepr\n\nsha256:${sha("apk 1")}  cli-android-arm64\nsha256:${sha("linux 1")}  cli-linux-x86_64\nblake3:${blake3Hex("apk 1")}  cli-android-arm64\nblake3:${blake3Hex("linux 1")}  cli-linux-x86_64\n`,275+		);276+	});277+278+	test("downloads count, then redirect to the stored file", async () => {279+		const before = downloads().length;280+		expect(before).toBe(0);281+		const latest = await get("/cli/releases/latest/linux-x86_64");282+		expect(latest.status).toBe(302);283+		expect(latest.headers.get("Cache-Control")).toBe("no-store");284+		expect(latest.headers.get("Location")).toBe(285+			`file://${join(bucketDir, "cli/releases/1.1.0/linux-x86_64/cli")}`,286+		);287+		// The newest release that has the platform: 1.1.0 has no APK.288+		expect((await get("/cli/releases/latest/android-arm64")).headers.get("Location")).toEndWith(289+			"/cli/releases/1.0.0/android-arm64/cli-1.0.0.apk",290+		);291+		for (const path of [292+			"/cli/releases/1.0.0/android-arm64",293+			"/cli/releases/1.0.0/cli-android-arm64",294+		]) {295+			expect((await get(path)).status).toBe(302);296+		}297+		expect(downloads()).toEqual([298+			{ version: "1.0.0", platform: "android-arm64", count: 3 },299+			{ version: "1.1.0", platform: "linux-x86_64", count: 1 },300+		]);301+	});302+303+	test("a missing release is a 404 that counts nothing, and a private repo looks unknown", async () => {304+		const before = downloads();305+		for (const path of [306+			"/cli/releases/1.0.0/macos-aarch64",307+			"/cli/releases/9.9.9/linux-x86_64",308+			"/cli/releases/1.0.0/plan9",309+			"/cli/releases/latest/windows-x86_64",310+			"/cli/releases/9.9.9/checksums.txt",311+		]) {312+			const res = await get(path);313+			expect([path, res.status, await res.text()]).toEqual([314+				path,315+				404,316+				'{"error":"not_found","message":"release not found"}',317+			]);318+		}319+		for (const repo of ["hush", "nowhere"]) {320+			for (const suffix of ["/releases/latest.json", "/releases/1.0.0/linux-x86_64", "/meta"]) {321+				const res = await get(`/${repo}${suffix}`);322+				expect([res.status, await res.text()]).toEqual([323+					404,324+					'{"error":"repo_not_found","message":"repo not found"}',325+				]);326+			}327+		}328+		expect(downloads()).toEqual(before);329+	});330+331+	test("a hidden version leaves latest, the feeds and its downloads", async () => {332+		kepr.run("INSERT INTO release_settings (repo, version, hidden) VALUES ('cli', '1.1.0', 1)");333+		expect(await (await get("/cli/releases/latest/version.txt")).text()).toBe("1.0.0");334+		expect(await (await get("/cli/releases/history.json")).text()).not.toContain("1.1.0");335+		expect((await get("/cli/releases/1.1.0/linux-x86_64")).status).toBe(404);336+		expect((await get("/cli/releases/latest/linux-x86_64")).headers.get("Location")).toEndWith(337+			"/1.0.0/linux-x86_64/cli",338+		);339+		kepr.run("DELETE FROM release_settings");340+	});341+342+	test("releases/latest sends old links to the builds panel", async () => {343+		const res = await get("/cli/releases/latest");344+		expect([res.status, res.headers.get("Location")]).toEqual([302, "/cli/releases"]);345+		expect((await get("/hush/releases/latest")).status).toBe(404);346+	});347+348+	test("a Nix fetch of a tarball counts as an install; redirects and other clients don't", async () => {349+		const installs = () =>350+			(351+				kepr.query("SELECT count FROM installs WHERE repo = 'cli'").get() as {352+					count: number;353+				} | null354+			)?.count ?? 0;355+		const nix = { "User-Agent": "curl/8.7.1 Nix/2.24.0 nix/2.24.0" };356+		const head = await get("/cli/archive/HEAD.tar.gz", nix);357+		expect(installs()).toBe(0);358+		const tarball = head.headers.get("Location") as string;359+		expect((await get(tarball, nix)).status).toBe(200);360+		expect((await get(tarball, { "User-Agent": "curl/8.7.1" })).status).toBe(200);361+		expect(installs()).toBe(1);362+	});363+});364+365+test("release files are served with §22.2's content types", () => {366+	expect(releaseContentType("peach-2.9.28-android-arm64.apk")).toBe(367+		"application/vnd.android.package-archive",368+	);369+	expect(releaseContentType("Haven.app.ZIP")).toBe("application/zip");370+	expect(releaseContentType("koh")).toBe("application/octet-stream");192371 });193372 194373 const releaseKepr = new WeakMap<Hono, Database>();

view file