dsh-tap 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +99 -0
- package/CHANGELOG.md +751 -0
- package/LICENSE +21 -0
- package/README.en.md +59 -0
- package/README.md +256 -0
- package/cordis.patch.yml +385 -0
- package/core/bridge.js +698 -0
- package/core/json-store.js +91 -0
- package/core/rotation.js +108 -0
- package/core/usage-meter.js +176 -0
- package/docs/diagnosis-cache-quota.md +181 -0
- package/docs/diagnosis-qoder-flash.md +67 -0
- package/docs/diagnosis-trae-3003.md +302 -0
- package/docs/goals/bridge-port-host-split.md +91 -0
- package/docs/goals/desktop-adaptation.md +106 -0
- package/docs/goals/qoder-cn-provider-design.md +308 -0
- package/docs/goals/settings-card-ux-redesign-plan.md +1680 -0
- package/docs/goals/settings-card-ux-redesign.md +162 -0
- package/docs/goals/trae-agent-v3.md +41 -0
- package/docs/goals/trae-work-cn-repair.md +44 -0
- package/docs/goals/v0.8-/351/242/235/345/272/246/345/217/257/350/247/201-/346/250/241/345/236/213/345/212/250/346/200/201/345/214/226-/345/244/232/346/234/215/345/212/241/345/225/206.md +85 -0
- package/docs/pitfalls.md +105 -0
- package/docs/reverse/trae-cloud-api.md +218 -0
- package/docs/reverse/trae-model-catalog.md +129 -0
- package/docs/reverse/traework-cn.md +520 -0
- package/docs/rules/STATE.md +198 -0
- package/docs/rules/content-moderation.md +54 -0
- package/docs/rules/dev-role-boundary.md +94 -0
- package/docs/rules/extra-providers.md +42 -0
- package/docs/rules/gateway-facts.md +91 -0
- package/docs/rules/oauth-handshake.md +76 -0
- package/docs/rules/prompt-cache.md +93 -0
- package/docs/rules/quota-signals.md +125 -0
- package/docs/rules/routing.md +84 -0
- package/docs/rules/templates/oauth-reverse-checklist.md +42 -0
- package/docs/rules/trae-surface.md +242 -0
- package/docs/rules/ua-validation.md +81 -0
- package/host-config.js +282 -0
- package/index.js +2306 -0
- package/lib/client.js +2893 -0
- package/local-scan.js +104 -0
- package/package.json +82 -0
- package/providers/ark/index.js +11 -0
- package/providers/bailian/index.js +10 -0
- package/providers/bigmodel/index.js +11 -0
- package/providers/codebuddy/agenttool.js +122 -0
- package/providers/codebuddy/catalog.js +227 -0
- package/providers/codebuddy/errors.js +44 -0
- package/providers/codebuddy/headers.js +36 -0
- package/providers/codebuddy/images.js +125 -0
- package/providers/codebuddy/index.js +123 -0
- package/providers/codebuddy/oauth.js +279 -0
- package/providers/deepseek/index.js +11 -0
- package/providers/moonshot/index.js +11 -0
- package/providers/openai-compat.js +177 -0
- package/providers/openrouter/index.js +27 -0
- package/providers/qoder/catalog.js +145 -0
- package/providers/qoder/cosy.js +419 -0
- package/providers/qoder/gateway.js +563 -0
- package/providers/qoder/index.js +116 -0
- package/providers/qoder/oauth.js +364 -0
- package/providers/qoder/qoder_auth.wasm +0 -0
- package/providers/qoder/quota.js +56 -0
- package/providers/qwen/index.js +15 -0
- package/providers/tool-pairing.js +129 -0
- package/providers/trae/catalog.js +103 -0
- package/providers/trae/errors.js +85 -0
- package/providers/trae/gateway.js +853 -0
- package/providers/trae/index.js +126 -0
- package/providers/trae/oauth.js +443 -0
- package/providers/trae/quota.js +75 -0
- package/providers/trae/remote.js +365 -0
- package/scripts/capture-cache.mjs +65 -0
- package/scripts/capture-traffic.mjs +83 -0
- package/scripts/hermes-probe-dev-role.mjs +263 -0
- package/scripts/measure-latency.mjs +253 -0
- package/scripts/probe-ark-thinking.mjs +298 -0
- package/scripts/probe-cache-decline.mjs +292 -0
- package/scripts/probe-cache-ttl.mjs +221 -0
- package/scripts/probe-cache.mjs +156 -0
- package/scripts/probe-codebuddy-efforts.mjs +355 -0
- package/scripts/probe-codebuddy-tier-wiring.mjs +244 -0
- package/scripts/probe-effort-gaps.mjs +133 -0
- package/scripts/probe-media.mjs +115 -0
- package/scripts/probe-moderation.mjs +159 -0
- package/scripts/probe-oauth.mjs +617 -0
- package/scripts/probe-qoder-attribution-arm8.mjs +92 -0
- package/scripts/probe-qoder-attribution-arm9.mjs +102 -0
- package/scripts/probe-qoder-attribution.mjs +266 -0
- package/scripts/probe-qoder-flash-confirm.mjs +94 -0
- package/scripts/probe-qoder-live.mjs +344 -0
- package/scripts/probe-qoder-matrix.mjs +274 -0
- package/scripts/probe-qoder-null-content.mjs +153 -0
- package/scripts/probe-qoder-pairing.mjs +308 -0
- package/scripts/probe-qoder-quota.mjs +162 -0
- package/scripts/probe-qoder-thinking-config.mjs +52 -0
- package/scripts/probe-qoder-thinking-efforts.mjs +269 -0
- package/scripts/probe-quota.mjs +136 -0
- package/scripts/probe-quota2.mjs +144 -0
- package/scripts/probe-routing.mjs +226 -0
- package/scripts/probe-trae-3003-diagnosis.mjs +139 -0
- package/scripts/probe-trae-agent-v3.mjs +399 -0
- package/scripts/probe-trae-efforts.mjs +149 -0
- package/scripts/probe-trae-live.mjs +147 -0
- package/scripts/probe-trae-max-effort.mjs +179 -0
- package/scripts/probe-trae-model-routing.mjs +381 -0
- package/scripts/probe-trae-thinking-scene.mjs +238 -0
- package/scripts/probe-trae-transport-outage.mjs +176 -0
- package/scripts/probe-ua.mjs +508 -0
- package/scripts/trae-model-catalog.mjs +632 -0
- package/scripts/verify-agents-md.mjs +45 -0
- package/scripts/verify-bridge.mjs +1041 -0
- package/scripts/verify-core-generic.mjs +302 -0
- package/scripts/verify-desktop-acceptance.mjs +184 -0
- package/scripts/verify-host-config.mjs +265 -0
- package/scripts/verify-models.mjs +390 -0
- package/scripts/verify-providers.mjs +255 -0
- package/scripts/verify-qoder-provider.mjs +1020 -0
- package/scripts/verify-rotation.mjs +308 -0
- package/scripts/verify-trae-model-catalog.mjs +284 -0
- package/scripts/verify-trae-provider.mjs +1451 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* providers/trae/index.js — TraeWork CN(api.trae.cn / trae-api-cn.mchost.guru)
|
|
3
|
+
* 薄适配器。
|
|
4
|
+
*
|
|
5
|
+
* 与 providers/codebuddy/ 同构:本目录收敛全部 Trae 上游事实(OAuth 设备流、
|
|
6
|
+
* 本地模型目录、云端翻译网关、错误信封),core/ 与组合根通过结构钩子消费。
|
|
7
|
+
* 规则/证据文档:
|
|
8
|
+
* - docs/reverse/traework-cn.md → OAuth 与云端面逆向档案
|
|
9
|
+
* - docs/reverse/trae-cloud-api.md → 2026-08-23 无凭据探测校准(网关/信封)
|
|
10
|
+
* - docs/reverse/trae-model-catalog.md → 目录提取安全契约
|
|
11
|
+
*
|
|
12
|
+
* 凭据形态与 codebuddy 的差异:Trae 只有 OAuth 一种模式(订阅额度跟账号走),
|
|
13
|
+
* 没有多 Key 轮换;设备密钥自持(oauth.js 文件头),刷新可自控。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { createTraeOAuth } from './oauth.js'
|
|
17
|
+
import { fetchLocalCatalog } from './catalog.js'
|
|
18
|
+
import { createTraeGateway } from './gateway.js'
|
|
19
|
+
import { createTraeQuota } from './quota.js'
|
|
20
|
+
|
|
21
|
+
export const TRAE_PROVIDER_ID = 'trae'
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {{
|
|
25
|
+
* readAuth: () => object,
|
|
26
|
+
* writeAuth: (v: object) => void,
|
|
27
|
+
* settings: () => object,
|
|
28
|
+
* withCredentials: (attempt: (cred) => Promise<Response>) => Promise<{cred,res,err}>,
|
|
29
|
+
* meter: { record: Function },
|
|
30
|
+
* runtime: { running: boolean, port: number|null, lastError: string|null },
|
|
31
|
+
* forensics?: { logPath: () => string|undefined },
|
|
32
|
+
* getModelPrefs?: () => object, // { [id]: { effort? } } remote 出站补默认档位(G3)
|
|
33
|
+
* }} deps
|
|
34
|
+
*/
|
|
35
|
+
export function createTraeProvider(deps) {
|
|
36
|
+
const oauth = createTraeOAuth({ readAuth: deps.readAuth, writeAuth: deps.writeAuth })
|
|
37
|
+
|
|
38
|
+
// 目录实例状态(模块作用域每插件实例一份,踩坑 #20 纪律)。
|
|
39
|
+
let catalogState = null // { profiles, catalog, fetchedAt, source }
|
|
40
|
+
let lastSyncError = null // 最近一次同步失败原因(成功即清空;设置卡模型区显示用)
|
|
41
|
+
|
|
42
|
+
const provider = {
|
|
43
|
+
id: TRAE_PROVIDER_ID,
|
|
44
|
+
oauth,
|
|
45
|
+
|
|
46
|
+
/** 从本机 state.vscdb 同步目录(单飞)。返回 {ok, count|error, kept}。 */
|
|
47
|
+
async syncCatalog({ dbPath } = {}) {
|
|
48
|
+
try {
|
|
49
|
+
const result = fetchLocalCatalog({ dbPath })
|
|
50
|
+
catalogState = result
|
|
51
|
+
lastSyncError = null
|
|
52
|
+
return { ok: true, count: result.profiles.length, fetchedAt: result.fetchedAt, source: result.source }
|
|
53
|
+
} catch (err) {
|
|
54
|
+
lastSyncError = err?.message ?? String(err)
|
|
55
|
+
return { ok: false, error: lastSyncError, kept: catalogState != null }
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
catalogView() {
|
|
60
|
+
return catalogState
|
|
61
|
+
? {
|
|
62
|
+
at: catalogState.fetchedAt,
|
|
63
|
+
count: catalogState.profiles.length,
|
|
64
|
+
candidate: catalogState.source.chosen,
|
|
65
|
+
profiles: catalogState.profiles,
|
|
66
|
+
// 逐模型目录声明档位(reasoning_effort_config.options 原文拼写,G3)——
|
|
67
|
+
// 设置卡 select 选项与 traeModelSetPrefs 校验的真源(上游词汇;宿主
|
|
68
|
+
// 选择器侧的枚举键映射在 catalogToProfiles 的 reasoningEfforts 里)。
|
|
69
|
+
// 未声明模型不在场。max 不在目录 options 里但上游实测接受且产生
|
|
70
|
+
// 比 extra_high 更多的推理(docs/probes/trae-max-effort-*.json),
|
|
71
|
+
// 给声明了 extra_high 的模型追加为上位档。
|
|
72
|
+
efforts: Object.fromEntries(
|
|
73
|
+
(catalogState.catalog?.models ?? [])
|
|
74
|
+
.filter((m) => m.reasoningEffortConfig?.supportThinking === true
|
|
75
|
+
&& Array.isArray(m.reasoningEffortConfig.options) && m.reasoningEffortConfig.options.length)
|
|
76
|
+
.map((m) => {
|
|
77
|
+
const opts = m.reasoningEffortConfig.options.slice()
|
|
78
|
+
if (opts.includes('extra_high') && !opts.includes('max')) opts.push('max')
|
|
79
|
+
return [m.id, opts]
|
|
80
|
+
})),
|
|
81
|
+
}
|
|
82
|
+
: null
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
/** 设置卡模型区的同步状态:成功 → {at,count,candidate};失败 → 附 error
|
|
86
|
+
* (有旧清单则连同旧计数一起给 = kept 语义;从未成功过则只有 error)。 */
|
|
87
|
+
syncView() {
|
|
88
|
+
if (!catalogState) return lastSyncError ? { error: lastSyncError } : null
|
|
89
|
+
const view = {
|
|
90
|
+
at: catalogState.fetchedAt,
|
|
91
|
+
count: catalogState.profiles.length,
|
|
92
|
+
candidate: catalogState.source.chosen,
|
|
93
|
+
}
|
|
94
|
+
if (lastSyncError) view.error = lastSyncError
|
|
95
|
+
return view
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
/** 已同步目录 id(/models 端点与镜像同步用)。 */
|
|
99
|
+
catalogIds() {
|
|
100
|
+
return catalogState ? catalogState.profiles.map((p) => p.id) : []
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
/** 凭据视图(设置卡)。 */
|
|
104
|
+
credentialView() {
|
|
105
|
+
return oauth.oauthStatus()
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
/** 双额度池余额只读快照(60s memoize,永不 throw)。 */
|
|
109
|
+
quota: createTraeQuota({ settings: deps.settings, readAuth: deps.readAuth, oauth }),
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const gateway = createTraeGateway({
|
|
113
|
+
settings: deps.settings,
|
|
114
|
+
withCredentials: deps.withCredentials,
|
|
115
|
+
readAuthDevice: () => deps.readAuth().device ?? null,
|
|
116
|
+
readAuthMeta: () => ({ uid: deps.readAuth().account?.uid ?? null }),
|
|
117
|
+
meter: deps.meter,
|
|
118
|
+
runtime: deps.runtime,
|
|
119
|
+
forensics: deps.forensics,
|
|
120
|
+
getCatalogIds: () => provider.catalogIds(),
|
|
121
|
+
getModelPrefs: deps.getModelPrefs ?? (() => ({})),
|
|
122
|
+
})
|
|
123
|
+
provider.gateway = gateway
|
|
124
|
+
|
|
125
|
+
return provider
|
|
126
|
+
}
|
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* providers/trae/oauth.js — TraeWork CN OAuth 设备流(自持设备密钥)。
|
|
3
|
+
*
|
|
4
|
+
* 流程(docs/reverse/traework-cn.md §OAuth,2026-08-23 无凭据实测校准):
|
|
5
|
+
* 1. 本地回环服务 127.0.0.1:<随机端口>,唯一端点 GET /authorize;
|
|
6
|
+
* 2. 授权页 URL = <loginHost>/authorization?…&code_challenge=S256…(PKCE);
|
|
7
|
+
* 3. 浏览器完成登录后 302 回 /authorize?userInfo=…&authCodeInfo=…(JSON 字符串
|
|
8
|
+
* 内嵌 AuthCode);
|
|
9
|
+
* 4. POST <authBase>/trae/api/v3/oauth/ExchangeToken(AuthCode 模式):
|
|
10
|
+
* {ClientID, AuthCode, CodeVerifier, DeviceInfo{…DevicePublicKey}, IDEVersion}
|
|
11
|
+
* → Result.{Token, RefreshToken, TokenExpireAt, TokenExpireDuration, RefreshExpireAt};
|
|
12
|
+
* 5. 刷新 = 同端点 RefreshToken 模式 + DeviceProof{Signature,Timestamp,Nonce}:
|
|
13
|
+
* 签名串逐行拼接 "POST\n/trae/api/v3/oauth/ExchangeToken\n<ClientID>\n
|
|
14
|
+
* <RefreshToken>\n<Timestamp>\n<Nonce>",ECDSA P-256/SHA-256。
|
|
15
|
+
*
|
|
16
|
+
* 关键设计:设备密钥对由**本插件生成并持有**(首次登录时创建 P-256 PKCS#8,
|
|
17
|
+
* 存 ~/.dsh/trae-plugin-auth.json),DeviceInfo.DevicePublicKey 上报公钥——因此
|
|
18
|
+
* refresh 的 DeviceProof 签名完全可自控,不依赖官方 IDE 的安全存储(traework-cn.md
|
|
19
|
+
* 关键判断 #5 只针对"偷 IDE 的 refresh token"路线,本路线不受其限制)。
|
|
20
|
+
*
|
|
21
|
+
* 实测锚点(2026-08-23,无凭据探测):
|
|
22
|
+
* - ExchangeToken 双路径均存活;假 ClientID → 400 code 10101 "Invalid client.";
|
|
23
|
+
* 真实 ClientID + 假 AuthCode → 400 code 10101 "无效参数:{__Message.field}.";
|
|
24
|
+
* - GetUserInfo 无 token → 401 code 20310 "The user is not logged in,";
|
|
25
|
+
* - 错误信封均为火山系 ResponseMetadata.Error{Code,Message,StandardCode}。
|
|
26
|
+
*
|
|
27
|
+
* 实例状态(refresh 单飞锁、pending 回环服务句柄)在本工厂闭包内(踩坑 #20 纪律)。
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { createServer } from 'node:http'
|
|
31
|
+
import { createHash, generateKeyPairSync, randomBytes, randomInt, randomUUID, sign, createPrivateKey } from 'node:crypto'
|
|
32
|
+
import { homedir } from 'node:os'
|
|
33
|
+
import { join } from 'node:path'
|
|
34
|
+
|
|
35
|
+
const CLIENT_ID_SOLO_LITE = 'en1oxy7wnw8j9n' // SOLO Lite 分支默认(traework-cn.md §3)
|
|
36
|
+
const CLIENT_ID_TRAE = 'ono9krqynydwx5' // 普通 TRAE 分支默认
|
|
37
|
+
const PLATFORM_CODE = 'SOLO_PC'
|
|
38
|
+
const IDE_VERSION = '0.1.52'
|
|
39
|
+
const PLUGIN_VERSION = '2.3.73734'
|
|
40
|
+
const LOGIN_TIMEOUT_MS = 10 * 60 * 1000
|
|
41
|
+
|
|
42
|
+
/** 秒级时间戳与毫秒级并存于线上响应(doc 示例是秒),统一归一到毫秒。 */
|
|
43
|
+
function normalizeExpiry(value) {
|
|
44
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return null
|
|
45
|
+
return value > 1e12 ? value : value * 1000
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function b64url(buf) {
|
|
49
|
+
return Buffer.from(buf).toString('base64url')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** HTML 转义(审计 [10]/[15]/[21]/[24]):登录回调页以 text/html 应答,所有
|
|
53
|
+
* 来自 URL query / 上游响应的插值必须先转义,杜绝回环页上的脚本注入。 */
|
|
54
|
+
function esc(value) {
|
|
55
|
+
return String(value)
|
|
56
|
+
.replaceAll('&', '&')
|
|
57
|
+
.replaceAll('<', '<')
|
|
58
|
+
.replaceAll('>', '>')
|
|
59
|
+
.replaceAll('"', '"')
|
|
60
|
+
.replaceAll("'", ''')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 从设备身份生成出站 DeviceInfo(公钥用 SPKI base64,私钥永不出存储)。 */
|
|
64
|
+
function deviceInfoFrom(device) {
|
|
65
|
+
return {
|
|
66
|
+
DeviceID: device.deviceId,
|
|
67
|
+
MachineID: device.machineId,
|
|
68
|
+
PlatformCode: PLATFORM_CODE,
|
|
69
|
+
DeviceType: 'PC',
|
|
70
|
+
DeviceName: device.deviceName,
|
|
71
|
+
DeviceModel: device.deviceModel,
|
|
72
|
+
ClientVersion: IDE_VERSION,
|
|
73
|
+
DevicePublicKey: device.devicePublicKey,
|
|
74
|
+
DeviceBrand: device.deviceBrand,
|
|
75
|
+
DeviceCPU: device.deviceCpu,
|
|
76
|
+
OSInfo: device.osInfo,
|
|
77
|
+
OSVersion: device.osVersion,
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 解析 ExchangeToken 响应(成功 Result / 失败 ResponseMetadata.Error)。 */
|
|
82
|
+
function parseExchangeResult(body) {
|
|
83
|
+
if (!body || typeof body !== 'object') return { error: '响应不是 JSON 对象' }
|
|
84
|
+
if (body.Result?.Token) {
|
|
85
|
+
const r = body.Result
|
|
86
|
+
return {
|
|
87
|
+
token: r.Token,
|
|
88
|
+
refreshToken: r.RefreshToken ?? null,
|
|
89
|
+
expiresAt: normalizeExpiry(r.TokenExpireAt),
|
|
90
|
+
refreshExpiresAt: normalizeExpiry(r.RefreshExpireAt),
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const err = body.ResponseMetadata?.Error
|
|
94
|
+
if (err) return { error: `ExchangeToken ${err.Code}:${err.Message}` }
|
|
95
|
+
return { error: `ExchangeToken 响应缺少 Result.Token` }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* @param {{
|
|
100
|
+
* readAuth: () => object, writeAuth: (v: object) => void,
|
|
101
|
+
* }} deps 令牌存储 IO(组合根绑定 ~/.dsh/trae-plugin-auth.json;令牌与设备
|
|
102
|
+
* 私钥永不回传浏览器——oauthStatus() 只出视图字段)。
|
|
103
|
+
*/
|
|
104
|
+
export function createTraeOAuth({ readAuth, writeAuth }) {
|
|
105
|
+
let refreshInFlight = null
|
|
106
|
+
// 令牌存储代际号:logout 令其失效。refresh 落盘是读-改-写(writeAuth
|
|
107
|
+
// ({...readAuth(), auth: next}))——与 logout 的整体覆写竞态时,在飞刷新
|
|
108
|
+
// 会把已登出的令牌写回(登出失效)。落盘前比对代际,过期即丢弃结果。
|
|
109
|
+
let storeGeneration = 0
|
|
110
|
+
const pending = { active: false, authUrl: '', error: '' , closeServer: null }
|
|
111
|
+
|
|
112
|
+
/** 首次使用时生成并持久化设备身份 + P-256 密钥对(幂等)。 */
|
|
113
|
+
function ensureDevice(store) {
|
|
114
|
+
if (store.device?.privateKeyPem && store.device?.clientId) return store.device
|
|
115
|
+
const { publicKey, privateKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' })
|
|
116
|
+
const device = {
|
|
117
|
+
clientId: CLIENT_ID_SOLO_LITE,
|
|
118
|
+
// 设备标识形态对齐官方日志:十进制设备 id + 64 hex 机器 id。16 位、
|
|
119
|
+
// 首位非零——node:crypto 随机(审计 [16]:Math.random 可预测,凭据栈禁用)。
|
|
120
|
+
deviceId: Array.from({ length: 16 }, (_, i) => randomInt(i === 0 ? 1 : 0, 10)).join(''),
|
|
121
|
+
machineId: randomBytes(32).toString('hex'),
|
|
122
|
+
deviceName: 'DESKTOP-' + randomBytes(4).toString('hex').toUpperCase(),
|
|
123
|
+
deviceModel: 'PC',
|
|
124
|
+
deviceBrand: 'volcengine',
|
|
125
|
+
deviceCpu: 'AMD64',
|
|
126
|
+
osInfo: 'Windows',
|
|
127
|
+
osVersion: '10.0.26100',
|
|
128
|
+
devicePublicKey: publicKey.export({ type: 'spki', format: 'der' }).toString('base64'),
|
|
129
|
+
privateKeyPem: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(),
|
|
130
|
+
// DeviceProof 签名编码:DER(node:crypto 原生);若线上校验要求 IEEE-P1363
|
|
131
|
+
// 裸签名,改为 'raw'(DER→r||s 转换在 signDeviceProof 内)。
|
|
132
|
+
signatureFormat: 'der',
|
|
133
|
+
}
|
|
134
|
+
const next = { ...store, device }
|
|
135
|
+
writeAuth(next)
|
|
136
|
+
return device
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function derToRaw(der) {
|
|
140
|
+
// SEQUENCE(r INTEGER, s INTEGER) → 32+32 大端拼接
|
|
141
|
+
let offset = 2
|
|
142
|
+
if (der[1] & 0x80) offset += der[1] & 0x7f
|
|
143
|
+
const readInt = () => {
|
|
144
|
+
const len = der[offset + 1]
|
|
145
|
+
offset += 2
|
|
146
|
+
const bytes = der.subarray(offset, offset + len)
|
|
147
|
+
offset += len
|
|
148
|
+
// 去掉前导 0x00,左填充到 32 字节
|
|
149
|
+
const stripped = bytes[0] === 0 ? bytes.subarray(1) : bytes
|
|
150
|
+
return Buffer.concat([Buffer.alloc(32 - stripped.length), stripped])
|
|
151
|
+
}
|
|
152
|
+
return Buffer.concat([readInt(), readInt()])
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function signDeviceProof(device, refreshToken, timestamp, nonce) {
|
|
156
|
+
const stringToSign = ['POST', '/trae/api/v3/oauth/ExchangeToken', device.clientId, refreshToken, String(timestamp), nonce].join('\n')
|
|
157
|
+
const key = createPrivateKey(device.privateKeyPem)
|
|
158
|
+
const der = sign('sha256', Buffer.from(stringToSign, 'utf8'), key)
|
|
159
|
+
const sig = device.signatureFormat === 'raw' ? derToRaw(der) : der
|
|
160
|
+
return sig.toString('base64')
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** RefreshToken 模式换新 token(单飞;失败返回 undefined 不抛)。 */
|
|
164
|
+
async function refreshOAuth(s, auth) {
|
|
165
|
+
if (refreshInFlight) return refreshInFlight
|
|
166
|
+
const generation = storeGeneration
|
|
167
|
+
refreshInFlight = (async () => {
|
|
168
|
+
try {
|
|
169
|
+
const store = readAuth()
|
|
170
|
+
const device = ensureDevice(store)
|
|
171
|
+
const timestamp = Math.floor(Date.now() / 1000)
|
|
172
|
+
const nonce = randomBytes(16).toString('hex')
|
|
173
|
+
const body = {
|
|
174
|
+
ClientID: device.clientId,
|
|
175
|
+
ClientSecret: '',
|
|
176
|
+
RefreshToken: auth.refreshToken,
|
|
177
|
+
DeviceInfo: deviceInfoFrom(device),
|
|
178
|
+
DeviceProof: {
|
|
179
|
+
Signature: signDeviceProof(device, auth.refreshToken, timestamp, nonce),
|
|
180
|
+
Timestamp: timestamp,
|
|
181
|
+
Nonce: nonce,
|
|
182
|
+
},
|
|
183
|
+
IDEVersion: IDE_VERSION,
|
|
184
|
+
}
|
|
185
|
+
const res = await fetch(`${s.traeAuthBaseURL}/trae/api/v3/oauth/ExchangeToken`, {
|
|
186
|
+
method: 'POST',
|
|
187
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
188
|
+
body: JSON.stringify(body),
|
|
189
|
+
})
|
|
190
|
+
const parsed = parseExchangeResult(await res.json().catch(() => null))
|
|
191
|
+
if (parsed.error) return undefined
|
|
192
|
+
// logout 竞态守卫:代际在飞期间已变(用户点了退出)→ 丢弃刷新结果
|
|
193
|
+
if (generation !== storeGeneration) return undefined
|
|
194
|
+
const next = {
|
|
195
|
+
accessToken: parsed.token,
|
|
196
|
+
expiresAt: parsed.expiresAt ?? (Date.now() + 3600_000),
|
|
197
|
+
refreshToken: parsed.refreshToken ?? auth.refreshToken,
|
|
198
|
+
refreshExpiresAt: parsed.refreshExpiresAt ?? auth.refreshExpiresAt ?? null,
|
|
199
|
+
}
|
|
200
|
+
writeAuth({ ...readAuth(), auth: next })
|
|
201
|
+
return next
|
|
202
|
+
} catch {
|
|
203
|
+
return undefined
|
|
204
|
+
} finally {
|
|
205
|
+
refreshInFlight = null
|
|
206
|
+
}
|
|
207
|
+
})()
|
|
208
|
+
return refreshInFlight
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** 每次出站共用的凭据分支:临期自动刷新,双头形态(Cloud-IDE-JWT + x-cloudide-token)。
|
|
212
|
+
* expiresAt 缺失视为临期而非永不过期——拿着未知新鲜度的凭据出门,上游 401
|
|
213
|
+
* 远不如本地一次清晰刷新(有 refreshToken 时)。 */
|
|
214
|
+
async function resolveTraeCredential(s) {
|
|
215
|
+
const store = readAuth()
|
|
216
|
+
const auth = store.auth
|
|
217
|
+
if (!auth?.accessToken) return null
|
|
218
|
+
let current = auth
|
|
219
|
+
const stale = !auth.expiresAt || auth.expiresAt - Date.now() < 60_000
|
|
220
|
+
if (auth.refreshToken && stale) {
|
|
221
|
+
const refreshed = await refreshOAuth(s, auth)
|
|
222
|
+
if (!refreshed) return null
|
|
223
|
+
current = refreshed
|
|
224
|
+
}
|
|
225
|
+
return {
|
|
226
|
+
authorization: `Cloud-IDE-JWT ${current.accessToken}`,
|
|
227
|
+
headers: { 'x-cloudide-token': current.accessToken, 'X-User-Region': 'CN' },
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* 启动登录:开回环回调服务 → 返回授权页 URL。浏览器 302 回 /authorize 后,
|
|
233
|
+
* 回调内完成 AuthCode→Token 交换与账号信息拉取,然后关闭服务。
|
|
234
|
+
*/
|
|
235
|
+
async function startOAuth(s) {
|
|
236
|
+
if (pending.active) return { started: true, authUrl: pending.authUrl }
|
|
237
|
+
|
|
238
|
+
// 基址前置校验(开回环服务之前):traeLoginHost 保存侧已过 validateBaseURL
|
|
239
|
+
// (组合根),但手改设置文件仍可能塞进 javascript: 之类非法值——在此响亮
|
|
240
|
+
// 失败,而不是拼出可执行授权页 URL 直送浏览器导航。与 codebuddy 的
|
|
241
|
+
// assertSafeAuthUrl 不同:本 URL 的 host 来自本地用户设置而非上游响应,
|
|
242
|
+
// 故只做基址校验、不做站点族校验。校验置于 listen 之前:失败时不遗留
|
|
243
|
+
// 回环监听句柄。
|
|
244
|
+
let loginBase
|
|
245
|
+
try {
|
|
246
|
+
loginBase = new URL(s.traeLoginHost)
|
|
247
|
+
} catch {
|
|
248
|
+
throw new Error('traeLoginHost 不是合法 URL')
|
|
249
|
+
}
|
|
250
|
+
if (loginBase.protocol !== 'http:' && loginBase.protocol !== 'https:') {
|
|
251
|
+
throw new Error('traeLoginHost 必须使用 http 或 https')
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const store = readAuth()
|
|
255
|
+
const device = ensureDevice(store)
|
|
256
|
+
const codeVerifier = b64url(randomBytes(48))
|
|
257
|
+
const codeChallenge = b64url(createHash('sha256').update(codeVerifier).digest())
|
|
258
|
+
const traceId = randomUUID()
|
|
259
|
+
|
|
260
|
+
const server = createServer((req, res) => { handleCallback(req, res) })
|
|
261
|
+
await new Promise((resolve, reject) => {
|
|
262
|
+
server.once('error', reject)
|
|
263
|
+
server.listen(0, '127.0.0.1', () => resolve())
|
|
264
|
+
})
|
|
265
|
+
const port = server.address().port
|
|
266
|
+
const callbackUrl = `http://127.0.0.1:${port}/authorize`
|
|
267
|
+
|
|
268
|
+
const params = new URLSearchParams({
|
|
269
|
+
login_version: '1',
|
|
270
|
+
auth_from: 'solo',
|
|
271
|
+
login_channel: 'native_ide',
|
|
272
|
+
plugin_version: PLUGIN_VERSION,
|
|
273
|
+
auth_type: 'local',
|
|
274
|
+
client_id: device.clientId,
|
|
275
|
+
redirect: '0',
|
|
276
|
+
login_trace_id: traceId,
|
|
277
|
+
auth_callback_url: callbackUrl,
|
|
278
|
+
machine_id: device.machineId,
|
|
279
|
+
device_id: device.deviceId,
|
|
280
|
+
x_device_id: device.deviceId,
|
|
281
|
+
x_machine_id: device.machineId,
|
|
282
|
+
x_device_brand: device.deviceBrand,
|
|
283
|
+
x_device_type: device.osInfo,
|
|
284
|
+
x_os_version: device.osVersion,
|
|
285
|
+
x_app_version: IDE_VERSION,
|
|
286
|
+
x_app_type: 'stable',
|
|
287
|
+
code_challenge: codeChallenge,
|
|
288
|
+
code_challenge_method: 'S256',
|
|
289
|
+
hide_saas_login: 'true',
|
|
290
|
+
})
|
|
291
|
+
// URL 解析构造:合法 http(s) 基址 + 路径绝对引用恒可解析;基址带尾路径或
|
|
292
|
+
// 尾斜杠时以 /authorization 为准(旧字符串拼接会产出 `//authorization`
|
|
293
|
+
// 双斜杠或把基址路径串进授权页路径)。
|
|
294
|
+
const authUrl = new URL(`/authorization?${params.toString()}`, loginBase).toString()
|
|
295
|
+
|
|
296
|
+
pending.active = true
|
|
297
|
+
pending.authUrl = authUrl
|
|
298
|
+
pending.error = ''
|
|
299
|
+
pending.closeServer = () => {
|
|
300
|
+
try { server.closeAllConnections?.(); server.close() } catch { /* best-effort */ }
|
|
301
|
+
pending.closeServer = null
|
|
302
|
+
}
|
|
303
|
+
const timer = setTimeout(() => {
|
|
304
|
+
if (pending.active) pending.error = '登录超时(10 分钟未完成回调)'
|
|
305
|
+
pending.active = false
|
|
306
|
+
pending.closeServer?.()
|
|
307
|
+
}, LOGIN_TIMEOUT_MS)
|
|
308
|
+
timer.unref?.()
|
|
309
|
+
|
|
310
|
+
/** 回调处理:解析 query → 交换 token → 拉账号 → 落盘 → 应答成功页。 */
|
|
311
|
+
async function handleCallback(req, res) {
|
|
312
|
+
const finish = (html, logErr) => {
|
|
313
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
|
|
314
|
+
res.end(html)
|
|
315
|
+
clearTimeout(timer)
|
|
316
|
+
pending.active = false
|
|
317
|
+
pending.closeServer?.()
|
|
318
|
+
if (logErr) pending.error = logErr
|
|
319
|
+
}
|
|
320
|
+
try {
|
|
321
|
+
const url = new URL(req.url, callbackUrl)
|
|
322
|
+
if (url.pathname !== '/authorize') {
|
|
323
|
+
finish('<h3>404</h3>')
|
|
324
|
+
return
|
|
325
|
+
}
|
|
326
|
+
const q = url.searchParams
|
|
327
|
+
const errCode = q.get('error_code')
|
|
328
|
+
if (errCode) {
|
|
329
|
+
const errMsg = q.get('error_msg') ?? ''
|
|
330
|
+
// query 是外部输入:进 text/html 前一律转义(审计 [10]/[15]/[21]/[24])。
|
|
331
|
+
// pending.error 是 JSON 状态视图字段(设置卡按 React 文本节点渲染),
|
|
332
|
+
// 保持原文不双转义。
|
|
333
|
+
finish(`<h3>登录失败</h3><p>${esc(errCode)} ${esc(errMsg)}</p>`,
|
|
334
|
+
`登录失败:${errCode} ${errMsg}`)
|
|
335
|
+
return
|
|
336
|
+
}
|
|
337
|
+
let authCode = null
|
|
338
|
+
try {
|
|
339
|
+
const info = JSON.parse(q.get('authCodeInfo') ?? 'null')
|
|
340
|
+
authCode = info?.AuthCode ?? info?.authCode ?? null
|
|
341
|
+
} catch { /* not JSON */ }
|
|
342
|
+
if (!authCode) {
|
|
343
|
+
finish('<h3>回调缺少 AuthCode</h3>', '回调缺少 AuthCode')
|
|
344
|
+
return
|
|
345
|
+
}
|
|
346
|
+
const body = {
|
|
347
|
+
ClientID: device.clientId,
|
|
348
|
+
AuthCode: authCode,
|
|
349
|
+
CodeVerifier: codeVerifier,
|
|
350
|
+
DeviceInfo: deviceInfoFrom(device),
|
|
351
|
+
IDEVersion: IDE_VERSION,
|
|
352
|
+
}
|
|
353
|
+
const res2 = await fetch(`${s.traeAuthBaseURL}/trae/api/v3/oauth/ExchangeToken`, {
|
|
354
|
+
method: 'POST',
|
|
355
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
356
|
+
body: JSON.stringify(body),
|
|
357
|
+
})
|
|
358
|
+
const parsed = parseExchangeResult(await res2.json().catch(() => null))
|
|
359
|
+
if (parsed.error) {
|
|
360
|
+
finish(`<h3>Token 交换失败</h3><p>${esc(parsed.error)}</p>`, parsed.error)
|
|
361
|
+
return
|
|
362
|
+
}
|
|
363
|
+
const auth = {
|
|
364
|
+
accessToken: parsed.token,
|
|
365
|
+
expiresAt: parsed.expiresAt ?? (Date.now() + 3600_000),
|
|
366
|
+
refreshToken: parsed.refreshToken,
|
|
367
|
+
refreshExpiresAt: parsed.refreshExpiresAt ?? null,
|
|
368
|
+
}
|
|
369
|
+
// 账号信息尽力而为(GetUserInfo,双头形态)。
|
|
370
|
+
let account = {}
|
|
371
|
+
try {
|
|
372
|
+
const acc = await fetch(`${s.traeAuthBaseURL}/cloudide/api/v3/trae/GetUserInfo`, {
|
|
373
|
+
method: 'POST',
|
|
374
|
+
headers: {
|
|
375
|
+
'Content-Type': 'application/json',
|
|
376
|
+
Authorization: `Cloud-IDE-JWT ${auth.accessToken}`,
|
|
377
|
+
'x-cloudide-token': auth.accessToken,
|
|
378
|
+
'X-User-Region': 'CN',
|
|
379
|
+
},
|
|
380
|
+
})
|
|
381
|
+
const accBody = await acc.json().catch(() => null)
|
|
382
|
+
const r = accBody?.Result
|
|
383
|
+
if (r && typeof r === 'object') {
|
|
384
|
+
account = {
|
|
385
|
+
// 2026-08-24 实测:GetUserInfo 的昵称字段是 ScreenName。
|
|
386
|
+
nickname: r.ScreenName ?? r.Name ?? r.Nickname ?? r.UserName ?? r.DisplayName ?? null,
|
|
387
|
+
uid: r.UserId ?? r.UserID ?? r.Uid ?? null,
|
|
388
|
+
email: r.Email ?? null,
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
} catch { /* best-effort */ }
|
|
392
|
+
writeAuth({ ...readAuth(), auth, account })
|
|
393
|
+
finish('<h3>登录成功</h3><p>可以回到 dsh 设置卡继续。</p>')
|
|
394
|
+
} catch (err) {
|
|
395
|
+
const errMsg = String(err?.message ?? err)
|
|
396
|
+
finish(`<h3>登录处理异常</h3><p>${esc(errMsg)}</p>`, errMsg)
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return { started: true, authUrl }
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** 视图(令牌/私钥永不出宿主)。 */
|
|
404
|
+
function oauthStatus() {
|
|
405
|
+
const store = readAuth()
|
|
406
|
+
const auth = store.auth
|
|
407
|
+
const acct = store.account ?? {}
|
|
408
|
+
return {
|
|
409
|
+
pending: pending.active,
|
|
410
|
+
authUrl: pending.active ? pending.authUrl : '',
|
|
411
|
+
error: pending.error,
|
|
412
|
+
signedIn: Boolean(auth?.accessToken),
|
|
413
|
+
account: acct.nickname || acct.uid ? { nickname: acct.nickname, uid: acct.uid } : null,
|
|
414
|
+
accessTokenExpiresAt: auth?.expiresAt ?? null,
|
|
415
|
+
refreshExpiresAt: auth?.refreshExpiresAt ?? null,
|
|
416
|
+
signatureFormat: store.device?.signatureFormat ?? null,
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function logout() {
|
|
421
|
+
if (pending.closeServer) { pending.closeServer(); }
|
|
422
|
+
pending.active = false
|
|
423
|
+
pending.error = ''
|
|
424
|
+
storeGeneration++ // 终止在飞 refresh:其落盘前的代际校验必然失败
|
|
425
|
+
// 设备身份保留(clientId/密钥对与该账号的设备注册绑定,重登录可复用;
|
|
426
|
+
// 只清令牌与账号)。
|
|
427
|
+
const store = readAuth()
|
|
428
|
+
writeAuth({ device: store.device })
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** 供联调探测切换签名编码(der/raw)。 */
|
|
432
|
+
function setSignatureFormat(format) {
|
|
433
|
+
if (format !== 'der' && format !== 'raw') throw new Error('signatureFormat 只支持 der|raw')
|
|
434
|
+
const store = readAuth()
|
|
435
|
+
if (store.device) {
|
|
436
|
+
store.device.signatureFormat = format
|
|
437
|
+
writeAuth(store)
|
|
438
|
+
}
|
|
439
|
+
return format
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
return { resolveTraeCredential, startOAuth, oauthStatus, logout, setSignatureFormat }
|
|
443
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* providers/trae/quota.js — TraeWork CN 双额度池余额只读快照(设置卡用)。
|
|
3
|
+
*
|
|
4
|
+
* 端点:POST {traeAuthBaseURL}/trae/api/v2/pay/ide_user_ent_usage。
|
|
5
|
+
* 实测口径(docs/probes/trae-credits-2026-08-24T07-16-51.json):
|
|
6
|
+
* - req_source=0 一次调用即返回全部资源包(req_source=1 只回 endpoint=0 子集),
|
|
7
|
+
* 故只发一发;按 entitlement_base_info.available_endpoint 分池
|
|
8
|
+
* (0=IDE 池/raw 面消耗、1=work 池/remote 面消耗,docs/reverse/trae-cloud-api.md)。
|
|
9
|
+
* - 头组 = traeOutboundHeaders(设备指纹与登录上报一致)+ 凭证三头
|
|
10
|
+
* (Authorization: Cloud-IDE-JWT / X-Cloudide-Token / x-ide-token)。
|
|
11
|
+
* 成本纪律:60s memoize(与 codebuddy quotaSnapshot 同口径);永不 throw——
|
|
12
|
+
* 失败回 { error },调用方(设置卡)据此显示「—」,绝不编造数值。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { traeOutboundHeaders } from './gateway.js'
|
|
16
|
+
|
|
17
|
+
export function createTraeQuota({ settings, readAuth, oauth }) {
|
|
18
|
+
let cache = { at: 0, value: null }
|
|
19
|
+
// 单飞:并发 snapshot 共享同一在飞请求,不重复打上游。
|
|
20
|
+
let inFlight = null
|
|
21
|
+
|
|
22
|
+
async function fetchSnapshot() {
|
|
23
|
+
const s = settings()
|
|
24
|
+
const cred = await oauth.resolveTraeCredential(s)
|
|
25
|
+
if (!cred) return { error: '未登录', fetchedAt: Date.now() }
|
|
26
|
+
const token = String(cred.authorization).replace(/^Cloud-IDE-JWT\s+/, '')
|
|
27
|
+
const store = readAuth()
|
|
28
|
+
const uid = store?.account?.uid
|
|
29
|
+
const headers = {
|
|
30
|
+
...traeOutboundHeaders(store?.device ?? null, uid, crypto.randomUUID()),
|
|
31
|
+
Authorization: `Cloud-IDE-JWT ${token}`,
|
|
32
|
+
'X-Cloudide-Token': token,
|
|
33
|
+
'x-ide-token': token,
|
|
34
|
+
}
|
|
35
|
+
const res = await fetch(`${s.traeAuthBaseURL}/trae/api/v2/pay/ide_user_ent_usage`, {
|
|
36
|
+
method: 'POST',
|
|
37
|
+
headers,
|
|
38
|
+
body: JSON.stringify({ require_usage: true, req_source: 0 }),
|
|
39
|
+
signal: AbortSignal.timeout(15_000),
|
|
40
|
+
})
|
|
41
|
+
const body = await res.json().catch(() => null)
|
|
42
|
+
const packs = body?.user_entitlement_pack_list
|
|
43
|
+
if (!res.ok || !Array.isArray(packs)) {
|
|
44
|
+
return { error: `code ${body?.code ?? `http ${res.status}`}`, fetchedAt: Date.now() }
|
|
45
|
+
}
|
|
46
|
+
const sum = { ide: { limit: 0, used: 0 }, work: { limit: 0, used: 0 } }
|
|
47
|
+
for (const p of packs) {
|
|
48
|
+
const ep = p?.entitlement_base_info?.available_endpoint
|
|
49
|
+
const pool = ep === 1 ? sum.work : ep === 0 ? sum.ide : null
|
|
50
|
+
if (!pool) continue
|
|
51
|
+
const limit = p?.entitlement_base_info?.quota?.credits_limit
|
|
52
|
+
const used = p?.usage?.credits_amount
|
|
53
|
+
if (typeof limit === 'number') pool.limit += limit
|
|
54
|
+
if (typeof used === 'number') pool.used += used
|
|
55
|
+
}
|
|
56
|
+
const round = (n) => Math.round(n * 100) / 100
|
|
57
|
+
const pack = (pool) => ({ limit: round(pool.limit), used: round(pool.used), remain: round(pool.limit - pool.used) })
|
|
58
|
+
return { pools: { ide: pack(sum.ide), work: pack(sum.work) }, fetchedAt: Date.now() }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function snapshot() {
|
|
62
|
+
if (cache.value && Date.now() - cache.at < 60_000) return Promise.resolve(cache.value)
|
|
63
|
+
if (inFlight) return inFlight
|
|
64
|
+
inFlight = fetchSnapshot()
|
|
65
|
+
.catch((err) => ({ error: err?.message ?? String(err), fetchedAt: Date.now() }))
|
|
66
|
+
.then((value) => {
|
|
67
|
+
cache = { at: Date.now(), value }
|
|
68
|
+
return value
|
|
69
|
+
})
|
|
70
|
+
.finally(() => { inFlight = null })
|
|
71
|
+
return inFlight
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return { snapshot }
|
|
75
|
+
}
|