diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts
index 7ec72b78023..daf35cdc7f8 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", () => {
@@ -67,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: {
@@ -80,11 +96,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,
@@ -93,9 +117,22 @@ 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,
+ encodedRelativePath: "report.html",
+ viewerSessionId: OTHER_SURFACE_SESSION_ID,
+ viewerAudienceCeiling: "factory",
+ }),
+ ).toEqual({ kind: "file", path: canonicalHtmlPath });
}).pipe(Effect.provide(testLayer)),
);
@@ -197,11 +234,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,
@@ -223,23 +268,38 @@ 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({
+ dataAudience: "factory",
+ issuingAudience: "factory",
+ 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,
+ encodedRelativePath: "ignored.png",
+ viewerSessionId: OTHER_SURFACE_SESSION_ID,
+ viewerAudienceCeiling: "factory",
+ }),
+ ).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("preserves signed direct URLs for clients without surface credentials", () =>
+ 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;
@@ -263,12 +323,12 @@ describe("AssetAccess", () => {
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`) });
+ expect(yield* resolveAssetImpl(token, "ignored.png", { allowUnbound: true })).toEqual({
+ kind: "file",
+ path: path.join(config.attachmentsDir, `${attachmentId}.png`),
+ });
}).pipe(Effect.provide(testLayer)),
);
@@ -285,9 +345,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),
@@ -298,6 +367,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 6d11fdc54b6..7f2be716ef7 100644
--- a/apps/server/src/assets/AssetAccess.ts
+++ b/apps/server/src/assets/AssetAccess.ts
@@ -17,7 +17,9 @@ import {
type AssetClaim,
type AssetClientCapability,
type AssetResource,
+ type AuthAudienceCeiling,
type AuthSessionId,
+ type DataAudience,
} from "@t3tools/contracts";
import {
isWorkspaceImagePreviewPath,
@@ -43,8 +45,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 +56,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 +119,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);
}
@@ -182,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;
@@ -191,6 +229,14 @@ 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;
+ 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;
@@ -262,7 +308,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
workspaceRoot: canonicalWorkspaceRoot,
relativePath: resolved.relativePath,
expiresAt,
- dataAudience: "private",
+ ...audienceClaims,
surfaceBindingId: null,
}
: {
@@ -271,7 +317,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
workspaceRoot: canonicalWorkspaceRoot,
baseRelativePath: path.dirname(resolved.relativePath),
expiresAt,
- dataAudience: "private",
+ ...audienceClaims,
surfaceBindingId: null,
};
fileName = path.basename(resolved.relativePath);
@@ -293,7 +339,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
kind: "attachment",
attachmentId: input.resource.attachmentId,
expiresAt,
- dataAudience: "private",
+ ...audienceClaims,
surfaceBindingId: null,
};
fileName = path.basename(attachmentPath);
@@ -350,7 +396,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
),
relativePath,
expiresAt,
- dataAudience: "private",
+ ...audienceClaims,
surfaceBindingId: null,
};
fileName = relativePath ? path.basename(relativePath) : PROJECT_FAVICON_FALLBACK_MARKER;
@@ -452,20 +498,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 +582,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 encodedRelativePath: 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.encodedRelativePath, { 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.encodedRelativePath, {
+ surfaceCredentials: [relayProof],
+ });
+ },
+);
diff --git a/apps/server/src/http.test.ts b/apps/server/src/http.test.ts
index a1d4230e75c..1c591756db5 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,73 @@ 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")];
+
+ 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();
+ });
+
+ 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 437ba791ae1..49b0cd9a610 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,
@@ -10,6 +11,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 +24,9 @@ import * as Cookies from "effect/unstable/http/Cookies";
import {
HttpBody,
HttpClient,
+ HttpClientRequest,
HttpClientResponse,
+ FetchHttpClient,
HttpRouter,
HttpServerResponse,
HttpServerRequest,
@@ -33,13 +37,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 +65,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 +78,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 +617,211 @@ 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 encodedRelativePath: 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,
+ encodedRelativePath: 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;
+}
+
+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,
+) =>
+ 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 viewer = yield* authenticateAssetRelayViewer(request);
+ if (viewer === null) return maskedAssetRelayResponse();
+
+ const routingClaim = decodeAssetRelayRoutingClaim(parsed.token);
+ if (
+ routingClaim === null ||
+ routingClaim.expiresAt <= (yield* Clock.currentTimeMillis) ||
+ !canReadDataAudience(viewer.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,
+ encodedRelativePath: parsed.encodedRelativePath,
+ viewerSessionId: viewer.sessionId,
+ viewerAudienceCeiling: viewer.audienceCeiling,
+ ...(viewer.expiresAt ? { viewerSessionExpiresAt: viewer.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 = relayContentLength(upstream.headers);
+ 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 = {
+ parseAssetRelayPath,
+ relayContentLength,
+ 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/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 7de0ede0333..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,16 +2383,54 @@ 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();
+ 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,6 +2439,19 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
const rpc = (f: (client: WsRpcClient) => Effect.Effect) =>
Effect.scoped(withWsRpcClient(wsUrl, f));
+ 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 },
@@ -2442,6 +2494,48 @@ 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 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 },
+ });
+ 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/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) =>
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,
};