diff --git a/src/index.ts b/src/index.ts index 9a23682b..782d6f8f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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"; @@ -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"; @@ -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); diff --git a/src/routes/admin/audit.ts b/src/routes/admin/audit.ts index 4955ed9a..e9a805b1 100644 --- a/src/routes/admin/audit.ts +++ b/src/routes/admin/audit.ts @@ -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(); diff --git a/src/routes/auth.ts b/src/routes/auth.ts index bf37e348..07422c1e 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -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, @@ -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); @@ -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); } @@ -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); diff --git a/src/routes/predictions/cancel.ts b/src/routes/predictions/cancel.ts index ca49cc3f..590ab1da 100644 --- a/src/routes/predictions/cancel.ts +++ b/src/routes/predictions/cancel.ts @@ -1,9 +1,9 @@ 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(); @@ -11,7 +11,7 @@ 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; diff --git a/tests/authChallenge.test.ts b/tests/authChallenge.test.ts index 0cdc87cc..2b7b5677 100644 --- a/tests/authChallenge.test.ts +++ b/tests/authChallenge.test.ts @@ -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)), @@ -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"; diff --git a/tests/authVerify.test.ts b/tests/authVerify.test.ts index 33799e00..3bf196f5 100644 --- a/tests/authVerify.test.ts +++ b/tests/authVerify.test.ts @@ -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(); diff --git a/tests/refreshToken.test.ts b/tests/refreshToken.test.ts index 3c40c136..9f3bafdc 100644 --- a/tests/refreshToken.test.ts +++ b/tests/refreshToken.test.ts @@ -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);