OpenStatus 服务端架构解析:Hono on Deno 的四面路由、API Key 双层权限与 MCP 作用域门控
【免费下载链接】openstatus🫖 Status page with uptime monitoring & API monitoring as code 🫖项目地址: https://gitcode.com/GitHub_Trending/op/openstatus
OpenStatus 的 API 服务端(apps/server)采用 Hono 框架运行在 Deno 之上,在apps/server/src/routes/下划分出v1(公开 REST)、rpc(ConnectRPC)、mcp与slack四个路由面。本文以 apps/server/AGENTS.md 为骨架,结合源码与测试,完整解析这套服务端的设计约定:API Key 的read/write双层权限如何落地、路由配置为何要"一次解析并注入"、MCP 工具如何按作用域注册并对只读 Key 隐藏写工具。读完后,你将掌握在 OpenStatus 中新增端点、迁移服务层、接入 Slack 路由与扩展 MCP 工具时的全部规范与原理。
一、服务端总览:Hono on Deno 与四个路由面
apps/server是一个典型的"Hono 跑在 Deno 上"的 API 服务。入口在 apps/server/src/index.ts,由 apps/server/src/serve.ts 通过Deno.serve({ port: 3000 }, app.fetch)启动(端口固定为 3000,见 serve.ts)。
应用在根路由上挂载了如下表面(见 index.ts):
| 路由前缀 | 表面 | 说明 |
|---|---|---|
/v1 | 公开 REST | 面向第三方 API 的历史版本 REST 接口,使用 OpenAPIHono 构建,支持x-openstatus-key认证(见 v1/index.ts) |
/(ConnectRPC) | ConnectRPC | 通过mountRpcRoutes(app)挂载,承载各领域 handler(monitor、notification、status-report 等) |
/mcp | MCP Server | Model Context Protocol 服务,Streamable HTTP 传输,供 AI 客户端调用(见 index.ts) |
/slack | Slack 应用 | Slack 命令、事件、交互与 OAuth 回调(见 index.ts) |
此外还有/public(公开状态页接口)、/上的 OAuth 授权服务器(RFC 8414/9728)与健康检查路由。从源码结构看,四个主要路由面共享同一套认证与权限基础设施——这正是本文后半部分要展开的核心。
二、API Key 权限体系:read / write 双层强制
AGENTS.md 开篇即强调:两层机制共同强制read/write权限,缺一不可。第一层在服务层(service level),第二层在传输层(transport level)。
2.1 服务层门控:requireScope(ctx, "write")
凡是经由@openstatus/services路由的服务动词,第一行必须是requireScope(ctx, "write")(或对应的read)。这是真正意义上的"闸门"。
实现位于 packages/services/src/auth/require-scope.ts。核心逻辑:
- 只有
apiKey与mcp两类 actor 会被检查(对应ServiceContext.actor类型,见 packages/services/src/context.ts); user(Dashboard 会话)与system、slack、webhook、subscriberactor 是no-op——它们已经通过了各自的信任边界(member 角色权限是独立项目);- 权限不匹配时抛
ForbiddenError,并输出一条含keyId、userId、workspaceId、所需与持有 scope 的告警日志,便于泄漏 Key 的应急溯源; - 空 scopes 列表
fail-closed:任何权限都不放行。
源码注释还给出一个重要约定:把requireScope放在写动词的第一行、withTransaction之前——权限检查不应打开一个事务再回滚,而且该检查没有数据库依赖。
与之配套的纯函数匹配器是 packages/services/src/auth/matches-scope.ts,其权限层级为:
'*' ⊇ 'write' ⊇ 'read'即持有write的 Key 天然满足read要求;*(超级管理员)满足一切;任何无法识别的 scope 字符串一律不匹配(fail-closed)。该匹配器"前向兼容":未来引入monitor.write之类的资源级 scope 时只需原地扩展,无需改动require-scope.ts。
packages/services/src/auth/tests/require-scope.test.ts 用 12 个用例覆盖了全部行为:只读 Key 请求write抛错、请求read放行、*通吃、mcp actor 与 apiKey 同等约束、user/system/slack/webhook/subscriber 全为 no-op、空 scopes fail-closed。
2.2 传输层门控:requireWriteScope()与 V1 的历史债
第二层是 apps/server/src/libs/middlewares/require-scope.ts 中的requireWriteScope()中间件,它被挂载在 V1 路由器上、紧随authMiddleware之后(见 v1/index.ts):
api.use("/*", authMiddleware); // V1 路由使用内联 Drizzle 查询而非 @openstatus/services, // 因此服务层 requireScope 不会执行;此中间件补上缺口, // 迁移完成后仍作为纵深防御保留。 api.use("/*", requireWriteScope());为什么 V1 需要传输层检查?AGENTS.md 说得非常清楚:V1 先于 services 约定存在,至今仍直接内联 Drizzle 查询,服务层的requireScope会完全跳过它的写处理器。该中间件按 HTTP 方法映射 scope:GET/HEAD视为 read,其余方法一律视为 write。
这种映射对 V1 是精确的,因为每个 V1 写路由都是POST/PUT/PATCH/DELETE,每个读路由都是GET/HEAD。尤其要注意POST /v1/check(按需探测)也属于 write——按计划文档的写规则定义,POST 即为写。文件头注释还特别警告:如果给 V1 新增一个通过GET完成变更的路由,会静默绕过此中间件。
实现细节值得逐一推敲:
- 对
GET/HEAD直接next(),不查 Key; - 若
apiKey缺失则放行,把 401 的判定留给上游authMiddleware,避免"本应是 401 的请求被误判为 403"; const scopes = apiKey.scopes ?? []是"双保险":即使authMiddleware部分填充了apiKey,也会 fail-closed(无 scopes 即无写权限)而不是在.includes上崩溃;- 只有同时不持有
write与*时才抛 403"API key lacks write scope"。
2.3 scope 从哪来:validateKey与三类凭证
authMiddleware(apps/server/src/libs/middlewares/auth.ts)调用validateKey解析凭证并得到 scopes。从 auth.ts 可以归纳出 scope 的完整来源:
| 凭证类型 | 触发条件 | scopes | 说明 |
|---|---|---|---|
| OAuth Access Token | 以oat_为 keyId 的授权 | 来自 grant | 本地 OAuth 流程走通的关键,审计归因到 grant |
| 自定义 Key | os_前缀,先查本地 DB | 行的scopes列 | 验证哈希、检查过期、best-effort 更新lastUsedAt |
| Unkey Key | os_前缀但本地 DB 无记录 | ["write"] | 历史遗留姿态:Unkey 时代 minted 的 Key 保持完整工作区访问权 |
| 超级管理员 | sa_前缀且等于SUPER_ADMIN_TOKEN | ["*"] | *仅内部可用,任何公开 API 都无法设置 |
| 开发环境 | 非 production | ["*"] | 本地测试镜像超级管理员,避免被 scope 检查误锁 |
其中c.set("apiKey", ...)总是填充apiKey(缺 keyId 时回退到工作区级占位符ws:<id>并打告警),保证下游适配器可以无差别访问。
2.4 迁移约束:oxlint 禁止直连数据库
AGENTS.md 指出:新端点应调用服务动词,而不是直接查询 Drizzle,并且oxlint.config.ts已对已迁移域名的 handler 禁用@openstatus/db与drizzle-orm的导入,"这个名单每个 PR 增长一个域名"。
oxlint.config.ts 中的no-restricted-imports规则印证了这一点:packages/api/src/router/下的 statusReport、maintenance、incident、monitor、page 等,以及apps/server/src/routes/rpc/handlers/下的 health、status-report、maintenance、notification 和slack/interactions.ts都在禁用名单内,报错信息统一为 "Use @openstatus/services instead"。同时用excludeFiles放行测试文件与少数仍直读 DB 的实现(如 notification 的limits.ts、converters.ts),并注释说明了豁免原因。
三、路由配置模式:一次解析,注入传递
AGENTS.md 的第二条约定是"配置只解析一次并传入路由",标杆示例是 apps/server/src/routes/slack/index.ts 的createSlackRoute(config):
export function createSlackRoute(config: SlackConfig) { const slack = new Hono<SlackEnv>(); slack.use("*", async (c, next) => { c.set("slackConfig", config); if (!config.signingSecret || !config.aiGatewayApiKey) { return c.json({ error: "Slack agent not configured" }, 503); } await next(); }); // GET /install、GET /oauth/callback、POST /events|/interactions|/commands return slack; } // 生产环境:模块作用域一次解析 export const slackRoute = createSlackRoute(slackConfigFromEnv());slackConfigFromEnv()定义在 apps/server/src/routes/slack/config.ts,把SLACK_SIGNING_SECRET、SLACK_CLIENT_ID、SLACK_CLIENT_SECRET、SLACK_REDIRECT_URI、AI_GATEWAY_API_KEY等环境变量一次性收敛为SlackConfig对象,dashboardUrl还会按NODE_ENV区分生产(https://app.openstatus.dev)与本地(http://localhost:3000)。SlackConfig的全部字段可见 config.ts。
这套"依赖注入式"设计的动机来自一个真实的测试痛点:deno test --parallel下,worker 共享同一进程环境,各测试文件并发修改process.env会互相覆盖。因此 handler 一律从请求上下文读取配置,而不是在请求时读取env——测试可以构造一个显式配置的路由,彻底避开全局环境变量污染。生产则退化为"模块加载时解析一次"的简单形式。
四、MCP:按作用域注册工具,只读 Key 看不到写工具
4.1 路由与传输
MCP 表面挂在/mcp,使用@hono/mcp的StreamableHTTPTransport(见 apps/server/src/routes/mcp/index.ts)。它同时是 RFC 9728 的 OAuth 保护资源:每次请求都经authMiddleware,匿名请求返回带WWW-Authenticate的 401——这个 401 不是失败,而是发现机制:MCP 客户端先无凭证initialize,靠 401 挑战获知授权服务器地址,进而走 OAuth 握手;如果匿名应答握手,客户端会永远停留在"已连接但未认证"的状态,既学不到 OAuth 的存在,也看不到任何工具。
每个请求构建一个全新的McpServer(apps/server/src/routes/mcp/server.ts),工具注册时通过闭包捕获本次请求的ServiceContext,从而在结构上保证工作区隔离。工具列表为静态(listChanged: false),资源是公开文档。
4.2 核心机制:registerScopedTool
AGENTS.md 的第三条约定:MCP 工具声明scope: 'read' | 'write',并通过registerScopedTool注册,这样只读 Key 在tools/list里永远不会看到写工具;用普通方式注册的工具则无论 Key 是什么都会泄露。
实现位于 apps/server/src/routes/mcp/tools/register-scoped.ts。要点:
- 每个工具定义多了一个必填字段
scope(见 register-scoped.ts); - 注册前用
matchesScope(actorScopes(ctx), def.scope)过滤:不满足直接返回undefined,工具根本不会注册进 server——过滤是"UX",底层服务动词的requireScope抛ForbiddenError才是"正确性"; actorScopes对非 Key actor(防御纵深)返回["*"];- 注册时校验
scope与annotations.readOnlyHint的一致性:若写工具声明readOnlyHint: true,会在注册阶段直接抛错,防止对 MCP 客户端(Claude Desktop、Cursor 等)谎报"可安全调用"; - 返回
RegisteredTool | undefined,调用方可以维护Map<name, RegisteredTool>供测试/调试。
apps/server/src/routes/mcp/tools/register-scoped.test.ts 的 5 个用例覆盖:满足作用域时注册、只读时返回undefined、readOnlyHint与 scope 冲突双向抛错、省略 hint 时不触发校验。
4.3 实际工具定义与工具族
工具定义存放在packages/services/src/agent-tools/,以 maintenance.ts 中的create_maintenance为例:
export const createMaintenanceTool: AgentTool<...> = { name: "create_maintenance", description: "Schedule a maintenance window on a status page. PUBLIC, AUDIT-LOGGED, AND POTENTIALLY NOTIFIES SUBSCRIBERS — irreversible side effects. ...", scope: "write", destructive: true, inputSchema: CreateMaintenanceInputShape, outputSchema: CreateMaintenanceOutput, approval: { extraFlags: [{ id: "notify", label: "Notify subscribers" }], applyFlags: (input, flags) => ({ ...input, notify: flags.notify ?? false }), summarize: (input) => ({ title: `Schedule Maintenance: ${input.title}`, ... }), verb: "scheduled", }, async run({ ctx, input }) { /* 调用 @openstatus/services 的 createMaintenance */ }, };注意几个与权限强相关的设计:副作用型工具(发通知、不可逆操作)都标记destructive: true并带approval摘要与确认 flag;run内部调用createMaintenance服务动词——服务层的requireScope(ctx, "write")会再次把关,形成"注册过滤(UX)+ 服务层检查(正确性)"的双保险。
各工具族通过registerRegistryTools(见 apps/server/src/routes/mcp/tools/monitor.ts)统一接入,当前注册域包括 page、status-report、maintenance、monitor、notification、private-location、audit、content(见 server.ts)。从agent-tools的 scope 声明分布(monitor 族 6 个全为read,maintenance 与 status-report 各含write)可以推断:只读 Key 的tools/list将只看到查询类工具,而创建维护窗口、发布状态报告等写工具会被整体隐藏。
五、把约定变成习惯:新增端点时的检查清单
综合 AGENTS.md 与源码,在 OpenStatus 服务端新增端点或扩展工具时应遵循以下顺序:
- 走服务层,不走 Drizzle:新端点调用
@openstatus/services的服务动词;若域名尚未迁移,先迁移并在 oxlint.config.ts 的no-restricted-imports名单中登记该文件。 - 写动词第一行调用
requireScope(ctx, "write"),放在withTransaction之前;读动词如需要同样显式声明read。 - V1 新增路由需自证方法语义:写操作必须使用
POST/PUT/PATCH/DELETE,因为传输层的requireWriteScope()以方法映射 scope;用GET做变更会静默绕过。 - 路由配置一次解析并注入:仿照
createSlackRoute(config)+slackConfigFromEnv(),让 handler 从c.get("slackConfig")之类上下文读取配置,保证deno test --parallel下可测。 - MCP 工具一律
registerScopedTool:声明scope,保持readOnlyHint与 scope 一致,副作用工具标注destructive并提供approval摘要;切勿用 SDK 裸registerTool注册,否则会向只读 Key 泄露写工具。
六、小结
OpenStatus 的apps/server用一套小而清晰的约定,同时管理了四个路由面的认证、授权与可测试性:服务层requireScope与传输层requireWriteScope双闸互补(前者覆盖已迁移域,后者兜底仍内联 Drizzle 的 V1);createSlackRoute模式确立了"配置一次解析、上下文注入"的测试友好范式;registerScopedTool把 MCP 的tools/list变成权限的忠实投影。这三条约定从 apps/server/AGENTS.md 出发、在源码与测试中逐行落地,是阅读和扩展 OpenStatus 服务端最值得先掌握的地图。
【免费下载链接】openstatus🫖 Status page with uptime monitoring & API monitoring as code 🫖项目地址: https://gitcode.com/GitHub_Trending/op/openstatus
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考