From 82a4bd83415bad9c03b3567bdd0e2b44340d5c00 Mon Sep 17 00:00:00 2001 From: Julien Herr Date: Mon, 25 May 2026 15:50:45 +0200 Subject: [PATCH] feat(api): expose app version in /api/v1/stats Add version to StatsResponse and getStats so the canonical public monitoring endpoint reports the running build alongside the footer and /health. Co-Authored-By: Claude Opus 4.7 --- TODO.md | 2 +- src/application/stats.ts | 2 ++ src/routes/api/api.test.ts | 2 ++ src/routes/api/schemas.ts | 3 +++ src/types/index.ts | 1 + 5 files changed, 9 insertions(+), 1 deletion(-) diff --git a/TODO.md b/TODO.md index 64b829e..b847f11 100644 --- a/TODO.md +++ b/TODO.md @@ -192,7 +192,7 @@ Breakdown of the _"Per-feed favicon from the last sender's domain"_ item above ( Self-host operational quality-of-life: knowing which version you run, when to update, and how many people run KTN. -- [x] `P3·S` **Display the running version** **[table-stakes, easy]** — surface the deployed app version (from `package.json` `version`, currently `0.2.1`) somewhere visible: the admin UI footer and/or the public status page (`src/routes/home.tsx`), and ideally the `/health` JSON. Bundle the version at build time (inline the `package.json` version into the Worker, since there's no filesystem at runtime) and render it. Foundation for the update-notification item below. — **Shipped:** `package.json` version is inlined at bundle time via `src/config/version.ts` (`import pkg from "../../package.json"`, `resolveJsonModule`), exposed as `APP_VERSION`; rendered in the shared admin/status footer (`src/routes/admin/ui.tsx` Layout, so both the status page and admin show it) and added to the `/health` JSON. — _origin: internal_ +- [x] `P3·S` **Display the running version** **[table-stakes, easy]** — surface the deployed app version (from `package.json` `version`, currently `0.2.1`) somewhere visible: the admin UI footer and/or the public status page (`src/routes/home.tsx`), and ideally the `/health` JSON. Bundle the version at build time (inline the `package.json` version into the Worker, since there's no filesystem at runtime) and render it. Foundation for the update-notification item below. — **Shipped:** `package.json` version is inlined at bundle time via `src/config/version.ts` (`import pkg from "../../package.json"`, `resolveJsonModule`), exposed as `APP_VERSION`; rendered in the shared admin/status footer (`src/routes/admin/ui.tsx` Layout, so both the status page and admin show it), added to the `/health` JSON, and to the canonical monitoring endpoint `/api/v1/stats` (`StatsResponse.version`, public). — _origin: internal_ - [ ] `P3·M` **Notify when an update is available** **[differentiating for self-hosters]** — compare the running version against the latest GitHub Release tag and show a discreet "update available → vX.Y.Z" banner in the admin UI when behind. Fetch `https://api.github.com/repos///releases/latest` (cache aggressively — Cache API / KV with a long TTL — to respect GitHub rate limits and avoid a call per page load), compare semver against the bundled version. Depends on the "display version" item. Keep it opt-out-able (it makes one outbound call). — _origin: internal_ diff --git a/src/application/stats.ts b/src/application/stats.ts index 0e4818e..f95889b 100644 --- a/src/application/stats.ts +++ b/src/application/stats.ts @@ -1,4 +1,5 @@ import { Counters, Env, StatsResponse } from "../types"; +import { APP_VERSION } from "../config/version"; import { logger } from "../infrastructure/logger"; import { FeedRepository } from "../infrastructure/feed-repository"; import { CountersRepository } from "../infrastructure/counters-repository"; @@ -77,6 +78,7 @@ export async function getStats(env: Env): Promise { active_feeds: feeds.length, websub_subscriptions_active: websubCount, attachments_enabled: !!getAttachmentBucket(env), + version: APP_VERSION, }; } diff --git a/src/routes/api/api.test.ts b/src/routes/api/api.test.ts index bf97c99..ee3fa99 100644 --- a/src/routes/api/api.test.ts +++ b/src/routes/api/api.test.ts @@ -295,10 +295,12 @@ describe("REST API (/api/v1)", () => { feeds_created: number; active_feeds: number; attachments_enabled: boolean; + version: string; }; expect(stats.feeds_created).toBeGreaterThanOrEqual(1); expect(stats.active_feeds).toBeGreaterThanOrEqual(1); expect(typeof stats.attachments_enabled).toBe("boolean"); + expect(stats.version).toMatch(/^\d+\.\d+\.\d+/); }); }); diff --git a/src/routes/api/schemas.ts b/src/routes/api/schemas.ts index 3e7f329..b2afb3a 100644 --- a/src/routes/api/schemas.ts +++ b/src/routes/api/schemas.ts @@ -154,6 +154,9 @@ export const StatsSchema = z active_feeds: z.number(), websub_subscriptions_active: z.number(), attachments_enabled: z.boolean(), + version: z.string().openapi({ + description: "Running app version (package.json), inlined at build time.", + }), last_email_at: z.string().optional(), last_feed_created_at: z.string().optional(), first_seen: z.string().optional(), diff --git a/src/types/index.ts b/src/types/index.ts index f2e8a8d..2c69cb5 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -125,6 +125,7 @@ export interface StatsResponse extends Counters { active_feeds: number; websub_subscriptions_active: number; attachments_enabled: boolean; + version: string; // Running app version (package.json), inlined at build time } // WebSub (PubSubHubbub) subscription configuration