@owlmeans/server-api 0.1.18-rc.8 → 0.1.18

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.
Files changed (140) hide show
  1. package/README.md +203 -46
  2. package/agent-meta/manifest.json +3 -3
  3. package/agent-meta/skills/server-api/SKILL.md +142 -22
  4. package/build/consts.local.d.ts +2 -0
  5. package/build/consts.local.d.ts.map +1 -0
  6. package/build/consts.local.js +2 -0
  7. package/build/consts.local.js.map +1 -0
  8. package/build/errors.d.ts +12 -0
  9. package/build/errors.d.ts.map +1 -1
  10. package/build/errors.js +16 -0
  11. package/build/errors.js.map +1 -1
  12. package/build/helper.d.ts +3 -6
  13. package/build/helper.d.ts.map +1 -1
  14. package/build/helper.js +10 -1
  15. package/build/helper.js.map +1 -1
  16. package/build/hold.d.ts +35 -0
  17. package/build/hold.d.ts.map +1 -0
  18. package/build/hold.js +57 -0
  19. package/build/hold.js.map +1 -0
  20. package/build/index.d.ts +2 -0
  21. package/build/index.d.ts.map +1 -1
  22. package/build/index.js +2 -0
  23. package/build/index.js.map +1 -1
  24. package/build/protocol.d.ts +18 -0
  25. package/build/protocol.d.ts.map +1 -0
  26. package/build/protocol.js +87 -0
  27. package/build/protocol.js.map +1 -0
  28. package/build/server.d.ts +2 -3
  29. package/build/server.d.ts.map +1 -1
  30. package/build/server.js +42 -34
  31. package/build/server.js.map +1 -1
  32. package/build/types.d.ts +31 -2
  33. package/build/types.d.ts.map +1 -1
  34. package/build/types.local.d.ts +38 -0
  35. package/build/types.local.d.ts.map +1 -0
  36. package/build/types.local.js +2 -0
  37. package/build/types.local.js.map +1 -0
  38. package/build/utils/consts.d.ts +2 -0
  39. package/build/utils/consts.d.ts.map +1 -0
  40. package/build/utils/consts.js +2 -0
  41. package/build/utils/consts.js.map +1 -0
  42. package/build/utils/consts.local.d.ts +5 -0
  43. package/build/utils/consts.local.d.ts.map +1 -0
  44. package/build/utils/consts.local.js +5 -0
  45. package/build/utils/consts.local.js.map +1 -0
  46. package/build/utils/context/types.d.ts +13 -0
  47. package/build/utils/context/types.d.ts.map +1 -0
  48. package/build/utils/context/types.js +2 -0
  49. package/build/utils/context/types.js.map +1 -0
  50. package/build/utils/context.d.ts +3 -3
  51. package/build/utils/context.d.ts.map +1 -1
  52. package/build/utils/context.js +5 -2
  53. package/build/utils/context.js.map +1 -1
  54. package/build/utils/error/types.d.ts +52 -0
  55. package/build/utils/error/types.d.ts.map +1 -0
  56. package/build/utils/error/types.js +2 -0
  57. package/build/utils/error/types.js.map +1 -0
  58. package/build/utils/error.d.ts +5 -15
  59. package/build/utils/error.d.ts.map +1 -1
  60. package/build/utils/error.js +134 -29
  61. package/build/utils/error.js.map +1 -1
  62. package/build/utils/guards.d.ts +1 -5
  63. package/build/utils/guards.d.ts.map +1 -1
  64. package/build/utils/guards.js +15 -19
  65. package/build/utils/guards.js.map +1 -1
  66. package/build/utils/index.d.ts +6 -1
  67. package/build/utils/index.d.ts.map +1 -1
  68. package/build/utils/index.js +2 -1
  69. package/build/utils/index.js.map +1 -1
  70. package/build/utils/log/consts.d.ts +14 -0
  71. package/build/utils/log/consts.d.ts.map +1 -0
  72. package/build/utils/log/consts.js +17 -0
  73. package/build/utils/log/consts.js.map +1 -0
  74. package/build/utils/log/types.d.ts +42 -0
  75. package/build/utils/log/types.d.ts.map +1 -0
  76. package/build/utils/log/types.js +2 -0
  77. package/build/utils/log/types.js.map +1 -0
  78. package/build/utils/log.d.ts +4 -0
  79. package/build/utils/log.d.ts.map +1 -0
  80. package/build/utils/log.js +170 -0
  81. package/build/utils/log.js.map +1 -0
  82. package/build/utils/payload/types.d.ts +18 -0
  83. package/build/utils/payload/types.d.ts.map +1 -0
  84. package/build/utils/payload/types.js +2 -0
  85. package/build/utils/payload/types.js.map +1 -0
  86. package/build/utils/payload.d.ts +3 -15
  87. package/build/utils/payload.d.ts.map +1 -1
  88. package/build/utils/payload.js +41 -57
  89. package/build/utils/payload.js.map +1 -1
  90. package/build/utils/schema.d.ts +7 -0
  91. package/build/utils/schema.d.ts.map +1 -0
  92. package/build/utils/schema.js +20 -0
  93. package/build/utils/schema.js.map +1 -0
  94. package/build/utils/server.d.ts +3 -6
  95. package/build/utils/server.d.ts.map +1 -1
  96. package/build/utils/server.js +12 -13
  97. package/build/utils/server.js.map +1 -1
  98. package/build/utils/types.d.ts +18 -0
  99. package/build/utils/types.d.ts.map +1 -0
  100. package/build/utils/types.js +2 -0
  101. package/build/utils/types.js.map +1 -0
  102. package/build/utils/types.local.d.ts +9 -0
  103. package/build/utils/types.local.d.ts.map +1 -0
  104. package/build/utils/types.local.js +2 -0
  105. package/build/utils/types.local.js.map +1 -0
  106. package/package.json +23 -19
  107. package/src/consts.local.ts +1 -0
  108. package/src/errors.ts +18 -0
  109. package/src/helper.ts +13 -8
  110. package/src/hold.ts +66 -0
  111. package/src/index.ts +2 -0
  112. package/src/protocol.ts +149 -0
  113. package/src/server.ts +72 -69
  114. package/src/types.local.ts +44 -0
  115. package/src/types.ts +38 -4
  116. package/src/utils/consts.local.ts +7 -0
  117. package/src/utils/consts.ts +1 -0
  118. package/src/utils/context/types.ts +13 -0
  119. package/src/utils/context.ts +9 -4
  120. package/src/utils/error/types.ts +52 -0
  121. package/src/utils/error.ts +156 -29
  122. package/src/utils/guards.ts +16 -26
  123. package/src/utils/index.ts +6 -1
  124. package/src/utils/log/consts.ts +18 -0
  125. package/src/utils/log/types.ts +43 -0
  126. package/src/utils/log.ts +193 -0
  127. package/src/utils/payload/types.ts +18 -0
  128. package/src/utils/payload.ts +42 -60
  129. package/src/utils/schema.ts +23 -0
  130. package/src/utils/server.ts +16 -21
  131. package/src/utils/types.local.ts +12 -0
  132. package/src/utils/types.ts +18 -0
  133. package/tests/error-status.spec.ts +349 -0
  134. package/tests/handlers.spec.ts +83 -0
  135. package/tests/json-body.spec.ts +72 -0
  136. package/tests/log-adapter.spec.ts +62 -0
  137. package/tests/log-redaction.spec.ts +181 -0
  138. package/tests/tsconfig.json +12 -0
  139. package/tsconfig.json +1 -0
  140. package/build/.gitkeep +0 -0
package/src/types.ts CHANGED
@@ -1,14 +1,13 @@
1
1
 
2
- import type { InitializedService, Layer } from '@owlmeans/context'
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'
6
+ import type { MultipartFile } from '@fastify/multipart'
6
7
 
7
8
  export interface ApiServer extends InitializedService {
8
9
  server: FastifyInstance
9
10
 
10
- layers: [Layer.System]
11
-
12
11
  listen: () => Promise<void>
13
12
  }
14
13
 
@@ -20,7 +19,42 @@ export interface ApiServerAppend {
20
19
  getApiServer: () => ApiServer
21
20
  }
22
21
 
23
- export interface Config extends ServerConfig { }
22
+ export type HttpErrorExposure = 'production' | 'development'
23
+
24
+ export interface HttpErrorsConfig {
25
+ /** Production is the safe default; development is the only mode that exposes stacks. */
26
+ exposure?: HttpErrorExposure
27
+ }
28
+
29
+ export interface HttpConfig {
30
+ errors?: HttpErrorsConfig
31
+ }
32
+
33
+ export interface Config extends ServerConfig {
34
+ http?: HttpConfig
35
+ }
24
36
 
25
37
  export interface Context<C extends Config = Config> extends ServerContext<C>,
26
38
  ApiServerAppend { }
39
+
40
+ export interface UploadedFile extends MultipartFile { }
41
+
42
+ export interface ApiPortHoldOptions {
43
+ /**
44
+ * The one path answered 200 while the hold is in place — the app's health endpoint, so
45
+ * whatever supervises the process keeps a conventional liveness answer through the boot.
46
+ * Everything else answers 503 with the same body. Absent, everything is 503.
47
+ */
48
+ okPath?: string
49
+ /** Body of every answer, evaluated per request so a changing boot phase is reported live. */
50
+ payload?: () => unknown
51
+ }
52
+
53
+ export interface ApiPortHold {
54
+ /**
55
+ * Free the port for the real server. Awaits the actual close — `listen()` follows immediately,
56
+ * and a predecessor still bound makes it throw — and force-closes keep-alive sockets, so a
57
+ * supervisor's own poller cannot hold the release open.
58
+ */
59
+ release: () => Promise<void>
60
+ }
@@ -0,0 +1,7 @@
1
+ export const CLIENT_ERROR_FIRST = 400
2
+
3
+ export const CLIENT_ERROR_LAST = 499
4
+
5
+ export const SERVER_ERROR_FIRST = 500
6
+
7
+ export const SERVER_ERROR_LAST = 599
@@ -0,0 +1 @@
1
+ export const INCIDENT_ID_HEADER = 'X-Incident-ID'
@@ -0,0 +1,13 @@
1
+ import type { Config, Context } from '../../types.js'
2
+
3
+ /** Carries the serving context on one raw request. */
4
+ export interface RequestContextHelper {
5
+ /** Stores the context on the request. */
6
+ populateContext: <C extends Config, T extends Context<C>>(context: T) => void
7
+ /**
8
+ * The context stored on the request, else `ctx`.
9
+ *
10
+ * @throws {SyntaxError} when neither is there
11
+ */
12
+ extractContext: <C extends Config, T extends Context<C>>(ctx?: T, location?: string) => T
13
+ }
@@ -1,9 +1,14 @@
1
1
  import { assertContext } from '@owlmeans/context'
2
2
  import type { Config, Context, Request } from '../types.js'
3
3
  import { DEFAULT_ALIAS } from '../consts.js'
4
+ import type { RequestContextHelper } from './context/types.js'
4
5
 
5
- export const populateContext = <C extends Config, T extends Context<C>>(req: Request, context: T): void =>
6
- void ((req as any)._ctx = context)
6
+ export const makeRequestContextHelper = (req: Request): RequestContextHelper => {
7
+ const populateContext = <C extends Config, T extends Context<C>>(context: T): void =>
8
+ void ((req as any)._ctx = context)
7
9
 
8
- export const extractContext = <C extends Config, T extends Context<C>>(req: Request, ctx?: T, location?: string): T =>
9
- assertContext<C, T>((req as any)._ctx ?? ctx, location ?? DEFAULT_ALIAS)
10
+ const extractContext = <C extends Config, T extends Context<C>>(ctx?: T, location?: string): T =>
11
+ assertContext<C, T>((req as any)._ctx ?? ctx, location ?? DEFAULT_ALIAS)
12
+
13
+ return { populateContext, extractContext }
14
+ }
@@ -0,0 +1,52 @@
1
+ import type { FastifyReply } from 'fastify'
2
+ import type { Config, HttpErrorExposure } from '../../types.js'
3
+ import type { SerializedHttpError } from '../types.js'
4
+
5
+ /** How the HTTP boundary answers a thrown error: its status, its wire body, its headers and its log entry. */
6
+ export interface HttpErrorHelper {
7
+ /**
8
+ * The HTTP status an error's class declares, or `null` when it declares none that may be honoured.
9
+ *
10
+ * Read through the constructor chain — a static property is inherited, so a subclass answers what
11
+ * its nearest declaring ancestor says, and redeclares to change it. An integer 4xx is honoured
12
+ * directly. A 5xx is honoured only with `allowServerErrorStatus = true`, so a domain class must
13
+ * explicitly opt in to exposing a precise availability response. The read is structural, so a
14
+ * class from a duplicate module copy declares exactly what the imported one does.
15
+ */
16
+ declaredErrorStatus: (error: unknown) => number | null
17
+ /**
18
+ * The HTTP status an error is answered with, read off the error exactly as given.
19
+ *
20
+ * Two families reach here and both must be recognised. `AuthFailedError` / `AccessError` are this
21
+ * package's own, raised by the request pipeline itself. `AuthorizationError` / `AuthForbidden`
22
+ * come from `@owlmeans/auth` and are what the guards and gates actually throw — an unrecognised
23
+ * one answers 500, which reports a refused request as a crashed server: the client cannot tell
24
+ * "sign in again" from "the service is broken", and a consumer watching statuses concludes the
25
+ * application fell over. A family member is recognised by class OR by registered type name, so a
26
+ * class from a duplicate module copy answers the same status.
27
+ *
28
+ * **Order matters.** `AuthForbidden extends AuthorizationError`, so the 403 branch has to be
29
+ * tested first or every refusal of a permission is reported as a failure to authenticate. Both
30
+ * auth branches come before a class's own declaration, so an auth refusal can never be restated
31
+ * as something else. Everything that is neither an auth error nor a class declaring an allowed
32
+ * {@link HttpErrorHelper.declaredErrorStatus} answers 500.
33
+ */
34
+ errorStatus: (error: unknown) => number
35
+ /** Invalid or absent configuration always resolves to the production-safe policy. */
36
+ errorExposure: (config?: Pick<Config, 'http'>) => HttpErrorExposure
37
+ /** One central wire shape for every server error. */
38
+ serializeError: (error: Error, exposure?: HttpErrorExposure) => SerializedHttpError
39
+ /** Apply safe response metadata carried by an error without coupling its package to Fastify. */
40
+ applyErrorHeaders: (error: unknown, reply: FastifyReply) => void
41
+ /**
42
+ * Answer a thrown error: a status, and the marshalled `ResilientError` as the body.
43
+ *
44
+ * The status is resolved in two steps. The error AS THROWN comes first: it is the one object whose
45
+ * class is certainly the class that was raised, while `ResilientError.ensure` rebuilds through the
46
+ * registry of whichever `@owlmeans/error` copy this module loaded — under duplicate module copies
47
+ * that turns a perfectly recognisable auth refusal into a plain `ResilientError` answering 500.
48
+ * Only when the thrown error answers 500 is the ENSURED error asked, because the rebuild is what
49
+ * gives a status to a marshalled error that crossed a hop as a plain `Error`.
50
+ */
51
+ handleError: (error: Error, reply: FastifyReply, exposure?: HttpErrorExposure) => void
52
+ }
@@ -1,35 +1,162 @@
1
- import { FORBIDDEN_ERROR, SERVER_ERROR, UNAUTHORIZED_ERROR } from '@owlmeans/api'
1
+ import {
2
+ FORBIDDEN_ERROR, SERVER_ERROR, UNAUTHORIZED_ERROR, DENIAL_KIND_HEADER, ACCESS_DENIED_KIND,
3
+ } from '@owlmeans/api'
2
4
  import type { FastifyReply } from 'fastify'
3
5
  import { AccessError, AuthFailedError } from '../errors.js'
4
6
  import { AuthForbidden, AuthorizationError } from '@owlmeans/auth'
5
- import { ResilientError } from '@owlmeans/error'
6
-
7
- /**
8
- * Map a thrown error onto an HTTP status.
9
- *
10
- * Two families reach here and both must be recognised. `AuthFailedError` / `AccessError` are this
11
- * package's own, raised by the request pipeline itself. `AuthorizationError` / `AuthForbidden`
12
- * come from `@owlmeans/auth` and are what the guards and gates actually throw — an unrecognised
13
- * one answers 500, which reports a refused request as a crashed server: the client cannot tell
14
- * "sign in again" from "the service is broken", and a consumer watching statuses concludes the
15
- * application fell over.
16
- *
17
- * **Order matters.** `AuthForbidden extends AuthorizationError`, so the 403 branch has to be
18
- * tested first or every refusal of a permission is reported as a failure to authenticate.
19
- */
20
- export const handleError = (error: Error, reply: FastifyReply) => {
21
- let code: number = SERVER_ERROR
22
- if (error instanceof AuthForbidden || error instanceof AccessError) {
23
- // An established identity that lacks the permission. Re-authenticating changes nothing.
24
- code = FORBIDDEN_ERROR
25
- } else if (error instanceof AuthorizationError || error instanceof AuthFailedError) {
26
- // No usable credential — absent, expired, revoked, or naming a session this server no longer
27
- // holds. The caller is told to sign in, not that signing in would not help.
28
- code = UNAUTHORIZED_ERROR
7
+ import { isResilientError, ResilientError, SEPARATOR } from '@owlmeans/error'
8
+ import { randomUUID } from 'node:crypto'
9
+ import { fastifyLogUtils } from './log.js'
10
+ import type { Config, HttpErrorExposure } from '../types.js'
11
+ import { CLIENT_ERROR_FIRST, CLIENT_ERROR_LAST, SERVER_ERROR_FIRST, SERVER_ERROR_LAST } from './consts.local.js'
12
+ import { INCIDENT_ID_HEADER } from './consts.js'
13
+ import type { HttpStatusDeclaration, SerializedHttpError } from './types.js'
14
+ import type { HttpErrorHelper } from './error/types.js'
15
+
16
+ export const createHttpErrorHelper = (): HttpErrorHelper => {
17
+ const declaredErrorStatus = (error: unknown): number | null => {
18
+ if (error == null || typeof error !== 'object') {
19
+ return null
20
+ }
21
+ const declaration = error.constructor as HttpStatusDeclaration | undefined
22
+ const status = declaration?.httpStatus
23
+ if (typeof status !== 'number' || !Number.isInteger(status)) {
24
+ return null
25
+ }
26
+ if (status >= CLIENT_ERROR_FIRST && status <= CLIENT_ERROR_LAST) return status
27
+ if (status >= SERVER_ERROR_FIRST && status <= SERVER_ERROR_LAST
28
+ && declaration?.allowServerErrorStatus === true) return status
29
+
30
+ return null
31
+ }
32
+
33
+ /**
34
+ * The registered type names an error carries: its own `type`, and the static `typeName` of every
35
+ * class on its constructor chain.
36
+ *
37
+ * This is how a class is recognised when `instanceof` cannot see it. A process that loads a package
38
+ * twice — `bun --preserve-symlinks` over a linked workspace resolves one package through several
39
+ * `node_modules` paths — holds several copies of every error class, and an error built by one copy
40
+ * is not an instance of another. The registered names are the same in every copy. A subclass's
41
+ * `typeName` does not reliably embed its parent's (`EntitlementRefusal` extends `AuthForbidden`), so
42
+ * a family is recognised by walking the chain and matching names exactly, never by a substring.
43
+ */
44
+ const typeNamesOf = (error: object): Set<string> => {
45
+ const names = new Set<string>()
46
+ const type = (error as { type?: unknown }).type
47
+ if (typeof type === 'string') {
48
+ names.add(type)
49
+ }
50
+ let ctor: unknown = error.constructor
51
+ while (typeof ctor === 'function') {
52
+ const name = (ctor as { typeName?: unknown }).typeName
53
+ if (typeof name === 'string') {
54
+ names.add(name)
55
+ }
56
+ ctor = Object.getPrototypeOf(ctor)
57
+ }
58
+
59
+ return names
29
60
  }
30
- if (!reply.sent) {
31
- reply.code(code).send(
32
- ResilientError.marshal(ResilientError.ensure(error as Error)).message
33
- )
61
+
62
+ const isOfFamily = (names: Set<string>, family: string[]): boolean =>
63
+ family.some(typeName => names.has(typeName))
64
+
65
+ const errorStatus = (error: unknown): number => {
66
+ if (error == null || typeof error !== 'object') {
67
+ return SERVER_ERROR
68
+ }
69
+ const names = typeNamesOf(error)
70
+ if (error instanceof AuthForbidden || error instanceof AccessError
71
+ || isOfFamily(names, [AuthForbidden.typeName, AccessError.typeName])) {
72
+ // An established identity that lacks the permission. Re-authenticating changes nothing.
73
+ return FORBIDDEN_ERROR
74
+ }
75
+ if (error instanceof AuthorizationError || error instanceof AuthFailedError
76
+ || isOfFamily(names, [AuthorizationError.typeName, AuthFailedError.typeName])) {
77
+ // No usable credential — absent, expired, revoked, or naming a session this server no longer
78
+ // holds. The caller is told to sign in, not that signing in would not help.
79
+ return UNAUTHORIZED_ERROR
80
+ }
81
+
82
+ return declaredErrorStatus(error) ?? SERVER_ERROR
83
+ }
84
+
85
+ const errorExposure = (config?: Pick<Config, 'http'>): HttpErrorExposure =>
86
+ config?.http?.errors?.exposure === 'development' ? 'development' : 'production'
87
+
88
+ /**
89
+ * Preserve a typed/marshalled error when possible without asking `ensure` to convert a plain
90
+ * error. In particular, `ensure` deliberately rethrows SyntaxError; an HTTP boundary still has
91
+ * to serialize it safely and let the incident log retain the original object.
92
+ */
93
+ const normalizeForWire = (error: Error): Error => {
94
+ if (error instanceof SyntaxError || isResilientError(error)) {
95
+ return error
96
+ }
97
+ if (error.message.includes(SEPARATOR)) {
98
+ const ensured = ResilientError.ensure(error)
99
+ // The catch-all converter's legacy constructor shape shifts an ordinary error's fields.
100
+ // Only keep `ensure`'s result when a registered wire prefix actually rebuilt something.
101
+ if (ensured.type !== error.message || ensured.message !== error.stack) {
102
+ return ensured
103
+ }
104
+ }
105
+ return error
34
106
  }
107
+
108
+ const serializeError = (
109
+ error: Error, exposure: HttpErrorExposure = 'production'
110
+ ): SerializedHttpError => {
111
+ const normalized = normalizeForWire(error)
112
+ const thrown = errorStatus(error)
113
+ const status = thrown !== SERVER_ERROR ? thrown : errorStatus(normalized)
114
+ const incidentId = randomUUID()
115
+ // The server log owns every diagnostic. Production callers receive only its correlation key;
116
+ // development keeps the typed wire form so local clients can retain their usual error details.
117
+ Object.assign(error, { incidentId })
118
+ if (isResilientError(normalized)) normalized.incidentId = incidentId
119
+ const body = exposure === 'production'
120
+ ? incidentId
121
+ : ResilientError.marshal(normalized, { includeStack: true, incidentId }).message
122
+
123
+ return { status, body, incidentId }
124
+ }
125
+
126
+ const applyErrorHeaders = (error: unknown, reply: FastifyReply): void => {
127
+ if (error == null || typeof error !== 'object') {
128
+ return
129
+ }
130
+ const retryAfter = (error as { retryAfter?: unknown }).retryAfter
131
+ if (typeof retryAfter === 'number' && Number.isFinite(retryAfter) && retryAfter > 0) {
132
+ reply.header('Retry-After', String(Math.ceil(retryAfter)))
133
+ }
134
+ // Match exact registered types. EntitlementRefusal also extends AuthForbidden but is a plan
135
+ // decision, not a person's IAM permission denial.
136
+ const type = (error as { type?: unknown }).type
137
+ if (type === AuthForbidden.typeName || type === AccessError.typeName) {
138
+ reply.header(DENIAL_KIND_HEADER, ACCESS_DENIED_KIND)
139
+ }
140
+ }
141
+
142
+ const handleError = (
143
+ error: Error, reply: FastifyReply, exposure: HttpErrorExposure = 'production'
144
+ ): void => {
145
+ if (!reply.sent) {
146
+ const serialized = serializeError(error, exposure)
147
+ applyErrorHeaders(error, reply)
148
+ // The one record of a failed request: method, path and status — never what the caller sent.
149
+ fastifyLogUtils.failure(error, reply.request, serialized.status, serialized.incidentId)
150
+ reply.header(INCIDENT_ID_HEADER, serialized.incidentId)
151
+ .code(serialized.status)
152
+ .send(serialized.body)
153
+ }
154
+ }
155
+
156
+ return { declaredErrorStatus, errorStatus, errorExposure, serializeError, applyErrorHeaders, handleError }
35
157
  }
158
+
159
+ export const httpErrorHelper = createHttpErrorHelper()
160
+
161
+ /** @deprecated compat:factory-refactor — use `httpErrorHelper.errorStatus(…)` */
162
+ export const errorStatus = (error: unknown): number => httpErrorHelper.errorStatus(error)
@@ -1,15 +1,12 @@
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
- import type { GuardService } from '@owlmeans/entrypoint'
5
- import { provideResponse } from '@owlmeans/entrypoint'
6
- import type { ServerConfig, ServerContext } from '@owlmeans/server-context'
7
- import { executeResponse, provideRequest } from './payload.js'
3
+ import { type GuardService, provideResponse } from '@owlmeans/entrypoint'
4
+ import { payloadHelper } from './payload.js'
8
5
  import { AuthFailedError } from '../errors.js'
9
6
  import type { Auth } from '@owlmeans/auth'
7
+ import type { Config, Context } from './types.local.js'
8
+ import { makeEntityScope } from '@owlmeans/auth-common'
10
9
 
11
- type Config = ServerConfig
12
- interface Context<C extends Config = Config> extends ServerContext<C> { }
13
10
 
14
11
  export const authorize = async <C extends Config, T extends Context<C>>(
15
12
  context: T, module: ServerEntrypoint<FastifyRequest>,
@@ -18,7 +15,7 @@ export const authorize = async <C extends Config, T extends Context<C>>(
18
15
  const guards = module.getGuards()
19
16
  if (guards.length > 0) {
20
17
  const response = provideResponse(reply)
21
- const request = provideRequest(module.alias, req)
18
+ const request = payloadHelper.provideRequest(module.alias, req)
22
19
 
23
20
  let guard: GuardService | undefined = undefined
24
21
  for (const alias of guards) {
@@ -26,7 +23,7 @@ export const authorize = async <C extends Config, T extends Context<C>>(
26
23
  if (await _guard.match(request, response)) {
27
24
  guard = _guard
28
25
  }
29
- executeResponse(response, reply, true)
26
+ payloadHelper.executeResponse(response, reply, true)
30
27
  if (guard != null) {
31
28
  break
32
29
  }
@@ -40,7 +37,7 @@ export const authorize = async <C extends Config, T extends Context<C>>(
40
37
  if (!await guard.handle<boolean>(request, authResponse)) {
41
38
  throw new AuthFailedError(guard.alias)
42
39
  }
43
- executeResponse(authResponse, reply, true)
40
+ payloadHelper.executeResponse(authResponse, reply, true)
44
41
  // Guard that returns true and does not provide an error is an optional guard
45
42
  // if (authResponse.value == null) {
46
43
  // throw SyntaxError(`Guard that returns true and does not provide an error, should provide authorization`)
@@ -50,22 +47,15 @@ export const authorize = async <C extends Config, T extends Context<C>>(
50
47
  (req as any)._auth = request.auth
51
48
  }
52
49
 
53
- if (request.auth?.entityId != null) {
54
- // @TODO Probably we need to downgrade context in this case
55
- if (!isContextWithoutIds(context as any)) {
56
- throw SyntaxError(`Context should be without ids during authorization ${context.cfg.layer}:${context.cfg.layerId}`)
57
- }
58
-
59
- if (isContextWithoutIds(context as any) && context.cfg.layer !== Layer.Service) {
60
- context = await context.updateContext(undefined, Layer.Service)
61
- await context.waitForInitialized()
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()
50
+ // The token names the organization by slug; everything downstream — grants, records, minted
51
+ // infrastructure names — keys on the entity's stable id. Resolving here, once, is what keeps a
52
+ // rename from being a sweep of every handler: the id arrives on the request, and a slug that
53
+ // has since been retired still resolves to the entity that retired it.
54
+ const entity = await makeEntityScope(request).attachEntity(context)
55
+ if (entity != null) {
56
+ // Carried on the raw request the same way `_auth` is, so the request the handler is given
57
+ // later — a different object, built after this runs — sees the same resolution.
58
+ ;(req as any)._entity = entity
69
59
  }
70
60
  }
71
61
  // Update context in request object
@@ -1,7 +1,12 @@
1
1
 
2
2
  export * from './error.js'
3
+ export * from './consts.js'
4
+ export type * from './types.js'
5
+ export type * from './error/types.js'
6
+ export type * from './payload/types.js'
7
+ export type * from './context/types.js'
3
8
  export * from './server.js'
4
9
  export * from './payload.js'
5
- export * from './server.js'
10
+ export * from './schema.js'
6
11
  export * from './guards.js'
7
12
  export * from './context.js'
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Keys of a Fastify log record that carry what a caller sent. They are dropped at every depth: a
3
+ * request body and its raw form (`rawBody`), multipart fields and file buffers, headers (with the
4
+ * authorization and cookie values), cookies and the parsed query string.
5
+ */
6
+ export const UNSAFE_LOG_KEYS: ReadonlySet<string> = new Set([
7
+ 'body', 'rawBody', 'payload', 'files', 'file', 'headers', 'rawHeaders', 'trailers', 'rawTrailers',
8
+ 'query', 'querystring', 'cookies', 'cookie', 'authorization',
9
+ ])
10
+
11
+ /** Deepest nesting `safeData` walks; anything below is one marker. */
12
+ export const SAFE_DATA_DEPTH = 4
13
+
14
+ /**
15
+ * The query string of a path or URL inside free text (a Fastify warning names `"/x?token=…"`): the
16
+ * path is kept (`$1`), the query is cut.
17
+ */
18
+ export const QUERY_IN_TEXT = /(\/[^\s?"'`]*)\?[^\s"'`)]+/g
@@ -0,0 +1,43 @@
1
+ import type { FastifyBaseLogger, LogController } from 'fastify'
2
+ import type { Logger } from '@owlmeans/log'
3
+
4
+ /** All a log record may say about a request: its method and its path — never the query string. */
5
+ export interface RequestSummary {
6
+ method?: string
7
+ path?: string
8
+ }
9
+
10
+ /**
11
+ * How the API server's Fastify instance logs: through `@owlmeans/log`, and never with what a caller
12
+ * sent — a body, a raw body, multipart bytes, headers, cookies or a query string.
13
+ */
14
+ export interface FastifyLogUtils {
15
+ /**
16
+ * A pino-shaped logger over `log` (scope `http` by default), handed to Fastify as its
17
+ * `loggerInstance`. Every record's data — Fastify's own and any plugin's — passes `safeData`.
18
+ */
19
+ logger: (log?: Logger, bindings?: Record<string, unknown>) => FastifyBaseLogger
20
+ /**
21
+ * The `logController` Fastify writes its own request lines through: no per-request pair (the
22
+ * server writes one debug record from `onResponse`), a request the default error handler answers
23
+ * is `failure`, a missing route is one debug line without its query.
24
+ */
25
+ controller: () => LogController
26
+ /**
27
+ * A copy of a record's data that is safe to write: a request becomes its `RequestSummary`, a reply
28
+ * its status, bytes their size, any other class instance its name; the keys that carry what a
29
+ * caller sent (`body`, `rawBody`, `headers`, `query`, `cookies`, files…) are dropped at every depth
30
+ * (a number or boolean under them stays: a count, a flag), and any string loses the query of a URL
31
+ * it names. An `Error` passes as it is (a copy, when its message names a URL with a query) — the
32
+ * log writes only its name, message, code and stack.
33
+ */
34
+ safeData: (data: unknown) => unknown
35
+ /** The method and the path, without the query string, of a Fastify or Node request. */
36
+ requestOf: (request: unknown) => RequestSummary
37
+ /**
38
+ * The one record of a failed request, by what its status means: 5xx `error` with the error,
39
+ * 403 `warn` `access.forbidden`, 401 `debug` `auth.refused`, any other 4xx `debug` with the error's
40
+ * code and message. Method, path and status only — never the request's content.
41
+ */
42
+ failure: (error: Error, request: unknown, status: number, incidentId?: string) => void
43
+ }