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;