Skip to content
Open
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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ JWT_AUDIENCE=brandblitz-client
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
WEB_URL=http://localhost:3000
GOOGLE_REDIRECT_URI=http://localhost:3000/api/auth/callback/google
GOOGLE_OAUTH_PKCE_TTL_SECONDS=300
# Defaults to strict-origin-when-cross-origin; set to no-referrer for stricter deployments.
REFERRER_POLICY=strict-origin-when-cross-origin

# NextAuth configuration
NEXTAUTH_SECRET=same-as-jwt-secret-for-nextauth
Expand Down
3 changes: 3 additions & 0 deletions apps/api/.env.test
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ JWT_REFRESH_SECRET=test-refresh-secret-that-is-at-least-32-chars
GOOGLE_CLIENT_ID=test-google-client-id
GOOGLE_CLIENT_SECRET=test-google-client-secret
WEB_URL=http://localhost:3000
GOOGLE_REDIRECT_URI=http://localhost:3000/api/auth/callback/google
GOOGLE_OAUTH_PKCE_TTL_SECONDS=300
REFERRER_POLICY=strict-origin-when-cross-origin

# Explicit CORS allow-list (required in every environment — no wildcard).
ALLOWED_ORIGINS=http://localhost:3000
Expand Down
4 changes: 1 addition & 3 deletions apps/api/src/db/queries/payouts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,12 @@ export async function createPayout(data: {
VALUES ($1,$2,$3,$4)
ON CONFLICT (challenge_id, user_id) DO UPDATE
SET stellar_address = EXCLUDED.stellar_address,
amount_usdc = EXCLUDED.amount_usdc,
amount_stroops = EXCLUDED.amount_stroops,
status = CASE
WHEN payouts.status = 'failed' THEN 'pending'
ELSE payouts.status
END,
error_message = NULL
RETURNING *`,
[data.challengeId, data.userId, data.stellarAddress, data.amountUsdc]
RETURNING *, (amount_stroops::numeric / 10000000)::numeric(20,7)::text AS amount_usdc`,
[data.challengeId, data.userId, data.stellarAddress, amountStroops]
);
Expand Down
15 changes: 13 additions & 2 deletions apps/api/src/db/queries/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,15 @@ export interface LeaderboardSession extends GameSession {
stellar_address: string | null;
}

export const LEADERBOARD_SORTS = ["score", "rank", "created_at"] as const;
export type LeaderboardSort = (typeof LEADERBOARD_SORTS)[number];

const leaderboardOrderBy: Record<LeaderboardSort, string> = {
score: "gs.total_score DESC, gs.completed_at ASC, gs.id ASC",
rank: "gs.total_score DESC, gs.completed_at ASC, gs.id ASC",
created_at: "gs.created_at DESC, gs.total_score DESC, gs.id ASC",
};

export async function createSession(data: {
userId: string;
challengeId: string;
Expand Down Expand Up @@ -274,8 +283,10 @@ export async function markAbandonedSessions(): Promise<number> {
export async function getLeaderboard(
challengeId: string,
limit = 20,
offset = 0
offset = 0,
sortBy: LeaderboardSort = "score"
): Promise<LeaderboardSession[]> {
const orderBy = leaderboardOrderBy[sortBy];
const result = await query<LeaderboardSession>(
`SELECT gs.*,
u.email AS username,
Expand All @@ -294,7 +305,7 @@ export async function getLeaderboard(
AND gs.is_practice = FALSE
AND gs.status = 'completed'
AND u.deleted_at IS NULL
ORDER BY gs.total_score DESC, gs.completed_at ASC
ORDER BY ${orderBy}
LIMIT $2 OFFSET $3`,
[challengeId, limit, offset]
);
Expand Down
66 changes: 64 additions & 2 deletions apps/api/src/helmet.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,61 @@
import { describe, it, expect } from "vitest";
import type { Express } from "express";
import { beforeAll, describe, it, expect, vi } from "vitest";
import request from "supertest";
import { app } from "./index";

let app: Express;

vi.mock("@brandblitz/stellar", () => ({
MIN_POOL_STROOPS: 1_000_000_000,
WARMUP_MIN_SECONDS: 10,
EscrowClient: vi.fn(),
feeBumpTransaction: vi.fn(),
getHorizonServer: vi.fn(),
getAccountUsdcBalance: vi.fn(),
submitBatchPayout: vi.fn(),
drainSharedAgent: vi.fn(),
}));

vi.mock("./routes/admin/escrow", () => ({
default: (_req: unknown, _res: unknown, next: () => void) => next(),
}));

vi.mock("./routes/admin", () => ({
default: (_req: unknown, _res: unknown, next: () => void) => next(),
}));

vi.mock("./routes/docs", () => ({
default: (_req: unknown, _res: unknown, next: () => void) => next(),
}));

vi.mock("./lib/redis", () => ({
redis: {
call: vi.fn(),
sendCommand: vi.fn(),
get: vi.fn().mockResolvedValue(null),
set: vi.fn().mockResolvedValue("OK"),
del: vi.fn().mockResolvedValue(1),
scan: vi.fn().mockResolvedValue(["0", []]),
disconnect: vi.fn().mockResolvedValue(undefined),
on: vi.fn(),
},
connectRedis: vi.fn().mockResolvedValue(undefined),
}));

vi.mock("./middleware/rate-limit", () => ({
apiLimiter: (_req: unknown, _res: unknown, next: () => void) => next(),
authLimiter: (_req: unknown, _res: unknown, next: () => void) => next(),
challengeStartLimiter: (_req: unknown, _res: unknown, next: () => void) => next(),
uploadLimiter: (_req: unknown, _res: unknown, next: () => void) => next(),
webhookLimiter: (_req: unknown, _res: unknown, next: () => void) => next(),
phoneRateLimit: (_req: unknown, _res: unknown, next: () => void) => next(),
webhookRotationLimiter: (_req: unknown, _res: unknown, next: () => void) => next(),
}));

describe("Helmet Security Headers", () => {
beforeAll(async () => {
app = (await import("./index")).app;
});

it("should include security headers on health endpoint", async () => {
const response = await request(app).get("/health");

Expand All @@ -16,4 +69,13 @@ describe("Helmet Security Headers", () => {
expect(csp).toContain("default-src 'self'");
expect(csp).toContain("frame-ancestors 'none'");
});

it.each(["/sessions", "/leaderboard/global", "/challenges"])(
"sets Referrer-Policy on %s",
async (path) => {
const response = await request(app).get(path);

expect(response.headers["referrer-policy"]).toBe("strict-origin-when-cross-origin");
}
);
});
2 changes: 1 addition & 1 deletion apps/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ app.use(
}
: false,
referrerPolicy: {
policy: "strict-origin-when-cross-origin",
policy: config.REFERRER_POLICY,
},
xFrameOptions: {
action: "deny",
Expand Down
6 changes: 5 additions & 1 deletion apps/api/src/lib/config-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ export const configSchema = z.object({
GOOGLE_CLIENT_ID: z.string().min(1),
GOOGLE_CLIENT_SECRET: z.string().min(1),
WEB_URL: z.string().url().default("http://localhost:3000"),

GOOGLE_REDIRECT_URI: z.string().url().optional(),
GOOGLE_OAUTH_PKCE_TTL_SECONDS: z.coerce.number().int().positive().max(900).default(300),
/**
* Comma-separated list of origins permitted by CORS. Required in EVERY
* environment — there is intentionally no default and no wildcard fallback.
Expand All @@ -49,6 +50,9 @@ export const configSchema = z.object({
.refine((origins) => !origins.includes("*"), {
message: "ALLOWED_ORIGINS must not contain a wildcard '*'",
}),
REFERRER_POLICY: z
.enum(["strict-origin-when-cross-origin", "no-referrer"])
.default("strict-origin-when-cross-origin"),

// Stellar
STELLAR_NETWORK: z.enum(["testnet", "public"]).default("testnet"),
Expand Down
3 changes: 3 additions & 0 deletions apps/api/src/lib/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const VALID_ENV: Record<string, string> = {
GOOGLE_CLIENT_ID: "google-client-id",
GOOGLE_CLIENT_SECRET: "google-client-secret",
WEB_URL: "http://localhost:3000",
ALLOWED_ORIGINS: "http://localhost:3000",
STELLAR_NETWORK: "testnet",
HOT_WALLET_SECRET: "SBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB",
HOT_WALLET_PUBLIC_KEY: "GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB",
Expand All @@ -47,6 +48,8 @@ describe("configSchema — valid env", () => {
expect(result.data.PORT).toBe(3001);
expect(result.data.DB_POOL_MAX).toBe(10);
expect(result.data.PAYOUT_WORKER_CONCURRENCY).toBe(2);
expect(result.data.GOOGLE_OAUTH_PKCE_TTL_SECONDS).toBe(300);
expect(result.data.REFERRER_POLICY).toBe("strict-origin-when-cross-origin");
});

it("coerces PORT from string to number", () => {
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/lib/openapi-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
* },
* });
*
* router.post("/google/callback", (req, res) => { /* ... */ });
* router.post("/google/callback", (req, res) => { ... });
*/

import { OpenAPIRegistry, extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi";
Expand Down
32 changes: 30 additions & 2 deletions apps/api/src/middleware/error.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ function makeResponse() {
const status = vi.fn().mockReturnValue({ json });

return {
locals: { requestId: "req-test-123" },
status,
json,
} as any;
Expand Down Expand Up @@ -65,7 +66,10 @@ describe("error middleware", () => {
errorHandler(new Error("Boom"), req, res, vi.fn());

expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({ error: "Internal Server Error" });
expect(res.json).toHaveBeenCalledWith({
error: "Internal Server Error",
requestId: "req-test-123",
});
});

it("includes stack trace in development only", () => {
Expand Down Expand Up @@ -94,7 +98,31 @@ describe("error middleware", () => {
errorHandler(error, req, res, vi.fn());

expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({ error: "Internal Server Error" });
expect(res.json).toHaveBeenCalledWith({
error: "Internal Server Error",
requestId: "req-test-123",
});
});

it("strips database error details from production 5xx responses", () => {
process.env.NODE_ENV = "production";
const req = makeRequest();
const res = makeResponse();
const error = Object.assign(new Error("duplicate key violates unique constraint users_email_key"), {
code: "23505",
table: "users",
column: "email",
constraint: "users_email_key",
stack: "db-stack",
});

errorHandler(error as any, req, res, vi.fn());

expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
error: "Internal Server Error",
requestId: "req-test-123",
});
});

it("maps ZodError to 400 with field-level details", () => {
Expand Down
22 changes: 15 additions & 7 deletions apps/api/src/middleware/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ export function errorHandler(

statusCode = statusCode ?? 500;
const isServerError = statusCode >= 500;
const nodeEnv = process.env.NODE_ENV ?? config.NODE_ENV;
const isProduction = nodeEnv === "production";

if (isServerError) {
message = "Internal Server Error";
Expand All @@ -49,15 +51,21 @@ export function errorHandler(
captureExceptionSync(err, { method: req.method, url: req.url });
}

const payload: Record<string, unknown> = {
error: message,
};
const payload: Record<string, unknown> =
isProduction && isServerError
? {
error: "Internal Server Error",
requestId: res.locals.requestId,
}
: {
error: message,
};

if (err.code) {
if (!(isProduction && isServerError) && err.code) {
payload.code = err.code;
}

if (err instanceof ZodError) {
if (!(isProduction && isServerError) && err instanceof ZodError) {
payload.details = err.issues.map((issue) => ({
path: issue.path,
message: issue.message,
Expand All @@ -69,7 +77,7 @@ export function errorHandler(
}));
}

if (config.NODE_ENV === "development" && err.stack) {
if (nodeEnv === "development" && err.stack) {
payload.stack = err.stack;
}

Expand All @@ -81,4 +89,4 @@ export function createError(message: string, statusCode: number, code?: string):
err.statusCode = statusCode;
err.code = code;
return err;
}
}
5 changes: 5 additions & 0 deletions apps/api/src/routes/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ import { webhookRotationLimiter } from "../middleware/rate-limit";

const router = Router();

// Admin leaderboard-style queries must follow the same rule as
// routes/leaderboard.ts: validate sort params against an allowlist before
// choosing an ORDER BY expression. This file currently has no user-controlled
// leaderboard ORDER BY clauses.

router.use(authenticate);

router.use(async (req, _res, next) => {
Expand Down
2 changes: 0 additions & 2 deletions apps/api/src/routes/admin/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,6 @@ const router = Router();
router.use(authenticate);
router.use(requireAdmin);

import { z } from "zod";

const KnownConfigSchema = z.discriminatedUnion("key", [
z.object({
key: z.literal("anti_cheat"),
Expand Down
Loading