From 0599110aed25783576cb8e0deff5d7b54c11b03b Mon Sep 17 00:00:00 2001 From: Richard Tan Date: Tue, 31 Mar 2026 16:20:38 +0800 Subject: [PATCH 1/2] Route GitHub webhook events to the correct environment based on PR origin --- .../handlers/handle-webhook-receiver.ts | 18 +- .../handlers/webhook-filter-handlers.ts | 214 ++++++++++++++++-- .../server-endpoints/webhook-receiver-test.ts | 101 +++++++++ 3 files changed, 315 insertions(+), 18 deletions(-) diff --git a/packages/realm-server/handlers/handle-webhook-receiver.ts b/packages/realm-server/handlers/handle-webhook-receiver.ts index 7fa2a3ac1ad..35753f0d3e1 100644 --- a/packages/realm-server/handlers/handle-webhook-receiver.ts +++ b/packages/realm-server/handlers/handle-webhook-receiver.ts @@ -111,7 +111,12 @@ export default function handleWebhookReceiverRequest({ // Delegate filter matching to the handler if ( commandFilter && - !filterHandler.matches(payload, ctxt.req.headers, commandFilter) + !(await filterHandler.matches( + payload, + ctxt.req.headers, + commandFilter, + dbAdapter, + )) ) { continue; } @@ -121,11 +126,18 @@ export default function handleWebhookReceiverRequest({ let realmURL: string; let commandInput: Record; try { - realmURL = filterHandler.getRealmURL(commandFilter ?? {}, commandURL); - commandInput = filterHandler.buildCommandInput( + realmURL = await filterHandler.getRealmURL( + commandFilter ?? {}, + commandURL, + payload, + ctxt.req.headers, + dbAdapter, + ); + commandInput = await filterHandler.buildCommandInput( payload, ctxt.req.headers, commandFilter ?? {}, + dbAdapter, ); } catch (error) { console.error( diff --git a/packages/realm-server/handlers/webhook-filter-handlers.ts b/packages/realm-server/handlers/webhook-filter-handlers.ts index aab015b6244..015ee61063a 100644 --- a/packages/realm-server/handlers/webhook-filter-handlers.ts +++ b/packages/realm-server/handlers/webhook-filter-handlers.ts @@ -1,4 +1,6 @@ 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. */ @@ -6,48 +8,215 @@ export interface WebhookFilterHandler { payload: Record, headers: Koa.Context['req']['headers'], filter: Record, - ): boolean; + dbAdapter?: DBAdapter, + ): Promise; /** Assemble the command input from the webhook payload and filter config. */ buildCommandInput( payload: Record, headers: Koa.Context['req']['headers'], filter: Record, - ): Record; + dbAdapter?: DBAdapter, + ): Promise>; /** Determine the realm URL where the command should run. */ - getRealmURL(filter: Record, commandURL: string): string; + getRealmURL( + filter: Record, + commandURL: string, + payload?: Record, + headers?: Koa.Context['req']['headers'], + dbAdapter?: DBAdapter, + ): Promise; +} + +/** + * 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); +} + +/** + * Extract the PR number from a GitHub webhook payload. + * Different event types store the PR number in different locations. + */ +export function extractPrNumberFromPayload( + payload: Record, +): 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 | 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. + */ +async function lookupRealmByPrNumber( + dbAdapter: DBAdapter, + prNumber: number, +): Promise { + try { + let rows = await query(dbAdapter, [ + `SELECT realm_url FROM boxel_index`, + `WHERE type = 'instance'`, + `AND is_deleted = FALSE`, + `AND search_doc->>'prNumber' =`, + param(String(prNumber)), + `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 realm URL dynamically from the GitHub webhook payload. + * + * Strategy: + * 1. If the payload contains a PR body, extract the realm from the Submission Card URL + * 2. Otherwise, look up the PrCard by prNumber in the index DB + * 3. Fall back to the static filter.realm if neither works + */ +async function resolveRealmFromPayload( + payload: Record, + filter: Record, + dbAdapter?: DBAdapter, +): Promise { + // Strategy 1: Extract from PR body (available on pull_request, pull_request_review events) + let prBody = extractPrBodyFromPayload(payload); + let realm = extractRealmFromPrBody(prBody); + if (realm) return realm; + + // Strategy 2: Look up PrCard by prNumber (for check_run, check_suite events) + if (dbAdapter) { + let prNumber = extractPrNumberFromPayload(payload); + if (prNumber != null) { + realm = await lookupRealmByPrNumber(dbAdapter, prNumber); + if (realm) return realm; + } + } + + // Strategy 3: Fall back to static filter.realm + return (filter.realm as string) ?? 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, + async matches( + payload: Record, headers: Koa.Context['req']['headers'], filter: Record, - ): boolean { + dbAdapter?: DBAdapter, + ): Promise { 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 prBody = extractPrBodyFromPayload(payload); + let resolvedRealm = extractRealmFromPrBody(prBody); + + if (!resolvedRealm && dbAdapter) { + let prNumber = extractPrNumberFromPayload(payload); + if (prNumber != null) { + resolvedRealm = await lookupRealmByPrNumber(dbAdapter, prNumber); + } + } + + if (resolvedRealm) { + try { + let filterOrigin = new URL(filter.realm as string).origin; + let resolvedOrigin = new URL(resolvedRealm).origin; + if (filterOrigin !== resolvedOrigin) { + return false; + } + } catch { + console.warn( + `Failed to compare realm origins for webhook filter ` + + `(filter.realm=${filter.realm}, resolvedRealm=${resolvedRealm}), ` + + `allowing match as fallback`, + ); + } + } else { + let prNumber = extractPrNumberFromPayload(payload); + console.warn( + `Could not resolve realm from webhook payload ` + + `(eventType=${eventType}, prNumber=${prNumber ?? 'unknown'}). ` + + `Falling back to broadcast — event will be processed by all environments.`, + ); + } + } + return true; } - buildCommandInput( + async buildCommandInput( payload: Record, headers: Koa.Context['req']['headers'], filter: Record, - ): Record { + dbAdapter?: DBAdapter, + ): Promise> { let eventType = (headers['x-github-event'] as string) ?? ''; - let realm = filter.realm as string | undefined; + + let realm = await resolveRealmFromPayload(payload, filter, dbAdapter); if (!realm) { throw new Error( - 'realm must be provided in the filter for github-event webhook commands', + 'Could not determine realm for github-event webhook command: ' + + 'no Submission Card URL found in PR body, no PrCard found in index, ' + + 'and no static realm configured in filter', ); } @@ -58,7 +227,17 @@ class GithubEventFilterHandler implements WebhookFilterHandler { }; } - getRealmURL(filter: Record, commandURL: string): string { + async getRealmURL( + filter: Record, + commandURL: string, + payload?: Record, + _headers?: Koa.Context['req']['headers'], + dbAdapter?: DBAdapter, + ): Promise { + if (payload) { + let realm = await resolveRealmFromPayload(payload, filter, dbAdapter); + if (realm) return realm; + } return ( (filter.realm as string | undefined) ?? new URL('/submissions/', commandURL).href @@ -72,15 +251,20 @@ class GithubEventFilterHandler implements WebhookFilterHandler { * command input. */ class DefaultFilterHandler implements WebhookFilterHandler { - matches(): boolean { + async matches(): Promise { return true; } - buildCommandInput(payload: Record): Record { + async buildCommandInput( + payload: Record, + ): Promise> { return { payload }; } - getRealmURL(filter: Record, commandURL: string): string { + async getRealmURL( + filter: Record, + commandURL: string, + ): Promise { return ( (filter.realmUrl as string | undefined) ?? new URL('/', commandURL).href ); diff --git a/packages/realm-server/tests/server-endpoints/webhook-receiver-test.ts b/packages/realm-server/tests/server-endpoints/webhook-receiver-test.ts index 017d4988e36..3701b0104de 100644 --- a/packages/realm-server/tests/server-endpoints/webhook-receiver-test.ts +++ b/packages/realm-server/tests/server-endpoints/webhook-receiver-test.ts @@ -5,6 +5,10 @@ import { createJWT as createRealmServerJWT } from '../../utils/jwt'; import { realmSecretSeed, insertUser } from '../helpers'; import { param, query, uuidv4 } from '@cardstack/runtime-common'; import { setupServerEndpointsTest } from './helpers'; +import { + extractRealmFromPrBody, + extractPrNumberFromPayload, +} from '../../handlers/webhook-filter-handlers'; module(`server-endpoints/${basename(__filename)}`, function () { module('Webhook Receiver Endpoint', function (hooks) { @@ -546,4 +550,101 @@ module(`server-endpoints/${basename(__filename)}`, function () { ); }); }); + + module('extractRealmFromPrBody', function () { + test('extracts realm from production Submission Card URL', function (assert) { + let body = [ + '## Summary', + 'Some description', + '---', + '- Listing Name: Recipe Card Definition', + '- Room ID: `!IUlOGgAWjwfwemOykG:boxel.ai`', + '- User ID: `@richard.tan:boxel.ai`', + '- Number of Files: 1', + '- Submission Card: [https://app.boxel.ai/richard.tan/ric-test-1/SubmissionCard/f0028a1c-777a-4d34-9f93-8f02667484d5](https://app.boxel.ai/richard.tan/ric-test-1/SubmissionCard/f0028a1c-777a-4d34-9f93-8f02667484d5)', + ].join('\n'); + + assert.strictEqual( + extractRealmFromPrBody(body), + 'https://app.boxel.ai/richard.tan/ric-test-1/', + ); + }); + + test('extracts realm from staging Submission Card URL', function (assert) { + let body = [ + '## Summary', + '- Submission Card: [https://realms-staging.stack.cards/chuan16/pure-creativity/SubmissionCard/01166122-d67f-4950-a708-b451564b30cb](https://realms-staging.stack.cards/chuan16/pure-creativity/SubmissionCard/01166122-d67f-4950-a708-b451564b30cb)', + ].join('\n'); + + assert.strictEqual( + extractRealmFromPrBody(body), + 'https://realms-staging.stack.cards/chuan16/pure-creativity/', + ); + }); + + test('extracts realm from local Submission Card URL', function (assert) { + let body = [ + '## Summary', + '- Submission Card: [http://localhost:4201/experiments/SubmissionCard/5e3c8a93-24b1-4143-958a-a65270110c52](http://localhost:4201/experiments/SubmissionCard/5e3c8a93-24b1-4143-958a-a65270110c52)', + ].join('\n'); + + assert.strictEqual( + extractRealmFromPrBody(body), + 'http://localhost:4201/experiments/', + ); + }); + + test('returns null when no Submission Card line exists', function (assert) { + let body = '## Summary\nSome PR description without submission card'; + assert.strictEqual(extractRealmFromPrBody(body), null); + }); + + test('returns null for null/undefined body', function (assert) { + assert.strictEqual(extractRealmFromPrBody(null), null); + assert.strictEqual(extractRealmFromPrBody(undefined), null); + }); + }); + + module('extractPrNumberFromPayload', function () { + test('extracts PR number from pull_request event', function (assert) { + assert.strictEqual( + extractPrNumberFromPayload({ + action: 'opened', + pull_request: { number: 296, body: '...' }, + }), + 296, + ); + }); + + test('extracts PR number from check_run event', function (assert) { + assert.strictEqual( + extractPrNumberFromPayload({ + action: 'completed', + check_run: { + pull_requests: [{ number: 42 }], + }, + }), + 42, + ); + }); + + test('extracts PR number from check_suite event', function (assert) { + assert.strictEqual( + extractPrNumberFromPayload({ + action: 'completed', + check_suite: { + pull_requests: [{ number: 99 }], + }, + }), + 99, + ); + }); + + test('returns null when no PR number found', function (assert) { + assert.strictEqual( + extractPrNumberFromPayload({ action: 'created', comment: {} }), + null, + ); + }); + }); }); From 9bad05d376562b4f23fb3efb62bb12cf552d4566 Mon Sep 17 00:00:00 2001 From: Richard Tan Date: Tue, 31 Mar 2026 16:57:15 +0800 Subject: [PATCH 2/2] Address feedback --- .../handlers/webhook-filter-handlers.ts | 89 ++-- .../server-endpoints/webhook-receiver-test.ts | 497 ++++++++++++++++++ 2 files changed, 545 insertions(+), 41 deletions(-) diff --git a/packages/realm-server/handlers/webhook-filter-handlers.ts b/packages/realm-server/handlers/webhook-filter-handlers.ts index 015ee61063a..0a426f20e7e 100644 --- a/packages/realm-server/handlers/webhook-filter-handlers.ts +++ b/packages/realm-server/handlers/webhook-filter-handlers.ts @@ -86,6 +86,10 @@ function extractPrBodyFromPayload(payload: Record): 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, @@ -95,9 +99,11 @@ async function lookupRealmByPrNumber( let rows = await query(dbAdapter, [ `SELECT realm_url FROM boxel_index`, `WHERE type = 'instance'`, - `AND is_deleted = FALSE`, + `AND (is_deleted = FALSE OR is_deleted IS NULL)`, + `AND url LIKE '%/PrCard/%'`, `AND search_doc->>'prNumber' =`, param(String(prNumber)), + `ORDER BY indexed_at DESC`, `LIMIT 1`, ]); if (rows.length > 0) { @@ -110,34 +116,45 @@ async function lookupRealmByPrNumber( } /** - * Resolve the realm URL dynamically from the GitHub webhook payload. + * Resolve the origin of the realm that a GitHub webhook event belongs to. * * Strategy: - * 1. If the payload contains a PR body, extract the realm from the Submission Card URL - * 2. Otherwise, look up the PrCard by prNumber in the index DB - * 3. Fall back to the static filter.realm if neither works + * 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 resolveRealmFromPayload( +async function resolveOriginFromPayload( payload: Record, - filter: Record, dbAdapter?: DBAdapter, ): Promise { // Strategy 1: Extract from PR body (available on pull_request, pull_request_review events) let prBody = extractPrBodyFromPayload(payload); let realm = extractRealmFromPrBody(prBody); - if (realm) return realm; + 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) { - realm = await lookupRealmByPrNumber(dbAdapter, prNumber); - if (realm) return realm; + let realmUrl = await lookupRealmByPrNumber(dbAdapter, prNumber); + if (realmUrl) { + try { + return new URL(realmUrl).origin; + } catch { + // fall through + } + } } } - // Strategy 3: Fall back to static filter.realm - return (filter.realm as string) ?? null; + return null; } /** @@ -166,37 +183,32 @@ class GithubEventFilterHandler implements WebhookFilterHandler { // 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 prBody = extractPrBodyFromPayload(payload); - let resolvedRealm = extractRealmFromPrBody(prBody); + let resolvedOrigin = await resolveOriginFromPayload(payload, dbAdapter); - if (!resolvedRealm && dbAdapter) { - let prNumber = extractPrNumberFromPayload(payload); - if (prNumber != null) { - resolvedRealm = await lookupRealmByPrNumber(dbAdapter, prNumber); - } - } - - if (resolvedRealm) { + if (resolvedOrigin) { try { let filterOrigin = new URL(filter.realm as string).origin; - let resolvedOrigin = new URL(resolvedRealm).origin; if (filterOrigin !== resolvedOrigin) { return false; } } catch { + // filter.realm is malformed — reject to avoid bypassing origin check console.warn( - `Failed to compare realm origins for webhook filter ` + - `(filter.realm=${filter.realm}, resolvedRealm=${resolvedRealm}), ` + - `allowing match as fallback`, + `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( - `Could not resolve realm from webhook payload ` + - `(eventType=${eventType}, prNumber=${prNumber ?? 'unknown'}). ` + - `Falling back to broadcast — event will be processed by all environments.`, + `Could not resolve realm origin from webhook payload ` + + `(eventType=${eventType}, prNumber=${prNumber ?? 'unknown'}), ` + + `rejecting match`, ); + return false; } } @@ -207,16 +219,16 @@ class GithubEventFilterHandler implements WebhookFilterHandler { payload: Record, headers: Koa.Context['req']['headers'], filter: Record, - dbAdapter?: DBAdapter, ): Promise> { let eventType = (headers['x-github-event'] as string) ?? ''; - let realm = await resolveRealmFromPayload(payload, filter, dbAdapter); + // 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( - 'Could not determine realm for github-event webhook command: ' + - 'no Submission Card URL found in PR body, no PrCard found in index, ' + - 'and no static realm configured in filter', + 'realm must be provided in the filter for github-event webhook commands', ); } @@ -230,14 +242,9 @@ class GithubEventFilterHandler implements WebhookFilterHandler { async getRealmURL( filter: Record, commandURL: string, - payload?: Record, - _headers?: Koa.Context['req']['headers'], - dbAdapter?: DBAdapter, ): Promise { - if (payload) { - let realm = await resolveRealmFromPayload(payload, filter, dbAdapter); - if (realm) return realm; - } + // 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 diff --git a/packages/realm-server/tests/server-endpoints/webhook-receiver-test.ts b/packages/realm-server/tests/server-endpoints/webhook-receiver-test.ts index 3701b0104de..0ff2e1244aa 100644 --- a/packages/realm-server/tests/server-endpoints/webhook-receiver-test.ts +++ b/packages/realm-server/tests/server-endpoints/webhook-receiver-test.ts @@ -526,6 +526,7 @@ module(`server-endpoints/${basename(__filename)}`, function () { pull_request: { number: 42, html_url: 'https://github.com/test/repo/pull/42', + body: '- Submission Card: [http://localhost:4201/user/realm/SubmissionCard/abc-123](http://localhost:4201/user/realm/SubmissionCard/abc-123)', }, sender: { login: 'testuser' }, }); @@ -551,6 +552,502 @@ module(`server-endpoints/${basename(__filename)}`, function () { }); }); + module('Origin-based filtering', function (hooks) { + let context = setupServerEndpointsTest(hooks); + + test('rejects event when PR origin does not match filter realm', async function (assert) { + let matrixUserId = '@user:localhost'; + await insertUser( + context.dbAdapter, + matrixUserId, + 'cus_123', + 'user@example.com', + ); + + let jwt = `Bearer ${createRealmServerJWT( + { user: matrixUserId, sessionRoom: 'session-room-test' }, + realmSecretSeed, + )}`; + + let createWebhookResponse = await context.request + .post('/_incoming-webhooks') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', jwt) + .send({ + data: { + type: 'incoming-webhook', + attributes: { + verificationType: 'HMAC_SHA256_HEADER', + verificationConfig: { + header: 'X-Hub-Signature-256', + encoding: 'hex', + }, + }, + }, + }); + + let webhookId = createWebhookResponse.body.data.id; + let webhookPath = createWebhookResponse.body.data.attributes.webhookPath; + let signingSecret = + createWebhookResponse.body.data.attributes.signingSecret; + + // Register command for production realm + await context.request + .post('/_webhook-commands') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', jwt) + .send({ + data: { + type: 'webhook-command', + attributes: { + incomingWebhookId: webhookId, + command: `http://test-realm/commands/process-github-event`, + filter: { + type: 'github-event', + eventType: 'pull_request', + realm: 'https://app.boxel.ai/submissions/', + }, + }, + }, + }); + + // Send event from a STAGING PR (different origin) + let payload = JSON.stringify({ + action: 'opened', + pull_request: { + number: 100, + body: '- Submission Card: [https://realms-staging.stack.cards/user/realm/SubmissionCard/abc-123](https://realms-staging.stack.cards/user/realm/SubmissionCard/abc-123)', + }, + }); + let signature = + 'sha256=' + + createHmac('sha256', signingSecret) + .update(payload, 'utf8') + .digest('hex'); + + let response = await context.request + .post(`/_webhooks/${webhookPath}`) + .set('Content-Type', 'application/json') + .set('X-Hub-Signature-256', signature) + .set('X-GitHub-Event', 'pull_request') + .send(payload); + + assert.strictEqual(response.status, 200, 'HTTP 200 status'); + assert.strictEqual( + response.body.commandsExecuted, + 0, + 'command was rejected because PR origin does not match filter realm', + ); + }); + + test('accepts event when PR origin matches filter realm', async function (assert) { + let matrixUserId = '@user:localhost'; + await insertUser( + context.dbAdapter, + matrixUserId, + 'cus_123', + 'user@example.com', + ); + + let jwt = `Bearer ${createRealmServerJWT( + { user: matrixUserId, sessionRoom: 'session-room-test' }, + realmSecretSeed, + )}`; + + let createWebhookResponse = await context.request + .post('/_incoming-webhooks') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', jwt) + .send({ + data: { + type: 'incoming-webhook', + attributes: { + verificationType: 'HMAC_SHA256_HEADER', + verificationConfig: { + header: 'X-Hub-Signature-256', + encoding: 'hex', + }, + }, + }, + }); + + let webhookId = createWebhookResponse.body.data.id; + let webhookPath = createWebhookResponse.body.data.attributes.webhookPath; + let signingSecret = + createWebhookResponse.body.data.attributes.signingSecret; + + // Register command for production realm + await context.request + .post('/_webhook-commands') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', jwt) + .send({ + data: { + type: 'webhook-command', + attributes: { + incomingWebhookId: webhookId, + command: `http://test-realm/commands/process-github-event`, + filter: { + type: 'github-event', + eventType: 'pull_request', + realm: 'https://app.boxel.ai/submissions/', + }, + }, + }, + }); + + // Send event from a PRODUCTION PR (same origin) + let payload = JSON.stringify({ + action: 'opened', + pull_request: { + number: 200, + body: '- Submission Card: [https://app.boxel.ai/richard.tan/my-realm/SubmissionCard/def-456](https://app.boxel.ai/richard.tan/my-realm/SubmissionCard/def-456)', + }, + }); + let signature = + 'sha256=' + + createHmac('sha256', signingSecret) + .update(payload, 'utf8') + .digest('hex'); + + let response = await context.request + .post(`/_webhooks/${webhookPath}`) + .set('Content-Type', 'application/json') + .set('X-Hub-Signature-256', signature) + .set('X-GitHub-Event', 'pull_request') + .send(payload); + + assert.strictEqual(response.status, 200, 'HTTP 200 status'); + assert.strictEqual( + response.body.commandsExecuted, + 1, + 'command was accepted because PR origin matches filter realm', + ); + }); + + test('rejects event when realm cannot be resolved from payload', async function (assert) { + let matrixUserId = '@user:localhost'; + await insertUser( + context.dbAdapter, + matrixUserId, + 'cus_123', + 'user@example.com', + ); + + let jwt = `Bearer ${createRealmServerJWT( + { user: matrixUserId, sessionRoom: 'session-room-test' }, + realmSecretSeed, + )}`; + + let createWebhookResponse = await context.request + .post('/_incoming-webhooks') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', jwt) + .send({ + data: { + type: 'incoming-webhook', + attributes: { + verificationType: 'HMAC_SHA256_HEADER', + verificationConfig: { + header: 'X-Hub-Signature-256', + encoding: 'hex', + }, + }, + }, + }); + + let webhookId = createWebhookResponse.body.data.id; + let webhookPath = createWebhookResponse.body.data.attributes.webhookPath; + let signingSecret = + createWebhookResponse.body.data.attributes.signingSecret; + + await context.request + .post('/_webhook-commands') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', jwt) + .send({ + data: { + type: 'webhook-command', + attributes: { + incomingWebhookId: webhookId, + command: `http://test-realm/commands/process-github-event`, + filter: { + type: 'github-event', + eventType: 'pull_request', + realm: 'https://app.boxel.ai/submissions/', + }, + }, + }, + }); + + // Send event with no Submission Card in PR body + let payload = JSON.stringify({ + action: 'opened', + pull_request: { + number: 999, + body: '## Summary\nNo submission card here', + }, + }); + let signature = + 'sha256=' + + createHmac('sha256', signingSecret) + .update(payload, 'utf8') + .digest('hex'); + + let response = await context.request + .post(`/_webhooks/${webhookPath}`) + .set('Content-Type', 'application/json') + .set('X-Hub-Signature-256', signature) + .set('X-GitHub-Event', 'pull_request') + .send(payload); + + assert.strictEqual(response.status, 200, 'HTTP 200 status'); + assert.strictEqual( + response.body.commandsExecuted, + 0, + 'command was rejected because realm could not be resolved (fail-closed)', + ); + }); + }); + + module('check_run DB lookup routing', function (hooks) { + let context = setupServerEndpointsTest(hooks); + + test('check_run event matches via PrCard DB lookup', async function (assert) { + let matrixUserId = '@user:localhost'; + await insertUser( + context.dbAdapter, + matrixUserId, + 'cus_123', + 'user@example.com', + ); + + let jwt = `Bearer ${createRealmServerJWT( + { user: matrixUserId, sessionRoom: 'session-room-test' }, + realmSecretSeed, + )}`; + + // Insert a PrCard into boxel_index + let prCardId = uuidv4(); + let prCardUrl = `https://app.boxel.ai/submissions/PrCard/${prCardId}`; + await query(context.dbAdapter, [ + `INSERT INTO boxel_index (url, file_alias, realm_url, realm_version, type, pristine_doc, search_doc, deps, is_deleted, indexed_at)`, + `VALUES (`, + param(prCardUrl), + `,`, + param(prCardUrl), + `,`, + param('https://app.boxel.ai/submissions/'), + `,`, + param(1), + `,`, + param('instance'), + `,`, + `'{}'::jsonb`, + `,`, + `'{"prNumber": "55"}'::jsonb`, + `,`, + `'[]'::jsonb`, + `,`, + param(false), + `,`, + param(Date.now()), + `)`, + ]); + + let createWebhookResponse = await context.request + .post('/_incoming-webhooks') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', jwt) + .send({ + data: { + type: 'incoming-webhook', + attributes: { + verificationType: 'HMAC_SHA256_HEADER', + verificationConfig: { + header: 'X-Hub-Signature-256', + encoding: 'hex', + }, + }, + }, + }); + + let webhookId = createWebhookResponse.body.data.id; + let webhookPath = createWebhookResponse.body.data.attributes.webhookPath; + let signingSecret = + createWebhookResponse.body.data.attributes.signingSecret; + + await context.request + .post('/_webhook-commands') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', jwt) + .send({ + data: { + type: 'webhook-command', + attributes: { + incomingWebhookId: webhookId, + command: `http://test-realm/commands/process-github-event`, + filter: { + type: 'github-event', + eventType: 'check_run', + realm: 'https://app.boxel.ai/submissions/', + }, + }, + }, + }); + + let payload = JSON.stringify({ + action: 'completed', + check_run: { + id: 1, + pull_requests: [{ number: 55 }], + }, + }); + let signature = + 'sha256=' + + createHmac('sha256', signingSecret) + .update(payload, 'utf8') + .digest('hex'); + + let response = await context.request + .post(`/_webhooks/${webhookPath}`) + .set('Content-Type', 'application/json') + .set('X-Hub-Signature-256', signature) + .set('X-GitHub-Event', 'check_run') + .send(payload); + + assert.strictEqual(response.status, 200, 'HTTP 200 status'); + assert.strictEqual( + response.body.commandsExecuted, + 1, + 'check_run matched via PrCard DB lookup', + ); + }); + + test('check_run event rejected when PrCard origin differs from filter realm', async function (assert) { + let matrixUserId = '@user:localhost'; + await insertUser( + context.dbAdapter, + matrixUserId, + 'cus_123', + 'user@example.com', + ); + + let jwt = `Bearer ${createRealmServerJWT( + { user: matrixUserId, sessionRoom: 'session-room-test' }, + realmSecretSeed, + )}`; + + // Insert a PrCard in STAGING + let prCardId = uuidv4(); + let prCardUrl = `https://realms-staging.stack.cards/submissions/PrCard/${prCardId}`; + await query(context.dbAdapter, [ + `INSERT INTO boxel_index (url, file_alias, realm_url, realm_version, type, pristine_doc, search_doc, deps, is_deleted, indexed_at)`, + `VALUES (`, + param(prCardUrl), + `,`, + param(prCardUrl), + `,`, + param('https://realms-staging.stack.cards/submissions/'), + `,`, + param(1), + `,`, + param('instance'), + `,`, + `'{}'::jsonb`, + `,`, + `'{"prNumber": "77"}'::jsonb`, + `,`, + `'[]'::jsonb`, + `,`, + param(false), + `,`, + param(Date.now()), + `)`, + ]); + + let createWebhookResponse = await context.request + .post('/_incoming-webhooks') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', jwt) + .send({ + data: { + type: 'incoming-webhook', + attributes: { + verificationType: 'HMAC_SHA256_HEADER', + verificationConfig: { + header: 'X-Hub-Signature-256', + encoding: 'hex', + }, + }, + }, + }); + + let webhookId = createWebhookResponse.body.data.id; + let webhookPath = createWebhookResponse.body.data.attributes.webhookPath; + let signingSecret = + createWebhookResponse.body.data.attributes.signingSecret; + + // Register command for PRODUCTION realm + await context.request + .post('/_webhook-commands') + .set('Accept', 'application/vnd.api+json') + .set('Content-Type', 'application/vnd.api+json') + .set('Authorization', jwt) + .send({ + data: { + type: 'webhook-command', + attributes: { + incomingWebhookId: webhookId, + command: `http://test-realm/commands/process-github-event`, + filter: { + type: 'github-event', + eventType: 'check_run', + realm: 'https://app.boxel.ai/submissions/', + }, + }, + }, + }); + + // check_run for PR #77 — PrCard is in staging, command is production + let payload = JSON.stringify({ + action: 'completed', + check_run: { + id: 2, + pull_requests: [{ number: 77 }], + }, + }); + let signature = + 'sha256=' + + createHmac('sha256', signingSecret) + .update(payload, 'utf8') + .digest('hex'); + + let response = await context.request + .post(`/_webhooks/${webhookPath}`) + .set('Content-Type', 'application/json') + .set('X-Hub-Signature-256', signature) + .set('X-GitHub-Event', 'check_run') + .send(payload); + + assert.strictEqual(response.status, 200, 'HTTP 200 status'); + assert.strictEqual( + response.body.commandsExecuted, + 0, + 'check_run rejected — PrCard origin (staging) does not match filter realm (production)', + ); + }); + }); + module('extractRealmFromPrBody', function () { test('extracts realm from production Submission Card URL', function (assert) { let body = [