@takosjp/yurucommu-core 3.0.3 → 3.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.en.md +92 -0
  2. package/README.md +56 -47
  3. package/migrations/0019_notification_push_delivery.sql +103 -0
  4. package/migrations/README.md +7 -6
  5. package/package.json +11 -5
  6. package/packages/api/package.json +1 -1
  7. package/packages/api/src/lib/api/browser-push.ts +545 -0
  8. package/packages/api/src/lib/api/communities.ts +25 -2
  9. package/packages/api/src/lib/api/dm.ts +14 -2
  10. package/packages/api/src/lib/api/normalize.ts +15 -4
  11. package/packages/api/src/lib/api/notification-target.ts +106 -0
  12. package/packages/api/src/lib/api/notifications.ts +53 -1
  13. package/packages/api/src/lib/api/push-config.ts +132 -0
  14. package/packages/api/src/lib/api.ts +3 -0
  15. package/packages/api/src/social-server.ts +9 -0
  16. package/packages/api/src/types/index.ts +48 -0
  17. package/src/backend/index.ts +67 -3
  18. package/src/backend/lib/attachments.ts +52 -0
  19. package/src/backend/lib/delivery/queue.ts +73 -0
  20. package/src/backend/lib/delivery/types.ts +15 -1
  21. package/src/backend/lib/notification-eligibility.ts +150 -0
  22. package/src/backend/lib/notification-push.ts +1213 -0
  23. package/src/backend/lib/notification-pusher-contract.ts +340 -0
  24. package/src/backend/lib/oauth-providers.ts +7 -6
  25. package/src/backend/lib/session-actor.ts +16 -1
  26. package/src/backend/lib/unread-counts.ts +79 -0
  27. package/src/backend/middleware/csrf.ts +11 -0
  28. package/src/backend/routes/account-teardown.ts +13 -0
  29. package/src/backend/routes/auth-helpers.ts +10 -7
  30. package/src/backend/routes/auth.ts +124 -2
  31. package/src/backend/routes/communities/messages.ts +123 -9
  32. package/src/backend/routes/dm/contacts.ts +6 -44
  33. package/src/backend/routes/dm/messages.ts +51 -4
  34. package/src/backend/routes/notification-pushers.ts +93 -0
  35. package/src/backend/routes/notifications.ts +76 -69
  36. package/src/backend/routes/posts/transformers.ts +8 -10
  37. package/src/backend/server.ts +6 -0
  38. package/src/backend/types.ts +12 -0
  39. package/src/db/index.ts +11 -10
  40. package/src/db/schema/mobile.ts +99 -1
@@ -0,0 +1,1213 @@
1
+ import type { Message } from "@cloudflare/workers-types";
2
+ import { and, asc, eq, exists, inArray, lte, ne, sql } from "drizzle-orm";
3
+
4
+ import {
5
+ activities,
6
+ affectedRowCount,
7
+ communityMembers,
8
+ inbox,
9
+ notificationPushers,
10
+ notificationPushJobs,
11
+ objectRecipients,
12
+ objects,
13
+ type Database,
14
+ } from "../../db/index.ts";
15
+ import type { Actor, Env } from "../types.ts";
16
+ import {
17
+ isLoopbackGatewayUrl,
18
+ normalizeGatewayUrl,
19
+ type JsonObject,
20
+ type ParsedNotificationPusherDeleteRequest,
21
+ type ParsedNotificationPusherSetRequest,
22
+ type SocialNotificationProduct,
23
+ } from "./notification-pusher-contract.ts";
24
+ import {
25
+ DELIVERY_QUEUE_MESSAGE_VERSION,
26
+ type DeliveryNotificationPushMessageV1,
27
+ type DeliveryQueueMessageV1,
28
+ } from "./delivery/types.ts";
29
+ import { excludeBlockedMutedAuthors } from "./feed-exclude.ts";
30
+ import {
31
+ NOTIFICATION_ACTIVITY_TYPES,
32
+ notificationEligibilityWhere,
33
+ } from "./notification-eligibility.ts";
34
+ import { yurumeUnreadCounts } from "./unread-counts.ts";
35
+ import { logger } from "./logger.ts";
36
+ import { generateId } from "./oauth-utils.ts";
37
+
38
+ const log = logger.child({ component: "notification.push" });
39
+
40
+ export const MAX_NOTIFICATION_PUSHERS_PER_PRODUCT = 16;
41
+ export const MAX_NOTIFICATION_PUSHERS_PER_APP = 8;
42
+ export const MAX_NOTIFICATION_PUSH_DISPATCH = 16;
43
+ export const MAX_NOTIFICATION_PUSH_ATTEMPTS = 5;
44
+ export const NOTIFICATION_PUSHER_RETENTION_DAYS = 90;
45
+ export const NOTIFICATION_PUSH_JOB_RETENTION_DAYS = 90;
46
+ export const MAX_NOTIFICATION_PUSH_JOB_PURGE = 50;
47
+ export const MAX_NOTIFICATION_PUSHER_PURGE = 50;
48
+
49
+ const DEFAULT_GATEWAY_TIMEOUT_MS = 10_000;
50
+ const MAX_GATEWAY_RESPONSE_BYTES = 64 * 1024;
51
+ const MAX_QUEUE_SCAN = 50;
52
+ const STALE_PROCESSING_MS = 2 * 60 * 1000;
53
+ // A 'queued'/'processing' row whose queue message is GONE (auto-dead-lettered
54
+ // after max retries with the raw body, dropped after queue retention, consumer
55
+ // down long enough) is otherwise unreachable: the sweep sends only
56
+ // pending/retry_wait and message-based recovery needs a message. Reclaim such
57
+ // rows to 'pending' once they are far staler than any live in-flight state —
58
+ // a live processing owner refreshes its lease before every gateway call
59
+ // (≤ ~40s between touches) and a queued message is consumed or dead-lettered
60
+ // within minutes.
61
+ const STALE_INFLIGHT_RECLAIM_MS = 15 * 60 * 1000;
62
+
63
+ export interface NotificationPusherRegistrationResponse {
64
+ readonly id: string;
65
+ readonly kind: "http";
66
+ readonly app_id: string;
67
+ readonly app_display_name?: string;
68
+ readonly device_display_name?: string;
69
+ readonly profile_tag?: string;
70
+ readonly lang?: string;
71
+ readonly data: JsonObject;
72
+ readonly gateway_url: string;
73
+ readonly product: SocialNotificationProduct;
74
+ readonly scope: string | null;
75
+ readonly registered_at: string;
76
+ readonly last_seen_at: string;
77
+ }
78
+
79
+ type StoredPusher = {
80
+ id: string;
81
+ actorApId: string;
82
+ product: string;
83
+ appId: string;
84
+ pushkey: string;
85
+ pushkeyHash: string;
86
+ dataJson: string;
87
+ gatewayUrl: string;
88
+ };
89
+
90
+ type GatewayResult = {
91
+ retryIds: string[];
92
+ retryAfterSeconds: number;
93
+ error: string | null;
94
+ };
95
+
96
+ type NotificationPushFormat = "event_id_only" | "full";
97
+
98
+ type GatewayDispatchGroup = {
99
+ gatewayUrl: string;
100
+ format: NotificationPushFormat;
101
+ pushers: StoredPusher[];
102
+ };
103
+
104
+ type ProcessingLease = {
105
+ jobId: string;
106
+ processingToken: string;
107
+ };
108
+
109
+ export function isNotificationGatewayAllowed(env: Env, value: string): boolean {
110
+ const normalized = normalizeGatewayUrl(value);
111
+ if (!normalized) return false;
112
+ if (isLoopbackGatewayUrl(normalized)) {
113
+ return isTruthy(env.YURUCOMMU_NOTIFICATION_PUSH_ALLOW_INSECURE_LOOPBACK);
114
+ }
115
+ const host = new URL(normalized).hostname.toLowerCase();
116
+ const allowed = new Set(
117
+ (env.YURUCOMMU_NOTIFICATION_PUSH_GATEWAY_ALLOWED_HOSTS ?? "")
118
+ .split(",")
119
+ .map((entry) => entry.trim().toLowerCase())
120
+ .filter(Boolean),
121
+ );
122
+ return allowed.has(host);
123
+ }
124
+
125
+ export async function registerNotificationPusher(
126
+ db: Database,
127
+ actor: Actor,
128
+ input: ParsedNotificationPusherSetRequest,
129
+ ): Promise<NotificationPusherRegistrationResponse> {
130
+ const now = new Date().toISOString();
131
+ const pushkeyHash = await sha256Hex(input.pusher.pushkey);
132
+
133
+ // The device key is globally unique inside product+app. Reassigning that row
134
+ // is one atomic upsert, so concurrent logins cannot leave duplicate owners.
135
+ const existing = await db
136
+ .select({
137
+ id: notificationPushers.id,
138
+ actorApId: notificationPushers.actorApId,
139
+ })
140
+ .from(notificationPushers)
141
+ .where(
142
+ and(
143
+ eq(notificationPushers.product, input.product),
144
+ eq(notificationPushers.appId, input.pusher.app_id),
145
+ eq(notificationPushers.pushkeyHash, pushkeyHash),
146
+ ),
147
+ )
148
+ .get();
149
+
150
+ const sameActor = existing?.actorApId === actor.ap_id;
151
+ if (!sameActor) {
152
+ await enforcePusherQuota(
153
+ db,
154
+ actor.ap_id,
155
+ input.product,
156
+ input.pusher.app_id,
157
+ );
158
+ }
159
+ const registrationId = sameActor ? existing.id : generateId(16);
160
+
161
+ await db
162
+ .insert(notificationPushers)
163
+ .values({
164
+ id: registrationId,
165
+ actorApId: actor.ap_id,
166
+ product: input.product,
167
+ scope: input.scope,
168
+ kind: "http",
169
+ appId: input.pusher.app_id,
170
+ pushkey: input.pusher.pushkey,
171
+ pushkeyHash,
172
+ appDisplayName: input.pusher.app_display_name ?? null,
173
+ deviceDisplayName: input.pusher.device_display_name ?? null,
174
+ profileTag: input.pusher.profile_tag ?? null,
175
+ lang: input.pusher.lang ?? null,
176
+ dataJson: JSON.stringify(input.storedData),
177
+ gatewayUrl: input.gatewayUrl,
178
+ createdAt: now,
179
+ updatedAt: now,
180
+ lastSeenAt: now,
181
+ })
182
+ .onConflictDoUpdate({
183
+ target: [
184
+ notificationPushers.product,
185
+ notificationPushers.appId,
186
+ notificationPushers.pushkeyHash,
187
+ ],
188
+ set: {
189
+ id: registrationId,
190
+ actorApId: actor.ap_id,
191
+ kind: "http",
192
+ pushkey: input.pusher.pushkey,
193
+ scope: input.scope,
194
+ appDisplayName: input.pusher.app_display_name ?? null,
195
+ deviceDisplayName: input.pusher.device_display_name ?? null,
196
+ profileTag: input.pusher.profile_tag ?? null,
197
+ lang: input.pusher.lang ?? null,
198
+ dataJson: JSON.stringify(input.storedData),
199
+ gatewayUrl: input.gatewayUrl,
200
+ ...(sameActor ? {} : { createdAt: now }),
201
+ updatedAt: now,
202
+ lastSeenAt: now,
203
+ },
204
+ });
205
+
206
+ const row = await db
207
+ .select()
208
+ .from(notificationPushers)
209
+ .where(
210
+ and(
211
+ eq(notificationPushers.actorApId, actor.ap_id),
212
+ eq(notificationPushers.product, input.product),
213
+ eq(notificationPushers.appId, input.pusher.app_id),
214
+ eq(notificationPushers.pushkeyHash, pushkeyHash),
215
+ ),
216
+ )
217
+ .get();
218
+ if (!row) throw new Error("Failed to register notification pusher");
219
+ return {
220
+ id: row.id,
221
+ kind: "http",
222
+ app_id: row.appId,
223
+ ...(row.appDisplayName ? { app_display_name: row.appDisplayName } : {}),
224
+ ...(row.deviceDisplayName
225
+ ? { device_display_name: row.deviceDisplayName }
226
+ : {}),
227
+ ...(row.profileTag ? { profile_tag: row.profileTag } : {}),
228
+ ...(row.lang ? { lang: row.lang } : {}),
229
+ data: parseStoredData(row.dataJson),
230
+ gateway_url: row.gatewayUrl,
231
+ product: input.product,
232
+ scope: row.scope,
233
+ registered_at: row.createdAt,
234
+ last_seen_at: row.lastSeenAt,
235
+ };
236
+ }
237
+
238
+ export async function deleteNotificationPusher(
239
+ db: Database,
240
+ actor: Actor,
241
+ input: ParsedNotificationPusherDeleteRequest,
242
+ ): Promise<void> {
243
+ const hash = await sha256Hex(input.pushkey);
244
+ await db
245
+ .delete(notificationPushers)
246
+ .where(
247
+ and(
248
+ eq(notificationPushers.actorApId, actor.ap_id),
249
+ eq(notificationPushers.product, input.product),
250
+ eq(notificationPushers.appId, input.appId),
251
+ eq(notificationPushers.pushkeyHash, hash),
252
+ eq(notificationPushers.pushkey, input.pushkey),
253
+ input.scope === null
254
+ ? undefined
255
+ : eq(notificationPushers.scope, input.scope),
256
+ ),
257
+ );
258
+ }
259
+
260
+ async function enforcePusherQuota(
261
+ db: Database,
262
+ actorApId: string,
263
+ product: SocialNotificationProduct,
264
+ appId: string,
265
+ ): Promise<void> {
266
+ const [sameApp, sameProduct] = await Promise.all([
267
+ db
268
+ .select({ id: notificationPushers.id })
269
+ .from(notificationPushers)
270
+ .where(
271
+ and(
272
+ eq(notificationPushers.actorApId, actorApId),
273
+ eq(notificationPushers.product, product),
274
+ eq(notificationPushers.appId, appId),
275
+ ),
276
+ )
277
+ .orderBy(asc(notificationPushers.createdAt)),
278
+ db
279
+ .select({ id: notificationPushers.id })
280
+ .from(notificationPushers)
281
+ .where(
282
+ and(
283
+ eq(notificationPushers.actorApId, actorApId),
284
+ eq(notificationPushers.product, product),
285
+ ),
286
+ )
287
+ .orderBy(asc(notificationPushers.createdAt)),
288
+ ]);
289
+ const evict = new Set<string>();
290
+ for (const row of sameApp.slice(
291
+ 0,
292
+ Math.max(0, sameApp.length + 1 - MAX_NOTIFICATION_PUSHERS_PER_APP),
293
+ )) {
294
+ evict.add(row.id);
295
+ }
296
+ const remainingProduct = sameProduct.filter((row) => !evict.has(row.id));
297
+ for (const row of remainingProduct.slice(
298
+ 0,
299
+ Math.max(
300
+ 0,
301
+ remainingProduct.length + 1 - MAX_NOTIFICATION_PUSHERS_PER_PRODUCT,
302
+ ),
303
+ )) {
304
+ evict.add(row.id);
305
+ }
306
+ if (evict.size > 0) {
307
+ await db
308
+ .delete(notificationPushers)
309
+ .where(inArray(notificationPushers.id, [...evict]));
310
+ }
311
+ }
312
+
313
+ export function buildNotificationPushMessage(
314
+ jobId: string,
315
+ ): DeliveryNotificationPushMessageV1 {
316
+ return {
317
+ version: DELIVERY_QUEUE_MESSAGE_VERSION,
318
+ type: "notification_push",
319
+ jobId,
320
+ scheduledAt: new Date().toISOString(),
321
+ };
322
+ }
323
+
324
+ /** Enqueue durable outbox rows. Safe to call after every request/queue batch. */
325
+ export async function enqueuePendingNotificationPushJobs(
326
+ env: Env,
327
+ ): Promise<number> {
328
+ const db = env.DB_INSTANCE;
329
+ // Expired rows are retained long enough to preserve the deterministic job
330
+ // idempotency window, then removed opportunistically in a bounded batch.
331
+ // This runs even without a Queue binding so disabled push delivery cannot
332
+ // turn the outbox ledger into unbounded storage. Stale pushers are purged
333
+ // here too (bounded), NOT inside per-job processing.
334
+ await purgeExpiredNotificationPushJobs(db);
335
+ await purgeExpiredNotificationPushers(db);
336
+ if (!env.DELIVERY_QUEUE) return 0;
337
+ const now = new Date().toISOString();
338
+
339
+ // Reclaim in-flight rows whose queue message is gone (see
340
+ // STALE_INFLIGHT_RECLAIM_MS). The reclaim consumes one attempt so a
341
+ // crash-looping job terminates at the same MAX_NOTIFICATION_PUSH_ATTEMPTS
342
+ // budget instead of ping-ponging forever.
343
+ const staleCutoff = new Date(
344
+ Date.now() - STALE_INFLIGHT_RECLAIM_MS,
345
+ ).toISOString();
346
+ await db
347
+ .update(notificationPushJobs)
348
+ .set({
349
+ status: sql`CASE WHEN ${notificationPushJobs.attempts} + 1 >= ${MAX_NOTIFICATION_PUSH_ATTEMPTS} THEN 'failed' ELSE 'pending' END`,
350
+ attempts: sql`${notificationPushJobs.attempts} + 1`,
351
+ processingToken: null,
352
+ lastError: sql`COALESCE(${notificationPushJobs.lastError}, 'reclaimed stale in-flight push job')`,
353
+ updatedAt: now,
354
+ })
355
+ .where(
356
+ and(
357
+ inArray(notificationPushJobs.status, ["queued", "processing"]),
358
+ lte(notificationPushJobs.updatedAt, staleCutoff),
359
+ ),
360
+ );
361
+
362
+ // The trigger creates a job for EVERY unread inbox insert, including
363
+ // recipients with zero pushers. Only enqueue jobs that can actually deliver;
364
+ // pusher-less rows stay pending (no queue traffic, no processing cycle) and
365
+ // age out through the retention purge above.
366
+ const actorHasPusher = exists(
367
+ db
368
+ .select({ id: notificationPushers.id })
369
+ .from(notificationPushers)
370
+ .where(eq(notificationPushers.actorApId, notificationPushJobs.actorApId)),
371
+ );
372
+ const rows = await db
373
+ .select({ id: notificationPushJobs.id })
374
+ .from(notificationPushJobs)
375
+ .where(
376
+ and(
377
+ inArray(notificationPushJobs.status, ["pending", "retry_wait"]),
378
+ lte(notificationPushJobs.nextAttemptAt, now),
379
+ actorHasPusher,
380
+ ),
381
+ )
382
+ .orderBy(asc(notificationPushJobs.createdAt))
383
+ .limit(MAX_QUEUE_SCAN);
384
+ if (rows.length === 0) return 0;
385
+
386
+ await env.DELIVERY_QUEUE.sendBatch(
387
+ rows.map((row) => ({ body: buildNotificationPushMessage(row.id) })),
388
+ );
389
+ await db
390
+ .update(notificationPushJobs)
391
+ .set({ status: "queued", processingToken: null, updatedAt: now })
392
+ .where(
393
+ and(
394
+ inArray(
395
+ notificationPushJobs.id,
396
+ rows.map((row) => row.id),
397
+ ),
398
+ inArray(notificationPushJobs.status, ["pending", "retry_wait"]),
399
+ ),
400
+ );
401
+ return rows.length;
402
+ }
403
+
404
+ /**
405
+ * Reset a dead-lettered push job so the durable outbox can retry it. Called by
406
+ * the DLQ consumer when a `notification_push` message exhausted its Cloudflare
407
+ * Queue retries with the RAW body (automatic dead-lettering) — without this,
408
+ * the job row would be stranded in 'queued'/'processing' forever. Consumes one
409
+ * attempt so a permanently failing job still terminates at the attempts budget.
410
+ */
411
+ export async function recoverDeadLetteredNotificationPushJob(
412
+ db: Database,
413
+ jobId: string,
414
+ ): Promise<boolean> {
415
+ const now = new Date().toISOString();
416
+ const recovered = await db
417
+ .update(notificationPushJobs)
418
+ .set({
419
+ status: sql`CASE WHEN ${notificationPushJobs.attempts} + 1 >= ${MAX_NOTIFICATION_PUSH_ATTEMPTS} THEN 'failed' ELSE 'retry_wait' END`,
420
+ attempts: sql`${notificationPushJobs.attempts} + 1`,
421
+ processingToken: null,
422
+ nextAttemptAt: now,
423
+ lastError: sql`COALESCE(${notificationPushJobs.lastError}, 'queue message dead-lettered')`,
424
+ updatedAt: now,
425
+ })
426
+ .where(
427
+ and(
428
+ eq(notificationPushJobs.id, jobId),
429
+ inArray(notificationPushJobs.status, [
430
+ "pending",
431
+ "queued",
432
+ "processing",
433
+ "retry_wait",
434
+ ]),
435
+ ),
436
+ );
437
+ return affectedRowCount(recovered) > 0;
438
+ }
439
+
440
+ /** Remove at most one bounded batch of pushers idle past the retention window. */
441
+ export async function purgeExpiredNotificationPushers(
442
+ db: Database,
443
+ now = new Date(),
444
+ ): Promise<number> {
445
+ const cutoff = new Date(
446
+ now.getTime() - NOTIFICATION_PUSHER_RETENTION_DAYS * 86_400_000,
447
+ ).toISOString();
448
+ const stale = await db
449
+ .select({ id: notificationPushers.id })
450
+ .from(notificationPushers)
451
+ .where(lte(notificationPushers.lastSeenAt, cutoff))
452
+ .orderBy(asc(notificationPushers.lastSeenAt), asc(notificationPushers.id))
453
+ .limit(MAX_NOTIFICATION_PUSHER_PURGE);
454
+ if (stale.length === 0) return 0;
455
+ const deleted = await db.delete(notificationPushers).where(
456
+ and(
457
+ inArray(
458
+ notificationPushers.id,
459
+ stale.map((row) => row.id),
460
+ ),
461
+ lte(notificationPushers.lastSeenAt, cutoff),
462
+ ),
463
+ );
464
+ return affectedRowCount(deleted);
465
+ }
466
+
467
+ /**
468
+ * Remove at most one bounded batch of jobs past the retention window. ANY
469
+ * status qualifies: terminal rows have served their idempotency window, a
470
+ * pending row that old belongs to a pusher-less recipient (never enqueued —
471
+ * see the sweep's actorHasPusher filter), and no live in-flight state survives
472
+ * 90 days (stale queued/processing rows are reclaimed within minutes).
473
+ */
474
+ export async function purgeExpiredNotificationPushJobs(
475
+ db: Database,
476
+ now = new Date(),
477
+ ): Promise<number> {
478
+ const cutoff = new Date(
479
+ now.getTime() - NOTIFICATION_PUSH_JOB_RETENTION_DAYS * 86_400_000,
480
+ ).toISOString();
481
+ const expired = await db
482
+ .select({ id: notificationPushJobs.id })
483
+ .from(notificationPushJobs)
484
+ .where(lte(notificationPushJobs.updatedAt, cutoff))
485
+ .orderBy(asc(notificationPushJobs.updatedAt), asc(notificationPushJobs.id))
486
+ .limit(MAX_NOTIFICATION_PUSH_JOB_PURGE);
487
+ if (expired.length === 0) return 0;
488
+
489
+ const deleted = await db.delete(notificationPushJobs).where(
490
+ and(
491
+ inArray(
492
+ notificationPushJobs.id,
493
+ expired.map((row) => row.id),
494
+ ),
495
+ lte(notificationPushJobs.updatedAt, cutoff),
496
+ ),
497
+ );
498
+ return affectedRowCount(deleted);
499
+ }
500
+
501
+ export async function processNotificationPushJob(
502
+ env: Env,
503
+ body: DeliveryNotificationPushMessageV1,
504
+ message: Message<DeliveryQueueMessageV1>,
505
+ ): Promise<void> {
506
+ const db = env.DB_INSTANCE;
507
+ let job = await db
508
+ .select()
509
+ .from(notificationPushJobs)
510
+ .where(eq(notificationPushJobs.id, body.jobId))
511
+ .get();
512
+ if (!job || job.status === "delivered" || job.status === "failed") {
513
+ message.ack();
514
+ return;
515
+ }
516
+
517
+ const processingAgeMs = Date.now() - Date.parse(job.updatedAt);
518
+ if (job.status === "processing") {
519
+ if (processingAgeMs < STALE_PROCESSING_MS) {
520
+ message.retry({ delaySeconds: 30 });
521
+ return;
522
+ }
523
+ // Reclaim a stale processing row through an actual status transition.
524
+ // Updating processing -> processing lets two workers that read the same
525
+ // stale row both satisfy the old broad claim predicate and duplicate a
526
+ // push. Only the worker that wins this processing -> queued CAS may proceed.
527
+ const reclaimedAt = new Date().toISOString();
528
+ const reclaimed = await db
529
+ .update(notificationPushJobs)
530
+ .set({
531
+ status: "queued",
532
+ processingToken: null,
533
+ updatedAt: reclaimedAt,
534
+ })
535
+ .where(
536
+ and(
537
+ eq(notificationPushJobs.id, job.id),
538
+ eq(notificationPushJobs.status, "processing"),
539
+ eq(notificationPushJobs.updatedAt, job.updatedAt),
540
+ ),
541
+ );
542
+ if (affectedRowCount(reclaimed) === 0) {
543
+ message.retry({ delaySeconds: 30 });
544
+ return;
545
+ }
546
+ job = {
547
+ ...job,
548
+ status: "queued",
549
+ processingToken: null,
550
+ updatedAt: reclaimedAt,
551
+ };
552
+ }
553
+
554
+ const now = new Date().toISOString();
555
+ const processingToken = generateId(16);
556
+ const claimed = await db
557
+ .update(notificationPushJobs)
558
+ .set({ status: "processing", processingToken, updatedAt: now })
559
+ .where(
560
+ and(
561
+ eq(notificationPushJobs.id, job.id),
562
+ eq(notificationPushJobs.status, job.status),
563
+ eq(notificationPushJobs.updatedAt, job.updatedAt),
564
+ // Honor the backoff schedule: a duplicate message that survived a
565
+ // double-enqueue race must not claim a retry_wait row before its
566
+ // nextAttemptAt, which would bypass exponential backoff / Retry-After
567
+ // and hammer a gateway that is actively rate-limiting us.
568
+ lte(notificationPushJobs.nextAttemptAt, now),
569
+ ),
570
+ );
571
+ if (affectedRowCount(claimed) === 0) {
572
+ // Another Queue delivery owns this job now, or it is not yet due. Do not
573
+ // ack the competing message permanently: a retry lets it observe the
574
+ // terminal row, become due, or recover if that owner crashes after claiming.
575
+ message.retry({ delaySeconds: 30 });
576
+ return;
577
+ }
578
+ const lease: ProcessingLease = { jobId: job.id, processingToken };
579
+
580
+ try {
581
+ const explicitProduct =
582
+ job.product === "yurucommu" || job.product === "yurume"
583
+ ? job.product
584
+ : null;
585
+ const event = await loadPushEvent(
586
+ db,
587
+ job.actorApId,
588
+ job.activityApId,
589
+ explicitProduct,
590
+ );
591
+ if (!event) {
592
+ await finishJob(db, lease, "notification is no longer eligible");
593
+ message.ack();
594
+ return;
595
+ }
596
+
597
+ const product: SocialNotificationProduct =
598
+ explicitProduct ??
599
+ (event.visibility === "direct" ? "yurume" : "yurucommu");
600
+ let pendingIds = parsePendingIds(job.pendingPusherIdsJson);
601
+ if (pendingIds === null) {
602
+ // Retention purge of idle pushers is a bounded sweep in
603
+ // enqueuePendingNotificationPushJobs, NOT an unbounded all-actor DELETE
604
+ // on this hot per-job path. Stale rows simply resolve to zero deliveries
605
+ // here and get reaped by the sweep.
606
+ pendingIds = (
607
+ await db
608
+ .select({ id: notificationPushers.id })
609
+ .from(notificationPushers)
610
+ .where(
611
+ and(
612
+ eq(notificationPushers.actorApId, job.actorApId),
613
+ eq(notificationPushers.product, product),
614
+ lte(notificationPushers.createdAt, job.createdAt),
615
+ ),
616
+ )
617
+ .orderBy(asc(notificationPushers.createdAt))
618
+ .limit(MAX_NOTIFICATION_PUSH_DISPATCH)
619
+ ).map((row) => row.id);
620
+ const pendingIdsUpdated = await db
621
+ .update(notificationPushJobs)
622
+ .set({
623
+ pendingPusherIdsJson: JSON.stringify(pendingIds),
624
+ updatedAt: now,
625
+ })
626
+ .where(processingLeaseWhere(lease));
627
+ if (affectedRowCount(pendingIdsUpdated) === 0) {
628
+ message.ack();
629
+ return;
630
+ }
631
+ }
632
+
633
+ if (pendingIds.length === 0) {
634
+ await finishJob(db, lease, null);
635
+ message.ack();
636
+ return;
637
+ }
638
+
639
+ const pushers = (await db
640
+ .select({
641
+ id: notificationPushers.id,
642
+ actorApId: notificationPushers.actorApId,
643
+ product: notificationPushers.product,
644
+ appId: notificationPushers.appId,
645
+ pushkey: notificationPushers.pushkey,
646
+ pushkeyHash: notificationPushers.pushkeyHash,
647
+ dataJson: notificationPushers.dataJson,
648
+ gatewayUrl: notificationPushers.gatewayUrl,
649
+ })
650
+ .from(notificationPushers)
651
+ .where(
652
+ and(
653
+ inArray(notificationPushers.id, pendingIds),
654
+ eq(notificationPushers.actorApId, job.actorApId),
655
+ eq(notificationPushers.product, product),
656
+ ),
657
+ )) as StoredPusher[];
658
+ if (pushers.length === 0) {
659
+ await finishJob(db, lease, null);
660
+ message.ack();
661
+ return;
662
+ }
663
+
664
+ const unread = await unreadCountForProduct(db, job.actorApId, product);
665
+ const grouped = groupByGatewayAndFormat(pushers);
666
+ const retryIds: string[] = [];
667
+ let retryAfterSeconds = 0;
668
+ const errors: string[] = [];
669
+ for (const group of grouped.values()) {
670
+ // A job can fan out to sixteen different gateways. Refresh the durable
671
+ // lease before each bounded network call so a healthy worker cannot age
672
+ // past the stale-reclaim window while progressing through that fanout.
673
+ if (!(await refreshProcessingLease(db, lease))) {
674
+ message.ack();
675
+ return;
676
+ }
677
+ const outcome = await deliverGatewayGroup(
678
+ env,
679
+ db,
680
+ group.gatewayUrl,
681
+ group.pushers,
682
+ group.format,
683
+ {
684
+ id: event.activityApId,
685
+ type: event.visibility === "direct" ? "dm" : event.type.toLowerCase(),
686
+ sender: event.actorApId,
687
+ scopeId: event.objectApId,
688
+ unread,
689
+ },
690
+ );
691
+ retryIds.push(...outcome.retryIds);
692
+ retryAfterSeconds = Math.max(
693
+ retryAfterSeconds,
694
+ outcome.retryAfterSeconds,
695
+ );
696
+ if (outcome.error) errors.push(outcome.error);
697
+ }
698
+
699
+ if (retryIds.length === 0) {
700
+ await finishJob(db, lease, errors.length > 0 ? errors.join("; ") : null);
701
+ message.ack();
702
+ return;
703
+ }
704
+
705
+ const attempts = job.attempts + 1;
706
+ const lastError =
707
+ errors.join("; ").slice(0, 1024) || "retryable gateway failure";
708
+ if (attempts >= MAX_NOTIFICATION_PUSH_ATTEMPTS) {
709
+ const failed = await db
710
+ .update(notificationPushJobs)
711
+ .set({
712
+ status: "failed",
713
+ processingToken: null,
714
+ attempts,
715
+ pendingPusherIdsJson: JSON.stringify([...new Set(retryIds)]),
716
+ lastError,
717
+ updatedAt: new Date().toISOString(),
718
+ })
719
+ .where(processingLeaseWhere(lease));
720
+ if (affectedRowCount(failed) === 0) {
721
+ message.ack();
722
+ return;
723
+ }
724
+ log.error("Notification push exhausted its retry budget", {
725
+ event: "notification.push.exhausted",
726
+ jobId: job.id,
727
+ attempts,
728
+ });
729
+ message.ack();
730
+ return;
731
+ }
732
+
733
+ const delaySeconds = Math.max(
734
+ retryAfterSeconds,
735
+ Math.min(12 * 60 * 60, 30 * 2 ** Math.max(0, attempts - 1)),
736
+ );
737
+ const nextAttemptAt = new Date(
738
+ Date.now() + delaySeconds * 1000,
739
+ ).toISOString();
740
+ const retryScheduled = await db
741
+ .update(notificationPushJobs)
742
+ .set({
743
+ status: "retry_wait",
744
+ processingToken: null,
745
+ attempts,
746
+ pendingPusherIdsJson: JSON.stringify([...new Set(retryIds)]),
747
+ nextAttemptAt,
748
+ lastError,
749
+ updatedAt: new Date().toISOString(),
750
+ })
751
+ .where(processingLeaseWhere(lease));
752
+ if (affectedRowCount(retryScheduled) === 0) {
753
+ message.ack();
754
+ return;
755
+ }
756
+ message.retry({ delaySeconds });
757
+ } catch (error) {
758
+ const attempts = job.attempts + 1;
759
+ const errorText = error instanceof Error ? error.message : String(error);
760
+ if (attempts >= MAX_NOTIFICATION_PUSH_ATTEMPTS) {
761
+ const failed = await db
762
+ .update(notificationPushJobs)
763
+ .set({
764
+ status: "failed",
765
+ processingToken: null,
766
+ attempts,
767
+ lastError: errorText.slice(0, 1024),
768
+ updatedAt: new Date().toISOString(),
769
+ })
770
+ .where(processingLeaseWhere(lease));
771
+ if (affectedRowCount(failed) === 0) {
772
+ message.ack();
773
+ return;
774
+ }
775
+ message.ack();
776
+ return;
777
+ }
778
+ const delaySeconds = Math.min(12 * 60 * 60, 30 * 2 ** (attempts - 1));
779
+ const retryScheduled = await db
780
+ .update(notificationPushJobs)
781
+ .set({
782
+ status: "retry_wait",
783
+ processingToken: null,
784
+ attempts,
785
+ nextAttemptAt: new Date(Date.now() + delaySeconds * 1000).toISOString(),
786
+ lastError: errorText.slice(0, 1024),
787
+ updatedAt: new Date().toISOString(),
788
+ })
789
+ .where(processingLeaseWhere(lease));
790
+ if (affectedRowCount(retryScheduled) === 0) {
791
+ message.ack();
792
+ return;
793
+ }
794
+ message.retry({ delaySeconds });
795
+ }
796
+ }
797
+
798
+ async function deliverGatewayGroup(
799
+ env: Env,
800
+ db: Database,
801
+ gatewayUrl: string,
802
+ pushers: StoredPusher[],
803
+ format: NotificationPushFormat,
804
+ event: {
805
+ id: string;
806
+ type: string;
807
+ sender: string;
808
+ scopeId: string | null;
809
+ unread: number;
810
+ },
811
+ ): Promise<GatewayResult> {
812
+ if (!isNotificationGatewayAllowed(env, gatewayUrl)) {
813
+ return {
814
+ retryIds: [],
815
+ retryAfterSeconds: 0,
816
+ error: "gateway is no longer operator-allowed",
817
+ };
818
+ }
819
+ const data = pushers.map((row) => normalizedStoredData(row.dataJson));
820
+ const eventIdOnly = format === "event_id_only";
821
+ const payload = {
822
+ notification: {
823
+ event_id: event.id,
824
+ room_id: event.scopeId ?? undefined,
825
+ counts: { unread: event.unread },
826
+ ...(!eventIdOnly
827
+ ? {
828
+ type: event.type,
829
+ sender: event.sender,
830
+ user_is_target: true,
831
+ prio: "high",
832
+ }
833
+ : {}),
834
+ devices: pushers.map((row, index) => ({
835
+ app_id: row.appId,
836
+ pushkey: row.pushkey,
837
+ pushkey_ts: Math.floor(Date.now() / 1000),
838
+ data: data[index],
839
+ })),
840
+ },
841
+ };
842
+
843
+ const controller = new AbortController();
844
+ const timeout = setTimeout(
845
+ () => controller.abort(),
846
+ parseTimeout(env.YURUCOMMU_NOTIFICATION_PUSH_GATEWAY_TIMEOUT_MS),
847
+ );
848
+ let response: Response;
849
+ try {
850
+ const headers = new Headers({ "Content-Type": "application/json" });
851
+ const canonical = normalizeGatewayUrl(
852
+ env.YURUCOMMU_NOTIFICATION_PUSH_GATEWAY_URL,
853
+ );
854
+ if (
855
+ canonical === normalizeGatewayUrl(gatewayUrl) &&
856
+ env.YURUCOMMU_NOTIFICATION_PUSH_GATEWAY_TOKEN &&
857
+ new URL(gatewayUrl).protocol === "https:"
858
+ ) {
859
+ headers.set(
860
+ "Authorization",
861
+ `Bearer ${env.YURUCOMMU_NOTIFICATION_PUSH_GATEWAY_TOKEN}`,
862
+ );
863
+ }
864
+ response = await fetch(gatewayUrl, {
865
+ method: "POST",
866
+ headers,
867
+ body: JSON.stringify(payload),
868
+ redirect: "error",
869
+ signal: controller.signal,
870
+ });
871
+ } catch (error) {
872
+ return {
873
+ retryIds: pushers.map((row) => row.id),
874
+ retryAfterSeconds: 0,
875
+ error: error instanceof Error ? error.message : String(error),
876
+ };
877
+ } finally {
878
+ clearTimeout(timeout);
879
+ }
880
+
881
+ if (response.status === 429 || response.status >= 500) {
882
+ return {
883
+ retryIds: pushers.map((row) => row.id),
884
+ retryAfterSeconds: parseRetryAfter(response.headers.get("Retry-After")),
885
+ error: `gateway HTTP ${response.status}`,
886
+ };
887
+ }
888
+ if (!response.ok) {
889
+ return {
890
+ retryIds: [],
891
+ retryAfterSeconds: 0,
892
+ error: `gateway HTTP ${response.status}`,
893
+ };
894
+ }
895
+
896
+ let parsed: unknown;
897
+ try {
898
+ parsed = JSON.parse(
899
+ await readResponseText(response, MAX_GATEWAY_RESPONSE_BYTES),
900
+ );
901
+ } catch (error) {
902
+ return {
903
+ retryIds: pushers.map((row) => row.id),
904
+ retryAfterSeconds: 0,
905
+ error:
906
+ error instanceof Error ? error.message : "invalid gateway response",
907
+ };
908
+ }
909
+ if (!parsed || typeof parsed !== "object") {
910
+ return {
911
+ retryIds: pushers.map((row) => row.id),
912
+ retryAfterSeconds: 0,
913
+ error: "invalid gateway response",
914
+ };
915
+ }
916
+ const responseBody = parsed as Record<string, unknown>;
917
+ const rejected = stringArray(responseBody.rejected);
918
+ const retryable = stringArray(responseBody.retryable);
919
+ const failed = stringArray(responseBody.failed);
920
+ if (!rejected || !retryable || !failed) {
921
+ return {
922
+ retryIds: pushers.map((row) => row.id),
923
+ retryAfterSeconds: 0,
924
+ error: "invalid gateway result arrays",
925
+ };
926
+ }
927
+
928
+ const byPushkey = new Map(pushers.map((row) => [row.pushkey, row]));
929
+ for (const pushkey of rejected) {
930
+ const row = byPushkey.get(pushkey);
931
+ if (!row) continue;
932
+ await db
933
+ .delete(notificationPushers)
934
+ .where(
935
+ and(
936
+ eq(notificationPushers.id, row.id),
937
+ eq(notificationPushers.actorApId, row.actorApId),
938
+ eq(notificationPushers.product, row.product),
939
+ eq(notificationPushers.pushkeyHash, row.pushkeyHash),
940
+ eq(notificationPushers.pushkey, row.pushkey),
941
+ ),
942
+ );
943
+ }
944
+ const terminal = new Set([...rejected, ...failed]);
945
+ const retrySet = new Set(retryable);
946
+ return {
947
+ retryIds: pushers
948
+ .filter((row) => retrySet.has(row.pushkey) && !terminal.has(row.pushkey))
949
+ .map((row) => row.id),
950
+ retryAfterSeconds: parseRetryAfter(response.headers.get("Retry-After")),
951
+ error: failed.length > 0 ? "gateway reported permanent failures" : null,
952
+ };
953
+ }
954
+
955
+ async function loadPushEvent(
956
+ db: Database,
957
+ actorApId: string,
958
+ activityApId: string,
959
+ explicitProduct: SocialNotificationProduct | null,
960
+ ) {
961
+ const selectEvent = () =>
962
+ db.select({
963
+ activityApId: activities.apId,
964
+ type: activities.type,
965
+ actorApId: activities.actorApId,
966
+ objectApId: activities.objectApId,
967
+ objectType: objects.type,
968
+ visibility: objects.visibility,
969
+ conversation: objects.conversation,
970
+ });
971
+
972
+ if (explicitProduct !== null) {
973
+ // Explicit jobs currently represent community talk, which intentionally
974
+ // has no social-inbox row. It still observes per-recipient block/mute and
975
+ // self-notification rules at delivery time.
976
+ const currentCommunityMembership = db
977
+ .select({ actorApId: communityMembers.actorApId })
978
+ .from(objectRecipients)
979
+ .innerJoin(
980
+ communityMembers,
981
+ eq(communityMembers.communityApId, objectRecipients.recipientApId),
982
+ )
983
+ .where(
984
+ and(
985
+ eq(objectRecipients.objectApId, activities.objectApId),
986
+ eq(objectRecipients.type, "audience"),
987
+ eq(communityMembers.actorApId, actorApId),
988
+ ),
989
+ );
990
+ return selectEvent()
991
+ .from(activities)
992
+ .leftJoin(objects, eq(activities.objectApId, objects.apId))
993
+ .where(
994
+ and(
995
+ eq(activities.apId, activityApId),
996
+ ne(activities.actorApId, actorApId),
997
+ inArray(activities.type, [...NOTIFICATION_ACTIVITY_TYPES]),
998
+ exists(currentCommunityMembership),
999
+ excludeBlockedMutedAuthors(db, actorApId, activities.actorApId),
1000
+ ),
1001
+ )
1002
+ .get();
1003
+ }
1004
+
1005
+ // The inbox trigger intentionally captures every unread insert so it cannot
1006
+ // lose a notification in a route-specific crash window. Eligibility is
1007
+ // therefore re-checked here immediately before external delivery, using the
1008
+ // SAME shared predicate builder as the notification list/count. Direct
1009
+ // Creates remain eligible (archived DMs excepted) and route to Yurume below.
1010
+ return selectEvent()
1011
+ .from(inbox)
1012
+ .innerJoin(activities, eq(inbox.activityApId, activities.apId))
1013
+ .leftJoin(objects, eq(activities.objectApId, objects.apId))
1014
+ .where(
1015
+ and(
1016
+ eq(inbox.actorApId, actorApId),
1017
+ eq(inbox.activityApId, activityApId),
1018
+ ...notificationEligibilityWhere(db, actorApId, {
1019
+ direct: "unless-dm-archived",
1020
+ }),
1021
+ ),
1022
+ )
1023
+ .get();
1024
+ }
1025
+
1026
+ /** Product-specific unread badge included in the event-id-only push payload. */
1027
+ async function unreadCountForProduct(
1028
+ db: Database,
1029
+ actorApId: string,
1030
+ product: SocialNotificationProduct,
1031
+ ): Promise<number> {
1032
+ if (product === "yurume") {
1033
+ // Reuse the SAME helper as GET /api/dm/unread/count so the badge a push
1034
+ // sets can never drift from the badge the client computes on open.
1035
+ return (await yurumeUnreadCounts(db, actorApId)).total;
1036
+ }
1037
+
1038
+ // Reuse the SAME eligibility predicate as the notification list/badge. A
1039
+ // Yurume DM row must never inflate the Yurucommu app badge (direct: exclude).
1040
+ const row = await db
1041
+ .select({ count: sql<number>`COUNT(*)` })
1042
+ .from(inbox)
1043
+ .innerJoin(activities, eq(inbox.activityApId, activities.apId))
1044
+ .leftJoin(objects, eq(activities.objectApId, objects.apId))
1045
+ .where(
1046
+ and(
1047
+ eq(inbox.actorApId, actorApId),
1048
+ eq(inbox.read, 0),
1049
+ ...notificationEligibilityWhere(db, actorApId, { direct: "exclude" }),
1050
+ ),
1051
+ )
1052
+ .get();
1053
+ return Number(row?.count ?? 0);
1054
+ }
1055
+
1056
+ async function finishJob(
1057
+ db: Database,
1058
+ lease: ProcessingLease,
1059
+ lastError: string | null,
1060
+ ): Promise<boolean> {
1061
+ const now = new Date().toISOString();
1062
+ const finished = await db
1063
+ .update(notificationPushJobs)
1064
+ .set({
1065
+ status: "delivered",
1066
+ processingToken: null,
1067
+ pendingPusherIdsJson: "[]",
1068
+ lastError,
1069
+ deliveredAt: now,
1070
+ updatedAt: now,
1071
+ })
1072
+ .where(processingLeaseWhere(lease));
1073
+ return affectedRowCount(finished) > 0;
1074
+ }
1075
+
1076
+ function processingLeaseWhere(lease: ProcessingLease) {
1077
+ return and(
1078
+ eq(notificationPushJobs.id, lease.jobId),
1079
+ eq(notificationPushJobs.status, "processing"),
1080
+ eq(notificationPushJobs.processingToken, lease.processingToken),
1081
+ );
1082
+ }
1083
+
1084
+ async function refreshProcessingLease(
1085
+ db: Database,
1086
+ lease: ProcessingLease,
1087
+ ): Promise<boolean> {
1088
+ const refreshed = await db
1089
+ .update(notificationPushJobs)
1090
+ .set({ updatedAt: new Date().toISOString() })
1091
+ .where(processingLeaseWhere(lease));
1092
+ return affectedRowCount(refreshed) > 0;
1093
+ }
1094
+
1095
+ function groupByGatewayAndFormat(
1096
+ pushers: StoredPusher[],
1097
+ ): Map<string, GatewayDispatchGroup> {
1098
+ const result = new Map<string, GatewayDispatchGroup>();
1099
+ for (const row of pushers) {
1100
+ const format = notificationPushFormat(parseStoredData(row.dataJson));
1101
+ const key = JSON.stringify([row.gatewayUrl, format]);
1102
+ const group = result.get(key) ?? {
1103
+ gatewayUrl: row.gatewayUrl,
1104
+ format,
1105
+ pushers: [],
1106
+ };
1107
+ group.pushers.push(row);
1108
+ result.set(key, group);
1109
+ }
1110
+ return result;
1111
+ }
1112
+
1113
+ function parsePendingIds(value: string | null): string[] | null {
1114
+ if (value === null) return null;
1115
+ try {
1116
+ const parsed = JSON.parse(value);
1117
+ return Array.isArray(parsed) &&
1118
+ parsed.every((item) => typeof item === "string")
1119
+ ? parsed
1120
+ : [];
1121
+ } catch {
1122
+ return [];
1123
+ }
1124
+ }
1125
+
1126
+ function parseStoredData(value: string): JsonObject {
1127
+ try {
1128
+ const parsed = JSON.parse(value);
1129
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
1130
+ ? (parsed as JsonObject)
1131
+ : {};
1132
+ } catch {
1133
+ return {};
1134
+ }
1135
+ }
1136
+
1137
+ function notificationPushFormat(data: JsonObject): NotificationPushFormat {
1138
+ return data.format === "full" ? "full" : "event_id_only";
1139
+ }
1140
+
1141
+ function normalizedStoredData(value: string): JsonObject {
1142
+ const data = parseStoredData(value);
1143
+ return { ...data, format: notificationPushFormat(data) };
1144
+ }
1145
+
1146
+ function stringArray(value: unknown): string[] | null {
1147
+ if (value === undefined) return [];
1148
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
1149
+ return null;
1150
+ }
1151
+ return value;
1152
+ }
1153
+
1154
+ function parseTimeout(value: string | undefined): number {
1155
+ const parsed = Number(value);
1156
+ if (!Number.isFinite(parsed)) return DEFAULT_GATEWAY_TIMEOUT_MS;
1157
+ return Math.max(250, Math.min(30_000, Math.floor(parsed)));
1158
+ }
1159
+
1160
+ function parseRetryAfter(value: string | null): number {
1161
+ if (!value) return 0;
1162
+ const seconds = Number(value);
1163
+ if (Number.isFinite(seconds) && seconds >= 0) {
1164
+ return Math.min(12 * 60 * 60, Math.ceil(seconds));
1165
+ }
1166
+ const timestamp = Date.parse(value);
1167
+ if (!Number.isFinite(timestamp)) return 0;
1168
+ return Math.min(
1169
+ 12 * 60 * 60,
1170
+ Math.max(0, Math.ceil((timestamp - Date.now()) / 1000)),
1171
+ );
1172
+ }
1173
+
1174
+ async function readResponseText(
1175
+ response: Response,
1176
+ maxBytes: number,
1177
+ ): Promise<string> {
1178
+ if (!response.body) return "";
1179
+ const reader = response.body.getReader();
1180
+ const chunks: Uint8Array[] = [];
1181
+ let size = 0;
1182
+ while (true) {
1183
+ const { done, value } = await reader.read();
1184
+ if (done) break;
1185
+ size += value.byteLength;
1186
+ if (size > maxBytes) {
1187
+ await reader.cancel();
1188
+ throw new Error("gateway response is too large");
1189
+ }
1190
+ chunks.push(value);
1191
+ }
1192
+ const bytes = new Uint8Array(size);
1193
+ let offset = 0;
1194
+ for (const chunk of chunks) {
1195
+ bytes.set(chunk, offset);
1196
+ offset += chunk.byteLength;
1197
+ }
1198
+ return new TextDecoder().decode(bytes);
1199
+ }
1200
+
1201
+ function isTruthy(value: string | undefined): boolean {
1202
+ return ["1", "true", "yes", "on"].includes(value?.trim().toLowerCase() ?? "");
1203
+ }
1204
+
1205
+ async function sha256Hex(value: string): Promise<string> {
1206
+ const digest = await crypto.subtle.digest(
1207
+ "SHA-256",
1208
+ new TextEncoder().encode(value),
1209
+ );
1210
+ return Array.from(new Uint8Array(digest), (byte) =>
1211
+ byte.toString(16).padStart(2, "0"),
1212
+ ).join("");
1213
+ }