diff --git a/deploy/compose/.env.example b/deploy/compose/.env.example index cebe879da0..3ff80b51b0 100644 --- a/deploy/compose/.env.example +++ b/deploy/compose/.env.example @@ -12,6 +12,12 @@ BUZZ_MEDIA_BASE_URL=https://buzz.example.com/media BUZZ_MEDIA_SERVER_DOMAIN=buzz.example.com BUZZ_CORS_ORIGINS=https://buzz.example.com +# Optional NIP-AB pairing sidecar. Start it with `--profile pairing`, then +# uncomment the TLS reverse-proxy path. Never advertise it before the sidecar +# and proxy route are healthy, and never publish its raw port externally. +# BUZZ_PAIRING_RELAY_URL=wss://buzz.example.com/pair +BUZZ_PAIR_RELAY_PORT=5000 + # Production defaults. Closed relay mode requires RELAY_OWNER_PUBKEY and a stable relay key. BUZZ_REQUIRE_AUTH_TOKEN=true BUZZ_REQUIRE_RELAY_MEMBERSHIP=true diff --git a/deploy/compose/compose.yml b/deploy/compose/compose.yml index bc3c27501e..e374dc5850 100644 --- a/deploy/compose/compose.yml +++ b/deploy/compose/compose.yml @@ -46,6 +46,21 @@ services: networks: - buzz-net + # Optional stateless NIP-AB transport for pairing a device that is not yet a + # relay member. The host port is loopback-only; a TLS reverse proxy must + # expose only /pair. Enable explicitly with `--profile pairing`. + pairing-relay: + profiles: ["pairing"] + image: ${BUZZ_IMAGE:-ghcr.io/block/buzz:main} + entrypoint: ["/usr/local/bin/buzz-pair-relay"] + environment: + BUZZ_PAIR_RELAY_BIND_ADDR: 0.0.0.0:5000 + ports: + - "127.0.0.1:${BUZZ_PAIR_RELAY_PORT:-5000}:5000" + restart: unless-stopped + networks: + - buzz-net + postgres: image: postgres:17-alpine environment: diff --git a/deploy/local/aeon-aspects/README.md b/deploy/local/aeon-aspects/README.md index 4c64b0fbd2..2999f0db23 100644 --- a/deploy/local/aeon-aspects/README.md +++ b/deploy/local/aeon-aspects/README.md @@ -15,6 +15,23 @@ proactive count-based session rotation are off, thread/DM context is bounded to absolute cap. `bypassPermissions` is an explicit canary posture; it does not prove that a future interactive approval workflow is preserved. +The checked-in AEON manifest uses the single canonical tailnet authority +`wss://architects-mac-mini.tail757661.ts.net`. Production workers, Desktop, +mobile, relay NIP-11 metadata, media URLs, and the durable community host map +must all use that same authority. The validator permits insecure WebSocket only +for the exact local development URL `ws://localhost:3000`; every non-local +deployment must use a root `wss://` URL with no credentials, path, query, or +fragment. Tailscale Funnel is not required and must remain off. + +Mobile identity transfer uses Buzz's existing NIP-AB QR and SAS flow. Because +the production relay requires membership, the unpaired phone cannot use the +main relay for that handshake. Run the existing `buzz-pair-relay` Compose +profile on loopback and route only `/pair` to it through Tailscale Serve; set +`BUZZ_PAIRING_RELAY_URL` to the canonical `wss://` `/pair` URL advertised by +NIP-11. The encrypted pairing payload still carries the canonical HTTPS main +relay URL used by mobile for chat, media, and subsequent authenticated WebSocket +sessions. + `--trusted-inbound-envelope` copies one signature-verified triggering event into the ACP request as `_meta.buzz.inboundEvent` (`schemaVersion: 1`). The envelope carries the signed event ID, author, kind, exact channel ID, and exact diff --git a/deploy/local/aeon-aspects/launchagents/org.aeon.buzz-acp.fontis.plist b/deploy/local/aeon-aspects/launchagents/org.aeon.buzz-acp.fontis.plist index 98b1f16b6f..322c5d7a06 100644 --- a/deploy/local/aeon-aspects/launchagents/org.aeon.buzz-acp.fontis.plist +++ b/deploy/local/aeon-aspects/launchagents/org.aeon.buzz-acp.fontis.plist @@ -7,7 +7,7 @@ /Volumes/AEON/Projects/buzz/target/release/buzz-acp --relay-url - ws://localhost:3000 + wss://architects-mac-mini.tail757661.ts.net --private-key-file /Volumes/AEON/Projects/buzz-data/keys/fontis.sk --expected-public-key diff --git a/deploy/local/aeon-aspects/launchagents/org.aeon.buzz-acp.mechanon.plist b/deploy/local/aeon-aspects/launchagents/org.aeon.buzz-acp.mechanon.plist index 76de7ccb77..4b1a6daa7b 100644 --- a/deploy/local/aeon-aspects/launchagents/org.aeon.buzz-acp.mechanon.plist +++ b/deploy/local/aeon-aspects/launchagents/org.aeon.buzz-acp.mechanon.plist @@ -7,7 +7,7 @@ /Volumes/AEON/Projects/buzz/target/release/buzz-acp --relay-url - ws://localhost:3000 + wss://architects-mac-mini.tail757661.ts.net --private-key-file /Volumes/AEON/Projects/buzz-data/keys/mechanon.sk --expected-public-key diff --git a/deploy/local/aeon-aspects/launchagents/org.aeon.buzz-acp.nexus.plist b/deploy/local/aeon-aspects/launchagents/org.aeon.buzz-acp.nexus.plist index 0bd9f5f5c0..ca262a58c3 100644 --- a/deploy/local/aeon-aspects/launchagents/org.aeon.buzz-acp.nexus.plist +++ b/deploy/local/aeon-aspects/launchagents/org.aeon.buzz-acp.nexus.plist @@ -7,7 +7,7 @@ /Volumes/AEON/Projects/buzz/target/release/buzz-acp --relay-url - ws://localhost:3000 + wss://architects-mac-mini.tail757661.ts.net --private-key-file /Volumes/AEON/Projects/buzz-data/keys/nexus.sk --expected-public-key diff --git a/deploy/local/aeon-aspects/launchagents/org.aeon.buzz-acp.sapientis.plist b/deploy/local/aeon-aspects/launchagents/org.aeon.buzz-acp.sapientis.plist index 44723d324e..ef064d1cdd 100644 --- a/deploy/local/aeon-aspects/launchagents/org.aeon.buzz-acp.sapientis.plist +++ b/deploy/local/aeon-aspects/launchagents/org.aeon.buzz-acp.sapientis.plist @@ -7,7 +7,7 @@ /Volumes/AEON/Projects/buzz/target/release/buzz-acp --relay-url - ws://localhost:3000 + wss://architects-mac-mini.tail757661.ts.net --private-key-file /Volumes/AEON/Projects/buzz-data/keys/sapientis.sk --expected-public-key diff --git a/deploy/local/aeon-aspects/launchagents/org.aeon.buzz-acp.viatica.plist b/deploy/local/aeon-aspects/launchagents/org.aeon.buzz-acp.viatica.plist index ec8506e0fc..22d16cd69e 100644 --- a/deploy/local/aeon-aspects/launchagents/org.aeon.buzz-acp.viatica.plist +++ b/deploy/local/aeon-aspects/launchagents/org.aeon.buzz-acp.viatica.plist @@ -7,7 +7,7 @@ /Volumes/AEON/Projects/buzz/target/release/buzz-acp --relay-url - ws://localhost:3000 + wss://architects-mac-mini.tail757661.ts.net --private-key-file /Volumes/AEON/Projects/buzz-data/keys/viatica.sk --expected-public-key diff --git a/deploy/local/aeon-aspects/launchagents/org.aeon.buzz-acp.voxis.plist b/deploy/local/aeon-aspects/launchagents/org.aeon.buzz-acp.voxis.plist index 972422b400..e7143a7689 100644 --- a/deploy/local/aeon-aspects/launchagents/org.aeon.buzz-acp.voxis.plist +++ b/deploy/local/aeon-aspects/launchagents/org.aeon.buzz-acp.voxis.plist @@ -7,7 +7,7 @@ /Volumes/AEON/Projects/buzz/target/release/buzz-acp --relay-url - ws://localhost:3000 + wss://architects-mac-mini.tail757661.ts.net --private-key-file /Volumes/AEON/Projects/buzz-data/keys/voxis.sk --expected-public-key diff --git a/deploy/local/aeon-aspects/worker.mjs b/deploy/local/aeon-aspects/worker.mjs index cf373442e6..d828aa734c 100644 --- a/deploy/local/aeon-aspects/worker.mjs +++ b/deploy/local/aeon-aspects/worker.mjs @@ -1,5 +1,7 @@ import fs from "node:fs"; +const AEON_PRODUCTION_RELAY_URL = "wss://architects-mac-mini.tail757661.ts.net"; + export function loadJson(path) { return JSON.parse(fs.readFileSync(path, "utf8")); } @@ -9,7 +11,36 @@ export function validateManifest(manifest, identityMap) { const warnings = []; if (manifest.enabled !== false) errors.push("package must be disabled by default"); if (manifest.workers?.length !== 6) errors.push("exactly six Aspect workers are required"); - if (manifest.buzz?.relayUrl !== "ws://localhost:3000") errors.push("Buzz relay must use localhost"); + try { + const configuredRelayUrl = manifest.buzz?.relayUrl; + const relayUrl = new URL(configuredRelayUrl); + const isLoopbackDevelopment = + relayUrl.protocol === "ws:" && + relayUrl.hostname === "localhost" && + relayUrl.port === "3000"; + const isSecureProduction = + relayUrl.protocol === "wss:" && + configuredRelayUrl.startsWith("wss://") && + configuredRelayUrl === AEON_PRODUCTION_RELAY_URL; + const hasCanonicalRoot = + relayUrl.username === "" && + relayUrl.password === "" && + relayUrl.pathname === "/" && + relayUrl.search === "" && + relayUrl.hash === ""; + const isCanonicalSpelling = + configuredRelayUrl === relayUrl.origin || + configuredRelayUrl === `${relayUrl.origin}/`; + if ( + (!isLoopbackDevelopment && !isSecureProduction) || + !hasCanonicalRoot || + !isCanonicalSpelling + ) { + errors.push("Buzz relay must use ws://localhost:3000 for development or a canonical root wss:// URL"); + } + } catch { + errors.push("Buzz relay URL is invalid"); + } if (manifest.posture?.memory !== false) errors.push("Buzz memory injection must be disabled"); if (manifest.posture?.basePrompt !== false) errors.push("Buzz base prompt must be disabled"); if (manifest.posture?.respondTo !== "owner-only") errors.push("respondTo must be owner-only"); diff --git a/deploy/local/aeon-aspects/worker.test.mjs b/deploy/local/aeon-aspects/worker.test.mjs index 4ccb310d3c..47993ad108 100644 --- a/deploy/local/aeon-aspects/worker.test.mjs +++ b/deploy/local/aeon-aspects/worker.test.mjs @@ -19,6 +19,35 @@ test("six-worker manifest matches the synthetic identity-map contract", () => { assert.match(result.warnings[0], /avatar metadata is absent/); }); +test("worker relay authority accepts canonical secure production WSS and fails closed elsewhere", () => { + assert.equal(manifest.buzz.relayUrl, "wss://architects-mac-mini.tail757661.ts.net"); + for (const relayUrl of [ + "wss://relay.example.com/path", + "wss://relay.example.com", + "wss://relay.example.com?tenant=aeon", + "wss://user@relay.example.com", + "ws://relay.example.com", + "https://relay.example.com", + "wss:relay.example.com", + "wss:/relay.example.com", + "wss://relay.example.com:443", + "not-a-url", + ]) { + const result = validateManifest( + { ...manifest, buzz: { ...manifest.buzz, relayUrl } }, + identityMap, + ); + assert.equal(result.ok, false, relayUrl); + assert.match(result.errors.join("\n"), /Buzz relay/); + } + + const development = validateManifest( + { ...manifest, buzz: { ...manifest.buzz, relayUrl: "ws://localhost:3000" } }, + identityMap, + ); + assert.equal(development.ok, true, development.errors.join("\n")); +}); + test("every rendered worker is disabled and binds an existing fixed session", () => { for (const worker of manifest.workers) { const rendered = renderWorker(manifest, identityMap, worker.aspect, "/owned/gateway.token"); diff --git a/deploy/local/aeon-aspects/workers.json b/deploy/local/aeon-aspects/workers.json index 1c21489ee0..8870c85245 100644 --- a/deploy/local/aeon-aspects/workers.json +++ b/deploy/local/aeon-aspects/workers.json @@ -9,7 +9,7 @@ "provenance": "meta+receipt" }, "buzz": { - "relayUrl": "ws://localhost:3000", + "relayUrl": "wss://architects-mac-mini.tail757661.ts.net", "architectPubkey": "73ac8798fd9cedcc5d24645d7ed49332d94a28f2c937f4c1b638f92bf6e8e91f", "conciliumChannelId": "4ac1cee0-2238-483f-b25b-f99ec17eec46" },