diff --git a/src/orb/relay.ts b/src/orb/relay.ts index e4c9236f22..b6626be94a 100644 --- a/src/orb/relay.ts +++ b/src/orb/relay.ts @@ -291,8 +291,14 @@ export async function forwardOrbEvent( fetchImpl: typeof fetch = fetch, ): Promise<"forwarded" | "queued" | "skipped" | "failed"> { if (!args.installationId || !RELAY_FORWARD_EVENTS.has(args.eventName)) return "skipped"; + // issueOrbEnrollment inserts a new `enrolled` row per enrollment without revoking the prior one, so one + // installation can have several enrolled rows. Without ORDER BY, D1/SQLite returns an arbitrary match, so a + // stale row (no relay URL, or an old push URL) can win over the current container. Pick the most recently + // registered enrollment — by relay_registered_at, then enrolled_at — so re-enrollment targets the new host (#1783). const row = await env.DB - .prepare("SELECT relay_mode, relay_url, relay_secret_enc, relay_secret_iv, relay_secret_salt FROM orb_enrollments WHERE installation_id = ? AND state = 'enrolled' AND revoked_at IS NULL") + .prepare( + "SELECT relay_mode, relay_url, relay_secret_enc, relay_secret_iv, relay_secret_salt FROM orb_enrollments WHERE installation_id = ? AND state = 'enrolled' AND revoked_at IS NULL ORDER BY relay_registered_at DESC, enrolled_at DESC", + ) .bind(args.installationId) .first<{ relay_mode: string; relay_url: string | null; relay_secret_enc: string | null; relay_secret_iv: string | null; relay_secret_salt: string | null }>(); if (!row) return "skipped"; // not a brokered self-host (or revoked) — nothing to relay to diff --git a/test/integration/orb-relay.test.ts b/test/integration/orb-relay.test.ts index 65b4fa6376..e911d00681 100644 --- a/test/integration/orb-relay.test.ts +++ b/test/integration/orb-relay.test.ts @@ -181,6 +181,19 @@ describe("forwardOrbEvent", () => { expect(calls[0]?.init?.body).toBe(body); }); + it("forwards to the most recently registered enrollment when an installation has multiple enrolled rows (#1783)", async () => { + const e = brokeredEnv(); + // Re-enrollment inserts a second `enrolled` row for the same installation (the install is already seeded, + // so only issueOrbEnrollment runs again); row A stays enrolled with no relay. + await enroll(e, 815); // row A — enrolled, never registers a relay + const secretB = ((await issueOrbEnrollment(e, 815)) as { secret: string }).secret; // row B — the current container + await registerOrbRelay(e, secretB, "https://new-host.example/v1/orb/relay"); // register the relay on row B only + const { fetchImpl, calls } = capture(new Response("ok")); + // Without ordering, the arbitrary first row could be A (no relay) → "skipped"; the fix must pick B. + expect(await forwardOrbEvent(e, { eventName: "pull_request", installationId: 815, deliveryId: "d-1783", rawBody: "{}" }, fetchImpl)).toBe("forwarded"); + expect(calls[0]?.url).toBe("https://new-host.example/v1/orb/relay"); + }); + it("returns FAILED (never throws) on a non-ok response or a thrown fetch — the Orb 202 always stands", async () => { const e = brokeredEnv(); const secret = await enroll(e, 802);