diff --git a/src/app/api/v1/conversation-folders/[folderKey]/route.ts b/src/app/api/v1/conversation-folders/[folderKey]/route.ts index 3a23e9a..89ac155 100644 --- a/src/app/api/v1/conversation-folders/[folderKey]/route.ts +++ b/src/app/api/v1/conversation-folders/[folderKey]/route.ts @@ -1,7 +1,8 @@ -import { NextRequest, NextResponse } from "next/server"; +import { NextRequest } from "next/server"; import { requireRequestSession } from "@/lib/boss-auth"; import { getConversationFolderView } from "@/lib/boss-projections"; import { readState } from "@/lib/boss-data"; +import { jsonNoStore } from "@/lib/api-response"; export async function GET( request: NextRequest, @@ -9,13 +10,13 @@ export async function GET( ) { const session = await requireRequestSession(request); if (!session) { - return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 }); + return jsonNoStore({ ok: false, message: "UNAUTHORIZED" }, { status: 401 }); } const { folderKey } = await context.params; const state = await readState(); const folder = getConversationFolderView(state, decodeURIComponent(folderKey)); if (!folder) { - return NextResponse.json({ ok: false, message: "FOLDER_NOT_FOUND" }, { status: 404 }); + return jsonNoStore({ ok: false, message: "FOLDER_NOT_FOUND" }, { status: 404 }); } - return NextResponse.json({ ok: true, folder }); + return jsonNoStore({ ok: true, folder }); } diff --git a/src/app/api/v1/conversations/home/route.ts b/src/app/api/v1/conversations/home/route.ts index 29711c4..de39232 100644 --- a/src/app/api/v1/conversations/home/route.ts +++ b/src/app/api/v1/conversations/home/route.ts @@ -1,15 +1,16 @@ -import { NextRequest, NextResponse } from "next/server"; +import { NextRequest } from "next/server"; import { requireRequestSession } from "@/lib/boss-auth"; import { getConversationHomeItems } from "@/lib/boss-projections"; import { readState } from "@/lib/boss-data"; +import { jsonNoStore } from "@/lib/api-response"; export async function GET(request: NextRequest) { const session = await requireRequestSession(request); if (!session) { - return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 }); + return jsonNoStore({ ok: false, message: "UNAUTHORIZED" }, { status: 401 }); } const state = await readState(); - return NextResponse.json({ + return jsonNoStore({ ok: true, conversations: getConversationHomeItems(state), }); diff --git a/src/app/api/v1/conversations/route.ts b/src/app/api/v1/conversations/route.ts index dc000c2..3e2fede 100644 --- a/src/app/api/v1/conversations/route.ts +++ b/src/app/api/v1/conversations/route.ts @@ -1,15 +1,16 @@ -import { NextRequest, NextResponse } from "next/server"; +import { NextRequest } from "next/server"; import { requireRequestSession } from "@/lib/boss-auth"; import { getConversationItems } from "@/lib/boss-projections"; import { readState } from "@/lib/boss-data"; +import { jsonNoStore } from "@/lib/api-response"; export async function GET(request: NextRequest) { const session = await requireRequestSession(request); if (!session) { - return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 }); + return jsonNoStore({ ok: false, message: "UNAUTHORIZED" }, { status: 401 }); } const state = await readState(); - return NextResponse.json({ + return jsonNoStore({ ok: true, conversations: getConversationItems(state), }); diff --git a/src/app/api/v1/devices/route.ts b/src/app/api/v1/devices/route.ts index a245e8e..dce4f32 100644 --- a/src/app/api/v1/devices/route.ts +++ b/src/app/api/v1/devices/route.ts @@ -1,18 +1,19 @@ -import { NextRequest, NextResponse } from "next/server"; +import { NextRequest } from "next/server"; import { requireRequestSession } from "@/lib/boss-auth"; import { getDeviceWorkspaceView } from "@/lib/boss-projections"; import { readState } from "@/lib/boss-data"; +import { jsonNoStore } from "@/lib/api-response"; export async function GET(request: NextRequest) { const session = await requireRequestSession(request); if (!session) { - return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 }); + return jsonNoStore({ ok: false, message: "UNAUTHORIZED" }, { status: 401 }); } const url = new URL(request.url); const deviceId = url.searchParams.get("device"); const state = await readState(); - return NextResponse.json({ + return jsonNoStore({ ok: true, devices: state.devices, enrollments: state.deviceEnrollments, diff --git a/src/app/api/v1/projects/[projectId]/route.ts b/src/app/api/v1/projects/[projectId]/route.ts index 8431fcc..bd6a5ec 100644 --- a/src/app/api/v1/projects/[projectId]/route.ts +++ b/src/app/api/v1/projects/[projectId]/route.ts @@ -1,7 +1,8 @@ -import { NextRequest, NextResponse } from "next/server"; +import { NextRequest } from "next/server"; import { requireRequestSession } from "@/lib/boss-auth"; import { getProjectDetailView } from "@/lib/boss-projections"; import { readState } from "@/lib/boss-data"; +import { jsonNoStore } from "@/lib/api-response"; export async function GET( request: NextRequest, @@ -9,17 +10,17 @@ export async function GET( ) { const session = await requireRequestSession(request); if (!session) { - return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 }); + return jsonNoStore({ ok: false, message: "UNAUTHORIZED" }, { status: 401 }); } const { projectId } = await context.params; const state = await readState(); const detail = getProjectDetailView(state, projectId, session.account); if (!detail) { - return NextResponse.json({ ok: false, message: "PROJECT_NOT_FOUND" }, { status: 404 }); + return jsonNoStore({ ok: false, message: "PROJECT_NOT_FOUND" }, { status: 404 }); } - return NextResponse.json({ + return jsonNoStore({ ok: true, ...detail, }); diff --git a/src/app/api/v1/settings/route.ts b/src/app/api/v1/settings/route.ts index 48f2c85..79e99f9 100644 --- a/src/app/api/v1/settings/route.ts +++ b/src/app/api/v1/settings/route.ts @@ -1,14 +1,15 @@ import { NextRequest, NextResponse } from "next/server"; import { requireRequestSession } from "@/lib/boss-auth"; import { readState, updateUserSettings } from "@/lib/boss-data"; +import { jsonNoStore } from "@/lib/api-response"; export async function GET(request: NextRequest) { const session = await requireRequestSession(request); if (!session) { - return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 }); + return jsonNoStore({ ok: false, message: "UNAUTHORIZED" }, { status: 401 }); } const state = await readState(); - return NextResponse.json({ + return jsonNoStore({ ok: true, settings: state.user.settings, user: state.user, diff --git a/src/lib/api-response.ts b/src/lib/api-response.ts new file mode 100644 index 0000000..50614be --- /dev/null +++ b/src/lib/api-response.ts @@ -0,0 +1,18 @@ +import { NextResponse } from "next/server"; + +export const NO_STORE_JSON_HEADERS = { + "Cache-Control": "private, no-store, max-age=0", +} as const; + +export function jsonNoStore( + body: Parameters[0], + init?: Parameters[1], +) { + return NextResponse.json(body, { + ...init, + headers: { + ...NO_STORE_JSON_HEADERS, + ...(init?.headers ?? {}), + }, + }); +} diff --git a/tests/live-data-cache-headers.test.ts b/tests/live-data-cache-headers.test.ts new file mode 100644 index 0000000..5ce034f --- /dev/null +++ b/tests/live-data-cache-headers.test.ts @@ -0,0 +1,107 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import os from "node:os"; +import path from "node:path"; +import { mkdtemp, rm } from "node:fs/promises"; +import { NextRequest } from "next/server"; + +let runtimeRoot = ""; +let createAuthSession: (typeof import("../src/lib/boss-data"))["createAuthSession"]; +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 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"]; + +async function setup() { + if (runtimeRoot) return; + + runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-live-data-cache-headers-")); + process.env.BOSS_RUNTIME_ROOT = runtimeRoot; + process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json"); + + const [homeRoute, conversationsRoute, folderRoute, projectRoute, devicesRoute, settingsRoute, dataModule, authModule] = + await Promise.all([ + 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/projects/[projectId]/route.ts"), + import("../src/app/api/v1/devices/route.ts"), + import("../src/app/api/v1/settings/route.ts"), + import("../src/lib/boss-data.ts"), + import("../src/lib/boss-auth.ts"), + ]); + + getConversationHomeRoute = homeRoute.GET; + getConversationsRoute = conversationsRoute.GET; + getFolderRoute = folderRoute.GET; + getProjectDetailRoute = projectRoute.GET; + getDevicesRoute = devicesRoute.GET; + getSettingsRoute = settingsRoute.GET; + createAuthSession = dataModule.createAuthSession; + AUTH_SESSION_COOKIE = authModule.AUTH_SESSION_COOKIE; +} + +test.after(async () => { + if (runtimeRoot) { + await rm(runtimeRoot, { recursive: true, force: true }); + } +}); + +async function createAuthedRequest(url: string) { + const session = await createAuthSession({ + account: "17600003315", + role: "highest_admin", + displayName: "Boss 超级管理员", + loginMethod: "password", + }); + + return new NextRequest(url, { + method: "GET", + headers: { + cookie: `${AUTH_SESSION_COOKIE}=${session.sessionToken}`, + }, + }); +} + +function assertNoStoreHeader(response: Response) { + assert.equal(response.headers.get("Cache-Control"), "private, no-store, max-age=0"); +} + +test("live conversation and device routes disable cache storage", async () => { + await setup(); + + const homeResponse = await getConversationHomeRoute( + await createAuthedRequest("http://127.0.0.1:3000/api/v1/conversations/home"), + ); + assertNoStoreHeader(homeResponse); + + const conversationsResponse = await getConversationsRoute( + await createAuthedRequest("http://127.0.0.1:3000/api/v1/conversations"), + ); + assertNoStoreHeader(conversationsResponse); + + const folderResponse = await getFolderRoute( + await createAuthedRequest("http://127.0.0.1:3000/api/v1/conversation-folders/mac-studio%3Aboss"), + { params: Promise.resolve({ folderKey: "mac-studio%3Aboss" }) }, + ); + assertNoStoreHeader(folderResponse); + + const projectResponse = await getProjectDetailRoute( + await createAuthedRequest("http://127.0.0.1:3000/api/v1/projects/master-agent"), + { params: Promise.resolve({ projectId: "master-agent" }) }, + ); + assertNoStoreHeader(projectResponse); + + const devicesResponse = await getDevicesRoute( + await createAuthedRequest("http://127.0.0.1:3000/api/v1/devices"), + ); + assertNoStoreHeader(devicesResponse); + + const settingsResponse = await getSettingsRoute( + await createAuthedRequest("http://127.0.0.1:3000/api/v1/settings"), + ); + assertNoStoreHeader(settingsResponse); +});