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
↓ kepr-0f7e06f6.tar.gzatlas at this save
files changed10 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 −1package.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": {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 }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;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+}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+}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 }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 }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);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 });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>();