@naturalcycles/backend-lib 9.73.1 → 9.75.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/admin/adminMiddleware.d.ts +17 -0
- package/dist/admin/adminMiddleware.js +11 -0
- package/dist/admin/base.admin.service.d.ts +17 -0
- package/dist/admin/base.admin.service.js +7 -2
- package/dist/admin/firebaseAuth.service.d.ts +41 -0
- package/dist/admin/firebaseAuth.service.js +103 -0
- package/dist/admin/index.d.ts +1 -0
- package/dist/admin/index.js +1 -0
- package/dist/admin/secureHeaderMiddleware.d.ts +5 -0
- package/dist/admin/secureHeaderMiddleware.js +2 -0
- package/dist/auth/auth.service.d.ts +119 -0
- package/dist/auth/auth.service.js +147 -0
- package/dist/auth/index.d.ts +1 -0
- package/dist/auth/index.js +1 -0
- package/dist/sentry/sentry.shared.service.js +14 -15
- package/dist/server/asyncLocalStorageMiddleware.js +2 -6
- package/dist/server/genericErrorMiddleware.js +2 -2
- package/dist/server/logMiddleware.js +25 -66
- package/dist/server/server.model.d.ts +3 -11
- package/package.json +3 -1
- package/src/admin/adminMiddleware.ts +17 -0
- package/src/admin/base.admin.service.ts +22 -5
- package/src/admin/firebaseAuth.service.ts +139 -0
- package/src/admin/index.ts +1 -0
- package/src/admin/secureHeaderMiddleware.ts +5 -0
- package/src/auth/auth.service.ts +303 -0
- package/src/auth/index.ts +1 -0
- package/src/sentry/sentry.shared.service.ts +15 -17
- package/src/server/asyncLocalStorageMiddleware.ts +4 -6
- package/src/server/genericErrorMiddleware.ts +2 -2
- package/src/server/logMiddleware.ts +35 -69
- package/src/server/server.model.ts +3 -13
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { _assert, AppError } from '@naturalcycles/js-lib/error'
|
|
2
|
+
import type { ErrorData } from '@naturalcycles/js-lib/error'
|
|
3
|
+
import type { AnyObject, Promisable } from '@naturalcycles/js-lib/types'
|
|
4
|
+
import { timingSafeStringEqual } from '@naturalcycles/nodejs-lib'
|
|
5
|
+
import type { BackendRequest, BackendRequestHandler } from '../server/server.model.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Authenticates requests and checks permissions.
|
|
9
|
+
*/
|
|
10
|
+
export class AuthService<CTX = AnyObject> {
|
|
11
|
+
constructor(cfg: AuthServiceCfg<CTX>) {
|
|
12
|
+
this.cfg = {
|
|
13
|
+
authEnabled: true,
|
|
14
|
+
...cfg,
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
cfg: AuthServiceCfg<CTX> & { authEnabled: boolean }
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Responds JSON 401 / 403.
|
|
22
|
+
*/
|
|
23
|
+
getMiddleware(cfg: AuthMiddlewareCfg<CTX> = {}): AuthMiddleware {
|
|
24
|
+
return reqPermissions => this.createHandler(cfg, reqPermissions)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Like `getMiddleware`, but 401 redirects to the login page.
|
|
29
|
+
*/
|
|
30
|
+
getLoginRedirectMiddleware(cfg: AuthLoginRedirectMiddlewareCfg<CTX>): AuthMiddleware {
|
|
31
|
+
const loginHref = this.getLoginHref(cfg)
|
|
32
|
+
return reqPermissions => this.createHandler(cfg, reqPermissions, loginHref)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
private createHandler(
|
|
36
|
+
cfg: AuthMiddlewareCfg<CTX>,
|
|
37
|
+
reqPermissions: string[] = [],
|
|
38
|
+
loginHref?: string,
|
|
39
|
+
): BackendRequestHandler {
|
|
40
|
+
const {
|
|
41
|
+
getResolved = req => this.resolve(req),
|
|
42
|
+
getCtx,
|
|
43
|
+
andComparison = true,
|
|
44
|
+
secureHeader,
|
|
45
|
+
} = cfg
|
|
46
|
+
|
|
47
|
+
return async (req, res, next) => {
|
|
48
|
+
if (!this.cfg.authEnabled) return next()
|
|
49
|
+
|
|
50
|
+
if (secureHeader) {
|
|
51
|
+
const providedHeader = req.get(secureHeader.name)
|
|
52
|
+
if (providedHeader) {
|
|
53
|
+
if (timingSafeStringEqual(providedHeader, secureHeader.value)) {
|
|
54
|
+
return next()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return next(
|
|
58
|
+
new AppError('secureHeader or authentication required', this.authRequiredData()),
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
const resolved = await getResolved(req)
|
|
65
|
+
if (!resolved?.subject && loginHref) {
|
|
66
|
+
res.status(401).send(this.getLoginHtmlRedirect(loginHref))
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
this.require(resolved, reqPermissions, { andComparison, ctx: getCtx?.(req) })
|
|
71
|
+
return next()
|
|
72
|
+
} catch (err) {
|
|
73
|
+
return next(err)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Runs `authenticate`, then `getPermissions` and `isAlwaysAuthorized` if there's a subject.
|
|
80
|
+
* Ignores `authEnabled`.
|
|
81
|
+
*/
|
|
82
|
+
async resolve(req: BackendRequest): Promise<ResolvedAuth> {
|
|
83
|
+
const { authenticate, getPermissions, isAlwaysAuthorized } = this.cfg
|
|
84
|
+
|
|
85
|
+
const subject = await authenticate(req)
|
|
86
|
+
if (!subject) return {}
|
|
87
|
+
|
|
88
|
+
const permissions = await getPermissions(subject, req)
|
|
89
|
+
const alwaysAuthorized = isAlwaysAuthorized
|
|
90
|
+
? await isAlwaysAuthorized(subject, permissions, req)
|
|
91
|
+
: false
|
|
92
|
+
|
|
93
|
+
return { subject, permissions, alwaysAuthorized }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Throws 401 if there's no subject, 403 if not granted.
|
|
98
|
+
* Returns the granted permissions (all of the subject's if alwaysAuthorized),
|
|
99
|
+
* or `{ subject: 'authDisabled', permissions: [] }` if auth is disabled.
|
|
100
|
+
*/
|
|
101
|
+
require(
|
|
102
|
+
resolved: ResolvedAuth | undefined,
|
|
103
|
+
reqPermissions: string[] = [],
|
|
104
|
+
opt: AuthCheckOptions<CTX> = {},
|
|
105
|
+
): PermissionInfo {
|
|
106
|
+
if (!this.cfg.authEnabled) return { subject: 'authDisabled', permissions: [] }
|
|
107
|
+
|
|
108
|
+
this.assertAuthenticated(resolved)
|
|
109
|
+
const { subject, permissions, alwaysAuthorized } = resolved
|
|
110
|
+
|
|
111
|
+
const grantedPermissions = this.checkSubject(resolved, reqPermissions, opt)
|
|
112
|
+
this.assertGranted(subject, grantedPermissions, reqPermissions)
|
|
113
|
+
|
|
114
|
+
if (alwaysAuthorized) {
|
|
115
|
+
return { subject, permissions: permissions?.slice() || [] }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return { subject, permissions: grantedPermissions.slice() }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private checkSubject(
|
|
122
|
+
resolved: AuthenticatedAuth,
|
|
123
|
+
reqPermissions: string[],
|
|
124
|
+
opt: AuthCheckOptions<CTX>,
|
|
125
|
+
): string[] | undefined {
|
|
126
|
+
const { subject, permissions, alwaysAuthorized = false } = resolved
|
|
127
|
+
const { andComparison = true, ctx } = opt
|
|
128
|
+
|
|
129
|
+
const grantedPermissions = alwaysAuthorized
|
|
130
|
+
? reqPermissions
|
|
131
|
+
: this.getGrantedPermissions(permissions, reqPermissions, andComparison)
|
|
132
|
+
|
|
133
|
+
this.cfg.onCheck?.(
|
|
134
|
+
{
|
|
135
|
+
subject,
|
|
136
|
+
granted: !!grantedPermissions,
|
|
137
|
+
alwaysAuthorized,
|
|
138
|
+
reqPermissions,
|
|
139
|
+
checkedPermissions: grantedPermissions || reqPermissions,
|
|
140
|
+
},
|
|
141
|
+
ctx,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
return grantedPermissions
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private assertAuthenticated(
|
|
148
|
+
resolved: ResolvedAuth | undefined,
|
|
149
|
+
): asserts resolved is AuthenticatedAuth {
|
|
150
|
+
_assert(resolved?.subject, 'authentication required', this.authRequiredData())
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private authRequiredData(): ErrorData {
|
|
154
|
+
return {
|
|
155
|
+
authRequired: true,
|
|
156
|
+
backendResponseStatusCode: 401,
|
|
157
|
+
userFriendly: true,
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private assertGranted(
|
|
162
|
+
subject: string,
|
|
163
|
+
grantedPermissions: string[] | undefined,
|
|
164
|
+
reqPermissions: string[],
|
|
165
|
+
): asserts grantedPermissions is string[] {
|
|
166
|
+
_assert(grantedPermissions, `Permissions required: [${reqPermissions.join(', ')}]`, {
|
|
167
|
+
permissionsRequired: reqPermissions,
|
|
168
|
+
subject,
|
|
169
|
+
backendResponseStatusCode: 403,
|
|
170
|
+
userFriendly: true,
|
|
171
|
+
})
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* undefined - not granted.
|
|
176
|
+
*/
|
|
177
|
+
private getGrantedPermissions(
|
|
178
|
+
permissions: string[] | undefined,
|
|
179
|
+
reqPermissions: string[],
|
|
180
|
+
andComparison: boolean,
|
|
181
|
+
): string[] | undefined {
|
|
182
|
+
if (!permissions) return
|
|
183
|
+
const grantedPermissions = reqPermissions.filter(p => permissions.includes(p))
|
|
184
|
+
const granted = andComparison
|
|
185
|
+
? grantedPermissions.length === reqPermissions.length
|
|
186
|
+
: grantedPermissions.length > 0
|
|
187
|
+
return granted ? grantedPermissions : undefined
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private getLoginHref(cfg: AuthLoginRedirectMiddlewareCfg<CTX>): string {
|
|
191
|
+
return `${cfg.loginHtmlPath}?autoLogin=1&returnUrl=\${encodeURIComponent(location.href)}`
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private getLoginHtmlRedirect(href: string): string {
|
|
195
|
+
return `
|
|
196
|
+
<html>
|
|
197
|
+
<body>401 Authentication Required
|
|
198
|
+
<script>
|
|
199
|
+
document.write(\`: <a href="${href}" id="loginLink">Login</a>\`)
|
|
200
|
+
document.getElementById('loginLink').click()
|
|
201
|
+
</script>
|
|
202
|
+
</body>
|
|
203
|
+
</html>
|
|
204
|
+
`
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export interface AuthServiceCfg<CTX> {
|
|
209
|
+
/**
|
|
210
|
+
* Returns the subject, or undefined if not authenticated.
|
|
211
|
+
*/
|
|
212
|
+
authenticate: (req: BackendRequest) => Promise<string | undefined>
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* undefined - no permission set (never granted).
|
|
216
|
+
*/
|
|
217
|
+
getPermissions: (subject: string, req: BackendRequest) => Promise<string[] | undefined>
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* true - every check is granted for this subject.
|
|
221
|
+
*/
|
|
222
|
+
isAlwaysAuthorized?: (
|
|
223
|
+
subject: string,
|
|
224
|
+
permissions: string[] | undefined,
|
|
225
|
+
req: BackendRequest,
|
|
226
|
+
) => Promisable<boolean>
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Called on every `require` check that has a subject, while auth is enabled. Must not throw.
|
|
230
|
+
*/
|
|
231
|
+
onCheck?: (check: AuthCheck, ctx: (CTX & AnyObject) | undefined) => void
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* false - every check is granted. For tests / local debugging. Read on every check.
|
|
235
|
+
*
|
|
236
|
+
* @default true
|
|
237
|
+
*/
|
|
238
|
+
authEnabled?: boolean
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export interface AuthCheck {
|
|
242
|
+
subject: string
|
|
243
|
+
granted: boolean
|
|
244
|
+
alwaysAuthorized: boolean
|
|
245
|
+
reqPermissions: string[]
|
|
246
|
+
/** The granted permissions, or reqPermissions if denied */
|
|
247
|
+
checkedPermissions: string[]
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export interface AuthLoginRedirectMiddlewareCfg<CTX> extends AuthMiddlewareCfg<CTX> {
|
|
251
|
+
loginHtmlPath: string
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export interface AuthMiddlewareCfg<CTX> {
|
|
255
|
+
/**
|
|
256
|
+
* Defaults to `resolve(req)`.
|
|
257
|
+
*/
|
|
258
|
+
getResolved?: (req: BackendRequest) => Promisable<ResolvedAuth | undefined>
|
|
259
|
+
getCtx?: (req: BackendRequest) => (CTX & AnyObject) | undefined
|
|
260
|
+
/**
|
|
261
|
+
* @default true
|
|
262
|
+
*/
|
|
263
|
+
andComparison?: boolean
|
|
264
|
+
/**
|
|
265
|
+
* A request with this header passes without auth. A wrong value responds 401.
|
|
266
|
+
*/
|
|
267
|
+
secureHeader?: SecureHeaderCfg
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export interface SecureHeaderCfg {
|
|
271
|
+
/** e.g. 'Authorization' */
|
|
272
|
+
name: string
|
|
273
|
+
value: string
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export type AuthMiddleware = (reqPermissions?: string[]) => BackendRequestHandler
|
|
277
|
+
|
|
278
|
+
export interface AuthCheckOptions<CTX> {
|
|
279
|
+
/**
|
|
280
|
+
* false - one granted permission is enough.
|
|
281
|
+
*
|
|
282
|
+
* @default true
|
|
283
|
+
*/
|
|
284
|
+
andComparison?: boolean
|
|
285
|
+
/** Passed to onCheck: the app's CTX, plus any extra keys */
|
|
286
|
+
ctx?: CTX & AnyObject
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export interface PermissionInfo {
|
|
290
|
+
subject: string
|
|
291
|
+
permissions: string[]
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
type AuthenticatedAuth = ResolvedAuth & { subject: string }
|
|
295
|
+
|
|
296
|
+
export interface ResolvedAuth {
|
|
297
|
+
/** undefined - not authenticated */
|
|
298
|
+
subject?: string
|
|
299
|
+
/** undefined - no permission set */
|
|
300
|
+
permissions?: string[]
|
|
301
|
+
/** Result of `isAlwaysAuthorized` */
|
|
302
|
+
alwaysAuthorized?: boolean
|
|
303
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './auth.service.js'
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { _anyToError, _isErrorObject } from '@naturalcycles/js-lib/error'
|
|
2
|
+
import { commonLoggerCreate } from '@naturalcycles/js-lib/log'
|
|
2
3
|
import type { CommonLogger, CommonLogLevel } from '@naturalcycles/js-lib/log'
|
|
4
|
+
import { _omit } from '@naturalcycles/js-lib/object/object.util.js'
|
|
3
5
|
import type { Primitive, StringMap } from '@naturalcycles/js-lib/types'
|
|
4
6
|
import type { InspectAnyOptions } from '@naturalcycles/nodejs-lib'
|
|
5
|
-
import { _inspect } from '@naturalcycles/nodejs-lib'
|
|
7
|
+
import { _inspect, logEntryToString } from '@naturalcycles/nodejs-lib'
|
|
6
8
|
import type { Breadcrumb, SeverityLevel } from '@sentry/node-core/light'
|
|
7
9
|
import type * as SentryLib from '@sentry/node-core/light'
|
|
8
10
|
import { getRequestLogger } from '../server/asyncLocalStorageMiddleware.js'
|
|
@@ -78,7 +80,7 @@ export class SentrySharedService {
|
|
|
78
80
|
|
|
79
81
|
// Using request-aware logger here
|
|
80
82
|
// Log both the error and attached ErrorData (if any)
|
|
81
|
-
getRequestLogger().error(
|
|
83
|
+
getRequestLogger().error({ err: err_, ...data })
|
|
82
84
|
|
|
83
85
|
if (data?.report === false) {
|
|
84
86
|
// Skip reporting the error
|
|
@@ -106,7 +108,7 @@ export class SentrySharedService {
|
|
|
106
108
|
* Returns "eventId"
|
|
107
109
|
*/
|
|
108
110
|
captureMessage(msg: string, level?: SeverityLevel): string {
|
|
109
|
-
getRequestLogger()[sentrySeverityMap[level!] || 'log'](
|
|
111
|
+
getRequestLogger()[sentrySeverityMap[level!] || 'log'](`captureMessage: ${msg}`)
|
|
110
112
|
return this.sentry.captureMessage(msg, level)
|
|
111
113
|
}
|
|
112
114
|
|
|
@@ -122,19 +124,15 @@ export class SentrySharedService {
|
|
|
122
124
|
* @experimental
|
|
123
125
|
*/
|
|
124
126
|
getCommonLogger(): CommonLogger {
|
|
125
|
-
return {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
this.sentry.captureException(_anyToError(args.length === 1 ? args[0] : args))
|
|
137
|
-
},
|
|
138
|
-
}
|
|
127
|
+
return commonLoggerCreate(entry => {
|
|
128
|
+
if (entry.level !== 'error') return // noop
|
|
129
|
+
const { msg, err } = entry
|
|
130
|
+
|
|
131
|
+
this.sentry.addBreadcrumb({
|
|
132
|
+
message: logEntryToString(entry, INSPECT_OPT),
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
this.sentry.captureException(_anyToError(err ?? msg ?? _omit(entry, ['level'])))
|
|
136
|
+
})
|
|
139
137
|
}
|
|
140
138
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
2
2
|
import { _lazyValue } from '@naturalcycles/js-lib'
|
|
3
|
+
import { commonLoggerCreate } from '@naturalcycles/js-lib/log'
|
|
3
4
|
import type { CommonLogger } from '@naturalcycles/js-lib/log'
|
|
4
5
|
import { ciLogger, devLogger, gcpStructuredLogger } from './logMiddleware.js'
|
|
5
6
|
import type { BackendRequest, BackendRequestHandler } from './server.model.js'
|
|
@@ -53,9 +54,6 @@ export function getRequestLogger(): CommonLogger {
|
|
|
53
54
|
*
|
|
54
55
|
* @experimental
|
|
55
56
|
*/
|
|
56
|
-
export const requestLogger: CommonLogger = {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
warn: (...args) => getRequestLogger().warn(...args),
|
|
60
|
-
error: (...args) => getRequestLogger().error(...args),
|
|
61
|
-
}
|
|
57
|
+
export const requestLogger: CommonLogger = commonLoggerCreate(({ level, ...data }) =>
|
|
58
|
+
getRequestLogger()[level](data),
|
|
59
|
+
)
|
|
@@ -75,9 +75,9 @@ export function respondWithError(req: BackendRequest, res: BackendResponse, err:
|
|
|
75
75
|
// because errorService was not provided - we are going to log the error here
|
|
76
76
|
|
|
77
77
|
if (headersSent) {
|
|
78
|
-
req.error(`error after headersSent:`, err)
|
|
78
|
+
req.error({ msg: `error after headersSent:`, err })
|
|
79
79
|
} else {
|
|
80
|
-
req.error(err)
|
|
80
|
+
req.error({ err })
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
// todo: add endpoint to the log
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { inspect } from 'node:util'
|
|
2
|
-
import { _isPlainObject } from '@naturalcycles/js-lib'
|
|
3
2
|
import { _anyToErrorObject } from '@naturalcycles/js-lib/error'
|
|
4
|
-
import
|
|
3
|
+
import { commonLoggerCreate } from '@naturalcycles/js-lib/log'
|
|
4
|
+
import type { CommonLogger, CommonLogLevel, LogEntry } from '@naturalcycles/js-lib/log'
|
|
5
5
|
import { _safeJsonStringify } from '@naturalcycles/js-lib/string/safeJsonStringify.js'
|
|
6
6
|
import { _objectAssign } from '@naturalcycles/js-lib/types'
|
|
7
7
|
import type { AnyObject } from '@naturalcycles/js-lib/types'
|
|
8
|
-
import {
|
|
8
|
+
import { logEntryToString } from '@naturalcycles/nodejs-lib'
|
|
9
9
|
import { dimGrey } from '@naturalcycles/nodejs-lib/colors'
|
|
10
10
|
import type { BackendRequestHandler } from './server.model.js'
|
|
11
11
|
|
|
@@ -22,85 +22,54 @@ let reqCounter = 0
|
|
|
22
22
|
* Logger that logs in "GCP structured log" format.
|
|
23
23
|
* To be used in outside-of-request situations (otherwise req.log should be used).
|
|
24
24
|
*/
|
|
25
|
-
export const gcpStructuredLogger: CommonLogger =
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
warn: (...args) => writeGCPStructuredLog({ severity: 'WARNING' }, args),
|
|
29
|
-
error: (...args) => writeGCPStructuredLog({ severity: 'ERROR' }, args),
|
|
30
|
-
}
|
|
25
|
+
export const gcpStructuredLogger: CommonLogger = commonLoggerCreate(entry =>
|
|
26
|
+
writeGCPStructuredLog({}, entry),
|
|
27
|
+
)
|
|
31
28
|
|
|
32
29
|
/**
|
|
33
30
|
* Fancy development logger, to be used in outside-of-request situations
|
|
34
31
|
* (otherwise req.log should be used).
|
|
35
32
|
*/
|
|
36
|
-
export const devLogger: CommonLogger =
|
|
37
|
-
debug: (...args) => logToDev(null, args),
|
|
38
|
-
log: (...args) => logToDev(null, args),
|
|
39
|
-
warn: (...args) => logToDev(null, args),
|
|
40
|
-
error: (...args) => logToDev(null, args),
|
|
41
|
-
}
|
|
33
|
+
export const devLogger: CommonLogger = commonLoggerCreate(entry => logToDev(null, entry))
|
|
42
34
|
|
|
43
35
|
/**
|
|
44
36
|
* Same as devLogger, but without colors (e.g to not confuse Sentry).
|
|
45
37
|
*/
|
|
46
|
-
export const ciLogger: CommonLogger =
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
38
|
+
export const ciLogger: CommonLogger = commonLoggerCreate(logToCI)
|
|
39
|
+
|
|
40
|
+
const gcpSeverityByLevel: Record<CommonLogLevel, string> = {
|
|
41
|
+
debug: 'DEBUG',
|
|
42
|
+
log: 'INFO',
|
|
43
|
+
warn: 'WARNING',
|
|
44
|
+
error: 'ERROR',
|
|
51
45
|
}
|
|
52
46
|
|
|
53
47
|
// Documented here: https://cloud.google.com/logging/docs/structured-logging
|
|
54
48
|
// Cloud Run logging: https://cloud.google.com/run/docs/logging
|
|
55
|
-
function writeGCPStructuredLog(meta: AnyObject,
|
|
56
|
-
|
|
57
|
-
console.log(
|
|
58
|
-
JSON.stringify({
|
|
59
|
-
message: args.map(a => (typeof a === 'string' ? a : inspect(a))).join(' '),
|
|
60
|
-
...meta,
|
|
61
|
-
}),
|
|
62
|
-
)
|
|
63
|
-
return
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
const { msg, err, ...entry } = args[0]
|
|
49
|
+
function writeGCPStructuredLog(meta: AnyObject, entry: LogEntry): void {
|
|
50
|
+
const { level, msg, err, ...fields } = entry
|
|
67
51
|
if (err instanceof Error) {
|
|
68
|
-
|
|
69
|
-
|
|
52
|
+
fields['err'] = _anyToErrorObject(err)
|
|
53
|
+
fields['message'] = msg ? `${msg}\n${inspect(err)}` : inspect(err)
|
|
70
54
|
} else {
|
|
71
|
-
if (err !== undefined)
|
|
72
|
-
if (msg !== undefined)
|
|
55
|
+
if (err !== undefined) fields['err'] = err
|
|
56
|
+
if (msg !== undefined) fields['message'] = msg
|
|
73
57
|
}
|
|
74
|
-
console.log(_safeJsonStringify({ ...
|
|
58
|
+
console.log(_safeJsonStringify({ ...fields, ...meta, severity: gcpSeverityByLevel[level] }))
|
|
75
59
|
}
|
|
76
60
|
|
|
77
|
-
function logToDev(requestId: string | null,
|
|
61
|
+
function logToDev(requestId: string | null, entry: LogEntry): void {
|
|
78
62
|
// Run on local machine
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
[
|
|
82
|
-
requestId ? [dimGrey(`[${requestId}]`)] : [],
|
|
83
|
-
...args.map(a => _inspect(a, { includeErrorStack: true, colors: true })),
|
|
84
|
-
].join(' '),
|
|
85
|
-
)
|
|
86
|
-
return
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
const { msg, err, ...rest } = args[0]
|
|
90
|
-
const parts: string[] = []
|
|
91
|
-
if (requestId) parts.push(dimGrey(`[${requestId}]`))
|
|
92
|
-
if (msg !== undefined) parts.push(String(msg))
|
|
93
|
-
if (err !== undefined) parts.push(_inspect(err, { includeErrorStack: true, colors: true }))
|
|
94
|
-
if (Object.keys(rest).length) parts.push(dimGrey(_inspect(rest, { colors: false })))
|
|
95
|
-
console.log(parts.join(' '))
|
|
63
|
+
const line = logEntryToString(entry, { colors: true })
|
|
64
|
+
console.log(requestId ? `${dimGrey(`[${requestId}]`)} ${line}` : line)
|
|
96
65
|
}
|
|
97
66
|
|
|
98
67
|
/**
|
|
99
68
|
* Same as logToDev, but without request and without colors.
|
|
100
69
|
* This is to not confuse e.g Sentry when it picks up messages with colors
|
|
101
70
|
*/
|
|
102
|
-
function logToCI(
|
|
103
|
-
console.log(
|
|
71
|
+
function logToCI(entry: LogEntry): void {
|
|
72
|
+
console.log(logEntryToString(entry, { colors: false }))
|
|
104
73
|
}
|
|
105
74
|
|
|
106
75
|
export function logMiddleware(): BackendRequestHandler {
|
|
@@ -130,12 +99,10 @@ export function logMiddleware(): BackendRequestHandler {
|
|
|
130
99
|
meta['appengine.googleapis.com/request_id'] = req.header('x-appengine-request-log-id')
|
|
131
100
|
}
|
|
132
101
|
|
|
133
|
-
_objectAssign(
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
error: (...args: any[]) => writeGCPStructuredLog({ ...meta, severity: 'ERROR' }, args),
|
|
138
|
-
} satisfies CommonLogger)
|
|
102
|
+
_objectAssign(
|
|
103
|
+
req,
|
|
104
|
+
commonLoggerCreate(entry => writeGCPStructuredLog(meta, entry)),
|
|
105
|
+
)
|
|
139
106
|
|
|
140
107
|
next()
|
|
141
108
|
}
|
|
@@ -146,11 +113,10 @@ export function logMiddleware(): BackendRequestHandler {
|
|
|
146
113
|
return function devLogHandler(req, _res, next) {
|
|
147
114
|
// Local machine
|
|
148
115
|
req.requestId = String(++reqCounter)
|
|
149
|
-
|
|
150
|
-
req
|
|
151
|
-
req.
|
|
152
|
-
|
|
153
|
-
(...args: any[]) => logToDev(req.requestId!, args)
|
|
116
|
+
_objectAssign(
|
|
117
|
+
req,
|
|
118
|
+
commonLoggerCreate(entry => logToDev(req.requestId!, entry)),
|
|
119
|
+
)
|
|
154
120
|
next()
|
|
155
121
|
}
|
|
156
122
|
}
|
|
@@ -158,7 +124,7 @@ export function logMiddleware(): BackendRequestHandler {
|
|
|
158
124
|
// Otherwise, return "simple" logger
|
|
159
125
|
// This includes: unit tests, CI environments
|
|
160
126
|
return function simpleLogHandler(req, _res, next) {
|
|
161
|
-
req
|
|
127
|
+
_objectAssign(req, ciLogger)
|
|
162
128
|
next()
|
|
163
129
|
}
|
|
164
130
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { CommonLogger } from '@naturalcycles/js-lib/log'
|
|
2
2
|
import type { Promisable } from '@naturalcycles/js-lib/types'
|
|
3
3
|
import type { Application, IRouter, NextFunction, Request, Response } from 'express'
|
|
4
4
|
|
|
@@ -9,12 +9,7 @@ import type { Application, IRouter, NextFunction, Request, Response } from 'expr
|
|
|
9
9
|
* `BackendRequest` seems to not conflict with anything right now.
|
|
10
10
|
* Previous name `ExpressRequest` was clashing with Sentry.
|
|
11
11
|
*/
|
|
12
|
-
export interface BackendRequest<BODY = unknown> extends Request {
|
|
13
|
-
debug: CommonLogFunction
|
|
14
|
-
log: CommonLogFunction
|
|
15
|
-
warn: CommonLogFunction
|
|
16
|
-
error: CommonLogFunction
|
|
17
|
-
|
|
12
|
+
export interface BackendRequest<BODY = unknown> extends Request, CommonLogger {
|
|
18
13
|
requestId?: string
|
|
19
14
|
/**
|
|
20
15
|
* Only used for request logging purposes.
|
|
@@ -71,12 +66,7 @@ export type BackendRouter = IRouter
|
|
|
71
66
|
export type BackendApplication = Application
|
|
72
67
|
|
|
73
68
|
declare module 'http' {
|
|
74
|
-
interface IncomingMessage {
|
|
75
|
-
debug: CommonLogFunction
|
|
76
|
-
log: CommonLogFunction
|
|
77
|
-
warn: CommonLogFunction
|
|
78
|
-
error: CommonLogFunction
|
|
79
|
-
|
|
69
|
+
interface IncomingMessage extends CommonLogger {
|
|
80
70
|
requestId?: string
|
|
81
71
|
|
|
82
72
|
requestTimeout?: NodeJS.Timeout
|