@c9up/ream 0.1.6 → 0.1.8
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/dist/Application.d.ts +9 -1
- package/dist/Application.d.ts.map +1 -1
- package/dist/Application.js +35 -5
- package/dist/Application.js.map +1 -1
- package/dist/GracefulShutdown.d.ts.map +1 -1
- package/dist/GracefulShutdown.js +13 -7
- package/dist/GracefulShutdown.js.map +1 -1
- package/dist/HttpKernel.d.ts.map +1 -1
- package/dist/HttpKernel.js +43 -14
- package/dist/HttpKernel.js.map +1 -1
- package/dist/Ignitor.d.ts +10 -10
- package/dist/Ignitor.d.ts.map +1 -1
- package/dist/Ignitor.js +104 -32
- package/dist/Ignitor.js.map +1 -1
- package/dist/bodyparser/BodyParserMiddleware.d.ts +3 -0
- package/dist/bodyparser/BodyParserMiddleware.d.ts.map +1 -1
- package/dist/bodyparser/BodyParserMiddleware.js +32 -16
- package/dist/bodyparser/BodyParserMiddleware.js.map +1 -1
- package/dist/bodyparser/parseSize.d.ts +2 -1
- package/dist/bodyparser/parseSize.d.ts.map +1 -1
- package/dist/bodyparser/parseSize.js +2 -1
- package/dist/bodyparser/parseSize.js.map +1 -1
- package/dist/container/Container.d.ts.map +1 -1
- package/dist/container/Container.js +6 -7
- package/dist/container/Container.js.map +1 -1
- package/dist/decorators/Service.d.ts +4 -6
- package/dist/decorators/Service.d.ts.map +1 -1
- package/dist/decorators/Service.js +0 -1
- package/dist/decorators/Service.js.map +1 -1
- package/dist/events/Emitter.d.ts +16 -6
- package/dist/events/Emitter.d.ts.map +1 -1
- package/dist/events/Emitter.js +32 -25
- package/dist/events/Emitter.js.map +1 -1
- package/dist/events/EventsProvider.d.ts +1 -0
- package/dist/events/EventsProvider.d.ts.map +1 -1
- package/dist/events/EventsProvider.js +6 -4
- package/dist/events/EventsProvider.js.map +1 -1
- package/dist/graphql/GraphQLEngine.d.ts +16 -0
- package/dist/graphql/GraphQLEngine.d.ts.map +1 -1
- package/dist/graphql/GraphQLEngine.js +122 -4
- package/dist/graphql/GraphQLEngine.js.map +1 -1
- package/dist/graphql/GraphQLProvider.d.ts.map +1 -1
- package/dist/graphql/GraphQLProvider.js +13 -2
- package/dist/graphql/GraphQLProvider.js.map +1 -1
- package/dist/http/HttpContext.d.ts +27 -1
- package/dist/http/HttpContext.d.ts.map +1 -1
- package/dist/http/HttpContext.js +20 -1
- package/dist/http/HttpContext.js.map +1 -1
- package/dist/http/RedirectBuilder.d.ts +5 -1
- package/dist/http/RedirectBuilder.d.ts.map +1 -1
- package/dist/http/RedirectBuilder.js +26 -3
- package/dist/http/RedirectBuilder.js.map +1 -1
- package/dist/http/Request.d.ts +13 -0
- package/dist/http/Request.d.ts.map +1 -1
- package/dist/http/Request.js +18 -0
- package/dist/http/Request.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/middleware/Pipeline.d.ts +13 -0
- package/dist/middleware/Pipeline.d.ts.map +1 -1
- package/dist/middleware/Pipeline.js +43 -7
- package/dist/middleware/Pipeline.js.map +1 -1
- package/dist/router/Router.d.ts +21 -2
- package/dist/router/Router.d.ts.map +1 -1
- package/dist/router/Router.js +53 -8
- package/dist/router/Router.js.map +1 -1
- package/dist/rpc/RpcProvider.d.ts.map +1 -1
- package/dist/rpc/RpcProvider.js +11 -2
- package/dist/rpc/RpcProvider.js.map +1 -1
- package/dist/rpc/RpcRouter.d.ts +11 -3
- package/dist/rpc/RpcRouter.d.ts.map +1 -1
- package/dist/rpc/RpcRouter.js +92 -8
- package/dist/rpc/RpcRouter.js.map +1 -1
- package/dist/scheduler/ScheduleProvider.d.ts.map +1 -1
- package/dist/scheduler/ScheduleProvider.js +10 -0
- package/dist/scheduler/ScheduleProvider.js.map +1 -1
- package/dist/scheduler/locks/MemoryLockBackend.d.ts +0 -1
- package/dist/scheduler/locks/MemoryLockBackend.d.ts.map +1 -1
- package/dist/scheduler/locks/MemoryLockBackend.js +0 -1
- package/dist/scheduler/locks/MemoryLockBackend.js.map +1 -1
- package/dist/services/app.d.ts +6 -0
- package/dist/services/app.d.ts.map +1 -1
- package/dist/services/app.js +9 -0
- package/dist/services/app.js.map +1 -1
- package/dist/services/router.d.ts +5 -0
- package/dist/services/router.d.ts.map +1 -1
- package/dist/services/router.js +8 -0
- package/dist/services/router.js.map +1 -1
- package/dist/services/server.d.ts +5 -0
- package/dist/services/server.d.ts.map +1 -1
- package/dist/services/server.js +8 -0
- package/dist/services/server.js.map +1 -1
- package/dist/session/SessionMiddleware.d.ts.map +1 -1
- package/dist/session/SessionMiddleware.js +4 -0
- package/dist/session/SessionMiddleware.js.map +1 -1
- package/dist/session/drivers/MemoryDriver.d.ts +1 -1
- package/dist/session/drivers/MemoryDriver.d.ts.map +1 -1
- package/dist/session/drivers/MemoryDriver.js +14 -6
- package/dist/session/drivers/MemoryDriver.js.map +1 -1
- package/dist/testing/TestClient.d.ts +11 -5
- package/dist/testing/TestClient.d.ts.map +1 -1
- package/dist/testing/TestClient.js +9 -4
- package/dist/testing/TestClient.js.map +1 -1
- package/events.darwin-arm64.node +0 -0
- package/events.darwin-x64.node +0 -0
- package/events.linux-arm64-gnu.node +0 -0
- package/events.linux-x64-gnu.node +0 -0
- package/events.win32-x64-msvc.node +0 -0
- package/index.darwin-arm64.node +0 -0
- package/index.darwin-x64.node +0 -0
- package/index.linux-arm64-gnu.node +0 -0
- package/index.linux-x64-gnu.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +29 -1
- package/scheduler.darwin-arm64.node +0 -0
- package/scheduler.darwin-x64.node +0 -0
- package/scheduler.linux-arm64-gnu.node +0 -0
- package/scheduler.linux-x64-gnu.node +0 -0
- package/scheduler.win32-x64-msvc.node +0 -0
- package/src/Application.ts +36 -5
- package/src/GracefulShutdown.ts +15 -7
- package/src/HttpKernel.ts +52 -17
- package/src/Ignitor.ts +106 -34
- package/src/bodyparser/BodyParserMiddleware.ts +43 -16
- package/src/bodyparser/parseSize.ts +2 -1
- package/src/container/Container.ts +6 -9
- package/src/decorators/Service.ts +4 -6
- package/src/events/Emitter.ts +36 -28
- package/src/events/EventsProvider.ts +6 -4
- package/src/graphql/GraphQLEngine.ts +139 -3
- package/src/graphql/GraphQLProvider.ts +17 -2
- package/src/http/HttpContext.ts +38 -1
- package/src/http/RedirectBuilder.ts +23 -3
- package/src/http/Request.ts +20 -0
- package/src/index.ts +2 -2
- package/src/middleware/Pipeline.ts +61 -8
- package/src/router/Router.ts +53 -8
- package/src/rpc/RpcProvider.ts +14 -2
- package/src/rpc/RpcRouter.ts +115 -11
- package/src/scheduler/ScheduleProvider.ts +10 -0
- package/src/scheduler/locks/MemoryLockBackend.ts +0 -2
- package/src/services/app.ts +9 -0
- package/src/services/router.ts +8 -0
- package/src/services/server.ts +8 -0
- package/src/session/SessionMiddleware.ts +6 -0
- package/src/session/drivers/MemoryDriver.ts +14 -6
- package/src/testing/TestClient.ts +18 -7
- package/dist/server/HyperServer.d.ts +0 -64
- package/dist/server/HyperServer.d.ts.map +0 -1
- package/dist/server/HyperServer.js +0 -54
- package/dist/server/HyperServer.js.map +0 -1
- package/dist/storage/S3Driver.d.ts +0 -27
- package/dist/storage/S3Driver.d.ts.map +0 -1
- package/dist/storage/S3Driver.js +0 -135
- package/dist/storage/S3Driver.js.map +0 -1
- package/dist/storage/StorageManager.d.ts +0 -31
- package/dist/storage/StorageManager.d.ts.map +0 -1
- package/dist/storage/StorageManager.js +0 -120
- package/dist/storage/StorageManager.js.map +0 -1
- package/src/server/HyperServer.ts +0 -121
- package/src/storage/S3Driver.ts +0 -171
- package/src/storage/StorageManager.ts +0 -142
package/src/http/Request.ts
CHANGED
|
@@ -55,6 +55,7 @@ export class Request {
|
|
|
55
55
|
#parsedQs: Dict<unknown> | undefined
|
|
56
56
|
#merged: Dict<unknown> | undefined
|
|
57
57
|
#files: Map<string, import('../bodyparser/MultipartFile.js').MultipartFile[]> = new Map()
|
|
58
|
+
#validated: unknown
|
|
58
59
|
|
|
59
60
|
/**
|
|
60
61
|
* CSRF token for this request (AdonisJS idiom: `request.csrfToken`). Seeded
|
|
@@ -376,6 +377,25 @@ export class Request {
|
|
|
376
377
|
this.#merged = undefined // reset merged cache
|
|
377
378
|
}
|
|
378
379
|
|
|
380
|
+
/**
|
|
381
|
+
* @internal Store the validated + transformed payload (called by the
|
|
382
|
+
* validation middleware after a route validator passes).
|
|
383
|
+
*/
|
|
384
|
+
setValidated(data: unknown): void {
|
|
385
|
+
this.#validated = data
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* The validated + coerced payload produced by the route's `.validate()`
|
|
390
|
+
* validator, or `undefined` when the route declares no validator. Prefer
|
|
391
|
+
* this over `body()` in a validated handler — it carries the sanitized,
|
|
392
|
+
* type-coerced values, not the raw input. Returns `unknown`; narrow it at
|
|
393
|
+
* the call site (the validator already guaranteed the shape).
|
|
394
|
+
*/
|
|
395
|
+
validated(): unknown {
|
|
396
|
+
return this.#validated
|
|
397
|
+
}
|
|
398
|
+
|
|
379
399
|
#ensureParsedBody(): void {
|
|
380
400
|
if (this.#parsedBody !== undefined) return
|
|
381
401
|
const raw = this.#raw.body
|
package/src/index.ts
CHANGED
|
@@ -25,7 +25,7 @@ export {
|
|
|
25
25
|
Exception,
|
|
26
26
|
ExceptionHandler,
|
|
27
27
|
} from './http/Exception.js'
|
|
28
|
-
export type { AuthState, RouteInfo } from './http/HttpContext.js'
|
|
28
|
+
export type { Authorizer, AuthState, RouteInfo } from './http/HttpContext.js'
|
|
29
29
|
export { HttpContext } from './http/HttpContext.js'
|
|
30
30
|
export { RedirectBuilder } from './http/RedirectBuilder.js'
|
|
31
31
|
export type { RawRequest } from './http/Request.js'
|
|
@@ -260,4 +260,4 @@ export {
|
|
|
260
260
|
StatsTracker,
|
|
261
261
|
} from './scheduler/index.js'
|
|
262
262
|
|
|
263
|
-
export const VERSION = '0.1.
|
|
263
|
+
export const VERSION = '0.1.7'
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { ReamError } from '../errors/ReamError.js'
|
|
15
|
-
import { E_FORBIDDEN, E_UNAUTHORIZED } from '../http/Exception.js'
|
|
15
|
+
import { E_FORBIDDEN, E_UNAUTHORIZED, E_VALIDATION_ERROR } from '../http/Exception.js'
|
|
16
16
|
import type { HttpContext } from '../http/HttpContext.js'
|
|
17
17
|
|
|
18
18
|
export type MiddlewareFunction = (
|
|
@@ -20,6 +20,15 @@ export type MiddlewareFunction = (
|
|
|
20
20
|
next: () => Promise<void>,
|
|
21
21
|
) => Promise<void> | void
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Structural contract for a route validator. A `@c9up/rune` schema matches this
|
|
25
|
+
* shape, so ream stays decoupled from the validation engine — any object with a
|
|
26
|
+
* `validate(data)` returning `{ valid, errors, data? }` works.
|
|
27
|
+
*/
|
|
28
|
+
export interface RuntimeValidator {
|
|
29
|
+
validate(data: unknown): { valid: boolean; errors: unknown[]; data?: unknown }
|
|
30
|
+
}
|
|
31
|
+
|
|
23
32
|
/** Named middleware registry. */
|
|
24
33
|
export class MiddlewareRegistry {
|
|
25
34
|
#global: MiddlewareFunction[] = []
|
|
@@ -50,15 +59,29 @@ export class MiddlewareRegistry {
|
|
|
50
59
|
namedMiddleware: string[],
|
|
51
60
|
inlineMiddleware: MiddlewareFunction[],
|
|
52
61
|
handler: MiddlewareFunction,
|
|
53
|
-
options?: {
|
|
62
|
+
options?: {
|
|
63
|
+
guards?: string[]
|
|
64
|
+
roles?: string[]
|
|
65
|
+
permissions?: string[]
|
|
66
|
+
validators?: RuntimeValidator[]
|
|
67
|
+
},
|
|
54
68
|
): MiddlewareFunction {
|
|
55
69
|
const stack: MiddlewareFunction[] = [
|
|
56
70
|
// 1. Global middleware
|
|
57
71
|
...this.#global,
|
|
58
|
-
// 2. Named middleware
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
72
|
+
// 2. Named middleware — an unknown name is a hard error, NOT a silent
|
|
73
|
+
// skip: a typo'd `.middleware('auht')` must never run the route
|
|
74
|
+
// unprotected with zero diagnostics.
|
|
75
|
+
...namedMiddleware.map((name) => {
|
|
76
|
+
const mw = this.#named.get(name)
|
|
77
|
+
if (mw === undefined) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
`[E_MIDDLEWARE_NOT_FOUND] Named middleware '${name}' is not registered. ` +
|
|
80
|
+
`Registered: ${[...this.#named.keys()].join(', ') || '(none)'}`,
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
return mw
|
|
84
|
+
}),
|
|
62
85
|
// 3. Inline middleware
|
|
63
86
|
...inlineMiddleware,
|
|
64
87
|
// 4. Guard enforcement (throws exceptions instead of setting response)
|
|
@@ -67,6 +90,11 @@ export class MiddlewareRegistry {
|
|
|
67
90
|
(options?.permissions?.length ?? 0) > 0
|
|
68
91
|
? [createGuardMiddleware(options?.guards ?? [], options?.roles, options?.permissions)]
|
|
69
92
|
: []),
|
|
93
|
+
// 4b. Validation — runs AFTER auth (so unauthenticated requests 401 before
|
|
94
|
+
// the body is inspected) and BEFORE the handler.
|
|
95
|
+
...((options?.validators?.length ?? 0) > 0
|
|
96
|
+
? [createValidationMiddleware(options?.validators ?? [])]
|
|
97
|
+
: []),
|
|
70
98
|
// 5. Handler
|
|
71
99
|
handler,
|
|
72
100
|
]
|
|
@@ -114,8 +142,13 @@ function createGuardMiddleware(
|
|
|
114
142
|
throw new E_UNAUTHORIZED()
|
|
115
143
|
}
|
|
116
144
|
|
|
145
|
+
// Auth providers may expose roles/permissions at the top level OR nested
|
|
146
|
+
// under `user` (e.g. @c9up/warden sets `ctx.auth.user.roles`). Read both so
|
|
147
|
+
// route-level `.role()`/`.permission()` work regardless of the provider's
|
|
148
|
+
// shape — reading only the top level silently denied every authenticated
|
|
149
|
+
// user when warden was wired.
|
|
117
150
|
if (roles && roles.length > 0) {
|
|
118
|
-
const userRoles = ctx.auth.roles ?? []
|
|
151
|
+
const userRoles = ctx.auth.roles ?? ctx.auth.user?.roles ?? []
|
|
119
152
|
const hasAnyRole = roles.some((r) => userRoles.includes(r))
|
|
120
153
|
if (!hasAnyRole) {
|
|
121
154
|
throw new E_FORBIDDEN('Insufficient role', roles)
|
|
@@ -123,7 +156,7 @@ function createGuardMiddleware(
|
|
|
123
156
|
}
|
|
124
157
|
|
|
125
158
|
if (permissions && permissions.length > 0) {
|
|
126
|
-
const userPerms = ctx.auth.permissions ?? []
|
|
159
|
+
const userPerms = ctx.auth.permissions ?? ctx.auth.user?.permissions ?? []
|
|
127
160
|
if (!permissions.every((p) => userPerms.includes(p))) {
|
|
128
161
|
throw new E_FORBIDDEN('Insufficient permissions', permissions)
|
|
129
162
|
}
|
|
@@ -133,4 +166,24 @@ function createGuardMiddleware(
|
|
|
133
166
|
}
|
|
134
167
|
}
|
|
135
168
|
|
|
169
|
+
/**
|
|
170
|
+
* Validation enforcement middleware. Runs each resolved route validator against
|
|
171
|
+
* the request body; the first failure throws `E_VALIDATION_ERROR` (422). On
|
|
172
|
+
* success the sanitized/coerced payload is stored on `request.validated()`.
|
|
173
|
+
*/
|
|
174
|
+
function createValidationMiddleware(validators: RuntimeValidator[]): MiddlewareFunction {
|
|
175
|
+
return async (ctx, next) => {
|
|
176
|
+
for (const validator of validators) {
|
|
177
|
+
const result = validator.validate(ctx.request.body())
|
|
178
|
+
if (!result.valid) {
|
|
179
|
+
throw new E_VALIDATION_ERROR(result.errors)
|
|
180
|
+
}
|
|
181
|
+
if (result.data !== undefined) {
|
|
182
|
+
ctx.request.setValidated(result.data)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
await next()
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
136
189
|
export { compose }
|
package/src/router/Router.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { HttpContext } from '../http/HttpContext.js'
|
|
8
|
+
import { safeDecodeURIComponent } from '../http/urlDecode.js'
|
|
8
9
|
import type { MiddlewareFunction } from '../middleware/Pipeline.js'
|
|
9
10
|
import type { MiddlewareEntry } from '../server/Server.js'
|
|
10
11
|
import { resolveMiddlewareEntry } from '../server/Server.js'
|
|
@@ -120,7 +121,21 @@ export class RouteBuilder {
|
|
|
120
121
|
return this
|
|
121
122
|
}
|
|
122
123
|
|
|
123
|
-
/**
|
|
124
|
+
/**
|
|
125
|
+
* Validate the request body against a named validator. At request time the
|
|
126
|
+
* kernel resolves the validator from the IoC container under the token
|
|
127
|
+
* `validator:<name>` and runs it after the auth guards and before the handler;
|
|
128
|
+
* a failure short-circuits with `422 E_VALIDATION_ERROR` and the validated,
|
|
129
|
+
* coerced payload is exposed on `ctx.request.validated()`. The same name also
|
|
130
|
+
* feeds the OpenAPI generator's requestBody schema.
|
|
131
|
+
*
|
|
132
|
+
* Register the validator (any object with `validate(data) => { valid, errors,
|
|
133
|
+
* data? }`, e.g. a `@c9up/rune` schema):
|
|
134
|
+
*
|
|
135
|
+
* container.singleton('validator:createUser', () => schema({ ... }))
|
|
136
|
+
*
|
|
137
|
+
* An unregistered name is a hard error — validation is never silently skipped.
|
|
138
|
+
*/
|
|
124
139
|
validate(validator: string): this {
|
|
125
140
|
this.#route.validators.push(validator)
|
|
126
141
|
return this
|
|
@@ -572,6 +587,13 @@ export class Router {
|
|
|
572
587
|
}
|
|
573
588
|
}
|
|
574
589
|
|
|
590
|
+
// HEAD falls back to the matching GET route (RFC 9110 §9.3.2 semantics —
|
|
591
|
+
// same headers, no body). Without this, `curl -I` and LB health probes
|
|
592
|
+
// 404 on every GET-only route.
|
|
593
|
+
if (method === 'HEAD') {
|
|
594
|
+
return this.match('GET', path, host)
|
|
595
|
+
}
|
|
596
|
+
|
|
575
597
|
return undefined
|
|
576
598
|
}
|
|
577
599
|
|
|
@@ -592,9 +614,12 @@ export class Router {
|
|
|
592
614
|
let url = route.path
|
|
593
615
|
if (params) {
|
|
594
616
|
for (const [key, value] of Object.entries(params)) {
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
617
|
+
// Word-boundary substitution: a bare `.replace(':id')` would corrupt
|
|
618
|
+
// `:idx` (`/x/:idx` → `/x/42x`). The lookahead requires the param name
|
|
619
|
+
// to END at the placeholder boundary. Keys are validated identifiers
|
|
620
|
+
// in route paths, but escape defensively (caller-supplied object).
|
|
621
|
+
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
622
|
+
url = url.replace(new RegExp(`:${escaped}\\??(?![\\w])`, 'g'), encodeURIComponent(value))
|
|
598
623
|
}
|
|
599
624
|
}
|
|
600
625
|
// Strip remaining optional placeholders (`:name?` segments not provided).
|
|
@@ -617,9 +642,17 @@ export class Router {
|
|
|
617
642
|
return [...this.#routes]
|
|
618
643
|
}
|
|
619
644
|
|
|
620
|
-
/**
|
|
645
|
+
/**
|
|
646
|
+
* Clear all registered routes AND registries (used by hot-reload). Router
|
|
647
|
+
* middleware, named middleware, and global matchers are reset too — the
|
|
648
|
+
* preloads that registered them re-run on reload, so keeping them would
|
|
649
|
+
* accumulate duplicates (middleware executing N+1 times after N reloads).
|
|
650
|
+
*/
|
|
621
651
|
clear(): void {
|
|
622
652
|
this.#routes = []
|
|
653
|
+
this.#routerMiddleware = []
|
|
654
|
+
this.#namedMiddleware.clear()
|
|
655
|
+
this.#globalMatchers = {}
|
|
623
656
|
this.#indexDirty = true
|
|
624
657
|
}
|
|
625
658
|
|
|
@@ -660,6 +693,10 @@ export class Router {
|
|
|
660
693
|
const matcher = matcherMap[param]
|
|
661
694
|
if (!matcher) continue
|
|
662
695
|
const regex = matcher instanceof RegExp ? matcher : matcher.pattern
|
|
696
|
+
// A user-supplied /g or /y regex is stateful (`lastIndex` advances on
|
|
697
|
+
// match) — without a reset, the same URL alternates match/no-match
|
|
698
|
+
// across requests. Reset before every test.
|
|
699
|
+
if (regex.global || regex.sticky) regex.lastIndex = 0
|
|
663
700
|
if (!regex.test(value)) return false
|
|
664
701
|
}
|
|
665
702
|
return true
|
|
@@ -684,12 +721,17 @@ function matchPath(pattern: string, actual: string): Record<string, string> | nu
|
|
|
684
721
|
for (let i = 0; i < wildcardIdx; i++) {
|
|
685
722
|
const part = patternParts[i]
|
|
686
723
|
if (part.startsWith(':')) {
|
|
687
|
-
params[part.substring(1)] = actualParts[i]
|
|
724
|
+
params[part.substring(1)] = safeDecodeURIComponent(actualParts[i])
|
|
688
725
|
} else if (part !== actualParts[i]) {
|
|
689
726
|
return null
|
|
690
727
|
}
|
|
691
728
|
}
|
|
692
|
-
|
|
729
|
+
// Decode per segment, then rejoin — an encoded `/` inside a segment stays
|
|
730
|
+
// a value character, the path structure was already split on raw `/`.
|
|
731
|
+
params['*'] = actualParts
|
|
732
|
+
.slice(wildcardIdx)
|
|
733
|
+
.map((s) => safeDecodeURIComponent(s))
|
|
734
|
+
.join('/')
|
|
693
735
|
return params
|
|
694
736
|
}
|
|
695
737
|
|
|
@@ -708,7 +750,10 @@ function matchPath(pattern: string, actual: string): Record<string, string> | nu
|
|
|
708
750
|
if (part.startsWith(':')) {
|
|
709
751
|
const paramName = isOptional ? part.slice(1, -1) : part.substring(1)
|
|
710
752
|
if (i < actualParts.length) {
|
|
711
|
-
|
|
753
|
+
// Percent-decode the captured segment (query strings already are) so
|
|
754
|
+
// `/users/Jos%C3%A9` yields `José` and matchers test the real value.
|
|
755
|
+
// safeDecode falls back to the raw segment on malformed input.
|
|
756
|
+
params[paramName] = safeDecodeURIComponent(actualParts[i])
|
|
712
757
|
}
|
|
713
758
|
// Optional param with no actual part — skip (param not set)
|
|
714
759
|
} else if (i < actualParts.length && part !== actualParts[i]) {
|
package/src/rpc/RpcProvider.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* @implements Story 24.1, 24.2
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
+
import { ReamError } from '../errors/ReamError.js'
|
|
18
19
|
import type { HttpContext } from '../http/HttpContext.js'
|
|
19
20
|
import type { AppContext } from '../Provider.js'
|
|
20
21
|
import { Provider } from '../Provider.js'
|
|
@@ -40,8 +41,19 @@ export class RpcProvider extends Provider {
|
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
override register(): void {
|
|
43
|
-
this.
|
|
44
|
-
|
|
44
|
+
const container = this.app.container
|
|
45
|
+
if (container.has('rpc')) {
|
|
46
|
+
if (container.resolve('rpc') === this.rpc) return // re-register is idempotent
|
|
47
|
+
throw new ReamError(
|
|
48
|
+
'RPC_PROVIDER_ALREADY_REGISTERED',
|
|
49
|
+
"Container token 'rpc' is already bound to a different instance",
|
|
50
|
+
{
|
|
51
|
+
hint: 'Only one RpcProvider can own the rpc binding. Remove the duplicate provider from your reamrc.ts.',
|
|
52
|
+
},
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
this.rpc.useContainer(container)
|
|
56
|
+
container.singleton('rpc', () => this.rpc)
|
|
45
57
|
}
|
|
46
58
|
|
|
47
59
|
override async boot(): Promise<void> {
|
package/src/rpc/RpcRouter.ts
CHANGED
|
@@ -12,9 +12,21 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import type { HttpContext } from '../http/HttpContext.js'
|
|
15
|
+
import type { MiddlewareRegistry, RuntimeValidator } from '../middleware/Pipeline.js'
|
|
16
|
+
import { compose } from '../middleware/Pipeline.js'
|
|
15
17
|
|
|
16
18
|
export type RpcHandler = (ctx: HttpContext, params: unknown) => Promise<unknown> | unknown
|
|
17
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Subset of the IoC container RpcRouter needs: resolve `namespace()` controllers,
|
|
22
|
+
* the shared `'middleware'` registry, and `validator:<name>` schemas.
|
|
23
|
+
*/
|
|
24
|
+
interface RpcContainer {
|
|
25
|
+
make<T>(target: new (...args: unknown[]) => T): T
|
|
26
|
+
resolve<T>(token: string): T
|
|
27
|
+
has(token: string): boolean
|
|
28
|
+
}
|
|
29
|
+
|
|
18
30
|
export interface RpcMethodDefinition {
|
|
19
31
|
name: string
|
|
20
32
|
handler: RpcHandler
|
|
@@ -60,13 +72,17 @@ export class RpcMethodBuilder {
|
|
|
60
72
|
|
|
61
73
|
interface RpcErrorResponse {
|
|
62
74
|
jsonrpc: '2.0'
|
|
63
|
-
error: { code: number; message: string }
|
|
75
|
+
error: { code: number; message: string; data?: unknown }
|
|
64
76
|
id: unknown
|
|
65
77
|
}
|
|
66
78
|
|
|
67
79
|
/** Build a JSON-RPC 2.0 error envelope. */
|
|
68
|
-
function rpcError(code: number, message: string, id: unknown): RpcErrorResponse {
|
|
69
|
-
return {
|
|
80
|
+
function rpcError(code: number, message: string, id: unknown, data?: unknown): RpcErrorResponse {
|
|
81
|
+
return {
|
|
82
|
+
jsonrpc: '2.0',
|
|
83
|
+
error: data === undefined ? { code, message } : { code, message, data },
|
|
84
|
+
id,
|
|
85
|
+
}
|
|
70
86
|
}
|
|
71
87
|
|
|
72
88
|
type ParsedRpcRequest =
|
|
@@ -91,6 +107,24 @@ function parseRpcRequest(request: unknown): ParsedRpcRequest {
|
|
|
91
107
|
return { ok: true, method, params, id }
|
|
92
108
|
}
|
|
93
109
|
|
|
110
|
+
/**
|
|
111
|
+
* A JSON-RPC notification is a well-formed request with NO `id` member. The spec
|
|
112
|
+
* (§4.1) says the server MUST NOT reply to one — it still runs for side-effects.
|
|
113
|
+
* A malformed object (no method / wrong version) is NOT a notification: it gets
|
|
114
|
+
* an `id:null` error response since the server can't tell the client's intent.
|
|
115
|
+
*/
|
|
116
|
+
function isNotification(request: unknown): boolean {
|
|
117
|
+
return (
|
|
118
|
+
!!request &&
|
|
119
|
+
typeof request === 'object' &&
|
|
120
|
+
'jsonrpc' in request &&
|
|
121
|
+
request.jsonrpc === '2.0' &&
|
|
122
|
+
'method' in request &&
|
|
123
|
+
typeof request.method === 'string' &&
|
|
124
|
+
!('id' in request)
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
|
|
94
128
|
/**
|
|
95
129
|
* Enforce the method's guards/roles/permissions against the request auth state.
|
|
96
130
|
* Returns the JSON-RPC error code+message to reject with, or undefined when the
|
|
@@ -105,13 +139,15 @@ function checkRpcAuthorization(
|
|
|
105
139
|
return { code: -32003, message: 'Unauthorized' }
|
|
106
140
|
}
|
|
107
141
|
if (def.roles.length > 0) {
|
|
108
|
-
|
|
142
|
+
// Warden nests roles/permissions under ctx.auth.user — mirror the HTTP path
|
|
143
|
+
// (Pipeline.ts createGuardMiddleware) so RPC role gates aren't fail-closed-dead.
|
|
144
|
+
const userRoles = ctx.auth?.roles ?? ctx.auth?.user?.roles ?? []
|
|
109
145
|
if (!def.roles.some((r: string) => userRoles.includes(r))) {
|
|
110
146
|
return { code: -32003, message: 'Insufficient role' }
|
|
111
147
|
}
|
|
112
148
|
}
|
|
113
149
|
if (def.permissions.length > 0) {
|
|
114
|
-
const userPerms = ctx.auth?.permissions ?? []
|
|
150
|
+
const userPerms = ctx.auth?.permissions ?? ctx.auth?.user?.permissions ?? []
|
|
115
151
|
if (!def.permissions.every((p: string) => userPerms.includes(p))) {
|
|
116
152
|
return { code: -32003, message: 'Insufficient permissions' }
|
|
117
153
|
}
|
|
@@ -122,7 +158,7 @@ function checkRpcAuthorization(
|
|
|
122
158
|
export class RpcRouter {
|
|
123
159
|
#methods: Map<string, RpcMethodDefinition> = new Map()
|
|
124
160
|
#groupConfig: { guards: string[]; roles: string[]; middleware: string[] } | null = null
|
|
125
|
-
#container?:
|
|
161
|
+
#container?: RpcContainer
|
|
126
162
|
|
|
127
163
|
/**
|
|
128
164
|
* Set the IoC container used to resolve `namespace()` controllers — mirrors
|
|
@@ -130,7 +166,7 @@ export class RpcRouter {
|
|
|
130
166
|
* same way (fresh DI per call). Without a container, controllers fall back to
|
|
131
167
|
* `new Controller()`.
|
|
132
168
|
*/
|
|
133
|
-
useContainer(container:
|
|
169
|
+
useContainer(container: RpcContainer): void {
|
|
134
170
|
this.#container = container
|
|
135
171
|
}
|
|
136
172
|
|
|
@@ -178,8 +214,13 @@ export class RpcRouter {
|
|
|
178
214
|
roles: [],
|
|
179
215
|
middleware: config.middleware ?? [],
|
|
180
216
|
}
|
|
181
|
-
callback
|
|
182
|
-
|
|
217
|
+
// try/finally so a throwing callback can't leave #groupConfig stuck at the
|
|
218
|
+
// group's value and leak its guards/middleware onto sibling registrations.
|
|
219
|
+
try {
|
|
220
|
+
callback(this)
|
|
221
|
+
} finally {
|
|
222
|
+
this.#groupConfig = prevConfig
|
|
223
|
+
}
|
|
183
224
|
}
|
|
184
225
|
|
|
185
226
|
/** Handle an incoming JSON-RPC request. */
|
|
@@ -197,18 +238,58 @@ export class RpcRouter {
|
|
|
197
238
|
return
|
|
198
239
|
}
|
|
199
240
|
// Note: batch items share the same ctx (auth state). Each is processed sequentially.
|
|
241
|
+
// Notifications still run (side-effects) but their result is omitted from
|
|
242
|
+
// the reply per JSON-RPC §4.1.
|
|
200
243
|
const results: unknown[] = []
|
|
201
244
|
for (const req of body) {
|
|
202
|
-
|
|
245
|
+
const r = await this.#processOne(ctx, req)
|
|
246
|
+
if (!isNotification(req)) results.push(r)
|
|
247
|
+
}
|
|
248
|
+
// An all-notification batch produces no response body (spec: no reply).
|
|
249
|
+
if (results.length === 0) {
|
|
250
|
+
ctx.response.status(204).send('')
|
|
251
|
+
return
|
|
203
252
|
}
|
|
204
253
|
ctx.response.status(200).json(results)
|
|
205
254
|
return
|
|
206
255
|
}
|
|
207
256
|
|
|
208
257
|
const result = await this.#processOne(ctx, body)
|
|
258
|
+
// A notification runs for side-effects but MUST NOT receive a reply.
|
|
259
|
+
if (isNotification(body)) {
|
|
260
|
+
ctx.response.status(204).send('')
|
|
261
|
+
return
|
|
262
|
+
}
|
|
209
263
|
ctx.response.status(200).json(result)
|
|
210
264
|
}
|
|
211
265
|
|
|
266
|
+
/**
|
|
267
|
+
* Run the method's declared named middleware (resolved from the shared
|
|
268
|
+
* `'middleware'` registry) onion-style before the handler. Guards/auth
|
|
269
|
+
* middleware deny by THROWING — #processOne maps the throw to a JSON-RPC
|
|
270
|
+
* error. An unknown middleware name is a hard error, never a silent skip.
|
|
271
|
+
* (RPC shares one ctx per batch, so response-writing/short-circuit middleware
|
|
272
|
+
* are unsupported — deny by throwing.)
|
|
273
|
+
*/
|
|
274
|
+
async #runMiddleware(ctx: HttpContext, names: string[]): Promise<void> {
|
|
275
|
+
const registry = this.#container?.has('middleware')
|
|
276
|
+
? this.#container.resolve<MiddlewareRegistry>('middleware')
|
|
277
|
+
: undefined
|
|
278
|
+
if (!registry) {
|
|
279
|
+
throw new Error(
|
|
280
|
+
`[E_MIDDLEWARE_NOT_FOUND] RPC middleware [${names.join(', ')}] is declared but no middleware registry is wired into the RpcRouter.`,
|
|
281
|
+
)
|
|
282
|
+
}
|
|
283
|
+
const fns = names.map((name) => {
|
|
284
|
+
const mw = registry.get(name)
|
|
285
|
+
if (!mw) {
|
|
286
|
+
throw new Error(`[E_MIDDLEWARE_NOT_FOUND] Named middleware '${name}' is not registered.`)
|
|
287
|
+
}
|
|
288
|
+
return mw
|
|
289
|
+
})
|
|
290
|
+
await compose(fns)(ctx, async () => {})
|
|
291
|
+
}
|
|
292
|
+
|
|
212
293
|
async #processOne(ctx: HttpContext, request: unknown): Promise<unknown> {
|
|
213
294
|
const parsed = parseRpcRequest(request)
|
|
214
295
|
if (!parsed.ok) return parsed.response
|
|
@@ -221,7 +302,30 @@ export class RpcRouter {
|
|
|
221
302
|
const denied = checkRpcAuthorization(ctx, def)
|
|
222
303
|
if (denied) return rpcError(denied.code, denied.message, id)
|
|
223
304
|
|
|
224
|
-
|
|
305
|
+
// Run declared named middleware (e.g. guards) — they throw to deny, mapped
|
|
306
|
+
// to a JSON-RPC error by the catch below.
|
|
307
|
+
if (def.middleware.length > 0) {
|
|
308
|
+
await this.#runMiddleware(ctx, def.middleware)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Run the declared validator against params. A declared-but-unregistered
|
|
312
|
+
// validator is a hard error — never silently skip validation.
|
|
313
|
+
let effectiveParams = params
|
|
314
|
+
if (def.validator) {
|
|
315
|
+
const token = `validator:${def.validator}`
|
|
316
|
+
if (!this.#container?.has(token)) {
|
|
317
|
+
return rpcError(
|
|
318
|
+
-32603,
|
|
319
|
+
`Validator '${def.validator}' is not registered (bind container.singleton('${token}', () => schema)).`,
|
|
320
|
+
id,
|
|
321
|
+
)
|
|
322
|
+
}
|
|
323
|
+
const outcome = this.#container.resolve<RuntimeValidator>(token).validate(params)
|
|
324
|
+
if (!outcome.valid) return rpcError(-32602, 'Invalid params', id, outcome.errors)
|
|
325
|
+
effectiveParams = outcome.data ?? params
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const result = await def.handler(ctx, effectiveParams)
|
|
225
329
|
return { jsonrpc: '2.0', result, id }
|
|
226
330
|
} catch (err) {
|
|
227
331
|
// Don't leak internal error details to caller
|
|
@@ -35,6 +35,9 @@ export interface ScheduleProviderOptions {
|
|
|
35
35
|
scheduler?: Scheduler
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/** Prevents two ScheduleProvider instances from ticking simultaneously (two apps, same process). */
|
|
39
|
+
let activeProvider: ScheduleProvider | undefined
|
|
40
|
+
|
|
38
41
|
export class ScheduleProvider extends Provider {
|
|
39
42
|
readonly scheduler: Scheduler
|
|
40
43
|
#booted = false
|
|
@@ -168,10 +171,17 @@ export class ScheduleProvider extends Provider {
|
|
|
168
171
|
}
|
|
169
172
|
|
|
170
173
|
override async start(): Promise<void> {
|
|
174
|
+
if (activeProvider !== undefined && activeProvider !== this) {
|
|
175
|
+
// A previous app's scheduler is still running — stop it before this one starts,
|
|
176
|
+
// otherwise every @Schedule task fires twice per tick.
|
|
177
|
+
activeProvider.scheduler.stop()
|
|
178
|
+
}
|
|
179
|
+
activeProvider = this
|
|
171
180
|
this.scheduler.start()
|
|
172
181
|
}
|
|
173
182
|
|
|
174
183
|
override async shutdown(): Promise<void> {
|
|
184
|
+
if (activeProvider === this) activeProvider = undefined
|
|
175
185
|
this.scheduler.stop()
|
|
176
186
|
}
|
|
177
187
|
}
|
package/src/services/app.ts
CHANGED
|
@@ -16,6 +16,15 @@ export function setApp(app: Application): void {
|
|
|
16
16
|
instance = app
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* @internal Unset the locator IF it still points at `app` (called by
|
|
21
|
+
* Ignitor.stop()). Ownership-guarded: when a second Ignitor rebound the
|
|
22
|
+
* locator, the older app's stop() must not tear down the newer binding.
|
|
23
|
+
*/
|
|
24
|
+
export function clearApp(app: Application): void {
|
|
25
|
+
if (instance === app) instance = undefined
|
|
26
|
+
}
|
|
27
|
+
|
|
19
28
|
const app: Application = new Proxy({} as Application, {
|
|
20
29
|
get(_target, prop) {
|
|
21
30
|
if (!instance) {
|
package/src/services/router.ts
CHANGED
|
@@ -17,6 +17,14 @@ export function setRouter(router: Router): void {
|
|
|
17
17
|
instance = router
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* @internal Unset the locator IF it still points at `router` (called by
|
|
22
|
+
* Ignitor.stop()). Ownership-guarded — see services/app.ts.
|
|
23
|
+
*/
|
|
24
|
+
export function clearRouter(router: Router): void {
|
|
25
|
+
if (instance === router) instance = undefined
|
|
26
|
+
}
|
|
27
|
+
|
|
20
28
|
/** @internal Get the router instance directly. */
|
|
21
29
|
export function getRouter(): Router | undefined {
|
|
22
30
|
return instance
|
package/src/services/server.ts
CHANGED
|
@@ -16,6 +16,14 @@ export function setServer(server: Server): void {
|
|
|
16
16
|
instance = server
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* @internal Unset the locator IF it still points at `server` (called by
|
|
21
|
+
* Ignitor.stop()). Ownership-guarded — see services/app.ts.
|
|
22
|
+
*/
|
|
23
|
+
export function clearServer(server: Server): void {
|
|
24
|
+
if (instance === server) instance = undefined
|
|
25
|
+
}
|
|
26
|
+
|
|
19
27
|
/** @internal Get the server instance directly. */
|
|
20
28
|
export function getServer(): Server | undefined {
|
|
21
29
|
return instance
|
|
@@ -78,6 +78,12 @@ export default class SessionMiddleware {
|
|
|
78
78
|
const shouldEmit = session.isDirty() || (rolling && hadIncomingCookie)
|
|
79
79
|
if (shouldEmit) {
|
|
80
80
|
const encoded = this.#cookieDriver.encode(session.toJSON())
|
|
81
|
+
if (encoded.length > 4096) {
|
|
82
|
+
process.stderr.write(
|
|
83
|
+
`[session] Cookie session exceeds 4096 bytes (${encoded.length}). ` +
|
|
84
|
+
'Switch to a server-side driver (memory/redis) or reduce session data.\n',
|
|
85
|
+
)
|
|
86
|
+
}
|
|
81
87
|
ctx.response.cookie(cookieName, encoded, {
|
|
82
88
|
maxAge: this.#config.clearWithBrowser ? undefined : maxAge,
|
|
83
89
|
path: '/',
|
|
@@ -1,27 +1,35 @@
|
|
|
1
1
|
import type { SessionDriver } from '../Session.js'
|
|
2
2
|
|
|
3
|
+
const SWEEP_THRESHOLD = 1000
|
|
4
|
+
|
|
3
5
|
export class MemoryDriver implements SessionDriver {
|
|
4
|
-
|
|
6
|
+
#store: Map<string, { data: Record<string, unknown>; expiresAt: number }> = new Map()
|
|
5
7
|
|
|
6
8
|
async read(sessionId: string): Promise<Record<string, unknown>> {
|
|
7
|
-
const entry = this
|
|
9
|
+
const entry = this.#store.get(sessionId)
|
|
8
10
|
if (!entry || entry.expiresAt < Date.now()) {
|
|
9
|
-
this
|
|
11
|
+
this.#store.delete(sessionId)
|
|
10
12
|
return {}
|
|
11
13
|
}
|
|
12
14
|
return { ...entry.data }
|
|
13
15
|
}
|
|
14
16
|
|
|
15
17
|
async write(sessionId: string, data: Record<string, unknown>, ttl: number): Promise<void> {
|
|
16
|
-
this
|
|
18
|
+
if (this.#store.size >= SWEEP_THRESHOLD) {
|
|
19
|
+
const now = Date.now()
|
|
20
|
+
for (const [id, entry] of this.#store) {
|
|
21
|
+
if (entry.expiresAt < now) this.#store.delete(id)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
this.#store.set(sessionId, { data, expiresAt: Date.now() + ttl * 1000 })
|
|
17
25
|
}
|
|
18
26
|
|
|
19
27
|
async destroy(sessionId: string): Promise<void> {
|
|
20
|
-
this
|
|
28
|
+
this.#store.delete(sessionId)
|
|
21
29
|
}
|
|
22
30
|
|
|
23
31
|
async touch(sessionId: string, ttl: number): Promise<void> {
|
|
24
|
-
const entry = this
|
|
32
|
+
const entry = this.#store.get(sessionId)
|
|
25
33
|
if (entry) {
|
|
26
34
|
entry.expiresAt = Date.now() + ttl * 1000
|
|
27
35
|
}
|