mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-20 22:03:48 +00:00
06c436c36a
Move four DDD tensions on the Feed aggregate to ground: - #1 The aggregate now holds a domain FeedState (camelCase) instead of the snake_case FeedConfig DTO; infrastructure/feed-mapper.ts owns the FeedState<->FeedConfig/FeedListItem translation as the sole snake_case site outside the HTTP edge. - #3 Replace the edit() recomputeExpiry control flag with a Lifetime VO: passing a lifetime recomputes expiry, omitting it preserves the current one (the dashboard quick-edit path). - #4 Domain events carry their own feedId; dispatchFeedEvents centralizes the drain+dispatch in the application layer (no more manual pullEvents at call sites), keeping infra->application dependency direction intact. - #6 Rename FeedId.fromTrusted to FeedId.unchecked to make the absence of revalidation explicit. Adds Lifetime + feed-mapper round-trip tests. 353 tests green, tsc clean, wrangler dry-run OK. Docs (CLAUDE.md) synced. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
114 lines
3.1 KiB
TypeScript
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.unchecked(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);
|
|
});
|