Parcourir la source

修复跨端回执编码与聊天流登出并记录配套验收

Samien il y a 1 mois
Parent
commit
6959ef6d79

+ 30 - 0
docs/paired-release-acceptance.md

@@ -0,0 +1,30 @@
+# 配套版本与验收
+
+主项目沿用 v0.4.77;移动端为独立版本 1.5.0(226)。具体 Git 提交、构建号、路径和 SHA-256 以每次产物目录中的清单为准。客户端安装成功不代表服务端或 Runner 已更新。
+
+| 部分 | 运行要求 | 配套要求 |
+| --- | --- | --- |
+| Web/BFF | Node.js 24、持久数据目录、已有 Hermes | 本轮服务端包包含持久去重/回执、附件授权、删除清理、取消屏障和普通聊天/Bot SSE 登出修复 |
+| 独立 Runner | Node.js 24、完整 Runner 目录、已有 Hermes | 与本轮 Web 一起更新;支持所属会话的 session.active_list,停止未确认时不释放控制权或复用执行 |
+| Mac arm64 纯客户端 | macOS 12+、可访问 Web | 不含服务器或 Runner;旧 v0.4.76 仍使用同一协议,本轮新包采用 v0.4.77 |
+| iOS | iOS 17+、移动端 1.5.0(226) | 保留已验签的 1358b49 IPA;本轮未改变移动源码。包含持久发送检查点及附件恢复 |
+
+普通聊天协议为 `ordinary-chat-transcript-v2`、HTTP/SSE 实时协议版本为 1;Bot 使用 `patch-v1`;Runner 机器协议为 1,并绑定账号、Profile、实例和连接代次。相同协议号不保证旧 Runner 支持新增停止确认,所以独立 Runner 应配套更新。Hermes 负责模型和原生工具执行;夭夭负责权限、调度、持久记录及接入。本轮没有改变、安装插件到或重启已有 Hermes。
+
+## 本地验证
+
+关键服务端/Runner 回归使用隔离 SQLite、HTTP/WebSocket mock 和模拟电脑。桌面 E2E、移动单测、typecheck、构建及签名结果分别记录。
+
+`npm run test:pair:mobile` 直接加载相邻 `yaoyao-mobile` checkout 的真实 `YaoyaoAPI`、`OrdinaryStore` 和 SQLite 缓存,通过真实本地 HTTP/SSE 对接当前 BFF;Hermes 是确定模拟。可用 `YAOYAO_MOBILE_CHECKOUT=/绝对路径` 指定移动仓库。覆盖附件成功后断线、缓存重开、BFF 重启、编码回执查询、只上传一次、只提交一次、canonical echo 和登出关闭 transcript 流。Node 夹具提供 Cookie jar,不能替代 iOS URLSession 或真机验证。
+
+## 运行与切换
+
+macOS arm64 Web 运行包包含 Node,解包后执行 `./node server.mjs`;通过 `HERMES_YAOYAO_HOME` 指向持久目录,`HERMES_YAOYAO_UPSTREAM` 指向现有 Hermes,并设置 `NODE_ENV=production`。npm 分发包需在目标环境安装生产依赖后执行其 CLI,不是 Linux 自包含二进制。
+
+独立 Runner 解包后执行 `node runner.mjs --config /私有路径/runner.json`。配置保持私有;`artifactRoots` 留空拒绝文件导出。运行包包含 Worker、工具桥和 Playwright 运行包,不包含 Chromium 二进制、Docker 镜像、模型或用户数据。浏览器和 VM 按 `runner.md`、`managed-browser.md`、`local-vm.md` 在目标环境准备。
+
+生产切换前应确认任务空闲、备份持久数据,升级 Web 和独立 Runner 后再连接客户端,并核对实际进程/容器的版本与产物提交。私有配对配置可保留,不重新创建/撤销凭据或放宽权限。本轮构建没有执行生产切换。
+
+## 真实环境限制
+
+真实 Hermes 停止/工具行为、目标浏览器/VM、iOS 真机网络/音频/推送、生产 TLS 和数据迁移未在本轮执行。IPA 使用开发签名,仅能安装到配置文件登记的设备;未安装真机或上传 TestFlight。Mac 签名/公证状态以产物记录为准;未公证本地包可能受 Gatekeeper 限制。编译成功不代表这些验收已完成。

+ 1 - 1
docs/runner.md

@@ -20,7 +20,7 @@ Runner 在运行 Hermes 的电脑上主动连接夭夭服务。用于 Web 位于
 node runner.mjs --config /完整路径/runner.json
 ```
 
-macOS App 可直接通过菜单栏“执行节点 → 导入节点配置…”导入下载的配置,无需另外安装 Node.js。App 使用系统钥匙串支持的加密存储保存配置,通过私有进程消息传入内置 Runner;凭据不进入命令行或子进程环境变量。随后每次打开 App 都会自动启动已配置的 Runner。关闭窗口保持连接,退出 App 时清理自己管理的 Runner;“断开并忘记配置”移除本机保存的配置。原始下载文件仍由用户管理。
+macOS 完整版 App 可直接通过菜单栏“执行节点 → 导入节点配置…”导入下载的配置,无需另外安装 Node.js。App 使用系统钥匙串支持的加密存储保存配置,通过私有进程消息传入内置 Runner;凭据不进入命令行或子进程环境变量。随后每次打开 App 都会自动启动已配置的 Runner。关闭窗口保持连接,退出 App 时清理自己管理的 Runner;“断开并忘记配置”移除本机保存的配置。原始下载文件仍由用户管理。纯客户端 App 不内置 Runner;需在 Hermes 电脑运行独立 Runner,配套版本见 [版本验收](paired-release-acceptance.md)。
 
 配置文件至少包括以下字段。`token` 由注册操作生成,不需要填写模型密钥。
 

+ 1 - 0
package.json

@@ -25,6 +25,7 @@
     "brand:verify": "node scripts/verify-brand.mjs",
     "release:verify": "node scripts/verify-release.mjs",
     "verify:lifecycle": "node scripts/verify-lifecycle.mjs",
+    "test:pair:mobile": "node --import tsx --test scripts/verify-mobile-pair.test.ts",
     "test:e2e:workspace": "npm run build && playwright test -c playwright.workspace.config.ts",
     "test:e2e:desktop-hosts": "npm run build && playwright test -c playwright.desktop-hosts.config.ts",
     "test:e2e:team-tools": "npm run build && playwright test -c playwright.team-tools.config.ts",

+ 131 - 0
scripts/verify-mobile-pair.test.ts

@@ -0,0 +1,131 @@
+/** Optional two-checkout acceptance: real mobile core + real BFF over local HTTP.
+ * Hermes is a deterministic loopback fixture. No production service is used. */
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { createServer, type Server } from 'node:http'
+import { mkdtempSync, mkdirSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join, resolve } from 'node:path'
+import { pathToFileURL } from 'node:url'
+import { randomUUID } from 'node:crypto'
+import { DatabaseSync } from 'node:sqlite'
+import { WebSocketServer } from 'ws'
+import { createApplication, type ApplicationRuntime } from '../src/server/app.js'
+import type { ServerConfig } from '../src/server/config.js'
+
+const root = resolve(import.meta.dirname, '..')
+const mobile = resolve(process.env.YAOYAO_MOBILE_CHECKOUT ?? join(root, '../yaoyao-mobile'))
+const { YaoyaoAPI } = await import(pathToFileURL(join(mobile, 'apps/mobile/src/core/api.ts')).href)
+const { OrdinaryStore } = await import(pathToFileURL(join(mobile, 'apps/mobile/src/core/ordinary.ts')).href)
+const { createOrdinaryCache } = await import(pathToFileURL(join(mobile, 'apps/mobile/src/core/ordinary-cache.ts')).href)
+const listen = (server: Server, port = 0) => new Promise<string>(done => server.listen(port, '127.0.0.1', () => done(`http://127.0.0.1:${(server.address() as { port: number }).port}`)))
+const close = (server: Server) => new Promise<void>(done => { server.close(() => done()); server.closeAllConnections() })
+function sqlite(db: DatabaseSync) {
+  return { async execAsync(sql: string) { db.exec(sql) }, async runAsync(sql: string, ...args: any[]) { return db.prepare(sql).run(...args) },
+    async getFirstAsync(sql: string, ...args: any[]) { return db.prepare(sql).get(...args) ?? null }, async getAllAsync(sql: string, ...args: any[]) { return db.prepare(sql).all(...args) } }
+}
+
+test('mobile 1.5 core recovers durable attachments across BFF restart, never repeats admission, and loses its transcript stream on logout', { timeout: 45000 }, async () => {
+  const home = mkdtempSync(join(tmpdir(), 'yaoyao-mobile-pair-')), cookies = new Map<string, string>()
+  const commands: Array<{ method: string; params: any }> = []
+  let runtime: ApplicationRuntime | undefined, web: Server | undefined, store: any, api: any, db: DatabaseSync | undefined
+  let attached = false, simulateDisconnect = true
+  const hermes = createServer((req, res) => {
+    res.setHeader('Content-Type', 'application/json')
+    const path = req.url?.split('?')[0]
+    res.end(JSON.stringify(path === '/api/status' ? { auth_required: true, dashboard_running: true }
+      : path === '/api/auth/ws-ticket' ? { ticket: 'fixture' }
+      : path === '/api/profiles' ? { profiles: [{ name: 'default', is_default: true }] }
+      : path?.endsWith('/messages') ? { messages: [], pagination: { total: 0, returned: 0 } }
+      : path === '/api/sessions' || path === '/api/profiles/sessions' ? { sessions: [] }
+      : path === '/api/config' ? { config: { terminal: { cwd: '' } } } : { ok: true }))
+  })
+  const upstream = await listen(hermes), ws = new WebSocketServer({ server: hermes })
+  ws.on('connection', socket => {
+    socket.send(JSON.stringify({ method: 'event', params: { type: 'gateway.ready', payload: { replay_epoch: 'fixture' } } }))
+    socket.on('message', raw => {
+      const f = JSON.parse(String(raw)); commands.push(f)
+      const result = ['session.create', 'session.resume'].includes(f.method) ? { session_id: 'runtime-fixture', stored_session_id: 'stored-fixture', running: false, info: { profile_name: 'default' } }
+        : f.method === 'file.attach' ? { ref_text: '@file:/fixture/upload.txt', path: '/fixture/upload.txt' }
+        : f.method === 'session.events.since' ? { epoch: 'fixture', events: [] }
+        : f.method === 'session.active_list' ? { sessions: [{ id: 'runtime-fixture', status: 'idle' }] } : { status: 'accepted', ok: true }
+      socket.send(JSON.stringify({ id: f.id, result }))
+    })
+  })
+  for (const folder of ['media', 'attachments', 'images']) mkdirSync(join(home, folder))
+  const config: ServerConfig = { home, host: '127.0.0.1', port: 0, upstream: new URL(upstream), allowedHosts: new Set(),
+    mediaRoot: join(home, 'media'), attachmentsRoot: join(home, 'attachments'), imagesRoot: join(home, 'images'), mediaOwner: 'fixture',
+    production: true, allowInsecureLan: false, insecureLan: false, superviseDashboard: false, localVmHost: 'runner' }
+  const fetcher = async (url: string, options?: RequestInit) => {
+    const headers = new Headers(options?.headers)
+    // This Node fixture has no native Cookie/URLSession jar. Avoid reusing its
+    // global fetch pool across a deliberately destroyed/rebound test listener.
+    headers.set('Connection', 'close')
+    headers.set('Cookie', [...cookies].map(([k, v]) => `${k}=${v}`).join('; '))
+    if (attached && simulateDisconnect && decodeURIComponent(new URL(url).pathname).startsWith('/api/realtime/commands/ios:prompt:')) {
+      simulateDisconnect = false; throw new Error('Fixture disconnect before prompt admission')
+    }
+    const response = await fetch(url, { ...options, headers })
+    for (const cookie of response.headers.getSetCookie()) {
+      const pair = cookie.split(';')[0]!, at = pair.indexOf('='); cookies.set(pair.slice(0, at), pair.slice(at + 1))
+    }
+    if (options?.method === 'POST' && new URL(url).pathname.endsWith('/commands') && JSON.parse(String(options.body)).method === 'file.attach' && response.ok) attached = true
+    return response
+  }
+  const openCache = async () => {
+    db = new DatabaseSync(join(home, 'mobile.sqlite3'))
+    db.exec('CREATE TABLE IF NOT EXISTS account_documents(scope TEXT PRIMARY KEY,payload TEXT NOT NULL)')
+    return createOrdinaryCache(sqlite(db))
+  }
+  try {
+    runtime = createApplication({ config, deferBackground: true }); web = createServer(runtime.app.callback())
+    const origin = await listen(web); config.port = Number(new URL(origin).port)
+    api = new YaoyaoAPI(origin, fetcher); await api.bootstrap()
+    const setup = await api.request('/api/app/setup', 'POST', { username: 'pair-fixture', password: 'pair-fixture-password' })
+    api.csrf = setup.csrfToken
+    const owner = setup.user.id
+    assert.ok((await api.request('/api/realtime/capabilities')).features.includes('ordinary-chat-transcript-v2'))
+    store = new OrdinaryStore(api, await openCache(), 'pair-account', randomUUID, async () => 'Zml4dHVyZQ==')
+    await store.start(); await store.newSession('default')
+    await store.setAttachments([{ id: 'file', name: 'fixture.txt', mimeType: 'text/plain', uri: 'file:///fixture.txt' }])
+    await store.send('paired attachment')
+    const pending = store.getSnapshot().data.pending[0]
+    assert.ok(pending, 'the interrupted send must remain in the durable mobile cache')
+    assert.ok(pending.attemptedCommands.includes(`ios:attachment:${pending.id}:file`), JSON.stringify({ state: pending.state, error: pending.error, attempted: pending.attemptedCommands, methods: commands.map(f => f.method) }))
+    assert.ok(!pending.attemptedCommands.includes(`ios:prompt:${pending.id}`))
+    assert.equal(commands.filter(f => f.method === 'file.attach').length, 1)
+    assert.equal(commands.filter(f => f.method === 'prompt.submit').length, 0)
+    await store.close(); await api.close(); db!.close(); db = undefined
+    runtime.close(); runtime = undefined; await close(web); web = undefined
+    runtime = createApplication({ config, deferBackground: true }); web = createServer(runtime.app.callback()); await listen(web, config.port)
+    api = new YaoyaoAPI(origin, fetcher); await api.bootstrap()
+    store = new OrdinaryStore(api, await openCache(), 'pair-account', randomUUID, async () => 'Zml4dHVyZQ==')
+    await store.start(); await store.retry(pending.id); await store.retry(pending.id)
+    assert.equal(commands.filter(f => f.method === 'file.attach').length, 1, 'restart must reuse the persisted attachment reference')
+    const prompts = commands.filter(f => f.method === 'prompt.submit')
+    assert.equal(prompts.length, 1, JSON.stringify({ expectation: 'retry must execute the never-admitted prompt once', pending: store.getSnapshot().data.pending.map((p: any) => ({ state: p.state, error: p.error, attempted: p.attemptedCommands })), methods: commands.map(f => f.method) }))
+    assert.match(prompts[0]!.params.text, /@file:\/fixture\/upload\.txt/)
+    const snapshot = await api.request('/api/app/chat/sessions/stored-fixture/snapshot?profile=default')
+    assert.equal(snapshot.protocol, 'ordinary-chat-transcript-v2')
+    assert.ok(snapshot.messages.some((m: any) => m.client_message_id === pending.id))
+    let ready!: () => void
+    const opened = new Promise<void>(done => { ready = done }), frames: any[] = []
+    const stream = api.events('/api/app/chat/sessions/stored-fixture/events?profile=default', `${snapshot.epoch}:${snapshot.cursor}`, async (frame: any) => {
+      frames.push(frame); if (frame.event === 'ready') ready()
+    }).catch((error: Error) => error)
+    await opened
+    await api.request('/api/app/logout', 'POST', {})
+    runtime.chatCache!.store.recordCommand(owner, 'default', 'stored-fixture', 'prompt.submit', { text: 'must-not-leak-after-logout', _delivery_id: 'late-fixture' })
+    let timeout: ReturnType<typeof setTimeout> | undefined
+    try { await Promise.race([stream, new Promise((_, reject) => { timeout = setTimeout(() => reject(new Error('Logged-out transcript stream did not close')), 1500) })]) }
+    finally { clearTimeout(timeout) }
+    assert.ok(!JSON.stringify(frames).includes('must-not-leak-after-logout'))
+    console.log('PAIR_OK: transcript-v2 handshake; real HTTP/SSE; SQLite reopen; BFF restart; attachment once; prompt once; canonical echo; logout closes stream')
+  } finally {
+    await store?.close().catch(() => {}); await api?.close().catch(() => {}); db?.close(); runtime?.close()
+    if (web) await close(web)
+    for (const socket of ws.clients) socket.terminate()
+    await new Promise<void>(done => ws.close(() => done())); await close(hermes)
+    rmSync(home, { recursive: true, force: true })
+  }
+})

+ 2 - 2
src/server/chatTranscriptApi.ts

@@ -32,7 +32,7 @@ export function chatTranscriptRouter(cache:ChatCacheCoordinator,auth:LocalAuthSt
     }catch(error){store.db.exec('ROLLBACK TO transcript_snapshot; RELEASE transcript_snapshot');throw error}
   })
   router.get('/api/app/chat/sessions/:id/events',ctx=>{
-    const {owner,profile,id}=scope(ctx),version=auth.pushAuthorizationVersion(owner)
+    const {owner,profile,id}=scope(ctx),version=auth.pushAuthorizationVersion(owner),cookie=ctx.get('cookie')
     const raw=ctx.get('last-event-id')||String(ctx.query.after??''),parts=raw.split(':')
     let cursor=Number(parts.at(-1)),closed=false,lastFlush=0,ready=false
     transcripts.seed(owner,profile,id)
@@ -40,7 +40,7 @@ export function chatTranscriptRouter(cache:ChatCacheCoordinator,auth:LocalAuthSt
     if(parts.length!==2||parts[0]!==epoch||!Number.isSafeInteger(cursor)||cursor<0||cursor>transcripts.cursor(owner,profile,id))throw new HttpError(409,'聊天事件需要重新同步','transcript_reset')
     const res=ctx.res
     let timer:ReturnType<typeof setTimeout>|undefined,heartbeat:ReturnType<typeof setInterval>|undefined,off=()=>{}
-    const valid=()=>!store.isClosed&&auth.isUserActive(owner)&&auth.pushAuthorizationVersion(owner)===version&&auth.current(ctx)?.id===owner&&store.ownsSession(owner,profile,id)&&auth.canUseSource(owner,'local',profile)
+    const valid=()=>!store.isClosed&&auth.isUserActive(owner)&&auth.pushAuthorizationVersion(owner)===version&&auth.currentFromCookieHeader(cookie)?.id===owner&&store.ownsSession(owner,profile,id)&&auth.canUseSource(owner,'local',profile)
     const close=()=>{if(closed)return;closed=true;clearTimeout(timer);clearInterval(heartbeat);off();if(!res.writableEnded)res.end()}
     const write=(value:string)=>{
       if(closed||res.destroyed||!valid()){close();return false}

+ 7 - 2
src/server/realtimeApi.ts

@@ -186,8 +186,13 @@ export class RealtimeAPI {
           ? { epoch: canonicalEpoch(body.epoch), cursor: groupCursor(body.cursor) } : undefined)
         ctx.status = 201; ctx.body = { id: c.id, brokerEpoch: this.broker.epoch }; return
       }
-      const receipt = /^\/commands\/([A-Za-z0-9:_-]{1,200})$/.exec(path)
-      if (receipt && ctx.method === 'GET') { ctx.body = this.broker.receipt(principal, receipt[1]!); return }
+      const receipt = /^\/commands\/([^/]+)$/.exec(path)
+      if (receipt && ctx.method === 'GET') {
+        let id: string
+        try { id = decodeURIComponent(receipt[1]!) } catch { throw new HttpError(400, 'Invalid request ID', 'invalid_request_id') }
+        if (!/^[A-Za-z0-9:_-]{1,200}$/.test(id)) throw new HttpError(400, 'Invalid request ID', 'invalid_request_id')
+        ctx.body = this.broker.receipt(principal, id); return
+      }
       const channel = /^\/channels\/([0-9a-f-]{36})(?:\/(commands|events))?$/.exec(path)
       if (!channel) throw new HttpError(404, 'Realtime route not found', 'not_found')
       const c = this.broker.get(principal, channel[1]!)

+ 71 - 0
tests/server/chatTranscriptAuthorization.test.ts

@@ -0,0 +1,71 @@
+// @vitest-environment node
+import { EventEmitter } from 'node:events'
+import { mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import type Koa from 'koa'
+import { afterEach, beforeEach, expect, it, vi } from 'vitest'
+import { LocalAuthStore, type UpstreamServiceSession } from '../../src/server/localAuth'
+import { ChatCacheStore, ChatCacheCoordinator } from '../../src/server/chatCache'
+import { chatTranscriptRouter } from '../../src/server/chatTranscriptApi'
+
+class Response extends EventEmitter {
+  writableEnded = false
+  destroyed = false
+  writableLength = 0
+  writes: string[] = []
+  writeHead() {}
+  flushHeaders() {}
+  write(value: string) { this.writes.push(value); return true }
+  end() { this.writableEnded = true; this.emit('close') }
+}
+function context(cookie = ''): Koa.Context {
+  const headers: Record<string, string | string[]> = {}
+  return { state: {}, request: {}, response: { headers }, get: (name: string) => name === 'cookie' ? cookie : '',
+    set: (name: string, value: string | string[]) => { headers[name.toLowerCase()] = value } } as unknown as Koa.Context
+}
+const issuedCookie = (ctx: Koa.Context) => (ctx.response.headers['set-cookie'] as string[])[0]!.split(';')[0]!
+let home: string, store: ChatCacheStore, cache: ChatCacheCoordinator, auth: LocalAuthStore, cookie: string, owner: string, res: Response
+beforeEach(async () => {
+  vi.useFakeTimers()
+  home = mkdtempSync(join(tmpdir(), 'yaoyao-transcript-auth-'))
+  store = new ChatCacheStore(home)
+  cache = new ChatCacheCoordinator(store, { request: vi.fn() } as unknown as UpstreamServiceSession)
+  auth = new LocalAuthStore(home)
+  const setup = context(); owner = auth.setupAdmin(setup, 'fixture-user', 'fixture-password').id; cookie = issuedCookie(setup)
+  store.recordCommand(owner, 'default', 'session', 'session.create', {})
+  store.transcripts.seed(owner, 'default', 'session')
+  res = new Response()
+  const ctx = context(cookie), cursor = `${store.transcripts.epochFor(owner, 'default', 'session')}:0`
+  ctx.state.localUser = auth.require(ctx)
+  ctx.path = '/api/app/chat/sessions/session/events'; ctx.method = 'GET'; ctx.query = { profile: 'default', after: cursor }
+  ctx.res = res as unknown as Koa.Context['res']
+  await chatTranscriptRouter(cache, auth).routes()(ctx, async () => {})
+  expect(res.writes.join('')).toContain('event: ready')
+})
+afterEach(() => { res?.end(); cache?.close(); store?.close(); rmSync(home, { recursive: true, force: true }); vi.useRealTimers() })
+const publish = () => store.recordCommand(owner, 'default', 'session', 'prompt.submit', { text: 'after-auth-change', _delivery_id: 'fixture-prompt' })
+
+it('closes a cached-user transcript stream before publishing any message after logout', async () => {
+  auth.logout(context(cookie))
+  publish(); await Promise.resolve()
+  expect(res.writableEnded).toBe(true)
+  expect(res.writes.join('')).not.toContain('after-auth-change')
+})
+it('closes an idle expired-session transcript stream on its next heartbeat', () => {
+  vi.setSystemTime(Date.now() + 31 * 24 * 60 * 60 * 1000)
+  vi.advanceTimersByTime(5000)
+  expect(res.writableEnded).toBe(true)
+})
+it('keeps a valid transcript stream open when another login logs out', async () => {
+  const other = context(); auth.login(other, 'fixture-user', 'fixture-password'); auth.logout(context(issuedCookie(other)))
+  publish(); await Promise.resolve()
+  expect(res.writableEnded).toBe(false)
+  expect(res.writes.join('')).toContain('after-auth-change')
+})
+it('closes the transcript stream after credentials revoke its session', async () => {
+  auth.changeCredentials(context(cookie), 'fixture-password', 'fixture-password-changed')
+  publish(); await Promise.resolve()
+  expect(res.writableEnded).toBe(true)
+  expect(res.writes.join('')).not.toContain('after-auth-change')
+})

+ 36 - 0
tests/server/realtimeReceiptRoutes.test.ts

@@ -0,0 +1,36 @@
+// @vitest-environment node
+import { afterEach, expect, it } from 'vitest'
+import { mkdtempSync, mkdirSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { createServer, type Server } from 'node:http'
+import request from 'supertest'
+import { createApplication, type ApplicationRuntime } from '../../src/server/app'
+import type { RealtimeReceipts } from '../../src/server/realtimeReceipts'
+
+let runtime: ApplicationRuntime | undefined, home: string, server: Server | undefined
+afterEach(async () => { runtime?.close(); if (server) { server.closeAllConnections(); await new Promise<void>(done => server!.close(() => done())) }
+  if (home) rmSync(home, { recursive: true, force: true }) })
+it('recovers encoded mobile receipt IDs while preserving authentication and strict ID validation', async () => {
+  home = mkdtempSync(join(tmpdir(), 'yaoyao-receipt-route-'))
+  for (const name of ['media', 'attachments', 'images']) mkdirSync(join(home, name))
+  runtime = createApplication({ deferBackground: true, fetchImpl: async () => { throw new Error('No upstream network in this test') },
+    config: { host: '127.0.0.1', port: 15300, upstream: new URL('http://127.0.0.1:1'), home, allowedHosts: new Set(),
+      mediaRoot: join(home, 'media'), attachmentsRoot: join(home, 'attachments'), imagesRoot: join(home, 'images'),
+      mediaOwner: 'fixture', production: false, allowInsecureLan: false, insecureLan: false } })
+  server = createServer(runtime.app.callback()); await new Promise<void>(done => server!.listen(0, '127.0.0.1', done))
+  const agent = request.agent(server), headers = { Host: '127.0.0.1:15300', Origin: 'http://127.0.0.1:15300' }
+  const bootstrap = await agent.get('/api/app/bootstrap?inspectOnly=1').set(headers).expect(200)
+  const setup = await agent.post('/api/app/setup').set(headers).set('X-CSRF-Token', bootstrap.body.csrfToken)
+    .send({ username: 'receipt-fixture', password: 'fixture-password' }).expect(200)
+  const id = 'ios:attachment:request:file', owner = `user:${setup.body.user.id}:${runtime.auth.pushAuthorizationVersion(setup.body.user.id)}`
+  const receipts = (runtime.realtime.broker as unknown as { receipts: RealtimeReceipts }).receipts
+  receipts.reserve(owner, id, '{}'); receipts.finish(owner, id, 'confirmed', { result: { ref_text: '@file:/fixture/file' } }, { method: 'file.attach' })
+  for (const path of [id, encodeURIComponent(id)]) {
+    const result = await agent.get(`/api/realtime/commands/${path}`).set(headers).expect(200)
+    expect(result.body).toMatchObject({ requestId: id, state: 'confirmed', response: { result: { ref_text: '@file:/fixture/file' } } })
+  }
+  await request(server).get(`/api/realtime/commands/${encodeURIComponent(id)}`).set(headers).expect(401)
+  for (const invalid of ['ios%2Fforeign', '%ZZ', 'bad%20id'])
+    await agent.get(`/api/realtime/commands/${invalid}`).set(headers).expect(400)
+})