@getsoda/core 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -0
- package/dist/app/app.d.ts +17 -1
- package/dist/app/app.d.ts.map +1 -1
- package/dist/app/app.js +29 -10
- package/dist/app/app.js.map +1 -1
- package/dist/di/container.d.ts +5 -0
- package/dist/di/container.d.ts.map +1 -1
- package/dist/di/container.js +15 -1
- package/dist/di/container.js.map +1 -1
- package/dist/di/graph.d.ts +2 -2
- package/dist/di/graph.d.ts.map +1 -1
- package/dist/di/graph.js +20 -10
- package/dist/di/graph.js.map +1 -1
- package/dist/http/arguments.d.ts +20 -2
- package/dist/http/arguments.d.ts.map +1 -1
- package/dist/http/arguments.js +29 -0
- package/dist/http/arguments.js.map +1 -1
- package/dist/http/body.d.ts +28 -0
- package/dist/http/body.d.ts.map +1 -1
- package/dist/http/body.js +96 -1
- package/dist/http/body.js.map +1 -1
- package/dist/http/context-key.d.ts +33 -0
- package/dist/http/context-key.d.ts.map +1 -0
- package/dist/http/context-key.js +37 -0
- package/dist/http/context-key.js.map +1 -0
- package/dist/http/context.d.ts +37 -3
- package/dist/http/context.d.ts.map +1 -1
- package/dist/http/context.js +63 -5
- package/dist/http/context.js.map +1 -1
- package/dist/http/cookies.d.ts +49 -0
- package/dist/http/cookies.d.ts.map +1 -0
- package/dist/http/cookies.js +77 -0
- package/dist/http/cookies.js.map +1 -0
- package/dist/http/cors.d.ts +41 -0
- package/dist/http/cors.d.ts.map +1 -0
- package/dist/http/cors.js +97 -0
- package/dist/http/cors.js.map +1 -0
- package/dist/http/index.d.ts +7 -1
- package/dist/http/index.d.ts.map +1 -1
- package/dist/http/index.js +4 -1
- package/dist/http/index.js.map +1 -1
- package/dist/http/middleware.d.ts +56 -0
- package/dist/http/middleware.d.ts.map +1 -0
- package/dist/http/middleware.js +83 -0
- package/dist/http/middleware.js.map +1 -0
- package/dist/http/parsers.d.ts +3 -0
- package/dist/http/parsers.d.ts.map +1 -1
- package/dist/http/parsers.js +2 -1
- package/dist/http/parsers.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/jwt/algorithms.d.ts +19 -0
- package/dist/jwt/algorithms.d.ts.map +1 -0
- package/dist/jwt/algorithms.js +60 -0
- package/dist/jwt/algorithms.js.map +1 -0
- package/dist/jwt/errors.d.ts +24 -0
- package/dist/jwt/errors.d.ts.map +1 -0
- package/dist/jwt/errors.js +14 -0
- package/dist/jwt/errors.js.map +1 -0
- package/dist/jwt/guard.d.ts +30 -0
- package/dist/jwt/guard.d.ts.map +1 -0
- package/dist/jwt/guard.js +61 -0
- package/dist/jwt/guard.js.map +1 -0
- package/dist/jwt/index.d.ts +14 -0
- package/dist/jwt/index.d.ts.map +1 -0
- package/dist/jwt/index.js +10 -0
- package/dist/jwt/index.js.map +1 -0
- package/dist/jwt/module.d.ts +36 -0
- package/dist/jwt/module.d.ts.map +1 -0
- package/dist/jwt/module.js +38 -0
- package/dist/jwt/module.js.map +1 -0
- package/dist/jwt/service.d.ts +66 -0
- package/dist/jwt/service.d.ts.map +1 -0
- package/dist/jwt/service.js +138 -0
- package/dist/jwt/service.js.map +1 -0
- package/dist/logger/console-logger.d.ts +28 -0
- package/dist/logger/console-logger.d.ts.map +1 -0
- package/dist/logger/console-logger.js +87 -0
- package/dist/logger/console-logger.js.map +1 -0
- package/dist/logger/index.d.ts +9 -0
- package/dist/logger/index.d.ts.map +1 -0
- package/dist/logger/index.js +7 -0
- package/dist/logger/index.js.map +1 -0
- package/dist/logger/logger.d.ts +55 -0
- package/dist/logger/logger.d.ts.map +1 -0
- package/dist/logger/logger.js +66 -0
- package/dist/logger/logger.js.map +1 -0
- package/dist/routing/types.d.ts +1 -1
- package/dist/routing/types.d.ts.map +1 -1
- package/package.json +6 -12
- package/src/app/app.ts +53 -12
- package/src/di/container.ts +24 -2
- package/src/di/graph.ts +26 -11
- package/src/http/arguments.ts +42 -2
- package/src/http/body.ts +142 -1
- package/src/http/context-key.ts +35 -0
- package/src/http/context.ts +94 -8
- package/src/http/cookies.ts +96 -0
- package/src/http/cors.ts +143 -0
- package/src/http/index.ts +7 -1
- package/src/http/middleware.ts +124 -0
- package/src/http/parsers.ts +2 -1
- package/src/index.ts +1 -0
- package/src/jwt/algorithms.ts +113 -0
- package/src/jwt/errors.ts +30 -0
- package/src/jwt/guard.ts +74 -0
- package/src/jwt/index.ts +14 -0
- package/src/jwt/module.ts +58 -0
- package/src/jwt/service.ts +216 -0
- package/src/logger/console-logger.ts +119 -0
- package/src/logger/index.ts +9 -0
- package/src/logger/logger.ts +96 -0
- package/src/routing/types.ts +1 -1
package/src/http/context.ts
CHANGED
|
@@ -4,11 +4,13 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import type { Constructor } from '../di/index.ts'
|
|
7
|
-
import { readJsonBody } from './body.ts'
|
|
7
|
+
import { readFormBody, readJsonBody } from './body.ts'
|
|
8
|
+
import type { FormFields, FormLimits } from './body.ts'
|
|
9
|
+
import type { ContextKey } from './context-key.ts'
|
|
8
10
|
import type { MetadataDecorator } from './enhancers.ts'
|
|
9
11
|
import { applyParser } from './parsers.ts'
|
|
10
12
|
import type { Parsed, ValueParser } from './parsers.ts'
|
|
11
|
-
import { validate } from './schema.ts'
|
|
13
|
+
import { isStandardSchema, validate } from './schema.ts'
|
|
12
14
|
import type { InferOutput, StandardSchemaV1 } from './schema.ts'
|
|
13
15
|
import type { AnyParams, PathParams } from './types.ts'
|
|
14
16
|
|
|
@@ -49,18 +51,54 @@ export interface RouteInfo {
|
|
|
49
51
|
* }
|
|
50
52
|
* ```
|
|
51
53
|
*
|
|
52
|
-
* The URL is parsed only when `url` or `query()` is used; the body is read at
|
|
54
|
+
* The URL is parsed only when `url` or `query()` is used; the body is read at
|
|
55
|
+
* most once; the storage of `set()` is created on the first `set()`.
|
|
53
56
|
*/
|
|
54
57
|
export class Context<P extends string = string> {
|
|
55
58
|
#url: URL | undefined
|
|
56
|
-
#body: Promise<unknown> | undefined
|
|
59
|
+
#body: { readonly kind: 'JSON' | 'form'; readonly value: Promise<unknown> } | undefined
|
|
60
|
+
#values: Map<ContextKey<unknown>, unknown> | undefined
|
|
57
61
|
|
|
58
62
|
constructor(
|
|
59
|
-
readonly req:
|
|
63
|
+
readonly req: Bun.BunRequest,
|
|
60
64
|
readonly params: ContextParams<P>,
|
|
61
65
|
readonly route: RouteInfo
|
|
62
66
|
) {}
|
|
63
67
|
|
|
68
|
+
/** Attaches a value to this request, for the guards, interceptors and handler that follow. */
|
|
69
|
+
set<T>(key: ContextKey<T>, value: T): void {
|
|
70
|
+
this.#values ??= new Map()
|
|
71
|
+
this.#values.set(key, value)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Value attached to this request under `key`, or its default. Throws if
|
|
76
|
+
* nothing was set and the key has no default: a guard is missing on the route.
|
|
77
|
+
*/
|
|
78
|
+
get<T>(key: ContextKey<T>): T {
|
|
79
|
+
if (this.#values?.has(key) === true) {
|
|
80
|
+
return this.#values.get(key) as T
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (key.defaultValue === undefined) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
`${key.toString()} is not set for ${this.route.controller.name}.${this.route.handler}: ` +
|
|
86
|
+
'is the guard or interceptor that sets it applied to this route?'
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const value = key.defaultValue()
|
|
91
|
+
|
|
92
|
+
this.set(key, value)
|
|
93
|
+
|
|
94
|
+
return value
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** `true` if a value was set under `key` for this request. */
|
|
98
|
+
has(key: ContextKey<unknown>): boolean {
|
|
99
|
+
return this.#values?.has(key) === true
|
|
100
|
+
}
|
|
101
|
+
|
|
64
102
|
/**
|
|
65
103
|
* Custom metadata of the route, the method's value over the class's.
|
|
66
104
|
* Like Nest's `reflector.getAllAndOverride(Roles, [handler, class])`.
|
|
@@ -102,6 +140,28 @@ export class Context<P extends string = string> {
|
|
|
102
140
|
return parser === undefined ? value : applyParser(parser, value, name)
|
|
103
141
|
}
|
|
104
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Cookies of the request: Bun's `CookieMap`. What `set()` and `delete()`
|
|
145
|
+
* change is sent with the response as `Set-Cookie`, whatever the response is.
|
|
146
|
+
*
|
|
147
|
+
* ```ts
|
|
148
|
+
* ctx.cookies.set('theme', 'dark', { maxAge: 60 * 60 * 24 * 365 })
|
|
149
|
+
* ctx.cookies.delete('session')
|
|
150
|
+
* ```
|
|
151
|
+
*/
|
|
152
|
+
get cookies(): Bun.CookieMap {
|
|
153
|
+
return this.req.cookies
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Cookie, decoded; converted and checked when a parser is given, such as `signed()`. */
|
|
157
|
+
cookie(name: string): string | undefined
|
|
158
|
+
cookie<V extends ValueParser<unknown>>(name: string, parser: V): Parsed<V>
|
|
159
|
+
cookie(name: string, parser?: ValueParser<unknown>): unknown {
|
|
160
|
+
const value = this.req.cookies.get(name) ?? undefined
|
|
161
|
+
|
|
162
|
+
return parser === undefined ? value : applyParser(parser, value, name)
|
|
163
|
+
}
|
|
164
|
+
|
|
105
165
|
/**
|
|
106
166
|
* JSON body, validated by `schema` when given. `415` for a non-JSON body,
|
|
107
167
|
* `400` for invalid JSON or a failed validation.
|
|
@@ -109,10 +169,36 @@ export class Context<P extends string = string> {
|
|
|
109
169
|
body(): Promise<unknown>
|
|
110
170
|
body<S extends StandardSchemaV1>(schema: S): Promise<InferOutput<S>>
|
|
111
171
|
async body(schema?: StandardSchemaV1): Promise<unknown> {
|
|
112
|
-
this.#
|
|
113
|
-
|
|
114
|
-
const value = await this.#body
|
|
172
|
+
const value = await this.#read('JSON', readJsonBody)
|
|
115
173
|
|
|
116
174
|
return schema === undefined ? value : validate(schema, value, '')
|
|
117
175
|
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Form body (`multipart/form-data` or `application/x-www-form-urlencoded`),
|
|
179
|
+
* validated by `schema` when given. `415` for another content type.
|
|
180
|
+
* Repeated fields and fields named `name[]` are arrays; files are `File`s
|
|
181
|
+
* whose `type` is what the client declared, not checked against the content.
|
|
182
|
+
* `limits` bound the body, the files and the fields: `413` past them.
|
|
183
|
+
*/
|
|
184
|
+
form(limits?: FormLimits): Promise<FormFields>
|
|
185
|
+
form<S extends StandardSchemaV1>(schema: S, limits?: FormLimits): Promise<InferOutput<S>>
|
|
186
|
+
async form(schemaOrLimits?: StandardSchemaV1 | FormLimits, limits?: FormLimits): Promise<unknown> {
|
|
187
|
+
if (isStandardSchema(schemaOrLimits)) {
|
|
188
|
+
return validate(schemaOrLimits, await this.#read('form', (req) => readFormBody(req, limits)), '')
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return this.#read('form', (req) => readFormBody(req, schemaOrLimits))
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Reads the body once; reading it again in another format is a programming error. */
|
|
195
|
+
#read(kind: 'JSON' | 'form', read: (req: Request) => Promise<unknown>): Promise<unknown> {
|
|
196
|
+
if (this.#body === undefined) {
|
|
197
|
+
this.#body = { kind, value: read(this.req) }
|
|
198
|
+
} else if (this.#body.kind !== kind) {
|
|
199
|
+
throw new TypeError(`The body of this request was already read as ${this.#body.kind}, it cannot be read as ${kind}`)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return this.#body.value
|
|
203
|
+
}
|
|
118
204
|
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module
|
|
3
|
+
* Signed cookies. Reading and writing cookies is Bun's: `ctx.cookies` is the
|
|
4
|
+
* request's `CookieMap`, whose changes Bun sends with the response as
|
|
5
|
+
* `Set-Cookie`. This module adds what `@fastify/cookie` and `cookie-signature`
|
|
6
|
+
* add on top: values signed with HMAC-SHA256, so the client can read them but
|
|
7
|
+
* not forge them.
|
|
8
|
+
*
|
|
9
|
+
* ```ts
|
|
10
|
+
* const signer = new CookieSigner(process.env.COOKIE_SECRET!)
|
|
11
|
+
*
|
|
12
|
+
* ctx.cookies.set('session', signer.sign(id), { httpOnly: true, secure: true })
|
|
13
|
+
*
|
|
14
|
+
* @Get('me', [cookie('session', signed(signer))])
|
|
15
|
+
* me(id: string) {}
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* A signature proves the value was set by the server, not that it is secret
|
|
19
|
+
* or recent: store secrets server-side, and limit the lifetime with `maxAge`.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { timingSafeEqual } from 'node:crypto'
|
|
23
|
+
import { failure } from './parsers.ts'
|
|
24
|
+
import type { Parser, ParserOptions } from './parsers.ts'
|
|
25
|
+
|
|
26
|
+
const hmac = (secret: string, value: string): string =>
|
|
27
|
+
new Bun.CryptoHasher('sha256', secret).update(value).digest('base64url')
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Signs cookie values as `value.signature` and checks them. Several secrets
|
|
31
|
+
* allow rotation: the first signs, every one verifies.
|
|
32
|
+
*
|
|
33
|
+
* ```ts
|
|
34
|
+
* new CookieSigner([process.env.COOKIE_SECRET!, process.env.OLD_COOKIE_SECRET!])
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
export class CookieSigner {
|
|
38
|
+
readonly #secrets: readonly string[]
|
|
39
|
+
|
|
40
|
+
/** Secrets should be random and long: `openssl rand -base64 32`. */
|
|
41
|
+
constructor(secrets: string | readonly string[]) {
|
|
42
|
+
const list = typeof secrets === 'string' ? [secrets] : [...secrets]
|
|
43
|
+
|
|
44
|
+
if (list.length === 0 || list.includes('')) {
|
|
45
|
+
throw new TypeError('CookieSigner needs at least one secret, and no empty ones')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
this.#secrets = list
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** `value` followed by `.` and its signature, in base64url. */
|
|
52
|
+
sign(value: string): string {
|
|
53
|
+
return `${value}.${hmac(this.#secrets[0]!, value)}`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The value of a signed string, or `undefined` if it is not signed by one of the secrets. */
|
|
57
|
+
unsign(signed: string): string | undefined {
|
|
58
|
+
const dot = signed.lastIndexOf('.')
|
|
59
|
+
|
|
60
|
+
if (dot === -1) {
|
|
61
|
+
return undefined
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const value = signed.slice(0, dot)
|
|
65
|
+
const signature = Buffer.from(signed.slice(dot + 1))
|
|
66
|
+
|
|
67
|
+
for (const secret of this.#secrets) {
|
|
68
|
+
const expected = Buffer.from(hmac(secret, value))
|
|
69
|
+
|
|
70
|
+
if (expected.length === signature.length && timingSafeEqual(expected, signature)) {
|
|
71
|
+
return value
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return undefined
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
type Result<O extends ParserOptions> = O extends { readonly optional: true } ? string | undefined : string
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Parser of a signed cookie: its value, `400` when it is missing or its
|
|
83
|
+
* signature is wrong. With `{ optional: true }`, both give `undefined`, as a
|
|
84
|
+
* forged cookie is no better than none.
|
|
85
|
+
*/
|
|
86
|
+
export const signed = <const O extends ParserOptions = {}>(signer: CookieSigner, options?: O): Parser<Result<O>> => ({
|
|
87
|
+
parse: (value, name) => {
|
|
88
|
+
const unsigned = value === undefined ? undefined : signer.unsign(value)
|
|
89
|
+
|
|
90
|
+
if (unsigned === undefined && options?.optional !== true) {
|
|
91
|
+
throw failure(name, 'signed cookie')
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return unsigned as Result<O>
|
|
95
|
+
},
|
|
96
|
+
})
|
package/src/http/cors.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module
|
|
3
|
+
* CORS middleware.
|
|
4
|
+
*
|
|
5
|
+
* Follows Hono's `cors()` and the `cors` package Nest uses, with two differences:
|
|
6
|
+
*
|
|
7
|
+
* - only a real preflight (`OPTIONS` with `Access-Control-Request-Method`, as
|
|
8
|
+
* the Fetch spec defines it) is answered here; other `OPTIONS` requests reach
|
|
9
|
+
* the application and its own `OPTIONS` routes;
|
|
10
|
+
* - `credentials: true` with `origin: '*'` is rejected when the middleware is
|
|
11
|
+
* created: browsers refuse that pair, so it could never work.
|
|
12
|
+
*
|
|
13
|
+
* Response headers are set in place: Bun's responses always have mutable headers.
|
|
14
|
+
*
|
|
15
|
+
* ```ts
|
|
16
|
+
* cors({ origin: ['https://app.example.com'], credentials: true, maxAge: 600 })
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { MiddlewareFunction } from './middleware.ts'
|
|
21
|
+
|
|
22
|
+
export interface CorsOptions {
|
|
23
|
+
/**
|
|
24
|
+
* Allowed origins: `'*'` (default), one origin, a list, or a predicate.
|
|
25
|
+
* An allowed origin is echoed back; a refused one gets no CORS headers, so the browser blocks the response.
|
|
26
|
+
*/
|
|
27
|
+
readonly origin?: '*' | string | readonly string[] | ((origin: string, req: Request) => boolean)
|
|
28
|
+
/** Methods announced to preflights. */
|
|
29
|
+
readonly methods?: readonly string[]
|
|
30
|
+
/** Headers announced to preflights. Defaults to the ones the preflight asks for. */
|
|
31
|
+
readonly allowedHeaders?: readonly string[]
|
|
32
|
+
/** Response headers the browser exposes to scripts. */
|
|
33
|
+
readonly exposedHeaders?: readonly string[]
|
|
34
|
+
/** Allows cookies and `Authorization`; needs explicit origins. */
|
|
35
|
+
readonly credentials?: boolean
|
|
36
|
+
/** Seconds a browser may cache a preflight. */
|
|
37
|
+
readonly maxAge?: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const DEFAULT_METHODS = ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE']
|
|
41
|
+
|
|
42
|
+
const createOriginCheck = (origin: NonNullable<CorsOptions['origin']>): ((origin: string, req: Request) => boolean) => {
|
|
43
|
+
if (typeof origin === 'function') {
|
|
44
|
+
return origin
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (typeof origin === 'string') {
|
|
48
|
+
return (requested) => requested === origin
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const allowed = new Set(origin)
|
|
52
|
+
|
|
53
|
+
return (requested) => allowed.has(requested)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const appendVary = (headers: Headers, value: string): void => {
|
|
57
|
+
const current = headers.get('vary')
|
|
58
|
+
|
|
59
|
+
if (current === null) {
|
|
60
|
+
headers.set('vary', value)
|
|
61
|
+
} else if (current !== '*' && !current.toLowerCase().split(/\s*,\s*/).includes(value.toLowerCase())) {
|
|
62
|
+
headers.set('vary', `${current}, ${value}`)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Creates the CORS middleware. Throws a `TypeError` for options browsers would refuse.
|
|
68
|
+
*/
|
|
69
|
+
export const cors = (options: CorsOptions = {}): MiddlewareFunction => {
|
|
70
|
+
const origin = options.origin ?? '*'
|
|
71
|
+
const any = origin === '*'
|
|
72
|
+
|
|
73
|
+
if (any && options.credentials === true) {
|
|
74
|
+
throw new TypeError(
|
|
75
|
+
"cors(): credentials cannot be used with origin '*', browsers reject that combination; list the allowed origins instead"
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const isAllowed = any ? () => true : createOriginCheck(origin)
|
|
80
|
+
const methods = (options.methods ?? DEFAULT_METHODS).join(', ')
|
|
81
|
+
const allowedHeaders = options.allowedHeaders?.join(', ')
|
|
82
|
+
const exposedHeaders = options.exposedHeaders?.join(', ')
|
|
83
|
+
const maxAge = options.maxAge === undefined ? undefined : String(options.maxAge)
|
|
84
|
+
|
|
85
|
+
/** Headers every CORS response of an allowed origin gets. */
|
|
86
|
+
const applyCommon = (headers: Headers, requested: string | null, req: Request): void => {
|
|
87
|
+
if (requested !== null && isAllowed(requested, req)) {
|
|
88
|
+
headers.set('access-control-allow-origin', any ? '*' : requested)
|
|
89
|
+
|
|
90
|
+
if (options.credentials === true) {
|
|
91
|
+
headers.set('access-control-allow-credentials', 'true')
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// The response depends on Origin as soon as it is not the same for every origin.
|
|
96
|
+
if (!any) {
|
|
97
|
+
appendVary(headers, 'Origin')
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return async (req, next) => {
|
|
102
|
+
const requested = req.headers.get('origin')
|
|
103
|
+
const preflight = req.method === 'OPTIONS' && requested !== null && req.headers.has('access-control-request-method')
|
|
104
|
+
|
|
105
|
+
if (preflight) {
|
|
106
|
+
const headers = new Headers()
|
|
107
|
+
|
|
108
|
+
applyCommon(headers, requested, req)
|
|
109
|
+
|
|
110
|
+
if (headers.has('access-control-allow-origin')) {
|
|
111
|
+
headers.set('access-control-allow-methods', methods)
|
|
112
|
+
|
|
113
|
+
const requestedHeaders = req.headers.get('access-control-request-headers')
|
|
114
|
+
const allowHeaders = allowedHeaders ?? requestedHeaders ?? ''
|
|
115
|
+
|
|
116
|
+
if (allowHeaders !== '') {
|
|
117
|
+
headers.set('access-control-allow-headers', allowHeaders)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (allowedHeaders === undefined && requestedHeaders !== null) {
|
|
121
|
+
appendVary(headers, 'Access-Control-Request-Headers')
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (maxAge !== undefined) {
|
|
125
|
+
headers.set('access-control-max-age', maxAge)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return new Response(null, { status: 204, headers })
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const response = await next()
|
|
133
|
+
const headers = response.headers
|
|
134
|
+
|
|
135
|
+
applyCommon(headers, requested, req)
|
|
136
|
+
|
|
137
|
+
if (exposedHeaders !== undefined && headers.has('access-control-allow-origin')) {
|
|
138
|
+
headers.set('access-control-expose-headers', exposedHeaders)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return response
|
|
142
|
+
}
|
|
143
|
+
}
|
package/src/http/index.ts
CHANGED
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
* HTTP layer for Soda: controllers, route decorators, request context, validation, exceptions.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
export { body, context, header, param, query } from './arguments.ts'
|
|
6
|
+
export { body, context, cookie, form, header, param, query, value } from './arguments.ts'
|
|
7
7
|
export type { Argument, ArgumentFor, ArgumentKind, ArgumentValues } from './arguments.ts'
|
|
8
8
|
export { Context } from './context.ts'
|
|
9
|
+
export { CookieSigner, signed } from './cookies.ts'
|
|
10
|
+
export { ContextKey } from './context-key.ts'
|
|
9
11
|
export type { ContextParams, ParamNameOf } from './context.ts'
|
|
10
12
|
export { All, Controller, Delete, Get, Head, Header, HttpCode, Options, Patch, Post, Put, Route } from './decorators.ts'
|
|
11
13
|
export type {
|
|
@@ -56,3 +58,7 @@ export type {
|
|
|
56
58
|
MetadataDecorator,
|
|
57
59
|
} from './enhancers.ts'
|
|
58
60
|
export type { RouteInfo } from './context.ts'
|
|
61
|
+
export { cors } from './cors.ts'
|
|
62
|
+
export type { CorsOptions } from './cors.ts'
|
|
63
|
+
export type { Middleware, MiddlewareFunction, NestMiddleware, Next } from './middleware.ts'
|
|
64
|
+
export type { FormFields, FormLimits, FormValue } from './body.ts'
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module
|
|
3
|
+
* Application middleware: runs around every request, before routing.
|
|
4
|
+
*
|
|
5
|
+
* Unlike guards and interceptors, which belong to a route, middleware sees
|
|
6
|
+
* every request: unknown paths (404), wrong methods (405), CORS preflights.
|
|
7
|
+
* That is where CORS, request ids, access logs or compression belong.
|
|
8
|
+
*
|
|
9
|
+
* ```ts
|
|
10
|
+
* const requestId: MiddlewareFunction = async (req, next) => {
|
|
11
|
+
* const response = await next()
|
|
12
|
+
*
|
|
13
|
+
* response.headers.set('x-request-id', crypto.randomUUID())
|
|
14
|
+
*
|
|
15
|
+
* return response
|
|
16
|
+
* }
|
|
17
|
+
*
|
|
18
|
+
* await createApp(AppModule, { middleware: [cors({ origin: 'https://app.example.com' }), requestId] })
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* As guards: a class is created once by the container (root module) and gets
|
|
22
|
+
* lifecycle hooks, a function runs where `inject()` works, an instance is used as is.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { Constructor } from '../di/index.ts'
|
|
26
|
+
import type { NativeHandler } from '../routing/index.ts'
|
|
27
|
+
|
|
28
|
+
/** Continues with the next middleware, then the route. */
|
|
29
|
+
export type Next = () => Promise<Response>
|
|
30
|
+
|
|
31
|
+
/** Class middleware. Like Nest's `NestMiddleware`, for the Fetch API. */
|
|
32
|
+
export interface NestMiddleware {
|
|
33
|
+
use(req: Request, next: Next): Response | Promise<Response>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Function middleware; `inject()` works inside it, before the first `await`. */
|
|
37
|
+
export type MiddlewareFunction = (req: Request, next: Next) => Response | Promise<Response>
|
|
38
|
+
|
|
39
|
+
/** What `middleware` accepts. */
|
|
40
|
+
export type Middleware = Constructor<NestMiddleware> | NestMiddleware | MiddlewareFunction
|
|
41
|
+
|
|
42
|
+
type Step = (req: Request, next: Next) => Response | Promise<Response>
|
|
43
|
+
|
|
44
|
+
/** Runs a step, turning a synchronous throw into a rejection. */
|
|
45
|
+
const settle = (step: () => Response | Promise<Response>): Promise<Response> => {
|
|
46
|
+
try {
|
|
47
|
+
return Promise.resolve(step())
|
|
48
|
+
} catch (error) {
|
|
49
|
+
return Promise.reject(error)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Turns middleware declarations into steps. Classes are created now, inside
|
|
55
|
+
* `runInContext`, and passed to `onCreate` for their lifecycle hooks.
|
|
56
|
+
*/
|
|
57
|
+
export const resolveMiddleware = (
|
|
58
|
+
middleware: readonly Middleware[],
|
|
59
|
+
runInContext: <R>(fn: () => R) => R,
|
|
60
|
+
onCreate: (instance: unknown) => void
|
|
61
|
+
): Step[] => {
|
|
62
|
+
const steps: Step[] = []
|
|
63
|
+
|
|
64
|
+
for (const entry of middleware) {
|
|
65
|
+
if (typeof entry === 'function' && typeof (entry.prototype as Record<string, unknown> | undefined)?.use === 'function') {
|
|
66
|
+
const instance = runInContext(() => new (entry as Constructor<NestMiddleware>)())
|
|
67
|
+
|
|
68
|
+
onCreate(instance)
|
|
69
|
+
steps.push((req, next) => instance.use(req, next))
|
|
70
|
+
} else if (typeof entry === 'function') {
|
|
71
|
+
const fn = entry as MiddlewareFunction
|
|
72
|
+
|
|
73
|
+
steps.push((req, next) => runInContext(() => fn(req, next)))
|
|
74
|
+
} else if (typeof entry === 'object' && entry !== null && typeof entry.use === 'function') {
|
|
75
|
+
steps.push((req, next) => entry.use(req, next))
|
|
76
|
+
} else {
|
|
77
|
+
throw new TypeError(`Invalid middleware: ${String(entry)} is neither a function nor an object with use()`)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return steps
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Wraps `handler` with the middleware steps, first one outermost.
|
|
86
|
+
* Errors of middleware go to `onError`, which builds the response.
|
|
87
|
+
*/
|
|
88
|
+
export const withMiddleware = <R extends Request>(
|
|
89
|
+
steps: readonly Step[],
|
|
90
|
+
handler: (req: R) => Response | Promise<Response>,
|
|
91
|
+
onError: (error: unknown, req: Request) => Response
|
|
92
|
+
): ((req: R) => Promise<Response>) => {
|
|
93
|
+
const count = steps.length
|
|
94
|
+
|
|
95
|
+
return (req) => {
|
|
96
|
+
const call = (index: number): Promise<Response> =>
|
|
97
|
+
index === count ? settle(() => handler(req)) : settle(() => steps[index]!(req, () => call(index + 1)))
|
|
98
|
+
|
|
99
|
+
return call(0).catch((error: unknown) => onError(error, req))
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Applies middleware to every native route and to the fallback of a compiled table.
|
|
105
|
+
* Without middleware, the table is returned unchanged: no wrapper on the request path.
|
|
106
|
+
*/
|
|
107
|
+
export const applyMiddleware = (
|
|
108
|
+
steps: readonly Step[],
|
|
109
|
+
routes: Record<string, NativeHandler>,
|
|
110
|
+
fetch: (req: Request) => Response | Promise<Response>,
|
|
111
|
+
onError: (error: unknown, req: Request) => Response
|
|
112
|
+
): { routes: Record<string, NativeHandler>; fetch: (req: Request) => Response | Promise<Response> } => {
|
|
113
|
+
if (steps.length === 0) {
|
|
114
|
+
return { routes, fetch }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const wrapped: Record<string, NativeHandler> = {}
|
|
118
|
+
|
|
119
|
+
for (const key of Object.keys(routes)) {
|
|
120
|
+
wrapped[key] = withMiddleware(steps, routes[key]!, onError)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return { routes: wrapped, fetch: withMiddleware(steps, fetch, onError) }
|
|
124
|
+
}
|
package/src/http/parsers.ts
CHANGED
|
@@ -39,7 +39,8 @@ const INTEGER = /^-?\d+$/
|
|
|
39
39
|
const NUMBER = /^-?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?$/
|
|
40
40
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
/** The `400` of a value that is missing or not what a parser expects. */
|
|
43
|
+
export const failure = (name: string, expected: string): BadRequestException =>
|
|
43
44
|
new BadRequestException(`Validation failed for "${name}" (${expected} is expected)`)
|
|
44
45
|
|
|
45
46
|
const createParser = <T>(
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module
|
|
3
|
+
* JWS algorithms (RFC 7518) on WebCrypto, and the keys they take.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { webcrypto } from 'node:crypto'
|
|
7
|
+
|
|
8
|
+
/** Algorithms a token can be signed with. `none` is not one of them. */
|
|
9
|
+
export type JwtAlgorithm =
|
|
10
|
+
| 'HS256'
|
|
11
|
+
| 'HS384'
|
|
12
|
+
| 'HS512'
|
|
13
|
+
| 'RS256'
|
|
14
|
+
| 'RS384'
|
|
15
|
+
| 'RS512'
|
|
16
|
+
| 'PS256'
|
|
17
|
+
| 'PS384'
|
|
18
|
+
| 'PS512'
|
|
19
|
+
| 'ES256'
|
|
20
|
+
| 'ES384'
|
|
21
|
+
| 'ES512'
|
|
22
|
+
| 'EdDSA'
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A key: a `CryptoKey`, a JWK, a PEM string (`PRIVATE KEY` in PKCS #8 or
|
|
26
|
+
* `PUBLIC KEY` in SPKI), or the secret of an HMAC algorithm.
|
|
27
|
+
*/
|
|
28
|
+
export type JwtKey = CryptoKey | webcrypto.JsonWebKey | string
|
|
29
|
+
|
|
30
|
+
type KeyParams = webcrypto.HmacImportParams | webcrypto.RsaHashedImportParams | webcrypto.EcKeyImportParams | webcrypto.Algorithm
|
|
31
|
+
type SignParams = webcrypto.Algorithm | webcrypto.EcdsaParams | webcrypto.RsaPssParams
|
|
32
|
+
|
|
33
|
+
/** Import parameters, and the signing parameters where they differ. */
|
|
34
|
+
const ALGORITHMS: Readonly<Record<JwtAlgorithm, { readonly key: KeyParams; readonly sign: SignParams }>> = {
|
|
35
|
+
HS256: hmac('SHA-256'),
|
|
36
|
+
HS384: hmac('SHA-384'),
|
|
37
|
+
HS512: hmac('SHA-512'),
|
|
38
|
+
RS256: rsa('RSASSA-PKCS1-v1_5', 'SHA-256'),
|
|
39
|
+
RS384: rsa('RSASSA-PKCS1-v1_5', 'SHA-384'),
|
|
40
|
+
RS512: rsa('RSASSA-PKCS1-v1_5', 'SHA-512'),
|
|
41
|
+
PS256: pss('SHA-256', 32),
|
|
42
|
+
PS384: pss('SHA-384', 48),
|
|
43
|
+
PS512: pss('SHA-512', 64),
|
|
44
|
+
ES256: ecdsa('P-256', 'SHA-256'),
|
|
45
|
+
ES384: ecdsa('P-384', 'SHA-384'),
|
|
46
|
+
ES512: ecdsa('P-521', 'SHA-512'),
|
|
47
|
+
EdDSA: { key: { name: 'Ed25519' }, sign: { name: 'Ed25519' } },
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function hmac(hash: string) {
|
|
51
|
+
const params = { name: 'HMAC', hash }
|
|
52
|
+
|
|
53
|
+
return { key: params, sign: params }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function rsa(name: string, hash: string) {
|
|
57
|
+
return { key: { name, hash }, sign: { name } }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function pss(hash: string, saltLength: number) {
|
|
61
|
+
return { key: { name: 'RSA-PSS', hash }, sign: { name: 'RSA-PSS', saltLength } }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function ecdsa(namedCurve: string, hash: string) {
|
|
65
|
+
return { key: { name: 'ECDSA', namedCurve }, sign: { name: 'ECDSA', hash } }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const isJwtAlgorithm = (value: unknown): value is JwtAlgorithm => typeof value === 'string' && Object.hasOwn(ALGORITHMS, value)
|
|
69
|
+
|
|
70
|
+
export const isHmac = (algorithm: JwtAlgorithm): boolean => algorithm.startsWith('HS')
|
|
71
|
+
|
|
72
|
+
const pemBody = (pem: string): Uint8Array<ArrayBuffer> =>
|
|
73
|
+
Uint8Array.fromBase64(pem.replace(/-----(?:BEGIN|END) [A-Z ]+-----/g, '').replace(/\s/g, ''))
|
|
74
|
+
|
|
75
|
+
/** Imports `key` once for `usage` with `algorithm`. */
|
|
76
|
+
export const importKey = async (key: JwtKey, algorithm: JwtAlgorithm, usage: 'sign' | 'verify'): Promise<CryptoKey> => {
|
|
77
|
+
const params = ALGORITHMS[algorithm].key
|
|
78
|
+
|
|
79
|
+
if (key instanceof CryptoKey) {
|
|
80
|
+
return key
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (typeof key === 'object') {
|
|
84
|
+
return crypto.subtle.importKey('jwk', key, params, false, [usage])
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (isHmac(algorithm)) {
|
|
88
|
+
return crypto.subtle.importKey('raw', new TextEncoder().encode(key), params, false, [usage])
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (key.includes('-----BEGIN PRIVATE KEY-----') && usage === 'sign') {
|
|
92
|
+
return crypto.subtle.importKey('pkcs8', pemBody(key), params, false, [usage])
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (key.includes('-----BEGIN PUBLIC KEY-----') && usage === 'verify') {
|
|
96
|
+
return crypto.subtle.importKey('spki', pemBody(key), params, false, [usage])
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
throw new TypeError(
|
|
100
|
+
`A ${algorithm} key must be a CryptoKey, a JWK or a PEM string: ` +
|
|
101
|
+
(usage === 'sign' ? 'a PKCS #8 "PRIVATE KEY" to sign' : 'an SPKI "PUBLIC KEY" to verify')
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export const signBytes = async (algorithm: JwtAlgorithm, key: CryptoKey, data: Uint8Array<ArrayBuffer>): Promise<Uint8Array<ArrayBuffer>> =>
|
|
106
|
+
new Uint8Array(await crypto.subtle.sign(ALGORITHMS[algorithm].sign, key, data))
|
|
107
|
+
|
|
108
|
+
export const verifyBytes = (
|
|
109
|
+
algorithm: JwtAlgorithm,
|
|
110
|
+
key: CryptoKey,
|
|
111
|
+
signature: Uint8Array<ArrayBuffer>,
|
|
112
|
+
data: Uint8Array<ArrayBuffer>
|
|
113
|
+
): Promise<boolean> => crypto.subtle.verify(ALGORITHMS[algorithm].sign, key, signature, data)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module
|
|
3
|
+
* Why a token was refused.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type JwtErrorCode =
|
|
7
|
+
/** Not three base64url parts holding JSON objects. */
|
|
8
|
+
| 'malformed'
|
|
9
|
+
/** Signed with another algorithm than the one configured, or with critical extensions. */
|
|
10
|
+
| 'algorithm'
|
|
11
|
+
/** The signature does not match. */
|
|
12
|
+
| 'signature'
|
|
13
|
+
/** `exp` is past. */
|
|
14
|
+
| 'expired'
|
|
15
|
+
/** `nbf` is still to come. */
|
|
16
|
+
| 'not-active'
|
|
17
|
+
/** A registered claim has the wrong type, or `iss` or `aud` is not the expected one. */
|
|
18
|
+
| 'claims'
|
|
19
|
+
|
|
20
|
+
/** A token that `JwtService.verify()` refused. */
|
|
21
|
+
export class JwtError extends Error {
|
|
22
|
+
override readonly name = 'JwtError'
|
|
23
|
+
|
|
24
|
+
constructor(
|
|
25
|
+
readonly code: JwtErrorCode,
|
|
26
|
+
message: string
|
|
27
|
+
) {
|
|
28
|
+
super(message)
|
|
29
|
+
}
|
|
30
|
+
}
|