diff --git a/src/app/api/v1/attachments/[attachmentId]/download/route.ts b/src/app/api/v1/attachments/[attachmentId]/download/route.ts index ab1118d..d12ffbe 100644 --- a/src/app/api/v1/attachments/[attachmentId]/download/route.ts +++ b/src/app/api/v1/attachments/[attachmentId]/download/route.ts @@ -2,6 +2,7 @@ import { createReadStream } from "node:fs"; import { stat } from "node:fs/promises"; import { Readable } from "node:stream"; import { NextRequest, NextResponse } from "next/server"; +import { jsonNoStore } from "@/lib/api-response"; import { requireRequestSession } from "@/lib/boss-auth"; import { canSessionAccessAttachmentProject } from "@/lib/boss-attachment-access"; import { getAttachmentById, getAttachmentStorageConfig, getMasterAgentTask, readState } from "@/lib/boss-data"; @@ -38,17 +39,17 @@ export async function GET( const session = await requireRequestSession(request); const taskTokenAccess = session ? false : await hasTaskTokenAccess(request, attachmentId); if (!session && !taskTokenAccess) { - return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 }); + return jsonNoStore({ ok: false, message: "UNAUTHORIZED" }, { status: 401 }); } const record = await getAttachmentById(attachmentId); if (!record) { - return NextResponse.json({ ok: false, message: "ATTACHMENT_NOT_FOUND" }, { status: 404 }); + return jsonNoStore({ ok: false, message: "ATTACHMENT_NOT_FOUND" }, { status: 404 }); } if (session) { const state = await readState(); if (!canSessionAccessAttachmentProject(state, session, record.project)) { - return NextResponse.json({ ok: false, message: "FORBIDDEN" }, { status: 403 }); + return jsonNoStore({ ok: false, message: "FORBIDDEN" }, { status: 403 }); } } @@ -74,7 +75,7 @@ export async function GET( ? storageConfig.aliyunOss : null); if (!resolvedConfig) { - return NextResponse.json({ ok: false, message: "ATTACHMENT_STORAGE_CONFIG_NOT_FOUND" }, { status: 404 }); + return jsonNoStore({ ok: false, message: "ATTACHMENT_STORAGE_CONFIG_NOT_FOUND" }, { status: 404 }); } const signedUrl = await getAliyunOssSignedDownloadUrl(resolvedConfig, record.attachment.storagePath); return NextResponse.redirect(signedUrl, { @@ -84,19 +85,19 @@ export async function GET( } if (record.attachment.storageBackend !== "server_file") { - return NextResponse.json({ ok: false, message: "UNSUPPORTED_ATTACHMENT_STORAGE_BACKEND" }, { status: 501 }); + return jsonNoStore({ ok: false, message: "UNSUPPORTED_ATTACHMENT_STORAGE_BACKEND" }, { status: 501 }); } let absolutePath: string; try { absolutePath = resolveServerFileAttachmentAbsolutePath(record.attachment.storagePath); } catch { - return NextResponse.json({ ok: false, message: "ATTACHMENT_FILE_NOT_FOUND" }, { status: 404 }); + return jsonNoStore({ ok: false, message: "ATTACHMENT_FILE_NOT_FOUND" }, { status: 404 }); } try { await stat(absolutePath); } catch { - return NextResponse.json({ ok: false, message: "ATTACHMENT_FILE_NOT_FOUND" }, { status: 404 }); + return jsonNoStore({ ok: false, message: "ATTACHMENT_FILE_NOT_FOUND" }, { status: 404 }); } const stream = createReadStream(absolutePath); diff --git a/src/app/api/v1/events/route.ts b/src/app/api/v1/events/route.ts index 8d78a80..df8d2ee 100644 --- a/src/app/api/v1/events/route.ts +++ b/src/app/api/v1/events/route.ts @@ -1,4 +1,5 @@ import { NextRequest } from "next/server"; +import { jsonNoStore } from "@/lib/api-response"; import { requireRequestSession } from "@/lib/boss-auth"; import { subscribeBossEvents } from "@/lib/boss-events"; import { getAuditSummaryView, getConversationItems, getOpsSummaryView } from "@/lib/boss-projections"; @@ -13,10 +14,7 @@ function sseEvent(event: string, data: unknown) { export async function GET(request: NextRequest) { const session = await requireRequestSession(request); if (!session) { - return new Response(JSON.stringify({ ok: false, message: "UNAUTHORIZED" }), { - status: 401, - headers: { "Content-Type": "application/json; charset=utf-8" }, - }); + return jsonNoStore({ ok: false, message: "UNAUTHORIZED" }, { status: 401 }); } const encoder = new TextEncoder(); let heartbeatTimer: ReturnType | undefined; diff --git a/src/app/api/v1/user/ota/package/route.ts b/src/app/api/v1/user/ota/package/route.ts index b5975eb..8637fce 100644 --- a/src/app/api/v1/user/ota/package/route.ts +++ b/src/app/api/v1/user/ota/package/route.ts @@ -1,23 +1,18 @@ import { promises as fs } from "node:fs"; import { NextRequest } from "next/server"; +import { jsonNoStore } from "@/lib/api-response"; import { requireRequestSession } from "@/lib/boss-auth"; import { getPublishedOtaAsset } from "@/lib/boss-ota"; export async function GET(request: NextRequest) { const session = await requireRequestSession(request); if (!session) { - return new Response(JSON.stringify({ ok: false, message: "UNAUTHORIZED" }), { - status: 401, - headers: { "Content-Type": "application/json; charset=utf-8" }, - }); + return jsonNoStore({ ok: false, message: "UNAUTHORIZED" }, { status: 401 }); } const asset = await getPublishedOtaAsset(); if (!asset) { - return new Response(JSON.stringify({ ok: false, message: "OTA_PACKAGE_NOT_FOUND" }), { - status: 404, - headers: { "Content-Type": "application/json; charset=utf-8" }, - }); + return jsonNoStore({ ok: false, message: "OTA_PACKAGE_NOT_FOUND" }, { status: 404 }); } const content = await fs.readFile(asset.absolutePath); diff --git a/tests/live-data-cache-headers.test.ts b/tests/live-data-cache-headers.test.ts index 38e8834..3aab63d 100644 --- a/tests/live-data-cache-headers.test.ts +++ b/tests/live-data-cache-headers.test.ts @@ -11,6 +11,7 @@ let AUTH_SESSION_COOKIE = ""; let getConversationHomeRoute: (typeof import("../src/app/api/v1/conversations/home/route"))["GET"]; let getConversationsRoute: (typeof import("../src/app/api/v1/conversations/route"))["GET"]; let getFolderRoute: (typeof import("../src/app/api/v1/conversation-folders/[folderKey]/route"))["GET"]; +let getEventsRoute: (typeof import("../src/app/api/v1/events/route"))["GET"]; let getProjectDetailRoute: (typeof import("../src/app/api/v1/projects/[projectId]/route"))["GET"]; let getDevicesRoute: (typeof import("../src/app/api/v1/devices/route"))["GET"]; let getSettingsRoute: (typeof import("../src/app/api/v1/settings/route"))["GET"]; @@ -35,6 +36,8 @@ let getMasterAgentPromptRoute: (typeof import("../src/app/api/v1/master-agent/pr let getMasterAgentMemoriesRoute: (typeof import("../src/app/api/v1/master-agent/memories/route"))["GET"]; let getStorageConfigRoute: (typeof import("../src/app/api/v1/storage/config/route"))["GET"]; let getAccountDetailRoute: (typeof import("../src/app/api/v1/accounts/[accountId]/route"))["GET"]; +let getOtaPackageRoute: (typeof import("../src/app/api/v1/user/ota/package/route"))["GET"]; +let getAttachmentDownloadRoute: (typeof import("../src/app/api/v1/attachments/[attachmentId]/download/route"))["GET"]; async function setup() { if (runtimeRoot) return; @@ -47,6 +50,7 @@ async function setup() { homeRoute, conversationsRoute, folderRoute, + eventsRoute, projectRoute, devicesRoute, settingsRoute, @@ -71,6 +75,8 @@ async function setup() { masterAgentMemoriesRoute, storageConfigRoute, accountDetailRoute, + otaPackageRoute, + attachmentDownloadRoute, dataModule, authModule, ] = @@ -78,6 +84,7 @@ async function setup() { import("../src/app/api/v1/conversations/home/route.ts"), import("../src/app/api/v1/conversations/route.ts"), import("../src/app/api/v1/conversation-folders/[folderKey]/route.ts"), + import("../src/app/api/v1/events/route.ts"), import("../src/app/api/v1/projects/[projectId]/route.ts"), import("../src/app/api/v1/devices/route.ts"), import("../src/app/api/v1/settings/route.ts"), @@ -102,6 +109,8 @@ async function setup() { import("../src/app/api/v1/master-agent/memories/route.ts"), import("../src/app/api/v1/storage/config/route.ts"), import("../src/app/api/v1/accounts/[accountId]/route.ts"), + import("../src/app/api/v1/user/ota/package/route.ts"), + import("../src/app/api/v1/attachments/[attachmentId]/download/route.ts"), import("../src/lib/boss-data.ts"), import("../src/lib/boss-auth.ts"), ]); @@ -109,6 +118,7 @@ async function setup() { getConversationHomeRoute = homeRoute.GET; getConversationsRoute = conversationsRoute.GET; getFolderRoute = folderRoute.GET; + getEventsRoute = eventsRoute.GET; getProjectDetailRoute = projectRoute.GET; getDevicesRoute = devicesRoute.GET; getSettingsRoute = settingsRoute.GET; @@ -133,6 +143,8 @@ async function setup() { getMasterAgentMemoriesRoute = masterAgentMemoriesRoute.GET; getStorageConfigRoute = storageConfigRoute.GET; getAccountDetailRoute = accountDetailRoute.GET; + getOtaPackageRoute = otaPackageRoute.GET; + getAttachmentDownloadRoute = attachmentDownloadRoute.GET; createAuthSession = dataModule.createAuthSession; AUTH_SESSION_COOKIE = authModule.AUTH_SESSION_COOKIE; } @@ -163,6 +175,47 @@ function assertNoStoreHeader(response: Response) { assert.equal(response.headers.get("Cache-Control"), "private, no-store, max-age=0"); } +test("event stream keeps SSE cache headers while unauthorized event JSON disables caching", async () => { + await setup(); + + const streamResponse = await getEventsRoute( + await createAuthedRequest("http://127.0.0.1:3000/api/v1/events"), + ); + assert.equal(streamResponse.headers.get("Content-Type"), "text/event-stream; charset=utf-8"); + assert.equal(streamResponse.headers.get("Cache-Control"), "no-cache, no-transform"); + await streamResponse.body?.cancel(); + + const unauthorizedResponse = await getEventsRoute( + new NextRequest("http://127.0.0.1:3000/api/v1/events"), + ); + assert.equal(unauthorizedResponse.status, 401); + assertNoStoreHeader(unauthorizedResponse); +}); + +test("download error JSON responses disable cache storage", async () => { + await setup(); + + const otaUnauthorizedResponse = await getOtaPackageRoute( + new NextRequest("http://127.0.0.1:3000/api/v1/user/ota/package"), + ); + assert.equal(otaUnauthorizedResponse.status, 401); + assertNoStoreHeader(otaUnauthorizedResponse); + + const attachmentUnauthorizedResponse = await getAttachmentDownloadRoute( + new NextRequest("http://127.0.0.1:3000/api/v1/attachments/missing/download"), + { params: Promise.resolve({ attachmentId: "missing" }) }, + ); + assert.equal(attachmentUnauthorizedResponse.status, 401); + assertNoStoreHeader(attachmentUnauthorizedResponse); + + const attachmentNotFoundResponse = await getAttachmentDownloadRoute( + await createAuthedRequest("http://127.0.0.1:3000/api/v1/attachments/missing/download"), + { params: Promise.resolve({ attachmentId: "missing" }) }, + ); + assert.equal(attachmentNotFoundResponse.status, 404); + assertNoStoreHeader(attachmentNotFoundResponse); +}); + test("live conversation and device routes disable cache storage", async () => { await setup();