@heavybit/shared-lib 1.3.1 → 2.1.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/README.md +100 -0
- package/dist/cjs/common/errors.js +44 -5
- package/dist/cjs/common/errors.js.map +1 -1
- package/dist/cjs/common/phone.js +43 -10
- package/dist/cjs/common/phone.js.map +1 -1
- package/dist/cjs/common/response.js +78 -5
- package/dist/cjs/common/response.js.map +1 -1
- package/dist/cjs/config/index.js +1 -1
- package/dist/cjs/config/index.js.map +1 -1
- package/dist/cjs/context/index.js +49 -0
- package/dist/cjs/context/index.js.map +1 -0
- package/dist/cjs/contracts/index.js +0 -2
- package/dist/cjs/contracts/index.js.map +1 -1
- package/dist/cjs/contracts/notification-dispatch.js +87 -63
- package/dist/cjs/contracts/notification-dispatch.js.map +1 -1
- package/dist/cjs/express/auth.js +44 -11
- package/dist/cjs/express/auth.js.map +1 -1
- package/dist/cjs/express/index.js +1 -0
- package/dist/cjs/express/index.js.map +1 -1
- package/dist/cjs/express/middleware.js +154 -34
- package/dist/cjs/express/middleware.js.map +1 -1
- package/dist/cjs/express/respond.js +47 -0
- package/dist/cjs/express/respond.js.map +1 -0
- package/dist/cjs/index.js +1 -0
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/logging/index.js +76 -40
- package/dist/cjs/logging/index.js.map +1 -1
- package/dist/cjs/logging/redact.js +117 -0
- package/dist/cjs/logging/redact.js.map +1 -0
- package/dist/cjs/messaging/index.js +1 -0
- package/dist/cjs/messaging/index.js.map +1 -1
- package/dist/cjs/messaging/staff-notifier.js +36 -44
- package/dist/cjs/messaging/staff-notifier.js.map +1 -1
- package/dist/cjs/messaging/staff-recipients.js +57 -0
- package/dist/cjs/messaging/staff-recipients.js.map +1 -0
- package/dist/cjs/validation/index.js +27 -3
- package/dist/cjs/validation/index.js.map +1 -1
- package/dist/esm/common/errors.js +42 -4
- package/dist/esm/common/errors.js.map +1 -1
- package/dist/esm/common/phone.js +43 -10
- package/dist/esm/common/phone.js.map +1 -1
- package/dist/esm/common/response.js +75 -5
- package/dist/esm/common/response.js.map +1 -1
- package/dist/esm/config/index.js +1 -1
- package/dist/esm/config/index.js.map +1 -1
- package/dist/esm/context/index.js +44 -0
- package/dist/esm/context/index.js.map +1 -0
- package/dist/esm/contracts/index.js +0 -2
- package/dist/esm/contracts/index.js.map +1 -1
- package/dist/esm/contracts/notification-dispatch.js +85 -62
- package/dist/esm/contracts/notification-dispatch.js.map +1 -1
- package/dist/esm/express/auth.js +45 -12
- package/dist/esm/express/auth.js.map +1 -1
- package/dist/esm/express/index.js +1 -0
- package/dist/esm/express/index.js.map +1 -1
- package/dist/esm/express/middleware.js +146 -27
- package/dist/esm/express/middleware.js.map +1 -1
- package/dist/esm/express/respond.js +42 -0
- package/dist/esm/express/respond.js.map +1 -0
- package/dist/esm/index.js +1 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/logging/index.js +68 -39
- package/dist/esm/logging/index.js.map +1 -1
- package/dist/esm/logging/redact.js +110 -0
- package/dist/esm/logging/redact.js.map +1 -0
- package/dist/esm/messaging/index.js +1 -0
- package/dist/esm/messaging/index.js.map +1 -1
- package/dist/esm/messaging/staff-notifier.js +36 -43
- package/dist/esm/messaging/staff-notifier.js.map +1 -1
- package/dist/esm/messaging/staff-recipients.js +53 -0
- package/dist/esm/messaging/staff-recipients.js.map +1 -0
- package/dist/esm/validation/index.js +27 -2
- package/dist/esm/validation/index.js.map +1 -1
- package/dist/types/alerting/index.d.ts +1 -1
- package/dist/types/alerting/index.d.ts.map +1 -1
- package/dist/types/cache/index.d.ts +1 -1
- package/dist/types/cache/index.d.ts.map +1 -1
- package/dist/types/common/errors.d.ts +48 -7
- package/dist/types/common/errors.d.ts.map +1 -1
- package/dist/types/common/phone.d.ts +35 -6
- package/dist/types/common/phone.d.ts.map +1 -1
- package/dist/types/common/response.d.ts +66 -5
- package/dist/types/common/response.d.ts.map +1 -1
- package/dist/types/config/index.d.ts +1 -1
- package/dist/types/config/index.d.ts.map +1 -1
- package/dist/types/context/index.d.ts +39 -0
- package/dist/types/context/index.d.ts.map +1 -0
- package/dist/types/contracts/index.d.ts +0 -2
- package/dist/types/contracts/index.d.ts.map +1 -1
- package/dist/types/contracts/notification-dispatch.d.ts +208 -90
- package/dist/types/contracts/notification-dispatch.d.ts.map +1 -1
- package/dist/types/express/auth.d.ts +42 -6
- package/dist/types/express/auth.d.ts.map +1 -1
- package/dist/types/express/index.d.ts +1 -0
- package/dist/types/express/index.d.ts.map +1 -1
- package/dist/types/express/middleware.d.ts +86 -7
- package/dist/types/express/middleware.d.ts.map +1 -1
- package/dist/types/express/respond.d.ts +41 -0
- package/dist/types/express/respond.d.ts.map +1 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/logging/index.d.ts +30 -4
- package/dist/types/logging/index.d.ts.map +1 -1
- package/dist/types/logging/redact.d.ts +54 -0
- package/dist/types/logging/redact.d.ts.map +1 -0
- package/dist/types/messaging/index.d.ts +1 -0
- package/dist/types/messaging/index.d.ts.map +1 -1
- package/dist/types/messaging/staff-notifier.d.ts +67 -36
- package/dist/types/messaging/staff-notifier.d.ts.map +1 -1
- package/dist/types/messaging/staff-recipients.d.ts +50 -0
- package/dist/types/messaging/staff-recipients.d.ts.map +1 -0
- package/dist/types/types/index.d.ts +36 -1
- package/dist/types/types/index.d.ts.map +1 -1
- package/dist/types/validation/index.d.ts +33 -2
- package/dist/types/validation/index.d.ts.map +1 -1
- package/package.json +2 -2
- package/dist/cjs/contracts/editorial-draft.js +0 -120
- package/dist/cjs/contracts/editorial-draft.js.map +0 -1
- package/dist/cjs/contracts/upload-import.js +0 -54
- package/dist/cjs/contracts/upload-import.js.map +0 -1
- package/dist/esm/contracts/editorial-draft.js +0 -117
- package/dist/esm/contracts/editorial-draft.js.map +0 -1
- package/dist/esm/contracts/upload-import.js +0 -51
- package/dist/esm/contracts/upload-import.js.map +0 -1
- package/dist/types/contracts/editorial-draft.d.ts +0 -610
- package/dist/types/contracts/editorial-draft.d.ts.map +0 -1
- package/dist/types/contracts/upload-import.d.ts +0 -96
- package/dist/types/contracts/upload-import.d.ts.map +0 -1
|
@@ -1,89 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notification dispatch: a generic command contract (shared plumbing, no app vocabulary).
|
|
3
|
+
*
|
|
4
|
+
* Services never send email, SMS or push themselves. They stage one dispatch command (default
|
|
5
|
+
* `notification:dispatch`) through their outbox, and the app's notification-service renders a seeded
|
|
6
|
+
* template and delivers it. Both sides build their schema from {@link createNotificationDispatchSchema},
|
|
7
|
+
* so producers and the consumer cannot drift.
|
|
8
|
+
*
|
|
9
|
+
* App-specific parts are parameters: the app passes its own categories (e.g. `security`, `order`) and
|
|
10
|
+
* channels (e.g. `email`, `sms`, `in_app`). Template slugs and their variables also belong to the app
|
|
11
|
+
* (keep a table next to the notification-service seeds and test both sides against it).
|
|
12
|
+
*/
|
|
1
13
|
import { z } from 'zod';
|
|
2
14
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* and notification-service validates against it, so the two cannot drift.
|
|
15
|
+
* Default command name for a dispatch. An app may use another name and pass it to its producers.
|
|
16
|
+
*
|
|
17
|
+
* @example await outbox.command(tx, NOTIFICATION_DISPATCH, dispatch, { correlationId });
|
|
7
18
|
*/
|
|
8
19
|
export const NOTIFICATION_DISPATCH = 'notification:dispatch';
|
|
9
|
-
/** Channels a dispatch may ask for. `in_app` needs a recipient `userId` (the bell feed is per user). */
|
|
10
|
-
export const DISPATCH_CHANNELS = ['email', 'in_app'];
|
|
11
20
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
21
|
+
* Size limits that keep a dispatch small enough for a queue message and safe to render.
|
|
22
|
+
*
|
|
23
|
+
* @example z.string().max(DISPATCH_LIMITS.template)
|
|
14
24
|
*/
|
|
15
|
-
export const NOTIFICATION_CATEGORIES = ['security', 'account', 'team', 'marketing', 'staff'];
|
|
16
25
|
export const DISPATCH_LIMITS = {
|
|
17
26
|
template: 100,
|
|
18
27
|
email: 254,
|
|
19
28
|
name: 200,
|
|
29
|
+
userId: 36,
|
|
20
30
|
dataKeys: 40,
|
|
21
31
|
dataKey: 64,
|
|
22
32
|
dataValue: 2000,
|
|
23
33
|
secretKeys: 10,
|
|
24
34
|
permission: 100,
|
|
25
35
|
};
|
|
26
|
-
/**
|
|
36
|
+
/**
|
|
37
|
+
* Template variables are flat scalars: they are printed into the copy, never serialised objects.
|
|
38
|
+
*
|
|
39
|
+
* @example dispatchValueSchema.parse('1250.00')
|
|
40
|
+
*/
|
|
27
41
|
export const dispatchValueSchema = z.union([z.string().max(DISPATCH_LIMITS.dataValue), z.number().finite(), z.boolean(), z.null()]);
|
|
42
|
+
/**
|
|
43
|
+
* Who receives a dispatch. `userId` is needed for per-user channels such as an in-app feed;
|
|
44
|
+
* addresses with no account behind them carry only `email`.
|
|
45
|
+
*
|
|
46
|
+
* @example { email: 'wanjiru@example.com', name: 'Wanjiru', userId: '01J…', timezone: 'Africa/Nairobi' }
|
|
47
|
+
*/
|
|
28
48
|
export const dispatchRecipientSchema = z.object({
|
|
29
49
|
email: z.string().trim().email().max(DISPATCH_LIMITS.email),
|
|
30
50
|
name: z.string().trim().max(DISPATCH_LIMITS.name).optional(),
|
|
31
|
-
userId: z.string().max(
|
|
51
|
+
userId: z.string().max(DISPATCH_LIMITS.userId).nullish(),
|
|
32
52
|
locale: z.string().max(20).nullish(),
|
|
33
53
|
/** IANA zone; time variables are rendered in it. */
|
|
34
54
|
timezone: z.string().max(64).nullish(),
|
|
35
55
|
});
|
|
36
|
-
const dataKey = z.string().regex(/^[A-Za-z][A-Za-z0-9_]*$/, 'Use a template variable name').max(DISPATCH_LIMITS.dataKey);
|
|
37
|
-
export const notificationDispatchSchema = z
|
|
38
|
-
.object({
|
|
39
|
-
/** Seeded `notification_templates.slug`. */
|
|
40
|
-
template: z.string().trim().min(1).max(DISPATCH_LIMITS.template),
|
|
41
|
-
category: z.enum(NOTIFICATION_CATEGORIES),
|
|
42
|
-
recipient: dispatchRecipientSchema,
|
|
43
|
-
channels: z.array(z.enum(DISPATCH_CHANNELS)).min(1).max(DISPATCH_CHANNELS.length),
|
|
44
|
-
data: z
|
|
45
|
-
.record(dataKey, dispatchValueSchema)
|
|
46
|
-
.default({})
|
|
47
|
-
.refine((data) => Object.keys(data).length <= DISPATCH_LIMITS.dataKeys, `At most ${DISPATCH_LIMITS.dataKeys} variables`),
|
|
48
|
-
/**
|
|
49
|
-
* Keys of `data` holding a one-time secret (a sign-in code, a reset or confirm link). They are used to
|
|
50
|
-
* render the email and nothing else: notification-service never stores them and never puts them in the
|
|
51
|
-
* in-app feed, and email-service masks them in what it keeps.
|
|
52
|
-
*/
|
|
53
|
-
secretKeys: z.array(dataKey).max(DISPATCH_LIMITS.secretKeys).default([]),
|
|
54
|
-
/** Security and staff alerts are always delivered, whatever the recipient's preferences. */
|
|
55
|
-
bypassPreferences: z.boolean().optional(),
|
|
56
|
-
/** The permission staff recipients hold; informational until recipients are resolved by permission. */
|
|
57
|
-
targetPermission: z.string().max(DISPATCH_LIMITS.permission).optional(),
|
|
58
|
-
})
|
|
59
|
-
.superRefine((dispatch, ctx) => {
|
|
60
|
-
for (const key of dispatch.secretKeys) {
|
|
61
|
-
if (!(key in dispatch.data)) {
|
|
62
|
-
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['secretKeys'], message: `Secret key "${key}" is not in data` });
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
});
|
|
66
56
|
/**
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
57
|
+
* A template variable name the renderer can match.
|
|
58
|
+
*
|
|
59
|
+
* @example dispatchDataKeySchema.parse('expiresInMinutes')
|
|
70
60
|
*/
|
|
71
|
-
export const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
};
|
|
61
|
+
export const dispatchDataKeySchema = z.string().regex(/^[A-Za-z][A-Za-z0-9_]*$/, 'Use a template variable name').max(DISPATCH_LIMITS.dataKey);
|
|
62
|
+
/**
|
|
63
|
+
* Build the dispatch schema for an app's categories and channels. Producers type their payload with it;
|
|
64
|
+
* the notification-service validates incoming commands with the same schema.
|
|
65
|
+
*
|
|
66
|
+
* Rules every app gets: a seeded `template` slug; one or more distinct channels; at most 40 flat-scalar `data`
|
|
67
|
+
* variables with renderer-safe names; `secretKeys` (one-time codes, reset links) must be keys of `data`;
|
|
68
|
+
* the consumer must use them only to render, never store them or show them in a feed.
|
|
69
|
+
*
|
|
70
|
+
* @typeParam C The app's categories (inferred as literals).
|
|
71
|
+
* @typeParam Ch The app's channels (inferred as literals).
|
|
72
|
+
* @param vocabulary The app's categories and channels.
|
|
73
|
+
* @returns A Zod schema; `data` defaults to `{}` and `secretKeys` to `[]`.
|
|
74
|
+
* @example
|
|
75
|
+
* export const dispatchSchema = createNotificationDispatchSchema({
|
|
76
|
+
* categories: ['security', 'account', 'order', 'staff'],
|
|
77
|
+
* channels: ['email', 'sms'],
|
|
78
|
+
* });
|
|
79
|
+
* export type Dispatch = z.input<typeof dispatchSchema>;
|
|
80
|
+
*/
|
|
81
|
+
export function createNotificationDispatchSchema(vocabulary) {
|
|
82
|
+
return z
|
|
83
|
+
.object({
|
|
84
|
+
/** Seeded template slug in the notification-service. */
|
|
85
|
+
template: z.string().trim().min(1).max(DISPATCH_LIMITS.template),
|
|
86
|
+
category: z.enum([...vocabulary.categories]),
|
|
87
|
+
recipient: dispatchRecipientSchema,
|
|
88
|
+
channels: z
|
|
89
|
+
.array(z.enum([...vocabulary.channels]))
|
|
90
|
+
.min(1)
|
|
91
|
+
.max(vocabulary.channels.length)
|
|
92
|
+
.refine((channels) => new Set(channels).size === channels.length, 'Each channel at most once'),
|
|
93
|
+
data: z
|
|
94
|
+
.record(dispatchDataKeySchema, dispatchValueSchema)
|
|
95
|
+
.default({})
|
|
96
|
+
.refine((data) => Object.keys(data).length <= DISPATCH_LIMITS.dataKeys, `At most ${DISPATCH_LIMITS.dataKeys} variables`),
|
|
97
|
+
/** Keys of `data` holding a one-time secret: used to render, never stored or shown in a feed. */
|
|
98
|
+
secretKeys: z.array(dispatchDataKeySchema).max(DISPATCH_LIMITS.secretKeys).default([]),
|
|
99
|
+
/** Deliver whatever the recipient's notification preferences say (for mail the recipient cannot opt out of). */
|
|
100
|
+
bypassPreferences: z.boolean().optional(),
|
|
101
|
+
/** The permission the recipients were resolved by, when they were (traceability only). */
|
|
102
|
+
targetPermission: z.string().max(DISPATCH_LIMITS.permission).optional(),
|
|
103
|
+
})
|
|
104
|
+
.superRefine((dispatch, ctx) => {
|
|
105
|
+
for (const key of dispatch.secretKeys) {
|
|
106
|
+
if (!(key in dispatch.data)) {
|
|
107
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['secretKeys'], message: `Secret key "${key}" is not in data` });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
}
|
|
89
112
|
//# sourceMappingURL=notification-dispatch.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"notification-dispatch.js","sourceRoot":"","sources":["../../../src/contracts/notification-dispatch.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB
|
|
1
|
+
{"version":3,"file":"notification-dispatch.js","sourceRoot":"","sources":["../../../src/contracts/notification-dispatch.ts"],"names":[],"mappings":"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"}
|
package/dist/esm/express/auth.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { hasAllPermissions, hasPermission, hasRole } from '../access/index.js';
|
|
2
2
|
import { safeEqual } from '../common/crypto.js';
|
|
3
|
-
import {
|
|
3
|
+
import { errorBody } from '../common/response.js';
|
|
4
|
+
import { correlationId } from './middleware.js';
|
|
4
5
|
import { CONTEXT_HEADERS, verifyIdentityHeaders } from '../identity/index.js';
|
|
5
6
|
function resolveIdentity(req, options) {
|
|
6
7
|
const result = verifyIdentityHeaders(req.headers, options.secret, { maxSkewMs: options.maxSkewMs });
|
|
@@ -14,19 +15,32 @@ function resolveIdentity(req, options) {
|
|
|
14
15
|
}
|
|
15
16
|
return 'anonymous';
|
|
16
17
|
}
|
|
17
|
-
/**
|
|
18
|
+
/**
|
|
19
|
+
* Requires a valid gateway-signed identity and sets `req.user`; 401 UNAUTHENTICATED (with `requestId`) otherwise.
|
|
20
|
+
*
|
|
21
|
+
* @param options Secret, skew window and logger.
|
|
22
|
+
* @returns Express middleware.
|
|
23
|
+
* @example router.use(authenticateFromGateway({ secret: config.gatewaySecret, logger }));
|
|
24
|
+
*/
|
|
18
25
|
export function authenticateFromGateway(options) {
|
|
19
26
|
return (req, res, next) => {
|
|
20
27
|
if (resolveIdentity(req, options) === 'ok')
|
|
21
28
|
return next();
|
|
22
|
-
res.status(401).json(
|
|
29
|
+
res.status(401).json(errorBody('UNAUTHENTICATED', 'Authentication required', { requestId: correlationId(req) }));
|
|
23
30
|
};
|
|
24
31
|
}
|
|
25
|
-
/**
|
|
32
|
+
/**
|
|
33
|
+
* Attaches `req.user` when a valid identity is present and continues anonymously when none is sent.
|
|
34
|
+
* A forged or stale identity is still rejected with 401, never downgraded to anonymous.
|
|
35
|
+
*
|
|
36
|
+
* @param options Secret, skew window and logger.
|
|
37
|
+
* @returns Express middleware.
|
|
38
|
+
* @example router.get('/products', optionalAuthFromGateway({ secret }), controller.list);
|
|
39
|
+
*/
|
|
26
40
|
export function optionalAuthFromGateway(options) {
|
|
27
41
|
return (req, res, next) => {
|
|
28
42
|
if (resolveIdentity(req, options) === 'invalid') {
|
|
29
|
-
res.status(401).json(
|
|
43
|
+
res.status(401).json(errorBody('UNAUTHENTICATED', 'Invalid identity', { requestId: correlationId(req) }));
|
|
30
44
|
return;
|
|
31
45
|
}
|
|
32
46
|
next();
|
|
@@ -35,29 +49,48 @@ export function optionalAuthFromGateway(options) {
|
|
|
35
49
|
function guard(check, message = 'You do not have permission to perform this action') {
|
|
36
50
|
return (req, res, next) => {
|
|
37
51
|
if (!req.user) {
|
|
38
|
-
res.status(401).json(
|
|
52
|
+
res.status(401).json(errorBody('UNAUTHENTICATED', 'Authentication required', { requestId: correlationId(req) }));
|
|
39
53
|
return;
|
|
40
54
|
}
|
|
41
55
|
if (!check(req.user)) {
|
|
42
|
-
res.status(403).json(
|
|
56
|
+
res.status(403).json(errorBody('FORBIDDEN', message, { requestId: correlationId(req) }));
|
|
43
57
|
return;
|
|
44
58
|
}
|
|
45
59
|
next();
|
|
46
60
|
};
|
|
47
61
|
}
|
|
48
|
-
/**
|
|
62
|
+
/**
|
|
63
|
+
* Allows callers holding any of `permissions` (401 without identity, 403 FORBIDDEN otherwise).
|
|
64
|
+
*
|
|
65
|
+
* @example router.post('/refunds', requirePermission('refunds.create'), controller.create);
|
|
66
|
+
*/
|
|
49
67
|
export const requirePermission = (...permissions) => guard((u) => hasPermission(u, ...permissions));
|
|
50
|
-
/**
|
|
68
|
+
/**
|
|
69
|
+
* Allows callers holding every one of `permissions`.
|
|
70
|
+
*
|
|
71
|
+
* @example router.delete('/x/:id', requireAllPermissions('x.read', 'x.delete'), controller.remove);
|
|
72
|
+
*/
|
|
51
73
|
export const requireAllPermissions = (...permissions) => guard((u) => hasAllPermissions(u, ...permissions));
|
|
52
|
-
/**
|
|
74
|
+
/**
|
|
75
|
+
* Allows callers with any of `roles`. Prefer permissions; roles are for coarse gates only.
|
|
76
|
+
*
|
|
77
|
+
* @example router.use('/ops', requireRole('platform_admin'));
|
|
78
|
+
*/
|
|
53
79
|
export const requireRole = (...roles) => guard((u) => hasRole(u, ...roles), 'Insufficient role');
|
|
54
|
-
/**
|
|
80
|
+
/**
|
|
81
|
+
* Guards `/internal/*` routes: requires the service secret header (constant-time compare);
|
|
82
|
+
* 401 UNAUTHENTICATED with `requestId` otherwise.
|
|
83
|
+
*
|
|
84
|
+
* @param secret The expected `x-internal-service-secret`; an empty secret rejects every call (fail closed).
|
|
85
|
+
* @returns Express middleware.
|
|
86
|
+
* @example router.use('/internal', requireInternalServiceSecret(config.internalSecret));
|
|
87
|
+
*/
|
|
55
88
|
export function requireInternalServiceSecret(secret) {
|
|
56
89
|
return (req, res, next) => {
|
|
57
90
|
const provided = req.headers[CONTEXT_HEADERS.INTERNAL_SECRET];
|
|
58
91
|
if (typeof provided === 'string' && secret && safeEqual(provided, secret))
|
|
59
92
|
return next();
|
|
60
|
-
res.status(401).json(
|
|
93
|
+
res.status(401).json(errorBody('UNAUTHENTICATED', 'Invalid internal service credentials', { requestId: correlationId(req) }));
|
|
61
94
|
};
|
|
62
95
|
}
|
|
63
96
|
//# sourceMappingURL=auth.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"auth.js","sourceRoot":"","sources":["../../../src/express/auth.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,iBAAiB,EAAE,aAAa,EAAE,OAAO,EAAE,MAAM,oBAAoB,CAAC;AAC/E,OAAO,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAC;AAChD,OAAO,EAAE,
|
|
1
|
+
{"version":3,"file":"auth.js","sourceRoot":"","sources":["../../../src/express/auth.ts"],"names":[],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/express/index.ts"],"names":[],"mappings":"AAAA,cAAc,WAAW,CAAC;AAC1B,cAAc,iBAAiB,CAAC;AAChC,cAAc,aAAa,CAAC;AAC5B,cAAc,eAAe,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/express/index.ts"],"names":[],"mappings":"AAAA,cAAc,WAAW,CAAC;AAC1B,cAAc,iBAAiB,CAAC;AAChC,cAAc,aAAa,CAAC;AAC5B,cAAc,eAAe,CAAC;AAC9B,cAAc,cAAc,CAAC"}
|
|
@@ -1,84 +1,203 @@
|
|
|
1
|
-
import { isAppError, publicMessage } from '../common/errors.js';
|
|
1
|
+
import { GENERIC_ERROR_MESSAGE, ValidationError, isAppError, publicMessage } from '../common/errors.js';
|
|
2
2
|
import { newId } from '../common/id.js';
|
|
3
|
-
import {
|
|
3
|
+
import { errorBody } from '../common/response.js';
|
|
4
|
+
import { getRequestContext, runWithContext } from '../context/index.js';
|
|
4
5
|
import { CONTEXT_HEADERS } from '../identity/index.js';
|
|
5
6
|
import { zodIssuesToErrors } from '../validation/index.js';
|
|
6
|
-
/**
|
|
7
|
-
export
|
|
7
|
+
/** An incoming correlation id is trusted only if it is 8–64 chars of `[A-Za-z0-9-]` (no log injection). */
|
|
8
|
+
export const CORRELATION_ID_PATTERN = /^[A-Za-z0-9-]{8,64}$/;
|
|
9
|
+
/**
|
|
10
|
+
* Gives every request one correlation id and runs the rest of the request inside a
|
|
11
|
+
* {@link RequestContext}. A valid incoming `x-correlation-id` (see {@link CORRELATION_ID_PATTERN}) is
|
|
12
|
+
* kept; anything else is replaced with a new ULID. The id is echoed on the response, written back to
|
|
13
|
+
* the request header (so outbound calls forward it) and becomes every error body's `requestId`.
|
|
14
|
+
* Mount it first: correlation → json → routes → notFoundHandler → errorHandler.
|
|
15
|
+
*
|
|
16
|
+
* @param serviceName Recorded in the context (and so in log lines written without a service).
|
|
17
|
+
* @returns Express middleware.
|
|
18
|
+
* @example app.use(correlationMiddleware('orders'));
|
|
19
|
+
*/
|
|
20
|
+
export function correlationMiddleware(serviceName) {
|
|
8
21
|
return (req, res, next) => {
|
|
9
22
|
const incoming = req.headers[CONTEXT_HEADERS.CORRELATION_ID];
|
|
10
|
-
const id = typeof incoming === 'string' && incoming
|
|
23
|
+
const id = typeof incoming === 'string' && CORRELATION_ID_PATTERN.test(incoming) ? incoming : newId();
|
|
11
24
|
req.headers[CONTEXT_HEADERS.CORRELATION_ID] = id;
|
|
12
25
|
res.setHeader(CONTEXT_HEADERS.CORRELATION_ID, id);
|
|
13
|
-
next
|
|
26
|
+
runWithContext({ correlationId: id, service: serviceName }, next);
|
|
14
27
|
};
|
|
15
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* The request's correlation id (the source of every error body's `requestId`): the active request
|
|
31
|
+
* context's id, else the `x-correlation-id` header.
|
|
32
|
+
*
|
|
33
|
+
* @param req Express request.
|
|
34
|
+
* @returns The id, or undefined when neither is present.
|
|
35
|
+
* @example logger.info('Order placed', { correlationId: correlationId(req) });
|
|
36
|
+
*/
|
|
16
37
|
export function correlationId(req) {
|
|
38
|
+
const fromContext = getRequestContext()?.correlationId;
|
|
39
|
+
if (fromContext)
|
|
40
|
+
return fromContext;
|
|
17
41
|
const value = req.headers[CONTEXT_HEADERS.CORRELATION_ID];
|
|
18
42
|
return typeof value === 'string' ? value : undefined;
|
|
19
43
|
}
|
|
20
|
-
/**
|
|
44
|
+
/**
|
|
45
|
+
* Real client IP: gateway-forwarded header first, then Express's `req.ip`.
|
|
46
|
+
*
|
|
47
|
+
* @param req Express request.
|
|
48
|
+
* @returns The IP, or null when unknown.
|
|
49
|
+
* @example const ip = clientIp(req);
|
|
50
|
+
*/
|
|
21
51
|
export function clientIp(req) {
|
|
22
52
|
const forwarded = req.headers[CONTEXT_HEADERS.CLIENT_IP];
|
|
23
53
|
if (typeof forwarded === 'string' && forwarded)
|
|
24
54
|
return forwarded;
|
|
25
55
|
return req.ip ?? null;
|
|
26
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* The caller's IANA time zone as forwarded by the gateway.
|
|
59
|
+
*
|
|
60
|
+
* @param req Express request.
|
|
61
|
+
* @returns e.g. `Europe/Paris`, or null when not sent.
|
|
62
|
+
* @example formatInTimeZone(date, clientTimezone(req) ?? 'UTC');
|
|
63
|
+
*/
|
|
27
64
|
export function clientTimezone(req) {
|
|
28
65
|
const tz = req.headers[CONTEXT_HEADERS.TIMEZONE];
|
|
29
66
|
return typeof tz === 'string' && tz ? tz : null;
|
|
30
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* The anonymous visitor id forwarded by the gateway (for anonymous visitor state, rate limits and analytics).
|
|
70
|
+
*
|
|
71
|
+
* @param req Express request.
|
|
72
|
+
* @returns The id, or null when not sent.
|
|
73
|
+
* @example const visitor = req.user?.id ?? anonId(req);
|
|
74
|
+
*/
|
|
31
75
|
export function anonId(req) {
|
|
32
76
|
const id = req.headers[CONTEXT_HEADERS.ANON_ID];
|
|
33
77
|
return typeof id === 'string' && id ? id : null;
|
|
34
78
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
79
|
+
const PART_ORDER = ['params', 'query', 'body'];
|
|
80
|
+
/** Field-error keys: body fields keep their own path; params/query are prefixed (`query.limit`). */
|
|
81
|
+
function prefixErrors(part, errors) {
|
|
82
|
+
if (part === 'body')
|
|
83
|
+
return errors;
|
|
84
|
+
return Object.fromEntries(Object.entries(errors).map(([key, messages]) => [key === '_' ? part : `${part}.${key}`, messages]));
|
|
85
|
+
}
|
|
86
|
+
function isSchema(value) {
|
|
87
|
+
return typeof value?.safeParse === 'function';
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Validates the request with Zod at the controller boundary. Each given part is parsed and
|
|
91
|
+
* `req[part]` is replaced with the parsed value, so unknown keys are stripped (or rejected with
|
|
92
|
+
* `.strict()`) and coercions/defaults apply. Every part is checked before failing, and the combined
|
|
93
|
+
* field errors are thrown as a 422 {@link ValidationError} for the error handler (body fields keep
|
|
94
|
+
* their path; params and query fields are prefixed, e.g. `params.id`).
|
|
95
|
+
*
|
|
96
|
+
* The older single-schema form `validate(schema, part = 'body')` still works.
|
|
97
|
+
*
|
|
98
|
+
* @param schemas `{ params?, query?, body? }`, or one schema.
|
|
99
|
+
* @param part With a single schema: the part it checks (default `body`).
|
|
100
|
+
* @returns Express middleware.
|
|
101
|
+
* @example router.patch('/:id', validate({ params: idParam, body: updateSchema }), controller.update);
|
|
102
|
+
*/
|
|
103
|
+
export function validate(schemas, part = 'body') {
|
|
104
|
+
const parts = isSchema(schemas) ? { [part]: schemas } : schemas;
|
|
105
|
+
return (req, _res, next) => {
|
|
106
|
+
let errors = {};
|
|
107
|
+
const parsed = {};
|
|
108
|
+
for (const name of PART_ORDER) {
|
|
109
|
+
const schema = parts[name];
|
|
110
|
+
if (!schema)
|
|
111
|
+
continue;
|
|
112
|
+
const result = schema.safeParse(req[name]);
|
|
113
|
+
if (result.success)
|
|
114
|
+
parsed[name] = result.data;
|
|
115
|
+
else
|
|
116
|
+
errors = { ...errors, ...prefixErrors(name, zodIssuesToErrors(result.error)) };
|
|
117
|
+
}
|
|
118
|
+
if (Object.keys(errors).length > 0) {
|
|
119
|
+
next(new ValidationError(errors));
|
|
41
120
|
return;
|
|
42
121
|
}
|
|
43
|
-
|
|
44
|
-
|
|
122
|
+
for (const [name, value] of Object.entries(parsed)) {
|
|
123
|
+
// Express 5 exposes req.query as a getter; redefine to store the parsed value.
|
|
124
|
+
Object.defineProperty(req, name, { value, writable: true, configurable: true, enumerable: true });
|
|
125
|
+
}
|
|
45
126
|
next();
|
|
46
127
|
};
|
|
47
128
|
}
|
|
48
|
-
/**
|
|
129
|
+
/**
|
|
130
|
+
* Wraps an async handler so rejections reach the error middleware (needed on Express 4; harmless on 5).
|
|
131
|
+
*
|
|
132
|
+
* @param fn Async route handler.
|
|
133
|
+
* @returns A handler that forwards rejections to `next(err)`.
|
|
134
|
+
* @example router.get('/:id', asyncHandler(controller.get));
|
|
135
|
+
*/
|
|
49
136
|
export function asyncHandler(fn) {
|
|
50
137
|
return (req, res, next) => {
|
|
51
138
|
fn(req, res, next).catch(next);
|
|
52
139
|
};
|
|
53
140
|
}
|
|
54
|
-
/**
|
|
141
|
+
/** Zod's error, recognised structurally so a service on another zod copy/version still maps to 422. */
|
|
142
|
+
function isZodError(err) {
|
|
143
|
+
return err instanceof Error && err.name === 'ZodError' && Array.isArray(err.issues);
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Last error middleware. Every body is `{ success: false, code, message, errors?, requestId }`:
|
|
147
|
+
* - body-parser failures → 400 BAD_REQUEST / 413 PAYLOAD_TOO_LARGE;
|
|
148
|
+
* - a thrown ZodError (e.g. from `schema.parse`) → 422 VALIDATION_FAILED with field errors;
|
|
149
|
+
* - AppError → its status, code and errors; 4xx keep their message, 5xx get a generic one; a
|
|
150
|
+
* `retryAfterSeconds` becomes the `Retry-After` header;
|
|
151
|
+
* - anything else → logged in full, answered 500 INTERNAL_ERROR with a generic message.
|
|
152
|
+
* `requestId` is the request context's correlation id (set by {@link correlationMiddleware}).
|
|
153
|
+
*
|
|
154
|
+
* @param logger Receives 5xx details (message, stack, path, correlation id); never sent to the client.
|
|
155
|
+
* @returns Express error middleware.
|
|
156
|
+
* @example app.use(notFoundHandler()); app.use(errorHandler(logger));
|
|
157
|
+
*/
|
|
55
158
|
export function errorHandler(logger) {
|
|
56
|
-
return (err, req, res,
|
|
57
|
-
if (res.headersSent)
|
|
159
|
+
return (err, req, res, next) => {
|
|
160
|
+
if (res.headersSent) {
|
|
161
|
+
next(err);
|
|
58
162
|
return;
|
|
163
|
+
}
|
|
164
|
+
const requestId = correlationId(req);
|
|
59
165
|
const bodyError = err;
|
|
60
166
|
if (bodyError?.type === 'entity.parse.failed') {
|
|
61
|
-
res.status(400).json(
|
|
167
|
+
res.status(400).json(errorBody('BAD_REQUEST', 'Malformed JSON body', { requestId }));
|
|
62
168
|
return;
|
|
63
169
|
}
|
|
64
170
|
if (bodyError?.type === 'entity.too.large') {
|
|
65
|
-
res.status(413).json(
|
|
171
|
+
res.status(413).json(errorBody('PAYLOAD_TOO_LARGE', 'Request body too large', { requestId }));
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (isZodError(err)) {
|
|
175
|
+
res.status(422).json(errorBody('VALIDATION_FAILED', 'Validation failed', { errors: zodIssuesToErrors(err), requestId }));
|
|
66
176
|
return;
|
|
67
177
|
}
|
|
68
178
|
if (isAppError(err)) {
|
|
69
179
|
if (err.statusCode >= 500)
|
|
70
|
-
logger?.error(err.message, { path: req.path,
|
|
71
|
-
|
|
180
|
+
logger?.error(err.message, { path: req.path, errorCode: err.code, stack: err.stack, correlationId: requestId });
|
|
181
|
+
if (err.retryAfterSeconds !== undefined)
|
|
182
|
+
res.setHeader('Retry-After', String(Math.max(0, Math.ceil(err.retryAfterSeconds))));
|
|
183
|
+
const errors = err.statusCode < 500 ? err.errors : undefined;
|
|
184
|
+
res.status(err.statusCode).json(errorBody(err.code, publicMessage(err), { errors, requestId }));
|
|
72
185
|
return;
|
|
73
186
|
}
|
|
74
187
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
75
|
-
logger?.error('Unhandled error', { error: error.message, stack: error.stack, path: req.path, correlationId:
|
|
76
|
-
res.status(500).json(
|
|
188
|
+
logger?.error('Unhandled error', { error: error.message, stack: error.stack, path: req.path, correlationId: requestId });
|
|
189
|
+
res.status(500).json(errorBody('INTERNAL_ERROR', GENERIC_ERROR_MESSAGE, { requestId }));
|
|
77
190
|
};
|
|
78
191
|
}
|
|
192
|
+
/**
|
|
193
|
+
* Catch-all for unmatched routes: 404 NOT_FOUND with the request id. Mount after every router.
|
|
194
|
+
*
|
|
195
|
+
* @returns Express middleware.
|
|
196
|
+
* @example app.use(notFoundHandler()); app.use(errorHandler(logger));
|
|
197
|
+
*/
|
|
79
198
|
export function notFoundHandler() {
|
|
80
|
-
return (
|
|
81
|
-
res.status(404).json(
|
|
199
|
+
return (req, res) => {
|
|
200
|
+
res.status(404).json(errorBody('NOT_FOUND', 'Resource not found', { requestId: correlationId(req) }));
|
|
82
201
|
};
|
|
83
202
|
}
|
|
84
203
|
//# sourceMappingURL=middleware.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"middleware.js","sourceRoot":"","sources":["../../../src/express/middleware.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,UAAU,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;
|
|
1
|
+
{"version":3,"file":"middleware.js","sourceRoot":"","sources":["../../../src/express/middleware.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { cursorPaginatedResponse, successResponse } from '../common/response.js';
|
|
2
|
+
/**
|
|
3
|
+
* 200 with the success envelope.
|
|
4
|
+
*
|
|
5
|
+
* @param res Express response.
|
|
6
|
+
* @param data The payload placed under `data`.
|
|
7
|
+
* @param message Optional human-readable message (default `Success`).
|
|
8
|
+
* @param meta Optional list metadata.
|
|
9
|
+
* @returns The Express response.
|
|
10
|
+
* @example ok(res, product, 'Product updated');
|
|
11
|
+
*/
|
|
12
|
+
export function ok(res, data, message, meta) {
|
|
13
|
+
const body = successResponse(data, message);
|
|
14
|
+
return res.status(200).json(meta ? { ...body, meta } : body);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* 201 with the success envelope, for a newly created resource.
|
|
18
|
+
*
|
|
19
|
+
* @param res Express response.
|
|
20
|
+
* @param data The created resource (or its id).
|
|
21
|
+
* @param message Optional message (default `Created`).
|
|
22
|
+
* @returns The Express response.
|
|
23
|
+
*
|
|
24
|
+
* @example created(res, { id }, 'Shop created');
|
|
25
|
+
*/
|
|
26
|
+
export function created(res, data, message = 'Created') {
|
|
27
|
+
return res.status(201).json(successResponse(data, message));
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* 200 with a cursor-paginated list: `data` is the items, `meta` is `{ limit, nextCursor, hasMore }`.
|
|
31
|
+
*
|
|
32
|
+
* @param res Express response.
|
|
33
|
+
* @param page The page from the service (see `buildCursorPageMeta`).
|
|
34
|
+
* @param message Optional message.
|
|
35
|
+
* @returns The Express response.
|
|
36
|
+
*
|
|
37
|
+
* @example okPage(res, await service.list(scope, { cursor, limit }));
|
|
38
|
+
*/
|
|
39
|
+
export function okPage(res, page, message) {
|
|
40
|
+
return res.status(200).json(cursorPaginatedResponse(page, message));
|
|
41
|
+
}
|
|
42
|
+
//# sourceMappingURL=respond.js.map
|