Skip to content

Add factory webhook handler#360

Merged
khaliqgant merged 2 commits into
mainfrom
ar-347-factory-webhook-handler
Jun 15, 2026
Merged

Add factory webhook handler#360
khaliqgant merged 2 commits into
mainfrom
ar-347-factory-webhook-handler

Conversation

@khaliqgant

@khaliqgant khaliqgant commented Jun 15, 2026

Copy link
Copy Markdown
Member

Summary

  • add factory-sdk webhook handler for Relayfile outbound webhook HMAC verification, dedupe, and provider path routing
  • add webhook registrar and in-memory factory state store exports
  • add focused webhook handler tests for valid routes, invalid HMAC, duplicate event IDs, and retryable handler failures

Tests

  • npm run typecheck
  • npx vitest run packages/factory-sdk/src/webhook/handler.test.ts

Review in cubic

@coderabbitai

coderabbitai Bot commented Jun 15, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@khaliqgant, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 46 minutes and 27 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 084bd692-b833-439b-aa6b-431253e0129a

📥 Commits

Reviewing files that changed from the base of the PR and between 31d9871 and 7faad14.

📒 Files selected for processing (8)
  • packages/factory-sdk/src/index.ts
  • packages/factory-sdk/src/state/index.ts
  • packages/factory-sdk/src/state/store.ts
  • packages/factory-sdk/src/webhook/handler.test.ts
  • packages/factory-sdk/src/webhook/handler.ts
  • packages/factory-sdk/src/webhook/index.ts
  • packages/factory-sdk/src/webhook/registrar.test.ts
  • packages/factory-sdk/src/webhook/registrar.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch ar-347-factory-webhook-handler

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a webhook handling and registration system along with an in-memory state store in the factory-sdk package. Specifically, it adds a WebhookHandler for processing and verifying signatures of incoming events (Linear, Slack, GitHub), a WebhookRegistrar for managing subscriptions, and an InMemoryStateStore implementing a StateStore interface. The review feedback highlights several critical improvements: preventing a memory leak in the webhook deduplication cache, addressing potential timing leaks and decoding vulnerabilities in the signature verification function, validating incoming payloads to avoid runtime type errors, and adding a guard to prevent orphaned webhook subscriptions when registering multiple times.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +65 to +67
if (eventId) {
this.#seen.add(eventId)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The #seen Set is used to deduplicate webhook events by eventId. However, because it is an unbounded Set, it will grow indefinitely as more events are processed. In a long-running server process, this will eventually lead to a memory leak and potential Out Of Memory (OOM) crashes.

To prevent this, we can limit the size of the Set by evicting the oldest entries (FIFO) when a threshold (e.g., 10,000 events) is exceeded.

    if (eventId) {
      this.#seen.add(eventId)
      if (this.#seen.size > 10000) {
        const oldest = this.#seen.values().next().value
        if (oldest !== undefined) {
          this.#seen.delete(oldest)
        }
      }
    }

Comment on lines +73 to +78
function timingSafeHexEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false
const bufA = Buffer.from(a, 'hex')
const bufB = Buffer.from(b, 'hex')
return bufA.length === bufB.length && cryptoTimingSafeEqual(bufA, bufB)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

The current implementation of timingSafeHexEqual has potential timing leaks and vulnerabilities:

  1. Length-based Timing Leak: If a.length !== b.length, it returns false immediately, leaking the length of the expected signature.
  2. Hex Decoding Anomalies: Buffer.from(b, 'hex') can produce buffers of different lengths if b contains non-hex characters (e.g., Buffer.from('zz', 'hex') has length 0). This causes bufA.length === bufB.length to short-circuit, leaking whether the input contains valid hex characters.

To resolve these issues, we can compare the strings as UTF-8 buffers (since hex characters are safe ASCII/UTF-8). If the lengths differ, we can perform a dummy comparison against bufA itself to ensure constant-time execution before returning false.

Suggested change
function timingSafeHexEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false
const bufA = Buffer.from(a, 'hex')
const bufB = Buffer.from(b, 'hex')
return bufA.length === bufB.length && cryptoTimingSafeEqual(bufA, bufB)
}
function timingSafeHexEqual(a: string, b: string): boolean {
const bufA = Buffer.from(a, 'utf8')
const bufB = Buffer.from(b, 'utf8')
if (bufA.length !== bufB.length) {
cryptoTimingSafeEqual(bufA, bufA)
return false
}
return cryptoTimingSafeEqual(bufA, bufB)
}

Comment on lines +46 to +51
let event: WebhookEvent
try {
event = JSON.parse(rawBody) as WebhookEvent
} catch {
return new Response('bad_request', { status: 400 })
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The parsed event is cast directly to WebhookEvent without validation. If the incoming payload is malformed or missing the path property, event.path will be undefined. This will cause event.path.startsWith(...) to throw a TypeError, resulting in a 500 internal_error response.

To make the handler more robust and return a proper 400 bad_request for malformed payloads, we should validate that event?.path is a string before proceeding.

    let event: WebhookEvent
    try {
      event = JSON.parse(rawBody) as WebhookEvent
    } catch {
      return new Response('bad_request', { status: 400 })
    }

    if (typeof event?.path !== 'string') {
      return new Response('bad_request', { status: 400 })
    }

Comment on lines +19 to +20
async register(): Promise<void> {
const pathGlobs = this.#config.pathGlobs ?? [

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If register() is called multiple times on the same registrar instance, it will overwrite this.#subscriptionId without unregistering or throwing an error. This can lead to orphaned webhook subscriptions on the RelayFile server.

We should add a guard to prevent registering again if a subscription is already active.

Suggested change
async register(): Promise<void> {
const pathGlobs = this.#config.pathGlobs ?? [
async register(): Promise<void> {
if (this.#subscriptionId) {
throw new Error('Webhook is already registered')
}
const pathGlobs = this.#config.pathGlobs ?? [

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3401544ae5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +42 to +44
if (eventId && this.#seen.has(eventId)) {
return new Response('ok', { status: 200 })
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Coalesce duplicate webhook deliveries while in flight

When two deliveries with the same x-relay-event-id arrive concurrently, both requests can pass this #seen check before either handler finishes and adds the id at the end, so both provider handlers run and can spawn duplicate factory work or duplicate writebacks. Duplicate delivery is a normal Relayfile failure mode; reserve the event id or keep a keyed in-flight promise before invoking the provider handler, and clear it on retryable failure so later retries still work.

Useful? React with 👍 / 👎.

Comment on lines +25 to +31
const subscription = await this.#config.client.registerWebhook({
workspaceId: this.#config.workspaceId,
url: this.#config.receiverUrl,
pathGlobs,
secret: this.#config.secret,
})
this.#subscriptionId = subscription.subscriptionId

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid creating duplicate webhook subscriptions

Calling register() more than once creates another webhook subscription and overwrites the only stored #subscriptionId; unregister() can then delete only the newest subscription, leaving the earlier one active and causing duplicate webhook deliveries for the same factory. This can happen on retries/reconnects or repeated startup paths, so register() should be idempotent and preferably coalesce concurrent calls before invoking registerWebhook.

Useful? React with 👍 / 👎.

@agent-relay-code

Copy link
Copy Markdown
Contributor

Findings

  1. packages/factory-sdk/src/webhook/registrar.ts: register() is not idempotent. Calling it twice creates another webhook subscription and overwrites #subscriptionId, so unregister() can only delete the last one and leaves earlier subscriptions active. That will multiply deliveries for the same logical event. This needs a human-authored behavior fix, not an auto-edit.

  2. packages/factory-sdk/src/webhook/handler.ts: duplicate suppression only uses the x-relay-event-id header, even though WebhookEvent requires eventId in the signed body. If a valid relay/client sends the identity in the payload but omits the header, duplicate deliveries are reprocessed. Consider parsing first after signature verification and deduping on the stable event identity from header or body.

Addressed Comments

  • No bot or reviewer comments were present in .workforce/context.json or other .workforce files, so there were no existing threads to address.

Verification

No files were edited.

Ran:

  • npm ci
  • npm run verify:mcp-resources-drift
  • npm run lint passes with existing warnings
  • npm run typecheck:web
  • npm run typecheck:node
  • npm test
  • npx vitest run failed once on an existing timing-sensitive factory heartbeat test, then the targeted rerun passed, and a second full npx vitest run passed
  • npm run build
  • npm run build:web
  • npx playwright test --config playwright.fidelity.config.ts
  • npx playwright test --config playwright.redraw.config.ts

I did not run the macOS-only packaging smoke job locally.

@khaliqgant khaliqgant merged commit 14909ab into main Jun 15, 2026
5 checks passed
@khaliqgant khaliqgant deleted the ar-347-factory-webhook-handler branch June 15, 2026 13:38
@agent-relay-code

Copy link
Copy Markdown
Contributor

Findings

  1. packages/factory-sdk/src/webhook/handler.ts: duplicate webhook deliveries are only marked seen after the provider handler finishes at lines 54-67. Two concurrent requests with the same eventId can both pass the #seen check and both invoke the handler. This needs a human-authored in-flight/idempotency patch, so I did not edit it.

  2. packages/factory-sdk/src/webhook/registrar.ts: register() is idempotent for sequential calls, but not concurrent calls. Two simultaneous calls can both pass the #subscriptionId guard before either stores the result, creating duplicate webhooks. This is lifecycle/ownership logic, so I left it as a review finding.

  3. packages/factory-sdk/src/webhook/handler.ts: signed but malformed JSON payloads are cast to WebhookEvent without validation. For example, {} reaches event.path.startsWith(...), throws, and returns 500, causing retry behavior for a bad request. This is behavioral validation logic, so I did not auto-edit it.

Addressed comments

  • coderabbitai[bot]: rate-limit notice only; no actionable code review comment to fix.
  • gemini-code-assist[bot]: unbounded #seen set in handler.ts is valid, but cache eviction policy is semantic; left unchanged.
  • gemini-code-assist[bot]: timingSafeHexEqual concern at handler.ts; reviewed, not treated as a blocking PR issue because expected signature length is not secret and invalid hex still fails closed.
  • gemini-code-assist[bot]: duplicate register() comment is stale for sequential calls because registrar.ts now returns when already registered; concurrent duplicate registration remains valid and is listed above.
  • gemini-code-assist[bot]: review summary also raised payload validation; validated current checkout and listed above at handler.ts.
  • chatgpt-codex-connector[bot]: top-level review banner only; no specific code action.
  • chatgpt-codex-connector[bot]: concurrent duplicate webhook delivery finding is valid in handler.ts; left unchanged because it is semantic/safety logic.
  • chatgpt-codex-connector[bot]: concurrent duplicate webhook registration finding is valid in registrar.ts; left unchanged because it is lifecycle logic.
  • agent-relay-code[bot]: prior registrar finding is stale for sequential calls but valid for concurrent calls; see registrar.ts.
  • agent-relay-code[bot]: prior body/header event ID finding is stale; current code uses event.eventId || headerEventId at handler.ts.

Verification

No files were edited.

Ran successfully:

  • npm ci
  • npm run verify:mcp-resources-drift
  • npm run lint passes with existing warnings
  • npm run typecheck:web
  • npm run typecheck:node
  • npx vitest run packages/factory-sdk/src/webhook/handler.test.ts packages/factory-sdk/src/webhook/registrar.test.ts
  • npm test
  • npx vitest run passed on rerun; first run hit a transient existing factory heartbeat timing failure that passed in isolation
  • npm run build
  • npm run build:web
  • npx playwright install --with-deps chromium
  • npx playwright test --config playwright.redraw.config.ts
  • npx playwright test --config playwright.fidelity.config.ts passed sequentially; a prior parallel local run timed out under contention

GitHub reports PR #360 is already merged/closed, so I am not printing READY.

khaliqgant added a commit that referenced this pull request Jun 15, 2026
Merge origin/main (#360 webhook handler, #362 park completed PRs). The
ar-366 re-dispatch and ar-243 PR-state sweep tests came from main with
pre-label-routing implementer names; under AR-274 label routing the
implementer is repo-slug suffixed (ar-366-impl-pear / ar-243-impl-pear).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
khaliqgant added a commit that referenced this pull request Jun 15, 2026
* AR-274 route implementers by repo labels

* fix(factory): fail dispatch without repo labels

* AR-274 rebind reviewer to label route

* AR-274 require repo labels for dispatch (no default-route fallback)

Remove defaultRouteAssignment/repoSlug so an unlabeled or unmapped issue
fails dispatch loudly instead of silently routing to repos.default. Cap
label implementers at MAX_LABEL_IMPLEMENTERS=4 and rebind the reviewer to
the first label route via routeReviewerSpec().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(factory): green CI + dedupe invalid-label dispatch comments

- fleet.test.ts: implementer name is now repo-slug suffixed (ar-77-impl-pear)
  under label routing.
- Dedupe the invalid-label dispatch failure comment by reason+offending-labels
  signature so a stuck Ready issue (or the writeback's own change event) no
  longer re-posts the same notice every cycle; clear on successful dispatch.

Addresses Codex review on PR #363.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(factory): suffix implementer names for merged-in main tests

Merge origin/main (#360 webhook handler, #362 park completed PRs). The
ar-366 re-dispatch and ar-243 PR-state sweep tests came from main with
pre-label-routing implementer names; under AR-274 label routing the
implementer is repo-slug suffixed (ar-366-impl-pear / ar-243-impl-pear).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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