Skip to content

feat(gatekeeper-webhook): inbound webhook endpoints for workspaces - #85

Closed
MartinRoberts-Fountain wants to merge 1 commit into
cloudflare:mainfrom
MartinRoberts-Fountain:martin/multi-user-workspace-810d08
Closed

feat(gatekeeper-webhook): inbound webhook endpoints for workspaces#85
MartinRoberts-Fountain wants to merge 1 commit into
cloudflare:mainfrom
MartinRoberts-Fountain:martin/multi-user-workspace-810d08

Conversation

@MartinRoberts-Fountain

Copy link
Copy Markdown

Adds packages/gatekeeper-webhook: a gatekeeper that gives each workspace inbound HTTP endpoints a third-party service can POST to. A delivery wakes a persistent workspace callback with the request's headers, query and body.

There is currently no way to get an external event into a workspace. ExternalMessageGateway exists in workshop-backend for roughly this, but nothing binds it and it takes a callerEmail it trusts unconditionally, so it can't be a public surface on its own. This is the missing piece in front of it.

Shape

File Role
webhook.ts vendor, account, per-workspace facet, per-endpoint facet, sessions, configurator RPC
endpoint-registry.ts one DO per account: endpoint records, hook capabilities, delivery queue + alarm, delivery log, rate limit
endpoint-index.ts one DO per endpoint ID → its account
receiver.ts the public fetch handler
endpoint-core.ts pure helpers: token mint/HMAC, sanitization, event building, limits
app/, src/configurator/ management app and the workspace resource form

Decisions worth a reviewer's attention

Ack fast, deliver async. The receiver answers 202 and queues; an alarm delivers with eight attempts and backoff. An agent turn outlasts any sender's timeout, so handling inline would make senders retry or disable the endpoint.

Tokens follow the share-key discipline — 256-bit random, only the HMAC-SHA-256 digest stored, returned exactly once by the call that mints it. See the open question below.

EndpointIndex exists to keep URLs uncorrelated. It maps an endpoint ID to its account so the URL itself carries no account identity; two endpoints handed to two different vendors can't be linked. The cost is one extra DO hop per delivery, which is cheap at webhook volumes.

Disable pauses, it does not invalidate. Unlike a schedule, the URL already lives in a third party's configuration, so a pause has to be resumable. Disabling drops queued deliveries and the capability but keeps the endpoint's ID and token.

Credential headers are stripped, signature headers are not. authorization, cookie and proxy-authorization never reach the workspace. x-hub-signature-256 and friends deliberately do, since a gadget verifying a payload needs them and they grant no access.

The observation names source, timestamp and size only. The body is untrusted third-party input; putting it in the action log would echo secrets into a record the workspace's collaborators can read.

Two creation paths

WEBHOOKS.register() on the ambient binding mints endpoint and token together and binds the hook in one step.

Connect resource → Webhook endpoint in a workspace creates one as a per-endpoint binding. This is what lets a workspace run several independent flows: each endpoint delivers to its own gadget and therefore reaches only that gadget's connections. An alerts endpoint bound to a gadget holding ClickHouse cannot reach GitHub, and an agent-spawner's env narrows a spawned triage agent further.

An endpoint belongs to the first workspace that binds it; a second bind is refused rather than silently redirecting a live URL.

Verification

67 worker tests + 8 app tests. pnpm lint and pnpm types:check clean. Golden manifest regenerated.

Also exercised end to end against a local instance: an agent registered an endpoint, curl of an Alertmanager payload returned 202, the gadget received it, and it spawned a triage agent chat in a clean context. Both spawned agents independently refused to act on a runbook URL planted in the payload, which is the prompt-injection fencing doing its job.

Open question for reviewers

Configurator-created endpoints currently have no token until one is generated in the Webhooks app. ConfiguratorUISpec.render is synchronous and the form closes on submit, so there is nowhere to reveal a secret exactly once. I split creation from issuance rather than weaken hash-only storage.

In use this reads as broken — you create a webhook and get no credential. The fix I'd make is reveal-once storage: mint the token at creation, keep the plaintext until the first reveal, then drop it and keep only the hash. Steady state stays hash-only, and it matches how the platform already stores OAuth access tokens for other vendors. Not done here; flagging it rather than shipping the decision silently.

Two other things left undone: spawnCallable() for accumulating one agent per incident is untested, and submitting the configurator form was verified only as far as render (the sandboxed frame doesn't take synthetic clicks in the tooling I had).

Adds a Webhooks gatekeeper: each workspace gets its own inbound HTTP
endpoints that third-party services POST to, delivered to a persistent
workspace callback as an observation.

There was no way to get an external event into a workspace. The Workshop
already exports ExternalMessageGateway for this, but nothing binds it, and
it takes a caller email it trusts unconditionally -- unusable as a public
surface. This gatekeeper is the missing piece.

Design notes worth reviewing:

* Ack fast, deliver async. The receiver answers 202 and queues; an alarm
  delivers with eight attempts and backoff. An agent turn outlasts any
  sender's timeout, so handling inline would make senders retry or disable
  the endpoint.
* Tokens follow the share-key discipline: 256-bit random, only the
  HMAC-SHA-256 digest is stored, returned exactly once by the call that
  mints it.
* EndpointIndex is a DO per endpoint ID that maps it to its account, so an
  endpoint URL carries no account identity -- two endpoints handed to two
  vendors stay uncorrelated.
* Disable pauses an endpoint but keeps its URL and token. Unlike a
  schedule, the URL already lives in a third party's configuration, so a
  pause has to be resumable.
* authorization/cookie/proxy-authorization are stripped before delivery;
  service signature headers are deliberately kept, since a gadget
  verifying x-hub-signature-256 needs them and they grant no access.
* The observation description names source, timestamp and size only. The
  body is untrusted third-party input and must not land in the action log.

Endpoints can be created two ways. WEBHOOKS.register() on the ambient
binding mints endpoint and token together. Or "Connect resource" ->
"Webhook endpoint" in a workspace creates one as a per-endpoint binding,
which is what lets a workspace run several independent flows: each
endpoint delivers to its own gadget and therefore reaches only that
gadget's connections.

Verified end to end against a local instance: an agent registered an
endpoint, a curl'd Alertmanager payload arrived, and the gadget spawned a
triage agent chat in a clean context.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown


Thank you for your submission, we really appreciate it. Like many open-source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution. You can sign the CLA by just posting a Pull Request Comment same as the below format.


I have read the CLA Document and I hereby sign the CLA


You can retrigger this bot by commenting recheck in this Pull Request. Posted by the CLA Assistant Lite bot.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant