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