Files
hermes-agent/apps/desktop/electron/user-account-ipc.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

435 lines
14 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.
/**
* user-account-ipc.ts — 用户账号会话的 IPC 边界(§21 DB-T1/DB-T2,U-1/U-3/U-5)。
*
* token 只活在 main 进程(内存 + safeStorage 落盘),renderer 永远拿不到
* access/refresh token——login 之后 renderer 只能拿到 profile 与状态。
*
* 401 反应式刷新:任何带令牌调用 401 → 用 refresh token 续一次重试;
* refresh 也被拒(session_inactive / refresh_reuse_or_invalid)= 唯一的
* 硬会话终点(U-3)→ 本地清零并上报 sessionExpired,由 renderer 落登录页。
*
* U-5 机器-用户锁:登录成功当场校验本机 installation ∈ 该用户的列表;
* 不符立即销毁会话并返回 machine_bound_to_other(附绑定者邮箱,便于
* 认领/联系解绑——2026-09-19 用户裁定容许显示)。绑定状态文件损坏
* fail-closed:锁无法判定时不许登录。
*
* 登出≠解绑:logout 只清用户会话槽,机器绑定(H/R)完全不经过这里。
*/
import { loadMachineBinding, MachineBindingError } from './machine-binding'
import {
canonicalizeRelaySite,
type FetchLike,
relayAccountBindingOwner,
relayAccountDeleteInstallation,
RelayAccountError,
relayAccountFetchMe,
relayAccountListInstallationIds,
relayAccountLogin,
relayAccountLogout,
relayAccountRefresh,
relayAccountRegisterResend,
relayAccountRegisterStart,
relayAccountResetConfirm,
relayAccountResetRequest,
type RelayAccountSession,
relayAccountUpdateProfile
} from './relay-account'
import {
loadUserAccount,
persistUserAccount,
type StoredUserAccount,
type UserAccountStoreIo
} from './user-account-store'
export const USER_ACCOUNT_CLIENT_ID = 'hermes-desktop'
/** 插件管理面(/api/plugins/mercury-relay/*)失败的结构化形态。 */
export class PluginApiError extends Error {
readonly code: string
readonly status: number
constructor(code: string, status: number) {
super(code)
this.code = code
this.status = status
}
}
export interface UserAccountIpcDeps {
ipcMain: { handle: (channel: string, fn: (event: any, payload?: any) => Promise<any>) => void }
io: UserAccountStoreIo
fetcher: FetchLike
/** 插件 state.json 原文;ENOENT 抛错视为未绑定。 */
readMachineBindingState: () => string
/**
* 调本机 gateway 的插件管理 API(路径如 'identity/login')。
* 非 2xx 必须抛 PluginApiError(detail 即 code);gateway 不可达抛
* PluginApiError('plugin_unreachable', 0)。
*/
callPluginApi: (path: string, body: Record<string, unknown>) => Promise<any>
/** 绑定时的 suggested_name(本机显示名)。 */
machineName: string
}
/** 预期内的失败走结构化返回(renderer 按 code 上文案);意外仍抛。 */
function failure(error: unknown): { ok: false; code: string } {
if (error instanceof RelayAccountError || error instanceof PluginApiError) {
return { ok: false, code: error.code }
}
throw error
}
export function registerUserAccountIpc(deps: UserAccountIpcDeps): void {
const { ipcMain, io, fetcher, readMachineBindingState, callPluginApi, machineName } = deps
let cached: StoredUserAccount | null = null
function current(): StoredUserAccount | null {
if (cached) {return cached}
cached = loadUserAccount(io)
return cached
}
function store(account: StoredUserAccount | null): void {
cached = account
persistUserAccount(account, io)
}
/** 401 → refresh 一次重试;refresh 也被拒 = 硬终点,本地清零。 */
async function withSession<T>(fn: (session: RelayAccountSession) => Promise<T>): Promise<T> {
const account = current()
if (!account) {throw new RelayAccountError('not_logged_in', 0)}
try {
return await fn(account.session)
} catch (error) {
if (!(error instanceof RelayAccountError) || error.status !== 401) {throw error}
}
let refreshed: RelayAccountSession
try {
refreshed = await relayAccountRefresh(account.session, fetcher)
} catch (error) {
if (error instanceof RelayAccountError && error.status === 401) {
store(null)
throw new RelayAccountError('session_expired', 401)
}
throw error
}
store({ ...account, session: refreshed })
return fn(refreshed)
}
/**
* U-5:登录成功后当场校验。返回 null 放行;否则结构化失败(会话已销毁)。
* 绑定状态损坏 = 锁无法判定,fail-closed 拒绝登录(同样销毁新会话)。
*/
async function enforceMachineLock(session: RelayAccountSession): Promise<Record<string, unknown> | null> {
let binding
try {
binding = loadMachineBinding(readMachineBindingState)
} catch (error) {
if (error instanceof MachineBindingError) {
await relayAccountLogout(session, fetcher)
return { ok: false, code: 'binding_state_invalid' }
}
throw error
}
if (!binding) {return null}
if (binding.site === session.site) {
const owned = await relayAccountListInstallationIds(session.site, session.accessToken, fetcher)
if (owned.includes(binding.installationId)) {return null}
}
// 他账号/他站:当场销毁会话(U-5),再尽力取绑定者邮箱供锁态提示。
const binderEmail = await relayAccountBindingOwner(session.site, session.accessToken, binding.installationId, fetcher).catch(
() => null
)
await relayAccountLogout(session, fetcher)
return { ok: false, code: 'machine_bound_to_other', binderEmail }
}
ipcMain.handle('hermes:account:login', async (_event, payload) => {
let site: string
try {
site = canonicalizeRelaySite(String(payload?.site ?? ''))
} catch (error) {
return failure(error)
}
const email = String(payload?.email ?? '').trim()
const password = String(payload?.password ?? '')
if (!email || !password) {
return { ok: false, code: 'invalid_login_request' }
}
try {
const session = await relayAccountLogin(site, email, password, USER_ACCOUNT_CLIENT_ID, fetcher)
const locked = await enforceMachineLock(session)
if (locked) {return locked}
const profile = await relayAccountFetchMe(site, session.accessToken, fetcher)
store({ session, profile, profileAsOf: new Date().toISOString() })
return { ok: true, site, profile }
} catch (error) {
return failure(error)
}
})
ipcMain.handle('hermes:account:status', async () => {
const account = current()
if (!account) {return { loggedIn: false }}
try {
const profile = await withSession(session => relayAccountFetchMe(session.site, session.accessToken, fetcher))
store({ ...current()!, profile, profileAsOf: new Date().toISOString() })
return { loggedIn: true, site: account.session.site, offline: false, profile }
} catch (error) {
if (error instanceof RelayAccountError && error.code === 'session_expired') {
return { loggedIn: false, sessionExpired: true }
}
if (error instanceof RelayAccountError && error.code === 'network') {
// U-3:relay 不可达但有本地会话 → 标离线进入,展示缓存数据 + 数据时间。
return {
loggedIn: true,
site: account.session.site,
offline: true,
profile: account.profile,
profileAsOf: account.profileAsOf
}
}
throw error
}
})
ipcMain.handle('hermes:account:me', async () => {
const profile = await withSession(session => relayAccountFetchMe(session.site, session.accessToken, fetcher))
store({ ...current()!, profile, profileAsOf: new Date().toISOString() })
return { profile }
})
ipcMain.handle('hermes:account:update-profile', async (_event, payload) => {
// 只放行服务端 PROFILE_FIELDS 同口径三字段;null = 清空。其余键(含
// email)一律丢弃,绝不转发——renderer 按码上文案,不需要 email_not_editable。
const patch: Record<string, string | null> = {}
for (const field of ['username', 'nickname', 'phone'] as const) {
const value = payload?.[field]
if (value === null) {patch[field] = null}
if (typeof value === 'string') {patch[field] = value}
}
if (Object.keys(patch).length === 0) {
return { ok: false, code: 'nothing_to_update' }
}
try {
const updated = await withSession(session => relayAccountUpdateProfile(session.site, session.accessToken, patch, fetcher))
const account = current()!
const profile = { ...account.profile, ...updated }
store({ ...account, profile, profileAsOf: new Date().toISOString() })
return { ok: true, profile }
} catch (error) {
return failure(error)
}
})
ipcMain.handle('hermes:account:binding-status', async () => {
let binding
try {
binding = loadMachineBinding(readMachineBindingState)
} catch (error) {
if (error instanceof MachineBindingError) {return { bound: false, stateInvalid: true }}
throw error
}
if (!binding) {return { bound: false }}
return { bound: true, site: binding.site, installationId: binding.installationId }
})
/**
* U-1/RL-A1 免口令绑定:用当前用户会话 access token 让本机插件完成
* 绑定(口令永远不再经手)。插件 401(会话失效)并入 withSession 的
* 刷新重试链。binding_pending(双因素)原样透传给 UI。
*/
ipcMain.handle('hermes:account:bind-machine', async () => {
let existing
try {
existing = loadMachineBinding(readMachineBindingState)
} catch (error) {
if (error instanceof MachineBindingError) {return { ok: false, code: 'binding_state_invalid' }}
throw error
}
if (existing) {return { ok: false, code: 'already_bound' }}
try {
const result = await withSession(async session => {
try {
return await callPluginApi('identity/login', {
site: session.site,
session_token: session.accessToken,
suggested_name: machineName
})
} catch (error) {
if (error instanceof PluginApiError && error.status === 401) {
throw new RelayAccountError(error.code, 401)
}
throw error
}
})
if (result?.state === 'binding_pending') {
return {
ok: true,
state: 'binding_pending',
code: typeof result.code === 'string' ? result.code : '',
expiresAt: typeof result.expires_at === 'string' ? result.expires_at : ''
}
}
if (result?.state !== 'bound') {return { ok: false, code: 'server' }}
return { ok: true, state: 'bound' }
} catch (error) {
return failure(error)
}
})
/**
* U-6 解绑 = 全清:服务端撤销 installation(尽力,404 幂等;会话死/
* 网络断不阻塞)→ 插件 purge(清绑定含 R + 抹 H,机器回裸态)→
* 删除所有会话(本地用户槽清零 + 尽力服务端吊销)。插件不可达时
* 结构化失败、会话保留,用户可重试——不留下"半清"的确定态假象。
*/
ipcMain.handle('hermes:account:unbind-machine', async () => {
let binding
try {
binding = loadMachineBinding(readMachineBindingState)
} catch (error) {
if (error instanceof MachineBindingError) {return { ok: false, code: 'binding_state_invalid' }}
throw error
}
if (!binding) {return { ok: false, code: 'not_bound' }}
try {
await withSession(session => relayAccountDeleteInstallation(session.site, session.accessToken, binding.installationId, fetcher))
} catch {
// 尽力撤销:本地清零照旧
}
try {
await callPluginApi('identity/purge', {})
} catch (error) {
return failure(error)
}
const account = current()
if (account) {
await relayAccountLogout(account.session, fetcher)
store(null)
}
return { ok: true }
})
ipcMain.handle('hermes:account:logout', async () => {
const account = current()
if (account) {
await relayAccountLogout(account.session, fetcher)
store(null)
}
return { ok: true }
})
ipcMain.handle('hermes:account:register-start', async (_event, payload) => {
try {
const site = canonicalizeRelaySite(String(payload?.site ?? ''))
await relayAccountRegisterStart(site, String(payload?.email ?? '').trim(), String(payload?.password ?? ''), fetcher)
return { ok: true }
} catch (error) {
return failure(error)
}
})
ipcMain.handle('hermes:account:register-resend', async (_event, payload) => {
try {
const site = canonicalizeRelaySite(String(payload?.site ?? ''))
await relayAccountRegisterResend(site, String(payload?.email ?? '').trim(), fetcher)
return { ok: true }
} catch (error) {
return failure(error)
}
})
ipcMain.handle('hermes:account:reset-request', async (_event, payload) => {
try {
const site = canonicalizeRelaySite(String(payload?.site ?? ''))
await relayAccountResetRequest(site, String(payload?.email ?? '').trim(), fetcher)
return { ok: true }
} catch (error) {
return failure(error)
}
})
ipcMain.handle('hermes:account:reset-confirm', async (_event, payload) => {
try {
const site = canonicalizeRelaySite(String(payload?.site ?? ''))
await relayAccountResetConfirm(
site,
String(payload?.email ?? '').trim(),
String(payload?.code ?? '').trim(),
String(payload?.password ?? ''),
fetcher
)
return { ok: true }
} catch (error) {
return failure(error)
}
})
}