diff --git a/apps/desktop/extensions/ai-sidebar/options.html b/apps/desktop/extensions/ai-sidebar/options.html index 7281e0c..921e15d 100644 --- a/apps/desktop/extensions/ai-sidebar/options.html +++ b/apps/desktop/extensions/ai-sidebar/options.html @@ -77,6 +77,25 @@

Account

+

Name resolution

+

+ Moshpit names (fuck.yeah, original.sploof) always resolve here — + clearnet has never heard of those endings. This setting only decides what happens when + both namespaces answer, e.g. profullstack.ai. +

+ + +

+ 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. +

+ + +
+

AI providers (bring your own keys)

Add keys for as many providers as you like, then pick a default for the sidebar. Keys are stored on your account (encrypted end-to-end when a vault diff --git a/apps/desktop/extensions/ai-sidebar/options.js b/apps/desktop/extensions/ai-sidebar/options.js index 3ddb85c..8ffe2dc 100644 --- a/apps/desktop/extensions/ai-sidebar/options.js +++ b/apps/desktop/extensions/ai-sidebar/options.js @@ -366,6 +366,10 @@ async function loadAll() { buildProviders(provs, aiDefault); el("cpClient").value = coinpayConfig?.clientId || ""; el("syncUrl").value = syncConfig?.url || ""; + const moshpit = (await chrome.storage.local.get("moshpitConfig")).moshpitConfig || {}; + // Absent means never configured, which is the default — not "off". + el("moshpitMode").value = moshpit.mode === "moshpit" ? "moshpit" : "clearnet"; + el("moshpitRegistry").value = moshpit.registryBase || ""; await renderAccount(); await mountSections(); // Search / Markets / Sports / RSS feeds (shared module) await renderBtr(); @@ -383,6 +387,22 @@ el("syncUrl").addEventListener("change", async () => { syncConfig: { url: el("syncUrl").value.trim() }, }); }); +async function saveMoshpit() { + await chrome.storage.local.set({ + moshpitConfig: { + mode: el("moshpitMode").value === "moshpit" ? "moshpit" : "clearnet", + registryBase: el("moshpitRegistry").value.trim(), + }, + }); + flash( + "savedMoshpit", + el("moshpitMode").value === "moshpit" + ? "Moshpit will override clearnet for names it holds" + : "Clearnet wins; Moshpit fills gaps only", + ); +} +el("moshpitMode").addEventListener("change", saveMoshpit); +el("moshpitRegistry").addEventListener("change", saveMoshpit); async function get(k) { return (await chrome.storage.local.get(k))[k] || {}; } diff --git a/apps/desktop/src/moshpit-resolve.test.ts b/apps/desktop/src/moshpit-resolve.test.ts new file mode 100644 index 0000000..4fd34fc --- /dev/null +++ b/apps/desktop/src/moshpit-resolve.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + DEFAULT_RESOLVE_MODE, + decideResolution, + gatewayUrlFor, + lookupMoshpit, + parseRegistryName, + type MoshpitLookup, +} from './moshpit-resolve'; + +const registered = (resolved: string): MoshpitLookup => ({ registered: true, resolved }); +const unregistered: MoshpitLookup = { registered: false, resolved: '' }; + +describe('decideResolution — clearnet mode (the default)', () => { + it('defaults to clearnet', () => { + expect(DEFAULT_RESOLVE_MODE).toBe('clearnet'); + }); + + it('leaves a working clearnet domain alone even when Moshpit holds the name', () => { + // The squatting case, from the safe side: someone holds profullstack.ai on + // clearnet AND we hold it in Moshpit. Default must not hijack it — silently + // redirecting a domain that resolves is indistinguishable from a takeover. + const d = decideResolution({ + hostname: 'profullstack.ai', + mode: 'clearnet', + clearnetResolves: true, + moshpit: registered('profullstack.ai'), + }); + expect(d.use).toBe('clearnet'); + expect(d.reason).toMatch(/backfill/i); + }); + + it('backfills a name clearnet cannot answer', () => { + const d = decideResolution({ + hostname: 'original.sploof', + mode: 'clearnet', + clearnetResolves: false, + moshpit: registered('original.sploof'), + }); + expect(d.use).toBe('moshpit'); + expect(d.resolved).toBe('original.sploof'); + }); +}); + +describe('decideResolution — moshpit mode (the override)', () => { + it('overrides a live clearnet domain', () => { + // The whole point of registering profullstack.ai in Moshpit: your version + // wins regardless of who holds the clearnet domain. + const d = decideResolution({ + hostname: 'profullstack.ai', + mode: 'moshpit', + clearnetResolves: true, + moshpit: registered('profullstack.ai'), + }); + expect(d.use).toBe('moshpit'); + expect(d.reason).toMatch(/overriding the clearnet domain/i); + expect(d.resolved).toBe('profullstack.ai'); + }); + + it('follows an alias to its target', () => { + const d = decideResolution({ + hostname: 'profullstack.agentic', + mode: 'moshpit', + clearnetResolves: false, + moshpit: registered('profullstack.agent'), + }); + expect(d.resolved).toBe('profullstack.agent'); + }); + + it('still falls through to clearnet for a name Moshpit does not hold', () => { + const d = decideResolution({ + hostname: 'example.com', + mode: 'moshpit', + clearnetResolves: true, + moshpit: unregistered, + }); + expect(d.use).toBe('clearnet'); + }); +}); + +describe('decideResolution — a registry outage must not break browsing', () => { + it.each(['clearnet', 'moshpit'] as const)('falls back to clearnet in %s mode', (mode) => { + const d = decideResolution({ + hostname: 'profullstack.ai', + mode, + clearnetResolves: true, + moshpit: null, + }); + expect(d.use).toBe('clearnet'); + expect(d.reason).toMatch(/unreachable|not consulted/i); + }); + + it('always explains itself', () => { + // Every branch carries a reason, so an override never looks like a glitch. + for (const mode of ['clearnet', 'moshpit'] as const) { + for (const clearnetResolves of [true, false]) { + for (const moshpit of [null, unregistered, registered('x.y')]) { + const d = decideResolution({ hostname: 'x.y', mode, clearnetResolves, moshpit }); + expect(d.reason.length).toBeGreaterThan(0); + } + } + } + }); +}); + +describe('parseRegistryName', () => { + it('accepts exactly one label and one TLD', () => { + expect(parseRegistryName('fuck.yeah')).toEqual({ label: 'fuck', tld: 'yeah' }); + expect(parseRegistryName('California.Oranges')).toEqual({ label: 'california', tld: 'oranges' }); + expect(parseRegistryName('original.sploof.')).toEqual({ label: 'original', tld: 'sploof' }); + }); + + it('rejects anything that is not a registry name', () => { + // Sending these to the registry would be asking about a name that cannot + // exist — and acting on the answer would misroute ordinary browsing. + expect(parseRegistryName('a.b.c')).toBeNull(); + expect(parseRegistryName('localhost')).toBeNull(); + expect(parseRegistryName('192.168.1.1')).toBeNull(); + expect(parseRegistryName('box.example.com:9161')).toBeNull(); + expect(parseRegistryName('')).toBeNull(); + expect(parseRegistryName('-bad.yeah')).toBeNull(); + }); +}); + +describe('lookupMoshpit', () => { + const okFetch = (body: unknown): typeof fetch => + vi.fn(async () => ({ ok: true, json: async () => body })) as unknown as typeof fetch; + + it('reads the registry answer', async () => { + const result = await lookupMoshpit('profullstack.agentic', { + fetchImpl: okFetch({ name: 'profullstack.agentic', resolved: 'profullstack.agent', registered: true }), + }); + expect(result).toEqual({ registered: true, resolved: 'profullstack.agent' }); + }); + + it('never asks about a name the registry could not hold', async () => { + const fetchImpl = okFetch({ registered: true, resolved: 'x' }); + expect(await lookupMoshpit('a.b.c', { fetchImpl })).toBeNull(); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('returns null rather than throwing when the registry is down', async () => { + const dead = vi.fn(async () => { + throw new Error('ECONNREFUSED'); + }) as unknown as typeof fetch; + expect(await lookupMoshpit('fuck.yeah', { fetchImpl: dead })).toBeNull(); + }); + + it('returns null on a nonsense payload', async () => { + expect(await lookupMoshpit('fuck.yeah', { fetchImpl: okFetch({ nope: 1 }) })).toBeNull(); + }); +}); + +describe('gatewayUrlFor', () => { + it('builds a gateway URL and tolerates a trailing slash', () => { + expect(gatewayUrlFor('fuck.yeah')).toBe('https://pit.moshcode.sh/n/fuck.yeah'); + expect(gatewayUrlFor('fuck.yeah', 'https://my.pit/')).toBe('https://my.pit/n/fuck.yeah'); + }); +}); diff --git a/apps/desktop/src/moshpit-resolve.ts b/apps/desktop/src/moshpit-resolve.ts new file mode 100644 index 0000000..ef48a00 --- /dev/null +++ b/apps/desktop/src/moshpit-resolve.ts @@ -0,0 +1,158 @@ +/** + * Moshpit name resolution, and how it coexists with clearnet DNS. + * + * Two namespaces now answer to the same shape of name. `profullstack.ai` is a + * real clearnet domain someone can squat, and it is *also* a name the Moshpit + * registry can hold. Something has to decide which one a navigation means, and + * that decision cannot be hardcoded: a user who has never heard of Moshpit must + * keep getting clearnet, while an operator who registered the name in Moshpit + * expects their version to win. + * + * So it is a setting, with two honest positions: + * + * 'clearnet' (default) — clearnet owns any name clearnet can answer. Moshpit + * is consulted only where DNS came up empty, which makes the registry a + * *backfill*: it fills the gaps rather than shadowing the existing web. + * Chosen as the default because silently redirecting a domain that + * resolves perfectly well is indistinguishable from hijacking it. + * + * 'moshpit' — a name registered in Moshpit wins, even when clearnet has an + * answer for it. This is the override: the point of registering + * `profullstack.ai` in Moshpit is that your version is the one you get, + * regardless of who holds the clearnet domain. + * + * Names under a TLD that clearnet has never heard of (`.eggs`, `.sploof`) + * resolve through Moshpit in either mode — there is nothing to conflict with, + * and refusing to resolve them would defeat the entire namespace. + */ + +export type ResolveMode = 'clearnet' | 'moshpit'; + +export const DEFAULT_RESOLVE_MODE: ResolveMode = 'clearnet'; + +/** The public registry. Overridable so a self-hosted pit can be pointed at. */ +export const DEFAULT_REGISTRY_BASE = 'https://pit.moshcode.sh'; + +export interface MoshpitLookup { + /** The registry holds this name. */ + registered: boolean; + /** Where it actually points once aliases are followed (`foo.agent`). */ + resolved: string; +} + +export interface ResolveInputs { + hostname: string; + mode: ResolveMode; + /** Whether ordinary DNS has an answer. */ + clearnetResolves: boolean; + /** Registry answer, or null when it was not consulted / was unreachable. */ + moshpit: MoshpitLookup | null; +} + +export interface ResolveDecision { + /** Which namespace serves this navigation. */ + use: 'clearnet' | 'moshpit'; + /** 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'. */ + resolved?: string; +} + +/** + * Decide which namespace a hostname belongs to. + * + * Deliberately pure and total: every branch returns a decision with a reason, + * so the caller never has to invent behaviour for an unhandled combination, and + * the whole policy is testable without a network or a browser. + */ +export function decideResolution(inputs: ResolveInputs): ResolveDecision { + const { mode, clearnetResolves, moshpit } = inputs; + + // 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', + }; + } + + 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: 'clearnet has no answer — resolved through Moshpit', + resolved: moshpit.resolved, + }; +} + +/** + * 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: string): { label: string; tld: string } | null { + const host = hostname.trim().toLowerCase().replace(/\.$/, ''); + if (!host || host.includes(':')) return null; + // An IPv4 literal is not a name. + 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 URL that serves a resolved Moshpit name through the gateway. */ +export function gatewayUrlFor(resolved: string, registryBase = DEFAULT_REGISTRY_BASE): string { + 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" instead of breaking browsing. + */ +export async function lookupMoshpit( + hostname: string, + options: { registryBase?: string; fetchImpl?: typeof fetch; timeoutMs?: number } = {}, +): Promise { + const parsed = parseRegistryName(hostname); + if (!parsed) return null; + + const base = (options.registryBase ?? DEFAULT_REGISTRY_BASE).replace(/\/+$/, ''); + const fetchImpl = options.fetchImpl ?? fetch; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? 4000); + try { + 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; + return { + registered: json.registered, + resolved: typeof json.resolved === 'string' ? json.resolved : `${parsed.label}.${parsed.tld}`, + }; + } catch { + return null; + } finally { + clearTimeout(timer); + } +}