From fdccfcf32a8df51f05e83960ef155979235cc426 Mon Sep 17 00:00:00 2001 From: Thomas <31560900+0xtlt@users.noreply.github.com> Date: Mon, 6 Jul 2026 11:38:44 +0200 Subject: [PATCH 1/3] feat: support signed URL patterns without route lookup --- src/router/signed_url_builder.ts | 31 +++++++++++++--- src/types/url_builder.ts | 19 ++++++++++ tests/router/url_builder.spec.ts | 64 ++++++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+), 5 deletions(-) diff --git a/src/router/signed_url_builder.ts b/src/router/signed_url_builder.ts index 04e3f54..3554943 100644 --- a/src/router/signed_url_builder.ts +++ b/src/router/signed_url_builder.ts @@ -10,8 +10,8 @@ import type { Encryption } from '@boringnode/encryption' import { type Router } from './main.ts' -import { createSignedURL } from '../helpers.ts' -import { type UrlFor, type LookupList, type SignedURLOptions } from '../types/url_builder.ts' +import { createSignedURL, parseRoute } from '../helpers.ts' +import { type LookupList, type SignedURLOptions, type SignedUrlFor } from '../types/url_builder.ts' /** * Creates the URLBuilder helper for making signed URLs @@ -24,15 +24,34 @@ export function createSignedUrlBuilder( router: Router, encryption: Encryption, searchParamsStringifier: (qs: Record) => string -): UrlFor { +): SignedUrlFor { let domainsList: string[] + function createSignedUrlForRoutePattern( + identifier: string, + params: any, + options?: SignedURLOptions + ) { + return createSignedURL( + identifier, + parseRoute(identifier), + searchParamsStringifier, + encryption, + params, + options + ) + } + function createSignedUrlForRoute( identifier: string, params: any, options?: SignedURLOptions, method?: string ) { + if (options?.disableRouteLookup) { + return createSignedUrlForRoutePattern(identifier, params, options) + } + if (!domainsList) { domainsList = Object.keys(router.toJSON()).filter((domain) => domain !== 'root') } @@ -50,8 +69,10 @@ export function createSignedUrlBuilder( ) } - const signedRoute: UrlFor = function route( - ...[identifier, params, options] + const signedRoute: SignedUrlFor = function route( + identifier: string, + params?: any[] | Record, + options?: SignedURLOptions ) { return createSignedUrlForRoute(identifier, params, options) } diff --git a/src/types/url_builder.ts b/src/types/url_builder.ts index abfc1d9..ed21e7f 100644 --- a/src/types/url_builder.ts +++ b/src/types/url_builder.ts @@ -25,8 +25,27 @@ export type SignedURLOptions = URLOptions & { expiresIn?: string | number /** Purpose identifier for the signed URL */ purpose?: string + /** Make a signed URL using a route pattern without performing a route lookup */ + disableRouteLookup?: boolean } +/** + * Configuration options for signed URL generation without performing a route lookup + */ +export type SignedURLPatternOptions = SignedURLOptions & { + disableRouteLookup: true +} + +/** + * URL builder helper for creating signed URLs. + */ +export type SignedUrlFor = UrlFor & + (( + identifier: string, + params: any[] | Record | undefined, + options: SignedURLPatternOptions + ) => string) + /** * Utility type to extract routes for a specific HTTP method from the routes collection */ diff --git a/tests/router/url_builder.spec.ts b/tests/router/url_builder.spec.ts index 5809df5..ff866b3 100644 --- a/tests/router/url_builder.spec.ts +++ b/tests/router/url_builder.spec.ts @@ -16,6 +16,7 @@ import { URLBuilderFactory } from '../../factories/url_builder_factory.ts' import { type UrlFor, type URLOptions, + type SignedUrlFor, type GetRoutesForMethod, type RouteBuilderArguments, } from '../../src/types/url_builder.ts' @@ -290,6 +291,36 @@ test.group('URLBuilder', () => { assert.isTrue(request.hasValidSignature()) }) + test('create and verify signed URLs with route lookup disabled', async ({ assert }) => { + const encryption = new EncryptionFactory().create() + const { signedUrlFor } = new URLBuilderFactory().merge({ encryption }).create() + + const signedUrl = signedUrlFor( + '/files/:key', + { key: 'invoice.pdf' }, + { + disableRouteLookup: true, + prefixUrl: 'http://localhost:4000', + qs: { + download: true, + }, + } + ) + + const url = new URL(signedUrl) + const request = new HttpRequestFactory() + .merge({ + encryption, + url: `${url.pathname}${url.search}`, + }) + .create() + + assert.equal(url.origin, 'http://localhost:4000') + assert.equal(url.pathname, '/files/invoice.pdf') + assert.equal(url.searchParams.get('download'), 'true') + assert.isTrue(request.hasValidSignature()) + }) + test('raise error when unable to lookup route', ({ assert }) => { const router = new RouterFactory().create() const { urlFor } = new URLBuilderFactory().merge({ router }).create() @@ -684,4 +715,37 @@ test.group('URLBuilder | types', () => { ] >() }) + + test('accept route patterns when signed URL route lookup is disabled', ({ expectTypeOf }) => { + type Routes = { + ALL: { + 'users.show': { + params: { id: string } + paramsTuple: [string] + } + } + GET: { + 'users.show': { + params: { id: string } + paramsTuple: [string] + } + } + } + + type RouteBuilder = SignedUrlFor + const assertSignedUrlForTypes = (signedUrlFor: RouteBuilder) => { + expectTypeOf( + signedUrlFor( + '/files/:key', + { key: 'invoice.pdf' }, + { + disableRouteLookup: true, + prefixUrl: 'http://localhost:4000', + } + ) + ).toEqualTypeOf() + } + + expectTypeOf(assertSignedUrlForTypes).returns.toEqualTypeOf() + }) }) From 1f6de67ee04cd90e00ce5dc43d229a768ed05e27 Mon Sep 17 00:00:00 2001 From: Thomas <31560900+0xtlt@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:03:53 +0200 Subject: [PATCH 2/3] fix: infer signed URL pattern params --- package-lock.json | 1 + package.json | 1 + src/router/signed_url_builder.ts | 2 +- src/types/url_builder.ts | 14 +++++++-- tests/router/url_builder.spec.ts | 53 ++++++++++++++++++++++++++++++++ 5 files changed, 67 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 65cd8a0..33e4e71 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@poppinss/matchit": "^3.2.0", "@poppinss/middleware": "^3.2.7", "@poppinss/qs": "^6.15.0", + "@poppinss/types": "^1.2.1", "@poppinss/utils": "^7.0.1", "@sindresorhus/is": "^8.1.0", "content-disposition": "^1.1.0", diff --git a/package.json b/package.json index 11dc2ef..254d095 100644 --- a/package.json +++ b/package.json @@ -99,6 +99,7 @@ "@poppinss/matchit": "^3.2.0", "@poppinss/middleware": "^3.2.7", "@poppinss/qs": "^6.15.0", + "@poppinss/types": "^1.2.1", "@poppinss/utils": "^7.0.1", "@sindresorhus/is": "^8.1.0", "content-disposition": "^1.1.0", diff --git a/src/router/signed_url_builder.ts b/src/router/signed_url_builder.ts index 3554943..d59f7ea 100644 --- a/src/router/signed_url_builder.ts +++ b/src/router/signed_url_builder.ts @@ -71,7 +71,7 @@ export function createSignedUrlBuilder( const signedRoute: SignedUrlFor = function route( identifier: string, - params?: any[] | Record, + params?: unknown, options?: SignedURLOptions ) { return createSignedUrlForRoute(identifier, params, options) diff --git a/src/types/url_builder.ts b/src/types/url_builder.ts index ed21e7f..ad1d177 100644 --- a/src/types/url_builder.ts +++ b/src/types/url_builder.ts @@ -7,6 +7,8 @@ * file that was distributed with this source code. */ +import type { InferRouteParams } from '@poppinss/types' + import { type UrlFor, type LookupList, @@ -36,13 +38,19 @@ export type SignedURLPatternOptions = SignedURLOptions & { disableRouteLookup: true } +type SignedURLPatternParams = string extends Identifier + ? any[] | Record | undefined + : keyof InferRouteParams extends never + ? undefined + : InferRouteParams + /** * URL builder helper for creating signed URLs. */ export type SignedUrlFor = UrlFor & - (( - identifier: string, - params: any[] | Record | undefined, + (( + identifier: Identifier, + params: SignedURLPatternParams, options: SignedURLPatternOptions ) => string) diff --git a/tests/router/url_builder.spec.ts b/tests/router/url_builder.spec.ts index ff866b3..988e9e6 100644 --- a/tests/router/url_builder.spec.ts +++ b/tests/router/url_builder.spec.ts @@ -744,8 +744,61 @@ test.group('URLBuilder | types', () => { } ) ).toEqualTypeOf() + + expectTypeOf( + signedUrlFor( + '/files/:key?', + {}, + { + disableRouteLookup: true, + } + ) + ).toEqualTypeOf() + + expectTypeOf( + signedUrlFor( + '/files/*', + { '*': ['invoices', 'invoice.pdf'] }, + { + disableRouteLookup: true, + } + ) + ).toEqualTypeOf() + + expectTypeOf( + signedUrlFor('/health', undefined, { + disableRouteLookup: true, + }) + ).toEqualTypeOf() + + const pattern: string = '/files/:key' + + expectTypeOf( + signedUrlFor( + pattern, + { key: 'invoice.pdf' }, + { + disableRouteLookup: true, + } + ) + ).toEqualTypeOf() + } + + const assertInvalidSignedUrlForTypes = (signedUrlFor: RouteBuilder) => { + // @ts-expect-error Missing "key" param inferred from the route pattern + signedUrlFor('/files/:key', {}, { disableRouteLookup: true }) + + // @ts-expect-error Unknown "slug" param is not accepted for the route pattern + signedUrlFor('/files/:key', { slug: 'invoice.pdf' }, { disableRouteLookup: true }) + + // @ts-expect-error Wildcard params must be provided as an array of strings + signedUrlFor('/files/*', { '*': 'invoice.pdf' }, { disableRouteLookup: true }) + + // @ts-expect-error Patterns without params expect undefined params + signedUrlFor('/health', {}, { disableRouteLookup: true }) } expectTypeOf(assertSignedUrlForTypes).returns.toEqualTypeOf() + expectTypeOf(assertInvalidSignedUrlForTypes).returns.toEqualTypeOf() }) }) From c38c4b7e2666821f4cde5b323f057c12ef9498a7 Mon Sep 17 00:00:00 2001 From: Thomas <31560900+0xtlt@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:40:58 +0200 Subject: [PATCH 3/3] style: fix lint formatting --- src/client/url_builder.ts | 3 +-- src/router/route.ts | 8 ++------ src/types/route.ts | 11 ++--------- 3 files changed, 5 insertions(+), 17 deletions(-) diff --git a/src/client/url_builder.ts b/src/client/url_builder.ts index 8fa2e54..5224bc8 100644 --- a/src/client/url_builder.ts +++ b/src/client/url_builder.ts @@ -21,8 +21,7 @@ export { createURL, findRoute } */ export function createUrlBuilder( routesLoader: - | { [domain: string]: ClientRouteJSON[] } - | (() => { [domain: string]: ClientRouteJSON[] }), + { [domain: string]: ClientRouteJSON[] } | (() => { [domain: string]: ClientRouteJSON[] }), searchParamsStringifier: (qs: Record) => string, defaultOptions?: URLOptions ): UrlFor { diff --git a/src/router/route.ts b/src/router/route.ts index 5c108be..d993cb7 100644 --- a/src/router/route.ts +++ b/src/router/route.ts @@ -144,9 +144,7 @@ export class Route = any> extends Macroable pattern: string methods: string[] handler: - | RouteFn - | string - | [LazyImport | Controller, GetControllerHandlers?] + RouteFn | string | [LazyImport | Controller, GetControllerHandlers?] globalMatchers: RouteMatchers } ) { @@ -174,9 +172,7 @@ export class Route = any> extends Macroable */ #resolveRouteHandle( handler: - | RouteFn - | string - | [LazyImport | Controller, GetControllerHandlers?] + RouteFn | string | [LazyImport | Controller, GetControllerHandlers?] ) { /** * Convert magic string to handle method call diff --git a/src/types/route.ts b/src/types/route.ts index a3bedb4..976b5d5 100644 --- a/src/types/route.ts +++ b/src/types/route.ts @@ -73,8 +73,7 @@ export type StoreRouteHandler = * Middleware representation stored with route information */ export type StoreRouteMiddleware = - | MiddlewareFn - | ({ name?: string; args?: any[] } & ParsedGlobalMiddleware) + MiddlewareFn | ({ name?: string; args?: any[] } & ParsedGlobalMiddleware) /** * Route storage structure for a specific HTTP method containing tokens and route mappings @@ -188,13 +187,7 @@ export type RouteJSON = Pick