Files
kill-the-news/src/routes/hub.ts
T
Julien HerrandClaude Opus 4.7 ad196f1761 refactor: tighten DDD boundaries on the Feed aggregate
Address five modeling tensions in one pass:

- Encapsulation: the Feed aggregate no longer exposes raw config/metadata
  (a shallow Readonly still leaked mutable arrays). It now offers
  intention-revealing accessors that return copies, plus
  toConfigSnapshot/toMetadataSnapshot for the repository and summary() for
  the global registry.
- feeds:list consistency: FeedRepository.save/saveConfig upsert the registry
  entry from feed.summary(), so services no longer mirror title/description/
  expiry by hand (the old add/updateInList footgun is gone).
- domain/feed.ts: drop the dead applySenderPolicy, internalise resolveExpiresAt
  and trimToByteBudget into the aggregate; feed.ts keeps only the shared
  isExpired predicate used by the read-model routes.
- Single edit path: remove editDetails; edit(patch, deps) is the sole config
  mutation, with a systematic expired guard. Renaming an expired feed now 403s.
- FeedId flows through the application and infrastructure signatures;
  fromTrusted/parse happen once at the edge, .value only at the serialisation
  boundaries (urls, feed-generator, feed-keys, logs, JSON).

347 tests green, tsc clean, Worker bundle builds.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-24 13:45:13 +02:00

114 lines
3.1 KiB
TypeScript

import { Hono } from "hono";
import { Env } from "../types";
import {
verifyAndStoreSubscription,
verifyAndDeleteSubscription,
} from "../infrastructure/websub";
import { waitUntilSafe } from "../infrastructure/worker";
import { DEFAULT_LEASE_SECONDS, MAX_LEASE_SECONDS } from "../config/constants";
import { feedTopicPattern } from "../infrastructure/urls";
import { FeedRepository } from "../infrastructure/feed-repository";
import { FeedId } from "../domain/value-objects/feed-id";
type AppEnv = { Bindings: Env };
export const hubRouter = new Hono<AppEnv>();
hubRouter.post("/", async (c) => {
const env = c.env;
let form: FormData;
try {
form = await c.req.formData();
} catch {
return c.text(
"Bad Request: expected application/x-www-form-urlencoded",
400,
);
}
const mode = form.get("hub.mode");
const topic = form.get("hub.topic");
const callbackUrl = form.get("hub.callback");
if (!mode || !topic || !callbackUrl) {
return c.text(
"Bad Request: hub.mode, hub.topic and hub.callback are required",
400,
);
}
if (
typeof mode !== "string" ||
typeof topic !== "string" ||
typeof callbackUrl !== "string"
) {
return c.text("Bad Request: unexpected field types", 400);
}
if (mode !== "subscribe" && mode !== "unsubscribe") {
return c.text(
"Bad Request: hub.mode must be subscribe or unsubscribe",
400,
);
}
let parsedCallback: URL;
try {
parsedCallback = new URL(callbackUrl);
} catch {
return c.text("Bad Request: hub.callback must be a valid URL", 400);
}
if (parsedCallback.protocol !== "https:") {
return c.text("Bad Request: hub.callback must use HTTPS", 400);
}
// Validate that topic matches a known RSS or Atom feed on this hub
const topicPattern = feedTopicPattern(env);
const match = topic.match(topicPattern);
if (!match) {
return c.text(
"Bad Request: hub.topic must be an RSS or Atom feed URL on this hub",
400,
);
}
const format = match[1] as "rss" | "atom";
const feedId = FeedId.fromTrusted(match[2]);
// Verify the feed exists before accepting any subscription
const feedConfig = await FeedRepository.from(env).getConfig(feedId);
if (!feedConfig) {
return c.text("Not Found: feed does not exist", 404);
}
const secret = form.get("hub.secret") || undefined; // "" → undefined
if (secret && secret.length > 200) {
return c.text("Bad Request: hub.secret must be under 200 bytes", 400);
}
const rawLease = parseInt(form.get("hub.lease_seconds") ?? "", 10);
const leaseSeconds = isNaN(rawLease)
? DEFAULT_LEASE_SECONDS
: Math.min(Math.max(rawLease, 1), MAX_LEASE_SECONDS);
// Return 202 immediately; verification is async
if (mode === "subscribe") {
waitUntilSafe(
c,
verifyAndStoreSubscription(
feedId,
callbackUrl as string,
secret as string | undefined,
leaseSeconds,
format,
env,
),
);
} else {
waitUntilSafe(
c,
verifyAndDeleteSubscription(feedId, callbackUrl as string, env),
);
}
return c.text("Accepted", 202);
});