feat(api): add versioned REST API with OpenAPI 3.1 spec

Expose /api/v1/* for feed and email management (feeds CRUD, email
list/get/delete, stats) so the service can be automated without scraping
the admin UI. Built on @hono/zod-openapi; the OpenAPI 3.1 spec is served at
/api/openapi.json with a Scalar reference at /api/docs.

Auth is token-based (Authorization: Bearer <ADMIN_PASSWORD>) plus the
existing reverse-proxy headers — no cookie, no CSRF. Extracted the auth
primitives into src/lib/auth.ts and the feed create/update/delete
orchestration into src/lib/feed-service.ts so the admin UI and the REST API
share a single source of truth.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Julien Herr
2026-05-23 23:01:15 +02:00
parent 7f5b913576
commit 45d2a14a12
14 changed files with 1398 additions and 234 deletions
+397
View File
@@ -0,0 +1,397 @@
import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi";
import { Scalar } from "@scalar/hono-api-reference";
import { Env, FeedConfig } from "../../types";
import { apiAuthMiddleware } from "../../lib/auth";
import {
createFeedRecord,
updateFeedRecord,
deleteFeedRecord,
} from "../../lib/feed-service";
import { listAllFeeds, deleteAttachmentsForEmails } from "../admin/helpers";
import {
getFeedConfig,
getFeedMetadata,
getEmailData,
} from "../../utils/storage";
import { getStats } from "../../utils/stats";
import { feedEmailAddress, feedRssUrl, feedAtomUrl } from "../../utils/urls";
import {
ErrorSchema,
FeedIdParam,
EntryIdParam,
FeedCreateSchema,
FeedUpdateSchema,
FeedSchema,
FeedListSchema,
EmailListSchema,
EmailSchema,
StatsSchema,
} from "./schemas";
type AppEnv = { Bindings: Env };
const OkSchema = z.object({ ok: z.boolean() }).openapi("Ok");
const jsonContent = <T>(schema: T, description: string) => ({
content: { "application/json": { schema } },
description,
});
const bearer = [{ bearerAuth: [] }];
function normalizeSenders(senders?: string[]): string[] | undefined {
return senders?.map((s) => s.trim().toLowerCase()).filter(Boolean);
}
function toFeed(
id: string,
config: FeedConfig,
emailCount: number,
env: Env,
): z.infer<typeof FeedSchema> {
return {
id,
title: config.title,
description: config.description,
language: config.language,
allowedSenders: config.allowed_senders ?? [],
blockedSenders: config.blocked_senders ?? [],
createdAt: config.created_at,
updatedAt: config.updated_at,
expiresAt: config.expires_at,
emailCount,
emailAddress: feedEmailAddress(id, env),
rssUrl: feedRssUrl(id, env),
atomUrl: feedAtomUrl(id, env),
};
}
export const apiApp = new OpenAPIHono<AppEnv>({
defaultHook: (result, c) => {
if (!result.success) {
const message = result.error.issues
.map((i) => `${i.path.join(".")}: ${i.message}`)
.join("; ");
return c.json({ error: `Validation failed: ${message}` }, 400);
}
},
});
// Token auth on every /v1 route. The spec + docs stay public.
apiApp.use("/v1/*", apiAuthMiddleware);
apiApp.openAPIRegistry.registerComponent("securitySchemes", "bearerAuth", {
type: "http",
scheme: "bearer",
description: "Use the admin password as the bearer token.",
});
// ── Feeds ─────────────────────────────────────────────────────────────────────
apiApp.openapi(
createRoute({
method: "get",
path: "/v1/feeds",
tags: ["Feeds"],
summary: "List all feeds",
security: bearer,
responses: {
200: jsonContent(FeedListSchema, "The list of feeds"),
401: jsonContent(ErrorSchema, "Unauthorized"),
},
}),
async (c) => {
const env = c.env;
const feeds = await listAllFeeds(env.EMAIL_STORAGE);
return c.json(
{
feeds: feeds.map((f) => ({
id: f.id,
title: f.title,
description: f.description,
expiresAt: f.expires_at,
emailAddress: feedEmailAddress(f.id, env),
rssUrl: feedRssUrl(f.id, env),
atomUrl: feedAtomUrl(f.id, env),
})),
},
200,
);
},
);
apiApp.openapi(
createRoute({
method: "post",
path: "/v1/feeds",
tags: ["Feeds"],
summary: "Create a feed",
security: bearer,
request: {
body: { content: { "application/json": { schema: FeedCreateSchema } } },
},
responses: {
201: jsonContent(FeedSchema, "The created feed"),
400: jsonContent(ErrorSchema, "Invalid request body"),
401: jsonContent(ErrorSchema, "Unauthorized"),
},
}),
async (c) => {
const env = c.env;
const body = c.req.valid("json");
const { feedId, config } = await createFeedRecord(env, {
title: body.title,
description: body.description,
language: body.language,
allowedSenders: normalizeSenders(body.allowedSenders) ?? [],
blockedSenders: normalizeSenders(body.blockedSenders) ?? [],
lifetimeHours: body.lifetimeHours,
});
return c.json(toFeed(feedId, config, 0, env), 201);
},
);
apiApp.openapi(
createRoute({
method: "get",
path: "/v1/feeds/{feedId}",
tags: ["Feeds"],
summary: "Get a feed",
security: bearer,
request: { params: FeedIdParam },
responses: {
200: jsonContent(FeedSchema, "The feed"),
401: jsonContent(ErrorSchema, "Unauthorized"),
404: jsonContent(ErrorSchema, "Feed not found"),
},
}),
async (c) => {
const env = c.env;
const { feedId } = c.req.valid("param");
const config = await getFeedConfig(env.EMAIL_STORAGE, feedId);
if (!config) return c.json({ error: "Feed not found" }, 404);
const metadata = await getFeedMetadata(env.EMAIL_STORAGE, feedId);
return c.json(
toFeed(feedId, config, metadata?.emails.length ?? 0, env),
200,
);
},
);
apiApp.openapi(
createRoute({
method: "patch",
path: "/v1/feeds/{feedId}",
tags: ["Feeds"],
summary: "Update a feed",
security: bearer,
request: {
params: FeedIdParam,
body: { content: { "application/json": { schema: FeedUpdateSchema } } },
},
responses: {
200: jsonContent(FeedSchema, "The updated feed"),
400: jsonContent(ErrorSchema, "Invalid request body"),
401: jsonContent(ErrorSchema, "Unauthorized"),
404: jsonContent(ErrorSchema, "Feed not found"),
409: jsonContent(ErrorSchema, "Feed has expired"),
},
}),
async (c) => {
const env = c.env;
const { feedId } = c.req.valid("param");
const body = c.req.valid("json");
const result = await updateFeedRecord(env, feedId, {
title: body.title,
description: body.description,
language: body.language,
allowedSenders: normalizeSenders(body.allowedSenders),
blockedSenders: normalizeSenders(body.blockedSenders),
lifetimeHours: body.lifetimeHours,
});
if (result.status === "not_found")
return c.json({ error: "Feed not found" }, 404);
if (result.status === "expired")
return c.json({ error: "Feed has expired and cannot be modified" }, 409);
const metadata = await getFeedMetadata(env.EMAIL_STORAGE, feedId);
return c.json(
toFeed(feedId, result.config, metadata?.emails.length ?? 0, env),
200,
);
},
);
apiApp.openapi(
createRoute({
method: "delete",
path: "/v1/feeds/{feedId}",
tags: ["Feeds"],
summary: "Delete a feed",
security: bearer,
request: { params: FeedIdParam },
responses: {
200: jsonContent(OkSchema, "The feed was deleted"),
401: jsonContent(ErrorSchema, "Unauthorized"),
404: jsonContent(ErrorSchema, "Feed not found"),
},
}),
async (c) => {
const env = c.env;
const { feedId } = c.req.valid("param");
const removed = await deleteFeedRecord(c, env, feedId);
if (!removed) return c.json({ error: "Feed not found" }, 404);
return c.json({ ok: true }, 200);
},
);
// ── Emails ──────────────────────────────────────────────────────────────────
apiApp.openapi(
createRoute({
method: "get",
path: "/v1/feeds/{feedId}/emails",
tags: ["Emails"],
summary: "List a feed's emails",
security: bearer,
request: { params: FeedIdParam },
responses: {
200: jsonContent(EmailListSchema, "The feed's emails"),
401: jsonContent(ErrorSchema, "Unauthorized"),
404: jsonContent(ErrorSchema, "Feed not found"),
},
}),
async (c) => {
const env = c.env;
const { feedId } = c.req.valid("param");
const metadata = await getFeedMetadata(env.EMAIL_STORAGE, feedId);
if (!metadata) return c.json({ error: "Feed not found" }, 404);
return c.json(
{
emails: metadata.emails.map((e) => ({
entryId: e.receivedAt,
subject: e.subject,
receivedAt: e.receivedAt,
size: e.size,
attachmentIds: e.attachmentIds,
})),
},
200,
);
},
);
apiApp.openapi(
createRoute({
method: "get",
path: "/v1/feeds/{feedId}/emails/{entryId}",
tags: ["Emails"],
summary: "Get a single email",
security: bearer,
request: { params: EntryIdParam },
responses: {
200: jsonContent(EmailSchema, "The email"),
401: jsonContent(ErrorSchema, "Unauthorized"),
404: jsonContent(ErrorSchema, "Email not found"),
},
}),
async (c) => {
const env = c.env;
const { feedId, entryId } = c.req.valid("param");
const receivedAt = parseInt(entryId, 10);
const metadata = await getFeedMetadata(env.EMAIL_STORAGE, feedId);
const metaEntry = metadata?.emails.find((e) => e.receivedAt === receivedAt);
if (!metaEntry) return c.json({ error: "Email not found" }, 404);
const data = await getEmailData(env.EMAIL_STORAGE, metaEntry.key);
if (!data) return c.json({ error: "Email not found" }, 404);
return c.json(
{
entryId: receivedAt,
subject: data.subject,
from: data.from,
receivedAt: data.receivedAt,
content: data.content,
attachments: (data.attachments ?? []).map((a) => ({
id: a.id,
filename: a.filename,
contentType: a.contentType,
size: a.size,
url: `/files/${a.id}/${encodeURIComponent(a.filename)}`,
})),
},
200,
);
},
);
apiApp.openapi(
createRoute({
method: "delete",
path: "/v1/feeds/{feedId}/emails/{entryId}",
tags: ["Emails"],
summary: "Delete a single email",
security: bearer,
request: { params: EntryIdParam },
responses: {
200: jsonContent(OkSchema, "The email was deleted"),
401: jsonContent(ErrorSchema, "Unauthorized"),
404: jsonContent(ErrorSchema, "Email not found"),
},
}),
async (c) => {
const env = c.env;
const emailStorage = env.EMAIL_STORAGE;
const { feedId, entryId } = c.req.valid("param");
const receivedAt = parseInt(entryId, 10);
const metadata = await getFeedMetadata(emailStorage, feedId);
const metaEntry = metadata?.emails.find((e) => e.receivedAt === receivedAt);
if (!metadata || !metaEntry)
return c.json({ error: "Email not found" }, 404);
await emailStorage.delete(metaEntry.key);
await deleteAttachmentsForEmails(env, metadata.emails, [metaEntry.key]);
metadata.emails = metadata.emails.filter((e) => e.key !== metaEntry.key);
await emailStorage.put(`feed:${feedId}:metadata`, JSON.stringify(metadata));
return c.json({ ok: true }, 200);
},
);
// ── Stats ─────────────────────────────────────────────────────────────────────
apiApp.openapi(
createRoute({
method: "get",
path: "/v1/stats",
tags: ["Stats"],
summary: "Read monitoring counters",
security: bearer,
responses: {
200: jsonContent(StatsSchema, "Monitoring counters"),
401: jsonContent(ErrorSchema, "Unauthorized"),
},
}),
async (c) => {
return c.json(await getStats(c.env), 200);
},
);
// ── OpenAPI document + docs (public) ───────────────────────────────────────────
apiApp.doc31("/openapi.json", {
openapi: "3.1.0",
info: {
title: "kill-the-news API",
version: "1.0.0",
description:
"REST API for managing email-to-RSS feeds, their emails, and monitoring counters.",
},
servers: [{ url: "/api" }],
});
apiApp.get(
"/docs",
Scalar({
url: "/api/openapi.json",
pageTitle: "kill-the-news API",
}),
);