diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..78e22a855 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +server/backend/** text eol=lf +# Unified-diff context lines intentionally contain a leading space on empty lines. +*.patch -whitespace diff --git a/.gitignore b/.gitignore index 3508c4f17..f4bd52d3c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,13 @@ dist *.local *.log .data +__pycache__/ +*.py[cod] +.pytest_cache/ +.ruff_cache/ +.venv/ +.memos/ +/docs/superpowers/ .env .env.* diff --git a/README.md b/README.md index 998c98409..c0300bd33 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ MemOS 记忆服务的 Web 控制台:接入说明、接口密钥、调用日志 模型清单、自定义抽取、用量监控。 - 页面:Vite + React 19 + react-router-dom,全部走 `/cn/*` 路由。 -- 后端:独立的 Go 服务 `memosd`(另一个仓库),同源托管 `dist/`;本仓库只负责前端与接口契约。 +- 后端:本分支新增独立 Python 适配服务,接入 deployment-v2 的 MemOS 与网关审计数据;部署、权限、上游配套补丁及验收入口见 [后端说明](server/backend/README.md)。原有独立 Go 服务 `memosd` 不属于本次实现。 - 本地 mock:`server/mock/`,只在开发时用,不部署。 - 接口契约:`shared/contract.ts`(前后端共用的类型)+ 仓库根目录的 `openapi.json`。 diff --git a/openapi.json b/openapi.json index 04eea16bb..1d739c6d9 100644 --- a/openapi.json +++ b/openapi.json @@ -3,13 +3,21 @@ "info": { "title": "MemOS Dashboard API", "version": "0.1.0", - "description": "控制台前端依赖的后端接口定义。\n\n## 通用约定\n\n**统一返回格式。** 所有接口返回 `{ code, data, message }`。`code === 0` 表示成功,前端解包后只拿 `data`;非 0 时前端按错误处理,把 `message` 弹成提示。HTTP 状态码不参与判断,200 + 非 0 code 也算失败。\n\n**请求体。** 除少数 GET 外,参数一律放 body,不放 query。字段值为空串或 null 时前端会**删掉该字段**再发出去,后端要按「字段不存在」处理,不要依赖空串。\n\n**分页。** 参数名固定 `pageNum` / `pageSize`,从 1 开始。\n\n**列表响应的键不统一。** 同一个前端里四种写法并存,这是最容易被忽略的地方:\n\n| 接口 | 列表键 | 分页字段 |\n|---|---|---|\n| `project/list`、`keys/list`、`knowledgebase/list`、`knowledgebase/memory/list` | `records` | `total` |\n| `log/list` | `logs` | `total` |\n| `project/memory/list` | `memListItemResponseList` | `total`、`current` |\n| `profileTemplate/list` | `list` | `total`、`current`、`size`、`pages` |\n\n**时间字段。** 一律叫 `createTime`(不是 `createdAt`),值是可被 `dayjs` 直接解析的字符串。\n\n## 错误码\n\n| code | 含义 |\n|---|---|\n| 0 | 成功 |\n| 40000 | 参数错误 |\n| 40001 | 资源不存在 |\n| 40002 | 请求过于频繁 |\n| 40003 | 无权限 |\n| 40100 | 未登录 |\n| 40104 | 未授权 |\n| 50106 | 知识库数量超限 |\n| 50107 | 文档删除中 |\n| 60004 | 容量超限 |\n\n## 模型列表\n\n「模型列表」页的数据来自 `GET /api/dashboard/models`,返回上游实际生效的模型配置(形状 `{ records: [...] }`)。\n\n## 鉴权\n\n**控制台不发任何鉴权头**,请求里只有 `Content-Type`;但同源请求会自动带 Cookie。根级 `security: []` 表示不要求在请求头里放 token。\n\nv2 新增了 `/api/auth/me` 与 `/admin/*`:前者返回当前登录主体,后者需要管理员身份。会话方式(Cookie / 反向代理注入 / 其他)由后端决定;若改成要求 token,前端也要相应改动。\n\n**字段命名。** `/api/*` 用 camelCase;`/admin/*` 沿用 snake_case(`display_name`、`platform_admin`、`created_at`),不要统一改成 camelCase。" + "description": "控制台前端依赖的后端接口定义。\n\n## 通用约定\n\n**统一返回格式。** 所有接口返回 `{ code, data, message }`。`code === 0` 表示成功,前端解包后只拿 `data`;非 0 时前端按错误处理,把 `message` 弹成提示。HTTP 状态码不参与判断,200 + 非 0 code 也算失败。\n\n**请求体。** 除少数 GET 外,参数一律放 body,不放 query。字段值为空串或 null 时前端会**删掉该字段**再发出去,后端要按「字段不存在」处理,不要依赖空串。\n\n**分页。** 参数名固定 `pageNum` / `pageSize`,从 1 开始。\n\n**列表响应的键不统一。** 同一个前端里四种写法并存,这是最容易被忽略的地方:\n\n| 接口 | 列表键 | 分页字段 |\n|---|---|---|\n| `project/list`、`keys/list`、`knowledgebase/list`、`knowledgebase/memory/list` | `records` | `total` |\n| `log/list` | `logs` | `total` |\n| `project/memory/list` | `memListItemResponseList` | `total`、`current` |\n| `profileTemplate/list` | `list` | `total`、`current`、`size`、`pages` |\n\n**时间字段。** 一律叫 `createTime`(不是 `createdAt`),值是可被 `dayjs` 直接解析的字符串。\n\n## 错误码\n\n| code | 含义 |\n|---|---|\n| 0 | 成功 |\n| 40000 | 参数错误 |\n| 40001 | 资源不存在 |\n| 40002 | 请求过于频繁 |\n| 40003 | 无权限 |\n| 40100 | 未登录 |\n| 40104 | 未授权 |\n| 50106 | 知识库数量超限 |\n| 50107 | 文档删除中 |\n| 60004 | 容量超限 |\n\n## 模型列表\n\n「模型列表」页的数据来自 `GET /api/dashboard/models`,返回上游实际生效的模型配置(形状 `{ records: [...] }`)。\n\n## 鉴权\n\n**控制台不发任何鉴权头**,请求里只有 `Content-Type`;但同源请求会自动带 Cookie。根级 `security: []` 表示不要求在请求头里放 token。\n\nv2 新增了 `/api/auth/me` 与 `/admin/*`:前者返回当前登录主体,其中 `/admin/accounts*` 要求平台管理员,`/admin/tenants/*` 按目标项目角色授权。会话方式(Cookie / 反向代理注入 / 其他)由后端决定;若改成要求 token,前端也要相应改动。\n\n**字段命名。** `/api/*` 用 camelCase;`/admin/*` 沿用 snake_case(`display_name`、`platform_admin`、`created_at`),不要统一改成 camelCase。\n\n## Layered authorization\nThe /admin URL namespace contains both platform and project administration. /admin/accounts requires platform_admin. /admin/tenants operations use the target project role: members may read its member list and leave themselves; owners manage members, candidate search and invitations. Platform administrators override project-role checks, but business invariants still apply. See each operation x-authorization and server/backend/README.md. This is the chosen implementation interpretation of Issue 3, not a claim of upstream maintainer approval.", + "license": { + "name": "Apache License 2.0", + "identifier": "Apache-2.0" + } }, - "security": [], + "security": [ + { + "CookieSession": [] + } + ], "servers": [ { - "url": "http://localhost:5280", - "description": "本地开发(`pnpm mock`)" + "url": "/", + "description": "Same-origin Dashboard API; local development proxy may target the real backend or Mock" } ], "tags": [ @@ -64,16 +72,18 @@ { "name": "模型", "description": "上游实际生效的模型清单" + }, + { + "name": "认证", + "description": "Backend-owned login and registration HTML forms." } ], "paths": { "/api/dashboard/project/dict": { "get": { - "tags": [ - "项目" - ], + "tags": ["项目"], "summary": "项目下拉字典", - "description": "返回**数组**,不是分页对象。`status === \"ENABLED\"` 的那条是当前项目。", + "description": "Returns active accessible workspaces. Exactly one status=ENABLED indicates the current opaque browser project context when any active workspace exists. Inactive/archived workspaces are excluded. HttpOnly SameSite=Lax project preference cookie persists for 30 days; it is not an authenticated account session.", "parameters": [ { "name": "language", @@ -81,25 +91,80 @@ "required": false, "schema": { "type": "string", - "examples": [ - "zh-CN", - "en-US" - ] + "examples": ["zh-CN", "en-US"] } } ], "responses": { "200": { "$ref": "#/components/responses/ProjectDict" + }, + "400": { + "description": "Invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Origin rejected", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "Unknown or unavailable project", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "409": { + "description": "Name or lifecycle conflict", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Project storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } } } }, "/api/dashboard/project/list": { "post": { - "tags": [ - "项目" - ], + "tags": ["项目"], "summary": "项目分页列表", "requestBody": { "required": true, @@ -110,13 +175,18 @@ "properties": { "pageNum": { "type": "integer", - "default": 1 + "default": 1, + "minimum": 1, + "maximum": 2147483647 }, "pageSize": { "type": "integer", - "default": 10 + "default": 10, + "minimum": 1, + "maximum": 200 } - } + }, + "additionalProperties": false } } } @@ -124,40 +194,98 @@ "responses": { "200": { "$ref": "#/components/responses/ProjectRecords" + }, + "400": { + "description": "Invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Origin rejected", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "Unknown or unavailable project", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "409": { + "description": "Name or lifecycle conflict", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Project storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } - } + }, + "description": "SQLite-backed workspaces visible to the authenticated account (or explicit legacy local operator mode); includes accessible disabled/archived rows for recovery. Records counted before pagination. status=DISABLED alone means not selected; disabled=true means stopped. role is the effective project role; platform admins may manage every visible configured/locally-created workspace." } }, "/api/dashboard/project/create": { "post": { - "tags": [ - "项目" - ], + "tags": ["项目"], "summary": "新建项目", - "description": "返回新建的项目对象,前端拿它的 `id` 立刻切过去。", + "description": "Persist a workspace and immutable upstream namespace mapping. ID is server generated, upstream_cube_id defaults to that ID. No memory/cube data is fabricated or deleted. NFKC/casefold trimmed names must be unique, including archived names.", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", - "required": [ - "name", - "description" - ], + "additionalProperties": false, + "required": ["name", "description"], "properties": { "name": { - "type": "string" + "type": "string", + "minLength": 1, + "maxLength": 128 }, "description": { - "type": "string" + "type": "string", + "maxLength": 4000 }, "isMultiView": { "type": "integer", - "enum": [ - 0, - 1 - ] + "enum": [0, 1], + "default": 0 } } } @@ -167,45 +295,109 @@ "responses": { "200": { "$ref": "#/components/responses/Project" + }, + "400": { + "description": "Invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Origin rejected", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "Unknown or unavailable project", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "409": { + "description": "Name or lifecycle conflict", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Project storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } } } }, "/api/dashboard/project/update": { "post": { - "tags": [ - "项目" - ], + "tags": ["项目"], "summary": "更新项目 / 切换当前项目", - "description": "切换当前项目就是传 `{ id, status: \"ENABLED\" }`,后端要把其它项目置回 `DISABLED`。", + "description": "Nonempty partial update. ENABLED selects an active project only for the calling browser context; for a stopped project it re-enables without selecting. DISABLED stops the project and resets only contexts selecting it. Archived projects must be restored first. Namespace and ownership fields are not writable. Authenticated deployments use session-bound selection and actual membership roles. Owner/platform admin manages lifecycle; viewer may select an accessible active project.", "requestBody": { "required": true, "content": { "application/json": { "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/Project" + "type": "object", + "additionalProperties": false, + "required": ["id"], + "minProperties": 2, + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9_.-]+$" }, - { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "status": { - "type": "string", - "enum": [ - "ENABLED", - "DISABLED" - ] - } - }, - "required": [ - "id" - ] + "name": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "description": { + "type": "string", + "maxLength": 4000 + }, + "isMultiView": { + "type": "integer", + "enum": [0, 1] + }, + "status": { + "type": "string", + "enum": ["ENABLED", "DISABLED"] } - ] + } } } } @@ -213,15 +405,73 @@ "responses": { "200": { "$ref": "#/components/responses/Project" + }, + "400": { + "description": "Invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Origin rejected", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "Unknown or unavailable project", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "409": { + "description": "Name or lifecycle conflict", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Project storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } } } }, "/api/dashboard/project/revoke/{id}": { "post": { - "tags": [ - "项目" - ], + "tags": ["项目"], "summary": "删除项目", "parameters": [ { @@ -236,26 +486,83 @@ "responses": { "200": { "$ref": "#/components/responses/Ok" + }, + "400": { + "description": "Invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Origin rejected", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "Unknown or unavailable project", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "409": { + "description": "Name or lifecycle conflict", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Project storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } - } + }, + "description": "Reversible archive (legacy revoke is the same compatibility alias). Preserve workspace, namespace and all memories. Listed for recovery; unavailable to normal memory/log/usage calls; active dictionary excludes it." } }, "/api/dashboard/keys/list": { "post": { - "tags": [ - "接口密钥" - ], + "tags": ["接口密钥"], "summary": "密钥分页列表", - "description": "列表里下发的 `apiKey` 就是**完整密钥**,没有掩码字段,前端自己截断成 `${key.slice(0,7)}*******${key.slice(-8)}` 展示。", + "description": "列表里下发的 `apiKey` 就是**完整密钥**,没有掩码字段,前端自己截断成 `${key.slice(0,7)}*******${key.slice(-8)}` 展示。`apiKey` 始终为完整密钥(前端自截断展示)。完整下发仅限项目 owner(平台管理员视同 owner)。点击层级与权限、撤销行不返回。", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", - "required": [ - "projectId" - ], + "required": ["projectId"], "properties": { "projectId": { "type": "string" @@ -276,26 +583,52 @@ "responses": { "200": { "$ref": "#/components/responses/ApiKeyRecords" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } } } }, "/api/dashboard/keys/create": { "post": { - "tags": [ - "接口密钥" - ], + "tags": ["接口密钥"], "summary": "新建密钥", + "description": "密钥创建即写入网关配置文件 `DASHBOARD_GATEWAY_KEYS_FILE`(成功返回 ⟺ 已写入),网关须重启方可生效;未配置交付路径或写入失败返回 503,密钥不落库。完整密钥仅创建响应可见一次(前端弹窗),后续 list/copy 仍按契约返回完整密钥(仅 owner)。\n\nThe upgraded gateway detects confirmed file publications before each authentication request. A newly created key works without restarting the gateway. Initial deployment of the reload code and changes to MEMOS_TENANTS environment variables still require restart.", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", - "required": [ - "projectId", - "keyName" - ], + "required": ["projectId", "keyName"], "properties": { "projectId": { "type": "string" @@ -311,16 +644,45 @@ "responses": { "200": { "$ref": "#/components/responses/ApiKey" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } } } }, "/api/dashboard/keys/revoke/{id}": { "post": { - "tags": [ - "接口密钥" - ], + "tags": ["接口密钥"], "summary": "吊销密钥", + "description": "软撤销:行保留(审计可用),立即从网关配置文件移除,已升级网关在下一次鉴权前自动重载,后续请求失效(不取消已授权执行中的请求);撤销成功返回 `{ok:true}`。", "parameters": [ { "name": "id", @@ -334,26 +696,52 @@ "responses": { "200": { "$ref": "#/components/responses/Ok" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } } } }, "/api/dashboard/keys/copy": { "post": { - "tags": [ - "接口密钥" - ], + "tags": ["接口密钥"], "summary": "取完整密钥", - "description": "`data` 是密钥字符串本身,不是对象。", + "description": "返回完整密钥字符串本身(仅 owner;已撤销/不存在返回 404)。", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", - "required": [ - "apiKeyId" - ], + "required": ["apiKeyId"], "properties": { "apiKeyId": { "type": "string" @@ -366,65 +754,102 @@ "responses": { "200": { "$ref": "#/components/responses/StringData" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } } } }, "/api/dashboard/log/list": { "post": { - "tags": [ - "调用日志" - ], + "tags": ["调用日志"], "summary": "调用日志分页列表", - "description": "`pageSize` 默认 50,前端只渲染返回结果的前 100 条。\n\n`subjectType` 取 `all` / `user` / `agent`;`subjectId` 为空时 `subjectType` 会被强制清空。", + "description": "`pageSize` 默认 50,前端只渲染返回结果的前 100 条。\n\n`subjectType` 取 `all` / `user` / `agent`;`subjectId` 为空时 `subjectType` 会被强制清空。\n\nThe source of truth is configured JSONL. Missing source returns 503 rather than an empty success. Empty optional strings disable filters. Unknown event filters return 400. Time boundaries require an offset, or a UTC date; end date includes that entire UTC day. Keyword is a case-insensitive literal substring of the sanitized event, call ID, subject IDs and request/response bodies. Async rows with an explicit call ID count as one logical call within source and tenant.", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", - "required": [ - "projectId" - ], + "required": ["projectId"], "properties": { "projectId": { - "type": "string" + "type": "string", + "minLength": 1, + "maxLength": 128 }, "pageNum": { "type": "integer", - "default": 1 + "default": 1, + "minimum": 1, + "maximum": 2147483647 }, "pageSize": { "type": "integer", - "default": 50 + "default": 50, + "minimum": 1, + "maximum": 100 }, "subjectId": { "type": "string" }, "subjectType": { "type": "string", - "enum": [ - "all", - "user", - "agent" - ] + "enum": ["all", "user", "agent"] }, "eventType": { "$ref": "#/components/schemas/EventType" }, "query": { - "type": "string" + "type": "string", + "maxLength": 1000 }, "status": { - "type": "string" + "type": "string", + "description": "SUCCESS / FAILURE / PENDING / UNKNOWN; case insensitive; FAIL and FAILED are aliases. Empty means no filter." }, "startTime": { "type": "string" }, "endTime": { "type": "string" + }, + "scope": { + "type": "string", + "enum": ["all", "gateway", "console"], + "default": "all", + "description": "Trusted configured audit source. all requires the gateway source to be configured and available." } - } + }, + "additionalProperties": false } } } @@ -432,59 +857,174 @@ "responses": { "200": { "$ref": "#/components/responses/LogList" - } - } - } - }, - "/api/dashboard/log/detail": { - "post": { - "tags": [ - "调用日志" - ], - "summary": "调用日志详情", - "description": "`requestBody` 与 `responseBody` 是 **JSON 字符串**,不是对象,前端要二次 `JSON.parse`。\n\n`eventType === \"chat\"` 时 `responseBody` 再解析一次:解析结果带 `code` 与 `data` 就直接用,否则包成 `{ code: responseCode, data: <解析结果> }`。\n\n### 详情抽屉按 eventType 取哪些字段\n\n通用字段(每条日志都有):`status`、`duration`、`traceId`、`userId`、`agentId`、`createTime`、`requestTokens`、`responseTokens`、`requestCode`、`responseCode`、`eventType`。\n\n各类型额外读取的路径(点号分隔,`requestBody` / `responseBody` 解析后的字段):\n\n| eventType | 请求 | 响应 |\n|---|---|---|\n| `addMessage` | `async_mode` `conversation_id` `allow_memory_view` `messages` `info` | `code` `data.memory_views` `message` |\n| `addFeedback` | `feedback_target` `profile_id` `attr_id` `feedback_content` `feedback_time` | `code` `data.memory_views` `message` |\n| `searchMemory` | `conversation_id` `conversationId` `query` `include_memory_view` `memory_limit_number` `includePreference` `preferenceLimitNumber` `includeToolMemory` `toolMemoryLimitNumber` `includeSkill` `skillLimitNumber` `knowledgebase_ids` `filter` | `code` `data` `message` |\n| `getMessage` | `message_limit_number` | `code` `data.message_detail_list` `message` |\n| `getMemory` | `page` `size` `filter` `include_memory_view` | `code` `data` `message` |\n| `deleteMemory` | `memory_ids` `message_id` | `code` `data` `message` |\n| `manualAddMemory` | `memory_view` `add_time` `algorithm_updatable` `content` | `code` `data` `message` |\n| `updateMemory` | `memory_id` `title` `algorithm_updatable` `content` | `code` `data` `message` |\n| `bindProfileTemplate` | `bind_list` | `code` `message` |\n| `editProfile` | `profile_template_id` `metadata` `remove_fields` | `code` `message` |\n| `deleteProfile` | `profile_template_id` | `code` `message` |\n| `chat` | `conversation_id` `knowledgebase_ids` `query` `filter` `base_prompt` `model_name` `include_preference` `add_message_on_answer` | `code` `data.response` `message` |\n| `extractMemory` | `messages` `extraction_types` | `code` `message`、`data.choices.0.message.content` |\n| `rerank` | `model` `query` `documents` `top_n` | `code` `data.results` `message` |\n\n请求体里的键名风格不统一,既有下划线(`conversation_id`)也有驼峰(`conversationId`),`searchMemory` 两种都会去取。", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "logId" - ], - "properties": { - "logId": { - "type": "string" - } + }, + "400": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" } } } + }, + "404": { + "description": "Project or log not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Audit source or index unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "500": { + "description": "Unexpected internal error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + } + } + } + }, + "/api/dashboard/log/detail": { + "post": { + "tags": ["调用日志"], + "summary": "调用日志详情", + "description": "`requestBody` 与 `responseBody` 是 **JSON 字符串**,不是对象,前端要二次 `JSON.parse`。\n\n`eventType === \"chat\"` 时 `responseBody` 再解析一次:解析结果带 `code` 与 `data` 就直接用,否则包成 `{ code: responseCode, data: <解析结果> }`。\n\n### 详情抽屉按 eventType 取哪些字段\n\n通用字段(每条日志都有):`status`、`duration`、`traceId`、`userId`、`agentId`、`createTime`、`requestTokens`、`responseTokens`、`requestCode`、`responseCode`、`eventType`。\n\n各类型额外读取的路径(点号分隔,`requestBody` / `responseBody` 解析后的字段):\n\n| eventType | 请求 | 响应 |\n|---|---|---|\n| `addMessage` | `async_mode` `conversation_id` `allow_memory_view` `messages` `info` | `code` `data.memory_views` `message` |\n| `addFeedback` | `feedback_target` `profile_id` `attr_id` `feedback_content` `feedback_time` | `code` `data.memory_views` `message` |\n| `searchMemory` | `conversation_id` `conversationId` `query` `include_memory_view` `memory_limit_number` `includePreference` `preferenceLimitNumber` `includeToolMemory` `toolMemoryLimitNumber` `includeSkill` `skillLimitNumber` `knowledgebase_ids` `filter` | `code` `data` `message` |\n| `getMessage` | `message_limit_number` | `code` `data.message_detail_list` `message` |\n| `getMemory` | `page` `size` `filter` `include_memory_view` | `code` `data` `message` |\n| `deleteMemory` | `memory_ids` `message_id` | `code` `data` `message` |\n| `manualAddMemory` | `memory_view` `add_time` `algorithm_updatable` `content` | `code` `data` `message` |\n| `updateMemory` | `memory_id` `title` `algorithm_updatable` `content` | `code` `data` `message` |\n| `bindProfileTemplate` | `bind_list` | `code` `message` |\n| `editProfile` | `profile_template_id` `metadata` `remove_fields` | `code` `message` |\n| `deleteProfile` | `profile_template_id` | `code` `message` |\n| `chat` | `conversation_id` `knowledgebase_ids` `query` `filter` `base_prompt` `model_name` `include_preference` `add_message_on_answer` | `code` `data.response` `message` |\n| `extractMemory` | `messages` `extraction_types` | `code` `message`、`data.choices.0.message.content` |\n| `rerank` | `model` `query` `documents` `top_n` | `code` `data.results` `message` |\n\n请求体里的键名风格不统一,既有下划线(`conversation_id`)也有驼峰(`conversationId`),`searchMemory` 两种都会去取。\n\nScope is the calling browser selected project; without a recognized preference cookie use the configured bootstrap or another active accessible project. This contract carries no projectId. A foreign project ID is not readable. Detail reads the persisted index; list refreshes it. Missing historical bodies are JSON string null, duration is null, tokens are 0, missing timestamp is an empty string and unknown status is UNKNOWN. Oversized bodies are explicit truncated JSON objects; credentials are redacted.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["logId"], + "properties": { + "logId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9_.-]+$" + } + }, + "additionalProperties": false + } + } } }, "responses": { "200": { "$ref": "#/components/responses/LogDetail" + }, + "400": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "Project or log not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Audit source or index unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "500": { + "description": "Unexpected internal error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } } } }, "/api/dashboard/project/memory/list": { "post": { - "tags": [ - "记忆" - ], + "tags": ["记忆"], "summary": "记忆分页列表", - "description": "列表键是 `memListItemResponseList`,不是 `records`。\n\n`memoryDetailType` 决定返回哪一类记忆,取值见 `MemoryDetailType`。", + "description": "列表键是 `memListItemResponseList`,不是 `records`。\n\n`memoryDetailType` 决定返回哪一类记忆,取值见 `MemoryDetailType`。 任务02:真实只读查询;项目由服务器配置白名单映射,详情使用该实例固定默认项目。列表仅统计 activated,缺失状态不计入;先筛选计数后分页。", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", - "required": [ - "projectId", - "memoryDetailType" - ], + "required": ["projectId", "memoryDetailType"], "properties": { "projectId": { "type": "string" @@ -494,20 +1034,26 @@ }, "pageNum": { "type": "integer", - "default": 1 + "default": 1, + "minimum": 1 }, "pageSize": { "type": "integer", - "default": 50 + "default": 50, + "minimum": 1, + "maximum": 100 }, "knowledgeBaseId": { - "type": "string" + "type": "string", + "description": "按图节点 knowledgebase_id / knowledge_base_id / knowledgebase_ids 关联元数据过滤;无关联记录不匹配。画像按实例 knowledgebase_id 过滤。" }, "startTime": { - "type": "string" + "type": "string", + "description": "带时区 RFC3339 时间,或按 UTC 自然日解释的 YYYY-MM-DD;起点包含,日期终点包含整日,时间戳终点包含。" }, "endTime": { - "type": "string" + "type": "string", + "description": "带时区 RFC3339 时间,或按 UTC 自然日解释的 YYYY-MM-DD;起点包含,日期终点包含整日,时间戳终点包含。" }, "externalUserId": { "type": "string" @@ -516,9 +1062,11 @@ "$ref": "#/components/schemas/MemorySubjectType" }, "memSearchContent": { - "type": "string" + "type": "string", + "description": "图节点正文或技能名的区分大小写子串;画像字段路径和值的不区分大小写子串。" } - } + }, + "additionalProperties": false } } } @@ -526,15 +1074,83 @@ "responses": { "200": { "$ref": "#/components/responses/MemoryList" + }, + "400": { + "description": "统一非零业务错误,data=null", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "统一非零业务错误,data=null", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "502": { + "description": "统一非零业务错误,data=null", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "统一非零业务错误,data=null", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "504": { + "description": "统一非零业务错误,data=null", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } } } }, "/api/dashboard/project/memory/detail": { "post": { - "tags": [ - "记忆" - ], + "tags": ["记忆"], "summary": "记忆详情", "requestBody": { "required": true, @@ -542,10 +1158,7 @@ "application/json": { "schema": { "type": "object", - "required": [ - "memoryId", - "memType" - ], + "required": ["memoryId", "memType"], "properties": { "memoryId": { "type": "string" @@ -561,34 +1174,120 @@ "responses": { "200": { "$ref": "#/components/responses/Memory" + }, + "400": { + "description": "统一非零业务错误,data=null", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "统一非零业务错误,data=null", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "502": { + "description": "统一非零业务错误,data=null", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "统一非零业务错误,data=null", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "504": { + "description": "统一非零业务错误,data=null", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } - } + }, + "description": "Read an activated memory in the calling browser project context; without a recognized preference cookie use the configured bootstrap project or another active accessible workspace. No projectId field is accepted; copying an ID cannot grant access to a different namespace." } }, "/api/dashboard/project/memory/delete": { "post": { - "tags": [ - "记忆" - ], - "summary": "批量删除记忆", + "tags": ["记忆"], + "summary": "批量软删除记忆", + "description": "任务03:先校验全部目标的项目、标签和状态,再逐项执行。普通记忆保留上游图节点与向量,双边状态读回一致才确认;画像按单字段软删除并保留原值。恢复使用上游 previous_status。部分失败不回滚已完成项,通过同一 operationId 查询和重试。", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", - "required": [ - "memory_ids" - ], + "additionalProperties": false, + "required": ["projectId", "memory_ids", "memType"], "properties": { + "projectId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, "memory_ids": { "type": "array", + "minItems": 1, + "maxItems": 100, + "uniqueItems": true, "items": { - "type": "string" + "type": "string", + "minLength": 1, + "maxLength": 1024 } }, "memType": { "$ref": "#/components/schemas/MemoryDetailType" + }, + "operationId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9_.-]+$", + "description": "客户端可指定幂等 ID;省略时服务端生成,响应中返回。复用到不同目标或动作返回 409。" } } } @@ -598,15 +1297,116 @@ "responses": { "200": { "$ref": "#/components/responses/DeleteResult" + }, + "503": { + "description": "存在未确认项,code=50301,data 保留操作 ID、确认数和逐项结果,可查询/重试。", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Envelope" + }, + { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/MemoryOperation" + } + } + } + ] + } + } + } + }, + "400": { + "description": "参数、来源、范围、状态冲突或上游故障;非零 code。", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "参数、来源、范围、状态冲突或上游故障;非零 code。", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "参数、来源、范围、状态冲突或上游故障;非零 code。", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "409": { + "description": "参数、来源、范围、状态冲突或上游故障;非零 code。", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "502": { + "description": "参数、来源、范围、状态冲突或上游故障;非零 code。", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "504": { + "description": "参数、来源、范围、状态冲突或上游故障;非零 code。", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "500": { + "description": "参数、来源、范围、状态冲突或上游故障;非零 code。", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } } } }, "/api/dashboard/knowledgebase/list": { "post": { - "tags": [ - "知识库" - ], + "tags": ["知识库"], "summary": "知识库分页列表", "requestBody": { "required": true, @@ -637,15 +1437,43 @@ "responses": { "200": { "$ref": "#/components/responses/KnowledgeBaseRecords" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } } } }, "/api/dashboard/knowledgebase/create": { "post": { - "tags": [ - "知识库" - ], + "tags": ["知识库"], "summary": "新建知识库", "requestBody": { "required": true, @@ -653,9 +1481,7 @@ "application/json": { "schema": { "type": "object", - "required": [ - "knowledgebaseName" - ], + "required": ["knowledgebaseName"], "properties": { "knowledgebaseName": { "type": "string" @@ -677,15 +1503,43 @@ "responses": { "200": { "$ref": "#/components/responses/KnowledgeBase" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } } } }, "/api/dashboard/knowledgebase/update": { "post": { - "tags": [ - "知识库" - ], + "tags": ["知识库"], "summary": "更新知识库", "requestBody": { "required": true, @@ -693,9 +1547,7 @@ "application/json": { "schema": { "type": "object", - "required": [ - "id" - ], + "required": ["id"], "properties": { "id": { "type": "string" @@ -720,15 +1572,43 @@ "responses": { "200": { "$ref": "#/components/responses/KnowledgeBase" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } } } }, "/api/dashboard/knowledgebase/detail": { "post": { - "tags": [ - "知识库" - ], + "tags": ["知识库"], "summary": "知识库详情", "requestBody": { "required": true, @@ -736,9 +1616,7 @@ "application/json": { "schema": { "type": "object", - "required": [ - "knowledgeBaseId" - ], + "required": ["knowledgeBaseId"], "properties": { "knowledgeBaseId": { "type": "string" @@ -751,15 +1629,43 @@ "responses": { "200": { "$ref": "#/components/responses/KnowledgeBase" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } } } }, "/api/dashboard/knowledgebase/file/list": { "post": { - "tags": [ - "知识库" - ], + "tags": ["知识库"], "summary": "知识库文档列表", "requestBody": { "required": true, @@ -767,9 +1673,7 @@ "application/json": { "schema": { "type": "object", - "required": [ - "knowledgeBaseId" - ], + "required": ["knowledgeBaseId"], "properties": { "knowledgeBaseId": { "type": "string" @@ -805,15 +1709,43 @@ "responses": { "200": { "$ref": "#/components/responses/UntypedRecords" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } } } }, "/api/dashboard/knowledgebase/memory/list": { "post": { - "tags": [ - "知识库" - ], + "tags": ["知识库"], "summary": "知识库的记忆列表", "description": "比其它列表多一个 `size` 字段。", "requestBody": { @@ -822,9 +1754,7 @@ "application/json": { "schema": { "type": "object", - "required": [ - "knowledgeBaseId" - ], + "required": ["knowledgeBaseId"], "properties": { "knowledgeBaseId": { "type": "string" @@ -860,15 +1790,43 @@ "responses": { "200": { "$ref": "#/components/responses/MemoryRecords" - } - } - } - }, - "/api/dashboard/knowledgebase/project/associate": { - "post": { - "tags": [ - "知识库" - ], + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + } + } + } + }, + "/api/dashboard/knowledgebase/project/associate": { + "post": { + "tags": ["知识库"], "summary": "把知识库关联到项目", "description": "传的是该项目的**全量**知识库 id 列表,不在列表里的会被解除关联。", "requestBody": { @@ -877,10 +1835,7 @@ "application/json": { "schema": { "type": "object", - "required": [ - "projectId", - "knowledgeBaseIds" - ], + "required": ["projectId", "knowledgeBaseIds"], "properties": { "projectId": { "type": "string" @@ -899,15 +1854,43 @@ "responses": { "200": { "$ref": "#/components/responses/Ok" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } } } }, "/api/dashboard/knowledgebase/project/remove": { "post": { - "tags": [ - "知识库" - ], + "tags": ["知识库"], "summary": "解除知识库与项目的关联", "requestBody": { "required": true, @@ -915,10 +1898,7 @@ "application/json": { "schema": { "type": "object", - "required": [ - "projectId", - "knowledgeBaseId" - ], + "required": ["projectId", "knowledgeBaseId"], "properties": { "projectId": { "type": "string" @@ -938,15 +1918,43 @@ "responses": { "200": { "$ref": "#/components/responses/Ok" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } } } }, "/api/dashboard/profileTemplate/list": { "post": { - "tags": [ - "属性树模板" - ], + "tags": ["属性树模板"], "summary": "属性树模板分页列表", "description": "列表键是 `list`,不是 `records`,且多一个 `pages`。空态是 `{ current: 1, size, total: 0, pages: 0, list: [] }`。", "requestBody": { @@ -955,9 +1963,7 @@ "application/json": { "schema": { "type": "object", - "required": [ - "projectId" - ], + "required": ["projectId"], "properties": { "projectId": { "type": "string" @@ -978,15 +1984,43 @@ "responses": { "200": { "$ref": "#/components/responses/ProfileTemplateList" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } } } }, "/api/dashboard/profileTemplate/detail": { "get": { - "tags": [ - "属性树模板" - ], + "tags": ["属性树模板"], "summary": "属性树模板详情", "description": "少数 GET 之一,参数走 query。", "parameters": [ @@ -1010,15 +2044,43 @@ "responses": { "200": { "$ref": "#/components/responses/ProfileTemplate" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } } } }, "/api/dashboard/profileTemplate/create": { "post": { - "tags": [ - "属性树模板" - ], + "tags": ["属性树模板"], "summary": "新建属性树模板", "requestBody": { "required": true, @@ -1033,15 +2095,43 @@ "responses": { "200": { "$ref": "#/components/responses/Ok" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } } } }, "/api/dashboard/profileTemplate/update": { "post": { - "tags": [ - "属性树模板" - ], + "tags": ["属性树模板"], "summary": "更新属性树模板", "requestBody": { "required": true, @@ -1056,15 +2146,43 @@ "responses": { "200": { "$ref": "#/components/responses/Ok" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } } } }, "/api/dashboard/profileTemplate/delete": { "post": { - "tags": [ - "属性树模板" - ], + "tags": ["属性树模板"], "summary": "删除属性树模板", "requestBody": { "required": true, @@ -1072,10 +2190,7 @@ "application/json": { "schema": { "type": "object", - "required": [ - "projectId", - "templateId" - ], + "required": ["projectId", "templateId"], "properties": { "projectId": { "type": "string" @@ -1091,22 +2206,50 @@ "responses": { "200": { "$ref": "#/components/responses/Ok" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } } } }, "/api/dashboard/prompt-config/detail": { "post": { - "tags": [ - "自定义抽取" - ], + "tags": ["自定义抽取"], "summary": "读取抽取 Prompt", "requestBody": { "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProjectScopedRequest" + "$ref": "#/components/schemas/PromptReadRequest" } } } @@ -1114,37 +2257,91 @@ "responses": { "200": { "$ref": "#/components/responses/PromptConfig" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "Project missing, inactive or inaccessible", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "502": { + "description": "Upstream contract or operation failure", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "504": { + "description": "Upstream/model deadline exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } - } + }, + "description": "Read the active project prompt. Viewer or higher." } }, "/api/dashboard/prompt-config/save": { "post": { - "tags": [ - "自定义抽取" - ], + "tags": ["自定义抽取"], "summary": "保存抽取 Prompt", "requestBody": { "required": true, "content": { "application/json": { "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ProjectScopedRequest" - }, - { - "type": "object", - "required": [ - "prompt" - ], - "properties": { - "prompt": { - "type": "string" - } - } - } - ] + "$ref": "#/components/schemas/PromptSaveRequest" } } } @@ -1152,37 +2349,91 @@ "responses": { "200": { "$ref": "#/components/responses/Ok" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "Project missing, inactive or inaccessible", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "502": { + "description": "Upstream contract or operation failure", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "504": { + "description": "Upstream/model deadline exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } - } + }, + "description": "Owner only. Preserve ${conversation}; saved rules apply to genuine chat extraction." } }, "/api/dashboard/prompt-config/generate": { "post": { - "tags": [ - "自定义抽取" - ], + "tags": ["自定义抽取"], "summary": "按一句话描述生成 Prompt", "requestBody": { "required": true, "content": { "application/json": { "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ProjectScopedRequest" - }, - { - "type": "object", - "required": [ - "description" - ], - "properties": { - "description": { - "type": "string" - } - } - } - ] + "$ref": "#/components/schemas/PromptGenerateRequest" } } } @@ -1190,22 +2441,91 @@ "responses": { "200": { "$ref": "#/components/responses/Prompt" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "Project missing, inactive or inaccessible", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "502": { + "description": "Upstream contract or operation failure", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "504": { + "description": "Upstream/model deadline exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } - } + }, + "description": "Editor or higher. Real model call; generated text is not saved automatically." } }, "/api/dashboard/prompt-config/reset-default": { "post": { - "tags": [ - "自定义抽取" - ], + "tags": ["自定义抽取"], "summary": "恢复默认 Prompt", "requestBody": { "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProjectScopedRequest" + "$ref": "#/components/schemas/PromptResetRequest" } } } @@ -1213,64 +2533,92 @@ "responses": { "200": { "$ref": "#/components/responses/Prompt" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "Project missing, inactive or inaccessible", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "502": { + "description": "Upstream contract or operation failure", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "504": { + "description": "Upstream/model deadline exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } - } + }, + "description": "Owner only. Remove saved configuration and return the upstream default." } }, "/api/dashboard/prompt-config/debug/extract": { "post": { - "tags": [ - "自定义抽取" - ], + "tags": ["自定义抽取"], "summary": "快速调试:对一段文本跑抽取", - "description": "`messages` 是标准的 chat 消息数组,前端传一条 `user` 消息。", + "description": "Editor or higher. Real model call without memory writes; raw output is explicitly typed raw.", "requestBody": { "required": true, "content": { "application/json": { "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ProjectScopedRequest" - }, - { - "type": "object", - "required": [ - "messages" - ], - "properties": { - "messages": { - "type": "array", - "items": { - "type": "object", - "required": [ - "role", - "content" - ], - "properties": { - "role": { - "type": "string", - "enum": [ - "system", - "user", - "assistant" - ] - }, - "content": { - "type": "string" - } - } - } - }, - "extractionTypes": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MemoryDetailType" - } - } - } - } - ] + "$ref": "#/components/schemas/PromptDebugRequest" } } } @@ -1278,47 +2626,142 @@ "responses": { "200": { "$ref": "#/components/responses/ExtractDebug" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "Project missing, inactive or inaccessible", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "502": { + "description": "Upstream contract or operation failure", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "504": { + "description": "Upstream/model deadline exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } } } }, "/api/dashboard/usage/query": { "post": { - "tags": [ - "用量" - ], + "tags": ["用量"], "summary": "按天聚合的用量", - "description": "`projectId` 传 `\"all\"` 时会被改写成空串再发出。`startDate` / `endDate` 格式 `YYYY-MM-DD`,默认当月 1 号到今天。\n\n`dailyStats` 的每项里,`extractMemory` 与 `rerank` 可能缺失,前端用可选链读。响应里除 `dailyStats` 外的字段前端没有读取。", + "description": "复用调用日志的逻辑审计索引,仅统计 gateway 来源。projectId 可省略、为空串或为 all,此时范围仅为当前配置允许的项目;具体项目必须在允许范围内。当前为本机操作员配置范围,不代表账号鉴权已交付。startDate/endDate 严格 YYYY-MM-DD,按北京时间固定 UTC+8(Asia/Shanghai)切天;默认当月1日至今天,首尾日期均包含,最长366天,缺失时间的历史日志不归入任何一天。状态不限:成功、失败、pending、unknown 各逻辑调用均计一次,异步请求及终态复用 callId 合并。addMessage/searchMemory/rerank 返回 calls 和真实 totalTokens;chat/extractMemory 仅返回 token 字段,不因对象存在增加调用次数。缺失 token 为0,未知事件和 console 来源不计入本接口。非空 apiKeyId 因审计缺可信密钥标识明确返回400。includeDailyDetails=false 返回空 dailyStats,summary 与 true 相同;summary.totalCalls 仅为 addMessage+searchMemory,匹配当前页面总量;summary.totalTokens 为五个已知事件的 token 总和。 Task06 adds persisted locally-owned API-created projects to the allowed scope; stopped/archived projects are excluded, and explicitly requesting them returns404.", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", - "required": [ - "projectId" - ], "properties": { "projectId": { - "type": "string", - "description": "传 \"all\" 表示全部" + "type": ["string", "null"], + "maxLength": 128, + "description": "省略、null、空串或all:当前配置允许的全部项目;其他值必须为已允许项目。" }, "apiKeyId": { - "type": "string" + "type": ["string", "null"], + "maxLength": 256, + "description": "目前仅接受省略/null/空白;非空明确400,不静默忽略。" }, "startDate": { - "type": "string", - "format": "date" + "description": "北京时间UTC+8日期;省略/null/纯空白按默认;非空必须严格YYYY-MM-DD,不接受时间戳或日期两侧空白。", + "anyOf": [ + { + "type": "null" + }, + { + "type": "string", + "pattern": "^\\s*$" + }, + { + "type": "string", + "format": "date", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + } + ] }, "endDate": { - "type": "string", - "format": "date" + "description": "北京时间UTC+8日期;省略/null/纯空白按默认;非空必须严格YYYY-MM-DD,不接受时间戳或日期两侧空白。", + "anyOf": [ + { + "type": "null" + }, + { + "type": "string", + "pattern": "^\\s*$" + }, + { + "type": "string", + "format": "date", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + } + ] }, "includeDailyDetails": { "type": "boolean", "default": true } - } + }, + "additionalProperties": false } } } @@ -1326,15 +2769,73 @@ "responses": { "200": { "$ref": "#/components/responses/UsageQuery" + }, + "400": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "Project or log not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Audit source or index unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "500": { + "description": "Unexpected internal error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } } } }, "/api/dashboard/skill/skillMD": { "post": { - "tags": [ - "技能" - ], + "tags": ["技能"], "summary": "取技能包 Markdown", "requestBody": { "required": true, @@ -1342,9 +2843,7 @@ "application/json": { "schema": { "type": "object", - "required": [ - "oss_url" - ], + "required": ["oss_url"], "properties": { "oss_url": { "type": "string" @@ -1357,15 +2856,43 @@ "responses": { "200": { "$ref": "#/components/responses/StringData" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } } } }, "/api/user/quota/summary": { "get": { - "tags": [ - "账号" - ], + "tags": ["账号"], "summary": "额度概览", "parameters": [ { @@ -1386,9 +2913,7 @@ }, "/api/user/subscription/details": { "post": { - "tags": [ - "账号" - ], + "tags": ["账号"], "summary": "订阅周期", "requestBody": { "required": true, @@ -1417,39 +2942,108 @@ }, "/api/meta": { "get": { - "tags": [ - "运行信息" - ], + "tags": ["运行信息"], "summary": "控制台运行信息", - "description": "控制台自有的扩展接口,用于在界面上标注当前数据来源、密钥前缀与可用工具。", + "description": "控制台自有的扩展接口,用于在界面上标注当前数据来源、密钥前缀与可用工具。真实后端每次检查 MemOS 健康状态及 contract_version=2,成功才返回 mode=live;此标记不代表其他业务模块均已实现。上游异常返回非零业务码,错误体不透传上游敏感信息。", "responses": { "200": { "$ref": "#/components/responses/Meta" + }, + "502": { + "description": "上游 HTTP、JSON、服务身份或契约不符合预期,业务 code=50200", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "上游连接失败或健康状态异常,业务 code=50300", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "504": { + "description": "上游检查超时,业务 code=50400", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "default": { + "description": "统一错误。参数错误 40000、不存在 40001、无权限 40003、内部异常 50000;data 为 null", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } - } + }, + "security": [] } }, "/api/auth/me": { "get": { - "tags": [ - "账号" - ], + "tags": ["账号"], "summary": "当前登录主体", - "description": "返回账号、平台管理员标记、当前项目与可访问项目列表。", + "description": "Real authenticated account, platform administrator flag, active accessible projects and current session workspace; no password hashes, session tokens or CSRF secrets.\n\nAuthorization: valid login session; returns only the current account.", "responses": { "200": { "$ref": "#/components/responses/Account" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } + }, + "x-authorization": { + "loginRequired": true, + "scope": "session" } } }, "/api/dashboard/project/delete/{id}": { "post": { - "tags": [ - "项目" - ], + "tags": ["项目"], "summary": "归档项目(软删除)", - "description": "团队项目删除后进入归档态,可在项目列表恢复;上游记忆不删。", + "description": "Reversible archive (legacy revoke is the same compatibility alias). Preserve workspace, namespace and all memories. Listed for recovery; unavailable to normal memory/log/usage calls; active dictionary excludes it.", "parameters": [ { "name": "id", @@ -1463,15 +3057,73 @@ "responses": { "200": { "$ref": "#/components/responses/ProjectArchive" + }, + "400": { + "description": "Invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Origin rejected", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "Unknown or unavailable project", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "409": { + "description": "Name or lifecycle conflict", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Project storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } } } }, "/api/dashboard/project/restore/{id}": { "post": { - "tags": [ - "项目" - ], + "tags": ["项目"], "summary": "恢复已归档项目", "parameters": [ { @@ -1486,16 +3138,76 @@ "responses": { "200": { "$ref": "#/components/responses/Project" + }, + "400": { + "description": "Invalid parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Origin rejected", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "Unknown or unavailable project", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "409": { + "description": "Name or lifecycle conflict", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Project storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } - } + }, + "description": "Remove archive state without selecting the project. Preserve prior disabled flag and immutable namespace mapping." } }, "/api/dashboard/keys/rename/{id}": { "post": { - "tags": [ - "接口密钥" - ], + "tags": ["接口密钥"], "summary": "重命名密钥", + "description": "仅改名;网关配置文件不含名称,字节不变。", "parameters": [ { "name": "id", @@ -1512,9 +3224,7 @@ "application/json": { "schema": { "type": "object", - "required": [ - "keyName" - ], + "required": ["keyName"], "properties": { "keyName": { "type": "string" @@ -1527,15 +3237,43 @@ "responses": { "200": { "$ref": "#/components/responses/ApiKey" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } } } }, "/api/dashboard/knowledgebase/delete": { "post": { - "tags": [ - "知识库" - ], + "tags": ["知识库"], "summary": "删除知识库", "requestBody": { "required": true, @@ -1543,10 +3281,7 @@ "application/json": { "schema": { "type": "object", - "required": [ - "projectId", - "knowledgeBaseId" - ], + "required": ["projectId", "knowledgeBaseId"], "properties": { "projectId": { "type": "string" @@ -1562,15 +3297,43 @@ "responses": { "200": { "$ref": "#/components/responses/KnowledgeBaseDelete" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } } } }, "/api/dashboard/knowledgebase/file/add": { "post": { - "tags": [ - "知识库" - ], + "tags": ["知识库"], "summary": "添加知识库文件", "description": "文件内容以 data URL 或纯文本放在 `file.content` 里。", "requestBody": { @@ -1579,11 +3342,7 @@ "application/json": { "schema": { "type": "object", - "required": [ - "projectId", - "knowledgeBaseId", - "file" - ], + "required": ["projectId", "knowledgeBaseId", "file"], "properties": { "projectId": { "type": "string" @@ -1593,10 +3352,7 @@ }, "file": { "type": "object", - "required": [ - "name", - "content" - ], + "required": ["name", "content"], "properties": { "name": { "type": "string" @@ -1617,15 +3373,43 @@ "responses": { "200": { "$ref": "#/components/responses/KnowledgeFileRecords" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } } } }, "/api/dashboard/knowledgebase/file/delete": { "post": { - "tags": [ - "知识库" - ], + "tags": ["知识库"], "summary": "删除知识库文件", "requestBody": { "required": true, @@ -1633,10 +3417,7 @@ "application/json": { "schema": { "type": "object", - "required": [ - "projectId", - "fileIds" - ], + "required": ["projectId", "fileIds"], "properties": { "projectId": { "type": "string" @@ -1655,15 +3436,43 @@ "responses": { "200": { "$ref": "#/components/responses/KnowledgeFileDelete" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } } } }, "/api/dashboard/knowledgebase/file/download": { "get": { - "tags": [ - "知识库" - ], + "tags": ["知识库"], "summary": "下载知识库文件", "description": "直接返回文件字节,不套统一返回格式。", "parameters": [ @@ -1687,42 +3496,76 @@ "responses": { "200": { "$ref": "#/components/responses/FileDownload" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } } } }, "/api/dashboard/graph/memories": { "get": { - "tags": [ - "图谱" - ], + "tags": ["图谱"], "summary": "记忆星座图", - "description": "只读视图,参数放 query,便于直接打开或重放。", + "description": "Read-only, authorized active project. Combines ordinary memory graph nodes with bound ProfileMemory fields using their original field IDs. Both sources exclude deleted/archived entries. Profile fields have no invented session or relationship edges. Ordinary nodes consume the shared limit first; stats.truncated reports omitted nodes or byte limits. Source failures return an error rather than a partial success.", "parameters": [ { "name": "tenant", "in": "query", "required": false, "schema": { - "type": "string" + "type": "string", + "minLength": 1, + "maxLength": 128 }, - "description": "租户;留空用当前项目" + "description": "Dashboard project ID; defaults to the authenticated session selection. Never accepts an arbitrary upstream cube ID." }, { "name": "types", "in": "query", "required": false, "schema": { - "type": "string" + "type": "string", + "maxLength": 1024 }, - "description": "逗号分隔的记忆类型" + "description": "Comma-separated native memory type names. Only activated nodes are returned." }, { "name": "limit", "in": "query", "required": false, "schema": { - "type": "integer" + "type": "integer", + "minimum": 1, + "maximum": 2000, + "default": 2000 }, "description": "节点上限" } @@ -1730,24 +3573,94 @@ "responses": { "200": { "$ref": "#/components/responses/GraphMemoryGraph" - } - } - } + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "400": { + "description": "Invalid scope/input, upstream contract failure or deadline exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "Invalid scope/input, upstream contract failure or deadline exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "502": { + "description": "Invalid scope/input, upstream contract failure or deadline exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "504": { + "description": "Invalid scope/input, upstream contract failure or deadline exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + } + } + } }, "/api/dashboard/graph/memory": { "get": { - "tags": [ - "图谱" - ], + "tags": ["图谱"], "summary": "单条记忆全文", - "description": "与图谱节点同源,供抽屉里的「展开全文」使用。", + "description": "Read-only, authorized active project. Resolves ordinary IDs from the memory graph store and profile field IDs from the profile store. Full text excludes deleted/archived entries and is bounded to 1 MiB of JSON.", "parameters": [ { "name": "id", "in": "query", "required": true, "schema": { - "type": "string" + "type": "string", + "minLength": 1, + "maxLength": 1024 } }, { @@ -1755,36 +3668,175 @@ "in": "query", "required": false, "schema": { - "type": "string" - } + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "description": "Dashboard project ID; defaults to the authenticated session selection. Never accepts an arbitrary upstream cube ID." } ], "responses": { "200": { "$ref": "#/components/responses/GraphMemory" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "400": { + "description": "Invalid scope/input, upstream contract failure or deadline exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "Invalid scope/input, upstream contract failure or deadline exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "502": { + "description": "Invalid scope/input, upstream contract failure or deadline exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "504": { + "description": "Invalid scope/input, upstream contract failure or deadline exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } } } }, "/api/dashboard/models": { "get": { - "tags": [ - "模型" - ], + "tags": ["模型"], "summary": "模型清单", - "description": "返回上游实际生效的模型配置,不是前端静态副本。", + "description": "Authenticated read-only inventory from effective upstream configuration. Exposes only known model/source/role/group and sanitized host/dimension/backend; never API keys, credentials, query strings or configuration bodies.", "responses": { "200": { "$ref": "#/components/responses/ModelRecords" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "400": { + "description": "Invalid scope/input, upstream contract failure or deadline exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "Invalid scope/input, upstream contract failure or deadline exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "502": { + "description": "Invalid scope/input, upstream contract failure or deadline exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "504": { + "description": "Invalid scope/input, upstream contract failure or deadline exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } } } }, "/admin/accounts": { "get": { - "tags": [ - "账号" - ], + "tags": ["账号"], "summary": "账号分页列表", "parameters": [ { @@ -1792,7 +3844,8 @@ "in": "query", "required": false, "schema": { - "type": "string" + "type": "string", + "maxLength": 128 }, "description": "按用户名或显示名搜索" }, @@ -1802,7 +3855,9 @@ "required": false, "schema": { "type": "integer", - "default": 1 + "default": 1, + "minimum": 1, + "maximum": 2147483647 } }, { @@ -1811,22 +3866,88 @@ "required": false, "schema": { "type": "integer", - "default": 20 + "default": 20, + "minimum": 1, + "maximum": 200 } } ], "responses": { "200": { "$ref": "#/components/responses/AdminAccountRecords" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "400": { + "description": "Invalid fields or pagination", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "Resource outside visible scope or nonexistent", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "409": { + "description": "Last effective admin/owner or lifecycle conflict", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } - } + }, + "x-authorization": { + "loginRequired": true, + "scope": "platform", + "platformAdminRequired": true + }, + "description": "Authorization: platform administrator required. Project ownership alone grants no platform account-management permission. Last effective administrator and project-owner invariants remain enforced." } }, "/admin/accounts/{id}": { "post": { - "tags": [ - "账号" - ], + "tags": ["账号"], "summary": "更新账号", "parameters": [ { @@ -1846,7 +3967,9 @@ "type": "object", "properties": { "display_name": { - "type": "string" + "type": "string", + "minLength": 1, + "maxLength": 128 }, "disabled": { "type": "boolean" @@ -1854,7 +3977,9 @@ "platform_admin": { "type": "boolean" } - } + }, + "additionalProperties": false, + "minProperties": 1 } } } @@ -1862,15 +3987,79 @@ "responses": { "200": { "$ref": "#/components/responses/AdminAccount" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "400": { + "description": "Invalid fields or pagination", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "Resource outside visible scope or nonexistent", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "409": { + "description": "Last effective admin/owner or lifecycle conflict", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } - } + }, + "x-authorization": { + "loginRequired": true, + "scope": "platform", + "platformAdminRequired": true + }, + "description": "Authorization: platform administrator required. Project ownership alone grants no platform account-management permission. Last effective administrator and project-owner invariants remain enforced." }, "delete": { - "tags": [ - "账号" - ], + "tags": ["账号"], "summary": "删除账号", - "description": "个人项目与成员关系一起删;团队 Key / 邀请保留但清空创建者。", + "description": "个人项目与成员关系一起删;团队 Key / 邀请保留但清空创建者。\n\nAuthorization: platform administrator required. Project ownership alone grants no platform account-management permission. Last effective administrator and project-owner invariants remain enforced.", "parameters": [ { "name": "id", @@ -1884,15 +4073,78 @@ "responses": { "200": { "$ref": "#/components/responses/Ok" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "400": { + "description": "Invalid fields or pagination", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "Resource outside visible scope or nonexistent", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "409": { + "description": "Last effective admin/owner or lifecycle conflict", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } + }, + "x-authorization": { + "loginRequired": true, + "scope": "platform", + "platformAdminRequired": true } } }, "/admin/tenants/{tenantId}/members": { "get": { - "tags": [ - "账号" - ], + "tags": ["账号"], "summary": "项目成员列表", "parameters": [ { @@ -1907,13 +4159,80 @@ "responses": { "200": { "$ref": "#/components/responses/ProjectMemberRecords" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "400": { + "description": "Invalid fields or pagination", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "Resource outside visible scope or nonexistent", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "409": { + "description": "Last effective admin/owner or lifecycle conflict", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } - } + }, + "x-authorization": { + "loginRequired": true, + "scope": "project", + "minimumRole": "viewer", + "platformAdminOverride": true, + "selfOnly": false, + "teamOnly": true + }, + "description": "Authorization: target team project viewer or platform administrator. No access to unrelated projects. Insufficient role on a visible project returns 403; an inaccessible project or foreign resource returns 404. Business-state and last-owner constraints remain enforced." }, "post": { - "tags": [ - "账号" - ], + "tags": ["账号"], "summary": "添加成员或修改角色", "parameters": [ { @@ -1931,20 +4250,32 @@ "application/json": { "schema": { "type": "object", - "required": [ - "role" - ], + "required": ["role"], "properties": { "account_id": { - "type": "string" + "type": "string", + "minLength": 1, + "maxLength": 128 }, "username": { - "type": "string" + "type": "string", + "minLength": 1, + "maxLength": 64 }, "role": { - "type": "string" + "type": "string", + "enum": ["viewer", "editor", "owner"] } - } + }, + "additionalProperties": false, + "oneOf": [ + { + "required": ["account_id"] + }, + { + "required": ["username"] + } + ] } } } @@ -1952,15 +4283,82 @@ "responses": { "200": { "$ref": "#/components/responses/MemberSet" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "400": { + "description": "Invalid fields or pagination", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "Resource outside visible scope or nonexistent", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "409": { + "description": "Last effective admin/owner or lifecycle conflict", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } - } + }, + "x-authorization": { + "loginRequired": true, + "scope": "project", + "minimumRole": "owner", + "platformAdminOverride": true, + "selfOnly": false, + "teamOnly": true + }, + "description": "Authorization: target team project owner or platform administrator. No access to unrelated projects. Insufficient role on a visible project returns 403; an inaccessible project or foreign resource returns 404. Business-state and last-owner constraints remain enforced." } }, "/admin/tenants/{tenantId}/members/{accountId}/remove": { "post": { - "tags": [ - "账号" - ], + "tags": ["账号"], "summary": "移除成员", "parameters": [ { @@ -1994,17 +4392,84 @@ "responses": { "200": { "$ref": "#/components/responses/Ok" - } - } - } + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "400": { + "description": "Invalid fields or pagination", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "Resource outside visible scope or nonexistent", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "409": { + "description": "Last effective admin/owner or lifecycle conflict", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + } + }, + "x-authorization": { + "loginRequired": true, + "scope": "project", + "minimumRole": "owner", + "platformAdminOverride": true, + "selfOnly": false, + "teamOnly": true + }, + "description": "Authorization: target team project owner or platform administrator. No access to unrelated projects. Insufficient role on a visible project returns 403; an inaccessible project or foreign resource returns 404. Business-state and last-owner constraints remain enforced." + } }, "/admin/tenants/{tenantId}/members/leave": { "post": { - "tags": [ - "账号" - ], + "tags": ["账号"], "summary": "自己退出项目", - "description": "不需要管理权限;最后一个 owner 会被后端拒绝。", + "description": "不需要管理权限;最后一个 owner 会被后端拒绝。\n\nAuthorization: target team project viewer or platform administrator. Self-exit only; the final effective owner cannot leave.", "parameters": [ { "name": "tenantId", @@ -2029,17 +4494,83 @@ "responses": { "200": { "$ref": "#/components/responses/Ok" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "400": { + "description": "Invalid fields or pagination", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "Resource outside visible scope or nonexistent", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "409": { + "description": "Last effective admin/owner or lifecycle conflict", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } + }, + "x-authorization": { + "loginRequired": true, + "scope": "project", + "minimumRole": "viewer", + "platformAdminOverride": true, + "selfOnly": true, + "teamOnly": true } } }, "/admin/tenants/{tenantId}/member-candidates": { "get": { - "tags": [ - "账号" - ], + "tags": ["账号"], "summary": "可加入成员候选", - "description": "`@` 搜索可加入的人;只有项目 owner 能调。", + "description": "Owner or platform admin may search usernames/display names. Returns at most100 candidates with total matching count and existing-member/disabled flags.\n\nAuthorization: target team project owner or platform administrator. No access to unrelated projects. Insufficient role on a visible project returns 403; an inaccessible project or foreign resource returns 404. Business-state and last-owner constraints remain enforced.", "parameters": [ { "name": "tenantId", @@ -2054,22 +4585,89 @@ "in": "query", "required": false, "schema": { - "type": "string" + "type": "string", + "maxLength": 128 } } ], "responses": { "200": { "$ref": "#/components/responses/MemberCandidateRecords" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "400": { + "description": "Invalid fields or pagination", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "Resource outside visible scope or nonexistent", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "409": { + "description": "Last effective admin/owner or lifecycle conflict", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } + }, + "x-authorization": { + "loginRequired": true, + "scope": "project", + "minimumRole": "owner", + "platformAdminOverride": true, + "selfOnly": false, + "teamOnly": true } } }, "/admin/tenants/{tenantId}/invites": { "get": { - "tags": [ - "账号" - ], + "tags": ["账号"], "summary": "邀请列表", "parameters": [ { @@ -2084,13 +4682,80 @@ "responses": { "200": { "$ref": "#/components/responses/ProjectInviteRecords" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "400": { + "description": "Invalid fields or pagination", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "Resource outside visible scope or nonexistent", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "409": { + "description": "Last effective admin/owner or lifecycle conflict", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } - } + }, + "x-authorization": { + "loginRequired": true, + "scope": "project", + "minimumRole": "owner", + "platformAdminOverride": true, + "selfOnly": false, + "teamOnly": true + }, + "description": "Authorization: target team project owner or platform administrator. No access to unrelated projects. Insufficient role on a visible project returns 403; an inaccessible project or foreign resource returns 404. Business-state and last-owner constraints remain enforced." }, "post": { - "tags": [ - "账号" - ], + "tags": ["账号"], "summary": "创建邀请", "parameters": [ { @@ -2108,18 +4773,20 @@ "application/json": { "schema": { "type": "object", - "required": [ - "role" - ], + "required": ["role"], "properties": { "role": { - "type": "string" + "type": "string", + "enum": ["viewer", "editor", "owner"] }, "expiresIn": { "type": "string", - "description": "例如 `168h`;留空用后端默认值" + "pattern": "^[1-9][0-9]{0,6}[smh]$", + "default": "168h", + "description": "Positive seconds/minutes/hours, maximum30days; e.g.168h. Values above30days rejected400." } - } + }, + "additionalProperties": false } } } @@ -2127,15 +4794,82 @@ "responses": { "200": { "$ref": "#/components/responses/CreatedInvite" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "400": { + "description": "Invalid fields or pagination", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "Resource outside visible scope or nonexistent", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "409": { + "description": "Last effective admin/owner or lifecycle conflict", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } - } + }, + "x-authorization": { + "loginRequired": true, + "scope": "project", + "minimumRole": "owner", + "platformAdminOverride": true, + "selfOnly": false, + "teamOnly": true + }, + "description": "Authorization: target team project owner or platform administrator. No access to unrelated projects. Insufficient role on a visible project returns 403; an inaccessible project or foreign resource returns 404. Business-state and last-owner constraints remain enforced." } }, "/admin/tenants/{tenantId}/invites/{inviteId}/revoke": { "post": { - "tags": [ - "账号" - ], + "tags": ["账号"], "summary": "撤销邀请", "parameters": [ { @@ -2169,15 +4903,82 @@ "responses": { "200": { "$ref": "#/components/responses/Ok" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "400": { + "description": "Invalid fields or pagination", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "Resource outside visible scope or nonexistent", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "409": { + "description": "Last effective admin/owner or lifecycle conflict", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } - } + }, + "x-authorization": { + "loginRequired": true, + "scope": "project", + "minimumRole": "owner", + "platformAdminOverride": true, + "selfOnly": false, + "teamOnly": true + }, + "description": "Authorization: target team project owner or platform administrator. No access to unrelated projects. Insufficient role on a visible project returns 403; an inaccessible project or foreign resource returns 404. Business-state and last-owner constraints remain enforced." } }, "/admin/tenants/{tenantId}/invites/{inviteId}/role": { "post": { - "tags": [ - "账号" - ], + "tags": ["账号"], "summary": "修改邀请角色", "parameters": [ { @@ -2200,15 +5001,1238 @@ "requestBody": { "required": true, "content": { - "application/json": { + "application/json": { + "schema": { + "type": "object", + "required": ["role"], + "properties": { + "role": { + "type": "string", + "enum": ["viewer", "editor", "owner"] + } + }, + "additionalProperties": false + } + } + } + }, + "responses": { + "200": { + "$ref": "#/components/responses/InviteRole" + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Insufficient platform/project role or rejected browser Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "400": { + "description": "Invalid fields or pagination", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "Resource outside visible scope or nonexistent", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "409": { + "description": "Last effective admin/owner or lifecycle conflict", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + } + }, + "x-authorization": { + "loginRequired": true, + "scope": "project", + "minimumRole": "owner", + "platformAdminOverride": true, + "selfOnly": false, + "teamOnly": true + }, + "description": "Authorization: target team project owner or platform administrator. No access to unrelated projects. Insufficient role on a visible project returns 403; an inaccessible project or foreign resource returns 404. Business-state and last-owner constraints remain enforced." + } + }, + "/api/dashboard/project/memory/restore": { + "post": { + "tags": ["记忆"], + "summary": "恢复软删除记忆", + "description": "任务03:先校验全部目标的项目、标签和状态,再逐项执行。普通记忆保留上游图节点与向量,双边状态读回一致才确认;画像按单字段软删除并保留原值。恢复使用上游 previous_status。部分失败不回滚已完成项,通过同一 operationId 查询和重试。", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "required": ["projectId", "memory_ids", "memType"], + "properties": { + "projectId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "memory_ids": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 1024 + } + }, + "memType": { + "$ref": "#/components/schemas/MemoryDetailType" + }, + "operationId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9_.-]+$", + "description": "客户端可指定幂等 ID;省略时服务端生成,响应中返回。复用到不同目标或动作返回 409。" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "操作回执。仅全部 confirmed 时写操作 code=0;查询接口 code=0 仅表示查询成功,仍须检查 state。", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Envelope" + }, + { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/MemoryOperation" + } + } + } + ] + } + } + } + }, + "503": { + "description": "存在未确认项,code=50301,data 保留操作 ID、确认数和逐项结果,可查询/重试。", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Envelope" + }, + { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/MemoryOperation" + } + } + } + ] + } + } + } + }, + "400": { + "description": "参数、来源、范围、状态冲突或上游故障;非零 code。", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "参数、来源、范围、状态冲突或上游故障;非零 code。", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "参数、来源、范围、状态冲突或上游故障;非零 code。", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "409": { + "description": "参数、来源、范围、状态冲突或上游故障;非零 code。", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "502": { + "description": "参数、来源、范围、状态冲突或上游故障;非零 code。", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "504": { + "description": "参数、来源、范围、状态冲突或上游故障;非零 code。", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "500": { + "description": "参数、来源、范围、状态冲突或上游故障;非零 code。", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + } + } + } + }, + "/api/dashboard/project/memory/retry": { + "post": { + "tags": ["记忆"], + "summary": "重试未确认的记忆生命周期操作", + "description": "仅重试持久回执中尚未确认的项;沿用原子操作 ID,不重复执行已确认项。", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": false, + "required": ["projectId", "operationId"], + "properties": { + "projectId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "operationId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9_.-]+$", + "description": "客户端可指定幂等 ID;省略时服务端生成,响应中返回。复用到不同目标或动作返回 409。" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "操作回执。仅全部 confirmed 时写操作 code=0;查询接口 code=0 仅表示查询成功,仍须检查 state。", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Envelope" + }, + { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/MemoryOperation" + } + } + } + ] + } + } + } + }, + "503": { + "description": "存在未确认项,code=50301,data 保留操作 ID、确认数和逐项结果,可查询/重试。", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Envelope" + }, + { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/MemoryOperation" + } + } + } + ] + } + } + } + }, + "400": { + "description": "参数、来源、范围、状态冲突或上游故障;非零 code。", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "参数、来源、范围、状态冲突或上游故障;非零 code。", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "参数、来源、范围、状态冲突或上游故障;非零 code。", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "409": { + "description": "参数、来源、范围、状态冲突或上游故障;非零 code。", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "502": { + "description": "参数、来源、范围、状态冲突或上游故障;非零 code。", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "504": { + "description": "参数、来源、范围、状态冲突或上游故障;非零 code。", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "500": { + "description": "参数、来源、范围、状态冲突或上游故障;非零 code。", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + } + } + } + }, + "/api/dashboard/project/memory/operation": { + "get": { + "tags": ["记忆"], + "summary": "查询记忆生命周期操作回执", + "description": "查询自身成功时 code=0;data.state=pending 表示仍未完成,不能解读为删除成功。按项目限定回执,必要时读取上游意图进度,不触发新的状态变更。", + "parameters": [ + { + "in": "query", + "name": "projectId", + "required": true, + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + { + "in": "query", + "name": "operationId", + "required": true, + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9_.-]+$", + "description": "客户端可指定幂等 ID;省略时服务端生成,响应中返回。复用到不同目标或动作返回 409。" + } + } + ], + "responses": { + "200": { + "description": "操作回执。仅全部 confirmed 时写操作 code=0;查询接口 code=0 仅表示查询成功,仍须检查 state。", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Envelope" + }, + { + "type": "object", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/MemoryOperation" + } + } + } + ] + } + } + } + }, + "400": { + "description": "参数、来源、范围、状态冲突或上游故障;非零 code。", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "参数、来源、范围、状态冲突或上游故障;非零 code。", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "参数、来源、范围、状态冲突或上游故障;非零 code。", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "409": { + "description": "参数、来源、范围、状态冲突或上游故障;非零 code。", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "502": { + "description": "参数、来源、范围、状态冲突或上游故障;非零 code。", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "504": { + "description": "参数、来源、范围、状态冲突或上游故障;非零 code。", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "500": { + "description": "参数、来源、范围、状态冲突或上游故障;非零 code。", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "401": { + "description": "Login session missing, expired, disabled or deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "503": { + "description": "Identity/control storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + } + } + } + }, + "/__login": { + "get": { + "tags": ["账号"], + "summary": "Login form", + "security": [], + "responses": { + "200": { + "description": "Escaped HTML form with CSRF token; no-store, same-origin referrer policy and restrictive CSP", + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "Malformed or oversized form", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "401": { + "description": "Invalid/disabled credentials or expired account session", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Missing/invalid CSRF or foreign Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "Unknown invitation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "409": { + "description": "Invite consumed/revoked, account exists, existing member, or project inactive", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "410": { + "description": "Invite expired", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "429": { + "description": "Login attempt limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + } + } + }, + "post": { + "tags": ["账号"], + "summary": "Login form submit", + "security": [], + "responses": { + "303": { + "description": "Same-origin continuation after committed action; session cookie is issued for login/new invite account" + }, + "400": { + "description": "Malformed or oversized form", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "401": { + "description": "Invalid/disabled credentials or expired account session", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Missing/invalid CSRF or foreign Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "Unknown invitation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "409": { + "description": "Invite consumed/revoked, account exists, existing member, or project inactive", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "410": { + "description": "Invite expired", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "429": { + "description": "Login attempt limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "type": "object", + "required": ["csrf", "username", "password"], + "properties": { + "csrf": { + "type": "string", + "minLength": 43, + "maxLength": 43 + }, + "username": { + "type": "string", + "maxLength": 64 + }, + "password": { + "type": "string", + "maxLength": 256 + }, + "next": { + "type": "string", + "description": "Only same-origin /__login/invite/{token} continuation accepted" + } + } + } + } + } + } + } + }, + "/__logout": { + "get": { + "tags": ["账号"], + "summary": "Logout confirmation", + "security": [ + { + "CookieSession": [] + } + ], + "responses": { + "200": { + "description": "Escaped HTML form with CSRF token; no-store, same-origin referrer policy and restrictive CSP", + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "Malformed or oversized form", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "401": { + "description": "Invalid/disabled credentials or expired account session", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Missing/invalid CSRF or foreign Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "Unknown invitation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "409": { + "description": "Invite consumed/revoked, account exists, existing member, or project inactive", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "410": { + "description": "Invite expired", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "429": { + "description": "Login attempt limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + } + } + }, + "post": { + "tags": ["账号"], + "summary": "Logout confirmation submit", + "security": [ + { + "CookieSession": [] + } + ], + "responses": { + "303": { + "description": "Same-origin continuation after committed action; session cookie is issued for login/new invite account" + }, + "400": { + "description": "Malformed or oversized form", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "401": { + "description": "Invalid/disabled credentials or expired account session", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Missing/invalid CSRF or foreign Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "Unknown invitation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "409": { + "description": "Invite consumed/revoked, account exists, existing member, or project inactive", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "410": { + "description": "Invite expired", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "429": { + "description": "Login attempt limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "type": "object", + "required": ["csrf"], + "properties": { + "csrf": { + "type": "string", + "minLength": 43, + "maxLength": 43 + } + } + } + } + } + } + } + }, + "/__login/invite/{token}": { + "get": { + "tags": ["账号"], + "summary": "Invitation landing and acceptance/signup", + "security": [], + "responses": { + "200": { + "description": "Escaped HTML form with CSRF token; no-store, same-origin referrer policy and restrictive CSP", + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "Malformed or oversized form", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "401": { + "description": "Invalid/disabled credentials or expired account session", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Missing/invalid CSRF or foreign Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "Unknown invitation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "409": { + "description": "Invite consumed/revoked, account exists, existing member, or project inactive", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "410": { + "description": "Invite expired", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "429": { + "description": "Login attempt limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + } + }, + "parameters": [ + { + "name": "token", + "in": "path", + "required": true, + "schema": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]{43}$" + } + } + ] + }, + "post": { + "tags": ["账号"], + "summary": "Invitation landing and acceptance/signup submit", + "security": [], + "responses": { + "303": { + "description": "Same-origin continuation after committed action; session cookie is issued for login/new invite account" + }, + "400": { + "description": "Malformed or oversized form", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "401": { + "description": "Invalid/disabled credentials or expired account session", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "Missing/invalid CSRF or foreign Origin", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "404": { + "description": "Unknown invitation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "409": { + "description": "Invite consumed/revoked, account exists, existing member, or project inactive", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "410": { + "description": "Invite expired", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "429": { + "description": "Login attempt limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + } + }, + "parameters": [ + { + "name": "token", + "in": "path", + "required": true, + "schema": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]{43}$" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "type": "object", + "required": ["csrf"], + "properties": { + "csrf": { + "type": "string", + "minLength": 43, + "maxLength": 43 + }, + "username": { + "type": "string", + "description": "New account username; omit for an authenticated accept" + }, + "password": { + "type": "string", + "minLength": 12, + "maxLength": 256 + } + } + } + } + } + } + } + }, + "/__login/register": { + "get": { + "tags": ["认证"], + "summary": "注册页面:账号、密码、确认密码", + "responses": { + "200": { + "description": "CSRF-protected registration HTML", + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + } + } + } + }, + "post": { + "tags": ["认证"], + "summary": "注册普通账号及个人空间,成功后返回登录页", + "requestBody": { + "required": true, + "content": { + "application/x-www-form-urlencoded": { "schema": { "type": "object", - "required": [ - "role" - ], + "additionalProperties": false, + "required": ["csrf", "username", "password", "confirm_password"], "properties": { - "role": { - "type": "string" + "csrf": { + "type": "string", + "minLength": 43, + "maxLength": 43 + }, + "username": { + "type": "string", + "pattern": "^[A-Za-z0-9_.-]{1,64}$" + }, + "password": { + "type": "string", + "minLength": 12, + "maxLength": 256 + }, + "confirm_password": { + "type": "string", + "minLength": 12, + "maxLength": 256 } } } @@ -2216,8 +6240,53 @@ } }, "responses": { - "200": { - "$ref": "#/components/responses/InviteRole" + "303": { + "description": "Account committed; redirect to /__login?registered=1" + }, + "400": { + "description": "Invalid form or mismatched passwords; never echoes password", + "content": { + "text/html": { + "schema": { + "type": "string" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "403": { + "description": "CSRF or Origin rejected", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } + }, + "409": { + "description": "Username already exists", + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + } + }, + "503": { + "description": "Identity storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BackendErrorEnvelope" + } + } + } } } } @@ -2227,10 +6296,7 @@ "schemas": { "Envelope": { "type": "object", - "required": [ - "code", - "message" - ], + "required": ["code", "message"], "properties": { "code": { "type": "integer", @@ -2242,12 +6308,33 @@ "data": {} } }, + "BackendErrorEnvelope": { + "allOf": [ + { + "$ref": "#/components/schemas/Envelope" + }, + { + "type": "object", + "required": ["code", "data", "message"], + "properties": { + "code": { + "type": "integer", + "minimum": 1 + }, + "data": { + "type": "null" + }, + "message": { + "type": "string", + "minLength": 1 + } + } + } + ] + }, "Project": { "type": "object", - "required": [ - "id", - "name" - ], + "required": ["id", "name"], "properties": { "id": { "type": "string" @@ -2260,10 +6347,7 @@ }, "status": { "type": "string", - "enum": [ - "ENABLED", - "DISABLED" - ], + "enum": ["ENABLED", "DISABLED"], "description": "`ENABLED` 的那条是当前项目" }, "createTime": { @@ -2271,14 +6355,11 @@ }, "isMultiView": { "type": "integer", - "enum": [ - 0, - 1 - ] + "enum": [0, 1] }, "role": { "type": "string", - "description": "当前账号在这个项目里的角色" + "enum": ["viewer", "editor", "owner"] }, "disabled": { "type": "boolean", @@ -2286,10 +6367,7 @@ }, "kind": { "type": "string", - "enum": [ - "team", - "personal" - ] + "enum": ["team", "personal"] }, "archived": { "type": "boolean", @@ -2302,10 +6380,7 @@ }, "ProjectDictItem": { "type": "object", - "required": [ - "id", - "name" - ], + "required": ["id", "name"], "properties": { "id": { "type": "string" @@ -2315,21 +6390,13 @@ }, "status": { "type": "string", - "enum": [ - "ENABLED", - "DISABLED" - ] + "enum": ["ENABLED", "DISABLED"] } } }, "ApiKey": { "type": "object", - "required": [ - "id", - "keyName", - "apiKey", - "createTime" - ], + "required": ["id", "keyName", "apiKey", "createTime"], "properties": { "id": { "type": "string" @@ -2339,7 +6406,7 @@ }, "apiKey": { "type": "string", - "description": "完整密钥,列表里也下发,前端自己截断展示" + "description": "完整密钥,列表里也下发,前端自己截断展示。明文保存于本地 control.db 与网关配置文件(gitignore 范围内,无 KMS 不加密——诚实声明)。" }, "createTime": { "type": "string" @@ -2368,16 +6435,11 @@ }, "LogStatus": { "type": "string", - "description": "`SUCCESS` 与 `success` 都判为成功,其余判为失败" + "description": "SUCCESS (or historical success), FAILURE, PENDING, UNKNOWN. Unknown/missing evidence is not treated as successful; pending duration is null." }, "RequestLog": { "type": "object", - "required": [ - "id", - "createTime", - "eventType", - "status" - ], + "required": ["id", "createTime", "eventType", "status"], "properties": { "id": { "type": "string" @@ -2386,7 +6448,8 @@ "type": "string" }, "eventType": { - "$ref": "#/components/schemas/EventType" + "type": "string", + "description": "Known normalized event or original unknown event label; an absent event is unknown." }, "userIds": { "type": "array", @@ -2404,10 +6467,7 @@ "$ref": "#/components/schemas/LogStatus" }, "duration": { - "type": [ - "number", - "null" - ], + "type": ["number", "null"], "description": "毫秒数;异步 add 还没跑完时是 null" }, "callId": { @@ -2419,17 +6479,16 @@ }, "responseTokens": { "type": "integer" + }, + "source": { + "type": "string", + "enum": ["gateway", "console"] } } }, "LogDetail": { "type": "object", - "required": [ - "id", - "createTime", - "eventType", - "status" - ], + "required": ["id", "createTime", "eventType", "status"], "properties": { "id": { "type": "string" @@ -2438,16 +6497,14 @@ "type": "string" }, "eventType": { - "$ref": "#/components/schemas/EventType" + "type": "string", + "description": "Known normalized event or original unknown event label; an absent event is unknown." }, "status": { "$ref": "#/components/schemas/LogStatus" }, "duration": { - "type": [ - "number", - "null" - ] + "type": ["number", "null"] }, "traceId": { "type": "string" @@ -2484,42 +6541,35 @@ "llmCalls": { "type": "integer", "description": "上游 add 回报的 LLM 调用次数" + }, + "source": { + "type": "string", + "enum": ["gateway", "console"] + }, + "requestId": { + "type": "string" } } }, "MemorySubjectType": { "type": "string", - "enum": [ - "user", - "agent", - "public" - ] + "enum": ["user", "agent", "public"] }, "MemoryDetailType": { "type": "string", "description": "记忆类型。`text_mem` 事实记忆、`pref_mem` 偏好记忆、`event_mem` 事件记忆、`profile_mem` 属性树记忆、`skill_mem` 技能、`tool_mem` 工具记忆。", - "enum": [ - "text_mem", - "pref_mem", - "event_mem", - "profile_mem", - "skill_mem", - "tool_mem" - ] + "enum": ["text_mem", "pref_mem", "event_mem", "profile_mem", "skill_mem", "tool_mem"] }, "Memory": { "type": "object", - "required": [ - "memoryId", - "memoryContent", - "createTime" - ], + "required": ["memoryId", "memoryContent", "createTime"], "properties": { "memoryId": { "type": "string" }, "memoryContent": { - "type": "string" + "type": "string", + "description": "列表最多500字符加省略号,详情返回全文;不包含 embedding、sources、history。" }, "subjectType": { "$ref": "#/components/schemas/MemorySubjectType" @@ -2554,10 +6604,7 @@ }, "KnowledgeBase": { "type": "object", - "required": [ - "kbId", - "knowledgebaseName" - ], + "required": ["kbId", "knowledgebaseName"], "properties": { "kbId": { "type": "string" @@ -2590,15 +6637,7 @@ }, "DocumentStatus": { "type": "string", - "enum": [ - "waiting", - "uploading", - "processing", - "available", - "deleting", - "failed", - "processFailed" - ] + "enum": ["waiting", "uploading", "processing", "available", "deleting", "failed", "processFailed"] }, "ProfileTemplateField": { "type": "object", @@ -2661,7 +6700,8 @@ "type": "object", "properties": { "type": { - "$ref": "#/components/schemas/MemoryDetailType" + "type": "string", + "enum": ["text_mem", "pref_mem", "event_mem", "raw"] }, "content": { "type": "string" @@ -2670,9 +6710,7 @@ }, "UsageDailyStat": { "type": "object", - "required": [ - "date" - ], + "required": ["date"], "properties": { "date": { "type": "string", @@ -2699,10 +6737,12 @@ "type": "object", "properties": { "calls": { - "type": "integer" + "type": "integer", + "minimum": 0 }, "totalTokens": { - "type": "integer" + "type": "integer", + "minimum": 0 } } }, @@ -2710,13 +6750,16 @@ "type": "object", "properties": { "requestTokens": { - "type": "integer" + "type": "integer", + "minimum": 0 }, "responseTokens": { - "type": "integer" + "type": "integer", + "minimum": 0 }, "totalTokens": { - "type": "integer" + "type": "integer", + "minimum": 0 } } }, @@ -2781,33 +6824,27 @@ }, "Meta": { "type": "object", - "required": [ - "mode", - "keyPrefix", - "tools" - ], + "required": ["mode", "keyPrefix", "tools"], "properties": { "mode": { "type": "string", - "enum": [ - "sample", - "live" - ], + "enum": ["sample", "live"], "description": "`sample` 示例后端,`live` 真实后端" }, "service": { "type": "string" }, "health": { - "type": [ - "object", - "null" - ] + "type": ["object", "null"] }, "keyPrefix": { "type": "string", "description": "新建密钥的展示与校验用它" }, + "authEnabled": { + "type": "boolean", + "description": "当前控制台后端是否已启用鉴权;基础运行信息阶段为 false" + }, "tools": { "type": "array", "items": { @@ -2816,21 +6853,9 @@ } } }, - "ProjectScopedRequest": { - "type": "object", - "properties": { - "projectId": { - "type": "string" - } - } - }, "AccountProject": { "type": "object", - "required": [ - "id", - "name", - "role" - ], + "required": ["id", "name", "role"], "properties": { "id": { "type": "string" @@ -2839,26 +6864,18 @@ "type": "string" }, "role": { - "type": "string" + "type": "string", + "enum": ["viewer", "editor", "owner"] }, "kind": { "type": "string", - "enum": [ - "team", - "personal" - ] + "enum": ["team", "personal"] } } }, "Account": { "type": "object", - "required": [ - "username", - "displayName", - "platformAdmin", - "currentProjectId", - "projects" - ], + "required": ["username", "displayName", "platformAdmin", "currentProjectId", "projects"], "properties": { "accountId": { "type": "string", @@ -2887,14 +6904,7 @@ "AdminAccount": { "type": "object", "description": "`/admin/*` 的账号对象,字段沿用 snake_case。", - "required": [ - "id", - "username", - "display_name", - "platform_admin", - "disabled", - "created_at" - ], + "required": ["id", "username", "display_name", "platform_admin", "disabled", "created_at"], "properties": { "id": { "type": "string" @@ -2924,14 +6934,7 @@ }, "ProjectMember": { "type": "object", - "required": [ - "account_id", - "username", - "display_name", - "disabled", - "role", - "created_at" - ], + "required": ["account_id", "username", "display_name", "disabled", "role", "created_at"], "properties": { "account_id": { "type": "string" @@ -2946,7 +6949,8 @@ "type": "boolean" }, "role": { - "type": "string" + "type": "string", + "enum": ["viewer", "editor", "owner"] }, "created_at": { "type": "string" @@ -2955,13 +6959,7 @@ }, "MemberCandidate": { "type": "object", - "required": [ - "accountId", - "username", - "displayName", - "disabled", - "member" - ], + "required": ["accountId", "username", "displayName", "disabled", "member"], "properties": { "accountId": { "type": "string" @@ -2983,14 +6981,7 @@ }, "ProjectInvite": { "type": "object", - "required": [ - "id", - "tenantId", - "role", - "status", - "createdAt", - "expiresAt" - ], + "required": ["id", "tenantId", "role", "status", "createdAt", "expiresAt"], "properties": { "id": { "type": "string" @@ -2999,16 +6990,12 @@ "type": "string" }, "role": { - "type": "string" + "type": "string", + "enum": ["viewer", "editor", "owner"] }, "status": { "type": "string", - "enum": [ - "active", - "used", - "expired", - "revoked" - ] + "enum": ["active", "used", "expired", "revoked"] }, "createdAt": { "type": "string" @@ -3030,14 +7017,7 @@ "CreatedInvite": { "type": "object", "description": "明文 token 只在创建响应里出现一次(放在 `path` 里),列表不返回 token。", - "required": [ - "id", - "tenantId", - "role", - "path", - "createdAt", - "expiresAt" - ], + "required": ["id", "tenantId", "role", "path", "createdAt", "expiresAt"], "properties": { "id": { "type": "string" @@ -3046,7 +7026,8 @@ "type": "string" }, "role": { - "type": "string" + "type": "string", + "enum": ["viewer", "editor", "owner"] }, "path": { "type": "string", @@ -3062,13 +7043,7 @@ }, "KnowledgeFile": { "type": "object", - "required": [ - "id", - "fileId", - "knowledgebaseId", - "name", - "size" - ], + "required": ["id", "fileId", "knowledgebaseId", "name", "size"], "properties": { "id": { "type": "string" @@ -3101,24 +7076,15 @@ }, "GraphNode": { "type": "object", - "required": [ - "id", - "label", - "labelTruncated", - "memoryType", - "createdAt", - "sessionId", - "clusterId", - "userId", - "sourceIds" - ], + "required": ["id", "label", "labelTruncated", "memoryType", "createdAt", "sessionId", "clusterId", "userId", "sourceIds"], "properties": { "id": { "type": "string" }, "label": { "type": "string", - "description": "记忆正文的截断(≤120 字)" + "description": "记忆正文的截断(≤120 字)", + "maxLength": 120 }, "labelTruncated": { "type": "boolean" @@ -3145,15 +7111,12 @@ "type": "string" } } - } + }, + "additionalProperties": false }, "GraphLink": { "type": "object", - "required": [ - "source", - "target", - "kind" - ], + "required": ["source", "target", "kind"], "properties": { "source": { "type": "string" @@ -3163,41 +7126,34 @@ }, "kind": { "type": "string", - "enum": [ - "session", - "parent", - "merged", - "derived" - ] + "enum": ["session", "parent", "merged", "derived"] } - } + }, + "additionalProperties": false }, "MemoryGraphStats": { "type": "object", - "required": [ - "nodes", - "links", - "sessions", - "clusters", - "orphans", - "truncated", - "types" - ], + "required": ["nodes", "links", "sessions", "clusters", "orphans", "truncated", "types"], "properties": { "nodes": { - "type": "integer" + "type": "integer", + "minimum": 0 }, "links": { - "type": "integer" + "type": "integer", + "minimum": 0 }, "sessions": { - "type": "integer" + "type": "integer", + "minimum": 0 }, "clusters": { - "type": "integer" + "type": "integer", + "minimum": 0 }, "orphans": { - "type": "integer" + "type": "integer", + "minimum": 0 }, "truncated": { "type": "boolean" @@ -3206,10 +7162,7 @@ "type": "array", "items": { "type": "object", - "required": [ - "name", - "count" - ], + "required": ["name", "count"], "properties": { "name": { "type": "string" @@ -3220,27 +7173,26 @@ } } } - } + }, + "additionalProperties": false }, "MemoryGraph": { "type": "object", - "required": [ - "nodes", - "links", - "stats" - ], + "required": ["nodes", "links", "stats"], "properties": { "nodes": { "type": "array", "items": { "$ref": "#/components/schemas/GraphNode" - } + }, + "maxItems": 2000 }, "links": { "type": "array", "items": { "$ref": "#/components/schemas/GraphLink" - } + }, + "maxItems": 8000 }, "tenant": { "type": "string", @@ -3249,17 +7201,12 @@ "stats": { "$ref": "#/components/schemas/MemoryGraphStats" } - } + }, + "additionalProperties": false }, "ModelRecord": { "type": "object", - "required": [ - "role", - "model", - "spec", - "source", - "group" - ], + "required": ["role", "model", "spec", "source", "group"], "properties": { "role": { "type": "string" @@ -3275,10 +7222,190 @@ }, "group": { "type": "string", - "enum": [ - "memory", - "chat" - ] + "enum": ["memory", "chat"] + } + }, + "additionalProperties": false + }, + "MemoryOperation": { + "type": "object", + "required": ["operationId", "state", "results"], + "properties": { + "operationId": { + "type": "string" + }, + "state": { + "type": "string", + "enum": ["confirmed", "pending"] + }, + "deleted": { + "type": "integer", + "minimum": 0 + }, + "restored": { + "type": "integer", + "minimum": 0 + }, + "results": { + "type": "array", + "items": { + "type": "object", + "required": ["memoryId", "operationId", "state", "retryable"], + "properties": { + "memoryId": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "state": { + "type": "string", + "enum": ["queued", "pending", "confirmed"] + }, + "retryable": { + "type": "boolean" + }, + "errorCode": { + "type": "integer" + }, + "vectorState": { + "type": "string", + "enum": ["unconfirmed", "confirmed", "explicitly_not_indexed", "not_applicable"] + } + } + } + } + } + }, + "UsageSummary": { + "type": "object", + "required": ["addMessage", "searchMemory", "chat", "extractMemory", "rerank", "totalCalls", "totalTokens"], + "properties": { + "addMessage": { + "$ref": "#/components/schemas/UsageCalls" + }, + "searchMemory": { + "$ref": "#/components/schemas/UsageCalls" + }, + "chat": { + "$ref": "#/components/schemas/UsageTokens" + }, + "extractMemory": { + "$ref": "#/components/schemas/UsageTokens" + }, + "rerank": { + "$ref": "#/components/schemas/UsageCalls" + }, + "totalCalls": { + "type": "integer", + "minimum": 0, + "description": "仅addMessage.calls+searchMemory.calls,匹配页面总量。" + }, + "totalTokens": { + "type": "integer", + "minimum": 0, + "description": "五个已知事件的真实token总和;缺失0。" + } + } + }, + "PromptReadRequest": { + "type": "object", + "additionalProperties": false, + "required": ["projectId"], + "properties": { + "projectId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + } + }, + "PromptSaveRequest": { + "type": "object", + "additionalProperties": false, + "required": ["projectId", "prompt"], + "properties": { + "projectId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "prompt": { + "type": "string", + "minLength": 1, + "maxLength": 20000, + "pattern": "\\$\\{conversation\\}" + } + } + }, + "PromptGenerateRequest": { + "type": "object", + "additionalProperties": false, + "required": ["projectId", "description"], + "properties": { + "projectId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 2000 + } + } + }, + "PromptResetRequest": { + "type": "object", + "additionalProperties": false, + "required": ["projectId"], + "properties": { + "projectId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + } + }, + "PromptDebugRequest": { + "type": "object", + "additionalProperties": false, + "required": ["projectId", "messages"], + "properties": { + "projectId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "messages": { + "type": "array", + "minItems": 1, + "maxItems": 30, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["role", "content"], + "properties": { + "role": { + "type": "string", + "enum": ["system", "user", "assistant"] + }, + "content": { + "type": "string", + "minLength": 1, + "maxLength": 20000 + } + } + } + }, + "extractionTypes": { + "type": ["array", "null"], + "maxItems": 3, + "uniqueItems": true, + "items": { + "type": "string", + "enum": ["text_mem", "pref_mem", "event_mem"] + } } } } @@ -3330,7 +7457,7 @@ } }, "DeleteResult": { - "description": "成功,返回删除条数", + "description": "操作回执。仅全部 confirmed 时写操作 code=0;查询接口 code=0 仅表示查询成功,仍须检查 state。", "content": { "application/json": { "schema": { @@ -3340,14 +7467,10 @@ }, { "type": "object", + "required": ["data"], "properties": { "data": { - "type": "object", - "properties": { - "deleted": { - "type": "integer" - } - } + "$ref": "#/components/schemas/MemoryOperation" } } } @@ -3417,10 +7540,7 @@ "properties": { "data": { "type": "object", - "required": [ - "records", - "total" - ], + "required": ["records", "total"], "properties": { "records": { "type": "array", @@ -3476,10 +7596,7 @@ "properties": { "data": { "type": "object", - "required": [ - "records", - "total" - ], + "required": ["records", "total"], "properties": { "records": { "type": "array", @@ -3513,10 +7630,7 @@ "properties": { "data": { "type": "object", - "required": [ - "logs", - "total" - ], + "required": ["logs", "total"], "properties": { "logs": { "type": "array", @@ -3594,10 +7708,7 @@ "properties": { "data": { "type": "object", - "required": [ - "memListItemResponseList", - "total" - ], + "required": ["memListItemResponseList", "total"], "properties": { "memListItemResponseList": { "type": "array", @@ -3634,10 +7745,7 @@ "properties": { "data": { "type": "object", - "required": [ - "records", - "total" - ], + "required": ["records", "total"], "properties": { "records": { "type": "array", @@ -3674,10 +7782,7 @@ "properties": { "data": { "type": "object", - "required": [ - "records", - "total" - ], + "required": ["records", "total"], "properties": { "records": { "type": "array", @@ -3734,10 +7839,7 @@ "properties": { "data": { "type": "object", - "required": [ - "records", - "total" - ], + "required": ["records", "total"], "properties": { "records": { "type": "array", @@ -3793,10 +7895,7 @@ "properties": { "data": { "type": "object", - "required": [ - "list", - "total" - ], + "required": ["list", "total"], "properties": { "list": { "type": "array", @@ -3892,8 +7991,10 @@ "extractionTypes": { "type": "array", "items": { - "$ref": "#/components/schemas/MemoryDetailType" - } + "type": "string", + "enum": ["text_mem", "pref_mem", "event_mem"] + }, + "maxItems": 3 }, "results": { "type": "array", @@ -3924,15 +8025,32 @@ "properties": { "data": { "type": "object", - "required": [ - "dailyStats" - ], + "required": ["dailyStats", "summary", "startDate", "endDate", "timezone", "source"], "properties": { "dailyStats": { "type": "array", "items": { "$ref": "#/components/schemas/UsageDailyStat" } + }, + "summary": { + "$ref": "#/components/schemas/UsageSummary" + }, + "startDate": { + "type": "string", + "format": "date" + }, + "endDate": { + "type": "string", + "format": "date" + }, + "timezone": { + "type": "string", + "enum": ["Asia/Shanghai"] + }, + "source": { + "type": "string", + "enum": ["gateway"] } } } @@ -4067,10 +8185,7 @@ "properties": { "data": { "type": "object", - "required": [ - "records", - "total" - ], + "required": ["records", "total"], "properties": { "records": { "type": "array", @@ -4104,10 +8219,7 @@ "properties": { "data": { "type": "object", - "required": [ - "records", - "total" - ], + "required": ["records", "total"], "properties": { "records": { "type": "array", @@ -4141,10 +8253,7 @@ "properties": { "data": { "type": "object", - "required": [ - "records", - "total" - ], + "required": ["records", "total"], "properties": { "records": { "type": "array", @@ -4178,10 +8287,7 @@ "properties": { "data": { "type": "object", - "required": [ - "records", - "total" - ], + "required": ["records", "total"], "properties": { "records": { "type": "array", @@ -4237,10 +8343,7 @@ "properties": { "data": { "type": "object", - "required": [ - "records", - "total" - ], + "required": ["records", "total"], "properties": { "records": { "type": "array", @@ -4274,10 +8377,7 @@ "properties": { "data": { "type": "object", - "required": [ - "ok", - "id" - ], + "required": ["ok", "id"], "properties": { "ok": { "type": "boolean" @@ -4308,10 +8408,7 @@ "properties": { "data": { "type": "object", - "required": [ - "ok", - "deleted" - ], + "required": ["ok", "deleted"], "properties": { "ok": { "type": "boolean" @@ -4342,11 +8439,7 @@ "properties": { "data": { "type": "object", - "required": [ - "ok", - "id", - "archived" - ], + "required": ["ok", "id", "archived"], "properties": { "ok": { "type": "boolean" @@ -4402,10 +8495,7 @@ "properties": { "data": { "type": "object", - "required": [ - "id", - "memory" - ], + "required": ["id", "memory"], "properties": { "id": { "type": "string" @@ -4436,9 +8526,7 @@ "properties": { "data": { "type": "object", - "required": [ - "records" - ], + "required": ["records"], "properties": { "records": { "type": "array", @@ -4469,11 +8557,7 @@ "properties": { "data": { "type": "object", - "required": [ - "account_id", - "tenant_id", - "role" - ], + "required": ["account_id", "tenant_id", "role"], "properties": { "account_id": { "type": "string" @@ -4507,10 +8591,7 @@ "properties": { "data": { "type": "object", - "required": [ - "ok", - "role" - ], + "required": ["ok", "role"], "properties": { "ok": { "type": "boolean" @@ -4538,6 +8619,14 @@ } } } + }, + "securitySchemes": { + "CookieSession": { + "type": "apiKey", + "in": "cookie", + "name": "memos_session", + "description": "Opaque persisted login session. HttpOnly, SameSite=Lax; Secure on HTTPS. Disabled/deleted accounts and expired sessions are rejected on every request." + } } } } diff --git a/package.json b/package.json index 94c07d164..c5306c6b4 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,8 @@ "format:check": "prettier --check .", "verify": "pnpm typecheck && pnpm lint && pnpm format:check && pnpm build", "mock": "node --watch server/mock/server.ts", + "backend": "python -m server.backend", + "backend:test": "python -m pytest server/backend/tests -q", "dev:all": "concurrently -n mock,web -c cyan,green \"pnpm mock\" \"pnpm dev\"" }, "dependencies": { diff --git a/server/backend/.env.example b/server/backend/.env.example new file mode 100644 index 000000000..c2e4806bb --- /dev/null +++ b/server/backend/.env.example @@ -0,0 +1,22 @@ +# Export these process environment variables before starting the backend. +# This adapter uses the configured upstream; never put model API keys here. +DASHBOARD_UPSTREAM_URL=http://127.0.0.1:8000/ +DASHBOARD_UPSTREAM_TIMEOUT_SECONDS=10 +# Seed allowlist: projectId:cubeId separated by semicolons; locally created projects persist too. +DASHBOARD_PROJECTS=issue3-project-a:issue3-project-a;issue3-project-b:issue3-project-b +# Bootstrap selection when no valid persisted project preference is present. +DASHBOARD_DEFAULT_PROJECT_ID=issue3-project-a +DASHBOARD_DATA_DIR=.data +# Required to read real gateway call logs; use the gateway's calls-*.jsonl directory. +DASHBOARD_AUDIT_GATEWAY_DIR=/absolute/path/to/gateway/audit +# Share this path with the gateway MEMOS_TENANTS_FILE (and its writable parent directory). +DASHBOARD_GATEWAY_KEYS_FILE=/absolute/path/to/mcp-gateway/tenants.conf + +# Authentication is enabled by default; initialize a local administrator first. +DASHBOARD_AUTH_ENABLED=true +DASHBOARD_SESSION_TTL_SECONDS=86400 +# Optional semicolon-separated explicit origins, no trailing path/slash. +# DASHBOARD_ALLOWED_ORIGINS=http://127.0.0.1:5276;http://127.0.0.1:5281 + +# Total deadline for genuine prompt generation/debug calls (seconds; >0 and <=180). +DASHBOARD_MODEL_TIMEOUT_SECONDS=120 diff --git a/server/backend/README.md b/server/backend/README.md new file mode 100644 index 000000000..ebe29702c --- /dev/null +++ b/server/backend/README.md @@ -0,0 +1,184 @@ +# Dashboard 后端 + +为 Issue #3 提供 FastAPI 适配服务,将现有 Dashboard 接入真实 MemOS REST 和 MCP 网关审计 JSONL。前端 src/、shared/ 和 server/mock/ 不变。 + +- 接口规格:仓库根目录 openapi.json + +## 范围 + +已实现元信息、六标签记忆列表/详情、软删除与恢复/重试、调用日志、按日用量、项目生命周期、账号/会话、成员/邀请、抽取 Prompt、图谱/全文、模型清单、项目密钥及属性树模板接口。额度/订阅和 skillMD 按 Issue 约定返回固定数据。 + +知识库按请求者要求停止继续开发:保留实体 CRUD、项目关联、文件上传/列表/下载/删除及测试;**POST /api/dashboard/knowledgebase/memory/list 未实现**。此范围需要维护者审阅,不声明完整解决原 Issue。 + +## 架构与数据流 + +```text +浏览器 → 前端同源代理 → Dashboard FastAPI 后端 + ├─ REST → MemOS → Neo4j / Qdrant + ├─ SQLite:账号、会话、项目、成员、邀请、审计索引 + └─ 共享配置文件 → MCP 网关密钥自动重载 + +项目客户端 → MCP 网关 → MemOS + └─ calls-*.jsonl → 增量审计索引 → 日志列表/详情、每日用量 +``` + +后端按 OpenAPI 投影真实上游数据,先校验会话和项目权限,再访问对应 cube;控制面状态与审计索引持久化到本地 SQLite。日志与用量复用同一索引,避免重复解析及统计口径分叉。模型调用由上游执行,Dashboard 不保存模型服务凭据;网关密钥通过共享文件发布,下次鉴权请求检查并重载。 + +## 上游依赖 + +后端依赖 deployment-v2 的配套能力。使用该分支的独立工作区,基线为 8d6e231d7d95ea29b6a2716c9c34837644d3e7f6,在干净基线上应用本 PR 唯一的完整补丁: + +```sh +git checkout 8d6e231d7d95ea29b6a2716c9c34837644d3e7f6 +git apply --check /path/to/dashboard/server/backend/patches/deployment-v2-dashboard-support.patch +git apply /path/to/dashboard/server/backend/patches/deployment-v2-dashboard-support.patch +``` + +补丁包含记忆图/向量生命周期确认、画像字段读写状态、事件筛选、Prompt 运行时修复、模型提供方兼容、网关配置发布与自动重载,以及对应测试和上游 API schema。请按 deployment-v2 的说明配置模型、启动或重建 MemOS、Neo4j、Qdrant 和网关。不能只部署 Dashboard 适配器而忽略该补丁。 + +## 安装与启动 + +需要 Python 3.11+,以下命令在 Dashboard 仓库根目录运行: + +```sh +python -m venv .venv +# 按当前 shell 的方式激活 .venv +python -m pip install -r server/backend/requirements-dev.txt +``` + +参考 [.env.example](.env.example) 导出进程环境变量;程序不会自动读取该文件。管理员初始化与后端服务必须使用相同配置: + +```sh +export DASHBOARD_DATA_DIR="$PWD/.data" +export DASHBOARD_UPSTREAM_URL=http://127.0.0.1:8000/ +export DASHBOARD_PROJECTS='demo:demo' +export DASHBOARD_DEFAULT_PROJECT_ID=demo +export DASHBOARD_AUDIT_GATEWAY_DIR=/absolute/path/to/gateway/audit +export DASHBOARD_GATEWAY_KEYS_FILE=/absolute/path/to/mcp-gateway/tenants.conf +python -m server.backend.bootstrap admin --bootstrap +python -m server.backend --host 127.0.0.1 --port 5281 +``` + +PowerShell 使用 `$env:NAME = 'value'` 设置同名变量。初始化命令以隐藏输入提示设置密码,不将密码放入命令行。鉴权默认开启;后端不持有模型密钥。上游服务本身无鉴权,应置于受信网络中。 + +另开终端启动原有前端代理,不启动 Mock: + +```sh +pnpm install --frozen-lockfile +MOCK_ORIGIN=http://127.0.0.1:5281 pnpm dev +``` + +PowerShell 先设置 `$env:MOCK_ORIGIN = 'http://127.0.0.1:5281'`,再运行 `pnpm dev`。访问 http://127.0.0.1:5274/__login 登录并选择项目。使用其他域名或端口时,通过 DASHBOARD_ALLOWED_ORIGINS 设置分号分隔的可信 Origin。 + +## 配置 + +| 环境变量 | 默认值/用途 | +|---|---| +| DASHBOARD_UPSTREAM_URL | http://127.0.0.1:8000/ | +| DASHBOARD_UPSTREAM_TIMEOUT_SECONDS | 3;普通上游请求截止时间,范围 0–60 秒,不含 0 | +| DASHBOARD_MODEL_TIMEOUT_SECONDS | 120;Prompt 生成/调试截止时间,不超过 180 秒 | +| DASHBOARD_DATA_DIR | .data;控制面、会话与审计索引持久化目录 | +| DASHBOARD_PROJECTS | projectId:cubeId,以分号分隔的初始项目映射 | +| DASHBOARD_DEFAULT_PROJECT_ID | 初始项目,配置种子映射时必须指定 | +| DASHBOARD_AUDIT_GATEWAY_DIR | 网关 calls-*.jsonl 所在目录;缺失/不可读返回明确错误 | +| DASHBOARD_AUDIT_CONSOLE_DIR | 可选;默认位于数据目录下 | +| DASHBOARD_GATEWAY_KEYS_FILE | 与网关 MEMOS_TENANTS_FILE 指向同一绝对路径 | +| DASHBOARD_AUTH_ENABLED | true;正式验收保持开启 | +| DASHBOARD_SESSION_TTL_SECONDS | 86400 | +| DASHBOARD_ALLOWED_ORIGINS | 默认允许本地 5274/5276/5281;生产部署应显式配置 | + +首次部署升级后的网关需要重启一次。之后密钥新增/撤销成功发布后,下一个鉴权请求自动生效,无需重启;改名不影响调用。共享配置目录须支持发布锁。损坏、丢失、冲突或 pending 配置暂时拒绝文件密钥,确认发布后自动恢复;独立环境变量密钥保留。环境变量变更仍需重启,已授权执行中的请求不会被取消。 + +## 数据与接口约定 + +- 统一响应为 `{code,data,message}`;列表键、分页参数及字段命名按 OpenAPI,不改前端来迁就后端。 +- 记忆列表/统计仅计算授权项目、指定类型及筛选范围内的 activated 数据;先筛选和计数,再分页。不透传 embedding、sources、history,列表正文限制 500 字,详情保留全文。 +- 六标签映射集中在 memory_models.py;UserMemory 属于事实列表,ProfileMemory 对应用户画像字段。图谱保持原始类型和字段 ID,不伪造关系。超出节点/字节限制时返回 truncated。 +- 展示时间固定 UTC+8;存储与机器查询不被展示转换覆盖。日期筛选按 UTC 日边界,带时区时间戳按明确时刻处理。 +- 删除为软删除,恢复/重试/回执通过接口;普通记忆检查图与向量状态,画像字段使用字段状态语义。 +- 日志缺正文时返回 JSON 字符串 null,缺耗时为 null、token 为 0、时间为空串、状态为 UNKNOWN。无法可靠归属租户的记录隔离,不猜测归属。 +- SQLite 与共享文件锁适用于当前单机部署,不承诺分布式 HA;控制面数据库和网关明文凭据文件应限制目录权限,不提交到仓库。 + +## 验证 + +```sh +python -m pytest server/backend/tests -q +python -m ruff check server/backend +pnpm verify +pnpm --package=@redocly/cli dlx redocly lint openapi.json +git diff origin/dashboard-v2 -- src/ shared/ server/mock/ +python -m server.backend.acceptance.smoke --base http://127.0.0.1:5281 --project demo +``` + +smoke 命令提示输入账号密码,也支持 DASHBOARD_TEST_USERNAME/DASHBOARD_TEST_PASSWORD;不创建、修改或删除业务资源,不调用对话模型。额外提供 `--native http://127.0.0.1:8000 --cube demo` 可按同 ID 对照上游正文。只输出检查结果、数量和大小,不打印密钥或记忆正文。 + +性能验收目标:记忆单页响应小于 200 KB,至少 1 万行日志首屏小于 1 秒。可用 acceptance/ 中的脚本复验;其依赖限制见下文。上游完整补丁应在指定干净基线上执行 `git apply --check`。私有运行日志、凭据、数据库和截图不随代码提交。 + +### 已执行验收摘要 + +以下为开发阶段已执行结果,不表示本次文档整理重新运行了全量测试;不同批次的测试数量不相加。 + +| 范围 | 已记录结果 | +|---|---| +| 后端完整测试 | 318 passed | +| 后续授权策略回归 | 23 passed | +| 网关自动重载 / 后端密钥回归 | 56 passed / 27 passed | +| 打包与交付检查 | 12 passed | +| 前端检查 | pnpm verify 通过;src/、shared/、server/mock/ 相对基线零改动 | +| OpenAPI 检查 | Redocly 0 错误、4 警告:四个表单接口成功返回 303 | +| 1 万行日志冷首屏 | 三次 805.350 / 779.142 / 795.091 ms(本机测试) | +| 10 条记忆列表响应 | 3,688 字节(该样本,不代表所有页) | +| 真实链路与故障 | 停 Mock、同 ID REST 对照、软删除/恢复、JSONL 对照、重启持久化及上游故障提示已验证 | + +未宣称上游全仓测试通过:曾复现 18 项上游基线失败。知识库未完成部分、属性树前端占位及其他限制见下节。私有验收日志不提交;复核以本节命令、人工审核步骤及配套测试代码为入口。 + +## 已知限制 + +- 知识库入口禁用,且缺少知识库记忆列表;未完成范围如上,不作为完整交付。 +- 属性树模板页面仍为占位页,已交付 API,不宣称有编辑 UI。 +- 原前端缺少 ProfileMemory 中文图例;列表大类与图谱原始类型标签口径不同。本 PR 不改 src/,不通过伪造类型掩盖差异。 +- 归档当前项目后表格状态可能需要刷新;恢复、重试等没有对应前端按钮。 +- 用量无法按缺少可信审计来源的 apiKeyId 筛选,该请求明确拒绝。 +- acceptance/task14_driver.py、task15_driver.py、task16_driver.py 和 start_live.ps1 是依赖原 Windows 私有夹具的历史工具;部署与跨平台验收请使用本文模块命令和 smoke,不需要本机 D:/MemOS/setup。 + +## 权限边界 + +平台账号管理要求平台管理员;项目管理按 viewer/editor/owner 授权。`/admin/` 前缀不等于全部接口都要求平台管理员。此策略已在 OpenAPI 标注,仍需维护者确认与 Issue 的预期一致。 + +| 接口 | 最低权限 | 范围/约束 | +|---|---|---| +| GET /api/auth/me | 已登录 | 只返回当前会话账号 | +| GET /admin/accounts | 平台管理员 | 全站账号列表 | +| POST /admin/accounts/{id} | 平台管理员 | 更新账号,保护最后有效管理员及 owner | +| DELETE /admin/accounts/{id} | 平台管理员 | 删除账号,保护最后有效管理员及 owner | +| GET /admin/tenants/{tenantId}/members | 项目 viewer | 仅目标团队项目 | +| GET /admin/tenants/{tenantId}/member-candidates | 项目 owner | 候选账号搜索,不授予全站账号管理权限 | +| POST /admin/tenants/{tenantId}/members | 项目 owner | 添加或修改成员;项目需可用,保护最后有效 owner | +| POST /admin/tenants/{tenantId}/members/{accountId}/remove | 项目 owner | 仅目标项目;保护最后有效 owner | +| POST /admin/tenants/{tenantId}/members/leave | 本项目成员 | 仅退出自己;保护最后有效 owner | +| GET /admin/tenants/{tenantId}/invites | 项目 owner | 仅目标团队项目 | +| POST /admin/tenants/{tenantId}/invites | 项目 owner | 目标项目需可用 | +| POST /admin/tenants/{tenantId}/invites/{inviteId}/role | 项目 owner | 邀请必须属于该项目 | +| POST /admin/tenants/{tenantId}/invites/{inviteId}/revoke | 项目 owner | 邀请必须属于该项目 | + +- 平台管理员可覆盖项目角色检查,但个人空间、项目状态、最后有效管理员/owner 约束仍生效。 +- 匿名、过期会话、禁用账号返回 401;可见资源权限不足返回 403;无权访问的项目或跨项目资源返回 404。 +- 普通项目 owner 不能管理全站账号或其他项目。邀请接受独立校验令牌、过期、撤销和单次使用。 +- 浏览器写请求校验 Origin/CSRF;正式部署保持鉴权开启。 + +## 人工审核 + +先停止 Mock,启动真实上游、网关、后端和前端代理。仅使用新建演示资源,不操作已有业务数据。 + +1. 登录后检查 `/api/meta` 的 `code=0`、`mode=live`,刷新仍能读取数据。 +2. 用演示项目密钥调用网关 `add_message` 写入唯一标记,在记忆列表搜索;按同一 ID 用上游 REST 对照详情。检查六标签、筛选和分页,切换项目验证隔离。 +3. 删除自己的测试记忆,确认列表、详情与原生读取不可见;通过恢复接口验证同 ID 与正文。恢复/重试没有前端按钮。 +4. 同一会话多次写入后检查图谱分组、连线、缩放、筛选和节点详情,不要求上游未提供的关系出现。 +5. 日志搜索该标记,对照原始 `calls-*.jsonl` 的事件、时间和耗时;详情请求/响应是 JSON 字符串。用量按相同日期范围与同一审计数据核对,缺失 token 显示 0 不代表没有消耗。 +6. 创建测试项目,编辑、切换、停用/启用、归档/恢复,并在服务重启后检查持久化。 +7. 使用普通账号和管理员分别验证上表权限、成员添加/变更/移除/退出,以及最后有效管理员/owner 保护。 +8. 创建邀请,在独立浏览器会话打开 `/__login/invite/{token}`,登录或注册接受;已用、撤销或过期邀请不能再次加入。 +9. 创建演示密钥直接调用网关;改名不影响凭据,撤销后下一请求应 401,其他密钥仍可用。 +10. 在隔离项目保存 Prompt,通过真实写入验证唯一规则生效,结束后恢复配置;生成候选不保存不生效,快速调试不写入记忆。模型调用可能产生费用。 +11. 模型清单应与真实配置一致且不包含凭据。属性树模板仅验 API,不能将前端占位页算作功能 UI;知识库不在本轮完整验收范围。 +12. 仅在可控环境停止上游,检查页面错误提示与重试,随后恢复服务。清理自己的测试资源,不在提交或截图中公开有效凭据、邀请令牌及业务隐私。 diff --git a/server/backend/__init__.py b/server/backend/__init__.py new file mode 100644 index 000000000..ddc8f4be9 --- /dev/null +++ b/server/backend/__init__.py @@ -0,0 +1 @@ +"""Independent HTTP backend for the MemOS Dashboard.""" diff --git a/server/backend/__main__.py b/server/backend/__main__.py new file mode 100644 index 000000000..286aa3794 --- /dev/null +++ b/server/backend/__main__.py @@ -0,0 +1,26 @@ +"""Run with python -m server.backend from the Dashboard repository root.""" + +import argparse + +import uvicorn +from pydantic import ValidationError + +from .app import create_app +from .settings import Settings + + +def main() -> None: + parser = argparse.ArgumentParser(description="Run the independent MemOS Dashboard backend") + parser.add_argument("--host", default="127.0.0.1", help="Bind address (default: loopback)") + parser.add_argument("--port", type=int, default=5281, help="Bind port (default: 5281)") + args = parser.parse_args() + try: + settings = Settings.from_env() + except ValidationError: + parser.error("Invalid backend configuration; check DASHBOARD_UPSTREAM_URL and timeout") + # Invitation URLs contain a capability token; never persist raw request URLs. + uvicorn.run(create_app(settings), host=args.host, port=args.port, access_log=False) + + +if __name__ == "__main__": + main() diff --git a/server/backend/acceptance/__init__.py b/server/backend/acceptance/__init__.py new file mode 100644 index 000000000..643052583 --- /dev/null +++ b/server/backend/acceptance/__init__.py @@ -0,0 +1 @@ +"""Explicit live acceptance tools; never run automatically by pytest.""" diff --git a/server/backend/acceptance/probe_gateway.py b/server/backend/acceptance/probe_gateway.py new file mode 100644 index 000000000..c74698e0e --- /dev/null +++ b/server/backend/acceptance/probe_gateway.py @@ -0,0 +1,106 @@ +"""Direct loopback MCP probes. Credentials remain in memory, never evidence.""" + +import json +import os +from pathlib import Path + +import httpx + +ACCEPT = "application/json, text/event-stream" + + +def gateway_client(**kwargs): + return httpx.Client(trust_env=False, timeout=120, **kwargs) + + +def old_token(root: Path, project="issue3-project-a"): + for line in (root / ".local/gateway.env").read_text(encoding="utf-8-sig").splitlines(): + if not line.strip().startswith("MEMOS_TENANTS="): + continue + raw = line.split("=", 1)[1].strip() + if raw.startswith("{"): + for token, spec in json.loads(raw).items(): + uid = spec if isinstance(spec, str) else spec.get("user_id") + if uid == project: + return token + else: + for pair in raw.split(","): + token, _, uid = pair.rpartition(":") + if uid.strip() == project: + return token.strip() + raise ValueError("Existing regression credential not configured") + + +def result(response): + for frame in [response.text] + [ + s[5:] for s in response.text.splitlines() if s.startswith("data:") + ]: + try: + data = json.loads(frame) + except ValueError: + continue + if isinstance(data, dict) and isinstance(data.get("result"), dict): + return data["result"] + return {} + + +def rpc(client, gateway, token, method, params, number=1): + return client.post( + gateway + "/mcp", + headers={"Authorization": "Bearer " + token, "Accept": ACCEPT}, + json={"jsonrpc": "2.0", "id": number, "method": method, "params": params}, + ) + + +def probe(client, gateway, token): + response = rpc( + client, + gateway, + token, + "initialize", + { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "task14-isolated", "version": "2"}, + }, + ) + if response.status_code != 200: + return {"http_status": response.status_code, "tools": []} + response = rpc(client, gateway, token, "tools/list", {}, 2) + tools = result(response).get("tools", []) + return { + "http_status": response.status_code, + "tools": [ + item["name"] + for item in tools + if isinstance(item, dict) and isinstance(item.get("name"), str) + ], + } + + +def main(): + import argparse + + parser = argparse.ArgumentParser() + parser.add_argument("mode", choices=["old", "malicious", "env"], nargs="?", default="env") + parser.add_argument("--root", type=Path, default=Path("D:/MemOS")) + parser.add_argument("--gateway", default="http://127.0.0.1:8002") + args = parser.parse_args() + token = ( + old_token(args.root) + if args.mode == "old" + else "sk-" + "0" * 48 + if args.mode == "malicious" + else os.environ.get("PROBE_TOKEN", "") + ) + with gateway_client() as client: + data = probe(client, args.gateway, token) + safe = json.dumps({"step": args.mode, **data}) + if "sk-" in safe: + raise ValueError("Unsafe probe output rejected") + print(safe) + return 0 if data["http_status"] == (401 if args.mode == "malicious" else 200) else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/server/backend/acceptance/smoke.py b/server/backend/acceptance/smoke.py new file mode 100644 index 000000000..fa352a5bd --- /dev/null +++ b/server/backend/acceptance/smoke.py @@ -0,0 +1,141 @@ +"""Portable, read-only live-service checks; no private fixtures or local setup imports.""" + +import argparse +import getpass +import json +import os +import re +from urllib.parse import quote, urlsplit + +import httpx + +TABS = ("text_mem", "pref_mem", "event_mem", "profile_mem", "skill_mem", "tool_mem") + + +def envelope(response, code=0): + response.raise_for_status() + body = response.json() + if body.get("code") != code: + raise ValueError("Unexpected response envelope") + return body["data"] + + +def origin(value): + parsed = urlsplit(value) + if ( + parsed.scheme not in {"http", "https"} + or not parsed.hostname + or (parsed.username or parsed.password or parsed.query or parsed.fragment) + ): + raise argparse.ArgumentTypeError("Use an HTTP(S) service URL without credentials") + return value.rstrip("/") + + +def run_checks(web, base, project, native=None, cube=None): + meta = envelope(web.get(base + "/api/meta")) + assert meta["mode"] == "live" + envelope(web.get(base + "/api/auth/me")) + envelope(web.get(base + "/api/dashboard/project/dict")) + proof = {"live": True, "project": project, "pages": [], "sameIdNativeMatches": 0} + for tab in TABS: + response = web.post( + base + "/api/dashboard/project/memory/list", + json={"projectId": project, "memoryDetailType": tab, "pageNum": 1, "pageSize": 10}, + ) + data = envelope(response) + assert len(response.content) < 200000 + rows = data["memListItemResponseList"] + for row in rows: + assert {"memoryId", "memoryContent", "createTime"} <= row.keys() + assert not {"embedding", "sources", "history", "metadata"} & row.keys() + proof["pages"].append({"tab": tab, "total": data["total"], "bytes": len(response.content)}) + if native and rows: + identifier = rows[0]["memoryId"] + full = envelope( + web.get( + base + "/api/dashboard/graph/memory", + params={ + "tenant": project, + "id": identifier, + }, + ) + ) + if tab == "profile_mem": + remote = envelope( + web.post( + native + "/product/get_profile_memory", + json={ + "mem_cube_id": cube, + "memory_id": identifier, + }, + ), + code=200, + ) + else: + remote = envelope( + web.get( + native + "/product/get_memory/" + quote(identifier, safe=""), + params={"mem_cube_id": cube}, + ), + code=200, + ) + assert remote["id"] == identifier == full["id"] + assert remote["memory"] == full["memory"] + proof["sameIdNativeMatches"] += 1 + graph = envelope(web.get(base + "/api/dashboard/graph/memories", params={"tenant": project})) + proof["graphNodes"] = len(graph["nodes"]) + proof["graphTruncated"] = graph["stats"]["truncated"] + proof["models"] = envelope(web.get(base + "/api/dashboard/models"))["total"] + logs = envelope( + web.post( + base + "/api/dashboard/log/list", + json={ + "projectId": project, + "pageNum": 1, + "pageSize": 10, + }, + ) + ) + proof["logTotal"] = logs["total"] + envelope(web.post(base + "/api/dashboard/usage/query", json={"projectId": project})) + envelope(web.post(base + "/api/dashboard/prompt-config/detail", json={"projectId": project})) + proof["passed"] = True + return proof + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--base", type=origin, default="http://127.0.0.1:5281") + parser.add_argument("--project", required=True) + parser.add_argument("--native", type=origin) + parser.add_argument("--cube") + args = parser.parse_args() + if bool(args.native) != bool(args.cube): + parser.error("--native and --cube must be supplied together") + username = os.getenv("DASHBOARD_TEST_USERNAME") or input("Username: ") + password = os.getenv("DASHBOARD_TEST_PASSWORD") or getpass.getpass("Password: ") + try: + with httpx.Client(trust_env=False, timeout=45, follow_redirects=False) as web: + page = web.get(args.base + "/__login") + page.raise_for_status() + token = re.search(r'name="csrf" value="([^\"]+)"', page.text) + assert token is not None + response = web.post( + args.base + "/__login", + data={ + "username": username, + "password": password, + "csrf": token[1], + }, + ) + assert response.status_code == 303 + print(json.dumps(run_checks(web, args.base, args.project, args.native, args.cube))) + return 0 + except Exception as error: + # Do not echo service bodies, auth headers, credentials or business memories. + print(json.dumps({"passed": False, "errorType": type(error).__name__})) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/server/backend/acceptance/start_live.ps1 b/server/backend/acceptance/start_live.ps1 new file mode 100644 index 000000000..b349f6537 --- /dev/null +++ b/server/backend/acceptance/start_live.ps1 @@ -0,0 +1,34 @@ +# Launch the existing frontend against the real backend; never edit .env.local. +$ErrorActionPreference = 'Stop' +$taskRoot = 'D:/MemOS' +$dashboard = Join-Path $taskRoot 'dashboard' +$statePath = Join-Path $taskRoot '.local/processes.json' +$state = Get-Content -Raw -LiteralPath $statePath | ConvertFrom-Json + +function Stop-OwnedListener([int]$Port, [string]$Name, [string]$Marker) { + $listeners = @(Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction SilentlyContinue) + foreach ($listener in $listeners) { + $service = Get-CimInstance Win32_Process -Filter "ProcessId = $($listener.OwningProcess)" + $entry = $state.PSObject.Properties[$Name] + if (-not $entry -or $entry.Value.id -ne $service.ProcessId -or + $service.ExecutablePath -ne $entry.Value.executable -or + -not $service.CommandLine.Replace('/', '\').Contains($Marker)) { + throw "Port $Port is not the recorded owned $Name process; refusing to stop it" + } + Stop-Process -Id $service.ProcessId -ErrorAction Stop + } +} + +$health = Invoke-RestMethod -Uri 'http://127.0.0.1:5281/health' +if ($health.code -ne 0) { throw 'Real backend is not healthy' } +Stop-OwnedListener 5280 'mock' 'server\mock\server.ts' +Stop-OwnedListener 5274 'web' 'vite\bin\vite.js' +$env:MOCK_ORIGIN = 'http://127.0.0.1:5281' +$node = 'E:/nodejs/node.exe' +$vite = Join-Path $dashboard 'node_modules/vite/bin/vite.js' +$service = Start-Process -FilePath $node -ArgumentList @($vite, '--host', '127.0.0.1', '--port', '5274', '--strictPort') -WorkingDirectory $dashboard -WindowStyle Hidden -RedirectStandardOutput (Join-Path $taskRoot '.local/logs/web-live.out.log') -RedirectStandardError (Join-Path $taskRoot '.local/logs/web-live.err.log') -PassThru +$entry = [pscustomobject]@{id=$service.Id; executable=$node.Replace('/', '\'); marker='vite\bin\vite.js'; port=5274; mode='live'} +$state | Add-Member -MemberType NoteProperty -Name web -Value $entry -Force +$state.PSObject.Properties.Remove('mock') +$state | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $statePath -Encoding UTF8 +Write-Host 'Mock stopped. Main frontend 5274 now proxies real backend 5281.' diff --git a/server/backend/acceptance/task14_driver.py b/server/backend/acceptance/task14_driver.py new file mode 100644 index 000000000..2055efb8f --- /dev/null +++ b/server/backend/acceptance/task14_driver.py @@ -0,0 +1,250 @@ +"""Explicit isolated Task14 acceptance; issues/revokes only this run's credential.""" + +import argparse +import hashlib +import json +import re +import subprocess +import tempfile +import time +import uuid +from dataclasses import dataclass +from pathlib import Path + +from .probe_gateway import gateway_client, old_token, probe, result, rpc + + +def require(condition, message): + if not condition: + raise AssertionError(message) + + +class Recorder: + def __init__(self, path, run_id): + self.path, self.run_id, self.rows = Path(path), run_id, [] + + def record(self, step, **data): + rows = self.rows + [{"run_id": self.run_id, "step": step, **data}] + encoded = json.dumps(rows, indent=2) + if "sk-" in encoded: + raise ValueError("Unsafe credential evidence rejected") + self.path.parent.mkdir(parents=True, exist_ok=True) + self.path.write_text(encoded + "\n", encoding="utf-8") + self.rows = rows + print(json.dumps({"step": step, **data}), flush=True) + + +@dataclass +class RunResources: + project_id: str | None = None + key_id: str | None = None + revoked: bool = False + + def cleanup_ids(self): + return [self.key_id] if self.key_id and not self.revoked else [] + + +def restart(root, script, name, recorder): + state = root / ".local/processes.json" + before = json.loads(state.read_text(encoding="utf-8-sig"))[name]["id"] + with tempfile.TemporaryFile() as stdout, tempfile.TemporaryFile() as stderr: + process = subprocess.run( + [ + "powershell", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + str(root / "setup" / script), + ], + stdout=stdout, + stderr=stderr, + timeout=100, + ) + require(process.returncode == 0, "Owned service restart failed") + after = json.loads(state.read_text(encoding="utf-8-sig"))[name]["id"] + require(before != after, "Service PID did not change") + recorder.record(name + "_restart", passed=True, pid_changed=True) + + +def main(argv=None): + parser = argparse.ArgumentParser() + parser.add_argument("--root", type=Path, default=Path("D:/MemOS")) + parser.add_argument("--output", type=Path) + args = parser.parse_args(argv) + root = args.root + run_id = "task14-" + uuid.uuid4().hex[:12] + path = args.output or root / ("dashboard/.data/task14-keys/runs/" + run_id + ".json") + require(not path.exists(), "Fresh output path required; historical evidence is immutable") + recorder = Recorder(path, run_id) + resources = RunResources() + manifest = root / "upstream/deploy/mcp-gateway/tenants.conf" + base, gateway = "http://127.0.0.1:5281", "http://127.0.0.1:8002" + start_hash = hashlib.sha256(manifest.read_bytes()).hexdigest() + process_state = root / ".local/processes.json" + gateway_pid = json.loads(process_state.read_text(encoding="utf-8-sig"))["gateway"]["id"] + failed = False + with gateway_client() as client: + + def post(route, body): + response = client.post(base + route, json=body) + require(response.status_code == 200, "Dashboard operation failed") + data = response.json() + require(data.get("code") == 0, "Dashboard business failure") + return data["data"] + + try: + account = json.loads((root / ".local/dashboard-admin.json").read_text(encoding="utf-8")) + page = client.get(base + "/__login") + csrf = re.search(r'name="csrf" value="([^" ]+)"', page.text) + require(csrf is not None, "Missing login CSRF") + login = client.post( + base + "/__login", + data={ + "username": account["username"], + "password": account["password"], + "csrf": csrf[1], + }, + follow_redirects=False, + ) + require(login.status_code == 303, "Login rejected") + recorder.record("login", passed=True) + existing = old_token(root) + require( + probe(client, gateway, existing)["http_status"] == 200, + "Existing key rejected before run", + ) + recorder.record("existing_key_before", status=200) + require( + probe(client, gateway, "sk-" + "0" * 48)["http_status"] == 401, + "Invalid key accepted", + ) + recorder.record("invalid_key", status=401) + project = post( + "/api/dashboard/project/create", + {"name": run_id, "description": "Isolated Task14 credential acceptance"}, + ) + require( + project.get("name") == run_id and project["id"].startswith("proj_"), + "Unexpected project identity", + ) + resources.project_id = project["id"] + recorder.record("isolated_project", project_id=resources.project_id) + created = post( + "/api/dashboard/keys/create", {"projectId": resources.project_id, "keyName": run_id} + ) + resources.key_id = created["id"] + token = created["apiKey"] + require( + bool(re.fullmatch(r"sk-[0-9a-f]{48}", token)), "Invalid generated credential shape" + ) + require( + token in json.loads(manifest.read_text(encoding="utf-8")), + "Missing published credential", + ) + recorder.record("create", passed=True, file_matches=True) + copied = post("/api/dashboard/keys/copy", {"apiKeyId": resources.key_id}) + require(copied == token, "Copy response mismatch") + recorder.record("copy", passed=True) + before_rename = manifest.read_bytes() + post("/api/dashboard/keys/rename/" + resources.key_id, {"keyName": run_id + "-renamed"}) + require(manifest.read_bytes() == before_rename, "Rename changed gateway bytes") + recorder.record("rename", file_unchanged=True) + restart(root, "Restart-Backend.ps1", "backend", recorder) + persisted = post("/api/dashboard/keys/copy", {"apiKeyId": resources.key_id}) + require(persisted == token, "Credential/session did not persist") + recorder.record("backend_persistence", passed=True) + require( + probe(client, gateway, token)["http_status"] == 200, + "New key did not become live without gateway restart", + ) + recorder.record("new_key_without_gateway_restart", status=200) + live = probe(client, gateway, token) + require( + live["http_status"] == 200 and "search_memory" in live["tools"], + "New credential not usable", + ) + recorder.record("new_key_live", status=200, has_search=True) + response = rpc( + client, + gateway, + token, + "tools/call", + {"name": "search_memory", "arguments": {"query": run_id}}, + 3, + ) + content = result(response) + require( + response.status_code == 200 + and isinstance(content.get("content"), list) + and not content.get("isError"), + "Real search failed", + ) + recorder.record("real_search", status=200, success=True) + hits = [] + for _ in range(15): + hits = [] + for audit in (root / ".local/audit").glob("calls-*.jsonl"): + for line in audit.read_text(encoding="utf-8").splitlines(): + if run_id not in line: + continue + row = json.loads(line) + if row.get("tool") == "search_memory" and run_id in json.dumps( + row.get("input") + ): + hits.append(row) + if hits: + break + time.sleep(0.2) + require( + len(hits) == 1 and hits[0].get("tenant") == resources.project_id, + "Audit tenant/count mismatch", + ) + recorder.record("audit_attribution", hits=1, tenant_matches=True) + post("/api/dashboard/keys/revoke/" + resources.key_id, {}) + resources.revoked = True + require( + token not in json.loads(manifest.read_text(encoding="utf-8")), + "Revoked key still in configuration", + ) + recorder.record("revoke", file_absent=True) + require( + probe(client, gateway, token)["http_status"] == 401, "Revoked key still accepted" + ) + recorder.record("revoked_key_without_gateway_restart", status=401) + require( + probe(client, gateway, existing)["http_status"] == 200, "Existing key regressed" + ) + recorder.record("existing_key_after", status=200) + except Exception as error: + failed = True + recorder.record("failed", passed=False, error_type=type(error).__name__) + finally: + try: + for identifier in resources.cleanup_ids(): + post("/api/dashboard/keys/revoke/" + identifier, {}) + resources.revoked = True + if resources.project_id: + post("/api/dashboard/project/revoke/" + resources.project_id, {}) + require( + hashlib.sha256(manifest.read_bytes()).hexdigest() == start_hash, + "Preexisting manifest bytes changed", + ) + recorder.record( + "cleanup", own_resources_only=True, original_manifest_unchanged=True + ) + require( + json.loads(process_state.read_text(encoding="utf-8-sig"))["gateway"]["id"] + == gateway_pid, + "Gateway process changed during live credential acceptance", + ) + recorder.record("gateway_pid_unchanged", pid=gateway_pid) + except Exception as error: + failed = True + recorder.record("cleanup_failed", passed=False, error_type=type(error).__name__) + recorder.record("complete", passed=not failed, secret_scan_passed=True) + return int(failed) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/server/backend/acceptance/task15_driver.py b/server/backend/acceptance/task15_driver.py new file mode 100644 index 000000000..26ec61e7b --- /dev/null +++ b/server/backend/acceptance/task15_driver.py @@ -0,0 +1,178 @@ +"""Replay Task15 against local real services; create and delete only our own template.""" + +import argparse +import hashlib +import json +import re +import sys +import time +import uuid +from pathlib import Path + +import httpx + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--root", type=Path, default=Path("D:/MemOS")) + parser.add_argument("--project", required=True) + parser.add_argument("--other-project", required=True) + parser.add_argument("--cube", required=True) + parser.add_argument("--restart", action="store_true") + args = parser.parse_args() + sys.path.insert(0, str(args.root / "setup")) + from verify_task01 import authenticate_local_backend + from verify_task06 import run_ps + + base = "http://127.0.0.1:5281/api/dashboard/profileTemplate/" + native = "http://127.0.0.1:8000/product/" + run = uuid.uuid4().hex[:12] + output = args.root / "dashboard/.data/task15" / (run + ".json") + output.parent.mkdir(parents=True, exist_ok=True) + proof = {"run_id": run, "passed": False, "checks": []} + identifier = None + + def record(name, **details): + proof["checks"].append({"name": name, "passed": True, **details}) + + def envelope(response, code=0): + assert response.status_code == 200, ("HTTP", response.status_code) + body = response.json() + assert body["code"] == code, ("envelope", body["code"]) + return body["data"] + + with httpx.Client(trust_env=False, timeout=45) as web: + try: + assert web.post(base + "list", json={"projectId": args.project}).status_code == 401 + record("anonymous_denied") + authenticate_local_backend(web) + body = { + "projectId": args.project, + "templateName": "TASK15_" + run, + "Work": { + "role": {"value": "engineer", "algorithm_updatable": False}, + "location": {"value": "test-only", "algorithm_updatable": True}, + }, + } + identifier = envelope(web.post(base + "create", json=body))["templateId"] + params = {"projectId": args.project, "templateId": identifier} + record("create_real_upstream", template_id=identifier) + upstream = envelope( + web.post( + native + "get_profile_template", + json={"mem_cube_id": args.cube, "profile_template_id": identifier}, + ) + ) + detail = envelope(web.get(base + "detail", params=params)) + assert upstream["profile_template_id"] == detail["templateId"] == identifier + assert upstream["metadata"] == {"Work": detail["Work"]} + assert detail["bindingCount"] == upstream["bound_count"] == 0 + assert re.fullmatch(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}", detail["updatedAt"]) + record( + "same_id_rest_and_tree", + tree_sha256=hashlib.sha256( + json.dumps(detail["Work"], sort_keys=True).encode() + ).hexdigest(), + ) + page = 1 + found = False + while not found: + listing = envelope( + web.post( + base + "list", + json={"projectId": args.project, "pageNum": page, "pageSize": 2}, + ) + ) + assert listing["current"] == page and listing["size"] == 2 + assert listing["pages"] == (listing["total"] + 1) // 2 + found = any(row["templateId"] == identifier for row in listing["list"]) + assert found or page < min(listing["pages"], 100) + page += 1 + record("paged_list_contains_same_id") + foreign = {**params, "projectId": args.other_project} + for operation in ["detail", "update", "delete"]: + response = ( + web.get(base + operation, params=foreign) + if operation == "detail" + else web.post( + base + operation, + json=(foreign if operation == "delete" else {**body, **foreign}), + ) + ) + assert response.status_code == 404 + record("foreign_detail_update_delete_denied") + # Remove a field to validate the native retained/deprecated-field semantics. + updated = { + **body, + **params, + "templateName": "TASK15_UPDATED_" + run, + "Work": {"role": {"value": "reviewer", "algorithm_updatable": False}}, + } + envelope(web.post(base + "update", json=updated)) + detail = envelope(web.get(base + "detail", params=params)) + assert detail["Work"] == updated["Work"] + upstream = envelope( + web.post( + native + "get_profile_template", + json={"mem_cube_id": args.cube, "profile_template_id": identifier}, + ) + ) + assert "Work.location" in upstream["deprecated_paths"] + record("update_active_tree_and_native_deprecation") + if args.restart: + for service in ["Backend", "Upstream"]: + result = run_ps(args.root / "setup" / ("Restart-" + service + ".ps1")) + assert result.returncode == 0, "Owned service restart failed: " + service + deadline = time.monotonic() + 45 + while True: + ready = web.get("http://127.0.0.1:5281/api/meta") + if ready.status_code == 200 and ready.json().get("code") == 0: + break + assert time.monotonic() < deadline, "Upstream readiness timeout" + time.sleep(0.5) + authenticate_local_backend(web) + detail = envelope(web.get(base + "detail", params=params)) + assert detail["templateName"] == updated["templateName"] + assert detail["Work"] == updated["Work"] + record("backend_and_upstream_restart_persistence") + envelope(web.post(base + "delete", json=params)) + assert web.get(base + "detail", params=params).status_code == 404 + assert ( + web.post( + native + "get_profile_template", + json={"mem_cube_id": args.cube, "profile_template_id": identifier}, + ).json()["code"] + == 404 + ) + record("delete_native_and_bff_missing") + identifier = None + proof["passed"] = True + except Exception as error: + proof["error_type"] = type(error).__name__ + raise + finally: + if identifier is not None: + try: + authenticate_local_backend(web) + cleaned = web.post( + base + "delete", json={"projectId": args.project, "templateId": identifier} + ) + proof["cleanup_status"] = cleaned.status_code + except Exception as error: + proof["cleanup_error_type"] = type(error).__name__ + serialized = json.dumps(proof, indent=2) + assert "sk-" not in serialized + output.write_text(serialized, encoding="utf-8") + print( + json.dumps( + { + "passed": proof["passed"], + "checks": len(proof["checks"]), + "evidence": str(output), + } + ) + ) + + +if __name__ == "__main__": + main() diff --git a/server/backend/acceptance/task16_driver.py b/server/backend/acceptance/task16_driver.py new file mode 100644 index 000000000..f62ce440e --- /dev/null +++ b/server/backend/acceptance/task16_driver.py @@ -0,0 +1,295 @@ +"""Final local HTTP/contract and reversible synthetic lifecycle acceptance, with Mock stopped.""" + +import hashlib +import json +import socket +import subprocess +import sys +import uuid +from pathlib import Path + +import httpx +from jsonschema import Draft202012Validator +from server.backend.app import create_app + +ROOT = Path("D:/MemOS") +BASE = "http://127.0.0.1:5274" +NATIVE = "http://127.0.0.1:8000" + + +def main(): + sys.path.insert(0, str(ROOT / "setup")) + from verify_task01 import authenticate_local_backend + + spec = json.loads((ROOT / "dashboard/openapi.json").read_text(encoding="utf-8")) + evidence = ROOT / "dashboard/.data/task16" + evidence.mkdir(exist_ok=True) + run = uuid.uuid4().hex[:12] + checks = [] + + def record(name, **details): + checks.append({"name": name, "passed": True, **details}) + + def validate(response, component): + assert response.status_code == 200, (component, response.status_code) + payload = response.json() + assert payload["code"] == 0 + if component in spec["components"]["responses"]: + schema = spec["components"]["responses"][component]["content"]["application/json"][ + "schema" + ] + else: + schema = { + "allOf": [ + {"$ref": "#/components/schemas/Envelope"}, + {"properties": {"data": {"$ref": "#/components/schemas/" + component}}}, + ] + } + Draft202012Validator({**schema, "components": spec["components"]}).validate(payload) + return payload["data"] + + def post(web, path, body, component): + return validate(web.post(BASE + path, json=body), component) + + try: + with socket.socket() as probe: + assert probe.connect_ex(("127.0.0.1", 5280)) != 0, "Mock must be stopped" + record("mock_port_closed") + app = create_app() + routes = { + (method.lower(), route.path) + for route in app.routes + for method in getattr(route, "methods", []) + } + fixed = { + "/api/dashboard/skill/skillMD", + "/api/user/quota/summary", + "/api/user/subscription/details", + } + matrix = [] + for path, operations in spec["paths"].items(): + for method in operations: + if method not in {"get", "post", "put", "patch", "delete", "head", "options"}: + continue + implemented = (method, path) in routes + status = ( + "fixed_compatibility" + if path in fixed + else "frozen_knowledge" + if "/knowledgebase/" in path + else "implemented" + ) + assert implemented or (method, path) == ( + "post", + "/api/dashboard/knowledgebase/memory/list", + ), (method, path) + matrix.append( + { + "method": method.upper(), + "path": path, + "status": status, + "handler_registered": implemented, + } + ) + (evidence / "operation-matrix.json").write_text( + json.dumps(matrix, indent=2), encoding="utf-8" + ) + record( + "all_spec_operations_classified", + operations=len(matrix), + implemented=sum(row["handler_registered"] for row in matrix), + fixed_compatibility=sum(row["status"] == "fixed_compatibility" for row in matrix), + ) + with httpx.Client(trust_env=False, timeout=45) as web: + meta = validate(web.get(BASE + "/api/meta"), "Meta") + assert meta["mode"] == "live" + quota = validate(web.get(BASE + "/api/user/quota/summary"), "QuotaSummary") + assert quota["memoryCount"] == 0 + subscription = post( + web, + "/api/user/subscription/details", + { + "cycleId": "", + "includeAllCycles": True, + }, + "SubscriptionDetails", + ) + assert subscription == {"cycles": []} + assert web.post(BASE + "/api/dashboard/project/list", json={}).status_code == 401 + record("main_frontend_live_and_anonymous_denied") + authenticate_local_backend(web) + assert ( + post( + web, + "/api/dashboard/skill/skillMD", + { + "oss_url": "https://example.invalid/not-fetched", + }, + "StringData", + ) + == "" + ) + record("fixed_compatibility_contracts") + validate(web.get(BASE + "/api/auth/me"), "Account") + validate(web.get(BASE + "/api/dashboard/project/dict"), "ProjectDict") + post(web, "/api/dashboard/project/list", {"pageSize": 200}, "ProjectRecords") + validate(web.get(BASE + "/admin/accounts"), "AdminAccountRecords") + project = "issue3-project-a" + post( + web, + "/api/dashboard/project/update", + {"id": project, "status": "ENABLED"}, + "Project", + ) + record("project_account_session_contracts") + for tab in [ + "text_mem", + "pref_mem", + "event_mem", + "profile_mem", + "skill_mem", + "tool_mem", + ]: + response = web.post( + BASE + "/api/dashboard/project/memory/list", + json={"projectId": project, "memoryDetailType": tab, "pageSize": 10}, + ) + validate(response, "MemoryList") + assert len(response.content) < 200 * 1024 + record("six_tab_projection", tab=tab, bytes=len(response.content)) + seed = json.loads((ROOT / ".local/task03-seed.json").read_text(encoding="utf-8")) + assert seed["marker"].startswith("Task03-") + memory = seed["memory_id"] + before = post( + web, + "/api/dashboard/project/memory/detail", + {"memoryId": memory, "memType": "text_mem"}, + "Memory", + ) + native = web.get( + NATIVE + "/product/get_memory/" + memory, params={"mem_cube_id": project} + ).json()["data"] + assert native["id"] == before["memoryId"] == memory + assert before["memoryContent"] == native["memory"] + record( + "unique_synthetic_same_id_rest", + memory_id=memory, + content_sha256=hashlib.sha256(before["memoryContent"].encode()).hexdigest(), + ) + for identifier, tab in [ + (memory, "text_mem"), + (seed["profile_field_ids"][0], "profile_mem"), + ]: + original = post( + web, + "/api/dashboard/project/memory/detail", + {"memoryId": identifier, "memType": tab}, + "Memory", + ) + body = {"projectId": project, "memory_ids": [identifier], "memType": tab} + try: + deleted = post( + web, + "/api/dashboard/project/memory/delete", + {**body, "operationId": "task16-delete-" + run + tab}, + "MemoryOperation", + ) + assert deleted["state"] == "confirmed" and deleted["deleted"] == 1 + assert ( + web.post( + BASE + "/api/dashboard/project/memory/detail", + json={"memoryId": identifier, "memType": tab}, + ).status_code + == 404 + ) + if tab == "text_mem": + assert ( + web.get( + NATIVE + "/product/get_memory/" + identifier, + params={"mem_cube_id": project}, + ).json()["data"] + is None + ) + subprocess.run( + [sys.executable, str(ROOT / "setup/task03_fault_probe.py"), "read"], + check=True, + capture_output=True, + timeout=40, + ) + raw = json.loads( + (ROOT / ".local/task03-fault-read.json").read_text(encoding="utf-8") + ) + assert raw["graph_status"] == raw["vector_status"] == "deleted" + assert raw["stale_vector_result_count"] == 0 + record("independent_graph_vector_deleted", stale_vector_results=0) + finally: + restored = post( + web, + "/api/dashboard/project/memory/restore", + {**body, "operationId": "task16-restore-" + run + tab}, + "MemoryOperation", + ) + assert restored["state"] == "confirmed" and restored["restored"] == 1 + final = post( + web, + "/api/dashboard/project/memory/detail", + {"memoryId": identifier, "memType": tab}, + "Memory", + ) + assert final["memoryContent"] == original["memoryContent"] + record("real_soft_delete_restore", tab=tab, same_id=True, same_content=True) + logs = post( + web, "/api/dashboard/log/list", {"projectId": project, "scope": "all"}, "LogList" + ) + assert logs["logs"] + log = post( + web, "/api/dashboard/log/detail", {"logId": logs["logs"][0]["id"]}, "LogDetail" + ) + assert isinstance(log["requestBody"], str) and isinstance(log["responseBody"], str) + json.loads(log["requestBody"]) + json.loads(log["responseBody"]) + record("audit_list_detail_json_strings") + post(web, "/api/dashboard/usage/query", {"projectId": project}, "UsageQuery") + post(web, "/api/dashboard/prompt-config/detail", {"projectId": project}, "PromptConfig") + post( + web, + "/api/dashboard/profileTemplate/list", + {"projectId": project}, + "ProfileTemplateList", + ) + validate( + web.get(BASE + "/api/dashboard/graph/memories", params={"tenant": project}), + "GraphMemoryGraph", + ) + validate(web.get(BASE + "/api/dashboard/models"), "ModelRecords") + record("usage_prompt_templates_graph_models_contracts") + foreign = post( + web, + "/api/dashboard/project/memory/list", + { + "projectId": "issue3-project-b", + "memoryDetailType": "text_mem", + "memSearchContent": seed["marker"], + }, + "MemoryList", + ) + assert foreign["total"] == 0 + record("foreign_project_unique_marker_absent") + result = {"passed": True, "run_id": run, "checks": checks} + except Exception as error: + result = { + "passed": False, + "run_id": run, + "checks": checks, + "error_type": type(error).__name__, + } + raise + finally: + serialized = json.dumps(result, indent=2) + assert "sk-" not in serialized + (evidence / "live.json").write_text(serialized, encoding="utf-8") + print(json.dumps({"passed": result["passed"], "checks": len(checks)})) + + +if __name__ == "__main__": + main() diff --git a/server/backend/app.py b/server/backend/app.py new file mode 100644 index 000000000..7a86d8ac2 --- /dev/null +++ b/server/backend/app.py @@ -0,0 +1,113 @@ +"""Application factory: independent of model SDKs, databases, and Mock.""" + +import logging +import sqlite3 +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +import httpx +from fastapi import FastAPI, Request, Response + +from .audit_routes import AuditService +from .audit_routes import router as audit_router +from .audit_writer import ConsoleAuditMiddleware, ConsoleWriter +from .auth import SessionMiddleware +from .auth import router as auth_router +from .compatibility import router as compatibility_router +from .control import ControlStore +from .errors import install_error_handlers +from .identity_store import IdentityStore +from .invites import InviteStore +from .invites import router as invite_router +from .kb_store import KnowledgeStore +from .key_store import ApiKeyStore, sync_manifest +from .keys import router as keys_router +from .knowledge import router as knowledge_router +from .knowledge_files import router as kb_file_router +from .members import router as member_router +from .memories import router as memory_router +from .memory_lifecycle import router as lifecycle_router +from .memory_upstream import MemoryUpstream +from .models import SERVICE_NAME, Envelope, Meta +from .product_client import ProductClient +from .profile_templates import router as template_router +from .projects import router as project_router +from .prompts import router as prompt_router +from .read_views import router as read_router +from .settings import Settings +from .upstream import UpstreamClient +from .usage_routes import router as usage_router + + +def create_app( + settings: Settings | None = None, + *, + transport: httpx.AsyncBaseTransport | None = None, +) -> FastAPI: + config = settings if settings is not None else Settings.from_env() + + @asynccontextmanager + async def lifespan(app: FastAPI) -> AsyncIterator[None]: + async with httpx.AsyncClient( + base_url=config.upstream_url, + timeout=config.upstream_timeout_seconds, + follow_redirects=False, + trust_env=False, + transport=transport, + headers={"Accept": "application/json", "Accept-Encoding": "identity"}, + ) as client: + app.state.upstream = UpstreamClient(client) + app.state.memories = MemoryUpstream(client) + app.state.product = ProductClient(client) + app.state.settings = config + app.state.control = ControlStore(config) + app.state.identity = IdentityStore(app.state.control, config) + app.state.knowledge = KnowledgeStore(app.state.control) + app.state.invites = InviteStore(app.state.identity) + app.state.audit = AuditService(config) + app.state.console_audit = ConsoleWriter( + config.audit_console_dir or config.data_dir / "audit" + ) + if config.gateway_keys_file: + try: + sync_manifest(ApiKeyStore(app.state.control), config.gateway_keys_file) + except (OSError, sqlite3.Error) as error: + logging.getLogger(__name__).warning( + "gateway keys manifest self-heal failed; later writes will " + "report the real error: %r", + error, + ) + yield + + app = FastAPI(title="MemOS Dashboard Backend", version="0.1.0", lifespan=lifespan) + install_error_handlers(app) + app.add_middleware(ConsoleAuditMiddleware) + app.add_middleware(SessionMiddleware) + app.include_router(memory_router) + app.include_router(lifecycle_router) + app.include_router(audit_router) + app.include_router(usage_router) + app.include_router(project_router) + app.include_router(auth_router) + app.include_router(member_router) + app.include_router(invite_router) + app.include_router(knowledge_router) + app.include_router(kb_file_router) + app.include_router(prompt_router) + app.include_router(keys_router) + app.include_router(read_router) + app.include_router(template_router) + app.include_router(compatibility_router) + + @app.get("/health", response_model=Envelope[dict[str, str]]) + async def liveness(response: Response) -> Envelope[dict[str, str]]: + response.headers["Cache-Control"] = "no-store" + return Envelope(data={"status": "healthy", "service": SERVICE_NAME}) + + @app.get("/api/meta", response_model=Envelope[Meta]) + async def meta(request: Request, response: Response) -> Envelope[Meta]: + health = await request.app.state.upstream.health() + response.headers["Cache-Control"] = "no-store" + return Envelope(data=Meta(health=health, authEnabled=config.auth_enabled)) + + return app diff --git a/server/backend/audit_index.py b/server/backend/audit_index.py new file mode 100644 index 000000000..fd1426095 --- /dev/null +++ b/server/backend/audit_index.py @@ -0,0 +1,447 @@ +"""Incremental, transactional JSONL index. Sources are operator configuration only.""" + +import hashlib +import json +import os +import re +import sqlite3 +import uuid +from contextlib import contextmanager +from datetime import timedelta +from pathlib import Path + +import orjson + +from .audit_normalize import UnscopedLine, normalize +from .errors import ApiError +from .memory_models import time_boundary +from .timefmt import display +from .usage_models import midnight + +MAX_LINE_BYTES = 1024 * 1024 + + +def compact(value): + return orjson.dumps(value).decode("utf-8") + + +def unavailable(): + return ApiError(50300, "审计日志来源暂不可用", status_code=503) + + +class AuditIndex: + def __init__(self, path: Path, sources: dict[str, Path]): + self.path, self.sources = path, sources + path.parent.mkdir(parents=True, exist_ok=True) + with self.connection() as db: + db.execute("PRAGMA journal_mode=WAL") + db.executescript(""" + CREATE TABLE IF NOT EXISTS audit_files ( + source TEXT, identity TEXT, generation TEXT, offset INTEGER, + prefix_len INTEGER, prefix_sha TEXT, tail_sha TEXT, + PRIMARY KEY(source,identity)); + CREATE TABLE IF NOT EXISTS audit_lines ( + generation TEXT, position INTEGER, call_key TEXT, + PRIMARY KEY(generation,position)); + CREATE TABLE IF NOT EXISTS audit_rejects ( + generation TEXT, position INTEGER, reason TEXT, + PRIMARY KEY(generation,position)); + CREATE TABLE IF NOT EXISTS audit_calls ( + id TEXT PRIMARY KEY, cube TEXT, source TEXT, event TEXT, status TEXT, + ts INTEGER, users TEXT, agents TEXT, search_text TEXT, + detail TEXT, request_record TEXT, terminal_record TEXT); + CREATE INDEX IF NOT EXISTS audit_tenant_time ON audit_calls(cube,ts DESC,id DESC); + CREATE INDEX IF NOT EXISTS audit_source_time + ON audit_calls(cube,source,ts DESC,id DESC); + CREATE INDEX IF NOT EXISTS audit_event_time + ON audit_calls(cube,event,ts DESC,id DESC); + CREATE INDEX IF NOT EXISTS audit_status_time + ON audit_calls(cube,status,ts DESC,id DESC); + """) + + @contextmanager + def connection(self): + db = sqlite3.connect(self.path, timeout=10) + db.row_factory = sqlite3.Row + # The authoritative JSONL can rebuild this cache. NORMAL preserves WAL + # transaction atomicity; after power loss both rows and cursor may replay. + db.execute("PRAGMA synchronous=NORMAL") + db.execute("PRAGMA cache_size=-32768") + try: + with db: + yield db + finally: + db.close() + + @staticmethod + def _anchors(file, offset): + length = min(offset, 4096) + file.seek(0) + prefix = hashlib.sha256(file.read(length)).hexdigest() + file.seek(offset - length) + tail = hashlib.sha256(file.read(length)).hexdigest() + return length, prefix, tail + + def sync(self, scope="all"): + totals = {"imported": 0, "invalid": 0, "unscoped": 0} + names = tuple(self.sources) if scope == "all" else (scope,) + if not names: + raise unavailable() + try: + # Validate every selected source before importing any of them. + files = [] + for source in names: + folder = self.sources.get(source) + if folder is None or not folder.is_dir(): + raise unavailable() + prefix = "calls-" if source == "gateway" else "console-" + for path in sorted(folder.iterdir()): + if not re.fullmatch( + re.escape(prefix) + r"[^/\\]+\.jsonl(?:\.[1-9][0-9]*)?", path.name + ): + continue + if path.is_symlink() or not path.is_file(): + raise unavailable() + files.append((source, path)) + for source, path in files: + counts = self._sync_file(source, path) + for key in totals: + totals[key] += counts[key] + except (OSError, sqlite3.Error) as exc: + raise unavailable() from exc + return totals + + def _sync_file(self, source, path): + counts = {"imported": 0, "invalid": 0, "unscoped": 0} + with path.open("rb") as file, self.connection() as db: + db.execute("BEGIN IMMEDIATE") + stat = os.fstat(file.fileno()) + identity = f"{stat.st_dev}:{stat.st_ino}" + if os.name == "nt": + identity += f":{stat.st_ctime_ns}" + cursor = db.execute( + "SELECT * FROM audit_files WHERE source=? AND identity=?", (source, identity) + ).fetchone() + offset = cursor["offset"] if cursor else 0 + generation = cursor["generation"] if cursor else uuid.uuid4().hex + if cursor and offset: + file.seek(0) + prefix = hashlib.sha256(file.read(cursor["prefix_len"])).hexdigest() + file.seek(max(0, offset - min(offset, 4096))) + tail = hashlib.sha256(file.read(min(offset, 4096))).hexdigest() + if ( + stat.st_size < offset + or prefix != cursor["prefix_sha"] + or tail != cursor["tail_sha"] + ): + offset, generation = 0, uuid.uuid4().hex + file.seek(offset) + while file.tell() < stat.st_size: + position = file.tell() + raw = file.readline(min(MAX_LINE_BYTES + 1, stat.st_size - position)) + oversized = len(raw) > MAX_LINE_BYTES + while oversized and not raw.endswith(b"\n") and file.tell() < stat.st_size: + raw = file.readline(min(MAX_LINE_BYTES + 1, stat.st_size - file.tell())) + if not raw.endswith(b"\n"): + break # The cursor stays at the start of a partial line. + offset = file.tell() + if oversized: + counts["invalid"] += 1 + db.execute( + "INSERT OR IGNORE INTO audit_rejects VALUES (?,?,?)", + (generation, position, "invalid"), + ) + continue + try: + entry = orjson.loads(raw) + record = normalize(entry, source) + counts["imported"] += self._store_line(db, generation, position, record) + except UnscopedLine: + counts["unscoped"] += 1 + db.execute( + "INSERT OR IGNORE INTO audit_rejects VALUES (?,?,?)", + (generation, position, "unscoped"), + ) + except (ValueError, TypeError, RecursionError): + counts["invalid"] += 1 + db.execute( + "INSERT OR IGNORE INTO audit_rejects VALUES (?,?,?)", + (generation, position, "invalid"), + ) + if os.fstat(file.fileno()).st_size < offset: + raise OSError("Audit file changed during import") + anchors = self._anchors(file, offset) + db.execute( + "INSERT OR REPLACE INTO audit_files VALUES (?,?,?,?,?,?,?)", + (source, identity, generation, offset, *anchors), + ) + return counts + + def _store_line(self, db, generation, position, record): + call_id = record["call_id"] + key = ( + "\0".join((record["source"], record["cube"], call_id)) + if call_id + else f"{generation}:{position}" + ) + identifier = "log_" + hashlib.sha256(key.encode()).hexdigest() + existing = ( + db.execute( + "SELECT request_record,terminal_record,event FROM audit_calls WHERE id=?", + (identifier,), + ).fetchone() + if call_id + else None + ) + if existing and record["event"] and existing["event"] not in (record["event"], "unknown"): + raise ValueError("Conflicting events for a call ID") + if not db.execute( + "INSERT OR IGNORE INTO audit_lines VALUES (?,?,?)", (generation, position, identifier) + ).rowcount: + return 0 + request = ( + json.loads(existing["request_record"]) + if existing and existing["request_record"] + else None + ) + terminal = ( + json.loads(existing["terminal_record"]) + if existing and existing["terminal_record"] + else None + ) + if record["phase"] == "terminal": + terminal = self._merge_terminal(terminal, record) + elif request is None or record["ts"] < request["ts"]: + request = record + base, final = request or terminal, terminal or request + users = sorted(set((request or {}).get("users", []) + (terminal or {}).get("users", []))) + agents = sorted(set((request or {}).get("agents", []) + (terminal or {}).get("agents", []))) + event = (request or {}).get("event") or (terminal or {}).get("event") or "unknown" + detail = { + "id": identifier, + "createTime": base["time"], + "eventType": event, + "status": final["status"], + "duration": final["duration"], + "requestTokens": final["request_tokens"], + "responseTokens": final["response_tokens"], + "requestBody": base["request_body"], + "responseBody": final["response_body"], + "source": record["source"], + } + if users: + detail["userId"] = ",".join(users) + if agents: + detail["agentId"] = ",".join(agents) + for output, field in ( + ("callId", "call_id"), + ("traceId", "trace_id"), + ("requestId", "request_id"), + ("requestCode", "request_code"), + ("responseCode", "response_code"), + ("llmCalls", "llm_calls"), + ): + value = final[field] if final[field] is not None else base[field] + if value is not None: + detail[output] = value + search = " ".join( + (event, call_id or "", *users, *agents, detail["requestBody"], detail["responseBody"]) + ).casefold() + db.execute( + "INSERT OR REPLACE INTO audit_calls VALUES (?,?,?,?,?,?,?,?,?,?,?,?)", + ( + identifier, + record["cube"], + record["source"], + event, + final["status"], + base["ts"], + compact(users), + compact(agents), + search, + compact(detail), + compact(request) if request and call_id else None, + compact(terminal) if terminal and call_id else None, + ), + ) + return 1 + + @staticmethod + def _merge_terminal(previous, incoming): + if previous is None: + return incoming + # Later explicit evidence wins; an equal-time conflict prefers FAILURE. + newer, older = ( + (incoming, previous) + if (incoming["ts"], incoming["status"] == "FAILURE") + >= (previous["ts"], previous["status"] == "FAILURE") + else (previous, incoming) + ) + merged = dict(newer) + if newer["status"] == "UNKNOWN": + merged["status"] = older["status"] + for field in ( + "duration", + "request_tokens", + "response_tokens", + "request_body", + "response_body", + ): + if field not in newer.get("observed", []) and field in older.get("observed", []): + merged[field] = older[field] + merged["observed"] = sorted(set(newer.get("observed", []) + older.get("observed", []))) + for field in ( + "event", + "trace_id", + "request_id", + "request_code", + "response_code", + "llm_calls", + ): + if merged[field] is None: + merged[field] = older[field] + for field in ("users", "agents"): + merged[field] = sorted(set(newer[field] + older[field])) + return merged + + def usage(self, cubes, start, end, *, include_daily=True): + """Aggregate existing logical rows; no second reader or status-based billing guess.""" + events = ("addMessage", "searchMemory", "chat", "extractMemory", "rerank") + + def empty(): + return { + event: {"calls": 0, "totalTokens": 0} + if event in {"addMessage", "searchMemory", "rerank"} + else {"requestTokens": 0, "responseTokens": 0, "totalTokens": 0} + for event in events + } + + days = { + (start + timedelta(days=n)).isoformat(): empty() for n in range((end - start).days + 1) + } + summary = empty() + # Read all selected cubes in one SQLite snapshot. Date bounds are UTC + # microseconds, bucket labels follow the frontend's fixed UTC+8 calendar. + with self.connection() as db: + db.execute("BEGIN") + for cube in dict.fromkeys(cubes): + rows = db.execute( + "SELECT strftime('%Y-%m-%d'," + "(ts-CASE WHEN ts<0 THEN 999999 ELSE 0 END)/1000000," + "'unixepoch','+8 hours') AS day," + "event,json_extract(detail,'$.requestTokens') AS req," + "json_extract(detail,'$.responseTokens') AS res " + "FROM audit_calls WHERE cube=? AND source='gateway' AND ts>=? AND ts0") + values.append(query.query.casefold()) + if query.subjectId: + columns = ( + ("users",) + if query.subjectType == "user" + else ("agents",) + if query.subjectType == "agent" + else ("users", "agents") + ) + clauses.append( + "(" + + " OR ".join( + f"EXISTS(SELECT 1 FROM json_each({col}) WHERE value=?)" for col in columns + ) + + ")" + ) + values.extend([query.subjectId] * len(columns)) + for raw, end in ((query.startTime, False), (query.endTime, True)): + date, exclusive = time_boundary(raw, end=end) + if date is not None: + clauses.append("ts" + ("<" if exclusive else "<=" if end else ">=") + "?") + values.append(round(date.timestamp() * 1_000_000)) + where = " AND ".join(clauses) + with self.connection() as db: + db.execute("BEGIN") + total = db.execute( + "SELECT count(*) FROM audit_calls WHERE " + where, values + ).fetchone()[0] + rows = db.execute( + "SELECT detail,users,agents FROM audit_calls WHERE " + + where + + " ORDER BY ts DESC,id DESC LIMIT ? OFFSET ?", + (*values, query.pageSize, (query.pageNum - 1) * query.pageSize), + ).fetchall() + logs = [] + for row in rows: + detail = json.loads(row["detail"]) + served = { + key: value + for key, value in detail.items() + if key + not in { + "requestBody", + "responseBody", + "userId", + "agentId", + "traceId", + "requestId", + "requestCode", + "responseCode", + "llmCalls", + } + } | {"userIds": json.loads(row["users"]), "agentIds": json.loads(row["agents"])} + if served.get("createTime"): + served["createTime"] = display(served["createTime"]) + logs.append(served) + return {"logs": logs, "total": total} + + def detail(self, cube, identifier): + with self.connection() as db: + row = db.execute( + "SELECT detail FROM audit_calls WHERE cube=? AND id=?", (cube, identifier) + ).fetchone() + if row is None: + return None + served = json.loads(row[0]) + if served.get("createTime"): + served["createTime"] = display(served["createTime"]) + return served diff --git a/server/backend/audit_models.py b/server/backend/audit_models.py new file mode 100644 index 000000000..2a7513634 --- /dev/null +++ b/server/backend/audit_models.py @@ -0,0 +1,88 @@ +"""Call log query contracts; output unknown history is kept explicit.""" + +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +from .memory_models import time_boundary + +EVENT_TYPES = frozenset( + ( + "addMessage", + "searchMemory", + "getMemory", + "deleteMemory", + "addFeedback", + "manualAddMemory", + "updateMemory", + "bindProfileTemplate", + "editProfile", + "deleteProfile", + "getMessage", + "chat", + "extractMemory", + "rerank", + ) +) + + +class LogListRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + projectId: str = Field(min_length=1, max_length=128) + pageNum: int = Field(1, ge=1, le=2_147_483_647, strict=True) + pageSize: int = Field(50, ge=1, le=100, strict=True) + scope: Literal["all", "gateway", "console"] = "all" + subjectType: Literal["all", "user", "agent"] | None = None + subjectId: str | None = Field(None, max_length=256) + eventType: str | None = Field(None, max_length=256) + query: str | None = Field(None, max_length=1000) + status: Literal["SUCCESS", "FAILURE", "PENDING", "UNKNOWN"] | None = None + startTime: str | None = Field(None, max_length=64) + endTime: str | None = Field(None, max_length=64) + + @field_validator( + "subjectType", + "subjectId", + "eventType", + "query", + "status", + "startTime", + "endTime", + mode="before", + ) + @classmethod + def empty_is_missing(cls, value): + return value.strip() or None if isinstance(value, str) else value + + @field_validator("status", mode="before") + @classmethod + def status_alias(cls, value): + if not isinstance(value, str) or not value.strip(): + return None if value == "" else value + return {"FAILED": "FAILURE", "FAIL": "FAILURE"}.get(value.upper(), value.upper()) + + @field_validator("eventType") + @classmethod + def event_filter(cls, value): + value = "extractMemory" if value == "extractMemoryDirect" else value + if value is not None and value not in EVENT_TYPES: + raise ValueError("Unsupported event type filter") + return value + + @model_validator(mode="after") + def validate_filters(self): + if self.subjectId is None: + self.subjectType = None + try: + start, _ = time_boundary(self.startTime) + end, exclusive = time_boundary(self.endTime, end=True) + except OverflowError as exc: + raise ValueError("UTC date boundary out of range") from exc + if start and end and (start > end or (exclusive and start == end)): + raise ValueError("Invalid date range") + return self + + +class LogDetailRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + logId: str = Field(min_length=1, max_length=128, pattern=r"^[A-Za-z0-9_.-]+$") diff --git a/server/backend/audit_normalize.py b/server/backend/audit_normalize.py new file mode 100644 index 000000000..e4d5fe506 --- /dev/null +++ b/server/backend/audit_normalize.py @@ -0,0 +1,242 @@ +"""Normalize only evidence present in audit lines; never guess tenant/subjects.""" + +import json +import math +import re +from datetime import datetime, timezone +from functools import lru_cache +from typing import Any + +TOOL_EVENTS = { + "add_message": "addMessage", + "search_memory": "searchMemory", + "get_user_profile": "getMemory", + "delete_memory": "deleteMemory", + "add_feedback": "addFeedback", +} +SECRET_KEY = re.compile(r"(?:apikey|authorization|password|secret|accesstoken|refreshtoken|token)$") +SECRET_VALUE = re.compile(r"\bsk-[A-Za-z0-9_-]{20,}\b") +BODY_LIMIT = 65536 + + +class UnscopedLine(ValueError): + pass + + +@lru_cache(maxsize=512) +def credential_key(key: str) -> bool: + """Cache common schema keys, never credential values or unbounded long keys.""" + return bool(SECRET_KEY.search(re.sub(r"[^a-z0-9]", "", key.casefold()))) + + +def redact(value: Any) -> Any: + if isinstance(value, dict): + return { + key: "[REDACTED]" + if ( + credential_key(key) + if len(key) <= 128 + else SECRET_KEY.search(re.sub(r"[^a-z0-9]", "", key.casefold())) + ) + else redact(item) + for key, item in value.items() + } + if isinstance(value, list): + return [redact(item) for item in value] + if isinstance(value, str): + return SECRET_VALUE.sub("[REDACTED]", value) + return value + + +def decoded(value: Any) -> Any: + if isinstance(value, str): + try: + return json.loads(value) + except (ValueError, RecursionError): + return value + return value + + +def body_json(value: Any) -> str: + parsed = decoded(value) + clean = redact(parsed) + if isinstance(value, str) and clean == parsed and not isinstance(parsed, str): + result = value + elif isinstance(value, str) and clean == parsed: + try: + json.loads(value) + result = value + except ValueError: + result = json.dumps(clean, ensure_ascii=False, separators=(",", ":"), allow_nan=False) + else: + result = json.dumps(clean, ensure_ascii=False, separators=(",", ":"), allow_nan=False) + if len(result) > BODY_LIMIT: + result = json.dumps( + {"truncated": True, "chars": len(result), "text": result[:BODY_LIMIT]}, + ensure_ascii=False, + separators=(",", ":"), + ) + return result + + +def text(value: Any, limit: int = 256) -> str | None: + return value[:limit] if isinstance(value, str) and value else None + + +def integer(value: Any) -> int | None: + return value if type(value) is int and value >= 0 else None + + +def timestamp(value: Any) -> tuple[str, int]: + try: + if isinstance(value, str): + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + if parsed.utcoffset() is None: + return "", 0 + elif type(value) in (int, float) and math.isfinite(value): + parsed = datetime.fromtimestamp(value, timezone.utc) + else: + return "", 0 + parsed = parsed.astimezone(timezone.utc) + return parsed.isoformat(), round(parsed.timestamp() * 1_000_000) + except (ValueError, OverflowError, OSError): + return "", 0 + + +def identifiers(entry: dict, request: dict, kind: str) -> list[str]: + values = [] + for container in (entry, request): + for key in (kind + "Ids", kind + "_ids", kind + "Id", kind + "_id"): + value = container.get(key) + if isinstance(value, str): + values.append(value) + elif isinstance(value, list): + values.extend(item for item in value if isinstance(item, str)) + return sorted({item[:256] for item in values if item})[:100] + + +def normalize(entry: dict, source: str) -> dict: + if not isinstance(entry, dict): + raise ValueError("Audit line must be an object") + raw_cube = entry.get("tenant") or entry.get("mem_cube_id") or entry.get("cube_id") + if isinstance(raw_cube, str) and len(raw_cube) > 128: + raise ValueError("Tenant identifier is too long") + cube = text(raw_cube, 128) + if cube is None: + raise UnscopedLine("Audit tenant is absent") + request = decoded(entry.get("input", entry.get("requestBody"))) + output = decoded(entry.get("output", entry.get("responseBody"))) + input_dict = request if isinstance(request, dict) else {} + output_dict = output if isinstance(output, dict) else {} + data = output_dict.get("data") + data = data if isinstance(data, dict) else {} + event = text(entry.get("eventType") or entry.get("event_type") or entry.get("tool")) + event = TOOL_EVENTS.get(event, event) + event = "extractMemory" if event == "extractMemoryDirect" else event + raw_call_id = entry.get("callId") or entry.get("call_id") + if isinstance(raw_call_id, str) and len(raw_call_id) > 256: + raise ValueError("Call identifier is too long") + call_id = text(raw_call_id) + raw_phase = str(entry.get("phase") or "").lower() + phase = ( + "terminal" + if raw_phase in {"completed", "complete", "completion", "final", "terminal", "failed"} + else "request" + if raw_phase in {"request", "start", "accepted", "pending"} + else "sync" + ) + statuses = { + str(value).lower() + for value in (entry.get("status"), data.get("status"), output_dict.get("status")) + if value is not None + } + codes = [ + integer(value) + for value in ( + entry.get("responseCode"), + entry.get("response_code"), + output_dict.get("code"), + ) + ] + response_code = integer( + entry.get("responseCode", entry.get("response_code", output_dict.get("code"))) + ) + failed = ( + entry.get("ok") is False + or entry.get("err") not in (None, "", False) + or output_dict.get("success") is False + or data.get("success") is False + or bool(statuses & {"failed", "failure", "error"}) + or raw_phase == "failed" + or any(value is not None and value not in (0, 200) for value in codes) + ) + pending = bool(statuses & {"pending", "running", "in_progress", "queued"}) or ( + phase == "request" and bool(call_id) + ) + status = ( + "FAILURE" + if failed + else "PENDING" + if pending + else "SUCCESS" + if entry.get("ok") is True + or bool(statuses & {"success", "succeeded", "completed", "done"}) + or any(value in (0, 200) for value in codes) + else "UNKNOWN" + ) + if call_id and status == "PENDING": + phase = "request" + elif call_id and phase == "sync" and status in {"SUCCESS", "FAILURE"}: + phase = "terminal" + duration = entry.get("ms", entry.get("duration")) + duration = ( + duration + if type(duration) in (int, float) + and math.isfinite(duration) + and duration >= 0 + and status != "PENDING" + else None + ) + usage = output_dict.get("usage") or data.get("usage") or entry.get("usage") or {} + usage = usage if isinstance(usage, dict) else {} + request_tokens = integer( + entry.get("requestTokens", usage.get("prompt_tokens", usage.get("input_tokens"))) + ) + response_tokens = integer( + entry.get("responseTokens", usage.get("completion_tokens", usage.get("output_tokens"))) + ) + observed = [ + key + for key, value in ( + ("duration", duration), + ("request_tokens", request_tokens), + ("response_tokens", response_tokens), + ("request_body", entry.get("input", entry.get("requestBody"))), + ("response_body", entry.get("output", entry.get("responseBody"))), + ) + if value is not None + ] + time, ts = timestamp(entry.get("ts", entry.get("createTime"))) + return { + "cube": cube, + "source": source, + "event": event, + "call_id": call_id, + "phase": phase, + "status": status, + "duration": duration, + "time": time, + "ts": ts, + "users": identifiers(entry, input_dict, "user"), + "agents": identifiers(entry, input_dict, "agent"), + "request_tokens": request_tokens or 0, + "response_tokens": response_tokens or 0, + "observed": observed, + "request_body": body_json(entry.get("input", entry.get("requestBody"))), + "response_body": body_json(entry.get("output", entry.get("responseBody"))), + "trace_id": text(entry.get("traceId") or entry.get("trace_id")), + "request_id": text(entry.get("requestId") or entry.get("request_id")), + "request_code": integer(entry.get("requestCode", entry.get("request_code"))), + "response_code": response_code, + "llm_calls": integer(entry.get("llmCalls", data.get("llm_calls"))), + } diff --git a/server/backend/audit_routes.py b/server/backend/audit_routes.py new file mode 100644 index 000000000..999066f7c --- /dev/null +++ b/server/backend/audit_routes.py @@ -0,0 +1,77 @@ +"""Audit queries run in worker threads: importing JSONL cannot block the event loop.""" + +import sqlite3 +import threading + +from fastapi import APIRouter, Request, Response + +from .audit_index import AuditIndex, unavailable +from .audit_models import LogDetailRequest, LogListRequest +from .auth import control_for +from .errors import ApiError +from .models import Envelope +from .project_context import selected_project + +router = APIRouter() + + +class AuditService: + """Lazily initialize so the first-query benchmark includes database creation.""" + + def __init__(self, config): + self.config, self.index = config, None + self.lock = threading.Lock() + + def get(self): + with self.lock: + if self.index is None: + sources = { + "console": self.config.audit_console_dir or self.config.data_dir / "audit" + } + if self.config.audit_gateway_dir is not None: + sources["gateway"] = self.config.audit_gateway_dir + self.index = AuditIndex(self.config.data_dir / "audit.db", sources) + return self.index + + def sync(self, scope="all"): + if scope in {"all", "gateway"} and self.config.audit_gateway_dir is None: + raise unavailable() + try: + index = self.get() + index.sync(scope) + return index + except (OSError, sqlite3.Error) as exc: + raise unavailable() from exc + + +@router.post("/api/dashboard/log/list", response_model=Envelope[dict]) +def log_list(body: LogListRequest, request: Request, response: Response): + control = control_for(request) + cube = control.resolve_cube(body.projectId) if control else None + if cube is None: + raise ApiError(40001, "项目不存在或未配置访问范围", status_code=404) + index = request.app.state.audit.sync(body.scope) + try: + result = index.page(cube, body) + except (OSError, sqlite3.Error) as exc: + raise unavailable() from exc + response.headers["Cache-Control"] = "no-store" + return Envelope(data=result) + + +@router.post("/api/dashboard/log/detail", response_model=Envelope[dict]) +def log_detail(body: LogDetailRequest, request: Request, response: Response): + control = control_for(request) + cube = control.resolve_cube(selected_project(request)) if control else None + if cube is None: + raise ApiError(50300, "尚未配置当前项目 DASHBOARD_DEFAULT_PROJECT_ID", status_code=503) + # An indexed console detail does not depend on gateway availability. + try: + index = request.app.state.audit.get() + detail = index.detail(cube, body.logId) + except (OSError, sqlite3.Error) as exc: + raise unavailable() from exc + if detail is None: + raise ApiError(40001, "日志不存在", status_code=404) + response.headers["Cache-Control"] = "no-store" + return Envelope(data=detail) diff --git a/server/backend/audit_writer.py b/server/backend/audit_writer.py new file mode 100644 index 000000000..17d1160db --- /dev/null +++ b/server/backend/audit_writer.py @@ -0,0 +1,128 @@ +"""Bounded console audit capture. Log queries never generate business call rows.""" + +import json +import logging +import threading +import time +import uuid +from datetime import datetime, timezone + +from starlette.concurrency import run_in_threadpool +from starlette.requests import Request + +from .audit_normalize import body_json +from .auth import control_for +from .project_context import selected_project + +logger = logging.getLogger(__name__) +LIMIT = 65536 +PREFIX = "/api/dashboard/project/memory/" +EVENTS = { + "list": "console.memory.list", + "detail": "getMemory", + "delete": "deleteMemory", + "restore": "console.memory.restore", + "retry": "console.memory.retry", +} + + +class ConsoleWriter: + def __init__(self, folder): + self.folder, self.lock = folder, threading.Lock() + folder.mkdir(parents=True, exist_ok=True) + + def append(self, record): + line = json.dumps(record, ensure_ascii=False, separators=(",", ":"), allow_nan=False) + "\n" + filename = "console-" + datetime.now(timezone.utc).strftime("%Y%m%d") + ".jsonl" + # One append syscall; serialization protects simultaneous worker threads. + with self.lock, (self.folder / filename).open("ab", buffering=0) as file: + file.write(line.encode("utf-8")) + + +def captured_body(raw, size): + if size > LIMIT: + # Do not retain a sliced secret or invalid JSON from an oversized payload. + return {"truncated": True, "bytes": size} + try: + return json.loads(raw) + except (ValueError, UnicodeError): + return {"invalidJson": True, "bytes": size} + + +class ConsoleAuditMiddleware: + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + path = scope.get("path", "") + event = EVENTS.get(path.removeprefix(PREFIX)) if path.startswith(PREFIX) else None + if scope["type"] != "http" or scope.get("method") != "POST" or event is None: + await self.app(scope, receive, send) + return + started = time.perf_counter() + request_parts, response_parts = bytearray(), bytearray() + request_size = response_size = 0 + http_status = 500 + + async def capture_receive(): + nonlocal request_size + message = await receive() + if message["type"] == "http.request": + body = message.get("body", b"") + request_size += len(body) + request_parts.extend(body[: max(0, LIMIT - len(request_parts))]) + return message + + async def capture_send(message): + nonlocal response_size, http_status + if message["type"] == "http.response.start": + http_status = message["status"] + elif message["type"] == "http.response.body": + body = message.get("body", b"") + response_size += len(body) + response_parts.extend(body[: max(0, LIMIT - len(response_parts))]) + await send(message) + + await self.app(scope, capture_receive, capture_send) + try: + state = scope["app"].state + input_body = captured_body(request_parts, request_size) + output = captured_body(response_parts, response_size) + control = control_for(Request(scope)) + # Only detail's contract lacks projectId. An invalid explicit project never falls back. + project = ( + selected_project(Request(scope)) + if path == PREFIX + "detail" + else input_body.get("projectId") + if isinstance(input_body, dict) + else None + ) + cube = scope.get("audit_cube") + if cube is None and control and project: + cube = await run_in_threadpool(control.resolve_cube, project) + if cube is not None: + code = scope.get( + "audit_code", output.get("code") if isinstance(output, dict) else None + ) + success = ( + (http_status < 400 and code == 0) + if code is not None + else False + if http_status >= 400 + else None + ) + record = { + "ts": datetime.now(timezone.utc).isoformat(), + "tenant": cube, + "eventType": event, + "callId": uuid.uuid4().hex, + "ok": success, + "ms": round((time.perf_counter() - started) * 1000, 3), + "responseCode": code, + "input": body_json(input_body), + "output": body_json(output), + } + await run_in_threadpool(state.console_audit.append, record) + except Exception as exc: + # Audit I/O failure cannot undo an already delivered mutation or leak its payload. + logger.error("Console audit write failed (%s)", type(exc).__name__) diff --git a/server/backend/auth.py b/server/backend/auth.py new file mode 100644 index 000000000..2e8f41369 --- /dev/null +++ b/server/backend/auth.py @@ -0,0 +1,342 @@ +"""Session enforcement and backend-owned HTML forms, without React changes.""" + +import hmac +import re +import secrets +import sqlite3 +from html import escape +from urllib.parse import parse_qs + +from fastapi import APIRouter, Query, Request, Response +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse +from pydantic import BaseModel, ConfigDict, Field, model_validator +from starlette.concurrency import run_in_threadpool + +from .auth_ui import STYLE_SOURCE, render +from .errors import ApiError +from .identity_store import SESSION_COOKIE, forbidden +from .models import Envelope +from .security import browser_write_receive, check_origin + +router = APIRouter() + + +def control_for(request): + actor = request.scope.get("identity") + store = request.app.state.control + return store.with_actor(actor) if actor else store + + +def actor_for(request, *, admin=False): + actor = request.scope.get("identity") + if actor is None: + raise ApiError(40100, "请先登录", status_code=401) + if admin and not actor["platform_admin"]: + raise forbidden() + return actor + + +class SessionMiddleware: + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + if scope["type"] != "http" or not scope["app"].state.settings.auth_enabled: + await self.app(scope, receive, send) + return + request = Request(scope) + try: + actor = await run_in_threadpool( + scope["app"].state.identity.identity, request.cookies.get(SESSION_COOKIE) + ) + scope["identity"] = actor + protected = ( + scope["path"].startswith(("/api/dashboard/", "/admin/")) + or scope["path"] == "/api/auth/me" + ) + if protected and actor is None: + raise ApiError(40100, "请先登录", status_code=401) + if protected and scope["method"] not in {"GET", "HEAD", "OPTIONS"}: + check_origin(request) + if request.headers.get("origin") is not None: + receive = await browser_write_receive(request, receive) + except ApiError as exc: + response = JSONResponse( + {"code": exc.code, "data": None, "message": exc.message}, + status_code=exc.status_code, + headers={"Cache-Control": "no-store"}, + ) + await response(scope, receive, send) + return + except sqlite3.Error: + await JSONResponse( + {"code": 50300, "data": None, "message": "身份存储暂不可用"}, status_code=503 + )(scope, receive, send) + return + await self.app(scope, receive, send) + + +def page(title, content, status=200): + return HTMLResponse( + render(title, content), + status_code=status, + headers={ + "Cache-Control": "no-store", + "Referrer-Policy": "same-origin", + "Content-Security-Policy": ( + "default-src 'none'; form-action 'self'; frame-ancestors 'none'; " + "style-src " + STYLE_SOURCE + ), + "X-Content-Type-Options": "nosniff", + }, + ) + + +def form_fields(token): + return '' + + +async def form(request): + check_origin(request) + if request.headers.get("content-type", "").split(";")[0] != "application/x-www-form-urlencoded": + raise ApiError(40000, "表单格式错误", status_code=400) + raw = bytearray() + async for chunk in request.stream(): + raw.extend(chunk) + if len(raw) > 8192: + raise ApiError(40000, "表单过大", status_code=400) + try: + values = parse_qs(raw.decode("utf-8"), keep_blank_values=True, max_num_fields=10) + if any(len(v) != 1 for v in values.values()): + raise ValueError() + return {k: v[0] for k, v in values.items()} + except (ValueError, UnicodeError) as exc: + raise ApiError(40000, "表单格式错误", status_code=400) from exc + + +def check_csrf(request, body, expected=None): + expected = expected or request.cookies.get("memos_login_csrf", "") + supplied = body.get("csrf", "") + if ( + not re.fullmatch(r"[A-Za-z0-9_-]{43}", supplied) + or not expected + or len(expected) != 43 + or not hmac.compare_digest(supplied, expected) + ): + raise forbidden() + + +@router.get("/__login", response_class=HTMLResponse) +def login_page(request: Request): + token = secrets.token_urlsafe(32) + response = page( + "登录 MemOS", + '
' + + form_fields(token) + + ( + '