Skip to content

Optional contact on a name, behind a forwarding guard address - #442

Merged
ralyodio merged 1 commit into
mainfrom
moshpit-contact-guard
Aug 29, 2026
Merged

Optional contact on a name, behind a forwarding guard address#442
ralyodio merged 1 commit into
mainfrom
moshpit-contact-guard

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

What

A Moshpit name can now say how to reach whoever holds it, without saying who that is. Opt-in, off by default.

The holder gives an address they read on /pit/contact; the registry publishes k7m2xqbn3f@moshcode.sh, which forwards there via Forward Email. Their real address is never in a page, an API response, or the allocation log — including on their own management route, which does not need it to work.

Why now

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, to anyone who could count to 200 in an ?offset=. The worst of both: personal addresses exposed with no consent, and still no dependable way to reach a name's holder.

Both halves are in this PR, because shipping either alone gets the trade wrong.

The redaction

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 and "who holds how much of the namespace" stays answerable. What is gone is the address.

Nothing read that field: not moshpit-registry, not the DNS bridge, not moshpit-name/moshpit-resolve, not a page in this app. The search branch of the same endpoint and /api/moshpit/market already redacted; this brings the last branch into line.

Three states

visibility what a visitor sees
guard <token>@moshcode.sh, forwarding. The default.
public the address as typed, for a role address they want exposed.
none nothing — token kept

none is not the same as no contact. A published address lives 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.

Details worth a look

  • Tokens exclude vowels, so one can never spell a word — a minted address cannot collide with a mailbox someone holds at the same domain. support@, abuse@, notify@ are unreachable from the alphabet rather than from a reserved list somebody has to maintain.
  • A contact dies with the name. releaseName drops the row and destroys the alias alongside the pins, records and twin. Otherwise the next holder inherits a forwarding address pointing at the last one, and an offer for the name goes to a stranger.
  • Recipient verification is on. Forward Email confirms with the address before forwarding, so publishing a guard address needs consent from the address itself and not just from whoever filled in the form — without it the registry is a spam relay wearing our domain. Cost: a short window where a contact is saved and not yet forwarding, which /pit/contact states rather than letting the holder find out from a bounce.
  • Disabled aliases reject with 550, not the default 250. An address published as a way to reach somebody should tell a sender the mail did not arrive.
  • Contacts are not inherited. A name with none does not fall back to its ending's, or a buyer's mail and abuse reports about a sold name would go to the ending's operator while looking authoritative.
  • Intent is written before the mail host is called, so a network blip loses the sync and not the holder's choice.

Not live yet — needs DNS

moshcode.sh has no MX and no SPF today, so no alias can be minted. Without FORWARDEMAIL_API_KEY 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.

Three steps in docs/contact-and-guard-addresses.md. The first is a hand-off — moshcode.sh is on Porkbun and there are no credentials on the dev box:

  1. MX to mx1/mx2.forwardemail.net, SPF, and their site-verification TXT.
  2. Add the domain on the Forward Email account, on a plan that covers API alias management.
  3. FORWARDEMAIL_API_KEY on the Railway service, from the vault.

Tests

28 new, covering the rules, the storage lifecycle, the routes and the pages. Full suite 670/670 green.

🤖 Generated with Claude Code

https://claude.ai/code/session_017QSp1aAoQQB7jFV7hwt4jJ

…address

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 `<token>@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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017QSp1aAoQQB7jFV7hwt4jJ
Comment thread apps/pwa/src/moshpit.mjs
* 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]);
Comment thread apps/pwa/src/moshpit.mjs

/** 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]);
Comment thread apps/pwa/src/moshpit.mjs
*/
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 ?`,
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",
// 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 };
});

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" };
@github-actions

Copy link
Copy Markdown

ThreatCrush Security Scan

33 finding(s) in the 9 file(s) this pull request changes.

MEDIUM: 30 | LOW: 3

Severity Rule Location
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:112
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:126
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:152
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:339
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:343
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:388
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:626
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:807
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:809
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:868
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:914
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:984
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:1087
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:1110
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:1132
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:1331
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:1481
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:1630
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:1636
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:1683
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:1732
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:1763
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:1863
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:1982
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:1994
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:2006
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:2040
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:2238
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:2251
MEDIUM js-unescaped-html-sink apps/pwa/src/routes/moshpit.mjs:2406
LOW secret-generic-credential apps/pwa/test/moshpit-contact.test.mjs:103
LOW secret-generic-credential apps/pwa/test/moshpit-contact.test.mjs:112
LOW secret-generic-credential apps/pwa/test/moshpit-contact.test.mjs:118
38 pre-existing finding(s) elsewhere in the repository — **HIGH/CRITICAL**: 5 | **MEDIUM**: 27 | **LOW**: 6

Not introduced by this pull request. The full set is in the Security tab.

Severity Rule Location
HIGH js-ssrf-outbound-request apps/pwa/public/sw.js:45
HIGH tls-verification-disabled apps/pwa/src/lib/moshpit-gateway.mjs:299
HIGH sh-remote-script-execution install.sh:79
HIGH sh-remote-script-execution install.sh:83
HIGH tls-verification-disabled src/dns.mjs:766
MEDIUM js-timing-unsafe-mac-compare apps/pwa/src/routes/settings-sync.mjs:192
MEDIUM js-dynamic-code-execution apps/pwa/test/apikey-mask.test.mjs:129
MEDIUM sql-template-interpolation apps/pwa/test/credits-webhook-event-match.test.mjs:111
MEDIUM sql-template-interpolation apps/pwa/test/credits-webhook-event-match.test.mjs:131
MEDIUM sql-template-interpolation apps/pwa/test/moshpit-terms.test.mjs:207
MEDIUM sql-string-concatenation src/cli-schema.mjs:162
MEDIUM sql-string-concatenation src/cli-schema.mjs:504
MEDIUM sql-string-concatenation src/cli-schema.mjs:630
MEDIUM redos-nested-quantifier src/clients.mjs:29
MEDIUM js-uninitialized-buffer src/pty.mjs:120
MEDIUM js-timing-unsafe-mac-compare src/settings-sync.mjs:325
MEDIUM insecure-temp-file test/dns-disable-restore.test.mjs:93
MEDIUM insecure-temp-file test/dns-disable-restore.test.mjs:314
MEDIUM insecure-temp-file test/news.test.mjs:693
MEDIUM insecure-temp-file test/plugins.test.mjs:167

…and 18 more. Full results in the Security tab.

Snippets are redacted; ThreatCrush never prints matched credential material.

@ralyodio
ralyodio merged commit 5c54fd4 into main Aug 29, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants