{
  "config": {
    "argv": [
      "/opt/hostedtoolcache/node/20.20.2/x64/bin/node",
      "/home/runner/work/dr-card-web/dr-card-web/node_modules/.bin/playwright",
      "test",
      "tests/e2e/test-gate.spec.ts"
    ],
    "configFile": "/home/runner/work/dr-card-web/dr-card-web/playwright.config.ts",
    "rootDir": "/home/runner/work/dr-card-web/dr-card-web/tests/e2e",
    "failOnFlakyTests": false,
    "forbidOnly": true,
    "fullyParallel": true,
    "globalSetup": null,
    "globalTeardown": null,
    "globalTimeout": 0,
    "grep": {},
    "grepInvert": null,
    "maxFailures": 0,
    "metadata": {
      "ci": {
        "commitHref": "https://github.com/sanchuang-dev/dr-card-web/commit/de0d27e0dd94e5e84ade029472d52416c9c1159f",
        "commitHash": "de0d27e0dd94e5e84ade029472d52416c9c1159f",
        "prHref": "https://github.com/sanchuang-dev/dr-card-web/pull/249",
        "prTitle": "chore(test): promote API QS signature and chat preflight fixes",
        "prBaseHash": "aabe754da7bf380ada520643cd2f117a575434bc",
        "buildHref": "https://github.com/sanchuang-dev/dr-card-web/actions/runs/38892048769"
      },
      "gitCommit": {
        "shortHash": "de0d27e",
        "hash": "de0d27e0dd94e5e84ade029472d52416c9c1159f",
        "subject": "Merge ae36725d9d0cc8c292dd6a711a3406178ac34c92 into aabe754da7bf380ada520643cd2f117a575434bc",
        "body": "Merge ae36725d9d0cc8c292dd6a711a3406178ac34c92 into aabe754da7bf380ada520643cd2f117a575434bc\n",
        "author": {
          "name": "Tomz Dang",
          "email": "dangjingtao@163.com",
          "time": 1792149039000
        },
        "committer": {
          "name": "GitHub",
          "email": "noreply@github.com",
          "time": 1792149039000
        },
        "branch": "HEAD"
      },
      "gitDiff": "diff --git a/docs/engineering/auth-boundary.md b/docs/engineering/auth-boundary.md\nindex 5b2afe9..6ad7466 100644\n--- a/docs/engineering/auth-boundary.md\n+++ b/docs/engineering/auth-boundary.md\n@@ -19,6 +19,16 @@\n \n 旧 iOS `webkit.messageHandlers.getAuthorizationInfo.postMessage({}) → window.onToken(token)` 仅作为历史联调 / Bridge Lab Raw Probe 证据保留，不再是 production auth contract。\n \n+## 2026-10-16｜/api 请求签名（来自后台源码静态核对）\n+\n+本地 API `docs/api-signature.md`、`src/middleware/auth.ts`、`src/app/api/config.ts` 已确认：所有 `/api/**` HTTP 请求经 `AppAuthMiddleware` 校验。H5 除已有 Bearer 会话外，还需携带 `token`、`timesp`（Asia/Shanghai 的 `yyyy-MM-dd HH:mm:ss`）及 `secstr`（`md5(salt + 排序拼接参数 + salt)`）；服务端对收到的 Query + Body 合并（Body 优先），排除 `secstr`/`callback` 后计算签名，时间窗口默认 ±5 分钟。\n+\n+实现统一在 `src/services/http/apiSignature.ts`；Axios 通过 `httpClient` 拦截器补签，`/api/oauth/login` 的 `skipAuth` **仅跳过 Bearer，不能跳过 secstr**；客服 SSE 和真人转接的独立 `fetch` 调同一签名函数，保留不可重放语义；Socket.IO 握手仍使用既有 `auth.token`，不属于 `/api/**` HTTP 中间件。\n+\n+**两种 Token 不可混淆**：Native `getLoginToken()` 返回的上游 `token/salt` 用于请求签名；OAuth 返回的 `accessToken` 负责 Bearer 业务会话。Native 凭据只在文档内存保存，不新增持久化字段、不注入第三方 origin。无有效签名凭据不能静默发送无签名业务请求。\n+\n+此结论基于本地后端源码和签名样例复算，以及 H5 CI；**不代表后端已部署该版本，亦不代表 Android/iOS WebView、CORS、上传 FormData 和真实用户会话已验收**。后端文档另已知「客户端自行提交 salt 即被采信」及「窗口内无 nonce 防重放」的安全限制，当前仅做协议对接；后端安全策略升级须独立评估。\n+\n ## 当前仍待确认\n \n Native Bridge 解决的是“如何从 App 拿到登录 token”，以下问题仍不能自行推断：\ndiff --git a/src/pages/serviceChat/useAiChatSend.test.tsx b/src/pages/serviceChat/useAiChatSend.test.tsx\nindex 4f264b9..cd747c6 100644\n--- a/src/pages/serviceChat/useAiChatSend.test.tsx\n+++ b/src/pages/serviceChat/useAiChatSend.test.tsx\n@@ -2,10 +2,15 @@ import { act, renderHook } from '@testing-library/react'\n import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'\n import { combineChatMessages, useAiChatSend } from './useAiChatSend'\n import type { ChatHistoryMessage } from '../../services/chatMessages'\n+import { AppError } from '../../services/http'\n \n const mocks = vi.hoisted(() => ({ token: 'token-A', send: vi.fn() }))\n vi.mock('../../services/auth/session', () => ({\n   getAuthSession: () => ({ accessToken: mocks.token, userInfo: null }),\n+  isApiSignaturePreflightError: (error: unknown) =>\n+    error instanceof Error && 'code' in error &&\n+    ['AUTH_SIGNATURE_CREDENTIALS_MISSING', 'AUTH_NATIVE_CREDENTIALS_INVALID',\n+      'AUTH_NATIVE_CREDENTIALS_UNAVAILABLE'].includes(String(error.code)),\n }))\n vi.mock('../../services/chatStream', () => ({ sendChatStream: mocks.send }))\n const message = (id: number, role: 'bot' | 'user' = 'bot', text = '回复'): ChatHistoryMessage => ({\n@@ -78,6 +83,29 @@ describe('#137 AI stream UI state machine', () => {\n     hook.unmount()\n   })\n \n+  it.each([\n+    'AUTH_SIGNATURE_CREDENTIALS_MISSING',\n+    'AUTH_NATIVE_CREDENTIALS_INVALID',\n+    'AUTH_NATIVE_CREDENTIALS_UNAVAILABLE',\n+  ])('marks unsent user bubble failed on %s without claiming delivery', async code => {\n+    mocks.send.mockRejectedValueOnce(new AppError({\n+      kind: 'contract', code, message: 'Native 签名凭据不可用',\n+    }))\n+    const sync = vi.fn()\n+    const hook = renderHook(() => useAiChatSend(sync))\n+    await act(async () => {\n+      expect(await hook.result.current.send('未发出去的消息', [])).toBe(false)\n+    })\n+    expect(hook.result.current.phase).toBe('failed')\n+    expect(hook.result.current.error).toBe('Native 签名凭据不可用')\n+    expect(hook.result.current.messages).toEqual([\n+      expect.objectContaining({ role: 'user', text: '未发出去的消息', status: 'failed' }),\n+    ])\n+    expect(sync).not.toHaveBeenCalled()\n+    expect(mocks.send).toHaveBeenCalledTimes(1)\n+    hook.unmount()\n+  })\n+\n   it('retains uncertain server results with an explicit history check, not silent resend', async () => {\n     mocks.send.mockResolvedValueOnce({ mode: 'ai', text: '完成', messageId: 42 })\n     const sync = vi.fn().mockResolvedValueOnce(null)\ndiff --git a/src/pages/serviceChat/useAiChatSend.ts b/src/pages/serviceChat/useAiChatSend.ts\nindex 33e9a5e..57a947a 100644\n--- a/src/pages/serviceChat/useAiChatSend.ts\n+++ b/src/pages/serviceChat/useAiChatSend.ts\n@@ -1,6 +1,6 @@\n import { useCallback, useEffect, useRef, useState } from 'react'\n import type { ChatMessage } from '../../app/fixtures'\n-import { getAuthSession } from '../../services/auth/session'\n+import { getAuthSession, isApiSignaturePreflightError } from '../../services/auth/session'\n import type { ChatHistoryMessage } from '../../services/chatMessages'\n import { sendChatStream } from '../../services/chatStream'\n \n@@ -155,11 +155,14 @@ export function useAiChatSend(syncLatest: () => Promise<ChatHistoryMessage[] | n\n       return true\n     } catch (error) {\n       if (!alive()) return false\n+      // Signature preflight happens before fetch; the optimistic message was never sent.\n+      // All other failures retain the history-check warning and no-auto-retry guard.\n+      const noRequestSent = isApiSignaturePreflightError(error)\n       update(previous => ({\n         ...previous, phase: 'failed',\n         messages: previous.messages.map(item => item.id === userId\n-          ? { ...item, status: 'sent' } : item),\n-        error: errorText(error) + '。' + CHECK_HISTORY,\n+          ? { ...item, status: noRequestSent ? 'failed' : 'sent' } : item),\n+        error: noRequestSent ? errorText(error) : errorText(error) + '。' + CHECK_HISTORY,\n       }))\n       return false\n     } finally {\ndiff --git a/src/pages/serviceChat/useHumanChat.test.tsx b/src/pages/serviceChat/useHumanChat.test.tsx\nindex ddc60f3..df3e23f 100644\n--- a/src/pages/serviceChat/useHumanChat.test.tsx\n+++ b/src/pages/serviceChat/useHumanChat.test.tsx\n@@ -2,6 +2,7 @@ import { act, renderHook, waitFor } from '@testing-library/react'\n import { beforeEach, describe, expect, it, vi } from 'vitest'\n import { useHumanChat } from './useHumanChat'\n import type { ChatSocketEvents } from '../../services/chatHuman'\n+import { AppError } from '../../services/http'\n \n const mocks = vi.hoisted(() => ({\n   token: 'user-a-token', events: null as ChatSocketEvents | null,\n@@ -9,6 +10,10 @@ const mocks = vi.hoisted(() => ({\n }))\n vi.mock('../../services/auth/session', () => ({\n   getAuthSession: () => mocks.token ? { accessToken: mocks.token } : undefined,\n+  isApiSignaturePreflightError: (error: unknown) =>\n+    error instanceof Error && 'code' in error &&\n+    ['AUTH_SIGNATURE_CREDENTIALS_MISSING', 'AUTH_NATIVE_CREDENTIALS_INVALID',\n+      'AUTH_NATIVE_CREDENTIALS_UNAVAILABLE'].includes(String(error.code)),\n }))\n vi.mock('../../services/chatHuman', () => ({\n   connectChatSocket: mocks.connect,\n@@ -82,6 +87,26 @@ describe('#138 human chat connection, transfer, and lifecycle', () => {\n     hook.unmount()\n   })\n \n+  it.each([\n+    'AUTH_SIGNATURE_CREDENTIALS_MISSING',\n+    'AUTH_NATIVE_CREDENTIALS_INVALID',\n+    'AUTH_NATIVE_CREDENTIALS_UNAVAILABLE',\n+  ])('shows %s as a preflight error without an uncertain-transfer warning', async code => {\n+    mocks.transfer.mockRejectedValueOnce(new AppError({\n+      kind: 'contract', code, message: 'Native 登录凭据缺少 token 或 salt',\n+    }))\n+    const onTransferred = vi.fn()\n+    const hook = renderHook(() => useHumanChat(vi.fn(), vi.fn().mockResolvedValue(true), 0, onTransferred))\n+    act(() => mocks.events?.onStatus('connected'))\n+    await act(async () => { expect(await hook.result.current.transfer()).toBe(false) })\n+    expect(hook.result.current.active).toBe(false)\n+    expect(hook.result.current.needsHistoryCheck).toBe(false)\n+    expect(hook.result.current.error).toBe('Native 登录凭据缺少 token 或 salt')\n+    expect(hook.result.current.canTransfer).toBe(true)\n+    expect(onTransferred).not.toHaveBeenCalled()\n+    hook.unmount()\n+  })\n+\n   it('retains a content-free uncertain-transfer warning across 401 auth reset', async () => {\n     const onTransferred = vi.fn()\n     mocks.transfer.mockImplementationOnce(async () => {\ndiff --git a/src/pages/serviceChat/useHumanChat.ts b/src/pages/serviceChat/useHumanChat.ts\nindex e607c3a..c823c7c 100644\n--- a/src/pages/serviceChat/useHumanChat.ts\n+++ b/src/pages/serviceChat/useHumanChat.ts\n@@ -1,5 +1,5 @@\n import { useCallback, useEffect, useRef, useState } from 'react'\n-import { getAuthSession } from '../../services/auth/session'\n+import { getAuthSession, isApiSignaturePreflightError } from '../../services/auth/session'\n import { connectChatSocket, transferChatToHuman, type ChatSocketHandle, type ChatSocketStatus } from '../../services/chatHuman'\n import type { BackendChatRecord } from '../../services/chatMessages'\n \n@@ -139,11 +139,16 @@ export function useHumanChat(\n       return true\n     } catch (error) {\n       if (generation.current !== epoch || tokenKey() !== key) return false\n-      authInterrupted.current = true\n+      // Only these signature preflight failures guarantee that no HTTP transfer was sent.\n+      // Keep the history-check guard for failures after the network request may have started.\n+      const noRequestSent = isApiSignaturePreflightError(error)\n+      if (!noRequestSent) authInterrupted.current = true\n       setSnapshot(old => ({\n-        ...old, transferring: false, needsHistoryCheck: true,\n-        error: (error instanceof Error ? error.message : '转人工失败') +\n-          '。如请求已经送达，请先检查历史，避免重复转接。',\n+        ...old, transferring: false, needsHistoryCheck: !noRequestSent,\n+        error: noRequestSent\n+          ? error.message\n+          : (error instanceof Error ? error.message : '转人工失败') +\n+            '。如请求已经送达，请先检查历史，避免重复转接。',\n       }))\n       return false\n     } finally {\ndiff --git a/src/services/auth/session.bootstrap.test.ts b/src/services/auth/session.bootstrap.test.ts\nindex 239bf60..0ed24db 100644\n--- a/src/services/auth/session.bootstrap.test.ts\n+++ b/src/services/auth/session.bootstrap.test.ts\n@@ -4,6 +4,7 @@ const mocks = vi.hoisted(() => ({\n   request: vi.fn(),\n   getLoginToken: vi.fn(),\n   setAuthHeadersProvider: vi.fn(),\n+  setSignatureCredentialsProvider: vi.fn(),\n   setUnauthorizedHandler: vi.fn(),\n   setAuthFailureHandler: vi.fn(),\n }))\n@@ -24,6 +25,7 @@ vi.mock('../http/httpClient', () => ({\n     request: mocks.request,\n   }),\n   setHttpAuthHeadersProvider: mocks.setAuthHeadersProvider,\n+  setHttpSignatureCredentialsProvider: mocks.setSignatureCredentialsProvider,\n   setHttpUnauthorizedHandler: mocks.setUnauthorizedHandler,\n   setHttpAuthFailureHandler: mocks.setAuthFailureHandler,\n }))\n@@ -34,6 +36,7 @@ describe('bootstrap auth 401 handling', () => {\n     mocks.request.mockReset()\n     mocks.getLoginToken.mockReset()\n     mocks.setAuthHeadersProvider.mockReset()\n+    mocks.setSignatureCredentialsProvider.mockReset()\n     mocks.setUnauthorizedHandler.mockReset()\n     mocks.setAuthFailureHandler.mockReset()\n     mocks.getLoginToken.mockResolvedValue({ token: 'native-token', salt: 'native-salt' })\ndiff --git a/src/services/auth/session.notice-auth.test.ts b/src/services/auth/session.notice-auth.test.ts\nindex 75243a0..0b43ace 100644\n--- a/src/services/auth/session.notice-auth.test.ts\n+++ b/src/services/auth/session.notice-auth.test.ts\n@@ -21,14 +21,17 @@ vi.mock('../http/httpClient', async importOriginal => {\n   const actual = await importOriginal<typeof import('../http/httpClient')>()\n   const adapter: AxiosAdapter = async (config) => {\n     const url = config.url ?? ''\n+    const parsedUrl = new URL(url, 'https://api.example.test')\n+    const sameOrigin = parsedUrl.origin === 'https://api.example.test'\n+    const route = sameOrigin ? parsedUrl.pathname : url\n     probes.requests.push({\n-      url,\n+      url: route,\n       authorization: config.headers.get('Authorization')?.toString(),\n     })\n     let data: unknown = { ok: true }\n-    if (url === '/api/oauth/login') {\n+    if (route === '/api/oauth/login') {\n       data = { code: 0, data: { userInfo: { id: 9 }, accessToken: probes.issuedToken } }\n-    } else if (url === '/api/notices/unread-count') {\n+    } else if (route === '/api/notices/unread-count') {\n       if (probes.unread401Once) {\n         probes.unread401Once = false\n         data = { code: 401, message: '请先登录' }\n@@ -69,12 +72,11 @@ describe('#213 notice auth header follows the session, not the current route', (\n       .toEqual([{ url: '/api/notices/unread-count', authorization: 'Bearer access-A' }])\n   })\n \n-  it('sends no invented credentials when there is no session', async () => {\n-    await expect(fetchNoticeUnreadCounts()).resolves.toMatchObject({ total: 2 })\n-    expect(probes.requests[0]).toEqual({\n-      url: '/api/notices/unread-count',\n-      authorization: undefined,\n+  it('does not silently send an unsigned API request when no Native session exists', async () => {\n+    await expect(fetchNoticeUnreadCounts()).rejects.toMatchObject({\n+      code: 'AUTH_SIGNATURE_CREDENTIALS_MISSING',\n     })\n+    expect(probes.requests).toHaveLength(0)\n   })\n \n   it('excludes Bearer from OAuth skipAuth, explicit skipAuth and cross-origin requests', async () => {\ndiff --git a/src/services/auth/session.ts b/src/services/auth/session.ts\nindex 260115d..eacd033 100644\n--- a/src/services/auth/session.ts\n+++ b/src/services/auth/session.ts\n@@ -1,11 +1,13 @@\n import { runtimePolicy } from '../../app/config/runtime'\n import { getLoginToken } from '../nativeBridge'\n-import { createContractError, isAppError } from '../http/appError'\n+import { createContractError, isAppError, type AppError } from '../http/appError'\n import { STORAGE_KEYS, storage } from '../../storage'\n+import type { ApiSignatureCredentials } from '../http/apiSignature'\n import {\n   createHttpClient,\n   setHttpAuthFailureHandler,\n   setHttpAuthHeadersProvider,\n+  setHttpSignatureCredentialsProvider,\n   setHttpUnauthorizedHandler,\n } from '../http/httpClient'\n \n@@ -38,6 +40,8 @@ let initialBootstrap: Promise<AuthSession> | undefined\n let authFlowEnabled = false\n let initializedForDocument = false\n let volatileSession: AuthSession | undefined\n+// Native upstream credentials never enter sessionStorage, URLs outside /api, or logs.\n+let signatureCredentials: ApiSignatureCredentials | undefined\n \n function readSession(): AuthSession | undefined {\n   const value = storage.read(STORAGE_KEYS.authSession)\n@@ -55,6 +59,7 @@ function writeSession(session: AuthSession) {\n \n export function clearAuthSession() {\n   volatileSession = undefined\n+  signatureCredentials = undefined\n   storage.remove(STORAGE_KEYS.authSession)\n   if (typeof window !== 'undefined') window.dispatchEvent(new Event('dr-card-ui:auth-session-cleared'))\n }\n@@ -104,9 +109,11 @@ async function loginFromNative(): Promise<AuthSession> {\n     url: LOGIN_PATH,\n     data: { salt: credentials.salt, token: credentials.token },\n     skipAuth: true,\n+    signatureCredentials: { token: credentials.token, salt: credentials.salt },\n   })\n \n   const session = parseLoginResponse(result)\n+  signatureCredentials = { token: credentials.token, salt: credentials.salt }\n   writeSession(session)\n   return session\n }\n@@ -114,7 +121,7 @@ async function loginFromNative(): Promise<AuthSession> {\n export function authenticate(force = false): Promise<AuthSession> {\n   if (!force) {\n     const current = readSession()\n-    if (current) return Promise.resolve(current)\n+    if (current && signatureCredentials) return Promise.resolve(current)\n   }\n   if (inFlightLogin) return inFlightLogin\n \n@@ -179,6 +186,41 @@ export async function refreshChatAuthAfterUnauthorized(): Promise<void> {\n   if (authFlowEnabled) await reauthenticateAfterUnauthorized()\n }\n \n+/** Obtain the upstream Native token/salt for independent SSE and HTTP transports.\n+ * A restored accessToken requires re-reading the Native bridge before signing. */\n+export async function getApiSignatureCredentials(): Promise<ApiSignatureCredentials> {\n+  if (signatureCredentials) return signatureCredentials\n+  if (!readSession()) {\n+    throw createContractError('未建立签名会话', { code: 'AUTH_SIGNATURE_CREDENTIALS_MISSING' })\n+  }\n+  // Bridge failures happen before the HTTP request; preserve that distinction.\n+  let native: Awaited<ReturnType<typeof getLoginToken>>\n+  try {\n+    native = await getLoginToken()\n+  } catch {\n+    throw createContractError('无法取得 Native 签名凭据，请检查 App 登录状态', {\n+      code: 'AUTH_NATIVE_CREDENTIALS_UNAVAILABLE',\n+    })\n+  }\n+  if (!native.token.trim() || !native.salt?.trim()) {\n+    throw createContractError('Native 登录凭据缺少 token 或 salt', {\n+      code: 'AUTH_NATIVE_CREDENTIALS_INVALID',\n+    })\n+  }\n+  signatureCredentials = { token: native.token, salt: native.salt }\n+  return signatureCredentials\n+}\n+\n+/** Only these failures certify that signing stopped before HTTP transport. */\n+export function isApiSignaturePreflightError(error: unknown): error is AppError {\n+  return isAppError(error) &&\n+    (error.code === 'AUTH_SIGNATURE_CREDENTIALS_MISSING' ||\n+      error.code === 'AUTH_NATIVE_CREDENTIALS_INVALID' ||\n+      error.code === 'AUTH_NATIVE_CREDENTIALS_UNAVAILABLE')\n+}\n+\n+setHttpSignatureCredentialsProvider(getApiSignatureCredentials)\n+\n setHttpAuthHeadersProvider(() => {\n   // Route-level authFlowEnabled controls reauthentication/expiry UI, not credential attachment.\n   // Any same-origin API request with an existing session must carry its Bearer token.\ndiff --git a/src/services/chatHuman.test.ts b/src/services/chatHuman.test.ts\nindex 82e63e8..d8d3fc5 100644\n--- a/src/services/chatHuman.test.ts\n+++ b/src/services/chatHuman.test.ts\n@@ -1,5 +1,6 @@\n import { beforeEach, describe, expect, it, vi } from 'vitest'\n import { connectChatSocket, transferChatToHuman } from './chatHuman'\n+import { AppError } from './http'\n \n const mocks = vi.hoisted(() => {\n   const handlers = new Map<string, (...args: any[]) => void>()\n@@ -13,7 +14,7 @@ const mocks = vi.hoisted(() => {\n   }\n   return {\n     socket, handlers, io: vi.fn(() => socket), fetch: vi.fn(),\n-    token: 'secret-token', refresh: vi.fn(),\n+    token: 'secret-token', refresh: vi.fn(), signature: vi.fn(),\n   }\n })\n vi.mock('socket.io-client', () => ({ io: mocks.io }))\n@@ -23,6 +24,7 @@ vi.mock('../app/config/runtime', () => ({\n vi.mock('./auth/session', () => ({\n   getAuthSession: () => ({ accessToken: mocks.token }),\n   refreshChatAuthAfterUnauthorized: mocks.refresh,\n+  getApiSignatureCredentials: mocks.signature,\n }))\n \n beforeEach(() => {\n@@ -33,6 +35,7 @@ beforeEach(() => {\n   mocks.socket.connected = false\n   mocks.token = 'secret-token'\n   mocks.fetch.mockReset(); mocks.refresh.mockReset()\n+  mocks.signature.mockReset().mockResolvedValue({ token: 'native-token', salt: 'test-salt' })\n   vi.stubGlobal('fetch', mocks.fetch)\n })\n \n@@ -79,13 +82,31 @@ describe('#138 Socket.IO client contract', () => {\n     }), { status: 200, headers: { 'Content-Type': 'application/json' } }))\n     await expect(transferChatToHuman()).resolves.toBeUndefined()\n     const [url, request] = mocks.fetch.mock.calls[0]\n-    expect(url).toBe('https://api.example.test/api/chatmessages/transfer')\n+    const signedUrl = new URL(url)\n+    expect(signedUrl.origin + signedUrl.pathname).toBe('https://api.example.test/api/chatmessages/transfer')\n+    expect(signedUrl.searchParams.get('token')).toBe('native-token')\n+    expect(signedUrl.searchParams.get('secstr')).toMatch(/^[0-9a-f]{32}$/)\n+    expect(request.headers.salt).toBe('test-salt')\n     expect(request.headers.Authorization).toBe('Bearer secret-token')\n     expect(request.method).toBe('POST')\n     expect(JSON.parse(request.body)).toEqual({})\n     expect(mocks.fetch).toHaveBeenCalledTimes(1)\n   })\n \n+  it.each([\n+    'AUTH_SIGNATURE_CREDENTIALS_MISSING',\n+    'AUTH_NATIVE_CREDENTIALS_INVALID',\n+    'AUTH_NATIVE_CREDENTIALS_UNAVAILABLE',\n+  ])('preserves %s before sending any transfer request', async code => {\n+    const error = new AppError({\n+      kind: 'contract', code, message: 'Native 登录凭据缺少 token 或 salt',\n+    })\n+    mocks.signature.mockRejectedValueOnce(error)\n+    await expect(transferChatToHuman()).rejects.toBe(error)\n+    expect(mocks.fetch).not.toHaveBeenCalled()\n+    expect(mocks.refresh).not.toHaveBeenCalled()\n+  })\n+\n   it('does not fake transfer success on business error, network failure or invalid mode', async () => {\n     mocks.fetch.mockResolvedValueOnce(new Response(JSON.stringify({\n       code: 500, message: '没有对应服务', data: [],\ndiff --git a/src/services/chatHuman.ts b/src/services/chatHuman.ts\nindex 73aad1d..0ba2ade 100644\n--- a/src/services/chatHuman.ts\n+++ b/src/services/chatHuman.ts\n@@ -1,8 +1,9 @@\n import { io, type Socket } from 'socket.io-client'\n import { runtimePolicy } from '../app/config/runtime'\n-import { getAuthSession, refreshChatAuthAfterUnauthorized } from './auth/session'\n+import { getApiSignatureCredentials, getAuthSession, refreshChatAuthAfterUnauthorized } from './auth/session'\n import { chatRecordSchema, type BackendChatRecord } from './chatMessages'\n import { AppError, createBusinessError, createContractError } from './http'\n+import { signCardApiUrl } from './http/apiSignature'\n \n export const CHAT_TRANSFER_PATH = '/api/chatmessages/transfer'\n const transferResultSchema = { mode: 'human' } as const\n@@ -92,11 +93,16 @@ export async function transferChatToHuman(signal?: AbortSignal): Promise<void> {\n   const base = runtimePolicy.apiBaseUrl\n   if (!token) throw new AppError({ kind: 'http', status: 401, message: '请先登录' })\n   if (!base) throw new AppError({ kind: 'configuration', message: '人工客服接口未配置' })\n+  // Complete signature preflight before entering the network-only error handler.\n+  // Missing Native credentials cannot be mistaken for an uncertain transfer.\n+  const credentials = await getApiSignatureCredentials()\n+  const url = signCardApiUrl(base.replace(/\\/+$/, '').replace(/\\/api$/, '') + CHAT_TRANSFER_PATH,\n+    undefined, {}, credentials)\n   let response: Response\n   try {\n-    response = await fetch(base.replace(/\\/+$/, '').replace(/\\/api$/, '') + CHAT_TRANSFER_PATH, {\n+    response = await fetch(url, {\n       method: 'POST',\n-      headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json',\n+      headers: { Authorization: `Bearer ${token}`, salt: credentials.salt, 'Content-Type': 'application/json',\n         Accept: 'application/json' },\n       body: JSON.stringify({}),\n       signal,\ndiff --git a/src/services/chatStream.test.ts b/src/services/chatStream.test.ts\nindex b607fb3..c37a8a6 100644\n--- a/src/services/chatStream.test.ts\n+++ b/src/services/chatStream.test.ts\n@@ -6,6 +6,7 @@ const mocks = vi.hoisted(() => ({\n   getSession: vi.fn(() => ({ accessToken: 'secret-token', userInfo: null })),\n   bootstrap: vi.fn(),\n   refresh: vi.fn(),\n+  getSignature: vi.fn(),\n }))\n vi.mock('../app/config/runtime', () => ({\n   runtimePolicy: { dataMode: 'api', apiBaseUrl: 'https://api.test.example' },\n@@ -14,6 +15,7 @@ vi.mock('./auth/session', () => ({\n   getAuthSession: mocks.getSession,\n   bootstrapAuthSession: mocks.bootstrap,\n   refreshChatAuthAfterUnauthorized: mocks.refresh,\n+  getApiSignatureCredentials: mocks.getSignature,\n }))\n \n const encode = (value: string) => new TextEncoder().encode(value)\n@@ -32,6 +34,7 @@ beforeEach(() => {\n   mocks.getSession.mockReset().mockReturnValue({ accessToken: 'secret-token', userInfo: null })\n   mocks.bootstrap.mockReset().mockResolvedValue({ accessToken: 'new-token', userInfo: null })\n   mocks.refresh.mockReset().mockResolvedValue(undefined)\n+  mocks.getSignature.mockReset().mockResolvedValue({ token: 'upstream-token', salt: 'test-salt' })\n   vi.stubGlobal('fetch', mocks.fetch)\n })\n afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals() })\n@@ -46,7 +49,11 @@ describe('#136 non-idempotent AI stream transport', () => {\n     expect(delta).toEqual(['你🙂好'])\n     expect(mocks.fetch).toHaveBeenCalledTimes(1)\n     const [url, options] = mocks.fetch.mock.calls[0]\n-    expect(url).toBe('https://api.test.example' + CHAT_ADD_PATH)\n+    const signedUrl = new URL(url)\n+    expect(signedUrl.origin + signedUrl.pathname).toBe('https://api.test.example' + CHAT_ADD_PATH)\n+    expect(signedUrl.searchParams.get('token')).toBe('upstream-token')\n+    expect(signedUrl.searchParams.get('secstr')).toMatch(/^[0-9a-f]{32}$/)\n+    expect(options.headers.salt).toBe('test-salt')\n     expect(options.method).toBe('POST')\n     expect(options.headers).toMatchObject({\n       Authorization: 'Bearer secret-token',\n@@ -135,11 +142,25 @@ describe('#136 non-idempotent AI stream transport', () => {\n       }))\n     const controller = new AbortController()\n     const request = sendChatStream('取消测试', { signal: controller.signal })\n+    await vi.waitFor(() => expect(mocks.fetch).toHaveBeenCalledOnce())\n     controller.abort()\n     await expect(request).rejects.toMatchObject({ kind: 'cancelled' })\n     expect(mocks.fetch).toHaveBeenCalledTimes(1)\n   })\n \n+  it('cancels before fetch when signature credentials resolve after abort', async () => {\n+    let resolveCredentials!: (value: { token: string; salt: string }) => void\n+    mocks.getSignature.mockReturnValueOnce(new Promise(resolve => {\n+      resolveCredentials = resolve\n+    }))\n+    const controller = new AbortController()\n+    const request = sendChatStream('取消前置签名', { signal: controller.signal })\n+    controller.abort()\n+    resolveCredentials({ token: 'upstream-token', salt: 'test-salt' })\n+    await expect(request).rejects.toMatchObject({ kind: 'cancelled' })\n+    expect(mocks.fetch).not.toHaveBeenCalled()\n+  })\n+\n   it('times out an unresponsive stream and does not issue another request', async () => {\n     vi.useFakeTimers()\n     mocks.fetch.mockImplementationOnce((_url: string, options: RequestInit) =>\n@@ -161,6 +182,7 @@ describe('#136 non-idempotent AI stream transport', () => {\n           () => reject(new DOMException('aborted', 'AbortError')), { once: true })\n       }))\n     const request = sendChatStream('账号切换')\n+    await vi.waitFor(() => expect(mocks.fetch).toHaveBeenCalledOnce())\n     mocks.getSession.mockReturnValue({ accessToken: 'other-token', userInfo: null })\n     window.dispatchEvent(new Event('dr-card-ui:auth-session-changed'))\n     await expect(request).rejects.toMatchObject({ kind: 'cancelled' })\n@@ -169,6 +191,11 @@ describe('#136 non-idempotent AI stream transport', () => {\n \n   it('bootstraps session when missing before sending the only POST', async () => {\n     mocks.getSession.mockReturnValue(undefined as unknown as { accessToken: string; userInfo: null })\n+    mocks.bootstrap.mockImplementationOnce(async () => {\n+      const session = { accessToken: 'new-token', userInfo: null }\n+      mocks.getSession.mockReturnValue(session)\n+      return session\n+    })\n     mocks.fetch.mockResolvedValueOnce(stream('data: {\"type\":\"done\"}\\n\\n'))\n     await expect(sendChatStream('hello')).resolves.toMatchObject({ mode: 'ai', text: '' })\n     expect(mocks.bootstrap).toHaveBeenCalledOnce()\ndiff --git a/src/services/chatStream.ts b/src/services/chatStream.ts\nindex f2ce30a..ae86244 100644\n--- a/src/services/chatStream.ts\n+++ b/src/services/chatStream.ts\n@@ -1,6 +1,7 @@\n import { runtimePolicy } from '../app/config/runtime'\n-import { bootstrapAuthSession, getAuthSession, refreshChatAuthAfterUnauthorized } from './auth/session'\n+import { bootstrapAuthSession, getApiSignatureCredentials, getAuthSession, refreshChatAuthAfterUnauthorized } from './auth/session'\n import { AppError, createBusinessError, createContractError } from './http'\n+import { signCardApiUrl } from './http/apiSignature'\n import { ChatSseDecoder, type ChatSseFrame } from './chatSse'\n \n export const CHAT_ADD_PATH = '/api/chatmessages/add'\n@@ -58,14 +59,23 @@ export async function sendChatStream(content: string, options: ChatStreamOptions\n     if (controller.signal.aborted) throw new AppError({ kind: 'cancelled', message: '客服发送已���消' })\n     window.addEventListener('dr-card-ui:auth-session-changed', onSessionChange)\n     window.addEventListener('dr-card-ui:auth-session-cleared', onSessionChange)\n-    const response = await fetch(base.replace(/\\/+$/, '').replace(/\\/api$/, '') + CHAT_ADD_PATH, {\n+    const body = { content: message, msg_type: 1 }\n+    const credentials = await getApiSignatureCredentials()\n+    // Native credential resolution may complete after cancellation or account switch.\n+    if (controller.signal.aborted || getAuthSession()?.accessToken !== authToken) {\n+      throw new AppError({ kind: 'cancelled', message: '客服发送已取消或账号已变化' })\n+    }\n+    const url = signCardApiUrl(base.replace(/\\/+$/, '').replace(/\\/api$/, '') + CHAT_ADD_PATH,\n+      undefined, body, credentials)\n+    const response = await fetch(url, {\n       method: 'POST',\n       headers: {\n         Authorization: `Bearer ${authToken}`,\n+        salt: credentials.salt,\n         'Content-Type': 'application/json',\n         Accept: 'text/event-stream',\n       },\n-      body: JSON.stringify({ content: message, msg_type: 1 }),\n+      body: JSON.stringify(body),\n       signal: controller.signal,\n       credentials: 'omit',\n     })\ndiff --git a/src/services/http/apiSignature.test.ts b/src/services/http/apiSignature.test.ts\nnew file mode 100644\nindex 0000000..f6f2b24\n--- /dev/null\n+++ b/src/services/http/apiSignature.test.ts\n@@ -0,0 +1,44 @@\n+import { describe, expect, it } from 'vitest'\n+import { cardApiSecstr, signCardApiUrl } from './apiSignature'\n+\n+const SALT = 'tqNGAU30Ij5mqZycGtDYa4eKo5!CeTke'\n+\n+describe('Card API secstr matches the Midway middleware', () => {\n+  it('reproduces both backend documentation vectors', () => {\n+    expect(cardApiSecstr({ page: '1', token: '', timesp: '2026-10-16 12:00:00' }, SALT))\n+      .toBe('994de3ccc47ee80a380637484825f7d2')\n+    expect(cardApiSecstr({\n+      phone: '13800000000', timesp: '2026-10-16 12:00:00',\n+      token: 'eyJhbGciOiJIUzI1NiJ9.abc',\n+    }, 'kbs-salt-from-login')).toBe('64693e641aca2e4f0686718ff8674a7f')\n+  })\n+\n+  it('signs raw decoded query+body including login salt; never signs secstr/callback', () => {\n+    const credentials = { salt: SALT, token: 'native-token' }\n+    const url = new URL(signCardApiUrl('https://api.example.test/api/oauth/login?trace=a%20b&callback=ignore',\n+      undefined, { salt: SALT, token: 'native-token', optional: null, count: 1 },\n+      credentials, new Date('2026-10-16T04:00:00Z')))\n+    const received = Object.fromEntries(url.searchParams.entries())\n+    expect(received.timesp).toBe('2026-10-16 12:00:00')\n+    expect(received.token).toBe('native-token')\n+    expect(received.trace).toBe('a b')\n+    expect(received.secstr).toBe(cardApiSecstr({\n+      ...received, salt: SALT, count: 1, optional: null,\n+    }, SALT))\n+    expect(received.secstr).not.toBe(cardApiSecstr({ ...received, count: 1 }, SALT))\n+    expect(url.searchParams.get('callback')).toBe('ignore')\n+  })\n+\n+  it('preserves query business fields, serializes nested POST values, and ignores nullish', () => {\n+    const url = new URL(signCardApiUrl('https://api.example.test/api/thing?page=1',\n+      { term: 'a b&c=d', unused: null, bool: false },\n+      { obj: { a: 1 }, list: [1, 'a'] }, { token: '', salt: SALT },\n+      new Date('2026-10-16T00:00:00Z')))\n+    expect(url.searchParams.get('term')).toBe('a b&c=d')\n+    expect(url.searchParams.has('unused')).toBe(false)\n+    const body = { obj: { a: 1 }, list: [1, 'a'] }\n+    expect(url.searchParams.get('secstr')).toBe(cardApiSecstr({\n+      ...Object.fromEntries(url.searchParams.entries()), ...body,\n+    }, SALT))\n+  })\n+})\ndiff --git a/src/services/http/apiSignature.ts b/src/services/http/apiSignature.ts\nnew file mode 100644\nindex 0000000..d933f26\n--- /dev/null\n+++ b/src/services/http/apiSignature.ts\n@@ -0,0 +1,76 @@\n+import { md5 } from 'js-md5'\n+\n+/** Card API /api/** request signing; matches backend src/middleware/auth.ts. */\n+export interface ApiSignatureCredentials {\n+  token: string\n+  salt: string\n+}\n+\n+function wireValue(value: unknown): string {\n+  return typeof value === 'object' ? JSON.stringify(value) : String(value)\n+}\n+\n+function bodyFields(body: unknown): Record<string, unknown> {\n+  if (body == null) return {}\n+  if (body instanceof URLSearchParams) return Object.fromEntries(body.entries())\n+  if (typeof FormData !== 'undefined' && body instanceof FormData) {\n+    // Uploaded binary files aren't ordinary body fields in the backend signature middleware.\n+    return Object.fromEntries(Array.from(body.entries()).filter(([, v]) => typeof v === 'string'))\n+  }\n+  if (typeof body === 'string') {\n+    try {\n+      const parsed: unknown = JSON.parse(body)\n+      return parsed && typeof parsed === 'object' && !Array.isArray(parsed)\n+        ? parsed as Record<string, unknown> : {}\n+    } catch {\n+      return Object.fromEntries(new URLSearchParams(body).entries())\n+    }\n+  }\n+  if (typeof body === 'object' && !Array.isArray(body)) return body as Record<string, unknown>\n+  return {}\n+}\n+\n+function shanghaiTimestamp(now: Date): string {\n+  // Shanghai is fixed UTC+08:00; do not inherit the WebView's device timezone.\n+  return new Date(now.getTime() + 8 * 60 * 60 * 1000).toISOString().slice(0, 19).replace('T', ' ')\n+}\n+\n+/** Matches server: query merged with JSON body, body takes precedence; nullish keys skipped. */\n+export function cardApiSecstr(params: Record<string, unknown>, salt: string): string {\n+  const sorted = Object.keys(params)\n+    .filter(key => key !== 'secstr' && key !== 'callback' && params[key] != null)\n+    .sort()\n+    .map(key => key + wireValue(params[key]))\n+    .join('')\n+  return md5(salt + sorted + salt)\n+}\n+\n+export function signCardApiUrl(\n+  absoluteUrl: string,\n+  query: Record<string, unknown> | URLSearchParams | undefined,\n+  body: unknown,\n+  credentials: ApiSignatureCredentials,\n+  now: Date = new Date(),\n+): string {\n+  if (!credentials.salt?.trim()) throw new Error('Native 签名凭据缺少 salt')\n+  const url = new URL(absoluteUrl)\n+  const incoming = query instanceof URLSearchParams\n+    ? Object.fromEntries(query.entries())\n+    : query ?? {}\n+  for (const [key, value] of Object.entries(incoming)) {\n+    if (value == null) url.searchParams.delete(key)\n+    else url.searchParams.set(key, wireValue(value))\n+  }\n+  url.searchParams.delete('secstr')\n+  url.searchParams.set('timesp', shanghaiTimestamp(now))\n+  url.searchParams.set('token', credentials.token)\n+  const merged = {\n+    ...Object.fromEntries(url.searchParams.entries()),\n+    ...bodyFields(body),\n+    // Token and timestamp are authoritative, including for /api/oauth/login.\n+    token: credentials.token,\n+    timesp: url.searchParams.get('timesp'),\n+  }\n+  url.searchParams.set('secstr', cardApiSecstr(merged, credentials.salt))\n+  return url.toString()\n+}\ndiff --git a/src/services/http/httpClient.test.ts b/src/services/http/httpClient.test.ts\nindex a8306f1..4e6a786 100644\n--- a/src/services/http/httpClient.test.ts\n+++ b/src/services/http/httpClient.test.ts\n@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'\n \n import { AppError } from './appError'\n import { createHttpClient, DEFAULT_HTTP_TIMEOUT_MS } from './httpClient'\n+import { cardApiSecstr } from './apiSignature'\n \n function axiosLikeError(config: unknown, overrides: Record<string, unknown> = {}) {\n   const message = typeof overrides.message === 'string' ? overrides.message : 'Axios probe error'\n@@ -18,6 +19,55 @@ function axiosLikeError(config: unknown, overrides: Record<string, unknown> = {}\n }\n \n describe('HTTP foundation', () => {\n+  it('signs same-origin /api GET+POST at send time, including skipAuth OAuth, not foreign URLs', async () => {\n+    const requests: Array<{ url: string; salt: string | undefined; auth: string | undefined }> = []\n+    const client = createHttpClient({\n+      baseURL: 'https://api.example.test',\n+      signatureEnabled: true,\n+      signatureCredentialsProvider: () => ({ token: 'native-token', salt: 'native-salt' }),\n+      authHeadersProvider: () => ({ Authorization: 'Bearer access-token' }),\n+      adapter: async config => {\n+        requests.push({ url: config.url ?? '', salt: config.headers.get('salt') as string | undefined,\n+          auth: config.headers.get('Authorization') as string | undefined })\n+        return { data: { code: 0 }, status: 200, statusText: 'OK', headers: {}, config }\n+      },\n+    })\n+    await client.request({ url: '/api/oauth/login', method: 'POST',\n+      data: { token: 'native-token', salt: 'native-salt' }, skipAuth: true })\n+    await client.request({ url: '/api/user/profile?page=2', method: 'GET' })\n+    await client.request({ url: 'https://other.example.test/api/private' })\n+    const login = new URL(requests[0].url)\n+    expect(requests[0].auth).toBeUndefined()\n+    expect(requests[0].salt).toBe('native-salt')\n+    expect(login.searchParams.get('secstr')).toBe(cardApiSecstr({\n+      ...Object.fromEntries(login.searchParams), salt: 'native-salt',\n+    }, 'native-salt'))\n+    const profile = new URL(requests[1].url)\n+    expect(profile.searchParams.get('page')).toBe('2')\n+    expect(profile.searchParams.get('token')).toBe('native-token')\n+    expect(profile.searchParams.get('secstr')).toMatch(/^[0-9a-f]{32}$/)\n+    expect(requests[1].auth).toBe('Bearer access-token')\n+    expect(requests[2].url).toBe('https://other.example.test/api/private')\n+    expect(requests[2].salt).toBeUndefined()\n+    expect(requests[2].auth).toBeUndefined()\n+  })\n+\n+  it('fails closed without credentials instead of sending unsigned API calls', async () => {\n+    let sent = 0\n+    const client = createHttpClient({\n+      baseURL: 'https://api.example.test', signatureEnabled: true,\n+      signatureCredentialsProvider: () => undefined,\n+      adapter: async config => {\n+        sent++\n+        return { data: {}, status: 200, statusText: 'OK', headers: {}, config }\n+      },\n+    })\n+    await expect(client.request({ url: '/api/user/detail' })).rejects.toMatchObject({\n+      code: 'API_SIGNATURE_CREDENTIALS_MISSING', kind: 'configuration',\n+    })\n+    expect(sent).toBe(0)\n+  })\n+\n   it('returns response data and applies the default timeout', async () => {\n     const client = createHttpClient({\n       baseURL: 'https://api.example.test',\ndiff --git a/src/services/http/httpClient.ts b/src/services/http/httpClient.ts\nindex c16f6fa..8a067e9 100644\n--- a/src/services/http/httpClient.ts\n+++ b/src/services/http/httpClient.ts\n@@ -6,23 +6,30 @@ import axios, {\n } from 'axios'\n import { runtimePolicy } from '../../app/config/runtime'\n import { AppError, toAppError } from './appError'\n+import { signCardApiUrl, type ApiSignatureCredentials } from './apiSignature'\n \n export const DEFAULT_HTTP_TIMEOUT_MS = 10_000\n \n type Awaitable<T> = T | Promise<T>\n \n export type HttpAuthHeadersProvider = () => Awaitable<Record<string, string> | undefined>\n+export type HttpSignatureCredentialsProvider = () => Awaitable<ApiSignatureCredentials | undefined>\n export type HttpUnauthorizedHandler = () => Awaitable<void>\n export type HttpAuthFailureHandler = () => void\n \n export interface HttpRequestConfig extends AxiosRequestConfig {\n   skipAuth?: boolean\n+  /** Used for the OAuth request before the H5 session exists. */\n+  signatureCredentials?: ApiSignatureCredentials\n }\n \n export interface HttpClientOptions {\n   baseURL?: string\n   timeoutMs?: number\n   authHeadersProvider?: HttpAuthHeadersProvider\n+  signatureCredentialsProvider?: HttpSignatureCredentialsProvider\n+  /** Override only for focused tests; production follows runtime API mode. */\n+  signatureEnabled?: boolean\n   onUnauthorized?: HttpUnauthorizedHandler\n   onAuthFailure?: HttpAuthFailureHandler\n   adapter?: AxiosAdapter\n@@ -33,6 +40,7 @@ export interface HttpClient {\n }\n \n let defaultAuthHeadersProvider: HttpAuthHeadersProvider | undefined\n+let defaultSignatureCredentialsProvider: HttpSignatureCredentialsProvider | undefined\n let defaultUnauthorizedHandler: HttpUnauthorizedHandler | undefined\n let defaultAuthFailureHandler: HttpAuthFailureHandler | undefined\n \n@@ -40,6 +48,10 @@ export function setHttpAuthHeadersProvider(provider: HttpAuthHeadersProvider | u\n   defaultAuthHeadersProvider = provider\n }\n \n+export function setHttpSignatureCredentialsProvider(provider: HttpSignatureCredentialsProvider | undefined) {\n+  defaultSignatureCredentialsProvider = provider\n+}\n+\n export function setHttpUnauthorizedHandler(handler: HttpUnauthorizedHandler | undefined) {\n   defaultUnauthorizedHandler = handler\n }\n@@ -123,14 +135,32 @@ export function createHttpClient(options: HttpClientOptions = {}): HttpClient {\n       (config as HttpRequestConfig).skipAuth || !belongsToBaseURL(config, effectiveBaseURL)\n         ? undefined\n         : await headersProvider()\n+    const headers = AxiosHeaders.from(config.headers)\n     if (authHeaders) {\n-      const headers = AxiosHeaders.from(config.headers)\n-      for (const [name, value] of Object.entries(authHeaders)) {\n-        headers.set(name, value)\n-      }\n-      config.headers = headers\n+      for (const [name, value] of Object.entries(authHeaders)) headers.set(name, value)\n     }\n \n+    // skipAuth suppresses Bearer on OAuth, but DOES NOT suppress the API signature.\n+    // The backend signs the received query + body; all parameters are encoded only\n+    // after computing secstr. Never attach Native credentials to a foreign origin.\n+    const sameOrigin = belongsToBaseURL(config, effectiveBaseURL)\n+    const requestUrl = new URL(config.url ?? '', effectiveBaseURL).toString()\n+    if ((options.signatureEnabled ?? runtimePolicy.dataMode === 'api') &&\n+        sameOrigin && new URL(requestUrl).pathname.startsWith('/api/')) {\n+      const credentials = (config as HttpRequestConfig).signatureCredentials ??\n+        await (options.signatureCredentialsProvider ?? defaultSignatureCredentialsProvider)?.()\n+      if (!credentials?.salt) {\n+        throw new AppError({\n+          kind: 'configuration', code: 'API_SIGNATURE_CREDENTIALS_MISSING',\n+          message: '当前 App 未提供签名凭据，请重新进入登录流程',\n+        })\n+      }\n+      config.url = signCardApiUrl(requestUrl, config.params as Record<string, unknown> | URLSearchParams | undefined,\n+        config.data, credentials)\n+      config.params = undefined\n+      headers.set('salt', credentials.salt)\n+    }\n+    config.headers = headers\n     return config\n   })",
      "actualWorkers": 2
    },
    "preserveOutput": "always",
    "projects": [
      {
        "outputDir": "/home/runner/work/dr-card-web/dr-card-web/test-results/artifacts",
        "repeatEach": 1,
        "retries": 1,
        "metadata": {
          "ci": {
            "commitHref": "https://github.com/sanchuang-dev/dr-card-web/commit/de0d27e0dd94e5e84ade029472d52416c9c1159f",
            "commitHash": "de0d27e0dd94e5e84ade029472d52416c9c1159f",
            "prHref": "https://github.com/sanchuang-dev/dr-card-web/pull/249",
            "prTitle": "chore(test): promote API QS signature and chat preflight fixes",
            "prBaseHash": "aabe754da7bf380ada520643cd2f117a575434bc",
            "buildHref": "https://github.com/sanchuang-dev/dr-card-web/actions/runs/38892048769"
          },
          "gitCommit": {
            "shortHash": "de0d27e",
            "hash": "de0d27e0dd94e5e84ade029472d52416c9c1159f",
            "subject": "Merge ae36725d9d0cc8c292dd6a711a3406178ac34c92 into aabe754da7bf380ada520643cd2f117a575434bc",
            "body": "Merge ae36725d9d0cc8c292dd6a711a3406178ac34c92 into aabe754da7bf380ada520643cd2f117a575434bc\n",
            "author": {
              "name": "Tomz Dang",
              "email": "dangjingtao@163.com",
              "time": 1792149039000
            },
            "committer": {
              "name": "GitHub",
              "email": "noreply@github.com",
              "time": 1792149039000
            },
            "branch": "HEAD"
          },
          "gitDiff": "diff --git a/docs/engineering/auth-boundary.md b/docs/engineering/auth-boundary.md\nindex 5b2afe9..6ad7466 100644\n--- a/docs/engineering/auth-boundary.md\n+++ b/docs/engineering/auth-boundary.md\n@@ -19,6 +19,16 @@\n \n 旧 iOS `webkit.messageHandlers.getAuthorizationInfo.postMessage({}) → window.onToken(token)` 仅作为历史联调 / Bridge Lab Raw Probe 证据保留，不再是 production auth contract。\n \n+## 2026-10-16｜/api 请求签名（来自后台源码静态核对）\n+\n+本地 API `docs/api-signature.md`、`src/middleware/auth.ts`、`src/app/api/config.ts` 已确认：所有 `/api/**` HTTP 请求经 `AppAuthMiddleware` 校验。H5 除已有 Bearer 会话外，还需携带 `token`、`timesp`（Asia/Shanghai 的 `yyyy-MM-dd HH:mm:ss`）及 `secstr`（`md5(salt + 排序拼接参数 + salt)`）；服务端对收到的 Query + Body 合并（Body 优先），排除 `secstr`/`callback` 后计算签名，时间窗口默认 ±5 分钟。\n+\n+实现统一在 `src/services/http/apiSignature.ts`；Axios 通过 `httpClient` 拦截器补签，`/api/oauth/login` 的 `skipAuth` **仅跳过 Bearer，不能跳过 secstr**；客服 SSE 和真人转接的独立 `fetch` 调同一签名函数，保留不可重放语义；Socket.IO 握手仍使用既有 `auth.token`，不属于 `/api/**` HTTP 中间件。\n+\n+**两种 Token 不可混淆**：Native `getLoginToken()` 返回的上游 `token/salt` 用于请求签名；OAuth 返回的 `accessToken` 负责 Bearer 业务会话。Native 凭据只在文档内存保存，不新增持久化字段、不注入第三方 origin。无有效签名凭据不能静默发送无签名业务请求。\n+\n+此结论基于本地后端源码和签名样例复算，以及 H5 CI；**不代表后端已部署该版本，亦不代表 Android/iOS WebView、CORS、上传 FormData 和真实用户会话已验收**。后端文档另已知「客户端自行提交 salt 即被采信」及「窗口内无 nonce 防重放」的安全限制，当前仅做协议对接；后端安全策略升级须独立评估。\n+\n ## 当前仍待确认\n \n Native Bridge 解决的是“如何从 App 拿到登录 token”，以下问题仍不能自行推断：\ndiff --git a/src/pages/serviceChat/useAiChatSend.test.tsx b/src/pages/serviceChat/useAiChatSend.test.tsx\nindex 4f264b9..cd747c6 100644\n--- a/src/pages/serviceChat/useAiChatSend.test.tsx\n+++ b/src/pages/serviceChat/useAiChatSend.test.tsx\n@@ -2,10 +2,15 @@ import { act, renderHook } from '@testing-library/react'\n import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'\n import { combineChatMessages, useAiChatSend } from './useAiChatSend'\n import type { ChatHistoryMessage } from '../../services/chatMessages'\n+import { AppError } from '../../services/http'\n \n const mocks = vi.hoisted(() => ({ token: 'token-A', send: vi.fn() }))\n vi.mock('../../services/auth/session', () => ({\n   getAuthSession: () => ({ accessToken: mocks.token, userInfo: null }),\n+  isApiSignaturePreflightError: (error: unknown) =>\n+    error instanceof Error && 'code' in error &&\n+    ['AUTH_SIGNATURE_CREDENTIALS_MISSING', 'AUTH_NATIVE_CREDENTIALS_INVALID',\n+      'AUTH_NATIVE_CREDENTIALS_UNAVAILABLE'].includes(String(error.code)),\n }))\n vi.mock('../../services/chatStream', () => ({ sendChatStream: mocks.send }))\n const message = (id: number, role: 'bot' | 'user' = 'bot', text = '回复'): ChatHistoryMessage => ({\n@@ -78,6 +83,29 @@ describe('#137 AI stream UI state machine', () => {\n     hook.unmount()\n   })\n \n+  it.each([\n+    'AUTH_SIGNATURE_CREDENTIALS_MISSING',\n+    'AUTH_NATIVE_CREDENTIALS_INVALID',\n+    'AUTH_NATIVE_CREDENTIALS_UNAVAILABLE',\n+  ])('marks unsent user bubble failed on %s without claiming delivery', async code => {\n+    mocks.send.mockRejectedValueOnce(new AppError({\n+      kind: 'contract', code, message: 'Native 签名凭据不可用',\n+    }))\n+    const sync = vi.fn()\n+    const hook = renderHook(() => useAiChatSend(sync))\n+    await act(async () => {\n+      expect(await hook.result.current.send('未发出去的消息', [])).toBe(false)\n+    })\n+    expect(hook.result.current.phase).toBe('failed')\n+    expect(hook.result.current.error).toBe('Native 签名凭据不可用')\n+    expect(hook.result.current.messages).toEqual([\n+      expect.objectContaining({ role: 'user', text: '未发出去的消息', status: 'failed' }),\n+    ])\n+    expect(sync).not.toHaveBeenCalled()\n+    expect(mocks.send).toHaveBeenCalledTimes(1)\n+    hook.unmount()\n+  })\n+\n   it('retains uncertain server results with an explicit history check, not silent resend', async () => {\n     mocks.send.mockResolvedValueOnce({ mode: 'ai', text: '完成', messageId: 42 })\n     const sync = vi.fn().mockResolvedValueOnce(null)\ndiff --git a/src/pages/serviceChat/useAiChatSend.ts b/src/pages/serviceChat/useAiChatSend.ts\nindex 33e9a5e..57a947a 100644\n--- a/src/pages/serviceChat/useAiChatSend.ts\n+++ b/src/pages/serviceChat/useAiChatSend.ts\n@@ -1,6 +1,6 @@\n import { useCallback, useEffect, useRef, useState } from 'react'\n import type { ChatMessage } from '../../app/fixtures'\n-import { getAuthSession } from '../../services/auth/session'\n+import { getAuthSession, isApiSignaturePreflightError } from '../../services/auth/session'\n import type { ChatHistoryMessage } from '../../services/chatMessages'\n import { sendChatStream } from '../../services/chatStream'\n \n@@ -155,11 +155,14 @@ export function useAiChatSend(syncLatest: () => Promise<ChatHistoryMessage[] | n\n       return true\n     } catch (error) {\n       if (!alive()) return false\n+      // Signature preflight happens before fetch; the optimistic message was never sent.\n+      // All other failures retain the history-check warning and no-auto-retry guard.\n+      const noRequestSent = isApiSignaturePreflightError(error)\n       update(previous => ({\n         ...previous, phase: 'failed',\n         messages: previous.messages.map(item => item.id === userId\n-          ? { ...item, status: 'sent' } : item),\n-        error: errorText(error) + '。' + CHECK_HISTORY,\n+          ? { ...item, status: noRequestSent ? 'failed' : 'sent' } : item),\n+        error: noRequestSent ? errorText(error) : errorText(error) + '。' + CHECK_HISTORY,\n       }))\n       return false\n     } finally {\ndiff --git a/src/pages/serviceChat/useHumanChat.test.tsx b/src/pages/serviceChat/useHumanChat.test.tsx\nindex ddc60f3..df3e23f 100644\n--- a/src/pages/serviceChat/useHumanChat.test.tsx\n+++ b/src/pages/serviceChat/useHumanChat.test.tsx\n@@ -2,6 +2,7 @@ import { act, renderHook, waitFor } from '@testing-library/react'\n import { beforeEach, describe, expect, it, vi } from 'vitest'\n import { useHumanChat } from './useHumanChat'\n import type { ChatSocketEvents } from '../../services/chatHuman'\n+import { AppError } from '../../services/http'\n \n const mocks = vi.hoisted(() => ({\n   token: 'user-a-token', events: null as ChatSocketEvents | null,\n@@ -9,6 +10,10 @@ const mocks = vi.hoisted(() => ({\n }))\n vi.mock('../../services/auth/session', () => ({\n   getAuthSession: () => mocks.token ? { accessToken: mocks.token } : undefined,\n+  isApiSignaturePreflightError: (error: unknown) =>\n+    error instanceof Error && 'code' in error &&\n+    ['AUTH_SIGNATURE_CREDENTIALS_MISSING', 'AUTH_NATIVE_CREDENTIALS_INVALID',\n+      'AUTH_NATIVE_CREDENTIALS_UNAVAILABLE'].includes(String(error.code)),\n }))\n vi.mock('../../services/chatHuman', () => ({\n   connectChatSocket: mocks.connect,\n@@ -82,6 +87,26 @@ describe('#138 human chat connection, transfer, and lifecycle', () => {\n     hook.unmount()\n   })\n \n+  it.each([\n+    'AUTH_SIGNATURE_CREDENTIALS_MISSING',\n+    'AUTH_NATIVE_CREDENTIALS_INVALID',\n+    'AUTH_NATIVE_CREDENTIALS_UNAVAILABLE',\n+  ])('shows %s as a preflight error without an uncertain-transfer warning', async code => {\n+    mocks.transfer.mockRejectedValueOnce(new AppError({\n+      kind: 'contract', code, message: 'Native 登录凭据缺少 token 或 salt',\n+    }))\n+    const onTransferred = vi.fn()\n+    const hook = renderHook(() => useHumanChat(vi.fn(), vi.fn().mockResolvedValue(true), 0, onTransferred))\n+    act(() => mocks.events?.onStatus('connected'))\n+    await act(async () => { expect(await hook.result.current.transfer()).toBe(false) })\n+    expect(hook.result.current.active).toBe(false)\n+    expect(hook.result.current.needsHistoryCheck).toBe(false)\n+    expect(hook.result.current.error).toBe('Native 登录凭据缺少 token 或 salt')\n+    expect(hook.result.current.canTransfer).toBe(true)\n+    expect(onTransferred).not.toHaveBeenCalled()\n+    hook.unmount()\n+  })\n+\n   it('retains a content-free uncertain-transfer warning across 401 auth reset', async () => {\n     const onTransferred = vi.fn()\n     mocks.transfer.mockImplementationOnce(async () => {\ndiff --git a/src/pages/serviceChat/useHumanChat.ts b/src/pages/serviceChat/useHumanChat.ts\nindex e607c3a..c823c7c 100644\n--- a/src/pages/serviceChat/useHumanChat.ts\n+++ b/src/pages/serviceChat/useHumanChat.ts\n@@ -1,5 +1,5 @@\n import { useCallback, useEffect, useRef, useState } from 'react'\n-import { getAuthSession } from '../../services/auth/session'\n+import { getAuthSession, isApiSignaturePreflightError } from '../../services/auth/session'\n import { connectChatSocket, transferChatToHuman, type ChatSocketHandle, type ChatSocketStatus } from '../../services/chatHuman'\n import type { BackendChatRecord } from '../../services/chatMessages'\n \n@@ -139,11 +139,16 @@ export function useHumanChat(\n       return true\n     } catch (error) {\n       if (generation.current !== epoch || tokenKey() !== key) return false\n-      authInterrupted.current = true\n+      // Only these signature preflight failures guarantee that no HTTP transfer was sent.\n+      // Keep the history-check guard for failures after the network request may have started.\n+      const noRequestSent = isApiSignaturePreflightError(error)\n+      if (!noRequestSent) authInterrupted.current = true\n       setSnapshot(old => ({\n-        ...old, transferring: false, needsHistoryCheck: true,\n-        error: (error instanceof Error ? error.message : '转人工失败') +\n-          '。如请求已经送达，请先检查历史，避免重复转接。',\n+        ...old, transferring: false, needsHistoryCheck: !noRequestSent,\n+        error: noRequestSent\n+          ? error.message\n+          : (error instanceof Error ? error.message : '转人工失败') +\n+            '。如请求已经送达，请先检查历史，避免重复转接。',\n       }))\n       return false\n     } finally {\ndiff --git a/src/services/auth/session.bootstrap.test.ts b/src/services/auth/session.bootstrap.test.ts\nindex 239bf60..0ed24db 100644\n--- a/src/services/auth/session.bootstrap.test.ts\n+++ b/src/services/auth/session.bootstrap.test.ts\n@@ -4,6 +4,7 @@ const mocks = vi.hoisted(() => ({\n   request: vi.fn(),\n   getLoginToken: vi.fn(),\n   setAuthHeadersProvider: vi.fn(),\n+  setSignatureCredentialsProvider: vi.fn(),\n   setUnauthorizedHandler: vi.fn(),\n   setAuthFailureHandler: vi.fn(),\n }))\n@@ -24,6 +25,7 @@ vi.mock('../http/httpClient', () => ({\n     request: mocks.request,\n   }),\n   setHttpAuthHeadersProvider: mocks.setAuthHeadersProvider,\n+  setHttpSignatureCredentialsProvider: mocks.setSignatureCredentialsProvider,\n   setHttpUnauthorizedHandler: mocks.setUnauthorizedHandler,\n   setHttpAuthFailureHandler: mocks.setAuthFailureHandler,\n }))\n@@ -34,6 +36,7 @@ describe('bootstrap auth 401 handling', () => {\n     mocks.request.mockReset()\n     mocks.getLoginToken.mockReset()\n     mocks.setAuthHeadersProvider.mockReset()\n+    mocks.setSignatureCredentialsProvider.mockReset()\n     mocks.setUnauthorizedHandler.mockReset()\n     mocks.setAuthFailureHandler.mockReset()\n     mocks.getLoginToken.mockResolvedValue({ token: 'native-token', salt: 'native-salt' })\ndiff --git a/src/services/auth/session.notice-auth.test.ts b/src/services/auth/session.notice-auth.test.ts\nindex 75243a0..0b43ace 100644\n--- a/src/services/auth/session.notice-auth.test.ts\n+++ b/src/services/auth/session.notice-auth.test.ts\n@@ -21,14 +21,17 @@ vi.mock('../http/httpClient', async importOriginal => {\n   const actual = await importOriginal<typeof import('../http/httpClient')>()\n   const adapter: AxiosAdapter = async (config) => {\n     const url = config.url ?? ''\n+    const parsedUrl = new URL(url, 'https://api.example.test')\n+    const sameOrigin = parsedUrl.origin === 'https://api.example.test'\n+    const route = sameOrigin ? parsedUrl.pathname : url\n     probes.requests.push({\n-      url,\n+      url: route,\n       authorization: config.headers.get('Authorization')?.toString(),\n     })\n     let data: unknown = { ok: true }\n-    if (url === '/api/oauth/login') {\n+    if (route === '/api/oauth/login') {\n       data = { code: 0, data: { userInfo: { id: 9 }, accessToken: probes.issuedToken } }\n-    } else if (url === '/api/notices/unread-count') {\n+    } else if (route === '/api/notices/unread-count') {\n       if (probes.unread401Once) {\n         probes.unread401Once = false\n         data = { code: 401, message: '请先登录' }\n@@ -69,12 +72,11 @@ describe('#213 notice auth header follows the session, not the current route', (\n       .toEqual([{ url: '/api/notices/unread-count', authorization: 'Bearer access-A' }])\n   })\n \n-  it('sends no invented credentials when there is no session', async () => {\n-    await expect(fetchNoticeUnreadCounts()).resolves.toMatchObject({ total: 2 })\n-    expect(probes.requests[0]).toEqual({\n-      url: '/api/notices/unread-count',\n-      authorization: undefined,\n+  it('does not silently send an unsigned API request when no Native session exists', async () => {\n+    await expect(fetchNoticeUnreadCounts()).rejects.toMatchObject({\n+      code: 'AUTH_SIGNATURE_CREDENTIALS_MISSING',\n     })\n+    expect(probes.requests).toHaveLength(0)\n   })\n \n   it('excludes Bearer from OAuth skipAuth, explicit skipAuth and cross-origin requests', async () => {\ndiff --git a/src/services/auth/session.ts b/src/services/auth/session.ts\nindex 260115d..eacd033 100644\n--- a/src/services/auth/session.ts\n+++ b/src/services/auth/session.ts\n@@ -1,11 +1,13 @@\n import { runtimePolicy } from '../../app/config/runtime'\n import { getLoginToken } from '../nativeBridge'\n-import { createContractError, isAppError } from '../http/appError'\n+import { createContractError, isAppError, type AppError } from '../http/appError'\n import { STORAGE_KEYS, storage } from '../../storage'\n+import type { ApiSignatureCredentials } from '../http/apiSignature'\n import {\n   createHttpClient,\n   setHttpAuthFailureHandler,\n   setHttpAuthHeadersProvider,\n+  setHttpSignatureCredentialsProvider,\n   setHttpUnauthorizedHandler,\n } from '../http/httpClient'\n \n@@ -38,6 +40,8 @@ let initialBootstrap: Promise<AuthSession> | undefined\n let authFlowEnabled = false\n let initializedForDocument = false\n let volatileSession: AuthSession | undefined\n+// Native upstream credentials never enter sessionStorage, URLs outside /api, or logs.\n+let signatureCredentials: ApiSignatureCredentials | undefined\n \n function readSession(): AuthSession | undefined {\n   const value = storage.read(STORAGE_KEYS.authSession)\n@@ -55,6 +59,7 @@ function writeSession(session: AuthSession) {\n \n export function clearAuthSession() {\n   volatileSession = undefined\n+  signatureCredentials = undefined\n   storage.remove(STORAGE_KEYS.authSession)\n   if (typeof window !== 'undefined') window.dispatchEvent(new Event('dr-card-ui:auth-session-cleared'))\n }\n@@ -104,9 +109,11 @@ async function loginFromNative(): Promise<AuthSession> {\n     url: LOGIN_PATH,\n     data: { salt: credentials.salt, token: credentials.token },\n     skipAuth: true,\n+    signatureCredentials: { token: credentials.token, salt: credentials.salt },\n   })\n \n   const session = parseLoginResponse(result)\n+  signatureCredentials = { token: credentials.token, salt: credentials.salt }\n   writeSession(session)\n   return session\n }\n@@ -114,7 +121,7 @@ async function loginFromNative(): Promise<AuthSession> {\n export function authenticate(force = false): Promise<AuthSession> {\n   if (!force) {\n     const current = readSession()\n-    if (current) return Promise.resolve(current)\n+    if (current && signatureCredentials) return Promise.resolve(current)\n   }\n   if (inFlightLogin) return inFlightLogin\n \n@@ -179,6 +186,41 @@ export async function refreshChatAuthAfterUnauthorized(): Promise<void> {\n   if (authFlowEnabled) await reauthenticateAfterUnauthorized()\n }\n \n+/** Obtain the upstream Native token/salt for independent SSE and HTTP transports.\n+ * A restored accessToken requires re-reading the Native bridge before signing. */\n+export async function getApiSignatureCredentials(): Promise<ApiSignatureCredentials> {\n+  if (signatureCredentials) return signatureCredentials\n+  if (!readSession()) {\n+    throw createContractError('未建立签名会话', { code: 'AUTH_SIGNATURE_CREDENTIALS_MISSING' })\n+  }\n+  // Bridge failures happen before the HTTP request; preserve that distinction.\n+  let native: Awaited<ReturnType<typeof getLoginToken>>\n+  try {\n+    native = await getLoginToken()\n+  } catch {\n+    throw createContractError('无法取得 Native 签名凭据，请检查 App 登录状态', {\n+      code: 'AUTH_NATIVE_CREDENTIALS_UNAVAILABLE',\n+    })\n+  }\n+  if (!native.token.trim() || !native.salt?.trim()) {\n+    throw createContractError('Native 登录凭据缺少 token 或 salt', {\n+      code: 'AUTH_NATIVE_CREDENTIALS_INVALID',\n+    })\n+  }\n+  signatureCredentials = { token: native.token, salt: native.salt }\n+  return signatureCredentials\n+}\n+\n+/** Only these failures certify that signing stopped before HTTP transport. */\n+export function isApiSignaturePreflightError(error: unknown): error is AppError {\n+  return isAppError(error) &&\n+    (error.code === 'AUTH_SIGNATURE_CREDENTIALS_MISSING' ||\n+      error.code === 'AUTH_NATIVE_CREDENTIALS_INVALID' ||\n+      error.code === 'AUTH_NATIVE_CREDENTIALS_UNAVAILABLE')\n+}\n+\n+setHttpSignatureCredentialsProvider(getApiSignatureCredentials)\n+\n setHttpAuthHeadersProvider(() => {\n   // Route-level authFlowEnabled controls reauthentication/expiry UI, not credential attachment.\n   // Any same-origin API request with an existing session must carry its Bearer token.\ndiff --git a/src/services/chatHuman.test.ts b/src/services/chatHuman.test.ts\nindex 82e63e8..d8d3fc5 100644\n--- a/src/services/chatHuman.test.ts\n+++ b/src/services/chatHuman.test.ts\n@@ -1,5 +1,6 @@\n import { beforeEach, describe, expect, it, vi } from 'vitest'\n import { connectChatSocket, transferChatToHuman } from './chatHuman'\n+import { AppError } from './http'\n \n const mocks = vi.hoisted(() => {\n   const handlers = new Map<string, (...args: any[]) => void>()\n@@ -13,7 +14,7 @@ const mocks = vi.hoisted(() => {\n   }\n   return {\n     socket, handlers, io: vi.fn(() => socket), fetch: vi.fn(),\n-    token: 'secret-token', refresh: vi.fn(),\n+    token: 'secret-token', refresh: vi.fn(), signature: vi.fn(),\n   }\n })\n vi.mock('socket.io-client', () => ({ io: mocks.io }))\n@@ -23,6 +24,7 @@ vi.mock('../app/config/runtime', () => ({\n vi.mock('./auth/session', () => ({\n   getAuthSession: () => ({ accessToken: mocks.token }),\n   refreshChatAuthAfterUnauthorized: mocks.refresh,\n+  getApiSignatureCredentials: mocks.signature,\n }))\n \n beforeEach(() => {\n@@ -33,6 +35,7 @@ beforeEach(() => {\n   mocks.socket.connected = false\n   mocks.token = 'secret-token'\n   mocks.fetch.mockReset(); mocks.refresh.mockReset()\n+  mocks.signature.mockReset().mockResolvedValue({ token: 'native-token', salt: 'test-salt' })\n   vi.stubGlobal('fetch', mocks.fetch)\n })\n \n@@ -79,13 +82,31 @@ describe('#138 Socket.IO client contract', () => {\n     }), { status: 200, headers: { 'Content-Type': 'application/json' } }))\n     await expect(transferChatToHuman()).resolves.toBeUndefined()\n     const [url, request] = mocks.fetch.mock.calls[0]\n-    expect(url).toBe('https://api.example.test/api/chatmessages/transfer')\n+    const signedUrl = new URL(url)\n+    expect(signedUrl.origin + signedUrl.pathname).toBe('https://api.example.test/api/chatmessages/transfer')\n+    expect(signedUrl.searchParams.get('token')).toBe('native-token')\n+    expect(signedUrl.searchParams.get('secstr')).toMatch(/^[0-9a-f]{32}$/)\n+    expect(request.headers.salt).toBe('test-salt')\n     expect(request.headers.Authorization).toBe('Bearer secret-token')\n     expect(request.method).toBe('POST')\n     expect(JSON.parse(request.body)).toEqual({})\n     expect(mocks.fetch).toHaveBeenCalledTimes(1)\n   })\n \n+  it.each([\n+    'AUTH_SIGNATURE_CREDENTIALS_MISSING',\n+    'AUTH_NATIVE_CREDENTIALS_INVALID',\n+    'AUTH_NATIVE_CREDENTIALS_UNAVAILABLE',\n+  ])('preserves %s before sending any transfer request', async code => {\n+    const error = new AppError({\n+      kind: 'contract', code, message: 'Native 登录凭据缺少 token 或 salt',\n+    })\n+    mocks.signature.mockRejectedValueOnce(error)\n+    await expect(transferChatToHuman()).rejects.toBe(error)\n+    expect(mocks.fetch).not.toHaveBeenCalled()\n+    expect(mocks.refresh).not.toHaveBeenCalled()\n+  })\n+\n   it('does not fake transfer success on business error, network failure or invalid mode', async () => {\n     mocks.fetch.mockResolvedValueOnce(new Response(JSON.stringify({\n       code: 500, message: '没有对应服务', data: [],\ndiff --git a/src/services/chatHuman.ts b/src/services/chatHuman.ts\nindex 73aad1d..0ba2ade 100644\n--- a/src/services/chatHuman.ts\n+++ b/src/services/chatHuman.ts\n@@ -1,8 +1,9 @@\n import { io, type Socket } from 'socket.io-client'\n import { runtimePolicy } from '../app/config/runtime'\n-import { getAuthSession, refreshChatAuthAfterUnauthorized } from './auth/session'\n+import { getApiSignatureCredentials, getAuthSession, refreshChatAuthAfterUnauthorized } from './auth/session'\n import { chatRecordSchema, type BackendChatRecord } from './chatMessages'\n import { AppError, createBusinessError, createContractError } from './http'\n+import { signCardApiUrl } from './http/apiSignature'\n \n export const CHAT_TRANSFER_PATH = '/api/chatmessages/transfer'\n const transferResultSchema = { mode: 'human' } as const\n@@ -92,11 +93,16 @@ export async function transferChatToHuman(signal?: AbortSignal): Promise<void> {\n   const base = runtimePolicy.apiBaseUrl\n   if (!token) throw new AppError({ kind: 'http', status: 401, message: '请先登录' })\n   if (!base) throw new AppError({ kind: 'configuration', message: '人工客服接口未配置' })\n+  // Complete signature preflight before entering the network-only error handler.\n+  // Missing Native credentials cannot be mistaken for an uncertain transfer.\n+  const credentials = await getApiSignatureCredentials()\n+  const url = signCardApiUrl(base.replace(/\\/+$/, '').replace(/\\/api$/, '') + CHAT_TRANSFER_PATH,\n+    undefined, {}, credentials)\n   let response: Response\n   try {\n-    response = await fetch(base.replace(/\\/+$/, '').replace(/\\/api$/, '') + CHAT_TRANSFER_PATH, {\n+    response = await fetch(url, {\n       method: 'POST',\n-      headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json',\n+      headers: { Authorization: `Bearer ${token}`, salt: credentials.salt, 'Content-Type': 'application/json',\n         Accept: 'application/json' },\n       body: JSON.stringify({}),\n       signal,\ndiff --git a/src/services/chatStream.test.ts b/src/services/chatStream.test.ts\nindex b607fb3..c37a8a6 100644\n--- a/src/services/chatStream.test.ts\n+++ b/src/services/chatStream.test.ts\n@@ -6,6 +6,7 @@ const mocks = vi.hoisted(() => ({\n   getSession: vi.fn(() => ({ accessToken: 'secret-token', userInfo: null })),\n   bootstrap: vi.fn(),\n   refresh: vi.fn(),\n+  getSignature: vi.fn(),\n }))\n vi.mock('../app/config/runtime', () => ({\n   runtimePolicy: { dataMode: 'api', apiBaseUrl: 'https://api.test.example' },\n@@ -14,6 +15,7 @@ vi.mock('./auth/session', () => ({\n   getAuthSession: mocks.getSession,\n   bootstrapAuthSession: mocks.bootstrap,\n   refreshChatAuthAfterUnauthorized: mocks.refresh,\n+  getApiSignatureCredentials: mocks.getSignature,\n }))\n \n const encode = (value: string) => new TextEncoder().encode(value)\n@@ -32,6 +34,7 @@ beforeEach(() => {\n   mocks.getSession.mockReset().mockReturnValue({ accessToken: 'secret-token', userInfo: null })\n   mocks.bootstrap.mockReset().mockResolvedValue({ accessToken: 'new-token', userInfo: null })\n   mocks.refresh.mockReset().mockResolvedValue(undefined)\n+  mocks.getSignature.mockReset().mockResolvedValue({ token: 'upstream-token', salt: 'test-salt' })\n   vi.stubGlobal('fetch', mocks.fetch)\n })\n afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals() })\n@@ -46,7 +49,11 @@ describe('#136 non-idempotent AI stream transport', () => {\n     expect(delta).toEqual(['你🙂好'])\n     expect(mocks.fetch).toHaveBeenCalledTimes(1)\n     const [url, options] = mocks.fetch.mock.calls[0]\n-    expect(url).toBe('https://api.test.example' + CHAT_ADD_PATH)\n+    const signedUrl = new URL(url)\n+    expect(signedUrl.origin + signedUrl.pathname).toBe('https://api.test.example' + CHAT_ADD_PATH)\n+    expect(signedUrl.searchParams.get('token')).toBe('upstream-token')\n+    expect(signedUrl.searchParams.get('secstr')).toMatch(/^[0-9a-f]{32}$/)\n+    expect(options.headers.salt).toBe('test-salt')\n     expect(options.method).toBe('POST')\n     expect(options.headers).toMatchObject({\n       Authorization: 'Bearer secret-token',\n@@ -135,11 +142,25 @@ describe('#136 non-idempotent AI stream transport', () => {\n       }))\n     const controller = new AbortController()\n     const request = sendChatStream('取消测试', { signal: controller.signal })\n+    await vi.waitFor(() => expect(mocks.fetch).toHaveBeenCalledOnce())\n     controller.abort()\n     await expect(request).rejects.toMatchObject({ kind: 'cancelled' })\n     expect(mocks.fetch).toHaveBeenCalledTimes(1)\n   })\n \n+  it('cancels before fetch when signature credentials resolve after abort', async () => {\n+    let resolveCredentials!: (value: { token: string; salt: string }) => void\n+    mocks.getSignature.mockReturnValueOnce(new Promise(resolve => {\n+      resolveCredentials = resolve\n+    }))\n+    const controller = new AbortController()\n+    const request = sendChatStream('取消前置签名', { signal: controller.signal })\n+    controller.abort()\n+    resolveCredentials({ token: 'upstream-token', salt: 'test-salt' })\n+    await expect(request).rejects.toMatchObject({ kind: 'cancelled' })\n+    expect(mocks.fetch).not.toHaveBeenCalled()\n+  })\n+\n   it('times out an unresponsive stream and does not issue another request', async () => {\n     vi.useFakeTimers()\n     mocks.fetch.mockImplementationOnce((_url: string, options: RequestInit) =>\n@@ -161,6 +182,7 @@ describe('#136 non-idempotent AI stream transport', () => {\n           () => reject(new DOMException('aborted', 'AbortError')), { once: true })\n       }))\n     const request = sendChatStream('账号切换')\n+    await vi.waitFor(() => expect(mocks.fetch).toHaveBeenCalledOnce())\n     mocks.getSession.mockReturnValue({ accessToken: 'other-token', userInfo: null })\n     window.dispatchEvent(new Event('dr-card-ui:auth-session-changed'))\n     await expect(request).rejects.toMatchObject({ kind: 'cancelled' })\n@@ -169,6 +191,11 @@ describe('#136 non-idempotent AI stream transport', () => {\n \n   it('bootstraps session when missing before sending the only POST', async () => {\n     mocks.getSession.mockReturnValue(undefined as unknown as { accessToken: string; userInfo: null })\n+    mocks.bootstrap.mockImplementationOnce(async () => {\n+      const session = { accessToken: 'new-token', userInfo: null }\n+      mocks.getSession.mockReturnValue(session)\n+      return session\n+    })\n     mocks.fetch.mockResolvedValueOnce(stream('data: {\"type\":\"done\"}\\n\\n'))\n     await expect(sendChatStream('hello')).resolves.toMatchObject({ mode: 'ai', text: '' })\n     expect(mocks.bootstrap).toHaveBeenCalledOnce()\ndiff --git a/src/services/chatStream.ts b/src/services/chatStream.ts\nindex f2ce30a..ae86244 100644\n--- a/src/services/chatStream.ts\n+++ b/src/services/chatStream.ts\n@@ -1,6 +1,7 @@\n import { runtimePolicy } from '../app/config/runtime'\n-import { bootstrapAuthSession, getAuthSession, refreshChatAuthAfterUnauthorized } from './auth/session'\n+import { bootstrapAuthSession, getApiSignatureCredentials, getAuthSession, refreshChatAuthAfterUnauthorized } from './auth/session'\n import { AppError, createBusinessError, createContractError } from './http'\n+import { signCardApiUrl } from './http/apiSignature'\n import { ChatSseDecoder, type ChatSseFrame } from './chatSse'\n \n export const CHAT_ADD_PATH = '/api/chatmessages/add'\n@@ -58,14 +59,23 @@ export async function sendChatStream(content: string, options: ChatStreamOptions\n     if (controller.signal.aborted) throw new AppError({ kind: 'cancelled', message: '客服发送已���消' })\n     window.addEventListener('dr-card-ui:auth-session-changed', onSessionChange)\n     window.addEventListener('dr-card-ui:auth-session-cleared', onSessionChange)\n-    const response = await fetch(base.replace(/\\/+$/, '').replace(/\\/api$/, '') + CHAT_ADD_PATH, {\n+    const body = { content: message, msg_type: 1 }\n+    const credentials = await getApiSignatureCredentials()\n+    // Native credential resolution may complete after cancellation or account switch.\n+    if (controller.signal.aborted || getAuthSession()?.accessToken !== authToken) {\n+      throw new AppError({ kind: 'cancelled', message: '客服发送已取消或账号已变化' })\n+    }\n+    const url = signCardApiUrl(base.replace(/\\/+$/, '').replace(/\\/api$/, '') + CHAT_ADD_PATH,\n+      undefined, body, credentials)\n+    const response = await fetch(url, {\n       method: 'POST',\n       headers: {\n         Authorization: `Bearer ${authToken}`,\n+        salt: credentials.salt,\n         'Content-Type': 'application/json',\n         Accept: 'text/event-stream',\n       },\n-      body: JSON.stringify({ content: message, msg_type: 1 }),\n+      body: JSON.stringify(body),\n       signal: controller.signal,\n       credentials: 'omit',\n     })\ndiff --git a/src/services/http/apiSignature.test.ts b/src/services/http/apiSignature.test.ts\nnew file mode 100644\nindex 0000000..f6f2b24\n--- /dev/null\n+++ b/src/services/http/apiSignature.test.ts\n@@ -0,0 +1,44 @@\n+import { describe, expect, it } from 'vitest'\n+import { cardApiSecstr, signCardApiUrl } from './apiSignature'\n+\n+const SALT = 'tqNGAU30Ij5mqZycGtDYa4eKo5!CeTke'\n+\n+describe('Card API secstr matches the Midway middleware', () => {\n+  it('reproduces both backend documentation vectors', () => {\n+    expect(cardApiSecstr({ page: '1', token: '', timesp: '2026-10-16 12:00:00' }, SALT))\n+      .toBe('994de3ccc47ee80a380637484825f7d2')\n+    expect(cardApiSecstr({\n+      phone: '13800000000', timesp: '2026-10-16 12:00:00',\n+      token: 'eyJhbGciOiJIUzI1NiJ9.abc',\n+    }, 'kbs-salt-from-login')).toBe('64693e641aca2e4f0686718ff8674a7f')\n+  })\n+\n+  it('signs raw decoded query+body including login salt; never signs secstr/callback', () => {\n+    const credentials = { salt: SALT, token: 'native-token' }\n+    const url = new URL(signCardApiUrl('https://api.example.test/api/oauth/login?trace=a%20b&callback=ignore',\n+      undefined, { salt: SALT, token: 'native-token', optional: null, count: 1 },\n+      credentials, new Date('2026-10-16T04:00:00Z')))\n+    const received = Object.fromEntries(url.searchParams.entries())\n+    expect(received.timesp).toBe('2026-10-16 12:00:00')\n+    expect(received.token).toBe('native-token')\n+    expect(received.trace).toBe('a b')\n+    expect(received.secstr).toBe(cardApiSecstr({\n+      ...received, salt: SALT, count: 1, optional: null,\n+    }, SALT))\n+    expect(received.secstr).not.toBe(cardApiSecstr({ ...received, count: 1 }, SALT))\n+    expect(url.searchParams.get('callback')).toBe('ignore')\n+  })\n+\n+  it('preserves query business fields, serializes nested POST values, and ignores nullish', () => {\n+    const url = new URL(signCardApiUrl('https://api.example.test/api/thing?page=1',\n+      { term: 'a b&c=d', unused: null, bool: false },\n+      { obj: { a: 1 }, list: [1, 'a'] }, { token: '', salt: SALT },\n+      new Date('2026-10-16T00:00:00Z')))\n+    expect(url.searchParams.get('term')).toBe('a b&c=d')\n+    expect(url.searchParams.has('unused')).toBe(false)\n+    const body = { obj: { a: 1 }, list: [1, 'a'] }\n+    expect(url.searchParams.get('secstr')).toBe(cardApiSecstr({\n+      ...Object.fromEntries(url.searchParams.entries()), ...body,\n+    }, SALT))\n+  })\n+})\ndiff --git a/src/services/http/apiSignature.ts b/src/services/http/apiSignature.ts\nnew file mode 100644\nindex 0000000..d933f26\n--- /dev/null\n+++ b/src/services/http/apiSignature.ts\n@@ -0,0 +1,76 @@\n+import { md5 } from 'js-md5'\n+\n+/** Card API /api/** request signing; matches backend src/middleware/auth.ts. */\n+export interface ApiSignatureCredentials {\n+  token: string\n+  salt: string\n+}\n+\n+function wireValue(value: unknown): string {\n+  return typeof value === 'object' ? JSON.stringify(value) : String(value)\n+}\n+\n+function bodyFields(body: unknown): Record<string, unknown> {\n+  if (body == null) return {}\n+  if (body instanceof URLSearchParams) return Object.fromEntries(body.entries())\n+  if (typeof FormData !== 'undefined' && body instanceof FormData) {\n+    // Uploaded binary files aren't ordinary body fields in the backend signature middleware.\n+    return Object.fromEntries(Array.from(body.entries()).filter(([, v]) => typeof v === 'string'))\n+  }\n+  if (typeof body === 'string') {\n+    try {\n+      const parsed: unknown = JSON.parse(body)\n+      return parsed && typeof parsed === 'object' && !Array.isArray(parsed)\n+        ? parsed as Record<string, unknown> : {}\n+    } catch {\n+      return Object.fromEntries(new URLSearchParams(body).entries())\n+    }\n+  }\n+  if (typeof body === 'object' && !Array.isArray(body)) return body as Record<string, unknown>\n+  return {}\n+}\n+\n+function shanghaiTimestamp(now: Date): string {\n+  // Shanghai is fixed UTC+08:00; do not inherit the WebView's device timezone.\n+  return new Date(now.getTime() + 8 * 60 * 60 * 1000).toISOString().slice(0, 19).replace('T', ' ')\n+}\n+\n+/** Matches server: query merged with JSON body, body takes precedence; nullish keys skipped. */\n+export function cardApiSecstr(params: Record<string, unknown>, salt: string): string {\n+  const sorted = Object.keys(params)\n+    .filter(key => key !== 'secstr' && key !== 'callback' && params[key] != null)\n+    .sort()\n+    .map(key => key + wireValue(params[key]))\n+    .join('')\n+  return md5(salt + sorted + salt)\n+}\n+\n+export function signCardApiUrl(\n+  absoluteUrl: string,\n+  query: Record<string, unknown> | URLSearchParams | undefined,\n+  body: unknown,\n+  credentials: ApiSignatureCredentials,\n+  now: Date = new Date(),\n+): string {\n+  if (!credentials.salt?.trim()) throw new Error('Native 签名凭据缺少 salt')\n+  const url = new URL(absoluteUrl)\n+  const incoming = query instanceof URLSearchParams\n+    ? Object.fromEntries(query.entries())\n+    : query ?? {}\n+  for (const [key, value] of Object.entries(incoming)) {\n+    if (value == null) url.searchParams.delete(key)\n+    else url.searchParams.set(key, wireValue(value))\n+  }\n+  url.searchParams.delete('secstr')\n+  url.searchParams.set('timesp', shanghaiTimestamp(now))\n+  url.searchParams.set('token', credentials.token)\n+  const merged = {\n+    ...Object.fromEntries(url.searchParams.entries()),\n+    ...bodyFields(body),\n+    // Token and timestamp are authoritative, including for /api/oauth/login.\n+    token: credentials.token,\n+    timesp: url.searchParams.get('timesp'),\n+  }\n+  url.searchParams.set('secstr', cardApiSecstr(merged, credentials.salt))\n+  return url.toString()\n+}\ndiff --git a/src/services/http/httpClient.test.ts b/src/services/http/httpClient.test.ts\nindex a8306f1..4e6a786 100644\n--- a/src/services/http/httpClient.test.ts\n+++ b/src/services/http/httpClient.test.ts\n@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'\n \n import { AppError } from './appError'\n import { createHttpClient, DEFAULT_HTTP_TIMEOUT_MS } from './httpClient'\n+import { cardApiSecstr } from './apiSignature'\n \n function axiosLikeError(config: unknown, overrides: Record<string, unknown> = {}) {\n   const message = typeof overrides.message === 'string' ? overrides.message : 'Axios probe error'\n@@ -18,6 +19,55 @@ function axiosLikeError(config: unknown, overrides: Record<string, unknown> = {}\n }\n \n describe('HTTP foundation', () => {\n+  it('signs same-origin /api GET+POST at send time, including skipAuth OAuth, not foreign URLs', async () => {\n+    const requests: Array<{ url: string; salt: string | undefined; auth: string | undefined }> = []\n+    const client = createHttpClient({\n+      baseURL: 'https://api.example.test',\n+      signatureEnabled: true,\n+      signatureCredentialsProvider: () => ({ token: 'native-token', salt: 'native-salt' }),\n+      authHeadersProvider: () => ({ Authorization: 'Bearer access-token' }),\n+      adapter: async config => {\n+        requests.push({ url: config.url ?? '', salt: config.headers.get('salt') as string | undefined,\n+          auth: config.headers.get('Authorization') as string | undefined })\n+        return { data: { code: 0 }, status: 200, statusText: 'OK', headers: {}, config }\n+      },\n+    })\n+    await client.request({ url: '/api/oauth/login', method: 'POST',\n+      data: { token: 'native-token', salt: 'native-salt' }, skipAuth: true })\n+    await client.request({ url: '/api/user/profile?page=2', method: 'GET' })\n+    await client.request({ url: 'https://other.example.test/api/private' })\n+    const login = new URL(requests[0].url)\n+    expect(requests[0].auth).toBeUndefined()\n+    expect(requests[0].salt).toBe('native-salt')\n+    expect(login.searchParams.get('secstr')).toBe(cardApiSecstr({\n+      ...Object.fromEntries(login.searchParams), salt: 'native-salt',\n+    }, 'native-salt'))\n+    const profile = new URL(requests[1].url)\n+    expect(profile.searchParams.get('page')).toBe('2')\n+    expect(profile.searchParams.get('token')).toBe('native-token')\n+    expect(profile.searchParams.get('secstr')).toMatch(/^[0-9a-f]{32}$/)\n+    expect(requests[1].auth).toBe('Bearer access-token')\n+    expect(requests[2].url).toBe('https://other.example.test/api/private')\n+    expect(requests[2].salt).toBeUndefined()\n+    expect(requests[2].auth).toBeUndefined()\n+  })\n+\n+  it('fails closed without credentials instead of sending unsigned API calls', async () => {\n+    let sent = 0\n+    const client = createHttpClient({\n+      baseURL: 'https://api.example.test', signatureEnabled: true,\n+      signatureCredentialsProvider: () => undefined,\n+      adapter: async config => {\n+        sent++\n+        return { data: {}, status: 200, statusText: 'OK', headers: {}, config }\n+      },\n+    })\n+    await expect(client.request({ url: '/api/user/detail' })).rejects.toMatchObject({\n+      code: 'API_SIGNATURE_CREDENTIALS_MISSING', kind: 'configuration',\n+    })\n+    expect(sent).toBe(0)\n+  })\n+\n   it('returns response data and applies the default timeout', async () => {\n     const client = createHttpClient({\n       baseURL: 'https://api.example.test',\ndiff --git a/src/services/http/httpClient.ts b/src/services/http/httpClient.ts\nindex c16f6fa..8a067e9 100644\n--- a/src/services/http/httpClient.ts\n+++ b/src/services/http/httpClient.ts\n@@ -6,23 +6,30 @@ import axios, {\n } from 'axios'\n import { runtimePolicy } from '../../app/config/runtime'\n import { AppError, toAppError } from './appError'\n+import { signCardApiUrl, type ApiSignatureCredentials } from './apiSignature'\n \n export const DEFAULT_HTTP_TIMEOUT_MS = 10_000\n \n type Awaitable<T> = T | Promise<T>\n \n export type HttpAuthHeadersProvider = () => Awaitable<Record<string, string> | undefined>\n+export type HttpSignatureCredentialsProvider = () => Awaitable<ApiSignatureCredentials | undefined>\n export type HttpUnauthorizedHandler = () => Awaitable<void>\n export type HttpAuthFailureHandler = () => void\n \n export interface HttpRequestConfig extends AxiosRequestConfig {\n   skipAuth?: boolean\n+  /** Used for the OAuth request before the H5 session exists. */\n+  signatureCredentials?: ApiSignatureCredentials\n }\n \n export interface HttpClientOptions {\n   baseURL?: string\n   timeoutMs?: number\n   authHeadersProvider?: HttpAuthHeadersProvider\n+  signatureCredentialsProvider?: HttpSignatureCredentialsProvider\n+  /** Override only for focused tests; production follows runtime API mode. */\n+  signatureEnabled?: boolean\n   onUnauthorized?: HttpUnauthorizedHandler\n   onAuthFailure?: HttpAuthFailureHandler\n   adapter?: AxiosAdapter\n@@ -33,6 +40,7 @@ export interface HttpClient {\n }\n \n let defaultAuthHeadersProvider: HttpAuthHeadersProvider | undefined\n+let defaultSignatureCredentialsProvider: HttpSignatureCredentialsProvider | undefined\n let defaultUnauthorizedHandler: HttpUnauthorizedHandler | undefined\n let defaultAuthFailureHandler: HttpAuthFailureHandler | undefined\n \n@@ -40,6 +48,10 @@ export function setHttpAuthHeadersProvider(provider: HttpAuthHeadersProvider | u\n   defaultAuthHeadersProvider = provider\n }\n \n+export function setHttpSignatureCredentialsProvider(provider: HttpSignatureCredentialsProvider | undefined) {\n+  defaultSignatureCredentialsProvider = provider\n+}\n+\n export function setHttpUnauthorizedHandler(handler: HttpUnauthorizedHandler | undefined) {\n   defaultUnauthorizedHandler = handler\n }\n@@ -123,14 +135,32 @@ export function createHttpClient(options: HttpClientOptions = {}): HttpClient {\n       (config as HttpRequestConfig).skipAuth || !belongsToBaseURL(config, effectiveBaseURL)\n         ? undefined\n         : await headersProvider()\n+    const headers = AxiosHeaders.from(config.headers)\n     if (authHeaders) {\n-      const headers = AxiosHeaders.from(config.headers)\n-      for (const [name, value] of Object.entries(authHeaders)) {\n-        headers.set(name, value)\n-      }\n-      config.headers = headers\n+      for (const [name, value] of Object.entries(authHeaders)) headers.set(name, value)\n     }\n \n+    // skipAuth suppresses Bearer on OAuth, but DOES NOT suppress the API signature.\n+    // The backend signs the received query + body; all parameters are encoded only\n+    // after computing secstr. Never attach Native credentials to a foreign origin.\n+    const sameOrigin = belongsToBaseURL(config, effectiveBaseURL)\n+    const requestUrl = new URL(config.url ?? '', effectiveBaseURL).toString()\n+    if ((options.signatureEnabled ?? runtimePolicy.dataMode === 'api') &&\n+        sameOrigin && new URL(requestUrl).pathname.startsWith('/api/')) {\n+      const credentials = (config as HttpRequestConfig).signatureCredentials ??\n+        await (options.signatureCredentialsProvider ?? defaultSignatureCredentialsProvider)?.()\n+      if (!credentials?.salt) {\n+        throw new AppError({\n+          kind: 'configuration', code: 'API_SIGNATURE_CREDENTIALS_MISSING',\n+          message: '当前 App 未提供签名凭据，请重新进入登录流程',\n+        })\n+      }\n+      config.url = signCardApiUrl(requestUrl, config.params as Record<string, unknown> | URLSearchParams | undefined,\n+        config.data, credentials)\n+      config.params = undefined\n+      headers.set('salt', credentials.salt)\n+    }\n+    config.headers = headers\n     return config\n   })",
          "actualWorkers": 2
        },
        "id": "chromium-mobile",
        "name": "chromium-mobile",
        "testDir": "/home/runner/work/dr-card-web/dr-card-web/tests/e2e",
        "testIgnore": [],
        "testMatch": [
          "**/*.@(spec|test).?(c|m)[jt]s?(x)"
        ],
        "timeout": 30000
      }
    ],
    "quiet": false,
    "reporter": [
      [
        "line",
        null
      ],
      [
        "html",
        {
          "outputFolder": "playwright-report",
          "open": "never"
        }
      ],
      [
        "json",
        {
          "outputFile": "test-results/playwright-results.json"
        }
      ]
    ],
    "reportSlowTests": {
      "max": 5,
      "threshold": 300000
    },
    "shard": null,
    "tags": [],
    "updateSnapshots": "missing",
    "updateSourceMethod": "patch",
    "version": "1.63.0",
    "workers": 2,
    "webServer": null
  },
  "suites": [
    {
      "title": "test-gate.spec.ts",
      "file": "test-gate.spec.ts",
      "column": 0,
      "line": 0,
      "specs": [
        {
          "title": "@test-gate build identity matches the production-like test policy",
          "ok": true,
          "tags": [
            "test-gate"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium-mobile",
              "projectName": "chromium-mobile",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 45,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-16T11:11:40.202Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-c3b6e0ce5d8c5d8734e5",
          "file": "test-gate.spec.ts",
          "line": 31,
          "column": 1
        },
        {
          "title": "@test-gate browser entry does not start the app router",
          "ok": true,
          "tags": [
            "test-gate"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium-mobile",
              "projectName": "chromium-mobile",
              "results": [
                {
                  "workerIndex": 1,
                  "parallelIndex": 1,
                  "status": "passed",
                  "duration": 1106,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-16T11:11:40.190Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-8ed37c8041402a2a6b34",
          "file": "test-gate.spec.ts",
          "line": 40,
          "column": 1
        },
        {
          "title": "@test-gate / shows the unsupported-host notice in a browser",
          "ok": true,
          "tags": [
            "test-gate"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium-mobile",
              "projectName": "chromium-mobile",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 1048,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-16T11:11:40.314Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-b030d57053896c93a550",
          "file": "test-gate.spec.ts",
          "line": 51,
          "column": 3
        },
        {
          "title": "@test-gate /profile shows the unsupported-host notice in a browser",
          "ok": true,
          "tags": [
            "test-gate"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium-mobile",
              "projectName": "chromium-mobile",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 1006,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-16T11:11:41.595Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-8605535a2807e17fcccc",
          "file": "test-gate.spec.ts",
          "line": 51,
          "column": 3
        },
        {
          "title": "@test-gate /settings shows the unsupported-host notice in a browser",
          "ok": true,
          "tags": [
            "test-gate"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium-mobile",
              "projectName": "chromium-mobile",
              "results": [
                {
                  "workerIndex": 1,
                  "parallelIndex": 1,
                  "status": "passed",
                  "duration": 979,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-16T11:11:41.670Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-4a2fdc9884c1725de4f8",
          "file": "test-gate.spec.ts",
          "line": 51,
          "column": 3
        },
        {
          "title": "@test-gate gated browser entry issues no business API request",
          "ok": true,
          "tags": [
            "test-gate"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium-mobile",
              "projectName": "chromium-mobile",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 859,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-16T11:11:42.613Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-09f38a2b8ed735e192d8",
          "file": "test-gate.spec.ts",
          "line": 65,
          "column": 1
        },
        {
          "title": "@test-gate injected native host bypasses the notice and starts the app",
          "ok": true,
          "tags": [
            "test-gate"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium-mobile",
              "projectName": "chromium-mobile",
              "results": [
                {
                  "workerIndex": 1,
                  "parallelIndex": 1,
                  "status": "passed",
                  "duration": 888,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-16T11:11:42.668Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-8caf237c4d42513fff2f",
          "file": "test-gate.spec.ts",
          "line": 80,
          "column": 1
        }
      ]
    }
  ],
  "errors": [],
  "stats": {
    "startTime": "2026-10-16T11:11:39.429Z",
    "duration": 4179.009999999999,
    "expected": 7,
    "skipped": 0,
    "unexpected": 0,
    "flaky": 0
  }
}