From f823a5f222effbd1de9726976154dd3adc83a458 Mon Sep 17 00:00:00 2001 From: Julien Herr Date: Sun, 24 May 2026 10:02:23 +0200 Subject: [PATCH] =?UTF-8?q?refactor:=20move=20KV=20repositories=20to=20inf?= =?UTF-8?q?rastructure=20(Track=20P=20=E2=80=94=20points=202,=206c)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the domain stop depending on infrastructure ("imports point inward"). - Point 2: relocate the four KV adapters (FeedRepository, IconRepository, WebSubSubscriptionRepository, CountersRepository) from domain/ to infrastructure/, where the logger import is legitimate. The domain now keeps only the pure key schema (feed-keys.ts), the Feed aggregate and value objects; it imports nothing outward. Deliberately no hand-rolled 24-method port interface (YAGNI without DI) — relocation alone fixes the direction. - Point 6c: EmailParser.extractFeedId now returns a validated FeedId value object instead of a raw string, so the most untrusted input (an inbound recipient address) is guarded at the parse boundary and no longer round-trips through FeedId.fromTrusted in the ingest path. All import paths updated; CLAUDE.md source layout/KV-schema notes updated. 351 tests pass; tsc --noEmit clean. Co-Authored-By: Claude Opus 4.7 --- CLAUDE.md | 15 ++++++++------- src/application/email-processor.ts | 11 +++++------ src/application/feed-fetcher.ts | 2 +- src/application/feed-service.ts | 2 +- src/application/stats.ts | 6 +++--- src/domain/email-parser.test.ts | 12 ++++++------ src/domain/email-parser.ts | 13 +++++++++---- src/domain/feed.aggregate.test.ts | 2 +- src/index.ts | 2 +- .../counters-repository.test.ts | 0 .../counters-repository.ts | 2 +- src/infrastructure/favicon-fetcher.ts | 4 ++-- .../feed-repository.test.ts | 2 +- src/{domain => infrastructure}/feed-repository.ts | 8 ++++---- .../icon-repository.test.ts | 0 src/{domain => infrastructure}/icon-repository.ts | 2 +- .../websub-subscription-repository.test.ts | 0 .../websub-subscription-repository.ts | 4 ++-- src/infrastructure/websub.ts | 4 ++-- src/routes/admin.tsx | 2 +- src/routes/admin/emails.tsx | 2 +- src/routes/admin/feeds.tsx | 2 +- src/routes/admin/helpers.ts | 2 +- src/routes/api/index.ts | 2 +- src/routes/entries.ts | 2 +- src/routes/favicon.ts | 2 +- src/routes/hub.ts | 2 +- 27 files changed, 56 insertions(+), 51 deletions(-) rename src/{domain => infrastructure}/counters-repository.test.ts (100%) rename src/{domain => infrastructure}/counters-repository.ts (93%) rename src/{domain => infrastructure}/feed-repository.test.ts (98%) rename src/{domain => infrastructure}/feed-repository.ts (97%) rename src/{domain => infrastructure}/icon-repository.test.ts (100%) rename src/{domain => infrastructure}/icon-repository.ts (94%) rename src/{domain => infrastructure}/websub-subscription-repository.test.ts (100%) rename src/{domain => infrastructure}/websub-subscription-repository.ts (93%) diff --git a/CLAUDE.md b/CLAUDE.md index b2f1323..5c23af5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,17 +56,14 @@ src/ index.ts # App entrypoint: CORS, IP middleware, route mounting, email handler export config/constants.ts # Shared constants (TTLs, limits) types/index.ts # Env, FeedConfig, EmailData, WebSubSubscription, etc. - domain/ # Framework-agnostic core (no Hono imports leak out) + domain/ # Framework-agnostic core (no Hono/infra imports leak out) feed.aggregate.ts # Feed aggregate: consistency boundary; all config/metadata mutations go through it feed.ts # Pure invariant functions (expiry, sender policy, byte budget) the aggregate delegates to feed-keys.ts # The KV key schema (pure string builders), shared by every repository - feed-repository.ts # KV access for the Feed aggregate + global feed list + email bodies (load/save) - icon-repository.ts # KV access for cached favicons (icon:*) - websub-subscription-repository.ts # KV access for WebSub subscriber lists (websub:subs:*) - counters-repository.ts # KV access for the monitoring counters singleton (stats:counters) + clock.ts # Clock port (systemClock) — injected into the aggregate; no ambient Date.now() email-parser.ts # Email parsing (addresses, headers, encoded words) format.ts # Pure formatting helpers (formatBytes) - value-objects/ # FeedId, EmailAddress, Domain (immutable, self-validating) + value-objects/ # FeedId, EmailAddress, Domain, SenderPolicy (immutable, self-validating) application/ # Use-cases / orchestration (wires domain + infrastructure) feed-service.ts # createFeedRecord / renameFeed / editFeed / deleteFeedRecord (admin UI + REST API) email-processor.ts # Core ingestion: load aggregate → accepts? → feed.ingest → persist @@ -74,6 +71,10 @@ src/ stats.ts # Monitoring counters increment policy + storage scans infrastructure/ # Adapters: KV/R2, outbound HTTP, logging, framework glue logger.ts # JSON structured logger + feed-repository.ts # KV adapter for the Feed aggregate + global feed list + email bodies (load/save) + icon-repository.ts # KV adapter for cached favicons (icon:*) + websub-subscription-repository.ts # KV adapter for WebSub subscriber lists (websub:subs:*) + counters-repository.ts # KV adapter for the monitoring counters singleton (stats:counters) auth.ts # timingSafeEqual, proxy-auth check, API bearer middleware cloudflare-email.ts # Cloudflare Email routing handler forwardemail.ts # ForwardEmail webhook types/parsing @@ -130,7 +131,7 @@ All data lives in the `EMAIL_STORAGE` KV namespace: | `icon:` | Cached favicon record (base64 + content type; negative entries allowed) | | `stats:counters` | `Counters` (cumulative monitoring counters singleton) | -The KV key schema lives in `src/domain/feed-keys.ts` — never inline a `feed:`/`feeds:list`/`websub:`/`icon:`/`stats:counters` key string anywhere else. KV access is owned by four repositories, each for one concern: `FeedRepository` (the Feed aggregate + global list + email bodies), `IconRepository` (`icon:*`), `WebSubSubscriptionRepository` (`websub:subs:*`), and `CountersRepository` (`stats:counters`). Go through a repository, never `env.EMAIL_STORAGE.get/put` directly. +The KV key schema lives in `src/domain/feed-keys.ts` (pure, framework-agnostic) — never inline a `feed:`/`feeds:list`/`websub:`/`icon:`/`stats:counters` key string anywhere else. KV access is owned by four repository **adapters** in `src/infrastructure/`, each for one concern: `FeedRepository` (the Feed aggregate + global list + email bodies), `IconRepository` (`icon:*`), `WebSubSubscriptionRepository` (`websub:subs:*`), and `CountersRepository` (`stats:counters`). Go through a repository, never `env.EMAIL_STORAGE.get/put` directly. The domain depends only on the key schema, not on these adapters. ### Domain & layering rules diff --git a/src/application/email-processor.ts b/src/application/email-processor.ts index ec390eb..f5c8759 100644 --- a/src/application/email-processor.ts +++ b/src/application/email-processor.ts @@ -8,9 +8,8 @@ import { } from "../infrastructure/favicon-fetcher"; import { parseOneClickUnsubscribe } from "../infrastructure/unsubscribe"; import { getAttachmentBucket } from "../infrastructure/attachments"; -import { FeedRepository } from "../domain/feed-repository"; +import { FeedRepository } from "../infrastructure/feed-repository"; import { Feed } from "../domain/feed.aggregate"; -import { FeedId } from "../domain/value-objects/feed-id"; import { logger } from "../infrastructure/logger"; import { FEED_MAX_BYTES } from "../config/constants"; @@ -85,18 +84,18 @@ async function loadAcceptingFeed( return { ok: false, reason: "invalid_address" }; } - const feed = await FeedRepository.from(env).load(FeedId.fromTrusted(feedId)); + const feed = await FeedRepository.from(env).load(feedId); if (!feed) { - logger.error("Feed not found", { feedId }); + logger.error("Feed not found", { feedId: feedId.value }); return { ok: false, reason: "feed_not_found" }; } if (feed.isExpired()) { - logger.warn("Rejected email: feed expired", { feedId }); + logger.warn("Rejected email: feed expired", { feedId: feedId.value }); return { ok: false, reason: "feed_expired" }; } if (feed.accepts(input.senders) === "blocked") { logger.warn("Rejected email: sender filter", { - feedId, + feedId: feedId.value, senders: input.senders, allowedSenders: feed.config.allowed_senders, blockedSenders: feed.config.blocked_senders, diff --git a/src/application/feed-fetcher.ts b/src/application/feed-fetcher.ts index 8cc61d6..1c66490 100644 --- a/src/application/feed-fetcher.ts +++ b/src/application/feed-fetcher.ts @@ -1,6 +1,6 @@ import { Env, FeedConfig, EmailData } from "../types"; import { MAX_FEED_ITEMS } from "../config/constants"; -import { FeedRepository } from "../domain/feed-repository"; +import { FeedRepository } from "../infrastructure/feed-repository"; import { FeedId } from "../domain/value-objects/feed-id"; export interface FeedData { diff --git a/src/application/feed-service.ts b/src/application/feed-service.ts index f82aedb..a92f446 100644 --- a/src/application/feed-service.ts +++ b/src/application/feed-service.ts @@ -4,7 +4,7 @@ import { bumpCounters } from "../application/stats"; import { waitUntilSafe } from "../infrastructure/worker"; import { sendUnsubscribes } from "../infrastructure/unsubscribe"; import { getAttachmentBucket } from "../infrastructure/attachments"; -import { FeedRepository } from "../domain/feed-repository"; +import { FeedRepository } from "../infrastructure/feed-repository"; import { FeedId } from "../domain/value-objects/feed-id"; import { Feed, diff --git a/src/application/stats.ts b/src/application/stats.ts index 43bb999..2f50c35 100644 --- a/src/application/stats.ts +++ b/src/application/stats.ts @@ -1,8 +1,8 @@ import { Counters, Env, StatsResponse } from "../types"; import { logger } from "../infrastructure/logger"; -import { FeedRepository } from "../domain/feed-repository"; -import { CountersRepository } from "../domain/counters-repository"; -import { WebSubSubscriptionRepository } from "../domain/websub-subscription-repository"; +import { FeedRepository } from "../infrastructure/feed-repository"; +import { CountersRepository } from "../infrastructure/counters-repository"; +import { WebSubSubscriptionRepository } from "../infrastructure/websub-subscription-repository"; import { FeedId } from "../domain/value-objects/feed-id"; import { getAttachmentBucket } from "../infrastructure/attachments"; diff --git a/src/domain/email-parser.test.ts b/src/domain/email-parser.test.ts index 2b76981..5b08e33 100644 --- a/src/domain/email-parser.test.ts +++ b/src/domain/email-parser.test.ts @@ -3,15 +3,15 @@ import { EmailParser } from "./email-parser"; describe("EmailParser.extractFeedId", () => { it("extracts a valid feed ID from an email address", () => { - expect(EmailParser.extractFeedId("river.castle.42@example.com")).toBe( - "river.castle.42", - ); + expect( + EmailParser.extractFeedId("river.castle.42@example.com")?.value, + ).toBe("river.castle.42"); }); it("is case-insensitive for the local part", () => { - expect(EmailParser.extractFeedId("River.Castle.42@example.com")).toBe( - "River.Castle.42", - ); + expect( + EmailParser.extractFeedId("River.Castle.42@example.com")?.value, + ).toBe("River.Castle.42"); }); it("returns null for an address with no feed ID format", () => { diff --git a/src/domain/email-parser.ts b/src/domain/email-parser.ts index 52ecd75..e8d0234 100644 --- a/src/domain/email-parser.ts +++ b/src/domain/email-parser.ts @@ -1,10 +1,15 @@ import { EmailData } from "../types"; -import { FeedId } from "../domain/value-objects/feed-id"; +import { FeedId } from "./value-objects/feed-id"; export class EmailParser { - // Matches noun1.noun2.XY (the feed ID format) before the @ symbol - static extractFeedId(emailAddress: string): string | null { - return FeedId.parse(emailAddress)?.value ?? null; + /** + * Extract the feed id from an inbound recipient address. Returns a validated + * `FeedId` value object (not a raw string) so the most untrusted input in the + * system — an address typed by a sender — is guarded at the parse boundary and + * never needs `FeedId.fromTrusted` downstream. + */ + static extractFeedId(emailAddress: string): FeedId | null { + return FeedId.parse(emailAddress); } // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/src/domain/feed.aggregate.test.ts b/src/domain/feed.aggregate.test.ts index 82c1627..cb4c62b 100644 --- a/src/domain/feed.aggregate.test.ts +++ b/src/domain/feed.aggregate.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from "vitest"; import { createMockEnv } from "../test/setup"; import { Feed, CreateFeedInput } from "./feed.aggregate"; -import { FeedRepository } from "./feed-repository"; +import { FeedRepository } from "../infrastructure/feed-repository"; import { FeedId } from "./value-objects/feed-id"; import { Clock } from "./clock"; import type { Env, EmailMetadata } from "../types"; diff --git a/src/index.ts b/src/index.ts index 0d0d7a4..0e968ba 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,7 +13,7 @@ import { apiApp } from "./routes/api"; import { handleCloudflareEmail } from "./infrastructure/cloudflare-email"; import { Env } from "./types"; import { logger } from "./infrastructure/logger"; -import { FeedRepository } from "./domain/feed-repository"; +import { FeedRepository } from "./infrastructure/feed-repository"; import { purgeExpiredFeeds } from "./routes/admin/helpers"; import { bumpCounters, diff --git a/src/domain/counters-repository.test.ts b/src/infrastructure/counters-repository.test.ts similarity index 100% rename from src/domain/counters-repository.test.ts rename to src/infrastructure/counters-repository.test.ts diff --git a/src/domain/counters-repository.ts b/src/infrastructure/counters-repository.ts similarity index 93% rename from src/domain/counters-repository.ts rename to src/infrastructure/counters-repository.ts index 7a3c57e..efb8db7 100644 --- a/src/domain/counters-repository.ts +++ b/src/infrastructure/counters-repository.ts @@ -1,5 +1,5 @@ import { Counters, Env } from "../types"; -import { STATS_KEY } from "./feed-keys"; +import { STATS_KEY } from "../domain/feed-keys"; /** * KV access for the monitoring counters singleton (`stats:counters`). The diff --git a/src/infrastructure/favicon-fetcher.ts b/src/infrastructure/favicon-fetcher.ts index 463bcb4..c927b74 100644 --- a/src/infrastructure/favicon-fetcher.ts +++ b/src/infrastructure/favicon-fetcher.ts @@ -4,9 +4,9 @@ import { ICON_TTL_SECONDS, MAX_ICON_BYTES, } from "../config/constants"; -import { IconRepository } from "../domain/icon-repository"; +import { IconRepository } from "./icon-repository"; import { EmailAddress } from "../domain/value-objects/email-address"; -import { logger } from "../infrastructure/logger"; +import { logger } from "./logger"; interface IconRecord { data: string | null; // base64 icon bytes, or null for a negative cache entry diff --git a/src/domain/feed-repository.test.ts b/src/infrastructure/feed-repository.test.ts similarity index 98% rename from src/domain/feed-repository.test.ts rename to src/infrastructure/feed-repository.test.ts index 41635a4..c9956d6 100644 --- a/src/domain/feed-repository.test.ts +++ b/src/infrastructure/feed-repository.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from "vitest"; import { createMockEnv } from "../test/setup"; import { FeedRepository } from "./feed-repository"; -import { FeedId } from "./value-objects/feed-id"; +import { FeedId } from "../domain/value-objects/feed-id"; import type { Env, FeedConfig, FeedMetadata, EmailData } from "../types"; const mockEnv = () => createMockEnv() as unknown as Env; diff --git a/src/domain/feed-repository.ts b/src/infrastructure/feed-repository.ts similarity index 97% rename from src/domain/feed-repository.ts rename to src/infrastructure/feed-repository.ts index 24ecb62..5d2a7e8 100644 --- a/src/domain/feed-repository.ts +++ b/src/infrastructure/feed-repository.ts @@ -7,10 +7,10 @@ import { FeedMetadata, } from "../types"; import { FEEDS_LIST_KEY } from "../config/constants"; -import { feedKeys } from "./feed-keys"; -import { Feed } from "./feed.aggregate"; -import { FeedId } from "./value-objects/feed-id"; -import { logger } from "../infrastructure/logger"; +import { feedKeys } from "../domain/feed-keys"; +import { Feed } from "../domain/feed.aggregate"; +import { FeedId } from "../domain/value-objects/feed-id"; +import { logger } from "./logger"; /** * Single source of truth for KV access to the Feed aggregate. The key schema diff --git a/src/domain/icon-repository.test.ts b/src/infrastructure/icon-repository.test.ts similarity index 100% rename from src/domain/icon-repository.test.ts rename to src/infrastructure/icon-repository.test.ts diff --git a/src/domain/icon-repository.ts b/src/infrastructure/icon-repository.ts similarity index 94% rename from src/domain/icon-repository.ts rename to src/infrastructure/icon-repository.ts index e791f57..5d7074b 100644 --- a/src/domain/icon-repository.ts +++ b/src/infrastructure/icon-repository.ts @@ -1,5 +1,5 @@ import { Env } from "../types"; -import { feedKeys } from "./feed-keys"; +import { feedKeys } from "../domain/feed-keys"; /** * KV access for cached per-domain favicons (`icon:`). Entries may be diff --git a/src/domain/websub-subscription-repository.test.ts b/src/infrastructure/websub-subscription-repository.test.ts similarity index 100% rename from src/domain/websub-subscription-repository.test.ts rename to src/infrastructure/websub-subscription-repository.test.ts diff --git a/src/domain/websub-subscription-repository.ts b/src/infrastructure/websub-subscription-repository.ts similarity index 93% rename from src/domain/websub-subscription-repository.ts rename to src/infrastructure/websub-subscription-repository.ts index f73df15..943413d 100644 --- a/src/domain/websub-subscription-repository.ts +++ b/src/infrastructure/websub-subscription-repository.ts @@ -1,6 +1,6 @@ import { Env, WebSubSubscription } from "../types"; -import { feedKeys } from "./feed-keys"; -import { logger } from "../infrastructure/logger"; +import { feedKeys } from "../domain/feed-keys"; +import { logger } from "./logger"; /** * KV access for per-feed WebSub subscriber lists (`websub:subs:`). diff --git a/src/infrastructure/websub.ts b/src/infrastructure/websub.ts index a5cb022..776aaa7 100644 --- a/src/infrastructure/websub.ts +++ b/src/infrastructure/websub.ts @@ -1,8 +1,8 @@ import { Env, FeedConfig, EmailData, WebSubSubscription } from "../types"; import { generateRssFeed, generateAtomFeed } from "./feed-generator"; import { baseUrl, feedRssUrl, feedAtomUrl, feedUrl } from "./urls"; -import { FeedRepository } from "../domain/feed-repository"; -import { WebSubSubscriptionRepository } from "../domain/websub-subscription-repository"; +import { FeedRepository } from "./feed-repository"; +import { WebSubSubscriptionRepository } from "./websub-subscription-repository"; import { FeedId } from "../domain/value-objects/feed-id"; export async function getSubscriptions( diff --git a/src/routes/admin.tsx b/src/routes/admin.tsx index d7c200e..3f2269e 100644 --- a/src/routes/admin.tsx +++ b/src/routes/admin.tsx @@ -8,7 +8,7 @@ import { ADMIN_COOKIE_MAX_AGE } from "../config/constants"; import { logger } from "../infrastructure/logger"; import { timingSafeEqual, checkProxyAuth } from "../infrastructure/auth"; import { Layout, clampText } from "./admin/ui"; -import { FeedRepository } from "../domain/feed-repository"; +import { FeedRepository } from "../infrastructure/feed-repository"; import { renameFeed } from "../application/feed-service"; import { feedRssUrl, diff --git a/src/routes/admin/emails.tsx b/src/routes/admin/emails.tsx index b30665a..9f64aff 100644 --- a/src/routes/admin/emails.tsx +++ b/src/routes/admin/emails.tsx @@ -6,7 +6,7 @@ import { deleteAttachmentsForEmails, deleteKeysWithConcurrency, } from "./helpers"; -import { FeedRepository } from "../../domain/feed-repository"; +import { FeedRepository } from "../../infrastructure/feed-repository"; import { FeedId } from "../../domain/value-objects/feed-id"; import { feedRssUrl, diff --git a/src/routes/admin/feeds.tsx b/src/routes/admin/feeds.tsx index e437c25..a7ffb4b 100644 --- a/src/routes/admin/feeds.tsx +++ b/src/routes/admin/feeds.tsx @@ -9,7 +9,7 @@ import { sendUnsubscribes } from "../../infrastructure/unsubscribe"; import { getAttachmentBucket } from "../../infrastructure/attachments"; import { Layout } from "./ui"; import { purgeFeedKeysStep, collectUnsubscribeUrls } from "./helpers"; -import { FeedRepository } from "../../domain/feed-repository"; +import { FeedRepository } from "../../infrastructure/feed-repository"; import { FeedId } from "../../domain/value-objects/feed-id"; import { createFeedRecord, diff --git a/src/routes/admin/helpers.ts b/src/routes/admin/helpers.ts index fbbca08..10b59f1 100644 --- a/src/routes/admin/helpers.ts +++ b/src/routes/admin/helpers.ts @@ -1,7 +1,7 @@ import { EmailData, EmailMetadata, Env } from "../../types"; import { logger } from "../../infrastructure/logger"; import { getAttachmentBucket } from "../../infrastructure/attachments"; -import { FeedRepository } from "../../domain/feed-repository"; +import { FeedRepository } from "../../infrastructure/feed-repository"; import { FeedId } from "../../domain/value-objects/feed-id"; // Delete the R2 attachments belonging to the given email keys. Call before the diff --git a/src/routes/api/index.ts b/src/routes/api/index.ts index 6ece629..b369a10 100644 --- a/src/routes/api/index.ts +++ b/src/routes/api/index.ts @@ -9,7 +9,7 @@ import { deleteFeedRecord, } from "../../application/feed-service"; import { deleteAttachmentsForEmails } from "../admin/helpers"; -import { FeedRepository } from "../../domain/feed-repository"; +import { FeedRepository } from "../../infrastructure/feed-repository"; import { FeedId } from "../../domain/value-objects/feed-id"; import { getStats } from "../../application/stats"; import { diff --git a/src/routes/entries.ts b/src/routes/entries.ts index 982f22c..090b145 100644 --- a/src/routes/entries.ts +++ b/src/routes/entries.ts @@ -3,7 +3,7 @@ import { html, raw } from "hono/html"; import { Env } from "../types"; import { processEmailContent } from "../infrastructure/html-processor"; import { formatBytes } from "../domain/format"; -import { FeedRepository } from "../domain/feed-repository"; +import { FeedRepository } from "../infrastructure/feed-repository"; import { FeedId } from "../domain/value-objects/feed-id"; import { isExpired } from "../domain/feed"; diff --git a/src/routes/favicon.ts b/src/routes/favicon.ts index e28bb29..a7f96b0 100644 --- a/src/routes/favicon.ts +++ b/src/routes/favicon.ts @@ -1,6 +1,6 @@ import { Context } from "hono"; import { Env } from "../types"; -import { FeedRepository } from "../domain/feed-repository"; +import { FeedRepository } from "../infrastructure/feed-repository"; import { FeedId } from "../domain/value-objects/feed-id"; import { cacheFaviconForDomain, diff --git a/src/routes/hub.ts b/src/routes/hub.ts index bbe3597..0b67c75 100644 --- a/src/routes/hub.ts +++ b/src/routes/hub.ts @@ -7,7 +7,7 @@ import { import { waitUntilSafe } from "../infrastructure/worker"; import { DEFAULT_LEASE_SECONDS, MAX_LEASE_SECONDS } from "../config/constants"; import { feedTopicPattern } from "../infrastructure/urls"; -import { FeedRepository } from "../domain/feed-repository"; +import { FeedRepository } from "../infrastructure/feed-repository"; import { FeedId } from "../domain/value-objects/feed-id"; type AppEnv = { Bindings: Env };