miki-moni 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +283 -0
  3. package/README.zh-CN.md +275 -0
  4. package/README.zh-TW.md +275 -0
  5. package/bin/miki.mjs +49 -0
  6. package/dist/web/assets/favicon-DFpLtP36.svg +13 -0
  7. package/dist/web/assets/index--89DkyV1.css +1 -0
  8. package/dist/web/assets/index-CyPlxvOn.js +64 -0
  9. package/dist/web/index.html +20 -0
  10. package/dist/web/pair-info.html +138 -0
  11. package/dist/web-phone/assets/app-CyQWCdKZ.js +64 -0
  12. package/dist/web-phone/assets/index-D5BUh7Uf.js +1 -0
  13. package/dist/web-phone/assets/index-D8vY_9ld.css +1 -0
  14. package/dist/web-phone/index.html +20 -0
  15. package/hooks/miki-emit.ps1 +56 -0
  16. package/package.json +89 -0
  17. package/shared/i18n.ts +915 -0
  18. package/src/cli/i18n-cli.ts +149 -0
  19. package/src/cli/miki.ts +168 -0
  20. package/src/cli/pair.ts +534 -0
  21. package/src/cli/prompt.ts +6 -0
  22. package/src/cli/pushable-iter.ts +45 -0
  23. package/src/cli/setup-self-host.ts +292 -0
  24. package/src/cli/setup-wizard.ts +130 -0
  25. package/src/cli/wrap.ts +742 -0
  26. package/src/config.ts +121 -0
  27. package/src/crypto.ts +66 -0
  28. package/src/data-dir.ts +31 -0
  29. package/src/ext-registry.ts +47 -0
  30. package/src/hook-handler.ts +86 -0
  31. package/src/index.ts +279 -0
  32. package/src/install-hooks.ts +107 -0
  33. package/src/notifier.ts +21 -0
  34. package/src/pairing.ts +100 -0
  35. package/src/protocol-ext.ts +46 -0
  36. package/src/relay-client.ts +468 -0
  37. package/src/relay-protocol.ts +57 -0
  38. package/src/server.ts +1134 -0
  39. package/src/session-resolver.ts +437 -0
  40. package/src/session-store.ts +131 -0
  41. package/src/types.ts +33 -0
  42. package/src/vscode-bridge.ts +407 -0
  43. package/src/wrap-process.ts +183 -0
  44. package/tools/tray.ps1 +286 -0
  45. package/worker/package.json +24 -0
  46. package/worker/src/daemon-relay.ts +348 -0
  47. package/worker/src/env.ts +11 -0
  48. package/worker/src/handshake.ts +63 -0
  49. package/worker/src/index.ts +81 -0
  50. package/worker/src/pairing-code.ts +39 -0
  51. package/worker/src/pairing-coordinator.ts +145 -0
  52. package/worker/wrangler-selfhost.toml +36 -0
  53. package/worker/wrangler.toml +29 -0
package/tools/tray.ps1 ADDED
@@ -0,0 +1,286 @@
1
+ <#
2
+ miki-moni system-tray helper.
3
+
4
+ Spawned by the daemon (src/index.ts) on Windows. Owns a NotifyIcon in the
5
+ system tray for as long as the daemon process is alive — when the daemon
6
+ PID disappears, this helper exits and the icon vanishes.
7
+
8
+ Args:
9
+ -DaemonPid <int> PID to monitor. When it exits, this script exits too.
10
+ -Port <int> Daemon HTTP port (used by the "Open dashboard" menu).
11
+
12
+ The cat icon is rendered at runtime via GDI+ — same path data as the SVG
13
+ in web/assets/miki-cat.svg, just translated to GraphicsPath calls. No
14
+ external icon file needed, so the daemon stays "single binary"-ish.
15
+ #>
16
+
17
+ param(
18
+ [Parameter(Mandatory = $true)][int]$DaemonPid,
19
+ [Parameter(Mandatory = $false)][int]$Port = 8765
20
+ )
21
+
22
+ $ErrorActionPreference = 'Stop'
23
+
24
+ # Write all output to ~/.miki-moni/tray.log so silent failures can be
25
+ # diagnosed post-mortem. Append so successive spawns don't clobber history.
26
+ $trayLogPath = Join-Path $env:USERPROFILE ".miki-moni\tray.log"
27
+ try {
28
+ $logDir = Split-Path $trayLogPath -Parent
29
+ if (-not (Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir -Force | Out-Null }
30
+ Start-Transcript -Path $trayLogPath -Append -Force | Out-Null
31
+ Write-Output "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] tray.ps1 starting (DaemonPid=$DaemonPid, Port=$Port, PSVersion=$($PSVersionTable.PSVersion))"
32
+ } catch { }
33
+
34
+ Add-Type -AssemblyName System.Windows.Forms
35
+ Add-Type -AssemblyName System.Drawing
36
+
37
+ # ── Render the sleeping-cat icon ────────────────────────────────────────
38
+ # SVG coords are in a 24×24 viewBox; we render to 32×32 (standard tray icon
39
+ # size) and rely on GDI+ antialiasing for the curves. GetHicon() returns an
40
+ # HICON that NotifyIcon can own; we wrap it in System.Drawing.Icon.
41
+ function New-CatIcon {
42
+ $size = 32
43
+ $scale = $size / 24.0
44
+ $bmp = New-Object System.Drawing.Bitmap $size, $size
45
+ $g = [System.Drawing.Graphics]::FromImage($bmp)
46
+ $g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
47
+ $g.CompositingQuality = [System.Drawing.Drawing2D.CompositingQuality]::HighQuality
48
+ $g.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
49
+
50
+ $color = [System.Drawing.Color]::FromArgb(255, 28, 32, 36)
51
+ $pen = New-Object System.Drawing.Pen $color, ([float](1.6 * $scale))
52
+ $pen.StartCap = [System.Drawing.Drawing2D.LineCap]::Round
53
+ $pen.EndCap = [System.Drawing.Drawing2D.LineCap]::Round
54
+ $pen.LineJoin = [System.Drawing.Drawing2D.LineJoin]::Round
55
+
56
+ function P([double]$x, [double]$y) {
57
+ New-Object System.Drawing.PointF ([float]($x * $scale)), ([float]($y * $scale))
58
+ }
59
+
60
+ # ── Body + two ear peaks (one continuous closed path) ────────────────
61
+ $body = New-Object System.Drawing.Drawing2D.GraphicsPath
62
+ # M 4.5,13.2 → L peaks → close with Beziers
63
+ $body.AddLine((P 4.5 13.2), (P 6 10))
64
+ $body.AddLine((P 6 10), (P 7.6 13))
65
+ $body.AddLine((P 7.6 13), (P 9.6 11))
66
+ $body.AddLine((P 9.6 11), (P 11.4 13))
67
+ # C 14.5,13 18.5,13.6 20,15.8
68
+ $body.AddBezier((P 11.4 13), (P 14.5 13), (P 18.5 13.6), (P 20 15.8))
69
+ # C 20.8,17 20.4,18.3 18.6,18.7
70
+ $body.AddBezier((P 20 15.8), (P 20.8 17), (P 20.4 18.3), (P 18.6 18.7))
71
+ # C 16,19.2 7,19.2 5.4,18.4
72
+ $body.AddBezier((P 18.6 18.7), (P 16 19.2), (P 7 19.2), (P 5.4 18.4))
73
+ # C 3.8,17.5 3.6,14.5 4.5,13.2
74
+ $body.AddBezier((P 5.4 18.4), (P 3.8 17.5), (P 3.6 14.5), (P 4.5 13.2))
75
+ $body.CloseFigure()
76
+ $g.DrawPath($pen, $body)
77
+
78
+ # ── Closed sleeping eye: q 0.9,0.7 1.8,0 starting at (6.8, 15.2) ─────
79
+ # SVG quadratic q → endpoint at (6.8+1.8, 15.2+0) = (8.6, 15.2)
80
+ # Convert quadratic Bezier (P0, C, P2) to cubic for GDI+:
81
+ # CP1 = P0 + 2/3 (C - P0); CP2 = P2 + 2/3 (C - P2)
82
+ # P0=(6.8,15.2) C=(7.7,15.9) P2=(8.6,15.2)
83
+ $eyePen = New-Object System.Drawing.Pen $color, ([float](1.3 * $scale))
84
+ $eyePen.StartCap = [System.Drawing.Drawing2D.LineCap]::Round
85
+ $eyePen.EndCap = [System.Drawing.Drawing2D.LineCap]::Round
86
+ $eye = New-Object System.Drawing.Drawing2D.GraphicsPath
87
+ # CP1 = (6.8 + 2/3*(7.7-6.8), 15.2 + 2/3*(15.9-15.2)) = (7.4, 15.667)
88
+ # CP2 = (8.6 + 2/3*(7.7-8.6), 15.2 + 2/3*(15.9-15.2)) = (8.0, 15.667)
89
+ $eye.AddBezier((P 6.8 15.2), (P 7.4 15.667), (P 8.0 15.667), (P 8.6 15.2))
90
+ $g.DrawPath($eyePen, $eye)
91
+
92
+ # ── Two Z's drifting up: each is "h L h" — three line segments ──────
93
+ $zPen1 = New-Object System.Drawing.Pen $color, ([float](1.3 * $scale))
94
+ $zPen1.StartCap = [System.Drawing.Drawing2D.LineCap]::Round
95
+ $zPen1.EndCap = [System.Drawing.Drawing2D.LineCap]::Round
96
+ $zPen1.LineJoin = [System.Drawing.Drawing2D.LineJoin]::Round
97
+ $z1 = New-Object System.Drawing.Drawing2D.GraphicsPath
98
+ # M 15,7 h 2.4 l -2.4,2.6 h 2.4 → (15,7)→(17.4,7)→(15,9.6)→(17.4,9.6)
99
+ $z1.AddLine((P 15 7), (P 17.4 7))
100
+ $z1.AddLine((P 17.4 7), (P 15 9.6))
101
+ $z1.AddLine((P 15 9.6), (P 17.4 9.6))
102
+ $g.DrawPath($zPen1, $z1)
103
+
104
+ $zPen2 = New-Object System.Drawing.Pen $color, ([float](1.1 * $scale))
105
+ $zPen2.StartCap = [System.Drawing.Drawing2D.LineCap]::Round
106
+ $zPen2.EndCap = [System.Drawing.Drawing2D.LineCap]::Round
107
+ $zPen2.LineJoin = [System.Drawing.Drawing2D.LineJoin]::Round
108
+ $z2 = New-Object System.Drawing.Drawing2D.GraphicsPath
109
+ # M 18.4,4.5 h 1.6 l -1.6,1.8 h 1.6 → (18.4,4.5)→(20,4.5)→(18.4,6.3)→(20,6.3)
110
+ $z2.AddLine((P 18.4 4.5), (P 20 4.5))
111
+ $z2.AddLine((P 20 4.5), (P 18.4 6.3))
112
+ $z2.AddLine((P 18.4 6.3), (P 20 6.3))
113
+ $g.DrawPath($zPen2, $z2)
114
+
115
+ $g.Dispose()
116
+ $hicon = $bmp.GetHicon()
117
+ $icon = [System.Drawing.Icon]::FromHandle($hicon)
118
+ return @{ Icon = $icon; Bitmap = $bmp; Handle = $hicon }
119
+ }
120
+
121
+ # ── Build the NotifyIcon ────────────────────────────────────────────────
122
+ $catIcon = New-CatIcon
123
+ $notify = New-Object System.Windows.Forms.NotifyIcon
124
+ $notify.Icon = $catIcon.Icon
125
+ $notify.Text = "miki-moni · running · pid $DaemonPid"
126
+ $notify.Visible = $true
127
+
128
+ $menu = New-Object System.Windows.Forms.ContextMenuStrip
129
+ $openItem = $menu.Items.Add("Open dashboard")
130
+ $pairItem = $menu.Items.Add("Show pairing QR")
131
+ $rotateItem = $menu.Items.Add("Rotate pairing token (new QR / password)")
132
+ $restartItem = $menu.Items.Add("Restart daemon")
133
+ $null = $menu.Items.Add("-") # separator
134
+ $quitItem = $menu.Items.Add("Quit daemon")
135
+
136
+ $openItem.Add_Click({
137
+ Start-Process "http://127.0.0.1:$Port" | Out-Null
138
+ })
139
+
140
+ # Read pair_token + worker_url + phone_pwa_url from config.json and open the
141
+ # static pair-info.html page that the daemon serves out of dist/web/. The page
142
+ # renders the QR in-browser, no server endpoint needed.
143
+ $pairItem.Add_Click({
144
+ try {
145
+ $cfgPath = Join-Path $env:USERPROFILE ".miki-moni\config.json"
146
+ if (-not (Test-Path $cfgPath)) {
147
+ [System.Windows.Forms.MessageBox]::Show(
148
+ "config.json not found at $cfgPath. Run ``miki setup`` first.",
149
+ "miki-moni", "OK", "Warning") | Out-Null
150
+ return
151
+ }
152
+ # PS 5.1 defaults to system ANSI codepage; force UTF-8 or Chinese device
153
+ # names (and any non-ASCII path) break ConvertFrom-Json with a cryptic
154
+ # "傳入了無效的物件,必須有 ':' 或 '}'" error.
155
+ $cfg = Get-Content -Raw -Encoding UTF8 $cfgPath | ConvertFrom-Json
156
+ $token = $cfg.remote.pair_token
157
+ $relay = $cfg.remote.worker_url
158
+ $pwa = if ($cfg.remote.phone_pwa_url) { $cfg.remote.phone_pwa_url } else { "https://miki-moni.pages.dev/" }
159
+ if (-not $token -or -not $relay) {
160
+ [System.Windows.Forms.MessageBox]::Show(
161
+ "No pair_token or worker_url in config. Run ``miki pair`` to create one.",
162
+ "miki-moni", "OK", "Warning") | Out-Null
163
+ return
164
+ }
165
+ Add-Type -AssemblyName System.Web
166
+ $q = "t=" + [System.Web.HttpUtility]::UrlEncode($token) +
167
+ "&r=" + [System.Web.HttpUtility]::UrlEncode($relay) +
168
+ "&pwa=" + [System.Web.HttpUtility]::UrlEncode($pwa)
169
+ Start-Process "http://127.0.0.1:$Port/pair-info.html?$q" | Out-Null
170
+ } catch {
171
+ [System.Windows.Forms.MessageBox]::Show(
172
+ "Could not open pairing page: $_",
173
+ "miki-moni", "OK", "Error") | Out-Null
174
+ }
175
+ })
176
+
177
+ $rotateItem.Add_Click({
178
+ # Confirm before rotating — old QR / URL / 16-char code immediately invalid.
179
+ # Already-paired phones still work (signing key, not token, re-auths them).
180
+ $confirm = [System.Windows.Forms.MessageBox]::Show(
181
+ "Generate a new pairing token? The current QR / URL / 16-char code will stop working immediately. Already-paired phones are unaffected.",
182
+ "miki-moni · rotate pairing token",
183
+ "OKCancel", "Question")
184
+ if ($confirm -ne "OK") { return }
185
+ try {
186
+ $r = Invoke-RestMethod -Uri "http://127.0.0.1:$Port/admin/rotate-pair" `
187
+ -Method Post -TimeoutSec 5 -ContentType "application/json"
188
+ $token = $r.token
189
+ $relay = $r.worker_url
190
+ $pwa = if ($r.phone_pwa_url) { $r.phone_pwa_url } else { "https://miki-moni.pages.dev/" }
191
+ Add-Type -AssemblyName System.Web
192
+ $q = "t=" + [System.Web.HttpUtility]::UrlEncode($token) +
193
+ "&r=" + [System.Web.HttpUtility]::UrlEncode($relay) +
194
+ "&pwa=" + [System.Web.HttpUtility]::UrlEncode($pwa)
195
+ # Daemon restarts itself after rotate so RelayClient re-registers the new
196
+ # token. Give it ~2s to come back up, then open the new QR page.
197
+ Start-Sleep -Seconds 2
198
+ Start-Process "http://127.0.0.1:$Port/pair-info.html?$q" | Out-Null
199
+ } catch {
200
+ [System.Windows.Forms.MessageBox]::Show(
201
+ "Rotate failed: $_",
202
+ "miki-moni", "OK", "Error") | Out-Null
203
+ }
204
+ })
205
+
206
+ $restartItem.Add_Click({
207
+ # POST /admin/restart so the daemon can decide how to relaunch itself.
208
+ # Best-effort: fire and forget; if the endpoint doesn't exist we just
209
+ # exit and rely on `miki claude` to respawn on next use.
210
+ try {
211
+ Invoke-WebRequest -Uri "http://127.0.0.1:$Port/admin/restart" `
212
+ -Method Post -UseBasicParsing -TimeoutSec 2 | Out-Null
213
+ } catch { }
214
+ })
215
+
216
+ $quitItem.Add_Click({
217
+ try {
218
+ Invoke-WebRequest -Uri "http://127.0.0.1:$Port/admin/quit" `
219
+ -Method Post -UseBasicParsing -TimeoutSec 2 | Out-Null
220
+ } catch { }
221
+ # If the daemon ignored /admin/quit, fall back to killing the PID directly.
222
+ # Use the latest watched pid (in case daemon respawned since tray launch).
223
+ Start-Sleep -Milliseconds 800
224
+ $pidToKill = if ($script:DaemonPidWatched) { $script:DaemonPidWatched } else { $DaemonPid }
225
+ if (Get-Process -Id $pidToKill -ErrorAction SilentlyContinue) {
226
+ Stop-Process -Id $pidToKill -Force -ErrorAction SilentlyContinue
227
+ }
228
+ })
229
+
230
+ # Single-click also opens the dashboard — matches most tray-app conventions.
231
+ $notify.Add_MouseClick({
232
+ param($sender, $e)
233
+ if ($e.Button -eq [System.Windows.Forms.MouseButtons]::Left) {
234
+ Start-Process "http://127.0.0.1:$Port" | Out-Null
235
+ }
236
+ })
237
+ $notify.ContextMenuStrip = $menu
238
+
239
+ # ── PID watcher ─────────────────────────────────────────────────────────
240
+ # Poll the watched PID once per second. When it dies, give a grace window
241
+ # for a replacement daemon (rotate / restart respawns) to come up — query
242
+ # /admin/pid and if a NEW daemon is alive on the same port, follow it.
243
+ # Only exit if the daemon stays gone for `$maxGraceTicks` consecutive polls.
244
+ # A WinForms Timer fires on the UI thread so we can touch $notify safely.
245
+ $script:DaemonPidWatched = $DaemonPid
246
+ $script:GraceTicks = 0
247
+ $script:MaxGraceTicks = 10 # ~10s window for respawn
248
+ $timer = New-Object System.Windows.Forms.Timer
249
+ $timer.Interval = 1000
250
+ $timer.Add_Tick({
251
+ if (Get-Process -Id $script:DaemonPidWatched -ErrorAction SilentlyContinue) {
252
+ $script:GraceTicks = 0
253
+ return
254
+ }
255
+ # Watched pid is gone — see if a replacement daemon answered /admin/pid.
256
+ try {
257
+ $r = Invoke-RestMethod -Uri "http://127.0.0.1:$Port/admin/pid" -TimeoutSec 1
258
+ if ($r.pid -and $r.pid -ne $script:DaemonPidWatched) {
259
+ $script:DaemonPidWatched = [int]$r.pid
260
+ $notify.Text = "miki-moni · running · pid $($script:DaemonPidWatched)"
261
+ $script:GraceTicks = 0
262
+ return
263
+ }
264
+ } catch {
265
+ # daemon unreachable — keep counting toward grace deadline
266
+ }
267
+ $script:GraceTicks++
268
+ if ($script:GraceTicks -ge $script:MaxGraceTicks) {
269
+ $timer.Stop()
270
+ $notify.Visible = $false
271
+ $notify.Dispose()
272
+ [System.Windows.Forms.Application]::Exit()
273
+ }
274
+ })
275
+ $timer.Start()
276
+
277
+ # Run the WinForms message pump. Application.Exit() above breaks out cleanly.
278
+ try {
279
+ [System.Windows.Forms.Application]::Run()
280
+ }
281
+ finally {
282
+ $timer.Stop()
283
+ if ($notify) { $notify.Visible = $false; $notify.Dispose() }
284
+ if ($catIcon.Icon) { $catIcon.Icon.Dispose() }
285
+ if ($catIcon.Bitmap) { $catIcon.Bitmap.Dispose() }
286
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "miki-relay",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "description": "Cloudflare Worker + Durable Objects for miki-moni E2E relay",
6
+ "scripts": {
7
+ "dev": "wrangler dev",
8
+ "deploy": "wrangler deploy",
9
+ "test": "vitest run",
10
+ "test:watch": "vitest",
11
+ "typecheck": "tsc --noEmit"
12
+ },
13
+ "dependencies": {
14
+ "tweetnacl": "^1.0.3",
15
+ "tweetnacl-util": "^0.15.1"
16
+ },
17
+ "devDependencies": {
18
+ "@cloudflare/vitest-pool-workers": "^0.7.0",
19
+ "@cloudflare/workers-types": "^4.20250515.0",
20
+ "typescript": "^5.6.2",
21
+ "vitest": "^2.0.5",
22
+ "wrangler": "^3.85.0"
23
+ }
24
+ }
@@ -0,0 +1,348 @@
1
+ import type { Env } from "./env.js";
2
+ import {
3
+ generateChallenge,
4
+ buildChallengeMessage,
5
+ toBase64,
6
+ fromBase64,
7
+ deriveDaemonId,
8
+ CHALLENGE_TTL_MS,
9
+ type Challenge,
10
+ } from "./handshake.js";
11
+ import nacl from "tweetnacl";
12
+
13
+ interface DaemonAttachment {
14
+ role: "daemon";
15
+ pubkey_b64: string; // Ed25519 signing pubkey (challenge-response + daemon_id derivation)
16
+ enc_pubkey_b64?: string; // X25519 encryption pubkey (sent to phones for ECDH in pair_init)
17
+ challenge?: Challenge;
18
+ authed: boolean;
19
+ daemon_id: string;
20
+ }
21
+
22
+ interface PhoneAttachment {
23
+ role: "phone";
24
+ phone_id: string; // signing pubkey b64 (reconnect-mode auth + revoke_self routing)
25
+ peer_id?: string; // computePeerId(encryption_pubkey) — addresses daemon→phone envelopes
26
+ pairing_token?: string;
27
+ authed: boolean;
28
+ }
29
+
30
+ type Attachment = DaemonAttachment | PhoneAttachment;
31
+
32
+ export class DaemonRelay implements DurableObject {
33
+ constructor(private state: DurableObjectState, private env: Env) {}
34
+
35
+ async fetch(req: Request): Promise<Response> {
36
+ const url = new URL(req.url);
37
+ if (url.pathname.endsWith("/v1/daemon")) return this.acceptDaemon(req);
38
+ if (url.pathname.endsWith("/v1/phone")) return this.acceptPhone(req);
39
+ return new Response("not_found", { status: 404 });
40
+ }
41
+
42
+ private async acceptDaemon(req: Request): Promise<Response> {
43
+ const pubkey_b64 = req.headers.get("X-Daemon-Pubkey");
44
+ if (!pubkey_b64) return new Response("missing X-Daemon-Pubkey", { status: 400 });
45
+ let pubkey: Uint8Array;
46
+ try {
47
+ pubkey = fromBase64(pubkey_b64);
48
+ if (pubkey.length !== 32) throw new Error("bad length");
49
+ } catch {
50
+ return new Response("bad X-Daemon-Pubkey", { status: 400 });
51
+ }
52
+ // X-Daemon-Enc-Pubkey: daemon's X25519 encryption pubkey. Required for
53
+ // phones to derive a working shared secret in pair_offer — without this
54
+ // they'd ECDH against the signing key (different curve) and get garbage.
55
+ const enc_pubkey_b64 = req.headers.get("X-Daemon-Enc-Pubkey") ?? undefined;
56
+ const daemon_id = this.state.id.name ?? await deriveDaemonId(pubkey);
57
+
58
+ const pair = new WebSocketPair();
59
+ const [client, server] = Object.values(pair) as [WebSocket, WebSocket];
60
+
61
+ const challenge = generateChallenge();
62
+ const att: DaemonAttachment = {
63
+ role: "daemon", pubkey_b64, enc_pubkey_b64,
64
+ challenge, authed: false, daemon_id,
65
+ };
66
+
67
+ this.state.acceptWebSocket(server, ["daemon"]);
68
+ server.serializeAttachment(att);
69
+
70
+ server.send(JSON.stringify({
71
+ type: "challenge",
72
+ nonce: toBase64(challenge.nonce),
73
+ issued_at_ms: challenge.issued_at_ms,
74
+ }));
75
+
76
+ return new Response(null, { status: 101, webSocket: client });
77
+ }
78
+
79
+ private async acceptPhone(req: Request): Promise<Response> {
80
+ const url = new URL(req.url);
81
+ const pairing_token = req.headers.get("X-Pairing-Token") ?? url.searchParams.get("token");
82
+ const phone_pubkey_hdr = req.headers.get("X-Phone-Pubkey") ?? url.searchParams.get("phone_pubkey");
83
+ const sig_hdr = req.headers.get("X-Sig") ?? url.searchParams.get("sig");
84
+
85
+ const pair = new WebSocketPair();
86
+ const [client, server] = Object.values(pair) as [WebSocket, WebSocket];
87
+
88
+ if (!pairing_token && !phone_pubkey_hdr) {
89
+ server.close(4000, "missing_pairing_token_or_phone_pubkey");
90
+ return new Response(null, { status: 101, webSocket: client });
91
+ }
92
+
93
+ if (pairing_token) {
94
+ const phone_id = pairing_token;
95
+ const att: PhoneAttachment = { role: "phone", phone_id, pairing_token, authed: false };
96
+ this.state.acceptWebSocket(server, ["phone", phone_id]);
97
+ server.serializeAttachment(att);
98
+
99
+ // Prefer the X25519 encryption pubkey (post-fix daemons). Older daemons
100
+ // only stored the signing pubkey — phones paired against those have
101
+ // broken shared secrets and must re-pair after the daemon upgrades.
102
+ const daemonEncPubkey = await this.state.storage.get<string>("daemon_enc_pubkey_b64");
103
+ const daemonPubkey = daemonEncPubkey ?? await this.state.storage.get<string>("daemon_pubkey_b64");
104
+ const pending = await this.state.storage.get<{ token: string; expires_at_ms: number }>("pending_pair");
105
+ if (!daemonPubkey || !pending || pending.token !== pairing_token || Date.now() > pending.expires_at_ms) {
106
+ server.close(4002, "pairing_token_invalid");
107
+ return new Response(null, { status: 101, webSocket: client });
108
+ }
109
+
110
+ server.send(JSON.stringify({ type: "pair_init", daemon_pubkey: daemonPubkey }));
111
+ return new Response(null, { status: 101, webSocket: client });
112
+ }
113
+
114
+ // Reconnect mode (sig verification)
115
+ if (!phone_pubkey_hdr || !sig_hdr) {
116
+ return new Response("missing phone_pubkey or sig", { status: 400 });
117
+ }
118
+ const phone_id = phone_pubkey_hdr;
119
+ const att: PhoneAttachment = { role: "phone", phone_id, authed: false };
120
+ this.state.acceptWebSocket(server, ["phone", phone_id]);
121
+ server.serializeAttachment(att);
122
+ const paired = await this.state.storage.get<Record<string, string>>("paired_phones");
123
+ if (!paired || !paired[phone_pubkey_hdr]) {
124
+ server.close(4001, "unknown_phone");
125
+ return new Response(null, { status: 101, webSocket: client });
126
+ }
127
+ if (!this.verifyReconnectSig(phone_pubkey_hdr, sig_hdr)) {
128
+ server.close(4001, "bad_sig");
129
+ return new Response(null, { status: 101, webSocket: client });
130
+ }
131
+ att.authed = true;
132
+ server.serializeAttachment(att);
133
+ server.send(JSON.stringify({ type: "ready" }));
134
+ return new Response(null, { status: 101, webSocket: client });
135
+ }
136
+
137
+ private verifyReconnectSig(phone_pubkey_b64: string, sig_b64: string): boolean {
138
+ try {
139
+ const pubkey = fromBase64(phone_pubkey_b64);
140
+ const sig = fromBase64(sig_b64);
141
+ const daemon_id = this.state.id.name!;
142
+ const daemonIdBytes = new TextEncoder().encode(daemon_id);
143
+ const nowMinute = Math.floor(Date.now() / 60_000);
144
+ for (const m of [nowMinute, nowMinute - 1]) {
145
+ const msg = new Uint8Array(daemonIdBytes.length + 8);
146
+ msg.set(daemonIdBytes, 0);
147
+ new DataView(msg.buffer, daemonIdBytes.length, 8).setBigUint64(0, BigInt(m), false);
148
+ if (nacl.sign.detached.verify(msg, sig, pubkey)) return true;
149
+ }
150
+ return false;
151
+ } catch {
152
+ return false;
153
+ }
154
+ }
155
+
156
+ // ── Hibernating WebSocket handlers ────────────────────────────────────────
157
+
158
+ async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
159
+ const att = ws.deserializeAttachment() as Attachment | undefined;
160
+ if (!att) { ws.close(1011, "no_attachment"); return; }
161
+ const text = typeof message === "string" ? message : new TextDecoder().decode(message);
162
+ let msg: any;
163
+ try { msg = JSON.parse(text); } catch { ws.close(1008, "bad_json"); return; }
164
+
165
+ if (att.role === "daemon") return this.handleDaemonMessage(ws, att, msg);
166
+ return this.handlePhoneMessage(ws, att, msg);
167
+ }
168
+
169
+ private async handleDaemonMessage(ws: WebSocket, att: DaemonAttachment, msg: any): Promise<void> {
170
+ if (!att.authed) {
171
+ if (msg.type !== "challenge_response") { ws.close(1008, "expected_challenge_response"); return; }
172
+ const pubkey = fromBase64(att.pubkey_b64);
173
+ const sig = fromBase64(msg.sig);
174
+ const ch = att.challenge!;
175
+ const sigMsg = buildChallengeMessage(ch.nonce, ch.issued_at_ms);
176
+ if (Date.now() > ch.issued_at_ms + CHALLENGE_TTL_MS) { ws.close(4001, "challenge_expired"); return; }
177
+ if (!nacl.sign.detached.verify(sigMsg, sig, pubkey)) { ws.close(4001, "bad_sig"); return; }
178
+ att.authed = true;
179
+ att.challenge = undefined;
180
+ ws.serializeAttachment(att);
181
+ await this.state.storage.put("daemon_pubkey_b64", att.pubkey_b64);
182
+ // Store the X25519 encryption pubkey separately so pair_init returns the
183
+ // right key for phone-side ECDH. Older daemons that don't send the
184
+ // header → field stays undefined and we fall back in acceptPhone.
185
+ if (att.enc_pubkey_b64) {
186
+ await this.state.storage.put("daemon_enc_pubkey_b64", att.enc_pubkey_b64);
187
+ }
188
+ ws.send(JSON.stringify({ type: "ready", daemon_id: att.daemon_id }));
189
+ return;
190
+ }
191
+
192
+ if (msg.type === "register_pairing") {
193
+ const token = String(msg.token ?? "");
194
+ // `persistent: true` makes the token survive both TTL sweeps and claims,
195
+ // so the QR can be permanent until the user rotates. DO storage gets the
196
+ // same flag for its own consistency check in acceptPhone().
197
+ const persistent = msg.persistent === true;
198
+ const expires_at_ms = persistent ? Number.MAX_SAFE_INTEGER : Date.now() + 10 * 60 * 1000;
199
+ await this.state.storage.put("pending_pair", { token, expires_at_ms, persistent });
200
+ if (this.env.PAIRING) {
201
+ const coordId = this.env.PAIRING.idFromName("coordinator");
202
+ const coordStub = this.env.PAIRING.get(coordId);
203
+ await coordStub.fetch("https://x/register", {
204
+ method: "POST",
205
+ body: JSON.stringify({ token, daemon_id: att.daemon_id, persistent }),
206
+ headers: { "content-type": "application/json" },
207
+ });
208
+ }
209
+ return;
210
+ }
211
+
212
+ if (msg.type === "pair_ack") {
213
+ for (const phone of this.state.getWebSockets("phone")) {
214
+ const p = phone.deserializeAttachment() as PhoneAttachment;
215
+ if (p && p.pairing_token) {
216
+ // Forward the WHOLE message so daemon_id flows through to phone.
217
+ phone.send(JSON.stringify(msg));
218
+ p.authed = true;
219
+ p.pairing_token = undefined;
220
+ phone.serializeAttachment(p);
221
+ }
222
+ }
223
+ // Persistent tokens are kept — same QR can pair the next device.
224
+ // Ephemeral tokens are consumed once.
225
+ const pending = await this.state.storage.get<{ token: string; expires_at_ms: number; persistent?: boolean }>("pending_pair");
226
+ if (!pending?.persistent) {
227
+ await this.state.storage.delete("pending_pair");
228
+ }
229
+ return;
230
+ }
231
+
232
+ if (msg.type === "revoke_phone") {
233
+ // Daemon kicks a previously-paired phone: drop from paired_phones map and
234
+ // close any live WS for that phone.
235
+ const signPk = String(msg.phone_pubkey_b64 ?? "");
236
+ if (signPk) {
237
+ const paired = (await this.state.storage.get<Record<string, string>>("paired_phones")) ?? {};
238
+ if (paired[signPk]) {
239
+ delete paired[signPk];
240
+ await this.state.storage.put("paired_phones", paired);
241
+ }
242
+ for (const ph of this.state.getWebSockets("phone")) {
243
+ const p = ph.deserializeAttachment() as PhoneAttachment | undefined;
244
+ if (p && p.phone_id === signPk) {
245
+ try { ph.send(JSON.stringify({ type: "phone_revoked", by: "daemon" })); } catch { /* */ }
246
+ try { ph.close(4003, "revoked"); } catch { /* */ }
247
+ }
248
+ }
249
+ }
250
+ return;
251
+ }
252
+
253
+ // Envelope or other daemon-originated message. Route by `to` field if it
254
+ // identifies a specific phone; otherwise broadcast to all authed phones.
255
+ // to: "phone:<peer_id>" → only the matching phone (avoids decryption noise
256
+ // on other paired phones that share this DO)
257
+ // to: anything else / absent → broadcast (back-compat for unaddressed sends)
258
+ const target = typeof msg?.to === "string" ? msg.to : "";
259
+ const peerMatch = /^phone:(.+)$/.exec(target);
260
+ if (peerMatch) {
261
+ const targetPeerId = peerMatch[1];
262
+ for (const phone of this.state.getWebSockets("phone")) {
263
+ const p = phone.deserializeAttachment() as PhoneAttachment;
264
+ if (p && p.authed && p.peer_id === targetPeerId) {
265
+ phone.send(JSON.stringify(msg));
266
+ }
267
+ }
268
+ return;
269
+ }
270
+ for (const phone of this.state.getWebSockets("phone")) {
271
+ const p = phone.deserializeAttachment() as PhoneAttachment;
272
+ if (p && p.authed) phone.send(JSON.stringify(msg));
273
+ }
274
+ }
275
+
276
+ private async handlePhoneMessage(ws: WebSocket, att: PhoneAttachment, msg: any): Promise<void> {
277
+ if (!att.authed) {
278
+ if (msg.type === "pair_offer" && att.pairing_token) {
279
+ const signPk = String(msg.phone_sign_pubkey ?? msg.phone_pubkey ?? "");
280
+ if (signPk) {
281
+ const paired = (await this.state.storage.get<Record<string, string>>("paired_phones")) ?? {};
282
+ paired[signPk] = String(Date.now());
283
+ await this.state.storage.put("paired_phones", paired);
284
+ // Re-key attachment to the signing pubkey so a later revoke_self on
285
+ // this same socket can identify itself without a disconnect+reconnect.
286
+ att.phone_id = signPk;
287
+ ws.serializeAttachment(att);
288
+ }
289
+ this.broadcastToDaemons(msg);
290
+ return;
291
+ }
292
+ ws.close(1008, "expected_pair_offer");
293
+ return;
294
+ }
295
+
296
+ if (msg.type === "register_peer_id") {
297
+ // Phone tells the relay its addressable peer_id so daemon→phone envelopes
298
+ // addressed `to: "phone:<peer_id>"` can be routed precisely instead of
299
+ // broadcast-and-discard-noise on every other paired phone.
300
+ const peer_id = String(msg.peer_id ?? "");
301
+ if (peer_id) {
302
+ att.peer_id = peer_id;
303
+ ws.serializeAttachment(att);
304
+ }
305
+ return;
306
+ }
307
+
308
+ if (msg.type === "revoke_self") {
309
+ // Phone wants to unpair. att.phone_id IS the signing pubkey in reconnect mode.
310
+ const signPk = att.phone_id;
311
+ if (signPk) {
312
+ const paired = (await this.state.storage.get<Record<string, string>>("paired_phones")) ?? {};
313
+ if (paired[signPk]) {
314
+ delete paired[signPk];
315
+ await this.state.storage.put("paired_phones", paired);
316
+ }
317
+ // Tell daemon to clean up its local config too.
318
+ this.broadcastToDaemons({ type: "phone_revoked", phone_pubkey_b64: signPk });
319
+ }
320
+ try { ws.send(JSON.stringify({ type: "revoked_ok" })); } catch { /* */ }
321
+ ws.close(1000, "revoked");
322
+ return;
323
+ }
324
+
325
+ this.broadcastToDaemons(msg);
326
+ }
327
+
328
+ /** Multiple daemon WSes can accumulate (e.g. pair CLI restarts before the prior WS is GC'd).
329
+ * Broadcast to all — only the live process replies. Dead sockets either no-op or get cleaned up
330
+ * in webSocketClose. */
331
+ /** Broadcast to every daemon WS in the tag. Stale sockets from crashed CLI
332
+ * sessions can accumulate; sending to a dead one throws and is ignored. The
333
+ * live daemon(s) — typically one — receive and respond. */
334
+ private broadcastToDaemons(msg: any): void {
335
+ const payload = JSON.stringify(msg);
336
+ for (const d of this.state.getWebSockets("daemon")) {
337
+ try { d.send(payload); } catch { /* dead socket; CF GCs on close */ }
338
+ }
339
+ }
340
+
341
+ async webSocketClose(_ws: WebSocket, _code: number, _reason: string, _wasClean: boolean): Promise<void> {}
342
+ async webSocketError(_ws: WebSocket, _err: unknown): Promise<void> {}
343
+
344
+ private daemonWs(): WebSocket | null {
345
+ const arr = this.state.getWebSockets("daemon");
346
+ return arr[0] ?? null;
347
+ }
348
+ }
@@ -0,0 +1,11 @@
1
+ // Bindings exposed by wrangler.toml to the Worker runtime.
2
+ export interface Env {
3
+ PAIRING: DurableObjectNamespace;
4
+ RELAY: DurableObjectNamespace;
5
+ RATE_LIMITER: RateLimit;
6
+ }
7
+
8
+ // CF rate-limit binding (not in workers-types yet).
9
+ export interface RateLimit {
10
+ limit(opts: { key: string }): Promise<{ success: boolean }>;
11
+ }