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:
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Vendored
+5
@@ -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.
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user