Webhook:thin、签名、限定范围

更新于 · 在 sijie.xyz 查看原条目 ↗

状态: 已在 v0.1.76 发布(2026-09-27)—— 设计与落地记录见 StandMeet 仓库的 docs/design/event-bus-outbox-webhooks.md。

Webhook 就是“出实例的订阅方”。投递是 owner 域里的两种任务(internal/owner/subscriber/webhooks.go):

  • webhook.fanout 订阅所有 Webhook 可见的类型。它读出 owner 启用的端点,匹配类型通配,做范围判定,在一个事务里给每个端点入队一个 webhook.deliver {endpoint_id, event_id}。入队按参数唯一,所以扇出跑两遍也只排一次投递。
  • webhook.deliver 先拿端点的租约,读事件和密钥,用 Standard Webhooks 的头给 thin payload 签名并 POST,最后结算端点状态。重试归 River 管(retry-has-one-owner)。

端点

webhook_endpoints (id, owner_id, url, description, event_types text[] -- glob list,
                   secret_enc, embed_id NULL -- scope source, enabled,
                   disabled_reason, failing_since, busy_until, created_at, updated_at)

ops 由 owner 域声明一次,MCP 和后台(/api/admin/webhooks*)都是它的投影。

op 做什么
webhooks.list / .create / .update / .delete 端点增删改查;update 可以关掉端点,重新打开会清掉失败状态
webhooks.rotate_secret 新密钥只显示这一次
webhooks.send_test 只给这个端点发一条 webhook.test 事件
webhooks.deliveries 读该端点的 webhook.deliver 任务行:尝试次数、状态、最后错误、任务链接
webhooks.redeliver 对方修好后,把该端点所有 discarded 的投递重投一遍
webhooks.event_types 列出 Exposure 为 Webhook 的类型

后台在 Integrations 下有 Webhooks 页:端点列表、填 URL 并勾选事件类型的表单、一次性密钥、投递日志(每行链到 /admin/tasks?job=<id>)、发送测试。端点是实例配置,和 suppliers 同类。

线格式:Standard Webhooks + thin event

POST <endpoint url>
webhook-id: <event id>
webhook-timestamp: 1790412000
webhook-signature: v1,<base64 HMAC-SHA256(key, id.timestamp.body)>

{ "id": "…", "type": "corpus.note.changed",
  "subject": "wiki://software/project/standmeet/architecture", "occurred_at": "…",
  "data": { "op": "updated", "note_id": "…", "published": true, "was_published": true } }
  • 只带类型、主体和 id,没有正文;接收方回它本来就用的公开或授权 API 取数据。删除事件带最后已知的主体。
  • 每次重试的 webhook-id 都等于事件 id,消费方按它去重(message-loss-guarantees)。
  • 每个端点一把密钥:whsec_ + 32 字节随机数的 base64;HMAC 的 key 是解码后的部分。用 cryptobox 封存落盘,只在 cmd/server/unseal.go 里解封。
  • 经 httpx.NewClient{BlockInternalEgress: true, NoRetry: true} 发出,HTTP 超时 10 秒,在 15 秒任务超时之内;内网、回环、重定向到内网的地址被拦截并直接 discarded。
  • 有一条 UT 拿 Standard Webhooks 的官方测试向量校验签名。

范围:一个判定,raw 永不出

只有 corpus.note.changed 需要判范围。其它类型发给所有订阅了它的端点(webhook.test 只发给它点名的那个端点)。

  • raw:// 永不出实例。
  • 单独创建的端点看已发布切片:笔记已发布,或刚刚还是已发布(published 或 was_published)时才发出,所以取消发布也能被听到。
  • 挂在 embed 上的端点用该 embed 访问码的范围,由 access 域回答(EmbedAdmits),用唯一的 ACL 判定 entity.AllowsCorpusEntry:角色 glob 减去码的 deny。只有码的角色读已发布切片时 published 才起作用。码被吊销或过期、embed 被删,一律不放行。每条事件都重新问一次,因为码随时可能被吊销或改范围(embed-update-hook)。
  • embed 范围是组合根交进来的一个函数,所以 owner 域的订阅方不 import access 域的内部,infra/events 也不 import 任何域。
  • Exposure 默认 Internal,忘了归类的事件出不了实例(event-model)。

一次投递

stateDiagram-v2
  [*] --> pending : webhook.fanout (scope passed, 5 min out while the endpoint is failing)
  pending --> running : worker claims (SKIP LOCKED)
  running --> pending : endpoint busy → snooze 2 s (no attempt spent)
  running --> completed : 2xx
  running --> pending : 429 / 503 with Retry-After → snooze (no attempt spent)
  running --> retryable : 5xx / 408 / 429 / timeout / connection failure
  running --> discarded : other 4xx · SSRF-blocked · endpoint disabled or gone · event pruned
  retryable --> pending : backoff 5s·5m·30m·2h·5h·10h…
  retryable --> discarded : 18 attempts (about 5.3 days)
  completed --> [*]
  discarded --> [*]

一个端点

stateDiagram-v2
  [*] --> enabled : webhooks.create (secret shown once)
  enabled --> failing : any non-success (failing_since set)
  failing --> enabled : any delivery succeeds (failing_since cleared)
  failing --> disabled : 5 days of continuous failure (reason recorded)
  disabled --> enabled : owner turns it on (clears the failure)
  enabled --> enabled : rotate_secret / send_test
  enabled --> [*] : webhooks.delete
  disabled --> [*] : webhooks.delete
  • 租约。 每个端点同时只有一次投递,靠一行租约(busy_until,用 UPDATE … RETURNING 拿)。没用 advisory 事务锁:那样 POST 期间要一直占着事务(concurrency-control)。
  • 冷却。 failing_since 有值期间,新投递排到 5 分钟后,挂掉的接收方不会每来一条新事件就被撞一次。
  • 任何非成功都会设 failing_since,410 和 429 也算;一次成功就清掉。停用的端点不再有新投递。

开放的事件类型

声明的 39 个类型全部 Webhook 可见、全部 thin,默认都不订阅。它们都是 owner 自己实例的事实;安全类事件恰好适合告警。完整清单在 event-model,扫描条目在 consolidation-inventory。有一条 e2e(webhook-event-types)对每个类型各做一次真实动作,断言 * 端点收到。

# 事件 期
— corpus.note.changed、webhook.test P1、P2
75–76 access_request.created / .approved / .status_changed P2
77–78 code.issued / .revoked / .redeemed P2
79–82 conversation.started / .message / .pruned、ghost.accepted P2
83 booking.created / .cancelled / .rescheduled P4
84–85 application.committed、jobs.fetched P4
86–88 writing.published / .unpublished、vault.imported P2
89–90 microsite.build.settled、page.promoted_live / .rolled_back / .unpublished、microsite.store.doc_inserted P4
91–93 api_key.issued / .revoked、supplier.connected / .disconnected / .activated、block.installed / .failed P2
94–97 gas.exhausted / .refilled、instance.upgrade_requested、owner.login / .email_changed / .recovery_requested、ip_ban.added P2

webhook 投递从不合并:同一主体的两条事件是两件事实(relay-claims-rows-not-cursor)。