-
Notifications
You must be signed in to change notification settings - Fork 12
Route GitHub webhook events to the correct environment based on PR origin #4285
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,49 +1,230 @@ | ||
| import type Koa from 'koa'; | ||
| import type { DBAdapter } from '@cardstack/runtime-common'; | ||
| import { param, query } from '@cardstack/runtime-common'; | ||
|
|
||
| export interface WebhookFilterHandler { | ||
| /** Return true if this payload matches the filter configuration. */ | ||
| matches( | ||
| payload: Record<string, any>, | ||
| headers: Koa.Context['req']['headers'], | ||
| filter: Record<string, any>, | ||
| ): boolean; | ||
| dbAdapter?: DBAdapter, | ||
| ): Promise<boolean>; | ||
|
|
||
| /** Assemble the command input from the webhook payload and filter config. */ | ||
| buildCommandInput( | ||
| payload: Record<string, any>, | ||
| headers: Koa.Context['req']['headers'], | ||
| filter: Record<string, any>, | ||
| ): Record<string, any>; | ||
| dbAdapter?: DBAdapter, | ||
| ): Promise<Record<string, any>>; | ||
|
|
||
| /** Determine the realm URL where the command should run. */ | ||
| getRealmURL(filter: Record<string, any>, commandURL: string): string; | ||
| getRealmURL( | ||
| filter: Record<string, any>, | ||
| commandURL: string, | ||
| payload?: Record<string, any>, | ||
| headers?: Koa.Context['req']['headers'], | ||
| dbAdapter?: DBAdapter, | ||
| ): Promise<string>; | ||
| } | ||
|
|
||
| /** | ||
| * Extract the realm URL from a Submission Card URL found in a PR body. | ||
| * | ||
| * The PR body contains a line like: | ||
| * - Submission Card: [https://app.boxel.ai/user/realm/SubmissionCard/uuid](...) | ||
| * | ||
| * The realm is everything before "SubmissionCard/" in that URL. | ||
| */ | ||
| export function extractRealmFromPrBody( | ||
| prBody: string | undefined | null, | ||
| ): string | null { | ||
| if (!prBody) return null; | ||
|
|
||
| // Match the Submission Card URL in the PR body markdown link | ||
| let match = prBody.match(/- Submission Card: \[([^\]]+)\]/); | ||
| if (!match) return null; | ||
|
|
||
| let submissionCardUrl = match[1]; | ||
| let submissionCardIndex = submissionCardUrl.indexOf('SubmissionCard/'); | ||
| if (submissionCardIndex === -1) return null; | ||
|
|
||
| return submissionCardUrl.slice(0, submissionCardIndex); | ||
|
richardhjtan marked this conversation as resolved.
|
||
| } | ||
|
|
||
| /** | ||
| * Extract the PR number from a GitHub webhook payload. | ||
| * Different event types store the PR number in different locations. | ||
| */ | ||
| export function extractPrNumberFromPayload( | ||
| payload: Record<string, any>, | ||
| ): number | null { | ||
| // pull_request, pull_request_review, pull_request_review_comment events | ||
| if (payload.pull_request?.number != null) { | ||
| return payload.pull_request.number; | ||
| } | ||
| // check_run events | ||
| if (payload.check_run?.pull_requests?.[0]?.number != null) { | ||
| return payload.check_run.pull_requests[0].number; | ||
| } | ||
| // check_suite events | ||
| if (payload.check_suite?.pull_requests?.[0]?.number != null) { | ||
| return payload.check_suite.pull_requests[0].number; | ||
| } | ||
| return null; | ||
| } | ||
|
|
||
| /** | ||
| * Extract the PR body from a GitHub webhook payload. | ||
| * Available on pull_request, pull_request_review, and pull_request_review_comment events. | ||
| */ | ||
| function extractPrBodyFromPayload(payload: Record<string, any>): string | null { | ||
| return (payload.pull_request?.body as string) ?? null; | ||
| } | ||
|
|
||
| /** | ||
| * Look up the realm URL for a PrCard with the given PR number by querying | ||
| * the card index database. | ||
| * | ||
| * The query restricts results to PrCard instances (URL contains '/PrCard/') | ||
| * to avoid matching GithubEventCard instances which also carry a prNumber | ||
| * field but may exist in a different realm. | ||
| */ | ||
| async function lookupRealmByPrNumber( | ||
| dbAdapter: DBAdapter, | ||
| prNumber: number, | ||
| ): Promise<string | null> { | ||
| try { | ||
| let rows = await query(dbAdapter, [ | ||
| `SELECT realm_url FROM boxel_index`, | ||
| `WHERE type = 'instance'`, | ||
| `AND (is_deleted = FALSE OR is_deleted IS NULL)`, | ||
| `AND url LIKE '%/PrCard/%'`, | ||
| `AND search_doc->>'prNumber' =`, | ||
| param(String(prNumber)), | ||
|
richardhjtan marked this conversation as resolved.
|
||
| `ORDER BY indexed_at DESC`, | ||
| `LIMIT 1`, | ||
| ]); | ||
| if (rows.length > 0) { | ||
| return rows[0].realm_url as string; | ||
| } | ||
| } catch (error) { | ||
| console.warn(`Failed to look up realm for PR #${prNumber}:`, error); | ||
| } | ||
| return null; | ||
| } | ||
|
|
||
| /** | ||
| * Resolve the origin of the realm that a GitHub webhook event belongs to. | ||
| * | ||
| * Strategy: | ||
| * 1. If the payload contains a PR body, extract the origin from the Submission Card URL | ||
| * 2. Otherwise, look up the PrCard by prNumber in the index DB and extract its origin | ||
| * | ||
| * Returns null if the origin cannot be determined. | ||
| */ | ||
| async function resolveOriginFromPayload( | ||
| payload: Record<string, any>, | ||
| dbAdapter?: DBAdapter, | ||
| ): Promise<string | null> { | ||
| // Strategy 1: Extract from PR body (available on pull_request, pull_request_review events) | ||
| let prBody = extractPrBodyFromPayload(payload); | ||
| let realm = extractRealmFromPrBody(prBody); | ||
| if (realm) { | ||
| try { | ||
| return new URL(realm).origin; | ||
| } catch { | ||
| // fall through | ||
| } | ||
| } | ||
|
|
||
| // Strategy 2: Look up PrCard by prNumber (for check_run, check_suite events) | ||
| if (dbAdapter) { | ||
| let prNumber = extractPrNumberFromPayload(payload); | ||
| if (prNumber != null) { | ||
| let realmUrl = await lookupRealmByPrNumber(dbAdapter, prNumber); | ||
| if (realmUrl) { | ||
| try { | ||
| return new URL(realmUrl).origin; | ||
| } catch { | ||
| // fall through | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return null; | ||
| } | ||
|
|
||
| /** | ||
| * Handler for GitHub webhook events. Supports filtering by event type | ||
| * (from X-GitHub-Event header). | ||
| * (from X-GitHub-Event header) and dynamically resolves the target realm | ||
| * from the PR body's Submission Card URL or by looking up the PrCard. | ||
| */ | ||
| class GithubEventFilterHandler implements WebhookFilterHandler { | ||
| matches( | ||
| _payload: Record<string, any>, | ||
| async matches( | ||
| payload: Record<string, any>, | ||
| headers: Koa.Context['req']['headers'], | ||
| filter: Record<string, any>, | ||
| ): boolean { | ||
| dbAdapter?: DBAdapter, | ||
| ): Promise<boolean> { | ||
| let eventType = headers['x-github-event'] as string | undefined; | ||
|
|
||
| if (filter.eventType && filter.eventType !== eventType) { | ||
| return false; | ||
| } | ||
|
|
||
| // If the filter has a configured realm, check that the event's realm | ||
| // belongs to the same server/origin. This ensures each environment | ||
| // only processes events from PRs that originated in that environment. | ||
| // | ||
| // First try the cheap path: extract realm from the PR body (no DB query). | ||
| // If that's not available, fall back to the full resolution strategy | ||
| // (which may query the DB for check_run/check_suite events). | ||
| if (filter.realm) { | ||
| let resolvedOrigin = await resolveOriginFromPayload(payload, dbAdapter); | ||
|
Comment on lines
+185
to
+186
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎. |
||
|
|
||
| if (resolvedOrigin) { | ||
| try { | ||
| let filterOrigin = new URL(filter.realm as string).origin; | ||
| if (filterOrigin !== resolvedOrigin) { | ||
| return false; | ||
| } | ||
| } catch { | ||
| // filter.realm is malformed — reject to avoid bypassing origin check | ||
| console.warn( | ||
| `Failed to parse filter.realm URL (${filter.realm}), rejecting match`, | ||
| ); | ||
| return false; | ||
| } | ||
| } else { | ||
| // Could not resolve origin from payload — reject the match to prevent | ||
| // cross-environment broadcast. This is the safer default: if we can't | ||
| // determine which environment the PR belongs to, don't process it. | ||
| let prNumber = extractPrNumberFromPayload(payload); | ||
| console.warn( | ||
|
richardhjtan marked this conversation as resolved.
|
||
| `Could not resolve realm origin from webhook payload ` + | ||
| `(eventType=${eventType}, prNumber=${prNumber ?? 'unknown'}), ` + | ||
| `rejecting match`, | ||
| ); | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| return true; | ||
|
richardhjtan marked this conversation as resolved.
|
||
| } | ||
|
|
||
| buildCommandInput( | ||
| async buildCommandInput( | ||
| payload: Record<string, any>, | ||
| headers: Koa.Context['req']['headers'], | ||
| filter: Record<string, any>, | ||
| ): Record<string, any> { | ||
| ): Promise<Record<string, any>> { | ||
| let eventType = (headers['x-github-event'] as string) ?? ''; | ||
|
|
||
| // Always use the static filter.realm (the submissions realm) for the | ||
| // command input. The dynamic origin check in matches() already ensures | ||
| // we only reach here for the correct environment. | ||
| let realm = filter.realm as string | undefined; | ||
| if (!realm) { | ||
| throw new Error( | ||
|
|
@@ -58,7 +239,12 @@ class GithubEventFilterHandler implements WebhookFilterHandler { | |
| }; | ||
| } | ||
|
|
||
| getRealmURL(filter: Record<string, any>, commandURL: string): string { | ||
| async getRealmURL( | ||
| filter: Record<string, any>, | ||
| commandURL: string, | ||
| ): Promise<string> { | ||
| // Always use the static filter.realm. The dynamic origin check in | ||
| // matches() already ensures we only reach here for the correct environment. | ||
| return ( | ||
| (filter.realm as string | undefined) ?? | ||
| new URL('/submissions/', commandURL).href | ||
|
|
@@ -72,15 +258,20 @@ class GithubEventFilterHandler implements WebhookFilterHandler { | |
| * command input. | ||
| */ | ||
| class DefaultFilterHandler implements WebhookFilterHandler { | ||
| matches(): boolean { | ||
| async matches(): Promise<boolean> { | ||
| return true; | ||
| } | ||
|
|
||
| buildCommandInput(payload: Record<string, any>): Record<string, any> { | ||
| async buildCommandInput( | ||
| payload: Record<string, any>, | ||
| ): Promise<Record<string, any>> { | ||
| return { payload }; | ||
| } | ||
|
|
||
| getRealmURL(filter: Record<string, any>, commandURL: string): string { | ||
| async getRealmURL( | ||
| filter: Record<string, any>, | ||
| commandURL: string, | ||
| ): Promise<string> { | ||
| return ( | ||
| (filter.realmUrl as string | undefined) ?? new URL('/', commandURL).href | ||
| ); | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.