Repository navigation
feat(web): webhooks for Cap Infra videos (video.ready, video.errored) - #4139
Merged
Merged
Conversation
…n public-only requests Node skips a custom lookup when the host is already an IP address, so requestPublicOnly on its own would have connected to a literal private address. Every caller checked the URL first, so nothing reached it, but the request now refuses the literal itself. It also sends a text body, which outbound webhooks need. The address list gains the documentation ranges, 6to4, Teredo, the discard prefix and local-use NAT64.
Record a delivery's count, streak start and disable decision under the endpoint's row lock. Run the host lookup inside the delivery deadline and sign at the moment of sending. Measure a URL as it is stored, after encoding. Close the connection once the status is known, and drop a connection that sends content after a no-content answer.
Contributor
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Member
Author
|
hey @greptileai, please re-review the PR |
|
Sandchest reviewed Sandchest reviews every change for vulnerabilities an attacker could use · 9 credits · Review details |
# Conflicts: # apps/web/content/docs/api/webhooks.mdx
This was referenced Nov 13, 2026
Merged
# Conflicts: # packages/database/migrations/meta/0057_snapshot.json # packages/database/migrations/meta/_journal.json
Main took 0056 and 0057 and 0058 is reserved for the storage PR. The SQL is unchanged and the snapshot now chains from main's 0057.
POST /webhooks/:id/rotate-secret replaces the secret and returns the new one once. Every later delivery, retries included, is signed with it.
…ard for storage callers
Private and plain http targets were allowed whenever NODE_ENV was not production, so a self-hosted install or a misconfigured server could reach internal hosts. They are now reachable only when a caller passes allowPrivate, which only tests do. A test runs the production path under seven NODE_ENV values against a real local server and sees no request.
Member
Author
|
@greptileai please re-review |
This PR lands before the storage PR, so it takes the next number on main. The SQL is unchanged, the snapshot chains from main's 0057 and the journal when is newer than every entry on main.
Member
Author
|
@greptileai please re-review |
The ranges added for webhooks (documentation, 6to4, Teredo, discard, local NAT64) refused a whole name when one AAAA fell inside them, which custom S3 endpoints did not do before. They now drop only their own addresses, and a name is refused only when nothing public is left. A private or loopback address anywhere in the answer still refuses the name, as on main.
Member
Author
|
@greptileai please re-review |
# Conflicts: # apps/web/app/api/developer/v1/[...route]/route.ts # apps/web/lib/developer-openapi.ts
Member
Author
|
@greptileai please re-review |
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

What this adds
Cap Infra apps can register a URL and be told when a video is ready or has failed, instead of polling.
/api/developer/v1/webhooks(secret key only): create, list, get, update (url,events,enabled), delete, rotate the signing secret, and send a test event. An app can have 5 endpoints. Unknown body fields are refused.video.readyandvideo.errored. The body is{ id, type, createdAt, data }, wheredatais the video exactly asGET /videos/:idreturns it.Cap-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">, plusCap-Event-IdandCap-Event-Type. The signing secret (cws_...) is returned once, on create or onPOST /webhooks/:id/rotate-secret, and stored encrypted with the same helper as the app's API keys. Rotation takes effect at once: every later request, retries included, is signed with the new secret.PATCH { "enabled": true }turns it back on.webhooks.mdxis rewritten to describe what exists,rest-api.mdxgains a short Webhooks section that links to it, and the OpenAPI description gains the seven operations, their schemas and the three outbound requests.How an event is sent
Every status change of a developer video goes through one row-locked update (
mutateDeveloperVideo). That is the only place this PR touches in the processing code: one call after the update, which does nothing unless the status has just becomereadyorerrored.SSRF guard
The URL is supplied by a customer and requested by our servers, so the request is treated as hostile input. This reuses the guard already on main (
lib/s3-endpoint-policy.tsandrequestPublicOnlyinlib/storage-fetch.ts, the same one the speech API uses for URL sources) rather than adding a second one.Merge conditions, each tested:
NODE_ENVcheck. Plainhttpand private addresses are reachable only when a caller passesallowPrivate: true, and only tests do; the routes and the delivery workflow pass nothing.developer-webhook-private-targets.test.tsruns the production path underNODE_ENV= development, test, staging, preview, empty, unset and production against a real local server and sees zero requests. With the oldNODE_ENV !== "production"rule put back, all seven cases fail.requestPublicOnlynever follows a redirect. Any 3xx is a failed delivery with coderedirect, and theLocationis never requested. Tested with a redirect to the metadata address and against a real server.publicOnlyLookup, which checks every address in the answer and refuses the connection if any is private; Node dials the address that lookup returned, with the original Host header and SNI. The early check at create, update and before each delivery is extra; it is not what keeps the connection safe.developer-webhook-rebinding.test.tsmocks DNS to answer public on the check and private on the connection's lookup (loopback, metadata,fd00:ec2::254, mapped v4, a mixed answer) and the delivery never connects. A control request without the guard does reach the local server, which shows the second answer is the one that gets dialled. With the connect-time check turned off, all seven tests fail.Mixed DNS answers
When a name resolves to several addresses, the guard does not treat every refused range the same way. The same rule applies at save time (
isBlockedS3Endpoint) and at connect time (publicOnlyLookup), for webhooks and for regular Cap's custom S3 endpoints.64:ff9b::/96. This list and this behaviour are exactly main's, unchanged.192.0.2.0/24,198.51.100.0/24,203.0.113.0/24,2001:db8::/32), 6to4 relay anycast192.88.99.0/24, 6to42002::/16, Teredo2001::/32, the discard prefix100::/64and local-use NAT6464:ff9b:1::/48. These addresses are never dialled. A name whose answer holds a public address too connects to the public one, and only the public addresses are handed to the connection. A name with nothing else is refused.So a mixed answer never gets stricter than on main. The only new refusals are a name or a literal made up entirely of the added ranges. Real storage hosts do not publish documentation, 6to4 or Teredo addresses. NAT64 addresses only appear from a DNS64 resolver on an IPv6-only network, which Vercel functions do not use, and
64:ff9b::/96was already refused on main.A one-commit undo of the added ranges is pushed as branch
revert/storage-guard-ranges(no PR), in case custom bucket uploads show refusals after deploy.Also:
httpsonly, default port only, at most 2,048 characters as stored, no username or password.Changes to the shared guard, and why regular Cap is unaffected
Callers of these two files: custom S3 endpoints (
actions/organization/storage.ts, desktops3Config.ts,api/v1route),fetchStorageUrl(transcription, OG thumbnails, desktop manifest rebuild) andpublic-media-fetch.ts(speech URL sources).isBlockedS3Endpoint) and the connect-timepublicOnlyLookuprefuse the whole name when any address in the answer is private or loopback. That is unchanged. A public A with a 6to4, Teredo, local NAT64, discard or documentation AAAA still connects, as it did on main. The 11 mixed-answer cases ins3-endpoint-policy.test.tswere also run against main's copy of the file and give the same result there. The only stricter cases are a name or literal made up entirely of the new ranges.2001:1::1,2001:db9::1and2003::1next to the new prefixes) as still allowed.requestPublicOnlyrefuses a private IP literal itself, because Node skips a custom lookup for literals. Every existing caller already checks the URL first, so none of them sees a difference.requestPublicOnlyaccepts a text body, which webhooks need. A non-text body is still refused.The guard suites for every caller pass:
s3-endpoint-policy,storage-fetch,public-media-fetch,organization-storage-s3-endpoint,agent-s3-endpoint-policy.Migration
packages/database/migrations/0058_developer_webhook_endpoints.sql(journal idx 58,when1794549219180, newer than every entry on main). It creates one table,developer_webhook_endpoints, with a foreign key todeveloper_appsand one index (appId, deletedAt, createdAt, id). Nothing existing is altered. The snapshotmeta/0058_snapshot.jsonchains from main's0057, anddrizzle-kit generatereports no schema changes against it. #4145 takes the next number after this one. It needs to be applied by deploy request before this is merged.What I ran
bunx vitest runon every webhook, developer, infra, guard and guard caller test file: 144 files, 2,953 passed. The whole web unit suite: green apart fromshare-stage-identity, which already fails locally on main.tsc --noEmitforapps/webafter rebuilding the referenced packages: clean apart from one error inorganization-settings-ownership.test.ts, which this PR does not touch and which is the same on main.Earlier end to end run on a scratch local database, local storage, a local media server and a local HTTP server as the customer endpoint:
400409 webhook_limit_reached404each, and that app lists none200, signature accepted by the docs' Node examplevideo.readydelivered once to each endpoint with the same event id, signature accepted by the Node and Python examplesvideo.erroreddelivered once, with the reason indata.errorReasontimeout, retried 60 s later, failure count risingThat run used the old local-build switch for
httpon localhost. That switch is gone, so a local build now needs a publichttpsURL, such as a tunnel, to receive webhooks.Not covered
apps/web/lib/developer-webhooks.ts.GET /videos/:id/statusstays the source of truth. A receiver can also see the same event more than once and should drop a repeated id.

Confidence Score: 5/5
The PR appears safe to merge once the documented database migration is applied.Summary
Adds signed webhooks for Cap Infra videos, with endpoint management, test events, retries, and automatic disabling after repeated failures.
Reviews (12) · Last reviewed commit: "Merge remote-tracking branch 'origin/mai..." · Reviewed by Greptile