a45c35ce54
- 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 失败 结构化返回且不动本地会话(不留半清假象);登出≠解绑硬断言
435 lines
14 KiB
TypeScript
435 lines
14 KiB
TypeScript
/**
|
||
* 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)
|
||
}
|
||
})
|
||
}
|