From 38e58480a5c441ef3deeda5f91d70b75ff8f3fd8 Mon Sep 17 00:00:00 2001 From: Douglas Francis Date: Fri, 29 May 2026 14:12:52 +0100 Subject: [PATCH 1/2] feat(auth): integrate Redis token blacklisting and lifecycle hooks with verification suites --- backend/package.json | 4 +- backend/src/index.ts | 22 +++++++-- backend/test/run-tests.ts | 96 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 6 deletions(-) create mode 100644 backend/test/run-tests.ts diff --git a/backend/package.json b/backend/package.json index c8356698..866ab005 100644 --- a/backend/package.json +++ b/backend/package.json @@ -7,7 +7,7 @@ "dev": "nodemon src/index.ts", "build": "tsc", "start": "node dist/index.js", - "test": "echo \"Error: no test specified\" && exit 1" + "test": "ts-node test/run-tests.ts" }, "keywords": [], "author": "", @@ -47,4 +47,4 @@ "ts-node": "^10.9.2", "typescript": "^6.0.3" } -} +} \ No newline at end of file diff --git a/backend/src/index.ts b/backend/src/index.ts index 207d68b4..c5ca2c04 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -4,6 +4,7 @@ import cookieParser from "cookie-parser"; import crypto from "crypto"; import dotenv from "dotenv"; import { prisma, connectWithRetry, startPoolHealthCheck } from "./config/db"; +import { redis } from "./config/redis"; import { trace } from "./config/tracing"; import { intakeRateLimit } from "./middleware/intakeRateLimit"; import { sqlInjectionGuard } from "./middleware/sanitize"; @@ -42,9 +43,9 @@ app.use(express.json()); // CSRF protection middleware (double-submit cookie pattern) const csrfMiddleware = (req: Request, res: Response, next: NextFunction) => { // Skip CSRF for GET/HEAD/OPTIONS and auth challenge/verify routes - if (["GET", "HEAD", "OPTIONS"].includes(req.method) || - (req.path.startsWith("/api/v1/auth/") && - (req.path.endsWith("/challenge") || req.path.endsWith("/verify")))) { + if (["GET", "HEAD", "OPTIONS"].includes(req.method) || + (req.path.startsWith("/api/v1/auth/") && + (req.path.endsWith("/challenge") || req.path.endsWith("/verify")))) { return next(); } @@ -62,7 +63,7 @@ const csrfMiddleware = (req: Request, res: Response, next: NextFunction) => { // Route to get CSRF token app.get("/api/v1/auth/csrf", (req: Request, res: Response) => { const csrfToken = crypto.randomBytes(32).toString("hex"); - + // Set CSRF cookie (HttpOnly false so frontend can read it, SameSite strict) res.cookie(CSRF_COOKIE_NAME, csrfToken, { httpOnly: false, @@ -161,6 +162,12 @@ process.on("SIGTERM", async () => { stopStorageCleanup(); try { await prisma.$disconnect(); + try { + await redis.quit(); + logger.info("Redis connection closed"); + } catch (err: any) { + logger.debug("Error closing Redis on shutdown:", err?.message || err); + } logger.info("Database connection closed"); process.exit(0); } catch (error) { @@ -178,6 +185,13 @@ process.on("SIGTERM", async () => { async function bootstrap(): Promise { try { await connectWithRetry(); + // Ensure Redis client is connected (lazyConnect enabled in config) + try { + await redis.connect(); + console.log("[redis] connected (bootstrap)"); + } catch (err: any) { + console.warn("[redis] could not connect at startup:", err?.message || err); + } startPoolHealthCheck(); startStorageCleanup(); app.listen(port, () => { diff --git a/backend/test/run-tests.ts b/backend/test/run-tests.ts new file mode 100644 index 00000000..c8a8f55e --- /dev/null +++ b/backend/test/run-tests.ts @@ -0,0 +1,96 @@ +import assert from "assert"; +import jwt from "jsonwebtoken"; +import crypto from "crypto"; + +// Ensure ts-node can run this file even if project modules expect env vars +process.env.JWT_SECRET = process.env.JWT_SECRET || "test-secret"; + +// Lightweight mock Redis implementation +class MockRedis { + store: Map = new Map(); + lastSetArgs: any = null; + async connect() { } + async quit() { } + async set(key: string, value: string, ...args: any[]) { + // Parse args for EX and NX + let exSeconds: number | undefined = undefined; + for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (a === "EX") { + exSeconds = Number(args[i + 1]); + } + } + const expiresAt = exSeconds ? Math.floor(Date.now() / 1000) + exSeconds : undefined; + this.store.set(key, { value, expiresAt }); + this.lastSetArgs = { key, value, exSeconds, args }; + return "OK"; + } + async get(key: string) { + const v = this.store.get(key); + if (!v) return null; + if (v.expiresAt && v.expiresAt < Math.floor(Date.now() / 1000)) { + this.store.delete(key); + return null; + } + return v.value; + } +} + +async function main() { + // Replace the real Redis client with our mock before importing modules + const redisModulePath = "../src/config/redis"; + const redisModule = require(redisModulePath); + const mock = new MockRedis(); + redisModule.redis = mock; + + const auth = require("../src/routes/auth"); + const authGuardModule = require("../src/middleware/authGuard"); + + // Test 1: blacklistToken stores the key and isTokenBlacklisted reads it + const jti = "test-jti-1"; + const expiresAt = Math.floor(Date.now() / 1000) + 60; // expires in 60s + await auth.blacklistToken(jti, expiresAt); + assert(mock.lastSetArgs, "redis.set should have been called"); + assert.strictEqual(mock.lastSetArgs.key, `jwt:blacklist:${jti}`); + + const blacklisted = await auth.isTokenBlacklisted(jti); + assert.strictEqual(blacklisted, true, "Token should be reported blacklisted"); + + // Test 2: TTL matches remaining seconds approximately + const recordedTtl = mock.lastSetArgs.exSeconds; + const expected = expiresAt - Math.floor(Date.now() / 1000); + assert(Math.abs(recordedTtl - expected) <= 2, `TTL should match remaining lifetime (got ${recordedTtl}, expected ${expected})`); + + // Test 3: authGuard rejects a request when token jti is blacklisted + // Create an access token with the same jti and appropriate claims + const secret = process.env.JWT_SECRET as string; + const token = jwt.sign({ address: "GABCDEFGHIJKLMNOPQRSTUVWXYYYYYYYYYYYYYYYYYYYYY" }, secret, { + jwtid: jti, + subject: "GABCDEFGHIJKLMNOPQRSTUVWXYYYYYYYYYYYYYYYYYYYYY", + expiresIn: 60, // 1 minute + issuer: "lance-marketplace", + audience: "lance-frontend", + }); + + // Build fake req/res/next + const req: any = { headers: { authorization: `Bearer ${token}` }, cookies: {} }; + let statusSet: number | null = null; + let jsonBody: any = null; + const res: any = { + status(code: number) { statusSet = code; return this; }, + json(body: any) { jsonBody = body; return this; } + }; + let nextCalled = false; + const next = () => { nextCalled = true; }; + + await authGuardModule.authGuard(req, res, next); + assert.strictEqual(statusSet, 401, "authGuard should respond 401 for blacklisted token"); + assert(jsonBody && jsonBody.error && jsonBody.error.includes("revoked" || "revoked"), "Expected revoked message"); + + console.log("ALL TESTS PASSED"); +} + +main().catch((err) => { + console.error("Tests failed:", err); + process.exit(1); +}); From 2be94b8217f989d4e90b3f2573040dcd1300e554 Mon Sep 17 00:00:00 2001 From: Douglas Francis Date: Sat, 30 May 2026 01:06:51 +0100 Subject: [PATCH 2/2] Fix pool-enhanced syntax; add missing index event structs in job_registry --- backend/src/routes/pool-enhanced.ts | 12 +++++------- contracts/job_registry/src/lib.rs | 15 +++++++++++++++ 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/backend/src/routes/pool-enhanced.ts b/backend/src/routes/pool-enhanced.ts index c3b6dbdb..087ac29e 100644 --- a/backend/src/routes/pool-enhanced.ts +++ b/backend/src/routes/pool-enhanced.ts @@ -60,7 +60,7 @@ router.get("/health", async (req: Request, res: Response) => { const uptime = stats.uptimeSeconds || 0; // Determine overall health status - const isPrimary Healthy = stats.primaryStatus === "healthy"; + const isPrimaryHealthy = stats.primaryStatus === "healthy"; const hasHealthyReplicas = (stats.replicaStatuses || []).some( (s: string) => s === "healthy" ); @@ -95,8 +95,8 @@ router.get("/health", async (req: Request, res: Response) => { connectionPoolUtilization: stats.totalConnections && stats.totalConnections > 0 ? ((stats.totalConnections - (stats.idleConnections || 0)) / - stats.totalConnections) * - 100 + stats.totalConnections) * + 100 : 0, uptime: uptime, timestamp: Date.now(), @@ -141,8 +141,7 @@ router.get("/stats", async (req: Request, res: Response) => { ``, `# HELP lance_pool_active_connections Active database pool connections`, `# TYPE lance_pool_active_connections gauge`, - `lance_pool_active_connections ${ - (stats.totalConnections || 0) - (stats.idleConnections || 0) + `lance_pool_active_connections ${(stats.totalConnections || 0) - (stats.idleConnections || 0) }`, ``, `# HELP lance_pool_waiting_requests Requests waiting for connection`, @@ -155,8 +154,7 @@ router.get("/stats", async (req: Request, res: Response) => { ``, `# HELP lance_pool_health_checks_total Total health checks performed`, `# TYPE lance_pool_health_checks_total counter`, - `lance_pool_health_checks_total ${ - (stats.successfulHealthChecks || 0) + (stats.failedHealthChecks || 0) + `lance_pool_health_checks_total ${(stats.successfulHealthChecks || 0) + (stats.failedHealthChecks || 0) }`, ``, `# HELP lance_pool_health_check_failures_total Failed health checks`, diff --git a/contracts/job_registry/src/lib.rs b/contracts/job_registry/src/lib.rs index dd4a1042..0ade30b2 100644 --- a/contracts/job_registry/src/lib.rs +++ b/contracts/job_registry/src/lib.rs @@ -60,6 +60,21 @@ pub struct JobAssignedIndexEvent { pub final_amount: i128, } +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BidPlacedIndexEvent { + pub job_id: u64, + pub bidder: Address, + pub amount: i128, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct JobStorageReclaimedEvent { + pub job_id: u64, + pub reclaimer: Address, +} + /* ----------------------------------------------------------------- 3. Smart Contract Implementation ----------------------------------------------------------------- */