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 失败
  结构化返回且不动本地会话(不留半清假象);登出≠解绑硬断言
This commit is contained in:
m4
2026-09-19 18:00:34 +08:00
parent 9d606be4f1
commit a45c35ce54
7 changed files with 512 additions and 21 deletions
+34 -2
View File
@@ -415,7 +415,7 @@ import {
windowsUpdatePrerequisiteError,
wrapHandoffForDetachedConsole
} from './updater-process'
import { registerUserAccountIpc } from './user-account-ipc'
import { PluginApiError, registerUserAccountIpc } from './user-account-ipc'
import {
formatBlockerMessage,
formatProbeFailedMessage,
@@ -8009,7 +8009,39 @@ registerUserAccountIpc({
},
fetcher: fetch as any,
readMachineBindingState: () =>
fs.readFileSync(path.join(resolveHermesHome(), 'mercury-relay', 'state.json'), 'utf8')
fs.readFileSync(path.join(resolveHermesHome(), 'mercury-relay', 'state.json'), 'utf8'),
// 本机 gateway 插件管理面(§21 DB-T4b):经 dashboard 会话令牌走
// /api/plugins/mercury-relay/*;FastAPI {detail} 即稳定 code。
callPluginApi: async (apiPath: string, body: Record<string, unknown>) => {
let descriptor
try {
descriptor = await ensureBackend(primaryProfileKey(), { passive: true })
} catch {
throw new PluginApiError('plugin_unreachable', 0)
}
try {
return await fetchJson(`${descriptor.baseUrl}/api/plugins/mercury-relay/${apiPath}`, descriptor.token, {
method: 'POST',
body
})
} catch (error: any) {
const status = typeof error?.statusCode === 'number' ? error.statusCode : 0
let code = status > 0 ? 'server' : 'plugin_unreachable'
try {
const detail = JSON.parse(String(error?.message || '').replace(/^\d+:\s*/, ''))?.detail
if (typeof detail === 'string' && /^[a-z][a-z0-9_]{1,63}$/.test(detail)) {code = detail}
} catch {
// 非 JSON 错误体:保留归纳码
}
throw new PluginApiError(code, status)
}
},
machineName: os.hostname()
})
function _loadNativeTokens(baseUrl: string): NativeTokenSet | null {
+4 -1
View File
@@ -44,7 +44,10 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
registerStart: payload => ipcRenderer.invoke('hermes:account:register-start', payload),
registerResend: payload => ipcRenderer.invoke('hermes:account:register-resend', payload),
resetRequest: payload => ipcRenderer.invoke('hermes:account:reset-request', payload),
resetConfirm: payload => ipcRenderer.invoke('hermes:account:reset-confirm', payload)
resetConfirm: payload => ipcRenderer.invoke('hermes:account:reset-confirm', payload),
bindingStatus: () => ipcRenderer.invoke('hermes:account:binding-status'),
bindMachine: () => ipcRenderer.invoke('hermes:account:bind-machine'),
unbindMachine: () => ipcRenderer.invoke('hermes:account:unbind-machine')
},
// Registry-scoped backend resolution: { connectionId, profile } → descriptor.
getConnectionFor: payload => ipcRenderer.invoke('hermes:connection:for', payload),
+21
View File
@@ -294,3 +294,24 @@ export async function relayAccountBindingOwner(
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
}
}
+276 -16
View File
@@ -87,7 +87,7 @@ test('login 持久化会话并返回 profile;token 不出 IPC 返回值', asyn
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const { fetcher } = loginOkFetcher()
registerUserAccountIpc({ ipcMain, io, fetcher, readMachineBindingState: unbound })
register({ ipcMain, io, fetcher, readMachineBindingState: unbound })
const result = await handlers.get('hermes:account:login')!(null, {
site: 'https://relay.example.com/',
@@ -108,7 +108,7 @@ test('status 未登录返回 loggedIn:false', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const { fetcher } = loginOkFetcher()
registerUserAccountIpc({ ipcMain, io, fetcher, readMachineBindingState: unbound })
register({ ipcMain, io, fetcher, readMachineBindingState: unbound })
assert.deepEqual(await handlers.get('hermes:account:status')!(null), { loggedIn: false })
})
@@ -134,7 +134,7 @@ test('me 401 → 自动 refresh 一次重试成功,轮换后的令牌落盘',
return { status: 200, body: {} }
})
registerUserAccountIpc({ ipcMain, io, fetcher, readMachineBindingState: unbound })
register({ ipcMain, io, fetcher, readMachineBindingState: unbound })
await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'u', password: 'p' })
const me = await handlers.get('hermes:account:me')!(null)
assert.equal(me.profile.email, 'u@example.com')
@@ -164,7 +164,7 @@ test('refresh 也被拒 = 硬会话终点:本地清零并上报 sessionExpired
return { status: 200, body: {} }
})
registerUserAccountIpc({ ipcMain, io, fetcher, readMachineBindingState: unbound })
register({ ipcMain, io, fetcher, readMachineBindingState: unbound })
await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'u', password: 'p' })
const status = await handlers.get('hermes:account:status')!(null)
assert.deepEqual(status, { loggedIn: false, sessionExpired: true })
@@ -186,7 +186,7 @@ test('relay 不可达但本地会话在:status 标离线并回缓存 profile +
return { ok: true, status: 200, json: async () => ME, text: async () => '' }
}
registerUserAccountIpc({ ipcMain, io, fetcher, readMachineBindingState: unbound })
register({ ipcMain, io, fetcher, readMachineBindingState: unbound })
await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'u', password: 'p' })
online = false
const status = await handlers.get('hermes:account:status')!(null)
@@ -210,7 +210,7 @@ test('logout 尽力通知服务端,本地清零;服务端 401 不阻塞', as
return { status: 200, body: {} }
})
registerUserAccountIpc({ ipcMain, io, fetcher, readMachineBindingState: unbound })
register({ ipcMain, io, fetcher, readMachineBindingState: unbound })
await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'u', password: 'p' })
const result = await handlers.get('hermes:account:logout')!(null)
assert.deepEqual(result, { ok: true })
@@ -218,6 +218,20 @@ test('logout 尽力通知服务端,本地清零;服务端 401 不阻塞', as
assert.deepEqual(await handlers.get('hermes:account:status')!(null), { loggedIn: false })
})
function register(deps: {
ipcMain: ReturnType<typeof fakeIpcMain>['ipcMain']
io: UserAccountStoreIo
fetcher: FetchLike
readMachineBindingState: () => string
callPluginApi?: (path: string, body: Record<string, unknown>) => Promise<any>
}): void {
registerUserAccountIpc({
...deps,
callPluginApi: deps.callPluginApi ?? (async () => { throw new Error('unexpected plugin api call') }),
machineName: 'test-machine'
})
}
function unbound(): string {
throw new Error('ENOENT')
}
@@ -257,7 +271,7 @@ test('U-5:本机绑定属于登录者本人 → 放行', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const { fetcher } = u5Fetcher('u@example.com', ['inst-1'])
registerUserAccountIpc({ ipcMain, io, fetcher, readMachineBindingState: () => BINDING_STATE })
register({ ipcMain, io, fetcher, readMachineBindingState: () => BINDING_STATE })
const result = await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'u@example.com', password: 'pw' })
assert.equal(result.ok, true)
@@ -268,7 +282,7 @@ test('U-5:他账号登录 → 当场销毁会话,返回锁码 + 绑定者邮
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const { fetcher, calls } = u5Fetcher('owner@example.com', ['inst-other'])
registerUserAccountIpc({ ipcMain, io, fetcher, readMachineBindingState: () => BINDING_STATE })
register({ ipcMain, io, fetcher, readMachineBindingState: () => BINDING_STATE })
const result = await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'intruder@example.com', password: 'pw' })
assert.deepEqual(result, { ok: false, code: 'machine_bound_to_other', binderEmail: 'owner@example.com' })
@@ -282,7 +296,7 @@ test('U-5:绑定在别的站点 → 同样拒绝', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const { fetcher } = u5Fetcher(null, [])
registerUserAccountIpc({ ipcMain, io, fetcher, readMachineBindingState: () => BINDING_STATE })
register({ ipcMain, io, fetcher, readMachineBindingState: () => BINDING_STATE })
const result = await handlers.get('hermes:account:login')!(null, { site: 'https://other', email: 'u@example.com', password: 'pw' })
assert.equal(result.ok, false)
@@ -294,7 +308,7 @@ test('U-5:绑定状态损坏 fail-closed,新会话一并销毁', async () =>
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const { fetcher, calls } = u5Fetcher(null, [])
registerUserAccountIpc({ ipcMain, io, fetcher, readMachineBindingState: () => '{broken json' })
register({ ipcMain, io, fetcher, readMachineBindingState: () => '{broken json' })
const result = await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'u@example.com', password: 'pw' })
assert.deepEqual(result, { ok: false, code: 'binding_state_invalid' })
@@ -306,7 +320,7 @@ test('login 预期失败走结构化返回:invalid_credentials / login_rate_li
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const { fetcher } = makeFetcher(() => ({ status: 401, text: 'invalid_credentials' }))
registerUserAccountIpc({ ipcMain, io, fetcher, readMachineBindingState: unbound })
register({ ipcMain, io, fetcher, readMachineBindingState: unbound })
assert.deepEqual(await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'u', password: 'p' }), {
ok: false,
code: 'invalid_credentials'
@@ -318,7 +332,7 @@ test('register/reset 四通道透传路径与参数', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const { fetcher, calls } = makeFetcher(() => ({ status: 202, body: { status: 'verification_sent' } }))
registerUserAccountIpc({ ipcMain, io, fetcher, readMachineBindingState: unbound })
register({ ipcMain, io, fetcher, readMachineBindingState: unbound })
assert.deepEqual(await handlers.get('hermes:account:register-start')!(null, { site: 'https://r', email: 'u@e.c', password: 'pw' }), { ok: true })
assert.deepEqual(await handlers.get('hermes:account:register-resend')!(null, { site: 'https://r', email: 'u@e.c' }), { ok: true })
@@ -341,7 +355,7 @@ test('register-start 失败码透传(weak_password / registration_rate_limited
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const { fetcher } = makeFetcher(() => ({ status: 400, text: 'weak_password' }))
registerUserAccountIpc({ ipcMain, io, fetcher, readMachineBindingState: unbound })
register({ ipcMain, io, fetcher, readMachineBindingState: unbound })
assert.deepEqual(await handlers.get('hermes:account:register-start')!(null, { site: 'https://r', email: 'u@e.c', password: 'x' }), {
ok: false,
code: 'weak_password'
@@ -364,7 +378,7 @@ test('update-profile:PATCH /me 白名单三字段,合并回完整快照落
return { status: 200, body: {} }
})
registerUserAccountIpc({ ipcMain, io, fetcher, readMachineBindingState: unbound })
register({ ipcMain, io, fetcher, readMachineBindingState: unbound })
await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'u', password: 'p' })
const result = await handlers.get('hermes:account:update-profile')!(null, {
@@ -399,7 +413,7 @@ test('update-profile:空补丁不出网;服务端拒绝码透传', async ()
return { status: 200, body: {} }
})
registerUserAccountIpc({ ipcMain, io, fetcher, readMachineBindingState: unbound })
register({ ipcMain, io, fetcher, readMachineBindingState: unbound })
await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'u', password: 'p' })
const before = calls.length
assert.deepEqual(await handlers.get('hermes:account:update-profile')!(null, { email: 'x@y.z' }), {
@@ -423,7 +437,7 @@ test('update-profile:服务端 403 profile_self_service_disabled 结构化透
return { status: 200, body: {} }
})
registerUserAccountIpc({ ipcMain, io, fetcher, readMachineBindingState: unbound })
register({ ipcMain, io, fetcher, readMachineBindingState: unbound })
await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'u', password: 'p' })
assert.deepEqual(await handlers.get('hermes:account:update-profile')!(null, { nickname: 'x' }), {
ok: false,
@@ -432,3 +446,249 @@ test('update-profile:服务端 403 profile_self_service_disabled 结构化透
// 失败不落盘改动
assert.equal(loadUserAccount(io)?.profile?.nickname, '小赫')
})
// ---------- DB-T4b:绑定打通 + U-6 解绑全清(§21) ----------------------------
test('binding-status:未绑定 / 已绑定只回 site+installationId / 损坏 fail-closed 标记', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const { fetcher } = loginOkFetcher()
register({ ipcMain, io, fetcher, readMachineBindingState: unbound })
assert.deepEqual(await handlers.get('hermes:account:binding-status')!(null), { bound: false })
})
test('binding-status:已绑定不含凭据字段;损坏报 stateInvalid', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const { fetcher } = loginOkFetcher()
register({ ipcMain, io, fetcher, readMachineBindingState: () => BINDING_STATE })
const bound = await handlers.get('hermes:account:binding-status')!(null)
assert.deepEqual(bound, { bound: true, site: 'https://r', installationId: 'inst-1' })
assert.equal(JSON.stringify(bound).includes('SHOULD-NOT-BE-READ'), false)
const { handlers: h2, ipcMain: i2 } = fakeIpcMain()
register({ ipcMain: i2, io: fakeIo().io, fetcher, readMachineBindingState: () => '{broken' })
assert.deepEqual(await h2.get('hermes:account:binding-status')!(null), { bound: false, stateInvalid: true })
})
test('bind-machine:插件收到 site+session_token(Bearer 等价物)+suggested_name;不落 email/password', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const { fetcher } = loginOkFetcher()
const pluginCalls: { path: string; body: any }[] = []
register({
ipcMain,
io,
fetcher,
readMachineBindingState: unbound,
callPluginApi: async (path, body) => {
pluginCalls.push({ path, body })
return { state: 'bound', created: true }
}
})
await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'u', password: 'p' })
const result = await handlers.get('hermes:account:bind-machine')!(null)
assert.deepEqual(result, { ok: true, state: 'bound' })
assert.equal(pluginCalls.length, 1)
assert.equal(pluginCalls[0].path, 'identity/login')
assert.equal(pluginCalls[0].body.site, 'https://r')
assert.equal(pluginCalls[0].body.session_token, 'AT-1')
assert.equal(pluginCalls[0].body.suggested_name, 'test-machine')
assert.equal('password' in pluginCalls[0].body, false)
})
test('bind-machine:已绑定不出网(already_bound);未登录 not_logged_in', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const { fetcher } = loginOkFetcher()
let pluginCalls = 0
register({
ipcMain,
io,
fetcher,
readMachineBindingState: () => BINDING_STATE,
callPluginApi: async () => {
pluginCalls += 1
return { state: 'bound' }
}
})
assert.deepEqual(await handlers.get('hermes:account:bind-machine')!(null), { ok: false, code: 'already_bound' })
assert.equal(pluginCalls, 0)
})
test('bind-machine:binding_pending 原样透传确认码', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const { fetcher } = loginOkFetcher()
register({
ipcMain,
io,
fetcher,
readMachineBindingState: unbound,
callPluginApi: async () => ({ state: 'binding_pending', code: 'MR-7K2P-Q9', expires_at: '2026-09-19T12:00:00Z' })
})
await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'u', password: 'p' })
assert.deepEqual(await handlers.get('hermes:account:bind-machine')!(null), {
ok: true,
state: 'binding_pending',
code: 'MR-7K2P-Q9',
expiresAt: '2026-09-19T12:00:00Z'
})
})
test('unbind-machine(U-6):服务端撤销 → 插件 purge → 删所有会话;本地会话清零', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const { fetcher, calls } = makeFetcher(url => {
if (url.endsWith('/auth/login')) {return { status: 200, body: TOKENS }}
if (url.endsWith('/me')) {return { status: 200, body: ME }}
if (url.endsWith('/installations')) {return { status: 200, body: { items: [{ id: 'inst-1' }] } }}
if (url.includes('/installations/')) {return { status: 202, body: { status: 'requested' } }}
return { status: 200, body: {} }
})
const pluginCalls: string[] = []
register({
ipcMain,
io,
fetcher,
readMachineBindingState: () => BINDING_STATE,
callPluginApi: async path => {
pluginCalls.push(path)
return { state: 'purged' }
}
})
await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'u', password: 'p' })
assert.notEqual(loadUserAccount(io), null)
const result = await handlers.get('hermes:account:unbind-machine')!(null)
assert.deepEqual(result, { ok: true })
// 1. 服务端撤销带用户 Bearer
const revoke = calls.find(c => c.url.includes('/installations/'))
assert.equal(revoke?.init?.method, 'DELETE')
assert.equal(revoke?.init?.headers?.authorization, 'Bearer AT-1')
assert.ok(revoke!.url.endsWith('/installations/inst-1'))
// 2. 插件 purge
assert.deepEqual(pluginCalls, ['identity/purge'])
// 3. 会话清零 + 尽力服务端吊销
assert.equal(loadUserAccount(io), null)
assert.ok(calls.some(c => c.url.endsWith('/auth/logout')))
assert.deepEqual(await handlers.get('hermes:account:status')!(null), { loggedIn: false })
})
test('unbind-machine:服务端撤销失败不阻塞本地全清(尽力语义)', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const { fetcher } = makeFetcher(url => {
if (url.endsWith('/auth/login')) {return { status: 200, body: TOKENS }}
if (url.endsWith('/me')) {return { status: 200, body: ME }}
if (url.endsWith('/installations')) {return { status: 200, body: { items: [{ id: 'inst-1' }] } }}
if (url.includes('/installations/')) {return { status: 503, text: 'server' }}
return { status: 200, body: {} }
})
register({
ipcMain,
io,
fetcher,
readMachineBindingState: () => BINDING_STATE,
callPluginApi: async () => ({ state: 'purged' })
})
await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'u', password: 'p' })
assert.deepEqual(await handlers.get('hermes:account:unbind-machine')!(null), { ok: true })
assert.equal(loadUserAccount(io), null)
})
test('unbind-machine:插件不可达则结构化失败且会话保留(不留半清假象)', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const { fetcher } = makeFetcher(url => {
if (url.endsWith('/auth/login')) {return { status: 200, body: TOKENS }}
if (url.endsWith('/me')) {return { status: 200, body: ME }}
if (url.endsWith('/installations')) {return { status: 200, body: { items: [{ id: 'inst-1' }] } }}
return { status: 200, body: {} }
})
const { PluginApiError } = await import('./user-account-ipc')
register({
ipcMain,
io,
fetcher,
readMachineBindingState: () => BINDING_STATE,
callPluginApi: async () => {
throw new PluginApiError('plugin_unreachable', 0)
}
})
await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'u', password: 'p' })
assert.deepEqual(await handlers.get('hermes:account:unbind-machine')!(null), { ok: false, code: 'plugin_unreachable' })
assert.notEqual(loadUserAccount(io), null)
})
test('unbind-machine:未绑定 not_bound,不出网', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const { fetcher, calls } = loginOkFetcher()
let pluginCalls = 0
register({
ipcMain,
io,
fetcher,
readMachineBindingState: unbound,
callPluginApi: async () => {
pluginCalls += 1
return {}
}
})
assert.deepEqual(await handlers.get('hermes:account:unbind-machine')!(null), { ok: false, code: 'not_bound' })
assert.equal(pluginCalls, 0)
assert.equal(calls.some(c => c.url.includes('/installations/')), false)
})
test('登出≠解绑:logout 绝不碰插件 API 与 installation 端点', async () => {
const { handlers, ipcMain } = fakeIpcMain()
const { io } = fakeIo()
const { fetcher, calls } = loginOkFetcher()
let pluginCalls = 0
register({
ipcMain,
io,
fetcher,
readMachineBindingState: () => BINDING_STATE,
callPluginApi: async () => {
pluginCalls += 1
return {}
}
})
await handlers.get('hermes:account:login')!(null, { site: 'https://r', email: 'u', password: 'p' })
calls.length = 0
await handlers.get('hermes:account:logout')!(null)
assert.equal(pluginCalls, 0)
assert.equal(calls.some(c => c.url.includes('/installations/')), false)
})
+132 -2
View File
@@ -21,6 +21,7 @@ import {
canonicalizeRelaySite,
type FetchLike,
relayAccountBindingOwner,
relayAccountDeleteInstallation,
RelayAccountError,
relayAccountFetchMe,
relayAccountListInstallationIds,
@@ -43,17 +44,37 @@ import {
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) {
if (error instanceof RelayAccountError || error instanceof PluginApiError) {
return { ok: false, code: error.code }
}
@@ -61,7 +82,7 @@ function failure(error: unknown): { ok: false; code: string } {
}
export function registerUserAccountIpc(deps: UserAccountIpcDeps): void {
const { ipcMain, io, fetcher, readMachineBindingState } = deps
const { ipcMain, io, fetcher, readMachineBindingState, callPluginApi, machineName } = deps
let cached: StoredUserAccount | null = null
function current(): StoredUserAccount | null {
@@ -241,6 +262,115 @@ export function registerUserAccountIpc(deps: UserAccountIpcDeps): void {
}
})
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()
+5
View File
@@ -13,6 +13,8 @@ import type {
} from './store/pet-overlay'
import type { QuickEntryStatePush, QuickEntryStatus, QuickEntrySubmitPayload } from './store/quick-entry'
import type {
HermesUserAccountBindingStatus,
HermesUserAccountBindResult,
HermesUserAccountLoginResult,
HermesUserAccountOpResult,
HermesUserAccountProfile,
@@ -57,6 +59,9 @@ declare global {
registerResend: (payload: { site: string; email: string }) => Promise<HermesUserAccountOpResult>
resetRequest: (payload: { site: string; email: string }) => Promise<HermesUserAccountOpResult>
resetConfirm: (payload: { site: string; email: string; code: string; password: string }) => Promise<HermesUserAccountOpResult>
bindingStatus: () => Promise<HermesUserAccountBindingStatus>
bindMachine: () => Promise<HermesUserAccountBindResult>
unbindMachine: () => Promise<{ ok: boolean; code?: string }>
}
// Registry-scoped backend resolution: dial (connectionId, profile). An
// empty/local connectionId delegates to the legacy getConnection path.
+40
View File
@@ -58,6 +58,16 @@ export type HermesUserAccountLoginResult =
| { ok: true; site: string; profile: HermesUserAccountProfile }
| { ok: false; code: string; binderEmail?: string | null }
/** 本机绑定状态(§21 DB-T4b):只含 site + installationId,凭据绝不出 main。 */
export type HermesUserAccountBindingStatus =
| { bound: false; stateInvalid?: boolean }
| { bound: true; site: string; installationId: string }
export type HermesUserAccountBindResult =
| { ok: true; state: 'bound' }
| { ok: true; state: 'binding_pending'; code: string; expiresAt: string }
| { ok: false; code: string }
/** 登录/注册/找回的可预期失败码(renderer 按码上文案,绝不回显服务端原文)。 */
export type UserAccountFailureCode =
| 'account_locked'
@@ -187,3 +197,33 @@ export function userAccountDisplayName(profile: HermesUserAccountProfile | null)
return at > 0 ? profile.email.slice(0, at) : profile.email
}
/** 本机绑定状态(只读插件 state.json 的非敏感二字段)。 */
export function userAccountBindingStatus(): Promise<HermesUserAccountBindingStatus> {
return bridge().bindingStatus()
}
/** 绑定本机到当前账号(§21 U-1 免口令)。失败抛 UserAccountFailure;pending 返回确认码。 */
export async function userAccountBindMachine(): Promise<{ state: 'bound' } | { state: 'binding_pending'; code: string; expiresAt: string }> {
const result = await bridge().bindMachine()
if (!result.ok) {throw new UserAccountFailure(result.code)}
if (result.state === 'binding_pending') {
return { state: 'binding_pending', code: result.code, expiresAt: result.expiresAt }
}
return { state: 'bound' }
}
/**
* U-6 解绑 = 全清:服务端撤销 + 插件 purge(含 H)+ 删所有会话。
* 成功后本地会话已不在,状态落 signed_out。
*/
export async function userAccountUnbindMachine(): Promise<void> {
const result = await bridge().unbindMachine()
if (!result.ok) {throw new UserAccountFailure(result.code ?? 'server')}
$userAccount.set({ status: 'signed_out' })
}