Skip to content

Commit eb6990d

Browse files
Merge pull request #209 from MerlinTheWhiz/types/tighten-express-locals-typing
Types: tighten Express locals typing
2 parents 01e1754 + 531ebd6 commit eb6990d

5 files changed

Lines changed: 76 additions & 52 deletions

File tree

jest.config.cjs

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,34 @@
11
/** @type {import('ts-jest').JestConfigWithTsJest} */
22
module.exports = {
3-
preset: 'ts-jest',
4-
testEnvironment: 'node',
5-
testMatch: ['**/?(*.)+(spec|test).ts'],
3+
preset: "ts-jest",
4+
testEnvironment: "node",
5+
testMatch: ["**/?(*.)+(spec|test).ts"],
66
// Exclude tests that use Node.js native test runner
7-
testPathIgnorePatterns: ['/node_modules/', 'event.emitter.test.ts'],
8-
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
7+
testPathIgnorePatterns: ["/node_modules/", "event.emitter.test.ts"],
8+
setupFilesAfterEnv: ["<rootDir>/jest.setup.ts"],
99
moduleNameMapper: {
10-
'^(.*/)?generated/prisma/client(\\.js)?$': '<rootDir>/src/test-support/prismaClient.jest.ts',
11-
'^(\\.{1,2}/.*)\\.js$': '$1',
10+
"^(.*/)?generated/prisma/client(\\.js)?$":
11+
"<rootDir>/src/test-support/prismaClient.jest.ts",
12+
"^(\\.{1,2}/.*)\\.js$": "$1",
13+
"^uuid$": "<rootDir>/src/test-support/uuid.jest.cjs",
1214
},
1315
transform: {
14-
'^.+\\.ts$': [
15-
'ts-jest',
16+
"^.+\\.ts$": [
17+
"ts-jest",
1618
{
1719
useESM: false,
1820
tsconfig: {
19-
module: 'commonjs',
20-
moduleResolution: 'node',
21+
module: "commonjs",
22+
moduleResolution: "node",
2123
isolatedModules: true,
2224
},
2325
},
2426
],
2527
},
28+
// Transform selected ESM packages (e.g. uuid) so Jest can parse them
29+
transformIgnorePatterns: ["node_modules/(?!(uuid)/)"],
2630
// Parallel execution settings
27-
maxWorkers: '50%', // Use 50% of available CPU cores
31+
maxWorkers: "50%", // Use 50% of available CPU cores
2832
// Ensure proper cleanup between tests
2933
clearMocks: true,
3034
resetMocks: false,

src/lib/prisma.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ function getPrismaClient(): PrismaClientLike {
1212
if (!prisma) {
1313
const connectionString = process.env.DATABASE_URL;
1414
if (!connectionString) {
15-
throw new Error('DATABASE_URL environment variable is required');
15+
throw new Error("DATABASE_URL environment variable is required");
1616
}
1717
const adapter = new PrismaPg({ connectionString });
1818
prisma = new PrismaClient({ adapter }) as unknown as PrismaClientLike;
@@ -31,6 +31,6 @@ export default new Proxy({} as PrismaClientLike, {
3131
get(_target, prop, receiver) {
3232
const client = getPrismaClient();
3333
const value = Reflect.get(client, prop, receiver);
34-
return typeof value === 'function' ? value.bind(client) : value;
34+
return typeof value === "function" ? value.bind(client) : value;
3535
},
3636
});

src/middleware/requireAuth.ts

Lines changed: 40 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,37 @@
1-
import type { NextFunction, Request, Response } from 'express';
2-
import jwt from 'jsonwebtoken';
1+
import type { NextFunction, Request, Response } from "express";
2+
import jwt from "jsonwebtoken";
33

4-
import type { AuthenticatedUser } from '../types/auth.js';
5-
import { UnauthorizedError } from '../errors/index.js';
6-
import { logger } from '../logger.js';
4+
import type { AuthenticatedUser } from "../types/auth.js";
5+
import { UnauthorizedError } from "../errors/index.js";
6+
import { logger } from "../logger.js";
77

8-
export interface AuthenticatedLocals {
8+
// Re-export the locals shape for files that import it from this module
9+
export type AuthenticatedLocals = {
910
authenticatedUser?: AuthenticatedUser;
10-
}
11-
12-
// Extend Express Request to carry the authenticated developer id
13-
declare module 'express-serve-static-core' {
14-
interface Request {
15-
developerId?: string;
16-
}
17-
}
11+
};
1812

1913
/** Restrict accepted signing algorithms to prevent algorithm-confusion attacks. */
20-
const ALLOWED_ALGORITHMS: jwt.Algorithm[] = ['HS256'];
14+
const ALLOWED_ALGORITHMS: jwt.Algorithm[] = ["HS256"];
2115

2216
export const requireAuth = (
2317
req: Request,
2418
res: Response<unknown, AuthenticatedLocals>,
25-
next: NextFunction
19+
next: NextFunction,
2620
): void => {
2721
let userId: string | undefined;
2822

29-
const authHeader = req.header('authorization');
30-
if (authHeader?.startsWith('Bearer ')) {
31-
const token = authHeader.slice('Bearer '.length).trim();
23+
const authHeader = req.header("authorization");
24+
if (authHeader?.startsWith("Bearer ")) {
25+
const token = authHeader.slice("Bearer ".length).trim();
3226

3327
if (!token) {
34-
next(new UnauthorizedError('Missing token', 'MISSING_TOKEN'));
28+
next(new UnauthorizedError("Missing token", "MISSING_TOKEN"));
3529
return;
3630
}
3731

3832
const secret = process.env.JWT_SECRET;
3933
if (!secret) {
40-
logger.error('[requireAuth] JWT_SECRET is not configured');
34+
logger.error("[requireAuth] JWT_SECRET is not configured");
4135
next(new UnauthorizedError());
4236
return;
4337
}
@@ -48,37 +42,45 @@ export const requireAuth = (
4842
});
4943

5044
// jwt.verify can return a plain string for unsigned payloads
51-
if (typeof decoded === 'string' || !decoded) {
52-
logger.warn('[requireAuth] Token payload is not a valid object');
53-
next(new UnauthorizedError('Invalid token', 'INVALID_TOKEN'));
45+
if (typeof decoded === "string" || !decoded) {
46+
logger.warn("[requireAuth] Token payload is not a valid object");
47+
next(new UnauthorizedError("Invalid token", "INVALID_TOKEN"));
5448
return;
5549
}
5650

5751
const uid = (decoded as Record<string, unknown>).userId;
58-
if (typeof uid !== 'string' || uid.trim() === '') {
59-
logger.warn('[requireAuth] Token missing required userId claim');
60-
next(new UnauthorizedError('Token missing required claims', 'MISSING_CLAIMS'));
52+
if (typeof uid !== "string" || uid.trim() === "") {
53+
logger.warn("[requireAuth] Token missing required userId claim");
54+
next(
55+
new UnauthorizedError(
56+
"Token missing required claims",
57+
"MISSING_CLAIMS",
58+
),
59+
);
6160
return;
6261
}
6362

6463
userId = uid;
6564
} catch (err) {
6665
// Log the failure reason but never the token contents
67-
const code = err instanceof jwt.TokenExpiredError
68-
? 'TOKEN_EXPIRED'
69-
: err instanceof jwt.NotBeforeError
70-
? 'TOKEN_NOT_ACTIVE'
71-
: 'INVALID_TOKEN';
66+
const code =
67+
err instanceof jwt.TokenExpiredError
68+
? "TOKEN_EXPIRED"
69+
: err instanceof jwt.NotBeforeError
70+
? "TOKEN_NOT_ACTIVE"
71+
: "INVALID_TOKEN";
7272

73-
logger.warn('[requireAuth] JWT verification failed', { code });
74-
next(new UnauthorizedError(
75-
code === 'TOKEN_EXPIRED' ? 'Token expired' : 'Invalid token',
76-
code,
77-
));
73+
logger.warn("[requireAuth] JWT verification failed", { code });
74+
next(
75+
new UnauthorizedError(
76+
code === "TOKEN_EXPIRED" ? "Token expired" : "Invalid token",
77+
code,
78+
),
79+
);
7880
return;
7981
}
8082
} else {
81-
userId = req.header('x-user-id');
83+
userId = req.header("x-user-id");
8284
}
8385

8486
if (!userId) {

src/test-support/uuid.jest.cjs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
const { randomUUID } = require("crypto");
2+
3+
module.exports = {
4+
v4: () => randomUUID(),
5+
};

src/types/express.d.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,20 @@
1+
import type { AuthenticatedUser } from "./auth";
2+
13
declare global {
24
namespace Express {
5+
/**
6+
* Locals available on Response.locals throughout the app.
7+
* Add other commonly used locals here to avoid per-file casts.
8+
*/
9+
interface Locals {
10+
authenticatedUser?: AuthenticatedUser;
11+
// dbPool is set in `app.ts` during initialization and is useful in handlers
12+
dbPool?: unknown;
13+
}
14+
315
interface Request {
416
id: string;
17+
developerId?: string;
518
user?: Record<string, unknown>;
619
vault?: Record<string, unknown> | null;
720
api?: Record<string, unknown>;

0 commit comments

Comments
 (0)