diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts index 1b96984cf76b..bd530366cd7e 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; +import { AuthOrchestrationReadScope, EnvironmentAuthorizationError } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -418,3 +419,31 @@ describe("what one diff slice spends", () => { }), ); }); + +it.effect.each(["changed files", "threads"] as const)( + "preserves typed denial from %s instead of returning an unavailable read", + (method) => + Effect.gen(function* () { + const denial = new EnvironmentAuthorizationError({ + requiredScope: AuthOrchestrationReadScope, + message: "fixture denial", + }); + const provider = yield* make.pipe( + Effect.provide( + Layer.mock(AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli)({ + getPullRequest: () => Effect.succeed(PULL_REQUEST), + listIterations: () => Effect.fail(denial), + listThreads: () => Effect.fail(denial), + }), + ), + ); + const input = { cwd: "/w", host: "dev.azure.com", repository: "acme/web", number: 7 }; + expect( + yield* ( + method === "changed files" + ? provider.getChangeRequest(input).pipe(Effect.asVoid) + : provider.getChangeRequestActivity(input).pipe(Effect.asVoid) + ).pipe(Effect.flip), + ).toBe(denial); + }), +); diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts index a0d1d3b6ef09..d04379e61010 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts @@ -1,3 +1,7 @@ +import { + beforeCurrentWorkspaceEffect, + recoverWorkspaceError, +} from "../auth/workspaceReadAccess.ts"; import * as Effect from "effect/Effect"; import * as Semaphore from "effect/Semaphore"; import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3tools/contracts"; @@ -189,15 +193,20 @@ export const make = Effect.gen(function* () { */ const locations = new Map(); - const locationOf = (input: { readonly cwd: string; readonly number: number }) => { + const locationOf = Effect.fn("AzureDevOpsPullRequestProvider.locationOf")(function* (input: { + readonly cwd: string; + readonly number: number; + }) { const key = `${input.cwd} ${input.number}`; + yield* beforeCurrentWorkspaceEffect; const held = locations.get(key); if (held !== undefined) { locations.delete(key); locations.set(key, held); - return Effect.succeed(held); + return held; } - return cli.getPullRequest({ cwd: input.cwd, number: input.number }).pipe( + return yield* cli.getPullRequest({ cwd: input.cwd, number: input.number }).pipe( + Effect.tap(() => beforeCurrentWorkspaceEffect), Effect.map((pullRequest) => { const location = pullRequest.location; if (location === null) return null; @@ -209,7 +218,7 @@ export const make = Effect.gen(function* () { return location; }), ); - }; + }); /** * Everything a diff read needs before it can ask for a file: where the repository lives, and @@ -370,7 +379,7 @@ export const make = Effect.gen(function* () { }), ), Effect.map((listed) => listed.changes.length), - Effect.orElseSucceed(() => 0), + recoverWorkspaceError(() => 0), ); const detail: ProviderChangeRequestDetail = { ...toChangeRequest(pullRequest), @@ -404,7 +413,7 @@ export const make = Effect.gen(function* () { }) .pipe( Effect.map((comments) => ({ comments, truncated: false })), - Effect.orElseSucceed(() => ({ comments: [], truncated: true })), + recoverWorkspaceError(() => ({ comments: [], truncated: true })), ) ).pipe( Effect.map((conversation): ProviderChangeRequestActivity => ({ diff --git a/apps/server/src/pullRequest/BitbucketPullRequestApi.ts b/apps/server/src/pullRequest/BitbucketPullRequestApi.ts index c7d017071fc6..cfdc14b252a3 100644 --- a/apps/server/src/pullRequest/BitbucketPullRequestApi.ts +++ b/apps/server/src/pullRequest/BitbucketPullRequestApi.ts @@ -1,3 +1,4 @@ +import { beforeCurrentWorkspaceEffect } from "../auth/workspaceReadAccess.ts"; import * as Cache from "effect/Cache"; import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; @@ -591,7 +592,9 @@ export const make = Effect.gen(function* () { const revisionPatches = yield* Cache.makeWith( (key: string) => { const [repository, number] = JSON.parse(key) as [string, number]; - return pullRequestDiff({ repository, number }).pipe( + return beforeCurrentWorkspaceEffect.pipe( + Effect.andThen(pullRequestDiff({ repository, number })), + Effect.tap(() => beforeCurrentWorkspaceEffect), Effect.map((diff) => ({ revisions: parseDiffFileRevisions(diff.patch), truncated: diff.truncated, @@ -682,7 +685,11 @@ export const make = Effect.gen(function* () { getFileRevisions: (input) => input.paths.length === 0 ? Effect.succeed({ revisions: new Map(), complete: false }) - : Cache.get(revisionPatches, JSON.stringify([input.repository, input.number])).pipe( + : beforeCurrentWorkspaceEffect.pipe( + Effect.andThen( + Cache.get(revisionPatches, JSON.stringify([input.repository, input.number])), + ), + Effect.tap(() => beforeCurrentWorkspaceEffect), Effect.map((diff) => { const revisions = new Map(diff.revisions); // A patch cut short at the byte ceiling says nothing about the files past the cut, diff --git a/apps/server/src/pullRequest/BitbucketPullRequestProvider.test.ts b/apps/server/src/pullRequest/BitbucketPullRequestProvider.test.ts index 7c2d58af950e..2676527354bc 100644 --- a/apps/server/src/pullRequest/BitbucketPullRequestProvider.test.ts +++ b/apps/server/src/pullRequest/BitbucketPullRequestProvider.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; +import { AuthOrchestrationReadScope, EnvironmentAuthorizationError } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Result from "effect/Result"; @@ -19,8 +20,8 @@ for (const operation of [ "listComments", "listCommits", ] as const) { - it.effect.each(["response", "body read"])( - `preserves rate limits from ${operation} on %s errors while recovering other optional-read failures`, + it.effect.each(["response", "body read", "authorization"])( + `preserves rate limits and authorization from ${operation} on %s errors while recovering ordinary optional-read failures`, (variant) => Effect.gen(function* () { const pullRequest = Result.getOrThrow( @@ -33,7 +34,11 @@ for (const operation of [ "links": { "html": { "href": "https://bitbucket.org/acme/web/pull-requests/1" } } }`), ); - for (const status of [429, 403]) { + const denial = new EnvironmentAuthorizationError({ + requiredScope: AuthOrchestrationReadScope, + message: "fixture denial", + }); + for (const status of variant === "authorization" ? [403] : [429, 403]) { const provider = yield* make.pipe( Effect.provide( Layer.mock(BitbucketPullRequestApi.BitbucketPullRequestApi)({ @@ -46,19 +51,21 @@ for (const operation of [ listCommits: () => Effect.succeed([]), [operation]: () => Effect.fail( - variant === "response" - ? new BitbucketApi.BitbucketResponseError({ - operation: "request", - status, - responseBodyLength: 0, - retryAt: 120_000, - }) - : new BitbucketApi.BitbucketResponseBodyReadError({ - operation: "request", - status, - cause: new Error("response stream failed"), - retryAt: 120_000, - }), + variant === "authorization" + ? denial + : variant === "response" + ? new BitbucketApi.BitbucketResponseError({ + operation: "request", + status, + responseBodyLength: 0, + retryAt: 120_000, + }) + : new BitbucketApi.BitbucketResponseBodyReadError({ + operation: "request", + status, + cause: new Error("response stream failed"), + retryAt: 120_000, + }), ), }), ), @@ -72,7 +79,10 @@ for (const operation of [ const result = yield* operation === "listComments" || operation === "listCommits" ? Effect.result(provider.getChangeRequestActivity(reference)) : Effect.result(provider.getChangeRequest(reference)); - if (status === 429) { + if (variant === "authorization") { + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") expect(result.failure).toBe(denial); + } else if (status === 429) { expect(result).toMatchObject({ _tag: "Failure", failure: { reason: "rate-limited", retryAt: 120_000 }, diff --git a/apps/server/src/pullRequest/GitHubPullRequestApi.test.ts b/apps/server/src/pullRequest/GitHubPullRequestApi.test.ts index 294d053d6f05..09da904150e0 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestApi.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestApi.test.ts @@ -1,6 +1,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import { afterEach, assert, expect, it, vi } from "@effect/vitest"; +import { AuthOrchestrationReadScope, EnvironmentAuthorizationError } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Clock from "effect/Clock"; import * as Deferred from "effect/Deferred"; @@ -19,6 +20,7 @@ import * as GitHubQuota from "../sourceControl/githubQuota.ts"; import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts"; +import * as GitHubPullRequestProvider from "./GitHubPullRequestProvider.ts"; import { KnownWorkflowRuns } from "./gitHubConditionalChecks.ts"; const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); @@ -4586,3 +4588,142 @@ layer("GitHubPullRequestApi.layer", (it) => { }), ); }); + +layer("workflow REST recoveries", (it) => { + const input = { cwd: "/w", repository: "acme/web", host: "github.com", number: 7 }; + const ordinaryFailure = new GitHubApi.GitHubApiResponseError({ + host: "github.com", + operation: "listWorkflowRunsRequiringApproval", + status: 403, + }); + const denial = new EnvironmentAuthorizationError({ + requiredScope: AuthOrchestrationReadScope, + message: "workflow admission denied", + }); + const routeFailure = (error: GitHubApi.GitHubApiError) => + mockedExecute.mockImplementation((call) => + call.kind === "rest" + ? Effect.fail(error) + : Effect.succeed( + output( + encodeJson( + call.query.includes("headRefName: $head") ? heads([7]) : crossRepositoryDetail(), + ), + ), + ), + ); + + it.effect.each(["denial", "ordinary"] as const)( + "workflow REST %s retains the actual PR API error channel", + (failure) => + Effect.gen(function* () { + routeFailure(failure === "denial" ? denial : ordinaryFailure); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + const error = yield* cli + .listWorkflowRunsRequiringApproval({ + ...input, + headSha: "abc123", + headBranch: "feat/page", + headRepositoryOwner: "octocat", + isCrossRepository: true, + }) + .pipe(Effect.flip); + if (failure === "denial") { + expect(error).toBe(denial); + } else { + expect(error._tag).toBe("GitHubPullRequestReadError"); + if (error._tag === "GitHubPullRequestReadError") { + expect(error.operation).toBe("listWorkflowRunsRequiringApproval"); + expect(error.cause).toBe(ordinaryFailure); + } + } + expect(restCallsTo("actions/runs?")).toHaveLength(1); + }), + ); + + it.effect.each([ + { read: "detail", failure: "denial" }, + { read: "checks", failure: "denial" }, + { read: "detail", failure: "ordinary" }, + { read: "checks", failure: "ordinary" }, + ] as const)( + "workflow REST $read read preserves $failure through the production provider", + ({ read, failure }) => + Effect.gen(function* () { + routeFailure(failure === "denial" ? denial : ordinaryFailure); + const provider = yield* GitHubPullRequestProvider.make; + const operation = + read === "detail" ? provider.getChangeRequest : provider.getChangeRequestChecks; + if (operation === undefined) return yield* Effect.die("checks read missing"); + const result = yield* Effect.result(operation(input)); + if (failure === "denial") { + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") expect(result.failure).toBe(denial); + } else { + expect(result._tag).toBe("Success"); + if (result._tag === "Success") { + expect(result.success.checks).toContainEqual({ + name: "Workflow approval status", + status: "action-required", + description: "GitHub could not determine whether workflows are awaiting approval.", + url: null, + }); + } + } + expect(restCallsTo("actions/runs?")).toHaveLength(1); + }), + ); +}); + +layer("authorization recoveries", (it) => { + it.effect("optional stack badges preserve typed denial rather than returning the listing", () => + Effect.gen(function* () { + const denial = new EnvironmentAuthorizationError({ + requiredScope: AuthOrchestrationReadScope, + message: "fixture denial", + }); + mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(1, 1)))); + mockedStackMemberships.mockReturnValueOnce(Effect.fail(denial)); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + expect( + yield* cli + .listPullRequests({ + cwd: "/w", + host: "github.com", + repository: "acme/web", + state: "open", + involvement: "all", + viewer: "viewer", + limit: 10, + }) + .pipe(Effect.flip), + ).toBe(denial); + expect(mockedExecute).toHaveBeenCalledTimes(1); + expect(mockedStackMemberships).toHaveBeenCalledTimes(1); + }), + ); + it.effect( + "files fallback preserves its typed denial instead of relabeling it as the original HTTP refusal", + () => + Effect.gen(function* () { + const denial = new EnvironmentAuthorizationError({ + requiredScope: AuthOrchestrationReadScope, + message: "fixture denial", + }); + mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); + mockedExecute.mockReturnValueOnce(Effect.fail(denial)); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + expect( + yield* cli + .getPullRequestDiff({ + cwd: "/w", + host: "github.com", + repository: "acme/web", + number: 7, + }) + .pipe(Effect.flip), + ).toBe(denial); + expect(mockedExecute).toHaveBeenCalledTimes(2); + }), + ); +}); diff --git a/apps/server/src/pullRequest/GitHubPullRequestApi.ts b/apps/server/src/pullRequest/GitHubPullRequestApi.ts index 5e3572915460..651a7c05a997 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestApi.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestApi.ts @@ -1,7 +1,11 @@ +import { + beforeCurrentWorkspaceEffect, + workspaceEffectAdmission, +} from "../auth/workspaceReadAccess.ts"; +import { trustedReadAuthority } from "../auth/ResourceAuthorization.ts"; import { removeAgentCredits } from "./mergeMessage.ts"; import { KnownWorkflowRuns, makeChecksRevalidator } from "./gitHubConditionalChecks.ts"; import { runGitHubStackAction, type GitHubStackActionError } from "./githubStackActions.ts"; -import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Clock from "effect/Clock"; import * as Duration from "effect/Duration"; @@ -1126,7 +1130,9 @@ export const make = Effect.gen(function* () { const unavailable = () => new GitHubViewerLoginUnavailableError({ cwd: input.cwd }); const host = input.host.toLowerCase(); // A missing or signed-out credential keeps its own error, so the page can say which. + yield* beforeCurrentWorkspaceEffect; const { token, fingerprint: key } = yield* api.credential(host); + yield* beforeCurrentWorkspaceEffect; const credential = { host, token, credentialFingerprint: key }; // A cold page may ask several times. Wait per credential and check again after the // first verification; cancellation releases the next waiter without losing its request. @@ -1141,6 +1147,7 @@ export const make = Effect.gen(function* () { lock.gate.withPermit( Effect.gen(function* () { const now = yield* Clock.currentTimeMillis; + yield* beforeCurrentWorkspaceEffect; const cached = routingIdentities.get(key); if (cached !== undefined && now - cached.at < 10 * 60_000) return { ...credential, ...cached.value }; @@ -1155,6 +1162,7 @@ export const make = Effect.gen(function* () { Effect.mapError(unavailable), ); const value = { accountId: String(identity.id), viewer: identity.login }; + yield* beforeCurrentWorkspaceEffect; if (routingIdentities.size >= 128) routingIdentities.delete(routingIdentities.keys().next().value!); routingIdentities.set(key, { at: now, value }); @@ -1199,15 +1207,16 @@ export const make = Effect.gen(function* () { */ const nodeIds = new Map(); - const pullRequestNodeId = (input: { + const pullRequestNodeId = Effect.fn("GitHubPullRequestApi.pullRequestNodeId")(function* (input: { readonly cwd: string; readonly repository: string; readonly host: string; readonly number: number; readonly operation: string; - }): Effect.Effect => { + }): Effect.fn.Return { const { owner, name } = parseRepositorySelector(input.repository); const key = `${input.host} ${owner}/${name} ${input.number}`; + yield* beforeCurrentWorkspaceEffect; const held = nodeIds.get(key); if (held !== undefined) { // Put back at the end on every hit, so what falls out is the pull request nobody has looked @@ -1215,9 +1224,9 @@ export const make = Effect.gen(function* () { // open review and make it pay a round trip per press. nodeIds.delete(key); nodeIds.set(key, held); - return Effect.succeed(held); + return held; } - return graphqlRead({ + return yield* graphqlRead({ cwd: input.cwd, host: input.host, operation: input.operation, @@ -1236,7 +1245,7 @@ export const make = Effect.gen(function* () { }), ), ); - }; + }); /** * Whether a client-given subject actually belongs to the pull request the request names. A @@ -1306,7 +1315,11 @@ export const make = Effect.gen(function* () { ...(input.variables === undefined ? {} : { variables: input.variables }), ...(input.allowReserve === true ? { allowReserve: true } : {}), }) - .pipe(Effect.flatMap((raw) => decodeWith(input.cwd, input.operation, input.decode, raw))); + .pipe( + Effect.tap(() => beforeCurrentWorkspaceEffect), + Effect.flatMap((raw) => decodeWith(input.cwd, input.operation, input.decode, raw)), + (read) => beforeCurrentWorkspaceEffect.pipe(Effect.andThen(read)), + ); /** A REST read whose JSON answer is decoded the same way. */ const restRead = (input: { @@ -1316,13 +1329,13 @@ export const make = Effect.gen(function* () { readonly path: string; readonly decode: (raw: string) => Result.Result; }): Effect.Effect => - api - .rest({ host: input.host, operation: input.operation, path: input.path }) - .pipe( - Effect.flatMap((response) => - decodeWith(input.cwd, input.operation, input.decode, response.body), - ), - ); + api.rest({ host: input.host, operation: input.operation, path: input.path }).pipe( + Effect.tap(() => beforeCurrentWorkspaceEffect), + Effect.flatMap((response) => + decodeWith(input.cwd, input.operation, input.decode, response.body), + ), + (read) => beforeCurrentWorkspaceEffect.pipe(Effect.andThen(read)), + ); /** * One page of the patch, read from the files API. GitHub refuses a whole diff past 300 changed @@ -1567,8 +1580,10 @@ export const make = Effect.gen(function* () { }; const workflowApprovalLimit = 1_000; - const workflowApprovalReadError = (cwd: string, cause: unknown) => - readError(cwd, "listWorkflowRunsRequiringApproval", cause); + const workflowApprovalReadError = (cwd: string, cause: GitHubPullRequestApiError) => + cause._tag === "EnvironmentAuthorizationError" + ? cause + : readError(cwd, "listWorkflowRunsRequiringApproval", cause); /** * Every open pull request whose head branch carries this name, up to one past the limit. @@ -1746,73 +1761,82 @@ export const make = Effect.gen(function* () { * `gh pr view` apiece is most of what it spends. Whatever the batch cannot answer — a selector * GraphQL cannot address, a pull request GitHub returned nothing for — is read on its own. */ - const summaryResolver = RequestResolver.makeGrouped({ - key: ({ request, context }) => - JSON.stringify([ + // Pending keys isolate external entries without interning and retaining every caller. + const summaryResolver = RequestResolver.makeWith({ + delay: Effect.yieldNow, + collectWhile: () => true, + batchKey: (entry) => { + const { request, context } = entry; + const admission = Context.getOrElse(context, workspaceEffectAdmission, () => undefined); + // External requests keep their own current resource and action, even on one account. + if (admission !== undefined && admission.authority !== trustedReadAuthority) return entry; + return JSON.stringify([ request.host.toLowerCase(), Context.getOrElse(context, GitHubApi.PinnedGitHubCredential, () => null) ?.credentialFingerprint ?? null, Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""), - ]), - resolver: (entries) => { + ]); + }, + runAll: (entries) => { const [first] = entries; - const batchable = entries.filter( - (entry) => buildPullRequestSummariesGraphQlQuery([entry.request]) !== null, - ); - // Stack membership rides along where GitHub serves stacks, so the background sync can skip - // the REST stack read for pull requests that are in none. - const document = buildPullRequestSummariesGraphQlQuery( - batchable.map((entry) => entry.request), - first.request.host === "github.com", - ); - const batched = - document === null - ? Effect.succeed(new Map()) - : graphqlRead({ - cwd: first.request.cwd, - host: first.request.host, - operation: "getPullRequestSummary", - ...document, - decode: decodePullRequestSummariesJson, + return Effect.gen(function* () { + yield* beforeCurrentWorkspaceEffect; + const batchable = entries.filter( + (entry) => buildPullRequestSummariesGraphQlQuery([entry.request]) !== null, + ); + // Stack membership rides along where GitHub serves stacks, so the background sync can skip + // the REST stack read for pull requests that are in none. + const document = buildPullRequestSummariesGraphQlQuery( + batchable.map((entry) => entry.request), + first.request.host === "github.com", + ); + const batched = + document === null + ? Effect.succeed(new Map()) + : graphqlRead({ + cwd: first.request.cwd, + host: first.request.host, + operation: "getPullRequestSummary", + ...document, + decode: decodePullRequestSummariesJson, + }); + return yield* batched.pipe( + // A GraphQL error anywhere fails the whole document — one repository gone or out of + // reach — so a batch that could not be read leaves every entry to its own read. A paused + // budget is the exception: reading one at a time would only spend what is being saved. + Effect.catch((error) => + error._tag === "SourceControlRateLimitPausedError" || + error._tag === "EnvironmentAuthorizationError" + ? Effect.fail(error) + : Effect.logDebug("batched pull request summary read failed", { error }).pipe( + Effect.as(new Map()), + ), + ), + Effect.flatMap((summaries) => { + const unanswered = entries.filter((entry) => { + const summary = summaries.get(batchable.indexOf(entry)); + if (summary === undefined) return true; + entry.completeUnsafe(Exit.succeed(summary)); + return false; }); - return batched.pipe( - // A GraphQL error anywhere fails the whole document — one repository gone or out of - // reach — so a batch that could not be read leaves every entry to its own read. A paused - // budget is the exception: reading one at a time would only spend what is being saved. - Effect.catchCauseIf( - (cause) => - !Cause.hasInterruptsOnly(cause) && - !Cause.findErrorOption(cause).pipe( - Option.exists((error) => error._tag === "SourceControlRateLimitPausedError"), - ), - (cause) => - Effect.logDebug("batched pull request summary read failed", { cause }).pipe( - Effect.as(new Map()), - ), - ), - Effect.flatMap((summaries) => { - const unanswered = entries.filter((entry) => { - const summary = summaries.get(batchable.indexOf(entry)); - if (summary === undefined) return true; - entry.completeUnsafe(Exit.succeed(summary)); - return false; - }); - return Effect.forEach( - unanswered, - (entry) => - viewPullRequestSummary(entry.request).pipe( - Effect.exit, - Effect.map((exit) => entry.completeUnsafe(exit)), - ), - { concurrency: STAT_REQUEST_CONCURRENCY, discard: true }, - ); - }), - Effect.catchCause((cause) => - Effect.sync(() => { - for (const entry of entries) entry.completeUnsafe(Exit.failCause(cause)); + return Effect.forEach( + unanswered, + (entry) => + viewPullRequestSummary(entry.request).pipe( + Effect.provideContext(entry.context), + Effect.exit, + Effect.map((exit) => entry.completeUnsafe(exit)), + ), + { concurrency: STAT_REQUEST_CONCURRENCY, discard: true }, + ); }), - ), - ); + Effect.catchCause((cause) => + Effect.sync(() => { + for (const entry of entries) entry.completeUnsafe(Exit.failCause(cause)); + }), + ), + ); + }).pipe(Effect.provideContext(first.context)); }, }).pipe( RequestResolver.setDelay(SUMMARY_BATCH_WINDOW), @@ -1823,50 +1847,57 @@ export const make = Effect.gen(function* () { // Every watched pull request on a host in one read per pass. A pull request the batch has no // answer for gets null, and its watch reads it in full; a failed batch fails every entry. - const watchFingerprintResolver = RequestResolver.makeGrouped< - PullRequestWatchFingerprintRead, - string - >({ - key: ({ request, context }) => - JSON.stringify([ + const watchFingerprintResolver = RequestResolver.makeWith({ + delay: Effect.yieldNow, + collectWhile: () => true, + batchKey: (entry) => { + const { request, context } = entry; + const admission = Context.getOrElse(context, workspaceEffectAdmission, () => undefined); + // External requests keep their own current resource and action, even on one account. + if (admission !== undefined && admission.authority !== trustedReadAuthority) return entry; + return JSON.stringify([ request.host.toLowerCase(), Context.getOrElse(context, GitHubApi.PinnedGitHubCredential, () => null) ?.credentialFingerprint ?? null, Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""), - ]), - resolver: (entries) => { + ]); + }, + runAll: (entries) => { const [first] = entries; - const batchable = entries.filter( - (entry) => buildPullRequestWatchFingerprintsGraphQlQuery([entry.request]) !== null, - ); - const document = buildPullRequestWatchFingerprintsGraphQlQuery( - batchable.map((entry) => entry.request), - ); - const read = - document === null - ? Effect.succeed(new Map()) - : graphqlRead({ - cwd: first.request.cwd, - host: first.request.host, - operation: "getPullRequestWatchFingerprint", - ...document, - decode: decodePullRequestWatchFingerprintsJson, - }); - return read.pipe( - Effect.map((fingerprints) => { - for (const entry of entries) { - const index = batchable.indexOf(entry); - entry.completeUnsafe( - Exit.succeed(index === -1 ? null : (fingerprints.get(index) ?? null)), - ); - } - }), - Effect.catchCause((cause) => - Effect.sync(() => { - for (const entry of entries) entry.completeUnsafe(Exit.failCause(cause)); + return Effect.gen(function* () { + yield* beforeCurrentWorkspaceEffect; + const batchable = entries.filter( + (entry) => buildPullRequestWatchFingerprintsGraphQlQuery([entry.request]) !== null, + ); + const document = buildPullRequestWatchFingerprintsGraphQlQuery( + batchable.map((entry) => entry.request), + ); + const read = + document === null + ? Effect.succeed(new Map()) + : graphqlRead({ + cwd: first.request.cwd, + host: first.request.host, + operation: "getPullRequestWatchFingerprint", + ...document, + decode: decodePullRequestWatchFingerprintsJson, + }); + return yield* read.pipe( + Effect.map((fingerprints) => { + for (const entry of entries) { + const index = batchable.indexOf(entry); + entry.completeUnsafe( + Exit.succeed(index === -1 ? null : (fingerprints.get(index) ?? null)), + ); + } }), - ), - ); + Effect.catchCause((cause) => + Effect.sync(() => { + for (const entry of entries) entry.completeUnsafe(Exit.failCause(cause)); + }), + ), + ); + }).pipe(Effect.provideContext(first.context)); }, }).pipe( RequestResolver.setDelay(SUMMARY_BATCH_WINDOW), @@ -2022,12 +2053,14 @@ export const make = Effect.gen(function* () { }), ), // Optional badges must not take down a listing that already read successfully. - Effect.catch(() => - Effect.logWarning("Pull request stack membership enrichment failed", { - operation: "listPullRequestStackMemberships", - host: input.host, - rows: chunk.length, - }).pipe(Effect.as(chunk)), + Effect.catch((error) => + error._tag === "EnvironmentAuthorizationError" + ? Effect.fail(error) + : Effect.logWarning("Pull request stack membership enrichment failed", { + operation: "listPullRequestStackMemberships", + host: input.host, + rows: chunk.length, + }).pipe(Effect.as(chunk)), ), ); }, @@ -2248,7 +2281,12 @@ export const make = Effect.gen(function* () { // the page. Narrowed to a request GitHub answered and refused: a missing credential or // a rate limit fails the same way for every request. Effect.catchTags({ - GitHubApiResponseError: (error) => filesPage(1).pipe(Effect.mapError(() => error)), + GitHubApiResponseError: (error) => + filesPage(1).pipe( + Effect.mapError((fallback) => + fallback._tag === "EnvironmentAuthorizationError" ? fallback : error, + ), + ), }), ); }, diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts index b148a82e4e46..2aca2bf13a34 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts @@ -3,7 +3,11 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Redacted from "effect/Redacted"; import * as Result from "effect/Result"; -import type { PullRequestReaction } from "@t3tools/contracts"; +import { + AuthOrchestrationReadScope, + EnvironmentAuthorizationError, + type PullRequestReaction, +} from "@t3tools/contracts"; import { decodePullRequestDetailJson } from "./gitHubPullRequestJson.ts"; import * as GitHubApi from "../sourceControl/GitHubApi.ts"; @@ -1067,3 +1071,57 @@ describe("loginAvatarUrl", () => { } }); }); + +it.effect.each(["avatars", "activity", "viewer fallback", "workflow unavailable"] as const)( + "preserves typed denial from %s without optional recovery or another read", + (stage) => + Effect.gen(function* () { + const denial = new EnvironmentAuthorizationError({ + requiredScope: AuthOrchestrationReadScope, + message: "fixture denial", + }); + let fallback = 0; + const provider = yield* make.pipe( + Effect.provide( + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ + revalidateChecks: (_input, read) => read, + listPullRequests: () => + Effect.succeed({ items: [], truncated: false, continues: true }), + listActorAvatars: () => Effect.fail(denial), + getPullRequestActivity: () => + Effect.succeed({ author: null, comments: [], commits: [] }), + listReviewThreadComments: () => Effect.fail(denial), + getPullRequestDetail: () => + stage === "viewer fallback" ? Effect.fail(denial) : Effect.succeed(openDetail), + listWorkflowRunsRequiringApproval: () => Effect.fail(denial), + getViewerAccess: () => + Effect.sync(() => { + fallback++; + return coreFields.viewerAccess; + }), + }), + ), + ); + const input = { cwd: "/w", host: "github.com", repository: "acme/web", number: 7 }; + const result = yield* ( + stage === "avatars" + ? provider + .listChangeRequests({ + ...input, + state: "open", + involvement: "all", + viewer: "viewer", + limit: 10, + }) + .pipe(Effect.asVoid) + : stage === "activity" + ? provider.getChangeRequestActivity(input).pipe(Effect.asVoid) + : stage === "viewer fallback" + ? provider.getViewerPermissions(input).pipe(Effect.asVoid) + : provider.getChangeRequest(input).pipe(Effect.asVoid) + ).pipe(Effect.result); + expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) expect(result.failure).toBe(denial); + expect(fallback).toBe(0); + }), +); diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts index 92e77d03517b..c3e4c40478dd 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts @@ -1,3 +1,4 @@ +import { recoverWorkspaceError } from "../auth/workspaceReadAccess.ts"; import * as Effect from "effect/Effect"; import type { PullRequestActor, @@ -234,6 +235,7 @@ export const make = Effect.gen(function* () { .pipe( Effect.matchEffect({ onFailure: (error) => + error._tag === "EnvironmentAuthorizationError" || error._tag === "GitHubApiRateLimitError" || error._tag === "SourceControlRateLimitPausedError" ? Effect.fail(error) @@ -303,7 +305,7 @@ export const make = Effect.gen(function* () { // A listing without faces is still a listing, so a failed lookup falls back to // the initials rather than taking the rows down with it. .pipe( - Effect.orElseSucceed(() => new Map()), + recoverWorkspaceError(() => new Map()), Effect.map((avatarsByLogin) => ({ ...page, items: page.items.map((item) => ({ @@ -419,7 +421,7 @@ export const make = Effect.gen(function* () { // Line comments live on review threads, which `gh pr view --json` cannot reach. A // GraphQL hiccup degrades to a truncated conversation rather than blanking activity. cli.listReviewThreadComments(input).pipe( - Effect.orElseSucceed(() => ({ + recoverWorkspaceError(() => ({ comments: [], dismissalsByReviewId: new Map(), reactions: [], @@ -536,11 +538,12 @@ export const make = Effect.gen(function* () { pullRequest.state === "open" && pullRequest.comparison?.viewerCanUpdate === true, }), ), - Effect.catchIf( - (error) => - error._tag !== "GitHubApiRateLimitError" && - error._tag !== "SourceControlRateLimitPausedError", - () => lightAccess, + Effect.catch((error) => + error._tag === "EnvironmentAuthorizationError" || + error._tag === "GitHubApiRateLimitError" || + error._tag === "SourceControlRateLimitPausedError" + ? Effect.fail(error) + : lightAccess, ), Effect.mapError(fail("getViewerPermissions")), ); diff --git a/apps/server/src/pullRequest/GitLabPullRequestProvider.test.ts b/apps/server/src/pullRequest/GitLabPullRequestProvider.test.ts index d506aace9489..6fbb4d181611 100644 --- a/apps/server/src/pullRequest/GitLabPullRequestProvider.test.ts +++ b/apps/server/src/pullRequest/GitLabPullRequestProvider.test.ts @@ -1,4 +1,5 @@ import { assert, describe, expect, it, vi } from "@effect/vitest"; +import { AuthOrchestrationReadScope, EnvironmentAuthorizationError } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -212,3 +213,35 @@ describe("rewriting what has already been said", () => { }), ); }); + +it.effect.each(["listNotes", "listCommits", "listDiscussions", "listReactions"] as const)( + "activity preserves typed denial from %s instead of returning a partial conversation", + (method) => + Effect.gen(function* () { + const denial = new EnvironmentAuthorizationError({ + requiredScope: AuthOrchestrationReadScope, + message: "fixture denial", + }); + const provider = yield* make.pipe( + Effect.provide( + Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({ + listNotes: () => Effect.succeed({ comments: [], truncated: false }), + listCommits: () => Effect.succeed([]), + listDiscussions: () => Effect.succeed({ threads: [], truncated: false }), + listReactions: () => Effect.succeed({ reactions: [], reactionsByNoteId: new Map() }), + [method]: () => Effect.fail(denial), + }), + ), + ); + expect( + yield* provider + .getChangeRequestActivity({ + cwd: "/w", + host: "gitlab.com", + repository: "acme/web", + number: 7, + }) + .pipe(Effect.flip), + ).toBe(denial); + }), +); diff --git a/apps/server/src/pullRequest/GitLabPullRequestProvider.ts b/apps/server/src/pullRequest/GitLabPullRequestProvider.ts index 214bf52cb5f1..fd8967375352 100644 --- a/apps/server/src/pullRequest/GitLabPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GitLabPullRequestProvider.ts @@ -1,3 +1,4 @@ +import { recoverWorkspaceError } from "../auth/workspaceReadAccess.ts"; import * as Effect from "effect/Effect"; import type { PullRequestCapabilities, @@ -184,15 +185,15 @@ export const make = Effect.gen(function* () { [ cli .listNotes(input) - .pipe(Effect.orElseSucceed(() => ({ comments: [], truncated: true }))), - cli.listCommits(input).pipe(Effect.orElseSucceed(() => [])), + .pipe(recoverWorkspaceError(() => ({ comments: [], truncated: true }))), + cli.listCommits(input).pipe(recoverWorkspaceError(() => [])), cli .listDiscussions(input) - .pipe(Effect.orElseSucceed(() => ({ threads: [], truncated: true }))), + .pipe(recoverWorkspaceError(() => ({ threads: [], truncated: true }))), // The notes endpoint carries no award of any kind, so they are read alongside it. A // failed read costs the conversation its reactions rather than its words. cli.listReactions(input).pipe( - Effect.orElseSucceed(() => ({ + recoverWorkspaceError(() => ({ reactions: [] as ReadonlyArray, reactionsByNoteId: new Map>(), })), diff --git a/apps/server/src/pullRequest/PullRequestReadEffects.test.ts b/apps/server/src/pullRequest/PullRequestReadEffects.test.ts new file mode 100644 index 000000000000..fcf8612b2aed --- /dev/null +++ b/apps/server/src/pullRequest/PullRequestReadEffects.test.ts @@ -0,0 +1,1096 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { + AuthOrchestrationReadScope, + AuthSourceControlWriteScope, + type AuthEnvironmentScope, + type AuthResourceGrant, + EnvironmentAuthorizationError, + type SourceControlProviderError, + ServerSettingsError, +} from "@t3tools/contracts"; +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import * as Crypto from "effect/Crypto"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Redacted from "effect/Redacted"; +import * as Result from "effect/Result"; +import * as SqlClient from "effect/sql/SqlClient"; +import * as TestClock from "effect/testing/TestClock"; +import { HttpClient, HttpClientResponse } from "effect/http"; +import { ChildProcessSpawner } from "effect/process"; +import { trustedReadAuthority, type ThreadReadAuthority } from "../auth/ResourceAuthorization.ts"; +import * as Access from "../auth/workspaceReadAccess.ts"; +import { + collectionStoreLayer, + grantA, + grantNone, + makeCollectionFixture, + projectA, + projectB, +} from "../orchestration-v2/projectCollectionTestFixtures.ts"; +import * as Settings from "../serverSettings.ts"; +import * as GitHubApi from "../sourceControl/GitHubApi.ts"; +import * as Credentials from "../sourceControl/GitHubCredentials.ts"; +import * as Forgejo from "../sourceControl/ForgejoCli.ts"; +import * as Bitbucket from "../sourceControl/BitbucketApi.ts"; +import * as BitbucketPR from "./BitbucketPullRequestApi.ts"; +import * as Quota from "../sourceControl/githubQuota.ts"; +import * as Hosting from "../sourceControl/GitHubSourceControlProvider.ts"; +import * as Limits from "../sourceControl/SourceControlRateLimit.ts"; +import * as Git from "../vcs/GitVcsDriver.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as PullRequests from "./GitHubPullRequestApi.ts"; +import * as ConditionalChecks from "./gitHubConditionalChecks.ts"; +import * as GitLab from "../sourceControl/GitLabCli.ts"; +import * as Azure from "../sourceControl/AzureDevOpsCli.ts"; +import * as Registry from "../vcs/VcsDriverRegistry.ts"; +import * as ForgejoProvider from "./ForgejoPullRequestProvider.ts"; + +const layer = Layer.mergeAll(NodeServices.layer, collectionStoreLayer); +const fixture = (scope: AuthEnvironmentScope = AuthOrchestrationReadScope) => + Effect.gen(function* () { + yield* makeCollectionFixture; + const sql = yield* SqlClient.SqlClient; + const fs = yield* FileSystem.FileSystem; + const base = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pr-read-effects-" }); + const a = `${base}/a`, + b = `${base}/b`; + for (const root of [a, b]) yield* fs.makeDirectory(`${root}/.git`, { recursive: true }); + yield* sql`UPDATE projection_projects SET workspace_root = ${a} WHERE project_id = ${projectA}`; + yield* sql`UPDATE projection_projects SET workspace_root = ${b} WHERE project_id = ${projectB}`; + let current: { resourceGrant: AuthResourceGrant; scopes: ReadonlyArray } = + { + resourceGrant: { ...grantA, projects: { mode: "explicit", ids: [projectB] } }, + scopes: [scope], + }; + const authority: ThreadReadAuthority = { current: Effect.sync(() => current) }; + const aAuthority: ThreadReadAuthority = { + current: Effect.succeed({ + resourceGrant: grantA, + scopes: [scope], + }), + }; + const access = yield* Access.make; + const cut = (kind: "grant" | "root" | "membership" | "action") => { + if (kind === "root") + return sql`UPDATE projection_projects SET workspace_root = ${`${base}/moved`} WHERE project_id = ${projectB}`.pipe( + Effect.asVoid, + ); + if (kind === "membership") + return sql`UPDATE projection_projects SET deleted_at = '2026-10-11T00:00:00Z' WHERE project_id = ${projectB}`.pipe( + Effect.asVoid, + ); + return Effect.sync(() => { + current = + kind === "grant" ? { ...current, resourceGrant: grantNone } : { ...current, scopes: [] }; + }); + }; + return { + a, + b, + access, + authority, + aAuthority, + cut, + restore: + sql`UPDATE projection_projects SET workspace_root = ${b} WHERE project_id = ${projectB}`.pipe( + Effect.asVoid, + ), + }; + }); +const admitted = ( + effect: Effect.Effect, + admission: Access.WorkspaceActionAdmission, +) => effect.pipe(Effect.provideService(Access.workspaceEffectAdmission, admission)); +const expectDenied = (result: Result.Result) => { + expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) + expect(result.failure).toMatchObject({ _tag: "EnvironmentAuthorizationError" }); +}; +const output = (stdout: string, exitCode = 0): VcsProcess.VcsProcessOutput => ({ + exitCode: ChildProcessSpawner.ExitCode(exitCode), + stdout, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, +}); +const summary = (number: number) => ({ + number, + title: `PR ${number}`, + url: `https://github.com/acme/web/pull/${number}`, + author: null, + baseRefName: "main", + headRefName: `feat/${number}`, + state: "OPEN", + isDraft: false, + mergeable: "MERGEABLE", + reviewDecision: null, + latestReviews: { nodes: [] }, + additions: 1, + deletions: 0, + changedFiles: 1, + updatedAt: "2026-10-11T00:00:00Z", + mergedAt: null, + closedAt: null, + commits: { nodes: [] }, +}); +const fingerprint = { + state: "OPEN", + mergeable: "MERGEABLE", + headRefOid: "abc123", + comments: { totalCount: 0, nodes: [] }, + reviews: { totalCount: 0, nodes: [] }, + reviewThreads: { totalCount: 0 }, + commits: { nodes: [] }, +}; +const github = (waitForCredential: Effect.Effect) => + Effect.gen(function* () { + const documents: Array> = []; + const calls: string[] = []; + const api = yield* GitHubApi.make.pipe( + Effect.provide( + Layer.mergeAll( + Layer.mock(Credentials.GitHubCredentials)({ + get: (host) => + waitForCredential.pipe( + Effect.as({ + host, + token: Redacted.make("fixture-token"), + source: "gh" as const, + fingerprint: "same-account", + }), + ), + invalidate: () => + Effect.sync(() => { + calls.push("invalidate"); + }), + }), + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.sync(() => { + calls.push(request.url); + if (request.url.endsWith("/user")) + return HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify({ id: 123, login: "same-account" })), + ); + if (request.body._tag !== "Uint8Array") + throw new Error("Expected a GraphQL document"); + const { query, variables } = JSON.parse( + new TextDecoder().decode(request.body.body), + ) as { query: string; variables: Record }; + documents.push(variables); + const data: Record = {}; + for (const [key, value] of Object.entries(variables)) { + if (key.endsWith("_number")) + data[key.slice(0, -7)] = { + pullRequest: query.includes("PullRequestWatchFingerprints") + ? fingerprint + : summary(Number(value)), + }; + } + if (query.includes("PullRequestsByHead")) + data.repository = { h0: { nodes: [] }, h1: { nodes: [] } }; + return HttpClientResponse.fromWeb(request, new Response(JSON.stringify({ data }))); + }), + ), + ), + Quota.layer, + Limits.layer, + ), + ), + ); + const process = Layer.mock(VcsProcess.VcsProcess)({ + run: (input) => + Effect.succeed( + output( + input.args[0] === "remote" ? "origin git@github.com:acme/web.git (fetch)" : "", + input.args[0] === "config" ? 1 : 0, + ), + ), + }); + const cli = yield* PullRequests.make.pipe( + Effect.provideService(GitHubApi.GitHubApi, api), + Effect.provide(process), + ); + const hosting = yield* Hosting.make.pipe( + Effect.provideService(GitHubApi.GitHubApi, api), + Effect.provide(Layer.mergeAll(process, Layer.mock(Git.GitVcsDriver)({}))), + Effect.provideService(HostProcessEnvironment, {}), + ); + return { api, cli, hosting, documents, calls }; + }); + +it.effect.each( + (["summary", "fingerprint", "head"] as const).flatMap((method) => + (["grant", "root", "membership", "action"] as const).map((cut) => ({ method, cut })), + ), +)( + "$method: same-credential B $cut cut during credential preflight never reaches the document", + ({ method, cut }) => + Effect.gen(function* () { + const f = yield* fixture( + method === "head" ? AuthSourceControlWriteScope : AuthOrchestrationReadScope, + ); + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + const g = yield* github( + Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(release))), + ); + const scope = method === "head" ? AuthSourceControlWriteScope : AuthOrchestrationReadScope; + const a = yield* f.access.capture(f.aAuthority, { cwd: f.a, projectId: projectA }, scope); + const b = yield* f.access.capture(f.authority, { cwd: f.b, projectId: projectB }, scope); + const read = ( + cwd: string, + number: number, + ): Effect.Effect< + unknown, + | PullRequests.GitHubPullRequestApiError + | SourceControlProviderError + | EnvironmentAuthorizationError + > => + method === "head" + ? g.hosting.listChangeRequests({ + cwd, + headSelector: `feature-${number}`, + state: "open", + }) + : method === "summary" + ? g.cli.getPullRequestSummary({ + cwd, + repository: "acme/web", + host: "github.com", + number, + }) + : g.cli.getPullRequestWatchFingerprint({ + cwd, + repository: "acme/web", + host: "github.com", + number, + }); + const first = yield* admitted(read(f.a, 1), a).pipe( + Effect.result, + Effect.forkChild({ startImmediately: true }), + ); + const second = yield* admitted(read(f.b, 2), b).pipe( + Effect.result, + Effect.forkChild({ startImmediately: true }), + ); + yield* TestClock.adjust(method === "head" ? "50 millis" : "10 millis"); + yield* Deferred.await(started); + yield* f.cut(cut); + yield* Deferred.succeed(release, undefined); + expect(Result.isSuccess(yield* Fiber.join(first))).toBe(true); + expectDenied(yield* Fiber.join(second)); + expect(g.documents).toHaveLength(1); + expect(Object.values(g.documents[0]!)).not.toContain(2); + expect(Object.values(g.documents[0]!)).not.toContain("feature-2"); + expect(g.calls).toHaveLength(1); + }).pipe(Effect.provide(layer)), +); + +it.effect.each(["summary", "fingerprint", "head"] as const)( + "%s: external B cannot borrow trusted A through the batching delay", + (method) => + Effect.gen(function* () { + const f = yield* fixture( + method === "head" ? AuthSourceControlWriteScope : AuthOrchestrationReadScope, + ); + const g = yield* github(Effect.void); + const scope = method === "head" ? AuthSourceControlWriteScope : AuthOrchestrationReadScope; + const a = yield* f.access.capture(trustedReadAuthority, { cwd: f.a }, scope); + const b = yield* f.access.capture(f.authority, { cwd: f.b, projectId: projectB }, scope); + const read = ( + cwd: string, + number: number, + ): Effect.Effect< + unknown, + | PullRequests.GitHubPullRequestApiError + | SourceControlProviderError + | EnvironmentAuthorizationError + > => + method === "head" + ? g.hosting.listChangeRequests({ cwd, headSelector: `feature-${number}`, state: "open" }) + : method === "summary" + ? g.cli.getPullRequestSummary({ + cwd, + repository: "acme/web", + host: "github.com", + number, + }) + : g.cli.getPullRequestWatchFingerprint({ + cwd, + repository: "acme/web", + host: "github.com", + number, + }); + const first = yield* admitted(read(f.a, 1), a).pipe( + Effect.result, + Effect.forkChild({ startImmediately: true }), + ); + const second = yield* admitted(read(f.b, 2), b).pipe( + Effect.result, + Effect.forkChild({ startImmediately: true }), + ); + yield* f.cut("grant"); + yield* TestClock.adjust(method === "head" ? "50 millis" : "10 millis"); + expect(Result.isSuccess(yield* Fiber.join(first))).toBe(true); + expectDenied(yield* Fiber.join(second)); + expect(g.documents).toHaveLength(1); + expect(Object.values(g.documents[0]!)).not.toContain(2); + expect(Object.values(g.documents[0]!)).not.toContain("feature-2"); + }).pipe(Effect.provide(layer)), +); + +it.effect.each(["host settings", "saved token settings", "warm token"] as const)( + "GitHubCredentials: %s wait cannot reach a credential after a current root cut", + (stage) => + Effect.gen(function* () { + const f = yield* fixture(); + const admission = yield* f.access.capture( + f.authority, + { cwd: f.b, projectId: projectB }, + AuthOrchestrationReadScope, + ); + const settings = yield* Settings.ServerSettingsService.pipe( + Effect.provide(Settings.ServerSettingsService.layerTest()), + ); + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + let hold = stage !== "warm token", + reads = 0, + commands = 0, + tokenReads = 0; + const snapshot = yield* settings.getSettings; + const tokens = Object.defineProperty({}, "github.com", { + get: () => { + tokenReads++; + return undefined; + }, + }); + const credentials = yield* Credentials.make.pipe( + Effect.provideService(HostProcessEnvironment, {}), + Effect.provideService(Settings.ServerSettingsService, { + ...settings, + getSettings: Effect.suspend(() => { + reads++; + const wait = hold && (stage === "saved token settings" ? reads === 2 : true); + return ( + wait + ? Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(release))) + : Effect.void + ).pipe(Effect.as({ ...snapshot, github: { ...snapshot.github, tokens } })); + }), + }), + Effect.provide( + Layer.mock(VcsProcess.VcsProcess)({ + run: () => + Access.beforeCurrentWorkspaceEffect.pipe( + Effect.andThen( + Effect.sync(() => { + commands++; + return output("fixture-token"); + }), + ), + ), + }), + ), + ); + if (stage === "warm token") { + yield* admitted(credentials.get("github.com"), admission); + hold = true; + tokenReads = 0; + } + const pending = yield* admitted(credentials.get("github.com"), admission).pipe( + Effect.result, + Effect.forkChild, + ); + yield* Deferred.await(started); + yield* f.cut("root"); + yield* Deferred.succeed(release, undefined); + expectDenied(yield* Fiber.join(pending)); + expect(commands).toBe(stage === "warm token" ? 1 : 0); + expect(tokenReads).toBe(0); + }).pipe(Effect.provide(layer)), +); + +it.effect("GitHubApi: a pinned credential cannot skip current action admission", () => + Effect.gen(function* () { + const f = yield* fixture(AuthSourceControlWriteScope); + const admission = yield* f.access.capture( + f.authority, + { cwd: f.b, projectId: projectB }, + AuthSourceControlWriteScope, + ); + const g = yield* github(Effect.void); + yield* f.cut("action"); + const result = yield* admitted( + g.api.credential("github.com").pipe( + Effect.provideService(GitHubApi.PinnedGitHubCredential, { + host: "github.com", + token: Redacted.make("fixture-token"), + credentialFingerprint: "same-account", + }), + ), + admission, + ).pipe(Effect.result); + expectDenied(result); + expect(g.calls).toEqual([]); + }).pipe(Effect.provide(layer)), +); + +it.effect.each(["settings", "gh"] as const)( + "GitHubCredentials: %s fingerprint await cannot publish a denied token", + (source) => + Effect.gen(function* () { + const f = yield* fixture(); + const admission = yield* f.access.capture( + f.authority, + { cwd: f.b, projectId: projectB }, + AuthOrchestrationReadScope, + ); + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + let commands = 0, + hold = true; + const crypto = yield* Crypto.Crypto; + const credentials = yield* Credentials.make.pipe( + Effect.provide( + Layer.mergeAll( + Settings.ServerSettingsService.layerTest({ + github: { + tokens: source === "settings" ? { "github.com": "fixture-token" } : {}, + hosts: {}, + }, + }), + Layer.mock(VcsProcess.VcsProcess)({ + run: () => + Effect.sync(() => { + commands++; + return output("fixture-token"); + }), + }), + ), + ), + Effect.provideService(HostProcessEnvironment, {}), + Effect.provideService(Crypto.Crypto, { + ...crypto, + digest: (algorithm, value) => + Effect.suspend(() => + (hold + ? Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(release))) + : Effect.void + ).pipe(Effect.andThen(crypto.digest(algorithm, value))), + ), + }), + ); + const pending = yield* admitted(credentials.get("github.com"), admission).pipe( + Effect.result, + Effect.forkChild({ startImmediately: true }), + ); + yield* Deferred.await(started); + yield* f.cut("root"); + yield* Deferred.succeed(release, undefined); + expectDenied(yield* Fiber.join(pending)); + yield* f.restore; + hold = false; + yield* admitted(credentials.get("github.com"), admission); + expect(commands).toBe(source === "gh" ? 2 : 0); + }).pipe(Effect.provide(layer)), +); + +it.effect.each(["grant", "root", "membership", "action"] as const)( + "GitHub identity lock: queued B %s cut cannot read A's cached identity", + (cut) => + Effect.gen(function* () { + const f = yield* fixture(); + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + const queued = yield* Deferred.make(); + let http = 0, + bChecks = 0; + const cli = yield* PullRequests.make.pipe( + Effect.provide( + Layer.mergeAll( + Layer.mock(GitHubApi.GitHubApi)({ + credential: () => + Effect.succeed({ + token: Redacted.make("fixture-token"), + fingerprint: "same-account", + }), + rest: () => + Effect.sync(() => { + http++; + }).pipe( + Effect.andThen(Deferred.succeed(started, undefined)), + Effect.andThen(Deferred.await(release)), + Effect.as({ + status: 200, + headers: {}, + body: '{"id":123,"login":"same-account"}', + truncated: false, + invalidUtf8: false, + }), + ), + }), + Layer.mock(VcsProcess.VcsProcess)({}), + ), + ), + ); + const a = yield* f.access.capture( + f.aAuthority, + { cwd: f.a, projectId: projectA }, + AuthOrchestrationReadScope, + ); + const rawB = yield* f.access.capture( + f.authority, + { cwd: f.b, projectId: projectB }, + AuthOrchestrationReadScope, + ); + const b = { + ...rawB, + beforeEffect: (cwd: string, scope?: AuthEnvironmentScope) => + rawB.beforeEffect(cwd, scope).pipe( + Effect.tap(() => { + // The second completed admission is after the credential, just before taking the lock. + bChecks++; + return bChecks === 2 ? Deferred.succeed(queued, undefined) : Effect.void; + }), + ), + }; + const first = yield* admitted( + cli.getRoutingIdentity({ cwd: f.a, host: "github.com" }), + a, + ).pipe(Effect.result, Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(started); + const second = yield* admitted( + cli.getRoutingIdentity({ cwd: f.b, host: "github.com" }), + b, + ).pipe(Effect.result, Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(queued); + yield* f.cut(cut); + yield* Deferred.succeed(release, undefined); + expect(Result.isSuccess(yield* Fiber.join(first))).toBe(true); + expectDenied(yield* Fiber.join(second)); + expect(http).toBe(1); + }).pipe(Effect.provide(layer)), +); + +it.effect.each([false, true])( + "Bitbucket revision cache: warm=%s cannot cross a current root cut", + (warm) => + Effect.gen(function* () { + const f = yield* fixture(); + const admission = yield* f.access.capture( + f.authority, + { cwd: f.b, projectId: projectB }, + AuthOrchestrationReadScope, + ); + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + let requests = 0, + hold = !warm; + const api = yield* BitbucketPR.make.pipe( + Effect.provide( + Layer.mock(Bitbucket.BitbucketApi)({ + request: () => + Effect.suspend(() => { + requests++; + return ( + hold + ? Deferred.succeed(started, undefined).pipe( + Effect.andThen(Deferred.await(release)), + ) + : Effect.void + ).pipe( + Effect.as({ + body: "diff --git a/a.ts b/a.ts\nindex abc123..def456 100644\n--- a/a.ts\n+++ b/a.ts\n@@ -1 +1 @@\n-old\n+new\n", + truncated: false, + }), + ); + }), + }), + ), + ); + const read = api.getFileRevisions({ repository: "acme/web", number: 7, paths: ["a.ts"] }); + if (warm) yield* admitted(read, admission); + const pending = yield* ( + warm + ? Deferred.succeed(started, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.andThen(admitted(read, admission)), + ) + : admitted(read, admission) + ).pipe(Effect.result, Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(started); + yield* f.cut("root"); + yield* Deferred.succeed(release, undefined); + expectDenied(yield* Fiber.join(pending)); + expect(requests).toBe(1); + yield* f.restore; + hold = false; + yield* admitted(read, admission); + expect(requests).toBe(warm ? 1 : 2); + }).pipe(Effect.provide(layer)), +); + +it.effect.each([true, false])( + "Forgejo: credential file exists=%s wait cut denies before reading tokens or trying fj/tea", + (exists) => + Effect.gen(function* () { + const f = yield* fixture(); + const admission = yield* f.access.capture( + f.authority, + { cwd: f.b, projectId: projectB }, + AuthOrchestrationReadScope, + ); + const fs = yield* FileSystem.FileSystem; + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + const effects: string[] = []; + const cli = yield* Forgejo.make.pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fs, + exists: () => + Deferred.succeed(started, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.as(exists), + ), + readFileString: () => + Effect.sync(() => { + effects.push("credential file"); + return "{}"; + }), + }), + Effect.provide( + Layer.mergeAll( + Layer.mock(VcsProcess.VcsProcess)({ + run: () => + Effect.sync(() => { + effects.push("process"); + return output(""); + }), + }), + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.sync(() => { + effects.push("HTTP"); + return HttpClientResponse.fromWeb(request, new Response("{}")); + }), + ), + ), + ), + ), + ); + const pending = yield* admitted(cli.listLogins!({ cwd: f.b, command: "fj" }), admission).pipe( + Effect.result, + Effect.forkChild({ startImmediately: true }), + ); + yield* Deferred.await(started); + yield* f.cut("membership"); + yield* Deferred.succeed(release, undefined); + expectDenied(yield* Fiber.join(pending)); + expect(effects).toEqual([]); + }).pipe(Effect.provide(layer)), +); + +it.effect( + "Forgejo: queued authLock waiter cannot read credential files or cached identity after a grant cut", + () => + Effect.gen(function* () { + const f = yield* fixture(); + const a = yield* f.access.capture( + f.aAuthority, + { cwd: f.a, projectId: projectA }, + AuthOrchestrationReadScope, + ); + const rawB = yield* f.access.capture( + f.authority, + { cwd: f.b, projectId: projectB }, + AuthOrchestrationReadScope, + ); + const fs = yield* FileSystem.FileSystem; + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + const bFile = yield* Deferred.make(); + let bChecks = 0; + const b = { + ...rawB, + beforeEffect: (cwd: string, scope?: AuthEnvironmentScope) => + rawB.beforeEffect(cwd, scope).pipe( + Effect.tap(() => { + // The file has returned and its final admission passed; the next wait is authLock. + bChecks++; + return bChecks === 4 ? Deferred.succeed(bFile, undefined) : Effect.void; + }), + ), + }; + const effects: string[] = []; + const cli = yield* Forgejo.make.pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fs, + exists: () => Effect.succeed(true), + readFileString: () => + Access.workspaceEffectAdmission.pipe( + Effect.flatMap((admission) => { + effects.push(`file:${admission!.projectId}`); + return Effect.succeed( + '{"hosts":{"forgejo.test":{"type":"Application","token":"fixture-token"}}}', + ); + }), + ), + }), + Effect.provide( + Layer.mergeAll( + Layer.mock(VcsProcess.VcsProcess)({ + run: () => + Effect.sync(() => { + effects.push("fj whoami"); + }).pipe( + Effect.andThen(Deferred.succeed(started, undefined)), + Effect.andThen(Deferred.await(release)), + Effect.as(output("")), + ), + }), + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.sync(() => { + effects.push("HTTP"); + return HttpClientResponse.fromWeb( + request, + new Response('{"login":"same-account"}'), + ); + }), + ), + ), + ), + ), + ); + const first = yield* admitted( + cli.getAccount!({ cwd: f.a, baseUrl: "https://forgejo.test" }), + a, + ).pipe(Effect.result, Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(started); + const second = yield* admitted( + cli.getAccount!({ cwd: f.b, baseUrl: "https://forgejo.test" }), + b, + ).pipe(Effect.result, Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(bFile); + yield* f.cut("grant"); + yield* Deferred.succeed(release, undefined); + expect(Result.isSuccess(yield* Fiber.join(first))).toBe(true); + expectDenied(yield* Fiber.join(second)); + expect(effects.filter((effect) => effect === `file:${projectB}`)).toHaveLength(1); + expect(effects.filter((effect) => effect === "fj whoami")).toHaveLength(1); + expect(effects.filter((effect) => effect === "HTTP")).toHaveLength(1); + }).pipe(Effect.provide(layer)), +); + +it.effect( + "GitHub conditional validators: a cut during REST cannot cache its answer or take the ordinary unsupported fallback", + () => + Effect.gen(function* () { + const f = yield* fixture(); + const admission = yield* f.access.capture( + f.authority, + { cwd: f.b, projectId: projectB }, + AuthOrchestrationReadScope, + ); + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + const requests: GitHubApi.GitHubRestInput[] = []; + let hold = true, + reads = 0; + const sha = "a".repeat(40); + const revalidate = yield* ConditionalChecks.makeChecksRevalidator.pipe( + Effect.provide( + Layer.mock(GitHubApi.GitHubApi)({ + rest: (input) => + Effect.suspend(() => { + requests.push(input); + return ( + hold + ? Deferred.succeed(started, undefined).pipe( + Effect.andThen(Deferred.await(release)), + ) + : Effect.void + ).pipe( + Effect.as({ + status: 200, + headers: { etag: "fixture-etag" }, + body: input.path.endsWith("/pulls/7") + ? JSON.stringify({ + head: { sha, repo: { id: 1 } }, + base: { repo: { id: 1 } }, + }) + : "{}", + truncated: false, + invalidUtf8: false, + }), + ); + }), + }), + ), + ); + const read = Effect.sync(() => { + reads++; + return { state: "open" as const, checks: [], headSha: sha, workflowApprovalsRequired: 0 }; + }); + const checked = admitted( + revalidate({ cwd: f.b, host: "github.com", repository: "acme/web", number: 7 }, read).pipe( + Effect.provideService(GitHubApi.PinnedGitHubCredential, { + host: "github.com", + token: Redacted.make("fixture-token"), + credentialFingerprint: "same-account", + }), + ), + admission, + ); + const pending = yield* checked.pipe( + Effect.result, + Effect.forkChild({ startImmediately: true }), + ); + yield* Deferred.await(started); + yield* f.cut("root"); + yield* Deferred.succeed(release, undefined); + expectDenied(yield* Fiber.join(pending)); + expect(requests).toHaveLength(1); + expect(reads).toBe(0); + yield* f.restore; + hold = false; + yield* checked; + expect(requests[1]!.ifNoneMatch).toBeUndefined(); + expect(reads).toBe(1); + }).pipe(Effect.provide(layer)), +); + +it.effect( + "GitHub summary: a typed transport denial escapes unchanged without an individual fallback", + () => + Effect.gen(function* () { + const denial = new EnvironmentAuthorizationError({ + requiredScope: AuthOrchestrationReadScope, + message: "fixture denial", + }); + let requests = 0; + const cli = yield* PullRequests.make.pipe( + Effect.provide( + Layer.mergeAll( + Layer.mock(GitHubApi.GitHubApi)({ + graphql: () => + Effect.sync(() => { + requests++; + }).pipe(Effect.andThen(Effect.fail(denial))), + }), + Layer.mock(VcsProcess.VcsProcess)({}), + ), + ), + ); + const pending = yield* cli + .getPullRequestSummary({ + cwd: "/fixture", + repository: "acme/web", + host: "github.com", + number: 7, + }) + .pipe(Effect.result, Effect.forkChild({ startImmediately: true })); + yield* TestClock.adjust("10 millis"); + const result = yield* Fiber.join(pending); + expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) expect(result.failure).toBe(denial); + expect(requests).toBe(1); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect.each(["success", "failure"] as const)( + "Bitbucket: Settings %s wait cut denies before resolving saved tokens or starting HTTP", + (outcome) => + Effect.gen(function* () { + const f = yield* fixture(); + const admission = yield* f.access.capture( + f.authority, + { cwd: f.b, projectId: projectB }, + AuthOrchestrationReadScope, + ); + const settings = yield* Settings.ServerSettingsService.pipe( + Effect.provide(Settings.ServerSettingsService.layerTest()), + ); + const snapshot = yield* settings.getSettings; + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + let tokens = 0, + http = 0; + const api = yield* Bitbucket.make.pipe( + Effect.provide( + Layer.mergeAll( + Layer.mock(Registry.VcsDriverRegistry)({}), + Layer.mock(Git.GitVcsDriver)({}), + Layer.succeed(Settings.ServerSettingsService, { + ...settings, + getSettings: Deferred.succeed(started, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.flatMap(() => + outcome === "failure" + ? Effect.fail( + new ServerSettingsError({ + settingsPath: "", + operation: "read-file", + cause: "fixture failure", + }), + ) + : Effect.sync(() => + Object.defineProperty({ ...snapshot }, "bitbucket", { + get: () => { + tokens++; + return snapshot.bitbucket; + }, + }), + ), + ), + ), + }), + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.sync(() => { + http++; + return HttpClientResponse.fromWeb(request, new Response("{}")); + }), + ), + ), + ), + ), + ); + const pending = yield* admitted(api.request({ method: "GET", url: "/user" }), admission).pipe( + Effect.result, + Effect.forkChild({ startImmediately: true }), + ); + yield* Deferred.await(started); + yield* f.cut("action"); + yield* Deferred.succeed(release, undefined); + expectDenied(yield* Fiber.join(pending)); + expect(tokens).toBe(0); + expect(http).toBe(0); + }).pipe(Effect.provide(layer)), +); + +it.effect.each(["gitlab", "azure-devops", "forgejo"] as const)( + "%s: lower process denial retains its exact typed error without another command", + (provider) => + Effect.gen(function* () { + const denial = new EnvironmentAuthorizationError({ + requiredScope: AuthOrchestrationReadScope, + message: "fixture denial", + }); + let commands = 0; + const process = Layer.mock(VcsProcess.VcsProcess)({ + run: () => + Effect.sync(() => { + commands++; + }).pipe(Effect.andThen(Effect.fail(denial))), + }); + if (provider === "gitlab") { + const cli = yield* GitLab.make.pipe(Effect.provide(process)); + expect( + yield* cli.execute({ cwd: "/fixture", args: ["api", "user"] }).pipe(Effect.flip), + ).toBe(denial); + } else if (provider === "azure-devops") { + const cli = yield* Azure.make.pipe(Effect.provide(process)); + expect( + yield* cli.execute({ cwd: "/fixture", args: ["repos", "pr", "show"] }).pipe(Effect.flip), + ).toBe(denial); + } else { + const cli = yield* Forgejo.make.pipe( + Effect.provide( + Layer.mergeAll( + process, + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("Unexpected HTTP")), + ), + ), + ), + ); + expect(yield* cli.resolveRepository({ cwd: "/fixture" }).pipe(Effect.flip)).toBe(denial); + const adapter = yield* ForgejoProvider.make.pipe( + Effect.provide(Layer.mock(Forgejo.ForgejoCli)({ api: () => Effect.fail(denial) })), + ); + expect( + yield* adapter.getChangeRequestSummary!({ + cwd: "/fixture", + host: "forgejo.test", + repository: "acme/web", + number: 7, + }).pipe(Effect.flip), + ).toBe(denial); + } + expect(commands).toBe(1); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect( + "GitHub identity: current root cut after user HTTP prevents a successful identity cache write", + () => + Effect.gen(function* () { + const f = yield* fixture(); + const admission = yield* f.access.capture( + f.authority, + { cwd: f.b, projectId: projectB }, + AuthOrchestrationReadScope, + ); + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + let requests = 0, + hold = true; + const cli = yield* PullRequests.make.pipe( + Effect.provide( + Layer.mergeAll( + Layer.mock(GitHubApi.GitHubApi)({ + credential: () => + Effect.succeed({ + token: Redacted.make("fixture-token"), + fingerprint: "same-account", + }), + rest: () => + Effect.suspend(() => { + requests++; + return ( + hold + ? Deferred.succeed(started, undefined).pipe( + Effect.andThen(Deferred.await(release)), + ) + : Effect.void + ).pipe( + Effect.as({ + status: 200, + headers: {}, + body: '{"id":123,"login":"same-account"}', + truncated: false, + invalidUtf8: false, + }), + ); + }), + }), + Layer.mock(VcsProcess.VcsProcess)({}), + ), + ), + ); + const read = admitted(cli.getRoutingIdentity({ cwd: f.b, host: "github.com" }), admission); + const pending = yield* read.pipe(Effect.result, Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(started); + yield* f.cut("root"); + yield* Deferred.succeed(release, undefined); + expectDenied(yield* Fiber.join(pending)); + yield* f.restore; + hold = false; + yield* read; + expect(requests).toBe(2); + }).pipe(Effect.provide(layer)), +); diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index a7aa3211ab2c..7c93662a97aa 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -1,4 +1,5 @@ import * as SqlClient from "effect/sql/SqlClient"; +import * as WorkspaceAccess from "../auth/workspaceReadAccess.ts"; import * as FileSystem from "effect/FileSystem"; import * as Context from "effect/Context"; import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; @@ -608,6 +609,7 @@ function fakeProvider( function makeService(input: { readonly settings?: ServerSettings; + readonly realPath?: FileSystem.FileSystem["realPath"]; readonly setup?: ( sql: SqlClient.SqlClient, ) => Effect.Effect; @@ -655,7 +657,7 @@ function makeService(input: { SqlitePersistence.layerMemory, Layer.succeed( FileSystem.FileSystem, - FileSystem.makeNoop({ realPath: (path) => Effect.succeed(path) }), + FileSystem.makeNoop({ realPath: input.realPath ?? ((path) => Effect.succeed(path)) }), ), // The real store over a database of its own, so the environment-kept marks are exercised // through the SQL that holds them rather than through a stand-in that agrees with itself. @@ -7715,7 +7717,7 @@ it.effect.each([ }), ); -const assertDenied = (operation: Effect.Effect) => +const assertDenied = (operation: Effect.Effect) => operation.pipe( Effect.result, Effect.map((result) => { @@ -8185,3 +8187,234 @@ it.effect( yield* assertDenied(service.subscribeStateChanges(authority)); }), ); + +it.effect.each( + (["list", "listStats"] as const).flatMap((method) => + (["grant", "root", "membership", "action"] as const).map((cut) => ({ method, cut })), + ), +)( + "$method retains B participant admission after the selected A adapter waits ($cut)", + ({ method, cut }) => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + let database!: SqlClient.SqlClient; + let resourceGrant: AuthResourceGrant = { + ...grantA, + projects: { mode: "explicit", ids: [projectA, projectB] }, + }; + let scopes: ReadonlyArray = [ + AuthOrchestrationReadScope, + ]; + const authority: ThreadReadAuthority = { + current: Effect.sync(() => ({ resourceGrant, scopes })), + }; + const effects: string[] = []; + const finalEffect = Deferred.succeed(started, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.andThen(WorkspaceAccess.beforeCurrentWorkspaceEffect), + ); + const service = yield* makeService({ + projects: [ + project({ id: projectA, title: "a", workspaceRoot: "/a", repository: "acme/a" }), + project({ id: projectB, title: "b", workspaceRoot: "/b", repository: "acme/b" }), + ], + setup: (sql) => + Effect.sync(() => { + database = sql; + }), + providers: [ + fakeProvider("github", { + getViewer: () => Effect.succeed("viewer"), + listChangeRequestsAcross: ({ repositories }) => + finalEffect.pipe( + Effect.andThen( + Effect.sync(() => { + effects.push(...repositories); + return { items: [], truncated: false, continues: false }; + }), + ), + ), + listChangeRequestStats: ({ changeRequests }) => + finalEffect.pipe( + Effect.andThen( + Effect.sync(() => { + effects.push(...changeRequests.map((ref) => ref.repository)); + return []; + }), + ), + ), + listChangeRequests: () => + Effect.sync(() => { + effects.push("fallback"); + return { items: [], truncated: false, continues: false }; + }), + }), + ], + }); + const read = + method === "list" + ? service.list(authority, { state: "open" }).pipe(Effect.asVoid) + : service + .listStats(authority, { + refs: [ + { projectId: projectA, repository: "acme/a", host: "github.com", number: 1 }, + { projectId: projectB, repository: "acme/b", host: "github.com", number: 2 }, + ], + }) + .pipe(Effect.asVoid); + const pending = yield* assertDenied(read).pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(started); + if (cut === "root") + yield* database`UPDATE projection_projects SET workspace_root = '/moved' WHERE project_id = ${projectB}`; + else if (cut === "membership") + yield* database`UPDATE projection_projects SET deleted_at = '2026-10-11T00:00:00Z' WHERE project_id = ${projectB}`; + else if (cut === "grant") resourceGrant = grantA; + else scopes = []; + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(pending); + assert.deepEqual(effects, []); + }), +); + +it.effect( + "external stale diff refresh keeps the invoking authority and denies a cut before its adapter effect", + () => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + const drained = yield* Deferred.make(); + let grant = grantA, + reads = 0, + effects = 0; + const authority: ThreadReadAuthority = { + current: Effect.sync(() => ({ + resourceGrant: grant, + scopes: [AuthOrchestrationReadScope], + })), + }; + const service = yield* makeService({ + projects: [ + project({ id: projectA, title: "a", workspaceRoot: "/a", repository: "acme/a" }), + ], + readCache: { get: (_key, read) => read, invalidate: () => Effect.void }, + providers: [ + fakeProvider("github", { + getDiff: () => + Effect.gen(function* () { + const admission = yield* WorkspaceAccess.workspaceEffectAdmission; + assert.strictEqual(admission?.authority, authority); + assert.strictEqual(admission?.scope, AuthOrchestrationReadScope); + if (++reads === 2) { + yield* Deferred.succeed(started, undefined); + yield* Deferred.await(release); + } + yield* WorkspaceAccess.beforeCurrentWorkspaceEffect; + effects++; + return { patch: "old patch", truncated: false, nextCursor: null }; + }).pipe( + Effect.onExit(() => + reads === 2 ? Deferred.succeed(drained, undefined) : Effect.void, + ), + ), + }), + ], + }); + const input = { projectId: projectA, host: "github.com", repository: "acme/a", number: 1 }; + assert.equal((yield* service.diff(authority, input)).patch, "old patch"); + yield* TestClock.adjust("61 seconds"); + assert.equal((yield* service.diff(authority, input)).patch, "old patch"); + yield* Deferred.await(started); + grant = grantNone; + yield* Deferred.succeed(release, undefined); + yield* Deferred.await(drained); + assert.equal(reads, 2); + assert.equal(effects, 1); + }), +); + +it.effect.each(["list", "listStats"] as const)( + "%s denies B owner cut during the selected A adapter's last canonical await", + (method) => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const enter = yield* Deferred.make(); + const canonical = yield* Deferred.make(); + const release = yield* Deferred.make(); + let database!: SqlClient.SqlClient; + let armed = false, + checkedB = false, + canonicalCalls = 0, + held = false, + effects = 0; + const grant: AuthResourceGrant = { + ...grantA, + projects: { mode: "explicit", ids: [projectA, projectB] }, + }; + const effect = Deferred.succeed(started, undefined).pipe( + Effect.andThen(Deferred.await(enter)), + Effect.andThen(WorkspaceAccess.beforeCurrentWorkspaceEffect), + Effect.tap(() => + Effect.sync(() => { + effects++; + }), + ), + ); + const service = yield* makeService({ + projects: [ + project({ id: projectA, title: "a", workspaceRoot: "/a", repository: "acme/a" }), + project({ id: projectB, title: "b", workspaceRoot: "/b", repository: "acme/b" }), + ], + setup: (sql) => + Effect.sync(() => { + database = sql; + }), + realPath: (path) => + Effect.suspend(() => { + // Each canonical admission reads the requested path, then the two registered roots. + const requested = armed && canonicalCalls++ % 3 === 0; + if (requested && path === "/b") checkedB = true; + if (requested && checkedB && path === "/a" && !held) { + held = true; + return Deferred.succeed(canonical, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.as(path), + ); + } + return Effect.succeed(path); + }), + providers: [ + fakeProvider("github", { + listChangeRequestsAcross: () => + effect.pipe(Effect.as({ items: [], truncated: false, continues: false })), + listChangeRequestStats: () => effect.pipe(Effect.as([])), + listChangeRequests: () => + Effect.sync(() => { + effects++; + return { items: [], truncated: false, continues: false }; + }), + }), + ], + }); + const read = + method === "list" + ? service.list(testAuthority(grant), { state: "open" }).pipe(Effect.asVoid) + : service + .listStats(testAuthority(grant), { + refs: [ + { projectId: projectA, repository: "acme/a", host: "github.com", number: 1 }, + { projectId: projectB, repository: "acme/b", host: "github.com", number: 2 }, + ], + }) + .pipe(Effect.asVoid); + const pending = yield* assertDenied(read).pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(started); + armed = true; + yield* Deferred.succeed(enter, undefined); + yield* Deferred.await(canonical); + yield* database`UPDATE projection_projects SET workspace_root = '/moved' WHERE project_id = ${projectB}`; + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(pending); + assert.equal(effects, 0); + }), +); diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 21db696ffdd0..7215b822a689 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -407,6 +407,9 @@ const requestAccess = Context.Reference< readonly authority: ThreadReadAuthority; readonly scope: AuthEnvironmentScope; readonly admissions: Map; + readonly checkSql: ( + inherited?: Effect.Effect, + ) => Effect.Effect; } | undefined >("t3/PullRequestService/access", { defaultValue: () => undefined }); @@ -433,7 +436,7 @@ const checkAccess = Effect.gen(function* () { const access = yield* currentAccess; for (const admission of access.admissions.values()) yield* admission.beforeEffect(admission.input.cwd); - yield* currentAccess; + yield* access.checkSql(); }); const firstAdmittedSuccess = ( @@ -675,6 +678,32 @@ function withRateLimitBackoff( }, ): PullRequestProviderApi { const key = { provider: api.kind, host }; + const withAdmission = (effect: Effect.Effect) => + options?.admission === undefined + ? effect + : Effect.gen(function* () { + const inherited = yield* WorkspaceAccess.workspaceEffectAdmission; + const admission = options.admission!; + yield* checkAccess; + const access = yield* currentAccess; + return yield* effect.pipe( + Effect.provideService(WorkspaceAccess.workspaceEffectAdmission, { + ...admission, + // Host documents may name several projects. Recheck every captured participant + // after adapter waits, not only the checkout that supplied the API/credential. + beforeEffect: (cwd, scope) => + checkAccess.pipe( + Effect.andThen( + inherited === undefined + ? Effect.void + : inherited.beforeEffect(inherited.input.cwd), + ), + Effect.andThen(admission.beforeEffect(cwd, scope)), + Effect.andThen(access.checkSql(inherited?.checkSql)), + ), + }), + ); + }); const protect = ( operation: string, effect: Effect.Effect, @@ -696,24 +725,7 @@ function withRateLimitBackoff( }), ), Effect.flatMap((lease) => - (options?.admission === undefined || !admit - ? effect - : Effect.gen(function* () { - const inherited = yield* WorkspaceAccess.workspaceEffectAdmission; - if (inherited !== undefined) yield* inherited.beforeEffect(inherited.input.cwd); - yield* options.admission!.beforeEffect(options.admission!.input.cwd); - return yield* effect.pipe( - Effect.provideService(WorkspaceAccess.workspaceEffectAdmission, { - ...options.admission!, - beforeEffect: (cwd, scope) => - (inherited === undefined - ? Effect.void - : inherited.beforeEffect(inherited.input.cwd) - ).pipe(Effect.andThen(options.admission!.beforeEffect(cwd, scope))), - }), - ); - }) - ).pipe( + (admit ? withAdmission(effect) : effect).pipe( Effect.provideService(AllowGitHubReserve, allowPaused), Effect.tap(() => (admit ? checkAccess : Effect.void)), Effect.tap(() => limits.recordSuccess({ ...key, lease })), @@ -772,7 +784,10 @@ function withRateLimitBackoff( readonly viewer: string; readonly credentialFingerprint: string; }) => Effect.Effect, - ) => checkAccess.pipe(Effect.andThen(api.withVerifiedCredential!(input, use))), + ) => + checkAccess.pipe( + Effect.andThen(withAdmission(api.withVerifiedCredential!(input, use))), + ), }), listChangeRequests: wrap("listChangeRequests", api.listChangeRequests), ...(api.listChangeRequestsAcross === undefined @@ -881,14 +896,31 @@ export const make = Effect.gen(function* () { scope: AuthEnvironmentScope, operation: Effect.Effect, ) => - Effect.suspend(() => - checkAccess.pipe( + Effect.suspend(() => { + const admissions = new Map(); + const checkSql = (inherited?: Effect.Effect) => { + const checked = Effect.gen(function* () { + yield* inherited ?? Effect.void; + for (const admission of admissions.values()) yield* admission.checkSql; + yield* currentAccess; + }); + // Canonical preflight stays outside SQL. One final snapshot checks every participant + // after the last preflight await, including the selected adapter's own checkout. + return authority === trustedReadAuthority || admissions.size === 0 + ? checked + : sql + .withTransaction(checked) + .pipe( + Effect.mapError((error) => (isAuthorizationError(error) ? error : denied(scope))), + ); + }; + return checkAccess.pipe( Effect.andThen(operation), Effect.tap(() => checkAccess), Effect.tapError(() => checkAccess), - Effect.provideService(requestAccess, { authority, scope, admissions: new Map() }), - ), - ); + Effect.provideService(requestAccess, { authority, scope, admissions, checkSql }), + ); + }); const captureProject = Effect.fnUntraced(function* (projectId: ProjectId, cwd: string) { yield* checkAccess; const access = yield* currentAccess; diff --git a/apps/server/src/pullRequest/gitHubConditionalChecks.ts b/apps/server/src/pullRequest/gitHubConditionalChecks.ts index 6975da251936..c18acb18d7d4 100644 --- a/apps/server/src/pullRequest/gitHubConditionalChecks.ts +++ b/apps/server/src/pullRequest/gitHubConditionalChecks.ts @@ -1,3 +1,4 @@ +import { beforeCurrentWorkspaceEffect } from "../auth/workspaceReadAccess.ts"; import * as Clock from "effect/Clock"; import * as Cache from "effect/Cache"; import * as Context from "effect/Context"; @@ -77,9 +78,11 @@ export const makeChecksRevalidator = Effect.gen(function* () { const credential = yield* GitHubApi.PinnedGitHubCredential; if (credential === null) return yield* read; const key = `${credential.credentialFingerprint}\0${input.host}\0${input.repository}\0${input.number}`; + yield* beforeCurrentWorkspaceEffect; const entry = yield* Cache.get(entries, key); return yield* entry.gate.withPermit( Effect.gen(function* () { + yield* beforeCurrentWorkspaceEffect; if (!entry.supported) return yield* read; const fail = (status: number) => new GitHubApi.GitHubApiResponseError({ @@ -90,6 +93,7 @@ export const makeChecksRevalidator = Effect.gen(function* () { /** Whether the endpoint has a next page, or null where the host cannot say. */ const get = (endpoint: string, head = false) => Effect.gen(function* () { + yield* beforeCurrentWorkspaceEffect; const previous = entry.validators.get(endpoint); const response = yield* api .rest({ @@ -101,17 +105,26 @@ export const makeChecksRevalidator = Effect.gen(function* () { .pipe( Effect.catchTags({ GitHubApiNotFoundError: () => - Effect.sync(() => { - entry.supported = false; - return null; - }), + beforeCurrentWorkspaceEffect.pipe( + Effect.andThen( + Effect.sync(() => { + entry.supported = false; + return null; + }), + ), + ), GitHubApiResponseError: (error) => - Effect.sync(() => { - entry.supported = !UNSUPPORTED_STATUSES.has(error.status); - return null; - }), + beforeCurrentWorkspaceEffect.pipe( + Effect.andThen( + Effect.sync(() => { + entry.supported = !UNSUPPORTED_STATUSES.has(error.status); + return null; + }), + ), + ), }), ); + yield* beforeCurrentWorkspaceEffect; if (response === null) { entry.value = null; return null; @@ -125,6 +138,7 @@ export const makeChecksRevalidator = Effect.gen(function* () { const decoded = yield* decodeHead(response.body).pipe( Effect.mapError(() => fail(response.status)), ); + yield* beforeCurrentWorkspaceEffect; if (entry.head?.head.sha !== decoded.head.sha) entry.validators.clear(); entry.head = decoded; } @@ -161,6 +175,7 @@ export const makeChecksRevalidator = Effect.gen(function* () { } } const now = yield* Clock.currentTimeMillis; + yield* beforeCurrentWorkspaceEffect; if (entry.value !== null && now - entry.readAt < 5 * 60_000) return entry.value; // Every page of the head's runs was just confirmed current, so the read filters them // rather than listing the runs a second time. @@ -177,6 +192,7 @@ export const makeChecksRevalidator = Effect.gen(function* () { } : null; const fresh = yield* read.pipe(Effect.provideService(KnownWorkflowRuns, known)); + yield* beforeCurrentWorkspaceEffect; const value = { state: fresh.state, checks: fresh.checks, diff --git a/apps/server/src/sourceControl/BitbucketApi.test.ts b/apps/server/src/sourceControl/BitbucketApi.test.ts index d5d9296ac997..70861df7b284 100644 --- a/apps/server/src/sourceControl/BitbucketApi.test.ts +++ b/apps/server/src/sourceControl/BitbucketApi.test.ts @@ -1106,7 +1106,6 @@ it.effect.each(["request", "redirect", "probeAuth"] as const)( message: "denied", }); let revoked = action !== "redirect"; - let checks = 0; const { layer, execute } = makeLayer({ response: () => { revoked = true; @@ -1121,7 +1120,6 @@ it.effect.each(["request", "redirect", "probeAuth"] as const)( : api.request({ method: "GET", url: "/diff" }).pipe(Effect.asVoid) ).pipe(Effect.flip); assert.strictEqual(execute.mock.calls.length, action === "redirect" ? 1 : 0); - assert.strictEqual(checks, action === "redirect" ? 2 : 1); assert.strictEqual(error, denial); }).pipe( Effect.provideService(workspaceEffectAdmission, { @@ -1132,7 +1130,6 @@ it.effect.each(["request", "redirect", "probeAuth"] as const)( checkSql: Effect.void, beforeEffect: () => Effect.suspend(() => { - checks++; return revoked ? Effect.fail(denial) : Effect.void; }), }), diff --git a/apps/server/src/sourceControl/BitbucketApi.ts b/apps/server/src/sourceControl/BitbucketApi.ts index 26760efed8a6..d76c9d58f335 100644 --- a/apps/server/src/sourceControl/BitbucketApi.ts +++ b/apps/server/src/sourceControl/BitbucketApi.ts @@ -671,13 +671,20 @@ export const make = Effect.gen(function* () { const apiUrl = (path: string) => `${config.baseUrl.replace(/\/+$/u, "")}${path}`; // Read on every request so credentials saved in settings apply without a restart. - const currentCredential = serverSettings.getSettings.pipe( + const currentCredential = beforeCurrentWorkspaceEffect.pipe( + Effect.andThen(serverSettings.getSettings), + Effect.tap(() => beforeCurrentWorkspaceEffect), Effect.map((settings) => resolveCredential(settings.bitbucket, config)), Effect.catch((error) => - // No cause: a settings decode error can quote a hand-edited token. - Effect.logWarning("failed to read Bitbucket credentials from settings", { - operation: error.operation, - }).pipe(Effect.as(resolveCredential(DEFAULT_SERVER_SETTINGS.bitbucket, config))), + error._tag === "EnvironmentAuthorizationError" + ? Effect.fail(error) + : // No cause: a settings decode error can quote a hand-edited token. + Effect.logWarning("failed to read Bitbucket credentials from settings", { + operation: error.operation, + }).pipe( + Effect.andThen(beforeCurrentWorkspaceEffect), + Effect.map(() => resolveCredential(DEFAULT_SERVER_SETTINGS.bitbucket, config)), + ), ), ); diff --git a/apps/server/src/sourceControl/ForgejoCli.ts b/apps/server/src/sourceControl/ForgejoCli.ts index 556b1488ba4f..1c797619b528 100644 --- a/apps/server/src/sourceControl/ForgejoCli.ts +++ b/apps/server/src/sourceControl/ForgejoCli.ts @@ -260,6 +260,7 @@ export const make = Effect.gen(function* () { ); const readKeys = Effect.fn("ForgejoCli.readKeys")(function* (cwd: string) { + yield* beforeCurrentWorkspaceEffect; for (const path of forgejoKeysPaths({ platform: yield* HostProcessPlatform, home: NodeOS.homedir(), @@ -268,6 +269,7 @@ export const make = Effect.gen(function* () { : {}), ...(globalThis.process.env.APPDATA ? { appData: globalThis.process.env.APPDATA } : {}), })) { + yield* beforeCurrentWorkspaceEffect; const exists = yield* fileSystem.exists(path).pipe( Effect.mapError( () => @@ -279,6 +281,7 @@ export const make = Effect.gen(function* () { }), ), ); + yield* beforeCurrentWorkspaceEffect; if (!exists) continue; const raw = yield* fileSystem.readFileString(path).pipe( Effect.mapError( @@ -291,6 +294,7 @@ export const make = Effect.gen(function* () { }), ), ); + yield* beforeCurrentWorkspaceEffect; const decoded = parseForgejoKeys(raw); if (Result.isFailure(decoded)) return yield* new ForgejoCliError({ @@ -333,6 +337,7 @@ export const make = Effect.gen(function* () { if (input.command === "fj") { const keys = yield* readKeys(input.cwd).pipe(Effect.result); if (Result.isFailure(keys)) { + if (keys.failure._tag === "EnvironmentAuthorizationError") return yield* keys.failure; // Stale credentials from an uninstalled fj must not disable an available tea login. const available = yield* execute({ command: "fj", cwd: input.cwd, args: ["version"] }).pipe( Effect.result, @@ -456,6 +461,7 @@ export const make = Effect.gen(function* () { const keys = yield* readKeys(cwd); const token = keys.hosts[login.name]?.token; const now = yield* Clock.currentTimeMillis; + yield* beforeCurrentWorkspaceEffect; const cached = authenticated.get(login.url); if (token && cached?.token === token && now - cached.time < 30_000) return token; yield* execute({ command: "fj", cwd, args: ["--host", login.url, "whoami"] }); @@ -468,6 +474,7 @@ export const make = Effect.gen(function* () { reason: "authentication", detail: "fj has no credentials for this server. Authenticate again with fj.", }); + yield* beforeCurrentWorkspaceEffect; authenticated.set(login.url, { token: refreshed, time: now }); return refreshed; }, authLock.withPermits(1)); @@ -526,14 +533,15 @@ export const make = Effect.gen(function* () { allowNonZeroExit: true, }) .pipe( - Effect.mapError( - (cause) => - new ForgejoCliError({ - command: "tea", - cwd: input.cwd, - detail: "Could not resolve the Forgejo repository remote.", - cause, - }), + Effect.mapError((cause) => + cause._tag === "EnvironmentAuthorizationError" + ? cause + : new ForgejoCliError({ + command: "tea", + cwd: input.cwd, + detail: "Could not resolve the Forgejo repository remote.", + cause, + }), ), ); if (input.host) { diff --git a/apps/server/src/sourceControl/GitHubApi.ts b/apps/server/src/sourceControl/GitHubApi.ts index f3d42470751a..7801ebba7f67 100644 --- a/apps/server/src/sourceControl/GitHubApi.ts +++ b/apps/server/src/sourceControl/GitHubApi.ts @@ -353,6 +353,7 @@ export const make = Effect.gen(function* () { const credential: GitHubApi["Service"]["credential"] = Effect.fn("GitHubApi.credential")( function* (host) { + yield* beforeCurrentWorkspaceEffect; const normalized = normalizeHost(host); const pinned = yield* PinnedGitHubCredential; if (pinned !== null) { @@ -366,6 +367,7 @@ export const make = Effect.gen(function* () { return { token: pinned.token, fingerprint: pinned.credentialFingerprint }; } const held = yield* credentials.get(normalized); + yield* beforeCurrentWorkspaceEffect; return { token: held.token, fingerprint: held.fingerprint }; }, ); diff --git a/apps/server/src/sourceControl/GitHubCredentials.ts b/apps/server/src/sourceControl/GitHubCredentials.ts index 2c298d56d03e..1d06b44a6f83 100644 --- a/apps/server/src/sourceControl/GitHubCredentials.ts +++ b/apps/server/src/sourceControl/GitHubCredentials.ts @@ -1,3 +1,7 @@ +import { + beforeCurrentWorkspaceEffect, + recoverWorkspaceError, +} from "../auth/workspaceReadAccess.ts"; import type { EnvironmentAuthorizationError } from "@t3tools/contracts"; import * as Cache from "effect/Cache"; import * as Context from "effect/Context"; @@ -94,7 +98,7 @@ export class GitHubCredentials extends Context.Service< host: string, ) => Effect.Effect; /** Drops the held token after GitHub refused it, so the next read asks its source again. */ - readonly invalidate: (host: string) => Effect.Effect; + readonly invalidate: (host: string) => Effect.Effect; } >()("t3/sourceControl/GitHubCredentials") {} @@ -186,16 +190,20 @@ export const make = Effect.gen(function* () { /** The Settings choice for a host; unreadable settings fall back to gh's own choice. */ const hostChoice = (host: string) => - serverSettings.getSettings.pipe( + beforeCurrentWorkspaceEffect.pipe( + Effect.andThen(serverSettings.getSettings), + Effect.tap(() => beforeCurrentWorkspaceEffect), Effect.map((settings) => settings.github.hosts[host]), - Effect.orElseSucceed(() => undefined), + recoverWorkspaceError(() => undefined), ); /** A token saved in Settings for the host, read fresh so a saved or removed one applies at once. */ const savedToken = (host: string) => - serverSettings.getSettings.pipe( + beforeCurrentWorkspaceEffect.pipe( + Effect.andThen(serverSettings.getSettings), + Effect.tap(() => beforeCurrentWorkspaceEffect), Effect.map((settings) => settings.github.tokens[host]?.trim() || null), - Effect.orElseSucceed(() => null), + recoverWorkspaceError(() => null), ); /** Cache key: the host plus its pinned account, so a changed pin misses the cache. */ @@ -203,6 +211,7 @@ export const make = Effect.gen(function* () { account === undefined ? host : `${host}\u0000${account}`; const lookup = Effect.fn("GitHubCredentials.lookup")(function* (key: string) { + yield* beforeCurrentWorkspaceEffect; const [host = key, choice] = key.split("\u0000"); // An environment token wins over a pinned account, exactly as it does in gh. const fromEnv = environmentToken(host, environment); @@ -216,11 +225,14 @@ export const make = Effect.gen(function* () { choice === undefined ? Effect.fail(error) : fromGh(host, undefined), }), )); + yield* beforeCurrentWorkspaceEffect; + const fingerprint = yield* fingerprintOf(host, token); + yield* beforeCurrentWorkspaceEffect; return { host, token: Redacted.make(token), source: fromEnv !== null ? "env" : "gh", - fingerprint: yield* fingerprintOf(host, token), + fingerprint, } satisfies GitHubCredential; }); @@ -252,19 +264,28 @@ export const make = Effect.gen(function* () { // environment and gh. It is read from the secret store each time, so it needs no cache. const saved = yield* savedToken(host); if (saved !== null) { + const fingerprint = yield* fingerprintOf(host, saved); + yield* beforeCurrentWorkspaceEffect; return { host, token: Redacted.make(saved), source: "settings", - fingerprint: yield* fingerprintOf(host, saved), + fingerprint, } satisfies GitHubCredential; } - return yield* Cache.get(cache, cacheKey(host, choice?.account)); + yield* beforeCurrentWorkspaceEffect; + return yield* Cache.get(cache, cacheKey(host, choice?.account)).pipe( + Effect.tap(() => beforeCurrentWorkspaceEffect), + ); }), invalidate: (rawHost) => { const host = normalizeHost(rawHost); return hostChoice(host).pipe( - Effect.flatMap((choice) => Cache.invalidate(cache, cacheKey(host, choice?.account))), + Effect.flatMap((choice) => + beforeCurrentWorkspaceEffect.pipe( + Effect.andThen(Cache.invalidate(cache, cacheKey(host, choice?.account))), + ), + ), ); }, }); diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index 8217292d6a33..ce56fc90e121 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -1,4 +1,9 @@ -import { recoverWorkspaceError } from "../auth/workspaceReadAccess.ts"; +import { trustedReadAuthority } from "../auth/ResourceAuthorization.ts"; +import { + beforeCurrentWorkspaceEffect, + workspaceEffectAdmission, + recoverWorkspaceError, +} from "../auth/workspaceReadAccess.ts"; import { EnvironmentAuthorizationError } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Data from "effect/Data"; @@ -205,7 +210,9 @@ export const makeDiscovery = Effect.gen(function* () { label: discovery.label, installHint: discovery.installHint, probe: Effect.fn("GitHubSourceControlProvider.discovery")(function* (cwd: string) { - const settings = yield* serverSettings.getSettings.pipe( + const settings = yield* beforeCurrentWorkspaceEffect.pipe( + Effect.andThen(serverSettings.getSettings), + Effect.tap(() => beforeCurrentWorkspaceEffect), Effect.map((current) => current.github), recoverWorkspaceError(() => DEFAULT_SERVER_SETTINGS.github), ); @@ -215,6 +222,7 @@ export const makeDiscovery = Effect.gen(function* () { spec: { ...discovery, parseAuth: (input) => parseGitHubAuth(input, settings) }, }); // A token saved in Settings wins over the environment, which wins over gh. + yield* beforeCurrentWorkspaceEffect; const savedToken = (settings.tokens["github.com"] ?? "").trim() !== ""; const variable = environmentTokenVariable(environment); const tokenSource = savedToken ? "the token saved in Settings" : variable; @@ -522,9 +530,15 @@ export const make = Effect.gen(function* () { const rest = (input: GitHubApi.GitHubRestInput, notFoundDetail?: string) => api.rest(input).pipe(Effect.mapError((error) => fromGitHubApiError(error, notFoundDetail))); - const headResolver = RequestResolver.makeGrouped({ - key: ({ request, context }) => - [ + // Pending keys isolate external entries without interning and retaining every caller. + const headResolver = RequestResolver.makeWith({ + delay: Effect.yieldNow, + collectWhile: () => true, + batchKey: (entry) => { + const { request, context } = entry; + const admission = Context.getOrElse(context, workspaceEffectAdmission, () => undefined); + if (admission !== undefined && admission.authority !== trustedReadAuthority) return entry; + return [ request.host, request.owner, request.name, @@ -532,44 +546,48 @@ export const make = Effect.gen(function* () { Context.getOrElse(context, GitHubApi.PinnedGitHubCredential, () => null) ?.credentialFingerprint ?? "", Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""), - ].join("\0"), - resolver: (entries) => { + ].join("\0"); + }, + runAll: (entries) => { const [first] = entries; - const { host, owner, name, allowReserve } = first.request; - const query = buildPullRequestsByHeadQuery(entries.map((entry) => entry.request)); - return graphqlJson( - { - host, - operation: "listPullRequestsByHead", - query: query.document, - variables: { owner, name, ...query.variables }, - allowReserve, - // Up to 50 heads of 100 rows each. A default branch such as `main` can match a hundred - // fork pull requests, so the usual cap would cut the answer short. - maxResponseBytes: HEAD_LOOKUP_MAX_RESPONSE_BYTES, - }, - decodePullRequestsByHead, - "GitHub returned an invalid change request list.", - ).pipe( - Effect.flatMap((decoded) => - decoded.data.repository === null - ? Effect.fail(notFound("The repository could not be read.")) - : Effect.succeed(decoded.data.repository), - ), - Effect.map((aliases) => { - for (const [index, entry] of entries.entries()) { - const alias = aliases[`h${index}`]; - entry.completeUnsafe( - Exit.succeed(alias == null ? [] : decodeGitHubPullRequestEntries(alias.nodes)), - ); - } - }), - Effect.catchCause((cause) => - Effect.sync(() => { - for (const entry of entries) entry.completeUnsafe(Exit.failCause(cause)); + return Effect.gen(function* () { + yield* beforeCurrentWorkspaceEffect; + const { host, owner, name, allowReserve } = first.request; + const query = buildPullRequestsByHeadQuery(entries.map((entry) => entry.request)); + return yield* graphqlJson( + { + host, + operation: "listPullRequestsByHead", + query: query.document, + variables: { owner, name, ...query.variables }, + allowReserve, + // Up to 50 heads of 100 rows each. A default branch such as `main` can match a hundred + // fork pull requests, so the usual cap would cut the answer short. + maxResponseBytes: HEAD_LOOKUP_MAX_RESPONSE_BYTES, + }, + decodePullRequestsByHead, + "GitHub returned an invalid change request list.", + ).pipe( + Effect.flatMap((decoded) => + decoded.data.repository === null + ? Effect.fail(notFound("The repository could not be read.")) + : Effect.succeed(decoded.data.repository), + ), + Effect.map((aliases) => { + for (const [index, entry] of entries.entries()) { + const alias = aliases[`h${index}`]; + entry.completeUnsafe( + Exit.succeed(alias == null ? [] : decodeGitHubPullRequestEntries(alias.nodes)), + ); + } }), - ), - ); + Effect.catchCause((cause) => + Effect.sync(() => { + for (const entry of entries) entry.completeUnsafe(Exit.failCause(cause)); + }), + ), + ); + }).pipe(Effect.provideContext(first.context)); }, }).pipe(RequestResolver.batchN(HEAD_LOOKUPS_PER_DOCUMENT)); const interactiveHeadResolver = headResolver.pipe(