diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 13d9deff4d..0affc0631c 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -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) => { + 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 { diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index bafd3dd58b..b2d598b231 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -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), diff --git a/apps/desktop/electron/relay-account.ts b/apps/desktop/electron/relay-account.ts index 4efb3c3d8b..24402cb95d 100644 --- a/apps/desktop/electron/relay-account.ts +++ b/apps/desktop/electron/relay-account.ts @@ -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 { + 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 + } +} diff --git a/apps/desktop/electron/user-account-ipc.test.ts b/apps/desktop/electron/user-account-ipc.test.ts index 8483a2b235..00ef1f15e3 100644 --- a/apps/desktop/electron/user-account-ipc.test.ts +++ b/apps/desktop/electron/user-account-ipc.test.ts @@ -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['ipcMain'] + io: UserAccountStoreIo + fetcher: FetchLike + readMachineBindingState: () => string + callPluginApi?: (path: string, body: Record) => Promise +}): 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) +}) diff --git a/apps/desktop/electron/user-account-ipc.ts b/apps/desktop/electron/user-account-ipc.ts index 66d24eaf7b..8cd255362b 100644 --- a/apps/desktop/electron/user-account-ipc.ts +++ b/apps/desktop/electron/user-account-ipc.ts @@ -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) => 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) => Promise + /** 绑定时的 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() diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index 1a7349da3f..684ae270f7 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -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 resetRequest: (payload: { site: string; email: string }) => Promise resetConfirm: (payload: { site: string; email: string; code: string; password: string }) => Promise + bindingStatus: () => Promise + bindMachine: () => Promise + unbindMachine: () => Promise<{ ok: boolean; code?: string }> } // Registry-scoped backend resolution: dial (connectionId, profile). An // empty/local connectionId delegates to the legacy getConnection path. diff --git a/apps/desktop/src/store/user-account.ts b/apps/desktop/src/store/user-account.ts index 42e2daa800..3d52869447 100644 --- a/apps/desktop/src/store/user-account.ts +++ b/apps/desktop/src/store/user-account.ts @@ -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 { + 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 { + const result = await bridge().unbindMachine() + + if (!result.ok) {throw new UserAccountFailure(result.code ?? 'server')} + + $userAccount.set({ status: 'signed_out' }) +}