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..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", @@ -454,6 +457,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..b9bd8c196 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,10 @@ export function createAutomationIngressService(args: AutomationIngressServiceArg { headers: { accept: "application/json", - authorization: `Bearer ${authToken}`, + ...(authToken ? { authorization: `Bearer ${authToken}` } : {}), + ...(accountAccessToken && !useLegacyProjectRoute + ? { [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..5fa250b2e 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,20 @@ 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 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 `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 +159,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/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/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..496f02f72 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,98 @@ 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 { + 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, + 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"); + 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 { + 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); @@ -416,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); @@ -507,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( @@ -787,6 +916,38 @@ 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 = ?, 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 + 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(); + return true; +} + function gitHubAppApiConfigured(env: RelayEnv): boolean { return Boolean(env.GITHUB_APP_ID?.trim() && env.GITHUB_APP_PRIVATE_KEY?.trim()); } @@ -1024,6 +1185,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 +1193,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,12 +1345,20 @@ 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; + let accountId: string | null = null; + if (!auth.authorized) { + 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); 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; @@ -1193,17 +1370,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 @@ -1211,12 +1388,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; @@ -1225,11 +1402,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 ?? []; } } @@ -1239,11 +1416,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 ?? []; } @@ -1387,27 +1564,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 == null + && 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 +1611,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 +1771,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 +1798,42 @@ 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 = 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) + .bind(auth.organizationId, secret, now, now, accountId) .run(); + if (accountId) { + await env.DB + .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(); + } return json({ organizationId: auth.organizationId }); } @@ -1699,13 +1917,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,19 +1960,31 @@ 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; + let accountId: string | 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) { + 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); const after = url.searchParams.get("after")?.trim() || ""; + const accountPredicate = accountId ? " and account_id = ?" : ""; + const accountBinding = accountId ? [accountId] : []; let rows: LinearEventRow[]; let cursorExpired = false; @@ -1763,27 +1999,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; @@ -1791,11 +2027,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 ?? []; } } @@ -1804,11 +2040,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 ?? []; } @@ -1819,12 +2055,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 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 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 = ?") + .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..5fe44aafc --- /dev/null +++ b/apps/webhook-relay/test/account.test.ts @@ -0,0 +1,638 @@ +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; + unlinked_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; + unlinked_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; + } + 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; + } + + 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 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 + && (accountId == null || row.account_id === accountId) + )) + .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 accountId = sql.includes("account_id = ?") ? String(values[1]) : null; + const limit = Number(values.at(-1)); + return this.linearEvents + .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[]; + } + 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 ( + 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), + webhook_secret: String(secret), + registered_at: String(registeredAt), + updated_at: String(updatedAt), + account_id: accountId == null ? null : String(accountId), + unlinked_account_id: null, + }); + } + 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 == 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 = ?")) { + 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.account_id == null && row.repository_full_name.toLowerCase() === String(repositoryFullName).toLowerCase()) { + row.account_id = String(accountId); + } + } + return; + } + 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.account_id == null && row.org_id === organizationId) row.account_id = String(accountId); + } + 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") + ? 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: 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); + if (expires) token = token.setExpirationTime(now + 600); + if (audience) token = token.setAudience(audience); + return token.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, + unlinked_account_id: null, + }); + 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, + unlinked_account_id: null, + }); + 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("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", + ); + 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 () => { + 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"); + 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); + 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); + 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); + 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, + }), 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(); + 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); + 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); + 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 () => { + 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: [] }); + }); +});