feat(server): GitHub webhook receiver triggers source sync (#30)#60
Merged
Conversation
Add public POST /webhooks/github/:workspaceId/:sourceId. Verifies the HMAC against a per-source secret, confirms repository.full_name matches the source's configured owner/repo, then fires sourceLoaderRunner.syncOne in the background and returns 202 so GitHub's 10s delivery timeout cannot trip on slow sync. Workspace-scoped admin routes (GET / POST rotate) live under /workspaces/:ws/source-webhooks/:sourceId/github and surface the URL + secret rotation to Studio. The secret returns exactly once on rotate; subsequent reads only see hasSecret + createdAt. Studio renders a GitHub webhook panel under github-loader AND git-loader filesystem sources (both can receive deliveries from a GitHub repo; the git URL is parsed to extract owner/repo for the repo-match check). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ract The receiver previously hard-coded github vs git loader branches and the ref-vs-branch check, violating OCP: adding a new loader required editing the route. Move the contract into `SourceLoaderPlugin.webhook` with two methods the plugin owns: `repoIdentity(config)` resolves the provider-owner-repo triple for full_name matching, and `shouldDispatch(config, delivery)` decides whether a verified delivery should fire syncOne. github and git loaders now declare their own event filters (issues/issue_comment/ping vs push-to-tracked-branch respectively). The receiver becomes loader-agnostic and delegates both questions. Studio's GitHub webhook panel now gates on the server's source-loaders listing (which carries a new \`webhook: boolean\` flag) instead of hardcoding \`loader.kind\`, so a new webhook-capable loader surfaces the panel automatically. Each loader package gains a unit test covering its \`webhook\` capability in isolation; server tests use inline fake plugins to exercise the receiver's contract without coupling to specific loader semantics. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Verified findings from /code-review max on PR #30. Receiver hardening (packages/server/src/routes/sourceWebhooks.ts): - #5 wrap pre-signature flow in a single try/catch returning uniform 401 so a ZodError on schema drift, an unknown workspace, or a thrown plugin method cannot leak detail to anonymous callers - #10 collapse every pre-signature failure into 401 'invalid signature' so anonymous probes cannot enumerate workspace / source ids - #3 require X-GitHub-Event; missing header is 400, not a silent skip - #2 require payload.repository.full_name; compare case-insensitively - #12 admin returns 503 when apiUrl is unset rather than a relative URL - #15 convert admin router to OpenAPIHono to match the convention used by workspaces / batch / skills / history Loader (packages/source-loader-git/src/GitLoader.ts): - #1 webhook.shouldDispatch accepts both main and master when branch is unset (default-branch heuristic) since the loader's sync follows HEAD - #6 drop interpolateEnv in repoIdentity so a missing env var no longer throws or leaks the env var name on an anonymous endpoint - #11 parseGithubUrl tolerates trailing slash, uppercase host, git+https://, query / fragment portions Studio (packages/studio/src/components/WorkspaceDetailsSheet.tsx): - #8 clear revealedSecret on panel collapse and on rotate onMutate so the "shown once" claim holds Auth (packages/server/src/middleware/auth.ts): - #14 narrow PUBLIC_PREFIXES from '/webhooks/' to '/webhooks/github/' Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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.
Closes #30.
Summary
POST /webhooks/github/:workspaceId/:sourceIdreceiver. Verifies the per-source HMAC, confirmsrepository.full_namematches the source's configured owner/repo, then firessourceLoaderRunner.syncOnein the background and returns 202 so GitHub's ~10s delivery timeout cannot trip on slow syncs.GET / POST rotate) under/workspaces/:ws/source-webhooks/:sourceId/githublet Studio surface the payload URL and mint a fresh 32-byte hex secret. The secret is returned exactly once on rotate; subsequent GETs report onlyhasSecret+createdAt.SourceLoaderPlugin.webhookcapability (repoIdentity+shouldDispatch). The receiver becomes loader-agnostic and delegates both questions to the plugin, so adding a new webhook-capable loader needs no receiver edit (OCP).githubloader dispatchesissues/issue_comment/pingevents.gitloader dispatchespushevents whose ref matchesconfig.branch(ormain/masteras the unset default).Security posture
401 invalid signatureso anonymous callers cannot enumerate workspace ids, source ids, or which sources are webhook-armed by reading distinct error messages./webhooks/github/is the only prefix exempt from the Bearer auth middleware (narrower than a blanket/webhooks/), so a future debug / admin endpoint added under/webhooks/does not silently inherit the anonymous bypass.X-Hub-Signature-256andX-GitHub-Event; missing either is rejected rather than silently dropped.payload.repository.full_nameis required and compared case-insensitively (GitHub repo names are case-insensitive on the wire).Test plan
pnpm --filter @braidhq/server test— 292 tests pass, including 17 receiver + admin tests covering: valid HMAC dispatchessyncOne; bad HMAC → 401; missingX-Hub-Signature-256/X-GitHub-Event→ 401 / 400; missing or mismatchedrepository.full_name→ 400; uppercasefull_namematches; ref filter on the git loader; push event on a github-issues loader skipped; uniform 401 for unknown workspace / source / no secret; rotate persists + replaces; status GET never returns the secret.pnpm --filter @braidhq/source-loader-git test— 14 tests, including new webhook-capability suite (https/ssh URL parsing, trailing slash + uppercase host +git+https://+?querytolerance,${VAR}placeholder not throwing, push-ref filter,mainandmasterdefault).pnpm --filter @braidhq/source-loader-github test— 9 tests, including new webhook-capability suite (issues/issue_comment/pingdispatched,pushskipped).pnpm --filter @braidhq/studio typecheck— clean.pnpm lint— clean.localhost:4321, registered two GitHub webhooks onmroops0111/braid(one per source), and verified end to end:issues.edited→ 202 →intents/issues/issues/30.mdupdated within seconds.feat/github-webhook-trigger-syncfirespush→ 202 with{skipped: true}(feat branch ≠ trackedmain/master)./webhooks/github/<guess>/<guess>with bad signature / missing header / fake workspace / fake source all return identical401 invalid signature.Notes for review
SourceLoaderPlugin.webhook, and the/code-review maxfollow-up fixes.OpenAPIHonoto match the convention shared byworkspaces/batch/skills/historyso the rotate / status surface appears in/openapi.json.oauth-google/oauth-githublayout (webhook-github, key<workspaceId>--<sourceId>).🤖 Generated with Claude Code