diff --git a/README.md b/README.md
index fc349d1e..d6acd9b7 100644
--- a/README.md
+++ b/README.md
@@ -57,7 +57,7 @@ or miss one that does. A test fails the build when it drifts.
| `moshcode export` | account | operator only: export users as CSV (this app, or --all properties), optionally cleaned |
| `moshcode console` | account | serve or connect to the browser terminal |
| `moshcode dns` | hosting | resolve Moshpit names on this machine |
-| `moshcode name` | hosting | prove you hold a Moshpit name, so an app can use it as your identity |
+| `moshcode name` | hosting | prove you hold a Moshpit name, or serve it from a c0mpute site |
| `moshcode doh` | hosting | run the DNS-over-HTTPS resolver |
| `moshcode site` `serve` | hosting | install web-server config for a Moshpit name |
| `moshcode template` `templates` | hosting | scaffold a stack for a Moshpit-hosted service |
diff --git a/apps/pwa/docs/moshpit-ca.md b/apps/pwa/docs/moshpit-ca.md
index abea9231..a49f16a8 100644
--- a/apps/pwa/docs/moshpit-ca.md
+++ b/apps/pwa/docs/moshpit-ca.md
@@ -36,6 +36,18 @@ top-level domain are refused unconditionally, whatever the registry's tables
say, so a registry bug cannot mint `google.com`. Twenty-four issuances per name
per day, so a broken renewal loop is stopped rather than served.
+One exception, opted into by the holder: a name pointed at a c0mpute site
+(`moshcode name c0mpute `, or `PUT
+/api/moshpit/tlds/:tld/names/c0mpute`) may also be issued to a c0mpute edge
+account listed in `MOSHPIT_C0MPUTE_EDGE_USERS`. The edge
+(`moshpit-proxy/bin/moshpit-c0mpute-edge.ts`) terminates TLS for the name and
+fetches the site from c0mpute gateways. The permission lasts exactly as long as
+the setting: clear the site, set a target, sell the name or let a lease lapse
+and the edge is refused like anyone else. With `MOSHPIT_C0MPUTE_EDGE` set to
+the edge's hostname, `/api/moshpit/resolve` answers that hostname as the
+name's target (and the site id as `c0mpute`), so existing resolvers need no
+change; with it unset nothing resolves differently.
+
Every leaf's key is also published as a `tls` pin, so a client that still
checks pins keeps working through the transition.
diff --git a/apps/pwa/src/lib/moshpit-c0mpute.mjs b/apps/pwa/src/lib/moshpit-c0mpute.mjs
new file mode 100644
index 00000000..793e853f
--- /dev/null
+++ b/apps/pwa/src/lib/moshpit-c0mpute.mjs
@@ -0,0 +1,72 @@
+// Pointing a Moshpit name at a site on the c0mpute network.
+//
+// Pure: no database, no request. The registry stores a c0mpute site id on a
+// name; this decides what that means to a resolver and to the CA.
+//
+// Why the name resolves to an edge rather than to c0mpute gateways directly:
+// a gateway serves plain HTTP on :7777 and holds no Moshpit certificate, so a
+// browser asking for https://blog.eggs would get a refused connection or a
+// certificate for some other name. The edge is the one thing on the path that
+// terminates TLS, with a leaf the registry signs for the name, and it fetches
+// /s// from whichever c0mpute gateways are healthy. Nothing on the
+// c0mpute side changes and no c0mpute server is on the path: any gateway can
+// serve any site, and the edge fails over between them.
+
+/** A c0mpute site id: 20 lowercase hex characters (blake3 of owner + name). */
+export const C0MPUTE_SITE_RE = /^[0-9a-f]{20}$/;
+
+/**
+ * A site id as stored, `""` for "clear it", or null when it is not one.
+ *
+ * Accepts the forms people paste: the bare id, `site=` (the TXT record
+ * c0mpute documents for custom domains) and a gateway URL ending in
+ * `/s//`, because all three are what `c0mpute hosting publish` prints.
+ */
+export function normalizeC0mputeSite(input) {
+ if (input === null || input === undefined) return "";
+ const raw = String(input).trim().toLowerCase();
+ if (!raw || raw === "off" || raw === "none") return "";
+ const fromUrl = raw.match(/\/s\/([0-9a-f]{20})(?:\/|$)/);
+ const bare = fromUrl ? fromUrl[1] : raw.replace(/^site=/, "");
+ return C0MPUTE_SITE_RE.test(bare) ? bare : null;
+}
+
+const HOSTNAME = /^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/;
+
+/**
+ * The edge hostname names with a c0mpute site resolve to, or null when none is
+ * configured (`MOSHPIT_C0MPUTE_EDGE`).
+ *
+ * A hostname, not an address, for the reason the registry refuses IPv4
+ * targets: several edges behind one name is how this gets redundancy, and a
+ * hostname is the only thing a resolver can follow to all of them.
+ */
+export function c0mputeEdgeHost(env = process.env) {
+ const host = String(env.MOSHPIT_C0MPUTE_EDGE || "").trim().toLowerCase().replace(/\.+$/, "");
+ return HOSTNAME.test(host) ? host : null;
+}
+
+/**
+ * The accounts allowed to ask the CA for a certificate on a name that points at
+ * c0mpute (`MOSHPIT_C0MPUTE_EDGE_USERS`, comma- or space-separated user ids).
+ *
+ * Unset means none, and then only the holder can get a certificate, exactly as
+ * before this existed.
+ */
+export function c0mputeEdgeUsers(env = process.env) {
+ return new Set(String(env.MOSHPIT_C0MPUTE_EDGE_USERS || "").split(/[\s,;]+/).map((s) => s.trim()).filter(Boolean));
+}
+
+/**
+ * What a resolver should be told a name points at.
+ *
+ * A c0mpute site wins over a stored target when an edge is configured: setting
+ * it is the more deliberate act (and setting a target clears it again). With no
+ * edge configured the site cannot be served, so the stored target stands and
+ * nothing changes for anyone.
+ */
+export function effectiveC0mputeTarget({ target = null, site = null } = {}, env = process.env) {
+ const edge = c0mputeEdgeHost(env);
+ if (site && C0MPUTE_SITE_RE.test(site) && edge) return edge;
+ return target ?? null;
+}
diff --git a/apps/pwa/src/lib/moshpit-certs.mjs b/apps/pwa/src/lib/moshpit-certs.mjs
index 79879ed5..2e01f923 100644
--- a/apps/pwa/src/lib/moshpit-certs.mjs
+++ b/apps/pwa/src/lib/moshpit-certs.mjs
@@ -11,6 +11,7 @@
import { all, get, run } from "../db.mjs";
import { addPin, controlledName, logAction, normalizeLabel, normalizeTld } from "../moshpit.mjs";
import { issueLeaf } from "./moshpit-ca.mjs";
+import { c0mputeEdgeUsers } from "./moshpit-c0mpute.mjs";
const CERT_COLS = `serial, tld, label, pin, cert, user_id, not_before, not_after, issued_at`;
const DAY = 24 * 60 * 60 * 1000;
@@ -25,7 +26,18 @@ export const MAX_CERTS_PER_DAY = 24;
* or { ok: false, error, status }.
*/
export async function issueNameCertificate({ tld: tldInput, label: labelInput, userId, csr, now = Date.now() }) {
- const owned = await controlledName(tldInput, labelInput, userId, now);
+ let owned = await controlledName(tldInput, labelInput, userId, now);
+ // The one exception to "the holder asks": a c0mpute edge, for a name whose
+ // holder pointed it at a c0mpute site. Setting the site is the holder's
+ // consent to being served by the edge, and the edge cannot serve
+ // https:// without a leaf. It lapses with the setting: once the site
+ // is cleared (or the name changes hands, which clears it) the edge is refused
+ // like anyone else, and the leaf it holds runs out within LEAF_DAYS.
+ let pinAs = userId;
+ if (!owned.ok && c0mputeEdgeUsers().has(userId)) {
+ const edge = await c0mputeEdgeName(tldInput, labelInput, now);
+ if (edge) { owned = edge; pinAs = edge.controller; }
+ }
if (!owned.ok) return { ok: false, error: owned.error, status: 403 };
const { tld, label } = owned;
@@ -48,7 +60,9 @@ export async function issueNameCertificate({ tld: tldInput, label: labelInput, u
// pin-checking client (moshcode dns trust, the pit helper) in step. A pin that
// is already there is fine; one already published as a different kind is not
// ours to fix here, and the certificate is still valid without it.
- const pinned = await addPin({ tld, label, pin: leaf.pin, kind: "tls", note: "moshpit-ca", userId });
+ // Published as the name's controller, because a pin is theirs: the edge
+ // asked for the leaf, but the key it carries is one the holder agreed to.
+ const pinned = await addPin({ tld, label, pin: leaf.pin, kind: "tls", note: pinAs === userId ? "moshpit-ca" : "moshpit-ca c0mpute-edge", userId: pinAs });
await logAction(tld, userId, `cert:issue:${label}:${leaf.serial.slice(0, 8)}`);
return {
@@ -64,6 +78,27 @@ export async function issueNameCertificate({ tld: tldInput, label: labelInput, u
};
}
+/**
+ * `{ ok, tld, label, controller }` when `label.tld` currently points at a
+ * c0mpute site, else null. `controller` is whoever controlledName() would
+ * accept: the tenant while a lease runs, otherwise the holder.
+ */
+async function c0mputeEdgeName(tldInput, labelInput, now) {
+ const tld = normalizeTld(tldInput);
+ const label = normalizeLabel(labelInput);
+ if (!tld || !label) return null;
+ const row = await get(
+ `SELECT user_id, c0mpute_site, leased_to, leased_until FROM moshpit_names WHERE tld = ? AND label = ?`,
+ [tld, label],
+ );
+ if (!row?.c0mpute_site) return null;
+ // A lease that ran out but has not been swept yet: the site is the former
+ // tenant's, and resolveMoshpitName already stopped serving it.
+ if (row.leased_to && row.leased_until && row.leased_until <= now) return null;
+ const tenanted = row.leased_to && row.leased_until > now;
+ return { ok: true, tld, label, controller: tenanted ? row.leased_to : row.user_id };
+}
+
/** What has been issued under a name, newest first. Public: certificates are. */
export async function listNameCertificates(tldInput, labelInput, { limit = 50, now = Date.now() } = {}) {
const tld = normalizeTld(tldInput);
diff --git a/apps/pwa/src/migrations-pg/028_moshpit_c0mpute.sql b/apps/pwa/src/migrations-pg/028_moshpit_c0mpute.sql
new file mode 100644
index 00000000..a0eda4cf
--- /dev/null
+++ b/apps/pwa/src/migrations-pg/028_moshpit_c0mpute.sql
@@ -0,0 +1,3 @@
+-- Postgres twin of migrations/028_moshpit_c0mpute.sql.
+
+alter table moshpit_names add column if not exists c0mpute_site text;
diff --git a/apps/pwa/src/migrations/028_moshpit_c0mpute.sql b/apps/pwa/src/migrations/028_moshpit_c0mpute.sql
new file mode 100644
index 00000000..40a58b77
--- /dev/null
+++ b/apps/pwa/src/migrations/028_moshpit_c0mpute.sql
@@ -0,0 +1,13 @@
+-- A name may point at a static site on the c0mpute network.
+--
+-- `c0mpute_site` is the 20-hex site id a c0mpute publisher prints
+-- (`c0mpute hosting publish`). It is a third answer to "where does this name
+-- go", beside `target` (a server you run) and `feed_url` (somewhere you
+-- publish): a site that a peer-to-peer network keeps alive with no server of
+-- yours at all.
+--
+-- A column rather than a record type: a resolver never answers it directly. The
+-- registry turns it into a target at read time (the c0mpute edge that holds a
+-- Moshpit certificate for the name and fetches the site from c0mpute gateways),
+-- so every resolver already deployed keeps working unchanged.
+ALTER TABLE moshpit_names ADD COLUMN c0mpute_site TEXT;
diff --git a/apps/pwa/src/moshpit.mjs b/apps/pwa/src/moshpit.mjs
index 8df7081e..accf6672 100644
--- a/apps/pwa/src/moshpit.mjs
+++ b/apps/pwa/src/moshpit.mjs
@@ -75,6 +75,7 @@ import {
normalizeRecordType,
recordConflict,
} from "./lib/moshpit-records.mjs";
+import { effectiveC0mputeTarget, normalizeC0mputeSite } from "./lib/moshpit-c0mpute.mjs";
import {
TWIN_UNLINK_LEAD_MS,
clearnetTwins,
@@ -367,7 +368,7 @@ async function ownedTldAndLabel(tldInput, labelInput, userId) {
/* ---- names under a TLD ---- */
-const NAME_COLS = `tld, label, user_id, target, feed_url, feed_kind, leased_to, leased_until, created_at`;
+const NAME_COLS = `tld, label, user_id, target, feed_url, feed_kind, c0mpute_site, leased_to, leased_until, created_at`;
export async function getName(tld, label) {
return get(`SELECT ${NAME_COLS} FROM moshpit_names WHERE tld = ? AND label = ?`, [tld, label]);
@@ -480,12 +481,44 @@ export async function setNameTarget({ tld: tldInput, label: labelInput, userId,
if (!owned.ok) return owned;
const dest = normalizeTarget(target);
if (!dest.ok) return { ok: false, error: dest.error };
- await run(`UPDATE moshpit_names SET target = ? WHERE tld = ? AND label = ?`,
- [dest.target, owned.tld, owned.label]);
+ // Pointing a name at a server is pointing it away from a c0mpute site: a
+ // site id left behind would keep winning at read time and the new target
+ // would never be served. Clearing the target leaves the site alone.
+ if (dest.target) {
+ await run(`UPDATE moshpit_names SET target = ?, c0mpute_site = NULL WHERE tld = ? AND label = ?`,
+ [dest.target, owned.tld, owned.label]);
+ } else {
+ await run(`UPDATE moshpit_names SET target = ? WHERE tld = ? AND label = ?`,
+ [dest.target, owned.tld, owned.label]);
+ }
await logAction(owned.tld, userId, `retarget:${owned.label}`);
return { ok: true };
}
+/**
+ * Point a name at a static site on the c0mpute network, or take it off.
+ *
+ * `site` is the 20-hex id `c0mpute hosting publish` prints (or `site=`,
+ * or a gateway URL ending in /s//). Empty clears it. The holder or the
+ * current tenant may set it, the same rule as a target, because it is one.
+ *
+ * Stored as given and turned into a target at read time
+ * (lib/moshpit-c0mpute.mjs), so the registry never has to rewrite rows when the
+ * edge moves.
+ */
+export async function setNameC0mpute({ tld: tldInput, label: labelInput, userId, site }) {
+ const owned = await controlledName(tldInput, labelInput, userId);
+ if (!owned.ok) return owned;
+ const id = normalizeC0mputeSite(site);
+ if (id === null) {
+ return { ok: false, error: "not a c0mpute site id — 20 hex characters, as printed by `c0mpute hosting publish`" };
+ }
+ await run(`UPDATE moshpit_names SET c0mpute_site = ? WHERE tld = ? AND label = ?`,
+ [id || null, owned.tld, owned.label]);
+ await logAction(owned.tld, userId, `${id ? "c0mpute" : "unc0mpute"}:${owned.label}`);
+ return { ok: true, site: id || null };
+}
+
/**
* Point a name at a feed, or take the feed off it.
*
@@ -826,7 +859,13 @@ export async function resolveMoshpitName(input) {
registered: true,
...(Boolean(owner.alias_of) && !aliased ? { exempt: true } : {}),
name_registered: Boolean(entry),
- target: lapsed ? null : (entry?.target ?? null),
+ // A c0mpute site becomes the edge's hostname here, so every resolver that
+ // already follows a hostname target (the bridge, the DoH server,
+ // TronBrowser) reaches the edge without learning anything new.
+ target: lapsed ? null : effectiveC0mputeTarget({ target: entry?.target ?? null, site: entry?.c0mpute_site ?? null }),
+ // The site id itself, for the edge: it is how the edge learns which site to
+ // fetch for the Host it was handed. Null for every other name.
+ c0mpute: lapsed ? null : (entry?.c0mpute_site ?? null),
// Carried alongside the target rather than folded into it. A resolver
// answering AAAA has no use for a feed and ignores these; /n/ is the caller
// that turns them into a page, and it needs both to decide which it serves.
@@ -2803,7 +2842,7 @@ async function handOverName(tld, label, buyerId) {
// The target goes too. It names a server the seller runs, and leaving it
// would point the buyer's new name at the seller's machine.
await run(
- `UPDATE moshpit_names SET user_id = ?, target = NULL, feed_url = NULL, feed_kind = NULL,
+ `UPDATE moshpit_names SET user_id = ?, target = NULL, feed_url = NULL, feed_kind = NULL, c0mpute_site = NULL,
leased_to = NULL, leased_until = NULL WHERE tld = ? AND label = ?`,
[buyerId, tld, label],
);
@@ -2833,7 +2872,7 @@ export async function endExpiredLeases(now = Date.now(), limit = 200) {
await run(`DELETE FROM moshpit_records WHERE tld = ? AND label = ?`, [lease.tld, lease.label]);
await run(`DELETE FROM moshpit_name_pins WHERE tld = ? AND label = ?`, [lease.tld, lease.label]);
await run(
- `UPDATE moshpit_names SET target = NULL, feed_url = NULL, feed_kind = NULL, leased_to = NULL, leased_until = NULL
+ `UPDATE moshpit_names SET target = NULL, feed_url = NULL, feed_kind = NULL, c0mpute_site = NULL, leased_to = NULL, leased_until = NULL
WHERE tld = ? AND label = ?`,
[lease.tld, lease.label],
);
diff --git a/apps/pwa/src/routes/moshpit.mjs b/apps/pwa/src/routes/moshpit.mjs
index eda230e8..344b700d 100644
--- a/apps/pwa/src/routes/moshpit.mjs
+++ b/apps/pwa/src/routes/moshpit.mjs
@@ -23,6 +23,7 @@
// DELETE /api/moshpit/tlds/:tld/records withdraw one
// GET /pit the human page
// PUT /api/moshpit/tlds/:tld/names/feed point a name you hold at an RSS/Atom feed
+// PUT /api/moshpit/tlds/:tld/names/c0mpute point a name you hold at a c0mpute site
// GET /api/moshpit/sites/:name/content everything a name publishes
// POST /api/moshpit/sites/:name/content publish a page, section or post (upsert — the webhook)
// GET /api/moshpit/sites/:name/content/:slug one item
@@ -153,6 +154,7 @@ import {
setAlias,
setContact,
setExempt,
+ setNameC0mpute,
setNameFeed,
setNameTarget,
setTldPrice,
@@ -185,6 +187,7 @@ const page = (opts) => shellPage({ ...opts, tracker: config.crawlproofSite });
import { shortLinkUrl } from "../lib/moshpit-links.mjs";
import { caEnabled, caMaterial } from "../lib/moshpit-ca.mjs";
import { getNameCertificate, issueNameCertificate, listNameCertificates } from "../lib/moshpit-certs.mjs";
+import { c0mputeEdgeHost } from "../lib/moshpit-c0mpute.mjs";
export const moshpitRouter = Router();
@@ -504,6 +507,32 @@ moshpitRouter.put("/api/moshpit/tlds/:tld/names/feed", async (req, res) => {
});
});
+/**
+ * Point a name at a c0mpute site, or clear it by sending an empty `site`.
+ *
+ * `{ label, site }`, where site is the 20-hex id `c0mpute hosting publish`
+ * prints. Its own endpoint for the reason the feed has one: a deploy script
+ * that only knows about targets must not wipe a site it never sent. The answer
+ * says whether an edge is configured, because without one the site is stored
+ * but nothing serves it yet, and an owner should hear that now rather than
+ * from a visitor.
+ */
+moshpitRouter.put("/api/moshpit/tlds/:tld/names/c0mpute", async (req, res) => {
+ if (!req.user) return unauthorized(res);
+ const result = await setNameC0mpute({
+ tld: req.params.tld, label: req.body?.label, userId: req.user.id, site: req.body?.site,
+ });
+ if (!result.ok) return bad(res, result.error || "could not set that c0mpute site");
+ const edge = c0mputeEdgeHost();
+ res.json({
+ tld: normalizeTld(req.params.tld),
+ label: normalizeLabel(req.body?.label),
+ c0mpute: result.site,
+ edge,
+ served: Boolean(result.site && edge),
+ });
+});
+
moshpitRouter.put("/api/moshpit/tlds/:tld/names", async (req, res) => {
if (!req.user) return unauthorized(res);
const result = await setNameTarget({
diff --git a/apps/pwa/test/moshpit-c0mpute.test.mjs b/apps/pwa/test/moshpit-c0mpute.test.mjs
new file mode 100644
index 00000000..150e88a8
--- /dev/null
+++ b/apps/pwa/test/moshpit-c0mpute.test.mjs
@@ -0,0 +1,170 @@
+// Pointing a Moshpit name at a c0mpute site, against a real (throwaway) libSQL
+// database and a CA generated in memory.
+//
+// What matters: the site id is validated and stored; a resolver is told the
+// edge's hostname only while an edge is configured (so nothing changes for
+// anyone until it is); a target set later takes the name back; and the CA signs
+// for the edge only while the holder has the name pointed at c0mpute.
+//
+// Skips cleanly when the PWA dependencies are not installed.
+import assert from "node:assert/strict";
+import { mkdtempSync } from "node:fs";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import { createRequire } from "node:module";
+import { X509Certificate } from "node:crypto";
+import test from "node:test";
+
+import {
+ c0mputeEdgeHost,
+ c0mputeEdgeUsers,
+ effectiveC0mputeTarget,
+ normalizeC0mputeSite,
+} from "../src/lib/moshpit-c0mpute.mjs";
+
+const require = createRequire(import.meta.url);
+let installed = true;
+try { require("@libsql/client"); require("@peculiar/x509"); } catch { installed = false; }
+
+const SITE = "0123456789abcdef0123";
+const OTHER = "fedcba9876543210fedc";
+
+test("c0mpute site ids are validated, in the forms people paste", () => {
+ assert.equal(normalizeC0mputeSite(SITE), SITE);
+ assert.equal(normalizeC0mputeSite(SITE.toUpperCase()), SITE);
+ assert.equal(normalizeC0mputeSite(` site=${SITE} `), SITE, "the TXT form");
+ assert.equal(normalizeC0mputeSite(`http://gw.example:7777/s/${SITE}/`), SITE, "a gateway URL");
+ assert.equal(normalizeC0mputeSite(`http://gw.example:7777/s/${SITE}`), SITE);
+ assert.equal(normalizeC0mputeSite(""), "", "empty clears");
+ assert.equal(normalizeC0mputeSite(null), "");
+ assert.equal(normalizeC0mputeSite("off"), "");
+ assert.equal(normalizeC0mputeSite("0123"), null, "too short");
+ assert.equal(normalizeC0mputeSite(`${SITE}00`), null, "too long");
+ assert.equal(normalizeC0mputeSite("g123456789abcdef0123"), null, "not hex");
+ assert.equal(normalizeC0mputeSite("../../etc/passwd"), null);
+});
+
+test("the edge is a hostname or nothing", () => {
+ assert.equal(c0mputeEdgeHost({}), null);
+ assert.equal(c0mputeEdgeHost({ MOSHPIT_C0MPUTE_EDGE: "Edge.Moshcode.sh." }), "edge.moshcode.sh");
+ assert.equal(c0mputeEdgeHost({ MOSHPIT_C0MPUTE_EDGE: "https://edge.moshcode.sh" }), null, "no scheme");
+ assert.equal(c0mputeEdgeHost({ MOSHPIT_C0MPUTE_EDGE: "edge" }), null, "a bare label resolves nowhere");
+ assert.deepEqual([...c0mputeEdgeUsers({ MOSHPIT_C0MPUTE_EDGE_USERS: "u1, u2 u3" })], ["u1", "u2", "u3"]);
+ assert.equal(c0mputeEdgeUsers({}).size, 0);
+});
+
+test("a site wins over a target only while an edge is configured", () => {
+ const env = { MOSHPIT_C0MPUTE_EDGE: "edge.moshcode.sh" };
+ assert.equal(effectiveC0mputeTarget({ target: "box.example.com", site: SITE }, env), "edge.moshcode.sh");
+ assert.equal(effectiveC0mputeTarget({ target: null, site: SITE }, env), "edge.moshcode.sh");
+ assert.equal(effectiveC0mputeTarget({ target: "box.example.com", site: null }, env), "box.example.com");
+ assert.equal(effectiveC0mputeTarget({ target: "box.example.com", site: SITE }, {}), "box.example.com");
+ assert.equal(effectiveC0mputeTarget({ target: null, site: SITE }, {}), null);
+});
+
+const workdir = mkdtempSync(path.join(tmpdir(), "moshcode-c0mpute-test-"));
+process.env.DATABASE_URL = `file:${path.join(workdir, "test.db")}`;
+process.env.SESSION_SECRET = "test-secret";
+
+const ALICE = "user-alice"; // owns .eggs, blue.eggs, red.eggs
+const BOB = "user-bob"; // stranger
+const EDGE = "user-edge"; // the c0mpute edge's account
+
+test("names pointed at c0mpute", { skip: installed ? false : "pwa dependencies not installed" }, async (t) => {
+ const { migrate } = await import("../src/migrate.mjs");
+ await migrate();
+ const { run } = await import("../src/db.mjs");
+ for (const [id, email] of [[ALICE, "alice@example.com"], [BOB, "bob@example.com"], [EDGE, "edge@example.com"]]) {
+ await run(`INSERT OR IGNORE INTO users (id, email, created_at) VALUES (?,?,?)`, [id, email, Date.now()]);
+ }
+ const m = await import("../src/moshpit.mjs");
+ const ca = await import("../src/lib/moshpit-ca.mjs");
+ const certs = await import("../src/lib/moshpit-certs.mjs");
+
+ assert.equal((await m.registerTld({ tld: "eggs", userId: ALICE })).ok, true);
+ assert.equal((await m.registerName({ tld: "eggs", label: "blue", userId: ALICE, target: "box.example.com" })).ok, true);
+ assert.equal((await m.registerName({ tld: "eggs", label: "red", userId: ALICE })).ok, true);
+
+ const saved = { edge: process.env.MOSHPIT_C0MPUTE_EDGE, users: process.env.MOSHPIT_C0MPUTE_EDGE_USERS };
+ t.after(() => {
+ for (const [k, v] of [["MOSHPIT_C0MPUTE_EDGE", saved.edge], ["MOSHPIT_C0MPUTE_EDGE_USERS", saved.users]]) {
+ if (v === undefined) delete process.env[k]; else process.env[k] = v;
+ }
+ });
+ delete process.env.MOSHPIT_C0MPUTE_EDGE;
+ delete process.env.MOSHPIT_C0MPUTE_EDGE_USERS;
+
+ await t.test("only the holder may set it, and only to a site id", async () => {
+ assert.equal((await m.setNameC0mpute({ tld: "eggs", label: "blue", userId: BOB, site: SITE })).ok, false);
+ const badId = await m.setNameC0mpute({ tld: "eggs", label: "blue", userId: ALICE, site: "nope" });
+ assert.equal(badId.ok, false);
+ assert.match(badId.error, /20 hex/);
+ const r = await m.setNameC0mpute({ tld: "eggs", label: "blue", userId: ALICE, site: `site=${SITE}` });
+ assert.equal(r.ok, true, r.error);
+ assert.equal(r.site, SITE);
+ });
+
+ await t.test("with no edge configured, resolvers see exactly what they saw before", async () => {
+ const res = await m.resolveMoshpitName("blue.eggs");
+ assert.equal(res.target, "box.example.com");
+ assert.equal(res.c0mpute, SITE);
+ });
+
+ await t.test("with an edge configured, the name resolves to it", async () => {
+ process.env.MOSHPIT_C0MPUTE_EDGE = "edge.moshcode.sh";
+ const res = await m.resolveMoshpitName("blue.eggs");
+ assert.equal(res.target, "edge.moshcode.sh");
+ assert.equal(res.c0mpute, SITE);
+ const plain = await m.resolveMoshpitName("red.eggs");
+ assert.equal(plain.target, null, "names without a site are untouched");
+ assert.equal(plain.c0mpute, null);
+ });
+
+ await t.test("setting a target takes the name back from c0mpute", async () => {
+ assert.equal((await m.setNameTarget({ tld: "eggs", label: "blue", userId: ALICE, target: "other.example.com" })).ok, true);
+ const res = await m.resolveMoshpitName("blue.eggs");
+ assert.equal(res.target, "other.example.com");
+ assert.equal(res.c0mpute, null);
+ });
+
+ await t.test("clearing works, and clearing a target leaves the site alone", async () => {
+ assert.equal((await m.setNameC0mpute({ tld: "eggs", label: "red", userId: ALICE, site: OTHER })).ok, true);
+ assert.equal((await m.setNameTarget({ tld: "eggs", label: "red", userId: ALICE, target: "" })).ok, true);
+ assert.equal((await m.resolveMoshpitName("red.eggs")).c0mpute, OTHER);
+ const cleared = await m.setNameC0mpute({ tld: "eggs", label: "red", userId: ALICE, site: "" });
+ assert.deepEqual(cleared, { ok: true, site: null });
+ assert.equal((await m.resolveMoshpitName("red.eggs")).target, null);
+ });
+
+ await t.test("the CA signs for the edge only while the name points at c0mpute", async () => {
+ const generated = await ca.generateCa({ rootName: "Test Root", issuerName: "Test Issuer" });
+ await ca.configureCa({ cert: generated.issuer.cert, key: generated.issuer.key, root: generated.root.cert, leafDays: 30 });
+ t.after(() => ca.resetCaForTests());
+ process.env.MOSHPIT_C0MPUTE_EDGE_USERS = EDGE;
+
+ const { csr } = await ca.generateCsr("red.eggs");
+ // red.eggs has no site: the edge is a stranger.
+ assert.equal((await certs.issueNameCertificate({ tld: "eggs", label: "red", userId: EDGE, csr })).status, 403);
+
+ assert.equal((await m.setNameC0mpute({ tld: "eggs", label: "red", userId: ALICE, site: SITE })).ok, true);
+ const r = await certs.issueNameCertificate({ tld: "eggs", label: "red", userId: EDGE, csr });
+ assert.equal(r.ok, true, r.error);
+ const leaf = new X509Certificate(r.cert);
+ assert.equal(leaf.subject, "CN=red.eggs");
+ const pins = await m.listPins("eggs", "red", "tls");
+ assert.equal(pins.length, 1, "published as a pin");
+ assert.equal(pins[0].user_id, ALICE, "under the holder, whose name it is");
+
+ // A stranger who is not an edge still gets nothing.
+ assert.equal((await certs.issueNameCertificate({ tld: "eggs", label: "red", userId: BOB, csr })).status, 403);
+
+ // Taken off c0mpute: the edge is refused again.
+ assert.equal((await m.setNameC0mpute({ tld: "eggs", label: "red", userId: ALICE, site: "" })).ok, true);
+ assert.equal((await certs.issueNameCertificate({ tld: "eggs", label: "red", userId: EDGE, csr })).status, 403);
+
+ // Not an edge account: refused even with a site set.
+ process.env.MOSHPIT_C0MPUTE_EDGE_USERS = "";
+ assert.equal((await m.setNameC0mpute({ tld: "eggs", label: "red", userId: ALICE, site: SITE })).ok, true);
+ assert.equal((await certs.issueNameCertificate({ tld: "eggs", label: "red", userId: EDGE, csr })).status, 403);
+ });
+});
diff --git a/src/cli-schema.mjs b/src/cli-schema.mjs
index 80c4431d..9274a278 100644
--- a/src/cli-schema.mjs
+++ b/src/cli-schema.mjs
@@ -706,8 +706,11 @@ export const CORE_CLI_COMMANDS = [
{
name: "name",
group: "hosting",
- description: "prove you hold a Moshpit name, so an app can use it as your identity",
- synopsis: [["moshcode name link [--app ]", ""]],
+ description: "prove you hold a Moshpit name, or serve it from a c0mpute site",
+ synopsis: [
+ ["moshcode name link [--app ]", "prove you hold it, for an app"],
+ ["moshcode name c0mpute ", "serve it from a c0mpute site, no server of yours"],
+ ],
flags: [
["--app ", "the app to prove the name to", "https://qrypt.chat"],
["--dir ", "where the name's key and certificate live", "/etc/ssl/moshpit"],
@@ -716,6 +719,8 @@ export const CORE_CLI_COMMANDS = [
examples: [
["sudo moshcode name link chovy.hacker", "the key is root-owned"],
["sudo moshcode name link blue.eggs --json", "hand the bundle to a script"],
+ ["moshcode name c0mpute blue.eggs 0123456789abcdef0123", "https://blue.eggs serves that c0mpute site"],
+ ["moshcode name c0mpute blue.eggs off", "stop serving it from c0mpute"],
],
seeAlso: ["dns", "site", "whoami"],
},
diff --git a/src/name-c0mpute.mjs b/src/name-c0mpute.mjs
new file mode 100644
index 00000000..63210be0
--- /dev/null
+++ b/src/name-c0mpute.mjs
@@ -0,0 +1,117 @@
+// `moshcode name c0mpute ` — serve a Moshpit name from the
+// c0mpute network.
+//
+// c0mpute keeps static sites alive peer to peer: `c0mpute hosting publish`
+// prints a 20-hex site id, three hosting nodes hold copies, and every c0mpute
+// gateway serves it at /s//. This tells the registry that a name you
+// hold is that site. The registry then resolves the name to the c0mpute edge,
+// which holds a Moshpit certificate for it and fetches the site from whichever
+// gateways are healthy, so https:// works in any browser that trusts the
+// Moshpit root, with no server of yours anywhere.
+//
+// Nothing here touches this machine's DNS. It is one write to the registry.
+
+import { DEFAULT_REGISTRY_BASE } from "./pit-url.mjs";
+import { loadCreds } from "./auth.mjs";
+import { normalizeName } from "./name-link.mjs";
+
+/** A site id as the registry stores it, `""` to clear, or null when it is not one. */
+export function normalizeSiteId(input) {
+ const raw = String(input ?? "").trim().toLowerCase();
+ if (!raw || raw === "off" || raw === "none") return "";
+ const fromUrl = raw.match(/\/s\/([0-9a-f]{20})(?:\/|$)/);
+ const bare = fromUrl ? fromUrl[1] : raw.replace(/^site=/, "");
+ return /^[0-9a-f]{20}$/.test(bare) ? bare : null;
+}
+
+/**
+ * Tell the registry `name` is c0mpute site `site` ("" clears it).
+ *
+ * Returns { ok, site, edge, served } or { ok: false, error, needsAuth? }.
+ */
+export async function setC0mputeSite({
+ name,
+ site,
+ registryBase = DEFAULT_REGISTRY_BASE,
+ token,
+ fetchImpl = fetch,
+} = {}) {
+ if (!token) {
+ return { ok: false, needsAuth: true, error: "not logged in — run `moshcode login`, or set MOSHCODE_API_KEY" };
+ }
+ const [label, tld] = name.split(".");
+ const url = `${String(registryBase).replace(/\/+$/, "")}/api/moshpit/tlds/${encodeURIComponent(tld)}/names/c0mpute`;
+ let response;
+ try {
+ response = await fetchImpl(url, {
+ method: "PUT",
+ headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
+ body: JSON.stringify({ label, site }),
+ });
+ } catch (error) {
+ return { ok: false, error: `registry unreachable: ${error.message}` };
+ }
+ if (response.status === 401) return { ok: false, needsAuth: true, error: "the registry rejected the credentials" };
+ const body = await response.json().catch(() => null);
+ if (!response.ok) {
+ return { ok: false, error: body?.error ? String(body.error) : `registry said ${response.status}` };
+ }
+ return { ok: true, site: body?.c0mpute ?? null, edge: body?.edge ?? null, served: Boolean(body?.served) };
+}
+
+/**
+ * `moshcode name c0mpute [--registry ] [--json]`.
+ *
+ * @param {string[]} argv the words after `c0mpute`
+ * @returns {Promise} exit code
+ */
+export async function nameC0mputeCommand(argv, io = {}) {
+ const out = io.out || ((s) => console.log(s));
+ const err = io.err || ((s) => console.error(s));
+ const at = argv.indexOf("--registry");
+ const registryBase = at >= 0 && argv[at + 1] ? argv[at + 1] : (io.env?.MOSHPIT_REGISTRY_BASE || DEFAULT_REGISTRY_BASE);
+ const json = argv.includes("--json");
+ const positional = argv.filter((a, i) => !a.startsWith("--") && !(i > 0 && argv[i - 1] === "--registry"));
+ const [rawName, rawSite] = positional;
+
+ const usage = "usage: moshcode name c0mpute [--json]";
+ const name = normalizeName(rawName);
+ if (!name) {
+ err(rawName ? `not a Moshpit name: ${rawName} — expected