From 6065c6cfc61b0dd9913959c01d806a3a2c5e1471 Mon Sep 17 00:00:00 2001 From: "wizzoapp[bot]" <254688279+wizzoapp[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:53:05 +0100 Subject: [PATCH 1/3] feat(server): add environment-aware asset relay --- apps/server/src/assets/AssetAccess.test.ts | 57 ++++++-- apps/server/src/assets/AssetAccess.ts | 125 +++++++++++++--- apps/server/src/http.test.ts | 62 +++++++- apps/server/src/http.ts | 161 +++++++++++++++++++++ apps/server/src/server.test.ts | 48 +++++- apps/server/src/server.ts | 2 + packages/contracts/src/assetClaims.test.ts | 29 ++++ packages/contracts/src/assetClaims.ts | 22 ++- 8 files changed, 459 insertions(+), 47 deletions(-) diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 7ec72b78023..5654e6c19de 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -1,5 +1,10 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY, AuthSessionId, ThreadId } from "@t3tools/contracts"; +import { + ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY, + AuthSessionId, + EnvironmentId, + ThreadId, +} from "@t3tools/contracts"; import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -11,17 +16,21 @@ import * as PlatformError from "effect/PlatformError"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as SessionStore from "../auth/SessionStore.ts"; import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; import { ASSET_ROUTE_PREFIX, ASSET_SURFACE_RELAY_PREFIX, + decodeAssetRelayRoutingClaim, issueAssetUrl as issueAssetUrlImpl, resolveAsset as resolveAssetImpl, + resolveLocalAssetRelay, } from "./AssetAccess.ts"; const TEST_SURFACE_SESSION_ID = AuthSessionId.make("asset-access-test-surface"); const OTHER_SURFACE_SESSION_ID = AuthSessionId.make("asset-access-other-surface"); +const TEST_ENVIRONMENT_ID = EnvironmentId.make("asset-access-environment"); const assetRouteSuffix = (relativeUrl: string) => { const prefix = relativeUrl.startsWith(`${ASSET_SURFACE_RELAY_PREFIX}/`) @@ -55,6 +64,10 @@ const testLayer = Layer.mergeAll( cookieName: "t3_asset_access_test", isActive: () => Effect.succeed(true), }), + Layer.mock(ServerEnvironment.ServerEnvironment)({ + getEnvironmentId: Effect.succeed(TEST_ENVIRONMENT_ID), + getDescriptor: Effect.die("unused test environment descriptor"), + }), ).pipe(Layer.provideMerge(NodeServices.layer)); describe("AssetAccess", () => { @@ -228,18 +241,38 @@ describe("AssetAccess", () => { const separatorIndex = suffix.indexOf("/"); const token = suffix.slice(0, separatorIndex); + expect(decodeAssetRelayRoutingClaim(token)).toMatchObject({ + issuingAudience: "private", + issuingBackendId: TEST_ENVIRONMENT_ID, + }); expect(yield* resolveAsset(token, "ignored.png")).toEqual({ kind: "file", path: attachmentPath, }); expect(yield* resolveAsset(token, "ignored.png", OTHER_SURFACE_SESSION_ID)).toBeNull(); expect(yield* resolveAssetImpl(token, "ignored.png")).toBeNull(); + expect( + yield* resolveLocalAssetRelay({ + token, + relativePath: "ignored.png", + viewerSessionId: OTHER_SURFACE_SESSION_ID, + viewerAudienceCeiling: "private", + }), + ).toEqual({ kind: "file", path: attachmentPath }); + expect( + yield* resolveLocalAssetRelay({ + token, + relativePath: "ignored.png", + viewerSessionId: OTHER_SURFACE_SESSION_ID, + viewerAudienceCeiling: "factory", + }), + ).toBeNull(); expect(result.relativeUrl).not.toContain(TEST_SURFACE_SESSION_ID); expect(result.relativeUrl).not.toContain(result.surfaceCredential); }).pipe(Effect.provide(testLayer)), ); - it.effect("preserves signed direct URLs for clients without surface credentials", () => + it.effect("requires relay capability from old clients before issuing private URLs", () => Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; const fileSystem = yield* FileSystem.FileSystem; @@ -251,24 +284,16 @@ describe("AssetAccess", () => { new Uint8Array([1, 2, 3]), ); - const result = yield* issueAssetUrlImpl({ + const error = yield* issueAssetUrlImpl({ resource: { _tag: "attachment", attachmentId }, surfaceSessionId: TEST_SURFACE_SESSION_ID, - }); - const suffix = assetRouteSuffix(result.relativeUrl); - const separatorIndex = suffix.indexOf("/"); - const token = suffix.slice(0, separatorIndex); + }).pipe(Effect.flip); - expect(result).toEqual({ - relativeUrl: expect.stringMatching(/^\/api\/assets\/(?!relay\/)/), - expiresAt: expect.any(Number), + expect(error).toMatchObject({ + _tag: "AssetClientUpgradeRequiredError", + requiredCapability: ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY, + resource: { _tag: "attachment", attachmentId }, }); - expect(yield* resolveAssetImpl(token, "ignored.png")).toBeNull(); - expect( - yield* resolveAssetImpl(token, "ignored.png", { - allowUnbound: true, - }), - ).toEqual({ kind: "file", path: path.join(config.attachmentsDir, `${attachmentId}.png`) }); }).pipe(Effect.provide(testLayer)), ); diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index 6d11fdc54b6..78fb80b4319 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -2,6 +2,7 @@ import { ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY, AssetClaimJson, AssetAttachmentNotFoundError, + AssetClientUpgradeRequiredError, AssetPreviewTypeValidationError, AssetProjectFaviconInspectionError, AssetProjectFaviconNotFoundError, @@ -17,7 +18,9 @@ import { type AssetClaim, type AssetClientCapability, type AssetResource, + type AuthAudienceCeiling, type AuthSessionId, + type DataAudience, } from "@t3tools/contracts"; import { isWorkspaceImagePreviewPath, @@ -43,8 +46,10 @@ import { } from "../auth/utils.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as SessionStore from "../auth/SessionStore.ts"; +import { canReadDataAudience } from "../auth/audienceDataPolicy.ts"; import { resolveAttachmentPathById } from "../attachmentStore.ts"; import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; @@ -52,6 +57,7 @@ export const ASSET_ROUTE_PREFIX = "/api/assets"; export const ASSET_SURFACE_RELAY_PREFIX = `${ASSET_ROUTE_PREFIX}/relay`; export const ASSET_SURFACE_BIND_PATH = `${ASSET_SURFACE_RELAY_PREFIX}/surface`; export const ASSET_SURFACE_CREDENTIAL_HEADER = "x-t3-asset-surface"; +export const ASSET_APP_RELAY_PREFIX = "/_asset-relay"; export function assetSurfaceCookiePrefix(sessionCookieName: string): string { return `${sessionCookieName}_asset_surface_`; @@ -114,6 +120,37 @@ function splitSignedToken(token: string): readonly [string, string] | null { return parts.length === 2 && parts[0] && parts[1] ? [parts[0], parts[1]] : null; } +export function decodeAssetRelayRoutingClaim(token: string): AssetClaim | null { + const tokenParts = splitSignedToken(token); + return tokenParts === null ? null : decodeClaims(tokenParts[0]); +} + +export function effectiveAssetClaimAudience(claim: AssetClaim): DataAudience { + return claim.dataAudience === "private" || claim.issuingAudience === "private" + ? "private" + : "factory"; +} + +const verifyAssetClaimToken = Effect.fn("AssetAccess.verifyAssetClaimToken")(function* ( + token: string, +) { + const tokenParts = splitSignedToken(token); + if (tokenParts === null) return null; + const [encodedPayload, signature] = tokenParts; + + const secretStore = yield* ServerSecretStore.ServerSecretStore; + const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32).pipe( + Effect.tapError((cause) => Effect.logError("Failed to load the asset signing key.", { cause })), + Effect.orElseSucceed(() => null), + ); + if (!signingSecret) return null; + if (!timingSafeEqualBase64Url(signature, signPayload(encodedPayload, signingSecret))) return null; + + const claim = decodeClaims(encodedPayload); + if (!claim || claim.expiresAt <= (yield* Clock.currentTimeMillis)) return null; + return { claim, signingSecret }; +}); + function surfaceBindingIdForSession(sessionId: AuthSessionId, signingSecret: Uint8Array): string { return signPayload(`asset-surface:${sessionId}`, signingSecret); } @@ -191,6 +228,8 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i const workspacePaths = yield* WorkspacePaths.WorkspacePaths; const now = yield* Clock.currentTimeMillis; let expiresAt = now + ASSET_TOKEN_TTL_MS; + const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; + const issuingBackendId = yield* serverEnvironment.getEnvironmentId; let claims: AssetClaim; let fileName: string; @@ -263,6 +302,8 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i relativePath: resolved.relativePath, expiresAt, dataAudience: "private", + issuingAudience: "private", + issuingBackendId, surfaceBindingId: null, } : { @@ -272,6 +313,8 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i baseRelativePath: path.dirname(resolved.relativePath), expiresAt, dataAudience: "private", + issuingAudience: "private", + issuingBackendId, surfaceBindingId: null, }; fileName = path.basename(resolved.relativePath); @@ -294,6 +337,8 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i attachmentId: input.resource.attachmentId, expiresAt, dataAudience: "private", + issuingAudience: "private", + issuingBackendId, surfaceBindingId: null, }; fileName = path.basename(attachmentPath); @@ -351,6 +396,8 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i relativePath, expiresAt, dataAudience: "private", + issuingAudience: "private", + issuingBackendId, surfaceBindingId: null, }; fileName = relativePath ? path.basename(relativePath) : PROJECT_FAVICON_FALLBACK_MARKER; @@ -358,6 +405,13 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i } } + if (!input.clientCapabilities?.includes(ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY)) { + return yield* new AssetClientUpgradeRequiredError({ + resource: input.resource, + requiredCapability: ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY, + }); + } + const secretStore = yield* ServerSecretStore.ServerSecretStore; const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32).pipe( Effect.mapError( @@ -368,13 +422,6 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i }), ), ); - if (!input.clientCapabilities?.includes(ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY)) { - const encodedPayload = base64UrlEncode(encodeAssetClaims(claims)); - return { - relativeUrl: `${ASSET_ROUTE_PREFIX}/${signToken(encodedPayload, signingSecret)}/${encodeURIComponent(fileName)}`, - expiresAt, - }; - } if (input.surfaceSessionId === undefined) { return yield* new AssetWorkspaceContextNotFoundError({ resource: input.resource }); } @@ -452,20 +499,9 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( readonly allowUnbound?: boolean; } = {}, ) { - const tokenParts = splitSignedToken(token); - if (tokenParts === null) return null; - const [encodedPayload, signature] = tokenParts; - - const secretStore = yield* ServerSecretStore.ServerSecretStore; - const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32).pipe( - Effect.tapError((cause) => Effect.logError("Failed to load the asset signing key.", { cause })), - Effect.orElseSucceed(() => null), - ); - if (!signingSecret) return null; - if (!timingSafeEqualBase64Url(signature, signPayload(encodedPayload, signingSecret))) return null; - - const claims = decodeClaims(encodedPayload); - if (!claims || claims.expiresAt <= (yield* Clock.currentTimeMillis)) return null; + const verified = yield* verifyAssetClaimToken(token); + if (verified === null) return null; + const { claim: claims, signingSecret } = verified; if (claims.surfaceBindingId === null) { if (requestProof.allowUnbound !== true) return null; @@ -547,3 +583,50 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( }); return workspaceFile ? ({ kind: "file", path: workspaceFile } satisfies ResolvedAsset) : null; }); + +export const resolveLocalAssetRelay = Effect.fn("AssetAccess.resolveLocalAssetRelay")( + function* (input: { + readonly token: string; + readonly relativePath: string; + readonly viewerSessionId: AuthSessionId; + readonly viewerAudienceCeiling: AuthAudienceCeiling; + readonly viewerSessionExpiresAt?: DateTime.DateTime; + }) { + const verified = yield* verifyAssetClaimToken(input.token); + if (verified === null) return null; + const { claim, signingSecret } = verified; + const environment = yield* ServerEnvironment.ServerEnvironment; + const localBackendId = yield* environment.getEnvironmentId; + if (claim.issuingBackendId !== null && claim.issuingBackendId !== localBackendId) return null; + + const audience = effectiveAssetClaimAudience(claim); + if (!canReadDataAudience(input.viewerAudienceCeiling, audience)) return null; + if (claim.surfaceBindingId === null) { + return audience === "private" + ? null + : yield* resolveAsset(input.token, input.relativePath, { allowUnbound: true }); + } + + const now = yield* Clock.currentTimeMillis; + const proofExpiresAt = Math.min( + claim.expiresAt, + input.viewerSessionExpiresAt?.epochMilliseconds ?? claim.expiresAt, + ); + if (proofExpiresAt <= now) return null; + const relayProof = signToken( + base64UrlEncode( + encodeAssetSurfaceCredentialClaims({ + version: 1, + kind: "asset-surface", + surfaceSessionId: input.viewerSessionId, + surfaceBindingId: claim.surfaceBindingId, + expiresAt: proofExpiresAt, + }), + ), + signingSecret, + ); + return yield* resolveAsset(input.token, input.relativePath, { + surfaceCredentials: [relayProof], + }); + }, +); diff --git a/apps/server/src/http.test.ts b/apps/server/src/http.test.ts index a1d4230e75c..f0e7787d041 100644 --- a/apps/server/src/http.test.ts +++ b/apps/server/src/http.test.ts @@ -1,6 +1,18 @@ +import { EnvironmentId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { isLoopbackHostname, resolveDevRedirectUrl } from "./http.ts"; +import type { SubagentPeer } from "./subagents/SubagentPeerRegistry.ts"; +import { __assetRelayTesting, isLoopbackHostname, resolveDevRedirectUrl } from "./http.ts"; + +const peer = (environmentId: string, overrides: Partial = {}): SubagentPeer => ({ + alias: `peer-${environmentId}`, + environmentId: EnvironmentId.make(environmentId), + httpBaseUrl: `https://${environmentId}.example.test`, + mcpEndpoint: `https://${environmentId}.example.test/mcp`, + credential: { _tag: "bearer", token: `token-${environmentId}` }, + pairedAt: "2026-07-22T00:00:00.000Z", + ...overrides, +}); describe("http dev routing", () => { it("treats localhost and loopback addresses as local", () => { @@ -25,3 +37,51 @@ describe("http dev routing", () => { ); }); }); + +describe("asset app relay routing", () => { + it("selects only the peer whose environment id issued the claim", () => { + const peers = [peer("backend-a"), peer("backend-b")]; + + expect(__assetRelayTesting.selectAssetRelayPeer(peers, "backend-b")).toBe(peers[1]); + expect(__assetRelayTesting.selectAssetRelayPeer(peers, "missing")).toBeUndefined(); + }); + + it("uses only server-to-server bearer and service-token headers", () => { + expect( + __assetRelayTesting.trustedAssetRelayHeaders( + peer("backend-a", { + cfAccess: { + _tag: "service-token", + clientId: "service-client", + clientSecret: "service-secret", + }, + }), + ), + ).toEqual({ + accept: "application/octet-stream", + authorization: "Bearer token-backend-a", + "cf-access-client-id": "service-client", + "cf-access-client-secret": "service-secret", + }); + }); + + it("rejects credential references and browser-bound Cloudflare credentials", () => { + expect( + __assetRelayTesting.trustedAssetRelayHeaders( + peer("credential-ref", { + credential: { _tag: "credential-ref", ref: "op://vault/item/token" }, + }), + ), + ).toBeNull(); + expect( + __assetRelayTesting.trustedAssetRelayHeaders( + peer("cookie", { cfAccess: { _tag: "cookie", cookieValue: "browser-cookie" } }), + ), + ).toBeNull(); + expect( + __assetRelayTesting.trustedAssetRelayHeaders( + peer("jwt", { cfAccess: { _tag: "jwt", jwt: "browser-jwt" } }), + ), + ).toBeNull(); + }); +}); diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 437ba791ae1..0e33e46c64f 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -10,6 +10,7 @@ import { } from "@t3tools/contracts"; import { decodeOtlpTraceRecords } from "@t3tools/shared/observability"; import * as Data from "effect/Data"; +import * as Clock from "effect/Clock"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -22,7 +23,9 @@ import * as Cookies from "effect/unstable/http/Cookies"; import { HttpBody, HttpClient, + HttpClientRequest, HttpClientResponse, + FetchHttpClient, HttpRouter, HttpServerResponse, HttpServerRequest, @@ -33,13 +36,17 @@ import { OtlpTracer } from "effect/unstable/observability"; import * as ServerConfig from "./config.ts"; import { + ASSET_APP_RELAY_PREFIX, ASSET_ROUTE_PREFIX, ASSET_SURFACE_BIND_PATH, ASSET_SURFACE_CREDENTIAL_HEADER, ASSET_SURFACE_RELAY_PREFIX, assetSurfaceCookieName, assetSurfaceCookiePrefix, + decodeAssetRelayRoutingClaim, + effectiveAssetClaimAudience, resolveAsset, + resolveLocalAssetRelay, verifyAssetSurfaceCredential, } from "./assets/AssetAccess.ts"; import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts"; @@ -57,6 +64,7 @@ import { configuredCookieAuthCsrfOriginsEffect, } from "./auth/http.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; +import { canReadDataAudience } from "./auth/audienceDataPolicy.ts"; import { browserApiCorsAllowedHeaders, browserApiCorsAllowedMethods, @@ -69,7 +77,9 @@ import { SUBAGENT_PEER_MCP_TOKEN_PATH, SubagentPeerMcpTokenRequest, type SubagentPeerMcpTokenResult, + environmentUrl, } from "./subagents/SubagentPeerHttp.ts"; +import * as SubagentPeerRegistry from "./subagents/SubagentPeerRegistry.ts"; const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces"; const TTS_SPEAK_PATH = "/api/tts/speak"; @@ -606,6 +616,157 @@ export const assetRouteLayer = HttpRouter.add( }), ); +const ASSET_RELAY_CLAIM_MAX_LENGTH = 4096; +const maskedAssetRelayResponse = () => + HttpServerResponse.text("Not Found", { + status: 404, + headers: { "Cache-Control": "no-store" }, + }); + +function parseAssetRelayPath(pathname: string): { + readonly token: string; + readonly relativePath: string; +} | null { + const prefix = `${ASSET_APP_RELAY_PREFIX}/`; + if (!pathname.startsWith(prefix)) return null; + const suffix = pathname.slice(prefix.length); + const separatorIndex = suffix.indexOf("/"); + const token = separatorIndex === -1 ? suffix : suffix.slice(0, separatorIndex); + if (token.length === 0 || token.length > ASSET_RELAY_CLAIM_MAX_LENGTH) return null; + return { + token, + relativePath: separatorIndex === -1 ? "" : suffix.slice(separatorIndex + 1), + }; +} + +function trustedAssetRelayHeaders( + peer: SubagentPeerRegistry.SubagentPeer, +): Record | null { + if (peer.credential._tag !== "bearer") return null; + if (peer.cfAccess !== undefined && peer.cfAccess._tag !== "service-token") return null; + return { + accept: "application/octet-stream", + authorization: `Bearer ${peer.credential.token}`, + ...(peer.cfAccess === undefined + ? {} + : { + "cf-access-client-id": peer.cfAccess.clientId, + "cf-access-client-secret": peer.cfAccess.clientSecret, + }), + }; +} + +function selectAssetRelayPeer( + peers: ReadonlyArray, + issuingBackendId: string, +): SubagentPeerRegistry.SubagentPeer | undefined { + return peers.find((candidate) => candidate.environmentId === issuingBackendId); +} + +function safeContentLength(value: string | undefined): number | undefined { + if (value === undefined || !/^\d+$/.test(value)) return undefined; + const parsed = Number(value); + return Number.isSafeInteger(parsed) ? parsed : undefined; +} + +const makeAssetAppRelayRouteLayer = ( + peerRegistry: SubagentPeerRegistry.SubagentPeerRegistryShape, +) => + HttpRouter.add( + "GET", + `${ASSET_APP_RELAY_PREFIX}/*`, + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const url = HttpServerRequest.toURL(request); + if (Option.isNone(url)) return maskedAssetRelayResponse(); + const parsed = parseAssetRelayPath(url.value.pathname); + if (parsed === null) return maskedAssetRelayResponse(); + + const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + const viewer = yield* serverAuth.authenticateHttpRequest(request).pipe(Effect.option); + if (Option.isNone(viewer) || !viewer.value.scopes.includes(AuthOrchestrationReadScope)) { + return maskedAssetRelayResponse(); + } + + const routingClaim = decodeAssetRelayRoutingClaim(parsed.token); + if ( + routingClaim === null || + routingClaim.expiresAt <= (yield* Clock.currentTimeMillis) || + !canReadDataAudience( + viewer.value.audienceCeiling, + effectiveAssetClaimAudience(routingClaim), + ) + ) { + return maskedAssetRelayResponse(); + } + + const environment = yield* ServerEnvironment.ServerEnvironment; + const localBackendId = yield* environment.getEnvironmentId; + if ( + routingClaim.issuingBackendId === null || + routingClaim.issuingBackendId === localBackendId + ) { + const asset = yield* resolveLocalAssetRelay({ + token: parsed.token, + relativePath: parsed.relativePath, + viewerSessionId: viewer.value.sessionId, + viewerAudienceCeiling: viewer.value.audienceCeiling, + ...(viewer.value.expiresAt ? { viewerSessionExpiresAt: viewer.value.expiresAt } : {}), + }); + if (asset === null) return maskedAssetRelayResponse(); + return yield* HttpServerResponse.file(asset.path, { + status: 200, + headers: { + "Cache-Control": "no-store", + "X-Content-Type-Options": "nosniff", + }, + }).pipe(Effect.orElseSucceed(maskedAssetRelayResponse)); + } + + const peers = yield* peerRegistry.list.pipe(Effect.orElseSucceed(() => [])); + const peer = selectAssetRelayPeer(peers, routingClaim.issuingBackendId); + const headers = peer === undefined ? null : trustedAssetRelayHeaders(peer); + if (peer === undefined || headers === null) return maskedAssetRelayResponse(); + + const upstreamUrl = environmentUrl(peer.httpBaseUrl, url.value.pathname); + const httpClient = yield* HttpClient.HttpClient; + const upstream = yield* httpClient + .execute(HttpClientRequest.get(upstreamUrl, { headers })) + .pipe( + Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" }), + Effect.orElseSucceed(() => null), + ); + if (upstream === null || upstream.status !== 200) return maskedAssetRelayResponse(); + + const contentType = upstream.headers["content-type"]; + const contentLength = safeContentLength(upstream.headers["content-length"]); + return HttpServerResponse.stream(upstream.stream, { + status: 200, + headers: { + "Cache-Control": "no-store", + "X-Content-Type-Options": "nosniff", + }, + ...(contentType === undefined ? {} : { contentType }), + ...(contentLength === undefined ? {} : { contentLength }), + }); + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Asset app relay request failed closed.", { cause }).pipe( + Effect.as(maskedAssetRelayResponse()), + ), + ), + ), + ); + +export const assetAppRelayRouteLayer = Layer.unwrap( + Effect.map(SubagentPeerRegistry.SubagentPeerRegistry, makeAssetAppRelayRouteLayer), +).pipe(Layer.provide(SubagentPeerRegistry.layer)); + +export const __assetRelayTesting = { + selectAssetRelayPeer, + trustedAssetRelayHeaders, +}; + const AssetSurfaceBindingInput = Schema.Struct({ credential: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(4096)), redirect: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(4096))), diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 7de0ede0333..3673b7fd39e 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -2392,6 +2392,19 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const issuerCookie = yield* getAuthenticatedSessionCookieHeader(); const otherCookie = yield* getAuthenticatedSessionCookieHeader(); + const factoryPairingResponse = yield* fetchEffect( + yield* getHttpServerUrl("/api/auth/pairing-token"), + { + method: "POST", + headers: { cookie: issuerCookie, "content-type": "application/json" }, + body: jsonRequestBody({ audienceCeiling: "factory" }), + }, + ); + assert.equal(factoryPairingResponse.status, 200); + const factoryPairing = yield* responseJsonEffect<{ readonly credential: string }>( + factoryPairingResponse, + ); + const factoryCookie = yield* getAuthenticatedSessionCookieHeader(factoryPairing.credential); assert.notEqual(issuerCookie, otherCookie); const wsUrl = appendSessionCookieToWsUrl( yield* getWsServerUrl("/ws", { authenticated: false }), @@ -2400,17 +2413,12 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const rpc = (f: (client: WsRpcClient) => Effect.Effect) => Effect.scoped(withWsRpcClient(wsUrl, f)); - const legacyIssued = yield* rpc((client) => + const legacyError = yield* rpc((client) => client[WS_METHODS.assetsCreateUrl]({ resource: { _tag: "attachment", attachmentId }, - }), + }).pipe(Effect.flip), ); - assert.include(legacyIssued.relativeUrl, "/api/assets/"); - assert.notInclude(legacyIssued.relativeUrl, "/api/assets/relay/"); - assert.notProperty(legacyIssued, "surfaceCredential"); - const legacyResponse = yield* fetchEffect(yield* getHttpServerUrl(legacyIssued.relativeUrl)); - assert.equal(legacyResponse.status, 200); - assert.equal(yield* legacyResponse.text, "same-surface-private-asset"); + assert.equal(legacyError._tag, "AssetClientUpgradeRequiredError"); const issued = yield* rpc((client) => client[WS_METHODS.assetsCreateUrl]({ @@ -2442,6 +2450,30 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.equal(nativeResponse.status, 200); assert.equal(yield* nativeResponse.text, "same-surface-private-asset"); + const appRelayPath = issued.relativeUrl.replace("/api/assets/relay/", "/_asset-relay/"); + assert.notInclude(appRelayPath, issued.surfaceCredential ?? "surface-credential"); + const unauthenticatedRelay = yield* fetchEffect(yield* getHttpServerUrl(appRelayPath)); + assert.equal(unauthenticatedRelay.status, 404); + const sameSurfaceRelay = yield* fetchEffect(yield* getHttpServerUrl(appRelayPath), { + headers: { cookie: issuerCookie }, + redirect: "manual", + }); + const sameSurfaceRelayBody = yield* sameSurfaceRelay.text; + assert.equal(sameSurfaceRelay.status, 200, sameSurfaceRelayBody); + assert.equal(sameSurfaceRelay.headers["cache-control"], "no-store"); + assert.isUndefined(sameSurfaceRelay.headers.location); + assert.isUndefined(sameSurfaceRelay.headers["set-cookie"]); + assert.equal(sameSurfaceRelayBody, "same-surface-private-asset"); + const otherPrivateSurfaceRelay = yield* fetchEffect(yield* getHttpServerUrl(appRelayPath), { + headers: { cookie: otherCookie }, + }); + assert.equal(otherPrivateSurfaceRelay.status, 200); + assert.equal(yield* otherPrivateSurfaceRelay.text, "same-surface-private-asset"); + const factorySurfaceRelay = yield* fetchEffect(yield* getHttpServerUrl(appRelayPath), { + headers: { cookie: factoryCookie }, + }); + assert.equal(factorySurfaceRelay.status, 404); + for (const origin of ["null", "not an origin"]) { const nonHttpsBindingResponse = yield* fetchEffect( yield* getHttpServerUrl("/api/assets/relay/surface"), diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 2dc84544347..1544b54f29b 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -13,6 +13,7 @@ import { notificationRecoveryRouteLayer, mcpPeerTokenRouteLayer, assetRouteLayer, + assetAppRelayRouteLayer, assetSurfaceBindingRouteLayer, serverEnvironmentHttpApiLayer, staticAndDevRouteLayer, @@ -428,6 +429,7 @@ export const makeRoutesLayer = Layer.mergeAll( notificationAckRouteLayer, notificationRecoveryRouteLayer, assetRouteLayer, + assetAppRelayRouteLayer, assetSurfaceBindingRouteLayer, staticAndDevRouteLayer, websocketRpcRouteLayer, diff --git a/packages/contracts/src/assetClaims.test.ts b/packages/contracts/src/assetClaims.test.ts index ad648f0ea0b..dce6ddefcea 100644 --- a/packages/contracts/src/assetClaims.test.ts +++ b/packages/contracts/src/assetClaims.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; import * as Schema from "effect/Schema"; +import { EnvironmentId } from "./baseSchemas.ts"; import { AssetClaimJson } from "./assetClaims.ts"; const decodeClaim = Schema.decodeUnknownSync(AssetClaimJson); @@ -14,6 +15,8 @@ describe("asset claim codec", () => { attachmentId: "attachment-1", expiresAt: 123, dataAudience: "factory" as const, + issuingAudience: "factory" as const, + issuingBackendId: EnvironmentId.make("environment-1"), surfaceBindingId: "surface-binding-1", }; @@ -36,7 +39,33 @@ describe("asset claim codec", () => { attachmentId: "attachment-legacy", expiresAt: 123, dataAudience: "private", + issuingAudience: "private", + issuingBackendId: null, surfaceBindingId: null, }); }); + + it("decodes pre-relay audience claims as local private claims", () => { + expect( + decodeClaim( + JSON.stringify({ + version: 1, + kind: "attachment", + attachmentId: "attachment-pre-relay", + expiresAt: 123, + dataAudience: "factory", + surfaceBindingId: "surface-binding-1", + }), + ), + ).toEqual({ + version: 1, + kind: "attachment", + attachmentId: "attachment-pre-relay", + expiresAt: 123, + dataAudience: "factory", + issuingAudience: "private", + issuingBackendId: null, + surfaceBindingId: "surface-binding-1", + }); + }); }); diff --git a/packages/contracts/src/assetClaims.ts b/packages/contracts/src/assetClaims.ts index 0bf2d6d24bb..8a835bd9a6c 100644 --- a/packages/contracts/src/assetClaims.ts +++ b/packages/contracts/src/assetClaims.ts @@ -2,7 +2,7 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import * as SchemaGetter from "effect/SchemaGetter"; -import { AuthSessionId, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { AuthSessionId, EnvironmentId, TrimmedNonEmptyString } from "./baseSchemas.ts"; import { DataAudience, type DataAudience as DataAudienceType } from "./orchestration.ts"; const ASSET_SESSION_BINDING_ID_MAX_LENGTH = 256; @@ -22,6 +22,24 @@ const AssetClaimDataAudience = Schema.optionalKey(DataAudience).pipe( }), ); +// Relay routing fields were added after the initial audience-bound claim. Old +// claims remain decodable, but default to the strictest audience and the local +// backend so they can never acquire cross-backend authority during upgrade. +const AssetClaimIssuingAudience = Schema.optionalKey(DataAudience).pipe( + Schema.decodeTo(Schema.toType(DataAudience), { + decode: SchemaGetter.withDefault(Effect.succeed("private")), + encode: SchemaGetter.required(), + }), +); + +const NullableAssetIssuingBackendId = Schema.NullOr(EnvironmentId); +const AssetClaimIssuingBackendId = Schema.optionalKey(NullableAssetIssuingBackendId).pipe( + Schema.decodeTo(Schema.toType(NullableAssetIssuingBackendId), { + decode: SchemaGetter.withDefault(Effect.succeed(null)), + encode: SchemaGetter.required(), + }), +); + const NullableAssetSessionBindingId = Schema.NullOr(AssetSessionBindingId); const AssetClaimSessionBindingId = Schema.optionalKey(NullableAssetSessionBindingId).pipe( Schema.decodeTo(Schema.toType(NullableAssetSessionBindingId), { @@ -32,6 +50,8 @@ const AssetClaimSessionBindingId = Schema.optionalKey(NullableAssetSessionBindin const AudienceBoundAssetClaimFields = { dataAudience: AssetClaimDataAudience, + issuingAudience: AssetClaimIssuingAudience, + issuingBackendId: AssetClaimIssuingBackendId, surfaceBindingId: AssetClaimSessionBindingId, }; From 3d7de8cc84ebb0327528b08c490f5757e287c9a3 Mon Sep 17 00:00:00 2001 From: "wizzoapp[bot]" <254688279+wizzoapp[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:07:13 +0100 Subject: [PATCH 2/3] fix(server): address asset relay review findings --- apps/server/src/assets/AssetAccess.test.ts | 72 ++++++++--- apps/server/src/assets/AssetAccess.ts | 39 +++--- apps/server/src/http.test.ts | 11 ++ apps/server/src/http.ts | 80 ++++++++++-- apps/server/src/mcp/McpSessionRegistry.ts | 7 ++ apps/server/src/server.test.ts | 70 ++++++++++- apps/server/src/ws.ts | 140 ++++++++++++++------- 7 files changed, 318 insertions(+), 101 deletions(-) diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 5654e6c19de..5898b564ca9 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -93,11 +93,19 @@ describe("AssetAccess", () => { path: htmlPath, }, workspaceRoot: root, + dataAudience: "factory", + issuingAudience: "factory", }); const suffix = assetRouteSuffix(result.relativeUrl); const separatorIndex = suffix.indexOf("/"); const token = suffix.slice(0, separatorIndex); + expect(decodeAssetRelayRoutingClaim(token)).toMatchObject({ + kind: "workspace-file", + dataAudience: "factory", + issuingAudience: "factory", + }); + expect(yield* resolveAsset(token, "report.html")).toEqual({ kind: "file", path: canonicalHtmlPath, @@ -109,6 +117,14 @@ describe("AssetAccess", () => { expect(yield* resolveAsset(token, "../secret.txt")).toBeNull(); expect(yield* resolveAsset(token, ".env")).toBeNull(); expect(yield* resolveAsset(`${token}tampered`, "report.html")).toBeNull(); + expect( + yield* resolveLocalAssetRelay({ + token, + relativePath: "report.html", + viewerSessionId: OTHER_SURFACE_SESSION_ID, + viewerAudienceCeiling: "factory", + }), + ).toEqual({ kind: "file", path: canonicalHtmlPath }); }).pipe(Effect.provide(testLayer)), ); @@ -210,11 +226,19 @@ describe("AssetAccess", () => { path: imagePath, }, workspaceRoot: root, + dataAudience: "factory", + issuingAudience: "factory", }); const suffix = assetRouteSuffix(result.relativeUrl); const separatorIndex = suffix.indexOf("/"); const token = suffix.slice(0, separatorIndex); + expect(decodeAssetRelayRoutingClaim(token)).toMatchObject({ + kind: "workspace-file-exact", + dataAudience: "factory", + issuingAudience: "factory", + }); + expect(yield* resolveAsset(token, "icon.png")).toEqual({ kind: "file", path: canonicalImagePath, @@ -236,13 +260,16 @@ describe("AssetAccess", () => { const result = yield* issueAssetUrl({ resource: { _tag: "attachment", attachmentId }, + dataAudience: "factory", + issuingAudience: "factory", }); const suffix = assetRouteSuffix(result.relativeUrl); const separatorIndex = suffix.indexOf("/"); const token = suffix.slice(0, separatorIndex); expect(decodeAssetRelayRoutingClaim(token)).toMatchObject({ - issuingAudience: "private", + dataAudience: "factory", + issuingAudience: "factory", issuingBackendId: TEST_ENVIRONMENT_ID, }); expect(yield* resolveAsset(token, "ignored.png")).toEqual({ @@ -251,14 +278,6 @@ describe("AssetAccess", () => { }); expect(yield* resolveAsset(token, "ignored.png", OTHER_SURFACE_SESSION_ID)).toBeNull(); expect(yield* resolveAssetImpl(token, "ignored.png")).toBeNull(); - expect( - yield* resolveLocalAssetRelay({ - token, - relativePath: "ignored.png", - viewerSessionId: OTHER_SURFACE_SESSION_ID, - viewerAudienceCeiling: "private", - }), - ).toEqual({ kind: "file", path: attachmentPath }); expect( yield* resolveLocalAssetRelay({ token, @@ -266,13 +285,13 @@ describe("AssetAccess", () => { viewerSessionId: OTHER_SURFACE_SESSION_ID, viewerAudienceCeiling: "factory", }), - ).toBeNull(); + ).toEqual({ kind: "file", path: attachmentPath }); expect(result.relativeUrl).not.toContain(TEST_SURFACE_SESSION_ID); expect(result.relativeUrl).not.toContain(result.surfaceCredential); }).pipe(Effect.provide(testLayer)), ); - it.effect("requires relay capability from old clients before issuing private URLs", () => + it.effect("preserves signed direct URLs for web clients without relay capability", () => Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; const fileSystem = yield* FileSystem.FileSystem; @@ -284,15 +303,23 @@ describe("AssetAccess", () => { new Uint8Array([1, 2, 3]), ); - const error = yield* issueAssetUrlImpl({ + const result = yield* issueAssetUrlImpl({ resource: { _tag: "attachment", attachmentId }, surfaceSessionId: TEST_SURFACE_SESSION_ID, - }).pipe(Effect.flip); + }); + const suffix = assetRouteSuffix(result.relativeUrl); + const separatorIndex = suffix.indexOf("/"); + const token = suffix.slice(0, separatorIndex); - expect(error).toMatchObject({ - _tag: "AssetClientUpgradeRequiredError", - requiredCapability: ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY, - resource: { _tag: "attachment", attachmentId }, + expect(result).toEqual({ + relativeUrl: expect.stringMatching(/^\/api\/assets\/(?!relay\/)/), + expiresAt: expect.any(Number), + }); + expect(result).not.toHaveProperty("surfaceCredential"); + expect(yield* resolveAssetImpl(token, "ignored.png")).toBeNull(); + expect(yield* resolveAssetImpl(token, "ignored.png", { allowUnbound: true })).toEqual({ + kind: "file", + path: path.join(config.attachmentsDir, `${attachmentId}.png`), }); }).pipe(Effect.provide(testLayer)), ); @@ -310,9 +337,18 @@ describe("AssetAccess", () => { const faviconResult = yield* issueAssetUrl({ resource: { _tag: "project-favicon", cwd: root }, + dataAudience: "factory", + issuingAudience: "factory", }); const faviconSuffix = assetRouteSuffix(faviconResult.relativeUrl); const faviconSeparatorIndex = faviconSuffix.indexOf("/"); + expect( + decodeAssetRelayRoutingClaim(faviconSuffix.slice(0, faviconSeparatorIndex)), + ).toMatchObject({ + kind: "project-favicon", + dataAudience: "factory", + issuingAudience: "factory", + }); expect( yield* resolveAsset( faviconSuffix.slice(0, faviconSeparatorIndex), @@ -323,6 +359,8 @@ describe("AssetAccess", () => { yield* fileSystem.remove(faviconPath); const fallbackResult = yield* issueAssetUrl({ resource: { _tag: "project-favicon", cwd: root }, + dataAudience: "factory", + issuingAudience: "factory", }); expect(fallbackResult.relativeUrl.endsWith(`/${PROJECT_FAVICON_FALLBACK_MARKER}`)).toBe(true); const fallbackSuffix = assetRouteSuffix(fallbackResult.relativeUrl); diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index 78fb80b4319..351dcf13309 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -2,7 +2,6 @@ import { ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY, AssetClaimJson, AssetAttachmentNotFoundError, - AssetClientUpgradeRequiredError, AssetPreviewTypeValidationError, AssetProjectFaviconInspectionError, AssetProjectFaviconNotFoundError, @@ -219,6 +218,8 @@ const resolveCanonicalWorkspaceFileForRequest = (input: { export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (input: { readonly resource: AssetResource; readonly workspaceRoot?: string; + readonly dataAudience?: DataAudience; + readonly issuingAudience?: DataAudience; readonly clientCapabilities?: ReadonlyArray; readonly surfaceSessionId?: AuthSessionId; readonly surfaceSessionExpiresAt?: DateTime.DateTime; @@ -230,6 +231,12 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i let expiresAt = now + ASSET_TOKEN_TTL_MS; const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; const issuingBackendId = yield* serverEnvironment.getEnvironmentId; + const dataAudience = input.dataAudience ?? "private"; + const issuingAudience = input.issuingAudience ?? "private"; + if (!canReadDataAudience(issuingAudience, dataAudience)) { + return yield* new AssetWorkspaceContextNotFoundError({ resource: input.resource }); + } + const audienceClaims = { dataAudience, issuingAudience, issuingBackendId }; let claims: AssetClaim; let fileName: string; @@ -301,9 +308,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i workspaceRoot: canonicalWorkspaceRoot, relativePath: resolved.relativePath, expiresAt, - dataAudience: "private", - issuingAudience: "private", - issuingBackendId, + ...audienceClaims, surfaceBindingId: null, } : { @@ -312,9 +317,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i workspaceRoot: canonicalWorkspaceRoot, baseRelativePath: path.dirname(resolved.relativePath), expiresAt, - dataAudience: "private", - issuingAudience: "private", - issuingBackendId, + ...audienceClaims, surfaceBindingId: null, }; fileName = path.basename(resolved.relativePath); @@ -336,9 +339,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i kind: "attachment", attachmentId: input.resource.attachmentId, expiresAt, - dataAudience: "private", - issuingAudience: "private", - issuingBackendId, + ...audienceClaims, surfaceBindingId: null, }; fileName = path.basename(attachmentPath); @@ -395,9 +396,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i ), relativePath, expiresAt, - dataAudience: "private", - issuingAudience: "private", - issuingBackendId, + ...audienceClaims, surfaceBindingId: null, }; fileName = relativePath ? path.basename(relativePath) : PROJECT_FAVICON_FALLBACK_MARKER; @@ -405,13 +404,6 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i } } - if (!input.clientCapabilities?.includes(ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY)) { - return yield* new AssetClientUpgradeRequiredError({ - resource: input.resource, - requiredCapability: ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY, - }); - } - const secretStore = yield* ServerSecretStore.ServerSecretStore; const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32).pipe( Effect.mapError( @@ -422,6 +414,13 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i }), ), ); + if (!input.clientCapabilities?.includes(ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY)) { + const encodedPayload = base64UrlEncode(encodeAssetClaims(claims)); + return { + relativeUrl: `${ASSET_ROUTE_PREFIX}/${signToken(encodedPayload, signingSecret)}/${encodeURIComponent(fileName)}`, + expiresAt, + }; + } if (input.surfaceSessionId === undefined) { return yield* new AssetWorkspaceContextNotFoundError({ resource: input.resource }); } diff --git a/apps/server/src/http.test.ts b/apps/server/src/http.test.ts index f0e7787d041..c327c4b5512 100644 --- a/apps/server/src/http.test.ts +++ b/apps/server/src/http.test.ts @@ -84,4 +84,15 @@ describe("asset app relay routing", () => { ), ).toBeNull(); }); + + it("forwards valid lengths only for unencoded upstream bodies", () => { + expect(__assetRelayTesting.relayContentLength({ "content-length": "42" })).toBe(42); + expect( + __assetRelayTesting.relayContentLength({ + "content-encoding": "gzip", + "content-length": "24", + }), + ).toBeUndefined(); + expect(__assetRelayTesting.relayContentLength({ "content-length": "invalid" })).toBeUndefined(); + }); }); diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 0e33e46c64f..f8b314040fc 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -1,5 +1,6 @@ import Mime from "@effect/platform-node/Mime"; import { + type AuthAudienceCeiling, type AuthSessionId, AuthOrchestrationOperateScope, AuthOrchestrationReadScope, @@ -669,6 +670,64 @@ function safeContentLength(value: string | undefined): number | undefined { return Number.isSafeInteger(parsed) ? parsed : undefined; } +function relayContentLength(headers: Readonly>) { + return headers["content-encoding"] === undefined + ? safeContentLength(headers["content-length"]) + : undefined; +} + +type AssetRelayViewer = { + readonly sessionId: AuthSessionId; + readonly audienceCeiling: AuthAudienceCeiling; + readonly expiresAt?: DateTime.DateTime; +}; + +const authenticateAssetRelayViewer = Effect.fn("http.authenticateAssetRelayViewer")(function* ( + request: HttpServerRequest.HttpServerRequest, +): Effect.fn.Return< + AssetRelayViewer | null, + never, + EnvironmentAuth.EnvironmentAuth | SessionStore.SessionStore +> { + const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + const environmentSession = yield* serverAuth.authenticateHttpRequest(request).pipe(Effect.option); + if ( + Option.isSome(environmentSession) && + environmentSession.value.scopes.includes(AuthOrchestrationReadScope) + ) { + return { + sessionId: environmentSession.value.sessionId, + audienceCeiling: environmentSession.value.audienceCeiling, + ...(environmentSession.value.expiresAt + ? { expiresAt: environmentSession.value.expiresAt } + : {}), + }; + } + + const authorization = request.headers.authorization; + const rawToken = + authorization?.startsWith("Bearer ") === true + ? authorization.slice("Bearer ".length).trim() + : ""; + const invocation = yield* McpSessionRegistry.resolveActiveMcpInvocation(rawToken); + if (invocation?.credentialKind !== "peer" || invocation.sourceSessionId === undefined) { + return null; + } + + const sessions = yield* SessionStore.SessionStore; + const sourceSession = (yield* sessions.listActive().pipe(Effect.orElseSucceed(() => []))).find( + (session) => session.sessionId === invocation.sourceSessionId, + ); + if (sourceSession === undefined || !sourceSession.scopes.includes(AuthOrchestrationReadScope)) { + return null; + } + return { + sessionId: sourceSession.sessionId, + audienceCeiling: sourceSession.audienceCeiling, + expiresAt: sourceSession.expiresAt, + }; +}); + const makeAssetAppRelayRouteLayer = ( peerRegistry: SubagentPeerRegistry.SubagentPeerRegistryShape, ) => @@ -682,20 +741,14 @@ const makeAssetAppRelayRouteLayer = ( const parsed = parseAssetRelayPath(url.value.pathname); if (parsed === null) return maskedAssetRelayResponse(); - const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; - const viewer = yield* serverAuth.authenticateHttpRequest(request).pipe(Effect.option); - if (Option.isNone(viewer) || !viewer.value.scopes.includes(AuthOrchestrationReadScope)) { - return maskedAssetRelayResponse(); - } + const viewer = yield* authenticateAssetRelayViewer(request); + if (viewer === null) return maskedAssetRelayResponse(); const routingClaim = decodeAssetRelayRoutingClaim(parsed.token); if ( routingClaim === null || routingClaim.expiresAt <= (yield* Clock.currentTimeMillis) || - !canReadDataAudience( - viewer.value.audienceCeiling, - effectiveAssetClaimAudience(routingClaim), - ) + !canReadDataAudience(viewer.audienceCeiling, effectiveAssetClaimAudience(routingClaim)) ) { return maskedAssetRelayResponse(); } @@ -709,9 +762,9 @@ const makeAssetAppRelayRouteLayer = ( const asset = yield* resolveLocalAssetRelay({ token: parsed.token, relativePath: parsed.relativePath, - viewerSessionId: viewer.value.sessionId, - viewerAudienceCeiling: viewer.value.audienceCeiling, - ...(viewer.value.expiresAt ? { viewerSessionExpiresAt: viewer.value.expiresAt } : {}), + viewerSessionId: viewer.sessionId, + viewerAudienceCeiling: viewer.audienceCeiling, + ...(viewer.expiresAt ? { viewerSessionExpiresAt: viewer.expiresAt } : {}), }); if (asset === null) return maskedAssetRelayResponse(); return yield* HttpServerResponse.file(asset.path, { @@ -739,7 +792,7 @@ const makeAssetAppRelayRouteLayer = ( if (upstream === null || upstream.status !== 200) return maskedAssetRelayResponse(); const contentType = upstream.headers["content-type"]; - const contentLength = safeContentLength(upstream.headers["content-length"]); + const contentLength = relayContentLength(upstream.headers); return HttpServerResponse.stream(upstream.stream, { status: 200, headers: { @@ -763,6 +816,7 @@ export const assetAppRelayRouteLayer = Layer.unwrap( ).pipe(Layer.provide(SubagentPeerRegistry.layer)); export const __assetRelayTesting = { + relayContentLength, selectAssetRelayPeer, trustedAssetRelayHeaders, }; diff --git a/apps/server/src/mcp/McpSessionRegistry.ts b/apps/server/src/mcp/McpSessionRegistry.ts index 6c30824c3ec..4d35719cde0 100644 --- a/apps/server/src/mcp/McpSessionRegistry.ts +++ b/apps/server/src/mcp/McpSessionRegistry.ts @@ -609,6 +609,13 @@ export const issueActiveMcpPeerCredential = ( .pipe(Effect.map((issued): McpIssuedPeerCredential | undefined => issued)) : Effect.sync((): McpIssuedPeerCredential | undefined => undefined); +export const resolveActiveMcpInvocation = ( + rawToken: string, +): Effect.Effect => + activeMcpSessionRegistry + ? activeMcpSessionRegistry.resolve(rawToken) + : Effect.sync((): McpInvocationContext.McpInvocationScope | undefined => undefined); + export const revokeActiveMcpThread = (threadId: ThreadId): Effect.Effect => activeMcpSessionRegistry ? activeMcpSessionRegistry.revokeThread(threadId) : Effect.void; diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 3673b7fd39e..ac85fbeffd4 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -138,6 +138,7 @@ import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; import * as DeviceNotifications from "./notifications/DeviceNotifications.ts"; import * as WorkerProcessIsolation from "./process/WorkerProcessIsolation.ts"; import * as McpSessionRegistry from "./mcp/McpSessionRegistry.ts"; +import { decodeAssetRelayRoutingClaim } from "./assets/AssetAccess.ts"; import { collectUint8StreamText } from "./stream/collectUint8StreamText.ts"; import * as Data from "effect/Data"; @@ -2382,13 +2383,38 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const config = yield* buildAppUnderTest(); + const orphanedThreadId = ThreadId.make("thread-asset-orphaned-project"); + const orphanedProjectId = ProjectId.make("project-asset-orphaned"); + const orphanedAttachmentId = + "thread-asset-orphaned-project-00000000-0000-4000-8000-000000000001"; + const orphanedReadModel = makeDefaultOrchestrationReadModel(); + const config = yield* buildAppUnderTest({ + layers: { + projectionSnapshotQuery: { + getCommandReadModel: () => + Effect.succeed({ + ...orphanedReadModel, + projects: [], + threads: orphanedReadModel.threads.map((thread) => ({ + ...thread, + id: orphanedThreadId, + projectId: orphanedProjectId, + dataAudience: "factory" as const, + })), + }), + }, + }, + }); const attachmentId = "asset-session-binding-integration"; yield* fileSystem.makeDirectory(config.attachmentsDir, { recursive: true }); yield* fileSystem.writeFileString( path.join(config.attachmentsDir, `${attachmentId}.bin`), "same-surface-private-asset", ); + yield* fileSystem.writeFileString( + path.join(config.attachmentsDir, `${orphanedAttachmentId}.bin`), + "orphaned-project-private-asset", + ); const issuerCookie = yield* getAuthenticatedSessionCookieHeader(); const otherCookie = yield* getAuthenticatedSessionCookieHeader(); @@ -2413,12 +2439,30 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const rpc = (f: (client: WsRpcClient) => Effect.Effect) => Effect.scoped(withWsRpcClient(wsUrl, f)); - const legacyError = yield* rpc((client) => + const orphanedIssued = yield* rpc((client) => + client[WS_METHODS.assetsCreateUrl]({ + resource: { _tag: "attachment", attachmentId: orphanedAttachmentId }, + capabilities: [ASSET_SAME_ORIGIN_RELAY_V1_CAPABILITY], + }), + ); + const orphanedToken = orphanedIssued.relativeUrl.split("/").at(-2); + assert.isDefined(orphanedToken); + assert.deepInclude(decodeAssetRelayRoutingClaim(orphanedToken ?? ""), { + dataAudience: "private", + issuingAudience: "private", + }); + + const legacyIssued = yield* rpc((client) => client[WS_METHODS.assetsCreateUrl]({ resource: { _tag: "attachment", attachmentId }, - }).pipe(Effect.flip), + }), ); - assert.equal(legacyError._tag, "AssetClientUpgradeRequiredError"); + assert.include(legacyIssued.relativeUrl, "/api/assets/"); + assert.notInclude(legacyIssued.relativeUrl, "/api/assets/relay/"); + assert.notProperty(legacyIssued, "surfaceCredential"); + const legacyResponse = yield* fetchEffect(yield* getHttpServerUrl(legacyIssued.relativeUrl)); + assert.equal(legacyResponse.status, 200); + assert.equal(yield* legacyResponse.text, "same-surface-private-asset"); const issued = yield* rpc((client) => client[WS_METHODS.assetsCreateUrl]({ @@ -2464,6 +2508,24 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.isUndefined(sameSurfaceRelay.headers.location); assert.isUndefined(sameSurfaceRelay.headers["set-cookie"]); assert.equal(sameSurfaceRelayBody, "same-surface-private-asset"); + + const relaySourceToken = yield* getAuthenticatedBearerSessionToken(); + const peerTokenResponse = yield* fetchEffect(yield* getHttpServerUrl("/api/mcp/peer-token"), { + method: "POST", + headers: { + authorization: `Bearer ${relaySourceToken}`, + "content-type": "application/json", + }, + body: jsonRequestBody({ sourceEnvironmentId: "asset-relay-source" }), + }); + assert.equal(peerTokenResponse.status, 200); + const peerToken = yield* responseJsonEffect<{ readonly token: string }>(peerTokenResponse); + const peerRelayResponse = yield* fetchEffect(yield* getHttpServerUrl(appRelayPath), { + headers: { authorization: `Bearer ${peerToken.token}` }, + }); + assert.equal(peerRelayResponse.status, 200); + assert.equal(yield* peerRelayResponse.text, "same-surface-private-asset"); + const otherPrivateSurfaceRelay = yield* fetchEffect(yield* getHttpServerUrl(appRelayPath), { headers: { cookie: otherCookie }, }); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index c031edfbff1..9e0dbbb4af6 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -18,9 +18,11 @@ import { AuthAccessReadScope, AuthAccessStreamError, type AuthAccessStreamEvent, + type AssetResource, type AuthEnvironmentScope, AuthSessionId, type DiscoveredLocalServerList, + type DataAudience, type OrchestrationCommand, type GitActionProgressEvent, type GitManagerServiceError, @@ -94,6 +96,10 @@ import * as PlanUsageSnapshot from "./usage/PlanUsageSnapshot.ts"; import * as TerminalManager from "./terminal/Manager.ts"; import * as PreviewManager from "./preview/Manager.ts"; import { issueAssetUrl } from "./assets/AssetAccess.ts"; +import { + parseThreadSegmentFromAttachmentId, + toSafeThreadAttachmentSegment, +} from "./attachmentStore.ts"; import * as PortScanner from "./preview/PortScanner.ts"; import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; @@ -138,6 +144,9 @@ const isOrchestrationScheduledTaskMutationError = Schema.is( ); const SHELL_REPLAY_SNAPSHOT_GAP_THRESHOLD = 1_000; +const strictestDataAudience = (left: DataAudience, right: DataAudience): DataAudience => + left === "private" || right === "private" ? "private" : "factory"; + function unexpectedCompatibilityError(error: never): never { throw new Error(`Unhandled compatibility error: ${String(error)}`); } @@ -454,6 +463,71 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => ...currentSession, scopes: new Set(currentSession.scopes), }; + const classifyAttachmentAudience = Effect.fn("ws.classifyAttachmentAudience")(function* ( + attachmentId: string, + ) { + const threadSegment = parseThreadSegmentFromAttachmentId(attachmentId); + if (threadSegment === null) return null; + + const snapshot = yield* projectionSnapshotQuery.getCommandReadModel(); + const projectById = new Map(snapshot.projects.map((project) => [project.id, project])); + let audience: DataAudience | null = null; + for (const thread of snapshot.threads) { + if (toSafeThreadAttachmentSegment(thread.id) !== threadSegment) continue; + const project = projectById.get(thread.projectId); + const threadAudience = + project === undefined + ? "private" + : strictestDataAudience(thread.dataAudience, project.dataAudience); + audience = + audience === null ? threadAudience : strictestDataAudience(audience, threadAudience); + } + return audience; + }); + const resolveAssetContext = Effect.fn("ws.resolveAssetContext")(function* ( + resource: AssetResource, + ) { + switch (resource._tag) { + case "workspace-file": { + const thread = yield* projectionSnapshotQuery.getThreadShellById(resource.threadId); + if (Option.isNone(thread)) { + return yield* new AssetWorkspaceContextNotFoundError({ resource }); + } + const project = yield* projectionSnapshotQuery.getProjectShellById( + thread.value.projectId, + ); + if (Option.isNone(project)) { + return yield* new AssetWorkspaceContextNotFoundError({ resource }); + } + return { + dataAudience: strictestDataAudience( + thread.value.dataAudience, + project.value.dataAudience, + ), + workspaceRoot: thread.value.worktreePath ?? project.value.workspaceRoot, + }; + } + case "attachment": { + const dataAudience = yield* classifyAttachmentAudience(resource.attachmentId); + if (dataAudience === null) { + if (currentSession.audienceCeiling === "private") { + return { dataAudience: "private" as const }; + } + return yield* new AssetWorkspaceContextNotFoundError({ resource }); + } + return { dataAudience }; + } + case "project-favicon": { + const project = yield* projectionSnapshotQuery.getActiveProjectByWorkspaceRoot( + resource.cwd, + ); + if (Option.isNone(project)) { + return yield* new AssetWorkspaceContextNotFoundError({ resource }); + } + return { dataAudience: project.value.dataAudience }; + } + } + }); const authorizationError = (requiredScope: AuthEnvironmentScope, method: string) => new EnvironmentAuthorizationError({ message: currentSession.scopes.includes(requiredScope) @@ -1760,59 +1834,31 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => [WS_METHODS.assetsCreateUrl]: (input) => observeRpcEffect( WS_METHODS.assetsCreateUrl, - Effect.gen(function* () { - if (input.resource._tag !== "workspace-file") { - return yield* issueAssetUrl({ + resolveAssetContext(input.resource).pipe( + Effect.mapError((cause) => + cause._tag === "AssetWorkspaceContextNotFoundError" + ? cause + : new AssetWorkspaceContextResolutionError({ + resource: input.resource, + cause, + }), + ), + Effect.flatMap((context) => + issueAssetUrl({ resource: input.resource, + ...(context.workspaceRoot === undefined + ? {} + : { workspaceRoot: context.workspaceRoot }), + dataAudience: context.dataAudience, + issuingAudience: currentSession.audienceCeiling, ...(input.capabilities ? { clientCapabilities: input.capabilities } : {}), surfaceSessionId: currentSession.sessionId, ...(currentSession.expiresAt ? { surfaceSessionExpiresAt: currentSession.expiresAt } : {}), - }); - } - const thread = yield* projectionSnapshotQuery - .getThreadShellById(input.resource.threadId) - .pipe( - Effect.mapError( - (cause) => - new AssetWorkspaceContextResolutionError({ - resource: input.resource, - cause, - }), - ), - ); - if (Option.isNone(thread)) { - return yield* new AssetWorkspaceContextNotFoundError({ - resource: input.resource, - }); - } - const project = yield* projectionSnapshotQuery - .getProjectShellById(thread.value.projectId) - .pipe( - Effect.mapError( - (cause) => - new AssetWorkspaceContextResolutionError({ - resource: input.resource, - cause, - }), - ), - ); - if (Option.isNone(project)) { - return yield* new AssetWorkspaceContextNotFoundError({ - resource: input.resource, - }); - } - return yield* issueAssetUrl({ - resource: input.resource, - workspaceRoot: thread.value.worktreePath ?? project.value.workspaceRoot, - ...(input.capabilities ? { clientCapabilities: input.capabilities } : {}), - surfaceSessionId: currentSession.sessionId, - ...(currentSession.expiresAt - ? { surfaceSessionExpiresAt: currentSession.expiresAt } - : {}), - }); - }), + }), + ), + ), { "rpc.aggregate": "workspace" }, ), [WS_METHODS.subscribeVcsStatus]: (input) => From 20655317ef2d960274aa1dd81b8dadd3d1458ca8 Mon Sep 17 00:00:00 2001 From: "wizzoapp[bot]" <254688279+wizzoapp[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:21:25 +0100 Subject: [PATCH 3/3] fix(server): preserve encoded relay paths --- apps/server/src/assets/AssetAccess.test.ts | 12 ++++++++++-- apps/server/src/assets/AssetAccess.ts | 6 +++--- apps/server/src/http.test.ts | 11 +++++++++++ apps/server/src/http.ts | 7 ++++--- 4 files changed, 28 insertions(+), 8 deletions(-) diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 5898b564ca9..daf35cdc7f8 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -80,11 +80,14 @@ describe("AssetAccess", () => { }); const htmlPath = path.join(root, "report.html"); const cssPath = path.join(root, "report.css"); + const percentCssPath = path.join(root, "theme%20.css"); yield* fileSystem.writeFileString(htmlPath, ''); yield* fileSystem.writeFileString(cssPath, "body { color: red; }"); + yield* fileSystem.writeFileString(percentCssPath, "body { color: blue; }"); yield* fileSystem.writeFileString(path.join(root, ".env"), "SECRET=value"); const canonicalHtmlPath = yield* fileSystem.realPath(htmlPath); const canonicalCssPath = yield* fileSystem.realPath(cssPath); + const canonicalPercentCssPath = yield* fileSystem.realPath(percentCssPath); const result = yield* issueAssetUrl({ resource: { @@ -114,13 +117,18 @@ describe("AssetAccess", () => { kind: "file", path: canonicalCssPath, }); + expect(yield* resolveAsset(token, "theme%2520.css")).toEqual({ + kind: "file", + path: canonicalPercentCssPath, + }); + expect(yield* resolveAsset(token, "broken%ZZ.css")).toBeNull(); expect(yield* resolveAsset(token, "../secret.txt")).toBeNull(); expect(yield* resolveAsset(token, ".env")).toBeNull(); expect(yield* resolveAsset(`${token}tampered`, "report.html")).toBeNull(); expect( yield* resolveLocalAssetRelay({ token, - relativePath: "report.html", + encodedRelativePath: "report.html", viewerSessionId: OTHER_SURFACE_SESSION_ID, viewerAudienceCeiling: "factory", }), @@ -281,7 +289,7 @@ describe("AssetAccess", () => { expect( yield* resolveLocalAssetRelay({ token, - relativePath: "ignored.png", + encodedRelativePath: "ignored.png", viewerSessionId: OTHER_SURFACE_SESSION_ID, viewerAudienceCeiling: "factory", }), diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index 351dcf13309..7f2be716ef7 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -586,7 +586,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( export const resolveLocalAssetRelay = Effect.fn("AssetAccess.resolveLocalAssetRelay")( function* (input: { readonly token: string; - readonly relativePath: string; + readonly encodedRelativePath: string; readonly viewerSessionId: AuthSessionId; readonly viewerAudienceCeiling: AuthAudienceCeiling; readonly viewerSessionExpiresAt?: DateTime.DateTime; @@ -603,7 +603,7 @@ export const resolveLocalAssetRelay = Effect.fn("AssetAccess.resolveLocalAssetRe if (claim.surfaceBindingId === null) { return audience === "private" ? null - : yield* resolveAsset(input.token, input.relativePath, { allowUnbound: true }); + : yield* resolveAsset(input.token, input.encodedRelativePath, { allowUnbound: true }); } const now = yield* Clock.currentTimeMillis; @@ -624,7 +624,7 @@ export const resolveLocalAssetRelay = Effect.fn("AssetAccess.resolveLocalAssetRe ), signingSecret, ); - return yield* resolveAsset(input.token, input.relativePath, { + return yield* resolveAsset(input.token, input.encodedRelativePath, { surfaceCredentials: [relayProof], }); }, diff --git a/apps/server/src/http.test.ts b/apps/server/src/http.test.ts index c327c4b5512..1c591756db5 100644 --- a/apps/server/src/http.test.ts +++ b/apps/server/src/http.test.ts @@ -39,6 +39,17 @@ describe("http dev routing", () => { }); describe("asset app relay routing", () => { + it("preserves the encoded asset path for the single downstream decode boundary", () => { + expect( + __assetRelayTesting.parseAssetRelayPath( + "/_asset-relay/signed-token/styles/my%20theme%23snowman-%E2%98%83.css", + ), + ).toEqual({ + token: "signed-token", + encodedRelativePath: "styles/my%20theme%23snowman-%E2%98%83.css", + }); + }); + it("selects only the peer whose environment id issued the claim", () => { const peers = [peer("backend-a"), peer("backend-b")]; diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index f8b314040fc..49b0cd9a610 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -626,7 +626,7 @@ const maskedAssetRelayResponse = () => function parseAssetRelayPath(pathname: string): { readonly token: string; - readonly relativePath: string; + readonly encodedRelativePath: string; } | null { const prefix = `${ASSET_APP_RELAY_PREFIX}/`; if (!pathname.startsWith(prefix)) return null; @@ -636,7 +636,7 @@ function parseAssetRelayPath(pathname: string): { if (token.length === 0 || token.length > ASSET_RELAY_CLAIM_MAX_LENGTH) return null; return { token, - relativePath: separatorIndex === -1 ? "" : suffix.slice(separatorIndex + 1), + encodedRelativePath: separatorIndex === -1 ? "" : suffix.slice(separatorIndex + 1), }; } @@ -761,7 +761,7 @@ const makeAssetAppRelayRouteLayer = ( ) { const asset = yield* resolveLocalAssetRelay({ token: parsed.token, - relativePath: parsed.relativePath, + encodedRelativePath: parsed.encodedRelativePath, viewerSessionId: viewer.sessionId, viewerAudienceCeiling: viewer.audienceCeiling, ...(viewer.expiresAt ? { viewerSessionExpiresAt: viewer.expiresAt } : {}), @@ -816,6 +816,7 @@ export const assetAppRelayRouteLayer = Layer.unwrap( ).pipe(Layer.provide(SubagentPeerRegistry.layer)); export const __assetRelayTesting = { + parseAssetRelayPath, relayContentLength, selectAssetRelayPeer, trustedAssetRelayHeaders,