Skip to content

Commit 019235c

Browse files
authored
Route GitHub webhook events to the correct environment based on PR origin (#4285)
* Route GitHub webhook events to the correct environment based on PR origin * Address feedback
1 parent 293328d commit 019235c

3 files changed

Lines changed: 817 additions & 16 deletions

File tree

packages/realm-server/handlers/handle-webhook-receiver.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,12 @@ export default function handleWebhookReceiverRequest({
111111
// Delegate filter matching to the handler
112112
if (
113113
commandFilter &&
114-
!filterHandler.matches(payload, ctxt.req.headers, commandFilter)
114+
!(await filterHandler.matches(
115+
payload,
116+
ctxt.req.headers,
117+
commandFilter,
118+
dbAdapter,
119+
))
115120
) {
116121
continue;
117122
}
@@ -121,11 +126,18 @@ export default function handleWebhookReceiverRequest({
121126
let realmURL: string;
122127
let commandInput: Record<string, any>;
123128
try {
124-
realmURL = filterHandler.getRealmURL(commandFilter ?? {}, commandURL);
125-
commandInput = filterHandler.buildCommandInput(
129+
realmURL = await filterHandler.getRealmURL(
130+
commandFilter ?? {},
131+
commandURL,
132+
payload,
133+
ctxt.req.headers,
134+
dbAdapter,
135+
);
136+
commandInput = await filterHandler.buildCommandInput(
126137
payload,
127138
ctxt.req.headers,
128139
commandFilter ?? {},
140+
dbAdapter,
129141
);
130142
} catch (error) {
131143
console.error(

packages/realm-server/handlers/webhook-filter-handlers.ts

Lines changed: 204 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,230 @@
11
import type Koa from 'koa';
2+
import type { DBAdapter } from '@cardstack/runtime-common';
3+
import { param, query } from '@cardstack/runtime-common';
24

35
export interface WebhookFilterHandler {
46
/** Return true if this payload matches the filter configuration. */
57
matches(
68
payload: Record<string, any>,
79
headers: Koa.Context['req']['headers'],
810
filter: Record<string, any>,
9-
): boolean;
11+
dbAdapter?: DBAdapter,
12+
): Promise<boolean>;
1013

1114
/** Assemble the command input from the webhook payload and filter config. */
1215
buildCommandInput(
1316
payload: Record<string, any>,
1417
headers: Koa.Context['req']['headers'],
1518
filter: Record<string, any>,
16-
): Record<string, any>;
19+
dbAdapter?: DBAdapter,
20+
): Promise<Record<string, any>>;
1721

1822
/** Determine the realm URL where the command should run. */
19-
getRealmURL(filter: Record<string, any>, commandURL: string): string;
23+
getRealmURL(
24+
filter: Record<string, any>,
25+
commandURL: string,
26+
payload?: Record<string, any>,
27+
headers?: Koa.Context['req']['headers'],
28+
dbAdapter?: DBAdapter,
29+
): Promise<string>;
30+
}
31+
32+
/**
33+
* Extract the realm URL from a Submission Card URL found in a PR body.
34+
*
35+
* The PR body contains a line like:
36+
* - Submission Card: [https://app.boxel.ai/user/realm/SubmissionCard/uuid](...)
37+
*
38+
* The realm is everything before "SubmissionCard/" in that URL.
39+
*/
40+
export function extractRealmFromPrBody(
41+
prBody: string | undefined | null,
42+
): string | null {
43+
if (!prBody) return null;
44+
45+
// Match the Submission Card URL in the PR body markdown link
46+
let match = prBody.match(/- Submission Card: \[([^\]]+)\]/);
47+
if (!match) return null;
48+
49+
let submissionCardUrl = match[1];
50+
let submissionCardIndex = submissionCardUrl.indexOf('SubmissionCard/');
51+
if (submissionCardIndex === -1) return null;
52+
53+
return submissionCardUrl.slice(0, submissionCardIndex);
54+
}
55+
56+
/**
57+
* Extract the PR number from a GitHub webhook payload.
58+
* Different event types store the PR number in different locations.
59+
*/
60+
export function extractPrNumberFromPayload(
61+
payload: Record<string, any>,
62+
): number | null {
63+
// pull_request, pull_request_review, pull_request_review_comment events
64+
if (payload.pull_request?.number != null) {
65+
return payload.pull_request.number;
66+
}
67+
// check_run events
68+
if (payload.check_run?.pull_requests?.[0]?.number != null) {
69+
return payload.check_run.pull_requests[0].number;
70+
}
71+
// check_suite events
72+
if (payload.check_suite?.pull_requests?.[0]?.number != null) {
73+
return payload.check_suite.pull_requests[0].number;
74+
}
75+
return null;
76+
}
77+
78+
/**
79+
* Extract the PR body from a GitHub webhook payload.
80+
* Available on pull_request, pull_request_review, and pull_request_review_comment events.
81+
*/
82+
function extractPrBodyFromPayload(payload: Record<string, any>): string | null {
83+
return (payload.pull_request?.body as string) ?? null;
84+
}
85+
86+
/**
87+
* Look up the realm URL for a PrCard with the given PR number by querying
88+
* the card index database.
89+
*
90+
* The query restricts results to PrCard instances (URL contains '/PrCard/')
91+
* to avoid matching GithubEventCard instances which also carry a prNumber
92+
* field but may exist in a different realm.
93+
*/
94+
async function lookupRealmByPrNumber(
95+
dbAdapter: DBAdapter,
96+
prNumber: number,
97+
): Promise<string | null> {
98+
try {
99+
let rows = await query(dbAdapter, [
100+
`SELECT realm_url FROM boxel_index`,
101+
`WHERE type = 'instance'`,
102+
`AND (is_deleted = FALSE OR is_deleted IS NULL)`,
103+
`AND url LIKE '%/PrCard/%'`,
104+
`AND search_doc->>'prNumber' =`,
105+
param(String(prNumber)),
106+
`ORDER BY indexed_at DESC`,
107+
`LIMIT 1`,
108+
]);
109+
if (rows.length > 0) {
110+
return rows[0].realm_url as string;
111+
}
112+
} catch (error) {
113+
console.warn(`Failed to look up realm for PR #${prNumber}:`, error);
114+
}
115+
return null;
116+
}
117+
118+
/**
119+
* Resolve the origin of the realm that a GitHub webhook event belongs to.
120+
*
121+
* Strategy:
122+
* 1. If the payload contains a PR body, extract the origin from the Submission Card URL
123+
* 2. Otherwise, look up the PrCard by prNumber in the index DB and extract its origin
124+
*
125+
* Returns null if the origin cannot be determined.
126+
*/
127+
async function resolveOriginFromPayload(
128+
payload: Record<string, any>,
129+
dbAdapter?: DBAdapter,
130+
): Promise<string | null> {
131+
// Strategy 1: Extract from PR body (available on pull_request, pull_request_review events)
132+
let prBody = extractPrBodyFromPayload(payload);
133+
let realm = extractRealmFromPrBody(prBody);
134+
if (realm) {
135+
try {
136+
return new URL(realm).origin;
137+
} catch {
138+
// fall through
139+
}
140+
}
141+
142+
// Strategy 2: Look up PrCard by prNumber (for check_run, check_suite events)
143+
if (dbAdapter) {
144+
let prNumber = extractPrNumberFromPayload(payload);
145+
if (prNumber != null) {
146+
let realmUrl = await lookupRealmByPrNumber(dbAdapter, prNumber);
147+
if (realmUrl) {
148+
try {
149+
return new URL(realmUrl).origin;
150+
} catch {
151+
// fall through
152+
}
153+
}
154+
}
155+
}
156+
157+
return null;
20158
}
21159

22160
/**
23161
* Handler for GitHub webhook events. Supports filtering by event type
24-
* (from X-GitHub-Event header).
162+
* (from X-GitHub-Event header) and dynamically resolves the target realm
163+
* from the PR body's Submission Card URL or by looking up the PrCard.
25164
*/
26165
class GithubEventFilterHandler implements WebhookFilterHandler {
27-
matches(
28-
_payload: Record<string, any>,
166+
async matches(
167+
payload: Record<string, any>,
29168
headers: Koa.Context['req']['headers'],
30169
filter: Record<string, any>,
31-
): boolean {
170+
dbAdapter?: DBAdapter,
171+
): Promise<boolean> {
32172
let eventType = headers['x-github-event'] as string | undefined;
33173

34174
if (filter.eventType && filter.eventType !== eventType) {
35175
return false;
36176
}
37177

178+
// If the filter has a configured realm, check that the event's realm
179+
// belongs to the same server/origin. This ensures each environment
180+
// only processes events from PRs that originated in that environment.
181+
//
182+
// First try the cheap path: extract realm from the PR body (no DB query).
183+
// If that's not available, fall back to the full resolution strategy
184+
// (which may query the DB for check_run/check_suite events).
185+
if (filter.realm) {
186+
let resolvedOrigin = await resolveOriginFromPayload(payload, dbAdapter);
187+
188+
if (resolvedOrigin) {
189+
try {
190+
let filterOrigin = new URL(filter.realm as string).origin;
191+
if (filterOrigin !== resolvedOrigin) {
192+
return false;
193+
}
194+
} catch {
195+
// filter.realm is malformed — reject to avoid bypassing origin check
196+
console.warn(
197+
`Failed to parse filter.realm URL (${filter.realm}), rejecting match`,
198+
);
199+
return false;
200+
}
201+
} else {
202+
// Could not resolve origin from payload — reject the match to prevent
203+
// cross-environment broadcast. This is the safer default: if we can't
204+
// determine which environment the PR belongs to, don't process it.
205+
let prNumber = extractPrNumberFromPayload(payload);
206+
console.warn(
207+
`Could not resolve realm origin from webhook payload ` +
208+
`(eventType=${eventType}, prNumber=${prNumber ?? 'unknown'}), ` +
209+
`rejecting match`,
210+
);
211+
return false;
212+
}
213+
}
214+
38215
return true;
39216
}
40217

41-
buildCommandInput(
218+
async buildCommandInput(
42219
payload: Record<string, any>,
43220
headers: Koa.Context['req']['headers'],
44221
filter: Record<string, any>,
45-
): Record<string, any> {
222+
): Promise<Record<string, any>> {
46223
let eventType = (headers['x-github-event'] as string) ?? '';
224+
225+
// Always use the static filter.realm (the submissions realm) for the
226+
// command input. The dynamic origin check in matches() already ensures
227+
// we only reach here for the correct environment.
47228
let realm = filter.realm as string | undefined;
48229
if (!realm) {
49230
throw new Error(
@@ -58,7 +239,12 @@ class GithubEventFilterHandler implements WebhookFilterHandler {
58239
};
59240
}
60241

61-
getRealmURL(filter: Record<string, any>, commandURL: string): string {
242+
async getRealmURL(
243+
filter: Record<string, any>,
244+
commandURL: string,
245+
): Promise<string> {
246+
// Always use the static filter.realm. The dynamic origin check in
247+
// matches() already ensures we only reach here for the correct environment.
62248
return (
63249
(filter.realm as string | undefined) ??
64250
new URL('/submissions/', commandURL).href
@@ -72,15 +258,20 @@ class GithubEventFilterHandler implements WebhookFilterHandler {
72258
* command input.
73259
*/
74260
class DefaultFilterHandler implements WebhookFilterHandler {
75-
matches(): boolean {
261+
async matches(): Promise<boolean> {
76262
return true;
77263
}
78264

79-
buildCommandInput(payload: Record<string, any>): Record<string, any> {
265+
async buildCommandInput(
266+
payload: Record<string, any>,
267+
): Promise<Record<string, any>> {
80268
return { payload };
81269
}
82270

83-
getRealmURL(filter: Record<string, any>, commandURL: string): string {
271+
async getRealmURL(
272+
filter: Record<string, any>,
273+
commandURL: string,
274+
): Promise<string> {
84275
return (
85276
(filter.realmUrl as string | undefined) ?? new URL('/', commandURL).href
86277
);

0 commit comments

Comments
 (0)