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 失败 结构化返回且不动本地会话(不留半清假象);登出≠解绑硬断言
318 lines
9.8 KiB
TypeScript
318 lines
9.8 KiB
TypeScript
/**
|
||
* relay-account.ts — Hermes 用户账号会话的 relay 客户端(§21 U-1/U-2)。
|
||
*
|
||
* 只讲 /api/v2/auth/* 与 /api/v2/me。无 electron import,fetch 注入,
|
||
* 与 freemodel2api.ts 同一可测纪律。
|
||
*
|
||
* 口令只穿过 login() 这一次;refresh/logout/me 全部走令牌。
|
||
* 用户会话与机器绑定(H/R)是两套独立凭据,本模块绝不碰绑定。
|
||
*/
|
||
|
||
export interface FetchLike {
|
||
(url: string, init?: any): Promise<{ ok: boolean; status: number; json: () => Promise<any>; text?: () => Promise<string> }>
|
||
}
|
||
|
||
export interface RelayAccountSession {
|
||
site: string
|
||
accessToken: string
|
||
refreshToken: string
|
||
sessionId: string
|
||
}
|
||
|
||
export interface RelayAccountProfile {
|
||
email: string
|
||
username: string
|
||
nickname: string
|
||
phone: string
|
||
planId: string
|
||
periodEnd: string
|
||
dataAsOf: string
|
||
}
|
||
|
||
export class RelayAccountError extends Error {
|
||
readonly code: string
|
||
readonly status: number
|
||
|
||
constructor(code: string, status: number) {
|
||
super(code)
|
||
this.code = code
|
||
this.status = status
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 与插件 canonicalize_relay_origin 同一纪律:https 强制,
|
||
* http 只放行 loopback(开发用);去 query/fragment/尾斜杠。
|
||
*/
|
||
export function canonicalizeRelaySite(raw: string): string {
|
||
const value = String(raw ?? '').trim()
|
||
|
||
if (!value) {throw new RelayAccountError('invalid_site', 0)}
|
||
let parsed: URL
|
||
|
||
try {
|
||
parsed = new URL(value)
|
||
} catch {
|
||
throw new RelayAccountError('invalid_site', 0)
|
||
}
|
||
|
||
const loopback = parsed.hostname === '127.0.0.1' || parsed.hostname === 'localhost' || parsed.hostname === '::1'
|
||
|
||
if (parsed.protocol !== 'https:' && !(parsed.protocol === 'http:' && loopback)) {
|
||
throw new RelayAccountError('invalid_site', 0)
|
||
}
|
||
|
||
const path = parsed.pathname.replace(/\/+$/, '')
|
||
|
||
return `${parsed.protocol}//${parsed.host}${path}`
|
||
}
|
||
|
||
async function readErrorCode(response: { status: number; text?: () => Promise<string> }): Promise<string> {
|
||
try {
|
||
const body = (await response.text?.())?.trim() ?? ''
|
||
|
||
// 服务端错误体就是小写错误码本身;任何不像码的(HTML、堆栈)一律归纳。
|
||
if (/^[a-z][a-z0-9_]{1,63}$/.test(body)) {return body}
|
||
} catch {
|
||
// 读不出体也归纳
|
||
}
|
||
|
||
return 'server'
|
||
}
|
||
|
||
async function request(
|
||
fetcher: FetchLike,
|
||
url: string,
|
||
init: any
|
||
): Promise<any> {
|
||
let response
|
||
|
||
try {
|
||
response = await fetcher(url, init)
|
||
} catch {
|
||
throw new RelayAccountError('network', 0)
|
||
}
|
||
|
||
if (!response.ok) {
|
||
throw new RelayAccountError(await readErrorCode(response), response.status)
|
||
}
|
||
|
||
return response.json()
|
||
}
|
||
|
||
function parseTokenPayload(payload: any, site: string): RelayAccountSession {
|
||
const accessToken = typeof payload?.access_token === 'string' ? payload.access_token : ''
|
||
const refreshToken = typeof payload?.refresh_token === 'string' ? payload.refresh_token : ''
|
||
const sessionId = typeof payload?.session_id === 'string' ? payload.session_id : ''
|
||
|
||
if (!accessToken || !refreshToken || !sessionId) {
|
||
// 响应形状不符绝不猜:宁可报 server 也不存半个会话。
|
||
throw new RelayAccountError('server', 200)
|
||
}
|
||
|
||
return { site, accessToken, refreshToken, sessionId }
|
||
}
|
||
|
||
export async function relayAccountLogin(
|
||
site: string,
|
||
email: string,
|
||
password: string,
|
||
clientId: string,
|
||
fetcher: FetchLike
|
||
): Promise<RelayAccountSession> {
|
||
const payload = await request(fetcher, `${site}/api/v2/auth/login`, {
|
||
method: 'POST',
|
||
headers: { 'content-type': 'application/json' },
|
||
body: JSON.stringify({ email, password, client_id: clientId })
|
||
})
|
||
|
||
return parseTokenPayload(payload, site)
|
||
}
|
||
|
||
export async function relayAccountRefresh(
|
||
session: RelayAccountSession,
|
||
fetcher: FetchLike
|
||
): Promise<RelayAccountSession> {
|
||
const payload = await request(fetcher, `${session.site}/api/v2/auth/refresh`, {
|
||
method: 'POST',
|
||
headers: { 'content-type': 'application/json' },
|
||
body: JSON.stringify({ session_id: session.sessionId, refresh_token: session.refreshToken })
|
||
})
|
||
|
||
return parseTokenPayload(payload, session.site)
|
||
}
|
||
|
||
/**
|
||
* 尽力通知服务端吊销;access 已过期/网络不通都不阻塞本地清零(U-1:
|
||
* 登出只动用户会话,反正机器通道本来也不由它供能)。
|
||
*/
|
||
export async function relayAccountLogout(session: RelayAccountSession, fetcher: FetchLike): Promise<void> {
|
||
try {
|
||
await fetcher(`${session.site}/api/v2/auth/logout`, {
|
||
method: 'POST',
|
||
headers: { authorization: `Bearer ${session.accessToken}` }
|
||
})
|
||
} catch {
|
||
// 本地照常清零
|
||
}
|
||
}
|
||
|
||
export function parseRelayMe(payload: any): RelayAccountProfile {
|
||
const account = payload?.account ?? {}
|
||
|
||
return {
|
||
email: typeof account.email === 'string' ? account.email : '',
|
||
username: typeof account.username === 'string' ? account.username : '',
|
||
nickname: typeof account.nickname === 'string' ? account.nickname : '',
|
||
phone: typeof account.phone === 'string' ? account.phone : '',
|
||
planId: typeof account.plan_id === 'string' ? account.plan_id : '',
|
||
periodEnd: typeof account.period_end === 'string' ? account.period_end : '',
|
||
dataAsOf: typeof payload?.data_as_of === 'string' ? payload.data_as_of : ''
|
||
}
|
||
}
|
||
|
||
export async function relayAccountFetchMe(
|
||
site: string,
|
||
accessToken: string,
|
||
fetcher: FetchLike
|
||
): Promise<RelayAccountProfile> {
|
||
const payload = await request(fetcher, `${site}/api/v2/me`, {
|
||
headers: { authorization: `Bearer ${accessToken}` }
|
||
})
|
||
|
||
return parseRelayMe(payload)
|
||
}
|
||
|
||
/** 本人可改字段(服务端 PROFILE_FIELDS 同口径;邮箱永不可改)。 */
|
||
export interface RelayAccountProfilePatch {
|
||
username?: string | null
|
||
nickname?: string | null
|
||
phone?: string | null
|
||
}
|
||
|
||
/**
|
||
* PATCH /api/v2/me。响应的 profile 只含 email/username/nickname/phone/status,
|
||
* 不含 plan/periodEnd/dataAsOf——调用方负责并入已有快照,不得以偏概全。
|
||
*/
|
||
export async function relayAccountUpdateProfile(
|
||
site: string,
|
||
accessToken: string,
|
||
patch: RelayAccountProfilePatch,
|
||
fetcher: FetchLike
|
||
): Promise<Pick<RelayAccountProfile, 'email' | 'username' | 'nickname' | 'phone'>> {
|
||
const payload = await request(fetcher, `${site}/api/v2/me`, {
|
||
method: 'PATCH',
|
||
headers: { authorization: `Bearer ${accessToken}`, 'content-type': 'application/json' },
|
||
body: JSON.stringify(patch)
|
||
})
|
||
|
||
const profile = payload?.profile ?? {}
|
||
|
||
return {
|
||
email: typeof profile.email === 'string' ? profile.email : '',
|
||
username: typeof profile.username === 'string' ? profile.username : '',
|
||
nickname: typeof profile.nickname === 'string' ? profile.nickname : '',
|
||
phone: typeof profile.phone === 'string' ? profile.phone : ''
|
||
}
|
||
}
|
||
|
||
/** 注册第一步:投递验证邮件。202 与「邮箱已被注册」同形(防枚举)。 */
|
||
export async function relayAccountRegisterStart(
|
||
site: string,
|
||
email: string,
|
||
password: string,
|
||
fetcher: FetchLike
|
||
): Promise<void> {
|
||
await request(fetcher, `${site}/api/v2/registration/start`, {
|
||
method: 'POST',
|
||
headers: { 'content-type': 'application/json' },
|
||
body: JSON.stringify({ email, password })
|
||
})
|
||
}
|
||
|
||
export async function relayAccountRegisterResend(site: string, email: string, fetcher: FetchLike): Promise<void> {
|
||
await request(fetcher, `${site}/api/v2/registration/resend`, {
|
||
method: 'POST',
|
||
headers: { 'content-type': 'application/json' },
|
||
body: JSON.stringify({ email })
|
||
})
|
||
}
|
||
|
||
/** 找回第一步:投递重置码邮件。202 同样防枚举。 */
|
||
export async function relayAccountResetRequest(site: string, email: string, fetcher: FetchLike): Promise<void> {
|
||
await request(fetcher, `${site}/api/v2/auth/password-reset/request`, {
|
||
method: 'POST',
|
||
headers: { 'content-type': 'application/json' },
|
||
body: JSON.stringify({ email })
|
||
})
|
||
}
|
||
|
||
export async function relayAccountResetConfirm(
|
||
site: string,
|
||
email: string,
|
||
code: string,
|
||
password: string,
|
||
fetcher: FetchLike
|
||
): Promise<void> {
|
||
await request(fetcher, `${site}/api/v2/auth/password-reset/confirm`, {
|
||
method: 'POST',
|
||
headers: { 'content-type': 'application/json' },
|
||
body: JSON.stringify({ email, code, password })
|
||
})
|
||
}
|
||
|
||
/** U-5 所有权校验:列出本会话用户的 installation id 集合。 */
|
||
export async function relayAccountListInstallationIds(
|
||
site: string,
|
||
accessToken: string,
|
||
fetcher: FetchLike
|
||
): Promise<string[]> {
|
||
const payload = await request(fetcher, `${site}/api/v2/installations`, {
|
||
headers: { authorization: `Bearer ${accessToken}` }
|
||
})
|
||
|
||
const items = Array.isArray(payload?.items) ? payload.items : []
|
||
|
||
return items.map((item: any) => (typeof item?.id === 'string' ? item.id : '')).filter((id: string) => id !== '')
|
||
}
|
||
|
||
/** U-5 锁态提示:查一台已绑定机器的绑定者邮箱;404 时返回 null。 */
|
||
export async function relayAccountBindingOwner(
|
||
site: string,
|
||
accessToken: string,
|
||
installationId: string,
|
||
fetcher: FetchLike
|
||
): Promise<string | null> {
|
||
try {
|
||
const payload = await request(fetcher, `${site}/api/v2/agent/binding/owner?installation_id=${encodeURIComponent(installationId)}`, {
|
||
headers: { authorization: `Bearer ${accessToken}` }
|
||
})
|
||
|
||
return typeof payload?.email === 'string' ? payload.email : null
|
||
} catch (error) {
|
||
if (error instanceof RelayAccountError && error.status === 404) {return null}
|
||
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
|
||
}
|
||
}
|