| name | miki-moni-dev-change-pair-flow |
| description | Use when modifying any part of miki-moni's pairing / relay flow โ daemon-side pair handling, worker DurableObjects, phone client bootstrap, QR generation, token registration. Triggers on "ๆน pair", "modify pairing", "relay change", "add pair token field", "pair URL format", "anti-MITM", "pair approval". This area has bitten us repeatedly (chicken-egg, silent-fallback, double-path, MITM); this skill is the regression-prevention checklist. |
Changing the Pair Flow Safely
The pairing handshake spans 6 files across 3 deployment targets that all have to agree. Get one wrong and a clean upgrade ends with users staring at "ws_error". This skill is the do-not-repeat-history checklist.
The 6 files
src/pairing.ts โ pair token generation + QR payload format
src/relay-client.ts โ daemon side: connect to worker, handle pair_offer, persist paired_peers
src/cli/pair.ts โ CLI --new / --rotate / --list / --show / --revoke
worker/src/pairing-coordinator.ts โ DurableObject token registry (register / claim / revoke)
worker/src/daemon-relay.ts โ DurableObject per-daemon relay (acceptPhone, reconnect-sig verify, message routing)
web-phone/relay.ts + web-phone/main-tunnel.tsx โ phone client: parse URL fragment, performPairing, bootstrap effect
If your change touches the protocol (message types, field names, URL fragment params), you almost certainly need to edit โฅ3 of these. Also see worker/src/handshake.ts and src/crypto.ts for primitives.
Mandatory pre-flight checklist
Before pressing edit on any of those files, ask:
- Is this gating something on existing state? (e.g. "only do X if
paired_peers.length > 0")
- โ That's chicken-and-egg. First-time users can't pair. See 0.3.7.
- Is this preferring cached state over explicit user input? (URL hash, CLI flag, env var)
- โ Silent fallback = misleading. URL/explicit input wins. See 0.3.6.
- Am I writing data to disk AFTER ACKing?
- โ Split-brain on disk failure. Persist first, ACK after. See 0.3.8
handlePairOffer.
- Am I appending a path to
worker_url? (${worker_url}/v1/phone etc)
- โ Some configs have
/v1/daemon baked in. Strip before appending. See 0.3.9.
- Am I assuming the relay is honest?
- โ It's only honest-but-curious. Anything the phone learns from
pair_init (e.g. daemon_pubkey) needs out-of-band verification (QR &k=). See 0.3.8 SEC-1.
- Does this introduce a new bearer credential? (token, sig, secret in URL)
- โ Persistent tokens = permanent access if leaked. At minimum, document it. Better: gate behind
MIKI_PAIR_REQUIRE_APPROVAL.
Protocol message types (cheatsheet)
| Direction | Type | Origin | Handled by |
|---|
| daemon โ worker | register_pairing | relay-client.ts | daemon-relay.ts:handleDaemonMessage โ forwards to coordinator |
| worker โ daemon | challenge then ready | daemon-relay.ts:acceptDaemon | relay-client.ts challenge-response |
| daemon โ worker | challenge_response (sig) | relay-client.ts | daemon-relay.ts verify |
| worker โ phone | pair_init (with daemon_pubkey) | daemon-relay.ts:acceptPhone | web-phone/relay.ts:performPairing โ verifies against QR &k= |
| phone โ daemon (via worker) | pair_offer | web-phone/relay.ts | relay-client.ts:handlePairOffer |
| daemon โ phone | pair_ack / pair_nack | relay-client.ts | phone performPairing resolves/rejects |
| both โ | envelope (encrypted Plaintext) | various | relay-protocol.ts:encodeEnvelope / decodeEnvelope |
| daemon โ worker | keepalive (every 50s) | relay-client.ts | daemon-relay.ts no-op ack (defeats CF 100s idle timeout) |
URL fragment format (current)
https://miki-moni.pages.dev/#t=<TOKEN>&r=<WORKER_URL_ENCODED>&k=<DAEMON_PUBKEY_B64_ENCODED>
t = 16-char pair token (Crockford-base32-ish alphabet, rejection-sampled)
r = worker_url (NOT including /v1/daemon path โ strip if present)
k = daemon X25519 encryption pubkey, base64 (anti-MITM, optional for legacy QRs)
Anything new you add โ parsePairFragment in web-phone/main-tunnel.tsx must also parse it, AND pairingQrPayload in src/pairing.ts must emit it.
Required test cycle for any pair-flow change
1. pnpm typecheck (touch all 3 deploy targets โ daemon, worker, phone โ typescript must agree)
2. pnpm vitest run tests/integration-relay.test.ts (full E2E daemon โ mock-worker โ phone)
3. pnpm verify (heavier: starts a mock worker + runs the verify script)
4. Manual Playwright smoke (tools/dev/smoke-pair-038.py as template):
- new format URL works
- old format URL (no &k=) still works (back-compat)
- bad URL (token doesn't exist on worker) fails cleanly
Deploy order matters
If your change is protocol-breaking (e.g. new required field, removed message type):
- Worker first (must accept BOTH old and new formats for transition)
- Daemon second (sends new format, but worker still tolerates old phones)
- Pages last (phone PWA โ once deployed, all users start using new format)
Reverse order = old daemons can't talk to new worker, or new phones can't talk to old daemons.
If your change is protocol-compatible (e.g. add optional field, tighten validation): deploy order doesn't matter, but worker_url-shape changes are protocol-breaking even if they look compatible โ be careful.
Back-compat rules
We support old QRs without &k= (pre-0.3.8 generation) โ phone falls back to trust-on-first-use with console warning. Don't remove this until we're confident no one's still using old QRs.
Same for any future protocol field: assume some phone is running 6-month-old bundle.
Known regression traps (canonical bug list)
| Bug | What broke | Fix file | Version |
|---|
| Chicken-egg relay startup | First pair impossible | src/index.ts:157 removed paired_peers > 0 gate | 0.3.7 |
| localStorage > URL hash | New pair URL silently ignored | web-phone/main-tunnel.tsx reorder | 0.3.6 |
| AskQ Submit shown on Q1/N | Premature submit | web/app.tsx:533 Next/Submit conditional | 0.3.4 |
| Image content blocks dropped | Bubble shows text only | src/session-resolver.ts block.type==="image" handler + web/app.tsx:TurnView | 0.3.4 |
| Permission mode menu cut off | Dropdown off-screen at right edge | web/app.tsx:PermissionModeChip viewport-clamp | 0.3.5 |
| Pair token modulo bias | Skewed entropy | src/pairing.ts + worker/src/pairing-code.ts rejection sampling | 0.3.8 |
| Reconnect-sig 2-min window | Replay possible | worker/src/daemon-relay.ts:verifyReconnectSig current minute only | 0.3.8 |
| Pair-offer ACK before save | Disk failure โ silent peer loss | src/relay-client.ts:handlePairOffer persist-first | 0.3.8 |
cmdNew replaces remote whole | Persistent token wiped | src/cli/pair.ts:cmdNew use {...remote, worker_url} | 0.3.8 |
cmdRotate dueling daemons | Two daemons race for 8765 | src/cli/pair.ts:cmdRotate skip spawn if /admin/restart acked | 0.3.8 |
| Rotate to new relay leaves old token | Old QR usable forever | cmdRotate revokeOldRelayToken | 0.3.8 |
paired_peers unbounded growth | Config bloat (29 entries seen) | src/config.ts:addPairedPeer LRU + stale prune | 0.3.8 |
last_seen_at never written | Can't prune dead peers | src/relay-client.ts:touchPeerSeen | 0.3.8 |
| DNS rebinding | evil.com can POST /admin/* | src/server.ts middleware | 0.3.8 |
| Relay can MITM ECDH | Worker swaps daemon pubkey | QR &k= + phone verify | 0.3.8 |
worker_url w/ /v1/daemon baked in | Phone tries /v1/daemon/v1/phone | Strip in pairingQrPayload + performPairing | 0.3.9 |
If you cause a 13th entry, this skill needs updating.
Related skills
miki-moni-dev:locate-code โ finds files quickly
miki-moni-dev:release-flow โ pair flow change usually needs worker + Pages + npm all 3 deployed; this skill covers ordering