/**
* Public-plane mailbox room derivations (docs/PROTOCOL.md §2). Pure functions:
* every room id is HKDF-SHA256 over PUBLIC data (a profile's raw pub bytes, or
* a feed shard string), salt = APP_SALT, rendered as unpadded b64url. This is
* deliberate obfuscation-free design — anyone holding the pub can derive the
* same rooms, which is exactly the point (§1: keys derivable from public data
* would be obfuscation, so the public plane doesn't pretend to encrypt).
*
* Separate rooms per concern on purpose: comment floods must not evict
* profile docs (§2).
*/
import { APP_SALT } from "../config";
import { fromB64url, toB64url, type Bytes } from "../lib/crypto";
const utf8 = (s: string): Bytes => new TextEncoder().encode(s) as Bytes;
async function derive(ikm: Bytes, info: string): Promise<string> {
const key = await crypto.subtle.importKey("raw", ikm as BufferSource, "HKDF", false, ["deriveBits"]);
const bits = await crypto.subtle.deriveBits(
{ name: "HKDF", hash: "SHA-256", salt: utf8(APP_SALT) as BufferSource, info: utf8(info) as BufferSource },
key,
256,
);
return toB64url(new Uint8Array(bits));
}
/** ikm = the 32 raw bytes of a 43-char b64url Ed25519 pub. Throws on garbage. */
function rawPubBytes(pub: string): Bytes {
const raw = fromB64url(pub);
if (raw.length !== 32) throw new Error("bad pub");
return raw;
}
/** profileRoom(P) — the owner's profile + mod docs + avatar blocks. */
export function profileRoom(pub: string): Promise<string> {
return derive(rawPubBytes(pub), "cp/mbx/profile|v1");
}
/** commentsRoom(P) — comments ON P's profile (anyone deposits). */
export function commentsRoom(pub: string): Promise<string> {
return derive(rawPubBytes(pub), "cp/mbx/comments|v1");
}
/** inboxRoom(P) — contact requests TO P (creq) and acks TO P (cack). */
export function inboxRoom(pub: string): Promise<string> {
return derive(rawPubBytes(pub), "cp/mbx/inbox|v1");
}
/** feedRoom(shard, stripe) — M5 posts; stripe ∈ 0..3. */
export function feedRoom(shard: string, stripe: number): Promise<string> {
return derive(utf8(`shard|${shard}|s${stripe}`), "cp/mbx/feed|v1");
}
/** feedTopic(shard) — the live gossipsub announce topic for one shard (M5). */
export async function feedTopic(shard: string): Promise<string> {
return "cp/1/feed/" + (await derive(utf8(`shard|${shard}`), "cp/topic/feed|v1"));
}
/** stripe(author) = rawPubBytes(author)[0] & 0x03 — which feed stripe P posts to. */
export function stripe(pub: string): number {
return rawPubBytes(pub)[0] & 0x03;
}
/**
* poolRoom(pool) — matching cards for one pool (P2). pool = "<category>|<county>"
* (the same open-vocabulary slug shape as feed shards, §2). ONE room per pool, no striping:
* cards are tiny, LWW-collapsed to at most one per author, and browse walks
* backward under the same page budget — stripes would buy nothing at v1 scale.
*/
export function poolRoom(pool: string): Promise<string> {
return derive(utf8(`pool|${pool}`), "cp/mbx/pool|v1");
}