From 557dce6fbecf4ccf1eee5805a0f1cb5c6be4c369 Mon Sep 17 00:00:00 2001 From: TianYun Date: Sat, 23 May 2026 11:33:41 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BD=91=E5=85=B3=E9=80=9A=E8=BF=87=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E8=8E=B7=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 0 src/app/api/_auth.ts | 12 +++ src/app/api/_gatewayApiKey.ts | 12 ++- src/app/api/chat/__tests__/route.test.ts | 6 ++ src/app/api/chat/route.ts | 4 +- src/app/api/generate/__tests__/route.test.ts | 20 +++-- src/app/api/generate/route.ts | 4 +- src/app/api/llm/__tests__/route.test.ts | 7 +- src/app/api/llm/route.ts | 4 +- src/app/api/models/__tests__/route.test.ts | 14 +++- src/app/api/models/route.ts | 4 +- src/app/api/quickstart/propose/route.ts | 4 +- src/app/api/quickstart/route.ts | 4 +- .../quickstart/__tests__/modelClient.test.ts | 20 +++-- src/store/__tests__/userStore.test.ts | 77 ++++++++++++++--- src/store/userStore.ts | 83 ++++++++++++++++++- src/store/utils/buildApiHeaders.ts | 8 +- src/types/user.ts | 13 +++ src/utils/__tests__/authFetch.test.ts | 7 +- src/utils/authFetch.ts | 16 +++- src/utils/gatewayHeaders.ts | 2 + 21 files changed, 267 insertions(+), 54 deletions(-) create mode 100644 AGENTS.md create mode 100644 src/utils/gatewayHeaders.ts diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..e69de29b diff --git a/src/app/api/_auth.ts b/src/app/api/_auth.ts index 0841bed6..bf17e122 100644 --- a/src/app/api/_auth.ts +++ b/src/app/api/_auth.ts @@ -1,4 +1,8 @@ import type { NextRequest } from "next/server"; +import { + LEGACY_NEWAPIWG_API_KEY_HEADER, + NEWAPIWG_API_KEY_HEADER, +} from "@/utils/gatewayHeaders"; import { ApiError } from "./_errors"; export interface LoginContext { @@ -21,6 +25,14 @@ export function getRequestToken(request: Request | NextRequest): string | null { return bearerToken?.trim() || null; } +export function getRequestGatewayApiKey(request: Request | NextRequest): string | null { + return ( + request.headers.get(NEWAPIWG_API_KEY_HEADER)?.trim() || + request.headers.get(LEGACY_NEWAPIWG_API_KEY_HEADER)?.trim() || + null + ); +} + export function requireLogin(request: Request | NextRequest): LoginContext { const token = getRequestToken(request); if (!token) { diff --git a/src/app/api/_gatewayApiKey.ts b/src/app/api/_gatewayApiKey.ts index 98f72077..dfdaf359 100644 --- a/src/app/api/_gatewayApiKey.ts +++ b/src/app/api/_gatewayApiKey.ts @@ -1,6 +1,6 @@ import type { NextRequest } from "next/server"; import { createHash } from "crypto"; -import { getRequestToken } from "./_auth"; +import { getRequestGatewayApiKey, getRequestToken } from "./_auth"; import { ApiError } from "./_errors"; const UPSTREAM_USER_APIKEY_PATH = "/api_client/gateway/apikey/list"; @@ -102,3 +102,13 @@ export async function fetchUserGatewayApiKey( return item.apikey; } + +export async function resolveUserGatewayApiKey( + request: Request | NextRequest +): Promise { + const apiKey = getRequestGatewayApiKey(request); + if (!apiKey) { + throw ApiError.badRequest("Current user gateway API key is required."); + } + return apiKey; +} diff --git a/src/app/api/chat/__tests__/route.test.ts b/src/app/api/chat/__tests__/route.test.ts index 4c9e7841..8f39df4e 100644 --- a/src/app/api/chat/__tests__/route.test.ts +++ b/src/app/api/chat/__tests__/route.test.ts @@ -62,6 +62,7 @@ describe("/api/chat route", () => { const request = new Request("http://localhost/api/chat", { method: "POST", headers: { + Authorization: "Bearer login-token", "X-NewApiWG-Key": "browser-newapi-key", "X-NewApiWG-Base-URL": "https://llmapitest.popi.art/v1", }, @@ -79,6 +80,11 @@ describe("/api/chat route", () => { name: "newapiwg", apiKey: "browser-newapi-key", baseURL: "https://llmapitest.popi.art/v1", + headers: { + "X-User-Authorization": "Bearer login-token", + "X-User-Token": "login-token", + token: "login-token", + }, }); expect(mockNewApiWGProvider).toHaveBeenCalledWith("doubao-seed-2-0-lite-260428"); expect(mockStreamText).toHaveBeenCalledWith( diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index 6126d27f..bb817811 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -9,7 +9,7 @@ import { normalizeNewApiWGBaseUrl } from '@/app/api/generate/providers/newapiwg' import { DEFAULT_NEWAPIWG_LLM_MODEL_ID } from '@/lib/llmModels'; import { buildUserForwardHeaders, requireLogin } from '@/app/api/_auth'; import { ApiError, apiErrorResponse } from '@/app/api/_errors'; -import { fetchUserGatewayApiKey } from '@/app/api/_gatewayApiKey'; +import { resolveUserGatewayApiKey } from '@/app/api/_gatewayApiKey'; export const maxDuration = 60; // 1 minute timeout @@ -22,7 +22,7 @@ export async function POST(request: Request) { selectedNodeIds?: string[]; }; - const apiKey = await fetchUserGatewayApiKey(request); + const apiKey = await resolveUserGatewayApiKey(request); const baseUrl = normalizeNewApiWGBaseUrl( request.headers.get('X-NewApiWG-Base-URL') || process.env.NEWAPIWG_BASE_URL ); diff --git a/src/app/api/generate/__tests__/route.test.ts b/src/app/api/generate/__tests__/route.test.ts index a449a3b9..7ef4674b 100644 --- a/src/app/api/generate/__tests__/route.test.ts +++ b/src/app/api/generate/__tests__/route.test.ts @@ -53,7 +53,10 @@ function createMockPostRequest( ): NextRequest { return { json: vi.fn().mockResolvedValue(body), - headers: new Headers(headers), + headers: new Headers({ + Authorization: "Bearer login-token", + ...(headers || {}), + }), } as unknown as NextRequest; } @@ -121,8 +124,7 @@ describe("/api/generate route", () => { }); }); - it("should read NewApiWG key and base URL from headers or env", () => { - process.env.NEWAPIWG_API_KEY = "env-newapiwg-key"; + it("should read NewApiWG base URL from headers or env", () => { process.env.NEWAPIWG_BASE_URL = "https://env.example/v1"; const request = createMockPostRequest( @@ -131,7 +133,7 @@ describe("/api/generate route", () => { ); expect(getConnectionParams(request, "newapiwg")).toEqual({ - apiKey: "env-newapiwg-key", + apiKey: undefined, baseUrl: "https://header.example/v1", }); }); @@ -1057,7 +1059,10 @@ describe("/api/generate route", () => { size: "1024x1024", }, }, - { "X-NewApiWG-Key": "test-newapiwg-key" } + { + Authorization: "Bearer login-token", + "X-NewApiWG-Key": "test-newapiwg-key", + } ); const response = await POST(request); @@ -1108,7 +1113,10 @@ describe("/api/generate route", () => { capabilities: ["text-to-image", "image-to-image"], }, }, - { "X-NewApiWG-Key": "test-newapiwg-key" } + { + Authorization: "Bearer login-token", + "X-NewApiWG-Key": "test-newapiwg-key", + } ); const response = await POST(request); diff --git a/src/app/api/generate/route.ts b/src/app/api/generate/route.ts index d19b5c18..d3d3dddf 100644 --- a/src/app/api/generate/route.ts +++ b/src/app/api/generate/route.ts @@ -21,7 +21,7 @@ import { generateWithWaveSpeed } from "./providers/wavespeed"; import { generateWithNewApiWG } from "./providers/newapiwg"; import { requireConfiguredValue, requireLogin } from "../_auth"; import { ApiError, apiErrorResponse } from "../_errors"; -import { fetchUserGatewayApiKey } from "../_gatewayApiKey"; +import { resolveUserGatewayApiKey } from "../_gatewayApiKey"; // Re-export for backward compatibility (test file imports from route) export const clearFalInputMappingCache = _clearFalInputMappingCache; @@ -218,7 +218,7 @@ export async function POST(request: NextRequest) { const selected = requireSelectedModel(selectedModel, "NewApiWG"); const { baseUrl: newApiWGBaseUrl } = getConnectionParams(request, "newapiwg"); - const apiKey = await fetchUserGatewayApiKey(request); + const apiKey = await resolveUserGatewayApiKey(request); const genInput: GenerationInput = { model: { diff --git a/src/app/api/llm/__tests__/route.test.ts b/src/app/api/llm/__tests__/route.test.ts index a1c2c9ff..d5ef8674 100644 --- a/src/app/api/llm/__tests__/route.test.ts +++ b/src/app/api/llm/__tests__/route.test.ts @@ -60,7 +60,10 @@ function createMockPostRequest( ): NextRequest { return { json: vi.fn().mockResolvedValue(body), - headers: new Headers(headers), + headers: new Headers({ + Authorization: "Bearer login-token", + ...(headers || {}), + }), } as unknown as NextRequest; } @@ -336,6 +339,7 @@ describe("/api/llm route", () => { maxTokens: 1024, }, { + Authorization: "Bearer login-token", "X-NewApiWG-Key": "test-newapiwg-key", "X-NewApiWG-Base-URL": "https://newapi.example/v1", } @@ -376,6 +380,7 @@ describe("/api/llm route", () => { model: "Doubao-Seed-2.0-lite", }, { + Authorization: "Bearer login-token", "X-NewApiWG-Key": "test-newapiwg-key", "X-NewApiWG-Base-URL": "https://newapi.example/v1", } diff --git a/src/app/api/llm/route.ts b/src/app/api/llm/route.ts index d2a1f23d..1390620e 100644 --- a/src/app/api/llm/route.ts +++ b/src/app/api/llm/route.ts @@ -5,7 +5,7 @@ import { logger } from "@/utils/logger"; import { normalizeNewApiWGBaseUrl } from "@/app/api/generate/providers/newapiwg"; import { buildGatewayHeaders, requireLogin } from "@/app/api/_auth"; import { ApiError, apiErrorResponse } from "@/app/api/_errors"; -import { fetchUserGatewayApiKey } from "@/app/api/_gatewayApiKey"; +import { resolveUserGatewayApiKey } from "@/app/api/_gatewayApiKey"; export const maxDuration = 60; // 1 minute timeout @@ -436,7 +436,7 @@ export async function POST(request: NextRequest) { let text: string; if (provider === "newapiwg") { - const newApiWGKey = await fetchUserGatewayApiKey(request); + const newApiWGKey = await resolveUserGatewayApiKey(request); text = await generateWithNewApiWG(prompt, model, temperature, maxTokens, images, videos, requestId, newApiWGKey, newApiWGBaseUrl, login.token); } else if (provider === "google") { text = await generateWithGoogle(prompt, model, temperature, maxTokens, images, videos, requestId, geminiApiKey); diff --git a/src/app/api/models/__tests__/route.test.ts b/src/app/api/models/__tests__/route.test.ts index 5f60f4d8..efdb8c8c 100644 --- a/src/app/api/models/__tests__/route.test.ts +++ b/src/app/api/models/__tests__/route.test.ts @@ -37,7 +37,10 @@ function createMockGetRequest( return { nextUrl: url, - headers: new Headers(headers), + headers: new Headers({ + Authorization: "Bearer login-token", + ...(headers || {}), + }), } as unknown as NextRequest; } @@ -134,7 +137,7 @@ describe("/api/models route", () => { }), }); - const request = createMockGetRequest(); + const request = createMockGetRequest({}, { "X-NewApiWG-Key": "test-newapiwg-key" }); const response = await GET(request); const data = await response.json(); @@ -153,7 +156,10 @@ describe("/api/models route", () => { process.env.NEXT_PUBLIC_PROVIDER_MODE = "popi"; process.env.NEWAPIWG_API_KEY = "test-newapiwg-key"; - const request = createMockGetRequest({ provider: "replicate" }); + const request = createMockGetRequest( + { provider: "replicate" }, + { "X-NewApiWG-Key": "test-newapiwg-key" } + ); const response = await GET(request); const data = await response.json(); @@ -174,7 +180,7 @@ describe("/api/models route", () => { expect(response.status).toBe(400); expect(data.success).toBe(false); - expect(data.error).toContain("Popi Models API key required"); + expect(data.error).toContain("Current user gateway API key is required"); expect(mockFetch).not.toHaveBeenCalled(); }); }); diff --git a/src/app/api/models/route.ts b/src/app/api/models/route.ts index 5e7703d0..d383661c 100644 --- a/src/app/api/models/route.ts +++ b/src/app/api/models/route.ts @@ -46,7 +46,7 @@ import { } from "@/lib/providerMode"; import { requireLogin } from "@/app/api/_auth"; import { apiErrorResponse } from "@/app/api/_errors"; -import { fetchUserGatewayApiKey } from "@/app/api/_gatewayApiKey"; +import { resolveUserGatewayApiKey } from "@/app/api/_gatewayApiKey"; // API base URLs const REPLICATE_API_BASE = "https://api.replicate.com/v1"; @@ -1122,7 +1122,7 @@ export async function GET( if (needsNewApiWGKey) { try { - newApiWGKey = await fetchUserGatewayApiKey(request); + newApiWGKey = await resolveUserGatewayApiKey(request); } catch (error) { if (popiProviderMode || providerFilter === "newapiwg") { return apiErrorResponse(error) as unknown as NextResponse; diff --git a/src/app/api/quickstart/propose/route.ts b/src/app/api/quickstart/propose/route.ts index b2deed86..22e0a9e8 100644 --- a/src/app/api/quickstart/propose/route.ts +++ b/src/app/api/quickstart/propose/route.ts @@ -5,7 +5,7 @@ import { getQuickstartMissingConfigError, resolveQuickstartModelConfig, } from "@/lib/quickstart/modelClient"; -import { fetchUserGatewayApiKey } from "@/app/api/_gatewayApiKey"; +import { resolveUserGatewayApiKey } from "@/app/api/_gatewayApiKey"; import { ApiError } from "@/app/api/_errors"; import { parseJSONFromResponse } from "@/lib/quickstart/validation"; import type { WorkflowProposal, WorkflowComplexity, NodeType } from "@/types"; @@ -196,7 +196,7 @@ export async function POST(request: NextRequest) { ); } - const newApiWGKey = await fetchUserGatewayApiKey(request); + const newApiWGKey = await resolveUserGatewayApiKey(request); const modelConfig = resolveQuickstartModelConfig(request.headers, { newApiWGApiKey: newApiWGKey, }); diff --git a/src/app/api/quickstart/route.ts b/src/app/api/quickstart/route.ts index 15fe52a4..5e88799c 100644 --- a/src/app/api/quickstart/route.ts +++ b/src/app/api/quickstart/route.ts @@ -7,7 +7,7 @@ import { getQuickstartMissingConfigError, resolveQuickstartModelConfig, } from "@/lib/quickstart/modelClient"; -import { fetchUserGatewayApiKey } from "@/app/api/_gatewayApiKey"; +import { resolveUserGatewayApiKey } from "@/app/api/_gatewayApiKey"; import { ApiError } from "@/app/api/_errors"; import { validateWorkflowJSON, @@ -131,7 +131,7 @@ export async function POST(request: NextRequest) { ); } - const newApiWGKey = await fetchUserGatewayApiKey(request); + const newApiWGKey = await resolveUserGatewayApiKey(request); const modelConfig = resolveQuickstartModelConfig(request.headers, { newApiWGApiKey: newApiWGKey, }); diff --git a/src/lib/quickstart/__tests__/modelClient.test.ts b/src/lib/quickstart/__tests__/modelClient.test.ts index 656c2c0b..d562d30f 100644 --- a/src/lib/quickstart/__tests__/modelClient.test.ts +++ b/src/lib/quickstart/__tests__/modelClient.test.ts @@ -9,10 +9,12 @@ describe("quickstart model client", () => { it("prefers NewApiWG request headers over Gemini env config", () => { const config = resolveQuickstartModelConfig( new Headers({ - "X-NewApiWG-Key": "sk-user", "X-NewApiWG-Base-URL": "https://gateway.example/v1/", }), - { GEMINI_API_KEY: "gemini-env" } + { + env: { GEMINI_API_KEY: "gemini-env" }, + newApiWGApiKey: "sk-user", + } ); expect(config).toEqual({ @@ -23,16 +25,18 @@ describe("quickstart model client", () => { }); }); - it("prefers NewApiWG env config over Gemini env config", () => { + it("uses NewApiWG base URL from env when a user gateway key is available", () => { const config = resolveQuickstartModelConfig(new Headers(), { - NEWAPIWG_API_KEY: "sk-env", - NEWAPIWG_BASE_URL: "https://env-gateway.example/v1/", - GEMINI_API_KEY: "gemini-env", + env: { + NEWAPIWG_BASE_URL: "https://env-gateway.example/v1/", + GEMINI_API_KEY: "gemini-env", + }, + newApiWGApiKey: "sk-user", }); expect(config).toEqual({ provider: "newapiwg", - apiKey: "sk-env", + apiKey: "sk-user", baseUrl: "https://env-gateway.example/v1", model: DEFAULT_NEWAPIWG_LLM_MODEL_ID, }); @@ -40,7 +44,7 @@ describe("quickstart model client", () => { it("falls back to Gemini when NewApiWG is not configured", () => { const config = resolveQuickstartModelConfig(new Headers(), { - GEMINI_API_KEY: "gemini-env", + env: { GEMINI_API_KEY: "gemini-env" }, }); expect(config).toEqual({ diff --git a/src/store/__tests__/userStore.test.ts b/src/store/__tests__/userStore.test.ts index e9def08a..d4994482 100644 --- a/src/store/__tests__/userStore.test.ts +++ b/src/store/__tests__/userStore.test.ts @@ -2,7 +2,9 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { openLoginRequiredModal } from "@/utils/loginRequiredModal"; import { USER_INFO_ENDPOINT, + USER_API_KEY_ENDPOINT, USER_SESSION_STORAGE_KEY, + fetchGatewayApiKeyByToken, fetchUserInfoByToken, useUserStore, } from "../userStore"; @@ -127,6 +129,33 @@ describe("fetchUserInfoByToken", () => { }); }); +describe("fetchGatewayApiKeyByToken", () => { + it("fetches the current user gateway API key through the Next.js API route", async () => { + const fetcher = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + data: { apikey: "sk-user-gateway" }, + message: "ok", + status: "0000", + }), + { status: 200 } + ) + ); + + await expect(fetchGatewayApiKeyByToken("login-token", fetcher)).resolves.toBe( + "sk-user-gateway" + ); + expect(fetcher).toHaveBeenCalledWith(USER_API_KEY_ENDPOINT, { + method: "GET", + headers: { + Accept: "application/json", + Authorization: "Bearer login-token", + token: "login-token", + }, + }); + }); +}); + describe("useUserStore.fetchUserInfo", () => { afterEach(() => { vi.mocked(openLoginRequiredModal).mockClear(); @@ -135,8 +164,11 @@ describe("useUserStore.fetchUserInfo", () => { useUserStore.setState({ token: null, userInfo: null, + gatewayApiKey: null, isLoadingUserInfo: false, + isLoadingGatewayApiKey: false, userInfoError: null, + gatewayApiKeyError: null, }); }); @@ -152,19 +184,31 @@ describe("useUserStore.fetchUserInfo", () => { }) ); - const fetcher = vi.fn().mockResolvedValue( - new Response( - JSON.stringify({ - data: { - token: "stored-token", - user, - }, - message: "ok", - status: "0000", - }), - { status: 200 } + const fetcher = vi + .fn() + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + data: { + token: "stored-token", + user, + }, + message: "ok", + status: "0000", + }), + { status: 200 } + ) ) - ); + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + data: { apikey: "stored-gateway-key" }, + message: "ok", + status: "0000", + }), + { status: 200 } + ) + ); vi.stubGlobal("fetch", fetcher); await expect(useUserStore.getState().fetchUserInfo("")).resolves.toEqual( @@ -178,6 +222,15 @@ describe("useUserStore.fetchUserInfo", () => { token: "stored-token", }, }); + expect(fetcher).toHaveBeenCalledWith(USER_API_KEY_ENDPOINT, { + method: "GET", + headers: { + Accept: "application/json", + Authorization: "Bearer stored-token", + token: "stored-token", + }, + }); + expect(useUserStore.getState().gatewayApiKey).toBe("stored-gateway-key"); expect(openLoginRequiredModal).not.toHaveBeenCalled(); }); diff --git a/src/store/userStore.ts b/src/store/userStore.ts index 25d7e937..3d947105 100644 --- a/src/store/userStore.ts +++ b/src/store/userStore.ts @@ -1,6 +1,7 @@ import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; import type { + UserApiKeyResponse, UserInfo, UserInfoResponse, UserSession, @@ -10,6 +11,7 @@ import { openLoginRequiredModal } from "@/utils/loginRequiredModal"; export const USER_SESSION_STORAGE_KEY = "node-banana-user-session"; export const USER_INFO_ENDPOINT = "/api/user/info"; +export const USER_API_KEY_ENDPOINT = "/api/user/apikey"; const userInfoRequests = new Map>(); @@ -95,24 +97,98 @@ export const fetchUserInfoByToken = async ( return request; }; +export const fetchGatewayApiKeyByToken = async ( + token: string, + fetcher: typeof fetch = fetch, +): Promise => { + const response = await fetcher(USER_API_KEY_ENDPOINT, { + method: "GET", + headers: { + Accept: "application/json", + Authorization: `Bearer ${token}`, + token, + }, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch gateway API key: ${response.status}`); + } + + const payload = (await response.json()) as UserApiKeyResponse; + if (payload.status !== "0000") { + throw new Error( + typeof payload.message === "string" + ? payload.message + : "Failed to fetch gateway API key" + ); + } + + const apiKey = payload.data?.apikey; + return typeof apiKey === "string" && apiKey.trim() ? apiKey.trim() : null; +}; + export const useUserStore = create()( persist( (set, get) => ({ token: null, userInfo: null, + gatewayApiKey: null, isLoadingUserInfo: false, + isLoadingGatewayApiKey: false, userInfoError: null, + gatewayApiKeyError: null, setToken: (token) => set({ token }), + fetchGatewayApiKey: async () => { + const token = get().token?.trim() || getStoredLoginToken(); + if (!token) { + set({ gatewayApiKey: null, gatewayApiKeyError: null }); + return null; + } + + set({ isLoadingGatewayApiKey: true, gatewayApiKeyError: null }); + + try { + const apiKey = await fetchGatewayApiKeyByToken(token); + set({ + gatewayApiKey: apiKey, + isLoadingGatewayApiKey: false, + gatewayApiKeyError: null, + }); + return apiKey; + } catch (error) { + set({ + gatewayApiKey: null, + isLoadingGatewayApiKey: false, + gatewayApiKeyError: getErrorMessage(error), + }); + return null; + } + }, + clearGatewayApiKey: () => + set({ + gatewayApiKey: null, + isLoadingGatewayApiKey: false, + gatewayApiKeyError: null, + }), fetchUserInfo: async (token) => { const normalizedToken = resolveLoginToken(token, get().token); if (!normalizedToken) { - set({ token: null, userInfo: null, userInfoError: null }); + set({ + token: null, + userInfo: null, + gatewayApiKey: null, + userInfoError: null, + gatewayApiKeyError: null, + }); openLoginRequiredModal(); return null; } const current = get(); if (current.token === normalizedToken && current.userInfo) { + if (!current.gatewayApiKey) { + await get().fetchGatewayApiKey(); + } return current.userInfo; } @@ -130,11 +206,13 @@ export const useUserStore = create()( isLoadingUserInfo: false, userInfoError: null, }); + await get().fetchGatewayApiKey(); return session.userInfo; } catch (error) { set({ isLoadingUserInfo: false, userInfo: null, + gatewayApiKey: null, userInfoError: getErrorMessage(error), }); return null; @@ -144,8 +222,11 @@ export const useUserStore = create()( set({ token: null, userInfo: null, + gatewayApiKey: null, isLoadingUserInfo: false, + isLoadingGatewayApiKey: false, userInfoError: null, + gatewayApiKeyError: null, }), }), { diff --git a/src/store/utils/buildApiHeaders.ts b/src/store/utils/buildApiHeaders.ts index a30198bf..e8f62929 100644 --- a/src/store/utils/buildApiHeaders.ts +++ b/src/store/utils/buildApiHeaders.ts @@ -34,14 +34,14 @@ export function buildGenerateHeaders( const providerKey = provider as ProviderType; const headerName = PROVIDER_HEADER_MAP[providerKey]; + const config = providerSettings.providers[providerKey]; if (headerName) { - const config = providerSettings.providers[providerKey]; if (config?.apiKey) { headers[headerName] = config.apiKey; } - if (providerKey === "newapiwg" && config?.baseUrl) { - headers["X-NewApiWG-Base-URL"] = config.baseUrl; - } + } + if (providerKey === "newapiwg" && config?.baseUrl) { + headers["X-NewApiWG-Base-URL"] = config.baseUrl; } return headers; diff --git a/src/types/user.ts b/src/types/user.ts index 95b903a4..2c408d07 100644 --- a/src/types/user.ts +++ b/src/types/user.ts @@ -47,12 +47,25 @@ export interface UserSession { userInfo: UserInfo; } +export interface UserApiKeyResponse { + data?: { + apikey?: unknown; + } | null; + message?: unknown; + status?: unknown; +} + export interface UserStoreState { token: string | null; userInfo: UserInfo | null; + gatewayApiKey: string | null; isLoadingUserInfo: boolean; + isLoadingGatewayApiKey: boolean; userInfoError: string | null; + gatewayApiKeyError: string | null; setToken: (token: string | null) => void; fetchUserInfo: (token: string) => Promise; + fetchGatewayApiKey: () => Promise; + clearGatewayApiKey: () => void; clearSession: () => void; } diff --git a/src/utils/__tests__/authFetch.test.ts b/src/utils/__tests__/authFetch.test.ts index 7bd17ec0..ded248e0 100644 --- a/src/utils/__tests__/authFetch.test.ts +++ b/src/utils/__tests__/authFetch.test.ts @@ -11,11 +11,11 @@ describe("auth fetch interceptor", () => { afterEach(() => { vi.mocked(openLoginRequiredModal).mockClear(); vi.unstubAllGlobals(); - useUserStore.setState({ token: null, userInfo: null }); + useUserStore.setState({ token: null, userInfo: null, gatewayApiKey: null }); }); it("adds login headers to same-origin API requests", async () => { - useUserStore.setState({ token: "login-token" }); + useUserStore.setState({ token: "login-token", gatewayApiKey: "gateway-key" }); const fetchMock = vi.fn(async () => new Response(JSON.stringify({ ok: true }), { status: 200 })); vi.stubGlobal("fetch", fetchMock); @@ -30,11 +30,12 @@ describe("auth fetch interceptor", () => { const headers = new Headers(init.headers); expect(headers.get("Authorization")).toBe("Bearer login-token"); expect(headers.get("token")).toBe("login-token"); + expect(headers.get("X-NewApiWG-Key")).toBe("gateway-key"); expect(init.credentials).toBe("same-origin"); }); it("does not attach login headers to external requests", async () => { - useUserStore.setState({ token: "login-token" }); + useUserStore.setState({ token: "login-token", gatewayApiKey: "gateway-key" }); const fetchMock = vi.fn(async () => new Response(null, { status: 200 })); vi.stubGlobal("fetch", fetchMock); diff --git a/src/utils/authFetch.ts b/src/utils/authFetch.ts index e62c384c..fb5cebb0 100644 --- a/src/utils/authFetch.ts +++ b/src/utils/authFetch.ts @@ -1,6 +1,7 @@ "use client"; import { useUserStore } from "@/store/userStore"; +import { NEWAPIWG_API_KEY_HEADER } from "@/utils/gatewayHeaders"; import { openLoginRequiredModal } from "@/utils/loginRequiredModal"; const ORIGINAL_FETCH_KEY = Symbol.for("popiart.originalFetch"); @@ -32,21 +33,32 @@ function getLoginToken(): string | null { return token?.trim() || null; } +function getGatewayApiKey(): string | null { + const apiKey = useUserStore.getState().gatewayApiKey; + return apiKey?.trim() || null; +} + function withLoginHeaders(input: RequestInfo | URL, init?: RequestInit): RequestInit | undefined { if (!isSameOriginApiRequest(input)) { return init; } const token = getLoginToken(); + const gatewayApiKey = getGatewayApiKey(); + const requestHeaders = input instanceof Request ? input.headers : undefined; + const headers = new Headers(init?.headers ?? requestHeaders); + if (gatewayApiKey) { + headers.set(NEWAPIWG_API_KEY_HEADER, gatewayApiKey); + } + if (!token) { return { ...init, + headers, credentials: init?.credentials ?? "same-origin", }; } - const requestHeaders = input instanceof Request ? input.headers : undefined; - const headers = new Headers(init?.headers ?? requestHeaders); headers.set("Authorization", `Bearer ${token}`); headers.set("token", token); diff --git a/src/utils/gatewayHeaders.ts b/src/utils/gatewayHeaders.ts new file mode 100644 index 00000000..fe1614ea --- /dev/null +++ b/src/utils/gatewayHeaders.ts @@ -0,0 +1,2 @@ +export const NEWAPIWG_API_KEY_HEADER = "X-NewApiWG-Key"; +export const LEGACY_NEWAPIWG_API_KEY_HEADER = "NEWAPIWG_API_KEY";