diff --git a/packages/client/ui-settings-account/README.i18n.yaml b/packages/client/ui-settings-account/README.i18n.yaml index 07145eee3b..644aee7ecd 100644 --- a/packages/client/ui-settings-account/README.i18n.yaml +++ b/packages/client/ui-settings-account/README.i18n.yaml @@ -36,5 +36,5 @@ en: 7afbd3f7afc20bd8 zh: f54c57e3d6738c7c /deepseek-ai-dsh-client-ui-settings-account/known-limitations-and-deferred-work/dev-note: - en: 2facb0fc2e6c1a9c - zh: bd04803f5c9e132c + en: ebc93259d9f494e2 + zh: dcc679e43cdbd594 diff --git a/packages/client/ui-settings-account/README.md b/packages/client/ui-settings-account/README.md index 434a761785..9a5abacae5 100644 --- a/packages/client/ui-settings-account/README.md +++ b/packages/client/ui-settings-account/README.md @@ -114,6 +114,6 @@ The [desktop login decision](../../../.agents/notes/implemented/architecture/202 Usage and top-up show a centered 24px loading indicator without visible loading text until the native document loads; the return action remains available. The loading SVG is embedded locally from Figma node 2957:72553. -Default-model initialization runs after publishing and accepting the sign-in frame, without delaying subsequent account frames. Failures are recorded in diagnostics and do not mark the signed-in account as failed. +Default-model initialization runs after publishing and accepting the sign-in frame, without delaying subsequent account frames. Failures are recorded with `console.error` in the renderer Console and do not mark the signed-in account as failed. Profile and balance reads also report only failures, without account data or raw exceptions; successful refreshes and account frames are silent. Read-only onboarding settings and a failed initial account stream leave the workspace accessible. A failed initial settings read exposes Retry, which reissues the shared settings read. Overlapping onboarding and Platform overlays release background interaction only after their last owner unmounts. diff --git a/packages/client/ui-settings-account/README.zh.md b/packages/client/ui-settings-account/README.zh.md index 7e7d00a3e6..128f491fb9 100644 --- a/packages/client/ui-settings-account/README.zh.md +++ b/packages/client/ui-settings-account/README.zh.md @@ -114,6 +114,6 @@ Desktop preload 标记在账号凭证已存储后启用引导。进度属于本 用量和充值在原生页面加载完成前显示居中的 24px 加载图标,不显示加载文字;返回操作始终可用。加载 SVG 从 Figma 节点 2957:72553 内嵌到本地样式。 -默认模型初始化在登录帧发布并确认接收后运行,不延迟后续账号帧。初始化失败会记录诊断日志,不会将已登录账号标记为失败。 +默认模型初始化在登录帧发布并确认接收后运行,不延迟后续账号帧。初始化失败通过 `console.error` 记录在渲染进程 Console 中,不会将已登录账号标记为失败。资料和余额读取也仅在失败时记录日志,不包含账号数据或原始异常;成功刷新和账号状态帧不输出日志。 引导设置只读或初始账号流失败时,工作区保持可访问。首次设置读取失败会显示重试入口,重新发起共享设置读取。引导与 Platform 覆盖层重叠时,最后一个持有者卸载后才恢复背景交互。 diff --git a/packages/client/ui-settings-account/src/client/index.ts b/packages/client/ui-settings-account/src/client/index.ts index 3e64599cd4..bbb892ca91 100644 --- a/packages/client/ui-settings-account/src/client/index.ts +++ b/packages/client/ui-settings-account/src/client/index.ts @@ -93,6 +93,7 @@ export function apply(ctx: Context): void { let value: AccountDetails[K] | null try { value = await query() } catch { value = { status: 'failed' } } + if (value?.status === 'failed') console.error('[deepseek-account] account read failed', { field }) if (generation === revision && value !== null) publish({ ...snapshot, details: { ...snapshot.details, [field]: value } }) } const request = Promise.all([ @@ -133,9 +134,9 @@ export function apply(ctx: Context): void { if (initialize) void (async () => { try { const initialized = await ctx.remote.session.initializeDefaultModel() - if (!initialized.ok) console.info('[deepseek-account] default model initialization failed', { reason: 'refused' }) + if (!initialized.ok) console.error('[deepseek-account] default model initialization failed', { reason: 'refused' }) } catch (_error) { - console.info('[deepseek-account] default model initialization failed', { reason: 'disconnected' }) + console.error('[deepseek-account] default model initialization failed', { reason: 'disconnected' }) } })() void refresh() diff --git a/packages/client/ui-settings-account/tests/apply.client.spec.ts b/packages/client/ui-settings-account/tests/apply.client.spec.ts index 741d485213..e34a109b77 100644 --- a/packages/client/ui-settings-account/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-account/tests/apply.client.spec.ts @@ -207,6 +207,40 @@ it('reports the account sampled by the click when a native read outlasts a sign- expect(new URL(String(open.mock.calls.at(-1)![0])).searchParams.get('prefill_uid')).toBe('account-user') }, 60_000) +it('keeps successful account refreshes silent and reports only failed reads', async ({ start }) => { + vi.stubGlobal('dshDesktop', {}) + const output = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const info = vi.spyOn(console, 'info').mockImplementation(() => undefined) + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined) + const c = await start() + const actions = operations(c) + await actions.refreshAccount() + c.mock.remote.account.getProfile.mockResolvedValue(ok(profile)) + c.mock.remote.account.getBalance.mockResolvedValue(ok({ status: 'ready', value: [], bonusWallets: [] })) + c.mock.streams.push('account/watch', stored) + await vi.waitFor(() => { expect(actions.hooks.account.getSnapshot().details?.profile).toEqual(profile) }) + await actions.refreshAccount() + for (const spy of [output, info, log]) { + expect(spy.mock.calls.filter(call => String(call[0]).startsWith('[deepseek-account]'))).toEqual([]) + } + c.mock.remote.account.getProfile.mockResolvedValueOnce(ok({ status: 'failed' })) + c.mock.remote.account.getBalance.mockRejectedValueOnce(new Error('private-transport-detail')) + await actions.refreshAccount() + expect(output).toHaveBeenCalledWith('[deepseek-account] account read failed', expect.objectContaining({ + field: 'profile', + })) + expect(output).toHaveBeenCalledWith('[deepseek-account] account read failed', expect.objectContaining({ + field: 'balance', + })) + expect(JSON.stringify(output.mock.calls)).not.toContain('private-transport-detail') + c.mock.remote.account.getBalance.mockResolvedValueOnce({ ok: false, error: new RemoteError('gateway/internal', 'private-rpc-detail', {}) }) + await actions.refreshAccount() + expect(output).toHaveBeenCalledWith('[deepseek-account] account read failed', expect.objectContaining({ + field: 'balance', + })) + expect(JSON.stringify(output.mock.calls)).not.toContain('private-rpc-detail') +}, 60_000) + it('coalesces refreshes, publishes independent failures, and rejects stale responses after sign-out or unload', async ({ start }) => { vi.stubGlobal('dshDesktop', {}) const c = await start() diff --git a/packages/client/ui-settings-account/tests/initialization.client.spec.ts b/packages/client/ui-settings-account/tests/initialization.client.spec.ts index ccd394d506..daa962d263 100644 --- a/packages/client/ui-settings-account/tests/initialization.client.spec.ts +++ b/packages/client/ui-settings-account/tests/initialization.client.spec.ts @@ -4,11 +4,12 @@ import { expect, it, vi, afterEach } from 'vitest' import type { AccountView, SignInAttemptId } from '@deepseek-ai/dsh-deepseek-account/types' import { apply, type AccountSectionInjected } from '../src/client/index.ts' -afterEach(() => vi.unstubAllGlobals()) +afterEach(() => { vi.unstubAllGlobals(); vi.unstubAllEnvs() }) it.each(['accepted', 'refused', 'disconnected', 'pending'] as const)('initializes account models and keeps reading account state: %s', async (outcome) => { vi.stubGlobal('dshDesktop', {}) - const log = vi.spyOn(console, 'info').mockImplementation(() => {}) + vi.stubEnv('DSH_CLIENT_VERSION', '0.0.0-test') + const log = vi.spyOn(console, 'error').mockImplementation(() => {}) const observed: { status: string | undefined; failed: boolean | undefined }[] = [] const abort = new AbortController() const done = Promise.withResolvers() @@ -28,7 +29,7 @@ it.each(['accepted', 'refused', 'disconnected', 'pending'] as const)('initialize let operations: AccountSectionInjected | undefined const ctx = { effect: (install: () => (() => void) | undefined) => { const dispose = install(); if (dispose) disposers.push(dispose) }, - locale: { register: () => () => {}, bind: () => (key: string) => key }, + locale: { register: () => () => {}, bind: () => (key: string) => key, getSnapshot: () => ({ active: 'en' }) }, configForms: { get: () => ({ subscribe: () => () => {}, getSnapshot: () => ({ status: 'loading', value: undefined }) }), describe: () => ({ subscribe: () => () => {}, getSnapshot: () => ({ error: null }) }), diff --git a/packages/credentials/deepseek-account-platform/README.i18n.yaml b/packages/credentials/deepseek-account-platform/README.i18n.yaml index 7d4dc7ce14..67220428cd 100644 --- a/packages/credentials/deepseek-account-platform/README.i18n.yaml +++ b/packages/credentials/deepseek-account-platform/README.i18n.yaml @@ -15,8 +15,8 @@ en: af856ba79fe31b8e zh: 15b7d1a5d1856de2 /deepseek-ai-dsh-deepseek-account-platform/use-this-package: - en: ec9bb416eec2dcae - zh: 15b9c498388be972 + en: 03ce750274b355f6 + zh: 0bfb86570c686e17 /deepseek-ai-dsh-deepseek-account-platform/understand-the-implementation: en: 76d2da2dab100ac3 zh: 2c9404f27915daab diff --git a/packages/credentials/deepseek-account-platform/README.md b/packages/credentials/deepseek-account-platform/README.md index 787e36c2d0..394322d273 100644 --- a/packages/credentials/deepseek-account-platform/README.md +++ b/packages/credentials/deepseek-account-platform/README.md @@ -59,7 +59,7 @@ A private patch can read environment variables through Cordis expressions: The authenticated client supplies its browser-accessible callback origin, including the local SSH forwarding port, and its UI kind. The provider fixes the callback path and binds the resulting redirect_uri to both initialization and exchange. Callback requests use state and PKCE validation rather than RPC authentication. Completion, cancellation and disposal remove only the attempt route. Exchange failures publish failed state without retrying: Web closes its authorization tab and keeps the failure dialog in the original tab; the callback page attempts to close itself and offers a manual-close message. Desktop receives HTTP 204 and focuses its existing login UI through the account stream. -Host diagnostics use the `[deepseek-account]` prefix on standard output and in the Host inspector Console. They include endpoint paths, HTTP status, numeric response codes, failure stages, invalid field paths, received types and validation codes, and browser URL rejection rules; headers, request and response bodies, authorization URLs, and raw exceptions are excluded. +Host diagnostics use `console.error` with the `[deepseek-account]` prefix on standard error and in the Host inspector Console, only for request, response-validation, or sign-in failures. Successful requests, caller cancellation, and normal account state changes are silent; request timeouts remain failures. Failure diagnostics include endpoint paths, HTTP status, numeric response codes, failure stages, invalid field paths, received types and validation codes, and browser URL rejection rules; headers, request and response bodies, authorization URLs, and raw exceptions are excluded. `rewriteBrowserOrigin` defaults to `false`, requiring same-origin browser URLs. A private development patch may set it to `true` to map authorization and completion URLs to `platformOrigin`, preserving their fixed paths and query strings. Source URLs must use HTTPS or already match the configured origin; user information, fragments and unexpected paths remain rejected. Shipped profiles retain strict same-origin validation. diff --git a/packages/credentials/deepseek-account-platform/README.zh.md b/packages/credentials/deepseek-account-platform/README.zh.md index 8e292950c1..611752c3d8 100644 --- a/packages/credentials/deepseek-account-platform/README.zh.md +++ b/packages/credentials/deepseek-account-platform/README.zh.md @@ -59,7 +59,7 @@ getUnnotifiedBonuses 读取同一来源上的 GET /api/v0/users/get_unnotified_b 已鉴权客户端提供浏览器可访问的回调来源(含 SSH 本地转发端口)和 UI 类型。提供者固定回调路径,并在初始化与兑换时使用同一个 redirect_uri。回调请求使用 state 和 PKCE 校验,不要求 RPC 鉴权。完成、取消和卸载仅移除本次尝试的路由。兑换失败发布 failed 状态,不自动重试:Web 关闭授权标签页,原标签页显示失败弹窗;回调页尝试自行关闭,并提供手动关闭提示。Desktop 收到 HTTP 204,并通过账号状态流聚焦现有登录界面。 -Host 诊断通过标准输出及 Host 调试器 Console 输出,前缀为 `[deepseek-account]`。日志包含接口路径、HTTP 状态、数值响应码、失败阶段、校验失败的字段路径、实际类型和校验码,以及浏览器 URL 拒绝规则,不包含请求头、请求和响应正文、授权 URL 或原始异常。 +Host 诊断仅在请求、响应校验或登录失败时,通过 `console.error` 写入标准错误及 Host 调试器 Console,前缀为 `[deepseek-account]`。成功请求、调用方主动取消和正常账号状态变化不输出日志;请求超时仍记录为失败。失败诊断包含接口路径、HTTP 状态、数值响应码、失败阶段、校验失败的字段路径、实际类型和校验码,以及浏览器 URL 拒绝规则,不包含请求头、请求和响应正文、授权 URL 或原始异常。 `rewriteBrowserOrigin` 默认为 `false`,要求浏览器地址同源。私有开发 patch 可设为 `true`,将授权页和完成页地址映射到 `platformOrigin`,保留固定路径和查询字符串。原地址必须使用 HTTPS 或已匹配配置来源;仍拒绝用户名密码、片段和非预期路径。发布的 profile 保持严格同源校验。 diff --git a/packages/credentials/deepseek-account-platform/src/details.ts b/packages/credentials/deepseek-account-platform/src/details.ts index a93a383ee0..f80cdee953 100644 --- a/packages/credentials/deepseek-account-platform/src/details.ts +++ b/packages/credentials/deepseek-account-platform/src/details.ts @@ -29,23 +29,39 @@ const unnotified = z.array(bonus) export function profile(value: unknown): AccountProfile { const parsed = user.safeParse(value) if (!parsed.success) throw new PlatformAuthError('protocol') - const { email, mobile, mobile_number: mobileNumber, id_profile: identity } = parsed.data + return projectProfile(parsed.data) +} + +function projectProfile(value: z.infer): AccountProfile { + const { email, mobile, mobile_number: mobileNumber, id_profile: identity } = value return { - id: parsed.data.id == null ? null : parsed.data.id as AccountUserId, + id: value.id == null ? null : value.id as AccountUserId, avatarUrl: identity?.picture || null, name: identity?.name || null, contact: mobile || mobileNumber || email || null, } } +function accountPayload(schema: z.ZodType, value: unknown, field: 'profile' | 'balance' | 'bonus'): T { + const parsed = schema.safeParse(value) + if (!parsed.success) { + console.error(`[deepseek-account] ${field} schema rejected`, { + issues: parsed.error.issues.map(issue => ({ path: issue.path, code: issue.code })), + }) + throw new PlatformAuthError('protocol') + } + return parsed.data +} + const queries: { [K in keyof AccountDetails]: { path: '/auth-api/v0/users/current' | '/api/v0/users/get_user_summary' parse: (value: unknown) => AccountDetails[K] } } = { - profile: { path: '/auth-api/v0/users/current', parse: value => ({ status: 'ready', value: profile(value) }) }, + profile: { path: '/auth-api/v0/users/current', parse: value => ({ + status: 'ready', value: projectProfile(accountPayload(user, value, 'profile')), + }) }, balance: { path: '/api/v0/users/get_user_summary', parse: (value) => { - const parsed = summary.safeParse(value) - if (!parsed.success) throw new PlatformAuthError('protocol') - return { status: 'ready', value: parsed.data.normal_wallets, bonusWallets: parsed.data.bonus_wallets } + const parsed = accountPayload(summary, value, 'balance') + return { status: 'ready', value: parsed.normal_wallets, bonusWallets: parsed.bonus_wallets } } }, } @@ -79,9 +95,8 @@ export async function readAccountDetail(field: K */ export async function readUnnotifiedBonuses(origin: string, token: string, signal: AbortSignal, headers: Record): Promise { - const parsed = unnotified.safeParse(await requestUnnotifiedBonuses(origin, token, signal, headers)) - if (!parsed.success) throw new PlatformAuthError('protocol') - return parsed.data.map(row => ({ + const parsed = accountPayload(unnotified, await requestUnnotifiedBonuses(origin, token, signal, headers), 'bonus') + return parsed.map(row => ({ orderId: row.order_id as AccountBonusOrderId, campaign: row.campaign, amount: row.amount, currency: row.currency, grantedAt: row.granted_at, expiresAt: row.expires_at, message: row.msg, })) diff --git a/packages/credentials/deepseek-account-platform/src/index.ts b/packages/credentials/deepseek-account-platform/src/index.ts index bfd2577a1e..0319a553c3 100644 --- a/packages/credentials/deepseek-account-platform/src/index.ts +++ b/packages/credentials/deepseek-account-platform/src/index.ts @@ -172,7 +172,6 @@ export class PlatformAccount extends DeepSeekAccount { if (!parsed.success) throw new PlatformAuthError('storage') if (parsed.data.issuer === this.origin) return await this.ctx.credentials.deleteRecord(KEY) - console.info('[deepseek-account] stored grant discarded', { reason: 'issuer-mismatch' }) } override async getState(): Promise { @@ -433,7 +432,7 @@ export class PlatformAccount extends DeepSeekAccount { } else attempt.callback?.writeHead(204, { 'cache-control': 'no-store' }).end() }).catch((error: unknown) => { const code = error instanceof PlatformAuthError ? error.code : 'protocol' - console.info('[deepseek-account] sign-in failed', { errorCode: code }) + console.error('[deepseek-account] sign-in failed', { errorCode: code }) this.update(attempt, { phase: code === 'expired' ? 'expired' : 'failed', errorCode: code }) this.finishFailedCallback(attempt) }).then(async () => { @@ -613,7 +612,7 @@ export class PlatformAccount extends DeepSeekAccount { } private rejectPayload(stage: string, error: z.ZodError): never { - console.info('[deepseek-account] payload rejected', { + console.error('[deepseek-account] payload rejected', { stage, issues: error.issues.map(issue => ({ path: issue.path, code: issue.code, receivedType: issue.input === null ? 'null' : Array.isArray(issue.input) ? 'array' : typeof issue.input, diff --git a/packages/credentials/deepseek-account-platform/src/protocol.ts b/packages/credentials/deepseek-account-platform/src/protocol.ts index a8ab9b3d3c..54d1afd9ad 100644 --- a/packages/credentials/deepseek-account-platform/src/protocol.ts +++ b/packages/credentials/deepseek-account-platform/src/protocol.ts @@ -40,12 +40,12 @@ export function platformOrigin(value: string, allowLoopbackHttp: boolean): strin export function browserUrl(value: string, origin: string, path: string, rewriteOrigin = false): string { let url: URL try { url = new URL(value) } catch { - console.info('[deepseek-account] browser URL rejected', { path, reason: 'invalid-url' }) + console.error('[deepseek-account] browser URL rejected', { path, reason: 'invalid-url' }) throw new PlatformAuthError('protocol') } const allowedOrigin = url.origin === origin || (rewriteOrigin && url.protocol === 'https:') if (!allowedOrigin || url.pathname !== path || url.username || url.password || url.hash) { - console.info('[deepseek-account] browser URL rejected', { + console.error('[deepseek-account] browser URL rejected', { path, originMismatch: !allowedOrigin, pathMismatch: url.pathname !== path, hasCredentials: Boolean(url.username || url.password), hasFragment: Boolean(url.hash), }) @@ -82,7 +82,7 @@ export const initialization = z.object({ export const exchange = z.object({ token: z.string().regex(/^[\x21-\x7e]+$/), authorized_url: z.url(), user: z.unknown().optional() }) /** - * Read one bounded platform response with stable, non-secret diagnostics. + * Read one bounded platform response; non-cancellation failures emit non-secret diagnostics to stderr. * @param origin - validated platform origin. * @param method - platform endpoint suffix. * @param body - protocol request, never logged. @@ -163,22 +163,28 @@ export async function logoutAccount(origin: string, token: string, }, signal) } +/** Caller cancellation stays silent; standard TimeoutError aborts remain failures. */ +function callerCancelled(signal: AbortSignal): boolean { + const reason: unknown = signal.reason + return signal.aborted && !(reason instanceof Error && reason.name === 'TimeoutError') +} + async function platformRequest(url: string, init: RequestInit, signal: AbortSignal): Promise { const path = new URL(url).pathname - console.info('[deepseek-account] request', { path, method: init.method }) let response: Response try { response = await fetch(url, { ...init, redirect: 'error', signal }) } catch { - console.info('[deepseek-account] request failed', { path, errorCode: 'no-response', aborted: signal.aborted }) + if (!callerCancelled(signal)) console.error('[deepseek-account] request failed', { path, errorCode: 'no-response', aborted: signal.aborted }) throw new PlatformAuthError('no-response') } - console.info('[deepseek-account] response', { path, status: response.status }) if (response.status === 401 && new Headers(init.headers).has('x-dsh-auth-token')) { + if (!callerCancelled(signal)) console.error('[deepseek-account] request failed', { path, status: response.status, errorCode: 'expired' }) await response.body?.cancel() throw new AccountUnauthorizedError() } if (!response.ok || response.body === null) { + if (!callerCancelled(signal)) console.error('[deepseek-account] request failed', { path, status: response.status, errorCode: 'network' }) await response.body?.cancel() throw new PlatformAuthError('network') } @@ -186,6 +192,8 @@ async function platformRequest(url: string, init: RequestInit, signal: AbortSign const chunks: Uint8Array[] = [] let size = 0 let stage = 'read-body' + let codes: { code: number; data?: { biz_code: number } | undefined } | undefined + let validationError: z.ZodError | undefined try { while (true) { const next = await reader.read() @@ -199,20 +207,16 @@ async function platformRequest(url: string, init: RequestInit, signal: AbortSign } stage = 'parse-json' const payload: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8')) + const responseCodes = z.object({ code: z.number().int(), data: z.object({ biz_code: z.number().int() }).optional() }).safeParse(payload) + if (responseCodes.success) codes = responseCodes.data const authorization = z.object({ code: z.literal(40003) }).safeParse(payload) if (authorization.success && new Headers(init.headers).has('x-dsh-auth-token')) { throw new AccountUnauthorizedError() } - const codes = z.object({ code: z.number().int(), data: z.object({ biz_code: z.number().int() }).optional() }).safeParse(payload) - if (codes.success) console.info('[deepseek-account] response codes', { - path, code: codes.data.code, bizCode: codes.data.data?.biz_code, - }) stage = 'envelope' const parsed = envelope.safeParse(payload) if (!parsed.success) { - console.info('[deepseek-account] envelope rejected', { - path, issues: parsed.error.issues.map(issue => ({ path: issue.path, code: issue.code })), - }) + validationError = parsed.error throw new PlatformAuthError('protocol') } stage = 'business-code' @@ -223,8 +227,11 @@ async function platformRequest(url: string, init: RequestInit, signal: AbortSign } return parsed.data.data.biz_data } catch (error) { - console.info('[deepseek-account] response rejected', { path, stage, - errorCode: error instanceof PlatformAuthError ? error.code : 'protocol' }) + if (!callerCancelled(signal)) console.error('[deepseek-account] response rejected', { + path, status: response.status, stage, code: codes?.code, bizCode: codes?.data?.biz_code, + issues: validationError?.issues.map(issue => ({ path: issue.path, code: issue.code })), + errorCode: error instanceof PlatformAuthError ? error.code : 'protocol', + }) if (error instanceof PlatformAuthError) throw error throw new PlatformAuthError('protocol') } finally { diff --git a/packages/credentials/deepseek-account-platform/tests/diagnostics.spec.ts b/packages/credentials/deepseek-account-platform/tests/diagnostics.spec.ts index 55e41ac62b..7ade4bc6f0 100644 --- a/packages/credentials/deepseek-account-platform/tests/diagnostics.spec.ts +++ b/packages/credentials/deepseek-account-platform/tests/diagnostics.spec.ts @@ -1,5 +1,7 @@ import { expect, it, vi } from 'vitest' -import { browserUrl, requestPlatform } from '../src/protocol.ts' +import { browserUrl, logoutAccount, requestAccount, requestBonusNotified, requestPlatform, requestUnnotifiedBonuses } from '../src/protocol.ts' +import type { AccountBonusOrderId } from '@deepseek-ai/dsh-deepseek-account/types' +import { readAccountDetail, readUnnotifiedBonuses } from '../src/details.ts' it.each(['/dsh/authorize', '/dsh/authorized'])('maps %s to the configured development origin and preserves query bytes', (path) => { const origin = 'http://localhost:8081' @@ -18,7 +20,7 @@ it.each(['/dsh/authorize', '/dsh/authorized'])('maps %s to the configured develo }) it('reports business failure codes without credentials or response messages', async () => { - const output = vi.spyOn(console, 'info').mockImplementation(() => undefined) + const output = vi.spyOn(console, 'error').mockImplementation(() => undefined) const fetcher = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ code: 0, data: { biz_code: 40123, biz_data: { token: 'response-secret' } }, message: 'private-message', }))) @@ -26,6 +28,7 @@ it('reports business failure codes without credentials or response messages', as await expect(requestPlatform('https://platform.deepseek.com', 'auth_init', { code_verifier: 'request-secret' }, new AbortController().signal, { Cookie: 'cookie-secret' })).rejects.toThrow('account: protocol') + expect(output).toHaveBeenCalledOnce() const logged = JSON.stringify(output.mock.calls) expect(logged).toContain('/auth-api/v0/dsh/auth_init') expect(logged).toContain('40123') @@ -40,7 +43,7 @@ it('reports business failure codes without credentials or response messages', as }) it('reports transport failure without exposing the thrown error', async () => { - const output = vi.spyOn(console, 'info').mockImplementation(() => undefined) + const output = vi.spyOn(console, 'error').mockImplementation(() => undefined) const fetcher = vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('private-network-detail')) try { await expect(requestPlatform('https://platform.deepseek.com', 'auth_init', {}, @@ -56,7 +59,7 @@ it('reports transport failure without exposing the thrown error', async () => { it('identifies invalid envelope fields without logging response values', async () => { - const output = vi.spyOn(console, 'info').mockImplementation(() => undefined) + const output = vi.spyOn(console, 'error').mockImplementation(() => undefined) const fetcher = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ code: 0, data: { biz_code: 'private-code', biz_data: { token: 'private-token' } }, }))) @@ -76,7 +79,7 @@ it('identifies invalid envelope fields without logging response values', async ( }) it('reports browser URL rejection rules without exposing the destination', () => { - const output = vi.spyOn(console, 'info').mockImplementation(() => undefined) + const output = vi.spyOn(console, 'error').mockImplementation(() => undefined) try { expect(() => browserUrl('https://private.example/wrong?code=private-code', 'https://platform.deepseek.com', '/dsh/authorize')).toThrow('account: protocol') @@ -102,14 +105,198 @@ it.each([ start(controller) { controller.error(new Error('private-stream-error')) }, })), code: 'protocol' }, ])('rejects $name without exposing response data', async ({ response, code }) => { - const output = vi.spyOn(console, 'info').mockImplementation(() => undefined) + const output = vi.spyOn(console, 'error').mockImplementation(() => undefined) const fetcher = vi.spyOn(globalThis, 'fetch').mockResolvedValue(response()) try { await expect(requestPlatform('https://platform.deepseek.com', 'auth_init', {}, new AbortController().signal, {})).rejects.toThrow(`account: ${code}`) + expect(output).toHaveBeenCalled() expect(JSON.stringify(output.mock.calls)).not.toContain('private-') } finally { fetcher.mockRestore() output.mockRestore() } }) + +it('keeps successful authorization, account, bonus and logout requests silent', async () => { + const output = ['log', 'info', 'error'] as const + const spies = output.map(method => vi.spyOn(console, method).mockImplementation(() => undefined)) + const fetcher = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => new Response(JSON.stringify({ + code: 0, data: { biz_code: 0, biz_data: null }, + }))) + try { + const origin = 'https://platform.deepseek.com' + const signal = new AbortController().signal + for (const method of ['auth_init', 'auth_exchange', 'auth_cancel']) { + await requestPlatform(origin, method, {}, signal, {}) + } + for (const path of ['/auth-api/v0/users/current', '/api/v0/users/get_user_summary'] as const) { + await requestAccount(origin, path, 'private-token', signal, {}) + } + await requestUnnotifiedBonuses(origin, 'private-token', signal, {}) + await requestBonusNotified(origin, 'private-token', 'fixture-order' as AccountBonusOrderId, signal, {}) + await logoutAccount(origin, 'private-token', signal, {}) + fetcher.mockResolvedValueOnce(new Response(JSON.stringify({ + code: 0, data: { biz_code: 0, biz_data: { email: 'private-email' } }, + }))) + expect(await readAccountDetail('profile', origin, 'private-token', signal, {})).toMatchObject({ status: 'ready' }) + fetcher.mockResolvedValueOnce(new Response(JSON.stringify({ + code: 0, data: { biz_code: 0, biz_data: { normal_wallets: [], bonus_wallets: [] } }, + }))) + expect(await readAccountDetail('balance', origin, 'private-token', signal, {})).toMatchObject({ status: 'ready' }) + for (const spy of spies) expect(spy).not.toHaveBeenCalled() + } finally { + fetcher.mockRestore() + for (const spy of spies) spy.mockRestore() + } +}) + +it.each([ + { name: 'HTTP rejection', status: 401, payload: 'private-body' }, + { name: 'authorization code rejection', status: 200, payload: JSON.stringify({ code: 40003 }) }, +])('reports $name on the error console without the credential or body', async ({ status, payload }) => { + const output = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const info = vi.spyOn(console, 'info').mockImplementation(() => undefined) + const fetcher = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(payload, { status })) + try { + await expect(requestAccount('https://platform.deepseek.com', '/auth-api/v0/users/current', + 'private-token', new AbortController().signal, {})).rejects.toThrow('account: expired') + expect(output).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ + path: '/auth-api/v0/users/current', status, + })) + expect(JSON.stringify(output.mock.calls)).not.toContain('private-') + expect(info).not.toHaveBeenCalled() + } finally { + fetcher.mockRestore() + info.mockRestore() + output.mockRestore() + } +}) + + +it('reports invalid bonus fields without logging bonus data', async () => { + const output = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const fetcher = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ + code: 0, data: { biz_code: 0, biz_data: [{ order_id: 'private-order', msg: 'private-message' }] }, + }))) + try { + await expect(readUnnotifiedBonuses('https://platform.deepseek.com', 'private-token', + new AbortController().signal, {})).rejects.toThrow('account: protocol') + expect(output.mock.calls[0]?.[0]).toBe('[deepseek-account] bonus schema rejected') + expect(JSON.stringify(output.mock.calls)).toContain('order_id') + expect(JSON.stringify(output.mock.calls)).not.toContain('private-') + } finally { + fetcher.mockRestore() + output.mockRestore() + } +}) + + +it.each([ + { stage: 'fetch', code: 'no-response' }, + { stage: 'unauthorized', code: 'expired' }, + { stage: 'http', code: 'network' }, + { stage: 'body', code: 'protocol' }, +])('keeps caller cancellation silent during $stage', async ({ stage, code }) => { + const output = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const fetcher = vi.spyOn(globalThis, 'fetch') + const cancellation = new AbortController() + const reading = Promise.withResolvers() + let body: ReadableStreamDefaultController | undefined + try { + if (stage === 'body') { + fetcher.mockResolvedValue(new Response(new ReadableStream({ + start(controller) { body = controller }, + pull() { reading.resolve(undefined) }, + }))) + } else if (stage === 'fetch') { + cancellation.abort() + fetcher.mockRejectedValue(cancellation.signal.reason) + } else { + fetcher.mockImplementation(async () => { + cancellation.abort() + return new Response('private-response', { status: stage === 'unauthorized' ? 401 : 503 }) + }) + } + const request = requestAccount('https://platform.deepseek.com', '/auth-api/v0/users/current', + 'private-token', cancellation.signal, {}) + if (stage === 'body') { + await reading.promise + cancellation.abort() + body!.error(cancellation.signal.reason) + } + await expect(request).rejects.toThrow(`account: ${code}`) + expect(output).not.toHaveBeenCalled() + } finally { + body?.error() + fetcher.mockRestore() + output.mockRestore() + } +}) + +it('keeps cancellation with a non-error reason silent', async () => { + const output = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const fetcher = vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('private-network-error')) + const cancellation = new AbortController() + cancellation.abort('private-cancel-reason') + try { + expect(await readAccountDetail('profile', 'https://platform.deepseek.com', 'private-token', + cancellation.signal, {})).toEqual({ status: 'failed' }) + expect(output).not.toHaveBeenCalled() + } finally { + fetcher.mockRestore() + output.mockRestore() + } +}) + +it.each(['fetch', 'body'] as const)('reports request timeouts during %s once without raw exceptions', async (stage) => { + const output = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const fetcher = vi.spyOn(globalThis, 'fetch') + const timeout = new AbortController() + const reading = Promise.withResolvers() + let body: ReadableStreamDefaultController | undefined + try { + if (stage === 'body') { + fetcher.mockResolvedValue(new Response(new ReadableStream({ + start(controller) { body = controller }, + pull() { reading.resolve(undefined) }, + }))) + } else { + timeout.abort(new DOMException('private-timeout-message', 'TimeoutError')) + fetcher.mockRejectedValue(timeout.signal.reason) + } + const request = readAccountDetail('profile', 'https://platform.deepseek.com', 'private-token', timeout.signal, {}) + if (stage === 'body') { + await reading.promise + timeout.abort(new DOMException('private-timeout-message', 'TimeoutError')) + body!.error(timeout.signal.reason) + } + expect(await request).toEqual({ status: 'failed' }) + expect(output).toHaveBeenCalledOnce() + expect(JSON.stringify(output.mock.calls)).not.toContain('private-') + } finally { + body?.error() + fetcher.mockRestore() + output.mockRestore() + } +}) + +it('reports invalid profile fields once without logging profile values', async () => { + const output = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const fetcher = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ + code: 0, data: { biz_code: 0, biz_data: { email: 42, id: 'private-id' } }, + }))) + try { + expect(await readAccountDetail('profile', 'https://platform.deepseek.com', 'private-token', + new AbortController().signal, {})).toEqual({ status: 'failed' }) + expect(output).toHaveBeenCalledOnce() + expect(output.mock.calls[0]?.[0]).toBe('[deepseek-account] profile schema rejected') + const logged = JSON.stringify(output.mock.calls) + expect(logged).toContain('email') + expect(logged).toContain('invalid_type') + expect(logged).not.toContain('private-') + } finally { + fetcher.mockRestore() + output.mockRestore() + } +})