CHECKLIST.TXT β€” Notepad (this is also our public status page)_β–‘X

Project Checklist (Master β€” Track A: Build, Track B: Sustainability)

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.


Track A β€” Build

Phase 0 β€” Pre-Phase (Planning) β€” IN PROGRESS (current)

  • Define threat model & design principles
  • Choose VPN transport (WireGuard)
  • Choose broker/mesh model (coordination-only broker + onion-style multi-hop)
  • Choose chat architecture (custom E2E, server as ciphertext relay only)
  • Choose mail architecture (minimal hand-assembled stack)
  • Write WHITEPAPER.md
  • Finalize language/runtime per component (STACK.md β€” Go stdlib-first, vanilla JS+WASM chat client, Next.js exception for website)
  • Write MONETIZATION.md
  • Initialize git repo
  • Scaffold repo layout (broker/, mesh-host/, chat-server/, chat-client/, mail/, installer/, website/, docs/)
  • Pick a project name β€” TintedSand
  • Pick a dev/test environment for early host work β€” disposable VPS (local VMs ruled out, not viable on this machine)

Phase 1 β€” WireGuard + Host Hardening (single host, no mesh yet) β€” IN PROGRESS (current)

  • .gitignore for Go build artifacts, node_modules, secrets/keys
  • installer/bootstrap.sh: SSH key-only + no root login, nftables default-deny, fail2ban, unattended-upgrades, sysctl hardening
  • installer/wg-keygen.sh: WireGuard server keypair + base wg0.conf generation
  • installer/wg-client-config.sh: per-client .conf + QR code generator
  • nftables ruleset (nftables.conf.tmpl): default-deny inbound, explicit allow for WireGuard UDP port + SSH only
  • installer/README.md
  • Decide: WireGuard obfuscation is deferred to Phase 8 β€” v1 targets plain WireGuard UDP only
  • Researched what a Phase 1 checklist usually misses (WireGuard hardening guides, IPv6-leak reports, current CVE search) and closed the real gaps found:
    • Boot persistence β€” wg-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)
    • IPv6 leak β€” server + each client now get an IPv6 ULA on 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)
    • Per-peer PresharedKey added (wg genpsk) as defense-in-depth on top of the Curve25519 handshake
    • journald retention capped (200M / 2 weeks) β€” whitepaper commits to minimal logging, bootstrap.sh previously didn't enforce it
    • SSH new-connection rate-limit added in nftables alongside fail2ban (belt and suspenders, fail2ban is reactive, this is proactive)
    • CVE search came back clean for core WireGuard itself β€” the 2025-26 CVEs found are all in unrelated software (a web admin panel we don't use, router firmware, Cilium)
  • installer/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)
  • Manually confirmed inside the container: 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)
  • Automated end-to-end run of 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 specifically
  • IPv6 is deliberately skipped in this harness, not silently broken β€” wg-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 about
  • Real VPS stood up (DigitalOcean droplet, Ubuntu 24.04.4 LTS, fra1): bootstrap.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.
  • Port-scanned from a genuinely separate machine (not the VPS itself): 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.)
  • IPv6 leak-prevention path + tunnel-passes-traffic, from a real client: deliberately not done by the agent β€” provisioning a client (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.

Phase 2 β€” Broker β€” IN PROGRESS (current)

  • Define client enrollment mechanism β€” invite codes, single-use, operator-generated via broker -gen-invite
  • Implement client credential issuance (POST /enroll β€” short-lived Ed25519-signed token, self-verifying, no callback needed)
  • Implement host registration (GET /hosts/register/challenge + POST /hosts/register β€” nonce challenge/response proves possession of the host's long-term key)
  • Implement signed, versioned host directory (GET /directory β€” canonical JSON body + X-Signature header, Ed25519; Version tracks registry mutations, GeneratedAt proves a fresh snapshot)
  • Implement host heartbeat protocol + directory pruning on missed heartbeats (POST /hosts/heartbeat, signed, skew-checked; stale hosts drop out of /directory without deleting their registration)
  • Implement directory-fetch endpoint for clients (GET /directory, above)
  • Manual-approval path (-approve-host) alongside -auto-approve-hosts for early/low-trust operation, per WHITEPAPER Β§7.4
  • Zero third-party dependencies β€” pure Go stdlib (crypto/ed25519), better than the +1 x/crypto STACK.md originally assumed, since the broker never needed X25519/ChaCha20
  • Test: broker + 2 hosts + 1 client β€” directory issuance/refresh works, revoked/expired hosts drop out (broker/broker_test.go β€” go test ./... passes: full flow, bad-signature rejection, manual-approval gating)
  • Independently re-verified in a clean 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, fixed
  • Broker signing-key handling policy doc β€” broker/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 3
  • mesh-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)
  • Real host test, for real: ran actual 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)
  • Enrollment tested for real too (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 blocking
  • Free/paid tiering β€” HostRecord.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 package

Track B decision, made β€” see updated MONETIZATION.md

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

Phase 3 β€” Multi-Hop Circuits β€” CORE IMPLEMENTED (verification pass by Claude; the diversity/rotation/relay code itself is the user's)

  • Design onion packet format (types.go β€” EncryptedLayer/ForwardPayload/ExitPayload/LayerPayload; a readable self-made wire format, explicitly not Sphinx-style constant-size packets β€” documented limitation, see WHITEPAPER Β§14)
  • Implement client-side circuit builder (build.go β€” BuildCircuit, layers entryβ†’exit with fresh ephemeral X25519 per hop)
  • Implement per-hop peel/forward logic on mesh-host (relay.go β€” relayHost.handleConn, length-prefixed frames, forwards or dials exit)
  • Implement circuit diversity constraint (diversity.go β€” SelectCircuit/pickDiverse, prefers distinct Operator tags, falls back rather than hard-failing on a small directory)
  • Implement circuit rotation (rotation.go β€” CircuitManager, time-based + per-destination, never reuses ephemeral keys/nonces even when the hop selection is cached)
  • Test: 3-hop circuit end to end (mesh_test.go β€” TestThreeHopCircuitEndToEnd)
  • Adversarial test (mesh_test.go β€” TestAdversarialEntryNeverSeesDestinationExitNeverSeesClientIP) β€” this is the honesty check against WHITEPAPER Β§14
  • Independently re-verified in a clean golang: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)
  • Wire onion circuits through a real WireGuard tunnel from 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.

Mullvad-style VPN host list β€” CORE IMPLEMENTED AND VERIFIED LIVE

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.

  • Broker directory extended with 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 was
  • mesh-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 body
  • Container wiring (mesh-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 copies
  • mesh-host/connect -vpn-connect <pubkey-prefix>: picks a VPN-capable host out of the verified directory and prints a ready-to-use config
  • Verified live against the real docker-compose stack, not just unit tests: mesh-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)
  • Real bug found and fixed getting this running: the host-mapped UDP port (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)
  • Independently re-verified end to end, one level deeper than "the response looks right": took a real 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
  • New finding, IPv6 fwmark routing works fine in this Docker Desktop/WSL2 setup after all β€” Phase 1's harness (line ~41 above) concluded IPv6 fwmark routing was a Docker Desktop/WSL2 kernel limitation and gave up on it for that test. Here, with --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 limitation
  • NAT/forwarding gap (above) fixed: vpn-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-tools
  • Re-verified live, the same rigorous way as the tunnel itself: rebuilt mesh-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.)
  • Independently reproduced (separate session, same day): fresh 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-off
  • Broker + mesh-host deployed bare-metal on the real VPS (Phase 7's bare-metal gap, first instance of it): built both as plain Go binaries directly on the droplet (golang-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.
    • Real fix applied while wiring this up: the broker admin token was first passed as an -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 exposed
    • GET /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 correct
    • Opened 8443/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
  • A genuine external client over the actual internet, self-serving via /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.
  • Documented as a required deployment step, not app-layer code β€” deliberately kept as a deployment-level reverse-proxy concern rather than in-app rate limiting (consistent with the zero-extra-dependency stance, STACK.md). 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 docs
  • mesh-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 model

Phase 4 β€” Chat Service β€” CORE IMPLEMENTED

  • Client X25519 keypair generation in web client (chat-client/app.js β€” generateIdentity, persisted in localStorage)
  • Hand-rolled WebSocket framing (RFC 6455) on the server (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.
  • Message encrypt/decrypt (AES-256-GCM via X25519 ECDH + HKDF, both platforms' native/stdlib crypto β€” chat-server/crypto.go, chat-client/app.js)
  • Server-side ciphertext relay + delivery queue with TTL purge (chat-server/store.go) β€” no plaintext, no long-term storage
  • ECDH-based auth handshake proving a client controls its claimed identity key before it can send/receive (chat-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 messages
  • Minimal web UI (chat-client/index.html β€” send/receive, no history beyond the session)
  • Test: two clients exchange a message while both online, offline queueing + delivery on reconnect, TTL sweep, and auth-rejection β€” all passing (chat-server/chat_test.go, real WebSocket handshake both directions, not mocked)
  • Paid-tier access gating (WHITEPAPER Β§7.5, added once the account/payment decision landed): 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)
  • Route chat traffic through the mesh (reachable only via an exit/rendezvous host) β€” chat-server currently listens directly; wiring it behind Phase 3's circuits is still open
  • Tested in an actual browser, for real β€” real Chromium (Playwright, downloaded and driven headlessly, not the Go test client) against the live 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:
    1. 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'.
    2. 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.
    • Re-verified: 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.

Phase 5 β€” File Sharing β€” CORE IMPLEMENTED (verification pass by Claude; the code is the user's, checklist just hadn't caught up)

  • Reuse chat's X25519 client identity + auth handshake and broker-issued paid-tier token β€” no second identity/entitlement system (token.go, same scheme as chat-server)
  • Client-side chunking + per-chunk encryption, efficiently framed: only the header (index, nonce) is JSON, ciphertext rides a raw binary WS frame right after (types.go/ws.go)
  • Resumable chunked upload/download protocol (status/status_result reports received indices so a client resumes only what's missing)
  • Server-side temp relay by transfer ID, no plaintext, no metadata beyond what's needed to reassemble (store.go) β€” matches WHITEPAPER Β§9
  • TTL purge for undelivered transfers (default 7 days, background sweep)
  • Test: upload/download round trip, resumable upload after a simulated disconnect, TTL sweep, same auth-rejection cases as chat-server β€” all passing (file_share_test.go)
  • Independently re-verified in a clean golang:1.22-bookworm container: go build/go vet/go test all green
  • Route file transfers through the mesh (Phase 3) β€” standalone listener for now, same open item as chat-server
  • Web UI β€” chat-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 app
  • Reconcile chunk size vs. frame size: resolved by raising file-share's own ws.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 change
  • Tested in an actual browser, for real β€” same real-Chromium/Playwright pass as chat-client above, extended to a full file-transfer round trip: alice (one browser context) sent a 9,000,000-byte file to bob (a second, fully isolated context) β€” 3 chunks at the real 4MB FILE_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:
    1. 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.
    2. Even after the above, a 3-chunk download silently corrupted: Go's 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).
    3. Separately, 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.
    • Re-verified: go build/go vet/gofmt -l/go test ./... all still green in golang:1.22-bookworm after all fixes.

Phase 6 β€” Mail Service β€” CORE IMPLEMENTED

  • Postfix base config (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 hook
  • Dovecot config (dovecot/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 setup + key generation (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 doc (dns-records.md): MX, A/AAAA, PTR, SPF, DKIM, DMARC, plus Β§7 on why correct DNS alone doesn't fix deliverability (IP reputation/blocklists)
  • Custom Go policy daemon (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 slot
  • Test: protocol parsing, all three decision paths (anonymous/inactive/rate-limited), hot-reload on file change, and one true end-to-end test over a real TCP connection through serve() β€” 8 tests, all passing (policyd/policy_test.go)
  • Independently re-verified in a clean golang:1.22-bookworm container: go build/go vet/gofmt -l/go test ./... -v all green (two files needed a gofmt -w pass, applied)
  • TLS via certbot for SMTP/IMAP (install.sh β€” certbot certonly --standalone, deploy-hook reloads postfix/dovecot)
  • Mailbox provisioning automated β€” 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 var
  • Route mail submission through the mesh where applicable β€” standalone/direct for now, same open item as chat-server/file-share
  • Deprovisioning trigger β€” lib/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)
  • Test: send/receive real mail; verify SPF/DKIM/DMARC pass on a major receiving provider; document deliverability caveats actually observed (needs a real domain + VPS β€” same "needs real infra" caveat as the Phase 1 VPS tests; nothing here has sent real mail yet, and mailbox provisioning has never run against a live Dovecot either)

Phase 6 decision, revised β€” persistent paid mailboxes shelved, temp-mail is the shipped feature (WHITEPAPER Β§10/Β§10.1) β€” CORE IMPLEMENTED AND VERIFIED

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 returned
  • Disabled Postfix submission (587)/smtps (465) for this deployment (postfix/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)
  • Dropped OpenDKIM from 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
  • Website: /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)
  • Test: 20 tests total (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)
  • Verified live, not just unit tests: built the real Linux binary, ran it in Docker with -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
  • Rate-limit /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)
  • Route temp-mail traffic through the mesh (same open item as chat/file-share above)
  • Real-domain test, once a domain exists: create a temp address, receive a real verification email from a major provider (Gmail/Outlook signup flow), confirm it displays correctly and expires/deletes on schedule β€” lower-stakes than the original full-mailbox test since there's no outbound deliverability to verify. Nothing here has received real internet mail yet β€” the live verification above used a synthetic message written directly to disk, not Postfix's real inbound delivery path

Phase 7 β€” Packaging β€” LOCAL COMPOSE STACK WORKING (mail excluded, needs real infra)

  • Docker Compose file (root 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 yet
  • docker/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
  • Real bugs found and fixed bringing this up for the first time, each confirmed not assumed:
    1. Root .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 everything
    2. website/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 stage
    3. Port 3000 conflicted with an already-running dev server on the host β€” not a bug, just needed the compose mapping to point elsewhere temporarily
    4. website/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-share
  • Verified live, not just "containers started": all 3 mesh hosts registered with the broker and show up correctly in GET /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 directory
  • Mail is deliberately not in this compose file β€” it needs a real domain/DNS/TLS to mean anything at all (see mail/README.md); containerizing Postfix/Dovecot for a local-only test would prove nothing real
  • Bare-metal systemd units β€” started for real, against a real VPS: broker.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 had
  • Real bare-metal-only bug found and fixed: attempted to add a second VPN-capable mesh-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 box
  • Single bootstrap entrypoint choosing container vs. bare-metal β€” not started
  • End-to-end fresh-VPS install test, both paths β€” partially done: bare-metal path exercised for real above (bootstrap.sh β†’ wg-keygen.sh β†’ broker/mesh-host systemd units, on a genuinely fresh droplet, several real bugs found and fixed along the way β€” see above); the Docker-compose path on a fresh VPS, and a from-scratch templated systemd generator, are still open
  • Usage docs for this compose stack β€” root README.md (new β€” repo had no top-level README until now)

Phase 8 β€” Hardening & Polish

  • Chat ratchet upgrade (minimal forward-secrecy scheme, WHITEPAPER Β§8)
  • Broker signing-key hardening (offline/HSM + short-lived delegate keys)
  • Circuit diversity now uses real, verified ASN data, not just self-reported Operator (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)
  • Optional WireGuard obfuscation transport for censored/UDP-blocked networks
  • Security review / external audit pass before wider release
  • Refresh the public limitations writeup (WHITEPAPER Β§14) against what actually shipped

Phase 9 β€” Service Unification & Distributed Storage β€” NEW, PLANNED (WHITEPAPER Β§5.1/Β§8/Β§9/Β§10/Β§15)

Architecture decisions locked in WHITEPAPER v0.4; nothing below is implemented yet.

Mesh-routing (closes the open item carried since Phases 4-6):

  • Route chat traffic through an exit/rendezvous host instead of chat-server's direct listener
  • Route file-share traffic through an exit/rendezvous host instead of file-share's direct listener
  • Route mail submission (SMTP) + retrieval (IMAP) through an exit/rendezvous host

Streaming circuits (WHITEPAPER Β§7.2) β€” VERIFIED, prerequisite for onion-mode VPN traffic:

  • Extend the onion circuit protocol from one-shot request/response to a persistent, multi-round-trip session β€” 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.
  • Test: 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.
  • Documented limitation, not silently glossed over: this is a ping-pong stream (each 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.
  • A client that actually tunnels general traffic through this (Android VpnService + TUN capture + a tun2socks-style bridge into CircuitStream) β€” see "Native client scope" below; this item is the backend half only

Chat ratchet (WHITEPAPER Β§8):

  • Symmetric hash-ratchet: HKDF(chain_key) β†’ (next_chain_key, message_key) per message, both directions
  • Periodic X25519 re-exchange (reusing existing ECDH code) for a coarse DH-ratchet/post-compromise-recovery step
  • Test: compromising one derived message key doesn't decrypt earlier/later messages in the same session

Chat rooms + presence (WHITEPAPER Β§8.1) β€” Jabber/Matrix-like properties, extending the existing chat-server, not adopting XMPP/Matrix server software:

  • Room membership: server-side metadata only (set of member pubkeys), no plaintext/content
  • Sender-side fan-out: client encrypts once per room member (reuses existing per-recipient ECDH+AEAD path), server relays each ciphertext to the matching member's queue like a normal 1:1 message
  • Presence: in-memory-only online/offline flag per identity, set/cleared on WebSocket connect/disconnect, no persistence
  • chat-client: room UI (create/invite/send-to-room) and presence indicator
  • Explicitly deferred: multi-device/linked-device support (WHITEPAPER Β§8.1)

Distributed file storage (WHITEPAPER Β§9):

  • Add 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.
  • Client picks N storage-capable hosts from the verified directory, replicates each encrypted chunk to all N (no erasure coding β€” see WHITEPAPER Β§9's reasoning)
  • Upload/download requests to storage-role hosts go through an onion circuit, not a direct connection
  • "Shareable" transfer flag: chunks stay replicated past first download until TTL, so a manifest can be redeemed by more than one recipient
  • Wire storage-node uptime/capacity into the existing contribution-credit tracking (shares implementation with MONETIZATION.md Β§5.5, don't build a second reward path)

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):

  • Confirm scope boundary: mesh-host/connect stays VPN-connection-only; chat/file-share/mail stay web-only β€” do not build a bundled native app
  • Direct-VPN mode needs no custom app at all β€” self-serve QR provisioning, VERIFIED: mesh-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.
  • Dual mode decided: fast direct VPN (above, done) + anonymous onion-circuit VPN, selectable β€” not two skins on the same feature, a real speed-vs-anonymity tradeoff the client should surface explicitly (WHITEPAPER Β§5.1).
  • Onion-mode backend prerequisite (streaming circuits) β€” see above, verified.
  • Onion-mode client itself β€” not started, scope stated honestly: needs (a) a local SOCKS-or-similar bridge backed by 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.
  • Kill switch (block non-tunnel traffic if the tunnel drops)
  • Auto-reconnect
  • Split-tunneling
  • DNS-leak protection

Track B β€” Sustainability (parallel to Track A; see MONETIZATION.md)

  • Decide vehicle: for-profit (user decision, not an engineering call β€” confirmed directly, not assumed)
  • Set up a donation channel (GitHub Sponsors / Open Collective) β€” user decision: not yet, deliberately deferred until there's a public repo/whitepaper to point donors at (matches Β§3's own suggested phasing)
  • Research and apply to relevant grants (NLnet, Open Technology Fund)
  • Decide license: AGPL-3.0 (user decision, confirmed directly β€” matches what MONETIZATION.md/this checklist had been assuming throughout). LICENSE file itself not added yet this round.
  • Decide the paid-tier model: Mullvad-style anonymous account number, no email/password (see CHECKLIST.md Phase 2's tiering note and MONETIZATION.md Β§2.1). Chat/mail/file-share/full mesh are paid-only; a capped free VPN tier needs no account and never loses the no-logs/onion guarantee
  • Accounts + payments implemented: 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
  • Anti-abuse limits for the free-tier /enroll/free path documented as a required deployment step β€” see the VPN host list section above (same fix covers both endpoints)
  • Draft a support/consulting offering (viable once Phase 1-2 are stable)
  • Native client scope decided (WHITEPAPER Β§5.1): the native app is a Mullvad-style VPN connection manager only (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 app
  • Revisit dual licensing / branded hardware once there's real demand, not speculatively upfront

Website Track (parallel, doesn't gate Track A)

  • website/: Next.js scaffold
  • Publish WHITEPAPER.md / this checklist as site content (/whitepaper, /checklist) β€” MONETIZATION.md deliberately stays internal-only (it's the business-strategy scratchpad, not customer-facing copy); /pricing is the public-facing equivalent
  • Public status/progress page (/checklist, which renders this file directly)
  • Account system: anonymous account numbers, no email/password (/account, lib/db.js)
  • Payments: crypto via self-hosted BTCPay Server (recommended) + card via Stripe (optional) (/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 anywhere
  • BROKER_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.
  • Independently re-verified (Claude): 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 crashing
  • Re-checked live, already fixed: website/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
  • Live homepage status panel (Claude, at the user's request): a real visitor counter (/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 environment

Track C β€” Marketing & Growth (see MARKETING.md)

Docs 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).

Commercial (MONETIZATION.md Β§5)

  • Gift/voucher codes: website redemption UI + admin batch-generation view around the existing broker -gen-invite/POST /admin/gen-invite β€” closest to shippable, no new broker work
  • Tiered plans (plan field alongside tier, VPN-only vs. full-suite vs. family/team) β€” touches ClientToken/entitlements in broker, mail/admind, and entitlement.js
  • Annual billing discount β€” business logic only, in entitlement.js + a second Stripe price/BTCPay amount
  • Referral credit β€” referred_by column in accounts.db, a referral-code panel on /account, crediting logic in entitlement.js
  • Revisit the contribution-credit ledger (MONETIZATION.md Β§2.2/Β§5.5) sooner than originally phased β€” the broker heartbeat/uptime data it needs already exists and is tested
  • Developer/API tier β€” rate-limited API keys for /directory//enroll//vpn/provision, monetizes the infra without building more client UX

Marketing (MARKETING.md)

  • Decide the launch-transparency question: keep /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
  • "Show HN" / r/selfhosted / r/privacy launch posts β€” gate on Phase 1's real-VPS test actually passing first, not before
  • Product Hunt launch, timed with the above
  • Blog series from WHITEPAPER.md/STACK.md's own reasoning (onion-circuit mesh, why AES-256-GCM for chat, the threat model's honest limitations) β€” content mostly already drafted internally, needs a public-facing rewrite
  • "Building in public" post using CHECKLIST.md's own real bug list (the four Phase 1 Docker bugs, the mesh-host port-mismatch bug, the NAT/forwarding gap) as source material
  • /compare page or blog post: TintedSand vs. Mullvad/ProtonVPN/NordVPN on the open-source/self-hostable/anonymous-account axis specifically, not feature-count
  • Get listed on privacy-tool directories (privacyguides.org, awesome-selfhosted, awesome-privacy)
  • Public Matrix/Discord + GitHub Discussions once the repo is public
  • Decide the affiliate-marketing question deliberately (MARKETING.md Β§4.5) β€” referral credits are fine, third-party paid affiliate deals are an industry-norm-vs-Mullvad's-principled-refusal tradeoff worth an explicit call, not a default