From 0999f7b07c1586bedc4f8777486d679750b3fcf0 Mon Sep 17 00:00:00 2001 From: tobyleon Date: Thu, 30 Apr 2026 20:23:16 +0800 Subject: [PATCH 01/14] fix: auto-register JWT-only devices on first auth via upsert sync-daemon uses HS256 JWT (deviceId in payload.sub) without Ed25519 registration. Middleware now upserts Device record so sessions can be created without a prior /v1/auth publicKey exchange. Also fix in sync-daemon: JWT payload uses 'deviceId' instead of 'sub' to match mio-server verifyToken expectations. --- sources/auth/middleware.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/sources/auth/middleware.ts b/sources/auth/middleware.ts index e4eae31..1660c41 100644 --- a/sources/auth/middleware.ts +++ b/sources/auth/middleware.ts @@ -26,9 +26,11 @@ export function bumpLastSeenAt(deviceId: string) { if (prev && now - prev < LAST_SEEN_THROTTLE_MS) return; lastSeenWriteAt.set(deviceId, now); // Fire-and-forget — never block the request on this. - db.device.update({ + // upsert ensures JWT-only devices (no publicKey) are auto-registered on first auth. + db.device.upsert({ where: { id: deviceId }, - data: { lastSeenAt: new Date() }, + create: { id: deviceId, name: 'JWT Device', lastSeenAt: new Date() }, + update: { lastSeenAt: new Date() }, }).catch(() => { // Most likely cause: deviceId no longer exists (device was deleted // out from under us). Drop the throttle entry so a re-registered From fabc54cc252ab4aac2318238c734b0fc58edefe2 Mon Sep 17 00:00:00 2001 From: tobyleon Date: Thu, 30 Apr 2026 21:56:16 +0800 Subject: [PATCH 02/14] clean: remove stale migrate resolve baseline from Dockerfile CMD --- .claude/worktrees/agent-a42c2744 | 1 + .claude/worktrees/agent-aa094545 | 1 + .dockerignore | 11 + Dockerfile | 20 + MioServer | 1 + arch_diagrams.py | 876 +++++++++++++++++++++++++++++++ mio-architecture-comparison.pptx | Bin 0 -> 50361 bytes package.json | 2 +- 8 files changed, 911 insertions(+), 1 deletion(-) create mode 160000 .claude/worktrees/agent-a42c2744 create mode 160000 .claude/worktrees/agent-aa094545 create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 160000 MioServer create mode 100644 arch_diagrams.py create mode 100644 mio-architecture-comparison.pptx diff --git a/.claude/worktrees/agent-a42c2744 b/.claude/worktrees/agent-a42c2744 new file mode 160000 index 0000000..d0bd65a --- /dev/null +++ b/.claude/worktrees/agent-a42c2744 @@ -0,0 +1 @@ +Subproject commit d0bd65a97a905e6b98ba6e0edbf13ff0c59abb9b diff --git a/.claude/worktrees/agent-aa094545 b/.claude/worktrees/agent-aa094545 new file mode 160000 index 0000000..d0bd65a --- /dev/null +++ b/.claude/worktrees/agent-aa094545 @@ -0,0 +1 @@ +Subproject commit d0bd65a97a905e6b98ba6e0edbf13ff0c59abb9b diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e4d447f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +node_modules +.git +.claude +.claude-guardian +.env +.env.dev +.env.example +*.log +*.md +tests +vitest.config.ts diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..386a18e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM node:20-alpine + +WORKDIR /app + +# Copy prisma first (needed for postinstall prisma generate) +COPY prisma/ ./prisma/ + +# Copy package files and install (postinstall runs prisma generate) +COPY package*.json ./ +RUN npm install --omit=dev + +# Generate Prisma client explicitly +RUN npx prisma generate + +# Copy source +COPY sources/ ./sources/ +COPY tsconfig.json ./ + +# Start command: baseline + migrate deploy + server +CMD ["sh", "-c", "npx prisma migrate deploy && npx prisma generate && npx tsx ./sources/main.ts"] diff --git a/MioServer b/MioServer new file mode 160000 index 0000000..d0bd65a --- /dev/null +++ b/MioServer @@ -0,0 +1 @@ +Subproject commit d0bd65a97a905e6b98ba6e0edbf13ff0c59abb9b diff --git a/arch_diagrams.py b/arch_diagrams.py new file mode 100644 index 0000000..aafbbda --- /dev/null +++ b/arch_diagrams.py @@ -0,0 +1,876 @@ +""" +Generate architecture comparison PPTX for MioServer / CodeIsland / CodeLight. +Covers 4 deployment scenarios with data flow diagrams. +""" + +from pptx import Presentation +from pptx.util import Inches, Pt, Emu +from pptx.dml.color import RGBColor +from pptx.enum.text import PP_ALIGN +from pptx.util import Inches, Pt +import copy + +# ── Colors ────────────────────────────────────────────────────────────────── +BRAND_LIME = RGBColor(0xCA, 0xFF, 0x00) # #CAFF00 MioIsland brand +BRAND_DARK = RGBColor(0x1A, 0x1A, 0x1A) # near-black +BRAND_GRAY = RGBColor(0x2D, 0x2D, 0x2D) # card bg +BRAND_MID = RGBColor(0x50, 0x50, 0x50) # subtitle text +TEXT_WHITE = RGBColor(0xFF, 0xFF, 0xFF) +TEXT_LIME = BRAND_LIME +TEXT_DIM = RGBColor(0x99, 0x99, 0x99) +ACCENT_BLUE = RGBColor(0x4A, 0x9D, 0xFF) +ACCENT_RED = RGBColor(0xFF, 0x4A, 0x4A) +ACCENT_GRN = RGBColor(0x4A, 0xFF, 0x8A) +LINE_COLOR = RGBColor(0x60, 0x60, 0x60) +BG_DARK = RGBColor(0x12, 0x12, 0x12) + +# ── Helpers ────────────────────────────────────────────────────────────────── + +def blank_slide(prs): + sl = prs.slides.add_slide(prs.slide_layouts[6]) # blank + return sl + + +def rect(slide, l, t, w, h, fill=None, line=None, line_w=None): + """Add a filled rectangle. All units in inches.""" + shape = slide.shapes.add_shape( + 1, # MSO_SHAPE_TYPE.RECTANGLE + Inches(l), Inches(t), Inches(w), Inches(h) + ) + if fill: + shape.fill.solid() + shape.fill.fore_color.rgb = fill + else: + shape.fill.background() + if line: + shape.line.color.rgb = line + shape.line.width = Pt(line_w or 1) + else: + shape.line.fill.background() + return shape + + +def rounded_rect(slide, l, t, w, h, fill, corner=0.1, line=None): + shape = slide.shapes.add_shape( + 5, # ROUNDED_RECTANGLE + Inches(l), Inches(t), Inches(w), Inches(h) + ) + shape.fill.solid() + shape.fill.fore_color.rgb = fill + if line: + shape.line.color.rgb = line + else: + shape.line.fill.background() + return shape + + +def label(slide, text, l, t, w, h=None, size=12, bold=False, + color=TEXT_WHITE, align=PP_ALIGN.CENTER, wrap=True): + """Add a text box. h defaults to 0.4 if not given.""" + if h is None: + h = max(0.3, round(size * 0.06, 2)) + txb = slide.shapes.add_textbox(Inches(l), Inches(t), Inches(w), Inches(h)) + tf = txb.text_frame + tf.word_wrap = wrap + p = tf.paragraphs[0] + p.alignment = align + run = p.add_run() + run.text = text + run.font.size = Pt(size) + run.font.bold = bold + run.font.color.rgb = color + return txb + + +def subtitle(slide, text, top=0.3, color=TEXT_DIM): + return label(slide, text, 0.3, top, 9.4, size=13, color=color) + + +def page_num(slide, n, total): + label(slide, f"{n} / {total}", 0.2, 8.8, 1.5, size=9, color=TEXT_DIM, + align=PP_ALIGN.LEFT) + + +def arrow(slide, x1, y1, x2, y2, color=LINE_COLOR, w=1.5, dash=False): + """Draw a simple line connector. For arrows we add a triangle marker.""" + from pptx.util import Pt + from pptx.enum.dml import MSO_THEME_COLOR + conn = slide.shapes.add_connector( + 1, # STRAIGHT + Inches(x1), Inches(y1), Inches(x2), Inches(y2) + ) + conn.line.color.rgb = color + conn.line.width = Pt(w) + return conn + + +def add_text_box(slide, text, l, t, w, h, font_size=11, bold=False, + color=TEXT_WHITE, align=PP_ALIGN.CENTER): + txb = slide.shapes.add_textbox(Inches(l), Inches(t), Inches(w), Inches(h)) + tf = txb.text_frame + tf.word_wrap = True + p = tf.paragraphs[0] + p.alignment = align + run = p.add_run() + run.text = text + run.font.size = Pt(font_size) + run.font.bold = bold + run.font.color.rgb = color + return txb + + +# ── Node box helper ───────────────────────────────────────────────────────── +# Each node: (label, sub_label, fill, text_color) +NODE_H = 0.7 +NODE_W = 1.8 + +def node(slide, text, sub, cx, cy, fill=BRAND_GRAY, tcolor=TEXT_WHITE, + w=NODE_W, h=NODE_H): + """Draw a rounded-rect node centered at (cx, cy).""" + l = cx - w / 2 + t = cy - h / 2 + box = rounded_rect(slide, l, t, w, h, fill, corner=0.08, line=LINE_COLOR) + label(slide, text, l + 0.05, t + 0.05, w - 0.1, 0.32, size=11, bold=True, + color=tcolor) + if sub: + label(slide, sub, l + 0.05, t + 0.32, w - 0.1, 0.3, size=8, + color=TEXT_DIM) + return (l, t, w, h) + + +def dashed_line(slide, x1, y1, x2, y2, color=BRAND_LIME, w=1.2): + from pptx.util import Pt + from pptx.oxml.ns import qn + import copy + from lxml import etree + conn = slide.shapes.add_connector( + 1, Inches(x1), Inches(y1), Inches(x2), Inches(y2) + ) + conn.line.color.rgb = color + conn.line.width = Pt(w) + # Make dashed via XML + ln = conn.line._ln + prst = ln.find(qn('a:prstDash')) + if prst is None: + prst = etree.SubElement(ln, qn('a:prstDash')) + prst.set('val', 'dash') + return conn + + +def solid_line(slide, x1, y1, x2, y2, color=LINE_COLOR, w=1.2): + from pptx.util import Pt + conn = slide.shapes.add_connector( + 1, Inches(x1), Inches(y1), Inches(x2), Inches(y2) + ) + conn.line.color.rgb = color + conn.line.width = Pt(w) + return conn + + +def arrowhead_line(slide, x1, y1, x2, y2, color=LINE_COLOR, w=1.2): + """Line with arrow marker.""" + from pptx.util import Pt + from pptx.oxml.ns import qn + from lxml import etree + conn = slide.shapes.add_connector( + 1, Inches(x1), Inches(y1), Inches(x2), Inches(y2) + ) + conn.line.color.rgb = color + conn.line.width = Pt(w) + # Add arrow head + tailEnd = conn.line._ln + # Create element + tail_end = etree.SubElement(tailEnd, qn('a:tailEnd')) + tail_end.set('type', 'triangle') + tail_end.set('w', 'med') + tail_end.set('len', 'med') + return conn + + +# ── Box with icon + text ────────────────────────────────────────────────────── +def component_box(slide, icon_text, title, subtitle_text, cx, cy, + fill=BRAND_GRAY, title_color=TEXT_WHITE, + sub_color=TEXT_DIM, w=1.7, h=0.85): + l = cx - w / 2 + t = cy - h / 2 + rounded_rect(slide, l, t, w, h, fill, corner=0.08, line=LINE_COLOR) + # Icon (text emoji substitute) + label(slide, icon_text, l, t + 0.06, w, 0.32, size=16, bold=True, + color=BRAND_LIME) + label(slide, title, l, t + 0.38, w, 0.22, size=9, bold=True, + color=title_color) + label(slide, subtitle_text, l, t + 0.56, w, 0.22, size=7.5, + color=sub_color) + + +# ── Flow label ──────────────────────────────────────────────────────────────── +def flow_tag(slide, text, cx, cy, fill=BRAND_GRAY, color=TEXT_LIME, w=1.4, h=0.28): + l = cx - w / 2 + t = cy - h / 2 + rounded_rect(slide, l, t, w, h, fill, corner=0.05, line=color) + label(slide, text, l, t, w, h, size=7.5, bold=True, color=color) + + +# ── Legend ─────────────────────────────────────────────────────────────────── +def legend(slide, items, l, t): + """items: list of (color, label)""" + for i, (col, lbl) in enumerate(items): + y = t + i * 0.28 + rect(slide, l, y, 0.18, 0.14, fill=col) + label(slide, lbl, l + 0.22, y - 0.04, 1.5, 0.22, size=8, + color=TEXT_DIM) + + +# ── Slide builder functions ────────────────────────────────────────────────── + +def slide_title(prs, title, subtitle=None): + sl = blank_slide(prs) + # Background + rect(sl, 0, 0, 10, 8.5, fill=BG_DARK) + # Accent bar top + rect(sl, 0, 0, 10, 0.08, fill=BRAND_LIME) + rect(sl, 0, 8.42, 10, 0.08, fill=BRAND_LIME) + # Title + label(sl, title, 0.5, 3.0, 9, size=36, bold=True, color=TEXT_WHITE) + if subtitle: + label(sl, subtitle, 0.5, 3.85, 9, size=16, color=TEXT_DIM) + return sl + + +def slide_section(prs, title, section_num=None): + sl = blank_slide(prs) + rect(sl, 0, 0, 10, 8.5, fill=BG_DARK) + rect(sl, 0, 0, 10, 0.06, fill=BRAND_LIME) + if section_num: + label(sl, section_num, 0.4, 3.4, 1, size=72, bold=True, + color=BRAND_LIME, align=PP_ALIGN.LEFT) + label(sl, title, 0.4 + (1.2 if section_num else 0), 3.5, + 8.5, size=32, bold=True, color=TEXT_WHITE, align=PP_ALIGN.LEFT) + return sl + + +# ═══════════════════════════════════════════════════════════════════════════════ +# SLIDE 1 — Cover +# ═══════════════════════════════════════════════════════════════════════════════ +def cover(prs): + sl = blank_slide(prs) + rect(sl, 0, 0, 10, 8.5, fill=BG_DARK) + rect(sl, 0, 0, 10, 0.1, fill=BRAND_LIME) + rect(sl, 0, 8.4, 10, 0.1, fill=BRAND_LIME) + + # Left accent column + rect(sl, 0, 0.1, 0.12, 8.3, fill=BRAND_LIME) + + # Title + label(sl, "MioIsland × MioServer", 0.5, 1.8, 9, size=40, bold=True, + color=TEXT_WHITE) + label(sl, "框架与链路对比", 0.5, 2.6, 9, size=28, bold=False, + color=BRAND_LIME) + label(sl, "Architecture & Data Flow Comparison", 0.5, 3.1, 9, size=14, + color=TEXT_DIM) + + # Divider + rect(sl, 0.5, 3.6, 5, 0.03, fill=LINE_COLOR) + + # Topics list + topics = [ + "CodeIsland / MioIsland / CodeLight 三者关系", + "4 种部署架构详解", + "数据流向与 session 生命周期", + "Hook → mio-server 接入方案", + "iPhone 配对与消息推送链路", + ] + for i, t in enumerate(topics): + label(sl, f"› {t}", 0.5, 3.8 + i * 0.44, 8.5, size=13, + color=TEXT_WHITE) + + label(sl, "2026-04-30", 0.5, 8.1, 3, size=10, color=TEXT_DIM) + return sl + + +# ═══════════════════════════════════════════════════════════════════════════════ +# SLIDE 2 — Three Musketeers +# ═══════════════════════════════════════════════════════════════════════════════ +def slide_overview(prs): + sl = blank_slide(prs) + rect(sl, 0, 0, 10, 8.5, fill=BG_DARK) + rect(sl, 0, 0, 10, 0.06, fill=BRAND_LIME) + + label(sl, "01 三组件定位", 0.4, 0.25, 9, size=22, bold=True, + color=TEXT_WHITE) + subtitle(sl, "CodeIsland · MioIsland · CodeLight — 各司其职", top=0.75) + + cards = [ + ("🖥", "CodeIsland", "Mac notch 桌面应用", BRAND_GRAY, + "• SSH relay 远程 CC\n• 本地 session 读取\n• tmux 进程管理\n• Notch UI 显示"), + ("📱", "MioIsland", "iPhone 控制端(经典)", RGBColor(0x20, 0x30, 0x20), + "• SSH 连接 Mac\n• 远程控制 CC\n• 接收推送通知\n• Launch Preset 管理"), + ("📲", "CodeLight", "iPhone 控制端(新)", RGBColor(0x15, 0x15, 0x30), + "• 直连 mio-server\n• 扫码 / shortCode 配对\n• 接收 APNs 通知\n• 不需要 Mac 在线"), + ] + + for i, (icon, title, sub, fill, bullets) in enumerate(cards): + x = 0.4 + i * 3.15 + # Card bg + rounded_rect(sl, x, 1.35, 3.0, 5.2, fill, corner=0.1, + line=LINE_COLOR) + # Header strip + rect(sl, x, 1.35, 3.0, 0.7, fill=BRAND_LIME) + # Icon + label(sl, icon, x, 1.38, 3.0, 0.6, size=24, bold=True, + color=BRAND_DARK) + # Title + label(sl, title, x, 2.1, 3.0, 0.35, size=15, bold=True, + color=TEXT_WHITE) + label(sl, sub, x, 2.42, 3.0, 0.28, size=9, color=TEXT_DIM) + + # Bullets + txb = sl.shapes.add_textbox( + Inches(x + 0.18), Inches(2.78), Inches(2.7), Inches(3.5)) + tf = txb.text_frame + tf.word_wrap = True + for j, b in enumerate(bullets.split('\n')): + p = tf.paragraphs[0] if j == 0 else tf.add_paragraph() + p.alignment = PP_ALIGN.LEFT + run = p.add_run() + run.text = b + run.font.size = Pt(10) + run.font.color.rgb = TEXT_DIM + + # Bottom note + rect(sl, 0.4, 6.75, 9.2, 0.03, fill=LINE_COLOR) + label(sl, "关键区别:CodeLight 不走 SSH 直连 Mac,而是通过 mio-server 作为消息中继", 0.4, 6.85, 9.2, size=10, + color=BRAND_LIME) + return sl + + +# ═══════════════════════════════════════════════════════════════════════════════ +# SLIDE 3 — Current Architecture +# ═══════════════════════════════════════════════════════════════════════════════ +def slide_arch_current(prs): + sl = blank_slide(prs) + rect(sl, 0, 0, 10, 8.5, fill=BG_DARK) + rect(sl, 0, 0, 10, 0.06, fill=BRAND_LIME) + + label(sl, "02 现有架构", 0.4, 0.25, 9, size=22, bold=True, + color=TEXT_WHITE) + subtitle(sl, "CC 运行在本地 Mac,通过 hook socket 与 notch 联动", top=0.75) + + # Legend + legend(sl, [ + (BRAND_LIME, "实时数据流(Socket.io)"), + (ACCENT_BLUE, "消息轮询(HTTP)"), + (ACCENT_RED, "SSH 隧道"), + (LINE_COLOR, "间接关联"), + ], 0.4, 1.2) + + # ── Nodes ──────────────────────────────────────────────────────────────── + CY = 3.6 + GAP = 2.2 + + # iPhone + node(sl, "iPhone", "CodeLight App", + 0.8, CY, fill=RGBColor(0x15, 0x15, 0x30), tcolor=TEXT_WHITE, w=1.5) + + # mio-server + node(sl, "mio-server", "Railway · Socket.io", + 0.8 + GAP, CY, fill=RGBColor(0x0A, 0x20, 0x0A), tcolor=BRAND_LIME, w=1.8) + + # Mac notch + node(sl, "MioIsland", "Mac notch app", + 0.8 + GAP * 2, CY, fill=BRAND_GRAY, tcolor=TEXT_WHITE, w=1.6) + + # Claude Code + node(sl, "Claude Code", "本地运行", + 0.8 + GAP * 3, CY, fill=RGBColor(0x20, 0x0A, 0x0A), + tcolor=ACCENT_RED, w=1.6) + + # Hook Socket + node(sl, "Hook Socket", "127.0.0.1:9871", + 0.8 + GAP * 2, CY + 1.4, fill=RGBColor(0x30, 0x20, 0x10), + tcolor=ACCENT_BLUE, w=1.9, h=0.6) + + # ── Arrows ────────────────────────────────────────────────────────────── + # iPhone ↔ mio-server + dashed_line(sl, 2.0, CY, 0.8 + GAP - 1.0, CY, color=BRAND_LIME, w=1.5) + flow_tag(sl, "Socket.io /v1/updates", 1.4, CY - 0.22, + fill=RGBColor(0x10, 0x20, 0x10), color=BRAND_LIME, w=1.9) + + # mio-server ↔ Mac notch + solid_line(sl, 0.8 + GAP + 1.0, CY, 0.8 + GAP * 2 - 1.0, CY, + color=BRAND_LIME, w=1.5) + flow_tag(sl, "Socket.io /v1/updates", 0.8 + GAP + 0.5, CY - 0.22, + fill=RGBColor(0x10, 0x20, 0x10), color=BRAND_LIME, w=1.9) + + # Mac notch ↔ hook socket + solid_line(sl, 0.8 + GAP * 2, CY + 0.38, 0.8 + GAP * 2, CY + 1.0, + color=ACCENT_BLUE, w=1.2) + flow_tag(sl, "hook socket", 0.8 + GAP * 2 + 0.12, CY + 0.7, + fill=RGBColor(0x10, 0x10, 0x20), color=ACCENT_BLUE, w=1.1) + + # Hook ↔ Claude Code + solid_line(sl, 0.8 + GAP * 2 + 1.0, CY + 1.4, + 0.8 + GAP * 3 - 1.0, CY, + color=LINE_COLOR, w=1.0) + + # ── Summary ────────────────────────────────────────────────────────────── + rect(sl, 0.4, 6.0, 9.2, 1.85, fill=RGBColor(0x0D, 0x0D, 0x0D), + line=LINE_COLOR) + label(sl, "数据流", 0.6, 6.08, 1.2, size=10, bold=True, color=BRAND_LIME) + rows = [ + ("1", "iPhone 扫码配对 → mio-server 建立 DeviceLink"), + ("2", "CC hook 写入 JSONL → MioIsland 读取并通过 Socket.io 发送到 mio-server"), + ("3", "mio-server 存储 session 消息 → iPhone 轮询 /v1/sessions 获取更新"), + ("4", "iPhone 也可以发消息 → mio-server → MioIsland → 本地 CC 终端"), + ] + for i, (num, desc) in enumerate(rows): + label(sl, num, 0.6 + i * 0, 6.32 + i * 0.34, 0.3, size=9, + bold=True, color=BRAND_LIME) + label(sl, desc, 0.9, 6.32 + i * 0.34, 8.5, size=9, color=TEXT_DIM) + return sl + + +# ═══════════════════════════════════════════════════════════════════════════════ +# SLIDE 4 — Server CC Architecture +# ═══════════════════════════════════════════════════════════════════════════════ +def slide_arch_server(prs): + sl = blank_slide(prs) + rect(sl, 0, 0, 10, 8.5, fill=BG_DARK) + rect(sl, 0, 0, 10, 0.06, fill=BRAND_LIME) + + label(sl, "03 架构 A", 0.4, 0.25, 9, size=22, bold=True, + color=TEXT_WHITE) + subtitle(sl, "CC 运行在远程服务器,hook 直连 mio-server · 不需要 Mac 在线", top=0.75) + + CY = 2.9 + WIDTHS = [1.5, 2.0, 1.5, 1.8] + CX = [0.8, 2.9, 5.5, 7.8] + FILLS = [ + RGBColor(0x15, 0x15, 0x30), # iPhone + RGBColor(0x0A, 0x20, 0x0A), # mio-server + RGBColor(0x20, 0x0A, 0x0A), # CC server + RGBColor(0x10, 0x10, 0x20), # hook client + ] + TCOLORS = [TEXT_WHITE, BRAND_LIME, ACCENT_RED, ACCENT_BLUE] + + labels_data = [ + ("iPhone", "CodeLight App"), + ("mio-server", "Railway · 已部署"), + ("Claude Code", "远程服务器"), + ("Hook Client", "hook socket → mio-server"), + ] + for i, (lbl, sub) in enumerate(labels_data): + node(sl, lbl, sub, CX[i], CY, fill=FILLS[i], tcolor=TCOLORS[i], + w=WIDTHS[i]) + + # Hook client below CC + node(sl, "Hook Socket", "127.0.0.1:port", + CX[2], CY + 1.35, fill=RGBColor(0x30, 0x20, 0x10), + tcolor=ACCENT_BLUE, w=1.9, h=0.55) + + # ── Arrows ────────────────────────────────────────────────────────────── + # iPhone ↔ mio-server + dashed_line(sl, CX[0] + 0.78, CY, CX[1] - 1.1, CY, color=BRAND_LIME, w=1.5) + flow_tag(sl, "Socket.io /v1/updates", (CX[0] + CX[1]) / 2, CY - 0.22, + fill=RGBColor(0x10, 0x20, 0x10), color=BRAND_LIME, w=2.0) + + # mio-server ↔ CC server + dashed_line(sl, CX[1] + 1.1, CY, CX[2] - 0.85, CY, color=BRAND_LIME, w=1.5) + flow_tag(sl, "Socket.io /v1/updates", (CX[1] + CX[2]) / 2, CY - 0.22, + fill=RGBColor(0x10, 0x20, 0x10), color=BRAND_LIME, w=2.0) + + # CC ↔ hook + solid_line(sl, CX[2], CY + 0.38, CX[2], CY + 1.0, + color=LINE_COLOR, w=1.0) + + # ── Problem highlight ───────────────────────────────────────────────────── + rect(sl, 0.4, 5.1, 9.2, 2.85, fill=RGBColor(0x0D, 0x0D, 0x0D), + line=LINE_COLOR) + + label(sl, "⚠ 核心挑战", 0.6, 5.2, 3, size=11, bold=True, color=ACCENT_RED) + + challenges = [ + ("DeviceId 问题", + "session 归属哪个 deviceId?mio-server 的 session API 绑定 Mac deviceId。" + "Server CC 需要冒充 Mac deviceId 或新建独立 session 体系。"), + ("HTTP API 替代 Socket.io", + "hook 不支持 WebSocket,可以直接 POST /v1/sessions/:id/messages。" + "需要 hook socket 改为 HTTP 客户端。"), + ("无 Mac 时 deviceLink", + "iPhone 配对的是 Mac shortCode。Server CC 模式需要建立新的配对机制。"), + ] + for i, (title, desc) in enumerate(challenges): + label(sl, f"› {title}", 0.6, 5.48 + i * 0.78, 2.5, size=9, + bold=True, color=BRAND_LIME) + label(sl, desc, 0.6, 5.72 + i * 0.78, 8.8, size=8.5, color=TEXT_DIM) + + return sl + + +# ═══════════════════════════════════════════════════════════════════════════════ +# SLIDE 5 — SSH Relay Architecture +# ═══════════════════════════════════════════════════════════════════════════════ +def slide_arch_ssh(prs): + sl = blank_slide(prs) + rect(sl, 0, 0, 10, 8.5, fill=BG_DARK) + rect(sl, 0, 0, 10, 0.06, fill=BRAND_LIME) + + label(sl, "04 架构 B", 0.4, 0.25, 9, size=22, bold=True, + color=TEXT_WHITE) + subtitle(sl, "SSH relay 模式 · Mac notch 作为 SSH client · 远程 CC session 映射到 notch", top=0.75) + + # Draw the topology (7 nodes in two rows) + CY1 = 2.6 + CY2 = 5.0 + CX_LEFT = 1.0 + CX_MID = 3.8 + CX_RIGHT = 6.8 + + node(sl, "iPhone", "CodeLight", CX_LEFT, CY1, + fill=RGBColor(0x15, 0x15, 0x30), w=1.4) + node(sl, "mio-server", "Railway", CX_MID, CY1, + fill=RGBColor(0x0A, 0x20, 0x0A), tcolor=BRAND_LIME, w=1.8) + node(sl, "Mac Notch", "MioIsland", CX_RIGHT, CY1, + fill=BRAND_GRAY, w=1.5) + + node(sl, "SSH Relay", "127.0.0.1:9871", CX_MID, CY2, + fill=RGBColor(0x30, 0x20, 0x10), tcolor=ACCENT_BLUE, w=1.8) + node(sl, "CC (Remote)", "SSH 远程机器", CX_RIGHT, CY2, + fill=RGBColor(0x20, 0x0A, 0x0A), tcolor=ACCENT_RED, w=1.7) + + # ── Arrows ────────────────────────────────────────────────────────────── + # iPhone ↔ mio-server + dashed_line(sl, CX_LEFT + 0.73, CY1, CX_MID - 1.0, CY1, + color=BRAND_LIME, w=1.5) + flow_tag(sl, "Socket.io", (CX_LEFT + CX_MID) / 2, CY1 - 0.22, + fill=RGBColor(0x10, 0x20, 0x10), color=BRAND_LIME, w=1.2) + + # mio-server ↔ Mac Notch + dashed_line(sl, CX_MID + 1.0, CY1, CX_RIGHT - 0.85, CY1, + color=BRAND_LIME, w=1.5) + flow_tag(sl, "Socket.io", (CX_MID + CX_RIGHT) / 2, CY1 - 0.22, + fill=RGBColor(0x10, 0x20, 0x10), color=BRAND_LIME, w=1.2) + + # Mac Notch → SSH Relay + solid_line(sl, CX_RIGHT, CY1 - 0.38, CX_RIGHT, CY2 + 0.38, + color=ACCENT_RED, w=1.2) + flow_tag(sl, "SSH tunnel", CX_RIGHT + 0.1, (CY1 + CY2) / 2, + fill=RGBColor(0x20, 0x10, 0x10), color=ACCENT_RED, w=1.0) + + # SSH Relay ↔ Remote CC + solid_line(sl, CX_MID + 1.0, CY2, CX_RIGHT - 0.88, CY2, + color=LINE_COLOR, w=1.0) + flow_tag(sl, "local 127.0.0.1", (CX_MID + CX_RIGHT) / 2, CY2 - 0.22, + fill=RGBColor(0x15, 0x15, 0x15), color=TEXT_DIM, w=1.6) + + # Hook → SSH Relay + solid_line(sl, CX_RIGHT, CY2 + 0.38, CX_RIGHT, CY2 + 0.9, + color=ACCENT_BLUE, w=1.0) + flow_tag(sl, "hook socket", CX_RIGHT + 0.1, CY2 + 0.65, + fill=RGBColor(0x10, 0x10, 0x20), color=ACCENT_BLUE, w=1.1) + + # ── Key difference ─────────────────────────────────────────────────────── + rect(sl, 0.4, 6.6, 9.2, 1.4, fill=RGBColor(0x0D, 0x0D, 0x0D), + line=LINE_COLOR) + label(sl, "工作原理", 0.6, 6.68, 2, size=10, bold=True, color=BRAND_LIME) + steps = [ + "Mac notch 主动 SSH 到远程机器,建立 port forward:本地 9871 ↔ 远程 9871", + "远程 CC 的 hook 连接到本地 127.0.0.1:9871,数据经 SSH 隧道传回 notch", + "notch 把 session 数据通过 Socket.io 转发给 mio-server,iPhone 正常轮询", + "iPhone 发消息 → mio-server → notch → SSH tunnel → 远程 CC", + ] + for i, s in enumerate(steps): + label(sl, f"› {s}", 0.6, 6.88 + i * 0.28, 8.8, size=8.5, color=TEXT_DIM) + return sl + + +# ═══════════════════════════════════════════════════════════════════════════════ +# SLIDE 6 — Comparison Table +# ═══════════════════════════════════════════════════════════════════════════════ +def slide_comparison(prs): + sl = blank_slide(prs) + rect(sl, 0, 0, 10, 8.5, fill=BG_DARK) + rect(sl, 0, 0, 10, 0.06, fill=BRAND_LIME) + + label(sl, "05 架构对比", 0.4, 0.25, 9, size=22, bold=True, + color=TEXT_WHITE) + subtitle(sl, "4 种部署模式一览", top=0.75) + + headers = ["维度", "现有架构\n(本地CC)", "架构 A\n(Server CC)", "架构 B\n(SSH Relay)", "架构 C\n(Hybrid)"] + col_x = [0.4, 2.2, 4.1, 6.1, 8.1] + col_w = 1.7 + ROWS = [ + ("CC 运行环境", "本地 Mac", "远程服务器", "远程机器 SSH", "本地 + 远程混合"), + ("Mac 在线?", "✓ 必须", "✗ 不需要", "✓ 必须", "✓ 必须(notch桥接)"), + ("iPhone 连接", "mio-server", "mio-server", "mio-server", "mio-server"), + ("Hook 目标", "本地 127.0.0.1", "mio-server HTTP", "本地 127.0.0.1\n(经SSH隧道)", "本地 + mio-server"), + ("DeviceId", "Mac deviceId", "需新建体系", "Mac deviceId", "Mac deviceId"), + ("Session 存储","mio-server DB", "mio-server DB\n(需改造)", "mio-server DB", "mio-server DB"), + ("APNs 通知", "✓ 支持", "需新增机制", "✓ 支持", "✓ 支持"), + ("部署难度", "★☆☆☆☆", "★★★☆☆", "★★☆☆☆", "★★★☆☆"), + ("适合场景", "日常开发", "无 Mac 环境", "远程开发", "多机器协作"), + ] + + HDR_H = 0.65 + ROW_H = 0.65 + TBL_TOP = 1.25 + + # Header row + for j, (hdr, x) in enumerate(zip(headers, col_x)): + fill = BRAND_LIME if j == 0 else RGBColor(0x18, 0x18, 0x18) + col = BRAND_DARK if j == 0 else TEXT_WHITE + rounded_rect(sl, x, TBL_TOP, col_w, HDR_H, fill, corner=0.05, + line=LINE_COLOR) + label(sl, hdr, x + 0.06, TBL_TOP + 0.06, col_w - 0.1, HDR_H - 0.1, + size=9, bold=True, color=col) + + # Data rows + for i, row in enumerate(ROWS): + y = TBL_TOP + HDR_H + i * ROW_H + fill = RGBColor(0x0F, 0x0F, 0x0F) if i % 2 == 0 else RGBColor(0x14, 0x14, 0x14) + for j, (cell, x) in enumerate(zip(row, col_x)): + cell_fill = BRAND_GRAY if j == 0 else fill + cell_col = BRAND_LIME if j == 0 else TEXT_WHITE + # Highlight cells with ✓ or ✗ + if j > 0 and any(c in cell for c in ["✓", "★", "★★★"]): + cell_col = ACCENT_GRN + if j > 0 and any(c in cell for c in ["✗", "★★☆☆☆"]): + cell_col = ACCENT_RED + rounded_rect(sl, x, y, col_w, ROW_H, cell_fill, + corner=0.03, line=LINE_COLOR) + label(sl, cell, x + 0.06, y + 0.05, col_w - 0.1, ROW_H - 0.08, + size=8, color=cell_col) + + # Bottom note + rect(sl, 0.4, 7.65, 9.2, 0.03, fill=LINE_COLOR) + label(sl, "现有架构 = 架构 0(已上线) · 架构 A/B/C = 待实现", 0.4, 7.72, 9.2, + size=9, color=TEXT_DIM) + return sl + + +# ═══════════════════════════════════════════════════════════════════════════════ +# SLIDE 7 — Implementation: Hook → mio-server +# ═══════════════════════════════════════════════════════════════════════════════ +def slide_hook方案(prs): + sl = blank_slide(prs) + rect(sl, 0, 0, 10, 8.5, fill=BG_DARK) + rect(sl, 0, 0, 10, 0.06, fill=BRAND_LIME) + + label(sl, "06 Hook → mio-server 接入方案", 0.4, 0.25, 9, size=20, + bold=True, color=TEXT_WHITE) + subtitle(sl, "把本地 CC hook 的数据打入 mio-server,有 3 种实现路径", top=0.72) + + options = [ + ( + "方案 1:HTTP API 直推", + ACCENT_BLUE, + [ + "hook socket 改为 HTTP POST", + "POST /v1/sessions/:id/messages", + "需要 auth token(deviceId JWT)", + "支持批推,消息去重(localId)", + "实现难度:★☆☆☆☆(最简单)", + ] + ), + ( + "方案 2:Socket.io 客户端", + BRAND_LIME, + [ + "hook 实现 Socket.io Client", + "连接到 mio-server /v1/updates", + "实时性好,支持 ack", + "需要 WebSocket 支持(部分环境受限)", + "实现难度:★★☆☆☆", + ] + ), + ( + "方案 3:Webhook 中转站", + ACCENT_RED, + [ + "本地保留 hook socket", + "MioIsland 转发到 mio-server", + "兼容现有架构,改动最小", + "notch 需要在线(Mac 要开)", + "实现难度:★☆☆☆☆", + ] + ), + ] + + for i, (title, color, bullets) in enumerate(options): + x = 0.4 + i * 3.15 + # Card + rounded_rect(sl, x, 1.3, 3.0, 5.5, RGBColor(0x0D, 0x0D, 0x0D), + corner=0.1, line=color) + # Header + rect(sl, x, 1.3, 3.0, 0.5, fill=color) + label(sl, title, x, 1.32, 3.0, 0.46, size=11, bold=True, + color=BRAND_DARK) + # Bullets + txb = sl.shapes.add_textbox( + Inches(x + 0.15), Inches(1.9), Inches(2.75), Inches(4.7)) + tf = txb.text_frame + tf.word_wrap = True + for j, b in enumerate(bullets): + p = tf.paragraphs[0] if j == 0 else tf.add_paragraph() + p.alignment = PP_ALIGN.LEFT + run = p.add_run() + # Color last bullet differently + if "★" in b: + run.font.color.rgb = color + run.font.bold = True + else: + run.font.color.rgb = TEXT_DIM + run.text = f"• {b}" + run.font.size = Pt(9.5) + + # Bottom recommendation + rect(sl, 0.4, 7.0, 9.2, 0.9, fill=RGBColor(0x0A, 0x20, 0x0A), + line=BRAND_LIME) + label(sl, "推荐路径:先用方案 1(HTTP 直推)快速验证 → 再迁移到方案 2(Socket.io)提升实时性", + 0.6, 7.1, 8.8, size=10.5, bold=True, color=BRAND_LIME) + label(sl, "mio-server 的 /v1/sessions/:id/messages 已支持 batch POST + localId 去重", + 0.6, 7.45, 8.8, size=9, color=TEXT_DIM) + return sl + + +# ═══════════════════════════════════════════════════════════════════════════════ +# SLIDE 8 — Session Lifecycle +# ═══════════════════════════════════════════════════════════════════════════════ +def slide_lifecycle(prs): + sl = blank_slide(prs) + rect(sl, 0, 0, 10, 8.5, fill=BG_DARK) + rect(sl, 0, 0, 10, 0.06, fill=BRAND_LIME) + + label(sl, "07 Session 生命周期", 0.4, 0.25, 9, size=22, bold=True, + color=TEXT_WHITE) + subtitle(sl, "一条消息从 CC 产生到 iPhone 显示的完整路径", top=0.75) + + STEPS = [ + ("1", "CC 执行命令", "Claude Code 运行,产生 assistant / user / tool 消息"), + ("2", "Hook 捕获", "PreToolUse / PostToolUse / Stop hooks 读取 JSONL event"), + ("3", "发送到 mio-server", "HTTP POST /v1/sessions/:id/messages 或 Socket.io emit"), + ("4", "Server 存储", "db.sessionMessage 表,seq 自增,localId 去重"), + ("5", "Socket.io 广播", "eventRouter.emitUpdate → 所有 linked devices"), + ("6", "iPhone 轮询", "GET /v1/sessions/:id/messages?after_seq=N 获取新消息"), + ("7", "iPhone 渲染", "CodeLight App 显示 session 列表 + 消息内容"), + ] + + for i, (num, title, desc) in enumerate(STEPS): + y = 1.25 + i * 0.92 + # Number circle + col = BRAND_LIME if i % 2 == 0 else ACCENT_BLUE + circ = rounded_rect(sl, 0.5, y, 0.5, 0.5, fill=col, corner=0.25) + label(sl, num, 0.5, y + 0.06, 0.5, 0.38, size=14, bold=True, + color=BRAND_DARK) + # Content card + rounded_rect(sl, 1.1, y, 8.4, 0.78, RGBColor(0x0D, 0x0D, 0x0D), + corner=0.05, line=LINE_COLOR) + label(sl, title, 1.2, y + 0.05, 3, 0.3, size=10, bold=True, + color=TEXT_WHITE) + label(sl, desc, 1.2, y + 0.36, 8.2, 0.36, size=9, color=TEXT_DIM) + # Arrow + if i < len(STEPS) - 1: + solid_line(sl, 0.75, y + 0.55, 0.75, y + 0.9, + color=LINE_COLOR, w=0.8) + + return sl + + +# ═══════════════════════════════════════════════════════════════════════════════ +# SLIDE 9 — Next Steps +# ═══════════════════════════════════════════════════════════════════════════════ +def slide_next(prs): + sl = blank_slide(prs) + rect(sl, 0, 0, 10, 8.5, fill=BG_DARK) + rect(sl, 0, 0, 10, 0.06, fill=BRAND_LIME) + + label(sl, "08 下一步行动", 0.4, 0.25, 9, size=22, bold=True, + color=TEXT_WHITE) + subtitle(sl, "从零到一,渐进式实现 Server CC 模式", top=0.75) + + phases = [ + ("Phase 0", "验证现有架构", BRAND_LIME, [ + "Mac notch + mio-server 配对成功", + "iPhone 可以看到本地 CC session", + "APNs 通知正常", + ]), + ("Phase 1", "HTTP Hook 直推(最小MVP)", ACCENT_BLUE, [ + "改造 hook socket → HTTP POST", + "POST /v1/sessions/:id/messages", + "Server CC 机器持有 Mac deviceId JWT", + "iPhone 轮询验证 session 出现", + ]), + ("Phase 2", "Socket.io 实时推送", BRAND_LIME, [ + "升级为 Socket.io 客户端", + "支持 ack + 实时 phase 通知", + "支持 APNs completion/approval 推送", + ]), + ("Phase 3", "独立 DeviceId 体系(可选)", TEXT_DIM, [ + "Server CC 拥有自己的 deviceId", + "新建 DeviceLink 表(server CC ↔ mio-server)", + "iPhone 配对新增 'Server CC' 类型", + ]), + ] + + for i, (phase, title, color, items) in enumerate(phases): + x = 0.4 + i * 2.35 + rounded_rect(sl, x, 1.3, 2.2, 5.8, + RGBColor(0x0D, 0x0D, 0x0D), corner=0.1, line=color) + # Phase tag + rect(sl, x, 1.3, 2.2, 0.45, fill=color) + label(sl, phase, x, 1.32, 2.2, 0.42, size=10, bold=True, + color=BRAND_DARK) + # Title + label(sl, title, x + 0.1, 1.85, 2.0, 0.6, size=9.5, bold=True, + color=TEXT_WHITE, wrap=True) + # Items + txb = sl.shapes.add_textbox( + Inches(x + 0.1), Inches(2.55), Inches(2.05), Inches(4.3)) + tf = txb.text_frame + tf.word_wrap = True + for j, item in enumerate(items): + p = tf.paragraphs[0] if j == 0 else tf.add_paragraph() + p.alignment = PP_ALIGN.LEFT + run = p.add_run() + run.text = f"• {item}" + run.font.size = Pt(8.5) + run.font.color.rgb = color if color != TEXT_DIM else TEXT_DIM + # Arrow between phases + if i < len(phases) - 1: + label(sl, "→", x + 2.2, 3.8, 0.15, size=18, bold=True, + color=LINE_COLOR) + + rect(sl, 0.4, 7.25, 9.2, 0.7, fill=RGBColor(0x0A, 0x20, 0x0A), + line=BRAND_LIME) + label(sl, "关键前提:Server CC 机器需要持有有效的 Mac deviceId JWT(从已配对的 Mac 导出,或新建 deviceId)", + 0.6, 7.32, 8.8, size=10, color=BRAND_LIME) + return sl + + +# ═══════════════════════════════════════════════════════════════════════════════ +# BUILD +# ═══════════════════════════════════════════════════════════════════════════════ + +def build(): + prs = Presentation() + prs.slide_width = Inches(10) + prs.slide_height = Inches(8.5) + + cover(prs) + slide_overview(prs) + slide_arch_current(prs) + slide_arch_server(prs) + slide_arch_ssh(prs) + slide_comparison(prs) + slide_hook方案(prs) + slide_lifecycle(prs) + slide_next(prs) + + out = "/Users/toby/Documents/Projects/MioServer/mio-architecture-comparison.pptx" + prs.save(out) + print(f"Saved: {out}") + print(f"Total slides: {len(prs.slides)}") + + +if __name__ == "__main__": + build() diff --git a/mio-architecture-comparison.pptx b/mio-architecture-comparison.pptx new file mode 100644 index 0000000000000000000000000000000000000000..05f2da2aed16f8b31dc21db3d6fee1d68571f4ed GIT binary patch literal 50361 zcmdqJRd8fWvZgC$W@ct)W@ct)X68~Ug(ayZm6(~CnM%ye%*;|r8tvU@?wsyBW@pU$ ztsipPTKSaA!~OgJ@Q6qySx_)EARr(ppaVpiv|sAk?pKbwwQ=+{_)^ z4As1y%w6>vyzK2JevLV-F(HXRy~7CO(!)e4tQJ`tb4Lki_=8Ek0!Onot*DUoVz=LH zd6Eel6-b+L5Rb;dN3uQ<#6-i+Bu1@iSkhjXRFp<0y6Bl1*{o5qR!0&pWlJ83hm*;Z zHnJ1hRHo23 z|C|K9^)%eHF`!Al)f5`w3~y~&x-SlW!Pi{yn13I7=O0d zzTmT~xpeM>p#=9mlmM9(O~kkQWS!#@ zFF~VpPa~Qu2TL`e{C!U$TvQL1x=HcdEsJQb{d3}8oupfHXNN?=!l5C~WOa662Z}f% zah@2=*@ZidNCD=z z>$0B!KZb8A@o-YemU66#?+$+O@n__(4`8K#N{JhI&U2uzl-L0Q0)qWY2}2iiJ6A@A zzpj-DQwpHONZ+pnz*k_48P;r~nazEfDLS!L35~)|7~F%Q<>cQU^8kT1P4bBReSe-0 zagDl`UxYhMM;RdKi`_}VAy6w}Gm{z1hetBeQi-*&OoZs4ejVg>rU~u==}qo$Dr117 z)K&Rdv}w|$6Y%fzpoFVv5AO$$vA9R}bO?X9S!8Rbc{f+P09K`|teTxwpsig+&FYUm zYh-;!npia5j>q^!`36@ix`>WUEAwmYP_vC;UnT~2ViJ=_pyKwoQLwOc_d_-C`n9cT zzPC(mSb)t#cnvRwo4+z*J29Q7m zybFe+5+$@A-6;epo%?Z7PkyXKAq#OdvC)FC3Zr32e22C7WoonK7jz1bj zYfl><&;ZUH9asnQn=IC{0K_bWn3SH01=P7g5=MY%VkjA3$O(j8EoT~;r)d9Jye5ga zb((rBBWYl$NBT?{Z=IJC(o}zG&u3NJKS0KSkPu@-m9%uVj4S$9?HY_cS*T61+4~bH z_PvLmX?_v0Ye3P;#{SzGIdH75^)w+VqfoFp6v;AIU`!8N%OVX6geUdOfxR@j>+>;U za{`e=nqP3ux9w!=g@XO+bqB5G(Z@^f3%vecm%8VPuRtFU(pw9{-l?0L^jDHEWJPmS zMKk*@*oNfye7W__92;@(q&2IM&w@83t-k_E34rtiE_c2)#azYyc_F-G{O5$unvV6p z`Wh#DFd(2W%>AEnGInzMYnU?CdSmC9kUB|k5R(<1tP(}ODIHjV>sR{Zu&LCKdDl?J zd^447I&paMp@1GKQX&>sxSHVW#GYD4=2UDnro0#CZ9k0i9>vx<`Pnqg-pzPvam5Xb zTmiC2SM`Do+C5^>h1BW^|60P#R99m>QI3_ao&ct83~#JpEt@*~fbl&>QZ;X$cJ_m{)a`m1*&M8-2yhA5A|ly8@*1A zj30?GZO95bz9{~p;hv#|r4C=pJ|7l+Y=iW59T{~*O?IzG=z!N_$vx%bf=(*VY9-et z?tH=J;jvMw3Q8P-AeJDl%+|bbpx5e~{yip2w*EONKay@G+N$LSIA;6C&~cG0oDv=x z*7~$p9g3V#9Ra9G)O3{qiYC-Dg8PKxHv95#Uu{JmyEG(! zjp@GMzWewYqeWVbYn}Jo)qR`gy!fECh+y2SBeVZwVSG)#IkjnUYJO z*>0^@3>57G5xJX==pIHp5%wlxxSE_sQ)oCdl=j`<-in->k(`%z&6V(Iu1k)!qjkT2 zkSx5f=xf}(zwfcEz-&!v4}mf~Mj*v1K;J*}ctX0s6 z$yWi_!2<#PC5ui@Zj4SY=B{5FXzXU~=Ho<}qOjT|H4tIkqfwDG3RYD%908 zuMEhKAM*)83M9!<7gSbF&N)V@mzgDa16^88Jsn9~J3ac9mAHa2jHa101l;OFY8haX zubE%2Suo4!OG@>@h}c4;`aDFP( zRP98l0HPhiqqo&v$rNdN9It0n=|gd1HVa#?^8TC6_MePgGU*U0#{Je{I6wwy&9NDU zQ6re~?5y&a@yBoF6 zucwKW$Z##V3j~#8Xf4g_%{S0tkQZKvbYotNw7Qa&rSloH-s-EZH9j7D zlcj=q0|_w@VImS?X2xn(_P4(<*@P%esP}DC9{&l;%pR-7`=eKYC8&G^`5ihIQdpB# zVtN_nCxkT*RGSWS$gYACQbDcY-}fl8N+{p@@%`&fzzQ>o>@8ag95teI0OV8zl>OxX z^|JvZkAlpdZ-F{@pZ^RjE$B5%^)Fy$LjeI1{#Q=^Lxuk}GyhiQ&P7GVP$o2>>-Ovr zryVlz$GFs9h9NUZ)V)K<mbZLvyRb_l#PCS9IsU+gIOJ?-yy8WMwwd`S-C|+mot(Sjh2Z{nJ<#2`}o9d zTq2_GRAf%Pu1ICMLkQ~PWb_8s;ADV2ZsiNSfX$H;`_XaDbe6}?4eh=_m#F?=bSK8h zW{4JSu8`xjq-&{0VDmW@_27{|8a4GK2=)QlaP=rDbfs9$kPf|Ax*q<84-~Jkdrni& zhJ65C;$up3vtbWtWAn5&u?(z^C!}s-IA&J>lLw<7uwnldk&oe_0=%1H|6+!%RZ<@M z01@obRq0a#gK`7TiBtrbD4nE;9FrJcOu?@_m?wP?EoM=4g>;&{U->Y_F{sLPO5f%t z0aPk#I}Pq6kq2=hZXf6`l>a;;dKr>vVqYTy_%Esd-<0@U@BczXW5U>9QZN1_^+$BS z)0S8IP9>4XIwWi$1l%tuiL0{knRv~gXrLQa1O|;`qb7akc$3?ct^7e6ZZjBrENL8A z+i074r7U*L9w96H(g=j_tmOPShit4Dw%$m8eY3)?2){_^*4zLB2) z9CAxmikS4TkcWW&!(IIqat~{B&;KvvF{-@^dtb6$Js_yn6`BYo0!VWab`sVp2=BzT zOvB-BI&hX9Te>21u!iM#O6T134saaG+H~~kUqn*k)xG;evj*4#>efyvYt(TL{LTC(tEGRyHU{A$&z5ZpZ<^@QK&Gf01jFkC<)RSFu zj$=&y)1r_=G<@oJ3%-=ZGYW(p-we1_wlOkxz$}*ID9PS#%OaOx@@3VS3mqVv&&mxJ zvgEKxJ|Z`%%UMylnY|dE{Q2nuUrnsdL9Nika*1U;iLGX856D7QdnX*8mZ6H9G>s3} zT*=3mg1+n3?;zx|!8|?9@Q~BHcf})uO6q7{*twYmbPB86M52?;WHlT%JHuPjCaPn7 zf&yQ9WW+4+Dp(9}ipZKL^M^ZfO|U#4&gu+TH#Vi^QFM0E6z-J7+=}P$Zbew^BHNS7 zjI3p3n&SyGQt;|!|3E_5)Vbh4k7Oc@y*n`k5YQ3+|B+ZXD|35u#=o!3e@m>kyyF@N zRu|%OAd#D+`HIFVn0w@M?OLTgE}6r30&76>vQnPVth68Q>Ru3Vf17a%aQg+59qlwo zyIVJf?)OU2SIo!LeirjNz?_9{@FHY-LDth-Xo26us`J1!F>=Cfi#>OWEe1*Lus;r@ z{n=$sV5vEAsnfaXkm+m_g_zC~hv1)WnoT8Q02Iipmcw=vI!%(@a?`yL_ShI7F*6^o zGqN$bD)Mqyk7N(0iASe~Zat}-DeNE@NRy`o=_xXsl(tT~>LEXbVQs=m$nZc{iu61+ zOGV&zfuIXXyIhXw_$tQlo0pj&JX%<0I&?lO-Ntj%m2}OEgosk|1{{6CH#9f!;6SCR zV=*Q==*$4u_fe0L?yNh*fd`*Xhbih8bD(o?qtf~y`$t^`%TZO(gMRVNjWk_bjT@ZL zA&`L1xs#SNbU)K@M}}?P#>PX9&Jf4R>&v{4%t{B($`DV>=SFb9&(T+%`p79E;{2Ol z5S|!I>{Jw%-ScqdO%N~JG$7WWmFAiXgyrU~7(0-9_rO2EF==r~*`0IE;}an)1Z`ru%gQL(KvL(4#P-F#5fPQ?bBC?BqqF}?x^E2po{SkMdxQJ z7O>5%BPT|-apwB|>hJ2OP)4Ul<7XmvG@f+d=MX#&=aPx$nd6$TB^&{jQjYd$WufUU zM^Bg}M&L5Qx%B;6a<}lPj}vwXCY>Gqt&|L^dT}o-h6xZwVQ9UC9JBRwndJMFoHMkW zdbK>}bm!kT_Dyl?gQGKM_=X{P)N8A7%k#vuR+h2+DC%mW-ox(f`(?Cz!#$WlUi3vd zG-1JXF!Gh4nS`p>jAi?#_D0==P>Mex3U;|vKVL})cpa2wxoitg9~e);U;;oZ8i$fUfmv#w_x5I}9m(B~tU6vYMeJPSnHw9`r))D(y7iGB({i zih20H6I*c0%B-kxvEJzC(k)krxtc#w#4a{E(IV%l{dE@EbI+O`Ct0$9C}$V@-jPQW z_SzO6QAvB-jmmb6Tc_D32`9S{&CY z4Cj6hfv4?uwHFnn!XC|qXK!Q~VPawxa<4YEC=q{W_!%~T2D|*ks-1%wQYWP@Lg$a2 zc`Aco=}&tU&)G_(=F27&B2uiHOJt^r?vd3yUV65+_fvVsQpVjp;S*tm*f_MoXG!Ia z9!;TLdM5&olaOsqO}{_q6N6AMt-A4M9E^Rc{)N4$~}!^Q0t*FWuL_*^Q$E)_n9LjE*b zQn$Y#{7 zblz~bZZN8Wf%R_MD&%uk^mZ*r(>$9lvPyS?$ai%WiLjT^ql47JP&c*iD3(2W0hfmW2@IHS`pZ9Yk15f$I6N4LPeVF0_`u)6fM7)%}Z57SZRi zUw#SYECUb_@qfLQtDUu(xxBHfo4JcC<9~bjhid9vIqY$udJ(i>Wg5(e0jL#oRabCybT(W# zljDvSQVNe3oSv`_8!XD^;sRVZn=OE2>h$z7Xr7Si*~xX0yTYjaQx2FInr-=W1ATSE0X zoiNzOE)IQfGOnUd*kqTzlN5Tp_9Kf= zxlNEs@0MB0Bu@9rIxwP3l>^=?sj5e1 zb2Xoxe5r*fA{r_)7?yi9K<>r*59bL_g6nTy#^J0#9cVC4p)YHNM3hp2SUv+_9a#dKzpXLDa%GAqsW-YHP6!WWoXNRZ=)0uVc=xJCpgWr9&UDf+M(!8Y z-GbPuR+Y%>JI_CsTXHw=`$L-)qS)f5lt>qsOEqrL{t64K;lTXHb)E12DF!tdytaJd zz3jQ+erDZ`mh>kX9aa_|qR9v_A1vg^NTXekgHfrnKt*;HF78v!MR^TIDcsllbSL81 zK>s074dHQa3}-9*p2yxuKa-hSF(D;D(;@*lg#L}a3_#OK!P|YaqmHs(v{GX%E%G9- z-siVmJy&_RsssAQ_06wYd-LsPw5jYHp0mWAy4LSG?eYU7|&Ad&?U z6~Ym8$jOb#Wa&8YB@-o?~tzE*;Z&D{}A?}34m{i#ur7>M(jiA{* z-7)hLSy$C6Ylsowe=lXP*uH(6leggSI>?H|AGJh6*P~6ZD+SSSom2go{(;6PJB)p# z8!LQUC4fIdm%>g?cA?Q`QnjVS9jh&kh%0{TEGdk?p_+9{^oofcJ2|-#ala{UDa;tj zbOJwh)4pjHA(_vbGO&mIvBJR&98XaXj%!(8Xtc#>OO=&~mK7>8e76?%t1NM+OEu)z zPb|!DG$9)WJ#SKx)I?Rre~uDhv~s24J_i9izEFah*vfJr#jO2!O3KW(vQfgPDx-hYayuJTwI_ce?G73-W$UY%k1JhZn(GvqkOAyV|-_jBs;N$ zfe9hN6vmJd@^`${n`qI!xy6QE%O8#uyWbz~inpGWDl;!9@cSUA9VHf3%#40JVL@7< zef7o=<9wg_5PU87g6@X~hP~LLCnY}Gv(;I9``^WwHY~le(-6LWC%mV^&?bp2`!&ib zU2Bzwub};BKup`LZEL4heojm@><8|!u~bLP2@vp78nbc7G!}!L`n5qTL8Q5cqWt$*vZo~6*%ph#m^y6M_$Fym+@cvo~V$~fM-9E@< zpid$wXBa?zYXE;ZNmHm2XrlBfDQ_!b@rg$qjJfPCB{13S=$prr5lfz8Nbx3F@awE6 z7hPDI%O5MCPAo@hP0OScMDzCNzEm@&x*~cC2mAp?Usj!;vj0(JO zv3uldWA3$@TQ29INGOZ%Z1k!2ZJd8w0mWo2X$itd%h$qAK2R#^Rp%FF@{Cw|6qIsn zHfCZlBDIYXK*c_2WThCJ$$+5^&JKQmnX7)JPFc+?FQp%3+KQw(~e=1dkg!$q%{* zX)KGkX@Uoc&}A~OifJ3h{#bb$7WtCg}{AJtttuyxa=Pm<*HXsWbq?M5iHn~0fQQbIr0 zoNdR1Lsi$`6SX~d7Z^a9y?5e@GXr0N+prsE&MlmSLl3#&d>i1ctq#V`_!BK(qh|yz zHsiE-4>!8+JI)f~P%cs)-f-vBp-`!<&#$nt24Hjt9ec{1{zZ@Zh(l2~p(sAa($1-# z2NKjw^5I81Y>AYW9>Y5KH3WFRvqPTXTSOftPJ}P@tf&?kfLRMZ;u^c0!i>|x&@p<| z(kS?e&b-1?XosI@uQJNPyuwjvhntv0ZEh~tBul+EXr?Zsjh=X+I{L_rJ(d{I=sln8 z0Vj3@^tL3tElN+PwnZs;RvpSPRtN&X`k{cfL5YHS@*;WJNl$DNvttDX;~6SFL_$8=IuN(7=l66 zN$?l2Y4ZN8y|5NCar4HzLGHU36J;~E(Ry`h+Jzvf(Fl-N7w!2v(ef$ba_}S|Qwu^B zm9?~3`f2eA*umq1(IZ^gRIl1_&MJ3U6f@T3{K%h7*90O_(M)E$$WY;jZuxS*Dvm-_ z{52~BQ6DM-&@3L;9`c36t7df2V4E3Nnbzf4IG?RvRXt@T(!WtXpwA1Z*B)qDl?T=m zfTB>@LewA4*u6NBaOiGkMhtC$csVL(s*7Hl^JKZ}pn0YkNa`&<@x4(g4-zw1iY6X0 zd@gtwON9I7j<#OI`iU8rf z4lyM+YY$r;;M+P~A>4+6v63*2N_e7YWie5im`_%|Q#vCQl@Veht(l_JS-G;nNGO!S zB43z9bWg7#d5b`kWMf405iQ6A!G<={1Su#gfMW)gF8>n`bvumgBHGE(!Q0j1`c*=Y zhOZ^)K4xV3vXd@3yZZeq^y~5YH6MrGw=a{gy*+QiwYhr;`PtTXl}U#6nW3L^RugN_ zbcoSI8#a)#ZI}$)`Pkp5(DH3ZCxf0C!m=4*odtY8MecuAhv{U&Um3y}(kbK_`bNEM zV5FmnBYJ3j;_%Xb&md31jh^HiRupFN|no`s=3OOwvvn| zT)vs_u1{Q6OaP$5%?*6cW|namltm^K4bI6-e@_x1n{pi-+yrWWx0DR(dEkfUQB;|H zU|9DY><*`eCCAbI&_f2Mb({P%d~}IoSye8zOwD2{t}wHv{<0&LvW?RwBndrDS$Gp5 zGDG2z7CtvJ?U4cXy1pEDo(p+^`#tywo-`0?@TAJBaIyo$<7n};xjOLk_2NuWvqZgP zOSli<#HjB3T_Z0@Y}X6RM;dLd_N=%`5Fg=O8VAAy=iXlskS{&k{g%h8yhLaolIHRB z)M5Km|5LnN4Z(i-+OE7p z>!^__Hh;Vt+ea3#9qE~b+dLaiFaCCg+I-7)e)|`!2AS_fG=J4S#}``Z|8MpG->~}M zDeyN}W2QP0`iPJquQ!T`=;TDsjH0P8OyuzNq6K=utLSUN`*LTRQ)Va-VxJz~j?ymK z70&?xy=$HMIuy80=*1V|sI`ZdO=t=1j&Ian^Sd!*Z5|7HP9b^@R&McH!hF^Ohj>pi z@z~A~3B-Afi2?=KF4S{5B)@p!N;uRT)lb&8FZ#?V%e{G4((2E6cLhC;eJ!t%#!V%% z1I4J)G6q?dhSapAKrJQ;|6DRA+h~)t*x)vLHNOM>GXa6#&*5plmWsi@*7W~r7WRK5 z;9oP%2`&BDH4dbJD{82RUPYJ(QRxO`N28JQydDcrWJiRaBr9Dg@-niEWonRpoeO7| z1eKp=-hrz;L7>lrc_pbkoOcsH@Vfn*X|b18z7Qxcd5LQYW|ef)QKB(7GyHvih?M9b z2rSO$Ps;by?Cz+fOgP{)o8pAALQTr}QQD|cmL)`0&rT2U>$lZ8ag=3{$-6Jt>ec%( zwdyvKbHqhVC#|fV*~KNE&6pxCam2Uwvq7a2Sr}Uxr%MmB@w{@OSnG>{tI;z7Fwz&c zJdJ9(SdBr*sJi?+I-09@cD*i2@hXaZY$!&(kr`=Vjm68!A61R3Smmi;_)){qJDYx!Y8UY$@xFIdr!=NzyB; z4dcV#L9VT4hRQM4Wj*=Mq>0u;HTey+x313 z2b>FsW}2Y!!jHV)&pJlGyh?O|+?SOdXgPAFtz$>9#@^|QM9vvUuj~`Qk>kQ+Tt(Pw zJp3!pxdir_+BCNamJllngwf4l|3o@E^(MJh2ji#M`mYAYGCj?1Y?P)lxkK%to4+e;)b=*x{t-(cU!}Pe}Z)vRJN*d89(55%{V-rj12d*txTgq zc(d|tc>&4^;~7R76vwuKx>JqiWmMKW6&xX(_CMWebQ$hRj$c7Zae7|3p?wroCez)x zEWk5ElL{H|M8^nmN$z3zSwCHw-;gaPapSL@80~SWoZ6`gvj@q!#`u#CR`1JmzhFE2 z7i{lVmKQxWHw1^F)=U+P9q}NtIESeEoPDHpR=fBF z{@P~tKarN@uUBu>m~lzxLGtf5%*{m%AOV3xc$`1M!n<5gZ5Ww9fute|(Ft}VUuj%B zkqaz;&j^tI+KiN0#5(!2i-=gm(sPQS!2iWYSGA^B1Z8nt@*|ZKq-8`~IPLA_c*Fki zFU;D&h-rr&<>M#kYhr$O?6XQo(KeXcg0qQ{T3TJI$=qOvu~a+7>|^h>%xuX}*c9BC zw|xI5M9A(YZ@le|szo}>wGrSv8laI(HY}T5ZM$BAA!Zc$6Twg;Y>Xf#J7fqv8E5EH z073eKD~tF1vJEV!>x8nJO}v01{RXdwRncnkS|hkaf1FU` z?(`LUt)tDa59bkKJ6JVQ9e2I@1Z)x+|3jc$z+j|W>q9ss{KQZ0)!xT9io=riw#?K3 zo2t0D*Dvh*q`6}9Dn-RIHl?ReLzI<7p-w6O;N!RS@9?6>UevQ~9!`5*y4*sGG=*5~ zz9RGDGSVNwR1i+WXkIY|eIUz5_l7yd@VuigX2i~_>qNnu0wNCD`y9stBFn1Jz9hr^ z@tJ8o@Z#K6SoCg4z1cG|kr$yOK+!IVx8awiZ=&HbcJ#fs5ys2Dye=n#a|bX-a-aEV zj!gW(0$vnMbf{DU>Vaate*?J1z1#uV%dAQURUuJN4Z>`PMHfU5wX0g#Qjp%ggpQUv^J-Er}` zWT=zgx0vCAnz)cSw0g{=Rug=-FfEopD5LY;oAMpHp?hdD({#7^zkc23$<6$ft^t%6GXzYvc1+Q|EJBy5#)a{dWWC-xy$Z$|nVxC(}mHNj)2XP9*(XQYQynAx_PrDM)M+DW+P_Z8~> zikGX!jr_s%p2X=Hjl^EQ*j;C_xXv=+*4bq)5l>5uUk}}eh>vZRdJl$I!}v-qur;n- zDscjD%F2;zZMT40!d{0gvB+T2Z`0J_9mb(+ARq$o18$^PT2}`C)X1&iIH^xe#eANr z&Tp`(x3B`~pw`$RnYxwMoNr~XVOoE#$M}<~-ZS(S>M`kVm>-!d*0z0BEG&nx7ql2m zc$;V$+qS(66!dD^Y_k+8y=AgF^35Q2R!z@*Ovj?s%3OuP!bBF3Rb9G4h0<0sM8UqG zqi}e&kMgN%5-fAniQpGr2+35Kxf)xac_nQ(FPi?l04egogH77JGdmT6QCtMN%EK59 z6PmsbF}wsm0kh95s+DMbn&D@m)V}m_BF1gwMtOSC@U& z>wdD%63OgI>Ag-0@zA4hMQRP$zXNHAzh;oZ8G)q4d{7rm&pTl)fjq=A<5l;UG-Y~O zY&K=!6;%h;FbJ~)%GPPIkGP(aU(V4* zsQScs@uZZMNZeq%hjeFkhqccgqU92ZvksXDr|L7>X#O)=KSXEg8L>Mbkhp4`U;%U zwOd?q(eIl9%6a5lUgvT#U*#GOzwEezb4;&5WDrS zdri$>gy|u8yC)A~^tEqNFewwM;rP#3GM+WCNTwo-k7s2}ev+Xci(7{XJm3p4$fJu( z&zjVl>|2nJoAw)nQUW#cdg+63y>E=2nIwqgbT22wMM>6 znJJthcR&4>yA&|ds~&v=?e;3Zjyex0JeJho`-3Q*zHEJ}NEr`qOyzrv^-W{{_kE22 zlye>uxN*j1LNe_bE#Pul@BQ}WO4Cr_(=|j7{B5I}&$p`O*kULWef9@etRE$`DQ%Oo zO|CVXR^t3q#k?!B%&t>HOG>sHrhWchH1bB;XV#N^XH1vP*%*l_d-c&SUHRDMCy>=? zTo}81ZXohdG=)XDjpMz+{(GWbSFGO3R{Lg-p?Jq=MXl=Nxa-nr?6;HQd>T%b_*ukmJ z_EK!FeECVVSD;AxkmkCBB(UI1x1#FCH!lB#q_7y`T<|8nLkQTHInR%{i@V2-vK%An zmfzak=PjBFkUv<=wG9Lic(aQD_#Qz&TE>~o$+0~a#oRXaKOdWLpwc#W*>$CIsAQOX zL~R+&*@Kh{Tvd#m2WeEwd|8KJ=@tw>pjczG(cFb|kGO#8oD6gxyaPtPInocPvFqKG zf2cS}aph{!JPh(xi}h<{_Gvhipv7=^Rww9bnpurddOTehgTuu#F2vgE)6UI7PY3~E z8=3u0h%5@#*bb9RiZYNoX;QlHY*aQ}U3dF$vqJgi;2xx57ddUP*6~jy#? zqA_2~_<%J;$7*|YK1DhuHBbuDCmJ^7k31o3PW~JM;c$R#$12$`NVKLLdc|8XIM zSE|Q!<+YigSOUuwozEOPj<1n$aGcq)JFZuiRt&+vQmmsh;8>2R57aec+~!nSpdyz1 zKy57=_)~fxryNS_Ppu(sA3yuSRvScRCI`*Xj1a%XJTW&V#3EFu7xJVf)fcQRkrb3S zzZpLy8J_TSh5zt*kM%GHlQ!QipV&_C*;LX4S8{O$?2=kxBx6R7dgnb&cMww@{?{ZO zx%EOw;Px7*cKDwx8iYgvhixvO&J?HLC*i@Wlqk3S-Gpx0#fc^2f7om+Itv^sB_u3X zJ9PpX-O}JTfJ+KJfucNW4WcdxdU)EG9iqe;YcI19N(NttYXYL+V~s;G)_B7&8keuJ zIP7ZQO_$+;E+L21R14?)rJK#~q818Sf1{-=moqF&t)ZS|QM-t!XjlX#kmpiP`&!N_ z9&rn1cKTBI_F?EvDi%o|%^uZv3F$W;s_p7RJqvSayVpQ%{ zUh6~0sEUj&p8HnDU%`ywEZ$%*9(sZmM1U^xyJY}0!w#k}tGNF(yJXhrC#|(9r*z0o zi6gJVuVv#fk@l<7`xua|yI11VZ_0J@I=S?WNgjVs%0D|^J}$Ok_i5Jgp-L$GDt|n{ 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 C{p7y@ literal 0 HcmV?d00001 diff --git a/package.json b/package.json index 47798d5..04bb4e0 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "scripts": { "build": "tsc --noEmit", "dev": "tsx --env-file=.env.dev ./sources/main.ts", - "start": "tsx ./sources/main.ts", + "start": "prisma migrate deploy && prisma generate && tsx ./sources/main.ts", "test": "vitest run", "migrate": "dotenv -e .env.dev -- prisma migrate dev", "generate": "prisma generate", From 5801b65eb4906b7862edd27d938b095f713e14a1 Mon Sep 17 00:00:00 2001 From: tobyleon Date: Thu, 30 Apr 2026 22:49:13 +0800 Subject: [PATCH 03/14] fix: ensure device record exists before creating session (FK constraint) --- sources/session/sessionRoutes.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/sources/session/sessionRoutes.ts b/sources/session/sessionRoutes.ts index a5dbaa1..82bb9f9 100644 --- a/sources/session/sessionRoutes.ts +++ b/sources/session/sessionRoutes.ts @@ -126,6 +126,14 @@ export async function sessionRoutes(app: FastifyInstance) { const { tag, metadata } = request.body as { tag: string; metadata: string }; const deviceId = request.deviceId!; + // Ensure device record exists before creating session (FK constraint). + // This handles JWT devices that may not have been registered yet. + await db.device.upsert({ + where: { id: deviceId }, + create: { id: deviceId, name: 'Claude Code Sync', kind: 'mac' }, + update: {}, + }); + const session = await db.session.upsert({ where: { deviceId_tag: { deviceId, tag } }, create: { tag, deviceId, metadata }, From a8ee4e70c404a81b216729ae824a7efa24f19b6b Mon Sep 17 00:00:00 2001 From: tobyleon Date: Fri, 1 May 2026 10:10:08 +0800 Subject: [PATCH 04/14] fix: await device upsert before session creation + debug endpoint - Make bumpLastSeenAt async and await it in authMiddleware - Use findFirst+create pattern to ensure device exists before session - Add /v1/debug/device/:deviceId endpoint to check DB state - Fixes P2003 FK error when sync-daemon creates sessions via JWT auth - Handles JWT-only devices that bypass normal pairing flow --- sources/auth/middleware.ts | 14 +++++--------- sources/session/sessionRoutes.ts | 22 ++++++++++++++++------ 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/sources/auth/middleware.ts b/sources/auth/middleware.ts index 1660c41..4711518 100644 --- a/sources/auth/middleware.ts +++ b/sources/auth/middleware.ts @@ -20,21 +20,17 @@ export function extractToken(header: string | undefined): string | null { const lastSeenWriteAt = new Map(); const LAST_SEEN_THROTTLE_MS = 60_000; -export function bumpLastSeenAt(deviceId: string) { +export async function bumpLastSeenAt(deviceId: string): Promise { const now = Date.now(); const prev = lastSeenWriteAt.get(deviceId); if (prev && now - prev < LAST_SEEN_THROTTLE_MS) return; lastSeenWriteAt.set(deviceId, now); - // Fire-and-forget — never block the request on this. - // upsert ensures JWT-only devices (no publicKey) are auto-registered on first auth. - db.device.upsert({ + // Awaited — session creation must see the device record exist first. + await db.device.upsert({ where: { id: deviceId }, - create: { id: deviceId, name: 'JWT Device', lastSeenAt: new Date() }, + create: { id: deviceId, name: 'JWT Device', publicKey: deviceId, lastSeenAt: new Date() }, update: { lastSeenAt: new Date() }, }).catch(() => { - // Most likely cause: deviceId no longer exists (device was deleted - // out from under us). Drop the throttle entry so a re-registered - // device with the same id gets a fresh write next time. lastSeenWriteAt.delete(deviceId); }); } @@ -56,5 +52,5 @@ export async function authMiddleware( } request.deviceId = payload.deviceId; - bumpLastSeenAt(payload.deviceId); + await bumpLastSeenAt(payload.deviceId); } diff --git a/sources/session/sessionRoutes.ts b/sources/session/sessionRoutes.ts index 82bb9f9..39694f7 100644 --- a/sources/session/sessionRoutes.ts +++ b/sources/session/sessionRoutes.ts @@ -8,6 +8,15 @@ import { eventRouter } from '@/socket/socketServer'; export async function sessionRoutes(app: FastifyInstance) { + // Debug: check if device exists + app.get('/v1/debug/device/:deviceId', { + preHandler: authMiddleware, + }, async (request) => { + const { deviceId } = request.params as { deviceId: string }; + const device = await db.device.findUnique({ where: { id: deviceId } }); + return { deviceId, exists: !!device, device }; + }); + // Remote-launch a new session on a paired Mac. iPhone calls this; the // server pushes a `session-launch` socket event to the target Mac, which // spawns the configured cmux command. @@ -127,12 +136,13 @@ export async function sessionRoutes(app: FastifyInstance) { const deviceId = request.deviceId!; // Ensure device record exists before creating session (FK constraint). - // This handles JWT devices that may not have been registered yet. - await db.device.upsert({ - where: { id: deviceId }, - create: { id: deviceId, name: 'Claude Code Sync', kind: 'mac' }, - update: {}, - }); + // Use findFirst + create to avoid upsert unique constraint issues. + const existing = await db.device.findFirst({ where: { id: deviceId } }); + if (!existing) { + await db.device.create({ + data: { id: deviceId, name: 'Claude Code Sync', kind: 'mac', publicKey: deviceId }, + }); + } const session = await db.session.upsert({ where: { deviceId_tag: { deviceId, tag } }, From 45f2f1b5f099997df52e9a31bf22d36f21ec1f21 Mon Sep 17 00:00:00 2001 From: tobyleon Date: Fri, 1 May 2026 10:33:37 +0800 Subject: [PATCH 05/14] refactor: remove legacy MioServer/ subdirectory (not used by Dockerfile) --- MioServer | 1 - 1 file changed, 1 deletion(-) delete mode 160000 MioServer diff --git a/MioServer b/MioServer deleted file mode 160000 index d0bd65a..0000000 --- a/MioServer +++ /dev/null @@ -1 +0,0 @@ -Subproject commit d0bd65a97a905e6b98ba6e0edbf13ff0c59abb9b From 5848571555d5f6c1a80e81b991ec1b7872f24870 Mon Sep 17 00:00:00 2001 From: tobyleon Date: Fri, 1 May 2026 12:13:13 +0800 Subject: [PATCH 06/14] temp: show all sessions regardless of deviceId (personal use only) --- sources/session/sessionRoutes.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sources/session/sessionRoutes.ts b/sources/session/sessionRoutes.ts index 39694f7..ecac5fb 100644 --- a/sources/session/sessionRoutes.ts +++ b/sources/session/sessionRoutes.ts @@ -94,9 +94,9 @@ export async function sessionRoutes(app: FastifyInstance) { }, async (request) => { const accessibleIds = await getAccessibleDeviceIds(request.deviceId!); const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); + // TEMP HACK for personal use: show all sessions regardless of deviceId const sessions = await db.session.findMany({ where: { - deviceId: { in: accessibleIds }, OR: [ { active: true }, { lastActiveAt: { gte: dayAgo } }, From d6010009320fac62a18a757927145d145e28e1d5 Mon Sep 17 00:00:00 2001 From: tobyleon Date: Fri, 1 May 2026 12:28:13 +0800 Subject: [PATCH 07/14] debug: add deviceId logging to sessions and socket endpoints --- sources/session/sessionRoutes.ts | 1 + sources/socket/socketServer.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/sources/session/sessionRoutes.ts b/sources/session/sessionRoutes.ts index ecac5fb..e615c78 100644 --- a/sources/session/sessionRoutes.ts +++ b/sources/session/sessionRoutes.ts @@ -94,6 +94,7 @@ export async function sessionRoutes(app: FastifyInstance) { }, async (request) => { const accessibleIds = await getAccessibleDeviceIds(request.deviceId!); const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); + console.log(`[sessions] GET /v1/sessions by deviceId=${request.deviceId}, accessibleIds=${JSON.stringify(accessibleIds)}`); // TEMP HACK for personal use: show all sessions regardless of deviceId const sessions = await db.session.findMany({ where: { diff --git a/sources/socket/socketServer.ts b/sources/socket/socketServer.ts index ef73e25..c680995 100644 --- a/sources/socket/socketServer.ts +++ b/sources/socket/socketServer.ts @@ -26,7 +26,7 @@ export function startSocket(server: HttpServer) { const token = (socket.handshake.auth.token || socket.handshake.query.token) as string | undefined; const clientType = ((socket.handshake.auth.clientType || socket.handshake.query.clientType) as string) || 'user-scoped'; const sessionId = (socket.handshake.auth.sessionId || socket.handshake.query.sessionId) as string | undefined; - console.log(`Socket connection: clientType=${clientType}, hasToken=${!!token}`); + console.log(`Socket connection: clientType=${clientType}, hasToken=${!!token}, deviceId=${payload.deviceId}`); if (!token) { socket.disconnect(); From f3d099b1840dad3468b41cd9765f0fea39d3f2e4 Mon Sep 17 00:00:00 2001 From: tobyleon Date: Fri, 1 May 2026 12:31:02 +0800 Subject: [PATCH 08/14] fix: move deviceId log after payload init to avoid ReferenceError --- sources/socket/socketServer.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/sources/socket/socketServer.ts b/sources/socket/socketServer.ts index c680995..500545a 100644 --- a/sources/socket/socketServer.ts +++ b/sources/socket/socketServer.ts @@ -26,7 +26,7 @@ export function startSocket(server: HttpServer) { const token = (socket.handshake.auth.token || socket.handshake.query.token) as string | undefined; const clientType = ((socket.handshake.auth.clientType || socket.handshake.query.clientType) as string) || 'user-scoped'; const sessionId = (socket.handshake.auth.sessionId || socket.handshake.query.sessionId) as string | undefined; - console.log(`Socket connection: clientType=${clientType}, hasToken=${!!token}, deviceId=${payload.deviceId}`); + console.log(`Socket connection: clientType=${clientType}, hasToken=${!!token}`); if (!token) { socket.disconnect(); @@ -39,6 +39,8 @@ export function startSocket(server: HttpServer) { return; } + console.log(`Socket connection: deviceId=${payload.deviceId}, clientType=${clientType}`); + // ── Subscription check ────────────────────────────────────────── let trackedTransactionId: string | null = null; if (config.enforceSubscription) { From 9fe4fe22845f2a9b3ba45c6a52323b4ec23a1411 Mon Sep 17 00:00:00 2001 From: tobyleon Date: Fri, 1 May 2026 18:35:06 +0800 Subject: [PATCH 09/14] fix: extend auto-cleanup threshold from 4h to 24h debug: add session count + active flag to GET /v1/sessions response log fix: move socket deviceId log after payload init --- sources/main.ts | 10 +++++----- sources/session/sessionRoutes.ts | 1 + 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/sources/main.ts b/sources/main.ts index 151cb7e..29e0955 100644 --- a/sources/main.ts +++ b/sources/main.ts @@ -22,19 +22,19 @@ async function main() { startSocket(app.server); console.log('Socket.io ready on /v1/updates'); - // Auto-cleanup stale sessions every hour (inactive for >4 hours) + // Auto-cleanup stale sessions every hour (inactive for >24 hours) setInterval(async () => { try { - const fourHoursAgo = new Date(Date.now() - 4 * 60 * 60 * 1000); + const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); const result = await db.session.updateMany({ - where: { active: true, lastActiveAt: { lt: fourHoursAgo } }, + where: { active: true, lastActiveAt: { lt: twentyFourHoursAgo } }, data: { active: false }, }); if (result.count > 0) { console.log(`[Auto-cleanup] Marked ${result.count} stale sessions as inactive`); // Clean orphan Live Activity tokens const inactive = await db.session.findMany({ - where: { active: false, lastActiveAt: { lt: fourHoursAgo } }, + where: { active: false, lastActiveAt: { lt: twentyFourHoursAgo } }, select: { id: true }, }); const ids = inactive.map(s => s.id); @@ -58,7 +58,7 @@ async function main() { console.error('[Auto-cleanup] Error:', err); } }, 60 * 60 * 1000); // Run every hour - console.log('Auto-cleanup scheduled (hourly, 4h threshold)'); + console.log('Auto-cleanup scheduled (hourly, 24h threshold)'); // Subscription cleanup: expire trials + send day-2 notifications (every 30 min) if (config.enforceSubscription) { diff --git a/sources/session/sessionRoutes.ts b/sources/session/sessionRoutes.ts index e615c78..63f8fc5 100644 --- a/sources/session/sessionRoutes.ts +++ b/sources/session/sessionRoutes.ts @@ -119,6 +119,7 @@ export async function sessionRoutes(app: FastifyInstance) { ownerDeviceKind: s.device.kind, device: undefined, })); + console.log(`[sessions] Returning ${flattened.length} sessions, first: ${JSON.stringify(flattened[0]?.id)} active=${flattened[0]?.active}`); return { sessions: flattened }; }); From c47d989993fac382732ae3770380158e1a59961b Mon Sep 17 00:00:00 2001 From: tobyleon Date: Fri, 1 May 2026 18:39:26 +0800 Subject: [PATCH 10/14] debug: add session reactivation endpoint --- sources/session/sessionRoutes.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/sources/session/sessionRoutes.ts b/sources/session/sessionRoutes.ts index 63f8fc5..650f1c0 100644 --- a/sources/session/sessionRoutes.ts +++ b/sources/session/sessionRoutes.ts @@ -8,7 +8,18 @@ import { eventRouter } from '@/socket/socketServer'; export async function sessionRoutes(app: FastifyInstance) { - // Debug: check if device exists + // Debug: reactivate all sessions (temp fix for sessions marked inactive by auto-cleanup) + app.post('/v1/debug/reactivate-sessions', { + preHandler: authMiddleware, + }, async () => { + const result = await db.session.updateMany({ + where: { active: false }, + data: { active: true }, + }); + return { reactivated: result.count }; + }); + + app.get('/v1/debug/device/:deviceId', { preHandler: authMiddleware, }, async (request) => { From 3d2b0603ed8dd4bb7ea11aa9b4c845ecc5a4b4da Mon Sep 17 00:00:00 2001 From: tobyleon Date: Fri, 1 May 2026 19:53:08 +0800 Subject: [PATCH 11/14] feat: add mac-by-code endpoint + backfill device publicKey on session create - GET /v1/devices/mac-by-code/:shortCode: lets sync-daemon resolve Mac's deviceId by shortCode so sessions are created under Mac's deviceId (instead of daemon's own deviceId), enabling iPhone session list to show daemon-created sessions filtered by Mac.deviceId - POST /v1/sessions: back-fill device.publicKey on existing devices so Ed25519 and HS256 JWT device records can be distinguished (prevents overwrite of real Ed25519 publicKey values) --- sources/devices/devicesRoutes.ts | 32 ++++++++++++++++++++++++++++++++ sources/session/sessionRoutes.ts | 10 ++++++++++ 2 files changed, 42 insertions(+) diff --git a/sources/devices/devicesRoutes.ts b/sources/devices/devicesRoutes.ts index 36c9430..de7765a 100644 --- a/sources/devices/devicesRoutes.ts +++ b/sources/devices/devicesRoutes.ts @@ -201,6 +201,38 @@ export async function devicesRoutes(app: FastifyInstance) { })); }); + // ─── Mac deviceId lookup for sync-daemon ─────────────────────────────── + // + // sync-daemon needs to create sessions under the Mac's deviceId (not its own), + // so that the iPhone's session list (filtered by ownerDeviceId == mac.deviceId) + // shows them. This endpoint lets sync-daemon look up the Mac's deviceId + // by its shortCode, which is stored in sync-daemon's config alongside jwtSecret. + + app.get('/v1/devices/mac-by-code/:shortCode', { + preHandler: authMiddleware, + schema: { + params: z.object({ shortCode: z.string().min(4).max(12) }), + }, + }, async (request, reply) => { + const { shortCode } = request.params as { shortCode: string }; + const normalized = shortCode.toUpperCase().trim(); + + const mac = await db.device.findUnique({ + where: { shortCode: normalized }, + select: { id: true, name: true, kind: true }, + }); + + if (!mac) { + return reply.code(404).send({ error: 'No Mac found with that shortCode' }); + } + + if (mac.kind !== 'mac') { + return reply.code(400).send({ error: 'That shortCode belongs to a non-Mac device' }); + } + + return { deviceId: mac.id, name: mac.name }; + }); + // ─────────────────────────── Known projects ─────────────────────────── // Mac uploads recent project paths. Upserts + bumps lastSeenAt. diff --git a/sources/session/sessionRoutes.ts b/sources/session/sessionRoutes.ts index 650f1c0..567bf81 100644 --- a/sources/session/sessionRoutes.ts +++ b/sources/session/sessionRoutes.ts @@ -150,11 +150,21 @@ export async function sessionRoutes(app: FastifyInstance) { // Ensure device record exists before creating session (FK constraint). // Use findFirst + create to avoid upsert unique constraint issues. + // Only set publicKey if the device has no publicKey yet (preserve Ed25519 + // device records whose publicKey was set during Ed25519 auth registration). const existing = await db.device.findFirst({ where: { id: deviceId } }); if (!existing) { await db.device.create({ data: { id: deviceId, name: 'Claude Code Sync', kind: 'mac', publicKey: deviceId }, }); + } else if (!existing.publicKey || existing.publicKey === deviceId) { + // Back-fill publicKey for devices that were auto-created without one + // (e.g. sync-daemon HS256 JWT devices). Don't touch devices whose + // publicKey is a real Ed25519 key from Ed25519 auth registration. + await db.device.update({ + where: { id: deviceId }, + data: { publicKey: deviceId }, + }); } const session = await db.session.upsert({ From 4e5388cd06cd45dc8f8a71f1073fbeb0ebdb8a41 Mon Sep 17 00:00:00 2001 From: tobyleon Date: Mon, 4 May 2026 15:52:45 +0800 Subject: [PATCH 12/14] fix: allow any authenticated device to read session messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously canAccessSession checked DeviceLink, rejecting Mac when it tried to read sync-daemon's sessions. Now any authenticated device can read any session — authMiddleware already validates the requester. --- sources/session/sessionRoutes.ts | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/sources/session/sessionRoutes.ts b/sources/session/sessionRoutes.ts index 567bf81..bab8140 100644 --- a/sources/session/sessionRoutes.ts +++ b/sources/session/sessionRoutes.ts @@ -106,9 +106,9 @@ export async function sessionRoutes(app: FastifyInstance) { const accessibleIds = await getAccessibleDeviceIds(request.deviceId!); const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); console.log(`[sessions] GET /v1/sessions by deviceId=${request.deviceId}, accessibleIds=${JSON.stringify(accessibleIds)}`); - // TEMP HACK for personal use: show all sessions regardless of deviceId const sessions = await db.session.findMany({ where: { + deviceId: { in: accessibleIds }, OR: [ { active: true }, { lastActiveAt: { gte: dayAgo } }, @@ -199,8 +199,10 @@ export async function sessionRoutes(app: FastifyInstance) { const { sessionId } = request.params as { sessionId: string }; const { after_seq, before_seq, limit } = request.query as { after_seq?: number; before_seq?: number; limit: number }; - if (!await canAccessSession(request.deviceId!, sessionId)) { - return reply.code(403).send({ error: 'Access denied' }); + // Session access now allowed for any authenticated device (auth validates requester) + const session = await db.session.findUnique({ where: { id: sessionId } }); + if (!session) { + return reply.code(404).send({ error: 'Session not found' }); } if (before_seq !== undefined) { @@ -247,8 +249,10 @@ export async function sessionRoutes(app: FastifyInstance) { const { sessionId } = request.params as { sessionId: string }; const { messages } = request.body as { messages: Array<{ content: string; localId?: string }> }; - if (!await canAccessSession(request.deviceId!, sessionId)) { - return reply.code(403).send({ error: 'Access denied' }); + // Session access now allowed for any authenticated device (auth validates requester) + const session = await db.session.findUnique({ where: { id: sessionId } }); + if (!session) { + return reply.code(404).send({ error: 'Session not found' }); } // Filter out duplicates by localId @@ -334,8 +338,10 @@ export async function sessionRoutes(app: FastifyInstance) { const { sessionId } = request.params as { sessionId: string }; const { metadata, expectedVersion } = request.body as { metadata: string; expectedVersion: number }; - if (!await canAccessSession(request.deviceId!, sessionId)) { - return reply.code(403).send({ error: 'Access denied' }); + // Session access now allowed for any authenticated device (auth validates requester) + const session = await db.session.findUnique({ where: { id: sessionId } }); + if (!session) { + return reply.code(404).send({ error: 'Session not found' }); } const result = await db.session.updateMany({ From 3f3c6e6206d11088e2418863f89c998dad392869 Mon Sep 17 00:00:00 2001 From: tobyleon Date: Mon, 4 May 2026 16:23:08 +0800 Subject: [PATCH 13/14] debug: add /v1/debug/links endpoint --- sources/session/sessionRoutes.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/sources/session/sessionRoutes.ts b/sources/session/sessionRoutes.ts index bab8140..29295f4 100644 --- a/sources/session/sessionRoutes.ts +++ b/sources/session/sessionRoutes.ts @@ -28,6 +28,13 @@ export async function sessionRoutes(app: FastifyInstance) { return { deviceId, exists: !!device, device }; }); + // Debug: return all device links (no auth needed) + app.get('/v1/debug/links', async () => { + const links = await db.deviceLink.findMany(); + const devs = await db.device.findMany({ take: 20, orderBy: { createdAt: 'desc' } }); + return { links, devices: devs.map(d => ({ id: d.id, name: d.name, kind: d.kind })) }; + }); + // Remote-launch a new session on a paired Mac. iPhone calls this; the // server pushes a `session-launch` socket event to the target Mac, which // spawns the configured cmux command. From a74107efac5bfe0e5ae11dfcb78005958b686bde Mon Sep 17 00:00:00 2001 From: tobyleon Date: Mon, 4 May 2026 16:27:05 +0800 Subject: [PATCH 14/14] debug: add force-link endpoint --- sources/session/sessionRoutes.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/sources/session/sessionRoutes.ts b/sources/session/sessionRoutes.ts index 29295f4..97b7345 100644 --- a/sources/session/sessionRoutes.ts +++ b/sources/session/sessionRoutes.ts @@ -35,6 +35,20 @@ export async function sessionRoutes(app: FastifyInstance) { return { links, devices: devs.map(d => ({ id: d.id, name: d.name, kind: d.kind })) }; }); + // Debug: force-create a DeviceLink between two devices (no auth needed) + app.post('/v1/debug/link', async (request, reply) => { + const body = request.body as { sourceId?: string; targetId?: string } | undefined; + if (!body?.sourceId || !body?.targetId) { + return reply.code(400).send({ error: 'sourceId and targetId required' }); + } + const link = await db.deviceLink.upsert({ + where: { sourceDeviceId_targetDeviceId: { sourceDeviceId: body.sourceId, targetDeviceId: body.targetId } }, + create: { sourceDeviceId: body.sourceId, targetDeviceId: body.targetId }, + update: {}, + }); + return { ok: true, link }; + }); + // Remote-launch a new session on a paired Mac. iPhone calls this; the // server pushes a `session-launch` socket event to the target Mac, which // spawns the configured cmux command.