Merge branch 'main' into fix/webapp-sanitize-prisma-leaks
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
---
|
||||
area: webapp
|
||||
type: fix
|
||||
---
|
||||
|
||||
Pin chat.agent session snapshots to a single object store so writes and reads
|
||||
always round-trip through the same provider when `OBJECT_STORE_DEFAULT_PROTOCOL`
|
||||
is set.
|
||||
@@ -0,0 +1,69 @@
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { $replica } from "~/db.server";
|
||||
import { chatSnapshotStoragePathForSession } from "~/services/realtime/chatSnapshot.server";
|
||||
import { resolveSessionByIdOrExternalId } from "~/services/realtime/sessions.server";
|
||||
import {
|
||||
createActionApiRoute,
|
||||
createLoaderApiRoute,
|
||||
} from "~/services/routeBuilders/apiBuilder.server";
|
||||
import { generatePresignedUrl } from "~/v3/objectStore.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
sessionId: z.string(),
|
||||
});
|
||||
|
||||
// `chatSnapshotStoragePath` is stamped on every new Session at row creation
|
||||
// (see api.v1.sessions.ts). The fallback handles sessions created before
|
||||
// the column existed — read against the currently-configured default
|
||||
// protocol and compute the same path the SDK uploaded under.
|
||||
function snapshotKey(session: { friendlyId: string; chatSnapshotStoragePath: string | null }) {
|
||||
return session.chatSnapshotStoragePath ?? chatSnapshotStoragePathForSession(session.friendlyId);
|
||||
}
|
||||
|
||||
const routeConfig = {
|
||||
params: ParamsSchema,
|
||||
allowJWT: true,
|
||||
corsStrategy: "all" as const,
|
||||
findResource: async (params: z.infer<typeof ParamsSchema>, auth: { environment: { id: string } }) =>
|
||||
resolveSessionByIdOrExternalId($replica, auth.environment.id, params.sessionId),
|
||||
};
|
||||
|
||||
export const { action } = createActionApiRoute(
|
||||
{ ...routeConfig, method: "PUT" },
|
||||
async ({ authentication, resource: session }) => {
|
||||
if (!session) {
|
||||
return json({ error: "Session not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const signed = await generatePresignedUrl(
|
||||
authentication.environment.project.externalRef,
|
||||
authentication.environment.slug,
|
||||
snapshotKey(session),
|
||||
"PUT"
|
||||
);
|
||||
if (!signed.success) {
|
||||
return json({ error: `Failed to generate presigned URL: ${signed.error}` }, { status: 500 });
|
||||
}
|
||||
|
||||
return json({ presignedUrl: signed.url });
|
||||
}
|
||||
);
|
||||
|
||||
export const loader = createLoaderApiRoute(routeConfig, async ({ authentication, resource: session }) => {
|
||||
if (!session) {
|
||||
return json({ error: "Session not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const signed = await generatePresignedUrl(
|
||||
authentication.environment.project.externalRef,
|
||||
authentication.environment.slug,
|
||||
snapshotKey(session),
|
||||
"GET"
|
||||
);
|
||||
if (!signed.success) {
|
||||
return json({ error: `Failed to generate presigned URL: ${signed.error}` }, { status: 500 });
|
||||
}
|
||||
|
||||
return json({ presignedUrl: signed.url });
|
||||
});
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
ensureRunForSession,
|
||||
type SessionTriggerConfig,
|
||||
} from "~/services/realtime/sessionRunManager.server";
|
||||
import { chatSnapshotStoragePathForSession } from "~/services/realtime/chatSnapshot.server";
|
||||
import { serializeSession } from "~/services/realtime/sessions.server";
|
||||
import { SessionsRepository } from "~/services/sessionsRepository/sessionsRepository.server";
|
||||
import {
|
||||
@@ -181,6 +182,7 @@ const { action } = createActionApiRoute(
|
||||
environmentType: authentication.environment.type,
|
||||
organizationId: authentication.environment.organizationId,
|
||||
streamBasinName: authentication.environment.organization.streamBasinName,
|
||||
chatSnapshotStoragePath: chatSnapshotStoragePathForSession(friendlyId),
|
||||
},
|
||||
update: { triggerConfig: triggerConfigJson },
|
||||
});
|
||||
@@ -201,6 +203,7 @@ const { action } = createActionApiRoute(
|
||||
environmentType: authentication.environment.type,
|
||||
organizationId: authentication.environment.organizationId,
|
||||
streamBasinName: authentication.environment.organization.streamBasinName,
|
||||
chatSnapshotStoragePath: chatSnapshotStoragePathForSession(friendlyId),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ export const apiRateLimiter = authorizationRateLimitMiddleware({
|
||||
// customer-facing surface so customer rate limits shouldn't apply.
|
||||
/^\/api\/v1\/packets\//,
|
||||
/^\/api\/v2\/packets\//,
|
||||
/^\/api\/v1\/sessions\/[^\/]+\/snapshot-url$/,
|
||||
],
|
||||
log: {
|
||||
rejections: env.API_RATE_LIMIT_REJECTION_LOGS_ENABLED === "1",
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { env } from "~/env.server";
|
||||
|
||||
/**
|
||||
* Canonical storage URI for a session's chat.agent snapshot. Stamped on
|
||||
* `Session.chatSnapshotStoragePath` at row creation so PUT/GET presigns
|
||||
* resolve to the same store even if `OBJECT_STORE_DEFAULT_PROTOCOL`
|
||||
* changes later.
|
||||
*/
|
||||
export function chatSnapshotStoragePathForSession(friendlyId: string): string {
|
||||
const path = `sessions/${friendlyId}/snapshot.json`;
|
||||
const protocol = env.OBJECT_STORE_DEFAULT_PROTOCOL;
|
||||
return protocol ? `${protocol}://${path}` : path;
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
import type { UIMessage } from "ai";
|
||||
import { afterEach, describe, expect, vi } from "vitest";
|
||||
import { env } from "~/env.server";
|
||||
import { chatSnapshotStoragePathForSession } from "~/services/realtime/chatSnapshot.server";
|
||||
import { generatePresignedUrl } from "~/v3/objectStore.server";
|
||||
|
||||
vi.setConfig({ testTimeout: 60_000 });
|
||||
@@ -54,22 +55,21 @@ function makeSnapshot(opts: { messages?: UIMessage[]; lastOutEventId?: string }
|
||||
|
||||
/**
|
||||
* Stub `apiClientManager.clientOrThrow()` so the SDK helpers see a fake
|
||||
* api client whose `getPayloadUrl` / `createUploadPayloadUrl` return
|
||||
* presigned URLs minted by the webapp's real `generatePresignedUrl`
|
||||
* (which signs against MinIO).
|
||||
*
|
||||
* The SDK helpers internally do `fetch(presignedUrl, ...)` to read/write
|
||||
* the blob, so MinIO ends up holding the actual bytes.
|
||||
* api client. Mirrors the snapshot-url route: derive the canonical
|
||||
* `sessions/{id}/snapshot.json` key (with optional default-protocol
|
||||
* prefix) and sign it via `generatePresignedUrl` against MinIO.
|
||||
*/
|
||||
function stubApiClient(opts: { projectRef: string; envSlug: string }) {
|
||||
vi.spyOn(apiClientManager, "clientOrThrow").mockReturnValue({
|
||||
async getPayloadUrl(filename: string) {
|
||||
const result = await generatePresignedUrl(opts.projectRef, opts.envSlug, filename, "GET");
|
||||
async getChatSnapshotUrl(sessionId: string) {
|
||||
const key = chatSnapshotStoragePathForSession(sessionId);
|
||||
const result = await generatePresignedUrl(opts.projectRef, opts.envSlug, key, "GET");
|
||||
if (!result.success) throw new Error(result.error);
|
||||
return { presignedUrl: result.url };
|
||||
},
|
||||
async createUploadPayloadUrl(filename: string) {
|
||||
const result = await generatePresignedUrl(opts.projectRef, opts.envSlug, filename, "PUT");
|
||||
async createChatSnapshotUploadUrl(sessionId: string) {
|
||||
const key = chatSnapshotStoragePathForSession(sessionId);
|
||||
const result = await generatePresignedUrl(opts.projectRef, opts.envSlug, key, "PUT");
|
||||
if (!result.success) throw new Error(result.error);
|
||||
return { presignedUrl: result.url };
|
||||
},
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
import type { UIMessageChunk } from "ai";
|
||||
import { afterEach, describe, expect, vi } from "vitest";
|
||||
import { env } from "~/env.server";
|
||||
import { chatSnapshotStoragePathForSession } from "~/services/realtime/chatSnapshot.server";
|
||||
import { generatePresignedUrl } from "~/v3/objectStore.server";
|
||||
|
||||
vi.setConfig({ testTimeout: 60_000 });
|
||||
@@ -77,13 +78,15 @@ function stubApiClient(opts: {
|
||||
})
|
||||
);
|
||||
vi.spyOn(apiClientManager, "clientOrThrow").mockReturnValue({
|
||||
async getPayloadUrl(filename: string) {
|
||||
const result = await generatePresignedUrl(opts.projectRef, opts.envSlug, filename, "GET");
|
||||
async getChatSnapshotUrl(sessionId: string) {
|
||||
const key = chatSnapshotStoragePathForSession(sessionId);
|
||||
const result = await generatePresignedUrl(opts.projectRef, opts.envSlug, key, "GET");
|
||||
if (!result.success) throw new Error(result.error);
|
||||
return { presignedUrl: result.url };
|
||||
},
|
||||
async createUploadPayloadUrl(filename: string) {
|
||||
const result = await generatePresignedUrl(opts.projectRef, opts.envSlug, filename, "PUT");
|
||||
async createChatSnapshotUploadUrl(sessionId: string) {
|
||||
const key = chatSnapshotStoragePathForSession(sessionId);
|
||||
const result = await generatePresignedUrl(opts.projectRef, opts.envSlug, key, "PUT");
|
||||
if (!result.success) throw new Error(result.error);
|
||||
return { presignedUrl: result.url };
|
||||
},
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
ALTER TABLE "Session" ADD COLUMN IF NOT EXISTS "chatSnapshotStoragePath" TEXT;
|
||||
@@ -830,6 +830,11 @@ model Session {
|
||||
/// (OSS, or pre-backfill); reads fall back to the global basin.
|
||||
streamBasinName String?
|
||||
|
||||
/// Storage URI (with protocol prefix) for this session's chat.agent
|
||||
/// snapshot blob. Set on first snapshot write. Null = pre-column session,
|
||||
/// fall back to computed default path.
|
||||
chatSnapshotStoragePath String?
|
||||
|
||||
runs SessionRun[]
|
||||
|
||||
/// Idempotency: `(env, externalId)` uniquely identifies a session.
|
||||
|
||||
@@ -602,6 +602,32 @@ export class ApiClient {
|
||||
);
|
||||
}
|
||||
|
||||
/** Presigned PUT URL for a `chat.agent` session snapshot. */
|
||||
createChatSnapshotUploadUrl(sessionId: string, requestOptions?: ZodFetchOptions) {
|
||||
return zodfetch(
|
||||
CreateUploadPayloadUrlResponseBody,
|
||||
`${this.baseUrl}/api/v1/sessions/${encodeURIComponent(sessionId)}/snapshot-url`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: this.#getHeaders(false),
|
||||
},
|
||||
mergeRequestOptions(this.defaultRequestOptions, requestOptions)
|
||||
);
|
||||
}
|
||||
|
||||
/** Presigned GET URL for a `chat.agent` session snapshot. */
|
||||
getChatSnapshotUrl(sessionId: string, requestOptions?: ZodFetchOptions) {
|
||||
return zodfetch(
|
||||
CreateUploadPayloadUrlResponseBody,
|
||||
`${this.baseUrl}/api/v1/sessions/${encodeURIComponent(sessionId)}/snapshot-url`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: this.#getHeaders(false),
|
||||
},
|
||||
mergeRequestOptions(this.defaultRequestOptions, requestOptions)
|
||||
);
|
||||
}
|
||||
|
||||
retrieveRun(runId: string, requestOptions?: ZodFetchOptions) {
|
||||
return zodfetch(
|
||||
RetrieveRunResponse,
|
||||
|
||||
@@ -219,20 +219,6 @@ async function findLatestSessionInCursor(
|
||||
*/
|
||||
export type { ChatSnapshotV1 } from "@trigger.dev/core/v3";
|
||||
|
||||
/**
|
||||
* S3 key suffix for a session's snapshot blob. The webapp's presigned-URL
|
||||
* routes prefix this with `packets/{projectRef}/{envSlug}/` server-side, so
|
||||
* the final S3 key lands at
|
||||
* `packets/{projectRef}/{envSlug}/sessions/{sessionId}/snapshot.json`.
|
||||
*
|
||||
* Stable per session: the friendlyId persists across `chat.requestUpgrade`
|
||||
* continuations and idle-suspend restarts.
|
||||
* @internal
|
||||
*/
|
||||
function snapshotFilename(sessionId: string): string {
|
||||
return `sessions/${sessionId}/snapshot.json`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test-only override hook — `mockChatAgent` installs a fake to return
|
||||
* synthetic snapshots without hitting S3. Mirrors the `__set*ImplForTests`
|
||||
@@ -285,7 +271,7 @@ async function readChatSnapshot<TUIMessage extends UIMessage>(
|
||||
const apiClient = apiClientManager.clientOrThrow();
|
||||
let presignedUrl: string;
|
||||
try {
|
||||
const resp = await apiClient.getPayloadUrl(snapshotFilename(sessionId));
|
||||
const resp = await apiClient.getChatSnapshotUrl(sessionId);
|
||||
presignedUrl = resp.presignedUrl;
|
||||
} catch (error) {
|
||||
logger.warn("chat.agent: snapshot presign (read) failed; continuing without snapshot", {
|
||||
@@ -360,7 +346,7 @@ async function writeChatSnapshot<TUIMessage extends UIMessage>(
|
||||
const apiClient = apiClientManager.clientOrThrow();
|
||||
let presignedUrl: string;
|
||||
try {
|
||||
const resp = await apiClient.createUploadPayloadUrl(snapshotFilename(sessionId));
|
||||
const resp = await apiClient.createChatSnapshotUploadUrl(sessionId);
|
||||
presignedUrl = resp.presignedUrl;
|
||||
} catch (error) {
|
||||
logger.warn("chat.agent: snapshot presign (write) failed; next run will replay further", {
|
||||
|
||||
@@ -34,28 +34,27 @@ function buildSnapshot(count = 1): ChatSnapshotV1 {
|
||||
|
||||
/**
|
||||
* Stub `apiClientManager.clientOrThrow()` so the helpers see a fake API
|
||||
* client whose `getPayloadUrl` / `createUploadPayloadUrl` resolve with the
|
||||
* presigned URLs the test wants. Returns spies for assertion.
|
||||
* client whose `getChatSnapshotUrl` / `createChatSnapshotUploadUrl` resolve
|
||||
* with the presigned URLs the test wants. Returns spies for assertion.
|
||||
*/
|
||||
function stubApiClient(opts: {
|
||||
getPayloadUrl?: (filename: string) => Promise<{ presignedUrl: string }>;
|
||||
createUploadPayloadUrl?: (filename: string) => Promise<{ presignedUrl: string }>;
|
||||
getChatSnapshotUrl?: (sessionId: string) => Promise<{ presignedUrl: string }>;
|
||||
createChatSnapshotUploadUrl?: (sessionId: string) => Promise<{ presignedUrl: string }>;
|
||||
}) {
|
||||
const getPayloadUrl = vi.fn(
|
||||
opts.getPayloadUrl ?? (async (_filename: string) => ({ presignedUrl: "https://example.invalid/get" }))
|
||||
const getChatSnapshotUrl = vi.fn(
|
||||
opts.getChatSnapshotUrl ??
|
||||
(async (_sessionId: string) => ({ presignedUrl: "https://example.invalid/get" }))
|
||||
);
|
||||
const createUploadPayloadUrl = vi.fn(
|
||||
opts.createUploadPayloadUrl ??
|
||||
(async (_filename: string) => ({ presignedUrl: "https://example.invalid/put" }))
|
||||
const createChatSnapshotUploadUrl = vi.fn(
|
||||
opts.createChatSnapshotUploadUrl ??
|
||||
(async (_sessionId: string) => ({ presignedUrl: "https://example.invalid/put" }))
|
||||
);
|
||||
const fakeClient = {
|
||||
getPayloadUrl,
|
||||
createUploadPayloadUrl,
|
||||
getChatSnapshotUrl,
|
||||
createChatSnapshotUploadUrl,
|
||||
};
|
||||
vi.spyOn(apiClientManager, "clientOrThrow").mockReturnValue(
|
||||
fakeClient as never
|
||||
);
|
||||
return { getPayloadUrl, createUploadPayloadUrl };
|
||||
vi.spyOn(apiClientManager, "clientOrThrow").mockReturnValue(fakeClient as never);
|
||||
return { getChatSnapshotUrl, createChatSnapshotUploadUrl };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,7 +86,7 @@ describe("chat snapshot helpers", () => {
|
||||
|
||||
describe("readChatSnapshot", () => {
|
||||
it("returns the snapshot on a successful GET", async () => {
|
||||
const { getPayloadUrl } = stubApiClient({});
|
||||
const { getChatSnapshotUrl } = stubApiClient({});
|
||||
const snapshot = buildSnapshot(2);
|
||||
stubFetch(async () =>
|
||||
new Response(JSON.stringify(snapshot), {
|
||||
@@ -97,7 +96,7 @@ describe("chat snapshot helpers", () => {
|
||||
);
|
||||
|
||||
const result = await readChatSnapshot("session-1");
|
||||
expect(getPayloadUrl).toHaveBeenCalledWith("sessions/session-1/snapshot.json");
|
||||
expect(getChatSnapshotUrl).toHaveBeenCalledWith("session-1");
|
||||
expect(result).toMatchObject({
|
||||
version: 1,
|
||||
messages: snapshot.messages,
|
||||
@@ -177,7 +176,7 @@ describe("chat snapshot helpers", () => {
|
||||
|
||||
it("returns undefined when presign call fails", async () => {
|
||||
stubApiClient({
|
||||
getPayloadUrl: async () => {
|
||||
getChatSnapshotUrl: async () => {
|
||||
throw new Error("presign denied");
|
||||
},
|
||||
});
|
||||
@@ -202,13 +201,13 @@ describe("chat snapshot helpers", () => {
|
||||
|
||||
describe("writeChatSnapshot", () => {
|
||||
it("PUTs the snapshot JSON to the presigned URL", async () => {
|
||||
const { createUploadPayloadUrl } = stubApiClient({});
|
||||
const { createChatSnapshotUploadUrl } = stubApiClient({});
|
||||
const fetchSpy = stubFetch(async () => new Response(null, { status: 200 }));
|
||||
|
||||
const snapshot = buildSnapshot(3);
|
||||
await writeChatSnapshot("session-2", snapshot);
|
||||
|
||||
expect(createUploadPayloadUrl).toHaveBeenCalledWith("sessions/session-2/snapshot.json");
|
||||
expect(createChatSnapshotUploadUrl).toHaveBeenCalledWith("session-2");
|
||||
expect(fetchSpy).toHaveBeenCalledOnce();
|
||||
const [url, init] = fetchSpy.mock.calls[0]!;
|
||||
expect(url).toBe("https://example.invalid/put");
|
||||
@@ -239,7 +238,7 @@ describe("chat snapshot helpers", () => {
|
||||
|
||||
it("returns without throwing when presign fails (warns)", async () => {
|
||||
stubApiClient({
|
||||
createUploadPayloadUrl: async () => {
|
||||
createChatSnapshotUploadUrl: async () => {
|
||||
throw new Error("presign denied");
|
||||
},
|
||||
});
|
||||
@@ -250,29 +249,28 @@ describe("chat snapshot helpers", () => {
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the same `snapshotFilename(sessionId)` convention as the read path", async () => {
|
||||
// Round-trip check: read and write target the same key for a given
|
||||
// sessionId. The runtime relies on this to make read-after-write
|
||||
// coherent on subsequent boots.
|
||||
const { getPayloadUrl } = stubApiClient({
|
||||
getPayloadUrl: async () => ({ presignedUrl: "https://example.invalid/get" }),
|
||||
it("addresses reads and writes by the same sessionId", async () => {
|
||||
// Round-trip check: both presign methods receive the same sessionId.
|
||||
// The canonical key (`sessions/{id}/snapshot.json`) lives server-side
|
||||
// now, so the SDK has no key string to compare — sessionId equality
|
||||
// is the SDK-visible invariant.
|
||||
const { getChatSnapshotUrl } = stubApiClient({
|
||||
getChatSnapshotUrl: async () => ({ presignedUrl: "https://example.invalid/get" }),
|
||||
});
|
||||
stubFetch(async () => new Response(null, { status: 404 }));
|
||||
|
||||
// Trigger a read.
|
||||
await readChatSnapshot("round-trip-session");
|
||||
const [readKey] = getPayloadUrl.mock.calls[0]!;
|
||||
const [readArg] = getChatSnapshotUrl.mock.calls[0]!;
|
||||
|
||||
// Trigger a write to the same session.
|
||||
const { createUploadPayloadUrl } = stubApiClient({
|
||||
createUploadPayloadUrl: async () => ({ presignedUrl: "https://example.invalid/put" }),
|
||||
const { createChatSnapshotUploadUrl } = stubApiClient({
|
||||
createChatSnapshotUploadUrl: async () => ({ presignedUrl: "https://example.invalid/put" }),
|
||||
});
|
||||
stubFetch(async () => new Response(null, { status: 200 }));
|
||||
await writeChatSnapshot("round-trip-session", buildSnapshot());
|
||||
const [writeKey] = createUploadPayloadUrl.mock.calls[0]!;
|
||||
const [writeArg] = createChatSnapshotUploadUrl.mock.calls[0]!;
|
||||
|
||||
expect(readKey).toBe(writeKey);
|
||||
expect(readKey).toBe("sessions/round-trip-session/snapshot.json");
|
||||
expect(readArg).toBe(writeArg);
|
||||
expect(readArg).toBe("round-trip-session");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user