Skip to content

Backfill a Moshpit name with a clearnet twin - #435

Merged
ralyodio merged 4 commits into
mainfrom
worktree-moshpit-backfill
Aug 28, 2026
Merged

Backfill a Moshpit name with a clearnet twin#435
ralyodio merged 4 commits into
mainfrom
worktree-moshpit-backfill

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

A pit name cannot be reached from outside the pit and cannot hold a certificate, because no CA will issue for an ending ICANN does not delegate. That is the ceiling on the namespace: people take the clean name and then hand out an ugly domain anyway, because the ugly one is the one that works.

A twin is a real registered domain a name publishes as its way in — financial.advisors backfilled by financial-advisors.net. The pit name stays the identity; the domain is only transport.

The transform is deterministic in both directions, because a pit label may not contain a hyphen. A twin therefore has exactly one hyphen in its stem and splits back into exactly one name with no lookup — which is what lets a client holding only the domain name the pit name it belongs to.

The four constraints

  • A twin never touches prefer. It is a domain that already answers in the legacy root, so folding it into precedence would have the pit outrank DNS for names DNS handed it — indistinguishable from the hijack the clearnet-wins default exists to prevent. Covered by a test comparing a backfilled name against a bare one, in both modes.
  • Ownership is proven before a twin is served, with one TXT record doing two jobs: publishing it proves control of the domain, and the same record is the reverse pointer that lets someone arriving at the domain discover the name. Two records would have let a domain prove itself and never advertise the name — the state where nobody learns the clean name exists.
  • A lapsed domain fails into whoever catches the drop, so the link is dropped on our clock a week ahead of the registrar's, read at query time rather than swept. A sweep that has not run yet is a window serving a link already known to be dead.
  • A domain whose stem reads as a different name is refused. Letting red-eggs.net back blue.eggs would make the cheap computation and the published proof disagree, and any client trusting the former is sent somewhere its owner never pointed it.

Releasing a name now takes its twin with it, deleted explicitly like the pins and records above it since foreign keys are not enforced here. It matters more than either: an inherited twin would point the next holder's visitors at a stranger's site under their own name.

Shape

src/lib/moshpit-twin.mjsthe rules, DB-free so the extension and DNS bridge can reuse them
src/migrations/015_moshpit_twins.sqlmoshpit_twins; PK (tld,label), partial unique index on verified domain
src/moshpit.mjsclaimTwin / verifyTwin / setTwinExpiry / removeTwin / twinForName / availableTwins / expiringTwins
src/routes/moshpit.mjssix endpoints, plus ?twin=1 on /api/moshpit/resolve

Not in this PR

The checkout. TWIN_PRICE_USD is settled at $12 and quoted by the API, but nothing charges it — buying the domain on a customer's behalf needs a registrar integration, which is its own unit of work. Half-wiring a payment rail would be worse than leaving the seam clean.

Tests

51 new. 626/626 pass in apps/pwa. Migration applies cleanly from scratch.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GWhPX5Uzd29whRg5WPAYM7

ralyodioand others added 3 commits August 28, 2026 20:28
A pit name cannot be reached from outside the pit and cannot hold a
certificate, because no CA will issue for an ending ICANN does not
delegate. That is the ceiling on the namespace: people take the clean
name and hand out an ugly domain anyway, because the ugly one works.
A twin is a real registered domain a name publishes as its way in --
`financial.advisors` backfilled by `financial-advisors.net`. The pit
name stays the identity; the domain is only transport.
The transform is deterministic in both directions, because a pit label
may not contain a hyphen. So a twin has exactly one hyphen in its stem
and splits back into exactly one name with no lookup, which is what
lets a client holding only the domain name the pit name it belongs to.
Four things the design holds to:
- A twin never touches `prefer`. It is a domain that already answers in
the legacy root, so folding it into precedence would have the pit
outrank DNS for names DNS handed it -- indistinguishable from the
hijack the clearnet-wins default exists to prevent. Covered by a test
that compares a backfilled name against a bare one.
- Ownership is proven before a twin is served, with one TXT record that
does two jobs: publishing it proves control of the domain, and the
same record is the reverse pointer that lets someone arriving at the
domain discover the name. Two records would have allowed a domain to
prove itself and never advertise the name, which is the state where
nobody learns the clean name exists.
- A lapsed domain does not fail closed, it fails into whoever catches
the drop. So the link is dropped on our clock, a week ahead of the
registrar's, and it is read at query time rather than swept -- a
sweep that has not run yet is a window serving a link already known
to be dead.
- A domain whose stem reads as a different name is refused. Letting
`red-eggs.net` back `blue.eggs` would make the cheap computation and
the published proof disagree, and any client trusting the former gets
sent somewhere its owner never pointed it.
Releasing a name takes its twin with it, deleted explicitly like the
pins and records above it, since foreign keys are not enforced here. It
matters more than either: an inherited twin would point the next
holder's visitors at a stranger's site under their own name.
Not built yet: the checkout. TWIN_PRICE_USD is settled and quoted by
the API, but nothing charges it, and buying the domain on a customer's
behalf needs a registrar integration that is its own unit of work.
51 new tests; 626 pass in apps/pwa.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWhPX5Uzd29whRg5WPAYM7
Migration 010 gave endings a one-year term with renewals, per PRD 0005
§5. That is withdrawn. The prices are unchanged; what changed is that
they are not charged again.
The reason is what the namespace is for. A name that lapses is a name
somebody else can catch, and the whole pitch is that you can finally
hold the clean name instead of the hyphenated one you settled for. An
annual invoice with a drop date attached is the thing people are
trying to get away from, and selling it back to them undoes the pitch.
Names were already sold this way and only the comments said otherwise:
`moshpit_names` has never had an expiry column and nothing has ever
renewed a name. The PRD called it a yearly fee, the schema sold it
outright, and the doc comment described the PRD. It now describes the
code.
Endings really did have the machinery, but it never shipped -- nothing
in the app could open an ending checkout, so `quoteTld` and
`quoteRenewal` were unreachable and only the webhook settler was wired
up. No ending was ever charged a renewal and no row in the wild has an
expiry this takes away, which is why migration 016 is a plain drop
rather than a grandfathering policy: §21.8 asks for one before putting
endings INTO a lifecycle, and taking them back out of one nobody was
in needs no such thing.
- `expires_at` and `term_started_at` are dropped from `moshpit_tlds`,
columns and index. Gone rather than left NULL, because a nullable
expiry is an annual term waiting to be switched back on.
- `moshpit_tld_purchases` KEEPS `kind` and `years`. That is a financial
record of what was sold at the time, and a ledger is not something to
rewrite once the product changes.
- A 'renew' row can no longer be created, but one may still settle
late. It is honoured rather than refused -- the buyer keeps the
ending, which now keeps itself. Refusing it would take money for
nothing.
- `isExpired()` stays and always answers no, so callers asking a fair
question get a permanent answer instead of an import that fails.
The pit page leads with it, since it is the one claim here worth making
above the fold and the whole reason to prefer this over a registrar.
Note for whoever writes the next migration: nothing may follow the last
statement in a .sql file but whitespace. migrate.mjs splits on
semicolons and hands each piece to libSQL, and a trailing comment-only
piece comes back as `SQLITE_OK: not an error`, which is as unhelpful as
it sounds.
591 pass in apps/pwa.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWhPX5Uzd29whRg5WPAYM7
const tld = normalizeTld(tldInput);
const label = normalizeLabel(labelInput);
if (!tld || !label) return null;
return get(`SELECT ${TWIN_COLS} FROM moshpit_twins WHERE tld = ? AND label = ?`, [tld, label]);
// silently satisfy a new one.
const token = randomBytes(16).toString("hex");
await run(
`INSERT INTO moshpit_twins (${TWIN_COLS}) VALUES (?,?,?,?,?,?,?,?,?)
}

export async function listTwinsForUser(userId) {
return all(`SELECT ${TWIN_COLS} FROM moshpit_twins WHERE user_id = ? ORDER BY created_at DESC`, [userId]);
*/
export async function expiringTwins({ within = 30 * 24 * 60 * 60 * 1000, now = Date.now(), limit = 500 } = {}) {
return all(
`SELECT ${TWIN_COLS} FROM moshpit_twins
const candidates = clearnetTwins(input);
if (!candidates.length) return [];
const rows = await all(
`SELECT domain FROM moshpit_twins WHERE status = 'verified' AND domain IN (${candidates.map(() => "?").join(",")})`,
@github-actions

github-actionsBot commented Aug 28, 2026

Copy link
Copy Markdown

ThreatCrush Security Scan

28 finding(s) in the 10 file(s) this pull request changes.

MEDIUM: 28

SeverityRuleLocation
MEDIUMsql-template-interpolationapps/pwa/src/moshpit.mjs:92
MEDIUMsql-template-interpolationapps/pwa/src/moshpit.mjs:106
MEDIUMsql-template-interpolationapps/pwa/src/moshpit.mjs:132
MEDIUMsql-template-interpolationapps/pwa/src/moshpit.mjs:319
MEDIUMsql-template-interpolationapps/pwa/src/moshpit.mjs:323
MEDIUMsql-template-interpolationapps/pwa/src/moshpit.mjs:368
MEDIUMsql-template-interpolationapps/pwa/src/moshpit.mjs:597
MEDIUMsql-template-interpolationapps/pwa/src/moshpit.mjs:778
MEDIUMsql-template-interpolationapps/pwa/src/moshpit.mjs:780
MEDIUMsql-template-interpolationapps/pwa/src/moshpit.mjs:839
MEDIUMsql-template-interpolationapps/pwa/src/moshpit.mjs:885
MEDIUMsql-template-interpolationapps/pwa/src/moshpit.mjs:955
MEDIUMsql-template-interpolationapps/pwa/src/moshpit.mjs:1058
MEDIUMsql-template-interpolationapps/pwa/src/moshpit.mjs:1081
MEDIUMsql-template-interpolationapps/pwa/src/moshpit.mjs:1103
MEDIUMsql-template-interpolationapps/pwa/src/moshpit.mjs:1302
MEDIUMsql-template-interpolationapps/pwa/src/moshpit.mjs:1452
MEDIUMsql-template-interpolationapps/pwa/src/moshpit.mjs:1601
MEDIUMsql-template-interpolationapps/pwa/src/moshpit.mjs:1607
MEDIUMsql-template-interpolationapps/pwa/src/moshpit.mjs:1654
MEDIUMsql-template-interpolationapps/pwa/src/moshpit.mjs:1703
MEDIUMsql-template-interpolationapps/pwa/src/moshpit.mjs:1734
MEDIUMsql-template-interpolationapps/pwa/src/moshpit.mjs:1834
MEDIUMsql-template-interpolationapps/pwa/src/moshpit.mjs:1953
MEDIUMsql-template-interpolationapps/pwa/src/moshpit.mjs:1965
MEDIUMsql-template-interpolationapps/pwa/src/moshpit.mjs:1977
MEDIUMjs-unescaped-html-sinkapps/pwa/src/routes/moshpit.mjs:2201
MEDIUMsql-template-interpolationapps/pwa/test/moshpit-terms.test.mjs:207
36 pre-existing finding(s) elsewhere in the repository — **HIGH/CRITICAL**: 5 | **MEDIUM**: 25 | **LOW**: 6

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

SeverityRuleLocation
HIGHjs-ssrf-outbound-requestapps/pwa/public/sw.js:45
HIGHtls-verification-disabledapps/pwa/src/lib/moshpit-gateway.mjs:299
HIGHsh-remote-script-executioninstall.sh:79
HIGHsh-remote-script-executioninstall.sh:83
HIGHtls-verification-disabledsrc/dns.mjs:766
MEDIUMjs-timing-unsafe-mac-compareapps/pwa/src/routes/settings-sync.mjs:192
MEDIUMjs-dynamic-code-executionapps/pwa/test/apikey-mask.test.mjs:129
MEDIUMsql-template-interpolationapps/pwa/test/credits-webhook-event-match.test.mjs:111
MEDIUMsql-template-interpolationapps/pwa/test/credits-webhook-event-match.test.mjs:131
MEDIUMsql-string-concatenationsrc/cli-schema.mjs:161
MEDIUMsql-string-concatenationsrc/cli-schema.mjs:503
MEDIUMsql-string-concatenationsrc/cli-schema.mjs:629
MEDIUMjs-uninitialized-buffersrc/pty.mjs:120
MEDIUMjs-timing-unsafe-mac-comparesrc/settings-sync.mjs:325
MEDIUMinsecure-temp-filetest/dns-disable-restore.test.mjs:93
MEDIUMinsecure-temp-filetest/dns-disable-restore.test.mjs:314
MEDIUMinsecure-temp-filetest/news.test.mjs:693
MEDIUMinsecure-temp-filetest/plugins.test.mjs:152
MEDIUMinsecure-temp-filetest/pty.test.mjs:28
MEDIUMinsecure-temp-filetest/pty.test.mjs:31

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

Snippets are redacted; ThreatCrush never prints matched credential material.

@ralyodio
ralyodio merged commit b90c979 into mainAug 28, 2026
6 checks passed
@ralyodioralyodio mentioned this pull request Aug 28, 2026
Sign up for freeto 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

@ralyodio@github-advanced-security