Skip to content

Commit 0ead0ec

Browse files
authored
fix: auth typings and reduced type ambiguity (#4562)
* fix: revert helper that broke auth * fix: remove type ambiguity, add TS docs
1 parent 303571b commit 0ead0ec

4 files changed

Lines changed: 1047 additions & 21 deletions

File tree

lib/types/request.d.ts

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,8 @@ export interface RequestAuth<
8989
* set to false.
9090
*/
9191
isAuthorized: boolean;
92+
/** true if the request has been authenticated via the `server.inject()` `auth` option, otherwise `undefined`. */
93+
isInjected?: boolean | undefined;
9294
/** the route authentication mode. */
9395
mode: AuthMode;
9496
/** the name of the strategy used. */
@@ -250,7 +252,7 @@ export interface RequestLog {
250252
}
251253

252254
export interface RequestQuery {
253-
[key: string]: any;
255+
[key: string]: string | string[] | undefined;
254256
}
255257

256258
/**
@@ -269,9 +271,9 @@ export interface InternalRequestDefaults {
269271

270272
Payload: stream.Readable | Buffer | string | object;
271273
Query: RequestQuery;
272-
Params: Record<string, any>;
274+
Params: Record<string, string>;
273275
Pres: Record<string, any>;
274-
Headers: Record<string, any>;
276+
Headers: Record<string, string | string[] | undefined>;
275277
RequestApp: RequestApplicationState;
276278

277279
AuthUser: UserCredentials;
@@ -303,11 +305,7 @@ export type ReqRef = Partial<Record<keyof ReqRefDefaults, unknown>>;
303305
/**
304306
* Utilities for merging request refs and other things
305307
*/
306-
export type MergeType<T, U> = {
307-
[K in keyof T]: K extends keyof U
308-
? U[K]
309-
: T[K];
310-
};
308+
export type MergeType<T, U> = Omit<T, keyof U> & U;
311309

312310
export type MergeRefs<T extends ReqRef> = MergeType<ReqRefDefaults, T>;
313311

@@ -441,7 +439,7 @@ export interface Request<Refs extends ReqRef = ReqRefDefaults> extends Podium {
441439
/**
442440
* Same as pre but represented as the response object created by the pre method.
443441
*/
444-
readonly preResponses: Record<string, any>;
442+
readonly preResponses: Record<string, unknown>;
445443

446444
/**
447445
* By default the object outputted from node's URL parse() method.
@@ -474,7 +472,7 @@ export interface Request<Refs extends ReqRef = ReqRefDefaults> extends Podium {
474472
/**
475473
* An object containing parsed HTTP state information (cookies) where each key is the cookie name and value is the matching cookie content after processing using any registered cookie definition.
476474
*/
477-
readonly state: Record<string, any>;
475+
readonly state: Record<string, unknown>;
478476

479477
/**
480478
* The parsed request URI.

lib/types/route.d.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,7 @@ export interface RouteOptionsPreObject<Refs extends ReqRef = ReqRefDefaults> {
358358
/**
359359
* key name used to assign the response of the method to in request.pre and request.preResponses.
360360
*/
361-
assign?: keyof Refs['Pres'] | undefined;
361+
assign?: keyof MergeRefs<Refs>['Pres'] | undefined;
362362
/**
363363
* A failAction value which determine what to do when a pre-handler method throws an error. If assign is specified and the failAction setting is not 'error', the error will be assigned.
364364
*/
@@ -978,5 +978,5 @@ export interface ServerRoute<Refs extends ReqRef = ReqRefDefaults> {
978978
/**
979979
* route custom rules object. The object is passed to each rules processor registered with server.rules(). Cannot be used if route.options.rules is defined.
980980
*/
981-
rules?: Refs['Rules'] | undefined;
981+
rules?: MergeRefs<Refs>['Rules'] | undefined;
982982
}

test/types/index.ts

Lines changed: 237 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import * as CatboxMemory from '@hapi/catbox-memory';
44

55
import {
66
Plugin,
7+
ReqRef,
78
Request,
89
RequestRoute,
910
ResponseToolkit,
@@ -68,20 +69,20 @@ interface RequestDecorations {
6869
prefix: string[];
6970
},
7071
AuthUser: {
71-
id: string,
72-
name: string
73-
email: string
72+
id: string;
73+
name: string;
74+
email: string;
7475
},
7576
AuthCredentialsExtra: {
76-
test: number
77-
}
77+
test: number;
78+
},
7879
AuthApp: {
79-
key: string
80-
name: string
80+
key: string;
81+
name: string;
8182
},
8283
AuthArtifactsExtra: {
83-
some: string
84-
thing: number
84+
some: string;
85+
thing: number;
8586
}
8687
}
8788

@@ -287,3 +288,230 @@ server.decorate('server', 'obj3_1', { func5: theFunc });
287288
// Error when extending on server with objects
288289
// @ts-expect-error Lab does not support overload errors
289290
check.error(() => server.decorate('server', 'obj3_1', { func5: theFunc }, { apply: true, extend: true }));
291+
292+
// Issue #4561 - Generic Request<Refs> should resolve augmented ReqRefDefaults auth properties
293+
294+
interface ExtraCred {
295+
extra_id: string;
296+
}
297+
298+
interface UserProfile {
299+
id: string;
300+
}
301+
302+
declare module '../..' {
303+
interface ReqRefDefaults {
304+
AuthCredentialsExtra: Partial<ExtraCred>;
305+
}
306+
}
307+
308+
// Generic route (no custom refs) should see augmented UserCredentials
309+
const genericAuthRoute: ServerRoute = {
310+
method: 'GET',
311+
path: '/auth-check',
312+
handler: (request, h) => {
313+
314+
check.type<string>(request.auth.credentials.user!.someId);
315+
check.type<string>(request.auth.credentials.user!.someName);
316+
317+
const credIsAny: IsAny<typeof request.auth.credentials> = false;
318+
319+
return 'ok';
320+
}
321+
};
322+
323+
// Generic function should see augmented credentials from ReqRefDefaults
324+
export function processAuthGeneric<Refs extends ReqRef>(req: Request<Refs>): void {
325+
326+
if (req.auth.isAuthenticated && req.auth.credentials.extra_id) {
327+
check.type<string | undefined>(req.auth.credentials.extra_id);
328+
}
329+
}
330+
331+
// Non-generic Request should also see augmented credentials
332+
export function processAuthConcrete(req: Request): void {
333+
334+
if (req.auth.isAuthenticated && req.auth.credentials.extra_id) {
335+
check.type<string | undefined>(req.auth.credentials.extra_id);
336+
}
337+
338+
// credentials should NOT resolve to `any`
339+
const credIsAny: IsAny<typeof req.auth.credentials> = false;
340+
const artifactsIsAny: IsAny<typeof req.auth.artifacts> = false;
341+
}
342+
343+
// Generic function should accept Request with specific route refs
344+
interface SpecificRouteRefs {
345+
Params: { id: string };
346+
}
347+
348+
export function callWithSpecificRefs(req: Request<SpecificRouteRefs>): void {
349+
350+
processAuthGeneric(req);
351+
}
352+
353+
// =============================================================================
354+
// ReqRef System Issue Tests
355+
// Each section demonstrates a specific weakness in the current type system.
356+
// These tests produce VISIBLE compiler errors to demonstrate each problem.
357+
// =============================================================================
358+
359+
// -----------------------------------------------------------------------------
360+
// ISSUE 1: Direct Refs['Key'] access bypasses MergeRefs (route.d.ts:361)
361+
//
362+
// RouteOptionsPreObject.assign uses `keyof Refs['Pres']` instead of
363+
// `keyof MergeRefs<Refs>['Pres']`. When the user doesn't explicitly provide
364+
// `Pres` in their Refs, `Refs['Pres']` is `unknown` (from ReqRef's
365+
// Partial<Record<..., unknown>>), so `keyof unknown` is `never`.
366+
// This means `assign` is impossible unless Pres is explicitly provided.
367+
// -----------------------------------------------------------------------------
368+
369+
// This should compile — the user only customizes Params, and the default
370+
// Pres (Record<string, any>) should allow any string for `assign`.
371+
// ERROR: Type '"user"' is not assignable to type 'never'.
372+
const issuePreAssign: ServerRoute<{ Params: { id: string } }> = {
373+
method: 'GET',
374+
path: '/users/{id}',
375+
options: {
376+
pre: [
377+
{
378+
method: (request, h) => ({ name: 'test' }),
379+
assign: 'user' // TS ERROR — should work
380+
}
381+
],
382+
handler: (request, h) => 'ok'
383+
}
384+
};
385+
386+
// -----------------------------------------------------------------------------
387+
// ISSUE 2: Params defaults to Record<string, any> — allows unsafe access
388+
//
389+
// URL path params are ALWAYS strings at runtime (before Joi validation), but
390+
// the default type Record<string, any> means TypeScript allows anything.
391+
// These assignments should all be errors but none are.
392+
// -----------------------------------------------------------------------------
393+
394+
const issueParamsAny: ServerRoute = {
395+
method: 'GET',
396+
path: '/items/{id}',
397+
handler: (request, h) => {
398+
399+
// FIXED: Params now correctly typed as Record<string, string>
400+
// @ts-expect-error - params are strings, not numbers
401+
const id: number = request.params.id;
402+
// @ts-expect-error - params are strings, not boolean[]
403+
const wat: boolean[] = request.params.id;
404+
405+
// FIXED: params is no longer `any`
406+
const paramsIsAny: IsAny<typeof request.params.id> = false;
407+
408+
return 'ok';
409+
}
410+
};
411+
412+
// -----------------------------------------------------------------------------
413+
// ISSUE 3: Headers defaults to Record<string, any>
414+
//
415+
// Node's http.IncomingHttpHeaders types headers as string | string[] | undefined.
416+
// The Record<string, any> default loses this.
417+
// -----------------------------------------------------------------------------
418+
419+
const issueHeadersAny: ServerRoute = {
420+
method: 'GET',
421+
path: '/headers',
422+
handler: (request, h) => {
423+
424+
// FIXED: Headers now correctly typed as Record<string, string | string[] | undefined>
425+
// @ts-expect-error - headers are string | string[] | undefined, not number
426+
const auth: number = request.headers.authorization;
427+
428+
// FIXED: headers is no longer `any`
429+
const headersIsAny: IsAny<typeof request.headers.authorization> = false;
430+
431+
return 'ok';
432+
}
433+
};
434+
435+
// -----------------------------------------------------------------------------
436+
// ISSUE 4: Default RequestQuery has [key: string]: any index signature
437+
//
438+
// Without a Query override, any access on request.query is `any`.
439+
// -----------------------------------------------------------------------------
440+
441+
const issueQueryAny: ServerRoute = {
442+
method: 'GET',
443+
path: '/search',
444+
handler: (request, h) => {
445+
446+
// FIXED: Query now correctly typed as Record<string, string | string[] | undefined>
447+
// @ts-expect-error - query values are string | string[] | undefined, not number
448+
const page: number = request.query.page;
449+
// @ts-expect-error - query values are string | string[] | undefined, not boolean[]
450+
const wat: boolean[] = request.query.anything;
451+
452+
// FIXED: query is no longer `any`
453+
const queryIsAny: IsAny<typeof request.query.page> = false;
454+
455+
return 'ok';
456+
}
457+
};
458+
459+
// -----------------------------------------------------------------------------
460+
// ISSUE 5: Request<CustomRefs> not assignable to Request<ReqRefDefaults>
461+
//
462+
// A function taking Request (no generic) can't accept Request<{ Params: ... }>
463+
// even though the custom refs only NARROW a property. Users are forced to
464+
// choose between generic (accepts all) or concrete (sees defaults).
465+
// -----------------------------------------------------------------------------
466+
467+
export function concreteHelper(req: Request): string | undefined {
468+
469+
if (req.auth.credentials.extra_id) {
470+
return req.auth.credentials.extra_id;
471+
}
472+
473+
return undefined;
474+
}
475+
476+
interface MyRouteRefs {
477+
Params: { id: string };
478+
Query: { expand: string };
479+
}
480+
481+
// KNOWN LIMITATION: Request<MyRouteRefs> is not assignable to Request<ReqRefDefaults>
482+
// because TypeScript checks generic interface compatibility invariantly when
483+
// the generic appears in contravariant positions (e.g. lifecycle method parameters).
484+
// Workaround: use a generic function like processAuthGeneric<Refs> above instead
485+
// of concrete Request (no generic) for helper functions that need to accept
486+
// requests with different Refs.
487+
export function issueConcreteVsGeneric(req: Request<MyRouteRefs>): void {
488+
489+
// @ts-expect-error - Known TS limitation: Request<CustomRefs> not assignable to Request<ReqRefDefaults>
490+
concreteHelper(req);
491+
}
492+
493+
// -----------------------------------------------------------------------------
494+
// ISSUE 6: state and preResponses are not extensible through ReqRef
495+
//
496+
// These properties use hardcoded Record<string, any> and are NOT wired
497+
// through InternalRequestDefaults/ReqRef, so users can't type them.
498+
// -----------------------------------------------------------------------------
499+
500+
const issueStateAny: ServerRoute = {
501+
method: 'GET',
502+
path: '/state',
503+
handler: (request, h) => {
504+
505+
// FIXED: state is now Record<string, unknown> — requires type narrowing
506+
// @ts-expect-error - state values are unknown, not directly assignable to number
507+
const session: number = request.state.session;
508+
509+
// FIXED: state is no longer `any`
510+
const stateIsAny: IsAny<typeof request.state.session> = false;
511+
512+
// FIXED: preResponses is no longer `any`
513+
const preRespIsAny: IsAny<typeof request.preResponses.myPre> = false;
514+
515+
return 'ok';
516+
}
517+
};

0 commit comments

Comments
 (0)