@c9up/ream 0.2.29 → 0.2.30
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 +5 -6
- package/dist/Codemods.d.ts +8 -0
- package/dist/Codemods.d.ts.map +1 -1
- package/dist/Codemods.js +29 -1
- package/dist/Codemods.js.map +1 -1
- package/dist/HttpKernel.d.ts.map +1 -1
- package/dist/HttpKernel.js +17 -5
- package/dist/HttpKernel.js.map +1 -1
- package/dist/Ignitor.d.ts +5 -0
- package/dist/Ignitor.d.ts.map +1 -1
- package/dist/Ignitor.js +12 -1
- package/dist/Ignitor.js.map +1 -1
- package/dist/bodyparser/BodyParserMiddleware.d.ts +4 -4
- package/dist/bodyparser/BodyParserMiddleware.d.ts.map +1 -1
- package/dist/bodyparser/BodyParserMiddleware.js +38 -11
- package/dist/bodyparser/BodyParserMiddleware.js.map +1 -1
- package/dist/bodyparser/MultipartFile.d.ts +21 -15
- package/dist/bodyparser/MultipartFile.d.ts.map +1 -1
- package/dist/bodyparser/MultipartFile.js +40 -58
- package/dist/bodyparser/MultipartFile.js.map +1 -1
- package/dist/bodyparser/parseSize.d.ts +7 -4
- package/dist/bodyparser/parseSize.d.ts.map +1 -1
- package/dist/bodyparser/parseSize.js +12 -17
- package/dist/bodyparser/parseSize.js.map +1 -1
- package/dist/bodyparser/qsParse.d.ts +10 -0
- package/dist/bodyparser/qsParse.d.ts.map +1 -1
- package/dist/bodyparser/qsParse.js +27 -12
- package/dist/bodyparser/qsParse.js.map +1 -1
- package/dist/console/prompts.d.ts.map +1 -1
- package/dist/console/prompts.js +3 -1
- package/dist/console/prompts.js.map +1 -1
- package/dist/container/Container.d.ts.map +1 -1
- package/dist/container/Container.js +61 -71
- package/dist/container/Container.js.map +1 -1
- package/dist/events/Emitter.d.ts +12 -0
- package/dist/events/Emitter.d.ts.map +1 -1
- package/dist/events/Emitter.js +36 -9
- package/dist/events/Emitter.js.map +1 -1
- package/dist/events/helix/helpers.d.ts.map +1 -1
- package/dist/events/helix/helpers.js +19 -9
- package/dist/events/helix/helpers.js.map +1 -1
- package/dist/graphql/GraphQLEngine.d.ts +8 -0
- package/dist/graphql/GraphQLEngine.d.ts.map +1 -1
- package/dist/graphql/GraphQLEngine.js +77 -11
- package/dist/graphql/GraphQLEngine.js.map +1 -1
- package/dist/helpers/MessageBuilder.d.ts +4 -2
- package/dist/helpers/MessageBuilder.d.ts.map +1 -1
- package/dist/helpers/MessageBuilder.js +4 -2
- package/dist/helpers/MessageBuilder.js.map +1 -1
- package/dist/helpers/errors.d.ts +11 -0
- package/dist/helpers/errors.d.ts.map +1 -0
- package/dist/helpers/errors.js +17 -0
- package/dist/helpers/errors.js.map +1 -0
- package/dist/helpers/napi-loader.d.ts.map +1 -1
- package/dist/helpers/napi-loader.js +3 -2
- package/dist/helpers/napi-loader.js.map +1 -1
- package/dist/http/HttpContext.d.ts +20 -2
- package/dist/http/HttpContext.d.ts.map +1 -1
- package/dist/http/HttpContext.js +10 -4
- package/dist/http/HttpContext.js.map +1 -1
- package/dist/http/RedirectBuilder.d.ts.map +1 -1
- package/dist/http/RedirectBuilder.js +4 -0
- package/dist/http/RedirectBuilder.js.map +1 -1
- package/dist/http/Request.d.ts.map +1 -1
- package/dist/http/Request.js +15 -29
- package/dist/http/Request.js.map +1 -1
- package/dist/http/Response.d.ts +7 -4
- package/dist/http/Response.d.ts.map +1 -1
- package/dist/http/Response.js +14 -6
- package/dist/http/Response.js.map +1 -1
- package/dist/http/permissions.d.ts +14 -8
- package/dist/http/permissions.d.ts.map +1 -1
- package/dist/http/permissions.js +26 -16
- package/dist/http/permissions.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/index.js.map +1 -1
- package/dist/middleware/Pipeline.js +1 -1
- package/dist/middleware/Pipeline.js.map +1 -1
- package/dist/native/generated.d.ts +16 -30
- package/dist/native/generated.d.ts.map +1 -1
- package/dist/profiler/Profiler.d.ts.map +1 -1
- package/dist/profiler/Profiler.js +4 -1
- package/dist/profiler/Profiler.js.map +1 -1
- package/dist/profiler/client.generated.d.ts +5 -0
- package/dist/profiler/client.generated.d.ts.map +1 -0
- package/dist/profiler/client.generated.js +6 -0
- package/dist/profiler/client.generated.js.map +1 -0
- package/dist/profiler/collectors.d.ts +92 -13
- package/dist/profiler/collectors.d.ts.map +1 -1
- package/dist/profiler/collectors.js +106 -19
- package/dist/profiler/collectors.js.map +1 -1
- package/dist/profiler/endpoint.d.ts +2 -1
- package/dist/profiler/endpoint.d.ts.map +1 -1
- package/dist/profiler/endpoint.js +25 -1
- package/dist/profiler/endpoint.js.map +1 -1
- package/dist/profiler/index.d.ts +8 -4
- package/dist/profiler/index.d.ts.map +1 -1
- package/dist/profiler/index.js +11 -4
- package/dist/profiler/index.js.map +1 -1
- package/dist/profiler/middleware.d.ts.map +1 -1
- package/dist/profiler/middleware.js +76 -10
- package/dist/profiler/middleware.js.map +1 -1
- package/dist/profiler/preview.d.ts +17 -0
- package/dist/profiler/preview.d.ts.map +1 -0
- package/dist/profiler/preview.js +100 -0
- package/dist/profiler/preview.js.map +1 -0
- package/dist/profiler/toolbar.d.ts +15 -23
- package/dist/profiler/toolbar.d.ts.map +1 -1
- package/dist/profiler/toolbar.js +25 -91
- package/dist/profiler/toolbar.js.map +1 -1
- package/dist/profiler/types.d.ts +6 -1
- package/dist/profiler/types.d.ts.map +1 -1
- package/dist/router/Router.d.ts +6 -0
- package/dist/router/Router.d.ts.map +1 -1
- package/dist/router/Router.js +115 -12
- package/dist/router/Router.js.map +1 -1
- package/dist/rpc/RpcRouter.js +3 -3
- package/dist/rpc/RpcRouter.js.map +1 -1
- package/dist/scheduler/Scheduler.js +1 -1
- package/dist/scheduler/Scheduler.js.map +1 -1
- package/dist/security/CookieSigner.d.ts.map +1 -1
- package/dist/security/CookieSigner.js +15 -5
- package/dist/security/CookieSigner.js.map +1 -1
- package/dist/security/SignedUrl.d.ts.map +1 -1
- package/dist/security/SignedUrl.js +10 -18
- package/dist/security/SignedUrl.js.map +1 -1
- package/dist/server/Server.d.ts +4 -1
- package/dist/server/Server.d.ts.map +1 -1
- package/dist/server/Server.js.map +1 -1
- package/dist/server/nativeLimits.d.ts +9 -1
- package/dist/server/nativeLimits.d.ts.map +1 -1
- package/dist/server/nativeLimits.js +8 -0
- package/dist/server/nativeLimits.js.map +1 -1
- package/dist/session/Session.d.ts.map +1 -1
- package/dist/session/Session.js +13 -4
- package/dist/session/Session.js.map +1 -1
- package/dist/session/drivers/MemoryDriver.d.ts.map +1 -1
- package/dist/session/drivers/MemoryDriver.js +14 -2
- package/dist/session/drivers/MemoryDriver.js.map +1 -1
- package/dist/storage/StaticMiddleware.d.ts.map +1 -1
- package/dist/storage/StaticMiddleware.js +20 -9
- package/dist/storage/StaticMiddleware.js.map +1 -1
- package/dist/testing/ApiResponse.d.ts.map +1 -1
- package/dist/testing/ApiResponse.js +7 -1
- package/dist/testing/ApiResponse.js.map +1 -1
- package/dist/testing/TestClient.d.ts.map +1 -1
- package/dist/testing/TestClient.js +16 -4
- package/dist/testing/TestClient.js.map +1 -1
- package/dist/utils/Macroable.d.ts +1 -1
- package/dist/utils/Macroable.js +2 -2
- package/dist/utils/Macroable.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 +1 -2
- 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/Codemods.ts +49 -3
- package/src/HttpKernel.ts +18 -7
- package/src/Ignitor.ts +21 -4
- package/src/bodyparser/BodyParserMiddleware.ts +42 -16
- package/src/bodyparser/MultipartFile.ts +46 -67
- package/src/bodyparser/parseSize.ts +17 -17
- package/src/bodyparser/qsParse.ts +28 -13
- package/src/console/prompts.ts +3 -1
- package/src/container/Container.ts +97 -81
- package/src/events/Emitter.ts +37 -10
- package/src/events/helix/helpers.ts +24 -19
- package/src/graphql/GraphQLEngine.ts +89 -13
- package/src/helpers/MessageBuilder.ts +4 -2
- package/src/helpers/errors.ts +16 -0
- package/src/helpers/napi-loader.ts +3 -2
- package/src/http/HttpContext.ts +20 -4
- package/src/http/RedirectBuilder.ts +3 -0
- package/src/http/Request.ts +15 -29
- package/src/http/Response.ts +15 -7
- package/src/http/permissions.ts +31 -15
- package/src/index.ts +2 -1
- package/src/middleware/Pipeline.ts +1 -1
- package/src/native/generated.ts +18 -42
- package/src/profiler/Profiler.ts +4 -1
- package/src/profiler/client/toolbar.css +595 -0
- package/src/profiler/client/toolbar.js +1299 -0
- package/src/profiler/client.generated.ts +7 -0
- package/src/profiler/collectors.ts +156 -19
- package/src/profiler/endpoint.ts +27 -1
- package/src/profiler/index.ts +17 -3
- package/src/profiler/middleware.ts +74 -9
- package/src/profiler/preview.ts +104 -0
- package/src/profiler/toolbar.ts +28 -91
- package/src/profiler/types.ts +6 -1
- package/src/router/Router.ts +136 -13
- package/src/rpc/RpcRouter.ts +4 -4
- package/src/scheduler/Scheduler.ts +1 -1
- package/src/security/CookieSigner.ts +13 -5
- package/src/security/SignedUrl.ts +12 -16
- package/src/server/Server.ts +4 -1
- package/src/server/nativeLimits.ts +18 -1
- package/src/session/Session.ts +13 -4
- package/src/session/drivers/MemoryDriver.ts +15 -2
- package/src/storage/StaticMiddleware.ts +18 -9
- package/src/testing/ApiResponse.ts +7 -1
- package/src/testing/TestClient.ts +14 -5
- package/src/utils/Macroable.ts +2 -2
- package/scripts/build-napi-types.mjs +0 -69
- package/scripts/copy-napi.mjs +0 -121
- package/scripts/generate-mime.mjs +0 -119
- package/scripts/generate-napi-types.mjs +0 -187
package/src/events/Emitter.ts
CHANGED
|
@@ -96,6 +96,8 @@ export class Emitter {
|
|
|
96
96
|
#fakedEvents?: Set<BufferedEventName>
|
|
97
97
|
/** Wildcard subscriptions, so `offAny(listener)` can find its Rust handle. */
|
|
98
98
|
readonly #anySubscriptions = new Map<(eventName: string, data: unknown) => void, Set<number>>()
|
|
99
|
+
/** Observers told of every emission as it happens — see {@link tap}. */
|
|
100
|
+
readonly #taps = new Set<(event: string, data: unknown) => void>()
|
|
99
101
|
|
|
100
102
|
constructor(bus: EventBus, resolver?: EmitterResolver) {
|
|
101
103
|
this.#bus = bus
|
|
@@ -227,6 +229,34 @@ export class Emitter {
|
|
|
227
229
|
|
|
228
230
|
// ─── Emit ─────────────────────────────────────────────────
|
|
229
231
|
|
|
232
|
+
/**
|
|
233
|
+
* Watch every emission — string and class-based — as it happens.
|
|
234
|
+
*
|
|
235
|
+
* Development tooling: the profiler's events panel. `onAny` is not enough for
|
|
236
|
+
* it, because a wildcard subscription goes through the native bus and is
|
|
237
|
+
* called later, outside the request that emitted. A tap runs synchronously,
|
|
238
|
+
* in the emitting code's own async context, before any listener.
|
|
239
|
+
*
|
|
240
|
+
* It observes and nothing more: it sees a faked event too, it cannot change
|
|
241
|
+
* or cancel the emission, and a tap that throws is ignored.
|
|
242
|
+
*/
|
|
243
|
+
tap(observer: (event: string, data: unknown) => void): UnsubscribeFunction {
|
|
244
|
+
this.#taps.add(observer)
|
|
245
|
+
return () => {
|
|
246
|
+
this.#taps.delete(observer)
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
#notifyTaps(event: string, data: unknown): void {
|
|
251
|
+
for (const observer of this.#taps) {
|
|
252
|
+
try {
|
|
253
|
+
observer(event, data)
|
|
254
|
+
} catch {
|
|
255
|
+
// Observation must never break the emission it watches.
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
230
260
|
/**
|
|
231
261
|
* Run an event's listeners, in parallel or one after another.
|
|
232
262
|
*
|
|
@@ -235,10 +265,9 @@ export class Emitter {
|
|
|
235
265
|
* event it does not own.
|
|
236
266
|
*/
|
|
237
267
|
async #dispatchListeners(event: string, data: unknown, serial: boolean): Promise<void> {
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
]
|
|
268
|
+
// `onAny` listeners are not here: they subscribe through the native bus,
|
|
269
|
+
// which delivers every matching event to them on its own.
|
|
270
|
+
const listeners = this.#stringListeners.get(event) ?? []
|
|
242
271
|
if (listeners.length === 0) return
|
|
243
272
|
const run = async (fn: ListenerFn): Promise<void> => {
|
|
244
273
|
try {
|
|
@@ -254,17 +283,13 @@ export class Emitter {
|
|
|
254
283
|
await Promise.all(listeners.map(run))
|
|
255
284
|
}
|
|
256
285
|
|
|
257
|
-
/** Listeners registered through `onAny` whose pattern matches `event`. */
|
|
258
|
-
#wildcardListenersFor(_event: string): ListenerFn[] {
|
|
259
|
-
return []
|
|
260
|
-
}
|
|
261
|
-
|
|
262
286
|
/**
|
|
263
287
|
* Emit and run the listeners ONE AT A TIME (AdonisJS `emitSerial`).
|
|
264
288
|
*
|
|
265
289
|
* Use it when order matters — a listener that seeds what the next one reads.
|
|
266
290
|
*/
|
|
267
291
|
async emitSerial(event: string, data: unknown): Promise<void> {
|
|
292
|
+
this.#notifyTaps(event, data)
|
|
268
293
|
if (this.#capture(event, data)) return
|
|
269
294
|
await this.#dispatchListeners(event, data, true)
|
|
270
295
|
this.#publishToBus(event, data)
|
|
@@ -282,6 +307,7 @@ export class Emitter {
|
|
|
282
307
|
* rejects is reported through `emitError`, never left unhandled.
|
|
283
308
|
*/
|
|
284
309
|
async emit(event: string, data: unknown): Promise<void> {
|
|
310
|
+
this.#notifyTaps(event, data)
|
|
285
311
|
if (this.#capture(event, data)) return
|
|
286
312
|
await this.#dispatchListeners(event, data, false)
|
|
287
313
|
this.#publishToBus(event, data)
|
|
@@ -331,11 +357,12 @@ export class Emitter {
|
|
|
331
357
|
*/
|
|
332
358
|
async dispatchEvent<T extends object>(event: T): Promise<void> {
|
|
333
359
|
const EventClass = event.constructor as EventConstructor<T>
|
|
360
|
+
const name = (EventClass as { eventName?: string }).eventName ?? classToEventName(EventClass)
|
|
361
|
+
this.#notifyTaps(name, event)
|
|
334
362
|
// Faked by the class, as AdonisJS does — `fake([TaskDeclared])` catches
|
|
335
363
|
// `new TaskDeclared(...).emit()`.
|
|
336
364
|
if (this.#capture(EventClass, event)) return
|
|
337
365
|
const listeners = this.#classListeners.get(EventClass) ?? []
|
|
338
|
-
const name = (EventClass as { eventName?: string }).eventName ?? classToEventName(EventClass)
|
|
339
366
|
|
|
340
367
|
// Per-listener error isolation — mirrors the string `emit()` contract.
|
|
341
368
|
// A side-effect listener that throws (SMTP down, etc.) MUST NOT abort
|
|
@@ -15,6 +15,16 @@ export interface CollectedEvent {
|
|
|
15
15
|
[key: string]: unknown
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
function isCollectedEvent(value: unknown): value is CollectedEvent {
|
|
19
|
+
return (
|
|
20
|
+
typeof value === 'object' &&
|
|
21
|
+
value !== null &&
|
|
22
|
+
'name' in value &&
|
|
23
|
+
typeof value.name === 'string' &&
|
|
24
|
+
'data' in value
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
|
|
18
28
|
export interface CollectResult {
|
|
19
29
|
events: CollectedEvent[]
|
|
20
30
|
subId: number
|
|
@@ -34,15 +44,17 @@ interface BusLike {
|
|
|
34
44
|
export function collect(bus: BusLike, pattern: string): CollectResult {
|
|
35
45
|
const events: CollectedEvent[] = []
|
|
36
46
|
const subId = bus.subscribe(pattern, (eventJson: string) => {
|
|
47
|
+
const unreadable: CollectedEvent = {
|
|
48
|
+
_raw: eventJson,
|
|
49
|
+
_parseError: true,
|
|
50
|
+
name: '',
|
|
51
|
+
data: undefined,
|
|
52
|
+
}
|
|
37
53
|
try {
|
|
38
|
-
|
|
54
|
+
const parsed: unknown = JSON.parse(eventJson)
|
|
55
|
+
events.push(isCollectedEvent(parsed) ? parsed : unreadable)
|
|
39
56
|
} catch {
|
|
40
|
-
events.push(
|
|
41
|
-
_raw: eventJson,
|
|
42
|
-
_parseError: true,
|
|
43
|
-
name: '',
|
|
44
|
-
data: undefined,
|
|
45
|
-
} as CollectedEvent)
|
|
57
|
+
events.push(unreadable)
|
|
46
58
|
}
|
|
47
59
|
})
|
|
48
60
|
return { events, subId }
|
|
@@ -233,20 +245,13 @@ function partialMatch(actual: unknown, expected: unknown): boolean {
|
|
|
233
245
|
|
|
234
246
|
if (Array.isArray(expected)) {
|
|
235
247
|
if (!Array.isArray(actual)) return false
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
)
|
|
248
|
+
const items: unknown[] = actual
|
|
249
|
+
return expected.every((item, i) => i < items.length && partialMatch(items[i], item))
|
|
239
250
|
}
|
|
240
251
|
|
|
241
|
-
for (const key of Object.keys(expected
|
|
242
|
-
if (!(key in
|
|
243
|
-
if (
|
|
244
|
-
!partialMatch(
|
|
245
|
-
(actual as Record<string, unknown>)[key],
|
|
246
|
-
(expected as Record<string, unknown>)[key],
|
|
247
|
-
)
|
|
248
|
-
)
|
|
249
|
-
return false
|
|
252
|
+
for (const key of Object.keys(expected)) {
|
|
253
|
+
if (!(key in actual)) return false
|
|
254
|
+
if (!partialMatch(Reflect.get(actual, key), Reflect.get(expected, key))) return false
|
|
250
255
|
}
|
|
251
256
|
return true
|
|
252
257
|
}
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
import * as fs from 'node:fs'
|
|
25
25
|
import { currentNodeEnv } from '../env/nodeEnv.js'
|
|
26
26
|
import { loadNapi } from '../helpers/napi-loader.js'
|
|
27
|
+
import { Exception } from '../http/Exception.js'
|
|
27
28
|
import type { HttpContext } from '../http/HttpContext.js'
|
|
28
29
|
import { holdsPermissions } from '../http/permissions.js'
|
|
29
30
|
|
|
@@ -170,6 +171,14 @@ export interface GraphQLConfig {
|
|
|
170
171
|
* (default: 1000).
|
|
171
172
|
*/
|
|
172
173
|
maxComplexity?: number
|
|
174
|
+
/**
|
|
175
|
+
* Echo the message of any error a resolver throws to the client. Default:
|
|
176
|
+
* only when NODE_ENV is development. Otherwise only a deliberate
|
|
177
|
+
* `Exception`, a client error (status below 500) or an error flagged
|
|
178
|
+
* `expose: true` keeps its message; anything else reads "Internal server
|
|
179
|
+
* error", and the real error goes to `ctx.logger`.
|
|
180
|
+
*/
|
|
181
|
+
debug?: boolean
|
|
173
182
|
}
|
|
174
183
|
|
|
175
184
|
export interface ResolverOptions {
|
|
@@ -208,6 +217,7 @@ export class GraphQLEngine {
|
|
|
208
217
|
#container?: { make<T>(target: abstract new (...args: never[]) => T): Promise<T> }
|
|
209
218
|
readonly path: string
|
|
210
219
|
#playground: boolean
|
|
220
|
+
readonly #debug: boolean
|
|
211
221
|
#maxQueryBytes: number
|
|
212
222
|
#maxDepth: number
|
|
213
223
|
#maxComplexity: number
|
|
@@ -237,6 +247,7 @@ export class GraphQLEngine {
|
|
|
237
247
|
this.#maxQueryBytes = config.maxQueryBytes ?? 100_000
|
|
238
248
|
this.#maxDepth = config.maxDepth ?? 12
|
|
239
249
|
this.#maxComplexity = config.maxComplexity ?? 1000
|
|
250
|
+
this.#debug = config.debug ?? currentNodeEnv() === 'development'
|
|
240
251
|
}
|
|
241
252
|
|
|
242
253
|
/** Set IoC container for resolver instantiation. */
|
|
@@ -280,8 +291,19 @@ export class GraphQLEngine {
|
|
|
280
291
|
return
|
|
281
292
|
}
|
|
282
293
|
|
|
283
|
-
|
|
284
|
-
|
|
294
|
+
// Only a JSON body is a GraphQL request. A form post, a text/plain body or
|
|
295
|
+
// one with no Content-Type is a CORS simple request: a foreign page sends
|
|
296
|
+
// it with the user's cookies and no preflight, so a mutation would run
|
|
297
|
+
// cross-site. Refused before the body is read.
|
|
298
|
+
const mediaType = (ctx.request.header('content-type')?.split(';')[0] ?? '').trim().toLowerCase()
|
|
299
|
+
if (mediaType !== 'application/json') {
|
|
300
|
+
ctx.response.status(415).json({ errors: [{ message: 'Unsupported Media Type' }] })
|
|
301
|
+
return
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Read from the raw body rather than `request.body()`, so the endpoint
|
|
305
|
+
// works whether or not the body parser runs in front of it.
|
|
306
|
+
const body = parseGraphQLBody(parseJson(ctx.request.raw()))
|
|
285
307
|
if (!body) {
|
|
286
308
|
ctx.response.status(400).json({ errors: [{ message: 'Missing or invalid query' }] })
|
|
287
309
|
return
|
|
@@ -291,11 +313,7 @@ export class GraphQLEngine {
|
|
|
291
313
|
const result = await this.#execute(body, ctx)
|
|
292
314
|
ctx.response.status(200).json(result)
|
|
293
315
|
} catch (err) {
|
|
294
|
-
|
|
295
|
-
currentNodeEnv() !== 'production' && err instanceof Error
|
|
296
|
-
? err.message
|
|
297
|
-
: 'Internal server error'
|
|
298
|
-
ctx.response.status(500).json({ errors: [{ message }] })
|
|
316
|
+
ctx.response.status(500).json({ errors: [{ message: this.#publicMessage(err, ctx) }] })
|
|
299
317
|
}
|
|
300
318
|
}
|
|
301
319
|
|
|
@@ -365,7 +383,7 @@ export class GraphQLEngine {
|
|
|
365
383
|
}
|
|
366
384
|
}
|
|
367
385
|
|
|
368
|
-
const guardError = this.#enforceGuards(entry.options, ctx)
|
|
386
|
+
const guardError = await this.#enforceGuards(entry.options, ctx)
|
|
369
387
|
if (guardError) {
|
|
370
388
|
return { ok: false, error: { message: guardError, path: [field.name] } }
|
|
371
389
|
}
|
|
@@ -396,11 +414,26 @@ export class GraphQLEngine {
|
|
|
396
414
|
// ask for. Empty selection (scalar field) returns the value as-is.
|
|
397
415
|
return { ok: true, value: pruneToSelection(result, field.selection) }
|
|
398
416
|
} catch (err) {
|
|
399
|
-
|
|
400
|
-
return { ok: false, error: { message: msg, path: [field.name] } }
|
|
417
|
+
return { ok: false, error: { message: this.#publicMessage(err, ctx), path: [field.name] } }
|
|
401
418
|
}
|
|
402
419
|
}
|
|
403
420
|
|
|
421
|
+
/**
|
|
422
|
+
* The message a client may see for a thrown error — the rule the HTTP
|
|
423
|
+
* exception handler applies. A driver or library error carries internal
|
|
424
|
+
* hosts, credentials and stored values in its text, so outside debug it is
|
|
425
|
+
* collapsed, and the original is logged for the operator instead.
|
|
426
|
+
*/
|
|
427
|
+
#publicMessage(err: unknown, ctx: HttpContext): string {
|
|
428
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
429
|
+
if (this.#debug || isPublicError(err)) return message
|
|
430
|
+
ctx.logger.error('GraphQL resolver failed', {
|
|
431
|
+
message,
|
|
432
|
+
stack: err instanceof Error ? err.stack : undefined,
|
|
433
|
+
})
|
|
434
|
+
return 'Internal server error'
|
|
435
|
+
}
|
|
436
|
+
|
|
404
437
|
/**
|
|
405
438
|
* Build the resolver argument bag: `$variable` references resolve against
|
|
406
439
|
* `request.variables`, then any unreferenced variables are merged in.
|
|
@@ -436,7 +469,7 @@ export class GraphQLEngine {
|
|
|
436
469
|
}
|
|
437
470
|
|
|
438
471
|
/** Enforce guard/role/permission on a resolver. */
|
|
439
|
-
#enforceGuards(options: ResolverOptions, ctx: HttpContext): string | null {
|
|
472
|
+
async #enforceGuards(options: ResolverOptions, ctx: HttpContext): Promise<string | null> {
|
|
440
473
|
const guards = [...(options.guards ?? []), ...(options.guard ? [options.guard] : [])]
|
|
441
474
|
const roles = [...(options.roles ?? []), ...(options.role ? [options.role] : [])]
|
|
442
475
|
const permissions = options.permissions ?? []
|
|
@@ -458,7 +491,7 @@ export class GraphQLEngine {
|
|
|
458
491
|
|
|
459
492
|
// A token-authenticated request also needs the token to allow each one.
|
|
460
493
|
if (permissions.length > 0) {
|
|
461
|
-
if (!holdsPermissions(ctx.auth, permissions)) {
|
|
494
|
+
if (!(await holdsPermissions(ctx.auth, permissions))) {
|
|
462
495
|
return 'Insufficient permissions'
|
|
463
496
|
}
|
|
464
497
|
}
|
|
@@ -478,7 +511,10 @@ export class GraphQLEngine {
|
|
|
478
511
|
// Full-Rust: the spec-compliant `ream-graphql` parser (graphql-parser
|
|
479
512
|
// crate) rejects invalid queries before any resolver runs. No TS fallback —
|
|
480
513
|
// ream ships the native binary, so when Rust exists the engine is Rust.
|
|
481
|
-
const raw = JSON.parse(graphqlNative().graphqlParse(query))
|
|
514
|
+
const raw: unknown = JSON.parse(graphqlNative().graphqlParse(query))
|
|
515
|
+
if (!isRustParseResult(raw)) {
|
|
516
|
+
return { typeName: 'Query', fields: [], errors: [{ message: 'Unreadable parse result' }] }
|
|
517
|
+
}
|
|
482
518
|
const typeName = OPERATION_TYPE[raw.operation_type] ?? 'Query'
|
|
483
519
|
if (raw.errors.length > 0) {
|
|
484
520
|
return { typeName, fields: [], errors: raw.errors.map((message) => ({ message })) }
|
|
@@ -520,6 +556,22 @@ function isVariablesRecord(value: unknown): value is Record<string, unknown> {
|
|
|
520
556
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
521
557
|
}
|
|
522
558
|
|
|
559
|
+
/** A deliberate `Exception`, a client error, or an error flagged `expose: true`. */
|
|
560
|
+
function isPublicError(err: unknown): boolean {
|
|
561
|
+
if (err instanceof Exception) return true
|
|
562
|
+
if (err === null || typeof err !== 'object') return false
|
|
563
|
+
if ('expose' in err && err.expose === true) return true
|
|
564
|
+
return 'status' in err && typeof err.status === 'number' && err.status < 500
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function parseJson(raw: string): unknown {
|
|
568
|
+
try {
|
|
569
|
+
return JSON.parse(raw)
|
|
570
|
+
} catch {
|
|
571
|
+
return undefined
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
523
575
|
function parseGraphQLBody(body: unknown): GraphQLRequest | null {
|
|
524
576
|
if (!body || typeof body !== 'object' || Array.isArray(body)) return null
|
|
525
577
|
// After typeof check, body is a non-null non-array object; index via 'in' narrowing
|
|
@@ -537,6 +589,30 @@ function parseGraphQLBody(body: unknown): GraphQLRequest | null {
|
|
|
537
589
|
return { query: q, variables, operationName }
|
|
538
590
|
}
|
|
539
591
|
|
|
592
|
+
/** The shape the native parser answers with, checked rather than assumed. */
|
|
593
|
+
function isRustParseResult(value: unknown): value is RustParseResult {
|
|
594
|
+
if (!isRecord(value)) return false
|
|
595
|
+
return (
|
|
596
|
+
typeof value.operation_type === 'string' &&
|
|
597
|
+
(value.operation_name === null || typeof value.operation_name === 'string') &&
|
|
598
|
+
Array.isArray(value.errors) &&
|
|
599
|
+
value.errors.every((error) => typeof error === 'string') &&
|
|
600
|
+
Array.isArray(value.fields) &&
|
|
601
|
+
value.fields.every(isRustParsedField)
|
|
602
|
+
)
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function isRustParsedField(value: unknown): value is RustParsedField {
|
|
606
|
+
if (!isRecord(value)) return false
|
|
607
|
+
return (
|
|
608
|
+
typeof value.name === 'string' &&
|
|
609
|
+
(value.alias === null || typeof value.alias === 'string') &&
|
|
610
|
+
isRecord(value.args) &&
|
|
611
|
+
Array.isArray(value.sub_fields) &&
|
|
612
|
+
value.sub_fields.every(isRustParsedField)
|
|
613
|
+
)
|
|
614
|
+
}
|
|
615
|
+
|
|
540
616
|
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
541
617
|
return typeof v === 'object' && v !== null && !Array.isArray(v)
|
|
542
618
|
}
|
|
@@ -7,8 +7,10 @@
|
|
|
7
7
|
* signed email-verification token. Those two claims travel inside the payload,
|
|
8
8
|
* which is why they are added before signing rather than checked after.
|
|
9
9
|
*
|
|
10
|
-
* Ported from `@poppinss/utils
|
|
11
|
-
*
|
|
10
|
+
* Ported from `@poppinss/utils`, as a helper for application code. Ream's own
|
|
11
|
+
* signers do not use it: `CookieSigner` seals its values in a compact envelope
|
|
12
|
+
* of its own (`{ m, e, p }`, an epoch-millisecond expiry), and `SignedUrl`
|
|
13
|
+
* carries the expiry and the purpose as query parameters.
|
|
12
14
|
*/
|
|
13
15
|
|
|
14
16
|
import { milliseconds } from './duration.js'
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A caught value as the `NodeJS.ErrnoException` an error callback takes.
|
|
3
|
+
*
|
|
4
|
+
* `catch` binds `unknown`: an `Error` already is one (every field it adds is
|
|
5
|
+
* optional), and anything else thrown is wrapped, so a callback never reads
|
|
6
|
+
* `.code` or `.message` off a string.
|
|
7
|
+
*/
|
|
8
|
+
export function toErrnoException(error: unknown): NodeJS.ErrnoException {
|
|
9
|
+
return error instanceof Error ? error : new Error(String(error))
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** The `code` of a caught system error (`ENOENT`, `MODULE_NOT_FOUND`…), if any. */
|
|
13
|
+
export function errorCode(error: unknown): string | undefined {
|
|
14
|
+
if (typeof error !== 'object' || error === null || !('code' in error)) return undefined
|
|
15
|
+
return typeof error.code === 'string' ? error.code : undefined
|
|
16
|
+
}
|
|
@@ -12,6 +12,7 @@ import { dirname, join } from 'node:path'
|
|
|
12
12
|
import { arch, platform } from 'node:process'
|
|
13
13
|
import { fileURLToPath } from 'node:url'
|
|
14
14
|
import { ReamError } from '../errors/ReamError.js'
|
|
15
|
+
import { errorCode } from './errors.js'
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* Platform triple suffix (matches napi-rs naming).
|
|
@@ -88,14 +89,14 @@ export function loadNapi<T>(options: LoadNapiOptions): T {
|
|
|
88
89
|
try {
|
|
89
90
|
return req(path) as T
|
|
90
91
|
} catch (e) {
|
|
91
|
-
const code = (e
|
|
92
|
+
const code = errorCode(e)
|
|
92
93
|
if (code === 'MODULE_NOT_FOUND') continue
|
|
93
94
|
// Any other error (SyntaxError, ABI mismatch, permissions,
|
|
94
95
|
// corrupt binary) means the file exists but cannot load. Surface
|
|
95
96
|
// the real cause rather than a generic "not found".
|
|
96
97
|
throw new ReamError(
|
|
97
98
|
`${errorCodePrefix}_NAPI_LOAD_FAILED`,
|
|
98
|
-
`${binaryName} NAPI binary exists at '${path}' but failed to load: ${
|
|
99
|
+
`${binaryName} NAPI binary exists at '${path}' but failed to load: ${e instanceof Error ? e.message : String(e)}`,
|
|
99
100
|
{
|
|
100
101
|
hint: 'Check that the binary was built for the current platform and Node ABI. Rebuild with `pnpm --filter @c9up/ream build:rust`.',
|
|
101
102
|
},
|
package/src/http/HttpContext.ts
CHANGED
|
@@ -55,6 +55,16 @@ export interface AuthState {
|
|
|
55
55
|
check?(): Promise<boolean>
|
|
56
56
|
authenticateUsing?(guards?: string[], options?: { loginRoute?: string }): Promise<void>
|
|
57
57
|
getUserOrFail?(): AuthState['user']
|
|
58
|
+
/**
|
|
59
|
+
* Whether the authenticated user holds every permission in `permissions`,
|
|
60
|
+
* as the auth layer resolves them (warden's authenticator asks its rights
|
|
61
|
+
* resolver and narrows by the current access token). When present, the
|
|
62
|
+
* `.permission()` gates trust this answer instead of the payload's
|
|
63
|
+
* `permissions`, which can be stale or incomplete. `scope` is the auth
|
|
64
|
+
* layer's own notion (warden: `'global'` or a tenant); the gates pass none,
|
|
65
|
+
* so the layer's default applies.
|
|
66
|
+
*/
|
|
67
|
+
hasAllPermissions?(permissions: readonly string[], scope?: unknown): Promise<boolean>
|
|
58
68
|
/**
|
|
59
69
|
* The guard behind `name` (AdonisJS `auth.use('web')`).
|
|
60
70
|
*
|
|
@@ -405,10 +415,16 @@ export class HttpContext extends Macroable {
|
|
|
405
415
|
this.request.setSignedUrl(signedUrl)
|
|
406
416
|
}
|
|
407
417
|
|
|
408
|
-
/**
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
418
|
+
/**
|
|
419
|
+
* The values the matched route's domain captured from the host:
|
|
420
|
+
* `{ tenant: 'acme' }` for a route on `:tenant.app.test` reached through
|
|
421
|
+
* `acme.app.test`. Empty when the route has no parameterised domain. Set by
|
|
422
|
+
* the HTTP kernel from the route match, as upstream's router does.
|
|
423
|
+
*
|
|
424
|
+
* BREAKING — this used to be the host's labels (`['api']`); that is
|
|
425
|
+
* `ctx.request.subdomains()`, and stays there.
|
|
426
|
+
*/
|
|
427
|
+
subdomains: Record<string, string> = {}
|
|
412
428
|
|
|
413
429
|
/** Stable identifier for the matched route (`METHOD-pattern`) — AdonisJS `ctx.routeKey`. */
|
|
414
430
|
get routeKey(): string {
|
|
@@ -32,6 +32,9 @@ function isTrustedRedirect(
|
|
|
32
32
|
} catch {
|
|
33
33
|
return false
|
|
34
34
|
}
|
|
35
|
+
// Only a web URL is a redirect target: `javascript://app.test/%0aalert(1)`
|
|
36
|
+
// has the right host and is a script, not a page.
|
|
37
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false
|
|
35
38
|
if (allowedHosts.includes(parsed.host)) return true
|
|
36
39
|
// Upstream accepts any absolute URL when it knows no host to compare with;
|
|
37
40
|
// refusing it is the safer reading of an attacker-controlled header.
|
package/src/http/Request.ts
CHANGED
|
@@ -478,7 +478,9 @@ export class Request extends Macroable {
|
|
|
478
478
|
encryptedCookie(name: string, defaultValue?: string): string | null
|
|
479
479
|
encryptedCookie(name: string, defaultValue?: string): string | null {
|
|
480
480
|
const raw = this.plainCookie<string>(name, undefined, { encoded: false })
|
|
481
|
-
|
|
481
|
+
// Decrypted for the purpose it was written under — its own name — so a
|
|
482
|
+
// value issued as another encrypted cookie is refused here.
|
|
483
|
+
const value = raw === null || !this.#cookieSigner ? null : this.#cookieSigner.decrypt(raw, name)
|
|
482
484
|
return value ?? defaultValue ?? null
|
|
483
485
|
}
|
|
484
486
|
|
|
@@ -757,7 +759,9 @@ export class Request extends Macroable {
|
|
|
757
759
|
cookies: this.cookies(),
|
|
758
760
|
hostname: this.hostname(),
|
|
759
761
|
ip: this.ip(),
|
|
760
|
-
|
|
762
|
+
// What the route's domain captured (`ctx.subdomains`), as upstream
|
|
763
|
+
// serialises it — not the host's labels, which `subdomains()` returns.
|
|
764
|
+
subdomains: (this.ctx === undefined ? undefined : Reflect.get(this.ctx, 'subdomains')) ?? {},
|
|
761
765
|
}
|
|
762
766
|
}
|
|
763
767
|
|
|
@@ -1119,39 +1123,21 @@ export class Request extends Macroable {
|
|
|
1119
1123
|
}
|
|
1120
1124
|
}
|
|
1121
1125
|
|
|
1126
|
+
/**
|
|
1127
|
+
* The body is whatever the body parser decided it is, and `{}` when no
|
|
1128
|
+
* parser claimed it. Nothing is parsed here: a JSON body is only JSON when
|
|
1129
|
+
* its Content-Type says so. Reading any body as JSON let a foreign page
|
|
1130
|
+
* drive a JSON route with a CORS simple request (no Content-Type, or
|
|
1131
|
+
* `text/plain`), which a browser sends cross-site with the user's cookies
|
|
1132
|
+
* and without a preflight.
|
|
1133
|
+
*/
|
|
1122
1134
|
#ensureParsedBody(): void {
|
|
1123
1135
|
if (this.#parsedBody !== undefined) return
|
|
1124
1136
|
this.#assertBodyKept()
|
|
1125
|
-
|
|
1126
|
-
if (!raw || raw.length === 0) {
|
|
1127
|
-
this.#parsedBody = {}
|
|
1128
|
-
return
|
|
1129
|
-
}
|
|
1130
|
-
try {
|
|
1131
|
-
const parsed: unknown = JSON.parse(raw)
|
|
1132
|
-
this.#parsedBody = isPlainObject(parsed) ? parsed : { _body: parsed }
|
|
1133
|
-
} catch {
|
|
1134
|
-
this.#parsedBody = {}
|
|
1135
|
-
}
|
|
1137
|
+
this.#parsedBody = {}
|
|
1136
1138
|
}
|
|
1137
1139
|
}
|
|
1138
1140
|
|
|
1139
|
-
/** Parse a query string (key=value&key2=value2) into an object. */
|
|
1140
|
-
/**
|
|
1141
|
-
* Test whether an IPv4 address is within a CIDR range. Bare IP entries
|
|
1142
|
-
* (`10.0.0.42`) are accepted as `/32`. IPv6 falls back to string equality
|
|
1143
|
-
* — a full v6 implementation isn't required by the current security tests.
|
|
1144
|
-
*/
|
|
1145
|
-
/**
|
|
1146
|
-
* Type guard for plain JSON-decoded objects. Excludes `null` (which is
|
|
1147
|
-
* `typeof === 'object'`) and arrays so consumers can index by string key
|
|
1148
|
-
* without a separate runtime check.
|
|
1149
|
-
*/
|
|
1150
|
-
function isPlainObject(value: unknown): value is Dict<unknown> {
|
|
1151
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
1152
|
-
}
|
|
1153
|
-
|
|
1154
|
-
/** True for an IPv4 dotted-quad or a (bracketed) IPv6 literal — hosts with no subdomains. */
|
|
1155
1141
|
/**
|
|
1156
1142
|
* Headers whose VALUE is a credential. Listing them is useful; printing them
|
|
1157
1143
|
* hands whoever reads the log a working key.
|
package/src/http/Response.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { readFile, stat } from 'node:fs/promises'
|
|
|
13
13
|
import { basename } from 'node:path'
|
|
14
14
|
import { ReamError } from '../errors/ReamError.js'
|
|
15
15
|
import { durationToSeconds } from '../helpers/duration.js'
|
|
16
|
+
import { toErrnoException } from '../helpers/errors.js'
|
|
16
17
|
import { etag, matchesIfNoneMatch } from './etag.js'
|
|
17
18
|
import { contentType } from './mime.js'
|
|
18
19
|
|
|
@@ -848,7 +849,7 @@ export class Response extends Macroable {
|
|
|
848
849
|
size = (await stat(filePath)).size
|
|
849
850
|
} catch (error) {
|
|
850
851
|
if (errorCallback) {
|
|
851
|
-
const [message, status] = errorCallback(error
|
|
852
|
+
const [message, status] = errorCallback(toErrnoException(error))
|
|
852
853
|
this.status(status ?? 404).send(message)
|
|
853
854
|
return
|
|
854
855
|
}
|
|
@@ -1002,7 +1003,7 @@ export class Response extends Macroable {
|
|
|
1002
1003
|
// The headers are already out, so there is no status left to change: end
|
|
1003
1004
|
// the body and let the client see a short read. `errorCallback` is still
|
|
1004
1005
|
// consulted for whatever logging it does.
|
|
1005
|
-
errorCallback?.(err
|
|
1006
|
+
errorCallback?.(toErrnoException(err))
|
|
1006
1007
|
} finally {
|
|
1007
1008
|
// Never rethrows: this runs detached, and an unhandled rejection here
|
|
1008
1009
|
// would take the process down over one client's download.
|
|
@@ -1022,7 +1023,7 @@ export class Response extends Macroable {
|
|
|
1022
1023
|
}
|
|
1023
1024
|
} catch (err) {
|
|
1024
1025
|
const [message, status] = errorCallback
|
|
1025
|
-
? errorCallback(err
|
|
1026
|
+
? errorCallback(toErrnoException(err))
|
|
1026
1027
|
: ['Cannot process the request', 500]
|
|
1027
1028
|
this.status(status ?? 500)
|
|
1028
1029
|
this.send(message)
|
|
@@ -1133,7 +1134,9 @@ export class Response extends Macroable {
|
|
|
1133
1134
|
'encryptedCookie() requires APP_KEY — set it so the encryption service is registered.',
|
|
1134
1135
|
)
|
|
1135
1136
|
}
|
|
1136
|
-
|
|
1137
|
+
// The cookie name is the purpose, as for a signed cookie: a value issued
|
|
1138
|
+
// as one encrypted cookie cannot be replayed under another one's name.
|
|
1139
|
+
return this.#writeCookie(name, this.#cookieSigner.encrypt(value, undefined, name), options)
|
|
1137
1140
|
}
|
|
1138
1141
|
|
|
1139
1142
|
#writeCookie(name: string, value: string, options?: CookieOptions): this {
|
|
@@ -1195,10 +1198,15 @@ export class Response extends Macroable {
|
|
|
1195
1198
|
|
|
1196
1199
|
/**
|
|
1197
1200
|
* Expire a cookie immediately (AdonisJS `clearCookie`) — re-sends it with
|
|
1198
|
-
* `Max-Age=0
|
|
1201
|
+
* `Max-Age=0` and an `Expires` in 1970, the RFC 6265 delete-now signals.
|
|
1202
|
+
*
|
|
1203
|
+
* A browser matches the cookie to replace on its name, domain and path, so
|
|
1204
|
+
* a cookie written with a `domain` (or a `path`) is only cleared when the
|
|
1205
|
+
* same ones are passed here; `secure` and `sameSite` are accepted so a
|
|
1206
|
+
* `SameSite=None` cookie can be cleared at all.
|
|
1199
1207
|
*/
|
|
1200
|
-
clearCookie(name: string, options?:
|
|
1201
|
-
return this.plainCookie(name, '', { ...options, maxAge: 0 })
|
|
1208
|
+
clearCookie(name: string, options?: Omit<CookieOptions, 'maxAge' | 'expires'>): this {
|
|
1209
|
+
return this.plainCookie(name, '', { ...options, maxAge: 0, expires: new Date(0) })
|
|
1202
1210
|
}
|
|
1203
1211
|
|
|
1204
1212
|
// ─── Internals (used by HttpKernel for NAPI serialization) ─
|
package/src/http/permissions.ts
CHANGED
|
@@ -9,23 +9,39 @@ import type { AuthState } from './HttpContext.js'
|
|
|
9
9
|
* Whether the request's identity holds every permission in `required` — what
|
|
10
10
|
* the route, RPC and GraphQL `.permission()` gates ask.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
12
|
+
* When the auth state exposes `hasAllPermissions` (the auth layer resolves
|
|
13
|
+
* permissions itself, as warden does through its rights resolver), its
|
|
14
|
+
* answer decides: a permission granted only by the resolver passes, one left
|
|
15
|
+
* only in the payload does not. Anything but `true` denies, and an error from
|
|
16
|
+
* the resolver propagates rather than grants. Only an auth state without that
|
|
17
|
+
* method falls back to the payload: top-level `auth.permissions`, or
|
|
18
|
+
* `auth.user.permissions`.
|
|
19
|
+
*
|
|
20
|
+
* When the request was authenticated with a token, each permission must ALSO
|
|
21
|
+
* be allowed by `auth.user.currentAccessToken`: an intersection, never a
|
|
22
|
+
* union. A token scope named like a permission grants nothing the user
|
|
23
|
+
* lacks, and a token limited to `profile` does not carry every permission of
|
|
24
|
+
* its user. A token whose `allows` cannot be called denies.
|
|
19
25
|
*/
|
|
20
|
-
export function holdsPermissions(
|
|
26
|
+
export async function holdsPermissions(
|
|
21
27
|
auth: AuthState | undefined,
|
|
22
28
|
required: readonly string[],
|
|
23
|
-
): boolean {
|
|
24
|
-
|
|
29
|
+
): Promise<boolean> {
|
|
30
|
+
if (!(await userHolds(auth, required))) return false
|
|
25
31
|
const token = auth?.user?.currentAccessToken
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
32
|
+
if (token === undefined || token === null) return true
|
|
33
|
+
return required.every(
|
|
34
|
+
(permission) => typeof token.allows === 'function' && token.allows(permission) === true,
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function userHolds(
|
|
39
|
+
auth: AuthState | undefined,
|
|
40
|
+
required: readonly string[],
|
|
41
|
+
): Promise<boolean> {
|
|
42
|
+
if (typeof auth?.hasAllPermissions === 'function') {
|
|
43
|
+
return (await auth.hasAllPermissions(required)) === true
|
|
44
|
+
}
|
|
45
|
+
const granted = auth?.permissions ?? auth?.user?.permissions ?? []
|
|
46
|
+
return required.every((permission) => granted.includes(permission))
|
|
31
47
|
}
|