@rei-standard/amsg-server 2.5.3 → 2.6.0-next.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/dist/chunk-6WA3XDPP.mjs +1826 -0
- package/dist/chunk-S5DV7AUH.cjs +1826 -0
- package/dist/cloudflare-5Ryuxz10.d.cts +2775 -0
- package/dist/cloudflare-5Ryuxz10.d.ts +2775 -0
- package/dist/cloudflare.cjs +22 -0
- package/dist/cloudflare.d.cts +3 -0
- package/dist/cloudflare.d.ts +3 -0
- package/dist/cloudflare.mjs +22 -0
- package/dist/index.cjs +50 -1126
- package/dist/index.d.cts +37 -1769
- package/dist/index.d.ts +37 -1769
- package/dist/index.mjs +43 -1119
- package/package.json +9 -3
|
@@ -0,0 +1,2775 @@
|
|
|
1
|
+
import { createDecipheriv, createHash, randomBytes as randomBytes$1, createCipheriv, randomUUID } from 'crypto';
|
|
2
|
+
import { validateAvatarUrl, readReasoningContent, stripReasoningTags, buildReasoningPush, buildContentPush, toUint8, base64UrlToBytes, normalizeVapidSubject, concatBytes } from '@rei-standard/amsg-shared';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Validation utility library (SDK version)
|
|
6
|
+
* ReiStandard SDK v2.0.1
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Validate ISO 8601 date string.
|
|
12
|
+
* @param {string} dateString
|
|
13
|
+
* @returns {boolean}
|
|
14
|
+
*/
|
|
15
|
+
function isValidISO8601(dateString) {
|
|
16
|
+
const date = new Date(dateString);
|
|
17
|
+
return date instanceof Date && !isNaN(date.getTime());
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Validate URL format.
|
|
22
|
+
* @param {string} urlString
|
|
23
|
+
* @returns {boolean}
|
|
24
|
+
*/
|
|
25
|
+
function isValidUrl(urlString) {
|
|
26
|
+
try {
|
|
27
|
+
new URL(urlString);
|
|
28
|
+
return true;
|
|
29
|
+
} catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Validate UUID format.
|
|
36
|
+
* @param {string} uuid
|
|
37
|
+
* @returns {boolean}
|
|
38
|
+
*/
|
|
39
|
+
function isValidUUID(uuid) {
|
|
40
|
+
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
41
|
+
return uuidRegex.test(uuid);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Validate UUID v4 format.
|
|
46
|
+
* @param {string} uuid
|
|
47
|
+
* @returns {boolean}
|
|
48
|
+
*/
|
|
49
|
+
function isValidUUIDv4(uuid) {
|
|
50
|
+
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;
|
|
51
|
+
return uuidV4Regex.test(uuid);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const VALID_LLM_MESSAGE_ROLES = new Set(['system', 'user', 'assistant', 'tool']);
|
|
55
|
+
|
|
56
|
+
const SPLIT_PATTERN_MAX_LENGTH = 200;
|
|
57
|
+
const SPLIT_PATTERN_MAX_ITEMS = 10;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Validate the optional `splitPattern` field (amsg-server scheduled tasks
|
|
61
|
+
* only; amsg-instant 0.8.0 dropped its request-level `splitPattern`).
|
|
62
|
+
* Accepts `string`, `string[]`, or absent/null. Returns an error message
|
|
63
|
+
* string, or null when valid.
|
|
64
|
+
*
|
|
65
|
+
* Limits (per-item length ≤ 200, array ≤ 10 items, must compile via
|
|
66
|
+
* `new RegExp(item)`) are an **input-size guard**, NOT a ReDoS defense —
|
|
67
|
+
* a 6-character pattern like `(a+)+$` is enough to trigger catastrophic
|
|
68
|
+
* backtracking. The real backstop is Worker / runtime CPU limits + the
|
|
69
|
+
* fact that splitPattern is stored under the user's own encrypted task
|
|
70
|
+
* and matched against output from the user's own LLM API key, so the
|
|
71
|
+
* blast radius is self-inflicted only (no cross-tenant attack surface).
|
|
72
|
+
*
|
|
73
|
+
* @param {unknown} value
|
|
74
|
+
* @returns {string | null}
|
|
75
|
+
*/
|
|
76
|
+
function validateSplitPattern(value) {
|
|
77
|
+
if (value === undefined || value === null) return null;
|
|
78
|
+
const isArray = Array.isArray(value);
|
|
79
|
+
const items = isArray ? value : [value];
|
|
80
|
+
if (isArray && items.length === 0) return null; // empty array = use default
|
|
81
|
+
if (items.length > SPLIT_PATTERN_MAX_ITEMS) {
|
|
82
|
+
return `splitPattern 数组最多 ${SPLIT_PATTERN_MAX_ITEMS} 项`;
|
|
83
|
+
}
|
|
84
|
+
for (let i = 0; i < items.length; i++) {
|
|
85
|
+
const s = items[i];
|
|
86
|
+
const label = isArray ? `splitPattern[${i}]` : 'splitPattern';
|
|
87
|
+
if (typeof s !== 'string') return `${label} 必须是字符串`;
|
|
88
|
+
if (s.length > SPLIT_PATTERN_MAX_LENGTH) {
|
|
89
|
+
return `${label} 不能超过 ${SPLIT_PATTERN_MAX_LENGTH} 字符`;
|
|
90
|
+
}
|
|
91
|
+
try { new RegExp(s); }
|
|
92
|
+
catch (_) { return `${label} 不是有效正则表达式`; }
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Validate an OpenAI-style messages array. Same shape contract as
|
|
99
|
+
* `@rei-standard/amsg-instant` (kept in lockstep on purpose — both packages
|
|
100
|
+
* end up forwarding this to the same LLM body).
|
|
101
|
+
*
|
|
102
|
+
* @param {unknown} messages
|
|
103
|
+
* @returns {string | null} Error message, or null if valid.
|
|
104
|
+
*/
|
|
105
|
+
function validateLlmMessagesArray(messages) {
|
|
106
|
+
if (!Array.isArray(messages) || messages.length === 0) {
|
|
107
|
+
return 'messages must be a non-empty array';
|
|
108
|
+
}
|
|
109
|
+
for (let i = 0; i < messages.length; i++) {
|
|
110
|
+
const m = messages[i];
|
|
111
|
+
if (!m || typeof m !== 'object' || Array.isArray(m)) {
|
|
112
|
+
return `messages[${i}] must be an object`;
|
|
113
|
+
}
|
|
114
|
+
if (!VALID_LLM_MESSAGE_ROLES.has(m.role)) {
|
|
115
|
+
return `messages[${i}].role must be one of system / user / assistant / tool`;
|
|
116
|
+
}
|
|
117
|
+
if (typeof m.content === 'string') {
|
|
118
|
+
if (!m.content) return `messages[${i}].content must be a non-empty string`;
|
|
119
|
+
} else if (Array.isArray(m.content)) {
|
|
120
|
+
if (m.content.length === 0) return `messages[${i}].content array must be non-empty`;
|
|
121
|
+
// Element schema is intentionally not enforced — passed through to LLM as-is.
|
|
122
|
+
} else {
|
|
123
|
+
return `messages[${i}].content must be a non-empty string or a non-empty array`;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Validate the schedule-message request payload.
|
|
131
|
+
*
|
|
132
|
+
* @param {Object} payload
|
|
133
|
+
* @returns {{ valid: boolean, errorCode?: string, errorMessage?: string, details?: Object }}
|
|
134
|
+
*/
|
|
135
|
+
function validateScheduleMessagePayload(payload) {
|
|
136
|
+
if (!payload.contactName || typeof payload.contactName !== 'string') {
|
|
137
|
+
return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { missingFields: ['contactName'] } };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (!payload.messageType || !['fixed', 'prompted', 'auto', 'instant'].includes(payload.messageType)) {
|
|
141
|
+
return { valid: false, errorCode: 'INVALID_MESSAGE_TYPE', errorMessage: '消息类型无效', details: { providedType: payload.messageType, allowedTypes: ['fixed', 'prompted', 'auto', 'instant'] } };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (!payload.firstSendTime || !isValidISO8601(payload.firstSendTime)) {
|
|
145
|
+
return { valid: false, errorCode: 'INVALID_TIMESTAMP', errorMessage: '时间格式无效', details: { field: 'firstSendTime' } };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (payload.firstSendTime && new Date(payload.firstSendTime) <= new Date()) {
|
|
149
|
+
return { valid: false, errorCode: 'INVALID_TIMESTAMP', errorMessage: '时间必须在未来', details: { field: 'firstSendTime', reason: 'must be in the future' } };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (!payload.pushSubscription || typeof payload.pushSubscription !== 'object') {
|
|
153
|
+
return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { missingFields: ['pushSubscription'] } };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (payload.recurrenceType && !['none', 'daily', 'weekly'].includes(payload.recurrenceType)) {
|
|
157
|
+
return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { invalidFields: ['recurrenceType'] } };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (
|
|
161
|
+
payload.maxTokens !== undefined &&
|
|
162
|
+
payload.maxTokens !== null &&
|
|
163
|
+
(!Number.isInteger(payload.maxTokens) || payload.maxTokens <= 0)
|
|
164
|
+
) {
|
|
165
|
+
return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { invalidFields: ['maxTokens'] } };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (payload.messageType === 'fixed') {
|
|
169
|
+
if (!payload.userMessage) {
|
|
170
|
+
return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { missingFields: ['userMessage (required for fixed type)'] } };
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ─── Prompt schema (shared by prompted / auto / instant AI configs) ──
|
|
175
|
+
//
|
|
176
|
+
// Callers provide *exactly one of* `completePrompt` (string) or `messages`
|
|
177
|
+
// (OpenAI-style array). Same contract as @rei-standard/amsg-instant; the
|
|
178
|
+
// server's LLM path forwards either verbatim.
|
|
179
|
+
const promptCheck = (() => {
|
|
180
|
+
const hasCompletePrompt = payload.completePrompt !== undefined && payload.completePrompt !== null && payload.completePrompt !== '';
|
|
181
|
+
const hasMessages = payload.messages !== undefined && payload.messages !== null;
|
|
182
|
+
if (hasCompletePrompt && hasMessages) {
|
|
183
|
+
return {
|
|
184
|
+
error: { code: 'INVALID_PARAMETERS', message: 'exactly one of `completePrompt` or `messages` must be provided(两者不能同时出现)', details: { invalidFields: ['completePrompt', 'messages'] } },
|
|
185
|
+
hasCompletePrompt: true, hasMessages: true,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
if (hasMessages) {
|
|
189
|
+
const err = validateLlmMessagesArray(payload.messages);
|
|
190
|
+
if (err) {
|
|
191
|
+
return {
|
|
192
|
+
error: { code: 'INVALID_PARAMETERS', message: err, details: { invalidFields: ['messages'] } },
|
|
193
|
+
hasCompletePrompt: false, hasMessages: true,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return { error: null, hasCompletePrompt, hasMessages };
|
|
198
|
+
})();
|
|
199
|
+
|
|
200
|
+
if (promptCheck.error) {
|
|
201
|
+
return { valid: false, errorCode: promptCheck.error.code, errorMessage: promptCheck.error.message, details: promptCheck.error.details };
|
|
202
|
+
}
|
|
203
|
+
const hasPrompt = promptCheck.hasCompletePrompt || promptCheck.hasMessages;
|
|
204
|
+
|
|
205
|
+
if (payload.messageType === 'prompted' || payload.messageType === 'auto') {
|
|
206
|
+
const missingAiFields = [];
|
|
207
|
+
if (!hasPrompt) missingAiFields.push('completePrompt or messages');
|
|
208
|
+
if (!payload.apiUrl) missingAiFields.push('apiUrl');
|
|
209
|
+
if (!payload.apiKey) missingAiFields.push('apiKey');
|
|
210
|
+
if (!payload.primaryModel) missingAiFields.push('primaryModel');
|
|
211
|
+
if (missingAiFields.length > 0) {
|
|
212
|
+
return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { missingFields: missingAiFields } };
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (payload.messageType === 'instant') {
|
|
217
|
+
if (payload.recurrenceType && payload.recurrenceType !== 'none') {
|
|
218
|
+
return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: 'instant 类型的 recurrenceType 必须为 none', details: { invalidFields: ['recurrenceType (must be "none" for instant type)'] } };
|
|
219
|
+
}
|
|
220
|
+
const hasAiConfig = hasPrompt && payload.apiUrl && payload.apiKey && payload.primaryModel;
|
|
221
|
+
const hasUserMessage = payload.userMessage;
|
|
222
|
+
if (!hasAiConfig && !hasUserMessage) {
|
|
223
|
+
return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: 'instant 类型必须提供 userMessage 或完整的 AI 配置', details: { missingFields: ['userMessage or ((completePrompt | messages) + apiUrl + apiKey + primaryModel)'] } };
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (
|
|
228
|
+
payload.temperature !== undefined &&
|
|
229
|
+
payload.temperature !== null &&
|
|
230
|
+
(typeof payload.temperature !== 'number' || !Number.isFinite(payload.temperature))
|
|
231
|
+
) {
|
|
232
|
+
return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { invalidFields: ['temperature (must be a finite number)'] } };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const avatarErr = validateAvatarUrl(payload.avatarUrl);
|
|
236
|
+
if (avatarErr) {
|
|
237
|
+
// Soft-strip: a bad avatarUrl (data: URI / oversized / malformed) used to
|
|
238
|
+
// 400 the whole schedule. Avatar is cosmetic — drop the field, log, and
|
|
239
|
+
// let the rest of the task ship. See standards §6.2.
|
|
240
|
+
console.warn('[amsg-server] avatarUrl 不合法,已置空:', avatarErr);
|
|
241
|
+
payload.avatarUrl = null;
|
|
242
|
+
}
|
|
243
|
+
if (payload.uuid && !isValidUUID(payload.uuid)) {
|
|
244
|
+
return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { invalidFields: ['uuid (invalid UUID format)'] } };
|
|
245
|
+
}
|
|
246
|
+
// messageSubtype is a free-form string tag forwarded to SW-side push payload
|
|
247
|
+
// for classification. Only the type is enforced; the taxonomy is the
|
|
248
|
+
// consumer's call (previously the enum was chat/forum/moment).
|
|
249
|
+
if (
|
|
250
|
+
payload.messageSubtype !== undefined &&
|
|
251
|
+
payload.messageSubtype !== null &&
|
|
252
|
+
typeof payload.messageSubtype !== 'string'
|
|
253
|
+
) {
|
|
254
|
+
return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { invalidFields: ['messageSubtype'] } };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const splitErr = validateSplitPattern(payload.splitPattern);
|
|
258
|
+
if (splitErr) {
|
|
259
|
+
return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: splitErr, details: { invalidFields: ['splitPattern'] } };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return { valid: true };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Request payload utilities.
|
|
267
|
+
* Keeps body parsing and shape validation consistent across handlers.
|
|
268
|
+
*/
|
|
269
|
+
|
|
270
|
+
const REQUEST_ERRORS = {
|
|
271
|
+
INVALID_JSON: { code: 'INVALID_JSON', message: '请求体不是有效的 JSON' },
|
|
272
|
+
INVALID_REQUEST_BODY: { code: 'INVALID_REQUEST_BODY', message: '请求体格式无效' },
|
|
273
|
+
INVALID_ENCRYPTED_PAYLOAD: { code: 'INVALID_ENCRYPTED_PAYLOAD', message: '加密数据格式错误' }
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* @typedef {{ code: string, message: string }} ValidationError
|
|
278
|
+
*/
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* @typedef {{
|
|
282
|
+
* invalidJson?: ValidationError,
|
|
283
|
+
* invalidType?: ValidationError
|
|
284
|
+
* }} ParseBodyOptions
|
|
285
|
+
*/
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* @typedef {{
|
|
289
|
+
* ok: true,
|
|
290
|
+
* data: Record<string, any>
|
|
291
|
+
* } | {
|
|
292
|
+
* ok: false,
|
|
293
|
+
* error: ValidationError
|
|
294
|
+
* }} ParseBodyResult
|
|
295
|
+
*/
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Parse body into a JSON object.
|
|
299
|
+
*
|
|
300
|
+
* @param {unknown} body
|
|
301
|
+
* @param {ParseBodyOptions} [options]
|
|
302
|
+
* @returns {ParseBodyResult}
|
|
303
|
+
*/
|
|
304
|
+
function parseBodyAsObject(body, options = {}) {
|
|
305
|
+
const invalidJson = options.invalidJson || REQUEST_ERRORS.INVALID_JSON;
|
|
306
|
+
const invalidType = options.invalidType || REQUEST_ERRORS.INVALID_REQUEST_BODY;
|
|
307
|
+
|
|
308
|
+
let parsed = body;
|
|
309
|
+
if (typeof parsed === 'string') {
|
|
310
|
+
try {
|
|
311
|
+
parsed = JSON.parse(parsed);
|
|
312
|
+
} catch {
|
|
313
|
+
return { ok: false, error: invalidJson };
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (!isPlainObject(parsed)) {
|
|
318
|
+
return { ok: false, error: invalidType };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return { ok: true, data: parsed };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Parse a standard JSON object body.
|
|
326
|
+
*
|
|
327
|
+
* @param {unknown} body
|
|
328
|
+
* @returns {ParseBodyResult}
|
|
329
|
+
*/
|
|
330
|
+
function parseJsonBody(body) {
|
|
331
|
+
return parseBodyAsObject(body, {
|
|
332
|
+
invalidJson: REQUEST_ERRORS.INVALID_JSON,
|
|
333
|
+
invalidType: REQUEST_ERRORS.INVALID_REQUEST_BODY
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Check if a value is a plain object (and not null/array).
|
|
339
|
+
*
|
|
340
|
+
* @param {unknown} value
|
|
341
|
+
* @returns {value is Record<string, any>}
|
|
342
|
+
*/
|
|
343
|
+
function isPlainObject(value) {
|
|
344
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Check if an object follows the encrypted payload envelope shape.
|
|
349
|
+
*
|
|
350
|
+
* @param {unknown} payload
|
|
351
|
+
* @returns {payload is { iv: string, authTag: string, encryptedData: string }}
|
|
352
|
+
*/
|
|
353
|
+
function isEncryptedEnvelope(payload) {
|
|
354
|
+
if (!isPlainObject(payload)) return false;
|
|
355
|
+
|
|
356
|
+
return (
|
|
357
|
+
typeof payload.iv === 'string' &&
|
|
358
|
+
typeof payload.authTag === 'string' &&
|
|
359
|
+
typeof payload.encryptedData === 'string'
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Parse and validate an encrypted payload envelope.
|
|
365
|
+
*
|
|
366
|
+
* @param {unknown} body
|
|
367
|
+
* @returns {ParseBodyResult}
|
|
368
|
+
*/
|
|
369
|
+
function parseEncryptedBody(body) {
|
|
370
|
+
const parsedBody = parseBodyAsObject(body, {
|
|
371
|
+
invalidJson: REQUEST_ERRORS.INVALID_ENCRYPTED_PAYLOAD,
|
|
372
|
+
invalidType: REQUEST_ERRORS.INVALID_ENCRYPTED_PAYLOAD
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
if (!parsedBody.ok) {
|
|
376
|
+
return parsedBody;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (!isEncryptedEnvelope(parsedBody.data)) {
|
|
380
|
+
return { ok: false, error: REQUEST_ERRORS.INVALID_ENCRYPTED_PAYLOAD };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
return parsedBody;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Read a header value case-insensitively.
|
|
388
|
+
*
|
|
389
|
+
* @param {Record<string, any>} headers
|
|
390
|
+
* @param {string} name
|
|
391
|
+
* @returns {string}
|
|
392
|
+
*/
|
|
393
|
+
function getHeader(headers = {}, name) {
|
|
394
|
+
if (!headers || typeof headers !== 'object') {
|
|
395
|
+
return '';
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const lowerName = String(name || '').toLowerCase();
|
|
399
|
+
if (!lowerName) return '';
|
|
400
|
+
|
|
401
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
402
|
+
if (String(key).toLowerCase() === lowerName) {
|
|
403
|
+
return String(value || '').trim();
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return '';
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Encryption utility library (SDK version)
|
|
412
|
+
* ReiStandard SDK v2.0.1
|
|
413
|
+
*
|
|
414
|
+
* Wraps AES-256-GCM operations for request/response and storage encryption.
|
|
415
|
+
*/
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Derive a user-specific encryption key from the master key.
|
|
420
|
+
*
|
|
421
|
+
* @param {string} userId - Unique user identifier.
|
|
422
|
+
* @param {string} masterKey - 64-char hex master key.
|
|
423
|
+
* @returns {string} 64-char hex key.
|
|
424
|
+
*/
|
|
425
|
+
function deriveUserEncryptionKey(userId, masterKey) {
|
|
426
|
+
return createHash('sha256')
|
|
427
|
+
.update(masterKey + userId)
|
|
428
|
+
.digest('hex')
|
|
429
|
+
.slice(0, 64);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Decrypt a client-encrypted request body (AES-256-GCM, base64 encoded).
|
|
434
|
+
*
|
|
435
|
+
* @param {{ iv: string, authTag: string, encryptedData: string }} encryptedPayload
|
|
436
|
+
* @param {string} encryptionKey - 64-char hex key.
|
|
437
|
+
* @returns {Object} Decrypted JSON object.
|
|
438
|
+
*/
|
|
439
|
+
function decryptPayload(encryptedPayload, encryptionKey) {
|
|
440
|
+
const { iv, authTag, encryptedData } = encryptedPayload;
|
|
441
|
+
|
|
442
|
+
const decipher = createDecipheriv(
|
|
443
|
+
'aes-256-gcm',
|
|
444
|
+
Buffer.from(encryptionKey, 'hex'),
|
|
445
|
+
Buffer.from(iv, 'base64')
|
|
446
|
+
);
|
|
447
|
+
|
|
448
|
+
decipher.setAuthTag(Buffer.from(authTag, 'base64'));
|
|
449
|
+
|
|
450
|
+
const decrypted = Buffer.concat([
|
|
451
|
+
decipher.update(Buffer.from(encryptedData, 'base64')),
|
|
452
|
+
decipher.final()
|
|
453
|
+
]);
|
|
454
|
+
|
|
455
|
+
return JSON.parse(decrypted.toString('utf8'));
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Encrypt a JSON payload for API transfer (AES-256-GCM, base64 encoded).
|
|
460
|
+
*
|
|
461
|
+
* @param {string|Object} payload
|
|
462
|
+
* @param {string} encryptionKey - 64-char hex key.
|
|
463
|
+
* @returns {{ iv: string, authTag: string, encryptedData: string }}
|
|
464
|
+
*/
|
|
465
|
+
function encryptPayload(payload, encryptionKey) {
|
|
466
|
+
const plaintext = typeof payload === 'string' ? payload : JSON.stringify(payload);
|
|
467
|
+
const iv = randomBytes$1(12);
|
|
468
|
+
const cipher = createCipheriv('aes-256-gcm', Buffer.from(encryptionKey, 'hex'), iv);
|
|
469
|
+
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
|
470
|
+
const authTag = cipher.getAuthTag();
|
|
471
|
+
|
|
472
|
+
return {
|
|
473
|
+
iv: iv.toString('base64'),
|
|
474
|
+
authTag: authTag.toString('base64'),
|
|
475
|
+
encryptedData: encrypted.toString('base64')
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Encrypt data for database storage (hex encoded, colon-separated).
|
|
481
|
+
*
|
|
482
|
+
* @param {string} text - Plaintext string.
|
|
483
|
+
* @param {string} encryptionKey - 64-char hex key.
|
|
484
|
+
* @returns {string} Format: iv:authTag:encryptedData
|
|
485
|
+
*/
|
|
486
|
+
function encryptForStorage(text, encryptionKey) {
|
|
487
|
+
const iv = randomBytes$1(16);
|
|
488
|
+
const cipher = createCipheriv('aes-256-gcm', Buffer.from(encryptionKey, 'hex'), iv);
|
|
489
|
+
const encrypted = cipher.update(text, 'utf8', 'hex') + cipher.final('hex');
|
|
490
|
+
const authTag = cipher.getAuthTag();
|
|
491
|
+
return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Decrypt data from database storage format.
|
|
496
|
+
*
|
|
497
|
+
* @param {string} encryptedText - Format: iv:authTag:encryptedData
|
|
498
|
+
* @param {string} encryptionKey - 64-char hex key.
|
|
499
|
+
* @returns {string} Plaintext string.
|
|
500
|
+
*/
|
|
501
|
+
function decryptFromStorage(encryptedText, encryptionKey) {
|
|
502
|
+
const [ivHex, authTagHex, encryptedDataHex] = encryptedText.split(':');
|
|
503
|
+
const decipher = createDecipheriv(
|
|
504
|
+
'aes-256-gcm',
|
|
505
|
+
Buffer.from(encryptionKey, 'hex'),
|
|
506
|
+
Buffer.from(ivHex, 'hex')
|
|
507
|
+
);
|
|
508
|
+
decipher.setAuthTag(Buffer.from(authTagHex, 'hex'));
|
|
509
|
+
return decipher.update(encryptedDataHex, 'hex', 'utf8') + decipher.final('utf8');
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Handler: get-user-key
|
|
514
|
+
* ReiStandard SDK v2.0.1
|
|
515
|
+
*
|
|
516
|
+
* @param {Object} ctx - Server context.
|
|
517
|
+
* @returns {{ GET: function }}
|
|
518
|
+
*/
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
function createGetUserKeyHandler(ctx) {
|
|
522
|
+
async function GET(url, headers) {
|
|
523
|
+
const effectiveHeaders = headers || url || {};
|
|
524
|
+
const tenantResult = await ctx.tenantManager.resolveTenant(effectiveHeaders);
|
|
525
|
+
if (!tenantResult.ok) {
|
|
526
|
+
return tenantResult.error;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const { masterKey } = tenantResult.context;
|
|
530
|
+
const userId = getHeader(effectiveHeaders, 'x-user-id');
|
|
531
|
+
|
|
532
|
+
if (!userId) {
|
|
533
|
+
return {
|
|
534
|
+
status: 400,
|
|
535
|
+
body: { success: false, error: { code: 'USER_ID_REQUIRED', message: '缺少用户标识符' } }
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if (!isValidUUIDv4(userId)) {
|
|
540
|
+
return {
|
|
541
|
+
status: 400,
|
|
542
|
+
body: { success: false, error: { code: 'INVALID_USER_ID_FORMAT', message: 'X-User-Id 必须是 UUID v4 格式' } }
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
return {
|
|
547
|
+
status: 200,
|
|
548
|
+
body: {
|
|
549
|
+
success: true,
|
|
550
|
+
data: {
|
|
551
|
+
userKey: deriveUserEncryptionKey(userId, masterKey),
|
|
552
|
+
version: 1
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
return { GET };
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Database error helpers.
|
|
563
|
+
*/
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Check whether an error is caused by a unique-constraint violation.
|
|
567
|
+
*
|
|
568
|
+
* @param {unknown} error
|
|
569
|
+
* @returns {boolean}
|
|
570
|
+
*/
|
|
571
|
+
function isUniqueViolation(error) {
|
|
572
|
+
if (!error || typeof error !== 'object') return false;
|
|
573
|
+
|
|
574
|
+
const code = error.code;
|
|
575
|
+
if (code === '23505') return true;
|
|
576
|
+
|
|
577
|
+
const message = typeof error.message === 'string' ? error.message.toLowerCase() : '';
|
|
578
|
+
return message.includes('duplicate key') || message.includes('unique constraint');
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Message Processor (SDK version)
|
|
583
|
+
* ReiStandard amsg-server v2.4.0
|
|
584
|
+
*
|
|
585
|
+
* Handles single message content generation and Web Push delivery for
|
|
586
|
+
* scheduled tasks (`fixed` / `prompted` / `auto`) and the
|
|
587
|
+
* in-server instant path (`messageType: 'instant'`).
|
|
588
|
+
*
|
|
589
|
+
* Push wire shape comes from `@rei-standard/amsg-shared`'s
|
|
590
|
+
* discriminated union (`AmsgPush`). The SW (`@rei-standard/amsg-sw`)
|
|
591
|
+
* routes on `messageKind`. Server-driven pushes always carry
|
|
592
|
+
* `source: 'instant'` (for the in-server instant path) or
|
|
593
|
+
* `source: 'scheduled'` (for everything else).
|
|
594
|
+
*
|
|
595
|
+
* v2.4.0: when the LLM response carries non-empty
|
|
596
|
+
* `choices[0].message.reasoning_content`, the processor now emits a
|
|
597
|
+
* standalone `ReasoningPush` **before** the `ContentPush` burst.
|
|
598
|
+
* `messagesSent` in the return value continues to reflect the sentence
|
|
599
|
+
* count only (reasoning is an auxiliary push, not a sentence).
|
|
600
|
+
*/
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
const DEFAULT_SPLIT_REGEX = /([。!?!?]+)/;
|
|
604
|
+
|
|
605
|
+
// Pacing between consecutive Web Push deliveries (reasoning → content, and
|
|
606
|
+
// between content sentences) so the client renders a natural typing cadence.
|
|
607
|
+
// Kept equal to amsg-instant's SLEEP_BETWEEN_MESSAGES_MS default.
|
|
608
|
+
const SLEEP_BETWEEN_MESSAGES_MS = 1500;
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Split a single chunk by one regex; on no-match return [chunk] so a later
|
|
612
|
+
* regex in a cascade can still take a swing at it.
|
|
613
|
+
*/
|
|
614
|
+
function splitOnceByRegex(chunk, regex) {
|
|
615
|
+
const out = chunk
|
|
616
|
+
.split(regex)
|
|
617
|
+
.reduce((acc, part, i, arr) => {
|
|
618
|
+
if (i % 2 === 0 && part.trim()) {
|
|
619
|
+
const punctuation = arr[i + 1] || '';
|
|
620
|
+
acc.push(part.trim() + punctuation);
|
|
621
|
+
}
|
|
622
|
+
return acc;
|
|
623
|
+
}, [])
|
|
624
|
+
.filter(s => s.length > 0);
|
|
625
|
+
return out.length > 0 ? out : [chunk];
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* Sentence splitter for amsg-server's scheduled `splitPattern` feature
|
|
630
|
+
* (see standards §6.1). Server-only: amsg-instant 0.8.0 dropped its
|
|
631
|
+
* request-level `splitPattern`, so there is no instant counterpart to keep
|
|
632
|
+
* in lockstep.
|
|
633
|
+
*
|
|
634
|
+
* @param {string} messageContent
|
|
635
|
+
* @param {string | string[] | null} [splitPattern=null]
|
|
636
|
+
* @returns {string[]}
|
|
637
|
+
*/
|
|
638
|
+
function splitMessageIntoSentences(messageContent, splitPattern = null) {
|
|
639
|
+
const sources =
|
|
640
|
+
splitPattern == null ? null :
|
|
641
|
+
Array.isArray(splitPattern) ? splitPattern :
|
|
642
|
+
[splitPattern];
|
|
643
|
+
|
|
644
|
+
const regexes = (sources && sources.length > 0)
|
|
645
|
+
? sources.map(s => new RegExp(s))
|
|
646
|
+
: [DEFAULT_SPLIT_REGEX];
|
|
647
|
+
|
|
648
|
+
let chunks = [messageContent];
|
|
649
|
+
for (const regex of regexes) {
|
|
650
|
+
chunks = chunks.flatMap(c => splitOnceByRegex(c, regex));
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
return chunks.length > 0 ? chunks : [messageContent];
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* @typedef {Object} ProcessorContext
|
|
658
|
+
* @property {Object} webpush - The web-push module instance (already VAPID-configured).
|
|
659
|
+
* @property {Object} vapid - { email, publicKey, privateKey }
|
|
660
|
+
* @property {import('../adapters/interface.js').DbAdapter} db
|
|
661
|
+
*/
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* Process a single database task row: decrypt → generate content → push.
|
|
665
|
+
*
|
|
666
|
+
* @param {import('../adapters/interface.js').TaskRow} task
|
|
667
|
+
* @param {ProcessorContext} ctx
|
|
668
|
+
* @param {string} [providedMasterKey]
|
|
669
|
+
* @returns {Promise<{ success: boolean, messagesSent: number, error?: string }>}
|
|
670
|
+
*/
|
|
671
|
+
async function processSingleMessage(task, ctx, providedMasterKey) {
|
|
672
|
+
try {
|
|
673
|
+
const masterKey = providedMasterKey || ctx.masterKey;
|
|
674
|
+
if (!masterKey) {
|
|
675
|
+
return { success: false, messagesSent: 0, error: 'TENANT_MASTER_KEY_MISSING' };
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
const userKey = deriveUserEncryptionKey(task.user_id, masterKey);
|
|
679
|
+
const decryptedPayload = JSON.parse(decryptFromStorage(task.encrypted_payload, userKey));
|
|
680
|
+
|
|
681
|
+
let messageContent;
|
|
682
|
+
/** @type {unknown} */
|
|
683
|
+
let llmResponse = null;
|
|
684
|
+
|
|
685
|
+
if (decryptedPayload.messageType === 'fixed') {
|
|
686
|
+
messageContent = decryptedPayload.userMessage;
|
|
687
|
+
|
|
688
|
+
} else if (decryptedPayload.messageType === 'instant') {
|
|
689
|
+
const hasPrompt = !!decryptedPayload.completePrompt
|
|
690
|
+
|| (Array.isArray(decryptedPayload.messages) && decryptedPayload.messages.length > 0);
|
|
691
|
+
if (hasPrompt && decryptedPayload.apiUrl && decryptedPayload.apiKey && decryptedPayload.primaryModel) {
|
|
692
|
+
const aiResult = await _callAI(decryptedPayload);
|
|
693
|
+
messageContent = aiResult.content;
|
|
694
|
+
llmResponse = aiResult.response;
|
|
695
|
+
} else if (decryptedPayload.userMessage) {
|
|
696
|
+
messageContent = decryptedPayload.userMessage;
|
|
697
|
+
} else {
|
|
698
|
+
throw new Error('Invalid instant message: no content source available');
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
} else if (decryptedPayload.messageType === 'prompted' || decryptedPayload.messageType === 'auto') {
|
|
702
|
+
const aiResult = await _callAI(decryptedPayload);
|
|
703
|
+
messageContent = aiResult.content;
|
|
704
|
+
llmResponse = aiResult.response;
|
|
705
|
+
} else {
|
|
706
|
+
throw new Error('Invalid message configuration: no content source available');
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// Auto-extract reasoning BEFORE the sentence split: when reasoning
|
|
710
|
+
// came from the `<think>` fallback inside message.content, the same
|
|
711
|
+
// span is still embedded in messageContent and would otherwise leak
|
|
712
|
+
// as raw markup into ContentPush.
|
|
713
|
+
const reasoning = readReasoningContent(llmResponse);
|
|
714
|
+
if (reasoning) {
|
|
715
|
+
messageContent = stripReasoningTags(messageContent);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// Sentence splitting (mirrors @rei-standard/amsg-instant
|
|
719
|
+
// splitMessageIntoSentences — keep in lockstep; do not drift). Caller may
|
|
720
|
+
// override the default regex via decryptedPayload.splitPattern (string
|
|
721
|
+
// for a single regex, string[] for a cascade). Validation already enforces
|
|
722
|
+
// length cap + RegExp compilability upstream.
|
|
723
|
+
const messages = splitMessageIntoSentences(messageContent, decryptedPayload.splitPattern ?? null);
|
|
724
|
+
|
|
725
|
+
if (!ctx.vapid.email || !ctx.vapid.publicKey || !ctx.vapid.privateKey) {
|
|
726
|
+
throw new Error('VAPID configuration missing - push notifications cannot be sent');
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
const pushSubscription = decryptedPayload.pushSubscription;
|
|
730
|
+
// sessionId is shared across the optional ReasoningPush and every
|
|
731
|
+
// ContentPush from this LLM round. Pin it to the task id when
|
|
732
|
+
// available (scheduled tasks) so retries reuse the same id;
|
|
733
|
+
// otherwise mint a UUID.
|
|
734
|
+
const sessionId = task.id != null
|
|
735
|
+
? `sess_task_${task.id}`
|
|
736
|
+
: `sess_${randomUUID()}`;
|
|
737
|
+
const source = decryptedPayload.messageType === 'instant' ? 'instant' : 'scheduled';
|
|
738
|
+
const messageSubtype = decryptedPayload.messageSubtype || 'chat';
|
|
739
|
+
const avatarUrl = decryptedPayload.avatarUrl || null;
|
|
740
|
+
const metadata = decryptedPayload.metadata || {};
|
|
741
|
+
|
|
742
|
+
// `messageId` format — deterministic when we have a task.id so a
|
|
743
|
+
// retry produces the same id for the same (task, sentence) pair
|
|
744
|
+
// (downstream dedupers can key on it). Falls back to a UUID for
|
|
745
|
+
// the in-server instant path that has no row id.
|
|
746
|
+
const messageIdBase = task.id != null
|
|
747
|
+
? `msg_task_${task.id}`
|
|
748
|
+
: `msg_${randomUUID()}_instant`;
|
|
749
|
+
|
|
750
|
+
// ReasoningPush — auto-emitted before the content burst when the
|
|
751
|
+
// LLM response carried non-empty reasoning_content. `fixed` and
|
|
752
|
+
// explicit-userMessage paths produce no LLM response, so this
|
|
753
|
+
// block is naturally skipped for them (llmResponse stays null).
|
|
754
|
+
if (reasoning) {
|
|
755
|
+
const reasoningPush = buildReasoningPush({
|
|
756
|
+
messageType: decryptedPayload.messageType,
|
|
757
|
+
source,
|
|
758
|
+
messageId: `${messageIdBase}_reasoning`,
|
|
759
|
+
sessionId,
|
|
760
|
+
reasoningContent: reasoning,
|
|
761
|
+
timestamp: new Date().toISOString(),
|
|
762
|
+
title: `来自 ${decryptedPayload.contactName}`,
|
|
763
|
+
contactName: decryptedPayload.contactName,
|
|
764
|
+
avatarUrl,
|
|
765
|
+
messageSubtype,
|
|
766
|
+
metadata,
|
|
767
|
+
});
|
|
768
|
+
await ctx.webpush.sendNotification(pushSubscription, JSON.stringify(reasoningPush));
|
|
769
|
+
await new Promise(resolve => setTimeout(resolve, SLEEP_BETWEEN_MESSAGES_MS));
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
for (let i = 0; i < messages.length; i++) {
|
|
773
|
+
const contentPush = buildContentPush({
|
|
774
|
+
messageType: decryptedPayload.messageType,
|
|
775
|
+
source,
|
|
776
|
+
messageId: `${messageIdBase}_${i}`,
|
|
777
|
+
sessionId,
|
|
778
|
+
message: messages[i],
|
|
779
|
+
timestamp: new Date().toISOString(),
|
|
780
|
+
title: `来自 ${decryptedPayload.contactName}`,
|
|
781
|
+
contactName: decryptedPayload.contactName,
|
|
782
|
+
avatarUrl,
|
|
783
|
+
messageSubtype,
|
|
784
|
+
messageIndex: i + 1,
|
|
785
|
+
totalMessages: messages.length,
|
|
786
|
+
taskId: task.id || null,
|
|
787
|
+
metadata,
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
await ctx.webpush.sendNotification(pushSubscription, JSON.stringify(contentPush));
|
|
791
|
+
|
|
792
|
+
if (i < messages.length - 1) {
|
|
793
|
+
await new Promise(resolve => setTimeout(resolve, SLEEP_BETWEEN_MESSAGES_MS));
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
return { success: true, messagesSent: messages.length };
|
|
798
|
+
|
|
799
|
+
} catch (error) {
|
|
800
|
+
return { success: false, messagesSent: 0, error: error.message };
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* Process a single message identified by UUID (used for instant type).
|
|
806
|
+
*
|
|
807
|
+
* @param {string} uuid
|
|
808
|
+
* @param {ProcessorContext} ctx
|
|
809
|
+
* @param {number} [maxRetries=2]
|
|
810
|
+
* @param {string} [userId]
|
|
811
|
+
* @param {string} [providedMasterKey]
|
|
812
|
+
* @returns {Promise<{ success: boolean, messagesSent?: number, retriesUsed?: number, error?: Object }>}
|
|
813
|
+
*/
|
|
814
|
+
async function processMessagesByUuid(uuid, ctx, maxRetries = 2, userId, providedMasterKey) {
|
|
815
|
+
let retryCount = 0;
|
|
816
|
+
const masterKey = providedMasterKey || ctx.masterKey;
|
|
817
|
+
|
|
818
|
+
if (!masterKey) {
|
|
819
|
+
return {
|
|
820
|
+
success: false,
|
|
821
|
+
error: { code: 'TENANT_MASTER_KEY_MISSING', message: '租户主密钥不存在或配置异常' }
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
while (retryCount <= maxRetries) {
|
|
826
|
+
let task;
|
|
827
|
+
try {
|
|
828
|
+
task = userId
|
|
829
|
+
? await ctx.db.getTaskByUuid(uuid, userId)
|
|
830
|
+
: await ctx.db.getTaskByUuidOnly(uuid);
|
|
831
|
+
} catch (error) {
|
|
832
|
+
if (retryCount < maxRetries) {
|
|
833
|
+
retryCount++;
|
|
834
|
+
await new Promise(resolve => setTimeout(resolve, 1000 * retryCount));
|
|
835
|
+
continue;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
return {
|
|
839
|
+
success: false,
|
|
840
|
+
error: { code: 'INTERNAL_ERROR', message: error.message, retriesAttempted: retryCount }
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
if (!task) {
|
|
845
|
+
return { success: false, error: { code: 'TASK_NOT_FOUND', message: '任务不存在或已处理' } };
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
const result = await processSingleMessage(task, ctx, masterKey);
|
|
849
|
+
|
|
850
|
+
if (!result.success) {
|
|
851
|
+
if (retryCount < maxRetries) {
|
|
852
|
+
retryCount++;
|
|
853
|
+
await new Promise(resolve => setTimeout(resolve, 1000 * retryCount));
|
|
854
|
+
continue;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
try {
|
|
858
|
+
await ctx.db.updateTaskById(task.id, { status: 'failed', retry_count: retryCount });
|
|
859
|
+
} catch (_updateError) {
|
|
860
|
+
// best-effort status update; keep original processing error as primary signal
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
return {
|
|
864
|
+
success: false,
|
|
865
|
+
error: { code: 'PROCESSING_ERROR', message: result.error, retriesAttempted: retryCount }
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
try {
|
|
870
|
+
await ctx.db.deleteTaskById(task.id);
|
|
871
|
+
} catch (error) {
|
|
872
|
+
try {
|
|
873
|
+
await ctx.db.updateTaskById(task.id, { status: 'sent', retry_count: 0 });
|
|
874
|
+
} catch (_markSentError) {
|
|
875
|
+
// best effort: avoid re-sending if storage mutation partially fails
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
return {
|
|
879
|
+
success: false,
|
|
880
|
+
error: {
|
|
881
|
+
code: 'POST_SEND_CLEANUP_FAILED',
|
|
882
|
+
message: '消息已发送,但任务清理失败',
|
|
883
|
+
details: { error: error.message }
|
|
884
|
+
}
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
return { success: true, messagesSent: result.messagesSent, retriesUsed: retryCount };
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* Call an OpenAI-compatible API.
|
|
894
|
+
*
|
|
895
|
+
* Returns the full response object alongside the extracted (trimmed)
|
|
896
|
+
* `content` string. Callers that only need the text can ignore
|
|
897
|
+
* `response`; callers that want `reasoning_content` / `tool_calls`
|
|
898
|
+
* read from `response.choices[0].message`.
|
|
899
|
+
*
|
|
900
|
+
* @private
|
|
901
|
+
* @param {Object} payload
|
|
902
|
+
* @returns {Promise<{ response: unknown, content: string }>}
|
|
903
|
+
*/
|
|
904
|
+
async function _callAI(payload) {
|
|
905
|
+
const normalizedApiUrl = normalizeAiApiUrl(payload.apiUrl);
|
|
906
|
+
const requestBody = buildAiRequestBody(payload);
|
|
907
|
+
|
|
908
|
+
const aiResponse = await fetch(normalizedApiUrl, {
|
|
909
|
+
method: 'POST',
|
|
910
|
+
headers: {
|
|
911
|
+
'Content-Type': 'application/json',
|
|
912
|
+
'Authorization': `Bearer ${payload.apiKey}`
|
|
913
|
+
},
|
|
914
|
+
body: JSON.stringify(requestBody),
|
|
915
|
+
signal: AbortSignal.timeout(300000)
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
if (!aiResponse.ok) {
|
|
919
|
+
if (aiResponse.status === 405) {
|
|
920
|
+
throw new Error(
|
|
921
|
+
`AI API error: 405 Method Not Allowed. ` +
|
|
922
|
+
`apiUrl must point to a full chat endpoint (for example: /chat/completions). ` +
|
|
923
|
+
`Received: ${normalizedApiUrl}`
|
|
924
|
+
);
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
throw new Error(
|
|
928
|
+
`AI API error: ${aiResponse.status} ${aiResponse.statusText || 'Unknown Error'}. ` +
|
|
929
|
+
`Request URL: ${normalizedApiUrl}`
|
|
930
|
+
);
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
const aiData = await aiResponse.json();
|
|
934
|
+
const content = aiData?.choices?.[0]?.message?.content;
|
|
935
|
+
if (typeof content !== 'string' || !content.trim()) {
|
|
936
|
+
throw new Error('AI API error: response missing choices[0].message.content');
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
return { response: aiData, content: content.trim() };
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
/**
|
|
943
|
+
* Build OpenAI-compatible request body.
|
|
944
|
+
*
|
|
945
|
+
* `max_tokens` is optional:
|
|
946
|
+
* - include it only when payload.maxTokens is provided
|
|
947
|
+
* - omit it when payload.maxTokens is undefined / null
|
|
948
|
+
*
|
|
949
|
+
* @param {Object} payload
|
|
950
|
+
* @returns {Object}
|
|
951
|
+
*/
|
|
952
|
+
function buildAiRequestBody(payload) {
|
|
953
|
+
// messages mode (added in v2.2.0): forward the caller's OpenAI-style array
|
|
954
|
+
// verbatim — same contract as @rei-standard/amsg-instant 0.5.0+. No auto
|
|
955
|
+
// role injection, no concatenation back to a single user message. Lets
|
|
956
|
+
// the upstream app preserve system / multi-turn context byte-for-byte
|
|
957
|
+
// across the schedule-message path.
|
|
958
|
+
const llmMessages = Array.isArray(payload.messages) && payload.messages.length > 0
|
|
959
|
+
? payload.messages
|
|
960
|
+
: [{ role: 'user', content: payload.completePrompt }];
|
|
961
|
+
|
|
962
|
+
const requestBody = {
|
|
963
|
+
model: payload.primaryModel,
|
|
964
|
+
messages: llmMessages,
|
|
965
|
+
};
|
|
966
|
+
|
|
967
|
+
// Match the instant package's behavior: only inject default temperature
|
|
968
|
+
// for the legacy completePrompt path; messages mode forwards whatever the
|
|
969
|
+
// upstream app set (or nothing) so behavior matches their main chat path.
|
|
970
|
+
if (payload.temperature !== undefined && payload.temperature !== null) {
|
|
971
|
+
requestBody.temperature = payload.temperature;
|
|
972
|
+
} else if (!Array.isArray(payload.messages)) {
|
|
973
|
+
requestBody.temperature = 0.8;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
if (payload.maxTokens === undefined || payload.maxTokens === null) {
|
|
977
|
+
return requestBody;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
if (!Number.isInteger(payload.maxTokens) || payload.maxTokens <= 0) {
|
|
981
|
+
throw new Error('Invalid maxTokens: maxTokens must be a positive integer when provided.');
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
requestBody.max_tokens = payload.maxTokens;
|
|
985
|
+
return requestBody;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
/**
|
|
989
|
+
* Normalize AI API URL for OpenAI-compatible chat endpoints.
|
|
990
|
+
*
|
|
991
|
+
* **Keep in sync** with `@rei-standard/amsg-instant`'s
|
|
992
|
+
* `src/message-processor.js` `normalizeAiApiUrl` — same rules, same
|
|
993
|
+
* tests. The two packages share this logic but each carry their own copy
|
|
994
|
+
* to avoid an architectural dependency (server should not depend on the
|
|
995
|
+
* stateless worker package).
|
|
996
|
+
*
|
|
997
|
+
* @param {string} apiUrl
|
|
998
|
+
* @returns {string}
|
|
999
|
+
*/
|
|
1000
|
+
function normalizeAiApiUrl(apiUrl) {
|
|
1001
|
+
if (typeof apiUrl !== 'string' || !apiUrl.trim()) {
|
|
1002
|
+
throw new Error(
|
|
1003
|
+
'Invalid apiUrl: apiUrl is required. ' +
|
|
1004
|
+
'Please provide a chat endpoint URL ' +
|
|
1005
|
+
'(for example: https://api.openai.com or https://api.openai.com/v1/chat/completions).'
|
|
1006
|
+
);
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
const trimmedApiUrl = apiUrl.trim();
|
|
1010
|
+
let parsedUrl;
|
|
1011
|
+
|
|
1012
|
+
try {
|
|
1013
|
+
parsedUrl = new URL(trimmedApiUrl);
|
|
1014
|
+
} catch {
|
|
1015
|
+
throw new Error(
|
|
1016
|
+
`Invalid apiUrl: "${apiUrl}". Please provide a valid absolute URL.`
|
|
1017
|
+
);
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
let path = parsedUrl.pathname.replace(/\/+$/, '') || '/';
|
|
1021
|
+
|
|
1022
|
+
if (/\/chat\/completions$/.test(path)) ; else if (path === '/') {
|
|
1023
|
+
// Bare host → assume OpenAI shape.
|
|
1024
|
+
path = '/v1/chat/completions';
|
|
1025
|
+
} else if (/\/v\d+$/.test(path)) {
|
|
1026
|
+
// Path ends in `/v1`, `/v2`, … — caller already versioned the URL.
|
|
1027
|
+
// Append only `/chat/completions`; never re-add `/v1`.
|
|
1028
|
+
path = `${path}/chat/completions`;
|
|
1029
|
+
}
|
|
1030
|
+
// Any other custom path is left untouched on purpose.
|
|
1031
|
+
|
|
1032
|
+
parsedUrl.pathname = path;
|
|
1033
|
+
return parsedUrl.toString();
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
/**
|
|
1037
|
+
* Handler: schedule-message
|
|
1038
|
+
* ReiStandard SDK v2.0.1
|
|
1039
|
+
*
|
|
1040
|
+
* @param {Object} ctx - Server context.
|
|
1041
|
+
* @returns {{ POST: function }}
|
|
1042
|
+
*/
|
|
1043
|
+
|
|
1044
|
+
|
|
1045
|
+
function createScheduleMessageHandler(ctx) {
|
|
1046
|
+
async function POST(headers, body) {
|
|
1047
|
+
const tenantResult = await ctx.tenantManager.resolveTenant(headers);
|
|
1048
|
+
if (!tenantResult.ok) {
|
|
1049
|
+
return tenantResult.error;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
const tenantCtx = tenantResult.context;
|
|
1053
|
+
const db = tenantCtx.db;
|
|
1054
|
+
const masterKey = tenantCtx.masterKey;
|
|
1055
|
+
const isEncrypted = getHeader(headers, 'x-payload-encrypted') === 'true';
|
|
1056
|
+
const encryptionVersion = getHeader(headers, 'x-encryption-version');
|
|
1057
|
+
const userId = getHeader(headers, 'x-user-id');
|
|
1058
|
+
|
|
1059
|
+
if (!isEncrypted) {
|
|
1060
|
+
return { status: 400, body: { success: false, error: { code: 'ENCRYPTION_REQUIRED', message: '请求体必须加密' } } };
|
|
1061
|
+
}
|
|
1062
|
+
if (!userId) {
|
|
1063
|
+
return { status: 400, body: { success: false, error: { code: 'USER_ID_REQUIRED', message: '缺少用户标识符' } } };
|
|
1064
|
+
}
|
|
1065
|
+
if (!isValidUUIDv4(userId)) {
|
|
1066
|
+
return { status: 400, body: { success: false, error: { code: 'INVALID_USER_ID_FORMAT', message: 'X-User-Id 必须是 UUID v4 格式' } } };
|
|
1067
|
+
}
|
|
1068
|
+
if (encryptionVersion !== '1') {
|
|
1069
|
+
return { status: 400, body: { success: false, error: { code: 'UNSUPPORTED_ENCRYPTION_VERSION', message: '加密版本不支持' } } };
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
// Decrypt request body
|
|
1073
|
+
const parsedBody = parseEncryptedBody(body);
|
|
1074
|
+
if (!parsedBody.ok) {
|
|
1075
|
+
return { status: 400, body: { success: false, error: parsedBody.error } };
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
const encryptedBody = parsedBody.data;
|
|
1079
|
+
|
|
1080
|
+
let payload;
|
|
1081
|
+
try {
|
|
1082
|
+
const userKey = deriveUserEncryptionKey(userId, masterKey);
|
|
1083
|
+
payload = decryptPayload(encryptedBody, userKey);
|
|
1084
|
+
} catch (error) {
|
|
1085
|
+
if (error instanceof SyntaxError) {
|
|
1086
|
+
return { status: 400, body: { success: false, error: { code: 'INVALID_PAYLOAD_FORMAT', message: '解密后的数据不是有效 JSON' } } };
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
const message = typeof error.message === 'string' ? error.message : '';
|
|
1090
|
+
if (message.includes('auth') || message.includes('Unsupported state')) {
|
|
1091
|
+
return { status: 400, body: { success: false, error: { code: 'DECRYPTION_FAILED', message: '请求体解密失败' } } };
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
return { status: 400, body: { success: false, error: { code: 'DECRYPTION_FAILED', message: '请求体解密失败' } } };
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
if (!isPlainObject(payload)) {
|
|
1098
|
+
return { status: 400, body: { success: false, error: { code: 'INVALID_PAYLOAD_FORMAT', message: '解密后的数据必须是 JSON 对象' } } };
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// Validate
|
|
1102
|
+
const validationResult = validateScheduleMessagePayload(payload);
|
|
1103
|
+
if (!validationResult.valid) {
|
|
1104
|
+
return { status: 400, body: { success: false, error: { code: validationResult.errorCode, message: validationResult.errorMessage, details: validationResult.details } } };
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
const taskUuid = payload.uuid || randomUUID();
|
|
1108
|
+
const userKey = deriveUserEncryptionKey(userId, masterKey);
|
|
1109
|
+
|
|
1110
|
+
const fullTaskData = {
|
|
1111
|
+
contactName: payload.contactName,
|
|
1112
|
+
avatarUrl: payload.avatarUrl || null,
|
|
1113
|
+
messageType: payload.messageType,
|
|
1114
|
+
messageSubtype: payload.messageSubtype || 'chat',
|
|
1115
|
+
userMessage: payload.userMessage || null,
|
|
1116
|
+
firstSendTime: payload.firstSendTime,
|
|
1117
|
+
recurrenceType: payload.recurrenceType || 'none',
|
|
1118
|
+
apiUrl: payload.apiUrl || null,
|
|
1119
|
+
apiKey: payload.apiKey || null,
|
|
1120
|
+
primaryModel: payload.primaryModel || null,
|
|
1121
|
+
// Prompt is one-of: legacy completePrompt (string) OR messages (OpenAI-
|
|
1122
|
+
// style array). Validation has already enforced exactly-one-of, so
|
|
1123
|
+
// exactly one of these will be non-null when an AI config is provided.
|
|
1124
|
+
completePrompt: payload.completePrompt || null,
|
|
1125
|
+
messages: Array.isArray(payload.messages) ? payload.messages : null,
|
|
1126
|
+
maxTokens: payload.maxTokens ?? null,
|
|
1127
|
+
temperature: payload.temperature ?? null,
|
|
1128
|
+
// 0.6.0+: optional caller-provided regex (string or string[]) used by
|
|
1129
|
+
// the message processor to chunk LLM output into individual pushes.
|
|
1130
|
+
// null → processor falls back to the default /([。!?!?]+)/ regex.
|
|
1131
|
+
splitPattern: payload.splitPattern ?? null,
|
|
1132
|
+
pushSubscription: payload.pushSubscription,
|
|
1133
|
+
metadata: payload.metadata || {}
|
|
1134
|
+
};
|
|
1135
|
+
|
|
1136
|
+
const encryptedPayload = encryptForStorage(JSON.stringify(fullTaskData), userKey);
|
|
1137
|
+
|
|
1138
|
+
/**
|
|
1139
|
+
* In-server instant path. Delivers an instant message through this
|
|
1140
|
+
* server's own task queue (create task → process by UUID → delete task).
|
|
1141
|
+
* The task is written to the database before processing, so delivery is
|
|
1142
|
+
* not tied to the request connection: even if the client disconnects, the
|
|
1143
|
+
* row stays and the generation keeps running (and can be retried) for as
|
|
1144
|
+
* long as it needs. Use this when you have a database and want long or
|
|
1145
|
+
* guaranteed-complete generations with no dropped messages.
|
|
1146
|
+
*
|
|
1147
|
+
* The stateless alternative is `@rei-standard/amsg-instant`: it streams
|
|
1148
|
+
* over SSE with a Web Push backup and needs no database, which makes it a
|
|
1149
|
+
* good fit for edge runtimes (e.g. Cloudflare Workers). Its work rides the
|
|
1150
|
+
* response connection, so after the client disconnects it only has the
|
|
1151
|
+
* platform's brief grace window to finish (≈20-30s observed on Deno
|
|
1152
|
+
* Deploy) — ideal for short instant messages that complete quickly.
|
|
1153
|
+
*/
|
|
1154
|
+
// Instant type: check VAPID before creating the task to avoid orphaned rows
|
|
1155
|
+
if (payload.messageType === 'instant') {
|
|
1156
|
+
if (!ctx.vapid.email || !ctx.vapid.publicKey || !ctx.vapid.privateKey) {
|
|
1157
|
+
return {
|
|
1158
|
+
status: 500,
|
|
1159
|
+
body: {
|
|
1160
|
+
success: false,
|
|
1161
|
+
error: {
|
|
1162
|
+
code: 'VAPID_CONFIG_ERROR',
|
|
1163
|
+
message: 'VAPID 配置缺失,无法发送即时消息',
|
|
1164
|
+
details: {
|
|
1165
|
+
missingKeys: [
|
|
1166
|
+
!ctx.vapid.email && 'VAPID_EMAIL',
|
|
1167
|
+
!ctx.vapid.publicKey && 'NEXT_PUBLIC_VAPID_PUBLIC_KEY',
|
|
1168
|
+
!ctx.vapid.privateKey && 'VAPID_PRIVATE_KEY'
|
|
1169
|
+
].filter(Boolean)
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
};
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
// Insert into database
|
|
1178
|
+
let dbResult;
|
|
1179
|
+
try {
|
|
1180
|
+
dbResult = await db.createTask({
|
|
1181
|
+
user_id: userId,
|
|
1182
|
+
uuid: taskUuid,
|
|
1183
|
+
encrypted_payload: encryptedPayload,
|
|
1184
|
+
next_send_at: payload.firstSendTime,
|
|
1185
|
+
message_type: payload.messageType
|
|
1186
|
+
});
|
|
1187
|
+
} catch (error) {
|
|
1188
|
+
if (isUniqueViolation(error)) {
|
|
1189
|
+
return {
|
|
1190
|
+
status: 409,
|
|
1191
|
+
body: {
|
|
1192
|
+
success: false,
|
|
1193
|
+
error: {
|
|
1194
|
+
code: 'TASK_UUID_CONFLICT',
|
|
1195
|
+
message: '任务 UUID 已存在,请使用新的 uuid 重新提交'
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
};
|
|
1199
|
+
}
|
|
1200
|
+
throw error;
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
if (!dbResult) {
|
|
1204
|
+
return { status: 500, body: { success: false, error: { code: 'TASK_CREATE_FAILED', message: '创建任务失败' } } };
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
/**
|
|
1208
|
+
* In-server instant path. Delivers an instant message through this
|
|
1209
|
+
* server's own task queue (create task → process by UUID → delete task).
|
|
1210
|
+
* The task is written to the database before processing, so delivery is
|
|
1211
|
+
* not tied to the request connection: even if the client disconnects, the
|
|
1212
|
+
* row stays and the generation keeps running (and can be retried) for as
|
|
1213
|
+
* long as it needs. Use this when you have a database and want long or
|
|
1214
|
+
* guaranteed-complete generations with no dropped messages.
|
|
1215
|
+
*
|
|
1216
|
+
* The stateless alternative is `@rei-standard/amsg-instant`: it streams
|
|
1217
|
+
* over SSE with a Web Push backup and needs no database, which makes it a
|
|
1218
|
+
* good fit for edge runtimes (e.g. Cloudflare Workers). Its work rides the
|
|
1219
|
+
* response connection, so after the client disconnects it only has the
|
|
1220
|
+
* platform's brief grace window to finish (≈20-30s observed on Deno
|
|
1221
|
+
* Deploy) — ideal for short instant messages that complete quickly.
|
|
1222
|
+
*/
|
|
1223
|
+
// Instant type: send immediately
|
|
1224
|
+
if (payload.messageType === 'instant') {
|
|
1225
|
+
try {
|
|
1226
|
+
const sendResult = await processMessagesByUuid(taskUuid, {
|
|
1227
|
+
...ctx,
|
|
1228
|
+
db,
|
|
1229
|
+
masterKey
|
|
1230
|
+
}, 2, userId, masterKey);
|
|
1231
|
+
|
|
1232
|
+
if (!sendResult.success) {
|
|
1233
|
+
return { status: 500, body: { success: false, error: { code: 'MESSAGE_SEND_FAILED', message: '消息发送失败', details: sendResult.error } } };
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
return {
|
|
1237
|
+
status: 200,
|
|
1238
|
+
body: {
|
|
1239
|
+
success: true,
|
|
1240
|
+
data: {
|
|
1241
|
+
uuid: taskUuid,
|
|
1242
|
+
contactName: payload.contactName,
|
|
1243
|
+
messagesSent: sendResult.messagesSent,
|
|
1244
|
+
sentAt: new Date().toISOString(),
|
|
1245
|
+
status: 'sent',
|
|
1246
|
+
retriesUsed: sendResult.retriesUsed || 0
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
};
|
|
1250
|
+
} catch (error) {
|
|
1251
|
+
return { status: 500, body: { success: false, error: { code: 'MESSAGE_SEND_FAILED', message: '消息发送失败', details: { error: error.message } } } };
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
// Non-instant: return scheduled response
|
|
1256
|
+
return {
|
|
1257
|
+
status: 201,
|
|
1258
|
+
body: {
|
|
1259
|
+
success: true,
|
|
1260
|
+
data: {
|
|
1261
|
+
id: dbResult.id,
|
|
1262
|
+
uuid: dbResult.uuid,
|
|
1263
|
+
contactName: payload.contactName,
|
|
1264
|
+
nextSendAt: dbResult.next_send_at,
|
|
1265
|
+
status: dbResult.status,
|
|
1266
|
+
createdAt: dbResult.created_at
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
};
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
return { POST };
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
/**
|
|
1276
|
+
* Scheduled tick core: fetch due tasks, deliver, reschedule/retry, cleanup.
|
|
1277
|
+
* Extracted verbatim from the send-notifications handler so both the HTTP
|
|
1278
|
+
* handler (multi-tenant) and the CF scheduled() path (single-user) share it.
|
|
1279
|
+
*
|
|
1280
|
+
* @param {Object} ctx - { db, masterKey, vapid, webpush }
|
|
1281
|
+
* @returns {Promise<Object>} summary { totalTasks, successCount, failedCount, processedAt, executionTime, details }
|
|
1282
|
+
*/
|
|
1283
|
+
|
|
1284
|
+
|
|
1285
|
+
async function runScheduledTick(ctx) {
|
|
1286
|
+
const db = ctx.db;
|
|
1287
|
+
const masterKey = ctx.masterKey;
|
|
1288
|
+
|
|
1289
|
+
const startTime = Date.now();
|
|
1290
|
+
const tasks = await db.getPendingTasks(50);
|
|
1291
|
+
|
|
1292
|
+
const MAX_CONCURRENT = 8;
|
|
1293
|
+
const results = {
|
|
1294
|
+
totalTasks: tasks.length,
|
|
1295
|
+
successCount: 0,
|
|
1296
|
+
failedCount: 0,
|
|
1297
|
+
deletedOnceOffTasks: 0,
|
|
1298
|
+
updatedRecurringTasks: 0,
|
|
1299
|
+
failedTasks: []
|
|
1300
|
+
};
|
|
1301
|
+
|
|
1302
|
+
async function handleDeliveryFailure(task, reason) {
|
|
1303
|
+
results.failedCount++;
|
|
1304
|
+
try {
|
|
1305
|
+
if (task.retry_count >= 3) {
|
|
1306
|
+
await db.updateTaskById(task.id, { status: 'failed' });
|
|
1307
|
+
results.failedTasks.push({ taskId: task.id, reason, retryCount: task.retry_count, status: 'permanently_failed' });
|
|
1308
|
+
} else {
|
|
1309
|
+
const nextRetryTime = new Date(Date.now() + (task.retry_count + 1) * 2 * 60 * 1000);
|
|
1310
|
+
await db.updateTaskById(task.id, { next_send_at: nextRetryTime.toISOString(), retry_count: task.retry_count + 1 });
|
|
1311
|
+
results.failedTasks.push({ taskId: task.id, reason, retryCount: task.retry_count + 1, nextRetryAt: nextRetryTime.toISOString() });
|
|
1312
|
+
}
|
|
1313
|
+
} catch (updateError) {
|
|
1314
|
+
results.failedTasks.push({ taskId: task.id, reason, status: 'retry_update_failed', updateError: updateError.message });
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
async function handlePostSendPersistenceFailure(task, reason) {
|
|
1319
|
+
results.failedCount++;
|
|
1320
|
+
let markedSent = false;
|
|
1321
|
+
try {
|
|
1322
|
+
await db.updateTaskById(task.id, { status: 'sent', retry_count: 0 });
|
|
1323
|
+
markedSent = true;
|
|
1324
|
+
} catch (_markSentError) {
|
|
1325
|
+
markedSent = false;
|
|
1326
|
+
}
|
|
1327
|
+
results.failedTasks.push({
|
|
1328
|
+
taskId: task.id,
|
|
1329
|
+
reason,
|
|
1330
|
+
status: markedSent ? 'post_send_cleanup_failed_marked_sent' : 'post_send_cleanup_failed',
|
|
1331
|
+
messageDelivered: true
|
|
1332
|
+
});
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
async function processTask(task) {
|
|
1336
|
+
let sendResult;
|
|
1337
|
+
try {
|
|
1338
|
+
sendResult = await processSingleMessage(task, { ...ctx, db, masterKey }, masterKey);
|
|
1339
|
+
} catch (error) {
|
|
1340
|
+
await handleDeliveryFailure(task, error.message || '消息发送失败');
|
|
1341
|
+
return;
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
if (!sendResult.success) {
|
|
1345
|
+
await handleDeliveryFailure(task, sendResult.error || '消息发送失败');
|
|
1346
|
+
return;
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
try {
|
|
1350
|
+
const userKey = deriveUserEncryptionKey(task.user_id, masterKey);
|
|
1351
|
+
const decryptedPayload = JSON.parse(decryptFromStorage(task.encrypted_payload, userKey));
|
|
1352
|
+
|
|
1353
|
+
if (decryptedPayload.recurrenceType === 'none') {
|
|
1354
|
+
await db.deleteTaskById(task.id);
|
|
1355
|
+
results.deletedOnceOffTasks++;
|
|
1356
|
+
} else {
|
|
1357
|
+
let nextSendAt;
|
|
1358
|
+
const currentSendAt = new Date(task.next_send_at);
|
|
1359
|
+
if (decryptedPayload.recurrenceType === 'daily') {
|
|
1360
|
+
nextSendAt = new Date(currentSendAt.getTime() + 24 * 60 * 60 * 1000);
|
|
1361
|
+
} else if (decryptedPayload.recurrenceType === 'weekly') {
|
|
1362
|
+
nextSendAt = new Date(currentSendAt.getTime() + 7 * 24 * 60 * 60 * 1000);
|
|
1363
|
+
}
|
|
1364
|
+
await db.updateTaskById(task.id, { next_send_at: nextSendAt.toISOString(), retry_count: 0 });
|
|
1365
|
+
results.updatedRecurringTasks++;
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
results.successCount++;
|
|
1369
|
+
} catch (error) {
|
|
1370
|
+
await handlePostSendPersistenceFailure(task, error.message || '发送后状态更新失败');
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
const taskQueue = [...tasks];
|
|
1375
|
+
const processing = [];
|
|
1376
|
+
|
|
1377
|
+
while (taskQueue.length > 0 || processing.length > 0) {
|
|
1378
|
+
while (processing.length < MAX_CONCURRENT && taskQueue.length > 0) {
|
|
1379
|
+
const task = taskQueue.shift();
|
|
1380
|
+
const promise = processTask(task);
|
|
1381
|
+
processing.push(promise);
|
|
1382
|
+
promise.finally(() => {
|
|
1383
|
+
const index = processing.indexOf(promise);
|
|
1384
|
+
if (index > -1) processing.splice(index, 1);
|
|
1385
|
+
});
|
|
1386
|
+
}
|
|
1387
|
+
if (processing.length > 0) {
|
|
1388
|
+
await Promise.race(processing);
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
await db.cleanupOldTasks(7);
|
|
1393
|
+
|
|
1394
|
+
const executionTime = Date.now() - startTime;
|
|
1395
|
+
|
|
1396
|
+
return {
|
|
1397
|
+
totalTasks: results.totalTasks,
|
|
1398
|
+
successCount: results.successCount,
|
|
1399
|
+
failedCount: results.failedCount,
|
|
1400
|
+
processedAt: new Date().toISOString(),
|
|
1401
|
+
executionTime,
|
|
1402
|
+
details: {
|
|
1403
|
+
deletedOnceOffTasks: results.deletedOnceOffTasks,
|
|
1404
|
+
updatedRecurringTasks: results.updatedRecurringTasks,
|
|
1405
|
+
failedTasks: results.failedTasks
|
|
1406
|
+
}
|
|
1407
|
+
};
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
/**
|
|
1411
|
+
* Handler: update-message
|
|
1412
|
+
* ReiStandard SDK v2.0.1
|
|
1413
|
+
*
|
|
1414
|
+
* @param {Object} ctx - Server context.
|
|
1415
|
+
* @returns {{ PUT: function }}
|
|
1416
|
+
*/
|
|
1417
|
+
|
|
1418
|
+
|
|
1419
|
+
function createUpdateMessageHandler(ctx) {
|
|
1420
|
+
async function PUT(url, headers, body) {
|
|
1421
|
+
const tenantResult = await ctx.tenantManager.resolveTenant(headers, { url });
|
|
1422
|
+
if (!tenantResult.ok) {
|
|
1423
|
+
return tenantResult.error;
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
const tenantCtx = tenantResult.context;
|
|
1427
|
+
const db = tenantCtx.db;
|
|
1428
|
+
const masterKey = tenantCtx.masterKey;
|
|
1429
|
+
const u = new URL(url, 'https://dummy');
|
|
1430
|
+
const taskUuid = u.searchParams.get('id');
|
|
1431
|
+
|
|
1432
|
+
if (!taskUuid) {
|
|
1433
|
+
return { status: 400, body: { success: false, error: { code: 'TASK_ID_REQUIRED', message: '缺少任务ID' } } };
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
const userId = getHeader(headers, 'x-user-id');
|
|
1437
|
+
if (!userId) {
|
|
1438
|
+
return { status: 400, body: { success: false, error: { code: 'USER_ID_REQUIRED', message: '缺少用户标识符' } } };
|
|
1439
|
+
}
|
|
1440
|
+
if (!isValidUUIDv4(userId)) {
|
|
1441
|
+
return { status: 400, body: { success: false, error: { code: 'INVALID_USER_ID_FORMAT', message: 'X-User-Id 必须是 UUID v4 格式' } } };
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
const isEncrypted = getHeader(headers, 'x-payload-encrypted') === 'true';
|
|
1445
|
+
const encryptionVersion = getHeader(headers, 'x-encryption-version');
|
|
1446
|
+
|
|
1447
|
+
if (!isEncrypted) {
|
|
1448
|
+
return { status: 400, body: { success: false, error: { code: 'ENCRYPTION_REQUIRED', message: '请求体必须加密' } } };
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
if (encryptionVersion !== '1') {
|
|
1452
|
+
return { status: 400, body: { success: false, error: { code: 'UNSUPPORTED_ENCRYPTION_VERSION', message: '加密版本不支持' } } };
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
const parsedBody = parseEncryptedBody(body);
|
|
1456
|
+
if (!parsedBody.ok) {
|
|
1457
|
+
return { status: 400, body: { success: false, error: parsedBody.error } };
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
const encryptedBody = parsedBody.data;
|
|
1461
|
+
const userKey = deriveUserEncryptionKey(userId, masterKey);
|
|
1462
|
+
let updates;
|
|
1463
|
+
|
|
1464
|
+
try {
|
|
1465
|
+
updates = decryptPayload(encryptedBody, userKey);
|
|
1466
|
+
} catch (_error) {
|
|
1467
|
+
return { status: 400, body: { success: false, error: { code: 'DECRYPTION_FAILED', message: '请求体解密失败' } } };
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
if (!isPlainObject(updates)) {
|
|
1471
|
+
return { status: 400, body: { success: false, error: { code: 'INVALID_UPDATE_DATA', message: '更新数据格式错误' } } };
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
if (updates.nextSendAt && !isValidISO8601(updates.nextSendAt)) {
|
|
1475
|
+
return { status: 400, body: { success: false, error: { code: 'INVALID_UPDATE_DATA', message: '更新数据格式错误', details: { invalidFields: ['nextSendAt'] } } } };
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
if (updates.recurrenceType && !['none', 'daily', 'weekly'].includes(updates.recurrenceType)) {
|
|
1479
|
+
return { status: 400, body: { success: false, error: { code: 'INVALID_UPDATE_DATA', message: '更新数据格式错误', details: { invalidFields: ['recurrenceType'] } } } };
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
if (
|
|
1483
|
+
Object.prototype.hasOwnProperty.call(updates, 'maxTokens') &&
|
|
1484
|
+
updates.maxTokens !== null &&
|
|
1485
|
+
(!Number.isInteger(updates.maxTokens) || updates.maxTokens <= 0)
|
|
1486
|
+
) {
|
|
1487
|
+
return { status: 400, body: { success: false, error: { code: 'INVALID_UPDATE_DATA', message: '更新数据格式错误', details: { invalidFields: ['maxTokens'] } } } };
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
// Reject updates that try to set both completePrompt and messages at
|
|
1491
|
+
// once. We don't enforce one-of-required here (callers may patch other
|
|
1492
|
+
// fields), but the two prompt sources are mutually exclusive and must
|
|
1493
|
+
// stay that way in storage too.
|
|
1494
|
+
if (
|
|
1495
|
+
updates.completePrompt &&
|
|
1496
|
+
updates.messages !== undefined && updates.messages !== null
|
|
1497
|
+
) {
|
|
1498
|
+
return { status: 400, body: { success: false, error: { code: 'INVALID_UPDATE_DATA', message: 'completePrompt 与 messages 不能同时更新(二选一)', details: { invalidFields: ['completePrompt', 'messages'] } } } };
|
|
1499
|
+
}
|
|
1500
|
+
if (updates.messages !== undefined && updates.messages !== null) {
|
|
1501
|
+
const msgErr = validateLlmMessagesArray(updates.messages);
|
|
1502
|
+
if (msgErr) {
|
|
1503
|
+
return { status: 400, body: { success: false, error: { code: 'INVALID_UPDATE_DATA', message: msgErr, details: { invalidFields: ['messages'] } } } };
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
if (
|
|
1507
|
+
Object.prototype.hasOwnProperty.call(updates, 'temperature') &&
|
|
1508
|
+
updates.temperature !== null &&
|
|
1509
|
+
(typeof updates.temperature !== 'number' || !Number.isFinite(updates.temperature))
|
|
1510
|
+
) {
|
|
1511
|
+
return { status: 400, body: { success: false, error: { code: 'INVALID_UPDATE_DATA', message: '更新数据格式错误', details: { invalidFields: ['temperature'] } } } };
|
|
1512
|
+
}
|
|
1513
|
+
if (Object.prototype.hasOwnProperty.call(updates, 'splitPattern')) {
|
|
1514
|
+
const splitErr = validateSplitPattern(updates.splitPattern);
|
|
1515
|
+
if (splitErr) {
|
|
1516
|
+
return { status: 400, body: { success: false, error: { code: 'INVALID_UPDATE_DATA', message: splitErr, details: { invalidFields: ['splitPattern'] } } } };
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
if (Object.prototype.hasOwnProperty.call(updates, 'avatarUrl')) {
|
|
1520
|
+
const avatarErr = validateAvatarUrl(updates.avatarUrl);
|
|
1521
|
+
if (avatarErr) {
|
|
1522
|
+
// Soft-strip: drop the bad avatarUrl from the patch (keeps the
|
|
1523
|
+
// existing stored avatar untouched) and continue applying the rest
|
|
1524
|
+
// of the update. See standards §6.2.
|
|
1525
|
+
console.warn('[amsg-server] update-message avatarUrl 不合法,已忽略:', avatarErr);
|
|
1526
|
+
delete updates.avatarUrl;
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
// Fetch existing task
|
|
1531
|
+
const existingTask = await db.getTaskByUuid(taskUuid, userId);
|
|
1532
|
+
|
|
1533
|
+
if (!existingTask) {
|
|
1534
|
+
const taskStatus = await db.getTaskStatus(taskUuid, userId);
|
|
1535
|
+
if (!taskStatus) {
|
|
1536
|
+
return { status: 404, body: { success: false, error: { code: 'TASK_NOT_FOUND', message: '指定的任务不存在或已被删除' } } };
|
|
1537
|
+
}
|
|
1538
|
+
return { status: 409, body: { success: false, error: { code: 'TASK_ALREADY_COMPLETED', message: '任务已完成或已失败,无法更新' } } };
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
const existingData = JSON.parse(decryptFromStorage(existingTask.encrypted_payload, userKey));
|
|
1542
|
+
|
|
1543
|
+
// When the caller switches prompt source (completePrompt ↔ messages),
|
|
1544
|
+
// null out the other so storage stays one-of (matches schedule-message
|
|
1545
|
+
// shape and prevents buildAiRequestBody from accidentally seeing both).
|
|
1546
|
+
const promptUpdates = {};
|
|
1547
|
+
if (updates.completePrompt) {
|
|
1548
|
+
promptUpdates.completePrompt = updates.completePrompt;
|
|
1549
|
+
promptUpdates.messages = null;
|
|
1550
|
+
} else if (updates.messages !== undefined && updates.messages !== null) {
|
|
1551
|
+
promptUpdates.messages = updates.messages;
|
|
1552
|
+
promptUpdates.completePrompt = null;
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
const updatedData = {
|
|
1556
|
+
...existingData,
|
|
1557
|
+
...promptUpdates,
|
|
1558
|
+
...(updates.userMessage && { userMessage: updates.userMessage }),
|
|
1559
|
+
...(updates.recurrenceType && { recurrenceType: updates.recurrenceType }),
|
|
1560
|
+
...(updates.avatarUrl && { avatarUrl: updates.avatarUrl }),
|
|
1561
|
+
...(updates.metadata && { metadata: updates.metadata }),
|
|
1562
|
+
...(Object.prototype.hasOwnProperty.call(updates, 'maxTokens') && { maxTokens: updates.maxTokens ?? null }),
|
|
1563
|
+
...(Object.prototype.hasOwnProperty.call(updates, 'temperature') && { temperature: updates.temperature ?? null }),
|
|
1564
|
+
// splitPattern: hasOwnProperty so that explicit `null` (= revert to
|
|
1565
|
+
// default) doesn't get swallowed by truthy-spread the way the optional
|
|
1566
|
+
// string fields above are.
|
|
1567
|
+
...(Object.prototype.hasOwnProperty.call(updates, 'splitPattern') && { splitPattern: updates.splitPattern ?? null })
|
|
1568
|
+
};
|
|
1569
|
+
|
|
1570
|
+
const encryptedPayload = encryptForStorage(JSON.stringify(updatedData), userKey);
|
|
1571
|
+
const extraFields = updates.nextSendAt ? { next_send_at: updates.nextSendAt } : undefined;
|
|
1572
|
+
|
|
1573
|
+
const result = await db.updateTaskByUuid(taskUuid, userId, encryptedPayload, extraFields);
|
|
1574
|
+
|
|
1575
|
+
if (!result) {
|
|
1576
|
+
return { status: 409, body: { success: false, error: { code: 'UPDATE_CONFLICT', message: '任务更新失败,任务可能已被修改或删除' } } };
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
return {
|
|
1580
|
+
status: 200,
|
|
1581
|
+
body: {
|
|
1582
|
+
success: true,
|
|
1583
|
+
data: {
|
|
1584
|
+
uuid: taskUuid,
|
|
1585
|
+
updatedFields: Object.keys(updates),
|
|
1586
|
+
updatedAt: result.updated_at
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
};
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
return { PUT };
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
/**
|
|
1596
|
+
* Handler: cancel-message
|
|
1597
|
+
* ReiStandard SDK v2.0.1
|
|
1598
|
+
*
|
|
1599
|
+
* @param {Object} ctx - Server context.
|
|
1600
|
+
* @returns {{ DELETE: function }}
|
|
1601
|
+
*/
|
|
1602
|
+
|
|
1603
|
+
|
|
1604
|
+
function createCancelMessageHandler(ctx) {
|
|
1605
|
+
async function DELETE(url, headers) {
|
|
1606
|
+
const tenantResult = await ctx.tenantManager.resolveTenant(headers, { url });
|
|
1607
|
+
if (!tenantResult.ok) {
|
|
1608
|
+
return tenantResult.error;
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
const db = tenantResult.context.db;
|
|
1612
|
+
const u = new URL(url, 'https://dummy');
|
|
1613
|
+
const taskUuid = u.searchParams.get('id');
|
|
1614
|
+
|
|
1615
|
+
if (!taskUuid) {
|
|
1616
|
+
return { status: 400, body: { success: false, error: { code: 'TASK_ID_REQUIRED', message: '缺少任务ID' } } };
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
const userId = getHeader(headers, 'x-user-id');
|
|
1620
|
+
if (!userId) {
|
|
1621
|
+
return { status: 400, body: { success: false, error: { code: 'USER_ID_REQUIRED', message: '缺少用户标识符' } } };
|
|
1622
|
+
}
|
|
1623
|
+
if (!isValidUUIDv4(userId)) {
|
|
1624
|
+
return { status: 400, body: { success: false, error: { code: 'INVALID_USER_ID_FORMAT', message: 'X-User-Id 必须是 UUID v4 格式' } } };
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
const deleted = await db.deleteTaskByUuid(taskUuid, userId);
|
|
1628
|
+
|
|
1629
|
+
if (!deleted) {
|
|
1630
|
+
return {
|
|
1631
|
+
status: 404,
|
|
1632
|
+
body: { success: false, error: { code: 'TASK_NOT_FOUND', message: '指定的任务不存在或已被删除' } }
|
|
1633
|
+
};
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
return {
|
|
1637
|
+
status: 200,
|
|
1638
|
+
body: {
|
|
1639
|
+
success: true,
|
|
1640
|
+
data: { uuid: taskUuid, message: '任务已成功取消', deletedAt: new Date().toISOString() }
|
|
1641
|
+
}
|
|
1642
|
+
};
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
return { DELETE };
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
/**
|
|
1649
|
+
* Handler: messages
|
|
1650
|
+
* ReiStandard SDK v2.0.1
|
|
1651
|
+
*
|
|
1652
|
+
* @param {Object} ctx - Server context.
|
|
1653
|
+
* @returns {{ GET: function }}
|
|
1654
|
+
*/
|
|
1655
|
+
|
|
1656
|
+
|
|
1657
|
+
function createMessagesHandler(ctx) {
|
|
1658
|
+
async function GET(url, headers) {
|
|
1659
|
+
const tenantResult = await ctx.tenantManager.resolveTenant(headers, { url });
|
|
1660
|
+
if (!tenantResult.ok) {
|
|
1661
|
+
return tenantResult.error;
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
const tenantCtx = tenantResult.context;
|
|
1665
|
+
const db = tenantCtx.db;
|
|
1666
|
+
const masterKey = tenantCtx.masterKey;
|
|
1667
|
+
const userId = getHeader(headers, 'x-user-id');
|
|
1668
|
+
|
|
1669
|
+
if (!userId) {
|
|
1670
|
+
return {
|
|
1671
|
+
status: 400,
|
|
1672
|
+
body: { success: false, error: { code: 'USER_ID_REQUIRED', message: '必须提供 X-User-Id 请求头' } }
|
|
1673
|
+
};
|
|
1674
|
+
}
|
|
1675
|
+
if (!isValidUUIDv4(userId)) {
|
|
1676
|
+
return {
|
|
1677
|
+
status: 400,
|
|
1678
|
+
body: { success: false, error: { code: 'INVALID_USER_ID_FORMAT', message: 'X-User-Id 必须是 UUID v4 格式' } }
|
|
1679
|
+
};
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
const u = new URL(url, 'https://dummy');
|
|
1683
|
+
const status = u.searchParams.get('status') || 'all';
|
|
1684
|
+
const limit = Math.min(parseInt(u.searchParams.get('limit') || '20', 10), 100);
|
|
1685
|
+
const offset = parseInt(u.searchParams.get('offset') || '0', 10);
|
|
1686
|
+
|
|
1687
|
+
if (isNaN(limit) || limit < 1) {
|
|
1688
|
+
return { status: 400, body: { success: false, error: { code: 'INVALID_PARAMETERS', message: 'limit 参数无效,必须为正整数' } } };
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
if (isNaN(offset) || offset < 0) {
|
|
1692
|
+
return { status: 400, body: { success: false, error: { code: 'INVALID_PARAMETERS', message: 'offset 参数无效,必须为非负整数' } } };
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
const { tasks, total } = await db.listTasks(userId, { status, limit, offset });
|
|
1696
|
+
|
|
1697
|
+
const userKey = deriveUserEncryptionKey(userId, masterKey);
|
|
1698
|
+
|
|
1699
|
+
const decryptedTasks = tasks.map(task => {
|
|
1700
|
+
const decrypted = JSON.parse(decryptFromStorage(task.encrypted_payload, userKey));
|
|
1701
|
+
return {
|
|
1702
|
+
id: task.id,
|
|
1703
|
+
uuid: task.uuid,
|
|
1704
|
+
contactName: decrypted.contactName,
|
|
1705
|
+
messageType: task.message_type,
|
|
1706
|
+
messageSubtype: decrypted.messageSubtype,
|
|
1707
|
+
nextSendAt: task.next_send_at,
|
|
1708
|
+
recurrenceType: decrypted.recurrenceType,
|
|
1709
|
+
status: task.status,
|
|
1710
|
+
retryCount: task.retry_count,
|
|
1711
|
+
createdAt: task.created_at,
|
|
1712
|
+
updatedAt: task.updated_at
|
|
1713
|
+
};
|
|
1714
|
+
});
|
|
1715
|
+
|
|
1716
|
+
const responsePayload = {
|
|
1717
|
+
tasks: decryptedTasks,
|
|
1718
|
+
pagination: { total, limit, offset, hasMore: offset + limit < total }
|
|
1719
|
+
};
|
|
1720
|
+
const encryptedResponse = encryptPayload(responsePayload, userKey);
|
|
1721
|
+
|
|
1722
|
+
return {
|
|
1723
|
+
status: 200,
|
|
1724
|
+
body: {
|
|
1725
|
+
success: true,
|
|
1726
|
+
encrypted: true,
|
|
1727
|
+
version: 1,
|
|
1728
|
+
data: encryptedResponse
|
|
1729
|
+
}
|
|
1730
|
+
};
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
return { GET };
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
/**
|
|
1737
|
+
* SQLite (Cloudflare D1) dialect schema for scheduled_messages.
|
|
1738
|
+
*
|
|
1739
|
+
* Differences from the Postgres schema (adapters/schema.js):
|
|
1740
|
+
* - id: INTEGER PRIMARY KEY AUTOINCREMENT (vs SERIAL)
|
|
1741
|
+
* - timestamps stored as TEXT ISO8601 UTC (vs TIMESTAMP WITH TIME ZONE)
|
|
1742
|
+
* - no NOW()/DEFAULT; the adapter always writes timestamps explicitly
|
|
1743
|
+
* - retry_count is NOT NULL here (Postgres omits NOT NULL); every write path
|
|
1744
|
+
* sets it explicitly, so the tighter constraint just documents that intent
|
|
1745
|
+
* Partial indexes and CHECK constraints are native to SQLite, so they carry over.
|
|
1746
|
+
* Index entries mirror the Postgres INDEXES shape ({ name, sql, description,
|
|
1747
|
+
* critical }) so both adapters' initSchema() return the same index metadata.
|
|
1748
|
+
*/
|
|
1749
|
+
|
|
1750
|
+
const SQLITE_TABLE_SQL = `
|
|
1751
|
+
CREATE TABLE IF NOT EXISTS scheduled_messages (
|
|
1752
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1753
|
+
user_id TEXT NOT NULL,
|
|
1754
|
+
uuid TEXT,
|
|
1755
|
+
encrypted_payload TEXT NOT NULL,
|
|
1756
|
+
message_type TEXT NOT NULL CHECK (message_type IN ('fixed', 'prompted', 'auto', 'instant')),
|
|
1757
|
+
next_send_at TEXT NOT NULL,
|
|
1758
|
+
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'failed')),
|
|
1759
|
+
retry_count INTEGER NOT NULL DEFAULT 0,
|
|
1760
|
+
created_at TEXT NOT NULL,
|
|
1761
|
+
updated_at TEXT NOT NULL
|
|
1762
|
+
)
|
|
1763
|
+
`;
|
|
1764
|
+
|
|
1765
|
+
const SQLITE_INDEXES = [
|
|
1766
|
+
{
|
|
1767
|
+
name: 'idx_pending_tasks_optimized',
|
|
1768
|
+
sql: `CREATE INDEX IF NOT EXISTS idx_pending_tasks_optimized
|
|
1769
|
+
ON scheduled_messages (status, next_send_at, id, retry_count)
|
|
1770
|
+
WHERE status = 'pending'`,
|
|
1771
|
+
description: 'Main query index (Cron Job finds pending tasks)',
|
|
1772
|
+
critical: false
|
|
1773
|
+
},
|
|
1774
|
+
{
|
|
1775
|
+
name: 'idx_cleanup_completed',
|
|
1776
|
+
sql: `CREATE INDEX IF NOT EXISTS idx_cleanup_completed
|
|
1777
|
+
ON scheduled_messages (status, updated_at)
|
|
1778
|
+
WHERE status IN ('sent', 'failed')`,
|
|
1779
|
+
description: 'Cleanup query index',
|
|
1780
|
+
critical: false
|
|
1781
|
+
},
|
|
1782
|
+
{
|
|
1783
|
+
name: 'idx_failed_retry',
|
|
1784
|
+
sql: `CREATE INDEX IF NOT EXISTS idx_failed_retry
|
|
1785
|
+
ON scheduled_messages (status, retry_count, next_send_at)
|
|
1786
|
+
WHERE status = 'failed' AND retry_count < 3`,
|
|
1787
|
+
description: 'Failed retry index',
|
|
1788
|
+
critical: false
|
|
1789
|
+
},
|
|
1790
|
+
{
|
|
1791
|
+
name: 'idx_user_id',
|
|
1792
|
+
sql: `CREATE INDEX IF NOT EXISTS idx_user_id
|
|
1793
|
+
ON scheduled_messages (user_id)`,
|
|
1794
|
+
description: 'User task query index',
|
|
1795
|
+
critical: false
|
|
1796
|
+
},
|
|
1797
|
+
{
|
|
1798
|
+
name: 'uidx_uuid',
|
|
1799
|
+
sql: `CREATE UNIQUE INDEX IF NOT EXISTS uidx_uuid
|
|
1800
|
+
ON scheduled_messages (uuid)
|
|
1801
|
+
WHERE uuid IS NOT NULL`,
|
|
1802
|
+
description: 'UUID uniqueness guard',
|
|
1803
|
+
critical: true
|
|
1804
|
+
}
|
|
1805
|
+
];
|
|
1806
|
+
|
|
1807
|
+
/**
|
|
1808
|
+
* Cloudflare D1 (SQLite) Database Adapter.
|
|
1809
|
+
*
|
|
1810
|
+
* @implements {import('./interface.js').DbAdapter}
|
|
1811
|
+
*
|
|
1812
|
+
* Timestamps are stored as ISO8601 UTC TEXT. Every timestamp is normalized
|
|
1813
|
+
* with new Date(v).toISOString() before store/compare so lexical ordering
|
|
1814
|
+
* equals chronological ordering (mixed offsets like +08:00 vs Z are unified).
|
|
1815
|
+
*/
|
|
1816
|
+
|
|
1817
|
+
|
|
1818
|
+
// Update methods build a dynamic SET clause from object keys. Callers pass only
|
|
1819
|
+
// hardcoded column names today, but enforcing a whitelist keeps a future caller
|
|
1820
|
+
// from ever turning a caller-supplied key into interpolated SQL.
|
|
1821
|
+
const UPDATABLE_COLUMNS = new Set([
|
|
1822
|
+
'user_id', 'uuid', 'encrypted_payload', 'message_type',
|
|
1823
|
+
'next_send_at', 'status', 'retry_count', 'created_at', 'updated_at'
|
|
1824
|
+
]);
|
|
1825
|
+
|
|
1826
|
+
class D1Adapter {
|
|
1827
|
+
/** @param {{ prepare: (sql: string) => any }} db - Cloudflare D1 binding */
|
|
1828
|
+
constructor(db) {
|
|
1829
|
+
/** @private */
|
|
1830
|
+
this._db = db;
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
/** @private */
|
|
1834
|
+
_now() {
|
|
1835
|
+
return new Date().toISOString();
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
/** @private */
|
|
1839
|
+
_iso(value) {
|
|
1840
|
+
const d = new Date(value);
|
|
1841
|
+
if (Number.isNaN(d.getTime())) {
|
|
1842
|
+
throw new Error(`[amsg-server D1] invalid timestamp: ${value}`);
|
|
1843
|
+
}
|
|
1844
|
+
return d.toISOString();
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
async initSchema() {
|
|
1848
|
+
await this._db.prepare(SQLITE_TABLE_SQL).run();
|
|
1849
|
+
|
|
1850
|
+
const indexResults = [];
|
|
1851
|
+
for (const index of SQLITE_INDEXES) {
|
|
1852
|
+
try {
|
|
1853
|
+
await this._db.prepare(index.sql).run();
|
|
1854
|
+
indexResults.push({ name: index.name, status: 'success', description: index.description, critical: !!index.critical });
|
|
1855
|
+
} catch (error) {
|
|
1856
|
+
indexResults.push({ name: index.name, status: 'failed', description: index.description, critical: !!index.critical, error: error.message });
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
const criticalFailures = indexResults.filter((i) => i.critical && i.status === 'failed');
|
|
1861
|
+
if (criticalFailures.length > 0) {
|
|
1862
|
+
const names = criticalFailures.map((i) => i.name).join(', ');
|
|
1863
|
+
throw new Error(
|
|
1864
|
+
`Critical index creation failed (${names}). ` +
|
|
1865
|
+
'Please remove duplicate UUID rows and run initSchema again.'
|
|
1866
|
+
);
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
return {
|
|
1870
|
+
columnsCreated: 10,
|
|
1871
|
+
indexesCreated: indexResults.filter((r) => r.status === 'success').length,
|
|
1872
|
+
indexesFailed: indexResults.filter((r) => r.status === 'failed').length,
|
|
1873
|
+
columns: [],
|
|
1874
|
+
indexes: indexResults
|
|
1875
|
+
};
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
async dropSchema() {
|
|
1879
|
+
await this._db.prepare('DROP TABLE IF EXISTS scheduled_messages').run();
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
async createTask(params) {
|
|
1883
|
+
const now = this._now();
|
|
1884
|
+
const nextSendAt = this._iso(params.next_send_at);
|
|
1885
|
+
const res = await this._db.prepare(
|
|
1886
|
+
`INSERT INTO scheduled_messages
|
|
1887
|
+
(user_id, uuid, encrypted_payload, next_send_at, message_type, status, retry_count, created_at, updated_at)
|
|
1888
|
+
VALUES (?, ?, ?, ?, ?, 'pending', 0, ?, ?)`
|
|
1889
|
+
).bind(params.user_id, params.uuid, params.encrypted_payload, nextSendAt, params.message_type, now, now).run();
|
|
1890
|
+
|
|
1891
|
+
const id = res.meta.last_row_id;
|
|
1892
|
+
return this._db.prepare(
|
|
1893
|
+
`SELECT id, uuid, next_send_at, status, created_at FROM scheduled_messages WHERE id = ?`
|
|
1894
|
+
).bind(id).first();
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
async getTaskByUuid(uuid, userId) {
|
|
1898
|
+
return this._db.prepare(
|
|
1899
|
+
`SELECT id, user_id, uuid, encrypted_payload, message_type, next_send_at, status, retry_count
|
|
1900
|
+
FROM scheduled_messages
|
|
1901
|
+
WHERE uuid = ? AND user_id = ? AND status = 'pending'
|
|
1902
|
+
LIMIT 1`
|
|
1903
|
+
).bind(uuid, userId).first();
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
async getTaskByUuidOnly(uuid) {
|
|
1907
|
+
return this._db.prepare(
|
|
1908
|
+
`SELECT id, user_id, uuid, encrypted_payload, message_type, next_send_at, status, retry_count
|
|
1909
|
+
FROM scheduled_messages
|
|
1910
|
+
WHERE uuid = ? AND status = 'pending'
|
|
1911
|
+
LIMIT 1`
|
|
1912
|
+
).bind(uuid).first();
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
async updateTaskById(taskId, updates) {
|
|
1916
|
+
const sets = [];
|
|
1917
|
+
const values = [];
|
|
1918
|
+
for (const [key, value] of Object.entries(updates)) {
|
|
1919
|
+
if (!UPDATABLE_COLUMNS.has(key)) {
|
|
1920
|
+
throw new Error(`[amsg-server D1] rejected unknown update column: ${key}`);
|
|
1921
|
+
}
|
|
1922
|
+
sets.push(`${key} = ?`);
|
|
1923
|
+
values.push(key === 'next_send_at' ? this._iso(value) : value);
|
|
1924
|
+
}
|
|
1925
|
+
// Callers may pass updated_at explicitly (tests); otherwise stamp now.
|
|
1926
|
+
if (!Object.prototype.hasOwnProperty.call(updates, 'updated_at')) {
|
|
1927
|
+
sets.push('updated_at = ?');
|
|
1928
|
+
values.push(this._now());
|
|
1929
|
+
}
|
|
1930
|
+
values.push(taskId);
|
|
1931
|
+
|
|
1932
|
+
await this._db.prepare(
|
|
1933
|
+
`UPDATE scheduled_messages SET ${sets.join(', ')} WHERE id = ?`
|
|
1934
|
+
).bind(...values).run();
|
|
1935
|
+
|
|
1936
|
+
return this._db.prepare('SELECT * FROM scheduled_messages WHERE id = ?').bind(taskId).first();
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
async updateTaskByUuid(uuid, userId, encryptedPayload, extraFields) {
|
|
1940
|
+
const now = this._now();
|
|
1941
|
+
const sets = ['encrypted_payload = ?', 'updated_at = ?'];
|
|
1942
|
+
const values = [encryptedPayload, now];
|
|
1943
|
+
if (extraFields) {
|
|
1944
|
+
for (const [key, value] of Object.entries(extraFields)) {
|
|
1945
|
+
if (!UPDATABLE_COLUMNS.has(key)) {
|
|
1946
|
+
throw new Error(`[amsg-server D1] rejected unknown update column: ${key}`);
|
|
1947
|
+
}
|
|
1948
|
+
sets.push(`${key} = ?`);
|
|
1949
|
+
values.push(key === 'next_send_at' ? this._iso(value) : value);
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
values.push(uuid, userId);
|
|
1953
|
+
|
|
1954
|
+
const res = await this._db.prepare(
|
|
1955
|
+
`UPDATE scheduled_messages SET ${sets.join(', ')}
|
|
1956
|
+
WHERE uuid = ? AND user_id = ? AND status = 'pending'`
|
|
1957
|
+
).bind(...values).run();
|
|
1958
|
+
|
|
1959
|
+
if (!res.meta.changes) return null;
|
|
1960
|
+
return { uuid, updated_at: now };
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
async deleteTaskById(taskId) {
|
|
1964
|
+
const res = await this._db.prepare('DELETE FROM scheduled_messages WHERE id = ?').bind(taskId).run();
|
|
1965
|
+
return res.meta.changes > 0;
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
async deleteTaskByUuid(uuid, userId) {
|
|
1969
|
+
const res = await this._db.prepare(
|
|
1970
|
+
'DELETE FROM scheduled_messages WHERE uuid = ? AND user_id = ?'
|
|
1971
|
+
).bind(uuid, userId).run();
|
|
1972
|
+
return res.meta.changes > 0;
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
async getPendingTasks(limit = 50) {
|
|
1976
|
+
const res = await this._db.prepare(
|
|
1977
|
+
`SELECT id, user_id, uuid, encrypted_payload, message_type, next_send_at, status, retry_count
|
|
1978
|
+
FROM scheduled_messages
|
|
1979
|
+
WHERE status = 'pending' AND next_send_at <= ?
|
|
1980
|
+
ORDER BY next_send_at ASC
|
|
1981
|
+
LIMIT ?`
|
|
1982
|
+
).bind(this._now(), limit).all();
|
|
1983
|
+
return res.results || [];
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
async listTasks(userId, opts = {}) {
|
|
1987
|
+
const { status = 'all', limit = 20, offset = 0 } = opts;
|
|
1988
|
+
const conditions = ['user_id = ?'];
|
|
1989
|
+
const params = [userId];
|
|
1990
|
+
if (status !== 'all') {
|
|
1991
|
+
conditions.push('status = ?');
|
|
1992
|
+
params.push(status);
|
|
1993
|
+
}
|
|
1994
|
+
const where = conditions.join(' AND ');
|
|
1995
|
+
|
|
1996
|
+
const countRow = await this._db.prepare(
|
|
1997
|
+
`SELECT COUNT(*) as count FROM scheduled_messages WHERE ${where}`
|
|
1998
|
+
).bind(...params).first();
|
|
1999
|
+
const total = Number(countRow.count) || 0;
|
|
2000
|
+
|
|
2001
|
+
const res = await this._db.prepare(
|
|
2002
|
+
`SELECT id, user_id, uuid, encrypted_payload, message_type, next_send_at, status, retry_count, created_at, updated_at
|
|
2003
|
+
FROM scheduled_messages
|
|
2004
|
+
WHERE ${where}
|
|
2005
|
+
ORDER BY next_send_at ASC
|
|
2006
|
+
LIMIT ? OFFSET ?`
|
|
2007
|
+
).bind(...params, limit, offset).all();
|
|
2008
|
+
|
|
2009
|
+
return { tasks: res.results || [], total };
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
async cleanupOldTasks(days = 7) {
|
|
2013
|
+
const safeDays = Math.max(1, Math.floor(Number(days)));
|
|
2014
|
+
const cutoff = new Date(Date.now() - safeDays * 24 * 60 * 60 * 1000).toISOString();
|
|
2015
|
+
const res = await this._db.prepare(
|
|
2016
|
+
`DELETE FROM scheduled_messages
|
|
2017
|
+
WHERE status IN ('sent', 'failed') AND updated_at < ?`
|
|
2018
|
+
).bind(cutoff).run();
|
|
2019
|
+
return res.meta.changes || 0;
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
async getTaskStatus(uuid, userId) {
|
|
2023
|
+
const row = await this._db.prepare(
|
|
2024
|
+
'SELECT status FROM scheduled_messages WHERE uuid = ? AND user_id = ? LIMIT 1'
|
|
2025
|
+
).bind(uuid, userId).first();
|
|
2026
|
+
return row ? row.status : null;
|
|
2027
|
+
}
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
/**
|
|
2031
|
+
* Create a D1 adapter from a Cloudflare D1 binding (env.DB).
|
|
2032
|
+
* @param {{ prepare: (sql: string) => any }} db
|
|
2033
|
+
* @returns {import('./interface.js').DbAdapter}
|
|
2034
|
+
*/
|
|
2035
|
+
function createD1Adapter(db) {
|
|
2036
|
+
if (!db || typeof db.prepare !== 'function') {
|
|
2037
|
+
throw new Error('[amsg-server] createD1Adapter requires a D1 database binding (env.DB)');
|
|
2038
|
+
}
|
|
2039
|
+
return new D1Adapter(db);
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
/**
|
|
2043
|
+
* Portable constant-time string comparison.
|
|
2044
|
+
*
|
|
2045
|
+
* Runs identically on Node (tests) and Cloudflare Workers (prod). We avoid
|
|
2046
|
+
* both node:crypto's timingSafeEqual (undefined on Workers historically) and
|
|
2047
|
+
* crypto.subtle.timingSafeEqual (absent on Node). Instead we HMAC-SHA256 both
|
|
2048
|
+
* inputs under one fresh random key, then compare the two digests. Because the
|
|
2049
|
+
* key is random per call, an attacker can't precompute or replay a stable
|
|
2050
|
+
* timing oracle; digests are a fixed 32 bytes, so the XOR-accumulate compare
|
|
2051
|
+
* runs the same number of steps regardless of input length, with no early-out.
|
|
2052
|
+
*
|
|
2053
|
+
* globalThis.crypto (Web Crypto) is available on Node >= 20 and on Workers.
|
|
2054
|
+
*/
|
|
2055
|
+
async function constantTimeEqual(a, b) {
|
|
2056
|
+
const enc = new TextEncoder();
|
|
2057
|
+
const keyBytes = globalThis.crypto.getRandomValues(new Uint8Array(32));
|
|
2058
|
+
const key = await globalThis.crypto.subtle.importKey(
|
|
2059
|
+
'raw',
|
|
2060
|
+
keyBytes,
|
|
2061
|
+
{ name: 'HMAC', hash: 'SHA-256' },
|
|
2062
|
+
false,
|
|
2063
|
+
['sign']
|
|
2064
|
+
);
|
|
2065
|
+
const da = new Uint8Array(await globalThis.crypto.subtle.sign('HMAC', key, enc.encode(String(a))));
|
|
2066
|
+
const db = new Uint8Array(await globalThis.crypto.subtle.sign('HMAC', key, enc.encode(String(b))));
|
|
2067
|
+
|
|
2068
|
+
let diff = 0;
|
|
2069
|
+
for (let i = 0; i < da.length; i++) {
|
|
2070
|
+
diff |= da[i] ^ db[i];
|
|
2071
|
+
}
|
|
2072
|
+
return diff === 0;
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
/**
|
|
2076
|
+
* Single-user tenant context manager.
|
|
2077
|
+
*
|
|
2078
|
+
* Interface-compatible with createTenantContextManager (resolveTenant /
|
|
2079
|
+
* initializeTenant), so the existing business handlers reuse it unchanged.
|
|
2080
|
+
* No blob registry, no tenant token — db and masterKey come from the caller
|
|
2081
|
+
* (the Worker resolves them from env + D1 binding per request).
|
|
2082
|
+
*/
|
|
2083
|
+
|
|
2084
|
+
|
|
2085
|
+
function createSingleUserContextManager({ db, masterKey, serverToken } = {}) {
|
|
2086
|
+
if (!db) throw new Error('[amsg-server single-user] db (adapter) is required');
|
|
2087
|
+
if (!masterKey) throw new Error('[amsg-server single-user] masterKey is required');
|
|
2088
|
+
const token = String(serverToken || '').trim();
|
|
2089
|
+
|
|
2090
|
+
async function isAuthorized(headers) {
|
|
2091
|
+
if (!token) return true; // open when no shared secret configured
|
|
2092
|
+
const provided = getHeader(headers, 'x-client-token');
|
|
2093
|
+
if (!provided) return false;
|
|
2094
|
+
return constantTimeEqual(provided, token);
|
|
2095
|
+
}
|
|
2096
|
+
|
|
2097
|
+
async function resolveTenant(headers) {
|
|
2098
|
+
if (!(await isAuthorized(headers))) {
|
|
2099
|
+
return {
|
|
2100
|
+
ok: false,
|
|
2101
|
+
error: {
|
|
2102
|
+
status: 401,
|
|
2103
|
+
body: { success: false, error: { code: 'INVALID_CLIENT_TOKEN', message: '共享密钥无效或缺失' } }
|
|
2104
|
+
}
|
|
2105
|
+
};
|
|
2106
|
+
}
|
|
2107
|
+
return {
|
|
2108
|
+
ok: true,
|
|
2109
|
+
context: { tenantId: 'single', tokenType: 'tenant', db, masterKey }
|
|
2110
|
+
};
|
|
2111
|
+
}
|
|
2112
|
+
|
|
2113
|
+
async function initializeTenant() {
|
|
2114
|
+
const schema = await db.initSchema();
|
|
2115
|
+
return { tenantId: 'single', schema };
|
|
2116
|
+
}
|
|
2117
|
+
|
|
2118
|
+
return { resolveTenant, initializeTenant };
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
/**
|
|
2122
|
+
* Handler: single-user-init
|
|
2123
|
+
*
|
|
2124
|
+
* Idempotent "just create the tables" endpoint for single-user deployments
|
|
2125
|
+
* (the degenerate form of init-tenant). Reuses resolveTenant purely to enforce
|
|
2126
|
+
* the optional shared secret, then runs initSchema. Issues no token.
|
|
2127
|
+
*
|
|
2128
|
+
* @param {Object} ctx - Single-user server context (ctx.tenantManager).
|
|
2129
|
+
* @returns {{ POST: function }}
|
|
2130
|
+
*/
|
|
2131
|
+
function createSingleUserInitHandler(ctx) {
|
|
2132
|
+
async function POST(headers /* , body */) {
|
|
2133
|
+
const auth = await ctx.tenantManager.resolveTenant(headers || {});
|
|
2134
|
+
if (!auth.ok) {
|
|
2135
|
+
return auth.error;
|
|
2136
|
+
}
|
|
2137
|
+
try {
|
|
2138
|
+
const result = await ctx.tenantManager.initializeTenant();
|
|
2139
|
+
return {
|
|
2140
|
+
status: 200,
|
|
2141
|
+
body: { success: true, data: { tenantId: result.tenantId, schema: result.schema } }
|
|
2142
|
+
};
|
|
2143
|
+
} catch (error) {
|
|
2144
|
+
return {
|
|
2145
|
+
status: 500,
|
|
2146
|
+
body: { success: false, error: { code: 'INIT_FAILED', message: error.message } }
|
|
2147
|
+
};
|
|
2148
|
+
}
|
|
2149
|
+
}
|
|
2150
|
+
|
|
2151
|
+
return { POST };
|
|
2152
|
+
}
|
|
2153
|
+
|
|
2154
|
+
/**
|
|
2155
|
+
* Handler: vapid-public-key
|
|
2156
|
+
*
|
|
2157
|
+
* Exposes this worker's own VAPID public key so a browser frontend can build a
|
|
2158
|
+
* Web Push subscription (`applicationServerKey`) at runtime. Each self-hosted
|
|
2159
|
+
* worker owns its keypair, so the key can't be baked into the frontend — it
|
|
2160
|
+
* pulls it from here.
|
|
2161
|
+
*
|
|
2162
|
+
* Auth funnels through the same resolveTenant as every other endpoint, so with
|
|
2163
|
+
* a serverToken configured this route requires `X-Client-Token` too (the
|
|
2164
|
+
* all-or-nothing contract). The public key itself is not a secret; gating it
|
|
2165
|
+
* just keeps every endpoint consistent.
|
|
2166
|
+
*
|
|
2167
|
+
* @param {Object} ctx - Server context ({ vapid, tenantManager, ... }).
|
|
2168
|
+
* @returns {{ GET: function }}
|
|
2169
|
+
*/
|
|
2170
|
+
|
|
2171
|
+
function createVapidPublicKeyHandler(ctx) {
|
|
2172
|
+
async function GET(url, headers) {
|
|
2173
|
+
const effectiveHeaders = headers || url || {};
|
|
2174
|
+
const tenantResult = await ctx.tenantManager.resolveTenant(effectiveHeaders);
|
|
2175
|
+
if (!tenantResult.ok) {
|
|
2176
|
+
return tenantResult.error;
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2179
|
+
const publicKey = ctx.vapid && ctx.vapid.publicKey;
|
|
2180
|
+
if (!publicKey) {
|
|
2181
|
+
return {
|
|
2182
|
+
status: 503,
|
|
2183
|
+
body: {
|
|
2184
|
+
success: false,
|
|
2185
|
+
error: {
|
|
2186
|
+
code: 'VAPID_NOT_CONFIGURED',
|
|
2187
|
+
message: 'VAPID 公钥未配置:请为本 Worker 设置 VAPID_PUBLIC_KEY'
|
|
2188
|
+
}
|
|
2189
|
+
}
|
|
2190
|
+
};
|
|
2191
|
+
}
|
|
2192
|
+
|
|
2193
|
+
return {
|
|
2194
|
+
status: 200,
|
|
2195
|
+
body: { success: true, publicKey }
|
|
2196
|
+
};
|
|
2197
|
+
}
|
|
2198
|
+
|
|
2199
|
+
return { GET };
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2202
|
+
/**
|
|
2203
|
+
* Single-user ReiStandard server assembly.
|
|
2204
|
+
*
|
|
2205
|
+
* Same shape as createReiServer ({ handlers }), but wired for a single user:
|
|
2206
|
+
* - tenant context comes from createSingleUserContextManager (db + masterKey
|
|
2207
|
+
* supplied by the caller; no blob registry, no tenant token)
|
|
2208
|
+
* - only the 5 business handlers + an idempotent init route are exposed
|
|
2209
|
+
* - send-notifications is NOT exposed over HTTP (cron runs via CF scheduled())
|
|
2210
|
+
*
|
|
2211
|
+
* @param {Object} config
|
|
2212
|
+
* @param {import('./adapters/interface.js').DbAdapter} config.db
|
|
2213
|
+
* @param {string} config.masterKey
|
|
2214
|
+
* @param {string} [config.serverToken] - optional shared secret (X-Client-Token)
|
|
2215
|
+
* @param {{ email?: string, publicKey?: string, privateKey?: string }} [config.vapid]
|
|
2216
|
+
* @param {{ sendNotification: function }} [config.webpush] - web-push-compatible sender
|
|
2217
|
+
* @returns {{ handlers: Object, ctx: Object }}
|
|
2218
|
+
*/
|
|
2219
|
+
|
|
2220
|
+
|
|
2221
|
+
function createSingleUserServer(config) {
|
|
2222
|
+
if (!config || !config.db) throw new Error('[amsg-server single-user] config.db is required');
|
|
2223
|
+
if (!config.masterKey) throw new Error('[amsg-server single-user] config.masterKey is required');
|
|
2224
|
+
|
|
2225
|
+
const vapid = config.vapid || {};
|
|
2226
|
+
const tenantManager = createSingleUserContextManager({
|
|
2227
|
+
db: config.db,
|
|
2228
|
+
masterKey: config.masterKey,
|
|
2229
|
+
serverToken: config.serverToken
|
|
2230
|
+
});
|
|
2231
|
+
|
|
2232
|
+
const ctx = {
|
|
2233
|
+
vapid: {
|
|
2234
|
+
email: vapid.email || '',
|
|
2235
|
+
publicKey: vapid.publicKey || '',
|
|
2236
|
+
privateKey: vapid.privateKey || ''
|
|
2237
|
+
},
|
|
2238
|
+
webpush: config.webpush || null,
|
|
2239
|
+
tenantManager
|
|
2240
|
+
};
|
|
2241
|
+
|
|
2242
|
+
return {
|
|
2243
|
+
ctx,
|
|
2244
|
+
handlers: {
|
|
2245
|
+
init: createSingleUserInitHandler(ctx),
|
|
2246
|
+
getUserKey: createGetUserKeyHandler(ctx),
|
|
2247
|
+
scheduleMessage: createScheduleMessageHandler(ctx),
|
|
2248
|
+
updateMessage: createUpdateMessageHandler(ctx),
|
|
2249
|
+
cancelMessage: createCancelMessageHandler(ctx),
|
|
2250
|
+
messages: createMessagesHandler(ctx),
|
|
2251
|
+
vapidPublicKey: createVapidPublicKeyHandler(ctx)
|
|
2252
|
+
}
|
|
2253
|
+
};
|
|
2254
|
+
}
|
|
2255
|
+
|
|
2256
|
+
/**
|
|
2257
|
+
* Runtime-neutral crypto + encoding helpers.
|
|
2258
|
+
*
|
|
2259
|
+
* Everything in here is implemented on top of WHATWG-standard primitives
|
|
2260
|
+
* (`globalThis.crypto.subtle`, `TextEncoder`, `Uint8Array`) so the package
|
|
2261
|
+
* runs on Cloudflare Workers, Vercel Edge, Netlify Edge, Deno, Bun, and
|
|
2262
|
+
* Node ≥ 19 with zero polyfills. The Node adapter polyfills
|
|
2263
|
+
* `globalThis.crypto` for Node 18 deployments before any of these are
|
|
2264
|
+
* touched.
|
|
2265
|
+
*/
|
|
2266
|
+
|
|
2267
|
+
const TEXT_ENCODER = new TextEncoder();
|
|
2268
|
+
new TextDecoder('utf-8', { fatal: false });
|
|
2269
|
+
|
|
2270
|
+
/** UTF-8 encode a string into a Uint8Array. */
|
|
2271
|
+
function utf8(str) {
|
|
2272
|
+
return TEXT_ENCODER.encode(String(str));
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
|
|
2276
|
+
/** Encode bytes as base64url (no padding). */
|
|
2277
|
+
function bytesToBase64Url(buf) {
|
|
2278
|
+
const bytes = toUint8(buf);
|
|
2279
|
+
let bin = '';
|
|
2280
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
2281
|
+
bin += String.fromCharCode(bytes[i]);
|
|
2282
|
+
}
|
|
2283
|
+
// btoa is available in all Web Crypto runtimes (browsers, Workers, Node 16+).
|
|
2284
|
+
const b64 = (typeof btoa === 'function')
|
|
2285
|
+
? btoa(bin)
|
|
2286
|
+
: Buffer.from(bin, 'binary').toString('base64');
|
|
2287
|
+
return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
|
|
2291
|
+
/** Encode a JSON-serializable value as base64url (UTF-8 JSON). */
|
|
2292
|
+
function jsonToBase64Url(value) {
|
|
2293
|
+
return bytesToBase64Url(utf8(JSON.stringify(value)));
|
|
2294
|
+
}
|
|
2295
|
+
|
|
2296
|
+
/** Cryptographically random bytes. */
|
|
2297
|
+
function randomBytes(n) {
|
|
2298
|
+
const out = new Uint8Array(n);
|
|
2299
|
+
globalThis.crypto.getRandomValues(out);
|
|
2300
|
+
return out;
|
|
2301
|
+
}
|
|
2302
|
+
|
|
2303
|
+
/**
|
|
2304
|
+
* Web Push — RFC 8030 (transport) + RFC 8291 (aes128gcm payload encryption)
|
|
2305
|
+
* + RFC 8292 (VAPID).
|
|
2306
|
+
*
|
|
2307
|
+
* Pure-WebCrypto implementation. Zero runtime dependencies. Runs natively on
|
|
2308
|
+
* Cloudflare Workers, Vercel Edge, Netlify Edge, Deno, Bun, and Node ≥ 19.
|
|
2309
|
+
* Node 18 deployments must go through the `adapters/node` entry which
|
|
2310
|
+
* polyfills `globalThis.crypto` from `node:crypto.webcrypto`.
|
|
2311
|
+
*
|
|
2312
|
+
* The wire format produced here is byte-identical to the `web-push` npm
|
|
2313
|
+
* package and to the Push API in any modern browser, so amsg-sw and any
|
|
2314
|
+
* existing Web Push subscriptions keep working untouched.
|
|
2315
|
+
*/
|
|
2316
|
+
|
|
2317
|
+
|
|
2318
|
+
// RFC 8291 fixed labels (each followed by a NUL byte per HKDF "info" framing).
|
|
2319
|
+
const KEY_INFO_PREFIX = utf8('WebPush: info\0');
|
|
2320
|
+
const CEK_INFO = utf8('Content-Encoding: aes128gcm\0');
|
|
2321
|
+
const NONCE_INFO = utf8('Content-Encoding: nonce\0');
|
|
2322
|
+
|
|
2323
|
+
const VAPID_DEFAULT_TTL = 60; // seconds — short, matches single-shot instant.
|
|
2324
|
+
const VAPID_TOKEN_LIFETIME = 12 * 3600; // 12h — comfortably under the 24h RFC 8292 cap.
|
|
2325
|
+
const RECORD_SIZE = 4096; // arbitrary — must be ≥ ciphertext length.
|
|
2326
|
+
|
|
2327
|
+
/**
|
|
2328
|
+
* Send a single Web Push notification.
|
|
2329
|
+
*
|
|
2330
|
+
* @param {Object} args
|
|
2331
|
+
* @param {Object} args.subscription - Standard PushSubscription JSON.
|
|
2332
|
+
* @param {string} args.subscription.endpoint
|
|
2333
|
+
* @param {Object} args.subscription.keys
|
|
2334
|
+
* @param {string} args.subscription.keys.p256dh - base64url, 65 B uncompressed P-256 point.
|
|
2335
|
+
* @param {string} args.subscription.keys.auth - base64url, 16 B auth secret.
|
|
2336
|
+
* @param {string} args.payload - Already-stringified JSON to deliver.
|
|
2337
|
+
* @param {Object} args.vapid
|
|
2338
|
+
* @param {string} args.vapid.email - VAPID `sub` (mailto: auto-prepended if missing).
|
|
2339
|
+
* @param {string} args.vapid.publicKey - base64url, 65 B uncompressed P-256 point.
|
|
2340
|
+
* @param {string} args.vapid.privateKey - base64url, 32 B scalar.
|
|
2341
|
+
* @param {number} [args.ttl=60] - Push service TTL header, seconds.
|
|
2342
|
+
* @param {typeof fetch} [args.fetch] - Override fetch impl (testing / proxy).
|
|
2343
|
+
* @returns {Promise<{ statusCode: number, body: string, headers: Headers }>}
|
|
2344
|
+
* @throws {Error} err.code = 'PUSH_SEND_FAILED' on push-service error.
|
|
2345
|
+
*/
|
|
2346
|
+
async function sendWebPush({ subscription, payload, vapid, ttl, fetch: fetchImpl }) {
|
|
2347
|
+
if (!subscription || typeof subscription.endpoint !== 'string') {
|
|
2348
|
+
throw new Error('sendWebPush: invalid subscription');
|
|
2349
|
+
}
|
|
2350
|
+
if (typeof payload !== 'string') {
|
|
2351
|
+
throw new Error('sendWebPush: payload must be a string');
|
|
2352
|
+
}
|
|
2353
|
+
if (!vapid || !vapid.email || !vapid.publicKey || !vapid.privateKey) {
|
|
2354
|
+
throw new Error('VAPID_CONFIG_MISSING');
|
|
2355
|
+
}
|
|
2356
|
+
|
|
2357
|
+
const subscriptionKeys = subscription.keys || {};
|
|
2358
|
+
if (typeof subscriptionKeys.p256dh !== 'string' || typeof subscriptionKeys.auth !== 'string') {
|
|
2359
|
+
throw new Error('sendWebPush: subscription.keys.p256dh and .auth are required');
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2362
|
+
const encryptedBody = await encryptPushPayload({
|
|
2363
|
+
plaintext: utf8(payload),
|
|
2364
|
+
uaPublicKey: base64UrlToBytes(subscriptionKeys.p256dh),
|
|
2365
|
+
authSecret: base64UrlToBytes(subscriptionKeys.auth),
|
|
2366
|
+
});
|
|
2367
|
+
|
|
2368
|
+
const jwt = await buildVapidJwt({
|
|
2369
|
+
audience: originOf(subscription.endpoint),
|
|
2370
|
+
subject: normalizeVapidSubject(vapid.email),
|
|
2371
|
+
publicKey: vapid.publicKey,
|
|
2372
|
+
privateKey: vapid.privateKey,
|
|
2373
|
+
});
|
|
2374
|
+
|
|
2375
|
+
const fetchFn = fetchImpl || globalThis.fetch;
|
|
2376
|
+
if (typeof fetchFn !== 'function') {
|
|
2377
|
+
throw new Error('sendWebPush: no fetch implementation available');
|
|
2378
|
+
}
|
|
2379
|
+
|
|
2380
|
+
const res = await fetchFn(subscription.endpoint, {
|
|
2381
|
+
method: 'POST',
|
|
2382
|
+
headers: {
|
|
2383
|
+
'Content-Type': 'application/octet-stream',
|
|
2384
|
+
'Content-Encoding': 'aes128gcm',
|
|
2385
|
+
'TTL': String(Number.isFinite(ttl) ? ttl : VAPID_DEFAULT_TTL),
|
|
2386
|
+
'Authorization': `vapid t=${jwt}, k=${vapid.publicKey}`,
|
|
2387
|
+
},
|
|
2388
|
+
body: encryptedBody,
|
|
2389
|
+
});
|
|
2390
|
+
|
|
2391
|
+
// Push services return 201 (RFC 8030) or 200/202 depending on implementation.
|
|
2392
|
+
if (!res.ok) {
|
|
2393
|
+
const text = await safeReadText(res);
|
|
2394
|
+
const err = new Error(
|
|
2395
|
+
`Web Push delivery failed: ${res.status} ${res.statusText || ''}${text ? ` — ${text}` : ''}`
|
|
2396
|
+
);
|
|
2397
|
+
err.code = 'PUSH_SEND_FAILED';
|
|
2398
|
+
err.statusCode = res.status;
|
|
2399
|
+
throw err;
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
return {
|
|
2403
|
+
statusCode: res.status,
|
|
2404
|
+
body: await safeReadText(res),
|
|
2405
|
+
headers: res.headers,
|
|
2406
|
+
};
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
// ─── RFC 8291: aes128gcm payload encryption ────────────────────────────
|
|
2410
|
+
|
|
2411
|
+
/**
|
|
2412
|
+
* @param {Object} args
|
|
2413
|
+
* @param {Uint8Array} args.plaintext
|
|
2414
|
+
* @param {Uint8Array} args.uaPublicKey - recipient p256dh, 65 B uncompressed.
|
|
2415
|
+
* @param {Uint8Array} args.authSecret - recipient auth, 16 B.
|
|
2416
|
+
* @returns {Promise<Uint8Array>} encryption header || ciphertext
|
|
2417
|
+
*/
|
|
2418
|
+
async function encryptPushPayload({ plaintext, uaPublicKey, authSecret }) {
|
|
2419
|
+
// 1. Ephemeral ECDH key pair (as = "application server" per RFC 8291).
|
|
2420
|
+
const asKeyPair = await globalThis.crypto.subtle.generateKey(
|
|
2421
|
+
{ name: 'ECDH', namedCurve: 'P-256' },
|
|
2422
|
+
true,
|
|
2423
|
+
['deriveBits']
|
|
2424
|
+
);
|
|
2425
|
+
const asPublicRaw = new Uint8Array(
|
|
2426
|
+
await globalThis.crypto.subtle.exportKey('raw', asKeyPair.publicKey)
|
|
2427
|
+
);
|
|
2428
|
+
|
|
2429
|
+
// 2. ECDH shared secret with recipient's p256dh.
|
|
2430
|
+
const uaPublicCryptoKey = await globalThis.crypto.subtle.importKey(
|
|
2431
|
+
'raw',
|
|
2432
|
+
uaPublicKey,
|
|
2433
|
+
{ name: 'ECDH', namedCurve: 'P-256' },
|
|
2434
|
+
false,
|
|
2435
|
+
[]
|
|
2436
|
+
);
|
|
2437
|
+
const ecdhSecret = new Uint8Array(
|
|
2438
|
+
await globalThis.crypto.subtle.deriveBits(
|
|
2439
|
+
{ name: 'ECDH', public: uaPublicCryptoKey },
|
|
2440
|
+
asKeyPair.privateKey,
|
|
2441
|
+
256
|
|
2442
|
+
)
|
|
2443
|
+
);
|
|
2444
|
+
|
|
2445
|
+
// 3. IKM = HKDF-SHA256(salt=auth_secret, ikm=ecdh_secret,
|
|
2446
|
+
// info="WebPush: info\0" || ua_public || as_public, L=32)
|
|
2447
|
+
const keyInfo = concatBytes(KEY_INFO_PREFIX, uaPublicKey, asPublicRaw);
|
|
2448
|
+
const ikm = await hkdfSha256(authSecret, ecdhSecret, keyInfo, 32);
|
|
2449
|
+
|
|
2450
|
+
// 4. encryption_salt (random, 16 B). Goes into the header so the recipient
|
|
2451
|
+
// can re-derive CEK / NONCE.
|
|
2452
|
+
const salt = randomBytes(16);
|
|
2453
|
+
|
|
2454
|
+
// 5. CEK = HKDF-SHA256(salt, ikm, "Content-Encoding: aes128gcm\0", 16)
|
|
2455
|
+
const cekBytes = await hkdfSha256(salt, ikm, CEK_INFO, 16);
|
|
2456
|
+
const cek = await globalThis.crypto.subtle.importKey(
|
|
2457
|
+
'raw',
|
|
2458
|
+
cekBytes,
|
|
2459
|
+
{ name: 'AES-GCM' },
|
|
2460
|
+
false,
|
|
2461
|
+
['encrypt']
|
|
2462
|
+
);
|
|
2463
|
+
|
|
2464
|
+
// 6. NONCE = HKDF-SHA256(salt, ikm, "Content-Encoding: nonce\0", 12)
|
|
2465
|
+
const nonce = await hkdfSha256(salt, ikm, NONCE_INFO, 12);
|
|
2466
|
+
|
|
2467
|
+
// 7. Single-record AES-128-GCM. Padding delimiter 0x02 marks the final
|
|
2468
|
+
// (and only) record per RFC 8188 §2.
|
|
2469
|
+
const padded = concatBytes(plaintext, new Uint8Array([0x02]));
|
|
2470
|
+
const ciphertext = new Uint8Array(
|
|
2471
|
+
await globalThis.crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce }, cek, padded)
|
|
2472
|
+
);
|
|
2473
|
+
|
|
2474
|
+
// 8. aes128gcm content-encoding framing (RFC 8188 §2.1):
|
|
2475
|
+
// salt(16) || rs(4 BE) || idlen(1) || keyid(idlen) || ciphertext
|
|
2476
|
+
// For Web Push, keyid is the application-server public key (65 B).
|
|
2477
|
+
const header = new Uint8Array(16 + 4 + 1 + asPublicRaw.byteLength);
|
|
2478
|
+
header.set(salt, 0);
|
|
2479
|
+
writeUint32BE(header, 16, RECORD_SIZE);
|
|
2480
|
+
header[20] = asPublicRaw.byteLength;
|
|
2481
|
+
header.set(asPublicRaw, 21);
|
|
2482
|
+
|
|
2483
|
+
return concatBytes(header, ciphertext);
|
|
2484
|
+
}
|
|
2485
|
+
|
|
2486
|
+
/**
|
|
2487
|
+
* HKDF-SHA-256 (extract-then-expand) via WebCrypto.
|
|
2488
|
+
*
|
|
2489
|
+
* @param {Uint8Array} salt
|
|
2490
|
+
* @param {Uint8Array} ikm
|
|
2491
|
+
* @param {Uint8Array} info
|
|
2492
|
+
* @param {number} length - desired output length in bytes (≤ 32 in our usage).
|
|
2493
|
+
* @returns {Promise<Uint8Array>}
|
|
2494
|
+
*/
|
|
2495
|
+
async function hkdfSha256(salt, ikm, info, length) {
|
|
2496
|
+
const baseKey = await globalThis.crypto.subtle.importKey(
|
|
2497
|
+
'raw',
|
|
2498
|
+
toUint8(ikm),
|
|
2499
|
+
{ name: 'HKDF' },
|
|
2500
|
+
false,
|
|
2501
|
+
['deriveBits']
|
|
2502
|
+
);
|
|
2503
|
+
const bits = await globalThis.crypto.subtle.deriveBits(
|
|
2504
|
+
{
|
|
2505
|
+
name: 'HKDF',
|
|
2506
|
+
hash: 'SHA-256',
|
|
2507
|
+
salt: toUint8(salt),
|
|
2508
|
+
info: toUint8(info),
|
|
2509
|
+
},
|
|
2510
|
+
baseKey,
|
|
2511
|
+
length * 8
|
|
2512
|
+
);
|
|
2513
|
+
return new Uint8Array(bits);
|
|
2514
|
+
}
|
|
2515
|
+
|
|
2516
|
+
// ─── RFC 8292: VAPID JWT ───────────────────────────────────────────────
|
|
2517
|
+
|
|
2518
|
+
/**
|
|
2519
|
+
* Build a VAPID `Authorization` JWT for a single push.
|
|
2520
|
+
*
|
|
2521
|
+
* @param {Object} args
|
|
2522
|
+
* @param {string} args.audience - Origin of the push endpoint (e.g. https://fcm.googleapis.com).
|
|
2523
|
+
* @param {string} args.subject - VAPID `sub` claim, typically `mailto:you@example.com`.
|
|
2524
|
+
* @param {string} args.publicKey - base64url, 65 B uncompressed P-256 point.
|
|
2525
|
+
* @param {string} args.privateKey - base64url, 32 B scalar.
|
|
2526
|
+
* @returns {Promise<string>} compact JWS (three base64url segments).
|
|
2527
|
+
*/
|
|
2528
|
+
async function buildVapidJwt({ audience, subject, publicKey, privateKey }) {
|
|
2529
|
+
const header = jsonToBase64Url({ typ: 'JWT', alg: 'ES256' });
|
|
2530
|
+
const payload = jsonToBase64Url({
|
|
2531
|
+
aud: audience,
|
|
2532
|
+
exp: Math.floor(Date.now() / 1000) + VAPID_TOKEN_LIFETIME,
|
|
2533
|
+
sub: subject,
|
|
2534
|
+
});
|
|
2535
|
+
|
|
2536
|
+
const signingInput = utf8(`${header}.${payload}`);
|
|
2537
|
+
|
|
2538
|
+
const pubBytes = base64UrlToBytes(publicKey);
|
|
2539
|
+
const privBytes = base64UrlToBytes(privateKey);
|
|
2540
|
+
if (pubBytes.length !== 65 || pubBytes[0] !== 0x04) {
|
|
2541
|
+
throw new Error('VAPID publicKey must be a 65-byte uncompressed P-256 point (base64url).');
|
|
2542
|
+
}
|
|
2543
|
+
if (privBytes.length !== 32) {
|
|
2544
|
+
throw new Error('VAPID privateKey must be a 32-byte scalar (base64url).');
|
|
2545
|
+
}
|
|
2546
|
+
|
|
2547
|
+
// Import as JWK so we can supply both private scalar (d) and public point
|
|
2548
|
+
// (x, y) in one step — required by WebCrypto for ECDSA signing.
|
|
2549
|
+
const jwk = {
|
|
2550
|
+
kty: 'EC',
|
|
2551
|
+
crv: 'P-256',
|
|
2552
|
+
d: bytesToBase64Url(privBytes),
|
|
2553
|
+
x: bytesToBase64Url(pubBytes.subarray(1, 33)),
|
|
2554
|
+
y: bytesToBase64Url(pubBytes.subarray(33, 65)),
|
|
2555
|
+
ext: true,
|
|
2556
|
+
};
|
|
2557
|
+
const key = await globalThis.crypto.subtle.importKey(
|
|
2558
|
+
'jwk',
|
|
2559
|
+
jwk,
|
|
2560
|
+
{ name: 'ECDSA', namedCurve: 'P-256' },
|
|
2561
|
+
false,
|
|
2562
|
+
['sign']
|
|
2563
|
+
);
|
|
2564
|
+
|
|
2565
|
+
// WebCrypto ECDSA produces a raw 64-byte (r || s) signature — exactly the
|
|
2566
|
+
// wire format JOSE/JWS expects, so no DER unwrapping is needed.
|
|
2567
|
+
const sig = await globalThis.crypto.subtle.sign(
|
|
2568
|
+
{ name: 'ECDSA', hash: 'SHA-256' },
|
|
2569
|
+
key,
|
|
2570
|
+
signingInput
|
|
2571
|
+
);
|
|
2572
|
+
|
|
2573
|
+
return `${header}.${payload}.${bytesToBase64Url(sig)}`;
|
|
2574
|
+
}
|
|
2575
|
+
|
|
2576
|
+
// ─── Helpers ───────────────────────────────────────────────────────────
|
|
2577
|
+
|
|
2578
|
+
function originOf(endpoint) {
|
|
2579
|
+
return new URL(endpoint).origin;
|
|
2580
|
+
}
|
|
2581
|
+
|
|
2582
|
+
function writeUint32BE(buf, offset, value) {
|
|
2583
|
+
buf[offset] = (value >>> 24) & 0xff;
|
|
2584
|
+
buf[offset + 1] = (value >>> 16) & 0xff;
|
|
2585
|
+
buf[offset + 2] = (value >>> 8) & 0xff;
|
|
2586
|
+
buf[offset + 3] = value & 0xff;
|
|
2587
|
+
}
|
|
2588
|
+
|
|
2589
|
+
async function safeReadText(res) {
|
|
2590
|
+
try {
|
|
2591
|
+
return await res.text();
|
|
2592
|
+
} catch {
|
|
2593
|
+
return '';
|
|
2594
|
+
}
|
|
2595
|
+
}
|
|
2596
|
+
|
|
2597
|
+
// Scheduled reminders must survive an offline device, so default to the same
|
|
2598
|
+
// 4-week TTL the web-push npm backend applies. sendWebPush's module-level
|
|
2599
|
+
// VAPID_DEFAULT_TTL (60s) is tuned for single-shot instant pushes; using it for
|
|
2600
|
+
// durable schedules would drop any reminder whose device was offline > 1 min.
|
|
2601
|
+
const SCHEDULED_DEFAULT_TTL = 2419200; // 4 weeks, in seconds
|
|
2602
|
+
|
|
2603
|
+
/**
|
|
2604
|
+
* web-push-compatible sender backed by the Web Crypto implementation above.
|
|
2605
|
+
* message-processor calls `ctx.webpush.sendNotification(subscription, payloadString)`,
|
|
2606
|
+
* so we only need that one method. VAPID keys are baked in at construction.
|
|
2607
|
+
*
|
|
2608
|
+
* @param {{ email: string, publicKey: string, privateKey: string }} [vapid]
|
|
2609
|
+
* @param {{ ttl?: number }} [options] - Push TTL in seconds; defaults to 4 weeks
|
|
2610
|
+
* (matches the web-push backend) so scheduled pushes outlive an offline device.
|
|
2611
|
+
* @returns {{ sendNotification: (subscription: Object, payload: string) => Promise<any> }}
|
|
2612
|
+
*/
|
|
2613
|
+
function createWebCryptoWebPush(vapid = {}, { ttl = SCHEDULED_DEFAULT_TTL } = {}) {
|
|
2614
|
+
return {
|
|
2615
|
+
async sendNotification(subscription, payload) {
|
|
2616
|
+
return sendWebPush({
|
|
2617
|
+
subscription,
|
|
2618
|
+
payload,
|
|
2619
|
+
ttl,
|
|
2620
|
+
vapid: {
|
|
2621
|
+
email: vapid.email,
|
|
2622
|
+
publicKey: vapid.publicKey,
|
|
2623
|
+
privateKey: vapid.privateKey
|
|
2624
|
+
},
|
|
2625
|
+
fetch: globalThis.fetch
|
|
2626
|
+
});
|
|
2627
|
+
}
|
|
2628
|
+
};
|
|
2629
|
+
}
|
|
2630
|
+
|
|
2631
|
+
/**
|
|
2632
|
+
* Cloudflare Worker factory for the single-user amsg-server.
|
|
2633
|
+
*
|
|
2634
|
+
* Mirrors instant's createCloudflareWorker: you pass a buildConfig(env) that
|
|
2635
|
+
* returns the single-user config; we build the server per request (cheap) and
|
|
2636
|
+
* dispatch. Returns { fetch, scheduled } for `export default`.
|
|
2637
|
+
*
|
|
2638
|
+
* Routes (server endpoints only — NO /send-notifications; cron is scheduled()):
|
|
2639
|
+
* POST /init-tenant → build tables (idempotent)
|
|
2640
|
+
* GET /get-user-key → derive user key
|
|
2641
|
+
* POST /schedule-message → create task
|
|
2642
|
+
* GET /messages → list
|
|
2643
|
+
* PUT /update-message → patch
|
|
2644
|
+
* DELETE /cancel-message → delete
|
|
2645
|
+
* GET /vapid-public-key → this worker's VAPID public key (for the frontend's
|
|
2646
|
+
* Web Push subscription); 503 if VAPID_PUBLIC_KEY unset
|
|
2647
|
+
*
|
|
2648
|
+
* CORS is opt-in: pass `cors: { origin }` in the config (a fixed origin, '*', or
|
|
2649
|
+
* an (origin) => allowedOrigin function) to answer OPTIONS preflights and echo
|
|
2650
|
+
* Access-Control-* on responses. With no `cors` the Worker stays same-origin.
|
|
2651
|
+
*/
|
|
2652
|
+
|
|
2653
|
+
|
|
2654
|
+
function headersToObject(h) {
|
|
2655
|
+
const out = {};
|
|
2656
|
+
for (const [k, v] of h) out[k] = v;
|
|
2657
|
+
return out;
|
|
2658
|
+
}
|
|
2659
|
+
|
|
2660
|
+
function jsonResponse(status, body, extraHeaders) {
|
|
2661
|
+
return new Response(JSON.stringify(body), {
|
|
2662
|
+
status,
|
|
2663
|
+
headers: { 'Content-Type': 'application/json; charset=utf-8', ...(extraHeaders || {}) }
|
|
2664
|
+
});
|
|
2665
|
+
}
|
|
2666
|
+
|
|
2667
|
+
// The custom headers the amsg-client sends; browsers preflight any request
|
|
2668
|
+
// carrying them, so cross-origin callers need them echoed in the CORS response.
|
|
2669
|
+
const CORS_ALLOW_HEADERS =
|
|
2670
|
+
'Content-Type, X-User-Id, X-Payload-Encrypted, X-Encryption-Version, X-Response-Encrypted, X-Client-Token';
|
|
2671
|
+
const CORS_ALLOW_METHODS = 'GET, POST, PUT, DELETE, OPTIONS';
|
|
2672
|
+
|
|
2673
|
+
/**
|
|
2674
|
+
* Resolve the CORS response headers for a request, or null when CORS is off.
|
|
2675
|
+
* Opt-in: with no `cors` config the Worker stays same-origin (no headers, and
|
|
2676
|
+
* OPTIONS falls through to 404) — so nothing is exposed unless asked for.
|
|
2677
|
+
*
|
|
2678
|
+
* @param {undefined | { origin: string | ((requestOrigin: string) => string|null|undefined), allowHeaders?: string, maxAge?: number }} cors
|
|
2679
|
+
* @param {string} requestOrigin - the request's Origin header (may be '')
|
|
2680
|
+
*/
|
|
2681
|
+
function corsHeadersFor(cors, requestOrigin) {
|
|
2682
|
+
if (!cors || cors.origin == null) return null;
|
|
2683
|
+
const allowOrigin = typeof cors.origin === 'function'
|
|
2684
|
+
? cors.origin(requestOrigin) || null
|
|
2685
|
+
: cors.origin; // e.g. '*' or a fixed origin like 'https://app.example.com'
|
|
2686
|
+
if (!allowOrigin) return null;
|
|
2687
|
+
|
|
2688
|
+
const headers = {
|
|
2689
|
+
'Access-Control-Allow-Origin': allowOrigin,
|
|
2690
|
+
'Access-Control-Allow-Methods': CORS_ALLOW_METHODS,
|
|
2691
|
+
'Access-Control-Allow-Headers': cors.allowHeaders || CORS_ALLOW_HEADERS,
|
|
2692
|
+
'Access-Control-Max-Age': String(cors.maxAge ?? 86400)
|
|
2693
|
+
};
|
|
2694
|
+
// A per-origin echo must vary the cache by Origin; '*' does not.
|
|
2695
|
+
if (allowOrigin !== '*') headers['Vary'] = 'Origin';
|
|
2696
|
+
return headers;
|
|
2697
|
+
}
|
|
2698
|
+
|
|
2699
|
+
function createSingleUserCloudflareWorker(buildConfig) {
|
|
2700
|
+
async function resolveConfig(env) {
|
|
2701
|
+
const cfg = await buildConfig(env);
|
|
2702
|
+
if (!cfg.db) cfg.db = createD1Adapter(env.DB);
|
|
2703
|
+
return cfg;
|
|
2704
|
+
}
|
|
2705
|
+
|
|
2706
|
+
async function fetch(request, env /* , ctx */) {
|
|
2707
|
+
// Error boundary: a handler (or config build) may throw — e.g.
|
|
2708
|
+
// schedule-message re-throws a non-unique DB error. Keep the client-facing
|
|
2709
|
+
// contract consistent (a JSON envelope, not the runtime's HTML error page).
|
|
2710
|
+
try {
|
|
2711
|
+
const cfg = await resolveConfig(env);
|
|
2712
|
+
const cors = corsHeadersFor(cfg.cors, request.headers.get('origin') || '');
|
|
2713
|
+
const method = request.method.toUpperCase();
|
|
2714
|
+
|
|
2715
|
+
// CORS preflight: answer OPTIONS directly when CORS is configured.
|
|
2716
|
+
if (method === 'OPTIONS') {
|
|
2717
|
+
return cors
|
|
2718
|
+
? new Response(null, { status: 204, headers: cors })
|
|
2719
|
+
: jsonResponse(404, { success: false, error: { code: 'NOT_FOUND', message: 'Unknown route' } });
|
|
2720
|
+
}
|
|
2721
|
+
|
|
2722
|
+
const server = createSingleUserServer(cfg);
|
|
2723
|
+
|
|
2724
|
+
const url = request.url;
|
|
2725
|
+
// Strip trailing slash(es) so `/init-tenant/` routes like `/init-tenant`
|
|
2726
|
+
// (endsWith matching is kept so a prefixed mount still resolves).
|
|
2727
|
+
const pathname = new URL(url).pathname.replace(/\/+$/, '') || '/';
|
|
2728
|
+
const headers = headersToObject(request.headers);
|
|
2729
|
+
|
|
2730
|
+
let result;
|
|
2731
|
+
if (method === 'POST' && pathname.endsWith('/init-tenant')) {
|
|
2732
|
+
result = await server.handlers.init.POST(headers, await request.text());
|
|
2733
|
+
} else if (method === 'GET' && pathname.endsWith('/get-user-key')) {
|
|
2734
|
+
result = await server.handlers.getUserKey.GET(url, headers);
|
|
2735
|
+
} else if (method === 'POST' && pathname.endsWith('/schedule-message')) {
|
|
2736
|
+
result = await server.handlers.scheduleMessage.POST(headers, await request.text());
|
|
2737
|
+
} else if (method === 'GET' && pathname.endsWith('/messages')) {
|
|
2738
|
+
result = await server.handlers.messages.GET(url, headers);
|
|
2739
|
+
} else if (method === 'PUT' && pathname.endsWith('/update-message')) {
|
|
2740
|
+
result = await server.handlers.updateMessage.PUT(url, headers, await request.text());
|
|
2741
|
+
} else if (method === 'DELETE' && pathname.endsWith('/cancel-message')) {
|
|
2742
|
+
result = await server.handlers.cancelMessage.DELETE(url, headers);
|
|
2743
|
+
} else if (method === 'GET' && pathname.endsWith('/vapid-public-key')) {
|
|
2744
|
+
result = await server.handlers.vapidPublicKey.GET(url, headers);
|
|
2745
|
+
} else {
|
|
2746
|
+
result = { status: 404, body: { success: false, error: { code: 'NOT_FOUND', message: 'Unknown route' } } };
|
|
2747
|
+
}
|
|
2748
|
+
|
|
2749
|
+
return jsonResponse(result.status, result.body, cors);
|
|
2750
|
+
} catch (error) {
|
|
2751
|
+
console.error('[amsg single-user] fetch() unhandled error:', error && error.message);
|
|
2752
|
+
return jsonResponse(500, { success: false, error: { code: 'INTERNAL_ERROR', message: '服务器内部错误' } });
|
|
2753
|
+
}
|
|
2754
|
+
}
|
|
2755
|
+
|
|
2756
|
+
async function scheduled(event, env /* , ctx */) {
|
|
2757
|
+
const cfg = await resolveConfig(env);
|
|
2758
|
+
const vapid = cfg.vapid || {};
|
|
2759
|
+
if (!cfg.webpush || !vapid.email || !vapid.publicKey || !vapid.privateKey) {
|
|
2760
|
+
console.error('[amsg single-user] scheduled(): VAPID/webpush not configured; skipping tick');
|
|
2761
|
+
return;
|
|
2762
|
+
}
|
|
2763
|
+
// Swallow tick failures: pending tasks stay pending, so the next cron tick
|
|
2764
|
+
// retries them. Logging keeps the failure visible in the tail log.
|
|
2765
|
+
try {
|
|
2766
|
+
await runScheduledTick({ db: cfg.db, masterKey: cfg.masterKey, vapid, webpush: cfg.webpush });
|
|
2767
|
+
} catch (error) {
|
|
2768
|
+
console.error('[amsg single-user] scheduled(): tick failed:', error && error.message);
|
|
2769
|
+
}
|
|
2770
|
+
}
|
|
2771
|
+
|
|
2772
|
+
return { fetch, scheduled };
|
|
2773
|
+
}
|
|
2774
|
+
|
|
2775
|
+
export { createCancelMessageHandler as a, createUpdateMessageHandler as b, createMessagesHandler as c, createScheduleMessageHandler as d, createGetUserKeyHandler as e, createD1Adapter as f, getHeader as g, createSingleUserCloudflareWorker as h, isValidUUIDv4 as i, createSingleUserServer as j, createWebCryptoWebPush as k, decryptFromStorage as l, decryptPayload as m, deriveUserEncryptionKey as n, encryptForStorage as o, parseJsonBody as p, isValidISO8601 as q, runScheduledTick as r, isValidUUID as s, isValidUrl as t, validateScheduleMessagePayload as u, validateLlmMessagesArray as v, validateSplitPattern as w };
|