@zintrust/core 0.5.9 → 0.7.2
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/package.json +7 -5
- package/src/auth/LoginFlow.d.ts +65 -0
- package/src/auth/LoginFlow.d.ts.map +1 -0
- package/src/auth/LoginFlow.js +352 -0
- package/src/cache/drivers/KVRemoteDriver.d.ts.map +1 -1
- package/src/cache/drivers/KVRemoteDriver.js +9 -3
- package/src/cli/commands/MigrateCommand.js +1 -1
- package/src/cli/commands/ScheduleListCommand.d.ts.map +1 -1
- package/src/cli/commands/ScheduleListCommand.js +3 -0
- package/src/cli/commands/ScheduleRunCommand.d.ts.map +1 -1
- package/src/cli/commands/ScheduleRunCommand.js +3 -0
- package/src/cli/commands/ScheduleStartCommand.d.ts.map +1 -1
- package/src/cli/commands/ScheduleStartCommand.js +3 -0
- package/src/cli/commands/schedule/ScheduleCliSupport.d.ts +1 -0
- package/src/cli/commands/schedule/ScheduleCliSupport.d.ts.map +1 -1
- package/src/cli/commands/schedule/ScheduleCliSupport.js +116 -6
- package/src/cli/scaffolding/GovernanceScaffolder.d.ts.map +1 -1
- package/src/cli/scaffolding/GovernanceScaffolder.js +22 -3
- package/src/cli/scaffolding/MigrationGenerator.d.ts.map +1 -1
- package/src/cli/scaffolding/MigrationGenerator.js +2 -1
- package/src/cli/scaffolding/ProjectScaffolder.d.ts.map +1 -1
- package/src/cli/scaffolding/ProjectScaffolder.js +79 -8
- package/src/cli/scaffolding/ScaffoldingVersionUtils.js +1 -1
- package/src/common/ContextLoader.d.ts +27 -0
- package/src/common/ContextLoader.d.ts.map +1 -0
- package/src/common/ContextLoader.js +187 -0
- package/src/config/logger.d.ts +2 -0
- package/src/config/logger.d.ts.map +1 -1
- package/src/config/logger.js +12 -0
- package/src/index.d.ts +7 -0
- package/src/index.d.ts.map +1 -1
- package/src/index.js +7 -3
- package/src/orm/Model.d.ts.map +1 -1
- package/src/orm/Model.js +33 -1
- package/src/orm/adapters/D1Adapter.d.ts.map +1 -1
- package/src/orm/adapters/D1Adapter.js +1 -1
- package/src/orm/adapters/MySQLAdapter.d.ts.map +1 -1
- package/src/orm/adapters/MySQLAdapter.js +1 -1
- package/src/orm/adapters/MySQLProxyAdapter.d.ts.map +1 -1
- package/src/orm/adapters/MySQLProxyAdapter.js +11 -9
- package/src/orm/adapters/PostgreSQLAdapter.js +1 -1
- package/src/orm/adapters/SQLServerAdapter.d.ts.map +1 -1
- package/src/orm/adapters/SQLServerAdapter.js +1 -1
- package/src/orm/adapters/SQLiteAdapter.js +1 -1
- package/src/proxy/ProxyServerUtils.d.ts.map +1 -1
- package/src/proxy/ProxyServerUtils.js +15 -11
- package/src/security/SecurePayload.d.ts +38 -0
- package/src/security/SecurePayload.d.ts.map +1 -0
- package/src/security/SecurePayload.js +214 -0
- package/src/templates/project/basic/app/Controllers/AuthController.ts.tpl +132 -46
- package/src/templates/project/basic/package.json.tpl +6 -2
- package/src/tools/notification/Composer.d.ts +40 -0
- package/src/tools/notification/Composer.d.ts.map +1 -0
- package/src/tools/notification/Composer.js +140 -0
- package/src/tools/notification/Notification.d.ts +6 -0
- package/src/tools/notification/Notification.d.ts.map +1 -1
- package/src/tools/notification/Notification.js +7 -0
- package/src/tools/queue/AdvancedQueue.js +15 -0
- package/src/tools/queue/Queue.d.ts +1 -0
- package/src/tools/queue/Queue.d.ts.map +1 -1
- package/src/types/Queue.d.ts +1 -0
- package/src/types/Queue.d.ts.map +1 -1
- package/src/zintrust.comon.d.ts +0 -11
- package/src/zintrust.comon.d.ts.map +0 -1
- package/src/zintrust.comon.js +0 -17
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { ErrorFactory } from '../exceptions/ZintrustError.js';
|
|
2
|
+
import { isObject, isString } from '../helper/index.js';
|
|
3
|
+
import { EncryptedEnvelope } from './EncryptedEnvelope.js';
|
|
4
|
+
import { Validator } from '../validation/Validator.js';
|
|
5
|
+
const decryptors = new Map();
|
|
6
|
+
const createStageError = (stage, message, details) => {
|
|
7
|
+
return ErrorFactory.createValidationError(`SecurePayload ${stage} failed: ${message}`, {
|
|
8
|
+
stage,
|
|
9
|
+
details,
|
|
10
|
+
});
|
|
11
|
+
};
|
|
12
|
+
const resolveDecryptor = (decryptor) => {
|
|
13
|
+
if (typeof decryptor === 'function') {
|
|
14
|
+
return decryptor;
|
|
15
|
+
}
|
|
16
|
+
if (typeof decryptor === 'string' && decryptor.trim().length > 0) {
|
|
17
|
+
const registered = decryptors.get(decryptor);
|
|
18
|
+
if (registered) {
|
|
19
|
+
return registered;
|
|
20
|
+
}
|
|
21
|
+
throw createStageError('decrypt', `Unknown decryptor: ${decryptor}`);
|
|
22
|
+
}
|
|
23
|
+
throw createStageError('decrypt', 'No decryptor was provided');
|
|
24
|
+
};
|
|
25
|
+
const coerceBoolean = (value) => {
|
|
26
|
+
if (typeof value === 'boolean')
|
|
27
|
+
return value;
|
|
28
|
+
if (typeof value === 'number') {
|
|
29
|
+
if (value === 1)
|
|
30
|
+
return true;
|
|
31
|
+
if (value === 0)
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
if (typeof value === 'string') {
|
|
35
|
+
const normalized = value.trim().toLowerCase();
|
|
36
|
+
if (['true', '1', 'yes', 'on'].includes(normalized))
|
|
37
|
+
return true;
|
|
38
|
+
if (['false', '0', 'no', 'off'].includes(normalized))
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
throw createStageError('coerce', 'Boolean coercion failed', { value });
|
|
42
|
+
};
|
|
43
|
+
const throwNumberCoercionError = (value, integer) => {
|
|
44
|
+
throw createStageError('coerce', integer ? 'Integer coercion failed' : 'Numeric coercion failed', {
|
|
45
|
+
value,
|
|
46
|
+
});
|
|
47
|
+
};
|
|
48
|
+
const coerceExistingNumber = (value, integer) => {
|
|
49
|
+
if (!Number.isFinite(value)) {
|
|
50
|
+
throwNumberCoercionError(value, integer);
|
|
51
|
+
}
|
|
52
|
+
if (integer && !Number.isInteger(value)) {
|
|
53
|
+
throwNumberCoercionError(value, true);
|
|
54
|
+
}
|
|
55
|
+
return value;
|
|
56
|
+
};
|
|
57
|
+
const coerceStringNumber = (value, integer) => {
|
|
58
|
+
const trimmed = value.trim();
|
|
59
|
+
if (trimmed.length === 0) {
|
|
60
|
+
throwNumberCoercionError(value, integer);
|
|
61
|
+
}
|
|
62
|
+
if (integer && !/^[-+]?\d+$/.test(trimmed)) {
|
|
63
|
+
throwNumberCoercionError(value, true);
|
|
64
|
+
}
|
|
65
|
+
const parsed = integer ? Number.parseInt(trimmed, 10) : Number.parseFloat(trimmed);
|
|
66
|
+
if (!Number.isFinite(parsed)) {
|
|
67
|
+
throwNumberCoercionError(value, integer);
|
|
68
|
+
}
|
|
69
|
+
return parsed;
|
|
70
|
+
};
|
|
71
|
+
const coerceNumber = (value, integer = false) => {
|
|
72
|
+
if (typeof value === 'number') {
|
|
73
|
+
return coerceExistingNumber(value, integer);
|
|
74
|
+
}
|
|
75
|
+
if (typeof value === 'string') {
|
|
76
|
+
return coerceStringNumber(value, integer);
|
|
77
|
+
}
|
|
78
|
+
return throwNumberCoercionError(value, integer);
|
|
79
|
+
};
|
|
80
|
+
const coerceValue = (value, target) => {
|
|
81
|
+
if (value === null || value === undefined)
|
|
82
|
+
return value;
|
|
83
|
+
if (target === 'string') {
|
|
84
|
+
return isString(value) ? value : String(value);
|
|
85
|
+
}
|
|
86
|
+
if (target === 'number') {
|
|
87
|
+
return coerceNumber(value, false);
|
|
88
|
+
}
|
|
89
|
+
if (target === 'integer') {
|
|
90
|
+
return coerceNumber(value, true);
|
|
91
|
+
}
|
|
92
|
+
return coerceBoolean(value);
|
|
93
|
+
};
|
|
94
|
+
const applyCoercion = (input, shape) => {
|
|
95
|
+
if (!isObject(input)) {
|
|
96
|
+
throw createStageError('coerce', 'Coercion requires a JSON object payload', {
|
|
97
|
+
receivedType: Array.isArray(input) ? 'array' : typeof input,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
const output = { ...input };
|
|
101
|
+
for (const [key, target] of Object.entries(shape)) {
|
|
102
|
+
if (!(key in output))
|
|
103
|
+
continue;
|
|
104
|
+
output[key] = coerceValue(output[key], target);
|
|
105
|
+
}
|
|
106
|
+
return output;
|
|
107
|
+
};
|
|
108
|
+
const validatePayload = (input, schema) => {
|
|
109
|
+
if (!isObject(input)) {
|
|
110
|
+
throw createStageError('validate', 'Validation requires an object payload', {
|
|
111
|
+
receivedType: Array.isArray(input) ? 'array' : typeof input,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
return Validator.validate(input, schema);
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
if (error instanceof Error) {
|
|
119
|
+
throw createStageError('validate', error.message, { cause: error.message });
|
|
120
|
+
}
|
|
121
|
+
throw createStageError('validate', 'Schema validation failed', { cause: error });
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
const parseJson = (input) => {
|
|
125
|
+
if (!isString(input)) {
|
|
126
|
+
throw createStageError('json', 'JSON parsing requires a string payload', {
|
|
127
|
+
receivedType: Array.isArray(input) ? 'array' : typeof input,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
return JSON.parse(input);
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
throw createStageError('json', 'Invalid JSON payload', {
|
|
135
|
+
cause: error instanceof Error ? error.message : String(error),
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
const decryptPayload = async (input, options) => {
|
|
140
|
+
if (!isString(input)) {
|
|
141
|
+
throw createStageError('decrypt', 'Encrypted payload must be a string', {
|
|
142
|
+
receivedType: Array.isArray(input) ? 'array' : typeof input,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
const decryptor = resolveDecryptor(options.decryptor);
|
|
146
|
+
try {
|
|
147
|
+
return await decryptor(input, options.context);
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
if (error instanceof Error) {
|
|
151
|
+
throw createStageError('decrypt', error.message, { cause: error.message });
|
|
152
|
+
}
|
|
153
|
+
throw createStageError('decrypt', 'Decryptor failed', { cause: error });
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
const createPipeline = (raw, options, operations = []) => {
|
|
157
|
+
const withOperation = (operation) => {
|
|
158
|
+
return createPipeline(raw, options, [...operations, operation]);
|
|
159
|
+
};
|
|
160
|
+
const execute = async () => {
|
|
161
|
+
return operations.reduce(async (currentPromise, operation) => {
|
|
162
|
+
const current = await currentPromise;
|
|
163
|
+
if (operation.type === 'decrypt') {
|
|
164
|
+
return decryptPayload(current, options);
|
|
165
|
+
}
|
|
166
|
+
if (operation.type === 'json') {
|
|
167
|
+
return parseJson(current);
|
|
168
|
+
}
|
|
169
|
+
if (operation.type === 'coerce') {
|
|
170
|
+
return applyCoercion(current, operation.shape);
|
|
171
|
+
}
|
|
172
|
+
return validatePayload(current, operation.schema);
|
|
173
|
+
}, Promise.resolve(raw));
|
|
174
|
+
};
|
|
175
|
+
return Object.freeze({
|
|
176
|
+
decrypt: () => withOperation({ type: 'decrypt' }),
|
|
177
|
+
json: () => withOperation({ type: 'json' }),
|
|
178
|
+
coerce: (shape) => withOperation({ type: 'coerce', shape }),
|
|
179
|
+
validate: (schema) => withOperation({ type: 'validate', schema }),
|
|
180
|
+
value: async () => execute(),
|
|
181
|
+
typed: async () => execute(),
|
|
182
|
+
});
|
|
183
|
+
};
|
|
184
|
+
export const SecurePayload = Object.freeze({
|
|
185
|
+
registerDecryptor(name, decryptor) {
|
|
186
|
+
if (!isString(name) || name.trim().length === 0) {
|
|
187
|
+
throw ErrorFactory.createValidationError('SecurePayload decryptor name must be provided');
|
|
188
|
+
}
|
|
189
|
+
decryptors.set(name.trim(), decryptor);
|
|
190
|
+
},
|
|
191
|
+
unregisterDecryptor(name) {
|
|
192
|
+
return decryptors.delete(name);
|
|
193
|
+
},
|
|
194
|
+
hasDecryptor(name) {
|
|
195
|
+
return decryptors.has(name);
|
|
196
|
+
},
|
|
197
|
+
listDecryptors() {
|
|
198
|
+
return Array.from(decryptors.keys()).sort((left, right) => left.localeCompare(right));
|
|
199
|
+
},
|
|
200
|
+
clearDecryptors() {
|
|
201
|
+
decryptors.clear();
|
|
202
|
+
},
|
|
203
|
+
createEnvelopeDecryptor(options) {
|
|
204
|
+
return (raw) => EncryptedEnvelope.decryptString(raw, {
|
|
205
|
+
cipher: options.cipher,
|
|
206
|
+
key: options.key,
|
|
207
|
+
previousKeys: options.previousKeys,
|
|
208
|
+
});
|
|
209
|
+
},
|
|
210
|
+
decode(raw, options = {}) {
|
|
211
|
+
return createPipeline(raw, options);
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
export default SecurePayload;
|
|
@@ -8,15 +8,15 @@ import type { AuthControllerApi, JsonRecord, UserRow } from '@app/Types/controll
|
|
|
8
8
|
import type { IRequest, IResponse } from '@zintrust/core';
|
|
9
9
|
import {
|
|
10
10
|
Auth,
|
|
11
|
+
ErrorFactory,
|
|
11
12
|
JwtManager,
|
|
13
|
+
LoginFlow,
|
|
12
14
|
Logger,
|
|
13
15
|
getString,
|
|
14
16
|
getValidatedBody,
|
|
15
17
|
isUndefinedOrNull,
|
|
16
18
|
} from '@zintrust/core';
|
|
17
19
|
|
|
18
|
-
|
|
19
|
-
|
|
20
20
|
const pickPublicUser = (row: UserRow): { id: unknown; name: string; email: string } => {
|
|
21
21
|
return {
|
|
22
22
|
id: row.id,
|
|
@@ -25,75 +25,155 @@ const pickPublicUser = (row: UserRow): { id: unknown; name: string; email: strin
|
|
|
25
25
|
};
|
|
26
26
|
};
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
28
|
+
type PasswordLoginContext = {
|
|
29
|
+
email: string;
|
|
30
|
+
ipAddress: string;
|
|
31
|
+
requestId?: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const toSubject = (id: unknown): string | undefined => {
|
|
35
|
+
if (typeof id === 'string' && id.length > 0) return id;
|
|
36
|
+
if (typeof id === 'number' && Number.isFinite(id)) return String(id);
|
|
37
|
+
return undefined;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const toDeviceId = (subject: string | undefined): string | undefined => {
|
|
41
|
+
return isUndefinedOrNull(subject) ? undefined : `dev-${subject}`;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const getClaimString = (claims: unknown, key: string): string | undefined => {
|
|
45
|
+
if (typeof claims !== 'object' || claims === null) {
|
|
46
|
+
return undefined;
|
|
41
47
|
}
|
|
42
|
-
const email = getString(body['email']);
|
|
43
|
-
const password = getString(body['password']);
|
|
44
|
-
const ipAddress = req.getRaw().socket.remoteAddress ?? 'unknown';
|
|
45
48
|
|
|
46
|
-
|
|
47
|
-
|
|
49
|
+
const value = (claims as Record<string, unknown>)[key];
|
|
50
|
+
return typeof value === 'string' && value.trim() !== '' ? value : undefined;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const getIssuedToken = (issued: unknown): string => {
|
|
54
|
+
if (typeof issued === 'string' && issued.trim() !== '') {
|
|
55
|
+
return issued;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
throw ErrorFactory.createSecurityError('LoginFlow jwt issuer returned an invalid access token');
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const isLoginFlowUnauthorizedFailure = (error: unknown): boolean => {
|
|
62
|
+
if (typeof error !== 'object' || error === null) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const details = (error as { details?: unknown }).details;
|
|
67
|
+
if (typeof details !== 'object' || details === null) {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const nested = (details as Record<string, unknown>)['error'];
|
|
72
|
+
return (
|
|
73
|
+
typeof nested === 'object' &&
|
|
74
|
+
nested !== null &&
|
|
75
|
+
(nested as { statusCode?: unknown }).statusCode === 401
|
|
76
|
+
);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const passwordLoginProvider = Object.freeze({
|
|
80
|
+
async identify(
|
|
81
|
+
input: { email: string },
|
|
82
|
+
context: PasswordLoginContext
|
|
83
|
+
): Promise<UserRow | null> {
|
|
84
|
+
const existing = await User.where('email', '=', input.email).first<UserRow>();
|
|
48
85
|
|
|
49
86
|
if (existing === null) {
|
|
50
87
|
Logger.warn('AuthController.login: failed login attempt', {
|
|
51
|
-
email,
|
|
52
|
-
ip: ipAddress,
|
|
88
|
+
email: context.email,
|
|
89
|
+
ip: context.ipAddress,
|
|
53
90
|
reason: 'user_not_found',
|
|
91
|
+
...(context.requestId ? { requestId: context.requestId } : {}),
|
|
54
92
|
timestamp: new Date().toISOString(),
|
|
55
93
|
});
|
|
56
|
-
res.setStatus(401).json({ error: 'Invalid credentials' });
|
|
57
|
-
return;
|
|
58
94
|
}
|
|
59
95
|
|
|
60
|
-
|
|
61
|
-
|
|
96
|
+
return existing;
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
async verify(
|
|
100
|
+
identity: UserRow | null,
|
|
101
|
+
input: { password: string },
|
|
102
|
+
context: PasswordLoginContext
|
|
103
|
+
) {
|
|
104
|
+
if (identity === null) {
|
|
105
|
+
throw ErrorFactory.createUnauthorizedError('Invalid credentials');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const passwordHash = getString(identity.password);
|
|
109
|
+
const ok = await Auth.compare(input.password, passwordHash);
|
|
62
110
|
if (!ok) {
|
|
63
111
|
Logger.warn('AuthController.login: failed login attempt', {
|
|
64
|
-
email,
|
|
65
|
-
ip: ipAddress,
|
|
112
|
+
email: context.email,
|
|
113
|
+
ip: context.ipAddress,
|
|
66
114
|
reason: 'invalid_password',
|
|
115
|
+
...(context.requestId ? { requestId: context.requestId } : {}),
|
|
67
116
|
timestamp: new Date().toISOString(),
|
|
68
117
|
});
|
|
69
|
-
|
|
70
|
-
return;
|
|
118
|
+
throw ErrorFactory.createUnauthorizedError('Invalid credentials');
|
|
71
119
|
}
|
|
72
120
|
|
|
73
|
-
const user = pickPublicUser(
|
|
121
|
+
const user = pickPublicUser(identity);
|
|
122
|
+
const subject = toSubject(user.id);
|
|
123
|
+
const deviceId = toDeviceId(subject);
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
user,
|
|
127
|
+
subject,
|
|
128
|
+
claims: {
|
|
129
|
+
sub: subject,
|
|
130
|
+
email: user.email,
|
|
131
|
+
...(isUndefinedOrNull(deviceId) ? {} : { deviceId }),
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
},
|
|
135
|
+
});
|
|
74
136
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
137
|
+
/**
|
|
138
|
+
* Authenticates a user by email and password.
|
|
139
|
+
* Validates credentials against the database and returns a JWT access token on success.
|
|
140
|
+
* Logs all authentication attempts for security auditing.
|
|
141
|
+
* @param req - HTTP request containing email and password
|
|
142
|
+
* @param res - HTTP response to send authentication result
|
|
143
|
+
* @returns Promise that resolves after sending the response
|
|
144
|
+
*/
|
|
145
|
+
async function login(req: IRequest, res: IResponse): Promise<void> {
|
|
146
|
+
const body = getValidatedBody<JsonRecord>(req);
|
|
147
|
+
if (!body) {
|
|
148
|
+
Logger.error('AuthController.login: validation middleware did not populate req.validated.body');
|
|
149
|
+
return res.setStatus(500).json({ error: 'Internal server error' });
|
|
150
|
+
}
|
|
151
|
+
const email = getString(body['email']);
|
|
152
|
+
const password = getString(body['password']);
|
|
153
|
+
const ipAddress = req.getRaw().socket.remoteAddress ?? 'unknown';
|
|
154
|
+
const requestId = typeof req.getHeader === 'function' ? req.getHeader('x-request-id') : undefined;
|
|
81
155
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
156
|
+
try {
|
|
157
|
+
const result = await LoginFlow.create({
|
|
158
|
+
provider: passwordLoginProvider,
|
|
159
|
+
context: Object.freeze({ email, ipAddress, requestId }),
|
|
160
|
+
})
|
|
161
|
+
.identify({ email })
|
|
162
|
+
.verify({ password })
|
|
163
|
+
.issue('jwt')
|
|
164
|
+
.audit()
|
|
165
|
+
.run();
|
|
86
166
|
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
});
|
|
167
|
+
const user = result.verified.user as { id: unknown; name: string; email: string };
|
|
168
|
+
const subject = getClaimString(result.verified.claims, 'sub');
|
|
169
|
+
const deviceId = getClaimString(result.verified.claims, 'deviceId');
|
|
170
|
+
const token = getIssuedToken(result.issued);
|
|
92
171
|
|
|
93
172
|
Logger.info('AuthController.login: successful login', {
|
|
94
173
|
userId: subject,
|
|
95
174
|
email,
|
|
96
175
|
ip: ipAddress,
|
|
176
|
+
...(requestId ? { requestId } : {}),
|
|
97
177
|
timestamp: new Date().toISOString(),
|
|
98
178
|
});
|
|
99
179
|
|
|
@@ -104,9 +184,15 @@ async function login(req: IRequest, res: IResponse): Promise<void> {
|
|
|
104
184
|
user,
|
|
105
185
|
});
|
|
106
186
|
} catch (error) {
|
|
187
|
+
if (isLoginFlowUnauthorizedFailure(error)) {
|
|
188
|
+
res.setStatus(401).json({ error: 'Invalid credentials' });
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
107
192
|
Logger.error('AuthController.login: unexpected error', {
|
|
108
193
|
email,
|
|
109
194
|
ip: ipAddress,
|
|
195
|
+
...(requestId ? { requestId } : {}),
|
|
110
196
|
error: error instanceof Error ? error.message : String(error),
|
|
111
197
|
timestamp: new Date().toISOString(),
|
|
112
198
|
});
|
|
@@ -12,8 +12,7 @@
|
|
|
12
12
|
"type-check": "tsc --noEmit"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@zintrust/core": "^{{coreVersion}}"
|
|
16
|
-
"@zintrust/d1-migrator": "^0.4.6"
|
|
15
|
+
"@zintrust/core": "^{{coreVersion}}"
|
|
17
16
|
},
|
|
18
17
|
"devDependencies": {
|
|
19
18
|
"@zintrust/governance": "{{governanceVersion}}",
|
|
@@ -23,5 +22,10 @@
|
|
|
23
22
|
"tsc-alias": "^1.8.16",
|
|
24
23
|
"typescript": "^5.9.3",
|
|
25
24
|
"vitest": "^4.0.16"
|
|
25
|
+
},
|
|
26
|
+
"overrides": {
|
|
27
|
+
"@zintrust/governance": {
|
|
28
|
+
"@zintrust/core": ">=0.6.0"
|
|
29
|
+
}
|
|
26
30
|
}
|
|
27
31
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export type NotificationComposePolicy = 'required' | 'best_effort';
|
|
2
|
+
export type NotificationChannelHandler<TPayload = unknown, TContext = unknown> = (payload: TPayload, context: TContext) => Promise<unknown>;
|
|
3
|
+
export type NotificationComposeChannelResult = {
|
|
4
|
+
channel: string;
|
|
5
|
+
policy: NotificationComposePolicy;
|
|
6
|
+
ok: boolean;
|
|
7
|
+
payload: unknown;
|
|
8
|
+
result?: unknown;
|
|
9
|
+
error?: unknown;
|
|
10
|
+
};
|
|
11
|
+
export type NotificationComposeResult = {
|
|
12
|
+
ok: boolean;
|
|
13
|
+
results: NotificationComposeChannelResult[];
|
|
14
|
+
};
|
|
15
|
+
export type NotificationComposeError = Error & {
|
|
16
|
+
results: NotificationComposeChannelResult[];
|
|
17
|
+
};
|
|
18
|
+
export type NotificationComposeOptions<TContext = unknown> = {
|
|
19
|
+
context?: TContext;
|
|
20
|
+
};
|
|
21
|
+
export type NotificationComposeBuilder<TContext = unknown> = {
|
|
22
|
+
channel: (name: string, payload: unknown) => NotificationComposeBuilder<TContext>;
|
|
23
|
+
email: (payload: unknown) => NotificationComposeBuilder<TContext>;
|
|
24
|
+
push: (payload: unknown) => NotificationComposeBuilder<TContext>;
|
|
25
|
+
sms: (payload: unknown) => NotificationComposeBuilder<TContext>;
|
|
26
|
+
webhook: (payload: unknown) => NotificationComposeBuilder<TContext>;
|
|
27
|
+
required: (channels: string[]) => NotificationComposeBuilder<TContext>;
|
|
28
|
+
bestEffort: (channels: string[]) => NotificationComposeBuilder<TContext>;
|
|
29
|
+
send: () => Promise<NotificationComposeResult>;
|
|
30
|
+
};
|
|
31
|
+
export type NotificationComposerNamespace = {
|
|
32
|
+
compose: <TContext = unknown>(options?: NotificationComposeOptions<TContext>) => NotificationComposeBuilder<TContext>;
|
|
33
|
+
registerChannel: <TPayload = unknown, TContext = unknown>(name: string, handler: NotificationChannelHandler<TPayload, TContext>) => void;
|
|
34
|
+
unregisterChannel: (name: string) => void;
|
|
35
|
+
hasChannel: (name: string) => boolean;
|
|
36
|
+
listChannels: () => string[];
|
|
37
|
+
clearChannels: () => void;
|
|
38
|
+
};
|
|
39
|
+
export declare const NotificationComposer: NotificationComposerNamespace;
|
|
40
|
+
//# sourceMappingURL=Composer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Composer.d.ts","sourceRoot":"","sources":["../../../../src/tools/notification/Composer.ts"],"names":[],"mappings":"AAIA,MAAM,MAAM,yBAAyB,GAAG,UAAU,GAAG,aAAa,CAAC;AAEnE,MAAM,MAAM,0BAA0B,CAAC,QAAQ,GAAG,OAAO,EAAE,QAAQ,GAAG,OAAO,IAAI,CAC/E,OAAO,EAAE,QAAQ,EACjB,OAAO,EAAE,QAAQ,KACd,OAAO,CAAC,OAAO,CAAC,CAAC;AAEtB,MAAM,MAAM,gCAAgC,GAAG;IAC7C,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,yBAAyB,CAAC;IAClC,EAAE,EAAE,OAAO,CAAC;IACZ,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB,CAAC;AAEF,MAAM,MAAM,yBAAyB,GAAG;IACtC,EAAE,EAAE,OAAO,CAAC;IACZ,OAAO,EAAE,gCAAgC,EAAE,CAAC;CAC7C,CAAC;AAEF,MAAM,MAAM,wBAAwB,GAAG,KAAK,GAAG;IAC7C,OAAO,EAAE,gCAAgC,EAAE,CAAC;CAC7C,CAAC;AAEF,MAAM,MAAM,0BAA0B,CAAC,QAAQ,GAAG,OAAO,IAAI;IAC3D,OAAO,CAAC,EAAE,QAAQ,CAAC;CACpB,CAAC;AAEF,MAAM,MAAM,0BAA0B,CAAC,QAAQ,GAAG,OAAO,IAAI;IAC3D,OAAO,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,KAAK,0BAA0B,CAAC,QAAQ,CAAC,CAAC;IAClF,KAAK,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,0BAA0B,CAAC,QAAQ,CAAC,CAAC;IAClE,IAAI,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,0BAA0B,CAAC,QAAQ,CAAC,CAAC;IACjE,GAAG,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,0BAA0B,CAAC,QAAQ,CAAC,CAAC;IAChE,OAAO,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,0BAA0B,CAAC,QAAQ,CAAC,CAAC;IACpE,QAAQ,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,0BAA0B,CAAC,QAAQ,CAAC,CAAC;IACvE,UAAU,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,0BAA0B,CAAC,QAAQ,CAAC,CAAC;IACzE,IAAI,EAAE,MAAM,OAAO,CAAC,yBAAyB,CAAC,CAAC;CAChD,CAAC;AAEF,MAAM,MAAM,6BAA6B,GAAG;IAC1C,OAAO,EAAE,CAAC,QAAQ,GAAG,OAAO,EAC1B,OAAO,CAAC,EAAE,0BAA0B,CAAC,QAAQ,CAAC,KAC3C,0BAA0B,CAAC,QAAQ,CAAC,CAAC;IAC1C,eAAe,EAAE,CAAC,QAAQ,GAAG,OAAO,EAAE,QAAQ,GAAG,OAAO,EACtD,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,0BAA0B,CAAC,QAAQ,EAAE,QAAQ,CAAC,KACpD,IAAI,CAAC;IACV,iBAAiB,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC1C,UAAU,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC;IACtC,YAAY,EAAE,MAAM,MAAM,EAAE,CAAC;IAC7B,aAAa,EAAE,MAAM,IAAI,CAAC;CAC3B,CAAC;AAsMF,eAAO,MAAM,oBAAoB,EAAE,6BAOjC,CAAC"}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { SystemTraceBridge } from '../../trace/SystemTraceBridge.js';
|
|
2
|
+
import { ErrorFactory } from '../../exceptions/ZintrustError.js';
|
|
3
|
+
import { isArray, isFunction, isNonEmptyString } from '../../helper/index.js';
|
|
4
|
+
const channelRegistry = new Map();
|
|
5
|
+
const normalizeChannelName = (name) => {
|
|
6
|
+
const normalized = String(name ?? '')
|
|
7
|
+
.trim()
|
|
8
|
+
.toLowerCase();
|
|
9
|
+
if (!isNonEmptyString(normalized)) {
|
|
10
|
+
throw ErrorFactory.createValidationError('Notification channel name must be a non-empty string');
|
|
11
|
+
}
|
|
12
|
+
return normalized;
|
|
13
|
+
};
|
|
14
|
+
const createComposeError = (message, results) => {
|
|
15
|
+
return Object.assign(ErrorFactory.createValidationError(message, {
|
|
16
|
+
results,
|
|
17
|
+
}), { results });
|
|
18
|
+
};
|
|
19
|
+
const ensureChannelsInput = (channels) => {
|
|
20
|
+
if (!isArray(channels)) {
|
|
21
|
+
throw ErrorFactory.createValidationError('Notification compose channel list must be an array');
|
|
22
|
+
}
|
|
23
|
+
return channels.map((channel) => normalizeChannelName(channel));
|
|
24
|
+
};
|
|
25
|
+
const ensureHandler = (handler) => {
|
|
26
|
+
if (!isFunction(handler)) {
|
|
27
|
+
throw ErrorFactory.createValidationError('Notification channel handler must be a function');
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
const getPolicy = (policies, channel) => {
|
|
31
|
+
return policies.get(channel) ?? 'required';
|
|
32
|
+
};
|
|
33
|
+
const ensureEntries = (entries) => {
|
|
34
|
+
if (entries.length === 0) {
|
|
35
|
+
throw ErrorFactory.createValidationError('Notification compose requires at least one channel before send()');
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
const deliverEntry = async (entry, policy, context) => {
|
|
39
|
+
const handler = channelRegistry.get(entry.channel);
|
|
40
|
+
if (!handler) {
|
|
41
|
+
return {
|
|
42
|
+
channel: entry.channel,
|
|
43
|
+
policy,
|
|
44
|
+
ok: false,
|
|
45
|
+
payload: entry.payload,
|
|
46
|
+
error: ErrorFactory.createConfigError(`Notification compose channel not registered: ${entry.channel}`),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
const result = await handler(entry.payload, context);
|
|
51
|
+
SystemTraceBridge.emitNotification(`compose:${entry.channel}`, [entry.channel], undefined, undefined, entry.payload);
|
|
52
|
+
return {
|
|
53
|
+
channel: entry.channel,
|
|
54
|
+
policy,
|
|
55
|
+
ok: true,
|
|
56
|
+
payload: entry.payload,
|
|
57
|
+
result,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
return {
|
|
62
|
+
channel: entry.channel,
|
|
63
|
+
policy,
|
|
64
|
+
ok: false,
|
|
65
|
+
payload: entry.payload,
|
|
66
|
+
error,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
const hasRequiredFailure = (results) => {
|
|
71
|
+
return results.some((entry) => !entry.ok && entry.policy === 'required');
|
|
72
|
+
};
|
|
73
|
+
const compose = (options = {}) => {
|
|
74
|
+
const entries = [];
|
|
75
|
+
const policies = new Map();
|
|
76
|
+
const builder = Object.freeze({
|
|
77
|
+
channel(name, payload) {
|
|
78
|
+
entries.push({ channel: normalizeChannelName(name), payload });
|
|
79
|
+
return this;
|
|
80
|
+
},
|
|
81
|
+
email(payload) {
|
|
82
|
+
return this.channel('email', payload);
|
|
83
|
+
},
|
|
84
|
+
push(payload) {
|
|
85
|
+
return this.channel('push', payload);
|
|
86
|
+
},
|
|
87
|
+
sms(payload) {
|
|
88
|
+
return this.channel('sms', payload);
|
|
89
|
+
},
|
|
90
|
+
webhook(payload) {
|
|
91
|
+
return this.channel('webhook', payload);
|
|
92
|
+
},
|
|
93
|
+
required(channels) {
|
|
94
|
+
for (const channel of ensureChannelsInput(channels)) {
|
|
95
|
+
policies.set(channel, 'required');
|
|
96
|
+
}
|
|
97
|
+
return this;
|
|
98
|
+
},
|
|
99
|
+
bestEffort(channels) {
|
|
100
|
+
for (const channel of ensureChannelsInput(channels)) {
|
|
101
|
+
policies.set(channel, 'best_effort');
|
|
102
|
+
}
|
|
103
|
+
return this;
|
|
104
|
+
},
|
|
105
|
+
async send() {
|
|
106
|
+
ensureEntries(entries);
|
|
107
|
+
const results = await Promise.all(entries.map(async (entry) => deliverEntry(entry, getPolicy(policies, entry.channel), options.context)));
|
|
108
|
+
if (hasRequiredFailure(results)) {
|
|
109
|
+
throw createComposeError('Notification compose failed for required channels', results);
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
ok: true,
|
|
113
|
+
results,
|
|
114
|
+
};
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
return builder;
|
|
118
|
+
};
|
|
119
|
+
const registerChannel = (name, handler) => {
|
|
120
|
+
ensureHandler(handler);
|
|
121
|
+
channelRegistry.set(normalizeChannelName(name), handler);
|
|
122
|
+
};
|
|
123
|
+
const unregisterChannel = (name) => {
|
|
124
|
+
channelRegistry.delete(normalizeChannelName(name));
|
|
125
|
+
};
|
|
126
|
+
const hasChannel = (name) => channelRegistry.has(normalizeChannelName(name));
|
|
127
|
+
const listChannels = () => {
|
|
128
|
+
return Array.from(channelRegistry.keys()).sort((left, right) => left.localeCompare(right));
|
|
129
|
+
};
|
|
130
|
+
const clearChannels = () => {
|
|
131
|
+
channelRegistry.clear();
|
|
132
|
+
};
|
|
133
|
+
export const NotificationComposer = Object.freeze({
|
|
134
|
+
compose,
|
|
135
|
+
registerChannel,
|
|
136
|
+
unregisterChannel,
|
|
137
|
+
hasChannel,
|
|
138
|
+
listChannels,
|
|
139
|
+
clearChannels,
|
|
140
|
+
});
|
|
@@ -18,6 +18,12 @@ export declare const Notification: Readonly<{
|
|
|
18
18
|
channel: (name: string) => Readonly<{
|
|
19
19
|
send: (recipient: string, message: string, options?: Record<string, unknown>) => Promise<unknown>;
|
|
20
20
|
}>;
|
|
21
|
+
compose: <TContext = unknown>(options?: import("./Composer").NotificationComposeOptions<TContext>) => import("./Composer").NotificationComposeBuilder<TContext>;
|
|
22
|
+
registerChannel: <TPayload = unknown, TContext = unknown>(name: string, handler: import("./Composer").NotificationChannelHandler<TPayload, TContext>) => void;
|
|
23
|
+
unregisterChannel: (name: string) => void;
|
|
24
|
+
hasChannel: (name: string) => boolean;
|
|
25
|
+
listChannels: () => string[];
|
|
26
|
+
clearChannels: () => void;
|
|
21
27
|
listDrivers: () => string[];
|
|
22
28
|
}>;
|
|
23
29
|
export default Notification;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Notification.d.ts","sourceRoot":"","sources":["../../../../src/tools/notification/Notification.ts"],"names":[],"mappings":"AAAA;;;;GAIG;
|
|
1
|
+
{"version":3,"file":"Notification.d.ts","sourceRoot":"","sources":["../../../../src/tools/notification/Notification.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAKH,eAAO,MAAM,YAAY;;;2BAQV,MAAM,WACR,MAAM,kBACA,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,iBACxB;QAAE,SAAS,CAAC,EAAE,MAAM,CAAC;QAAC,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,GACvD,OAAO,CAAC,MAAM,CAAC;qBAcD,MAAM;iCAGN,MAAM,WACR,MAAM,kBACA,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,iBACxB;YAAE,SAAS,CAAC,EAAE,MAAM,CAAA;SAAE;;oBAM1B,MAAM;0BAEM,MAAM,WAAW,MAAM,YAAY,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;;;;;;;;;EAUtF,CAAC;AAEH,eAAe,YAAY,CAAC"}
|