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
13 changes: 9 additions & 4 deletions next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ const withBundleAnalyzer = bundleAnalyzer({

/**
* CSP sources for Freighter wallet messaging and Stellar network/CDN resources.
* Freighter communicates via window.postMessage; chrome-extension: covers
* residual extension fetches. Horizon + Soroban RPC cover on-chain calls.
* Wallet extension origins are explicitly scoped. Horizon + Soroban RPC cover
* on-chain calls.
*/
function resolveApiOrigin() {
try {
Expand All @@ -23,6 +23,11 @@ function resolveApiOrigin() {

const isDev = process.env.NODE_ENV !== "production";
const apiOrigin = resolveApiOrigin();
const walletExtensionOrigins = [
"chrome-extension://bcacfldlkkdogcmkkibnjlakofdplcbk",
"chrome-extension://ldiagbjmlmjiieclmdkagofdjcgodjle",
"chrome-extension://hgmoaheomcjnaheggkfafnjilfcefbmo",
];

const ContentSecurityPolicy = [
"default-src 'self'",
Expand All @@ -45,9 +50,9 @@ const ContentSecurityPolicy = [
"https://*.stellar.gateway.fm",
"https://stellar.expert",
"https://*.stellar.expert",
"chrome-extension:",
...walletExtensionOrigins,
].join(" "),
"frame-src 'self' chrome-extension:",
["frame-src 'self'", ...walletExtensionOrigins].join(" "),
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
Expand Down
1 change: 1 addition & 0 deletions src/app/courses/[courseId]/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Metadata } from "next";
import { encodePathParam } from "@/lib/api/path";
import { apiClient, requireApiData } from "@/lib/api/client";
import type { Course } from "@/types/course";

Expand Down
10 changes: 7 additions & 3 deletions src/app/courses/[courseId]/opengraph-image.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createOgImage, OG_SIZE } from "@/lib/utils/og-image";
import { encodePathParam } from "@/lib/api/path";
import type { Course } from "@/types/course";

export const alt = "ChainLearn course";
Expand All @@ -18,9 +19,12 @@ export default async function Image({ params }: ImageProps) {
"Earn tokens and verifiable credentials while learning on Stellar.";

try {
const response = await fetch(`${API_URL}/courses/${courseId}`, {
next: { revalidate: 3600 },
});
const response = await fetch(
`${API_URL}/courses/${encodePathParam(courseId, "courseId")}`,
{
next: { revalidate: 3600 },
}
);
if (response.ok) {
const payload = (await response.json()) as { data?: Course } & Course;
const course = payload.data ?? payload;
Expand Down
2 changes: 1 addition & 1 deletion src/app/credentials/[credentialId]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>

try {
const response = await apiClient.get<CredentialNFT>(
`/credentials/${credentialId}`
`/credentials/${encodePathParam(credentialId, "credentialId")}`
);
const credential = requireApiData(response.data);
const title = `${credential.courseTitle} - Credential`;
Expand Down
8 changes: 5 additions & 3 deletions src/app/credentials/[credentialId]/opengraph-image.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createOgImage, OG_SIZE } from "@/lib/utils/og-image";
import { encodePathParam } from "@/lib/api/path";
import type { CredentialNFT } from "@/types/stellar";

export const alt = "ChainLearn credential";
Expand All @@ -17,9 +18,10 @@ export default async function Image({ params }: ImageProps) {
let description = "On-chain verifiable credential issued on the Stellar network.";

try {
const response = await fetch(`${API_URL}/credentials/${credentialId}`, {
next: { revalidate: 3600 },
});
const response = await fetch(
`${API_URL}/credentials/${encodePathParam(credentialId, "credentialId")}`,
{ next: { revalidate: 3600 } }
);
if (response.ok) {
const payload = (await response.json()) as {
data?: CredentialNFT;
Expand Down
3 changes: 2 additions & 1 deletion src/app/verify/[credentialId]/opengraph-image.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createOgImage, OG_SIZE } from "@/lib/utils/og-image";
import { encodePathParam } from "@/lib/api/path";

export const alt = "ChainLearn credential verification";
export const size = OG_SIZE;
Expand All @@ -18,7 +19,7 @@ export default async function Image({ params }: ImageProps) {

try {
const response = await fetch(
`${API_URL}/credentials/${credentialId}/verify`,
`${API_URL}/credentials/${encodePathParam(credentialId, "credentialId")}/verify`,
{ next: { revalidate: 3600 } }
);
if (response.ok) {
Expand Down
2 changes: 1 addition & 1 deletion src/lib/api/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ export async function revokeSession(
const validToken = await getValidToken();
const token = validToken || jwt;
const response = await apiClient.delete<{ success: boolean }>(
`/auth/sessions/${sessionId}`,
`/auth/sessions/${encodePathParam(sessionId, "sessionId")}`,
token,
signal
);
Expand Down
5 changes: 4 additions & 1 deletion src/lib/api/client.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { ApiError, ApiResponse } from "@/types/api";
import { ApiError } from "@/types/api";
import type { ApiResponse, ResponseInterceptor } from "@/types/api";
import { useAuthStore } from "@/store/auth-store";
import { useErrorStore } from "@/store/error-store";

export type { ResponseInterceptor } from "@/types/api";

/**
* AbortController support (#315):
* - signal parameter on all methods (get, post, put, delete)
Expand Down
6 changes: 3 additions & 3 deletions src/lib/api/courses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ export async function enrollInCourse(
const validToken = await getValidToken();
const token = validToken || jwt;
const response = await apiClient.post<CourseEnrollment>(
`/courses/${courseId}/enroll`,
`/courses/${encodePathParam(courseId, "courseId")}/enroll`,
{},
token,
signal
Expand All @@ -118,7 +118,7 @@ export async function getModule(
signal?: AbortSignal
): Promise<Module> {
const response = await apiClient.get<Module>(
`/courses/${courseId}/modules/${moduleId}`,
`/courses/${encodePathParam(courseId, "courseId")}/modules/${encodePathParam(moduleId, "moduleId")}`,
jwt,
signal
);
Expand Down Expand Up @@ -147,7 +147,7 @@ export async function markModuleComplete(
const validToken = await getValidToken();
const token = validToken || jwt;
const response = await apiClient.post<ModuleCompletionResult>(
`/courses/${courseId}/modules/${moduleId}/complete`,
`/courses/${encodePathParam(courseId, "courseId")}/modules/${encodePathParam(moduleId, "moduleId")}/complete`,
{},
token,
signal
Expand Down
19 changes: 19 additions & 0 deletions src/lib/api/credentials.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,25 @@ describe("verifyCredential", () => {
expect(url).toMatch(/\/credentials\/verify\/cred-abc$/);
});

it("encodes credential IDs as one path segment", async () => {
fetchMock.mockResolvedValueOnce(
jsonResponse({ data: { valid: false }, success: true })
);

await verifyCredential("cred/id?x#fragment");

expect(String(fetchMock.mock.calls[0][0])).toContain(
"/credentials/verify/cred%2Fid%3Fx%23fragment"
);
});

it("rejects a blank credential ID before making a request", async () => {
await expect(verifyCredential(" ")).rejects.toThrow(
"credentialId must be a non-empty path identifier"
);
expect(fetchMock).not.toHaveBeenCalled();
});

it("returns { valid: false, error: 'not_found' } on 404 without throwing", async () => {
fetchMock.mockResolvedValueOnce(
jsonResponse({ message: "Credential not found" }, 404)
Expand Down
6 changes: 4 additions & 2 deletions src/lib/api/credentials.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { apiClient, requireApiData } from "./client";
import { ApiError } from "@/types/api";
import { getValidToken } from "./auth";
import { encodePathParam } from "./path";
import type { CredentialNFT } from "@/types/stellar";

/** Reason a public credential verification did not succeed (issue #309). */
Expand Down Expand Up @@ -48,7 +50,7 @@ export async function getCredential(
signal?: AbortSignal
): Promise<CredentialNFT> {
const response = await apiClient.get<CredentialNFT>(
`/credentials/${credentialId}`,
`/credentials/${encodePathParam(credentialId, "credentialId")}`,
jwt,
signal
);
Expand All @@ -70,7 +72,7 @@ export async function verifyCredential(
): Promise<VerifyCredentialResult> {
try {
const response = await apiClient.get<VerifyCredentialResult>(
`/credentials/verify/${credentialId}`,
`/credentials/verify/${encodePathParam(credentialId, "credentialId")}`,
undefined,
signal
);
Expand Down
3 changes: 2 additions & 1 deletion src/lib/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,5 +71,6 @@ export {
isAbortError,
createLoggingInterceptor,
createAnalyticsInterceptor,
type ResponseInterceptor,
} from "./client";

export type { ResponseInterceptor } from "@/types/api";
7 changes: 6 additions & 1 deletion src/lib/api/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,12 @@ export async function markNotificationAsRead(
): Promise<void> {
const validToken = await getValidToken();
const token = validToken || jwt;
await apiClient.post<void>(`/notifications/${id}/read`, {}, token, signal);
await apiClient.post<void>(
`/notifications/${encodePathParam(id, "notificationId")}/read`,
{},
token,
signal
);
}

/**
Expand Down
22 changes: 22 additions & 0 deletions src/lib/api/path.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { encodePathParam } from "./path";

describe("encodePathParam", () => {
it("encodes reserved URL characters as a single path segment", () => {
expect(encodePathParam("id/part?query#fragment", "id")).toBe(
"id%2Fpart%3Fquery%23fragment"
);
});

it.each([".", ".."])("rejects dot segment %s", (value) => {
expect(() => encodePathParam(value, "id")).toThrow(
"id must be a non-empty path identifier"
);
});

it.each(["", " ", "\t\n"])("rejects blank values", (value) => {
expect(() => encodePathParam(value, "id")).toThrow(
"id must be a non-empty path identifier"
);
});
});
6 changes: 6 additions & 0 deletions src/lib/api/path.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export function encodePathParam(value: string, name: string): string {
if (!value.trim() || value === "." || value === "..") {
throw new TypeError(`${name} must be a non-empty path identifier.`);
}
return encodeURIComponent(value);
}
6 changes: 3 additions & 3 deletions src/lib/api/quizzes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export async function getQuiz(
signal?: AbortSignal
): Promise<Quiz> {
const response = await apiClient.get<Quiz>(
`/courses/${courseId}/quiz`,
`/courses/${encodePathParam(courseId, "courseId")}/quiz`,
jwt,
signal
);
Expand Down Expand Up @@ -98,7 +98,7 @@ export async function submitQuiz(
const validToken = await getValidToken();
const token = validToken || jwt;
const response = await apiClient.post<QuizAttempt>(
`/quizzes/${submission.quizId}/submit`,
`/quizzes/${encodePathParam(submission.quizId, "quizId")}/submit`,
submission,
token,
signal
Expand All @@ -125,7 +125,7 @@ export async function getQuizAttempts(
const validToken = await getValidToken();
const token = validToken || jwt;
const response = await apiClient.get<QuizAttempt[]>(
`/quizzes/${quizId}/attempts`,
`/quizzes/${encodePathParam(quizId, "quizId")}/attempts`,
token,
signal
);
Expand Down
Loading