@owlmeans/server-api 0.1.18-rc.4 → 0.1.18-rc.40
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 +202 -46
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/server-api/SKILL.md +95 -22
- package/build/errors.d.ts +12 -0
- package/build/errors.d.ts.map +1 -1
- package/build/errors.js +16 -0
- package/build/errors.js.map +1 -1
- package/build/helper.d.ts.map +1 -1
- package/build/helper.js +10 -1
- package/build/helper.js.map +1 -1
- package/build/hold.d.ts +52 -0
- package/build/hold.d.ts.map +1 -0
- package/build/hold.js +56 -0
- package/build/hold.js.map +1 -0
- package/build/index.d.ts +2 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +2 -0
- package/build/index.js.map +1 -1
- package/build/protocol.d.ts +45 -0
- package/build/protocol.d.ts.map +1 -0
- package/build/protocol.js +86 -0
- package/build/protocol.js.map +1 -0
- package/build/server.d.ts.map +1 -1
- package/build/server.js +9 -13
- package/build/server.js.map +1 -1
- package/build/types.d.ts +10 -2
- package/build/types.d.ts.map +1 -1
- package/build/utils/error.d.ts +64 -1
- package/build/utils/error.d.ts.map +1 -1
- package/build/utils/error.js +160 -8
- package/build/utils/error.js.map +1 -1
- package/build/utils/guards.d.ts.map +1 -1
- package/build/utils/guards.js +11 -15
- package/build/utils/guards.js.map +1 -1
- package/build/utils/payload.d.ts.map +1 -1
- package/build/utils/payload.js +6 -2
- package/build/utils/payload.js.map +1 -1
- package/build/utils/server.d.ts +3 -2
- package/build/utils/server.d.ts.map +1 -1
- package/build/utils/server.js +5 -6
- package/build/utils/server.js.map +1 -1
- package/package.json +13 -11
- package/src/errors.ts +18 -0
- package/src/helper.ts +10 -1
- package/src/hold.ts +84 -0
- package/src/index.ts +2 -0
- package/src/protocol.ts +185 -0
- package/src/server.ts +36 -45
- package/src/types.ts +15 -4
- package/src/utils/error.ts +198 -10
- package/src/utils/guards.ts +10 -17
- package/src/utils/payload.ts +6 -2
- package/src/utils/server.ts +8 -8
- package/tests/error-status.spec.ts +290 -0
- package/tests/handlers.spec.ts +83 -0
- package/tests/tsconfig.json +12 -0
- package/tsconfig.json +1 -0
- package/build/.gitkeep +0 -0
package/src/server.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ServerConfig, ServerContext } from '@owlmeans/server-context'
|
|
2
2
|
import type { ApiServer, ApiServerAppend } from './types.js'
|
|
3
|
-
import {
|
|
3
|
+
import { assertContext, createService } from '@owlmeans/context'
|
|
4
4
|
import { canServeModule, executeResponse, provideRequest } from './utils/index.js'
|
|
5
5
|
import { DEFAULT_ALIAS, CLOSED_HOST, PORT, OPENED_HOST } from './consts.js'
|
|
6
6
|
import type { ServerEntrypoint } from '@owlmeans/server-entrypoint'
|
|
@@ -8,6 +8,7 @@ import { RouteMethod } from '@owlmeans/route'
|
|
|
8
8
|
import { createServerHandler, fixFormatDates } from './utils/index.js'
|
|
9
9
|
import { provideResponse } from '@owlmeans/entrypoint'
|
|
10
10
|
import { TOKEN_UPDATE } from '@owlmeans/auth-common'
|
|
11
|
+
import { INCIDENT_ID_HEADER } from './utils/error.js'
|
|
11
12
|
|
|
12
13
|
import Fastify from 'fastify'
|
|
13
14
|
import type { FastifyRequest } from 'fastify'
|
|
@@ -57,8 +58,6 @@ export const createApiServer = (alias: string): ApiServer => {
|
|
|
57
58
|
}*/
|
|
58
59
|
}),
|
|
59
60
|
|
|
60
|
-
layers: [Layer.System],
|
|
61
|
-
|
|
62
61
|
listen: async () => {
|
|
63
62
|
const context = _assertContext(service.ctx as Context)
|
|
64
63
|
|
|
@@ -101,7 +100,7 @@ export const createApiServer = (alias: string): ApiServer => {
|
|
|
101
100
|
...Object.values(RouteMethod).map(method => method.toUpperCase()),
|
|
102
101
|
'HEAD', 'OPTIONS'
|
|
103
102
|
],
|
|
104
|
-
exposedHeaders: [TOKEN_UPDATE]
|
|
103
|
+
exposedHeaders: [TOKEN_UPDATE, INCIDENT_ID_HEADER]
|
|
105
104
|
})
|
|
106
105
|
|
|
107
106
|
await server.register(Multipart, {
|
|
@@ -133,10 +132,8 @@ export const createApiServer = (alias: string): ApiServer => {
|
|
|
133
132
|
return context
|
|
134
133
|
}
|
|
135
134
|
|
|
136
|
-
//
|
|
137
|
-
|
|
138
|
-
// Actually intermediate module can be created without handler by elevate function
|
|
139
|
-
if (module.route.match(request) && module.handle != null) {
|
|
135
|
+
// An intermediate route can be created without a handler by the binding layer.
|
|
136
|
+
if (module.route.match(request, module.mount()) && module.handle != null) {
|
|
140
137
|
const response = provideResponse(reply)
|
|
141
138
|
const currentRequest = provideRequest(module.alias, request, true)
|
|
142
139
|
currentRequest.original._ctx = context
|
|
@@ -150,42 +147,38 @@ export const createApiServer = (alias: string): ApiServer => {
|
|
|
150
147
|
}, Promise.resolve(context))
|
|
151
148
|
})
|
|
152
149
|
|
|
153
|
-
|
|
154
|
-
context.
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
headers: fixFormatDates(module.filter?.headers ?? {})
|
|
184
|
-
},
|
|
185
|
-
handler: createServerHandler(module, location)
|
|
186
|
-
})
|
|
150
|
+
context.entrypoints<ServerEntrypoint<FastifyRequest>>()
|
|
151
|
+
.filter(module => canServeModule(context, module) && !module.route.isIntermediate())
|
|
152
|
+
.forEach(module => {
|
|
153
|
+
const method = module.route.route.method ?? RouteMethod.GET
|
|
154
|
+
if (module.handle == null) {
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
server.route({
|
|
158
|
+
url: module.mount(), method,
|
|
159
|
+
schema: {
|
|
160
|
+
consumes: [
|
|
161
|
+
'application/json',
|
|
162
|
+
'application/x-www-form-urlencoded',
|
|
163
|
+
'multipart/form-data',
|
|
164
|
+
],
|
|
165
|
+
querystring: fixFormatDates(module.filter?.query ?? {}),
|
|
166
|
+
...(
|
|
167
|
+
[
|
|
168
|
+
RouteMethod.POST,
|
|
169
|
+
RouteMethod.PATCH,
|
|
170
|
+
RouteMethod.PUT
|
|
171
|
+
].includes(method)
|
|
172
|
+
? { body: fixFormatDates(module.filter?.body ?? {}) }
|
|
173
|
+
: {}
|
|
174
|
+
),
|
|
175
|
+
params: fixFormatDates(module.filter?.params ?? {}),
|
|
176
|
+
response: module.filter?.response,
|
|
177
|
+
headers: fixFormatDates(module.filter?.headers ?? {})
|
|
178
|
+
},
|
|
179
|
+
handler: createServerHandler(module, location)
|
|
187
180
|
})
|
|
188
|
-
|
|
181
|
+
})
|
|
189
182
|
|
|
190
183
|
service.initialized = true
|
|
191
184
|
})
|
|
@@ -207,5 +200,3 @@ export const appendApiServer = <C extends Config, T extends ServerContext<C>>(
|
|
|
207
200
|
|
|
208
201
|
return context
|
|
209
202
|
}
|
|
210
|
-
|
|
211
|
-
|
package/src/types.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
import type { InitializedService
|
|
2
|
+
import type { InitializedService } from '@owlmeans/context'
|
|
3
3
|
import type { ServerConfig, ServerContext } from '@owlmeans/server-context'
|
|
4
4
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
|
|
5
5
|
import '@fastify/middie/types/index.d.ts'
|
|
@@ -7,8 +7,6 @@ import '@fastify/middie/types/index.d.ts'
|
|
|
7
7
|
export interface ApiServer extends InitializedService {
|
|
8
8
|
server: FastifyInstance
|
|
9
9
|
|
|
10
|
-
layers: [Layer.System]
|
|
11
|
-
|
|
12
10
|
listen: () => Promise<void>
|
|
13
11
|
}
|
|
14
12
|
|
|
@@ -20,7 +18,20 @@ export interface ApiServerAppend {
|
|
|
20
18
|
getApiServer: () => ApiServer
|
|
21
19
|
}
|
|
22
20
|
|
|
23
|
-
export
|
|
21
|
+
export type HttpErrorExposure = 'production' | 'development'
|
|
22
|
+
|
|
23
|
+
export interface HttpErrorsConfig {
|
|
24
|
+
/** Production is the safe default; development is the only mode that exposes stacks. */
|
|
25
|
+
exposure?: HttpErrorExposure
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface HttpConfig {
|
|
29
|
+
errors?: HttpErrorsConfig
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface Config extends ServerConfig {
|
|
33
|
+
http?: HttpConfig
|
|
34
|
+
}
|
|
24
35
|
|
|
25
36
|
export interface Context<C extends Config = Config> extends ServerContext<C>,
|
|
26
37
|
ApiServerAppend { }
|
package/src/utils/error.ts
CHANGED
|
@@ -1,18 +1,206 @@
|
|
|
1
1
|
import { FORBIDDEN_ERROR, SERVER_ERROR, UNAUTHORIZED_ERROR } from '@owlmeans/api'
|
|
2
2
|
import type { FastifyReply } from 'fastify'
|
|
3
3
|
import { AccessError, AuthFailedError } from '../errors.js'
|
|
4
|
-
import {
|
|
4
|
+
import { AuthForbidden, AuthorizationError } from '@owlmeans/auth'
|
|
5
|
+
import { isResilientError, ResilientError, SEPARATOR } from '@owlmeans/error'
|
|
6
|
+
import { randomUUID } from 'node:crypto'
|
|
7
|
+
import type { Config, HttpErrorExposure } from '../types.js'
|
|
5
8
|
|
|
6
|
-
export const
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
export const INCIDENT_ID_HEADER = 'X-Incident-ID'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* What an error class may declare about the HTTP status it is answered with.
|
|
13
|
+
*
|
|
14
|
+
* Structural on purpose: the classes that declare it live in packages that must not depend on an
|
|
15
|
+
* HTTP server (`@owlmeans/payment`, a product's contract package), so they write a plain
|
|
16
|
+
* `public static httpStatus = 409` and nothing imports this type.
|
|
17
|
+
*/
|
|
18
|
+
export interface HttpStatusDeclaration {
|
|
19
|
+
httpStatus?: unknown
|
|
20
|
+
/** Explicit opt-in for a declared 5xx response. Client-error declarations need no opt-in. */
|
|
21
|
+
allowServerErrorStatus?: unknown
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const CLIENT_ERROR_FIRST = 400
|
|
25
|
+
const CLIENT_ERROR_LAST = 499
|
|
26
|
+
const SERVER_ERROR_FIRST = 500
|
|
27
|
+
const SERVER_ERROR_LAST = 599
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The HTTP status an error's class declares, or `null` when it declares none that may be honoured.
|
|
31
|
+
*
|
|
32
|
+
* Read through the constructor chain — a static property is inherited, so a subclass answers what
|
|
33
|
+
* its nearest declaring ancestor says, and redeclares to change it. An integer 4xx is honoured
|
|
34
|
+
* directly. A 5xx is honoured only with `allowServerErrorStatus = true`, so a domain class must
|
|
35
|
+
* explicitly opt in to exposing a precise availability response. The read is structural, so a
|
|
36
|
+
* class from a duplicate module copy declares exactly what the imported one does.
|
|
37
|
+
*/
|
|
38
|
+
export const declaredErrorStatus = (error: unknown): number | null => {
|
|
39
|
+
if (error == null || typeof error !== 'object') {
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
const declaration = error.constructor as HttpStatusDeclaration | undefined
|
|
43
|
+
const status = declaration?.httpStatus
|
|
44
|
+
if (typeof status !== 'number' || !Number.isInteger(status)) {
|
|
45
|
+
return null
|
|
46
|
+
}
|
|
47
|
+
if (status >= CLIENT_ERROR_FIRST && status <= CLIENT_ERROR_LAST) return status
|
|
48
|
+
if (status >= SERVER_ERROR_FIRST && status <= SERVER_ERROR_LAST
|
|
49
|
+
&& declaration?.allowServerErrorStatus === true) return status
|
|
50
|
+
|
|
51
|
+
return null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The registered type names an error carries: its own `type`, and the static `typeName` of every
|
|
56
|
+
* class on its constructor chain.
|
|
57
|
+
*
|
|
58
|
+
* This is how a class is recognised when `instanceof` cannot see it. A process that loads a package
|
|
59
|
+
* twice — `bun --preserve-symlinks` over a linked workspace resolves one package through several
|
|
60
|
+
* `node_modules` paths — holds several copies of every error class, and an error built by one copy
|
|
61
|
+
* is not an instance of another. The registered names are the same in every copy. A subclass's
|
|
62
|
+
* `typeName` does not reliably embed its parent's (`EntitlementRefusal` extends `AuthForbidden`), so
|
|
63
|
+
* a family is recognised by walking the chain and matching names exactly, never by a substring.
|
|
64
|
+
*/
|
|
65
|
+
const typeNamesOf = (error: object): Set<string> => {
|
|
66
|
+
const names = new Set<string>()
|
|
67
|
+
const type = (error as { type?: unknown }).type
|
|
68
|
+
if (typeof type === 'string') {
|
|
69
|
+
names.add(type)
|
|
70
|
+
}
|
|
71
|
+
let ctor: unknown = error.constructor
|
|
72
|
+
while (typeof ctor === 'function') {
|
|
73
|
+
const name = (ctor as { typeName?: unknown }).typeName
|
|
74
|
+
if (typeof name === 'string') {
|
|
75
|
+
names.add(name)
|
|
76
|
+
}
|
|
77
|
+
ctor = Object.getPrototypeOf(ctor)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return names
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const isOfFamily = (names: Set<string>, family: string[]): boolean =>
|
|
84
|
+
family.some(typeName => names.has(typeName))
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The HTTP status an error is answered with, read off the error exactly as given.
|
|
88
|
+
*
|
|
89
|
+
* Two families reach here and both must be recognised. `AuthFailedError` / `AccessError` are this
|
|
90
|
+
* package's own, raised by the request pipeline itself. `AuthorizationError` / `AuthForbidden`
|
|
91
|
+
* come from `@owlmeans/auth` and are what the guards and gates actually throw — an unrecognised
|
|
92
|
+
* one answers 500, which reports a refused request as a crashed server: the client cannot tell
|
|
93
|
+
* "sign in again" from "the service is broken", and a consumer watching statuses concludes the
|
|
94
|
+
* application fell over. A family member is recognised by class OR by registered type name
|
|
95
|
+
* ({@link typeNamesOf}), so a class from a duplicate module copy answers the same status.
|
|
96
|
+
*
|
|
97
|
+
* **Order matters.** `AuthForbidden extends AuthorizationError`, so the 403 branch has to be
|
|
98
|
+
* tested first or every refusal of a permission is reported as a failure to authenticate. Both
|
|
99
|
+
* auth branches come before a class's own declaration, so an auth refusal can never be restated
|
|
100
|
+
* as something else. Everything that is neither an auth error nor a class declaring an allowed
|
|
101
|
+
* {@link declaredErrorStatus} answers 500.
|
|
102
|
+
*/
|
|
103
|
+
export const errorStatus = (error: unknown): number => {
|
|
104
|
+
if (error == null || typeof error !== 'object') {
|
|
105
|
+
return SERVER_ERROR
|
|
106
|
+
}
|
|
107
|
+
const names = typeNamesOf(error)
|
|
108
|
+
if (error instanceof AuthForbidden || error instanceof AccessError
|
|
109
|
+
|| isOfFamily(names, [AuthForbidden.typeName, AccessError.typeName])) {
|
|
110
|
+
// An established identity that lacks the permission. Re-authenticating changes nothing.
|
|
111
|
+
return FORBIDDEN_ERROR
|
|
112
|
+
}
|
|
113
|
+
if (error instanceof AuthorizationError || error instanceof AuthFailedError
|
|
114
|
+
|| isOfFamily(names, [AuthorizationError.typeName, AuthFailedError.typeName])) {
|
|
115
|
+
// No usable credential — absent, expired, revoked, or naming a session this server no longer
|
|
116
|
+
// holds. The caller is told to sign in, not that signing in would not help.
|
|
117
|
+
return UNAUTHORIZED_ERROR
|
|
12
118
|
}
|
|
119
|
+
|
|
120
|
+
return declaredErrorStatus(error) ?? SERVER_ERROR
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface SerializedHttpError {
|
|
124
|
+
status: number
|
|
125
|
+
body: string
|
|
126
|
+
incidentId: string
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Invalid or absent configuration always resolves to the production-safe policy. */
|
|
130
|
+
export const errorExposure = (config?: Pick<Config, 'http'>): HttpErrorExposure =>
|
|
131
|
+
config?.http?.errors?.exposure === 'development' ? 'development' : 'production'
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Preserve a typed/marshalled error when possible without asking `ensure` to convert a plain
|
|
135
|
+
* error. In particular, `ensure` deliberately rethrows SyntaxError; an HTTP boundary still has
|
|
136
|
+
* to serialize it safely and let the incident log retain the original object.
|
|
137
|
+
*/
|
|
138
|
+
const normalizeForWire = (error: Error): Error => {
|
|
139
|
+
if (error instanceof SyntaxError || isResilientError(error)) {
|
|
140
|
+
return error
|
|
141
|
+
}
|
|
142
|
+
if (error.message.includes(SEPARATOR)) {
|
|
143
|
+
const ensured = ResilientError.ensure(error)
|
|
144
|
+
// The catch-all converter's legacy constructor shape shifts an ordinary error's fields.
|
|
145
|
+
// Only keep `ensure`'s result when a registered wire prefix actually rebuilt something.
|
|
146
|
+
if (ensured.type !== error.message || ensured.message !== error.stack) {
|
|
147
|
+
return ensured
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return error
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** One central wire shape for every server error. */
|
|
154
|
+
export const serializeError = (
|
|
155
|
+
error: Error, exposure: HttpErrorExposure = 'production'
|
|
156
|
+
): SerializedHttpError => {
|
|
157
|
+
const normalized = normalizeForWire(error)
|
|
158
|
+
const thrown = errorStatus(error)
|
|
159
|
+
const status = thrown !== SERVER_ERROR ? thrown : errorStatus(normalized)
|
|
160
|
+
const incidentId = randomUUID()
|
|
161
|
+
// The server log owns every diagnostic. Production callers receive only its correlation key;
|
|
162
|
+
// development keeps the typed wire form so local clients can retain their usual error details.
|
|
163
|
+
Object.assign(error, { incidentId })
|
|
164
|
+
if (isResilientError(normalized)) normalized.incidentId = incidentId
|
|
165
|
+
const body = exposure === 'production'
|
|
166
|
+
? incidentId
|
|
167
|
+
: ResilientError.marshal(normalized, { includeStack: true, incidentId }).message
|
|
168
|
+
|
|
169
|
+
return { status, body, incidentId }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Apply safe response metadata carried by an error without coupling its package to Fastify. */
|
|
173
|
+
export const applyErrorHeaders = (error: unknown, reply: FastifyReply): void => {
|
|
174
|
+
if (error == null || typeof error !== 'object') {
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
const retryAfter = (error as { retryAfter?: unknown }).retryAfter
|
|
178
|
+
if (typeof retryAfter === 'number' && Number.isFinite(retryAfter) && retryAfter > 0) {
|
|
179
|
+
reply.header('Retry-After', String(Math.ceil(retryAfter)))
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Answer a thrown error: a status, and the marshalled `ResilientError` as the body.
|
|
185
|
+
*
|
|
186
|
+
* The status is resolved in two steps. The error AS THROWN comes first: it is the one object whose
|
|
187
|
+
* class is certainly the class that was raised, while `ResilientError.ensure` rebuilds through the
|
|
188
|
+
* registry of whichever `@owlmeans/error` copy this module loaded — under duplicate module copies
|
|
189
|
+
* that turns a perfectly recognisable auth refusal into a plain `ResilientError` answering 500.
|
|
190
|
+
* Only when the thrown error answers 500 is the ENSURED error asked, because the rebuild is what
|
|
191
|
+
* gives a status to a marshalled error that crossed a hop as a plain `Error`.
|
|
192
|
+
*/
|
|
193
|
+
export const handleError = (
|
|
194
|
+
error: Error, reply: FastifyReply, exposure: HttpErrorExposure = 'production'
|
|
195
|
+
) => {
|
|
13
196
|
if (!reply.sent) {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
197
|
+
const serialized = serializeError(error, exposure)
|
|
198
|
+
applyErrorHeaders(error, reply)
|
|
199
|
+
// Fastify/Pino's `err` serializer retains the original type, message and full stack. This is
|
|
200
|
+
// the one log entry that owns those details in production, keyed by the response incident ID.
|
|
201
|
+
reply.log.error({ err: error, incidentId: serialized.incidentId }, 'Request failed')
|
|
202
|
+
reply.header(INCIDENT_ID_HEADER, serialized.incidentId)
|
|
203
|
+
.code(serialized.status)
|
|
204
|
+
.send(serialized.body)
|
|
17
205
|
}
|
|
18
206
|
}
|
package/src/utils/guards.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { isContextWithoutIds, Layer } from '@owlmeans/context'
|
|
2
1
|
import type { FastifyReply, FastifyRequest } from 'fastify'
|
|
3
2
|
import type { ServerEntrypoint } from '@owlmeans/server-entrypoint'
|
|
4
3
|
import type { GuardService } from '@owlmeans/entrypoint'
|
|
@@ -7,6 +6,7 @@ import type { ServerConfig, ServerContext } from '@owlmeans/server-context'
|
|
|
7
6
|
import { executeResponse, provideRequest } from './payload.js'
|
|
8
7
|
import { AuthFailedError } from '../errors.js'
|
|
9
8
|
import type { Auth } from '@owlmeans/auth'
|
|
9
|
+
import { attachEntity } from '@owlmeans/auth-common'
|
|
10
10
|
|
|
11
11
|
type Config = ServerConfig
|
|
12
12
|
interface Context<C extends Config = Config> extends ServerContext<C> { }
|
|
@@ -50,22 +50,15 @@ export const authorize = async <C extends Config, T extends Context<C>>(
|
|
|
50
50
|
(req as any)._auth = request.auth
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
63
|
-
context = await context.updateContext(request.auth.entityId, Layer.Entity)
|
|
64
|
-
await context.waitForInitialized()
|
|
65
|
-
|
|
66
|
-
// We elevate module to the context level if it was changed
|
|
67
|
-
module = context.entrypoint(module.alias)
|
|
68
|
-
await module.resolve()
|
|
53
|
+
// The token names the organization by slug; everything downstream — grants, records, minted
|
|
54
|
+
// infrastructure names — keys on the entity's stable id. Resolving here, once, is what keeps a
|
|
55
|
+
// rename from being a sweep of every handler: the id arrives on the request, and a slug that
|
|
56
|
+
// has since been retired still resolves to the entity that retired it.
|
|
57
|
+
const entity = await attachEntity(context, request)
|
|
58
|
+
if (entity != null) {
|
|
59
|
+
// Carried on the raw request the same way `_auth` is, so the request the handler is given
|
|
60
|
+
// later — a different object, built after this runs — sees the same resolution.
|
|
61
|
+
;(req as any)._entity = entity
|
|
69
62
|
}
|
|
70
63
|
}
|
|
71
64
|
// Update context in request object
|
package/src/utils/payload.ts
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
import { EntrypointOutcome } from '@owlmeans/entrypoint'
|
|
2
2
|
import type { AbstractResponse, AbstractRequest } from '@owlmeans/entrypoint'
|
|
3
3
|
import type { Request, Response } from '../types.js'
|
|
4
|
-
import { ACCEPTED, CREATED, OK
|
|
4
|
+
import { ACCEPTED, CREATED, OK } from '@owlmeans/api'
|
|
5
5
|
import type { AnySchemaObject } from 'ajv'
|
|
6
|
+
import { handleError } from './error.js'
|
|
6
7
|
|
|
7
8
|
export const provideRequest = (alias: string, req: Request, provision?: boolean): AbstractRequest => {
|
|
8
9
|
provision = provision ?? false
|
|
9
10
|
return {
|
|
10
11
|
alias,
|
|
11
12
|
auth: (req as any)._auth ?? undefined,
|
|
13
|
+
// Resolved once by `authorize` and carried by reference, exactly like `_auth`, so a handler
|
|
14
|
+
// reached through any path sees the same entity the guard admitted.
|
|
15
|
+
entity: (req as any)._entity ?? undefined,
|
|
12
16
|
params: req.params as Record<string, string | number | undefined | null>,
|
|
13
17
|
body: req.body as Record<string, any>,
|
|
14
18
|
headers: req.headers,
|
|
@@ -32,7 +36,7 @@ export const executeResponse = <T>(response: AbstractResponse<T>, reply: Respons
|
|
|
32
36
|
if (throwOnError ?? false) {
|
|
33
37
|
throw response.error
|
|
34
38
|
}
|
|
35
|
-
|
|
39
|
+
handleError(response.error, reply)
|
|
36
40
|
return true
|
|
37
41
|
} else if (response.outcome != null) {
|
|
38
42
|
switch (response.outcome) {
|
package/src/utils/server.ts
CHANGED
|
@@ -5,15 +5,16 @@ import type { FixerService, ServerEntrypoint } from '@owlmeans/server-entrypoint
|
|
|
5
5
|
import type { CommonEntrypoint } from '@owlmeans/entrypoint'
|
|
6
6
|
import type { GateService } from '@owlmeans/entrypoint'
|
|
7
7
|
import { provideResponse } from '@owlmeans/entrypoint'
|
|
8
|
-
import type { ServerContext
|
|
8
|
+
import type { ServerContext } from '@owlmeans/server-context'
|
|
9
9
|
import { ResilientError } from '@owlmeans/error'
|
|
10
10
|
import { OK } from '@owlmeans/api'
|
|
11
|
-
import { handleError } from './error.js'
|
|
11
|
+
import { errorExposure, handleError } from './error.js'
|
|
12
12
|
import { executeResponse, provideRequest } from './payload.js'
|
|
13
13
|
import { authorize } from './guards.js'
|
|
14
14
|
import { RouteProtocols } from '@owlmeans/route'
|
|
15
|
+
import type { Config as ApiConfig } from '../types.js'
|
|
15
16
|
|
|
16
|
-
type Config =
|
|
17
|
+
type Config = ApiConfig
|
|
17
18
|
type Context = ServerContext<Config>
|
|
18
19
|
|
|
19
20
|
export const canServeModule = (context: Context, module: CommonEntrypoint): module is ServerEntrypoint<unknown> => {
|
|
@@ -23,7 +24,9 @@ export const canServeModule = (context: Context, module: CommonEntrypoint): modu
|
|
|
23
24
|
if (module.route.route.service != null && module.route.route.service !== context.cfg.service) {
|
|
24
25
|
return false
|
|
25
26
|
}
|
|
26
|
-
|
|
27
|
+
// HTTP serves only its own protocol. Custom transports are handled by their owning packages.
|
|
28
|
+
if (module.route.route.protocol != null
|
|
29
|
+
&& module.route.route.protocol !== RouteProtocols.WEB) {
|
|
27
30
|
return false
|
|
28
31
|
}
|
|
29
32
|
|
|
@@ -63,14 +66,11 @@ export const createServerHandler = (module: ServerEntrypoint<FastifyRequest>, lo
|
|
|
63
66
|
reply.code(OK).send(response.value)
|
|
64
67
|
}
|
|
65
68
|
} catch (error) {
|
|
66
|
-
console.error(`Error in ${module.alias} (${location})`)
|
|
67
|
-
console.error(JSON.stringify(error, null, 2))
|
|
68
|
-
console.error(error)
|
|
69
69
|
if (module.fixer != null) {
|
|
70
70
|
const fixer: FixerService = context.service(module.fixer)
|
|
71
71
|
fixer.handle(reply, ResilientError.ensure(error as Error))
|
|
72
72
|
return
|
|
73
73
|
}
|
|
74
|
-
handleError(error as Error, reply)
|
|
74
|
+
handleError(error as Error, reply, errorExposure(context.cfg))
|
|
75
75
|
}
|
|
76
76
|
}
|