Doop 实时协作与访问控制:谁能看见、谁能修改
拆解 Doop 的 REST 权限、WebSocket 房间、iframe 交互桥和测试用例边界。
访问模型很小
小函数守住大部分边界
Doop 的权限模型集中在一个很短的函数:server/access.ts:9 判断 owner、公开链接或成员访问,server/access.ts:14 检查 publicLinkEnabled,server/access.ts:18 检查 members。简单模型的好处是 REST、WebSocket 和 MCP 都能复用,但这也意味着每个入口都必须确实调用 requireCanvas。
server/access.ts:1canvas 级访问判断1import type { Canvas } from '../shared/types.ts'
2
3/**
4 * Canvas access, Figma-style: private by default. The owner always has
5 * access; invited members (canvas_members) always have access; everyone
6 * else gets in only when the owner has turned the share link on
7 * (linkAccess 'edit' — unset means 'none').
8 * Ownerless (pre-auth/legacy) canvases stay open so they can be claimed.
9 *
10 * Every canvas-scoped surface — REST, the WS join, MCP tools — must gate
11 * through this one helper.
12 */
13export function canAccessCanvas(userId: string | undefined, canvas: Canvas): boolean {
14 if (!canvas.ownerId) return true
15 if (!userId) return false
16 if (canvas.ownerId === userId) return true
17 if (canvas.memberIds?.includes(userId)) return true
18 return canvas.linkAccess === 'edit'
19}
API auth middleware 先解析用户 server/index.ts:440。 requireCanvas 使用 hasCanvasAccess server/index.ts:456。 无权限时返回 403 server/index.ts:462。 requireFrame 继续限定 frame server/index.ts:469。 frame 不存在会返回 404 server/index.ts:480。
REST 入口先验权
Canvas 和 Frame 分两层校验
在 REST 层,server/index.ts:456 的 requireCanvas 先找 canvas,再调用访问检查;frame 级操作又经过 server/index.ts:469 的 requireFrame。这个分层对设计工具很关键,因为 frame ID 可能通过 UI、URL 或 agent 工具流转,不能只依赖前端不传错。
server/index.ts:440API auth、canvas 权限与 frame 权限440app.use('/api', async (req, res, next) => {
441 try {
442 const session = await auth.api.getSession({ headers: fromNodeHeaders(req.headers) })
443 if (!session) return res.status(401).json({ error: 'unauthorized' })
444 req.user = session.user
445 next()
446 } catch (err) {
447 next(err)
448 }
449})
450
451app.get('/api/me', (req, res) => res.json(req.user))
452
453/* Canvas access on the REST surface: resolve the canvas (or the frame's
454 canvas) and run it through canAccessCanvas before touching anything.
455 Both helpers write the error response themselves and return null. */
456function requireCanvas(req: express.Request, res: express.Response, canvasId: string) {
457 const c = store.getCanvas(canvasId)
458 if (!c) {
459 res.status(404).json({ error: 'not found' })
460 return null
461 }
462 if (!canAccessCanvas(req.user!.id, c)) {
463 res.status(403).json({ error: 'this canvas is private — ask the owner for access' })
464 return null
465 }
466 return c
467}
468
469function requireFrame(req: express.Request, res: express.Response, frameId: string) {
470 const frame = store.getFrame(frameId)
471 if (!frame) {
472 res.status(404).json({ error: 'frame not found' })
473 return null
474 }
475 const c = store.getCanvas(frame.canvasId)
476 if (!c || !canAccessCanvas(req.user!.id, c)) {
477 res.status(403).json({ error: 'this canvas is private — ask the owner for access' })
478 return null
479 }
480 return frame
成员接口继续复用 canvas 权限 server/index.ts:491。 卡片列表入口走 requireCanvas server/index.ts:897。 新增卡片也经过同一权限层 server/index.ts:905。 反馈接口继续落在 canvas 范围 server/index.ts:924。 删除操作由 owner-only 测试覆盖 tests/access.test.ts:151。
WebSocket 把状态推回画布
客户端只负责连接和派发
前端 WebSocket client 很薄:src/lib/ws.ts:27 建立连接,src/lib/ws.ts:40 标记连接打开,src/lib/ws.ts:51 把 JSON message 交给 handler,src/lib/ws.ts:70 则暴露 send。真正的业务更新留给 store,因此浏览器实时层不会混进太多画布逻辑。
src/lib/ws.ts:27WebSocket lifecycle 与消息派发27function open() {
28 if (!currentCanvasId) return
29 const proto = location.protocol === 'https:' ? 'wss' : 'ws'
30 const s = new WebSocket(`${proto}://${location.host}/ws`)
31 socket = s
32 const canvasId = currentCanvasId
33
34 s.onopen = () => {
35 if (socket !== s) return
36 const { clientId, name } = getIdentity()
37 useStore.getState().setConnected(true)
38 sendWs({ type: 'join', canvasId, clientId, name, kind: 'user' })
39 }
40
41 s.onmessage = (ev) => {
42 if (socket !== s) return
43 let msg: ServerMessage
44 try {
45 msg = JSON.parse(ev.data)
46 } catch {
47 return
48 }
49 handle(msg)
50 }
51
52 s.onclose = (ev) => {
53 /* a superseded socket must not trigger reconnects */
54 if (socket !== s) return
55 useStore.getState().setConnected(false)
56 if (ev.code === 4401) {
57 /* session expired or missing — reload lands on the sign-in page */
58 currentCanvasId = null
59 location.reload()
60 return
61 }
62 if (ev.code === 4403) {
63 /* the owner locked this canvas — retrying would loop forever */
64 currentCanvasId = null
65 location.href = '/'
66 return
67 }
68 if (currentCanvasId) {
69 retryTimer = window.setTimeout(open, 1200)
70 }
服务端 WS join 解析 canvasId server/index.ts:1035。 join 时继续做访问检查 server/index.ts:1052。 通过后才加入 room server/index.ts:1068。 CanvasPage mount 时建立连接 src/pages/CanvasPage.tsx:58。 编辑 frame 会广播给协作者 src/pages/CanvasPage.tsx:69。
Iframe 交互桥
FrameView 把页面内容当作可检查产物
Doop 的 frame 是 iframe 内容,所以交互桥很关键。src/components/FrameView.tsx:126 开始监听 iframe message,src/components/FrameView.tsx:147 处理元素检查,src/components/FrameView.tsx:158 维护 hover target,src/components/FrameView.tsx:190 之后把评论入口挂到选中元素。这个结构让 agent 反馈能精确到 frame 内元素,而不是只停留在整页评论。
src/components/FrameView.tsx:126iframe postMessage 与元素检查桥126 const html = useThrottledValue(frame.html, 150)
127 const remoteEditor = editors[0]
128
129 /* The iframe loads a bootstrap once; HTML is posted in and DOM-morphed in
130 place, so updates never white-flash the frame with a full reload. */
131 const iframeRef = useRef<HTMLIFrameElement | null>(null)
132 const [runtimeReady, setRuntimeReady] = useState(false)
133 useEffect(() => {
134 function onMsg(ev: MessageEvent) {
135 if (ev.data?.type === 'doop:frame-ready' && ev.source === iframeRef.current?.contentWindow) {
136 setRuntimeReady(true)
137 }
138 }
139 window.addEventListener('message', onMsg)
140 return () => window.removeEventListener('message', onMsg)
141 }, [])
142 useEffect(() => {
143 if (!runtimeReady || editing || suspendPost) return
144 iframeRef.current?.contentWindow?.postMessage({ type: 'doop:html', html }, '*')
145 }, [runtimeReady, html, editing, suspendPost])
146
147 /* ---- element comments ---- */
148 const comments = useStore((s) => s.comments).filter((c) => c.frameId === frame.id && !c.resolvedAt)
149 const [probe, setProbe] = useState<ProbeHit | null>(null)
150 /* element selected for text editing inside the iframe (edit mode only) */
151 const [activeHit, setActiveHit] = useState<ProbeHit | null>(null)
152 const [composing, setComposing] = useState(false)
153 const [openThread, setOpenThread] = useState<string | null>(null)
154 const [pinPos, setPinPos] = useState<Record<string, { x: number; y: number } | null>>({})
155 const probeReq = useRef(0)
156 const probeTimer = useRef<number | null>(null)
157 const activeSelRef = useRef<string | null>(null)
158
159 /* ---- hover inspection (paper.design-style element outlines) ---- */
160 const [hover, setHover] = useState<HoverHit | null>(null)
161 const hoverReq = useRef(0)
162 const hoverKey = useRef<string | null>(null)
163
164 const hoverLast = useRef(0)
165 function sendHover(x: number, y: number) {
166 /* pointermove fires every frame — one probe per ~40ms is plenty */
167 const now = Date.now()
168 if (now - hoverLast.current < 40) return
169 hoverLast.current = now
170 hoverReq.current += 1
171 iframeRef.current?.contentWindow?.postMessage({ type: 'doop:hover', reqId: hoverReq.current, x, y }, '*')
172 }
173
174 function clearHover() {
175 hoverReq.current += 1 // drop any in-flight result
176 hoverKey.current = null
177 setHover(null)
178 }
179
180 /* source view + composer prefill for the element toolbar */
181 const [codeView, setCodeView] = useState<string | null>(null)
182 const [composePrefill, setComposePrefill] = useState('')
183 const codeReq = useRef(0)
184
185 function requestCode(selector: string) {
186 codeReq.current += 1
187 iframeRef.current?.contentWindow?.postMessage({ type: 'doop:code', reqId: codeReq.current, selector }, '*')
188 }
189
190 function closePopovers() {
元素评论在服务端成为任务输入 server/actions.ts:397。 selector 与 frameId 一起保存 server/actions.ts:424。 agent 可以领取对应评论 server/actions.ts:430。 评论 resolve 会更新状态 server/actions.ts:488。 前端 store 接收 comment 更新 src/lib/store.ts:167。
测试覆盖说明
权限测试覆盖到 MCP
现有测试不是只测 happy path。tests/access.test.ts:38 创建真实用户会话,tests/access.test.ts:71 验证私有画布默认不可读,tests/access.test.ts:100 验证邀请后可读,tests/access.test.ts:126 到 tests/access.test.ts:130 明确检查 MCP 未授权返回 401。对一个能让 agent 改页面的产品,这组测试是最应该保留和扩展的底线。
tests/access.test.ts:126未授权 MCP 与 owner-only 删除用例126 it('rejects unauthenticated API and MCP calls', async () => {
127 expect((await fetch(`${BASE}/api/canvases`)).status).toBe(401)
128 const mcp = await fetch(`${BASE}/mcp`, { method: 'POST', body: '{}' })
129 expect(mcp.status).toBe(401)
130 expect(mcp.headers.get('www-authenticate')).toContain('oauth-protected-resource')
131 })
132
133 it('signs up three accounts and creates a canvas with a frame', async () => {
134 await owner.signUp('owner@test.dev', 'Owner')
135 await invited.signUp('invited@test.dev', 'Invited')
136 await stranger.signUp('stranger@test.dev', 'Stranger')
137 invitedId = (await (await invited.get('/api/me')).json()).id
138
139 const canvas = await (await owner.post('/api/canvases', { name: 'ACL' })).json()
140 canvasId = canvas.id
141 const frame = await (await owner.post(`/api/canvases/${canvasId}/frames`, { name: 'F1' })).json()
142 frameId = frame.id
143 })
144
145 it('is private by default: non-owners are blocked everywhere', async () => {
146 expect((await stranger.get(`/api/canvases/${canvasId}`)).status).toBe(403)
147 expect((await stranger.post(`/api/canvases/${canvasId}/frames`, { name: 'x' })).status).toBe(403)
148 expect((await stranger.patch(`/api/frames/${frameId}`, { name: 'hacked' })).status).toBe(403)
149 expect((await stranger.delete(`/api/frames/${frameId}`)).status).toBe(403)
150 expect((await stranger.patch(`/api/canvases/${canvasId}`, { name: 'renamed' })).status).toBe(403)
151 expect(await stranger.joinWs(canvasId)).toEqual({ kind: 'closed', code: 4403 })
离开画布后访问应失败 tests/access.test.ts:161。 被移除成员不再有权限 tests/access.test.ts:181。 删除接口再次验证 owner tests/access.test.ts:189。 MCP 入口仍值得加更细审计 server/mcp.ts:1014。 transport 生命周期也需要覆盖异常路径 server/mcp.ts:1042。