Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,14 @@ const ContentSecurityPolicy = [
"base-uri 'self'",
"form-action 'self'",
"object-src 'none'",
"report-uri /api/csp-report",
// Avoid breaking local HTTP; enable HSTS-style upgrade only in production
...(isDev ? [] : ["upgrade-insecure-requests"]),
].join("; ");

const securityHeaders = [
{
key: "Content-Security-Policy",
key: "Content-Security-Policy-Report-Only",
value: ContentSecurityPolicy,
},
{
Expand Down
19 changes: 19 additions & 0 deletions src/app/api/csp-report/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
const MAX_REPORT_SIZE = 64 * 1024;

export async function POST(request: Request) {
const body = await request.text();
if (body.length > MAX_REPORT_SIZE) {
return new Response(null, { status: 413 });
}

try {
const report: unknown = JSON.parse(body);
if (report === null || typeof report !== "object") {
return new Response(null, { status: 400 });
}
console.warn("[CSP violation report]", report);
return new Response(null, { status: 204 });
} catch {
return new Response(null, { status: 400 });
}
}
4 changes: 2 additions & 2 deletions src/app/courses/[courseId]/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Metadata } from "next";
import { apiClient } from "@/lib/api/client";
import { apiClient, requireApiData } from "@/lib/api/client";
import type { Course } from "@/types/course";

interface PageProps {
Expand All @@ -11,7 +11,7 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>

try {
const response = await apiClient.get<Course>(`/courses/${courseId}`);
const course = response.data;
const course = requireApiData(response.data);
const images = course.imageUrl
? [
{
Expand Down
4 changes: 2 additions & 2 deletions src/app/credentials/[credentialId]/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Metadata } from "next";
import { apiClient } from "@/lib/api/client";
import { apiClient, requireApiData } from "@/lib/api/client";
import type { CredentialNFT } from "@/types/stellar";

interface PageProps {
Expand All @@ -13,7 +13,7 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
const response = await apiClient.get<CredentialNFT>(
`/credentials/${credentialId}`
);
const credential = response.data;
const credential = requireApiData(response.data);
const title = `${credential.courseTitle} - Credential`;
const description = `On-chain credential for ${credential.courseTitle} on ChainLearn.`;

Expand Down
4 changes: 2 additions & 2 deletions src/app/verify/[credentialId]/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Metadata } from "next";
import { apiClient } from "@/lib/api/client";
import { apiClient, requireApiData } from "@/lib/api/client";

interface PageProps {
params: { credentialId: string };
Expand All @@ -13,7 +13,7 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
valid: boolean;
metadata: { courseTitle: string };
}>(`/credentials/${credentialId}/verify`);
const data = response.data;
const data = requireApiData(response.data);
const title = `Verify - ${data.metadata.courseTitle}`;
const description = `On-chain verification of ${data.metadata.courseTitle} credential on ChainLearn.`;

Expand Down
18 changes: 9 additions & 9 deletions src/lib/api/auth.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { apiClient } from "./client";
import { apiClient, requireApiData } from "./client";
import type { AuthTokens, UserProfile, UserSession } from "@/types/api";
import { useAuthStore } from "@/store/auth-store";
import { getTokenExpiry, isTokenExpired } from "@/lib/utils/jwt";
Expand All @@ -20,7 +20,7 @@ export async function getChallenge(
undefined,
signal
);
return response.data.challenge;
return requireApiData(response.data).challenge;
}

/**
Expand All @@ -40,7 +40,7 @@ export async function verifySignature(
undefined,
signal
);
return response.data;
return requireApiData(response.data);
}

/**
Expand All @@ -53,7 +53,7 @@ export async function getProfile(
const validToken = await getValidToken();
const token = validToken || jwt;
const response = await apiClient.get<UserProfile>("/auth/profile", token, signal);
return response.data;
return requireApiData(response.data);
}

/**
Expand All @@ -72,7 +72,7 @@ export async function updateProfile(
token,
signal
);
return response.data;
return requireApiData(response.data);
}

/**
Expand All @@ -89,7 +89,7 @@ export async function refreshToken(
undefined,
signal
);
return response.data;
return requireApiData(response.data);
} catch (error) {
// Fallback to /api/v1/auth/refresh if configured
try {
Expand All @@ -99,7 +99,7 @@ export async function refreshToken(
undefined,
signal
);
return response.data;
return requireApiData(response.data);
} catch {
throw error;
}
Expand Down Expand Up @@ -179,7 +179,7 @@ export async function getSessions(
const validToken = await getValidToken();
const token = validToken || jwt;
const response = await apiClient.get<UserSession[]>("/auth/sessions", token, signal);
return response.data;
return requireApiData(response.data);
}

/**
Expand All @@ -197,5 +197,5 @@ export async function revokeSession(
token,
signal
);
return response.data;
return requireApiData(response.data);
}
59 changes: 59 additions & 0 deletions src/lib/api/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,62 @@ describe("apiClient request deduplication", () => {
});

describe("apiClient response interceptors", () => {
it("returns null data for successful empty responses", async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 204,
statusText: "No Content",
text: async () => "",
} as unknown as Response);

await expect(
apiClient.get("/empty-response", undefined, undefined, { bypassCache: true })
).resolves.toEqual({ data: null, success: true });
});

it("returns null data for a successful response with a blank body", async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
text: async () => " \n",
} as unknown as Response);

await expect(
apiClient.get("/blank-response", undefined, undefined, { bypassCache: true })
).resolves.toEqual({ data: null, success: true });
});

it("wraps malformed successful JSON in ApiError and notifies error interceptors", async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
text: async () => "<html>proxy error</html>",
} as unknown as Response);

const loggedErrors: unknown[] = [];
const remove = apiClient.addResponseInterceptor({
onError: (error) => {
loggedErrors.push(error);
},
});

try {
await expect(
apiClient.get("/malformed-json", undefined, undefined, { bypassCache: true })
).rejects.toMatchObject({
name: "ApiError",
status: 200,
message: expect.stringContaining("Invalid JSON"),
});
expect(loggedErrors).toHaveLength(1);
expect(loggedErrors[0]).toMatchObject({ name: "ApiError", status: 200 });
} finally {
remove();
}
});

it("runs onResponse and onSuccess and can transform responses", async () => {
fetchMock.mockResolvedValue(jsonResponse({ success: true, data: { value: 10 } }));

Expand Down Expand Up @@ -317,6 +373,9 @@ describe("apiClient retry backoff (issue #311)", () => {
expect(payload.delayMs).toBe(1000);

warnSpy.mockRestore();
});
});

// ── Issue #312: LRU + per-call TTL + cached-response cloning ─────────────────

describe("apiClient cache behaviour (issue #312)", () => {
Expand Down
49 changes: 33 additions & 16 deletions src/lib/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,13 @@ function logRetry(entry: {
const CACHE_TTL_MS = 60_000;
const CACHE_MAX_SIZE = 100;

export function requireApiData<T>(data: T | null): T {
if (data === null) {
throw new ApiError(200, "The API returned an empty response body.");
}
return data;
}

type CacheEntry = {
value: unknown;
expiresAt: number;
Expand Down Expand Up @@ -155,7 +162,9 @@ export type ResponseInterceptor = {
* Post-processing interceptor running after `handleResponse`.
* Can inspect or transform the parsed response data.
*/
onSuccess?: <T>(data: ApiResponse<T>) => ApiResponse<T> | Promise<ApiResponse<T>>;
onSuccess?: (
data: ApiResponse<unknown | null>
) => ApiResponse<unknown | null> | Promise<ApiResponse<unknown | null>>;
/**
* Error interceptor running whenever an error is encountered during a request.
* Can be used for global error logging or analytics events.
Expand Down Expand Up @@ -190,19 +199,19 @@ class ApiClient {
* Runs onResponse interceptors (pre-processing) then handleResponse,
* then onSuccess interceptors (post-processing).
*/
private async processResponse<T>(response: Response): Promise<ApiResponse<T>> {
private async processResponse<T>(response: Response): Promise<ApiResponse<T | null>> {
let currentResponse = response;
for (const interceptor of this.responseInterceptors) {
if (interceptor.onResponse) {
currentResponse = await interceptor.onResponse(currentResponse);
}
}

let data = await this.handleResponse<ApiResponse<T>>(currentResponse);
let data = await this.handleResponse<T>(currentResponse);

for (const interceptor of this.responseInterceptors) {
if (interceptor.onSuccess) {
data = await interceptor.onSuccess(data);
data = (await interceptor.onSuccess(data)) as ApiResponse<T | null>;
}
}

Expand Down Expand Up @@ -236,7 +245,7 @@ class ApiClient {
return headers;
}

private async handleResponse<T>(response: Response): Promise<T> {
private async handleResponse<T>(response: Response): Promise<ApiResponse<T | null>> {
if (!response.ok) {
if (response.status === 401) {
useAuthStore.getState().disconnect();
Expand All @@ -250,14 +259,22 @@ class ApiClient {
}

if (response.status === 204) {
return undefined as T;
return { data: null, success: true };
}

const text = await response.text();
if (!text) {
return undefined as T;
if (!text.trim()) {
return { data: null, success: true };
}
try {
return JSON.parse(text) as ApiResponse<T | null>;
} catch (error) {
const detail = error instanceof Error ? error.message : "Unknown parsing error";
throw new ApiError(
response.status,
`Invalid JSON in successful API response: ${detail}`
);
}
return JSON.parse(text) as T;
}

private isTransientError(error: Error): boolean {
Expand Down Expand Up @@ -423,12 +440,12 @@ class ApiClient {
jwt?: string,
signal?: AbortSignal,
options?: { bypassCache?: boolean; timeout?: number; ttl?: number }
): Promise<ApiResponse<T>> {
): Promise<ApiResponse<T | null>> {
const url = `${this.baseUrl}${path}`;
const key = this.cacheKey(url, jwt);

if (!options?.bypassCache) {
const cached = this.getCached<ApiResponse<T>>(key);
const cached = this.getCached<ApiResponse<T | null>>(key);
if (cached !== undefined) {
return cached;
}
Expand Down Expand Up @@ -474,7 +491,7 @@ class ApiClient {

entry.refs++;
try {
return (await withAbort(entry.promise, signal)) as ApiResponse<T>;
return (await withAbort(entry.promise, signal)) as ApiResponse<T | null>;
} finally {
entry.refs--;
// Last caller gone before the request finished — cancel it for real.
Expand All @@ -497,7 +514,7 @@ class ApiClient {
signal?: AbortSignal,
timeout?: number,
ttl?: number,
): Promise<ApiResponse<T>> {
): Promise<ApiResponse<T | null>> {
try {
const response = await this.fetchWithRetry(
url,
Expand Down Expand Up @@ -534,7 +551,7 @@ class ApiClient {
jwt?: string,
signal?: AbortSignal,
timeout?: number
): Promise<ApiResponse<T>> {
): Promise<ApiResponse<T | null>> {
try {
const response = await this.fetchWithRetry(
`${this.baseUrl}${path}`,
Expand Down Expand Up @@ -571,7 +588,7 @@ class ApiClient {
jwt?: string,
signal?: AbortSignal,
timeout?: number
): Promise<ApiResponse<T>> {
): Promise<ApiResponse<T | null>> {
try {
const response = await this.fetchWithRetry(
`${this.baseUrl}${path}`,
Expand Down Expand Up @@ -607,7 +624,7 @@ class ApiClient {
jwt?: string,
signal?: AbortSignal,
timeout?: number
): Promise<ApiResponse<T>> {
): Promise<ApiResponse<T | null>> {
try {
const response = await this.fetchWithRetry(
`${this.baseUrl}${path}`,
Expand Down
Loading