diff --git a/apps/pwa/.env.example b/apps/pwa/.env.example index ce25b72..b00178d 100644 --- a/apps/pwa/.env.example +++ b/apps/pwa/.env.example @@ -40,3 +40,13 @@ SLACK_WEBHOOK_URL= # it never invents an address for someone to paste into their DNS settings. # MOSHPIT_DNS_RESOLVERS=dns1.pit.moshcode.sh=203.0.113.7,dns2.pit.moshcode.sh=203.0.113.8 # MOSHPIT_DOH_URL=https://dns.pit.moshcode.sh/dns-query + +# Guard addresses: the forwarding address a name publishes instead of its +# holder's own — see docs/contact-and-guard-addresses.md for the DNS half. +# +# Unset, contacts are still recorded and simply never published, which is the +# right behaviour in development: no key means no alias, and no alias means no +# address on a page. The domain must be one this Forward Email account holds, +# with MX pointed at them, or every mint fails. +FORWARDEMAIL_API_KEY= +MOSHPIT_GUARD_DOMAIN=moshcode.sh diff --git a/apps/pwa/src/config.mjs b/apps/pwa/src/config.mjs index 65ee41d..75a3b4e 100644 --- a/apps/pwa/src/config.mjs +++ b/apps/pwa/src/config.mjs @@ -69,6 +69,18 @@ export const config = { apiKey: process.env.RESEND_API_KEY || "", from: process.env.RESEND_FROM || "moshcode ", }, + // The mail host behind a name's guard address: `@moshcode.sh` forwards + // to whatever the holder reads, so the real address is never published. + // + // The domain is separate from `origin` and `pitOrigin` on purpose. Those two + // are where the registry answers HTTP; this is where it answers mail, and the + // two need not be the same host -- moving the pit to another origin must not + // silently invalidate every contact address already printed on a page. + forwardEmail: { + apiKey: process.env.FORWARDEMAIL_API_KEY || "", + apiBase: (process.env.FORWARDEMAIL_API_BASE || "https://api.forwardemail.net").replace(/\/+$/, ""), + domain: (process.env.MOSHPIT_GUARD_DOMAIN || "moshcode.sh").trim().toLowerCase(), + }, push: { vapidPublic: process.env.VAPID_PUBLIC || "", vapidPrivate: process.env.VAPID_PRIVATE || "", @@ -89,6 +101,15 @@ export const config = { scope: process.env.COINPAY_OAUTH_SCOPE || "openid profile", }, }, + /** + * Whether a guard address can be minted right now. + * + * False is a working state, not a broken one: contacts are still recorded, + * they are simply not published until there is a mail host to forward them. + */ + get guardMailEnabled() { + return Boolean(this.forwardEmail.apiKey && this.forwardEmail.domain); + }, get coinpayLoginEnabled() { return Boolean(this.coinpay.oauth.authorizeUrl && this.coinpay.oauth.clientId); }, diff --git a/apps/pwa/src/lib/forwardemail.mjs b/apps/pwa/src/lib/forwardemail.mjs new file mode 100644 index 0000000..07a5139 --- /dev/null +++ b/apps/pwa/src/lib/forwardemail.mjs @@ -0,0 +1,142 @@ +// The mail host behind a guard address. +// +// A guard address only works if something actually receives mail at +// `@moshcode.sh` and forwards it on. That is Forward Email, which is +// already where profullstack.com's mail lives, and which exposes alias +// management over a plain REST API -- so an alias can be minted the moment a +// holder opts in rather than by hand. +// +// This module knows nothing about names, endings or the database. It creates, +// disables and destroys aliases at a domain, and reports what the host said. +// src/moshpit.mjs decides when to call it and records the outcome. +// +// Every function is safe to call when the API key is missing: it returns a +// `skipped` result rather than throwing, and the caller leaves the contact in +// `pending`, which publishes nothing. That is what development looks like, and +// it is also what production looks like for the window between this shipping +// and the key being set -- in both cases a contact is recorded and simply not +// advertised yet. +import { config } from "../config.mjs"; + +/** Forward Email authenticates with the API key as the basic-auth username and no password. */ +const authHeader = (apiKey) => `Basic ${Buffer.from(`${apiKey}:`).toString("base64")}`; + +/** + * Ten seconds, then give up. + * + * An opt-in happens inside a form post the holder is waiting on, so this cannot + * hang the request. Losing the race is not losing the work: the contact row is + * already written as `pending` before the host is called, and the reconcile + * path picks it up. + */ +const TIMEOUT_MS = 10_000; + +const enabled = () => Boolean(config.forwardEmail.apiKey && config.forwardEmail.domain); + +/** + * One call to the host, with the failure modes flattened into a result. + * + * Network errors, timeouts and HTTP errors all come back the same shape, + * because the caller does the same thing with all three -- record why, publish + * nothing, allow a retry. An exception here would abort a form post that has + * already successfully saved the holder's address. + */ +async function call(method, path, body) { + if (!enabled()) return { ok: false, skipped: true, error: "mail host not configured" }; + const url = `${config.forwardEmail.apiBase}${path}`; + try { + const res = await fetch(url, { + method, + headers: { + authorization: authHeader(config.forwardEmail.apiKey), + "content-type": "application/json", + }, + body: body === undefined ? undefined : JSON.stringify(body), + signal: AbortSignal.timeout(TIMEOUT_MS), + }); + const text = await res.text(); + let json = null; + try { json = text ? JSON.parse(text) : null; } catch { json = null; } + if (!res.ok) { + // Their errors carry a `message`; fall back to the status so a holder is + // never shown an empty reason for something that plainly did not work. + const message = json?.message || json?.error || `mail host returned ${res.status}`; + return { ok: false, status: res.status, error: String(message).slice(0, 300) }; + } + return { ok: true, data: json }; + } catch (e) { + const reason = e?.name === "TimeoutError" ? "mail host timed out" : e?.message || "mail host unreachable"; + return { ok: false, error: String(reason).slice(0, 300) }; + } +} + +/** + * Mint `@domain` forwarding to `recipient`. + * + * `has_recipient_verification` is the load-bearing flag and it is deliberately + * always on. Without it, anyone could type a stranger's address into the + * contact field and have the registry forward mail to a person who never asked + * for any -- a spam relay wearing our domain. With it, the recipient gets one + * confirmation link and nothing flows until they click it, so publishing a + * guard address requires consent from the address itself and not just from + * whoever filled in the form. + * + * `error_code_if_disabled` is 550 rather than the default 250: when an alias is + * disabled, a sender should be told the mail did not arrive. Quietly accepting + * and discarding it is the wrong answer for an address published as a way to + * reach somebody. + */ +export async function createGuardAlias({ token, recipient, description = "", isEnabled = true }) { + const res = await call("POST", `/v1/domains/${encodeURIComponent(config.forwardEmail.domain)}/aliases`, { + name: token, + recipients: [recipient], + description, + // Passed rather than hardcoded true. A holder whose first save is `public` + // or `none` still gets the alias minted — the address is their identity and + // has to exist before the day they switch it on — but it must not be + // forwarding mail they have not asked it to forward. + is_enabled: Boolean(isEnabled), + has_recipient_verification: true, + error_code_if_disabled: 550, + labels: ["moshpit-guard"], + }); + if (!res.ok) return res; + const id = res.data?.id || res.data?._id || null; + // An alias we cannot address later is only half created: without the id there + // is no way to repoint or revoke it, and revocation is the part that matters. + if (!id) return { ok: false, error: "mail host created the alias without returning an id" }; + return { ok: true, id }; +} + +/** Repoint an existing alias, or switch it on and off, without changing its address. */ +export async function updateGuardAlias({ id, recipient, isEnabled }) { + const body = {}; + if (recipient !== undefined) body.recipients = [recipient]; + if (isEnabled !== undefined) body.is_enabled = Boolean(isEnabled); + const res = await call( + "PUT", + `/v1/domains/${encodeURIComponent(config.forwardEmail.domain)}/aliases/${encodeURIComponent(id)}`, + body, + ); + return res.ok ? { ok: true, id } : res; +} + +/** + * Destroy the alias. + * + * Used when a contact is removed and when a name changes hands. A 404 counts as + * success: the goal is "this address forwards to nobody", and an alias the host + * has already lost is in that state. Treating it as a failure would leave the + * row stuck, retrying forever against something that does not exist. + */ +export async function deleteGuardAlias({ id }) { + const res = await call( + "DELETE", + `/v1/domains/${encodeURIComponent(config.forwardEmail.domain)}/aliases/${encodeURIComponent(id)}`, + ); + if (!res.ok && res.status === 404) return { ok: true, id }; + return res.ok ? { ok: true, id } : res; +} + +/** Whether guard addresses can be minted at all right now. */ +export const guardMailConfigured = enabled; diff --git a/apps/pwa/src/lib/moshpit-contact.mjs b/apps/pwa/src/lib/moshpit-contact.mjs new file mode 100644 index 0000000..6b766ce --- /dev/null +++ b/apps/pwa/src/lib/moshpit-contact.mjs @@ -0,0 +1,169 @@ +// Reaching the holder of a name without learning who they are. +// +// Every registry has this problem and most solve it badly. Publishing the +// holder's real address -- which is what /api/moshpit/tlds did for years, for +// every ending, to anyone who asked -- turns a namespace into a mailing list +// and gives the holder no say in it. Publishing nothing at all is the other +// failure: a name that resolves to a broken server, or one somebody wants to +// buy, has nobody to tell. +// +// A guard address is the way between. The registry publishes +// `k7m2xqbn3f@moshcode.sh`, which forwards to whatever address the holder +// actually reads. The real one is never in a response, a page, or the log. The +// holder can turn it off, and the token survives being turned off, because the +// published address ends up in other people's address books and on pages we do +// not control. +// +// Deliberately free of any database or network import, for the same reason +// moshpit-name and moshpit-twin are: these are the rules, and the rules have to +// be checkable without a libSQL connection or an API key. src/moshpit.mjs owns +// storage and src/lib/forwardemail.mjs owns the mail host. +import { randomBytes } from "node:crypto"; + +import { normalizeDomain } from "./moshpit-twin.mjs"; + +/** + * What a contact may be showing, in the order of how much it gives away. + * + * `none` is a state rather than the absence of one: see the migration -- a + * contact taken down and put back up has to come back at the same address. + */ +export const CONTACT_VISIBILITY = ["none", "guard", "public"]; + +export const DEFAULT_VISIBILITY = "guard"; + +/** + * The alphabet a guard token is drawn from: digits and consonants, minus the + * pairs that get misread. + * + * No `0`/`o`, no `1`/`l`/`i`, because these are read off a page and typed into + * a mail client by hand. And no vowels at all, which is doing more work than it + * looks: it means a token can never spell a word, so a minted address can never + * collide with a mailbox a person holds at the same domain. `support`, + * `abuse`, `notify` and every other role address are unreachable from here by + * construction, rather than by a reserved list somebody has to remember to keep + * up to date. + */ +const GUARD_ALPHABET = "23456789bcdfghjkmnpqrstvwxz"; + +/** + * Ten characters, ~47 bits. + * + * The token is not a secret -- it is printed on a public page -- so this is not + * sized against an attacker who wants to guess one. It is sized against someone + * enumerating the whole space to harvest forwarding addresses, which 47 bits + * makes pointless, and against collision, which the UNIQUE constraint catches + * anyway. + */ +const TOKEN_LENGTH = 10; + +const GUARD_TOKEN = new RegExp(`^[${GUARD_ALPHABET}]{${TOKEN_LENGTH}}$`); + +/** + * A fresh guard token. + * + * Rejection sampling rather than `% alphabet.length`: 256 is not a multiple of + * 27, so modulo would make the first thirteen characters of the alphabet + * measurably likelier than the rest. It costs nothing to do properly here and + * the bias would be permanent in every address ever minted. + */ +export function mintGuardToken() { + const limit = 256 - (256 % GUARD_ALPHABET.length); + let out = ""; + while (out.length < TOKEN_LENGTH) { + for (const byte of randomBytes(TOKEN_LENGTH)) { + if (byte >= limit) continue; + out += GUARD_ALPHABET[byte % GUARD_ALPHABET.length]; + if (out.length === TOKEN_LENGTH) break; + } + } + return out; +} + +/** Whether a string is shaped like a token this registry minted. */ +export const isGuardToken = (value) => GUARD_TOKEN.test(String(value ?? "")); + +/** + * Normalise a contact address, or null when it could never be one. + * + * Forgiving about what arrives -- the field is typed by hand and people paste + * `Anthony ` out of a mail client -- and strict about exactly two + * things: one `@`, and a domain that could exist. The domain half reuses + * normalizeDomain rather than a second regex, so a contact address and a + * clearnet twin agree on what a hostname is. + * + * No attempt at deciding whether the mailbox is real. That is not knowable from + * here, and the mail host answers it properly: an alias is created with + * recipient verification, so the address has to confirm before anything is + * forwarded to it. + */ +export function normalizeContactEmail(input) { + const raw = String(input ?? "").trim() + .replace(/^[^<]*<([^>]*)>.*$/, "$1") // a pasted "Name " + .replace(/^mailto:/i, "") + .trim(); + if (!raw || raw.length > 254) return null; + + const at = raw.lastIndexOf("@"); + if (at <= 0 || at === raw.length - 1) return null; + + const local = raw.slice(0, at); + // Lowercased whole. The local part is case-sensitive per RFC 5321 and case + // insensitive at every mail host anybody actually uses; folding it keeps one + // address from being stored twice and is what the mail host will do regardless. + const domain = normalizeDomain(raw.slice(at + 1)); + if (!domain) return null; + if (local.length > 64) return null; + // No spaces, no angle brackets, no comma -- the characters that mean a header + // was pasted rather than an address. Quoted local parts are legal and refused: + // they are vanishingly rare and every one seen here so far has been a paste + // that went wrong. + if (!/^[a-z0-9!#$%&'*+/=?^_`{|}~.-]+$/i.test(local)) return null; + if (local.startsWith(".") || local.endsWith(".") || local.includes("..")) return null; + + return `${local.toLowerCase()}@${domain}`; +} + +/** `k7m2xqbn3f` + `moshcode.sh` -> `k7m2xqbn3f@moshcode.sh`. */ +export function guardAddress(token, domain) { + const host = normalizeDomain(domain); + if (!isGuardToken(token) || !host) return null; + return `${token}@${host}`; +} + +/** + * What a visitor is allowed to see, or null when the answer is nothing. + * + * The single place that decision is made. Every caller -- the name page, the + * ending page, the resolve API -- goes through here rather than reading + * `visibility` and deciding for itself, because there are two independent + * reasons to publish nothing and a caller that checks only one of them leaks. + * + * The second reason is the one easy to miss: a `guard` contact whose alias is + * not `live` yet has an address that does not exist. Publishing it would hand + * out a bouncing address, which is worse than publishing none, so the alias + * status gates it and not just the holder's choice. + */ +export function publishedContact(row, guardDomain) { + if (!row) return null; + if (row.visibility === "public") { + const address = normalizeContactEmail(row.email); + return address ? { kind: "public", address } : null; + } + if (row.visibility !== "guard") return null; + if (row.alias_status !== "live") return null; + const address = guardAddress(row.guard_token, guardDomain); + return address ? { kind: "guard", address } : null; +} + +/** + * Whether a visibility string is one this registry understands. + * + * Returns the value rather than a boolean so callers read + * `normalizeVisibility(x) ?? fail()` and cannot accidentally write an + * unvalidated string into a CHECK-constrained column. + */ +export const normalizeVisibility = (input) => { + const value = String(input ?? "").trim().toLowerCase(); + return CONTACT_VISIBILITY.includes(value) ? value : null; +}; diff --git a/apps/pwa/src/migrations/017_moshpit_contact.sql b/apps/pwa/src/migrations/017_moshpit_contact.sql new file mode 100644 index 0000000..95cd7fd --- /dev/null +++ b/apps/pwa/src/migrations/017_moshpit_contact.sql @@ -0,0 +1,97 @@ +-- How to reach the holder of a name, without publishing who they are. +-- +-- The registry has never had a contact field, and has been publishing contact +-- details anyway: /api/moshpit/tlds returned `owner_email` in cleartext for +-- every ending, unauthenticated and pageable. That is the worst of both -- +-- real personal addresses exposed with no consent and no way to opt out, and +-- still no dependable way to reach whoever holds a name. This table is the +-- consented half; the redaction of `owner_email` is the other half, and the +-- two land together on purpose. +-- +-- Its own table rather than columns on moshpit_names, for the reason +-- moshpit_twins gives: a contact has a lifecycle the name does not. It is +-- offered, provisioned at a mail host we do not run, disabled, re-enabled and +-- eventually revoked, and each of those is a field. Most names will never have +-- one, and they should not carry six nullable columns to say so. +-- +-- Absence of a row is the default and means "no contact" -- which is what every +-- name registered before today has, and it stays that way without a backfill. +CREATE TABLE IF NOT EXISTS moshpit_contacts ( + tld TEXT NOT NULL, + -- The name this contact belongs to, or '' for the ending itself. + -- + -- One table for both rather than two, because everything below the key -- + -- token minting, alias provisioning, revocation -- is identical for an ending + -- and a name, and a second table would be the same lifecycle maintained + -- twice. The empty string rather than NULL: SQLite permits NULL in a non + -- INTEGER primary key and treats NULLs as distinct, so a NULL label would let + -- one ending hold unlimited contact rows and silently defeat the key. + label TEXT NOT NULL DEFAULT '', + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + -- Where mail actually goes. Never published, never returned by any route -- + -- the guard address below is what the registry shows instead. Held here + -- rather than read from users(email) at send time because they are different + -- facts: the account address signs in, the contact address is one the holder + -- chose to hand out, and someone selling names under an ending should be able + -- to publish a role address without changing how they log in. + email TEXT NOT NULL, + + -- none | guard | public. + -- + -- guard -- publish @moshcode.sh, which forwards. The default, and + -- the reason this feature exists. + -- public -- publish `email` as typed. For a holder who wants a role address + -- reachable directly and has decided the exposure is fine. + -- none -- opted in once, currently showing nothing. + -- + -- `none` is not the same as having no row, and the difference is the token. + -- A holder who takes their contact down during a spam wave and puts it back + -- up a week later must get the same address back: the old one is printed in + -- other people's address books and linked from pages we do not control. + -- Deleting the row would mint a new token and silently break all of that. + visibility TEXT NOT NULL DEFAULT 'guard' CHECK (visibility IN ('none','guard','public')), + + -- The local part of the guard address, and the stable public identity of this + -- contact. Unique across the whole registry because it is an address at one + -- shared domain -- two names holding the same token would forward one + -- person's mail to the other. + -- + -- Never recycled. A token dies with the row (see releaseName, which drops + -- contacts alongside pins, records and twins) and the next holder of the name + -- mints a fresh one, so mail addressed to the previous holder can never be + -- delivered to whoever comes after them. + guard_token TEXT NOT NULL UNIQUE, + + -- The alias at the mail host, which is a separate system that can be down, + -- rate limited, or simply not configured in development. + -- + -- pending -- recorded here, not yet created there. Nothing is published. + -- live -- created and forwarding. The only state that publishes. + -- failed -- the host refused; `alias_error` says what it said. + -- revoked -- deliberately torn down, kept so a retry does not resurrect it. + -- + -- The guard address is published only from `live`. Printing an address before + -- the host knows about it means publishing one that bounces, which is worse + -- than publishing none. + alias_status TEXT NOT NULL DEFAULT 'pending' + CHECK (alias_status IN ('pending','live','failed','revoked')), + -- The mail host's own id for the alias, needed to update or delete it later. + -- The local part alone is not enough for their API. + alias_id TEXT, + -- What the host said when it refused, for the holder to read. Cleared on the + -- next success rather than left behind describing a problem already fixed. + alias_error TEXT, + alias_synced_at INTEGER, + + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + + PRIMARY KEY (tld, label) +); + +CREATE INDEX IF NOT EXISTS idx_moshpit_contacts_user ON moshpit_contacts(user_id); +-- The reconcile sweep reads "not live yet", which is a small slice of a table +-- that is mostly live -- so it is worth an index and worth being partial. +CREATE INDEX IF NOT EXISTS idx_moshpit_contacts_unsynced ON moshpit_contacts(alias_status) + WHERE alias_status IN ('pending','failed'); diff --git a/apps/pwa/src/moshpit.mjs b/apps/pwa/src/moshpit.mjs index 0b82c98..fc07a5e 100644 --- a/apps/pwa/src/moshpit.mjs +++ b/apps/pwa/src/moshpit.mjs @@ -12,8 +12,23 @@ import { randomBytes } from "node:crypto"; +import { config } from "./config.mjs"; import { db, get, all, run } from "./db.mjs"; import { normalizeFeedKind, normalizeFeedUrl } from "./lib/feed.mjs"; +import { + createGuardAlias, + deleteGuardAlias, + guardMailConfigured, + updateGuardAlias, +} from "./lib/forwardemail.mjs"; +import { + CONTACT_VISIBILITY, + DEFAULT_VISIBILITY, + mintGuardToken, + normalizeContactEmail, + normalizeVisibility, + publishedContact, +} from "./lib/moshpit-contact.mjs"; import { contentOut, MAX_ITEMS_PER_NAME, normalizeContent, normalizeSlug } from "./lib/moshpit-content.mjs"; import { normalizeTarget } from "./lib/moshpit-gateway.mjs"; import { @@ -72,6 +87,11 @@ export { parseTwinProof, twinIsLive, twinProof, twinProofMatches, twinProofName, } from "./lib/moshpit-twin.mjs"; +export { + CONTACT_VISIBILITY, DEFAULT_VISIBILITY, + guardAddress, isGuardToken, mintGuardToken, normalizeContactEmail, normalizeVisibility, publishedContact, +} from "./lib/moshpit-contact.mjs"; + /** * The largest number this column will accept. * @@ -479,6 +499,15 @@ export async function releaseName({ tld: tldInput, label: labelInput, userId }) // point the next holder's visitors at a stranger's website under their own // name — and hand that stranger a proof record they can revoke at will. await run(`DELETE FROM moshpit_twins WHERE tld = ? AND label = ?`, [owned.tld, owned.label]); + // And the contact, which is the one with a live consequence outside this + // database. Its guard address forwards mail at our domain to the person + // giving the name up; inheriting it would deliver the next holder's mail -- + // an offer for the name, an abuse report about it -- to the last one. The + // alias is torn down at the mail host first, because deleting only the row + // would leave that forwarding in place with nothing left that knows how to + // stop it. + await revokeContactAlias(await getContactPrivate(owned.tld, owned.label)); + await run(`DELETE FROM moshpit_contacts WHERE tld = ? AND label = ?`, [owned.tld, owned.label]); await run(`DELETE FROM moshpit_names WHERE tld = ? AND label = ?`, [owned.tld, owned.label]); await logAction(owned.tld, userId, `unname:${owned.label}`); return { ok: true }; @@ -1980,3 +2009,246 @@ export async function availableTwins(input) { const taken = new Set(rows.map((r) => r.domain)); return candidates.filter((d) => !taken.has(d)); } + +/* ---- how to reach the holder ---- */ + +/** + * A contact is the consented half of a WHOIS. + * + * The registry used to publish `owner_email` for every ending to anyone who + * asked, which gave holders no say and still left most names unreachable. This + * replaces it with something the holder opts into: they say where they read + * mail, the registry publishes `@moshcode.sh`, and the real address + * never appears in a response, a page or the log. + * + * Storage only. The rules live in lib/moshpit-contact.mjs and the mail host in + * lib/forwardemail.mjs; what this file owns is the order things happen in -- + * which matters, because two of the three steps can fail independently. + */ +const CONTACT_COLS = + `tld, label, user_id, email, visibility, guard_token, alias_status, alias_id, alias_error, alias_synced_at, created_at, updated_at`; + +/** + * The raw row, real address included. Never hand this to a route that renders. + * + * Named `getContactPrivate` rather than `getContact` so that reaching for the + * one that leaks is a deliberate act with the word in front of you. What a + * visitor may see comes from publishedContact(), which takes this row and + * returns an address or nothing. + */ +export async function getContactPrivate(tld, label = "") { + return get(`SELECT ${CONTACT_COLS} FROM moshpit_contacts WHERE tld = ? AND label = ?`, [tld, label]); +} + +/** + * What to show a visitor asking how to reach `label.tld`, or null. + * + * No fallback to the ending's contact when a name has none, and that is the + * whole point rather than an omission. Names under a priced ending are sold to + * other people -- showing the ending operator's address on a name they do not + * hold would route a buyer's mail, a bug report, or an abuse complaint to the + * wrong person entirely, and do it while looking authoritative. + */ +export async function publicContactFor(tld, label = "") { + return publishedContact(await getContactPrivate(tld, label), config.forwardEmail.domain); +} + +/** Ownership for both shapes a contact comes in: a name, or the ending itself. */ +async function ownedContactScope(tldInput, labelInput, userId) { + const raw = String(labelInput ?? "").trim(); + if (raw) return ownedName(tldInput, raw, userId); + + const tld = normalizeTld(tldInput); + if (!tld) return { ok: false, error: "not a valid ending" }; + const owner = await getTld(tld); + if (!owner) return { ok: false, error: `.${tld} is not registered` }; + if (owner.user_id !== userId) return { ok: false, error: `you do not own .${tld}` }; + return { ok: true, tld, label: "" }; +} + +/** How a contact reads in the allocation log -- the ending, never the address. */ +const contactLogLabel = (label) => (label ? `contact:${label}` : "contact"); + +/** + * The alias is enabled at the mail host exactly when the guard address is the + * thing being published. + * + * A `public` or `none` contact keeps its token -- see the migration on why the + * address has to survive being taken down -- but the address stops forwarding, + * and a sender gets a 550 rather than silence. Keeping it live while the + * registry advertises something else would leave a forwarding address in + * service that the holder believes they have turned off. + */ +const aliasWanted = (visibility) => visibility === "guard"; + +/** + * Record where a holder reads mail, and make the guard address match. + * + * Written first, synced second, and deliberately in that order. The holder's + * intent is the durable fact; the alias at the mail host is a copy of it that + * can fail, time out, or not exist yet because no API key is configured. If the + * sync loses, the row still says what they asked for and `alias_status` says + * the address is not ready -- which publishes nothing and can be retried. The + * reverse order would lose the intent on a network blip. + */ +export async function setContact({ tld: tldInput, label: labelInput, userId, email, visibility = DEFAULT_VISIBILITY }) { + const owned = await ownedContactScope(tldInput, labelInput, userId); + if (!owned.ok) return owned; + + const address = normalizeContactEmail(email); + if (!address) return { ok: false, error: "that does not look like an email address" }; + const shown = normalizeVisibility(visibility); + if (!shown) return { ok: false, error: `visibility must be one of ${CONTACT_VISIBILITY.join(", ")}` }; + + const existing = await getContactPrivate(owned.tld, owned.label); + // Reused when there is one. Minting a fresh token on every edit would change + // the published address every time a holder corrected a typo in their own. + const token = existing?.guard_token ?? mintGuardToken(); + const now = Date.now(); + + await run( + `INSERT INTO moshpit_contacts + (tld, label, user_id, email, visibility, guard_token, alias_status, alias_id, alias_error, alias_synced_at, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT (tld, label) DO UPDATE SET + email = excluded.email, + visibility = excluded.visibility, + -- Rewritten, not left alone. ownedContactScope has already established + -- that the caller holds this name, so if the stored owner disagrees the + -- row is stale and the caller is right -- and leaving it would list + -- somebody else's contact on the previous holder's /pit/contact page. + user_id = excluded.user_id, + updated_at = excluded.updated_at`, + [ + owned.tld, owned.label, userId, address, shown, token, + existing?.alias_status ?? "pending", + existing?.alias_id ?? null, + existing?.alias_error ?? null, + existing?.alias_synced_at ?? null, + existing?.created_at ?? now, + now, + ], + ); + + await logAction(owned.tld, userId, contactLogLabel(owned.label)); + const sync = await syncContactAlias(owned.tld, owned.label); + return { ok: true, contact: await getContactPrivate(owned.tld, owned.label), sync }; +} + +/** + * Make the mail host agree with the row. + * + * Idempotent and safe to call again, because it is called from three places + * that cannot coordinate: an edit, a retry the holder asks for, and the sweep. + * Everything it learns goes back onto the row, including failure -- an error + * nobody records is one the holder cannot see the reason for. + */ +export async function syncContactAlias(tld, label = "") { + const row = await getContactPrivate(tld, label); + if (!row) return { ok: false, error: "no contact recorded" }; + + // Nothing to sync against. The row keeps whatever status it had rather than + // being marked failed: "no mail host configured" is a fact about this + // deployment, not about the holder's contact, and writing `failed` would show + // them an error for something they cannot fix. + if (!guardMailConfigured()) return { ok: false, skipped: true, error: "mail host not configured" }; + + const wanted = aliasWanted(row.visibility); + const result = row.alias_id + ? await updateGuardAlias({ id: row.alias_id, recipient: row.email, isEnabled: wanted }) + // Created even when the holder chose `public` or `none`, then immediately + // disabled. The token is already published as this contact's identity, and + // minting the alias lazily would mean the address does not exist on the day + // they switch to `guard` and expect it to work. + : await createGuardAlias({ + token: row.guard_token, + recipient: row.email, + isEnabled: wanted, + description: `moshpit contact for ${label ? `${label}.${tld}` : `.${tld}`}`, + }); + + const now = Date.now(); + if (!result.ok) { + await run( + `UPDATE moshpit_contacts SET alias_status = 'failed', alias_error = ?, alias_synced_at = ? WHERE tld = ? AND label = ?`, + [result.error ?? "mail host refused", now, tld, label], + ); + return result; + } + + await run( + `UPDATE moshpit_contacts SET alias_status = 'live', alias_id = ?, alias_error = NULL, alias_synced_at = ? WHERE tld = ? AND label = ?`, + [result.id ?? row.alias_id, now, tld, label], + ); + return { ok: true }; +} + +/** The retry a holder reaches for after the mail host was down. */ +export async function retryContactAlias({ tld: tldInput, label: labelInput, userId }) { + const owned = await ownedContactScope(tldInput, labelInput, userId); + if (!owned.ok) return owned; + const sync = await syncContactAlias(owned.tld, owned.label); + return sync.ok ? { ok: true } : { ok: false, error: sync.error ?? "could not reach the mail host" }; +} + +/** + * Take the contact off the name entirely. + * + * The alias is destroyed at the host before the row goes, and the row goes + * either way. An alias left behind is the failure that matters here: it is a + * live forwarding address at our domain, pointing at a person who has asked to + * stop being contacted, that nothing left in the database remembers how to + * revoke. Keeping the row on a failed delete would be worse -- the holder asked + * to be gone -- so it is logged loudly instead. + */ +export async function removeContact({ tld: tldInput, label: labelInput, userId }) { + const owned = await ownedContactScope(tldInput, labelInput, userId); + if (!owned.ok) return owned; + const row = await getContactPrivate(owned.tld, owned.label); + if (!row) return { ok: false, error: "no contact to remove" }; + + await revokeContactAlias(row); + await run(`DELETE FROM moshpit_contacts WHERE tld = ? AND label = ?`, [owned.tld, owned.label]); + await logAction(owned.tld, userId, `un${contactLogLabel(owned.label)}`); + return { ok: true }; +} + +/** + * Destroy one contact's alias at the mail host. + * + * Shared by removal and by a name changing hands, because they are the same + * requirement seen from two directions: after this, mail to that address must + * reach nobody. Never throws -- both callers are deleting a row whatever + * happens, and an exception here would leave the row and the alias both alive. + */ +async function revokeContactAlias(row) { + if (!row?.alias_id || !guardMailConfigured()) return; + try { + const result = await deleteGuardAlias({ id: row.alias_id }); + if (!result.ok) { + console.error(`moshpit contact: alias ${row.alias_id} not revoked — ${result.error}`); + } + } catch (e) { + console.error(`moshpit contact: alias ${row.alias_id} not revoked — ${e?.message ?? e}`); + } +} + +/** Every contact a holder has, for the /pit page to draw. */ +export async function listContactsForUser(userId) { + return all(`SELECT ${CONTACT_COLS} FROM moshpit_contacts WHERE user_id = ? ORDER BY tld, label`, [userId]); +} + +/** + * Contacts whose alias never made it to the mail host. + * + * What a reconcile sweep reads. `pending` is mostly the window between this + * shipping and an API key being set; `failed` is the mail host having been down + * at the wrong moment. Both are fixed by calling syncContactAlias again, and + * neither fixes itself. + */ +export async function unsyncedContacts(limit = 200) { + return all( + `SELECT ${CONTACT_COLS} FROM moshpit_contacts WHERE alias_status IN ('pending','failed') ORDER BY updated_at LIMIT ?`, + [limit], + ); +} diff --git a/apps/pwa/src/routes/moshpit.mjs b/apps/pwa/src/routes/moshpit.mjs index 1849365..93ee9b8 100644 --- a/apps/pwa/src/routes/moshpit.mjs +++ b/apps/pwa/src/routes/moshpit.mjs @@ -70,17 +70,22 @@ import { countSearchTlds, countTldsNotOwnedBy, createLink, + CONTACT_VISIBILITY, CHILD_PRICE_USD, DEFAULT_TLD_PRICE_USD, + DEFAULT_VISIBILITY, ENDING_PRICE_USD, deleteContent, deleteLink, + getContactPrivate, getContent, getLink, getName, getTld, getTldWithPrice, + guardAddress, listAliasesTo, + listContactsForUser, listContent, listAllNames, listExempt, @@ -108,6 +113,7 @@ import { PIN_KINDS, pinsForName, popularLabels, + publicContactFor, putContent, quoteName, RECORD_HELP, @@ -117,12 +123,15 @@ import { registerTld, registerTlds, releaseName, + removeContact, removePin, removeRecord, resolutionPreference, resolveMoshpitName, + retryContactAlias, searchTlds, setAlias, + setContact, setExempt, setNameFeed, setNameTarget, @@ -235,7 +244,36 @@ moshpitRouter.get("/api/moshpit/tlds", async (req, res) => { // callers see no change in what arrives, only in being told there is more. const applied = limit ?? DEFAULT_PAGE; const tlds = await listTlds({ limit: applied, offset }); - res.json({ total: await countTlds(), limit: applied, offset, tlds }); + res.json({ total: await countTlds(), limit: applied, offset, tlds: tlds.map(publicTld) }); +}); + +/** + * One ending as a stranger may see it. + * + * This endpoint used to return the row as it sits in the table, which meant + * `owner_email` in cleartext for every ending in the registry — thousands of + * real addresses, other people's included, to anyone who could count to 200 in + * an `?offset=`. Nobody consented to that and nothing read it: not + * moshpit-registry, not the DNS bridge, not a single page in this app. It was a + * SELECT that grew a route. + * + * The policy it now follows is the one /api/moshpit/log already wrote down — + * ownership is public, the account behind it is not. Everything a mirror + * legitimately needs is still here: which ending, where it points, what a name + * under it costs, when it was claimed. + * + * `owner` is the same digest the log publishes, and it is the reason this is a + * redaction rather than a deletion. Two endings held by one person still + * visibly share a holder, so "who holds how much of the namespace" — the + * question that made the email field useful — is still answerable, by the same + * value, from either endpoint. Reaching the holder is what a contact is for. + */ +const publicTld = (t) => ({ + tld: t.tld, + owner: ownerDigest(t.user_id), + alias_of: t.alias_of, + price_usd: t.price_usd, + created_at: t.created_at, }); /** @@ -1072,11 +1110,14 @@ moshpitRouter.get("/n/:name", async (req, res) => { const ending = normalizeTld(String(req.params.name || "").replace(/^\.+/, "")); const owner = ending ? await getTldWithPrice(ending) : null; if (owner) { - const [names, aliasesTo, sameOwner, popular] = await Promise.all([ + const [names, aliasesTo, sameOwner, popular, contact] = await Promise.all([ listNames(ending), listAliasesTo(ending), listTldsForUser(owner.user_id, { limit: 50 }), popularLabels(), + // The operator of an ending is exactly who a would-be buyer of a name + // under it has to reach, and until now there was no way to. + publicContactFor(ending), ]); const publishing = await countContentForNames(names); // What could go under it next — the third question, after what is under @@ -1089,7 +1130,7 @@ moshpitRouter.get("/n/:name", async (req, res) => { return res.status(200).send(page({ title: `.${ending}`, head: endingHead(ending, owner), - body: endingDirectory({ tld: ending, owner, names, aliasesTo, sameOwner, suggestions, publishing, user: req.user, req }), + body: endingDirectory({ tld: ending, owner, names, aliasesTo, sameOwner, suggestions, contact, publishing, user: req.user, req }), })); } // Still 400 for an ending nobody holds: otherwise every typo under /n/ @@ -1142,9 +1183,13 @@ moshpitRouter.get("/n/:name", async (req, res) => { } // Neither: the directory. - const [names, tlds] = await Promise.all([ + const [names, tlds, contact] = await Promise.all([ tld ? listNames(tld) : Promise.resolve([]), listTlds({ limit: 200 }), + // Only on the directory. A name that serves a site, a feed or an origin is + // showing its holder's own page, and the registry has no business printing + // an address into it -- /api/moshpit/contact is the answer there. + tld && parsed ? publicContactFor(tld, parsed.label) : Promise.resolve(null), ]); const owner = tld ? await getTldWithPrice(tld) : null; const publishing = await countContentForNames(names); @@ -1165,7 +1210,7 @@ moshpitRouter.get("/n/:name", async (req, res) => { res.status(200).send(page({ title: resolution.name, head: nameHead(resolution), - body: directory({ resolution, tld, owner, names, tlds, quote, publishing, user: req.user, req }), + body: directory({ resolution, tld, owner, names, tlds, quote, contact, publishing, user: req.user, req }), })); }); @@ -1374,7 +1419,7 @@ function endingHead(tld, owner) { * ending's price and a box to pick a name under it — and the listing is the * whole ending rather than "what else lives near the name you asked for". */ -function endingDirectory({ tld, owner, names, aliasesTo = [], sameOwner = [], suggestions = [], publishing = new Map(), user, req }) { +function endingDirectory({ tld, owner, names, aliasesTo = [], sameOwner = [], suggestions = [], contact = null, publishing = new Map(), user, req }) { // A name with a feed, or with something published here, is as live as one // with a server: it draws a page when you visit it, which is the only thing // this list sorts on. @@ -1465,6 +1510,8 @@ function endingDirectory({ tld, owner, names, aliasesTo = [], sameOwner = [], su

Related endings

${related.map(endingLink).join(" · ")}

` : ""} + ${contactCard(contact)} +

the pit →

`; } @@ -1492,7 +1539,7 @@ const unreachable = (resolution, why) => ` * answer is what does. Live sites first — they are the only entries that go * anywhere real — then the rest of the ending, then other endings. */ -function directory({ resolution, tld, owner, names, tlds, quote, publishing = new Map(), user, req }) { +function directory({ resolution, tld, owner, names, tlds, quote, contact = null, publishing = new Map(), user, req }) { // As in endingDirectory: a feed, or a post published here, makes a name a // site, so it belongs in the list of entries that go somewhere real. const drawn = (n) => Boolean(n.target || n.feed_url || publishing.get(`${n.label}.${n.tld}`)); @@ -1554,10 +1601,38 @@ function directory({ resolution, tld, owner, names, tlds, quote, publishing = ne

More endings

${others.map(tldLink).join(" · ")}

` : ""} + ${contactCard(contact)} +

the pit →

`; } +/** + * How to reach whoever holds this, when they have said they want to be reached. + * + * Nothing at all when they have not, which is the default and will stay the + * common case. An empty slot is better than the alternative this replaces: the + * registry used to answer "who holds .eggs" with a real email address whether + * or not its holder had ever been asked. + * + * The guard line says the address forwards. That is not decoration -- someone + * writing to `k7m2xqbn3f@moshcode.sh` should know they are writing to a person + * and not to a support desk at moshcode, and the holder should be able to see + * from the public page that their own address is not on it. + */ +function contactCard(contact) { + if (!contact) return ""; + const address = esc(contact.address); + return ` +

Contact

+

+ ${address} + ${contact.kind === "guard" + ? ` — forwards to the holder, whose own address stays private.` + : ` — published by the holder.`} +

`; +} + /** * The offer on an unclaimed name. * @@ -1689,6 +1764,132 @@ moshpitRouter.delete("/api/moshpit/tlds/:tld/pins", async (req, res) => { res.json({ tld: normalizeTld(req.params.tld), label: normalizeLabel(req.body?.label), withdrawn: true }); }); +/* ---- how to reach the holder ---- */ + +/** + * GET /api/moshpit/contact?name=blue.eggs — public, and public means guarded. + * + * The answer is an address or nothing, and never the holder's own address + * unless they explicitly chose to publish it. A `guard` contact answers with + * `k7m2xqbn3f@moshcode.sh`, which forwards; nothing in this response, or in any + * other, says where it forwards to. + * + * 404 rather than 200-with-null when there is no contact, for the reason the + * pins route gives: a client caches on the status, and "this name has nobody to + * write to" is a definite answer worth caching, distinct from an outage. + * + * `?name=` takes an ending too — `.eggs` or `eggs` — because the operator of an + * ending is exactly who a would-be buyer of a name under it needs to reach. + */ +moshpitRouter.get("/api/moshpit/contact", async (req, res) => { + const raw = String(req.query.name ?? "").trim().replace(/^\.+/, ""); + if (!raw) return bad(res, "name is required"); + + const parsed = parseMoshpitName(raw); + // An ending on its own is not a name and parseMoshpitName rightly refuses it; + // here it is a legitimate subject, so it is tried second rather than treated + // as a malformed name. + const scope = parsed + ? { tld: parsed.tld, label: parsed.label } + : (normalizeTld(raw) ? { tld: normalizeTld(raw), label: "" } : null); + if (!scope) return bad(res, "not a Moshpit name or ending"); + + const contact = await publicContactFor(scope.tld, scope.label); + const body = { + name: scope.label ? `${scope.label}.${scope.tld}` : `.${scope.tld}`, + tld: scope.tld, + label: scope.label || null, + contact, + }; + return contact ? res.json(body) : res.status(404).json(body); +}); + +/** + * GET /api/moshpit/tlds/:tld/contact[?label=blue] — the holder's own view. + * + * The one route that returns the real address, and only ever to the account + * that owns the name. It exists because a holder editing their contact has to + * see what is currently recorded, and because `alias_status` is the only place + * that explains why a guard address they set up is not showing yet. + */ +moshpitRouter.get("/api/moshpit/tlds/:tld/contact", async (req, res) => { + if (!req.user) return unauthorized(res); + const tld = normalizeTld(req.params.tld); + if (!tld) return bad(res, "not a valid ending"); + const label = req.query.label ? normalizeLabel(req.query.label) : ""; + if (req.query.label && !label) return bad(res, "not a valid name"); + + const row = await getContactPrivate(tld, label); + if (!row) return res.status(404).json({ tld, label: label || null, contact: null }); + if (row.user_id !== req.user.id) return unauthorized(res); + res.json({ tld, label: label || null, contact: contactOut(row) }); +}); + +/** + * PUT /api/moshpit/tlds/:tld/contact { label?, email, visibility? } — opt in. + * + * Absent `label` means the ending itself. `visibility` defaults to `guard`, + * because a holder who fills in a contact form on a registry is asking to be + * reachable, not to be published — and if the safer of the two has to be typed + * out explicitly, someone will eventually not type it. + * + * 202 rather than 201 when the mail host did not answer. The contact is saved + * either way and the response says so; what is not yet true is that the address + * works, and a flat 200 would tell the caller it does. + */ +moshpitRouter.put("/api/moshpit/tlds/:tld/contact", async (req, res) => { + if (!req.user) return unauthorized(res); + const result = await setContact({ + tld: req.params.tld, + label: req.body?.label, + userId: req.user.id, + email: req.body?.email, + visibility: req.body?.visibility ?? DEFAULT_VISIBILITY, + }); + if (!result.ok) return bad(res, result.error || "could not save that contact"); + + const contact = contactOut(result.contact); + return contact.alias_status === "live" + ? res.json({ contact }) + : res.status(202).json({ contact, pending: result.sync?.error ?? "the mail host has not confirmed the address yet" }); +}); + +/** DELETE /api/moshpit/tlds/:tld/contact { label? } — opt back out, alias and all. */ +moshpitRouter.delete("/api/moshpit/tlds/:tld/contact", async (req, res) => { + if (!req.user) return unauthorized(res); + const result = await removeContact({ tld: req.params.tld, label: req.body?.label, userId: req.user.id }); + if (!result.ok) return bad(res, result.error || "could not remove that contact", 404); + res.json({ tld: normalizeTld(req.params.tld), label: normalizeLabel(req.body?.label) || null, removed: true }); +}); + +/** POST /api/moshpit/tlds/:tld/contact/retry { label? } — after the mail host was down. */ +moshpitRouter.post("/api/moshpit/tlds/:tld/contact/retry", async (req, res) => { + if (!req.user) return unauthorized(res); + const result = await retryContactAlias({ tld: req.params.tld, label: req.body?.label, userId: req.user.id }); + if (!result.ok) return bad(res, result.error || "could not reach the mail host", 502); + res.json({ tld: normalizeTld(req.params.tld), label: normalizeLabel(req.body?.label) || null, alias_status: "live" }); +}); + +/** + * A contact as its own holder may see it: everything except where mail goes. + * + * The real address is withheld even here, from the person who typed it. It is + * not needed to manage the contact — the page shows the guard address, the + * status and the error — and a management route that returns it is one + * misplaced `console.log`, one over-eager cache header, or one screenshot away + * from being the leak this whole change exists to close. Changing where mail + * goes is a write, not a read of the old value. + */ +const contactOut = (row) => ({ + tld: row.tld, + label: row.label || null, + visibility: row.visibility, + guard_address: guardAddress(row.guard_token, config.forwardEmail.domain), + alias_status: row.alias_status, + alias_error: row.alias_error, + updated_at: row.updated_at, +}); + /* ---- the market ---- */ /** TLDs other people hold. `?for_sale=1` narrows to the buyable ones. */ @@ -2033,6 +2234,9 @@ const PIT_CSS = ` .pit-msg{border-radius:8px;padding:10px 14px;margin:14px 0;font-family:var(--mono);font-size:.84rem} .pit-msg.err{border:1px solid var(--danger);color:var(--danger)} .pit-msg.ok{border:1px solid var(--acid);color:var(--acid)} +/* A failure stated inline, next to the thing that failed, rather than in the + banner at the top — the contact tab can have one row broken and the rest fine. */ +.pit-fail{color:var(--danger)} .pit-defaults{display:flex;gap:12px;flex-wrap:wrap;margin:10px 0 4px} .pit-defaults label{display:flex;align-items:center;gap:6px;font-family:var(--mono); font-size:.72rem;letter-spacing:.06em;color:var(--dim);white-space:nowrap} @@ -2137,6 +2341,7 @@ const pitTabs = (active, counts = null, query = "") => { counts?.theirs === undefined ? "" : `${counts.theirs}${counts.forSale ? ` · ${counts.forSale} for sale` : ""}`} DNS Records${ counts?.records === undefined ? "" : `${counts.records}`} + Contact Bulk publish Use it (DNS) `; @@ -3024,6 +3229,193 @@ moshpitRouter.post("/pit/records/delete", requireAuth, async (req, res) => { * arrives, which is the whole reason the endpoint speaks NDJSON rather than * answering once at the end. */ +/* ---------- the Contact tab ---------- */ + +/** + * Where a holder opts in to being reachable. + * + * Deliberately not a form drawn next to every name, which is how the DNS + * records tab works and would be wrong here. One account on this registry holds + * over five thousand endings; drawing a contact form under each of them would + * be five thousand forms to say what almost all of them will keep saying, which + * is nothing. A contact is sparse by nature, so the page is shaped around the + * few that exist: one form to add or change one, and a list of the ones you + * have. + */ +const backToContact = (req, res, params) => { + const qs = new URLSearchParams(params).toString(); + res.redirect(`/pit/contact${qs ? `?${qs}` : ""}`); +}; + +/** + * `blue.eggs` or `.eggs` -- a name or the ending itself. + * + * Both are legitimate subjects and they are typed into the same box, because + * asking someone to pick "name" or "ending" from a dropdown first is asking + * them to classify a string they already know how to write. + */ +function contactScope(input) { + const raw = String(input ?? "").trim().replace(/^\.+/, ""); + if (!raw) return null; + const parsed = parseMoshpitName(raw); + if (parsed) return { tld: parsed.tld, label: parsed.label }; + const tld = normalizeTld(raw); + return tld ? { tld, label: "" } : null; +} + +const scopeName = (row) => (row.label ? `${row.label}.${row.tld}` : `.${row.tld}`); + +/** One contact you hold, and what can be done to it. */ +function contactRow(req, row) { + const name = scopeName(row); + const address = guardAddress(row.guard_token, config.forwardEmail.domain); + const live = row.alias_status === "live"; + const showing = row.visibility === "guard" ? address + : row.visibility === "public" ? "your own address, as you typed it" + : "nothing"; + + // The status line is the only place a holder can find out why an address they + // set up is not on their name's page, so it says the reason rather than the + // state name. + const status = row.visibility === "none" + ? `Hidden. The address is held and can be switched back on.` + : live + ? `Live. Mail sent here reaches you and nothing published says where.` + : row.alias_status === "failed" + ? `The mail host refused: ${esc(row.alias_error || "no reason given")}` + : `Waiting on the mail host. Nothing is published until it answers.`; + + return ` +
+
+

+ ${esc(name)} +

+ ${esc(row.visibility)} +
+

Shows: ${esc(showing)}

+

${status}

+ +
+ ${csrfInput(req)} + + + ${visibilitySelect(row.visibility)} + +
+ +
+ ${csrfInput(req)} + + ${row.alias_status === "failed" || row.alias_status === "pending" ? ` + ` : ""} + + removing destroys the forwarding address for good +
+
`; +} + +const visibilitySelect = (selected = DEFAULT_VISIBILITY) => ` +`; + +moshpitRouter.get("/pit/contact", async (req, res) => { + const bal = req.user ? await balance(req.user.id) : 0; + const contacts = req.user ? await listContactsForUser(req.user.id) : []; + + const msg = req.query.err ? `

${esc(req.query.err)}

` + : req.query.ok ? `

${esc(req.query.ok)}

` : ""; + + const body = !req.user + ? `

Sign in to say how you can be reached — the same login the CLI uses.

+

Sign in →

` + : ` +
+ ${csrfInput(req)} + + + ${visibilitySelect()} + +
+ ${contacts.length + ? contacts.map((row) => contactRow(req, row)).join("") + : `

Nothing of yours publishes a contact. That is the default, and it stays that way until you add one above.

`}`; + + res.type("html").send(page({ + title: "moshcode ▸ the pit ▸ contact", + head: ``, + body: `${appBar(req.user, bal, req.csrfToken)} +
+

how people reach you

+

A contact, without a WHOIS

+

+ Say where you read mail and the registry publishes + something@${esc(config.forwardEmail.domain)} on your name instead — an address + that forwards to you. Your own address is never in a page, an API response, or the log. + Nothing is published for a name until you add one here. +

+

+ The mail host sends one confirmation link to the address you give, and forwards nothing until you click it. + That is what stops anyone typing a stranger's address in here and pointing our domain at them. +

+ ${config.guardMailEnabled ? "" : ` +

No mail host is configured on this deployment yet, so forwarding addresses cannot be + minted. A contact you add is recorded and stays unpublished until one is.

`} + ${pitTabs("contact")} + ${msg} +
${body}
+
${footer}`, + })); +}); + +moshpitRouter.post("/pit/contact", requireAuth, async (req, res) => { + const scope = contactScope(req.body?.name); + if (!scope) return backToContact(req, res, { err: "which name? that is not one, and not an ending either." }); + + const result = await setContact({ + tld: scope.tld, label: scope.label, userId: req.user.id, + email: req.body?.email, visibility: req.body?.visibility ?? DEFAULT_VISIBILITY, + }); + if (!result.ok) return backToContact(req, res, { err: result.error || "could not save that contact" }); + + const name = scopeName(result.contact); + const address = guardAddress(result.contact.guard_token, config.forwardEmail.domain); + // Told plainly when the address is not live yet, because the holder is about + // to go and look at their name's page for an address that is not on it. + return backToContact(req, res, { + ok: result.contact.alias_status === "live" + ? `${name} now publishes ${address}. Mail to it reaches you.` + : `${name} is saved. ${address} is not published yet — the mail host has not confirmed it.`, + }); +}); + +moshpitRouter.post("/pit/contact/remove", requireAuth, async (req, res) => { + const scope = contactScope(req.body?.name); + if (!scope) return backToContact(req, res, { err: "which name? that is not one, and not an ending either." }); + + const result = await removeContact({ tld: scope.tld, label: scope.label, userId: req.user.id }); + if (!result.ok) return backToContact(req, res, { err: result.error || "could not remove that contact" }); + return backToContact(req, res, { + ok: `${scope.label ? `${scope.label}.${scope.tld}` : `.${scope.tld}`} no longer publishes a contact, and its forwarding address is gone.`, + }); +}); + +moshpitRouter.post("/pit/contact/retry", requireAuth, async (req, res) => { + const scope = contactScope(req.body?.name); + if (!scope) return backToContact(req, res, { err: "which name? that is not one, and not an ending either." }); + + const result = await retryContactAlias({ tld: scope.tld, label: scope.label, userId: req.user.id }); + return result.ok + ? backToContact(req, res, { ok: "The mail host has the address. It is live." }) + : backToContact(req, res, { err: result.error || "the mail host still did not answer" }); +}); + moshpitRouter.get("/pit/publish", async (req, res) => { const bal = req.user ? await balance(req.user.id) : 0; const names = req.user ? await listNamesForUser(req.user.id) : []; diff --git a/apps/pwa/test/moshpit-contact.test.mjs b/apps/pwa/test/moshpit-contact.test.mjs new file mode 100644 index 0000000..18fc104 --- /dev/null +++ b/apps/pwa/test/moshpit-contact.test.mjs @@ -0,0 +1,382 @@ +// Contact on a name: the consented half of a WHOIS, and the leak it replaces. +// +// Two things are being asserted here and they are the same thing from opposite +// ends. A holder who opts in gets a guard address that forwards to them and +// never says where. A holder who does not opt in — which is everybody, until +// they act — has nothing published at all, including the account address the +// endings list used to hand out to anyone who asked. +// +// Same harness as moshpit-ending-page.test.mjs: the real router against a +// throwaway libsql file, skipped cleanly when the PWA deps are not installed. +// The pure rules run either way; they have no dependencies by design. +import assert from "node:assert/strict"; +import fs from "node:fs"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; +import test from "node:test"; + +import { + guardAddress, + isGuardToken, + mintGuardToken, + normalizeContactEmail, + normalizeVisibility, + publishedContact, +} from "../src/lib/moshpit-contact.mjs"; + +const require = createRequire(import.meta.url); +let deps = null; +try { + deps = { express: require("express") }; +} catch { + deps = null; +} + +const workdir = mkdtempSync(path.join(tmpdir(), "moshcode-contact-test-")); +process.env.DATABASE_URL = `file:${path.join(workdir, "test.db")}`; +process.env.SESSION_SECRET = "test-secret"; +process.env.PUBLIC_ORIGIN = "https://app.example.test"; +process.env.PIT_ORIGIN = "https://pit.example.test"; +process.env.MOSHPIT_GUARD_DOMAIN = "moshcode.sh"; +// Left unset on purpose. No mail host is the state this ships in and the state +// development runs in, and "records the contact, publishes nothing" has to be +// the behaviour rather than an error. +delete process.env.FORWARDEMAIL_API_KEY; + +/* ---- the rules, which need nothing ---- */ + +test("an address survives being pasted out of a mail client", () => { + assert.equal(normalizeContactEmail("Anthony "), "me@example.com"); + assert.equal(normalizeContactEmail(" mailto:me@example.com "), "me@example.com"); + assert.equal(normalizeContactEmail("me@example.com."), "me@example.com"); +}); + +test("what could never be an address is refused rather than stored", () => { + for (const bad of ["", "me", "me@", "@example.com", "me@localhost", "me@1.2.3.4", "a b@example.com", ".me@example.com", "me..you@example.com"]) { + assert.equal(normalizeContactEmail(bad), null, `expected ${JSON.stringify(bad)} to be refused`); + } +}); + +test("a guard token cannot spell a word, so it cannot collide with a real mailbox", () => { + // The property that makes a reserved-address list unnecessary: no vowels, so + // `support`, `abuse` and `notify` are unreachable from the alphabet itself. + for (let i = 0; i < 200; i += 1) { + const token = mintGuardToken(); + assert.ok(isGuardToken(token), `${token} is not a well-formed token`); + assert.doesNotMatch(token, /[aeiou]/); + assert.equal(token.length, 10); + } +}); + +test("tokens do not repeat", () => { + const seen = new Set(); + for (let i = 0; i < 500; i += 1) seen.add(mintGuardToken()); + assert.equal(seen.size, 500); +}); + +test("every character of the alphabet is reachable", () => { + // A rejection-sampling loop that silently never emits its tail would bias + // every address ever minted, and would still pass every other test here. + const seen = new Set(); + for (let i = 0; i < 4000; i += 1) for (const ch of mintGuardToken()) seen.add(ch); + assert.equal(seen.size, 27, `only ${seen.size} of 27 characters were ever emitted`); +}); + +test("a guard address is the token at the guard domain", () => { + assert.equal(guardAddress("k7m2xqbn3f", "moshcode.sh"), "k7m2xqbn3f@moshcode.sh"); + assert.equal(guardAddress("not-a-token", "moshcode.sh"), null); + assert.equal(guardAddress("k7m2xqbn3f", "not a domain"), null); +}); + +test("visibility is validated rather than trusted into a CHECK-constrained column", () => { + assert.equal(normalizeVisibility("GUARD"), "guard"); + assert.equal(normalizeVisibility("public"), "public"); + assert.equal(normalizeVisibility("everyone"), null); + assert.equal(normalizeVisibility(undefined), null); +}); + +test("a guard contact publishes the guard address, never the real one", () => { + const row = { + email: "real@example.com", visibility: "guard", + guard_token: "k7m2xqbn3f", alias_status: "live", + }; + assert.deepEqual(publishedContact(row, "moshcode.sh"), { kind: "guard", address: "k7m2xqbn3f@moshcode.sh" }); +}); + +test("a guard address is withheld until the mail host has it", () => { + // The failure this prevents is publishing an address that bounces, which is + // worse than publishing none: the holder looks reachable and is not. + for (const alias_status of ["pending", "failed", "revoked"]) { + const row = { email: "real@example.com", visibility: "guard", guard_token: "k7m2xqbn3f", alias_status }; + assert.equal(publishedContact(row, "moshcode.sh"), null, `${alias_status} should publish nothing`); + } +}); + +test("`public` is the only way the real address is ever shown, and `none` shows nothing", () => { + const base = { email: "Real@Example.com", guard_token: "k7m2xqbn3f", alias_status: "live" }; + assert.deepEqual( + publishedContact({ ...base, visibility: "public" }, "moshcode.sh"), + { kind: "public", address: "real@example.com" }, + ); + assert.equal(publishedContact({ ...base, visibility: "none" }, "moshcode.sh"), null); + assert.equal(publishedContact(null, "moshcode.sh"), null); +}); + +/* ---- storage and routes ---- */ + +async function boot() { + const { migrate } = await import("../src/migrate.mjs"); + await migrate(); + const { run, get, db } = await import("../src/db.mjs"); + const { moshpitRouter } = await import("../src/routes/moshpit.mjs"); + const moshpit = await import("../src/moshpit.mjs"); + + await run(`INSERT OR REPLACE INTO users (id,email,created_at) VALUES ('u1','holder@example.com',1)`); + await run(`INSERT OR REPLACE INTO users (id,email,created_at) VALUES ('u2','other@example.com',1)`); + await run(`INSERT INTO moshpit_tlds (tld,user_id,owner_email,created_at) VALUES ('eggs','u1','holder@example.com',1)`); + await run(`INSERT INTO moshpit_tlds (tld,user_id,owner_email,created_at) VALUES ('theirs','u2','other@example.com',1)`); + await run(`INSERT INTO moshpit_names (tld,label,user_id,target,created_at) VALUES ('eggs','blue','u1',NULL,1)`); + await run(`INSERT INTO moshpit_names (tld,label,user_id,target,created_at) VALUES ('eggs','green','u1',NULL,1)`); + + const app = deps.express(); + app.use(deps.express.json()); + app.use((req, _res, next) => { req.csrfToken = () => "csrf"; next(); }); + // Signing in, for the two pages that draw differently once you have. A header + // rather than a real session: what is under test is what the page says about + // a holder's contacts, not how the session cookie got there. + app.use((req, _res, next) => { + const id = req.headers["x-test-user"]; + if (id) req.user = { id, email: "holder@example.com" }; + next(); + }); + app.use(moshpitRouter); + const server = await new Promise((resolve) => { + const s = app.listen(0, "127.0.0.1", () => resolve(s)); + }); + const base = `http://127.0.0.1:${server.address().port}`; + const getJson = async (p) => { + const res = await fetch(`${base}${p}`); + return { status: res.status, body: await res.json() }; + }; + const getHtml = async (p, userId = null) => { + const res = await fetch(`${base}${p}`, { headers: userId ? { "x-test-user": userId } : {} }); + return { status: res.status, body: await res.text() }; + }; + // The one thing a test can do that the mail host would: mark the alias live. + const goLive = (tld, label) => + run(`UPDATE moshpit_contacts SET alias_status = 'live', alias_id = 'fe_1' WHERE tld = ? AND label = ?`, [tld, label]); + + return { server, db, run, get, getJson, getHtml, goLive, moshpit }; +} + +let booted = null; +const app = () => (booted ||= boot()); + +test.after(() => { + if (!booted) return; + booted.then(({ server, db }) => { server.close(); db.close?.(); }) + .finally(() => { try { fs.rmSync(workdir, { recursive: true, force: true }); } catch { /* noop */ } }); +}); + +const skip = { skip: !deps && "apps/pwa deps not installed" }; + +test("a contact is recorded even when there is no mail host to publish it", skip, async () => { + const { moshpit } = await app(); + const result = await moshpit.setContact({ + tld: "eggs", label: "blue", userId: "u1", email: "Holder ", + }); + + assert.equal(result.ok, true); + assert.equal(result.contact.email, "me@example.com"); + assert.equal(result.contact.visibility, "guard"); + // Not `failed`: an unconfigured mail host is a fact about the deployment, and + // showing the holder an error for it would be blaming them for our setup. + assert.equal(result.contact.alias_status, "pending"); + assert.equal(await moshpit.publicContactFor("eggs", "blue"), null); +}); + +test("once the alias is live the guard address is what the registry publishes", skip, async () => { + const { moshpit, goLive } = await app(); + await goLive("eggs", "blue"); + + const contact = await moshpit.publicContactFor("eggs", "blue"); + const row = await moshpit.getContactPrivate("eggs", "blue"); + assert.equal(contact.kind, "guard"); + assert.equal(contact.address, `${row.guard_token}@moshcode.sh`); + assert.doesNotMatch(contact.address, /example\.com/); +}); + +test("correcting the address keeps the one already printed on other people's pages", skip, async () => { + const { moshpit } = await app(); + const before = await moshpit.getContactPrivate("eggs", "blue"); + await moshpit.setContact({ tld: "eggs", label: "blue", userId: "u1", email: "corrected@example.com" }); + const after = await moshpit.getContactPrivate("eggs", "blue"); + + assert.equal(after.guard_token, before.guard_token); + assert.equal(after.email, "corrected@example.com"); +}); + +test("taking a contact down keeps the token, so putting it back is the same address", skip, async () => { + const { moshpit, goLive } = await app(); + const before = await moshpit.getContactPrivate("eggs", "blue"); + + await moshpit.setContact({ tld: "eggs", label: "blue", userId: "u1", email: "corrected@example.com", visibility: "none" }); + assert.equal(await moshpit.publicContactFor("eggs", "blue"), null); + + await moshpit.setContact({ tld: "eggs", label: "blue", userId: "u1", email: "corrected@example.com", visibility: "guard" }); + await goLive("eggs", "blue"); + const after = await moshpit.publicContactFor("eggs", "blue"); + assert.equal(after.address, `${before.guard_token}@moshcode.sh`); +}); + +test("only the holder may set a contact on a name", skip, async () => { + const { moshpit } = await app(); + const result = await moshpit.setContact({ tld: "eggs", label: "green", userId: "u2", email: "me@example.com" }); + assert.equal(result.ok, false); + assert.match(result.error, /do not own/); +}); + +test("an ending gets its own contact, and it is not inherited by names under it", skip, async () => { + const { moshpit, goLive } = await app(); + await moshpit.setContact({ tld: "eggs", userId: "u1", email: "operator@example.com" }); + await goLive("eggs", ""); + + assert.ok(await moshpit.publicContactFor("eggs")); + // `green.eggs` has no contact of its own. Falling back to the operator's + // would send a buyer's mail — or an abuse report — to someone who does not + // hold the name, while looking authoritative about it. + assert.equal(await moshpit.publicContactFor("eggs", "green"), null); +}); + +test("giving the name up takes the contact with it", skip, async () => { + const { moshpit, get } = await app(); + await moshpit.releaseName({ tld: "eggs", label: "blue", userId: "u1" }); + + const row = await get(`SELECT tld FROM moshpit_contacts WHERE tld = 'eggs' AND label = 'blue'`); + assert.ok(!row); + // The next holder must not inherit a forwarding address pointing at the last. + assert.equal(await moshpit.publicContactFor("eggs", "blue"), null); +}); + +test("the public contact route answers with an address or a definite 404", skip, async () => { + const { getJson, moshpit, goLive } = await app(); + await moshpit.setContact({ tld: "eggs", label: "green", userId: "u1", email: "green@example.com" }); + await goLive("eggs", "green"); + + const found = await getJson("/api/moshpit/contact?name=green.eggs"); + assert.equal(found.status, 200); + assert.equal(found.body.contact.kind, "guard"); + assert.match(found.body.contact.address, /@moshcode\.sh$/); + + const missing = await getJson("/api/moshpit/contact?name=nobody.eggs"); + assert.equal(missing.status, 404); + assert.equal(missing.body.contact, null); + + const ending = await getJson("/api/moshpit/contact?name=.eggs"); + assert.equal(ending.status, 200); + assert.equal(ending.body.label, null); +}); + +test("no route anywhere returns where a guard address forwards to", skip, async () => { + const { getJson } = await app(); + for (const route of ["/api/moshpit/contact?name=green.eggs", "/api/moshpit/tlds?limit=50"]) { + const { body } = await getJson(route); + assert.doesNotMatch(JSON.stringify(body), /green@example\.com/, `${route} leaked the real address`); + } +}); + +test("the endings list no longer hands out the account behind every ending", skip, async () => { + const { getJson } = await app(); + const { status, body } = await getJson("/api/moshpit/tlds?limit=50"); + + assert.equal(status, 200); + const serialized = JSON.stringify(body); + assert.doesNotMatch(serialized, /owner_email/); + assert.doesNotMatch(serialized, /holder@example\.com/); + assert.doesNotMatch(serialized, /other@example\.com/); + assert.doesNotMatch(serialized, /"user_id"/); +}); + +test("...but two endings held by one person are still visibly one person", skip, async () => { + // The digest is what keeps "who holds how much of the namespace" answerable + // after the email is gone. Losing that would make this a deletion rather + // than a redaction. + const { getJson, run } = await app(); + await run(`INSERT INTO moshpit_tlds (tld,user_id,owner_email,created_at) VALUES ('yolks','u1','holder@example.com',1)`); + const { body } = await getJson("/api/moshpit/tlds?limit=50"); + + const byTld = new Map(body.tlds.map((t) => [t.tld, t.owner])); + assert.ok(byTld.get("eggs")); + assert.equal(byTld.get("eggs"), byTld.get("yolks")); + assert.notEqual(byTld.get("eggs"), byTld.get("theirs")); +}); + +test("the list still says everything a mirror actually needs", skip, async () => { + const { getJson } = await app(); + const { body } = await getJson("/api/moshpit/tlds?limit=50"); + const eggs = body.tlds.find((t) => t.tld === "eggs"); + + assert.deepEqual(Object.keys(eggs).sort(), ["alias_of", "created_at", "owner", "price_usd", "tld"]); + assert.equal(typeof body.total, "number"); +}); + +/* ---- the pages ---- */ + +test("a name's page shows the guard address and not the address behind it", skip, async () => { + const { getHtml } = await app(); + const { status, body } = await getHtml("/n/green.eggs"); + + assert.equal(status, 200); + assert.match(body, /Contact/); + assert.match(body, /[0-9bcdfghjkmnpqrstvwxz]{10}@moshcode\.sh/); + assert.match(body, /forwards to the holder/); + assert.doesNotMatch(body, /green@example\.com/); +}); + +test("an ending's page shows the operator's contact, which is who a buyer needs", skip, async () => { + const { getHtml } = await app(); + const { status, body } = await getHtml("/n/.eggs"); + + assert.equal(status, 200); + assert.match(body, /[0-9bcdfghjkmnpqrstvwxz]{10}@moshcode\.sh/); + assert.doesNotMatch(body, /operator@example\.com/); +}); + +test("a name with no contact says nothing about one", skip, async () => { + const { getHtml, moshpit, run } = await app(); + await run(`INSERT OR IGNORE INTO moshpit_names (tld,label,user_id,target,created_at) VALUES ('eggs','quiet','u1',NULL,1)`); + assert.equal(await moshpit.publicContactFor("eggs", "quiet"), null); + + const { body } = await getHtml("/n/quiet.eggs"); + assert.doesNotMatch(body, /forwards to the holder/); + assert.doesNotMatch(body, /holder@example\.com/); +}); + +test("the contact tab lists what you publish, and never where it goes", skip, async () => { + const { getHtml } = await app(); + const { status, body } = await getHtml("/pit/contact", "u1"); + + assert.equal(status, 200); + assert.match(body, /without a WHOIS/); + assert.match(body, /green\.eggs/); + // The real address is withheld from the holder's own management page: it is + // not needed to manage the contact, and a page that prints it is one + // screenshot away from being the leak this change closes. + assert.doesNotMatch(body, /green@example\.com/); + assert.doesNotMatch(body, /operator@example\.com/); +}); + +test("the contact tab says plainly when no mail host can mint an address", skip, async () => { + const { getHtml } = await app(); + const { body } = await getHtml("/pit/contact", "u1"); + assert.match(body, /No mail host is configured/); +}); + +test("signed out, the contact tab asks you to sign in rather than 500ing", skip, async () => { + const { getHtml } = await app(); + const { status, body } = await getHtml("/pit/contact"); + assert.equal(status, 200); + assert.match(body, /Sign in to say how you can be reached/); +}); diff --git a/docs/contact-and-guard-addresses.md b/docs/contact-and-guard-addresses.md new file mode 100644 index 0000000..3fe7a55 --- /dev/null +++ b/docs/contact-and-guard-addresses.md @@ -0,0 +1,127 @@ +# Contact on a name, and the guard address behind it + +A Moshpit name can now say how to reach whoever holds it, without saying who +that is. The holder gives an address they read; the registry publishes +`k7m2xqbn3f@moshcode.sh`, which forwards to it. The real address is never in a +page, an API response, or the allocation log. + +This is opt-in and off by default. A name with no contact publishes nothing, +which is what every name registered before this shipped does, with no backfill. + +## What it replaced + +`GET /api/moshpit/tlds` used to return the `moshpit_tlds` row as it sits in the +table, which meant `owner_email` in cleartext for every ending in the registry — +thousands of real addresses, other people's included, to anyone who could count +to 200 in an `?offset=`. Nobody consented to it and nothing read it: not +`moshpit-registry`, not the DNS bridge, not a page in this app. + +That field is now redacted from the list, and the endpoint follows the policy +`/api/moshpit/log` already wrote down — ownership is public, the account behind +it is not. Endings still carry an `owner` digest, the same value the log +publishes, so two endings held by one person are still visibly one person and +"who holds how much of the namespace" is still answerable. What is gone is the +address, and a contact is the consented way to get one back. + +## The three states + +A holder picks one per name or ending, on `/pit/contact`: + +| visibility | what a visitor sees | +|---|---| +| `guard` | `@moshcode.sh`, forwarding to them. The default. | +| `public` | the address they typed, as typed. For a role address they are happy to expose. | +| `none` | nothing — but the token is kept, so switching back on restores the *same* address. | + +`none` is not the same as having no contact at all. A published address ends up +in other people's address books and on pages we do not control, so taking one +down for a week must not mint a different one on the way back. + +## The token + +Ten characters from digits and consonants — no vowels, no `0`/`o`, no `1`/`l`. +Excluding vowels does real work: a token can never spell a word, so a minted +address can never collide with a mailbox a person holds at the same domain. +`support@`, `abuse@` and `notify@` are unreachable from the alphabet itself +rather than by a reserved list somebody has to maintain. + +A token dies with the row. Releasing a name drops its contact and destroys the +alias alongside the pins, records and twin — otherwise the next holder inherits +a forwarding address pointing at the last one, and mail meant for them (an offer +for the name, an abuse report about it) goes to a stranger. + +## Standing the mail host up + +Nothing is published until an alias exists at the mail host, and no alias can +exist until `moshcode.sh` receives mail. As of this being written it does not: +the domain has no MX and no SPF record at all. + +Three steps, in order. The first is a hand-off — `moshcode.sh` is on Porkbun and +there are no Porkbun credentials on the dev box. + +**1. DNS on `moshcode.sh`.** MX is independent of the A record, so this does not +disturb `pit.` or `app.`: + +``` +moshcode.sh. MX 10 mx1.forwardemail.net. +moshcode.sh. MX 10 mx2.forwardemail.net. +moshcode.sh. TXT "v=spf1 include:spf.forwardemail.net -all" +``` + +Forward Email also issues a `forward-email-site-verification=…` TXT record when +the domain is added to the account; add it with the rest. A `_dmarc` TXT of +`v=DMARC1; p=none;` is worth having and is not required for forwarding. + +**2. The domain on the Forward Email account.** Add `moshcode.sh` there and +confirm the plan covers API alias management — the free tier forwards mail but +programmatic alias creation is a paid feature, and every mint fails without it. + +**3. The key, in the vault.** `FORWARDEMAIL_API_KEY` on the `moshcode` Railway +service, set from the logicsrc vault rather than committed to a `.env`. +`MOSHPIT_GUARD_DOMAIN` defaults to `moshcode.sh` and only needs setting to move +the addresses somewhere else. + +Until all three are done the feature is inert rather than broken: a contact is +recorded, `alias_status` stays `pending`, `/pit/contact` says plainly that no +mail host is configured, and nothing is published on any name. + +## Consent, twice + +Aliases are created with `has_recipient_verification` on. Forward Email sends +one confirmation link to the address given and forwards nothing until it is +clicked. That is what stops someone typing a stranger's address into the contact +form and pointing our domain at them — publishing a guard address needs consent +from the address itself, not just from whoever filled in the form. + +The cost is a short window where a holder has saved a contact and the address +does not yet forward. `/pit/contact` says so rather than letting them find out +from a bounce. + +Disabled aliases are set to reject with 550 rather than the default 250. An +address published as a way to reach somebody should tell a sender when the mail +did not arrive, instead of quietly accepting and discarding it. + +## Where it shows + +- `/n/` and `/n/.` — on the directory page, under **Contact**. + Not on a name that serves a site, a feed or an origin: that page belongs to + its holder and the registry has no business printing an address into it. +- `GET /api/moshpit/contact?name=blue.eggs` — public. 200 with an address, or a + definite 404, on the same reasoning as the pins route: clients cache on the + status, and "nobody to write to" is an answer worth caching. +- `/pit/contact` — where a holder adds, edits, hides or removes one. + +## Routes + +| route | who | +|---|---| +| `GET /api/moshpit/contact?name=` | anyone — the published address, or 404 | +| `GET /api/moshpit/tlds/:tld/contact[?label=]` | the holder — status and guard address, never the real one | +| `PUT /api/moshpit/tlds/:tld/contact` | the holder — `{ label?, email, visibility? }` | +| `DELETE /api/moshpit/tlds/:tld/contact` | the holder — `{ label? }` | +| `POST /api/moshpit/tlds/:tld/contact/retry` | the holder — after the mail host was down | + +Absent `label` means the ending itself. The real address is withheld even from +the holder's own management route: it is not needed to manage the contact, and +a route that returns it is one stray log line away from being the leak this +whole change closes. Changing where mail goes is a write, not a read.