Files
hermes-agent/apps/desktop/electron/relay-account.ts
T
m4 a45c35ce54 feat(desktop): DB-T4b 绑定/解绑 IPC + U-6 全清(§21 U-1/U-5/U-6)
- binding-status:只读插件 state.json 非敏感二字段,state 损坏 fail-closed
- bind-machine:session_token 免口令绑定(U-1),401 接入 refresh-retry 链,
  binding_pending 透传确认码;email/password 不出 main
- unbind-machine:尽力服务端撤销 → 插件 purge → 删所有会话;purge 失败
  结构化返回且不动本地会话(不留半清假象);登出≠解绑硬断言
2026-09-19 18:00:34 +08:00

318 lines
9.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* relay-account.ts — Hermes 用户账号会话的 relay 客户端(§21 U-1/U-2)。
*
* 只讲 /api/v2/auth/* 与 /api/v2/me。无 electron import,fetch 注入,
* 与 freemodel2api.ts 同一可测纪律。
*
* 口令只穿过 login() 这一次;refresh/logout/me 全部走令牌。
* 用户会话与机器绑定(H/R)是两套独立凭据,本模块绝不碰绑定。
*/
export interface FetchLike {
(url: string, init?: any): Promise<{ ok: boolean; status: number; json: () => Promise<any>; text?: () => Promise<string> }>
}
export interface RelayAccountSession {
site: string
accessToken: string
refreshToken: string
sessionId: string
}
export interface RelayAccountProfile {
email: string
username: string
nickname: string
phone: string
planId: string
periodEnd: string
dataAsOf: string
}
export class RelayAccountError extends Error {
readonly code: string
readonly status: number
constructor(code: string, status: number) {
super(code)
this.code = code
this.status = status
}
}
/**
* 与插件 canonicalize_relay_origin 同一纪律:https 强制,
* http 只放行 loopback(开发用);去 query/fragment/尾斜杠。
*/
export function canonicalizeRelaySite(raw: string): string {
const value = String(raw ?? '').trim()
if (!value) {throw new RelayAccountError('invalid_site', 0)}
let parsed: URL
try {
parsed = new URL(value)
} catch {
throw new RelayAccountError('invalid_site', 0)
}
const loopback = parsed.hostname === '127.0.0.1' || parsed.hostname === 'localhost' || parsed.hostname === '::1'
if (parsed.protocol !== 'https:' && !(parsed.protocol === 'http:' && loopback)) {
throw new RelayAccountError('invalid_site', 0)
}
const path = parsed.pathname.replace(/\/+$/, '')
return `${parsed.protocol}//${parsed.host}${path}`
}
async function readErrorCode(response: { status: number; text?: () => Promise<string> }): Promise<string> {
try {
const body = (await response.text?.())?.trim() ?? ''
// 服务端错误体就是小写错误码本身;任何不像码的(HTML、堆栈)一律归纳。
if (/^[a-z][a-z0-9_]{1,63}$/.test(body)) {return body}
} catch {
// 读不出体也归纳
}
return 'server'
}
async function request(
fetcher: FetchLike,
url: string,
init: any
): Promise<any> {
let response
try {
response = await fetcher(url, init)
} catch {
throw new RelayAccountError('network', 0)
}
if (!response.ok) {
throw new RelayAccountError(await readErrorCode(response), response.status)
}
return response.json()
}
function parseTokenPayload(payload: any, site: string): RelayAccountSession {
const accessToken = typeof payload?.access_token === 'string' ? payload.access_token : ''
const refreshToken = typeof payload?.refresh_token === 'string' ? payload.refresh_token : ''
const sessionId = typeof payload?.session_id === 'string' ? payload.session_id : ''
if (!accessToken || !refreshToken || !sessionId) {
// 响应形状不符绝不猜:宁可报 server 也不存半个会话。
throw new RelayAccountError('server', 200)
}
return { site, accessToken, refreshToken, sessionId }
}
export async function relayAccountLogin(
site: string,
email: string,
password: string,
clientId: string,
fetcher: FetchLike
): Promise<RelayAccountSession> {
const payload = await request(fetcher, `${site}/api/v2/auth/login`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email, password, client_id: clientId })
})
return parseTokenPayload(payload, site)
}
export async function relayAccountRefresh(
session: RelayAccountSession,
fetcher: FetchLike
): Promise<RelayAccountSession> {
const payload = await request(fetcher, `${session.site}/api/v2/auth/refresh`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ session_id: session.sessionId, refresh_token: session.refreshToken })
})
return parseTokenPayload(payload, session.site)
}
/**
* 尽力通知服务端吊销;access 已过期/网络不通都不阻塞本地清零(U-1:
* 登出只动用户会话,反正机器通道本来也不由它供能)。
*/
export async function relayAccountLogout(session: RelayAccountSession, fetcher: FetchLike): Promise<void> {
try {
await fetcher(`${session.site}/api/v2/auth/logout`, {
method: 'POST',
headers: { authorization: `Bearer ${session.accessToken}` }
})
} catch {
// 本地照常清零
}
}
export function parseRelayMe(payload: any): RelayAccountProfile {
const account = payload?.account ?? {}
return {
email: typeof account.email === 'string' ? account.email : '',
username: typeof account.username === 'string' ? account.username : '',
nickname: typeof account.nickname === 'string' ? account.nickname : '',
phone: typeof account.phone === 'string' ? account.phone : '',
planId: typeof account.plan_id === 'string' ? account.plan_id : '',
periodEnd: typeof account.period_end === 'string' ? account.period_end : '',
dataAsOf: typeof payload?.data_as_of === 'string' ? payload.data_as_of : ''
}
}
export async function relayAccountFetchMe(
site: string,
accessToken: string,
fetcher: FetchLike
): Promise<RelayAccountProfile> {
const payload = await request(fetcher, `${site}/api/v2/me`, {
headers: { authorization: `Bearer ${accessToken}` }
})
return parseRelayMe(payload)
}
/** 本人可改字段(服务端 PROFILE_FIELDS 同口径;邮箱永不可改)。 */
export interface RelayAccountProfilePatch {
username?: string | null
nickname?: string | null
phone?: string | null
}
/**
* PATCH /api/v2/me。响应的 profile 只含 email/username/nickname/phone/status,
* 不含 plan/periodEnd/dataAsOf——调用方负责并入已有快照,不得以偏概全。
*/
export async function relayAccountUpdateProfile(
site: string,
accessToken: string,
patch: RelayAccountProfilePatch,
fetcher: FetchLike
): Promise<Pick<RelayAccountProfile, 'email' | 'username' | 'nickname' | 'phone'>> {
const payload = await request(fetcher, `${site}/api/v2/me`, {
method: 'PATCH',
headers: { authorization: `Bearer ${accessToken}`, 'content-type': 'application/json' },
body: JSON.stringify(patch)
})
const profile = payload?.profile ?? {}
return {
email: typeof profile.email === 'string' ? profile.email : '',
username: typeof profile.username === 'string' ? profile.username : '',
nickname: typeof profile.nickname === 'string' ? profile.nickname : '',
phone: typeof profile.phone === 'string' ? profile.phone : ''
}
}
/** 注册第一步:投递验证邮件。202 与「邮箱已被注册」同形(防枚举)。 */
export async function relayAccountRegisterStart(
site: string,
email: string,
password: string,
fetcher: FetchLike
): Promise<void> {
await request(fetcher, `${site}/api/v2/registration/start`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email, password })
})
}
export async function relayAccountRegisterResend(site: string, email: string, fetcher: FetchLike): Promise<void> {
await request(fetcher, `${site}/api/v2/registration/resend`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email })
})
}
/** 找回第一步:投递重置码邮件。202 同样防枚举。 */
export async function relayAccountResetRequest(site: string, email: string, fetcher: FetchLike): Promise<void> {
await request(fetcher, `${site}/api/v2/auth/password-reset/request`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email })
})
}
export async function relayAccountResetConfirm(
site: string,
email: string,
code: string,
password: string,
fetcher: FetchLike
): Promise<void> {
await request(fetcher, `${site}/api/v2/auth/password-reset/confirm`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email, code, password })
})
}
/** U-5 所有权校验:列出本会话用户的 installation id 集合。 */
export async function relayAccountListInstallationIds(
site: string,
accessToken: string,
fetcher: FetchLike
): Promise<string[]> {
const payload = await request(fetcher, `${site}/api/v2/installations`, {
headers: { authorization: `Bearer ${accessToken}` }
})
const items = Array.isArray(payload?.items) ? payload.items : []
return items.map((item: any) => (typeof item?.id === 'string' ? item.id : '')).filter((id: string) => id !== '')
}
/** U-5 锁态提示:查一台已绑定机器的绑定者邮箱;404 时返回 null。 */
export async function relayAccountBindingOwner(
site: string,
accessToken: string,
installationId: string,
fetcher: FetchLike
): Promise<string | null> {
try {
const payload = await request(fetcher, `${site}/api/v2/agent/binding/owner?installation_id=${encodeURIComponent(installationId)}`, {
headers: { authorization: `Bearer ${accessToken}` }
})
return typeof payload?.email === 'string' ? payload.email : null
} catch (error) {
if (error instanceof RelayAccountError && error.status === 404) {return null}
throw error
}
}
/**
* U-6 解绑前置:用户会话撤销自己的 installation(202 受理即成功)。
* 404 not_visible = 已不在名下,幂等放行;其余错误照常抛。
*/
export async function relayAccountDeleteInstallation(
site: string,
accessToken: string,
installationId: string,
fetcher: FetchLike
): Promise<void> {
try {
await request(fetcher, `${site}/api/v2/installations/${encodeURIComponent(installationId)}`, {
method: 'DELETE',
headers: { authorization: `Bearer ${accessToken}` }
})
} catch (error) {
if (error instanceof RelayAccountError && error.status === 404) {return}
throw error
}
}