diff --git a/package.json b/package.json index 23433e5..233b721 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "scripts": { "analyze": "tsdown --clean --no-fixed-extension --sourcemap source/index.ts && esbuild-analyzer dist/", "build": "tsdown --clean --sourcemap source/index.ts", - "dev": "tsx watch source/app.ts", + "dev": "tsx watch source/index.ts", "docs": "typedoc --hideGenerator --categorizeByGroup", "format": "prettier --write . && eslint --fix", "spellcheck": "cspell 'source/**' 'test/**' 'playground/**'", diff --git a/source/auth/auth.ts b/source/auth/auth.ts new file mode 100644 index 0000000..929a243 --- /dev/null +++ b/source/auth/auth.ts @@ -0,0 +1,122 @@ +import type { Database } from '../services/storage/database.ts'; + +import { hexToBin, instantiateSecp256k1, type Secp256k1, sha256 } from '@bitauth/libauth'; +import { UnauthorizedError } from '../errors/unauthorized-error.ts'; + +export type AuthSecp256k1RequiredDeps = { + database: Database; +}; + +export type AuthSecp256k1OptionalDeps = { + secp256k1: Secp256k1; +}; +export type AuthSecp256k1Deps = AuthSecp256k1RequiredDeps & Partial; + +export type AuthSecp256k1Options = { + timestampWindowMs: number; +}; + +export class AuthSecp256k1 { + /** + * Create a new instance of AuthSecp256k1 + * @returns A new instance of AuthSecp256k1 + */ + static async create(inputDeps: AuthSecp256k1Deps, options: AuthSecp256k1Options): Promise { + const deps = { + secp256k1: await instantiateSecp256k1(), + ...inputDeps, + }; + + return new AuthSecp256k1(deps, options); + } + + /** + * TODO: Consider adding a Record where each key is the signature to guarantee signatures arent being processed concurrently. + */ + readonly #deps: Required; + readonly #options: AuthSecp256k1Options; + + /** + * @param deps - The dependencies to use + * @param options - The options to use + */ + private constructor(deps: Required, options: AuthSecp256k1Options) { + this.#deps = deps; + this.#options = options; + } + + /** + * Verify a signature + * @param publicKeyHex - The public key to verify the signature against + * @param signatureHex - The signature to verify + * @param payload - The payload to verify the signature against + * @returns Whether the signature is valid + */ + async verifySignature(publicKeyHex: string, signatureHex: string, payload: string): Promise { + // Convert the public key and signature to binary + const publicKey = hexToBin(publicKeyHex); + const signature = hexToBin(signatureHex); + + // Convert the payload to bytes and compute the sha256 hash + const payloadBytes = new TextEncoder().encode(payload); + const messageHash = sha256.hash(payloadBytes); + + // Verify the signature + const verified = this.#deps.secp256k1.verifySignatureDERLowS(signature, publicKey, messageHash); + + // If the signature is not valid, throw an unauthorized error + if (!verified) { + throw new UnauthorizedError('Invalid signature'); + } + + // Return the verified signature + return verified; + } + + /** + * Verify a signature is unique and add it to the database if it is + * + * @param signature - The signature to verify + * @returns Whether the signature is unique + */ + async verifyUniqueRequest(signature: string): Promise { + // If the signature is not provided, throw an unauthorized error + if (!signature) { + throw new UnauthorizedError('Signature is required'); + } + + // Check if the signature has been used before + const request = await this.#deps.database.db.selectFrom('authed_requests').selectAll() +.where('signature', '=', signature) +.executeTakeFirst(); + + // If the signature has been used before, throw an unauthorized error + if (request) { + throw new UnauthorizedError('Request already used'); + } + + // Add the signature to the requests table + await this.#deps.database.db.insertInto('authed_requests').values({ signature }) +.execute(); + + // Return true if the signature is valid + return true; + } + + /** + * Assert that a timestamp is within a allowed window + * @param timestamp - The timestamp to check + * @param windowMs - The window in milliseconds + * + * @throws An {@link UnauthorizedError} if the timestamp is outside the allowed window + */ + assertTimestampFreshness(timestamp: number, windowMs = this.#options.timestampWindowMs): void { + // Subtract the timestamp from the current time to get the age in milliseconds + const age = Math.abs(Date.now() - timestamp); + + // If the timestamp is outside the allowed window, throw an unauthorized error + if (age > windowMs) { + throw new UnauthorizedError('Timestamp outside allowed window'); + } + } +} diff --git a/source/index.ts b/source/index.ts index 2ff7ba8..cf09105 100644 --- a/source/index.ts +++ b/source/index.ts @@ -1,5 +1,6 @@ import { Config } from './services/config.ts'; import { Database, MigrationService } from './services/storage/index.ts'; +import { AuthSecp256k1 } from './auth/auth.ts'; import { Broadcaster } from './services/broadcaster.ts'; import { ApplicationRouter } from './services/router.ts'; import { HttpTransportRouter } from './services/transport/http-transport.ts'; @@ -23,6 +24,9 @@ export class App { const migrations = new MigrationService(database, debug); await migrations.migrateToLatest(); + // Create an Auth instance that can be passed in for signature validation + const auth = await AuthSecp256k1.create({ database }, { timestampWindowMs: config.auth.timestampWindowMs }); + // Domain services are shared across all transports and route modules. const broadcaster = new Broadcaster(debug); const routes = [ @@ -34,7 +38,7 @@ export class App { // Route loading is an explicit startup phase, not first-request work. // ApplicationRouter.create validates every path and rejects duplicates // before any client can connect. - const router = await ApplicationRouter.create(routes); + const router = await ApplicationRouter.create({ auth }, routes); // Both transports share one ApplicationRouter. Routes use the shared // Broadcaster directly, while HTTP and WebSocket remain protocol adapters. @@ -69,6 +73,16 @@ export class App { })(); await this.stopPromise; } + + startUniqueRequestCleanup(cleanupIntervalMs: number, timestampWindowMs: number): void { + // Every 10 seconds, we will cleanup the requests table + setInterval(async () => { + await this.database.db + .deleteFrom('authed_requests') + .where('timestamp', '<', Date.now() - timestampWindowMs) + .execute(); + }, cleanupIntervalMs); + } } const app = await App.create(); diff --git a/source/routes/types.ts b/source/routes/types.ts index 2d2746e..8888ab1 100644 --- a/source/routes/types.ts +++ b/source/routes/types.ts @@ -1,5 +1,8 @@ import type { BaseStream } from '../services/stream/base-stream.js'; +/** Canonical request headers exposed to transport-neutral route handlers. */ +export type RequestHeaders = Readonly>; + export type RouteSendOptions = { /** Defaults to `response`; any other value sends an application event. */ @@ -20,7 +23,11 @@ export interface RouteStream { /** Connection shared by every request on the same transport session. */ readonly connection: BaseStream; + /** Canonical route path selected for this request. */ + readonly path: string; + readonly body: unknown; + readonly headers: RequestHeaders; readonly streaming: boolean; readonly bidirectional: boolean; diff --git a/source/services/config.ts b/source/services/config.ts index 19bdd54..d13c079 100644 --- a/source/services/config.ts +++ b/source/services/config.ts @@ -32,7 +32,7 @@ const configSchema = z.object({ .object({ origin: z.string().default('*'), methods: z.array(z.string()).default([ 'GET', 'POST', 'PUT', 'DELETE', 'OPTIONS' ]), - allowedHeaders: z.array(z.string()).default([ 'Content-Type', 'cache-control', 'X-Timestamp', 'X-PublicKey', 'X-Signature' ]), + allowedHeaders: z.array(z.string()).default([ 'Content-Type', 'cache-control', 'X-Timestamp', 'X-Public-Key', 'X-Signature' ]), }) .partial() .prefault({}), @@ -48,6 +48,11 @@ const configSchema = z.object({ .int() .positive() .default(5 * 60 * 1000), + uniqueRequestCleanupIntervalMs: z.coerce + .number() + .int() + .positive() + .default(10 * 60 * 1000), }) .prefault({}), }); @@ -83,6 +88,7 @@ export class Config { }, auth: { timestampWindowMs: process.env.AUTH_TIMESTAMP_WINDOW_MS ? Number(process.env.AUTH_TIMESTAMP_WINDOW_MS) : undefined, + uniqueRequestCleanupIntervalMs: process.env.AUTH_UNIQUE_REQUEST_CLEANUP_INTERVAL_MS ? Number(process.env.AUTH_UNIQUE_REQUEST_CLEANUP_INTERVAL_MS) : undefined, }, }); } diff --git a/source/services/route-stream.ts b/source/services/route-stream.ts index e3b1b6c..156133b 100644 --- a/source/services/route-stream.ts +++ b/source/services/route-stream.ts @@ -1,7 +1,10 @@ -import type { RouteSendOptions, RouteStream } from '../routes/types.ts'; +import type { RequestHeaders, RouteSendOptions, RouteStream } from '../routes/types.ts'; import type { BaseStream } from './stream/base-stream.ts'; import { HTTP_STATUS_CODE_NO_CONTENT, HTTP_STATUS_CODE_SUCCESS } from '../constants.ts'; +/** Shared immutable value used when a request supplies no headers. */ +const EMPTY_REQUEST_HEADERS: RequestHeaders = Object.freeze({}); + /** * Binds one application request to a connection-level stream. * @@ -10,16 +13,24 @@ import { HTTP_STATUS_CODE_NO_CONTENT, HTTP_STATUS_CODE_SUCCESS } from '../consta * connection-level services such as the broadcaster. */ export class ApplicationRouteStream implements RouteStream { + readonly headers: RequestHeaders; + /** * @param connection - Shared transport stream backing this request. * @param body - Transport-decoded application payload for the route handler. + * @param path - Canonical application route selected for this request. * @param requestId - Optional correlation ID for multiplexed transports. + * @param headers - Transport-normalized request headers for this dispatch. */ constructor( readonly connection: BaseStream, readonly body: unknown, + readonly path: string, private readonly requestId?: string, - ) {} + headers: RequestHeaders = EMPTY_REQUEST_HEADERS, + ) { + this.headers = headers === EMPTY_REQUEST_HEADERS ? headers : Object.freeze({ ...headers }); + } /** Whether the underlying connection can deliver server-pushed events. */ get streaming(): boolean { diff --git a/source/services/router.ts b/source/services/router.ts index 71c4d50..6a90ea2 100644 --- a/source/services/router.ts +++ b/source/services/router.ts @@ -1,7 +1,11 @@ -import type { RouteDefinition, RouteModule } from '../routes/types.ts'; -import { ApplicationError } from '../errors/index.ts'; +import { z } from 'zod'; +import { toExtendedJson } from '@xo-cash/utils'; + +import type { RequestHeaders, RouteDefinition, RouteModule } from '../routes/types.ts'; +import { ApplicationError, UnauthorizedError } from '../errors/index.ts'; import { ApplicationRouteStream } from './route-stream.ts'; import type { BaseStream } from './stream/base-stream.ts'; +import type { AuthSecp256k1 } from '../auth/auth.ts'; /** Canonical request produced by every transport adapter. */ export type ApplicationRequest = { @@ -12,14 +16,38 @@ export type ApplicationRequest = { /** Transport-decoded application payload. */ body?: unknown; + /** Transport-normalized request headers, keyed by lowercase name. */ + headers?: RequestHeaders; + /** Optional correlation ID supplied by a multiplexed transport. */ requestId?: string; }; +export type ApplicationRouterDependencies = { + + /** Authentication service. */ + auth: AuthSecp256k1; +}; + +const accountSchema = z + .object({ + 'x-public-key': z.string(), + 'x-signature': z.string(), + 'x-timestamp': z.coerce.number(), + }) + .transform((data) => ({ + publicKey: data['x-public-key'], + signature: data['x-signature'], + timestamp: data['x-timestamp'], + })); + /** Exact-match application routing shared by every wire transport. */ export class ApplicationRouter { /** @param routes - Validated route table keyed by exact path. */ - private constructor(private readonly routes: ReadonlyMap) {} + private constructor( + private readonly deps: ApplicationRouterDependencies, + private readonly routes: ReadonlyMap, + ) {} /** * Load and validate the complete route table before accepting traffic. @@ -27,7 +55,7 @@ export class ApplicationRouter { * @param routeModules - Route modules whose handlers will be registered. * @returns A ready-to-dispatch router instance. */ - static async create(routeModules: RouteModule[]): Promise { + static async create(deps: ApplicationRouterDependencies, routeModules: RouteModule[]): Promise { const routes = new Map(); // Collect routes from every module and reject duplicates at startup. @@ -42,7 +70,7 @@ export class ApplicationRouter { } } - return new ApplicationRouter(routes); + return new ApplicationRouter(deps, routes); } /** @@ -52,12 +80,33 @@ export class ApplicationRouter { * @param connection - Shared connection stream for this transport session. */ async dispatch(request: ApplicationRequest, connection: BaseStream): Promise { + // Authenticate the headers on the request. + const { publicKey, signature, timestamp } = accountSchema.parse(request.headers); + + // Make sure the request signature is valid and hasnt been used before + await this.deps.auth.verifyUniqueRequest(signature); + + // Ensure that the headers are present. + if (!publicKey || !signature || !timestamp) { + throw new UnauthorizedError('Missing authentication headers'); + } + + // Compile the signature payload as `Path:Timestamp:Body` + const signaturePayload = `${timestamp}:${request.path}:${toExtendedJson(request.body)}`; + + // Verify the signature of the request. + const verified = await this.deps.auth.verifySignature(publicKey, signature, signaturePayload); + if (!verified) { + throw new UnauthorizedError('Invalid signature'); + } + + // Get the route from the routes map. const route = this.routes.get(request.path); if (!route) { throw new ApplicationError(404, `No route found for ${request.path}`); } - const stream = new ApplicationRouteStream(connection, request.body, request.requestId); + const stream = new ApplicationRouteStream(connection, request.body, request.path, request.requestId, request.headers); await route.handler(stream); } diff --git a/source/services/storage/migrations/001-resources.ts b/source/services/storage/migrations/001-resources.ts index 27dcf73..0e85e23 100644 --- a/source/services/storage/migrations/001-resources.ts +++ b/source/services/storage/migrations/001-resources.ts @@ -23,8 +23,19 @@ export const up = async (db: Kysely): Promise => { .addColumn('public_key', 'text', (col) => col.notNull()) .addColumn('blob', 'blob', (col) => col.notNull()) .addColumn('timestamp', 'integer', (col) => col.notNull().defaultTo(millisecondTime)) + .addColumn('signature', 'text', (col) => col.notNull()) .addPrimaryKeyConstraint('pk_resource_data', [ 'resource_id', 'public_key' ]) .execute(); + + // Table for authed requests + // We will store the signature and the timestamp of the request, and we will clear out rows that are older than our msTimeout for our auth + await db.schema + .createTable('authed_requests') + .ifNotExists() + .addColumn('signature', 'text', (col) => col.notNull()) + .addColumn('timestamp', 'integer', (col) => col.notNull().defaultTo(millisecondTime)) + .addPrimaryKeyConstraint('pk_authed_requests', [ 'signature' ]) + .execute(); }; /** @@ -35,4 +46,7 @@ export const up = async (db: Kysely): Promise => { export const down = async (db: Kysely): Promise => { await db.schema.dropTable('resource_data').ifExists() .execute(); + + await db.schema.dropTable('authed_requests').ifExists() +.execute(); }; diff --git a/source/services/storage/tables.ts b/source/services/storage/tables.ts index d937c28..ee5039d 100644 --- a/source/services/storage/tables.ts +++ b/source/services/storage/tables.ts @@ -22,9 +22,22 @@ export interface ResourceDataTable { /** Millisecond timestamp of the last write. */ timestamp: Timestamp; + + /** Signature of the write. */ + signature: string; +} + +export interface AuthedRequestsTable { + + /** Signature of the request. */ + signature: string; + + /** Millisecond timestamp of the request. */ + timestamp: Timestamp; } /** Complete Kysely schema mapping for the sync server database. */ export interface DatabaseTables { resource_data: ResourceDataTable; + authed_requests: AuthedRequestsTable; } diff --git a/source/services/transport/request-headers.ts b/source/services/transport/request-headers.ts new file mode 100644 index 0000000..9a65db2 --- /dev/null +++ b/source/services/transport/request-headers.ts @@ -0,0 +1,38 @@ +import type { RequestHeaders } from '../../routes/types.ts'; +import { ApplicationError } from '../../errors/index.ts'; +import { HTTP_STATUS_CODE_BAD_REQUEST } from '../../constants.ts'; + +/** RFC 9110 field-name token grammar. */ +const HEADER_NAME_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; + +/** + * Validate and normalize request headers at a transport boundary. + * + * Lowercase names give HTTP and WebSocket routes identical lookup semantics. + * Case-insensitive duplicates are rejected instead of selecting an ambiguous + * authentication value. Header values may not contain line breaks. + */ +export const normalizeRequestHeaders = (headers: Readonly>): RequestHeaders => { + const normalizedEntries: Array<[string, string]> = []; + const names = new Set(); + + for (const [ name, value ] of Object.entries(headers)) { + if (!HEADER_NAME_PATTERN.test(name)) { + throw new ApplicationError(HTTP_STATUS_CODE_BAD_REQUEST, 'Invalid request header name'); + } + + if (value.includes('\r') || value.includes('\n')) { + throw new ApplicationError(HTTP_STATUS_CODE_BAD_REQUEST, 'Invalid request header value'); + } + + const normalizedName = name.toLowerCase(); + if (names.has(normalizedName)) { + throw new ApplicationError(HTTP_STATUS_CODE_BAD_REQUEST, 'Duplicate request header name'); + } + + names.add(normalizedName); + normalizedEntries.push([ normalizedName, value ]); + } + + return Object.freeze(Object.fromEntries(normalizedEntries)); +}; diff --git a/test/services/config.test.ts b/test/services/config.test.ts index 6100e28..eb68f46 100644 --- a/test/services/config.test.ts +++ b/test/services/config.test.ts @@ -18,7 +18,7 @@ const testConfigDefaultsTo1MiBRequestBodyLimit = (): void => { expect(config.server.host).toBe('0.0.0.0'); expect(config.server.cors.origin).toBe('*'); expect(config.server.cors.methods).toEqual([ 'GET', 'POST', 'PUT', 'DELETE', 'OPTIONS' ]); - expect(config.server.cors.allowedHeaders).toEqual([ 'Content-Type', 'cache-control', 'X-Timestamp', 'X-PublicKey', 'X-Signature' ]); + expect(config.server.cors.allowedHeaders).toEqual([ 'Content-Type', 'cache-control', 'X-Timestamp', 'X-Public-Key', 'X-Signature' ]); expect(config.auth.timestampWindowMs).toBe(300000); }; diff --git a/test/services/router.test.ts b/test/services/router.test.ts index 20c1826..318170b 100644 --- a/test/services/router.test.ts +++ b/test/services/router.test.ts @@ -1,9 +1,35 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import type { RouteDefinition, RouteModule } from '../../source/routes/types.ts'; import { ApplicationRouter } from '../../source/services/router.ts'; import { TestConnection } from '../helpers/test-connection.ts'; +import type { AuthSecp256k1 } from '../../source/auth/auth.ts'; + +/** + * A controlled request is a request that is controlled by the test. + * It is used to control the request flow and ensure that the request is completed in the correct order. + */ +type ControlledRequest = { + request: Promise; + started: Promise; + release: () => void; +}; + +/** + * A mock of the AuthSecp256k1 service + */ +const auth = { + verifySignature: vi.fn().mockResolvedValue(true), + verifyUniqueRequest: vi.fn().mockResolvedValue(true), + assertTimestampFreshness: vi.fn().mockResolvedValue(true), +} as unknown as AuthSecp256k1; + +/** + * A helper function to create a route module with the given routes + * @param routes - The routes to create the module with + * @returns The created route module + */ const moduleWith = (routes: RouteDefinition[]): RouteModule => { return { async getRoutes(): Promise { @@ -16,66 +42,130 @@ describe('ApplicationRouter initialization', (): void => { it('rejects duplicate exact paths during startup', async (): Promise => { const route = { url: '/echo', handler: (): void => undefined }; - await expect(ApplicationRouter.create([ moduleWith([ route ]), moduleWith([ route ]) ])).rejects.toThrow('Duplicate application route: /echo'); + await expect(ApplicationRouter.create({ auth }, [ moduleWith([ route ]), moduleWith([ route ]) ])).rejects.toThrow('Duplicate application route: /echo'); }); it.each([ 'echo', '/', '/items/:id', '/items?active=true', '/items#active' ])('rejects the invalid route path %s', async (url): Promise => { - await expect(ApplicationRouter.create([ moduleWith([{ url, handler: (): void => undefined }]) ])).rejects.toThrow('Invalid application route'); + await expect(ApplicationRouter.create({ auth }, [ moduleWith([{ url, handler: (): void => undefined }]) ])).rejects.toThrow('Invalid application route'); }); }); describe('ApplicationRouter dispatch', (): void => { it('binds the connection, body, and request ID to one route stream', async (): Promise => { const connection = new TestConnection(false, false); - const router = await ApplicationRouter.create([ + const router = await ApplicationRouter.create({ auth }, [ moduleWith([ { url: '/echo', handler: async (stream): Promise => { expect(stream.connection).toBe(connection); + expect(stream.path).toBe('/echo'); + expect(stream.headers).toEqual({ 'x-public-key': 'public-key', 'x-signature': 'signature', 'x-timestamp': '1000' }); await stream.send(stream.body); }, }, ]), ]); - await router.dispatch({ path: '/echo', body: { value: 1 }, requestId: 'request-1' }, connection); + await router.dispatch( + { + path: '/echo', + body: { value: 1 }, + requestId: '1', + headers: { 'x-public-key': 'public-key', 'x-signature': 'signature', 'x-timestamp': '1000' }, + }, + connection, + ); expect(connection.messages).toEqual([ { - id: 'request-1', + id: '1', type: 'response', statusCode: 200, body: { value: 1 }, }, ]); - await expect(router.dispatch({ path: '/echo/other', body: {} }, connection)).rejects.toMatchObject({ statusCode: 404 }); + await expect(router.dispatch( + { path: '/echo/other', body: {}, headers: { 'x-public-key': 'public-key', 'x-signature': 'signature', 'x-timestamp': '1000' } }, + connection, + )).rejects.toMatchObject({ statusCode: 404 }); }); it('preserves correlation when concurrent requests finish out of order', async (): Promise => { - const completions = new Map void>(); - const router = await ApplicationRouter.create([ + const router = await ApplicationRouter.create({ auth }, [ moduleWith([ { url: '/delayed', handler: async (stream): Promise => { - const key = (stream.body as { key: string }).key; - await new Promise((resolve) => completions.set(key, resolve)); + const { key, signalStarted, released } = stream.body as { + key: string; + signalStarted: () => void; + released: Promise; + }; + + signalStarted(); + + await released; await stream.send({ key }); }, }, ]), ]); - const connection = new TestConnection(true, true); - const first = router.dispatch({ path: '/delayed', body: { key: 'A' }, requestId: 'A' }, connection); - const second = router.dispatch({ path: '/delayed', body: { key: 'B' }, requestId: 'B' }, connection); + const connection = new TestConnection(false, false); - completions.get('B')?.(); - await second; - completions.get('A')?.(); - await first; + const createControlledRequest = (key: string): ControlledRequest => { + const { promise: started, resolve: signalStarted } = Promise.withResolvers(); + + const { promise: released, resolve: release } = Promise.withResolvers(); + + const request = router.dispatch( + { + path: '/delayed', + body: { + key, + signalStarted, + released, + }, + requestId: key, + headers: { + 'x-public-key': 'public-key', + 'x-signature': 'signature', + 'x-timestamp': '1000', + }, + }, + connection, + ); + + return { + request, + started, + release, + }; + }; + + const first = createControlledRequest('A'); + const second = createControlledRequest('B'); + + expect(connection.messages).toEqual([]); + + await Promise.all([ first.started, second.started ]); + + second.release(); + await second.request; + + expect(connection.messages).toEqual([ + { + id: 'B', + type: 'response', + statusCode: 200, + body: { key: 'B' }, + }, + ]); + + first.release(); + await first.request; expect(connection.messages).toEqual([ { @@ -95,7 +185,7 @@ describe('ApplicationRouter dispatch', (): void => { it('propagates route failures without infrastructure-specific cleanup', async (): Promise => { const error = new Error('route failed'); - const router = await ApplicationRouter.create([ + const router = await ApplicationRouter.create({ auth }, [ moduleWith([ { url: '/failure', @@ -106,6 +196,9 @@ describe('ApplicationRouter dispatch', (): void => { ]), ]); - await expect(router.dispatch({ path: '/failure' }, new TestConnection(false, false))).rejects.toBe(error); + await expect(router.dispatch( + { path: '/failure', headers: { 'x-public-key': 'public-key', 'x-signature': 'signature', 'x-timestamp': '1000' } }, + new TestConnection(false, false), + )).rejects.toBe(error); }); }); diff --git a/tsconfig.json b/tsconfig.json index 6e15aa7..0db5f25 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,8 +2,8 @@ "compilerOptions": { "rootDir": "./source", "outDir": "./dist", - "module": "es2022", - "target": "es2022", + "module": "esnext", + "target": "esnext", "skipLibCheck": true, "strict": true, "moduleResolution": "bundler", @@ -15,5 +15,5 @@ "declarationMap": true, "types": ["node"] }, - "exclude": ["node_modules/**/*", "dist/**/*"] + "exclude": ["node_modules/**/*", "dist/**/*", "test/**/*"] }