From c945f6abf327ef2518a7da27b8afc420344b68bc Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 15 Jul 2026 01:14:15 -0400 Subject: [PATCH 1/6] feat(relay): additive account_id re-keying for GitHub + Linear integrations Keys the GitHub-App install + Linear workspace to the Clerk account so any signed-in machine auto-subscribes and revocation is single-place. Strictly additive: nullable account_id columns (NULL = legacy, routes as today); an optional JWKS-verified x-ade-account-token authorizes when the mapping's account_id matches the token sub, layered on top of the unchanged assertGitHubRepoAuthorized / Linear token-org gates (dual-key, either authorizes); coalesce stamping never nulls an association; account-scoped revocation leaves the legacy path untouched. No route/PK/token/secret/ fan-out replaced; ade_proj_ retained. 43 legacy tests unchanged + a byte-identical NULL-legacy test; 46/46 relay + 91/91 ingress + 13/13 account green. Deploy needs 3 Clerk Worker vars (README). Co-Authored-By: Claude Opus 4.8 --- apps/ade-cli/src/bootstrap.ts | 9 +- apps/ade-cli/src/headlessLinearServices.ts | 7 + .../account/accountAuthService.test.ts | 21 + .../services/account/accountAuthService.ts | 14 + apps/desktop/src/main/main.ts | 14 + .../automationIngressService.test.ts | 40 ++ .../automations/automationIngressService.ts | 48 +- .../automations/linearIngressService.test.ts | 54 +- .../automations/linearIngressService.ts | 21 +- .../main/services/github/githubRelayConfig.ts | 18 +- .../services/github/githubService.test.ts | 29 ++ .../src/main/services/github/githubService.ts | 6 + apps/webhook-relay/README.md | 21 +- .../0005_account_integration_rekey.sql | 16 + apps/webhook-relay/package-lock.json | 12 + apps/webhook-relay/package.json | 4 + apps/webhook-relay/src/relay.ts | 262 +++++++++- apps/webhook-relay/test/account.test.ts | 485 ++++++++++++++++++ 18 files changed, 1031 insertions(+), 50 deletions(-) create mode 100644 apps/webhook-relay/migrations/0005_account_integration_rekey.sql create mode 100644 apps/webhook-relay/test/account.test.ts diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index ea234b71a..c51a80b26 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -147,7 +147,10 @@ import { import { createLaneWorktreeLockService, type LaneWorktreeLockService } from "../../desktop/src/main/services/lanes/laneWorktreeLockService"; import { createHeadlessLinearServices } from "./headlessLinearServices"; import { EncryptedFileCredentialStore } from "./services/credentials/credentialStore"; -import type { AccountAuthService } from "./services/account/accountAuthService"; +import { + getSignedInAccountAccessToken, + type AccountAuthService, +} from "./services/account/accountAuthService"; import { getSharedAccountAuthService, registerAccountConfigProjectRoot, @@ -692,6 +695,7 @@ export async function createAdeRuntime(args: { projectRoots: () => [projectRoot], logger, }); + const getAccountAccessToken = () => getSignedInAccountAccessToken(accountAuthService); const onboardingService = createOnboardingService({ db, logger, @@ -1045,6 +1049,7 @@ export async function createAdeRuntime(args: { openExternal: async () => {}, onGitHubStatusChanged: (status) => pushEvent("runtime", { type: "github_status_changed", event: status }), + getAccountAccessToken, }); linearIssueTrackerRef = headlessLinearServices.linearIssueTracker; githubServiceRef = headlessLinearServices.githubService as ReturnType; @@ -1201,6 +1206,7 @@ export async function createAdeRuntime(args: { onPrStateIngested: () => prPollingServiceForIngress?.poke(), secretService: automationSecretService, githubService: headlessLinearServices.githubService, + getAccountAccessToken, listRules: () => (automationService ? projectConfigService.get().effective.automations ?? [] : []), ingressCursorStore: createKvIngressCursorStore(db), // 30s halves worst-case webhook latency. Each poll is one request to our @@ -1221,6 +1227,7 @@ export async function createAdeRuntime(args: { }), getLinearClient: () => headlessLinearServices.linearClient, getLinearAccessToken: createLinearAccessTokenGetter(headlessLinearServices.linearCredentialService), + getAccountAccessToken, cursorStore: createKvIngressCursorStore(db), hasEnabledLinearRules: () => automationService?.hasEnabledLinearRules() ?? false, isAdeAppConnection: () => { diff --git a/apps/ade-cli/src/headlessLinearServices.ts b/apps/ade-cli/src/headlessLinearServices.ts index e96df57be..9112cfb09 100644 --- a/apps/ade-cli/src/headlessLinearServices.ts +++ b/apps/ade-cli/src/headlessLinearServices.ts @@ -124,6 +124,7 @@ type HeadlessLinearDeps = { conflictService: ReturnType; openExternal?: (url: string) => Promise; onGitHubStatusChanged?: (status: HeadlessGitHubStatus) => void; + getAccountAccessToken?: () => Promise; }; type HeadlessLinearServices = { @@ -438,6 +439,7 @@ export function createHeadlessGitHubService( options: { onStatusChanged?: (status: HeadlessGitHubStatus) => void; githubRelaySecretReader?: GitHubRelaySecretReader | null; + getAccountAccessToken?: (() => Promise) | null; } = {}, ): HeadlessGitHubService { const credentialStore = new EncryptedFileCredentialStore(); @@ -997,11 +999,15 @@ export function createHeadlessGitHubService( const name = args.name?.trim(); const repo = owner && name ? { owner, name } : detectGitHubRepo(projectRoot); const githubAppUserToken = await appUserAuth.getValidTokenForRelay().catch(() => null); + const accountAccessToken = options.getAccountAccessToken + ? await options.getAccountAccessToken().catch(() => null) + : null; return fetchGitHubAppInstallationStatus({ repo, secretReader: options.githubRelaySecretReader, forceRefresh: args.forceRefresh === true, githubAppUserToken, + accountAccessToken, auditLog: appUserAuth.auditLog, }); }, @@ -1933,6 +1939,7 @@ export function createHeadlessLinearServices( { onStatusChanged: args.onGitHubStatusChanged, githubRelaySecretReader: (ref) => automationSecretService.getSecret(ref), + getAccountAccessToken: args.getAccountAccessToken, }, ); const linearClient = createLinearClientImpl({ diff --git a/apps/ade-cli/src/services/account/accountAuthService.test.ts b/apps/ade-cli/src/services/account/accountAuthService.test.ts index 97462b1f2..88f0ad552 100644 --- a/apps/ade-cli/src/services/account/accountAuthService.test.ts +++ b/apps/ade-cli/src/services/account/accountAuthService.test.ts @@ -5,6 +5,7 @@ import { ACCOUNT_SESSION_CREDENTIAL_KEY, createAccountAuthService, derivePkceChallenge, + getSignedInAccountAccessToken, type AccountAuthService, type AccountSessionRecord, } from "./accountAuthService"; @@ -409,4 +410,24 @@ describe("AccountAuthService refresh and sign-out", () => { expect(service.signOut()).toMatchObject({ signedIn: false, email: null }); expect(store.getSync(ACCOUNT_SESSION_CREDENTIAL_KEY)).toBeNull(); }); + + it("returns an account relay token only for a usable signed-in account", async () => { + const getAccessToken = vi.fn(async () => "clerk-access-token"); + await expect(getSignedInAccountAccessToken({ + getStatus: () => ({ signedIn: true, userId: "user_1", email: null, name: null, expiresAt: null }), + getAccessToken, + })).resolves.toBe("clerk-access-token"); + await expect(getSignedInAccountAccessToken({ + getStatus: () => ({ signedIn: false, userId: null, email: null, name: null, expiresAt: null }), + getAccessToken, + })).resolves.toBeNull(); + expect(getAccessToken).toHaveBeenCalledTimes(1); + }); + + it("leaves legacy integration auth available when account token refresh fails", async () => { + await expect(getSignedInAccountAccessToken({ + getStatus: () => ({ signedIn: true, userId: "user_1", email: null, name: null, expiresAt: null }), + getAccessToken: async () => { throw new Error("refresh failed"); }, + })).resolves.toBeNull(); + }); }); diff --git a/apps/ade-cli/src/services/account/accountAuthService.ts b/apps/ade-cli/src/services/account/accountAuthService.ts index 6d845a4f0..89ad5109f 100644 --- a/apps/ade-cli/src/services/account/accountAuthService.ts +++ b/apps/ade-cli/src/services/account/accountAuthService.ts @@ -116,6 +116,20 @@ export type AccountActionDomainService = { getToken(): Promise; }; +export async function getSignedInAccountAccessToken( + service: Pick, +): Promise { + const status = service.getStatus(); + if (!status.signedIn || !status.userId) return null; + try { + return (await service.getAccessToken()).trim() || null; + } catch { + // Relay account credentials are additive. An unavailable refresh must not + // block the independent GitHub, Linear, or ade_proj_ authorization path. + return null; + } +} + export const ACCOUNT_ACTION_NAMES = [ "startLogin", "pollLogin", diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 2aef4d9cb..fd30e929a 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -140,6 +140,11 @@ import { } from "../../../ade-cli/src/jsonrpc"; import { resolveMachineAdeLayout } from "../../../ade-cli/src/services/projects/machineLayout"; import { normalizeProjectRootPath } from "../../../ade-cli/src/services/projects/projectRoots"; +import { getSignedInAccountAccessToken } from "../../../ade-cli/src/services/account/accountAuthService"; +import { + getSharedAccountAuthService, + registerAccountConfigProjectRoot, +} from "../../../ade-cli/src/services/account/sharedAccountAuthService"; import { uninstallRuntimeService } from "../../../ade-cli/src/serviceManager"; import { ElectronSafeStorageCredentialStore, @@ -2069,6 +2074,12 @@ app.whenReady().then(async () => { credentialStore: createDesktopCredentialStore(machineAdeLayout.secretsDir), }); const logger = createFileLogger(path.join(adePaths.logsDir, "main.jsonl")); + registerAccountConfigProjectRoot(projectRoot); + const accountAuthService = getSharedAccountAuthService({ + projectRoots: () => [projectRoot], + logger, + }); + const getAccountAccessToken = () => getSignedInAccountAccessToken(accountAuthService); const diskPressureMonitor = createDiskPressureMonitor({ roots: [projectRoot, machineAdeLayout.adeDir], logger, @@ -2541,6 +2552,7 @@ app.whenReady().then(async () => { appDataDir: app.getPath("userData"), credentialStore: createDesktopCredentialStore(machineAdeLayout.secretsDir), githubRelaySecretReader: (ref) => githubRelaySecretService?.getSecret(ref) ?? null, + getAccountAccessToken, }); const projectScaffoldService = createProjectScaffoldService({ @@ -3167,6 +3179,7 @@ app.whenReady().then(async () => { onPrStateIngested: () => prPollingServiceRef?.poke(), secretService: automationSecretService, githubService, + getAccountAccessToken, listRules: () => (automationService ? projectConfigService.get().effective.automations ?? [] : []), ingressCursorStore: createKvIngressCursorStore(db), // 30s halves worst-case webhook latency. Each poll is one request to @@ -3190,6 +3203,7 @@ app.whenReady().then(async () => { credentialStore: linearCredentialStore, getLinearClient: () => linearClient, getLinearAccessToken: createLinearAccessTokenGetter(linearCredentialService), + getAccountAccessToken, cursorStore: createKvIngressCursorStore(db), hasEnabledLinearRules: () => automationService?.hasEnabledLinearRules() ?? false, isAdeAppConnection: () => diff --git a/apps/desktop/src/main/services/automations/automationIngressService.test.ts b/apps/desktop/src/main/services/automations/automationIngressService.test.ts index 1326650ea..8e777a10f 100644 --- a/apps/desktop/src/main/services/automations/automationIngressService.test.ts +++ b/apps/desktop/src/main/services/automations/automationIngressService.test.ts @@ -454,6 +454,46 @@ describe("automationIngressService", () => { })); }); + it("polls the hosted repo relay with only the signed-in account credential", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({ + events: [], + nextCursor: null, + }), { headers: { "content-type": "application/json" } })); + + service = createAutomationIngressService({ + logger: makeLogger() as never, + automationService: { + updateIngressStatus: vi.fn(), + dispatchIngressTrigger: vi.fn(), + getIngressCursor: () => null, + setIngressCursor: vi.fn(), + getIngressStatus: () => ({}), + } as never, + secretService: { getSecret: () => null } as never, + githubService: { + detectRepo: vi.fn(async () => ({ owner: "arul28", name: "ADE" })), + getAppUserTokenForRelay: vi.fn(async () => { + throw new Error("GitHub App user auth is absent on this machine."); + }), + }, + getAccountAccessToken: vi.fn(async () => "clerk-account-token"), + listRules: () => [], + }); + + await service.pollNow(); + + expect(fetchSpy).toHaveBeenCalledWith( + "https://ade-github-webhook-relay.arulsharma1028.workers.dev/github/repos/arul28/ADE/events", + expect.objectContaining({ + headers: expect.objectContaining({ + "x-ade-account-token": "clerk-account-token", + }), + }), + ); + const headers = (fetchSpy.mock.calls[0]?.[1]?.headers ?? {}) as Record; + expect(headers.authorization).toBeUndefined(); + }); + it("polls the hosted repo relay with a GitHub App user token", async () => { const updates: Array> = []; const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({ diff --git a/apps/desktop/src/main/services/automations/automationIngressService.ts b/apps/desktop/src/main/services/automations/automationIngressService.ts index 8f791a357..b508aba6f 100644 --- a/apps/desktop/src/main/services/automations/automationIngressService.ts +++ b/apps/desktop/src/main/services/automations/automationIngressService.ts @@ -9,6 +9,7 @@ import type { AutomationSecretService } from "./automationSecretService"; import type { createPrService } from "../prs/prService"; import { createGitHubRelayAuthAuditLog, + ACCOUNT_RELAY_TOKEN_HEADER, gitHubRelayAuthorizationToken, readGitHubRelayConfig, resolveHostedGitHubRelayAuthToken, @@ -52,6 +53,7 @@ type AutomationIngressServiceArgs = { detectRepo: () => Promise | GitHubRepoRef | null; getAppUserTokenForRelay: () => Promise; } | null; + getAccountAccessToken?: () => Promise; listRules: () => AutomationRule[]; // Cursor persistence fallback. REQUIRED when automationService is null — // without it the relay cursor would silently reset on every restart @@ -496,9 +498,12 @@ export function createAutomationIngressService(args: AutomationIngressServiceArg const pollGithubRelay = async () => { const config = buildGithubRelayConfig(); const useLegacyProjectRoute = shouldUseLegacyGitHubRelayProjectRoute(config); + const accountAccessToken = args.getAccountAccessToken + ? await args.getAccountAccessToken().catch(() => null) + : null; // Skip before the "polling" status write so the "disabled" + auth-error // status reported at cooldown entry stays accurate for the whole window. - if (config.configured && !useLegacyProjectRoute && Date.now() < hostedAuthPendingUntilMs) return; + if (config.configured && !useLegacyProjectRoute && !accountAccessToken && Date.now() < hostedAuthPendingUntilMs) return; updateGithubRelayStatus({ configured: config.configured, apiBaseUrl: config.apiBaseUrl, @@ -533,31 +538,39 @@ export function createAutomationIngressService(args: AutomationIngressServiceArg try { githubAppUserToken = (await args.githubService?.getAppUserTokenForRelay()) ?? null; } catch (error) { - hostedAuthPendingUntilMs = Date.now() + HOSTED_RELAY_AUTH_PENDING_RETRY_MS; - const message = error instanceof Error ? error.message : String(error); - if (!hostedAuthPendingLogged) { - hostedAuthPendingLogged = true; - args.logger.info("automations.github_relay_auth_pending", { error: message }); + if (accountAccessToken) { + githubAppUserToken = null; + } else { + hostedAuthPendingUntilMs = Date.now() + HOSTED_RELAY_AUTH_PENDING_RETRY_MS; + const message = error instanceof Error ? error.message : String(error); + if (!hostedAuthPendingLogged) { + hostedAuthPendingLogged = true; + args.logger.info("automations.github_relay_auth_pending", { error: message }); + } + updateGithubRelayStatus({ + healthy: false, + status: "disabled", + lastPolledAt: new Date().toISOString(), + lastError: message, + }); + return; } - updateGithubRelayStatus({ - healthy: false, - status: "disabled", - lastPolledAt: new Date().toISOString(), - lastError: message, - }); - return; } } const hostedAuth = useLegacyProjectRoute ? null : resolveHostedGitHubRelayAuthToken({ githubAppUserToken }); - if (hostedAuth && !hostedAuth.ok) { + if (hostedAuth && !hostedAuth.ok && !accountAccessToken) { throw new Error(hostedAuth.error); } hostedAuthPendingUntilMs = 0; hostedAuthPendingLogged = false; - const authToken = useLegacyProjectRoute ? legacyAuthToken : hostedAuth?.token ?? null; - if (!authToken) { + const authToken = useLegacyProjectRoute + ? legacyAuthToken + : hostedAuth?.ok + ? hostedAuth.token + : null; + if (!authToken && (!accountAccessToken || useLegacyProjectRoute)) { throw new Error("GitHub auth is required for relay polling."); } if (hostedAuth?.ok && repo) { @@ -581,7 +594,8 @@ export function createAutomationIngressService(args: AutomationIngressServiceArg { headers: { accept: "application/json", - authorization: `Bearer ${authToken}`, + ...(authToken ? { authorization: `Bearer ${authToken}` } : {}), + ...(accountAccessToken ? { [ACCOUNT_RELAY_TOKEN_HEADER]: accountAccessToken } : {}), }, signal: controller.signal, } diff --git a/apps/desktop/src/main/services/automations/linearIngressService.test.ts b/apps/desktop/src/main/services/automations/linearIngressService.test.ts index ffbc7a935..c17c9d89d 100644 --- a/apps/desktop/src/main/services/automations/linearIngressService.test.ts +++ b/apps/desktop/src/main/services/automations/linearIngressService.test.ts @@ -70,7 +70,13 @@ function makeWebhook(id = "webhook-1") { }; } -function createHarness(options: { enabled?: boolean; fetchImpl?: typeof fetch; adeApp?: boolean } = {}) { +function createHarness(options: { + enabled?: boolean; + fetchImpl?: typeof fetch; + adeApp?: boolean; + linearToken?: string | null; + accountToken?: string | null; +} = {}) { const db = new FakeDb(); const credentials = new FakeCredentialStore(); const cursorBySource = new Map(); @@ -99,7 +105,8 @@ function createHarness(options: { enabled?: boolean; fetchImpl?: typeof fetch; a projectId: "project-1", credentialStore: credentials, getLinearClient: () => client, - getLinearAccessToken: () => "Bearer linear-oauth-token", + getLinearAccessToken: () => options.linearToken === undefined ? "Bearer linear-oauth-token" : options.linearToken, + ...(options.accountToken ? { getAccountAccessToken: async () => options.accountToken ?? null } : {}), cursorStore: { get: (source) => cursorBySource.get(source) ?? null, set: ({ source, cursor }) => cursorBySource.set(source, cursor), @@ -181,6 +188,49 @@ describe("linearIngressService", () => { expect(Array.from(harness.db.kv.values())).not.toContain(registeredSecret); }); + it("attaches the account credential alongside Linear auth on register and poll requests", async () => { + const seenPaths: string[] = []; + const fetchImpl = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(String(input)); + const headers = new Headers(init?.headers); + seenPaths.push(url.pathname); + expect(headers.get("authorization")).toBe("Bearer linear-oauth-token"); + expect(headers.get("x-ade-account-token")).toBe("clerk-account-token"); + if (url.pathname.endsWith("/register")) { + return new Response(JSON.stringify({ organizationId: "org-1" }), { status: 200 }); + } + return new Response(JSON.stringify({ events: [], nextCursor: null, cursorExpired: false }), { status: 200 }); + }) as unknown as typeof fetch; + const harness = createHarness({ fetchImpl, accountToken: "clerk-account-token" }); + + await harness.service.setup(); + await harness.service.pollNow(); + + expect(seenPaths).toEqual([ + "/linear/orgs/register", + "/linear/orgs/org-1/events", + ]); + }); + + it("polls a configured Linear relay with only the signed-in account credential", async () => { + const fetchImpl = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + const headers = new Headers(init?.headers); + expect(headers.get("authorization")).toBeNull(); + expect(headers.get("x-ade-account-token")).toBe("clerk-account-token"); + return new Response(JSON.stringify({ events: [], nextCursor: null, cursorExpired: false }), { status: 200 }); + }) as unknown as typeof fetch; + const harness = createHarness({ + fetchImpl, + linearToken: null, + accountToken: "clerk-account-token", + }); + configureReady(harness); + + await harness.service.pollNow(); + + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + it("reuses an existing relay webhook and re-registers the stored signing secret", async () => { const registeredSecrets: string[] = []; const fetchImpl = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { diff --git a/apps/desktop/src/main/services/automations/linearIngressService.ts b/apps/desktop/src/main/services/automations/linearIngressService.ts index 61f0ea00d..c11fa84b4 100644 --- a/apps/desktop/src/main/services/automations/linearIngressService.ts +++ b/apps/desktop/src/main/services/automations/linearIngressService.ts @@ -5,6 +5,7 @@ import { linearIngressKindFromParts } from "../../../shared/types/linearSync"; import type { Logger } from "../logging/logger"; import type { AdeDb } from "../state/kvDb"; import type { LinearWebhookSummary } from "../cto/linearClient"; +import { ACCOUNT_RELAY_TOKEN_HEADER } from "../github/githubRelayConfig"; import { LINEAR_RELAY_LAST_ERROR_REF, LINEAR_RELAY_LAST_EVENT_AT_REF, @@ -95,6 +96,7 @@ export type LinearIngressServiceDeps = { getLinearClient: () => LinearWebhookClient | null; /** Raw API key or an OAuth value already prefixed with `Bearer `. */ getLinearAccessToken: () => string | null | Promise; + getAccountAccessToken?: () => Promise; cursorStore: LinearIngressCursorStore; /** Awaited before the cursor advances past the delivery. */ dispatch: (record: LinearIngressEventRecord) => void | Promise; @@ -253,15 +255,23 @@ export function createLinearIngressService(deps: LinearIngressServiceDeps) { return authorization; }; + const readAccountAccessToken = async (): Promise => { + return deps.getAccountAccessToken + ? await deps.getAccountAccessToken().catch(() => null) + : null; + }; + const registerOrganization = async (args: { relayBaseUrl: string; authorization: string; secret: string; }): Promise => { + const accountAccessToken = await readAccountAccessToken(); const response = await fetchImpl(`${args.relayBaseUrl}/linear/orgs/register`, { method: "POST", headers: { authorization: args.authorization, + ...(accountAccessToken ? { [ACCOUNT_RELAY_TOKEN_HEADER]: accountAccessToken } : {}), "content-type": "application/json", }, body: JSON.stringify({ secret: args.secret }), @@ -426,7 +436,11 @@ export function createLinearIngressService(deps: LinearIngressServiceDeps) { status = getStatus(); } if (status.state !== "ready" || !status.organizationId) return; - const authorization = await requireAuthorization(); + const authorization = (await deps.getLinearAccessToken())?.trim() ?? ""; + const accountAccessToken = await readAccountAccessToken(); + if (!authorization && !accountAccessToken) { + throw new Error("Connect Linear before configuring webhook ingestion."); + } // Drain full pages within one tick so a backlog larger than one page is // delivered this poll instead of trickling out one page per interval. @@ -436,7 +450,10 @@ export function createLinearIngressService(deps: LinearIngressServiceDeps) { url.searchParams.set("limit", String(LINEAR_RELAY_PAGE_LIMIT)); if (cursor) url.searchParams.set("after", cursor); const response = await fetchImpl(url, { - headers: { authorization }, + headers: { + ...(authorization ? { authorization } : {}), + ...(accountAccessToken ? { [ACCOUNT_RELAY_TOKEN_HEADER]: accountAccessToken } : {}), + }, // pollInFlight only clears in `finally`; an unbounded hung request // would block scheduled and manual polls until restart. signal: AbortSignal.timeout(30_000), diff --git a/apps/desktop/src/main/services/github/githubRelayConfig.ts b/apps/desktop/src/main/services/github/githubRelayConfig.ts index dd89b8b22..22cefad74 100644 --- a/apps/desktop/src/main/services/github/githubRelayConfig.ts +++ b/apps/desktop/src/main/services/github/githubRelayConfig.ts @@ -19,6 +19,7 @@ export const GITHUB_RELAY_API_BASE_ENV_KEYS = ["ADE_GITHUB_RELAY_API_BASE_URL", export const GITHUB_RELAY_PROJECT_ENV_KEYS = ["ADE_GITHUB_RELAY_REMOTE_PROJECT_ID", "GITHUB_RELAY_REMOTE_PROJECT_ID"] as const; export const GITHUB_RELAY_TOKEN_ENV_KEYS = ["ADE_GITHUB_RELAY_ACCESS_TOKEN", "GITHUB_RELAY_ACCESS_TOKEN"] as const; export const GITHUB_RELAY_PROJECT_TOKEN_PREFIX = "ade_proj_"; +export const ACCOUNT_RELAY_TOKEN_HEADER = "x-ade-account-token"; const GITHUB_RELAY_PROJECT_TOKEN_CONTEXT = "ade-github-relay-project"; export type GitHubRelaySecretReader = (ref: string) => string | null | undefined; @@ -204,6 +205,7 @@ export async function fetchGitHubAppInstallationStatus(args: { fetchImpl?: typeof fetch; forceRefresh?: boolean; githubAppUserToken?: string | null; + accountAccessToken?: string | null; auditLog?: GitHubRelayAuthAuditLog | null; }): Promise { const config = readGitHubRelayConfig(args.secretReader); @@ -225,6 +227,7 @@ export async function fetchGitHubAppInstallationStatus(args: { try { const baseUrl = config.apiBaseUrl!.replace(/\/+$/, ""); const githubAppUserToken = args.githubAppUserToken?.trim(); + const accountAccessToken = args.accountAccessToken?.trim(); const legacyAuthToken = gitHubRelayAuthorizationToken(config); const useLegacyProjectRoute = shouldUseLegacyGitHubRelayProjectRoute(config); const url = useLegacyProjectRoute @@ -233,15 +236,19 @@ export async function fetchGitHubAppInstallationStatus(args: { const hostedAuth = useLegacyProjectRoute ? null : resolveHostedGitHubRelayAuthToken({ githubAppUserToken }); - if (hostedAuth && !hostedAuth.ok) { + if (hostedAuth && !hostedAuth.ok && !accountAccessToken) { return baseStatus(args.repo, { relayConfigured: true, state: "error", error: hostedAuth.error, }); } - const authToken = useLegacyProjectRoute ? legacyAuthToken : hostedAuth?.token ?? null; - if (!authToken) { + const authToken = useLegacyProjectRoute + ? legacyAuthToken + : hostedAuth?.ok + ? hostedAuth.token + : null; + if (!authToken && (!accountAccessToken || useLegacyProjectRoute)) { return baseStatus(args.repo, { relayConfigured: true, state: "error", @@ -260,7 +267,10 @@ export async function fetchGitHubAppInstallationStatus(args: { method: "GET", headers: { accept: "application/json", - authorization: `Bearer ${authToken}`, + ...(authToken ? { authorization: `Bearer ${authToken}` } : {}), + ...(accountAccessToken && !useLegacyProjectRoute + ? { [ACCOUNT_RELAY_TOKEN_HEADER]: accountAccessToken } + : {}), }, }); const payload = await response.json().catch(() => ({})); diff --git a/apps/desktop/src/main/services/github/githubService.test.ts b/apps/desktop/src/main/services/github/githubService.test.ts index 042b4fba6..7450bc6e4 100644 --- a/apps/desktop/src/main/services/github/githubService.test.ts +++ b/apps/desktop/src/main/services/github/githubService.test.ts @@ -117,6 +117,7 @@ function makeService(options: { credentialStore?: MemoryCredentialStore; ghAuthTokenProvider?: () => { token: string | null; ghCliPath: string | null; ghAuthError: string | null }; githubRelaySecretReader?: (ref: string) => string | null; + getAccountAccessToken?: () => Promise; } = {}) { return createGithubService({ logger: makeLogger(), @@ -125,6 +126,7 @@ function makeService(options: { credentialStore: options.credentialStore as any, ghAuthTokenProvider: options.ghAuthTokenProvider, githubRelaySecretReader: options.githubRelaySecretReader, + getAccountAccessToken: options.getAccountAccessToken, }); } @@ -1342,6 +1344,33 @@ describe("githubService.getAppInstallationStatus", () => { expect(mockFetch).not.toHaveBeenCalled(); }); + it("checks the hosted relay with only the signed-in account credential", async () => { + mockFetch.mockResolvedValueOnce(jsonResponse(200, { + installed: true, + state: "configured", + installationId: 123, + repositorySelection: "selected", + checkedAt: "2026-06-30T00:00:01.000Z", + })); + + const status = await makeService({ + getAccountAccessToken: async () => "clerk-account-token", + }).getAppInstallationStatus({ owner: "acme", name: "repo" }); + + expect(status.installed).toBe(true); + expect(mockFetch).toHaveBeenCalledWith( + "https://ade-github-webhook-relay.arulsharma1028.workers.dev/github/repos/acme/repo/status", + expect.objectContaining({ + method: "GET", + headers: expect.objectContaining({ + "x-ade-account-token": "clerk-account-token", + }), + }), + ); + const headers = mockFetch.mock.calls[0]?.[1]?.headers as Record; + expect(headers.authorization).toBeUndefined(); + }); + it("checks the hosted relay with a GitHub App user token", async () => { process.env.ADE_GITHUB_TOKEN = "ghp_user_token"; const credentialStore = new MemoryCredentialStore(); diff --git a/apps/desktop/src/main/services/github/githubService.ts b/apps/desktop/src/main/services/github/githubService.ts index 262b6ef2d..9e551e76b 100644 --- a/apps/desktop/src/main/services/github/githubService.ts +++ b/apps/desktop/src/main/services/github/githubService.ts @@ -346,6 +346,7 @@ export function createGithubService({ credentialStore, ghAuthTokenProvider, githubRelaySecretReader, + getAccountAccessToken, }: { logger: Logger; projectRoot: string; @@ -353,6 +354,7 @@ export function createGithubService({ credentialStore?: SyncCredentialStore | null; ghAuthTokenProvider?: (() => GitHubCliAuthResult) | null; githubRelaySecretReader?: GitHubRelaySecretReader | null; + getAccountAccessToken?: (() => Promise) | null; }) { const legacyGithubStateDir = resolveAdeLayout(projectRoot).githubSecretsDir; const legacyTokenPath = path.join(legacyGithubStateDir, AUTH_STORE_FILE_NAME); @@ -1064,11 +1066,15 @@ export function createGithubService({ const name = args.name?.trim(); const repo = owner && name ? { owner, name } : await detectRepo(); const githubAppUserToken = await appUserAuth.getValidTokenForRelay().catch(() => null); + const accountAccessToken = getAccountAccessToken + ? await getAccountAccessToken().catch(() => null) + : null; return fetchGitHubAppInstallationStatus({ repo, secretReader: githubRelaySecretReader, forceRefresh: args.forceRefresh === true, githubAppUserToken, + accountAccessToken, auditLog: appUserAuth.auditLog, }); }; diff --git a/apps/webhook-relay/README.md b/apps/webhook-relay/README.md index 43dbcb42e..f9b8fbf9b 100644 --- a/apps/webhook-relay/README.md +++ b/apps/webhook-relay/README.md @@ -44,6 +44,7 @@ and each ADE poll is one Worker request plus a bounded D1 read. npm --prefix apps/webhook-relay install npm --prefix apps/webhook-relay run typecheck npm --prefix apps/webhook-relay run test +npm --prefix apps/webhook-relay run build npm --prefix apps/webhook-relay run dev ``` @@ -70,6 +71,9 @@ cd apps/webhook-relay printf '%s' "$GITHUB_WEBHOOK_SECRET" | npx wrangler secret put GITHUB_WEBHOOK_SECRET printf '%s' "$GITHUB_APP_ID" | npx wrangler secret put GITHUB_APP_ID printf '%s' "$GITHUB_APP_PRIVATE_KEY" | npx wrangler secret put GITHUB_APP_PRIVATE_KEY +printf '%s' "$CLERK_JWKS_URL" | npx wrangler secret put CLERK_JWKS_URL +printf '%s' "$CLERK_ISSUER" | npx wrangler secret put CLERK_ISSUER +printf '%s' "$CLERK_OAUTH_CLIENT_ID" | npx wrangler secret put CLERK_OAUTH_CLIENT_ID npm run deploy ``` @@ -80,8 +84,19 @@ when webhook state has not arrived yet or the user presses Refresh in Settings. `GITHUB_APP_PRIVATE_KEY` should be the private key PEM downloaded from the GitHub App settings. -Linear support requires no new Wrangler secrets. Each ADE client generates its -own webhook signing secret and registers it into the +`CLERK_JWKS_URL`, `CLERK_ISSUER`, and `CLERK_OAUTH_CLIENT_ID` enable the +additive account integration path. Signed-in ADE clients send the Clerk access +token in `x-ade-account-token` alongside the existing GitHub or Linear +`Authorization` header. A matching Clerk `sub` can read a repository or Linear +organization already associated with that account; callers without this header +continue through the legacy authorization paths unchanged. `GET /account/integrations` +lists only the caller's associated repositories and +Linear organizations. `DELETE /account/integrations` clears those account +associations without deleting provider mappings, events, signing secrets, or +legacy access. + +Legacy Linear support requires no additional Wrangler secrets. Each ADE client +generates its own webhook signing secret and registers it into the `linear_organizations` D1 table through the authenticated registration route. For local tests or a Linear-compatible proxy, `LINEAR_API_BASE_URL` overrides the default `https://api.linear.app/graphql` endpoint. It may be the GraphQL URL @@ -143,6 +158,8 @@ UUID or a stable ADE project slug. Self-hosted legacy project-token routes do not use the hosted app-user token and continue to authenticate with the derived `ade_proj_...` relay token. +The `ade_proj_...` scheme remains supported alongside account authentication; +account re-keying does not replace or retire it. For dev/runtime launches, ADE also accepts env vars instead of `local.secret.yaml`. Setting these opts the runtime into the legacy diff --git a/apps/webhook-relay/migrations/0005_account_integration_rekey.sql b/apps/webhook-relay/migrations/0005_account_integration_rekey.sql new file mode 100644 index 000000000..6ddff36c0 --- /dev/null +++ b/apps/webhook-relay/migrations/0005_account_integration_rekey.sql @@ -0,0 +1,16 @@ +alter table github_app_repositories add column account_id text; +alter table github_events add column account_id text; +alter table linear_organizations add column account_id text; +alter table linear_events add column account_id text; + +create index if not exists idx_github_app_repositories_account + on github_app_repositories(account_id, repository_key); + +create index if not exists idx_github_events_account_repository_received + on github_events(account_id, repository_full_name collate nocase, received_at desc, event_id desc); + +create index if not exists idx_linear_organizations_account + on linear_organizations(account_id, org_id); + +create index if not exists idx_linear_events_account_org_received + on linear_events(account_id, org_id, received_at desc, event_id desc); diff --git a/apps/webhook-relay/package-lock.json b/apps/webhook-relay/package-lock.json index ac15ff507..cbc86c407 100644 --- a/apps/webhook-relay/package-lock.json +++ b/apps/webhook-relay/package-lock.json @@ -7,6 +7,9 @@ "": { "name": "ade-webhook-relay", "version": "0.0.0", + "dependencies": { + "jose": "^6.2.3" + }, "devDependencies": { "@cloudflare/workers-types": "^4.20260620.0", "@types/node": "^20.11.30", @@ -1974,6 +1977,15 @@ "node": "*" } }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/kleur": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", diff --git a/apps/webhook-relay/package.json b/apps/webhook-relay/package.json index 2909288cf..56d5ca8b3 100644 --- a/apps/webhook-relay/package.json +++ b/apps/webhook-relay/package.json @@ -8,9 +8,13 @@ "deploy": "wrangler deploy", "d1:migrate:local": "wrangler d1 migrations apply ade-github-relay --local", "d1:migrate:remote": "wrangler d1 migrations apply ade-github-relay --remote", + "build": "wrangler deploy --dry-run --outdir dist", "test": "vitest run", "typecheck": "tsc -p tsconfig.json --noEmit" }, + "dependencies": { + "jose": "^6.2.3" + }, "devDependencies": { "@cloudflare/workers-types": "^4.20260620.0", "@types/node": "^20.11.30", diff --git a/apps/webhook-relay/src/relay.ts b/apps/webhook-relay/src/relay.ts index 1b4e68ceb..c6ffbdf53 100644 --- a/apps/webhook-relay/src/relay.ts +++ b/apps/webhook-relay/src/relay.ts @@ -1,3 +1,5 @@ +import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose"; + export type RelayEnv = { DB: D1Database; GITHUB_WEBHOOK_SECRET: string; @@ -7,6 +9,9 @@ export type RelayEnv = { GITHUB_APP_PRIVATE_KEY?: string; GITHUB_API_BASE_URL?: string; LINEAR_API_BASE_URL?: string; + CLERK_JWKS_URL?: string; + CLERK_ISSUER?: string; + CLERK_OAUTH_CLIENT_ID?: string; /** * Signing secret of the ADE Linear OAuth application. OAuth-app webhooks * sign every workspace's deliveries with this one app-level secret (unlike @@ -45,6 +50,23 @@ type LinearOrganizationRow = { webhook_secret: string; }; +type AccountMappingRow = { + account_id: string | null; +}; + +type AccountRepositoryRow = { + repository_full_name: string; + owner: string; + name: string; + installation_id: number | null; + repository_selection: string | null; + installed: number; +}; + +type AccountLinearOrganizationRow = { + org_id: string; +}; + type LinearViewerOrganizationResult = | { authorized: true; organizationId: string } | { authorized: false; response: Response }; @@ -74,6 +96,7 @@ type AppRepositoryRow = { installed: number; last_seen_at: string; removed_at: string | null; + account_id: string | null; }; type LatestHookConfigRow = { @@ -119,9 +142,11 @@ const LINEAR_AUTH_CACHE_TTL_MS = 5 * 60_000; const MAX_LINEAR_AUTH_CACHE_ENTRIES = 1_000; const PROJECT_RELAY_TOKEN_PREFIX = "ade_proj_"; const PROJECT_RELAY_TOKEN_CONTEXT = "ade-github-relay-project"; +const ACCOUNT_TOKEN_HEADER = "x-ade-account-token"; const encoder = new TextEncoder(); const linearOrganizationByTokenHash = new Map(); const linearWebhookAuthorityByTokenHash = new Map(); +const remoteJwksByUrl = new Map>(); function json(value: unknown, init: ResponseInit = {}): Response { return new Response(JSON.stringify(value), { @@ -398,6 +423,87 @@ function readAuthorizationHeader(request: Request): string { return request.headers.get("authorization")?.trim() ?? ""; } +function getRemoteJwks(rawUrl: string): ReturnType { + const url = new URL(rawUrl); + const cacheKey = url.toString(); + const cached = remoteJwksByUrl.get(cacheKey); + if (cached) return cached; + const jwks = createRemoteJWKSet(url); + remoteJwksByUrl.set(cacheKey, jwks); + return jwks; +} + +function audienceIncludes(audience: JWTPayload["aud"], expected: string): boolean { + return typeof audience === "string" ? audience === expected : Array.isArray(audience) && audience.includes(expected); +} + +function isAllowedAccountToken(payload: JWTPayload, oauthClientId: string): boolean { + if (payload.aud === undefined) return true; + return audienceIncludes(payload.aud, oauthClientId) || payload.azp === oauthClientId; +} + +function looksLikeJwt(value: string): boolean { + return value.split(".").length === 3; +} + +function accountTokenCandidates(request: Request): string[] { + const explicit = request.headers.get(ACCOUNT_TOKEN_HEADER)?.trim().replace(/^Bearer\s+/i, "") ?? ""; + const bearer = readBearerToken(request); + return [...new Set([explicit, bearer].filter((token) => token && looksLikeJwt(token)))]; +} + +export async function verifyAccountToken(token: string, env: RelayEnv): Promise { + const issuer = env.CLERK_ISSUER?.trim() ?? ""; + const jwksUrl = env.CLERK_JWKS_URL?.trim() ?? ""; + const oauthClientId = env.CLERK_OAUTH_CLIENT_ID?.trim() ?? ""; + if (!issuer || !jwksUrl || !oauthClientId) throw new Error("Clerk authentication is not configured"); + + const { payload } = await jwtVerify(token, getRemoteJwks(jwksUrl), { + issuer, + algorithms: ["RS256"], + clockTolerance: 5, + }); + if (typeof payload.sub !== "string" || !payload.sub.trim()) throw new Error("Token subject is required"); + if (!isAllowedAccountToken(payload, oauthClientId)) throw new Error("Token audience is not allowed"); + return payload.sub; +} + +async function authenticateAccount(request: Request, env: RelayEnv): Promise { + for (const token of accountTokenCandidates(request)) { + try { + return await verifyAccountToken(token, env); + } catch { + // Account auth is additive. An invalid or absent account credential must + // never suppress a successful legacy GitHub/Linear authorization path. + } + } + return null; +} + +async function githubRepositoryAccountMatches( + env: RelayEnv, + repo: { owner: string; name: string }, + accountId: string, +): Promise { + const row = await env.DB + .prepare("select account_id from github_app_repositories where repository_key = ? limit 1") + .bind(`${repo.owner}/${repo.name}`.toLowerCase()) + .first(); + return row?.account_id === accountId; +} + +async function linearOrganizationAccountMatches( + env: RelayEnv, + organizationId: string, + accountId: string, +): Promise { + const row = await env.DB + .prepare("select account_id from linear_organizations where org_id = ? limit 1") + .bind(organizationId) + .first(); + return row?.account_id === accountId; +} + function linearGraphqlUrl(env: RelayEnv): string { const configured = env.LINEAR_API_BASE_URL?.trim() || "https://api.linear.app/graphql"; const url = new URL(configured); @@ -787,6 +893,22 @@ async function upsertAppRepository( .run(); } +async function associateGitHubRepositoryWithAccount( + env: RelayEnv, + repositoryKey: string, + repositoryFullName: string, + accountId: string, +): Promise { + await env.DB + .prepare("update github_app_repositories set account_id = ? where repository_key = ?") + .bind(accountId, repositoryKey) + .run(); + await env.DB + .prepare("update github_events set account_id = ? where repository_full_name = ? collate nocase") + .bind(accountId, repositoryFullName) + .run(); +} + function gitHubAppApiConfigured(env: RelayEnv): boolean { return Boolean(env.GITHUB_APP_ID?.trim() && env.GITHUB_APP_PRIVATE_KEY?.trim()); } @@ -1024,6 +1146,7 @@ async function handleGitHubWebhook(request: Request, env: RelayEnv, projectId: s if (!isRecord(payload)) return json({ ok: false, error: "payload must be an object" }, { status: 400 }); const payloadJson = JSON.stringify(payload); + const repoFullName = repositoryFullName(payload); const eventId = githubDelivery || `sha256:${(await sha256Hex(`${githubEvent}:${payloadJson}`)).slice(0, 32)}`; const existing = await env.DB .prepare("select event_id from github_events where project_id = ? and event_id = ? limit 1") @@ -1031,24 +1154,31 @@ async function handleGitHubWebhook(request: Request, env: RelayEnv, projectId: s .first<{ event_id: string }>(); if (existing) return json({ ok: true, duplicate: true, eventId }, { status: 202 }); + const accountMapping = repoFullName + ? await env.DB + .prepare("select account_id from github_app_repositories where repository_key = ? limit 1") + .bind(repoFullName.toLowerCase()) + .first() + : null; const receivedAt = new Date().toISOString(); await env.DB .prepare(` insert into github_events( project_id, event_id, github_event, github_delivery, repository_full_name, - installation_id, summary, payload_json, received_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?) + installation_id, summary, payload_json, received_at, account_id + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `) .bind( projectId, eventId, githubEvent, githubDelivery || null, - repositoryFullName(payload), + repoFullName, installationId(payload), summarizeGitHubEvent(githubEvent, payload), payloadJson, receivedAt, + accountMapping?.account_id ?? null, ) .run(); @@ -1176,7 +1306,12 @@ async function handleListEvents(request: Request, env: RelayEnv, projectId: stri async function handleListRepoEvents(request: Request, env: RelayEnv, repo: { owner: string; name: string }): Promise { if (request.method !== "GET") return text("method not allowed", 405); const auth = await assertGitHubRepoAuthorized(request, env, repo); - if (!auth.authorized) return auth.response; + if (!auth.authorized) { + const accountId = await authenticateAccount(request, env); + if (!accountId || !await githubRepositoryAccountMatches(env, repo, accountId)) { + return auth.response; + } + } const url = new URL(request.url); const limit = parseLimit(url); @@ -1387,27 +1522,36 @@ async function handleWebhookDeliveries(request: Request, env: RelayEnv, repo: { async function handleRepoStatus(request: Request, env: RelayEnv, repo: { projectId: string | null; owner: string; name: string }): Promise { if (request.method !== "GET") return text("method not allowed", 405); + let accountId: string | null = null; if (repo.projectId) { const authError = await assertProjectRelayAuthorized(request, env, repo.projectId); if (authError) return authError; + accountId = await authenticateAccount(request, env); } else { const auth = await assertGitHubRepoAuthorized(request, env, repo); - if (!auth.authorized) return auth.response; + accountId = await authenticateAccount(request, env); + if (!auth.authorized && (!accountId || !await githubRepositoryAccountMatches(env, repo, accountId))) { + return auth.response; + } } const forceRefresh = new URL(request.url).searchParams.get("refresh") === "1"; const key = `${repo.owner}/${repo.name}`.toLowerCase(); const diagnostics = await readWebhookEventDiagnostics(env); - const row = await env.DB + let row = await env.DB .prepare(` select repository_full_name, installation_id, repository_selection, - installed, last_seen_at, removed_at + installed, last_seen_at, removed_at, account_id from github_app_repositories where repository_key = ? limit 1 `) .bind(key) .first(); + if (accountId && row && row.account_id !== accountId) { + await associateGitHubRepositoryWithAccount(env, key, row.repository_full_name, accountId); + row = { ...row, account_id: accountId }; + } const checkedAt = new Date().toISOString(); const installedFromWebhook = row?.installed === 1 && !row.removed_at; if ((forceRefresh || !installedFromWebhook) && gitHubAppApiConfigured(env)) { @@ -1425,6 +1569,9 @@ async function handleRepoStatus(request: Request, env: RelayEnv, repo: { project sourceEvent: "github_app_api", seenAt: checkedAt, }); + if (accountId) { + await associateGitHubRepositoryWithAccount(env, key, fullName, accountId); + } return json({ repo: { owner: repo.owner, name: repo.name, fullName }, installed: true, @@ -1582,6 +1729,7 @@ async function handleLinearOrganizationRegister(request: Request, env: RelayEnv) if (!auth.authorized) return auth.response; const authority = await verifyLinearWebhookAuthority(request, env); if (!authority.authorized) return authority.response; + const accountId = await authenticateAccount(request, env); if (contentLengthExceedsLimit(request.headers, MAX_LINEAR_REGISTRATION_BODY_BYTES)) { return json({ ok: false, error: "payload too large" }, { status: 413 }); } @@ -1608,14 +1756,21 @@ async function handleLinearOrganizationRegister(request: Request, env: RelayEnv) const now = new Date().toISOString(); await env.DB .prepare(` - insert into linear_organizations(org_id, webhook_secret, registered_at, updated_at) - values (?, ?, ?, ?) + insert into linear_organizations(org_id, webhook_secret, registered_at, updated_at, account_id) + values (?, ?, ?, ?, ?) on conflict(org_id) do update set webhook_secret = excluded.webhook_secret, - updated_at = excluded.updated_at + updated_at = excluded.updated_at, + account_id = coalesce(excluded.account_id, linear_organizations.account_id) `) - .bind(auth.organizationId, secret, now, now) + .bind(auth.organizationId, secret, now, now, accountId) .run(); + if (accountId) { + await env.DB + .prepare("update linear_events set account_id = ? where org_id = ?") + .bind(accountId, auth.organizationId) + .run(); + } return json({ organizationId: auth.organizationId }); } @@ -1699,13 +1854,19 @@ async function handleLinearWebhook(request: Request, env: RelayEnv): Promise(); if (existing) return json({ ok: true, duplicate: true, eventId }); + const accountMapping = organization + ? await env.DB + .prepare("select account_id from linear_organizations where org_id = ? limit 1") + .bind(organizationId) + .first() + : null; const receivedAt = new Date().toISOString(); await env.DB .prepare(` - insert or ignore into linear_events(org_id, event_id, event_type, action, received_at, body) - values (?, ?, ?, ?, ?, ?) + insert or ignore into linear_events(org_id, event_id, event_type, action, received_at, body, account_id) + values (?, ?, ?, ?, ?, ?, ?) `) - .bind(organizationId, eventId, eventType, action, receivedAt, rawBody) + .bind(organizationId, eventId, eventType, action, receivedAt, rawBody, accountMapping?.account_id ?? null) .run(); await pruneOldLinearEvents(env); @@ -1736,15 +1897,24 @@ async function handleListLinearEvents( ): Promise { if (request.method !== "GET") return text("method not allowed", 405); const auth = await verifyLinearViewerOrganization(request, env); - if (!auth.authorized) return auth.response; - if (auth.organizationId !== organizationId) { - return json({ ok: false, error: "forbidden" }, { status: 403 }); + let legacyError: Response | null = null; + if (!auth.authorized) { + legacyError = auth.response; + } else if (auth.organizationId !== organizationId) { + legacyError = json({ ok: false, error: "forbidden" }, { status: 403 }); + } else { + // Membership alone must not expose the org-wide backlog: app-delivered + // events can include private-team payloads a plain member cannot see in + // Linear. Reads require the same webhook authority as registration. + const authority = await verifyLinearWebhookAuthority(request, env); + if (!authority.authorized) legacyError = authority.response; + } + if (legacyError) { + const accountId = await authenticateAccount(request, env); + if (!accountId || !await linearOrganizationAccountMatches(env, organizationId, accountId)) { + return legacyError; + } } - // Membership alone must not expose the org-wide backlog: app-delivered - // events can include private-team payloads a plain member cannot see in - // Linear. Reads require the same webhook authority as registration. - const authority = await verifyLinearWebhookAuthority(request, env); - if (!authority.authorized) return authority.response; const url = new URL(request.url); const limit = parseLimit(url); @@ -1819,12 +1989,60 @@ async function handleListLinearEvents( }); } +async function handleAccountIntegrations(request: Request, env: RelayEnv): Promise { + if (request.method !== "GET" && request.method !== "DELETE") return text("method not allowed", 405); + const accountId = await authenticateAccount(request, env); + if (!accountId) return json({ ok: false, error: "unauthorized" }, { status: 401 }); + + if (request.method === "DELETE") { + await env.DB.prepare("update github_app_repositories set account_id = null where account_id = ?") + .bind(accountId) + .run(); + await env.DB.prepare("update github_events set account_id = null where account_id = ?") + .bind(accountId) + .run(); + await env.DB.prepare("update linear_organizations set account_id = null where account_id = ?") + .bind(accountId) + .run(); + await env.DB.prepare("update linear_events set account_id = null where account_id = ?") + .bind(accountId) + .run(); + return json({ ok: true }); + } + + const repositories = (await env.DB.prepare(` + select repository_full_name, owner, name, installation_id, + repository_selection, installed + from github_app_repositories + where account_id = ? + order by repository_full_name collate nocase + `).bind(accountId).all()).results ?? []; + const linearOrganizations = (await env.DB.prepare(` + select org_id + from linear_organizations + where account_id = ? + order by org_id + `).bind(accountId).all()).results ?? []; + return json({ + repositories: repositories.map((row) => ({ + owner: row.owner, + name: row.name, + fullName: row.repository_full_name, + installationId: row.installation_id, + repositorySelection: row.repository_selection, + installed: row.installed === 1, + })), + linearOrganizations: linearOrganizations.map((row) => ({ organizationId: row.org_id })), + }); +} + export async function handleRequest(request: Request, env: RelayEnv): Promise { const url = new URL(request.url); if (url.pathname === "/health") { return json({ ok: true }); } + if (url.pathname === "/account/integrations") return await handleAccountIntegrations(request, env); if (url.pathname === "/linear/orgs/register") return await handleLinearOrganizationRegister(request, env); if (url.pathname === "/linear/webhook") return await handleLinearWebhook(request, env); const linearOrganizationEvents = routeLinearOrganizationEvents(url.pathname); diff --git a/apps/webhook-relay/test/account.test.ts b/apps/webhook-relay/test/account.test.ts new file mode 100644 index 000000000..7a5b6df4d --- /dev/null +++ b/apps/webhook-relay/test/account.test.ts @@ -0,0 +1,485 @@ +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { exportJWK, generateKeyPair, SignJWT } from "jose"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { handleRequest, verifyAccountToken, type RelayEnv } from "../src/relay"; + +type RepositoryRow = { + repository_key: string; + repository_full_name: string; + owner: string; + name: string; + installation_id: number | null; + repository_selection: string | null; + installed: number; + last_seen_at: string; + removed_at: string | null; + account_id: string | null; +}; + +type GitHubEventRow = { + event_seq: number; + event_id: string; + github_event: string; + github_delivery: string | null; + repository_full_name: string; + summary: string; + payload_json: string; + received_at: string; + account_id: string | null; +}; + +type LinearOrganizationRow = { + org_id: string; + webhook_secret: string; + registered_at: string; + updated_at: string; + account_id: string | null; +}; + +type LinearEventRow = { + event_seq: number; + org_id: string; + event_id: string; + event_type: string; + action: string; + received_at: string; + body: string; + account_id: string | null; +}; + +class FakeStatement { + private values: unknown[] = []; + + constructor( + private readonly sql: string, + private readonly db: FakeAccountD1, + ) {} + + bind(...values: unknown[]): this { + this.values = values; + return this; + } + + async first(): Promise { + return this.db.first(this.sql, this.values); + } + + async all(): Promise<{ results: T[] }> { + return { results: this.db.all(this.sql, this.values) }; + } + + async run(): Promise<{ success: boolean }> { + this.db.run(this.sql, this.values); + return { success: true }; + } +} + +class FakeAccountD1 { + repositories: RepositoryRow[] = []; + githubEvents: GitHubEventRow[] = []; + linearOrganizations: LinearOrganizationRow[] = []; + linearEvents: LinearEventRow[] = []; + + prepare(sql: string): FakeStatement { + return new FakeStatement(sql, this); + } + + first(sql: string, values: unknown[]): T | null { + if (sql.includes("json_extract(payload_json, '$.hook.events')") || sql.includes("where github_event = 'meta'")) { + return null; + } + if (sql.includes("from github_app_repositories")) { + const repositoryKey = String(values[0]); + const row = this.repositories.find((entry) => entry.repository_key === repositoryKey); + if (!row) return null; + return (sql.includes("select account_id") ? { account_id: row.account_id } : row) as T; + } + if (sql.includes("select webhook_secret from linear_organizations")) { + const row = this.linearOrganizations.find((entry) => entry.org_id === values[0]); + return row ? ({ webhook_secret: row.webhook_secret } as T) : null; + } + if (sql.includes("select account_id from linear_organizations")) { + const row = this.linearOrganizations.find((entry) => entry.org_id === values[0]); + return row ? ({ account_id: row.account_id } as T) : null; + } + return null; + } + + all(sql: string, values: unknown[]): T[] { + if (sql.includes("from github_app_repositories")) { + const accountId = String(values[0]); + return this.repositories + .filter((row) => row.account_id === accountId) + .sort((left, right) => left.repository_full_name.localeCompare(right.repository_full_name)) as T[]; + } + if (sql.includes("from github_events")) { + const repositoryFullName = String(values[0]).toLowerCase(); + const limit = Number(values.at(-1)); + return this.githubEvents + .filter((row) => row.repository_full_name.toLowerCase() === repositoryFullName) + .sort((left, right) => right.event_seq - left.event_seq) + .slice(0, limit) as T[]; + } + if (sql.includes("from linear_organizations")) { + const accountId = String(values[0]); + return this.linearOrganizations.filter((row) => row.account_id === accountId) as T[]; + } + if (sql.includes("from linear_events")) { + const organizationId = String(values[0]); + const limit = Number(values.at(-1)); + return this.linearEvents + .filter((row) => row.org_id === organizationId) + .sort((left, right) => right.event_seq - left.event_seq) + .slice(0, limit) as T[]; + } + return []; + } + + run(sql: string, values: unknown[]): void { + if (sql.includes("insert into linear_organizations")) { + const [organizationId, secret, registeredAt, updatedAt, accountId] = values; + const existing = this.linearOrganizations.find((row) => row.org_id === organizationId); + if (existing) { + existing.webhook_secret = String(secret); + existing.updated_at = String(updatedAt); + if (accountId != null) existing.account_id = String(accountId); + } else { + this.linearOrganizations.push({ + org_id: String(organizationId), + webhook_secret: String(secret), + registered_at: String(registeredAt), + updated_at: String(updatedAt), + account_id: accountId == null ? null : String(accountId), + }); + } + return; + } + if (sql.includes("update github_app_repositories set account_id = ?")) { + const [accountId, repositoryKey] = values; + const row = this.repositories.find((entry) => entry.repository_key === repositoryKey); + if (row) row.account_id = String(accountId); + return; + } + if (sql.includes("update github_events set account_id = ?")) { + const [accountId, repositoryFullName] = values; + for (const row of this.githubEvents) { + if (row.repository_full_name.toLowerCase() === String(repositoryFullName).toLowerCase()) { + row.account_id = String(accountId); + } + } + return; + } + if (sql.includes("update linear_events set account_id = ?")) { + const [accountId, organizationId] = values; + for (const row of this.linearEvents) { + if (row.org_id === organizationId) row.account_id = String(accountId); + } + return; + } + if (sql.includes("set account_id = null where account_id = ?")) { + const accountId = String(values[0]); + const rows = sql.includes("github_app_repositories") + ? this.repositories + : sql.includes("github_events") + ? this.githubEvents + : sql.includes("linear_organizations") + ? this.linearOrganizations + : this.linearEvents; + for (const row of rows) { + if (row.account_id === accountId) row.account_id = null; + } + } + } +} + +const ISSUER = "https://clerk.test"; +const OAUTH_CLIENT_ID = "client_ade"; +let jwksServer: Server; +let jwksUrl = ""; +let signingKey: Awaited>["privateKey"]; + +beforeAll(async () => { + const keyPair = await generateKeyPair("RS256", { extractable: true }); + signingKey = keyPair.privateKey; + const publicJwk = await exportJWK(keyPair.publicKey); + const jwks = { keys: [{ ...publicJwk, alg: "RS256", kid: "account-test", use: "sig" }] }; + jwksServer = createServer((_request, response) => { + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify(jwks)); + }); + await new Promise((resolve, reject) => { + jwksServer.once("error", reject); + jwksServer.listen(0, "127.0.0.1", resolve); + }); + const address = jwksServer.address() as AddressInfo; + jwksUrl = `http://127.0.0.1:${address.port}/jwks`; +}); + +afterAll(async () => { + await new Promise((resolve, reject) => { + jwksServer.close((error) => error ? reject(error) : resolve()); + }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +async function mintToken(sub: string, audience = OAUTH_CLIENT_ID): Promise { + const now = Math.floor(Date.now() / 1000); + return new SignJWT({ azp: audience }) + .setProtectedHeader({ alg: "RS256", kid: "account-test" }) + .setIssuer(ISSUER) + .setSubject(sub) + .setAudience(audience) + .setIssuedAt(now) + .setExpirationTime(now + 600) + .sign(signingKey); +} + +function makeEnv(): RelayEnv & { DB: FakeAccountD1 } { + return { + DB: new FakeAccountD1(), + GITHUB_WEBHOOK_SECRET: "github-secret", + GITHUB_API_BASE_URL: "https://github.test", + LINEAR_API_BASE_URL: "https://linear.test/graphql", + CLERK_JWKS_URL: jwksUrl, + CLERK_ISSUER: ISSUER, + CLERK_OAUTH_CLIENT_ID: OAUTH_CLIENT_ID, + } as unknown as RelayEnv & { DB: FakeAccountD1 }; +} + +function seedRepository(db: FakeAccountD1, accountId: string | null): void { + db.repositories.push({ + repository_key: "acme/repo", + repository_full_name: "acme/repo", + owner: "acme", + name: "repo", + installation_id: 42, + repository_selection: "selected", + installed: 1, + last_seen_at: "2026-07-15T00:00:00.000Z", + removed_at: null, + account_id: accountId, + }); + db.githubEvents.push({ + event_seq: 1, + event_id: "github-delivery-1", + github_event: "pull_request", + github_delivery: "github-delivery-1", + repository_full_name: "acme/repo", + summary: "GitHub pull_request · opened · acme/repo", + payload_json: JSON.stringify({ action: "opened", repository: { full_name: "acme/repo" } }), + received_at: "2026-07-15T00:00:00.000Z", + account_id: accountId, + }); +} + +function seedLinear(db: FakeAccountD1, accountId: string | null): void { + db.linearOrganizations.push({ + org_id: "org-1", + webhook_secret: "linear-secret", + registered_at: "2026-07-15T00:00:00.000Z", + updated_at: "2026-07-15T00:00:00.000Z", + account_id: accountId, + }); + db.linearEvents.push({ + event_seq: 1, + org_id: "org-1", + event_id: "linear-delivery-1", + event_type: "Issue", + action: "update", + received_at: "2026-07-15T00:00:00.000Z", + body: JSON.stringify({ organizationId: "org-1", type: "Issue", action: "update" }), + account_id: accountId, + }); +} + +function stubLegacyApis(): void { + vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.startsWith("https://github.test/")) { + return new Response(JSON.stringify({ + id: 101, + permissions: { admin: false, push: true, pull: true }, + }), { status: 200, headers: { "content-type": "application/json" } }); + } + if (url === "https://linear.test/graphql") { + const authorization = new Headers(init?.headers).get("authorization"); + if (authorization !== "lin_admin") { + return new Response(JSON.stringify({ errors: [{ message: "unauthorized" }] }), { + status: 401, + headers: { "content-type": "application/json" }, + }); + } + const query = String(JSON.parse(String(init?.body ?? "{}")).query ?? ""); + const payload = query.includes("webhooks(") + ? { data: { webhooks: { nodes: [] } } } + : { data: { viewer: { organization: { id: "org-1" } } } }; + return new Response(JSON.stringify(payload), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + throw new Error(`Unexpected fetch: ${url}`); + })); +} + +function request(pathname: string, args: { + method?: string; + authorization?: string; + accountToken?: string; + body?: unknown; +} = {}): Request { + return new Request(`https://relay.test${pathname}`, { + method: args.method ?? "GET", + headers: { + ...(args.authorization ? { authorization: args.authorization } : {}), + ...(args.accountToken ? { "x-ade-account-token": args.accountToken } : {}), + ...(args.body === undefined ? {} : { "content-type": "application/json" }), + }, + ...(args.body === undefined ? {} : { body: JSON.stringify(args.body) }), + }); +} + +describe("account integration re-keying", () => { + it("reuses the directory Worker Clerk JWKS issuer, subject, and audience checks", async () => { + const env = makeEnv(); + await expect(verifyAccountToken(await mintToken("user_1"), env)).resolves.toBe("user_1"); + await expect(verifyAccountToken(await mintToken("user_1", "wrong-client"), env)).rejects.toThrow( + "Token audience is not allowed", + ); + }); + + it("keeps NULL account mappings on the byte-identical legacy register, status, and poll paths", async () => { + const env = makeEnv(); + seedRepository(env.DB, null); + stubLegacyApis(); + + const status = await handleRequest(request("/github/repos/acme/repo/status", { + authorization: "Bearer ghp_repo_token", + }), env); + const githubEvents = await handleRequest(request("/github/repos/acme/repo/events", { + authorization: "Bearer ghp_repo_token", + }), env); + const linearRegister = await handleRequest(request("/linear/orgs/register", { + method: "POST", + authorization: "lin_admin", + body: { secret: "linear-secret" }, + }), env); + const linearEvents = await handleRequest(request("/linear/orgs/org-1/events", { + authorization: "lin_admin", + }), env); + + expect(status.status).toBe(200); + expect(await githubEvents.json()).toEqual({ + events: [expect.objectContaining({ eventId: "github-delivery-1" })], + nextCursor: "seq:1", + cursorExpired: false, + }); + expect(await linearRegister.json()).toEqual({ organizationId: "org-1" }); + expect(await linearEvents.json()).toEqual({ events: [], nextCursor: null, cursorExpired: false }); + expect(env.DB.repositories[0]?.account_id).toBeNull(); + expect(env.DB.linearOrganizations[0]?.account_id).toBeNull(); + }); + + it("stamps account mappings, isolates account lists, supports both auth keys, and revokes only account access", async () => { + const env = makeEnv(); + seedRepository(env.DB, null); + env.DB.linearEvents.push({ + event_seq: 1, + org_id: "org-1", + event_id: "linear-delivery-1", + event_type: "Issue", + action: "update", + received_at: "2026-07-15T00:00:00.000Z", + body: JSON.stringify({ organizationId: "org-1", type: "Issue", action: "update" }), + account_id: null, + }); + stubLegacyApis(); + const accountToken = await mintToken("user_1"); + const otherAccountToken = await mintToken("user_2"); + + const status = await handleRequest(request("/github/repos/acme/repo/status", { + authorization: "Bearer ghp_repo_token", + accountToken, + }), env); + const registration = await handleRequest(request("/linear/orgs/register", { + method: "POST", + authorization: "lin_admin", + accountToken, + body: { secret: "linear-secret" }, + }), env); + expect(status.status).toBe(200); + expect(registration.status).toBe(200); + expect(env.DB.repositories[0]?.account_id).toBe("user_1"); + expect(env.DB.githubEvents[0]?.account_id).toBe("user_1"); + expect(env.DB.linearOrganizations[0]?.account_id).toBe("user_1"); + expect(env.DB.linearEvents[0]?.account_id).toBe("user_1"); + + const accountStatus = await handleRequest(request("/github/repos/acme/repo/status", { accountToken }), env); + const accountGitHub = await handleRequest(request("/github/repos/acme/repo/events", { accountToken }), env); + const legacyGitHub = await handleRequest(request("/github/repos/acme/repo/events", { + authorization: "Bearer ghp_repo_token", + }), env); + const accountLinear = await handleRequest(request("/linear/orgs/org-1/events", { accountToken }), env); + const legacyLinear = await handleRequest(request("/linear/orgs/org-1/events", { + authorization: "lin_admin", + }), env); + expect(accountStatus.status).toBe(200); + expect(accountGitHub.status).toBe(200); + expect(legacyGitHub.status).toBe(200); + expect(accountLinear.status).toBe(200); + expect(legacyLinear.status).toBe(200); + + const otherGitHub = await handleRequest(request("/github/repos/acme/repo/events", { + accountToken: otherAccountToken, + }), env); + const otherLinear = await handleRequest(request("/linear/orgs/org-1/events", { + accountToken: otherAccountToken, + }), env); + expect(otherGitHub.status).toBe(401); + expect(otherLinear.status).toBe(401); + + const integrations = await handleRequest(request("/account/integrations", { + authorization: `Bearer ${accountToken}`, + }), env); + const otherIntegrations = await handleRequest(request("/account/integrations", { + authorization: `Bearer ${otherAccountToken}`, + }), env); + expect(await integrations.json()).toEqual({ + repositories: [{ + owner: "acme", + name: "repo", + fullName: "acme/repo", + installationId: 42, + repositorySelection: "selected", + installed: true, + }], + linearOrganizations: [{ organizationId: "org-1" }], + }); + expect(await otherIntegrations.json()).toEqual({ repositories: [], linearOrganizations: [] }); + + const revoked = await handleRequest(request("/account/integrations", { + method: "DELETE", + authorization: `Bearer ${accountToken}`, + }), env); + expect(await revoked.json()).toEqual({ ok: true }); + expect(env.DB.repositories[0]?.account_id).toBeNull(); + expect(env.DB.githubEvents[0]?.account_id).toBeNull(); + expect(env.DB.linearOrganizations[0]?.account_id).toBeNull(); + expect(env.DB.linearEvents[0]?.account_id).toBeNull(); + + const revokedGitHub = await handleRequest(request("/github/repos/acme/repo/events", { accountToken }), env); + const revokedLinear = await handleRequest(request("/linear/orgs/org-1/events", { accountToken }), env); + expect(revokedGitHub.status).toBe(401); + expect(revokedLinear.status).toBe(401); + expect((await handleRequest(request("/github/repos/acme/repo/events", { + authorization: "Bearer ghp_repo_token", + }), env)).status).toBe(200); + expect((await handleRequest(request("/linear/orgs/org-1/events", { + authorization: "lin_admin", + }), env)).status).toBe(200); + }); +}); From 7d6c8bd553210bcc5cd2b32bfd3a1853f709fd5d Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 15 Jul 2026 01:48:26 -0400 Subject: [PATCH 2/6] =?UTF-8?q?ship:=20iteration=201=20=E2=80=94=20preserv?= =?UTF-8?q?e=20integration=20ownership?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/webhook-relay/src/relay.ts | 34 +++++++++++++---- apps/webhook-relay/test/account.test.ts | 49 +++++++++++++++++++++---- 2 files changed, 68 insertions(+), 15 deletions(-) diff --git a/apps/webhook-relay/src/relay.ts b/apps/webhook-relay/src/relay.ts index c6ffbdf53..7fe84c74d 100644 --- a/apps/webhook-relay/src/relay.ts +++ b/apps/webhook-relay/src/relay.ts @@ -900,12 +900,22 @@ async function associateGitHubRepositoryWithAccount( accountId: string, ): Promise { await env.DB - .prepare("update github_app_repositories set account_id = ? where repository_key = ?") + .prepare("update github_app_repositories set account_id = ? where repository_key = ? and account_id is null") .bind(accountId, repositoryKey) .run(); await env.DB - .prepare("update github_events set account_id = ? where repository_full_name = ? collate nocase") - .bind(accountId, repositoryFullName) + .prepare(` + update github_events + set account_id = ? + where repository_full_name = ? collate nocase + and account_id is null + and exists ( + select 1 + from github_app_repositories + where repository_key = ? and account_id = ? + ) + `) + .bind(accountId, repositoryFullName, repositoryKey, accountId) .run(); } @@ -1548,7 +1558,7 @@ async function handleRepoStatus(request: Request, env: RelayEnv, repo: { project `) .bind(key) .first(); - if (accountId && row && row.account_id !== accountId) { + if (accountId && row && row.account_id == null) { await associateGitHubRepositoryWithAccount(env, key, row.repository_full_name, accountId); row = { ...row, account_id: accountId }; } @@ -1761,14 +1771,24 @@ async function handleLinearOrganizationRegister(request: Request, env: RelayEnv) on conflict(org_id) do update set webhook_secret = excluded.webhook_secret, updated_at = excluded.updated_at, - account_id = coalesce(excluded.account_id, linear_organizations.account_id) + account_id = coalesce(linear_organizations.account_id, excluded.account_id) `) .bind(auth.organizationId, secret, now, now, accountId) .run(); if (accountId) { await env.DB - .prepare("update linear_events set account_id = ? where org_id = ?") - .bind(accountId, auth.organizationId) + .prepare(` + update linear_events + set account_id = ? + where org_id = ? + and account_id is null + and exists ( + select 1 + from linear_organizations + where org_id = ? and account_id = ? + ) + `) + .bind(accountId, auth.organizationId, auth.organizationId, accountId) .run(); } diff --git a/apps/webhook-relay/test/account.test.ts b/apps/webhook-relay/test/account.test.ts index 7a5b6df4d..d4d328f9f 100644 --- a/apps/webhook-relay/test/account.test.ts +++ b/apps/webhook-relay/test/account.test.ts @@ -143,7 +143,7 @@ class FakeAccountD1 { if (existing) { existing.webhook_secret = String(secret); existing.updated_at = String(updatedAt); - if (accountId != null) existing.account_id = String(accountId); + if (existing.account_id == null && accountId != null) existing.account_id = String(accountId); } else { this.linearOrganizations.push({ org_id: String(organizationId), @@ -158,22 +158,26 @@ class FakeAccountD1 { if (sql.includes("update github_app_repositories set account_id = ?")) { const [accountId, repositoryKey] = values; const row = this.repositories.find((entry) => entry.repository_key === repositoryKey); - if (row) row.account_id = String(accountId); + if (row && row.account_id == null) row.account_id = String(accountId); return; } - if (sql.includes("update github_events set account_id = ?")) { - const [accountId, repositoryFullName] = values; + if (sql.includes("update github_events") && sql.includes("set account_id = ?")) { + const [accountId, repositoryFullName, repositoryKey, mappedAccountId] = values; + const repository = this.repositories.find((entry) => entry.repository_key === repositoryKey); + if (repository?.account_id !== mappedAccountId) return; for (const row of this.githubEvents) { - if (row.repository_full_name.toLowerCase() === String(repositoryFullName).toLowerCase()) { + if (row.account_id == null && row.repository_full_name.toLowerCase() === String(repositoryFullName).toLowerCase()) { row.account_id = String(accountId); } } return; } - if (sql.includes("update linear_events set account_id = ?")) { - const [accountId, organizationId] = values; + if (sql.includes("update linear_events") && sql.includes("set account_id = ?")) { + const [accountId, organizationId, mappedOrganizationId, mappedAccountId] = values; + const organization = this.linearOrganizations.find((row) => row.org_id === mappedOrganizationId); + if (organization?.account_id !== mappedAccountId) return; for (const row of this.linearEvents) { - if (row.org_id === organizationId) row.account_id = String(accountId); + if (row.account_id == null && row.org_id === organizationId) row.account_id = String(accountId); } return; } @@ -482,4 +486,33 @@ describe("account integration re-keying", () => { authorization: "lin_admin", }), env)).status).toBe(200); }); + + it("preserves existing integration ownership for another legacy-authorized account", async () => { + const env = makeEnv(); + seedRepository(env.DB, "user_1"); + seedLinear(env.DB, "user_1"); + stubLegacyApis(); + const otherAccountToken = await mintToken("user_2"); + + const status = await handleRequest(request("/github/repos/acme/repo/status", { + authorization: "Bearer ghp_repo_token", + accountToken: otherAccountToken, + }), env); + const registration = await handleRequest(request("/linear/orgs/register", { + method: "POST", + authorization: "lin_admin", + accountToken: otherAccountToken, + body: { secret: "replacement-secret" }, + }), env); + + expect(status.status).toBe(200); + expect(registration.status).toBe(200); + expect(env.DB.repositories[0]?.account_id).toBe("user_1"); + expect(env.DB.githubEvents[0]?.account_id).toBe("user_1"); + expect(env.DB.linearOrganizations[0]?.account_id).toBe("user_1"); + expect(env.DB.linearEvents[0]?.account_id).toBe("user_1"); + expect(await (await handleRequest(request("/account/integrations", { + authorization: `Bearer ${otherAccountToken}`, + }), env)).json()).toEqual({ repositories: [], linearOrganizations: [] }); + }); }); From 59649aaccecc445a13e4534c98a1fa15bb42b308 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 15 Jul 2026 02:07:08 -0400 Subject: [PATCH 3/6] =?UTF-8?q?ship:=20iteration=202=20=E2=80=94=20bind=20?= =?UTF-8?q?account=20tokens=20to=20ADE=20client?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/webhook-relay/src/relay.ts | 1 - apps/webhook-relay/test/account.test.ts | 23 +++++++++++++++++------ 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/apps/webhook-relay/src/relay.ts b/apps/webhook-relay/src/relay.ts index 7fe84c74d..0251584a8 100644 --- a/apps/webhook-relay/src/relay.ts +++ b/apps/webhook-relay/src/relay.ts @@ -438,7 +438,6 @@ function audienceIncludes(audience: JWTPayload["aud"], expected: string): boolea } function isAllowedAccountToken(payload: JWTPayload, oauthClientId: string): boolean { - if (payload.aud === undefined) return true; return audienceIncludes(payload.aud, oauthClientId) || payload.azp === oauthClientId; } diff --git a/apps/webhook-relay/test/account.test.ts b/apps/webhook-relay/test/account.test.ts index d4d328f9f..2ae65c948 100644 --- a/apps/webhook-relay/test/account.test.ts +++ b/apps/webhook-relay/test/account.test.ts @@ -230,16 +230,20 @@ afterEach(() => { vi.unstubAllGlobals(); }); -async function mintToken(sub: string, audience = OAUTH_CLIENT_ID): Promise { +async function mintToken( + sub: string, + audience: string | null = OAUTH_CLIENT_ID, + authorizedParty: string | null = audience, +): Promise { const now = Math.floor(Date.now() / 1000); - return new SignJWT({ azp: audience }) + let token = new SignJWT(authorizedParty ? { azp: authorizedParty } : {}) .setProtectedHeader({ alg: "RS256", kid: "account-test" }) .setIssuer(ISSUER) .setSubject(sub) - .setAudience(audience) .setIssuedAt(now) - .setExpirationTime(now + 600) - .sign(signingKey); + .setExpirationTime(now + 600); + if (audience) token = token.setAudience(audience); + return token.sign(signingKey); } function makeEnv(): RelayEnv & { DB: FakeAccountD1 } { @@ -348,12 +352,19 @@ function request(pathname: string, args: { } describe("account integration re-keying", () => { - it("reuses the directory Worker Clerk JWKS issuer, subject, and audience checks", async () => { + it("verifies the Clerk issuer, subject, and ADE client binding", async () => { const env = makeEnv(); await expect(verifyAccountToken(await mintToken("user_1"), env)).resolves.toBe("user_1"); + await expect(verifyAccountToken(await mintToken("user_1", null, OAUTH_CLIENT_ID), env)).resolves.toBe("user_1"); await expect(verifyAccountToken(await mintToken("user_1", "wrong-client"), env)).rejects.toThrow( "Token audience is not allowed", ); + await expect(verifyAccountToken(await mintToken("user_1", null, "wrong-client"), env)).rejects.toThrow( + "Token audience is not allowed", + ); + await expect(verifyAccountToken(await mintToken("user_1", null, null), env)).rejects.toThrow( + "Token audience is not allowed", + ); }); it("keeps NULL account mappings on the byte-identical legacy register, status, and poll paths", async () => { From 2ea7b8c7c75485cb9de15c2a32207e772e5945df Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 15 Jul 2026 02:30:47 -0400 Subject: [PATCH 4/6] =?UTF-8?q?ship:=20iteration=203=20=E2=80=94=20isolate?= =?UTF-8?q?=20events=20and=20persist=20revocation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/webhook-relay/README.md | 5 +- ..._account_integration_unlink_tombstones.sql | 2 + apps/webhook-relay/src/relay.ts | 83 +++++++++----- apps/webhook-relay/test/account.test.ts | 104 +++++++++++++++++- 4 files changed, 158 insertions(+), 36 deletions(-) create mode 100644 apps/webhook-relay/migrations/0006_account_integration_unlink_tombstones.sql diff --git a/apps/webhook-relay/README.md b/apps/webhook-relay/README.md index f9b8fbf9b..5fa250b2e 100644 --- a/apps/webhook-relay/README.md +++ b/apps/webhook-relay/README.md @@ -92,8 +92,9 @@ organization already associated with that account; callers without this header continue through the legacy authorization paths unchanged. `GET /account/integrations` lists only the caller's associated repositories and Linear organizations. `DELETE /account/integrations` clears those account -associations without deleting provider mappings, events, signing secrets, or -legacy access. +associations and records an account-specific unlink tombstone, so subsequent +dual-credential requests cannot silently restore them. It does not delete +provider mappings, events, signing secrets, or legacy access. Legacy Linear support requires no additional Wrangler secrets. Each ADE client generates its own webhook signing secret and registers it into the diff --git a/apps/webhook-relay/migrations/0006_account_integration_unlink_tombstones.sql b/apps/webhook-relay/migrations/0006_account_integration_unlink_tombstones.sql new file mode 100644 index 000000000..2f95274e3 --- /dev/null +++ b/apps/webhook-relay/migrations/0006_account_integration_unlink_tombstones.sql @@ -0,0 +1,2 @@ +alter table github_app_repositories add column unlinked_account_id text; +alter table linear_organizations add column unlinked_account_id text; diff --git a/apps/webhook-relay/src/relay.ts b/apps/webhook-relay/src/relay.ts index 0251584a8..aa2d14263 100644 --- a/apps/webhook-relay/src/relay.ts +++ b/apps/webhook-relay/src/relay.ts @@ -897,11 +897,16 @@ async function associateGitHubRepositoryWithAccount( repositoryKey: string, repositoryFullName: string, accountId: string, -): Promise { +): Promise { await env.DB - .prepare("update github_app_repositories set account_id = ? where repository_key = ? and account_id is null") - .bind(accountId, repositoryKey) + .prepare("update github_app_repositories set account_id = ?, unlinked_account_id = null where repository_key = ? and account_id is null and (unlinked_account_id is null or unlinked_account_id <> ?)") + .bind(accountId, repositoryKey, accountId) .run(); + const mapping = await env.DB + .prepare("select account_id from github_app_repositories where repository_key = ? limit 1") + .bind(repositoryKey) + .first(); + if (mapping?.account_id !== accountId) return false; await env.DB .prepare(` update github_events @@ -916,6 +921,7 @@ async function associateGitHubRepositoryWithAccount( `) .bind(accountId, repositoryFullName, repositoryKey, accountId) .run(); + return true; } function gitHubAppApiConfigured(env: RelayEnv): boolean { @@ -1315,8 +1321,9 @@ async function handleListEvents(request: Request, env: RelayEnv, projectId: stri async function handleListRepoEvents(request: Request, env: RelayEnv, repo: { owner: string; name: string }): Promise { if (request.method !== "GET") return text("method not allowed", 405); const auth = await assertGitHubRepoAuthorized(request, env, repo); + let accountId: string | null = null; if (!auth.authorized) { - const accountId = await authenticateAccount(request, env); + accountId = await authenticateAccount(request, env); if (!accountId || !await githubRepositoryAccountMatches(env, repo, accountId)) { return auth.response; } @@ -1326,6 +1333,8 @@ async function handleListRepoEvents(request: Request, env: RelayEnv, repo: { own const limit = parseLimit(url); const after = url.searchParams.get("after")?.trim() || ""; const repoFullName = `${repo.owner}/${repo.name}`.toLowerCase(); + const accountPredicate = accountId ? " and account_id = ?" : ""; + const accountBinding = accountId ? [accountId] : []; let rows: GitHubEventRow[]; let cursorExpired = false; @@ -1337,17 +1346,17 @@ async function handleListRepoEvents(request: Request, env: RelayEnv, repo: { own select rowid as event_seq, event_id, github_event, github_delivery, repository_full_name, summary, payload_json, received_at from github_events - where repository_full_name = ? collate nocase + where repository_full_name = ? collate nocase${accountPredicate} and rowid > ? order by rowid desc limit ? `) - .bind(repoFullName, sequenceCursor, limit) + .bind(repoFullName, ...accountBinding, sequenceCursor, limit) .all()).results ?? []; } else { const cursor = await env.DB - .prepare("select rowid as event_seq, event_id from github_events where repository_full_name = ? collate nocase and event_id = ? limit 1") - .bind(repoFullName, after) + .prepare(`select rowid as event_seq, event_id from github_events where repository_full_name = ? collate nocase${accountPredicate} and event_id = ? limit 1`) + .bind(repoFullName, ...accountBinding, after) .first(); if (cursor) { rows = (await env.DB @@ -1355,12 +1364,12 @@ async function handleListRepoEvents(request: Request, env: RelayEnv, repo: { own select rowid as event_seq, event_id, github_event, github_delivery, repository_full_name, summary, payload_json, received_at from github_events - where repository_full_name = ? collate nocase + where repository_full_name = ? collate nocase${accountPredicate} and rowid > ? order by rowid desc limit ? `) - .bind(repoFullName, cursor.event_seq, limit) + .bind(repoFullName, ...accountBinding, cursor.event_seq, limit) .all()).results ?? []; } else { cursorExpired = true; @@ -1369,11 +1378,11 @@ async function handleListRepoEvents(request: Request, env: RelayEnv, repo: { own select rowid as event_seq, event_id, github_event, github_delivery, repository_full_name, summary, payload_json, received_at from github_events - where repository_full_name = ? collate nocase + where repository_full_name = ? collate nocase${accountPredicate} order by rowid desc limit ? `) - .bind(repoFullName, limit) + .bind(repoFullName, ...accountBinding, limit) .all()).results ?? []; } } @@ -1383,11 +1392,11 @@ async function handleListRepoEvents(request: Request, env: RelayEnv, repo: { own select rowid as event_seq, event_id, github_event, github_delivery, repository_full_name, summary, payload_json, received_at from github_events - where repository_full_name = ? collate nocase + where repository_full_name = ? collate nocase${accountPredicate} order by rowid desc limit ? `) - .bind(repoFullName, limit) + .bind(repoFullName, ...accountBinding, limit) .all()).results ?? []; } @@ -1557,8 +1566,8 @@ async function handleRepoStatus(request: Request, env: RelayEnv, repo: { project `) .bind(key) .first(); - if (accountId && row && row.account_id == null) { - await associateGitHubRepositoryWithAccount(env, key, row.repository_full_name, accountId); + if (accountId && row && row.account_id == null + && await associateGitHubRepositoryWithAccount(env, key, row.repository_full_name, accountId)) { row = { ...row, account_id: accountId }; } const checkedAt = new Date().toISOString(); @@ -1770,7 +1779,18 @@ async function handleLinearOrganizationRegister(request: Request, env: RelayEnv) on conflict(org_id) do update set webhook_secret = excluded.webhook_secret, updated_at = excluded.updated_at, - account_id = coalesce(linear_organizations.account_id, excluded.account_id) + account_id = case + when excluded.account_id is null then linear_organizations.account_id + when linear_organizations.account_id is not null then linear_organizations.account_id + when linear_organizations.unlinked_account_id = excluded.account_id then null + else excluded.account_id + end, + unlinked_account_id = case + when excluded.account_id is null then linear_organizations.unlinked_account_id + when linear_organizations.account_id is not null then linear_organizations.unlinked_account_id + when linear_organizations.unlinked_account_id = excluded.account_id then linear_organizations.unlinked_account_id + else null + end `) .bind(auth.organizationId, secret, now, now, accountId) .run(); @@ -1917,6 +1937,7 @@ async function handleListLinearEvents( if (request.method !== "GET") return text("method not allowed", 405); const auth = await verifyLinearViewerOrganization(request, env); let legacyError: Response | null = null; + let accountId: string | null = null; if (!auth.authorized) { legacyError = auth.response; } else if (auth.organizationId !== organizationId) { @@ -1929,7 +1950,7 @@ async function handleListLinearEvents( if (!authority.authorized) legacyError = authority.response; } if (legacyError) { - const accountId = await authenticateAccount(request, env); + accountId = await authenticateAccount(request, env); if (!accountId || !await linearOrganizationAccountMatches(env, organizationId, accountId)) { return legacyError; } @@ -1938,6 +1959,8 @@ async function handleListLinearEvents( const url = new URL(request.url); const limit = parseLimit(url); const after = url.searchParams.get("after")?.trim() || ""; + const accountPredicate = accountId ? " and account_id = ?" : ""; + const accountBinding = accountId ? [accountId] : []; let rows: LinearEventRow[]; let cursorExpired = false; @@ -1952,27 +1975,27 @@ async function handleListLinearEvents( .prepare(` select rowid as event_seq, event_id, event_type, action, received_at, body from linear_events - where org_id = ? and rowid > ? + where org_id = ?${accountPredicate} and rowid > ? order by rowid asc limit ? `) - .bind(organizationId, sequenceCursor, limit) + .bind(organizationId, ...accountBinding, sequenceCursor, limit) .all()).results ?? []; } else { const cursor = await env.DB - .prepare("select rowid as event_seq, event_id from linear_events where org_id = ? and event_id = ? limit 1") - .bind(organizationId, after) + .prepare(`select rowid as event_seq, event_id from linear_events where org_id = ?${accountPredicate} and event_id = ? limit 1`) + .bind(organizationId, ...accountBinding, after) .first(); if (cursor) { rows = (await env.DB .prepare(` select rowid as event_seq, event_id, event_type, action, received_at, body from linear_events - where org_id = ? and rowid > ? + where org_id = ?${accountPredicate} and rowid > ? order by rowid asc limit ? `) - .bind(organizationId, cursor.event_seq, limit) + .bind(organizationId, ...accountBinding, cursor.event_seq, limit) .all()).results ?? []; } else { cursorExpired = true; @@ -1980,11 +2003,11 @@ async function handleListLinearEvents( .prepare(` select rowid as event_seq, event_id, event_type, action, received_at, body from linear_events - where org_id = ? + where org_id = ?${accountPredicate} order by rowid desc limit ? `) - .bind(organizationId, limit) + .bind(organizationId, ...accountBinding, limit) .all()).results ?? []; } } @@ -1993,11 +2016,11 @@ async function handleListLinearEvents( .prepare(` select rowid as event_seq, event_id, event_type, action, received_at, body from linear_events - where org_id = ? + where org_id = ?${accountPredicate} order by rowid desc limit ? `) - .bind(organizationId, limit) + .bind(organizationId, ...accountBinding, limit) .all()).results ?? []; } @@ -2014,13 +2037,13 @@ async function handleAccountIntegrations(request: Request, env: RelayEnv): Promi if (!accountId) return json({ ok: false, error: "unauthorized" }, { status: 401 }); if (request.method === "DELETE") { - await env.DB.prepare("update github_app_repositories set account_id = null where account_id = ?") + await env.DB.prepare("update github_app_repositories set unlinked_account_id = account_id, account_id = null where account_id = ?") .bind(accountId) .run(); await env.DB.prepare("update github_events set account_id = null where account_id = ?") .bind(accountId) .run(); - await env.DB.prepare("update linear_organizations set account_id = null where account_id = ?") + await env.DB.prepare("update linear_organizations set unlinked_account_id = account_id, account_id = null where account_id = ?") .bind(accountId) .run(); await env.DB.prepare("update linear_events set account_id = null where account_id = ?") diff --git a/apps/webhook-relay/test/account.test.ts b/apps/webhook-relay/test/account.test.ts index 2ae65c948..4b2c36305 100644 --- a/apps/webhook-relay/test/account.test.ts +++ b/apps/webhook-relay/test/account.test.ts @@ -15,6 +15,7 @@ type RepositoryRow = { last_seen_at: string; removed_at: string | null; account_id: string | null; + unlinked_account_id: string | null; }; type GitHubEventRow = { @@ -35,6 +36,7 @@ type LinearOrganizationRow = { registered_at: string; updated_at: string; account_id: string | null; + unlinked_account_id: string | null; }; type LinearEventRow = { @@ -103,6 +105,28 @@ class FakeAccountD1 { const row = this.linearOrganizations.find((entry) => entry.org_id === values[0]); return row ? ({ account_id: row.account_id } as T) : null; } + if (sql.includes("from github_events")) { + const repositoryFullName = String(values[0]).toLowerCase(); + const accountId = sql.includes("account_id = ?") ? String(values[1]) : null; + const eventId = String(values.at(-1)); + const row = this.githubEvents.find((entry) => ( + entry.repository_full_name.toLowerCase() === repositoryFullName + && entry.event_id === eventId + && (accountId == null || entry.account_id === accountId) + )); + return row ? ({ event_seq: row.event_seq, event_id: row.event_id } as T) : null; + } + if (sql.includes("from linear_events")) { + const organizationId = String(values[0]); + const accountId = sql.includes("account_id = ?") ? String(values[1]) : null; + const eventId = String(values.at(-1)); + const row = this.linearEvents.find((entry) => ( + entry.org_id === organizationId + && entry.event_id === eventId + && (accountId == null || entry.account_id === accountId) + )); + return row ? ({ event_seq: row.event_seq, event_id: row.event_id } as T) : null; + } return null; } @@ -115,9 +139,13 @@ class FakeAccountD1 { } if (sql.includes("from github_events")) { const repositoryFullName = String(values[0]).toLowerCase(); + const accountId = sql.includes("account_id = ?") ? String(values[1]) : null; const limit = Number(values.at(-1)); return this.githubEvents - .filter((row) => row.repository_full_name.toLowerCase() === repositoryFullName) + .filter((row) => ( + row.repository_full_name.toLowerCase() === repositoryFullName + && (accountId == null || row.account_id === accountId) + )) .sort((left, right) => right.event_seq - left.event_seq) .slice(0, limit) as T[]; } @@ -127,9 +155,10 @@ class FakeAccountD1 { } if (sql.includes("from linear_events")) { const organizationId = String(values[0]); + const accountId = sql.includes("account_id = ?") ? String(values[1]) : null; const limit = Number(values.at(-1)); return this.linearEvents - .filter((row) => row.org_id === organizationId) + .filter((row) => row.org_id === organizationId && (accountId == null || row.account_id === accountId)) .sort((left, right) => right.event_seq - left.event_seq) .slice(0, limit) as T[]; } @@ -143,7 +172,14 @@ class FakeAccountD1 { if (existing) { existing.webhook_secret = String(secret); existing.updated_at = String(updatedAt); - if (existing.account_id == null && accountId != null) existing.account_id = String(accountId); + if ( + existing.account_id == null + && accountId != null + && existing.unlinked_account_id !== String(accountId) + ) { + existing.account_id = String(accountId); + existing.unlinked_account_id = null; + } } else { this.linearOrganizations.push({ org_id: String(organizationId), @@ -151,6 +187,7 @@ class FakeAccountD1 { registered_at: String(registeredAt), updated_at: String(updatedAt), account_id: accountId == null ? null : String(accountId), + unlinked_account_id: null, }); } return; @@ -158,7 +195,10 @@ class FakeAccountD1 { if (sql.includes("update github_app_repositories set account_id = ?")) { const [accountId, repositoryKey] = values; const row = this.repositories.find((entry) => entry.repository_key === repositoryKey); - if (row && row.account_id == null) row.account_id = String(accountId); + if (row && row.account_id == null && row.unlinked_account_id !== String(accountId)) { + row.account_id = String(accountId); + row.unlinked_account_id = null; + } return; } if (sql.includes("update github_events") && sql.includes("set account_id = ?")) { @@ -181,6 +221,17 @@ class FakeAccountD1 { } return; } + if (sql.includes("set unlinked_account_id = account_id, account_id = null")) { + const accountId = String(values[0]); + const rows = sql.includes("github_app_repositories") ? this.repositories : this.linearOrganizations; + for (const row of rows) { + if (row.account_id === accountId) { + row.unlinked_account_id = accountId; + row.account_id = null; + } + } + return; + } if (sql.includes("set account_id = null where account_id = ?")) { const accountId = String(values[0]); const rows = sql.includes("github_app_repositories") @@ -270,6 +321,7 @@ function seedRepository(db: FakeAccountD1, accountId: string | null): void { last_seen_at: "2026-07-15T00:00:00.000Z", removed_at: null, account_id: accountId, + unlinked_account_id: null, }); db.githubEvents.push({ event_seq: 1, @@ -291,6 +343,7 @@ function seedLinear(db: FakeAccountD1, accountId: string | null): void { registered_at: "2026-07-15T00:00:00.000Z", updated_at: "2026-07-15T00:00:00.000Z", account_id: accountId, + unlinked_account_id: null, }); db.linearEvents.push({ event_seq: 1, @@ -432,6 +485,19 @@ describe("account integration re-keying", () => { expect(env.DB.githubEvents[0]?.account_id).toBe("user_1"); expect(env.DB.linearOrganizations[0]?.account_id).toBe("user_1"); expect(env.DB.linearEvents[0]?.account_id).toBe("user_1"); + env.DB.githubEvents.push({ + ...env.DB.githubEvents[0]!, + event_seq: 2, + event_id: "github-delivery-other-account", + github_delivery: "github-delivery-other-account", + account_id: "user_2", + }); + env.DB.linearEvents.push({ + ...env.DB.linearEvents[0]!, + event_seq: 2, + event_id: "linear-delivery-other-account", + account_id: "user_2", + }); const accountStatus = await handleRequest(request("/github/repos/acme/repo/status", { accountToken }), env); const accountGitHub = await handleRequest(request("/github/repos/acme/repo/events", { accountToken }), env); @@ -447,6 +513,14 @@ describe("account integration re-keying", () => { expect(legacyGitHub.status).toBe(200); expect(accountLinear.status).toBe(200); expect(legacyLinear.status).toBe(200); + expect((await accountGitHub.json() as { events: Array<{ eventId: string }> }).events.map((event) => event.eventId)) + .toEqual(["github-delivery-1"]); + expect((await legacyGitHub.json() as { events: Array<{ eventId: string }> }).events.map((event) => event.eventId)) + .toEqual(["github-delivery-other-account", "github-delivery-1"]); + expect((await accountLinear.json() as { events: Array<{ eventId: string }> }).events.map((event) => event.eventId)) + .toEqual(["linear-delivery-1"]); + expect((await legacyLinear.json() as { events: Array<{ eventId: string }> }).events.map((event) => event.eventId)) + .toEqual(["linear-delivery-other-account", "linear-delivery-1"]); const otherGitHub = await handleRequest(request("/github/repos/acme/repo/events", { accountToken: otherAccountToken, @@ -485,6 +559,25 @@ describe("account integration re-keying", () => { expect(env.DB.githubEvents[0]?.account_id).toBeNull(); expect(env.DB.linearOrganizations[0]?.account_id).toBeNull(); expect(env.DB.linearEvents[0]?.account_id).toBeNull(); + expect(env.DB.repositories[0]?.unlinked_account_id).toBe("user_1"); + expect(env.DB.linearOrganizations[0]?.unlinked_account_id).toBe("user_1"); + + const legacyStatusAfterRevoke = await handleRequest(request("/github/repos/acme/repo/status", { + authorization: "Bearer ghp_repo_token", + accountToken, + }), env); + const legacyRegistrationAfterRevoke = await handleRequest(request("/linear/orgs/register", { + method: "POST", + authorization: "lin_admin", + accountToken, + body: { secret: "linear-secret-after-revoke" }, + }), env); + expect(legacyStatusAfterRevoke.status).toBe(200); + expect(legacyRegistrationAfterRevoke.status).toBe(200); + expect(env.DB.repositories[0]?.account_id).toBeNull(); + expect(env.DB.githubEvents[0]?.account_id).toBeNull(); + expect(env.DB.linearOrganizations[0]?.account_id).toBeNull(); + expect(env.DB.linearEvents[0]?.account_id).toBeNull(); const revokedGitHub = await handleRequest(request("/github/repos/acme/repo/events", { accountToken }), env); const revokedLinear = await handleRequest(request("/linear/orgs/org-1/events", { accountToken }), env); @@ -496,6 +589,9 @@ describe("account integration re-keying", () => { expect((await handleRequest(request("/linear/orgs/org-1/events", { authorization: "lin_admin", }), env)).status).toBe(200); + expect(await (await handleRequest(request("/account/integrations", { + authorization: `Bearer ${accountToken}`, + }), env)).json()).toEqual({ repositories: [], linearOrganizations: [] }); }); it("preserves existing integration ownership for another legacy-authorized account", async () => { From 8a54da52222a6c9b7d14ae961af0b4b4e6c51227 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 15 Jul 2026 02:51:02 -0400 Subject: [PATCH 5/6] =?UTF-8?q?ship:=20iteration=204=20=E2=80=94=20require?= =?UTF-8?q?=20account=20token=20expiry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/webhook-relay/src/relay.ts | 1 + apps/webhook-relay/test/account.test.ts | 9 ++++++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/webhook-relay/src/relay.ts b/apps/webhook-relay/src/relay.ts index aa2d14263..94f965e40 100644 --- a/apps/webhook-relay/src/relay.ts +++ b/apps/webhook-relay/src/relay.ts @@ -461,6 +461,7 @@ export async function verifyAccountToken(token: string, env: RelayEnv): Promise< issuer, algorithms: ["RS256"], clockTolerance: 5, + requiredClaims: ["sub", "exp"], }); if (typeof payload.sub !== "string" || !payload.sub.trim()) throw new Error("Token subject is required"); if (!isAllowedAccountToken(payload, oauthClientId)) throw new Error("Token audience is not allowed"); diff --git a/apps/webhook-relay/test/account.test.ts b/apps/webhook-relay/test/account.test.ts index 4b2c36305..2438719a1 100644 --- a/apps/webhook-relay/test/account.test.ts +++ b/apps/webhook-relay/test/account.test.ts @@ -285,14 +285,15 @@ async function mintToken( sub: string, audience: string | null = OAUTH_CLIENT_ID, authorizedParty: string | null = audience, + expires = true, ): Promise { const now = Math.floor(Date.now() / 1000); let token = new SignJWT(authorizedParty ? { azp: authorizedParty } : {}) .setProtectedHeader({ alg: "RS256", kid: "account-test" }) .setIssuer(ISSUER) .setSubject(sub) - .setIssuedAt(now) - .setExpirationTime(now + 600); + .setIssuedAt(now); + if (expires) token = token.setExpirationTime(now + 600); if (audience) token = token.setAudience(audience); return token.sign(signingKey); } @@ -405,10 +406,12 @@ function request(pathname: string, args: { } describe("account integration re-keying", () => { - it("verifies the Clerk issuer, subject, and ADE client binding", async () => { + it("verifies the Clerk issuer, subject, expiry, and ADE client binding", async () => { const env = makeEnv(); await expect(verifyAccountToken(await mintToken("user_1"), env)).resolves.toBe("user_1"); await expect(verifyAccountToken(await mintToken("user_1", null, OAUTH_CLIENT_ID), env)).resolves.toBe("user_1"); + await expect(verifyAccountToken(await mintToken("user_1", OAUTH_CLIENT_ID, OAUTH_CLIENT_ID, false), env)) + .rejects.toThrow('missing required "exp" claim'); await expect(verifyAccountToken(await mintToken("user_1", "wrong-client"), env)).rejects.toThrow( "Token audience is not allowed", ); From 13706018390661bae5bfbed0ce94fb1fe5f97b5a Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 15 Jul 2026 03:02:33 -0400 Subject: [PATCH 6/6] =?UTF-8?q?ship:=20iteration=205=20=E2=80=94=20contain?= =?UTF-8?q?=20account=20credentials?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../automationIngressService.test.ts | 3 +++ .../automations/automationIngressService.ts | 4 +++- apps/webhook-relay/src/relay.ts | 23 +++++++++++++++++++ apps/webhook-relay/test/account.test.ts | 10 ++++++++ 4 files changed, 39 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/main/services/automations/automationIngressService.test.ts b/apps/desktop/src/main/services/automations/automationIngressService.test.ts index 8e777a10f..9183c5103 100644 --- a/apps/desktop/src/main/services/automations/automationIngressService.test.ts +++ b/apps/desktop/src/main/services/automations/automationIngressService.test.ts @@ -189,6 +189,7 @@ describe("automationIngressService", () => { return null; }, } as never, + getAccountAccessToken: vi.fn(async () => "clerk-account-token"), listRules: () => [], }); @@ -202,6 +203,8 @@ describe("automationIngressService", () => { }), }), ); + const headers = new Headers(fetchSpy.mock.calls[0]?.[1]?.headers); + expect(headers.get("x-ade-account-token")).toBeNull(); expect(ingestGithubWebhook).toHaveBeenCalledWith(expect.objectContaining({ eventName: "pull_request", deliveryId: "delivery-2", diff --git a/apps/desktop/src/main/services/automations/automationIngressService.ts b/apps/desktop/src/main/services/automations/automationIngressService.ts index b508aba6f..b9bd8c196 100644 --- a/apps/desktop/src/main/services/automations/automationIngressService.ts +++ b/apps/desktop/src/main/services/automations/automationIngressService.ts @@ -595,7 +595,9 @@ export function createAutomationIngressService(args: AutomationIngressServiceArg headers: { accept: "application/json", ...(authToken ? { authorization: `Bearer ${authToken}` } : {}), - ...(accountAccessToken ? { [ACCOUNT_RELAY_TOKEN_HEADER]: accountAccessToken } : {}), + ...(accountAccessToken && !useLegacyProjectRoute + ? { [ACCOUNT_RELAY_TOKEN_HEADER]: accountAccessToken } + : {}), }, signal: controller.signal, } diff --git a/apps/webhook-relay/src/relay.ts b/apps/webhook-relay/src/relay.ts index 94f965e40..496f02f72 100644 --- a/apps/webhook-relay/src/relay.ts +++ b/apps/webhook-relay/src/relay.ts @@ -468,6 +468,17 @@ export async function verifyAccountToken(token: string, env: RelayEnv): Promise< return payload.sub; } +async function hasValidBearerAccountToken(request: Request, env: RelayEnv): Promise { + const token = readBearerToken(request); + if (!looksLikeJwt(token)) return false; + try { + await verifyAccountToken(token, env); + return true; + } catch { + return false; + } +} + async function authenticateAccount(request: Request, env: RelayEnv): Promise { for (const token of accountTokenCandidates(request)) { try { @@ -522,6 +533,12 @@ async function verifyLinearViewerOrganization( response: json({ ok: false, error: "Linear authorization token is required" }, { status: 401 }), }; } + if (await hasValidBearerAccountToken(request, env)) { + return { + authorized: false, + response: json({ ok: false, error: "Linear authorization token is required" }, { status: 401 }), + }; + } const tokenHash = await sha256Hex(authorization); const cached = linearOrganizationByTokenHash.get(tokenHash); @@ -613,6 +630,12 @@ async function assertGitHubRepoAuthorized( response: json({ ok: false, error: "GitHub auth token is required" }, { status: 401 }), }; } + if (await hasValidBearerAccountToken(request, env)) { + return { + authorized: false, + response: json({ ok: false, error: "GitHub auth token is required" }, { status: 401 }), + }; + } const apiBaseUrl = (env.GITHUB_API_BASE_URL?.trim() || "https://api.github.com").replace(/\/+$/, ""); const repoResponse = await fetchGitHubApiJson( diff --git a/apps/webhook-relay/test/account.test.ts b/apps/webhook-relay/test/account.test.ts index 2438719a1..5fe44aafc 100644 --- a/apps/webhook-relay/test/account.test.ts +++ b/apps/webhook-relay/test/account.test.ts @@ -504,6 +504,16 @@ describe("account integration re-keying", () => { const accountStatus = await handleRequest(request("/github/repos/acme/repo/status", { accountToken }), env); const accountGitHub = await handleRequest(request("/github/repos/acme/repo/events", { accountToken }), env); + const providerCallsBeforeBearerAccountAuth = vi.mocked(fetch).mock.calls.length; + const bearerAccountGitHub = await handleRequest(request("/github/repos/acme/repo/events", { + authorization: `Bearer ${accountToken}`, + }), env); + const bearerAccountLinear = await handleRequest(request("/linear/orgs/org-1/events", { + authorization: `Bearer ${accountToken}`, + }), env); + expect(bearerAccountGitHub.status).toBe(200); + expect(bearerAccountLinear.status).toBe(200); + expect(fetch).toHaveBeenCalledTimes(providerCallsBeforeBearerAccountAuth); const legacyGitHub = await handleRequest(request("/github/repos/acme/repo/events", { authorization: "Bearer ghp_repo_token", }), env);