Webhook:thin、签名、限定范围
上级:events
状态: 已在 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)。
一次投递
一个端点
- 租约。 每个端点同时只有一次投递,靠一行租约(
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)。