diff --git a/apps/desktop/extensions/ai-sidebar/background.js b/apps/desktop/extensions/ai-sidebar/background.js index e0f4992..9f61814 100644 --- a/apps/desktop/extensions/ai-sidebar/background.js +++ b/apps/desktop/extensions/ai-sidebar/background.js @@ -1,3 +1,5 @@ +import { destinationFor, moshpitConfig, parseRegistryName } from './moshpit.js'; + // Open the AI side panel when the toolbar action is clicked. chrome.sidePanel .setPanelBehavior({ openPanelOnActionClick: true }) @@ -288,3 +290,68 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { } } catch (_) { /* best effort */ } })(); + +// --- Moshpit name resolution --------------------------------------------- +// This is what makes the Moshpit settings on the options page actually do +// something: until now they were written to storage and never read. +// +// Two hooks, because "does clearnet answer for this name?" is only knowable at +// two different moments: +// +// onErrorOccurred — DNS came up empty (ERR_NAME_NOT_RESOLVED). This is the +// backfill path, and the ONLY one active in the default 'clearnet' mode, so +// someone who has never heard of Moshpit gets ordinary browsing plus a +// rescued error page. Nothing that already works is touched. +// +// onBeforeNavigate — consulted ONLY in 'moshpit' mode, where a registered +// name is meant to win even though clearnet has an answer. It costs a +// registry round-trip before navigation, which is why the default mode +// never goes near it. +// +// No redirect loop: every destination we send a tab to (pit.moshcode.sh/n/…, +// app.moshcode.sh/pit) has three labels, so parseRegistryName rejects it and +// the hooks ignore it on the way back through. + +const DNS_FAILED = new Set([ + 'net::ERR_NAME_NOT_RESOLVED', + 'net::ERR_NAME_RESOLUTION_FAILED', +]); + +function moshpitHostname(url) { + try { + const u = new URL(url); + if (u.protocol !== 'http:' && u.protocol !== 'https:') return ''; + return parseRegistryName(u.hostname) ? u.hostname : ''; + } catch { + return ''; + } +} + +async function sendTabTo(tabId, url) { + try { + await chrome.tabs.update(tabId, { url }); + } catch (err) { + console.warn('moshpit redirect:', err); + } +} + +chrome.webNavigation?.onErrorOccurred.addListener(async (details) => { + if (details.frameId !== 0) return; // top-level navigations only + if (!DNS_FAILED.has(details.error)) return; + const hostname = moshpitHostname(details.url); + if (!hostname) return; + const dest = await destinationFor(hostname, false); + if (dest) await sendTabTo(details.tabId, dest); +}); + +chrome.webNavigation?.onBeforeNavigate.addListener(async (details) => { + if (details.frameId !== 0) return; + const hostname = moshpitHostname(details.url); + if (!hostname) return; + // The default mode must never pre-empt a working clearnet domain — bail out + // before the registry is ever contacted. + const { mode } = await moshpitConfig(); + if (mode !== 'moshpit') return; + const dest = await destinationFor(hostname, true); + if (dest) await sendTabTo(details.tabId, dest); +}); diff --git a/apps/desktop/extensions/ai-sidebar/manifest.json b/apps/desktop/extensions/ai-sidebar/manifest.json index f7225f5..cea39a9 100644 --- a/apps/desktop/extensions/ai-sidebar/manifest.json +++ b/apps/desktop/extensions/ai-sidebar/manifest.json @@ -15,6 +15,7 @@ "tabs", "activeTab", "scripting", + "webNavigation", "proxy", "privacy", "notifications" @@ -35,7 +36,8 @@ "https://*/*" ], "background": { - "service_worker": "background.js" + "service_worker": "background.js", + "type": "module" }, "content_scripts": [ { diff --git a/apps/desktop/extensions/ai-sidebar/moshpit.js b/apps/desktop/extensions/ai-sidebar/moshpit.js new file mode 100644 index 0000000..6532cf7 --- /dev/null +++ b/apps/desktop/extensions/ai-sidebar/moshpit.js @@ -0,0 +1,195 @@ +// Moshpit name resolution for the extension. +// +// The policy here is a straight port of apps/desktop/src/moshpit-resolve.ts — +// same names, same semantics — because the extension is plain JS with no build +// step and cannot import the launcher's TypeScript. The TS module stays the +// reference implementation and keeps the exhaustive unit tests; this file is +// what actually runs in the browser. Keep them in sync. +// +// See that module's header for why 'clearnet' is the default: silently +// redirecting a domain that resolves perfectly well is indistinguishable from +// hijacking it. + +export const DEFAULT_REGISTRY_BASE = 'https://pit.moshcode.sh'; +export const DEFAULT_CONSOLE_BASE = 'https://app.moshcode.sh'; + +// The one label meaning "manage this namespace" rather than "visit a name". +// Reserved, not claimable — otherwise whoever holds `.eggs` could register +// `mosh.eggs` and own the page people use to check who holds `.eggs`. +export const CONSOLE_LABEL = 'mosh'; + +// Where a name with no destination yet is parked. A name inside the namespace +// should never dead-end on ERR_NAME_NOT_RESOLVED. +export const DEFAULT_PARKING_BASE = 'https://moshcoding.com'; + +/** The parking page for a name with no destination yet. */ +export function parkingUrlFor(name, parkingBase = DEFAULT_PARKING_BASE) { + return `${parkingBase.replace(/\/+$/, '')}/parking?name=${encodeURIComponent(name)}`; +} + +/** Read the settings the options page writes. */ +export async function moshpitConfig() { + const { moshpitConfig: cfg } = await chrome.storage.local.get('moshpitConfig'); + return { + mode: cfg?.mode === 'moshpit' ? 'moshpit' : 'clearnet', + registryBase: (cfg?.registryBase || DEFAULT_REGISTRY_BASE).replace(/\/+$/, ''), + consoleBase: (cfg?.consoleBase || DEFAULT_CONSOLE_BASE).replace(/\/+$/, ''), + parkingBase: (cfg?.parkingBase || DEFAULT_PARKING_BASE).replace(/\/+$/, ''), + }; +} + +/** + * Split a hostname the way the registry does: exactly one label and one TLD. + * Anything else (`a.b.c`, a bare `localhost`, an IP) is not a Moshpit name and + * must never be sent to the registry as if it were. + */ +export function parseRegistryName(hostname) { + const host = String(hostname || '').trim().toLowerCase().replace(/\.$/, ''); + if (!host || host.includes(':')) return null; + if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return null; + const parts = host.split('.'); + if (parts.length !== 2) return null; + const [label, tld] = parts; + const LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; + if (!LABEL.test(label) || !LABEL.test(tld)) return null; + return { label, tld }; +} + +/** The Pit URL for a `mosh.` hostname, or null when it isn't one. */ +export function consoleUrlFor(hostname, consoleBase = DEFAULT_CONSOLE_BASE) { + const parsed = parseRegistryName(hostname); + if (!parsed || parsed.label !== CONSOLE_LABEL) return null; + return `${consoleBase.replace(/\/+$/, '')}/pit?tld=${encodeURIComponent(parsed.tld)}`; +} + +/** The URL that serves a resolved Moshpit name through the gateway. */ +export function gatewayUrlFor(resolved, registryBase = DEFAULT_REGISTRY_BASE) { + return `${registryBase.replace(/\/+$/, '')}/n/${encodeURIComponent(resolved)}`; +} + +/** + * Ask the registry about a name. Any failure returns null rather than throwing: + * resolution sits in front of every navigation, so a registry that is slow, + * down, or serving nonsense must degrade to "clearnet as usual". + */ +export async function lookupMoshpit(hostname, { registryBase, timeoutMs = 4000 } = {}) { + const parsed = parseRegistryName(hostname); + if (!parsed) return null; + const base = (registryBase || DEFAULT_REGISTRY_BASE).replace(/\/+$/, ''); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const name = `${parsed.label}.${parsed.tld}`; + const res = await fetch(`${base}/api/moshpit/resolve?name=${encodeURIComponent(name)}`, { + signal: controller.signal, + }); + if (!res.ok) return null; + const json = await res.json(); + // `registered` means the TLD is claimed; `name_registered` means THIS name + // is. `target` is the address — null until the name points somewhere, which + // is what decides parked vs live. `resolved` echoes the name either way. + const claimed = typeof json?.name_registered === 'boolean' ? json.name_registered : json?.registered; + if (typeof claimed !== 'boolean') return null; + return { + registered: claimed, + resolved: typeof json.resolved === 'string' ? json.resolved : name, + target: typeof json.target === 'string' && json.target ? json.target : null, + }; + } catch { + return null; + } finally { + clearTimeout(timer); + } +} + +/** + * Decide which namespace a hostname belongs to. Pure and total: every branch + * returns a decision with a reason, so the caller never has to invent + * behaviour for an unhandled combination. + */ +export function decideResolution({ hostname, mode, clearnetResolves, moshpit, consoleBase, parkingBase }) { + // `mosh.` is the registration console for `.`, not a name to fetch. + // It obeys the SAME precedence as any other Moshpit answer rather than taking + // an exemption — `mosh.org` and `mosh.com` are real clearnet domains. + const consoleUrl = consoleUrlFor(hostname, consoleBase); + if (consoleUrl) { + if (mode === 'clearnet' && clearnetResolves) { + return { use: 'clearnet', reason: 'clearnet answers for this name (Moshpit set to backfill only)' }; + } + const tld = parseRegistryName(hostname)?.tld; + return { + use: 'register', + reason: `${CONSOLE_LABEL}.${tld} is the registration console for .${tld}`, + url: consoleUrl, + }; + } + + // A registry outage must not take the ordinary web down with it — nor lie + // with a parking page for a name it simply failed to look up. + if (!moshpit) { + return { use: 'clearnet', reason: 'Moshpit registry not consulted or unreachable' }; + } + + // Claimed AND pointed somewhere — the precedence rules apply to it. + if (moshpit.registered && moshpit.target) { + if (mode === 'moshpit') { + return { + use: 'moshpit', + reason: clearnetResolves + ? 'registered in Moshpit — overriding the clearnet domain' + : 'registered in Moshpit', + resolved: moshpit.resolved, + }; + } + if (clearnetResolves) { + return { use: 'clearnet', reason: 'clearnet answers for this name (Moshpit set to backfill only)' }; + } + return { + use: 'moshpit', + reason: 'clearnet has no answer — resolved through Moshpit', + resolved: moshpit.resolved, + }; + } + + // Unclaimed, or claimed but not pointed at an address yet — park it, so + // `california.oranges` explains itself instead of looking broken. Only ever + // where clearnet has nothing. + if (!clearnetResolves && parseRegistryName(hostname)) { + return { + use: 'park', + reason: moshpit.registered + ? 'registered in Moshpit but not pointed at an address yet' + : 'unclaimed Moshpit name — parked', + url: parkingUrlFor(String(hostname).trim().toLowerCase().replace(/\.$/, ''), parkingBase), + }; + } + if (clearnetResolves) { + return { + use: 'clearnet', + reason: moshpit.registered + ? 'registered in Moshpit but not pointed anywhere yet' + : 'not registered in Moshpit', + }; + } + return { use: 'clearnet', reason: 'not a Moshpit name' }; +} + +/** + * The whole policy for one navigation, as a URL to send the tab to (or null to + * leave it alone). `clearnetResolves` is supplied by the caller because only it + * knows whether DNS actually answered. + */ +export async function destinationFor(hostname, clearnetResolves) { + if (!parseRegistryName(hostname)) return null; + const { mode, registryBase, consoleBase, parkingBase } = await moshpitConfig(); + + // Skip the registry round-trip entirely for the console label — it is + // reserved, so no lookup can change the answer. + const isConsole = !!consoleUrlFor(hostname, consoleBase); + const moshpit = isConsole ? null : await lookupMoshpit(hostname, { registryBase }); + + const decision = decideResolution({ hostname, mode, clearnetResolves, moshpit, consoleBase, parkingBase }); + if (decision.use === 'register' || decision.use === 'park') return decision.url; + if (decision.use === 'moshpit') return gatewayUrlFor(decision.resolved, registryBase); + return null; +} diff --git a/apps/desktop/extensions/ai-sidebar/moshpit.test.js b/apps/desktop/extensions/ai-sidebar/moshpit.test.js new file mode 100644 index 0000000..7819b47 --- /dev/null +++ b/apps/desktop/extensions/ai-sidebar/moshpit.test.js @@ -0,0 +1,188 @@ +// The extension's moshpit.js is a hand port of ../../src/moshpit-resolve.ts, +// because the extension is plain JS with no build step. A port that silently +// drifts from its reference is worse than no port at all — it makes the browser +// behave differently from the module everyone reads and tests. So these tests +// run BOTH implementations over the same inputs and require identical answers. + +import { describe, expect, it } from 'vitest'; + +import * as ts from '../../src/moshpit-resolve'; +import * as js from './moshpit.js'; + +const HOSTNAMES = [ + 'mosh.eggs', + 'MOSH.Whatever', + 'mosh.org', + 'moshy.eggs', + 'eggs.mosh', + 'fuck.yeah', + 'profullstack.ai', + 'a.b.c', + 'localhost', + '127.0.0.1', + 'mosh', + '', + 'x.y', +]; + +describe('moshpit.js is faithful to moshpit-resolve.ts', () => { + it('parseRegistryName agrees on every hostname shape', () => { + for (const h of HOSTNAMES) { + expect(js.parseRegistryName(h), h).toEqual(ts.parseRegistryName(h)); + } + }); + + it('consoleUrlFor agrees on every hostname shape', () => { + for (const h of HOSTNAMES) { + expect(js.consoleUrlFor(h), h).toEqual(ts.consoleUrlFor(h)); + } + }); + + it('gatewayUrlFor agrees', () => { + expect(js.gatewayUrlFor('fuck.yeah')).toBe(ts.gatewayUrlFor('fuck.yeah')); + expect(js.gatewayUrlFor('fuck.yeah', 'https://my.pit/')).toBe( + ts.gatewayUrlFor('fuck.yeah', 'https://my.pit/'), + ); + }); + + it('decideResolution agrees across the whole input space', () => { + const lookups = [ + null, + { registered: false, resolved: '', target: null }, + { registered: true, resolved: 'r.eggs', target: null }, // claimed, unpointed -> parks + { registered: true, resolved: 'r.eggs', target: '203.0.113.7' }, // live + ]; + for (const hostname of HOSTNAMES) { + for (const mode of ['clearnet', 'moshpit']) { + for (const clearnetResolves of [true, false]) { + for (const moshpit of lookups) { + const inputs = { hostname, mode, clearnetResolves, moshpit }; + expect(js.decideResolution(inputs), JSON.stringify(inputs)).toEqual( + ts.decideResolution(inputs), + ); + } + } + } + } + }); + + it('shares the same constants', () => { + expect(js.CONSOLE_LABEL).toBe(ts.CONSOLE_LABEL); + expect(js.DEFAULT_CONSOLE_BASE).toBe(ts.DEFAULT_CONSOLE_BASE); + expect(js.DEFAULT_REGISTRY_BASE).toBe(ts.DEFAULT_REGISTRY_BASE); + }); +}); + +describe('destinationFor — the URL a navigation actually ends up at', () => { + const withConfig = (moshpitConfig) => { + globalThis.chrome = { + storage: { local: { get: async () => ({ moshpitConfig }) } }, + }; + }; + + it('sends mosh. to the Pit without ever contacting the registry', async () => { + withConfig({ mode: 'clearnet' }); + globalThis.fetch = () => { + throw new Error('registry must not be consulted for the reserved label'); + }; + expect(await js.destinationFor('mosh.eggs', false)).toBe( + 'https://app.moshcode.sh/pit?tld=eggs', + ); + }); + + it('leaves a working clearnet domain alone in the default mode', async () => { + withConfig({ mode: 'clearnet' }); + expect(await js.destinationFor('mosh.org', true)).toBeNull(); + }); + + it('ignores anything that is not a single label + TLD', async () => { + withConfig({ mode: 'moshpit' }); + expect(await js.destinationFor('a.b.c', false)).toBeNull(); + expect(await js.destinationFor('pit.moshcode.sh', false)).toBeNull(); // no redirect loop + expect(await js.destinationFor('app.moshcode.sh', false)).toBeNull(); + }); + + it('routes a registered name through the gateway when DNS came up empty', async () => { + withConfig({ mode: 'clearnet' }); + globalThis.fetch = async () => ({ + ok: true, + json: async () => ({ name_registered: true, resolved: 'original.sploof', target: '203.0.113.7' }), + }); + expect(await js.destinationFor('alias.sploof', false)).toBe( + 'https://pit.moshcode.sh/n/original.sploof', + ); + }); + + it('degrades to clearnet when the registry is unreachable', async () => { + withConfig({ mode: 'moshpit' }); + globalThis.fetch = async () => { + throw new Error('ECONNREFUSED'); + }; + expect(await js.destinationFor('fuck.yeah', true)).toBeNull(); + }); + + it('honours a self-hosted registry base', async () => { + withConfig({ mode: 'clearnet', registryBase: 'https://my.pit/' }); + let asked = ''; + globalThis.fetch = async (url) => { + asked = url; + return { ok: true, json: async () => ({ name_registered: true, resolved: 'a.eggs', target: '203.0.113.7' }) }; + }; + expect(await js.destinationFor('a.eggs', false)).toBe('https://my.pit/n/a.eggs'); + expect(asked).toContain('https://my.pit/api/moshpit/resolve'); + }); +}); + +describe('destinationFor — parking', () => { + it('sends an unclaimed name to the parking page', async () => { + globalThis.chrome = { storage: { local: { get: async () => ({ moshpitConfig: { mode: 'clearnet' } }) } } }; + globalThis.fetch = async () => ({ ok: true, json: async () => ({ name_registered: false, target: null }) }); + expect(await js.destinationFor('california.oranges', false)).toBe( + 'https://moshcoding.com/parking?name=california.oranges', + ); + }); + + it('leaves a working clearnet domain alone rather than parking it', async () => { + globalThis.chrome = { storage: { local: { get: async () => ({ moshpitConfig: { mode: 'clearnet' } }) } } }; + globalThis.fetch = async () => ({ ok: true, json: async () => ({ name_registered: false, target: null }) }); + expect(await js.destinationFor('example.com', true)).toBeNull(); + }); +}); + +describe('lookupMoshpit — the registry payload as it really is', () => { + const asRegistry = (payload) => { + globalThis.fetch = async () => ({ ok: true, json: async () => payload }); + }; + + it('parks a claimed-but-unpointed name — the real california.oranges response', async () => { + globalThis.chrome = { storage: { local: { get: async () => ({ moshpitConfig: { mode: 'clearnet' } }) } } }; + // Verbatim from https://pit.moshcode.sh/api/moshpit/resolve?name=california.oranges + asRegistry({ + name: 'california.oranges', + resolved: 'california.oranges', + aliased: false, + registered: true, + name_registered: true, + target: null, + mode: 'clearnet', + prefer: 'fallback', + }); + expect(await js.destinationFor('california.oranges', false)).toBe( + 'https://moshcoding.com/parking?name=california.oranges', + ); + }); + + it('reads name_registered, not the TLD-level registered flag', async () => { + asRegistry({ registered: true, name_registered: false, resolved: 'x.eggs', target: null }); + expect(await js.lookupMoshpit('x.eggs')).toEqual({ + registered: false, + resolved: 'x.eggs', + target: null, + }); + }); + + it('treats a present target as the address the name points at', async () => { + asRegistry({ name_registered: true, resolved: 'x.eggs', target: '203.0.113.7' }); + expect((await js.lookupMoshpit('x.eggs')).target).toBe('203.0.113.7'); + }); +}); diff --git a/apps/desktop/extensions/ai-sidebar/options.html b/apps/desktop/extensions/ai-sidebar/options.html index 921e15d..d8d9c95 100644 --- a/apps/desktop/extensions/ai-sidebar/options.html +++ b/apps/desktop/extensions/ai-sidebar/options.html @@ -92,6 +92,12 @@

Name resolution

Overriding means a domain someone else holds on clearnet resolves to your Moshpit registration instead. Left on the default, a domain that already works is never redirected.

+

+ To claim a namespace, type mosh. followed by the ending you want — + mosh.eggs opens the Pit for .eggs, where you register it and + every name.eggs under it. mosh.anything is reserved, so + nobody can register it and impersonate that page. +

diff --git a/apps/desktop/src/moshpit-resolve.test.ts b/apps/desktop/src/moshpit-resolve.test.ts index 3e3acca..51365f5 100644 --- a/apps/desktop/src/moshpit-resolve.test.ts +++ b/apps/desktop/src/moshpit-resolve.test.ts @@ -10,8 +10,10 @@ import { consoleUrlFor, } from './moshpit-resolve'; -const registered = (resolved: string): MoshpitLookup => ({ registered: true, resolved }); -const unregistered: MoshpitLookup = { registered: false, resolved: '' }; +const registered = (resolved: string): MoshpitLookup => ({ registered: true, resolved, target: '203.0.113.7' }); +const unregistered: MoshpitLookup = { registered: false, resolved: '', target: null }; +/** Claimed, but never pointed at an address — the state every name starts in. */ +const unpointed = (name: string): MoshpitLookup => ({ registered: true, resolved: name, target: null }); describe('decideResolution — clearnet mode (the default)', () => { it('defaults to clearnet', () => { @@ -132,7 +134,22 @@ describe('lookupMoshpit', () => { const result = await lookupMoshpit('profullstack.agentic', { fetchImpl: okFetch({ name: 'profullstack.agentic', resolved: 'profullstack.agent', registered: true }), }); - expect(result).toEqual({ registered: true, resolved: 'profullstack.agent' }); + expect(result).toEqual({ registered: true, resolved: 'profullstack.agent', target: null }); + }); + + it('prefers name_registered over the TLD-level registered flag', async () => { + const result = await lookupMoshpit('x.eggs', { + // `.eggs` is claimed by someone, but `x.eggs` itself is not. + fetchImpl: okFetch({ registered: true, name_registered: false, resolved: 'x.eggs', target: null }), + }); + expect(result).toEqual({ registered: false, resolved: 'x.eggs', target: null }); + }); + + it('carries the target through — it is what separates live from parked', async () => { + const result = await lookupMoshpit('x.eggs', { + fetchImpl: okFetch({ name_registered: true, resolved: 'x.eggs', target: '203.0.113.7' }), + }); + expect(result?.target).toBe('203.0.113.7'); }); it('never asks about a name the registry could not hold', async () => { @@ -234,3 +251,59 @@ describe('decideResolution — the registration console', () => { expect(d.url).toBe('https://my.console/pit?tld=eggs'); }); }); + +describe('decideResolution — parking unpointed names', () => { + it('parks an unclaimed name instead of dead-ending on a DNS error', () => { + const d = decideResolution({ + hostname: 'california.oranges', + mode: 'clearnet', + clearnetResolves: false, + moshpit: unregistered, + }); + expect(d.use).toBe('park'); + expect(d.url).toBe('https://moshcoding.com/parking?name=california.oranges'); + }); + + it('parks a claimed name that is not pointed at an address yet', () => { + const d = decideResolution({ + hostname: 'california.oranges', + mode: 'moshpit', + clearnetResolves: false, + moshpit: unpointed('california.oranges'), + }); + expect(d.use).toBe('park'); + }); + + it('never replaces a working clearnet domain with a parking page', () => { + for (const mode of ['clearnet', 'moshpit'] as const) { + const d = decideResolution({ + hostname: 'example.com', + mode, + clearnetResolves: true, + moshpit: unregistered, + }); + expect(d.use).toBe('clearnet'); + } + }); + + it('does not park when the registry was unreachable — that would be a lie', () => { + const d = decideResolution({ + hostname: 'california.oranges', + mode: 'clearnet', + clearnetResolves: false, + moshpit: null, + }); + expect(d.use).toBe('clearnet'); + }); + + it('honours an overridden parking base', () => { + const d = decideResolution({ + hostname: 'california.oranges', + mode: 'clearnet', + clearnetResolves: false, + moshpit: unregistered, + parkingBase: 'https://my.park/', + }); + expect(d.url).toBe('https://my.park/parking?name=california.oranges'); + }); +}); diff --git a/apps/desktop/src/moshpit-resolve.ts b/apps/desktop/src/moshpit-resolve.ts index 89232ce..e07f287 100644 --- a/apps/desktop/src/moshpit-resolve.ts +++ b/apps/desktop/src/moshpit-resolve.ts @@ -49,11 +49,38 @@ export const DEFAULT_CONSOLE_BASE = 'https://app.moshcode.sh'; */ export const CONSOLE_LABEL = 'mosh'; +/** + * Where a name that has no destination yet is parked. + * + * A name inside the Moshpit namespace should never dead-end on a DNS error: + * `california.oranges` is a perfectly good name that simply hasn't been pointed + * at an IP, and "this name is unclaimed / unpointed, here's how to take it" is + * a far more useful answer than ERR_NAME_NOT_RESOLVED. + */ +export const DEFAULT_PARKING_BASE = 'https://moshcoding.com'; + +/** The parking page for a name with no destination yet. */ +export function parkingUrlFor( + name: string, + parkingBase: string = DEFAULT_PARKING_BASE, +): string { + return `${parkingBase.replace(/\/+$/, '')}/parking?name=${encodeURIComponent(name)}`; +} + export interface MoshpitLookup { /** The registry holds this name. */ registered: boolean; /** Where it actually points once aliases are followed (`foo.agent`). */ resolved: string; + /** + * The address the name points at, or null when it has none yet. + * + * This — not `resolved` — is what "pointed at an IP" means. The registry + * echoes the name back in `resolved` whether or not it has a destination, so + * testing `resolved` would mark every claimed name as live and nothing would + * ever park. + */ + target: string | null; } export interface ResolveInputs { @@ -65,11 +92,13 @@ export interface ResolveInputs { moshpit: MoshpitLookup | null; /** The Pit console. Overridable alongside a self-hosted registry. */ consoleBase?: string; + /** Where unpointed names are parked. */ + parkingBase?: string; } export interface ResolveDecision { /** Which namespace serves this navigation. */ - use: 'clearnet' | 'moshpit' | 'register'; + use: 'clearnet' | 'moshpit' | 'register' | 'park'; /** Why — surfaced in the UI so an override never looks like a glitch. */ reason: string; /** The name to fetch through the gateway. Only set when `use` is 'moshpit'. */ @@ -103,7 +132,7 @@ export function consoleUrlFor( * the whole policy is testable without a network or a browser. */ export function decideResolution(inputs: ResolveInputs): ResolveDecision { - const { hostname, mode, clearnetResolves, moshpit, consoleBase } = inputs; + const { hostname, mode, clearnetResolves, moshpit, consoleBase, parkingBase } = inputs; // `mosh.` is the registration console for `.`, not a name to fetch. // It obeys the SAME precedence as any other Moshpit answer rather than @@ -125,33 +154,60 @@ export function decideResolution(inputs: ResolveInputs): ResolveDecision { // The registry could not be reached, or was never asked. Falling back to // clearnet is the only safe move: a registry outage must not take the - // ordinary web down with it. - if (!moshpit || !moshpit.registered) { - return { - use: 'clearnet', - reason: moshpit ? 'not registered in Moshpit' : 'Moshpit registry not consulted or unreachable', - }; + // ordinary web down with it — nor lie with a parking page for a name it + // simply failed to look up. + if (!moshpit) { + return { use: 'clearnet', reason: 'Moshpit registry not consulted or unreachable' }; } - if (mode === 'moshpit') { + // Claimed AND pointed somewhere — the precedence rules below apply to it. + if (moshpit.registered && moshpit.target) { + if (mode === 'moshpit') { + return { + use: 'moshpit', + reason: clearnetResolves + ? 'registered in Moshpit — overriding the clearnet domain' + : 'registered in Moshpit', + resolved: moshpit.resolved, + }; + } + // clearnet mode: the registry only fills gaps. + if (clearnetResolves) { + return { use: 'clearnet', reason: 'clearnet answers for this name (Moshpit set to backfill only)' }; + } return { use: 'moshpit', - reason: clearnetResolves - ? 'registered in Moshpit — overriding the clearnet domain' - : 'registered in Moshpit', + reason: 'clearnet has no answer — resolved through Moshpit', resolved: moshpit.resolved, }; } - // clearnet mode: the registry only fills gaps. + // Unclaimed, or claimed but not pointed at an address yet. A name in this + // namespace should not dead-end on ERR_NAME_NOT_RESOLVED — park it, so + // `california.oranges` explains itself instead of looking broken. + // + // Only ever where clearnet has nothing: a domain that already works is never + // replaced by a parking page, which is the same rule the override obeys. + if (!clearnetResolves && parseRegistryName(hostname)) { + return { + use: 'park', + reason: moshpit.registered + ? 'registered in Moshpit but not pointed at an address yet' + : 'unclaimed Moshpit name — parked', + url: parkingUrlFor(hostname.trim().toLowerCase().replace(/\.$/, ''), parkingBase), + }; + } if (clearnetResolves) { - return { use: 'clearnet', reason: 'clearnet answers for this name (Moshpit set to backfill only)' }; + return { + use: 'clearnet', + reason: moshpit.registered + ? 'registered in Moshpit but not pointed anywhere yet' + : 'not registered in Moshpit', + }; } - return { - use: 'moshpit', - reason: 'clearnet has no answer — resolved through Moshpit', - resolved: moshpit.resolved, - }; + // Not a registry-shaped name (`a.b.c`, an IP, a bare host). Nothing in the + // Moshpit namespace can speak for it, parking included. + return { use: 'clearnet', reason: 'not a Moshpit name' }; } /** @@ -204,11 +260,20 @@ export async function lookupMoshpit( const url = `${base}/api/moshpit/resolve?name=${encodeURIComponent(`${parsed.label}.${parsed.tld}`)}`; const res = await fetchImpl(url, { signal: controller.signal }); if (!res.ok) return null; - const json = (await res.json()) as { registered?: boolean; resolved?: string; name?: string }; - if (typeof json?.registered !== 'boolean') return null; + const json = (await res.json()) as { + registered?: boolean; + name_registered?: boolean; + resolved?: string; + target?: string | null; + }; + // `registered` means the TLD is claimed; `name_registered` means THIS name + // is. Prefer the specific one, falling back for older registry builds. + const claimed = typeof json?.name_registered === 'boolean' ? json.name_registered : json?.registered; + if (typeof claimed !== 'boolean') return null; return { - registered: json.registered, + registered: claimed, resolved: typeof json.resolved === 'string' ? json.resolved : `${parsed.label}.${parsed.tld}`, + target: typeof json.target === 'string' && json.target ? json.target : null, }; } catch { return null;