feat: JWT过期自动重置 + 用户端登录页 + 系统设置前三个真实实现
agent-111 JWT过期重置+系统设置去占位: - ws.ts 识别401/过期 markAuthExpired(清token+停重连+expired态),isTokenExpired;wsParse.ts拆出压行371;scheduleReconnect有token未过期才重连 - api.ts REST 401→auth_expired+清token+引导重登;login走skipAuthExpired密码错不误清 - types.ts WsStatus加expired;TopBar/Nav expired显示登录过期;System.tsx去alert占位改4张事实卡+合并Notice agent-113 用户端登录页: - components/Login.tsx Miuix登录卡(用户名/密码→login()→setAuthToken);App !getAuthToken()→<Login>;AdminApp LoginGate指向 agent-112 系统设置前三个(owner+审计): - auth.APIKey.RevokeAll(吊销全部key);auth.JWTService.RotateSecret(≥32校验+并发安全+新密钥不回显) - gateway admin_system.go: POST revoke-keys/rotate-jwt/backup(VACUUM INTO)/restore(防路径穿越+恢复前自动备份+原子替换+需重启) - wire.go 传DatabasePath;全仓 build/vet/test绿+交叉编译;api-client v0.17系统设置契约 实机验证: 登录(role=owner)+revoke-keys 200+渠道列表含kook; 全仓绿+前端build绿; .env密码仅登录验证不打印
This commit is contained in:
1 parent
7e39153a69
commit
1ebc82fffd
19 files changed
+1164
-245
No files matched your search
+12
-1
@@ -53,8 +53,12 @@
|
||||
| 17 | `/api/v1/admin/observability/audit` | GET | ✅ | 审计记录(who/what/when/result,SQLite 持久化) |
|
||||
| 18 | `/api/v1/admin/memories` | POST | ✅(写) | 手动添加记忆(owner-only,审计);body `{personaId?,content,scope?}`,无 store 回 `501` |
|
||||
| 19 | `/api/v1/admin/rag` | POST | ✅(写) | 导入知识文档(owner-only,审计);body `{name,desc?,content}`,分块后落项目索引可检索;无 store 回 `501` |
|
||||
| 20 | `/api/v1/admin/system/revoke-keys` | POST | ✅(写) | 吊销**全部** API key(owner-only,审计);返回 `{revoked:n}`——key 表清空,先前签发的所有 key 立即失效 |
|
||||
| 21 | `/api/v1/admin/system/rotate-jwt` | POST | ✅(写) | 轮换 JWT 密钥(owner-only,审计);body `{newSecret}`(≥32 位,不强校验熵)→ `{rotated:true,note}`——**现有 token 立即失效需重新登录**;新密钥**不回显、不落日志** |
|
||||
| 22 | `/api/v1/admin/system/backup` | POST | ✅(写) | 备份数据(owner-only,审计);用 SQLite `VACUUM INTO` 快照(含已提交 WAL 内容)到 `<dataDir>/backup-<ts>.db` → `{path,size}` |
|
||||
| 23 | `/api/v1/admin/system/restore` | POST | ✅(写) | 恢复数据(owner-only,审计);body `{path}` → 校验路径在 dataDir 内→**先备份当前**→替换 DB 文件→`{restored:true,backup,note:"需重启后生效"}`(运行进程持有 DB 句柄,热替换需重启可见) |
|
||||
|
||||
> **M10 前端契约补充(管理面板)**:`GET /api/v1/admin/users` · `GET /api/v1/admin/llm` · `GET /api/v1/admin/automations` · `GET /api/v1/admin/backthink` · `GET /api/v1/admin/rag` · `POST /api/v1/admin/rag/query` · `GET /api/v1/admin/observability/metrics` · `GET /api/v1/admin/observability/trace` · `GET /api/v1/admin/observability/audit` 已实现,返回真实数据;`GET /api/v1/admin/logs` · `GET /api/v1/admin/system` 仍🕐未实现(前端按契约封装、后端 404 时显示"待实现"壳,不报错)。**写操作已补齐**(见下方「管理面板写操作契约」):人格/API key/设备控制/自动化/LLM 测试/渠道连接与识别测试/记忆检索/会话导出/插件卸载**真实持久化或真实生效**;仍为后置 M10:**新增用户**、**IoT 场景与规则**、**插件安装(需源码清单)**、**主人档案保存**、**系统设置**、**知识库删除**——后端如实返回 `501 not_implemented`,不伪造成功。
|
||||
> **M10 前端契约补充(管理面板)**:`GET /api/v1/admin/users` · `GET /api/v1/admin/llm` · `GET /api/v1/admin/automations` · `GET /api/v1/admin/backthink` · `GET /api/v1/admin/rag` · `POST /api/v1/admin/rag/query` · `GET /api/v1/admin/observability/metrics` · `GET /api/v1/admin/observability/trace` · `GET /api/v1/admin/observability/audit` 已实现,返回真实数据;`GET /api/v1/admin/logs` 仍🕐未实现(前端按契约封装、后端 404 时显示"待实现"壳,不报错)。**写操作已补齐**(见下方「管理面板写操作契约」):人格/API key/设备控制/自动化/LLM 测试/渠道连接与识别测试/记忆检索/会话导出/插件卸载**真实持久化或真实生效**;**系统设置安全与存储类**(吊销 API key / 轮换 JWT 密钥 / 备份 / 恢复)也已**真实实现**(见下方「系统设置契约」);仍为后置 M10:**新增用户**、**IoT 场景与规则**、**插件安装(需源码清单)**、**主人档案保存**、**编辑环境变量/重启服务/检查更新**、**知识库删除**——后端如实返回 `501 not_implemented`,不伪造成功。
|
||||
|
||||
**管理面板写操作契约**(`POST/PUT/DELETE /api/v1/admin/*`,均 **owner-only**,读为 owner/admin;写操作经 audit 落 `audit_logs`;成功 `{data}` / 失败 `{error:{code,message}}`):
|
||||
- `POST /admin/personas` body `{id,name,role,systemPrompt?,characterRef?,modes?/mode?,memoryNamespace?}` → `201 {data:{id,name,character,isDefault,mode,memoryNamespace}}`(注册进 persona registry + 持久化到 `configs/personas/<id>.yaml`);id 已存在回 `409 duplicate`。
|
||||
@@ -80,6 +84,12 @@
|
||||
- `POST /admin/memories/search` body `{query,personaId?,topK?}` → `200 {data:{query,personaId,semantic,hits:[{id,scope,text,score}]}}`。真实检索(keyword×衰减);**语义/向量路径**需主会话注入 vector index(`semantic` 当前恒 `false`,诚实反映未接向量器)。
|
||||
- `GET /admin/sessions/{id}/export` → `200 {data:{session:{platform,channelId,sessionId,actorRole,owner,isGroup,memWritable}}}`;`404`。当前导出会话身份元数据(完整消息转录由 M10 提供)。
|
||||
|
||||
**系统设置契约**(`POST /api/v1/admin/system/*`,均 **owner-only**,审计;成功 `{data}` / 失败 `{error:{code,message}}`):
|
||||
- `POST /system/revoke-keys`(无 body)→ `200 {data:{revoked:n}}`。**吊销全部 API key**:清空 key 表(先前签发的所有 key 立即失效),`revoked` 为吊销数量;未注入 API key 服务回 `500 users_unavailable`。
|
||||
- `POST /system/rotate-jwt` body `{newSecret}` → `200 {data:{rotated:true,note:"现有 token 已全部失效,需重新登录"}}`。**轮换 JWT 密钥**:`newSecret` ≥32 位,替换当前签名密钥,**先前 token 全部失效**;`newSecret` 缺失/空白回 `400`;长度 <32 回 `400`;认证服务未配置回 `500 auth_unavailable`。**新密钥永不回显、不落日志**。
|
||||
- `POST /system/backup`(无 body)→ `200 {data:{path,size}}`。**备份数据**:经 SQLite `VACUUM INTO`(含已提交 WAL 内容)快照到 `<dataDir>/backup-<ts>.db`,`path` 为备份文件绝对路径、`size` 为字节数;数据库路径未配置回 `500 backup_unavailable`、快照失败回 `500 backup_failed`。
|
||||
- `POST /system/restore` body `{path}` → `200 {data:{restored:true,backup,note:"已安排恢复,需重启服务后生效"}}`。**恢复数据**:`path` 须为 **dataDir 内**的备份文件(**路径穿越防护**——`filepath.Rel` 校验,`..` 逃逸回 `400`);文件不存在回 `404`;**先自动备份当前数据库**(`backup` 为快照路径)再替换 DB 文件;运行进程持有 DB 句柄,**替换后需重启服务生效**;数据库路径未配置回 `500 backup_unavailable`、替换失败回 `500 restore_failed`。
|
||||
|
||||
**知识库 RAG 契约**:
|
||||
- `GET /admin/rag` → `{"data":{"documents":[{id,name,desc?,chunks,embedded}],"hits"?:[{docId,docName,chapter?,text,score,semantic}]}}`。`hits` 仅在携带 `?query=` 时出现;`embedded` 反映部署是否配置向量增强(未配置为待嵌入)。
|
||||
- `POST /admin/rag` body `{"name","desc?","content"}` → `201 {"data":{"name"}}`。**导入文档**:后端分块(空行分段,超长段按固定长度切)并落项目索引(项目+章节),立即可被关键词/行上下文检索;未注入 `RagStore` 回 `501`。语义/向量嵌入为优雅降级(知识库 Store 不直属向量索引,导入文档走 keyword/line 路径;语义检索仍对既有 project-linked 记忆生效)。
|
||||
@@ -161,6 +171,7 @@
|
||||
- 语音:上行 `voice_input`(base64 音频或已转写 text),下行 `voice_output`(合成音频 base64 + text)。
|
||||
|
||||
## 5. 变更记录
|
||||
- `v0.17`(系统设置安全与存储:吊销全部 API key / 轮换 JWT 密钥 / 备份 / 恢复,design/11 §4 + design/10 §3 模块14):新增 `POST /api/v1/admin/system/{revoke-keys,rotate-jwt,backup,restore}`(均 **owner-only** + 审计)。`revoke-keys` 走 `auth.APIKey.RevokeAll()`(清空 key 表,返回 `{revoked:n}`);`rotate-jwt` 走 `auth.JWTService.RotateSecret`(≥32 位,**新密钥不回显/不落日志**,现有 token 立即失效);`backup` 用 SQLite `VACUUM INTO` 快照到 `<dataDir>/backup-<ts>.db`(含已提交 WAL 内容)返回 `{path,size}`;`restore` 先校验路径在 dataDir 内(**`filepath.Rel` 防路径穿越**,逃逸回 `400`)→ **先自动备份当前 DB** → 替换 DB 文件 → 返回 `{restored:true,backup,note:"需重启后生效"}`(运行进程持有 DB 句柄,热替换需重启可见)。gateway 新增 `Deps.DatabasePath`(composition root 传入实际打开的 SQLite 路径)。`编辑环境变量 / 重启服务 / 检查更新` 仍为后置,未实现、不伪造。
|
||||
- `v0.16`(运行时配置体系:渠道适配器配置落盘 + 热更新,design/04 §3.1):`PUT /api/v1/admin/channels/{platform}/config`(**新增**,owner-only + 审计)body `{id?,token?,baseURL?,remoteURL?,listenAddr?,mode?,accessToken?,selfID?,config?,allowGroups?,denyGroups?,allowUsers?,denyUsers?,groupMsgPerMin?,msgPerMin?,maxMessages?}` → `201 {persisted:true,path,platform,accountId}`——读当前有效配置 → `UpsertChannel` → 落盘 `channels.json`(本地、gitignore、0600)→ `bridge.Reload(platform)` **热替换运行中 adapter**(进行中消息用旧、新消息用新),无需重启;token 写入文件但**不回显/不落日志**。`DELETE /api/v1/admin/channels/{platform}/config?id=`(**新增**)删除账号并热更新。`GET /api/v1/admin/channels` 每条渠道附 `accounts`(每平台已配账号,token 脱敏掩码)。`.env`(`CHANNELS_JSON`/`CHANNEL_*_TOKEN`)退为兜底。
|
||||
- `v0.15`(运行时配置体系:LLM 多厂商多模型独立组落盘 + 热更新,design/04 §3.1):`GET /api/v1/admin/llm` 返回 **models.json 的全部组**(`provider` 协议字段新增,index 0 = 生效组;APIKey 仍脱敏不回显);`POST /api/v1/admin/llm` 由「临时记录」改为**真实持久化 + 热更新**(body `{id?,provider,baseUrl,apiKey?,model,multimodal?,fallback?}` → `201 {provider,persisted:true,path}`,读-改-写 `models.json`、保存组提升为生效组并热重载运行中的 LLM stack,无需重启)。`.env` 退为兜底(无 `models.json` 时按 `LLM_PROVIDER`/`LLM_*`/`ANTHROPIC_*` 合成单组)。
|
||||
- `v0.14`(IM 多会话隔离 + 第三方身份 + 黑白名单,design/16 §4.6):**`chat_messages` 增 `sender_name` 列**(迁移 0006,默认 `''`);消息 DTO(REST 消息、WS `history` 回放、WS `message_persisted`)均带 **`senderName`**(第三方发送者昵称,主人私聊为空 `omitempty`)。**第三方身份边界**:IM 群聊/陌生人会话在上下文构建时对第三方消息注入 `[第三方 · <昵称>]` 前缀(**不改源消息正文**,落库保留原文)+ 前置系统提示词"每个 IM 的非管理员账户 = 第三方人员,和主人无关"——身份判定基于 channel 层 actor 元数据 + 白名单(`MemWritable=false` 即群聊/陌生人),**绝不靠 LLM 猜**。**会话隔离**:群聊/陌生人消息**落独立隔离会话**(`sess-xxx`,带 `platform`+`senderName`,**不**并入主会话 `web`、**不**走 `message_persisted` 广播),供 **`GET /api/v1/admin/channels/{platform}/messages`**(owner/admin,management 面板渠道日志区)以消息流显示;**前端主会话**只显示各平台管理员(owner)私聊;群聊/陌生人记忆只读不写主会话(`memory_gating` 已按 `MemWritable` 门控)。**多账号**:`CHANNELS_JSON` 支持同一平台多个接入配置(每个 account 独立 session/账号,见 §2 渠道契约)。**黑白名单**:每平台每账号可配 `allowGroups/denyGroups/allowUsers/denyUsers`,channelbridge **入站即拦截**(黑名单/不在白名单 → 丢弃,`audit` 可查,**不进 LLM**);白名单按 `platform+account`(群 id / 用户 uid)。
|
||||
|
||||
+30
-27
@@ -76,6 +76,7 @@ type app struct {
|
||||
cfg *config.Config
|
||||
log *slog.Logger
|
||||
db *sql.DB
|
||||
dbPath string
|
||||
bus bus.Bus
|
||||
modelAdapter model.Adapter
|
||||
llm *model.RuntimeLLM
|
||||
@@ -138,11 +139,12 @@ func newApp(ctx context.Context, cfg *config.Config, log *slog.Logger) (*app, er
|
||||
|
||||
eventBus := bus.New()
|
||||
a := &app{
|
||||
cfg: cfg,
|
||||
log: log,
|
||||
db: sqlDB,
|
||||
bus: eventBus,
|
||||
caps: registry.New(),
|
||||
cfg: cfg,
|
||||
log: log,
|
||||
db: sqlDB,
|
||||
dbPath: databasePath,
|
||||
bus: eventBus,
|
||||
caps: registry.New(),
|
||||
}
|
||||
|
||||
// Release already-opened resources if any later wiring step fails, without
|
||||
@@ -598,28 +600,29 @@ func (a *app) wireFrontend() error {
|
||||
// and the gateway share the same persistent store. History was a no-op dependency
|
||||
// before the IM path, so nothing downstream depends on its creation order change.
|
||||
gw, err := gateway.New(a.cfg, gateway.Deps{
|
||||
Log: logger.WithSource(a.log, "gateway"),
|
||||
Auth: jwt,
|
||||
Bus: a.bus,
|
||||
Registry: a.caps,
|
||||
Loop: a.loop,
|
||||
Session: a.sessions,
|
||||
Personas: a.personas,
|
||||
Memories: a.memStore,
|
||||
History: a.history,
|
||||
IoT: a.iot,
|
||||
Scheduler: a.scheduler,
|
||||
Tasks: a.tasks,
|
||||
Automations: a.automation,
|
||||
Users: apiKeys,
|
||||
Backthink: a.backthink,
|
||||
RAG: ragStore,
|
||||
LLM: a.llm,
|
||||
Channels: a.channels,
|
||||
AuditStore: auditStore,
|
||||
Streaming: true,
|
||||
Frontend: resolveFrontendFS(a.log),
|
||||
Webhooks: a.channelBridge.WebhookHandlers(),
|
||||
Log: logger.WithSource(a.log, "gateway"),
|
||||
Auth: jwt,
|
||||
Bus: a.bus,
|
||||
Registry: a.caps,
|
||||
Loop: a.loop,
|
||||
Session: a.sessions,
|
||||
Personas: a.personas,
|
||||
Memories: a.memStore,
|
||||
History: a.history,
|
||||
IoT: a.iot,
|
||||
Scheduler: a.scheduler,
|
||||
Tasks: a.tasks,
|
||||
Automations: a.automation,
|
||||
Users: apiKeys,
|
||||
Backthink: a.backthink,
|
||||
RAG: ragStore,
|
||||
LLM: a.llm,
|
||||
Channels: a.channels,
|
||||
AuditStore: auditStore,
|
||||
Streaming: true,
|
||||
Frontend: resolveFrontendFS(a.log),
|
||||
Webhooks: a.channelBridge.WebhookHandlers(),
|
||||
DatabasePath: a.dbPath,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("gateway: %w", err)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { TopBar } from "./components/TopBar";
|
||||
import { Sidebar, MobileNav } from "./components/Nav";
|
||||
import { ModeRouter } from "./modes/ModeRouter";
|
||||
import { AdminApp } from "./admin/AdminApp";
|
||||
import { Login } from "./components/Login";
|
||||
import { parseAdminHash } from "./admin/router";
|
||||
import { connectWs, disconnectWs } from "./lib/ws";
|
||||
import { initTheme } from "./lib/theme";
|
||||
@@ -121,6 +122,14 @@ export default function App(): React.ReactNode {
|
||||
return <AdminApp section={route.section} />;
|
||||
}
|
||||
|
||||
// No credential: never render the chat shell (it would silently show an
|
||||
// "未登录" shell with no way in). Show the user-side login card instead; its
|
||||
// onSuccess reloads the page so connectWs + session/persona effects re-run
|
||||
// with the fresh token. Admin shell keeps its own LoginGate (#/admin).
|
||||
if (!getAuthToken()) {
|
||||
return <Login onSuccess={() => window.location.reload()} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<TopBar onMenu={() => setMenuOpen(true)} />
|
||||
|
||||
@@ -170,7 +170,7 @@ function LoginGate(): React.ReactNode {
|
||||
</div>
|
||||
<div className="text-lg font-semibold">需要登录</div>
|
||||
<p className="max-w-md text-sm text-[var(--mu-text-lo)]">
|
||||
管理面板需持有 JWT。请先到用户端 <span className="text-[var(--mu-text-hi)]">设置 → 连接</span> 登录获取令牌后,再返回本页。
|
||||
管理面板需持有 JWT。请先在用户端 <span className="text-[var(--mu-text-hi)]">登录页</span> 完成登录后,再返回本页。
|
||||
</p>
|
||||
<Button variant="primary" onClick={() => { window.location.hash = ""; }}>前往用户端登录</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,86 +1,54 @@
|
||||
import { useState } from "react";
|
||||
import { getStoredRole } from "../../lib/api";
|
||||
import { useAuditStore } from "../audit";
|
||||
import { KeyValueRow, Notice, PageHeader } from "../components";
|
||||
import { Button, Surface, Tag } from "../../components/primitives";
|
||||
import { Surface, Tag } from "../../components/primitives";
|
||||
import { Icon } from "../../components/Icon";
|
||||
|
||||
/**
|
||||
* 系统设置 (design/10)。后端 M10+ 才提供真实的密钥轮换/备份/环境变量写入等
|
||||
* 系统级操作;在此之前只呈现**诚实的只读配置事实**,并把尚未实现的操作合并成
|
||||
* 一条「未实现」说明——不做满屏点击后 `alert` 的占位按钮。
|
||||
*/
|
||||
export function SystemModule(): React.ReactNode {
|
||||
const isOwner = getStoredRole() === "owner";
|
||||
const log = useAuditStore((s) => s.log);
|
||||
const [logLevel, setLogLevel] = useState("info");
|
||||
|
||||
const sensitive = (what: string, note: string) => {
|
||||
if (!isOwner) {
|
||||
alert(`仅 owner 可执行:${what}。当前角色 ${getStoredRole()}。`);
|
||||
return;
|
||||
}
|
||||
log(`system.${what}`, "ok");
|
||||
alert(`${note}(占位):后端 M10+ 提供真实执行。`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<PageHeader title="系统设置" subtitle="环境变量 + 安全(密钥/JWT) + 存储 + 备份/恢复 + 重启/更新 + 日志级别" />
|
||||
<PageHeader title="系统设置" subtitle="安全(密钥/JWT) + 运行时 + 存储 + 环境变量(只读);可写系统操作见末条说明" />
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<Surface className="space-y-2">
|
||||
<Surface className="space-y-1">
|
||||
<SectionTitle icon={<Icon name="shield" size={18} />} title="安全 & 密钥" ownerOnly />
|
||||
<KeyValueRow label="JWT 密钥" value="••••••••" mono />
|
||||
<KeyValueRow label="鉴权" value="JWT + RBAC + API key" />
|
||||
<KeyValueRow label="审计" value="who/what/when/result" />
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button size="sm" variant="outline" disabled={!isOwner} onClick={() => sensitive("rotateJwt", "轮换 JWT 密钥")}>轮换 JWT 密钥</Button>
|
||||
<Button size="sm" variant="outline" disabled={!isOwner} onClick={() => sensitive("revokeAllKeys", "吊销全部 API key")}>吊销全部 API key</Button>
|
||||
</div>
|
||||
<KeyValueRow label="审计" value="who / what / when / result" />
|
||||
</Surface>
|
||||
|
||||
<Surface className="space-y-2">
|
||||
<Surface className="space-y-1">
|
||||
<SectionTitle icon={<Icon name="storage" size={18} />} title="存储" />
|
||||
<KeyValueRow label="默认" value="SQLite" />
|
||||
<KeyValueRow label="可选增强" value="PG / pgvector(语义/向量路径)" />
|
||||
<KeyValueRow label="单二进制 / 单端口" value=":8080 · 零跨进程 IPC" />
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button size="sm" variant="outline" disabled={!isOwner} onClick={() => sensitive("backup", "备份数据")}>备份</Button>
|
||||
<Button size="sm" variant="outline" disabled={!isOwner} onClick={() => sensitive("restore", "恢复数据")}>恢复</Button>
|
||||
</div>
|
||||
</Surface>
|
||||
|
||||
<Surface className="space-y-2">
|
||||
<Surface className="space-y-1">
|
||||
<SectionTitle icon={<Icon name="package" size={18} />} title="环境变量" />
|
||||
<KeyValueRow label="SERVER_HOST" value="0.0.0.0" mono />
|
||||
<KeyValueRow label="SERVER_PORT" value="8080" mono />
|
||||
<KeyValueRow label="AUTH_ADMIN_USER/PASS" value="••••••••" mono />
|
||||
<div className="pt-2">
|
||||
<Button size="sm" variant="outline" disabled={!isOwner} onClick={() => sensitive("editEnv", "编辑环境变量")}>编辑环境变量</Button>
|
||||
</div>
|
||||
</Surface>
|
||||
|
||||
<Surface className="space-y-2">
|
||||
<Surface className="space-y-1">
|
||||
<SectionTitle icon={<Icon name="refresh" size={18} />} title="运行时" />
|
||||
<div className="flex items-center justify-between py-1.5">
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">日志级别</span>
|
||||
<span className="flex gap-1.5">
|
||||
{["debug", "info", "warn", "error"].map((lvl) => (
|
||||
<button
|
||||
key={lvl}
|
||||
onClick={() => setLogLevel(lvl)}
|
||||
className={`rounded-[var(--mu-radius)] px-2 py-1 text-xs ${
|
||||
logLevel === lvl ? "bg-[var(--mu-accent)]/20 text-[var(--mu-accent-hi)]" : "bg-[var(--mu-surface-4)] text-[var(--mu-text-mid)]"
|
||||
}`}
|
||||
>
|
||||
{lvl}
|
||||
</button>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
<Button size="sm" variant="outline" disabled={!isOwner} onClick={() => sensitive("restart", "重启服务")}>重启服务</Button>
|
||||
<Button size="sm" variant="outline" disabled={!isOwner} onClick={() => sensitive("update", "检查/安装更新")}>检查更新</Button>
|
||||
</div>
|
||||
<KeyValueRow label="部署形态" value="单二进制 · 单端口 · 零跨进程 IPC" />
|
||||
<KeyValueRow label="日志级别" value="后端配置,面板暂不可改(M10+)" />
|
||||
</Surface>
|
||||
</div>
|
||||
|
||||
<Notice tone="warn">
|
||||
以下系统操作后端暂未实现(M10+ 落地),当前不可用:轮换 JWT 密钥 · 吊销全部 API
|
||||
key · 备份/恢复 · 编辑环境变量 · 重启服务 · 检查更新。均为真实读写,未实现前不做占位。
|
||||
</Notice>
|
||||
|
||||
{!isOwner ? <Notice tone="warn">安全 / 密钥 / 系统设置类敏感操作仅 owner 可执行;当前角色 {getStoredRole()}。</Notice> : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useState } from "react";
|
||||
import type { FormEvent, ReactNode } from "react";
|
||||
import { ApiError, getStoredRole, login, setAuthToken } from "../lib/api";
|
||||
import type { Role } from "../lib/types";
|
||||
import { Button, Spinner, Surface, Tag } from "./primitives";
|
||||
import { Icon } from "./Icon";
|
||||
|
||||
/** localStorage key for the optional "记住用户名" toggle. */
|
||||
const USERNAME_KEY = "cyrene.username";
|
||||
|
||||
export interface LoginProps {
|
||||
/** Called after a successful sign-in; the parent decides how to reflect the
|
||||
* new session (typically reload so connectWs + session/persona fetch re-run
|
||||
* with the fresh credential). */
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
function roleTone(role: Role): "accent" | "ok" | "neutral" {
|
||||
return role === "owner" ? "accent" : role === "admin" ? "ok" : "neutral";
|
||||
}
|
||||
|
||||
/** Map a login failure to a user-safe message. Never echoes credentials, and
|
||||
* collapses server detail into generic wording so nothing sensitive leaks. */
|
||||
function toLoginMessage(err: unknown): string {
|
||||
if (err instanceof ApiError) {
|
||||
if (err.status === 401 || err.code === "unauthorized") return "用户名或密码错误";
|
||||
if (err.code === "network_error" || err.status === 0) return "网络连接失败,请确认服务已启动";
|
||||
return err.message || "登录失败";
|
||||
}
|
||||
return "登录失败";
|
||||
}
|
||||
|
||||
const inputCls =
|
||||
"w-full rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm text-[var(--mu-text-hi)] outline-none transition-all duration-200 focus:ring-1 focus:ring-[var(--mu-accent)] placeholder:text-[var(--mu-text-lo)]";
|
||||
|
||||
/**
|
||||
* Miuix user-facing login card. Rendered by App when no JWT is present, so the
|
||||
* user shell (TopBar/Sidebar/chat) stays unreachable until authenticated. It
|
||||
* carries no routing dependency — App owns what happens after onSuccess.
|
||||
*/
|
||||
export function Login({ onSuccess }: LoginProps): ReactNode {
|
||||
const [username, setUsername] = useState(() => localStorage.getItem(USERNAME_KEY) ?? "");
|
||||
const [password, setPassword] = useState("");
|
||||
const [remember, setRemember] = useState(() => localStorage.getItem(USERNAME_KEY) !== null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const role = getStoredRole();
|
||||
|
||||
const submit = async (e: FormEvent): Promise<void> => {
|
||||
e.preventDefault();
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await login(username.trim(), password);
|
||||
// Persist the JWT (and decoded role) immediately so App's next render sees
|
||||
// a real credential and proceeds to the main shell.
|
||||
setAuthToken(res.token);
|
||||
if (remember) localStorage.setItem(USERNAME_KEY, username.trim());
|
||||
else localStorage.removeItem(USERNAME_KEY);
|
||||
setPassword(""); // never keep the secret in component state after submit
|
||||
onSuccess();
|
||||
} catch (err: unknown) {
|
||||
setError(toLoginMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-6">
|
||||
<Surface className="w-full max-w-sm">
|
||||
<div className="flex flex-col items-center gap-1 pb-6 text-center">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-[var(--mu-radius-lg)] bg-[var(--mu-surface-4)] text-[var(--mu-accent)]">
|
||||
<Icon name="lock" size={28} />
|
||||
</div>
|
||||
<div className="text-lg font-semibold">登录 Cyrene</div>
|
||||
<div className="text-xs text-[var(--mu-text-lo)]">输入账号凭证以继续使用用户端</div>
|
||||
{role ? (
|
||||
<Tag tone={roleTone(role)} className="mt-1">
|
||||
{role}
|
||||
</Tag>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<form className="flex flex-col gap-3" onSubmit={submit}>
|
||||
<label className="block space-y-1">
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">用户名</span>
|
||||
<input
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete="username"
|
||||
placeholder="用户名"
|
||||
disabled={busy}
|
||||
className={inputCls}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block space-y-1">
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">密码</span>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
placeholder="密码"
|
||||
disabled={busy}
|
||||
className={inputCls}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex cursor-pointer select-none items-center gap-2 text-sm text-[var(--mu-text-mid)]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={remember}
|
||||
onChange={(e) => setRemember(e.target.checked)}
|
||||
disabled={busy}
|
||||
className="h-4 w-4 accent-[var(--mu-accent)]"
|
||||
/>
|
||||
记住用户名
|
||||
</label>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-[var(--mu-radius)] bg-[var(--mu-err)]/15 px-3 py-2 text-xs text-[var(--mu-err)]">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Button type="submit" variant="primary" disabled={busy} className="mt-1 w-full">
|
||||
{busy ? <Spinner size={16} label="登录中…" /> : "登录"}
|
||||
</Button>
|
||||
</form>
|
||||
</Surface>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -27,6 +27,7 @@ function NavContent({ onNavigate }: { onNavigate: () => void }): React.ReactNode
|
||||
const [theme, setTheme] = useState<ThemeMode>(readTheme);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const connected = wsStatus === "connected";
|
||||
const wsLabel = connected ? "实时连接" : wsStatus === "expired" ? "登录过期" : "未连接";
|
||||
const hasToken = !!getAuthToken();
|
||||
|
||||
const toggleTheme = () => {
|
||||
@@ -78,7 +79,7 @@ function NavContent({ onNavigate }: { onNavigate: () => void }): React.ReactNode
|
||||
<NavItem
|
||||
active={connected}
|
||||
icon="channel"
|
||||
label={connected ? "实时连接" : "未连接"}
|
||||
label={wsLabel}
|
||||
onClick={onNavigate}
|
||||
hint={wsStatus}
|
||||
/>
|
||||
|
||||
@@ -23,6 +23,7 @@ const wsMeta: Record<WsStatus, { label: string; tone: "ok" | "warn" | "neutral"
|
||||
connected: { label: "实时", tone: "ok" },
|
||||
reconnecting: { label: "重连中", tone: "warn" },
|
||||
disconnected: { label: "已断开", tone: "err" },
|
||||
expired: { label: "登录过期", tone: "err" },
|
||||
};
|
||||
|
||||
export function TopBar({ onMenu }: { onMenu: () => void }): React.ReactNode {
|
||||
@@ -71,7 +72,7 @@ export function TopBar({ onMenu }: { onMenu: () => void }): React.ReactNode {
|
||||
<AwakenBadge />
|
||||
|
||||
<Tag tone={meta.tone} className="hidden sm:inline-flex">
|
||||
<span className={`inline-block h-1.5 w-1.5 rounded-full ${wsStatus === "connected" ? "bg-[var(--mu-ok)]" : "mu-blink"}`} />
|
||||
<span className={`inline-block h-1.5 w-1.5 rounded-full ${wsStatus === "connected" ? "bg-[var(--mu-ok)]" : wsStatus === "expired" ? "bg-[var(--mu-err)]" : "mu-blink"}`} />
|
||||
{meta.label}
|
||||
</Tag>
|
||||
|
||||
|
||||
@@ -101,6 +101,38 @@ export function setStoredRole(role: Role | null): void {
|
||||
else localStorage.removeItem(ROLE_KEY);
|
||||
}
|
||||
|
||||
/** True when the JWT's `exp` (unix seconds) is already in the past. Used client-side
|
||||
* to recognise a handshake rejection as "token expired" before the browser ever
|
||||
* exposes the 401 (WS handshake failures surface only as a generic close). */
|
||||
export function isTokenExpired(token: string): boolean {
|
||||
const parts = token.split(".");
|
||||
if (parts.length < 2) return false;
|
||||
try {
|
||||
const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
||||
const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4);
|
||||
const payload = JSON.parse(atob(padded)) as { exp?: number };
|
||||
const exp = payload.exp;
|
||||
return typeof exp === "number" && exp > 0 && Math.floor(Date.now() / 1000) >= exp;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Subscribe to an "auth expired" event. The REST layer fires this exactly once
|
||||
* when a non-login request comes back 401/unauthorized, after already clearing
|
||||
* the stored token; the WS layer subscribes to stop reconnecting + flag the UI. */
|
||||
type AuthExpiredHandler = () => void;
|
||||
const authExpiredHandlers = new Set<AuthExpiredHandler>();
|
||||
export function onAuthExpired(handler: AuthExpiredHandler): () => void {
|
||||
authExpiredHandlers.add(handler);
|
||||
return () => {
|
||||
authExpiredHandlers.delete(handler);
|
||||
};
|
||||
}
|
||||
function fireAuthExpired(): void {
|
||||
authExpiredHandlers.forEach((h) => h());
|
||||
}
|
||||
|
||||
/** Optional API key for admin endpoints (X-API-Key). */
|
||||
export function getApiKey(): string | null {
|
||||
return localStorage.getItem(API_KEY_KEY);
|
||||
@@ -125,7 +157,8 @@ export interface ChannelConfigSaveResult {
|
||||
|
||||
async function request<T>(
|
||||
path: string,
|
||||
init: RequestInit = {}
|
||||
init: RequestInit = {},
|
||||
opts: { skipAuthExpired?: boolean } = {}
|
||||
): Promise<T> {
|
||||
const headers = new Headers(init.headers);
|
||||
headers.set("Content-Type", "application/json");
|
||||
@@ -151,9 +184,19 @@ async function request<T>(
|
||||
}
|
||||
|
||||
if (!res.ok || body.error) {
|
||||
const code = body.error?.code ?? (res.status === 401 ? "unauthorized" : "unknown");
|
||||
// A 401/unauthorized on a logged-in (non-login) call means the stored token is
|
||||
// gone/expired: clear it and notify the WS layer so it stops reconnecting, then
|
||||
// throw a dedicated code so the UI can prompt re-login. The login call itself is
|
||||
// exempt — a bad password is a per-request failure, not a stale session.
|
||||
if (!opts.skipAuthExpired && (res.status === 401 || code === "unauthorized")) {
|
||||
setAuthToken(null);
|
||||
fireAuthExpired();
|
||||
throw new ApiError(res.status, "auth_expired", "登录已过期,请重新登录");
|
||||
}
|
||||
throw new ApiError(
|
||||
res.status,
|
||||
body.error?.code ?? "unknown",
|
||||
code,
|
||||
body.error?.message ?? `请求失败(${res.status})`
|
||||
);
|
||||
}
|
||||
@@ -162,10 +205,14 @@ async function request<T>(
|
||||
|
||||
/** POST /api/v1/auth/login — exchanges user/password for a JWT. */
|
||||
export function login(username: string, password: string): Promise<LoginResult> {
|
||||
return request<LoginResult>("/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
return request<LoginResult>(
|
||||
"/auth/login",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username, password }),
|
||||
},
|
||||
{ skipAuthExpired: true }
|
||||
);
|
||||
}
|
||||
|
||||
/** GET /api/v1/health — liveness probe. */
|
||||
|
||||
@@ -30,7 +30,8 @@ export type WsStatus =
|
||||
| "connecting"
|
||||
| "connected"
|
||||
| "disconnected"
|
||||
| "reconnecting";
|
||||
| "reconnecting"
|
||||
| "expired";
|
||||
|
||||
export interface ToolUsed {
|
||||
name: string;
|
||||
|
||||
+61
-154
@@ -1,8 +1,17 @@
|
||||
import { useAppStore } from "../store/useAppStore";
|
||||
import { useModeStore } from "../store/useModeStore";
|
||||
import { getAuthToken } from "./api";
|
||||
import { MODES, type AgentInfo, type AwakenState, type ChatMessage, type FlowGraph, type Mode, type ToolUsed, type WsUpEvent } from "./types";
|
||||
import { now, uid } from "./util";
|
||||
import { getAuthToken, isTokenExpired, onAuthExpired, setAuthToken } from "./api";
|
||||
import {
|
||||
parseAgent,
|
||||
parseAwakenTool,
|
||||
parseGraph,
|
||||
parsePersistedMessage,
|
||||
parseTool,
|
||||
parseToolCall,
|
||||
playVoiceAudio,
|
||||
toRecord,
|
||||
} from "./wsParse";
|
||||
import { MODES, type AwakenState, type Mode, type WsUpEvent } from "./types";
|
||||
|
||||
/**
|
||||
* Realtime client for `/ws/chat`. Reconnects with exponential backoff and
|
||||
@@ -30,157 +39,6 @@ function wsUrl(): string {
|
||||
return `${proto}://${window.location.host}/ws/chat${qs}`;
|
||||
}
|
||||
|
||||
function toRecord(v: unknown): Record<string, unknown> | null {
|
||||
if (v !== null && typeof v === "object" && !Array.isArray(v)) {
|
||||
return v as Record<string, unknown>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Decode a base64 string into bytes (used for the voice audio reply). */
|
||||
function decodeBase64(b64: string): Uint8Array {
|
||||
const bin = atob(b64);
|
||||
const bytes = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/** Best-effort playback of a voice_output audio payload (blocked by autoplay
|
||||
* policy, so sound may be silent until the user interacts). */
|
||||
function playVoiceAudio(payload: unknown): void {
|
||||
const r = toRecord(payload);
|
||||
if (!r || typeof r.audio !== "string" || !r.audio) return;
|
||||
try {
|
||||
const bytes = decodeBase64(r.audio);
|
||||
const format = typeof r.format === "string" && r.format ? r.format : "wav";
|
||||
const buf = new ArrayBuffer(bytes.byteLength);
|
||||
new Uint8Array(buf).set(bytes);
|
||||
const blob = new Blob([buf], { type: `audio/${format}` });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const audio = new Audio(url);
|
||||
void audio.play().catch(() => {});
|
||||
audio.onended = () => URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
// Ignore malformed audio payloads.
|
||||
}
|
||||
}
|
||||
|
||||
function parseTool(v: unknown): ToolUsed | null {
|
||||
const r = toRecord(v);
|
||||
if (!r) return null;
|
||||
if (typeof r.name !== "string") return null;
|
||||
return {
|
||||
name: r.name,
|
||||
summary: typeof r.summary === "string" ? r.summary : undefined,
|
||||
detail: typeof r.detail === "string" ? r.detail : undefined,
|
||||
ok: typeof r.ok === "boolean" ? r.ok : undefined,
|
||||
at: now(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Normalize a capacity_awaken `tool` field into a tool-name string. The backend
|
||||
* may send a bare name or a `{name}` object; both reduce to the name. */
|
||||
function parseAwakenTool(v: unknown): string | undefined {
|
||||
if (typeof v === "string") return v || undefined;
|
||||
const r = toRecord(v);
|
||||
if (r && typeof r.name === "string" && r.name) return r.name;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Build a tool card from a tool_call.* payload. Unlike the legacy tool_used
|
||||
* frame these carry `id` (provider block id) rather than a guaranteed `name`,
|
||||
* so we label the card by id when no name is present. */
|
||||
function parseToolCall(v: unknown): ToolUsed | null {
|
||||
const r = toRecord(v);
|
||||
if (!r) return null;
|
||||
const name = typeof r.name === "string" ? r.name : "";
|
||||
const id = typeof r.id === "string" ? r.id : "";
|
||||
if (!name && !id) return null;
|
||||
return {
|
||||
name: name || `tool_${id.slice(0, 4)}`,
|
||||
summary: typeof r.summary === "string" ? r.summary : undefined,
|
||||
detail: typeof r.detail === "string" ? r.detail : undefined,
|
||||
ok: typeof r.ok === "boolean" ? r.ok : undefined,
|
||||
at: now(),
|
||||
};
|
||||
}
|
||||
|
||||
function parseAgent(payload: unknown): AgentInfo | null {
|
||||
const r = toRecord(payload);
|
||||
if (!r) return null;
|
||||
const id = typeof r.id === "string" ? r.id : uid();
|
||||
const status =
|
||||
typeof r.status === "string"
|
||||
? (r.status as AgentInfo["status"])
|
||||
: typeof r.state === "string"
|
||||
? (r.state as AgentInfo["status"])
|
||||
: "running";
|
||||
const tool = parseTool(r.tool);
|
||||
return {
|
||||
id,
|
||||
name: typeof r.name === "string" ? r.name : `子代理 ${id.slice(0, 4)}`,
|
||||
role: typeof r.role === "string" ? r.role : undefined,
|
||||
mode: typeof r.mode === "string" ? r.mode : undefined,
|
||||
status,
|
||||
progress: typeof r.progress === "number" ? r.progress : undefined,
|
||||
thinking: typeof r.thinking === "string" ? r.thinking : undefined,
|
||||
message: typeof r.message === "string" ? r.message : undefined,
|
||||
tools: tool ? [tool] : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function parseGraph(payload: unknown): FlowGraph | null {
|
||||
const r = toRecord(payload);
|
||||
if (!r || !Array.isArray(r.nodes)) return null;
|
||||
const nodes = r.nodes
|
||||
.map((n) => toRecord(n))
|
||||
.filter((n): n is Record<string, unknown> => n !== null)
|
||||
.map((n) => ({
|
||||
id: typeof n.id === "string" ? n.id : uid(),
|
||||
label: typeof n.label === "string" ? n.label : "节点",
|
||||
kind: typeof n.kind === "string" ? n.kind : undefined,
|
||||
status:
|
||||
typeof n.status === "string"
|
||||
? (n.status as FlowGraph["nodes"][number]["status"])
|
||||
: undefined,
|
||||
}));
|
||||
const edges = Array.isArray(r.edges)
|
||||
? r.edges
|
||||
.map((e) => toRecord(e))
|
||||
.filter((e): e is Record<string, unknown> => e !== null)
|
||||
.map((e) => ({
|
||||
from: typeof e.from === "string" ? e.from : "",
|
||||
to: typeof e.to === "string" ? e.to : "",
|
||||
}))
|
||||
: [];
|
||||
return { id: typeof r.id === "string" ? r.id : "graph", nodes, edges };
|
||||
}
|
||||
|
||||
/** Build a ChatMessage from a `message_persisted` payload (design/16 §3.3/§4.2):
|
||||
* the backend broadcast a fully-persisted message carrying the backend-generated
|
||||
* id. mode is validated against the known modes so a bad value never reaches the
|
||||
* UI; createdAt falls back to now when the wire omits it. */
|
||||
function parsePersistedMessage(v: unknown): ChatMessage | null {
|
||||
const r = toRecord(v);
|
||||
if (!r) return null;
|
||||
const id = typeof r.id === "string" && r.id ? r.id : "";
|
||||
const role = r.role;
|
||||
if (!id || (role !== "user" && role !== "assistant" && role !== "system")) return null;
|
||||
return {
|
||||
id,
|
||||
sessionId: typeof r.sessionId === "string" && r.sessionId ? r.sessionId : undefined,
|
||||
role,
|
||||
content: typeof r.content === "string" ? r.content : "",
|
||||
mode: typeof r.mode === "string" && MODES.includes(r.mode as Mode) ? (r.mode as Mode) : undefined,
|
||||
mood: typeof r.mood === "string" ? r.mood : undefined,
|
||||
image: typeof r.image === "string" ? r.image : undefined,
|
||||
platform: typeof r.platform === "string" && r.platform ? r.platform : undefined,
|
||||
deviceId: typeof r.deviceId === "string" && r.deviceId ? r.deviceId : undefined,
|
||||
senderName: typeof r.senderName === "string" && r.senderName ? r.senderName : undefined,
|
||||
createdAt: typeof r.createdAt === "number" ? r.createdAt : now(),
|
||||
};
|
||||
}
|
||||
|
||||
function dispatch(ev: { type: string; data: unknown }): void {
|
||||
const s = useAppStore.getState();
|
||||
const r = toRecord(ev.data);
|
||||
@@ -374,6 +232,13 @@ function dispatch(ev: { type: string; data: unknown }): void {
|
||||
function scheduleReconnect(): void {
|
||||
if (!shouldRun) return;
|
||||
if (reconnectTimer !== null) return;
|
||||
// Only a live, non-expired credential may retry; otherwise this isn't a network
|
||||
// blip but a dead session we should surface, not loop on.
|
||||
const token = getAuthToken();
|
||||
if (!token || isTokenExpired(token)) {
|
||||
markAuthExpired();
|
||||
return;
|
||||
}
|
||||
const delay = Math.min(BASE_BACKOFF * 2 ** attempt, MAX_BACKOFF);
|
||||
attempt += 1;
|
||||
useAppStore.getState().setWsStatus("reconnecting");
|
||||
@@ -385,6 +250,13 @@ function scheduleReconnect(): void {
|
||||
|
||||
function openSocket(): void {
|
||||
const s = useAppStore.getState();
|
||||
// A locally-expired token means the handshake will be rejected with 401 before
|
||||
// it ever upgrades; don't even attempt it — flag the expiry instead.
|
||||
const token = getAuthToken();
|
||||
if (token && isTokenExpired(token)) {
|
||||
markAuthExpired();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
ws = new WebSocket(wsUrl());
|
||||
} catch {
|
||||
@@ -393,9 +265,15 @@ function openSocket(): void {
|
||||
return;
|
||||
}
|
||||
|
||||
// Tracks whether this socket ever completed the handshake (reached OPEN). A
|
||||
// browser surfaces a handshake 401 only as onerror+onclose (code 1006, no HTTP
|
||||
// status), so the only way to tell "auth rejected" from "network blip" is to
|
||||
// compare the staged close against the token's own expiry.
|
||||
let opened = false;
|
||||
s.setWsStatus(attempt === 0 ? "connecting" : "reconnecting");
|
||||
|
||||
ws.onopen = () => {
|
||||
opened = true;
|
||||
attempt = 0;
|
||||
useAppStore.getState().setWsStatus("connected");
|
||||
// Sync the current mode on every (re)connect so the agent's shell matches.
|
||||
@@ -415,11 +293,23 @@ function openSocket(): void {
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
// Handshake/auth rejects land here first. An expired credential is not a
|
||||
// transient outage, so stop the loop and prompt re-login rather than looping.
|
||||
const cur = getAuthToken();
|
||||
if (cur && isTokenExpired(cur)) {
|
||||
markAuthExpired();
|
||||
return;
|
||||
}
|
||||
useAppStore.getState().setWsStatus("disconnected");
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
ws = null;
|
||||
const cur = getAuthToken();
|
||||
if (!opened && cur && isTokenExpired(cur)) {
|
||||
markAuthExpired();
|
||||
return;
|
||||
}
|
||||
useAppStore.getState().setWsStatus("disconnected");
|
||||
scheduleReconnect();
|
||||
};
|
||||
@@ -450,6 +340,16 @@ export function disconnectWs(): void {
|
||||
useAppStore.getState().setWsStatus("idle");
|
||||
}
|
||||
|
||||
/** Terminal "auth expired" path: stop reconnecting, drop the stale token, and
|
||||
* flag the UI so it prompts re-login instead of burning backoff on a dead
|
||||
* credential. Fired from (a) a handshake reject on an expired token and (b) the
|
||||
* REST 401 handler (via onAuthExpired). */
|
||||
function markAuthExpired(): void {
|
||||
setAuthToken(null);
|
||||
disconnectWs();
|
||||
useAppStore.getState().setWsStatus("expired");
|
||||
}
|
||||
|
||||
/** Send an upstream frame; returns false when the socket is not open. */
|
||||
export function sendUp(ev: WsUpEvent): boolean {
|
||||
const cur = ws;
|
||||
@@ -462,3 +362,10 @@ export function sendUp(ev: WsUpEvent): boolean {
|
||||
export function isWsOpen(): boolean {
|
||||
return ws?.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
// When the REST layer hits a 401/unauthorized on a logged-in call it clears the
|
||||
// token and fires this handler; mirror it into the same terminal expiry path so
|
||||
// the reconnect loop stops and the UI shows "登录过期" for both WS and REST 401s.
|
||||
onAuthExpired(() => {
|
||||
markAuthExpired();
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import { MODES, type AgentInfo, type ChatMessage, type FlowGraph, type Mode, type ToolUsed } from "./types";
|
||||
import { now, uid } from "./util";
|
||||
|
||||
/**
|
||||
* Pure frame decoders for the `/ws/chat` downstream stream (design/04 §1.1 +
|
||||
* design/16 §4). Each takes an `unknown` `data` payload, narrows it, and returns a
|
||||
* typed model (or `null` to drop the frame). None of these touch the store, so the
|
||||
* socket client (`ws.ts`) stays focused on connection lifecycle.
|
||||
*/
|
||||
|
||||
export function toRecord(v: unknown): Record<string, unknown> | null {
|
||||
if (v !== null && typeof v === "object" && !Array.isArray(v)) {
|
||||
return v as Record<string, unknown>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Decode a base64 string into bytes (used for the voice audio reply). */
|
||||
function decodeBase64(b64: string): Uint8Array {
|
||||
const bin = atob(b64);
|
||||
const bytes = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/** Best-effort playback of a voice_output audio payload (blocked by autoplay
|
||||
* policy, so sound may be silent until the user interacts). */
|
||||
export function playVoiceAudio(payload: unknown): void {
|
||||
const r = toRecord(payload);
|
||||
if (!r || typeof r.audio !== "string" || !r.audio) return;
|
||||
try {
|
||||
const bytes = decodeBase64(r.audio);
|
||||
const format = typeof r.format === "string" && r.format ? r.format : "wav";
|
||||
const buf = new ArrayBuffer(bytes.byteLength);
|
||||
new Uint8Array(buf).set(bytes);
|
||||
const blob = new Blob([buf], { type: `audio/${format}` });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const audio = new Audio(url);
|
||||
void audio.play().catch(() => {});
|
||||
audio.onended = () => URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
// Ignore malformed audio payloads.
|
||||
}
|
||||
}
|
||||
|
||||
export function parseTool(v: unknown): ToolUsed | null {
|
||||
const r = toRecord(v);
|
||||
if (!r) return null;
|
||||
if (typeof r.name !== "string") return null;
|
||||
return {
|
||||
name: r.name,
|
||||
summary: typeof r.summary === "string" ? r.summary : undefined,
|
||||
detail: typeof r.detail === "string" ? r.detail : undefined,
|
||||
ok: typeof r.ok === "boolean" ? r.ok : undefined,
|
||||
at: now(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Normalize a capacity_awaken `tool` field into a tool-name string. The backend
|
||||
* may send a bare name or a `{name}` object; both reduce to the name. */
|
||||
export function parseAwakenTool(v: unknown): string | undefined {
|
||||
if (typeof v === "string") return v || undefined;
|
||||
const r = toRecord(v);
|
||||
if (r && typeof r.name === "string" && r.name) return r.name;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Build a tool card from a tool_call.* payload. Unlike the legacy tool_used
|
||||
* frame these carry `id` (provider block id) rather than a guaranteed `name`,
|
||||
* so we label the card by id when no name is present. */
|
||||
export function parseToolCall(v: unknown): ToolUsed | null {
|
||||
const r = toRecord(v);
|
||||
if (!r) return null;
|
||||
const name = typeof r.name === "string" ? r.name : "";
|
||||
const id = typeof r.id === "string" ? r.id : "";
|
||||
if (!name && !id) return null;
|
||||
return {
|
||||
name: name || `tool_${id.slice(0, 4)}`,
|
||||
summary: typeof r.summary === "string" ? r.summary : undefined,
|
||||
detail: typeof r.detail === "string" ? r.detail : undefined,
|
||||
ok: typeof r.ok === "boolean" ? r.ok : undefined,
|
||||
at: now(),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseAgent(payload: unknown): AgentInfo | null {
|
||||
const r = toRecord(payload);
|
||||
if (!r) return null;
|
||||
const id = typeof r.id === "string" ? r.id : uid();
|
||||
const status =
|
||||
typeof r.status === "string"
|
||||
? (r.status as AgentInfo["status"])
|
||||
: typeof r.state === "string"
|
||||
? (r.state as AgentInfo["status"])
|
||||
: "running";
|
||||
const tool = parseTool(r.tool);
|
||||
return {
|
||||
id,
|
||||
name: typeof r.name === "string" ? r.name : `子代理 ${id.slice(0, 4)}`,
|
||||
role: typeof r.role === "string" ? r.role : undefined,
|
||||
mode: typeof r.mode === "string" ? r.mode : undefined,
|
||||
status,
|
||||
progress: typeof r.progress === "number" ? r.progress : undefined,
|
||||
thinking: typeof r.thinking === "string" ? r.thinking : undefined,
|
||||
message: typeof r.message === "string" ? r.message : undefined,
|
||||
tools: tool ? [tool] : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseGraph(payload: unknown): FlowGraph | null {
|
||||
const r = toRecord(payload);
|
||||
if (!r || !Array.isArray(r.nodes)) return null;
|
||||
const nodes = r.nodes
|
||||
.map((n) => toRecord(n))
|
||||
.filter((n): n is Record<string, unknown> => n !== null)
|
||||
.map((n) => ({
|
||||
id: typeof n.id === "string" ? n.id : uid(),
|
||||
label: typeof n.label === "string" ? n.label : "节点",
|
||||
kind: typeof n.kind === "string" ? n.kind : undefined,
|
||||
status:
|
||||
typeof n.status === "string"
|
||||
? (n.status as FlowGraph["nodes"][number]["status"])
|
||||
: undefined,
|
||||
}));
|
||||
const edges = Array.isArray(r.edges)
|
||||
? r.edges
|
||||
.map((e) => toRecord(e))
|
||||
.filter((e): e is Record<string, unknown> => e !== null)
|
||||
.map((e) => ({
|
||||
from: typeof e.from === "string" ? e.from : "",
|
||||
to: typeof e.to === "string" ? e.to : "",
|
||||
}))
|
||||
: [];
|
||||
return { id: typeof r.id === "string" ? r.id : "graph", nodes, edges };
|
||||
}
|
||||
|
||||
/** Build a ChatMessage from a `message_persisted` payload (design/16 §3.3/§4.2):
|
||||
* the backend broadcast a fully-persisted message carrying the backend-generated
|
||||
* id. mode is validated against the known modes so a bad value never reaches the
|
||||
* UI; createdAt falls back to now when the wire omits it. */
|
||||
export function parsePersistedMessage(v: unknown): ChatMessage | null {
|
||||
const r = toRecord(v);
|
||||
if (!r) return null;
|
||||
const id = typeof r.id === "string" && r.id ? r.id : "";
|
||||
const role = r.role;
|
||||
if (!id || (role !== "user" && role !== "assistant" && role !== "system")) return null;
|
||||
return {
|
||||
id,
|
||||
sessionId: typeof r.sessionId === "string" && r.sessionId ? r.sessionId : undefined,
|
||||
role,
|
||||
content: typeof r.content === "string" ? r.content : "",
|
||||
mode: typeof r.mode === "string" && MODES.includes(r.mode as Mode) ? (r.mode as Mode) : undefined,
|
||||
mood: typeof r.mood === "string" ? r.mood : undefined,
|
||||
image: typeof r.image === "string" ? r.image : undefined,
|
||||
platform: typeof r.platform === "string" && r.platform ? r.platform : undefined,
|
||||
deviceId: typeof r.deviceId === "string" && r.deviceId ? r.deviceId : undefined,
|
||||
senderName: typeof r.senderName === "string" && r.senderName ? r.senderName : undefined,
|
||||
createdAt: typeof r.createdAt === "number" ? r.createdAt : now(),
|
||||
};
|
||||
}
|
||||
@@ -72,6 +72,17 @@ func (k *APIKey) Revoke(key string) {
|
||||
delete(k.keys, key)
|
||||
}
|
||||
|
||||
// RevokeAll removes every currently registered API key and returns how many were
|
||||
// removed. It is the administrative "revoke all" operation: after a call the
|
||||
// table is empty, so no previously issued key can authenticate.
|
||||
func (k *APIKey) RevokeAll() int {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
n := len(k.keys)
|
||||
k.keys = make(map[string]Role)
|
||||
return n
|
||||
}
|
||||
|
||||
// RevokeByMask removes every key whose masked identifier (the same derived id
|
||||
// List returns) matches mask. It returns true when at least one key was revoked.
|
||||
// The mask is lossy, so it is matched by recomputing it for each key; the table
|
||||
|
||||
@@ -80,3 +80,28 @@ func TestAPIKey_Revoke(t *testing.T) {
|
||||
t.Error("Verify after revoke = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIKey_RevokeAll(t *testing.T) {
|
||||
t.Parallel()
|
||||
k := newTestAPIKey()
|
||||
keys := make([]string, 0, 3)
|
||||
for _, role := range []Role{RoleOwner, RoleAdmin, RoleViewer} {
|
||||
key, err := k.Generate(role)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate error: %v", err)
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
if got := k.RevokeAll(); got != 3 {
|
||||
t.Fatalf("RevokeAll() = %d, want 3", got)
|
||||
}
|
||||
for _, key := range keys {
|
||||
if k.Verify(key) {
|
||||
t.Errorf("Verify(%q) after RevokeAll = true, want false", key)
|
||||
}
|
||||
}
|
||||
// An empty table reports zero and is a safe no-op.
|
||||
if got := k.RevokeAll(); got != 0 {
|
||||
t.Fatalf("RevokeAll() on empty table = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
+26
-2
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
@@ -28,8 +29,11 @@ type jwtHeader struct {
|
||||
Typ string `json:"typ"`
|
||||
}
|
||||
|
||||
// JWTService signs and verifies HS256 JWTs using a shared secret.
|
||||
// JWTService signs and verifies HS256 JWTs using a shared secret. The secret is
|
||||
// protected by a RWMutex so RotateSecret can swap it while Sign/Verify read it
|
||||
// concurrently (the gateway rotates the secret live from the admin panel).
|
||||
type JWTService struct {
|
||||
mu sync.RWMutex
|
||||
secret []byte
|
||||
log *slog.Logger
|
||||
}
|
||||
@@ -76,10 +80,27 @@ func (s *JWTService) Sign(sub string, role Role, ttl time.Duration) (string, err
|
||||
return "", fmt.Errorf("auth: encode payload: %w", err)
|
||||
}
|
||||
signingInput := headerB64 + "." + payloadB64
|
||||
s.mu.RLock()
|
||||
sig := hmacSign(s.secret, []byte(signingInput))
|
||||
s.mu.RUnlock()
|
||||
return signingInput + "." + base64.RawURLEncoding.EncodeToString(sig), nil
|
||||
}
|
||||
|
||||
// RotateSecret replaces the signing secret with newSecret, invalidating every
|
||||
// token issued under the previous secret. It returns ErrInvalidSecret when
|
||||
// newSecret is shorter than the 32-character minimum. The new secret is never
|
||||
// logged or echoed back to the caller.
|
||||
func (s *JWTService) RotateSecret(newSecret string) error {
|
||||
if len(newSecret) < minSecretLen {
|
||||
return fmt.Errorf("%w: got %d chars", ErrInvalidSecret, len(newSecret))
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.secret = []byte(newSecret)
|
||||
s.mu.Unlock()
|
||||
s.log.Info("jwt secret rotated; previously issued tokens are now invalid")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Verify validates the token signature, algorithm, role, and expiry, returning
|
||||
// the parsed Claims on success.
|
||||
func (s *JWTService) Verify(token string) (*Claims, error) {
|
||||
@@ -94,7 +115,10 @@ func (s *JWTService) Verify(token string) (*Claims, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: decode signature: %v", ErrMalformedToken, err)
|
||||
}
|
||||
if !hmac.Equal(provided, hmacSign(s.secret, []byte(signingInput))) {
|
||||
s.mu.RLock()
|
||||
expected := hmacSign(s.secret, []byte(signingInput))
|
||||
s.mu.RUnlock()
|
||||
if !hmac.Equal(provided, expected) {
|
||||
return nil, ErrInvalidSignature
|
||||
}
|
||||
|
||||
|
||||
@@ -183,6 +183,49 @@ func TestJWTVerify_InvalidRoleClaim(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTService_RotateSecret(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := New(testConfig(strings.Repeat("a", 40)))
|
||||
token, err := svc.Sign("u", RoleOwner, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("Sign error: %v", err)
|
||||
}
|
||||
if _, err := svc.Verify(token); err != nil {
|
||||
t.Fatalf("Verify before rotation error: %v", err)
|
||||
}
|
||||
if err := svc.RotateSecret(strings.Repeat("b", 40)); err != nil {
|
||||
t.Fatalf("RotateSecret error: %v", err)
|
||||
}
|
||||
// The previously issued token no longer verifies under the new secret.
|
||||
if _, err := svc.Verify(token); !errors.Is(err, ErrInvalidSignature) {
|
||||
t.Fatalf("Verify after rotation err = %v, want ErrInvalidSignature", err)
|
||||
}
|
||||
// A token signed under the new secret verifies fine.
|
||||
newToken, err := svc.Sign("u", RoleOwner, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("Sign after rotation error: %v", err)
|
||||
}
|
||||
if _, err := svc.Verify(newToken); err != nil {
|
||||
t.Fatalf("Verify under new secret error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTService_RotateSecretShort(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := New(testConfig(strings.Repeat("a", 40)))
|
||||
if err := svc.RotateSecret("short"); !errors.Is(err, ErrInvalidSecret) {
|
||||
t.Fatalf("RotateSecret(short) err = %v, want ErrInvalidSecret", err)
|
||||
}
|
||||
// A rejected short secret leaves the previous secret intact.
|
||||
token, err := svc.Sign("u", RoleOwner, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("Sign error: %v", err)
|
||||
}
|
||||
if _, err := svc.Verify(token); err != nil {
|
||||
t.Fatalf("Verify after rejected rotation error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTVerify_Malformed(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := New(testConfig(""))
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
|
||||
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
|
||||
)
|
||||
|
||||
// System management admin writes (owner-only, audited; design/11 §4 敏感操作代审 +
|
||||
// design/10 §3 module 14 系统设置). Every handler audits a system.* action and never
|
||||
// echoes a JWT secret or a database path back to the client.
|
||||
|
||||
// adminRevokeAllKeys revokes every currently registered API key. It is the
|
||||
// `system.revoke_keys` admin write; the table is emptied so no prior key can
|
||||
// authenticate afterward.
|
||||
func (s *Server) adminRevokeAllKeys(w http.ResponseWriter, r *http.Request) {
|
||||
keys, ok := s.users.(*auth.APIKey)
|
||||
if !ok {
|
||||
s.auditClaims(r, "system.revoke_keys", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "users_unavailable", "用户服务未配置"))
|
||||
return
|
||||
}
|
||||
n := keys.RevokeAll()
|
||||
s.auditClaims(r, "system.revoke_keys", "ok")
|
||||
writeJSON(w, http.StatusOK, map[string]any{"revoked": n})
|
||||
}
|
||||
|
||||
// rotateJWTRequest is the POST /api/v1/admin/system/rotate-jwt body. The new
|
||||
// secret is accepted from the request so the owner can supply a fresh value; it
|
||||
// is never echoed back or logged.
|
||||
type rotateJWTRequest struct {
|
||||
NewSecret string `json:"newSecret"`
|
||||
}
|
||||
|
||||
// adminRotateJWT replaces the JWT signing secret, invalidating every token issued
|
||||
// under the old secret. It is the `system.rotate_jwt` admin write.
|
||||
func (s *Server) adminRotateJWT(w http.ResponseWriter, r *http.Request) {
|
||||
var req rotateJWTRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.NewSecret) == "" {
|
||||
s.auditClaims(r, "system.rotate_jwt", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "newSecret is required"))
|
||||
return
|
||||
}
|
||||
jwt, ok := s.auth.(*auth.JWTService)
|
||||
if !ok {
|
||||
s.auditClaims(r, "system.rotate_jwt", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "auth_unavailable", "认证服务未配置"))
|
||||
return
|
||||
}
|
||||
if err := jwt.RotateSecret(req.NewSecret); err != nil {
|
||||
s.auditClaims(r, "system.rotate_jwt", "error")
|
||||
if errors.Is(err, auth.ErrInvalidSecret) {
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "新密钥长度不足 32 位"))
|
||||
return
|
||||
}
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "rotate_failed", "failed to rotate jwt secret"))
|
||||
return
|
||||
}
|
||||
s.auditClaims(r, "system.rotate_jwt", "ok")
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"rotated": true,
|
||||
"note": "现有 token 已全部失效,需重新登录",
|
||||
})
|
||||
}
|
||||
|
||||
// adminBackup snapshots the SQLite database into the data directory using
|
||||
// `VACUUM INTO` (which includes committed WAL content), returning the backup path
|
||||
// and size. It is the `system.backup` admin write.
|
||||
func (s *Server) adminBackup(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.TrimSpace(s.dbPath) == "" {
|
||||
s.auditClaims(r, "system.backup", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "backup_unavailable", "数据库路径未配置"))
|
||||
return
|
||||
}
|
||||
path, size, err := s.backupDatabase(r.Context())
|
||||
if err != nil {
|
||||
s.auditClaims(r, "system.backup", "error")
|
||||
s.log.Error("backup", "err", err)
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "backup_failed", "备份失败"))
|
||||
return
|
||||
}
|
||||
s.auditClaims(r, "system.backup", "ok")
|
||||
writeJSON(w, http.StatusOK, map[string]any{"path": path, "size": size})
|
||||
}
|
||||
|
||||
// restoreRequest is the POST /api/v1/admin/system/restore body. Path is the
|
||||
// backup file to restore, expected to live inside the data directory.
|
||||
type restoreRequest struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
// adminRestore validates a backup path (must be inside the data directory — a
|
||||
// path-traversal guard), snapshots the current database first, then replaces the
|
||||
// live DB file. Because the running process holds the SQLite file handle open, the
|
||||
// replace takes effect on restart; it is reported as such. It is the
|
||||
// `system.restore` admin write.
|
||||
func (s *Server) adminRestore(w http.ResponseWriter, r *http.Request) {
|
||||
var req restoreRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(s.dbPath) == "" {
|
||||
s.auditClaims(r, "system.restore", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "backup_unavailable", "数据库路径未配置"))
|
||||
return
|
||||
}
|
||||
src := strings.TrimSpace(req.Path)
|
||||
if src == "" {
|
||||
s.auditClaims(r, "system.restore", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "path is required"))
|
||||
return
|
||||
}
|
||||
absSrc, ok := s.restorePathWithinDataDir(src)
|
||||
if !ok {
|
||||
s.auditClaims(r, "system.restore", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "恢复路径必须在数据目录内"))
|
||||
return
|
||||
}
|
||||
if _, err := os.Stat(absSrc); err != nil {
|
||||
s.auditClaims(r, "system.restore", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "备份文件不存在"))
|
||||
return
|
||||
}
|
||||
// Snapshot the current database before touching it, so an accidental restore
|
||||
// of the wrong file is recoverable.
|
||||
curPath, _, err := s.backupDatabase(r.Context())
|
||||
if err != nil {
|
||||
s.auditClaims(r, "system.restore", "error")
|
||||
s.log.Error("restore pre-backup", "err", err)
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "restore_failed", "恢复前备份失败"))
|
||||
return
|
||||
}
|
||||
if err := replaceDatabase(absSrc, s.dbPath); err != nil {
|
||||
s.auditClaims(r, "system.restore", "error")
|
||||
s.log.Error("restore replace", "err", err)
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "restore_failed", "替换数据库失败"))
|
||||
return
|
||||
}
|
||||
s.auditClaims(r, "system.restore", "ok")
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"restored": true,
|
||||
"backup": curPath,
|
||||
"note": "已安排恢复,需重启服务后生效",
|
||||
})
|
||||
}
|
||||
|
||||
// restorePathWithinDataDir resolves src to an absolute path and reports whether it
|
||||
// lives inside the data directory (the parent of s.dbPath). It is the path-
|
||||
// traversal guard for the restore endpoint: only paths under the data dir are
|
||||
// accepted.
|
||||
func (s *Server) restorePathWithinDataDir(src string) (string, bool) {
|
||||
dataDir, err := filepath.Abs(filepath.Dir(s.dbPath))
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
dataDir = filepath.Clean(dataDir)
|
||||
absSrc, err := filepath.Abs(src)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
absSrc = filepath.Clean(absSrc)
|
||||
rel, err := filepath.Rel(dataDir, absSrc)
|
||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return "", false
|
||||
}
|
||||
return absSrc, true
|
||||
}
|
||||
|
||||
// backupDatabase snapshots the live SQLite database into
|
||||
// <dataDir>/backup-<ts>.db via `VACUUM INTO`, which includes every committed WAL
|
||||
// transaction, and returns the new file's path and size. Opening a second
|
||||
// connection to the file-backed database is safe: SQLite serializes access, and
|
||||
// busy_timeout handles a momentarily locked source.
|
||||
func (s *Server) backupDatabase(ctx context.Context) (string, int64, error) {
|
||||
dir := filepath.Dir(s.dbPath)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "", 0, fmt.Errorf("create backup dir: %w", err)
|
||||
}
|
||||
dest := filepath.Join(dir, fmt.Sprintf("backup-%s.db", time.Now().UTC().Format("20060102T150405.000000000Z")))
|
||||
conn, err := db.Open(ctx, s.dbPath, s.log)
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("open db for backup: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
// VACUUM INTO takes a string literal, not a bound parameter; escape the
|
||||
// generated filename (safe characters, but escape defensively).
|
||||
query := fmt.Sprintf("VACUUM INTO '%s'", escapeSQLString(dest))
|
||||
if _, err := conn.ExecContext(ctx, query); err != nil {
|
||||
return "", 0, fmt.Errorf("vacuum into: %w", err)
|
||||
}
|
||||
info, err := os.Stat(dest)
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("stat backup: %w", err)
|
||||
}
|
||||
return dest, info.Size(), nil
|
||||
}
|
||||
|
||||
// replaceDatabase atomically replaces the live DB file at dest with a copy of src,
|
||||
// writing to a temp file first so a failure never leaves dest truncated. On Linux
|
||||
// a running SQLite connection keeps its open handle to the old inode, so the new
|
||||
// data becomes visible only after the process restarts — which the restore
|
||||
// endpoint reports.
|
||||
func replaceDatabase(src, dest string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open source: %w", err)
|
||||
}
|
||||
defer in.Close()
|
||||
tmp := dest + ".restore.tmp"
|
||||
out, err := os.Create(tmp)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temp: %w", err)
|
||||
}
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
_ = out.Close()
|
||||
_ = os.Remove(tmp)
|
||||
return fmt.Errorf("copy: %w", err)
|
||||
}
|
||||
if err := out.Close(); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return fmt.Errorf("close temp: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmp, dest); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return fmt.Errorf("rename: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// escapeSQLString doubles any single quote so a filename can be safely embedded
|
||||
// in a SQL string literal for VACUUM INTO.
|
||||
func escapeSQLString(s string) string {
|
||||
return strings.ReplaceAll(s, "'", "''")
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
|
||||
)
|
||||
|
||||
// newFileDB creates a real SQLite database file at dir/<name>, migrates it, and
|
||||
// closes the handle, returning the file path. The gateway backup/restore
|
||||
// endpoints open their own connections, so the handle is not held open here.
|
||||
func newFileDB(t *testing.T, dir, name string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, name)
|
||||
ctx := context.Background()
|
||||
conn, err := db.Open(ctx, path, gwTestLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("open file db: %v", err)
|
||||
}
|
||||
if err := db.MigrateUp(ctx, conn, gwTestLogger()); err != nil {
|
||||
_ = conn.Close()
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
if err := conn.Close(); err != nil {
|
||||
t.Fatalf("close file db: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// --- revoke-keys -------------------------------------------------------------
|
||||
|
||||
func TestAdminRevokeAllKeys(t *testing.T) {
|
||||
t.Parallel()
|
||||
keys := auth.NewAPIKey(config.AuthConfig{})
|
||||
for _, role := range []auth.Role{auth.RoleOwner, auth.RoleAdmin, auth.RoleViewer} {
|
||||
if _, err := keys.Generate(role); err != nil {
|
||||
t.Fatalf("generate key: %v", err)
|
||||
}
|
||||
}
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Users: keys})
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/system/revoke-keys", nil, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
var res struct {
|
||||
Revoked int `json:"revoked"`
|
||||
}
|
||||
if err := json.Unmarshal(decodeBody(t, body)["data"], &res); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if res.Revoked != 3 {
|
||||
t.Fatalf("revoked = %d, want 3", res.Revoked)
|
||||
}
|
||||
if len(keys.List()) != 0 {
|
||||
t.Fatalf("key table not empty after RevokeAll: %d", len(keys.List()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminRevokeAllKeysUnwired(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) // no Users store
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/system/revoke-keys", nil, tok)
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
wantErrorShape(t, body)
|
||||
}
|
||||
|
||||
// --- rotate-jwt --------------------------------------------------------------
|
||||
|
||||
func TestAdminRotateJWT(t *testing.T) {
|
||||
t.Parallel()
|
||||
jwt := auth.New(config.AuthConfig{JWTSecret: testSecret})
|
||||
_, ts, _ := buildEnv(t, cfgDefault(), Deps{Auth: jwt})
|
||||
// The route is owner-only; verify an agent with the current secret passes.
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
newSecret := strings.Repeat("Z", 40)
|
||||
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/system/rotate-jwt",
|
||||
map[string]any{"newSecret": newSecret}, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
var res struct {
|
||||
Rotated bool `json:"rotated"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
if err := json.Unmarshal(decodeBody(t, body)["data"], &res); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if !res.Rotated || res.Note == "" {
|
||||
t.Fatalf("res = %+v, want rotated with note", res)
|
||||
}
|
||||
// The pre-rotation token no longer verifies.
|
||||
if _, err := jwt.Verify(tok); err == nil {
|
||||
t.Fatal("pre-rotation token still verifies after RotateSecret")
|
||||
}
|
||||
// The new secret is never echoed back in the body, nor is a short one accepted.
|
||||
if strings.Contains(string(body), newSecret) {
|
||||
t.Fatalf("new secret leaked in response body: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminRotateJWTShortSecret(t *testing.T) {
|
||||
t.Parallel()
|
||||
jwt := auth.New(config.AuthConfig{JWTSecret: testSecret})
|
||||
_, ts, _ := buildEnv(t, cfgDefault(), Deps{Auth: jwt})
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/system/rotate-jwt",
|
||||
map[string]any{"newSecret": "short"}, tok)
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
wantErrorShape(t, body)
|
||||
// The previous secret stays intact after a rejected rotation.
|
||||
if _, err := jwt.Verify(tok); err != nil {
|
||||
t.Fatalf("token invalid after rejected rotation: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminRotateJWTMissingSecret(t *testing.T) {
|
||||
t.Parallel()
|
||||
jwt := auth.New(config.AuthConfig{JWTSecret: testSecret})
|
||||
_, ts, _ := buildEnv(t, cfgDefault(), Deps{Auth: jwt})
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
resp, _ := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/system/rotate-jwt",
|
||||
map[string]any{"newSecret": " "}, tok)
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// --- backup ------------------------------------------------------------------
|
||||
|
||||
func TestAdminBackupProducesFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
dbPath := newFileDB(t, dir, "cyrene.db")
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{DatabasePath: dbPath})
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/system/backup", nil, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
var res struct {
|
||||
Path string `json:"path"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
if err := json.Unmarshal(decodeBody(t, body)["data"], &res); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(res.Path, dir) {
|
||||
t.Fatalf("backup path %q is not under data dir %q", res.Path, dir)
|
||||
}
|
||||
if res.Size <= 0 {
|
||||
t.Fatalf("backup size = %d, want > 0", res.Size)
|
||||
}
|
||||
if info, err := os.Stat(res.Path); err != nil || info.Size() != res.Size {
|
||||
t.Fatalf("backup file stat = (%v, %d), want no error and size %d", err, info.Size(), res.Size)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminBackupUnwired(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) // no DatabasePath
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/system/backup", nil, tok)
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
wantErrorShape(t, body)
|
||||
}
|
||||
|
||||
// --- restore -----------------------------------------------------------------
|
||||
|
||||
func TestAdminRestoreReplacesAndPreBacksUp(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
dbPath := newFileDB(t, dir, "cyrene.db")
|
||||
backupSrc := newFileDB(t, dir, "backup.db")
|
||||
|
||||
// Make the backup source clearly different from the live db by appending a
|
||||
// marker. Restore only copies bytes and never opens the source, so the marker
|
||||
// survives into the replaced live file and proves the swap happened.
|
||||
srcFile, err := os.OpenFile(backupSrc, os.O_WRONLY|os.O_APPEND, 0o644)
|
||||
if err != nil {
|
||||
t.Fatalf("open backup source: %v", err)
|
||||
}
|
||||
if _, err := srcFile.WriteString("RESTORE-MARKER"); err != nil {
|
||||
t.Fatalf("append marker: %v", err)
|
||||
}
|
||||
if err := srcFile.Close(); err != nil {
|
||||
t.Fatalf("close backup source: %v", err)
|
||||
}
|
||||
liveBefore, err := os.ReadFile(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read live db before: %v", err)
|
||||
}
|
||||
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{DatabasePath: dbPath})
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
// No pre-existing backups yet.
|
||||
before, _ := filepath.Glob(filepath.Join(dir, "backup-*.db"))
|
||||
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/system/restore",
|
||||
map[string]any{"path": backupSrc}, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
var res struct {
|
||||
Restored bool `json:"restored"`
|
||||
Backup string `json:"backup"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
if err := json.Unmarshal(decodeBody(t, body)["data"], &res); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if !res.Restored || res.Note == "" {
|
||||
t.Fatalf("res = %+v, want restored with note", res)
|
||||
}
|
||||
// The current DB was snapshotted before the replace (a new backup appeared).
|
||||
after, _ := filepath.Glob(filepath.Join(dir, "backup-*.db"))
|
||||
if len(after) != len(before)+1 {
|
||||
t.Fatalf("backup count before=%d after=%d, want one more pre-restore snapshot", len(before), len(after))
|
||||
}
|
||||
// The live DB was replaced with the backup source's bytes, so it now carries
|
||||
// the marker and differs from its pre-restore content.
|
||||
liveAfter, err := os.ReadFile(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read live db after: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(liveAfter), "RESTORE-MARKER") {
|
||||
t.Fatalf("live db not replaced (marker missing); before len=%d after len=%d", len(liveBefore), len(liveAfter))
|
||||
}
|
||||
if string(liveAfter) == string(liveBefore) {
|
||||
t.Fatal("live db unchanged after restore")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminRestoreRejectsTraversal(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir() // data dir
|
||||
outside := t.TempDir() // a sibling directory, outside the data dir
|
||||
dbPath := newFileDB(t, dir, "cyrene.db")
|
||||
outsideBackup := newFileDB(t, outside, "outside.db")
|
||||
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{DatabasePath: dbPath})
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/system/restore",
|
||||
map[string]any{"path": outsideBackup}, tok)
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
wantErrorShape(t, body)
|
||||
}
|
||||
|
||||
func TestAdminRestoreRejectsMissingFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
dbPath := newFileDB(t, dir, "cyrene.db")
|
||||
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{DatabasePath: dbPath})
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/system/restore",
|
||||
map[string]any{"path": filepath.Join(dir, "does-not-exist.db")}, tok)
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
wantErrorShape(t, body)
|
||||
}
|
||||
|
||||
func TestAdminRestoreUnwired(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) // no DatabasePath
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/system/restore",
|
||||
map[string]any{"path": "x"}, tok)
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
wantErrorShape(t, body)
|
||||
}
|
||||
|
||||
func TestAdminRestoreRequiresPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
dbPath := newFileDB(t, dir, "cyrene.db")
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{DatabasePath: dbPath})
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
resp, _ := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/system/restore", map[string]any{"path": ""}, tok)
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -247,6 +247,12 @@ type Deps struct {
|
||||
// data; the composition root injects NewSQLiteAuditStore over a.db for real
|
||||
// persistence. Optional.
|
||||
AuditStore AuditStore
|
||||
// DatabasePath is the resolved path of the SQLite database file the
|
||||
// composition root actually opened (the value derived by resolveSQLitePath,
|
||||
// which may differ from cfg.DB.SQLitePath when the data dir default applies).
|
||||
// It backs the admin backup/restore endpoints. Optional; when empty those
|
||||
// endpoints answer honestly with a clear not-configured error.
|
||||
DatabasePath string
|
||||
// Streaming marks the injected Loop as streaming-capable (its OnStream is
|
||||
// wired to publish ws.stream_chunk). When set the hub skips its own single
|
||||
// final stream_chunk emission, since deltas already stream through OnStream.
|
||||
@@ -286,6 +292,7 @@ type Server struct {
|
||||
voice VoiceService
|
||||
streaming bool
|
||||
frontend fs.FS
|
||||
dbPath string
|
||||
// llm is the hot-swappable provider stack (models.json). When nil the
|
||||
// admin /llm endpoints fall back to the env config. It is optional so tests
|
||||
// that do not wire it keep working.
|
||||
@@ -329,6 +336,7 @@ func New(cfg *config.Config, deps Deps) (*Server, error) {
|
||||
llm: deps.LLM,
|
||||
channels: deps.Channels,
|
||||
webhooks: deps.Webhooks,
|
||||
dbPath: deps.DatabasePath,
|
||||
log: sourcedLog(deps.Log),
|
||||
}
|
||||
s.hub = newHub(s)
|
||||
@@ -415,6 +423,10 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.Handle("POST /api/v1/admin/memories/search", ownerAuth(http.HandlerFunc(s.adminMemorySearch)))
|
||||
mux.Handle("POST /api/v1/admin/rag", ownerAuth(http.HandlerFunc(s.adminIndexRag)))
|
||||
mux.Handle("GET /api/v1/admin/sessions/{id}/export", adminAuth(http.HandlerFunc(s.adminExportSession)))
|
||||
mux.Handle("POST /api/v1/admin/system/revoke-keys", ownerAuth(http.HandlerFunc(s.adminRevokeAllKeys)))
|
||||
mux.Handle("POST /api/v1/admin/system/rotate-jwt", ownerAuth(http.HandlerFunc(s.adminRotateJWT)))
|
||||
mux.Handle("POST /api/v1/admin/system/backup", ownerAuth(http.HandlerFunc(s.adminBackup)))
|
||||
mux.Handle("POST /api/v1/admin/system/restore", ownerAuth(http.HandlerFunc(s.adminRestore)))
|
||||
|
||||
// External webhook callbacks (e.g. the webhook channel) are mounted without
|
||||
// gateway auth; each handler enforces its own signature/authorization.
|
||||
|
||||
Reference in new issue
Block a user