mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-21 06:13:48 +00:00
feat: decouple read FeedId from inbound MailboxId
Separate the two feed identities so the public read URL never reveals the inbound address and vice-versa: - FeedId becomes an opaque high-entropy token (read id + KV key); MailboxId (noun.noun.NN) owns the inbound address and the untrusted-input boundary via MailboxId.parse. They map only through the inbound:<mailbox> secondary index, resolved solely at reception. - inbound index lifecycle is owned by FeedRepository: written by save/saveConfig, dropped by removeFromList(Bulk) — symmetric, never mirrored by hand (removes the manual delete in feed-service + the cron loop, and a silent empty-catch). - Feed.mailboxId exposes a MailboxId VO (symmetry with Feed.id); the mailbox@domain shape lives on MailboxId.emailAddress(domain). - Distinguish mailbox_unknown (no feed claims the address) from feed_not_found (dangling index) for observability; both forwardable, both 404. - Drop the redundant EmailParser.extractMailbox pass-through so MailboxId.parse is the single parse boundary. Docs (README/INSTALL/CLAUDE.md/landing) and tests updated; 439 tests green, tsc clean, build dry-run OK. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ import { FEEDS_LIST_KEY } from "../config/constants";
|
||||
import { feedKeys } from "../domain/feed-keys";
|
||||
import { Feed } from "../domain/feed.aggregate";
|
||||
import { FeedId } from "../domain/value-objects/feed-id";
|
||||
import { MailboxId } from "../domain/value-objects/mailbox-id";
|
||||
import { fromConfigDTO, toConfigDTO, toListItemDTO } from "./feed-mapper";
|
||||
import { logger } from "./logger";
|
||||
|
||||
@@ -87,6 +88,7 @@ export class FeedRepository {
|
||||
this.putConfig(feed.id, toConfigDTO(feed.state())),
|
||||
this.putMetadata(feed.id, feed.toMetadataSnapshot()),
|
||||
this.upsertListEntry(toListItemDTO(feed.id, feed.state())),
|
||||
this.putInboundIndex(feed.mailboxId, feed.id),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -108,9 +110,31 @@ export class FeedRepository {
|
||||
await Promise.all([
|
||||
this.putConfig(feed.id, toConfigDTO(feed.state())),
|
||||
this.upsertListEntry(toListItemDTO(feed.id, feed.state())),
|
||||
this.putInboundIndex(feed.mailboxId, feed.id),
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Inbound mailbox index ─────────────────────────────────────────────────
|
||||
// Secondary index mapping the friendly inbound address (`noun.noun.NN`) to the
|
||||
// feed's opaque id. Resolved only at reception (the write edge), so the public
|
||||
// read id and the inbound address stay decoupled.
|
||||
|
||||
/** Resolve an inbound mailbox to its feed id, or null when no feed claims it. */
|
||||
async resolveInbound(mailboxId: MailboxId): Promise<FeedId | null> {
|
||||
const feedId = await this.kv.get(feedKeys.inbound(mailboxId.value), {
|
||||
type: "text",
|
||||
});
|
||||
return feedId ? FeedId.unchecked(feedId) : null;
|
||||
}
|
||||
|
||||
async putInboundIndex(mailboxId: MailboxId, feedId: FeedId): Promise<void> {
|
||||
await this.kv.put(feedKeys.inbound(mailboxId.value), feedId.value);
|
||||
}
|
||||
|
||||
async deleteInboundIndex(mailboxId: MailboxId): Promise<void> {
|
||||
await this.kv.delete(feedKeys.inbound(mailboxId.value));
|
||||
}
|
||||
|
||||
// ── Feed config ───────────────────────────────────────────────────────────
|
||||
|
||||
async getConfig(feedId: FeedId): Promise<FeedConfig | null> {
|
||||
@@ -209,11 +233,13 @@ export class FeedRepository {
|
||||
if (toRemove.size === 0) return [];
|
||||
|
||||
const removed: string[] = [];
|
||||
const droppedMailboxes: string[] = [];
|
||||
const nextFeeds: FeedListItem[] = [];
|
||||
|
||||
for (const feed of feedList.feeds) {
|
||||
if (toRemove.has(feed.id)) {
|
||||
removed.push(feed.id);
|
||||
if (feed.mailbox_id) droppedMailboxes.push(feed.mailbox_id);
|
||||
continue;
|
||||
}
|
||||
nextFeeds.push(feed);
|
||||
@@ -223,6 +249,17 @@ export class FeedRepository {
|
||||
|
||||
feedList.feeds = nextFeeds;
|
||||
await this.kv.put(FEEDS_LIST_KEY, JSON.stringify(feedList));
|
||||
|
||||
// Drop each removed feed's inbound index — symmetric with save() writing
|
||||
// it. The index lives outside the feed:<id>: prefix the key purge sweeps,
|
||||
// so a deleted feed's address would keep resolving if left behind. The
|
||||
// mailbox is cached on the list item we just removed.
|
||||
await Promise.all(
|
||||
droppedMailboxes.map((mailbox) =>
|
||||
this.deleteInboundIndex(MailboxId.unchecked(mailbox)),
|
||||
),
|
||||
);
|
||||
|
||||
return removed;
|
||||
} catch (error) {
|
||||
logger.error("Error removing feeds from list", { error: String(error) });
|
||||
|
||||
Reference in New Issue
Block a user