@rei-standard/amsg-server 1.2.1 → 2.0.1
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 +82 -28
- package/dist/{chunk-H5EQVHAY.mjs → chunk-5ENJGVZX.mjs} +1 -36
- package/dist/{chunk-74H4WV6Q.cjs → chunk-RGECD4OH.cjs} +1 -36
- package/dist/index.cjs +702 -253
- package/dist/index.d.cts +2149 -2
- package/dist/index.d.ts +2149 -2
- package/dist/index.mjs +701 -252
- package/dist/{neon-ZQS3KT6C.cjs → neon-BFUS25UX.cjs} +7 -38
- package/dist/{neon-CK9Yty81.d.ts → neon-CJl66EGy.d.cts} +4 -38
- package/dist/{neon-YS3VCCSU.mjs → neon-CU5N3CSW.mjs} +3 -34
- package/dist/{neon-C29wdgUQ.d.cts → neon-DYvGnCzx.d.ts} +4 -38
- package/dist/{pg-DhwHwSPv.d.ts → pg-07S-u_H4.d.cts} +4 -36
- package/dist/{pg-DRLITtBW.d.cts → pg-Du-pN_UT.d.ts} +4 -36
- package/dist/{pg-2JJP632A.cjs → pg-IIH3M4OM.cjs} +7 -36
- package/dist/{pg-2CLYF2XS.mjs → pg-QO6NKTGL.mjs} +3 -32
- package/dist/schema-C8OnYk6j.d.cts +74 -0
- package/dist/schema-C8OnYk6j.d.ts +74 -0
- package/package.json +3 -2
- package/dist/index-BRvOilNH.d.cts +0 -1633
- package/dist/index-BXb6gptO.d.ts +0 -1633
package/dist/index.d.ts
CHANGED
|
@@ -1,2 +1,2149 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
import { createDecipheriv, createHash, randomBytes, createCipheriv, randomUUID, timingSafeEqual, createHmac } from 'crypto';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Adapter Factory
|
|
5
|
+
* ReiStandard SDK v2.0.1
|
|
6
|
+
*
|
|
7
|
+
* Creates a database adapter instance based on the supplied configuration.
|
|
8
|
+
*
|
|
9
|
+
* @typedef {'neon'|'pg'} DriverName
|
|
10
|
+
*
|
|
11
|
+
* @typedef {Object} AdapterConfig
|
|
12
|
+
* @property {DriverName} driver - Which database driver to use.
|
|
13
|
+
* @property {string} connectionString - Database connection URL.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Create a database adapter.
|
|
18
|
+
*
|
|
19
|
+
* @param {AdapterConfig} config
|
|
20
|
+
* @returns {Promise<import('./interface.js').DbAdapter>}
|
|
21
|
+
*/
|
|
22
|
+
async function createAdapter(config) {
|
|
23
|
+
if (!config || !config.driver) {
|
|
24
|
+
throw new Error(
|
|
25
|
+
'[rei-standard-amsg-server] "driver" is required in the db config. ' +
|
|
26
|
+
'Supported drivers: neon, pg'
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (!config.connectionString) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
'[rei-standard-amsg-server] "connectionString" is required in the db config.'
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
switch (config.driver) {
|
|
37
|
+
case 'neon': {
|
|
38
|
+
const { NeonAdapter } = await import('./neon-DYvGnCzx.d.ts');
|
|
39
|
+
return new NeonAdapter(config.connectionString);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
case 'pg': {
|
|
43
|
+
const { PgAdapter } = await import('./pg-Du-pN_UT.d.ts');
|
|
44
|
+
return new PgAdapter(config.connectionString);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
default:
|
|
48
|
+
throw new Error(
|
|
49
|
+
`[rei-standard-amsg-server] Unsupported driver "${config.driver}". ` +
|
|
50
|
+
'Supported drivers: neon, pg'
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Validation utility library (SDK version)
|
|
57
|
+
* ReiStandard SDK v2.0.1
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Validate ISO 8601 date string.
|
|
62
|
+
* @param {string} dateString
|
|
63
|
+
* @returns {boolean}
|
|
64
|
+
*/
|
|
65
|
+
function isValidISO8601(dateString) {
|
|
66
|
+
const date = new Date(dateString);
|
|
67
|
+
return date instanceof Date && !isNaN(date.getTime());
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Validate URL format.
|
|
72
|
+
* @param {string} urlString
|
|
73
|
+
* @returns {boolean}
|
|
74
|
+
*/
|
|
75
|
+
function isValidUrl(urlString) {
|
|
76
|
+
try {
|
|
77
|
+
new URL(urlString);
|
|
78
|
+
return true;
|
|
79
|
+
} catch {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Validate UUID format.
|
|
86
|
+
* @param {string} uuid
|
|
87
|
+
* @returns {boolean}
|
|
88
|
+
*/
|
|
89
|
+
function isValidUUID(uuid) {
|
|
90
|
+
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
91
|
+
return uuidRegex.test(uuid);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Validate UUID v4 format.
|
|
96
|
+
* @param {string} uuid
|
|
97
|
+
* @returns {boolean}
|
|
98
|
+
*/
|
|
99
|
+
function isValidUUIDv4(uuid) {
|
|
100
|
+
const uuidV4Regex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
101
|
+
return uuidV4Regex.test(uuid);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Validate the schedule-message request payload.
|
|
106
|
+
*
|
|
107
|
+
* @param {Object} payload
|
|
108
|
+
* @returns {{ valid: boolean, errorCode?: string, errorMessage?: string, details?: Object }}
|
|
109
|
+
*/
|
|
110
|
+
function validateScheduleMessagePayload(payload) {
|
|
111
|
+
if (!payload.contactName || typeof payload.contactName !== 'string') {
|
|
112
|
+
return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { missingFields: ['contactName'] } };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (!payload.messageType || !['fixed', 'prompted', 'auto', 'instant'].includes(payload.messageType)) {
|
|
116
|
+
return { valid: false, errorCode: 'INVALID_MESSAGE_TYPE', errorMessage: '消息类型无效', details: { providedType: payload.messageType, allowedTypes: ['fixed', 'prompted', 'auto', 'instant'] } };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (!payload.firstSendTime || !isValidISO8601(payload.firstSendTime)) {
|
|
120
|
+
return { valid: false, errorCode: 'INVALID_TIMESTAMP', errorMessage: '时间格式无效', details: { field: 'firstSendTime' } };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (payload.firstSendTime && new Date(payload.firstSendTime) <= new Date()) {
|
|
124
|
+
return { valid: false, errorCode: 'INVALID_TIMESTAMP', errorMessage: '时间必须在未来', details: { field: 'firstSendTime', reason: 'must be in the future' } };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (!payload.pushSubscription || typeof payload.pushSubscription !== 'object') {
|
|
128
|
+
return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { missingFields: ['pushSubscription'] } };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (payload.recurrenceType && !['none', 'daily', 'weekly'].includes(payload.recurrenceType)) {
|
|
132
|
+
return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { invalidFields: ['recurrenceType'] } };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (
|
|
136
|
+
payload.maxTokens !== undefined &&
|
|
137
|
+
payload.maxTokens !== null &&
|
|
138
|
+
(!Number.isInteger(payload.maxTokens) || payload.maxTokens <= 0)
|
|
139
|
+
) {
|
|
140
|
+
return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { invalidFields: ['maxTokens'] } };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (payload.messageType === 'fixed') {
|
|
144
|
+
if (!payload.userMessage) {
|
|
145
|
+
return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { missingFields: ['userMessage (required for fixed type)'] } };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (payload.messageType === 'prompted' || payload.messageType === 'auto') {
|
|
150
|
+
const missingAiFields = [];
|
|
151
|
+
if (!payload.completePrompt) missingAiFields.push('completePrompt');
|
|
152
|
+
if (!payload.apiUrl) missingAiFields.push('apiUrl');
|
|
153
|
+
if (!payload.apiKey) missingAiFields.push('apiKey');
|
|
154
|
+
if (!payload.primaryModel) missingAiFields.push('primaryModel');
|
|
155
|
+
if (missingAiFields.length > 0) {
|
|
156
|
+
return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { missingFields: missingAiFields } };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (payload.messageType === 'instant') {
|
|
161
|
+
if (payload.recurrenceType && payload.recurrenceType !== 'none') {
|
|
162
|
+
return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: 'instant 类型的 recurrenceType 必须为 none', details: { invalidFields: ['recurrenceType (must be "none" for instant type)'] } };
|
|
163
|
+
}
|
|
164
|
+
const hasAiConfig = payload.completePrompt && payload.apiUrl && payload.apiKey && payload.primaryModel;
|
|
165
|
+
const hasUserMessage = payload.userMessage;
|
|
166
|
+
if (!hasAiConfig && !hasUserMessage) {
|
|
167
|
+
return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: 'instant 类型必须提供 userMessage 或完整的 AI 配置', details: { missingFields: ['userMessage or (completePrompt + apiUrl + apiKey + primaryModel)'] } };
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (payload.avatarUrl && !isValidUrl(payload.avatarUrl)) {
|
|
172
|
+
return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { invalidFields: ['avatarUrl (invalid URL format)'] } };
|
|
173
|
+
}
|
|
174
|
+
if (payload.uuid && !isValidUUID(payload.uuid)) {
|
|
175
|
+
return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { invalidFields: ['uuid (invalid UUID format)'] } };
|
|
176
|
+
}
|
|
177
|
+
if (payload.messageSubtype && !['chat', 'forum', 'moment'].includes(payload.messageSubtype)) {
|
|
178
|
+
return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { invalidFields: ['messageSubtype'] } };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return { valid: true };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Request payload utilities.
|
|
186
|
+
* Keeps body parsing and shape validation consistent across handlers.
|
|
187
|
+
*/
|
|
188
|
+
|
|
189
|
+
const REQUEST_ERRORS = {
|
|
190
|
+
INVALID_JSON: { code: 'INVALID_JSON', message: '请求体不是有效的 JSON' },
|
|
191
|
+
INVALID_REQUEST_BODY: { code: 'INVALID_REQUEST_BODY', message: '请求体格式无效' },
|
|
192
|
+
INVALID_ENCRYPTED_PAYLOAD: { code: 'INVALID_ENCRYPTED_PAYLOAD', message: '加密数据格式错误' }
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* @typedef {{ code: string, message: string }} ValidationError
|
|
197
|
+
*/
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* @typedef {{
|
|
201
|
+
* invalidJson?: ValidationError,
|
|
202
|
+
* invalidType?: ValidationError
|
|
203
|
+
* }} ParseBodyOptions
|
|
204
|
+
*/
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* @typedef {{
|
|
208
|
+
* ok: true,
|
|
209
|
+
* data: Record<string, any>
|
|
210
|
+
* } | {
|
|
211
|
+
* ok: false,
|
|
212
|
+
* error: ValidationError
|
|
213
|
+
* }} ParseBodyResult
|
|
214
|
+
*/
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Parse body into a JSON object.
|
|
218
|
+
*
|
|
219
|
+
* @param {unknown} body
|
|
220
|
+
* @param {ParseBodyOptions} [options]
|
|
221
|
+
* @returns {ParseBodyResult}
|
|
222
|
+
*/
|
|
223
|
+
function parseBodyAsObject(body, options = {}) {
|
|
224
|
+
const invalidJson = options.invalidJson || REQUEST_ERRORS.INVALID_JSON;
|
|
225
|
+
const invalidType = options.invalidType || REQUEST_ERRORS.INVALID_REQUEST_BODY;
|
|
226
|
+
|
|
227
|
+
let parsed = body;
|
|
228
|
+
if (typeof parsed === 'string') {
|
|
229
|
+
try {
|
|
230
|
+
parsed = JSON.parse(parsed);
|
|
231
|
+
} catch {
|
|
232
|
+
return { ok: false, error: invalidJson };
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (!isPlainObject(parsed)) {
|
|
237
|
+
return { ok: false, error: invalidType };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return { ok: true, data: parsed };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Parse a standard JSON object body.
|
|
245
|
+
*
|
|
246
|
+
* @param {unknown} body
|
|
247
|
+
* @returns {ParseBodyResult}
|
|
248
|
+
*/
|
|
249
|
+
function parseJsonBody(body) {
|
|
250
|
+
return parseBodyAsObject(body, {
|
|
251
|
+
invalidJson: REQUEST_ERRORS.INVALID_JSON,
|
|
252
|
+
invalidType: REQUEST_ERRORS.INVALID_REQUEST_BODY
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Check if a value is a plain object (and not null/array).
|
|
258
|
+
*
|
|
259
|
+
* @param {unknown} value
|
|
260
|
+
* @returns {value is Record<string, any>}
|
|
261
|
+
*/
|
|
262
|
+
function isPlainObject(value) {
|
|
263
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Check if an object follows the encrypted payload envelope shape.
|
|
268
|
+
*
|
|
269
|
+
* @param {unknown} payload
|
|
270
|
+
* @returns {payload is { iv: string, authTag: string, encryptedData: string }}
|
|
271
|
+
*/
|
|
272
|
+
function isEncryptedEnvelope(payload) {
|
|
273
|
+
if (!isPlainObject(payload)) return false;
|
|
274
|
+
|
|
275
|
+
return (
|
|
276
|
+
typeof payload.iv === 'string' &&
|
|
277
|
+
typeof payload.authTag === 'string' &&
|
|
278
|
+
typeof payload.encryptedData === 'string'
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Parse and validate an encrypted payload envelope.
|
|
284
|
+
*
|
|
285
|
+
* @param {unknown} body
|
|
286
|
+
* @returns {ParseBodyResult}
|
|
287
|
+
*/
|
|
288
|
+
function parseEncryptedBody(body) {
|
|
289
|
+
const parsedBody = parseBodyAsObject(body, {
|
|
290
|
+
invalidJson: REQUEST_ERRORS.INVALID_ENCRYPTED_PAYLOAD,
|
|
291
|
+
invalidType: REQUEST_ERRORS.INVALID_ENCRYPTED_PAYLOAD
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
if (!parsedBody.ok) {
|
|
295
|
+
return parsedBody;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (!isEncryptedEnvelope(parsedBody.data)) {
|
|
299
|
+
return { ok: false, error: REQUEST_ERRORS.INVALID_ENCRYPTED_PAYLOAD };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return parsedBody;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Read a header value case-insensitively.
|
|
307
|
+
*
|
|
308
|
+
* @param {Record<string, any>} headers
|
|
309
|
+
* @param {string} name
|
|
310
|
+
* @returns {string}
|
|
311
|
+
*/
|
|
312
|
+
function getHeader(headers = {}, name) {
|
|
313
|
+
if (!headers || typeof headers !== 'object') {
|
|
314
|
+
return '';
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const lowerName = String(name || '').toLowerCase();
|
|
318
|
+
if (!lowerName) return '';
|
|
319
|
+
|
|
320
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
321
|
+
if (String(key).toLowerCase() === lowerName) {
|
|
322
|
+
return String(value || '').trim();
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return '';
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Handler: init-tenant
|
|
331
|
+
* ReiStandard SDK v2.0.1
|
|
332
|
+
*
|
|
333
|
+
* @param {Object} ctx - Server context.
|
|
334
|
+
* @returns {{ POST: function }}
|
|
335
|
+
*/
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
function createInitTenantHandler(ctx) {
|
|
339
|
+
async function POST(headers, body) {
|
|
340
|
+
const initSecret = getHeader(headers, 'x-init-secret');
|
|
341
|
+
|
|
342
|
+
// If initSecret is configured, require a matching X-Init-Secret.
|
|
343
|
+
if (ctx.tenant.initSecret && initSecret !== ctx.tenant.initSecret) {
|
|
344
|
+
return {
|
|
345
|
+
status: 401,
|
|
346
|
+
body: {
|
|
347
|
+
success: false,
|
|
348
|
+
error: {
|
|
349
|
+
code: 'INVALID_INIT_AUTH',
|
|
350
|
+
message: '初始化鉴权失败'
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const parsed = parseJsonBody(body);
|
|
357
|
+
if (!parsed.ok) {
|
|
358
|
+
return {
|
|
359
|
+
status: 400,
|
|
360
|
+
body: {
|
|
361
|
+
success: false,
|
|
362
|
+
error: parsed.error
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const payload = parsed.data;
|
|
368
|
+
const tenantId = payload.tenantId || undefined;
|
|
369
|
+
const driver = payload.driver;
|
|
370
|
+
const databaseUrl = payload.databaseUrl || payload.connectionString;
|
|
371
|
+
|
|
372
|
+
if (tenantId && !isValidUUIDv4(tenantId)) {
|
|
373
|
+
return {
|
|
374
|
+
status: 400,
|
|
375
|
+
body: {
|
|
376
|
+
success: false,
|
|
377
|
+
error: {
|
|
378
|
+
code: 'INVALID_TENANT_ID',
|
|
379
|
+
message: 'tenantId 必须是 UUID v4 格式'
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (!databaseUrl || !driver) {
|
|
386
|
+
return {
|
|
387
|
+
status: 400,
|
|
388
|
+
body: {
|
|
389
|
+
success: false,
|
|
390
|
+
error: {
|
|
391
|
+
code: 'INVALID_PARAMETERS',
|
|
392
|
+
message: '缺少 driver 或 databaseUrl 参数'
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
try {
|
|
399
|
+
const result = await ctx.tenantManager.initializeTenant({
|
|
400
|
+
tenantId,
|
|
401
|
+
driver,
|
|
402
|
+
connectionString: databaseUrl
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
return {
|
|
406
|
+
status: 201,
|
|
407
|
+
body: {
|
|
408
|
+
success: true,
|
|
409
|
+
data: {
|
|
410
|
+
tenantId: result.tenantId,
|
|
411
|
+
tenantToken: result.tenantToken,
|
|
412
|
+
cronToken: result.cronToken,
|
|
413
|
+
cronWebhookUrl: result.cronWebhookUrl,
|
|
414
|
+
masterKeyFingerprint: result.masterKeyFingerprint
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
};
|
|
418
|
+
} catch (error) {
|
|
419
|
+
if (error.message === 'INVALID_DRIVER') {
|
|
420
|
+
return {
|
|
421
|
+
status: 400,
|
|
422
|
+
body: {
|
|
423
|
+
success: false,
|
|
424
|
+
error: {
|
|
425
|
+
code: 'INVALID_DRIVER',
|
|
426
|
+
message: 'driver 必须是 neon 或 pg'
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (error.message === 'INVALID_DATABASE_URL') {
|
|
433
|
+
return {
|
|
434
|
+
status: 400,
|
|
435
|
+
body: {
|
|
436
|
+
success: false,
|
|
437
|
+
error: {
|
|
438
|
+
code: 'INVALID_DATABASE_URL',
|
|
439
|
+
message: 'databaseUrl 不能为空'
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (error.message === 'TENANT_ALREADY_INITIALIZED') {
|
|
446
|
+
return {
|
|
447
|
+
status: 409,
|
|
448
|
+
body: {
|
|
449
|
+
success: false,
|
|
450
|
+
error: {
|
|
451
|
+
code: 'TENANT_ALREADY_INITIALIZED',
|
|
452
|
+
message: 'tenantId 已存在,不能重复初始化'
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
throw error;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
return { POST };
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Encryption utility library (SDK version)
|
|
467
|
+
* ReiStandard SDK v2.0.1
|
|
468
|
+
*
|
|
469
|
+
* Wraps AES-256-GCM operations for request/response and storage encryption.
|
|
470
|
+
*/
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Derive a user-specific encryption key from the master key.
|
|
475
|
+
*
|
|
476
|
+
* @param {string} userId - Unique user identifier.
|
|
477
|
+
* @param {string} masterKey - 64-char hex master key.
|
|
478
|
+
* @returns {string} 64-char hex key.
|
|
479
|
+
*/
|
|
480
|
+
function deriveUserEncryptionKey(userId, masterKey) {
|
|
481
|
+
return createHash('sha256')
|
|
482
|
+
.update(masterKey + userId)
|
|
483
|
+
.digest('hex')
|
|
484
|
+
.slice(0, 64);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Decrypt a client-encrypted request body (AES-256-GCM, base64 encoded).
|
|
489
|
+
*
|
|
490
|
+
* @param {{ iv: string, authTag: string, encryptedData: string }} encryptedPayload
|
|
491
|
+
* @param {string} encryptionKey - 64-char hex key.
|
|
492
|
+
* @returns {Object} Decrypted JSON object.
|
|
493
|
+
*/
|
|
494
|
+
function decryptPayload(encryptedPayload, encryptionKey) {
|
|
495
|
+
const { iv, authTag, encryptedData } = encryptedPayload;
|
|
496
|
+
|
|
497
|
+
const decipher = createDecipheriv(
|
|
498
|
+
'aes-256-gcm',
|
|
499
|
+
Buffer.from(encryptionKey, 'hex'),
|
|
500
|
+
Buffer.from(iv, 'base64')
|
|
501
|
+
);
|
|
502
|
+
|
|
503
|
+
decipher.setAuthTag(Buffer.from(authTag, 'base64'));
|
|
504
|
+
|
|
505
|
+
const decrypted = Buffer.concat([
|
|
506
|
+
decipher.update(Buffer.from(encryptedData, 'base64')),
|
|
507
|
+
decipher.final()
|
|
508
|
+
]);
|
|
509
|
+
|
|
510
|
+
return JSON.parse(decrypted.toString('utf8'));
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Encrypt a JSON payload for API transfer (AES-256-GCM, base64 encoded).
|
|
515
|
+
*
|
|
516
|
+
* @param {string|Object} payload
|
|
517
|
+
* @param {string} encryptionKey - 64-char hex key.
|
|
518
|
+
* @returns {{ iv: string, authTag: string, encryptedData: string }}
|
|
519
|
+
*/
|
|
520
|
+
function encryptPayload(payload, encryptionKey) {
|
|
521
|
+
const plaintext = typeof payload === 'string' ? payload : JSON.stringify(payload);
|
|
522
|
+
const iv = randomBytes(12);
|
|
523
|
+
const cipher = createCipheriv('aes-256-gcm', Buffer.from(encryptionKey, 'hex'), iv);
|
|
524
|
+
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
|
525
|
+
const authTag = cipher.getAuthTag();
|
|
526
|
+
|
|
527
|
+
return {
|
|
528
|
+
iv: iv.toString('base64'),
|
|
529
|
+
authTag: authTag.toString('base64'),
|
|
530
|
+
encryptedData: encrypted.toString('base64')
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Encrypt data for database storage (hex encoded, colon-separated).
|
|
536
|
+
*
|
|
537
|
+
* @param {string} text - Plaintext string.
|
|
538
|
+
* @param {string} encryptionKey - 64-char hex key.
|
|
539
|
+
* @returns {string} Format: iv:authTag:encryptedData
|
|
540
|
+
*/
|
|
541
|
+
function encryptForStorage(text, encryptionKey) {
|
|
542
|
+
const iv = randomBytes(16);
|
|
543
|
+
const cipher = createCipheriv('aes-256-gcm', Buffer.from(encryptionKey, 'hex'), iv);
|
|
544
|
+
const encrypted = cipher.update(text, 'utf8', 'hex') + cipher.final('hex');
|
|
545
|
+
const authTag = cipher.getAuthTag();
|
|
546
|
+
return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Decrypt data from database storage format.
|
|
551
|
+
*
|
|
552
|
+
* @param {string} encryptedText - Format: iv:authTag:encryptedData
|
|
553
|
+
* @param {string} encryptionKey - 64-char hex key.
|
|
554
|
+
* @returns {string} Plaintext string.
|
|
555
|
+
*/
|
|
556
|
+
function decryptFromStorage(encryptedText, encryptionKey) {
|
|
557
|
+
const [ivHex, authTagHex, encryptedDataHex] = encryptedText.split(':');
|
|
558
|
+
const decipher = createDecipheriv(
|
|
559
|
+
'aes-256-gcm',
|
|
560
|
+
Buffer.from(encryptionKey, 'hex'),
|
|
561
|
+
Buffer.from(ivHex, 'hex')
|
|
562
|
+
);
|
|
563
|
+
decipher.setAuthTag(Buffer.from(authTagHex, 'hex'));
|
|
564
|
+
return decipher.update(encryptedDataHex, 'hex', 'utf8') + decipher.final('utf8');
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Handler: get-user-key
|
|
569
|
+
* ReiStandard SDK v2.0.1
|
|
570
|
+
*
|
|
571
|
+
* @param {Object} ctx - Server context.
|
|
572
|
+
* @returns {{ GET: function }}
|
|
573
|
+
*/
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
function createGetUserKeyHandler(ctx) {
|
|
577
|
+
async function GET(url, headers) {
|
|
578
|
+
const effectiveHeaders = headers || url || {};
|
|
579
|
+
const tenantResult = await ctx.tenantManager.resolveTenant(effectiveHeaders);
|
|
580
|
+
if (!tenantResult.ok) {
|
|
581
|
+
return tenantResult.error;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const { masterKey } = tenantResult.context;
|
|
585
|
+
const userId = getHeader(effectiveHeaders, 'x-user-id');
|
|
586
|
+
|
|
587
|
+
if (!userId) {
|
|
588
|
+
return {
|
|
589
|
+
status: 400,
|
|
590
|
+
body: { success: false, error: { code: 'USER_ID_REQUIRED', message: '缺少用户标识符' } }
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
if (!isValidUUIDv4(userId)) {
|
|
595
|
+
return {
|
|
596
|
+
status: 400,
|
|
597
|
+
body: { success: false, error: { code: 'INVALID_USER_ID_FORMAT', message: 'X-User-Id 必须是 UUID v4 格式' } }
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
return {
|
|
602
|
+
status: 200,
|
|
603
|
+
body: {
|
|
604
|
+
success: true,
|
|
605
|
+
data: {
|
|
606
|
+
userKey: deriveUserEncryptionKey(userId, masterKey),
|
|
607
|
+
version: 1
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
return { GET };
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Database error helpers.
|
|
618
|
+
*/
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Check whether an error is caused by a unique-constraint violation.
|
|
622
|
+
*
|
|
623
|
+
* @param {unknown} error
|
|
624
|
+
* @returns {boolean}
|
|
625
|
+
*/
|
|
626
|
+
function isUniqueViolation(error) {
|
|
627
|
+
if (!error || typeof error !== 'object') return false;
|
|
628
|
+
|
|
629
|
+
const code = error.code;
|
|
630
|
+
if (code === '23505') return true;
|
|
631
|
+
|
|
632
|
+
const message = typeof error.message === 'string' ? error.message.toLowerCase() : '';
|
|
633
|
+
return message.includes('duplicate key') || message.includes('unique constraint');
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* Message Processor (SDK version)
|
|
638
|
+
* ReiStandard SDK v2.0.1
|
|
639
|
+
*
|
|
640
|
+
* Handles single message content generation and Web Push delivery.
|
|
641
|
+
* Receives its dependencies (encryption helpers, webpush, VAPID config)
|
|
642
|
+
* via a context object so that it stays free of process.env references.
|
|
643
|
+
*/
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* @typedef {Object} ProcessorContext
|
|
648
|
+
* @property {Object} webpush - The web-push module instance (already VAPID-configured).
|
|
649
|
+
* @property {Object} vapid - { email, publicKey, privateKey }
|
|
650
|
+
* @property {import('../adapters/interface.js').DbAdapter} db
|
|
651
|
+
*/
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Process a single database task row: decrypt → generate content → push.
|
|
655
|
+
*
|
|
656
|
+
* @param {import('../adapters/interface.js').TaskRow} task
|
|
657
|
+
* @param {ProcessorContext} ctx
|
|
658
|
+
* @param {string} [providedMasterKey]
|
|
659
|
+
* @returns {Promise<{ success: boolean, messagesSent: number, error?: string }>}
|
|
660
|
+
*/
|
|
661
|
+
async function processSingleMessage(task, ctx, providedMasterKey) {
|
|
662
|
+
try {
|
|
663
|
+
const masterKey = providedMasterKey || ctx.masterKey;
|
|
664
|
+
if (!masterKey) {
|
|
665
|
+
return { success: false, messagesSent: 0, error: 'TENANT_MASTER_KEY_MISSING' };
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
const userKey = deriveUserEncryptionKey(task.user_id, masterKey);
|
|
669
|
+
const decryptedPayload = JSON.parse(decryptFromStorage(task.encrypted_payload, userKey));
|
|
670
|
+
|
|
671
|
+
let messageContent;
|
|
672
|
+
|
|
673
|
+
if (decryptedPayload.messageType === 'fixed') {
|
|
674
|
+
messageContent = decryptedPayload.userMessage;
|
|
675
|
+
|
|
676
|
+
} else if (decryptedPayload.messageType === 'instant') {
|
|
677
|
+
if (decryptedPayload.completePrompt && decryptedPayload.apiUrl && decryptedPayload.apiKey && decryptedPayload.primaryModel) {
|
|
678
|
+
messageContent = await _callAI(decryptedPayload);
|
|
679
|
+
} else if (decryptedPayload.userMessage) {
|
|
680
|
+
messageContent = decryptedPayload.userMessage;
|
|
681
|
+
} else {
|
|
682
|
+
throw new Error('Invalid instant message: no content source available');
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
} else if (decryptedPayload.messageType === 'prompted' || decryptedPayload.messageType === 'auto') {
|
|
686
|
+
messageContent = await _callAI(decryptedPayload);
|
|
687
|
+
} else {
|
|
688
|
+
throw new Error('Invalid message configuration: no content source available');
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// Sentence splitting
|
|
692
|
+
const sentences = messageContent
|
|
693
|
+
.split(/([。!?!?]+)/)
|
|
694
|
+
.reduce((acc, part, i, arr) => {
|
|
695
|
+
if (i % 2 === 0 && part.trim()) {
|
|
696
|
+
const punctuation = arr[i + 1] || '';
|
|
697
|
+
acc.push(part.trim() + punctuation);
|
|
698
|
+
}
|
|
699
|
+
return acc;
|
|
700
|
+
}, [])
|
|
701
|
+
.filter(s => s.length > 0);
|
|
702
|
+
|
|
703
|
+
const messages = sentences.length > 0 ? sentences : [messageContent];
|
|
704
|
+
|
|
705
|
+
if (!ctx.vapid.email || !ctx.vapid.publicKey || !ctx.vapid.privateKey) {
|
|
706
|
+
throw new Error('VAPID configuration missing - push notifications cannot be sent');
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
const pushSubscription = decryptedPayload.pushSubscription;
|
|
710
|
+
|
|
711
|
+
for (let i = 0; i < messages.length; i++) {
|
|
712
|
+
const notificationPayload = {
|
|
713
|
+
title: `来自 ${decryptedPayload.contactName}`,
|
|
714
|
+
message: messages[i],
|
|
715
|
+
contactName: decryptedPayload.contactName,
|
|
716
|
+
messageId: `msg_${randomUUID()}_${task.id || 'instant'}_${i}`,
|
|
717
|
+
messageIndex: i + 1,
|
|
718
|
+
totalMessages: messages.length,
|
|
719
|
+
messageType: decryptedPayload.messageType,
|
|
720
|
+
messageSubtype: decryptedPayload.messageSubtype || 'chat',
|
|
721
|
+
taskId: task.id || null,
|
|
722
|
+
timestamp: new Date().toISOString(),
|
|
723
|
+
source: decryptedPayload.messageType === 'instant' ? 'instant' : 'scheduled',
|
|
724
|
+
avatarUrl: decryptedPayload.avatarUrl || null,
|
|
725
|
+
metadata: decryptedPayload.metadata || {}
|
|
726
|
+
};
|
|
727
|
+
|
|
728
|
+
await ctx.webpush.sendNotification(pushSubscription, JSON.stringify(notificationPayload));
|
|
729
|
+
|
|
730
|
+
if (i < messages.length - 1) {
|
|
731
|
+
await new Promise(resolve => setTimeout(resolve, 1500));
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
return { success: true, messagesSent: messages.length };
|
|
736
|
+
|
|
737
|
+
} catch (error) {
|
|
738
|
+
return { success: false, messagesSent: 0, error: error.message };
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* Process a single message identified by UUID (used for instant type).
|
|
744
|
+
*
|
|
745
|
+
* @param {string} uuid
|
|
746
|
+
* @param {ProcessorContext} ctx
|
|
747
|
+
* @param {number} [maxRetries=2]
|
|
748
|
+
* @param {string} [userId]
|
|
749
|
+
* @param {string} [providedMasterKey]
|
|
750
|
+
* @returns {Promise<{ success: boolean, messagesSent?: number, retriesUsed?: number, error?: Object }>}
|
|
751
|
+
*/
|
|
752
|
+
async function processMessagesByUuid(uuid, ctx, maxRetries = 2, userId, providedMasterKey) {
|
|
753
|
+
let retryCount = 0;
|
|
754
|
+
const masterKey = providedMasterKey || ctx.masterKey;
|
|
755
|
+
|
|
756
|
+
if (!masterKey) {
|
|
757
|
+
return {
|
|
758
|
+
success: false,
|
|
759
|
+
error: { code: 'TENANT_MASTER_KEY_MISSING', message: '租户主密钥不存在或配置异常' }
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
while (retryCount <= maxRetries) {
|
|
764
|
+
let task;
|
|
765
|
+
try {
|
|
766
|
+
task = userId
|
|
767
|
+
? await ctx.db.getTaskByUuid(uuid, userId)
|
|
768
|
+
: await ctx.db.getTaskByUuidOnly(uuid);
|
|
769
|
+
} catch (error) {
|
|
770
|
+
if (retryCount < maxRetries) {
|
|
771
|
+
retryCount++;
|
|
772
|
+
await new Promise(resolve => setTimeout(resolve, 1000 * retryCount));
|
|
773
|
+
continue;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
return {
|
|
777
|
+
success: false,
|
|
778
|
+
error: { code: 'INTERNAL_ERROR', message: error.message, retriesAttempted: retryCount }
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
if (!task) {
|
|
783
|
+
return { success: false, error: { code: 'TASK_NOT_FOUND', message: '任务不存在或已处理' } };
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
const result = await processSingleMessage(task, ctx, masterKey);
|
|
787
|
+
|
|
788
|
+
if (!result.success) {
|
|
789
|
+
if (retryCount < maxRetries) {
|
|
790
|
+
retryCount++;
|
|
791
|
+
await new Promise(resolve => setTimeout(resolve, 1000 * retryCount));
|
|
792
|
+
continue;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
try {
|
|
796
|
+
await ctx.db.updateTaskById(task.id, { status: 'failed', retry_count: retryCount });
|
|
797
|
+
} catch (_updateError) {
|
|
798
|
+
// best-effort status update; keep original processing error as primary signal
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
return {
|
|
802
|
+
success: false,
|
|
803
|
+
error: { code: 'PROCESSING_ERROR', message: result.error, retriesAttempted: retryCount }
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
try {
|
|
808
|
+
await ctx.db.deleteTaskById(task.id);
|
|
809
|
+
} catch (error) {
|
|
810
|
+
try {
|
|
811
|
+
await ctx.db.updateTaskById(task.id, { status: 'sent', retry_count: 0 });
|
|
812
|
+
} catch (_markSentError) {
|
|
813
|
+
// best effort: avoid re-sending if storage mutation partially fails
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
return {
|
|
817
|
+
success: false,
|
|
818
|
+
error: {
|
|
819
|
+
code: 'POST_SEND_CLEANUP_FAILED',
|
|
820
|
+
message: '消息已发送,但任务清理失败',
|
|
821
|
+
details: { error: error.message }
|
|
822
|
+
}
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
return { success: true, messagesSent: result.messagesSent, retriesUsed: retryCount };
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/**
|
|
831
|
+
* Call an OpenAI-compatible API.
|
|
832
|
+
* @private
|
|
833
|
+
*/
|
|
834
|
+
async function _callAI(payload) {
|
|
835
|
+
const normalizedApiUrl = normalizeAiApiUrl(payload.apiUrl);
|
|
836
|
+
const requestBody = buildAiRequestBody(payload);
|
|
837
|
+
|
|
838
|
+
const aiResponse = await fetch(normalizedApiUrl, {
|
|
839
|
+
method: 'POST',
|
|
840
|
+
headers: {
|
|
841
|
+
'Content-Type': 'application/json',
|
|
842
|
+
'Authorization': `Bearer ${payload.apiKey}`
|
|
843
|
+
},
|
|
844
|
+
body: JSON.stringify(requestBody),
|
|
845
|
+
signal: AbortSignal.timeout(300000)
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
if (!aiResponse.ok) {
|
|
849
|
+
if (aiResponse.status === 405) {
|
|
850
|
+
throw new Error(
|
|
851
|
+
`AI API error: 405 Method Not Allowed. ` +
|
|
852
|
+
`apiUrl must point to a full chat endpoint (for example: /chat/completions). ` +
|
|
853
|
+
`Received: ${normalizedApiUrl}`
|
|
854
|
+
);
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
throw new Error(
|
|
858
|
+
`AI API error: ${aiResponse.status} ${aiResponse.statusText || 'Unknown Error'}. ` +
|
|
859
|
+
`Request URL: ${normalizedApiUrl}`
|
|
860
|
+
);
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
const aiData = await aiResponse.json();
|
|
864
|
+
const content = aiData?.choices?.[0]?.message?.content;
|
|
865
|
+
if (typeof content !== 'string' || !content.trim()) {
|
|
866
|
+
throw new Error('AI API error: response missing choices[0].message.content');
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
return content.trim();
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/**
|
|
873
|
+
* Build OpenAI-compatible request body.
|
|
874
|
+
*
|
|
875
|
+
* `max_tokens` is optional:
|
|
876
|
+
* - include it only when payload.maxTokens is provided
|
|
877
|
+
* - omit it when payload.maxTokens is undefined / null
|
|
878
|
+
*
|
|
879
|
+
* @param {Object} payload
|
|
880
|
+
* @returns {Object}
|
|
881
|
+
*/
|
|
882
|
+
function buildAiRequestBody(payload) {
|
|
883
|
+
const requestBody = {
|
|
884
|
+
model: payload.primaryModel,
|
|
885
|
+
messages: [{ role: 'user', content: payload.completePrompt }],
|
|
886
|
+
temperature: 0.8
|
|
887
|
+
};
|
|
888
|
+
|
|
889
|
+
if (payload.maxTokens === undefined || payload.maxTokens === null) {
|
|
890
|
+
return requestBody;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
if (!Number.isInteger(payload.maxTokens) || payload.maxTokens <= 0) {
|
|
894
|
+
throw new Error('Invalid maxTokens: maxTokens must be a positive integer when provided.');
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
requestBody.max_tokens = payload.maxTokens;
|
|
898
|
+
return requestBody;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/**
|
|
902
|
+
* Normalize AI API URL with minimal, non-breaking rules:
|
|
903
|
+
* - trim whitespace
|
|
904
|
+
* - remove redundant trailing slashes from pathname
|
|
905
|
+
* - keep query string untouched
|
|
906
|
+
*
|
|
907
|
+
* Note: This function does NOT auto-append /v1 or /chat/completions.
|
|
908
|
+
*
|
|
909
|
+
* @param {string} apiUrl
|
|
910
|
+
* @returns {string}
|
|
911
|
+
*/
|
|
912
|
+
function normalizeAiApiUrl(apiUrl) {
|
|
913
|
+
if (typeof apiUrl !== 'string' || !apiUrl.trim()) {
|
|
914
|
+
throw new Error(
|
|
915
|
+
'Invalid apiUrl: apiUrl is required. ' +
|
|
916
|
+
'Please provide a full chat endpoint URL (for example: https://api.openai.com/v1/chat/completions).'
|
|
917
|
+
);
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
const trimmedApiUrl = apiUrl.trim();
|
|
921
|
+
let parsedUrl;
|
|
922
|
+
|
|
923
|
+
try {
|
|
924
|
+
parsedUrl = new URL(trimmedApiUrl);
|
|
925
|
+
} catch {
|
|
926
|
+
throw new Error(
|
|
927
|
+
`Invalid apiUrl: "${apiUrl}". ` +
|
|
928
|
+
'Please provide a valid absolute URL that points to a full chat endpoint.'
|
|
929
|
+
);
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
parsedUrl.pathname = parsedUrl.pathname.replace(/\/+$/, '') || '/';
|
|
933
|
+
return parsedUrl.toString();
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
/**
|
|
937
|
+
* Handler: schedule-message
|
|
938
|
+
* ReiStandard SDK v2.0.1
|
|
939
|
+
*
|
|
940
|
+
* @param {Object} ctx - Server context.
|
|
941
|
+
* @returns {{ POST: function }}
|
|
942
|
+
*/
|
|
943
|
+
|
|
944
|
+
|
|
945
|
+
function createScheduleMessageHandler(ctx) {
|
|
946
|
+
async function POST(headers, body) {
|
|
947
|
+
const tenantResult = await ctx.tenantManager.resolveTenant(headers);
|
|
948
|
+
if (!tenantResult.ok) {
|
|
949
|
+
return tenantResult.error;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
const tenantCtx = tenantResult.context;
|
|
953
|
+
const db = tenantCtx.db;
|
|
954
|
+
const masterKey = tenantCtx.masterKey;
|
|
955
|
+
const isEncrypted = getHeader(headers, 'x-payload-encrypted') === 'true';
|
|
956
|
+
const encryptionVersion = getHeader(headers, 'x-encryption-version');
|
|
957
|
+
const userId = getHeader(headers, 'x-user-id');
|
|
958
|
+
|
|
959
|
+
if (!isEncrypted) {
|
|
960
|
+
return { status: 400, body: { success: false, error: { code: 'ENCRYPTION_REQUIRED', message: '请求体必须加密' } } };
|
|
961
|
+
}
|
|
962
|
+
if (!userId) {
|
|
963
|
+
return { status: 400, body: { success: false, error: { code: 'USER_ID_REQUIRED', message: '缺少用户标识符' } } };
|
|
964
|
+
}
|
|
965
|
+
if (!isValidUUIDv4(userId)) {
|
|
966
|
+
return { status: 400, body: { success: false, error: { code: 'INVALID_USER_ID_FORMAT', message: 'X-User-Id 必须是 UUID v4 格式' } } };
|
|
967
|
+
}
|
|
968
|
+
if (encryptionVersion !== '1') {
|
|
969
|
+
return { status: 400, body: { success: false, error: { code: 'UNSUPPORTED_ENCRYPTION_VERSION', message: '加密版本不支持' } } };
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
// Decrypt request body
|
|
973
|
+
const parsedBody = parseEncryptedBody(body);
|
|
974
|
+
if (!parsedBody.ok) {
|
|
975
|
+
return { status: 400, body: { success: false, error: parsedBody.error } };
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
const encryptedBody = parsedBody.data;
|
|
979
|
+
|
|
980
|
+
let payload;
|
|
981
|
+
try {
|
|
982
|
+
const userKey = deriveUserEncryptionKey(userId, masterKey);
|
|
983
|
+
payload = decryptPayload(encryptedBody, userKey);
|
|
984
|
+
} catch (error) {
|
|
985
|
+
if (error instanceof SyntaxError) {
|
|
986
|
+
return { status: 400, body: { success: false, error: { code: 'INVALID_PAYLOAD_FORMAT', message: '解密后的数据不是有效 JSON' } } };
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
const message = typeof error.message === 'string' ? error.message : '';
|
|
990
|
+
if (message.includes('auth') || message.includes('Unsupported state')) {
|
|
991
|
+
return { status: 400, body: { success: false, error: { code: 'DECRYPTION_FAILED', message: '请求体解密失败' } } };
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
return { status: 400, body: { success: false, error: { code: 'DECRYPTION_FAILED', message: '请求体解密失败' } } };
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
if (!isPlainObject(payload)) {
|
|
998
|
+
return { status: 400, body: { success: false, error: { code: 'INVALID_PAYLOAD_FORMAT', message: '解密后的数据必须是 JSON 对象' } } };
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
// Validate
|
|
1002
|
+
const validationResult = validateScheduleMessagePayload(payload);
|
|
1003
|
+
if (!validationResult.valid) {
|
|
1004
|
+
return { status: 400, body: { success: false, error: { code: validationResult.errorCode, message: validationResult.errorMessage, details: validationResult.details } } };
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
const taskUuid = payload.uuid || randomUUID();
|
|
1008
|
+
const userKey = deriveUserEncryptionKey(userId, masterKey);
|
|
1009
|
+
|
|
1010
|
+
const fullTaskData = {
|
|
1011
|
+
contactName: payload.contactName,
|
|
1012
|
+
avatarUrl: payload.avatarUrl || null,
|
|
1013
|
+
messageType: payload.messageType,
|
|
1014
|
+
messageSubtype: payload.messageSubtype || 'chat',
|
|
1015
|
+
userMessage: payload.userMessage || null,
|
|
1016
|
+
firstSendTime: payload.firstSendTime,
|
|
1017
|
+
recurrenceType: payload.recurrenceType || 'none',
|
|
1018
|
+
apiUrl: payload.apiUrl || null,
|
|
1019
|
+
apiKey: payload.apiKey || null,
|
|
1020
|
+
primaryModel: payload.primaryModel || null,
|
|
1021
|
+
completePrompt: payload.completePrompt || null,
|
|
1022
|
+
maxTokens: payload.maxTokens ?? null,
|
|
1023
|
+
pushSubscription: payload.pushSubscription,
|
|
1024
|
+
metadata: payload.metadata || {}
|
|
1025
|
+
};
|
|
1026
|
+
|
|
1027
|
+
const encryptedPayload = encryptForStorage(JSON.stringify(fullTaskData), userKey);
|
|
1028
|
+
|
|
1029
|
+
// Instant type: check VAPID before creating the task to avoid orphaned rows
|
|
1030
|
+
if (payload.messageType === 'instant') {
|
|
1031
|
+
if (!ctx.vapid.email || !ctx.vapid.publicKey || !ctx.vapid.privateKey) {
|
|
1032
|
+
return {
|
|
1033
|
+
status: 500,
|
|
1034
|
+
body: {
|
|
1035
|
+
success: false,
|
|
1036
|
+
error: {
|
|
1037
|
+
code: 'VAPID_CONFIG_ERROR',
|
|
1038
|
+
message: 'VAPID 配置缺失,无法发送即时消息',
|
|
1039
|
+
details: {
|
|
1040
|
+
missingKeys: [
|
|
1041
|
+
!ctx.vapid.email && 'VAPID_EMAIL',
|
|
1042
|
+
!ctx.vapid.publicKey && 'NEXT_PUBLIC_VAPID_PUBLIC_KEY',
|
|
1043
|
+
!ctx.vapid.privateKey && 'VAPID_PRIVATE_KEY'
|
|
1044
|
+
].filter(Boolean)
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
};
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// Insert into database
|
|
1053
|
+
let dbResult;
|
|
1054
|
+
try {
|
|
1055
|
+
dbResult = await db.createTask({
|
|
1056
|
+
user_id: userId,
|
|
1057
|
+
uuid: taskUuid,
|
|
1058
|
+
encrypted_payload: encryptedPayload,
|
|
1059
|
+
next_send_at: payload.firstSendTime,
|
|
1060
|
+
message_type: payload.messageType
|
|
1061
|
+
});
|
|
1062
|
+
} catch (error) {
|
|
1063
|
+
if (isUniqueViolation(error)) {
|
|
1064
|
+
return {
|
|
1065
|
+
status: 409,
|
|
1066
|
+
body: {
|
|
1067
|
+
success: false,
|
|
1068
|
+
error: {
|
|
1069
|
+
code: 'TASK_UUID_CONFLICT',
|
|
1070
|
+
message: '任务 UUID 已存在,请使用新的 uuid 重新提交'
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
};
|
|
1074
|
+
}
|
|
1075
|
+
throw error;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
if (!dbResult) {
|
|
1079
|
+
return { status: 500, body: { success: false, error: { code: 'TASK_CREATE_FAILED', message: '创建任务失败' } } };
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
// Instant type: send immediately
|
|
1083
|
+
if (payload.messageType === 'instant') {
|
|
1084
|
+
try {
|
|
1085
|
+
const sendResult = await processMessagesByUuid(taskUuid, {
|
|
1086
|
+
...ctx,
|
|
1087
|
+
db,
|
|
1088
|
+
masterKey
|
|
1089
|
+
}, 2, userId, masterKey);
|
|
1090
|
+
|
|
1091
|
+
if (!sendResult.success) {
|
|
1092
|
+
return { status: 500, body: { success: false, error: { code: 'MESSAGE_SEND_FAILED', message: '消息发送失败', details: sendResult.error } } };
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
return {
|
|
1096
|
+
status: 200,
|
|
1097
|
+
body: {
|
|
1098
|
+
success: true,
|
|
1099
|
+
data: {
|
|
1100
|
+
uuid: taskUuid,
|
|
1101
|
+
contactName: payload.contactName,
|
|
1102
|
+
messagesSent: sendResult.messagesSent,
|
|
1103
|
+
sentAt: new Date().toISOString(),
|
|
1104
|
+
status: 'sent',
|
|
1105
|
+
retriesUsed: sendResult.retriesUsed || 0
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
};
|
|
1109
|
+
} catch (error) {
|
|
1110
|
+
return { status: 500, body: { success: false, error: { code: 'MESSAGE_SEND_FAILED', message: '消息发送失败', details: { error: error.message } } } };
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
// Non-instant: return scheduled response
|
|
1115
|
+
return {
|
|
1116
|
+
status: 201,
|
|
1117
|
+
body: {
|
|
1118
|
+
success: true,
|
|
1119
|
+
data: {
|
|
1120
|
+
id: dbResult.id,
|
|
1121
|
+
uuid: dbResult.uuid,
|
|
1122
|
+
contactName: payload.contactName,
|
|
1123
|
+
nextSendAt: dbResult.next_send_at,
|
|
1124
|
+
status: dbResult.status,
|
|
1125
|
+
createdAt: dbResult.created_at
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
return { POST };
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
/**
|
|
1135
|
+
* Handler: send-notifications
|
|
1136
|
+
* ReiStandard SDK v2.0.1
|
|
1137
|
+
*
|
|
1138
|
+
* @param {Object} ctx - Server context.
|
|
1139
|
+
* @returns {{ POST: function }}
|
|
1140
|
+
*/
|
|
1141
|
+
|
|
1142
|
+
|
|
1143
|
+
function createSendNotificationsHandler(ctx) {
|
|
1144
|
+
async function POST(urlOrHeaders, maybeHeaders) {
|
|
1145
|
+
const url = typeof urlOrHeaders === 'string' ? urlOrHeaders : '';
|
|
1146
|
+
const headers = maybeHeaders || (typeof urlOrHeaders === 'object' ? urlOrHeaders : {});
|
|
1147
|
+
|
|
1148
|
+
const tenantResult = await ctx.tenantManager.resolveTenant(headers, {
|
|
1149
|
+
allowCronToken: true,
|
|
1150
|
+
url
|
|
1151
|
+
});
|
|
1152
|
+
if (!tenantResult.ok) {
|
|
1153
|
+
return tenantResult.error;
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
const tenantCtx = tenantResult.context;
|
|
1157
|
+
const db = tenantCtx.db;
|
|
1158
|
+
const masterKey = tenantCtx.masterKey;
|
|
1159
|
+
|
|
1160
|
+
if (!ctx.vapid.email || !ctx.vapid.publicKey || !ctx.vapid.privateKey) {
|
|
1161
|
+
return {
|
|
1162
|
+
status: 500,
|
|
1163
|
+
body: {
|
|
1164
|
+
success: false,
|
|
1165
|
+
error: {
|
|
1166
|
+
code: 'VAPID_CONFIG_ERROR',
|
|
1167
|
+
message: 'VAPID 配置缺失,无法发送推送通知',
|
|
1168
|
+
details: {
|
|
1169
|
+
missingKeys: [
|
|
1170
|
+
!ctx.vapid.email && 'VAPID_EMAIL',
|
|
1171
|
+
!ctx.vapid.publicKey && 'NEXT_PUBLIC_VAPID_PUBLIC_KEY',
|
|
1172
|
+
!ctx.vapid.privateKey && 'VAPID_PRIVATE_KEY'
|
|
1173
|
+
].filter(Boolean)
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
};
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
const startTime = Date.now();
|
|
1181
|
+
const tasks = await db.getPendingTasks(50);
|
|
1182
|
+
|
|
1183
|
+
const MAX_CONCURRENT = 8;
|
|
1184
|
+
const results = {
|
|
1185
|
+
totalTasks: tasks.length,
|
|
1186
|
+
successCount: 0,
|
|
1187
|
+
failedCount: 0,
|
|
1188
|
+
deletedOnceOffTasks: 0,
|
|
1189
|
+
updatedRecurringTasks: 0,
|
|
1190
|
+
failedTasks: []
|
|
1191
|
+
};
|
|
1192
|
+
|
|
1193
|
+
async function handleDeliveryFailure(task, reason) {
|
|
1194
|
+
results.failedCount++;
|
|
1195
|
+
|
|
1196
|
+
try {
|
|
1197
|
+
if (task.retry_count >= 3) {
|
|
1198
|
+
await db.updateTaskById(task.id, { status: 'failed' });
|
|
1199
|
+
results.failedTasks.push({ taskId: task.id, reason, retryCount: task.retry_count, status: 'permanently_failed' });
|
|
1200
|
+
} else {
|
|
1201
|
+
const nextRetryTime = new Date(Date.now() + (task.retry_count + 1) * 2 * 60 * 1000);
|
|
1202
|
+
await db.updateTaskById(task.id, { next_send_at: nextRetryTime.toISOString(), retry_count: task.retry_count + 1 });
|
|
1203
|
+
results.failedTasks.push({ taskId: task.id, reason, retryCount: task.retry_count + 1, nextRetryAt: nextRetryTime.toISOString() });
|
|
1204
|
+
}
|
|
1205
|
+
} catch (updateError) {
|
|
1206
|
+
results.failedTasks.push({
|
|
1207
|
+
taskId: task.id,
|
|
1208
|
+
reason,
|
|
1209
|
+
status: 'retry_update_failed',
|
|
1210
|
+
updateError: updateError.message
|
|
1211
|
+
});
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
async function handlePostSendPersistenceFailure(task, reason) {
|
|
1216
|
+
results.failedCount++;
|
|
1217
|
+
|
|
1218
|
+
let markedSent = false;
|
|
1219
|
+
try {
|
|
1220
|
+
await db.updateTaskById(task.id, { status: 'sent', retry_count: 0 });
|
|
1221
|
+
markedSent = true;
|
|
1222
|
+
} catch (_markSentError) {
|
|
1223
|
+
markedSent = false;
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
results.failedTasks.push({
|
|
1227
|
+
taskId: task.id,
|
|
1228
|
+
reason,
|
|
1229
|
+
status: markedSent ? 'post_send_cleanup_failed_marked_sent' : 'post_send_cleanup_failed',
|
|
1230
|
+
messageDelivered: true
|
|
1231
|
+
});
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
async function processTask(task) {
|
|
1235
|
+
let sendResult;
|
|
1236
|
+
try {
|
|
1237
|
+
sendResult = await processSingleMessage(task, {
|
|
1238
|
+
...ctx,
|
|
1239
|
+
db,
|
|
1240
|
+
masterKey
|
|
1241
|
+
}, masterKey);
|
|
1242
|
+
} catch (error) {
|
|
1243
|
+
await handleDeliveryFailure(task, error.message || '消息发送失败');
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
if (!sendResult.success) {
|
|
1248
|
+
await handleDeliveryFailure(task, sendResult.error || '消息发送失败');
|
|
1249
|
+
return;
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
try {
|
|
1253
|
+
const userKey = deriveUserEncryptionKey(task.user_id, masterKey);
|
|
1254
|
+
const decryptedPayload = JSON.parse(decryptFromStorage(task.encrypted_payload, userKey));
|
|
1255
|
+
|
|
1256
|
+
if (decryptedPayload.recurrenceType === 'none') {
|
|
1257
|
+
await db.deleteTaskById(task.id);
|
|
1258
|
+
results.deletedOnceOffTasks++;
|
|
1259
|
+
} else {
|
|
1260
|
+
let nextSendAt;
|
|
1261
|
+
const currentSendAt = new Date(task.next_send_at);
|
|
1262
|
+
if (decryptedPayload.recurrenceType === 'daily') {
|
|
1263
|
+
nextSendAt = new Date(currentSendAt.getTime() + 24 * 60 * 60 * 1000);
|
|
1264
|
+
} else if (decryptedPayload.recurrenceType === 'weekly') {
|
|
1265
|
+
nextSendAt = new Date(currentSendAt.getTime() + 7 * 24 * 60 * 60 * 1000);
|
|
1266
|
+
}
|
|
1267
|
+
await db.updateTaskById(task.id, { next_send_at: nextSendAt.toISOString(), retry_count: 0 });
|
|
1268
|
+
results.updatedRecurringTasks++;
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
results.successCount++;
|
|
1272
|
+
} catch (error) {
|
|
1273
|
+
await handlePostSendPersistenceFailure(task, error.message || '发送后状态更新失败');
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
// Dynamic task pool
|
|
1278
|
+
const taskQueue = [...tasks];
|
|
1279
|
+
const processing = [];
|
|
1280
|
+
|
|
1281
|
+
while (taskQueue.length > 0 || processing.length > 0) {
|
|
1282
|
+
while (processing.length < MAX_CONCURRENT && taskQueue.length > 0) {
|
|
1283
|
+
const task = taskQueue.shift();
|
|
1284
|
+
const promise = processTask(task);
|
|
1285
|
+
processing.push(promise);
|
|
1286
|
+
promise.finally(() => {
|
|
1287
|
+
const index = processing.indexOf(promise);
|
|
1288
|
+
if (index > -1) processing.splice(index, 1);
|
|
1289
|
+
});
|
|
1290
|
+
}
|
|
1291
|
+
if (processing.length > 0) {
|
|
1292
|
+
await Promise.race(processing);
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
// Cleanup old tasks
|
|
1297
|
+
await db.cleanupOldTasks(7);
|
|
1298
|
+
|
|
1299
|
+
const executionTime = Date.now() - startTime;
|
|
1300
|
+
|
|
1301
|
+
return {
|
|
1302
|
+
status: 200,
|
|
1303
|
+
body: {
|
|
1304
|
+
success: true,
|
|
1305
|
+
data: {
|
|
1306
|
+
totalTasks: results.totalTasks,
|
|
1307
|
+
successCount: results.successCount,
|
|
1308
|
+
failedCount: results.failedCount,
|
|
1309
|
+
processedAt: new Date().toISOString(),
|
|
1310
|
+
executionTime,
|
|
1311
|
+
details: {
|
|
1312
|
+
deletedOnceOffTasks: results.deletedOnceOffTasks,
|
|
1313
|
+
updatedRecurringTasks: results.updatedRecurringTasks,
|
|
1314
|
+
failedTasks: results.failedTasks
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
};
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
return { POST };
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
/**
|
|
1325
|
+
* Handler: update-message
|
|
1326
|
+
* ReiStandard SDK v2.0.1
|
|
1327
|
+
*
|
|
1328
|
+
* @param {Object} ctx - Server context.
|
|
1329
|
+
* @returns {{ PUT: function }}
|
|
1330
|
+
*/
|
|
1331
|
+
|
|
1332
|
+
|
|
1333
|
+
function createUpdateMessageHandler(ctx) {
|
|
1334
|
+
async function PUT(url, headers, body) {
|
|
1335
|
+
const tenantResult = await ctx.tenantManager.resolveTenant(headers, { url });
|
|
1336
|
+
if (!tenantResult.ok) {
|
|
1337
|
+
return tenantResult.error;
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
const tenantCtx = tenantResult.context;
|
|
1341
|
+
const db = tenantCtx.db;
|
|
1342
|
+
const masterKey = tenantCtx.masterKey;
|
|
1343
|
+
const u = new URL(url, 'https://dummy');
|
|
1344
|
+
const taskUuid = u.searchParams.get('id');
|
|
1345
|
+
|
|
1346
|
+
if (!taskUuid) {
|
|
1347
|
+
return { status: 400, body: { success: false, error: { code: 'TASK_ID_REQUIRED', message: '缺少任务ID' } } };
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
const userId = getHeader(headers, 'x-user-id');
|
|
1351
|
+
if (!userId) {
|
|
1352
|
+
return { status: 400, body: { success: false, error: { code: 'USER_ID_REQUIRED', message: '缺少用户标识符' } } };
|
|
1353
|
+
}
|
|
1354
|
+
if (!isValidUUIDv4(userId)) {
|
|
1355
|
+
return { status: 400, body: { success: false, error: { code: 'INVALID_USER_ID_FORMAT', message: 'X-User-Id 必须是 UUID v4 格式' } } };
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
const isEncrypted = getHeader(headers, 'x-payload-encrypted') === 'true';
|
|
1359
|
+
const encryptionVersion = getHeader(headers, 'x-encryption-version');
|
|
1360
|
+
|
|
1361
|
+
if (!isEncrypted) {
|
|
1362
|
+
return { status: 400, body: { success: false, error: { code: 'ENCRYPTION_REQUIRED', message: '请求体必须加密' } } };
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
if (encryptionVersion !== '1') {
|
|
1366
|
+
return { status: 400, body: { success: false, error: { code: 'UNSUPPORTED_ENCRYPTION_VERSION', message: '加密版本不支持' } } };
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
const parsedBody = parseEncryptedBody(body);
|
|
1370
|
+
if (!parsedBody.ok) {
|
|
1371
|
+
return { status: 400, body: { success: false, error: parsedBody.error } };
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
const encryptedBody = parsedBody.data;
|
|
1375
|
+
const userKey = deriveUserEncryptionKey(userId, masterKey);
|
|
1376
|
+
let updates;
|
|
1377
|
+
|
|
1378
|
+
try {
|
|
1379
|
+
updates = decryptPayload(encryptedBody, userKey);
|
|
1380
|
+
} catch (_error) {
|
|
1381
|
+
return { status: 400, body: { success: false, error: { code: 'DECRYPTION_FAILED', message: '请求体解密失败' } } };
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
if (!isPlainObject(updates)) {
|
|
1385
|
+
return { status: 400, body: { success: false, error: { code: 'INVALID_UPDATE_DATA', message: '更新数据格式错误' } } };
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
if (updates.nextSendAt && !isValidISO8601(updates.nextSendAt)) {
|
|
1389
|
+
return { status: 400, body: { success: false, error: { code: 'INVALID_UPDATE_DATA', message: '更新数据格式错误', details: { invalidFields: ['nextSendAt'] } } } };
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
if (updates.recurrenceType && !['none', 'daily', 'weekly'].includes(updates.recurrenceType)) {
|
|
1393
|
+
return { status: 400, body: { success: false, error: { code: 'INVALID_UPDATE_DATA', message: '更新数据格式错误', details: { invalidFields: ['recurrenceType'] } } } };
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
if (
|
|
1397
|
+
Object.prototype.hasOwnProperty.call(updates, 'maxTokens') &&
|
|
1398
|
+
updates.maxTokens !== null &&
|
|
1399
|
+
(!Number.isInteger(updates.maxTokens) || updates.maxTokens <= 0)
|
|
1400
|
+
) {
|
|
1401
|
+
return { status: 400, body: { success: false, error: { code: 'INVALID_UPDATE_DATA', message: '更新数据格式错误', details: { invalidFields: ['maxTokens'] } } } };
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
// Fetch existing task
|
|
1405
|
+
const existingTask = await db.getTaskByUuid(taskUuid, userId);
|
|
1406
|
+
|
|
1407
|
+
if (!existingTask) {
|
|
1408
|
+
const taskStatus = await db.getTaskStatus(taskUuid, userId);
|
|
1409
|
+
if (!taskStatus) {
|
|
1410
|
+
return { status: 404, body: { success: false, error: { code: 'TASK_NOT_FOUND', message: '指定的任务不存在或已被删除' } } };
|
|
1411
|
+
}
|
|
1412
|
+
return { status: 409, body: { success: false, error: { code: 'TASK_ALREADY_COMPLETED', message: '任务已完成或已失败,无法更新' } } };
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
const existingData = JSON.parse(decryptFromStorage(existingTask.encrypted_payload, userKey));
|
|
1416
|
+
|
|
1417
|
+
const updatedData = {
|
|
1418
|
+
...existingData,
|
|
1419
|
+
...(updates.completePrompt && { completePrompt: updates.completePrompt }),
|
|
1420
|
+
...(updates.userMessage && { userMessage: updates.userMessage }),
|
|
1421
|
+
...(updates.recurrenceType && { recurrenceType: updates.recurrenceType }),
|
|
1422
|
+
...(updates.avatarUrl && { avatarUrl: updates.avatarUrl }),
|
|
1423
|
+
...(updates.metadata && { metadata: updates.metadata }),
|
|
1424
|
+
...(Object.prototype.hasOwnProperty.call(updates, 'maxTokens') && { maxTokens: updates.maxTokens ?? null })
|
|
1425
|
+
};
|
|
1426
|
+
|
|
1427
|
+
const encryptedPayload = encryptForStorage(JSON.stringify(updatedData), userKey);
|
|
1428
|
+
const extraFields = updates.nextSendAt ? { next_send_at: updates.nextSendAt } : undefined;
|
|
1429
|
+
|
|
1430
|
+
const result = await db.updateTaskByUuid(taskUuid, userId, encryptedPayload, extraFields);
|
|
1431
|
+
|
|
1432
|
+
if (!result) {
|
|
1433
|
+
return { status: 409, body: { success: false, error: { code: 'UPDATE_CONFLICT', message: '任务更新失败,任务可能已被修改或删除' } } };
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
return {
|
|
1437
|
+
status: 200,
|
|
1438
|
+
body: {
|
|
1439
|
+
success: true,
|
|
1440
|
+
data: {
|
|
1441
|
+
uuid: taskUuid,
|
|
1442
|
+
updatedFields: Object.keys(updates),
|
|
1443
|
+
updatedAt: result.updated_at
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
};
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
return { PUT };
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
/**
|
|
1453
|
+
* Handler: cancel-message
|
|
1454
|
+
* ReiStandard SDK v2.0.1
|
|
1455
|
+
*
|
|
1456
|
+
* @param {Object} ctx - Server context.
|
|
1457
|
+
* @returns {{ DELETE: function }}
|
|
1458
|
+
*/
|
|
1459
|
+
|
|
1460
|
+
|
|
1461
|
+
function createCancelMessageHandler(ctx) {
|
|
1462
|
+
async function DELETE(url, headers) {
|
|
1463
|
+
const tenantResult = await ctx.tenantManager.resolveTenant(headers, { url });
|
|
1464
|
+
if (!tenantResult.ok) {
|
|
1465
|
+
return tenantResult.error;
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
const db = tenantResult.context.db;
|
|
1469
|
+
const u = new URL(url, 'https://dummy');
|
|
1470
|
+
const taskUuid = u.searchParams.get('id');
|
|
1471
|
+
|
|
1472
|
+
if (!taskUuid) {
|
|
1473
|
+
return { status: 400, body: { success: false, error: { code: 'TASK_ID_REQUIRED', message: '缺少任务ID' } } };
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
const userId = getHeader(headers, 'x-user-id');
|
|
1477
|
+
if (!userId) {
|
|
1478
|
+
return { status: 400, body: { success: false, error: { code: 'USER_ID_REQUIRED', message: '缺少用户标识符' } } };
|
|
1479
|
+
}
|
|
1480
|
+
if (!isValidUUIDv4(userId)) {
|
|
1481
|
+
return { status: 400, body: { success: false, error: { code: 'INVALID_USER_ID_FORMAT', message: 'X-User-Id 必须是 UUID v4 格式' } } };
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
const deleted = await db.deleteTaskByUuid(taskUuid, userId);
|
|
1485
|
+
|
|
1486
|
+
if (!deleted) {
|
|
1487
|
+
return {
|
|
1488
|
+
status: 404,
|
|
1489
|
+
body: { success: false, error: { code: 'TASK_NOT_FOUND', message: '指定的任务不存在或已被删除' } }
|
|
1490
|
+
};
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
return {
|
|
1494
|
+
status: 200,
|
|
1495
|
+
body: {
|
|
1496
|
+
success: true,
|
|
1497
|
+
data: { uuid: taskUuid, message: '任务已成功取消', deletedAt: new Date().toISOString() }
|
|
1498
|
+
}
|
|
1499
|
+
};
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
return { DELETE };
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
/**
|
|
1506
|
+
* Handler: messages
|
|
1507
|
+
* ReiStandard SDK v2.0.1
|
|
1508
|
+
*
|
|
1509
|
+
* @param {Object} ctx - Server context.
|
|
1510
|
+
* @returns {{ GET: function }}
|
|
1511
|
+
*/
|
|
1512
|
+
|
|
1513
|
+
|
|
1514
|
+
function createMessagesHandler(ctx) {
|
|
1515
|
+
async function GET(url, headers) {
|
|
1516
|
+
const tenantResult = await ctx.tenantManager.resolveTenant(headers, { url });
|
|
1517
|
+
if (!tenantResult.ok) {
|
|
1518
|
+
return tenantResult.error;
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
const tenantCtx = tenantResult.context;
|
|
1522
|
+
const db = tenantCtx.db;
|
|
1523
|
+
const masterKey = tenantCtx.masterKey;
|
|
1524
|
+
const userId = getHeader(headers, 'x-user-id');
|
|
1525
|
+
|
|
1526
|
+
if (!userId) {
|
|
1527
|
+
return {
|
|
1528
|
+
status: 400,
|
|
1529
|
+
body: { success: false, error: { code: 'USER_ID_REQUIRED', message: '必须提供 X-User-Id 请求头' } }
|
|
1530
|
+
};
|
|
1531
|
+
}
|
|
1532
|
+
if (!isValidUUIDv4(userId)) {
|
|
1533
|
+
return {
|
|
1534
|
+
status: 400,
|
|
1535
|
+
body: { success: false, error: { code: 'INVALID_USER_ID_FORMAT', message: 'X-User-Id 必须是 UUID v4 格式' } }
|
|
1536
|
+
};
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
const u = new URL(url, 'https://dummy');
|
|
1540
|
+
const status = u.searchParams.get('status') || 'all';
|
|
1541
|
+
const limit = Math.min(parseInt(u.searchParams.get('limit') || '20', 10), 100);
|
|
1542
|
+
const offset = parseInt(u.searchParams.get('offset') || '0', 10);
|
|
1543
|
+
|
|
1544
|
+
if (isNaN(limit) || limit < 1) {
|
|
1545
|
+
return { status: 400, body: { success: false, error: { code: 'INVALID_PARAMETERS', message: 'limit 参数无效,必须为正整数' } } };
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
if (isNaN(offset) || offset < 0) {
|
|
1549
|
+
return { status: 400, body: { success: false, error: { code: 'INVALID_PARAMETERS', message: 'offset 参数无效,必须为非负整数' } } };
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
const { tasks, total } = await db.listTasks(userId, { status, limit, offset });
|
|
1553
|
+
|
|
1554
|
+
const userKey = deriveUserEncryptionKey(userId, masterKey);
|
|
1555
|
+
|
|
1556
|
+
const decryptedTasks = tasks.map(task => {
|
|
1557
|
+
const decrypted = JSON.parse(decryptFromStorage(task.encrypted_payload, userKey));
|
|
1558
|
+
return {
|
|
1559
|
+
id: task.id,
|
|
1560
|
+
uuid: task.uuid,
|
|
1561
|
+
contactName: decrypted.contactName,
|
|
1562
|
+
messageType: task.message_type,
|
|
1563
|
+
messageSubtype: decrypted.messageSubtype,
|
|
1564
|
+
nextSendAt: task.next_send_at,
|
|
1565
|
+
recurrenceType: decrypted.recurrenceType,
|
|
1566
|
+
status: task.status,
|
|
1567
|
+
retryCount: task.retry_count,
|
|
1568
|
+
createdAt: task.created_at,
|
|
1569
|
+
updatedAt: task.updated_at
|
|
1570
|
+
};
|
|
1571
|
+
});
|
|
1572
|
+
|
|
1573
|
+
const responsePayload = {
|
|
1574
|
+
tasks: decryptedTasks,
|
|
1575
|
+
pagination: { total, limit, offset, hasMore: offset + limit < total }
|
|
1576
|
+
};
|
|
1577
|
+
const encryptedResponse = encryptPayload(responsePayload, userKey);
|
|
1578
|
+
|
|
1579
|
+
return {
|
|
1580
|
+
status: 200,
|
|
1581
|
+
body: {
|
|
1582
|
+
success: true,
|
|
1583
|
+
encrypted: true,
|
|
1584
|
+
version: 1,
|
|
1585
|
+
data: encryptedResponse
|
|
1586
|
+
}
|
|
1587
|
+
};
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
return { GET };
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
const inMemoryNamespaces = new Map();
|
|
1594
|
+
|
|
1595
|
+
function base64UrlEncode$1(input) {
|
|
1596
|
+
return Buffer.from(input)
|
|
1597
|
+
.toString('base64')
|
|
1598
|
+
.replace(/\+/g, '-')
|
|
1599
|
+
.replace(/\//g, '_')
|
|
1600
|
+
.replace(/=+$/g, '');
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
function base64UrlDecode$1(input) {
|
|
1604
|
+
const normalized = input
|
|
1605
|
+
.replace(/-/g, '+')
|
|
1606
|
+
.replace(/_/g, '/');
|
|
1607
|
+
const padLength = (4 - (normalized.length % 4)) % 4;
|
|
1608
|
+
return Buffer.from(normalized + '='.repeat(padLength), 'base64');
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
function getKekBuffer(kek) {
|
|
1612
|
+
const value = String(kek || '').trim();
|
|
1613
|
+
if (!value) {
|
|
1614
|
+
throw new Error('[rei-standard-amsg-server] tenant.kek is required');
|
|
1615
|
+
}
|
|
1616
|
+
return createHash('sha256').update(value).digest();
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
function encryptConfig(config, kekBuffer) {
|
|
1620
|
+
const iv = randomBytes(12);
|
|
1621
|
+
const cipher = createCipheriv('aes-256-gcm', kekBuffer, iv);
|
|
1622
|
+
const plaintext = Buffer.from(JSON.stringify(config), 'utf8');
|
|
1623
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
1624
|
+
const authTag = cipher.getAuthTag();
|
|
1625
|
+
|
|
1626
|
+
return `v1.${base64UrlEncode$1(iv)}.${base64UrlEncode$1(authTag)}.${base64UrlEncode$1(ciphertext)}`;
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
function decryptConfig(encrypted, kekBuffer) {
|
|
1630
|
+
const parts = String(encrypted || '').split('.');
|
|
1631
|
+
if (parts.length !== 4 || parts[0] !== 'v1') {
|
|
1632
|
+
throw new Error('INVALID_TENANT_CONFIG');
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
const iv = base64UrlDecode$1(parts[1]);
|
|
1636
|
+
const authTag = base64UrlDecode$1(parts[2]);
|
|
1637
|
+
const ciphertext = base64UrlDecode$1(parts[3]);
|
|
1638
|
+
|
|
1639
|
+
const decipher = createDecipheriv('aes-256-gcm', kekBuffer, iv);
|
|
1640
|
+
decipher.setAuthTag(authTag);
|
|
1641
|
+
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
|
|
1642
|
+
return JSON.parse(plaintext);
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
function createInMemoryStore(namespace) {
|
|
1646
|
+
if (!inMemoryNamespaces.has(namespace)) {
|
|
1647
|
+
inMemoryNamespaces.set(namespace, new Map());
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
const namespaceMap = inMemoryNamespaces.get(namespace);
|
|
1651
|
+
|
|
1652
|
+
return {
|
|
1653
|
+
async get(key) {
|
|
1654
|
+
return namespaceMap.has(key) ? namespaceMap.get(key) : null;
|
|
1655
|
+
},
|
|
1656
|
+
async set(key, value) {
|
|
1657
|
+
namespaceMap.set(key, value);
|
|
1658
|
+
},
|
|
1659
|
+
async delete(key) {
|
|
1660
|
+
namespaceMap.delete(key);
|
|
1661
|
+
}
|
|
1662
|
+
};
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
async function resolveBlobStore(namespace) {
|
|
1666
|
+
if (globalThis.__REI_BLOB_STORE__ && typeof globalThis.__REI_BLOB_STORE__.get === 'function') {
|
|
1667
|
+
return globalThis.__REI_BLOB_STORE__;
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
try {
|
|
1671
|
+
const blobModule = await import('@netlify/blobs');
|
|
1672
|
+
const getStore = blobModule.getStore || blobModule.default?.getStore;
|
|
1673
|
+
if (typeof getStore === 'function') {
|
|
1674
|
+
return getStore({ name: namespace });
|
|
1675
|
+
}
|
|
1676
|
+
} catch (_error) {
|
|
1677
|
+
// Fall back to in-memory store for local tests / non-Netlify runtimes
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
return createInMemoryStore(namespace);
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
/**
|
|
1684
|
+
* @typedef {{
|
|
1685
|
+
* tenantId: string,
|
|
1686
|
+
* db: { driver: 'neon'|'pg', connectionString: string },
|
|
1687
|
+
* masterKey: string,
|
|
1688
|
+
* createdAt: string,
|
|
1689
|
+
* updatedAt: string
|
|
1690
|
+
* }} TenantConfig
|
|
1691
|
+
*/
|
|
1692
|
+
|
|
1693
|
+
/**
|
|
1694
|
+
* @param {{ namespace?: string, kek: string }} options
|
|
1695
|
+
*/
|
|
1696
|
+
function createTenantBlobStore(options) {
|
|
1697
|
+
const namespace = String(options.namespace).trim() || 'rei-tenants';
|
|
1698
|
+
const kekBuffer = getKekBuffer(options.kek);
|
|
1699
|
+
|
|
1700
|
+
function getTenantKey(tenantId) {
|
|
1701
|
+
return `tenant/${tenantId}`;
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
return {
|
|
1705
|
+
async getTenantConfig(tenantId) {
|
|
1706
|
+
const store = await resolveBlobStore(namespace);
|
|
1707
|
+
const encrypted = await store.get(getTenantKey(tenantId), { type: 'text' });
|
|
1708
|
+
if (!encrypted) return null;
|
|
1709
|
+
return decryptConfig(encrypted, kekBuffer);
|
|
1710
|
+
},
|
|
1711
|
+
|
|
1712
|
+
async setTenantConfig(config) {
|
|
1713
|
+
const store = await resolveBlobStore(namespace);
|
|
1714
|
+
const encrypted = encryptConfig(config, kekBuffer);
|
|
1715
|
+
await store.set(getTenantKey(config.tenantId), encrypted);
|
|
1716
|
+
},
|
|
1717
|
+
|
|
1718
|
+
async deleteTenantConfig(tenantId) {
|
|
1719
|
+
const store = await resolveBlobStore(namespace);
|
|
1720
|
+
await store.delete(getTenantKey(tenantId));
|
|
1721
|
+
}
|
|
1722
|
+
};
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
function base64UrlEncode(input) {
|
|
1726
|
+
return Buffer.from(input)
|
|
1727
|
+
.toString('base64')
|
|
1728
|
+
.replace(/\+/g, '-')
|
|
1729
|
+
.replace(/\//g, '_')
|
|
1730
|
+
.replace(/=+$/g, '');
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
function base64UrlDecode(input) {
|
|
1734
|
+
const normalized = input
|
|
1735
|
+
.replace(/-/g, '+')
|
|
1736
|
+
.replace(/_/g, '/');
|
|
1737
|
+
const padLength = (4 - (normalized.length % 4)) % 4;
|
|
1738
|
+
return Buffer.from(normalized + '='.repeat(padLength), 'base64');
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
function sign(input, secret) {
|
|
1742
|
+
return base64UrlEncode(createHmac('sha256', secret).update(input).digest());
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
function nowEpochSeconds() {
|
|
1746
|
+
return Math.floor(Date.now() / 1000);
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
/**
|
|
1750
|
+
* @typedef {'tenant'|'cron'} TokenType
|
|
1751
|
+
*/
|
|
1752
|
+
|
|
1753
|
+
/**
|
|
1754
|
+
* @param {{ tenantId: string, type: TokenType, expiresInSeconds?: number }} params
|
|
1755
|
+
* @param {string} secret
|
|
1756
|
+
*/
|
|
1757
|
+
function createTenantToken(params, secret) {
|
|
1758
|
+
const issuedAt = nowEpochSeconds();
|
|
1759
|
+
const expiresInSeconds = Number(params.expiresInSeconds || 60 * 60 * 24 * 30);
|
|
1760
|
+
const payload = {
|
|
1761
|
+
tid: params.tenantId,
|
|
1762
|
+
typ: params.type,
|
|
1763
|
+
iat: issuedAt,
|
|
1764
|
+
exp: issuedAt + expiresInSeconds,
|
|
1765
|
+
v: 1
|
|
1766
|
+
};
|
|
1767
|
+
|
|
1768
|
+
const header = { alg: 'HS256', typ: 'JWT' };
|
|
1769
|
+
const encodedHeader = base64UrlEncode(JSON.stringify(header));
|
|
1770
|
+
const encodedPayload = base64UrlEncode(JSON.stringify(payload));
|
|
1771
|
+
const signingInput = `${encodedHeader}.${encodedPayload}`;
|
|
1772
|
+
const signature = sign(signingInput, secret);
|
|
1773
|
+
return `${signingInput}.${signature}`;
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
/**
|
|
1777
|
+
* @param {string} token
|
|
1778
|
+
* @param {string} secret
|
|
1779
|
+
* @param {{ expectedTypes?: TokenType[] }} [options]
|
|
1780
|
+
*/
|
|
1781
|
+
function verifyTenantToken(token, secret, options = {}) {
|
|
1782
|
+
if (!token || typeof token !== 'string') {
|
|
1783
|
+
throw new Error('INVALID_TENANT_AUTH');
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
const parts = token.split('.');
|
|
1787
|
+
if (parts.length !== 3) {
|
|
1788
|
+
throw new Error('INVALID_TENANT_AUTH');
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
const [encodedHeader, encodedPayload, receivedSignature] = parts;
|
|
1792
|
+
const signingInput = `${encodedHeader}.${encodedPayload}`;
|
|
1793
|
+
const expectedSignature = sign(signingInput, secret);
|
|
1794
|
+
|
|
1795
|
+
const receivedBuffer = Buffer.from(receivedSignature);
|
|
1796
|
+
const expectedBuffer = Buffer.from(expectedSignature);
|
|
1797
|
+
if (
|
|
1798
|
+
receivedBuffer.length !== expectedBuffer.length ||
|
|
1799
|
+
!timingSafeEqual(receivedBuffer, expectedBuffer)
|
|
1800
|
+
) {
|
|
1801
|
+
throw new Error('INVALID_TENANT_AUTH');
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
let payload;
|
|
1805
|
+
try {
|
|
1806
|
+
payload = JSON.parse(base64UrlDecode(encodedPayload).toString('utf8'));
|
|
1807
|
+
} catch {
|
|
1808
|
+
throw new Error('INVALID_TENANT_AUTH');
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
if (!payload || typeof payload !== 'object' || payload.v !== 1) {
|
|
1812
|
+
throw new Error('INVALID_TENANT_AUTH');
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
if (!payload.tid || !payload.typ || !payload.exp) {
|
|
1816
|
+
throw new Error('INVALID_TENANT_AUTH');
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
if (payload.exp <= nowEpochSeconds()) {
|
|
1820
|
+
throw new Error('INVALID_TENANT_AUTH');
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
const expectedTypes = options.expectedTypes || [];
|
|
1824
|
+
if (expectedTypes.length > 0 && !expectedTypes.includes(payload.typ)) {
|
|
1825
|
+
throw new Error('INVALID_TENANT_AUTH');
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
return payload;
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
/**
|
|
1832
|
+
* @param {Record<string, any>} headers
|
|
1833
|
+
* @returns {string}
|
|
1834
|
+
*/
|
|
1835
|
+
function extractBearerToken(headers = {}) {
|
|
1836
|
+
let authorization = '';
|
|
1837
|
+
for (const [key, value] of Object.entries(headers || {})) {
|
|
1838
|
+
if (String(key).toLowerCase() === 'authorization') {
|
|
1839
|
+
authorization = String(value || '').trim();
|
|
1840
|
+
break;
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
if (!authorization.toLowerCase().startsWith('bearer ')) {
|
|
1844
|
+
return '';
|
|
1845
|
+
}
|
|
1846
|
+
return authorization.slice(7).trim();
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
function fingerprint(masterKey) {
|
|
1850
|
+
return createHash('sha256').update(masterKey).digest('hex').slice(0, 16);
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
function sanitizeDbConfig(driver, connectionString) {
|
|
1854
|
+
const normalizedDriver = String(driver || '').trim();
|
|
1855
|
+
const normalizedConnectionString = String(connectionString || '').trim();
|
|
1856
|
+
|
|
1857
|
+
if (!['neon', 'pg'].includes(normalizedDriver)) {
|
|
1858
|
+
throw new Error('INVALID_DRIVER');
|
|
1859
|
+
}
|
|
1860
|
+
if (!normalizedConnectionString) {
|
|
1861
|
+
throw new Error('INVALID_DATABASE_URL');
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
return {
|
|
1865
|
+
driver: normalizedDriver,
|
|
1866
|
+
connectionString: normalizedConnectionString
|
|
1867
|
+
};
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
function parseTokenFromUrl(url) {
|
|
1871
|
+
if (!url || typeof url !== 'string') {
|
|
1872
|
+
return '';
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
try {
|
|
1876
|
+
const parsed = new URL(url, 'https://dummy.local');
|
|
1877
|
+
return String(parsed.searchParams.get('token') || '').trim();
|
|
1878
|
+
} catch {
|
|
1879
|
+
return '';
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
/**
|
|
1884
|
+
* @param {{
|
|
1885
|
+
* tenantStore: ReturnType<import('./blob-store.js').createTenantBlobStore>,
|
|
1886
|
+
* tokenSigningKey: string,
|
|
1887
|
+
* publicBaseUrl?: string
|
|
1888
|
+
* }} options
|
|
1889
|
+
*/
|
|
1890
|
+
function createTenantContextManager(options) {
|
|
1891
|
+
const tenantStore = options.tenantStore;
|
|
1892
|
+
const tokenSigningKey = String(options.tokenSigningKey || '').trim();
|
|
1893
|
+
const publicBaseUrl = String(options.publicBaseUrl || '').replace(/\/$/, '');
|
|
1894
|
+
const adapterFactory = options.adapterFactory || createAdapter;
|
|
1895
|
+
const adapterCache = new Map();
|
|
1896
|
+
|
|
1897
|
+
if (!tokenSigningKey) {
|
|
1898
|
+
throw new Error('[rei-standard-amsg-server] tenant.tokenSigningKey is required');
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
async function getOrCreateAdapter(dbConfig) {
|
|
1902
|
+
const cacheKey = `${dbConfig.driver}:${dbConfig.connectionString}`;
|
|
1903
|
+
if (!adapterCache.has(cacheKey)) {
|
|
1904
|
+
const adapter = await adapterFactory(dbConfig);
|
|
1905
|
+
adapterCache.set(cacheKey, adapter);
|
|
1906
|
+
}
|
|
1907
|
+
return adapterCache.get(cacheKey);
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
async function initializeTenant({ driver, connectionString, tenantId: providedTenantId }) {
|
|
1911
|
+
const dbConfig = sanitizeDbConfig(driver, connectionString);
|
|
1912
|
+
const tenantId = String(providedTenantId || randomUUID()).trim();
|
|
1913
|
+
|
|
1914
|
+
const existing = await tenantStore.getTenantConfig(tenantId);
|
|
1915
|
+
if (existing) {
|
|
1916
|
+
throw new Error('TENANT_ALREADY_INITIALIZED');
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
const adapter = await getOrCreateAdapter(dbConfig);
|
|
1920
|
+
await adapter.initSchema();
|
|
1921
|
+
|
|
1922
|
+
const masterKey = randomBytes(32).toString('hex');
|
|
1923
|
+
const now = new Date().toISOString();
|
|
1924
|
+
|
|
1925
|
+
await tenantStore.setTenantConfig({
|
|
1926
|
+
tenantId,
|
|
1927
|
+
db: dbConfig,
|
|
1928
|
+
masterKey,
|
|
1929
|
+
createdAt: now,
|
|
1930
|
+
updatedAt: now
|
|
1931
|
+
});
|
|
1932
|
+
|
|
1933
|
+
const tenantToken = createTenantToken({ tenantId, type: 'tenant' }, tokenSigningKey);
|
|
1934
|
+
const cronToken = createTenantToken({ tenantId, type: 'cron' }, tokenSigningKey);
|
|
1935
|
+
|
|
1936
|
+
return {
|
|
1937
|
+
tenantId,
|
|
1938
|
+
masterKeyFingerprint: fingerprint(masterKey),
|
|
1939
|
+
tenantToken,
|
|
1940
|
+
cronToken,
|
|
1941
|
+
cronWebhookUrl: publicBaseUrl ? `${publicBaseUrl}/api/v1/send-notifications?token=${cronToken}` : ''
|
|
1942
|
+
};
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
async function resolveTenant(headers, options = {}) {
|
|
1946
|
+
const allowCronToken = options.allowCronToken === true;
|
|
1947
|
+
const url = options.url || '';
|
|
1948
|
+
|
|
1949
|
+
const bearerToken = extractBearerToken(headers);
|
|
1950
|
+
const tokenFromQuery = allowCronToken ? parseTokenFromUrl(url) : '';
|
|
1951
|
+
const token = bearerToken || tokenFromQuery;
|
|
1952
|
+
|
|
1953
|
+
if (!token) {
|
|
1954
|
+
return {
|
|
1955
|
+
ok: false,
|
|
1956
|
+
error: {
|
|
1957
|
+
status: 401,
|
|
1958
|
+
body: { success: false, error: { code: 'INVALID_TENANT_AUTH', message: '缺少租户鉴权信息' } }
|
|
1959
|
+
}
|
|
1960
|
+
};
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
let payload;
|
|
1964
|
+
try {
|
|
1965
|
+
payload = verifyTenantToken(token, tokenSigningKey, {
|
|
1966
|
+
expectedTypes: allowCronToken ? ['cron'] : ['tenant']
|
|
1967
|
+
});
|
|
1968
|
+
} catch (_error) {
|
|
1969
|
+
return {
|
|
1970
|
+
ok: false,
|
|
1971
|
+
error: {
|
|
1972
|
+
status: 401,
|
|
1973
|
+
body: { success: false, error: { code: 'INVALID_TENANT_AUTH', message: '租户令牌无效或已过期' } }
|
|
1974
|
+
}
|
|
1975
|
+
};
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
const tenantConfig = await tenantStore.getTenantConfig(payload.tid);
|
|
1979
|
+
if (!tenantConfig) {
|
|
1980
|
+
return {
|
|
1981
|
+
ok: false,
|
|
1982
|
+
error: {
|
|
1983
|
+
status: 401,
|
|
1984
|
+
body: { success: false, error: { code: 'INVALID_TENANT_AUTH', message: '租户配置不存在或已失效' } }
|
|
1985
|
+
}
|
|
1986
|
+
};
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
const adapter = await getOrCreateAdapter(tenantConfig.db);
|
|
1990
|
+
|
|
1991
|
+
return {
|
|
1992
|
+
ok: true,
|
|
1993
|
+
context: {
|
|
1994
|
+
tenantId: tenantConfig.tenantId,
|
|
1995
|
+
tokenType: payload.typ,
|
|
1996
|
+
db: adapter,
|
|
1997
|
+
masterKey: tenantConfig.masterKey
|
|
1998
|
+
}
|
|
1999
|
+
};
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
return {
|
|
2003
|
+
initializeTenant,
|
|
2004
|
+
resolveTenant
|
|
2005
|
+
};
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
/**
|
|
2009
|
+
* ReiStandard Server SDK Entry Point
|
|
2010
|
+
* v2.0.1
|
|
2011
|
+
*
|
|
2012
|
+
* Usage:
|
|
2013
|
+
* import { createReiServer } from '@rei-standard/amsg-server';
|
|
2014
|
+
*
|
|
2015
|
+
* const rei = await createReiServer({
|
|
2016
|
+
* vapid: {
|
|
2017
|
+
* email: process.env.VAPID_EMAIL,
|
|
2018
|
+
* publicKey: process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY,
|
|
2019
|
+
* privateKey: process.env.VAPID_PRIVATE_KEY,
|
|
2020
|
+
* },
|
|
2021
|
+
* tenant: {
|
|
2022
|
+
* blobNamespace: 'rei-tenants',
|
|
2023
|
+
* kek: process.env.TENANT_CONFIG_KEK,
|
|
2024
|
+
* tokenSigningKey: process.env.TENANT_TOKEN_SIGNING_KEY,
|
|
2025
|
+
* initSecret: process.env.INIT_SECRET,
|
|
2026
|
+
* publicBaseUrl: process.env.PUBLIC_BASE_URL
|
|
2027
|
+
* }
|
|
2028
|
+
* });
|
|
2029
|
+
*/
|
|
2030
|
+
|
|
2031
|
+
|
|
2032
|
+
function normalizeVapidSubject(email) {
|
|
2033
|
+
const trimmedEmail = String(email || '').trim();
|
|
2034
|
+
if (!trimmedEmail) return '';
|
|
2035
|
+
return /^mailto:/i.test(trimmedEmail) ? trimmedEmail : `mailto:${trimmedEmail}`;
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
/**
|
|
2039
|
+
* @typedef {Object} VapidConfig
|
|
2040
|
+
* @property {string} [email] - VAPID contact email (e.g. mailto:…).
|
|
2041
|
+
* @property {string} [publicKey] - VAPID public key.
|
|
2042
|
+
* @property {string} [privateKey] - VAPID private key.
|
|
2043
|
+
*/
|
|
2044
|
+
|
|
2045
|
+
/**
|
|
2046
|
+
* @typedef {Object} TenantServerConfig
|
|
2047
|
+
* @property {string} [blobNamespace] - Netlify Blob namespace.
|
|
2048
|
+
* @property {string} kek - KEK used to encrypt tenant config in blobs.
|
|
2049
|
+
* @property {string} tokenSigningKey - HMAC key used to sign tenant/cron tokens.
|
|
2050
|
+
* @property {string} [initSecret] - Optional secret for /init-tenant bootstrap endpoint.
|
|
2051
|
+
* @property {string} [publicBaseUrl] - Optional base URL for generated cron webhook.
|
|
2052
|
+
* @property {(db: {driver:'neon'|'pg', connectionString:string}) => Promise<any>} [adapterFactory]
|
|
2053
|
+
* Optional adapter factory override (mainly for tests).
|
|
2054
|
+
*/
|
|
2055
|
+
|
|
2056
|
+
/**
|
|
2057
|
+
* @typedef {Object} ReiServerConfig
|
|
2058
|
+
* @property {VapidConfig} [vapid] - VAPID keys for Web Push.
|
|
2059
|
+
* @property {TenantServerConfig} tenant - Tenant config & auth settings.
|
|
2060
|
+
*/
|
|
2061
|
+
|
|
2062
|
+
/**
|
|
2063
|
+
* @typedef {Object} ReiHandlers
|
|
2064
|
+
* @property {{ POST: function }} initTenant
|
|
2065
|
+
* @property {{ GET: function }} getUserKey
|
|
2066
|
+
* @property {{ POST: function }} scheduleMessage
|
|
2067
|
+
* @property {{ POST: function }} sendNotifications
|
|
2068
|
+
* @property {{ PUT: function }} updateMessage
|
|
2069
|
+
* @property {{ DELETE: function }} cancelMessage
|
|
2070
|
+
* @property {{ GET: function }} messages
|
|
2071
|
+
*/
|
|
2072
|
+
|
|
2073
|
+
/**
|
|
2074
|
+
* @typedef {Object} ReiServer
|
|
2075
|
+
* @property {ReiHandlers} handlers - Standard API route handler objects.
|
|
2076
|
+
*/
|
|
2077
|
+
|
|
2078
|
+
/**
|
|
2079
|
+
* Initialise the ReiStandard server.
|
|
2080
|
+
*
|
|
2081
|
+
* @param {ReiServerConfig} config
|
|
2082
|
+
* @returns {Promise<ReiServer>}
|
|
2083
|
+
*/
|
|
2084
|
+
async function createReiServer(config) {
|
|
2085
|
+
if (!config) throw new Error('[rei-standard-amsg-server] config is required');
|
|
2086
|
+
if (!config.tenant) throw new Error('[rei-standard-amsg-server] tenant config is required');
|
|
2087
|
+
|
|
2088
|
+
// web-push is a hard dependency for ReiStandard server features
|
|
2089
|
+
let webpushModule;
|
|
2090
|
+
try {
|
|
2091
|
+
const webpushImport = await import('web-push');
|
|
2092
|
+
webpushModule = webpushImport.default || webpushImport;
|
|
2093
|
+
} catch (_err) {
|
|
2094
|
+
throw new Error(
|
|
2095
|
+
'[rei-standard-amsg-server] web-push is required. Install it with: npm install web-push'
|
|
2096
|
+
);
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
const vapid = config.vapid || {};
|
|
2100
|
+
|
|
2101
|
+
if (vapid.email && vapid.publicKey && vapid.privateKey) {
|
|
2102
|
+
webpushModule.setVapidDetails(
|
|
2103
|
+
normalizeVapidSubject(vapid.email),
|
|
2104
|
+
vapid.publicKey,
|
|
2105
|
+
vapid.privateKey
|
|
2106
|
+
);
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
const tenantStore = createTenantBlobStore({
|
|
2110
|
+
namespace: config.tenant.blobNamespace || 'rei-tenants',
|
|
2111
|
+
kek: config.tenant.kek
|
|
2112
|
+
});
|
|
2113
|
+
|
|
2114
|
+
const tenantManager = createTenantContextManager({
|
|
2115
|
+
tenantStore,
|
|
2116
|
+
tokenSigningKey: config.tenant.tokenSigningKey,
|
|
2117
|
+
publicBaseUrl: config.tenant.publicBaseUrl,
|
|
2118
|
+
adapterFactory: config.tenant.adapterFactory
|
|
2119
|
+
});
|
|
2120
|
+
|
|
2121
|
+
const initSecret = String(config.tenant.initSecret || '').trim();
|
|
2122
|
+
|
|
2123
|
+
const ctx = {
|
|
2124
|
+
vapid: {
|
|
2125
|
+
email: vapid.email || '',
|
|
2126
|
+
publicKey: vapid.publicKey || '',
|
|
2127
|
+
privateKey: vapid.privateKey || ''
|
|
2128
|
+
},
|
|
2129
|
+
webpush: webpushModule,
|
|
2130
|
+
tenant: {
|
|
2131
|
+
initSecret
|
|
2132
|
+
},
|
|
2133
|
+
tenantManager
|
|
2134
|
+
};
|
|
2135
|
+
|
|
2136
|
+
return {
|
|
2137
|
+
handlers: {
|
|
2138
|
+
initTenant: createInitTenantHandler(ctx),
|
|
2139
|
+
getUserKey: createGetUserKeyHandler(ctx),
|
|
2140
|
+
scheduleMessage: createScheduleMessageHandler(ctx),
|
|
2141
|
+
sendNotifications: createSendNotificationsHandler(ctx),
|
|
2142
|
+
updateMessage: createUpdateMessageHandler(ctx),
|
|
2143
|
+
cancelMessage: createCancelMessageHandler(ctx),
|
|
2144
|
+
messages: createMessagesHandler(ctx)
|
|
2145
|
+
}
|
|
2146
|
+
};
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2149
|
+
export { createAdapter, createReiServer, createTenantToken, decryptFromStorage, decryptPayload, deriveUserEncryptionKey, encryptForStorage, isValidISO8601, isValidUUID, isValidUUIDv4, isValidUrl, validateScheduleMessagePayload, verifyTenantToken };
|