From ab1c15e69a4b5c499336674f07c84186ca9c8008 Mon Sep 17 00:00:00 2001 From: Julien Herr Date: Sun, 24 May 2026 00:44:24 +0200 Subject: [PATCH] refactor(domain): make FeedId circulate through the domain and repository FeedId is now the type of Feed.id and of every single-feed method on FeedRepository; callers wrap raw strings via FeedId.fromTrusted at the repository boundary. String-medium operations (URLs, logs, JSON, list registry, email keys) stay string. Drop the redundant generateFeedId wrapper in favour of FeedId.generate. Co-Authored-By: Claude Opus 4.7 --- src/domain/feed-repository.test.ts | 64 +++++++++++++++-------------- src/domain/feed-repository.ts | 57 +++++++++++++------------ src/domain/feed.aggregate.test.ts | 31 +++++++------- src/domain/feed.aggregate.ts | 7 ++-- src/domain/value-objects/feed-id.ts | 9 ++++ src/lib/email-processor.ts | 9 ++-- src/lib/feed-service.ts | 21 +++++----- src/routes/admin/emails.tsx | 10 +++-- src/routes/admin/feeds.tsx | 10 +++-- src/routes/admin/helpers.ts | 10 +++-- src/routes/api/index.ts | 18 +++++--- src/routes/entries.ts | 6 ++- src/routes/favicon.ts | 5 ++- src/routes/hub.ts | 5 ++- src/utils/feed-fetcher.ts | 6 ++- src/utils/id-generator.ts | 9 ---- src/utils/stats.ts | 3 +- src/utils/websub.ts | 6 ++- 18 files changed, 162 insertions(+), 124 deletions(-) delete mode 100644 src/utils/id-generator.ts diff --git a/src/domain/feed-repository.test.ts b/src/domain/feed-repository.test.ts index 12b7142..41635a4 100644 --- a/src/domain/feed-repository.test.ts +++ b/src/domain/feed-repository.test.ts @@ -1,9 +1,11 @@ import { describe, it, expect } from "vitest"; import { createMockEnv } from "../test/setup"; import { FeedRepository } from "./feed-repository"; +import { FeedId } from "./value-objects/feed-id"; import type { Env, FeedConfig, FeedMetadata, EmailData } from "../types"; const mockEnv = () => createMockEnv() as unknown as Env; +const fid = (value: string) => FeedId.fromTrusted(value); const sampleConfig = (overrides: Partial = {}): FeedConfig => ({ title: "Test Feed", @@ -24,15 +26,17 @@ const sampleEmail = (overrides: Partial = {}): EmailData => ({ describe("FeedRepository key schema", () => { it("builds the canonical KV keys via the public API", () => { const repo = new FeedRepository(mockEnv().EMAIL_STORAGE); - expect(repo.feedKeyPrefix("a.b.42")).toBe("feed:a.b.42:"); - expect(repo.newEmailKey("a.b.42")).toMatch(/^feed:a\.b\.42:\d+$/); + expect(repo.feedKeyPrefix(fid("a.b.42"))).toBe("feed:a.b.42:"); + expect(repo.newEmailKey(fid("a.b.42"))).toMatch(/^feed:a\.b\.42:\d+$/); }); it("recognises email keys vs config/metadata keys", () => { const repo = new FeedRepository(mockEnv().EMAIL_STORAGE); - expect(repo.isEmailKey("a.b.42", "feed:a.b.42:config")).toBe(false); - expect(repo.isEmailKey("a.b.42", "feed:a.b.42:metadata")).toBe(false); - expect(repo.isEmailKey("a.b.42", "feed:a.b.42:1700000000000")).toBe(true); + expect(repo.isEmailKey(fid("a.b.42"), "feed:a.b.42:config")).toBe(false); + expect(repo.isEmailKey(fid("a.b.42"), "feed:a.b.42:metadata")).toBe(false); + expect(repo.isEmailKey(fid("a.b.42"), "feed:a.b.42:1700000000000")).toBe( + true, + ); }); it("recovers the feed id from an email key", () => { @@ -44,29 +48,29 @@ describe("FeedRepository key schema", () => { describe("FeedRepository config & metadata", () => { it("round-trips and deletes a feed config", async () => { const repo = new FeedRepository(mockEnv().EMAIL_STORAGE); - expect(await repo.getConfig("a.b.42")).toBeNull(); - await repo.putConfig("a.b.42", sampleConfig()); - expect(await repo.getConfig("a.b.42")).toMatchObject({ + expect(await repo.getConfig(fid("a.b.42"))).toBeNull(); + await repo.putConfig(fid("a.b.42"), sampleConfig()); + expect(await repo.getConfig(fid("a.b.42"))).toMatchObject({ title: "Test Feed", }); - await repo.deleteConfig("a.b.42"); - expect(await repo.getConfig("a.b.42")).toBeNull(); + await repo.deleteConfig(fid("a.b.42")); + expect(await repo.getConfig(fid("a.b.42"))).toBeNull(); }); it("round-trips and deletes feed metadata", async () => { const repo = new FeedRepository(mockEnv().EMAIL_STORAGE); const meta: FeedMetadata = { emails: [] }; - await repo.putMetadata("a.b.42", meta); - expect(await repo.getMetadata("a.b.42")).toEqual(meta); - await repo.deleteMetadata("a.b.42"); - expect(await repo.getMetadata("a.b.42")).toBeNull(); + await repo.putMetadata(fid("a.b.42"), meta); + expect(await repo.getMetadata(fid("a.b.42"))).toEqual(meta); + await repo.deleteMetadata(fid("a.b.42")); + expect(await repo.getMetadata(fid("a.b.42"))).toBeNull(); }); }); describe("FeedRepository emails", () => { it("stores and reads an email under a minted key", async () => { const repo = new FeedRepository(mockEnv().EMAIL_STORAGE); - const key = repo.newEmailKey("a.b.42"); + const key = repo.newEmailKey(fid("a.b.42")); await repo.putEmail(key, sampleEmail()); expect(await repo.getEmail(key)).toMatchObject({ subject: "Hello" }); await repo.deleteEmail(key); @@ -75,26 +79,26 @@ describe("FeedRepository emails", () => { it("lists every key under a feed prefix", async () => { const repo = new FeedRepository(mockEnv().EMAIL_STORAGE); - await repo.putConfig("a.b.42", sampleConfig()); - await repo.putMetadata("a.b.42", { emails: [] }); - const emailKey = repo.newEmailKey("a.b.42"); + await repo.putConfig(fid("a.b.42"), sampleConfig()); + await repo.putMetadata(fid("a.b.42"), { emails: [] }); + const emailKey = repo.newEmailKey(fid("a.b.42")); await repo.putEmail(emailKey, sampleEmail()); - const listed = await repo.listFeedKeys("a.b.42"); + const listed = await repo.listFeedKeys(fid("a.b.42")); expect(listed.names).toContain("feed:a.b.42:config"); expect(listed.names).toContain("feed:a.b.42:metadata"); expect(listed.names).toContain(emailKey); - expect(listed.names.filter((k) => repo.isEmailKey("a.b.42", k))).toEqual([ - emailKey, - ]); + expect( + listed.names.filter((k) => repo.isEmailKey(fid("a.b.42"), k)), + ).toEqual([emailKey]); }); }); describe("FeedRepository feed list", () => { it("adds, updates, lists and removes feeds with expiry", async () => { const repo = new FeedRepository(mockEnv().EMAIL_STORAGE); - await repo.addToList("a.b.42", "One", "desc", 5000); - await repo.addToList("c.d.99", "Two"); + await repo.addToList(fid("a.b.42"), "One", "desc", 5000); + await repo.addToList(fid("c.d.99"), "Two"); let feeds = await repo.listFeeds(); expect(feeds).toHaveLength(2); @@ -103,23 +107,23 @@ describe("FeedRepository feed list", () => { expires_at: 5000, }); - await repo.updateInList("a.b.42", "One-updated", undefined, undefined); + await repo.updateInList(fid("a.b.42"), "One-updated", undefined, undefined); feeds = await repo.listFeeds(); const updated = feeds.find((f) => f.id === "a.b.42"); expect(updated).toMatchObject({ title: "One-updated" }); expect(updated?.expires_at).toBeUndefined(); - expect(await repo.removeFromList("a.b.42")).toBe(true); - expect(await repo.removeFromList("missing")).toBe(false); + expect(await repo.removeFromList(fid("a.b.42"))).toBe(true); + expect(await repo.removeFromList(fid("missing"))).toBe(false); feeds = await repo.listFeeds(); expect(feeds.map((f) => f.id)).toEqual(["c.d.99"]); }); it("bulk-removes only the matching ids", async () => { const repo = new FeedRepository(mockEnv().EMAIL_STORAGE); - await repo.addToList("a.b.42", "One"); - await repo.addToList("c.d.99", "Two"); - await repo.addToList("e.f.10", "Three"); + await repo.addToList(fid("a.b.42"), "One"); + await repo.addToList(fid("c.d.99"), "Two"); + await repo.addToList(fid("e.f.10"), "Three"); const removed = await repo.removeFromListBulk(["a.b.42", "e.f.10", "nope"]); expect(removed.sort()).toEqual(["a.b.42", "e.f.10"]); diff --git a/src/domain/feed-repository.ts b/src/domain/feed-repository.ts index 378e355..5f51b96 100644 --- a/src/domain/feed-repository.ts +++ b/src/domain/feed-repository.ts @@ -9,6 +9,7 @@ import { 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 "../lib/logger"; /** @@ -28,27 +29,27 @@ export class FeedRepository { // ── Key schema (delegates to feed-keys) ─────────────────────────────────── - private configKey(feedId: string): string { - return feedKeys.config(feedId); + private configKey(feedId: FeedId): string { + return feedKeys.config(feedId.value); } - private metadataKey(feedId: string): string { - return feedKeys.metadata(feedId); + private metadataKey(feedId: FeedId): string { + return feedKeys.metadata(feedId.value); } /** Prefix covering every key owned by a feed (config, metadata, emails). */ - feedKeyPrefix(feedId: string): string { - return feedKeys.feedPrefix(feedId); + feedKeyPrefix(feedId: FeedId): string { + return feedKeys.feedPrefix(feedId.value); } /** Mint a fresh, time-ordered email key. Call once and reuse the result. */ - newEmailKey(feedId: string): string { - return feedKeys.newEmail(feedId); + newEmailKey(feedId: FeedId): string { + return feedKeys.newEmail(feedId.value); } /** True when `key` is an email entry (not the feed's config/metadata key). */ - isEmailKey(feedId: string, key: string): boolean { - return feedKeys.isEmail(feedId, key); + isEmailKey(feedId: FeedId, key: string): boolean { + return feedKeys.isEmail(feedId.value, key); } /** Recover the feed id embedded in an email key (`feed::`). */ @@ -62,7 +63,7 @@ export class FeedRepository { * Load the aggregate (config + email index). A feed exists iff it has a * config; metadata defaults to empty so a freshly-created feed still loads. */ - async load(feedId: string): Promise { + async load(feedId: FeedId): Promise { const [config, metadata] = await Promise.all([ this.getConfig(feedId), this.getMetadata(feedId), @@ -97,33 +98,33 @@ export class FeedRepository { // ── Feed config ─────────────────────────────────────────────────────────── - async getConfig(feedId: string): Promise { + async getConfig(feedId: FeedId): Promise { return (await this.kv.get(this.configKey(feedId), { type: "json", })) as FeedConfig | null; } - async putConfig(feedId: string, config: FeedConfig): Promise { + async putConfig(feedId: FeedId, config: FeedConfig): Promise { await this.kv.put(this.configKey(feedId), JSON.stringify(config)); } - async deleteConfig(feedId: string): Promise { + async deleteConfig(feedId: FeedId): Promise { await this.kv.delete(this.configKey(feedId)); } // ── Feed metadata ───────────────────────────────────────────────────────── - async getMetadata(feedId: string): Promise { + async getMetadata(feedId: FeedId): Promise { return (await this.kv.get(this.metadataKey(feedId), { type: "json", })) as FeedMetadata | null; } - async putMetadata(feedId: string, metadata: FeedMetadata): Promise { + async putMetadata(feedId: FeedId, metadata: FeedMetadata): Promise { await this.kv.put(this.metadataKey(feedId), JSON.stringify(metadata)); } - async deleteMetadata(feedId: string): Promise { + async deleteMetadata(feedId: FeedId): Promise { await this.kv.delete(this.metadataKey(feedId)); } @@ -156,7 +157,7 @@ export class FeedRepository { } async addToList( - feedId: string, + feedId: FeedId, title: string, description?: string, expires_at?: number, @@ -166,18 +167,18 @@ export class FeedRepository { type: "json", })) as FeedList | null) || { feeds: [] }; - feedList.feeds.push({ id: feedId, title, description, expires_at }); + feedList.feeds.push({ id: feedId.value, title, description, expires_at }); await this.kv.put(FEEDS_LIST_KEY, JSON.stringify(feedList)); } catch (error) { logger.error("Error adding feed to list", { - feedId, + feedId: feedId.value, error: String(error), }); } } async updateInList( - feedId: string, + feedId: FeedId, title: string, description?: string, expires_at?: number, @@ -187,7 +188,9 @@ export class FeedRepository { type: "json", })) as FeedList | null) || { feeds: [] }; - const feedIndex = feedList.feeds.findIndex((feed) => feed.id === feedId); + const feedIndex = feedList.feeds.findIndex( + (feed) => feed.id === feedId.value, + ); if (feedIndex !== -1) { feedList.feeds[feedIndex].title = title; feedList.feeds[feedIndex].description = description; @@ -196,7 +199,7 @@ export class FeedRepository { } } catch (error) { logger.error("Error updating feed in list", { - feedId, + feedId: feedId.value, error: String(error), }); } @@ -233,15 +236,15 @@ export class FeedRepository { } } - async removeFromList(feedId: string): Promise { - const removed = await this.removeFromListBulk([feedId]); - return removed.includes(feedId); + async removeFromList(feedId: FeedId): Promise { + const removed = await this.removeFromListBulk([feedId.value]); + return removed.includes(feedId.value); } // ── Key listing / counting ──────────────────────────────────────────────── async listFeedKeys( - feedId: string, + feedId: FeedId, options: { cursor?: string; limit?: number } = {}, ): Promise<{ names: string[]; cursor: string; listComplete: boolean }> { const prefix = this.feedKeyPrefix(feedId); diff --git a/src/domain/feed.aggregate.test.ts b/src/domain/feed.aggregate.test.ts index 5bbdaf1..6e70795 100644 --- a/src/domain/feed.aggregate.test.ts +++ b/src/domain/feed.aggregate.test.ts @@ -2,8 +2,11 @@ import { describe, it, expect } from "vitest"; import { createMockEnv } from "../test/setup"; import { Feed, CreateFeedInput } from "./feed.aggregate"; import { FeedRepository } from "./feed-repository"; +import { FeedId } from "./value-objects/feed-id"; import type { Env, EmailMetadata } from "../types"; +const FID = FeedId.fromTrusted("a.b.42"); + const mockEnv = (overrides: Partial = {}) => ({ ...createMockEnv(), ...overrides }) as unknown as Env; @@ -27,25 +30,21 @@ const entry = (overrides: Partial = {}): EmailMetadata => ({ describe("Feed.create", () => { it("builds a config with an empty email index and no expiry by default", () => { - const feed = Feed.create("a.b.42", createInput(), mockEnv()); - expect(feed.id).toBe("a.b.42"); + const feed = Feed.create(FID, createInput(), mockEnv()); + expect(feed.id.value).toBe("a.b.42"); expect(feed.config.title).toBe("News"); expect(feed.config.expires_at).toBeUndefined(); expect(feed.metadata.emails).toEqual([]); }); it("resolves expiry from lifetimeHours", () => { - const feed = Feed.create( - "a.b.42", - createInput({ lifetimeHours: 1 }), - mockEnv(), - ); + const feed = Feed.create(FID, createInput({ lifetimeHours: 1 }), mockEnv()); expect(feed.config.expires_at).toBeGreaterThan(Date.now()); }); it("lets FEED_TTL_HOURS override a client lifetime", () => { const feed = Feed.create( - "a.b.42", + FID, createInput({ lifetimeHours: 1000000 }), mockEnv({ FEED_TTL_HOURS: "1" }), ); @@ -57,7 +56,7 @@ describe("Feed.create", () => { describe("Feed.isExpired / accepts", () => { it("reports expiry against the configured instant", () => { const feed = Feed.reconstitute( - "a.b.42", + FID, { title: "T", language: "en", created_at: 0, expires_at: 100 }, { emails: [] }, ); @@ -67,7 +66,7 @@ describe("Feed.isExpired / accepts", () => { it("applies the sender policy", () => { const feed = Feed.reconstitute( - "a.b.42", + FID, { title: "T", language: "en", @@ -84,7 +83,7 @@ describe("Feed.isExpired / accepts", () => { describe("Feed.ingest", () => { it("prepends the entry, tracks icon/unsub and trims to the byte budget", () => { const feed = Feed.reconstitute( - "a.b.42", + FID, { title: "T", language: "en", created_at: 0 }, { emails: [entry({ key: "old", size: 400 })], @@ -110,7 +109,7 @@ describe("Feed.ingest", () => { describe("Feed.removeEmails", () => { it("drops matching keys and returns the removed entries", () => { const feed = Feed.reconstitute( - "a.b.42", + FID, { title: "T", language: "en", created_at: 0 }, { emails: [ @@ -131,20 +130,20 @@ describe("FeedRepository.load / save round-trip", () => { it("persists a created feed and reflects later mutations", async () => { const repo = new FeedRepository(mockEnv().EMAIL_STORAGE); const created = Feed.create( - "a.b.42", + FID, createInput({ title: "Round" }), mockEnv(), ); await repo.save(created); - const loaded = await repo.load("a.b.42"); + const loaded = await repo.load(FID); expect(loaded).not.toBeNull(); expect(loaded!.config.title).toBe("Round"); loaded!.ingest(entry({ key: "feed:a.b.42:1" }), { maxBytes: 1_000_000 }); await repo.saveMetadata(loaded!); - const reloaded = await repo.load("a.b.42"); + const reloaded = await repo.load(FID); expect(reloaded!.metadata.emails.map((e) => e.key)).toEqual([ "feed:a.b.42:1", ]); @@ -152,6 +151,6 @@ describe("FeedRepository.load / save round-trip", () => { it("returns null when the feed has no config", async () => { const repo = new FeedRepository(mockEnv().EMAIL_STORAGE); - expect(await repo.load("missing")).toBeNull(); + expect(await repo.load(FeedId.fromTrusted("missing"))).toBeNull(); }); }); diff --git a/src/domain/feed.aggregate.ts b/src/domain/feed.aggregate.ts index 6db319c..f5d8f14 100644 --- a/src/domain/feed.aggregate.ts +++ b/src/domain/feed.aggregate.ts @@ -1,4 +1,5 @@ import { Env, FeedConfig, FeedMetadata, EmailMetadata } from "../types"; +import { FeedId } from "./value-objects/feed-id"; import { resolveExpiresAt, isExpired, @@ -46,13 +47,13 @@ export interface IngestOptions { */ export class Feed { private constructor( - readonly id: string, + readonly id: FeedId, private _config: FeedConfig, private _metadata: FeedMetadata, ) {} /** Mint a brand-new feed with an empty email index. */ - static create(id: string, input: CreateFeedInput, env: Env): Feed { + static create(id: FeedId, input: CreateFeedInput, env: Env): Feed { const now = Date.now(); const expiresAt = resolveExpiresAt(env, input.lifetimeHours); const config: FeedConfig = { @@ -70,7 +71,7 @@ export class Feed { /** Rebuild an aggregate from persisted state. */ static reconstitute( - id: string, + id: FeedId, config: FeedConfig, metadata: FeedMetadata, ): Feed { diff --git a/src/domain/value-objects/feed-id.ts b/src/domain/value-objects/feed-id.ts index 17f59bc..fcbf14a 100644 --- a/src/domain/value-objects/feed-id.ts +++ b/src/domain/value-objects/feed-id.ts @@ -16,6 +16,15 @@ export class FeedId { return match ? new FeedId(match[1]) : null; } + /** + * Wrap an id we already trust — a value we minted ourselves and round-tripped + * through our own links or KV keys (route params, the feed list, email keys). + * No validation: a wrong id simply misses in KV and 404s, exactly as before. + */ + static fromTrusted(value: string): FeedId { + return new FeedId(value); + } + static generate(): FeedId { const noun1 = nouns[Math.floor(Math.random() * nouns.length)]; const noun2 = nouns[Math.floor(Math.random() * nouns.length)]; diff --git a/src/lib/email-processor.ts b/src/lib/email-processor.ts index 536e07c..462d136 100644 --- a/src/lib/email-processor.ts +++ b/src/lib/email-processor.ts @@ -10,6 +10,7 @@ import { parseOneClickUnsubscribe } from "../utils/unsubscribe"; import { getAttachmentBucket } from "../utils/attachments"; import { FeedRepository } from "../domain/feed-repository"; import { Feed } from "../domain/feed.aggregate"; +import { FeedId } from "../domain/value-objects/feed-id"; import { logger } from "./logger"; import { FEED_MAX_BYTES } from "../config/constants"; @@ -84,7 +85,7 @@ async function loadAcceptingFeed( return { ok: false, reason: "invalid_address" }; } - const feed = await FeedRepository.from(env).load(feedId); + const feed = await FeedRepository.from(env).load(FeedId.fromTrusted(feedId)); if (!feed) { logger.error("Feed not found", { feedId }); return { ok: false, reason: "feed_not_found" }; @@ -182,9 +183,9 @@ async function storeEmail( ...r2Deletions, ]); - logger.info("Email processed", { feedId: feed.id }); + logger.info("Email processed", { feedId: feed.id.value }); if (ctx) { - ctx.waitUntil(notifySubscribers(feed.id, env)); + ctx.waitUntil(notifySubscribers(feed.id.value, env)); if (iconDomain) { ctx.waitUntil(cacheFaviconForDomain(iconDomain, env)); } @@ -207,5 +208,5 @@ export async function processEmail( emails_received: 1, last_email_at: new Date().toISOString(), }); - return { ok: true, feedId: validation.feed.id }; + return { ok: true, feedId: validation.feed.id.value }; } diff --git a/src/lib/feed-service.ts b/src/lib/feed-service.ts index f8d4a3c..9bc18d7 100644 --- a/src/lib/feed-service.ts +++ b/src/lib/feed-service.ts @@ -1,11 +1,11 @@ import { Context } from "hono"; import { Env, FeedConfig } from "../types"; -import { generateFeedId } from "../utils/id-generator"; import { bumpCounters } from "../utils/stats"; import { waitUntilSafe } from "../utils/worker"; import { sendUnsubscribes } from "../utils/unsubscribe"; import { getAttachmentBucket } from "../utils/attachments"; import { FeedRepository } from "../domain/feed-repository"; +import { FeedId } from "../domain/value-objects/feed-id"; import { Feed, CreateFeedInput, @@ -27,7 +27,7 @@ export async function createFeedRecord( input: CreateFeedInput, ): Promise<{ feedId: string; config: FeedConfig }> { const repo = FeedRepository.from(env); - const feed = Feed.create(generateFeedId(), input, env); + const feed = Feed.create(FeedId.generate(), input, env); await repo.save(feed); await repo.addToList( @@ -42,7 +42,7 @@ export async function createFeedRecord( last_feed_created_at: new Date().toISOString(), }); - return { feedId: feed.id, config: feed.config }; + return { feedId: feed.id.value, config: feed.config }; } export type UpdateFeedResult = @@ -60,13 +60,13 @@ export async function renameFeed( patch: { title?: string; description?: string }, ): Promise { const repo = FeedRepository.from(env); - const feed = await repo.load(feedId); + const feed = await repo.load(FeedId.fromTrusted(feedId)); if (!feed) return { status: "not_found" }; feed.rename(patch); await repo.saveConfig(feed); await repo.updateInList( - feedId, + feed.id, feed.config.title, feed.config.description, feed.config.expires_at, @@ -85,7 +85,7 @@ export async function editFeed( input: UpdateFeedInput, ): Promise { const repo = FeedRepository.from(env); - const feed = await repo.load(feedId); + const feed = await repo.load(FeedId.fromTrusted(feedId)); if (!feed) return { status: "not_found" }; if (feed.edit(input, env).status === "expired") { @@ -94,7 +94,7 @@ export async function editFeed( await repo.saveConfig(feed); await repo.updateInList( - feedId, + feed.id, feed.config.title, feed.config.description, feed.config.expires_at, @@ -119,20 +119,21 @@ export async function deleteFeedFastDetailed( feedId: string, ): Promise { const repo = new FeedRepository(emailStorage); + const id = FeedId.fromTrusted(feedId); const errors: string[] = []; let configDeleted = false; let metadataDeleted = false; try { - await repo.deleteConfig(feedId); + await repo.deleteConfig(id); configDeleted = true; } catch (error) { errors.push(`config delete failed: ${String(error)}`); } try { - await repo.deleteMetadata(feedId); + await repo.deleteMetadata(id); metadataDeleted = true; } catch (error) { errors.push(`metadata delete failed: ${String(error)}`); @@ -159,7 +160,7 @@ export async function deleteFeedRecord( const unsubscribeUrls = await collectUnsubscribeUrls(emailStorage, feedId); await deleteFeedFastDetailed(emailStorage, feedId); - const removed = await repo.removeFromList(feedId); + const removed = await repo.removeFromList(FeedId.fromTrusted(feedId)); if (removed) { await bumpCounters(emailStorage, { feeds_deleted: 1 }); } diff --git a/src/routes/admin/emails.tsx b/src/routes/admin/emails.tsx index 9649770..c5ba2cc 100644 --- a/src/routes/admin/emails.tsx +++ b/src/routes/admin/emails.tsx @@ -7,6 +7,7 @@ import { deleteKeysWithConcurrency, } from "./helpers"; import { FeedRepository } from "../../domain/feed-repository"; +import { FeedId } from "../../domain/value-objects/feed-id"; import { feedRssUrl, feedAtomUrl, feedEmailAddress } from "../../utils/urls"; import { formatBytes } from "../../utils/format"; import { EmailAddress } from "../../domain/value-objects/email-address"; @@ -153,8 +154,9 @@ emailsRouter.get("/feeds/:feedId/emails", async (c) => { const message = c.req.query("message"); const count = Number(c.req.query("count") || "0"); - const feedConfig = await repo.getConfig(feedId); - const feedMetadata = await repo.getMetadata(feedId); + const id = FeedId.fromTrusted(feedId); + const feedConfig = await repo.getConfig(id); + const feedMetadata = await repo.getMetadata(id); if (!feedConfig || !feedMetadata) { return c.text("Feed not found", 404); @@ -650,7 +652,7 @@ emailsRouter.post("/emails/:emailKey/delete", async (c) => { return c.text("Feed ID is required", 400); } - const feed = await repo.load(feedId); + const feed = await repo.load(FeedId.fromTrusted(feedId)); await repo.deleteEmail(emailKey); if (feed) { @@ -685,7 +687,7 @@ emailsRouter.post("/feeds/:feedId/emails/bulk-delete", async (c) => { (c.req.header("Accept") || "").includes("application/json"); try { - const feed = await repo.load(feedId); + const feed = await repo.load(FeedId.fromTrusted(feedId)); if (!feed) { return wantsJson diff --git a/src/routes/admin/feeds.tsx b/src/routes/admin/feeds.tsx index b0cc686..3e2bc23 100644 --- a/src/routes/admin/feeds.tsx +++ b/src/routes/admin/feeds.tsx @@ -10,6 +10,7 @@ import { getAttachmentBucket } from "../../utils/attachments"; import { Layout } from "./ui"; import { purgeFeedKeysStep, collectUnsubscribeUrls } from "./helpers"; import { FeedRepository } from "../../domain/feed-repository"; +import { FeedId } from "../../domain/value-objects/feed-id"; import { createFeedRecord, editFeed, @@ -148,7 +149,9 @@ feedsRouter.get("/:feedId/edit", async (c) => { const env = c.env; const feedId = c.req.param("feedId"); - const feedConfig = await FeedRepository.from(env).getConfig(feedId); + const feedConfig = await FeedRepository.from(env).getConfig( + FeedId.fromTrusted(feedId), + ); if (!feedConfig) { return c.text("Feed not found", 404); @@ -359,6 +362,7 @@ feedsRouter.post("/:feedId/edit", async (c) => { feedsRouter.post("/:feedId/sender-filter", async (c) => { const env = c.env; const feedId = c.req.param("feedId"); + const id = FeedId.fromTrusted(feedId); const repo = FeedRepository.from(env); const body = await c.req.json().catch(() => null); @@ -370,7 +374,7 @@ feedsRouter.post("/:feedId/sender-filter", async (c) => { const { action, value } = parsed.data; const normalized = value.trim().toLowerCase(); - const feedConfig = await repo.getConfig(feedId); + const feedConfig = await repo.getConfig(id); if (!feedConfig) return c.json({ ok: false, error: "Feed not found" }, 404); const allowedSenders = (feedConfig.allowed_senders || []).map((s) => @@ -397,7 +401,7 @@ feedsRouter.post("/:feedId/sender-filter", async (c) => { if (!targetList.includes(normalized)) { targetList.push(normalized); - await repo.putConfig(feedId, { + await repo.putConfig(id, { ...feedConfig, allowed_senders: allowedSenders, blocked_senders: blockedSenders, diff --git a/src/routes/admin/helpers.ts b/src/routes/admin/helpers.ts index 0cf9296..bbab567 100644 --- a/src/routes/admin/helpers.ts +++ b/src/routes/admin/helpers.ts @@ -2,6 +2,7 @@ import { EmailData, EmailMetadata, Env } from "../../types"; import { logger } from "../../lib/logger"; import { getAttachmentBucket } from "../../utils/attachments"; import { FeedRepository } from "../../domain/feed-repository"; +import { FeedId } from "../../domain/value-objects/feed-id"; // Delete the R2 attachments belonging to the given email keys. Call before the // emails are removed from feed metadata, while `emails` still carries their @@ -60,7 +61,9 @@ export async function collectUnsubscribeUrls( feedId: string, ): Promise { try { - const metadata = await new FeedRepository(emailStorage).getMetadata(feedId); + const metadata = await new FeedRepository(emailStorage).getMetadata( + FeedId.fromTrusted(feedId), + ); return Object.values(metadata?.unsubscribe ?? {}); } catch (error) { logger.error("Error reading unsubscribe URLs", { @@ -82,14 +85,15 @@ export async function purgeFeedKeysStep( listComplete: boolean; }> { const repo = new FeedRepository(emailStorage); - const listed = await repo.listFeedKeys(feedId, { + const id = FeedId.fromTrusted(feedId); + const listed = await repo.listFeedKeys(id, { cursor: options.cursor, limit: options.limit, }); const keys = listed.names; if (options.bucket && keys.length > 0) { - const emailKeys = keys.filter((k) => repo.isEmailKey(feedId, k)); + const emailKeys = keys.filter((k) => repo.isEmailKey(id, k)); if (emailKeys.length > 0) { const emailDataResults = await Promise.allSettled( emailKeys.map((k) => repo.getEmail(k)), diff --git a/src/routes/api/index.ts b/src/routes/api/index.ts index 390fa42..1ee1a34 100644 --- a/src/routes/api/index.ts +++ b/src/routes/api/index.ts @@ -10,6 +10,7 @@ import { } from "../../lib/feed-service"; import { deleteAttachmentsForEmails } from "../admin/helpers"; import { FeedRepository } from "../../domain/feed-repository"; +import { FeedId } from "../../domain/value-objects/feed-id"; import { getStats } from "../../utils/stats"; import { feedEmailAddress, feedRssUrl, feedAtomUrl } from "../../utils/urls"; import { @@ -170,9 +171,10 @@ apiApp.openapi( const env = c.env; const { feedId } = c.req.valid("param"); const repo = FeedRepository.from(env); - const config = await repo.getConfig(feedId); + const id = FeedId.fromTrusted(feedId); + const config = await repo.getConfig(id); if (!config) return c.json({ error: "Feed not found" }, 404); - const metadata = await repo.getMetadata(feedId); + const metadata = await repo.getMetadata(id); return c.json( toFeed(feedId, config, metadata?.emails.length ?? 0, env), 200, @@ -215,7 +217,9 @@ apiApp.openapi( 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 FeedRepository.from(env).getMetadata(feedId); + const metadata = await FeedRepository.from(env).getMetadata( + FeedId.fromTrusted(feedId), + ); return c.json( toFeed(feedId, result.config, metadata?.emails.length ?? 0, env), 200, @@ -265,7 +269,9 @@ apiApp.openapi( async (c) => { const env = c.env; const { feedId } = c.req.valid("param"); - const metadata = await FeedRepository.from(env).getMetadata(feedId); + const metadata = await FeedRepository.from(env).getMetadata( + FeedId.fromTrusted(feedId), + ); if (!metadata) return c.json({ error: "Feed not found" }, 404); return c.json( { @@ -301,7 +307,7 @@ apiApp.openapi( const { feedId, entryId } = c.req.valid("param"); const receivedAt = parseInt(entryId, 10); const repo = FeedRepository.from(env); - const metadata = await repo.getMetadata(feedId); + const metadata = await repo.getMetadata(FeedId.fromTrusted(feedId)); const metaEntry = metadata?.emails.find((e) => e.receivedAt === receivedAt); if (!metaEntry) return c.json({ error: "Email not found" }, 404); const data = await repo.getEmail(metaEntry.key); @@ -345,7 +351,7 @@ apiApp.openapi( const repo = FeedRepository.from(env); const { feedId, entryId } = c.req.valid("param"); const receivedAt = parseInt(entryId, 10); - const feed = await repo.load(feedId); + const feed = await repo.load(FeedId.fromTrusted(feedId)); const metaEntry = feed?.metadata.emails.find( (e) => e.receivedAt === receivedAt, ); diff --git a/src/routes/entries.ts b/src/routes/entries.ts index 98f908d..2db4302 100644 --- a/src/routes/entries.ts +++ b/src/routes/entries.ts @@ -4,6 +4,7 @@ import { Env } from "../types"; import { processEmailContent } from "../utils/html-processor"; import { formatBytes } from "../utils/format"; import { FeedRepository } from "../domain/feed-repository"; +import { FeedId } from "../domain/value-objects/feed-id"; import { isExpired } from "../domain/feed"; export async function handle(c: Context<{ Bindings: Env }>): Promise { @@ -15,10 +16,11 @@ export async function handle(c: Context<{ Bindings: Env }>): Promise { } const repo = FeedRepository.from(c.env); + const id = FeedId.fromTrusted(feedId); const [feedMetadata, feedConfig] = await Promise.all([ - repo.getMetadata(feedId), - repo.getConfig(feedId), + repo.getMetadata(id), + repo.getConfig(id), ]); if (!feedMetadata) { return new Response("Feed not found", { status: 404 }); diff --git a/src/routes/favicon.ts b/src/routes/favicon.ts index 2357468..6dbc369 100644 --- a/src/routes/favicon.ts +++ b/src/routes/favicon.ts @@ -1,6 +1,7 @@ import { Context } from "hono"; import { Env } from "../types"; import { FeedRepository } from "../domain/feed-repository"; +import { FeedId } from "../domain/value-objects/feed-id"; import { cacheFaviconForDomain, getCachedIcon } from "../utils/favicon-fetcher"; export const FAVICON_PATH = "/favicon.svg"; @@ -40,7 +41,9 @@ export async function handleFeedFavicon( const feedId = c.req.param("feedId"); if (!feedId) return projectFavicon(); - const metadata = await FeedRepository.from(env).getMetadata(feedId); + const metadata = await FeedRepository.from(env).getMetadata( + FeedId.fromTrusted(feedId), + ); const domain = metadata?.iconDomain; if (!domain) return projectFavicon(); diff --git a/src/routes/hub.ts b/src/routes/hub.ts index 76c0e1f..7f5429f 100644 --- a/src/routes/hub.ts +++ b/src/routes/hub.ts @@ -8,6 +8,7 @@ import { waitUntilSafe } from "../utils/worker"; import { DEFAULT_LEASE_SECONDS, MAX_LEASE_SECONDS } from "../config/constants"; import { feedTopicPattern } from "../utils/urls"; import { FeedRepository } from "../domain/feed-repository"; +import { FeedId } from "../domain/value-objects/feed-id"; type AppEnv = { Bindings: Env }; @@ -74,7 +75,9 @@ hubRouter.post("/", async (c) => { const feedId = match[2]; // Verify the feed exists before accepting any subscription - const feedConfig = await FeedRepository.from(env).getConfig(feedId); + const feedConfig = await FeedRepository.from(env).getConfig( + FeedId.fromTrusted(feedId), + ); if (!feedConfig) { return c.text("Not Found: feed does not exist", 404); } diff --git a/src/utils/feed-fetcher.ts b/src/utils/feed-fetcher.ts index 2494401..8cc61d6 100644 --- a/src/utils/feed-fetcher.ts +++ b/src/utils/feed-fetcher.ts @@ -1,6 +1,7 @@ import { Env, FeedConfig, EmailData } from "../types"; import { MAX_FEED_ITEMS } from "../config/constants"; import { FeedRepository } from "../domain/feed-repository"; +import { FeedId } from "../domain/value-objects/feed-id"; export interface FeedData { feedConfig: FeedConfig; @@ -12,11 +13,12 @@ export async function fetchFeedData( env: Env, ): Promise { const repo = FeedRepository.from(env); + const id = FeedId.fromTrusted(feedId); - const feedMetadata = await repo.getMetadata(feedId); + const feedMetadata = await repo.getMetadata(id); if (!feedMetadata) return null; - const feedConfig = (await repo.getConfig(feedId)) ?? { + const feedConfig = (await repo.getConfig(id)) ?? { title: `Newsletter Feed ${feedId}`, description: "Converted email newsletter", language: "en", diff --git a/src/utils/id-generator.ts b/src/utils/id-generator.ts deleted file mode 100644 index a52b78f..0000000 --- a/src/utils/id-generator.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { FeedId } from "../domain/value-objects/feed-id"; - -/** - * Generates a random feed ID in the format noun1.noun2.XY - * @returns A random feed ID string - */ -export function generateFeedId(): string { - return FeedId.generate().value; -} diff --git a/src/utils/stats.ts b/src/utils/stats.ts index cb25f5d..aa04384 100644 --- a/src/utils/stats.ts +++ b/src/utils/stats.ts @@ -3,6 +3,7 @@ import { logger } from "../lib/logger"; import { FeedRepository } from "../domain/feed-repository"; import { CountersRepository } from "../domain/counters-repository"; import { WebSubSubscriptionRepository } from "../domain/websub-subscription-repository"; +import { FeedId } from "../domain/value-objects/feed-id"; import { getAttachmentBucket } from "./attachments"; const EMPTY_COUNTERS: Counters = { @@ -109,7 +110,7 @@ export async function scanKvUsage(kv: KVNamespace): Promise<{ bytes: number }> { const repo = new FeedRepository(kv); const feeds = await repo.listFeeds(); for (const feed of feeds) { - const metadata = await repo.getMetadata(feed.id); + const metadata = await repo.getMetadata(FeedId.fromTrusted(feed.id)); if (!metadata) continue; for (const email of metadata.emails) { bytes += email.size ?? 0; diff --git a/src/utils/websub.ts b/src/utils/websub.ts index 4419fb1..a5cb022 100644 --- a/src/utils/websub.ts +++ b/src/utils/websub.ts @@ -3,6 +3,7 @@ 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 { FeedId } from "../domain/value-objects/feed-id"; export async function getSubscriptions( feedId: string, @@ -47,9 +48,10 @@ async function buildFeedXml( format: "rss" | "atom" = "rss", ): Promise { const repo = FeedRepository.from(env); + const id = FeedId.fromTrusted(feedId); const [feedMetadata, rawConfig] = await Promise.all([ - repo.getMetadata(feedId), - repo.getConfig(feedId), + repo.getMetadata(id), + repo.getConfig(id), ]); if (!feedMetadata) return null;