11import type Koa from 'koa' ;
2+ import type { DBAdapter } from '@cardstack/runtime-common' ;
3+ import { param , query } from '@cardstack/runtime-common' ;
24
35export 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 ( / - S u b m i s s i o n C a r d : \[ ( [ ^ \] ] + ) \] / ) ;
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 */
26165class 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 */
74260class 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