Living document β go over this constantly, check items off as they land, add items as reality diverges from the plan. Mirrors WHITEPAPER.md Β§14 (Roadmap) and MONETIZATION.md Β§3 (phasing). Each phase should get its own detailed pass when it becomes current, same as Phase 0/1 below.
broker/, mesh-host/, chat-server/, chat-client/, mail/, installer/, website/, docs/).gitignore for Go build artifacts, node_modules, secrets/keysinstaller/bootstrap.sh: SSH key-only + no root login, nftables default-deny, fail2ban, unattended-upgrades, sysctl hardeninginstaller/wg-keygen.sh: WireGuard server keypair + base wg0.conf generationinstaller/wg-client-config.sh: per-client .conf + QR code generatornftables.conf.tmpl): default-deny inbound, explicit allow for WireGuard UDP port + SSH onlyinstaller/README.mdwg-keygen.sh now enables wg-quick@wg0; unattended-upgrades auto-reboot turned on to match (previously a reboot would've silently left the VPN down)wg0 so AllowedIPs ::/0 actually captures v6 traffic instead of letting it fall back to the client's real interface; IPv6 forwarding stays off so it dead-ends at the server rather than actually routing anywhere (documented as a deliberate scope decision in installer/README.md, not a half-built feature)wg genpsk) as defense-in-depth on top of the Curve25519 handshakeinstaller/test-local/: Docker-based local stand-in for the two VPS tests below (two containers, real wg-keygen.sh/wg-client-config.sh/nftables.conf.tmpl reused, not reimplemented)wg-keygen.sh + wg-quick up bring wg0 up with the right keys, wg-client-config.sh hot-reloads the new peer, and the rendered nftables ruleset matches the template exactly (nft list ruleset inspected directly)test-local/run-test.sh β passing (IPv4 only): tunnel ping 0% packet loss, decoy port blocked, WireGuard UDP port reports open|filtered to nmap (the correct outcome β WireGuard never responds to unauthenticated packets, so a scanner can't tell "open" from "filtered", by design). Took four real container-environment bugs to get here, each confirmed and fixed in turn: (1) missing resolvconf in the client image (wg-quick needs it whenever a config sets DNS =); (2) a Git-Bash/MSYS path-mangling bug in run-test.sh's own readiness-wait loop β /shared/server-ready was silently rewritten to a Windows path before reaching docker, so the check always reported "not found" even though the server was fine; (3) a missing nftables package on the client container β wg-quick shells out to nft client-side too, for IPv6 fwmark routing; (4) the IPv4 fwmark trick needs net.ipv4.conf.all.src_valid_mark=1, and wg-quick tries to set it itself at runtime, which Docker blocks even with NET_ADMIN/NET_RAW/SYS_ADMIN β needed privileged: true on the client service specificallywg-quick's IPv6 fwmark routing setup fails inside this container (nft: Error: Could not process rule: No such file or directory), reproduced identically across three independent fix attempts before concluding it's a Docker Desktop/WSL2 kernel limitation with that code path, not a config bug. client-entrypoint.sh strips the IPv6 address/route from the fetched conf before bringing wg-quick up, for this test only β the shipped wg-client-config.sh itself is untouched. This is the main reason the real-VPS test below still matters, not a formality: the IPv6 leak-prevention path (installer/README.md's "IPv6: contained, not routed" section) has never actually been exercised, only reasoned aboutbootstrap.sh and wg-keygen.sh run for real over SSH. One real finding fixed live: bootstrap.sh's PermitRootLogin no would have locked us out entirely on a fresh droplet with no non-root user yet β created a sudo user (real password, not passwordless) with the ops SSH key before running bootstrap, matching the script's own implicit assumption (its safety check only verifies an authorized_keys file exists, not that a non-root login path survives PermitRootLogin no). Confirmed post-bootstrap: the new user's key login still works, direct root login is now refused (Permission denied (publickey,password)), matching the intended hardened end state.nmap -sT across ports 1-1024 plus the app ports (51820/8443/9443/3000/9000/9001) shows only 22/tcp open, everything else filtered/no-response β the default-deny nftables policy is doing its job against a real scanner, not just reasoned about. (A raw UDP scan of 51820 itself needs root privileges this machine's nmap install doesn't have here; the expected open|filtered non-response behavior for that port was already established in the Phase 1 Docker harness above and is architectural β WireGuard doesn't answer unauthenticated packets regardless of host.)wg-client-config.sh or the new /vpn/provision self-serve endpoint below) hands back a live WireGuard private key, and materializing/handling an end-user credential like that isn't something the agent should do unsupervised (Claude Code's own auto-mode classifier declined the attempt, correctly). Per installer/README.md's own documented flow, this step is meant to be done by a human on a real device anyway (scan the QR / self-serve the config, connect, check test-ipv6.com and the tunnel's egress IP). Waiting on the user to run this from their own device and report the result.broker -gen-invitePOST /enroll β short-lived Ed25519-signed token, self-verifying, no callback needed)GET /hosts/register/challenge + POST /hosts/register β nonce challenge/response proves possession of the host's long-term key)GET /directory β canonical JSON body + X-Signature header, Ed25519; Version tracks registry mutations, GeneratedAt proves a fresh snapshot)POST /hosts/heartbeat, signed, skew-checked; stale hosts drop out of /directory without deleting their registration)GET /directory, above)-approve-host) alongside -auto-approve-hosts for early/low-trust operation, per WHITEPAPER Β§7.4crypto/ed25519), better than the +1 x/crypto STACK.md originally assumed, since the broker never needed X25519/ChaCha20broker/broker_test.go β go test ./... passes: full flow, bad-signature rejection, manual-approval gating)golang:1.22-bookworm container (no local Go on this machine): go build, go vet, go test all green; gofmt -l caught one misaligned comment in handlers.go, fixedbroker/KEY_POLICY.md (where it lives, backup, manual rotation runbook for v1, blast-radius note, and the Phase 8 delegate-key target end-state)mesh-host/main.go built (by the user, in parallel) β identity + enc key load/create, broker registration + heartbeat loop, wired to the relay listener. Was not building from a clean checkout (go.mod had no require golang.org/x/crypto); fixed the same way as mesh-host's own verification pass, see Phase 3mesh-host/connect/: a discovery client (enroll, fetch /directory, verify the Ed25519 signature against the pinned broker pubkey, list hosts) β deliberately stops at discovery; actually forming a circuit is BuildCircuit (Phase 3, package-private to mesh-host, not importable from a separate binary without a refactor that felt too risky to make mid-flight on actively-changing files)broker-bin + mesh-host-bin + connect-bin together on a live Docker network (not Go's httptest, not in-test keys) β mesh-host registered with a real challenge/response and got approved, connect fetched the directory and correctly verified+listed it (role, operator, endpoint, real Ed25519 pubkey all correct)connect -invite-code) β surfaced one genuine operational finding: -gen-invite/-approve-host write directly to state.json, but a running broker process keeps its own in-memory copy and never reloads, so an invite generated while the server is already up silently doesn't work until the broker is restarted. /admin/gen-invite (already implemented) is the correct fix for the invite case since it goes through the live process's own handler instead of the file β -approve-host has no live-HTTP equivalent yet and has the same limitation. Documented in broker/README.md; worth an admin endpoint for approval too at some point, not blockingHostRecord.Tier/DirectoryHost.Tier (self-reported at registration, like Operator), ClientToken.Tier inherited from the invite consumed. POST /enroll/free self-serve mints a capped, shorter-lived free token with no invite/account (-free-enroll flag; disabled by default). POST /admin/gen-invite (bearer-token-guarded) lets the website mint a paid invite over HTTP after a confirmed payment instead of needing shell access β see MONETIZATION.md's account/payment model below. Covered by broker_test.go (TestFreeEnroll, TestAdminGenInvite); re-verified in the golang:1.22-bookworm container alongside the rest of the packageSuperseded the original "contribution-credit ledger" idea below: chat/mail/file-share/full-priority-mesh are paid-tier-gated behind a Mullvad-style anonymous account number (no email/password), paid via a self-hosted BTCPay Server (crypto, recommended β preserves the account's anonymity) or Stripe (card, optional, does link billing details). A capped free VPN tier stays available with no account and the exact same no-logs/onion guarantees, just a smaller host pool. See website/lib/db.js, website/lib/entitlement.js, website/lib/btcpay.js, website/app/pricing, website/app/account.
types.go β EncryptedLayer/ForwardPayload/ExitPayload/LayerPayload; a readable self-made wire format, explicitly not Sphinx-style constant-size packets β documented limitation, see WHITEPAPER Β§14)build.go β BuildCircuit, layers entryβexit with fresh ephemeral X25519 per hop)mesh-host (relay.go β relayHost.handleConn, length-prefixed frames, forwards or dials exit)diversity.go β SelectCircuit/pickDiverse, prefers distinct Operator tags, falls back rather than hard-failing on a small directory)rotation.go β CircuitManager, time-based + per-destination, never reuses ephemeral keys/nonces even when the hop selection is cached)mesh_test.go β TestThreeHopCircuitEndToEnd)mesh_test.go β TestAdversarialEntryNeverSeesDestinationExitNeverSeesClientIP) β this is the honesty check against WHITEPAPER Β§14golang:1.22-bookworm container: go build/go vet/go test all green. Found and fixed one real bug in the process β go.mod had no require golang.org/x/crypto at all; a fresh checkout wouldn't have built. Pinned v0.31.0 (latest requires Go β₯1.25; this project targets 1.22)installer/ end to end β the circuit/relay logic is verified in isolation (Go tests, synthetic hops), not yet run against actual wg-quick interfaces on live hosts. Distinct from the direct-VPN item below: this is about circuits, not plain WireGuard.Answers "how does a user actually connect a VPN": fetch the broker's directory, pick a host, self-serve a WireGuard config from it β no operator running scripts by hand.
vpn_pubkey/vpn_endpoint/vpn_provision_url (broker/types.go/handlers.go/store.go) β optional per host, empty for onion-only relays, validated (shape + all-or-nothing) the same way enc_pubkey already wasmesh-host -vpn: reads the WireGuard pubkey installer/wg-keygen.sh generates, advertises it + endpoint + a provisioning URL at registration, and serves POST /vpn/provision (mesh-host/vpn.go) β shells out to the existing wg-client-config.sh rather than reimplementing peer allocation, returns the resulting .conf as the HTTP response bodymesh-host/vpn-entrypoint.sh, Dockerfile): runs wg-keygen.sh + wg-quick up before handing off to the Go binary; bundles wg-keygen.sh/wg-client-config.sh/lib.sh straight from installer/, not copiesmesh-host/connect -vpn-connect <pubkey-prefix>: picks a VPN-capable host out of the verified directory and prints a ready-to-use configmesh-entry and mesh-exit both came up with real wg0 interfaces (IPv4 and IPv6 addresses assigned), registered vpn_pubkey/vpn_endpoint with the broker correctly, GET /directory lists both with the right data, POST /vpn/provision returns a complete, correct WireGuard config (fresh keypair, PSK, IPv6 ULA, correct externally-reachable endpoint), and the connect CLI lists the directory with VPN status correctly marked (VPN @ host:port vs. no VPN)51821:51820/udp) didn't match wg0's actual internal listen port once WG_PORT was set to 51821 for the external endpoint β wg-keygen.sh uses that same env var for the internal ListenPort too, so the port mapping was forwarding to a port nothing was listening on. Fixed to 51821:51821/udp (host and container ports matching)POST /vpn/provision config, fed it to an actual second container running wg-quick (debian:bookworm-slim + wireguard-tools, joined to the compose network, Endpoint rewritten from localhost:51821 to mesh-entry:51821) β a genuine WireGuard handshake completed both directions (wg show on both sides agree: same peer keys, "latest handshake: 14 seconds ago", real byte counters), and ping across the tunnel got 0% packet loss on both IPv4 (10.66.0.x) and IPv6 (fd00:66:66::x) β the client conf's PSK/keys/addressing are all genuinely correct, not just well-formatted. Getting the client container itself working reused installer/test-local/'s already-documented gotchas (missing resolvconf/iptables/procps packages, --privileged needed for sysctl net.ipv4.conf.all.src_valid_mark) rather than rediscovering them from scratch β worth noting the client side needs --privileged too, not just capabilities, matching the existing Phase 1 harness finding--privileged (not just NET_ADMIN/NET_RAW) plus iptables installed (for ip6tables-restore), IPv6 came up and passed traffic cleanly on the first attempt. Worth revisiting whether Phase 1's test-local/client-entrypoint.sh IPv6-stripping workaround is still necessary, or was only ever a missing---privileged/missing-package problem, not a real kernel limitationvpn-entrypoint.sh now sets net.ipv4.ip_forward=1 and adds the MASQUERADE + FORWARD iptables rules (auto-detecting the external interface, idempotent β -C check before -A), same shape as Phase 1's nftables.conf.tmpl masquerade rule, just applied to the VPN-capable mesh-host path where it was missing. IPv6 forwarding deliberately stays off (contained-not-routed, same policy as Phase 1). mesh-host/Dockerfile now installs iptables alongside wireguard-toolsmesh-entry with the fix, brought it up via compose (sysctls: net.ipv4.ip_forward=1 β works without --privileged), provisioned a real config via POST /vpn/provision, fed it to a genuine third container running real wg-quick on the compose network. Real handshake completed (wg show β real endpoint/byte counters), and curl https://ifconfig.me through the tunnel returned mesh-entry's actual public egress IP, not a Docker-internal address β proof traffic genuinely left the host and came back, not just that routes/rules exist. iptables -L -v on mesh-entry showed real non-zero packet/byte counters on both the MASQUERADE and FORWARD rules afterward. (IPv6 side of this same client hit the already-documented Phase 1 nft_fib kernel-module gap again when tested β not new, not related to this fix; stripped IPv6 from the test config, same workaround test-local/client-entrypoint.sh already uses, to isolate testing the IPv4 forwarding path itself.)wg-quick client container, same result β real handshake, curl https://icanhazip.com through the tunnel returned a genuine external IP, and the same nft_fib_ipv6 kernel-module gap on the IPv6 side (modprobe nft_fib_ipv6 fails outright: "Module not found" β confirmed as a real missing module in this WSL2 kernel build, not a privilege/package issue). Same fix (strip IPv6 from the test config) got IPv4 verification unblocked. Good sign the fix is robust, not a one-offgolang-go 1.22.2 from apt, matching go.mod exactly β no Docker on this box at all), running as systemd units (broker.service as the unprivileged tsops user, mesh-host.service as root β root is required here because vpn.go shells out to wg-client-config.sh, which itself require_roots, matching the existing Docker image's precedent of running as root, not a new decision). installer/wg-client-config.sh/wg-keygen.sh/lib.sh copied to /usr/local/bin so vpn.go's bare exec.Command("wg-client-config.sh", ...) resolves via PATH, same as the Docker image's /usr/local/bin placement.-admin-token=... CLI flag in the systemd unit, which put it in plaintext in systemctl status/ps output β moved it to EnvironmentFile= only (broker's -admin-token flag already defaulted to os.Getenv("BROKER_ADMIN_TOKEN"), so this needed zero code changes, just a unit-file fix) and rotated the token that had briefly been exposedGET /directory fetched externally (from a genuinely different machine) shows the real signed directory with this host's vpn_pubkey/vpn_endpoint/vpn_provision_url all correct8443/9080 in nftables for this (both live-ruleset nft insert and persisted into /etc/nftables.conf so it survives a reboot) β a deliberate, user-confirmed posture change from Phase 1's "only SSH open" result above, since the self-serve flow is unusable if a client can never reach the broker or the provisioning endpoint at all. Per installer/README.md's own reverse-proxy rate-limiting section, this box does not yet have a rate limiter in front of either port β fine for this verification pass, a real gap before durable public exposure/vpn/provision: still open, same reason as the IPv6/tunnel item above β the agent deliberately did not call the provisioning endpoint itself (the response body contains a live private key, same credential-handling line as wg-client-config.sh). Broker+mesh-host are live and reachable at 164.92.187.33:8443/:9080 right now, ready for the user to self-serve a config from their own device and report back whether the tunnel + IPv6 leak-prevention actually hold up end to end.installer/README.md now has a "Reverse-proxy rate limiting" section with a concrete nginx limit_req example covering both /vpn/provision and /enroll/free; mesh-host/README.md/broker/README.md cross-link it from each endpoint's own docsmesh-middle deliberately has no VPN capability (onion-only) β intentional, not a gap, but worth stating: the "host list" only ever shows entry/exit-role hosts, matching the whitepaper's diversity modelchat-client/app.js β generateIdentity, persisted in localStorage)chat-server/ws.go) β no libsodium.js: switched to the browser's native Web Crypto API and AES-256-GCM instead, see STACK.md's "Chat's AEAD" note. This is a real deviation from the original whitepaper plan, made deliberately, not silently.chat-server/crypto.go, chat-client/app.js)chat-server/store.go) β no plaintext, no long-term storagechat-server/server.go) β not in the original checklist wording, but necessary: without it, anyone could claim any pubkey and siphon/delete someone else's queued messageschat-client/index.html β send/receive, no history beyond the session)chat-server/chat_test.go, real WebSocket handshake both directions, not mocked)hello now carries a broker-issued token, verified offline in token.go against -broker-pubkey/BROKER_PUBKEY before the ECDH challenge is even sent β missing/free-tier/expired/bad-signature tokens all get auth_error + closed connection. chat-client/index.html has a token field (from the website's /account page) instead of auto-connecting. Covered by TestAuthRejectsMissingOrFreeTierToken; re-verified in the golang:1.22-bookworm container (go build/go vet/gofmt -l/go test ./... -v, all green)docker compose stack: two genuinely separate browser contexts (isolated storage, distinct X25519 identities generated client-side), each authenticated with a real broker-issued free-tier token (POST /enroll/free), exchanged messages both directions, content verified byte-for-byte in the DOM. Found and fixed two real bugs neither Go's own test client nor code review had caught, both only visible from an actual browser:chat-client/app.js's generateIdentity/importIdentity called crypto.subtle.exportKey('raw', privateKey)/importKey('raw', ...) on the private key β Chromium's real Web Crypto implementation only allows 'raw' format for the public key; the private key needs 'pkcs8'. Failed immediately with "the key is not of the expected type" before any connection was even attempted. Fixed: private key now exported/imported as 'pkcs8'.chat-server/ws.go's WriteMessage sent every JSON envelope as a binary WebSocket frame (opcode 0x2). Go's own test client doesn't care about opcode and read the bytes fine either way β but a real browser's native WebSocket delivers a binary frame's payload as a Blob by default, so JSON.parse(event.data) in chat-client/app.js failed with "[object Blob]" is not valid JSON and the connection never completed. Fixed: chat only ever carries JSON, so WriteMessage now sends a text frame (0x1) β matches what file-share's copy of this file already did correctly.go build/go vet/gofmt -l/go test ./... -v all still green in golang:1.22-bookworm after both fixes; existing Go test suite caught neither regression nor the original bugs, underlining why the real-browser pass mattered.token.go, same scheme as chat-server)types.go/ws.go)status/status_result reports received indices so a client resumes only what's missing)store.go) β matches WHITEPAPER Β§9file_share_test.go)golang:1.22-bookworm container: go build/go vet/go test all greenchat-client/'s FileShareClient (app.js) + a file-share section in index.html: pick a file, chunked upload with progress, list pending transfers, chunked download + decrypt into a downloadable Blob. Same page/identity/token as chat, not a separate appws.go copy's maxFrameSize to 6MiB (chat-server's copy stays at 2MiB β chat never needs more) β room for a full ~4MB chunk plus its small JSON header overhead. go build/go vet/gofmt -l/go test ./... re-verified green in the container after the changeFILE_CHUNK_SIZE β bob listed the pending transfer, downloaded it, and the downloaded bytes were confirmed byte-identical to the original (Buffer.compare, not just "no error thrown"). Found and fixed two more real bugs, both invisible to the existing Go-only test suite:chat-client/app.js's shared HKDF_INFO_AUTH constant was reused by FileShareClient for its auth-challenge key derivation β but file-share/crypto.go deliberately uses a different info string (tintedsand-fileshare-auth-v1 vs. chat's tintedsand-chat-auth-v1) so the two services' derived keys can never collide. The client and server derived different keys, so every file-share auth challenge decrypt threw OperationError (AES-GCM tag mismatch). Fixed by giving FileShareClient its own matching HKDF_INFO_FILESHARE_AUTH constant.encoding/json omitempty on Index int in file-share/types.go's wire envelope drops the "index" key entirely when Index is 0 (0 is the int zero value, so omitempty treats it as absent) β the client's header.index for chunk 0 came back undefined in JS, and parts[undefined] = plaintext silently wrote it into a stray object property instead of array index 0. The Go test client never caught this because json.Unmarshal into a Go struct leaves a missing field at its zero value anyway β 0 either way, indistinguishable β but JS's undefined-on-missing-key made the bug visible immediately. Fixed by dropping omitempty from Index (a real, if narrow, general lesson: never omitempty an int/bool field where the zero value is meaningful and position/index-like).FileShareClient's internal _waitFor(type) request/response matching only tracked waiters, not a message buffer β file-share's server streams every download chunk back-to-back with no per-chunk client ack, so a chunk message arriving while the client was still await-ing the previous chunk's AES-GCM decrypt had no waiter registered yet and was silently dropped. Fixed with a proper buffered-queue (deliver-to-waiter-or-buffer-for-next-waitFor) pattern instead of a waiter-only list β this one could have caused intermittent, timing-dependent corruption even after fix (2) above, so it's fixed as a correctness issue in its own right, not just to unblock the test.go build/go vet/gofmt -l/go test ./... all still green in golang:1.22-bookworm after all fixes.postfix/main.cf.tmpl, postfix/master.cf.snippet) β TLS-only submission (587)/smtps (465), Dovecot SASL auth, check_policy_service unix:private/policyd, OpenDKIM milter hookdovecot/dovecot.conf.tmpl) β IMAP, Maildir, TLS-only, virtual mailboxes: a single shared vmail system user owns all mail storage, mailbox identity/auth comes from a flat passwd-file (admind/ below is the only writer) instead of real per-mailbox Unix accounts; SASL auth socket shared with Postfix; final delivery over LMTP (main.cf.tmpl's virtual_transport)opendkim/opendkim.conf.tmpl, opendkim/gen-dkim-key.sh β generates the keypair and prints the DNS TXT record to publish; refuses to clobber an existing key without --force)dns-records.md): MX, A/AAAA, PTR, SPF, DKIM, DMARC, plus Β§7 on why correct DNS alone doesn't fix deliverability (IP reputation/blocklists)policyd/, check_policy_service protocol implemented directly, zero dependencies) for basic abuse mitigation: rejects anonymous (unauthenticated) submission, rejects accounts without an active paid entitlement (entitlements.go, a flat JSON file hot-reloaded on mtime change), and rate-limits per account (ratelimit.go, fixed-window). Same paid-tier decision as chat/file-share (WHITEPAPER Β§7.5/Β§10), just enforced at the mailbox/SASL-username level since SMTP has no clean per-message bearer-token slotserve() β 8 tests, all passing (policyd/policy_test.go)golang:1.22-bookworm container: go build/go vet/gofmt -l/go test ./... -v all green (two files needed a gofmt -w pass, applied)install.sh β certbot certonly --standalone, deploy-hook reloads postfix/dovecot)admind/, a bearer-token-guarded HTTP daemon (zero dependencies) with POST /provision/POST /deprovision, called by website/lib/entitlement.js (via website/lib/mail.js) the same way it calls broker's /admin/gen-invite. A mailbox is a line in Dovecot's passwd-file ({SSHA256}-hashed password, generated and returned exactly once) plus an entitlements-file entry β no adduser/PAM/real Unix accounts, no shell-out at all. Idempotent (renewal never re-issues a password); deprovisioning blocks login but never deletes the Maildir. 9 tests passing, independently re-verified in golang:1.22-bookworm (go build/go vet/gofmt -l/go test ./... -v all green)install.sh updated: creates the shared vmail user + mail storage root, creates/permissions the passwd-file and entitlements file for cross-daemon read/write access, builds and installs mail-admind as a systemd unit alongside mail-policyd, generates (or accepts) MAIL_ADMIN_TOKEN and prints it once for the website's matching env varlib/entitlement.js's sweepLapsedMailboxes() (queries lib/db.js's getLapsedMailAccounts() for accounts with a mailbox whose paid_until has passed, calls /deprovision for each, best-effort) exposed at POST /api/admin/sweep-mail, bearer-guarded with MAIL_ADMIN_TOKEN; an operator points a cron/systemd timer at it (checklist previously said this wasn't wired up β it already was, just undocumented here). Verified live: built the real mail-admind binary, ran it in Docker, provisioned a real mailbox over HTTP, seeded a website SQLite row with a lapsed paid_until pointing at it, ran the actual sweepLapsedMailboxes() logic (unmodified, imported directly) against the live instance β confirmed {total:1, deprovisioned:1, failed:[]}, then confirmed on disk that the passwd-file entry and entitlements-file key were both really gone, and that re-calling /deprovision on an already-removed mailbox is a clean no-op (idempotent, matching mail/admind/server.go's contract)User call: scratch pursuing a real domain for persistent paid mailboxes right now β sender/IP reputation is the one problem in this whole project that isn't fixable by writing better code, and it's not worth taking on before the rest of the product is proven. Everything above stays as real, working, unit-tested code (nothing deleted) and is a valid base to come back to later. The feature that actually ships instead is a temporary, receive-only disposable mailbox β reuses the same Postfix/Dovecot/admind, reconfigured: submission (587)/smtps (465) disabled entirely (no SASL, nothing to authenticate β policyd isn't run in this deployment, it has nothing left to guard), OpenDKIM dropped (nothing outbound to sign), new admind endpoint POST /tempmail/create for a random short-TTL address, expiry hard-deletes the Maildir (unlike paid-mailbox deprovisioning, which deliberately keeps it). Free-tier eligible, rate-limited creation (same shape as /enroll/free), reachable only through the mesh.
mail/admind: POST /tempmail/create (random t-prefixed local-part, short TTL via -tempmail-ttl, a real password generated/hashed but never returned β nothing needs durable IMAP login for a disposable inbox) and GET /tempmail/{username}/messages (reads the Maildir directly off disk via stdlib net/mail, not IMAP β see mail/README.md's new Temp-mail section for the full design). A background sweep (sweepTempMail) hard-deletes both the passwd-file entry and the Maildir on expiry β verified with a test that starts the real goroutine and polls until the Maildir is actually gone from disk, not just that the function returnedpostfix/main.cf.tempmail.tmpl + postfix/master.cf.tempmail.snippet β the latter is deliberately empty, nothing to append); confirmed policyd simply isn't built or run in temp-mail mode (install.sh TEMPMAIL=1 skips it entirely)install.sh's temp-mail path (TEMPMAIL=1 skips the opendkim package install, key generation, and milter config); SPF/DMARC records still documented in dns-records.md to block spoofing even though this deployment never sends/tempmail page (generate an address, poll every 5s, view messages) + app/api/tempmail/{create,messages}/route.js proxying to mail-admind (never exposes MAIL_ADMIN_URL to the browser directly)tempmail_test.go/maildir_test.go/tempmail_http_test.go) β expiry DB CRUD, username generation/uniqueness, Maildir parsing (headers/body/delivery-order/skip-corrupt), full HTTP createβdeliverβread flow, unknown-address 404, live sweep-deletes-the-Maildir. All green in golang:1.22-bookworm (go build/go vet/gofmt -l/go test ./... -v)-tempmail, created a real address over HTTP, wrote a synthetic RFC822 message into the Maildir path it reported (standing in for Postfix/Dovecot LMTP delivery), confirmed the messages endpoint read it back correctly β then did the same thing again through the actual website's production build with MAIL_ADMIN_URL pointed at that live instance, confirming the full websiteβadmindβMaildir path, not admind in isolation/tempmail/create the same way as /enroll/free//vpn/provision β installer/README.md's reverse-proxy rate limiting section now covers all three unauthenticated mint-a-resource endpoints with an nginx limit_req example; GET /tempmail/{username}/messages deliberately left unlimited there (a read against an already-random address, not a resource-minting endpoint)docker-compose.yml): one container per service β broker, mesh-entry/mesh-middle/mesh-exit (same mesh-host image, different -role), chat-server, file-share, website. debian:bookworm-slim/node:22-bookworm-slim runtime images for now (easy docker exec debugging while the topology was still being proven) β swapping to distroless for a genuinely minimal image is a trivial follow-up, not done yetdocker/wait-for-broker.sh: chat-server/file-share/website all need the broker's Ed25519 pubkey at startup, but the broker only generates it on its own first boot β this polls GET /broker/pubkey until it answers, then execs the real process with BROKER_PUBKEY set. Reused across all three rather than three one-off scripts.dockerignore doesn't apply to website's build β its context was ./website, and Docker only honors a .dockerignore at the actual build-context root. The host's Windows-compiled node_modules/better-sqlite3 binary was getting copied over the container's correctly-compiled Linux one (RUN npm run build failed with invalid ELF header). Fixed by moving website's build context to the repo root too, matching the Go services, so one .dockerignore covers everythingwebsite/lib/docs.js reads WHITEPAPER.md/CHECKLIST.md/MONETIZATION.md from one directory above the app (path.join(process.cwd(), "..")) β with a repo-root build context this meant the Dockerfile had to explicitly place those files next to, not inside, /app/website in the image, at both the build stage (for prerendering /whitepaper, /checklist) and the runtime stagewebsite/app/nodes/page.js reads BROKER_DIRECTORY_URL and BROKER_PUBKEY β two more broker-derived env vars beyond what lib/broker.js needed, missed on the first pass and only caught because the page itself said so (β
NO DIRECTORY YET β
, a deliberately loud placeholder rather than a silent failure). website now goes through the same wait-for-broker.sh as chat-server/file-shareGET /directory (right role/endpoint/operator each); minted a real paid-tier invite via POST /admin/gen-invite (bearer-token-guarded, as the website's payment webhook would) and enrolled it into a real signed token via POST /enroll; website's /api/status shows genuine per-service health checks (broker/chat/file-share all "up" with real latency numbers, mail correctly "unconfigured" rather than a false red β the fourth state doing its job); /nodes renders the real signed directorybroker.service + mesh-host.service written and running on the real droplet (see "VPN host list" section above for the full account) β not generated from a shared template yet (hand-written, one-off for this box), and only two of the compose stack's six services (chat-server/file-share/website/mesh-middle/mesh-exit still don't have bare-metal units) β partial, not the full Phase 7 item, but the first real (non-Docker) production deployment this project has hadmesh-host instance (role=exit) on this same box to match the compose reference's entry+exit pair. wg-keygen.sh/wg-client-config.sh hardcode server.key/server.pub directly under /etc/wireguard (not namespaced by WG_IFACE), so a second instance would either refuse to run or clobber the first's server key β Docker's one-container-per-instance filesystem isolation had been silently hiding this the whole time the compose stack was the only thing exercising this path. Fixed by making WG_DIR itself configurable in both scripts (installer/README.md documents pointing mesh-host -wg-pubkey-file at the matching $WG_DIR/server.pub). Deliberately did not then deploy a second instance on this box β entry and exit sharing one physical host defeats the actual point of the diversity model (diversity.go exists specifically so one operator can't see both ends of a circuit); a second instance belongs on a second, differently-operated host, not squeezed onto this one just to check a boxOperator (PRE_LAUNCH_ENGINEERING.md Β§1's "vibes, not verification" gap): the broker independently resolves each registering host's origin ASN via Team Cymru's public DNS-based IP-to-ASN service (broker/asn.go, plain net.LookupTXT β stdlib only, no new dependency, no API key) and publishes it in the signed directory alongside Operator. mesh-host/diversity.go's SelectCircuit/pickDiverse now prefer the verified ASN over Operator (diversityTag picks ASN when the lookup resolved one, falls back to Operator otherwise β same graceful-degradation posture as before, an empty tag is never excluded). Closes the concrete attack this was flagging: a host claiming a different Operator string per registration no longer defeats the diversity constraint if those registrations resolve to the same ASN. Operator itself is untouched and still published β this adds a trust-verified signal alongside it, doesn't remove the self-reported one. New tests: broker/asn_test.go (TXT-response parsing, RemoteAddr host-extraction, both pure-function unit tests, no real DNS needed), broker_test.go's TestRegisterStoresVerifiedASN/TestRegisterToleratesASNLookupFailure (real HTTP registration flow, fake lookupASN injected via the now-overridable server.lookupASN field, same pattern as the existing overridable server.now), mesh-host/mesh_test.go's TestCircuitDiversityPrefersASNOverOperator (proves ASN wins over a colliding/differing Operator string in both directions). Independently re-verified in a clean golang:1.22-bookworm container: go build/go vet/gofmt -l/go test ./... -v all green for both broker and mesh-host, including the pre-existing circuit/streaming tests β no regressions from either this or the parallel streaming-circuits work landing at the same time. Real ASN lookups against real registering hosts haven't been exercised yet outside tests β the bare-metal broker on the real VPS (see Phase 1/7 above) predates this change; worth confirming it resolves a real ASN for a real second host once one exists at a different provider (IPv6-sourced registrations still fall back to Operator β origin6.asn.cymru.com's nibble-reversed format isn't implemented, a known, documented scope gap, not silently missed)Architecture decisions locked in WHITEPAPER v0.4; nothing below is implemented yet.
Mesh-routing (closes the open item carried since Phases 4-6):
Streaming circuits (WHITEPAPER Β§7.2) β VERIFIED, prerequisite for onion-mode VPN traffic:
mesh-host/types.go (Stream bool on ForwardPayload/ExitPayload), mesh-host/relay.go (handleConn loops instead of handling exactly one frame; forward/dialExit accept and return a reusable onward connection instead of dialing fresh each call; new readAvailable does a bounded, non-fatal-on-timeout read for the exit hop's streaming case), mesh-host/build.go (buildCircuit gained an internal stream flag β BuildCircuit's public signature/behavior is unchanged, byte-for-byte, when stream=false; new OpenStream/CircuitStream client type with Send/Close). Every existing one-shot circuit is untouched β this is additive, not a breaking change to Phase 3's design.TestStreamingCircuitProxiesBidirectionalBytes (mesh-host/mesh_test.go) β a real 3-hop circuit, opened once via OpenStream, carries three independent round trips to a persistent echo destination over the same per-hop connections, not three separate circuits. Independently verified in a clean golang:1.22-bookworm container: go build/go vet/gofmt -l/go test ./... -v all green, including the pre-existing TestThreeHopCircuitEndToEnd, TestAdversarialEntryNeverSeesDestinationExitNeverSeesClientIP, and the (parallel-work) ASN diversity tests β no regressions.Send both writes and polls for a reply within a bounded 300ms window at the exit hop), not independent concurrent full-duplex. Real unprompted server-push traffic needs a genuinely concurrent relay β flagged as follow-up work in WHITEPAPER Β§7.2, not implemented here.VpnService + TUN capture + a tun2socks-style bridge into CircuitStream) β see "Native client scope" below; this item is the backend half onlyChat ratchet (WHITEPAPER Β§8):
HKDF(chain_key) β (next_chain_key, message_key) per message, both directionsChat rooms + presence (WHITEPAPER Β§8.1) β Jabber/Matrix-like properties, extending the existing chat-server, not adopting XMPP/Matrix server software:
chat-client: room UI (create/invite/send-to-room) and presence indicatorDistributed file storage (WHITEPAPER Β§9):
storage capability flag to mesh-host registration/directory (same shape as the existing vpn capability) β broker/types.go (HostRecord/DirectoryHost.StorageCapable/StorageCapacityMB), broker/handlers.go (registerRequest fields + validation: storage_capacity_mb must be >0 when storage_capable is set, same "required together" shape as the VPN fields), broker/store.go (activeHosts projects both fields into the signed directory), mesh-host/main.go (-storage/-storage-capacity-mb flags, registerWithBroker plumbing, refuses to start with -storage set and no capacity). Independently verified in a clean golang:1.22-bookworm container: go build/go vet/gofmt -l/go test ./... green for both broker and mesh-host, including two new tests (TestRegisterRejectsStorageCapableWithoutCapacity, TestStorageCapableHostAppearsInDirectory β real registerβheartbeatβsigned-directory-fetch round trip, not just unit-level). This is registration/directory plumbing only β no chunk-replication logic yet, that's the next item.Mail (superseded β see "Phase 6 decision, revised" above): the PGP/WKD-for-persistent-mailboxes plan that was here is shelved along with persistent mailboxes themselves. Temp-mail's own task list lives under Phase 6 above, not here.
Native client scope (WHITEPAPER Β§5.1):
mesh-host/connect stays VPN-connection-only; chat/file-share/mail stay web-only β do not build a bundled native appmesh-host/vpn.go's POST /vpn/provision?format=qr serves the PNG installer/wg-client-config.sh already generates via qrencode (previously generated but never reachable over HTTP). Website's new /connect page (website/app/connect/page.js + ProvisionButton.js) lists VPN-capable hosts straight from the verified signed directory and gets either a QR code (scan with the official WireGuard app, Android or iOS) or a .conf download (desktop) β proxied server-side through a new POST /api/vpn/provision route that validates the requested host against the broker's own signed directory before proxying (guards against an open SSRF-style proxy to an arbitrary client-supplied URL). Independently verified: mesh-host go build/go vet/gofmt -l/go test ./... green; website npm run build compiles clean with both new routes listed.CircuitStream (Go β could reuse mesh-host's existing circuit-client code directly, e.g. via gomobile bindings, rather than reimplementing the onion/ECDH logic in Kotlin), and (b) Android's VpnService API + a tun2socks-style TUN-to-SOCKS bridge (existing open-source tool, not hand-rolled β same "reuse the ecosystem" call as everywhere else) to actually capture system traffic into that bridge. This environment has no Android SDK/Gradle/ADB (confirmed absent) β any Kotlin/Gradle project code written here would be unverified/unbuildable by me directly; needs a real Android dev setup (Android Studio, SDK, emulator or device) to actually build, run, and test before it's real, not just written.website/lib/db.js (SQLite, single file, same philosophy as broker/store.go's flat JSON), website/app/api/checkout (crypto via self-hosted BTCPay Server, recommended; card via Stripe, optional), website/app/api/webhooks/{btcpay,stripe} β website/lib/entitlement.js extends paid_until and mints a broker access token via /admin/gen-invite + /enroll/enroll/free path documented as a required deployment step β see the VPN host list section above (same fix covers both endpoints)mesh-host/connect, extended with kill switch/auto-reconnect/split-tunnel/DNS-leak protection per Phase 9) β chat/file-share/mail stay web-only via the paid-access hub, not bundled into a second native appwebsite/: Next.js scaffold/whitepaper, /checklist) β MONETIZATION.md deliberately stays internal-only (it's the business-strategy scratchpad, not customer-facing copy); /pricing is the public-facing equivalent/checklist, which renders this file directly)/account, lib/db.js)/pricing, api/checkout, api/webhooks/*)/nodes: live mirror of the broker's signed directory, split into free (endpoint shown) vs. paid (count only, endpoints handed out post-enrollment) tiers/check: free "connection check" / why-VPNs-matter diagnostic β computed per-request from the incoming request, nothing persisted anywhereBROKER_URL/BROKER_DIRECTORY_URL/BROKER_PUBKEY now verified against a real broker, not just .env.example: a website instance (npm run dev, port 3001 β deliberately not the existing docker-compose website on 3000, which has its own established local broker/BTCPay-regtest setup and shouldn't be disturbed) pointed at the real bare-metal broker at 164.92.187.33:8443. GET /api/status reports "vpn mesh / broker": "up" with a real ~124ms latency number (Frankfurt VPS round-trip, not a mocked value), and GET /nodes renders the actual signed directory β tintedsand-vps1, role entry, fetched live over the real internet. BROKER_ADMIN_TOKEN deliberately left unset for this instance (no need to hold that secret just to prove the directory/status wiring works).BTCPAY_*/STRIPE_* against a REAL (mainnet or real testnet) account is still open β there is a local BTCPay regtest stack running (docker ps shows btcpayserver/nbxplorer/postgres/bitcoind containers, referenced by the existing docker-compose website's .env), which is a real functioning test instance, not a mock, but still not the "real BTCPay/Stripe account" this item originally meant. Stays open pending an actual account/deployment decision, not code work.npm run build compiles clean (Turbopack, all 16 routes, static + dynamic correctly split); smoke-tested the already-running dev server live β all static pages return 200, POST /api/account/create genuinely creates an account against the SQLite db (not a stub), POST /api/checkout fails with a sensible JSON error ("unknown account") for an unrecognized account number rather than crashingwebsite/app/api/checkout/route.js already wraps request.json() in try/catch and returns a clean 400 ("invalid or missing JSON body"). Confirmed against a real running dev server with three variants β a truly empty POST, an explicit Content-Length: 0, and no body/no content-type header β all return 400, none 500s/api/visitors, increments a sqlite row, no per-visitor data stored), this-process uptime with a slow typewriter reveal (lib/uptime.js), and a green/yellow/red/grey service-status grid (/api/status β real HTTP checks for broker/chat/file-share, a raw TCP connect for mail, "unconfigured" as a distinct 4th state rather than folded into red). Styled as a CRT/cyberpunk terminal panel bolted onto the existing Win95 chrome. Smoke-tested against the live dev server: counter increments across requests, status correctly reports "unconfigured" for every service since no BROKER_URL/etc. are set yet in this environmentDocs pass (Claude): also brought README.md/STACK.md back in sync with what's actually built (VPN host-list ports, mail/admind status, mesh-host's -vpn mode), and added MONETIZATION.md Β§5 (concrete commercial ideas now that the product is real: gift/voucher codes, tiered plans, annual billing, referral credits, revisiting the contribution-credit ledger sooner, a developer/API tier).
broker -gen-invite/POST /admin/gen-invite β closest to shippable, no new broker workplan field alongside tier, VPN-only vs. full-suite vs. family/team) β touches ClientToken/entitlements in broker, mail/admind, and entitlement.jsentitlement.js + a second Stripe price/BTCPay amountreferred_by column in accounts.db, a referral-code panel on /account, crediting logic in entitlement.js/directory//enroll//vpn/provision, monetizes the infra without building more client UX/checklist public pre-launch (radical-transparency asset) or hold it back until Phase 1's real-VPS test passes β a deliberate call either way, see MARKETING.md Β§1/compare page or blog post: TintedSand vs. Mullvad/ProtonVPN/NordVPN on the open-source/self-hostable/anonymous-account axis specifically, not feature-count