Why: Project members need RDM commands to address the registered domain project. Failed project states and responses from a previous account must not create work, report a rejected mutation as successful, or restore private content. Evidence: SELF npm test: 32 files and 251 tests passed, including project command, mutation rejection, archived history, and delayed account response coverage. SELF npm run build and git diff --check passed. Original regressions were observed before implementation; root owns composition and independent replay. Impact: Keep host IDs for conversation and base settings APIs; send the decorated domain ID to RDM project commands. Reuse existing sidebar, settings and toast states. Reset identity projections and invalidate old asynchronous responses. No deployment, database change, push, merge, or workspace cleanup. Refs: Dev feature-project-member-access task card and implementation plan Task 4
59 lines
2.3 KiB
TypeScript
59 lines
2.3 KiB
TypeScript
// API key 凭证模块:登录门存取(localStorage 优先,无 DOM 环境回退内存)。
|
||
// 服务端开启认证时(PG 档默认),所有请求带 Bearer 头;SSE/静态资源链接走 ?key=。
|
||
const KEY = 'agent-system.apiKey'
|
||
|
||
interface KV { getItem(k: string): string | null; setItem(k: string, v: string): void; removeItem(k: string): void }
|
||
|
||
function pickStorage(): KV {
|
||
try {
|
||
if (typeof localStorage !== 'undefined') return localStorage
|
||
} catch { /* 隐私模式等 */ }
|
||
const mem = new Map<string, string>()
|
||
return {
|
||
getItem: k => mem.get(k) ?? null,
|
||
setItem: (k, v) => void mem.set(k, v),
|
||
removeItem: k => void mem.delete(k),
|
||
}
|
||
}
|
||
|
||
const storage = pickStorage()
|
||
const identityListeners = new Set<() => void>()
|
||
function identityChanged() { identityListeners.forEach(listener => listener()) }
|
||
|
||
export const auth = {
|
||
apiKey(): string { return storage.getItem(KEY) ?? '' },
|
||
setKey(key: string) {
|
||
const next = key.trim()
|
||
if (next === this.apiKey()) return
|
||
storage.setItem(KEY, next)
|
||
identityChanged()
|
||
},
|
||
clear() { storage.removeItem(KEY); identityChanged() },
|
||
onIdentityChange(listener: () => void) {
|
||
identityListeners.add(listener)
|
||
return () => { identityListeners.delete(listener) }
|
||
},
|
||
// 密码登录成功后切换到 HttpOnly 会话 cookie:必须清掉残留 Bearer——服务端
|
||
// 认证中间件 Bearer 优先于会话 cookie,失效残留 key 会把有效会话也判成 401。
|
||
beginSession() { this.clear() },
|
||
// 带凭证的 fetch:未设置密钥时与原生 fetch 等价(嵌入式档无认证)。
|
||
fetch(input: string, init?: RequestInit): Promise<Response> {
|
||
const key = this.apiKey()
|
||
if (!key) return fetch(input, init)
|
||
const headers = new Headers(init?.headers ?? {})
|
||
headers.set('Authorization', `Bearer ${key}`)
|
||
return fetch(input, { ...init, headers })
|
||
},
|
||
// 退出登录:立即清客户端投影并回登录门;调用者先经 api.logout 注销服务端会话。
|
||
logout() {
|
||
this.clear()
|
||
try { location.reload() } catch { /* 非 DOM 环境(测试)忽略 */ }
|
||
},
|
||
// 无法带头的资源 URL(EventSource / <img> / 下载):追加 ?key=。
|
||
withKey(url: string): string {
|
||
const key = this.apiKey()
|
||
if (!key) return url
|
||
return url + (url.includes('?') ? '&' : '?') + 'key=' + encodeURIComponent(key)
|
||
},
|
||
}
|