import type { SqlExecutor } from "./storage.ts"; import type { AddressRow, OwnerRef } from "./types.ts"; const ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789"; const LOCAL_PART_LENGTH = 10; const MAX_CREATE_ATTEMPTS = 5; // Addresses must not be predictable: users can mint addresses and see the // results, and Math.random()'s PRNG state is recoverable from observed // output, which would let one user guess another's addresses. Uses rejection // sampling: bytes at or above the largest multiple of the alphabet length // are discarded rather than folded in with %, which would bias the low // characters of the alphabet. const REJECTION_LIMIT = 256 - (256 % ALPHABET.length); // Exported since other receivers (mail.tm) need their own random strings // (a local part, an account password) with the same guarantee. export function randomAlphanumeric(length: number): string { let out = ""; while (out.length < length) { const bytes = new Uint8Array(length); crypto.getRandomValues(bytes); for (const byte of bytes) { if (byte >= REJECTION_LIMIT) { continue; } out += ALPHABET[byte % ALPHABET.length]; if (out.length === length) { break; } } } return out; } export async function createAddress( db: SqlExecutor, owner: OwnerRef, domain: string, ttlSeconds: number ): Promise { const now = Math.floor(Date.now() / 1000); const expiresAt = now + ttlSeconds; for (let attempt = 0; attempt < MAX_CREATE_ATTEMPTS; attempt++) { const address = `${randomAlphanumeric(LOCAL_PART_LENGTH)}@${domain}`; const result = await db.run( `INSERT INTO addresses (address, owner_type, owner_id, created_at, expires_at, revoked) VALUES (?, ?, ?, ?, ?, 0) ON CONFLICT(address) DO NOTHING`, address, owner.type, owner.id, now, expiresAt ); if (result.changes > 0) { return address; } } throw new Error("failed to allocate a unique address after several attempts"); } // For receivers that provision the address themselves (mail.tm calls their // API and gets an address back, rather than inventing a local part on a // domain we own) and just need it persisted. receiverData is opaque to core, // see schema.sql for what it's for. export async function registerAddress( db: SqlExecutor, address: string, owner: OwnerRef, ttlSeconds: number, receiverData: string ): Promise { const now = Math.floor(Date.now() / 1000); const expiresAt = now + ttlSeconds; await db.run( `INSERT INTO addresses (address, owner_type, owner_id, created_at, expires_at, revoked, receiver_data) VALUES (?, ?, ?, ?, ?, 0, ?)`, address, owner.type, owner.id, now, expiresAt, receiverData ); } export async function getAddress(db: SqlExecutor, address: string): Promise { return db.first(`SELECT * FROM addresses WHERE address = ?`, address); } export async function listActiveAddresses(db: SqlExecutor, owner: OwnerRef): Promise { const now = Math.floor(Date.now() / 1000); return db.all( `SELECT * FROM addresses WHERE owner_type = ? AND owner_id = ? AND revoked = 0 AND expires_at > ? ORDER BY expires_at ASC`, owner.type, owner.id, now ); } // Every active address that has receiver-specific data attached, regardless // of which receiver set it. Used by a receiver's own poller/cleanup to find // the rows it's responsible for; core has no idea what's inside the column, // callers filter by whatever shape they expect. export async function listActiveAddressesWithReceiverData(db: SqlExecutor): Promise { const now = Math.floor(Date.now() / 1000); return db.all( `SELECT * FROM addresses WHERE receiver_data IS NOT NULL AND revoked = 0 AND expires_at > ?`, now ); } // Same rows deleteExpiredAndRevoked would remove, but as a read. A receiver // that needs to clean up external state (mail.tm deleting the account on // its side) has to know which rows are about to go before they're gone. export async function listExpiredAndRevoked(db: SqlExecutor, graceSeconds: number): Promise { const now = Math.floor(Date.now() / 1000); return db.all( `SELECT * FROM addresses WHERE (revoked = 1 AND COALESCE(revoked_at, expires_at) + ? <= ?) OR (expires_at + ? <= ?)`, graceSeconds, now, graceSeconds, now ); } export async function countActiveAddresses(db: SqlExecutor, owner: OwnerRef): Promise { const now = Math.floor(Date.now() / 1000); const row = await db.first<{ count: number }>( `SELECT COUNT(*) as count FROM addresses WHERE owner_type = ? AND owner_id = ? AND revoked = 0 AND expires_at > ?`, owner.type, owner.id, now ); return row?.count ?? 0; } export async function extendAddress( db: SqlExecutor, owner: OwnerRef, address: string, ttlSeconds: number ): Promise { const newExpiresAt = Math.floor(Date.now() / 1000) + ttlSeconds; const result = await db.run( `UPDATE addresses SET expires_at = ? WHERE address = ? AND owner_type = ? AND owner_id = ? AND revoked = 0`, newExpiresAt, address, owner.type, owner.id ); return result.changes > 0; } export async function revokeAddress(db: SqlExecutor, owner: OwnerRef, address: string): Promise { const now = Math.floor(Date.now() / 1000); const result = await db.run( `UPDATE addresses SET revoked = 1, revoked_at = ? WHERE address = ? AND owner_type = ? AND owner_id = ? AND revoked = 0`, now, address, owner.type, owner.id ); return result.changes > 0; } // Expired rows are removed once they are `graceSeconds` past expiry; revoked // rows once they are `graceSeconds` past the moment they were revoked, rather // than waiting out their original (possibly week-long) expiry. Rows revoked // before revoked_at existed have no timestamp, so they fall back to expiry. export async function deleteExpiredAndRevoked(db: SqlExecutor, graceSeconds: number): Promise { const now = Math.floor(Date.now() / 1000); const result = await db.run( `DELETE FROM addresses WHERE (revoked = 1 AND COALESCE(revoked_at, expires_at) + ? <= ?) OR (expires_at + ? <= ?)`, graceSeconds, now, graceSeconds, now ); return result.changes; } // Rate-limit rows are keyed by (owner, action) so the table is bounded by // user count rather than traffic, but rows for users who stop using the bot // would otherwise persist forever. Anything whose window closed long ago is // inert. Dropping it is equivalent to the row never having existed. export async function deleteStaleRateLimits(db: SqlExecutor, olderThanSeconds: number): Promise { const cutoff = Math.floor(Date.now() / 1000) - olderThanSeconds; const result = await db.run(`DELETE FROM rate_limits WHERE window_start <= ?`, cutoff); return result.changes; }