Skip to content
99 changes: 88 additions & 11 deletions apps/server/src/assets/AssetAccess.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform";
import * as NodeFSP from "node:fs/promises";
import {
AssetAccessError,
AssetResource,
AssetPreviewTypeValidationError,
ThreadId,
TurnItemId,
Expand All @@ -21,13 +22,16 @@ import * as Path from "effect/Path";
import * as PlatformError from "effect/PlatformError";
import * as Schema from "effect/Schema";
import * as TestClock from "effect/testing/TestClock";
import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
import { HttpClient, HttpClientResponse, HttpServerResponse } from "effect/http";
import { vi } from "vite-plus/test";

import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
import * as ServerConfig from "../config.ts";
import * as Orchestrator from "../orchestration-v2/Orchestrator.ts";
import * as ProcessRunner from "../processRunner.ts";
import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";
import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts";
import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts";
import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
import { assetFileResponse } from "../http.ts";
Expand All @@ -47,6 +51,7 @@ vi.mock("node:fs/promises", async (importOriginal) => {
const layerConfig = ServerConfig.ServerConfig.layerTest(process.cwd(), {
prefix: "t3-asset-access-test-",
});
const decodeAssetResource = Schema.decodeUnknownSync(AssetResource);
const screenshotPng = new Uint8Array(24);
screenshotPng.set([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52,
Expand Down Expand Up @@ -195,17 +200,22 @@ describe("AssetAccess", () => {
expect(authorizations).toEqual([undefined, "Bearer signed-in", "Bearer signed-in"]);
}).pipe(
Effect.provide(
Layer.mock(GitHubCredentials.GitHubCredentials)({
get: (host) =>
++lookups === 1
? Effect.fail(new GitHubCredentials.GitHubNotSignedInError({ host }))
: Effect.succeed({
host,
token: Redacted.make("signed-in"),
source: "gh" as const,
fingerprint: "fingerprint",
}),
}),
Layer.mergeAll(
Layer.mock(RepositoryIdentityResolver.RepositoryIdentityResolver)({
resolveGitHubRepository: () => Effect.succeed(null),
}),
Layer.mock(GitHubCredentials.GitHubCredentials)({
get: (host) =>
++lookups === 1
? Effect.fail(new GitHubCredentials.GitHubNotSignedInError({ host }))
: Effect.succeed({
host,
token: Redacted.make("signed-in"),
source: "gh" as const,
fingerprint: "fingerprint",
}),
}),
),
),
Effect.provideService(
HttpClient.HttpClient,
Expand Down Expand Up @@ -1508,6 +1518,73 @@ describe("AssetAccess", () => {
const encoded = yield* Schema.encodeEffect(Schema.fromJsonString(AssetAccessError))(error);
expect(encoded).not.toContain(url);
}

const authorizations: Array<string | undefined> = [];
const mediaHttpClient = HttpClient.make((request) => {
authorizations.push(request.headers.authorization);
return Effect.succeed(
HttpClientResponse.fromWeb(
request,
new Response(null, { headers: { "content-type": "image/png" } }),
),
);
});
const processLayer = Layer.mock(ProcessRunner.ProcessRunner)({
run: ({ args }) =>
Effect.succeed({
stdout: args.includes("rev-parse")
? "/repo\n"
: args.includes("remote")
? "origin\thttps://github.com/workspace/app.git (fetch)\n"
: "",
stderr: "",
code: ChildProcessSpawner.ExitCode(0),
timedOut: false,
stdoutTruncated: false,
stderrTruncated: false,
stdoutInvalidUtf8: false,
stderrInvalidUtf8: false,
}),
});
const accounts = Layer.mergeAll(
Layer.effect(
RepositoryIdentityResolver.RepositoryIdentityResolver,
RepositoryIdentityResolver.make(),
).pipe(Layer.provide(processLayer)),
Layer.mock(GitHubCredentials.GitHubCredentials)({
get: (host, repository) =>
repository === "dependency/library" || repository === "workspace/app"
? Effect.succeed({
host,
token: Redacted.make(
repository === "dependency/library" ? "dependency-token" : "workspace-token",
),
source: "gh" as const,
fingerprint: repository,
})
: Effect.fail(new GitHubCredentials.GitHubNotSignedInError({ host })),
}),
);

for (const repository of [undefined, "dependency/library"]) {
const resource = decodeAssetResource({
_tag: "github-media",
cwd: "/repo",
url: "https://github.com/user-attachments/assets/1a1842fb-6383-492f-873c-57aa0033fa6c",
...(repository === undefined ? {} : { repository }),
});
const issued = yield* issueAssetUrl({ resource });
const asset = yield* resolve(issued.relativeUrl);
if (asset?.kind !== "github-media") throw new Error("Expected signed GitHub media");
if (repository !== undefined) expect.soft(asset).toMatchObject({ repository });
const response = yield* githubMediaResponse(asset, {}).pipe(
Effect.provide(accounts),
Effect.provideService(HttpClient.HttpClient, mediaHttpClient),
Effect.provideService(HostProcess.Environment, {}),
);
expect(response.status).toBe(200);
}
expect(authorizations).toEqual(["Bearer workspace-token", "Bearer dependency-token"]);
}).pipe(Effect.provide(layerTest)),
);
});
6 changes: 6 additions & 0 deletions apps/server/src/assets/AssetAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ const AssetClaimsSchema = Schema.Union([
Schema.Struct({
version: Schema.Literal(1),
kind: Schema.Literal("github-media"),
repository: Schema.optional(Schema.String),
/** Already narrowed to a GitHub media host at mint time; the signature is what keeps it there. */
url: Schema.String,
cwd: Schema.String,
Expand Down Expand Up @@ -188,6 +189,7 @@ export type ResolvedAsset =
}
| {
readonly kind: "github-media";
readonly repository?: string;
readonly url: string;
readonly cwd: string;
readonly expiresAt: number;
Expand Down Expand Up @@ -775,6 +777,9 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
kind: "github-media",
url: fetchUrl,
cwd: input.resource.cwd,
...(input.resource.repository === undefined
? {}
: { repository: input.resource.repository }),
expiresAt,
};
fileName = githubMediaFileName(fetchUrl);
Expand Down Expand Up @@ -892,6 +897,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
kind: "github-media",
url: claims.url,
cwd: claims.cwd,
...(claims.repository === undefined ? {} : { repository: claims.repository }),
expiresAt: claims.expiresAt,
} satisfies ResolvedAsset;
}
Expand Down
37 changes: 30 additions & 7 deletions apps/server/src/assets/GitHubMediaFetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ import {
} from "effect/http";

import * as GitHubCredentials from "@t3tools/source-control-github/server/GitHubCredentials";
import { parseGitHubRepositorySelector } from "@t3tools/source-control-github/server/gitHubRepositoryResolution";

import { workspaceGitHubCredential } from "../sourceControl/GitHubWorkspaceAccount.ts";

/**
* Everything a redirect leads to — the presigned object stores GitHub hands assets off to —
Expand Down Expand Up @@ -50,11 +53,26 @@ const SVG_CONTENT_TYPE = "image/svg+xml";
// body cannot run script against this origin.
const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inline'; sandbox";

const githubToken = GitHubCredentials.GitHubCredentials.pipe(
Effect.flatMap((credentials) => credentials.get("github.com")),
Effect.map((credential) => credential.token),
Effect.orElseSucceed(() => null),
);
/** Explicit PR repositories override the checkout; null still loads public media. */
const githubToken = (cwd: string, repository?: string) =>
Effect.gen(function* () {
if (repository === undefined) return yield* workspaceGitHubCredential(cwd);
const locator = parseGitHubRepositorySelector(repository, "github.com");
// These media URLs are only supported on github.com, regardless of the PR's host.
if (locator === null || locator.host !== "github.com") return null;
const credentials = yield* GitHubCredentials.GitHubCredentials;
return yield* credentials.get(locator.host, `${locator.owner}/${locator.name}`);
}).pipe(
Effect.flatMap((credential) =>
credential?.host === "github.com"
? Effect.succeed(credential)
: GitHubCredentials.GitHubCredentials.pipe(
Effect.flatMap((credentials) => credentials.get("github.com")),
),
),
Effect.map((credential) => credential.token),
Effect.orElseSucceed(() => null),
);

const fetchFollowingRedirects = Effect.fn("GitHubMediaFetch.fetchFollowingRedirects")(function* (
url: string,
Expand Down Expand Up @@ -88,11 +106,16 @@ const fetchFollowingRedirects = Effect.fn("GitHubMediaFetch.fetchFollowingRedire
});

export const githubMediaResponse = Effect.fn("GitHubMediaFetch.githubMediaResponse")(function* (
asset: { readonly url: string; readonly cwd: string; readonly expiresAt: number },
asset: {
readonly url: string;
readonly cwd: string;
readonly repository?: string;
readonly expiresAt: number;
},
requestHeaders: Record<string, string | undefined>,
) {
// Both media hosts are served by github.com's account, which is the host `gh` stores it under.
const token = yield* githubToken;
const token = yield* githubToken(asset.cwd, asset.repository);
const forwarded: Record<string, string> = {};
for (const name of FORWARDED_REQUEST_HEADERS) {
const value = requestHeaders[name];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ describe("ThreadPullRequestServiceV2 project guard", () => {
const result = yield* ThreadPullRequestService.resolveProjectForPullRequestDiscovery(
project,
{
resolveGitHubRepository: () => Effect.succeed(null),
resolve: (root) => {
resolvedRoot = root;
return Effect.succeed({
Expand Down Expand Up @@ -84,6 +85,7 @@ describe("ThreadPullRequestServiceV2 project guard", () => {
};
const refreshes: Array<boolean | undefined> = [];
const resolver = {
resolveGitHubRepository: () => Effect.succeed(null),
resolve: (_root: string, options?: { readonly refresh?: boolean }) => {
refreshes.push(options?.refresh);
return Effect.succeed(null);
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/project/ManagedProjectFolders.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ const layerGitWorkflow = (vcsProcess?: Layer.Layer<VcsProcess.VcsProcess>) =>
const layerEnrichment = ProjectEnrichmentService.layer.pipe(
Layer.provide(
Layer.mergeAll(
Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, {
Layer.mock(RepositoryIdentityResolver.RepositoryIdentityResolver)({
resolve: () => Effect.succeed(null),
}),
Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, {
Expand Down
18 changes: 9 additions & 9 deletions apps/server/src/project/ProjectEnrichmentService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ const layer = (
it.effect("preserves either enrichment field when the other resolver fails", () =>
Effect.gen(function* () {
const layerMetadata = Layer.merge(
Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, {
Layer.mock(RepositoryIdentityResolver.RepositoryIdentityResolver)({
resolve: (workspaceRoot) =>
workspaceRoot === "/repo-fails"
? Effect.die("repository resolver failed")
Expand Down Expand Up @@ -105,7 +105,7 @@ it.effect("does not warn about favicons for workspace roots that no longer exist
warnings.push(message);
});
const metadataLayer = Layer.merge(
Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, {
Layer.mock(RepositoryIdentityResolver.RepositoryIdentityResolver)({
resolve: (workspaceRoot) => Effect.succeed(identity(workspaceRoot)),
}),
Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, {
Expand Down Expand Up @@ -154,7 +154,7 @@ it.effect("publishes repository completion while favicon enrichment is still pen
Effect.gen(function* () {
const releaseFavicon = yield* Deferred.make<void>();
const layerMetadata = Layer.merge(
Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, {
Layer.mock(RepositoryIdentityResolver.RepositoryIdentityResolver)({
resolve: (workspaceRoot) => Effect.succeed(identity(workspaceRoot)),
}),
Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, {
Expand Down Expand Up @@ -182,7 +182,7 @@ it.effect("keeps repository workers available when every favicon worker is hung"
const faviconWorkersStarted = yield* Deferred.make<void>();
const faviconStarts = yield* Ref.make(0);
const layerMetadata = Layer.merge(
Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, {
Layer.mock(RepositoryIdentityResolver.RepositoryIdentityResolver)({
resolve: (workspaceRoot) => Effect.succeed(identity(workspaceRoot)),
}),
Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, {
Expand Down Expand Up @@ -229,7 +229,7 @@ it.effect("getAvailable returns immediately while repository identity is still u
const repositoryStarted = yield* Deferred.make<void>();
const releaseRepository = yield* Deferred.make<void>();
const layerMetadata = Layer.merge(
Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, {
Layer.mock(RepositoryIdentityResolver.RepositoryIdentityResolver)({
resolve: (workspaceRoot) =>
Deferred.succeed(repositoryStarted, undefined).pipe(
Effect.andThen(Deferred.await(releaseRepository)),
Expand Down Expand Up @@ -270,7 +270,7 @@ it.effect(
const repositoryStarted = yield* Deferred.make<void>();
const releaseRepository = yield* Deferred.make<void>();
const layerMetadata = Layer.merge(
Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, {
Layer.mock(RepositoryIdentityResolver.RepositoryIdentityResolver)({
resolve: (workspaceRoot) => {
if (workspaceRoot === "/no-remote") {
return Effect.succeed(null);
Expand Down Expand Up @@ -332,7 +332,7 @@ it.effect("deduplicates requests, bounds pending work, and reloads invalidated r
const repositoryCalls = yield* Ref.make<ReadonlyArray<string>>([]);
const version = yield* Ref.make(1);
const layerMetadata = Layer.merge(
Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, {
Layer.mock(RepositoryIdentityResolver.RepositoryIdentityResolver)({
resolve: (workspaceRoot) =>
Effect.gen(function* () {
yield* Ref.update(repositoryCalls, (calls) => [...calls, workspaceRoot]);
Expand Down Expand Up @@ -399,7 +399,7 @@ it.effect("rescans a favicon only after 15 minutes", () =>
Effect.gen(function* () {
const faviconScans = yield* Ref.make(0);
const layerMetadata = Layer.merge(
Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, {
Layer.mock(RepositoryIdentityResolver.RepositoryIdentityResolver)({
resolve: (workspaceRoot) => Effect.succeed(identity(workspaceRoot)),
}),
Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, {
Expand Down Expand Up @@ -444,7 +444,7 @@ it.effect(
const remoteVersion = yield* Ref.make(0);
const faviconScans = yield* Ref.make(0);
const layerMetadata = Layer.merge(
Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, {
Layer.mock(RepositoryIdentityResolver.RepositoryIdentityResolver)({
resolve: (workspaceRoot) =>
Ref.get(remoteVersion).pipe(
Effect.map((version) => (version === 0 ? null : identity(workspaceRoot, version))),
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/project/ProjectService.deletion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ const layerServices = Layer.mergeAll(
ProjectEnrichmentService.layer.pipe(
Layer.provide(
Layer.merge(
Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, {
Layer.mock(RepositoryIdentityResolver.RepositoryIdentityResolver)({
resolve: () => Effect.succeed(null),
}),
Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, {
Expand Down
8 changes: 4 additions & 4 deletions apps/server/src/project/ProjectService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ const layerWorkspacePaths = Layer.succeed(WorkspacePaths.WorkspacePaths, {
});

const layerMetadata = Layer.merge(
Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, {
Layer.mock(RepositoryIdentityResolver.RepositoryIdentityResolver)({
resolve: (workspaceRoot) =>
Effect.succeed({
canonicalKey: `github.com/t3tools/${workspaceRoot.split("/").at(-1)}`,
Expand Down Expand Up @@ -495,7 +495,7 @@ it.effect(
const faviconCalls = yield* Ref.make(0);

const layerSlowMetadata = Layer.merge(
Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, {
Layer.mock(RepositoryIdentityResolver.RepositoryIdentityResolver)({
resolve: (workspaceRoot) =>
Effect.gen(function* () {
yield* Ref.update(repositoryCalls, (count) => count + 1);
Expand Down Expand Up @@ -593,7 +593,7 @@ it.effect(
it.effect("keeps project snapshots available when optional metadata enrichment fails", () =>
Effect.gen(function* () {
const layerFailingMetadata = Layer.merge(
Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, {
Layer.mock(RepositoryIdentityResolver.RepositoryIdentityResolver)({
resolve: () => Effect.succeed(null),
}),
Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, {
Expand Down Expand Up @@ -632,7 +632,7 @@ it.effect("invalidates workspace-derived metadata when a project moves", () =>
Effect.gen(function* () {
const metadataVersion = yield* Ref.make(1);
const layerVersionedMetadata = Layer.merge(
Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, {
Layer.mock(RepositoryIdentityResolver.RepositoryIdentityResolver)({
resolve: (workspaceRoot) =>
Ref.get(metadataVersion).pipe(
Effect.map((version) => ({
Expand Down
Loading
Loading