@rei-standard/amsg-server 2.6.0-next.8 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +861 -35
  3. package/dist/adapters/d1.d.ts +693 -0
  4. package/dist/adapters/factory.d.ts +39 -0
  5. package/dist/adapters/interface.d.ts +384 -0
  6. package/dist/adapters/neon.d.ts +105 -0
  7. package/dist/adapters/pg-shared.d.ts +194 -0
  8. package/dist/adapters/pg.d.ts +101 -0
  9. package/dist/adapters/schema.d.ts +57 -0
  10. package/dist/adapters/schema.sqlite.d.ts +80 -0
  11. package/dist/chunk-7NMQFTDJ.cjs +148 -0
  12. package/dist/{chunk-GOBCWCJP.mjs → chunk-GN44PST5.mjs} +68 -1
  13. package/dist/chunk-RL7V7VSS.mjs +8784 -0
  14. package/dist/chunk-TKDISKCF.cjs +163 -0
  15. package/dist/chunk-XKUWMYVF.mjs +163 -0
  16. package/dist/chunk-YBDQJVFQ.cjs +8784 -0
  17. package/dist/cloudflare/single-user-worker.d.ts +39 -0
  18. package/dist/cloudflare.cjs +35 -2
  19. package/dist/cloudflare.d.ts +11 -2
  20. package/dist/cloudflare.mjs +36 -3
  21. package/dist/handlers/cancel-message.d.ts +3 -0
  22. package/dist/handlers/capabilities.d.ts +4 -0
  23. package/dist/handlers/client-state-namespaces.d.ts +5 -0
  24. package/dist/handlers/client-state.d.ts +7 -0
  25. package/dist/handlers/cloud-data.d.ts +4 -0
  26. package/dist/handlers/get-message.d.ts +3 -0
  27. package/dist/handlers/get-user-key.d.ts +3 -0
  28. package/dist/handlers/init-tenant.d.ts +23 -0
  29. package/dist/handlers/llm-credentials.d.ts +5 -0
  30. package/dist/handlers/messages.d.ts +3 -0
  31. package/dist/handlers/outbox.d.ts +9 -0
  32. package/dist/handlers/push-subscription.d.ts +5 -0
  33. package/dist/handlers/schedule-message.d.ts +3 -0
  34. package/dist/handlers/send-notifications.d.ts +3 -0
  35. package/dist/handlers/single-user-init.d.ts +13 -0
  36. package/dist/handlers/update-message.d.ts +3 -0
  37. package/dist/handlers/vapid-public-key.d.ts +19 -0
  38. package/dist/index.cjs +133 -44
  39. package/dist/index.d.cts +145 -766
  40. package/dist/index.d.ts +145 -766
  41. package/dist/index.mjs +125 -36
  42. package/dist/lib/agentic-fire.d.ts +177 -0
  43. package/dist/lib/client-state-store.d.ts +88 -0
  44. package/dist/lib/cloud-data-cleanup.d.ts +36 -0
  45. package/dist/lib/cloud-data-guard.d.ts +14 -0
  46. package/dist/lib/cloud-data-inventory.d.ts +15 -0
  47. package/dist/lib/constant-time.d.ts +14 -0
  48. package/dist/lib/db-errors.d.ts +10 -0
  49. package/dist/lib/encryption.d.ts +48 -0
  50. package/dist/lib/errors.d.ts +273 -0
  51. package/dist/lib/llm-credentials-store.d.ts +147 -0
  52. package/dist/lib/llm.d.ts +12 -0
  53. package/dist/lib/message-processor.d.ts +82 -0
  54. package/dist/lib/outbox-store.d.ts +162 -0
  55. package/dist/lib/push-policy.d.ts +11 -0
  56. package/dist/lib/push-subscription-store.d.ts +94 -0
  57. package/dist/lib/recurrence.d.ts +51 -0
  58. package/dist/lib/request.d.ts +182 -0
  59. package/dist/lib/result-emitter.d.ts +60 -0
  60. package/dist/lib/retry-policy.d.ts +11 -0
  61. package/dist/lib/run-tick.d.ts +121 -0
  62. package/dist/lib/schema-version.d.ts +50 -0
  63. package/dist/lib/state-accessors.d.ts +70 -0
  64. package/dist/lib/state-chunks.d.ts +65 -0
  65. package/dist/lib/task-projection.d.ts +92 -0
  66. package/dist/lib/validation.d.ts +97 -0
  67. package/dist/lib/version.d.ts +7 -0
  68. package/dist/lib/webcrypto-utils.d.ts +1 -0
  69. package/dist/lib/webpush-webcrypto.d.ts +78 -0
  70. package/dist/{neon-QJ7KYHEX.mjs → neon-GKLT4UY3.mjs} +116 -44
  71. package/dist/{neon-V4VOH55N.cjs → neon-XHPJZESV.cjs} +121 -49
  72. package/dist/{pg-6X74HKMX.cjs → pg-FHXQ6BJP.cjs} +109 -48
  73. package/dist/{pg-DFJSFOJQ.mjs → pg-J4YXRQP5.mjs} +104 -43
  74. package/dist/single-user.d.ts +106 -0
  75. package/dist/tenant/blob-store.d.ts +30 -0
  76. package/dist/tenant/context.d.ts +47 -0
  77. package/dist/tenant/single-user-context.d.ts +29 -0
  78. package/dist/tenant/token.d.ts +26 -0
  79. package/package.json +3 -3
  80. package/dist/chunk-DCKCAGFV.cjs +0 -81
  81. package/dist/chunk-HQHRXSQA.cjs +0 -2710
  82. package/dist/chunk-LEE5KQ7Y.mjs +0 -2710
  83. package/dist/cloudflare-CH3dBl5b.d.cts +0 -4235
  84. package/dist/cloudflare-CH3dBl5b.d.ts +0 -4235
  85. package/dist/cloudflare.d.cts +0 -2
  86. package/dist/neon-D8Z2Nn97.d.cts +0 -295
  87. package/dist/neon-PqNFJIz2.d.ts +0 -295
  88. package/dist/pg-C48FwCWW.d.cts +0 -289
  89. package/dist/pg-C5gtWUfU.d.ts +0 -289
  90. package/dist/schema-BHuyH_6Z.d.cts +0 -88
  91. package/dist/schema-BHuyH_6Z.d.ts +0 -88
@@ -1,4235 +0,0 @@
1
- import { validateAvatarUrl, toUint8, concatBytes, base64UrlToBytes, chunkReasoningByUtf8Bytes, extractAssistantMessage, buildSessionContext, assertValidDecision, extractToolCallsFromDecision, readReasoningContent, stripReasoningTags, buildReasoningPush, buildContentPush, normalizeVapidSubject } from '@rei-standard/amsg-shared';
2
-
3
- /**
4
- * Validation utility library (SDK version)
5
- * ReiStandard SDK v2.0.1
6
- */
7
-
8
-
9
- /**
10
- * Validate ISO 8601 date string.
11
- * @param {string} dateString
12
- * @returns {boolean}
13
- */
14
- function isValidISO8601(dateString) {
15
- const date = new Date(dateString);
16
- return date instanceof Date && !isNaN(date.getTime());
17
- }
18
-
19
- /**
20
- * Validate URL format.
21
- * @param {string} urlString
22
- * @returns {boolean}
23
- */
24
- function isValidUrl(urlString) {
25
- try {
26
- new URL(urlString);
27
- return true;
28
- } catch {
29
- return false;
30
- }
31
- }
32
-
33
- /**
34
- * Validate UUID format.
35
- * @param {string} uuid
36
- * @returns {boolean}
37
- */
38
- function isValidUUID(uuid) {
39
- const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
40
- return uuidRegex.test(uuid);
41
- }
42
-
43
- /**
44
- * Validate UUID v4 format.
45
- * @param {string} uuid
46
- * @returns {boolean}
47
- */
48
- function isValidUUIDv4(uuid) {
49
- 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;
50
- return uuidV4Regex.test(uuid);
51
- }
52
-
53
- const VALID_LLM_MESSAGE_ROLES = new Set(['system', 'user', 'assistant', 'tool']);
54
-
55
- const SPLIT_PATTERN_MAX_LENGTH = 200;
56
- const SPLIT_PATTERN_MAX_ITEMS = 10;
57
-
58
- /**
59
- * Validate the optional `splitPattern` field (amsg-server scheduled tasks
60
- * only; amsg-instant 0.8.0 dropped its request-level `splitPattern`).
61
- * Accepts `string`, `string[]`, or absent/null. Returns an error message
62
- * string, or null when valid.
63
- *
64
- * Limits (per-item length ≤ 200, array ≤ 10 items, must compile via
65
- * `new RegExp(item)`) are an **input-size guard**, NOT a ReDoS defense —
66
- * a 6-character pattern like `(a+)+$` is enough to trigger catastrophic
67
- * backtracking. The real backstop is Worker / runtime CPU limits + the
68
- * fact that splitPattern is stored under the user's own encrypted task
69
- * and matched against output from the user's own LLM API key, so the
70
- * blast radius is self-inflicted only (no cross-tenant attack surface).
71
- *
72
- * @param {unknown} value
73
- * @returns {string | null}
74
- */
75
- function validateSplitPattern(value) {
76
- if (value === undefined || value === null) return null;
77
- const isArray = Array.isArray(value);
78
- const items = isArray ? value : [value];
79
- if (isArray && items.length === 0) return null; // empty array = use default
80
- if (items.length > SPLIT_PATTERN_MAX_ITEMS) {
81
- return `splitPattern 数组最多 ${SPLIT_PATTERN_MAX_ITEMS} 项`;
82
- }
83
- for (let i = 0; i < items.length; i++) {
84
- const s = items[i];
85
- const label = isArray ? `splitPattern[${i}]` : 'splitPattern';
86
- if (typeof s !== 'string') return `${label} 必须是字符串`;
87
- if (s.length > SPLIT_PATTERN_MAX_LENGTH) {
88
- return `${label} 不能超过 ${SPLIT_PATTERN_MAX_LENGTH} 字符`;
89
- }
90
- try { new RegExp(s); }
91
- catch (_) { return `${label} 不是有效正则表达式`; }
92
- }
93
- return null;
94
- }
95
-
96
- /**
97
- * Validate an OpenAI-style messages array. Same shape contract as
98
- * `@rei-standard/amsg-instant` (kept in lockstep on purpose — both packages
99
- * end up forwarding this to the same LLM body).
100
- *
101
- * @param {unknown} messages
102
- * @returns {string | null} Error message, or null if valid.
103
- */
104
- function validateLlmMessagesArray(messages) {
105
- if (!Array.isArray(messages) || messages.length === 0) {
106
- return 'messages must be a non-empty array';
107
- }
108
- for (let i = 0; i < messages.length; i++) {
109
- const m = messages[i];
110
- if (!m || typeof m !== 'object' || Array.isArray(m)) {
111
- return `messages[${i}] must be an object`;
112
- }
113
- if (!VALID_LLM_MESSAGE_ROLES.has(m.role)) {
114
- return `messages[${i}].role must be one of system / user / assistant / tool`;
115
- }
116
- if (typeof m.content === 'string') {
117
- if (!m.content) return `messages[${i}].content must be a non-empty string`;
118
- } else if (Array.isArray(m.content)) {
119
- if (m.content.length === 0) return `messages[${i}].content array must be non-empty`;
120
- // Element schema is intentionally not enforced — passed through to LLM as-is.
121
- } else {
122
- return `messages[${i}].content must be a non-empty string or a non-empty array`;
123
- }
124
- }
125
- return null;
126
- }
127
-
128
- /**
129
- * Validate the schedule-message request payload.
130
- *
131
- * @param {Object} payload
132
- * @returns {{ valid: boolean, errorCode?: string, errorMessage?: string, details?: Object }}
133
- */
134
- function validateScheduleMessagePayload(payload) {
135
- if (!payload.contactName || typeof payload.contactName !== 'string') {
136
- return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { missingFields: ['contactName'] } };
137
- }
138
-
139
- if (!payload.messageType || !['fixed', 'prompted', 'auto', 'instant'].includes(payload.messageType)) {
140
- return { valid: false, errorCode: 'INVALID_MESSAGE_TYPE', errorMessage: '消息类型无效', details: { providedType: payload.messageType, allowedTypes: ['fixed', 'prompted', 'auto', 'instant'] } };
141
- }
142
-
143
- if (!payload.firstSendTime || !isValidISO8601(payload.firstSendTime)) {
144
- return { valid: false, errorCode: 'INVALID_TIMESTAMP', errorMessage: '时间格式无效', details: { field: 'firstSendTime' } };
145
- }
146
-
147
- if (payload.firstSendTime && new Date(payload.firstSendTime) <= new Date()) {
148
- return { valid: false, errorCode: 'INVALID_TIMESTAMP', errorMessage: '时间必须在未来', details: { field: 'firstSendTime', reason: 'must be in the future' } };
149
- }
150
-
151
- if (!payload.pushSubscription || typeof payload.pushSubscription !== 'object') {
152
- return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { missingFields: ['pushSubscription'] } };
153
- }
154
-
155
- if (payload.recurrenceType && !['none', 'daily', 'weekly'].includes(payload.recurrenceType)) {
156
- return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { invalidFields: ['recurrenceType'] } };
157
- }
158
-
159
- if (
160
- payload.maxTokens !== undefined &&
161
- payload.maxTokens !== null &&
162
- (!Number.isInteger(payload.maxTokens) || payload.maxTokens <= 0)
163
- ) {
164
- return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { invalidFields: ['maxTokens'] } };
165
- }
166
-
167
- if (payload.messageType === 'fixed') {
168
- if (!payload.userMessage) {
169
- return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { missingFields: ['userMessage (required for fixed type)'] } };
170
- }
171
- }
172
-
173
- // ─── Prompt schema (shared by prompted / auto / instant AI configs) ──
174
- //
175
- // Callers provide *exactly one of* `completePrompt` (string) or `messages`
176
- // (OpenAI-style array). Same contract as @rei-standard/amsg-instant; the
177
- // server's LLM path forwards either verbatim.
178
- const promptCheck = (() => {
179
- const hasCompletePrompt = payload.completePrompt !== undefined && payload.completePrompt !== null && payload.completePrompt !== '';
180
- const hasMessages = payload.messages !== undefined && payload.messages !== null;
181
- if (hasCompletePrompt && hasMessages) {
182
- return {
183
- error: { code: 'INVALID_PARAMETERS', message: 'exactly one of `completePrompt` or `messages` must be provided(两者不能同时出现)', details: { invalidFields: ['completePrompt', 'messages'] } },
184
- hasCompletePrompt: true, hasMessages: true,
185
- };
186
- }
187
- if (hasMessages) {
188
- const err = validateLlmMessagesArray(payload.messages);
189
- if (err) {
190
- return {
191
- error: { code: 'INVALID_PARAMETERS', message: err, details: { invalidFields: ['messages'] } },
192
- hasCompletePrompt: false, hasMessages: true,
193
- };
194
- }
195
- }
196
- return { error: null, hasCompletePrompt, hasMessages };
197
- })();
198
-
199
- if (promptCheck.error) {
200
- return { valid: false, errorCode: promptCheck.error.code, errorMessage: promptCheck.error.message, details: promptCheck.error.details };
201
- }
202
- const hasPrompt = promptCheck.hasCompletePrompt || promptCheck.hasMessages;
203
-
204
- if (payload.messageType === 'prompted' || payload.messageType === 'auto') {
205
- const missingAiFields = [];
206
- if (!hasPrompt) missingAiFields.push('completePrompt or messages');
207
- if (!payload.apiUrl) missingAiFields.push('apiUrl');
208
- if (!payload.apiKey) missingAiFields.push('apiKey');
209
- if (!payload.primaryModel) missingAiFields.push('primaryModel');
210
- if (missingAiFields.length > 0) {
211
- return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { missingFields: missingAiFields } };
212
- }
213
- }
214
-
215
- if (payload.messageType === 'instant') {
216
- if (payload.recurrenceType && payload.recurrenceType !== 'none') {
217
- return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: 'instant 类型的 recurrenceType 必须为 none', details: { invalidFields: ['recurrenceType (must be "none" for instant type)'] } };
218
- }
219
- const hasAiConfig = hasPrompt && payload.apiUrl && payload.apiKey && payload.primaryModel;
220
- const hasUserMessage = payload.userMessage;
221
- if (!hasAiConfig && !hasUserMessage) {
222
- return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: 'instant 类型必须提供 userMessage 或完整的 AI 配置', details: { missingFields: ['userMessage or ((completePrompt | messages) + apiUrl + apiKey + primaryModel)'] } };
223
- }
224
- }
225
-
226
- if (
227
- payload.temperature !== undefined &&
228
- payload.temperature !== null &&
229
- (typeof payload.temperature !== 'number' || !Number.isFinite(payload.temperature))
230
- ) {
231
- return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { invalidFields: ['temperature (must be a finite number)'] } };
232
- }
233
-
234
- const avatarErr = validateAvatarUrl(payload.avatarUrl);
235
- if (avatarErr) {
236
- // Soft-strip: a bad avatarUrl (data: URI / oversized / malformed) used to
237
- // 400 the whole schedule. Avatar is cosmetic — drop the field, log, and
238
- // let the rest of the task ship. See standards §6.2.
239
- console.warn('[amsg-server] avatarUrl 不合法,已置空:', avatarErr);
240
- payload.avatarUrl = null;
241
- }
242
- if (payload.uuid && !isValidUUID(payload.uuid)) {
243
- return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { invalidFields: ['uuid (invalid UUID format)'] } };
244
- }
245
- // messageSubtype is a free-form string tag forwarded to SW-side push payload
246
- // for classification. Only the type is enforced; the taxonomy is the
247
- // consumer's call (previously the enum was chat/forum/moment).
248
- if (
249
- payload.messageSubtype !== undefined &&
250
- payload.messageSubtype !== null &&
251
- typeof payload.messageSubtype !== 'string'
252
- ) {
253
- return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { invalidFields: ['messageSubtype'] } };
254
- }
255
-
256
- const splitErr = validateSplitPattern(payload.splitPattern);
257
- if (splitErr) {
258
- return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: splitErr, details: { invalidFields: ['splitPattern'] } };
259
- }
260
-
261
- return { valid: true };
262
- }
263
-
264
- /**
265
- * Request payload utilities.
266
- * Keeps body parsing and shape validation consistent across handlers.
267
- */
268
-
269
- const REQUEST_ERRORS = {
270
- INVALID_JSON: { code: 'INVALID_JSON', message: '请求体不是有效的 JSON' },
271
- INVALID_REQUEST_BODY: { code: 'INVALID_REQUEST_BODY', message: '请求体格式无效' },
272
- INVALID_ENCRYPTED_PAYLOAD: { code: 'INVALID_ENCRYPTED_PAYLOAD', message: '加密数据格式错误' }
273
- };
274
-
275
- /**
276
- * @typedef {{ code: string, message: string }} ValidationError
277
- */
278
-
279
- /**
280
- * @typedef {{
281
- * invalidJson?: ValidationError,
282
- * invalidType?: ValidationError
283
- * }} ParseBodyOptions
284
- */
285
-
286
- /**
287
- * @typedef {{
288
- * ok: true,
289
- * data: Record<string, any>
290
- * } | {
291
- * ok: false,
292
- * error: ValidationError
293
- * }} ParseBodyResult
294
- */
295
-
296
- /**
297
- * Parse body into a JSON object.
298
- *
299
- * @param {unknown} body
300
- * @param {ParseBodyOptions} [options]
301
- * @returns {ParseBodyResult}
302
- */
303
- function parseBodyAsObject(body, options = {}) {
304
- const invalidJson = options.invalidJson || REQUEST_ERRORS.INVALID_JSON;
305
- const invalidType = options.invalidType || REQUEST_ERRORS.INVALID_REQUEST_BODY;
306
-
307
- let parsed = body;
308
- if (typeof parsed === 'string') {
309
- try {
310
- parsed = JSON.parse(parsed);
311
- } catch {
312
- return { ok: false, error: invalidJson };
313
- }
314
- }
315
-
316
- if (!isPlainObject(parsed)) {
317
- return { ok: false, error: invalidType };
318
- }
319
-
320
- return { ok: true, data: parsed };
321
- }
322
-
323
- /**
324
- * Parse a standard JSON object body.
325
- *
326
- * @param {unknown} body
327
- * @returns {ParseBodyResult}
328
- */
329
- function parseJsonBody(body) {
330
- return parseBodyAsObject(body, {
331
- invalidJson: REQUEST_ERRORS.INVALID_JSON,
332
- invalidType: REQUEST_ERRORS.INVALID_REQUEST_BODY
333
- });
334
- }
335
-
336
- /**
337
- * Check if a value is a plain object (and not null/array).
338
- *
339
- * @param {unknown} value
340
- * @returns {value is Record<string, any>}
341
- */
342
- function isPlainObject(value) {
343
- return typeof value === 'object' && value !== null && !Array.isArray(value);
344
- }
345
-
346
- /**
347
- * Check if an object follows the encrypted payload envelope shape.
348
- *
349
- * @param {unknown} payload
350
- * @returns {payload is { iv: string, authTag: string, encryptedData: string }}
351
- */
352
- function isEncryptedEnvelope(payload) {
353
- if (!isPlainObject(payload)) return false;
354
-
355
- return (
356
- typeof payload.iv === 'string' &&
357
- typeof payload.authTag === 'string' &&
358
- typeof payload.encryptedData === 'string'
359
- );
360
- }
361
-
362
- /**
363
- * Parse and validate an encrypted payload envelope.
364
- *
365
- * @param {unknown} body
366
- * @returns {ParseBodyResult}
367
- */
368
- function parseEncryptedBody(body) {
369
- const parsedBody = parseBodyAsObject(body, {
370
- invalidJson: REQUEST_ERRORS.INVALID_ENCRYPTED_PAYLOAD,
371
- invalidType: REQUEST_ERRORS.INVALID_ENCRYPTED_PAYLOAD
372
- });
373
-
374
- if (!parsedBody.ok) {
375
- return parsedBody;
376
- }
377
-
378
- if (!isEncryptedEnvelope(parsedBody.data)) {
379
- return { ok: false, error: REQUEST_ERRORS.INVALID_ENCRYPTED_PAYLOAD };
380
- }
381
-
382
- return parsedBody;
383
- }
384
-
385
- /**
386
- * Read a header value case-insensitively.
387
- *
388
- * @param {Record<string, any>} headers
389
- * @param {string} name
390
- * @returns {string}
391
- */
392
- function getHeader(headers = {}, name) {
393
- if (!headers || typeof headers !== 'object') {
394
- return '';
395
- }
396
-
397
- const lowerName = String(name || '').toLowerCase();
398
- if (!lowerName) return '';
399
-
400
- for (const [key, value] of Object.entries(headers)) {
401
- if (String(key).toLowerCase() === lowerName) {
402
- return String(value || '').trim();
403
- }
404
- }
405
-
406
- return '';
407
- }
408
-
409
- /**
410
- * Runtime-neutral crypto + encoding helpers.
411
- *
412
- * Everything in here is implemented on top of WHATWG-standard primitives
413
- * (`globalThis.crypto.subtle`, `TextEncoder`, `Uint8Array`) so the package
414
- * runs on Cloudflare Workers, Vercel Edge, Netlify Edge, Deno, Bun, and
415
- * Node ≥ 19 with zero polyfills. The Node adapter polyfills
416
- * `globalThis.crypto` for Node 18 deployments before any of these are
417
- * touched.
418
- */
419
-
420
- const TEXT_ENCODER = new TextEncoder();
421
- const TEXT_DECODER = new TextDecoder('utf-8', { fatal: false });
422
-
423
- /** UTF-8 encode a string into a Uint8Array. */
424
- function utf8$1(str) {
425
- return TEXT_ENCODER.encode(String(str));
426
- }
427
-
428
- /** UTF-8 decode a Uint8Array / ArrayBuffer into a string. */
429
- function utf8Decode(buf) {
430
- return TEXT_DECODER.decode(toUint8(buf));
431
- }
432
-
433
-
434
- /** Encode bytes as standard base64 (with padding). */
435
- function bytesToBase64(buf) {
436
- const bytes = toUint8(buf);
437
- let bin = '';
438
- for (let i = 0; i < bytes.length; i++) {
439
- bin += String.fromCharCode(bytes[i]);
440
- }
441
- // btoa is available in all Web Crypto runtimes (browsers, Workers, Node 16+).
442
- return (typeof btoa === 'function')
443
- ? btoa(bin)
444
- : Buffer.from(bin, 'binary').toString('base64');
445
- }
446
-
447
- /** Encode bytes as base64url (no padding). */
448
- function bytesToBase64Url(buf) {
449
- return bytesToBase64(buf).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
450
- }
451
-
452
- /** Encode bytes as lowercase hex. */
453
- function bytesToHex(buf) {
454
- const bytes = toUint8(buf);
455
- let out = '';
456
- for (let i = 0; i < bytes.length; i++) {
457
- out += bytes[i].toString(16).padStart(2, '0');
458
- }
459
- return out;
460
- }
461
-
462
- /** Decode a hex string into a Uint8Array. */
463
- function hexToBytes(hex) {
464
- const out = new Uint8Array(hex.length / 2);
465
- for (let i = 0; i < out.length; i++) {
466
- out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
467
- }
468
- return out;
469
- }
470
-
471
-
472
- /** Encode a JSON-serializable value as base64url (UTF-8 JSON). */
473
- function jsonToBase64Url(value) {
474
- return bytesToBase64Url(utf8$1(JSON.stringify(value)));
475
- }
476
-
477
- /** `crypto.randomUUID()`. The Node adapter polyfills `globalThis.crypto`. */
478
- function randomUUID() {
479
- return globalThis.crypto.randomUUID();
480
- }
481
-
482
- /** Cryptographically random bytes. */
483
- function randomBytes(n) {
484
- const out = new Uint8Array(n);
485
- globalThis.crypto.getRandomValues(out);
486
- return out;
487
- }
488
-
489
- /**
490
- * Encryption utility library (SDK version)
491
- *
492
- * Wraps AES-256-GCM operations for request/response and storage encryption.
493
- * Implemented on `globalThis.crypto.subtle` (Web Crypto) so the module runs
494
- * on Cloudflare Workers and other edge runtimes without `nodejs_compat`, as
495
- * well as on Node ≥ 19. All exports are async because SubtleCrypto is.
496
- *
497
- * Wire formats are byte-identical to the previous node:crypto implementation
498
- * (pinned by test/encryption-compat.test.mjs):
499
- * - payload: 12-byte IV, base64 `{ iv, authTag, encryptedData }`
500
- * - storage: 16-byte IV, hex `iv:authTag:encryptedData`
501
- * Web Crypto appends the GCM auth tag to the ciphertext, while these formats
502
- * carry it detached — aesGcmSeal / aesGcmOpen below own that seam.
503
- */
504
-
505
-
506
- // Decoder for the wire formats' base64 fields. base64UrlToBytes normalizes
507
- // the url-safe alphabet and padding first, so it accepts both standard and
508
- // url-safe base64 — same leniency as node's Buffer.from(x, 'base64').
509
- const base64ToBytes = base64UrlToBytes;
510
-
511
- const TAG_LENGTH_BYTES = 16; // AES-GCM auth tag emitted on encrypt
512
-
513
- function importAesKey(hexKey, usage) {
514
- return globalThis.crypto.subtle.importKey(
515
- 'raw',
516
- hexToBytes(hexKey),
517
- { name: 'AES-GCM' },
518
- false,
519
- [usage]
520
- );
521
- }
522
-
523
- /** AES-256-GCM encrypt; returns the ciphertext and the detached 16-byte tag. */
524
- async function aesGcmSeal(hexKey, iv, plaintextBytes) {
525
- const key = await importAesKey(hexKey, 'encrypt');
526
- const sealed = new Uint8Array(await globalThis.crypto.subtle.encrypt(
527
- { name: 'AES-GCM', iv, tagLength: TAG_LENGTH_BYTES * 8 },
528
- key,
529
- plaintextBytes
530
- ));
531
- return {
532
- ciphertext: sealed.slice(0, sealed.length - TAG_LENGTH_BYTES),
533
- authTag: sealed.slice(sealed.length - TAG_LENGTH_BYTES)
534
- };
535
- }
536
-
537
- /** AES-256-GCM decrypt from detached-tag form; returns plaintext bytes. */
538
- async function aesGcmOpen(hexKey, iv, ciphertext, authTag) {
539
- const key = await importAesKey(hexKey, 'decrypt');
540
- // node's decipher accepted GCM tags truncated down to 96 bits; mirror that
541
- // by sizing tagLength from the provided tag. Anything outside the standard
542
- // 96–128-bit range keeps 128 and fails authentication, as it did before.
543
- const tagBits = authTag.length * 8;
544
- const tagLength = tagBits >= 96 && tagBits <= 128 ? tagBits : 128;
545
- const plain = await globalThis.crypto.subtle.decrypt(
546
- { name: 'AES-GCM', iv, tagLength },
547
- key,
548
- concatBytes(ciphertext, authTag)
549
- );
550
- return new Uint8Array(plain);
551
- }
552
-
553
- /**
554
- * Derive a user-specific encryption key from the master key.
555
- *
556
- * @param {string} userId - Unique user identifier.
557
- * @param {string} masterKey - 64-char hex master key.
558
- * @returns {Promise<string>} 64-char hex key.
559
- */
560
- async function deriveUserEncryptionKey(userId, masterKey) {
561
- const digest = await globalThis.crypto.subtle.digest('SHA-256', utf8$1(masterKey + userId));
562
- return bytesToHex(new Uint8Array(digest)).slice(0, 64);
563
- }
564
-
565
- /**
566
- * Decrypt a client-encrypted request body (AES-256-GCM, base64 encoded).
567
- *
568
- * @param {{ iv: string, authTag: string, encryptedData: string }} encryptedPayload
569
- * @param {string} encryptionKey - 64-char hex key.
570
- * @returns {Promise<Object>} Decrypted JSON object.
571
- */
572
- async function decryptPayload(encryptedPayload, encryptionKey) {
573
- const { iv, authTag, encryptedData } = encryptedPayload;
574
- const plain = await aesGcmOpen(
575
- encryptionKey,
576
- base64ToBytes(iv),
577
- base64ToBytes(encryptedData),
578
- base64ToBytes(authTag)
579
- );
580
- return JSON.parse(utf8Decode(plain));
581
- }
582
-
583
- /**
584
- * Encrypt a JSON payload for API transfer (AES-256-GCM, base64 encoded).
585
- *
586
- * @param {string|Object} payload
587
- * @param {string} encryptionKey - 64-char hex key.
588
- * @returns {Promise<{ iv: string, authTag: string, encryptedData: string }>}
589
- */
590
- async function encryptPayload(payload, encryptionKey) {
591
- const plaintext = typeof payload === 'string' ? payload : JSON.stringify(payload);
592
- const iv = randomBytes(12);
593
- const { ciphertext, authTag } = await aesGcmSeal(encryptionKey, iv, utf8$1(plaintext));
594
-
595
- return {
596
- iv: bytesToBase64(iv),
597
- authTag: bytesToBase64(authTag),
598
- encryptedData: bytesToBase64(ciphertext)
599
- };
600
- }
601
-
602
- /**
603
- * Encrypt data for database storage (hex encoded, colon-separated).
604
- *
605
- * @param {string} text - Plaintext string.
606
- * @param {string} encryptionKey - 64-char hex key.
607
- * @returns {Promise<string>} Format: iv:authTag:encryptedData
608
- */
609
- async function encryptForStorage(text, encryptionKey) {
610
- const iv = randomBytes(16);
611
- const { ciphertext, authTag } = await aesGcmSeal(encryptionKey, iv, utf8$1(text));
612
- return `${bytesToHex(iv)}:${bytesToHex(authTag)}:${bytesToHex(ciphertext)}`;
613
- }
614
-
615
- /**
616
- * Decrypt data from database storage format.
617
- *
618
- * @param {string} encryptedText - Format: iv:authTag:encryptedData
619
- * @param {string} encryptionKey - 64-char hex key.
620
- * @returns {Promise<string>} Plaintext string.
621
- */
622
- async function decryptFromStorage(encryptedText, encryptionKey) {
623
- const [ivHex, authTagHex, encryptedDataHex] = encryptedText.split(':');
624
- const plain = await aesGcmOpen(
625
- encryptionKey,
626
- hexToBytes(ivHex),
627
- hexToBytes(encryptedDataHex),
628
- hexToBytes(authTagHex)
629
- );
630
- return utf8Decode(plain);
631
- }
632
-
633
- /**
634
- * Handler: get-user-key
635
- * ReiStandard SDK v2.0.1
636
- *
637
- * @param {Object} ctx - Server context.
638
- * @returns {{ GET: function }}
639
- */
640
-
641
-
642
- function createGetUserKeyHandler(ctx) {
643
- async function GET(url, headers) {
644
- const effectiveHeaders = headers || url || {};
645
- const tenantResult = await ctx.tenantManager.resolveTenant(effectiveHeaders);
646
- if (!tenantResult.ok) {
647
- return tenantResult.error;
648
- }
649
-
650
- const { masterKey } = tenantResult.context;
651
- const userId = getHeader(effectiveHeaders, 'x-user-id');
652
-
653
- if (!userId) {
654
- return {
655
- status: 400,
656
- body: { success: false, error: { code: 'USER_ID_REQUIRED', message: '缺少用户标识符' } }
657
- };
658
- }
659
-
660
- if (!isValidUUIDv4(userId)) {
661
- return {
662
- status: 400,
663
- body: { success: false, error: { code: 'INVALID_USER_ID_FORMAT', message: 'X-User-Id 必须是 UUID v4 格式' } }
664
- };
665
- }
666
-
667
- return {
668
- status: 200,
669
- body: {
670
- success: true,
671
- data: {
672
- userKey: await deriveUserEncryptionKey(userId, masterKey),
673
- version: 1
674
- }
675
- }
676
- };
677
- }
678
-
679
- return { GET };
680
- }
681
-
682
- /**
683
- * Database error helpers.
684
- */
685
-
686
- /**
687
- * Check whether an error is caused by a unique-constraint violation.
688
- *
689
- * @param {unknown} error
690
- * @returns {boolean}
691
- */
692
- function isUniqueViolation(error) {
693
- if (!error || typeof error !== 'object') return false;
694
-
695
- const code = error.code;
696
- if (code === '23505') return true;
697
-
698
- const message = typeof error.message === 'string' ? error.message.toLowerCase() : '';
699
- return message.includes('duplicate key') || message.includes('unique constraint');
700
- }
701
-
702
- /**
703
- * OpenAI-compatible LLM call for the server-side fire chain.
704
- *
705
- * Extracted from message-processor.js so the agentic fire loop
706
- * (lib/agentic-fire.js) can call the LLM without a circular import.
707
- * The legacy single-shot path (processSingleMessage) and the multi-round
708
- * loop share this one function.
709
- */
710
-
711
- /**
712
- * Call an OpenAI-compatible API.
713
- *
714
- * Returns the full response object alongside the extracted (trimmed)
715
- * `content` string. Callers that only need the text can ignore
716
- * `response`; callers that want `reasoning_content` / `tool_calls`
717
- * read from `response.choices[0].message`.
718
- *
719
- * @param {Object} payload
720
- * @param {{ requireContent?: boolean, timeoutMs?: number }} [options]
721
- * requireContent defaults to true (legacy single-shot behavior:
722
- * throw when the response carries no content). Tool rounds legitimately
723
- * return no content (pure tool_calls), so the agentic loop passes
724
- * `{ requireContent: false }` — same precedent as amsg-instant's
725
- * callLlmRaw.
726
- * timeoutMs defaults to 300000 (the legacy per-call ceiling). The
727
- * agentic loop passes its remaining wall-time budget so a hung LLM
728
- * request cannot outlive totalTimeoutMs.
729
- * @returns {Promise<{ response: unknown, content: string }>}
730
- */
731
- async function callLlm(payload, options = {}) {
732
- const requireContent = options.requireContent !== false;
733
- const timeoutMs = typeof options.timeoutMs === 'number' && Number.isFinite(options.timeoutMs) && options.timeoutMs > 0
734
- ? options.timeoutMs
735
- : 300000;
736
- const normalizedApiUrl = normalizeAiApiUrl(payload.apiUrl);
737
- const requestBody = buildAiRequestBody(payload);
738
-
739
- const aiResponse = await fetch(normalizedApiUrl, {
740
- method: 'POST',
741
- headers: {
742
- 'Content-Type': 'application/json',
743
- 'Authorization': `Bearer ${payload.apiKey}`
744
- },
745
- body: JSON.stringify(requestBody),
746
- signal: AbortSignal.timeout(timeoutMs)
747
- });
748
-
749
- if (!aiResponse.ok) {
750
- if (aiResponse.status === 405) {
751
- throw new Error(
752
- `AI API error: 405 Method Not Allowed. ` +
753
- `apiUrl must point to a full chat endpoint (for example: /chat/completions). ` +
754
- `Received: ${normalizedApiUrl}`
755
- );
756
- }
757
-
758
- throw new Error(
759
- `AI API error: ${aiResponse.status} ${aiResponse.statusText || 'Unknown Error'}. ` +
760
- `Request URL: ${normalizedApiUrl}`
761
- );
762
- }
763
-
764
- const aiData = await aiResponse.json();
765
- const rawContent = aiData?.choices?.[0]?.message?.content;
766
- if (requireContent && (typeof rawContent !== 'string' || !rawContent.trim())) {
767
- throw new Error('AI API error: response missing choices[0].message.content');
768
- }
769
-
770
- return { response: aiData, content: typeof rawContent === 'string' ? rawContent.trim() : '' };
771
- }
772
-
773
- /**
774
- * Build OpenAI-compatible request body.
775
- *
776
- * `max_tokens` is optional:
777
- * - include it only when payload.maxTokens is provided
778
- * - omit it when payload.maxTokens is undefined / null
779
- *
780
- * `tools` / `tool_choice` are optional as well: forwarded only when the
781
- * caller passes a non-empty payload.tools.
782
- *
783
- * @param {Object} payload
784
- * @returns {Object}
785
- */
786
- function buildAiRequestBody(payload) {
787
- // messages mode (added in v2.2.0): forward the caller's OpenAI-style array
788
- // verbatim — same contract as @rei-standard/amsg-instant 0.5.0+. No auto
789
- // role injection, no concatenation back to a single user message. Lets
790
- // the upstream app preserve system / multi-turn context byte-for-byte
791
- // across the schedule-message path.
792
- const llmMessages = Array.isArray(payload.messages) && payload.messages.length > 0
793
- ? payload.messages
794
- : [{ role: 'user', content: payload.completePrompt }];
795
-
796
- const requestBody = {
797
- model: payload.primaryModel,
798
- messages: llmMessages,
799
- };
800
-
801
- // Match the instant package's behavior: only inject default temperature
802
- // for the legacy completePrompt path; messages mode forwards whatever the
803
- // upstream app set (or nothing) so behavior matches their main chat path.
804
- if (payload.temperature !== undefined && payload.temperature !== null) {
805
- requestBody.temperature = payload.temperature;
806
- } else if (!Array.isArray(payload.messages)) {
807
- requestBody.temperature = 0.8;
808
- }
809
-
810
- // tools mode (added in v2.6.0): forward the caller's OpenAI tools array
811
- // verbatim — same philosophy as messages mode above. The agentic loop
812
- // already appends assistant tool_calls + role:'tool' results; this is
813
- // the request-side half of the same protocol. An empty array is treated
814
- // as "no tools" because some OpenAI-compatible relays reject `tools: []`.
815
- if (Array.isArray(payload.tools) && payload.tools.length > 0) {
816
- requestBody.tools = payload.tools;
817
- if (payload.toolChoice !== undefined && payload.toolChoice !== null) {
818
- requestBody.tool_choice = payload.toolChoice;
819
- }
820
- }
821
-
822
- if (payload.maxTokens === undefined || payload.maxTokens === null) {
823
- return requestBody;
824
- }
825
-
826
- if (!Number.isInteger(payload.maxTokens) || payload.maxTokens <= 0) {
827
- throw new Error('Invalid maxTokens: maxTokens must be a positive integer when provided.');
828
- }
829
-
830
- requestBody.max_tokens = payload.maxTokens;
831
- return requestBody;
832
- }
833
-
834
- /**
835
- * Normalize AI API URL for OpenAI-compatible chat endpoints.
836
- *
837
- * **Keep in sync** with `@rei-standard/amsg-instant`'s
838
- * `src/message-processor.js` `normalizeAiApiUrl` — same rules, same
839
- * tests. The two packages share this logic but each carry their own copy
840
- * to avoid an architectural dependency (server should not depend on the
841
- * stateless worker package).
842
- *
843
- * @param {string} apiUrl
844
- * @returns {string}
845
- */
846
- function normalizeAiApiUrl(apiUrl) {
847
- if (typeof apiUrl !== 'string' || !apiUrl.trim()) {
848
- throw new Error(
849
- 'Invalid apiUrl: apiUrl is required. ' +
850
- 'Please provide a chat endpoint URL ' +
851
- '(for example: https://api.openai.com or https://api.openai.com/v1/chat/completions).'
852
- );
853
- }
854
-
855
- const trimmedApiUrl = apiUrl.trim();
856
- let parsedUrl;
857
-
858
- try {
859
- parsedUrl = new URL(trimmedApiUrl);
860
- } catch {
861
- throw new Error(
862
- `Invalid apiUrl: "${apiUrl}". Please provide a valid absolute URL.`
863
- );
864
- }
865
-
866
- let path = parsedUrl.pathname.replace(/\/+$/, '') || '/';
867
-
868
- if (/\/chat\/completions$/.test(path)) ; else if (path === '/') {
869
- // Bare host → assume OpenAI shape.
870
- path = '/v1/chat/completions';
871
- } else if (/\/v\d+$/.test(path)) {
872
- // Path ends in `/v1`, `/v2`, … — caller already versioned the URL.
873
- // Append only `/chat/completions`; never re-add `/v1`.
874
- path = `${path}/chat/completions`;
875
- }
876
- // Any other custom path is left untouched on purpose.
877
-
878
- parsedUrl.pathname = path;
879
- return parsedUrl.toString();
880
- }
881
-
882
- /**
883
- * client_state 大值透明分块(单用户/D1 专用;handlers/client-state.js 与
884
- * lib/agentic-fire.js 的 readState 共用)。
885
- *
886
- * 存储格式(库内部实现细节,不进公开契约):
887
- * - 单条 value ≤ STATE_CHUNK_SLICE_BYTES(200KB)→ 历史单行路径,存储字节级不变。
888
- * - 超过 → 服务端切片跨行:原 (namespace, key) 行的 value 写成纯文本 marker
889
- * (`\u001famsg-chunked\u001fv1\u001f<块数>`),切片本体逐片
890
- * encryptForStorage 后存进保留 namespace(`\u001famsg-chunks\u001f<原ns>`),
891
- * key 为 `<原key>\u001f<序号>`。写入方与读取方(客户端 / hook 作者)完全无感。
892
- * - marker 以 \u001f (Unit Separator) 开头;encryptForStorage 输出是
893
- * `hex:hex:hex`,永远不以控制字符开头,两种行值不会混淆。
894
- *
895
- * 读取完整性:块必须齐全,且每块 updated_at 与根行一致(同一次写入的印记)。
896
- * 不满足(写到一半断了 / 新旧写交错)→ 该 key 视为不存在,读方走自己的兜底,
897
- * 不抛错、不吐半截数据。
898
- *
899
- * 保留字符:namespace / key 里的 C0 控制字符(\u0000-\u001f)为库内部保留,
900
- * handler 对用户输入逐条拒绝。内部分隔符选 \u001f 而不是 NUL,因为 SQLite 的
901
- * LIKE 在 \u0000 处截断 pattern,前缀清理会失效。
902
- */
903
-
904
-
905
- // 每个切片行的 plaintext 上限 = 历史单条上限,沿用已验证的行大小。
906
- const STATE_CHUNK_SLICE_BYTES = 200 * 1024;
907
- // 单条 value 总上限的默认值(工厂配置 maxStateValueBytes 可调)。
908
- const DEFAULT_MAX_STATE_VALUE_BYTES = 5 * 1024 * 1024;
909
- // 用户输入里的保留字符(namespace / key 逐条拒绝)。
910
- const INTERNAL_STATE_CHAR_RE = /[\u0000-\u001f]/;
911
-
912
- const SEP = '\u001f';
913
- const CHUNK_NS_PREFIX = `${SEP}amsg-chunks${SEP}`;
914
- const ROOT_MARKER_PREFIX = `${SEP}amsg-chunked${SEP}v1${SEP}`;
915
-
916
- /** 某个用户 namespace 的切片行所在的保留 namespace。 */
917
- function chunkNamespaceFor(namespace) {
918
- return CHUNK_NS_PREFIX + namespace;
919
- }
920
-
921
- /** 第 index 片的存储 key。 */
922
- function chunkKeyFor(key, index) {
923
- return `${key}${SEP}${index}`;
924
- }
925
-
926
- /** 清理某 key 全部切片行用的 key 前缀。 */
927
- function chunkKeyPrefixFor(key) {
928
- return `${key}${SEP}`;
929
- }
930
-
931
- /** 分块根行的 value(纯文本 marker,不加密——不含用户数据)。 */
932
- function buildChunkedRootValue(chunkCount) {
933
- return `${ROOT_MARKER_PREFIX}${chunkCount}`;
934
- }
935
-
936
- /**
937
- * 严格解析根行 marker。不是 marker(普通密文 / 任意文本 / 计数非正整数)
938
- * → null,调用方按普通单行处理。
939
- *
940
- * @param {unknown} value
941
- * @returns {number | null}
942
- */
943
- function parseChunkedRootCount(value) {
944
- if (typeof value !== 'string' || !value.startsWith(ROOT_MARKER_PREFIX)) return null;
945
- const raw = value.slice(ROOT_MARKER_PREFIX.length);
946
- if (!/^[1-9][0-9]*$/.test(raw)) return null;
947
- return Number(raw);
948
- }
949
-
950
- /**
951
- * 把超限 value 切成 ≤ STATE_CHUNK_SLICE_BYTES 的切片。切点在码点边界
952
- * (chunkReasoningByUtf8Bytes 保证多字节字符 / emoji 代理对不被劈开,
953
- * join('') === 原文)。
954
- *
955
- * @param {string} value
956
- * @returns {string[]}
957
- */
958
- function splitStateValue(value) {
959
- return chunkReasoningByUtf8Bytes(value, STATE_CHUNK_SLICE_BYTES);
960
- }
961
-
962
- /**
963
- * 把一个 namespace 的存储行解析成逻辑条目(GET /client-state 与 readState
964
- * 共用)。普通行解密直读;分块根行按 marker 拼回。切片行查询是惰性的:
965
- * 整个 namespace 没有分块根行时一次都不发。
966
- *
967
- * @param {Array<{ namespace: string, key: string, value: string, updated_at: number }>} rows
968
- * 用户 namespace 的存储行(getClientState 返回值)。
969
- * @param {() => Promise<Array<{ key: string, value: string, updated_at: number }>>} fetchChunkRows
970
- * 取该 namespace 对应保留 namespace 的全部切片行(最多调用一次)。
971
- * @param {(value: string) => Promise<string>} decryptValue
972
- * @returns {Promise<Array<{ namespace: string, key: string, value: string, updatedAt: number }>>}
973
- */
974
- async function resolveClientStateEntries(rows, fetchChunkRows, decryptValue) {
975
- let chunkMap = null;
976
- const loadChunks = async () => {
977
- if (chunkMap === null) {
978
- const chunkRows = await fetchChunkRows();
979
- chunkMap = new Map(chunkRows.map((row) => [row.key, row]));
980
- }
981
- return chunkMap;
982
- };
983
-
984
- const entries = [];
985
- for (const row of rows) {
986
- const count = parseChunkedRootCount(row.value);
987
- if (count === null) {
988
- entries.push({
989
- namespace: row.namespace,
990
- key: row.key,
991
- value: await decryptValue(row.value),
992
- updatedAt: row.updated_at,
993
- });
994
- continue;
995
- }
996
-
997
- const map = await loadChunks();
998
- const chunkRows = [];
999
- let intact = true;
1000
- for (let i = 0; i < count; i++) {
1001
- const chunk = map.get(chunkKeyFor(row.key, i));
1002
- if (!chunk || chunk.updated_at !== row.updated_at) {
1003
- intact = false;
1004
- break;
1005
- }
1006
- chunkRows.push(chunk);
1007
- }
1008
- if (!intact) continue; // 写到一半断了 → 该 key 视为不存在
1009
-
1010
- const parts = await Promise.all(chunkRows.map((chunk) => decryptValue(chunk.value)));
1011
- entries.push({
1012
- namespace: row.namespace,
1013
- key: row.key,
1014
- value: parts.join(''),
1015
- updatedAt: row.updated_at,
1016
- });
1017
- }
1018
- return entries;
1019
- }
1020
-
1021
- /**
1022
- * client_state 的落库实现,所有写入方共用。
1023
- *
1024
- * 写入方有两个:HTTP 的 `PUT /client-state`(客户端批量把本地状态同步上来)
1025
- * 和 fire-time hook 的 `ctx.writeState()`(服务端在组消息时把大内容旁路存下
1026
- * 来,见 lib/agentic-fire.js)。加密、大值分块、旧切片清理都在这里一份,
1027
- * 两条路径写出来的行完全同构,读回时也就不用区分是谁写的。
1028
- *
1029
- * 读回在 lib/state-chunks.js 的 resolveClientStateEntries。
1030
- */
1031
-
1032
-
1033
- const utf8 = new TextEncoder();
1034
-
1035
- // 一次写入的条目上限。客户端一次后台窗口传几十条是设计负载,200 留足余量,
1036
- // 同时给 D1 的一次 batch 定个上界。
1037
- const MAX_STATE_ENTRIES_PER_BATCH = 200;
1038
- // namespace / key 的字符长度上限,HTTP 与 hook 两条写入路径共用一套。
1039
- const MAX_NAMESPACE_CHARS = 128;
1040
- const MAX_KEY_CHARS = 256;
1041
-
1042
- /** value 的 UTF-8 字节数——各处限额判断用的都是这个口径。 */
1043
- function stateValueBytes(value) {
1044
- return utf8.encode(value).length;
1045
- }
1046
-
1047
- /**
1048
- * 把逻辑条目写进 client_state。条目要么是覆盖写(`value` 是字符串),要么是
1049
- * 删除(`value` 为 `null`):
1050
- *
1051
- * - 覆盖写:≤ 200KB 单行存;超过的切片跨行存(见 lib/state-chunks.js)。
1052
- * 同一批里先清掉这个 key 上次写入留下的切片行,所以覆盖成更小的值、或者
1053
- * 块数变少,都不会留下尾巴。
1054
- * - 删除:根行按精确 key 删,切片行按 key 前缀删,一个 key 的数据清干净。
1055
- *
1056
- * 两者都受同一套 last-write-wins 约束:`updatedAt` 比库里已有值旧的写入
1057
- * (或删除)不生效,陈旧批次盖不掉新数据。
1058
- *
1059
- * 条目本身的合法性(namespace / key 字符、value 大小)由调用方先校验好:
1060
- * HTTP handler 逐条拒绝并把原因回给客户端,`writeState()` 直接抛错给 hook。
1061
- *
1062
- * @param {Object} args
1063
- * @param {{ upsertClientState: Function }} args.db
1064
- * @param {string} args.userId
1065
- * @param {CryptoKey|string} args.userKey - 该用户的存储密钥(value 用它加密)
1066
- * @param {Array<{ namespace: string, key: string, value: string|null, updatedAt: number }>} args.entries
1067
- * @returns {Promise<{ upserted: number, skipped: number, deleted: number }>}
1068
- * `upserted` / `skipped` 按逻辑条目计(切片行不计);`deleted` 是请求删除的
1069
- * key 数,不代表这些 key 原本一定存在。
1070
- */
1071
- async function writeClientStateEntries({ db, userId, userKey, entries }) {
1072
- const physicalRows = [];
1073
- const cleanups = [];
1074
- const rootRowIndexes = [];
1075
- let deleted = 0;
1076
-
1077
- for (const entry of entries) {
1078
- // 不管写还是删,都先清掉这个 key 上一次写入留下的切片行(同一批里先删后写)。
1079
- cleanups.push({
1080
- namespace: chunkNamespaceFor(entry.namespace),
1081
- keyPrefix: chunkKeyPrefixFor(entry.key),
1082
- updatedAt: entry.updatedAt,
1083
- });
1084
-
1085
- if (entry.value === null) {
1086
- // 根行按精确 key 删——用前缀会连带删掉同前缀的兄弟 key('note' 删掉 'notes')。
1087
- cleanups.push({
1088
- namespace: entry.namespace,
1089
- key: entry.key,
1090
- updatedAt: entry.updatedAt,
1091
- });
1092
- deleted++;
1093
- continue;
1094
- }
1095
-
1096
- rootRowIndexes.push(physicalRows.length);
1097
- if (stateValueBytes(entry.value) <= STATE_CHUNK_SLICE_BYTES) {
1098
- physicalRows.push({
1099
- namespace: entry.namespace,
1100
- key: entry.key,
1101
- value: await encryptForStorage(entry.value, userKey),
1102
- updatedAt: entry.updatedAt,
1103
- });
1104
- } else {
1105
- const slices = splitStateValue(entry.value);
1106
- physicalRows.push({
1107
- namespace: entry.namespace,
1108
- key: entry.key,
1109
- value: buildChunkedRootValue(slices.length),
1110
- updatedAt: entry.updatedAt,
1111
- });
1112
- const encryptedSlices = await Promise.all(slices.map((slice) => encryptForStorage(slice, userKey)));
1113
- for (let c = 0; c < encryptedSlices.length; c++) {
1114
- physicalRows.push({
1115
- namespace: chunkNamespaceFor(entry.namespace),
1116
- key: chunkKeyFor(entry.key, c),
1117
- value: encryptedSlices[c],
1118
- updatedAt: entry.updatedAt,
1119
- });
1120
- }
1121
- }
1122
- }
1123
-
1124
- if (physicalRows.length === 0 && cleanups.length === 0) {
1125
- return { upserted: 0, skipped: 0, deleted: 0 };
1126
- }
1127
-
1128
- const result = await db.upsertClientState(userId, physicalRows, cleanups);
1129
- let upserted = 0;
1130
- let skipped = 0;
1131
- if (Array.isArray(result.outcomes) && result.outcomes.length === physicalRows.length) {
1132
- // 逻辑计数:一条条目的 upserted/skipped 看它的根行,切片行不计数。
1133
- for (const rootIndex of rootRowIndexes) {
1134
- if (result.outcomes[rootIndex]) upserted++; else skipped++;
1135
- }
1136
- } else {
1137
- // 自定义 adapter 只回老形状 { upserted, skipped } 时按物理行计数兜底。
1138
- upserted = result.upserted;
1139
- skipped = result.skipped;
1140
- }
1141
- return { upserted, skipped, deleted };
1142
- }
1143
-
1144
- /**
1145
- * Server-side agentic fire loop.
1146
- *
1147
- * When the host configures fire-time hooks, an LLM task stops replaying
1148
- * the completePrompt frozen at schedule time. At fire time instead:
1149
- *
1150
- * onBeforeFire(fireCtx) → fresh messages (may read client_state) | { skip: true }
1151
- * → callLlm → onLLMOutput(sessionCtx) → decision
1152
- * ├─ 'finish' → push decision.pushPayloads, done
1153
- * ├─ 'skip-push' → record, done (task counts as delivered)
1154
- * ├─ 'continue' → replace history, next round
1155
- * └─ 'tool-request' → executeToolCalls IN the worker — the client
1156
- * is offline at fire time, so unlike
1157
- * amsg-instant nothing is pushed back for the
1158
- * client to execute — then append the
1159
- * assistant turn + tool results, next round
1160
- *
1161
- * The decision contract is shared with @rei-standard/amsg-instant
1162
- * (assertValidDecision / buildSessionContext live in
1163
- * @rei-standard/amsg-shared), so a classifier written for instant's
1164
- * onLLMOutput drops in unchanged: 'tool-request' may carry `toolCalls`
1165
- * directly, or tool_request pushPayloads that embed them — both work.
1166
- *
1167
- * Credential hiding: hook ctx objects never contain apiKey /
1168
- * pushSubscription / vapid / masterKey (same rationale as instant's
1169
- * SessionContext — a console.log(ctx) in a hook must not leak keys).
1170
- *
1171
- * client_state 读写:fireCtx 和每轮的 sessionCtx 上都挂着 readState /
1172
- * writeState,读到和写出的都是客户端 `GET/PUT /client-state` 那套数据。写口
1173
- * 在 sessionCtx 上也给,是因为「这条内容太大、塞不进 push」往往到工具跑完、
1174
- * 组 pushPayloads 时才知道,那时 onBeforeFire 早已返回。
1175
- *
1176
- * scratch:每次 fire 新建一个普通对象,onBeforeFire 的 fireCtx 与同一次
1177
- * fire 每轮的 sessionCtx 都持有同一个引用,hook 之间借它传工具上下文,
1178
- * 不用再自己维护 Map<sessionId, state>。fire 结束(finish / skip-push /
1179
- * 抛错 / 轮数超限)后随调用栈丢弃;库不读不写、不落库、不打日志。
1180
- *
1181
- * 工具声明:onBeforeFire 的返回值里可以带 `tools`(OpenAI 的 tools 数组,
1182
- * 另有可选的 `toolChoice`),本次 fire 的每一轮 LLM 请求都原样带上它们——
1183
- * 补完那轮模型仍可能再发起调用。库不看 tools 的内容,执行照旧是
1184
- * executeToolCalls 的事。
1185
- *
1186
- * Budget guards, both factory-level (ctx.maxToolIterations /
1187
- * ctx.totalTimeoutMs) and per-fire (onBeforeFire may return
1188
- * { messages, maxToolIterations?, totalTimeoutMs? } to override for one
1189
- * task — e.g. a task the host knows runs slow tools): maxToolIterations
1190
- * caps LLM rounds; totalTimeoutMs is a wall-time ceiling checked before
1191
- * each round so a cron tick can never hang forever. Both exhaustions
1192
- * surface as ordinary task failures → the existing retry / mark-failed
1193
- * semantics apply.
1194
- */
1195
-
1196
-
1197
- const DEFAULT_MAX_TOOL_ITERATIONS = 5;
1198
- const DEFAULT_TOTAL_TIMEOUT_MS = 240_000;
1199
-
1200
- // Same pacing as the legacy path / amsg-instant.
1201
- const SLEEP_BETWEEN_MESSAGES_MS$1 = 1500;
1202
-
1203
- // Task-payload fields that must never reach hook code.
1204
- const CREDENTIAL_PAYLOAD_KEYS = new Set(['apiKey', 'pushSubscription']);
1205
-
1206
- const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1207
-
1208
- /**
1209
- * Does this task need the LLM at fire time? Fixed text never does, so it
1210
- * always stays on the legacy path regardless of hooks.
1211
- *
1212
- * @param {Object} decryptedPayload
1213
- * @returns {boolean}
1214
- */
1215
- function taskNeedsLlm(decryptedPayload) {
1216
- const type = decryptedPayload.messageType;
1217
- if (type === 'prompted' || type === 'auto') return true;
1218
- if (type === 'instant') {
1219
- return !!(decryptedPayload.apiUrl && decryptedPayload.apiKey && decryptedPayload.primaryModel);
1220
- }
1221
- return false;
1222
- }
1223
-
1224
- /**
1225
- * Frozen, credential-free view of the task for hook authors.
1226
- *
1227
- * nextSendAt 是这条任务原本的触发时刻。run-tick 领取任务时写的是 lease_until,
1228
- * next_send_at 那一列不动,所以这里给出去的和库里的是同一个值——宿主拿它当
1229
- * 时间锚点(窗口判断、缓存键)时对得上。
1230
- */
1231
- function buildHookTask(task, decryptedPayload) {
1232
- const safe = {};
1233
- for (const [key, value] of Object.entries(decryptedPayload)) {
1234
- if (!CREDENTIAL_PAYLOAD_KEYS.has(key)) safe[key] = value;
1235
- }
1236
- return Object.freeze({
1237
- ...safe,
1238
- id: task.id ?? null,
1239
- uuid: task.uuid ?? null,
1240
- nextSendAt: task.next_send_at ?? null,
1241
- retryCount: task.retry_count ?? 0,
1242
- });
1243
- }
1244
-
1245
- function normalizeBeforeFireResult(result) {
1246
- if (Array.isArray(result)) {
1247
- return { messages: result };
1248
- }
1249
- if (result && typeof result === 'object' && Array.isArray(result.messages)) {
1250
- return {
1251
- messages: result.messages,
1252
- maxToolIterations: result.maxToolIterations,
1253
- totalTimeoutMs: result.totalTimeoutMs,
1254
- tools: Array.isArray(result.tools) && result.tools.length > 0 ? result.tools : undefined,
1255
- toolChoice: result.toolChoice,
1256
- };
1257
- }
1258
- throw new TypeError(
1259
- 'AGENTIC_BAD_BEFORE_FIRE: onBeforeFire must return ChatMessage[] | { messages, maxToolIterations?, totalTimeoutMs?, tools?, toolChoice? } | { skip: true } | null'
1260
- );
1261
- }
1262
-
1263
- function firstPositiveInt(values, fallback) {
1264
- for (const v of values) {
1265
- if (Number.isInteger(v) && v > 0) return v;
1266
- }
1267
- return fallback;
1268
- }
1269
-
1270
- function firstPositiveNumber(values, fallback) {
1271
- for (const v of values) {
1272
- if (typeof v === 'number' && Number.isFinite(v) && v > 0) return v;
1273
- }
1274
- return fallback;
1275
- }
1276
-
1277
- /**
1278
- * Try the hook-driven fire path for one task.
1279
- *
1280
- * @param {Object} args
1281
- * @param {import('../adapters/interface.js').TaskRow} args.task
1282
- * @param {Object} args.decryptedPayload - decrypted task payload (has credentials; they stop here)
1283
- * @param {string} args.userKey - per-user storage key (for readState decryption)
1284
- * @param {Object} args.ctx - processor ctx ({ db, webpush, vapid, hooks, maxToolIterations, totalTimeoutMs })
1285
- * @returns {Promise<{ handled: false } | { handled: true, result: { success: true, messagesSent: number, status: 'finished'|'skipped', iterations: number } }>}
1286
- * `handled: false` → caller falls back to the legacy frozen-prompt path
1287
- * (onBeforeFire returned null).
1288
- * `onBeforeFire` may also return `{ skip: true }` to complete the fire
1289
- * before the first LLM call → `status: 'skipped', iterations: 0`, same
1290
- * success handling as the post-LLM skip-push path.
1291
- * Failures (timeout / loop exceeded / config errors) throw — the caller's
1292
- * existing error handling turns them into task retry/failure.
1293
- */
1294
- async function runAgenticFire({ task, decryptedPayload, userKey, ctx }) {
1295
- const hooks = ctx.hooks;
1296
- if (typeof hooks.onLLMOutput !== 'function') {
1297
- throw new Error('AGENTIC_CONFIG_ERROR: hooks.onBeforeFire requires hooks.onLLMOutput to classify LLM rounds');
1298
- }
1299
-
1300
- // Injectable seams for tests only (fake clock / no real 1500ms pacing).
1301
- const nowFn = typeof ctx._agenticNow === 'function' ? ctx._agenticNow : Date.now;
1302
- const sleep = typeof ctx._agenticSleep === 'function' ? ctx._agenticSleep : defaultSleep;
1303
-
1304
- const readState = async (namespace) => {
1305
- if (typeof namespace !== 'string' || !namespace.trim()) {
1306
- throw new TypeError('readState(namespace) requires a non-empty string');
1307
- }
1308
- if (!ctx.db || typeof ctx.db.getClientState !== 'function') return [];
1309
- const rows = await ctx.db.getClientState(task.user_id, namespace);
1310
- // 分块存储的值在这里拼回原文(见 state-chunks.js);块不齐全的 key 视为
1311
- // 不存在 —— hook 作者拿到的与客户端写入的一致,永远不会是半截数据。
1312
- return resolveClientStateEntries(
1313
- rows,
1314
- () => ctx.db.getClientState(task.user_id, chunkNamespaceFor(namespace)),
1315
- (value) => decryptFromStorage(value, userKey)
1316
- );
1317
- };
1318
-
1319
- const maxStateValueBytes = Number.isInteger(ctx.maxStateValueBytes) && ctx.maxStateValueBytes > 0
1320
- ? ctx.maxStateValueBytes
1321
- : DEFAULT_MAX_STATE_VALUE_BYTES;
1322
-
1323
- /**
1324
- * writeState(namespace, entries) —— readState 的写入口,落库走的是
1325
- * `PUT /client-state` 那条路径的同一份实现(加密、大值分块、切片清理,见
1326
- * lib/client-state-store.js),所以 hook 写下的东西客户端 `GET /client-state`
1327
- * 能原样读回,反过来也一样。
1328
- *
1329
- * 典型用法是「大内容旁路」:一条 Web Push 的正文只有 4KB 出头(见
1330
- * lib/webpush-webcrypto.js 的 MAX_PUSH_PAYLOAD_BYTES),塞不下的内容先写进
1331
- * client_state,push 里只带一个引用键,客户端上线后按键取回。
1332
- *
1333
- * @param {string} namespace
1334
- * @param {Array<{ key: string, value: string|null, updatedAt?: number }>} entries
1335
- * `value` 是字符串 → 整条覆盖写(不是追加,宿主自己负责序列化);
1336
- * `value` 为 `null` → 删掉这个 key(含它的切片行)。
1337
- * `updatedAt` 默认取当前时刻,语义与客户端同步一致:比库里已有值旧的写入
1338
- * (或删除)不生效,客户端后写的数据不会被这次 fire 盖回去。
1339
- * @returns {Promise<{ upserted: number, skipped: number, deleted: number }>}
1340
- *
1341
- * 谁清、什么时候清:库不做 TTL 也不自动回收,写进去的东西一直在,直到有人
1342
- * 覆盖或删除它。旁路内容建议放在固定的少量 key 上(例如每个角色一个),下次
1343
- * 写同一个 key 直接覆盖,存量天然有上限;一次性的大内容在确认客户端取走后,
1344
- * 用 `{ key, value: null }` 删掉。`DELETE /client-state` 仍然是清空这个用户
1345
- * 全部状态的兜底。
1346
- */
1347
- const writeState = async (namespace, entries) => {
1348
- if (typeof namespace !== 'string' || !namespace.trim()) {
1349
- throw new TypeError('writeState(namespace, entries) requires a non-empty string namespace');
1350
- }
1351
- if (namespace.length > MAX_NAMESPACE_CHARS || INTERNAL_STATE_CHAR_RE.test(namespace)) {
1352
- throw new TypeError(
1353
- `writeState: namespace 必须是 1-${MAX_NAMESPACE_CHARS} 字符且不含控制字符(\\u0000-\\u001f 为库内部保留)`
1354
- );
1355
- }
1356
- if (!Array.isArray(entries)) {
1357
- throw new TypeError('writeState(namespace, entries) requires an array of { key, value }');
1358
- }
1359
- if (entries.length === 0) return { upserted: 0, skipped: 0, deleted: 0 };
1360
- if (entries.length > MAX_STATE_ENTRIES_PER_BATCH) {
1361
- throw new RangeError(`writeState: 单次最多 ${MAX_STATE_ENTRIES_PER_BATCH} 条,收到 ${entries.length} 条`);
1362
- }
1363
- // readState 在适配器不支持时返回空数组(读不到状态,hook 走自己的兜底)。
1364
- // 写不一样:静默成功会让 push 带上一个指向不存在数据的引用键,所以这里报错。
1365
- if (!ctx.db || typeof ctx.db.upsertClientState !== 'function') {
1366
- throw new Error('AGENTIC_STATE_WRITE_UNSUPPORTED: 当前数据库适配器不支持 client_state 写入');
1367
- }
1368
-
1369
- const now = nowFn();
1370
- const normalized = entries.map((entry, index) => {
1371
- if (!entry || typeof entry !== 'object') {
1372
- throw new TypeError(`writeState: entries[${index}] 必须是对象`);
1373
- }
1374
- if (typeof entry.key !== 'string' || !entry.key.trim() || entry.key.length > MAX_KEY_CHARS) {
1375
- throw new TypeError(`writeState: entries[${index}].key 必须是 1-${MAX_KEY_CHARS} 字符的字符串`);
1376
- }
1377
- if (INTERNAL_STATE_CHAR_RE.test(entry.key)) {
1378
- throw new TypeError(`writeState: entries[${index}].key 不能包含控制字符(\\u0000-\\u001f 为库内部保留)`);
1379
- }
1380
- if (entry.value !== null && typeof entry.value !== 'string') {
1381
- throw new TypeError(`writeState: entries[${index}].value 必须是字符串(宿主自行序列化),或 null 表示删除`);
1382
- }
1383
- if (typeof entry.value === 'string') {
1384
- const bytes = stateValueBytes(entry.value);
1385
- if (bytes > maxStateValueBytes) {
1386
- throw new RangeError(`writeState: entries[${index}].value 为 ${bytes} 字节,超过单条上限 ${maxStateValueBytes} 字节`);
1387
- }
1388
- }
1389
- if (entry.updatedAt !== undefined && (!Number.isInteger(entry.updatedAt) || entry.updatedAt <= 0)) {
1390
- throw new TypeError(`writeState: entries[${index}].updatedAt 必须是正整数(epoch 毫秒)`);
1391
- }
1392
- return {
1393
- namespace,
1394
- key: entry.key,
1395
- value: entry.value,
1396
- updatedAt: entry.updatedAt ?? now,
1397
- };
1398
- });
1399
-
1400
- return writeClientStateEntries({ db: ctx.db, userId: task.user_id, userKey, entries: normalized });
1401
- };
1402
-
1403
- // 单次 fire 的宿主便签:onBeforeFire 的 fireCtx 和同一次 fire 每轮的
1404
- // sessionCtx(onLLMOutput / executeToolCalls)拿到同一个对象引用,fire 结束
1405
- // (finish / skip-push / 抛错 / 轮数超限)随调用栈丢弃。库自己不读不写、
1406
- // 不落库、不打日志、不跨 fire 共享 —— 重试产生的新 fire 拿到的是新对象。
1407
- const scratch = {};
1408
-
1409
- const fireCtx = Object.freeze({
1410
- task: buildHookTask(task, decryptedPayload),
1411
- userId: task.user_id,
1412
- readState,
1413
- writeState,
1414
- now: new Date(nowFn()),
1415
- scratch,
1416
- });
1417
-
1418
- const before = await hooks.onBeforeFire(fireCtx);
1419
- if (before == null) return { handled: false };
1420
-
1421
- // Pre-LLM skip: the host judged this fire moot before generation (e.g. the
1422
- // conversation moved on after the task was scheduled). Shaped exactly like
1423
- // the post-LLM 'skipped' result, so run-tick's success handling (delete
1424
- // once-off / advance recurrence) applies unchanged — and zero tokens spent.
1425
- if (typeof before === 'object' && before.skip === true) {
1426
- return { handled: true, result: { success: true, messagesSent: 0, status: 'skipped', iterations: 0 } };
1427
- }
1428
-
1429
- const normalized = normalizeBeforeFireResult(before);
1430
- const maxToolIterations = firstPositiveInt(
1431
- [normalized.maxToolIterations, ctx.maxToolIterations],
1432
- DEFAULT_MAX_TOOL_ITERATIONS
1433
- );
1434
- const totalTimeoutMs = firstPositiveNumber(
1435
- [normalized.totalTimeoutMs, ctx.totalTimeoutMs],
1436
- DEFAULT_TOTAL_TIMEOUT_MS
1437
- );
1438
- const deadline = nowFn() + totalTimeoutMs;
1439
-
1440
- // Same sessionId scheme as the legacy path: pinned to the task id so a
1441
- // retried task reuses the same session and clients can group/dedupe.
1442
- const sessionId = task.id != null ? `sess_task_${task.id}` : `sess_${randomUUID()}`;
1443
- let messages = normalized.messages.slice();
1444
-
1445
- for (let iteration = 0; iteration < maxToolIterations; iteration++) {
1446
- if (nowFn() >= deadline) {
1447
- throw new Error(`AGENTIC_TOTAL_TIMEOUT: fire chain exceeded ${totalTimeoutMs}ms after ${iteration} LLM round(s)`);
1448
- }
1449
-
1450
- // Shrink each round's fetch timeout to the remaining wall-time budget
1451
- // (capped at the legacy 300s single-call ceiling) — a hung LLM request
1452
- // must not outlive totalTimeoutMs waiting for its own 300s abort.
1453
- const roundTimeoutMs = Math.max(1, Math.min(300_000, deadline - nowFn()));
1454
- const { response: llmResponse } = await callLlm(
1455
- {
1456
- ...decryptedPayload,
1457
- messages,
1458
- ...(normalized.tools ? { tools: normalized.tools, toolChoice: normalized.toolChoice } : {}),
1459
- },
1460
- { requireContent: false, timeoutMs: roundTimeoutMs }
1461
- );
1462
-
1463
- const assistantMessage = extractAssistantMessage(llmResponse);
1464
- messages = [...messages, assistantMessage];
1465
-
1466
- // 共享的 SessionContext(与 amsg-instant 同形状)之上,再挂两个状态访问器:
1467
- // 大内容要不要旁路存,往往到工具跑完、组 push 时才知道,而那正是
1468
- // onLLMOutput / executeToolCalls 的位置,onBeforeFire 早就返回了。
1469
- const sessionCtx = Object.freeze({
1470
- ...buildSessionContext({
1471
- sessionId,
1472
- messages,
1473
- llmResponse,
1474
- iteration,
1475
- contactName: decryptedPayload.contactName,
1476
- avatarUrl: decryptedPayload.avatarUrl || undefined,
1477
- charId: decryptedPayload.charId,
1478
- metadata: decryptedPayload.metadata,
1479
- scratch,
1480
- }),
1481
- readState,
1482
- writeState,
1483
- });
1484
-
1485
- const decision = await hooks.onLLMOutput(sessionCtx);
1486
- assertValidDecision(decision, { inlineToolCalls: true });
1487
-
1488
- if (decision.decision === 'continue') {
1489
- messages = decision.nextHistory.slice();
1490
- continue;
1491
- }
1492
-
1493
- if (decision.decision === 'skip-push') {
1494
- return { handled: true, result: { success: true, messagesSent: 0, status: 'skipped', iterations: iteration + 1 } };
1495
- }
1496
-
1497
- if (decision.decision === 'finish') {
1498
- const messagesSent = await sendHookPushPayloads(decision.pushPayloads, decryptedPayload, ctx, sessionId, task, sleep);
1499
- return { handled: true, result: { success: true, messagesSent, status: 'finished', iterations: iteration + 1 } };
1500
- }
1501
-
1502
- // 'tool-request' — execute right here in the worker.
1503
- const toolCalls = extractToolCallsFromDecision(decision);
1504
- if (toolCalls.length === 0) {
1505
- throw new Error('AGENTIC_EMPTY_TOOL_REQUEST: tool-request decision carried no toolCalls (neither decision.toolCalls nor pushPayloads[].toolCalls)');
1506
- }
1507
- if (typeof hooks.executeToolCalls !== 'function') {
1508
- throw new Error('AGENTIC_CONFIG_ERROR: onLLMOutput returned tool-request but hooks.executeToolCalls is not configured');
1509
- }
1510
- if (iteration === maxToolIterations - 1) {
1511
- // No LLM round left to consume the results — executing tools now
1512
- // would only burn external calls. Fall straight to the exceeded error.
1513
- break;
1514
- }
1515
-
1516
- let toolResults;
1517
- try {
1518
- toolResults = await hooks.executeToolCalls(toolCalls, sessionCtx);
1519
- if (!Array.isArray(toolResults)) {
1520
- throw new TypeError('executeToolCalls must resolve to an array of { tool_call_id, role: "tool", content }');
1521
- }
1522
- } catch (error) {
1523
- // Feed the failure back as tool results and let the LLM talk its way
1524
- // out, instead of failing the whole fire.
1525
- toolResults = toolCalls.map((toolCall) => ({
1526
- tool_call_id: toolCall && typeof toolCall === 'object' && typeof toolCall.id === 'string' ? toolCall.id : '',
1527
- role: 'tool',
1528
- content: `Tool execution failed: ${error?.message ?? String(error)}`,
1529
- }));
1530
- }
1531
-
1532
- // Text-protocol classifiers synthesize toolCalls the raw assistant
1533
- // message doesn't carry; stamp them on so the appended role:'tool'
1534
- // results stay valid for OpenAI-compatible APIs. Merge rather than pick
1535
- // one side: when a native tool_call and a synthesized one land in the
1536
- // same round, both need their id on the assistant turn, or the half
1537
- // that's missing leaves an orphan role:'tool' and a strict relay
1538
- // rejects the next round.
1539
- const nativeCalls = Array.isArray(assistantMessage.tool_calls) ? assistantMessage.tool_calls : [];
1540
- const nativeIds = new Set(nativeCalls.map((tc) => tc && tc.id));
1541
- const synthesized = toolCalls.filter((tc) => !nativeIds.has(tc && tc.id));
1542
- const assistantWithTools = synthesized.length === 0
1543
- ? assistantMessage
1544
- : { ...assistantMessage, tool_calls: [...nativeCalls, ...synthesized] };
1545
- messages = [...messages.slice(0, -1), assistantWithTools, ...toolResults];
1546
- }
1547
-
1548
- throw new Error(`AGENTIC_LOOP_EXCEEDED: no finish/skip-push decision within ${maxToolIterations} LLM round(s)`);
1549
- }
1550
-
1551
- /**
1552
- * Deliver the hook's pushPayloads sequentially. Mirrors instant's
1553
- * sendPushesSequentially: force-overwrite messageIndex/totalMessages,
1554
- * stamp missing ids, pace with the same 1500ms spacing. Ids are
1555
- * deterministic per (task, index) so a retried task reuses the same ids
1556
- * and clients can dedupe.
1557
- */
1558
- async function sendHookPushPayloads(pushPayloads, decryptedPayload, ctx, sessionId, task, sleep) {
1559
- if (!ctx.vapid || !ctx.vapid.email || !ctx.vapid.publicKey || !ctx.vapid.privateKey) {
1560
- throw new Error('VAPID configuration missing - push notifications cannot be sent');
1561
- }
1562
- const pushSubscription = decryptedPayload.pushSubscription;
1563
- const total = pushPayloads.length;
1564
- const messageIdBase = task.id != null ? `msg_task_${task.id}` : `msg_${randomUUID()}`;
1565
-
1566
- for (let i = 0; i < total; i++) {
1567
- const push = { ...pushPayloads[i] };
1568
- if (typeof push.messageId !== 'string' || !push.messageId) push.messageId = `${messageIdBase}_hook_${i}`;
1569
- if (typeof push.sessionId !== 'string' || !push.sessionId) push.sessionId = sessionId;
1570
- if (typeof push.timestamp !== 'string' || !push.timestamp) push.timestamp = new Date().toISOString();
1571
- push.messageIndex = i + 1;
1572
- push.totalMessages = total;
1573
-
1574
- await ctx.webpush.sendNotification(pushSubscription, JSON.stringify(push));
1575
- if (i < total - 1) await sleep(SLEEP_BETWEEN_MESSAGES_MS$1);
1576
- }
1577
- return total;
1578
- }
1579
-
1580
- /**
1581
- * Message Processor (SDK version)
1582
- * ReiStandard amsg-server v2.4.0
1583
- *
1584
- * Handles single message content generation and Web Push delivery for
1585
- * scheduled tasks (`fixed` / `prompted` / `auto`) and the
1586
- * in-server instant path (`messageType: 'instant'`).
1587
- *
1588
- * Push wire shape comes from `@rei-standard/amsg-shared`'s
1589
- * discriminated union (`AmsgPush`). The SW (`@rei-standard/amsg-sw`)
1590
- * routes on `messageKind`. Server-driven pushes always carry
1591
- * `source: 'instant'` (for the in-server instant path) or
1592
- * `source: 'scheduled'` (for everything else).
1593
- *
1594
- * v2.4.0: when the LLM response carries non-empty
1595
- * `choices[0].message.reasoning_content`, the processor now emits a
1596
- * standalone `ReasoningPush` **before** the `ContentPush` burst.
1597
- * `messagesSent` in the return value continues to reflect the sentence
1598
- * count only (reasoning is an auxiliary push, not a sentence).
1599
- */
1600
-
1601
-
1602
- const DEFAULT_SPLIT_REGEX = /([。!?!?]+)/;
1603
-
1604
- // Pacing between consecutive Web Push deliveries (reasoning → content, and
1605
- // between content sentences) so the client renders a natural typing cadence.
1606
- // Kept equal to amsg-instant's SLEEP_BETWEEN_MESSAGES_MS default.
1607
- const SLEEP_BETWEEN_MESSAGES_MS = 1500;
1608
-
1609
- /**
1610
- * Split a single chunk by one regex; on no-match return [chunk] so a later
1611
- * regex in a cascade can still take a swing at it.
1612
- */
1613
- function splitOnceByRegex(chunk, regex) {
1614
- const out = chunk
1615
- .split(regex)
1616
- .reduce((acc, part, i, arr) => {
1617
- if (i % 2 === 0 && part.trim()) {
1618
- const punctuation = arr[i + 1] || '';
1619
- acc.push(part.trim() + punctuation);
1620
- }
1621
- return acc;
1622
- }, [])
1623
- .filter(s => s.length > 0);
1624
- return out.length > 0 ? out : [chunk];
1625
- }
1626
-
1627
- /**
1628
- * Sentence splitter for amsg-server's scheduled `splitPattern` feature
1629
- * (see standards §6.1). Server-only: amsg-instant 0.8.0 dropped its
1630
- * request-level `splitPattern`, so there is no instant counterpart to keep
1631
- * in lockstep.
1632
- *
1633
- * @param {string} messageContent
1634
- * @param {string | string[] | null} [splitPattern=null]
1635
- * @returns {string[]}
1636
- */
1637
- function splitMessageIntoSentences(messageContent, splitPattern = null) {
1638
- const sources =
1639
- splitPattern == null ? null :
1640
- Array.isArray(splitPattern) ? splitPattern :
1641
- [splitPattern];
1642
-
1643
- const regexes = (sources && sources.length > 0)
1644
- ? sources.map(s => new RegExp(s))
1645
- : [DEFAULT_SPLIT_REGEX];
1646
-
1647
- let chunks = [messageContent];
1648
- for (const regex of regexes) {
1649
- chunks = chunks.flatMap(c => splitOnceByRegex(c, regex));
1650
- }
1651
-
1652
- return chunks.length > 0 ? chunks : [messageContent];
1653
- }
1654
-
1655
- /**
1656
- * @typedef {Object} ProcessorContext
1657
- * @property {Object} webpush - The web-push module instance (already VAPID-configured).
1658
- * @property {Object} vapid - { email, publicKey, privateKey }
1659
- * @property {import('../adapters/interface.js').DbAdapter} db
1660
- */
1661
-
1662
- /**
1663
- * Process a single database task row: decrypt → generate content → push.
1664
- *
1665
- * @param {import('../adapters/interface.js').TaskRow} task
1666
- * @param {ProcessorContext} ctx
1667
- * @param {string} [providedMasterKey]
1668
- * @returns {Promise<{ success: boolean, messagesSent: number, error?: string }>}
1669
- */
1670
- async function processSingleMessage(task, ctx, providedMasterKey) {
1671
- try {
1672
- const masterKey = providedMasterKey || ctx.masterKey;
1673
- if (!masterKey) {
1674
- return { success: false, messagesSent: 0, error: 'TENANT_MASTER_KEY_MISSING' };
1675
- }
1676
-
1677
- const userKey = await deriveUserEncryptionKey(task.user_id, masterKey);
1678
- const decryptedPayload = JSON.parse(await decryptFromStorage(task.encrypted_payload, userKey));
1679
-
1680
- // Fire-time hooks: when the host configured onBeforeFire and the task
1681
- // needs the LLM, offer the agentic path first. onBeforeFire → null
1682
- // falls straight through to the frozen-prompt chain below, and
1683
- // deployments without hooks never enter this branch — legacy behavior
1684
- // is byte-identical. onBeforeFire → { skip: true } completes the fire
1685
- // here as a zero-push success (no LLM call, no frozen-prompt fallback):
1686
- // use it when the host can tell at fire time the message is moot.
1687
- if (ctx.hooks && typeof ctx.hooks.onBeforeFire === 'function' && taskNeedsLlm(decryptedPayload)) {
1688
- const agentic = await runAgenticFire({ task, decryptedPayload, userKey, ctx });
1689
- if (agentic.handled) return agentic.result;
1690
- }
1691
-
1692
- let messageContent;
1693
- /** @type {unknown} */
1694
- let llmResponse = null;
1695
-
1696
- if (decryptedPayload.messageType === 'fixed') {
1697
- messageContent = decryptedPayload.userMessage;
1698
-
1699
- } else if (decryptedPayload.messageType === 'instant') {
1700
- const hasPrompt = !!decryptedPayload.completePrompt
1701
- || (Array.isArray(decryptedPayload.messages) && decryptedPayload.messages.length > 0);
1702
- if (hasPrompt && decryptedPayload.apiUrl && decryptedPayload.apiKey && decryptedPayload.primaryModel) {
1703
- const aiResult = await callLlm(decryptedPayload);
1704
- messageContent = aiResult.content;
1705
- llmResponse = aiResult.response;
1706
- } else if (decryptedPayload.userMessage) {
1707
- messageContent = decryptedPayload.userMessage;
1708
- } else {
1709
- throw new Error('Invalid instant message: no content source available');
1710
- }
1711
-
1712
- } else if (decryptedPayload.messageType === 'prompted' || decryptedPayload.messageType === 'auto') {
1713
- const aiResult = await callLlm(decryptedPayload);
1714
- messageContent = aiResult.content;
1715
- llmResponse = aiResult.response;
1716
- } else {
1717
- throw new Error('Invalid message configuration: no content source available');
1718
- }
1719
-
1720
- // Auto-extract reasoning BEFORE the sentence split: when reasoning
1721
- // came from the `<think>` fallback inside message.content, the same
1722
- // span is still embedded in messageContent and would otherwise leak
1723
- // as raw markup into ContentPush.
1724
- const reasoning = readReasoningContent(llmResponse);
1725
- if (reasoning) {
1726
- messageContent = stripReasoningTags(messageContent);
1727
- }
1728
-
1729
- // Sentence splitting (mirrors @rei-standard/amsg-instant
1730
- // splitMessageIntoSentences — keep in lockstep; do not drift). Caller may
1731
- // override the default regex via decryptedPayload.splitPattern (string
1732
- // for a single regex, string[] for a cascade). Validation already enforces
1733
- // length cap + RegExp compilability upstream.
1734
- const messages = splitMessageIntoSentences(messageContent, decryptedPayload.splitPattern ?? null);
1735
-
1736
- if (!ctx.vapid.email || !ctx.vapid.publicKey || !ctx.vapid.privateKey) {
1737
- throw new Error('VAPID configuration missing - push notifications cannot be sent');
1738
- }
1739
-
1740
- const pushSubscription = decryptedPayload.pushSubscription;
1741
- // sessionId is shared across the optional ReasoningPush and every
1742
- // ContentPush from this LLM round. Pin it to the task id when
1743
- // available (scheduled tasks) so retries reuse the same id;
1744
- // otherwise mint a UUID.
1745
- const sessionId = task.id != null
1746
- ? `sess_task_${task.id}`
1747
- : `sess_${randomUUID()}`;
1748
- const source = decryptedPayload.messageType === 'instant' ? 'instant' : 'scheduled';
1749
- const messageSubtype = decryptedPayload.messageSubtype || 'chat';
1750
- const avatarUrl = decryptedPayload.avatarUrl || null;
1751
- const metadata = decryptedPayload.metadata || {};
1752
-
1753
- // `messageId` format — deterministic when we have a task.id so a
1754
- // retry produces the same id for the same (task, sentence) pair
1755
- // (downstream dedupers can key on it). Falls back to a UUID for
1756
- // the in-server instant path that has no row id.
1757
- const messageIdBase = task.id != null
1758
- ? `msg_task_${task.id}`
1759
- : `msg_${randomUUID()}_instant`;
1760
-
1761
- // ReasoningPush — auto-emitted before the content burst when the
1762
- // LLM response carried non-empty reasoning_content. `fixed` and
1763
- // explicit-userMessage paths produce no LLM response, so this
1764
- // block is naturally skipped for them (llmResponse stays null).
1765
- if (reasoning) {
1766
- const reasoningPush = buildReasoningPush({
1767
- messageType: decryptedPayload.messageType,
1768
- source,
1769
- messageId: `${messageIdBase}_reasoning`,
1770
- sessionId,
1771
- reasoningContent: reasoning,
1772
- timestamp: new Date().toISOString(),
1773
- title: `来自 ${decryptedPayload.contactName}`,
1774
- contactName: decryptedPayload.contactName,
1775
- avatarUrl,
1776
- messageSubtype,
1777
- metadata,
1778
- });
1779
- await ctx.webpush.sendNotification(pushSubscription, JSON.stringify(reasoningPush));
1780
- await new Promise(resolve => setTimeout(resolve, SLEEP_BETWEEN_MESSAGES_MS));
1781
- }
1782
-
1783
- for (let i = 0; i < messages.length; i++) {
1784
- const contentPush = buildContentPush({
1785
- messageType: decryptedPayload.messageType,
1786
- source,
1787
- messageId: `${messageIdBase}_${i}`,
1788
- sessionId,
1789
- message: messages[i],
1790
- timestamp: new Date().toISOString(),
1791
- title: `来自 ${decryptedPayload.contactName}`,
1792
- contactName: decryptedPayload.contactName,
1793
- avatarUrl,
1794
- messageSubtype,
1795
- messageIndex: i + 1,
1796
- totalMessages: messages.length,
1797
- taskId: task.id || null,
1798
- metadata,
1799
- });
1800
-
1801
- await ctx.webpush.sendNotification(pushSubscription, JSON.stringify(contentPush));
1802
-
1803
- if (i < messages.length - 1) {
1804
- await new Promise(resolve => setTimeout(resolve, SLEEP_BETWEEN_MESSAGES_MS));
1805
- }
1806
- }
1807
-
1808
- return { success: true, messagesSent: messages.length };
1809
-
1810
- } catch (error) {
1811
- return { success: false, messagesSent: 0, error: error.message };
1812
- }
1813
- }
1814
-
1815
- /**
1816
- * Process a single message identified by UUID (used for instant type).
1817
- *
1818
- * @param {string} uuid
1819
- * @param {ProcessorContext} ctx
1820
- * @param {number} [maxRetries=2]
1821
- * @param {string} [userId]
1822
- * @param {string} [providedMasterKey]
1823
- * @returns {Promise<{ success: boolean, messagesSent?: number, retriesUsed?: number, error?: Object }>}
1824
- */
1825
- async function processMessagesByUuid(uuid, ctx, maxRetries = 2, userId, providedMasterKey) {
1826
- let retryCount = 0;
1827
- const masterKey = providedMasterKey || ctx.masterKey;
1828
-
1829
- if (!masterKey) {
1830
- return {
1831
- success: false,
1832
- error: { code: 'TENANT_MASTER_KEY_MISSING', message: '租户主密钥不存在或配置异常' }
1833
- };
1834
- }
1835
-
1836
- while (retryCount <= maxRetries) {
1837
- let task;
1838
- try {
1839
- task = userId
1840
- ? await ctx.db.getTaskByUuid(uuid, userId)
1841
- : await ctx.db.getTaskByUuidOnly(uuid);
1842
- } catch (error) {
1843
- if (retryCount < maxRetries) {
1844
- retryCount++;
1845
- await new Promise(resolve => setTimeout(resolve, 1000 * retryCount));
1846
- continue;
1847
- }
1848
-
1849
- return {
1850
- success: false,
1851
- error: { code: 'INTERNAL_ERROR', message: error.message, retriesAttempted: retryCount }
1852
- };
1853
- }
1854
-
1855
- if (!task) {
1856
- return { success: false, error: { code: 'TASK_NOT_FOUND', message: '任务不存在或已处理' } };
1857
- }
1858
-
1859
- const result = await processSingleMessage(task, ctx, masterKey);
1860
-
1861
- if (!result.success) {
1862
- if (retryCount < maxRetries) {
1863
- retryCount++;
1864
- await new Promise(resolve => setTimeout(resolve, 1000 * retryCount));
1865
- continue;
1866
- }
1867
-
1868
- try {
1869
- await ctx.db.updateTaskById(task.id, { status: 'failed', retry_count: retryCount });
1870
- } catch (_updateError) {
1871
- // best-effort status update; keep original processing error as primary signal
1872
- }
1873
-
1874
- return {
1875
- success: false,
1876
- error: { code: 'PROCESSING_ERROR', message: result.error, retriesAttempted: retryCount }
1877
- };
1878
- }
1879
-
1880
- try {
1881
- await ctx.db.deleteTaskById(task.id);
1882
- } catch (error) {
1883
- try {
1884
- await ctx.db.updateTaskById(task.id, { status: 'sent', retry_count: 0 });
1885
- } catch (_markSentError) {
1886
- // best effort: avoid re-sending if storage mutation partially fails
1887
- }
1888
-
1889
- return {
1890
- success: false,
1891
- error: {
1892
- code: 'POST_SEND_CLEANUP_FAILED',
1893
- message: '消息已发送,但任务清理失败',
1894
- details: { error: error.message }
1895
- }
1896
- };
1897
- }
1898
-
1899
- return { success: true, messagesSent: result.messagesSent, retriesUsed: retryCount };
1900
- }
1901
- }
1902
-
1903
- /**
1904
- * Handler: schedule-message
1905
- * ReiStandard SDK v2.0.1
1906
- *
1907
- * @param {Object} ctx - Server context.
1908
- * @returns {{ POST: function }}
1909
- */
1910
-
1911
-
1912
- function createScheduleMessageHandler(ctx) {
1913
- async function POST(headers, body) {
1914
- const tenantResult = await ctx.tenantManager.resolveTenant(headers);
1915
- if (!tenantResult.ok) {
1916
- return tenantResult.error;
1917
- }
1918
-
1919
- const tenantCtx = tenantResult.context;
1920
- const db = tenantCtx.db;
1921
- const masterKey = tenantCtx.masterKey;
1922
- const isEncrypted = getHeader(headers, 'x-payload-encrypted') === 'true';
1923
- const encryptionVersion = getHeader(headers, 'x-encryption-version');
1924
- const userId = getHeader(headers, 'x-user-id');
1925
-
1926
- if (!isEncrypted) {
1927
- return { status: 400, body: { success: false, error: { code: 'ENCRYPTION_REQUIRED', message: '请求体必须加密' } } };
1928
- }
1929
- if (!userId) {
1930
- return { status: 400, body: { success: false, error: { code: 'USER_ID_REQUIRED', message: '缺少用户标识符' } } };
1931
- }
1932
- if (!isValidUUIDv4(userId)) {
1933
- return { status: 400, body: { success: false, error: { code: 'INVALID_USER_ID_FORMAT', message: 'X-User-Id 必须是 UUID v4 格式' } } };
1934
- }
1935
- if (encryptionVersion !== '1') {
1936
- return { status: 400, body: { success: false, error: { code: 'UNSUPPORTED_ENCRYPTION_VERSION', message: '加密版本不支持' } } };
1937
- }
1938
-
1939
- // Decrypt request body
1940
- const parsedBody = parseEncryptedBody(body);
1941
- if (!parsedBody.ok) {
1942
- return { status: 400, body: { success: false, error: parsedBody.error } };
1943
- }
1944
-
1945
- const encryptedBody = parsedBody.data;
1946
- const userKey = await deriveUserEncryptionKey(userId, masterKey);
1947
-
1948
- let payload;
1949
- try {
1950
- payload = await decryptPayload(encryptedBody, userKey);
1951
- } catch (error) {
1952
- if (error instanceof SyntaxError) {
1953
- return { status: 400, body: { success: false, error: { code: 'INVALID_PAYLOAD_FORMAT', message: '解密后的数据不是有效 JSON' } } };
1954
- }
1955
-
1956
- const message = typeof error.message === 'string' ? error.message : '';
1957
- if (message.includes('auth') || message.includes('Unsupported state')) {
1958
- return { status: 400, body: { success: false, error: { code: 'DECRYPTION_FAILED', message: '请求体解密失败' } } };
1959
- }
1960
-
1961
- return { status: 400, body: { success: false, error: { code: 'DECRYPTION_FAILED', message: '请求体解密失败' } } };
1962
- }
1963
-
1964
- if (!isPlainObject(payload)) {
1965
- return { status: 400, body: { success: false, error: { code: 'INVALID_PAYLOAD_FORMAT', message: '解密后的数据必须是 JSON 对象' } } };
1966
- }
1967
-
1968
- // Validate
1969
- const validationResult = validateScheduleMessagePayload(payload);
1970
- if (!validationResult.valid) {
1971
- return { status: 400, body: { success: false, error: { code: validationResult.errorCode, message: validationResult.errorMessage, details: validationResult.details } } };
1972
- }
1973
-
1974
- const taskUuid = payload.uuid || randomUUID();
1975
-
1976
- const fullTaskData = {
1977
- contactName: payload.contactName,
1978
- avatarUrl: payload.avatarUrl || null,
1979
- messageType: payload.messageType,
1980
- messageSubtype: payload.messageSubtype || 'chat',
1981
- userMessage: payload.userMessage || null,
1982
- firstSendTime: payload.firstSendTime,
1983
- recurrenceType: payload.recurrenceType || 'none',
1984
- apiUrl: payload.apiUrl || null,
1985
- apiKey: payload.apiKey || null,
1986
- primaryModel: payload.primaryModel || null,
1987
- // Prompt is one-of: legacy completePrompt (string) OR messages (OpenAI-
1988
- // style array). Validation has already enforced exactly-one-of, so
1989
- // exactly one of these will be non-null when an AI config is provided.
1990
- completePrompt: payload.completePrompt || null,
1991
- messages: Array.isArray(payload.messages) ? payload.messages : null,
1992
- maxTokens: payload.maxTokens ?? null,
1993
- temperature: payload.temperature ?? null,
1994
- // 0.6.0+: optional caller-provided regex (string or string[]) used by
1995
- // the message processor to chunk LLM output into individual pushes.
1996
- // null → processor falls back to the default /([。!?!?]+)/ regex.
1997
- splitPattern: payload.splitPattern ?? null,
1998
- pushSubscription: payload.pushSubscription,
1999
- metadata: payload.metadata || {}
2000
- };
2001
-
2002
- const encryptedPayload = await encryptForStorage(JSON.stringify(fullTaskData), userKey);
2003
-
2004
- /**
2005
- * In-server instant path. Delivers an instant message through this
2006
- * server's own task queue (create task → process by UUID → delete task).
2007
- * The task is written to the database before processing, so delivery is
2008
- * not tied to the request connection: even if the client disconnects, the
2009
- * row stays and the generation keeps running (and can be retried) for as
2010
- * long as it needs. Use this when you have a database and want long or
2011
- * guaranteed-complete generations with no dropped messages.
2012
- *
2013
- * The stateless alternative is `@rei-standard/amsg-instant`: it streams
2014
- * over SSE with a Web Push backup and needs no database, which makes it a
2015
- * good fit for edge runtimes (e.g. Cloudflare Workers). Its work rides the
2016
- * response connection, so after the client disconnects it only has the
2017
- * platform's brief grace window to finish (≈20-30s observed on Deno
2018
- * Deploy) — ideal for short instant messages that complete quickly.
2019
- */
2020
- // Instant type: check VAPID before creating the task to avoid orphaned rows
2021
- if (payload.messageType === 'instant') {
2022
- if (!ctx.vapid.email || !ctx.vapid.publicKey || !ctx.vapid.privateKey) {
2023
- return {
2024
- status: 500,
2025
- body: {
2026
- success: false,
2027
- error: {
2028
- code: 'VAPID_CONFIG_ERROR',
2029
- message: 'VAPID 配置缺失,无法发送即时消息',
2030
- details: {
2031
- missingKeys: [
2032
- !ctx.vapid.email && 'VAPID_EMAIL',
2033
- !ctx.vapid.publicKey && 'NEXT_PUBLIC_VAPID_PUBLIC_KEY',
2034
- !ctx.vapid.privateKey && 'VAPID_PRIVATE_KEY'
2035
- ].filter(Boolean)
2036
- }
2037
- }
2038
- }
2039
- };
2040
- }
2041
- }
2042
-
2043
- // Insert into database
2044
- let dbResult;
2045
- try {
2046
- dbResult = await db.createTask({
2047
- user_id: userId,
2048
- uuid: taskUuid,
2049
- encrypted_payload: encryptedPayload,
2050
- next_send_at: payload.firstSendTime,
2051
- message_type: payload.messageType
2052
- });
2053
- } catch (error) {
2054
- if (isUniqueViolation(error)) {
2055
- return {
2056
- status: 409,
2057
- body: {
2058
- success: false,
2059
- error: {
2060
- code: 'TASK_UUID_CONFLICT',
2061
- message: '任务 UUID 已存在,请使用新的 uuid 重新提交'
2062
- }
2063
- }
2064
- };
2065
- }
2066
- throw error;
2067
- }
2068
-
2069
- if (!dbResult) {
2070
- return { status: 500, body: { success: false, error: { code: 'TASK_CREATE_FAILED', message: '创建任务失败' } } };
2071
- }
2072
-
2073
- /**
2074
- * In-server instant path. Delivers an instant message through this
2075
- * server's own task queue (create task → process by UUID → delete task).
2076
- * The task is written to the database before processing, so delivery is
2077
- * not tied to the request connection: even if the client disconnects, the
2078
- * row stays and the generation keeps running (and can be retried) for as
2079
- * long as it needs. Use this when you have a database and want long or
2080
- * guaranteed-complete generations with no dropped messages.
2081
- *
2082
- * The stateless alternative is `@rei-standard/amsg-instant`: it streams
2083
- * over SSE with a Web Push backup and needs no database, which makes it a
2084
- * good fit for edge runtimes (e.g. Cloudflare Workers). Its work rides the
2085
- * response connection, so after the client disconnects it only has the
2086
- * platform's brief grace window to finish (≈20-30s observed on Deno
2087
- * Deploy) — ideal for short instant messages that complete quickly.
2088
- */
2089
- // Instant type: send immediately
2090
- if (payload.messageType === 'instant') {
2091
- try {
2092
- const sendResult = await processMessagesByUuid(taskUuid, {
2093
- ...ctx,
2094
- db,
2095
- masterKey
2096
- }, 2, userId, masterKey);
2097
-
2098
- if (!sendResult.success) {
2099
- return { status: 500, body: { success: false, error: { code: 'MESSAGE_SEND_FAILED', message: '消息发送失败', details: sendResult.error } } };
2100
- }
2101
-
2102
- return {
2103
- status: 200,
2104
- body: {
2105
- success: true,
2106
- data: {
2107
- uuid: taskUuid,
2108
- contactName: payload.contactName,
2109
- messagesSent: sendResult.messagesSent,
2110
- sentAt: new Date().toISOString(),
2111
- status: 'sent',
2112
- retriesUsed: sendResult.retriesUsed || 0
2113
- }
2114
- }
2115
- };
2116
- } catch (error) {
2117
- return { status: 500, body: { success: false, error: { code: 'MESSAGE_SEND_FAILED', message: '消息发送失败', details: { error: error.message } } } };
2118
- }
2119
- }
2120
-
2121
- // Non-instant: return scheduled response
2122
- return {
2123
- status: 201,
2124
- body: {
2125
- success: true,
2126
- data: {
2127
- id: dbResult.id,
2128
- uuid: dbResult.uuid,
2129
- contactName: payload.contactName,
2130
- nextSendAt: dbResult.next_send_at,
2131
- status: dbResult.status,
2132
- createdAt: dbResult.created_at
2133
- }
2134
- }
2135
- };
2136
- }
2137
-
2138
- return { POST };
2139
- }
2140
-
2141
- /**
2142
- * Scheduled tick core: fetch due tasks, deliver, reschedule/retry, cleanup.
2143
- * Extracted verbatim from the send-notifications handler so both the HTTP
2144
- * handler (multi-tenant) and the CF scheduled() path (single-user) share it.
2145
- *
2146
- * 每个 tick 是一次独立调用(cron 每分钟一跳,不会因为上一跳还没跑完就跳过
2147
- * 这一跳),而一次投递「组 prompt → 调 LLM → 跑工具 → 推送」可能要几分钟。
2148
- * 所以每条任务开跑前先占位:在这一行的 lease_until 上写下「归我管到什么时
2149
- * 候」,本次投递期间别的 tick 领不走它。占位失败(0 行)说明别人先领走了,
2150
- * 直接跳过。
2151
- *
2152
- * 租约写在自己的列上,next_send_at 全程不动——那一列是用户设的触发时刻,
2153
- * 任务列表要读它、循环任务推进下一次也要拿它当基准。投递收尾时把租约放掉,
2154
- * 这条任务立刻可以被下一跳接手(失败重试的退避只有 2 分钟,比租期短得多,
2155
- * 不放掉的话退避就白设了)。
2156
- *
2157
- * 领了任务的 tick 中途没了(Worker 被回收之类)就没人来放租约,这条任务要
2158
- * 等租约到期才会被接手——这是租约本身的代价,把租期设得比最慢的一次投递长
2159
- * 一点就行。
2160
- *
2161
- * @param {Object} ctx - { db, masterKey, vapid, webpush, claimLeaseMs? }
2162
- * @returns {Promise<Object>} summary { totalTasks, successCount, failedCount, processedAt, executionTime, details }
2163
- */
2164
-
2165
-
2166
- // 占位租期:要盖住最慢的一次投递。老链路单次 LLM 调用上限 300s,agentic 链路
2167
- // 整链默认 240s,再留出推送节奏的余量。
2168
- const DEFAULT_CLAIM_LEASE_MS = 10 * 60 * 1000;
2169
- // 宿主把 totalTimeoutMs 调大时租期跟着抬,别让下一跳在投递还没跑完时就把行
2170
- // 捞回去。(onBeforeFire 里按次放宽的预算这里看不到,那种情况请显式设
2171
- // ctx.claimLeaseMs。)
2172
- const CLAIM_LEASE_MARGIN_MS = 2 * 60 * 1000;
2173
-
2174
- function positiveNumber(value) {
2175
- return (typeof value === 'number' && Number.isFinite(value) && value > 0) ? value : 0;
2176
- }
2177
-
2178
- function resolveClaimLeaseMs(ctx) {
2179
- return positiveNumber(ctx.claimLeaseMs)
2180
- || Math.max(DEFAULT_CLAIM_LEASE_MS, positiveNumber(ctx.totalTimeoutMs) + CLAIM_LEASE_MARGIN_MS);
2181
- }
2182
-
2183
- async function runScheduledTick(ctx) {
2184
- const db = ctx.db;
2185
- const masterKey = ctx.masterKey;
2186
- const claimLeaseMs = resolveClaimLeaseMs(ctx);
2187
-
2188
- const startTime = Date.now();
2189
- const tasks = await db.getPendingTasks(50);
2190
-
2191
- const MAX_CONCURRENT = 8;
2192
- const results = {
2193
- totalTasks: tasks.length,
2194
- successCount: 0,
2195
- failedCount: 0,
2196
- claimSkippedTasks: 0,
2197
- deletedOnceOffTasks: 0,
2198
- updatedRecurringTasks: 0,
2199
- failedTasks: []
2200
- };
2201
-
2202
- // 适配器没实现 claimTask(自定义适配器)→ 退回不占位的老行为:跑得动,只是
2203
- // 超过一跳间隔的慢任务仍可能被下一跳重复触发。
2204
- const supportsClaim = typeof db.claimTask === 'function';
2205
-
2206
- async function claimForThisTick(task) {
2207
- if (!supportsClaim) return true;
2208
- const leaseUntil = new Date(Date.now() + claimLeaseMs).toISOString();
2209
- return !!(await db.claimTask(task.id, task.next_send_at, leaseUntil));
2210
- }
2211
-
2212
- /**
2213
- * 投递收尾时写库,顺手把租约放掉。占位之后的每一次写库都要走这里,漏掉
2214
- * 一条那条任务就得等租约到期才动得了。
2215
- *
2216
- * 没实现 claimTask 的适配器不会有 lease_until 这一列,就别往它的
2217
- * updateTaskById 里塞这个字段了。
2218
- */
2219
- async function updateAndRelease(taskId, fields) {
2220
- return db.updateTaskById(taskId, supportsClaim ? { ...fields, lease_until: null } : fields);
2221
- }
2222
-
2223
- async function handleDeliveryFailure(task, reason) {
2224
- results.failedCount++;
2225
- try {
2226
- if (task.retry_count >= 3) {
2227
- await updateAndRelease(task.id, { status: 'failed' });
2228
- results.failedTasks.push({ taskId: task.id, reason, retryCount: task.retry_count, status: 'permanently_failed' });
2229
- } else {
2230
- const nextRetryTime = new Date(Date.now() + (task.retry_count + 1) * 2 * 60 * 1000);
2231
- await updateAndRelease(task.id, { next_send_at: nextRetryTime.toISOString(), retry_count: task.retry_count + 1 });
2232
- results.failedTasks.push({ taskId: task.id, reason, retryCount: task.retry_count + 1, nextRetryAt: nextRetryTime.toISOString() });
2233
- }
2234
- } catch (updateError) {
2235
- results.failedTasks.push({ taskId: task.id, reason, status: 'retry_update_failed', updateError: updateError.message });
2236
- }
2237
- }
2238
-
2239
- async function handlePostSendPersistenceFailure(task, reason) {
2240
- results.failedCount++;
2241
- let markedSent = false;
2242
- try {
2243
- await updateAndRelease(task.id, { status: 'sent', retry_count: 0 });
2244
- markedSent = true;
2245
- } catch (_markSentError) {
2246
- markedSent = false;
2247
- }
2248
- results.failedTasks.push({
2249
- taskId: task.id,
2250
- reason,
2251
- status: markedSent ? 'post_send_cleanup_failed_marked_sent' : 'post_send_cleanup_failed',
2252
- messageDelivered: true
2253
- });
2254
- }
2255
-
2256
- async function processTask(task) {
2257
- let claimed;
2258
- try {
2259
- claimed = await claimForThisTick(task);
2260
- } catch (error) {
2261
- // 占位这一步就出错,说明库有问题——此时不知道别人有没有在跑这条,宁可
2262
- // 不发。行还是 pending,下一跳会重新捞。
2263
- results.failedCount++;
2264
- results.failedTasks.push({ taskId: task.id, reason: error.message || '任务占位失败', status: 'claim_failed' });
2265
- return;
2266
- }
2267
- if (!claimed) {
2268
- // 另一个 tick 已经领走了这条,本次什么都不做。
2269
- results.claimSkippedTasks++;
2270
- return;
2271
- }
2272
-
2273
- let sendResult;
2274
- try {
2275
- sendResult = await processSingleMessage(task, { ...ctx, db, masterKey }, masterKey);
2276
- } catch (error) {
2277
- await handleDeliveryFailure(task, error.message || '消息发送失败');
2278
- return;
2279
- }
2280
-
2281
- if (!sendResult.success) {
2282
- await handleDeliveryFailure(task, sendResult.error || '消息发送失败');
2283
- return;
2284
- }
2285
-
2286
- try {
2287
- const userKey = await deriveUserEncryptionKey(task.user_id, masterKey);
2288
- const decryptedPayload = JSON.parse(await decryptFromStorage(task.encrypted_payload, userKey));
2289
-
2290
- if (decryptedPayload.recurrenceType === 'none') {
2291
- await db.deleteTaskById(task.id);
2292
- results.deletedOnceOffTasks++;
2293
- } else {
2294
- let nextSendAt;
2295
- // 以这条任务原本的触发时刻为基准往后推。
2296
- const currentSendAt = new Date(task.next_send_at);
2297
- if (decryptedPayload.recurrenceType === 'daily') {
2298
- nextSendAt = new Date(currentSendAt.getTime() + 24 * 60 * 60 * 1000);
2299
- } else if (decryptedPayload.recurrenceType === 'weekly') {
2300
- nextSendAt = new Date(currentSendAt.getTime() + 7 * 24 * 60 * 60 * 1000);
2301
- }
2302
- await updateAndRelease(task.id, { next_send_at: nextSendAt.toISOString(), retry_count: 0 });
2303
- results.updatedRecurringTasks++;
2304
- }
2305
-
2306
- results.successCount++;
2307
- } catch (error) {
2308
- await handlePostSendPersistenceFailure(task, error.message || '发送后状态更新失败');
2309
- }
2310
- }
2311
-
2312
- const taskQueue = [...tasks];
2313
- const processing = [];
2314
-
2315
- while (taskQueue.length > 0 || processing.length > 0) {
2316
- while (processing.length < MAX_CONCURRENT && taskQueue.length > 0) {
2317
- const task = taskQueue.shift();
2318
- const promise = processTask(task);
2319
- processing.push(promise);
2320
- promise.finally(() => {
2321
- const index = processing.indexOf(promise);
2322
- if (index > -1) processing.splice(index, 1);
2323
- });
2324
- }
2325
- if (processing.length > 0) {
2326
- await Promise.race(processing);
2327
- }
2328
- }
2329
-
2330
- await db.cleanupOldTasks(7);
2331
-
2332
- const executionTime = Date.now() - startTime;
2333
-
2334
- return {
2335
- totalTasks: results.totalTasks,
2336
- successCount: results.successCount,
2337
- failedCount: results.failedCount,
2338
- processedAt: new Date().toISOString(),
2339
- executionTime,
2340
- details: {
2341
- claimSkippedTasks: results.claimSkippedTasks,
2342
- deletedOnceOffTasks: results.deletedOnceOffTasks,
2343
- updatedRecurringTasks: results.updatedRecurringTasks,
2344
- failedTasks: results.failedTasks
2345
- }
2346
- };
2347
- }
2348
-
2349
- /**
2350
- * Handler: update-message
2351
- * ReiStandard SDK v2.0.1
2352
- *
2353
- * @param {Object} ctx - Server context.
2354
- * @returns {{ PUT: function }}
2355
- */
2356
-
2357
-
2358
- function createUpdateMessageHandler(ctx) {
2359
- async function PUT(url, headers, body) {
2360
- const tenantResult = await ctx.tenantManager.resolveTenant(headers, { url });
2361
- if (!tenantResult.ok) {
2362
- return tenantResult.error;
2363
- }
2364
-
2365
- const tenantCtx = tenantResult.context;
2366
- const db = tenantCtx.db;
2367
- const masterKey = tenantCtx.masterKey;
2368
- const u = new URL(url, 'https://dummy');
2369
- const taskUuid = u.searchParams.get('id');
2370
-
2371
- if (!taskUuid) {
2372
- return { status: 400, body: { success: false, error: { code: 'TASK_ID_REQUIRED', message: '缺少任务ID' } } };
2373
- }
2374
-
2375
- const userId = getHeader(headers, 'x-user-id');
2376
- if (!userId) {
2377
- return { status: 400, body: { success: false, error: { code: 'USER_ID_REQUIRED', message: '缺少用户标识符' } } };
2378
- }
2379
- if (!isValidUUIDv4(userId)) {
2380
- return { status: 400, body: { success: false, error: { code: 'INVALID_USER_ID_FORMAT', message: 'X-User-Id 必须是 UUID v4 格式' } } };
2381
- }
2382
-
2383
- const isEncrypted = getHeader(headers, 'x-payload-encrypted') === 'true';
2384
- const encryptionVersion = getHeader(headers, 'x-encryption-version');
2385
-
2386
- if (!isEncrypted) {
2387
- return { status: 400, body: { success: false, error: { code: 'ENCRYPTION_REQUIRED', message: '请求体必须加密' } } };
2388
- }
2389
-
2390
- if (encryptionVersion !== '1') {
2391
- return { status: 400, body: { success: false, error: { code: 'UNSUPPORTED_ENCRYPTION_VERSION', message: '加密版本不支持' } } };
2392
- }
2393
-
2394
- const parsedBody = parseEncryptedBody(body);
2395
- if (!parsedBody.ok) {
2396
- return { status: 400, body: { success: false, error: parsedBody.error } };
2397
- }
2398
-
2399
- const encryptedBody = parsedBody.data;
2400
- const userKey = await deriveUserEncryptionKey(userId, masterKey);
2401
- let updates;
2402
-
2403
- try {
2404
- updates = await decryptPayload(encryptedBody, userKey);
2405
- } catch (_error) {
2406
- return { status: 400, body: { success: false, error: { code: 'DECRYPTION_FAILED', message: '请求体解密失败' } } };
2407
- }
2408
-
2409
- if (!isPlainObject(updates)) {
2410
- return { status: 400, body: { success: false, error: { code: 'INVALID_UPDATE_DATA', message: '更新数据格式错误' } } };
2411
- }
2412
-
2413
- if (updates.nextSendAt && !isValidISO8601(updates.nextSendAt)) {
2414
- return { status: 400, body: { success: false, error: { code: 'INVALID_UPDATE_DATA', message: '更新数据格式错误', details: { invalidFields: ['nextSendAt'] } } } };
2415
- }
2416
-
2417
- if (updates.recurrenceType && !['none', 'daily', 'weekly'].includes(updates.recurrenceType)) {
2418
- return { status: 400, body: { success: false, error: { code: 'INVALID_UPDATE_DATA', message: '更新数据格式错误', details: { invalidFields: ['recurrenceType'] } } } };
2419
- }
2420
-
2421
- if (
2422
- Object.prototype.hasOwnProperty.call(updates, 'maxTokens') &&
2423
- updates.maxTokens !== null &&
2424
- (!Number.isInteger(updates.maxTokens) || updates.maxTokens <= 0)
2425
- ) {
2426
- return { status: 400, body: { success: false, error: { code: 'INVALID_UPDATE_DATA', message: '更新数据格式错误', details: { invalidFields: ['maxTokens'] } } } };
2427
- }
2428
-
2429
- // Reject updates that try to set both completePrompt and messages at
2430
- // once. We don't enforce one-of-required here (callers may patch other
2431
- // fields), but the two prompt sources are mutually exclusive and must
2432
- // stay that way in storage too.
2433
- if (
2434
- updates.completePrompt &&
2435
- updates.messages !== undefined && updates.messages !== null
2436
- ) {
2437
- return { status: 400, body: { success: false, error: { code: 'INVALID_UPDATE_DATA', message: 'completePrompt 与 messages 不能同时更新(二选一)', details: { invalidFields: ['completePrompt', 'messages'] } } } };
2438
- }
2439
- if (updates.messages !== undefined && updates.messages !== null) {
2440
- const msgErr = validateLlmMessagesArray(updates.messages);
2441
- if (msgErr) {
2442
- return { status: 400, body: { success: false, error: { code: 'INVALID_UPDATE_DATA', message: msgErr, details: { invalidFields: ['messages'] } } } };
2443
- }
2444
- }
2445
- if (
2446
- Object.prototype.hasOwnProperty.call(updates, 'temperature') &&
2447
- updates.temperature !== null &&
2448
- (typeof updates.temperature !== 'number' || !Number.isFinite(updates.temperature))
2449
- ) {
2450
- return { status: 400, body: { success: false, error: { code: 'INVALID_UPDATE_DATA', message: '更新数据格式错误', details: { invalidFields: ['temperature'] } } } };
2451
- }
2452
- if (Object.prototype.hasOwnProperty.call(updates, 'splitPattern')) {
2453
- const splitErr = validateSplitPattern(updates.splitPattern);
2454
- if (splitErr) {
2455
- return { status: 400, body: { success: false, error: { code: 'INVALID_UPDATE_DATA', message: splitErr, details: { invalidFields: ['splitPattern'] } } } };
2456
- }
2457
- }
2458
- if (Object.prototype.hasOwnProperty.call(updates, 'avatarUrl')) {
2459
- const avatarErr = validateAvatarUrl(updates.avatarUrl);
2460
- if (avatarErr) {
2461
- // Soft-strip: drop the bad avatarUrl from the patch (keeps the
2462
- // existing stored avatar untouched) and continue applying the rest
2463
- // of the update. See standards §6.2.
2464
- console.warn('[amsg-server] update-message avatarUrl 不合法,已忽略:', avatarErr);
2465
- delete updates.avatarUrl;
2466
- }
2467
- }
2468
-
2469
- // Fetch existing task
2470
- const existingTask = await db.getTaskByUuid(taskUuid, userId);
2471
-
2472
- if (!existingTask) {
2473
- const taskStatus = await db.getTaskStatus(taskUuid, userId);
2474
- if (!taskStatus) {
2475
- return { status: 404, body: { success: false, error: { code: 'TASK_NOT_FOUND', message: '指定的任务不存在或已被删除' } } };
2476
- }
2477
- return { status: 409, body: { success: false, error: { code: 'TASK_ALREADY_COMPLETED', message: '任务已完成或已失败,无法更新' } } };
2478
- }
2479
-
2480
- const existingData = JSON.parse(await decryptFromStorage(existingTask.encrypted_payload, userKey));
2481
-
2482
- // When the caller switches prompt source (completePrompt ↔ messages),
2483
- // null out the other so storage stays one-of (matches schedule-message
2484
- // shape and prevents buildAiRequestBody from accidentally seeing both).
2485
- const promptUpdates = {};
2486
- if (updates.completePrompt) {
2487
- promptUpdates.completePrompt = updates.completePrompt;
2488
- promptUpdates.messages = null;
2489
- } else if (updates.messages !== undefined && updates.messages !== null) {
2490
- promptUpdates.messages = updates.messages;
2491
- promptUpdates.completePrompt = null;
2492
- }
2493
-
2494
- const updatedData = {
2495
- ...existingData,
2496
- ...promptUpdates,
2497
- ...(updates.userMessage && { userMessage: updates.userMessage }),
2498
- ...(updates.recurrenceType && { recurrenceType: updates.recurrenceType }),
2499
- ...(updates.avatarUrl && { avatarUrl: updates.avatarUrl }),
2500
- ...(updates.metadata && { metadata: updates.metadata }),
2501
- ...(Object.prototype.hasOwnProperty.call(updates, 'maxTokens') && { maxTokens: updates.maxTokens ?? null }),
2502
- ...(Object.prototype.hasOwnProperty.call(updates, 'temperature') && { temperature: updates.temperature ?? null }),
2503
- // splitPattern: hasOwnProperty so that explicit `null` (= revert to
2504
- // default) doesn't get swallowed by truthy-spread the way the optional
2505
- // string fields above are.
2506
- ...(Object.prototype.hasOwnProperty.call(updates, 'splitPattern') && { splitPattern: updates.splitPattern ?? null })
2507
- };
2508
-
2509
- const encryptedPayload = await encryptForStorage(JSON.stringify(updatedData), userKey);
2510
- const extraFields = updates.nextSendAt ? { next_send_at: updates.nextSendAt } : undefined;
2511
-
2512
- const result = await db.updateTaskByUuid(taskUuid, userId, encryptedPayload, extraFields);
2513
-
2514
- if (!result) {
2515
- return { status: 409, body: { success: false, error: { code: 'UPDATE_CONFLICT', message: '任务更新失败,任务可能已被修改或删除' } } };
2516
- }
2517
-
2518
- return {
2519
- status: 200,
2520
- body: {
2521
- success: true,
2522
- data: {
2523
- uuid: taskUuid,
2524
- updatedFields: Object.keys(updates),
2525
- updatedAt: result.updated_at
2526
- }
2527
- }
2528
- };
2529
- }
2530
-
2531
- return { PUT };
2532
- }
2533
-
2534
- /**
2535
- * Handler: cancel-message
2536
- * ReiStandard SDK v2.0.1
2537
- *
2538
- * @param {Object} ctx - Server context.
2539
- * @returns {{ DELETE: function }}
2540
- */
2541
-
2542
-
2543
- function createCancelMessageHandler(ctx) {
2544
- async function DELETE(url, headers) {
2545
- const tenantResult = await ctx.tenantManager.resolveTenant(headers, { url });
2546
- if (!tenantResult.ok) {
2547
- return tenantResult.error;
2548
- }
2549
-
2550
- const db = tenantResult.context.db;
2551
- const u = new URL(url, 'https://dummy');
2552
- const taskUuid = u.searchParams.get('id');
2553
-
2554
- if (!taskUuid) {
2555
- return { status: 400, body: { success: false, error: { code: 'TASK_ID_REQUIRED', message: '缺少任务ID' } } };
2556
- }
2557
-
2558
- const userId = getHeader(headers, 'x-user-id');
2559
- if (!userId) {
2560
- return { status: 400, body: { success: false, error: { code: 'USER_ID_REQUIRED', message: '缺少用户标识符' } } };
2561
- }
2562
- if (!isValidUUIDv4(userId)) {
2563
- return { status: 400, body: { success: false, error: { code: 'INVALID_USER_ID_FORMAT', message: 'X-User-Id 必须是 UUID v4 格式' } } };
2564
- }
2565
-
2566
- const deleted = await db.deleteTaskByUuid(taskUuid, userId);
2567
-
2568
- if (!deleted) {
2569
- return {
2570
- status: 404,
2571
- body: { success: false, error: { code: 'TASK_NOT_FOUND', message: '指定的任务不存在或已被删除' } }
2572
- };
2573
- }
2574
-
2575
- return {
2576
- status: 200,
2577
- body: {
2578
- success: true,
2579
- data: { uuid: taskUuid, message: '任务已成功取消', deletedAt: new Date().toISOString() }
2580
- }
2581
- };
2582
- }
2583
-
2584
- return { DELETE };
2585
- }
2586
-
2587
- /**
2588
- * Handler: messages
2589
- * ReiStandard SDK v2.0.1
2590
- *
2591
- * @param {Object} ctx - Server context.
2592
- * @returns {{ GET: function }}
2593
- */
2594
-
2595
-
2596
- function createMessagesHandler(ctx) {
2597
- async function GET(url, headers) {
2598
- const tenantResult = await ctx.tenantManager.resolveTenant(headers, { url });
2599
- if (!tenantResult.ok) {
2600
- return tenantResult.error;
2601
- }
2602
-
2603
- const tenantCtx = tenantResult.context;
2604
- const db = tenantCtx.db;
2605
- const masterKey = tenantCtx.masterKey;
2606
- const userId = getHeader(headers, 'x-user-id');
2607
-
2608
- if (!userId) {
2609
- return {
2610
- status: 400,
2611
- body: { success: false, error: { code: 'USER_ID_REQUIRED', message: '必须提供 X-User-Id 请求头' } }
2612
- };
2613
- }
2614
- if (!isValidUUIDv4(userId)) {
2615
- return {
2616
- status: 400,
2617
- body: { success: false, error: { code: 'INVALID_USER_ID_FORMAT', message: 'X-User-Id 必须是 UUID v4 格式' } }
2618
- };
2619
- }
2620
-
2621
- const u = new URL(url, 'https://dummy');
2622
- const status = u.searchParams.get('status') || 'all';
2623
- const limit = Math.min(parseInt(u.searchParams.get('limit') || '20', 10), 100);
2624
- const offset = parseInt(u.searchParams.get('offset') || '0', 10);
2625
-
2626
- if (isNaN(limit) || limit < 1) {
2627
- return { status: 400, body: { success: false, error: { code: 'INVALID_PARAMETERS', message: 'limit 参数无效,必须为正整数' } } };
2628
- }
2629
-
2630
- if (isNaN(offset) || offset < 0) {
2631
- return { status: 400, body: { success: false, error: { code: 'INVALID_PARAMETERS', message: 'offset 参数无效,必须为非负整数' } } };
2632
- }
2633
-
2634
- const { tasks, total } = await db.listTasks(userId, { status, limit, offset });
2635
-
2636
- const userKey = await deriveUserEncryptionKey(userId, masterKey);
2637
-
2638
- const decryptedTasks = await Promise.all(tasks.map(async task => {
2639
- const decrypted = JSON.parse(await decryptFromStorage(task.encrypted_payload, userKey));
2640
- return {
2641
- id: task.id,
2642
- uuid: task.uuid,
2643
- contactName: decrypted.contactName,
2644
- messageType: task.message_type,
2645
- messageSubtype: decrypted.messageSubtype,
2646
- nextSendAt: task.next_send_at,
2647
- recurrenceType: decrypted.recurrenceType,
2648
- status: task.status,
2649
- retryCount: task.retry_count,
2650
- createdAt: task.created_at,
2651
- updatedAt: task.updated_at,
2652
- // Character ownership / client-side task identity, pulled from the
2653
- // scheduling host's metadata so it can filter tasks by character
2654
- // (contactName can collide across characters). Only these two
2655
- // metadata fields are surfaced — the rest of metadata may hold
2656
- // host-private data and stays server-side. Absent → null.
2657
- charId: decrypted.metadata?.charId ?? null,
2658
- clientTaskId: decrypted.metadata?.amsgClientTaskId ?? null
2659
- };
2660
- }));
2661
-
2662
- const responsePayload = {
2663
- tasks: decryptedTasks,
2664
- pagination: { total, limit, offset, hasMore: offset + limit < total }
2665
- };
2666
- const encryptedResponse = await encryptPayload(responsePayload, userKey);
2667
-
2668
- return {
2669
- status: 200,
2670
- body: {
2671
- success: true,
2672
- encrypted: true,
2673
- version: 1,
2674
- data: encryptedResponse
2675
- }
2676
- };
2677
- }
2678
-
2679
- return { GET };
2680
- }
2681
-
2682
- /**
2683
- * SQLite (Cloudflare D1) dialect schema for scheduled_messages.
2684
- *
2685
- * Differences from the Postgres schema (adapters/schema.js):
2686
- * - id: INTEGER PRIMARY KEY AUTOINCREMENT (vs SERIAL)
2687
- * - timestamps stored as TEXT ISO8601 UTC (vs TIMESTAMP WITH TIME ZONE)
2688
- * - no NOW()/DEFAULT; the adapter always writes timestamps explicitly
2689
- * - retry_count is NOT NULL here (Postgres omits NOT NULL); every write path
2690
- * sets it explicitly, so the tighter constraint just documents that intent
2691
- * Partial indexes and CHECK constraints are native to SQLite, so they carry over.
2692
- * Index entries mirror the Postgres INDEXES shape ({ name, sql, description,
2693
- * critical }) so both adapters' initSchema() return the same index metadata.
2694
- */
2695
-
2696
- const SQLITE_TABLE_SQL = `
2697
- CREATE TABLE IF NOT EXISTS scheduled_messages (
2698
- id INTEGER PRIMARY KEY AUTOINCREMENT,
2699
- user_id TEXT NOT NULL,
2700
- uuid TEXT,
2701
- encrypted_payload TEXT NOT NULL,
2702
- message_type TEXT NOT NULL CHECK (message_type IN ('fixed', 'prompted', 'auto', 'instant')),
2703
- next_send_at TEXT NOT NULL,
2704
- lease_until TEXT,
2705
- status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'failed')),
2706
- retry_count INTEGER NOT NULL DEFAULT 0,
2707
- created_at TEXT NOT NULL,
2708
- updated_at TEXT NOT NULL
2709
- )
2710
- `;
2711
-
2712
- /**
2713
- * 建表语句用的是 CREATE TABLE IF NOT EXISTS,已经存在的表不会被改动,所以
2714
- * 后加的列要单独补。initSchema 每次都会跑一遍,列已经在了就跳过。
2715
- */
2716
- const SQLITE_MIGRATIONS = [
2717
- {
2718
- name: 'add_lease_until',
2719
- sql: 'ALTER TABLE scheduled_messages ADD COLUMN lease_until TEXT',
2720
- description: 'Task claim lease (2.6.0)'
2721
- }
2722
- ];
2723
-
2724
- const SQLITE_INDEXES = [
2725
- {
2726
- name: 'idx_pending_tasks_optimized',
2727
- sql: `CREATE INDEX IF NOT EXISTS idx_pending_tasks_optimized
2728
- ON scheduled_messages (status, next_send_at, id, retry_count)
2729
- WHERE status = 'pending'`,
2730
- description: 'Main query index (Cron Job finds pending tasks)',
2731
- critical: false
2732
- },
2733
- {
2734
- name: 'idx_cleanup_completed',
2735
- sql: `CREATE INDEX IF NOT EXISTS idx_cleanup_completed
2736
- ON scheduled_messages (status, updated_at)
2737
- WHERE status IN ('sent', 'failed')`,
2738
- description: 'Cleanup query index',
2739
- critical: false
2740
- },
2741
- {
2742
- name: 'idx_failed_retry',
2743
- sql: `CREATE INDEX IF NOT EXISTS idx_failed_retry
2744
- ON scheduled_messages (status, retry_count, next_send_at)
2745
- WHERE status = 'failed' AND retry_count < 3`,
2746
- description: 'Failed retry index',
2747
- critical: false
2748
- },
2749
- {
2750
- name: 'idx_user_id',
2751
- sql: `CREATE INDEX IF NOT EXISTS idx_user_id
2752
- ON scheduled_messages (user_id)`,
2753
- description: 'User task query index',
2754
- critical: false
2755
- },
2756
- {
2757
- name: 'uidx_uuid',
2758
- sql: `CREATE UNIQUE INDEX IF NOT EXISTS uidx_uuid
2759
- ON scheduled_messages (uuid)
2760
- WHERE uuid IS NOT NULL`,
2761
- description: 'UUID uniqueness guard',
2762
- critical: true
2763
- }
2764
- ];
2765
-
2766
- // client_state: cloud mirror of client-side state for the single-user
2767
- // deployment. One live copy per (user, namespace, key) — not per-task
2768
- // snapshots. The client is the only writer (batch upsert, last-write-wins
2769
- // on updated_at); fire-time hooks are the reader. `value` holds
2770
- // encryptForStorage ciphertext. `updated_at` is a caller-supplied epoch-ms
2771
- // INTEGER (unlike scheduled_messages' ISO TEXT) so conflict resolution
2772
- // compares without parsing. Single-user/SQLite only — no Postgres mirror.
2773
- const CLIENT_STATE_TABLE_SQL = `
2774
- CREATE TABLE IF NOT EXISTS client_state (
2775
- user_id TEXT NOT NULL,
2776
- namespace TEXT NOT NULL,
2777
- key TEXT NOT NULL,
2778
- value TEXT NOT NULL,
2779
- updated_at INTEGER NOT NULL,
2780
- PRIMARY KEY (user_id, namespace, key)
2781
- )
2782
- `;
2783
-
2784
- /**
2785
- * Cloudflare D1 (SQLite) Database Adapter.
2786
- *
2787
- * @implements {import('./interface.js').DbAdapter}
2788
- *
2789
- * Timestamps are stored as ISO8601 UTC TEXT. Every timestamp is normalized
2790
- * with new Date(v).toISOString() before store/compare so lexical ordering
2791
- * equals chronological ordering (mixed offsets like +08:00 vs Z are unified).
2792
- */
2793
-
2794
-
2795
- // Update methods build a dynamic SET clause from object keys. Callers pass only
2796
- // hardcoded column names today, but enforcing a whitelist keeps a future caller
2797
- // from ever turning a caller-supplied key into interpolated SQL.
2798
- const UPDATABLE_COLUMNS = new Set([
2799
- 'user_id', 'uuid', 'encrypted_payload', 'message_type',
2800
- 'next_send_at', 'lease_until', 'status', 'retry_count', 'created_at', 'updated_at'
2801
- ]);
2802
-
2803
- // LIKE 前缀转义:用户 key 里的 % _ \ 不能变成通配符/转义符。
2804
- function escapeLikePrefix(prefix) {
2805
- return prefix.replace(/[\\%_]/g, (ch) => `\\${ch}`);
2806
- }
2807
-
2808
- class D1Adapter {
2809
- /** @param {{ prepare: (sql: string) => any }} db - Cloudflare D1 binding */
2810
- constructor(db) {
2811
- /** @private */
2812
- this._db = db;
2813
- }
2814
-
2815
- /** @private */
2816
- _now() {
2817
- return new Date().toISOString();
2818
- }
2819
-
2820
- /** @private */
2821
- _iso(value) {
2822
- const d = new Date(value);
2823
- if (Number.isNaN(d.getTime())) {
2824
- throw new Error(`[amsg-server D1] invalid timestamp: ${value}`);
2825
- }
2826
- return d.toISOString();
2827
- }
2828
-
2829
- async initSchema() {
2830
- await this._db.prepare(SQLITE_TABLE_SQL).run();
2831
- await this._db.prepare(CLIENT_STATE_TABLE_SQL).run();
2832
-
2833
- // SQLite 的 ALTER TABLE 没有 ADD COLUMN IF NOT EXISTS,列已经在了就会
2834
- // 报 duplicate column name。那正是「这一步不用做」的意思,跳过即可;
2835
- // 其他错误照常抛出去。
2836
- for (const migration of SQLITE_MIGRATIONS) {
2837
- try {
2838
- await this._db.prepare(migration.sql).run();
2839
- } catch (error) {
2840
- if (!/duplicate column name/i.test(error.message || '')) throw error;
2841
- }
2842
- }
2843
-
2844
- const indexResults = [];
2845
- for (const index of SQLITE_INDEXES) {
2846
- try {
2847
- await this._db.prepare(index.sql).run();
2848
- indexResults.push({ name: index.name, status: 'success', description: index.description, critical: !!index.critical });
2849
- } catch (error) {
2850
- indexResults.push({ name: index.name, status: 'failed', description: index.description, critical: !!index.critical, error: error.message });
2851
- }
2852
- }
2853
-
2854
- const criticalFailures = indexResults.filter((i) => i.critical && i.status === 'failed');
2855
- if (criticalFailures.length > 0) {
2856
- const names = criticalFailures.map((i) => i.name).join(', ');
2857
- throw new Error(
2858
- `Critical index creation failed (${names}). ` +
2859
- 'Please remove duplicate UUID rows and run initSchema again.'
2860
- );
2861
- }
2862
-
2863
- return {
2864
- columnsCreated: 11,
2865
- indexesCreated: indexResults.filter((r) => r.status === 'success').length,
2866
- indexesFailed: indexResults.filter((r) => r.status === 'failed').length,
2867
- columns: [],
2868
- indexes: indexResults
2869
- };
2870
- }
2871
-
2872
- async dropSchema() {
2873
- await this._db.prepare('DROP TABLE IF EXISTS scheduled_messages').run();
2874
- await this._db.prepare('DROP TABLE IF EXISTS client_state').run();
2875
- }
2876
-
2877
- async createTask(params) {
2878
- const now = this._now();
2879
- const nextSendAt = this._iso(params.next_send_at);
2880
- const res = await this._db.prepare(
2881
- `INSERT INTO scheduled_messages
2882
- (user_id, uuid, encrypted_payload, next_send_at, message_type, status, retry_count, created_at, updated_at)
2883
- VALUES (?, ?, ?, ?, ?, 'pending', 0, ?, ?)`
2884
- ).bind(params.user_id, params.uuid, params.encrypted_payload, nextSendAt, params.message_type, now, now).run();
2885
-
2886
- const id = res.meta.last_row_id;
2887
- return this._db.prepare(
2888
- `SELECT id, uuid, next_send_at, status, created_at FROM scheduled_messages WHERE id = ?`
2889
- ).bind(id).first();
2890
- }
2891
-
2892
- async getTaskByUuid(uuid, userId) {
2893
- return this._db.prepare(
2894
- `SELECT id, user_id, uuid, encrypted_payload, message_type, next_send_at, status, retry_count
2895
- FROM scheduled_messages
2896
- WHERE uuid = ? AND user_id = ? AND status = 'pending'
2897
- LIMIT 1`
2898
- ).bind(uuid, userId).first();
2899
- }
2900
-
2901
- async getTaskByUuidOnly(uuid) {
2902
- return this._db.prepare(
2903
- `SELECT id, user_id, uuid, encrypted_payload, message_type, next_send_at, status, retry_count
2904
- FROM scheduled_messages
2905
- WHERE uuid = ? AND status = 'pending'
2906
- LIMIT 1`
2907
- ).bind(uuid).first();
2908
- }
2909
-
2910
- async updateTaskById(taskId, updates) {
2911
- const sets = [];
2912
- const values = [];
2913
- for (const [key, value] of Object.entries(updates)) {
2914
- if (!UPDATABLE_COLUMNS.has(key)) {
2915
- throw new Error(`[amsg-server D1] rejected unknown update column: ${key}`);
2916
- }
2917
- sets.push(`${key} = ?`);
2918
- values.push(key === 'next_send_at' ? this._iso(value) : value);
2919
- }
2920
- // Callers may pass updated_at explicitly (tests); otherwise stamp now.
2921
- if (!Object.prototype.hasOwnProperty.call(updates, 'updated_at')) {
2922
- sets.push('updated_at = ?');
2923
- values.push(this._now());
2924
- }
2925
- values.push(taskId);
2926
-
2927
- await this._db.prepare(
2928
- `UPDATE scheduled_messages SET ${sets.join(', ')} WHERE id = ?`
2929
- ).bind(...values).run();
2930
-
2931
- return this._db.prepare('SELECT * FROM scheduled_messages WHERE id = ?').bind(taskId).first();
2932
- }
2933
-
2934
- async updateTaskByUuid(uuid, userId, encryptedPayload, extraFields) {
2935
- const now = this._now();
2936
- const sets = ['encrypted_payload = ?', 'updated_at = ?'];
2937
- const values = [encryptedPayload, now];
2938
- if (extraFields) {
2939
- for (const [key, value] of Object.entries(extraFields)) {
2940
- if (!UPDATABLE_COLUMNS.has(key)) {
2941
- throw new Error(`[amsg-server D1] rejected unknown update column: ${key}`);
2942
- }
2943
- sets.push(`${key} = ?`);
2944
- values.push(key === 'next_send_at' ? this._iso(value) : value);
2945
- }
2946
- }
2947
- values.push(uuid, userId);
2948
-
2949
- const res = await this._db.prepare(
2950
- `UPDATE scheduled_messages SET ${sets.join(', ')}
2951
- WHERE uuid = ? AND user_id = ? AND status = 'pending'`
2952
- ).bind(...values).run();
2953
-
2954
- if (!res.meta.changes) return null;
2955
- return { uuid, updated_at: now };
2956
- }
2957
-
2958
- async deleteTaskById(taskId) {
2959
- const res = await this._db.prepare('DELETE FROM scheduled_messages WHERE id = ?').bind(taskId).run();
2960
- return res.meta.changes > 0;
2961
- }
2962
-
2963
- async deleteTaskByUuid(uuid, userId) {
2964
- const res = await this._db.prepare(
2965
- 'DELETE FROM scheduled_messages WHERE uuid = ? AND user_id = ?'
2966
- ).bind(uuid, userId).run();
2967
- return res.meta.changes > 0;
2968
- }
2969
-
2970
- async getPendingTasks(limit = 50) {
2971
- const now = this._now();
2972
- const res = await this._db.prepare(
2973
- `SELECT id, user_id, uuid, encrypted_payload, message_type, next_send_at, status, retry_count
2974
- FROM scheduled_messages
2975
- WHERE status = 'pending' AND next_send_at <= ?
2976
- AND (lease_until IS NULL OR lease_until <= ?)
2977
- ORDER BY next_send_at ASC
2978
- LIMIT ?`
2979
- ).bind(now, now, limit).all();
2980
- return res.results || [];
2981
- }
2982
-
2983
- /**
2984
- * 领取一条到点的任务:在 lease_until 上写下「这条归我管到什么时候」,
2985
- * 本次投递期间别的 tick 领不走它。
2986
- *
2987
- * 租约写在自己的列上,next_send_at 全程不动——那一列是用户设的触发时刻,
2988
- * 任务列表要读它、循环任务推进下一次也要拿它当基准。
2989
- *
2990
- * 两个 tick 抢同一行时只有一个能改到行,另一个拿到 changes = 0,据此跳过。
2991
- * WHERE 里的两个条件各管一件事:
2992
- * - lease_until 为空或已过期:没人正在跑这条。领了任务的 tick 中途没了
2993
- * 也不会把行焊死,租约到期后自然可以被接手。
2994
- * - next_send_at 等于读这行时看到的值:读出来之后用户又改了排期的话,
2995
- * 这一跳就不该再按旧时刻发。
2996
- *
2997
- * 不加一个 'sending' 状态来表达「正在跑」:建表语句里 status 有
2998
- * CHECK (status IN ('pending','sent','failed')),加值要重建表。
2999
- *
3000
- * expectedNextSendAt 按读到的原样比对,不做时区归一化——老部署里可能还留
3001
- * 着非归一化写法的行(如 +08:00 结尾),归一化后反而对不上,那条任务会永
3002
- * 远领不到。
3003
- *
3004
- * @param {number} taskId
3005
- * @param {string} expectedNextSendAt - 读这行时拿到的 next_send_at 原值
3006
- * @param {string|Date} leaseUntil - 租期末尾
3007
- * @returns {Promise<boolean>} true = 领到了;false = 别人正拿着租约、排期被改过、或行已不是 pending
3008
- */
3009
- async claimTask(taskId, expectedNextSendAt, leaseUntil) {
3010
- const expected = typeof expectedNextSendAt === 'string'
3011
- ? expectedNextSendAt
3012
- : this._iso(expectedNextSendAt);
3013
- const res = await this._db.prepare(
3014
- `UPDATE scheduled_messages
3015
- SET lease_until = ?, updated_at = ?
3016
- WHERE id = ? AND status = 'pending' AND next_send_at = ?
3017
- AND (lease_until IS NULL OR lease_until <= ?)`
3018
- ).bind(this._iso(leaseUntil), this._now(), taskId, expected, this._now()).run();
3019
- return (res.meta.changes || 0) > 0;
3020
- }
3021
-
3022
- async listTasks(userId, opts = {}) {
3023
- const { status = 'all', limit = 20, offset = 0 } = opts;
3024
- const conditions = ['user_id = ?'];
3025
- const params = [userId];
3026
- if (status !== 'all') {
3027
- conditions.push('status = ?');
3028
- params.push(status);
3029
- }
3030
- const where = conditions.join(' AND ');
3031
-
3032
- const countRow = await this._db.prepare(
3033
- `SELECT COUNT(*) as count FROM scheduled_messages WHERE ${where}`
3034
- ).bind(...params).first();
3035
- const total = Number(countRow.count) || 0;
3036
-
3037
- const res = await this._db.prepare(
3038
- `SELECT id, user_id, uuid, encrypted_payload, message_type, next_send_at, status, retry_count, created_at, updated_at
3039
- FROM scheduled_messages
3040
- WHERE ${where}
3041
- ORDER BY next_send_at ASC
3042
- LIMIT ? OFFSET ?`
3043
- ).bind(...params, limit, offset).all();
3044
-
3045
- return { tasks: res.results || [], total };
3046
- }
3047
-
3048
- async cleanupOldTasks(days = 7) {
3049
- const safeDays = Math.max(1, Math.floor(Number(days)));
3050
- const cutoff = new Date(Date.now() - safeDays * 24 * 60 * 60 * 1000).toISOString();
3051
- const res = await this._db.prepare(
3052
- `DELETE FROM scheduled_messages
3053
- WHERE status IN ('sent', 'failed') AND updated_at < ?`
3054
- ).bind(cutoff).run();
3055
- return res.meta.changes || 0;
3056
- }
3057
-
3058
- async getTaskStatus(uuid, userId) {
3059
- const row = await this._db.prepare(
3060
- 'SELECT status FROM scheduled_messages WHERE uuid = ? AND user_id = ? LIMIT 1'
3061
- ).bind(uuid, userId).first();
3062
- return row ? row.status : null;
3063
- }
3064
-
3065
- // ── client_state (single-user cloud state mirror) ──────────────────────
3066
-
3067
- /**
3068
- * Batch upsert. Last-write-wins per (namespace, key): an entry older
3069
- * than the stored row (updatedAt strictly lower) is skipped; equal or
3070
- * newer overwrites. Values arrive pre-encrypted (the handler encrypts).
3071
- *
3072
- * `cleanups` 是删除项:在同一 batch 里先于 upsert 执行,`updated_at <= ?`
3073
- * 条件保证陈旧批次删不动更新写入的行。两种形态——
3074
- * - `{ namespace, keyPrefix, updatedAt }` 删 key 前缀下的所有行,用来清掉
3075
- * 大值旧写入留下的切片行(见 lib/state-chunks.js);
3076
- * - `{ namespace, key, updatedAt }` 删这一个 key,用来删整条状态(前缀会
3077
- * 连带删掉同前缀的兄弟 key,删单条必须走精确匹配)。
3078
- *
3079
- * 删掉一整条状态就是这两种各来一条(切片行走前缀、根行走精确 key),
3080
- * `entries` 传空数组即可——见 lib/client-state-store.js。
3081
- *
3082
- * Uses D1's batch() — one network round trip for the whole set (implicit
3083
- * transaction). The client calls this endpoint inside its few-seconds
3084
- * background window, so N sequential round trips could eat the whole
3085
- * window. Bindings without batch() (e.g. the sqlite test shim, custom
3086
- * adapters) fall back to a sequential loop.
3087
- *
3088
- * @param {string} userId
3089
- * @param {Array<{ namespace: string, key: string, value: string, updatedAt: number }>} entries
3090
- * @param {Array<{ namespace: string, key?: string, keyPrefix?: string, updatedAt: number }>} [cleanups]
3091
- * @returns {Promise<{ upserted: number, skipped: number, outcomes: boolean[] }>}
3092
- * `outcomes[i]` 对应 entries[i] 是否真的写入(changes > 0)。
3093
- */
3094
- async upsertClientState(userId, entries, cleanups = []) {
3095
- const UPSERT_SQL =
3096
- `INSERT INTO client_state (user_id, namespace, key, value, updated_at)
3097
- VALUES (?, ?, ?, ?, ?)
3098
- ON CONFLICT (user_id, namespace, key) DO UPDATE SET
3099
- value = excluded.value,
3100
- updated_at = excluded.updated_at
3101
- WHERE excluded.updated_at >= client_state.updated_at`;
3102
- const CLEANUP_PREFIX_SQL =
3103
- `DELETE FROM client_state
3104
- WHERE user_id = ? AND namespace = ? AND key LIKE ? ESCAPE '\\' AND updated_at <= ?`;
3105
- const CLEANUP_KEY_SQL =
3106
- `DELETE FROM client_state
3107
- WHERE user_id = ? AND namespace = ? AND key = ? AND updated_at <= ?`;
3108
-
3109
- const buildStatements = () => [
3110
- ...cleanups.map((c) => (
3111
- typeof c.key === 'string'
3112
- ? this._db.prepare(CLEANUP_KEY_SQL).bind(userId, c.namespace, c.key, c.updatedAt)
3113
- : this._db.prepare(CLEANUP_PREFIX_SQL).bind(userId, c.namespace, `${escapeLikePrefix(c.keyPrefix)}%`, c.updatedAt)
3114
- )),
3115
- ...entries.map((entry) =>
3116
- this._db.prepare(UPSERT_SQL).bind(userId, entry.namespace, entry.key, entry.value, entry.updatedAt)
3117
- ),
3118
- ];
3119
-
3120
- let results;
3121
- if (typeof this._db.batch === 'function') {
3122
- results = await this._db.batch(buildStatements());
3123
- } else {
3124
- results = [];
3125
- for (const stmt of buildStatements()) {
3126
- results.push(await stmt.run());
3127
- }
3128
- }
3129
-
3130
- // cleanup 语句不计数:upserted/skipped/outcomes 只看 entries 对应的语句。
3131
- const outcomes = results.slice(cleanups.length).map((res) => res.meta.changes > 0);
3132
- let upserted = 0;
3133
- let skipped = 0;
3134
- for (const wrote of outcomes) {
3135
- if (wrote) upserted++; else skipped++;
3136
- }
3137
- return { upserted, skipped, outcomes };
3138
- }
3139
-
3140
- /**
3141
- * All entries of one namespace (values still encrypted).
3142
- *
3143
- * @param {string} userId
3144
- * @param {string} namespace
3145
- * @returns {Promise<Array<{ namespace: string, key: string, value: string, updated_at: number }>>}
3146
- */
3147
- async getClientState(userId, namespace) {
3148
- const res = await this._db.prepare(
3149
- `SELECT namespace, key, value, updated_at
3150
- FROM client_state
3151
- WHERE user_id = ? AND namespace = ?
3152
- ORDER BY key ASC`
3153
- ).bind(userId, namespace).all();
3154
- return res.results || [];
3155
- }
3156
-
3157
- /**
3158
- * Wipe every entry of this user.
3159
- *
3160
- * @param {string} userId
3161
- * @returns {Promise<number>} rows deleted
3162
- */
3163
- async clearClientState(userId) {
3164
- const res = await this._db.prepare('DELETE FROM client_state WHERE user_id = ?').bind(userId).run();
3165
- return res.meta.changes || 0;
3166
- }
3167
- }
3168
-
3169
- /**
3170
- * Create a D1 adapter from a Cloudflare D1 binding (env.DB).
3171
- * @param {{ prepare: (sql: string) => any }} db
3172
- * @returns {import('./interface.js').DbAdapter}
3173
- */
3174
- function createD1Adapter(db) {
3175
- if (!db || typeof db.prepare !== 'function') {
3176
- throw new Error('[amsg-server] createD1Adapter requires a D1 database binding (env.DB)');
3177
- }
3178
- return new D1Adapter(db);
3179
- }
3180
-
3181
- /**
3182
- * Portable constant-time string comparison.
3183
- *
3184
- * Runs identically on Node (tests) and Cloudflare Workers (prod). We avoid
3185
- * both node:crypto's timingSafeEqual (undefined on Workers historically) and
3186
- * crypto.subtle.timingSafeEqual (absent on Node). Instead we HMAC-SHA256 both
3187
- * inputs under one fresh random key, then compare the two digests. Because the
3188
- * key is random per call, an attacker can't precompute or replay a stable
3189
- * timing oracle; digests are a fixed 32 bytes, so the XOR-accumulate compare
3190
- * runs the same number of steps regardless of input length, with no early-out.
3191
- *
3192
- * globalThis.crypto (Web Crypto) is available on Node >= 20 and on Workers.
3193
- */
3194
- async function constantTimeEqual(a, b) {
3195
- const enc = new TextEncoder();
3196
- const keyBytes = globalThis.crypto.getRandomValues(new Uint8Array(32));
3197
- const key = await globalThis.crypto.subtle.importKey(
3198
- 'raw',
3199
- keyBytes,
3200
- { name: 'HMAC', hash: 'SHA-256' },
3201
- false,
3202
- ['sign']
3203
- );
3204
- const da = new Uint8Array(await globalThis.crypto.subtle.sign('HMAC', key, enc.encode(String(a))));
3205
- const db = new Uint8Array(await globalThis.crypto.subtle.sign('HMAC', key, enc.encode(String(b))));
3206
-
3207
- let diff = 0;
3208
- for (let i = 0; i < da.length; i++) {
3209
- diff |= da[i] ^ db[i];
3210
- }
3211
- return diff === 0;
3212
- }
3213
-
3214
- /**
3215
- * Single-user tenant context manager.
3216
- *
3217
- * Interface-compatible with createTenantContextManager (resolveTenant /
3218
- * initializeTenant), so the existing business handlers reuse it unchanged.
3219
- * No blob registry, no tenant token — db and masterKey come from the caller
3220
- * (the Worker resolves them from env + D1 binding per request).
3221
- */
3222
-
3223
-
3224
- function createSingleUserContextManager({ db, masterKey, serverToken } = {}) {
3225
- if (!db) throw new Error('[amsg-server single-user] db (adapter) is required');
3226
- if (!masterKey) throw new Error('[amsg-server single-user] masterKey is required');
3227
- const token = String(serverToken || '').trim();
3228
-
3229
- async function isAuthorized(headers) {
3230
- if (!token) return true; // open when no shared secret configured
3231
- const provided = getHeader(headers, 'x-client-token');
3232
- if (!provided) return false;
3233
- return constantTimeEqual(provided, token);
3234
- }
3235
-
3236
- async function resolveTenant(headers) {
3237
- if (!(await isAuthorized(headers))) {
3238
- return {
3239
- ok: false,
3240
- error: {
3241
- status: 401,
3242
- body: { success: false, error: { code: 'INVALID_CLIENT_TOKEN', message: '共享密钥无效或缺失' } }
3243
- }
3244
- };
3245
- }
3246
- return {
3247
- ok: true,
3248
- context: { tenantId: 'single', tokenType: 'tenant', db, masterKey }
3249
- };
3250
- }
3251
-
3252
- async function initializeTenant() {
3253
- const schema = await db.initSchema();
3254
- return { tenantId: 'single', schema };
3255
- }
3256
-
3257
- return { resolveTenant, initializeTenant };
3258
- }
3259
-
3260
- /**
3261
- * Handler: single-user-init
3262
- *
3263
- * Idempotent "just create the tables" endpoint for single-user deployments
3264
- * (the degenerate form of init-tenant). Reuses resolveTenant purely to enforce
3265
- * the optional shared secret, then runs initSchema. Issues no token.
3266
- *
3267
- * @param {Object} ctx - Single-user server context (ctx.tenantManager).
3268
- * @returns {{ POST: function }}
3269
- */
3270
- function createSingleUserInitHandler(ctx) {
3271
- async function POST(headers /* , body */) {
3272
- const auth = await ctx.tenantManager.resolveTenant(headers || {});
3273
- if (!auth.ok) {
3274
- return auth.error;
3275
- }
3276
- try {
3277
- const result = await ctx.tenantManager.initializeTenant();
3278
- return {
3279
- status: 200,
3280
- body: { success: true, data: { tenantId: result.tenantId, schema: result.schema } }
3281
- };
3282
- } catch (error) {
3283
- return {
3284
- status: 500,
3285
- body: { success: false, error: { code: 'INIT_FAILED', message: error.message } }
3286
- };
3287
- }
3288
- }
3289
-
3290
- return { POST };
3291
- }
3292
-
3293
- /**
3294
- * Handler: vapid-public-key
3295
- *
3296
- * Exposes this worker's own VAPID public key so a browser frontend can build a
3297
- * Web Push subscription (`applicationServerKey`) at runtime. Each self-hosted
3298
- * worker owns its keypair, so the key can't be baked into the frontend — it
3299
- * pulls it from here.
3300
- *
3301
- * Auth funnels through the same resolveTenant as every other endpoint, so with
3302
- * a serverToken configured this route requires `X-Client-Token` too (the
3303
- * all-or-nothing contract). The public key itself is not a secret; gating it
3304
- * just keeps every endpoint consistent.
3305
- *
3306
- * @param {Object} ctx - Server context ({ vapid, tenantManager, ... }).
3307
- * @returns {{ GET: function }}
3308
- */
3309
-
3310
- function createVapidPublicKeyHandler(ctx) {
3311
- async function GET(url, headers) {
3312
- const effectiveHeaders = headers || url || {};
3313
- const tenantResult = await ctx.tenantManager.resolveTenant(effectiveHeaders);
3314
- if (!tenantResult.ok) {
3315
- return tenantResult.error;
3316
- }
3317
-
3318
- const publicKey = ctx.vapid && ctx.vapid.publicKey;
3319
- if (!publicKey) {
3320
- return {
3321
- status: 503,
3322
- body: {
3323
- success: false,
3324
- error: {
3325
- code: 'VAPID_NOT_CONFIGURED',
3326
- message: 'VAPID 公钥未配置:请为本 Worker 设置 VAPID_PUBLIC_KEY'
3327
- }
3328
- }
3329
- };
3330
- }
3331
-
3332
- return {
3333
- status: 200,
3334
- body: { success: true, publicKey }
3335
- };
3336
- }
3337
-
3338
- return { GET };
3339
- }
3340
-
3341
- /**
3342
- * Handler: client-state
3343
- *
3344
- * Cloud mirror of client-side state for the single-user deployment. The
3345
- * client batch-syncs entries up (PUT) whenever convenient — e.g. in the
3346
- * few-seconds window before iOS backgrounds the page — and fire-time
3347
- * hooks read them back via ctx.readState(namespace). One live copy per
3348
- * (user, namespace, key).
3349
- *
3350
- * 反方向也通:hook 用 ctx.writeState() 写进来的条目和这里写的完全同构
3351
- * (落库共用 lib/client-state-store.js),客户端 GET 回去读得到,读的时候
3352
- * 不区分是谁写的。
3353
- *
3354
- * PUT /client-state batch upsert, last-write-wins on updatedAt
3355
- * GET /client-state?namespace=<ns> one namespace's entries (decrypted, response re-encrypted)
3356
- * DELETE /client-state wipe every entry of this user
3357
- *
3358
- * 单条 value 超过 200KB 时由服务端透明分块(见 lib/state-chunks.js):写入时
3359
- * 切片跨行存储,GET / readState 返回拼好的原值,客户端与 hook 作者无感。
3360
- * 单条总上限默认 5MB,工厂配置 maxStateValueBytes 可调。批内某条超限/非法
3361
- * 只拒它自己:有拒绝时响应带 data.rejected 逐条给原因,全部成功时响应形状
3362
- * 与单值时代完全一致。
3363
- *
3364
- * Auth & crypto follow the existing endpoints exactly: X-Client-Token is
3365
- * all-or-nothing via resolveTenant, PUT bodies must be encrypted
3366
- * (X-Payload-Encrypted / X-Encryption-Version), values are stored as
3367
- * encryptForStorage ciphertext under the per-user key, and GET responses
3368
- * ride the existing encrypted-response envelope.
3369
- */
3370
-
3371
- // "a few dozen entries in one background-window request" is the design
3372
- // load; 200 bounds a single request with generous headroom.
3373
- const MAX_STATE_ENTRIES_PER_REQUEST = MAX_STATE_ENTRIES_PER_BATCH;
3374
-
3375
- function err(status, code, message, details) {
3376
- const error = details === undefined ? { code, message } : { code, message, details };
3377
- return { status, body: { success: false, error } };
3378
- }
3379
-
3380
- function requireUserId(headers) {
3381
- const userId = getHeader(headers, 'x-user-id');
3382
- if (!userId) return { error: err(400, 'USER_ID_REQUIRED', '缺少用户标识符') };
3383
- if (!isValidUUIDv4(userId)) return { error: err(400, 'INVALID_USER_ID_FORMAT', 'X-User-Id 必须是 UUID v4 格式') };
3384
- return { userId };
3385
- }
3386
-
3387
- function rejectEntry(entry, index, code, message, extra) {
3388
- const rejection = { index, code, message, ...(extra || {}) };
3389
- if (entry && typeof entry === 'object') {
3390
- if (typeof entry.namespace === 'string') rejection.namespace = entry.namespace;
3391
- if (typeof entry.key === 'string') rejection.key = entry.key;
3392
- }
3393
- return rejection;
3394
- }
3395
-
3396
- // 逐条校验:返回 null(合法)或拒绝对象(进 data.rejected,只拒这一条)。
3397
- function validateEntry(entry, index, maxValueBytes) {
3398
- if (!isPlainObject(entry)) {
3399
- return rejectEntry(entry, index, 'INVALID_STATE_ENTRY', `entries[${index}] 必须是对象`);
3400
- }
3401
- if (typeof entry.namespace !== 'string' || !entry.namespace.trim() || entry.namespace.length > MAX_NAMESPACE_CHARS) {
3402
- return rejectEntry(entry, index, 'INVALID_STATE_NAMESPACE', `entries[${index}].namespace 必须是 1-${MAX_NAMESPACE_CHARS} 字符的字符串`);
3403
- }
3404
- if (INTERNAL_STATE_CHAR_RE.test(entry.namespace)) {
3405
- return rejectEntry(entry, index, 'INVALID_STATE_NAMESPACE', `entries[${index}].namespace 不能包含控制字符(\\u0000-\\u001f 为库内部保留)`);
3406
- }
3407
- if (typeof entry.key !== 'string' || !entry.key.trim() || entry.key.length > MAX_KEY_CHARS) {
3408
- return rejectEntry(entry, index, 'INVALID_STATE_KEY', `entries[${index}].key 必须是 1-${MAX_KEY_CHARS} 字符的字符串`);
3409
- }
3410
- if (INTERNAL_STATE_CHAR_RE.test(entry.key)) {
3411
- return rejectEntry(entry, index, 'INVALID_STATE_KEY', `entries[${index}].key 不能包含控制字符(\\u0000-\\u001f 为库内部保留)`);
3412
- }
3413
- if (typeof entry.value !== 'string') {
3414
- return rejectEntry(entry, index, 'INVALID_STATE_VALUE', `entries[${index}].value 必须是字符串(宿主自行序列化)`);
3415
- }
3416
- const bytes = stateValueBytes(entry.value);
3417
- if (bytes > maxValueBytes) {
3418
- return rejectEntry(entry, index, 'STATE_VALUE_TOO_LARGE', `entries[${index}].value 超过单条总上限`, { bytes, maxBytes: maxValueBytes });
3419
- }
3420
- if (!Number.isInteger(entry.updatedAt) || entry.updatedAt <= 0) {
3421
- return rejectEntry(entry, index, 'INVALID_STATE_UPDATED_AT', `entries[${index}].updatedAt 必须是正整数(epoch 毫秒)`);
3422
- }
3423
- return null;
3424
- }
3425
-
3426
- function createClientStateHandler(ctx) {
3427
- async function PUT(headers, body) {
3428
- const tenantResult = await ctx.tenantManager.resolveTenant(headers);
3429
- if (!tenantResult.ok) return tenantResult.error;
3430
- const { db, masterKey } = tenantResult.context;
3431
-
3432
- if (getHeader(headers, 'x-payload-encrypted') !== 'true') {
3433
- return err(400, 'ENCRYPTION_REQUIRED', '请求体必须加密');
3434
- }
3435
- const gate = requireUserId(headers);
3436
- if (gate.error) return gate.error;
3437
- const { userId } = gate;
3438
- if (getHeader(headers, 'x-encryption-version') !== '1') {
3439
- return err(400, 'UNSUPPORTED_ENCRYPTION_VERSION', '加密版本不支持');
3440
- }
3441
-
3442
- const parsedBody = parseEncryptedBody(body);
3443
- if (!parsedBody.ok) return { status: 400, body: { success: false, error: parsedBody.error } };
3444
-
3445
- const userKey = await deriveUserEncryptionKey(userId, masterKey);
3446
- let payload;
3447
- try {
3448
- payload = await decryptPayload(parsedBody.data, userKey);
3449
- } catch (error) {
3450
- if (error instanceof SyntaxError) {
3451
- return err(400, 'INVALID_PAYLOAD_FORMAT', '解密后的数据不是有效 JSON');
3452
- }
3453
- return err(400, 'DECRYPTION_FAILED', '请求体解密失败');
3454
- }
3455
- if (!isPlainObject(payload)) return err(400, 'INVALID_PAYLOAD_FORMAT', '解密后的数据必须是 JSON 对象');
3456
-
3457
- const entries = payload.entries;
3458
- if (!Array.isArray(entries) || entries.length === 0) {
3459
- return err(400, 'INVALID_STATE_ENTRIES', 'entries 必须是非空数组');
3460
- }
3461
- if (entries.length > MAX_STATE_ENTRIES_PER_REQUEST) {
3462
- return err(400, 'TOO_MANY_STATE_ENTRIES', `单次最多 ${MAX_STATE_ENTRIES_PER_REQUEST} 条`, { count: entries.length });
3463
- }
3464
- const maxValueBytes = Number.isInteger(ctx.maxStateValueBytes) && ctx.maxStateValueBytes > 0
3465
- ? ctx.maxStateValueBytes
3466
- : DEFAULT_MAX_STATE_VALUE_BYTES;
3467
-
3468
- // 逐条校验:坏条目只拒它自己(进 rejected),好条目照常入库。
3469
- const accepted = [];
3470
- const rejected = [];
3471
- for (let i = 0; i < entries.length; i++) {
3472
- const rejection = validateEntry(entries[i], i, maxValueBytes);
3473
- if (rejection) rejected.push(rejection); else accepted.push(entries[i]);
3474
- }
3475
-
3476
- if (typeof db.upsertClientState !== 'function') {
3477
- return err(501, 'CLIENT_STATE_NOT_SUPPORTED', '当前数据库适配器不支持 client_state');
3478
- }
3479
-
3480
- // 落库(加密 + 大值分块 + 旧切片清理)与 hook 的 ctx.writeState() 共用
3481
- // lib/client-state-store.js。这条路径只写不删,所以 deleted 恒为 0。
3482
- const { upserted, skipped } = await writeClientStateEntries({ db, userId, userKey, entries: accepted });
3483
-
3484
- const data = { upserted, skipped };
3485
- if (rejected.length > 0) data.rejected = rejected;
3486
- return { status: 200, body: { success: true, data } };
3487
- }
3488
-
3489
- async function GET(url, headers) {
3490
- const tenantResult = await ctx.tenantManager.resolveTenant(headers);
3491
- if (!tenantResult.ok) return tenantResult.error;
3492
- const { db, masterKey } = tenantResult.context;
3493
-
3494
- const gate = requireUserId(headers);
3495
- if (gate.error) return gate.error;
3496
- const { userId } = gate;
3497
-
3498
- const namespace = new URL(url, 'https://dummy').searchParams.get('namespace') || '';
3499
- if (!namespace.trim()) return err(400, 'NAMESPACE_REQUIRED', '必须提供 namespace 查询参数');
3500
- if (INTERNAL_STATE_CHAR_RE.test(namespace)) {
3501
- return err(400, 'INVALID_STATE_NAMESPACE', 'namespace 不能包含控制字符(\\u0000-\\u001f 为库内部保留)');
3502
- }
3503
-
3504
- if (typeof db.getClientState !== 'function') {
3505
- return err(501, 'CLIENT_STATE_NOT_SUPPORTED', '当前数据库适配器不支持 client_state');
3506
- }
3507
-
3508
- const userKey = await deriveUserEncryptionKey(userId, masterKey);
3509
- const rows = await db.getClientState(userId, namespace);
3510
- // 分块存储的值在这里拼回原文;块不齐全的 key 视为不存在(不抛错)。
3511
- const decrypted = await resolveClientStateEntries(
3512
- rows,
3513
- () => db.getClientState(userId, chunkNamespaceFor(namespace)),
3514
- (value) => decryptFromStorage(value, userKey)
3515
- );
3516
-
3517
- const encryptedResponse = await encryptPayload({ namespace, entries: decrypted }, userKey);
3518
- return { status: 200, body: { success: true, encrypted: true, version: 1, data: encryptedResponse } };
3519
- }
3520
-
3521
- async function DELETE(url, headers) {
3522
- const tenantResult = await ctx.tenantManager.resolveTenant(headers);
3523
- if (!tenantResult.ok) return tenantResult.error;
3524
- const { db } = tenantResult.context;
3525
-
3526
- const gate = requireUserId(headers);
3527
- if (gate.error) return gate.error;
3528
- const { userId } = gate;
3529
-
3530
- if (typeof db.clearClientState !== 'function') {
3531
- return err(501, 'CLIENT_STATE_NOT_SUPPORTED', '当前数据库适配器不支持 client_state');
3532
- }
3533
-
3534
- const deleted = await db.clearClientState(userId);
3535
- return { status: 200, body: { success: true, data: { deleted } } };
3536
- }
3537
-
3538
- return { PUT, GET, DELETE };
3539
- }
3540
-
3541
- /**
3542
- * 构建期注入的包版本。tsup 用 define 把 __AMSG_SERVER_VERSION__ 替换成
3543
- * package.json 的 version(见 tsup.config.js),发布产物里是真实版本号;
3544
- * 直接跑 src(node --test / 本地调试)没有这个替换,typeof 守卫落到
3545
- * '0.0.0-dev'。
3546
- */
3547
- /* global __AMSG_SERVER_VERSION__ */
3548
- const SERVER_VERSION =
3549
- typeof __AMSG_SERVER_VERSION__ !== 'undefined' ? __AMSG_SERVER_VERSION__ : '0.0.0-dev';
3550
-
3551
- /**
3552
- * Handler: capabilities
3553
- *
3554
- * GET /capabilities → { success, serverVersion, features }。前端用它做特性
3555
- * 探测:worker 部署版本落后时,新链路只是「探测不到」(而不是静默失效),
3556
- * 设置页可以据此提示重新部署 worker。老部署没有这个路由 → 404,客户端 SDK
3557
- * 的 getCapabilities() 把 404 归一成 null。
3558
- *
3559
- * features 表达「这份代码支持什么」,随版本静态演进追加;不反映部署配置——
3560
- * 例如 'agentic-hooks' 表示该版本认识 fire-time hooks,宿主配没配 hooks 不
3561
- * 影响它出现。
3562
- *
3563
- * 鉴权与 /vapid-public-key 同待遇:走 resolveTenant,配置 serverToken 后同样
3564
- * 要求 X-Client-Token。
3565
- */
3566
-
3567
-
3568
- const SERVER_FEATURES = Object.freeze([
3569
- 'client-state',
3570
- 'client-state-chunking',
3571
- 'client-state-partial-failure',
3572
- 'agentic-hooks',
3573
- 'agentic-scratch',
3574
- 'agentic-write-state',
3575
- 'agentic-fire-tools',
3576
- 'vapid-public-key',
3577
- ]);
3578
-
3579
- function createCapabilitiesHandler(ctx) {
3580
- async function GET(url, headers) {
3581
- const effectiveHeaders = headers || url || {};
3582
- const tenantResult = await ctx.tenantManager.resolveTenant(effectiveHeaders);
3583
- if (!tenantResult.ok) {
3584
- return tenantResult.error;
3585
- }
3586
- return {
3587
- status: 200,
3588
- body: { success: true, serverVersion: SERVER_VERSION, features: [...SERVER_FEATURES] },
3589
- };
3590
- }
3591
- return { GET };
3592
- }
3593
-
3594
- /**
3595
- * Single-user ReiStandard server assembly.
3596
- *
3597
- * Same shape as createReiServer ({ handlers }), but wired for a single user:
3598
- * - tenant context comes from createSingleUserContextManager (db + masterKey
3599
- * supplied by the caller; no blob registry, no tenant token)
3600
- * - only the 5 business handlers + an idempotent init route are exposed
3601
- * - send-notifications is NOT exposed over HTTP (cron runs via CF scheduled())
3602
- *
3603
- * @param {Object} config
3604
- * @param {import('./adapters/interface.js').DbAdapter} config.db
3605
- * @param {string} config.masterKey
3606
- * @param {string} [config.serverToken] - optional shared secret (X-Client-Token)
3607
- * @param {{ email?: string, publicKey?: string, privateKey?: string }} [config.vapid]
3608
- * @param {{ sendNotification: function }} [config.webpush] - web-push-compatible sender
3609
- * @param {Object} [config.hooks] - optional fire-time hooks (see lib/agentic-fire.js):
3610
- * { onBeforeFire, onLLMOutput, executeToolCalls }. When omitted, AI tasks
3611
- * replay the schedule-time frozen prompt (legacy behavior, unchanged).
3612
- * @param {number} [config.maxToolIterations] - factory default LLM-round cap for the agentic loop (default 5).
3613
- * @param {number} [config.totalTimeoutMs] - factory default wall-time ceiling for the agentic loop (default 240000).
3614
- * @param {number} [config.maxStateValueBytes] - client_state 单条 value 的总上限(默认 5MB)。超过 200KB 的值由服务端透明分块存储(见 lib/state-chunks.js)。
3615
- * @returns {{ handlers: Object, ctx: Object }}
3616
- */
3617
-
3618
-
3619
- function createSingleUserServer(config) {
3620
- if (!config || !config.db) throw new Error('[amsg-server single-user] config.db is required');
3621
- if (!config.masterKey) throw new Error('[amsg-server single-user] config.masterKey is required');
3622
-
3623
- const vapid = config.vapid || {};
3624
- const tenantManager = createSingleUserContextManager({
3625
- db: config.db,
3626
- masterKey: config.masterKey,
3627
- serverToken: config.serverToken
3628
- });
3629
-
3630
- const ctx = {
3631
- vapid: {
3632
- email: vapid.email || '',
3633
- publicKey: vapid.publicKey || '',
3634
- privateKey: vapid.privateKey || ''
3635
- },
3636
- webpush: config.webpush || null,
3637
- tenantManager,
3638
- // Fire-time hooks (optional): the in-server instant path fires through
3639
- // processMessagesByUuid with this ctx, so instant-type tasks can take
3640
- // the agentic path too.
3641
- hooks: config.hooks || null,
3642
- maxToolIterations: config.maxToolIterations,
3643
- totalTimeoutMs: config.totalTimeoutMs,
3644
- maxStateValueBytes: config.maxStateValueBytes
3645
- };
3646
-
3647
- return {
3648
- ctx,
3649
- handlers: {
3650
- init: createSingleUserInitHandler(ctx),
3651
- getUserKey: createGetUserKeyHandler(ctx),
3652
- scheduleMessage: createScheduleMessageHandler(ctx),
3653
- updateMessage: createUpdateMessageHandler(ctx),
3654
- cancelMessage: createCancelMessageHandler(ctx),
3655
- messages: createMessagesHandler(ctx),
3656
- vapidPublicKey: createVapidPublicKeyHandler(ctx),
3657
- clientState: createClientStateHandler(ctx),
3658
- capabilities: createCapabilitiesHandler(ctx)
3659
- }
3660
- };
3661
- }
3662
-
3663
- /**
3664
- * Web Push — RFC 8030 (transport) + RFC 8291 (aes128gcm payload encryption)
3665
- * + RFC 8292 (VAPID).
3666
- *
3667
- * Pure-WebCrypto implementation. Zero runtime dependencies. Runs natively on
3668
- * Cloudflare Workers, Vercel Edge, Netlify Edge, Deno, Bun, and Node ≥ 19.
3669
- * Node 18 deployments must go through the `adapters/node` entry which
3670
- * polyfills `globalThis.crypto` from `node:crypto.webcrypto`.
3671
- *
3672
- * The wire format produced here is byte-identical to the `web-push` npm
3673
- * package and to the Push API in any modern browser, so amsg-sw and any
3674
- * existing Web Push subscriptions keep working untouched.
3675
- */
3676
-
3677
-
3678
- // RFC 8291 fixed labels (each followed by a NUL byte per HKDF "info" framing).
3679
- const KEY_INFO_PREFIX = utf8$1('WebPush: info\0');
3680
- const CEK_INFO = utf8$1('Content-Encoding: aes128gcm\0');
3681
- const NONCE_INFO = utf8$1('Content-Encoding: nonce\0');
3682
-
3683
- const VAPID_DEFAULT_TTL = 60; // seconds — short, matches single-shot instant.
3684
- const VAPID_TOKEN_LIFETIME = 12 * 3600; // 12h — comfortably under the 24h RFC 8292 cap.
3685
- const RECORD_SIZE = 4096; // arbitrary — must be ≥ ciphertext length.
3686
-
3687
- // ─── Payload budget ────────────────────────────────────────────────────
3688
- //
3689
- // 推送服务(FCM / APNs / Mozilla autopush)限的是 POST 上去的**密文** body,
3690
- // 上限 4096 字节,超了当场 413,消息就没了。明文能塞多少要把 aes128gcm 的固
3691
- // 定开销减掉:
3692
- //
3693
- // 密文 body = header(86) + 明文 + 填充分隔符(1) + GCM auth tag(16)
3694
- // header = salt(16) + record size(4) + keyid 长度(1) + keyid(65)
3695
- //
3696
- // keyid 是应用服务器的 P-256 公钥,恒定 65 字节;单记录只带一个 0x02 分隔符
3697
- // (RFC 8188 §2),不额外填充。所以开销是固定的 103 字节,明文上限
3698
- // 4096 - 103 = 3993 字节(UTF-8 计,不是字符数)。
3699
- // 这个换算由 webpush-webcrypto.test.mjs 钉住:上限大小的 payload 编出来的
3700
- // body 恰好 4096 字节。
3701
-
3702
- /** 推送服务对加密后 body 的上限(字节)。 */
3703
- const WEB_PUSH_MAX_BODY_BYTES = 4096;
3704
- /** aes128gcm 固定开销:header 86 + 填充分隔符 1 + GCM tag 16。 */
3705
- const WEB_PUSH_ENCRYPTION_OVERHEAD_BYTES = 16 + 4 + 1 + 65 + 1 + 16;
3706
- /** 一条 push 的明文(JSON 字符串)上限,UTF-8 字节数。 */
3707
- const MAX_PUSH_PAYLOAD_BYTES = WEB_PUSH_MAX_BODY_BYTES - WEB_PUSH_ENCRYPTION_OVERHEAD_BYTES;
3708
-
3709
- const payloadEncoder = new TextEncoder();
3710
-
3711
- /**
3712
- * 组 payload 前做预算用:先量骨架有多大,剩下多少字节才是能塞附加数据的额度。
3713
- *
3714
- * @example
3715
- * const { remainingBytes } = measurePushPayload(JSON.stringify(basePush));
3716
- * const excerpt = remainingBytes > 0 ? text.slice(0, remainingBytes) : '';
3717
- *
3718
- * @param {string} payload - 待发送的 payload 字符串(通常是 JSON.stringify 的结果)。
3719
- * @returns {{ bytes: number, maxBytes: number, remainingBytes: number, withinLimit: boolean }}
3720
- * `bytes` 是 payload 的 UTF-8 字节数;`remainingBytes` 是还能加多少字节
3721
- * (已超限时为负);`withinLimit` 为 false 时 sendWebPush 会抛
3722
- * `PUSH_PAYLOAD_TOO_LARGE`。
3723
- */
3724
- function measurePushPayload(payload) {
3725
- const bytes = payloadEncoder.encode(typeof payload === 'string' ? payload : String(payload)).length;
3726
- return {
3727
- bytes,
3728
- maxBytes: MAX_PUSH_PAYLOAD_BYTES,
3729
- remainingBytes: MAX_PUSH_PAYLOAD_BYTES - bytes,
3730
- withinLimit: bytes <= MAX_PUSH_PAYLOAD_BYTES,
3731
- };
3732
- }
3733
-
3734
- /**
3735
- * Send a single Web Push notification.
3736
- *
3737
- * @param {Object} args
3738
- * @param {Object} args.subscription - Standard PushSubscription JSON.
3739
- * @param {string} args.subscription.endpoint
3740
- * @param {Object} args.subscription.keys
3741
- * @param {string} args.subscription.keys.p256dh - base64url, 65 B uncompressed P-256 point.
3742
- * @param {string} args.subscription.keys.auth - base64url, 16 B auth secret.
3743
- * @param {string} args.payload - Already-stringified JSON to deliver.
3744
- * @param {Object} args.vapid
3745
- * @param {string} args.vapid.email - VAPID `sub` (mailto: auto-prepended if missing).
3746
- * @param {string} args.vapid.publicKey - base64url, 65 B uncompressed P-256 point.
3747
- * @param {string} args.vapid.privateKey - base64url, 32 B scalar.
3748
- * @param {number} [args.ttl=60] - Push service TTL header, seconds.
3749
- * @param {typeof fetch} [args.fetch] - Override fetch impl (testing / proxy).
3750
- * @returns {Promise<{ statusCode: number, body: string, headers: Headers }>}
3751
- * @throws {Error} err.code = 'PUSH_PAYLOAD_TOO_LARGE' when the payload
3752
- * exceeds MAX_PUSH_PAYLOAD_BYTES (nothing is sent);
3753
- * err.code = 'PUSH_SEND_FAILED' on push-service error.
3754
- */
3755
- async function sendWebPush({ subscription, payload, vapid, ttl, fetch: fetchImpl }) {
3756
- if (!subscription || typeof subscription.endpoint !== 'string') {
3757
- throw new Error('sendWebPush: invalid subscription');
3758
- }
3759
- if (typeof payload !== 'string') {
3760
- throw new Error('sendWebPush: payload must be a string');
3761
- }
3762
- if (!vapid || !vapid.email || !vapid.publicKey || !vapid.privateKey) {
3763
- throw new Error('VAPID_CONFIG_MISSING');
3764
- }
3765
-
3766
- const subscriptionKeys = subscription.keys || {};
3767
- if (typeof subscriptionKeys.p256dh !== 'string' || typeof subscriptionKeys.auth !== 'string') {
3768
- throw new Error('sendWebPush: subscription.keys.p256dh and .auth are required');
3769
- }
3770
-
3771
- // 超限的 payload 发出去只会被推送服务 413 掉,用户什么也收不到。加密之前就
3772
- // 拦下来,让调用方拿到一个说得清的错误(宿主可以退回一条短消息 + 引用键)。
3773
- const size = measurePushPayload(payload);
3774
- if (!size.withinLimit) {
3775
- const err = new Error(
3776
- `sendWebPush: payload is ${size.bytes} bytes, over the ${MAX_PUSH_PAYLOAD_BYTES}-byte limit ` +
3777
- `(push services cap the encrypted body at ${WEB_PUSH_MAX_BODY_BYTES} bytes; ` +
3778
- `aes128gcm adds ${WEB_PUSH_ENCRYPTION_OVERHEAD_BYTES} bytes)`
3779
- );
3780
- err.code = 'PUSH_PAYLOAD_TOO_LARGE';
3781
- err.bytes = size.bytes;
3782
- err.maxBytes = MAX_PUSH_PAYLOAD_BYTES;
3783
- throw err;
3784
- }
3785
-
3786
- const encryptedBody = await encryptPushPayload({
3787
- plaintext: utf8$1(payload),
3788
- uaPublicKey: base64UrlToBytes(subscriptionKeys.p256dh),
3789
- authSecret: base64UrlToBytes(subscriptionKeys.auth),
3790
- });
3791
-
3792
- const jwt = await buildVapidJwt({
3793
- audience: originOf(subscription.endpoint),
3794
- subject: normalizeVapidSubject(vapid.email),
3795
- publicKey: vapid.publicKey,
3796
- privateKey: vapid.privateKey,
3797
- });
3798
-
3799
- const fetchFn = fetchImpl || globalThis.fetch;
3800
- if (typeof fetchFn !== 'function') {
3801
- throw new Error('sendWebPush: no fetch implementation available');
3802
- }
3803
-
3804
- const res = await fetchFn(subscription.endpoint, {
3805
- method: 'POST',
3806
- headers: {
3807
- 'Content-Type': 'application/octet-stream',
3808
- 'Content-Encoding': 'aes128gcm',
3809
- 'TTL': String(Number.isFinite(ttl) ? ttl : VAPID_DEFAULT_TTL),
3810
- 'Authorization': `vapid t=${jwt}, k=${vapid.publicKey}`,
3811
- },
3812
- body: encryptedBody,
3813
- });
3814
-
3815
- // Push services return 201 (RFC 8030) or 200/202 depending on implementation.
3816
- if (!res.ok) {
3817
- const text = await safeReadText(res);
3818
- const err = new Error(
3819
- `Web Push delivery failed: ${res.status} ${res.statusText || ''}${text ? ` — ${text}` : ''}`
3820
- );
3821
- err.code = 'PUSH_SEND_FAILED';
3822
- err.statusCode = res.status;
3823
- throw err;
3824
- }
3825
-
3826
- return {
3827
- statusCode: res.status,
3828
- body: await safeReadText(res),
3829
- headers: res.headers,
3830
- };
3831
- }
3832
-
3833
- // ─── RFC 8291: aes128gcm payload encryption ────────────────────────────
3834
-
3835
- /**
3836
- * @param {Object} args
3837
- * @param {Uint8Array} args.plaintext
3838
- * @param {Uint8Array} args.uaPublicKey - recipient p256dh, 65 B uncompressed.
3839
- * @param {Uint8Array} args.authSecret - recipient auth, 16 B.
3840
- * @returns {Promise<Uint8Array>} encryption header || ciphertext
3841
- */
3842
- async function encryptPushPayload({ plaintext, uaPublicKey, authSecret }) {
3843
- // 1. Ephemeral ECDH key pair (as = "application server" per RFC 8291).
3844
- const asKeyPair = await globalThis.crypto.subtle.generateKey(
3845
- { name: 'ECDH', namedCurve: 'P-256' },
3846
- true,
3847
- ['deriveBits']
3848
- );
3849
- const asPublicRaw = new Uint8Array(
3850
- await globalThis.crypto.subtle.exportKey('raw', asKeyPair.publicKey)
3851
- );
3852
-
3853
- // 2. ECDH shared secret with recipient's p256dh.
3854
- const uaPublicCryptoKey = await globalThis.crypto.subtle.importKey(
3855
- 'raw',
3856
- uaPublicKey,
3857
- { name: 'ECDH', namedCurve: 'P-256' },
3858
- false,
3859
- []
3860
- );
3861
- const ecdhSecret = new Uint8Array(
3862
- await globalThis.crypto.subtle.deriveBits(
3863
- { name: 'ECDH', public: uaPublicCryptoKey },
3864
- asKeyPair.privateKey,
3865
- 256
3866
- )
3867
- );
3868
-
3869
- // 3. IKM = HKDF-SHA256(salt=auth_secret, ikm=ecdh_secret,
3870
- // info="WebPush: info\0" || ua_public || as_public, L=32)
3871
- const keyInfo = concatBytes(KEY_INFO_PREFIX, uaPublicKey, asPublicRaw);
3872
- const ikm = await hkdfSha256(authSecret, ecdhSecret, keyInfo, 32);
3873
-
3874
- // 4. encryption_salt (random, 16 B). Goes into the header so the recipient
3875
- // can re-derive CEK / NONCE.
3876
- const salt = randomBytes(16);
3877
-
3878
- // 5. CEK = HKDF-SHA256(salt, ikm, "Content-Encoding: aes128gcm\0", 16)
3879
- const cekBytes = await hkdfSha256(salt, ikm, CEK_INFO, 16);
3880
- const cek = await globalThis.crypto.subtle.importKey(
3881
- 'raw',
3882
- cekBytes,
3883
- { name: 'AES-GCM' },
3884
- false,
3885
- ['encrypt']
3886
- );
3887
-
3888
- // 6. NONCE = HKDF-SHA256(salt, ikm, "Content-Encoding: nonce\0", 12)
3889
- const nonce = await hkdfSha256(salt, ikm, NONCE_INFO, 12);
3890
-
3891
- // 7. Single-record AES-128-GCM. Padding delimiter 0x02 marks the final
3892
- // (and only) record per RFC 8188 §2.
3893
- const padded = concatBytes(plaintext, new Uint8Array([0x02]));
3894
- const ciphertext = new Uint8Array(
3895
- await globalThis.crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce }, cek, padded)
3896
- );
3897
-
3898
- // 8. aes128gcm content-encoding framing (RFC 8188 §2.1):
3899
- // salt(16) || rs(4 BE) || idlen(1) || keyid(idlen) || ciphertext
3900
- // For Web Push, keyid is the application-server public key (65 B).
3901
- const header = new Uint8Array(16 + 4 + 1 + asPublicRaw.byteLength);
3902
- header.set(salt, 0);
3903
- writeUint32BE(header, 16, RECORD_SIZE);
3904
- header[20] = asPublicRaw.byteLength;
3905
- header.set(asPublicRaw, 21);
3906
-
3907
- return concatBytes(header, ciphertext);
3908
- }
3909
-
3910
- /**
3911
- * HKDF-SHA-256 (extract-then-expand) via WebCrypto.
3912
- *
3913
- * @param {Uint8Array} salt
3914
- * @param {Uint8Array} ikm
3915
- * @param {Uint8Array} info
3916
- * @param {number} length - desired output length in bytes (≤ 32 in our usage).
3917
- * @returns {Promise<Uint8Array>}
3918
- */
3919
- async function hkdfSha256(salt, ikm, info, length) {
3920
- const baseKey = await globalThis.crypto.subtle.importKey(
3921
- 'raw',
3922
- toUint8(ikm),
3923
- { name: 'HKDF' },
3924
- false,
3925
- ['deriveBits']
3926
- );
3927
- const bits = await globalThis.crypto.subtle.deriveBits(
3928
- {
3929
- name: 'HKDF',
3930
- hash: 'SHA-256',
3931
- salt: toUint8(salt),
3932
- info: toUint8(info),
3933
- },
3934
- baseKey,
3935
- length * 8
3936
- );
3937
- return new Uint8Array(bits);
3938
- }
3939
-
3940
- // ─── RFC 8292: VAPID JWT ───────────────────────────────────────────────
3941
-
3942
- /**
3943
- * Build a VAPID `Authorization` JWT for a single push.
3944
- *
3945
- * @param {Object} args
3946
- * @param {string} args.audience - Origin of the push endpoint (e.g. https://fcm.googleapis.com).
3947
- * @param {string} args.subject - VAPID `sub` claim, typically `mailto:you@example.com`.
3948
- * @param {string} args.publicKey - base64url, 65 B uncompressed P-256 point.
3949
- * @param {string} args.privateKey - base64url, 32 B scalar.
3950
- * @returns {Promise<string>} compact JWS (three base64url segments).
3951
- */
3952
- async function buildVapidJwt({ audience, subject, publicKey, privateKey }) {
3953
- const header = jsonToBase64Url({ typ: 'JWT', alg: 'ES256' });
3954
- const payload = jsonToBase64Url({
3955
- aud: audience,
3956
- exp: Math.floor(Date.now() / 1000) + VAPID_TOKEN_LIFETIME,
3957
- sub: subject,
3958
- });
3959
-
3960
- const signingInput = utf8$1(`${header}.${payload}`);
3961
-
3962
- const pubBytes = base64UrlToBytes(publicKey);
3963
- const privBytes = base64UrlToBytes(privateKey);
3964
- if (pubBytes.length !== 65 || pubBytes[0] !== 0x04) {
3965
- throw new Error('VAPID publicKey must be a 65-byte uncompressed P-256 point (base64url).');
3966
- }
3967
- if (privBytes.length !== 32) {
3968
- throw new Error('VAPID privateKey must be a 32-byte scalar (base64url).');
3969
- }
3970
-
3971
- // Import as JWK so we can supply both private scalar (d) and public point
3972
- // (x, y) in one step — required by WebCrypto for ECDSA signing.
3973
- const jwk = {
3974
- kty: 'EC',
3975
- crv: 'P-256',
3976
- d: bytesToBase64Url(privBytes),
3977
- x: bytesToBase64Url(pubBytes.subarray(1, 33)),
3978
- y: bytesToBase64Url(pubBytes.subarray(33, 65)),
3979
- ext: true,
3980
- };
3981
- const key = await globalThis.crypto.subtle.importKey(
3982
- 'jwk',
3983
- jwk,
3984
- { name: 'ECDSA', namedCurve: 'P-256' },
3985
- false,
3986
- ['sign']
3987
- );
3988
-
3989
- // WebCrypto ECDSA produces a raw 64-byte (r || s) signature — exactly the
3990
- // wire format JOSE/JWS expects, so no DER unwrapping is needed.
3991
- const sig = await globalThis.crypto.subtle.sign(
3992
- { name: 'ECDSA', hash: 'SHA-256' },
3993
- key,
3994
- signingInput
3995
- );
3996
-
3997
- return `${header}.${payload}.${bytesToBase64Url(sig)}`;
3998
- }
3999
-
4000
- // ─── Helpers ───────────────────────────────────────────────────────────
4001
-
4002
- function originOf(endpoint) {
4003
- return new URL(endpoint).origin;
4004
- }
4005
-
4006
- function writeUint32BE(buf, offset, value) {
4007
- buf[offset] = (value >>> 24) & 0xff;
4008
- buf[offset + 1] = (value >>> 16) & 0xff;
4009
- buf[offset + 2] = (value >>> 8) & 0xff;
4010
- buf[offset + 3] = value & 0xff;
4011
- }
4012
-
4013
- async function safeReadText(res) {
4014
- try {
4015
- return await res.text();
4016
- } catch {
4017
- return '';
4018
- }
4019
- }
4020
-
4021
- // Scheduled reminders must survive an offline device, so default to the same
4022
- // 4-week TTL the web-push npm backend applies. sendWebPush's module-level
4023
- // VAPID_DEFAULT_TTL (60s) is tuned for single-shot instant pushes; using it for
4024
- // durable schedules would drop any reminder whose device was offline > 1 min.
4025
- const SCHEDULED_DEFAULT_TTL = 2419200; // 4 weeks, in seconds
4026
-
4027
- /**
4028
- * web-push-compatible sender backed by the Web Crypto implementation above.
4029
- * message-processor calls `ctx.webpush.sendNotification(subscription, payloadString)`,
4030
- * so we only need that one method. VAPID keys are baked in at construction.
4031
- *
4032
- * @param {{ email: string, publicKey: string, privateKey: string }} [vapid]
4033
- * @param {{ ttl?: number }} [options] - Push TTL in seconds; defaults to 4 weeks
4034
- * (matches the web-push backend) so scheduled pushes outlive an offline device.
4035
- * @returns {{ sendNotification: (subscription: Object, payload: string) => Promise<any> }}
4036
- */
4037
- function createWebCryptoWebPush(vapid = {}, { ttl = SCHEDULED_DEFAULT_TTL } = {}) {
4038
- return {
4039
- async sendNotification(subscription, payload) {
4040
- return sendWebPush({
4041
- subscription,
4042
- payload,
4043
- ttl,
4044
- vapid: {
4045
- email: vapid.email,
4046
- publicKey: vapid.publicKey,
4047
- privateKey: vapid.privateKey
4048
- },
4049
- fetch: globalThis.fetch
4050
- });
4051
- }
4052
- };
4053
- }
4054
-
4055
- /**
4056
- * Cloudflare Worker factory for the single-user amsg-server.
4057
- *
4058
- * Mirrors instant's createCloudflareWorker: you pass a buildConfig(env) that
4059
- * returns the single-user config; we build the server per request (cheap) and
4060
- * dispatch. Returns { fetch, scheduled } for `export default`.
4061
- *
4062
- * Routes (server endpoints only — NO /send-notifications; cron is scheduled()):
4063
- * POST /init-tenant → build tables (idempotent)
4064
- * GET /get-user-key → derive user key
4065
- * POST /schedule-message → create task
4066
- * GET /messages → list
4067
- * PUT /update-message → patch
4068
- * DELETE /cancel-message → delete
4069
- * GET /vapid-public-key → this worker's VAPID public key (for the frontend's
4070
- * Web Push subscription); 503 if VAPID_PUBLIC_KEY unset
4071
- * GET /capabilities → { serverVersion, features }(特性探测;老部署无此路由 → 404)
4072
- * PUT /client-state → batch upsert client state (last-write-wins on updatedAt)
4073
- * GET /client-state → read one namespace's entries (?namespace=<ns>)
4074
- * DELETE /client-state → wipe this user's client state
4075
- *
4076
- * CORS is opt-in: pass `cors: { origin }` in the config (a fixed origin, '*', or
4077
- * an (origin) => allowedOrigin function) to answer OPTIONS preflights and echo
4078
- * Access-Control-* on responses. With no `cors` the Worker stays same-origin.
4079
- *
4080
- * Fire-time hooks are opt-in too: pass `hooks: { onBeforeFire, onLLMOutput,
4081
- * executeToolCalls }` (+ optional `maxToolIterations` / `totalTimeoutMs`) in the
4082
- * config to let scheduled AI tasks assemble their prompt and run a server-side
4083
- * tool loop at fire time. Omit them and AI tasks replay the schedule-time frozen
4084
- * prompt exactly as before. See lib/agentic-fire.js.
4085
- *
4086
- * scheduled() 每次触发都会先给任务占位(在行的 lease_until 上写租约),同一
4087
- * 条任务不会被相邻两跳重复触发(见 lib/run-tick.js)。租期默认 10 分钟,可以
4088
- * 用 config 里的 `claimLeaseMs` 调整。
4089
- */
4090
-
4091
-
4092
- function headersToObject(h) {
4093
- const out = {};
4094
- for (const [k, v] of h) out[k] = v;
4095
- return out;
4096
- }
4097
-
4098
- function jsonResponse(status, body, extraHeaders) {
4099
- return new Response(JSON.stringify(body), {
4100
- status,
4101
- headers: { 'Content-Type': 'application/json; charset=utf-8', ...(extraHeaders || {}) }
4102
- });
4103
- }
4104
-
4105
- // The custom headers the amsg-client sends; browsers preflight any request
4106
- // carrying them, so cross-origin callers need them echoed in the CORS response.
4107
- const CORS_ALLOW_HEADERS =
4108
- 'Content-Type, X-User-Id, X-Payload-Encrypted, X-Encryption-Version, X-Response-Encrypted, X-Client-Token';
4109
- const CORS_ALLOW_METHODS = 'GET, POST, PUT, DELETE, OPTIONS';
4110
-
4111
- /**
4112
- * Resolve the CORS response headers for a request, or null when CORS is off.
4113
- * Opt-in: with no `cors` config the Worker stays same-origin (no headers, and
4114
- * OPTIONS falls through to 404) — so nothing is exposed unless asked for.
4115
- *
4116
- * @param {undefined | { origin: string | ((requestOrigin: string) => string|null|undefined), allowHeaders?: string, maxAge?: number }} cors
4117
- * @param {string} requestOrigin - the request's Origin header (may be '')
4118
- */
4119
- function corsHeadersFor(cors, requestOrigin) {
4120
- if (!cors || cors.origin == null) return null;
4121
- const allowOrigin = typeof cors.origin === 'function'
4122
- ? cors.origin(requestOrigin) || null
4123
- : cors.origin; // e.g. '*' or a fixed origin like 'https://app.example.com'
4124
- if (!allowOrigin) return null;
4125
-
4126
- const headers = {
4127
- 'Access-Control-Allow-Origin': allowOrigin,
4128
- 'Access-Control-Allow-Methods': CORS_ALLOW_METHODS,
4129
- 'Access-Control-Allow-Headers': cors.allowHeaders || CORS_ALLOW_HEADERS,
4130
- 'Access-Control-Max-Age': String(cors.maxAge ?? 86400)
4131
- };
4132
- // A per-origin echo must vary the cache by Origin; '*' does not.
4133
- if (allowOrigin !== '*') headers['Vary'] = 'Origin';
4134
- return headers;
4135
- }
4136
-
4137
- function createSingleUserCloudflareWorker(buildConfig) {
4138
- async function resolveConfig(env) {
4139
- const cfg = await buildConfig(env);
4140
- if (!cfg.db) cfg.db = createD1Adapter(env.DB);
4141
- return cfg;
4142
- }
4143
-
4144
- async function fetch(request, env /* , ctx */) {
4145
- // Error boundary: a handler (or config build) may throw — e.g.
4146
- // schedule-message re-throws a non-unique DB error. Keep the client-facing
4147
- // contract consistent (a JSON envelope, not the runtime's HTML error page).
4148
- try {
4149
- const cfg = await resolveConfig(env);
4150
- const cors = corsHeadersFor(cfg.cors, request.headers.get('origin') || '');
4151
- const method = request.method.toUpperCase();
4152
-
4153
- // CORS preflight: answer OPTIONS directly when CORS is configured.
4154
- if (method === 'OPTIONS') {
4155
- return cors
4156
- ? new Response(null, { status: 204, headers: cors })
4157
- : jsonResponse(404, { success: false, error: { code: 'NOT_FOUND', message: 'Unknown route' } });
4158
- }
4159
-
4160
- const server = createSingleUserServer(cfg);
4161
-
4162
- const url = request.url;
4163
- // Strip trailing slash(es) so `/init-tenant/` routes like `/init-tenant`
4164
- // (endsWith matching is kept so a prefixed mount still resolves).
4165
- const pathname = new URL(url).pathname.replace(/\/+$/, '') || '/';
4166
- const headers = headersToObject(request.headers);
4167
-
4168
- let result;
4169
- if (method === 'POST' && pathname.endsWith('/init-tenant')) {
4170
- result = await server.handlers.init.POST(headers, await request.text());
4171
- } else if (method === 'GET' && pathname.endsWith('/get-user-key')) {
4172
- result = await server.handlers.getUserKey.GET(url, headers);
4173
- } else if (method === 'POST' && pathname.endsWith('/schedule-message')) {
4174
- result = await server.handlers.scheduleMessage.POST(headers, await request.text());
4175
- } else if (method === 'GET' && pathname.endsWith('/messages')) {
4176
- result = await server.handlers.messages.GET(url, headers);
4177
- } else if (method === 'PUT' && pathname.endsWith('/update-message')) {
4178
- result = await server.handlers.updateMessage.PUT(url, headers, await request.text());
4179
- } else if (method === 'DELETE' && pathname.endsWith('/cancel-message')) {
4180
- result = await server.handlers.cancelMessage.DELETE(url, headers);
4181
- } else if (method === 'GET' && pathname.endsWith('/vapid-public-key')) {
4182
- result = await server.handlers.vapidPublicKey.GET(url, headers);
4183
- } else if (method === 'GET' && pathname.endsWith('/capabilities')) {
4184
- result = await server.handlers.capabilities.GET(url, headers);
4185
- } else if (method === 'PUT' && pathname.endsWith('/client-state')) {
4186
- result = await server.handlers.clientState.PUT(headers, await request.text());
4187
- } else if (method === 'GET' && pathname.endsWith('/client-state')) {
4188
- result = await server.handlers.clientState.GET(url, headers);
4189
- } else if (method === 'DELETE' && pathname.endsWith('/client-state')) {
4190
- result = await server.handlers.clientState.DELETE(url, headers);
4191
- } else {
4192
- result = { status: 404, body: { success: false, error: { code: 'NOT_FOUND', message: 'Unknown route' } } };
4193
- }
4194
-
4195
- return jsonResponse(result.status, result.body, cors);
4196
- } catch (error) {
4197
- console.error('[amsg single-user] fetch() unhandled error:', error && error.message);
4198
- return jsonResponse(500, { success: false, error: { code: 'INTERNAL_ERROR', message: '服务器内部错误' } });
4199
- }
4200
- }
4201
-
4202
- async function scheduled(event, env /* , ctx */) {
4203
- const cfg = await resolveConfig(env);
4204
- const vapid = cfg.vapid || {};
4205
- if (!cfg.webpush || !vapid.email || !vapid.publicKey || !vapid.privateKey) {
4206
- console.error('[amsg single-user] scheduled(): VAPID/webpush not configured; skipping tick');
4207
- return;
4208
- }
4209
- // Swallow tick failures: pending tasks stay pending, so the next cron tick
4210
- // retries them. Logging keeps the failure visible in the tail log.
4211
- try {
4212
- await runScheduledTick({
4213
- db: cfg.db,
4214
- masterKey: cfg.masterKey,
4215
- vapid,
4216
- webpush: cfg.webpush,
4217
- // Fire-time hooks (optional; see lib/agentic-fire.js). runScheduledTick
4218
- // spreads its ctx into processSingleMessage, so these ride along.
4219
- hooks: cfg.hooks || null,
4220
- maxToolIterations: cfg.maxToolIterations,
4221
- totalTimeoutMs: cfg.totalTimeoutMs,
4222
- // hook 的 ctx.writeState() 用它判断单条 value 的上限,和 PUT /client-state 同一个配置。
4223
- maxStateValueBytes: cfg.maxStateValueBytes,
4224
- // 任务占位租期(默认 10 分钟,随 totalTimeoutMs 抬高)。
4225
- claimLeaseMs: cfg.claimLeaseMs
4226
- });
4227
- } catch (error) {
4228
- console.error('[amsg single-user] scheduled(): tick failed:', error && error.message);
4229
- }
4230
- }
4231
-
4232
- return { fetch, scheduled };
4233
- }
4234
-
4235
- export { MAX_PUSH_PAYLOAD_BYTES as M, WEB_PUSH_ENCRYPTION_OVERHEAD_BYTES as W, createCancelMessageHandler as a, createUpdateMessageHandler as b, createMessagesHandler as c, createScheduleMessageHandler as d, createGetUserKeyHandler as e, WEB_PUSH_MAX_BODY_BYTES as f, getHeader as g, createD1Adapter as h, isValidUUIDv4 as i, createSingleUserCloudflareWorker as j, createSingleUserServer as k, createWebCryptoWebPush as l, decryptFromStorage as m, decryptPayload as n, deriveUserEncryptionKey as o, parseJsonBody as p, encryptForStorage as q, runScheduledTick as r, isValidISO8601 as s, isValidUUID as t, isValidUrl as u, measurePushPayload as v, validateLlmMessagesArray as w, validateScheduleMessagePayload as x, validateSplitPattern as y };