news 2026/9/16 10:40:10

OpenStatus 服务端架构解析:Hono on Deno 的四面路由、API Key 双层权限与 MCP 作用域门控

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
OpenStatus 服务端架构解析:Hono on Deno 的四面路由、API Key 双层权限与 MCP 作用域门控

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)、mcpslack四个路由面。本文以 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 等)
/mcpMCP ServerModel Context Protocol 服务,Streamable HTTP 传输,供 AI 客户端调用(见 index.ts)
/slackSlack 应用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。核心逻辑:

  • 只有apiKeymcp两类 actor 会被检查(对应ServiceContext.actor类型,见 packages/services/src/context.ts);
  • user(Dashboard 会话)与systemslackwebhooksubscriberactor 是no-op——它们已经通过了各自的信任边界(member 角色权限是独立项目);
  • 权限不匹配时抛ForbiddenError,并输出一条含keyIduserIdworkspaceId、所需与持有 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 Tokenoat_为 keyId 的授权来自 grant本地 OAuth 流程走通的关键,审计归因到 grant
自定义 Keyos_前缀,先查本地 DB行的scopes验证哈希、检查过期、best-effort 更新lastUsedAt
Unkey Keyos_前缀但本地 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/dbdrizzle-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.tsconverters.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_SECRETSLACK_CLIENT_IDSLACK_CLIENT_SECRETSLACK_REDIRECT_URIAI_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/mcpStreamableHTTPTransport(见 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",底层服务动词的requireScopeForbiddenError才是"正确性";
  • actorScopes对非 Key actor(防御纵深)返回["*"]
  • 注册时校验scopeannotations.readOnlyHint的一致性:若写工具声明readOnlyHint: true,会在注册阶段直接抛错,防止对 MCP 客户端(Claude Desktop、Cursor 等)谎报"可安全调用";
  • 返回RegisteredTool | undefined,调用方可以维护Map<name, RegisteredTool>供测试/调试。

apps/server/src/routes/mcp/tools/register-scoped.test.ts 的 5 个用例覆盖:满足作用域时注册、只读时返回undefinedreadOnlyHint与 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 服务端新增端点或扩展工具时应遵循以下顺序:

  1. 走服务层,不走 Drizzle:新端点调用@openstatus/services的服务动词;若域名尚未迁移,先迁移并在 oxlint.config.ts 的no-restricted-imports名单中登记该文件。
  2. 写动词第一行调用requireScope(ctx, "write"),放在withTransaction之前;读动词如需要同样显式声明read
  3. V1 新增路由需自证方法语义:写操作必须使用POST/PUT/PATCH/DELETE,因为传输层的requireWriteScope()以方法映射 scope;用GET做变更会静默绕过。
  4. 路由配置一次解析并注入:仿照createSlackRoute(config)+slackConfigFromEnv(),让 handler 从c.get("slackConfig")之类上下文读取配置,保证deno test --parallel下可测。
  5. 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),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/16 10:39:30

PTP硬件时间戳全链路解析:从网卡驱动到Linux内核实现

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/16 10:38:46

Java实现JSON转Excel嵌入式流水线工具

简介&#xff1a;这是一款面向Web开发与数据分析师的JSON/HTML/网络抓包三合一Excel导出工具&#xff0c;解决多源异构数据&#xff08;如API返回JSON、网页HTML结构、HTTP通信包&#xff09;难以直观分析与存档的痛点。资源为Java编写的桌面应用工程&#xff0c;共21个文件&am…

作者头像 李华
网站建设 2026/9/16 10:38:44

AS5048A与R7KA8D2KFLCAC磁角度传感方案实战指南

1. 项目概述&#xff1a;为什么磁角度传感需要“终极”方案&#xff1f;AS5048A 和 R7KA8D2KFLCAC 这两个器件组合&#xff0c;乍看像是一组陌生的型号代号&#xff0c;但拆开来看&#xff0c;它们各自代表了当前高精度磁角度传感领域里最成熟、最可靠、也最容易被低估的两类核…

作者头像 李华
网站建设 2026/9/16 10:37:51

COMSOL三次谐波仿真技巧与避坑指南

1. COMSOL三次谐波仿真实战指南作为一名长期与COMSOL斗智斗勇的光学仿真工程师&#xff0c;我深刻理解在非线性光学仿真中&#xff0c;三次谐波生成&#xff08;Third Harmonic Generation, THG&#xff09;这个磨人的小妖精有多难伺候。今天我就把自己在THG仿真中踩过的坑、总…

作者头像 李华
网站建设 2026/9/16 10:36:39

Django Web开发全流程实战:从零到一

1. 项目概述"从零到一&#xff1a;Django Web开发全流程实战"是一个面向初学者的完整Django开发教程。作为Python生态中最流行的Web框架&#xff0c;Django以其"开箱即用"的特性著称&#xff0c;但新手在实际开发中仍会遇到各种环境配置、项目结构设计和功…

作者头像 李华
网站建设 2026/9/16 10:33:36

STM32 RS485从机实现:MODBUS RTU协议栈与自动收发控制

简介&#xff1a;本资源是面向嵌入式开发工程师与STM32初学者的MODBUS RTU协议实战项目&#xff0c;聚焦RS485工业通信场景下STM32作为从机的完整实现方案。资源提供可直接编译运行的Keil工程&#xff08;含uvprojx/uvoptx配置&#xff09;&#xff0c;涵盖GPIO方向控制、USART…

作者头像 李华