# DEPLOY — M2 public beta (Cloudflare + two domains) Runbook for moving the current `emission-overhaul` tree onto **M2** (Fedora/x86, i7 / 64 GB / RTX 3080 Ti / 4 TB) and exposing it publicly to a few trusted testers via **Cloudflare Tunnel + Access** on **metatronmind.com** (cube face) and **doc-nor.com** (doc-nor face). One engine, two brands, same origin. Companion files: [`deploy/env.m2-beta.example`](../deploy/env.m2-beta.example), [`deploy/cloudflared/config.yml`](../deploy/cloudflared/config.yml), [`deploy/systemd/`](../deploy/systemd/). Tailnet runbook (the operator's own phone) is separate: [REMOTE-ACCESS.md](REMOTE-ACCESS.md). Security ledger: [SECURITY.md](../SECURITY.md). ## Decisions baked into this runbook (2026-07-12) | Axis | Choice | Why | |---|---|---| | Database | **M2's existing 150k DB** | Clean / no PII by construction (predates episodic + dream). Boot-test gates it (§3). | | Identity | **`REMOTE_ALLOW_ANONYMOUS=1`** behind Cloudflare Access | A few trusted testers; edge allowlist is the gate. No per-tester server code this round. | | Reader | **Local Ollama** (`granite3.1-dense:8b`) | Zero egress — nothing tester-typed leaves the box. | | Brand A/B | **Themed `/m` PWA** | `client-mobile` now carries the doc-nor face (palette + serif type) so each domain looks distinct. | ## The one architectural gotcha The remote-access model was built for **tailnet**, where `tailscale serve` injects `Tailscale-User-Login` and strips client-sent `Tailscale-*`. **Cloudflare does neither.** Two consequences, both handled below: 1. **Identity.** Cloudflare sends `Cf-Access-Authenticated-User-Email`, not the tailscale header → every tester is identity-less → SEC-15 would **403 them**. `REMOTE_ALLOW_ANONYMOUS=1` maps them to the `remote-anonymous` sentinel so they can use chat (no per-tester threads — accepted). 2. **Spoof / owner-takeover.** cloudflared does not strip inbound `Tailscale-*`, and the guard trusts that header for identity. A tester sending `Tailscale-User-Login: ` would resolve to the owner and read the owner's history. **Closed two ways** (belt + braces): the new `TRUST_TAILSCALE_IDENTITY=0` flag makes the header inert server-side, **and** a Cloudflare Transform Rule strips it at the edge (§6). `X-Forwarded-For` (which cloudflared *does* set) still marks requests remote, so the default-deny allowlist keeps admin/reset/ingest/training 403 for the public automatically — that part transfers cleanly. --- ## 1. Pre-flight on the Mac — sync the code M2 last synced 2026-07-04 (`~/m2`), so it's missing the Lance-fragmentation fixes, `server/ingestion/ingest-queue.ts`, X-import, and dream-autorun. rsync the current tree, **preserving M2-local files and never shipping arm64 binaries**: ```bash # from , over the tailnet rsync -av --delete \ --exclude '.git' \ --exclude 'node_modules' \ --exclude '.bridge-venv' \ --exclude 'cubememory.lance' --exclude 'metatron.db*' \ --exclude 'data/' --exclude 'backups/' --exclude 'ingest/' \ --exclude '.env' --exclude 'launch.sh' --exclude 'scripts/setup-linux.sh' \ --exclude 'training/geo_native' \ ./ metatron@..ts.net:~/m2/ ``` The excludes matter: M2's **DB, .env, launch.sh, and its own setup-linux.sh stay put**; node_modules is arm64 here and must be rebuilt native on M2 (§2). ## 2. On M2 — system deps + native rebuild ```bash # Fedora deps (Node 22 recommended, matches the Mac; age for backups; ocrmypdf # for scanned-PDF ingest — optional since the trainer is frozen for the beta) sudo dnf install -y nodejs npm age ocrmypdf curl # Ollama with CUDA (far faster 14b than the Mac's Metal): curl -fsSL https://ollama.com/install.sh | sh ollama pull granite3.1-dense:8b # chat reader (OLLAMA_MODEL) ollama pull qwen2.5:14b # ingest validator (frozen, but keep parity) cd ~/m2 npm install # builds x86_64 native modules fresh npm rebuild better-sqlite3 @lancedb/lancedb # belt-and-suspenders ``` **Embedder note:** embeddings are produced in-process by `@xenova/transformers` (nomic-embed-text-v1.5), which downloads the model from HuggingFace on first boot and caches it under `node_modules/.cache`. M2 ran a prior version, so it's likely already cached — but the box needs outbound internet on first boot if not. ## 3. Database — boot-test M2's 150k DB under current code The go/no-go. Two gates: 1. **`embedding_cache` Float32 schema** — an older DB may predate the Float32 BLOB migration; `server/storage/db.ts` refuses to start on the old TEXT/JSON schema and prints the fix. If it does, run the offline migration (server stopped): `npx tsx tools/embedding-cache-migrate.ts`. 2. **`MATH_VERSION`** — code is at `MATH_VERSION=1`; the restore validator only throws if the snapshot stamps a *different* version. Math primitives are unchanged, so the 150k DB should pass. (A `null` stamp is treated as 1.) Boot it once by hand and watch the log: ```bash cd ~/m2 && npx tsx server/index.ts # expect: init runs, `hasBaseline: true`, dict ~150k, no MATH_VERSION throw, # no db.ts schema refusal. Ctrl-C once it's up (state autosaves; §5 is the # real run). If either gate fails: migrate (gate 1), or fall back to a fresh # init (last resort — testers see a cold engine). ``` ## 4. Build the mobile PWA Testers land on `/m` (the guard 302s remote `/` → `/m/`). Build it so the themed shell exists: ```bash cd ~/m2 && npm run build:mobile # → client-mobile/dist, served same-origin at /m ``` Each domain now renders its face: `doc-nor.com/m/` = warm near-black + oxblood + serif; `metatronmind.com/m/` = the default cube-palette shell. (Verify locally with `?theme=doc-nor` on `npm run dev:mobile`.) ## 5. .env + run as a service Merge [`deploy/env.m2-beta.example`](../deploy/env.m2-beta.example) into `~/m2/.env` (the loopback bind, anonymous identity, `TRUST_TAILSCALE_IDENTITY=0`, local reader, and the freeze flags). Then install the systemd unit ([`deploy/systemd/metatron.service`](../deploy/systemd/metatron.service) — fix `User`/`WorkingDirectory` to M2's paths): ```bash sudo cp deploy/systemd/metatron.service /etc/systemd/system/ sudo systemctl daemon-reload && sudo systemctl enable --now metatron sudo systemctl status metatron # confirm it's listening on localhost:5001 # backups (needs the age key at ~/.config/metatron/backup-age.key): sudo cp deploy/systemd/metatron-backup.{service,timer} /etc/systemd/system/ sudo systemctl daemon-reload && sudo systemctl enable --now metatron-backup.timer ``` Clean restarts go through `curl -X POST http://localhost:5001/api/admin/shutdown` (what `ExecStop` calls) so the maintenance pass + snapshot complete. ## 6. Cloudflare Tunnel + Access ```bash sudo dnf install -y cloudflared # or the official cloudflare repo cloudflared tunnel login # opens a browser; authorizes the zone(s) cloudflared tunnel create metatron # prints + writes the cred json ``` Install [`deploy/cloudflared/config.yml`](../deploy/cloudflared/config.yml) at `/etc/cloudflared/config.yml`, replacing ``. Route DNS for all four hostnames, then run the tunnel as a service: ```bash cloudflared tunnel route dns metatron metatronmind.com cloudflared tunnel route dns metatron www.metatronmind.com cloudflared tunnel route dns metatron doc-nor.com cloudflared tunnel route dns metatron www.doc-nor.com sudo cloudflared service install # runs the tunnel under systemd ``` **In the Cloudflare Zero Trust dashboard:** 1. **Access → Applications → Add → Self-hosted.** Application domain covers all four hostnames (add each). Policy: **Allow**, selector = **Emails** → the tester list (or a small email-domain rule). This is the perimeter — the server treats everyone as anonymous, so Access is what keeps the public out. 2. **Rules → Transform Rules → Modify Request Header → Remove header**: `Tailscale-User-Login` and `Tailscale-User-Name`, scoped to these hostnames. Redundant with `TRUST_TAILSCALE_IDENTITY=0` but defense-in-depth. 3. *(Optional)* **Security → WAF → Rate limiting** — a simple per-IP rule (e.g. 20 req/min on `/api/chat`) fronts SEC-4: the local Ollama/embedder are serialized, so one greedy tester is an accidental chat DoS. Do **not** add ingress for :4174 or :5173 — the operator dashboards stay loopback-only and un-tunneled. ## 7. Verify ```bash # On M2 (loopback), the guard probes — simulate a tester with X-Forwarded-For: BASE=http://localhost:5001 npx tsx tools/verify-remote-access.ts # expect PASS curl -s -H 'X-Forwarded-For: 203.0.113.x' http://localhost:5001/api/admin/shutdown # → 403 # Spoof check (the whole point of TRUST_TAILSCALE_IDENTITY=0): a forged owner # header must NOT read owner history — expect anonymous scope, not owner data: curl -s -H 'X-Forwarded-For: 203.0.113.x' -H 'Tailscale-User-Login: ' \ http://localhost:5001/api/conversations # → empty/anonymous, NOT owner threads ``` From a tester's browser (through Access): open `https://metatronmind.com` and `https://doc-nor.com` → each redirects to `/m/`, shows its face, and chat answers stream. Confirm admin paths 404/403 and the dashboards are unreachable. ## 8. During the window - Engine is **frozen** (`DISABLE_TRAINER_AUTOSTART=1`) so quirks reproduce and the Lance store can't fragment under an unattended write firehose. - Kill-switches: `voice/.anthropic_paused` (paid API), `data/.dream_insight_paused`. Both moot here (dream + reader are off/local), but keep them handy. - Watch `journalctl -u metatron -f` and `GET /api/admin/lance-status` (loopback) for fragment drift. ## 9. Rollback - **Pull the tunnel** (fastest): `sudo systemctl stop cloudflared` — the box drops off the internet instantly; the engine keeps running on loopback. - **Revoke access**: disable the Access application, or `cloudflared tunnel delete metatron`. - **Un-freeze** the engine post-beta: remove the freeze flags from `.env`, restart via the admin shutdown endpoint.