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
4 changes: 1 addition & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import express from "express";
import { featureFlagsRouter } from './routes/admin/featureFlags';
import helmet from "helmet";
import pinoHttp from "pino-http";
import { v4 as uuidv4 } from "uuid";
Expand All @@ -19,7 +18,7 @@ import { usersRouter } from "./routes/users";
import { usersHealthRouter } from "./routes/users/health";
import { userPortfolioRouter } from "./routes/users/portfolio";
import { devicesRouter } from "./routes/devices";
import { adminFeatureFlagsRouter } from "./routes/admin/feature-flags";
import { adminFeatureFlagsRouter } from "./routes/admin/featureFlags";
import { adminUsersRouter } from "./routes/adminUsers";
import { leaderboardRouter } from "./routes/leaderboard";
import { createDocsRouter } from "./routes/docs";
Expand Down Expand Up @@ -128,7 +127,6 @@ export function createApp(): express.Express {
app.use("/api/admin/audit", adminAuditRouter);
app.use("/api/admin/users", adminUsersRouter);
app.use("/api/admin/feature-flags", adminFeatureFlagsRouter);
app.use('/feature-flags', featureFlagsRouter);
app.use("/api/admin/markets", adminMarketsRouter);
app.use("/api/admin/schema-versions", adminSchemaVersionsRouter);
app.use("/api/admin/rate-limit", adminRateLimitInspectRouter);
Expand Down
75 changes: 0 additions & 75 deletions src/routes/admin/audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,79 +73,4 @@ export function createAdminAuditRouter(opts: AdminAuditRouterOptions = {}): Rout
return router;
}

export const adminAuditRouter = createAdminAuditRouter();import { Router } from "express";
import { rateLimit } from "express-rate-limit";
import { z } from "zod";
import { requireAdmin } from "../../middleware/requireAdmin";
import { getAuditLogs } from "../../repositories/auditLogRepo";
import { RouteErrorFactory } from "../../errors";
import { searchAuditLogsHandler } from "./audit/search";

export interface AdminAuditRouterOptions {
rateLimitPerMinute?: number;
}

const auditQuerySchema = z.object({
action: z.string().optional(),
actor: z.string().optional(),
startDate: z.string()
.datetime({ message: "startDate must be a valid ISO 8601 datetime string" })
.transform((val) => new Date(val))
.optional(),
endDate: z.string()
.datetime({ message: "endDate must be a valid ISO 8601 datetime string" })
.transform((val) => new Date(val))
.optional(),
cursor: z.string().optional(),
limit: z.string()
.regex(/^\d+$/, { message: "limit must be a positive integer" })
.transform((val) => parseInt(val, 10))
.optional(),
});

export function createAdminAuditRouter(opts: AdminAuditRouterOptions = {}): Router {
const router = Router();
const limit = opts.rateLimitPerMinute ?? 60;

router.use(
rateLimit({
windowMs: 60_000,
limit,
keyGenerator: (req) =>
(req.headers.authorization as string | undefined) ?? req.ip ?? "unknown",
standardHeaders: "draft-6",
legacyHeaders: false,
message: { error: { code: "rate_limit_exceeded" } },
}),
);

router.use(requireAdmin);

// Mount search handler to clear unused variable lint error
router.get("/search", searchAuditLogsHandler);

router.get("/", async (req, res, next) => {
try {
const parseResult = auditQuerySchema.safeParse(req.query);
if (!parseResult.success) {
throw RouteErrorFactory.validation(
parseResult.error.issues[0]?.message ?? "invalid query parameters",
);
}

const filters = parseResult.data;
const page = await getAuditLogs(filters);

res.json({
data: page.data,
nextCursor: page.nextCursor,
});
} catch (e) {
next(e);
}
});

return router;
}

export const adminAuditRouter = createAdminAuditRouter();
13 changes: 11 additions & 2 deletions src/routes/auth.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Router } from "express";
import { z } from "zod";
import { StrKey } from "@stellar/stellar-sdk";
import { conditionalGet } from "../middleware/etag";
import { createPerUserRateLimiter } from "../middleware/rateLimit";
import {
rotateRefreshToken,
Expand Down Expand Up @@ -51,6 +52,8 @@ authRouter.post("/refresh", async (req, res, next) => {
throw result.error;
}

if (conditionalGet(result.value, req, res)) return;

res.json(result.value);
} catch (err) {
next(err);
Expand Down Expand Up @@ -99,10 +102,14 @@ authRouter.post("/challenge", async (req, res, next) => {
}

const result = await createChallenge(parsed.data.stellarAddress);
res.status(201).json({
const payload = {
nonce: result.nonce,
expiresAt: result.expiresAt.toISOString(),
});
};

if (conditionalGet(payload, req, res)) return;

res.status(201).json(payload);
} catch (e) {
next(e);
}
Expand Down Expand Up @@ -134,6 +141,8 @@ authRouter.post("/verify", async (req, res, next) => {
throw result.error;
}

if (conditionalGet(result.value, req, res)) return;

res.status(200).json(result.value);
} catch (e) {
next(e);
Expand Down
6 changes: 3 additions & 3 deletions src/routes/predictions/cancel.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
import { Router } from 'express';
import { authenticate } from '../../middleware/auth';
import { requireAuth } from '../../middleware/requireAuth';
import { db } from '../../db';
import { predictions, users } from '../../db/schema';
import { eq, and } from 'drizzle-orm';
import { logger } from '../../logging';
import { logger } from '../../config/logger';

const router = Router();

/**
* POST /api/predictions/:id/cancel
* Cancel an unresolved prediction and refund stake
*/
router.post('/:id/cancel', authenticate, async (req, res) => {
router.post('/:id/cancel', requireAuth, async (req, res) => {
const correlationId = req.headers['x-correlation-id'] || 'unknown';
const { id } = req.params;
const userId = req.user.id;
Expand Down
23 changes: 22 additions & 1 deletion tests/authChallenge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@ jest.mock("../src/services/auditService", () => ({
createAuditLog: jest.fn().mockResolvedValue(undefined),
}));

const MOCK_EXPIRES_AT = new Date(Date.now() + 300_000);

jest.mock("../src/services/authChallengeService", () => ({
generateNonce: jest.fn(() => "aaaa"),
computeExpiresAt: jest.fn(() => new Date()),
createChallenge: jest.fn((_addr: string) =>
Promise.resolve({
nonce: "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
expiresAt: new Date(Date.now() + 300_000),
expiresAt: MOCK_EXPIRES_AT,
}),
),
verifyAndConsume: jest.fn(() => Promise.resolve(null)),
Expand Down Expand Up @@ -75,6 +77,25 @@ describe("POST /api/auth/challenge", () => {
expect(res.body.error.type).toBe("BadRequest");
}, 10000);

it("supports ETag and returns 304 on match", async () => {
const res = await request(app)
.post("/api/auth/challenge")
.send({ stellarAddress: "GABSCDZCXMOO6CYNTHBGHAOE3RX72FRMNWK6O4FOXW6OBQATNWKBUUW6" });

expect(res.status).toBe(201);
expect(res.headers.etag).toBeDefined();

const etag = res.headers.etag;

const res304 = await request(app)
.post("/api/auth/challenge")
.set("If-None-Match", etag)
.send({ stellarAddress: "GABSCDZCXMOO6CYNTHBGHAOE3RX72FRMNWK6O4FOXW6OBQATNWKBUUW6" });

expect(res304.status).toBe(304);
expect(res304.body).toEqual({});
});

it("rate limits repeated challenge attempts for the same authenticated identity", async () => {
const address = "GABSCDZCXMOO6CYNTHBGHAOE3RX72FRMNWK6O4FOXW6OBQATNWKBUUW6";

Expand Down
32 changes: 32 additions & 0 deletions tests/authVerify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,38 @@ describe("POST /api/auth/verify", () => {
expect(res.body.error.code).toBe("challenge_used");
});

it("supports ETag and returns 304 on match", async () => {
const mockExpiresAt = new Date(Date.now() + 300000);
const mockCreatedAt = new Date();

verifyAndConsume.mockResolvedValueOnce({ nonce, expiresAt: mockExpiresAt });
upsertUserByStellarAddress.mockResolvedValueOnce({ id: "user-1", stellarAddress: address, createdAt: mockCreatedAt });

const payload = {
stellarAddress: address,
nonce,
signature: signatureForNonce(keypair, nonce),
};

const res = await request(app).post("/api/auth/verify").send(payload);

expect(res.status).toBe(200);
expect(res.headers.etag).toBeDefined();

const etag = res.headers.etag;

verifyAndConsume.mockResolvedValueOnce({ nonce, expiresAt: mockExpiresAt });
upsertUserByStellarAddress.mockResolvedValueOnce({ id: "user-1", stellarAddress: address, createdAt: mockCreatedAt });

const res304 = await request(app)
.post("/api/auth/verify")
.set("If-None-Match", etag)
.send(payload);

expect(res304.status).toBe(304);
expect(res304.body).toEqual({});
});

it("returns 401 bad_signature for wrong signer", async () => {
verifyAndConsume.mockResolvedValueOnce({ nonce, expiresAt: new Date(Date.now() + 300000) });
const wrongKeypair = Keypair.random();
Expand Down
31 changes: 31 additions & 0 deletions tests/refreshToken.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,37 @@ describe("Refresh Token Rotation and Lifecycle", () => {
});

describe("POST /api/auth/refresh", () => {
it("returns an ETag header on the response", async () => {
const validDate = new Date(Date.now() + 1000000);
const userId = "user-uuid-123";

mockLimit.mockResolvedValueOnce([
{
id: "token-uuid-1",
userId,
tokenHash: hashToken("valid-token"),
familyId: "family-uuid-999",
parentId: null,
expiresAt: validDate,
revokedAt: null,
},
]);
mockLimit.mockResolvedValueOnce([
{
id: userId,
stellarAddress: "GC3O2R44K...STELLAR",
createdAt: new Date(),
},
]);

const res = await request(app)
.post("/api/auth/refresh")
.send({ refreshToken: "valid-token" });

expect(res.status).toBe(200);
expect(res.headers.etag).toBeDefined();
});

it("returns 400 if refreshToken is not provided or invalid", async () => {
const res = await request(app).post("/api/auth/refresh").send({});
expect(res.status).toBe(400);
Expand Down