diff --git a/CLAUDE.md b/CLAUDE.md index 1c9b0cc..b8afe68 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,8 +34,9 @@ Single Cloudflare Worker built with Hono. Routes: | ------------------------------------ | ---------------------------------------------------------------------- | ------- | | `GET /` | Public status page (monitoring counters + link to admin) | | `POST /api/inbound` | Webhook from ForwardEmail; IP-allowlisted to their MX sources | -| `GET /api/stats` | Public monitoring counters (JSON) | -| `/api/v1/*` | Versioned REST API (Bearer/proxy auth) — feeds + emails CRUD, stats | +| `GET /api/stats` | Deprecated alias of `GET /api/v1/stats` (public monitoring counters) | +| `/api/v1/feeds*` | Versioned REST API (Bearer/proxy auth) — feeds + emails CRUD | +| `GET /api/v1/stats` | Public monitoring counters (JSON, CORS); canonical stats endpoint | | `GET /api/openapi.json` | OpenAPI 3.1 spec (public) | | `GET /api/docs` | Rendered API reference (Scalar, public) | | `GET /rss/:feedId` | Public RSS 2.0 feed | diff --git a/README.md b/README.md index 70c0702..61f57f9 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,9 @@ Main routes: ### Monitoring -`GET /api/stats` returns JSON counters (public, no auth) for uptime/monitoring tools: +`GET /api/v1/stats` returns JSON counters (public, no auth, CORS-enabled) for +uptime/monitoring tools and the landing page. `GET /api/stats` is a deprecated alias kept +for backward compatibility — prefer the versioned path. Both expose the same fields: | Field | Meaning | | ----------------------------- | -------------------------------------------------------- | @@ -84,23 +86,25 @@ A versioned REST API lets you automate feed and email management without scrapin admin UI. The OpenAPI 3.1 spec is served at `GET /api/openapi.json` and a rendered reference (Scalar) at `GET /api/docs` — both public. -All `/api/v1/*` endpoints require authentication, using either: +The feed and email endpoints require authentication, using either: - **Bearer token**: `Authorization: Bearer `, or - **Reverse-proxy auth**: the same trusted-IP + `X-Auth-Proxy-Secret` + `Remote-User` headers as the admin UI (see [INSTALL.md](INSTALL.md)). -| Method | Path | Purpose | -| -------- | ------------------------------------ | ------------------------ | -| `GET` | `/api/v1/feeds` | List feeds | -| `POST` | `/api/v1/feeds` | Create a feed | -| `GET` | `/api/v1/feeds/{feedId}` | Get a feed | -| `PATCH` | `/api/v1/feeds/{feedId}` | Update a feed | -| `DELETE` | `/api/v1/feeds/{feedId}` | Delete a feed | -| `GET` | `/api/v1/feeds/{feedId}/emails` | List a feed's emails | -| `GET` | `/api/v1/feeds/{feedId}/emails/{id}` | Get a single email | -| `DELETE` | `/api/v1/feeds/{feedId}/emails/{id}` | Delete a single email | -| `GET` | `/api/v1/stats` | Read monitoring counters | +`GET /api/v1/stats`, the OpenAPI spec, and the docs page are public. + +| Method | Path | Auth | Purpose | +| -------- | ------------------------------------ | ------ | ------------------------ | +| `GET` | `/api/v1/feeds` | yes | List feeds | +| `POST` | `/api/v1/feeds` | yes | Create a feed | +| `GET` | `/api/v1/feeds/{feedId}` | yes | Get a feed | +| `PATCH` | `/api/v1/feeds/{feedId}` | yes | Update a feed | +| `DELETE` | `/api/v1/feeds/{feedId}` | yes | Delete a feed | +| `GET` | `/api/v1/feeds/{feedId}/emails` | yes | List a feed's emails | +| `GET` | `/api/v1/feeds/{feedId}/emails/{id}` | yes | Get a single email | +| `DELETE` | `/api/v1/feeds/{feedId}/emails/{id}` | yes | Delete a single email | +| `GET` | `/api/v1/stats` | public | Read monitoring counters | The email `{id}` is the email's `receivedAt` timestamp (as returned by the list endpoint). diff --git a/docs/index.html b/docs/index.html index 8476256..70ee573 100644 --- a/docs/index.html +++ b/docs/index.html @@ -1249,7 +1249,7 @@ bucket_name = "kill-the-news-attachments" const section = document.getElementById('stats'); let data; try { - const res = await fetch('https://demo.kill-the.news/api/stats', { cache: 'no-store' }); + const res = await fetch('https://demo.kill-the.news/api/v1/stats', { cache: 'no-store' }); if (!res.ok) return; // section stays hidden data = await res.json(); } catch { return; } diff --git a/src/routes/api/api.test.ts b/src/routes/api/api.test.ts index f66e7ed..c8fed28 100644 --- a/src/routes/api/api.test.ts +++ b/src/routes/api/api.test.ts @@ -252,10 +252,11 @@ describe("REST API (/api/v1)", () => { }); describe("Stats", () => { - it("returns monitoring counters", async () => { + it("returns monitoring counters without a token (public)", async () => { await createFeed(); - const res = await request("/api/v1/stats", { headers: authHeaders }); + const res = await request("/api/v1/stats"); expect(res.status).toBe(200); + expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*"); const stats = (await res.json()) as { feeds_created: number; active_feeds: number; @@ -273,12 +274,15 @@ describe("REST API (/api/v1)", () => { expect(res.status).toBe(200); const doc = (await res.json()) as { openapi: string; - paths: Record; + paths: Record; }; expect(doc.openapi).toBe("3.1.0"); expect(doc.paths).toHaveProperty("/v1/feeds"); expect(doc.paths).toHaveProperty("/v1/feeds/{feedId}"); expect(doc.paths).toHaveProperty("/v1/stats"); + // Feed routes are secured; stats is public. + expect(doc.paths["/v1/feeds"].get?.security).toBeTruthy(); + expect(doc.paths["/v1/stats"].get?.security).toBeUndefined(); }); }); }); diff --git a/src/routes/api/index.ts b/src/routes/api/index.ts index 2087b25..b384a00 100644 --- a/src/routes/api/index.ts +++ b/src/routes/api/index.ts @@ -1,4 +1,5 @@ import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi"; +import { cors } from "hono/cors"; import { Scalar } from "@scalar/hono-api-reference"; import { Env, FeedConfig } from "../../types"; import { apiAuthMiddleware } from "../../lib/auth"; @@ -77,8 +78,12 @@ export const apiApp = new OpenAPIHono({ }, }); -// Token auth on every /v1 route. The spec + docs stay public. -apiApp.use("/v1/*", apiAuthMiddleware); +// Token auth on the feed/email routes. The spec, docs, and /v1/stats stay public. +apiApp.use("/v1/feeds", apiAuthMiddleware); +apiApp.use("/v1/feeds/*", apiAuthMiddleware); + +// Public monitoring stats — readable from any origin (landing page, embeds). +apiApp.use("/v1/stats", cors({ origin: "*" })); apiApp.openAPIRegistry.registerComponent("securitySchemes", "bearerAuth", { type: "http", @@ -363,11 +368,9 @@ apiApp.openapi( method: "get", path: "/v1/stats", tags: ["Stats"], - summary: "Read monitoring counters", - security: bearer, + summary: "Read monitoring counters (public)", responses: { 200: jsonContent(StatsSchema, "Monitoring counters"), - 401: jsonContent(ErrorSchema, "Unauthorized"), }, }), async (c) => {