From ec2c90d2086b8f379733f0fa597f405641b5e434 Mon Sep 17 00:00:00 2001 From: lofyer Date: Mon, 12 Oct 2026 14:21:13 +0800 Subject: [PATCH] feat: add local model gateway and console tabs --- .gitignore | 2 + README.md | 163 ++++++--- docs/features/share-server/README.md | 33 +- .../share-server/backend-foundation-design.md | 190 +++++++++++ docs/features/share-server/feature-list.md | 259 +++++++++++++++ docs/features/share-server/logic-design.md | 46 ++- docs/features/share-server/prd.md | 38 ++- docs/features/share-server/progress.md | 98 +++++- .../features/share-server/technical-design.md | 172 +++++++--- docs/features/share-server/ui-design.md | 104 ++++-- package.json | 1 + scripts/dev.mjs | 48 ++- scripts/init-master-key.mjs | 33 ++ server/src/admin-boundary.ts | 19 ++ server/src/app.test.ts | 5 +- server/src/app.ts | 23 +- server/src/gateway-credentials.ts | 42 +++ server/src/gateway-foundation.test.ts | 225 +++++++++++++ server/src/gateway-routes.ts | 245 ++++++++++++++ server/src/gateway-store.ts | 258 +++++++++++++++ server/src/gateway.test.ts | 312 ++++++++++++++++++ server/src/sharing.test.ts | 5 +- server/src/test-socket.ts | 12 + shared/gateway.ts | 56 ++++ web/src/App.test.tsx | 158 +++++---- web/src/App.tsx | 147 ++------- web/src/app-config.ts | 68 ++-- web/src/components/app-shell.tsx | 122 +------ web/src/components/devices-page.tsx | 73 ++++ web/src/components/distribution-page.tsx | 13 + web/src/components/gateway-access-keys.tsx | 106 ++++++ web/src/components/gateway-page.test.tsx | 294 +++++++++++++++++ web/src/components/gateway-page.tsx | 191 +++++++++++ web/src/components/local-sharing.test.tsx | 22 +- web/src/components/local-sharing.tsx | 37 ++- web/src/components/overview-page.tsx | 272 ++++----------- web/src/components/page-tabs.tsx | 41 +++ web/src/components/services-demo.tsx | 171 +--------- web/src/components/settings-page.tsx | 31 +- web/src/components/sharing-center.test.tsx | 160 ++++----- web/src/components/sharing-center.tsx | 129 +------- web/src/components/unavailable-page.tsx | 21 ++ web/src/lib/gateway-api.ts | 38 +++ web/src/lib/sharing-api.ts | 15 + web/src/vite-api-boundary.test.ts | 93 ++++++ web/vite.config.ts | 20 +- 46 files changed, 3491 insertions(+), 1120 deletions(-) create mode 100644 docs/features/share-server/backend-foundation-design.md create mode 100644 docs/features/share-server/feature-list.md create mode 100644 scripts/init-master-key.mjs create mode 100644 server/src/admin-boundary.ts create mode 100644 server/src/gateway-credentials.ts create mode 100644 server/src/gateway-foundation.test.ts create mode 100644 server/src/gateway-routes.ts create mode 100644 server/src/gateway-store.ts create mode 100644 server/src/gateway.test.ts create mode 100644 server/src/test-socket.ts create mode 100644 shared/gateway.ts create mode 100644 web/src/components/devices-page.tsx create mode 100644 web/src/components/distribution-page.tsx create mode 100644 web/src/components/gateway-access-keys.tsx create mode 100644 web/src/components/gateway-page.test.tsx create mode 100644 web/src/components/gateway-page.tsx create mode 100644 web/src/components/page-tabs.tsx create mode 100644 web/src/components/unavailable-page.tsx create mode 100644 web/src/lib/gateway-api.ts create mode 100644 web/src/lib/sharing-api.ts create mode 100644 web/src/vite-api-boundary.test.ts diff --git a/.gitignore b/.gitignore index 8f53b08..aafeb8d 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ dist/ node_modules/ *.png data/ +.env* +/docs/references/ diff --git a/README.md b/README.md index 048a4a2..0d704e7 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,8 @@ This repository contains the first runnable ShareServer project. It is an interactive control-console prototype based on the [ShareServer PRD](docs/features/share-server/prd.md), not a production ShareServer implementation. It also includes a persistent local sharing metadata -service connected to GoodBuddy's Device Sharing application. +service connected to GoodBuddy's Device Sharing application and a local +OpenAI-compatible model gateway that forwards requests to configured upstreams. ## Repository and documentation @@ -36,20 +37,17 @@ Source and feature documentation were extracted from GoodBuddy commit action endpoints. - Same-origin React, Vite, TypeScript, Tailwind CSS, and shadcn/ui-style Web console. -- Administrator views for overview, members, devices, capabilities, policies, - approvals, releases, federation, tasks, audit, and settings. -- Member self-service views for personal devices, grants, and requests. -- Sharing center at the preserved `/capabilities` route: capability and knowledge - catalogs, publisher/device views, URL filters and linked metadata details. - Knowledge fixtures distinguish synced copies from remote retrieval and display - separate search/read/download permissions; no content operations are connected. -- GoodBuddy service demos: `/model-gateway`, `/documents`, and `/distribution`. - They simulate upstream routing policies and call audit, document ownership and - recycle-bin permissions, OnlyOffice instance checks, and per-group configuration - releases and rollback. State survives navigation between these routes but resets - on refresh; these actions use browser memory only, with no network requests or - local storage. Offline devices retain their applied version and remain pending. -- Responsive navigation, dark and light themes, search, filters, details, +- Overview with service links; planned modules show pending-service notices. +- Sharing center at `/capabilities`: real device and publication metadata, + filters, source declarations and separate search/read/download flags; no content + operations or organization ownership checks are connected. +- Local model gateway at `/model-gateway`: persistent upstreams and model aliases, + encrypted upstream credentials, scoped client access keys, real model discovery, + request testing, JSON/SSE forwarding, and metadata audits. +- `/documents` and `/distribution` are placeholders without document operations, + OnlyOffice checks, drafts, releases or rollback; saving gateway models does not + deliver configuration to GoodBuddy. +- Responsive navigation, dark and light themes, filters, details, forms, confirmations, and keyboard-accessible dialogs. - Production build and a non-root container image. @@ -61,17 +59,18 @@ business pages. ## Not included yet -The existing snapshot records are read-only fixtures; the service demos mutate -only in-memory examples. The action endpoint returns an acknowledgement but does -not persist changes. PostgreSQL, -initialization and login, organization isolation, real authorization, device +Legacy snapshot and acknowledgement endpoints remain in the backend but are no +longer used by the console. PostgreSQL, +initialization and login, organization isolation, organization authorization, device WebSocket connections, task routing, relay, package storage, federation, and auditable mutations remain production implementation work. The planned Office editing integration also has no current implementation: ONLYOFFICE Docs, object storage, document sessions, save callbacks, checkpoints, and Desktop -document tabs remain design work. Model inference, actual gateway forwarding, -document saving, and device delivery are not implemented. The OnlyOffice checks -use preset results and do not establish an editing session. See the +document tabs remain design work. Model hosting and inference, document saving, +and device delivery are not implemented. B1 gateway code is implemented with +Bearer key authentication and model scopes, but acceptance is partial; GoodBuddy +Desktop, browser UI and a full tool-result roundtrip remain unverified for B1. +Organization authentication and quotas remain unimplemented. See the [ShareServer feature documentation](docs/features/share-server/README.md). Daily work belongs in GoodBuddy; ShareServer provides the organization backend. @@ -95,32 +94,113 @@ The readiness endpoint reports this explicitly: ## Run locally -```text -cd goodbuddy-shareserver +Run from the repository root. The development launcher creates and reuses a +persistent master key under the current user's home directory. For first-time +Windows PowerShell setup: + +```powershell npm ci +$env:SHARESERVER_GATEWAY_BASE_URL = 'http://127.0.0.1:8787/api/v1/gateway/v1' npm run dev ``` +The automatic key is outside `SHARESERVER_DATA_DIR` (default `./data`) and is +reused on later starts. To manage the key explicitly, create one with +`npm run gateway:init-key -- ` and set `SHARESERVER_MASTER_KEY_FILE` +before starting the service. Back up the key separately from `data/`; restoring +encrypted credentials requires the same master key. + Open `http://127.0.0.1:5174`. Vite proxies `/api` to the Hono service on port -`8787`. +`8787` by default. When the launcher loads `SHARESERVER_PORT`, it passes the +derived API port to Vite so the proxy follows the API child. Vite accepts `/api` +requests only from loopback sockets, even if its listen address is changed. ## Local GoodBuddy integration With `npm run dev` running here, run `npm run dev` in the adjacent GoodBuddy checkout. Open Device Sharing from its application center, save the server URL (`http://127.0.0.1:8787`), register the device, and publish a metadata entry. -Open `http://127.0.0.1:5174/capabilities?view=local` to view actual records separately -from the organization fixtures. Revoke in GoodBuddy and refresh the Web directory. +Open `http://127.0.0.1:5174/capabilities?view=local` to view actual records. +Organization fixture tabs have been removed. Revoke in GoodBuddy and refresh the Web directory. Records persist in `data/sharing.json`; override the directory with -`SHARESERVER_DATA_DIR`. Use one process per data directory. This API is a local, -unauthenticated development service: device IDs identify records, not owners. +`SHARESERVER_DATA_DIR`. Use one process per data directory. This API is a local +development service without device authentication, restricted to direct loopback +administration: device IDs identify records, not owners. Source modes and permission flags are declarations only; no knowledge content, file synchronization, remote execution, or organization authorization is provided. The [technical design](docs/features/share-server/technical-design.md#41-本地共享目录契约已实现) defines the actual HTTP contract. The readiness response additionally reports this local-service boundary and keeps `productionReady: false`. +## Local model gateway + +The gateway runs independently of GoodBuddy and has no Desktop source dependency. +Open `/model-gateway` in the Web console. A new store starts with no upstreams, +models or audits; mutations persist in `data/gateway.json` under +`SHARESERVER_DATA_DIR` (default `./data`). Use one process and one store per data +directory. It retains the newest 200 chat audits, containing metadata only, never +prompts, response content or credentials. Missing token usage remains unknown. + +Add an upstream with its name, HTTP/HTTPS Base URL, OpenAI Chat Completions +protocol and API Key (or no authentication). Credentials are encrypted with +AES-256-GCM and never returned by the API. Editing defaults to `keep`; `replace` +sets a new secret and `clear` removes it. Selecting no authentication does not +delete a saved credential. `credentialConfigured` reports stored configuration, +not connectivity or successful decryption. + +The master key is supplied by `SHARESERVER_MASTER_KEY_FILE`, or alternatively +`SHARESERVER_MASTER_KEY` containing 64 hexadecimal characters; do not set both. +Missing or incorrect keys report credential unavailability and never regenerate +the key over existing ciphertext. + +Persisted legacy `apiKeyEnv` references remain readable and callable. Replace +them explicitly through the upstream editor to use managed credentials. The +variable-name/prefix UI and `/import-env` endpoint have been removed. For legacy +references or authorized development testing only, an env file can be loaded: + +```text +npm run dev -- --env-file=../goodbuddy/.env.deepseek.local +``` + +`scripts/dev.mjs` passes values loaded from this file only to the API child, not +Vite; existing process variables take precedence. Restart the API after environment +changes. The adjacent path is optional test input, not a repository dependency. +Use preexisting personal configurations only for explicitly authorized environment +testing; the gateway does not automatically read or migrate GoodBuddy settings. + +Use **Discover models** to request the upstream model list, add or edit an alias +mapping, and explicitly send a real test request. Discovery and testing contact the upstream; chat may incur +charges. Manual mapping is also available. + +For an OpenAI-compatible client, use Base URL +`http://127.0.0.1:8787/api/v1/gateway/v1`, protocol `openai-chat-completions`, +a configured model alias and a newly created gateway access key as the client's +API Key. The console shows `SHARESERVER_GATEWAY_BASE_URL`; it does not infer a +public address from Vite or request Host headers. Supported +client endpoints are `/models` and `/chat/completions`; chat supports JSON or SSE, +tool-call data and upstream usage. Responses and Anthropic APIs are not supported, and the +gateway does not execute tools. Requests are limited to 1 MiB, upstream responses +to 16 MiB, and the default request timeout is 120 seconds. Exact stream limits and +API contracts are in [technical design section 4.2](docs/features/share-server/technical-design.md#42-本地模型网关契约已实现). + +Both client endpoints require a Bearer access key. Create a key for selected +model record IDs; renaming an alias preserves its scope. The full key is shown +only at creation, and only its SHA-256 digest is stored. Missing, invalid or +revoked keys return 401; unauthorized models return 403. Model lists include only +authorized, enabled models with enabled upstreams and usable credentials. Audits +link the access key ID, which identifies a calling configuration, not a user. + +Management and the legacy sharing API require a direct loopback socket. For these +requests, the API rejects `Forwarded`, `X-Forwarded-*` and `X-Real-IP` headers and checks Origin; +Origin alone is not authorization. Web tests use local-only `/api/v1/gateway/test`, +not a Bearer bypass on client endpoints. Reverse-proxy and container administration +are unsupported until B2 login exists; a proxy's loopback connection must not be +treated as an administrator. Nonlocal clients must reach the API inference +endpoints directly with a key, not through Vite. Use HTTPS for external deployment; +HTTP carries keys without transport encryption. Organization isolation and quotas +remain pending. Dated verification is in [progress](docs/features/share-server/progress.md). + ## Run the production build ```text @@ -131,20 +211,26 @@ npm start Open `http://127.0.0.1:8787`. +Use the same master-key and gateway Base URL environment settings as development +when starting the build. A successful build does not imply production readiness. + Build output is `dist/web/` for the console and `dist/server/` for the backend. `npm start` runs `dist/server/server/src/index.js` from the repository root and serves `dist/web/`. The backend preserves the source directory structure under its output directory so compiled imports can resolve root `shared/` modules. Set `SHARESERVER_PORT` to use another listen port. Local runs bind to -`127.0.0.1` by default; set `SHARESERVER_HOST` explicitly when a trusted reverse -proxy or container needs another interface. Do not expose the prototype as a -production service. +`127.0.0.1` by default; `SHARESERVER_HOST` changes the listen interface, not the +management boundary. Do not expose the prototype as a production service. ## Container -Container configuration lives in `docker/`. Run these commands from the repository -root to build and start the Web console and API together: +Container configuration lives in `docker/`. The existing commands below build and +start the image, but B1 does not support container/proxy administration: published +ports usually reach the API from a non-loopback socket and management returns 403. +The supplied Compose file does not configure the master-key file. Managed +credentials require a separate secret mount outside `/app/data` and its environment +setting; container management must wait for B2. Run from the repository root: ```text mkdir -p data @@ -155,11 +241,12 @@ docker compose -f docker/compose.yaml logs -f shareserver ``` Open `http://127.0.0.1:8787`. The container runs as the non-root `node` user -and stores sharing metadata in `/app/data`, bind-mounted from the project's +and stores sharing and gateway metadata in `/app/data`, bind-mounted from the project's `./data` directory. No named volume or additional database container is required. Existing local `data/sharing.json` is used directly and must be readable by UID 1000. -Only sharing metadata is -persisted; the console's other demos retain their prototype behavior. +Gateway configuration and its bounded audit metadata use `data/gateway.json` in +the same mount; upstream credentials are encrypted there and access keys are +stored as digests. The master key must be supplied and backed up separately. Stop with `docker compose -f docker/compose.yaml down`; the host directory survives container recreation and removal with `down -v`. Run only one process per data directory, including @@ -176,7 +263,7 @@ docker run --rm -p 127.0.0.1:8787:8787 --mount type=bind,source="$(pwd)/data",ta Compose publishes port 8787 on all host interfaces; the standalone example publishes only on the host loopback interface. Containerization -does not add authentication or authorization; this remains a local prototype. +does not add organization login or relax B1's socket restrictions. The image health check verifies HTTP availability, not storage writability or production readiness. diff --git a/docs/features/share-server/README.md b/docs/features/share-server/README.md index 9da536b..6fe5c09 100644 --- a/docs/features/share-server/README.md +++ b/docs/features/share-server/README.md @@ -23,34 +23,43 @@ ShareServer 不属于 Electron Main,不负责桌面端局域网发现,也不 | 文档 | 权威职责 | | --- | --- | +| [产品功能列表](./feature-list.md) | 按模块、页面和 Tab 汇总功能范围、状态和当前交付边界 | | [PRD](./prd.md) | 管理角色、产品范围、功能需求和验收 | | [用户故事](./user-stories.md) | 工作空间、实时消息、资料同步及数字分身处理的验收场景与逻辑审查 | | [功能逻辑](./logic-design.md) | 组织隔离、设备、发布、策略、任务和联邦状态规则 | | [管理界面设计](./ui-design.md) | ShareServer Web 控制台的信息架构与交互 | | [技术设计](./technical-design.md) | 服务模块、API、数据、部署、运维和验证 | +| [后端基础设计](./backend-foundation-design.md) | GoodBuddy 适配依据;B1 已实现、验收部分完成,B2 至 B6 待实现 | | [实施进度](./progress.md) | 已验证实现、剩余生产工作与验证证据 | | [共享网络总体设计](../../../../goodbuddy/docs/architecture/share-network-architecture.md) | 跨端身份、能力、授权、任务、更新和协议权威(GoodBuddy 仓库) | | [Desktop Office 协同编辑](../../../../goodbuddy/docs/features/office-document-editing/README.md) | 文档 Tab、源文件回写、冲突和 AI 选区编辑(GoodBuddy 仓库) | 工作空间协作场景由用户故事追踪;管理员、能力发布者、审计员等既有场景继续由 PRD 与逻辑 -设计覆盖。仓库已有全控制台交互原型及本地共享目录服务,后者持久化 GoodBuddy 注册和发布的元数据。 +设计覆盖。仓库已有控制台交互原型、本地共享目录服务和真实转发的本地模型网关;目录持久化 +GoodBuddy 注册和发布的元数据,网关独立保存上游、模型映射和有界调用元数据。 组织生产服务尚未实现。当前边界和运行方法见[实施进度](./progress.md)与 -[`README.md`](../../../README.md)。跨仓库文档链接要求将 `goodbuddy` 与 +[`README.md`](../../../README.md)。历史演示与测试记录保留其原有范围,当前实现说明不追溯扩大验证结论。 +跨仓库文档链接要求将 `goodbuddy` 与 `goodbuddy-shareserver` 克隆到同一级目录;服务构建和运行不依赖桌面端仓库。 当前源码按 `web/src/`、`server/src/` 和根 `shared/` 组织,构建、测试与依赖仍由根目录统一管理。 目录和产物路径见[技术设计第 1 节](./technical-design.md#1-仓库与运行边界)。 -## GoodBuddy 服务演示 +## GoodBuddy 服务入口 -当前新增 `/model-gateway`(模型网关)、`/documents`(文档与协作)、`/distribution`(配置下发) -三个路由,演示策略判断、文档归属与回收站、OnlyOffice 实例配置和检查、按组发布与回退。 -三页导航共享会话内存,刷新重置;服务演示操作不发网络请求、不写本地存储。角色开关、检查 -结果和在线设备应用状态均为模拟,未实现推理、真实转发、Office 编辑保存或设备交付。 -具体交互见 [UI 设计第 16.1 节](./ui-design.md#161-goodbuddy-服务演示)。 +`/model-gateway` 提供上游与模型映射保存、服务端加密凭据、模型访问 Key、真实模型发现、请求测试 +和调用元数据审计。管理仅限直连回环,客户端通过 Bearer Key 访问获授权的模型;组织认证、组织 +隔离及配额控制仍待实现。旧环境引用保留存储兼容,前缀导入界面和接口已移除。 +网关独立于 GoodBuddy 运行,无桌面源码依赖;个人既有配置仅可用于明确授权的环境测试,不自动读取或迁移。 +启动方法及客户端 Base URL 见[根 README](../../../README.md#local-model-gateway),接口、存储和限制见 +[技术设计第 4.2 节](./technical-design.md#42-本地模型网关契约已实现)。 -共享中心沿用 `/capabilities`。能力目录、共享知识库、按用户查看和按设备查看使用示例数据; -“本地服务目录”页签读取真实本地记录。来源模式、权限展示与跳转约定见 +`/documents` 显示待接入说明,没有示例文档或 OnlyOffice 模拟检查。`/distribution` 同样为待接入占位, +无配置草稿、设备回执、发布或回滚;保存网关模型 +不会自动下发到 GoodBuddy。交互见 [UI 设计第 16.1 节](./ui-design.md#161-goodbuddy-服务入口)。 + +`/devices` 和共享中心沿用真实本地 API:前者展示登记设备及其发布数量,后者直接读取设备与发布记录,支持筛选、设备跳转和撤销已发布元数据;已移除组织示例页签。 +来源模式与知识权限仅为声明,尚无正文服务。交互约定见 [UI 设计](./ui-design.md),本地 API 见[技术设计第 4.1 节](./technical-design.md#41-本地共享目录契约已实现)。 本地体验:在本仓库运行 `npm run dev`,在 GoodBuddy 仓库运行 `npm run dev`,从桌面应用中心 @@ -58,6 +67,10 @@ ShareServer 不属于 Electron Main,不负责桌面端局域网发现,也不 `http://127.0.0.1:5174/capabilities?view=local` 查看。服务数据默认保存到 `data/sharing.json`。 完整桌面范围与验证见[设备共享文档](../../../../goodbuddy/docs/features/device-sharing/README.md)。 +[后端基础设计 B1](./backend-foundation-design.md#4-b1正常模型网关接入)代码已实现,验收部分完成: +已记录 103 项自动化测试及部分真实 API 验证;完整工具结果回传、GoodBuddy Desktop 和浏览器界面 +验收仍未完成。Vite `/api` 只允许回环 socket,容器/反向代理管理须等待 B2 登录。 + ## 角色 - **系统管理员**:维护实例、身份源、域名、证书、存储和全局限制。 diff --git a/docs/features/share-server/backend-foundation-design.md b/docs/features/share-server/backend-foundation-design.md new file mode 100644 index 0000000..8efc90b --- /dev/null +++ b/docs/features/share-server/backend-foundation-design.md @@ -0,0 +1,190 @@ +# 基于 GoodBuddy 的后端基础设计 + +状态:B1 已实现、验收部分完成,2026-10-05;B2 至 B6 待实现。原始设计日期为 2026-10-04。 +本文是[技术设计](./technical-design.md)的基础服务实施分册,维护客户端适配依据、接口和实施顺序。 +产品范围由 [PRD](./prd.md)维护,业务状态由[功能逻辑](./logic-design.md)维护,已验证事实见[实施进度](./progress.md)。 + +## 1. 设计结论 + +GoodBuddy 承担模型选择、对话、工具执行、知识检索入口和文档日常操作。ShareServer 独立运行, +负责模型请求转发、组织身份与授权、共享目录和服务端文档存储。ONLYOFFICE 是独立编辑服务。 +不把 Electron、客户端 SQLite、Agent Runtime 或客户端源码引入服务端构建。 + +基础功能优先利用客户端已有调用路径:先完成正常的模型网关接入,再增加组织与设备身份, +随后实现组织模型导入和真实资源发布。知识检索、文档编辑各自按实际用户动作验收。 +完整组织服务仍以 PostgreSQL 为目标;本地网关阶段继续单进程文件存储,不先建设消息系统、联邦或通用任务平台。 + +## 2. 客户端依据与差距 + +下表链接指向相邻 `goodbuddy` 仓库,要求两个仓库位于同一级目录。只引用契约,不建立源码依赖。 + +| 客户端实现 | 已核对行为 | 后端设计影响 | +| --- | --- | --- | +| [模型配置 schema](../../../../goodbuddy/src/shared/contracts.ts) | profile 含 `baseUrl`、`modelName`、显式 `protocol`、认证方式及能力设置;模型 ID 最长 128 字符 | 开放模型 ID 按客户端限制保存;目录携带协议,不能只返回显示名称 | +| [OpenAI 地址拼接](../../../../goodbuddy/src/main/agent/openai-endpoint.ts) | 在 Base URL 后追加 `chat/completions` 或 `responses`;不自动增加 `/v1` | 接入信息必须给出包含 `/v1` 的完整 Base URL | +| [模型 Runtime](../../../../goodbuddy/src/main/agent/model-runtime.ts) | 连接测试发非流式生成请求;对话走 SSE,包含工具、工具结果和用量字段 | 获取模型成功不能代替连接测试;必须验收 JSON、SSE 与工具往返 | +| [配置存储](../../../../goodbuddy/src/main/runtime-settings-store.ts)、[凭据加密](../../../../goodbuddy/src/main/settings-credential-cipher.ts) | Main 保管模型 Key,Renderer 只看到是否已配置 | Desktop 保存网关访问 Key,上游 Key 留在 ShareServer | +| [模型选择](../../../../goodbuddy/src/shared/runtime-selection-contracts.ts) | 会话、项目、全局逐层解析模型 profile | 导入组织模型不覆盖个人模型,也不自动更换已有会话选择 | +| [设备共享服务](../../../../goodbuddy/src/main/device-sharing-service.ts) | 单服务 URL,稳定设备 ID,HTTP 注册、列表、发布和撤销;没有认证或心跳 | 现有 ID 不能当作用户身份;注册记录不能标为在线 | +| [发布契约](../../../../goodbuddy/src/shared/device-sharing-contracts.ts) | 仅名称、描述、类型、来源模式和权限声明,没有实际资源 ID | 不能从目录记录直接派发执行或检索请求 | +| [能力目录](../../../../goodbuddy/src/main/capabilities/capability-service.ts) | 可枚举本机 Skill、MCP 等配置 | 后续发布选择器可复用枚举;枚举结果不自动授权共享 | +| [知识服务](../../../../goodbuddy/src/main/knowledge/knowledge-service.ts) | `retrieve`、`retrieveMany` 是现有检索入口 | 提供端经授权后直接调用知识服务,不用 Agent 提示词代替检索协议 | +| [外部知识契约](../../../../goodbuddy/src/shared/external-knowledge-contracts.ts) | 当前提供者是 Dify、FastGPT、RAGFlow | ShareServer 共享知识需要新增适配,不能假设现有外部连接已支持 | +| [Office 功能文档](../../../../goodbuddy/docs/features/office-document-editing/README.md) | Office 编辑尚在设计;文件解析与外部打开已有实现 | 服务端 callback 和客户端文档 Tab 都要实现,不能把解析当作编辑 | + +GoodBuddy 目前没有自动获取网关模型并创建 profile 的通用流程。现有设备共享页面也不会自动 +启用模型连接。设备共享应用仍默认关闭,名称保留“设备共享(技术预览)”。 + +模型协议分别验收:本期只支持 `openai-chat-completions`。Responses、Anthropic、图像生成和 +Embedding 均不因为上游声称“OpenAI 兼容”而自动支持。现有网关也没有这些转发端点。 +远程 Agent 使用模型时,发请求的 Host 必须能访问网关;桌面本机的 `127.0.0.1` 地址不能直接用于该场景。 + +## 3. 服务划分与存储 + +| 服务 | 第一项可用行为 | 数据归属 | +| --- | --- | --- | +| 模型网关 | 保存上游、开放模型、验证访问 Key、转发并记录调用 | 上游配置及凭据、映射、访问 Key 摘要、调用元数据 | +| 身份与组织 | 管理员初始化、成员登录、加入组织 | 用户、组织、成员关系、会话 | +| 设备与目录 | 认证设备登记、撤销、发布者和资源绑定 | 设备归属、发布记录、动作授权;设备连接状态单独管理 | +| 客户端配置 | 显式获取并导入组织模型,回报应用结果 | 组织配置版本与每设备接收/应用结果 | +| 知识共享 | 对指定发布条目检索并返回引用 | 服务端保存发布元数据;远程模式的正文仍在提供设备 | +| 文档服务 | 上传、读取、编辑后保存、按归属删除或转交 | 主文档、归属、版本、回收记录;编辑会话单独保存 | + +继续沿用 Hono 路由、业务 Store 和根 `shared/` DTO。业务增长后按上述职责拆文件,不预先建立 +空模块树或通用插件框架。HTTP 边界校验输入与输出投影,控制台只使用 API,不直接访问存储。 + +现有 `sharing.json` 与 `gateway.json` 保留。网关新增秘密使用服务端加密密文,组织阶段再将 +用户、组织、设备和授权纳入 PostgreSQL 事务。迁移要保留现有 ID 和撤销事实;没有身份的旧目录 +只能作为未绑定记录,不能按设备名称自动认领。迁移命令、备份和重复执行行为随数据库实现交付。 + +## 4. B1:正常模型网关接入 + +这一实施单元完成本地可用路径,不依赖组织数据库。支持一个 ShareServer 实例、多个上游和 +多个模型映射;使用本机管理入口,GoodBuddy 可手工配置网关。组织分组、费用结算、自动切换上游另行实现。 + +### 4.1 上游与凭据 + +新建上游输入 `name`、`baseUrl`、`protocol=openai-chat-completions`、`authentication=api-key|none`、 +`enabled`。使用 API Key 时输入真实密钥。更新通过 `credentialAction=keep|replace|clear` 区分 +保留、替换和清除,空输入不解释为清除。响应只返回 `credentialConfigured`,不返回密钥或密文。 + +使用 Node 标准加密实现 AES-256-GCM;主密钥来自部署配置或数据目录外的秘密文件,首次配置 +命令为 `npm run gateway:init-key -- <文件路径>`,父目录须已存在,拒绝覆盖已有文件。 +通过 `SHARESERVER_MASTER_KEY_FILE` 指定文件,或用 `SHARESERVER_MASTER_KEY` 提供 64 位十六进制值,不能同时设置。 +密文带随机 nonce 与认证标签,以稳定上游 ID 为 AAD,随上游记录原子保存,避免独立文件半提交。 +缺少或错误的主密钥应报告凭据不可用,不能生成新密钥覆盖既有密文。备份说明要求单独保管主密钥。 +主密钥不进入 Web、普通数据备份和日志;HTTP 请求正文不记录密钥。 + +现有环境变量引用是已持久化的数据,需要继续可读、可调用;编辑时可以显式替换为托管凭据。 +移除产品界面的前缀导入和变量名输入,环境文件加载仅保留为开发测试方式,不自动扫描 GoodBuddy 配置。 + +Base URL 接受 HTTP/HTTPS,保留合法服务路径,拒绝内嵌凭据、query 和 fragment,去掉尾斜杠。 +可信内网 HTTP 上游已属于支持范围。访问 Key 经非加密 HTTP +传输的部署限制在接入说明中说明,对外部署使用 HTTPS;不为此加入额外网络发现流程。 + +### 4.2 模型与转发 + +发现模型调用上游 `/models`,发现失败不删除已保存配置,也不阻止手动填写模型 ID。 +用户选择模型并指定对外 ID 后才创建映射。新对外 ID 最长 128 字符,首字符为 ASCII 字母或数字, +其余允许字母、数字、下划线、点、冒号、斜杠、连字符;显示名称单独保存。现有更长别名保留调用, +在导入 GoodBuddy 前要求管理员显式改名,不静默截断已有标识。 + +每个对外 ID 明确绑定一个上游和远端模型。转发只替换模型名与上游认证,不执行工具,不自动重试 +有可能已经执行的请求。工具参数、工具结果和用量按 Chat Completions 协议传递;取消传播至上游。 +保留现有超时、输出大小边界和终态审计。上游缺失用量记录为未知,不显示为零消耗。 + +### 4.3 网关访问与管理边界 + +客户端访问 Key 与上游 API Key 分开。服务端生成随机访问 Key,只在创建成功时返回完整值, +持久化 SHA-256 摘要、ID、名称、创建/撤销时间和允许的模型记录 UUID;别名改名不改变授权,列表不返回原值。可撤销后重新创建, +不提供读取旧 Key。初期 Key 代表一个调用配置,审计可关联 Key ID,但不能把备注名解释为已认证用户。 + +`/v1/models` 与 `/v1/chat/completions` 使用 Bearer Key;未知或撤销的 Key 返回 401, +无权调用该模型返回 403。列表只返回该 Key 允许且可用的模型。停用或撤销阻止后续请求, +已开始的流按原请求结束或由调用方取消;本期不承诺撤销自动追回已输出内容。 + +本机管理接口不能仅依靠 Origin 检查授权。B1 默认管理请求限直连回环来源,校验实际连接地址, +拒绝携带 `Forwarded`、`X-Forwarded-*` 或 `X-Real-IP` 的管理请求,并检查 Origin。 +Vite 在代理 `/api` 前也检查原始 socket,只允许回环。除健康和就绪 GET 外,非回环 API 请求 +仅能访问已带 Key 的推理接口;无认证旧目录 API 也保持 +本机访问。容器/反向代理管理需要 B2 登录接入,不能将代理地址当作管理员。Web 网关测试仍通过 +网关转发逻辑,由本机管理入口调用,不在浏览器长期保存管理秘密或把豁免开放给推理接口。 + +### 4.4 已实现接口 + +下表记录 B1 已实现接口,字段、错误及资源限制以技术设计第 4.2 节为准。前缀导入接口已移除,旧环境引用仅保留存储兼容。 + +| 路径 | 已实现行为 | +| --- | --- | +| `GET /api/v1/gateway/config` | 上游与模型公开投影、调用元数据、部署指定 `gatewayBaseUrl`;不携带任何密文或完整 Key | +| `POST /api/v1/gateway/upstreams`、`PUT /api/v1/gateway/upstreams/:id` | 接收地址、协议、认证方式和凭据操作,替代新表单的 `apiKeyEnv` | +| `POST /api/v1/gateway/upstreams/:id/discover` | 真实模型发现,保留手工映射入口 | +| `POST /api/v1/gateway/models`、`PUT /api/v1/gateway/models/:id` | 保存对外 ID、显示名、远端模型和启停 | +| `GET/POST /api/v1/gateway/access-keys` | 列表或创建模型访问 Key | +| `POST /api/v1/gateway/access-keys/:id/revoke` | 撤销 Key,重复撤销返回同一撤销事实 | +| `POST /api/v1/gateway/test` | 本机管理端非流式测试;复用真实转发及审计,不接受自定义上游 URL | +| `GET /api/v1/gateway/v1/models` | Bearer 认证后的 OpenAI 模型列表 | +| `POST /api/v1/gateway/v1/chat/completions` | Bearer 认证、模型授权、JSON/SSE 转发 | + +Key 无效返回 401,其余错误沿用 `{error:{code,message},requestId}`,请求 ID 与调用 +审计关联。不能把上游含密钥的错误正文原样返回;保留可定位的上游状态和错误类别。 + +### 4.5 GoodBuddy 使用与验收 + +管理员保存上游并开放模型后,在网关接入区取得 Base URL、协议和对外模型 ID,创建访问 Key。 +GoodBuddy 在现有模型连接页面手动新增配置: + +```text +Base URL: http://127.0.0.1:8787/api/v1/gateway/v1 +Protocol: openai-chat-completions +Model: 管理员开放的模型 ID +API Key: ShareServer 签发的访问 Key +``` + +该操作不依赖设备共享开关,不读取供应商 Key,不修改个人已有 profile。异机调用用客户端可达 +的 ShareServer 地址;界面显示配置的公开地址,不能把 Vite 端口或请求中的任意 Host 当作权威地址。 + +以下为完整验收清单,尚未全部通过。2026-10-05 已完成自动化检查和部分真实 API 验证, +包括发现、JSON/SSE、工具选择及本地执行、重启持久化与撤销拒绝;工具结果未回传上游, +未验证 GoodBuddy Desktop;浏览器仅核对页面快照,交互与视觉验收未完成。详细证据和剩余项目见[实施进度](./progress.md)。 + +验收依次检查: + +1. 从空目录经 Web 保存真实上游,发现或手动添加模型;刷新、重启仍保留配置,密钥不回显。 +2. GoodBuddy 现有模型连接测试成功,真实会话收到流式回答,审计可以关联模型和访问 Key。 +3. 用无副作用工具完成一次真实工具调用与结果回传;停止生成时上游请求被取消。 +4. 错误 Key、撤销 Key、未授权模型和停用上游均拒绝新调用,错误不伪装成空成功响应。 +5. 上游超时、SSE 中断、凭据缺失、主密钥错误均给出准确状态;检查持久化及日志不含明文秘密或对话正文。 +6. 验证 HTTP 内网上游及管理接口来源限制。单元测试不能替代上述桌面与真实服务验证。 + +这组验证只覆盖本机模型 Runtime。OpenCode、Continue、Harness 和远程 Agent 另行记录结果, +不从同一个 profile 推断全部 Runtime 可用。真实模型验证记录调用次数,不将配置下发标记为已实现。 + +## 5. 后续基础服务实施顺序 + +| 单元 | 交付内容 | 前置条件与完成标准 | +| --- | --- | --- | +| B2 身份与组织 | PostgreSQL 迁移、首个管理员、登录退出、单组织成员、设备绑定与撤销;Web 使用会话,Desktop 凭据在 Main 保存 | 从可信本机管理扩展到组织部署;两个不同成员不能改动对方设备或私人资源;退出或撤销后新请求被拒 | +| B3 组织模型导入 | 认证目录、用户显式导入、组织模型来源标记、配置版本与应用回报 | 依赖 B2;客户端创建独立 profile,保留个人配置;离线为待接收,收到后保存成功才为已应用 | +| B4 真实资源发布 | 从本机能力/知识库选择资源,登记稳定来源 ID、所有者、提供设备及独立动作权限 | 依赖 B2;客户端增加资源绑定和提供端通道;无心跳不显示在线,旧手工目录不自动成为可调用资源 | +| B5 共享知识检索 | 消费者选共享库,服务端授权并路由,提供端调用 `retrieveMany`,返回有界引用 | 依赖 B4;两台 GoodBuddy 真实检索,撤销及提供端离线有准确失败;正文读取、下载各自另验收 | +| B6 服务端文档 | 主文档上传、读取、个人删除、转交组织、回收;再接 ONLYOFFICE 编辑保存与 GoodBuddy 文档入口 | 归属功能依赖 B2;保存后重开及重启可见,转交后个人删除权失效,管理员元数据可见不赋予正文权限 | + +Office 私有 DOCX 试验仍可按技术设计第 10.1 节独立验证部署与保存。该试验没有用户归属, +不能替代 B6。后续服务端主存文档保存不要求 Desktop 回写确认;显式本地/SSH 来源模式另走既有源文件契约。 + +B3 先实现主动获取和显式导入,不以推送、离线队列或回滚系统为第一步。组织配置关联服务端稳定 +模型 ID,个人 profile 不由组织配置覆盖。组织撤销后网关直接拒绝访问,客户端缓存不能维持授权。 + +B4 的提供端连接必须单独实现。已有 Agent 执行通道没有发布身份、共享授权和知识正文协议,不能 +直接暴露为共享 API。远程知识模式不上传整个库;服务器副本模式须实际完成文件与索引写入才可标为可检索。 + +## 6. 文档与实施记录 + +2026-10-04 设计轮只完成设计与源码核对,不更改两仓运行代码、不读取 `.env*`、不调用上游模型。 +现有模拟接口仍留在服务端源码但已退出控制台路由,后续清理应单独验证调用方,不作为基础服务使用。 +2026-10-05 按 B1 代码同步技术设计第 4.2 节、逻辑规则、UI 设计、运行文档和实施进度, +将实现状态与部分验收结果分开记录。B2 至 B6 和 B1 剩余验收继续按上述顺序推进。 + +跨产品网络与 Office 契约继续引用 GoodBuddy 原文;其总体架构仍有“没有真实 Desktop 集成”的旧描述, +应在后续 GoodBuddy 文档同步时改为“已有元数据接线,完整共享网络待实现”。本轮不修改相邻仓库。 diff --git a/docs/features/share-server/feature-list.md b/docs/features/share-server/feature-list.md new file mode 100644 index 0000000..d9c837f --- /dev/null +++ b/docs/features/share-server/feature-list.md @@ -0,0 +1,259 @@ +# ShareServer 产品功能列表 + +## 文档用途 + +本文用于确定 ShareServer 的产品信息架构、页面范围和功能状态。功能按“模块、页面、Tab、功能”组织,页面结构先于功能细节确定。 + +状态含义如下: + +| 状态 | 含义 | +| --- | --- | +| 已实现 | 代码和自动化验证已覆盖,范围以实施进度中的证据为准 | +| 部分实现 | 已有可运行功能,但生产验收或关键依赖尚未完成 | +| 设计中 | 已形成产品和技术设计,尚未作为生产功能交付 | +| 待规划 | 已进入产品方向,页面和接口仍需进一步确定 | + +ShareServer 是 GoodBuddy 的独立组织后台。成员的日常操作主要在 GoodBuddy 中完成,ShareServer 负责设备目录、能力发布、授权、任务、模型网关、文档和审计等组织能力。当前仓库已经包含本地共享目录和真实转发的本地模型网关,生产身份、组织隔离、设备控制连接和工具执行尚未完成。 + +## 页面组织规则 + +- 一级导航按业务对象分组,页面只负责一个相对完整的管理对象。 +- 页面内 Tab 按查看范围或操作阶段划分,不能把配置、运行记录和统计混在同一个 Tab。 +- 列表、详情和创建表单属于同一 Tab 的交互,不单独扩成一级导航。 +- Tab 名称使用产品对象或用户任务命名,避免使用“其他”“更多功能”等收纳式名称。 +- 当前未接入的 Tab 可以进入,但必须显示待接入边界,不生成示例数据,也不提供不会生效的操作。 + +## 信息架构总览 + +| 一级模块 | 页面 | Tab | 当前状态 | +| --- | --- | --- | --- | +| 工作台 | 运行概览 | 服务状态、待处理事项、最近活动 | 部分实现 | +| GoodBuddy 服务 | 模型网关 | 概览、上游服务、模型目录、访问密钥、请求测试、调用审计、客户端接入 | 部分实现 | +| GoodBuddy 服务 | 文档与协作 | 文档库、编辑会话、Office 服务、保存记录 | 待接入 | +| GoodBuddy 服务 | 配置下发 | 配置草稿、设备生效、发布记录、回滚记录 | 待接入 | +| 共享资源 | GoodBuddy 接入 | 设备列表、设备详情、连接记录、撤销记录 | 部分实现 | +| 共享资源 | 共享中心 | 能力目录、共享知识库、按成员查看、按设备查看 | 部分实现 | +| 共享资源 | 数字人 | 数字人目录、我的数字人、调用权限、发布记录 | 设计中 | +| 组织管理 | 组织与成员 | 成员、用户组、角色、服务账号 | 设计中 | +| 组织管理 | 权限策略 | 强制策略、Grant、策略模拟、变更记录 | 设计中 | +| 组织管理 | 审批 | 待处理、已处理、我的申请、审批规则 | 设计中 | +| 组织管理 | 发布与更新 | 能力包、发布通道、更新策略、撤销记录 | 设计中 | +| 组织管理 | 网关联邦 | 合作组织、待确认、开放范围、事件记录 | 设计中 | +| 运营与审计 | 任务与中继 | 任务列表、活动任务、中继连接、运行统计 | 设计中 | +| 运营与审计 | 审计 | 事件查询、调用链、导出任务、保留策略 | 部分实现 | +| 系统 | 系统设置 | 实例信息、身份与登录、基础设施、中继与联邦、通知、保留与审计 | 设计中 | +| 成员自助 | 我的设备与授权 | 我的设备、我的授权、我的申请、我的数字人 | 设计中 | + +## 1. 工作台 + +### 1.1 运行概览 + +| Tab | 内容 | 状态 | +| --- | --- | --- | +| 服务状态 | ShareServer、GoodBuddy 设备、模型上游和共享目录的关系;设备登记数、启用上游数和启用模型数 | 部分实现 | +| 待处理事项 | 待审批、失败任务、保存失败、连接异常和紧急撤销 | 设计中 | +| 最近活动 | 最近的注册、发布、网关调用、授权和任务事件 | 设计中 | + +当前页面只展示真实 API 返回的服务拓扑和入口,不生成预设指标或活动记录。 + +## 2. GoodBuddy 服务 + +### 2.1 模型网关 + +模型网关为 GoodBuddy 和兼容客户端提供统一的模型请求入口。网关保存上游配置和模型映射,转发请求并记录有界调用元数据,不托管模型,也不在网关执行模型返回的工具调用。 + +| Tab | 主要功能 | 当前状态 | +| --- | --- | --- | +| 概览 | 网关地址、启用上游数、启用模型数、访问密钥数、最近调用状态和管理边界 | 部分实现 | +| 上游服务 | 添加、编辑、启停上游;保存 Base URL、协议和认证方式;凭据留空则保留,填写则覆盖;发现远端模型 | 部分实现 | +| 模型目录 | 查看模型别名、远端模型名、所属上游和启用状态;手工添加或编辑映射;启停映射 | 部分实现 | +| 访问密钥 | 创建客户端 Bearer Key;按模型绑定授权范围;查看名称和状态;撤销密钥 | 部分实现 | +| 请求测试 | 选择可用模型发送真实非流式请求;显示结果;取消请求;提示数据去向和费用风险 | 部分实现 | +| 调用审计 | 查看时间、请求编号、模型、上游、访问密钥、流式方式、结果、HTTP 状态、耗时、Token 用量和错误码 | 部分实现 | +| 客户端接入 | 查看客户端 Base URL、协议、模型 ID 和配置步骤;说明客户端只使用网关访问 Key | 部分实现 | + +模型网关的页面关系如下: + +```text +概览 + ├─ 上游服务 管理外部模型服务连接 + ├─ 模型目录 管理客户端可见的模型映射 + ├─ 访问密钥 管理客户端调用凭据和模型授权 + ├─ 请求测试 验证模型请求转发 + ├─ 调用审计 查看调用结果和运行元数据 + └─ 客户端接入 将已配置模型交给 GoodBuddy 或兼容客户端使用 +``` + +网关后续扩展仍放在对应 Tab: + +- 上游服务增加健康状态、延迟和错误率。 +- 模型目录增加能力标签、路由规则和备用上游。 +- 访问密钥增加主体、组织、有效期、配额和撤销原因。 +- 请求测试增加工具调用和流式场景的受控验证。 +- 调用审计增加 `traceId`、父子请求、决策、失败阶段和链路详情。 +- 客户端接入增加按组织或用户组分发配置的能力。 + +组织身份、工具级权限、三态策略、供应商切换、配额、成本统计和工具执行属于后续网关能力,不能写成当前 Tab 已提供的操作。 + +### 2.2 文档与协作 + +| Tab | 主要功能 | 当前状态 | +| --- | --- | --- | +| 文档库 | 主存文档、归属、版本、保留规则和检索 | 待接入 | +| 编辑会话 | 活动会话、访问模式、checkpoint、保存状态和过期处理 | 待接入 | +| Office 服务 | Document Server、回调、存储和健康检查 | 待接入 | +| 保存记录 | 服务端保存、结果未知、失败重试和本地源文件回写状态 | 设计中 | + +首期 Office 范围为浏览器上传 DOCX、编辑、保存和重新打开。XLSX、PPTX、Desktop 文档 Tab 和本地回写属于后续范围。 + +### 2.3 配置下发 + +| Tab | 主要功能 | 当前状态 | +| --- | --- | --- | +| 配置草稿 | 按客户端、设备或成员组编排模型和服务配置 | 待接入 | +| 设备生效 | 查看目标设备、应用版本、生效状态和失败原因 | 待接入 | +| 发布记录 | 查看配置版本、发布时间、发布者和影响范围 | 待接入 | +| 回滚记录 | 记录回滚请求、目标版本和设备确认结果 | 待接入 | + +保存模型网关配置不会自动创建配置下发记录。 + +## 3. 共享资源 + +### 3.1 GoodBuddy 接入 + +| Tab | 主要功能 | 当前状态 | +| --- | --- | --- | +| 设备列表 | 查询设备名称、平台、应用版本、登记时间和发布数量 | 部分实现 | +| 设备详情 | 查看登记信息、发布记录和来源声明 | 部分实现 | +| 连接记录 | 查看在线状态、出站控制连接和最近心跳 | 设计中 | +| 撤销记录 | 撤销设备、停止新任务和查看历史撤销原因 | 设计中 | + +当前实现支持本地设备登记元数据和真实发布目录查询;生产设备认证、在线状态、出站 WebSocket 和任务控制待实现。 + +### 3.2 共享中心 + +| Tab | 主要功能 | 当前状态 | +| --- | --- | --- | +| 能力目录 | 能力名称、版本、来源设备、发布状态和可见范围 | 部分实现 | +| 共享知识库 | 知识来源模式,以及搜索、读取原文、下载三项权限声明 | 部分实现 | +| 按成员查看 | 按成员查看可发现、可检查和可调用的资源 | 设计中 | +| 按设备查看 | 按提供设备查看已发布能力、知识和撤销记录 | 部分实现 | + +当前共享中心只处理发布元数据,不提供知识正文、能力执行或真实授权。 + +### 3.3 数字人 + +| Tab | 主要功能 | 当前状态 | +| --- | --- | --- | +| 数字人目录 | 按 `@handle`、所有者、Runtime、版本和可用范围查询 | 设计中 | +| 我的数字人 | 创建发布草稿、选择 Agent、设置开放范围、提交审批和撤销 | 设计中 | +| 调用权限 | 查看当前成员的发现、检查、调用权限和审批要求 | 设计中 | +| 发布记录 | 查看版本、状态、权限变化和撤销原因 | 设计中 | + +## 4. 组织管理 + +### 4.1 组织与成员 + +| Tab | 主要功能 | 当前状态 | +| --- | --- | --- | +| 成员 | 邀请、停用、移除和查看成员设备、授权与申请 | 设计中 | +| 用户组 | 管理成员组、设备组和组内授权范围 | 设计中 | +| 角色 | 管理组织管理员、发布者、审批人和审计员 | 设计中 | +| 服务账号 | 管理自动化调用主体、凭据、权限和有效期 | 设计中 | + +### 4.2 权限策略 + +| Tab | 主要功能 | 当前状态 | +| --- | --- | --- | +| 强制策略 | 组织级禁止规则、数据范围、出站地址和风险限制 | 设计中 | +| Grant | 将主体、资源、动作、有效期和预算绑定为授权 | 设计中 | +| 策略模拟 | 输入主体、资源和动作,查看命中规则与最终决策 | 设计中 | +| 变更记录 | 查看策略版本、变更者、影响范围和撤销结果 | 设计中 | + +策略决策使用三种结果:自动允许、人工确认、直接拒绝。工具权限应落到具体动作,例如查询允许、修改需确认、删除拒绝。 + +### 4.3 审批 + +| Tab | 主要功能 | 当前状态 | +| --- | --- | --- | +| 待处理 | 按调用、发布、更新和联邦申请筛选待审批项 | 设计中 | +| 已处理 | 查看批准、拒绝、过期和撤销的申请 | 设计中 | +| 我的申请 | 查看本人提交的申请和当前状态 | 设计中 | +| 审批规则 | 设置审批人、风险条件和批量审批范围 | 设计中 | + +### 4.4 发布与更新 + +| Tab | 主要功能 | 当前状态 | +| --- | --- | --- | +| 能力包 | 上传、验证、查看 manifest、签名、版本和兼容范围 | 设计中 | +| 发布通道 | 按设备组、组织和发布范围安排版本 | 设计中 | +| 更新策略 | 设置审批、自动更新、灰度和失败处理规则 | 设计中 | +| 撤销记录 | 紧急撤销版本、查看影响设备和后续任务处理 | 设计中 | + +### 4.5 网关联邦 + +| Tab | 主要功能 | 当前状态 | +| --- | --- | --- | +| 合作组织 | 查看对端地址、指纹、连接状态和有效期 | 设计中 | +| 待确认 | 创建、接受或拒绝联邦 offer | 设计中 | +| 开放范围 | 分别查看我方开放、对方开放和最终可用的资源集合 | 设计中 | +| 事件记录 | 查看建立、暂停、恢复、撤销和调用事件 | 设计中 | + +## 5. 运营与审计 + +### 5.1 任务与中继 + +| Tab | 主要功能 | 当前状态 | +| --- | --- | --- | +| 任务列表 | 按调用方、能力、提供者、路由、状态和时间查询任务 | 设计中 | +| 活动任务 | 查看排队、运行、等待确认、取消和结果未知的任务 | 设计中 | +| 中继连接 | 查看活动连接、吞吐、容量和拒绝原因 | 设计中 | +| 运行统计 | 查看延迟、失败率、取消率和提供者状态 | 设计中 | + +### 5.2 审计 + +| Tab | 主要功能 | 当前状态 | +| --- | --- | --- | +| 事件查询 | 按操作者、动作、对象、组织、结果、时间和关联 ID 查询 | 设计中 | +| 调用链 | 查看模型、工具、任务、路由和最终结果的父子事件 | 设计中 | +| 导出任务 | 创建、查看和下载有时效的审计导出 | 设计中 | +| 保留策略 | 设置事件、正文、工具参数和模型输出的保留范围 | 设计中 | + +当前模型网关已经保存有界调用元数据。完整组织审计、调用链、导出和保留策略仍未实现。 + +## 6. 系统 + +### 6.1 系统设置 + +| Tab | 主要功能 | 当前状态 | +| --- | --- | --- | +| 实例信息 | 实例名称、公开地址、协议版本和部署信息 | 设计中 | +| 身份与登录 | 本地账号、OIDC、Web 会话、CSRF 和会话撤销 | 设计中 | +| 基础设施 | 数据库、对象存储、证书、版本和服务状态 | 设计中 | +| 中继与联邦 | 中继开关、容量限制、联邦总开关和证书 | 设计中 | +| 通知 | 邮件、审批提醒、失败告警和通知渠道 | 设计中 | +| 保留与审计 | 全局保留规则、归档和审计导出限制 | 设计中 | + +运行时秘密、数据库连接串、主密钥和容器挂载路径由部署环境提供,系统设置只显示状态或执行替换与测试。 + +## 7. 成员自助 + +### 7.1 我的设备与授权 + +| Tab | 主要功能 | 当前状态 | +| --- | --- | --- | +| 我的设备 | 管理本人设备、连接状态和设备撤销 | 设计中 | +| 我的授权 | 查看本人获得的能力、模型、工具、有效期和限制 | 设计中 | +| 我的申请 | 创建调用、发布和权限申请,查看处理结果 | 设计中 | +| 我的数字人 | 管理本人创建的数字人及其发布范围 | 设计中 | + +成员的日常协作、消息、资料同步和能力调用主要在 GoodBuddy 中完成,Web 端提供授权、申请和发布管理入口。 + +## 当前交付边界 + +当前仓库可以运行的功能集中在两个页面域: + +1. GoodBuddy 接入和共享中心:保存并读取本地设备登记、能力发布和知识发布元数据。 +2. 模型网关:保存上游、加密凭据、模型映射和访问 Key,支持真实模型发现、请求转发、请求测试和有界调用审计。 + +组织认证、组织隔离、生产设备认证、设备控制连接、工具执行、任务路由、文档保存、Office 编辑、实时协作、成本控制和联邦仍属于设计或后续实现范围。接口、状态规则和验证证据分别由 [PRD](./prd.md)、[功能逻辑](./logic-design.md)、[技术设计](./technical-design.md) 和 [实施进度](./progress.md) 维护。 diff --git a/docs/features/share-server/logic-design.md b/docs/features/share-server/logic-design.md index 828f951..7d327d1 100644 --- a/docs/features/share-server/logic-design.md +++ b/docs/features/share-server/logic-design.md @@ -12,8 +12,8 @@ ## 本地目录首期状态规则 -当前本地联调仅实现设备与发布元数据,使用[技术设计第 4.1 节](./technical-design.md#41-本地共享目录契约已实现) -的独立接口。下文组织身份、Grant、任务和文档归属规则属于目标设计。 +本地目录实现设备与发布元数据,使用[技术设计第 4.1 节](./technical-design.md#41-本地共享目录契约已实现) +的独立接口;本地模型网关规则见第 6.1 节。下文组织身份、Grant、任务和文档归属规则属于目标设计。 Office 私有部署第一阶段的限定状态规则见第 8.2 节,不声称具备 INV-1 的组织隔离或 INV-12 的多类凭据体系。 - 注册相同设备 ID 更新名称、平台及版本,保留首次注册时间;相同内容重试不改变时间。 @@ -23,7 +23,7 @@ Office 私有部署第一阶段的限定状态规则见第 8.2 节,不声称 - 撤销将状态变为 `revoked` 并保留目录记录。重复撤销不改变结果;重试旧发布请求不恢复已撤销记录。 - 能力与知识按 `kind` 区分。知识的搜索、原文、下载标志分别存储,不相互推导; `server` 来源也只是发布者声明,不表示文件已经上传或同步。 -- 当前没有用户主体或组织所有权,因此真实目录按设备查看。按用户查看仍使用组织示例, +- 当前没有用户主体或组织所有权,因此真实目录按设备查看。组织示例已移除, 不把设备名称推定为用户身份。 ## 1. 核心不变量 @@ -314,7 +314,7 @@ Publication/Grant,本人草稿确认也不能绕过当前访问权限。 共享知识区分服务器同步副本和远程设备取用。已就绪的服务器副本不因来源设备离线而失效, 但更新需等待设备连接;远程取用在设备离线时不可用。发布暂停或设备撤销仍阻止使用,不能 以已有副本绕过撤销。搜索、读取原文、下载分别判断权限;可用状态、共享对象和管理员元数据 -可见性均不替代动作授权。当前共享中心只演示这些状态,无真实内容请求。 +可见性均不替代动作授权。当前共享中心仅展示真实目录声明,无真实内容请求。 可用内容为来源授权、当前入口受众、动作、空间权限、组织策略及 Provider 本地权限的交集。 个人外部入口与组织空间独立授权;同一分身身份复用不复制其所有来源权限。 @@ -342,19 +342,37 @@ Publication/Grant,本人草稿确认也不能绕过当前访问权限。 任何权限扩大都从自动路径转为 `require_approval`。降级只有在目标版本仍被签名目录允许且 策略明确指定时才提供,不自动视为故障回滚。 -### 6.1 网关配置演示 +### 6.1 本地模型网关 -模型网关负责向其他服务转发,不托管模型。原型仅在上游与映射均启用、目标组被允许时判定 -模型可用;模拟调用记录允许/拒绝及路由元数据,不发送请求或记录正文。 +本节描述已实现的 [B1 网关](./backend-foundation-design.md#4-b1正常模型网关接入);完整验收尚未完成。 -配置演示与上述签名能力包更新分开。草稿选择目标组可用模型,发布时重新检查策略;每组 -独立记录发布历史。回退仅选择该组上一条记录,并重新检查其模型可用性,恢复版本及默认 -模型后移除当前历史末项。发布和回退仅更新目标组在线示例设备;离线设备保留已应用版本, -目标版本随发布变化并显示待下发,未模拟重连交付。 +模型网关向其他服务真实转发,不托管模型。新存储无上游、映射和审计。别名全局唯一并绑定 +一个上游与远端模型。客户端必须提供有效 Bearer 访问 Key;列表只返回 Key 授权、上游及映射 +均启用、凭据可用的模型,无需认证的上游不要求凭据。“凭据已配置”仅表示保存了密文或有效 +环境引用,不表示解密或连接已验证。 -这些状态仅保存在三页共享的会话内存中,刷新重置;角色开关不构成实际授权,发布状态不构成 -设备确认。文档演示中仅本人可删除/转移个人文档,组织删除/恢复要求显式模拟组织文档管理员 -角色,其他成员个人文档仅显示元数据,回收站不提供永久删除。 +上游凭据按 `keep|replace|clear` 显式保留、替换或清除;空值不代表清除,无需认证不会自动删除 +已有凭据。新秘密加密保存;主密钥缺失或错误时报告不可用,不覆盖旧密文。旧环境变量引用可 +继续调用,显式替换后改为托管凭据;前缀导入界面及接口已移除。 + +Key 仅创建时返回原文,存储摘要;授权绑定模型记录 UUID,修改别名不改变范围。未知或撤销 +Key 返回 401,未授权模型返回 403。重复撤销保持原撤销时间,撤销阻止新请求,不主动取消已开始 +的流。管理与旧目录接口限制实际 socket 为直连回环,不能靠 Origin 或转发头建立管理身份。 + +发现模型真实访问上游列表,但不自动保存发现结果;失败仍可手工映射。用户选择或手动填写映射, +再通过本机管理测试入口显式测试。调用按配置将别名 +换为远端模型,响应恢复别名,不允许请求覆盖上游地址或凭据,也不自动重试或切换上游。 + +调用终态为成功、失败或取消。JSON 响应通过格式检查后记录成功;SSE 必须收到 `[DONE]`,提前 +结束记为失败。取消或超时中止上游请求,流已开始时的 HTTP 200 不等于最终成功。审计只记录 +调用元数据,缺失用量保留未知;存储保留最新 200 条,不保存提示词、工具参数或响应正文。 +配置和审计写盘成功后才更新内存,坏文件或写入失败报错,不清空已有记录。 + +本地预览已有 Key 级模型授权,尚无组织认证、组织隔离、用户组策略或配额。配置下发为占位, +没有发布、回退或设备确认;保存网关模型不会自动下发。接口和资源限制见技术设计第 4.2 节。 +历史网关/配置演示记录保持原有证据范围,不能据此认定真实转发或交付已验证。 + +文档页显示待接入说明,已移除会话内存演示及模拟组织角色;文档归属与回收规则仍待服务端实现。 ## 7. 联邦可见性 diff --git a/docs/features/share-server/prd.md b/docs/features/share-server/prd.md index 7e3d43d..12ea55f 100644 --- a/docs/features/share-server/prd.md +++ b/docs/features/share-server/prd.md @@ -38,9 +38,35 @@ 相同记录,服务重启后记录仍保留。此阶段不自动枚举本地能力或上传知识文件。 验收要求:注册不自动发布,撤销后刷新仍为已撤销,网络失败有可重试反馈,知识权限独立保存; -真实目录与组织样例分别展示。API、状态和验证证据分别由技术设计、逻辑设计和实施进度维护。 +界面只展示真实目录,空目录不填充样例。API、状态和验证证据分别由技术设计、逻辑设计和实施进度维护。 身份认证、组织授权、内容同步与远程调用仍按以下完整产品范围继续实现。 +### 本地模型网关技术预览 + +当前实现允许在可信本地环境保存上游、模型别名,发现上游模型并发送真实测试请求。网关向 +其他模型服务转发 OpenAI 兼容 Chat Completions,支持 JSON、SSE、工具调用数据和上游用量, +提供模型列表;不运行模型或工具,不支持 Responses 或 Anthropic API。 + +此阶段已有按模型授权的 Bearer 访问 Key,尚无组织认证、组织隔离、用户组策略或配额控制。 +服务从空配置开始,持久化配置、加密上游凭据、访问 Key 摘要和最近 200 条调用元数据,不保存 +提示词、响应正文或明文秘密。页面接收上游密钥,编辑时留空保留、填写覆盖;旧环境引用保留存储 +兼容,前缀导入界面和接口已移除。管理限直连回环,发现模型和请求测试才访问上游。 +ShareServer 可独立构建和运行,不依赖 GoodBuddy 源码;个人既有配置仅用于明确授权的环境测试。 + +配置下发当前为占位,保存模型不会自动配置 GoodBuddy。生产访问策略和按用户组分配仍为目标 +范围。接口与限制由技术设计第 4.2 节维护,实际测试结果由实施进度维护;本节不改写历史验收证据。 + +### 正常模型网关接入(B1 已实现,验收部分完成) + +管理员填写上游名称、协议、API 地址和 API Key,保存后发现或手动添加模型,选择对 GoodBuddy +开放的模型 ID。界面不要求用户理解环境变量前缀。ShareServer 保管上游凭据,另外签发可撤销的 +网关访问 Key;GoodBuddy 使用网关地址和该 Key,通过现有模型连接入口发起实际对话。 + +首期完成本地网关管理、真实转发与调用记录;不依赖组织数据库或自动配置下发。只将实际支持的 +Chat Completions 协议提供给客户端。当前已完成部分真实 API 验证,GoodBuddy Desktop 连接测试、 +对话、完整工具往返、取消及浏览器界面仍需验收,不能用自动化或脚本调用代替。 +接口与凭据设计、后续组织及知识服务顺序见[后端基础设计](./backend-foundation-design.md)。 + ### Office 私有部署第一阶段(待实现) 在可信私有网络内,先验证浏览器上传 DOCX、打开、编辑、保存到 ShareServer、关闭后重新打开, @@ -77,7 +103,7 @@ JWT 用于编辑器配置和服务回调校验,不代表浏览器用户已认 - 远程管理设备桌面、任意 Shell 或任意文件系统。 - 发现未注册的局域网设备。 -- 收集或分发用户个人 API Key、Cookie 和桌面登录状态。模型网关所需的组织上游凭据由管理员单独配置、服务端保管,不下发给客户端;当前原型不接收凭据。 +- 自动收集或分发用户个人 API Key、Cookie 和桌面登录状态。管理员显式提供的网关上游凭据由服务端加密保管,不分发给客户端;客户端使用单独签发的访问 Key,见后端基础设计。 - 默认查看任务正文、附件和模型输出。 - 自动上传、同步或浏览数字人所有者的本地个人、项目、会话和原始 Agent 记忆;用户明确发布的知识与选定回顾按 FR-18 单独管理。 - 因为成员加入组织而自动开放其数字人、记忆、Runtime、工作区或工具。 @@ -175,11 +201,9 @@ JWT 用于编辑器配置和服务回调校验,不代表浏览器用户已认 按设备查看四个页签。内容展示发布者、提供设备、版本、在线与发布状态、共享对象;用户和 设备视图列出关联内容并可进入详情,既有设备详情提供对应共享视图入口。 -当前“GoodBuddy 服务”原型提供 `/model-gateway`、`/documents`、`/distribution` 三页,覆盖 -网关策略判断、文档归属与回收站、OnlyOffice 实例检查、配置发布和回退。操作仅修改会话内存, -跨页保留、刷新重置,不发送服务请求或写入本地存储;模拟角色不构成授权,模拟成功不构成 -推理、转发、Office 保存或设备交付的验收。离线设备保持待下发与原已应用版本;发布和回退 -都检查目标组当前可用模型,回退同时恢复版本及默认模型。细节见 UI 设计第 16.1 节。 +当前“GoodBuddy 服务”提供 `/model-gateway` 本地技术预览;`/documents`、`/distribution` +及其他未实现模块显示待接入说明。网关和共享目录使用真实 API,界面已移除模拟角色、文档、 +审批及配置发布操作。上述完整共享中心页签仍为目标范围。细节见 UI 设计第 16.1 节。 ### FR-12 可部署性 diff --git a/docs/features/share-server/progress.md b/docs/features/share-server/progress.md index 5f88f22..3dde427 100644 --- a/docs/features/share-server/progress.md +++ b/docs/features/share-server/progress.md @@ -2,8 +2,102 @@ ## 当前状态 -ShareServer 包含控制台交互原型和可持久化的本地共享目录服务。GoodBuddy 已能注册设备、发布与 -撤销元数据,Web 能查看真实目录;生产身份、组织隔离、设备控制连接、授权及任务路由仍未实现。 +ShareServer 包含 Web 控制台、可持久化的本地共享目录和真实模型网关。GoodBuddy 已能注册 +设备、发布与撤销元数据,Web 能查看真实目录。模型网关支持 OpenAI 兼容上游的发现、映射与 +Chat Completions 转发,以及 B1 加密凭据、访问 Key 和模型授权。B1 代码已实现、验收部分完成; +生产身份、组织隔离、设备控制连接、组织授权及任务路由仍未实现。 + +### 2026-10-07:前端真实目录完善 + +- `GoodBuddy 接入` 已接入本地共享 API,显示真实登记设备、登记时间、版本和关联发布数量;支持搜索、详情和跳转到该设备的共享内容。页面明确登记记录不代表在线、所有权或身份认证。 +- `共享中心` 新增真实发布记录的撤销确认和刷新流程。撤销调用 `POST /api/v1/sharing/publications/:id/revoke`,保留服务端撤销记录,不提供能力执行、知识正文读取或下载。 +- 其他规划页面改为按模块展示专属待接入边界和可用入口,不显示示例数据或不会生效的创建、审批、导出和配置操作。模型网关调用元数据仍从模型网关页面查看。 +- 新增共享 API 请求封装,统一错误信息和请求取消处理。前端自动化测试覆盖设备目录、待接入边界、共享筛选和撤销入口;本轮尚未进行浏览器视觉验收。 +- 顶部移除“技术预览 · 非生产服务”常驻文字;侧栏按业务重排为工作台、GoodBuddy 服务、共享资源、组织管理、运营与审计、系统和成员自助。各页面内部仍保留必要的实现边界说明。 +- 工作台增加服务拓扑,读取真实设备、发布记录和模型网关配置,展示 GoodBuddy 设备、ShareServer 与外部模型上游的关系及数量。空数据仅显示零接入状态,不生成示例节点;当前尚未进行浏览器视觉验收。 + +### 2026-10-05:B1 实现与部分验收 + +- 上游表单接收真实 API Key,支持保留、替换和清除;AES-256-GCM 密文随配置保存,主密钥位于 + 数据目录外并单独备份。旧环境变量引用继续可读、可调用,前缀导入界面与 `/import-env` 已移除。 +- 客户端模型列表和聊天要求 Bearer Key,Key 原文仅创建时返回,持久化 SHA-256 摘要。 + 授权绑定模型记录 UUID,修改别名不改变范围;审计关联 Key ID,不将其名称当作用户身份。 + 管理与旧目录 API 校验实际回环 socket,并拒绝转发头;Vite `/api` 也只允许回环来源。 + 容器/反向代理管理尚不支持,等待 B2 登录。 +- 实现轮验证记录:103 项测试通过,`npm run typecheck`、`npm run lint`、前后端构建通过。 + 自动化覆盖不等于 GoodBuddy Desktop 或浏览器端到端验收。 +- 实现轮真实供应商验证共 5 次上游请求:1 次模型发现;1 次非流式请求使用 16 Token 上限, + 输出过短,未形成有效结论;1 次 SSE 请求成功,包含 reasoning 内容和 Token 用量;1 次非流式 + 请求使用 1024 Token 上限并成功;1 次工具选择返回有效调用参数并在本地执行。工具结果未再次 + 发送给上游,因此未完成完整工具往返。 +- 实际 API 验证确认重启后配置与访问 Key 保留,撤销后拒绝调用;存储含加密凭据而无明文秘密。 + 上述真实验证由实现轮提供,本次文档同步没有新增供应商请求或读取环境秘密。 +- 浏览器已读取独立服务的网关页快照,确认空目录、访问密钥和客户端设置区域;点击被工具判定为超出视口,截图超时,未完成交互与视觉验收。独立服务已停止,默认数据未改动。 +- 本轮没有验证 GoodBuddy Desktop。B1 剩余验收包括从空存储经浏览器配置、刷新 + 与重启,Desktop 连接测试和真实 SSE 会话,完整工具结果回传、用户停止时上游取消,以及真实 + 环境下超时、流中断、错误主密钥、内网 HTTP 和部署来源限制。后端基础设计第 4.5 节继续保留完整 + 清单;历史浏览器和旧网关结果不追溯成为 B1 验收。 +- 文档同步轮重新运行 `npm test`,9 个文件、103 项测试通过;lint、含类型检查的前后端构建 + 通过。已阅读 deai-writing 并扫描相关文档,阻断项为 0;本次修改段落的字段枚举和验收限制 + 按技术含义保留,未借审校改写历史记录。`git diff --check` 通过,仅编辑 README 与文档。 + +以下按原日期保留历史记录,其中“当前”“本轮”均指对应日期的实现,不描述 2026-10-05 的状态。 + +### 2026-10-04:基于 GoodBuddy 源码的后端基础设计 + +- 新增[后端基础设计](./backend-foundation-design.md),核对模型 profile、协议 URL、Main 凭据保存、 + 设备共享 HTTP、资源发布字段、统一知识检索入口和 Office 设计状态。按实际客户端契约安排 B1 至 B6。 +- 首个实施单元为正常模型网关接入:密钥录入与服务端保存、开放模型、独立访问 Key、手工配置 + GoodBuddy 和真实对话验证。自动组织模型导入、身份权限、资源绑定、知识检索及文档归属另列依赖。 +- 修正功能入口、PRD、逻辑和技术文档中仍将模拟页面描述为当前实现的段落;保留历史验证记录。 + 新接口与凭据方案标记待实现,未把当前无认证网关描述成已有访问 Key。 +- 本轮仅改 ShareServer 文档,未修改 GoodBuddy 或服务代码,未读取环境秘密、启动服务或调用模型。 + 相邻仓库链接目标及新增章节锚点已核对,`git diff --check` 通过。新增设计的 deai-writing 扫描 + 为 0 个阻断项;复核项涉及字段枚举和实施边界,按技术含义保留。未运行业务测试或进行运行时验收。 + +### 2026-10-02:移除界面模拟数据 + +- 控制台不再加载原型快照,移除虚构身份、组织、统计数量、活动以及模拟操作。 + 概览改为服务入口,共享中心直接读取本地目录;未接入模块保留菜单并显示待实现说明。 +- 模型网关及共享目录继续使用真实 API,保留加载、空状态、失败重试和筛选。 + 未删除本地持久化数据,未改动 GoodBuddy 仓库。旧原型源码及接口未在本轮删除,已退出控制台路由。 +- `npm test`:7 个文件、87 项测试通过;类型检查、lint 和前后端构建通过。 + 浏览器访问本地开发地址返回 `Request blocked`,本轮未完成视觉复查。 + +### 2026-10-01:本地模型网关与真实上游联调 + +- `/model-gateway` 改为真实 API 页面,移除网关样例上游、模型和模拟调用。新增配置持久化、 + 环境引用导入、模型发现、映射编辑与启停、网页请求测试及调用元数据。配置下发移除虚构模型、 + 用户组与设备回执,改为待接入说明;文档及其他示例模块仍保留明确的演示边界。 +- API 支持模型列表、非流式 JSON 和流式 SSE 转发,透传工具调用数据和上游用量,不执行工具。 + 具体路径、资源限制和存储契约见[技术设计第 4.2 节](./technical-design.md#42-本地模型网关契约已实现)。 + 凭据只引用服务端环境变量,`scripts/dev.mjs` 支持显式 `--env-file`,加载值仅传给 API 子进程。 +- 测试使用 `../goodbuddy/.env.deepseek.local`,经用户授权加载;未修改 GoodBuddy 仓库。 + 在独立端口 8792 和临时数据目录完成联调,保留原有 8787/5174 开发服务及项目数据。 + 浏览器从空目录导入配置,发现接口返回 2 个模型,包含已配置模型 `deepseek-flash`。 +- 网页非流式请求返回预期内容,上游报告输入 41、输出 26 Token;另一条 SSE 请求收到 `[DONE]` + 和预期内容,用量为输入 37、输出 20 Token。停用映射后请求返回 403,错误请求 ID 与审计一致, + 被拒请求的未知用量未计为零。真实工具调用与 GoodBuddy 桌面端模型调用未在本轮验收。 +- Playwright/Edge 验证模型发现、网页请求、停用与启用、刷新后保留配置并清除测试正文; + 1440px 和 390px 的深浅主题未见横向溢出或页面脚本错误,并复核截图。重启独立 API 后, + 1 个上游、1 个模型和 3 条审计与重启前一致;持久化文件不含测试凭据、提示词或响应正文。 + 独立测试服务已停止,测试配置未导入项目默认数据目录。 +- `npm test`:7 个文件、77 项测试通过;`npm run lint`、`npm run build`(含类型检查)通过。 + 测试覆盖坏存储、上游错误、超限、流中断、超时、取消、审计写入失败和前端失败重试。 + 设计文档已按真实接口更新并使用 deai-writing 复核;历史演示验证记录保留原有时间与范围。 + +### 2026-10-01:本地仓库同步与开发入口 + +- 已逐项核对本地未提交代码和未跟踪源码:另一台机器提交的 `c9d5e63` 已包含这些功能与测试, + 代码差异仅为目录迁移后的引用和构建输出路径调整;`9fa8f3e` 进一步补充 Office 私有部署设计。 +- 按用户要求将本地 `main` 对齐 `origin/main` 的 `9fa8f3e`,清理旧路径重复源码,未产生合并提交。 + 同步后工作区干净;本地数据、依赖和忽略文件保留,GoodBuddy 仓库单独处理,本轮未操作。 +- 本地开发在仓库根目录运行 `npm run dev`,入口为 `scripts/dev.mjs`,同时启动 `dev:api` 与 + `dev:web`。API 使用 Node watch,前端使用 Vite 热更新;默认 Web 地址为 `http://127.0.0.1:5174`, + GoodBuddy 连接 API 地址 `http://127.0.0.1:8787`,Web 的 `/api` 请求代理到该端口。 +- 首次安装依赖用 `npm ci`,终端按 `Ctrl+C` 停止开发服务。开发时先停止占用同一 API 端口或 + 数据目录的旧预览/容器实例;共享记录默认保存在 `data/sharing.json`。 +- 本轮核对 Git 差异与启动配置,未重新启动服务或运行测试、构建。 ### 2026-10-01:Office 私有部署第一阶段设计 diff --git a/docs/features/share-server/technical-design.md b/docs/features/share-server/technical-design.md index 3bdb199..642f841 100644 --- a/docs/features/share-server/technical-design.md +++ b/docs/features/share-server/technical-design.md @@ -11,6 +11,10 @@ | 功能逻辑 | [功能逻辑设计](./logic-design.md) | | 共享协议 | [共享网络总体设计](../../../../goodbuddy/docs/architecture/share-network-architecture.md) | +基于 GoodBuddy 当前源码的适配依据、正常网关接入和基础服务实施顺序见 +[后端基础设计](./backend-foundation-design.md)。B1 已实现、验收部分完成,B2 至 B6 待实现;第 4.1、4.2 节记录 +当前已实现 API,组织与 Office 章节仍是目标设计,不代表现有菜单已有对应服务。 + ## 1. 仓库与运行边界 ShareServer 在独立的 `goodbuddy-shareserver` 仓库根目录维护。当前可运行结构为: @@ -22,7 +26,7 @@ goodbuddy-shareserver/ server/ src/ # Hono API、测试与同源静态资源服务 tsconfig.json - shared/ # 原型 fixture 与类型 + shared/ # 共享目录、网关 DTO 与保留的旧原型类型 web/ src/ # React Web 控制台与测试 index.html @@ -69,9 +73,9 @@ goodbuddy-shareserver/ `npm start` 或容器启动后,Hono 同源托管 `dist/web/`。Docker 配置仍在 `docker/`, Compose 的 `../data` 仍映射仓库根 `data/` 到容器 `/app/data`。 -仓库根目录包含全控制台交互原型和本地共享目录服务。Hono 提供健康、就绪、 -界面快照、原型操作确认与真实元数据 API,并同源托管 React/Vite Web 控制台;它不依赖 Electron 的 -`src/shared`。本地目录使用 JSON 文件持久化,契约见第 4.1 节。当前尚未建立生产数据库、认证、设备控制连接,因此也不提前创建空的 +仓库根目录包含控制台交互原型、本地共享目录和模型网关。Hono 提供健康、就绪、 +界面快照、原型操作确认、真实元数据 API 和网关转发,并同源托管 React/Vite Web 控制台;它不依赖 Electron 的 +`src/shared`。本地目录与网关使用独立 JSON 文件持久化,契约见第 4.1、4.2 节。当前尚未建立生产数据库、组织认证、设备控制连接,因此也不提前创建空的 `packages/share-protocol`。进入真实 Desktop 协议实现时,服务端和 Desktop 共同依赖 独立发布的 `share-protocol` 协议包,具体归属在真实协议实现时确定; ShareServer 仍不得导入 Electron 的 `src/shared`,也不得依赖相邻桌面仓库构建。 @@ -79,9 +83,9 @@ ShareServer 仍不得导入 Electron 的 `src/shared`,也不得依赖相邻桌 ## 2. 技术选型约束 本节及下文组织身份、数据库事务、Web session 安全与生产部署要求属于完整组织服务目标。 -本地目录按第 4.1 节实现;Office 私有部署第一阶段按第 10.1 节单独推进,不以前述能力为前置条件。 +本地目录和网关按第 4.1、4.2 节实现;Office 私有部署第一阶段按第 10.1 节单独推进,不以前述能力为前置条件。 -- 使用与仓库一致的 TypeScript/Node.js 工具链,具体 HTTP 框架在实现前根据维护性选择。 +- 使用与仓库一致的 TypeScript/Node.js 工具链,沿用当前 Hono 框架。 - PostgreSQL 是组织、策略、任务元数据和审计的权威数据库;工作空间消息、处理状态及可靠投递记录 也以数据库为准,具体业务表与受控正文存储在协作模块实现时增加。 - 完整组织服务计划使用 S3 兼容对象存储保存 Package、可选中继暂存和 Office 工作副本。 @@ -135,7 +139,7 @@ audience,并绑定单个 `DocumentEditSession`。 ### 4.1 本地共享目录契约(已实现) 此接口供同机 GoodBuddy 与 Web Demo 联调,默认监听 `127.0.0.1:8787`。没有登录、组织隔离 -或设备认证;可访问接口的客户端均可修改目录。设备 ID 仅标识记录,客户端只显示本机撤销按钮 +或设备认证;B1 起只允许直连回环管理请求,符合此限制的客户端均可修改目录。设备 ID 仅标识记录,客户端只显示本机撤销按钮 不构成服务端所有权校验。`productionReady` 保持 `false`。上文 audience、组织授权及 WebSocket 规则是后续组织服务目标,不由这些本地接口实现。 @@ -171,9 +175,110 @@ GoodBuddy 通过 `页面 → preload → 可信 IPC → Main HTTP 服务` 调用 服务地址与设备名称,并设置请求超时。Web 同源读取目录,不保存桌面设备凭据。 两仓独立构建;桌面实现与验证见[设备共享文档](../../../../goodbuddy/docs/features/device-sharing/README.md)。 +### 4.2 本地模型网关契约(已实现) + +实现位于 `server/src/gateway-routes.ts`、`gateway-store.ts` 和 `shared/gateway.ts`,挂载到 +`/api/v1/gateway`。B1 已实现本机管理、托管凭据和按模型授权的 Bearer 访问 Key,没有组织隔离、 +用户组策略、配额或速率/并发控制。客户端 Key 不转发给上游。就绪接口报告网关边界,保持 +`productionReady=false`、`storageChecked=false`。网关独立构建运行,不导入 GoodBuddy 源码。 + +下表路径均相对于 `/api/v1/gateway`,成功返回 200: + +| 方法与路径 | 输入与行为 | 响应 | +| --- | --- | --- | +| `GET /config` | 读取配置、调用元数据和部署指定地址 | `{upstreams,models,audits,gatewayBaseUrl}` | +| `POST /upstreams`、`PUT /upstreams/:id` | `name,baseUrl,protocol,authentication,enabled,credentialAction`;仅 `replace` 带 `apiKey` | 上游公开字段及 `credentialConfigured`,无秘密或密文 | +| `POST /models`、`PUT /models/:id` | `alias,upstreamId,remoteModel,enabled`,可选 `displayName` | 模型映射 | +| `GET /access-keys` | 列出 Key 元数据 | `{accessKeys}`,不含摘要或原文 | +| `POST /access-keys` | `{name,modelIds}`,至少一个不重复的模型记录 ID | `{accessKey,key}`,完整 Key 仅此次返回 | +| `POST /access-keys/:id/revoke` | 撤销 Key | Key 元数据,重复撤销保留原 `revokedAt` | +| `POST /upstreams/:id/discover` | 无请求体;向已启用上游的 `/models` 发 GET | 去重模型 ID 列表 `{models: string[]}`,不自动保存映射 | +| `POST /test` | 本机管理测试,使用 Chat 字段,仅非流式 | 真实 JSON 响应,复用转发和审计,`accessKeyId=null` | +| `GET /v1/models` | Bearer Key;仅列授权、启用且上游启用、凭据可用的模型 | OpenAI 模型列表,模型 ID 为别名 | +| `POST /v1/chat/completions` | Bearer Key 与模型授权;按别名转发 | JSON 或 SSE,响应 `model` 改回别名 | + +除两个 `/v1` 客户端端点外,网关管理和旧共享目录 API 均通过 `admin-boundary.ts` 校验实际 +`incoming.socket.remoteAddress`,只接受 `::1`、`127.*` IPv4 及其 IPv4-mapped 地址;没有 socket +信息也拒绝。含 `Forwarded`、`X-Forwarded-*` 或 `X-Real-IP` 的管理请求返回 403,不采信其来源声明。 +Origin 允许无值、同源、`http://127.0.0.1:5174` 和 `http://localhost:5174`;来源不符同样拒绝。 +Vite 在代理前对所有 `/api` 请求检查原始 socket,仅限回环。容器和任意反向代理管理不受支持, +须等待 B2 登录;无转发头的本机代理不能被当作管理员身份。健康与就绪 GET 不受管理限制。 + +Key 使用 `ss_` 加 32 个随机字节的 base64url 编码,存储 SHA-256 摘要。`modelIds` 绑定映射 +记录的 UUID,修改别名不改变授权。Key 名称不代表用户身份;缺失、格式错误、未知或已撤销的 Key +返回 401,未授权或不存在的请求模型返回 403。撤销阻止后续请求,不主动终止已经开始的流。 + +客户端 Base URL 为 `http://127.0.0.1:8787/api/v1/gateway/v1`。仅支持上述模型列表与 Chat +Completions,不实现 Responses、Anthropic 或其他推理端点,不执行工具。Chat 顶层字段采用 +`CHAT_FIELDS` 白名单,包含消息、流式选项、采样参数、`tools/tool_choice/parallel_tool_calls`、 +兼容的 `functions/function_call`、`response_format`、`reasoning_effort` 等;未知字段及地址/凭据 +覆盖报错。消息角色允许 `system/developer/user/assistant/tool/function`;具体参数语义由上游处理, +不保证每个上游支持全部字段。工具调用数据和 usage 随响应透传。 + +配置输入严格匹配字段。名称、显示名和远端模型名最多 200 字符,新建或改名的别名最多 128 字符; +已有最长 200 字符的别名可继续使用和原名编辑,接入 GoodBuddy 前需显式改为其支持的长度。 +别名以 ASCII 字母或数字开头,其余可含字母、数字、点、下划线、冒号、斜杠和连字符。 +上游、模型记录和 Key ID 为服务端生成的 UUID v4。Base URL 最多 2048 字符,允许 HTTP/HTTPS +及合法路径,去掉尾斜杠,禁止用户信息、查询和片段;请求不跟随重定向。支持可信内网 HTTP +上游,但 HTTP 不加密传输凭据,对外部署使用 HTTPS;该校验不构成生产出站访问策略。 + +`protocol` 仅支持 `openai-chat-completions`,`authentication=api-key|none`。 +`credentialAction=keep|replace|clear` 显式保留、替换或清除凭据;空输入不代表清除,选择 `none` +不自动删除已有凭据。替换时 `apiKey` 最多 16384 字符,使用 AES-256-GCM、随机 nonce、认证标签, +并以稳定上游 ID 作为 AAD;密文随上游一起原子保存。`credentialConfigured` 只反映密文或有效 +环境引用是否存在,不保证解密或连接成功。 + +`SHARESERVER_MASTER_KEY_FILE` 指向数据目录外的秘密文件,也可用 `SHARESERVER_MASTER_KEY` +提供 64 位十六进制主密钥,两者不能同时设置。文件路径及真实路径均须在数据目录外。 +初始化命令为 `npm run gateway:init-key -- <文件路径>`,父目录须已存在,拒绝覆盖已有文件; +Windows 可运行示例见根 README。主密钥与普通数据分开备份;缺失、错误或无法解密时返回 +`CREDENTIAL_UNAVAILABLE`,不重新生成或覆盖。替换任一凭据前先检查既有密文能否解密。 + +旧版本 `apiKeyEnv` 记录仍可读取并在调用时解析 API 环境,显式替换/清除后移除引用; +新表单及 API 不接受变量名,`/import-env` 已移除。环境文件仅供旧引用及授权开发测试加载: + +```text +npm run dev -- --env-file=../goodbuddy/.env.deepseek.local +``` + +`scripts/dev.mjs` 用 `parseEnv` 加载指定文件,仅将文件中的变量传给 API 子进程,Vite 不接收 +这份加载结果,仅接收派生的 `SHARESERVER_API_PORT` 以同步代理目标;已有进程环境优先。 +环境变更需重启 API。该路径仅是可选的授权测试输入, +不构成 GoodBuddy 依赖,不自动读取或迁移个人配置;使用既有个人配置须有明确的环境测试授权。 + +`SHARESERVER_GATEWAY_BASE_URL` 设置完整客户端 Base URL,`/config` 将其返回为 `gatewayBaseUrl`; +未配置时返回 `null`,界面提示配置,不从请求 Host 或 Vite 地址推断。自定义端口或异机访问须 +设置客户端可达的地址并保留 `/api/v1/gateway/v1`。 + +请求体要求 JSON,读取上限为 1 MiB;上游 JSON 响应、发现结果及 SSE 总字节上限为 16 MiB。 +SSE 单事件及未分帧缓冲的字符串长度上限为 1,048,576 个 JavaScript UTF-16 码元,不能视为 +1 MiB 字节上限。默认超时 120 秒,聊天从读取入站请求到完成流共用该期限;取消会中止上游。 +SSE 必须有 `[DONE]`,中途 EOF 记 `INCOMPLETE_STREAM`;聊天 JSON 结果/SSE JSON 数据事件须有 +`choices` 数组且无 `error`。流已发出后,失败通过错误事件或流中断报告,HTTP 200 不代表调用成功。 + +错误包为 `{error:{code,message},requestId}`,不回传上游错误正文。输入无效 400,Key 无效 401,停用、未授权模型或 Origin +拒绝 403,不存在 404,别名冲突 409,请求超限 413,非 JSON 415,上游故障或响应无效 502, +缺凭据/存储故障 503,超时 504。聊天审计的取消状态记 499;这不是承诺客户端能收到的 HTTP 状态。 +管理请求按上述 socket 和 Origin 规则校验;聊天端点也检查 Origin。未识别路径在通过管理边界后返回 JSON 404。 + +存储默认 `data/gateway.json`,跟随 `SHARESERVER_DATA_DIR`。文件缺失时从空上游、映射和审计 +开始,首次写入创建文件,无预置服务或调用;每个数据目录只允许单进程中的一个 GatewayStore。 +写入排队,经临时文件、文件同步和 rename 后更新内存;坏文件返回存储错误,不自动重置。 +配置无删除 API,可编辑或停用;当前不提供配置修订冲突控制或 PostgreSQL 迁移。 + +存储版本 2 包含上游密文、映射、Key 摘要及元数据与最新写入的 200 条聊天终态审计;版本 1 +可读取并在下次写入时保存为版本 2,保留原 ID 和旧环境引用。审计新记录在前,仅含 ID、时间、 +已识别模型别名、上游 ID、访问 Key ID、是否流式、成功/失败/取消、HTTP 状态、耗时、输入/输出 Token 和 +稳定错误码;未识别别名不保存用户原始值。用量只取上游有效非负整数,缺失为 `null`,不估算为零。 +审计不保存提示词、工具参数、响应正文或凭据;配置变更与模型发现不生成聊天审计,不具备完整管理审计。 +写入失败不能据此确认审计已保存,流中已发送的数据也不能撤回。 + +本节按当前源码描述契约。`gateway.test.ts` 中的测试用例不等同于本次运行证据,实测结果与日期 +由 `progress.md` 维护;历史模拟网关的通过记录不追溯作为真实转发验收。 + ## 5. 数据模型 -下列数据库模型属于组织服务目标设计。本地目录首期使用第 4.1 节的文件存储,不将本地设备 ID 当作组织身份。 +下列数据库模型属于组织服务目标设计。本地目录和网关使用第 4.1、4.2 节的文件存储,不将本地设备 ID 当作组织身份。 核心表建议为: @@ -287,7 +392,7 @@ Release catalog 使用独立签名域。服务器不能重打包已签名 payloa ### 10.1 私有部署第一阶段(待实现) 本节是首期容器、配置、接口和持久化的权威设计,范围以 PRD 第 3 节为准。当前 `/documents` -仍为演示,仓库只有 ShareServer 的 `docker/compose.yaml`,没有 Office overlay 或真实编辑链路。 +显示待接入说明,仓库只有 ShareServer 的 `docker/compose.yaml`,没有 Office overlay 或真实编辑链路。 第一阶段采用单个 ShareServer 进程、独立 Document Server 和本地文件系统,不新增身份源、数据库、 S3/MinIO、独立 callback worker 或启动 code/安全 Cookie 交换。仅适用于可信私有网络;当前无认证, 可访问服务者可操作试验文档,随机文档 ID 和 JWT 均不能据此宣称用户授权或组织隔离。 @@ -523,46 +628,25 @@ Office 第一阶段配置与独立往返验收见第 10.1 节;后续组织部 设计完成不代表服务已实现;只有独立部署和真实 Desktop 端到端路径通过后才能更新实施状态。 -## 16. 当前原型实现 +## 16. 当前控制台实现 -2026-09-05 建立的交互原型固定以下技术基线: +Hono 与 `@hono/node-server` 提供 API 和构建后的同源 Web 托管。Web 沿用 React、Vite、 +TypeScript、Tailwind CSS 和 Radix。部署已有非 root 多阶段 Docker 镜像。 -- API:Hono + `@hono/node-server`,保留 `/api/v1/public/*` 与 - `/api/v1/web/*` audience 路径。 -- Web:React、Vite、TypeScript、Tailwind CSS 和 Radix primitives,按 shadcn/ui 组件模式 - 维护本地可修改组件。 -- 路由与范围:管理员和成员自助使用不同可见导航;当前组织持续显示。 -- 部署:生产构建由同源 Hono 服务托管,并提供非 root 多阶段 Docker 镜像。 +`web/src/App.tsx` 不再请求原型快照,不显示虚构身份或角色切换。概览显示服务入口, +`/capabilities` 通过 `sharing-center.tsx` 与 `local-sharing.tsx` 读取真实设备和发布记录; +来源与知识权限只是声明,不代表正文、检索或调用已可用。 -`/api/v1/public/ready` 返回 `mode=interactive-prototype` 和 -`productionReady=false`。原型 fixture、角色切换和操作确认只用于评审信息架构,不得被 -Desktop 或部署脚本当作真实身份、策略、任务或审计 API。组织生产实施须从 FR-1 -初始化、本地身份、PostgreSQL 组织作用域和事务审计开始;第 10.1 节私有 Office 试验可独立推进, -使用真实文件和独立元数据,不在 fixture 上叠加权威状态。 -当前 `/documents` 已有 OnlyOffice 实例配置与预设检查模拟;Office 会话 API、对象存储、 -ONLYOFFICE 编辑、callback、文档保存和 Desktop 文档 Tab 集成仍未实现。 +`/model-gateway` 的 `gateway-page.tsx` 使用第 4.2 节真实 API 保存配置、发现模型、发起非流式 +测试和读取审计;测试正文仅在组件内存,卸载取消在途请求。`/distribution` 只显示待接入说明 +与网关链接。文档和其他未实现模块显示待接入状态,没有示例列表或模拟操作。 -`web/src/app-config.ts` 将 `/model-gateway`、`/documents`、`/distribution` 放入管理员的 -“GoodBuddy 服务”分组。`web/src/components/services-demo.tsx` 使用 `ServicesDemo` 的 React -Context 与 `useState` 共享演示状态,跨路由导航保留、刷新重置。服务演示操作不使用 Hono -操作确认 API、不发网络请求、不写本地存储,也不保存凭据;页面角色与权限判断均为内存模拟。 -控制台启动仍通过既有快照 API 加载示例数据,此边界仅指三个服务页的模拟操作。 +服务端仍保留旧 `/api/v1/web/prototype/*` 接口及 fixture,但它们已退出控制台调用路径,不能 +作为组织业务 API。`/api/v1/public/ready` 仍报告 `mode=interactive-prototype`、 +`productionReady=false` 和存储未检查。历史模拟测试不作为真实业务验收。 -模型网关只模拟上游/映射启用与用户组策略判断及最近 50 条有界调用审计;产品职责是转发 -其他模型服务,不托管模型或执行推理。文档仅保存示例名称、归属及回收站标记;转移清除个人 -删除权,组织删除/恢复依赖显式模拟角色,恢复保留归属。OnlyOffice 检查使用预设结果。 - -配置演示保存单个草稿和按组发布历史,发布与回退均重新检查模型可用性;回退取上一条记录并 -移除当前末项,恢复版本及默认模型。仅目标组在线示例设备更新 `applied`,离线设备保持原值与 -待下发状态。这里没有设备连接、重连投递或持久化版本记录,不能作为真实配置交付证据。 - -共享中心原型由 `web/src/components/sharing-center.tsx` 和 `web/src/lib/sharing-fixtures.ts` 提供。 -条目使用现有快照中的成员、设备 ID 建立关联,本地 fixture 仅含元数据和显式权限标记。 -既有 `/capabilities` 路由不变;查询参数 `view=catalog|knowledge|users|devices` 选择页签, -`user`/`device` 定位主体,`item` 打开详情,`q`、`status=ready|unavailable`、 -`source=synced|remote`、`audience` 保存筛选。刷新恢复 URL 状态;内容链接保留筛选, -跨主体链接清除旧条件,无效条目链接显示可关闭的错误详情。除启动快照外不新增 API 请求, -不保存正文、不模拟下载;真实授权和同步仍属后续实现。 +B1 正常网关接入代码已实现,真实验收部分完成,范围见实施进度。组织服务需补身份、PostgreSQL 与授权;Office 私有 +试验可按第 10.1 节独立推进。保存网关模型不会自动下发给 GoodBuddy,配置交付仍未实现。 ## 17. 工作空间协作的待实现契约 diff --git a/docs/features/share-server/ui-design.md b/docs/features/share-server/ui-design.md index 76f93e3..511490e 100644 --- a/docs/features/share-server/ui-design.md +++ b/docs/features/share-server/ui-design.md @@ -16,8 +16,14 @@ ShareServer 是独立 Web 产品,不直接复用 Electron 页面壳层实现 ## 1. 使用对象 -当前本地首期在 `/capabilities?view=local` 提供“本地服务目录”页签,读取真实设备和发布数据。 -其余四个共享中心页签保留组织演示。真实页不使用示例组织、用户归属或在线状态解释本地记录。 +当前控制台移除模拟数据与操作。`/capabilities` 直接读取本地服务的设备和发布数据,原 +`/capabilities?view=local` 地址仍可访问。共享中心不再提供组织示例页签,不使用示例组织、 +用户归属或在线状态解释本地记录。 + +概览只提供已接入服务的入口,不显示预设指标和活动。导航保留规划模块;未接入的页面显示 +待实现说明,不提供虚假的保存、审批或健康检查。模型网关、共享目录分别加载自己的 API, +控制台不再依赖原型快照。移除预设身份、组织切换、角色切换、通知数量和未实现的全局搜索。 +下文中的组织身份、文档协作及其他管理流程属于目标设计,尚未实现的部分不作为当前可用功能展示。 GoodBuddy 从应用菜单或应用中心进入“设备共享”,完成地址与名称保存、注册、发布、撤销和刷新。 Web 提供设备、类型、状态、关键词筛选,显示注册时间、来源声明及独立知识权限;提供加载、空目录、 @@ -56,6 +62,34 @@ GoodBuddy 接入 当前组织始终显示。切换组织会重新加载整个数据范围,不保留上一组织的选择、筛选或详情。 系统管理使用“实例范围”标识,不能伪装成某个组织。 +### 2.1 页面 Tab 结构 + +每个管理页面围绕一个业务对象组织,页面内通过 Tab 切换查看范围或操作阶段。列表、详情和 +编辑表单属于当前 Tab 的交互,不单独增加一级导航。未接入的页面保留 Tab 结构,但每个 Tab +只显示对应的待接入说明,不生成示例数据。 + +| 页面 | Tab | +| --- | --- | +| 运行概览 | 服务状态、待处理事项、最近活动 | +| 模型网关 | 概览、上游服务、模型目录、访问密钥、请求测试、调用审计、客户端接入 | +| 文档与协作 | 文档库、编辑会话、Office 服务、保存记录 | +| 配置下发 | 配置草稿、设备生效、发布记录、回滚记录 | +| GoodBuddy 接入 | 设备列表、设备详情、连接记录、撤销记录 | +| 共享中心 | 能力目录、共享知识库、按成员查看、按设备查看 | +| 数字人 | 数字人目录、我的数字人、调用权限、发布记录 | +| 组织与成员 | 成员、用户组、角色、服务账号 | +| 权限策略 | 强制策略、Grant、策略模拟、变更记录 | +| 审批 | 待处理、已处理、我的申请、审批规则 | +| 发布与更新 | 能力包、发布通道、更新策略、撤销记录 | +| 网关联邦 | 合作组织、待确认、开放范围、事件记录 | +| 任务与中继 | 任务列表、活动任务、中继连接、运行统计 | +| 审计 | 事件查询、调用链、导出任务、保留策略 | +| 系统设置 | 实例信息、身份与登录、基础设施、中继与联邦、通知、保留与审计 | +| 我的设备与授权 | 我的设备、我的授权、我的申请、我的数字人 | + +Tab 的状态和功能范围以[产品功能列表](./feature-list.md)为准。当前原型已经为组织与成员 +提供通用 Tab 组件;模型网关使用独立的真实 API 页面,其余尚未接入服务的页面显示占位说明。 + ## 3. 概览 概览只显示需要行动和运行事实: @@ -192,7 +226,7 @@ GPU-Worker-02 AI Team Linux 离线 8 2 小时前 ## 13. Office 编辑目标设计 Office 编辑管理归入“文档与协作”,属于系统管理员的服务配置和组织管理员的策略管理,不提供 -文档正文浏览器。以下是生产目标,当前实例配置与预设检查演示见第 16.1 节。 +文档正文浏览器。以下是生产目标,当前待接入页面见第 16.1 节。 日常文档操作在 GoodBuddy 中完成。文档以 ShareServer 为主存储;显式选择本地来源导入/回写 时,另行显示检查点与源文件回写状态。关闭会话不表示删除主文档,检查点可用不表示本地源文件已保存。 @@ -217,7 +251,7 @@ Office 编辑管理归入“文档与协作”,属于系统管理员的服务 ### 13.1 私有部署第一阶段入口(待实现) -在 `/documents` 提供明确区分于现有演示的“私有文档试验”入口,展示无用户授权的使用范围。 +后续在 `/documents` 提供“私有文档试验”入口,展示无用户授权的使用范围。 提供 DOCX 上传、真实文档列表、打开编辑器、保存状态与关闭后重新打开;不使用模拟个人/组织 归属或角色开关解释真实文件。列表显示名称、大小、最近持久化版本与保存时间,不显示服务器路径。 @@ -250,34 +284,56 @@ Office 编辑管理归入“文档与协作”,属于系统管理员的服务 shadcn/ui 的本地组件模式和 Radix 可访问 primitives,布局参考成熟后台的侧栏、顶栏、数据 表格和详情对话框,但不复制第三方示例业务页面。 -原型已覆盖数字人需求加入前的一级栏目、管理员与成员自助视图切换、组织范围、搜索筛选、 -页签、详情、创建表单、危险确认和 390px 窄屏导航。动态发光、网格和网络脉冲只用于运行状态 -与品牌氛围;业务表格仍保持克制,状态继续同时使用图标与文字。既有 fixture 页面操作会调用无持久化确认 -API,并持续标明不会写入生产数据,不能据此宣称真实审批、撤销、策略或任务操作已实现。 +早期原型覆盖一级栏目、角色切换、搜索筛选、详情、创建表单与窄屏导航。 +2026-10-02 起,控制台移除示例业务页面及其操作入口,保留侧栏、主题和窄屏导航。 `FR-13` 新增的数字人目录、“我的数字人”和 `@` 选择器尚未进入当前原型。 -“文档与协作”已提供 OnlyOffice 实例配置和预设健康检查模拟;真实 Office 编辑与会话管理尚未实现。 +“文档与协作”显示待接入状态;Office 编辑与会话管理尚未实现。 -### 16.1 GoodBuddy 服务演示 +### 16.1 GoodBuddy 服务入口 -侧栏“GoodBuddy 服务”分组提供三个管理员路由。页首持续标注模拟边界:三页共享当前会话 -内存,导航保留状态、刷新重置;操作不调用网络或写本地存储。角色选择仅用于评审,不构成 -真实授权;未接入模型推理、网关转发、OnlyOffice 编辑保存或设备交付。 +本节记录已实现的 [B1 网关界面](./backend-foundation-design.md#4-b1正常模型网关接入),本轮未完成浏览器验收。 + +工作台顶部显示服务拓扑,按真实 API 汇总已登记 GoodBuddy 设备、ShareServer 发布目录和模型网关上游。拓扑表达服务职责和配置关系,登记设备不显示为在线,发布目录不显示为可执行内容;没有数据时显示零接入状态。 +页面按 Tab 组织为“概览”“上游服务”“模型目录”“访问密钥”“请求测试”“调用审计”“客户端接入”。概览只展示网关地址、资源数量和最近状态; +上游服务管理外部连接,模型目录管理客户端可见的映射,访问密钥管理客户端授权,请求测试验证真实转发,调用审计查看有界元数据,客户端接入展示 +GoodBuddy 所需的地址和模型配置。不同 Tab 不混合配置表单、运行结果和审计记录。上游表单输入 API 地址和密钥,移除前缀与变量名; +接入区展示完整 Base URL、协议、模型 ID,并支持创建与撤销访问 Key。凭据只显示配置状态, +编辑上游时,凭据输入框留空表示保留当前值,填写新值表示覆盖;页面不要求用户选择额外的凭据操作。新建访问 Key 仅当次显示完整值,离开后只保留名称及状态。 +获取模型失败仍可手动添加,空状态引导接入,不填示例。访问 Key 的授权绑定模型记录 ID,修改别名不改变授权。 + +侧栏按业务流程分为“工作台”“GoodBuddy 服务”“共享资源”“组织管理”“运营与审计”“系统”和“成员自助”。 +其中“GoodBuddy 服务”分组保留模型网关、文档与协作、配置下发三个管理路由;“共享资源”集中设备接入和共享中心。 +各页分别说明实现范围。网关是无组织认证、 +组织隔离和配额的本地技术预览,已实现客户端 Key 认证及模型授权;文档与配置下发显示待接入说明。 | 路由 | 交互与结果 | | --- | --- | -| `/model-gateway` 模型网关 | “上游服务”“模型映射 / 访问策略”“调用审计”页签;可添加和启停上游,切换映射上游、允许组及启停状态,选择调用组模拟允许/拒绝。最近 50 条审计仅含路由与策略结果,可查看详情,不含请求/响应正文 | -| `/documents` 文档与协作 | “组织文档”“归属元数据”“OnlyOffice 服务器”“回收站”页签;展示名称与归属,无个人正文预览。OnlyOffice 可添加实例、预设健康/失败并累计模拟检查次数,不访问实例或建立编辑会话 | -| `/distribution` 配置下发 | 创建一个配置草稿,选择目标组、当前可用默认模型和三段数字版本;发布前再次检查模型与组策略。按组展示当前/上一版本、设备已应用/目标版本,并可回退到上一条发布记录 | +| `/model-gateway` 模型网关 | 按“概览/上游服务/模型目录/访问密钥/请求测试/调用审计/客户端接入”组织;配置保存到服务器,可添加、编辑、启停上游与映射,发现模型、创建和撤销访问 Key、测试及刷新审计,无用户组策略控件 | +| `/documents` 文档与协作 | 显示尚未接入服务,无示例文档、实例或操作按钮 | +| `/distribution` 配置下发 | 显示“尚无配置下发服务”及模型网关链接;无草稿、设备生效回执或发布回滚记录,保存网关模型不会自动下发到 GoodBuddy | -“打开模拟 GoodBuddy 客户端”仅打开对话框,个人创建者可删除自己的示例文档或确认转移给 -当前组织,不启动真实客户端或上传文件。转移后从个人列表移除,原个人删除权失效;显式 -勾选模拟“组织文档管理员”才可删除/恢复组织文档。其他成员个人文档不可操作,恢复保留 -原归属,演示不提供永久删除。 +网关初始展示空上游、空映射、空访问 Key 与空审计。上游表单填写名称、Base URL、协议、认证 +方式和启用状态;API Key 留空时保留已有凭据,填写真实 API Key 时覆盖旧值,提交后清空密钥输入。保存失败保留 +普通字段,密钥重试需重新输入。编辑默认保留;无需认证不会自动清除已有凭据。界面仅显示 +凭据配置状态,不返回旧值或密文。旧环境引用保留存储兼容,当前界面没有导入入口。 -配置发布与回退仅更新目标组在线示例设备;离线设备保持原已应用版本并标记待下发,无重连 -交付模拟。回退同时恢复版本和默认模型;历史模型停用或当前组策略不允许时禁止回退,不能 -仅改版本标签。上游及 OnlyOffice 地址表单只接受不含账号、密码、查询参数或片段的 HTTPS -地址,不提供密钥字段,也不验证连接。 +“发现模型”访问真实上游并列出结果,选择结果打开映射表单,也可手动填写。测试只列出上游、 +映射均启用且无需认证或已配置凭据的别名,用户填写提示词后通过本机管理 `/test` 发送非流式请求;发送前提示数据去向及 +可能费用。测试支持取消、清除结果,提示词与结果仅在组件内存中,离开页面清除。页面显示客户端 +Base URL 来自 `SHARESERVER_GATEWAY_BASE_URL`,本机示例为 `http://127.0.0.1:8787/api/v1/gateway/v1`; +未配置时提示管理员设置,不从 Vite 端口或浏览器 Host 推断。客户端使用访问 Key,不使用上游 API Key。 + +配置加载失败可重试;已有快照时保留并标注上次数据。保存失败保留普通字段,错误显示 HTTP +状态、错误码及请求 ID。取消测试后提示审计可能稍后更新。审计显示服务器保留的最新 200 条 +调用元数据:时间、编号、模型、上游、访问 Key ID、流式方式、结果、HTTP 状态、耗时、Token 用量与错误码; +不展示正文,缺失用量显示“未知”。窄屏审计表可横向滚动。 + +文档页已移除“打开模拟 GoodBuddy 客户端”、示例文档、模拟角色和回收操作;归属操作待后端实现。 + +网关上游接受 HTTP/HTTPS,包括可信内网 HTTP,不允许账号、密码、查询参数或片段;保存不验证连接, +发现与测试才发真实请求。OnlyOffice 实例表单与预设检查已退出当前界面。 +管理 API 与 Vite `/api` 均限制实际 socket 来源为回环;容器/反向代理管理待 B2 登录后支持。 +既有网关策略与配置发布演示记录属于历史交互证据,不作为当前真实网关或设备交付的测试结论。 ## 17. Desktop 工作空间协作目标交互 diff --git a/package.json b/package.json index e96dce3..05706f1 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "dev": "node scripts/dev.mjs", "dev:api": "node --watch --import tsx server/src/index.ts", "dev:web": "vite --config web/vite.config.ts", + "gateway:init-key": "node scripts/init-master-key.mjs", "build": "npm run typecheck && vite build --config web/vite.config.ts && npm run build:server", "build:server": "node -e \"require('node:fs').rmSync('dist/server',{recursive:true,force:true})\" && tsc -p server/tsconfig.json", "start": "node dist/server/server/src/index.js", diff --git a/scripts/dev.mjs b/scripts/dev.mjs index 27a5aee..2183472 100644 --- a/scripts/dev.mjs +++ b/scripts/dev.mjs @@ -1,10 +1,54 @@ import process from 'node:process' +import console from 'node:console' +import { randomBytes } from 'node:crypto' +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { dirname, join } from 'node:path' +import { parseEnv } from 'node:util' import { concurrently } from 'concurrently' +const args = process.argv.slice(2) +if (args.length > 1 || (args.length === 1 && !args[0].startsWith('--env-file='))) { + console.error('Usage: npm run dev -- --env-file=') + process.exit(1) +} +let apiEnvironment = process.env +if (args.length) { + try { + // Provider credentials belong only to the API child, not the Vite process. + apiEnvironment = { ...parseEnv(readFileSync(args[0].slice('--env-file='.length), 'utf8')), ...process.env } + } catch { + console.error('Unable to load API environment file. Check the supplied path and permissions.') + process.exit(1) + } +} + +function developmentMasterKey(environment) { + if (environment.SHARESERVER_MASTER_KEY || environment.SHARESERVER_MASTER_KEY_FILE) return environment + const file = join(homedir(), '.goodbuddy-shareserver', 'master-key') + try { + mkdirSync(dirname(file), { recursive: true }) + try { + const existing = readFileSync(file, 'utf8').trim() + if (!/^[0-9a-fA-F]{64}$/.test(existing)) throw new Error('The development gateway master key is invalid.') + } catch (error) { + if (error?.code !== 'ENOENT') throw error + writeFileSync(file, `${randomBytes(32).toString('hex')}\n`, { mode: 0o600, flag: 'wx' }) + } + return { ...environment, SHARESERVER_MASTER_KEY_FILE: file } + } catch (error) { + if (error?.code === 'EEXIST') return { ...environment, SHARESERVER_MASTER_KEY_FILE: file } + console.error(`Unable to prepare the development gateway master key: ${error instanceof Error ? error.message : 'unknown error'}`) + process.exit(1) + } +} + +apiEnvironment = developmentMasterKey(apiEnvironment) + // Avoid CLI argv detection treating Electron's Node mode as a bundled app. const { result } = concurrently([ - { command: 'npm run dev:api', name: 'api', prefixColor: 'cyan' }, - { command: 'npm run dev:web -- --host 127.0.0.1', name: 'web', prefixColor: 'magenta' } + { command: 'npm run dev:api', name: 'api', prefixColor: 'cyan', env: apiEnvironment }, + { command: 'npm run dev:web -- --host 127.0.0.1', name: 'web', prefixColor: 'magenta', env: { SHARESERVER_API_PORT: apiEnvironment.SHARESERVER_PORT ?? '8787' } } ], { killOthersOn: ['success', 'failure'] }) result.then(() => { process.exitCode = 0 }, () => { process.exitCode = 1 }) diff --git a/scripts/init-master-key.mjs b/scripts/init-master-key.mjs new file mode 100644 index 0000000..ce2e1bb --- /dev/null +++ b/scripts/init-master-key.mjs @@ -0,0 +1,33 @@ +import { randomBytes } from 'node:crypto' +import process from 'node:process' +import console from 'node:console' +import { open, realpath } from 'node:fs/promises' +import { basename, dirname, isAbsolute, relative, resolve, sep } from 'node:path' + +// Keep this file outside the data directory and back it up separately. +const target = process.argv[2] +if (!target || process.argv.length !== 3) { + console.error('Usage: npm run gateway:init-key -- ') + process.exitCode = 1 +} else { + try { + const directory = resolve(process.env.SHARESERVER_DATA_DIR ?? './data') + const destination = resolve(target) + const actualParent = await realpath(dirname(destination)) + const actualDirectory = await realpath(directory).catch(error => { + if (error.code !== 'ENOENT') throw error + return directory + }) + for (const [root, file] of [[directory, destination], [actualDirectory, resolve(actualParent, basename(destination))]]) { + const path = relative(root, file) + if (!(path === '..' || path.startsWith(`..${sep}`) || isAbsolute(path))) throw new Error('Secret file must be outside the data directory.') + } + const file = await open(destination, 'wx', 0o600) + try { await file.writeFile(`${randomBytes(32).toString('hex')}\n`); await file.sync() } + finally { await file.close() } + console.log('Master key created. Set SHARESERVER_MASTER_KEY_FILE to this file. Back it up separately from data; never publish it. On Windows, restrict its ACL to the service account.') + } catch { + console.error('Master key initialization failed. Use a new file in an existing directory outside SHARESERVER_DATA_DIR; existing files are never replaced.') + process.exitCode = 1 + } +} diff --git a/server/src/admin-boundary.ts b/server/src/admin-boundary.ts new file mode 100644 index 0000000..78117f9 --- /dev/null +++ b/server/src/admin-boundary.ts @@ -0,0 +1,19 @@ +import type { Context, Next } from 'hono' +import type { IncomingMessage } from 'node:http' +import { isIP } from 'node:net' +import { randomUUID } from 'node:crypto' + +export type SocketBindings = { incoming?: IncomingMessage } + +export async function localAdmin(c: Context, next: Next) { + const address = c.env?.incoming?.socket?.remoteAddress?.toLowerCase() + const ipv4 = address?.startsWith('::ffff:') ? address.slice(7) : address + const loopback = address === '::1' || (ipv4 !== undefined && isIP(ipv4) === 4 && ipv4.startsWith('127.')) + const forwarded = [...c.req.raw.headers.keys()].some(key => key === 'forwarded' || key.startsWith('x-forwarded-') || key === 'x-real-ip') + const origin = c.req.header('origin') + const allowedOrigin = !origin || origin === new URL(c.req.url).origin || ['http://127.0.0.1:5174', 'http://localhost:5174'].includes(origin) + if (!loopback || forwarded || !allowedOrigin) { + return c.json({ error: { code: 'ADMIN_ACCESS_DENIED', message: 'Direct loopback administration required.' }, requestId: randomUUID() }, 403) + } + await next() +} diff --git a/server/src/app.test.ts b/server/src/app.test.ts index 9a101de..2f727d5 100644 --- a/server/src/app.test.ts +++ b/server/src/app.test.ts @@ -1,6 +1,9 @@ // @vitest-environment node import { describe, expect, it } from 'vitest' -import { app } from './app.js' +import { createApp } from './app.js' +import { withLocalSocket } from './test-socket.js' + +const app = withLocalSocket(createApp()) describe('ShareServer prototype API', () => { it('reports prototype readiness without claiming production readiness', async () => { diff --git a/server/src/app.ts b/server/src/app.ts index 9bc40f5..b4e77b8 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -5,11 +5,19 @@ import { secureHeaders } from 'hono/secure-headers' import { prototypeSnapshot } from '../../shared/prototype-data.js' import { sharingRoutes } from './sharing-routes.js' import { SharingStore } from './sharing-store.js' +import { GatewayStore } from './gateway-store.js' +import { gatewayRoutes } from './gateway-routes.js' +import { localAdmin, type SocketBindings } from './admin-boundary.js' -export function createApp(store = new SharingStore()) { - const app = new Hono() +export function createApp(store = new SharingStore(), gateway = new GatewayStore()) { + const app = new Hono<{ Bindings: SocketBindings }>() app.use('*', logger()) + app.use('/api/*', async (c, next) => { + if (c.req.path.startsWith('/api/v1/gateway/') || (c.req.method === 'GET' && ['/api/v1/public/health', '/api/v1/public/ready'].includes(c.req.path))) await next() + else return localAdmin(c, next) + }) + app.use('/api', localAdmin) app.use( '*', secureHeaders({ @@ -37,6 +45,16 @@ export function createApp(store = new SharingStore()) { status: 'ready', mode: 'interactive-prototype', productionReady: false, + gateway: { + mode: 'local-openai-compatible', + authentication: 'scoped-bearer-key', + persistence: 'local-json', + credentials: 'aes-256-gcm-and-legacy-environment-references', + writerScope: 'single-process-single-store-per-data-directory', + networkBoundary: 'direct-loopback-admin-authenticated-inference', + organizationAclEnforced: false, + storageChecked: false + }, sharing: { mode: 'local-metadata-only', authentication: 'none', @@ -83,6 +101,7 @@ export function createApp(store = new SharingStore()) { }) app.route('/api/v1/sharing', sharingRoutes(store)) + app.route('/api/v1/gateway', gatewayRoutes(gateway)) app.all('/api/*', (context) => context.json({ code: 'NOT_FOUND', message: 'API route not found.', requestId: crypto.randomUUID() diff --git a/server/src/gateway-credentials.ts b/server/src/gateway-credentials.ts new file mode 100644 index 0000000..21bb4f9 --- /dev/null +++ b/server/src/gateway-credentials.ts @@ -0,0 +1,42 @@ +import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto' +import { readFile, realpath } from 'node:fs/promises' +import { isAbsolute, relative, resolve } from 'node:path' + +export type EncryptedCredential = { nonce: string; tag: string; ciphertext: string } + +export function outsideDirectory(directory: string, file: string) { + const path = relative(resolve(directory), resolve(file)) + return path !== '' && (path === '..' || path.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`) || isAbsolute(path)) +} + +async function masterKey(directory: string): Promise { + let value = process.env.SHARESERVER_MASTER_KEY + const file = process.env.SHARESERVER_MASTER_KEY_FILE + if (value && file) throw new Error('Ambiguous master key configuration') + if (file) { + const actualFile = await realpath(file) + const actualDirectory = await realpath(directory).catch((error: NodeJS.ErrnoException) => { + if (error.code !== 'ENOENT') throw error + return resolve(directory) + }) + if (!outsideDirectory(directory, file) || !outsideDirectory(actualDirectory, actualFile)) throw new Error('Master key must be outside data directory') + value = (await readFile(actualFile, 'utf8')).trim() + } + if (!value || !/^[0-9a-fA-F]{64}$/.test(value)) throw new Error('Master key unavailable') + return Buffer.from(value, 'hex') +} + +export async function encryptCredential(directory: string, id: string, secret: string): Promise { + const nonce = randomBytes(12) + const cipher = createCipheriv('aes-256-gcm', await masterKey(directory), nonce) + cipher.setAAD(Buffer.from(id)) + const ciphertext = Buffer.concat([cipher.update(secret, 'utf8'), cipher.final()]) + return { nonce: nonce.toString('hex'), tag: cipher.getAuthTag().toString('hex'), ciphertext: ciphertext.toString('hex') } +} + +export async function decryptCredential(directory: string, id: string, value: EncryptedCredential): Promise { + const decipher = createDecipheriv('aes-256-gcm', await masterKey(directory), Buffer.from(value.nonce, 'hex')) + decipher.setAAD(Buffer.from(id)) + decipher.setAuthTag(Buffer.from(value.tag, 'hex')) + return Buffer.concat([decipher.update(Buffer.from(value.ciphertext, 'hex')), decipher.final()]).toString('utf8') +} diff --git a/server/src/gateway-foundation.test.ts b/server/src/gateway-foundation.test.ts new file mode 100644 index 0000000..598aa18 --- /dev/null +++ b/server/src/gateway-foundation.test.ts @@ -0,0 +1,225 @@ +// @vitest-environment node +import { randomUUID } from 'node:crypto' +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createApp } from './app.js' +import { GatewayStore } from './gateway-store.js' +import { SharingStore } from './sharing-store.js' +import { socketBinding } from './test-socket.js' + +const prefix = '/api/v1/gateway' +const upstreamInput = { name: 'Provider', baseUrl: 'http://10.0.0.2:8000/service/v1/', protocol: 'openai-chat-completions', authentication: 'api-key', enabled: true, credentialAction: 'replace', apiKey: 'provider-secret' } +const post = (value: unknown, key?: string): RequestInit => ({ method: 'POST', headers: { 'content-type': 'application/json', ...(key ? { authorization: `Bearer ${key}` } : {}) }, body: JSON.stringify(value) }) +const exec = promisify(execFile) + +describe('B1 gateway security and persistence contracts', () => { + let directory: string + let store: GatewayStore + let app: ReturnType + const chat = { model: 'public-model', messages: [{ role: 'user', content: 'hello' }] } + beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'b1-gateway-')) + store = new GatewayStore(join(directory, 'data')) + app = createApp(new SharingStore(join(directory, 'data')), store) + vi.stubEnv('SHARESERVER_MASTER_KEY', '12'.repeat(32)) + vi.stubEnv('SHARESERVER_MASTER_KEY_FILE', '') + vi.stubEnv('SHARESERVER_GATEWAY_BASE_URL', '') + }) + afterEach(async () => { + vi.restoreAllMocks() + vi.unstubAllEnvs() + await rm(directory, { recursive: true, force: true }) + }) + async function configured() { + const upstream = await store.upstream(upstreamInput) + const model = await store.model({ alias: 'public-model', upstreamId: upstream.id, remoteModel: 'remote-model', enabled: true, displayName: 'Friendly model' }) + const created = await store.createAccessKey({ name: 'Desktop', modelIds: [model.id] }) + return { upstream, model, ...created } + } + function mockCompletion() { + return vi.spyOn(globalThis, 'fetch').mockImplementation(async () => Response.json({ choices: [{ message: { role: 'assistant', content: 'answer' } }] })) + } + it('encrypts with fresh nonces, projects no secrets, and keeps/replaces/clears credentials atomically', async () => { + const { upstream } = await configured() + const filename = join(directory, 'data', 'gateway.json') + const original = JSON.parse(await readFile(filename, 'utf8')) + expect(original.upstreams[0].credential).toMatchObject({ nonce: expect.stringMatching(/^[a-f0-9]{24}$/), tag: expect.stringMatching(/^[a-f0-9]{32}$/) }) + expect(JSON.stringify(original)).not.toContain('provider-secret') + expect(upstream).toEqual({ id: upstream.id, name: 'Provider', baseUrl: 'http://10.0.0.2:8000/service/v1', protocol: 'openai-chat-completions', authentication: 'api-key', enabled: true, credentialConfigured: true }) + const { apiKey, ...input } = upstreamInput + expect(apiKey).toBe('provider-secret') + await store.upstream({ ...input, credentialAction: 'keep' }, upstream.id) + expect(JSON.parse(await readFile(filename, 'utf8')).upstreams[0].credential).toEqual(original.upstreams[0].credential) + expect(() => store.upstream({ ...input, credentialAction: 'replace', apiKey: '' }, upstream.id)).toThrow() + await store.upstream(upstreamInput, upstream.id) + expect(JSON.parse(await readFile(filename, 'utf8')).upstreams[0].credential.nonce).not.toBe(original.upstreams[0].credential.nonce) + expect(await new GatewayStore(join(directory, 'data')).headers(upstream.id)).toMatchObject({ authorization: 'Bearer provider-secret' }) + await store.upstream({ ...input, credentialAction: 'clear' }, upstream.id) + expect((await store.snapshot()).upstreams[0].credentialConfigured).toBe(false) + await expect(store.headers(upstream.id)).rejects.toMatchObject({ code: 'CREDENTIAL_NOT_CONFIGURED' }) + await store.upstream({ ...input, authentication: 'none', credentialAction: 'keep' }, upstream.id) + expect(await store.headers(upstream.id)).toEqual({ 'content-type': 'application/json' }) + }) + it('fails on missing/wrong master keys and tampered ciphertext without replacing existing secrets', async () => { + const { upstream, key } = await configured() + const filename = join(directory, 'data', 'gateway.json') + const saved = await readFile(filename, 'utf8') + const fetch = mockCompletion() + for (const master of ['', '34'.repeat(32), 'invalid']) { + vi.stubEnv('SHARESERVER_MASTER_KEY', master) + const response = await app.request(`${prefix}/v1/chat/completions`, post(chat, key)) + expect(response.status).toBe(503) + expect(await response.json()).toMatchObject({ error: { code: 'CREDENTIAL_UNAVAILABLE' } }) + expect((await store.snapshot()).upstreams[0].credentialConfigured).toBe(true) + await expect(store.upstream(upstreamInput, upstream.id)).rejects.toMatchObject({ code: 'CREDENTIAL_UNAVAILABLE' }) + expect(JSON.parse(await readFile(filename, 'utf8')).upstreams).toEqual(JSON.parse(saved).upstreams) + } + expect(fetch).not.toHaveBeenCalled() + vi.stubEnv('SHARESERVER_MASTER_KEY', '12'.repeat(32)) + const corrupted = JSON.parse(saved) + corrupted.upstreams[0].credential.tag = '00'.repeat(16) + await writeFile(filename, JSON.stringify(corrupted)) + await expect(new GatewayStore(join(directory, 'data')).headers(upstream.id)).rejects.toMatchObject({ code: 'CREDENTIAL_UNAVAILABLE' }) + vi.stubEnv('SHARESERVER_MASTER_KEY', '') + await expect(store.upstream(upstreamInput, upstream.id)).rejects.toMatchObject({ code: 'CREDENTIAL_UNAVAILABLE' }) + expect(await readFile(filename, 'utf8')).toBe(JSON.stringify(corrupted)) + }) + it('reads legacy environment records and long aliases, then migrates explicitly without changing IDs', async () => { + const upstreamId = randomUUID(), modelId = randomUUID() + const alias = 'a'.repeat(180) + await mkdir(join(directory, 'data')) + await writeFile(join(directory, 'data', 'gateway.json'), JSON.stringify({ version: 1, + upstreams: [{ id: upstreamId, name: 'Old', baseUrl: upstreamInput.baseUrl, apiKeyEnv: 'B1_LEGACY_KEY', enabled: true }], + models: [{ id: modelId, alias, upstreamId, remoteModel: 'remote', enabled: true }], audits: [] })) + vi.stubEnv('B1_LEGACY_KEY', 'legacy-secret') + const snapshot = await store.snapshot() + expect(JSON.stringify(snapshot)).not.toContain('B1_LEGACY_KEY') + const { key } = await store.createAccessKey({ name: 'Migrated', modelIds: [modelId] }) + const fetch = mockCompletion() + expect((await app.request(`${prefix}/v1/chat/completions`, post({ ...chat, model: alias }, key))).status).toBe(200) + expect(fetch.mock.calls[0][1]?.headers).toMatchObject({ authorization: 'Bearer legacy-secret' }) + await store.model({ alias, upstreamId, remoteModel: 'remote', enabled: false }, modelId) + await expect(store.model({ alias: 'b'.repeat(129), upstreamId, remoteModel: 'remote', enabled: true })).rejects.toMatchObject({ status: 400 }) + await store.upstream(upstreamInput, upstreamId) + expect((await new GatewayStore(join(directory, 'data')).snapshot()).models[0].id).toBe(modelId) + expect(await store.headers(upstreamId)).toMatchObject({ authorization: 'Bearer provider-secret' }) + const saved = await readFile(join(directory, 'data', 'gateway.json'), 'utf8') + expect(saved).not.toContain('apiKeyEnv') + expect(saved).not.toContain('legacy-secret') + }) + it('returns keys only at creation, enforces stable model scopes, and preserves revocation across restarts', async () => { + const { upstream, model, key, accessKey } = await configured() + const fetch = mockCompletion() + const second = await store.model({ alias: 'other-model', upstreamId: upstream.id, remoteModel: 'other', enabled: true }) + for (const invalid of [undefined, 'bad-key']) { + expect((await app.request(`${prefix}/v1/models`, { headers: invalid ? { authorization: `Bearer ${invalid}` } : {} })).status).toBe(401) + expect((await app.request(`${prefix}/v1/chat/completions`, post(chat, invalid))).status).toBe(401) + } + expect((await app.request(`${prefix}/v1/chat/completions`, post({ ...chat, model: second.alias }, key))).status).toBe(403) + const list = await app.request(`${prefix}/v1/models`, { headers: { authorization: `Bearer ${key}` } }) + expect(await list.json()).toMatchObject({ data: [{ id: 'public-model' }] }) + expect((await app.request(`${prefix}/v1/chat/completions`, post(chat, key))).status).toBe(200) + expect((await store.snapshot()).audits[0]).toMatchObject({ accessKeyId: accessKey.id, inputTokens: null, outputTokens: null }) + const response = await app.request(`${prefix}/access-keys`, post({ name: 'Web-created', modelIds: [model.id] }), socketBinding()) + const created = await response.json() + expect(Object.keys(created).sort()).toEqual(['accessKey', 'key']) + expect(Object.keys(created.accessKey).sort()).toEqual(['createdAt', 'id', 'modelIds', 'name', 'revokedAt']) + const publicList = await (await app.request(`${prefix}/access-keys`, undefined, socketBinding())).json() + expect(publicList.accessKeys).toHaveLength(2) + expect(JSON.stringify(publicList)).not.toContain(created.key) + expect(await readFile(join(directory, 'data', 'gateway.json'), 'utf8')).not.toContain(key) + const revoked = await (await app.request(`${prefix}/access-keys/${accessKey.id}/revoke`, { method: 'POST' }, socketBinding())).json() + expect(revoked.revokedAt).toEqual(expect.any(String)) + const restarted = new GatewayStore(join(directory, 'data')) + expect(await restarted.revokeAccessKey(accessKey.id)).toEqual(revoked) + await expect(restarted.authenticate(`Bearer ${key}`)).rejects.toMatchObject({ status: 401 }) + expect((await app.request(`${prefix}/v1/chat/completions`, post(chat, key))).status).toBe(401) + expect(fetch).toHaveBeenCalledTimes(1) + for (const modelIds of [[], ['public-model'], [model.id, model.id], [randomUUID()]]) { + expect((await app.request(`${prefix}/access-keys`, post({ name: 'Invalid', modelIds }), socketBinding())).status).toBe(400) + } + }) + it('denies admin requests without an actual loopback binding, including legacy and prototype APIs', async () => { + const { key } = await configured() + for (const path of [`${prefix}/config`, `${prefix}/access-keys`, `${prefix}/test`, '/api/v1/sharing/devices', '/api/v1/web/prototype/snapshot', '/api/v1/web/prototype/actions']) { + const init = path.endsWith('/test') || path.endsWith('/actions') ? post(chat, key) : { headers: { authorization: `Bearer ${key}` } } + for (const binding of [undefined, socketBinding('192.168.1.8'), socketBinding('::ffff:192.168.1.8')]) { + expect((await app.request(path, init, binding)).status).toBe(403) + } + for (const headers of [{ 'x-forwarded-for': '127.0.0.1' }, { forwarded: 'for=127.0.0.1' }, { 'x-real-ip': '127.0.0.1' }]) { + expect((await app.request(path, { ...init, headers: { ...init.headers, ...headers } }, socketBinding())).status).toBe(403) + } + } + for (const address of ['127.0.0.1', '127.0.0.2', '::1', '::ffff:127.0.0.1']) { + expect((await app.request(`${prefix}/config`, undefined, socketBinding(address))).status).toBe(200) + } + mockCompletion() + expect((await app.request(`${prefix}/v1/chat/completions`, post(chat, key), socketBinding('192.168.1.8'))).status).toBe(200) + }) + it('uses the same forwarding and auditing for local nonstream tests without bypassing inference authentication', async () => { + const { upstream } = await configured() + const fetch = mockCompletion() + const toolMessages = [{ role: 'assistant', tool_calls: [{ id: 'call', type: 'function', function: { name: 'lookup', arguments: '{}' } }] }, { role: 'tool', tool_call_id: 'call', content: 'result' }] + const response = await app.request(`${prefix}/test`, post({ ...chat, messages: toolMessages }), socketBinding()) + expect(response.status).toBe(200) + expect(JSON.parse(fetch.mock.calls[0][1]?.body as string)).toMatchObject({ model: 'remote-model', messages: toolMessages }) + expect((await store.snapshot()).audits[0]).toMatchObject({ status: 'success', accessKeyId: null, upstreamId: upstream.id }) + expect((await app.request(`${prefix}/test`, post({ ...chat, stream: true }), socketBinding())).status).toBe(400) + expect((await app.request(`${prefix}/v1/chat/completions`, post(chat), socketBinding())).status).toBe(401) + expect(fetch).toHaveBeenCalledTimes(1) + }) + it('uses only the configured gateway URL and omits unavailable models from client discovery', async () => { + const { upstream, key } = await configured() + expect((await store.snapshot()).gatewayBaseUrl).toBeNull() + vi.stubEnv('SHARESERVER_GATEWAY_BASE_URL', 'https://gateway.example/service/api/v1/gateway/v1/') + const response = await app.request(`${prefix}/config`, { headers: { host: 'attacker.example' } }, socketBinding()) + expect((await response.json()).gatewayBaseUrl).toBe('https://gateway.example/service/api/v1/gateway/v1') + vi.stubEnv('SHARESERVER_MASTER_KEY', '') + expect(await (await app.request(`${prefix}/v1/models`, { headers: { authorization: `Bearer ${key}` } })).json()).toMatchObject({ data: [] }) + vi.stubEnv('SHARESERVER_MASTER_KEY', '12'.repeat(32)) + await store.upstream({ ...upstreamInput, enabled: false }, upstream.id) + expect((await app.request(`${prefix}/v1/chat/completions`, post(chat, key))).status).toBe(403) + }) + it('preserves mapping and credentials after failed discovery and sends no auth to unauthenticated upstreams', async () => { + const { upstream, model } = await configured() + const before = await store.snapshot() + const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response('provider-secret sensitive failure', { status: 401 })) + const response = await app.request(`${prefix}/upstreams/${upstream.id}/discover`, { method: 'POST' }, socketBinding()) + expect(response.status).toBe(502) + expect(await response.json()).toMatchObject({ error: { code: 'UPSTREAM_HTTP_ERROR', message: 'Upstream returned HTTP 401.' } }) + expect(await store.snapshot()).toEqual(before) + await store.upstream({ name: upstream.name, baseUrl: upstream.baseUrl, protocol: upstream.protocol, authentication: 'none', enabled: true, credentialAction: 'keep' }, upstream.id) + fetch.mockResolvedValueOnce(Response.json({ choices: [] })) + expect((await app.request(`${prefix}/test`, post(chat), socketBinding())).status).toBe(200) + expect(fetch.mock.calls[1][1]?.headers).toEqual({ 'content-type': 'application/json' }) + const renamed = await store.model({ alias: 'renamed', upstreamId: upstream.id, remoteModel: model.remoteModel, enabled: true }, model.id) + expect(renamed.id).toBe(model.id) + const { key } = await store.createAccessKey({ name: 'Renamed client', modelIds: [model.id] }) + expect(await (await app.request(`${prefix}/v1/models`, { headers: { authorization: `Bearer ${key}` } })).json()).toMatchObject({ data: [{ id: 'renamed' }] }) + }) + it('initializes an external master key explicitly, refuses overwrite/data paths, and supports file-backed credentials', async () => { + const file = join(directory, 'master.key') + const data = join(directory, 'data') + await mkdir(data) + const command = resolve('scripts/init-master-key.mjs') + const env = { ...process.env, SHARESERVER_DATA_DIR: data } + const result = await exec(process.execPath, [command, file], { env }) + const key = (await readFile(file, 'utf8')).trim() + expect(key).toMatch(/^[a-f0-9]{64}$/) + expect(result.stdout).not.toContain(key) + await expect(exec(process.execPath, [command, file], { env })).rejects.toMatchObject({ code: 1 }) + await expect(exec(process.execPath, [command, join(data, 'bad.key')], { env })).rejects.toMatchObject({ code: 1 }) + expect((await readFile(file, 'utf8')).trim()).toBe(key) + vi.stubEnv('SHARESERVER_MASTER_KEY', '') + vi.stubEnv('SHARESERVER_MASTER_KEY_FILE', file) + const upstream = await store.upstream(upstreamInput) + expect(await new GatewayStore(data).headers(upstream.id)).toMatchObject({ authorization: 'Bearer provider-secret' }) + await writeFile(join(data, 'bad.key'), `${key}\n`) + vi.stubEnv('SHARESERVER_MASTER_KEY_FILE', join(data, 'bad.key')) + await expect(store.headers(upstream.id)).rejects.toMatchObject({ code: 'CREDENTIAL_UNAVAILABLE' }) + }) +}) diff --git a/server/src/gateway-routes.ts b/server/src/gateway-routes.ts new file mode 100644 index 0000000..566c7d4 --- /dev/null +++ b/server/src/gateway-routes.ts @@ -0,0 +1,245 @@ +import { randomUUID } from 'node:crypto' +import { Hono } from 'hono' +import type { GatewayAudit } from '../../shared/gateway.js' +import { GatewayError, GatewayStore, record } from './gateway-store.js' +import { localAdmin, type SocketBindings } from './admin-boundary.js' + +const REQUEST_LIMIT = 1024 * 1024 +const RESPONSE_LIMIT = 16 * 1024 * 1024 +const EVENT_LIMIT = 1024 * 1024 +const CHAT_FIELDS = new Set(('model messages stream stream_options temperature top_p n stop max_tokens max_completion_tokens presence_penalty frequency_penalty logit_bias logprobs top_logprobs seed tools tool_choice parallel_tool_calls functions function_call response_format reasoning_effort verbosity user').split(' ')) +async function readBounded(body: ReadableStream | null, limit: number, signal: AbortSignal): Promise { + if (!body) throw new GatewayError(400, 'EMPTY_BODY') + const reader = body.getReader() + const abort = () => { void reader.cancel().catch(() => undefined) } + signal.addEventListener('abort', abort, { once: true }) + let size = 0 + let result = '' + const decoder = new TextDecoder() + try { + while (true) { + if (signal.aborted) throw new GatewayError(504, 'REQUEST_TIMEOUT') + const { done, value } = await reader.read() + if (signal.aborted) throw new GatewayError(504, 'REQUEST_TIMEOUT') + if (done) break + size += value.byteLength + if (size > limit) throw new GatewayError(413, 'BODY_TOO_LARGE') + result += decoder.decode(value, { stream: true }) + } + return result + decoder.decode() + } finally { signal.removeEventListener('abort', abort); await reader.cancel().catch(() => undefined); reader.releaseLock() } +} +function json(text: string): Record { + try { return record(JSON.parse(text)) } catch { throw new GatewayError(400, 'INVALID_JSON') } +} +function originAllowed(request: Request) { + const origin = request.headers.get('origin') + return !origin || origin === new URL(request.url).origin || ['http://127.0.0.1:5174', 'http://localhost:5174'].includes(origin) +} +function usage(value: Record, audit: GatewayAudit) { + if (value.usage && typeof value.usage === 'object') { + const u = value.usage as Record + if (Number.isSafeInteger(u.prompt_tokens) && Number(u.prompt_tokens) >= 0) audit.inputTokens = Number(u.prompt_tokens) + if (Number.isSafeInteger(u.completion_tokens) && Number(u.completion_tokens) >= 0) audit.outputTokens = Number(u.completion_tokens) + } +} + +export function gatewayRoutes(store: GatewayStore, timeoutMs = 120_000) { + const app = new Hono<{ Bindings: SocketBindings; Variables: { requestId: string } }>() + app.onError((error, c) => { + const safe = error instanceof GatewayError ? error : new GatewayError(500, 'GATEWAY_FAILURE') + return c.json({ error: { code: safe.code, message: safe.message }, requestId: c.get('requestId') ?? randomUUID() }, safe.status) + }) + app.use('*', async (c, next) => { + const inference = (c.req.method === 'POST' && c.req.path === '/api/v1/gateway/v1/chat/completions') || (c.req.method === 'GET' && c.req.path === '/api/v1/gateway/v1/models') + if (inference) await next() + else return localAdmin(c, next) + }) + const body = async (request: Request) => { + if (!request.headers.get('content-type')?.toLowerCase().startsWith('application/json')) throw new GatewayError(415, 'JSON_REQUIRED') + return json(await readBounded(request.body, REQUEST_LIMIT, AbortSignal.any([request.signal, AbortSignal.timeout(timeoutMs)]))) + } + app.get('/config', async c => c.json(await store.snapshot())) + app.post('/upstreams', async c => c.json(await store.upstream(await body(c.req.raw)))) + app.put('/upstreams/:id', async c => c.json(await store.upstream(await body(c.req.raw), c.req.param('id')))) + app.post('/models', async c => c.json(await store.model(await body(c.req.raw)))) + app.put('/models/:id', async c => c.json(await store.model(await body(c.req.raw), c.req.param('id')))) + app.get('/access-keys', async c => c.json(await store.accessKeys())) + app.post('/access-keys', async c => c.json(await store.createAccessKey(await body(c.req.raw)))) + app.post('/access-keys/:id/revoke', async c => c.json(await store.revokeAccessKey(c.req.param('id')))) + app.post('/upstreams/:id/discover', async c => { + const upstream = (await store.snapshot()).upstreams.find(u => u.id === c.req.param('id')) + if (!upstream) throw new GatewayError(404, 'UPSTREAM_NOT_FOUND') + if (!upstream.enabled) throw new GatewayError(403, 'UPSTREAM_DISABLED') + const signal = AbortSignal.any([c.req.raw.signal, AbortSignal.timeout(timeoutMs)]) + const headers = await store.headers(upstream.id) + try { + const response = await fetch(`${upstream.baseUrl}/models`, { headers, redirect: 'error', signal }) + if (!response.ok) { await response.body?.cancel(); throw new GatewayError(502, 'UPSTREAM_HTTP_ERROR', `Upstream returned HTTP ${response.status}.`) } + const data = json(await readBounded(response.body, RESPONSE_LIMIT, signal)).data + if (!Array.isArray(data) || data.some(m => !m || typeof m.id !== 'string' || m.id.length > 200)) throw new GatewayError(502, 'INVALID_UPSTREAM_RESPONSE') + return c.json({ models: [...new Set(data.map(m => m.id as string))] }) + } catch (error) { + if (error instanceof GatewayError && error.code === 'UPSTREAM_HTTP_ERROR') throw error + throw new GatewayError(signal.aborted ? 504 : 502, signal.aborted ? 'REQUEST_TIMEOUT' : 'UPSTREAM_FAILURE') + } + }) + app.get('/v1/models', async c => { + const key = await store.authenticate(c.req.header('authorization')) + const snapshot = await store.snapshot() + const available = [] + for (const model of snapshot.models) { + if (!key.modelIds.includes(model.id) || !model.enabled || !snapshot.upstreams.some(u => u.id === model.upstreamId && u.enabled)) continue + try { await store.headers(model.upstreamId) } + catch (error) { if (error instanceof GatewayError && ['CREDENTIAL_UNAVAILABLE', 'CREDENTIAL_NOT_CONFIGURED', 'UPSTREAM_DISABLED'].includes(error.code)) continue; throw error } + available.push({ id: model.alias, object: 'model', created: 0, owned_by: 'local-gateway' }) + } + return c.json({ object: 'list', data: available }) + }) + app.on('POST', ['/v1/chat/completions', '/test'], async c => { + const started = Date.now() + const audit: GatewayAudit = { id: randomUUID(), time: new Date().toISOString(), model: '', upstreamId: null, accessKeyId: null, stream: false, status: 'failed', httpStatus: 500, durationMs: 0, inputTokens: null, outputTokens: null, errorCode: null } + c.set('requestId', audit.id) + const controller = new AbortController() + let timedOut = false + let cancelled = false + let finished = false + const timer = setTimeout(() => { timedOut = true; controller.abort() }, timeoutMs) + const cancel = () => { cancelled = true; controller.abort() } + c.req.raw.signal.addEventListener('abort', cancel, { once: true }) + if (c.req.raw.signal.aborted) cancel() + const finish = async (status: GatewayAudit['status'], httpStatus: number, code: string | null) => { + if (finished) return + finished = true + clearTimeout(timer) + c.req.raw.signal.removeEventListener('abort', cancel) + Object.assign(audit, { status, httpStatus, errorCode: code, durationMs: Date.now() - started }) + await store.audit(audit) + } + const failure = (error: unknown) => cancelled ? new GatewayError(400, 'CLIENT_CANCELLED') : timedOut ? new GatewayError(504, 'REQUEST_TIMEOUT') : error instanceof GatewayError ? error : new GatewayError(502, 'UPSTREAM_FAILURE') + try { + const key = c.req.path === '/api/v1/gateway/test' ? null : await store.authenticate(c.req.header('authorization')) + audit.accessKeyId = key?.id ?? null + if (!originAllowed(c.req.raw)) throw new GatewayError(403, 'ORIGIN_DENIED') + if (!c.req.header('content-type')?.toLowerCase().startsWith('application/json')) throw new GatewayError(415, 'JSON_REQUIRED') + const input = json(await readBounded(c.req.raw.body, REQUEST_LIMIT, controller.signal)) + audit.stream = input.stream === true + if (c.req.path === '/api/v1/gateway/test' && input.stream === true) throw new GatewayError(400, 'TEST_NON_STREAMING_ONLY') + if (Object.keys(input).some(key => !CHAT_FIELDS.has(key))) throw new GatewayError(400, 'UNSUPPORTED_FIELD', 'Unsupported chat field or routing override.') + if (typeof input.model !== 'string' || input.model.length > 200 || !Array.isArray(input.messages) || !input.messages.length || + input.messages.some(m => !m || typeof m !== 'object' || !['system', 'developer', 'user', 'assistant', 'tool', 'function'].includes(m.role)) || + (input.stream !== undefined && typeof input.stream !== 'boolean')) throw new GatewayError(400, 'INVALID_CHAT_REQUEST') + const snapshot = await store.snapshot() + const model = snapshot.models.find(m => m.alias === input.model) + // Only configured aliases are persisted, never arbitrary user text. + if (key && (!model || !key.modelIds.includes(model.id))) throw new GatewayError(403, 'MODEL_NOT_AUTHORIZED') + if (!model) throw new GatewayError(404, 'MODEL_NOT_FOUND') + audit.model = model.alias + audit.upstreamId = model.upstreamId + const upstream = snapshot.upstreams.find(u => u.id === model.upstreamId)! + if (!model.enabled || !upstream.enabled) throw new GatewayError(403, 'MODEL_DISABLED') + const response = await fetch(`${upstream.baseUrl}/chat/completions`, { + method: 'POST', headers: await store.headers(upstream.id), body: JSON.stringify({ ...input, model: model.remoteModel }), redirect: 'error', signal: controller.signal + }) + if (!response.ok) { await response.body?.cancel(); throw new GatewayError(502, 'UPSTREAM_HTTP_ERROR', `Upstream returned HTTP ${response.status}.`) } + if (!input.stream) { + let output: Record + try { output = json(await readBounded(response.body, RESPONSE_LIMIT, controller.signal)) } + catch { throw new GatewayError(502, 'INVALID_UPSTREAM_RESPONSE') } + if (output.error || !Array.isArray(output.choices)) throw new GatewayError(502, 'INVALID_UPSTREAM_RESPONSE') + usage(output, audit) + output.model = model.alias + await finish('success', 200, null) + return c.json(output) + } + if (!response.headers.get('content-type')?.includes('text/event-stream') || !response.body) { + await response.body?.cancel() + throw new GatewayError(502, 'INVALID_UPSTREAM_RESPONSE') + } + const reader = response.body.getReader() + const decoder = new TextDecoder() + const encoder = new TextEncoder() + let buffer = '' + let bytes = 0 + const stream = new ReadableStream({ + start(output) { + const abortStream = () => { + if (finished) return + void reader.cancel().catch(() => undefined) + void finish(cancelled ? 'cancelled' : 'failed', cancelled ? 499 : 504, cancelled ? 'CLIENT_CANCELLED' : 'REQUEST_TIMEOUT') + .then(() => output.error(new Error(cancelled ? 'Client cancelled.' : 'Gateway request timed out.'))) + .catch(() => output.error(new Error('Gateway storage failed.'))) + } + controller.signal.addEventListener('abort', abortStream, { once: true }) + if (controller.signal.aborted) abortStream() + }, + async pull(output) { + if (finished) return + try { + while (true) { + const boundary = /\r?\n\r?\n/.exec(buffer) + if (boundary) { + const event = buffer.slice(0, boundary.index) + buffer = buffer.slice(boundary.index + boundary[0].length) + if (event.length > EVENT_LIMIT) throw new GatewayError(502, 'STREAM_TOO_LARGE') + const data = event.split(/\r?\n/).filter(line => line.startsWith('data:')).map(line => line.slice(5).trimStart()).join('\n') + if (!data) continue + if (data === '[DONE]') { + await finish('success', 200, null) + output.enqueue(encoder.encode('data: [DONE]\n\n')) + output.close() + controller.abort() + await reader.cancel().catch(() => undefined) + return + } + let chunk: Record + try { chunk = json(data) } catch { throw new GatewayError(502, 'INVALID_UPSTREAM_RESPONSE') } + if (chunk.error || !Array.isArray(chunk.choices)) throw new GatewayError(502, 'INVALID_UPSTREAM_RESPONSE') + usage(chunk, audit) + chunk.model = model.alias + output.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`)) + return + } + if (buffer.length > EVENT_LIMIT) throw new GatewayError(502, 'STREAM_TOO_LARGE') + const next = await reader.read() + if (finished) return + if (next.done) throw new GatewayError(502, 'INCOMPLETE_STREAM') + bytes += next.value.byteLength + if (bytes > RESPONSE_LIMIT) throw new GatewayError(502, 'STREAM_TOO_LARGE') + buffer += decoder.decode(next.value, { stream: true }) + } + } catch (error) { + if (finished) { + output.error(new Error('Gateway stream failed.')) + controller.abort() + await reader.cancel().catch(() => undefined) + return + } + const safe = failure(error) + await reader.cancel().catch(() => undefined) + try { + await finish(cancelled ? 'cancelled' : 'failed', cancelled ? 499 : safe.status, safe.code) + if (!cancelled) { output.enqueue(encoder.encode(`data: ${JSON.stringify({ error: { code: safe.code, message: 'Gateway stream failed.' }, requestId: audit.id })}\n\n`)); output.close() } + } catch { output.error(new Error('Gateway stream failed.')) } + controller.abort() + } + }, + async cancel() { + try { await finish('cancelled', 499, 'CLIENT_CANCELLED') } + finally { + cancel() + await reader.cancel().catch(() => undefined) + } + } + }) + return new Response(stream, { headers: { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', 'x-accel-buffering': 'no' } }) + } catch (error) { + const safe = failure(error) + controller.abort() + await finish(cancelled ? 'cancelled' : 'failed', cancelled ? 499 : safe.status, safe.code) + throw safe + } + }) + app.all('*', c => c.json({ error: { code: 'NOT_FOUND', message: 'API route not found.' }, requestId: randomUUID() }, 404)) + return app +} diff --git a/server/src/gateway-store.ts b/server/src/gateway-store.ts new file mode 100644 index 0000000..b9d1288 --- /dev/null +++ b/server/src/gateway-store.ts @@ -0,0 +1,258 @@ +import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto' +import { mkdir, open, readFile, rename, rm } from 'node:fs/promises' +import { resolve } from 'node:path' +import type { GatewayAccessKey, GatewayAudit, GatewayModel, GatewayModelInput, GatewaySnapshot, GatewayUpstream, GatewayUpstreamInput } from '../../shared/gateway.js' +import { decryptCredential, encryptCredential, type EncryptedCredential } from './gateway-credentials.js' + +export class GatewayError extends Error { + constructor(public status: 400 | 401 | 403 | 404 | 409 | 413 | 415 | 500 | 502 | 503 | 504, public code: string, message = 'Gateway request failed.') { + super(message) + } +} +export function record(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new GatewayError(400, 'INVALID_INPUT') + return value as Record +} +function exact(value: unknown, keys: string[]) { + const item = record(value) + if (Object.keys(item).length !== keys.length || keys.some(key => !Object.hasOwn(item, key))) throw new GatewayError(400, 'INVALID_FIELDS') + return item +} +function text(value: unknown, max = 200): string { + if (typeof value !== 'string' || !value.trim() || value.length > max || Array.from(value).some(c => c.charCodeAt(0) < 32 || c.charCodeAt(0) === 127)) throw new GatewayError(400, 'INVALID_TEXT') + return value +} +function enabled(value: unknown): boolean { + if (typeof value !== 'boolean') throw new GatewayError(400, 'INVALID_ENABLED') + return value +} +function uuid(value: unknown): string { + const id = text(value) + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(id)) throw new GatewayError(400, 'INVALID_ID') + return id +} +export function upstreamInput(value: unknown): GatewayUpstreamInput { + const item = record(value) + exact(item, ['name', 'baseUrl', 'protocol', 'authentication', 'enabled', 'credentialAction', ...(Object.hasOwn(item, 'apiKey') ? ['apiKey'] : [])]) + if (item.protocol !== 'openai-chat-completions' || !['api-key', 'none'].includes(String(item.authentication)) || !['keep', 'replace', 'clear'].includes(String(item.credentialAction))) throw new GatewayError(400, 'INVALID_UPSTREAM') + if ((item.credentialAction !== 'replace' && Object.hasOwn(item, 'apiKey')) || (item.authentication === 'none' && item.credentialAction === 'replace')) throw new GatewayError(400, 'INVALID_CREDENTIAL_ACTION') + return { + name: text(item.name), baseUrl: baseUrl(item.baseUrl), protocol: item.protocol, + authentication: item.authentication as GatewayUpstreamInput['authentication'], enabled: enabled(item.enabled), + credentialAction: item.credentialAction as GatewayUpstreamInput['credentialAction'], + ...(item.credentialAction === 'replace' ? { apiKey: text(item.apiKey, 16384) } : {}) + } +} +function baseUrl(value: unknown): string { + let url: URL + const raw = text(value, 2048) + try { url = new URL(raw) } catch { throw new GatewayError(400, 'INVALID_BASE_URL') } + if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password || raw.includes('?') || raw.includes('#')) { + throw new GatewayError(400, 'INVALID_BASE_URL', 'Use HTTP or HTTPS without credentials, query or fragment.') + } + return url.href.replace(/\/+$/, '') +} +export function modelInput(value: unknown, legacy = false): GatewayModelInput { + const item = record(value) + exact(item, ['alias', 'upstreamId', 'remoteModel', 'enabled', ...(Object.hasOwn(item, 'displayName') ? ['displayName'] : [])]) + const alias = text(item.alias, legacy ? 200 : 128) + if (!/^[A-Za-z0-9][A-Za-z0-9._:/-]*$/.test(alias)) throw new GatewayError(400, 'INVALID_ALIAS') + return { alias, upstreamId: uuid(item.upstreamId), remoteModel: text(item.remoteModel), enabled: enabled(item.enabled), ...(item.displayName !== undefined ? { displayName: text(item.displayName) } : {}) } +} +type StoredUpstream = Omit & { credential: EncryptedCredential | null; apiKeyEnv?: string } +type StoredAccessKey = GatewayAccessKey & { digest: string } +type State = { version: 2; upstreams: StoredUpstream[]; models: GatewayModel[]; audits: GatewayAudit[]; accessKeys: StoredAccessKey[] } +function publicUpstream(u: StoredUpstream): GatewayUpstream { + return { id: u.id, name: u.name, baseUrl: u.baseUrl, protocol: u.protocol, authentication: u.authentication, enabled: u.enabled, + credentialConfigured: Boolean(u.credential || (u.apiKeyEnv && process.env[u.apiKeyEnv]?.trim())) } +} +function publicAccessKey(k: StoredAccessKey): GatewayAccessKey { + return { id: k.id, name: k.name, modelIds: [...k.modelIds], createdAt: k.createdAt, revokedAt: k.revokedAt } +} +function timestamp(value: unknown): string { + if (typeof value !== 'string' || !Number.isFinite(Date.parse(value)) || new Date(value).toISOString() !== value) throw new Error('Invalid timestamp') + return value +} +function parseState(value: unknown): State { + const item = record(value) + exact(item, ['version', 'upstreams', 'models', 'audits', ...(item.version === 2 ? ['accessKeys'] : [])]) + if ((item.version !== 1 && item.version !== 2) || !Array.isArray(item.upstreams) || !Array.isArray(item.models) || !Array.isArray(item.audits) || item.audits.length > 200) throw new Error('Invalid state') + const upstreams = item.upstreams.map(value => { + const u = record(value) + if (item.version === 1) { + exact(u, ['id', 'name', 'baseUrl', 'apiKeyEnv', 'enabled']) + if (typeof u.apiKeyEnv !== 'string' || !/^[A-Z_][A-Z0-9_]{0,127}$/.test(u.apiKeyEnv)) throw new Error('Invalid legacy credential') + return { id: uuid(u.id), name: text(u.name), baseUrl: baseUrl(u.baseUrl), enabled: enabled(u.enabled), protocol: 'openai-chat-completions' as const, authentication: 'api-key' as const, apiKeyEnv: u.apiKeyEnv, credential: null } + } + exact(u, ['id', 'name', 'baseUrl', 'protocol', 'authentication', 'enabled', 'credential', ...(Object.hasOwn(u, 'apiKeyEnv') ? ['apiKeyEnv'] : [])]) + const input = upstreamInput({ name: u.name, baseUrl: u.baseUrl, protocol: u.protocol, authentication: u.authentication, enabled: u.enabled, credentialAction: 'keep' }) + if (u.apiKeyEnv !== undefined && (typeof u.apiKeyEnv !== 'string' || !/^[A-Z_][A-Z0-9_]{0,127}$/.test(u.apiKeyEnv))) throw new Error('Invalid credential reference') + if (u.credential !== null) { + const cipher = exact(u.credential, ['nonce', 'tag', 'ciphertext']) + if (typeof cipher.nonce !== 'string' || !/^[a-f0-9]{24}$/.test(cipher.nonce) || typeof cipher.tag !== 'string' || !/^[a-f0-9]{32}$/.test(cipher.tag) || typeof cipher.ciphertext !== 'string' || !/^(?:[a-f0-9]{2}){1,65536}$/.test(cipher.ciphertext)) throw new Error('Invalid ciphertext') + } + if (u.credential && u.apiKeyEnv) throw new Error('Ambiguous credential') + return { id: uuid(u.id), name: input.name, baseUrl: input.baseUrl, protocol: input.protocol, authentication: input.authentication, enabled: input.enabled, credential: u.credential as EncryptedCredential | null, ...(u.apiKeyEnv ? { apiKeyEnv: u.apiKeyEnv as string } : {}) } + }) + const models = item.models.map(value => { + const { id, ...input } = record(value) + return { id: uuid(id), ...modelInput(input, true) } + }) + const audits = item.audits.map(value => { + const audit: Record = { ...record(value), ...(item.version === 1 ? { accessKeyId: null } : {}) } + exact(audit, ['id', 'time', 'model', 'upstreamId', 'accessKeyId', 'stream', 'status', 'httpStatus', 'durationMs', 'inputTokens', 'outputTokens', 'errorCode']) + uuid(audit.id) + if (audit.accessKeyId !== null) uuid(audit.accessKeyId) + if (typeof audit.time !== 'string' || !Number.isFinite(Date.parse(audit.time)) || new Date(audit.time).toISOString() !== audit.time || + typeof audit.model !== 'string' || audit.model.length > 200 || (audit.upstreamId !== null && !upstreams.some(u => u.id === audit.upstreamId)) || + typeof audit.stream !== 'boolean' || !['success', 'failed', 'cancelled'].includes(String(audit.status)) || + !Number.isInteger(audit.httpStatus) || Number(audit.httpStatus) < 100 || Number(audit.httpStatus) > 599 || + !Number.isFinite(audit.durationMs) || Number(audit.durationMs) < 0 || + [audit.inputTokens, audit.outputTokens].some(n => n !== null && (!Number.isSafeInteger(n) || Number(n) < 0)) || + (audit.errorCode !== null && (typeof audit.errorCode !== 'string' || !/^[A-Z_]{1,64}$/.test(audit.errorCode)))) throw new Error('Invalid audit') + return audit as GatewayAudit + }) + if (new Set(upstreams.map(u => u.id)).size !== upstreams.length || new Set(models.map(m => m.id)).size !== models.length || + new Set(models.map(m => m.alias)).size !== models.length || new Set(audits.map(a => a.id)).size !== audits.length || + models.some(m => !upstreams.some(u => u.id === m.upstreamId))) throw new Error('Invalid references') + if (item.version === 2 && !Array.isArray(item.accessKeys)) throw new Error('Invalid access keys') + const accessKeys = ((item.accessKeys ?? []) as unknown[]).map(value => { + const k = exact(value, ['id', 'name', 'modelIds', 'createdAt', 'revokedAt', 'digest']) + if (!Array.isArray(k.modelIds) || !k.modelIds.length || k.modelIds.some(id => !models.some(m => m.id === id)) || new Set(k.modelIds).size !== k.modelIds.length || typeof k.digest !== 'string' || !/^[a-f0-9]{64}$/.test(k.digest)) throw new Error('Invalid access key') + return { id: uuid(k.id), name: text(k.name), modelIds: k.modelIds as string[], createdAt: timestamp(k.createdAt), revokedAt: k.revokedAt === null ? null : timestamp(k.revokedAt), digest: k.digest } + }) + if (new Set(accessKeys.map(k => k.id)).size !== accessKeys.length || new Set(accessKeys.map(k => k.digest)).size !== accessKeys.length || audits.some(a => a.accessKeyId !== null && !accessKeys.some(k => k.id === a.accessKeyId))) throw new Error('Invalid access key references') + return { version: 2, upstreams, models, audits, accessKeys } +} + +// One process and one store per data directory; publish memory only after atomic replacement. +export class GatewayStore { + private state?: State + private queue: Promise = Promise.resolve() + private readonly filename: string + constructor(private readonly directory = process.env.SHARESERVER_DATA_DIR ?? './data') { + this.filename = resolve(directory, 'gateway.json') + } + private run(operation: (state: State) => T | Promise, write = false): Promise { + const result = this.queue.then(async () => { + if (!this.state) { + try { this.state = parseState(JSON.parse(await readFile(this.filename, 'utf8'))) } + catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw new GatewayError(503, 'GATEWAY_STORAGE_UNAVAILABLE') + this.state = { version: 2, upstreams: [], models: [], audits: [], accessKeys: [] } + } + } + const next = structuredClone(this.state) + const value = await operation(next) + if (write) { + const temporary = `${this.filename}.${randomUUID()}.tmp` + try { + await mkdir(this.directory, { recursive: true }) + const file = await open(temporary, 'wx', 0o600) + try { await file.writeFile(JSON.stringify(next) + '\n'); await file.sync() } finally { await file.close() } + await rename(temporary, this.filename) + this.state = next + } catch { throw new GatewayError(503, 'GATEWAY_STORAGE_UNAVAILABLE') } + finally { await rm(temporary, { force: true }).catch(() => undefined) } + } + return value + }) + this.queue = result.catch(() => undefined) + return result + } + snapshot(): Promise { + return this.run(state => { + let gatewayBaseUrl: string | null = null + if (process.env.SHARESERVER_GATEWAY_BASE_URL) { + try { gatewayBaseUrl = baseUrl(process.env.SHARESERVER_GATEWAY_BASE_URL) } + catch { throw new GatewayError(503, 'INVALID_GATEWAY_BASE_URL') } + } + return { models: state.models, audits: state.audits, upstreams: state.upstreams.map(publicUpstream), gatewayBaseUrl } + }) + } + upstream(value: unknown, id?: string) { + const input = upstreamInput(value) + return this.run(async state => { + const index = id === undefined ? -1 : state.upstreams.findIndex(u => u.id === id) + if (id !== undefined && index < 0) throw new GatewayError(404, 'UPSTREAM_NOT_FOUND') + const { credentialAction, apiKey, ...fields } = input + const previous = state.upstreams[index] + const upstream: StoredUpstream = { ...fields, id: id ?? randomUUID(), credential: previous?.credential ?? null, ...(previous?.apiKeyEnv ? { apiKeyEnv: previous.apiKeyEnv } : {}) } + if (credentialAction === 'replace') { + try { + // A changed deployment key must not silently create a mixed-key store. + for (const existing of state.upstreams) { + if (existing.credential) await decryptCredential(this.directory, existing.id, existing.credential) + } + upstream.credential = await encryptCredential(this.directory, upstream.id, apiKey!) + } + catch { throw new GatewayError(503, 'CREDENTIAL_UNAVAILABLE') } + delete upstream.apiKeyEnv + } else if (credentialAction === 'clear') { + upstream.credential = null + delete upstream.apiKeyEnv + } + if (index < 0) state.upstreams.push(upstream); else state.upstreams[index] = upstream + return publicUpstream(upstream) + }, true) + } + model(value: unknown, id?: string) { + return this.run(state => { + const index = id === undefined ? -1 : state.models.findIndex(m => m.id === id) + if (id !== undefined && index < 0) throw new GatewayError(404, 'MODEL_NOT_FOUND') + const input = modelInput(value, index >= 0 && record(value).alias === state.models[index].alias) + if (!state.upstreams.some(u => u.id === input.upstreamId)) throw new GatewayError(404, 'UPSTREAM_NOT_FOUND') + if (state.models.some(m => m.alias === input.alias && m.id !== id)) throw new GatewayError(409, 'ALIAS_EXISTS') + const model = { ...input, id: id ?? randomUUID() } + if (index < 0) state.models.push(model); else state.models[index] = model + return model + }, true) + } + headers(id: string): Promise> { + return this.run(async (state): Promise> => { + const upstream = state.upstreams.find(u => u.id === id) + if (!upstream) throw new GatewayError(404, 'UPSTREAM_NOT_FOUND') + if (!upstream.enabled) throw new GatewayError(403, 'UPSTREAM_DISABLED') + if (upstream.authentication === 'none') return { 'content-type': 'application/json' } + let key: string | undefined + try { key = upstream.credential ? await decryptCredential(this.directory, id, upstream.credential) : upstream.apiKeyEnv ? process.env[upstream.apiKeyEnv]?.trim() : undefined } + catch { throw new GatewayError(503, 'CREDENTIAL_UNAVAILABLE') } + if (!key) throw new GatewayError(503, 'CREDENTIAL_NOT_CONFIGURED') + return { 'content-type': 'application/json', authorization: `Bearer ${key}` } + }) + } + accessKeys() { + return this.run(state => ({ accessKeys: state.accessKeys.map(publicAccessKey) })) + } + createAccessKey(value: unknown) { + const input = exact(value, ['name', 'modelIds']) + const name = text(input.name) + return this.run(state => { + if (!Array.isArray(input.modelIds) || !input.modelIds.length || input.modelIds.some(id => typeof id !== 'string' || !state.models.some(m => m.id === id)) || new Set(input.modelIds).size !== input.modelIds.length) throw new GatewayError(400, 'INVALID_MODEL_SCOPE') + const key = `ss_${randomBytes(32).toString('base64url')}` + const accessKey: StoredAccessKey = { id: randomUUID(), name, modelIds: [...input.modelIds] as string[], createdAt: new Date().toISOString(), revokedAt: null, digest: createHash('sha256').update(key).digest('hex') } + state.accessKeys.push(accessKey) + return { accessKey: publicAccessKey(accessKey), key } + }, true) + } + revokeAccessKey(id: string) { + return this.run(state => { + const key = state.accessKeys.find(k => k.id === id) + if (!key) throw new GatewayError(404, 'ACCESS_KEY_NOT_FOUND') + key.revokedAt ??= new Date().toISOString() + return publicAccessKey(key) + }, true) + } + authenticate(authorization?: string) { + if (!authorization || !/^Bearer ss_[A-Za-z0-9_-]{43}$/i.test(authorization)) throw new GatewayError(401, 'INVALID_ACCESS_KEY') + const digest = createHash('sha256').update(authorization.slice(7)).digest() + return this.run(state => { + const key = state.accessKeys.find(k => timingSafeEqual(Buffer.from(k.digest, 'hex'), digest)) + if (!key || key.revokedAt) throw new GatewayError(401, 'INVALID_ACCESS_KEY') + return publicAccessKey(key) + }) + } + audit(audit: GatewayAudit) { + return this.run(state => { state.audits = [audit, ...state.audits].slice(0, 200) }, true) + } +} diff --git a/server/src/gateway.test.ts b/server/src/gateway.test.ts new file mode 100644 index 0000000..3885b01 --- /dev/null +++ b/server/src/gateway.test.ts @@ -0,0 +1,312 @@ +// @vitest-environment node +import { createServer, type Server, type IncomingMessage, type ServerResponse } from 'node:http' +import { serve } from '@hono/node-server' +import { Hono } from 'hono' +import { randomUUID } from 'node:crypto' +import { once } from 'node:events' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createApp } from './app.js' +import { gatewayRoutes } from './gateway-routes.js' +import { GatewayStore } from './gateway-store.js' +import { SharingStore } from './sharing-store.js' +import { withLocalSocket } from './test-socket.js' + +let accessKey: string +const post = (body: unknown, extra: RequestInit = {}): RequestInit => ({ method: 'POST', body: JSON.stringify(body), ...extra, headers: { 'content-type': 'application/json', authorization: `Bearer ${accessKey}`, ...extra.headers } }) +const prefix = '/api/v1/gateway' +const chat = { model: 'local', messages: [{ role: 'user', content: 'private prompt' }] } +describe('local model gateway', () => { + let directory: string + let store: GatewayStore + let app: ReturnType + let server: Server + let baseUrl: string + let calls: { path: string; authorization?: string; body: Record }[] + let handler: (req: IncomingMessage, res: ServerResponse) => void + beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'gateway-test-')) + vi.stubEnv('SHARESERVER_MASTER_KEY', 'ab'.repeat(32)) + vi.stubEnv('SHARESERVER_MASTER_KEY_FILE', '') + vi.stubEnv('SHARESERVER_GATEWAY_BASE_URL', '') + accessKey = '' + store = new GatewayStore(directory) + app = withLocalSocket(createApp(new SharingStore(directory), store)) + calls = [] + handler = (req, res) => { + res.setHeader('content-type', 'application/json') + res.end(JSON.stringify(req.url === '/v1/models' ? { data: [{ id: 'remote' }] } : { + id: 'completion', model: 'remote', choices: [{ message: { role: 'assistant', content: 'private output', tool_calls: [{ id: 'tool', type: 'function', function: { name: 'lookup', arguments: '{}' } }] } }], usage: { prompt_tokens: 7, completion_tokens: 4 } + })) + } + server = createServer(async (req, res) => { + let body = '' + for await (const chunk of req) body += chunk + calls.push({ path: req.url!, authorization: req.headers.authorization, body: body ? JSON.parse(body) : {} }) + handler(req, res) + }) + server.listen(0, '127.0.0.1') + await once(server, 'listening') + const address = server.address() as { port: number } + baseUrl = `http://127.0.0.1:${address.port}/v1` + process.env.GATEWAY_TEST_API_KEY = 'test-only-secret' + }) + afterEach(async () => { + vi.restoreAllMocks() + vi.unstubAllEnvs() + server.closeAllConnections() + await new Promise(resolve => server.close(() => resolve())) + delete process.env.GATEWAY_TEST_API_KEY + delete process.env.GATEWAY_TEST_BASE_URL + delete process.env.GATEWAY_TEST_MODEL + await rm(directory, { recursive: true, force: true }) + }) + async function configure() { + const input = { name: 'Test', baseUrl, protocol: 'openai-chat-completions', authentication: 'api-key', credentialAction: 'replace', apiKey: 'test-only-secret', enabled: true } + const upstream = await (await app.request(`${prefix}/upstreams`, post(input))).json() + const model = await (await app.request(`${prefix}/models`, post({ alias: 'local', upstreamId: upstream.id, remoteModel: 'remote', enabled: true }))).json() + accessKey = (await store.createAccessKey({ name: 'Test client', modelIds: [model.id] })).key + return { upstream, model, input } + } + it('starts empty; persists managed credentials and full updates across restart', async () => { + expect(await (await app.request(`${prefix}/config`)).json()).toEqual({ upstreams: [], models: [], audits: [], gatewayBaseUrl: null }) + const { input } = await configure() + const snapshot = await new GatewayStore(directory).snapshot() + expect(snapshot.upstreams).toHaveLength(1) + expect(snapshot.models).toHaveLength(1) + const u = snapshot.upstreams[0] + expect(u.id).toMatch(/^[a-f0-9-]{36}$/) + const updated = await app.request(`${prefix}/upstreams/${u.id}`, post({ ...input, name: 'Updated', enabled: false }, { method: 'PUT' })) + expect(updated.status).toBe(200) + expect((await new GatewayStore(directory).snapshot()).upstreams[0].enabled).toBe(false) + expect(await readFile(join(directory, 'gateway.json'), 'utf8')).not.toContain('test-only-secret') + expect((await app.request(`${prefix}/import-env`, post({ prefix: 'GATEWAY_TEST' }))).status).toBe(404) + }) + it('fails closed on malformed persisted data without overwriting it', async () => { + for (const data of ['{broken', JSON.stringify({ version: 1, upstreams: [], models: [], audits: [{ prompt: 'bad' }] })]) { + await writeFile(join(directory, 'gateway.json'), data) + const broken = withLocalSocket(new Hono().route(prefix, gatewayRoutes(new GatewayStore(directory)))) + expect((await broken.request(`${prefix}/config`)).status).toBe(503) + expect((await broken.request(`${prefix}/upstreams`, post({ name: 'Test', baseUrl, protocol: 'openai-chat-completions', authentication: 'none', credentialAction: 'clear', enabled: true }))).status).toBe(503) + expect(await readFile(join(directory, 'gateway.json'), 'utf8')).toBe(data) + } + }) + it('discovers real models, maps aliases, preserves tools and records only metadata', async () => { + const { upstream } = await configure() + expect(await (await app.request(`${prefix}/upstreams/${upstream.id}/discover`, { method: 'POST' })).json()).toEqual({ models: ['remote'] }) + expect(await (await app.request(`${prefix}/v1/models`, { headers: { authorization: `Bearer ${accessKey}` } })).json()).toMatchObject({ data: [{ id: 'local' }] }) + const response = await app.request(`${prefix}/v1/chat/completions`, post({ ...chat, tools: [{ type: 'function', function: { name: 'lookup', parameters: { type: 'object' } } }] })) + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ model: 'local', choices: [{ message: { tool_calls: [{ id: 'tool' }] } }] }) + expect(calls[1]).toMatchObject({ authorization: 'Bearer test-only-secret', body: { model: 'remote', tools: [{ type: 'function' }] } }) + const snapshot = await new GatewayStore(directory).snapshot() + expect(snapshot.audits[0]).toMatchObject({ status: 'success', inputTokens: 7, outputTokens: 4 }) + const persisted = await readFile(join(directory, 'gateway.json'), 'utf8') + for (const secret of ['private prompt', 'private output', 'caller-secret', 'test-only-secret']) expect(persisted).not.toContain(secret) + expect(JSON.stringify(snapshot)).not.toContain('test-only-secret') + }) + it('rejects disabled routes, unknown fields, hostile origins and missing runtime credentials', async () => { + const { model, upstream, input } = await configure() + expect((await app.request(`${prefix}/models`, post({ alias: 'local', upstreamId: upstream.id, remoteModel: 'other', enabled: true }))).status).toBe(409) + expect((await app.request(`${prefix}/v1/chat/completions`, post({ ...chat, base_url: 'https://evil.example' }))).status).toBe(400) + const denied = await app.request(`${prefix}/v1/chat/completions`, post(chat, { headers: { 'content-type': 'application/json', origin: 'https://evil.example' } })) + expect(denied.status).toBe(403) + expect(await denied.json()).toMatchObject({ error: { code: 'ORIGIN_DENIED' }, requestId: (await store.snapshot()).audits[0].id }) + expect((await store.snapshot()).audits[0]).toMatchObject({ status: 'failed', httpStatus: 403, errorCode: 'ORIGIN_DENIED' }) + expect((await app.request(`${prefix}/upstreams`, post(input, { headers: { 'content-type': 'application/json', origin: 'null' } }))).status).toBe(403) + expect((await app.request(`${prefix}/v1/chat/completions`, post(chat, { headers: { 'content-type': 'application/json', origin: 'http://localhost:5174' } }))).status).toBe(200) + const { id, ...modelInput } = model + expect((await app.request(`${prefix}/models/${id}`, post({ ...modelInput, enabled: false }, { method: 'PUT' }))).status).toBe(200) + expect((await app.request(`${prefix}/v1/chat/completions`, post(chat))).status).toBe(403) + expect(await (await app.request(`${prefix}/v1/models`, { headers: { authorization: `Bearer ${accessKey}` } })).json()).toMatchObject({ data: [] }) + await app.request(`${prefix}/models/${id}`, post(modelInput, { method: 'PUT' })) + delete process.env.SHARESERVER_MASTER_KEY + expect((await app.request(`${prefix}/v1/chat/completions`, post(chat))).status).toBe(503) + expect(calls).toHaveLength(1) + expect((await store.snapshot()).audits[0]).toMatchObject({ status: 'failed', inputTokens: null, outputTokens: null }) + }) + it('validates URLs and sanitizes upstream HTTP errors and redirects', async () => { + const { input } = await configure() + for (const url of ['https://user:secret@example.com', `${baseUrl}?key=secret`, `${baseUrl}#fragment`, 'file:///tmp/test']) { + expect((await app.request(`${prefix}/upstreams`, post({ ...input, baseUrl: url }))).status).toBe(400) + } + for (const status of [401, 302]) { + handler = (_req, res) => { res.writeHead(status, { location: `${baseUrl}/leak` }); res.end('test-only-secret upstream-sensitive-error') } + const response = await app.request(`${prefix}/v1/chat/completions`, post(chat)) + expect(response.status).toBe(502) + const error = await response.text() + expect(error).not.toContain('test-only-secret') + expect(error).not.toContain(baseUrl) + expect(error).not.toContain('upstream-sensitive-error') + } + expect(calls).toHaveLength(2) + }) + it('streams fragmented SSE tool calls and usage through completion', async () => { + await configure() + handler = (_req, res) => { + res.writeHead(200, { 'content-type': 'text/event-stream' }) + res.write('data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call","function":{"name":"lookup","arguments":"{}"}}]}}]}\r') + setTimeout(() => res.end('\n\r\ndata: {"choices":[],"usage":{"prompt_tokens":3,"completion_tokens":2}}\n\ndata: [DONE]\n\n'), 10) + } + const response = await app.request(`${prefix}/v1/chat/completions`, post({ ...chat, stream: true, stream_options: { include_usage: true } })) + const text = await response.text() + expect(text).toContain('tool_calls') + expect(text).toContain('lookup') + expect(text).toContain('data: [DONE]') + expect((await new GatewayStore(directory).snapshot()).audits[0]).toMatchObject({ status: 'success', stream: true, inputTokens: 3, outputTokens: 2 }) + }) + it('cancels upstream on downstream cancellation and persists cancellation', async () => { + await configure() + let closed!: () => void + const closure = new Promise(resolve => { closed = resolve }) + handler = (_req, res) => { + res.on('close', closed) + res.writeHead(200, { 'content-type': 'text/event-stream' }) + res.write('data: {"choices":[{"delta":{"content":"hello"}}]}\n\n') + } + const response = await app.request(`${prefix}/v1/chat/completions`, post({ ...chat, stream: true })) + const reader = response.body!.getReader() + await reader.read() + await reader.cancel() + await closure + expect((await new GatewayStore(directory).snapshot()).audits[0]).toMatchObject({ status: 'cancelled', httpStatus: 499, inputTokens: null, outputTokens: null }) + }) + it('allows an already started stream to finish after key revocation but rejects subsequent calls', async () => { + await configure() + let complete!: () => void + handler = (_req, res) => { + res.writeHead(200, { 'content-type': 'text/event-stream' }) + res.write('data: {"choices":[{"delta":{"content":"first"}}]}\n\n') + complete = () => res.end('data: [DONE]\n\n') + } + const response = await app.request(`${prefix}/v1/chat/completions`, post({ ...chat, stream: true })) + const reader = response.body!.getReader() + expect(new TextDecoder().decode((await reader.read()).value)).toContain('first') + const issued = await store.authenticate(`Bearer ${accessKey}`) + await store.revokeAccessKey(issued.id) + expect((await app.request(`${prefix}/v1/chat/completions`, post(chat))).status).toBe(401) + complete() + expect(new TextDecoder().decode((await reader.read()).value)).toContain('[DONE]') + expect((await reader.read()).done).toBe(true) + expect((await store.snapshot()).audits[0]).toMatchObject({ status: 'success', accessKeyId: issued.id }) + }) + it('bounds requests and detects truncated streams and timeouts', async () => { + await configure() + expect((await app.request(`${prefix}/v1/chat/completions`, post({ ...chat, messages: [{ role: 'user', content: 'x'.repeat(1024 * 1024) }] }))).status).toBe(413) + handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/event-stream' }); res.end('data: {"choices":[]}\n\n') } + const truncated = await app.request(`${prefix}/v1/chat/completions`, post({ ...chat, stream: true })) + expect(await truncated.text()).toContain('INCOMPLETE_STREAM') + expect((await store.snapshot()).audits[0].status).toBe('failed') + handler = () => undefined + const short = new Hono().route(prefix, gatewayRoutes(store, 50)) + expect((await short.request(`${prefix}/v1/chat/completions`, post(chat))).status).toBe(504) + expect((await store.snapshot()).audits[0].errorCode).toBe('REQUEST_TIMEOUT') + }) + it('bounds upstream responses and streams, without persisting response data', async () => { + await configure() + handler = (_req, res) => { res.setHeader('content-type', 'application/json'); res.end(JSON.stringify({ choices: [], extra: 'x'.repeat(16 * 1024 * 1024) })) } + expect((await app.request(`${prefix}/v1/chat/completions`, post(chat))).status).toBe(502) + handler = (_req, res) => { res.setHeader('content-type', 'text/event-stream'); res.end(`data: ${'x'.repeat(1024 * 1024 + 1)}`) } + const stream = await app.request(`${prefix}/v1/chat/completions`, post({ ...chat, stream: true })) + expect(await stream.text()).toContain('STREAM_TOO_LARGE') + expect((await store.snapshot()).audits.every(a => a.status === 'failed')).toBe(true) + }) + it('persists only the newest 200 audits under concurrent writes', async () => { + await Promise.all(Array.from({ length: 205 }, (_, i) => store.audit({ + id: randomUUID(), time: new Date().toISOString(), model: '', upstreamId: null, accessKeyId: null, stream: false, + status: 'failed', httpStatus: 404, durationMs: i, inputTokens: null, outputTokens: null, errorCode: 'MODEL_NOT_FOUND' + }))) + const snapshot = await new GatewayStore(directory).snapshot() + expect(snapshot.audits).toHaveLength(200) + expect(snapshot.audits[0].durationMs).toBe(204) + expect(snapshot.audits[199].durationMs).toBe(5) + }) + it('propagates a real HTTP client disconnect to the upstream', async () => { + await configure() + let closed!: () => void + const closure = new Promise(resolve => { closed = resolve }) + handler = (_req, res) => { + res.on('close', closed) + res.writeHead(200, { 'content-type': 'text/event-stream' }) + res.write('data: {"choices":[{"delta":{"content":"hello"}}]}\n\n') + } + const gateway = serve({ fetch: app.fetch, port: 0, hostname: '127.0.0.1' }) + await once(gateway, 'listening') + try { + const address = gateway.address() as { port: number } + const controller = new AbortController() + const response = await fetch(`http://127.0.0.1:${address.port}${prefix}/v1/chat/completions`, post({ ...chat, stream: true }, { signal: controller.signal })) + await response.body!.getReader().read() + controller.abort() + await closure + await expect.poll(async () => (await store.snapshot()).audits[0]?.status).toBe('cancelled') + } finally { + (gateway as Server).closeAllConnections() + await new Promise(resolve => gateway.close(() => resolve())) + } + }) + it('times out a stalled SSE stream even without downstream reads', async () => { + await configure() + handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/event-stream' }); res.write('data: {"choices":[]}\n\n') } + const short = new Hono().route(prefix, gatewayRoutes(store, 100)) + const response = await short.request(`${prefix}/v1/chat/completions`, post({ ...chat, stream: true })) + await expect.poll(async () => (await store.snapshot()).audits[0]?.errorCode).toBe('REQUEST_TIMEOUT') + await expect(response.text()).rejects.toThrow('timed out') + }) + it('errors the stream and closes upstream when persisting a successful stream audit fails', async () => { + await configure() + // A directory at the destination makes the real atomic rename fail on all platforms. + await rm(join(directory, 'gateway.json')) + await mkdir(join(directory, 'gateway.json')) + let closed!: () => void + const closure = new Promise(resolve => { closed = resolve }) + handler = (_req, res) => { + res.on('close', closed) + res.writeHead(200, { 'content-type': 'text/event-stream' }) + res.write('data: {"choices":[],"usage":{"prompt_tokens":3,"completion_tokens":2}}\n\ndata: [DONE]\n\n') + } + const response = await app.request(`${prefix}/v1/chat/completions`, post({ ...chat, stream: true })) + expect(response.status).toBe(200) + await expect(response.text()).rejects.toThrow('Gateway stream failed.') + await closure + expect((await store.snapshot()).audits).toEqual([]) + }) + it('still closes upstream when persisting cancellation fails', async () => { + await configure() + await rm(join(directory, 'gateway.json')) + await mkdir(join(directory, 'gateway.json')) + let closed!: () => void + const closure = new Promise(resolve => { closed = resolve }) + handler = (_req, res) => { + res.on('close', closed) + res.writeHead(200, { 'content-type': 'text/event-stream' }) + res.write('data: {"choices":[]}\n\n') + } + const response = await app.request(`${prefix}/v1/chat/completions`, post({ ...chat, stream: true })) + const reader = response.body!.getReader() + await reader.read() + await expect(reader.cancel()).rejects.toMatchObject({ code: 'GATEWAY_STORAGE_UNAVAILABLE' }) + await closure + }) + it('handles abort after upstream headers but before stream startup, including EOF', async () => { + await configure() + const controller = new AbortController() + handler = (_req, res) => { + res.writeHead(200, { 'content-type': 'text/event-stream' }) + res.end('data: [DONE]\n\n') + } + const originalFetch = globalThis.fetch + vi.spyOn(globalThis, 'fetch').mockImplementationOnce(async (...args) => { + const response = await originalFetch(...args) + controller.abort() + return response + }) + const response = await app.request(`${prefix}/v1/chat/completions`, post({ ...chat, stream: true }, { signal: controller.signal })) + await expect(response.text()).rejects.toThrow('Client cancelled.') + expect((await new GatewayStore(directory).snapshot()).audits).toMatchObject([ + { status: 'cancelled', httpStatus: 499, errorCode: 'CLIENT_CANCELLED', inputTokens: null, outputTokens: null } + ]) + }) +}) diff --git a/server/src/sharing.test.ts b/server/src/sharing.test.ts index db30c33..0c9c813 100644 --- a/server/src/sharing.test.ts +++ b/server/src/sharing.test.ts @@ -7,9 +7,12 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { createApp } from './app.js' +import { createApp as createUnboundApp } from './app.js' +import { withLocalSocket } from './test-socket.js' import { SharingStore } from './sharing-store.js' +const createApp = (...args: Parameters) => withLocalSocket(createUnboundApp(...args)) + const device = { id: 'desktop-1', name: 'Local desktop', platform: 'win32', appVersion: '1.0.0' } const publication = { id: 'pub-1', deviceId: device.id, name: 'Local knowledge', kind: 'knowledge', diff --git a/server/src/test-socket.ts b/server/src/test-socket.ts new file mode 100644 index 0000000..c04e717 --- /dev/null +++ b/server/src/test-socket.ts @@ -0,0 +1,12 @@ +import type { Hono } from 'hono' +import type { IncomingMessage } from 'node:http' +import type { SocketBindings } from './admin-boundary.js' + +export const socketBinding = (remoteAddress = '127.0.0.1'): SocketBindings => ({ incoming: { socket: { remoteAddress } } as IncomingMessage }) + +// Inject the same binding read from IncomingMessage in production, never a header. +export function withLocalSocket(app: T): T { + const request = app.request.bind(app) + app.request = (input, init, env, executionCtx) => request(input, init, env ?? socketBinding(), executionCtx) + return app +} diff --git a/shared/gateway.ts b/shared/gateway.ts new file mode 100644 index 0000000..118d6bc --- /dev/null +++ b/shared/gateway.ts @@ -0,0 +1,56 @@ +export type GatewayUpstreamInput = { + name: string + baseUrl: string + protocol: 'openai-chat-completions' + authentication: 'api-key' | 'none' + enabled: boolean + credentialAction: 'keep' | 'replace' | 'clear' + apiKey?: string +} + +export type GatewayUpstream = Omit & { + id: string + credentialConfigured: boolean +} + +export type GatewayModelInput = { + alias: string + upstreamId: string + remoteModel: string + enabled: boolean + displayName?: string +} + +export type GatewayModel = GatewayModelInput & { id: string } + +export type GatewayAccessKey = { + id: string + name: string + modelIds: string[] + createdAt: string + revokedAt: string | null +} +export type GatewayAccessKeyInput = { name: string; modelIds: string[] } +export type GatewayAccessKeyCreated = { accessKey: GatewayAccessKey; key: string } + +export type GatewayAudit = { + id: string + time: string + model: string + upstreamId: string | null + accessKeyId: string | null + stream: boolean + status: 'success' | 'failed' | 'cancelled' + httpStatus: number + durationMs: number + inputTokens: number | null + outputTokens: number | null + errorCode: string | null +} + +export type GatewaySnapshot = { + upstreams: GatewayUpstream[] + models: GatewayModel[] + audits: GatewayAudit[] + gatewayBaseUrl: string | null +} diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index dd484ce..cd6a660 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -1,6 +1,6 @@ import { cleanup, fireEvent, render, screen, within } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { prototypeSnapshot } from '../../shared/prototype-data' +import { pages } from './app-config' import App from './App' describe('ShareServer console', () => { @@ -10,102 +10,98 @@ describe('ShareServer console', () => { }) beforeEach(() => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: async () => prototypeSnapshot - }) - ) + vi.stubGlobal('fetch', vi.fn().mockImplementation(async (url: string) => { + if (url === '/api/v1/gateway/config') { + return { ok: true, json: async () => ({ upstreams: [], models: [], audits: [], gatewayBaseUrl: null }) } + } + if (url === '/api/v1/gateway/access-keys') return { ok: true, json: async () => ({ accessKeys: [] }) } + if (url === '/api/v1/sharing/devices') return { ok: true, json: async () => ({ devices: [] }) } + if (url === '/api/v1/sharing/publications') return { ok: true, json: async () => ({ publications: [] }) } + throw new Error(`Unexpected request: ${url}`) + })) window.history.replaceState({}, '', '/') }) - it('renders the organization overview from the API snapshot', async () => { + it('renders service links without a snapshot request, fabricated metrics or identity', () => { render() - expect(await screen.findByRole('heading', { name: '运行概览' })).toBeInTheDocument() - expect(screen.getByText('Nebula ShareServer')).toBeInTheDocument() - expect(screen.getByText('在线设备')).toBeInTheDocument() - expect(screen.getByRole('navigation', { name: '管理控制台' })).toBeInTheDocument() + expect(screen.getByRole('heading', { name: '运行概览' })).toBeInTheDocument() + const services = screen.getByRole('region', { name: 'GoodBuddy 联动服务' }) + expect(within(services).getByRole('link', { name: /模型网关/ })).toHaveAttribute('href', '/model-gateway') + expect(within(services).getByRole('link', { name: /共享中心/ })).toHaveAttribute('href', '/capabilities') + const nav = screen.getByRole('navigation', { name: '管理控制台' }) + for (const page of pages) expect(within(nav).getByRole('link', { name: page.title })).toHaveAttribute('href', page.path) + expect(within(nav).getByRole('link', { name: '审批' })).toHaveTextContent(/^审批$/) + expect(screen.queryByText(/Nebula|MesaLab|林江|在线设备|任务活动|网络脉冲|示例数据/)).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /通知|搜索|切换到成员|切换到管理/ })).not.toBeInTheDocument() + fireEvent.keyDown(window, { key: 'k', ctrlKey: true }) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(fetch).toHaveBeenCalledWith('/api/v1/sharing/devices', expect.anything()) + expect(fetch).toHaveBeenCalledWith('/api/v1/sharing/publications', expect.anything()) + expect(fetch).toHaveBeenCalledWith('/api/v1/gateway/config', expect.anything()) }) - it('opens global search with the keyboard shortcut', async () => { + it.each(pages.filter((page) => !['/', '/capabilities', '/model-gateway', '/distribution', '/devices'].includes(page.path)))( + 'shows a boundary state without pretend actions at $path', (page) => { + window.history.replaceState({}, '', page.path) + render() + const main = screen.getByRole('main') + expect(within(main).getByRole('heading', { name: page.title })).toBeInTheDocument() + expect(within(main).getByRole('heading', { name: '当前没有可用的业务数据' })).toBeInTheDocument() + expect(within(main).queryByRole('button')).not.toBeInTheDocument() + expect(within(main).getAllByRole('tab').length).toBeGreaterThan(0) + expect(fetch).not.toHaveBeenCalled() + } + ) + + it('preserves theme switching and closes mobile navigation after navigation', () => { render() - await screen.findByRole('heading', { name: '运行概览' }) - window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', ctrlKey: true })) - expect(await screen.findByRole('dialog', { name: '全局搜索' })).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: '切换到浅色主题' })) + expect(document.documentElement).toHaveAttribute('data-theme', 'light') + fireEvent.click(screen.getByRole('button', { name: '切换到深色主题' })) + expect(document.documentElement).toHaveAttribute('data-theme', 'dark') + fireEvent.click(screen.getByRole('button', { name: '打开导航' })) + const navigation = screen.getAllByRole('navigation', { name: '管理控制台' }) + expect(navigation).toHaveLength(2) + fireEvent.click(within(navigation[1]).getByRole('link', { name: '组织与成员' })) + expect(screen.getAllByRole('navigation')).toHaveLength(1) + expect(screen.getByRole('heading', { name: '组织与成员' })).toBeInTheDocument() }) - it('changes list data when navigating between existing console routes', async () => { - render() - await screen.findByRole('heading', { name: '运行概览' }) - fireEvent.click(screen.getByRole('link', { name: 'GoodBuddy 接入' })) - expect(screen.getByText('Jiang-PC')).toBeInTheDocument() - fireEvent.click(screen.getByRole('link', { name: '组织与成员' })) - expect(screen.getByText('jiang@mesalab.internal')).toBeInTheDocument() - expect(screen.queryByText('Jiang-PC')).not.toBeInTheDocument() - }) - - it('retains gateway state across navigation and blocks publishing against a disabled upstream', async () => { + it('preserves distribution and its link to the real gateway', async () => { window.history.replaceState({}, '', '/distribution') render() - await screen.findByRole('heading', { name: '配置下发' }) - fireEvent.click(screen.getByRole('button', { name: '创建配置草稿' })) - fireEvent.change(screen.getByLabelText('版本'), { target: { value: '1.0.0' } }) - fireEvent.click(screen.getByRole('button', { name: '保存草稿' })) - fireEvent.click(screen.getByRole('link', { name: '模型网关' })) - fireEvent.click(screen.getByRole('button', { name: '停用 示例上游' })) - fireEvent.click(screen.getByRole('link', { name: '配置下发' })) - expect(screen.getByRole('button', { name: '模拟发布' })).toBeDisabled() - fireEvent.click(screen.getByRole('link', { name: '模型网关' })) - fireEvent.click(screen.getByRole('button', { name: '启用 示例上游' })) - fireEvent.click(screen.getByRole('link', { name: '配置下发' })) - fireEvent.click(screen.getByRole('button', { name: '模拟发布' })) - expect(screen.getByText('模拟已应用')).toBeInTheDocument() - expect(screen.getByText('离线 · 待下发(模拟)')).toBeInTheDocument() - expect(screen.getByText('已应用:1.0.0 / org-chat · 目标:1.0.0')).toBeInTheDocument() - expect(screen.getByText('已应用:无 · 目标:1.0.0')).toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: '创建配置草稿' })) - fireEvent.change(screen.getByLabelText('版本'), { target: { value: '1.1.0' } }) - fireEvent.change(screen.getByLabelText('默认模型'), { target: { value: 'model-code' } }) - fireEvent.click(screen.getByRole('button', { name: '保存草稿' })) - fireEvent.click(screen.getByRole('button', { name: '模拟发布' })) - expect(screen.getByText('已应用:1.1.0 / org-code · 目标:1.1.0')).toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: '模拟回退到 1.0.0' })) - expect(screen.getByText('已应用:1.0.0 / org-chat · 目标:1.0.0')).toBeInTheDocument() - expect(screen.getByText('已应用:无 · 目标:1.0.0')).toBeInTheDocument() + expect(screen.getByText('尚无配置下发服务')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: '创建配置草稿' })).not.toBeInTheDocument() + expect(screen.queryByRole('combobox')).not.toBeInTheDocument() + expect(screen.queryByText(/org-chat|org-code|工程工作站/)).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('link', { name: '前往模型网关' })) + expect(await screen.findByText(/尚无上游服务/)).toBeInTheDocument() + await screen.findByText(/暂无访问密钥/) + expect(fetch).toHaveBeenCalledTimes(2) + expect(fetch).toHaveBeenCalledWith('/api/v1/gateway/config', expect.anything()) }) - it('removes personal delete authority after document transfer and preserves ownership through restore', async () => { - window.history.replaceState({}, '', '/documents') + it('renders the real device directory', async () => { + vi.mocked(fetch).mockImplementation(async (input: string | URL | Request) => { + const url = input.toString() + if (url === '/api/v1/sharing/devices') return { ok: true, json: async () => ({ devices: [{ id: 'device-1', name: '本机工作站', platform: 'win32', appVersion: '1.0.0', registeredAt: '2026-10-01', updatedAt: '2026-10-02' }] }) } as Response + if (url === '/api/v1/sharing/publications') return { ok: true, json: async () => ({ publications: [] }) } as Response + throw new Error(`Unexpected request: ${url}`) + }) + window.history.replaceState({}, '', '/devices') render() - await screen.findByRole('heading', { name: '文档与协作' }) - fireEvent.click(screen.getByRole('button', { name: '打开模拟 GoodBuddy 客户端' })) - fireEvent.click(screen.getByRole('button', { name: '确认转移给组织' })) - const document = screen.getByRole('heading', { name: '我的项目提案.docx' }).closest('article')! - expect(within(document).getByText('归属:组织:MesaLab')).toBeInTheDocument() - expect(within(document).getByRole('button', { name: '移入回收站' })).toBeDisabled() - fireEvent.click(screen.getByRole('checkbox')) - fireEvent.click(within(document).getByRole('button', { name: '移入回收站' })) - fireEvent.click(screen.getByRole('tab', { name: '回收站' })) - expect(screen.getByText('归属:组织:MesaLab')).toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: '恢复文档' })) - fireEvent.click(screen.getByRole('tab', { name: '组织文档' })) - expect(screen.getByRole('heading', { name: '我的项目提案.docx' })).toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: '打开模拟 GoodBuddy 客户端' })) - expect(screen.queryByRole('button', { name: '删除我的文档' })).not.toBeInTheDocument() + expect(await screen.findByRole('heading', { name: '本机工作站' })).toBeInTheDocument() + expect(screen.getByText('已登记')).toBeInTheDocument() + expect(screen.getByRole('link', { name: '查看共享内容' })).toHaveAttribute('href', '/capabilities?view=local&device=device-1') }) - it('records denied gateway calls and hides administration from member navigation', async () => { - window.history.replaceState({}, '', '/model-gateway') + it('redirects unknown routes to the overview without requesting a snapshot', () => { + window.history.replaceState({}, '', '/missing') render() - await screen.findByRole('heading', { name: '模型网关' }) - fireEvent.click(screen.getByRole('tab', { name: '模型映射 / 访问策略' })) - fireEvent.change(screen.getByLabelText('模拟调用者用户组'), { target: { value: '设计中心' } }) - fireEvent.click(screen.getByRole('button', { name: '模拟调用 org-code' })) - fireEvent.click(screen.getByRole('tab', { name: '调用审计' })) - expect(screen.getByText('模拟拒绝')).toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: /切换到成员自助/ })) - expect(screen.getByRole('heading', { name: '我的设备' })).toBeInTheDocument() - expect(screen.queryByRole('link', { name: '模型网关' })).not.toBeInTheDocument() + expect(screen.getByRole('heading', { name: '运行概览' })).toBeInTheDocument() + expect(window.location.pathname).toBe('/') + expect(fetch).toHaveBeenCalledWith('/api/v1/sharing/devices', expect.anything()) + expect(fetch).toHaveBeenCalledWith('/api/v1/sharing/publications', expect.anything()) + expect(fetch).toHaveBeenCalledWith('/api/v1/gateway/config', expect.anything()) }) }) diff --git a/web/src/App.tsx b/web/src/App.tsx index 3789f36..ff2e0ea 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,132 +1,35 @@ -import { useEffect, useState } from 'react' -import { - BrowserRouter, - Navigate, - Route, - Routes, - useLocation -} from 'react-router-dom' -import type { PrototypeRole, PrototypeSnapshot } from '../../shared/prototype-data' -import { pages, visiblePages } from './app-config' +import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom' +import { pages } from './app-config' import { AppShell } from './components/app-shell' -import { DataPage } from './components/data-page' import { SharingCenter } from './components/sharing-center' import { OverviewPage } from './components/overview-page' -import { SettingsPage } from './components/settings-page' -import { ServiceDemoPage, ServicesDemo } from './components/services-demo' -import { Button } from './components/ui/button' - -async function loadSnapshot(): Promise { - const response = await fetch('/api/v1/web/prototype/snapshot') - if (!response.ok) throw new Error('控制台快照加载失败') - return response.json() as Promise -} - -function LoadingState() { - return ( -
-
-
-

正在连接 ShareServer…

-
-
- ) -} - -function Console({ - snapshot, - role, - onRoleChange -}: { - snapshot: PrototypeSnapshot - role: PrototypeRole - onRoleChange: (role: PrototypeRole) => void -}) { - const location = useLocation() - const allowedPaths = visiblePages(role).map((page) => page.path) - const fallback = role === 'admin' ? '/' : '/me/devices' - - if (!allowedPaths.includes(location.pathname)) { - return - } - - return ( - - - {pages.map((page) => ( - - ) : page.path === '/capabilities' ? ( - - ) : page.path === '/settings' ? ( - - ) : ['/model-gateway', '/documents', '/distribution'].includes(page.path) ? ( - - ) : ( - - ) - } - /> - ))} - } /> - - - ) -} +import { GatewayPage } from './components/gateway-page' +import { DistributionPage } from './components/distribution-page' +import { DevicesPage } from './components/devices-page' +import { UnavailablePage } from './components/unavailable-page' export default function App() { - const [snapshot, setSnapshot] = useState(null) - const [error, setError] = useState(null) - const [reloadKey, setReloadKey] = useState(0) - const [role, setRole] = useState('admin') - - useEffect(() => { - let cancelled = false - loadSnapshot() - .then((data) => { - if (!cancelled) setSnapshot(data) - }) - .catch((reason: unknown) => { - if (!cancelled) { - setError(reason instanceof Error ? reason.message : '控制台加载失败') - } - }) - return () => { - cancelled = true - } - }, [reloadKey]) - - if (error) { - return ( -
-
-

{error}

-

请确认 ShareServer API 已启动,然后重试。

- -
-
- ) - } - - if (!snapshot) return - return ( - - - + + + {pages.map((page) => ( + + : page.path === '/capabilities' ? + : page.path === '/model-gateway' ? + : page.path === '/distribution' ? + : page.path === '/devices' ? + : ({ label, key: dataKey }))} /> + } + /> + ))} + } /> + + ) } diff --git a/web/src/app-config.ts b/web/src/app-config.ts index fe2e356..885bba8 100644 --- a/web/src/app-config.ts +++ b/web/src/app-config.ts @@ -30,6 +30,8 @@ export type PageConfig = { primaryAction?: string group?: string tabs?: Array<{ label: string; dataKey: string }> + boundary?: string + next?: { label: string; to: string } } export const pages: PageConfig[] = [ @@ -38,15 +40,17 @@ export const pages: PageConfig[] = [ title: '运行概览', description: 'GoodBuddy 组织服务、接入状态与待处理事项。', icon: Gauge, - role: 'admin' + role: 'admin', + tabs: [{ label: '服务状态', dataKey: 'status' }, { label: '待处理事项', dataKey: 'pending' }, { label: '最近活动', dataKey: 'activity' }] }, { path: '/model-gateway', title: '模型网关', - description: '接入上游模型服务,管理转发、访问策略和调用审计;ShareServer 不运行模型。', + description: '本地技术预览:配置真实上游与模型别名、测试转发并查看审计;尚无组织认证与配额。', icon: Network, role: 'admin', - group: 'GoodBuddy 服务' + group: 'GoodBuddy 服务', + tabs: [{ label: '概览', dataKey: 'overview' }, { label: '上游服务', dataKey: 'upstreams' }, { label: '模型目录', dataKey: 'models' }, { label: '访问密钥', dataKey: 'keys' }, { label: '请求测试', dataKey: 'test' }, { label: '调用审计', dataKey: 'audits' }, { label: '客户端接入', dataKey: 'client' }] }, { path: '/documents', @@ -54,15 +58,17 @@ export const pages: PageConfig[] = [ description: '文档存储在 ShareServer,在 GoodBuddy 中使用;管理归属、回收站和 OnlyOffice 服务。', icon: FileText, role: 'admin', - group: 'GoodBuddy 服务' + group: 'GoodBuddy 服务', + tabs: [{ label: '文档库', dataKey: 'documents' }, { label: '编辑会话', dataKey: 'sessions' }, { label: 'Office 服务', dataKey: 'office' }, { label: '保存记录', dataKey: 'saves' }] }, { path: '/distribution', title: '配置下发', - description: '向 GoodBuddy 分配网关模型配置,查看版本、生效情况与回滚结果。', + description: '配置分发尚未接入;可先在模型网关配置与测试真实上游。', icon: Send, role: 'admin', - group: 'GoodBuddy 服务' + group: 'GoodBuddy 服务', + tabs: [{ label: '配置草稿', dataKey: 'drafts' }, { label: '设备生效', dataKey: 'devices' }, { label: '发布记录', dataKey: 'releases' }, { label: '回滚记录', dataKey: 'rollbacks' }] }, { path: '/members', @@ -71,7 +77,8 @@ export const pages: PageConfig[] = [ dataKey: 'members', icon: Building2, role: 'admin', - primaryAction: '邀请成员', + group: '组织管理', + boundary: '成员、用户组、角色、登录和组织授权 API 尚未接入。当前不显示成员示例,也不能从此页面发送不会生效的邀请。', tabs: [ { label: '成员', dataKey: 'members' }, { label: '用户组', dataKey: 'groups' }, @@ -85,7 +92,10 @@ export const pages: PageConfig[] = [ dataKey: 'devices', icon: Cpu, role: 'admin', - primaryAction: '生成注册链接' + group: '共享资源', + boundary: '当前可以查看真实登记设备和共享目录元数据。设备在线状态、身份认证、设备撤销和注册链接 API 尚未接入。', + next: { label: '查看真实设备目录', to: '/devices' }, + tabs: [{ label: '设备列表', dataKey: 'list' }, { label: '设备详情', dataKey: 'detail' }, { label: '连接记录', dataKey: 'connections' }, { label: '撤销记录', dataKey: 'revocations' }] }, { path: '/capabilities', @@ -93,7 +103,9 @@ export const pages: PageConfig[] = [ description: '按内容、发布用户或提供设备查看共享能力与知识库。', dataKey: 'capabilities', icon: Boxes, - role: 'admin' + role: 'admin', + group: '共享资源', + tabs: [{ label: '能力目录', dataKey: 'capabilities' }, { label: '共享知识库', dataKey: 'knowledge' }, { label: '按成员查看', dataKey: 'members' }, { label: '按设备查看', dataKey: 'devices' }] }, { path: '/policies', @@ -102,7 +114,9 @@ export const pages: PageConfig[] = [ dataKey: 'policies', icon: ShieldCheck, role: 'admin', - primaryAction: '新建策略' + group: '组织管理', + boundary: '权限策略、Grant、配额和组织授权 API 尚未接入。共享目录中的权限字段目前只是发布者声明,不能在此执行授权决策。', + tabs: [{ label: '强制策略', dataKey: 'mandatory' }, { label: 'Grant', dataKey: 'grants' }, { label: '策略模拟', dataKey: 'simulation' }, { label: '变更记录', dataKey: 'changes' }] }, { path: '/approvals', @@ -110,7 +124,9 @@ export const pages: PageConfig[] = [ description: '处理调用、发布、更新和联邦申请。', dataKey: 'approvals', icon: UserRoundCheck, - role: 'admin' + role: 'admin', + group: '组织管理', + tabs: [{ label: '待处理', dataKey: 'pending' }, { label: '已处理', dataKey: 'processed' }, { label: '我的申请', dataKey: 'mine' }, { label: '审批规则', dataKey: 'rules' }] }, { path: '/releases', @@ -119,7 +135,9 @@ export const pages: PageConfig[] = [ dataKey: 'releases', icon: PackageCheck, role: 'admin', - primaryAction: '上传能力包' + group: '运营与审计', + boundary: '能力包存储、签名校验、版本发布和客户端更新 API 尚未接入。当前不提供上传按钮,避免产生未保存的发布记录。', + tabs: [{ label: '能力包', dataKey: 'packages' }, { label: '发布通道', dataKey: 'channels' }, { label: '更新策略', dataKey: 'policies' }, { label: '撤销记录', dataKey: 'revocations' }] }, { path: '/federation', @@ -128,7 +146,9 @@ export const pages: PageConfig[] = [ dataKey: 'federation', icon: GitFork, role: 'admin', - primaryAction: '建立联邦关系' + group: '运营与审计', + boundary: '联邦握手、双边确认、共享范围和跨网关连接 API 尚未接入。当前不显示虚构的网关关系或连接状态。', + tabs: [{ label: '合作组织', dataKey: 'partners' }, { label: '待确认', dataKey: 'pending' }, { label: '开放范围', dataKey: 'scope' }, { label: '事件记录', dataKey: 'events' }] }, { path: '/tasks', @@ -136,7 +156,9 @@ export const pages: PageConfig[] = [ description: '查看任务路由、终态和可选中继容量。', dataKey: 'tasks', icon: Activity, - role: 'admin' + role: 'admin', + group: '运营与审计', + tabs: [{ label: '任务列表', dataKey: 'tasks' }, { label: '活动任务', dataKey: 'active' }, { label: '中继连接', dataKey: 'relay' }, { label: '运行统计', dataKey: 'stats' }] }, { path: '/audit', @@ -145,14 +167,19 @@ export const pages: PageConfig[] = [ dataKey: 'audit', icon: ScrollText, role: 'admin', - primaryAction: '导出当前结果' + group: '运营与审计', + boundary: '组织操作审计、筛选和导出 API 尚未接入。模型网关的调用元数据仍只能在模型网关页面查看。', + next: { label: '查看模型调用记录', to: '/model-gateway' }, + tabs: [{ label: '事件查询', dataKey: 'events' }, { label: '调用链', dataKey: 'traces' }, { label: '导出任务', dataKey: 'exports' }, { label: '保留策略', dataKey: 'retention' }] }, { path: '/settings', title: '系统设置', description: '查看实例、身份源、基础设施状态与保留策略。', icon: Settings2, - role: 'admin' + role: 'admin', + group: '系统', + tabs: [{ label: '实例信息', dataKey: 'instance' }, { label: '身份与登录', dataKey: 'identity' }, { label: '基础设施', dataKey: 'infrastructure' }, { label: '中继与联邦', dataKey: 'relay' }, { label: '通知', dataKey: 'notifications' }, { label: '保留与审计', dataKey: 'retention' }] }, { path: '/me/devices', @@ -160,7 +187,8 @@ export const pages: PageConfig[] = [ description: '查看你在当前组织注册的设备与连接状态。', dataKey: 'myDevices', icon: CircleUserRound, - role: 'member' + role: 'member', + tabs: [{ label: '我的设备', dataKey: 'devices' }, { label: '我的授权', dataKey: 'grants' }, { label: '我的申请', dataKey: 'requests' }, { label: '我的数字人', dataKey: 'digital-humans' }] }, { path: '/me/grants', @@ -168,7 +196,8 @@ export const pages: PageConfig[] = [ description: '查看你获准发现、调用或下载的能力。', dataKey: 'myGrants', icon: FileKey2, - role: 'member' + role: 'member', + tabs: [{ label: '授权详情', dataKey: 'grants' }, { label: '使用限制', dataKey: 'limits' }, { label: '变更记录', dataKey: 'changes' }] }, { path: '/me/requests', @@ -176,7 +205,8 @@ export const pages: PageConfig[] = [ description: '跟踪你的设备、能力与任务审批。', dataKey: 'myRequests', icon: UserRoundCheck, - role: 'member' + role: 'member', + tabs: [{ label: '全部申请', dataKey: 'all' }, { label: '待处理', dataKey: 'pending' }, { label: '已完成', dataKey: 'completed' }] } ] diff --git a/web/src/components/app-shell.tsx b/web/src/components/app-shell.tsx index d086cc4..7822124 100644 --- a/web/src/components/app-shell.tsx +++ b/web/src/components/app-shell.tsx @@ -1,21 +1,15 @@ import { - Bell, - ChevronsUpDown, - Command, Menu, MoonStar, - Search, Shield, Sun, X } from 'lucide-react' import { useEffect, useState, type ReactNode } from 'react' -import { NavLink, useNavigate } from 'react-router-dom' -import type { PrototypeRole, PrototypeSnapshot } from '../../../shared/prototype-data' -import { visiblePages } from '../app-config' +import { NavLink } from 'react-router-dom' +import { pages } from '../app-config' import { cn } from '../lib/utils' import { Button } from './ui/button' -import { Dialog } from './ui/dialog' function BrandMark() { return ( @@ -27,70 +21,38 @@ function BrandMark() { } export function AppShell({ - snapshot, - role, - onRoleChange, children }: { - snapshot: PrototypeSnapshot - role: PrototypeRole - onRoleChange: (role: PrototypeRole) => void children: ReactNode }) { const [mobileOpen, setMobileOpen] = useState(false) - const [searchOpen, setSearchOpen] = useState(false) const [dark, setDark] = useState(true) - const navigate = useNavigate() - const navigation = visiblePages(role).map((page) => ({ + const groupOrder = ['工作台', 'GoodBuddy 服务', '共享资源', '组织管理', '运营与审计', '系统', '成员自助'] + const navigation = pages.map((page) => ({ ...page, - group: page.group ?? (role === 'member' ? '成员自助' : page.path === '/' ? '概览' : '组织与运维') - })) + group: page.group ?? (page.role === 'member' ? '成员自助' : page.path === '/' ? '工作台' : '组织管理') + })).sort((left, right) => groupOrder.indexOf(left.group) - groupOrder.indexOf(right.group)) useEffect(() => { document.documentElement.dataset.theme = dark ? 'dark' : 'light' }, [dark]) - useEffect(() => { - const handler = (event: KeyboardEvent) => { - if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') { - event.preventDefault() - setSearchOpen(true) - } - } - window.addEventListener('keydown', handler) - return () => window.removeEventListener('keydown', handler) - }, []) - const nav = ( <>

- {snapshot.instance.name} + ShareServer

GoodBuddy 组织服务

-
- -
-
- -
- - -
-

- 输入关键词后,将按当前身份和组织权限显示结果。 -

-
) } diff --git a/web/src/components/devices-page.tsx b/web/src/components/devices-page.tsx new file mode 100644 index 0000000..1dc802c --- /dev/null +++ b/web/src/components/devices-page.tsx @@ -0,0 +1,73 @@ +import { useEffect, useState } from 'react' +import { Link, useSearchParams } from 'react-router-dom' +import type { Device, Publication } from '../../../shared/sharing' +import { sharingRequest } from '../lib/sharing-api' +import { PageHeader } from './page-header' +import { Button } from './ui/button' +import { Dialog } from './ui/dialog' +import { StatusBadge } from './ui/status-badge' +import { PageTabs } from './page-tabs' + +const panel = 'space-y-4 rounded-2xl border border-white/10 bg-[var(--panel)] p-4 sm:p-5' +const muted = 'text-sm leading-6 text-[var(--text-muted)]' +const control = 'mt-1 w-full min-w-0 rounded-lg border border-white/10 bg-[var(--control)] p-2 text-sm text-[var(--text-primary)] focus-visible:outline-2' + +type State = { status: 'loading' } | { status: 'error'; message: string } | { status: 'loaded'; devices: Device[]; publications: Publication[] } + +export function DevicesPage() { + const [params, setParams] = useSearchParams() + const [state, setState] = useState({ status: 'loading' }) + const [attempt, setAttempt] = useState(0) + const [activeTab, setActiveTab] = useState('list') + const selectedId = params.get('device') ?? '' + const query = params.get('q') ?? '' + const selected = state.status === 'loaded' ? state.devices.find((device) => device.id === selectedId) : undefined + + useEffect(() => { + const controller = new AbortController() + Promise.all([ + sharingRequest<{ devices: Device[] }>('/devices', controller.signal), + sharingRequest<{ publications: Publication[] }>('/publications', controller.signal) + ]).then(([devices, publications]) => { + if (!controller.signal.aborted) setState({ status: 'loaded', devices: devices.devices, publications: publications.publications }) + }).catch((error: unknown) => { + if (!controller.signal.aborted) setState({ status: 'error', message: error instanceof Error ? error.message : '网络请求失败' }) + }) + return () => controller.abort() + }, [attempt]) + + function selectDevice(id: string) { + const next = new URLSearchParams(params) + if (id) next.set('device', id) + else next.delete('device') + setParams(next) + } + + return <> + { setState({ status: 'loading' }); setAttempt((value) => value + 1) }} disabled={state.status === 'loading'}>刷新目录} /> + + {activeTab !== 'list' &&

{activeTab === 'detail' ? '设备详情' : activeTab === 'connections' ? '连接记录' : '撤销记录'}

该 Tab 的生产接口尚未接入。当前服务只提供设备登记元数据,暂不显示在线状态、连接心跳或撤销结果。

} + {state.status === 'loading' &&

正在加载登记设备…

} + {state.status === 'error' &&

设备目录加载失败

{state.message}

} + {state.status === 'loaded' && activeTab === 'list' && <> +
+ +

当前登记设备:{state.devices.length} 台。登记记录不代表设备在线。

+
+ {!state.devices.length ?

尚无登记设备

请在 GoodBuddy 的“设备共享(技术预览)”中开启功能并连接此服务。

:
+ {state.devices.filter((device) => [device.name, device.id, device.platform, device.appVersion].join(' ').toLowerCase().includes(query.trim().toLowerCase())).map((device) => { + const count = state.publications.filter((item) => item.deviceId === device.id).length + return
+

{device.name}

{device.platform} · {device.appVersion}

+
设备标识
{device.id}
共享记录
{count} 项
登记时间
最近登记
+
查看共享内容
+
+ })} +
} + {query && !state.devices.some((device) => [device.name, device.id, device.platform, device.appVersion].join(' ').toLowerCase().includes(query.trim().toLowerCase())) &&

没有符合条件的设备

请调整搜索条件。

} + } + { if (!open) selectDevice('') }} title={selected?.name ?? '设备详情'} description="以下信息来自本地登记记录,不代表在线连接或设备所有权。"> + {selected &&
设备标识
{selected.id}
平台
{selected.platform}
应用版本
{selected.appVersion}
登记时间
{selected.registeredAt}
selectDevice('')}>查看此设备的共享内容
} +
+ +} diff --git a/web/src/components/distribution-page.tsx b/web/src/components/distribution-page.tsx new file mode 100644 index 0000000..43688f3 --- /dev/null +++ b/web/src/components/distribution-page.tsx @@ -0,0 +1,13 @@ +import { Link } from 'react-router-dom' +import { PageHeader } from './page-header' + +export function DistributionPage() { + return
+ +
+

尚无配置下发服务

+

目前没有可用的配置草稿、设备生效回执或发布回滚记录。模型网关可独立配置与测试;保存网关模型不会自动下发到 GoodBuddy。

+ 前往模型网关 +
+
+} diff --git a/web/src/components/gateway-access-keys.tsx b/web/src/components/gateway-access-keys.tsx new file mode 100644 index 0000000..ddcaba7 --- /dev/null +++ b/web/src/components/gateway-access-keys.tsx @@ -0,0 +1,106 @@ +import { useEffect, useRef, useState } from 'react' +import type { GatewayAccessKey, GatewayAccessKeyCreated, GatewayAccessKeyInput, GatewayModel } from '../../../shared/gateway' +import { gatewayRequest } from '../lib/gateway-api' +import { Button } from './ui/button' +import { Dialog } from './ui/dialog' + +export function GatewayAccessKeys({ models }: { models: GatewayModel[] }) { + const [keys, setKeys] = useState(null) + const [error, setError] = useState('') + const [busy, setBusy] = useState(true) + const [creating, setCreating] = useState(false) + const [selected, setSelected] = useState([]) + const [created, setCreated] = useState(null) + const [revoking, setRevoking] = useState(null) + const active = useRef(null) + + function load() { + if (active.current) return + const controller = new AbortController() + active.current = controller + return gatewayRequest<{ accessKeys: GatewayAccessKey[] }>('/access-keys', controller.signal).then(data => { + if (!controller.signal.aborted) setKeys(data.accessKeys) + }).catch((cause: unknown) => { + if (!controller.signal.aborted) setError(`访问密钥加载失败:${cause instanceof Error ? cause.message : '请稍后重试。'}`) + }).finally(() => { + if (active.current === controller) active.current = null + if (!controller.signal.aborted) setBusy(false) + }) + } + + async function run(operation: 'create' | 'revoke', input?: GatewayAccessKeyInput, id?: string) { + if (active.current) return + const controller = new AbortController() + active.current = controller + try { + if (operation === 'create') { + const data = await gatewayRequest('/access-keys', controller.signal, 'POST', input) + if (!controller.signal.aborted) { + setKeys(previous => [...(previous ?? []), data.accessKey]); setCreated(data); setCreating(false) + } + } else { + const data = await gatewayRequest(`/access-keys/${encodeURIComponent(id!)}/revoke`, controller.signal, 'POST') + if (!controller.signal.aborted) { + setKeys(previous => previous?.map(key => key.id === data.id ? data : key) ?? []); setRevoking(null) + setCreated(previous => previous?.accessKey.id === data.id ? null : previous) + } + } + } catch (cause) { + if (!controller.signal.aborted) setError(`${{ load: '访问密钥加载', create: '创建访问密钥', revoke: '撤销访问密钥' }[operation]}失败:${cause instanceof Error ? cause.message : '请稍后重试。'}`) + } finally { + if (active.current === controller) active.current = null + if (!controller.signal.aborted) setBusy(false) + } + } + + function start(operation: 'load' | 'create' | 'revoke', input?: GatewayAccessKeyInput, id?: string) { + if (active.current) return + setBusy(true); setError('') + if (operation === 'load') void load() + else void run(operation, input, id) + } + + useEffect(() => { + void load() + return () => { active.current?.abort(); active.current = null } + }, []) + + return
+

访问密钥

+ + +
+

每个密钥只允许调用所选模型。原文仅在创建成功时显示一次,关闭后无法再次查看。撤销后使用该密钥的客户端将无法继续调用。

+ {busy &&

正在处理访问密钥…

} + {error && !creating && !revoking &&

{error}

} + {keys?.length === 0 &&

暂无访问密钥。配置模型后创建。

} + {keys?.map(key =>
+

{key.name}

+

{key.revokedAt ? '已撤销' : '有效'} · 创建于

+

授权模型:{key.modelIds.map(id => models.find(model => model.id === id)?.alias ?? '模型已不可用').join('、')}

+ {key.revokedAt ?

撤销于

: } +
)} + {created && { if (!open) setCreated(null) }}> + + + } + {creating && { if (!open && !busy) { setCreating(false); setError('') } }}> +
{ + event.preventDefault() + const form = new FormData(event.currentTarget) + start('create', { name: String(form.get('name')).trim(), modelIds: selected }) + }}> +
+ +
授权模型(至少选择一个){models.map(model => )}
+
+ {error &&

{error}

} +
+
+
} + {revoking && { if (!open && !busy) { setRevoking(null); setError('') } }}> + {error &&

{error}

} +
+
} +
+} diff --git a/web/src/components/gateway-page.test.tsx b/web/src/components/gateway-page.test.tsx new file mode 100644 index 0000000..a6fa18c --- /dev/null +++ b/web/src/components/gateway-page.test.tsx @@ -0,0 +1,294 @@ +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { GatewayAccessKey, GatewaySnapshot, GatewayUpstream } from '../../../shared/gateway' +import { GatewayPage } from './gateway-page' + +const upstream: GatewayUpstream = { id: 'up-1', name: 'Local provider', baseUrl: 'https://provider.example/v1', protocol: 'openai-chat-completions', authentication: 'api-key', enabled: true, credentialConfigured: true } +const model = { id: 'model-stable-id', alias: 'local-chat', remoteModel: 'remote-chat', upstreamId: upstream.id, enabled: true } +const response = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }) +const failure = () => response({ error: { code: 'CREDENTIAL_UNAVAILABLE', message: 'secret-should-not-echo' }, requestId: 'secret-should-not-echo' }, 503) + +describe('real gateway UI', () => { + let snapshot: GatewaySnapshot + let keys: GatewayAccessKey[] + let fetchMock: ReturnType + beforeEach(() => { + snapshot = { upstreams: [], models: [], audits: [], gatewayBaseUrl: null } + keys = [] + fetchMock = vi.fn(async (url: string, init?: RequestInit) => { + if (url === '/api/v1/gateway/config') return response(snapshot) + const body = init?.body ? JSON.parse(String(init.body)) : null + if (url === '/api/v1/gateway/access-keys') { + if (init?.method === 'POST') { + const accessKey = { ...body, id: 'key-1', createdAt: '2026-10-01T00:00:00.000Z', revokedAt: null } + keys.push(accessKey) + return response({ accessKey, key: 'ss_only-once-secret' }) + } + return response({ accessKeys: keys }) + } + if (url === '/api/v1/gateway/access-keys/key-1/revoke') { + keys = keys.map(key => ({ ...key, revokedAt: '2026-10-02T00:00:00.000Z' })) + return response(keys[0]) + } + if (url === '/api/v1/gateway/upstreams/up-1/discover') return response({ models: ['discovered-chat'] }) + if (url === '/api/v1/gateway/upstreams' || url === '/api/v1/gateway/upstreams/up-1') { + const { apiKey, credentialAction, ...fields } = body + const saved = { ...upstream, ...fields, credentialConfigured: credentialAction === 'clear' ? false : Boolean(apiKey) || (snapshot.upstreams[0]?.credentialConfigured ?? false) } + snapshot = { ...snapshot, upstreams: [saved] } + return response(saved) + } + if (url === '/api/v1/gateway/models' || url === `/api/v1/gateway/models/${model.id}`) { + const saved = { ...model, ...body } + snapshot = { ...snapshot, models: [saved] } + return response(saved) + } + throw new Error(`Unexpected request: ${init?.method} ${url}`) + }) + vi.stubGlobal('fetch', fetchMock) + }) + afterEach(() => { cleanup(); vi.unstubAllGlobals(); vi.restoreAllMocks() }) + + it('loads empty real data and asks for a configured client URL without inventing an origin', async () => { + render() + await screen.findByText(/尚无上游服务/) + await screen.findByText(/暂无访问密钥/) + expect(screen.getByText(/尚无模型映射/)).toBeInTheDocument() + expect(screen.getByText(/SHARESERVER_GATEWAY_BASE_URL/)).toBeInTheDocument() + expect(screen.queryByText(/127\.0\.0\.1:8787|localhost:5174/)).not.toBeInTheDocument() + expect(screen.queryByLabelText(/环境变量/)).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: '发送真实请求' })).toBeDisabled() + expect(fetchMock.mock.calls.map(([url]) => url)).toEqual(['/api/v1/gateway/config', '/api/v1/gateway/access-keys']) + }) + + it('shows the configured baseURL and client protocol, alias and key instructions', async () => { + snapshot = { ...snapshot, upstreams: [upstream], models: [{ ...model, displayName: '聊天模型' }], gatewayBaseUrl: 'https://gateway.example/custom/v1' } + render() + const settings = await screen.findByRole('region', { name: '客户端设置' }) + expect(within(settings).getByText(snapshot.gatewayBaseUrl!)).toBeInTheDocument() + expect(settings).toHaveTextContent('openai-chat-completions') + expect(settings).toHaveTextContent(`模型 ID:${model.alias}`) + expect(settings).toHaveTextContent('API Key 使用新建的访问密钥') + }) + + it('retries load failures with useful Chinese errors without reflecting arbitrary server content', async () => { + fetchMock.mockResolvedValueOnce(failure()) + render() + expect(await screen.findByRole('alert')).toHaveTextContent('HTTP 503 · 服务器无法加密或读取凭据') + expect(document.body).not.toHaveTextContent('secret-should-not-echo') + fireEvent.click(screen.getByRole('button', { name: '重试加载' })) + await screen.findByText(/尚无上游服务/) + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + + it('preserves the last snapshot after refresh failure', async () => { + snapshot.upstreams = [upstream] + render() + await screen.findByText(/暂无访问密钥/) + fetchMock.mockResolvedValueOnce(failure()) + fireEvent.click(screen.getByRole('button', { name: '刷新配置与审计' })) + expect(await screen.findByRole('alert')).toHaveTextContent('当前显示上次读取的数据') + expect(screen.getByRole('heading', { name: upstream.name })).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: '重试加载' })) + await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument()) + }) + + it('creates with a secret, keeps blank edits, and toggles without secrets', async () => { + const storage = vi.spyOn(Storage.prototype, 'setItem') + render() + await screen.findByText(/暂无访问密钥/) + fireEvent.click(screen.getByRole('button', { name: '添加上游' })) + fireEvent.change(screen.getByLabelText('上游名称'), { target: { value: upstream.name } }) + fireEvent.change(screen.getByLabelText('上游 Base URL'), { target: { value: upstream.baseUrl } }) + expect(screen.getByLabelText('上游 API Key')).toHaveAttribute('type', 'password') + fireEvent.change(screen.getByLabelText('上游 API Key'), { target: { value: 'private-upstream-key' } }) + fireEvent.click(screen.getByRole('button', { name: '保存配置' })) + await screen.findByRole('heading', { name: upstream.name }) + const input = { name: upstream.name, baseUrl: upstream.baseUrl, protocol: upstream.protocol, authentication: 'api-key', credentialAction: 'replace', apiKey: 'private-upstream-key', enabled: true } + expect(fetchMock).toHaveBeenCalledWith('/api/v1/gateway/upstreams', expect.objectContaining({ method: 'POST', body: JSON.stringify(input) })) + expect(screen.queryByDisplayValue('private-upstream-key')).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: `编辑上游 ${upstream.name}` })) + expect(screen.getByLabelText('上游 API Key')).toHaveValue('') + fireEvent.click(screen.getByRole('button', { name: '保存配置' })) + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + const keep = { name: upstream.name, baseUrl: upstream.baseUrl, protocol: upstream.protocol, authentication: 'api-key', credentialAction: 'keep', enabled: true } + expect(fetchMock).toHaveBeenCalledWith('/api/v1/gateway/upstreams/up-1', expect.objectContaining({ body: JSON.stringify(keep) })) + fireEvent.click(screen.getByRole('button', { name: `停用上游 ${upstream.name}` })) + await screen.findByRole('button', { name: `启用上游 ${upstream.name}` }) + expect(fetchMock).toHaveBeenCalledWith('/api/v1/gateway/upstreams/up-1', expect.objectContaining({ body: JSON.stringify({ ...keep, enabled: false }) })) + expect(storage).not.toHaveBeenCalled() + }) + + it('clears submitted secrets on errors and permits replacement retry', async () => { + snapshot.upstreams = [upstream] + render() + await screen.findByText(/暂无访问密钥/) + fireEvent.click(screen.getByRole('button', { name: `编辑上游 ${upstream.name}` })) + fireEvent.change(screen.getByLabelText('上游 API Key'), { target: { value: 'secret-should-not-echo' } }) + fetchMock.mockResolvedValueOnce(failure()) + fireEvent.click(screen.getByRole('button', { name: '保存配置' })) + expect(await screen.findByRole('alert')).toHaveTextContent('保存配置失败') + expect(screen.getByLabelText('上游 API Key')).toHaveValue('') + expect(screen.getByLabelText('上游名称')).toHaveValue(upstream.name) + expect(document.body).not.toHaveTextContent('secret-should-not-echo') + fireEvent.change(screen.getByLabelText('上游 API Key'), { target: { value: 'replacement-secret' } }) + fireEvent.click(screen.getByRole('button', { name: '保存配置' })) + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + fireEvent.click(screen.getByRole('button', { name: `编辑上游 ${upstream.name}` })) + expect(screen.getByLabelText('上游 API Key')).toHaveValue('') + }) + + it('allows unauthenticated upstream discovery and testing without configured credentials', async () => { + snapshot.upstreams = [{ ...upstream, authentication: 'none', credentialConfigured: false }] + snapshot.models = [model] + render() + await screen.findByText(/暂无访问密钥/) + expect(screen.getByRole('button', { name: '发送真实请求' })).toBeEnabled() + expect(screen.getByLabelText('测试模型')).toHaveValue(model.alias) + fireEvent.click(screen.getByRole('button', { name: `编辑上游 ${upstream.name}` })) + expect(screen.getByLabelText('认证方式')).toHaveValue('none') + expect(screen.queryByRole('option', { name: '设置或替换凭据' })).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: '取消' })) + fireEvent.click(screen.getByRole('button', { name: '发现模型 Local provider' })) + expect(await screen.findByRole('button', { name: '添加映射 discovered-chat' })).toBeEnabled() + }) + + it('discovers then selects a model and preserves alias and display name through edits and toggle', async () => { + snapshot.upstreams = [upstream] + render() + fireEvent.click(await screen.findByRole('button', { name: '发现模型 Local provider' })) + fireEvent.click(await screen.findByRole('button', { name: '添加映射 discovered-chat' })) + expect(screen.getByLabelText('远端模型名称')).toHaveValue('discovered-chat') + fireEvent.change(screen.getByLabelText('模型别名'), { target: { value: 'discovered-alias' } }) + fireEvent.change(screen.getByLabelText('显示名称(可选)'), { target: { value: '发现的模型' } }) + fireEvent.click(screen.getByRole('button', { name: '保存配置' })) + await screen.findByRole('heading', { name: 'discovered-alias → discovered-chat' }) + const input = { alias: 'discovered-alias', upstreamId: upstream.id, remoteModel: 'discovered-chat', displayName: '发现的模型', enabled: true } + expect(fetchMock).toHaveBeenCalledWith('/api/v1/gateway/models', expect.objectContaining({ method: 'POST', body: JSON.stringify(input) })) + fireEvent.click(screen.getByRole('button', { name: '编辑映射 discovered-alias' })) + expect(screen.getByLabelText('显示名称(可选)')).toHaveValue('发现的模型') + fireEvent.change(screen.getByLabelText('模型别名'), { target: { value: 'edited-alias' } }) + fireEvent.click(screen.getByRole('button', { name: '保存配置' })) + await screen.findByRole('heading', { name: 'edited-alias → discovered-chat' }) + fireEvent.click(screen.getByRole('button', { name: '停用映射 edited-alias' })) + await screen.findByRole('button', { name: '启用映射 edited-alias' }) + expect(fetchMock).toHaveBeenCalledWith(`/api/v1/gateway/models/${model.id}`, expect.objectContaining({ method: 'PUT', body: JSON.stringify({ ...input, alias: 'edited-alias', enabled: false }) })) + }) + + it('allows manual mappings when discovery is unavailable', async () => { + snapshot.upstreams = [{ ...upstream, credentialConfigured: false }] + render() + await screen.findByText(/暂无访问密钥/) + expect(screen.getByRole('button', { name: '发现模型 Local provider' })).toBeDisabled() + fireEvent.click(screen.getByRole('button', { name: '手动添加映射' })) + const alias = screen.getByLabelText('模型别名') + for (const value of ['spaces not allowed', '_invalid', '中文别名']) { + fireEvent.change(alias, { target: { value } }) + expect(alias).toBeInvalid() + } + fireEvent.change(alias, { target: { value: 'provider/model:1.0_test-chat' } }) + expect(alias).toBeValid() + fireEvent.change(screen.getByLabelText('模型别名'), { target: { value: 'manual-alias' } }) + fireEvent.change(screen.getByLabelText('远端模型名称'), { target: { value: 'manual-remote' } }) + fireEvent.click(screen.getByRole('button', { name: '保存配置' })) + await screen.findByRole('heading', { name: 'manual-alias → manual-remote' }) + expect(screen.getByRole('button', { name: '发送真实请求' })).toBeEnabled() + }) + + it('creates scoped keys by stable model IDs, dismisses raw secrets permanently, and revokes with retry', async () => { + const storage = vi.spyOn(Storage.prototype, 'setItem') + snapshot = { ...snapshot, upstreams: [upstream], models: [model] } + const view = render() + await screen.findByText(/暂无访问密钥/) + fireEvent.click(screen.getByRole('button', { name: '创建访问密钥' })) + expect(screen.getByRole('button', { name: '确认创建' })).toBeDisabled() + fireEvent.change(screen.getByLabelText('密钥名称'), { target: { value: 'Desktop' } }) + fireEvent.click(screen.getByRole('checkbox', { name: model.alias })) + fetchMock.mockResolvedValueOnce(failure()) + fireEvent.click(screen.getByRole('button', { name: '确认创建' })) + expect(await screen.findByRole('alert')).toHaveTextContent('创建访问密钥失败') + expect(screen.getByLabelText('密钥名称')).toHaveValue('Desktop') + expect(screen.getByRole('checkbox', { name: model.alias })).toBeChecked() + fireEvent.click(screen.getByRole('button', { name: '确认创建' })) + expect(await screen.findByLabelText('新访问密钥')).toHaveValue('ss_only-once-secret') + expect(fetchMock).toHaveBeenCalledWith('/api/v1/gateway/access-keys', expect.objectContaining({ method: 'POST', body: JSON.stringify({ name: 'Desktop', modelIds: [model.id] }) })) + fireEvent.click(screen.getByRole('button', { name: '已保存,关闭密钥' })) + expect(screen.queryByDisplayValue('ss_only-once-secret')).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: '刷新访问密钥' })) + await waitFor(() => expect(screen.getByRole('button', { name: '刷新访问密钥' })).toBeEnabled()) + expect(screen.queryByLabelText('新访问密钥')).not.toBeInTheDocument() + view.unmount() + snapshot.models = [{ ...model, alias: 'renamed-alias' }] + render() + await screen.findByRole('heading', { name: 'Desktop' }) + expect(screen.getByText('授权模型:renamed-alias')).toBeInTheDocument() + expect(screen.queryByDisplayValue('ss_only-once-secret')).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: '撤销密钥 Desktop' })) + expect(screen.getByRole('dialog')).toHaveTextContent('此操作无法恢复') + fireEvent.click(screen.getByRole('button', { name: '取消' })) + expect(fetchMock.mock.calls.some(([url]) => url.endsWith('/revoke'))).toBe(false) + fireEvent.click(screen.getByRole('button', { name: '撤销密钥 Desktop' })) + fetchMock.mockResolvedValueOnce(failure()) + fireEvent.click(screen.getByRole('button', { name: '确认撤销' })) + expect(await screen.findByRole('alert')).toHaveTextContent('撤销访问密钥失败') + fireEvent.click(screen.getByRole('button', { name: '确认撤销' })) + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + expect(screen.queryByRole('button', { name: '撤销密钥 Desktop' })).not.toBeInTheDocument() + expect(screen.getByText(/已撤销/)).toBeInTheDocument() + expect(fetchMock).toHaveBeenCalledWith('/api/v1/gateway/access-keys/key-1/revoke', expect.objectContaining({ method: 'POST' })) + expect(storage).not.toHaveBeenCalled() + }) + + it('keeps key list failures distinct from an empty list and supports retry', async () => { + fetchMock.mockImplementationOnce(async () => response(snapshot)).mockResolvedValueOnce(failure()) + render() + expect(await screen.findByRole('alert')).toHaveTextContent('访问密钥加载失败') + expect(screen.queryByText(/暂无访问密钥/)).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: '刷新访问密钥' })) + await screen.findByText(/暂无访问密钥/) + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + + it('tests via POST /test nonstreaming and forgets prompt and response after unmount', async () => { + snapshot = { ...snapshot, upstreams: [upstream], models: [model] } + const view = render() + await screen.findByText(/暂无访问密钥/) + fireEvent.change(screen.getByLabelText('测试模型'), { target: { value: model.alias } }) + fireEvent.change(screen.getByLabelText('测试提示词'), { target: { value: 'A private prompt' } }) + fetchMock.mockImplementationOnce(async () => { + snapshot.audits = [{ id: 'audit-1', time: '2026-10-01T00:00:00Z', model: model.alias, upstreamId: upstream.id, accessKeyId: null, stream: false, status: 'success', httpStatus: 200, durationMs: 128, inputTokens: null, outputTokens: 0, errorCode: null }] + return response({ choices: [{ message: { content: 'Actual response text' } }] }) + }) + fireEvent.click(screen.getByRole('button', { name: '发送真实请求' })) + await screen.findByText('Actual response text') + expect(fetchMock).toHaveBeenCalledWith('/api/v1/gateway/test', expect.objectContaining({ method: 'POST', body: JSON.stringify({ model: model.alias, messages: [{ role: 'user', content: 'A private prompt' }], stream: false }) })) + const table = await screen.findByRole('table') + expect(within(table).getByText('未知')).toBeInTheDocument() + expect(within(table).getByText('0')).toBeInTheDocument() + expect(table).not.toHaveTextContent('A private prompt') + view.unmount() + render() + await screen.findByText(/暂无访问密钥/) + expect(screen.getByLabelText('测试提示词')).toHaveValue('') + expect(screen.queryByText('Actual response text')).not.toBeInTheDocument() + }) + + it('cancels requests, ignores late responses, and aborts on unmount', async () => { + snapshot = { ...snapshot, upstreams: [upstream], models: [model] } + const view = render() + await screen.findByText(/暂无访问密钥/) + fireEvent.change(screen.getByLabelText('测试模型'), { target: { value: model.alias } }) + fireEvent.change(screen.getByLabelText('测试提示词'), { target: { value: 'Cancel me' } }) + let signal: AbortSignal | undefined + let resolve!: (value: Response) => void + fetchMock.mockImplementationOnce((_url: string, init: RequestInit) => { signal = init.signal!; return new Promise((done) => { resolve = done }) }) + fireEvent.click(screen.getByRole('button', { name: '发送真实请求' })) + fireEvent.click(screen.getByRole('button', { name: '取消测试' })) + expect(signal?.aborted).toBe(true) + await act(async () => resolve(response({ choices: [{ message: { content: 'Late response' } }] }))) + expect(screen.queryByText('Late response')).not.toBeInTheDocument() + fetchMock.mockImplementationOnce((_url: string, init: RequestInit) => { signal = init.signal!; return new Promise(() => {}) }) + fireEvent.click(screen.getByRole('button', { name: '发送真实请求' })) + view.unmount() + expect(signal?.aborted).toBe(true) + }) +}) diff --git a/web/src/components/gateway-page.tsx b/web/src/components/gateway-page.tsx new file mode 100644 index 0000000..f7ce0b3 --- /dev/null +++ b/web/src/components/gateway-page.tsx @@ -0,0 +1,191 @@ +import { useEffect, useRef, useState } from 'react' +import type { GatewayModel, GatewayModelInput, GatewaySnapshot, GatewayUpstream, GatewayUpstreamInput } from '../../../shared/gateway' +import { PageHeader } from './page-header' +import { Button } from './ui/button' +import { Dialog } from './ui/dialog' +import { StatusBadge } from './ui/status-badge' +import { gatewayRequest as request } from '../lib/gateway-api' +import { GatewayAccessKeys } from './gateway-access-keys' +import { PageTabs } from './page-tabs' +const panel = 'space-y-4 rounded-2xl border border-white/10 bg-[var(--panel)] p-4 sm:p-5' +const muted = 'text-sm leading-6 text-[var(--text-muted)]' +const control = 'mt-1 w-full min-w-0 rounded-lg border border-white/10 bg-[var(--control)] p-2 text-sm text-[var(--text-primary)] focus-visible:outline-2 focus-visible:outline-current' + +type Editor = { kind: 'upstream'; value?: GatewayUpstream } | { kind: 'model'; value?: GatewayModel; upstreamId?: string; remoteModel?: string } + +export function GatewayPage() { + const [snapshot, setSnapshot] = useState(null) + const [loading, setLoading] = useState(true) + const [loadError, setLoadError] = useState('') + const [actionError, setActionError] = useState('') + const [pending, setPending] = useState('') + const [message, setMessage] = useState('') + const [editor, setEditor] = useState(null) + const [authentication, setAuthentication] = useState('api-key') + const [discovery, setDiscovery] = useState<{ upstreamId: string; models: string[] } | null>(null) + const [testing, setTesting] = useState(false) + const [testMessage, setTestMessage] = useState('') + const [testError, setTestError] = useState('') + const [result, setResult] = useState('') + const [testAlias, setTestAlias] = useState('') + const [activeTab, setActiveTab] = useState('overview') + const requests = useRef(new Set()) + const testRequest = useRef(null) + const loadRequest = useRef(null) + const actionRequest = useRef(null) + + function loadConfig() { + loadRequest.current?.abort() + const controller = new AbortController() + loadRequest.current = controller + requests.current.add(controller) + return request('/config', controller.signal).then((data) => { + if (!controller.signal.aborted) { setSnapshot(data); setLoadError('') } + }).catch((error: unknown) => { + if (!controller.signal.aborted) setLoadError(`网关配置加载失败:${error instanceof Error ? error.message : '网络请求失败'}`) + }).finally(() => { + requests.current.delete(controller) + if (!controller.signal.aborted) setLoading(false) + }) + } + + function refresh() { + setLoading(true) + return loadConfig() + } + + useEffect(() => { + void loadConfig() + const active = requests.current + return () => { active.forEach((controller) => controller.abort()) } + }, []) + + async function act(label: string, path: string, method: string, body?: unknown, discoverId?: string) { + if (actionRequest.current) return + const controller = new AbortController() + actionRequest.current = controller + requests.current.add(controller) + setPending(label); setActionError(''); setMessage('') + try { + const data = await request(path, controller.signal, method, body) + if (controller.signal.aborted) return + if (discoverId) setDiscovery({ upstreamId: discoverId, models: (data as { models: string[] }).models }) + else { + setEditor(null) + await refresh() + } + setMessage(`${label}完成。${discoverId ? '请选择模型添加映射,也可手动填写。' : '配置已保存到服务器。'}`) + } catch (error) { + if (!controller.signal.aborted) setActionError(`${label}失败:${error instanceof Error ? error.message : '网络请求失败'}`) + } finally { + requests.current.delete(controller) + actionRequest.current = null + if (!controller.signal.aborted) setPending('') + } + } + + async function test(alias: string, prompt: string) { + if (testRequest.current) return + const controller = new AbortController() + testRequest.current = controller + requests.current.add(controller) + setTesting(true); setResult(''); setTestError(''); setTestMessage('正在请求上游…') + try { + const data = await request<{ choices?: { message?: { content?: string | null } }[] }>('/test', controller.signal, 'POST', { model: alias, messages: [{ role: 'user', content: prompt }], stream: false }) + if (!controller.signal.aborted) { + setResult(data.choices?.[0]?.message?.content ?? '上游未返回文本内容。') + setTestMessage('请求完成。') + } + } catch (error) { + if (!controller.signal.aborted) setTestError(`测试失败:${error instanceof Error ? error.message : '网络请求失败'}`) + if (!controller.signal.aborted) setTestMessage('') + } finally { + requests.current.delete(controller) + if (testRequest.current === controller) testRequest.current = null + if (!controller.signal.aborted) { setTesting(false); void refresh() } + } + } + + const busy = loading || Boolean(pending) + const available = snapshot?.models.filter((model) => model.enabled && snapshot.upstreams.some((upstream) => upstream.id === model.upstreamId && upstream.enabled)) ?? [] + return
+ { setActionError(''); setAuthentication('api-key'); setEditor({ kind: 'upstream' }) }}>添加上游} /> + + {loading &&

正在加载网关配置…

} + {loadError &&

{loadError}{snapshot ? '。当前显示上次读取的数据。' : ''}

} + {actionError && !editor &&

{actionError}

} + {pending &&

{pending}中…

} + {message &&

{message}

} + {snapshot && <> + +

网关概览

上游服务

{snapshot.upstreams.length}

模型映射

{snapshot.models.length}

调用记录

{snapshot.audits.length}

客户端地址

{snapshot.gatewayBaseUrl ?? '未配置'}

客户端通过访问密钥调用已授权模型;上游凭据只保存在服务器,网关不执行模型返回的工具调用。

+

上游服务

+ {!snapshot.upstreams.length &&

尚无上游服务。添加上游后,再配置模型映射。

} + {snapshot.upstreams.map((upstream) =>
+

{upstream.name}

+

{upstream.baseUrl}

OpenAI Chat Completions · {upstream.authentication === 'none' ? '无需认证' : upstream.credentialConfigured ? '凭据已配置' : '凭据未配置'}(不代表连接已验证)

+
+
)} + {discovery &&

发现结果 · {snapshot.upstreams.find((item) => item.id === discovery.upstreamId)?.name}

{!discovery.models.length &&

上游未返回模型,可手动添加映射。

}
{discovery.models.map((remoteModel) => )}
} +
+

模型映射

+ {!snapshot.models.length &&

尚无模型映射。先添加上游,再发现模型或手动填写远端模型名称与别名。

} + {snapshot.models.map((model) =>

{model.alias} → {model.remoteModel}

{model.displayName &&

{model.displayName}

}

上游:{snapshot.upstreams.find((item) => item.id === model.upstreamId)?.name ?? model.upstreamId} · {model.enabled ? '映射已启用' : '映射已停用'}

)} +
+
+

客户端设置

+

在 GoodBuddy 或兼容客户端中添加服务:协议选择 OpenAI Chat Completions(openai-chat-completions)。

+ {snapshot.gatewayBaseUrl ?

baseURL:{snapshot.gatewayBaseUrl}

:

尚未配置客户端 baseURL。请管理员设置 SHARESERVER_GATEWAY_BASE_URL 为客户端可达的完整网关 API 基础地址并重启服务。

} +

模型 ID 填写下方模型别名,不填远端模型名称或显示名称。API Key 使用新建的访问密钥,并确保它已授权该模型。

+
    {snapshot.models.map(model =>
  • 模型 ID:{model.alias}{model.displayName ? ` · ${model.displayName}` : ''}
  • )}
+
+

真实请求测试

选择模型并发送一条真实请求。提示词和结果仅保留在当前页面,失败后可以直接修改并重试。

+
{ event.preventDefault(); const form = new FormData(event.currentTarget); void test(String(form.get('alias')), String(form.get('prompt'))) }}> +