diff --git a/src/routes/api/the-nook/_releases.ts b/src/routes/api/the-nook/_releases.ts
new file mode 100644
index 0000000..063521f
--- /dev/null
+++ b/src/routes/api/the-nook/_releases.ts
@@ -0,0 +1,151 @@
+/**
+ * The release list for The Nook's in-app changelog (Settings > Changelog).
+ *
+ * The /changelog page already answers this question, but only as HTML, and it
+ * assembles the list inside the route component. A native client must not
+ * couple to that markup, and the appcast alone is not the full story: releases
+ * older than the appcast's retention live only in the checked-in archive. So
+ * this module mirrors the page's merge — live appcast items, then archived
+ * releases the appcast has aged out — and normalizes it into data a client can
+ * render without parsing HTML.
+ *
+ * Underscore-prefixed file — not a route.
+ */
+import { ARCHIVED_RELEASES } from "~/routes/nook/changelog-archive";
+import { fetchAppcastReleases, type AppcastRelease } from "./_appcast";
+
+export interface ReleaseSection {
+ /** "Added" / "Changed" / "Fixed"; "Notes" when the notes carry no label. */
+ title: string;
+ bullets: string[];
+}
+
+export interface AppRelease {
+ version: string;
+ /** Build number, null for archived releases (they carry none). */
+ build: string | null;
+ /** ISO calendar date, "2026-10-02"; null when no date was published. */
+ date: string | null;
+ sections: ReleaseSection[];
+}
+
+const ENTITY_REPLACEMENTS: [RegExp, string][] = [
+ [/</g, "<"],
+ [/>/g, ">"],
+ [/"/g, '"'],
+ [/'/g, "'"],
+ [/ /g, " "],
+ // Last, so "<" cannot decode twice.
+ [/&/g, "&"]
+];
+
+function decodeEntities(text: string): string {
+ return ENTITY_REPLACEMENTS.reduce((acc, [re, to]) => acc.replace(re, to), text);
+}
+
+function stripTags(text: string): string {
+ return text.replace(/<[^>]*>/g, "");
+}
+
+/** Collapses the whitespace an HTML source wraps bullets with. */
+function oneLine(text: string): string {
+ return text.replace(/\s+/g, " ").trim();
+}
+
+/**
+ * Reads the notes shape `embed_release_notes.py` writes —
+ * `
Added
` — into titled bullet groups.
+ *
+ * A `` with no label before it (what `generate_appcast` writes on its own)
+ * becomes "Notes". A `` is taken as the next list's label rather than as
+ * prose, which is safe for notes this pipeline generates and degrades to a
+ * stray heading if that ever changes.
+ */
+export function sectionsFromHtml(html: string): ReleaseSection[] {
+ const sections: ReleaseSection[] = [];
+ let label = "";
+
+ for (const block of html.matchAll(/
([\s\S]*?)<\/p>|
]*>([\s\S]*?)<\/ul>/gi)) {
+ if (block[1] !== undefined) {
+ label = oneLine(stripTags(decodeEntities(block[1])));
+ continue;
+ }
+ const bullets = [...block[2]!.matchAll(/- ]*>([\s\S]*?)<\/li>/gi)]
+ .map((b) => oneLine(stripTags(decodeEntities(b[1] ?? ""))))
+ .filter((b) => b.length > 0);
+ if (bullets.length === 0) continue;
+ sections.push({ title: label || "Notes", bullets });
+ label = "";
+ }
+
+ return sections;
+}
+
+/**
+ * Normalizes a published date to an ISO calendar date. The appcast carries an
+ * RFC-1123 `pubDate`, the archive a plain `2026-09-06`.
+ *
+ * The day is taken at the timestamp's own offset, not in UTC: a release
+ * published 23:29 -0400 is dated the 2nd, and reading it as UTC would move it
+ * to the 3rd.
+ */
+export function isoDateOrNull(raw: string | null): string | null {
+ const value = raw?.trim();
+ if (!value) return null;
+
+ const iso = /^(\d{4})-(\d{2})-(\d{2})/.exec(value);
+ if (iso) return `${iso[1]}-${iso[2]}-${iso[3]}`;
+
+ const parsed = new Date(value);
+ if (Number.isNaN(parsed.getTime())) return null;
+
+ const offset = /([+-])(\d{2}):?(\d{2})$/.exec(value);
+ const offsetMinutes = offset
+ ? (offset[1] === "-" ? -1 : 1) * (Number(offset[2]) * 60 + Number(offset[3]))
+ : 0;
+ return new Date(parsed.getTime() + offsetMinutes * 60_000).toISOString().slice(0, 10);
+}
+
+function toAppRelease(release: AppcastRelease | ArchivedReleaseShape): AppRelease {
+ return {
+ version: release.version,
+ build: "build" in release ? release.build : null,
+ date: isoDateOrNull(release.date),
+ sections: sectionsFromHtml(release.notesHtml)
+ };
+}
+
+/** The archive carries no build number; this is that shape without it. */
+type ArchivedReleaseShape = {
+ version: string;
+ date: string;
+ notesHtml: string;
+};
+
+/**
+ * Live releases first, newest first, then any archived release the appcast no
+ * longer lists. Order is concatenation rather than a sort because both inputs
+ * are newest-first and the archive only ever holds older releases — matching
+ * how /changelog builds its list.
+ */
+export function mergeReleases(
+ live: readonly AppcastRelease[],
+ archived: readonly ArchivedReleaseShape[]
+): AppRelease[] {
+ const releases = live.map(toAppRelease);
+ const seen = new Set(releases.map((r) => r.version));
+ for (const release of archived) {
+ if (seen.has(release.version)) continue;
+ releases.push(toAppRelease(release));
+ }
+ return releases;
+}
+
+/**
+ * Everything the in-app changelog shows. Throws when the appcast cannot be
+ * read — the caller must not serve an archived-only list as if it were
+ * current, since the app then still has the newest release in its own bundle.
+ */
+export async function collectReleases(): Promise {
+ return mergeReleases(await fetchAppcastReleases(), ARCHIVED_RELEASES);
+}
diff --git a/src/routes/api/the-nook/releases.json.ts b/src/routes/api/the-nook/releases.json.ts
new file mode 100644
index 0000000..c7e920f
--- /dev/null
+++ b/src/routes/api/the-nook/releases.json.ts
@@ -0,0 +1,35 @@
+import type { APIEvent } from "@solidjs/start/server";
+import { collectReleases } from "./_releases";
+import { error } from "./_lib";
+
+/**
+ * GET /api/the-nook/releases.json
+ *
+ * The release list behind The Nook's Settings > Changelog tab, newest first:
+ * `{ releases: [{ version, build, date, sections: [{ title, bullets }] }] }`.
+ *
+ * Same source as /changelog — the Sparkle appcast plus the checked-in archive
+ * of releases older than its retention — so the app and the website tell one
+ * story. Section titles and ISO dates instead of HTML keep the client from
+ * parsing markup it would only take apart again.
+ *
+ * Five-minute cache, in step with the appcast route. An unreadable appcast is
+ * a 503 rather than an archived-only 200: the app keeps the newest release in
+ * its own bundle and falls back to it, which beats showing stale notes as if
+ * they were current.
+ */
+export async function GET(_event: APIEvent) {
+ try {
+ const releases = await collectReleases();
+ return new Response(JSON.stringify({ releases }), {
+ status: 200,
+ headers: {
+ "Content-Type": "application/json; charset=utf-8",
+ "Cache-Control": "public, max-age=300"
+ }
+ });
+ } catch (err) {
+ console.error("Failed to build the Nook release list:", err);
+ return error("Release list unavailable", 503);
+ }
+}
diff --git a/src/routes/api/the-nook/releases.test.ts b/src/routes/api/the-nook/releases.test.ts
new file mode 100644
index 0000000..8700454
--- /dev/null
+++ b/src/routes/api/the-nook/releases.test.ts
@@ -0,0 +1,198 @@
+import { describe, expect, it, mock, beforeEach } from "bun:test";
+
+// The contract The Nook's Settings > Changelog tab is built on:
+// 1. One list, newest first, from the appcast plus the archive of releases
+// the appcast has aged out — the same merge /changelog renders.
+// 2. Structured notes and ISO dates, so the client renders instead of
+// parsing HTML it would only take apart again.
+// 3. An unreadable appcast is a 503, never an archived-only 200 that reads
+// as current.
+
+type Release = {
+ version: string;
+ build: string | null;
+ date: string | null;
+ sections: { title: string; bullets: string[] }[];
+};
+
+let appcastXml = "";
+let s3ShouldThrow = false;
+
+mock.module("~/env/server", () => ({
+ env: {
+ AWS_REGION: "us-east-1",
+ MY_AWS_ACCESS_KEY: "test-key",
+ MY_AWS_SECRET_KEY: "test-secret",
+ VITE_DOWNLOAD_BUCKET_STRING: "test-bucket"
+ }
+}));
+
+mock.module("@aws-sdk/client-s3", () => ({
+ S3Client: class {
+ async send() {
+ if (s3ShouldThrow) throw new Error("s3 unavailable");
+ return {
+ Body: appcastXml
+ ? { transformToString: async () => appcastXml }
+ : null
+ };
+ }
+ },
+ GetObjectCommand: class {
+ input: unknown;
+ constructor(input: unknown) {
+ this.input = input;
+ }
+ }
+}));
+
+const { GET } = await import("./releases.json");
+const { invalidateAppcastCache } = await import("./_appcast");
+const { isoDateOrNull, sectionsFromHtml } = await import("./_releases");
+const { ARCHIVED_RELEASES } = await import("~/routes/nook/changelog-archive");
+
+const item = (version: string, pubDate: string, notes: string) => `
+
-
+ Version ${version}
+ ${pubDate}
+ ${version === "0.6.3" ? "10" : "9"}
+ ${version}
+
+
+
`;
+
+const NOTES =
+ "Added
- Changelog tab in Settings.
" +
+ "Fixed
- Call actions press only their own button; "End Call" no longer matches "Send".
";
+
+const appcast = (items: string) =>
+ `${items}`;
+
+const request = () =>
+ ({
+ request: new Request("https://freno.me/api/the-nook/releases.json"),
+ nativeEvent: {}
+ }) as never;
+
+async function body(): Promise<{ releases: Release[] }> {
+ const res = await GET(request());
+ return (await res.json()) as { releases: Release[] };
+}
+
+beforeEach(() => {
+ appcastXml = appcast(
+ item("0.6.3", "Fri, 02 Oct 2026 23:29:42 -0400", NOTES) +
+ item("0.6.2", "Thu, 24 Sep 2026 21:16:04 -0400", NOTES)
+ );
+ s3ShouldThrow = false;
+ invalidateAppcastCache();
+});
+
+describe("GET /api/the-nook/releases.json", () => {
+ it("serves the appcast releases newest first, cacheable for five minutes", async () => {
+ const res = await GET(request());
+
+ expect(res.status).toBe(200);
+ expect(res.headers.get("Content-Type")).toBe("application/json; charset=utf-8");
+ expect(res.headers.get("Cache-Control")).toBe("public, max-age=300");
+
+ const { releases } = await body();
+ expect(releases.map((r) => r.version)).toEqual(["0.6.3", "0.6.2", ...archivedVersions]);
+ expect(releases[0].build).toBe("10");
+ });
+
+ it("splits the published notes into titled bullet groups", async () => {
+ const [first] = (await body()).releases;
+
+ expect(first!.sections).toEqual([
+ { title: "Added", bullets: ["Changelog tab in Settings."] },
+ {
+ title: "Fixed",
+ bullets: [
+ 'Call actions press only their own button; "End Call" no longer matches "Send".'
+ ]
+ }
+ ]);
+ });
+
+ it("dates a release on its own day, not the UTC one", async () => {
+ const releases = (await body()).releases;
+
+ // 23:29 -0400 is 03:29Z the next day; the release is still dated the 2nd.
+ expect(releases[0]!.date).toBe("2026-10-02");
+ expect(releases[1]!.date).toBe("2026-09-24");
+ });
+
+ it("appends the archived releases the appcast has aged out", async () => {
+ const releases = (await body()).releases;
+ const archived = releases.filter((r) => !["0.6.3", "0.6.2"].includes(r.version));
+
+ expect(archived.map((r) => r.version)).toEqual(archivedVersions);
+ expect(archived[0]!.build).toBeNull();
+ // Archive dates are already calendar dates and pass through untouched.
+ expect(archived[0]!.date).toBe(ARCHIVED_RELEASES[0]!.date);
+ expect(archived[0]!.sections.length).toBeGreaterThan(0);
+ });
+
+ it("lists a version once when the appcast and the archive both carry it", async () => {
+ appcastXml = appcast(item("0.5.0", "Mon, 07 Sep 2026 16:11:44 -0400", NOTES));
+ invalidateAppcastCache();
+
+ const releases = (await body()).releases;
+ const matches = releases.filter((r) => r.version === "0.5.0");
+
+ expect(matches.length).toBe(1);
+ // The live item wins: it carries the build number the archive lacks.
+ expect(matches[0]!.build).toBe("9");
+ expect(matches[0]!.date).toBe("2026-09-07");
+ });
+
+ it("503s when the appcast cannot be read, rather than serving archive-only notes", async () => {
+ s3ShouldThrow = true;
+
+ const res = await GET(request());
+ expect(res.status).toBe(503);
+ expect(((await res.json()) as { error: string }).error).toBe(
+ "Release list unavailable"
+ );
+ });
+});
+
+describe("sectionsFromHtml", () => {
+ it("labels an unlabelled list as Notes", () => {
+ expect(sectionsFromHtml("")).toEqual([
+ { title: "Notes", bullets: ["Fixes"] }
+ ]);
+ });
+
+ it("joins a bullet the HTML wrapped across lines, and drops empty ones", () => {
+ expect(
+ sectionsFromHtml(
+ "Fixed
\n- a bullet\n split over\n lines
-
"
+ )
+ ).toEqual([{ title: "Fixed", bullets: ["a bullet split over lines"] }]);
+ });
+
+ it("returns no sections for empty or note-less HTML", () => {
+ expect(sectionsFromHtml("")).toEqual([]);
+ expect(sectionsFromHtml("Added
")).toEqual([]);
+ });
+});
+
+describe("isoDateOrNull", () => {
+ it("reads an RFC-1123 pubDate at its own offset", () => {
+ expect(isoDateOrNull("Sat, 03 Oct 2026 00:00:00 +0000")).toBe("2026-10-03");
+ expect(isoDateOrNull("Fri, 02 Oct 2026 23:29:42 -0400")).toBe("2026-10-02");
+ expect(isoDateOrNull("Tue, 06 Oct 2026 09:00:00 +0200")).toBe("2026-10-06");
+ });
+
+ it("passes a calendar date through and rejects junk", () => {
+ expect(isoDateOrNull("2026-09-06")).toBe("2026-09-06");
+ expect(isoDateOrNull("sometime in September")).toBeNull();
+ expect(isoDateOrNull("")).toBeNull();
+ expect(isoDateOrNull(null)).toBeNull();
+ });
+});
+
+/** The archive in full, in file order (newest first) — the tail of the list. */
+const archivedVersions = ARCHIVED_RELEASES.map((r) => r.version);