From 814cb0ecfc2f80f1851b636fa9b0e7e3e291827b Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 29 Aug 2026 08:22:32 +0000 Subject: [PATCH] feat(moshpit): optional contact on a name, behind a forwarding guard address MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The registry had no contact field and was publishing contact details anyway: GET /api/moshpit/tlds returned `owner_email` in cleartext for every ending, unauthenticated and pageable — thousands of real addresses, other people's included. The worst of both: personal addresses exposed with no consent, and still no dependable way to reach whoever holds a name. Both halves land together. A holder can now opt in on /pit/contact. They say where they read mail; the registry publishes `@moshcode.sh`, which forwards there via Forward Email. The real address is never in a page, an API response, or the log — including on the holder's own management route, which does not need it. Three states per name or ending: `guard` (the forwarding address, default), `public` (their address as typed), `none` (nothing published, token kept — a published address lives in other people's address books, so taking it down for a week must not mint a different one on the way back). Absence of a row is the default and means no contact, which is what every existing name has, with no backfill. Tokens are ten characters of digits and consonants. Excluding vowels means a token can never spell a word, so a minted address can never collide with a mailbox someone holds at the same domain — support@, abuse@ and notify@ are unreachable from the alphabet rather than from a reserved list. A contact dies with the name: releaseName drops the row and destroys the alias alongside the pins, records and twin, or the next holder inherits a forwarding address pointing at the last one. Aliases are created with recipient verification on, so publishing a guard address needs consent from the address itself and not just from whoever filled in the form — otherwise the registry is a spam relay wearing our domain. Disabled aliases reject with 550 rather than silently discarding. The endings list now follows the policy /api/moshpit/log already wrote down: ownership is public, the account behind it is not. Endings keep the same owner digest the log publishes, so two held by one person are still visibly one person. No client read `owner_email` — not moshpit-registry, the DNS bridge, or any page here. Inert until DNS is done: moshcode.sh has no MX today. Without a key a contact is recorded, stays `pending`, and publishes nothing. docs/ has the three steps. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017QSp1aAoQQB7jFV7hwt4jJ --- apps/pwa/.env.example | 10 + apps/pwa/src/config.mjs | 21 + apps/pwa/src/lib/forwardemail.mjs | 142 ++++++ apps/pwa/src/lib/moshpit-contact.mjs | 169 ++++++++ .../src/migrations/017_moshpit_contact.sql | 97 +++++ apps/pwa/src/moshpit.mjs | 272 ++++++++++++ apps/pwa/src/routes/moshpit.mjs | 406 +++++++++++++++++- apps/pwa/test/moshpit-contact.test.mjs | 382 ++++++++++++++++ docs/contact-and-guard-addresses.md | 127 ++++++ 9 files changed, 1619 insertions(+), 7 deletions(-) create mode 100644 apps/pwa/src/lib/forwardemail.mjs create mode 100644 apps/pwa/src/lib/moshpit-contact.mjs create mode 100644 apps/pwa/src/migrations/017_moshpit_contact.sql create mode 100644 apps/pwa/test/moshpit-contact.test.mjs create mode 100644 docs/contact-and-guard-addresses.md diff --git a/apps/pwa/.env.example b/apps/pwa/.env.example index ce25b72e..b00178db 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 65ee41d4..75a3b4e7 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 00000000..07a51391 --- /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 00000000..6b766cee --- /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 00000000..95cd7fd4 --- /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 0b82c98d..fc07a5e3 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 1849365d..93ee9b85 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 00000000..18fc1047 --- /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 00000000..3fe7a558 --- /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.