# Relay nhiều máy qua Cloudflare

> A browser picks one of several online machines; each machine has a Durable Object as meeting point and its token never leaves it.

# Multi-Device Dashboard Relay (dash.kaiitech.net chọn nhiều PC)

## Mục đích
Cho phép **nhiều PC** cùng online và người dùng mở `dash.kaiitech.net`, đăng nhập Google,
thấy **danh sách device thuộc org của mình**, chọn 1 máy → điều khiển đúng **web dashboard sẵn có**
của máy đó (HTTP + WebSocket). Thay cho mô hình cũ: `dash.kaiitech.net` chỉ trỏ 1 PC qua 1
`cloudflare-tunnel-runner` tunnel.

Kiến trúc: **central relay broker** trên Cloudflare Workers + Durable Objects (DO), đặt trong `fleet-api`.
Plan đầy đủ 6 phase: `plans/` (serene-hopping-glade) — file này mô tả trạng thái đã build.

## Kiến trúc
```
Browser (dash.kaiitech.net)   CF Worker fleet-api            PC KazuoAI
 /d/<id>/api (fetch) ─┐                                       dashboard-server.ts (loopback)
 /d/<id>/ws  (WS)    ─┼─► RelayDeviceDO(<deviceId>) ◄══ 1 WSS bền ══► relay-agent.ts
                                (agent tunnel)                 tự đính dashboardToken → local-fetch
```
- **1 DO / deviceId** (`env.RELAY.idFromName(deviceId)`): giữ 1 agent-WS + N browser-session, route theo `rid`/`sid`.
- `dashboardToken` của máy **không rời PC**: relay-agent tự đính `Authorization: Bearer <dashboardToken>` phía loopback.
  Browser định danh bằng **appToken** (verify + ownership tại Worker TRƯỚC khi vào DO).
- Agent mở local `/ws` với `Origin === Host` (`http://127.0.0.1:<port>`) để qua `verifyClient` của dashboard-server.

## Frame protocol (`fleet-api/src/relay/frames.ts` + bản copy `electron/relay-frames.ts` — GIỮ ĐỒNG BỘ)
HTTP + WS gói trên 1 agent-WebSocket:
- HTTP request: `{t:'req', rid, method, path, headers, body?}` (request body inline base64 — nhỏ).
- HTTP response **STREAM** (Phase 2): `{t:'res-head', rid, status, headers}` → N **binary chunk** `[kind:1][ridLen:1][rid][payload]`
  (`encodeBinaryChunk`/`decodeBinaryChunk`, `BIN_RES_BODY`) → `{t:'res-end', rid}`. Media (`/serve/*`) không base64 cả file,
  không nạp vào RAM Worker/DO. Backpressure: agent `res.pause()` tới khi `ws.send` callback flush xong.
- WS ảo: `{t:'ws-open', sid, path}` → `ws-open-ok|ws-open-err`; `{t:'ws-msg', sid, dir:'up'|'down', enc:'text'|'b64', data}`; `{t:'ws-close', sid}`
- **Notify (agent → DO, mobile push):** `{t:'notify', title, body, source?}`. Agent gửi khi có `plugin:notification:show`
  (`sendRelayNotify` trong `electron/relay-agent.ts`, drop im lặng nếu uplink chưa OPEN, không queue). DO: `sanitizeNotify`
  (cắt title 100 / body 500 / source 64, rỗng cả 2 → bỏ) → `NotifyThrottle` (10/phút/device, dư thì drop; state trong RAM
  DO nên hibernate thì reset) → `ctx.waitUntil(pushNotify)` đọc `devices` (owner `account_id`, tên `name||hostname||id`,
  bỏ nếu không active) → `sendPushToAccount` (`fleet-api/src/lib/fcm.ts`). DO biết `deviceId` nhờ Worker gắn header
  `X-Relay-Device` khi forward agent upgrade → lưu trong attachment `{role:'agent', deviceId}` (DO không đọc ngược được
  tên từ `idFromName`). Chi tiết: `.claude/features/mobile-app-push.md`.

## Files
**fleet-api (Cloudflare Worker):**
- `src/relay/frames.ts` — codec dùng chung (types + encode/decode).
- `src/relay/RelayDeviceDO.ts` — Durable Object 1/deviceId. Nhận agent upgrade + browser ws + browser http;
  route `rid` / `sid` (tag `browser:<sid>`, agent tag `agent`). HTTP response dùng `TransformStream` (await `res-head` →
  `Response(readable)`; binary chunk → `writer.write`; `res-end` → `writer.close`). Hibernation API (`acceptWebSocket` +
  `serializeAttachment`). Agent drop → fail head 502 + đóng writer + đóng browser 1011.
  **Hibernation (Phase 3):** constructor set `setWebSocketAutoResponse('ping','pong')` → keepalive không đánh thức DO.
  Browser socket được stamp `{accountId,orgId}` (từ header Worker); `onBrowserMessage` **drop frame nếu thiếu identity**
  (authZ per-frame). Route `presence` (`X-Relay-Role: presence`) → `{online: có agent-socket?}`.
  **Device reconnect (máy restart):** `acceptAgent` đóng socket cũ rồi nhận socket mới, nhưng socket đã `close()`
  **vẫn nằm trong `getWebSockets('agent')`** cho tới khi đầu kia đáp close-handshake — tiến trình bị kill thì không bao giờ đáp.
  Vì vậy `agentSocket()` **chỉ lấy socket `readyState === OPEN`**; lấy `[0]` sẽ route vào socket chết → `send()` ném
  `TypeError: Can't call WebSocket send() after close()` → thoát ra khỏi DO → **HTTP 500 cho MỌI `/d/<id>/api/*` và `/ws`**
  suốt cửa sổ reconnect. Phòng thủ nhiều lớp: mọi `agent.send()` đều bọc try/catch → `forwardHttp` trả **502
  'device offline'** (thay vì 504/500), `acceptBrowserWs` đóng browser socket 1011; và `webSocketClose` nhánh agent
  **bỏ qua teardown nếu đã có agent OPEN khác** — close muộn của socket cũ không được đá văng phiên của agent mới.
  Regression test: `test/relay.test.ts` → "serves HTTP through the new agent after the device reconnects" +
  "opens a browser WebSocket through the new agent after the device reconnects".
  **Cap (Phase 6):** `MAX_BROWSER_SESSIONS=32` — `acceptBrowserWs` trả **429** khi vượt (bound memory/CPU mỗi DO).
  ⚠️ **`browserCount()` CHỈ đếm socket `readyState === OPEN`** — đúng cái bẫy mà `agentSocket()` đã phải né:
  socket còn nằm trong `getWebSockets()` tới khi close-handshake xong, mà trình duyệt đã rời trang/ngủ/mất mạng
  thì **không bao giờ đáp**. Đếm cả xác thì việc đổi thiết bị, tải lại trang hay một vòng reconnect bình thường
  cũng lấp đủ 32 chỗ → device bị **429 với mọi viewer mới**, và vì con số nằm trong DO chứ không nằm ở trang,
  **tải lại tab không xoá được nó**.
- `src/routes/relay.ts` — `isRelayUpgrade`/`handleRelayUpgrade` (agent `/relay/agent?token&deviceId`, browser `/d/:id/ws?ticket`)
  + `isRelayHttp`/`handleRelayHttp` (`ALL /d/:id/api/*`, Bearer hoặc `?ticket`). `authorizeDevice` (agent uplink) verify appToken +
  `device.account_id === accountId` + `status='active'`; đường browser dùng `authorizeBrowser` (owner/org/share — `device-sharing.md`). **Strip `ticket` khỏi query khi forward** (không leak token xuống device).
  Browser ws forward kèm `X-Relay-Account/Org` (stamp identity). `isDeviceOnline(env, deviceId)` — hỏi DO presence.
  **Browser API (Phase 4):** `GET /api/relay/devices` (org-scope `resolveEffectiveOrgId` + `isDeviceOnline` mỗi device,
  trả cả cột `name`), `GET /api/relay/ticket?deviceId` (mint ticket cho device org sở hữu). Browser ws giờ **verify relay ticket** (không phải appToken).
  **Naming (self-service):** `GET /api/relay/org` (`{id,name,isOwner}`), `POST /api/relay/org/name` (chỉ owner), `POST /api/relay/devices/:id/name`
  (org sở hữu device; body rỗng ⇒ xoá tên → fallback hostname). Chi tiết: `.claude/features/device-fleet-naming.md`.
  **Prefix allowlist (Phase 5):** `isRelayHttp` CHỈ tunnel
  `/d/:id/{api,plugins,plugins-library,backgrounds,backgrounds-library,resources,serve,upload}` — bare `/d/:id/`
  + client-route KHÔNG tunnel (là SPA shell, serve tĩnh; tránh leak dashboardToken + tải máy).
  **CẢNH BÁO khi thêm route mới vào `dashboard-server.ts`:** phải thêm prefix vào allowlist này cùng lúc.
  Thiếu prefix KHÔNG gây 404 — nó rơi xuống shell và trả về `dashboard.html` kèm HTTP 200. Với `<img>` thì
  chỉ là icon vỡ, nhưng với một `<iframe>` thì shell tự boot BÊN TRONG chính nó; nếu frame đó lại mount
  lớp background thì thành lồng vô hạn (đã xảy ra với `backgrounds`). Regression test:
  `test/relay.test.ts` → "tunnels only device sub-paths, never the SPA shell".
- `src/lib/relayTicket.ts` (mới) — sign/verify ticket TTL 60s `{deviceId,orgId,accountId,exp}`, crypto giống `appToken.ts`.
- `src/index.ts` — thêm nhánh relay TRƯỚC chuỗi `/api/*`; re-export `RelayDeviceDO`.
- `src/types.ts` — `Env.RELAY: DurableObjectNamespace`.
- `wrangler.toml` — `[[durable_objects.bindings]] RELAY` + `[[migrations]] v1 new_sqlite_classes`.
- `wrangler.toml` — `[observability] enabled = true` (Workers Logs, 100% sampling): xem log từng request ở tab Observability của Worker `kazuo-fleet-api`. Có hiệu lực sau lần `wrangler deploy` kế tiếp.
- `test/relay.test.ts` — integration test (agent connect, HTTP tunnel, WS echo, offline 502, unauthorized 401, device reconnect).

**src (web-dashboard client, Phase 5):**
- `src/lib/relay-session.ts` (mới) — `isRelay` (không có `__KAZUO_DASH_TOKEN__`), `deviceId` từ path `/d/<id>`, `appToken`
  (localStorage), `refreshTicket()` gọi `/api/relay/ticket`, `resolveAssetUrl()` prefix `/d/<id>`.
- `src/lib/ipc-web-transport.ts` — `apiBase()` (relay: `/d/<id>/api`, local: `/api`), `authHeader()` (relay: appToken, local: dashboardToken),
  `connectWS` async **refresh ticket** trước mỗi (re)connect ở relay mode. `ipc-web.ts`/`electron-api-web.ts` KHÔNG đổi.
  **Reconnect có phanh:** `scheduleReconnect()` giãn dần 3s → 30s (`WS_RETRY_MIN_MS`/`WS_RETRY_MAX_MS`), reset về 3s
  khi `onopen`, và giữ **một** timer duy nhất (cặp `onerror` → `close()` → `onclose` không được xếp chồng hai lần thử).
  **Vé rỗng bị chặn từ đầu:** `refreshTicket()` nuốt mọi lỗi (mạng, 401, 403, 429) và trả `''`; mở socket với vé rỗng
  là 401 cầm chắc mà vẫn tốn một handshake, nên `connectWS` coi đó là lần thử hỏng → `ws:disconnected` + backoff,
  không mở socket. Trước đó: thử lại phẳng 3 giây/lần **vĩnh viễn**, mỗi lần mint thêm một ticket.
- `src/DevicePicker.svelte` (mới) — GIS 'Sign in with Google' → `/api/auth/google` → appToken; list `/api/relay/devices` + chấm online → chọn → `/d/<id>/`.
  Hiển thị tên `name || hostname || id`, nút ✏ đổi tên mỗi device + khối tên fleet (đổi tên nếu `isOwner`) — xem `device-fleet-naming.md`.
- `src/DashboardApp.svelte` — **header device switcher** (chỉ relay mode). Thay ô "● Connected"/"Disconnected"
  bằng: **dot trạng thái WS + tên thiết bị đang kết nối** (`name || hostname`, fallback `deviceId`) +
  nút mở dropdown liệt kê các device trong org (fetch lại `/api/relay/devices` lúc `onMount`, mỗi item có dot
  `online`); chọn máy khác → `switchDevice()` điều hướng `/d/<id>/` (full reload, giống picker, vì device mới
  cần WS/ticket mới). Dot vẫn phản ánh `connected` (WS live) như cũ, chỉ bỏ chữ. **Local mode** (không relay,
  không `deviceId`) giữ nguyên "Connected/Disconnected" — không có khái niệm device để đổi. Dùng lại style
  dropdown `.dev-menu`/`.dev-menu-backdrop` có sẵn của menu Dev.
- `src/dashboard-entry.ts` — gate: relay+chưa chọn device → mount DevicePicker; relay+device+chưa login → về `/`; else **dynamic import** DashboardApp
  (để WS chỉ connect sau khi có device). Local mode không đổi.
- `fleet-api/RELAY-DEPLOY.md` (mới) — runbook deploy: Web OAuth client (GIS), `build:dash` → `deploy`, DNS, deprecate cloudflared, rate-limit edge.
- **Deploy Topology B (đã wire + verify):** `vite.dash.config.mts` (build shell base `/_dash/` → `fleet-api/admin/dist/_dash/`),
  npm script `build:dash` (+ `scripts/stage-dash-shell.mjs` copy shell → `_dash/index.html`), `fleet-api/src/index.ts` phục vụ shell
  trên dash host (`/_dash/*` → ASSETS; path khác → `env.ASSETS.fetch('/_dash/')` directory index; KHÔNG rơi về admin), route
  `dash.kaiitech.net/*` active trong `wrangler.toml`. Verify: vitest `worker.fetch('https://dash.kaiitech.net/')` → 200 shell chứa `/_dash/assets/`
  (KHÔNG phải admin), 52 test pass.

**electron (main process):**
- `electron/relay-frames.ts` — bản copy frames (byte-for-byte với fleet-api).
- `electron/relay-agent.ts` — WSS client lên `<fleet>/relay/agent`, reconnect backoff, self-gate theo `store.accountToken`.
  Replay `req` → local `http://127.0.0.1:<port>` (đính dashboardToken, `Accept-Encoding: identity`, strip hop-by-hop);
  `ws-open` → mở local `/ws` với Origin=Host; echo 2 chiều theo `sid`.
- `electron/dashboard-server.ts` — thêm `getDashboardPort()` + persist `boundPort` thực (vá luôn resolve khi EADDRINUSE +1).
- `electron/main.ts` — `startRelayAgent()` sau `startDashboardServer` (app-ready), `stopRelayAgent()` ở `will-quit`.
- `electron/cloud-client.ts` — `startRelayAgent()` sau login+register; `stopRelayAgent()` ở logout.
  **SSO mượn token từ Kazuo Hub:** Kazuo AI và Kazuo Hub là 2 app Electron tách biệt (userData riêng),
  nên đăng nhập ở Hub KHÔNG tự đăng nhập AI → relay uplink của AI đứng im (dashboard 502 "device offline").
  `initCloudClient()` (chạy lúc app-ready, trước `startRelayAgent()`) gọi `adoptHubTokenIfSignedOut()`:
  nếu AI chưa có `accountToken` của riêng nó → đọc `appData/kazuo-hub/config.json` (read-only, KHÔNG ghi
  ngược), lấy `accountToken`+`accountEmail`, `store.set` làm của mình, rồi `registerDevice()`. Sau đó
  `startRelayAgent()` ở `main.ts` thấy token → dial uplink. Chỉ mượn khi AI trống token (không đè phiên
  AI tự đăng nhập bằng tài khoản khác).

## Heartbeat qua relay (giảm tải Worker)
Trước đây mỗi máy gọi `POST /api/devices/heartbeat` mỗi 45s (~1.900 request/ngày/máy, ~4 thao tác D1 mỗi lần). Giờ khi relay uplink đang mở:
- **Lệnh admin đến tức thì:** `handleCreateCommand` (`routes/admin.ts`) INSERT lệnh vào D1 rồi `wakeDevice()` (`routes/relay.ts`) → DO (`X-Relay-Role: wake`)
  gửi frame `{t:'wake'}` xuống agent. Agent gọi handler do `cloud-client` đăng ký (`setRelayWakeHandler`, tránh import vòng) → `heartbeatOnce(true)`
  để lấy lệnh qua đường HTTP cũ (D1 vẫn là nguồn sự thật, `delivered_at` chống giao trùng). `wakeDevice` best-effort: máy offline thì lệnh
  nằm chờ trong D1.
- **Heartbeat HTTP thành lưới an toàn:** `cloud-client.heartbeatOnce()` bỏ qua gọi mạng nếu `isRelayConnected()` và chưa quá
  `RELAY_HEARTBEAT_INTERVAL_MS` (10 phút). Timer 45s vẫn chạy để kiểm tra gating entitlement cục bộ (không tốn mạng). Relay mất kết nối → quay về 45s.
- **Nối lại là lấy lệnh ngay:** agent gọi wake handler ở sự kiện `open` của uplink → lệnh xếp hàng lúc offline không phải chờ 10 phút.
- **`last_seen_at`:** DO ghi `devices.last_seen_at` khi agent nối (`acceptAgent`) và khi rớt (`webSocketClose`, bỏ qua socket bị thay thế).
  Khi đang nối, admin UI có thể thấy "last seen" cũ tới 10 phút (heartbeat an toàn) — online thực dùng presence DO.
- **Tương thích ngược:** bản app cũ bỏ qua frame `wake` (switch không có case) và vẫn heartbeat 45s → deploy Worker trước không hỏng gì;
  mức giảm request chỉ có sau khi người dùng cập nhật app.
- Test: `test/relay.test.ts` → "command wake-up over the relay uplink" (wake frame, máy offline vẫn queue lệnh, last_seen_at khi nối).

## Auth (chốt)
- Đăng nhập web = **Google Identity Services (GIS)** trên trình duyệt → id_token → `/api/auth/google` → appToken (localStorage).
  **CẦN Web OAuth client riêng** (client Desktop không dùng được cho GIS).
- **Multi-aud** (`src/lib/googleAuth.ts` `audAllowed`): `/api/auth/google` verify `aud` theo **danh sách client id phân tách dấu phẩy**
  (`GOOGLE_CLIENT_ID = "<desktop-id>,<web-id>"`) → Desktop app + web dashboard login chung fleet-api. Test: `test/aud-allowlist.test.ts`.
  Allowlist hiện tại có **3 id**: desktop cũ (project A, giữ cho bản đã phát hành),
  desktop mới + web (cùng project B). Bỏ id desktop cũ khi không còn bản cũ nào ngoài thực tế.
- **Sliding renewal** (`POST /api/auth/refresh`, `src/routes/auth.ts` `handleRefreshToken`): nhận appToken **còn hạn**, ký lại token 30 ngày mới
  cho cùng account (chặn nếu `accounts.status != 'active'`). Không dùng refresh_token của Google — appToken là HMAC của chính mình nên
  "gia hạn" chỉ là ký lại. Test: `test/auth-refresh.test.ts`.
- **Client**: `electron/lib/app-token.ts` (thuần, có test) giải mã `exp` cục bộ để biết **khi nào** hỏi; server luôn là bên quyết định
  phiên còn sống hay không (đồng hồ máy lệch không được phép đăng xuất ai). `electron/token-renewal.ts` giữ single-flight + latch +
  cooldown 15 phút, gọi từ `initCloudClient()` lúc boot và từ keepalive tick 30s của relay agent. Ngưỡng gia hạn 7 ngày.
- **Relay 401 KHÔNG đồng nghĩa token hết hạn**: `authorizeDevice` trả 401 cho 5 nguyên nhân (thiếu token, token hỏng/hết hạn, device
  không tồn tại, device thuộc account khác, device không active). Agent bắt `unexpected-response` lấy status rồi hỏi `/api/auth/refresh`
  phân xử: `expired` → thông báo + `stopRelayAgent()`; `ok` → nối lại ngay (token mới đã nằm trong store); `unknown` (mất mạng) → backoff
  bình thường. 401 lần hai sau khi server xác nhận token tốt ⇒ lỗi ở **device**, backoff max thay vì báo sai "hết hạn".
  ⚠️ `ws` bỏ `abortHandshake` khi có listener `unexpected-response` → phải tự `res.resume()` + `ws.close()`, nếu không socket treo ở
  CONNECTING và vòng reconnect (dựa vào `close`) chết hẳn.
- **Cứu phiên chết bằng Kazuo Hub** (`renewFromHub` trong `token-renewal.ts`): khi `/api/auth/refresh` trả 401 cho token của AI,
  trước khi latch `expired` thì đọc token Hub (`electron/hub-session.ts` `readHubSession()`, read-only, dùng chung với
  `adoptHubTokenIfSignedOut()`). Chỉ dùng nếu **khác** token chết và **cùng `accountId`** (`sameAccount()` trong `lib/app-token.ts`,
  bỏ qua `exp`) — token Hub của account khác sẽ lặng lẽ chuyển device sang fleet người khác nên bị từ chối. Đổi token Hub lấy token
  **mới của riêng AI** qua `/api/auth/refresh` rồi mới lưu (không lưu nguyên token Hub → 2 app giữ phiên độc lập). Thành công → `ok`
  → relay nối lại ngay. Trước đây `adoptHubTokenIfSignedOut()` chỉ mượn khi AI **trống** token, nên AI giữ token chết thì kẹt offline
  mãi dù Hub còn phiên hợp lệ.
- **Hiện hết hạn trong Settings**: `getAccountStatus()` có thêm `sessionExpired` = `isSessionExpired()` (latch server đã xác nhận)
  **HOẶC** `isExpired(token)` cục bộ — check cục bộ để Settings báo ngay, không phải chờ vòng 401 của relay; chỉ dẫn tới cảnh báo,
  không bao giờ đăng xuất, nên đồng hồ lệch tối đa gây 1 gợi ý "đăng nhập lại" thừa. `AccountSection.svelte` hiện "Phiên đăng nhập
  đã hết hạn" + nút **Đăng nhập lại** (gọi `login()`) cạnh Đăng xuất; `SidebarAccount.svelte` đổi nhãn sang màu cảnh báo. Trước đây
  Settings chỉ xét `!!accountToken` nên vẫn ghi "Signed in as" với token đã chết, và tín hiệu duy nhất là 1 toast Windows dễ bỏ lỡ.
  Settings chỉ đọc trạng thái lúc mount (không có push event) — mở lại tab là thấy.
- **Giới hạn**: máy tắt liên tục quá 30 ngày **và** Hub cũng không còn phiên hợp lệ cùng account → phải đăng nhập lại tay.
- Phạm vi device = **org của người dùng** (`devices.org_id`, `resolveEffectiveOrgId`) **+ máy được chia sẻ riêng**
  (`device_shares`, xem `.claude/features/device-sharing.md`).
- **Browser HTTP** `/d/:id/api/*` + cookie WS: `Authorization: Bearer <appToken>` → `authorizeBrowser` (owner / org / share,
  `lib/deviceShares.ts` `loadAccessibleDevice`). `authorizeDevice` (chỉ owner) giờ chỉ dùng cho agent uplink.
- **Browser WS** `/d/:id/ws`: **relay ticket** (TTL 60s) lấy qua `GET /api/relay/ticket` (WS handshake không set header được).
- **Agent uplink** `/relay/agent`: appToken + `deviceId` (verify ownership qua D1).

## Trạng thái
- **Phase 1 — COMPLETED** (tunnel end-to-end: agent WS + DO + browser HTTP/WS qua CF).
- **Phase 2 — COMPLETED** (streaming HTTP response bằng binary chunk cho media, backpressure).
  **LƯU Ý:** phần "serve SPA shell tĩnh từ ASSETS" đã dồn sang **Phase 5** (phụ thuộc bundle web-dashboard trong ASSETS + route dash).
- **Phase 3 — COMPLETED** (hibernation ping/pong; presence; authZ per-frame stamp identity → đóng residual WS auth).
- **Phase 4 — COMPLETED** (`/api/relay/devices` org-scope + online; `/api/relay/ticket` TTL 60s; browser ws verify ticket;
  `serveLocalFile` đổi sang relative `/serve/<token>`). Verify: `test/relay.test.ts` (11 test, gồm devices list org-scope,
  ticket mint/403, bad-ticket 401), fleet-api typecheck + 49 test pass, electron `tsc -p tsconfig.node.json` sạch.
- **Phase 5 — CODE COMPLETED** (client device-aware transport + `relay-session` + device-picker GIS login + prefix allowlist).
  Verify: `vite build` sạch, fleet-api typecheck + 50 test pass (thêm test allowlist prefix), tsc 2 file client-TS sạch.
  **DEPLOY PENDING (chủ dự án làm):** tạo Web OAuth client (`VITE_GIS_CLIENT_ID`), serve shell (Pages/Worker assets), enable route
  `dash.kaiitech.net`, trỏ DNS — xem `fleet-api/RELAY-DEPLOY.md`.
- **Phase 6 — COMPLETED** (cap browser session/DO = 8 → 429; rate-limit ticket/devices ghi hướng dẫn edge Cloudflare WAF trong
  `RELAY-DEPLOY.md`; cập nhật `dashboard-server-security.md` — residual WS đóng cho đường relay). Verify: fleet-api typecheck + 51 test pass
  (thêm test cap 429). Cờ tắt exec-class cho relay session: **bỏ qua (YAGNI)** — RCE đã chặn bởi org-ownership + `isRegisteredEnabledPlugin`.

## Known limitations / TODO
- **SSO Hub→AI 1 chiều, chỉ lúc boot:** AI mượn token của Hub 1 lần khi khởi động (nếu AI trống token).
  KHÔNG đồng bộ ngược lại: nếu Hub đăng xuất/đổi token SAU khi AI đã mượn, AI vẫn giữ token cũ tới khi
  AI tự đăng xuất hoặc khởi động lại + Hub trống. Chấp nhận được (token hết hạn thì relay/heartbeat tự 401),
  chưa làm watch/sync realtime (YAGNI).
- **Headless (`electron/headless.ts`) CHƯA có relay-agent** — headless là bản dashboard-server độc lập (port/token riêng),
  wiring cần parameter hoá relay-agent (port/token provider). Hoãn (mục tiêu chính là app GUI).
- Request body vẫn inline base64 (nhỏ, JSON POST) — chỉ RESPONSE body mới stream binary.
- `serveLocalFile` trả relative `/serve/<token>` (host-absolute) → local desktop OK; qua relay CẦN prefix `/d/<id>/serve/...`.
  `relaySession.resolveAssetUrl()` đã có nhưng **CHƯA áp vào đường dữ liệu plugin** (URL trả qua WS invoke tới iframe plugin) —
  cần hook chỗ plugin dùng media, verify trong trình duyệt (Phase 6 / khi deploy). Đây là điểm CHƯA verify được cục bộ.
- Toàn bộ Phase 5 client (GIS login, picker, transport) mới verify bằng build + typecheck; **chưa chạy thực trên trình duyệt**
  (cần deploy + Web OAuth client). Chủ dự án verify sau deploy theo checklist `RELAY-DEPLOY.md`.
- `isolatedStorage: false` trong `fleet-api/vitest.config.ts` (DO WS + Windows EBUSY) — test mới phải tự seed UUID.
