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 ts "
+ "AND json_extract(detail,'$.createTime')!='' "
+ "AND event IN ('addMessage','searchMemory','chat','extractMemory','rerank')",
+ (
+ cube,
+ round(midnight(start).timestamp() * 1_000_000),
+ round(midnight(end + timedelta(days=1)).timestamp() * 1_000_000),
+ ),
+ )
+ for row in rows:
+ if row["day"] not in days:
+ continue
+ req, res = int(row["req"] or 0), int(row["res"] or 0)
+ for bucket in (days[row["day"]][row["event"]], summary[row["event"]]):
+ bucket["totalTokens"] += req + res
+ if "calls" in bucket:
+ bucket["calls"] += 1
+ else:
+ bucket["requestTokens"] += req
+ bucket["responseTokens"] += res
+ summary["totalCalls"] = summary["addMessage"]["calls"] + summary["searchMemory"]["calls"]
+ summary["totalTokens"] = sum(summary[event]["totalTokens"] for event in events)
+ return {
+ "dailyStats": [{"date": day, **value} for day, value in days.items()]
+ if include_daily
+ else [],
+ "summary": summary,
+ "startDate": start.isoformat(),
+ "endDate": end.isoformat(),
+ "timezone": "Asia/Shanghai",
+ "source": "gateway",
+ }
+
+ def page(self, cube, query):
+ clauses, values = ["cube=?"], [cube]
+ for column, value in (
+ ("source", query.scope if query.scope != "all" else None),
+ ("event", query.eventType),
+ ("status", query.status),
+ ):
+ if value is not None:
+ clauses.append(column + "=?")
+ values.append(value)
+ if query.query:
+ clauses.append("instr(search_text,?)>0")
+ 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",
+ '
"
+ )
+ + '还没有账号?注册账号'
+ + (
+ '注册成功,请登录。
'
+ if request.query_params.get("registered") == "1"
+ else ""
+ ),
+ )
+ response.set_cookie(
+ "memos_login_csrf",
+ token,
+ httponly=True,
+ samesite="strict",
+ secure=request.url.scheme == "https",
+ max_age=600,
+ path="/__login",
+ )
+ next_path = request.query_params.get("next", "")
+ if re.fullmatch(r"/__login/invite/[A-Za-z0-9_-]{43}", next_path):
+ # The sole permitted continuation is a same-origin invitation page.
+ response.body = response.body.replace(
+ b"",
+ ('').encode(),
+ )
+ response.headers["content-length"] = str(len(response.body))
+ return response
+
+
+def registration_form(token, *, error=None, username=""):
+ content = (
+ ('' + escape(error) + "
" if error else "")
+ + ''
+ '已有账号?返回登录'
+ )
+ return page("注册 MemOS", content)
+
+
+@router.get("/__login/register", response_class=HTMLResponse)
+def register_page(request: Request):
+ token = secrets.token_urlsafe(32)
+ response = registration_form(token)
+ response.set_cookie(
+ "memos_login_csrf",
+ token,
+ httponly=True,
+ samesite="strict",
+ secure=request.url.scheme == "https",
+ max_age=600,
+ path="/__login",
+ )
+ return response
+
+
+@router.post("/__login/register")
+async def register_submit(request: Request):
+ body = await form(request)
+ check_csrf(request, body)
+ try:
+ if set(body) - {"csrf", "username", "password", "confirm_password"}:
+ raise ApiError(40000, "注册信息包含不支持的字段", status_code=400)
+ if body.get("password") != body.get("confirm_password"):
+ raise ApiError(40000, "两次输入的密码不一致", status_code=400)
+ await run_in_threadpool(
+ request.app.state.identity.create_account,
+ body.get("username", ""),
+ body.get("password", ""),
+ )
+ except ApiError as exc:
+ response = registration_form(
+ body["csrf"], error=exc.message, username=body.get("username", "")[:64]
+ )
+ response.status_code = exc.status_code
+ return response
+ response = RedirectResponse("/__login?registered=1", status_code=303)
+ response.delete_cookie("memos_login_csrf", path="/__login")
+ return response
+
+
+@router.post("/__login")
+async def login_submit(request: Request):
+ body = await form(request)
+ check_csrf(request, body)
+ token = await run_in_threadpool(
+ request.app.state.identity.login,
+ body.get("username", ""),
+ body.get("password", ""),
+ request.client.host if request.client else "local",
+ )
+ next_path = body.get("next", "")
+ response = RedirectResponse(
+ next_path
+ if re.fullmatch(r"/__login/invite/[A-Za-z0-9_-]{43}", next_path)
+ else "/cn/projects",
+ status_code=303,
+ )
+ response.set_cookie(
+ SESSION_COOKIE,
+ token,
+ httponly=True,
+ samesite="lax",
+ secure=request.url.scheme == "https",
+ max_age=request.app.state.settings.session_ttl_seconds,
+ path="/",
+ )
+ response.delete_cookie("memos_login_csrf", path="/__login")
+ return response
+
+
+@router.get("/__logout", response_class=HTMLResponse)
+def logout_page(request: Request):
+ actor = actor_for(request)
+ return page(
+ "退出登录",
+ '退出后,你需要重新登录才能访问控制台。
'
+ '返回控制台',
+ )
+
+
+@router.post("/__logout")
+async def logout_submit(request: Request):
+ actor = actor_for(request)
+ check_csrf(request, await form(request), actor["csrf"])
+ await run_in_threadpool(request.app.state.identity.logout, actor["session_key"])
+ response = RedirectResponse("/__login", status_code=303)
+ response.delete_cookie(SESSION_COOKIE, path="/")
+ return response
+
+
+@router.get("/api/auth/me", response_model=Envelope[dict])
+def me(request: Request, response: Response):
+ actor = actor_for(request)
+ store = control_for(request)
+ projects = store.dictionary(context=actor["session_key"])
+ response.headers["Cache-Control"] = "no-store"
+ return Envelope(
+ data={
+ "accountId": actor["id"],
+ "username": actor["username"],
+ "displayName": actor["display_name"],
+ "platformAdmin": actor["platform_admin"],
+ "currentProjectId": store.current(actor["session_key"]) or "",
+ "projects": [{k: p[k] for k in ("id", "name", "role", "kind")} for p in projects],
+ }
+ )
+
+
+class AccountUpdate(BaseModel):
+ model_config = ConfigDict(strict=True, extra="forbid")
+ display_name: str | None = Field(None, min_length=1, max_length=128)
+ disabled: bool | None = None
+ platform_admin: bool | None = None
+
+ @model_validator(mode="after")
+ def changes(self):
+ if not self.model_fields_set or any(
+ getattr(self, k) is None for k in self.model_fields_set
+ ):
+ raise ValueError("Expected explicit non-null fields")
+ if self.display_name is not None and (
+ not self.display_name.strip() or any(ord(c) < 32 for c in self.display_name)
+ ):
+ raise ValueError("Invalid name")
+ return self
+
+
+@router.get("/admin/accounts", response_model=Envelope[dict])
+def accounts(
+ request: Request,
+ response: Response,
+ query: str = Query("", max_length=128),
+ page: int = Query(1, ge=1, le=2147483647),
+ pageSize: int = Query(20, ge=1, le=200),
+):
+ actor_for(request, admin=True)
+ response.headers["Cache-Control"] = "no-store"
+ return Envelope(data=request.app.state.identity.account_list(query, page, pageSize))
+
+
+@router.post("/admin/accounts/{id}", response_model=Envelope[dict])
+def update_account(id: str, body: AccountUpdate, request: Request, response: Response):
+ actor_for(request, admin=True)
+ response.headers["Cache-Control"] = "no-store"
+ return Envelope(data=request.app.state.identity.update_account(id, body))
+
+
+@router.delete("/admin/accounts/{id}", response_model=Envelope[dict])
+def delete_account(id: str, request: Request, response: Response):
+ actor_for(request, admin=True)
+ response.headers["Cache-Control"] = "no-store"
+ return Envelope(data=request.app.state.identity.delete_account(id))
diff --git a/server/backend/auth_ui.py b/server/backend/auth_ui.py
new file mode 100644
index 000000000..2287835b6
--- /dev/null
+++ b/server/backend/auth_ui.py
@@ -0,0 +1,134 @@
+"""Small, self-contained authentication screens matching the console palette."""
+
+import base64
+import hashlib
+from html import escape
+
+CSS = """
+:root{color-scheme:light;
+--accent:#5140df;
+--ink:#192c48;
+--muted:#738097;
+--line:#e3e7ef}
+*{box-sizing:border-box}body{margin:0;
+background:#f5f6fb;
+color:var(--ink);
+
+font-family:Inter,-apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei",sans-serif;
+
+font-size:14px;
+line-height:1.6;
+min-height:100vh;
+min-height:100svh;
+display:grid;
+place-items:center;
+padding:32px 20px}
+.shell{width:100%;
+max-width:420px}.brand{display:flex;
+justify-content:center;
+align-items:center;
+
+gap:10px;
+margin-bottom:28px;
+font-size:23px;
+font-weight:700;
+letter-spacing:-.5px}
+.brand-mark{width:34px;
+height:34px;
+border-radius:10px;
+background:var(--accent);
+color:white;
+
+display:grid;
+place-items:center;
+font-size:20px;
+font-weight:600}.brand small{font-size:12px;
+font-weight:400;
+
+letter-spacing:0;
+color:var(--muted);
+border-left:1px solid #d5dbe6;
+padding-left:12px;
+margin-left:3px}
+main{background:white;
+border:1px solid var(--line);
+border-radius:18px;
+padding:36px;
+
+box-shadow:0 8px 30px #25345b06}h1{margin:0 0 8px;
+font-size:24px;
+line-height:1.4;
+letter-spacing:-.4px}
+.intro{color:var(--muted);
+margin:0 0 28px}form{display:grid;
+gap:20px}label{display:grid;
+gap:8px;
+
+font-weight:500}input:not([type=hidden]){width:100%;
+border:1px solid #dce1ec;
+border-radius:8px;
+
+padding:11px 12px;
+font:inherit;
+color:var(--ink);
+background:#fff;
+min-height:44px;
+transition:border-color .15s}
+input::placeholder{color:#9aa4b6}input:hover{border-color:#b9c1d2}
+input:focus-visible,button:focus-visible,a:focus-visible{outline:3px solid #dcd7ff;
+outline-offset:3px}
+input:focus{border-color:var(--accent)}button{border:0;
+border-radius:8px;
+background:var(--accent);
+
+color:white;
+font:inherit;
+font-weight:600;
+min-height:44px;
+padding:11px 18px;
+cursor:pointer;
+width:100%}
+button:hover{background:#4432cd}a{color:var(--accent);
+text-decoration:none}a:hover{text-decoration:underline}
+.secondary{display:block;
+text-align:center;
+margin-top:18px}.footer{text-align:center;
+color:#8c96a8;
+
+font-size:12px;
+margin:22px 0 0}.note{margin:0 0 24px;
+padding:14px 16px;
+border:1px solid var(--line);
+
+border-radius:10px;
+background:#fafbfe;
+color:#647189}.note strong{color:var(--ink);
+font-weight:500}
+@media(max-width:480px){body{padding:24px 16px}main{padding:28px 24px}.brand{margin-bottom:22px}}
+"""
+
+STYLE_SOURCE = "'sha256-" + base64.b64encode(hashlib.sha256(CSS.encode()).digest()).decode() + "'"
+
+
+def render(title, content):
+ introduction = {
+ "登录 MemOS": "欢迎回来,登录后继续管理你的记忆与项目。",
+ "注册 MemOS": "创建账号,开始管理你的个人记忆空间。",
+ "退出登录": "确认退出当前账号?",
+ }.get(title, "管理你的 MemOS 工作空间。")
+ return (
+ ''
+ ''
+ + escape(title)
+ + "'
+ '
M'
+ "MemOS记忆控制台
"
+ + escape(title)
+ + '
'
+ + introduction
+ + "
"
+ + content
+ + ' '
+ )
diff --git a/server/backend/bootstrap.py b/server/backend/bootstrap.py
new file mode 100644
index 000000000..9ef86326a
--- /dev/null
+++ b/server/backend/bootstrap.py
@@ -0,0 +1,66 @@
+"""Local account provisioning. Passwords never appear in command-line arguments."""
+
+import argparse
+import getpass
+import json
+import os
+import secrets
+from pathlib import Path
+
+from .control import ControlStore
+from .identity_store import IdentityStore
+from .settings import Settings
+
+
+def main():
+ parser = argparse.ArgumentParser(
+ description="Initialize a local account; no public registration endpoint"
+ )
+ parser.add_argument("username")
+ parser.add_argument("--admin", action="store_true")
+ parser.add_argument(
+ "--bootstrap", action="store_true", help="Only succeeds with an empty account store"
+ )
+ parser.add_argument(
+ "--credential-file",
+ type=Path,
+ help="Generate a random password into a new private local file",
+ )
+ args = parser.parse_args()
+ config = Settings.from_env()
+ store = IdentityStore(ControlStore(config), config)
+ if args.credential_file:
+ # Exclusive create before provisioning: never overwrite a credential file.
+ descriptor = os.open(args.credential_file, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
+ try:
+ password = secrets.token_urlsafe(24)
+ account = store.create_account(
+ args.username,
+ password,
+ admin=args.admin or args.bootstrap,
+ bootstrap=args.bootstrap,
+ )
+ with os.fdopen(descriptor, "w", encoding="utf-8") as output:
+ descriptor = None
+ json.dump(
+ {"username": args.username, "password": password, "accountId": account["id"]},
+ output,
+ )
+ print("Account provisioned; credentials saved locally to", args.credential_file)
+ except BaseException:
+ if descriptor is not None:
+ os.close(descriptor)
+ args.credential_file.unlink(missing_ok=True)
+ raise
+ else:
+ store.create_account(
+ args.username,
+ getpass.getpass("New password (12+ characters): "),
+ admin=args.admin or args.bootstrap,
+ bootstrap=args.bootstrap,
+ )
+ print("Account provisioned")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/server/backend/compatibility.py b/server/backend/compatibility.py
new file mode 100644
index 000000000..58eb6fbef
--- /dev/null
+++ b/server/backend/compatibility.py
@@ -0,0 +1,47 @@
+"""Fixed responses permitted by Issue3 section 2(3), not real quota or billing.
+
+No upstream lookup, knowledge-base access or URL fetching takes place here.
+"""
+
+from fastapi import APIRouter, Response
+from pydantic import BaseModel, ConfigDict
+
+from .models import Envelope
+
+router = APIRouter()
+
+
+class SubscriptionRequest(BaseModel):
+ model_config = ConfigDict(strict=True)
+ cycleId: str = ""
+ includeAllCycles: bool = True
+
+
+class SkillRequest(BaseModel):
+ model_config = ConfigDict(strict=True)
+ oss_url: str
+
+
+@router.get("/api/user/quota/summary", response_model=Envelope[dict])
+def quota(response: Response, knowledgebaseId: str | None = None):
+ response.headers["Cache-Control"] = "no-store"
+ return Envelope(
+ data={
+ "memoryCount": 0,
+ "knowledgebaseCount": 0,
+ "knowledgebaseCapacity": 1000000,
+ "allowedKnowledgebaseCount": 10,
+ }
+ )
+
+
+@router.post("/api/user/subscription/details", response_model=Envelope[dict])
+def subscription(body: SubscriptionRequest, response: Response):
+ response.headers["Cache-Control"] = "no-store"
+ return Envelope(data={"cycles": []})
+
+
+@router.post("/api/dashboard/skill/skillMD", response_model=Envelope[str])
+def skill(body: SkillRequest, response: Response):
+ response.headers["Cache-Control"] = "no-store"
+ return Envelope(data="")
diff --git a/server/backend/control.py b/server/backend/control.py
new file mode 100644
index 000000000..867ee2191
--- /dev/null
+++ b/server/backend/control.py
@@ -0,0 +1,361 @@
+"""Persist explicit operator-configured project mappings; no implicit tenant access."""
+
+import copy
+import hashlib
+import sqlite3
+import time
+import unicodedata
+import uuid
+from contextlib import contextmanager
+from datetime import datetime, timezone
+
+from .errors import ApiError
+from .settings import Settings
+from .timefmt import display
+
+
+class ControlStore:
+ actor = None
+
+ def with_actor(self, actor):
+ scoped = copy.copy(self)
+ scoped.actor = actor
+ return scoped
+
+ def role(self, identifier, db=None):
+ if db is None:
+ with self.connection() as connection:
+ return self.role(identifier, connection)
+ if self.actor is None:
+ return "owner"
+ account = db.execute(
+ "SELECT platform_admin,disabled FROM accounts WHERE id=?", (self.actor["id"],)
+ ).fetchone()
+ if account is None or account["disabled"]:
+ return None
+ if account["platform_admin"]:
+ return "owner"
+ row = db.execute(
+ "SELECT role FROM memberships WHERE tenant_id=? AND account_id=?",
+ (identifier, self.actor["id"]),
+ ).fetchone()
+ return row[0] if row else None
+
+ def require_role(self, identifier, minimum="owner", db=None):
+ from .identity_store import forbidden
+
+ if {"viewer": 1, "editor": 2, "owner": 3}.get(self.role(identifier, db), 0) < {
+ "viewer": 1,
+ "editor": 2,
+ "owner": 3,
+ }[minimum]:
+ raise forbidden()
+
+ def __init__(self, settings: Settings):
+ settings.data_dir.mkdir(parents=True, exist_ok=True)
+ self.path = settings.data_dir / "control.db"
+ self.allowed = frozenset(pair[0] for pair in settings.projects_seed)
+ self.default_project = settings.default_project_id
+ with self.connection() as connection, connection:
+ connection.execute("PRAGMA journal_mode=WAL")
+ connection.execute("BEGIN IMMEDIATE")
+ connection.execute(
+ "CREATE TABLE IF NOT EXISTS projects ("
+ "id TEXT PRIMARY KEY, name TEXT NOT NULL, "
+ "upstream_cube_id TEXT NOT NULL UNIQUE, created_at TEXT NOT NULL)"
+ )
+ columns = {row[1] for row in connection.execute("PRAGMA table_info(projects)")}
+ for name, definition in {
+ "description": "TEXT NOT NULL DEFAULT ''",
+ "is_multi_view": "INTEGER NOT NULL DEFAULT 0",
+ "disabled": "INTEGER NOT NULL DEFAULT 0",
+ "archived": "INTEGER NOT NULL DEFAULT 0",
+ "archived_at": "TEXT",
+ "managed": "INTEGER NOT NULL DEFAULT 0",
+ "name_key": "TEXT",
+ }.items():
+ if name not in columns:
+ connection.execute(f"ALTER TABLE projects ADD COLUMN {name} {definition}")
+ for row in connection.execute(
+ "SELECT id,name FROM projects WHERE name_key IS NULL"
+ ).fetchall():
+ connection.execute(
+ "UPDATE projects SET name_key=? WHERE id=?", (self.name_key(row[1]), row[0])
+ )
+ connection.execute(
+ "CREATE UNIQUE INDEX IF NOT EXISTS project_name_unique ON projects(name_key)"
+ )
+ connection.execute(
+ "CREATE TABLE IF NOT EXISTS project_contexts (token_hash TEXT PRIMARY KEY,"
+ "selected_project TEXT REFERENCES projects(id),expires_at INTEGER NOT NULL)"
+ )
+ connection.execute(
+ "CREATE TABLE IF NOT EXISTS api_keys ("
+ "id TEXT PRIMARY KEY, project_id TEXT NOT NULL REFERENCES projects(id), "
+ "name TEXT NOT NULL, token TEXT NOT NULL UNIQUE, "
+ "token_hash TEXT NOT NULL UNIQUE, status TEXT NOT NULL, "
+ "create_time TEXT NOT NULL, created_by TEXT, revoke_time TEXT, revoked_by TEXT)"
+ )
+ connection.execute(
+ "CREATE INDEX IF NOT EXISTS api_keys_active "
+ "ON api_keys(project_id, status, create_time DESC, id DESC)"
+ )
+ for project, cube in settings.projects_seed:
+ existing = connection.execute(
+ "SELECT upstream_cube_id FROM projects WHERE id=?", (project,)
+ ).fetchone()
+ if existing and existing[0] != cube:
+ raise ValueError("Configured project mapping conflicts with stored mapping")
+ connection.execute(
+ "INSERT INTO projects (id,name,upstream_cube_id,created_at,name_key) "
+ "VALUES (?,?,?,?,?) ON CONFLICT(id) DO NOTHING",
+ (
+ project,
+ project,
+ cube,
+ datetime.now(timezone.utc).isoformat(),
+ self.name_key(project),
+ ),
+ )
+
+ @contextmanager
+ def connection(self):
+ connection = sqlite3.connect(self.path, timeout=5)
+ connection.row_factory = sqlite3.Row
+ connection.execute("PRAGMA foreign_keys=ON")
+ try:
+ with connection:
+ yield connection
+ finally:
+ connection.close()
+
+ def resolve_cube(self, project_id: str) -> str | None:
+ with self.connection() as connection:
+ row = connection.execute(
+ "SELECT upstream_cube_id FROM projects "
+ "WHERE id=? AND disabled=0 AND archived=0 AND " + self.visibility()[0],
+ (project_id, *self.visibility()[1]),
+ ).fetchone()
+ return row[0] if row else None
+
+ def dictionary(self, default_project: str | None = None, *, context=None) -> list[dict]:
+ with self.connection() as connection:
+ rows = connection.execute(
+ "SELECT * FROM projects WHERE disabled=0 AND archived=0 AND "
+ + self.visibility()[0]
+ + " ORDER BY id",
+ self.visibility()[1],
+ ).fetchall()
+ selected = (
+ self.current(context, connection)
+ if context is not None or default_project is None
+ else default_project
+ )
+ return [self.project(row, selected) for row in rows]
+
+ def authorized_cubes(self) -> list[str]:
+ """Only this process's allowlist, never every persisted project."""
+ with self.connection() as db:
+ return [
+ row[0]
+ for row in db.execute(
+ "SELECT upstream_cube_id FROM projects WHERE disabled=0 AND archived=0 AND "
+ + self.visibility()[0],
+ self.visibility()[1],
+ )
+ ]
+
+ @staticmethod
+ def name_key(value):
+ return unicodedata.normalize("NFKC", value.strip()).casefold()
+
+ def visibility(self):
+ ids = sorted(self.allowed)
+ where, values = (
+ ("(managed=1 OR id IN (" + ",".join("?" for _ in ids) + "))", ids)
+ if ids
+ else ("managed=1", [])
+ )
+ if self.actor is not None and not self.actor["platform_admin"]:
+ where += " AND id IN (SELECT tenant_id FROM memberships WHERE account_id=?)"
+ values.append(self.actor["id"])
+ return where, values
+
+ def _row(self, db, identifier):
+ row = db.execute(
+ "SELECT * FROM projects WHERE id=? AND " + self.visibility()[0],
+ (identifier, *self.visibility()[1]),
+ ).fetchone()
+ if row is None:
+ raise ApiError(40001, "项目不存在或未配置访问范围", status_code=404)
+ return row
+
+ def current(self, context=None, db=None):
+ if db is None:
+ with self.connection() as connection:
+ return self.current(context, connection)
+ selected = None
+ if context:
+ row = db.execute(
+ "SELECT selected_project FROM project_contexts WHERE token_hash=? AND expires_at>?",
+ (context, int(time.time())),
+ ).fetchone()
+ selected = row[0] if row else None
+ active = [
+ r[0]
+ for r in db.execute(
+ "SELECT id FROM projects WHERE disabled=0 AND archived=0 AND "
+ + self.visibility()[0]
+ + " ORDER BY id",
+ self.visibility()[1],
+ )
+ ]
+ return (
+ selected
+ if selected in active
+ else self.default_project
+ if self.default_project in active
+ else active[0]
+ if active
+ else None
+ )
+
+ def ensure_context(self, token):
+ digest = hashlib.sha256(token.encode()).hexdigest()
+ with self.connection() as db:
+ db.execute("BEGIN IMMEDIATE")
+ db.execute("DELETE FROM project_contexts WHERE expires_at<=?", (int(time.time()),))
+ db.execute(
+ "INSERT OR IGNORE INTO project_contexts VALUES (?,?,?)",
+ (digest, self.current(db=db), int(time.time()) + 30 * 86400),
+ )
+ return digest
+
+ def project(self, row, selected, db=None):
+ return {
+ "id": row["id"],
+ "name": row["name"],
+ "description": row["description"],
+ "createTime": display(row["created_at"]),
+ "isMultiView": row["is_multi_view"],
+ "status": "ENABLED"
+ if row["id"] == selected and not row["disabled"] and not row["archived"]
+ else "DISABLED",
+ "disabled": bool(row["disabled"]),
+ "archived": bool(row["archived"]),
+ "kind": row["kind"] if "kind" in row.keys() else "team",
+ "role": self.role(row["id"], db),
+ **({"archivedAt": display(row["archived_at"])} if row["archived_at"] else {}),
+ }
+
+ def page(self, query, context):
+ where, values = self.visibility()
+ with self.connection() as db:
+ db.execute("BEGIN")
+ total = db.execute("SELECT count(*) FROM projects WHERE " + where, values).fetchone()[0]
+ rows = db.execute(
+ "SELECT * FROM projects WHERE "
+ + where
+ + " ORDER BY created_at DESC,id DESC LIMIT ? OFFSET ?",
+ (*values, query.pageSize, (query.pageNum - 1) * query.pageSize),
+ ).fetchall()
+ selected = self.current(context, db)
+ return {"records": [self.project(row, selected) for row in rows], "total": total}
+
+ def create(self, body, context):
+ identifier = "proj_" + uuid.uuid4().hex
+ try:
+ with self.connection() as db:
+ db.execute("BEGIN IMMEDIATE")
+ db.execute(
+ "INSERT INTO projects "
+ "(id,name,upstream_cube_id,created_at,name_key,"
+ "description,is_multi_view,managed) "
+ "VALUES (?,?,?,?,?,?,?,1)",
+ (
+ identifier,
+ body.name,
+ identifier,
+ datetime.now(timezone.utc).isoformat(),
+ self.name_key(body.name),
+ body.description,
+ body.isMultiView,
+ ),
+ )
+ if self.actor is not None:
+ db.execute(
+ "INSERT INTO memberships VALUES (?,?,'owner',?)",
+ (identifier, self.actor["id"], datetime.now(timezone.utc).isoformat()),
+ )
+ return self.project(self._row(db, identifier), self.current(context, db), db)
+ except sqlite3.IntegrityError as exc:
+ raise ApiError(40900, "项目名称已存在", status_code=409) from exc
+
+ def update(self, body, context):
+ try:
+ with self.connection() as db:
+ db.execute("BEGIN IMMEDIATE")
+ row = self._row(db, body.id)
+ if (
+ body.model_fields_set - {"id", "status"}
+ or body.status == "DISABLED"
+ or row["disabled"]
+ ):
+ self.require_role(body.id, db=db)
+ if row["archived"]:
+ raise ApiError(40900, "请先恢复已归档项目", status_code=409)
+ for public, column in (
+ ("name", "name"),
+ ("description", "description"),
+ ("isMultiView", "is_multi_view"),
+ ):
+ if public in body.model_fields_set:
+ db.execute(
+ f"UPDATE projects SET {column}=? WHERE id=?",
+ (getattr(body, public), body.id),
+ )
+ if public == "name":
+ db.execute(
+ "UPDATE projects SET name_key=? WHERE id=?",
+ (self.name_key(body.name), body.id),
+ )
+ if body.status == "DISABLED":
+ db.execute("UPDATE projects SET disabled=1 WHERE id=?", (body.id,))
+ self._fallback_contexts(db, body.id)
+ elif body.status == "ENABLED":
+ if row["disabled"]:
+ db.execute("UPDATE projects SET disabled=0 WHERE id=?", (body.id,))
+ else:
+ db.execute(
+ "UPDATE project_contexts SET selected_project=? WHERE token_hash=?",
+ (body.id, context),
+ )
+ return self.project(self._row(db, body.id), self.current(context, db), db)
+ except sqlite3.IntegrityError as exc:
+ raise ApiError(40900, "项目名称已存在", status_code=409) from exc
+
+ def _fallback_contexts(self, db, identifier):
+ db.execute(
+ "UPDATE project_contexts SET selected_project=? WHERE selected_project=?",
+ (self.current(db=db), identifier),
+ )
+
+ def archive(self, identifier, context, *, restore=False):
+ with self.connection() as db:
+ db.execute("BEGIN IMMEDIATE")
+ self._row(db, identifier)
+ self.require_role(identifier, db=db)
+ row = self._row(db, identifier)
+ if "kind" in row.keys() and row["kind"] == "personal":
+ raise ApiError(40900, "个人空间随账号管理", status_code=409)
+ db.execute(
+ "UPDATE projects SET archived=?,archived_at=? WHERE id=?",
+ (
+ 0 if restore else 1,
+ None if restore else datetime.now(timezone.utc).isoformat(),
+ identifier,
+ ),
+ )
+ if not restore:
+ self._fallback_contexts(db, identifier)
+ return self.project(self._row(db, identifier), self.current(context, db), db)
diff --git a/server/backend/errors.py b/server/backend/errors.py
new file mode 100644
index 000000000..01ac41f1b
--- /dev/null
+++ b/server/backend/errors.py
@@ -0,0 +1,89 @@
+"""Keep every failure JSON-shaped without exposing arbitrary exception text."""
+
+import logging
+import sqlite3
+
+from fastapi import FastAPI, Request
+from fastapi.exceptions import RequestValidationError
+from fastapi.responses import JSONResponse
+from starlette.exceptions import HTTPException
+from starlette.types import ASGIApp, Message, Receive, Scope, Send
+
+logger = logging.getLogger(__name__)
+
+
+class ApiError(Exception):
+ def __init__(self, code: int, message: str, *, status_code: int):
+ super().__init__(message)
+ self.code = code
+ self.message = message
+ self.status_code = status_code
+
+
+def error_response(code: int, message: str, status: int, headers=None) -> JSONResponse:
+ return JSONResponse(
+ status_code=status,
+ content={"code": code, "data": None, "message": message},
+ headers={**(headers or {}), "Cache-Control": "no-store"},
+ )
+
+
+class ErrorBoundaryMiddleware:
+ """Catch before ServerErrorMiddleware can log raw exception messages."""
+
+ def __init__(self, app: ASGIApp):
+ self.app = app
+
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
+ if scope["type"] != "http":
+ await self.app(scope, receive, send)
+ return
+ started = False
+
+ async def tracked_send(message: Message) -> None:
+ nonlocal started
+ if message["type"] == "http.response.start":
+ started = True
+ await send(message)
+
+ try:
+ await self.app(scope, receive, tracked_send)
+ except Exception as exc:
+ logger.error("Unhandled backend error (%s)", type(exc).__name__)
+ scope["audit_code"] = 50000
+ if started:
+ # Headers already sent cannot be replaced with a JSON envelope.
+ raise
+ await error_response(50000, "服务内部异常,请稍后重试", 500)(scope, receive, send)
+
+
+def install_error_handlers(app: FastAPI) -> None:
+ async def storage_error(request: Request, exc: sqlite3.Error) -> JSONResponse:
+ return error_response(50300, "控制面存储暂不可用", 503)
+
+ async def domain_error(request: Request, exc: ApiError) -> JSONResponse:
+ request.scope["audit_code"] = exc.code
+ return error_response(exc.code, exc.message, exc.status_code)
+
+ async def validation_error(request: Request, exc: RequestValidationError) -> JSONResponse:
+ request.scope["audit_code"] = 40000
+ return error_response(40000, "请求参数不合法", 400)
+
+ async def http_error(request: Request, exc: HTTPException) -> JSONResponse:
+ code, message = {
+ 401: (40100, "请先登录"),
+ 403: (40003, "没有访问权限"),
+ 404: (40001, "接口或资源不存在"),
+ 405: (40000, "请求方法不支持"),
+ }.get(
+ exc.status_code,
+ (50000, "服务内部异常") if exc.status_code >= 500 else (40000, "请求失败"),
+ )
+ request.scope["audit_code"] = code
+ return error_response(code, message, exc.status_code, exc.headers)
+
+ app.add_exception_handler(ApiError, domain_error)
+ app.add_exception_handler(sqlite3.Error, storage_error)
+ app.add_exception_handler(RequestValidationError, validation_error)
+ app.add_exception_handler(HTTPException, http_error)
+ app.add_middleware(ErrorBoundaryMiddleware)
diff --git a/server/backend/identity_store.py b/server/backend/identity_store.py
new file mode 100644
index 000000000..5a7ada115
--- /dev/null
+++ b/server/backend/identity_store.py
@@ -0,0 +1,336 @@
+"""Password identities and durable sessions in the existing transactional control DB."""
+
+import hashlib
+import hmac
+import re
+import secrets
+import sqlite3
+import time
+import uuid
+from datetime import datetime, timezone
+
+from .errors import ApiError
+from .timefmt import display
+
+SESSION_COOKIE = "memos_session"
+
+
+def now():
+ return datetime.now(timezone.utc).isoformat()
+
+
+def digest(token):
+ return hashlib.sha256(token.encode()).hexdigest()
+
+
+def password_hash(password, salt=None):
+ if not isinstance(password, str) or not 12 <= len(password) <= 256:
+ raise ApiError(40000, "密码长度须为12至256字符", status_code=400)
+ salt = salt or secrets.token_hex(16)
+ value = hashlib.scrypt(password.encode(), salt=bytes.fromhex(salt), n=16384, r=8, p=1)
+ return salt + ":" + value.hex()
+
+
+def forbidden():
+ return ApiError(40003, "没有执行该操作的权限", status_code=403)
+
+
+def conflict(message):
+ return ApiError(40900, message, status_code=409)
+
+
+def missing():
+ return ApiError(40001, "资源不存在或不可访问", status_code=404)
+
+
+class IdentityStore:
+ def __init__(self, control, settings):
+ self.control, self.settings = control, settings
+ with control.connection() as db:
+ db.execute("BEGIN IMMEDIATE")
+ columns = {r[1] for r in db.execute("PRAGMA table_info(projects)")}
+ if "kind" not in columns:
+ db.execute("ALTER TABLE projects ADD COLUMN kind TEXT NOT NULL DEFAULT 'team'")
+ db.execute("ALTER TABLE projects ADD COLUMN owner_account_id TEXT")
+ db.execute(
+ (
+ "CREATE TABLE IF NOT EXISTS accounts (id TEXT PRIMARY "
+ "KEY, username TEXT NOT NULL UNIQUE, username_key TEXT "
+ "NOT NULL UNIQUE, display_name TEXT NOT NULL, "
+ "password_hash TEXT NOT NULL, platform_admin INTEGER "
+ "NOT NULL, disabled INTEGER NOT NULL DEFAULT 0, "
+ "created_at TEXT NOT NULL, last_login_at TEXT)"
+ )
+ )
+ db.execute(
+ (
+ "CREATE TABLE IF NOT EXISTS memberships (tenant_id TEXT "
+ "REFERENCES projects(id) ON DELETE CASCADE, account_id "
+ "TEXT REFERENCES accounts(id) ON DELETE CASCADE, role "
+ "TEXT NOT NULL CHECK(role IN "
+ "('owner','editor','viewer')), created_at TEXT NOT "
+ "NULL, PRIMARY KEY(tenant_id,account_id))"
+ )
+ )
+ db.execute(
+ (
+ "CREATE TABLE IF NOT EXISTS sessions (token_hash TEXT "
+ "PRIMARY KEY, account_id TEXT NOT NULL REFERENCES "
+ "accounts(id) ON DELETE CASCADE, expires_at INTEGER NOT "
+ "NULL, csrf TEXT NOT NULL)"
+ )
+ )
+ db.execute(
+ (
+ "CREATE TABLE IF NOT EXISTS login_attempts (key TEXT "
+ "PRIMARY KEY, failures INTEGER NOT NULL, blocked_until "
+ "INTEGER NOT NULL)"
+ )
+ )
+ self.dummy_hash = password_hash("dummy-password-not-a-login")
+
+ @staticmethod
+ def account(row):
+ return {
+ "id": row["id"],
+ "username": row["username"],
+ "display_name": row["display_name"],
+ "platform_admin": bool(row["platform_admin"]),
+ "disabled": bool(row["disabled"]),
+ "created_at": display(row["created_at"]),
+ **({"last_login_at": display(row["last_login_at"])} if row["last_login_at"] else {}),
+ }
+
+ def create_account(self, username, password, *, admin=False, bootstrap=False, db=None):
+ if not isinstance(username, str) or not re.fullmatch(r"[A-Za-z0-9_.-]{1,64}", username):
+ raise ApiError(40000, "用户名仅允许1至64位字母数字及._-", status_code=400)
+ encoded = password_hash(password)
+ if db is None:
+ try:
+ with self.control.connection() as connection:
+ connection.execute("BEGIN IMMEDIATE")
+ return self._create(connection, username, encoded, admin, bootstrap)
+ except sqlite3.IntegrityError as exc:
+ raise conflict("用户名已存在") from exc
+ return self._create(db, username, encoded, admin, bootstrap)
+
+ def _create(self, db, username, encoded, admin, bootstrap):
+ if bootstrap and db.execute("SELECT 1 FROM accounts LIMIT 1").fetchone():
+ raise conflict("管理员初始化只允许执行一次")
+ identifier, created = "acct_" + uuid.uuid4().hex, now()
+ db.execute(
+ "INSERT INTO accounts VALUES (?,?,?,?,?,?,0,?,NULL)",
+ (identifier, username, username.casefold(), username, encoded, int(admin), created),
+ )
+ # Personal control workspace; deleting an account never deletes upstream memory.
+ personal = "personal_" + identifier
+ name = "personal-" + identifier
+ db.execute(
+ (
+ "INSERT INTO projects (id,name,upstream_cube_id,created_"
+ "at,name_key,managed,kind,owner_account_id) VALUES "
+ "(?,?,?,?,?,1,'personal',?)"
+ ),
+ (personal, name, personal, created, name, identifier),
+ )
+ db.execute(
+ "INSERT INTO memberships VALUES (?,?,'owner',?)", (personal, identifier, created)
+ )
+ if bootstrap:
+ db.execute(
+ (
+ "INSERT OR IGNORE INTO memberships SELECT "
+ "id,?,'owner',? FROM projects WHERE kind='team'"
+ ),
+ (identifier, created),
+ )
+ return self.account(
+ db.execute("SELECT * FROM accounts WHERE id=?", (identifier,)).fetchone()
+ )
+
+ def login(self, username, password, address):
+ if (
+ not isinstance(username, str)
+ or len(username) > 64
+ or not isinstance(password, str)
+ or len(password) > 256
+ ):
+ raise ApiError(40100, "用户名或密码错误", status_code=401)
+ key, timestamp = digest(address), int(time.time())
+ with self.control.connection() as db:
+ limit = db.execute("SELECT * FROM login_attempts WHERE key=?", (key,)).fetchone()
+ if limit and limit["blocked_until"] > timestamp:
+ raise ApiError(42900, "登录失败过多,请稍后重试", status_code=429)
+ row = db.execute(
+ "SELECT * FROM accounts WHERE username_key=?", (username.casefold(),)
+ ).fetchone()
+ encoded = row["password_hash"] if row else self.dummy_hash
+ salt, expected = encoded.split(":")
+ actual = hashlib.scrypt(
+ password.encode(), salt=bytes.fromhex(salt), n=16384, r=8, p=1
+ ).hex()
+ valid = hmac.compare_digest(expected, actual) and row is not None and not row["disabled"]
+ with self.control.connection() as db:
+ db.execute("BEGIN IMMEDIATE")
+ if not valid:
+ db.execute(
+ (
+ "INSERT INTO login_attempts VALUES (?,1,0) ON "
+ "CONFLICT(key) DO UPDATE SET failures=failures+1"
+ ),
+ (key,),
+ )
+ db.execute(
+ "UPDATE login_attempts SET blocked_until=? WHERE key=? AND failures>=10",
+ (timestamp + 300, key),
+ )
+ else:
+ current = db.execute(
+ "SELECT * FROM accounts WHERE id=? AND disabled=0", (row["id"],)
+ ).fetchone()
+ if current is None:
+ raise ApiError(40100, "用户名或密码错误", status_code=401)
+ db.execute("DELETE FROM login_attempts WHERE key=?", (key,))
+ db.execute(
+ "DELETE FROM project_contexts WHERE token_hash IN "
+ "(SELECT token_hash FROM sessions WHERE expires_at<=?)",
+ (timestamp,),
+ )
+ db.execute("DELETE FROM sessions WHERE expires_at<=?", (timestamp,))
+ token, csrf = secrets.token_urlsafe(32), secrets.token_urlsafe(32)
+ expiry = timestamp + self.settings.session_ttl_seconds
+ db.execute(
+ "INSERT INTO sessions VALUES (?,?,?,?)",
+ (digest(token), row["id"], expiry, csrf),
+ )
+ scoped = self.control.with_actor(self.account(current))
+ db.execute(
+ "INSERT INTO project_contexts VALUES (?,?,?)",
+ (digest(token), scoped.current(db=db), expiry),
+ )
+ db.execute("UPDATE accounts SET last_login_at=? WHERE id=?", (now(), row["id"]))
+ return token
+ raise ApiError(40100, "用户名或密码错误", status_code=401)
+
+ def identity(self, token):
+ if not token or not re.fullmatch(r"[A-Za-z0-9_-]{43}", token):
+ return None
+ with self.control.connection() as db:
+ row = db.execute(
+ (
+ "SELECT a.*,s.token_hash,s.csrf FROM sessions s JOIN "
+ "accounts a ON a.id=s.account_id WHERE s.token_hash=? "
+ "AND s.expires_at>? AND a.disabled=0"
+ ),
+ (digest(token), int(time.time())),
+ ).fetchone()
+ return (
+ {**self.account(row), "session_key": row["token_hash"], "csrf": row["csrf"]}
+ if row
+ else None
+ )
+
+ def logout(self, key):
+ with self.control.connection() as db:
+ db.execute("DELETE FROM sessions WHERE token_hash=?", (key,))
+ db.execute("DELETE FROM project_contexts WHERE token_hash=?", (key,))
+
+ def account_list(self, query, page, size):
+ with self.control.connection() as db:
+ db.execute("BEGIN")
+ where = "instr(lower(username),lower(?))>0 OR instr(lower(display_name),lower(?))>0"
+ values = (query, query)
+ total = db.execute("SELECT count(*) FROM accounts WHERE " + where, values).fetchone()[0]
+ rows = db.execute(
+ "SELECT * FROM accounts WHERE "
+ + where
+ + " ORDER BY created_at,id LIMIT ? OFFSET ?",
+ (*values, size, (page - 1) * size),
+ ).fetchall()
+ return {"records": [self.account(r) for r in rows], "total": total}
+
+ @staticmethod
+ def effective_guards(db, account_id, *, lose_admin=False, lose_owner=False):
+ row = db.execute("SELECT * FROM accounts WHERE id=?", (account_id,)).fetchone()
+ if row is None:
+ raise missing()
+ if lose_admin and row["platform_admin"] and not row["disabled"]:
+ if (
+ db.execute(
+ "SELECT count(*) FROM accounts WHERE platform_admin=1 AND disabled=0 AND id<>?",
+ (account_id,),
+ ).fetchone()[0]
+ == 0
+ ):
+ raise conflict("不能移除最后一个有效平台管理员")
+ if lose_owner and not row["disabled"]:
+ for owned in db.execute(
+ (
+ "SELECT tenant_id FROM memberships JOIN projects ON "
+ "projects.id=tenant_id WHERE account_id=? AND "
+ "role='owner' AND kind='team'"
+ ),
+ (account_id,),
+ ):
+ if (
+ db.execute(
+ (
+ "SELECT count(*) FROM memberships m JOIN accounts a ON "
+ "a.id=m.account_id WHERE m.tenant_id=? AND role='owner' "
+ "AND a.disabled=0 AND a.id<>?"
+ ),
+ (owned[0], account_id),
+ ).fetchone()[0]
+ == 0
+ ):
+ raise conflict("该账号是项目最后一个有效 owner,请先转移所有权")
+ return row
+
+ def update_account(self, identifier, body):
+ changes = body.model_dump(exclude_unset=True)
+ with self.control.connection() as db:
+ db.execute("BEGIN IMMEDIATE")
+ self.effective_guards(
+ db,
+ identifier,
+ lose_admin=changes.get("disabled") is True
+ or changes.get("platform_admin") is False,
+ lose_owner=changes.get("disabled") is True,
+ )
+ for column, value in changes.items():
+ db.execute(f"UPDATE accounts SET {column}=? WHERE id=?", (value, identifier))
+ if changes.get("disabled") is True:
+ self.revoke_account_sessions(db, identifier)
+ return self.account(
+ db.execute("SELECT * FROM accounts WHERE id=?", (identifier,)).fetchone()
+ )
+
+ @staticmethod
+ def revoke_account_sessions(db, identifier):
+ db.execute(
+ (
+ "DELETE FROM project_contexts WHERE token_hash IN "
+ "(SELECT token_hash FROM sessions WHERE account_id=?)"
+ ),
+ (identifier,),
+ )
+ db.execute("DELETE FROM sessions WHERE account_id=?", (identifier,))
+
+ def delete_account(self, identifier):
+ with self.control.connection() as db:
+ db.execute("BEGIN IMMEDIATE")
+ self.effective_guards(db, identifier, lose_admin=True, lose_owner=True)
+ self.revoke_account_sessions(db, identifier)
+ db.execute(
+ (
+ "UPDATE project_contexts SET selected_project=NULL "
+ "WHERE selected_project IN (SELECT id FROM projects "
+ "WHERE owner_account_id=?)"
+ ),
+ (identifier,),
+ )
+ db.execute(
+ "DELETE FROM projects WHERE kind='personal' AND owner_account_id=?", (identifier,)
+ )
+ db.execute("DELETE FROM accounts WHERE id=?", (identifier,))
+ return {"ok": True}
diff --git a/server/backend/invites.py b/server/backend/invites.py
new file mode 100644
index 000000000..fa129eb33
--- /dev/null
+++ b/server/backend/invites.py
@@ -0,0 +1,311 @@
+"""Single-use invite capabilities: only token digests are persisted."""
+
+import re
+import secrets
+import sqlite3
+import time
+import uuid
+from html import escape
+
+from fastapi import APIRouter, Request, Response
+from fastapi.responses import RedirectResponse
+from pydantic import BaseModel, ConfigDict, field_validator
+from starlette.concurrency import run_in_threadpool
+
+from .auth import actor_for, check_csrf, control_for, form, form_fields, page
+from .errors import ApiError
+from .identity_store import SESSION_COOKIE, conflict, digest, missing, now
+from .members import Role, workspace
+from .models import Envelope
+from .timefmt import display, display_epoch
+
+router = APIRouter()
+
+
+class InviteRole(BaseModel):
+ model_config = ConfigDict(strict=True, extra="forbid")
+ role: Role
+
+
+class InviteCreate(InviteRole):
+ expiresIn: str = "168h"
+
+ @field_validator("expiresIn")
+ @classmethod
+ def valid_duration(cls, value):
+ if not re.fullmatch(r"[1-9][0-9]{0,6}[smh]", value) or duration(value) > 30 * 86400:
+ raise ValueError("Expiry must be positive and no greater than30days")
+ return value
+
+
+def duration(value):
+ return int(value[:-1]) * {"s": 1, "m": 60, "h": 3600}[value[-1]]
+
+
+def status(row):
+ return (
+ "revoked"
+ if row["revoked_at"]
+ else "used"
+ if row["used_at"]
+ else "expired"
+ if row["expires_at"] <= int(time.time())
+ else "active"
+ )
+
+
+def projection(row):
+ return {
+ "id": row["id"],
+ "tenantId": row["tenant_id"],
+ "role": row["role"],
+ "status": status(row),
+ "createdAt": display(row["created_at"]),
+ "expiresAt": display_epoch(row["expires_at"]),
+ **({"usedAt": display(row["used_at"])} if row["used_at"] else {}),
+ **({"usedByAccountId": row["used_by_account_id"]} if row["used_by_account_id"] else {}),
+ **({"revokedAt": display(row["revoked_at"])} if row["revoked_at"] else {}),
+ }
+
+
+class InviteStore:
+ def __init__(self, identity):
+ self.identity, self.control = identity, identity.control
+ with self.control.connection() as db:
+ db.execute(
+ (
+ "CREATE TABLE IF NOT EXISTS project_invites (id TEXT "
+ "PRIMARY KEY,tenant_id TEXT NOT NULL REFERENCES "
+ "projects(id) ON DELETE CASCADE,token_hash TEXT NOT "
+ "NULL UNIQUE,role TEXT NOT NULL CHECK(role IN "
+ "('viewer','editor','owner')),created_at TEXT NOT "
+ "NULL,expires_at INTEGER NOT NULL,created_by_account_id "
+ "TEXT REFERENCES accounts(id) ON DELETE SET "
+ "NULL,used_at TEXT,used_by_account_id TEXT REFERENCES "
+ "accounts(id) ON DELETE SET NULL,revoked_at TEXT)"
+ )
+ )
+
+ def valid(self, db, token):
+ if not re.fullmatch(r"[A-Za-z0-9_-]{43}", token):
+ raise missing()
+ row = db.execute(
+ "SELECT * FROM project_invites WHERE token_hash=?", (digest(token),)
+ ).fetchone()
+ if row is None:
+ raise missing()
+ state = status(row)
+ if state != "active":
+ raise ApiError(
+ 41000 if state == "expired" else 40900,
+ "邀请已过期或不可使用",
+ status_code=410 if state == "expired" else 409,
+ )
+ project = self.control._row(db, row["tenant_id"])
+ if project["archived"] or project["disabled"]:
+ raise conflict("项目已停用或归档")
+ return row, project
+
+ def accept(self, token, actor, username=None, password=None):
+ try:
+ with self.control.connection() as db:
+ db.execute("BEGIN IMMEDIATE")
+ invitation, project = self.valid(db, token)
+ if actor:
+ account = db.execute(
+ "SELECT * FROM accounts WHERE id=? AND disabled=0", (actor["id"],)
+ ).fetchone()
+ session = db.execute(
+ (
+ "SELECT 1 FROM sessions WHERE token_hash=? "
+ "AND account_id=? AND expires_at>?"
+ ),
+ (actor["session_key"], actor["id"], int(time.time())),
+ ).fetchone()
+ if account is None or session is None:
+ raise ApiError(40100, "请重新登录", status_code=401)
+ identifier = account["id"]
+ else:
+ created = self.identity.create_account(username, password, db=db)
+ identifier = created["id"]
+ if db.execute(
+ "SELECT 1 FROM memberships WHERE tenant_id=? AND account_id=?",
+ (project["id"], identifier),
+ ).fetchone():
+ raise conflict("账号已是该项目成员,邀请未消耗")
+ db.execute(
+ "INSERT INTO memberships VALUES (?,?,?,?)",
+ (project["id"], identifier, invitation["role"], now()),
+ )
+ db.execute(
+ "UPDATE project_invites SET used_at=?,used_by_account_id=? WHERE id=?",
+ (now(), identifier, invitation["id"]),
+ )
+ session_token = None
+ if actor:
+ db.execute(
+ "UPDATE project_contexts SET selected_project=? WHERE token_hash=?",
+ (project["id"], actor["session_key"]),
+ )
+ else:
+ session_token, csrf = secrets.token_urlsafe(32), secrets.token_urlsafe(32)
+ expiry = int(time.time()) + self.identity.settings.session_ttl_seconds
+ db.execute(
+ "INSERT INTO sessions VALUES (?,?,?,?)",
+ (digest(session_token), identifier, expiry, csrf),
+ )
+ db.execute(
+ "INSERT INTO project_contexts VALUES (?,?,?)",
+ (digest(session_token), project["id"], expiry),
+ )
+ db.execute(
+ "UPDATE accounts SET last_login_at=? WHERE id=?", (now(), identifier)
+ )
+ return session_token
+ except sqlite3.IntegrityError as exc:
+ raise conflict("用户名已存在或邀请状态冲突") from exc
+
+
+@router.get("/admin/tenants/{tenantId}/invites", response_model=Envelope[dict])
+def list_invites(tenantId: str, request: Request, response: Response):
+ actor_for(request)
+ store = control_for(request)
+ with store.connection() as db:
+ workspace(store, db, tenantId, manage=True)
+ rows = db.execute(
+ "SELECT * FROM project_invites WHERE tenant_id=? ORDER BY created_at DESC,id",
+ (tenantId,),
+ ).fetchall()
+ response.headers["Cache-Control"] = "no-store"
+ return Envelope(data={"records": [projection(r) for r in rows], "total": len(rows)})
+
+
+@router.post("/admin/tenants/{tenantId}/invites", response_model=Envelope[dict])
+def create_invite(tenantId: str, body: InviteCreate, request: Request, response: Response):
+ actor = actor_for(request)
+ store = control_for(request)
+ token, identifier, created = secrets.token_urlsafe(32), "inv_" + uuid.uuid4().hex, now()
+ expires = int(time.time()) + duration(body.expiresIn)
+ with store.connection() as db:
+ db.execute("BEGIN IMMEDIATE")
+ workspace(store, db, tenantId, manage=True, active=True)
+ db.execute(
+ "INSERT INTO project_invites VALUES (?,?,?,?,?,?,?,NULL,NULL,NULL)",
+ (identifier, tenantId, digest(token), body.role, created, expires, actor["id"]),
+ )
+ response.headers["Cache-Control"] = "no-store"
+ return Envelope(
+ data={
+ "id": identifier,
+ "tenantId": tenantId,
+ "role": body.role,
+ "path": "/__login/invite/" + token,
+ "createdAt": display(created),
+ "expiresAt": display_epoch(expires),
+ }
+ )
+
+
+def modify(tenant, identifier, request, role=None):
+ actor_for(request)
+ store = control_for(request)
+ with store.connection() as db:
+ db.execute("BEGIN IMMEDIATE")
+ workspace(store, db, tenant, manage=True, active=True)
+ row = db.execute(
+ "SELECT * FROM project_invites WHERE id=? AND tenant_id=?", (identifier, tenant)
+ ).fetchone()
+ if row is None:
+ raise missing()
+ if status(row) != "active":
+ if role is None and row["revoked_at"]:
+ return {"ok": True}
+ raise conflict("只能修改有效邀请")
+ if role:
+ db.execute("UPDATE project_invites SET role=? WHERE id=?", (role, identifier))
+ else:
+ db.execute("UPDATE project_invites SET revoked_at=? WHERE id=?", (now(), identifier))
+ return {"ok": True, **({"role": role} if role else {})}
+
+
+@router.post("/admin/tenants/{tenantId}/invites/{inviteId}/revoke", response_model=Envelope[dict])
+def revoke(tenantId: str, inviteId: str, request: Request, response: Response):
+ response.headers["Cache-Control"] = "no-store"
+ return Envelope(data=modify(tenantId, inviteId, request))
+
+
+@router.post("/admin/tenants/{tenantId}/invites/{inviteId}/role", response_model=Envelope[dict])
+def change_role(
+ tenantId: str, inviteId: str, body: InviteRole, request: Request, response: Response
+):
+ response.headers["Cache-Control"] = "no-store"
+ return Envelope(data=modify(tenantId, inviteId, request, body.role))
+
+
+@router.get("/__login/invite/{token}")
+def landing(token: str, request: Request):
+ with request.app.state.control.connection() as db:
+ invitation, project = request.app.state.invites.valid(db, token)
+ actor = request.scope.get("identity")
+ csrf = actor["csrf"] if actor else secrets.token_urlsafe(32)
+ content = (
+ "加入项目:"
+ + escape(project["name"])
+ + ",角色:"
+ + escape(invitation["role"])
+ + '
"
+ if not actor:
+ content += (
+ '已有账号?先登录再接受邀请
'
+ )
+ response = page("项目邀请", content)
+ if not actor:
+ response.set_cookie(
+ "memos_login_csrf",
+ csrf,
+ httponly=True,
+ samesite="strict",
+ secure=request.url.scheme == "https",
+ max_age=600,
+ path="/__login",
+ )
+ return response
+
+
+@router.post("/__login/invite/{token}")
+async def accept_invite(token: str, request: Request):
+ body = await form(request)
+ actor = request.scope.get("identity")
+ check_csrf(request, body, actor["csrf"] if actor else None)
+ session = await run_in_threadpool(
+ request.app.state.invites.accept, token, actor, body.get("username"), body.get("password")
+ )
+ response = RedirectResponse("/cn/projects", status_code=303)
+ if session:
+ response.set_cookie(
+ SESSION_COOKIE,
+ session,
+ httponly=True,
+ samesite="lax",
+ secure=request.url.scheme == "https",
+ max_age=request.app.state.settings.session_ttl_seconds,
+ path="/",
+ )
+ response.delete_cookie("memos_login_csrf", path="/__login")
+ return response
diff --git a/server/backend/kb_models.py b/server/backend/kb_models.py
new file mode 100644
index 000000000..0a96b5329
--- /dev/null
+++ b/server/backend/kb_models.py
@@ -0,0 +1,84 @@
+"""Strict dashboard knowledge requests, with explicit project selection support."""
+
+from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
+
+Identifier = str
+
+
+class Strict(BaseModel):
+ model_config = ConfigDict(extra="forbid", strict=True)
+
+
+class Scoped(Strict):
+ projectId: str | None = Field(None, min_length=1, max_length=128)
+
+
+class Page(Scoped):
+ pageNum: int = Field(1, ge=1, le=2147483647)
+ pageSize: int = Field(10, ge=1, le=200)
+
+
+class KBList(Page):
+ name: str = Field("", max_length=128)
+
+
+class KBCreate(Scoped):
+ knowledgebaseName: str = Field(min_length=1, max_length=128)
+ knowledgebaseDescription: str = Field("", max_length=4000)
+ projectIds: list[str] | None = Field(None, max_length=100)
+
+ @field_validator("knowledgebaseName")
+ @classmethod
+ def name(cls, value):
+ if not isinstance(value, str):
+ raise ValueError("Expected text")
+ value = value.strip()
+ if not value or any(ord(c) < 32 for c in value):
+ raise ValueError("Invalid name")
+ return value
+
+ @field_validator("projectIds")
+ @classmethod
+ def unique(cls, value):
+ if value is not None and (
+ len(value) != len(set(value)) or any(not x or len(x) > 128 for x in value)
+ ):
+ raise ValueError("Invalid project IDs")
+ return value
+
+
+class KBUpdate(KBCreate):
+ id: str = Field(min_length=1, max_length=128)
+ knowledgebaseName: str | None = Field(None, min_length=1, max_length=128)
+ knowledgebaseDescription: str | None = Field(None, max_length=4000)
+
+ @model_validator(mode="after")
+ def changes(self):
+ fields = self.model_fields_set - {"id", "projectId"}
+ if not fields or any(getattr(self, k) is None for k in fields):
+ raise ValueError("Expected non-null update")
+ return self
+
+
+class KBDetail(Scoped):
+ knowledgeBaseId: str = Field(min_length=1, max_length=128)
+
+
+class KBDelete(KBDetail):
+ projectId: str = Field(min_length=1, max_length=128)
+
+
+class Associate(Strict):
+ projectId: str = Field(min_length=1, max_length=128)
+ knowledgeBaseIds: list[str] = Field(max_length=100)
+
+ @field_validator("knowledgeBaseIds")
+ @classmethod
+ def ids(cls, value):
+ if len(set(value)) != len(value) or any(not x or len(x) > 128 for x in value):
+ raise ValueError("Invalid IDs")
+ return value
+
+
+class Remove(KBDelete):
+ deleteAll: bool = False
diff --git a/server/backend/kb_store.py b/server/backend/kb_store.py
new file mode 100644
index 000000000..35cd4708e
--- /dev/null
+++ b/server/backend/kb_store.py
@@ -0,0 +1,143 @@
+"""Only namespace ownership and links live here; KB entities and bytes stay upstream."""
+
+import asyncio
+from collections import defaultdict
+
+from .identity_store import conflict, missing
+
+
+class KnowledgeStore:
+ def __init__(self, control):
+ self.control = control
+ self.locks = defaultdict(asyncio.Lock)
+ self.mutation = asyncio.Lock()
+ with control.connection() as db:
+ db.execute(
+ "CREATE TABLE IF NOT EXISTS kb_registry ("
+ "id TEXT PRIMARY KEY,"
+ "home_project TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,"
+ "state TEXT NOT NULL DEFAULT 'active')"
+ )
+ db.execute(
+ "CREATE TABLE IF NOT EXISTS kb_links ("
+ "kb_id TEXT REFERENCES kb_registry(id) ON DELETE CASCADE,"
+ "project_id TEXT REFERENCES projects(id) ON DELETE CASCADE,"
+ "PRIMARY KEY(kb_id,project_id))"
+ )
+ db.execute(
+ "CREATE TABLE IF NOT EXISTS kb_files ("
+ "id TEXT PRIMARY KEY,"
+ "kb_id TEXT NOT NULL REFERENCES kb_registry(id) ON DELETE CASCADE)"
+ )
+ if "state" not in {r[1] for r in db.execute("PRAGMA table_info(kb_files)")}:
+ db.execute("ALTER TABLE kb_files ADD COLUMN state TEXT NOT NULL DEFAULT 'active'")
+
+ @staticmethod
+ def project(store, identifier, minimum="viewer", db=None):
+ if db is None:
+ with store.connection() as conn:
+ return KnowledgeStore.project(store, identifier, minimum, conn)
+ row = store._row(db, identifier)
+ if row["disabled"] or row["archived"]:
+ raise missing()
+ store.require_role(identifier, minimum, db)
+ return row["upstream_cube_id"]
+
+ def register(self, identifier, home):
+ with self.control.connection() as db:
+ db.execute("BEGIN IMMEDIATE")
+ old = db.execute(
+ "SELECT home_project FROM kb_registry WHERE id=?", (identifier,)
+ ).fetchone()
+ if old and old[0] != home:
+ raise conflict("知识库归属冲突")
+ if not old:
+ db.execute(
+ "INSERT INTO kb_registry(id,home_project) VALUES (?,?)", (identifier, home)
+ )
+ db.execute("INSERT INTO kb_links VALUES (?,?)", (identifier, home))
+
+ def access(self, store, identifier, *, project=None, write=None, pending=False):
+ with store.connection() as db:
+ row = db.execute("SELECT * FROM kb_registry WHERE id=?", (identifier,)).fetchone()
+ if row is None:
+ raise missing()
+ home = row["home_project"]
+ links = [
+ r[0]
+ for r in db.execute(
+ "SELECT project_id FROM kb_links WHERE kb_id=? ORDER BY project_id",
+ (identifier,),
+ )
+ ]
+ visible = {r["id"] for r in store.dictionary()}
+ accessible = sorted(set(links) & visible)
+ home_manage = home in visible and store.role(home, db) == "owner"
+ if project is not None:
+ self.project(store, project, db=db)
+ if project not in links and not (project == home and home_manage):
+ raise missing()
+ elif not accessible and not home_manage:
+ raise missing()
+ # Reading a shared object does not authorize unrelated home resources.
+ base = self.control._row(db, home)
+ if base["disabled"] or base["archived"]:
+ raise missing()
+ if write:
+ for target in set(links) | {home}:
+ store.require_role(target, write, db)
+ self.project(store, target, write, db)
+ if row["state"] != "active" and not pending:
+ raise conflict("知识库删除尚未确认,请重试删除")
+ return home, base["upstream_cube_id"], accessible
+
+ def links(self, store, identifier, projects):
+ with store.connection() as db:
+ db.execute("BEGIN IMMEDIATE")
+ row = db.execute(
+ "SELECT home_project FROM kb_registry WHERE id=? AND state='active'", (identifier,)
+ ).fetchone()
+ if row is None:
+ raise missing()
+ home = row[0]
+ old = {
+ r[0]
+ for r in db.execute("SELECT project_id FROM kb_links WHERE kb_id=?", (identifier,))
+ }
+ for target in old | set(projects) | {home}:
+ self.project(store, target, "owner", db)
+ kind = store._row(db, target)["kind"]
+ if target != home and (
+ kind == "personal" or store._row(db, home)["kind"] == "personal"
+ ):
+ raise conflict("个人空间知识库不跨项目共享")
+ db.execute("DELETE FROM kb_links WHERE kb_id=?", (identifier,))
+ db.executemany("INSERT INTO kb_links VALUES (?,?)", [(identifier, p) for p in projects])
+
+ def ids(self, store, project=None):
+ visible = {p["id"] for p in store.dictionary()}
+ if project:
+ self.project(store, project)
+ with store.connection() as db:
+ rows = db.execute(
+ "SELECT id,home_project FROM kb_registry WHERE state='active' ORDER BY id"
+ ).fetchall()
+ result = []
+ for row in rows:
+ links = {
+ r[0]
+ for r in db.execute(
+ "SELECT project_id FROM kb_links WHERE kb_id=?", (row["id"],)
+ )
+ }
+ if (
+ project in links
+ if project
+ else bool(visible & links)
+ or (
+ row["home_project"] in visible
+ and store.role(row["home_project"], db) == "owner"
+ )
+ ):
+ result.append(row["id"])
+ return result
diff --git a/server/backend/key_store.py b/server/backend/key_store.py
new file mode 100644
index 000000000..7a8c4c9f9
--- /dev/null
+++ b/server/backend/key_store.py
@@ -0,0 +1,222 @@
+"""接口密钥的仓库与网关清单写入(control.db 的 ``api_keys`` 表)。
+
+key 一旦创建即代表那个项目本身:交付给网关的身份就是 ``user_id=cube_id=``,
+网关调用落在项目自己的记忆空间,审计归因与既有租户口径一致(设计 §4/D1)。
+"""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+import secrets
+import sqlite3
+import tempfile
+import typing
+from contextlib import contextmanager
+from pathlib import Path
+
+from .identity_store import now
+
+_TOKEN_RETRIES = 3
+
+
+class ApiKeyStore:
+ """只做库操作;路由负责鉴权与网关配置文件同步(各司其职)。"""
+
+ def __init__(self, control):
+ self.control = control
+
+ def _self(self, db, method, *args):
+ if db is None:
+ with self.control.connection() as connection:
+ return method(connection, *args)
+ return None
+
+ @staticmethod
+ def _token() -> str:
+ return "sk-" + secrets.token_hex(24)
+
+ def insert(self, db, project_id: str, name: str, created_by: str | None) -> dict:
+ if db is None:
+ with self.control.connection() as connection, connection:
+ connection.execute("BEGIN IMMEDIATE")
+ return self.insert(connection, project_id, name, created_by)
+ for _ in range(_TOKEN_RETRIES):
+ row_id = "key_" + secrets.token_hex(16)
+ token = self._token()
+ token_hash = hashlib.sha256(token.encode()).hexdigest()
+ try:
+ db.execute(
+ "INSERT INTO api_keys (id, project_id, name, token, token_hash, "
+ "status, create_time, created_by) VALUES (?,?,?,?,?,'active',?,?)",
+ (row_id, project_id, name, token, token_hash, now(), created_by),
+ )
+ except sqlite3.IntegrityError as error:
+ if "api_keys" in str(error) and "UNIQUE" in str(error):
+ continue
+ raise
+ row = db.execute("SELECT * FROM api_keys WHERE id=?", (row_id,)).fetchone()
+ return dict(row)
+ raise RuntimeError("接口密钥 token 生成冲突重试耗尽")
+
+ def get(self, db, key_id: str) -> dict | None:
+ if db is None:
+ with self.control.connection() as connection:
+ return self.get(connection, key_id)
+ row = db.execute("SELECT * FROM api_keys WHERE id=?", (key_id,)).fetchone()
+ return dict(row) if row else None
+
+ def page(self, db, project_id: str, page_num: int, page_size: int) -> tuple[list[dict], int]:
+ if db is None:
+ with self.control.connection() as connection:
+ return self.page(connection, project_id, page_num, page_size)
+ total = db.execute(
+ "SELECT count(*) FROM api_keys WHERE project_id=? AND status='active'",
+ (project_id,),
+ ).fetchone()[0]
+ rows = db.execute(
+ "SELECT * FROM api_keys WHERE project_id=? AND status='active' "
+ "ORDER BY create_time DESC, id DESC LIMIT ? OFFSET ?",
+ (project_id, page_size, (page_num - 1) * page_size),
+ ).fetchall()
+ return [dict(row) for row in rows], total
+
+ def active_entries(self, db) -> list[tuple[str, str]]:
+ if db is None:
+ with self.control.connection() as connection:
+ return self.active_entries(connection)
+ rows = db.execute(
+ "SELECT token, project_id FROM api_keys WHERE status='active' "
+ "ORDER BY create_time DESC, id DESC"
+ ).fetchall()
+ return [(row["token"], row["project_id"]) for row in rows]
+
+ def rename(self, db, key_id: str, name: str) -> dict | None:
+ if db is None:
+ with self.control.connection() as connection, connection:
+ connection.execute("BEGIN IMMEDIATE")
+ return self.rename(connection, key_id, name)
+ db.execute("UPDATE api_keys SET name=? WHERE id=? AND status='active'", (name, key_id))
+ row = db.execute("SELECT * FROM api_keys WHERE id=?", (key_id,)).fetchone()
+ return dict(row) if row else None
+
+ def mark_revoked(self, db, key_id: str, revoked_by: str | None) -> dict | None:
+ if db is None:
+ with self.control.connection() as connection, connection:
+ connection.execute("BEGIN IMMEDIATE")
+ return self.mark_revoked(connection, key_id, revoked_by)
+ cursor = db.execute(
+ "UPDATE api_keys SET status='revoked', revoke_time=?, revoked_by=? "
+ "WHERE id=? AND status='active'",
+ (now(), revoked_by, key_id),
+ )
+ if cursor.rowcount == 0:
+ return None
+ row = db.execute("SELECT * FROM api_keys WHERE id=?", (key_id,)).fetchone()
+ return dict(row)
+
+
+@contextmanager
+def manifest_lock(path: Path):
+ """Cross-process publication lock; gateway readers use the same protocol."""
+ path = Path(path)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ with path.with_name(path.name + ".lock").open("a+b") as handle:
+ handle.seek(0, os.SEEK_END)
+ if not handle.tell():
+ handle.write(b"0")
+ handle.flush()
+ handle.seek(0)
+ if os.name == "nt":
+ import msvcrt
+
+ msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1)
+ else:
+ import fcntl
+
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
+ try:
+ yield
+ finally:
+ handle.seek(0)
+ if os.name == "nt":
+ msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
+ else:
+ fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
+
+
+def pending_path(path: Path) -> Path:
+ return path.with_name(path.name + ".pending")
+
+
+def write_tenants_conf(path: Path, entries: typing.Sequence[tuple[str, str]]) -> None:
+ """Atomic, flushed replacement; callers hold the publication lock."""
+ payload = {token: {"user_id": project, "cube_id": project} for token, project in entries}
+ path = Path(path)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ descriptor, filename = tempfile.mkstemp(prefix="tenants.conf.", suffix=".tmp", dir=path.parent)
+ try:
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
+ handle.write(json.dumps(payload, indent=2) + "\n")
+ handle.flush()
+ os.fsync(handle.fileno())
+ os.replace(filename, path)
+ finally:
+ Path(filename).unlink(missing_ok=True)
+
+
+def _restore_manifest(store, path, writer):
+ writer(path, store.active_entries(None))
+ pending_path(path).unlink(missing_ok=True)
+
+
+def mutate_manifest(store, path, change, *, writer=None):
+ """Publish only committed state; compensate failures before releasing the lock."""
+ from .errors import ApiError
+
+ writer = writer or write_tenants_conf
+ marker = pending_path(path)
+ with manifest_lock(path):
+ if marker.exists():
+ try:
+ _restore_manifest(store, path, writer)
+ except (OSError, ValueError, sqlite3.Error) as error:
+ raise ApiError(50300, "网关配置仍待恢复,拒绝继续发布", status_code=503) from error
+ touched = False
+ try:
+ with store.control.connection() as db:
+ db.execute("BEGIN IMMEDIATE")
+ result = change(db)
+ marker.write_text("pending database commit\n", encoding="utf-8")
+ touched = True
+ writer(path, store.active_entries(db))
+ # ControlStore.connection commits here while publication remains locked.
+ except Exception as mutation_error:
+ if touched:
+ try:
+ _restore_manifest(store, path, writer)
+ except (OSError, ValueError, sqlite3.Error) as repair_error:
+ cause = (
+ "数据库提交失败"
+ if isinstance(mutation_error, sqlite3.Error)
+ else "网关配置写入失败"
+ )
+ raise ApiError(
+ 50300, cause + ",且恢复失败;网关拒绝加载待确认配置", status_code=503
+ ) from repair_error
+ raise
+ try:
+ marker.unlink(missing_ok=True)
+ except OSError as error:
+ raise ApiError(
+ 50300, "密钥变更已提交,但配置仍待确认;请恢复后重启网关", status_code=503
+ ) from error
+ return result
+
+
+def sync_manifest(store: ApiKeyStore, path: Path) -> None:
+ """Startup repair uses the same publication lock and failure marker as writes."""
+ with manifest_lock(path):
+ pending_path(path).write_text("pending startup reconciliation\n", encoding="utf-8")
+ _restore_manifest(store, path, write_tenants_conf)
diff --git a/server/backend/keys.py b/server/backend/keys.py
new file mode 100644
index 000000000..9c7b056ca
--- /dev/null
+++ b/server/backend/keys.py
@@ -0,0 +1,162 @@
+"""接口密钥:真实持久化 + 网关配置文件合成下发(指定权限=项目 owner,设计 task14 §5)。
+
+成功即已写入(create/revoke 的响应成功 ⟺ 网关配置文件已更新);文件与库在同一
+事务链内落成,写失败随事务回滚并返回 503——绝不假成功(前端创建弹窗文案以此为准)。
+"""
+
+from fastapi import APIRouter, Request, Response
+from pydantic import BaseModel, Field
+
+from .auth import control_for
+from .errors import ApiError
+from .identity_store import missing
+from .kb_models import Strict
+from .key_store import ApiKeyStore, mutate_manifest, write_tenants_conf
+from .models import Envelope
+from .timefmt import display
+
+router = APIRouter(prefix="/api/dashboard/keys")
+
+NOT_CONFIGURED = ApiError(50300, "未配置网关交付路径,无法签发密钥", status_code=503)
+
+
+def write_failure(verb: str) -> ApiError:
+ return ApiError(50300, f"网关配置写入失败,密钥未{verb}", status_code=503)
+
+
+class ListKeys(Strict):
+ projectId: str = Field(min_length=1, max_length=128)
+ pageNum: int = Field(1, ge=1, le=10000)
+ pageSize: int = Field(10, ge=1, le=100)
+
+
+class CreateKey(Strict):
+ projectId: str = Field(min_length=1, max_length=128)
+ keyName: str = Field(min_length=1, max_length=64)
+
+
+class CopyKey(Strict):
+ apiKeyId: str = Field(min_length=1, max_length=64)
+
+
+class RenameKey(Strict):
+ keyName: str = Field(min_length=1, max_length=64)
+
+
+class KeyRecord(BaseModel):
+ id: str
+ keyName: str
+ apiKey: str
+ createTime: str
+
+
+class KeyRecords(BaseModel):
+ records: list[KeyRecord]
+ total: int
+
+
+def project_guard(store: ApiKeyStore, db, project_id: str) -> None:
+ """可用项目不存在/不可见 → 404;停用/归档 → 404;不足 owner → 403(kb_store.project 同款)。"""
+ row = store.control._row(db, project_id)
+ if row["disabled"] or row["archived"]:
+ raise missing()
+ store.control.require_role(project_id, "owner", db)
+
+
+def key_guard(store: ApiKeyStore, db, key_id: str) -> dict:
+ """未知/已撤销 → 404;其项目不可用 → 404;非该项目的 owner → 403。"""
+ row = store.get(db, key_id)
+ if row is None or row["status"] != "active":
+ raise missing()
+ project_guard(store, db, row["project_id"])
+ return row
+
+
+def manifest_path(request: Request):
+ return request.app.state.settings.gateway_keys_file
+
+
+def as_record(row: dict) -> KeyRecord:
+ return KeyRecord(
+ id=row["id"],
+ keyName=row["name"],
+ apiKey=row["token"],
+ createTime=display(row["create_time"]),
+ )
+
+
+def actor_id(request: Request) -> str | None:
+ actor = request.scope.get("identity")
+ return actor["id"] if actor else None
+
+
+@router.post("/list", response_model=Envelope[KeyRecords])
+def list_keys(body: ListKeys, request: Request, response: Response):
+ store = ApiKeyStore(control_for(request))
+ with store.control.connection() as db:
+ project_guard(store, db, body.projectId)
+ records, total = store.page(db, body.projectId, body.pageNum, body.pageSize)
+ response.headers["Cache-Control"] = "no-store"
+ return Envelope(data=KeyRecords(records=[as_record(row) for row in records], total=total))
+
+
+@router.post("/create", response_model=Envelope[KeyRecord])
+def create_key(body: CreateKey, request: Request, response: Response):
+ path = manifest_path(request)
+ store = ApiKeyStore(control_for(request))
+ if path is None:
+ raise NOT_CONFIGURED
+ try:
+
+ def change(db):
+ project_guard(store, db, body.projectId)
+ return store.insert(db, body.projectId, body.keyName, actor_id(request))
+
+ row = mutate_manifest(store, path, change, writer=write_tenants_conf)
+ except ApiError:
+ raise
+ except (OSError, ValueError) as error:
+ raise write_failure("创建") from error
+ response.headers["Cache-Control"] = "no-store"
+ return Envelope(data=as_record(row))
+
+
+@router.post("/revoke/{id}", response_model=Envelope[dict])
+def revoke_key(id: str, request: Request, response: Response):
+ path = manifest_path(request)
+ store = ApiKeyStore(control_for(request))
+ if path is None:
+ raise NOT_CONFIGURED
+ try:
+
+ def change(db):
+ key_guard(store, db, id)
+ return store.mark_revoked(db, id, actor_id(request))
+
+ mutate_manifest(store, path, change, writer=write_tenants_conf)
+ except ApiError:
+ raise
+ except (OSError, ValueError) as error:
+ raise write_failure("撤销") from error
+ response.headers["Cache-Control"] = "no-store"
+ return Envelope(data={"ok": True})
+
+
+@router.post("/copy", response_model=Envelope[str])
+def copy_key(body: CopyKey, request: Request, response: Response):
+ store = ApiKeyStore(control_for(request))
+ with store.control.connection() as db:
+ row = key_guard(store, db, body.apiKeyId)
+ response.headers["Cache-Control"] = "no-store"
+ return Envelope(data=row["token"])
+
+
+@router.post("/rename/{id}", response_model=Envelope[KeyRecord])
+def rename_key(body: RenameKey, id: str, request: Request, response: Response):
+ store = ApiKeyStore(control_for(request))
+ with store.control.connection() as db, db:
+ db.execute("BEGIN IMMEDIATE")
+ key_guard(store, db, id)
+ row = store.rename(db, id, body.keyName)
+ response.headers["Cache-Control"] = "no-store"
+ return Envelope(data=as_record(row))
diff --git a/server/backend/knowledge.py b/server/backend/knowledge.py
new file mode 100644
index 000000000..b2d560d66
--- /dev/null
+++ b/server/backend/knowledge.py
@@ -0,0 +1,295 @@
+"""Upstream-backed KB entities with local, permission-checked workspace sharing."""
+
+from fastapi import APIRouter, Depends, Request, Response
+
+from .auth import control_for
+from .errors import ApiError
+from .identity_store import missing
+from .kb_models import Associate, KBCreate, KBDelete, KBDetail, KBList, KBUpdate, Remove
+from .models import Envelope
+from .product_client import malformed
+from .project_context import selected_project
+from .timefmt import display
+
+router = APIRouter(prefix="/api/dashboard/knowledgebase")
+
+
+async def mutation(request: Request):
+ async with request.app.state.knowledge.mutation:
+ yield
+
+
+def kind(store, project):
+ with store.connection() as db:
+ return store._row(db, project)["kind"]
+
+
+def parts(request, response):
+ response.headers["Cache-Control"] = "no-store"
+ return control_for(request), request.app.state.knowledge, request.app.state.product
+
+
+def view(row, projects):
+ if (
+ not isinstance(row, dict)
+ or not isinstance(row.get("knowledgebase_id"), str)
+ or not isinstance(row.get("knowledgebase_name"), str)
+ ):
+ raise malformed()
+ if row.get("knowledgebase_description") is not None and not isinstance(
+ row["knowledgebase_description"], str
+ ):
+ raise malformed()
+ result = {
+ "kbId": row["knowledgebase_id"],
+ "knowledgebaseName": row["knowledgebase_name"],
+ "knowledgebaseDescription": row.get("knowledgebase_description") or "",
+ "projectIds": projects,
+ }
+ for source, target, convert in [
+ ("file_count", "fileCount", None),
+ ("total_size", "totalSize", None),
+ ("created_at", "createTime", display),
+ ("updated_at", "updateTime", display),
+ ]:
+ value = row.get(source)
+ if value is not None:
+ if source in {"file_count", "total_size"} and (type(value) is not int or value < 0):
+ raise malformed()
+ if source.endswith("_at") and not isinstance(value, str):
+ raise malformed()
+ result[target] = value if convert is None else convert(value)
+ return result
+
+
+async def detail(request, identifier, project=None):
+ store = control_for(request)
+ registry = request.app.state.knowledge
+ with store.connection() as db:
+ registered = db.execute("SELECT 1 FROM kb_registry WHERE id=?", (identifier,)).fetchone()
+ if not registered:
+ for workspace in store.dictionary():
+ await discover(request, workspace["id"])
+ _, cube, projects = registry.access(store, identifier, project=project)
+ row = await request.app.state.product.request(
+ "get_knowledgebase", {"mem_cube_id": cube, "knowledgebase_id": identifier}
+ )
+ result = view(row, projects)
+ if result["kbId"] != identifier:
+ raise malformed()
+ return result
+
+
+async def discover(request, project):
+ store = control_for(request)
+ registry = request.app.state.knowledge
+ cube = registry.project(store, project)
+ page = 1
+ seen = set()
+ while True:
+ data = await request.app.state.product.request(
+ "get_knowledgebase", {"mem_cube_id": cube, "page": page, "page_size": 200}
+ )
+ rows = data.get("knowledgebase_list") if isinstance(data, dict) else None
+ total = data.get("total") if isinstance(data, dict) else None
+ if not isinstance(rows, list) or type(total) is not int or total < 0 or len(rows) > 200:
+ raise malformed()
+ for row in rows:
+ item = view(row, [project])
+ identifier = item["kbId"]
+ if identifier in seen:
+ raise malformed()
+ seen.add(identifier)
+ registry.register(identifier, project)
+ if len(seen) >= total:
+ return
+ if not rows or page >= 50:
+ raise malformed()
+ page += 1
+
+
+@router.post("/list", response_model=Envelope[dict])
+async def listing(body: KBList, request: Request, response: Response):
+ store, registry, _ = parts(request, response)
+ projects = [body.projectId] if body.projectId else [p["id"] for p in store.dictionary()]
+ for project in projects:
+ await discover(request, project)
+ records = []
+ for identifier in registry.ids(store, body.projectId):
+ try:
+ item = await detail(request, identifier, body.projectId)
+ except ApiError as exc:
+ if exc.status_code == 404:
+ continue
+ raise
+ if body.name.casefold() in item["knowledgebaseName"].casefold():
+ records.append(item)
+ offset = (body.pageNum - 1) * body.pageSize
+ return Envelope(
+ data={"records": records[offset : offset + body.pageSize], "total": len(records)}
+ )
+
+
+@router.post("/create", response_model=Envelope[dict], dependencies=[Depends(mutation)])
+async def create(body: KBCreate, request: Request, response: Response):
+ store, registry, up = parts(request, response)
+ home = body.projectId or (body.projectIds or [selected_project(request)])[0]
+ if not home:
+ raise missing()
+ cube = registry.project(store, home, "editor")
+ targets = body.projectIds if body.projectIds is not None else [home]
+ # Validate all targets before creating anything upstream.
+ if set(targets) != {home}:
+ for target in set(targets) | {home}:
+ registry.project(store, target, "owner")
+ if target != home and (
+ kind(store, home) == "personal" or kind(store, target) == "personal"
+ ):
+ raise ApiError(40900, "个人空间知识库不跨项目共享", status_code=409)
+ row = await up.request(
+ "create_knowledgebase",
+ {
+ "mem_cube_id": cube,
+ "knowledgebase_name": body.knowledgebaseName,
+ "knowledgebase_description": body.knowledgebaseDescription,
+ },
+ )
+ item = view(row, [home])
+ identifier = item["kbId"]
+ registry.register(identifier, home)
+ if set(targets) != {home}:
+ registry.links(store, identifier, targets)
+ return Envelope(data=await detail(request, identifier))
+
+
+@router.post("/detail", response_model=Envelope[dict])
+async def read(body: KBDetail, request: Request, response: Response):
+ parts(request, response)
+ return Envelope(data=await detail(request, body.knowledgeBaseId, body.projectId))
+
+
+@router.post("/update", response_model=Envelope[dict], dependencies=[Depends(mutation)])
+async def update(body: KBUpdate, request: Request, response: Response):
+ store, registry, up = parts(request, response)
+ async with registry.locks[body.id]:
+ home, cube, _ = registry.access(store, body.id, project=body.projectId, write="editor")
+ if body.projectIds is not None:
+ # Permission preflight without mutating associations.
+ for target in set(body.projectIds) | {home}:
+ registry.project(store, target, "owner")
+ registry.access(store, body.id, write="owner")
+ payload = {"mem_cube_id": cube, "knowledgebase_id": body.id}
+ for name in ("knowledgebaseName", "knowledgebaseDescription"):
+ if name in body.model_fields_set:
+ payload[
+ {
+ "knowledgebaseName": "knowledgebase_name",
+ "knowledgebaseDescription": "knowledgebase_description",
+ }[name]
+ ] = getattr(body, name)
+ if len(payload) > 2:
+ row = await up.request("update_knowledgebase", payload)
+ if view(row, [])["kbId"] != body.id:
+ raise malformed()
+ if body.projectIds is not None:
+ registry.links(store, body.id, body.projectIds)
+ return Envelope(data=await detail(request, body.id))
+
+
+async def erase(body, request):
+ store = control_for(request)
+ registry = request.app.state.knowledge
+ async with registry.locks[body.knowledgeBaseId]:
+ _, cube, _ = registry.access(
+ store, body.knowledgeBaseId, project=body.projectId, write="owner", pending=True
+ )
+ with store.connection() as db:
+ db.execute(
+ "UPDATE kb_registry SET state='deleting' WHERE id=?", (body.knowledgeBaseId,)
+ )
+ try:
+ data = await request.app.state.product.request(
+ "delete_knowledgebase",
+ {"mem_cube_id": cube, "knowledgebase_id": body.knowledgeBaseId, "delete_all": True},
+ )
+ if not isinstance(data, dict) or data.get("success") is not True:
+ raise malformed()
+ except ApiError as exc:
+ if exc.status_code != 404:
+ raise
+ with store.connection() as db:
+ db.execute("DELETE FROM kb_registry WHERE id=?", (body.knowledgeBaseId,))
+ return {"ok": True, "id": body.knowledgeBaseId}
+
+
+@router.post("/delete", response_model=Envelope[dict], dependencies=[Depends(mutation)])
+async def delete(body: KBDelete, request: Request, response: Response):
+ parts(request, response)
+ return Envelope(data=await erase(body, request))
+
+
+@router.post("/project/associate", response_model=Envelope[dict], dependencies=[Depends(mutation)])
+async def associate(body: Associate, request: Request, response: Response):
+ store, registry, _ = parts(request, response)
+ registry.project(store, body.projectId, "owner")
+ # Whole replacement is one local transaction; every ID is authorized before changes.
+ old = set(registry.ids(store, body.projectId))
+ new = set(body.knowledgeBaseIds)
+ plans = []
+ for identifier in sorted(old | new):
+ home, _, _ = registry.access(store, identifier, write="owner")
+ await detail(request, identifier)
+ with store.connection() as db:
+ targets = {
+ r[0]
+ for r in db.execute("SELECT project_id FROM kb_links WHERE kb_id=?", (identifier,))
+ }
+ targets.discard(body.projectId)
+ if identifier in new:
+ targets.add(body.projectId)
+ for target in targets | {home}:
+ registry.project(store, target, "owner")
+ plans.append((identifier, targets))
+ with store.connection() as db:
+ db.execute("BEGIN IMMEDIATE")
+ # Validate original and replacement scopes again under the write transaction.
+ for identifier, targets in plans:
+ row = db.execute(
+ "SELECT home_project,state FROM kb_registry WHERE id=?", (identifier,)
+ ).fetchone()
+ if row is None or row["state"] != "active":
+ raise missing()
+ old_targets = {
+ r[0]
+ for r in db.execute("SELECT project_id FROM kb_links WHERE kb_id=?", (identifier,))
+ }
+ for target in old_targets | targets | {row["home_project"]}:
+ registry.project(store, target, "owner", db)
+ if target != row["home_project"] and (
+ store._row(db, target)["kind"] == "personal"
+ or store._row(db, row["home_project"])["kind"] == "personal"
+ ):
+ raise ApiError(40900, "个人空间知识库不跨项目共享", status_code=409)
+ db.execute("DELETE FROM kb_links WHERE kb_id=?", (identifier,))
+ db.executemany("INSERT INTO kb_links VALUES (?,?)", [(identifier, p) for p in targets])
+ return Envelope(data={"ok": True})
+
+
+@router.post("/project/remove", response_model=Envelope[dict], dependencies=[Depends(mutation)])
+async def remove(body: Remove, request: Request, response: Response):
+ store, registry, _ = parts(request, response)
+ if body.deleteAll:
+ await erase(body, request)
+ else:
+ async with registry.locks[body.knowledgeBaseId]:
+ registry.access(store, body.knowledgeBaseId, project=body.projectId, write="owner")
+ with store.connection() as db:
+ targets = [
+ r[0]
+ for r in db.execute(
+ "SELECT project_id FROM kb_links WHERE kb_id=? AND project_id<>?",
+ (body.knowledgeBaseId, body.projectId),
+ )
+ ]
+ registry.links(store, body.knowledgeBaseId, targets)
+ return Envelope(data={"ok": True})
diff --git a/server/backend/knowledge_files.py b/server/backend/knowledge_files.py
new file mode 100644
index 000000000..bdfb7736e
--- /dev/null
+++ b/server/backend/knowledge_files.py
@@ -0,0 +1,424 @@
+"""Document bytes stay upstream; uploads never instruct the upstream to fetch URLs."""
+
+import base64
+import binascii
+import re
+from pathlib import PurePosixPath
+from typing import Literal
+from urllib.parse import quote
+
+from fastapi import APIRouter, Depends, Query, Request, Response
+from pydantic import Field, field_validator, model_validator
+
+from .auth import control_for
+from .errors import ApiError
+from .identity_store import missing
+from .kb_models import KBDelete, Page, Strict
+from .knowledge import detail, mutation
+from .memory_models import time_boundary
+from .models import Envelope
+from .product_client import malformed
+from .timefmt import display
+
+MAX_FILE_BYTES = 8 * 1024 * 1024
+EXTENSIONS = {"pdf", "doc", "docx", "txt", "md", "json", "xml"}
+STATUSES = {
+ "stored": "available",
+ "failed": "processFailed",
+ "processing": "processing",
+ "pending": "waiting",
+}
+router = APIRouter(prefix="/api/dashboard/knowledgebase/file")
+
+
+def bad_file():
+ return ApiError(40000, "文件名、格式或内容无效", status_code=400)
+
+
+class FilePayload(Strict):
+ name: str = Field(min_length=1, max_length=255)
+ content: str = Field(max_length=12 * 1024 * 1024)
+ type: str = Field("", max_length=128)
+
+ @field_validator("name")
+ @classmethod
+ def safe_name(cls, value):
+ if (
+ value in {".", ".."}
+ or any(c in value for c in "/\\:")
+ or value != value.strip()
+ or value.endswith(".")
+ or any(ord(c) < 32 or ord(c) == 127 for c in value)
+ or PurePosixPath(value).suffix.lower().lstrip(".") not in EXTENSIONS
+ ):
+ raise ValueError("Invalid document name")
+ return value
+
+ @field_validator("type")
+ @classmethod
+ def document_type(cls, value):
+ if value == "skill" or any(ord(c) < 32 for c in value):
+ raise ValueError("Only ordinary document uploads are supported")
+ return value
+
+ def bytes(self):
+ if self.content.startswith("data:"):
+ match = re.fullmatch(
+ r"data:([A-Za-z0-9.+-]+/[A-Za-z0-9.+-]+)?;base64,([A-Za-z0-9+/=]*)", self.content
+ )
+ if not match:
+ raise bad_file()
+ try:
+ raw = base64.b64decode(match[2], validate=True)
+ except (ValueError, binascii.Error) as exc:
+ raise bad_file() from exc
+ else:
+ raw = self.content.encode("utf-8")
+ if len(raw) > MAX_FILE_BYTES:
+ raise ApiError(41300, "文件超过8MiB限制", status_code=413)
+ return raw
+
+
+class Upload(KBDelete):
+ file: FilePayload
+
+
+class FileList(Page):
+ knowledgeBaseId: str = Field(min_length=1, max_length=128)
+ fileName: str = Field("", max_length=255)
+ type: str = Field("", max_length=128)
+ status: (
+ Literal[
+ "waiting", "uploading", "processing", "available", "deleting", "failed", "processFailed"
+ ]
+ | None
+ ) = None
+ startTime: str | None = Field(None, max_length=64)
+ endTime: str | None = Field(None, max_length=64)
+
+ @field_validator("type")
+ @classmethod
+ def file_type(cls, value):
+ if value and value not in EXTENSIONS | {
+ "document",
+ "text/plain",
+ "text/markdown",
+ "application/pdf",
+ "application/json",
+ "application/xml",
+ "text/xml",
+ "application/msword",
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ }:
+ raise ValueError("Unsupported file type filter")
+ return value
+
+ @model_validator(mode="after")
+ def times(self):
+ try:
+ start, _ = time_boundary(self.startTime)
+ end, exclusive = time_boundary(self.endTime, end=True)
+ if start and end and (start > end or (exclusive and start == end)):
+ raise ValueError("Invalid time range")
+ except (OverflowError, ValueError) as exc:
+ raise ValueError("Invalid time boundary") from exc
+ return self
+
+
+class DeleteFiles(Strict):
+ projectId: str = Field(min_length=1, max_length=128)
+ fileIds: list[str] = Field(min_length=1, max_length=100)
+
+ @field_validator("fileIds")
+ @classmethod
+ def unique(cls, value):
+ if len(set(value)) != len(value) or any(not x or len(x) > 128 for x in value):
+ raise ValueError("Invalid IDs")
+ return value
+
+
+def file_view(row, kb):
+ if (
+ not isinstance(row, dict)
+ or not isinstance(row.get("id"), str)
+ or row.get("knowledgebase_id") != kb
+ or not isinstance(row.get("name"), str)
+ or type(row.get("size")) is not int
+ or row["size"] < 0
+ ):
+ raise malformed()
+ try:
+ FilePayload.safe_name(row["name"])
+ except ValueError as exc:
+ raise malformed() from exc
+ status = STATUSES.get(row.get("status"), row.get("status"))
+ if status not in {
+ "waiting",
+ "uploading",
+ "processing",
+ "available",
+ "deleting",
+ "failed",
+ "processFailed",
+ }:
+ raise malformed()
+ result = {
+ "id": row["id"],
+ "fileId": row["id"],
+ "knowledgebaseId": kb,
+ "name": row["name"],
+ "size": row["size"],
+ "sizeMB": round(row["size"] / (1024 * 1024), 6),
+ "type": PurePosixPath(row["name"]).suffix.lower().lstrip("."),
+ "status": status,
+ }
+ if row.get("created_at") is not None:
+ if not isinstance(row["created_at"], str):
+ raise malformed()
+ try:
+ time_boundary(row["created_at"])
+ except (ValueError, OverflowError) as exc:
+ raise malformed() from exc
+ result["createTime"] = display(row["created_at"])
+ return result
+
+
+def remember(request, records):
+ with request.app.state.control.connection() as db:
+ db.execute("BEGIN IMMEDIATE")
+ for item in records:
+ old = db.execute("SELECT kb_id FROM kb_files WHERE id=?", (item["id"],)).fetchone()
+ if old and old[0] != item["knowledgebaseId"]:
+ raise malformed()
+ db.execute(
+ "INSERT INTO kb_files(id,kb_id) VALUES (?,?) ON CONFLICT(id) DO NOTHING",
+ (item["id"], item["knowledgebaseId"]),
+ )
+
+
+async def source(request, cube, *, kb=None, ids=None):
+ rows = []
+ seen = set()
+ page = 1
+ while True:
+ body = {"mem_cube_id": cube, "page": page, "page_size": 200}
+ if kb:
+ body["knowledgebase_id"] = kb
+ if ids:
+ body["file_ids"] = ids
+ data = await request.app.state.product.request("get_knowledgebase_file", body)
+ current = data.get("file_detail_list") if isinstance(data, dict) else None
+ total = data.get("total") if isinstance(data, dict) else None
+ if (
+ not isinstance(current, list)
+ or type(total) is not int
+ or total < 0
+ or len(current) > 200
+ ):
+ raise malformed()
+ for row in current:
+ if (
+ not isinstance(row, dict)
+ or row.get("id") in seen
+ or not isinstance(row.get("id"), str)
+ ):
+ raise malformed()
+ if ids and row["id"] not in ids:
+ raise malformed()
+ seen.add(row["id"])
+ rows.append(row)
+ if len(rows) >= total:
+ return rows
+ if not current or page >= 50:
+ raise malformed()
+ page += 1
+
+
+async def file_meta(request, identifier, project, *, for_delete=False):
+ store = control_for(request)
+ registry = request.app.state.knowledge
+ registry.project(store, project)
+ with store.connection() as db:
+ cached = db.execute("SELECT * FROM kb_files WHERE id=?", (identifier,)).fetchone()
+ if cached:
+ kb = cached["kb_id"]
+ _, cube, _ = registry.access(
+ store, kb, project=project, write="editor" if for_delete else None
+ )
+ if cached["state"] == "deleting" and not for_delete:
+ raise ApiError(40900, "文件删除尚未确认,请重试删除", status_code=409)
+ if cached["state"] == "deleted":
+ if for_delete:
+ return None, cube, kb
+ raise missing()
+ rows = await source(request, cube, kb=kb, ids=[identifier])
+ else:
+ allowed = {}
+ for kb in registry.ids(store, project):
+ _, cube, _ = registry.access(store, kb, project=project)
+ allowed.setdefault(cube, set()).add(kb)
+ rows = []
+ for cube, kbs in allowed.items():
+ candidates = await source(request, cube, ids=[identifier])
+ rows = [r for r in candidates if r.get("knowledgebase_id") in kbs]
+ if rows:
+ break
+ if not rows:
+ raise missing()
+ kb = rows[0]["knowledgebase_id"]
+ registry.access(store, kb, project=project, write="editor" if for_delete else None)
+ if not rows and cached and for_delete:
+ with store.connection() as db:
+ db.execute("UPDATE kb_files SET state='deleted' WHERE id=?", (identifier,))
+ return None, cube, kb
+ if len(rows) != 1:
+ raise missing() if not rows else malformed()
+ item = file_view(rows[0], kb)
+ remember(request, [item])
+ return item, cube, kb
+
+
+@router.post("/add", response_model=Envelope[dict], dependencies=[Depends(mutation)])
+async def upload(body: Upload, request: Request, response: Response):
+ store = control_for(request)
+ registry = request.app.state.knowledge
+ _, cube, _ = registry.access(
+ store, body.knowledgeBaseId, project=body.projectId, write="editor"
+ )
+ await detail(request, body.knowledgeBaseId, body.projectId)
+ raw = body.file.bytes()
+ data = await request.app.state.product.request(
+ "add_knowledgebase_file",
+ {
+ "mem_cube_id": cube,
+ "knowledgebase_id": body.knowledgeBaseId,
+ "file": [
+ {
+ "name": body.file.name,
+ "base64": base64.b64encode(raw).decode(),
+ "type": "document",
+ }
+ ],
+ },
+ )
+ if not isinstance(data, list) or len(data) != 1:
+ raise malformed()
+ records = [file_view(row, body.knowledgeBaseId) for row in data]
+ if records[0]["size"] != len(raw) or records[0]["name"] != body.file.name:
+ raise malformed()
+ remember(request, records)
+ response.headers["Cache-Control"] = "no-store"
+ return Envelope(data={"records": records, "total": len(records)})
+
+
+@router.post("/list", response_model=Envelope[dict])
+async def listing(body: FileList, request: Request, response: Response):
+ _, cube, _ = request.app.state.knowledge.access(
+ control_for(request), body.knowledgeBaseId, project=body.projectId
+ )
+ await detail(request, body.knowledgeBaseId, body.projectId)
+ rows = await source(request, cube, kb=body.knowledgeBaseId)
+ records = [file_view(r, body.knowledgeBaseId) for r in rows]
+ remember(request, records)
+ start, _ = time_boundary(body.startTime)
+ end, exclusive = time_boundary(body.endTime, end=True)
+ if start and end and (start > end or (exclusive and start == end)):
+ raise ApiError(40000, "时间范围无效", status_code=400)
+ selected = []
+ for row, item in zip(rows, records, strict=True):
+ if body.fileName.casefold() not in item["name"].casefold():
+ continue
+ if body.status and body.status != item["status"]:
+ continue
+ if body.type and body.type != "document":
+ extension = {
+ "text/plain": "txt",
+ "text/markdown": "md",
+ "application/pdf": "pdf",
+ "application/json": "json",
+ "application/xml": "xml",
+ "text/xml": "xml",
+ "application/msword": "doc",
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
+ }.get(body.type, body.type)
+ if item["type"] != extension:
+ continue
+ if start or end:
+ # Compare source instants, never the timezone-less presentation string.
+ created, _ = time_boundary(row.get("created_at"))
+ if created is None:
+ continue
+ if start and created < start:
+ continue
+ if end and (created >= end if exclusive else created > end):
+ continue
+ selected.append(item)
+ offset = (body.pageNum - 1) * body.pageSize
+ response.headers["Cache-Control"] = "no-store"
+ return Envelope(
+ data={"records": selected[offset : offset + body.pageSize], "total": len(selected)}
+ )
+
+
+@router.get("/download")
+async def download(
+ request: Request,
+ projectId: str = Query(min_length=1, max_length=128),
+ fileId: str = Query(min_length=1, max_length=128),
+):
+ item, cube, _ = await file_meta(request, fileId, projectId)
+ if item["size"] > MAX_FILE_BYTES:
+ raise ApiError(41300, "下载文件超过8MiB限制", status_code=413)
+ raw = await request.app.state.product.raw(
+ "download_knowledgebase_file",
+ {"mem_cube_id": cube, "file_id": fileId},
+ limit=MAX_FILE_BYTES,
+ )
+ if len(raw) != item["size"]:
+ raise malformed()
+ filename = item["name"].replace('"', "")
+ fallback = filename if filename.isascii() else "download"
+ disposition = (
+ f"attachment; filename=\"{fallback}\"; filename*=UTF-8''{quote(filename, safe='')}"
+ )
+ return Response(
+ content=raw,
+ media_type="application/octet-stream",
+ headers={
+ "Content-Disposition": disposition,
+ "Cache-Control": "no-store",
+ "X-Content-Type-Options": "nosniff",
+ },
+ )
+
+
+@router.post("/delete", response_model=Envelope[dict], dependencies=[Depends(mutation)])
+async def delete(body: DeleteFiles, request: Request, response: Response):
+ # Validate every file and permission before deleting any byte.
+ groups = {}
+ for identifier in body.fileIds:
+ item, cube, _ = await file_meta(request, identifier, body.projectId, for_delete=True)
+ if item is not None:
+ groups.setdefault(cube, []).append(identifier)
+ deleted = 0
+ for cube, identifiers in groups.items():
+ with request.app.state.control.connection() as db:
+ db.executemany(
+ "UPDATE kb_files SET state='deleting' WHERE id=?", [(i,) for i in identifiers]
+ )
+ data = await request.app.state.product.request(
+ "delete_knowledgebase_file", {"mem_cube_id": cube, "file_ids": identifiers}
+ )
+ if (
+ not isinstance(data, dict)
+ or type(data.get("deleted")) is not int
+ or data["deleted"] != len(identifiers)
+ ):
+ raise malformed()
+ deleted += data["deleted"]
+ with request.app.state.control.connection() as db:
+ db.executemany(
+ "UPDATE kb_files SET state='deleted' WHERE id=?", [(i,) for i in identifiers]
+ )
+ response.headers["Cache-Control"] = "no-store"
+ return Envelope(data={"ok": True, "deleted": deleted})
diff --git a/server/backend/members.py b/server/backend/members.py
new file mode 100644
index 000000000..c0d6e4be2
--- /dev/null
+++ b/server/backend/members.py
@@ -0,0 +1,199 @@
+"""Workspace roles and transactional protection of the last effective owner."""
+
+from typing import Literal
+
+from fastapi import APIRouter, Query, Request, Response
+from pydantic import BaseModel, ConfigDict, Field, model_validator
+
+from .auth import actor_for, control_for
+from .identity_store import conflict, missing, now
+from .models import Envelope
+
+Role = Literal["viewer", "editor", "owner"]
+router = APIRouter(prefix="/admin/tenants/{tenantId}")
+
+
+class MemberSet(BaseModel):
+ model_config = ConfigDict(strict=True, extra="forbid")
+ account_id: str | None = Field(None, min_length=1, max_length=128)
+ username: str | None = Field(None, min_length=1, max_length=64)
+ role: Role
+
+ @model_validator(mode="after")
+ def target(self):
+ if any(getattr(self, key) is None for key in self.model_fields_set):
+ raise ValueError("Explicit null fields are not accepted")
+ if bool(self.account_id) == bool(self.username):
+ raise ValueError("Supply exactly one account_id or username")
+ return self
+
+
+def workspace(store, db, tenant, *, manage=False, active=False):
+ row = store._row(db, tenant)
+ store.require_role(tenant, "owner" if manage else "viewer", db)
+ if row["kind"] != "team":
+ raise conflict("个人空间不支持团队成员或邀请操作")
+ if active and (row["disabled"] or row["archived"]):
+ raise conflict("项目已停用或归档")
+ return row
+
+
+def protect_owner(db, tenant, identifier):
+ row = db.execute(
+ (
+ "SELECT m.role,a.disabled FROM memberships m JOIN "
+ "accounts a ON a.id=m.account_id WHERE tenant_id=? AND "
+ "account_id=?"
+ ),
+ (tenant, identifier),
+ ).fetchone()
+ if row and row["role"] == "owner" and not row["disabled"]:
+ count = db.execute(
+ (
+ "SELECT count(*) FROM memberships m JOIN accounts a ON "
+ "a.id=m.account_id WHERE tenant_id=? AND role='owner' "
+ "AND a.disabled=0 AND account_id<>?"
+ ),
+ (tenant, identifier),
+ ).fetchone()[0]
+ if not count:
+ raise conflict("不能移除或降级最后一个有效 owner")
+
+
+@router.get("/members", response_model=Envelope[dict])
+def members(tenantId: str, request: Request, response: Response):
+ actor_for(request)
+ store = control_for(request)
+ with store.connection() as db:
+ workspace(store, db, tenantId)
+ rows = db.execute(
+ (
+ "SELECT m.*,a.username,a.display_name,a.disabled FROM "
+ "memberships m JOIN accounts a ON a.id=m.account_id "
+ "WHERE tenant_id=? ORDER BY m.created_at,m.account_id"
+ ),
+ (tenantId,),
+ ).fetchall()
+ response.headers["Cache-Control"] = "no-store"
+ records = [
+ {
+ "account_id": r["account_id"],
+ "username": r["username"],
+ "display_name": r["display_name"],
+ "disabled": bool(r["disabled"]),
+ "role": r["role"],
+ "created_at": r["created_at"],
+ }
+ for r in rows
+ ]
+ return Envelope(data={"records": records, "total": len(records)})
+
+
+@router.get("/member-candidates", response_model=Envelope[dict])
+def candidates(
+ tenantId: str, request: Request, response: Response, query: str = Query("", max_length=128)
+):
+ actor_for(request)
+ store = control_for(request)
+ with store.connection() as db:
+ workspace(store, db, tenantId, manage=True)
+ needle = query.strip().removeprefix("@")
+ where = "instr(lower(a.username),lower(?))>0 OR instr(lower(a.display_name),lower(?))>0"
+ values = (needle, needle)
+ total = db.execute("SELECT count(*) FROM accounts a WHERE " + where, values).fetchone()[0]
+ rows = db.execute(
+ (
+ "SELECT a.*,EXISTS(SELECT 1 FROM memberships m WHERE "
+ "m.account_id=a.id AND m.tenant_id=?) AS member FROM "
+ "accounts a WHERE "
+ )
+ + where
+ + " ORDER BY a.username LIMIT 100",
+ (tenantId, *values),
+ ).fetchall()
+ response.headers["Cache-Control"] = "no-store"
+ return Envelope(
+ data={
+ "records": [
+ {
+ "accountId": r["id"],
+ "username": r["username"],
+ "displayName": r["display_name"],
+ "disabled": bool(r["disabled"]),
+ "member": bool(r["member"]),
+ }
+ for r in rows
+ ],
+ "total": total,
+ }
+ )
+
+
+@router.post("/members", response_model=Envelope[dict])
+def set_member(tenantId: str, body: MemberSet, request: Request, response: Response):
+ actor_for(request)
+ store = control_for(request)
+ with store.connection() as db:
+ db.execute("BEGIN IMMEDIATE")
+ workspace(store, db, tenantId, manage=True, active=True)
+ account = db.execute(
+ "SELECT * FROM accounts WHERE " + ("id=?" if body.account_id else "username_key=?"),
+ (body.account_id or body.username.casefold(),),
+ ).fetchone()
+ if account is None:
+ raise missing()
+ if account["disabled"]:
+ raise conflict("不能添加或修改已禁用账号")
+ if body.role != "owner":
+ protect_owner(db, tenantId, account["id"])
+ db.execute(
+ (
+ "INSERT INTO memberships VALUES (?,?,?,?) ON "
+ "CONFLICT(tenant_id,account_id) DO UPDATE SET "
+ "role=excluded.role"
+ ),
+ (tenantId, account["id"], body.role, now()),
+ )
+ response.headers["Cache-Control"] = "no-store"
+ return Envelope(data={"account_id": account["id"], "tenant_id": tenantId, "role": body.role})
+
+
+def remove(store, tenant, identifier, *, leave=False):
+ with store.connection() as db:
+ db.execute("BEGIN IMMEDIATE")
+ workspace(store, db, tenant, manage=not leave)
+ if (
+ db.execute(
+ "SELECT 1 FROM memberships WHERE tenant_id=? AND account_id=?", (tenant, identifier)
+ ).fetchone()
+ is None
+ ):
+ raise missing()
+ protect_owner(db, tenant, identifier)
+ db.execute(
+ "DELETE FROM memberships WHERE tenant_id=? AND account_id=?", (tenant, identifier)
+ )
+ # The next request resolves a per-account fallback; never pick one globally.
+ db.execute(
+ (
+ "UPDATE project_contexts SET selected_project=NULL "
+ "WHERE selected_project=? AND token_hash IN (SELECT "
+ "token_hash FROM sessions WHERE account_id=?)"
+ ),
+ (tenant, identifier),
+ )
+ return {"ok": True}
+
+
+@router.post("/members/leave", response_model=Envelope[dict])
+def leave(tenantId: str, request: Request, response: Response):
+ actor = actor_for(request)
+ response.headers["Cache-Control"] = "no-store"
+ return Envelope(data=remove(control_for(request), tenantId, actor["id"], leave=True))
+
+
+@router.post("/members/{accountId}/remove", response_model=Envelope[dict])
+def remove_member(tenantId: str, accountId: str, request: Request, response: Response):
+ actor_for(request)
+ response.headers["Cache-Control"] = "no-store"
+ return Envelope(data=remove(control_for(request), tenantId, accountId))
diff --git a/server/backend/memories.py b/server/backend/memories.py
new file mode 100644
index 000000000..acb106654
--- /dev/null
+++ b/server/backend/memories.py
@@ -0,0 +1,129 @@
+"""Six read-only views: explicit tenancy, native pagination, whitelist projection."""
+
+from datetime import datetime
+
+from fastapi import APIRouter, Request, Response
+
+from .auth import control_for
+from .errors import ApiError
+from .memory_models import TAB_TYPES, MemoryDetailRequest, MemoryItem, MemoryListRequest, MemoryPage
+from .memory_upstream import bad_upstream
+from .models import Envelope
+from .project_context import selected_project
+from .timefmt import display
+
+
+def project_node(node: dict, cube: str, tab: str, *, detail: bool = False) -> MemoryItem:
+ profile = tab == "profile_mem"
+ metadata = node if profile else node.get("metadata")
+ if not isinstance(metadata, dict):
+ raise bad_upstream()
+ if metadata.get("user_name") not in (None, cube):
+ raise bad_upstream()
+ kind = metadata.get("memory_type")
+ if kind not in TAB_TYPES[tab] or metadata.get("status") != "activated":
+ raise bad_upstream()
+ identifier, text, created = node.get("id"), node.get("memory"), metadata.get("created_at")
+ if (
+ not isinstance(identifier, str)
+ or not identifier
+ or not isinstance(text, str)
+ or not isinstance(created, str)
+ ):
+ raise bad_upstream()
+ try:
+ timestamp = datetime.fromisoformat(created.replace("Z", "+00:00"))
+ if timestamp.utcoffset() is None:
+ raise ValueError("Missing timezone")
+ except ValueError as exc:
+ raise bad_upstream() from exc
+ values = {
+ "memoryId": identifier,
+ "memoryContent": text if detail or len(text) <= 500 else text[:500] + "…",
+ "createTime": display(timestamp.isoformat()),
+ "memoryType": kind,
+ }
+ owner = metadata.get("subject_type" if profile else "owner_type")
+ if owner is not None and not isinstance(owner, str):
+ raise bad_upstream()
+ subject_id = metadata.get("subject_id") if profile else None
+ if owner is None and not profile:
+ if metadata.get("user_id"):
+ owner, subject_id = "user", metadata["user_id"]
+ elif metadata.get("agent_id"):
+ owner, subject_id = "agent", metadata["agent_id"]
+ if owner in {"user", "agent", "public"}:
+ values["subjectType"] = owner
+ if not profile:
+ subject_id = (
+ ""
+ if owner == "public"
+ else metadata.get("owner_id")
+ or metadata.get("user_id" if owner == "user" else "agent_id")
+ )
+ if isinstance(subject_id, str):
+ values["externalUserId"] = subject_id
+ if tab == "skill_mem":
+ for out, source in [("skillName", "name"), ("skillUrl", "url")]:
+ if isinstance(metadata.get(source), str):
+ values[out] = metadata[source]
+ if profile:
+ for out, source in [
+ ("profileTemplateId", "template_id"),
+ ("profileInstanceId", "profile_instance_id"),
+ ]:
+ if isinstance(metadata.get(source), str) and metadata[source]:
+ values[out] = metadata[source]
+ return MemoryItem(**values)
+
+
+router = APIRouter()
+
+
+@router.post(
+ "/api/dashboard/project/memory/list",
+ response_model=Envelope[MemoryPage],
+ response_model_exclude_none=True,
+)
+async def list_memories(body: MemoryListRequest, 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)
+ request.scope["audit_cube"] = cube
+ rows, total = await request.app.state.memories.page(cube, body)
+ result = MemoryPage(
+ memListItemResponseList=[project_node(row, cube, body.memoryDetailType) for row in rows],
+ total=total,
+ current=body.pageNum,
+ )
+ if len(result.model_dump_json().encode("utf-8")) > 200 * 1024:
+ raise bad_upstream()
+ response.headers["Cache-Control"] = "no-store"
+ request.scope["audit_code"] = 0
+ return Envelope(data=result)
+
+
+@router.post(
+ "/api/dashboard/project/memory/detail",
+ response_model=Envelope[MemoryItem],
+ response_model_exclude_none=True,
+)
+async def memory_detail(body: MemoryDetailRequest, 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)
+ request.scope["audit_cube"] = cube
+ row = await request.app.state.memories.detail(cube, body.memoryId, body.memType)
+ metadata = (row if body.memType == "profile_mem" else row.get("metadata", {})) if row else {}
+ if not isinstance(metadata, dict):
+ raise bad_upstream()
+ if row is None or metadata.get("status") != "activated":
+ raise ApiError(40001, "记忆不存在", status_code=404)
+ if metadata.get("memory_type") not in TAB_TYPES[body.memType]:
+ raise ApiError(40000, "记忆类型与标签不匹配", status_code=400)
+ result = project_node(row, cube, body.memType, detail=True)
+ response.headers["Cache-Control"] = "no-store"
+ request.scope["audit_code"] = 0
+ return Envelope(data=result)
diff --git a/server/backend/memory_lifecycle.py b/server/backend/memory_lifecycle.py
new file mode 100644
index 000000000..5f4cfeee5
--- /dev/null
+++ b/server/backend/memory_lifecycle.py
@@ -0,0 +1,246 @@
+"""Validated batch soft deletion, recovery and durable operation receipts."""
+
+import hashlib
+import json
+import uuid
+
+from fastapi import APIRouter, Query, Request, Response
+from fastapi.responses import JSONResponse
+from pydantic import BaseModel, ConfigDict, Field, field_validator
+
+from .auth import control_for
+from .errors import ApiError
+from .memory_models import TAB_TYPES, MemoryTab
+from .memory_upstream import bad_upstream
+from .operation_store import OperationStore
+from .security import check_origin
+
+router = APIRouter(prefix="/api/dashboard/project/memory")
+
+
+class BatchRequest(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+ projectId: str = Field(min_length=1, max_length=128)
+ memType: MemoryTab
+ memory_ids: list[str] = Field(min_length=1, max_length=100)
+ operationId: str = Field(
+ default_factory=lambda: uuid.uuid4().hex,
+ min_length=1,
+ max_length=128,
+ pattern=r"^[A-Za-z0-9_.-]+$",
+ )
+
+ @field_validator("memory_ids")
+ @classmethod
+ def validate_ids(cls, values):
+ if len(set(values)) != len(values) or any(
+ not value or len(value) > 1024 or any(ord(c) < 32 or ord(c) == 127 for c in value)
+ for value in values
+ ):
+ raise ValueError("Invalid or duplicate memory IDs")
+ return values
+
+
+class OperationRequest(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+ projectId: str = Field(min_length=1, max_length=128)
+ operationId: str = Field(min_length=1, max_length=128, pattern=r"^[A-Za-z0-9_.-]+$")
+
+
+def dependencies(request, project):
+ control = control_for(request)
+ cube = control.resolve_cube(project) if control else None
+ if cube is None:
+ raise ApiError(40001, "项目不存在或未配置访问范围", status_code=404)
+ if request.scope.get("identity"):
+ control.require_role(project, "viewer" if request.method == "GET" else "editor")
+ request.scope["audit_cube"] = cube
+ return cube, OperationStore(control), request.app.state.memories
+
+
+async def upstream_call(upstream, path, body):
+ data = await upstream.request("POST", "product/" + path, body=body, allow_failure=True)
+ if isinstance(data, dict) and "error_code" in data:
+ code = data["error_code"]
+ raise ApiError(
+ {400: 40000, 403: 40003, 404: 40001, 409: 40900}[code],
+ "上游拒绝该生命周期操作",
+ status_code=code,
+ )
+ return data
+
+
+def view(record):
+ confirmed = sum(item["state"] == "confirmed" for item in record["results"])
+ return {
+ "operationId": record["operationId"],
+ "state": "confirmed" if confirmed == len(record["results"]) else "pending",
+ "deleted" if record["action"] == "deleted" else "restored": confirmed,
+ "results": record["results"],
+ }
+
+
+def answer(record, request=None):
+ result = view(record)
+ success = result["state"] == "confirmed"
+ if request is not None:
+ request.scope["audit_code"] = 0 if success else 50301
+ return JSONResponse(
+ status_code=200 if success else 503,
+ content={
+ "code": 0 if success else 50301,
+ "data": result,
+ "message": "success"
+ if success
+ else "部分记忆尚未确认完成,请使用 operationId 查询或重试",
+ },
+ headers={"Cache-Control": "no-store"},
+ )
+
+
+def validate_result(data, record, item):
+ if (
+ not isinstance(data, dict)
+ or data.get("operation_id") != item["operationId"]
+ or data.get("memory_id") != item["memoryId"]
+ or data.get("kind") != record["kind"]
+ or data.get("target_status")
+ != ("deleted" if record["action"] == "deleted" else "activated")
+ or data.get("state") not in ("confirmed", "pending")
+ or type(data.get("success")) is not bool
+ or data["success"] != (data["state"] == "confirmed")
+ ):
+ raise bad_upstream()
+ if data["success"] and data.get("vector_state") not in (
+ ("not_applicable",)
+ if record["kind"] == "profile"
+ else ("confirmed", "explicitly_not_indexed")
+ ):
+ raise bad_upstream()
+ return {
+ "memoryId": item["memoryId"],
+ "operationId": item["operationId"],
+ "state": data["state"],
+ "retryable": not data["success"],
+ "vectorState": data.get("vector_state", "unconfirmed"),
+ }
+
+
+async def execute(record, cube, store, upstream):
+ for item in record["results"]:
+ if item["state"] == "confirmed":
+ continue
+ try:
+ data = await upstream_call(
+ upstream,
+ "transition_profile_memory" if record["kind"] == "profile" else "transition_memory",
+ {
+ "mem_cube_id": cube,
+ "memory_id": item["memoryId"],
+ "operation_id": item["operationId"],
+ "kind": record["kind"],
+ "target_status": record["action"],
+ },
+ )
+ result = validate_result(data, record, item)
+ except ApiError as exc:
+ result = {**item, "state": "pending", "retryable": True, "errorCode": exc.code}
+ record = store.merge_result(record, result)
+ return record
+
+
+async def start(body, request, action):
+ check_origin(request)
+ cube, store, upstream = dependencies(request, body.projectId)
+ fingerprint = json.dumps([body.projectId, body.memType, action, sorted(body.memory_ids)])
+ previous = store.get(body.operationId, body.projectId)
+ if previous:
+ if previous["fingerprint"] != fingerprint:
+ raise ApiError(40900, "operationId 已用于其他操作", status_code=409)
+ return answer(await execute(previous, cube, store, upstream), request)
+ kind = "profile" if body.memType == "profile_mem" else "graph"
+ for identifier in body.memory_ids:
+ data = await upstream_call(
+ upstream,
+ "inspect_memory_lifecycle",
+ {"mem_cube_id": cube, "memory_id": identifier, "kind": kind},
+ )
+ if not isinstance(data, dict) or data.get("memory_id") != identifier:
+ raise bad_upstream()
+ if data.get("memory_type") not in TAB_TYPES[body.memType]:
+ raise ApiError(40000, "记忆类型与标签不匹配", status_code=400)
+ if data.get("status") != ("activated" if action == "deleted" else "deleted") or (
+ action == "restore" and data.get("previous_status") != "activated"
+ ):
+ raise ApiError(40900, "记忆状态不允许该操作", status_code=409)
+ record = store.prepare(
+ {
+ "operationId": body.operationId,
+ "projectId": body.projectId,
+ "fingerprint": fingerprint,
+ "kind": kind,
+ "action": action,
+ "results": [
+ {
+ "memoryId": identifier,
+ "operationId": "dash-"
+ + hashlib.sha256(
+ json.dumps([body.operationId, identifier]).encode()
+ ).hexdigest(),
+ "state": "queued",
+ "retryable": True,
+ }
+ for identifier in body.memory_ids
+ ],
+ }
+ )
+ return answer(await execute(record, cube, store, upstream), request)
+
+
+@router.post("/delete")
+async def delete(body: BatchRequest, request: Request):
+ return await start(body, request, "deleted")
+
+
+@router.post("/restore")
+async def restore(body: BatchRequest, request: Request):
+ return await start(body, request, "restore")
+
+
+@router.post("/retry")
+async def retry(body: OperationRequest, request: Request):
+ check_origin(request)
+ cube, store, upstream = dependencies(request, body.projectId)
+ record = store.get(body.operationId, body.projectId)
+ if record is None:
+ raise ApiError(40001, "操作不存在", status_code=404)
+ return answer(await execute(record, cube, store, upstream), request)
+
+
+@router.get("/operation")
+async def operation(
+ request: Request,
+ response: Response,
+ projectId: str = Query(..., min_length=1, max_length=128),
+ operationId: str = Query(..., min_length=1, max_length=128, pattern=r"^[A-Za-z0-9_.-]+$"),
+):
+ validated = OperationRequest(projectId=projectId, operationId=operationId)
+ cube, store, upstream = dependencies(request, validated.projectId)
+ record = store.get(validated.operationId, validated.projectId)
+ if record is None:
+ raise ApiError(40001, "操作不存在", status_code=404)
+ for item in record["results"]:
+ if item["state"] == "confirmed":
+ continue
+ try:
+ data = await upstream_call(
+ upstream,
+ "get_memory_operation",
+ {"mem_cube_id": cube, "operation_id": item["operationId"]},
+ )
+ if data is not None:
+ record = store.merge_result(record, validate_result(data, record, item))
+ except ApiError:
+ pass # Keep the persisted pending outcome; never fabricate confirmation.
+ response.headers["Cache-Control"] = "no-store"
+ return {"code": 0, "data": view(record), "message": "success"}
diff --git a/server/backend/memory_models.py b/server/backend/memory_models.py
new file mode 100644
index 000000000..cb9cca9c0
--- /dev/null
+++ b/server/backend/memory_models.py
@@ -0,0 +1,133 @@
+"""Dashboard request contracts, source mappings and unambiguous UTC date ranges."""
+
+from datetime import date, datetime, time, timedelta, timezone
+from typing import Literal
+
+from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
+
+MemoryTab = Literal["text_mem", "pref_mem", "event_mem", "profile_mem", "skill_mem", "tool_mem"]
+TAB_TYPES = {
+ "text_mem": ("WorkingMemory", "LongTermMemory", "UserMemory", "OuterMemory"),
+ "pref_mem": ("PreferenceMemory",),
+ "event_mem": ("EventMemory", "Context", "InsightMemory"),
+ "profile_mem": ("ProfileMemory",),
+ "skill_mem": ("SkillMemory",),
+ "tool_mem": ("ToolSchemaMemory", "ToolTrajectoryMemory"),
+}
+TAB_VIEWS = {
+ "text_mem": "detail_factual",
+ "pref_mem": "preference",
+ "event_mem": "event",
+ "skill_mem": "skill",
+ "tool_mem": "tool_memory",
+}
+
+
+def time_boundary(value: str | None, *, end: bool = False) -> tuple[datetime | None, bool]:
+ if value is None:
+ return None, False
+ if len(value) == 10:
+ parsed = datetime.combine(date.fromisoformat(value), time.min, tzinfo=timezone.utc)
+ return (parsed + timedelta(days=1) if end else parsed), end
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
+ if parsed.utcoffset() is None:
+ raise ValueError("Timestamp must include timezone offset")
+ return parsed.astimezone(timezone.utc), False
+
+
+class MemoryListRequest(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+ projectId: str = Field(min_length=1, max_length=128)
+ memoryDetailType: MemoryTab
+ pageNum: int = Field(1, ge=1, strict=True)
+ pageSize: int = Field(50, ge=1, le=100, strict=True)
+ knowledgeBaseId: str | None = Field(None, max_length=256)
+ startTime: str | None = Field(None, max_length=64)
+ endTime: str | None = Field(None, max_length=64)
+ externalUserId: str | None = Field(None, max_length=256)
+ subjectType: Literal["user", "agent", "public"] | None = None
+ memSearchContent: str | None = Field(None, max_length=1000)
+
+ @field_validator(
+ "knowledgeBaseId",
+ "startTime",
+ "endTime",
+ "externalUserId",
+ "memSearchContent",
+ mode="before",
+ )
+ @classmethod
+ def empty_is_missing(cls, value):
+ return value.strip() or None if isinstance(value, str) else value
+
+ @model_validator(mode="after")
+ def check_times(self) -> "MemoryListRequest":
+ try:
+ start, _ = time_boundary(self.startTime)
+ end, exclusive = time_boundary(self.endTime, end=True)
+ except OverflowError as exc:
+ raise ValueError("Time boundary is outside the supported UTC range") from exc
+ if start and end and (start > end or (exclusive and start == end)):
+ raise ValueError("Invalid time range")
+ return self
+
+
+class MemoryDetailRequest(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+ memoryId: str = Field(min_length=1, max_length=1024, pattern=r"^[^\x00-\x1f\x7f]+$")
+ memType: MemoryTab
+
+
+class MemoryItem(BaseModel):
+ memoryId: str
+ memoryContent: str
+ createTime: str
+ memoryType: str
+ subjectType: Literal["user", "agent", "public"] | None = None
+ externalUserId: str | None = None
+ skillName: str | None = None
+ skillUrl: str | None = None
+ profileTemplateId: str | None = None
+ profileInstanceId: str | None = None
+
+
+class MemoryPage(BaseModel):
+ memListItemResponseList: list[MemoryItem]
+ total: int
+ current: int
+
+
+def build_filter(request: MemoryListRequest) -> dict | None:
+ conditions = []
+ if request.memSearchContent:
+ conditions.append(
+ {
+ "or": [
+ {"memory": {"like": request.memSearchContent}},
+ {"name": {"like": request.memSearchContent}},
+ ]
+ }
+ )
+ if request.subjectType:
+ conditions.append({"owner_type": request.subjectType})
+ if request.externalUserId:
+ conditions.append(
+ {"or": [{"user_id": request.externalUserId}, {"owner_id": request.externalUserId}]}
+ )
+ if request.knowledgeBaseId:
+ conditions.append(
+ {
+ "or": [
+ {"knowledgebase_id": request.knowledgeBaseId},
+ {"knowledge_base_id": request.knowledgeBaseId},
+ {"knowledgebase_ids": {"contains": request.knowledgeBaseId}},
+ ]
+ }
+ )
+ start, _ = time_boundary(request.startTime)
+ end, exclusive = time_boundary(request.endTime, end=True)
+ if start:
+ conditions.append({"created_at": {"gte": start.isoformat()}})
+ if end:
+ conditions.append({"created_at": {"lt" if exclusive else "lte": end.isoformat()}})
+ return {"and": conditions} if conditions else None
diff --git a/server/backend/memory_upstream.py b/server/backend/memory_upstream.py
new file mode 100644
index 000000000..084ab8fbc
--- /dev/null
+++ b/server/backend/memory_upstream.py
@@ -0,0 +1,137 @@
+"""Bounded, typed reads of MemOS REST; no model SDK or database coupling."""
+
+import asyncio
+import json
+import re
+from urllib.parse import quote
+
+import httpx
+
+from .errors import ApiError
+from .memory_models import TAB_TYPES, TAB_VIEWS, MemoryListRequest, build_filter, time_boundary
+
+MAX_RESPONSE_BYTES = 32 * 1024 * 1024
+
+
+def bad_upstream() -> ApiError:
+ return ApiError(50200, "上游记忆响应格式或业务结果不符合约定", status_code=502)
+
+
+class MemoryUpstream:
+ def __init__(self, client: httpx.AsyncClient):
+ self.client = client
+
+ async def request(self, method: str, path: str, *, body=None, params=None, allow_failure=False):
+ try:
+ async with asyncio.timeout(self.client.timeout.read):
+ async with self.client.stream(method, path, json=body, params=params) as response:
+ if response.status_code != 200 or response.headers.get(
+ "content-encoding", "identity"
+ ).strip().lower() not in {"", "identity"}:
+ raise bad_upstream()
+ raw = bytearray()
+ async for chunk in response.aiter_bytes():
+ if len(raw) + len(chunk) > MAX_RESPONSE_BYTES:
+ raise bad_upstream()
+ raw.extend(chunk)
+ except (TimeoutError, httpx.TimeoutException) as exc:
+ raise ApiError(50400, "上游记忆查询超时", status_code=504) from exc
+ except httpx.ProtocolError as exc:
+ raise bad_upstream() from exc
+ except httpx.RequestError as exc:
+ raise ApiError(50300, "无法连接上游记忆服务", status_code=503) from exc
+ try:
+ envelope = json.loads(raw)
+ except (ValueError, RecursionError) as exc:
+ raise bad_upstream() from exc
+ if not isinstance(envelope, dict) or type(envelope.get("code")) is not int:
+ raise bad_upstream()
+ if envelope["code"] != 200:
+ if allow_failure and envelope["code"] in (400, 403, 404, 409):
+ return {"error_code": envelope["code"], "success": False}
+ raise bad_upstream()
+ data = envelope.get("data")
+ if (
+ not allow_failure
+ and isinstance(data, dict)
+ and (
+ data.get("status") in ("failure", "failed", "error") or data.get("success") is False
+ )
+ ):
+ raise bad_upstream()
+ return data
+
+ async def page(self, cube: str, request: MemoryListRequest) -> tuple[list[dict], int]:
+ tab = request.memoryDetailType
+ if tab == "profile_mem":
+ start, _ = time_boundary(request.startTime)
+ end, exclusive = time_boundary(request.endTime, end=True)
+ data = await self.request(
+ "POST",
+ "product/list_profile_memories",
+ body={
+ "mem_cube_id": cube,
+ "subject_type": request.subjectType,
+ "subject_id": request.externalUserId,
+ "search": request.memSearchContent,
+ "knowledgebase_id": request.knowledgeBaseId,
+ "start_time": start.isoformat() if start else None,
+ "end_time": end.isoformat() if end else None,
+ "end_exclusive": exclusive,
+ "page": request.pageNum,
+ "page_size": request.pageSize,
+ },
+ )
+ if not isinstance(data, dict) or data.get("cube_id") != cube:
+ raise bad_upstream()
+ rows, total = data.get("memories"), data.get("total")
+ else:
+ body = {
+ "mem_cube_id": cube,
+ "include_memory_view": [TAB_VIEWS[tab]],
+ "status": ["activated"],
+ "include_sources": False,
+ "page": request.pageNum,
+ "page_size": request.pageSize,
+ "filter": build_filter(request),
+ }
+ if tab == "event_mem":
+ body["event_memory_types"] = list(TAB_TYPES[tab])
+ data = await self.request("POST", "product/get_memory", body=body)
+ buckets = data.get(tab) if isinstance(data, dict) else None
+ if (
+ not isinstance(buckets, list)
+ or len(buckets) != 1
+ or not isinstance(buckets[0], dict)
+ or buckets[0].get("cube_id") != cube
+ ):
+ raise bad_upstream()
+ rows, total = buckets[0].get("memories"), buckets[0].get("total_nodes")
+ if (
+ not isinstance(rows, list)
+ or not all(isinstance(row, dict) for row in rows)
+ or type(total) is not int
+ or total < len(rows)
+ or len(rows) > request.pageSize
+ ):
+ raise bad_upstream()
+ return rows, total
+
+ async def detail(self, cube: str, identifier: str, tab: str) -> dict | None:
+ if tab == "profile_mem":
+ data = await self.request(
+ "POST",
+ "product/get_profile_memory",
+ body={"mem_cube_id": cube, "memory_id": identifier},
+ )
+ else:
+ if not re.fullmatch(r"[A-Za-z0-9_.-]{1,128}", identifier):
+ raise ApiError(40000, "memoryId 格式不合法", status_code=400)
+ data = await self.request(
+ "GET",
+ "product/get_memory/" + quote(identifier, safe=""),
+ params={"mem_cube_id": cube, "include_sources": "false"},
+ )
+ if data is not None and (not isinstance(data, dict) or data.get("id") != identifier):
+ raise bad_upstream()
+ return data
diff --git a/server/backend/models.py b/server/backend/models.py
new file mode 100644
index 000000000..29f029fbc
--- /dev/null
+++ b/server/backend/models.py
@@ -0,0 +1,47 @@
+"""Public responses and deliberately limited upstream health projection."""
+
+from typing import Generic, Literal, TypeVar
+
+from pydantic import BaseModel, ConfigDict, Field, StrictInt
+
+SERVICE_NAME = "memos-dashboard-backend"
+T = TypeVar("T")
+
+
+class Envelope(BaseModel, Generic[T]):
+ code: int = 0
+ data: T
+ message: str = "success"
+
+
+class UpstreamHealth(BaseModel):
+ model_config = ConfigDict(extra="ignore", strict=True)
+
+ status: Literal["healthy"]
+ service: Literal["memos"]
+ version: str = Field(min_length=1, max_length=64)
+ contract_version: StrictInt = Field(ge=2, le=2)
+
+
+class ToolInfo(BaseModel):
+ name: str
+ title: str
+
+
+# Gateway protocol descriptors, not a claim that Dashboard business routes exist.
+GATEWAY_TOOLS = (
+ ToolInfo(name="add_message", title="写入记忆"),
+ ToolInfo(name="search_memory", title="检索记忆"),
+ ToolInfo(name="get_user_profile", title="读取画像"),
+ ToolInfo(name="delete_memory", title="删除记忆"),
+ ToolInfo(name="add_feedback", title="提交反馈"),
+)
+
+
+class Meta(BaseModel):
+ mode: Literal["live"] = "live"
+ service: str = SERVICE_NAME
+ health: UpstreamHealth
+ keyPrefix: str = "sk-"
+ tools: tuple[ToolInfo, ...] = GATEWAY_TOOLS
+ authEnabled: bool = False
diff --git a/server/backend/operation_store.py b/server/backend/operation_store.py
new file mode 100644
index 000000000..19e06cdc9
--- /dev/null
+++ b/server/backend/operation_store.py
@@ -0,0 +1,60 @@
+"""Durable batch receipts; upstream intents remain the memory source of truth."""
+
+import json
+
+from .errors import ApiError
+
+
+class OperationStore:
+ def __init__(self, control):
+ self.control = control
+ with control.connection() as connection, connection:
+ connection.execute(
+ "CREATE TABLE IF NOT EXISTS operation_records (id TEXT PRIMARY KEY, "
+ "project_id TEXT NOT NULL, fingerprint TEXT NOT NULL, document TEXT NOT NULL)"
+ )
+
+ def get(self, identifier, project):
+ with self.control.connection() as connection:
+ row = connection.execute(
+ "SELECT document FROM operation_records WHERE id=? AND project_id=?",
+ (identifier, project),
+ ).fetchone()
+ return json.loads(row[0]) if row else None
+
+ def prepare(self, record):
+ encoded = json.dumps(record, ensure_ascii=False)
+ with self.control.connection() as connection, connection:
+ connection.execute(
+ "INSERT OR IGNORE INTO operation_records VALUES (?,?,?,?)",
+ (record["operationId"], record["projectId"], record["fingerprint"], encoded),
+ )
+ row = connection.execute(
+ "SELECT project_id,fingerprint,document FROM operation_records WHERE id=?",
+ (record["operationId"],),
+ ).fetchone()
+ if row[0] != record["projectId"] or row[1] != record["fingerprint"]:
+ raise ApiError(40900, "operationId 已用于其他操作", status_code=409)
+ return json.loads(row[2])
+
+ def merge_result(self, record, result):
+ with self.control.connection() as connection, connection:
+ connection.execute("BEGIN IMMEDIATE")
+ stored = json.loads(
+ connection.execute(
+ "SELECT document FROM operation_records WHERE id=? AND project_id=?",
+ (record["operationId"], record["projectId"]),
+ ).fetchone()[0]
+ )
+ for index, item in enumerate(stored["results"]):
+ if item["memoryId"] == result["memoryId"] and item["state"] != "confirmed":
+ stored["results"][index] = result
+ connection.execute(
+ "UPDATE operation_records SET document=? WHERE id=? AND project_id=?",
+ (
+ json.dumps(stored, ensure_ascii=False),
+ record["operationId"],
+ record["projectId"],
+ ),
+ )
+ return stored
diff --git a/server/backend/patches/deployment-v2-dashboard-support.patch b/server/backend/patches/deployment-v2-dashboard-support.patch
new file mode 100644
index 000000000..19b2592c4
--- /dev/null
+++ b/server/backend/patches/deployment-v2-dashboard-support.patch
@@ -0,0 +1,3638 @@
+diff --git a/deploy/mcp-gateway/.gitignore b/deploy/mcp-gateway/.gitignore
+index aea65a4..b59b7c3 100644
+--- a/deploy/mcp-gateway/.gitignore
++++ b/deploy/mcp-gateway/.gitignore
+@@ -3,3 +3,8 @@ tenants.conf
+
+ __pycache__/
+ **/__pycache__/
++
++# Publication coordination and crash-left credential temporary files
++tenants.conf.lock
++tenants.conf.pending
++tenants.conf.*.tmp
+diff --git a/deploy/mcp-gateway/README.md b/deploy/mcp-gateway/README.md
+index 8cc92b4..c5579e0 100644
+--- a/deploy/mcp-gateway/README.md
++++ b/deploy/mcp-gateway/README.md
+@@ -99,6 +99,7 @@ python3 memos_gateway.py --transport http --host 0.0.0.0 --port 8003
+ | 变量 | 默认 | 说明 |
+ |---|---|---|
+ | `MEMOS_TENANTS` | 空(必填) | token → user_id 映射,`tok-a:alice,tok-b:bob` 或 JSON 两种写法皆可 |
++| `MEMOS_TENANTS_FILE` | 仓库旁 `tenants.conf` | dashboard 合成下发的租户清单文件(形状 = `MEMOS_TENANTS` 的 JSON 写法);置空字符串则不读文件 |
+ | `MEMOS_API_BASE_URL` | `http://localhost:8000` | 后端 MemOS 服务地址 |
+ | `MEMORY_CALL_LOG_DIR` | `/var/log/memos-gateway` | 调用日志落盘目录 |
+ | `MEMORY_SERVICE_NAME` | `MemoryService` | 对客户端展示的服务名 |
+@@ -124,6 +125,18 @@ memos-zzzzzzzz:demo-agent-zhang ← 不同 user_id = 各自独立
+
+ 改完 `MEMOS_TENANTS` 必须重启进程:环境变量不会热更新。
+
++#### `tenants.conf`(dashboard 管理的接口密钥清单)
++
++dashboard 的「接口密钥」功能以**项目即身份**的方式自动维护这份文件
++(`{"token": {"user_id": 项目, "cube_id": 项目}}`,与 `MEMOS_TENANTS` 的 JSON 写法同形):
++
++- 网关启动时把 env 与文件**合并**:两处来源并存,同一个 token 在两处且身份一致 → 去重;
++ 同一个 token 身份不一致 → **拒绝启动**(错误信息带 `MEMOS_TENANTS` 与 `tenants.conf` 双来源点名,token 以掩码呈现)。
++- 文件缺席不算错误(当作空清单);坏 JSON 才是。
++- 文件在仓库 `.gitignore` 里(token 语义不入库)。
++- dashboard 是该文件的**单向权威**:dashboard 重启会按其库内 active 密钥重写整份文件,
++ 手工加进文件里的 token 会被拭除;手工管理的 token 请走 `MEMOS_TENANTS`。
++
+ ### 改网关代码
+
+ 改 `gateway/` 下对应模块(入口仍是 `memos_gateway.py`),重启进程即生效。
+@@ -198,6 +211,48 @@ calls-YYYYMMDD.jsonl
+ | `gateway/mcp_api/http.py` | `/` 落地页、`/health` |
+ | `gateway/identity.py` | token → Caller |
+ | `test_gateway_contract.py` | 工具名 / 签名 / docstring / 对外文案锁 |
++| `test_gateway_tenants_file.py` | tenants.conf 读取与 env 合并(不依赖 fastmcp) |
+ | `test_gateway_tools.py` | 工具 payload 与失败语义(不连后端) |
+ | `test_gateway_audit.py` | 审计截断 / JSONL fsync |
+ | `prompt_regression_test.py` | 工具描述的模型行为回归 |
++
++
++### Dashboard credential publication (Task14 repair, 2026-10-27)
++
++Dashboard and gateway coordinate through the same `tenants.conf.lock` first-byte advisory lock
++(Windows msvcrt / POSIX flock). The shared configuration directory must support this coordination
++file. Startup readers wait for publication to finish; live authentication uses a nonblocking lock.
++A `tenants.conf.pending` marker means a transaction
++or reconciliation has not been confirmed: gateway startup refuses the file instead of accepting
++possibly orphaned credentials. Dashboard startup or a subsequent successful mutation reconciles
++from committed SQLite state. A process crash releases the OS lock; the pending marker remains
++until reconciliation. This is a publication/compensation protocol, not a cross-filesystem database
++transaction or an assurance about power-loss durability.
++
++Parser errors for both environment and file mappings never include the original credential.
++Unchanged legacy credentials continue to work, including an omitted/null cube defaulting to user.
++File-backed credentials reload automatically before each authenticated request. Changes to
++MEMOS_TENANTS environment variables still require a process restart.
++
++### Automatic credential reload
++
++The verifier checks the file identity, size, nanosecond modification/change times and pending
++marker on every authentication request. An unchanged publication is not reparsed. Changes are
++read under the shared publication lock and swapped as a complete in-memory snapshot; disk work
++runs off the async event loop. No watcher process, management endpoint or new dependency is needed.
++
++- Dashboard key creation works on the next request after its successful publication; revocation
++ rejects subsequent requests, including requests made by already-connected MCP clients.
++- A rename does not change the credential publication or interrupt its authorization.
++- A missing, unreadable, malformed, conflicting or pending file temporarily disables file-backed
++ credentials instead of retaining potentially revoked keys. Environment-backed identities remain
++ available. A later confirmed publication automatically recovers without a restart.
++- An empty file stays authenticated: it does not silently turn off authentication. Environment
++ identities are independent; removing a duplicate file entry does not revoke its environment copy.
++- Requests already authorized may finish; reload does not cancel their side effects. Unchanged
++ public health/landing endpoints do not trigger a reload and reflect the latest auth snapshot.
++- Deploying this code requires one initial gateway restart. Afterwards dashboard key publication
++ needs no gateway restart. All workers detect the shared file independently on their next request.
++
++Regression: `python -m pytest test_gateway_live_reload.py test_gateway_tenants_file.py
++test_gateway_contract.py test_gateway_tools.py -q` (from this directory).
+diff --git a/deploy/mcp-gateway/gateway/app.py b/deploy/mcp-gateway/gateway/app.py
+index 5d000b5..1490d19 100644
+--- a/deploy/mcp-gateway/gateway/app.py
++++ b/deploy/mcp-gateway/gateway/app.py
+@@ -5,17 +5,19 @@ from __future__ import annotations
+ from dataclasses import dataclass
+
+ from fastmcp import FastMCP
+-from fastmcp.server.auth.providers.jwt import StaticTokenVerifier
+
+ from .audit.facade import Auditor
+ from .backend import ProductClient
+-from .config import Settings, settings as default_settings
++from .config import Settings
++from .config import settings as default_settings
+ from .domain.tenants import TenantRegistry
+ from .identity import IdentityResolver
++from .live_auth import ReloadingTokenVerifier
+ from .mcp_api.http import PublicPages
+ from .mcp_api.tools import register as register_tools
+ from .service import MemoryService
+
++
+ INSTRUCTIONS = (
+ "长期记忆服务。回答用户前先调用 search_memory 检索相关记忆;"
+ "用户明确要求记住某事时调用 add_message。"
+@@ -39,16 +41,13 @@ class App:
+
+ def build_app(settings: Settings | None = None) -> App:
+ settings = settings or default_settings
+- tenants = TenantRegistry.from_raw(settings.tenants_raw)
++ # env(MEMOS_TENANTS) 为主,dashboard 合成下发的 tenants.conf 为辅;token 冲突直接拒启。
++ tenants = TenantRegistry.from_sources(settings.tenants_raw, settings.tenants_file)
+ identity = IdentityResolver(settings)
+ memory = MemoryService(ProductClient(settings), settings)
+ auditor = Auditor(settings, tenants, identity)
+
+- verifier = (
+- StaticTokenVerifier(tokens=tenants.verifier_tokens())
+- if tenants
+- else None
+- )
++ verifier = ReloadingTokenVerifier(tenants) if tenants or settings.tenants_file else None
+ mcp = FastMCP(
+ name=settings.service_name,
+ instructions=INSTRUCTIONS,
+diff --git a/deploy/mcp-gateway/gateway/config.py b/deploy/mcp-gateway/gateway/config.py
+index b1ddbad..2d117d3 100644
+--- a/deploy/mcp-gateway/gateway/config.py
++++ b/deploy/mcp-gateway/gateway/config.py
+@@ -5,6 +5,7 @@ from __future__ import annotations
+ import os
+ import time
+ from dataclasses import dataclass
++from pathlib import Path
+
+
+ @dataclass(frozen=True)
+@@ -20,6 +21,8 @@ class Settings:
+ query_prefix: str
+ tenants_raw: str
+ started_at: float
++ # dashboard 合成下发的清单文件("" = 不读文件)。env 缺省即仓库旁的 tenants.conf。
++ tenants_file: str = ""
+
+ @classmethod
+ def from_env(cls) -> Settings:
+@@ -38,6 +41,10 @@ class Settings:
+ audit_log_dir=os.getenv("MEMORY_AUDIT_LOG_DIR", call_log_dir),
+ query_prefix=os.getenv("MEMORY_QUERY_PREFIX", ""),
+ tenants_raw=os.getenv("MEMOS_TENANTS", ""),
++ tenants_file=os.getenv(
++ "MEMOS_TENANTS_FILE",
++ str(Path(__file__).resolve().parent.parent / "tenants.conf"),
++ ),
+ started_at=time.time(),
+ )
+
+diff --git a/deploy/mcp-gateway/gateway/domain/tenants.py b/deploy/mcp-gateway/gateway/domain/tenants.py
+index c649408..6d0b118 100644
+--- a/deploy/mcp-gateway/gateway/domain/tenants.py
++++ b/deploy/mcp-gateway/gateway/domain/tenants.py
+@@ -3,7 +3,12 @@
+ from __future__ import annotations
+
+ import json
++import os
++import threading
++
++from contextlib import contextmanager
+ from dataclasses import dataclass
++from pathlib import Path
+
+
+ @dataclass(frozen=True)
+@@ -12,7 +17,7 @@ class Tenant:
+ cube_id: str
+
+
+-def parse_tenants(raw: str) -> dict[str, Tenant]:
++def _parse_tenants(raw: str) -> dict[str, Tenant]:
+ """把 MEMOS_TENANTS 解析成 {token: Tenant}。"""
+ raw = (raw or "").strip()
+ if not raw:
+@@ -23,10 +28,13 @@ def parse_tenants(raw: str) -> dict[str, Tenant]:
+ for token, spec in data.items():
+ if isinstance(spec, str):
+ spec = {"user_id": spec}
+- uid = (spec or {}).get("user_id")
+- if not uid:
+- raise ValueError(f"tenant '{token}' has no user_id")
+- out[token] = Tenant(user_id=uid, cube_id=spec.get("cube_id") or uid)
++ if not isinstance(spec, dict) or not isinstance(token, str) or not token:
++ raise ValueError("Invalid tenant entry")
++ uid = spec.get("user_id")
++ cube = spec.get("cube_id") or uid
++ if not isinstance(uid, str) or not uid or not isinstance(cube, str):
++ raise ValueError("Tenant entry has no valid user_id/cube_id")
++ out[token] = Tenant(user_id=uid, cube_id=cube)
+ return out
+ # 紧凑写法 tok:user_id,tok2:user_id2
+ # 用 rpartition 而不是 partition:token 里万一出现冒号也不会把映射切错,
+@@ -44,6 +52,83 @@ def parse_tenants(raw: str) -> dict[str, Tenant]:
+ return out
+
+
++def parse_tenants(raw: str) -> dict[str, Tenant]:
++ """Parse credentials without ever echoing source data in an exception."""
++ try:
++ return _parse_tenants(raw)
++ except (ValueError, TypeError, AttributeError):
++ raise ValueError(
++ "Invalid tenant configuration; expected token-to-identity mappings"
++ ) from None
++
++
++@contextmanager
++def _manifest_lock(path: Path, *, blocking: bool = True):
++ # Shared protocol with dashboard.key_store.manifest_lock; lock the first byte.
++ with path.with_name(path.name + ".lock").open("a+b") as handle:
++ handle.seek(0, os.SEEK_END)
++ if not handle.tell():
++ handle.write(b"0")
++ handle.flush()
++ handle.seek(0)
++ if os.name == "nt":
++ import msvcrt
++
++ msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK if blocking else msvcrt.LK_NBLCK, 1)
++ else:
++ import fcntl
++
++ fcntl.flock(handle.fileno(), fcntl.LOCK_EX | (0 if blocking else fcntl.LOCK_NB))
++ try:
++ yield
++ finally:
++ handle.seek(0)
++ if os.name == "nt":
++ msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
++ else:
++ fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
++
++
++def load_tenants_file(
++ path: str | os.PathLike[str] | None, *, blocking: bool = True
++) -> dict[str, Tenant]:
++ """Load only a confirmed publication, never a precommit or unreconciled file."""
++ if not path:
++ return {}
++ path = Path(path)
++ if not path.parent.exists():
++ return {}
++ with _manifest_lock(path, blocking=blocking):
++ if path.with_name(path.name + ".pending").exists():
++ raise ValueError(
++ "Tenant configuration awaits dashboard reconciliation; refusing startup"
++ )
++ try:
++ text = path.read_text(encoding="utf-8")
++ except FileNotFoundError:
++ return {}
++ return parse_tenants(text)
++
++
++def merge_tenants(
++ base: dict[str, Tenant], extra: dict[str, Tenant], *, extra_source: str
++) -> dict[str, Tenant]:
++ """env 为主、dashboard 文件为辅;同 token 但身份不同 → 拒绝启动,点名双来源。
++
++ 错误信息只带 token 掩码,绝不落 token 明文。
++ """
++ merged = dict(base)
++ for token, tenant in extra.items():
++ clash = merged.get(token)
++ if clash is not None and clash != tenant:
++ raise ValueError(
++ f"tenant token conflict between MEMOS_TENANTS and {extra_source}: "
++ f"token {mask_token(token)}"
++ )
++ merged[token] = tenant
++ return merged
++
++
+ def mask_token(token: str | None) -> str | None:
+ """token 掩码:前 12 + 后 4。"""
+ if not token:
+@@ -55,7 +140,61 @@ def mask_token(token: str | None) -> str | None:
+
+ class TenantRegistry:
+ def __init__(self, mapping: dict[str, Tenant]):
+- self._by_token = mapping
++ self._by_token = dict(mapping)
++ self._base = dict(mapping)
++ self._path: Path | None = None
++ self._version = None
++ self._refresh_lock = threading.Lock()
++
++ @classmethod
++ def from_sources(cls, raw: str, path: str | os.PathLike[str] | None) -> TenantRegistry:
++ """Static environment identities plus a revocable, atomically published file."""
++ registry = cls(parse_tenants(raw))
++ registry._path = Path(path) if path else None
++ registry._refresh(strict=True)
++ return registry
++
++ @staticmethod
++ def _stat(path: Path) -> tuple | None:
++ try:
++ stat = path.stat()
++ except FileNotFoundError:
++ return None
++ return stat.st_dev, stat.st_ino, stat.st_size, stat.st_mtime_ns, stat.st_ctime_ns
++
++ def _signature(self) -> tuple:
++ path = self._path
++ return self._stat(path), self._stat(path.with_name(path.name + ".pending"))
++
++ def _refresh(self, *, strict: bool = False) -> None:
++ if self._path is None:
++ return
++ with self._refresh_lock:
++ try:
++ before = self._signature()
++ if before == self._version:
++ return
++ extra = load_tenants_file(self._path, blocking=strict)
++ after = self._signature()
++ if before != after:
++ raise ValueError("Tenant publication changed during reload")
++ merged = merge_tenants(self._base, extra, extra_source="tenants.conf")
++ self._by_token = merged # replace one immutable snapshot, never mutate it
++ self._version = after
++ except (OSError, ValueError, UnicodeError):
++ # Retaining a stale file snapshot would also retain revoked keys.
++ self._by_token = dict(self._base)
++ self._version = None # retry even if the unreadable file stat is unchanged
++ if strict:
++ raise ValueError("Tenant configuration unavailable or unconfirmed") from None
++
++ def authenticate(self, token: str | None) -> Tenant | None:
++ """Refresh before each auth decision; execute this disk work off the event loop."""
++ # Keep refresh + lookup linearizable across concurrent verifier threads.
++ # _refresh owns the lock; the returned immutable snapshot remains valid for
++ # this decision even if another request publishes the next one afterwards.
++ self._refresh()
++ return self.get(token)
+
+ @classmethod
+ def from_raw(cls, raw: str) -> TenantRegistry:
+diff --git a/docs/openapi.json b/docs/openapi.json
+index ef8102c..3b7a241 100644
+--- a/docs/openapi.json
++++ b/docs/openapi.json
+@@ -2337,6 +2337,252 @@
+ }
+ }
+ },
++ "/product/list_profile_memories": {
++ "post": {
++ "tags": [
++ "Server API"
++ ],
++ "summary": "List Profile Memories",
++ "operationId": "list_profile_memories_product_list_profile_memories_post",
++ "requestBody": {
++ "content": {
++ "application/json": {
++ "schema": {
++ "$ref": "#/components/schemas/ProfileMemoryListRequest"
++ }
++ }
++ },
++ "required": true
++ },
++ "responses": {
++ "200": {
++ "description": "Successful Response",
++ "content": {
++ "application/json": {
++ "schema": {
++ "$ref": "#/components/schemas/GetMemoryResponse"
++ }
++ }
++ }
++ },
++ "422": {
++ "description": "Validation Error",
++ "content": {
++ "application/json": {
++ "schema": {
++ "$ref": "#/components/schemas/HTTPValidationError"
++ }
++ }
++ }
++ }
++ }
++ }
++ },
++ "/product/get_profile_memory": {
++ "post": {
++ "tags": [
++ "Server API"
++ ],
++ "summary": "Get Profile Memory",
++ "operationId": "get_profile_memory_product_get_profile_memory_post",
++ "requestBody": {
++ "content": {
++ "application/json": {
++ "schema": {
++ "$ref": "#/components/schemas/ProfileMemoryGetRequest"
++ }
++ }
++ },
++ "required": true
++ },
++ "responses": {
++ "200": {
++ "description": "Successful Response",
++ "content": {
++ "application/json": {
++ "schema": {
++ "$ref": "#/components/schemas/GetMemoryResponse"
++ }
++ }
++ }
++ },
++ "422": {
++ "description": "Validation Error",
++ "content": {
++ "application/json": {
++ "schema": {
++ "$ref": "#/components/schemas/HTTPValidationError"
++ }
++ }
++ }
++ }
++ }
++ }
++ },
++ "/product/transition_memory": {
++ "post": {
++ "tags": [
++ "Server API"
++ ],
++ "summary": "Transition Memory",
++ "operationId": "transition_memory_product_transition_memory_post",
++ "requestBody": {
++ "content": {
++ "application/json": {
++ "schema": {
++ "$ref": "#/components/schemas/TransitionMemoryRequest"
++ }
++ }
++ },
++ "required": true
++ },
++ "responses": {
++ "200": {
++ "description": "Successful Response",
++ "content": {
++ "application/json": {
++ "schema": {
++ "$ref": "#/components/schemas/GetMemoryResponse"
++ }
++ }
++ }
++ },
++ "422": {
++ "description": "Validation Error",
++ "content": {
++ "application/json": {
++ "schema": {
++ "$ref": "#/components/schemas/HTTPValidationError"
++ }
++ }
++ }
++ }
++ }
++ }
++ },
++ "/product/transition_profile_memory": {
++ "post": {
++ "tags": [
++ "Server API"
++ ],
++ "summary": "Transition Profile",
++ "operationId": "transition_profile_product_transition_profile_memory_post",
++ "requestBody": {
++ "content": {
++ "application/json": {
++ "schema": {
++ "$ref": "#/components/schemas/TransitionMemoryRequest"
++ }
++ }
++ },
++ "required": true
++ },
++ "responses": {
++ "200": {
++ "description": "Successful Response",
++ "content": {
++ "application/json": {
++ "schema": {
++ "$ref": "#/components/schemas/GetMemoryResponse"
++ }
++ }
++ }
++ },
++ "422": {
++ "description": "Validation Error",
++ "content": {
++ "application/json": {
++ "schema": {
++ "$ref": "#/components/schemas/HTTPValidationError"
++ }
++ }
++ }
++ }
++ }
++ }
++ },
++ "/product/inspect_memory_lifecycle": {
++ "post": {
++ "tags": [
++ "Server API"
++ ],
++ "summary": "Inspect",
++ "operationId": "inspect_product_inspect_memory_lifecycle_post",
++ "requestBody": {
++ "content": {
++ "application/json": {
++ "schema": {
++ "$ref": "#/components/schemas/LifecycleProbeRequest"
++ }
++ }
++ },
++ "required": true
++ },
++ "responses": {
++ "200": {
++ "description": "Successful Response",
++ "content": {
++ "application/json": {
++ "schema": {
++ "$ref": "#/components/schemas/GetMemoryResponse"
++ }
++ }
++ }
++ },
++ "422": {
++ "description": "Validation Error",
++ "content": {
++ "application/json": {
++ "schema": {
++ "$ref": "#/components/schemas/HTTPValidationError"
++ }
++ }
++ }
++ }
++ }
++ }
++ },
++ "/product/get_memory_operation": {
++ "post": {
++ "tags": [
++ "Server API"
++ ],
++ "summary": "Operation",
++ "operationId": "operation_product_get_memory_operation_post",
++ "requestBody": {
++ "content": {
++ "application/json": {
++ "schema": {
++ "$ref": "#/components/schemas/OperationRequest"
++ }
++ }
++ },
++ "required": true
++ },
++ "responses": {
++ "200": {
++ "description": "Successful Response",
++ "content": {
++ "application/json": {
++ "schema": {
++ "$ref": "#/components/schemas/GetMemoryResponse"
++ }
++ }
++ }
++ },
++ "422": {
++ "description": "Validation Error",
++ "content": {
++ "application/json": {
++ "schema": {
++ "$ref": "#/components/schemas/HTTPValidationError"
++ }
++ }
++ }
++ }
++ }
++ }
++ },
+ "/add/message": {
+ "post": {
+ "summary": "Add conversation messages (cloud contract)",
+@@ -8195,6 +8441,41 @@
+ "title": "Filter",
+ "description": "Optional metadata filter applied when retrieving memories."
+ },
++ "status": {
++ "anyOf": [
++ {
++ "items": {
++ "type": "string"
++ },
++ "type": "array"
++ },
++ {
++ "type": "null"
++ }
++ ],
++ "title": "Status",
++ "description": "Only return nodes in these lifecycle statuses. Omitted or None keeps the historical LISTED_STATUSES behaviour (activated/resolving/frozen/expired); an explicit list (e.g. ['activated']) narrows the listing."
++ },
++ "event_memory_types": {
++ "anyOf": [
++ {
++ "items": {
++ "type": "string",
++ "enum": [
++ "EventMemory",
++ "Context",
++ "InsightMemory"
++ ]
++ },
++ "type": "array"
++ },
++ {
++ "type": "null"
++ }
++ ],
++ "title": "Event Memory Types",
++ "description": "Optional event-view types; omitted retains EventMemory only"
++ },
+ "page": {
+ "anyOf": [
+ {
+@@ -8502,6 +8783,41 @@
+ "title": "Filter",
+ "description": "Optional metadata filter applied when retrieving memories."
+ },
++ "status": {
++ "anyOf": [
++ {
++ "items": {
++ "type": "string"
++ },
++ "type": "array"
++ },
++ {
++ "type": "null"
++ }
++ ],
++ "title": "Status",
++ "description": "Only return nodes in these lifecycle statuses. Omitted or None keeps the historical LISTED_STATUSES behaviour (activated/resolving/frozen/expired); an explicit list (e.g. ['activated']) narrows the listing."
++ },
++ "event_memory_types": {
++ "anyOf": [
++ {
++ "items": {
++ "type": "string",
++ "enum": [
++ "EventMemory",
++ "Context",
++ "InsightMemory"
++ ]
++ },
++ "type": "array"
++ },
++ {
++ "type": "null"
++ }
++ ],
++ "title": "Event Memory Types",
++ "description": "Optional event-view types; omitted retains EventMemory only"
++ },
+ "page": {
+ "anyOf": [
+ {
+@@ -9195,6 +9511,40 @@
+ "title": "KnowledgeBaseUpdateRequest",
+ "description": "Rename a knowledge base or replace its description."
+ },
++ "LifecycleProbeRequest": {
++ "properties": {
++ "mem_cube_id": {
++ "type": "string",
++ "maxLength": 128,
++ "minLength": 1,
++ "pattern": "^[A-Za-z0-9_.-]+$",
++ "title": "Mem Cube Id"
++ },
++ "memory_id": {
++ "type": "string",
++ "maxLength": 1024,
++ "minLength": 1,
++ "pattern": "^[^\\x00-\\x1f\\x7f]+$",
++ "title": "Memory Id"
++ },
++ "kind": {
++ "type": "string",
++ "enum": [
++ "graph",
++ "profile"
++ ],
++ "title": "Kind",
++ "default": "graph"
++ }
++ },
++ "additionalProperties": false,
++ "type": "object",
++ "required": [
++ "mem_cube_id",
++ "memory_id"
++ ],
++ "title": "LifecycleProbeRequest"
++ },
+ "MemoryResponse": {
+ "properties": {
+ "code": {
+@@ -9333,6 +9683,31 @@
+ ],
+ "title": "ModelRecord"
+ },
++ "OperationRequest": {
++ "properties": {
++ "mem_cube_id": {
++ "type": "string",
++ "maxLength": 128,
++ "minLength": 1,
++ "pattern": "^[A-Za-z0-9_.-]+$",
++ "title": "Mem Cube Id"
++ },
++ "operation_id": {
++ "type": "string",
++ "maxLength": 128,
++ "minLength": 1,
++ "pattern": "^[A-Za-z0-9_.-]+$",
++ "title": "Operation Id"
++ }
++ },
++ "additionalProperties": false,
++ "type": "object",
++ "required": [
++ "mem_cube_id",
++ "operation_id"
++ ],
++ "title": "OperationRequest"
++ },
+ "PermissionDict": {
+ "properties": {
+ "permissions": {
+@@ -9532,6 +9907,139 @@
+ "title": "ProfileEditRequest",
+ "description": "Edit one profile instance."
+ },
++ "ProfileMemoryGetRequest": {
++ "properties": {
++ "mem_cube_id": {
++ "type": "string",
++ "maxLength": 256,
++ "minLength": 1,
++ "title": "Mem Cube Id"
++ },
++ "memory_id": {
++ "type": "string",
++ "maxLength": 1024,
++ "minLength": 1,
++ "title": "Memory Id"
++ }
++ },
++ "type": "object",
++ "required": [
++ "mem_cube_id",
++ "memory_id"
++ ],
++ "title": "ProfileMemoryGetRequest",
++ "description": "Resolve a generated profile field id inside one cube."
++ },
++ "ProfileMemoryListRequest": {
++ "properties": {
++ "mem_cube_id": {
++ "type": "string",
++ "maxLength": 256,
++ "minLength": 1,
++ "title": "Mem Cube Id"
++ },
++ "subject_type": {
++ "anyOf": [
++ {
++ "type": "string",
++ "enum": [
++ "user",
++ "agent",
++ "public"
++ ]
++ },
++ {
++ "type": "null"
++ }
++ ],
++ "title": "Subject Type"
++ },
++ "subject_id": {
++ "anyOf": [
++ {
++ "type": "string",
++ "maxLength": 256
++ },
++ {
++ "type": "null"
++ }
++ ],
++ "title": "Subject Id"
++ },
++ "search": {
++ "anyOf": [
++ {
++ "type": "string",
++ "maxLength": 1000
++ },
++ {
++ "type": "null"
++ }
++ ],
++ "title": "Search"
++ },
++ "knowledgebase_id": {
++ "anyOf": [
++ {
++ "type": "string",
++ "maxLength": 256
++ },
++ {
++ "type": "null"
++ }
++ ],
++ "title": "Knowledgebase Id"
++ },
++ "start_time": {
++ "anyOf": [
++ {
++ "type": "string",
++ "format": "date-time"
++ },
++ {
++ "type": "null"
++ }
++ ],
++ "title": "Start Time"
++ },
++ "end_time": {
++ "anyOf": [
++ {
++ "type": "string",
++ "format": "date-time"
++ },
++ {
++ "type": "null"
++ }
++ ],
++ "title": "End Time"
++ },
++ "end_exclusive": {
++ "type": "boolean",
++ "title": "End Exclusive",
++ "default": false
++ },
++ "page": {
++ "type": "integer",
++ "minimum": 1.0,
++ "title": "Page",
++ "default": 1
++ },
++ "page_size": {
++ "type": "integer",
++ "maximum": 100.0,
++ "minimum": 1.0,
++ "title": "Page Size",
++ "default": 20
++ }
++ },
++ "type": "object",
++ "required": [
++ "mem_cube_id"
++ ],
++ "title": "ProfileMemoryListRequest",
++ "description": "Tenant-scoped, read-only profile fields for administrative listing."
++ },
+ "ProfileQueryRequest": {
+ "properties": {
+ "user_id": {
+@@ -10623,6 +11131,57 @@
+ "title": "TaskSummary",
+ "description": "Aggregated counts of tasks by status."
+ },
++ "TransitionMemoryRequest": {
++ "properties": {
++ "mem_cube_id": {
++ "type": "string",
++ "maxLength": 128,
++ "minLength": 1,
++ "pattern": "^[A-Za-z0-9_.-]+$",
++ "title": "Mem Cube Id"
++ },
++ "memory_id": {
++ "type": "string",
++ "maxLength": 1024,
++ "minLength": 1,
++ "pattern": "^[^\\x00-\\x1f\\x7f]+$",
++ "title": "Memory Id"
++ },
++ "kind": {
++ "type": "string",
++ "enum": [
++ "graph",
++ "profile"
++ ],
++ "title": "Kind",
++ "default": "graph"
++ },
++ "target_status": {
++ "type": "string",
++ "enum": [
++ "deleted",
++ "restore"
++ ],
++ "title": "Target Status"
++ },
++ "operation_id": {
++ "type": "string",
++ "maxLength": 128,
++ "minLength": 1,
++ "pattern": "^[A-Za-z0-9_.-]+$",
++ "title": "Operation Id"
++ }
++ },
++ "additionalProperties": false,
++ "type": "object",
++ "required": [
++ "mem_cube_id",
++ "memory_id",
++ "target_status",
++ "operation_id"
++ ],
++ "title": "TransitionMemoryRequest"
++ },
+ "UpdateMemoryRequest": {
+ "properties": {
+ "memory_id": {
+diff --git a/src/memos/api/handlers/memory_handler.py b/src/memos/api/handlers/memory_handler.py
+index 51c71df..6acf9e5 100644
+--- a/src/memos/api/handlers/memory_handler.py
++++ b/src/memos/api/handlers/memory_handler.py
+@@ -146,6 +146,11 @@ def handle_get_subgraph(
+ memories = naive_mem_cube.text_mem.get_relevant_subgraph(
+ query, top_k=top_k, user_name=mem_cube_id, search_type=search_type
+ )
++ from memos.api.memory_visibility import filter_current_subgraph
++
++ memories = filter_current_subgraph(
++ memories, naive_mem_cube.text_mem.graph_store, mem_cube_id
++ )
+
+ # Format and convert to tree structure
+ memories_cleaned = remove_embedding_recursive(memories)
+@@ -202,12 +207,14 @@ def handle_get_memory(
+
+ try:
+ memory = naive_mem_cube.text_mem.get(memory_id, user_name=mem_cube_id)
+- except Exception as e:
++ except ValueError as e:
+ logger.error(f"Failed to get memory {memory_id}: {e}")
+ memory = None
+
+ # Get the data
+ data = _with_skill_url(memory.model_dump()) if memory else None
++ if data and (data.get("metadata") or {}).get("status") == "deleted":
++ data = None
+
+ return GetMemoryResponse(
+ message="Memory retrieved successfully"
+@@ -230,7 +237,7 @@ def handle_get_memory_by_ids(
+ """
+ try:
+ memories = naive_mem_cube.text_mem.get_by_ids(memory_ids=memory_ids, user_name=mem_cube_id)
+- except Exception as e:
++ except ValueError as e:
+ logger.error(f"Failed to get memories: {e}")
+ memories = []
+
+@@ -241,7 +248,13 @@ def handle_get_memory_by_ids(
+ return GetMemoryResponse(
+ message="Memories retrieved successfully",
+ code=200,
+- data={"memories": [_with_skill_url(memory) for memory in memories]},
++ data={
++ "memories": [
++ _with_skill_url(memory)
++ for memory in memories
++ if (memory.get("metadata") or {}).get("status") != "deleted"
++ ]
++ },
+ )
+
+
+@@ -309,12 +322,17 @@ def handle_get_memory_graph(
+ endpoints fall outside the returned node page are dropped, because a client
+ cannot draw a dangling edge.
+ """
+- exported = naive_mem_cube.text_mem.get_all(
+- user_name=get_graph_req.mem_cube_id,
+- page=1,
+- page_size=get_graph_req.limit,
+- memory_type=get_graph_req.memory_types,
+- status=get_graph_req.status,
++ statuses = [status for status in get_graph_req.status if status != "deleted"]
++ exported = (
++ naive_mem_cube.text_mem.get_all(
++ user_name=get_graph_req.mem_cube_id,
++ page=1,
++ page_size=get_graph_req.limit,
++ memory_type=get_graph_req.memory_types,
++ status=statuses,
++ )
++ if statuses
++ else {"nodes": [], "edges": [], "total_nodes": 0}
+ )
+ raw_nodes = exported.get("nodes") or []
+ nodes = [_graph_node_json(node, get_graph_req.label_max) for node in raw_nodes]
+@@ -362,8 +380,14 @@ def handle_get_event_memories(
+ "memory_type": get_event_req.memory_types or None,
+ }
+ if get_event_req.status:
+- get_all_kwargs["status"] = get_event_req.status
+- exported = naive_mem_cube.text_mem.get_all(**get_all_kwargs)
++ get_all_kwargs["status"] = [
++ status for status in get_event_req.status if status != "deleted"
++ ]
++ exported = (
++ naive_mem_cube.text_mem.get_all(**get_all_kwargs)
++ if get_all_kwargs.get("status") != []
++ else {"nodes": [], "total_nodes": 0}
++ )
+ memories = [_graph_node_json(node, None) for node in (exported.get("nodes") or [])]
+ total = int(exported.get("total_nodes") or len(memories))
+ return GetEventMemoriesResponse(
+@@ -438,6 +462,9 @@ def handle_get_memories(
+ return [{"cube_id": get_mem_req.mem_cube_id, "memories": memories, "total_nodes": total}]
+
+ def list_of(memory_type: list[str]) -> tuple[list[dict[str, Any]], int]:
++ statuses = [s for s in (get_mem_req.status or LISTED_STATUSES) if s != "deleted"]
++ if not statuses:
++ return [], 0
+ info = naive_mem_cube.text_mem.get_all(
+ user_name=get_mem_req.mem_cube_id,
+ user_id=get_mem_req.user_id,
+@@ -445,7 +472,7 @@ def handle_get_memories(
+ page_size=get_mem_req.page_size,
+ filter=filter_,
+ memory_type=memory_type,
+- status=LISTED_STATUSES,
++ status=statuses,
+ )
+ return info["nodes"], info["total_nodes"]
+
+@@ -458,7 +485,9 @@ def handle_get_memories(
+ if "detail_factual" in views:
+ results["text_mem"] = bucket(*list_of(detail_types))
+ if "event" in views:
+- results["event_mem"] = bucket(*list_of(["EventMemory"]))
++ results["event_mem"] = bucket(
++ *list_of(get_mem_req.event_memory_types or ["EventMemory"])
++ )
+
+ if get_mem_req.include_tool_memory:
+ memories, total = list_of(["ToolSchemaMemory", "ToolTrajectoryMemory"])
+@@ -761,9 +790,7 @@ def handle_delete_memories(
+ # cube 的 id; 两者都没有则拒绝,避免只凭 id 删到别的租户。
+ delete_scope = delete_mem_req.writable_cube_ids
+ if not delete_scope:
+- cube_id = getattr(
+- getattr(naive_mem_cube.text_mem, "config", None), "cube_id", None
+- )
++ cube_id = getattr(getattr(naive_mem_cube.text_mem, "config", None), "cube_id", None)
+ delete_scope = [cube_id] if cube_id else None
+ if not delete_scope:
+ return DeleteMemoryResponse(
+diff --git a/src/memos/api/handlers/prompt_handler.py b/src/memos/api/handlers/prompt_handler.py
+index 8fdc273..7a52f6d 100644
+--- a/src/memos/api/handlers/prompt_handler.py
++++ b/src/memos/api/handlers/prompt_handler.py
+@@ -211,9 +211,15 @@ class PromptHandler(BaseHandler):
+ results: list[ExtractDebugResult] = []
+ try:
+ parsed = parse_json_result(raw or "")
+- except Exception: # noqa: BLE001 - a malformed model reply must not 500
++ except Exception:
+ parsed = None
+- memory_list = (parsed or {}).get("memory list") or (parsed or {}).get("memory_list")
++ # A valid empty list means "no memories", not malformed model output.
++ # Match the runtime reader: the explicit primary key takes precedence.
++ memory_list = (
++ parsed.get("memory list", parsed.get("memory_list"))
++ if isinstance(parsed, dict)
++ else None
++ )
+ if isinstance(memory_list, list):
+ for item in memory_list:
+ if not isinstance(item, dict):
+diff --git a/src/memos/api/handlers/search_handler.py b/src/memos/api/handlers/search_handler.py
+index c25f435..9fd9f7a 100644
+--- a/src/memos/api/handlers/search_handler.py
++++ b/src/memos/api/handlers/search_handler.py
+@@ -252,6 +252,14 @@ class SearchHandler(BaseHandler):
+ )
+ if search_req_local.include_history:
+ results = self._attach_history_versions(results)
++ from memos.api.cloud_store import build_metadata_store
++ from memos.api.memory_visibility import confirm_current_memories
++ from memos.api.profile import ProfileService
++
++ graph = self.graph_db
++ results = confirm_current_memories(
++ results, graph, ProfileService(build_metadata_store(graph))
++ )
+ return results
+
+ @staticmethod
+diff --git a/src/memos/api/product_models.py b/src/memos/api/product_models.py
+index 57a4249..8cdf3e3 100644
+--- a/src/memos/api/product_models.py
++++ b/src/memos/api/product_models.py
+@@ -1,5 +1,6 @@
+ import uuid
+
++from datetime import datetime
+ from typing import Any, Generic, Literal, TypeVar
+
+ from pydantic import BaseModel, Field, PrivateAttr, field_validator, model_validator
+@@ -1561,6 +1562,17 @@ class GetMemoryRequest(BaseRequest):
+ None,
+ description="Optional metadata filter applied when retrieving memories.",
+ )
++ status: list[str] | None = Field(
++ None,
++ description=(
++ "Only return nodes in these lifecycle statuses. Omitted or None keeps the "
++ "historical LISTED_STATUSES behaviour (activated/resolving/frozen/expired); "
++ "an explicit list (e.g. ['activated']) narrows the listing."
++ ),
++ )
++ event_memory_types: list[Literal["EventMemory", "Context", "InsightMemory"]] | None = Field(
++ None, description="Optional event-view types; omitted retains EventMemory only"
++ )
+ page: int | None = Field(1, ge=1, description="Page number, starting at 1 (cloud default 1)")
+ page_size: int | None = Field(
+ 10, ge=1, description="Items per page (cloud default 10); None exports everything"
+@@ -2405,6 +2417,37 @@ class KnowledgeBaseFileDownloadRequest(BaseRequest):
+ file_id: str = Field(..., description="File id")
+
+
++class ProfileMemoryListRequest(BaseRequest):
++ """Tenant-scoped, read-only profile fields for administrative listing."""
++
++ mem_cube_id: str = Field(..., min_length=1, max_length=256)
++ subject_type: Literal["user", "agent", "public"] | None = None
++ subject_id: str | None = Field(None, max_length=256)
++ search: str | None = Field(None, max_length=1000)
++ knowledgebase_id: str | None = Field(None, max_length=256)
++ start_time: datetime | None = None
++ end_time: datetime | None = None
++ end_exclusive: bool = False
++ page: int = Field(1, ge=1, strict=True)
++ page_size: int = Field(20, ge=1, le=100, strict=True)
++
++ @model_validator(mode="after")
++ def validate_time_range(self) -> "ProfileMemoryListRequest":
++ for value in (self.start_time, self.end_time):
++ if value is not None and value.utcoffset() is None:
++ raise ValueError("Time filters must include timezone offsets")
++ if self.start_time and self.end_time and self.start_time > self.end_time:
++ raise ValueError("start_time must not exceed end_time")
++ return self
++
++
++class ProfileMemoryGetRequest(BaseRequest):
++ """Resolve a generated profile field id inside one cube."""
++
++ mem_cube_id: str = Field(..., min_length=1, max_length=256)
++ memory_id: str = Field(..., min_length=1, max_length=1024)
++
++
+ class ProfileTemplateCreateRequest(BaseRequest):
+ """Create a profile template."""
+
+diff --git a/src/memos/api/profile.py b/src/memos/api/profile.py
+index 44da489..aec1a12 100644
+--- a/src/memos/api/profile.py
++++ b/src/memos/api/profile.py
+@@ -24,6 +24,7 @@ from dataclasses import dataclass
+ from datetime import datetime, timezone
+ from typing import TYPE_CHECKING, Any
+
++from memos.api.control_lock import serialized_profile_write
+ from memos.log import get_logger
+ from memos.mem_reader.custom_extract import apply_custom_extract_prompt
+
+@@ -58,6 +59,7 @@ class ProfileService:
+
+ # ── templates ────────────────────────────────────────────────────────────
+
++ @serialized_profile_write
+ def add_template(self, *, user_name: str, name: str, metadata: dict[str, Any]) -> dict:
+ name = (name or "").strip()
+ if not name:
+@@ -90,6 +92,7 @@ class ProfileService:
+ document = self._owned_template(user_name, template_id)
+ return self._template_view(document, user_name) if document else None
+
++ @serialized_profile_write
+ def update_template(
+ self,
+ *,
+@@ -136,6 +139,7 @@ class ProfileService:
+ self.store.put(PROFILE_TEMPLATE_KIND, document)
+ return {**self._template_view(document, user_name), "affected_instances": affected}
+
++ @serialized_profile_write
+ def delete_template(self, *, user_name: str, template_id: str) -> bool:
+ document = self._owned_template(user_name, template_id)
+ if document is None:
+@@ -148,6 +152,7 @@ class ProfileService:
+
+ # ── instances ────────────────────────────────────────────────────────────
+
++ @serialized_profile_write
+ def bind(self, *, user_name: str, bind_list: list[dict[str, Any]]) -> list[dict]:
+ bound: list[dict] = []
+ for item in bind_list or []:
+@@ -187,6 +192,7 @@ class ProfileService:
+ bound.append(self.instance_view(instance))
+ return bound
+
++ @serialized_profile_write
+ def edit(
+ self,
+ *,
+@@ -207,6 +213,8 @@ class ProfileService:
+
+ for path, change in flatten_edits(metadata or {}).items():
+ current = fields.get(path) or {}
++ if current.get("status") == "deleted":
++ continue
+ kept_flag = current.get(
+ "algorithm_updatable", allowed.get(path, {}).get("algorithm_updatable", True)
+ )
+@@ -219,7 +227,7 @@ class ProfileService:
+ }
+
+ for path in remove_fields or []:
+- if path in fields:
++ if path in fields and fields[path].get("status") != "deleted":
+ fields[path]["value"] = None
+ fields[path]["deprecated"] = True
+
+@@ -227,6 +235,7 @@ class ProfileService:
+ self.store.put(PROFILE_INSTANCE_KIND, instance)
+ return self.instance_view(instance)
+
++ @serialized_profile_write
+ def delete_instance(
+ self,
+ *,
+@@ -260,6 +269,7 @@ class ProfileService:
+
+ # ── extraction / recall ──────────────────────────────────────────────────
+
++ @serialized_profile_write
+ def extract_and_update(
+ self,
+ *,
+@@ -293,6 +303,7 @@ class ProfileService:
+ path: leaf
+ for path, leaf in flatten_template(template.get("metadata") or {}).items()
+ if leaf.get("algorithm_updatable", True)
++ and (instance.get("fields", {}).get(path) or {}).get("status") != "deleted"
+ }
+ if not updatable:
+ continue
+@@ -352,7 +363,11 @@ class ProfileService:
+ template_id_value = instance.get("template_id")
+ for path, field in (instance.get("fields") or {}).items():
+ value = field.get("value")
+- if value in (None, "", [], {}) or field.get("deprecated"):
++ if (
++ value in (None, "", [], {})
++ or field.get("deprecated")
++ or field.get("status") == "deleted"
++ ):
+ continue
+ category, _, name = path.rpartition(".")
+ memories.append(
+@@ -382,6 +397,119 @@ class ProfileService:
+ memories = memories[:top_k]
+ return memories
+
++ def list_field_rows(
++ self,
++ *,
++ user_name: str,
++ subject_type: str | None = None,
++ subject_id: str | None = None,
++ search: str | None = None,
++ start_time: str | None = None,
++ end_time: str | None = None,
++ end_exclusive: bool = False,
++ knowledgebase_id: str | None = None,
++ page: int = 1,
++ page_size: int = 20,
++ ) -> tuple[list[dict], int]:
++ """Tenant-wide field rows; filter and count before slicing a stable page."""
++ rows: list[dict] = []
++ for instance in self._instances(
++ user_name=user_name, subject_type=subject_type, subject_id=subject_id, status="bound"
++ ):
++ if instance.get("user_name") != user_name:
++ continue
++ if knowledgebase_id and instance.get("knowledgebase_id") != knowledgebase_id:
++ continue
++ for path in instance.get("fields") or {}:
++ row = self._field_row(instance, path)
++ if row is None or (search and search.casefold() not in row["memory"].casefold()):
++ continue
++ created = _row_timestamp(row.get("created_at"))
++ if start_time and created < _row_timestamp(start_time):
++ continue
++ if end_time:
++ end = _row_timestamp(end_time)
++ if created > end or (end_exclusive and created == end):
++ continue
++ rows.append(row)
++ rows.sort(key=lambda row: (row["profile_instance_id"], row["profile_path"]))
++ rows.sort(key=lambda row: _row_timestamp(row.get("created_at")), reverse=True)
++ total = len(rows)
++ offset = (page - 1) * page_size
++ return rows[offset : offset + page_size], total
++
++ def get_field_row(self, *, user_name: str, memory_id: str) -> dict | None:
++ """Resolve an opaque field ID without splitting underscores in the path."""
++ match = re.fullmatch(r"(profile_[0-9a-f]{32})_(.+)", memory_id)
++ if match is None:
++ return None
++ instance = self.store.get(PROFILE_INSTANCE_KIND, match.group(1))
++ if (
++ instance is None
++ or instance.get("user_name") != user_name
++ or instance.get("status") != "bound"
++ ):
++ return None
++ return self._field_row(instance, match.group(2))
++
++ @staticmethod
++ def _field_row(instance: dict, path: str) -> dict | None:
++ field = (instance.get("fields") or {}).get(path)
++ if not isinstance(field, dict):
++ return None
++ value = field.get("value")
++ if (
++ value in (None, "", [], {})
++ or field.get("deprecated")
++ or field.get("status", "activated") != "activated"
++ ):
++ return None
++ return {
++ "id": f"{instance['entity_id']}_{path}",
++ "memory": f"{path}: {value}",
++ "memory_type": PROFILE_MEMORY_TYPE,
++ "status": "activated",
++ "user_name": instance["user_name"],
++ "subject_type": instance.get("subject_type"),
++ "subject_id": instance.get("subject_id"),
++ "template_id": instance.get("template_id"),
++ "profile_instance_id": instance["entity_id"],
++ "profile_path": path,
++ "created_at": instance.get("created_at"),
++ "updated_at": instance.get("updated_at"),
++ }
++
++ def lifecycle_field(self, user_name: str, memory_id: str) -> tuple[dict, dict, str] | None:
++ """Trusted control-plane lookup; includes tombstones, never crosses cubes."""
++ match = re.fullmatch(r"(profile_[0-9a-f]{32})_(.+)", memory_id)
++ if match is None:
++ return None
++ instance = self.store.get(PROFILE_INSTANCE_KIND, match.group(1))
++ if (
++ not instance
++ or instance.get("user_name") != user_name
++ or instance.get("status") != "bound"
++ ):
++ return None
++ field = (instance.get("fields") or {}).get(match.group(2))
++ if (
++ not isinstance(field, dict)
++ or field.get("deprecated")
++ or field.get("value") in (None, "", [], {})
++ ):
++ return None
++ return instance, field, match.group(2)
++
++ def apply_field_lifecycle(self, user_name: str, memory_id: str, fields: dict) -> None:
++ # Called only while holding the shared profile-writes lock.
++ found = self.lifecycle_field(user_name, memory_id)
++ if found is None:
++ raise ProfileValidationError("Profile field not found")
++ instance, field, _ = found
++ field.update(fields)
++ instance["updated_at"] = _utc_now()
++ self.store.put(PROFILE_INSTANCE_KIND, instance)
++
+ # ── internals ────────────────────────────────────────────────────────────
+
+ def _owned_template(self, user_name: str, template_id: str) -> dict | None:
+@@ -478,12 +606,22 @@ class ProfileService:
+ **({"deprecated": True} if field.get("deprecated") else {}),
+ }
+ for path, field in (instance.get("fields") or {}).items()
++ if field.get("status") != "deleted"
+ },
+ "created_at": instance.get("created_at"),
+ "updated_at": instance.get("updated_at"),
+ }
+
+
++def _row_timestamp(value: str | None) -> datetime:
++ if not value:
++ return datetime.min.replace(tzinfo=timezone.utc)
++ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
++ if parsed.tzinfo is None:
++ raise ValueError("Profile timestamps must include a timezone")
++ return parsed.astimezone(timezone.utc)
++
++
+ # ── template helpers ─────────────────────────────────────────────────────────
+
+
+@@ -618,6 +756,8 @@ def _sync_instance_fields(
+ "deprecated": path in deprecated,
+ }
+ continue
++ if current.get("status") == "deleted":
++ continue
+ if reset_flags_of is None or path in reset_flags_of:
+ current["algorithm_updatable"] = bool(leaf.get("algorithm_updatable", True))
+ current.setdefault("value", _copy_value(leaf.get("value")))
+diff --git a/src/memos/api/routers/server_router.py b/src/memos/api/routers/server_router.py
+index 00259ab..f2b79e9 100644
+--- a/src/memos/api/routers/server_router.py
++++ b/src/memos/api/routers/server_router.py
+@@ -34,6 +34,7 @@ from memos.api.handlers.formatters_handler import strip_memory_sources
+ from memos.api.handlers.prompt_handler import PromptHandler, model_list
+ from memos.api.handlers.search_handler import SearchHandler
+ from memos.api.knowledge_base import KnowledgeBaseService
++from memos.api.memory_lifecycle_routes import create_lifecycle_router
+ from memos.api.message_store import build_message_store
+ from memos.api.product_models import (
+ AddStatusRequestV2,
+@@ -108,6 +109,7 @@ from memos.api.product_models import (
+ UpdateMemoryResponse,
+ )
+ from memos.api.profile import ProfileService
++from memos.api.profile_memory_routes import create_profile_memory_router
+ from memos.api.skill_sink import SkillMemorySink
+ from memos.log import get_logger
+ from memos.mem_scheduler.base_scheduler import BaseScheduler
+@@ -199,6 +201,12 @@ def get_profile_service() -> ProfileService:
+ return ProfileService(get_cloud_store())
+
+
++def get_memory_lifecycle():
++ from memos.api.memory_lifecycle import MemoryLifecycle
++
++ return MemoryLifecycle(get_cloud_store(), graph_db, get_profile_service())
++
++
+ # =============================================================================
+ # Contract
+ # =============================================================================
+@@ -941,6 +949,11 @@ def get_profile(req: ProfileQueryRequest):
+ return cloud_handler.handle_profile_query(req, get_profile_service())
+
+
++router.include_router(create_profile_memory_router(get_profile_service))
++
++
++router.include_router(create_lifecycle_router(get_memory_lifecycle))
++
+ # Cloud-contract facade: cloud paths at the root, so they are not added under /product.
+ # `include_router` would prepend this router's `/product` prefix, hence the direct extend.
+ router.routes.extend(cloud_router.routes)
+diff --git a/src/memos/api/server_api.py b/src/memos/api/server_api.py
+index fb9f55b..eb6e704 100644
+--- a/src/memos/api/server_api.py
++++ b/src/memos/api/server_api.py
+@@ -33,8 +33,15 @@ logger.info(
+
+ @asynccontextmanager
+ async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
+- yield
+- shutdown_components(server_router_module.components)
++ from memos.api.memory_lifecycle import LifecycleWorker
++
++ worker = LifecycleWorker(server_router_module.get_memory_lifecycle)
++ worker.start()
++ try:
++ yield
++ finally:
++ worker.stop()
++ shutdown_components(server_router_module.components)
+
+
+ app = FastAPI(
+diff --git a/src/memos/embedders/universal_api.py b/src/memos/embedders/universal_api.py
+index 22962e7..f2443cc 100644
+--- a/src/memos/embedders/universal_api.py
++++ b/src/memos/embedders/universal_api.py
+@@ -59,7 +59,10 @@ class UniversalAPIEmbedder(BaseEmbedder):
+ @staticmethod
+ def _build_embedding_kwargs(model: str, texts: list[str], embedding_dims: int | None) -> dict:
+ kwargs = {"model": model, "input": texts}
+- if embedding_dims is not None:
++ # BGE-M3 returns 1024 dimensions natively; some providers reject the
++ # dimensions parameter even when it requests that same native size.
++ native_bge_dims = model in {"BAAI/bge-m3", "Pro/BAAI/bge-m3"} and embedding_dims == 1024
++ if embedding_dims is not None and not native_bge_dims:
+ kwargs["dimensions"] = embedding_dims
+ return kwargs
+
+diff --git a/src/memos/mem_reader/multi_modal_struct.py b/src/memos/mem_reader/multi_modal_struct.py
+index 6b7fce6..bacf9ba 100644
+--- a/src/memos/mem_reader/multi_modal_struct.py
++++ b/src/memos/mem_reader/multi_modal_struct.py
+@@ -11,6 +11,7 @@ from memos.configs.mem_reader import MultiModalStructMemReaderConfig
+ from memos.context.context import ContextThreadPoolExecutor
+ from memos.llms.thinking import disable_thinking
+ from memos.mem_reader.custom_extract import apply_custom_extract_prompt
++from memos.mem_reader.prompt_config import current_base_extract_prompt
+ from memos.mem_reader.read_multi_modal import MultiModalParser, detect_lang
+ from memos.mem_reader.read_multi_modal.base import _derive_key
+ from memos.mem_reader.read_pref_memory.process_preference_memory import process_preference_fine
+@@ -481,7 +482,7 @@ class MultiModalStructMemReader(SimpleStructMemReader):
+ examples = ""
+ prompt = template.replace("{chunk_text}", mem_str)
+ else:
+- template = PROMPT_DICT["chat"][lang]
++ template = current_base_extract_prompt() or PROMPT_DICT["chat"][lang]
+ examples = PROMPT_DICT["chat"][f"{lang}_example"]
+ prompt = template.replace("${conversation}", mem_str)
+
+@@ -517,6 +518,14 @@ class MultiModalStructMemReader(SimpleStructMemReader):
+ try:
+ response_text = self.llm.generate(messages)
+ response_json = parse_json_result(response_text)
++ # The prompt debugger accepts both spellings; runtime must not
++ # silently drop a valid extraction returned with the underscore key.
++ if (
++ isinstance(response_json, dict)
++ and "memory list" not in response_json
++ and isinstance(response_json.get("memory_list"), list)
++ ):
++ response_json["memory list"] = response_json["memory_list"]
+ except Exception as e:
+ logger.error(f"[LLM] Exception during chat generation: {e}")
+ response_json = {
+@@ -1087,7 +1096,7 @@ class MultiModalStructMemReader(SimpleStructMemReader):
+ def known_ids(ids: list[str]) -> set[str]:
+ try:
+ return stored(ids)
+- except Exception as e: # noqa: BLE001 - a failed lookup only costs a re-embed
++ except Exception as e:
+ logger.warning(f"[MultiModalStruct] tool schema lookup failed: {e}")
+ return set()
+
+@@ -1095,7 +1104,7 @@ class MultiModalStructMemReader(SimpleStructMemReader):
+ return self.multi_modal_parser.system_parser.tool_schema_items(
+ scene_data_info, info, existing_ids=known_ids, **kwargs
+ )
+- except Exception as e: # noqa: BLE001 - schemas are an extra, never fail the add
++ except Exception as e:
+ logger.warning(f"[MultiModalStruct] tool schema extraction failed: {e}")
+ return []
+
+diff --git a/src/memos/multi_mem_cube/composite_cube.py b/src/memos/multi_mem_cube/composite_cube.py
+index f4fa825..83649f2 100644
+--- a/src/memos/multi_mem_cube/composite_cube.py
++++ b/src/memos/multi_mem_cube/composite_cube.py
+@@ -5,6 +5,7 @@ from dataclasses import dataclass
+ from typing import TYPE_CHECKING, Any
+
+ from memos.context.context import ContextThreadPoolExecutor
++from memos.mem_reader.prompt_config import use_project_extract_prompt
+ from memos.multi_mem_cube.views import MemCubeView
+ from memos.utils import timed_stage
+
+@@ -38,7 +39,8 @@ class CompositeCubeView(MemCubeView):
+ idx + 1,
+ cube_count,
+ )
+- results = view.add_memories(add_req)
++ with use_project_extract_prompt(view.cube_id):
++ results = view.add_memories(add_req)
+ all_results.extend(results)
+
+ return all_results
+@@ -53,7 +55,8 @@ class CompositeCubeView(MemCubeView):
+ idx + 1,
+ len(self.cube_views),
+ )
+- all_results.extend(view.extract_memories(add_req))
++ with use_project_extract_prompt(view.cube_id):
++ all_results.extend(view.extract_memories(add_req))
+ return all_results
+
+ def search_memories(self, search_req: APISearchRequest) -> dict[str, Any]:
+diff --git a/tests/api/test_cloud_facade.py b/tests/api/test_cloud_facade.py
+index 0e9e52c..298b0ec 100644
+--- a/tests/api/test_cloud_facade.py
++++ b/tests/api/test_cloud_facade.py
+@@ -191,6 +191,12 @@ def test_view_budget_uses_the_requested_top_k_not_the_dedup_widened_one():
+ }
+
+ class Cube:
++ def get_node(self, identifier, **kwargs):
++ return {
++ "id": identifier,
++ "metadata": {"status": "activated", "user_name": kwargs["user_name"]},
++ }
++
+ def search_memories(self, req):
+ # text_mem is capped at top_k by MMR, pref_mem at pref_top_k, so the
+ # candidates before the shared budget are 2 + 4 = 6 > top_k.
+@@ -211,7 +217,7 @@ def test_view_budget_uses_the_requested_top_k_not_the_dedup_widened_one():
+
+ handler = object.__new__(SearchHandler)
+ handler.logger = SimpleNamespace(info=lambda *a, **k: None, warning=lambda *a, **k: None)
+- handler.deps = SimpleNamespace(reranker=None, graph_db=None, searcher=None, llm=None)
++ handler.deps = SimpleNamespace(reranker=None, graph_db=Cube(), searcher=None, llm=None)
+ handler._build_cube_view = lambda req, searcher=None: Cube()
+ request = APISearchRequest(user_id="u1", readable_cube_ids=["c"], query="q", top_k=2)
+ request._budget_across_views = True
+diff --git a/tests/api/test_memory_handler_get_views.py b/tests/api/test_memory_handler_get_views.py
+index e1814e1..a202c5a 100644
+--- a/tests/api/test_memory_handler_get_views.py
++++ b/tests/api/test_memory_handler_get_views.py
+@@ -38,3 +38,41 @@ def test_memory_views_split_events_and_narrow_by_agent():
+ assert calls[1]["memory_type"] == ["EventMemory"]
+ assert calls[0]["filter"] == {"tags": {"contains": "x"}, "agent_id": {"in": ["a1", "a2"]}}
+ assert calls[0]["page"] == 1 and calls[0]["page_size"] == 10
++
++
++def test_cloud_status_field_overrides_listed_statuses():
++ """An explicit ``status`` list narrows the listing; e.g. dashboards ask for activated-only."""
++ calls = []
++ request = GetMemoryRequest(
++ mem_cube_id="c1",
++ include_memory_view=["detail_factual"],
++ status=["activated"],
++ )
++
++ memory_handler.handle_get_memories(request, _cube(calls))
++
++ assert calls[0]["status"] == ["activated"]
++
++
++def test_cloud_status_none_keeps_listed_statuses():
++ """Omitting ``status`` keeps the historical LISTED_STATUSES behaviour (backward compatible)."""
++ calls = []
++ request = GetMemoryRequest(mem_cube_id="c1", include_memory_view=["detail_factual"])
++
++ memory_handler.handle_get_memories(request, _cube(calls))
++
++ assert calls[0]["status"] == memory_handler.LISTED_STATUSES
++
++
++def test_explicit_event_types_support_combined_filters_without_changing_legacy_default():
++ calls = []
++ request = GetMemoryRequest(
++ mem_cube_id="c1",
++ include_memory_view=["event"],
++ status=["activated"],
++ event_memory_types=["EventMemory", "Context", "InsightMemory"],
++ filter={"and": [{"owner_type": "user"}, {"created_at": {"gte": "2026-01-01"}}]},
++ )
++ memory_handler.handle_get_memories(request, _cube(calls))
++ assert calls[0]["memory_type"] == ["EventMemory", "Context", "InsightMemory"]
++ assert calls[0]["filter"] == request.filter
+diff --git a/tests/api/test_product_models.py b/tests/api/test_product_models.py
+index c2b874e..efefbb6 100644
+--- a/tests/api/test_product_models.py
++++ b/tests/api/test_product_models.py
+@@ -14,7 +14,18 @@ import pytest
+
+ from pydantic import ValidationError
+
+-from memos.api.product_models import APIADDRequest, APISearchRequest
++from memos.api.product_models import APIADDRequest, APISearchRequest, GetMemoryRequest
++
++
++def test_get_memory_request_status_accepts_list_or_none():
++ """Cloud contract: status is an optional list[str] filter, None keeps legacy behaviour."""
++ assert GetMemoryRequest(mem_cube_id="c1", status=["activated"]).status == ["activated"]
++ assert GetMemoryRequest(mem_cube_id="c1").status is None
++
++
++def test_get_memory_request_status_rejects_bare_string():
++ with pytest.raises(ValidationError):
++ GetMemoryRequest(mem_cube_id="c1", status="activated")
+
+
+ def test_search_top_k_defaults_to_cloud_cap():
+@@ -32,7 +43,6 @@ def test_search_top_k_accepts_25():
+ assert APISearchRequest(query="q", user_id="u", top_k=25).top_k == 25
+
+
+-
+ def test_add_request_exposes_model_level_example():
+ """APIADDRequest must ship a model-level example for the interactive docs."""
+ schema = APIADDRequest.model_json_schema()
+diff --git a/tests/api/test_prompt_config.py b/tests/api/test_prompt_config.py
+index 8320f8f..2e871e6 100644
+--- a/tests/api/test_prompt_config.py
++++ b/tests/api/test_prompt_config.py
+@@ -190,3 +190,37 @@ def test_saved_prompt_replaces_the_reader_template(tmp_path, monkeypatch):
+ with use_project_extract_prompt("cube-reader"):
+ reader._get_llm_response("用户提到昨天的会议", None)
+ assert seen[-1].startswith(simple_struct.PROMPT_DICT["chat"]["zh"][:20])
++
++
++@pytest.mark.parametrize(
++ "payload",
++ [
++ {"memory list": [], "summary": "No durable facts"},
++ {"memory_list": [], "summary": "No durable facts"},
++ {"memory list": [], "memory_list": [{"value": "ignore fallback when primary is valid"}]},
++ ],
++)
++def test_debug_valid_empty_memory_list_stays_empty(client, mocked_llm, payload):
++ import json
++
++ mocked_llm.generate.return_value = json.dumps(payload)
++ result = client.post(
++ "/product/debug_extract",
++ json={"mem_cube_id": CUBE, "messages": [{"role": "user", "content": "hello"}]},
++ )
++ assert result.status_code == 200
++ assert result.json()["data"]["results"] == []
++
++
++@pytest.mark.parametrize(
++ "payload",
++ ["plain unstructured answer", '{"summary":"wrong shape"}', '{"memory list":"not a list"}'],
++)
++def test_debug_invalid_structure_is_explicit_raw(client, mocked_llm, payload):
++ mocked_llm.generate.return_value = payload
++ result = client.post(
++ "/product/debug_extract",
++ json={"mem_cube_id": CUBE, "messages": [{"role": "user", "content": "hello"}]},
++ )
++ assert result.status_code == 200
++ assert result.json()["data"]["results"] == [{"type": "raw", "content": payload}]
+diff --git a/tests/api/test_search_time_aware.py b/tests/api/test_search_time_aware.py
+index d165457..89e34d1 100644
+--- a/tests/api/test_search_time_aware.py
++++ b/tests/api/test_search_time_aware.py
+@@ -14,9 +14,20 @@ from memos.search import time_understanding
+ class FakeCube:
+ def __init__(self, *responses):
+ self.responses = list(responses)
++ self.current = {
++ m["id"]: {**m, "metadata": {**m["metadata"], "user_name": group["cube_id"]}}
++ for response in responses
++ for groups in response.values()
++ for group in groups
++ for m in group.get("memories", [])
++ }
+ self.filters = []
+ self.top_ks = []
+
++ def get_node(self, identifier, **kwargs):
++ row = self.current.get(identifier)
++ return row if row and row["metadata"]["user_name"] == kwargs.get("user_name") else None
++
+ def search_memories(self, req):
+ self.filters.append(req.filter)
+ self.top_ks.append(req.top_k)
+@@ -67,7 +78,7 @@ def _bucket(*memories, **other):
+ def _handler(cube, llm=None):
+ handler = object.__new__(SearchHandler)
+ handler.logger = SimpleNamespace(info=lambda *a, **k: None, warning=lambda *a, **k: None)
+- handler.deps = SimpleNamespace(reranker=None, graph_db=None, searcher=None, llm=llm)
++ handler.deps = SimpleNamespace(reranker=None, graph_db=cube, searcher=None, llm=llm)
+ handler._build_cube_view = lambda req, searcher=None: cube
+ return handler
+
+diff --git a/tests/api/test_search_time_history.py b/tests/api/test_search_time_history.py
+index 1857d60..d71814e 100644
+--- a/tests/api/test_search_time_history.py
++++ b/tests/api/test_search_time_history.py
+@@ -31,8 +31,19 @@ def _bucket(*memories):
+ class FakeCube:
+ def __init__(self, *responses):
+ self.responses = list(responses)
++ self.current = {
++ m["id"]: {**m, "metadata": {**m["metadata"], "user_name": group["cube_id"]}}
++ for response in responses
++ for groups in response.values()
++ for group in groups
++ for m in group.get("memories", [])
++ }
+ self.filters = []
+
++ def get_node(self, identifier, **kwargs):
++ row = self.current.get(identifier)
++ return row if row and row["metadata"]["user_name"] == kwargs.get("user_name") else None
++
+ def search_memories(self, req):
+ self.filters.append(req.filter)
+ return self.responses.pop(0)
+@@ -41,7 +52,7 @@ class FakeCube:
+ def _handler(cube):
+ handler = object.__new__(SearchHandler)
+ handler.logger = SimpleNamespace(info=lambda *a, **k: None, warning=lambda *a, **k: None)
+- handler.deps = SimpleNamespace(reranker=None, graph_db=None, searcher=None)
++ handler.deps = SimpleNamespace(reranker=None, graph_db=cube, searcher=None)
+ handler._build_cube_view = lambda req, searcher=None: cube
+ return handler
+
+diff --git a/tests/embedders/test_universal_api.py b/tests/embedders/test_universal_api.py
+index 7d897eb..b543dad 100644
+--- a/tests/embedders/test_universal_api.py
++++ b/tests/embedders/test_universal_api.py
+@@ -47,6 +47,25 @@ def _bad_request_error(
+
+
+ class TestUniversalAPIEmbedderDimensions:
++ @pytest.mark.parametrize("model", ["BAAI/bge-m3", "Pro/BAAI/bge-m3"])
++ @patch("memos.embedders.universal_api.OpenAIClient")
++ def test_bge_m3_works_with_fixed_dimension_provider(self, mock_openai_client, model):
++ response = _mock_embedding_response(dims=1024)
++
++ def create_embedding(**kwargs):
++ if "dimensions" in kwargs:
++ raise _bad_request_error("The parameter is invalid. Please check again.")
++ return response
++
++ mock_openai_client.return_value.embeddings.create.side_effect = create_embedding
++ embedder = UniversalAPIEmbedder(_make_config(model_name_or_path=model, embedding_dims=1024))
++
++ vectors = embedder.embed(["memory connectivity check"])
++
++ assert len(vectors) == 1
++ assert len(vectors[0]) == 1024
++ mock_openai_client.return_value.embeddings.create.assert_called_once()
++
+ def test_build_embedding_kwargs_no_dims(self):
+ kwargs = UniversalAPIEmbedder._build_embedding_kwargs(
+ "text-embedding-3-large", ["hello"], None
+@@ -81,7 +100,8 @@ class TestUniversalAPIEmbedderDimensions:
+ assert config["config"]["embedding_dims"] == 1536
+
+ @patch("memos.embedders.universal_api.OpenAIClient")
+- def test_embed_passes_embedding_dims_to_api(self, mock_openai_client):
++ def test_embed_passes_embedding_dims_to_api(self, mock_openai_client, monkeypatch):
++ monkeypatch.delenv("MOS_EMBEDDER_TIMEOUT", raising=False)
+ response = _mock_embedding_response(vectors_per_text=1, dims=2)
+ mock_create = MagicMock(return_value=response)
+ mock_openai_client.return_value.embeddings.create = mock_create
+@@ -96,7 +116,8 @@ class TestUniversalAPIEmbedderDimensions:
+ assert result == [[0.1, 0.2]]
+
+ @patch("memos.embedders.universal_api.OpenAIClient")
+- def test_embed_without_dims_does_not_pass_dimensions(self, mock_openai_client):
++ def test_embed_without_dims_does_not_pass_dimensions(self, mock_openai_client, monkeypatch):
++ monkeypatch.delenv("MOS_EMBEDDER_TIMEOUT", raising=False)
+ response = _mock_embedding_response(vectors_per_text=1, dims=2)
+ mock_create = MagicMock(return_value=response)
+ mock_openai_client.return_value.embeddings.create = mock_create
+diff --git a/deploy/mcp-gateway/gateway/live_auth.py b/deploy/mcp-gateway/gateway/live_auth.py
+new file mode 100644
+--- /dev/null
++++ b/deploy/mcp-gateway/gateway/live_auth.py
+@@ -0,0 +1,30 @@
++"""Request-time file change detection; no static token cache survives revocation."""
++
++from __future__ import annotations
++
++from typing import TYPE_CHECKING
++
++from anyio import to_thread
++from fastmcp.server.auth import AccessToken, TokenVerifier
++
++
++if TYPE_CHECKING:
++ from .domain.tenants import TenantRegistry
++
++
++class ReloadingTokenVerifier(TokenVerifier):
++ def __init__(self, tenants: TenantRegistry):
++ super().__init__()
++ self._tenants = tenants
++
++ async def verify_token(self, token: str) -> AccessToken | None:
++ tenant = await to_thread.run_sync(self._tenants.authenticate, token)
++ if tenant is None:
++ return None
++ claims = {
++ "client_id": tenant.user_id,
++ "scopes": ["memory"],
++ "user_id": tenant.user_id,
++ "cube_id": tenant.cube_id,
++ }
++ return AccessToken(token=token, client_id=tenant.user_id, scopes=["memory"], claims=claims)
+diff --git a/deploy/mcp-gateway/test_gateway_live_reload.py b/deploy/mcp-gateway/test_gateway_live_reload.py
+new file mode 100644
+--- /dev/null
++++ b/deploy/mcp-gateway/test_gateway_live_reload.py
+@@ -0,0 +1,217 @@
++"""Credential publications become effective on the next authenticated HTTP request."""
++
++from __future__ import annotations
++
++import asyncio
++import json
++import os
++import sys
++
++from dataclasses import replace
++from pathlib import Path
++
++import pytest
++
++from starlette.testclient import TestClient
++
++
++sys.path.insert(0, str(Path(__file__).parent))
++
++from gateway.app import build_app
++from gateway.config import settings
++from gateway.domain.tenants import TenantRegistry
++
++
++def publish(path, values):
++ temporary = path.with_suffix(".next")
++ temporary.write_text(json.dumps(values), encoding="utf-8")
++ os.replace(temporary, path)
++
++
++def registry(path):
++ return TenantRegistry.from_sources("env-token:operator", str(path))
++
++
++def test_add_revoke_and_identity_remap(tmp_path):
++ path = tmp_path / "tenants.conf"
++ publish(path, {"first": "alice"})
++ tenants = registry(path)
++ assert tenants.authenticate("first").user_id == "alice"
++ publish(path, {"second": {"user_id": "bob", "cube_id": "team-b"}})
++ assert tenants.authenticate("first") is None
++ assert tenants.authenticate("second").cube_id == "team-b"
++ assert tenants.get("second").user_id == "bob" # audit shares the live snapshot
++ publish(path, {"second": "carol"})
++ assert tenants.authenticate("second").user_id == "carol"
++ assert tenants.authenticate("env-token").user_id == "operator"
++
++
++@pytest.mark.parametrize("failure", ["invalid", "pending", "missing", "conflict"])
++def test_bad_publication_fails_closed_and_recovers(tmp_path, failure):
++ path = tmp_path / "tenants.conf"
++ publish(path, {"first": "alice"})
++ tenants = registry(path)
++ if failure == "invalid":
++ path.write_text('{"credential-secret":', encoding="utf-8")
++ elif failure == "pending":
++ path.with_name(path.name + ".pending").write_text("pending")
++ elif failure == "missing":
++ path.unlink()
++ else:
++ publish(path, {"env-token": "mallory"})
++ assert tenants.authenticate("first") is None
++ assert tenants.authenticate("credential-secret") is None
++ assert tenants.authenticate("env-token").user_id == "operator"
++ path.with_name(path.name + ".pending").unlink(missing_ok=True)
++ publish(path, {"second": "bob"})
++ assert tenants.authenticate("second").user_id == "bob"
++ assert tenants.authenticate("first") is None
++
++
++def test_unchanged_file_is_not_reparsed_and_same_mtime_replacement_detected(tmp_path, monkeypatch):
++ import gateway.domain.tenants as module
++
++ path = tmp_path / "tenants.conf"
++ publish(path, {"first": "alice"})
++ tenants = registry(path)
++ original = module.parse_tenants
++ calls = []
++
++ def parse(value):
++ calls.append(True)
++ return original(value)
++
++ monkeypatch.setattr(module, "parse_tenants", parse)
++ for _ in range(10):
++ assert tenants.authenticate("first")
++ assert calls == []
++ timestamp = path.stat().st_mtime_ns
++ publish(path, {"other": "bobby"})
++ os.utime(path, ns=(timestamp, timestamp))
++ assert tenants.authenticate("other").user_id == "bobby"
++ assert calls == [True]
++
++
++def test_file_read_failure_revokes_cached_file_keys(tmp_path, monkeypatch):
++ import gateway.domain.tenants as module
++
++ path = tmp_path / "tenants.conf"
++ publish(path, {"first": "alice"})
++ tenants = registry(path)
++ publish(path, {"second": "bob"})
++ original = module.load_tenants_file
++
++ def denied(*args, **kwargs):
++ raise PermissionError("sensitive source must not escape")
++
++ monkeypatch.setattr(module, "load_tenants_file", denied)
++ assert tenants.authenticate("first") is None
++ assert tenants.authenticate("env-token")
++ monkeypatch.setattr(module, "load_tenants_file", original)
++ assert tenants.authenticate("second")
++
++
++def test_publication_lock_does_not_block_auth_and_recovers(tmp_path):
++ from gateway.domain.tenants import _manifest_lock
++
++ path = tmp_path / "tenants.conf"
++ publish(path, {"first": "alice"})
++ tenants = registry(path)
++ marker = path.with_name(path.name + ".pending")
++ with _manifest_lock(path):
++ marker.write_text("pending")
++ publish(path, {"second": "bob"})
++ assert tenants.authenticate("first") is None
++ assert tenants.authenticate("second") is None
++ assert tenants.authenticate("env-token")
++ marker.unlink()
++ assert tenants.authenticate("second")
++
++
++def test_change_between_read_and_stat_never_accepts_mixed_publication(tmp_path, monkeypatch):
++ import gateway.domain.tenants as module
++
++ path = tmp_path / "tenants.conf"
++ publish(path, {"first": "alice"})
++ tenants = registry(path)
++ original = module.load_tenants_file
++ publish(path, {"second": "bob"})
++
++ def racing(*args, **kwargs):
++ loaded = original(*args, **kwargs)
++ publish(path, {"third": "carol"})
++ return loaded
++
++ monkeypatch.setattr(module, "load_tenants_file", racing)
++ assert tenants.authenticate("second") is None
++ monkeypatch.setattr(module, "load_tenants_file", original)
++ assert tenants.authenticate("third").user_id == "carol"
++
++
++def test_empty_startup_stays_authenticated_and_live_http_rechecks_each_request(tmp_path):
++ path = tmp_path / "tenants.conf"
++ publish(path, {})
++ app = build_app(
++ replace(
++ settings,
++ tenants_raw="",
++ tenants_file=str(path),
++ call_log_dir=str(tmp_path / "calls"),
++ audit_log_dir=str(tmp_path / "audit"),
++ )
++ )
++ rpc = {"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}
++ with TestClient(app.mcp.http_app(middleware=[app.http_middleware()])) as web:
++
++ def request(token):
++ return web.post(
++ "/mcp",
++ json=rpc,
++ headers={
++ "Authorization": "Bearer " + token,
++ "Accept": "application/json, text/event-stream",
++ },
++ )
++
++ assert web.post("/mcp", json=rpc).status_code == 401
++ assert request("first").status_code == 401
++ publish(path, {"first": "alice"})
++ assert request("first").status_code == 200
++ publish(path, {})
++ assert request("first").status_code == 401
++ publish(path, {"second": "bob"})
++ assert request("second").status_code == 200
++ assert request("first").status_code == 401
++
++
++def test_auth_disk_work_does_not_block_event_loop(tmp_path, monkeypatch):
++ import threading
++
++ from gateway.live_auth import ReloadingTokenVerifier
++
++ path = tmp_path / "tenants.conf"
++ publish(path, {"first": "alice"})
++ tenants = registry(path)
++ entered, released = threading.Event(), threading.Event()
++ original = tenants.authenticate
++
++ def slow(token):
++ entered.set()
++ assert released.wait(3)
++ return original(token)
++
++ monkeypatch.setattr(tenants, "authenticate", slow)
++
++ async def run():
++ task = asyncio.create_task(ReloadingTokenVerifier(tenants).verify_token("first"))
++ for _ in range(100):
++ if entered.is_set():
++ break
++ await asyncio.sleep(0.001)
++ try:
++ assert entered.is_set() and not task.done()
++ finally:
++ released.set()
++ assert (await task).claims["user_id"] == "alice"
++
++ asyncio.run(run())
+diff --git a/deploy/mcp-gateway/test_gateway_tenants_file.py b/deploy/mcp-gateway/test_gateway_tenants_file.py
+new file mode 100644
+--- /dev/null
++++ b/deploy/mcp-gateway/test_gateway_tenants_file.py
+@@ -0,0 +1,121 @@
++#!/usr/bin/env python3
++"""Task 14 RED:网关侧 tenants.conf(dashboard 合成下发)的读取与合并。
++
++不依赖 fastmcp、不起服务。跑法:
++
++ python test_gateway_tenants_file.py
++"""
++
++from __future__ import annotations
++
++import pathlib
++import tempfile
++import unittest
++
++from gateway.domain.tenants import load_tenants_file, merge_tenants, parse_tenants
++
++
++class LoadTenantsFile(unittest.TestCase):
++ def setUp(self):
++ self.tmp = tempfile.TemporaryDirectory()
++ self.dir = pathlib.Path(self.tmp.name)
++
++ def tearDown(self):
++ self.tmp.cleanup()
++
++ def write(self, text, name="tenants.conf"):
++ path = self.dir / name
++ path.write_text(text, encoding="utf-8")
++ return path
++
++ def test_missing_file_is_empty(self):
++ self.assertEqual(load_tenants_file(self.dir / "absent.conf"), {})
++
++ def test_file_json_same_shape_as_env(self):
++ path = self.write('{"tok-a": {"user_id": "a", "cube_id": "c"}, "tok-b": "b"}')
++ parsed = load_tenants_file(path)
++ self.assertEqual(parsed["tok-a"].user_id, "a")
++ self.assertEqual(parsed["tok-a"].cube_id, "c")
++ self.assertEqual(parsed["tok-b"].user_id, "b")
++ self.assertEqual(parsed["tok-b"].cube_id, "b")
++
++ def test_bad_json_raises_valueerror(self):
++ path = self.write("{not json")
++ with self.assertRaises(ValueError):
++ load_tenants_file(path)
++
++ def test_merge_disjoint_sources(self):
++ base = parse_tenants("env-tok:alice")
++ path = self.write('{"file-tok": {"user_id": "bob", "cube_id": "bob"}}')
++ merged = merge_tenants(base, load_tenants_file(path), extra_source="tenants.conf")
++ self.assertEqual(merged["env-tok"].user_id, "alice")
++ self.assertEqual(merged["file-tok"].user_id, "bob")
++ self.assertEqual(len(merged), 2)
++
++ def test_merge_conflict_names_both_sources_without_token_plaintext(self):
++ base = parse_tenants("same-tok:alice")
++ path = self.write('{"same-tok": {"user_id": "mallory", "cube_id": "mallory"}}')
++ with self.assertRaises(ValueError) as caught:
++ merge_tenants(base, load_tenants_file(path), extra_source="tenants.conf")
++ message = str(caught.exception)
++ self.assertIn("MEMOS_TENANTS", message)
++ self.assertIn("tenants.conf", message)
++ self.assertNotIn("same-tok", message)
++
++ def test_merge_identical_entry_dedupes(self):
++ base = parse_tenants("same-tok:alice")
++ path = self.write('{"same-tok": {"user_id": "alice", "cube_id": "alice"}}')
++ merged = merge_tenants(base, load_tenants_file(path), extra_source="tenants.conf")
++ self.assertEqual(len(merged), 1)
++ self.assertEqual(merged["same-tok"].user_id, "alice")
++
++ def test_invalid_config_never_exposes_token(self):
++ token = "sk-" + "x" * 48
++ for raw in ('{"' + token + '":{}}', token, '{"' + token + '":[1]}'):
++ try:
++ parse_tenants(raw)
++ except ValueError as error:
++ self.assertFalse(token in str(error))
++ else:
++ self.fail("Malformed credential configuration was accepted")
++
++ def test_pending_manifest_is_rejected(self):
++ path = self.write("{}")
++ path.with_name(path.name + ".pending").write_text("pending")
++ with self.assertRaises(ValueError):
++ load_tenants_file(path)
++
++ def test_reader_waits_for_publication_lock(self):
++ import threading
++
++ from gateway.domain.tenants import _manifest_lock
++
++ path = self.write("{}")
++ started, done = threading.Event(), threading.Event()
++ failures = []
++
++ def read():
++ started.set()
++ try:
++ load_tenants_file(path)
++ except Exception as error:
++ failures.append(type(error).__name__)
++ finally:
++ done.set()
++
++ with _manifest_lock(path):
++ reader = threading.Thread(target=read)
++ reader.start()
++ self.assertTrue(started.wait(2))
++ self.assertFalse(done.wait(0.1))
++ reader.join(4)
++ self.assertTrue(done.is_set())
++ self.assertEqual(failures, [])
++
++ def test_legacy_null_cube_still_defaults_to_user(self):
++ parsed = parse_tenants('{"legacy": {"user_id":"a", "cube_id":null}}')
++ self.assertEqual(parsed["legacy"].cube_id, "a")
++
++
++if __name__ == "__main__":
++ unittest.main(verbosity=2)
+diff --git a/src/memos/api/control_lock.py b/src/memos/api/control_lock.py
+new file mode 100644
+--- /dev/null
++++ b/src/memos/api/control_lock.py
+@@ -0,0 +1,86 @@
++"""Crash-released control locks shared across workers using the metadata backend."""
++
++import hashlib
++import os
++import threading
++import time
++import weakref
++
++from collections.abc import Callable, Iterator
++from contextlib import contextmanager
++from functools import wraps
++
++from memos.api.cloud_store import JsonMetadataStore, MetadataStore, Neo4jMetadataStore
++
++
++_registry_guard = threading.Lock()
++_registry = weakref.WeakValueDictionary()
++
++
++@contextmanager
++def control_lock(store: MetadataStore, key: str) -> Iterator[None]:
++ digest = hashlib.sha256(key.encode()).hexdigest()
++ identity = (str(getattr(store, "root", "neo4j")), digest)
++ with _registry_guard:
++ lock = _registry.setdefault(identity, threading.RLock())
++ with lock:
++ if isinstance(store, Neo4jMetadataStore):
++ if not getattr(store, "_control_constraint_ready", False):
++ with store._session() as session:
++ session.run(
++ "CREATE CONSTRAINT cloud_entity_id_unique IF NOT EXISTS "
++ "FOR (n:CloudEntity) REQUIRE n.entity_id IS UNIQUE"
++ ).consume()
++ store._control_constraint_ready = True
++ with store._session() as session, session.begin_transaction() as tx:
++ tx.run(
++ "MERGE (n:CloudEntity {entity_id: $id}) "
++ "SET n.entity_kind='control_lock', n.lock_version=coalesce(n.lock_version,0)+1",
++ id="control-lock-" + digest,
++ ).consume()
++ yield
++ # The transaction's exclusive node lock is released even after process death.
++ tx.commit()
++ elif isinstance(store, JsonMetadataStore):
++ directory = store.root / ".control-locks"
++ directory.mkdir(parents=True, exist_ok=True)
++ with (directory / digest).open("a+b") as handle:
++ handle.write(b"0")
++ handle.flush()
++ if os.name == "nt":
++ import msvcrt
++
++ deadline = time.monotonic() + 60
++ while True:
++ try:
++ handle.seek(0)
++ msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
++ break
++ except OSError:
++ if time.monotonic() > deadline:
++ raise TimeoutError("Control lock acquisition timed out") from None
++ time.sleep(0.02)
++ try:
++ yield
++ finally:
++ handle.seek(0)
++ msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
++ else:
++ import fcntl
++
++ fcntl.flock(handle, fcntl.LOCK_EX)
++ try:
++ yield
++ finally:
++ fcntl.flock(handle, fcntl.LOCK_UN)
++ else:
++ raise TypeError("Lifecycle requires a persistent metadata store with control locks")
++
++
++def serialized_profile_write(method: Callable) -> Callable:
++ @wraps(method)
++ def wrapped(self, *args, **kwargs):
++ with control_lock(self.store, "profile-writes"):
++ return method(self, *args, **kwargs)
++
++ return wrapped
+diff --git a/src/memos/api/memory_lifecycle.py b/src/memos/api/memory_lifecycle.py
+new file mode 100644
+--- /dev/null
++++ b/src/memos/api/memory_lifecycle.py
+@@ -0,0 +1,261 @@
++"""Durable, idempotent graph/vector transitions; no best-effort success."""
++
++import hashlib
++import json
++import threading
++
++from collections.abc import Callable
++from datetime import datetime, timezone
++from typing import Any
++
++from memos.api.cloud_store import MetadataStore
++from memos.api.control_lock import control_lock
++from memos.api.memory_state import read_control_node
++from memos.log import get_logger
++
++
++logger = get_logger(__name__)
++INTENT_KIND = "memory_lifecycle"
++
++
++class LifecycleError(ValueError):
++ def __init__(self, code: int, message: str):
++ self.code = code
++ super().__init__(message)
++
++
++def now() -> str:
++ return datetime.now(timezone.utc).isoformat()
++
++
++def operation_key(cube: str, operation_id: str) -> str:
++ return "lifecycle-" + hashlib.sha256(json.dumps([cube, operation_id]).encode()).hexdigest()
++
++
++class MemoryLifecycle:
++ def __init__(self, store: MetadataStore, graph: Any, profiles: Any | None = None) -> None:
++ self.store, self.graph, self.profiles = store, graph, profiles
++
++ def transition(
++ self,
++ *,
++ cube: str,
++ memory_id: str,
++ target_status: str,
++ operation_id: str,
++ kind: str = "graph",
++ ) -> dict[str, Any]:
++ if target_status not in ("deleted", "restore") or kind not in ("graph", "profile"):
++ raise LifecycleError(400, "Unsupported lifecycle transition")
++ key = operation_key(cube, operation_id)
++ with (
++ control_lock(self.store, "operation:" + key),
++ control_lock(self.store, self._resource(cube, memory_id, kind)),
++ ):
++ intent = self.store.get(INTENT_KIND, key)
++ if intent:
++ if (intent["memory_id"], intent["requested_status"], intent["kind"]) != (
++ memory_id,
++ target_status,
++ kind,
++ ):
++ raise LifecycleError(409, "Operation ID already used for different arguments")
++ return (
++ self._apply(intent) if intent["state"] != "confirmed" else self._public(intent)
++ )
++ # Complete older writes before admitting the opposite operation.
++ for pending in self.store.list(INTENT_KIND, user_name=cube):
++ if (
++ pending["memory_id"] == memory_id
++ and pending["kind"] == kind
++ and pending["state"] != "confirmed"
++ ):
++ result = self._apply(pending)
++ if not result["success"]:
++ raise LifecycleError(409, "Previous transition is still pending")
++ if kind == "profile":
++ current = self.profiles.lifecycle_field(cube, memory_id)
++ metadata = current[1] if current else None
++ else:
++ node = read_control_node(self.graph, memory_id, cube)
++ metadata = node.get("metadata") if node else None
++ if metadata is None or (kind == "graph" and metadata.get("user_name") != cube):
++ raise LifecycleError(404, "Memory not found in this cube")
++ status = metadata.get("status", "activated" if kind == "profile" else None)
++ if target_status == "deleted":
++ if status != "activated":
++ raise LifecycleError(409, "Only activated memories may be deleted")
++ target, previous = "deleted", status
++ else:
++ previous = metadata.get("previous_status")
++ if status != "deleted" or previous != "activated":
++ raise LifecycleError(409, "Memory is not recoverable")
++ target = previous
++ intent = {
++ "entity_id": key,
++ "user_name": cube,
++ "operation_id": operation_id,
++ "memory_id": memory_id,
++ "kind": kind,
++ "requested_status": target_status,
++ "target_status": target,
++ "previous_status": previous,
++ "state": "pending",
++ "phase": "prepared",
++ "attempts": 0,
++ "created_at": now(),
++ "updated_at": now(),
++ }
++ self.store.put(INTENT_KIND, intent) # Must succeed BEFORE changing memory.
++ return self._apply(intent)
++
++ @staticmethod
++ def _resource(cube, memory_id, kind):
++ # Profile writers and transitions serialize complete instance documents.
++ return "profile-writes" if kind == "profile" else "memory:" + json.dumps([cube, memory_id])
++
++ def _apply(self, intent):
++ if intent["state"] == "confirmed":
++ return self._public(intent)
++ intent["attempts"] += 1
++ intent["updated_at"] = now()
++ try:
++ cube, identifier, target = (
++ intent["user_name"],
++ intent["memory_id"],
++ intent["target_status"],
++ )
++ fields = {
++ "status": target,
++ "previous_status": intent["previous_status"],
++ "deleted_at": intent["created_at"] if target == "deleted" else "",
++ "lifecycle_operation_id": intent["operation_id"],
++ }
++ if intent["kind"] == "profile":
++ self.profiles.apply_field_lifecycle(cube, identifier, fields)
++ found = self.profiles.lifecycle_field(cube, identifier)
++ if not found or found[1].get("status") != target:
++ raise OSError("Profile status verification failed")
++ intent["vector_state"] = "not_applicable"
++ else:
++ node = read_control_node(self.graph, identifier, cube)
++ if not node or node["metadata"].get("user_name") != cube:
++ raise OSError("Memory vanished during transition")
++ self._write_graph(identifier, cube, fields)
++ intent["phase"] = "graph_written"
++ self.store.put(INTENT_KIND, intent)
++ vector = getattr(self.graph, "vec_db", None)
++ if vector is None:
++ raise OSError("Vector backend cannot be verified")
++ point = vector.get_by_id(identifier) # Exceptions are NOT absence.
++ if point is None:
++ if node["metadata"].get("vector_sync") != "not_indexed":
++ raise OSError("Indexed vector point missing or indexing state unknown")
++ intent["vector_state"] = "explicitly_not_indexed"
++ else:
++ payload = point.payload or {}
++ if payload.get("user_name") != cube:
++ raise OSError("Vector ownership mismatch")
++ vector.update(identifier, {"id": identifier, "payload": fields})
++ verified = vector.get_by_id(identifier)
++ if (
++ not verified
++ or (verified.payload or {}).get("status") != target
++ or (verified.payload or {}).get("user_name") != cube
++ ):
++ raise OSError("Vector status verification failed")
++ intent["vector_state"] = "confirmed"
++ current = read_control_node(self.graph, identifier, cube)
++ if not current or current["metadata"].get("status") != target:
++ raise OSError("Graph status verification failed")
++ intent["state"], intent["phase"] = "confirmed", "confirmed"
++ intent.pop("last_error", None)
++ except Exception as exc:
++ intent["state"] = "pending"
++ intent["last_error"] = type(exc).__name__ # Never persist bodies/credentials.
++ logger.warning("Lifecycle transition pending (%s)", type(exc).__name__)
++ self.store.put(INTENT_KIND, intent)
++ return self._public(intent)
++
++ def get_operation(self, cube: str, operation_id: str) -> dict[str, Any] | None:
++ record = self.store.get(INTENT_KIND, operation_key(cube, operation_id))
++ return self._public(record) if record and record.get("user_name") == cube else None
++
++ def _write_graph(self, identifier, cube, fields):
++ driver = getattr(self.graph, "driver", None)
++ if driver is None:
++ # Backend-independent test doubles; production deployment uses Neo4j.
++ self.graph.update_node(identifier, dict(fields), user_name=cube)
++ return
++ # Do not invoke update_node's best-effort vector side effect: ownership
++ # and vector confirmation belong to this controlled transition.
++ with driver.session(database=self.graph.db_name) as session:
++ record = session.run(
++ "MATCH (n:Memory {id:$id, user_name:$cube}) SET n += $fields RETURN n.id AS id",
++ id=identifier,
++ cube=cube,
++ fields=fields,
++ ).single()
++ if record is None:
++ raise OSError("Graph lifecycle target not found")
++
++ @staticmethod
++ def _public(intent):
++ return {
++ **{
++ k: intent[k]
++ for k in (
++ "operation_id",
++ "memory_id",
++ "kind",
++ "target_status",
++ "state",
++ "phase",
++ "attempts",
++ )
++ },
++ "success": intent["state"] == "confirmed",
++ "retryable": intent["state"] != "confirmed",
++ "vector_state": intent.get("vector_state", "unconfirmed"),
++ }
++
++ def reconcile(self) -> int:
++ completed = 0
++ for intent in self.store.list(INTENT_KIND):
++ if intent["state"] == "confirmed":
++ continue
++ try:
++ with control_lock(
++ self.store,
++ self._resource(intent["user_name"], intent["memory_id"], intent["kind"]),
++ ):
++ current = self.store.get(INTENT_KIND, intent["entity_id"])
++ if current and current["state"] != "confirmed":
++ completed += int(self._apply(current)["success"])
++ except Exception as exc:
++ logger.warning("Lifecycle reconciliation deferred (%s)", type(exc).__name__)
++ return completed
++
++
++class LifecycleWorker:
++ def __init__(self, factory: Callable[[], MemoryLifecycle], interval: float = 5) -> None:
++ self.factory, self.interval = factory, interval
++ self.stop_event = threading.Event()
++ self.thread = None
++
++ def start(self) -> None:
++ def run():
++ while not self.stop_event.is_set():
++ try:
++ self.factory().reconcile()
++ except Exception as exc:
++ logger.warning("Lifecycle worker deferred (%s)", type(exc).__name__)
++ self.stop_event.wait(self.interval)
++
++ self.thread = threading.Thread(target=run, name="memory-lifecycle-reconcile", daemon=True)
++ self.thread.start()
++
++ def stop(self) -> None:
++ self.stop_event.set()
++ if self.thread:
++ self.thread.join(timeout=10)
+diff --git a/src/memos/api/memory_lifecycle_routes.py b/src/memos/api/memory_lifecycle_routes.py
+new file mode 100644
+--- /dev/null
++++ b/src/memos/api/memory_lifecycle_routes.py
+@@ -0,0 +1,90 @@
++"""Trusted control-plane lifecycle routes, separate from ordinary read APIs."""
++
++from collections.abc import Callable
++from typing import Literal
++
++from fastapi import APIRouter
++from pydantic import BaseModel, ConfigDict, Field
++
++from memos.api.memory_lifecycle import LifecycleError, MemoryLifecycle
++from memos.api.memory_state import read_control_node
++from memos.api.product_models import GetMemoryResponse
++
++
++class OperationRequest(BaseModel):
++ model_config = ConfigDict(extra="forbid")
++ mem_cube_id: str = Field(min_length=1, max_length=128, pattern=r"^[A-Za-z0-9_.-]+$")
++ operation_id: str = Field(min_length=1, max_length=128, pattern=r"^[A-Za-z0-9_.-]+$")
++
++
++class LifecycleProbeRequest(BaseModel):
++ model_config = ConfigDict(extra="forbid")
++ mem_cube_id: str = Field(min_length=1, max_length=128, pattern=r"^[A-Za-z0-9_.-]+$")
++ memory_id: str = Field(min_length=1, max_length=1024, pattern=r"^[^\x00-\x1f\x7f]+$")
++ kind: Literal["graph", "profile"] = "graph"
++
++
++class TransitionMemoryRequest(LifecycleProbeRequest):
++ target_status: Literal["deleted", "restore"]
++ operation_id: str = Field(min_length=1, max_length=128, pattern=r"^[A-Za-z0-9_.-]+$")
++
++
++def create_lifecycle_router(factory: Callable[[], MemoryLifecycle]) -> APIRouter:
++ router = APIRouter()
++
++ def execute(request, kind):
++ if request.kind != kind:
++ return GetMemoryResponse(
++ code=400, message="Lifecycle kind does not match route", data=None
++ )
++ try:
++ result = factory().transition(
++ cube=request.mem_cube_id,
++ memory_id=request.memory_id,
++ operation_id=request.operation_id,
++ target_status=request.target_status,
++ kind=kind,
++ )
++ return GetMemoryResponse(message="Lifecycle transition evaluated", data=result)
++ except LifecycleError as exc:
++ return GetMemoryResponse(code=exc.code, message=str(exc), data=None)
++
++ @router.post("/transition_memory", response_model=GetMemoryResponse)
++ def transition_memory(request: TransitionMemoryRequest):
++ return execute(request, "graph")
++
++ @router.post("/transition_profile_memory", response_model=GetMemoryResponse)
++ def transition_profile(request: TransitionMemoryRequest):
++ return execute(request, "profile")
++
++ @router.post("/inspect_memory_lifecycle", response_model=GetMemoryResponse)
++ def inspect(request: LifecycleProbeRequest):
++ service = factory()
++ if request.kind == "profile":
++ found = service.profiles.lifecycle_field(request.mem_cube_id, request.memory_id)
++ metadata = {**found[1], "memory_type": "ProfileMemory"} if found else None
++ else:
++ node = read_control_node(service.graph, request.memory_id, request.mem_cube_id)
++ metadata = node.get("metadata") if node else None
++ if metadata and metadata.get("user_name") != request.mem_cube_id:
++ metadata = None
++ if metadata is None:
++ return GetMemoryResponse(code=404, message="Memory not found", data=None)
++ return GetMemoryResponse(
++ message="Lifecycle state inspected",
++ data={
++ "memory_id": request.memory_id,
++ "status": metadata.get("status", "activated"),
++ "previous_status": metadata.get("previous_status"),
++ "memory_type": metadata.get("memory_type"),
++ },
++ )
++
++ @router.post("/get_memory_operation", response_model=GetMemoryResponse)
++ def operation(request: OperationRequest):
++ return GetMemoryResponse(
++ message="Lifecycle operation retrieved",
++ data=factory().get_operation(request.mem_cube_id, request.operation_id),
++ )
++
++ return router
+diff --git a/src/memos/api/memory_state.py b/src/memos/api/memory_state.py
+new file mode 100644
+--- /dev/null
++++ b/src/memos/api/memory_state.py
+@@ -0,0 +1,22 @@
++"""Read authoritative scoped lifecycle fields before lossy provider parsing."""
++
++from typing import Any
++
++
++def read_control_node(graph: Any, identifier: str, cube: str) -> dict | None:
++ driver = getattr(graph, "driver", None)
++ if driver is None:
++ return graph.get_node(identifier, user_name=cube)
++ with driver.session(database=graph.db_name) as session:
++ row = session.run(
++ "MATCH (n:Memory {id:$id, user_name:$cube}) "
++ "RETURN n.id AS id, n.user_name AS user_name, n.status AS status, "
++ "n.memory_type AS memory_type, n.vector_sync AS vector_sync, "
++ "n.previous_status AS previous_status",
++ id=identifier,
++ cube=cube,
++ ).single()
++ if row is None:
++ return None
++ values = dict(row)
++ return {"id": values.pop("id"), "metadata": values}
+diff --git a/src/memos/api/memory_visibility.py b/src/memos/api/memory_visibility.py
+new file mode 100644
+--- /dev/null
++++ b/src/memos/api/memory_visibility.py
+@@ -0,0 +1,75 @@
++"""Revalidate recall against authoritative state, including during vector outage."""
++
++from typing import Any
++
++from memos.api.memory_state import read_control_node
++from memos.memories.textual.two_stage import two_stage_enabled
++
++
++def confirm_current_memories(results: dict | None, graph: Any, profiles: Any) -> dict | None:
++ allowed = {"activated", "resolving"} if two_stage_enabled() else {"activated"}
++ for tab, groups in (results or {}).items():
++ if not isinstance(groups, list):
++ continue
++ for group in groups:
++ if not isinstance(group, dict) or not isinstance(group.get("memories"), list):
++ continue
++ cube = group.get("cube_id")
++ kept = []
++ for memory in group["memories"]:
++ if (
++ not isinstance(memory, dict)
++ or not isinstance(memory.get("id"), str)
++ or not cube
++ ):
++ continue
++ if tab == "profile_mem":
++ current = (
++ profiles.get_field_row(user_name=cube, memory_id=memory["id"])
++ if profiles
++ else None
++ )
++ if current:
++ kept.append(memory)
++ continue
++ # No exception fallback: the API must fail if graph truth cannot be read.
++ node = read_control_node(graph, memory["id"], cube)
++ if not isinstance(node, dict):
++ continue
++ metadata = node.get("metadata") or {}
++ status = metadata.get("status")
++ if metadata.get("user_name") != cube:
++ continue
++ if status in allowed or (
++ tab == "dream_mem" and status in {"completed", "context_only", "skipped"}
++ ):
++ memory.setdefault("metadata", {})["status"] = status
++ kept.append(memory)
++ group["memories"] = kept
++ if "total_nodes" in group:
++ group["total_nodes"] = len(kept)
++ return results
++
++
++def filter_current_subgraph(subgraph: dict, graph: Any, cube: str) -> dict:
++ """Legacy get_all searches also contain graph neighbors and incident edges."""
++ nodes = []
++ for node in subgraph.get("nodes") or []:
++ current = read_control_node(graph, node["id"], cube)
++ metadata = current.get("metadata") if current else None
++ if (
++ metadata
++ and metadata.get("user_name") == cube
++ and metadata.get("status") not in (None, "deleted")
++ ):
++ nodes.append(node)
++ identifiers = {node["id"] for node in nodes}
++ return {
++ "core_id": subgraph.get("core_id") if subgraph.get("core_id") in identifiers else None,
++ "nodes": nodes,
++ "edges": [
++ edge
++ for edge in subgraph.get("edges") or []
++ if edge.get("source") in identifiers and edge.get("target") in identifiers
++ ],
++ }
+diff --git a/src/memos/api/profile_memory_routes.py b/src/memos/api/profile_memory_routes.py
+new file mode 100644
+--- /dev/null
++++ b/src/memos/api/profile_memory_routes.py
+@@ -0,0 +1,52 @@
++"""Read-only profile field routes, testable without initializing a model service."""
++
++from collections.abc import Callable
++
++from fastapi import APIRouter
++
++from memos.api.product_models import (
++ GetMemoryResponse,
++ ProfileMemoryGetRequest,
++ ProfileMemoryListRequest,
++)
++from memos.api.profile import ProfileService
++
++
++def create_profile_memory_router(service_factory: Callable[[], ProfileService]) -> APIRouter:
++ router = APIRouter()
++
++ @router.post("/list_profile_memories", response_model=GetMemoryResponse)
++ def list_profile_memories(request: ProfileMemoryListRequest) -> GetMemoryResponse:
++ rows, total = service_factory().list_field_rows(
++ user_name=request.mem_cube_id,
++ subject_type=request.subject_type,
++ subject_id=request.subject_id,
++ search=request.search,
++ knowledgebase_id=request.knowledgebase_id,
++ start_time=request.start_time.isoformat() if request.start_time else None,
++ end_time=request.end_time.isoformat() if request.end_time else None,
++ end_exclusive=request.end_exclusive,
++ page=request.page,
++ page_size=request.page_size,
++ )
++ return GetMemoryResponse(
++ message="Profile fields retrieved",
++ data={
++ "cube_id": request.mem_cube_id,
++ "memories": rows,
++ "total": total,
++ "page": request.page,
++ "page_size": request.page_size,
++ },
++ )
++
++ @router.post("/get_profile_memory", response_model=GetMemoryResponse)
++ def get_profile_memory(request: ProfileMemoryGetRequest) -> GetMemoryResponse:
++ row = service_factory().get_field_row(
++ user_name=request.mem_cube_id, memory_id=request.memory_id
++ )
++ return GetMemoryResponse(
++ message="Profile field retrieved" if row else "Not found", data=row
++ )
++
++ return router
+diff --git a/tests/api/test_lifecycle_routes.py b/tests/api/test_lifecycle_routes.py
+new file mode 100644
+--- /dev/null
++++ b/tests/api/test_lifecycle_routes.py
+@@ -0,0 +1,49 @@
++from fastapi import FastAPI
++from fastapi.testclient import TestClient
++from test_memory_lifecycle import service
++
++
++def test_routes_transition_probe_restore_operation_and_errors(tmp_path):
++ from memos.api.memory_lifecycle_routes import create_lifecycle_router
++
++ svc = service(tmp_path)
++ app = FastAPI()
++ app.include_router(create_lifecycle_router(lambda: svc), prefix="/product")
++ with TestClient(app) as client:
++ base = {"mem_cube_id": "a", "memory_id": "m1", "kind": "graph"}
++ assert (
++ client.post("/product/inspect_memory_lifecycle", json=base).json()["data"]["status"]
++ == "activated"
++ )
++ request = {**base, "operation_id": "route-delete", "target_status": "deleted"}
++ response = client.post("/product/transition_memory", json=request)
++ assert response.json()["code"] == 200 and response.json()["data"]["success"]
++ assert (
++ client.post(
++ "/product/get_memory_operation",
++ json={"mem_cube_id": "a", "operation_id": "route-delete"},
++ ).json()["data"]["state"]
++ == "confirmed"
++ )
++ assert (
++ client.post(
++ "/product/transition_memory", json={**request, "target_status": "restore"}
++ ).json()["code"]
++ == 409
++ )
++ assert client.post(
++ "/product/transition_memory",
++ json={**request, "operation_id": "restore", "target_status": "restore"},
++ ).json()["data"]["success"]
++ assert (
++ client.post(
++ "/product/inspect_memory_lifecycle", json={**base, "mem_cube_id": "b"}
++ ).json()["code"]
++ == 404
++ )
++ assert (
++ client.post(
++ "/product/transition_memory", json={**request, "operation_id": "../bad"}
++ ).status_code
++ == 422
++ )
+diff --git a/tests/api/test_lifecycle_visibility.py b/tests/api/test_lifecycle_visibility.py
+new file mode 100644
+--- /dev/null
++++ b/tests/api/test_lifecycle_visibility.py
+@@ -0,0 +1,144 @@
++from types import SimpleNamespace
++
++import pytest
++
++
++def test_control_read_uses_scoped_driver_when_community_parser_drops_tenant():
++ from memos.api.memory_state import read_control_node
++
++ class Session:
++ def __enter__(self):
++ return self
++
++ def __exit__(self, *args):
++ pass
++
++ def run(self, query, **params):
++ assert params == {"id": "m", "cube": "a"}
++ assert "user_name:$cube" in query
++ return SimpleNamespace(
++ single=lambda: {
++ "id": "m",
++ "user_name": "a",
++ "status": "deleted",
++ "memory_type": "UserMemory",
++ "vector_sync": "success",
++ "previous_status": "activated",
++ }
++ )
++
++ graph = SimpleNamespace(
++ driver=SimpleNamespace(session=lambda **kwargs: Session()), db_name="neo4j"
++ )
++ assert read_control_node(graph, "m", "a")["metadata"]["user_name"] == "a"
++
++
++def test_ordinary_single_and_batch_rest_hide_tombstones_and_propagate_backend_outage():
++ from memos.api.handlers.memory_handler import handle_get_memory, handle_get_memory_by_ids
++
++ row = {"id": "m", "memory": "test", "metadata": {"status": "deleted"}}
++
++ class Text:
++ def get(self, *args, **kwargs):
++ return SimpleNamespace(model_dump=lambda: row)
++
++ def get_by_ids(self, **kwargs):
++ return [row]
++
++ cube = SimpleNamespace(text_mem=Text())
++ assert handle_get_memory("m", cube, "a").data is None
++ assert handle_get_memory_by_ids(["m"], cube, "a").data["memories"] == []
++
++ def offline(*args, **kwargs):
++ raise ConnectionError("graph offline")
++
++ cube.text_mem.get = offline
++ with pytest.raises(ConnectionError):
++ handle_get_memory("m", cube, "a")
++
++
++def test_search_confirms_current_graph_state_instead_of_trusting_vector_payload():
++ from memos.api.memory_visibility import confirm_current_memories
++
++ row = {"id": "m", "memory": "stale-vector", "metadata": {"status": "activated"}}
++
++ class Graph:
++ def get_node(self, identifier, **kwargs):
++ assert kwargs["user_name"] == "a"
++ return {"id": "m", "metadata": {"user_name": "a", "status": "deleted"}}
++
++ result = {"text_mem": [{"cube_id": "a", "memories": [row]}]}
++ assert confirm_current_memories(result, Graph(), None)["text_mem"][0]["memories"] == []
++
++
++def test_search_graph_failure_fails_closed():
++ from memos.api.memory_visibility import confirm_current_memories
++
++ class Graph:
++ def get_node(self, *args, **kwargs):
++ raise OSError("graph unavailable")
++
++ with pytest.raises(OSError):
++ confirm_current_memories(
++ {"text_mem": [{"cube_id": "a", "memories": [{"id": "m"}]}]}, Graph(), None
++ )
++
++
++@pytest.mark.parametrize("view", ["graph", "event"])
++def test_graph_and_event_public_lists_exclude_explicit_deleted_filter(view):
++ from memos.api.handlers.memory_handler import handle_get_event_memories, handle_get_memory_graph
++ from memos.api.product_models import GetEventMemoriesRequest, GetMemoryGraphRequest
++
++ class Text:
++ def get_all(self, **kwargs):
++ assert "deleted" not in (kwargs.get("status") or [])
++ return {"nodes": [], "edges": [], "total_nodes": 0}
++
++ cube = SimpleNamespace(text_mem=Text())
++ if view == "graph":
++ response = handle_get_memory_graph(
++ GetMemoryGraphRequest(mem_cube_id="a", status=["deleted"]), cube
++ )
++ assert response.data["nodes"] == []
++ else:
++ response = handle_get_event_memories(
++ GetEventMemoriesRequest(mem_cube_id="a", status=["deleted"]), cube
++ )
++ assert response.data["memories"] == []
++
++
++def test_legacy_subgraph_read_drops_deleted_neighbor_and_incident_edge(monkeypatch):
++ from memos.api.handlers import memory_handler
++
++ active = {"id": "a", "memory": "active", "metadata": {"status": "activated"}}
++ deleted = {"id": "b", "memory": "deleted-secret", "metadata": {"status": "deleted"}}
++ raw = {"nodes": [active, deleted], "edges": [{"source": "a", "target": "b", "type": "PARENT"}]}
++ captured = []
++ monkeypatch.setattr(
++ memory_handler,
++ "convert_graph_to_tree_forworkmem",
++ lambda graph, **kwargs: (captured.append(graph) or {"children": []}, {}),
++ )
++ monkeypatch.setattr(memory_handler, "ensure_unique_tree_ids", lambda tree: tree)
++ monkeypatch.setattr(memory_handler, "filter_nodes_by_tree_ids", lambda tree, graph: graph)
++
++ class Text:
++ graph_store = SimpleNamespace(
++ get_node=lambda identifier, **kwargs: {
++ "id": identifier,
++ "metadata": {
++ "user_name": "tenant",
++ "status": "activated" if identifier == "a" else "deleted",
++ },
++ }
++ )
++
++ def get_relevant_subgraph(self, *args, **kwargs):
++ return raw
++
++ result = memory_handler.handle_get_subgraph(
++ "u", "tenant", "active", 10, SimpleNamespace(text_mem=Text()), "fulltext"
++ )
++ assert all(node["id"] != "b" for node in captured[0]["nodes"])
++ assert captured[0]["edges"] == []
++ assert "deleted-secret" not in str(result.data)
+diff --git a/tests/api/test_memory_lifecycle.py b/tests/api/test_memory_lifecycle.py
+new file mode 100644
+--- /dev/null
++++ b/tests/api/test_memory_lifecycle.py
+@@ -0,0 +1,198 @@
++import copy
++import threading
++
++from concurrent.futures import ThreadPoolExecutor
++from types import SimpleNamespace
++
++import pytest
++
++from memos.api.cloud_store import JsonMetadataStore
++
++
++class Graph:
++ def __init__(self):
++ self.rows = {
++ "m1": {
++ "id": "m1",
++ "memory": "test-only",
++ "metadata": {"user_name": "a", "status": "activated", "vector_sync": "success"},
++ }
++ }
++ self.vec_db = Vector()
++ self.crash = False
++ self.writes = 0
++
++ def get_node(self, identifier, **kwargs):
++ row = self.rows.get(identifier)
++ return (
++ copy.deepcopy(row)
++ if row and row["metadata"]["user_name"] == kwargs.get("user_name")
++ else None
++ )
++
++ def update_node(self, identifier, fields, **kwargs):
++ self.rows[identifier]["metadata"].update(fields)
++ self.writes += 1
++ if self.crash:
++ raise SystemExit("simulated process interruption after graph commit")
++
++
++class Vector:
++ def __init__(self):
++ self.payload = {"status": "activated", "user_name": "a"}
++ self.offline = False
++ self.ignore_write = False
++
++ def get_by_id(self, identifier):
++ if self.offline:
++ raise OSError("vector unavailable")
++ return (
++ SimpleNamespace(payload=copy.deepcopy(self.payload))
++ if self.payload is not None
++ else None
++ )
++
++ def update(self, identifier, data):
++ if self.offline:
++ raise OSError("vector unavailable")
++ if not self.ignore_write:
++ self.payload.update(data["payload"])
++
++
++def service(tmp_path, graph=None):
++ from memos.api.memory_lifecycle import MemoryLifecycle
++
++ return MemoryLifecycle(JsonMetadataStore(tmp_path), graph or Graph())
++
++
++def transition(svc, target="deleted", op="delete-1", cube="a", identifier="m1"):
++ return svc.transition(cube=cube, memory_id=identifier, target_status=target, operation_id=op)
++
++
++def test_delete_restore_confirm_both_and_replay_does_not_undo_later_restore(tmp_path):
++ svc = service(tmp_path)
++ result = transition(svc)
++ assert result["state"] == "confirmed" and result["success"] is True
++ assert (
++ svc.graph.rows["m1"]["metadata"]["status"]
++ == svc.graph.vec_db.payload["status"]
++ == "deleted"
++ )
++ restored = transition(svc, "restore", "restore-1")
++ assert restored["target_status"] == "activated" and restored["state"] == "confirmed"
++ assert transition(svc)["state"] == "confirmed"
++ assert svc.graph.rows["m1"]["metadata"]["status"] == "activated"
++
++
++@pytest.mark.parametrize("failure", ["offline", "ignore_write", "missing"])
++def test_vector_failure_never_succeeds_and_restart_reconciles(tmp_path, failure):
++ graph = Graph()
++ if failure == "missing":
++ graph.vec_db.payload = None
++ else:
++ setattr(graph.vec_db, failure, True)
++ svc = service(tmp_path, graph)
++ result = transition(svc)
++ assert result["state"] == "pending" and result["success"] is False
++ assert graph.rows["m1"]["metadata"]["status"] == "deleted"
++ graph.vec_db.offline = graph.vec_db.ignore_write = False
++ graph.vec_db.payload = {"status": "activated", "user_name": "a"}
++ restarted = service(tmp_path, graph)
++ assert restarted.reconcile() == 1
++ assert transition(restarted)["state"] == "confirmed"
++ assert graph.vec_db.payload["status"] == "deleted"
++
++
++def test_crash_after_graph_commit_is_recoverable_and_opposite_operation_drains_first(tmp_path):
++ graph = Graph()
++ graph.crash = True
++ svc = service(tmp_path, graph)
++ with pytest.raises(SystemExit):
++ transition(svc)
++ graph.crash = False
++ restarted = service(tmp_path, graph)
++ result = transition(restarted, "restore", "restore-after-crash")
++ assert result["state"] == "confirmed"
++ assert graph.rows["m1"]["metadata"]["status"] == graph.vec_db.payload["status"] == "activated"
++
++
++def test_no_graph_mutation_when_intent_cannot_be_persisted(tmp_path, monkeypatch):
++ svc = service(tmp_path)
++
++ def fail(*args):
++ raise OSError("metadata unavailable")
++
++ monkeypatch.setattr(svc.store, "put", fail)
++ with pytest.raises(OSError):
++ transition(svc)
++ assert svc.graph.writes == 0
++
++
++def test_scope_frozen_and_conflicting_operation_id(tmp_path):
++ from memos.api.memory_lifecycle import LifecycleError
++
++ svc = service(tmp_path)
++ with pytest.raises(LifecycleError) as error:
++ transition(svc, cube="b")
++ assert error.value.code == 404
++ svc.graph.rows["m1"]["metadata"]["status"] = "frozen"
++ with pytest.raises(LifecycleError) as error:
++ transition(svc)
++ assert error.value.code == 409
++ svc.graph.rows["m1"]["metadata"]["status"] = "activated"
++ transition(svc)
++ with pytest.raises(LifecycleError) as error:
++ transition(svc, "restore", "delete-1")
++ assert error.value.code == 409
++
++
++def test_opposite_transitions_serialize_across_service_instances(tmp_path):
++ graph = Graph()
++ first, second = service(tmp_path, graph), service(tmp_path, graph)
++ began, release = threading.Event(), threading.Event()
++ original = graph.vec_db.update
++
++ def wait(identifier, data):
++ if data["payload"]["status"] == "deleted":
++ began.set()
++ assert release.wait(5)
++ original(identifier, data)
++
++ graph.vec_db.update = wait
++ with ThreadPoolExecutor(2) as pool:
++ deleted = pool.submit(transition, first)
++ assert began.wait(5)
++ restored = pool.submit(transition, second, "restore", "opposite")
++ release.set()
++ assert deleted.result()["state"] == restored.result()["state"] == "confirmed"
++ assert graph.rows["m1"]["metadata"]["status"] == graph.vec_db.payload["status"] == "activated"
++
++
++def test_explicit_never_indexed_graph_only_record(tmp_path):
++ svc = service(tmp_path)
++ svc.graph.vec_db.payload = None
++ svc.graph.rows["m1"]["metadata"]["vector_sync"] = "not_indexed"
++ assert transition(svc)["state"] == "confirmed"
++
++
++def test_running_worker_retries_without_service_restart(tmp_path):
++ import time
++
++ from memos.api.memory_lifecycle import LifecycleWorker
++
++ svc = service(tmp_path)
++ svc.graph.vec_db.offline = True
++ assert transition(svc)["state"] == "pending"
++ worker = LifecycleWorker(lambda: svc, interval=0.02)
++ worker.start()
++ try:
++ svc.graph.vec_db.offline = False
++ deadline = time.monotonic() + 3
++ while time.monotonic() < deadline:
++ result = svc.get_operation("a", "delete-1")
++ if result["state"] == "confirmed":
++ break
++ time.sleep(0.02)
++ assert result["state"] == "confirmed" and result["attempts"] >= 2
++ finally:
++ worker.stop()
+diff --git a/tests/api/test_profile_lifecycle.py b/tests/api/test_profile_lifecycle.py
+new file mode 100644
+--- /dev/null
++++ b/tests/api/test_profile_lifecycle.py
+@@ -0,0 +1,96 @@
++from memos.api.cloud_store import JsonMetadataStore
++from memos.api.profile import PROFILE_INSTANCE_KIND, ProfileService
++
++
++def fixture(tmp_path):
++ svc = ProfileService(JsonMetadataStore(tmp_path))
++ template = svc.add_template(
++ user_name="a",
++ name="test",
++ metadata={"basic": {"user_name": {"value": None}, "城市": {"value": None}}},
++ )
++ instance = svc.bind(
++ user_name="a", bind_list=[{"user_id": "u", "profile_template_id": template["entity_id"]}]
++ )[0]
++ svc.edit(
++ user_name="a",
++ template_id=template["entity_id"],
++ user_id="u",
++ metadata={"basic.user_name": {"value": "original"}, "basic.城市": {"value": "杭州"}},
++ )
++ return svc, template["entity_id"], instance["id"]
++
++
++def test_delete_one_field_hides_all_views_and_restores_original_after_updates_and_restart(tmp_path):
++ from memos.api.memory_lifecycle import MemoryLifecycle
++
++ profile, template, instance = fixture(tmp_path)
++ lifecycle = MemoryLifecycle(profile.store, None, profile)
++ identifier = instance + "_basic.user_name"
++ result = lifecycle.transition(
++ cube="a",
++ memory_id=identifier,
++ target_status="deleted",
++ operation_id="delete",
++ kind="profile",
++ )
++ assert result["success"] is True and result["vector_state"] == "not_applicable"
++ assert profile.get_field_row(user_name="a", memory_id=identifier) is None
++ assert profile.list_field_rows(user_name="a")[1] == 1
++ assert len(profile.recall(user_name="a", user_id="u")) == 1
++ assert "basic.user_name" not in profile.query(user_name="a", user_id="u")[0]["metadata"]
++ profile.edit(
++ user_name="a",
++ template_id=template,
++ user_id="u",
++ metadata={"basic.user_name": {"value": "must-not-overwrite"}},
++ )
++
++ class LLM:
++ def generate(self, *args, **kwargs):
++ return '{"basic.user_name":"must-not-overwrite"}'
++
++ profile.extract_and_update(user_name="a", user_id="u", llm=LLM(), conversation="test")
++ profile.update_template(
++ user_name="a",
++ template_id=template,
++ metadata={"basic": {"user_name": {"value": "template-new"}, "城市": {"value": None}}},
++ )
++ raw = profile.store.get(PROFILE_INSTANCE_KIND, instance)
++ assert raw["fields"]["basic.user_name"]["status"] == "deleted"
++ assert raw["fields"]["basic.user_name"]["value"] == "original"
++ restarted = ProfileService(JsonMetadataStore(tmp_path))
++ assert restarted.get_field_row(user_name="a", memory_id=identifier) is None
++ restored = MemoryLifecycle(restarted.store, None, restarted).transition(
++ cube="a",
++ memory_id=identifier,
++ target_status="restore",
++ operation_id="restore",
++ kind="profile",
++ )
++ assert restored["success"] is True
++ assert (
++ restarted.get_field_row(user_name="a", memory_id=identifier)["memory"]
++ == "basic.user_name: original"
++ )
++ assert (
++ restarted.get_field_row(user_name="a", memory_id=instance + "_basic.城市")["memory"]
++ == "basic.城市: 杭州"
++ )
++
++
++def test_profile_transition_cross_tenant_rejected(tmp_path):
++ import pytest
++
++ from memos.api.memory_lifecycle import LifecycleError, MemoryLifecycle
++
++ profile, _, instance = fixture(tmp_path)
++ with pytest.raises(LifecycleError) as exc:
++ MemoryLifecycle(profile.store, None, profile).transition(
++ cube="b",
++ memory_id=instance + "_basic.user_name",
++ target_status="deleted",
++ operation_id="cross",
++ kind="profile",
++ )
++ assert exc.value.code == 404
+diff --git a/tests/api/test_profile_memory_routes.py b/tests/api/test_profile_memory_routes.py
+new file mode 100644
+--- /dev/null
++++ b/tests/api/test_profile_memory_routes.py
+@@ -0,0 +1,60 @@
++import importlib
++
++from fastapi import FastAPI
++from fastapi.testclient import TestClient
++
++from memos.api.cloud_store import JsonMetadataStore
++from memos.api.profile import PROFILE_INSTANCE_KIND, ProfileService
++
++
++def test_profile_read_routes_have_tenant_scoped_envelopes(tmp_path):
++ module = importlib.import_module("memos.api.profile_memory_routes")
++ service = ProfileService(JsonMetadataStore(tmp_path))
++ identifier = "profile_" + "a" * 32
++ service.store.put(
++ PROFILE_INSTANCE_KIND,
++ {
++ "entity_id": identifier,
++ "user_name": "cube-a",
++ "status": "bound",
++ "subject_type": "user",
++ "subject_id": "u1",
++ "template_id": "t1",
++ "created_at": "2026-01-01T00:00:00Z",
++ "fields": {"name_中文": {"value": "Marker"}},
++ },
++ )
++ app = FastAPI()
++ app.include_router(module.create_profile_memory_router(lambda: service), prefix="/product")
++ with TestClient(app) as client:
++ response = client.post("/product/list_profile_memories", json={"mem_cube_id": "cube-a"})
++ assert response.status_code == 200
++ data = response.json()["data"]
++ assert response.json()["code"] == 200
++ assert data["total"] == 1 and data["cube_id"] == "cube-a"
++ row = data["memories"][0]
++ response = client.post(
++ "/product/get_profile_memory", json={"mem_cube_id": "cube-a", "memory_id": row["id"]}
++ )
++ assert response.json()["data"] == row
++ assert (
++ client.post(
++ "/product/get_profile_memory",
++ json={"mem_cube_id": "cube-b", "memory_id": row["id"]},
++ ).json()["data"]
++ is None
++ )
++ assert client.post("/product/list_profile_memories", json={}).status_code == 422
++ assert (
++ client.post(
++ "/product/list_profile_memories", json={"mem_cube_id": "cube-a", "page": 0}
++ ).status_code
++ == 422
++ )
++ assert (
++ client.post(
++ "/product/list_profile_memories",
++ json={"mem_cube_id": "cube-a", "start_time": "2026-01-01T00:00:00"},
++ ).status_code
++ == 422
++ )
+diff --git a/tests/api/test_profile_memory_rows.py b/tests/api/test_profile_memory_rows.py
+new file mode 100644
+--- /dev/null
++++ b/tests/api/test_profile_memory_rows.py
+@@ -0,0 +1,79 @@
++from memos.api.cloud_store import JsonMetadataStore
++from memos.api.profile import PROFILE_INSTANCE_KIND, ProfileService
++
++
++def fixture_service(tmp_path):
++ store = JsonMetadataStore(tmp_path)
++ service = ProfileService(store)
++ ids = []
++ for n, (tenant, kind) in enumerate([("a", "user"), ("a", "agent"), ("b", "user")]):
++ identifier = "profile_" + f"{n:032x}"
++ ids.append(identifier)
++ store.put(
++ PROFILE_INSTANCE_KIND,
++ {
++ "entity_id": identifier,
++ "user_name": tenant,
++ "subject_type": kind,
++ "subject_id": f"subject-{n}",
++ "status": "bound",
++ "template_id": "template-1",
++ "created_at": f"2026-01-0{n + 1}T00:00:00+00:00",
++ "updated_at": "2026-02-01T00:00:00+00:00",
++ "fields": {
++ "basic.user_name": {"value": f"Marker-{n}"},
++ "城市": {"value": "杭州"},
++ "empty": {"value": None},
++ "old": {"value": "hidden", "deprecated": True},
++ "deleted": {"value": "hidden", "status": "deleted"},
++ },
++ },
++ )
++ return service, store, ids
++
++
++def test_project_rows_cover_subjects_paginate_and_keep_stable_ids(tmp_path):
++ service, _, ids = fixture_service(tmp_path)
++ rows, total = service.list_field_rows(user_name="a", page=1, page_size=2)
++ rest, second_total = service.list_field_rows(user_name="a", page=2, page_size=2)
++ assert total == second_total == 4
++ assert len(rows) == len(rest) == 2
++ assert {row["subject_type"] for row in rows + rest} == {"user", "agent"}
++ assert len({row["id"] for row in rows + rest}) == 4
++ assert rows[0]["profile_instance_id"] == ids[1]
++ for row in rows + rest:
++ assert service.get_field_row(user_name="a", memory_id=row["id"]) == row
++ assert service.get_field_row(user_name="b", memory_id=row["id"]) is None
++
++
++def test_profile_filters_before_total_and_paging(tmp_path):
++ service, _, _ = fixture_service(tmp_path)
++ rows, total = service.list_field_rows(
++ user_name="a", subject_type="user", search="marker", page_size=1
++ )
++ assert total == 1 and rows[0]["subject_id"] == "subject-0"
++ assert service.list_field_rows(user_name="a", subject_id="subject-1")[1] == 2
++ assert service.list_field_rows(user_name="a", start_time="2026-01-02T00:00:00+00:00")[1] == 2
++ assert (
++ service.list_field_rows(
++ user_name="a", end_time="2026-01-02T00:00:00+00:00", end_exclusive=True
++ )[1]
++ == 2
++ )
++ assert service.list_field_rows(user_name="a", knowledgebase_id="not-linked")[1] == 0
++ assert service.list_field_rows(user_name="missing")[1] == 0
++
++
++def test_profile_unicode_path_detached_and_invalid_ids(tmp_path):
++ service, store, ids = fixture_service(tmp_path)
++ assert (
++ service.get_field_row(user_name="a", memory_id=ids[0] + "_城市")["memory"] == "城市: 杭州"
++ )
++ assert service.get_field_row(user_name="a", memory_id=ids[0] + "_basic.user_name")
++ for suffix in ["_empty", "_old", "_deleted", "_missing"]:
++ assert service.get_field_row(user_name="a", memory_id=ids[0] + suffix) is None
++ assert service.get_field_row(user_name="a", memory_id="../bad") is None
++ instance = store.get(PROFILE_INSTANCE_KIND, ids[0])
++ instance["status"] = "detached"
++ store.put(PROFILE_INSTANCE_KIND, instance)
++ assert service.get_field_row(user_name="a", memory_id=ids[0] + "_城市") is None
+diff --git a/tests/mem_reader/test_project_extract_runtime.py b/tests/mem_reader/test_project_extract_runtime.py
+new file mode 100644
+--- /dev/null
++++ b/tests/mem_reader/test_project_extract_runtime.py
+@@ -0,0 +1,67 @@
++"""Saved project templates must reach the actual multimodal chat reader."""
++
++from types import SimpleNamespace
++from unittest.mock import Mock
++
++from memos.context.context import ContextThreadPoolExecutor
++from memos.mem_reader.multi_modal_struct import MultiModalStructMemReader
++from memos.mem_reader.prompt_config import use_base_extract_prompt
++
++
++def test_project_chat_template_reaches_worker_without_leaking_to_next_request():
++ reader = object.__new__(MultiModalStructMemReader)
++ reader.config = SimpleNamespace(remove_prompt_example=False)
++ reader.llm = Mock()
++ reader.llm.generate.return_value = '{"memory list": [], "summary": ""}'
++ with ContextThreadPoolExecutor(max_workers=1) as executor:
++ with use_base_extract_prompt("PROJECT_RULE ${conversation} ${custom_tags_prompt}"):
++ executor.submit(reader._get_llm_response, "user: stable policy", ["policy"]).result()
++ configured = reader.llm.generate.call_args.args[0][0]["content"]
++ assert "PROJECT_RULE" in configured
++ assert "user: stable policy" in configured
++ assert "${conversation}" not in configured
++ executor.submit(reader._get_llm_response, "user: another project").result()
++ ordinary = reader.llm.generate.call_args.args[0][0]["content"]
++ assert "PROJECT_RULE" not in ordinary
++ with use_base_extract_prompt("PROJECT_RULE ${conversation}"):
++ executor.submit(reader._get_llm_response, "file content", None, None, "doc").result()
++ assert "PROJECT_RULE" not in reader.llm.generate.call_args.args[0][0]["content"]
++
++
++def test_runtime_accepts_memory_list_alias_used_by_debug_extraction():
++ reader = object.__new__(MultiModalStructMemReader)
++ reader.config = SimpleNamespace(remove_prompt_example=False)
++ reader.llm = Mock()
++ reader.llm.generate.return_value = '{"memory_list": [{"value": "stable fact"}], "summary": ""}'
++ with use_base_extract_prompt("Saved rule ${conversation}"):
++ result = reader._get_llm_response("user: stable fact")
++ assert result["memory list"] == [{"value": "stable fact"}]
++
++
++def test_multi_cube_fanout_loads_each_project_template(tmp_path, monkeypatch):
++ from memos.mem_reader.prompt_config import (
++ current_base_extract_prompt,
++ save_prompt,
++ use_project_extract_prompt,
++ )
++ from memos.multi_mem_cube.composite_cube import CompositeCubeView
++
++ monkeypatch.setenv("MEMOS_PROMPT_CONFIG_DB", str(tmp_path / "prompts.db"))
++ save_prompt("a", "PROJECT_A ${conversation}")
++ save_prompt("b", "PROJECT_B ${conversation}")
++
++ def capture(request):
++ return [{"prompt": current_base_extract_prompt()}]
++
++ views = [
++ SimpleNamespace(cube_id=cube, add_memories=capture, extract_memories=capture)
++ for cube in ("a", "b", "unset")
++ ]
++ composite = CompositeCubeView(cube_views=views, logger=Mock())
++ with use_project_extract_prompt("a"):
++ for method in (composite.add_memories, composite.extract_memories):
++ assert [r["prompt"] for r in method(Mock())] == [
++ "PROJECT_A ${conversation}",
++ "PROJECT_B ${conversation}",
++ None,
++ ]
diff --git a/server/backend/product_client.py b/server/backend/product_client.py
new file mode 100644
index 000000000..351207a73
--- /dev/null
+++ b/server/backend/product_client.py
@@ -0,0 +1,78 @@
+"""Bounded upstream product transport; success codes differ by product API family."""
+
+import asyncio
+import json
+
+import httpx
+
+from .errors import ApiError
+
+
+def malformed():
+ return ApiError(50200, "上游响应不符合约定", status_code=502)
+
+
+class ProductClient:
+ def __init__(self, client):
+ self.client = client
+
+ async def raw(self, path, body=None, *, method="POST", limit=32 * 1024 * 1024, timeout=None):
+ seconds = timeout or self.client.timeout.read
+ try:
+ async with asyncio.timeout(seconds):
+ async with self.client.stream(
+ method, "product/" + path, json=body, timeout=seconds
+ ) as response:
+ if response.status_code in {400, 403, 404, 409, 413, 422}:
+ status = 400 if response.status_code == 422 else response.status_code
+ raise ApiError(
+ {400: 40000, 403: 40003, 404: 40001, 409: 40900, 413: 41300}[status],
+ "上游拒绝该操作",
+ status_code=status,
+ )
+ if response.status_code == 503:
+ raise ApiError(50300, "上游暂不可用", status_code=503)
+ if response.status_code != 200 or response.headers.get(
+ "content-encoding", "identity"
+ ).lower() not in {"", "identity"}:
+ raise malformed()
+ data = bytearray()
+ async for chunk in response.aiter_bytes():
+ if len(data) + len(chunk) > limit:
+ raise malformed()
+ data.extend(chunk)
+ return bytes(data)
+ except (TimeoutError, httpx.TimeoutException) as exc:
+ raise ApiError(50400, "上游操作超时", status_code=504) from exc
+ except httpx.ProtocolError as exc:
+ raise malformed() from exc
+ except httpx.RequestError as exc:
+ raise ApiError(50300, "无法连接上游", status_code=503) from exc
+
+ async def request(self, path, body=None, *, code=0, method="POST", timeout=None):
+ raw = await self.raw(path, body, method=method, timeout=timeout)
+ try:
+ value = json.loads(raw)
+ except (ValueError, RecursionError) as exc:
+ raise malformed() from exc
+ if not isinstance(value, dict) or type(value.get("code")) is not int:
+ raise malformed()
+ result_code = value["code"]
+ if result_code != code:
+ if result_code in {400, 403, 404, 409}:
+ raise ApiError(
+ {400: 40000, 403: 40003, 404: 40001, 409: 40900}[result_code],
+ "上游拒绝该操作",
+ status_code=result_code,
+ )
+ raise malformed()
+ data = value.get("data")
+ if isinstance(data, dict) and (
+ data.get("success") is False
+ or (
+ isinstance(data.get("status"), str)
+ and data["status"] in {"failed", "failure", "error"}
+ )
+ ):
+ raise malformed()
+ return data
diff --git a/server/backend/profile_graph.py b/server/backend/profile_graph.py
new file mode 100644
index 000000000..b1e6747c3
--- /dev/null
+++ b/server/backend/profile_graph.py
@@ -0,0 +1,97 @@
+"""Bounded, read-only profile field projections for the combined memory graph."""
+
+import json
+import re
+
+from .errors import ApiError
+from .identity_store import missing
+from .memories import project_node
+from .product_client import malformed
+
+PROFILE_ID = re.compile(r"(profile_[0-9a-f]{32})_(.+)")
+
+
+def profile_node(row, cube):
+ if not isinstance(row, dict) or row.get("user_name") != cube:
+ raise malformed()
+ item = project_node(row, cube, "profile_mem", detail=True)
+ identifier = item.memoryId
+ match = PROFILE_ID.fullmatch(identifier)
+ if (
+ not match
+ or len(identifier) > 1024
+ or row.get("profile_instance_id") != match[1]
+ or row.get("profile_path") != match[2]
+ ):
+ raise malformed()
+ memory = item.memoryContent
+ return {
+ "id": identifier,
+ "memory": memory[:120],
+ "memoryTruncated": len(memory) > 120,
+ "memoryType": "ProfileMemory",
+ "status": "activated",
+ "createdAt": row["created_at"],
+ "sessionId": "",
+ "userId": item.externalUserId or "",
+ "sourceMemoryIds": [],
+ }
+
+
+async def profile_nodes(upstream, cube, limit):
+ size = max(1, min(limit, 100))
+ nodes, seen = [], set()
+ total = None
+ page = 1
+ while True:
+ data = await upstream.request(
+ "list_profile_memories",
+ {
+ "mem_cube_id": cube,
+ "page": page,
+ "page_size": size,
+ },
+ code=200,
+ )
+ if not isinstance(data, dict) or data.get("cube_id") != cube:
+ raise malformed()
+ rows, count = data.get("memories"), data.get("total")
+ if (
+ not isinstance(rows, list)
+ or type(count) is not int
+ or count < 0
+ or len(rows) != min(size, max(0, count - (page - 1) * size))
+ or (total is not None and total != count)
+ ):
+ raise malformed()
+ total = count
+ for row in rows:
+ node = profile_node(row, cube)
+ if node["id"] in seen:
+ raise malformed()
+ seen.add(node["id"])
+ if len(nodes) < limit:
+ nodes.append(node)
+ if len(nodes) >= min(limit, total):
+ return nodes, total
+ page += 1
+
+
+async def profile_fulltext(upstream, cube, identifier):
+ row = await upstream.request(
+ "get_profile_memory",
+ {
+ "mem_cube_id": cube,
+ "memory_id": identifier,
+ },
+ code=200,
+ )
+ if row is None:
+ raise missing()
+ node = profile_node(row, cube)
+ if node["id"] != identifier:
+ raise malformed()
+ memory = row["memory"]
+ if len(json.dumps({"memory": memory}, ensure_ascii=False).encode()) > 1024 * 1024:
+ raise ApiError(41300, "Memory body exceeds the view limit", status_code=413)
+ return {"id": identifier, "memory": memory}
diff --git a/server/backend/profile_templates.py b/server/backend/profile_templates.py
new file mode 100644
index 000000000..5cb058dee
--- /dev/null
+++ b/server/backend/profile_templates.py
@@ -0,0 +1,266 @@
+"""Dashboard attribute templates backed exclusively by the upstream profile service."""
+
+import json
+
+from fastapi import APIRouter, Query, Request, Response
+from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
+
+from .auth import control_for
+from .kb_models import Strict
+from .kb_store import KnowledgeStore
+from .models import Envelope
+from .product_client import malformed
+from .timefmt import display
+
+router = APIRouter(prefix="/api/dashboard/profileTemplate")
+RESERVED = {"projectId", "templateId", "templateName", "bindingCount", "updatedAt"}
+
+
+def tree(value, *, upstream=False):
+ """The dashboard contract is group -> field -> string/boolean leaf (two key levels)."""
+ if not isinstance(value, dict) or not 1 <= len(value) <= 50:
+ raise ValueError("Expected 1 to 50 groups")
+ count = 0
+ result = {}
+ for group, fields in value.items():
+ if group in RESERVED:
+ raise ValueError("Reserved group name")
+ valid_key(group)
+ if not isinstance(fields, dict) or not fields:
+ raise ValueError("Expected nonempty group")
+ result[group] = {}
+ for name, leaf in fields.items():
+ valid_key(name)
+ if name in {"value", "algorithm_updatable"}:
+ raise ValueError("Ambiguous upstream field name")
+ if not isinstance(leaf, dict) or set(leaf) - {"value", "algorithm_updatable"}:
+ raise ValueError("Invalid field")
+ text = leaf.get("value", "")
+ automatic = leaf.get("algorithm_updatable", True)
+ if upstream and text is None and type(automatic) is bool:
+ # Native null means no default value. The dashboard's optional
+ # string field is omitted, rather than inventing a literal value.
+ result[group][name] = {"algorithm_updatable": automatic}
+ count += 1
+ continue
+ if not isinstance(text, str) or len(text) > 4000 or type(automatic) is not bool:
+ raise ValueError("Expected string value and boolean algorithm_updatable")
+ result[group][name] = {"value": text, "algorithm_updatable": automatic}
+ count += 1
+ if count > 500 or len(json.dumps(result, ensure_ascii=False).encode()) > 128 * 1024:
+ raise ValueError("Template exceeds size limit")
+ return result
+
+
+def valid_key(value):
+ if (
+ not isinstance(value, str)
+ or not value.strip()
+ or len(value) > 128
+ or "." in value
+ or any(ord(c) < 32 for c in value)
+ ):
+ raise ValueError("Invalid template key")
+
+
+class Project(Strict):
+ projectId: str = Field(min_length=1, max_length=128)
+
+
+class Page(Project):
+ pageNum: int = Field(1, ge=1, le=2147483647)
+ pageSize: int = Field(10, ge=1, le=200)
+
+
+class Identifier(Project):
+ templateId: str = Field(min_length=1, max_length=128)
+
+
+class Template(BaseModel):
+ model_config = ConfigDict(extra="allow", strict=True)
+ projectId: str = Field(min_length=1, max_length=128)
+ templateName: str = Field(min_length=1, max_length=128)
+
+ @field_validator("templateName")
+ @classmethod
+ def name(cls, value):
+ value = value.strip()
+ if not value or any(ord(c) < 32 for c in value):
+ raise ValueError("Invalid template name")
+ return value
+
+ @model_validator(mode="after")
+ def groups(self):
+ self.__pydantic_extra__ = tree(self.model_extra)
+ return self
+
+
+class Update(Template):
+ templateId: str = Field(min_length=1, max_length=128)
+
+
+def scope(request, response, project, role="viewer"):
+ cube = KnowledgeStore.project(control_for(request), project, role)
+ response.headers["Cache-Control"] = "no-store"
+ return cube, request.app.state.product
+
+
+def view(row, project, expected=None):
+ if not isinstance(row, dict):
+ raise malformed()
+ identifier = row.get("profile_template_id")
+ name = row.get("name")
+ count = row.get("bound_count")
+ if (
+ not isinstance(identifier, str)
+ or not 1 <= len(identifier) <= 128
+ or (expected and identifier != expected)
+ or not isinstance(name, str)
+ or not 1 <= len(name) <= 128
+ or type(count) is not int
+ or count < 0
+ ):
+ raise malformed()
+ try:
+ deprecated = row.get("deprecated_paths", [])
+ if not isinstance(deprecated, list) or any(not isinstance(p, str) for p in deprecated):
+ raise ValueError("Invalid deprecated paths")
+ # Upstream retains removed fields for bound-instance history; the dashboard
+ # template describes the active fields, not these retained historical values.
+ source = row.get("metadata")
+ if not isinstance(source, dict) or any(not isinstance(v, dict) for v in source.values()):
+ raise ValueError("Invalid template metadata")
+ active = {}
+ for group, fields in source.items():
+ active[group] = {
+ key: leaf for key, leaf in fields.items() if group + "." + key not in deprecated
+ }
+ if not active[group]:
+ del active[group]
+ metadata = tree(active, upstream=True)
+ updated = row.get("updated_at")
+ if updated is not None and not isinstance(updated, str):
+ raise ValueError("Invalid timestamp")
+ updated = display(updated)
+ except (ValueError, OverflowError) as exc:
+ raise malformed() from exc
+ result = {
+ "templateId": identifier,
+ "templateName": name,
+ "projectId": project,
+ "bindingCount": count,
+ **metadata,
+ }
+ if updated is not None:
+ result["updatedAt"] = updated
+ return result
+
+
+async def get(upstream, cube, project, identifier):
+ data = await upstream.request(
+ "get_profile_template",
+ {
+ "mem_cube_id": cube,
+ "profile_template_id": identifier,
+ },
+ )
+ return view(data, project, identifier)
+
+
+@router.post("/list", response_model=Envelope[dict])
+async def listing(body: Page, request: Request, response: Response):
+ cube, upstream = scope(request, response, body.projectId)
+ data = await upstream.request(
+ "list_profile_template",
+ {
+ "mem_cube_id": cube,
+ "page": body.pageNum,
+ "page_size": body.pageSize,
+ },
+ )
+ if not isinstance(data, dict):
+ raise malformed()
+ rows, total = data.get("profile_template_list"), data.get("total")
+ if not isinstance(rows, list) or type(total) is not int or total < 0:
+ raise malformed()
+ expected = min(body.pageSize, max(0, total - (body.pageNum - 1) * body.pageSize))
+ if len(rows) != expected:
+ raise malformed()
+ projected = [view(row, body.projectId) for row in rows]
+ if len({row["templateId"] for row in projected}) != len(projected):
+ raise malformed()
+ return Envelope(
+ data={
+ "list": projected,
+ "total": total,
+ "current": body.pageNum,
+ "size": body.pageSize,
+ "pages": (total + body.pageSize - 1) // body.pageSize,
+ }
+ )
+
+
+@router.get("/detail", response_model=Envelope[dict])
+async def detail(
+ request: Request,
+ response: Response,
+ projectId: str = Query(min_length=1, max_length=128),
+ templateId: str = Query(min_length=1, max_length=128),
+):
+ cube, upstream = scope(request, response, projectId)
+ return Envelope(data=await get(upstream, cube, projectId, templateId))
+
+
+@router.post("/create", response_model=Envelope[dict])
+async def create(body: Template, request: Request, response: Response):
+ cube, upstream = scope(request, response, body.projectId, "owner")
+ data = await upstream.request(
+ "add_profile_template",
+ {
+ "mem_cube_id": cube,
+ "name": body.templateName,
+ "metadata": body.model_extra,
+ },
+ )
+ identifier = data.get("profile_template_id") if isinstance(data, dict) else None
+ if not isinstance(identifier, str) or not 1 <= len(identifier) <= 128:
+ raise malformed()
+ return Envelope(data={"templateId": identifier})
+
+
+@router.post("/update", response_model=Envelope[dict])
+async def update(body: Update, request: Request, response: Response):
+ cube, upstream = scope(request, response, body.projectId, "owner")
+ # Upstream validates tenant ownership atomically with its own mutation lock.
+ data = await upstream.request(
+ "update_profile_template",
+ {
+ "mem_cube_id": cube,
+ "profile_template_id": body.templateId,
+ "name": body.templateName,
+ "metadata": body.model_extra,
+ },
+ )
+ view(data, body.projectId, body.templateId)
+ if data.get("success") is not True:
+ raise malformed()
+ return Envelope(data={})
+
+
+@router.post("/delete", response_model=Envelope[dict])
+async def delete(body: Identifier, request: Request, response: Response):
+ cube, upstream = scope(request, response, body.projectId, "owner")
+ data = await upstream.request(
+ "delete_profile_template",
+ {
+ "mem_cube_id": cube,
+ "profile_template_id": body.templateId,
+ },
+ )
+ if (
+ not isinstance(data, dict)
+ or data.get("success") is not True
+ or data.get("profile_template_id") != body.templateId
+ ):
+ raise malformed()
+ return Envelope(data={})
diff --git a/server/backend/project_context.py b/server/backend/project_context.py
new file mode 100644
index 000000000..745c6ec6b
--- /dev/null
+++ b/server/backend/project_context.py
@@ -0,0 +1,53 @@
+"""Opaque persisted project preference, deliberately separate from future authentication."""
+
+import hashlib
+import re
+import secrets
+import time
+
+COOKIE = "memos_project_context"
+
+
+def context_key(request):
+ token = request.cookies.get(COOKIE, "")
+ return (
+ hashlib.sha256(token.encode()).hexdigest()
+ if re.fullmatch(r"[A-Za-z0-9_-]{43}", token)
+ else None
+ )
+
+
+def ensure_context(request, response):
+ if request.scope.get("identity"):
+ return request.scope["identity"]["session_key"]
+ store = request.app.state.control
+ key = context_key(request)
+ with store.connection() as db:
+ valid = (
+ key
+ and db.execute(
+ "SELECT 1 FROM project_contexts WHERE token_hash=? AND expires_at>?",
+ (key, int(time.time())),
+ ).fetchone()
+ )
+ if valid:
+ return key
+ token = secrets.token_urlsafe(32)
+ key = store.ensure_context(token)
+ response.set_cookie(
+ COOKIE,
+ token,
+ max_age=30 * 86400,
+ httponly=True,
+ samesite="lax",
+ secure=request.url.scheme == "https",
+ path="/",
+ )
+ return key
+
+
+def selected_project(request):
+ from .auth import control_for
+
+ actor = request.scope.get("identity")
+ return control_for(request).current(actor["session_key"] if actor else context_key(request))
diff --git a/server/backend/project_models.py b/server/backend/project_models.py
new file mode 100644
index 000000000..93a907c60
--- /dev/null
+++ b/server/backend/project_models.py
@@ -0,0 +1,42 @@
+"""Writable fields only: clients cannot assign ownership, cube IDs or archive metadata."""
+
+from typing import Literal
+
+from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
+
+
+class ProjectPageRequest(BaseModel):
+ model_config = ConfigDict(extra="forbid", strict=True)
+ pageNum: int = Field(1, ge=1, le=2147483647)
+ pageSize: int = Field(10, ge=1, le=200)
+
+
+class ProjectCreate(BaseModel):
+ model_config = ConfigDict(extra="forbid", strict=True)
+ name: str = Field(min_length=1, max_length=128)
+ description: str = Field(max_length=4000)
+ isMultiView: int = Field(0, ge=0, le=1)
+
+ @field_validator("name", "description", mode="before")
+ @classmethod
+ def clean_text(cls, value):
+ if isinstance(value, str):
+ value = value.strip()
+ if any(ord(c) < 32 and c not in "\n\t" or ord(c) == 127 for c in value):
+ raise ValueError("Invalid text")
+ return value
+
+
+class ProjectUpdate(ProjectCreate):
+ id: str = Field(min_length=1, max_length=128, pattern=r"^[A-Za-z0-9_.-]+$")
+ name: str | None = Field(None, min_length=1, max_length=128)
+ description: str | None = Field(None, max_length=4000)
+ isMultiView: int | None = Field(None, ge=0, le=1)
+ status: Literal["ENABLED", "DISABLED"] | None = None
+
+ @model_validator(mode="after")
+ def actual_change(self):
+ fields = self.model_fields_set - {"id"}
+ if not fields or any(getattr(self, key) is None for key in fields):
+ raise ValueError("Expected explicit non-null update")
+ return self
diff --git a/server/backend/projects.py b/server/backend/projects.py
new file mode 100644
index 000000000..b54c537f6
--- /dev/null
+++ b/server/backend/projects.py
@@ -0,0 +1,89 @@
+"""Real SQLite project lifecycle, no upstream memory deletion or fake account identity."""
+
+import sqlite3
+from contextlib import contextmanager
+
+from fastapi import APIRouter, Path, Request, Response
+
+from .auth import control_for
+from .errors import ApiError
+from .memory_lifecycle import check_origin
+from .models import Envelope
+from .project_context import ensure_context
+from .project_models import ProjectCreate, ProjectPageRequest, ProjectUpdate
+
+router = APIRouter(prefix="/api/dashboard/project")
+
+
+@contextmanager
+def storage(request):
+ try:
+ yield control_for(request)
+ except sqlite3.Error as exc:
+ raise ApiError(50300, "项目存储暂不可用", status_code=503) from exc
+
+
+@router.get("/dict", response_model=Envelope[list[dict]])
+def dictionary(request: Request, response: Response, language: str | None = None):
+ with storage(request) as store:
+ key = ensure_context(request, response)
+ response.headers["Cache-Control"] = "no-store"
+ return Envelope(data=store.dictionary(context=key))
+
+
+@router.post("/list", response_model=Envelope[dict])
+def project_list(body: ProjectPageRequest, request: Request, response: Response):
+ with storage(request) as store:
+ key = ensure_context(request, response)
+ response.headers["Cache-Control"] = "no-store"
+ return Envelope(data=store.page(body, key))
+
+
+@router.post("/create", response_model=Envelope[dict])
+def create(body: ProjectCreate, request: Request, response: Response):
+ check_origin(request)
+ with storage(request) as store:
+ key = ensure_context(request, response)
+ result = store.create(body, key)
+ response.headers["Cache-Control"] = "no-store"
+ return Envelope(data=result)
+
+
+@router.post("/update", response_model=Envelope[dict])
+def update(body: ProjectUpdate, request: Request, response: Response):
+ check_origin(request)
+ with storage(request) as store:
+ key = ensure_context(request, response)
+ result = store.update(body, key)
+ response.headers["Cache-Control"] = "no-store"
+ return Envelope(data=result)
+
+
+def transition(request, response, identifier, restore=False):
+ check_origin(request)
+ with storage(request) as store:
+ key = ensure_context(request, response)
+ result = store.archive(identifier, key, restore=restore)
+ response.headers["Cache-Control"] = "no-store"
+ return Envelope(
+ data=result if restore else {"ok": True, "id": identifier, "archived": True}
+ )
+
+
+@router.post("/delete/{id}", response_model=Envelope[dict])
+@router.post("/revoke/{id}", response_model=Envelope[dict])
+def archive(
+ request: Request,
+ response: Response,
+ id: str = Path(min_length=1, max_length=128, pattern=r"^[A-Za-z0-9_.-]+$"),
+):
+ return transition(request, response, id)
+
+
+@router.post("/restore/{id}", response_model=Envelope[dict])
+def restore(
+ request: Request,
+ response: Response,
+ id: str = Path(min_length=1, max_length=128, pattern=r"^[A-Za-z0-9_.-]+$"),
+):
+ return transition(request, response, id, restore=True)
diff --git a/server/backend/prompts.py b/server/backend/prompts.py
new file mode 100644
index 000000000..7d6bf619d
--- /dev/null
+++ b/server/backend/prompts.py
@@ -0,0 +1,162 @@
+"""Project extraction rules are persisted and executed by the real upstream reader."""
+
+from typing import Literal
+
+from fastapi import APIRouter, Request, Response
+from pydantic import Field, field_validator
+
+from .auth import control_for
+from .kb_models import Strict
+from .kb_store import KnowledgeStore
+from .memory_models import TAB_TYPES
+from .models import Envelope
+from .product_client import malformed
+
+router = APIRouter(prefix="/api/dashboard/prompt-config")
+TYPES = ("text_mem", "pref_mem", "event_mem")
+
+
+class Project(Strict):
+ projectId: str = Field(min_length=1, max_length=128)
+
+
+class Save(Project):
+ prompt: str = Field(min_length=1, max_length=20000)
+
+ @field_validator("prompt")
+ @classmethod
+ def valid(cls, value):
+ value = value.strip()
+ if "${conversation}" not in value:
+ raise ValueError("Preserve the conversation placeholder")
+ return value
+
+
+class Generate(Project):
+ description: str = Field(min_length=1, max_length=2000)
+
+ @field_validator("description")
+ @classmethod
+ def nonempty(cls, value):
+ if not value.strip():
+ raise ValueError("Expected a description")
+ return value.strip()
+
+
+class Message(Strict):
+ role: Literal["system", "user", "assistant"]
+ content: str = Field(min_length=1, max_length=20000)
+
+
+class Debug(Project):
+ messages: list[Message] = Field(min_length=1, max_length=30)
+ extractionTypes: list[Literal["text_mem", "pref_mem", "event_mem"]] | None = Field(
+ None, max_length=3
+ )
+
+ @field_validator("extractionTypes")
+ @classmethod
+ def unique(cls, value):
+ if value and len(value) != len(set(value)):
+ raise ValueError("Duplicate types")
+ return value or None
+
+
+def scope(request, response, project, role):
+ store = control_for(request)
+ cube = KnowledgeStore.project(store, project, role)
+ response.headers["Cache-Control"] = "no-store"
+ return cube, request.app.state.product
+
+
+def prompt_value(data):
+ if (
+ not isinstance(data, dict)
+ or not isinstance(data.get("prompt"), str)
+ or not 1 <= len(data["prompt"]) <= 20000
+ or "${conversation}" not in data["prompt"]
+ ):
+ raise malformed()
+ return data["prompt"]
+
+
+@router.post("/detail", response_model=Envelope[dict])
+async def read(body: Project, request: Request, response: Response):
+ cube, up = scope(request, response, body.projectId, "viewer")
+ data = await up.request("get_extract_prompt", {"mem_cube_id": cube}, code=200)
+ if not isinstance(data, dict) or data.get("mem_cube_id") != cube:
+ raise malformed()
+ result = {"prompt": prompt_value(data), "projectId": body.projectId}
+ if data.get("updated_at") is not None:
+ if not isinstance(data["updated_at"], str):
+ raise malformed()
+ result["updatedAt"] = data["updated_at"]
+ return Envelope(data=result)
+
+
+@router.post("/save", response_model=Envelope[dict])
+async def save(body: Save, request: Request, response: Response):
+ cube, up = scope(request, response, body.projectId, "owner")
+ data = await up.request(
+ "save_extract_prompt", {"mem_cube_id": cube, "prompt": body.prompt}, code=200
+ )
+ if prompt_value(data) != body.prompt:
+ raise malformed()
+ return Envelope(data={"ok": True})
+
+
+@router.post("/reset-default", response_model=Envelope[dict])
+async def reset(body: Project, request: Request, response: Response):
+ cube, up = scope(request, response, body.projectId, "owner")
+ data = await up.request("reset_extract_prompt", {"mem_cube_id": cube}, code=200)
+ return Envelope(data={"prompt": prompt_value(data)})
+
+
+@router.post("/generate", response_model=Envelope[dict])
+async def generate(body: Generate, request: Request, response: Response):
+ cube, up = scope(request, response, body.projectId, "editor")
+ data = await up.request(
+ "generate_extract_prompt",
+ {"mem_cube_id": cube, "description": body.description},
+ code=200,
+ timeout=request.app.state.settings.model_timeout_seconds,
+ )
+ return Envelope(data={"prompt": prompt_value(data)})
+
+
+@router.post("/debug/extract", response_model=Envelope[dict])
+async def debug(body: Debug, request: Request, response: Response):
+ cube, up = scope(request, response, body.projectId, "editor")
+ types = body.extractionTypes or list(TYPES)
+ data = await up.request(
+ "debug_extract",
+ {
+ "mem_cube_id": cube,
+ "messages": [m.model_dump() for m in body.messages],
+ "extraction_types": types,
+ },
+ code=200,
+ timeout=request.app.state.settings.model_timeout_seconds,
+ )
+ if (
+ not isinstance(data, dict)
+ or data.get("extraction_types") != types
+ or not isinstance(data.get("results"), list)
+ or len(data["results"]) > 100
+ ):
+ raise malformed()
+ results = []
+ native = {kind: tab for tab, kinds in TAB_TYPES.items() for kind in kinds}
+ for row in data["results"]:
+ if (
+ not isinstance(row, dict)
+ or not isinstance(row.get("type"), str)
+ or not isinstance(row.get("content"), str)
+ or len(row["content"]) > 20000
+ ):
+ raise malformed()
+ mapped = native.get(row["type"], row["type"] if row["type"] in TAB_TYPES else "raw")
+ if mapped != "raw" and mapped not in types:
+ continue
+ results.append({"type": mapped, "content": row["content"]})
+ return Envelope(data={"extractionTypes": types, "results": results})
diff --git a/server/backend/read_views.py b/server/backend/read_views.py
new file mode 100644
index 000000000..ee3a7f7f8
--- /dev/null
+++ b/server/backend/read_views.py
@@ -0,0 +1,348 @@
+"""Read-only graph and model projections from the tenant-scoped upstream service."""
+
+import json
+import re
+from collections import Counter
+from urllib.parse import urlencode, urlsplit
+
+from fastapi import APIRouter, Query, Request, Response
+
+from .auth import control_for
+from .errors import ApiError
+from .identity_store import missing
+from .memory_models import TAB_TYPES
+from .models import Envelope
+from .product_client import malformed
+from .profile_graph import PROFILE_ID, profile_fulltext, profile_nodes
+from .project_context import selected_project
+from .timefmt import display
+
+router = APIRouter(prefix="/api/dashboard")
+NATIVE_TYPES = frozenset(kind for kinds in TAB_TYPES.values() for kind in kinds)
+NODE_BYTES = 1024 * 1024
+EDGE_LIMIT = 8000
+
+
+def text(value, maximum=1024):
+ if not isinstance(value, str) or len(value) > maximum:
+ raise malformed()
+ return value
+
+
+def scope(request, response, tenant):
+ store = control_for(request)
+ project = tenant if tenant is not None else selected_project(request)
+ cube = store.resolve_cube(project)
+ if cube is None:
+ raise missing()
+ store.require_role(project, "viewer")
+ response.headers["Cache-Control"] = "no-store"
+ return project, cube
+
+
+def validate_graph_data(data, cube):
+ if (
+ not isinstance(data, dict)
+ or data.get("cube_id") != cube
+ or not isinstance(data.get("nodes"), list)
+ or not isinstance(data.get("edges"), list)
+ or len(data["nodes"]) > 10000
+ or len(data["edges"]) > 100000
+ or type(data.get("total_nodes")) is not int
+ or data["total_nodes"] < 0
+ or type(data.get("truncated")) is not bool
+ ):
+ raise malformed()
+
+
+def project_graph(data, cube, project, requested, limit):
+ validate_graph_data(data, cube)
+ truncated = data["truncated"] or data["total_nodes"] > len(data["nodes"])
+ nodes = []
+ ids = set()
+ budget = 0
+ for row in data["nodes"]:
+ if not isinstance(row, dict):
+ raise malformed()
+ identifier = text(row.get("id"), 1024)
+ if not identifier or identifier in ids:
+ raise malformed()
+ ids.add(identifier)
+ status = text(row.get("status", ""), 64)
+ kind = text(row.get("memoryType", ""), 64)
+ if (
+ status != "activated"
+ or kind not in NATIVE_TYPES
+ or (requested and kind not in requested)
+ ):
+ continue
+ memory = text(row.get("memory"), 20000)
+ flag = row.get("memoryTruncated")
+ if type(flag) is not bool:
+ raise malformed()
+ sources = row.get("sourceMemoryIds", [])
+ if not isinstance(sources, list) or len(sources) > 10000:
+ raise malformed()
+ sources = list(dict.fromkeys(text(s, 256) for s in sources))
+ try:
+ created_at = display(text(row.get("createdAt", ""), 128))
+ except (ValueError, OverflowError) as exc:
+ raise malformed() from exc
+ # Preserve provenance IDs; draw derived edges only when both endpoints are visible.
+ node = {
+ "id": identifier,
+ "label": memory[:119] + "…" if len(memory) > 120 else memory,
+ "labelTruncated": flag or len(memory) > 120,
+ "memoryType": kind,
+ "createdAt": created_at,
+ "sessionId": text(row.get("sessionId", ""), 256),
+ "clusterId": identifier,
+ "userId": text(row.get("userId", ""), 256),
+ "sourceIds": sources,
+ }
+ size = len(json.dumps(node, ensure_ascii=False).encode())
+ if len(nodes) >= limit or budget + size > NODE_BYTES:
+ truncated = True
+ continue
+ budget += size
+ nodes.append(node)
+ by_id = {node["id"]: node for node in nodes}
+ links = []
+ seen = set()
+ edge_bytes = 0
+ parents = {identifier: identifier for identifier in by_id}
+ degrees = Counter()
+
+ def root(identifier):
+ while parents[identifier] != identifier:
+ parents[identifier] = parents[parents[identifier]]
+ identifier = parents[identifier]
+ return identifier
+
+ def link(source, target, kind):
+ nonlocal truncated, edge_bytes
+ if source not in by_id or target not in by_id or source == target:
+ return
+ key = (source, target, kind)
+ if key in seen:
+ return
+ projected = {"source": source, "target": target, "kind": kind}
+ size = len(json.dumps(projected, ensure_ascii=False).encode())
+ if len(links) >= EDGE_LIMIT or edge_bytes + size > 750 * 1024:
+ truncated = True
+ return
+ seen.add(key)
+ links.append(projected)
+ edge_bytes += size
+ degrees.update((source, target))
+ a, b = root(source), root(target)
+ parents[max(a, b)] = min(a, b)
+
+ for edge in data["edges"]:
+ if not isinstance(edge, dict):
+ raise malformed()
+ source = text(edge.get("source"), 1024)
+ target = text(edge.get("target"), 1024)
+ kind = text(edge.get("type"), 64)
+ mapped = {"PARENT": "parent", "MERGED_TO": "merged"}.get(kind)
+ if mapped:
+ link(source, target, mapped)
+ sessions = {}
+ for node in nodes:
+ for source in node["sourceIds"]:
+ link(source, node["id"], "derived")
+ session = node["sessionId"]
+ if session:
+ if session in sessions:
+ link(sessions[session], node["id"], "session")
+ else:
+ sessions[session] = node["id"]
+ clusters = set()
+ for node in nodes:
+ if degrees[node["id"]]:
+ node["clusterId"] = root(node["id"])
+ clusters.add(node["clusterId"])
+ types = Counter(node["memoryType"] for node in nodes)
+ return {
+ "tenant": project,
+ "nodes": nodes,
+ "links": links,
+ "stats": {
+ "nodes": len(nodes),
+ "links": len(links),
+ "sessions": len(sessions),
+ "clusters": len(clusters),
+ "orphans": sum(not degrees[n["id"]] for n in nodes),
+ "truncated": truncated,
+ "types": [{"name": name, "count": count} for name, count in sorted(types.items())],
+ },
+ }
+
+
+@router.get("/graph/memories", response_model=Envelope[dict])
+async def graph(
+ request: Request,
+ response: Response,
+ tenant: str | None = Query(None, min_length=1, max_length=128),
+ types: str | None = Query(None, max_length=1024),
+ limit: int = Query(2000, ge=1, le=2000),
+):
+ project, cube = scope(request, response, tenant)
+ requested = list(dict.fromkeys(part.strip() for part in types.split(","))) if types else []
+ if any(kind not in NATIVE_TYPES for kind in requested):
+ raise ApiError(40000, "Unknown memory type", status_code=400)
+ upstream = request.app.state.product
+ graph_types = (
+ [kind for kind in requested if kind != "ProfileMemory"]
+ if requested
+ else sorted(NATIVE_TYPES - {"ProfileMemory"})
+ )
+ data = (
+ await upstream.request(
+ "get_memory_graph",
+ {
+ "mem_cube_id": cube,
+ "memory_types": graph_types,
+ "status": ["activated"],
+ "limit": limit,
+ "label_max": 120,
+ "include_edges": True,
+ },
+ code=200,
+ )
+ if graph_types
+ else {
+ "cube_id": cube,
+ "nodes": [],
+ "edges": [],
+ "total_nodes": 0,
+ "truncated": False,
+ }
+ )
+ validate_graph_data(data, cube)
+ if not requested or "ProfileMemory" in requested:
+ fields, total = await profile_nodes(upstream, cube, max(0, limit - len(data["nodes"])))
+ data = {
+ **data,
+ "nodes": [*data["nodes"], *fields],
+ "total_nodes": data["total_nodes"] + total,
+ "truncated": data["truncated"] or total > len(fields),
+ }
+ return Envelope(data=project_graph(data, cube, project, requested, limit))
+
+
+@router.get("/graph/memory", response_model=Envelope[dict])
+async def fulltext(
+ request: Request,
+ response: Response,
+ id: str = Query(min_length=1, max_length=1024),
+ tenant: str | None = Query(None, min_length=1, max_length=128),
+):
+ _, cube = scope(request, response, tenant)
+ if PROFILE_ID.fullmatch(id):
+ return Envelope(data=await profile_fulltext(request.app.state.product, cube, id))
+ data = await request.app.state.product.request(
+ "get_memory_by_ids?" + urlencode({"mem_cube_id": cube}),
+ {"memory_ids": [id], "include_sources": False},
+ code=200,
+ )
+ if not isinstance(data, dict) or not isinstance(data.get("memories"), list):
+ raise malformed()
+ if not data["memories"]:
+ raise missing()
+ if len(data["memories"]) != 1:
+ raise malformed()
+ row = data["memories"][0]
+ if (
+ not isinstance(row, dict)
+ or row.get("id") != id
+ or not isinstance(row.get("metadata"), dict)
+ ):
+ raise malformed()
+ meta = row["metadata"]
+ text(meta.get("status", ""), 64)
+ text(meta.get("memory_type", ""), 64)
+ if (
+ meta.get("status") != "activated"
+ or meta.get("memory_type") not in NATIVE_TYPES
+ or (meta.get("user_name") is not None and meta["user_name"] != cube)
+ ):
+ raise missing()
+ memory = row.get("memory")
+ if not isinstance(memory, str):
+ raise malformed()
+ if len(json.dumps({"memory": memory}, ensure_ascii=False).encode()) > 1024 * 1024:
+ raise ApiError(41300, "Memory body exceeds the view limit", status_code=413)
+ return Envelope(data={"id": id, "memory": memory})
+
+
+MODEL_SOURCES = {
+ "MEMRADER_MODEL": ("记忆读取", "memory"),
+ "MOS_CHAT_MODEL": ("记忆写入 / 对话", "chat"),
+ "MOS_EMBEDDER_MODEL": ("向量化", "memory"),
+ "MOS_RERANKER_MODEL": ("重排", "memory"),
+}
+
+
+def model_spec(source, value):
+ value = text(value, 2048)
+ if source == "MOS_EMBEDDER_MODEL":
+ match = re.fullmatch(r"([1-9][0-9]{0,4})\s*维", value)
+ return f"{match[1]} 维" if match and int(match[1]) <= 65536 else ""
+ if source == "MOS_RERANKER_MODEL":
+ match = re.fullmatch(r"后端\s+(cosine_local|http_bge|http_bge_strategy|concat|noop)", value)
+ return f"后端 {match[1]}" if match else ""
+ if not value:
+ return ""
+ try:
+ url = urlsplit(value if "://" in value else "//" + value)
+ host, port = url.hostname, url.port
+ if not host or not re.fullmatch(r"[A-Za-z0-9.:-]+", host):
+ return ""
+ if ":" in host:
+ host = "[" + host + "]"
+ return host + (f":{port}" if port is not None else "")
+ except ValueError:
+ return ""
+
+
+@router.get("/models", response_model=Envelope[dict])
+async def models(request: Request, response: Response):
+ # Model inventory is deployment-wide and read-only for authenticated accounts.
+ response.headers["Cache-Control"] = "no-store"
+ data = await request.app.state.product.request("models", method="GET", code=200)
+ if (
+ not isinstance(data, dict)
+ or not isinstance(data.get("records"), list)
+ or len(data["records"]) > 100
+ ):
+ raise malformed()
+ records = []
+ seen = set()
+ for row in data["records"]:
+ if not isinstance(row, dict):
+ raise malformed()
+ source = text(row.get("source"), 128)
+ if source not in MODEL_SOURCES:
+ continue
+ if source in seen:
+ raise malformed()
+ seen.add(source)
+ model = text(row.get("model"), 256)
+ if (
+ not re.fullmatch(r"[A-Za-z0-9_./:-]{1,256}", model)
+ or "://" in model
+ or re.search(r"(?:^|[/_:])sk-[A-Za-z0-9_-]{12,}", model)
+ ):
+ raise malformed()
+ role, group = MODEL_SOURCES[source]
+ records.append(
+ {
+ "role": role,
+ "group": group,
+ "source": source,
+ "model": model,
+ "spec": model_spec(source, row.get("spec", "")),
+ }
+ )
+ return Envelope(data={"records": records, "total": len(records)})
diff --git a/server/backend/requirements-dev.txt b/server/backend/requirements-dev.txt
new file mode 100644
index 000000000..d902e8042
--- /dev/null
+++ b/server/backend/requirements-dev.txt
@@ -0,0 +1,4 @@
+-r requirements.txt
+pytest==9.0.2
+jsonschema==4.25.1
+ruff==0.11.13
diff --git a/server/backend/requirements.txt b/server/backend/requirements.txt
new file mode 100644
index 000000000..de8707be6
--- /dev/null
+++ b/server/backend/requirements.txt
@@ -0,0 +1,5 @@
+fastapi==0.115.14
+httpx==0.28.1
+pydantic==2.12.4
+uvicorn==0.38.0
+orjson==3.11.4
diff --git a/server/backend/ruff.toml b/server/backend/ruff.toml
new file mode 100644
index 000000000..e9c3dc23f
--- /dev/null
+++ b/server/backend/ruff.toml
@@ -0,0 +1,5 @@
+target-version = "py311"
+line-length = 100
+
+[lint]
+select = ["E", "F", "I"]
diff --git a/server/backend/security.py b/server/backend/security.py
new file mode 100644
index 000000000..9ae5a053e
--- /dev/null
+++ b/server/backend/security.py
@@ -0,0 +1,38 @@
+"""Shared browser Origin validation for control-plane writes."""
+
+import logging
+
+from .errors import ApiError
+
+
+def check_origin(request):
+ origin = request.headers.get("origin")
+ if origin is None:
+ return # Trusted local CLI callers do not send browser Origin.
+ valid = origin in request.app.state.settings.allowed_origins
+ if not valid:
+ logging.getLogger(__name__).warning("Rejected browser Origin (opaque=%s)", origin == "null")
+ raise ApiError(40003, "不允许该来源执行写操作", status_code=403)
+
+
+async def browser_write_receive(request, receive):
+ """Allow JSON or genuinely empty browser writes; never trust Content-Length alone."""
+ content_type = request.headers.get("content-type", "").split(";", 1)[0].strip().lower()
+ if content_type == "application/json":
+ return receive
+ if content_type:
+ raise ApiError(40003, "写操作仅接受JSON或空请求体", status_code=403)
+ message = await receive()
+ if message["type"] != "http.request" or message.get("body") or message.get("more_body", False):
+ raise ApiError(40003, "写操作仅接受JSON或空请求体", status_code=403)
+
+ original_receive = receive
+
+ async def replay():
+ nonlocal message
+ if message is not None:
+ first, message = message, None
+ return first
+ return await original_receive()
+
+ return replay
diff --git a/server/backend/settings.py b/server/backend/settings.py
new file mode 100644
index 000000000..d12a3f795
--- /dev/null
+++ b/server/backend/settings.py
@@ -0,0 +1,120 @@
+"""Small explicit configuration surface; never load model credentials."""
+
+import os
+import re
+from pathlib import Path
+from urllib.parse import urlsplit
+
+from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
+
+
+class Settings(BaseModel):
+ model_config = ConfigDict(frozen=True)
+
+ upstream_url: str = "http://127.0.0.1:8000/"
+ upstream_timeout_seconds: float = Field(default=3.0, gt=0, le=60, allow_inf_nan=False)
+ projects_seed: tuple[tuple[str, str], ...] = ()
+ default_project_id: str | None = None
+ data_dir: Path = Path(".data")
+ audit_gateway_dir: Path | None = None
+ audit_console_dir: Path | None = None
+ auth_enabled: bool = True
+ session_ttl_seconds: int = Field(86400, ge=60, le=2592000)
+ model_timeout_seconds: float = Field(120, gt=0, le=180, allow_inf_nan=False)
+ gateway_keys_file: Path | None = None
+ allowed_origins: tuple[str, ...] = (
+ "http://127.0.0.1:5274",
+ "http://127.0.0.1:5276",
+ "http://127.0.0.1:5281",
+ "http://localhost:5274",
+ "http://localhost:5276",
+ "http://localhost:5281",
+ "http://[::1]:5274",
+ "http://[::1]:5276",
+ "http://[::1]:5281",
+ )
+
+ @field_validator("allowed_origins", mode="before")
+ @classmethod
+ def origins(cls, value):
+ if isinstance(value, str):
+ value = tuple(part.strip() for part in value.split(";") if part.strip())
+ if not value:
+ raise ValueError("Set at least one explicit console origin")
+ for origin in value:
+ parsed = urlsplit(origin)
+ if (
+ parsed.scheme not in {"http", "https"}
+ or not parsed.hostname
+ or parsed.username
+ or parsed.password
+ or parsed.query
+ or parsed.fragment
+ or parsed.path
+ ):
+ raise ValueError(
+ "Origins must be explicit HTTP(S) origins without paths or credentials"
+ )
+ _ = parsed.port
+ return value
+
+ @field_validator("projects_seed", mode="before")
+ @classmethod
+ def parse_projects(cls, value):
+ if isinstance(value, str):
+ if not value:
+ return ()
+ value = tuple(tuple(part.split(":")) for part in value.split(";"))
+ for pair in value:
+ if len(pair) != 2 or any(
+ not isinstance(x, str) or not re.fullmatch(r"[A-Za-z0-9_.-]{1,128}", x)
+ for x in pair
+ ):
+ raise ValueError("Projects must be projectId:cubeId pairs of valid identifiers")
+ if len({x[0] for x in value}) != len(value) or len({x[1] for x in value}) != len(value):
+ raise ValueError("Project IDs and cube IDs must be unique")
+ return value
+
+ @model_validator(mode="after")
+ def validate_default(self) -> "Settings":
+ ids = {pair[0] for pair in self.projects_seed}
+ if self.default_project_id is not None and self.default_project_id not in ids:
+ raise ValueError("Default project must be in configured projects")
+ if ids and self.default_project_id is None:
+ raise ValueError("Set DASHBOARD_DEFAULT_PROJECT_ID when enabling project reads")
+ return self
+
+ @field_validator("upstream_url")
+ @classmethod
+ def validate_upstream_url(cls, value: str) -> str:
+ parsed = urlsplit(value)
+ if (
+ parsed.scheme not in {"http", "https"}
+ or not parsed.hostname
+ or parsed.username is not None
+ or parsed.password is not None
+ or parsed.query
+ or parsed.fragment
+ ):
+ raise ValueError("Use an HTTP(S) URL without credentials, query, or fragment")
+ # Accessing port also validates malformed and out-of-range ports.
+ _ = parsed.port
+ return value.rstrip("/") + "/"
+
+ @classmethod
+ def from_env(cls) -> "Settings":
+ return cls(
+ upstream_url=os.environ.get("DASHBOARD_UPSTREAM_URL", "http://127.0.0.1:8000/"),
+ upstream_timeout_seconds=os.environ.get("DASHBOARD_UPSTREAM_TIMEOUT_SECONDS", "3"),
+ projects_seed=os.environ.get("DASHBOARD_PROJECTS", ""),
+ default_project_id=os.environ.get("DASHBOARD_DEFAULT_PROJECT_ID") or None,
+ data_dir=Path(os.environ.get("DASHBOARD_DATA_DIR", ".data")),
+ audit_gateway_dir=os.environ.get("DASHBOARD_AUDIT_GATEWAY_DIR") or None,
+ audit_console_dir=os.environ.get("DASHBOARD_AUDIT_CONSOLE_DIR") or None,
+ auth_enabled=os.environ.get("DASHBOARD_AUTH_ENABLED", "true"),
+ session_ttl_seconds=os.environ.get("DASHBOARD_SESSION_TTL_SECONDS", "86400"),
+ model_timeout_seconds=os.environ.get("DASHBOARD_MODEL_TIMEOUT_SECONDS", "120"),
+ gateway_keys_file=os.environ.get("DASHBOARD_GATEWAY_KEYS_FILE") or None,
+ allowed_origins=os.environ.get("DASHBOARD_ALLOWED_ORIGINS")
+ or cls.model_fields["allowed_origins"].default,
+ )
diff --git a/server/backend/tests/conftest.py b/server/backend/tests/conftest.py
new file mode 100644
index 000000000..db6d1f8ff
--- /dev/null
+++ b/server/backend/tests/conftest.py
@@ -0,0 +1,8 @@
+"""Never let default relative control/audit paths reach the developer's live state."""
+
+import pytest
+
+
+@pytest.fixture(autouse=True)
+def isolate_relative_data_paths(tmp_path, monkeypatch):
+ monkeypatch.chdir(tmp_path)
diff --git a/server/backend/tests/test_accounts.py b/server/backend/tests/test_accounts.py
new file mode 100644
index 000000000..86b5ca3e6
--- /dev/null
+++ b/server/backend/tests/test_accounts.py
@@ -0,0 +1,166 @@
+import re
+
+import httpx
+from fastapi.testclient import TestClient
+from server.backend.app import create_app
+from server.backend.settings import Settings
+
+
+def configured(tmp_path):
+ return Settings(
+ data_dir=tmp_path / "data",
+ projects_seed=(("a", "cube-a"), ("b", "cube-b")),
+ default_project_id="a",
+ auth_enabled=True,
+ )
+
+
+def application(tmp_path):
+ return create_app(
+ configured(tmp_path), transport=httpx.MockTransport(lambda r: httpx.Response(500))
+ )
+
+
+def seed(app):
+ return app.state.identity.create_account(
+ "admin", "correct-password-123", admin=True, bootstrap=True
+ )
+
+
+def login(web, username="admin", password="correct-password-123"):
+ page = web.get("/__login")
+ csrf = re.search(r'name="csrf" value="([^"]+)"', page.text)[1]
+ return web.post(
+ "/__login",
+ data={"username": username, "password": password, "csrf": csrf},
+ follow_redirects=False,
+ )
+
+
+def test_login_session_logout_and_response_projection(tmp_path):
+ with TestClient(application(tmp_path)) as web:
+ seed(web.app)
+ assert web.get("/api/auth/me").status_code == 401
+ assert web.get("/admin/accounts").status_code == 401
+ assert login(web, password="wrong").status_code == 401
+ assert login(web).status_code == 303
+ assert "HttpOnly" in login(web).headers["set-cookie"]
+ me = web.get("/api/auth/me").json()["data"]
+ assert me["platformAdmin"] is True and me["currentProjectId"] == "a"
+ accounts = web.get("/admin/accounts").json()["data"]
+ assert accounts["total"] == 1
+ assert re.fullmatch(
+ r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}", accounts["records"][0]["created_at"]
+ )
+ assert "password" not in str(accounts) and "token_hash" not in str(accounts)
+ assert web.get("/__logout").status_code == 200
+ page = web.get("/__logout")
+ csrf = re.search(r'name="csrf" value="([^"]+)"', page.text)[1]
+ assert web.post("/__logout", data={"csrf": csrf}, follow_redirects=False).status_code == 303
+ assert web.get("/api/auth/me").status_code == 401
+
+
+def test_effective_last_admin_disable_delete_and_session_revocation(tmp_path):
+ with TestClient(application(tmp_path)) as web:
+ admin = seed(web.app)
+ user = web.app.state.identity.create_account("other", "other-password-123", admin=True)
+ login(web)
+ assert (
+ web.post(f"/admin/accounts/{admin['id']}", json={"platform_admin": False}).status_code
+ == 200
+ )
+ assert web.get("/admin/accounts").status_code == 403
+ login(web, "other", "other-password-123")
+ assert web.post(f"/admin/accounts/{user['id']}", json={"disabled": True}).status_code == 409
+ assert web.delete(f"/admin/accounts/{user['id']}").status_code == 409
+ assert (
+ web.post(f"/admin/accounts/{admin['id']}", json={"platform_admin": True}).status_code
+ == 200
+ )
+ assert web.post(f"/admin/accounts/{user['id']}", json={"disabled": True}).status_code == 200
+ assert web.get("/api/auth/me").status_code == 401
+ assert login(web, "other", "other-password-123").status_code == 401
+
+
+def test_session_persists_restart_expires_and_origin_and_csrf(tmp_path):
+ with TestClient(application(tmp_path)) as web:
+ seed(web.app)
+ assert (
+ web.post(
+ "/__login", data={"username": "admin", "password": "correct-password-123"}
+ ).status_code
+ == 403
+ )
+ login(web)
+ cookies = dict(web.cookies)
+ assert (
+ web.post(
+ "/api/dashboard/project/create",
+ json={"name": "evil", "description": ""},
+ headers={"Origin": "https://evil.example"},
+ ).status_code
+ == 403
+ )
+ with TestClient(application(tmp_path)) as web:
+ web.cookies.update(cookies)
+ assert web.get("/api/auth/me").status_code == 200
+ with web.app.state.control.connection() as db:
+ db.execute("UPDATE sessions SET expires_at=0")
+ assert web.get("/api/auth/me").status_code == 401
+
+
+def test_account_validation_paging_and_delete_relationships(tmp_path):
+ with TestClient(application(tmp_path)) as web:
+ seed(web.app)
+ other = web.app.state.identity.create_account("other", "other-password-123")
+ login(web)
+ assert web.get("/admin/accounts?page=0").status_code == 400
+ assert web.get("/admin/accounts?query=other").json()["data"]["total"] == 1
+ for body in ({}, {"disabled": "false"}, {"password": "injected"}, {"display_name": None}):
+ assert web.post(f"/admin/accounts/{other['id']}", json=body).status_code == 400
+ assert web.delete(f"/admin/accounts/{other['id']}").status_code == 200
+ assert web.get("/admin/accounts?query=other").json()["data"]["total"] == 0
+
+
+def test_login_form_retains_same_origin_for_browser_post(tmp_path):
+ with TestClient(application(tmp_path)) as web:
+ response = web.get("/__login")
+ assert response.headers["referrer-policy"] == "same-origin"
+ assert "form-action 'self'" in response.headers["content-security-policy"]
+
+
+def test_control_storage_failure_is_uniform_unavailable(tmp_path):
+ import sqlite3
+
+ with TestClient(application(tmp_path)) as web:
+ seed(web.app)
+ login(web)
+
+ def fail(*args):
+ raise sqlite3.OperationalError("private storage failure")
+
+ web.app.state.identity.account_list = fail
+ response = web.get("/admin/accounts")
+ assert response.status_code == 503
+ assert response.json()["code"] == 50300
+ assert "private" not in response.text
+
+
+def test_explicit_https_console_origin_and_secure_cookie(tmp_path):
+ from server.backend.app import create_app
+
+ config = configured(tmp_path).model_copy(
+ update={"allowed_origins": ("https://console.example",)}
+ )
+ with TestClient(create_app(config), base_url="https://console.example") as web:
+ seed(web.app)
+ page = web.get("/__login")
+ csrf = re.search(r'name="csrf" value="([^"]+)"', page.text)[1]
+ response = web.post(
+ "/__login",
+ data={"username": "admin", "password": "correct-password-123", "csrf": csrf},
+ headers={"Origin": "https://console.example"},
+ follow_redirects=False,
+ )
+ assert response.status_code == 303
+ assert "Secure" in response.headers["set-cookie"]
diff --git a/server/backend/tests/test_audit_api.py b/server/backend/tests/test_audit_api.py
new file mode 100644
index 000000000..23729323e
--- /dev/null
+++ b/server/backend/tests/test_audit_api.py
@@ -0,0 +1,200 @@
+import json
+from pathlib import Path
+
+import httpx
+import pytest
+from fastapi.testclient import TestClient
+from jsonschema import Draft202012Validator
+from server.backend.app import create_app
+from server.backend.settings import Settings
+
+
+def validate_contract(body, component):
+ spec = json.loads(
+ (Path(__file__).resolve().parents[3] / "openapi.json").read_text(encoding="utf-8")
+ )
+ schema = spec["components"]["responses"][component]["content"]["application/json"]["schema"]
+ Draft202012Validator({**schema, "components": spec["components"]}).validate(body)
+
+
+def application(tmp_path, *, gateway=True):
+ folder = tmp_path / "gateway"
+ folder.mkdir(exist_ok=True)
+ app = create_app(
+ Settings(
+ auth_enabled=False,
+ data_dir=tmp_path / "data",
+ projects_seed=(("p", "a"), ("q", "b")),
+ default_project_id="p",
+ audit_gateway_dir=folder if gateway else None,
+ ),
+ transport=httpx.MockTransport(
+ lambda req: httpx.Response(200, json={"code": 200, "data": {"items": [], "total": 0}})
+ ),
+ )
+ return app, folder
+
+
+def append(folder, **extra):
+ row = {
+ "tenant": "a",
+ "tool": "search_memory",
+ "ts": "2026-10-26T00:00:00Z",
+ "ok": True,
+ "input": {"query": "unique-marker", "user_id": "u"},
+ "output": '{ "code": 200 }',
+ **extra,
+ }
+ with (folder / "calls-20261026.jsonl").open("a", encoding="utf-8") as file:
+ file.write(json.dumps(row) + "\n")
+
+
+def test_list_detail_projection_and_tenant_scope(tmp_path):
+ app, folder = application(tmp_path)
+ append(folder)
+ append(folder, tenant="b")
+ with TestClient(app) as client:
+ response = client.post(
+ "/api/dashboard/log/list",
+ json={
+ "projectId": "p",
+ "scope": "gateway",
+ "query": "marker",
+ "subjectId": "u",
+ "eventType": "searchMemory",
+ "status": "SUCCESS",
+ },
+ )
+ assert response.status_code == 200 and response.json()["code"] == 0
+ validate_contract(response.json(), "LogList")
+ data = response.json()["data"]
+ assert data["total"] == 1 and "requestBody" not in data["logs"][0]
+ assert data["logs"][0]["createTime"] == "2026-10-26 08:00:00"
+ identifier = data["logs"][0]["id"]
+ detail = client.post("/api/dashboard/log/detail", json={"logId": identifier}).json()["data"]
+ assert detail["createTime"] == "2026-10-26 08:00:00"
+ validate_contract({"code": 0, "data": detail, "message": "success"}, "LogDetail")
+ assert detail["requestBody"] == '{"query":"unique-marker","user_id":"u"}'
+ assert detail["responseBody"] == '{ "code": 200 }'
+ foreign = client.post(
+ "/api/dashboard/log/list", json={"projectId": "q", "scope": "gateway"}
+ ).json()["data"]["logs"][0]["id"]
+ assert client.post("/api/dashboard/log/detail", json={"logId": foreign}).status_code == 404
+ assert (
+ client.post("/api/dashboard/log/list", json={"projectId": "foreign"}).status_code == 404
+ )
+ assert response.headers["cache-control"] == "no-store"
+
+
+@pytest.mark.parametrize(
+ "extra",
+ [
+ {"eventType": "invented"},
+ {"scope": "../../"},
+ {"pageNum": 10**30},
+ {"pageSize": 101},
+ {"startTime": "not-time"},
+ {"startTime": "2026-10-27", "endTime": "2026-10-26"},
+ {"path": "secret"},
+ ],
+)
+def test_bad_log_filters_rejected(tmp_path, extra):
+ app, _ = application(tmp_path)
+ with TestClient(app) as client:
+ response = client.post("/api/dashboard/log/list", json={"projectId": "p", **extra})
+ assert response.status_code == 400 and response.json()["code"] != 0
+
+
+def test_missing_gateway_is_not_fake_empty_but_console_remains_available(tmp_path):
+ app, _ = application(tmp_path, gateway=False)
+ with TestClient(app) as client:
+ assert client.post("/api/dashboard/log/list", json={"projectId": "p"}).status_code == 503
+ assert client.post(
+ "/api/dashboard/log/list", json={"projectId": "p", "scope": "console"}
+ ).json()["data"] == {"logs": [], "total": 0}
+
+
+def test_actual_console_operation_is_audited_without_log_read_feedback(tmp_path):
+ app, _ = application(tmp_path)
+ with TestClient(app) as client:
+ # Intentionally wrong upstream contract: the real 502 must be logged as failure.
+ result = client.post(
+ "/api/dashboard/project/memory/list",
+ json={
+ "projectId": "p",
+ "memoryDetailType": "text_mem",
+ "memSearchContent": "console-marker",
+ },
+ )
+ assert result.status_code == 502
+ query = {"projectId": "p", "scope": "console", "query": "console-marker"}
+ first = client.post("/api/dashboard/log/list", json=query).json()["data"]
+ assert first["total"] == 1 and first["logs"][0]["status"] == "FAILURE"
+ detail = client.post(
+ "/api/dashboard/log/detail", json={"logId": first["logs"][0]["id"]}
+ ).json()["data"]
+ assert json.loads(detail["requestBody"])["memSearchContent"] == "console-marker"
+ assert client.post("/api/dashboard/log/list", json=query).json()["data"]["total"] == 1
+ client.post(
+ "/api/dashboard/project/memory/list",
+ json={"projectId": "foreign", "memoryDetailType": "text_mem"},
+ )
+ assert (
+ client.post(
+ "/api/dashboard/log/list", json={"projectId": "p", "scope": "console"}
+ ).json()["data"]["total"]
+ == 1
+ )
+
+
+def test_missing_source_and_corrupt_database_fail_explicitly(tmp_path):
+ app, folder = application(tmp_path)
+ folder.rmdir()
+ with TestClient(app) as client:
+ response = client.post("/api/dashboard/log/list", json={"projectId": "p"})
+ assert response.status_code == 503
+
+
+def test_large_valid_memory_response_does_not_turn_success_into_failure(tmp_path):
+ from server.backend.tests.test_memories import node
+
+ app, _ = application(tmp_path)
+
+ async def memory_page(*_):
+ rows = [node(str(i), cube="a") for i in range(100)]
+ for row in rows:
+ row["memory"] = "中" * 400
+ return rows, 100
+
+ with TestClient(app) as client:
+ app.state.memories.page = memory_page
+ result = client.post(
+ "/api/dashboard/project/memory/list",
+ json={"projectId": "p", "memoryDetailType": "text_mem", "pageSize": 100},
+ )
+ assert result.status_code == 200 and len(result.content) > 65536
+ log = client.post(
+ "/api/dashboard/log/list", json={"projectId": "p", "scope": "console"}
+ ).json()["data"]["logs"][0]
+ assert log["status"] == "SUCCESS"
+ detail = client.post("/api/dashboard/log/detail", json={"logId": log["id"]}).json()["data"]
+ assert json.loads(detail["responseBody"])["truncated"] is True
+ assert detail["responseCode"] == 0
+
+
+def test_large_valid_batch_keeps_project_attribution_after_body_truncation(tmp_path):
+ app, _ = application(tmp_path)
+ with TestClient(app) as client:
+ result = client.post(
+ "/api/dashboard/project/memory/delete",
+ json={
+ "projectId": "p",
+ "memType": "text_mem",
+ "memory_ids": [str(i) + "x" * 800 for i in range(100)],
+ },
+ )
+ assert result.status_code == 502
+ page = client.post(
+ "/api/dashboard/log/list", json={"projectId": "p", "scope": "console"}
+ ).json()["data"]
+ assert page["total"] == 1 and page["logs"][0]["status"] == "FAILURE"
diff --git a/server/backend/tests/test_audit_index.py b/server/backend/tests/test_audit_index.py
new file mode 100644
index 000000000..46af3f1a0
--- /dev/null
+++ b/server/backend/tests/test_audit_index.py
@@ -0,0 +1,291 @@
+import json
+
+import pytest
+
+
+def line(number=1, **extra):
+ return {
+ "tenant": "a",
+ "tool": "search_memory",
+ "ts": f"2026-10-26T02:00:{number:02d}+00:00",
+ "ok": True,
+ "ms": 12,
+ "input": {"query": f"marker-{number}", "user_id": "u"},
+ "output": {"code": 200, "data": {}},
+ **extra,
+ }
+
+
+def write(path, *rows, mode="a"):
+ with path.open(mode, encoding="utf-8") as output:
+ for row in rows:
+ output.write(json.dumps(row, ensure_ascii=False) + "\n")
+
+
+def index_for(tmp_path, *, console=False):
+ from server.backend.audit_index import AuditIndex
+
+ directory = tmp_path / "gateway"
+ directory.mkdir(exist_ok=True)
+ sources = {"gateway": directory}
+ if console:
+ folder = tmp_path / "console"
+ folder.mkdir(exist_ok=True)
+ sources["console"] = folder
+ return AuditIndex(tmp_path / "audit.db", sources), directory / "calls-20261026.jsonl"
+
+
+def page(index, cube="a", **extra):
+ from server.backend.audit_models import LogListRequest
+
+ return index.page(cube, LogListRequest(projectId="p", **extra))
+
+
+def test_initial_append_restart_and_half_lines_are_incremental(tmp_path):
+ index, path = index_for(tmp_path)
+ write(path, line())
+ assert index.sync()["imported"] == 1
+ first = page(index)["logs"][0]
+ assert index.sync()["imported"] == 0
+ restarted, _ = index_for(tmp_path)
+ assert restarted.sync()["imported"] == 0
+ with path.open("ab") as output:
+ encoded = json.dumps(line(2)).encode()
+ output.write(encoded[:17])
+ assert restarted.sync()["imported"] == 0
+ with path.open("ab") as output:
+ output.write(encoded[17:] + b"\n")
+ assert restarted.sync()["imported"] == 1
+ assert page(restarted)["total"] == 2
+ assert index.detail("a", first["id"])["requestBody"] == '{"query":"marker-1","user_id":"u"}'
+ assert index.detail("b", first["id"]) is None
+
+
+def test_rotate_same_file_and_replace_same_path_without_duplicates(tmp_path):
+ index, path = index_for(tmp_path)
+ write(path, line())
+ index.sync()
+ first = page(index)["logs"][0]["id"]
+ rotated = path.with_name(path.name + ".1")
+ path.rename(rotated)
+ write(path, line(2))
+ assert index.sync()["imported"] == 1
+ assert page(index)["total"] == 2
+ assert index.detail("a", first)
+ replacement = path.parent / "replacement.tmp"
+ write(replacement, line(3))
+ replacement.replace(path)
+ assert index.sync()["imported"] == 1
+ assert page(index)["total"] == 3
+
+
+def test_truncate_regrow_past_checkpoint_and_bad_json_quarantine(tmp_path):
+ index, path = index_for(tmp_path)
+ write(path, line())
+ index.sync()
+ write(path, line(2), line(3), mode="w")
+ with path.open("a", encoding="utf-8") as output:
+ output.write("{broken\n[]\n" + json.dumps({"tool": "search_memory"}) + "\n")
+ write(path, line(4))
+ result = index.sync()
+ assert result["imported"] == 3
+ assert result["invalid"] == 2 and result["unscoped"] == 1
+ assert page(index)["total"] == 4
+ assert index.sync()["invalid"] == 0
+
+
+def test_failed_import_rolls_back_cursor_and_rows_together(tmp_path, monkeypatch):
+ index, path = index_for(tmp_path)
+ write(path, line(), line(2))
+ original = index._store_line
+ count = 0
+
+ def interrupt(*args, **kwargs):
+ nonlocal count
+ count += 1
+ if count == 2:
+ raise RuntimeError("simulated importer crash")
+ return original(*args, **kwargs)
+
+ monkeypatch.setattr(index, "_store_line", interrupt)
+ with pytest.raises(RuntimeError):
+ index.sync()
+ assert page(index)["total"] == 0
+ monkeypatch.setattr(index, "_store_line", original)
+ assert index.sync()["imported"] == 2
+ assert page(index)["total"] == 2
+
+
+def test_async_terminal_first_repeated_and_cross_scope_ids(tmp_path):
+ index, path = index_for(tmp_path, console=True)
+ terminal = line(
+ 3,
+ callId="same-call",
+ phase="completed",
+ ms=300,
+ output={"code": 200, "usage": {"prompt_tokens": 12, "completion_tokens": 8}},
+ )
+ write(path, terminal)
+ index.sync()
+ identifier = page(index)["logs"][0]["id"]
+ write(
+ path,
+ line(
+ 1,
+ callId="same-call",
+ phase="request",
+ ms=3,
+ input={"async_mode": "async", "user_id": "u"},
+ output={"status": "running"},
+ ),
+ terminal,
+ line(2, tenant="b", callId="same-call", phase="completed"),
+ )
+ write(
+ tmp_path / "console/console-20261026.jsonl", line(4, callId="same-call", phase="completed")
+ )
+ index.sync()
+ result = page(index)
+ assert result["total"] == 2
+ gateway = page(index, scope="gateway")["logs"][0]
+ assert (
+ gateway["id"] == identifier
+ and gateway["duration"] == 300
+ and gateway["status"] == "SUCCESS"
+ )
+ assert gateway["createTime"] == "2026-10-26 10:00:01" and gateway["requestTokens"] == 12
+ assert index.detail("a", identifier)["callId"] == "same-call"
+ assert page(index, cube="b")["total"] == 1
+
+
+def test_combined_filters_pagination_and_json_string_detail(tmp_path):
+ index, path = index_for(tmp_path)
+ write(path, line(1), line(2, ok=False), line(3, tool="add_message"), line(4, tenant="b"))
+ index.sync()
+ assert (
+ page(
+ index,
+ eventType="searchMemory",
+ subjectId="u",
+ subjectType="user",
+ status="success",
+ query="marker-1",
+ startTime="2026-10-26",
+ endTime="2026-10-26",
+ )["total"]
+ == 1
+ )
+ assert page(index, subjectId="u", subjectType="agent")["total"] == 0
+ assert page(index, query="%")["total"] == 0
+ first = page(index, pageSize=1)
+ second = page(index, pageSize=1, pageNum=2)
+ assert first["total"] == second["total"] == 3
+ assert first["logs"][0]["id"] != second["logs"][0]["id"]
+ assert page(index, pageNum=10)["logs"] == []
+
+
+def test_missing_configured_source_fails_explicitly(tmp_path):
+ from server.backend.audit_index import AuditIndex
+ from server.backend.errors import ApiError
+
+ index = AuditIndex(tmp_path / "audit.db", {"gateway": tmp_path / "missing"})
+ with pytest.raises(ApiError) as exc:
+ index.sync()
+ assert exc.value.status_code == 503
+
+
+def test_oversized_partial_and_malformed_fields_do_not_hide_following_rows(tmp_path):
+ from server.backend.audit_index import MAX_LINE_BYTES
+
+ index, path = index_for(tmp_path)
+ with path.open("wb") as file:
+ file.write(b"x" * (MAX_LINE_BYTES + 100))
+ assert index.sync()["invalid"] == 0
+ with path.open("ab") as file:
+ file.write(b"\n")
+ file.write(b'{"tenant":"a","input":{"user_ids":[{},null]},"output":[1,2],"ms":"bad"}\n')
+ write(path, line(1))
+ result = index.sync()
+ assert result["invalid"] == 1 and result["imported"] == 2 and page(index)["total"] == 2
+ assert index.sync()["invalid"] == 0
+
+
+def test_concurrent_imports_do_not_duplicate_and_conflicting_call_event_is_quarantined(tmp_path):
+ from concurrent.futures import ThreadPoolExecutor
+
+ index, path = index_for(tmp_path)
+ write(
+ path, line(callId="x", phase="completed"), line(2, callId="x", tool="add_message"), line(3)
+ )
+ with ThreadPoolExecutor(max_workers=4) as pool:
+ results = list(pool.map(lambda _: index.sync(), range(4)))
+ assert sum(item["imported"] for item in results) == 2
+ assert sum(item["invalid"] for item in results) == 1
+ assert page(index)["total"] == 2
+
+
+def test_sparse_late_terminal_does_not_erase_known_outcome_or_measurements(tmp_path):
+ index, path = index_for(tmp_path)
+ write(
+ path,
+ line(
+ callId="x",
+ phase="completed",
+ ms=300,
+ output={"code": 200, "usage": {"prompt_tokens": 12, "completion_tokens": 8}},
+ ),
+ )
+ index.sync()
+ first = page(index)["logs"][0]
+ write(path, {"tenant": "a", "callId": "x", "phase": "completed", "ts": "2026-10-26T03:00:00Z"})
+ index.sync()
+ row = page(index)["logs"][0]
+ assert row["id"] == first["id"] and row["status"] == "SUCCESS" and row["duration"] == 300
+ assert row["requestTokens"] == 12 and row["responseTokens"] == 8
+ assert index.detail("a", row["id"])["responseBody"] != "null"
+
+
+def test_rejected_line_diagnostics_are_durable_without_raw_payloads(tmp_path):
+ index, path = index_for(tmp_path)
+ path.write_text("{broken-secret\n{}\n", encoding="utf-8")
+ assert index.sync() == {"imported": 0, "invalid": 1, "unscoped": 1}
+ with index.connection() as db:
+ rows = db.execute(
+ "SELECT reason,count(*) FROM audit_rejects GROUP BY reason ORDER BY reason"
+ ).fetchall()
+ assert [tuple(row) for row in rows] == [("invalid", 1), ("unscoped", 1)]
+ assert b"broken-secret" not in index.path.read_bytes()
+
+
+def test_backup_and_temporary_files_are_not_business_call_sources(tmp_path):
+ index, path = index_for(tmp_path)
+ write(path, line())
+ for suffix in (".bak", ".tmp", ".backup", ".gz", ".zip"):
+ write(path.with_name(path.name + suffix), line())
+ assert index.sync()["imported"] == 1
+ assert page(index)["total"] == 1
+
+
+def test_conflicting_genuine_terminal_uses_latest_evidence_and_keeps_explicit_zero(tmp_path):
+ index, path = index_for(tmp_path)
+ write(
+ path,
+ line(callId="x", phase="completed", output={"code": 200, "usage": {"prompt_tokens": 12}}),
+ )
+ index.sync()
+ write(
+ path,
+ line(
+ 2,
+ callId="x",
+ phase="failed",
+ ok=False,
+ output={"code": 500, "usage": {"prompt_tokens": 0}},
+ ),
+ )
+ index.sync()
+ row = page(index)["logs"][0]
+ assert row["status"] == "FAILURE" and row["requestTokens"] == 0
+ write(path, line(callId="x", phase="completed"))
+ index.sync()
+ assert page(index)["logs"][0]["status"] == "FAILURE"
diff --git a/server/backend/tests/test_audit_normalize.py b/server/backend/tests/test_audit_normalize.py
new file mode 100644
index 000000000..c591a7eec
--- /dev/null
+++ b/server/backend/tests/test_audit_normalize.py
@@ -0,0 +1,173 @@
+import json
+
+import pytest
+
+
+def test_legacy_missing_fields_and_subject_is_not_tenant():
+ from server.backend.audit_normalize import normalize
+
+ row = normalize({"tenant": "cube-a", "tool": "get_user_profile"}, "gateway")
+ assert row["event"] == "getMemory"
+ assert row["status"] == "UNKNOWN" and row["duration"] is None
+ assert row["time"] == "" and row["ts"] == 0
+ assert row["users"] == row["agents"] == []
+ assert row["request_tokens"] == row["response_tokens"] == 0
+ assert json.loads(row["request_body"]) is None
+
+
+@pytest.mark.parametrize(
+ "tool,event",
+ [
+ ("add_message", "addMessage"),
+ ("search_memory", "searchMemory"),
+ ("get_user_profile", "getMemory"),
+ ("delete_memory", "deleteMemory"),
+ ("add_feedback", "addFeedback"),
+ ("unknown_tool", "unknown_tool"),
+ ],
+)
+def test_tool_mapping_keeps_unknown_events(tool, event):
+ from server.backend.audit_normalize import normalize
+
+ assert normalize({"tenant": "a", "tool": tool, "ok": True}, "gateway")["event"] == event
+
+
+def test_body_json_strings_preserved_credentials_redacted_and_business_failure_wins():
+ from server.backend.audit_normalize import normalize
+
+ encoded = '{ "query" : "unique-marker" }'
+ row = normalize(
+ {
+ "ts": "2026-10-26T10:00:00+0800",
+ "tool": "search_memory",
+ "tenant": "a",
+ "ok": True,
+ "input": encoded,
+ "output": '{"code":200,"data":{"success":false}}',
+ "ms": 12,
+ },
+ "gateway",
+ )
+ assert row["request_body"] == encoded
+ assert row["status"] == "FAILURE" and row["time"] == "2026-10-26T02:00:00+00:00"
+ redacted = normalize(
+ {
+ "tenant": "a",
+ "tool": "search_memory",
+ "input": {
+ "api_key": "private-test-secret",
+ "nested": {"Authorization": "Bearer private-test-token"},
+ },
+ "output": {"usage": {"prompt_tokens": 11, "completion_tokens": 7}},
+ },
+ "gateway",
+ )
+ assert "private-test" not in redacted["request_body"]
+ assert redacted["request_tokens"] == 11 and redacted["response_tokens"] == 7
+
+
+def test_async_request_duration_is_unknown_and_terminal_aliases():
+ from server.backend.audit_normalize import normalize
+
+ row = normalize(
+ {
+ "tenant": "a",
+ "tool": "add_message",
+ "callId": "call-1",
+ "phase": "request",
+ "ok": True,
+ "ms": 4,
+ "input": {"async_mode": "async", "user_id": "u", "agent_id": "agent"},
+ "output": {"status": "running"},
+ },
+ "gateway",
+ )
+ assert row["status"] == "PENDING" and row["duration"] is None
+ assert row["users"] == ["u"] and row["agents"] == ["agent"]
+ assert row["call_id"] == "call-1" and row["phase"] == "request"
+ done = normalize(
+ {"tenant": "a", "call_id": "call-1", "phase": "completed", "ok": True, "ms": 300}, "gateway"
+ )
+ assert done["phase"] == "terminal" and done["duration"] == 300
+
+
+def test_explicit_request_phase_is_pending_without_async_mode_hint():
+ from server.backend.audit_normalize import normalize
+
+ row = normalize(
+ {
+ "tenant": "a",
+ "tool": "add_message",
+ "callId": "x",
+ "phase": "request",
+ "ok": True,
+ "ms": 5,
+ },
+ "gateway",
+ )
+ assert row["status"] == "PENDING" and row["duration"] is None
+
+
+@pytest.mark.parametrize(
+ "extra",
+ [
+ {"responseCode": 200, "output": {"code": 500}},
+ {"status": "success", "output": {"data": {"status": "failed"}}},
+ {"callId": "x", "phase": "failed"},
+ ],
+)
+def test_any_explicit_business_failure_overrides_outer_success(extra):
+ from server.backend.audit_normalize import normalize
+
+ assert normalize({"tenant": "a", "ok": True, **extra}, "gateway")["status"] == "FAILURE"
+
+
+def test_camelcase_credentials_are_redacted():
+ from server.backend.audit_normalize import normalize
+
+ row = normalize(
+ {
+ "tenant": "a",
+ "input": {
+ "accessToken": "private-access",
+ "refreshToken": "private-refresh",
+ "clientSecret": "private-secret",
+ },
+ },
+ "gateway",
+ )
+ assert "private-" not in row["request_body"]
+
+
+def test_tenant_missing_is_quarantined_not_assigned_from_user():
+ from server.backend.audit_normalize import UnscopedLine, normalize
+
+ with pytest.raises(UnscopedLine):
+ normalize({"tool": "search_memory", "input": {"user_id": "cube-a"}}, "gateway")
+
+
+def test_console_source_comes_from_config_not_payload():
+ from server.backend.audit_normalize import normalize
+
+ row = normalize(
+ {"tenant": "a", "source": "gateway", "eventType": "console.memory.list", "ok": True},
+ "console",
+ )
+ assert row["source"] == "console" and row["event"] == "console.memory.list"
+
+
+def test_query_validation_date_semantics_and_subject_type_clearing():
+ from pydantic import ValidationError
+ from server.backend.audit_models import LogListRequest
+
+ body = LogListRequest(projectId="a", subjectType="user", subjectId="", scope="all")
+ assert body.subjectType is None
+ for extra in (
+ {"pageNum": 0},
+ {"pageSize": 101},
+ {"eventType": "not-a-filter"},
+ {"startTime": "2026-01-02", "endTime": "2026-01-01"},
+ {"endTime": "9999-12-31"},
+ ):
+ with pytest.raises(ValidationError):
+ LogListRequest(projectId="a", **extra)
diff --git a/server/backend/tests/test_authorization_policy.py b/server/backend/tests/test_authorization_policy.py
new file mode 100644
index 000000000..561597140
--- /dev/null
+++ b/server/backend/tests/test_authorization_policy.py
@@ -0,0 +1,98 @@
+"""The admin URL namespace includes both platform and project administration."""
+
+import json
+from pathlib import Path
+
+import pytest
+from fastapi.testclient import TestClient
+from server.backend.tests.test_accounts import application, login
+from server.backend.tests.test_members import member, provision
+
+
+def project_operations(tenant, target, invitation):
+ base = f"/admin/tenants/{tenant}"
+ return [
+ ("GET", base + "/members", None, "viewer"),
+ ("GET", base + "/member-candidates", None, "owner"),
+ ("POST", base + "/members", {"account_id": target, "role": "viewer"}, "owner"),
+ ("POST", base + f"/members/{target}/remove", {}, "owner"),
+ ("GET", base + "/invites", None, "owner"),
+ ("POST", base + "/invites", {"role": "viewer", "expiresIn": "24h"}, "owner"),
+ ("POST", base + f"/invites/{invitation}/role", {"role": "editor"}, "owner"),
+ ("POST", base + f"/invites/{invitation}/revoke", {}, "owner"),
+ ("POST", base + "/members/leave", {}, "viewer"),
+ ]
+
+
+@pytest.mark.parametrize("role", ["viewer", "editor", "owner", None])
+def test_nonplatform_roles_project_management_and_foreign_project(tmp_path, role):
+ with TestClient(application(tmp_path)) as web:
+ admin, user = provision(web)
+ target = web.app.state.identity.create_account("candidate", "candidate-password-123")
+ invitation = web.post(
+ "/admin/tenants/a/invites", json={"role": "viewer", "expiresIn": "24h"}
+ ).json()["data"]["id"]
+ if role:
+ assert member(web, user, role).status_code == 200
+ login(web, "member", "member-password-123")
+ assert web.get("/api/auth/me").json()["data"]["platformAdmin"] is False
+ for method, path, body in [
+ ("GET", "/admin/accounts", None),
+ ("POST", f"/admin/accounts/{admin['id']}", {"disabled": False}),
+ ("DELETE", f"/admin/accounts/{admin['id']}", None),
+ ]:
+ assert web.request(method, path, json=body).status_code == 403
+ # Even an owner of A has no access to B. No operation may alter B.
+ for method, path, body, _ in project_operations("b", target["id"], invitation):
+ assert web.request(method, path, json=body).status_code == 404
+ for method, path, body, minimum in project_operations("a", target["id"], invitation):
+ expected = (
+ 404 if role is None else 200 if minimum == "viewer" or role == "owner" else 403
+ )
+ assert web.request(method, path, json=body).status_code == expected, (method, path)
+ # The owner test leaves after exercising management; the seeded owner remains.
+ login(web)
+ members = web.get("/admin/tenants/a/members").json()["data"]["records"]
+ assert admin["id"] in {row["account_id"] for row in members}
+
+
+def test_anonymous_management_routes_require_session(tmp_path):
+ with TestClient(application(tmp_path)) as web:
+ operations = project_operations("a", "target", "invitation")
+ operations.extend(
+ [
+ ("GET", "/api/auth/me", None, "session"),
+ ("GET", "/admin/accounts", None, "platform"),
+ ("POST", "/admin/accounts/target", {"disabled": True}, "platform"),
+ ("DELETE", "/admin/accounts/target", None, "platform"),
+ ]
+ )
+ for method, path, body, _ in operations:
+ assert web.request(method, path, json=body).status_code == 401
+
+
+def test_spec_documents_every_platform_and_project_permission():
+ spec = json.loads((Path(__file__).parents[3] / "openapi.json").read_text(encoding="utf-8"))
+ for path, operations in spec["paths"].items():
+ if not path.startswith("/admin/") and path != "/api/auth/me":
+ continue
+ for method, operation in operations.items():
+ if method not in {"get", "post", "delete"}:
+ continue
+ policy = operation["x-authorization"]
+ assert policy["loginRequired"] is True
+ if path == "/api/auth/me":
+ assert policy["scope"] == "session"
+ elif path.startswith("/admin/accounts"):
+ assert policy["scope"] == "platform" and policy["platformAdminRequired"] is True
+ else:
+ expected = (
+ "viewer"
+ if path.endswith(("/members/leave", "/members"))
+ and (method == "get" or path.endswith("/leave"))
+ else "owner"
+ )
+ assert policy["scope"] == "project"
+ assert policy["minimumRole"] == expected
+ assert policy["platformAdminOverride"] is True
+ assert policy["selfOnly"] == path.endswith("/members/leave")
diff --git a/server/backend/tests/test_browser_bodyless.py b/server/backend/tests/test_browser_bodyless.py
new file mode 100644
index 000000000..bd6c83139
--- /dev/null
+++ b/server/backend/tests/test_browser_bodyless.py
@@ -0,0 +1,65 @@
+from fastapi.testclient import TestClient
+from server.backend.tests.test_accounts import login
+from server.backend.tests.test_keys import application, provision
+
+ORIGIN = {"Origin": "http://127.0.0.1:5274"}
+
+
+def test_admin_browser_can_revoke_key_without_json_body(tmp_path):
+ with TestClient(application(tmp_path)) as web:
+ provision(web)
+ key = web.post(
+ "/api/dashboard/keys/create", json={"projectId": "a", "keyName": "browser"}
+ ).json()["data"]
+ response = web.post("/api/dashboard/keys/revoke/" + key["id"], headers=ORIGIN)
+ assert response.status_code == 200
+ assert response.json()["code"] == 0
+ assert (
+ web.post("/api/dashboard/keys/list", json={"projectId": "a"}).json()["data"]["total"]
+ == 0
+ )
+
+
+def test_empty_browser_posts_keep_origin_role_and_body_validation(tmp_path):
+ with TestClient(application(tmp_path)) as web:
+ _, user = provision(web)
+ path = "/api/dashboard/keys/revoke/missing"
+ assert web.post(path, headers={"Origin": "https://evil.invalid"}).status_code == 403
+ assert web.post(path, headers=ORIGIN, content=b"{}").status_code == 403
+ assert (
+ web.post(path, headers={**ORIGIN, "Content-Length": "0"}, content=b"{}").status_code
+ == 403
+ )
+ assert (
+ web.post(
+ path, headers={**ORIGIN, "Content-Type": "text/plain"}, content=b"{}"
+ ).status_code
+ == 403
+ )
+ assert web.post("/api/dashboard/keys/create", headers=ORIGIN).status_code == 400
+ key = web.post(
+ "/api/dashboard/keys/create", json={"projectId": "a", "keyName": "protected"}
+ ).json()["data"]
+ web.post("/admin/tenants/a/members", json={"account_id": user["id"], "role": "viewer"})
+ login(web, "member", "member-password-123")
+ assert (
+ web.post("/api/dashboard/keys/revoke/" + key["id"], headers=ORIGIN).status_code == 403
+ )
+
+
+def test_browser_delete_and_project_transitions_without_body(tmp_path):
+ with TestClient(application(tmp_path)) as web:
+ _, user = provision(web)
+ response = web.delete("/admin/accounts/" + user["id"], headers=ORIGIN)
+ assert response.status_code == 200
+ project = web.post(
+ "/api/dashboard/project/create", json={"name": "browser-project", "description": ""}
+ ).json()["data"]
+ assert (
+ web.post("/api/dashboard/project/delete/" + project["id"], headers=ORIGIN).status_code
+ == 200
+ )
+ assert (
+ web.post("/api/dashboard/project/restore/" + project["id"], headers=ORIGIN).status_code
+ == 200
+ )
diff --git a/server/backend/tests/test_compatibility.py b/server/backend/tests/test_compatibility.py
new file mode 100644
index 000000000..66e1c7425
--- /dev/null
+++ b/server/backend/tests/test_compatibility.py
@@ -0,0 +1,67 @@
+import json
+from pathlib import Path
+
+import httpx
+from fastapi.testclient import TestClient
+from server.backend.app import create_app
+from server.backend.settings import Settings
+from server.backend.tests.test_accounts import login, seed
+from server.backend.tests.test_audit_api import validate_contract
+
+
+def test_fixed_endpoints_match_contract_without_upstream(tmp_path):
+ def unavailable(request):
+ raise AssertionError("Fixed responses must never access upstream or fetch URLs")
+
+ with TestClient(
+ create_app(
+ Settings(data_dir=tmp_path, auth_enabled=True),
+ transport=httpx.MockTransport(unavailable),
+ )
+ ) as web:
+ quota = web.get("/api/user/quota/summary", params={"knowledgebaseId": "ignored"})
+ assert quota.status_code == 200
+ validate_contract(quota.json(), "QuotaSummary")
+ assert quota.json()["code"] == 0
+ assert quota.json()["data"] == {
+ "memoryCount": 0,
+ "knowledgebaseCount": 0,
+ "knowledgebaseCapacity": 1000000,
+ "allowedKnowledgebaseCount": 10,
+ }
+ for body in [{}, {"cycleId": "", "includeAllCycles": True}]:
+ response = web.post("/api/user/subscription/details", json=body)
+ assert response.status_code == 200
+ validate_contract(response.json(), "SubscriptionDetails")
+ assert response.json()["data"] == {"cycles": []}
+ assert web.post("/api/dashboard/skill/skillMD", json={"oss_url": "x"}).status_code == 401
+ seed(web.app)
+ login(web)
+ for url in ["", "https://example.invalid/private", "file:///not-readable"]:
+ response = web.post("/api/dashboard/skill/skillMD", json={"oss_url": url})
+ assert response.status_code == 200
+ validate_contract(response.json(), "StringData")
+ assert response.json() == {"code": 0, "data": "", "message": "success"}
+ assert response.headers["cache-control"] == "no-store"
+ bad = web.post("/api/dashboard/skill/skillMD", json={})
+ assert bad.status_code == 400 and bad.json()["code"] == 40000
+ bad = web.post("/api/user/subscription/details", json={"includeAllCycles": "true"})
+ assert bad.status_code == 400 and set(bad.json()) == {"code", "data", "message"}
+
+
+def test_every_spec_operation_has_handler_except_frozen_kb():
+ spec = json.loads(
+ (Path(__file__).resolve().parents[3] / "openapi.json").read_text(encoding="utf-8")
+ )
+ routes = {
+ (method.lower(), route.path)
+ for route in create_app().routes
+ for method in getattr(route, "methods", [])
+ }
+ missing = {
+ (method, path)
+ for path, operations in spec["paths"].items()
+ for method in operations
+ if method in {"get", "post", "put", "patch", "delete"} and (method, path) not in routes
+ }
+ assert missing == {("post", "/api/dashboard/knowledgebase/memory/list")}
diff --git a/server/backend/tests/test_control.py b/server/backend/tests/test_control.py
new file mode 100644
index 000000000..a7bfb9ab5
--- /dev/null
+++ b/server/backend/tests/test_control.py
@@ -0,0 +1,49 @@
+import pytest
+from pydantic import ValidationError
+from server.backend.control import ControlStore
+from server.backend.settings import Settings
+
+
+def configured(tmp_path, seeds=(("a", "cube-a"),), default="a"):
+ return Settings(
+ auth_enabled=False, projects_seed=seeds, default_project_id=default, data_dir=tmp_path
+ )
+
+
+def test_project_mapping_persists_without_authorizing_retired_configuration(tmp_path):
+ store = ControlStore(configured(tmp_path))
+ assert store.resolve_cube("a") == "cube-a"
+ assert store.resolve_cube("cube-a") is None
+ assert ControlStore(configured(tmp_path)).resolve_cube("a") == "cube-a"
+ replacement = ControlStore(configured(tmp_path, (("b", "cube-b"),), "b"))
+ assert replacement.resolve_cube("a") is None
+ assert replacement.resolve_cube("b") == "cube-b"
+ assert [x["id"] for x in replacement.dictionary("b")] == ["b"]
+ with pytest.raises(ValueError, match="conflicts"):
+ ControlStore(configured(tmp_path, (("a", "changed-cube"),), "a"))
+
+
+@pytest.mark.parametrize(
+ "seeds,default",
+ [
+ ((("a", "cube-a"),), "missing"),
+ ((("a", "cube-a"),), None),
+ ((("a", "cube-a"), ("a", "cube-b")), "a"),
+ ((("a", "cube-a"), ("b", "cube-a")), "a"),
+ ("wrong-format", "a"),
+ ("a:cube-a;", "a"),
+ ],
+)
+def test_invalid_project_configuration_fails_startup_validation(tmp_path, seeds, default):
+ with pytest.raises(ValidationError):
+ configured(tmp_path, seeds, default)
+
+
+def test_project_environment_configuration(monkeypatch, tmp_path):
+ monkeypatch.setenv("DASHBOARD_PROJECTS", "a:cube-a;b:cube-b")
+ monkeypatch.setenv("DASHBOARD_DEFAULT_PROJECT_ID", "a")
+ monkeypatch.setenv("DASHBOARD_DATA_DIR", str(tmp_path))
+ settings = Settings.from_env()
+ assert settings.projects_seed == (("a", "cube-a"), ("b", "cube-b"))
+ assert settings.default_project_id == "a"
+ assert settings.data_dir == tmp_path
diff --git a/server/backend/tests/test_entrypoint.py b/server/backend/tests/test_entrypoint.py
new file mode 100644
index 000000000..04ea209c0
--- /dev/null
+++ b/server/backend/tests/test_entrypoint.py
@@ -0,0 +1,32 @@
+import os
+import subprocess
+import sys
+from pathlib import Path
+
+
+def test_module_has_standalone_cli():
+ completed = subprocess.run(
+ [sys.executable, "-m", "server.backend", "--help"],
+ cwd=Path(__file__).resolve().parents[3],
+ capture_output=True,
+ text=True,
+ timeout=15,
+ )
+ assert completed.returncode == 0, completed.stderr
+ assert "--host" in completed.stdout
+ assert "--port" in completed.stdout
+
+
+def test_invalid_startup_configuration_does_not_print_credentials():
+ env = dict(os.environ, DASHBOARD_UPSTREAM_URL="http://user:private-password@localhost/")
+ completed = subprocess.run(
+ [sys.executable, "-m", "server.backend", "--port", "0"],
+ cwd=Path(__file__).resolve().parents[3],
+ env=env,
+ capture_output=True,
+ text=True,
+ timeout=15,
+ )
+ assert completed.returncode == 2
+ assert "private-password" not in completed.stdout + completed.stderr
+ assert "Traceback" not in completed.stderr
diff --git a/server/backend/tests/test_errors.py b/server/backend/tests/test_errors.py
new file mode 100644
index 000000000..1bfff0972
--- /dev/null
+++ b/server/backend/tests/test_errors.py
@@ -0,0 +1,93 @@
+"""Verify errors through the same routing stack used in production."""
+
+import logging
+
+import httpx
+from fastapi import HTTPException
+from fastapi.testclient import TestClient
+from server.backend.app import create_app
+from server.backend.errors import ApiError
+from server.backend.settings import Settings
+
+
+def app_for_test():
+ return create_app(
+ Settings(auth_enabled=False, upstream_url="http://unused.test/"),
+ transport=httpx.MockTransport(lambda request: httpx.Response(500)),
+ )
+
+
+def assert_error(response, status, code):
+ assert response.status_code == status
+ body = response.json()
+ assert set(body) == {"code", "data", "message"}
+ assert body["code"] == code
+ assert body["data"] is None
+ assert isinstance(body["message"], str) and body["message"]
+
+
+def test_unimplemented_business_route_does_not_return_fake_success():
+ with TestClient(app_for_test()) as client:
+ response = client.post("/api/not-implemented", json={})
+ assert_error(response, 404, 40001)
+
+
+def test_method_not_allowed_uses_envelope_and_preserves_allow_header():
+ with TestClient(app_for_test()) as client:
+ response = client.post("/api/meta")
+ assert_error(response, 405, 40000)
+ assert "GET" in response.headers["allow"]
+
+
+def test_validation_error_uses_envelope_without_echoing_input():
+ app = app_for_test()
+
+ @app.get("/test-validation")
+ def validated(count: int):
+ return count
+
+ with TestClient(app) as client:
+ response = client.get("/test-validation", params={"count": "private-input"})
+ assert_error(response, 400, 40000)
+ assert "private-input" not in response.text
+
+
+def test_controlled_error_uses_its_business_code():
+ app = app_for_test()
+
+ @app.get("/test-denied")
+ def denied():
+ raise ApiError(40003, "没有访问权限", status_code=403)
+
+ with TestClient(app) as client:
+ response = client.get("/test-denied")
+ assert_error(response, 403, 40003)
+ assert response.json()["message"] == "没有访问权限"
+
+
+def test_http_exception_does_not_echo_arbitrary_details():
+ app = app_for_test()
+
+ @app.get("/test-http-error")
+ def http_error():
+ raise HTTPException(403, detail={"password": "private-http-detail"})
+
+ with TestClient(app) as client:
+ response = client.get("/test-http-error")
+ assert_error(response, 403, 40003)
+ assert "private-http-detail" not in response.text
+
+
+def test_unexpected_error_is_uniform_without_sensitive_response_or_log(caplog):
+ app = app_for_test()
+
+ @app.get("/test-crash")
+ def crash():
+ raise RuntimeError("private-error-detail")
+
+ with caplog.at_level(logging.ERROR), TestClient(app) as client:
+ response = client.get("/test-crash")
+ assert_error(response, 500, 50000)
+ assert "private-error-detail" not in response.text
+ assert "private-error-detail" not in caplog.text
+ assert "RuntimeError" in caplog.text
diff --git a/server/backend/tests/test_identity_boundaries.py b/server/backend/tests/test_identity_boundaries.py
new file mode 100644
index 000000000..a22cd17ff
--- /dev/null
+++ b/server/backend/tests/test_identity_boundaries.py
@@ -0,0 +1,112 @@
+import json
+import re
+from concurrent.futures import ThreadPoolExecutor
+
+import pytest
+from fastapi.testclient import TestClient
+from server.backend.tests.test_accounts import application, login
+from server.backend.tests.test_invites import create
+from server.backend.tests.test_members import member, provision
+
+
+@pytest.mark.parametrize("csrf", ["恶意", "é" * 43, "x" * 43])
+def test_malformed_csrf_is_forbidden_not_internal_error(tmp_path, csrf):
+ with TestClient(application(tmp_path)) as web:
+ provision(web)
+ assert web.post("/__logout", data={"csrf": csrf}).status_code == 403
+
+
+def test_usage_all_is_membership_scoped_and_read_receipts_are_viewable(tmp_path):
+ with TestClient(application(tmp_path)) as web:
+ admin, user = provision(web)
+ folder = tmp_path / "gateway"
+ folder.mkdir()
+ rows = [
+ {"tenant": cube, "ts": "2026-10-26T00:00:00Z", "tool": "search_memory", "ok": True}
+ for cube in ("cube-a", "cube-b")
+ ]
+ (folder / "calls-20261026.jsonl").write_text(
+ "".join(json.dumps(r) + "\n" for r in rows), encoding="utf-8"
+ )
+ web.app.state.audit.config = web.app.state.settings.model_copy(
+ update={"audit_gateway_dir": folder}
+ )
+ member(web, user, "viewer")
+ login(web, "member", "member-password-123")
+ response = web.post(
+ "/api/dashboard/usage/query",
+ json={"projectId": "all", "startDate": "2026-10-26", "endDate": "2026-10-26"},
+ )
+ assert response.json()["data"]["summary"]["totalCalls"] == 1
+ assert (
+ web.get(
+ "/api/dashboard/project/memory/operation?projectId=a&operationId=missing"
+ ).status_code
+ == 404
+ )
+
+
+def test_concurrent_admin_demotions_keep_one_effective_admin(tmp_path):
+ with TestClient(application(tmp_path)) as web:
+ admin, user = provision(web)
+ web.post(f"/admin/accounts/{user['id']}", json={"platform_admin": True})
+ with TestClient(web.app) as other:
+ login(other, "member", "member-password-123")
+ pairs = [(web, admin), (other, user)]
+ with ThreadPoolExecutor(max_workers=2) as pool:
+ codes = list(
+ pool.map(
+ lambda pair: pair[0]
+ .post(f"/admin/accounts/{pair[1]['id']}", json={"platform_admin": False})
+ .status_code,
+ pairs,
+ )
+ )
+ assert sorted(codes) == [200, 409]
+
+
+def test_invite_creator_deletion_preserves_invitation_and_clears_creator(tmp_path):
+ with TestClient(application(tmp_path)) as web:
+ admin, user = provision(web)
+ member(web, user, "owner")
+ web.post(f"/admin/accounts/{user['id']}", json={"platform_admin": True})
+ # Transfer both seeded workspaces before deleting the old bootstrap owner.
+ web.post("/admin/tenants/b/members", json={"account_id": user["id"], "role": "owner"})
+ invitation = create(web)
+ login(web, "member", "member-password-123")
+ assert web.delete(f"/admin/accounts/{admin['id']}").status_code == 200
+ with web.app.state.control.connection() as db:
+ row = db.execute(
+ "SELECT * FROM project_invites WHERE id=?", (invitation["id"],)
+ ).fetchone()
+ assert row["created_by_account_id"] is None
+ assert (
+ db.execute(
+ "SELECT count(*) FROM projects WHERE owner_account_id=?", (admin["id"],)
+ ).fetchone()[0]
+ == 0
+ )
+ assert web.get(invitation["path"]).status_code == 200
+
+
+def test_existing_account_invite_login_continuation_is_local_only(tmp_path):
+ with TestClient(application(tmp_path)) as web:
+ provision(web)
+ invitation = create(web)
+ for next_path in (invitation["path"], "https://evil.example/"):
+ page = web.get("/__login", params={"next": next_path})
+ csrf = re.search(r'name="csrf" value="([^"]+)"', page.text)[1]
+ reply = web.post(
+ "/__login",
+ data={
+ "username": "member",
+ "password": "member-password-123",
+ "csrf": csrf,
+ "next": next_path,
+ },
+ follow_redirects=False,
+ )
+ assert reply.status_code == 303
+ assert reply.headers["location"] == (
+ next_path if next_path.startswith("/__login/invite/") else "/cn/projects"
+ )
diff --git a/server/backend/tests/test_identity_contracts.py b/server/backend/tests/test_identity_contracts.py
new file mode 100644
index 000000000..cadfb8ed8
--- /dev/null
+++ b/server/backend/tests/test_identity_contracts.py
@@ -0,0 +1,39 @@
+from fastapi.testclient import TestClient
+from server.backend.tests.test_accounts import application, login
+from server.backend.tests.test_audit_api import validate_contract
+from server.backend.tests.test_members import provision
+
+
+def test_authenticated_accounts_members_and_invites_match_root_contract(tmp_path):
+ with TestClient(application(tmp_path)) as web:
+ admin, user = provision(web)
+ validate_contract(web.get("/api/auth/me").json(), "Account")
+ validate_contract(web.get("/admin/accounts").json(), "AdminAccountRecords")
+ validate_contract(
+ web.post(f"/admin/accounts/{user['id']}", json={"display_name": "Edited"}).json(),
+ "AdminAccount",
+ )
+ prefix = "/admin/tenants/a/"
+ validate_contract(web.get(prefix + "members").json(), "ProjectMemberRecords")
+ validate_contract(
+ web.get(prefix + "member-candidates?query=member").json(), "MemberCandidateRecords"
+ )
+ validate_contract(
+ web.post(prefix + "members", json={"account_id": user["id"], "role": "viewer"}).json(),
+ "MemberSet",
+ )
+ created = web.post(prefix + "invites", json={"role": "editor"}).json()
+ validate_contract(created, "CreatedInvite")
+ identifier = created["data"]["id"]
+ validate_contract(web.get(prefix + "invites").json(), "ProjectInviteRecords")
+ validate_contract(
+ web.post(prefix + f"invites/{identifier}/role", json={"role": "viewer"}).json(),
+ "InviteRole",
+ )
+ validate_contract(web.post(prefix + f"invites/{identifier}/revoke", json={}).json(), "Ok")
+ # A real non-admin owner receives owner permission from membership, not platformAdmin.
+ web.post(prefix + "members", json={"account_id": user["id"], "role": "owner"})
+ login(web, "member", "member-password-123")
+ assert web.get(prefix + "member-candidates?query=admin").status_code == 200
+ assert web.post(prefix + "invites", json={"role": "viewer"}).status_code == 200
+ assert web.get("/admin/accounts").status_code == 403
diff --git a/server/backend/tests/test_invites.py b/server/backend/tests/test_invites.py
new file mode 100644
index 000000000..092d811ee
--- /dev/null
+++ b/server/backend/tests/test_invites.py
@@ -0,0 +1,149 @@
+import re
+from concurrent.futures import ThreadPoolExecutor
+
+from fastapi.testclient import TestClient
+from server.backend.tests.test_accounts import application, login
+from server.backend.tests.test_members import provision
+
+BASE = "/admin/tenants/a/invites"
+
+
+def create(web, role="viewer", **extra):
+ response = web.post(BASE, json={"role": role, **extra})
+ assert response.status_code == 200, response.text
+ return response.json()["data"]
+
+
+def accept(web, path, **extra):
+ page = web.get(path)
+ assert page.status_code == 200, page.text
+ csrf = re.search(r'name="csrf" value="([^"]+)"', page.text)[1]
+ return web.post(path, data={"csrf": csrf, **extra}, follow_redirects=False)
+
+
+def test_invitation_create_role_list_digest_and_single_use(tmp_path):
+ with TestClient(application(tmp_path)) as web:
+ admin, user = provision(web)
+ invitation = create(web)
+ assert re.fullmatch(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}", invitation["expiresAt"])
+ path, identifier = invitation["path"], invitation["id"]
+ token = path.rsplit("/", 1)[1]
+ assert token not in web.get(BASE).text
+ with web.app.state.control.connection() as db:
+ assert token not in str([tuple(r) for r in db.execute("SELECT * FROM project_invites")])
+ assert web.post(BASE + f"/{identifier}/role", json={"role": "editor"}).status_code == 200
+ login(web, "member", "member-password-123")
+ assert accept(web, path).status_code == 303
+ assert web.get("/api/auth/me").json()["data"]["currentProjectId"] == "a"
+ assert web.get("/admin/tenants/a/members").json()["data"]["records"][1]["role"] == "editor"
+ assert web.post(path, data={"csrf": ""}).status_code == 403
+ assert web.get(path).status_code == 409
+
+
+def test_revoked_expired_disabled_workspace_and_cross_tenant(tmp_path):
+ with TestClient(application(tmp_path)) as web:
+ provision(web)
+ revoked = create(web)
+ assert web.post(BASE + f"/{revoked['id']}/revoke", json={}).status_code == 200
+ assert re.fullmatch(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}", revoked["expiresAt"])
+ assert web.get(revoked["path"]).status_code == 409
+ expired = create(web, expiresIn="1s")
+ with web.app.state.control.connection() as db:
+ db.execute("UPDATE project_invites SET expires_at=0 WHERE id=?", (expired["id"],))
+ assert web.get(expired["path"]).status_code == 410
+ assert web.get(BASE).json()["data"]["records"][0]["status"] in {"expired", "revoked"}
+ revoked_row = next(
+ row for row in web.get(BASE).json()["data"]["records"] if row["id"] == revoked["id"]
+ )
+ assert re.fullmatch(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}", revoked_row["revokedAt"])
+ assert (
+ web.post(
+ f"/admin/tenants/b/invites/{revoked['id']}/role", json={"role": "owner"}
+ ).status_code
+ == 404
+ )
+ active = create(web)
+ web.post("/api/dashboard/project/update", json={"id": "a", "status": "DISABLED"})
+ assert web.get(active["path"]).status_code == 409
+
+
+def test_signup_accept_and_no_partial_account_on_reused_invite(tmp_path):
+ with TestClient(application(tmp_path)) as admin:
+ provision(admin)
+ invitation = create(admin, "owner")
+ with TestClient(admin.app) as anonymous:
+ assert (
+ anonymous.post(
+ invitation["path"], data={"username": "joined", "password": "new-password-123"}
+ ).status_code
+ == 403
+ )
+ assert (
+ accept(
+ anonymous, invitation["path"], username="joined", password="new-password-123"
+ ).status_code
+ == 303
+ )
+ me = anonymous.get("/api/auth/me").json()["data"]
+ assert me["username"] == "joined" and me["currentProjectId"] == "a"
+ assert next(p for p in me["projects"] if p["id"] == "a")["role"] == "owner"
+ assert admin.get("/admin/accounts?query=joined").json()["data"]["total"] == 1
+
+
+def test_existing_member_not_downgraded_and_role_validation(tmp_path):
+ with TestClient(application(tmp_path)) as web:
+ admin, user = provision(web)
+ for body in (
+ {"role": "admin"},
+ {"role": "viewer", "expiresIn": "0h"},
+ {"role": "viewer", "expiresIn": "999999h"},
+ ):
+ assert web.post(BASE, json=body).status_code == 400
+ invitation = create(web)
+ assert accept(web, invitation["path"]).status_code == 409
+ assert web.get(BASE).json()["data"]["records"][0]["status"] == "active"
+ login(web, "member", "member-password-123")
+ assert web.get(BASE).status_code == 404
+
+
+def test_concurrent_accept_only_one_membership_created(tmp_path):
+ with TestClient(application(tmp_path)) as admin:
+ provision(admin)
+ invitation = create(admin)
+ users = [
+ admin.app.state.identity.create_account(f"join{i}", "password-for-join-123")
+ for i in range(2)
+ ]
+ clients = [TestClient(admin.app), TestClient(admin.app)]
+ for i, web in enumerate(clients):
+ web.__enter__()
+ login(web, f"join{i}", "password-for-join-123")
+ try:
+ forms = [
+ re.search(r'name="csrf" value="([^"]+)"', web.get(invitation["path"]).text)[1]
+ for web in clients
+ ]
+ with ThreadPoolExecutor(max_workers=2) as pool:
+ codes = list(
+ pool.map(
+ lambda i: clients[i]
+ .post(invitation["path"], data={"csrf": forms[i]}, follow_redirects=False)
+ .status_code,
+ range(2),
+ )
+ )
+ assert sorted(codes) == [303, 409]
+ with admin.app.state.control.connection() as db:
+ assert (
+ db.execute(
+ (
+ "SELECT count(*) FROM memberships WHERE tenant_id='a' "
+ "AND account_id IN (?,?)"
+ ),
+ tuple(u["id"] for u in users),
+ ).fetchone()[0]
+ == 1
+ )
+ finally:
+ for web in clients:
+ web.__exit__(None, None, None)
diff --git a/server/backend/tests/test_keys.py b/server/backend/tests/test_keys.py
new file mode 100644
index 000000000..3746a1a43
--- /dev/null
+++ b/server/backend/tests/test_keys.py
@@ -0,0 +1,368 @@
+"""Task 2 RED:/api/dashboard/keys 五条路由(owner-only、契约形状、文件同步回滚、审计打码)。"""
+
+import json
+import re
+
+import httpx
+from fastapi.testclient import TestClient
+from server.backend.app import create_app
+from server.backend.settings import Settings
+from server.backend.tests.test_accounts import login, seed
+
+FIVE_ROUTES = (
+ ("/api/dashboard/keys/list", {"projectId": "a"}),
+ ("/api/dashboard/keys/create", {"projectId": "a", "keyName": "x"}),
+ ("/api/dashboard/keys/copy", {"apiKeyId": "key_" + "0" * 32}),
+ ("/api/dashboard/keys/rename/x", {"keyName": "x"}),
+ ("/api/dashboard/keys/revoke/x", {}),
+)
+
+def application(tmp_path, **overrides):
+ settings = Settings(
+ data_dir=tmp_path / "data",
+ projects_seed=(("a", "cube-a"), ("b", "cube-b")),
+ default_project_id="a",
+ auth_enabled=True,
+ gateway_keys_file=overrides.pop("gateway_keys_file", tmp_path / "gateway" / "tenants.json"),
+ audit_console_dir=overrides.pop("audit_console_dir", None),
+ **overrides,
+ )
+ return create_app(settings, transport=httpx.MockTransport(lambda r: httpx.Response(500)))
+
+
+def provision(web):
+ admin = seed(web.app)
+ member = web.app.state.identity.create_account("member", "member-password-123")
+ login(web)
+ return admin, member
+
+
+def grant_admin(web):
+ """回到管理员会话(平台管理员对所有项目隐式 owner)。"""
+ login(web)
+
+
+def login_member(web):
+ login(web, "member", "member-password-123")
+
+
+def test_create_contract_shape_and_format(tmp_path):
+ with TestClient(application(tmp_path)) as web:
+ provision(web)
+ body = web.post("/api/dashboard/keys/create", json={"projectId": "a", "keyName": "主密钥"})
+ assert body.status_code == 200
+ envelope = body.json()
+ assert envelope["code"] == 0 and envelope["message"] == "success"
+ data = envelope["data"]
+ assert re.fullmatch(r"key_[0-9a-f]{32}", data["id"])
+ assert data["keyName"] == "主密钥"
+ assert re.fullmatch(r"sk-[0-9a-f]{48}", data["apiKey"])
+ assert re.fullmatch(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}", data["createTime"])
+ second = web.post(
+ "/api/dashboard/keys/create", json={"projectId": "a", "keyName": "二"}
+ ).json()["data"]
+ assert second["apiKey"] != data["apiKey"] and second["id"] != data["id"]
+
+
+def test_list_contract_with_stable_pagination(tmp_path):
+ with TestClient(application(tmp_path)) as web:
+ provision(web)
+ tokens = {}
+ for name in ("k1", "k2", "k3"):
+ row = web.post(
+ "/api/dashboard/keys/create", json={"projectId": "a", "keyName": name}
+ ).json()["data"]
+ tokens[name] = row["apiKey"]
+ page = web.post(
+ "/api/dashboard/keys/list", json={"projectId": "a", "pageNum": 1, "pageSize": 2}
+ ).json()["data"]
+ assert page["total"] == 3 and len(page["records"]) == 2
+ assert {r["apiKey"] for r in page["records"]} <= set(tokens.values())
+ tail = web.post(
+ "/api/dashboard/keys/list", json={"projectId": "a", "pageNum": 2, "pageSize": 2}
+ ).json()["data"]
+ assert tail["total"] == 3 and {r["id"] for r in page["records"]}.isdisjoint(
+ {r["id"] for r in tail["records"]}
+ )
+
+
+def test_copy_returns_raw_string(tmp_path):
+ with TestClient(application(tmp_path)) as web:
+ provision(web)
+ row = web.post(
+ "/api/dashboard/keys/create", json={"projectId": "a", "keyName": "k"}
+ ).json()["data"]
+ copied = web.post("/api/dashboard/keys/copy", json={"apiKeyId": row["id"]})
+ assert copied.status_code == 200
+ assert copied.json()["code"] == 0 and copied.json()["data"] == row["apiKey"]
+
+
+def test_revoke_soft_and_aftermath(tmp_path):
+ with TestClient(application(tmp_path)) as web:
+ provision(web)
+ row = web.post(
+ "/api/dashboard/keys/create", json={"projectId": "a", "keyName": "k"}
+ ).json()["data"]
+ revoked = web.post(f"/api/dashboard/keys/revoke/{row['id']}", json={})
+ assert revoked.status_code == 200 and revoked.json()["data"] == {"ok": True}
+ listing = web.post("/api/dashboard/keys/list", json={"projectId": "a"}).json()["data"]
+ assert listing["total"] == 0
+ assert web.post("/api/dashboard/keys/copy", json={"apiKeyId": row["id"]}).status_code == 404
+ assert (
+ web.post(
+ f"/api/dashboard/keys/rename/{row['id']}", json={"keyName": "n"}
+ ).status_code
+ == 404
+ )
+ with web.app.state.control.connection() as db:
+ kept = db.execute(
+ "SELECT status, token FROM api_keys WHERE id=?", (row["id"],)
+ ).fetchone()
+ assert kept["status"] == "revoked" and kept["token"] == row["apiKey"]
+
+
+def test_rename_contract_and_manifest_untouched(tmp_path):
+ manifest = tmp_path / "gateway" / "tenants.json"
+ with TestClient(application(tmp_path)) as web:
+ provision(web)
+ row = web.post(
+ "/api/dashboard/keys/create", json={"projectId": "a", "keyName": "before"}
+ ).json()["data"]
+ before = manifest.read_bytes()
+ renamed = web.post(
+ f"/api/dashboard/keys/rename/{row['id']}", json={"keyName": "after"}
+ )
+ assert renamed.status_code == 200 and renamed.json()["data"]["keyName"] == "after"
+ assert manifest.read_bytes() == before
+
+
+def test_manifest_written_on_create(tmp_path):
+ manifest = tmp_path / "gateway" / "tenants.json"
+ with TestClient(application(tmp_path)) as web:
+ provision(web)
+ row = web.post(
+ "/api/dashboard/keys/create", json={"projectId": "a", "keyName": "k"}
+ ).json()["data"]
+ listing = json.loads(manifest.read_text(encoding="utf-8"))
+ assert listing == {row["apiKey"]: {"user_id": "a", "cube_id": "a"}}
+
+
+def test_role_matrix_forbidden_probes(tmp_path):
+ with TestClient(application(tmp_path)) as web:
+ admin, member = provision(web)
+
+ def member_role(role):
+ grant_admin(web)
+ put = web.post(
+ "/admin/tenants/a/members", json={"account_id": member["id"], "role": role}
+ )
+ assert put.status_code == 200, put.text
+ owned = web.post(
+ "/api/dashboard/keys/create", json={"projectId": "a", "keyName": "owned"}
+ ).json()["data"]
+ login_member(web)
+ assert web.post("/api/dashboard/keys/list", json={"projectId": "a"}).status_code == 403
+ assert (
+ web.post(
+ "/api/dashboard/keys/create", json={"projectId": "a", "keyName": "n"}
+ ).status_code
+ == 403
+ )
+ assert (
+ web.post("/api/dashboard/keys/copy", json={"apiKeyId": owned["id"]}).status_code
+ == 403
+ )
+ assert (
+ web.post(f"/api/dashboard/keys/revoke/{owned['id']}", json={}).status_code == 403
+ )
+ assert (
+ web.post(
+ f"/api/dashboard/keys/rename/{owned['id']}", json={"keyName": "n"}
+ ).status_code
+ == 403
+ )
+
+ member_role("viewer")
+ member_role("editor")
+
+ # 平台管理员建在 a 的密钥对 b 会话不可见:先把成员改成 b-only。
+ grant_admin(web)
+ assert (
+ web.post(f"/admin/tenants/a/members/{member['id']}/remove", json={}).status_code == 200
+ )
+ grant_admin(web)
+ assert (
+ web.post("/admin/tenants/b/members", json={"account_id": member["id"], "role": "owner"})
+ .status_code
+ == 200
+ )
+ web.post(
+ "/api/dashboard/keys/create", json={"projectId": "a", "keyName": "hidden"}
+ ).json()["data"]
+ login_member(web)
+ assert web.post("/api/dashboard/keys/list", json={"projectId": "a"}).status_code == 404
+ own = web.post("/api/dashboard/keys/list", json={"projectId": "b"}).json()["data"]
+ assert own["total"] == 0
+
+ # 匿名(无 cookie 的独立 client)一律 401。
+ with TestClient(application(tmp_path)) as stranger:
+ for route, body in FIVE_ROUTES:
+ assert stranger.post(route, json=body).status_code == 401
+
+
+def test_auth_off_mode_owner_pass(tmp_path):
+ off = Settings(
+ data_dir=tmp_path / "off" / "data",
+ projects_seed=(("a", "cube-a"), ("b", "cube-b")),
+ default_project_id="a",
+ auth_enabled=False,
+ gateway_keys_file=tmp_path / "off" / "tenants.json",
+ )
+ with TestClient(
+ create_app(off, transport=httpx.MockTransport(lambda r: httpx.Response(500)))
+ ) as web:
+ created = web.post(
+ "/api/dashboard/keys/create", json={"projectId": "a", "keyName": "ops"}
+ ).json()["data"]
+ assert re.fullmatch(r"sk-[0-9a-f]{48}", created["apiKey"])
+ listing = web.post("/api/dashboard/keys/list", json={"projectId": "a"}).json()["data"]
+ assert listing["total"] == 1
+
+
+def test_create_not_configured_503(tmp_path):
+ with TestClient(application(tmp_path, gateway_keys_file=None)) as web:
+ provision(web)
+ failed_create = web.post(
+ "/api/dashboard/keys/create", json={"projectId": "a", "keyName": "x"}
+ )
+ assert failed_create.status_code == 503
+ assert "交付路径" in failed_create.json()["message"]
+ failed_revoke = web.post("/api/dashboard/keys/revoke/x", json={})
+ assert failed_revoke.status_code == 503
+ assert "交付路径" in failed_revoke.json()["message"]
+ for route, payload in (
+ ("/api/dashboard/keys/copy", {"apiKeyId": "key_" + "0" * 32}),
+ ("/api/dashboard/keys/rename/x", {"keyName": "x"}),
+ ):
+ assert web.post(route, json=payload).status_code == 404, route
+ assert web.post("/api/dashboard/keys/list", json={"projectId": "a"}).status_code == 200
+
+
+def test_create_rolls_back_if_manifest_write_fails(tmp_path, monkeypatch):
+ manifest = tmp_path / "gateway" / "tenants.json"
+ with TestClient(application(tmp_path)) as web:
+ provision(web)
+
+ def boom(path, entries):
+ raise OSError("disk on fire")
+
+ monkeypatch.setattr("server.backend.keys.write_tenants_conf", boom)
+ failed = web.post("/api/dashboard/keys/create", json={"projectId": "a", "keyName": "k"})
+ assert failed.status_code == 503
+ assert "网关配置写入失败" in failed.json()["message"]
+ monkeypatch.undo()
+ libs = web.post("/api/dashboard/keys/list", json={"projectId": "a"}).json()["data"]
+ assert libs["total"] == 0
+ with web.app.state.control.connection() as db:
+ count = db.execute("SELECT count(*) FROM api_keys").fetchone()[0]
+ # 启动自愈已写出空清单(Task 3 起 manifest 恒存在),回滚后的重点:零 token。
+ assert count == 0 and json.loads(manifest.read_text(encoding="utf-8")) == {}
+
+
+def test_revoke_rolls_back_if_manifest_write_fails(tmp_path, monkeypatch):
+ manifest = tmp_path / "gateway" / "tenants.json"
+ with TestClient(application(tmp_path)) as web:
+ provision(web)
+ row = web.post(
+ "/api/dashboard/keys/create", json={"projectId": "a", "keyName": "k"}
+ ).json()["data"]
+ before = manifest.read_bytes()
+
+ def boom(path, entries):
+ raise OSError("disk on fire")
+
+ monkeypatch.setattr("server.backend.keys.write_tenants_conf", boom)
+ failed = web.post(f"/api/dashboard/keys/revoke/{row['id']}", json={})
+ assert failed.status_code == 503
+ monkeypatch.undo()
+ assert web.post("/api/dashboard/keys/copy", json={"apiKeyId": row["id"]}).json()[
+ "data"
+ ] == row["apiKey"]
+ assert manifest.read_bytes() == before
+
+
+def test_self_heal_on_startup(tmp_path):
+ gateway = tmp_path / "gateway" / "tenants.json"
+ with TestClient(application(tmp_path)) as web:
+ provision(web)
+ row = web.post(
+ "/api/dashboard/keys/create", json={"projectId": "a", "keyName": "k"}
+ ).json()["data"]
+ # 模拟一次崩溃的 create:manifest 里留下一把库里没有的孤儿 token。
+ orphan = "sk-" + "0" * 48
+ gateway.write_text(
+ json.dumps(
+ {
+ orphan: {"user_id": "a", "cube_id": "a"},
+ row["apiKey"]: {"user_id": "a", "cube_id": "a"},
+ }
+ ),
+ encoding="utf-8",
+ )
+ with TestClient(application(tmp_path)) as web:
+ pass # TestClient 进入即触发 lifespan:启动自愈就在此发生。
+ payload = json.loads(gateway.read_text(encoding="utf-8"))
+ assert set(payload) == {row["apiKey"]} and orphan not in payload
+
+ # 空库启动 → 写出合法空清单而不是干脆不写。
+ empty = tmp_path / "empty"
+ with TestClient(application(empty, gateway_keys_file=empty / "tenants.json")) as web:
+ pass
+ assert json.loads((empty / "tenants.json").read_text(encoding="utf-8")) == {}
+
+
+def test_archived_project_keys_hidden(tmp_path):
+ with TestClient(application(tmp_path)) as web:
+ provision(web)
+ created = web.post(
+ "/api/dashboard/keys/create", json={"projectId": "a", "keyName": "k"}
+ ).json()["data"]
+ assert (
+ web.post("/api/dashboard/project/revoke/a", json={}).status_code == 200
+ ) # 既有语义:legacy revoke = 可恢复归档
+ probes = (
+ ("/api/dashboard/keys/list", {"projectId": "a"}),
+ ("/api/dashboard/keys/copy", {"apiKeyId": created["id"]}),
+ (f"/api/dashboard/keys/rename/{created['id']}", {"keyName": "n"}),
+ )
+ for route, body in probes:
+ probe = web.post(route, json=body)
+ assert probe.status_code == 404, route
+ assert web.post(f"/api/dashboard/keys/revoke/{created['id']}", json={}).status_code == 404
+ assert (
+ web.post("/api/dashboard/project/restore/a", json={}).status_code == 200
+ )
+ restored = web.post("/api/dashboard/keys/list", json={"projectId": "a"}).json()["data"]
+ assert restored["total"] == 1 and restored["records"][0]["apiKey"].startswith("sk-")
+
+
+def test_console_audit_redacts_token(tmp_path):
+ console = tmp_path / "console"
+ with TestClient(application(tmp_path, audit_console_dir=str(console))) as web:
+ provision(web)
+ row = web.post(
+ "/api/dashboard/keys/create", json={"projectId": "a", "keyName": "k"}
+ ).json()["data"]
+ from server.backend.audit_normalize import redact
+
+ texts = []
+ for path in console.rglob("*"):
+ if path.is_file():
+ texts.append(path.read_text(encoding="utf-8", errors="ignore"))
+ combined = "\n".join(texts)
+ # ①恒久不变量:console 目录里不允许出现完整 sk- 值(keys 路由不在有界捕获集内)。
+ assert row["apiKey"] not in combined
+ assert "/api/dashboard/keys/create" not in combined
+ # ②管底打码回归钉:audit_normalize 对与密钥同形载荷永远打码。
+ assert redact({"apiKey": row["apiKey"]})["apiKey"] == "[REDACTED]"
+ assert "[REDACTED]" in redact(f"raw {row['apiKey']} tail")
diff --git a/server/backend/tests/test_keys_consistency.py b/server/backend/tests/test_keys_consistency.py
new file mode 100644
index 000000000..bb4e095c7
--- /dev/null
+++ b/server/backend/tests/test_keys_consistency.py
@@ -0,0 +1,99 @@
+import json
+import sqlite3
+from contextlib import contextmanager
+from unittest.mock import patch
+
+import pytest
+from fastapi.testclient import TestClient
+from server.backend.control import ControlStore
+from server.backend.tests.test_keys import application, provision
+
+ORIGINAL = ControlStore.connection
+
+
+@contextmanager
+def deny_commit(self):
+ with ORIGINAL(self) as db:
+
+ def authorize(action, operation, *_):
+ if action == sqlite3.SQLITE_TRANSACTION and operation == "COMMIT":
+ return sqlite3.SQLITE_DENY
+ return sqlite3.SQLITE_OK
+
+ db.set_authorizer(authorize)
+ yield db
+
+
+@pytest.mark.parametrize("revoke", [False, True])
+def test_commit_failure_restores_committed_manifest(tmp_path, revoke):
+ manifest = tmp_path / "gateway/tenants.json"
+ with TestClient(application(tmp_path)) as web:
+ provision(web)
+ key = (
+ web.post(
+ "/api/dashboard/keys/create", json={"projectId": "a", "keyName": "keep"}
+ ).json()["data"]
+ if revoke
+ else None
+ )
+ before = manifest.read_bytes()
+ with patch.object(ControlStore, "connection", deny_commit):
+ result = (
+ web.post("/api/dashboard/keys/revoke/" + key["id"], json={})
+ if revoke
+ else web.post(
+ "/api/dashboard/keys/create", json={"projectId": "a", "keyName": "fail"}
+ )
+ )
+ assert result.status_code == 503
+ assert manifest.read_bytes() == before
+ with web.app.state.control.connection() as db:
+ assert db.execute("SELECT count(*) FROM api_keys WHERE status='active'").fetchone()[
+ 0
+ ] == int(revoke)
+
+
+def test_compensation_failure_blocks_publish_until_self_heal(tmp_path, monkeypatch):
+ from server.backend import key_store
+
+ manifest = tmp_path / "gateway/tenants.json"
+ with TestClient(application(tmp_path)) as web:
+ provision(web)
+ original = key_store.write_tenants_conf
+ calls = []
+
+ def fail_repair(path, entries):
+ calls.append(1)
+ if len(calls) > 1:
+ raise OSError("simulated repair failure")
+ return original(path, entries)
+
+ monkeypatch.setattr(key_store, "write_tenants_conf", fail_repair)
+ # Current code calls the imported writer; target both names for the RED probe.
+ monkeypatch.setattr("server.backend.keys.write_tenants_conf", fail_repair, raising=False)
+ with patch.object(ControlStore, "connection", deny_commit):
+ result = web.post(
+ "/api/dashboard/keys/create", json={"projectId": "a", "keyName": "fail"}
+ )
+ assert result.status_code == 503
+ assert manifest.with_name(manifest.name + ".pending").exists()
+ monkeypatch.undo()
+ key_store.sync_manifest(key_store.ApiKeyStore(web.app.state.control), manifest)
+ assert json.loads(manifest.read_text()) == {}
+ assert not manifest.with_name(manifest.name + ".pending").exists()
+
+
+def test_atomic_writer_cleans_secret_temporary_file_on_failure(tmp_path, monkeypatch):
+ from server.backend import key_store
+
+ target = tmp_path / "tenants.conf"
+ target.write_text("{}")
+
+ def fail(*_):
+ raise OSError("replace failure")
+
+ monkeypatch.setattr(key_store.os, "replace", fail)
+ with pytest.raises(OSError):
+ key_store.write_tenants_conf(target, [("synthetic-value", "a")])
+ assert target.read_text() == "{}"
+ assert not list(tmp_path.glob("*.tmp"))
diff --git a/server/backend/tests/test_keys_store.py b/server/backend/tests/test_keys_store.py
new file mode 100644
index 000000000..9717a54e6
--- /dev/null
+++ b/server/backend/tests/test_keys_store.py
@@ -0,0 +1,124 @@
+"""Task 1 RED:control.db 的 api_keys 表 + key_store 的最小仓库语义。"""
+
+import hashlib
+import json
+
+from server.backend.control import ControlStore
+from server.backend.key_store import ApiKeyStore, sync_manifest, write_tenants_conf
+from server.backend.settings import Settings
+
+
+def store_on(tmp_path):
+ settings = Settings(
+ data_dir=tmp_path / "data",
+ projects_seed=(("a", "cube-a"), ("b", "cube-b")),
+ default_project_id="a",
+ auth_enabled=True,
+ )
+ control = ControlStore(settings)
+ return control, ApiKeyStore(control)
+
+
+def insert(control, store, project_id, name):
+ with control.connection() as db:
+ db.execute("BEGIN IMMEDIATE")
+ return store.insert(db, project_id, name, None)
+
+
+def test_create_format_and_listing(tmp_path):
+ control, store = store_on(tmp_path)
+ rows = [
+ insert(control, store, "a", "k1"),
+ insert(control, store, "a", "k2"),
+ insert(control, store, "a", "k3"),
+ ]
+ row = rows[0]
+ assert row["id"].startswith("key_") and len(row["id"]) == 36
+ int(row["id"][4:], 16)
+ assert row["token"].startswith("sk-") and len(row["token"]) == 51
+ int(row["token"][3:], 16)
+ assert row["token_hash"] == hashlib.sha256(row["token"].encode()).hexdigest()
+ assert row["status"] == "active" and row["revoke_time"] is None and row["revoked_by"] is None
+ records, total = store.page(None, "a", 1, 10)
+ expected = sorted(rows, key=lambda r: (r["create_time"], r["id"]), reverse=True)
+ assert total == 3 and [r["id"] for r in records] == [r["id"] for r in expected]
+ tail, tail_total = store.page(None, "a", 2, 2)
+ assert tail_total == 3 and [r["id"] for r in tail] == [r["id"] for r in expected[2:]]
+
+
+def test_rename_keeps_token_and_status(tmp_path):
+ control, store = store_on(tmp_path)
+ row = insert(control, store, "a", "old")
+ with control.connection() as db:
+ renamed = store.rename(db, row["id"], "new")
+ assert renamed["name"] == "new"
+ assert renamed["token"] == row["token"] and renamed["status"] == "active"
+ assert renamed["create_time"] == row["create_time"]
+ assert store.rename(None, "key_" + "0" * 32, "x") is None
+
+
+def test_revoke_soft_row_remains(tmp_path):
+ control, store = store_on(tmp_path)
+ row = insert(control, store, "a", "k")
+ with control.connection() as db:
+ revoked = store.mark_revoked(db, row["id"], "admin-1")
+ assert revoked["status"] == "revoked"
+ assert revoked["revoke_time"] and revoked["revoked_by"] == "admin-1"
+ assert store.active_entries(None) == []
+ assert store.get(None, row["id"])["token"] == row["token"]
+ with control.connection() as db:
+ assert store.mark_revoked(db, row["id"], None) is None
+
+
+def test_persistence_across_control_instances(tmp_path):
+ settings = Settings(
+ data_dir=tmp_path / "data",
+ projects_seed=(("a", "cube-a"), ("b", "cube-b")),
+ default_project_id="a",
+ auth_enabled=True,
+ )
+ control = ControlStore(settings)
+ store = ApiKeyStore(control)
+ first = insert(control, store, "a", "live")
+ dead = insert(control, store, "a", "dead")
+ with control.connection() as db:
+ db.execute("BEGIN IMMEDIATE")
+ store.mark_revoked(db, dead["id"], None)
+ fresh = ApiKeyStore(ControlStore(Settings(
+ data_dir=tmp_path / "data",
+ projects_seed=(("a", "cube-a"), ("b", "cube-b")),
+ default_project_id="a",
+ auth_enabled=True,
+ )))
+ records, total = fresh.page(None, "a", 1, 10)
+ assert total == 1 and records[0]["id"] == first["id"]
+ assert fresh.get(None, first["id"])["status"] == "active"
+ assert fresh.active_entries(None) == [(first["token"], "a")]
+
+
+def test_write_tenants_conf_shape_and_atomic(tmp_path):
+ target = tmp_path / "tenants.conf"
+ write_tenants_conf(target, [("tok1", "a"), ("tok2", "b")])
+ assert json.loads(target.read_text(encoding="utf-8")) == {
+ "tok1": {"user_id": "a", "cube_id": "a"},
+ "tok2": {"user_id": "b", "cube_id": "b"},
+ }
+ assert target.read_text(encoding="utf-8").endswith("\n")
+ assert [entry.name for entry in tmp_path.iterdir()] == ["tenants.conf"]
+
+
+def test_sync_manifest_from_store(tmp_path):
+ control, store = store_on(tmp_path)
+ live = insert(control, store, "a", "live")
+ other = insert(control, store, "b", "other")
+ manifest = tmp_path / "m.json"
+ sync_manifest(store, manifest)
+ loaded = json.loads(manifest.read_text(encoding="utf-8"))
+ assert set(loaded) == {live["token"], other["token"]}
+ with control.connection() as db:
+ db.execute("BEGIN IMMEDIATE")
+ store.mark_revoked(db, live["id"], None)
+ sync_manifest(store, manifest)
+ loaded = json.loads(manifest.read_text(encoding="utf-8"))
+ assert set(loaded) == {other["token"]}
+ assert loaded == {other["token"]: {"user_id": "b", "cube_id": "b"}}
diff --git a/server/backend/tests/test_knowledge.py b/server/backend/tests/test_knowledge.py
new file mode 100644
index 000000000..979d0a86b
--- /dev/null
+++ b/server/backend/tests/test_knowledge.py
@@ -0,0 +1,199 @@
+import json
+
+import httpx
+from fastapi.testclient import TestClient
+from server.backend.app import create_app
+from server.backend.tests.test_accounts import configured, login, seed
+from server.backend.tests.test_audit_api import validate_contract
+
+BASE = "/api/dashboard/knowledgebase/"
+
+
+class KnowledgeTransport:
+ def __init__(self):
+ self.items = {}
+ self.calls = []
+ self.fail_delete = False
+
+ def __call__(self, request):
+ body = json.loads(request.content or b"{}")
+ self.calls.append((request.url.path, body))
+ cube, identifier = body.get("mem_cube_id"), body.get("knowledgebase_id")
+ path = request.url.path.rsplit("/", 1)[-1]
+ if path == "create_knowledgebase":
+ identifier = "kb_" + str(len(self.items) + 1)
+ row = {
+ "id": identifier,
+ "knowledgebase_id": identifier,
+ "knowledgebase_name": body["knowledgebase_name"],
+ "knowledgebase_description": body.get("knowledgebase_description", ""),
+ "file_count": 0,
+ "total_size": 0,
+ "created_at": "2026-10-26T00:00:00Z",
+ "updated_at": "2026-10-26T00:00:00Z",
+ "secret": "never-project-me",
+ }
+ self.items[cube, identifier] = row
+ return httpx.Response(200, json={"code": 0, "data": row})
+ if identifier:
+ row = self.items.get((cube, identifier))
+ if row is None:
+ return httpx.Response(200, json={"code": 404, "data": None})
+ if path == "update_knowledgebase":
+ row.update(
+ {
+ k: v
+ for k, v in body.items()
+ if k in ("knowledgebase_name", "knowledgebase_description")
+ }
+ )
+ if path == "delete_knowledgebase":
+ if self.fail_delete:
+ return httpx.Response(503)
+ del self.items[cube, identifier]
+ return httpx.Response(200, json={"code": 0, "data": {"success": True}})
+ return httpx.Response(200, json={"code": 0, "data": row})
+ rows = [v for (tenant, _), v in self.items.items() if tenant == cube]
+ size, page = body.get("page_size", 200), body.get("page", 1)
+ return httpx.Response(
+ 200,
+ json={
+ "code": 0,
+ "data": {
+ "knowledgebase_list": rows[(page - 1) * size : page * size],
+ "total": len(rows),
+ },
+ },
+ )
+
+
+def application(tmp_path, remote):
+ return create_app(configured(tmp_path), transport=httpx.MockTransport(remote))
+
+
+def create(web, **extra):
+ response = web.post(
+ BASE + "create", json={"knowledgebaseName": "kb-name", "projectId": "a", **extra}
+ )
+ assert response.status_code == 200, response.text
+ validate_contract(response.json(), "KnowledgeBase")
+ return response.json()["data"]
+
+
+def test_entities_crud_filter_projection_and_restart(tmp_path):
+ remote = KnowledgeTransport()
+ with TestClient(application(tmp_path, remote)) as web:
+ seed(web.app)
+ login(web)
+ kb = create(web)
+ assert kb["createTime"] == kb["updateTime"] == "2026-10-26 08:00:00"
+ identifier = kb["kbId"]
+ assert kb["projectIds"] == ["a"] and "secret" not in str(kb)
+ response = web.post(BASE + "list", json={"projectId": "a", "name": "kb-", "pageSize": 1})
+ validate_contract(response.json(), "KnowledgeBaseRecords")
+ assert response.json()["data"]["total"] == 1
+ assert web.post(BASE + "list", json={"projectId": "b"}).json()["data"]["total"] == 0
+ assert (
+ web.post(
+ BASE + "update", json={"id": identifier, "knowledgebaseName": "edited"}
+ ).json()["data"]["knowledgebaseName"]
+ == "edited"
+ )
+ cookies = dict(web.cookies)
+ with TestClient(application(tmp_path, remote)) as web:
+ web.cookies.update(cookies)
+ assert (
+ web.post(BASE + "detail", json={"knowledgeBaseId": identifier}).json()["data"][
+ "knowledgebaseName"
+ ]
+ == "edited"
+ )
+ assert web.post(
+ BASE + "delete", json={"projectId": "a", "knowledgeBaseId": identifier}
+ ).json()["data"] == {"ok": True, "id": identifier}
+ assert web.post(BASE + "detail", json={"knowledgeBaseId": identifier}).status_code == 404
+
+
+def test_association_full_replacement_sharing_and_role_boundaries(tmp_path):
+ remote = KnowledgeTransport()
+ with TestClient(application(tmp_path, remote)) as admin:
+ seed(admin.app)
+ user = admin.app.state.identity.create_account("reader", "reader-password-123")
+ login(admin)
+ admin.post("/admin/tenants/b/members", json={"account_id": user["id"], "role": "viewer"})
+ first, second = create(admin), create(admin, knowledgebaseName="second")
+ with TestClient(admin.app) as reader:
+ login(reader, "reader", "reader-password-123")
+ assert (
+ reader.post(BASE + "detail", json={"knowledgeBaseId": first["kbId"]}).status_code
+ == 404
+ )
+ assert (
+ reader.post(
+ BASE + "project/associate",
+ json={"projectId": "b", "knowledgeBaseIds": [first["kbId"]]},
+ ).status_code
+ == 403
+ )
+ assert (
+ admin.post(
+ BASE + "project/associate",
+ json={"projectId": "b", "knowledgeBaseIds": [first["kbId"], second["kbId"]]},
+ ).status_code
+ == 200
+ )
+ view = reader.post(BASE + "detail", json={"knowledgeBaseId": first["kbId"]}).json()[
+ "data"
+ ]
+ assert view["projectIds"] == ["b"]
+ assert (
+ reader.post(
+ BASE + "update", json={"id": first["kbId"], "knowledgebaseName": "denied"}
+ ).status_code
+ == 403
+ )
+ admin.post(
+ BASE + "project/associate",
+ json={"projectId": "b", "knowledgeBaseIds": [second["kbId"]]},
+ )
+ assert (
+ reader.post(BASE + "detail", json={"knowledgeBaseId": first["kbId"]}).status_code
+ == 404
+ )
+ assert reader.post(BASE + "list", json={"projectId": "b"}).json()["data"]["total"] == 1
+ assert (
+ admin.post(
+ BASE + "project/remove", json={"projectId": "b", "knowledgeBaseId": second["kbId"]}
+ ).status_code
+ == 200
+ )
+ assert remote.items["cube-a", second["kbId"]]
+
+
+def test_invalid_inputs_and_failed_delete_remains_retryable(tmp_path):
+ remote = KnowledgeTransport()
+ with TestClient(application(tmp_path, remote)) as web:
+ seed(web.app)
+ login(web)
+ for body in (
+ {"knowledgebaseName": " "},
+ {"knowledgebaseName": "ok", "projectIds": ["a", "foreign"]},
+ {"knowledgebaseName": "ok", "user_name": "foreign"},
+ ):
+ assert web.post(BASE + "create", json=body).status_code in {400, 404}
+ identifier = create(web)["kbId"]
+ remote.fail_delete = True
+ assert (
+ web.post(
+ BASE + "delete", json={"projectId": "a", "knowledgeBaseId": identifier}
+ ).status_code
+ == 503
+ )
+ assert web.post(BASE + "detail", json={"knowledgeBaseId": identifier}).status_code == 409
+ remote.fail_delete = False
+ assert (
+ web.post(
+ BASE + "delete", json={"projectId": "a", "knowledgeBaseId": identifier}
+ ).status_code
+ == 200
+ )
diff --git a/server/backend/tests/test_knowledge_files.py b/server/backend/tests/test_knowledge_files.py
new file mode 100644
index 000000000..38f964c3e
--- /dev/null
+++ b/server/backend/tests/test_knowledge_files.py
@@ -0,0 +1,217 @@
+import base64
+import hashlib
+import json
+import re
+
+import httpx
+from fastapi.testclient import TestClient
+from server.backend.tests.test_accounts import login, seed
+from server.backend.tests.test_audit_api import validate_contract
+from server.backend.tests.test_knowledge import BASE, KnowledgeTransport, application, create
+
+
+class FilesTransport(KnowledgeTransport):
+ def __init__(self):
+ super().__init__()
+ self.files = {}
+
+ def __call__(self, request):
+ body = json.loads(request.content or b"{}")
+ path = request.url.path.rsplit("/", 1)[-1]
+ if "knowledgebase_file" not in path:
+ return super().__call__(request)
+ cube = body["mem_cube_id"]
+ if path == "add_knowledgebase_file":
+ item = body["file"][0]
+ raw = base64.b64decode(item["base64"])
+ identifier = "file_" + str(len(self.files) + 1)
+ row = {
+ "id": identifier,
+ "knowledgebase_id": body["knowledgebase_id"],
+ "name": item["name"],
+ "size": len(raw),
+ "status": "stored",
+ "type": "document",
+ "created_at": "2026-10-26T00:00:00Z",
+ "storage_path": "private/not-for-api",
+ "raw": raw,
+ }
+ self.files[cube, identifier] = row
+ return httpx.Response(
+ 200, json={"code": 0, "data": [{k: v for k, v in row.items() if k != "raw"}]}
+ )
+ rows = [
+ row
+ for (tenant, identifier), row in self.files.items()
+ if tenant == cube
+ and (not body.get("file_ids") or identifier in body["file_ids"])
+ and (
+ not body.get("knowledgebase_id")
+ or row["knowledgebase_id"] == body["knowledgebase_id"]
+ )
+ ]
+ if path == "get_knowledgebase_file":
+ return httpx.Response(
+ 200,
+ json={
+ "code": 0,
+ "data": {
+ "file_detail_list": [
+ {k: v for k, v in r.items() if k != "raw"} for r in rows
+ ],
+ "total": len(rows),
+ },
+ },
+ )
+ if path == "download_knowledgebase_file":
+ row = self.files.get((cube, body["file_id"]))
+ return httpx.Response(200, content=row["raw"]) if row else httpx.Response(404)
+ if path == "delete_knowledgebase_file":
+ for row in rows:
+ del self.files[cube, row["id"]]
+ return httpx.Response(
+ 200, json={"code": 0, "data": {"success": bool(rows), "deleted": len(rows)}}
+ )
+ return httpx.Response(500)
+
+
+def upload(web, kb, content, name="original.txt"):
+ return web.post(
+ BASE + "file/add",
+ json={
+ "projectId": "a",
+ "knowledgeBaseId": kb,
+ "file": {"name": name, "content": content, "type": "text/plain"},
+ },
+ )
+
+
+def test_file_time_filters_use_source_instant_not_display(tmp_path):
+ remote = FilesTransport()
+ with TestClient(application(tmp_path, remote)) as web:
+ seed(web.app)
+ login(web)
+ kb = create(web)["kbId"]
+ assert upload(web, kb, "sample").status_code == 200
+ for bounds, total in [
+ ({"startTime": "2026-10-26", "endTime": "2026-10-26"}, 1),
+ ({"endTime": "2026-10-26T00:00:00Z"}, 1),
+ ({"startTime": "2026-10-26T00:00:01Z"}, 0),
+ ({"startTime": "2026-10-26T08:00:00+08:00"}, 1),
+ ]:
+ response = web.post(
+ BASE + "file/list", json={"projectId": "a", "knowledgeBaseId": kb, **bounds}
+ )
+ assert response.status_code == 200
+ data = response.json()["data"]
+ assert data["total"] == total
+ if total:
+ assert data["records"][0]["createTime"] == "2026-10-26 08:00:00"
+ assert next(iter(remote.files.values()))["created_at"] == "2026-10-26T00:00:00Z"
+
+
+def test_upload_list_byte_download_delete_and_restart(tmp_path):
+ remote = FilesTransport()
+ raw = "中文\x00bytes\nexact".encode()
+ with TestClient(application(tmp_path, remote)) as web:
+ seed(web.app)
+ login(web)
+ kb = create(web)["kbId"]
+ result = upload(web, kb, "data:text/plain;base64," + base64.b64encode(raw).decode())
+ assert result.status_code == 200, result.text
+ validate_contract(result.json(), "KnowledgeFileRecords")
+ file = result.json()["data"]["records"][0]
+ identifier = file["fileId"]
+ assert re.fullmatch(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}", file["createTime"])
+ assert "private" not in result.text and file["status"] == "available"
+ assert (
+ web.post(
+ BASE + "file/list",
+ json={"knowledgeBaseId": kb, "fileName": "original", "status": "available"},
+ ).json()["data"]["total"]
+ == 1
+ )
+ response = web.get(BASE + "file/download", params={"projectId": "a", "fileId": identifier})
+ assert (
+ response.status_code == 200
+ and hashlib.sha256(response.content).digest() == hashlib.sha256(raw).digest()
+ )
+ assert "attachment" in response.headers["content-disposition"]
+ cookies = dict(web.cookies)
+ with TestClient(application(tmp_path, remote)) as web:
+ web.cookies.update(cookies)
+ assert (
+ web.get(BASE + "file/download", params={"projectId": "a", "fileId": identifier}).content
+ == raw
+ )
+ result = web.post(BASE + "file/delete", json={"projectId": "a", "fileIds": [identifier]})
+ assert result.status_code == 200 and result.json()["data"]["deleted"] == 1
+ assert (
+ web.get(
+ BASE + "file/download", params={"projectId": "a", "fileId": identifier}
+ ).status_code
+ == 404
+ )
+ assert (
+ web.post(
+ BASE + "file/delete", json={"projectId": "a", "fileIds": [identifier]}
+ ).status_code
+ == 200
+ )
+
+
+def test_validation_plain_base64_looking_text_and_cross_project(tmp_path):
+ remote = FilesTransport()
+ with TestClient(application(tmp_path, remote)) as web:
+ seed(web.app)
+ login(web)
+ kb = create(web)["kbId"]
+ for name, content in [
+ ("../escape.txt", "x"),
+ ("C:\\escape.txt", "x"),
+ ("x\r\n.txt", "x"),
+ ("x.exe", "x"),
+ ("x.txt", "data:text/plain;base64,%%%"),
+ ]:
+ assert upload(web, kb, content, name).status_code == 400
+ response = upload(web, kb, "YWJj")
+ identifier = response.json()["data"]["records"][0]["fileId"]
+ assert (
+ web.get(BASE + "file/download", params={"projectId": "a", "fileId": identifier}).content
+ == b"YWJj"
+ )
+ assert (
+ web.get(
+ BASE + "file/download", params={"projectId": "b", "fileId": identifier}
+ ).status_code
+ == 404
+ )
+ assert (
+ web.post(
+ BASE + "file/delete",
+ json={"projectId": "a", "fileIds": [identifier, "unknown-foreign"]},
+ ).status_code
+ == 404
+ )
+ assert (
+ web.get(
+ BASE + "file/download", params={"projectId": "a", "fileId": identifier}
+ ).status_code
+ == 200
+ )
+ user = web.app.state.identity.create_account("reader", "reader-password-123")
+ web.post("/admin/tenants/a/members", json={"account_id": user["id"], "role": "viewer"})
+ login(web, "reader", "reader-password-123")
+ assert upload(web, kb, "no").status_code == 403
+ assert (
+ web.post(
+ BASE + "file/delete", json={"projectId": "a", "fileIds": [identifier]}
+ ).status_code
+ == 403
+ )
+ assert (
+ web.get(
+ BASE + "file/download", params={"projectId": "a", "fileId": identifier}
+ ).status_code
+ == 200
+ )
diff --git a/server/backend/tests/test_members.py b/server/backend/tests/test_members.py
new file mode 100644
index 000000000..d2f308ce4
--- /dev/null
+++ b/server/backend/tests/test_members.py
@@ -0,0 +1,133 @@
+from concurrent.futures import ThreadPoolExecutor
+
+from fastapi.testclient import TestClient
+from server.backend.tests.test_accounts import application, login, seed
+
+BASE = "/admin/tenants/a/"
+
+
+def provision(web):
+ admin = seed(web.app)
+ user = web.app.state.identity.create_account("member", "member-password-123")
+ login(web)
+ return admin, user
+
+
+def member(web, user, role):
+ return web.post(BASE + "members", json={"account_id": user["id"], "role": role})
+
+
+def test_candidates_roles_add_change_remove_and_leave(tmp_path):
+ with TestClient(application(tmp_path)) as web:
+ admin, user = provision(web)
+ assert (
+ web.get(BASE + "member-candidates?query=member").json()["data"]["records"][0]["member"]
+ is False
+ )
+ assert member(web, user, "viewer").status_code == 200
+ candidates = web.get(BASE + "member-candidates?query=member").json()["data"]["records"]
+ assert candidates[0]["member"] is True
+ assert member(web, user, "editor").json()["data"]["role"] == "editor"
+ assert len(web.get(BASE + "members").json()["data"]["records"]) == 2
+ assert web.post(BASE + f"members/{user['id']}/remove", json={}).status_code == 200
+ assert member(web, user, "viewer").status_code == 200
+ login(web, "member", "member-password-123")
+ assert web.get(BASE + "member-candidates?query=a").status_code == 403
+ assert web.get(BASE + "members").status_code == 200
+ assert web.post(BASE + "members/leave", json={}).status_code == 200
+ assert web.get(BASE + "members").status_code == 404
+
+
+def test_viewer_editor_owner_data_permissions_and_selection(tmp_path):
+ with TestClient(application(tmp_path)) as web:
+ admin, user = provision(web)
+ member(web, user, "viewer")
+ login(web, "member", "member-password-123")
+ projects = web.get("/api/auth/me").json()["data"]["projects"]
+ assert "a" in {p["id"] for p in projects} and "b" not in {p["id"] for p in projects}
+ assert (
+ web.post(
+ "/api/dashboard/project/update", json={"id": "a", "status": "ENABLED"}
+ ).status_code
+ == 200
+ )
+ assert web.get("/api/auth/me").json()["data"]["currentProjectId"] == "a"
+ delete_body = {"projectId": "a", "memory_ids": ["m"], "memType": "text_mem"}
+ assert web.post("/api/dashboard/project/memory/delete", json=delete_body).status_code == 403
+ assert (
+ web.post(
+ "/api/dashboard/project/memory/list",
+ json={"projectId": "b", "memoryDetailType": "text_mem"},
+ ).status_code
+ == 404
+ )
+ assert (
+ web.post(
+ "/api/dashboard/project/update", json={"id": "a", "name": "forbidden"}
+ ).status_code
+ == 403
+ )
+ assert member(web, admin, "viewer").status_code == 403
+ login(web)
+ member(web, user, "editor")
+ login(web, "member", "member-password-123")
+ # The transport returns500; editor gets past authorization into upstream,
+ # while viewer was rejected before any upstream request.
+ assert web.post("/api/dashboard/project/memory/delete", json=delete_body).status_code == 502
+ assert web.post("/api/dashboard/project/delete/a").status_code == 403
+
+
+def test_effective_last_owner_guards_members_account_disable_delete(tmp_path):
+ with TestClient(application(tmp_path)) as web:
+ admin, user = provision(web)
+ assert member(web, admin, "viewer").status_code == 409
+ assert web.post(BASE + "members/leave", json={}).status_code == 409
+ assert web.post(BASE + f"members/{admin['id']}/remove", json={}).status_code == 409
+ member(web, user, "owner")
+ member(web, admin, "viewer")
+ assert web.post(f"/admin/accounts/{user['id']}", json={"disabled": True}).status_code == 409
+ assert web.delete(f"/admin/accounts/{user['id']}").status_code == 409
+ member(web, admin, "owner")
+ assert web.post(f"/admin/accounts/{user['id']}", json={"disabled": True}).status_code == 200
+ assert member(web, admin, "viewer").status_code == 409
+
+
+def test_concurrent_owner_removals_leave_one_effective_owner(tmp_path):
+ with TestClient(application(tmp_path)) as web:
+ admin, user = provision(web)
+ member(web, user, "owner")
+ with ThreadPoolExecutor(max_workers=2) as pool:
+ results = list(
+ pool.map(lambda person: member(web, person, "viewer").status_code, [admin, user])
+ )
+ assert sorted(results) == [200, 409]
+
+
+def test_member_validation_personal_space_and_revocation(tmp_path):
+ with TestClient(application(tmp_path)) as web:
+ admin, user = provision(web)
+ for body in (
+ {"role": "owner"},
+ {"role": "root", "account_id": user["id"]},
+ {"role": "viewer", "account_id": user["id"], "username": "wrong"},
+ ):
+ assert web.post(BASE + "members", json=body).status_code == 400
+ personal = "personal_" + user["id"]
+ assert (
+ web.post(
+ f"/admin/tenants/{personal}/members", json={"username": "admin", "role": "owner"}
+ ).status_code
+ == 409
+ )
+ member(web, user, "viewer")
+ with TestClient(web.app) as other:
+ login(other, "member", "member-password-123")
+ assert (
+ other.post(
+ "/api/dashboard/project/update", json={"id": "a", "status": "ENABLED"}
+ ).status_code
+ == 200
+ )
+ web.post(BASE + f"members/{user['id']}/remove", json={})
+ assert other.post("/api/dashboard/log/list", json={"projectId": "a"}).status_code == 404
+ assert other.get("/api/auth/me").json()["data"]["currentProjectId"] != "a"
diff --git a/server/backend/tests/test_memories.py b/server/backend/tests/test_memories.py
new file mode 100644
index 000000000..597598b6c
--- /dev/null
+++ b/server/backend/tests/test_memories.py
@@ -0,0 +1,483 @@
+"""Behavioral read-only tests; the upstream boundary uses its actual REST shapes."""
+
+import json
+import re
+from datetime import datetime
+
+import httpx
+import pytest
+from fastapi.testclient import TestClient
+from server.backend.app import create_app
+from server.backend.settings import Settings
+
+TAB_TYPES = {
+ "text_mem": ["WorkingMemory", "LongTermMemory", "UserMemory", "OuterMemory"],
+ "pref_mem": ["PreferenceMemory"],
+ "event_mem": ["EventMemory", "Context", "InsightMemory"],
+ "skill_mem": ["SkillMemory"],
+ "tool_mem": ["ToolSchemaMemory", "ToolTrajectoryMemory"],
+}
+VIEWS = {
+ "detail_factual": "text_mem",
+ "preference": "pref_mem",
+ "event": "event_mem",
+ "skill": "skill_mem",
+ "tool_memory": "tool_mem",
+}
+PROFILE_ID = "profile_" + "a" * 32 + "_basic.user_name_中文"
+
+
+def node(identifier, kind="UserMemory", cube="cube-a", day=1):
+ return {
+ "id": identifier,
+ "memory": "Marker " + identifier,
+ "metadata": {
+ "memory_type": kind,
+ "status": "activated",
+ "user_name": cube,
+ "owner_type": "user",
+ "owner_id": "u1",
+ "user_id": "u1",
+ "created_at": f"2026-01-{day:02d}T00:00:00+00:00",
+ "embedding": [0.125] * 1024,
+ "sources": ["x" * 50000],
+ "history": ["y" * 50000],
+ },
+ }
+
+
+def match(node, condition):
+ if not condition:
+ return True
+ if "and" in condition:
+ return all(match(node, part) for part in condition["and"])
+ if "or" in condition:
+ return any(match(node, part) for part in condition["or"])
+ for key, value in condition.items():
+ actual = node.get(key, node.get("metadata", {}).get(key))
+ if isinstance(value, dict):
+ for op, expected in value.items():
+ if actual is None:
+ return False
+ if op == "like" and expected not in actual:
+ return False
+ if op == "contains" and expected not in actual:
+ return False
+ if op in {"gte", "lte", "lt"}:
+ a = datetime.fromisoformat(actual.replace("Z", "+00:00"))
+ b = datetime.fromisoformat(expected.replace("Z", "+00:00"))
+ if not {"gte": a >= b, "lte": a <= b, "lt": a < b}[op]:
+ return False
+ elif actual != value:
+ return False
+ return True
+
+
+class Upstream:
+ def __init__(self):
+ self.calls = []
+ self.nodes = [node(f"text-{i:02d}", day=i + 1) for i in range(25)]
+ self.nodes[0]["metadata"]["knowledgebase_id"] = "kb-a"
+ self.nodes[1]["metadata"]["knowledgebase_ids"] = ["kb-array"]
+ for tab, kinds in TAB_TYPES.items():
+ if tab != "text_mem":
+ self.nodes.extend(node(kind, kind) for kind in kinds)
+ self.nodes.append(node("foreign", cube="cube-b"))
+ self.profile = {
+ "id": PROFILE_ID,
+ "memory": "basic.user_name_中文: Marker",
+ "memory_type": "ProfileMemory",
+ "status": "activated",
+ "user_name": "cube-a",
+ "subject_type": "user",
+ "subject_id": "u1",
+ "template_id": "template-real",
+ "profile_instance_id": "profile_" + "a" * 32,
+ "created_at": "2026-01-01T00:00:00Z",
+ }
+
+ def __call__(self, request):
+ body = json.loads(request.content) if request.content else {}
+ self.calls.append((request, body))
+ cube = body.get("mem_cube_id") or request.url.params.get("mem_cube_id")
+ if request.url.path.endswith("/get_profile_memory"):
+ data = self.profile if cube == "cube-a" and body["memory_id"] == PROFILE_ID else None
+ elif request.url.path.endswith("/list_profile_memories"):
+ rows = [self.profile] if cube == "cube-a" else []
+ data = {"cube_id": cube, "memories": rows, "total": len(rows)}
+ elif request.method == "GET":
+ data = next(
+ (
+ n
+ for n in self.nodes
+ if n["id"] == request.url.path.rsplit("/", 1)[-1]
+ and n["metadata"]["user_name"] == cube
+ ),
+ None,
+ )
+ else:
+ tab = VIEWS[body["include_memory_view"][0]]
+ types = body.get("event_memory_types", TAB_TYPES[tab])
+ assert body["status"] == ["activated"]
+ rows = [
+ n
+ for n in self.nodes
+ if n["metadata"]["user_name"] == cube
+ and n["metadata"]["memory_type"] in types
+ and n["metadata"]["status"] in body["status"]
+ and match(n, body.get("filter"))
+ ]
+ rows.sort(key=lambda n: (n["metadata"]["created_at"], n["id"]), reverse=True)
+ total = len(rows)
+ start = (body["page"] - 1) * body["page_size"]
+ data = {
+ tab: [
+ {
+ "cube_id": cube,
+ "memories": rows[start : start + body["page_size"]],
+ "total_nodes": total,
+ }
+ ]
+ }
+ return httpx.Response(200, json={"code": 200, "data": data, "message": "success"})
+
+
+def client_for(tmp_path, upstream=None, default="a"):
+ upstream = upstream or Upstream()
+ settings = Settings(
+ auth_enabled=False,
+ upstream_url="http://memos.test/",
+ projects_seed=(("a", "cube-a"), ("b", "cube-b")),
+ default_project_id=default,
+ data_dir=tmp_path,
+ )
+ return TestClient(create_app(settings, transport=httpx.MockTransport(upstream)))
+
+
+def query(tab="text_mem", **extra):
+ return {"projectId": "a", "memoryDetailType": tab, "pageNum": 1, "pageSize": 10, **extra}
+
+
+@pytest.mark.parametrize("tab", [*TAB_TYPES, "profile_mem"])
+def test_six_lists_and_details_match_contract_and_drop_large_fields(tmp_path, tab):
+ upstream = Upstream()
+ with client_for(tmp_path, upstream) as client:
+ result = client.post("/api/dashboard/project/memory/list", json=query(tab))
+ assert result.status_code == 200, result.text
+ body = result.json()
+ data = body["data"]
+ assert body["code"] == 0 and data["current"] == 1
+ assert data["total"] > 0
+ row = data["memListItemResponseList"][0]
+ # 全 tab 通用格式钉(ISO「T」形态必不过 fullmatch;text_mem 首行 day=25 与其余 tab 不同)
+ assert re.fullmatch(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}", row["createTime"]), tab
+ detail = client.post(
+ "/api/dashboard/project/memory/detail",
+ json={"memoryId": row["memoryId"], "memType": tab},
+ )
+ assert detail.status_code == 200, detail.text
+ assert detail.json()["data"]["memoryId"] == row["memoryId"]
+ assert "metadata" not in row
+ assert (
+ "embedding" not in result.text
+ and "sources" not in result.text
+ and "history" not in result.text
+ )
+ assert len(result.content) < 200 * 1024
+ if tab == "profile_mem":
+ assert row["profileTemplateId"] == "template-real"
+ assert row["profileInstanceId"] == "profile_" + "a" * 32
+
+
+def test_filter_then_count_and_stable_pagination(tmp_path):
+ with client_for(tmp_path) as client:
+ pages = [
+ client.post("/api/dashboard/project/memory/list", json=query(pageNum=n)).json()["data"]
+ for n in [1, 2, 3, 4]
+ ]
+ assert [p["total"] for p in pages] == [25] * 4
+ assert [len(p["memListItemResponseList"]) for p in pages] == [10, 10, 5, 0]
+ ids = [r["memoryId"] for p in pages for r in p["memListItemResponseList"]]
+ assert len(set(ids)) == 25
+ response = client.post(
+ "/api/dashboard/project/memory/list",
+ json=query(
+ startTime="2026-01-10",
+ endTime="2026-01-12",
+ memSearchContent="Marker",
+ externalUserId="u1",
+ subjectType="user",
+ ),
+ )
+ data = response.json()["data"]
+ assert data["total"] == 3
+ assert [r["memoryId"] for r in data["memListItemResponseList"]] == [
+ "text-11",
+ "text-10",
+ "text-09",
+ ]
+
+
+@pytest.mark.parametrize("kb,expected", [("kb-a", 1), ("kb-array", 1), ("missing", 0)])
+def test_knowledgebase_filter_is_applied(tmp_path, kb, expected):
+ with client_for(tmp_path) as client:
+ data = client.post(
+ "/api/dashboard/project/memory/list", json=query(knowledgeBaseId=kb)
+ ).json()["data"]
+ assert data["total"] == expected
+
+
+def test_project_range_and_type_boundaries(tmp_path):
+ upstream = Upstream()
+ with client_for(tmp_path, upstream) as client:
+ assert (
+ client.post(
+ "/api/dashboard/project/memory/list", json=query(projectId="not-registered")
+ ).status_code
+ == 404
+ )
+ assert not upstream.calls
+ b = client.post("/api/dashboard/project/memory/list", json=query(projectId="b")).json()[
+ "data"
+ ]
+ assert [r["memoryId"] for r in b["memListItemResponseList"]] == ["foreign"]
+ assert (
+ client.post(
+ "/api/dashboard/project/memory/detail",
+ json={"memoryId": "foreign", "memType": "text_mem"},
+ ).status_code
+ == 404
+ )
+ assert (
+ client.post(
+ "/api/dashboard/project/memory/detail",
+ json={"memoryId": "EventMemory", "memType": "text_mem"},
+ ).status_code
+ == 400
+ )
+ with client_for(tmp_path, upstream, default="b") as client:
+ assert (
+ client.post(
+ "/api/dashboard/project/memory/detail",
+ json={"memoryId": "text-00", "memType": "text_mem"},
+ ).status_code
+ == 404
+ )
+
+
+@pytest.mark.parametrize(
+ "change",
+ [
+ {"pageNum": 0},
+ {"pageSize": 0},
+ {"pageSize": 101},
+ {"pageNum": True},
+ {"pageNum": 1.5},
+ {"memoryDetailType": "bad"},
+ {"subjectType": "group"},
+ {"startTime": "invalid"},
+ {"startTime": "2026-01-01T00:00:00"},
+ {"startTime": "2026-01-03", "endTime": "2026-01-01"},
+ ],
+)
+def test_bad_queries_fail_before_upstream(tmp_path, change):
+ upstream = Upstream()
+ with client_for(tmp_path, upstream) as client:
+ response = client.post("/api/dashboard/project/memory/list", json=query(**change))
+ assert response.status_code == 400 and response.json()["code"] == 40000
+ assert not upstream.calls
+
+
+@pytest.mark.parametrize(
+ "failure,status",
+ [
+ ("http", 502),
+ ("json", 502),
+ ("business", 502),
+ ("shape", 502),
+ ("connect", 503),
+ ("timeout", 504),
+ ],
+)
+def test_upstream_failures_are_not_success_empty_lists(tmp_path, failure, status):
+ def upstream(request):
+ if failure == "connect":
+ raise httpx.ConnectError("private-key", request=request)
+ if failure == "timeout":
+ raise httpx.ReadTimeout("private-key", request=request)
+ if failure == "http":
+ return httpx.Response(500, text="private-key")
+ if failure == "json":
+ return httpx.Response(200, text="not-json")
+ return httpx.Response(200, json={"code": 400 if failure == "business" else 200, "data": {}})
+
+ with client_for(tmp_path, upstream) as client:
+ response = client.post("/api/dashboard/project/memory/list", json=query())
+ assert response.status_code == status
+ assert response.json()["code"] != 0 and response.json()["data"] is None
+ assert "private-key" not in response.text
+
+
+def test_detail_is_not_list_projection_and_omits_unknown_ownership(tmp_path):
+ upstream = Upstream()
+ raw = upstream.nodes[0]
+ raw["memory"] = "中文" * 600
+ raw["metadata"] = {
+ "status": "activated",
+ "memory_type": "UserMemory",
+ "created_at": "2026-01-01T00:00:00Z",
+ "user_name": "cube-a",
+ }
+ with client_for(tmp_path, upstream) as client:
+ row = client.post(
+ "/api/dashboard/project/memory/list", json=query(memSearchContent="中文")
+ ).json()["data"]["memListItemResponseList"][0]
+ detail = client.post(
+ "/api/dashboard/project/memory/detail",
+ json={"memoryId": "text-00", "memType": "text_mem"},
+ ).json()["data"]
+ assert len(row["memoryContent"]) == 501
+ assert detail["memoryContent"] == "中文" * 600
+ assert "subjectType" not in detail and "externalUserId" not in detail
+
+
+def test_non_active_detail_and_invalid_id_are_not_readable(tmp_path):
+ upstream = Upstream()
+ upstream.nodes[0]["metadata"]["status"] = "deleted"
+ with client_for(tmp_path, upstream) as client:
+ assert (
+ client.post(
+ "/api/dashboard/project/memory/detail",
+ json={"memoryId": "text-00", "memType": "text_mem"},
+ ).status_code
+ == 404
+ )
+ assert (
+ client.post(
+ "/api/dashboard/project/memory/detail",
+ json={"memoryId": "../escape", "memType": "text_mem"},
+ ).status_code
+ == 400
+ )
+
+
+def test_no_implicit_project_configuration_exposes_memories():
+ app = create_app(
+ Settings(auth_enabled=False, upstream_url="http://unused.test/"),
+ transport=httpx.MockTransport(lambda r: pytest.fail("must not access upstream")),
+ )
+ with TestClient(app) as client:
+ assert client.post("/api/dashboard/project/memory/list", json=query()).status_code == 404
+ assert (
+ client.post(
+ "/api/dashboard/project/memory/detail",
+ json={"memoryId": "text-00", "memType": "text_mem"},
+ ).status_code
+ == 503
+ )
+
+
+def test_project_dictionary_is_only_configured_real_mapping(tmp_path):
+ with client_for(tmp_path) as client:
+ response = client.get("/api/dashboard/project/dict")
+ assert response.status_code == 200
+ assert [(r["id"], r["status"]) for r in response.json()["data"]] == [
+ ("a", "ENABLED"),
+ ("b", "DISABLED"),
+ ]
+
+
+@pytest.mark.parametrize(
+ "data",
+ [
+ {"status": {}},
+ {"status": []},
+ {"success": False},
+ {"text_mem": []},
+ {"text_mem": [{"cube_id": "wrong", "memories": [], "total_nodes": 0}]},
+ {"text_mem": [{"cube_id": "cube-a", "memories": [], "total_nodes": True}]},
+ ],
+)
+def test_malformed_product_payload_is_bad_gateway_not_internal_error(tmp_path, data):
+ with client_for(
+ tmp_path, lambda request: httpx.Response(200, json={"code": 200, "data": data})
+ ) as client:
+ response = client.post("/api/dashboard/project/memory/list", json=query())
+ assert response.status_code == 502
+ assert response.json()["code"] == 50200
+
+
+@pytest.mark.parametrize("bad_metadata", [None, [], "invalid"])
+def test_malformed_detail_metadata_is_bad_gateway(tmp_path, bad_metadata):
+ def upstream(request):
+ return httpx.Response(
+ 200,
+ json={"code": 200, "data": {"id": "text-00", "memory": "x", "metadata": bad_metadata}},
+ )
+
+ with client_for(tmp_path, upstream) as client:
+ response = client.post(
+ "/api/dashboard/project/memory/detail",
+ json={"memoryId": "text-00", "memType": "text_mem"},
+ )
+ assert response.status_code == 502
+
+
+def test_list_does_not_accept_nonactive_rows_or_foreign_tenant(tmp_path):
+ for change in [{"status": "archived"}, {"user_name": "cube-b"}]:
+ raw = node("bad")
+ raw["metadata"].update(change)
+
+ def upstream(request):
+ return httpx.Response(
+ 200,
+ json={
+ "code": 200,
+ "data": {
+ "text_mem": [{"cube_id": "cube-a", "memories": [raw], "total_nodes": 1}]
+ },
+ },
+ )
+
+ with client_for(tmp_path, upstream) as client:
+ assert (
+ client.post("/api/dashboard/project/memory/list", json=query()).status_code == 502
+ )
+
+
+def test_memory_response_size_guard(tmp_path, monkeypatch):
+ monkeypatch.setattr("server.backend.memory_upstream.MAX_RESPONSE_BYTES", 64, raising=False)
+ with client_for(tmp_path) as client:
+ response = client.post("/api/dashboard/project/memory/list", json=query())
+ assert response.status_code == 502
+
+
+def test_encoded_memory_body_is_never_consumed(tmp_path):
+ class Body(httpx.AsyncByteStream):
+ read = False
+
+ async def __aiter__(self):
+ self.read = True
+ yield b"must not decode"
+
+ body = Body()
+ with client_for(
+ tmp_path, lambda r: httpx.Response(200, headers={"content-encoding": "gzip"}, stream=body)
+ ) as client:
+ assert client.post("/api/dashboard/project/memory/list", json=query()).status_code == 502
+ assert not body.read
+
+
+@pytest.mark.parametrize(
+ "filters",
+ [
+ {"endTime": "9999-12-31"},
+ {"endTime": "9999-12-31T23:59:59-08:00"},
+ {"startTime": "0001-01-01T00:00:00+08:00"},
+ ],
+)
+def test_out_of_range_utc_boundaries_are_parameter_errors(tmp_path, filters):
+ with client_for(tmp_path) as client:
+ response = client.post("/api/dashboard/project/memory/list", json=query(**filters))
+ assert response.status_code == 400
+ assert response.json()["code"] == 40000
diff --git a/server/backend/tests/test_memory_lifecycle.py b/server/backend/tests/test_memory_lifecycle.py
new file mode 100644
index 000000000..aad573301
--- /dev/null
+++ b/server/backend/tests/test_memory_lifecycle.py
@@ -0,0 +1,208 @@
+import json
+
+import httpx
+import pytest
+from fastapi.testclient import TestClient
+from server.backend.app import create_app
+from server.backend.settings import Settings
+
+
+class Upstream:
+ def __init__(self):
+ self.rows = {
+ "one": {"status": "activated", "memory_type": "UserMemory"},
+ "two": {"status": "activated", "memory_type": "UserMemory"},
+ }
+ self.operations = {}
+ self.transitions = []
+ self.fail = set()
+ self.probes = []
+
+ def __call__(self, request):
+ body = json.loads(request.content)
+ path = request.url.path
+ if path.endswith("inspect_memory_lifecycle"):
+ self.probes.append(body)
+ row = self.rows.get(body["memory_id"]) if body["mem_cube_id"] == "cube-a" else None
+ return httpx.Response(
+ 200,
+ json={
+ "code": 200 if row else 404,
+ "data": {"memory_id": body["memory_id"], **row} if row else None,
+ },
+ )
+ if path.endswith("get_memory_operation"):
+ data = self.operations.get(body["operation_id"])
+ else:
+ self.transitions.append(body)
+ identifier = body["memory_id"]
+ if identifier in self.fail:
+ raise httpx.ReadTimeout("test outage")
+ target = "deleted" if body["target_status"] == "deleted" else "activated"
+ self.rows[identifier].update(status=target, previous_status="activated")
+ data = {
+ "memory_id": identifier,
+ "operation_id": body["operation_id"],
+ "state": "confirmed",
+ "target_status": target,
+ "phase": "confirmed",
+ "success": True,
+ "retryable": False,
+ "kind": body["kind"],
+ "vector_state": "not_applicable" if body["kind"] == "profile" else "confirmed",
+ }
+ self.operations[body["operation_id"]] = data
+ return httpx.Response(200, json={"code": 200, "data": data})
+
+
+def client(tmp_path, upstream):
+ return TestClient(
+ create_app(
+ Settings(
+ auth_enabled=False,
+ projects_seed=(("a", "cube-a"), ("b", "cube-b")),
+ default_project_id="a",
+ data_dir=tmp_path,
+ ),
+ transport=httpx.MockTransport(upstream),
+ )
+ )
+
+
+def body(**extra):
+ return {
+ "projectId": "a",
+ "memType": "text_mem",
+ "memory_ids": ["one", "two"],
+ "operationId": "batch-1",
+ **extra,
+ }
+
+
+def test_batch_delete_restore_and_idempotent_receipts_survive_backend_restart(tmp_path):
+ upstream = Upstream()
+ with client(tmp_path, upstream) as web:
+ response = web.post("/api/dashboard/project/memory/delete", json=body())
+ assert response.status_code == 200 and response.json()["code"] == 0
+ assert response.json()["data"]["deleted"] == 2
+ assert (
+ web.post("/api/dashboard/project/memory/delete", json=body()).json() == response.json()
+ )
+ assert len(upstream.transitions) == 2
+ with client(tmp_path, upstream) as web:
+ query = web.get(
+ "/api/dashboard/project/memory/operation",
+ params={"projectId": "a", "operationId": "batch-1"},
+ )
+ assert query.json()["data"]["state"] == "confirmed"
+ restored = web.post(
+ "/api/dashboard/project/memory/restore", json=body(operationId="restore")
+ )
+ assert restored.json()["data"]["restored"] == 2
+ conflict = web.post("/api/dashboard/project/memory/delete", json=body(memory_ids=["one"]))
+ assert conflict.status_code == 409
+
+
+def test_partial_timeout_is_not_success_and_retries_only_unconfirmed_ids(tmp_path):
+ upstream = Upstream()
+ upstream.fail.add("two")
+ with client(tmp_path, upstream) as web:
+ response = web.post("/api/dashboard/project/memory/delete", json=body())
+ assert response.status_code == 503 and response.json()["code"] != 0
+ data = response.json()["data"]
+ assert data["deleted"] == 1 and data["operationId"] == "batch-1"
+ upstream.fail.clear()
+ with client(tmp_path, upstream) as web:
+ retry = web.post(
+ "/api/dashboard/project/memory/retry", json={"projectId": "a", "operationId": "batch-1"}
+ )
+ assert retry.json()["code"] == 0 and retry.json()["data"]["deleted"] == 2
+ assert len([t for t in upstream.transitions if t["memory_id"] == "one"]) == 1
+
+
+@pytest.mark.parametrize("state,vector", [("pending", "unconfirmed"), ("confirmed", "unconfirmed")])
+def test_pending_or_unverified_vector_never_becomes_success(tmp_path, state, vector):
+ upstream = Upstream()
+
+ def handler(request):
+ if request.url.path.endswith("transition_memory"):
+ data = json.loads(request.content)
+ return httpx.Response(
+ 200,
+ json={
+ "code": 200,
+ "data": {
+ "memory_id": data["memory_id"],
+ "operation_id": data["operation_id"],
+ "kind": "graph",
+ "target_status": "deleted",
+ "state": state,
+ "success": state == "confirmed",
+ "vector_state": vector,
+ },
+ },
+ )
+ return upstream(request)
+
+ with client(tmp_path, handler) as web:
+ response = web.post("/api/dashboard/project/memory/delete", json=body(memory_ids=["one"]))
+ assert response.status_code == 503 and response.json()["code"] != 0
+ assert response.json()["data"]["deleted"] == 0
+
+
+@pytest.mark.parametrize(
+ "change", [{"memory_ids": ["one", "missing"]}, {"projectId": "b"}, {"memType": "event_mem"}]
+)
+def test_preflight_all_targets_before_any_write(tmp_path, change):
+ upstream = Upstream()
+ with client(tmp_path, upstream) as web:
+ response = web.post("/api/dashboard/project/memory/delete", json=body(**change))
+ assert response.status_code in (400, 404, 409)
+ assert upstream.transitions == []
+ assert upstream.probes
+
+
+def test_rejects_cross_origin_and_invalid_requests(tmp_path):
+ upstream = Upstream()
+ with client(tmp_path, upstream) as web:
+ assert (
+ web.post(
+ "/api/dashboard/project/memory/delete",
+ json=body(),
+ headers={"Origin": "https://evil.example"},
+ ).status_code
+ == 403
+ )
+ for bad in ({"memory_ids": []}, {"memory_ids": ["one", "one"]}, {"memType": "unknown"}):
+ assert (
+ web.post("/api/dashboard/project/memory/delete", json=body(**bad)).status_code
+ == 400
+ )
+ assert (
+ web.get(
+ "/api/dashboard/project/memory/operation",
+ params={"projectId": "b", "operationId": "batch-1"},
+ ).status_code
+ == 404
+ )
+ assert (
+ web.get(
+ "/api/dashboard/project/memory/operation",
+ params={"projectId": "a", "operationId": "../bad"},
+ ).status_code
+ == 400
+ )
+
+
+def test_profile_delete_uses_field_transition_and_reports_no_vector(tmp_path):
+ upstream = Upstream()
+ upstream.rows["one"]["memory_type"] = "ProfileMemory"
+ with client(tmp_path, upstream) as web:
+ assert (
+ web.post(
+ "/api/dashboard/project/memory/delete",
+ json=body(memType="profile_mem", memory_ids=["one"]),
+ ).json()["code"]
+ == 0
+ )
+ assert upstream.transitions[0]["kind"] == "profile"
diff --git a/server/backend/tests/test_meta.py b/server/backend/tests/test_meta.py
new file mode 100644
index 000000000..db3f66b93
--- /dev/null
+++ b/server/backend/tests/test_meta.py
@@ -0,0 +1,298 @@
+"""Exercise the metadata contract with isolated upstream HTTP responses."""
+
+import asyncio
+import gzip
+import json
+from pathlib import Path
+
+import httpx
+import pytest
+from fastapi.testclient import TestClient
+from jsonschema import Draft202012Validator
+from server.backend.app import create_app
+from server.backend.settings import Settings
+
+HEALTH = {
+ "status": "healthy",
+ "service": "memos",
+ "version": "1.0.1",
+ "contract_version": 2,
+}
+TOOLS = {
+ "add_message",
+ "search_memory",
+ "get_user_profile",
+ "delete_memory",
+ "add_feedback",
+}
+
+
+def client_for(handler):
+ return TestClient(
+ create_app(
+ Settings(
+ auth_enabled=False, upstream_url="http://memos.test/", upstream_timeout_seconds=1
+ ),
+ transport=httpx.MockTransport(handler),
+ )
+ )
+
+
+def validate_meta_contract(body):
+ spec = json.loads(
+ (Path(__file__).resolve().parents[3] / "openapi.json").read_text(encoding="utf-8")
+ )
+ schema = spec["components"]["responses"]["Meta"]["content"]["application/json"]["schema"]
+ Draft202012Validator({**schema, "components": spec["components"]}).validate(body)
+
+
+def test_meta_uses_real_health_and_matches_dashboard_contract():
+ calls = []
+
+ def upstream(request):
+ calls.append(request)
+ return httpx.Response(200, json=HEALTH)
+
+ with client_for(upstream) as client:
+ response = client.get("/api/meta")
+ assert response.status_code == 200
+ body = response.json()
+ assert set(body) == {"code", "data", "message"}
+ assert body["code"] == 0
+ assert body["data"]["mode"] == "live"
+ assert body["data"]["service"] == "memos-dashboard-backend"
+ assert body["data"]["health"] == HEALTH
+ assert body["data"]["keyPrefix"] == "sk-"
+ assert body["data"]["authEnabled"] is False
+ assert {tool["name"] for tool in body["data"]["tools"]} == TOOLS
+ assert all(tool["title"] for tool in body["data"]["tools"])
+ assert len(calls) == 1
+ assert str(calls[0].url) == "http://memos.test/health"
+ assert "authorization" not in calls[0].headers
+ assert response.headers["cache-control"] == "no-store"
+ validate_meta_contract(body)
+
+
+def test_health_projects_only_safe_fields():
+ health = {**HEALTH, "api_key": "private-test-value", "configuration": {"password": "hidden"}}
+ with client_for(lambda request: httpx.Response(200, json=health)) as client:
+ response = client.get("/api/meta")
+ assert response.json()["data"]["health"] == HEALTH
+ assert "private-test-value" not in response.text
+ assert "password" not in response.text
+
+
+@pytest.mark.parametrize("failure", [httpx.ConnectError, httpx.ReadError])
+def test_upstream_connection_failure_is_uniform_and_does_not_leak_details(failure):
+ def unavailable(request):
+ raise failure("sensitive upstream connection details", request=request)
+
+ with client_for(unavailable) as client:
+ response = client.get("/api/meta")
+ assert response.status_code == 503
+ assert response.json()["code"] == 50300
+ assert response.json()["data"] is None
+ assert response.json()["message"]
+ assert "sensitive" not in response.text
+
+
+@pytest.mark.parametrize("failure", [httpx.ConnectTimeout, httpx.ReadTimeout])
+def test_upstream_timeout_is_uniform(failure):
+ def timeout(request):
+ raise failure("sensitive timeout details", request=request)
+
+ with client_for(timeout) as client:
+ response = client.get("/api/meta")
+ assert response.status_code == 504
+ assert response.json()["code"] == 50400
+ assert response.json()["data"] is None
+ assert "sensitive" not in response.text
+
+
+@pytest.mark.parametrize("status", [301, 401, 404, 429, 500, 503])
+def test_upstream_non_success_status_is_not_live_success(status):
+ count = []
+
+ def upstream(request):
+ count.append(1)
+ return httpx.Response(
+ status, text="sensitive upstream body", headers={"location": "/login"}
+ )
+
+ with client_for(upstream) as client:
+ response = client.get("/api/meta")
+ assert response.status_code == 502
+ assert response.json()["code"] == 50200
+ assert response.json()["data"] is None
+ assert len(count) == 1
+ assert "sensitive" not in response.text
+
+
+@pytest.mark.parametrize("body", ["not json", "null", "[]", "42"])
+def test_invalid_upstream_json_or_shape_is_not_success(body):
+ with client_for(lambda request: httpx.Response(200, text=body)) as client:
+ response = client.get("/api/meta")
+ assert response.status_code == 502
+ assert response.json()["code"] == 50200
+ assert response.json()["data"] is None
+
+
+@pytest.mark.parametrize(
+ "changes",
+ [
+ {"service": "mock"},
+ {"contract_version": 1},
+ {"contract_version": "2"},
+ {"version": ""},
+ ],
+)
+def test_upstream_identity_or_contract_mismatch_is_not_success(changes):
+ with client_for(lambda request: httpx.Response(200, json={**HEALTH, **changes})) as client:
+ response = client.get("/api/meta")
+ assert response.status_code == 502
+ assert response.json()["code"] == 50200
+
+
+def test_missing_health_fields_are_rejected():
+ with client_for(lambda request: httpx.Response(200, json={"status": "healthy"})) as client:
+ response = client.get("/api/meta")
+ assert response.status_code == 502
+ assert response.json()["code"] != 0
+
+
+def test_unhealthy_upstream_does_not_report_success():
+ with client_for(
+ lambda request: httpx.Response(200, json={**HEALTH, "status": "degraded"})
+ ) as client:
+ response = client.get("/api/meta")
+ assert response.status_code == 503
+ assert response.json()["code"] == 50300
+
+
+def test_checks_are_fresh_and_can_recover_without_restarting_backend():
+ calls = []
+
+ def upstream(request):
+ calls.append(1)
+ if len(calls) == 2:
+ raise httpx.ConnectError("offline", request=request)
+ return httpx.Response(200, json=HEALTH)
+
+ with client_for(upstream) as client:
+ responses = [client.get("/api/meta") for _ in range(3)]
+ assert [response.status_code for response in responses] == [200, 503, 200]
+ assert len(calls) == 3
+
+
+def test_local_liveness_does_not_call_upstream():
+ def forbidden(request):
+ raise AssertionError("Local health must not need MemOS")
+
+ with client_for(forbidden) as client:
+ response = client.get("/health")
+ assert response.status_code == 200
+ assert response.json() == {
+ "code": 0,
+ "data": {"status": "healthy", "service": "memos-dashboard-backend"},
+ "message": "success",
+ }
+
+
+def test_http_client_is_closed_with_application_lifespan():
+ class Transport(httpx.MockTransport):
+ closed = False
+
+ async def aclose(self):
+ self.closed = True
+
+ transport = Transport(lambda request: httpx.Response(200, json=HEALTH))
+ app = create_app(
+ Settings(auth_enabled=False, upstream_url="http://memos.test/"), transport=transport
+ )
+ with TestClient(app) as client:
+ assert client.get("/api/meta").status_code == 200
+ assert not transport.closed
+ assert transport.closed
+
+
+def test_total_probe_deadline_is_enforced_even_if_transport_does_not_timeout():
+ async def slow(request):
+ await asyncio.sleep(0.15)
+ return httpx.Response(200, json=HEALTH)
+
+ app = create_app(
+ Settings(
+ auth_enabled=False, upstream_url="http://memos.test/", upstream_timeout_seconds=0.02
+ ),
+ transport=httpx.MockTransport(slow),
+ )
+ with TestClient(app) as client:
+ response = client.get("/api/meta")
+ assert response.status_code == 504
+ assert response.json()["code"] == 50400
+
+
+def test_oversized_health_body_is_rejected():
+ payload = {**HEALTH, "padding": "x" * 65536}
+ with client_for(lambda request: httpx.Response(200, json=payload)) as client:
+ response = client.get("/api/meta")
+ assert response.status_code == 502
+ assert response.json()["code"] == 50200
+
+
+def test_upstream_path_prefix_is_preserved():
+ calls = []
+
+ def upstream(request):
+ calls.append(str(request.url))
+ return httpx.Response(200, json=HEALTH)
+
+ app = create_app(
+ Settings(auth_enabled=False, upstream_url="http://memos.test/prefix/"),
+ transport=httpx.MockTransport(upstream),
+ )
+ with TestClient(app) as client:
+ assert client.get("/api/meta").status_code == 200
+ assert calls == ["http://memos.test/prefix/health"]
+
+
+@pytest.mark.parametrize("encoding", ["gzip", "br", "deflate", "GZip", "gzip, identity"])
+def test_encoded_health_is_rejected_before_reading_or_decoding_body(encoding):
+ class Body(httpx.AsyncByteStream):
+ read = False
+
+ async def __aiter__(self):
+ self.read = True
+ yield gzip.compress(b"x" * 131072)
+
+ body = Body()
+ with client_for(
+ lambda request: httpx.Response(200, headers={"Content-Encoding": encoding}, stream=body)
+ ) as client:
+ response = client.get("/api/meta")
+ assert response.status_code == 502
+ assert response.json()["code"] == 50200
+ assert not body.read
+
+
+def test_upstream_is_asked_for_identity_encoding():
+ encodings = []
+
+ def upstream(request):
+ encodings.append(request.headers["accept-encoding"])
+ return httpx.Response(200, json=HEALTH, headers={"Content-Encoding": "identity"})
+
+ with client_for(upstream) as client:
+ assert client.get("/api/meta").status_code == 200
+ assert encodings == ["identity"]
+
+
+def test_upstream_protocol_failure_is_bad_gateway():
+ def invalid_protocol(request):
+ raise httpx.RemoteProtocolError("private protocol details", request=request)
+
+ with client_for(invalid_protocol) as client:
+ response = client.get("/api/meta")
+ assert response.status_code == 502
+ assert response.json()["code"] == 50200
+ assert "private protocol details" not in response.text
diff --git a/server/backend/tests/test_portable_smoke.py b/server/backend/tests/test_portable_smoke.py
new file mode 100644
index 000000000..ba00b6e4a
--- /dev/null
+++ b/server/backend/tests/test_portable_smoke.py
@@ -0,0 +1,39 @@
+import argparse
+import json
+
+import httpx
+import pytest
+from server.backend.acceptance.smoke import origin, run_checks
+
+
+def test_read_only_smoke_has_no_private_setup_or_fixture_dependency():
+ calls = []
+
+ def transport(request):
+ path = request.url.path
+ calls.append(path)
+ if path == "/api/meta":
+ data = {"mode": "live"}
+ elif path.endswith("/memory/list"):
+ data = {"memListItemResponseList": [], "total": 0}
+ elif path.endswith("/graph/memories"):
+ data = {"nodes": [], "stats": {"truncated": False}}
+ elif path.endswith(("/models", "/log/list")):
+ data = {"total": 0}
+ else:
+ data = {}
+ return httpx.Response(200, json={"code": 0, "data": data, "message": "success"})
+
+ with httpx.Client(transport=httpx.MockTransport(transport)) as web:
+ proof = run_checks(web, "http://test", "demo")
+ assert proof["passed"] and len(proof["pages"]) == 6
+ assert "memoryContent" not in json.dumps(proof)
+ assert all(not path.endswith(("/create", "/delete", "/save", "/revoke")) for path in calls)
+
+
+@pytest.mark.parametrize(
+ "url", ["file:///tmp/test", "https://user:secret@example.com", "https://example.com?key=x"]
+)
+def test_service_url_rejects_embedded_credentials_and_non_http(url):
+ with pytest.raises(argparse.ArgumentTypeError):
+ origin(url)
diff --git a/server/backend/tests/test_profile_graph.py b/server/backend/tests/test_profile_graph.py
new file mode 100644
index 000000000..230b3aa07
--- /dev/null
+++ b/server/backend/tests/test_profile_graph.py
@@ -0,0 +1,168 @@
+import copy
+import json
+
+import httpx
+import pytest
+from fastapi.testclient import TestClient
+from server.backend.tests.test_accounts import login, seed
+from server.backend.tests.test_audit_api import validate_contract
+from server.backend.tests.test_read_views import BASE, ReadTransport, make
+
+
+def field(number=0, path=None):
+ path = path or "basic.field_" + str(number)
+ instance = "profile_" + "a" * 32
+ return {
+ "id": instance + "_" + path,
+ "memory": path + ": " + "value" * 40,
+ "memory_type": "ProfileMemory",
+ "status": "activated",
+ "user_name": "cube-a",
+ "subject_type": "user",
+ "subject_id": "profile-user",
+ "profile_instance_id": instance,
+ "profile_path": path,
+ "created_at": "2026-10-26T00:00:00Z",
+ "secret": "HIDDEN",
+ }
+
+
+class ProfileTransport(ReadTransport):
+ def __init__(self, count=2):
+ super().__init__()
+ self.rows = [field(i) for i in range(count)]
+ self.bad_page = False
+
+ def __call__(self, request):
+ body = json.loads(request.content or b"{}")
+ if request.url.path.endswith("list_profile_memories"):
+ self.calls.append((request.url, body))
+ size = body["page_size"]
+ assert 1 <= size <= 100
+ start = (body["page"] - 1) * size
+ rows = copy.deepcopy(self.rows[start : start + size])
+ if self.bad_page and rows:
+ rows[0]["user_name"] = "foreign"
+ return httpx.Response(
+ 200,
+ json={
+ "code": 200,
+ "data": {"cube_id": "cube-a", "memories": rows, "total": len(self.rows)},
+ },
+ )
+ if request.url.path.endswith("get_profile_memory"):
+ self.calls.append((request.url, body))
+ row = next((row for row in self.rows if row["id"] == body["memory_id"]), None)
+ if body["mem_cube_id"] != "cube-a":
+ row = None
+ return httpx.Response(200, json={"code": 200, "data": row})
+ return super().__call__(request)
+
+
+def test_graph_combines_profile_fields_same_ids_fulltext_and_deletion(tmp_path):
+ remote = ProfileTransport()
+ with TestClient(make(tmp_path, remote)) as web:
+ seed(web.app)
+ login(web)
+ response = web.get(BASE + "graph/memories", params={"tenant": "a"})
+ assert response.status_code == 200
+ validate_contract(response.json(), "GraphMemoryGraph")
+ graph = response.json()["data"]
+ assert graph["stats"]["nodes"] == 4 # two regular nodes plus two profile fields
+ profiles = [n for n in graph["nodes"] if n["memoryType"] == "ProfileMemory"]
+ assert {n["id"] for n in profiles} == {r["id"] for r in remote.rows}
+ assert all(n["createdAt"] == "2026-10-26 08:00:00" for n in profiles)
+ assert all(n["labelTruncated"] and len(n["label"]) <= 120 for n in profiles)
+ assert all(n["sessionId"] == "" and n["sourceIds"] == [] for n in profiles)
+ assert all(n["clusterId"] == n["id"] for n in profiles)
+ assert all(n["userId"] == "profile-user" for n in profiles)
+ assert "HIDDEN" not in response.text
+ identifier = remote.rows[0]["id"]
+ full = web.get(BASE + "graph/memory", params={"tenant": "a", "id": identifier})
+ assert full.status_code == 200
+ validate_contract(full.json(), "GraphMemory")
+ assert full.json()["data"] == {"id": identifier, "memory": remote.rows[0]["memory"]}
+ assert (
+ web.get(BASE + "graph/memory", params={"tenant": "b", "id": identifier}).status_code
+ == 404
+ )
+ removed = remote.rows.pop(0)
+ graph = web.get(BASE + "graph/memories", params={"tenant": "a"}).json()["data"]
+ assert identifier not in {n["id"] for n in graph["nodes"]}
+ assert (
+ web.get(BASE + "graph/memory", params={"tenant": "a", "id": identifier}).status_code
+ == 404
+ )
+ remote.rows.append(removed)
+ assert (
+ web.get(BASE + "graph/memory", params={"tenant": "a", "id": identifier}).status_code
+ == 200
+ )
+
+
+def test_profile_only_pagination_limits_and_no_graph_request(tmp_path):
+ remote = ProfileTransport(205)
+ with TestClient(make(tmp_path, remote)) as web:
+ seed(web.app)
+ login(web)
+ data = web.get(
+ BASE + "graph/memories", params={"tenant": "a", "types": "ProfileMemory"}
+ ).json()["data"]
+ assert data["stats"]["nodes"] == 205 and not data["stats"]["truncated"]
+ assert len(remote.calls) == 3
+ assert all(url.path.endswith("list_profile_memories") for url, _ in remote.calls)
+ data = web.get(
+ BASE + "graph/memories", params={"tenant": "a", "types": "ProfileMemory", "limit": 1}
+ ).json()["data"]
+ assert data["stats"]["nodes"] == 1 and data["stats"]["truncated"]
+ remote.calls.clear()
+ web.get(BASE + "graph/memories", params={"tenant": "a", "types": "PreferenceMemory"})
+ assert len(remote.calls) == 1 and remote.calls[0][0].path.endswith("get_memory_graph")
+
+
+@pytest.mark.parametrize("bad", ["foreign", "duplicate", "inactive", "invalid_time"])
+def test_profile_graph_failures_never_become_partial_success(tmp_path, bad):
+ remote = ProfileTransport()
+ if bad == "foreign":
+ remote.bad_page = True
+ elif bad == "duplicate":
+ remote.rows[1] = copy.deepcopy(remote.rows[0])
+ elif bad == "inactive":
+ remote.rows[0]["status"] = "deleted"
+ elif bad == "invalid_time":
+ remote.rows[0]["created_at"] = "invalid"
+ with TestClient(make(tmp_path, remote)) as web:
+ seed(web.app)
+ login(web)
+ assert web.get(BASE + "graph/memories", params={"tenant": "a"}).status_code == 502
+
+
+def test_long_profile_id_is_preserved_and_fulltext_resolves(tmp_path):
+ remote = ProfileTransport(0)
+ remote.rows = [field(path="group." + "x" * 270)]
+ with TestClient(make(tmp_path, remote)) as web:
+ seed(web.app)
+ login(web)
+ result = web.get(BASE + "graph/memories", params={"tenant": "a"}).json()["data"]
+ identifier = remote.rows[0]["id"]
+ assert identifier in {n["id"] for n in result["nodes"]}
+ assert (
+ web.get(BASE + "graph/memory", params={"tenant": "a", "id": identifier}).status_code
+ == 200
+ )
+
+
+def test_profile_fulltext_byte_budget_and_project_authorization(tmp_path):
+ remote = ProfileTransport(1)
+ with TestClient(make(tmp_path, remote)) as web:
+ seed(web.app)
+ login(web)
+ remote.rows[0]["memory"] = "\n" * (600 * 1024)
+ identifier = remote.rows[0]["id"]
+ assert (
+ web.get(BASE + "graph/memory", params={"tenant": "a", "id": identifier}).status_code
+ == 413
+ )
+ remote.calls.clear()
+ assert web.get(BASE + "graph/memories", params={"tenant": "unknown"}).status_code == 404
+ assert remote.calls == []
diff --git a/server/backend/tests/test_profile_templates.py b/server/backend/tests/test_profile_templates.py
new file mode 100644
index 000000000..ade3dcd82
--- /dev/null
+++ b/server/backend/tests/test_profile_templates.py
@@ -0,0 +1,213 @@
+import copy
+import json
+
+import httpx
+import pytest
+from fastapi.testclient import TestClient
+from server.backend.app import create_app
+from server.backend.tests.test_accounts import configured, login, seed
+from server.backend.tests.test_audit_api import validate_contract
+
+BASE = "/api/dashboard/profileTemplate/"
+TREE = {"工作": {"职责": {"value": "工程师", "algorithm_updatable": False}}}
+
+
+class TemplatesTransport:
+ def __init__(self):
+ self.rows = {}
+ self.calls = []
+ self.failure = None
+
+ def __call__(self, request):
+ body = json.loads(request.content)
+ self.calls.append((request.url.path, body))
+ if self.failure:
+ return httpx.Response(200, json=self.failure)
+ path = request.url.path.rsplit("/", 1)[-1]
+ cube = body["mem_cube_id"]
+ identifier = body.get("profile_template_id")
+ row = self.rows.get((cube, identifier))
+ if path == "add_profile_template":
+ identifier = "tpl_" + str(len(self.rows) + 1)
+ self.rows[cube, identifier] = {
+ "id": identifier,
+ "profile_template_id": identifier,
+ "name": body["name"],
+ "metadata": copy.deepcopy(body["metadata"]),
+ "deprecated_paths": [],
+ "bound_count": 0,
+ "updated_at": "2026-10-26T18:30:02Z",
+ "secret": "HIDDEN",
+ }
+ data = {"profile_template_id": identifier, "id": identifier}
+ elif path == "list_profile_template":
+ rows = [v for (tenant, _), v in self.rows.items() if tenant == cube]
+ start = (body["page"] - 1) * body["page_size"]
+ data = {
+ "profile_template_list": rows[start : start + body["page_size"]],
+ "total": len(rows),
+ }
+ elif row is None:
+ return httpx.Response(200, json={"code": 404, "data": None})
+ elif path == "get_profile_template":
+ data = {**row, "profile_template": row}
+ elif path == "update_profile_template":
+ previous = row["metadata"]
+ row.update(name=body["name"], metadata=copy.deepcopy(body["metadata"]))
+ row["deprecated_paths"] = []
+ for group, fields in previous.items():
+ for key, leaf in fields.items():
+ if key not in body["metadata"].get(group, {}):
+ row["metadata"].setdefault(group, {})[key] = {**leaf, "deprecated": True}
+ row["deprecated_paths"].append(group + "." + key)
+ data = {**row, "success": True}
+ elif path == "delete_profile_template":
+ del self.rows[cube, identifier]
+ data = {"success": True, "profile_template_id": identifier}
+ else:
+ return httpx.Response(500)
+ return httpx.Response(200, json={"code": 0, "data": data})
+
+
+def application(tmp_path, remote):
+ return create_app(configured(tmp_path), transport=httpx.MockTransport(remote))
+
+
+def test_template_crud_contract_isolation_pagination_and_restart(tmp_path):
+ remote = TemplatesTransport()
+ with TestClient(application(tmp_path, remote)) as web:
+ seed(web.app)
+ assert web.post(BASE + "list", json={"projectId": "a"}).status_code == 401
+ login(web)
+ empty = web.post(BASE + "list", json={"projectId": "a"}).json()
+ assert empty["data"] == {"list": [], "current": 1, "size": 10, "total": 0, "pages": 0}
+ body = {"projectId": "a", "templateName": "template", **TREE}
+ created = web.post(BASE + "create", json=body)
+ assert created.status_code == 200, created.text
+ validate_contract(created.json(), "Ok")
+ identifier = created.json()["data"]["templateId"]
+ params = {"projectId": "a", "templateId": identifier}
+ detail = web.get(BASE + "detail", params=params)
+ validate_contract(detail.json(), "ProfileTemplate")
+ assert detail.json()["data"] == {
+ **body,
+ "templateId": identifier,
+ "bindingCount": 0,
+ "updatedAt": "2026-10-27 02:30:02",
+ }
+ assert "HIDDEN" not in detail.text
+ listing = web.post(BASE + "list", json={"projectId": "a", "pageSize": 1})
+ validate_contract(listing.json(), "ProfileTemplateList")
+ assert listing.json()["data"]["list"][0] == detail.json()["data"]
+ assert (
+ web.post(BASE + "list", json={"projectId": "a", "pageSize": 1, "pageNum": 2}).json()[
+ "data"
+ ]["list"]
+ == []
+ )
+ foreign = {**params, "projectId": "b"}
+ assert web.get(BASE + "detail", params=foreign).status_code == 404
+ assert web.post(BASE + "update", json={**body, **foreign}).status_code == 404
+ assert web.post(BASE + "delete", json=foreign).status_code == 404
+ assert (
+ web.post(
+ BASE + "update", json={**body, **params, "templateName": "renamed"}
+ ).status_code
+ == 200
+ )
+ with TestClient(application(tmp_path, remote)) as web:
+ login(web)
+ assert web.get(BASE + "detail", params=params).json()["data"]["templateName"] == "renamed"
+ assert web.post(BASE + "delete", json=params).status_code == 200
+ assert web.get(BASE + "detail", params=params).status_code == 404
+
+
+@pytest.mark.parametrize(
+ "changes",
+ [
+ {"templateName": " "},
+ {"templateId": "forged"},
+ {"bindingCount": 10},
+ {"bad": {"field": {"value": 3}}},
+ {"bad": {"field": {"algorithm_updatable": "false"}}},
+ {"bad": {"value": {"value": "ambiguous"}}},
+ {"bad": {}},
+ {"bad": {"field": {"value": "x", "secret": "extra"}}},
+ ],
+)
+def test_invalid_tree_rejected_before_upstream(tmp_path, changes):
+ remote = TemplatesTransport()
+ with TestClient(application(tmp_path, remote)) as web:
+ seed(web.app)
+ login(web)
+ assert (
+ web.post(
+ BASE + "create", json={"projectId": "a", "templateName": "x", **TREE, **changes}
+ ).status_code
+ == 400
+ )
+ assert remote.calls == []
+
+
+def test_native_deprecated_fields_excluded_without_mutating_source():
+ from server.backend.profile_templates import view
+
+ row = {
+ "profile_template_id": "tpl_one",
+ "name": "one",
+ "bound_count": 0,
+ "metadata": {
+ "Work": {
+ "active": {"value": "x", "algorithm_updatable": False},
+ "old": {"value": "old", "algorithm_updatable": True, "deprecated": True},
+ }
+ },
+ "deprecated_paths": ["Work.old"],
+ }
+ before = copy.deepcopy(row)
+ assert view(row, "a")["Work"] == {"active": {"value": "x", "algorithm_updatable": False}}
+ assert row == before
+
+
+def test_native_null_default_is_optional_string_not_failed_list():
+ from server.backend.profile_templates import view
+
+ row = {
+ "profile_template_id": "tpl_one",
+ "name": "one",
+ "bound_count": 1,
+ "metadata": {"Work": {"role": {"value": None, "algorithm_updatable": True}}},
+ }
+ result = view(row, "a")
+ assert result["Work"]["role"] == {"algorithm_updatable": True}
+ validate_contract({"code": 0, "data": result, "message": "success"}, "ProfileTemplate")
+ assert row["metadata"]["Work"]["role"]["value"] is None
+
+
+def test_role_scope_upstream_errors_and_projection(tmp_path):
+ remote = TemplatesTransport()
+ with TestClient(application(tmp_path, remote)) as web:
+ seed(web.app)
+ login(web)
+ user = web.app.state.identity.create_account("reader", "reader-password-123")
+ web.post("/admin/tenants/a/members", json={"account_id": user["id"], "role": "viewer"})
+ login(web, "reader", "reader-password-123")
+ assert web.post(BASE + "list", json={"projectId": "a"}).status_code == 200
+ before = len(remote.calls)
+ assert (
+ web.post(
+ BASE + "create", json={"projectId": "a", "templateName": "x", **TREE}
+ ).status_code
+ == 403
+ )
+ assert web.post(BASE + "list", json={"projectId": "b"}).status_code == 404
+ assert web.post(BASE + "list", json={"projectId": "cube-a"}).status_code == 404
+ assert len(remote.calls) == before
+ for failure in [
+ {"code": 0, "data": {"profile_template_list": [], "total": -1}},
+ {"code": 0, "data": {"profile_template_list": [], "total": 1}},
+ {"code": 17, "data": {}},
+ {"code": 0, "data": None},
+ ]:
+ remote.failure = failure
+ assert web.post(BASE + "list", json={"projectId": "a"}).status_code == 502
diff --git a/server/backend/tests/test_projects.py b/server/backend/tests/test_projects.py
new file mode 100644
index 000000000..463edee2c
--- /dev/null
+++ b/server/backend/tests/test_projects.py
@@ -0,0 +1,249 @@
+import json
+from concurrent.futures import ThreadPoolExecutor
+
+import httpx
+import pytest
+from fastapi.testclient import TestClient
+from server.backend.app import create_app
+from server.backend.settings import Settings
+
+BASE = "/api/dashboard/project/"
+
+
+def app_for(tmp_path):
+ gateway = tmp_path / "gateway"
+ gateway.mkdir(exist_ok=True)
+ return create_app(
+ Settings(
+ auth_enabled=False,
+ data_dir=tmp_path / "data",
+ projects_seed=(("a", "cube-a"), ("b", "cube-b")),
+ default_project_id="a",
+ audit_gateway_dir=gateway,
+ ),
+ transport=httpx.MockTransport(lambda req: httpx.Response(500)),
+ )
+
+
+def post(web, endpoint, **body):
+ response = web.post(BASE + endpoint, json=body)
+ assert response.status_code == 200, (endpoint, response.status_code, response.text)
+ assert response.json()["code"] == 0
+ return response.json()["data"]
+
+
+def create(web, name="new project", **extra):
+ return post(web, "create", name=name, description="description", isMultiView=1, **extra)
+
+
+def test_real_create_edit_restart_selection_and_mapping(tmp_path):
+ with TestClient(app_for(tmp_path)) as web:
+ created = create(web)
+ identifier = created["id"]
+ assert created["name"] == "new project" and created["isMultiView"] == 1
+ assert created["status"] == "DISABLED" and created["disabled"] is False
+ assert web.app.state.control.resolve_cube(identifier) == identifier
+ edited = post(web, "update", id=identifier, name="changed", description="updated")
+ assert edited["isMultiView"] == 1 and edited["description"] == "updated"
+ post(web, "update", id=identifier, status="ENABLED")
+ cookies = dict(web.cookies)
+ with TestClient(app_for(tmp_path)) as web:
+ web.cookies.update(cookies)
+ rows = web.get(BASE + "dict").json()["data"]
+ assert [r["id"] for r in rows if r["status"] == "ENABLED"] == [identifier]
+ page = post(web, "list", pageNum=1, pageSize=200)
+ assert page["total"] == 3
+ assert next(r for r in page["records"] if r["id"] == identifier)["name"] == "changed"
+ assert web.app.state.control.resolve_cube(identifier) == identifier
+
+
+def test_two_browser_contexts_do_not_change_each_other(tmp_path):
+ with TestClient(app_for(tmp_path)) as one, TestClient(app_for(tmp_path)) as two:
+ one.get(BASE + "dict")
+ two.get(BASE + "dict")
+ post(one, "update", id="b", status="ENABLED")
+ assert [
+ r["id"] for r in one.get(BASE + "dict").json()["data"] if r["status"] == "ENABLED"
+ ] == ["b"]
+ assert [
+ r["id"] for r in two.get(BASE + "dict").json()["data"] if r["status"] == "ENABLED"
+ ] == ["a"]
+ assert all(cookie.has_nonstandard_attr("HttpOnly") for cookie in one.cookies.jar)
+
+
+def test_disable_archive_restore_and_restart_never_reseed_active(tmp_path):
+ with TestClient(app_for(tmp_path)) as web:
+ post(web, "update", id="b", status="ENABLED")
+ stopped = post(web, "update", id="b", status="DISABLED")
+ assert stopped["disabled"] is True
+ assert (
+ web.post(
+ BASE + "memory/list", json={"projectId": "b", "memoryDetailType": "text_mem"}
+ ).status_code
+ == 404
+ )
+ assert web.post("/api/dashboard/log/list", json={"projectId": "b"}).status_code == 404
+ assert web.post("/api/dashboard/usage/query", json={"projectId": "b"}).status_code == 404
+ with TestClient(app_for(tmp_path)) as web:
+ row = next(r for r in post(web, "list")["records"] if r["id"] == "b")
+ assert row["disabled"] is True
+ enabled = post(web, "update", id="b", status="ENABLED")
+ assert enabled["disabled"] is False and enabled["status"] == "DISABLED"
+ result = post(web, "delete/b")
+ assert result == {"ok": True, "id": "b", "archived": True}
+ assert "b" not in [r["id"] for r in web.get(BASE + "dict").json()["data"]]
+ assert web.post(BASE + "update", json={"id": "b", "status": "ENABLED"}).status_code == 409
+ restored = post(web, "restore/b")
+ assert restored["archived"] is False and restored["status"] == "DISABLED"
+ assert post(web, "revoke/b")["ok"] is True
+ assert next(r for r in post(web, "list")["records"] if r["id"] == "b")["archived"] is True
+
+
+def test_detail_resolves_selected_project_and_log_ids_remain_scoped(tmp_path):
+ from server.backend.tests.test_memories import node
+
+ rows = [
+ {"tenant": cube, "tool": "search_memory", "ts": "2026-10-26T00:00:00Z", "ok": True}
+ for cube in ("cube-a", "cube-b")
+ ]
+ app = app_for(tmp_path)
+ (tmp_path / "gateway/calls-20261026.jsonl").write_text(
+ "".join(json.dumps(r) + "\n" for r in rows), encoding="utf-8"
+ )
+
+ seen = []
+
+ async def memory_detail(cube, identifier, tab):
+ seen.append(cube)
+ return node(identifier, cube=cube)
+
+ with TestClient(app) as web:
+ app.state.memories.detail = memory_detail
+ a = web.post("/api/dashboard/log/list", json={"projectId": "a", "scope": "gateway"}).json()[
+ "data"
+ ]["logs"][0]["id"]
+ b = web.post("/api/dashboard/log/list", json={"projectId": "b", "scope": "gateway"}).json()[
+ "data"
+ ]["logs"][0]["id"]
+ post(web, "update", id="b", status="ENABLED")
+ assert web.post("/api/dashboard/log/detail", json={"logId": a}).status_code == 404
+ assert web.post("/api/dashboard/log/detail", json={"logId": b}).status_code == 200
+ assert (
+ web.post(
+ BASE + "memory/detail", json={"memoryId": "one", "memType": "text_mem"}
+ ).status_code
+ == 200
+ )
+ assert seen == ["cube-b"]
+
+
+@pytest.mark.parametrize(
+ "endpoint,body",
+ [
+ ("create", {"name": " ", "description": "x"}),
+ ("create", {"name": "x", "description": "x", "upstream_cube_id": "foreign"}),
+ ("update", {"id": "a"}),
+ ("update", {"id": "a", "name": None}),
+ ("update", {"id": "a", "disabled": True}),
+ ("list", {"pageSize": 201}),
+ ("list", {"pageNum": False}),
+ ],
+)
+def test_invalid_project_requests_fail(tmp_path, endpoint, body):
+ with TestClient(app_for(tmp_path)) as web:
+ result = web.post(BASE + endpoint, json=body)
+ assert result.status_code == 400 and result.json()["code"] != 0
+
+
+def test_uniqueness_and_origin_and_pagination(tmp_path):
+ with TestClient(app_for(tmp_path)) as web:
+ create(web, "same")
+ assert (
+ web.post(BASE + "create", json={"name": " SAME ", "description": ""}).status_code == 409
+ )
+ assert web.post(BASE + "update", json={"id": "a", "name": "same"}).status_code == 409
+ assert (
+ web.post(
+ BASE + "create",
+ json={"name": "cross-site", "description": ""},
+ headers={"Origin": "https://evil.example"},
+ ).status_code
+ == 403
+ )
+ first = post(web, "list", pageSize=1)
+ second = post(web, "list", pageSize=1, pageNum=2)
+ assert (
+ first["total"] == second["total"] == 3
+ and first["records"][0]["id"] != second["records"][0]["id"]
+ )
+
+
+def test_concurrent_duplicate_create_is_atomic(tmp_path):
+ app = app_for(tmp_path)
+ with TestClient(app) as web:
+
+ def action(_):
+ return web.post(
+ BASE + "create", json={"name": "concurrent", "description": ""}
+ ).status_code
+
+ with ThreadPoolExecutor(max_workers=3) as executor:
+ assert sorted(executor.map(action, range(3))) == [200, 409, 409]
+
+
+def test_legacy_four_column_database_migrates_without_losing_mapping(tmp_path):
+ import sqlite3
+
+ folder = tmp_path / "data"
+ folder.mkdir()
+ with sqlite3.connect(folder / "control.db") as db:
+ db.execute(
+ "CREATE TABLE projects (id TEXT PRIMARY KEY,name TEXT NOT NULL,"
+ "upstream_cube_id TEXT NOT NULL UNIQUE,created_at TEXT NOT NULL)"
+ )
+ db.execute("INSERT INTO projects VALUES ('a','original','cube-a','2026-01-01T00:00:00Z')")
+ with TestClient(app_for(tmp_path)) as web:
+ page = post(web, "list")
+ original = next(r for r in page["records"] if r["id"] == "a")
+ assert original["name"] == "original" and original["createTime"] == "2026-01-01 08:00:00"
+ assert web.app.state.control.resolve_cube("a") == "cube-a"
+
+
+def test_last_project_can_be_archived_without_fabricating_current_project(tmp_path):
+ with TestClient(app_for(tmp_path)) as web:
+ post(web, "delete/a")
+ post(web, "delete/b")
+ assert web.get(BASE + "dict").json()["data"] == []
+ assert (
+ web.post(
+ BASE + "memory/detail", json={"memoryId": "one", "memType": "text_mem"}
+ ).status_code
+ == 503
+ )
+ assert post(web, "restore/a")["archived"] is False
+
+
+def test_partial_update_rolls_back_name_when_uniqueness_fails(tmp_path):
+ with TestClient(app_for(tmp_path)) as web:
+ result = web.post(
+ BASE + "update", json={"id": "a", "name": "b", "description": "should-not-save"}
+ )
+ assert result.status_code == 409
+ row = next(r for r in post(web, "list")["records"] if r["id"] == "a")
+ assert row["name"] == "a" and row["description"] == ""
+
+
+def test_project_lifecycle_responses_match_authoritative_openapi(tmp_path):
+ from server.backend.tests.test_audit_api import validate_contract
+
+ with TestClient(app_for(tmp_path)) as web:
+ validate_contract(web.get(BASE + "dict").json(), "ProjectDict")
+ validate_contract(web.post(BASE + "list", json={}).json(), "ProjectRecords")
+ created = web.post(BASE + "create", json={"name": "contract", "description": ""})
+ validate_contract(created.json(), "Project")
+ identifier = created.json()["data"]["id"]
+ updated = web.post(BASE + "update", json={"id": identifier, "status": "ENABLED"})
+ validate_contract(updated.json(), "Project")
+ validate_contract(web.post(BASE + "delete/" + identifier).json(), "ProjectArchive")
+ validate_contract(web.post(BASE + "restore/" + identifier).json(), "Project")
+ validate_contract(web.post(BASE + "revoke/" + identifier).json(), "Ok")
diff --git a/server/backend/tests/test_prompts.py b/server/backend/tests/test_prompts.py
new file mode 100644
index 000000000..63b7f5fe4
--- /dev/null
+++ b/server/backend/tests/test_prompts.py
@@ -0,0 +1,245 @@
+import json
+
+import httpx
+import pytest
+from fastapi.testclient import TestClient
+from server.backend.app import create_app
+from server.backend.tests.test_accounts import configured, login, seed
+from server.backend.tests.test_audit_api import validate_contract
+
+BASE = "/api/dashboard/prompt-config/"
+
+
+class PromptTransport:
+ def __init__(self):
+ self.saved = {}
+ self.calls = []
+
+ def __call__(self, request):
+ body = json.loads(request.content or b"{}")
+ self.calls.append((request.url.path, body))
+ cube = body.get("mem_cube_id")
+ path = request.url.path.rsplit("/", 1)[-1]
+ prompt = self.saved.get(cube, "default ${conversation}")
+ if path == "get_extract_prompt":
+ return httpx.Response(
+ 200,
+ json={
+ "code": 200,
+ "data": {
+ "mem_cube_id": cube,
+ "prompt": prompt,
+ "updated_at": None,
+ "source": "saved" if cube in self.saved else "default",
+ "secret": "not-projected",
+ },
+ },
+ )
+ if path == "save_extract_prompt":
+ self.saved[cube] = body["prompt"]
+ prompt = body["prompt"]
+ elif path == "reset_extract_prompt":
+ self.saved.pop(cube, None)
+ prompt = "default ${conversation}"
+ elif path == "generate_extract_prompt":
+ prompt = "generated ${conversation}"
+ elif path == "debug_extract":
+ return httpx.Response(
+ 200,
+ json={
+ "code": 200,
+ "data": {
+ "extraction_types": body.get("extraction_types")
+ or ["text_mem", "pref_mem", "event_mem"],
+ "results": [
+ {"type": "LongTermMemory", "content": prompt, "secret": "hidden"}
+ ],
+ },
+ },
+ )
+ return httpx.Response(200, json={"code": 200, "data": {"prompt": prompt}})
+
+
+def app(tmp_path, remote):
+ return create_app(configured(tmp_path), transport=httpx.MockTransport(remote))
+
+
+def test_read_save_generate_reset_debug_and_project_isolation(tmp_path):
+ remote = PromptTransport()
+ with TestClient(app(tmp_path, remote)) as web:
+ seed(web.app)
+ login(web)
+ result = web.post(BASE + "detail", json={"projectId": "a"})
+ assert result.status_code == 200, result.text
+ validate_contract(result.json(), "PromptConfig")
+ prompt = "custom RULE_A ${conversation}"
+ assert web.post(BASE + "save", json={"projectId": "a", "prompt": prompt}).json()["data"][
+ "ok"
+ ]
+ assert remote.saved == {"cube-a": prompt}
+ assert (
+ web.post(BASE + "detail", json={"projectId": "b"}).json()["data"]["prompt"]
+ == "default ${conversation}"
+ )
+ generated = web.post(
+ BASE + "generate", json={"projectId": "a", "description": "more precise"}
+ )
+ validate_contract(generated.json(), "Prompt")
+ assert remote.saved["cube-a"] == prompt
+ debug = web.post(
+ BASE + "debug/extract",
+ json={"projectId": "a", "messages": [{"role": "user", "content": "extract"}]},
+ )
+ validate_contract(debug.json(), "ExtractDebug")
+ assert debug.json()["data"]["results"][0]["content"] == prompt
+ assert "secret" not in debug.text
+ assert web.post(BASE + "reset-default", json={"projectId": "a"}).status_code == 200
+ assert not remote.saved
+
+
+def test_prompt_validation_and_role_permissions_before_model_call(tmp_path):
+ remote = PromptTransport()
+ with TestClient(app(tmp_path, remote)) as web:
+ seed(web.app)
+ login(web)
+ for body in (
+ {"projectId": "a", "prompt": "missing placeholder"},
+ {"projectId": "a", "prompt": ""},
+ {"projectId": "a", "prompt": "x" * 20001},
+ ):
+ assert web.post(BASE + "save", json=body).status_code == 400
+ assert (
+ web.post(BASE + "debug/extract", json={"projectId": "a", "messages": []}).status_code
+ == 400
+ )
+ assert (
+ web.post(
+ BASE + "debug/extract",
+ json={
+ "projectId": "a",
+ "messages": [{"role": "user", "content": "x"}],
+ "extractionTypes": ["profile_mem"],
+ },
+ ).status_code
+ == 400
+ )
+ user = web.app.state.identity.create_account("viewer", "viewer-password-123")
+ web.post("/admin/tenants/a/members", json={"account_id": user["id"], "role": "viewer"})
+ login(web, "viewer", "viewer-password-123")
+ before = len(remote.calls)
+ assert (
+ web.post(
+ BASE + "save", json={"projectId": "a", "prompt": "${conversation}"}
+ ).status_code
+ == 403
+ )
+ assert (
+ web.post(BASE + "generate", json={"projectId": "a", "description": "x"}).status_code
+ == 403
+ )
+ assert len(remote.calls) == before
+ assert web.post(BASE + "detail", json={"projectId": "b"}).status_code == 404
+ assert web.post(BASE + "detail", json={"projectId": "a"}).status_code == 200
+
+
+@pytest.mark.parametrize(
+ "failure,expected",
+ [("timeout", 504), ("http", 502), ("code", 502), ("placeholder", 502), ("malformed", 502)],
+)
+def test_prompt_failures_are_explicit_and_never_expose_provider_secrets(
+ tmp_path, failure, expected
+):
+ def broken(request):
+ if failure == "timeout":
+ raise httpx.ReadTimeout("PRIVATE_PROVIDER_SECRET", request=request)
+ if failure == "http":
+ return httpx.Response(500, json={"detail": "PRIVATE_PROVIDER_SECRET"})
+ if failure == "code":
+ return httpx.Response(200, json={"code": 500, "message": "PRIVATE_PROVIDER_SECRET"})
+ if failure == "placeholder":
+ return httpx.Response(200, json={"code": 200, "data": {"prompt": "missing"}})
+ return httpx.Response(200, json={"code": 200, "data": ["PRIVATE_PROVIDER_SECRET"]})
+
+ with TestClient(app(tmp_path, broken)) as web:
+ seed(web.app)
+ login(web)
+ result = web.post(BASE + "generate", json={"projectId": "a", "description": "rewrite"})
+ assert result.status_code == expected
+ assert result.json()["code"] != 0 and result.json()["data"] is None
+ assert "PRIVATE_PROVIDER_SECRET" not in result.text
+
+
+def test_prompt_editor_debug_readonly_and_disabled_project_boundaries(tmp_path):
+ remote = PromptTransport()
+ with TestClient(app(tmp_path, remote)) as web:
+ seed(web.app)
+ login(web)
+ editor = web.app.state.identity.create_account("editor", "editor-password-123")
+ web.post("/admin/tenants/a/members", json={"account_id": editor["id"], "role": "editor"})
+ login(web, "editor", "editor-password-123")
+ assert (
+ web.post(
+ BASE + "generate", json={"projectId": "a", "description": "rewrite"}
+ ).status_code
+ == 200
+ )
+ assert (
+ web.post(
+ BASE + "debug/extract",
+ json={"projectId": "a", "messages": [{"role": "user", "content": "durable fact"}]},
+ ).status_code
+ == 200
+ )
+ assert not remote.saved
+ assert web.post(BASE + "reset-default", json={"projectId": "a"}).status_code == 403
+ login(web)
+ assert (
+ web.post(
+ "/api/dashboard/project/update", json={"id": "a", "status": "DISABLED"}
+ ).status_code
+ == 200
+ )
+ before = len(remote.calls)
+ assert web.post(BASE + "detail", json={"projectId": "a"}).status_code == 404
+ assert len(remote.calls) == before
+
+
+@pytest.mark.parametrize("selected", ["text_mem", "pref_mem", "event_mem"])
+def test_debug_filters_native_and_canonical_names_after_normalization(tmp_path, selected):
+ rows = [
+ {"type": kind, "content": kind}
+ for kind in (
+ "LongTermMemory",
+ "text_mem",
+ "PreferenceMemory",
+ "pref_mem",
+ "EventMemory",
+ "event_mem",
+ "ProfileMemory",
+ "profile_mem",
+ "SkillMemory",
+ "skill_mem",
+ )
+ ]
+ rows.append({"type": "raw", "content": "unstructured model output"})
+
+ def remote(request):
+ return httpx.Response(
+ 200, json={"code": 200, "data": {"extraction_types": [selected], "results": rows}}
+ )
+
+ with TestClient(app(tmp_path, remote)) as web:
+ seed(web.app)
+ login(web)
+ result = web.post(
+ BASE + "debug/extract",
+ json={
+ "projectId": "a",
+ "messages": [{"role": "user", "content": "synthetic input"}],
+ "extractionTypes": [selected],
+ },
+ )
+ assert result.status_code == 200
+ data = result.json()["data"]
+ assert data["extractionTypes"] == [selected]
+ assert [row["type"] for row in data["results"]] == [selected, selected, "raw"]
diff --git a/server/backend/tests/test_read_views.py b/server/backend/tests/test_read_views.py
new file mode 100644
index 000000000..ca6c5d543
--- /dev/null
+++ b/server/backend/tests/test_read_views.py
@@ -0,0 +1,262 @@
+import json
+
+import httpx
+import pytest
+from fastapi.testclient import TestClient
+from server.backend.app import create_app
+from server.backend.tests.test_accounts import configured, login, seed
+from server.backend.tests.test_audit_api import validate_contract
+
+BASE = "/api/dashboard/"
+
+
+class ReadTransport:
+ def __init__(self):
+ self.calls = []
+ self.cube = "cube-a"
+ self.deleted = False
+ self.bad = False
+
+ def __call__(self, request):
+ body = json.loads(request.content or b"{}")
+ self.calls.append((request.url, body))
+ if request.url.path.endswith("list_profile_memories"):
+ return httpx.Response(
+ 200,
+ json={
+ "code": 200,
+ "data": {
+ "cube_id": self.cube,
+ "memories": [],
+ "total": 0,
+ },
+ },
+ )
+ if request.url.path.endswith("get_memory_by_ids"):
+ assert request.url.params["mem_cube_id"] == "cube-a"
+ assert body == {"memory_ids": ["one"], "include_sources": False}
+ data = {
+ "memories": [
+ {
+ "id": "one",
+ "memory": "x" * 250,
+ "metadata": {
+ "user_name": self.cube,
+ "memory_type": "InsightMemory",
+ "status": "deleted" if self.deleted else "activated",
+ "api_key": "SECRET",
+ },
+ }
+ ]
+ }
+ elif request.url.path.endswith("get_memory_graph"):
+ assert body["mem_cube_id"] == "cube-a"
+ assert body["status"] == ["activated"]
+ nodes = [
+ {
+ "id": name,
+ "memory": "x" * 121,
+ "memoryTruncated": True,
+ "memoryType": "LongTermMemory",
+ "status": "activated",
+ "createdAt": "2026-10-26T18:30:02.075819+00:00",
+ "sessionId": "session",
+ "userId": "user",
+ "sourceMemoryIds": [],
+ "secret": "SECRET",
+ }
+ for name in ("one", "two")
+ ]
+ nodes.append(dict(nodes[0], id="deleted", status="deleted"))
+ data = {
+ "cube_id": self.cube,
+ "nodes": nodes,
+ "edges": [
+ {"source": "one", "target": "two", "type": "PARENT"},
+ {"source": "one", "target": "absent", "type": "PARENT"},
+ ],
+ "total_nodes": 4,
+ "truncated": True,
+ }
+ if self.bad:
+ data["nodes"][0]["sourceMemoryIds"] = [{}]
+ else:
+ assert request.method == "GET" and request.url.path.endswith("/models")
+ data = {
+ "records": [
+ {
+ "source": "MEMRADER_MODEL",
+ "model": "claude-haiku-4-5-20251001",
+ "spec": "https://user:SECRET@api.example.com:8443/v1?key=SECRET#SECRET",
+ "role": "SECRET",
+ "group": "chat",
+ "api_key": "SECRET",
+ },
+ {"source": "MOS_EMBEDDER_MODEL", "model": "BAAI/bge-m3", "spec": "1024 维"},
+ {"source": "UNKNOWN_SECRET", "model": "SECRET", "spec": "SECRET"},
+ ],
+ "total": 3,
+ }
+ return httpx.Response(200, json={"code": 200, "data": data})
+
+
+def make(tmp_path, remote):
+ return create_app(configured(tmp_path), transport=httpx.MockTransport(remote))
+
+
+def test_graph_projection_truncation_fulltext_and_models_redaction(tmp_path):
+ remote = ReadTransport()
+ with TestClient(make(tmp_path, remote)) as web:
+ seed(web.app)
+ login(web)
+ response = web.get(BASE + "graph/memories", params={"tenant": "a", "limit": 2})
+ assert response.status_code == 200, response.text
+ validate_contract(response.json(), "GraphMemoryGraph")
+ graph = response.json()["data"]
+ assert graph["tenant"] == "a"
+ assert len(graph["nodes"]) == 2
+ assert all(n["createdAt"] == "2026-10-27 02:30:02" for n in graph["nodes"])
+ assert all(len(n["label"]) <= 120 and n["labelTruncated"] for n in graph["nodes"])
+ assert graph["stats"]["truncated"] is True
+ assert graph["stats"]["clusters"] == 1 and graph["stats"]["orphans"] == 0
+ assert {e["kind"] for e in graph["links"]} == {"parent", "session"}
+ assert "SECRET" not in response.text
+ full = web.get(BASE + "graph/memory", params={"tenant": "a", "id": "one"})
+ validate_contract(full.json(), "GraphMemory")
+ assert full.json()["data"] == {"id": "one", "memory": "x" * 250}
+ models = web.get(BASE + "models")
+ validate_contract(models.json(), "ModelRecords")
+ assert models.json()["data"]["total"] == 2
+ assert models.json()["data"]["records"][0]["spec"] == "api.example.com:8443"
+ assert "SECRET" not in models.text
+ assert models.headers["cache-control"] == "no-store"
+ remote.deleted = True
+ assert (
+ web.get(BASE + "graph/memory", params={"tenant": "a", "id": "one"}).status_code == 404
+ )
+
+
+@pytest.mark.parametrize("timestamp", ["", "not-a-time"])
+def test_graph_time_empty_or_invalid(timestamp):
+ from server.backend.errors import ApiError
+ from server.backend.read_views import project_graph
+
+ data = {
+ "cube_id": "a",
+ "edges": [],
+ "total_nodes": 1,
+ "truncated": False,
+ "nodes": [
+ {
+ "id": "one",
+ "memory": "sample",
+ "memoryTruncated": False,
+ "memoryType": "LongTermMemory",
+ "status": "activated",
+ "createdAt": timestamp,
+ }
+ ],
+ }
+ if timestamp:
+ with pytest.raises(ApiError):
+ project_graph(data, "a", "a", [], 10)
+ else:
+ assert project_graph(data, "a", "a", [], 10)["nodes"][0]["createdAt"] == ""
+
+
+def test_graph_validation_permissions_and_untrusted_payloads(tmp_path):
+ remote = ReadTransport()
+ with TestClient(make(tmp_path, remote)) as web:
+ seed(web.app)
+ assert web.get(BASE + "models").status_code == 401
+ login(web)
+ for params in ({"limit": 0}, {"limit": 2001}, {"types": "Invalid"}):
+ assert web.get(BASE + "graph/memories", params=params).status_code == 400
+ before = len(remote.calls)
+ assert web.get(BASE + "graph/memories", params={"tenant": "cube-a"}).status_code == 404
+ assert len(remote.calls) == before
+ remote.cube = "cube-b"
+ assert web.get(BASE + "graph/memories").status_code == 502
+ assert web.get(BASE + "graph/memory", params={"id": "one"}).status_code == 404
+ remote.cube = "cube-a"
+ remote.bad = True
+ assert web.get(BASE + "graph/memories").status_code == 502
+ user = web.app.state.identity.create_account("reader", "reader-password-123")
+ web.post("/admin/tenants/a/members", json={"account_id": user["id"], "role": "viewer"})
+ login(web, "reader", "reader-password-123")
+ assert web.get(BASE + "graph/memories", params={"tenant": "b"}).status_code == 404
+ assert web.get(BASE + "models").status_code == 200
+ remote.bad = False
+ assert web.get(BASE + "graph/memories", params={"tenant": "a"}).status_code == 200
+
+
+@pytest.mark.parametrize(
+ "model",
+ ["sk-abcdefghijklmnopqrstuvwxyz", "https://user:password@host/model", "model?api_key=SECRET"],
+)
+def test_model_identifier_cannot_smuggle_credentials(tmp_path, model):
+ def remote(request):
+ return httpx.Response(
+ 200,
+ json={
+ "code": 200,
+ "data": {"records": [{"source": "MEMRADER_MODEL", "model": model, "spec": ""}]},
+ },
+ )
+
+ with TestClient(make(tmp_path, remote)) as web:
+ seed(web.app)
+ login(web)
+ result = web.get(BASE + "models")
+ assert result.status_code == 502
+ assert model not in result.text
+
+
+def test_graph_response_budget_provenance_and_derived_edges():
+ from server.backend.read_views import project_graph
+
+ nodes = [
+ {
+ "id": str(i) + "x" * 200,
+ "memory": "m" * 121,
+ "memoryTruncated": True,
+ "memoryType": "InsightMemory",
+ "status": "activated",
+ "sourceMemoryIds": [],
+ }
+ for i in range(2000)
+ ]
+ nodes[0]["sourceMemoryIds"] = [nodes[1]["id"], "not-on-this-page"]
+ edges = [
+ {
+ "source": nodes[i % 2000]["id"],
+ "target": nodes[(i + 1 + i // 2000) % 2000]["id"],
+ "type": "PARENT",
+ }
+ for i in range(20000)
+ ]
+ graph = project_graph(
+ {"cube_id": "a", "nodes": nodes, "edges": edges, "total_nodes": 2000, "truncated": False},
+ "a",
+ "a",
+ [],
+ 2000,
+ )
+ assert graph["stats"]["truncated"]
+ assert len(json.dumps(graph, ensure_ascii=False).encode()) < 2 * 1024 * 1024
+ assert "not-on-this-page" in graph["nodes"][0]["sourceIds"]
+ assert all(e["target"] != "not-on-this-page" for e in graph["links"])
+
+
+def test_graph_types_current_selection_and_readonly_methods(tmp_path):
+ remote = ReadTransport()
+ with TestClient(make(tmp_path, remote)) as web:
+ seed(web.app)
+ login(web)
+ graph = web.get(BASE + "graph/memories", params={"types": "PreferenceMemory"})
+ assert graph.status_code == 200
+ assert not graph.json()["data"]["nodes"]
+ assert remote.calls[-1][1]["memory_types"] == ["PreferenceMemory"]
+ assert remote.calls[-1][1]["mem_cube_id"] == "cube-a"
+ assert web.post(BASE + "models", json={}).status_code == 405
+ assert web.post(BASE + "graph/memories", json={}).status_code == 405
diff --git a/server/backend/tests/test_registration.py b/server/backend/tests/test_registration.py
new file mode 100644
index 000000000..25a6d88ac
--- /dev/null
+++ b/server/backend/tests/test_registration.py
@@ -0,0 +1,77 @@
+import re
+
+from fastapi.testclient import TestClient
+from server.backend.tests.test_accounts import application, login, seed
+
+
+def register(web, **changes):
+ page = web.get("/__login/register")
+ assert page.status_code == 200
+ csrf = re.search(r'name="csrf" value="([^"]+)"', page.text)[1]
+ return web.post(
+ "/__login/register",
+ data={
+ "csrf": csrf,
+ "username": "new-user",
+ "password": "new-password-123",
+ "confirm_password": "new-password-123",
+ **changes,
+ },
+ follow_redirects=False,
+ )
+
+
+def test_registration_login_permissions_and_persistence(tmp_path):
+ with TestClient(application(tmp_path)) as web:
+ seed(web.app)
+ assert 'href="/__login/register"' in web.get("/__login").text
+ response = register(web)
+ assert response.status_code == 303
+ assert response.headers["location"] == "/__login?registered=1"
+ assert "注册成功" in web.get(response.headers["location"]).text
+ assert login(web, "new-user", "new-password-123").status_code == 303
+ me = web.get("/api/auth/me").json()["data"]
+ assert me["platformAdmin"] is False
+ assert me["currentProjectId"].startswith("personal_")
+ assert web.get("/admin/accounts").status_code == 403
+ assert len(web.get("/api/dashboard/project/dict").json()["data"]) == 1
+ with TestClient(application(tmp_path)) as web:
+ assert login(web, "new-user", "new-password-123").status_code == 303
+
+
+def test_registration_validation_and_duplicate_do_not_create_accounts(tmp_path):
+ with TestClient(application(tmp_path)) as web:
+ seed(web.app)
+ for changes, status in [
+ ({"confirm_password": "different"}, 400),
+ ({"password": "short", "confirm_password": "short"}, 400),
+ ({"username": "bad username"}, 400),
+ ({"username": "ADMIN"}, 409),
+ ({"platform_admin": "true"}, 400),
+ ]:
+ response = register(web, **changes)
+ assert response.status_code == status
+ assert "