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 <[email protected]>
45 lines
1.5 KiB
TypeScript
45 lines
1.5 KiB
TypeScript
/** Encode bytes as unpadded base64url (URL- and KV-key-safe). */
|
|
function base64url(bytes: Uint8Array): string {
|
|
let binary = "";
|
|
for (const b of bytes) binary += String.fromCharCode(b);
|
|
return btoa(binary)
|
|
.replace(/\+/g, "-")
|
|
.replace(/\//g, "_")
|
|
.replace(/=+$/, "");
|
|
}
|
|
|
|
/**
|
|
* A feed's identity: the KV storage key AND the public read id in `/rss/:feedId`
|
|
* etc. It is an opaque, high-entropy random token — unguessable, so a feed's read
|
|
* URL can be shared without revealing its inbound address (the `MailboxId`, a
|
|
* separate value that resolves here via the `inbound:` index at reception).
|
|
*
|
|
* `generate` mints a fresh token; `unchecked` wraps a route param or stored key
|
|
* without revalidation (a wrong id simply misses in KV and 404s downstream).
|
|
*/
|
|
export class FeedId {
|
|
private constructor(readonly value: string) {}
|
|
|
|
/**
|
|
* Wrap a string as a FeedId WITHOUT revalidating it. The caller asserts the id
|
|
* originated from our own minting — a route param echoing a stored id, a
|
|
* `feeds:list` entry, an `inbound:` index value, or a KV key. The name is
|
|
* deliberately blunt: a wrong id is not rejected here, it simply misses in KV
|
|
* and 404s downstream.
|
|
*/
|
|
static unchecked(value: string): FeedId {
|
|
return new FeedId(value);
|
|
}
|
|
|
|
/** Mint a fresh, opaque identity (128 bits of entropy → 22 base64url chars). */
|
|
static generate(): FeedId {
|
|
const bytes = new Uint8Array(16);
|
|
crypto.getRandomValues(bytes);
|
|
return new FeedId(base64url(bytes));
|
|
}
|
|
|
|
toString(): string {
|
|
return this.value;
|
|
}
|
|
}
|