diff --git a/packages/control-plane/src/routes/keyboard-shortcuts.ts b/packages/control-plane/src/routes/keyboard-shortcuts.ts index 49a09da863..4fa789ec5f 100644 --- a/packages/control-plane/src/routes/keyboard-shortcuts.ts +++ b/packages/control-plane/src/routes/keyboard-shortcuts.ts @@ -9,7 +9,11 @@ export const keyboardShortcutRoutes = new Hono(); keyboardShortcutRoutes.get( "/keyboard-shortcuts", - admit({ ...SCM_AGNOSTIC_HUMAN_USER_ROUTE, authorization: ACTIVE_SELF }), + admit({ + ...SCM_AGNOSTIC_HUMAN_USER_ROUTE, + authorization: ACTIVE_SELF, + cacheControl: "private, no-store", + }), async (c) => { const { ctx } = c.var.admitted; const shortcuts = await new KeyboardShortcutPreferencesStore(ctx.db).get(ctx.principal.userId); diff --git a/packages/control-plane/src/routes/model-preferences.ts b/packages/control-plane/src/routes/model-preferences.ts index b396acf589..cd0d8ba228 100644 --- a/packages/control-plane/src/routes/model-preferences.ts +++ b/packages/control-plane/src/routes/model-preferences.ts @@ -103,6 +103,7 @@ modelPreferencesRoutes.get( admit({ ...GITHUB_USER_OR_SERVICE_ROUTE, authorization: activeGlobal({ actorlessGrants: [{ service: "slack-bot" }] }), + cacheControl: "private, no-store", }), (c) => getModelPreferences(c.var.admitted.ctx) ); diff --git a/packages/control-plane/src/routes/skills.ts b/packages/control-plane/src/routes/skills.ts index e6cc359865..316ca0dc17 100644 --- a/packages/control-plane/src/routes/skills.ts +++ b/packages/control-plane/src/routes/skills.ts @@ -625,6 +625,11 @@ const PROFILES_MANAGE_OWN = admit({ ...SCM_AGNOSTIC_HUMAN_USER_ROUTE, authorization: requirePermission("skill_profiles.manage_own"), }); +const PROFILES_READ_OWN = admit({ + ...SCM_AGNOSTIC_HUMAN_USER_ROUTE, + authorization: requirePermission("skill_profiles.manage_own"), + cacheControl: "private, no-store", +}); export const skillRoutes = new Hono(); @@ -649,7 +654,7 @@ skillRoutes.put("/skills/:id", SKILLS_MANAGE, (c) => dispatch(c, handleReplaceSkillContentAndAssignments) ); skillRoutes.delete("/skills/:id", SKILLS_MANAGE, (c) => dispatch(c, handleDeleteSkill)); -skillRoutes.get("/skill-profiles", PROFILES_MANAGE_OWN, (c) => dispatch(c, handleListProfiles)); +skillRoutes.get("/skill-profiles", PROFILES_READ_OWN, (c) => dispatch(c, handleListProfiles)); skillRoutes.post("/skill-profiles", PROFILES_MANAGE_OWN, (c) => dispatch(c, handleCreateProfile)); skillRoutes.patch("/skill-profiles/:id", PROFILES_MANAGE_OWN, (c) => dispatch(c, handleUpdateProfile) diff --git a/packages/control-plane/test/integration/__snapshots__/hono-route-catalog-conformance.test.ts.snap b/packages/control-plane/test/integration/__snapshots__/hono-route-catalog-conformance.test.ts.snap index d00f607bce..d0ef301eac 100644 --- a/packages/control-plane/test/integration/__snapshots__/hono-route-catalog-conformance.test.ts.snap +++ b/packages/control-plane/test/integration/__snapshots__/hono-route-catalog-conformance.test.ts.snap @@ -79,7 +79,7 @@ exports[`Hono route catalog conformance > dispatches every frozen method/path/po "{"identity":"GET /image-builds/status","pathname":"/image-builds/status","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"image_builds.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /image-builds/enabled","pathname":"/image-builds/enabled","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"image_builds.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /image-builds/enabled-repos","pathname":"/image-builds/enabled-repos","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"image_builds.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /model-preferences","pathname":"/model-preferences","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-global","service":{"kind":"actor","actorlessGrants":[{"service":"slack-bot"}]},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /model-preferences","pathname":"/model-preferences","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-global","service":{"kind":"actor","actorlessGrants":[{"service":"slack-bot"}]},"auditAllowed":false},"supportedScmProviders":["github"],"cacheControl":"private, no-store","hasServiceActorClaims":false}", "{"identity":"PUT /model-preferences","pathname":"/model-preferences","groups":{},"authentication":"user-or-service","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"models.preferences.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":["github"],"cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /model-provider-accounts/legacy-credentials","pathname":"/model-provider-accounts/legacy-credentials","groups":{},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", "{"identity":"GET /model-provider-accounts","pathname":"/model-provider-accounts","groups":{},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"provider_accounts.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", @@ -157,11 +157,11 @@ exports[`Hono route catalog conformance > dispatches every frozen method/path/po "{"identity":"PATCH /skills/:id","pathname":"/skills/fixture-152-id%2Fraw","groups":{"id":"fixture-152-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"PUT /skills/:id","pathname":"/skills/fixture-153-id%2Fraw","groups":{"id":"fixture-153-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"DELETE /skills/:id","pathname":"/skills/fixture-154-id%2Fraw","groups":{"id":"fixture-154-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skills.manage"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /skill-profiles","pathname":"/skill-profiles","groups":{},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skill_profiles.manage_own"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /skill-profiles","pathname":"/skill-profiles","groups":{},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skill_profiles.manage_own"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", "{"identity":"POST /skill-profiles","pathname":"/skill-profiles","groups":{},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skill_profiles.manage_own"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"PATCH /skill-profiles/:id","pathname":"/skill-profiles/fixture-157-id%2Fraw","groups":{"id":"fixture-157-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skill_profiles.manage_own"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"DELETE /skill-profiles/:id","pathname":"/skill-profiles/fixture-158-id%2Fraw","groups":{"id":"fixture-158-id%2Fraw"},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"skill_profiles.manage_own"}],"auditAllowed":true,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", - "{"identity":"GET /keyboard-shortcuts","pathname":"/keyboard-shortcuts","groups":{},"authentication":"user","authorization":{"kind":"active-self","auditAllowed":false},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", + "{"identity":"GET /keyboard-shortcuts","pathname":"/keyboard-shortcuts","groups":{},"authentication":"user","authorization":{"kind":"active-self","auditAllowed":false},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", "{"identity":"PUT /keyboard-shortcuts","pathname":"/keyboard-shortcuts","groups":{},"authentication":"user","authorization":{"kind":"active-self","auditAllowed":true},"supportedScmProviders":"all","cacheControl":null,"hasServiceActorClaims":false}", "{"identity":"GET /me/authorization","pathname":"/me/authorization","groups":{},"authentication":"user","authorization":{"kind":"authenticated","auditAllowed":false},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", "{"identity":"GET /roles","pathname":"/roles","groups":{},"authentication":"user","authorization":{"kind":"active-user","allOf":[{"kind":"permission","permission":"workspace.roles.read"}],"auditAllowed":false,"service":{"kind":"actor"}},"supportedScmProviders":"all","cacheControl":"private, no-store","hasServiceActorClaims":false}", diff --git a/packages/web/src/app/api/model-preferences/route.test.ts b/packages/web/src/app/api/model-preferences/route.test.ts new file mode 100644 index 0000000000..a7b2a0027f --- /dev/null +++ b/packages/web/src/app/api/model-preferences/route.test.ts @@ -0,0 +1,43 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; +import { controlPlaneUserFetch } from "@/lib/control-plane"; +import { GET, PUT } from "./route"; + +vi.mock("@/lib/control-plane", () => ({ controlPlaneUserFetch: vi.fn() })); + +const context = { params: Promise.resolve(undefined) }; + +describe("/api/model-preferences", () => { + beforeEach(() => vi.resetAllMocks()); + + it("relays the preferences read as a private, uncacheable response", async () => { + vi.mocked(controlPlaneUserFetch).mockResolvedValue( + Response.json({ defaultModel: "anthropic/claude-sonnet-5" }) + ); + + const response = await GET(new NextRequest("http://localhost/api/model-preferences"), context); + + expect(controlPlaneUserFetch).toHaveBeenCalledWith("/model-preferences", undefined); + expect(response.status).toBe(200); + expect(response.headers.get("Cache-Control")).toBe("private, no-store"); + await expect(response.json()).resolves.toEqual({ defaultModel: "anthropic/claude-sonnet-5" }); + }); + + it("forwards a preferences update with the browser session", async () => { + vi.mocked(controlPlaneUserFetch).mockResolvedValue(Response.json({ ok: true })); + const request = new NextRequest("http://localhost/api/model-preferences", { + method: "PUT", + headers: { Cookie: "__Secure-openinspect.session_token=session.signature" }, + body: JSON.stringify({ defaultModel: "anthropic/claude-opus-5" }), + }); + + const response = await PUT(request, context); + + expect(controlPlaneUserFetch).toHaveBeenCalledWith("/model-preferences", { + method: "PUT", + body: JSON.stringify({ defaultModel: "anthropic/claude-opus-5" }), + }); + expect(response.status).toBe(200); + expect(response.headers.get("Cache-Control")).toBe("private, no-store"); + }); +}); diff --git a/packages/web/src/app/api/model-preferences/route.ts b/packages/web/src/app/api/model-preferences/route.ts index 73caa1ca22..f59cd22053 100644 --- a/packages/web/src/app/api/model-preferences/route.ts +++ b/packages/web/src/app/api/model-preferences/route.ts @@ -1,42 +1,3 @@ -import type { NextRequest } from "next/server"; -import { NextResponse } from "next/server"; -import { getServerAuthSession } from "@/lib/server-auth-session"; -import { controlPlaneUserFetch } from "@/lib/control-plane"; +import { settingsProxy } from "@/lib/settings-proxy"; -export async function GET() { - const session = await getServerAuthSession(); - if (!session?.user) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - try { - const response = await controlPlaneUserFetch("/model-preferences"); - const data = await response.json(); - return NextResponse.json(data, { status: response.status }); - } catch (error) { - console.error("Failed to fetch model preferences:", error); - return NextResponse.json({ error: "Failed to fetch model preferences" }, { status: 500 }); - } -} - -export async function PUT(request: NextRequest) { - const session = await getServerAuthSession(); - if (!session?.user) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - try { - const body = await request.json(); - - const response = await controlPlaneUserFetch("/model-preferences", { - method: "PUT", - body: JSON.stringify(body), - }); - - const data = await response.json(); - return NextResponse.json(data, { status: response.status }); - } catch (error) { - console.error("Failed to update model preferences:", error); - return NextResponse.json({ error: "Failed to update model preferences" }, { status: 500 }); - } -} +export const { GET, PUT } = settingsProxy(() => "/model-preferences", "model preferences");