|
| 1 | +// Moshpit name resolution for the extension. |
| 2 | +// |
| 3 | +// The policy here is a straight port of apps/desktop/src/moshpit-resolve.ts — |
| 4 | +// same names, same semantics — because the extension is plain JS with no build |
| 5 | +// step and cannot import the launcher's TypeScript. The TS module stays the |
| 6 | +// reference implementation and keeps the exhaustive unit tests; this file is |
| 7 | +// what actually runs in the browser. Keep them in sync. |
| 8 | +// |
| 9 | +// See that module's header for why 'clearnet' is the default: silently |
| 10 | +// redirecting a domain that resolves perfectly well is indistinguishable from |
| 11 | +// hijacking it. |
| 12 | + |
| 13 | +export const DEFAULT_REGISTRY_BASE = 'https://pit.moshcode.sh'; |
| 14 | +export const DEFAULT_CONSOLE_BASE = 'https://app.moshcode.sh'; |
| 15 | + |
| 16 | +// The one label meaning "manage this namespace" rather than "visit a name". |
| 17 | +// Reserved, not claimable — otherwise whoever holds `.eggs` could register |
| 18 | +// `mosh.eggs` and own the page people use to check who holds `.eggs`. |
| 19 | +export const CONSOLE_LABEL = 'mosh'; |
| 20 | + |
| 21 | +// Where a name with no destination yet is parked. A name inside the namespace |
| 22 | +// should never dead-end on ERR_NAME_NOT_RESOLVED. |
| 23 | +export const DEFAULT_PARKING_BASE = 'https://moshcoding.com'; |
| 24 | + |
| 25 | +/** The parking page for a name with no destination yet. */ |
| 26 | +export function parkingUrlFor(name, parkingBase = DEFAULT_PARKING_BASE) { |
| 27 | + return `${parkingBase.replace(/\/+$/, '')}/parking?name=${encodeURIComponent(name)}`; |
| 28 | +} |
| 29 | + |
| 30 | +/** Read the settings the options page writes. */ |
| 31 | +export async function moshpitConfig() { |
| 32 | + const { moshpitConfig: cfg } = await chrome.storage.local.get('moshpitConfig'); |
| 33 | + return { |
| 34 | + mode: cfg?.mode === 'moshpit' ? 'moshpit' : 'clearnet', |
| 35 | + registryBase: (cfg?.registryBase || DEFAULT_REGISTRY_BASE).replace(/\/+$/, ''), |
| 36 | + consoleBase: (cfg?.consoleBase || DEFAULT_CONSOLE_BASE).replace(/\/+$/, ''), |
| 37 | + parkingBase: (cfg?.parkingBase || DEFAULT_PARKING_BASE).replace(/\/+$/, ''), |
| 38 | + }; |
| 39 | +} |
| 40 | + |
| 41 | +/** |
| 42 | + * Split a hostname the way the registry does: exactly one label and one TLD. |
| 43 | + * Anything else (`a.b.c`, a bare `localhost`, an IP) is not a Moshpit name and |
| 44 | + * must never be sent to the registry as if it were. |
| 45 | + */ |
| 46 | +export function parseRegistryName(hostname) { |
| 47 | + const host = String(hostname || '').trim().toLowerCase().replace(/\.$/, ''); |
| 48 | + if (!host || host.includes(':')) return null; |
| 49 | + if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return null; |
| 50 | + const parts = host.split('.'); |
| 51 | + if (parts.length !== 2) return null; |
| 52 | + const [label, tld] = parts; |
| 53 | + const LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; |
| 54 | + if (!LABEL.test(label) || !LABEL.test(tld)) return null; |
| 55 | + return { label, tld }; |
| 56 | +} |
| 57 | + |
| 58 | +/** The Pit URL for a `mosh.<tld>` hostname, or null when it isn't one. */ |
| 59 | +export function consoleUrlFor(hostname, consoleBase = DEFAULT_CONSOLE_BASE) { |
| 60 | + const parsed = parseRegistryName(hostname); |
| 61 | + if (!parsed || parsed.label !== CONSOLE_LABEL) return null; |
| 62 | + return `${consoleBase.replace(/\/+$/, '')}/pit?tld=${encodeURIComponent(parsed.tld)}`; |
| 63 | +} |
| 64 | + |
| 65 | +/** The URL that serves a resolved Moshpit name through the gateway. */ |
| 66 | +export function gatewayUrlFor(resolved, registryBase = DEFAULT_REGISTRY_BASE) { |
| 67 | + return `${registryBase.replace(/\/+$/, '')}/n/${encodeURIComponent(resolved)}`; |
| 68 | +} |
| 69 | + |
| 70 | +/** |
| 71 | + * Ask the registry about a name. Any failure returns null rather than throwing: |
| 72 | + * resolution sits in front of every navigation, so a registry that is slow, |
| 73 | + * down, or serving nonsense must degrade to "clearnet as usual". |
| 74 | + */ |
| 75 | +export async function lookupMoshpit(hostname, { registryBase, timeoutMs = 4000 } = {}) { |
| 76 | + const parsed = parseRegistryName(hostname); |
| 77 | + if (!parsed) return null; |
| 78 | + const base = (registryBase || DEFAULT_REGISTRY_BASE).replace(/\/+$/, ''); |
| 79 | + const controller = new AbortController(); |
| 80 | + const timer = setTimeout(() => controller.abort(), timeoutMs); |
| 81 | + try { |
| 82 | + const name = `${parsed.label}.${parsed.tld}`; |
| 83 | + const res = await fetch(`${base}/api/moshpit/resolve?name=${encodeURIComponent(name)}`, { |
| 84 | + signal: controller.signal, |
| 85 | + }); |
| 86 | + if (!res.ok) return null; |
| 87 | + const json = await res.json(); |
| 88 | + // `registered` means the TLD is claimed; `name_registered` means THIS name |
| 89 | + // is. `target` is the address — null until the name points somewhere, which |
| 90 | + // is what decides parked vs live. `resolved` echoes the name either way. |
| 91 | + const claimed = typeof json?.name_registered === 'boolean' ? json.name_registered : json?.registered; |
| 92 | + if (typeof claimed !== 'boolean') return null; |
| 93 | + return { |
| 94 | + registered: claimed, |
| 95 | + resolved: typeof json.resolved === 'string' ? json.resolved : name, |
| 96 | + target: typeof json.target === 'string' && json.target ? json.target : null, |
| 97 | + }; |
| 98 | + } catch { |
| 99 | + return null; |
| 100 | + } finally { |
| 101 | + clearTimeout(timer); |
| 102 | + } |
| 103 | +} |
| 104 | + |
| 105 | +/** |
| 106 | + * Decide which namespace a hostname belongs to. Pure and total: every branch |
| 107 | + * returns a decision with a reason, so the caller never has to invent |
| 108 | + * behaviour for an unhandled combination. |
| 109 | + */ |
| 110 | +export function decideResolution({ hostname, mode, clearnetResolves, moshpit, consoleBase, parkingBase }) { |
| 111 | + // `mosh.<tld>` is the registration console for `.<tld>`, not a name to fetch. |
| 112 | + // It obeys the SAME precedence as any other Moshpit answer rather than taking |
| 113 | + // an exemption — `mosh.org` and `mosh.com` are real clearnet domains. |
| 114 | + const consoleUrl = consoleUrlFor(hostname, consoleBase); |
| 115 | + if (consoleUrl) { |
| 116 | + if (mode === 'clearnet' && clearnetResolves) { |
| 117 | + return { use: 'clearnet', reason: 'clearnet answers for this name (Moshpit set to backfill only)' }; |
| 118 | + } |
| 119 | + const tld = parseRegistryName(hostname)?.tld; |
| 120 | + return { |
| 121 | + use: 'register', |
| 122 | + reason: `${CONSOLE_LABEL}.${tld} is the registration console for .${tld}`, |
| 123 | + url: consoleUrl, |
| 124 | + }; |
| 125 | + } |
| 126 | + |
| 127 | + // A registry outage must not take the ordinary web down with it — nor lie |
| 128 | + // with a parking page for a name it simply failed to look up. |
| 129 | + if (!moshpit) { |
| 130 | + return { use: 'clearnet', reason: 'Moshpit registry not consulted or unreachable' }; |
| 131 | + } |
| 132 | + |
| 133 | + // Claimed AND pointed somewhere — the precedence rules apply to it. |
| 134 | + if (moshpit.registered && moshpit.target) { |
| 135 | + if (mode === 'moshpit') { |
| 136 | + return { |
| 137 | + use: 'moshpit', |
| 138 | + reason: clearnetResolves |
| 139 | + ? 'registered in Moshpit — overriding the clearnet domain' |
| 140 | + : 'registered in Moshpit', |
| 141 | + resolved: moshpit.resolved, |
| 142 | + }; |
| 143 | + } |
| 144 | + if (clearnetResolves) { |
| 145 | + return { use: 'clearnet', reason: 'clearnet answers for this name (Moshpit set to backfill only)' }; |
| 146 | + } |
| 147 | + return { |
| 148 | + use: 'moshpit', |
| 149 | + reason: 'clearnet has no answer — resolved through Moshpit', |
| 150 | + resolved: moshpit.resolved, |
| 151 | + }; |
| 152 | + } |
| 153 | + |
| 154 | + // Unclaimed, or claimed but not pointed at an address yet — park it, so |
| 155 | + // `california.oranges` explains itself instead of looking broken. Only ever |
| 156 | + // where clearnet has nothing. |
| 157 | + if (!clearnetResolves && parseRegistryName(hostname)) { |
| 158 | + return { |
| 159 | + use: 'park', |
| 160 | + reason: moshpit.registered |
| 161 | + ? 'registered in Moshpit but not pointed at an address yet' |
| 162 | + : 'unclaimed Moshpit name — parked', |
| 163 | + url: parkingUrlFor(String(hostname).trim().toLowerCase().replace(/\.$/, ''), parkingBase), |
| 164 | + }; |
| 165 | + } |
| 166 | + if (clearnetResolves) { |
| 167 | + return { |
| 168 | + use: 'clearnet', |
| 169 | + reason: moshpit.registered |
| 170 | + ? 'registered in Moshpit but not pointed anywhere yet' |
| 171 | + : 'not registered in Moshpit', |
| 172 | + }; |
| 173 | + } |
| 174 | + return { use: 'clearnet', reason: 'not a Moshpit name' }; |
| 175 | +} |
| 176 | + |
| 177 | +/** |
| 178 | + * The whole policy for one navigation, as a URL to send the tab to (or null to |
| 179 | + * leave it alone). `clearnetResolves` is supplied by the caller because only it |
| 180 | + * knows whether DNS actually answered. |
| 181 | + */ |
| 182 | +export async function destinationFor(hostname, clearnetResolves) { |
| 183 | + if (!parseRegistryName(hostname)) return null; |
| 184 | + const { mode, registryBase, consoleBase, parkingBase } = await moshpitConfig(); |
| 185 | + |
| 186 | + // Skip the registry round-trip entirely for the console label — it is |
| 187 | + // reserved, so no lookup can change the answer. |
| 188 | + const isConsole = !!consoleUrlFor(hostname, consoleBase); |
| 189 | + const moshpit = isConsole ? null : await lookupMoshpit(hostname, { registryBase }); |
| 190 | + |
| 191 | + const decision = decideResolution({ hostname, mode, clearnetResolves, moshpit, consoleBase, parkingBase }); |
| 192 | + if (decision.use === 'register' || decision.use === 'park') return decision.url; |
| 193 | + if (decision.use === 'moshpit') return gatewayUrlFor(decision.resolved, registryBase); |
| 194 | + return null; |
| 195 | +} |
0 commit comments