@edge-base/server 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/admin-build/.gitkeep +0 -0
- package/admin-build/_app/env.js +1 -0
- package/admin-build/_app/immutable/assets/0.Bm6cF078.css +1 -0
- package/admin-build/_app/immutable/assets/1.BfW3pUNa.css +1 -0
- package/admin-build/_app/immutable/assets/11.CVmQOewb.css +1 -0
- package/admin-build/_app/immutable/assets/12.B1EhbRZT.css +1 -0
- package/admin-build/_app/immutable/assets/13.BvwYeuwE.css +1 -0
- package/admin-build/_app/immutable/assets/14.CdVfcO0R.css +1 -0
- package/admin-build/_app/immutable/assets/15.2yeZ66b-.css +1 -0
- package/admin-build/_app/immutable/assets/17.BVg0JEVu.css +1 -0
- package/admin-build/_app/immutable/assets/18.Rwnl3x_i.css +1 -0
- package/admin-build/_app/immutable/assets/20.DsPWA9AV.css +1 -0
- package/admin-build/_app/immutable/assets/21.Dz2RJ56c.css +1 -0
- package/admin-build/_app/immutable/assets/22.DwNLk5Ai.css +1 -0
- package/admin-build/_app/immutable/assets/23.CFpu0gOO.css +1 -0
- package/admin-build/_app/immutable/assets/24.Cy5LBeoJ.css +1 -0
- package/admin-build/_app/immutable/assets/25.pUyLVf-h.css +1 -0
- package/admin-build/_app/immutable/assets/26.DBcGrlXa.css +1 -0
- package/admin-build/_app/immutable/assets/27.BswYyAJD.css +1 -0
- package/admin-build/_app/immutable/assets/28.B4ueB1Kf.css +1 -0
- package/admin-build/_app/immutable/assets/29.B-qU6PdF.css +1 -0
- package/admin-build/_app/immutable/assets/3.Dg81Pgmd.css +1 -0
- package/admin-build/_app/immutable/assets/30.CsdWum94.css +1 -0
- package/admin-build/_app/immutable/assets/31.U6OwIp50.css +1 -0
- package/admin-build/_app/immutable/assets/4.CyawCCux.css +1 -0
- package/admin-build/_app/immutable/assets/5.C0YO2HTk.css +1 -0
- package/admin-build/_app/immutable/assets/8.Br5jd6kD.css +1 -0
- package/admin-build/_app/immutable/assets/Badge.EMYLHBxE.css +1 -0
- package/admin-build/_app/immutable/assets/Button.DpzMRTjK.css +1 -0
- package/admin-build/_app/immutable/assets/ConfirmDialog.DAnaWRRk.css +1 -0
- package/admin-build/_app/immutable/assets/EmptyState.CwKsu57Y.css +1 -0
- package/admin-build/_app/immutable/assets/Input.BDUSenmU.css +1 -0
- package/admin-build/_app/immutable/assets/Modal.Dm5B0Xie.css +1 -0
- package/admin-build/_app/immutable/assets/PageShell.CmU-Xh-b.css +1 -0
- package/admin-build/_app/immutable/assets/SchemaFieldEditor.g4NsCdno.css +1 -0
- package/admin-build/_app/immutable/assets/Select.BW4Keufm.css +1 -0
- package/admin-build/_app/immutable/assets/Skeleton.KWUulTKJ.css +1 -0
- package/admin-build/_app/immutable/assets/Tabs.CniGYb67.css +1 -0
- package/admin-build/_app/immutable/assets/TimeChart.BTCDAvmT.css +1 -0
- package/admin-build/_app/immutable/assets/Toggle.Cy_K12OM.css +1 -0
- package/admin-build/_app/immutable/assets/TopList.ClFzmPlA.css +1 -0
- package/admin-build/_app/immutable/chunks/7B47DvSx.js +1 -0
- package/admin-build/_app/immutable/chunks/7f08Id8e.js +1 -0
- package/admin-build/_app/immutable/chunks/8wJeQ7LN.js +1 -0
- package/admin-build/_app/immutable/chunks/B-h2afW5.js +1 -0
- package/admin-build/_app/immutable/chunks/B8vJP3wz.js +1 -0
- package/admin-build/_app/immutable/chunks/BR_fL5Yv.js +1 -0
- package/admin-build/_app/immutable/chunks/BY92tFS2.js +1 -0
- package/admin-build/_app/immutable/chunks/BcR-Rdj9.js +1 -0
- package/admin-build/_app/immutable/chunks/BdrwyZv8.js +1 -0
- package/admin-build/_app/immutable/chunks/Bh56EfQ_.js +1 -0
- package/admin-build/_app/immutable/chunks/BkrCkgYp.js +1 -0
- package/admin-build/_app/immutable/chunks/BmRjiP5k.js +1 -0
- package/admin-build/_app/immutable/chunks/BsokvhWC.js +1 -0
- package/admin-build/_app/immutable/chunks/C4D51vTW.js +1 -0
- package/admin-build/_app/immutable/chunks/C6puvcoR.js +2 -0
- package/admin-build/_app/immutable/chunks/CCKNu7m7.js +1 -0
- package/admin-build/_app/immutable/chunks/CWj6FrbW.js +1 -0
- package/admin-build/_app/immutable/chunks/Ce-ngf4p.js +5 -0
- package/admin-build/_app/immutable/chunks/Cs0GwzJA.js +1 -0
- package/admin-build/_app/immutable/chunks/CwROoZK0.js +1 -0
- package/admin-build/_app/immutable/chunks/CxCPv_Ut.js +1 -0
- package/admin-build/_app/immutable/chunks/CxbRue-5.js +1 -0
- package/admin-build/_app/immutable/chunks/CyqB6g-D.js +1 -0
- package/admin-build/_app/immutable/chunks/D5h5A1cc.js +2 -0
- package/admin-build/_app/immutable/chunks/DnyL7Zq-.js +1 -0
- package/admin-build/_app/immutable/chunks/DoPXzH7F.js +1 -0
- package/admin-build/_app/immutable/chunks/DrQSgw-f.js +1 -0
- package/admin-build/_app/immutable/chunks/DttM2zNO.js +1 -0
- package/admin-build/_app/immutable/chunks/DuXuUBWN.js +1 -0
- package/admin-build/_app/immutable/chunks/MdeqaOQx.js +10 -0
- package/admin-build/_app/immutable/chunks/NuUjtcO2.js +1 -0
- package/admin-build/_app/immutable/chunks/Q2nPFxS6.js +1 -0
- package/admin-build/_app/immutable/chunks/R6arueIl.js +1 -0
- package/admin-build/_app/immutable/chunks/UUazaC_N.js +1 -0
- package/admin-build/_app/immutable/chunks/cOYbrQxx.js +1 -0
- package/admin-build/_app/immutable/chunks/eFQHTGwA.js +1 -0
- package/admin-build/_app/immutable/chunks/ehbppgYb.js +1 -0
- package/admin-build/_app/immutable/chunks/glwixJlP.js +1 -0
- package/admin-build/_app/immutable/chunks/vApWTCBs.js +1 -0
- package/admin-build/_app/immutable/chunks/w89G9Xpi.js +1 -0
- package/admin-build/_app/immutable/chunks/wJsUhbfZ.js +1 -0
- package/admin-build/_app/immutable/chunks/zfauFM8P.js +1 -0
- package/admin-build/_app/immutable/entry/app.CcO-Uos3.js +2 -0
- package/admin-build/_app/immutable/entry/start.COebYq3I.js +1 -0
- package/admin-build/_app/immutable/nodes/0.CjtHKU-6.js +1 -0
- package/admin-build/_app/immutable/nodes/1.DEisjlM0.js +1 -0
- package/admin-build/_app/immutable/nodes/10.CvhdyWVB.js +1 -0
- package/admin-build/_app/immutable/nodes/11.DjHqcOvy.js +1 -0
- package/admin-build/_app/immutable/nodes/12.mQLz4Mj_.js +1 -0
- package/admin-build/_app/immutable/nodes/13.CBonZZyP.js +110 -0
- package/admin-build/_app/immutable/nodes/14.d-oiZL0j.js +3 -0
- package/admin-build/_app/immutable/nodes/15.CKPQsUYF.js +1 -0
- package/admin-build/_app/immutable/nodes/16.wPzAPQGx.js +1 -0
- package/admin-build/_app/immutable/nodes/17.DayhKyEZ.js +1 -0
- package/admin-build/_app/immutable/nodes/18.DKwS0Ir0.js +1 -0
- package/admin-build/_app/immutable/nodes/19.wPzAPQGx.js +1 -0
- package/admin-build/_app/immutable/nodes/2.BKoKrw1i.js +1 -0
- package/admin-build/_app/immutable/nodes/20.BvIkkkrW.js +1 -0
- package/admin-build/_app/immutable/nodes/21.DMaFhdHk.js +128 -0
- package/admin-build/_app/immutable/nodes/22.3xdgwuK1.js +1 -0
- package/admin-build/_app/immutable/nodes/23.8Bvgjbsl.js +112 -0
- package/admin-build/_app/immutable/nodes/24.DzSSzRhG.js +2 -0
- package/admin-build/_app/immutable/nodes/25.9KKYBnAE.js +2 -0
- package/admin-build/_app/immutable/nodes/26.Bhn9dfhY.js +1 -0
- package/admin-build/_app/immutable/nodes/27.kRLiC24G.js +1 -0
- package/admin-build/_app/immutable/nodes/28.BVIN1-7N.js +1 -0
- package/admin-build/_app/immutable/nodes/29.3yabZWj4.js +1 -0
- package/admin-build/_app/immutable/nodes/3.BFtSOkX7.js +2 -0
- package/admin-build/_app/immutable/nodes/30.CyCQlwaP.js +1 -0
- package/admin-build/_app/immutable/nodes/31.C4LDXjES.js +1 -0
- package/admin-build/_app/immutable/nodes/4.CvbiMlCa.js +1 -0
- package/admin-build/_app/immutable/nodes/5.C6BLv2eM.js +1 -0
- package/admin-build/_app/immutable/nodes/6.BcXvfl2P.js +1 -0
- package/admin-build/_app/immutable/nodes/7.CIuqhPiK.js +1 -0
- package/admin-build/_app/immutable/nodes/8.BQOR_JfO.js +1 -0
- package/admin-build/_app/immutable/nodes/9.NZqXQxPy.js +1 -0
- package/admin-build/_app/version.json +1 -0
- package/admin-build/favicon.svg +26 -0
- package/admin-build/index.html +45 -0
- package/openapi.json +19543 -0
- package/package.json +66 -0
- package/src/__tests__/admin-assets.test.ts +55 -0
- package/src/__tests__/admin-data-routes.test.ts +488 -0
- package/src/__tests__/admin-db-target.test.ts +103 -0
- package/src/__tests__/admin-routing.test.ts +31 -0
- package/src/__tests__/admin-user-management.test.ts +311 -0
- package/src/__tests__/analytics-query.test.ts +75 -0
- package/src/__tests__/auth-d1.test.ts +749 -0
- package/src/__tests__/auth-db-adapter.test.ts +73 -0
- package/src/__tests__/auth-jwt.test.ts +440 -0
- package/src/__tests__/auth-oauth.test.ts +389 -0
- package/src/__tests__/auth-password.test.ts +367 -0
- package/src/__tests__/auth-redirect.test.ts +87 -0
- package/src/__tests__/backup-restore.test.ts +711 -0
- package/src/__tests__/broadcast.test.ts +128 -0
- package/src/__tests__/cli.test.ts +178 -0
- package/src/__tests__/cloudflare-realtime.test.ts +113 -0
- package/src/__tests__/config.test.ts +469 -0
- package/src/__tests__/cors.test.ts +154 -0
- package/src/__tests__/cron.test.ts +302 -0
- package/src/__tests__/d1-handler.test.ts +402 -0
- package/src/__tests__/d1-sql.test.ts +120 -0
- package/src/__tests__/database-live-config.test.ts +42 -0
- package/src/__tests__/database-live-emitter.test.ts +56 -0
- package/src/__tests__/database-live-filters.test.ts +63 -0
- package/src/__tests__/database-live-route.test.ts +113 -0
- package/src/__tests__/db-sql.test.ts +163 -0
- package/src/__tests__/do-lifecycle.test.ts +263 -0
- package/src/__tests__/do-router.test.ts +729 -0
- package/src/__tests__/email-provider.test.ts +128 -0
- package/src/__tests__/email-templates.test.ts +528 -0
- package/src/__tests__/error-format.test.ts +250 -0
- package/src/__tests__/field-ops.test.ts +242 -0
- package/src/__tests__/functions-context.test.ts +334 -0
- package/src/__tests__/functions-d1-proxy.test.ts +229 -0
- package/src/__tests__/functions-registry-runtime-config.test.ts +17 -0
- package/src/__tests__/functions-route.test.ts +139 -0
- package/src/__tests__/internal-request.test.ts +77 -0
- package/src/__tests__/log-writer.test.ts +44 -0
- package/src/__tests__/logger.test.ts +58 -0
- package/src/__tests__/meta-admin-proxy.test.ts +48 -0
- package/src/__tests__/meta-export-coverage.test.ts +191 -0
- package/src/__tests__/meta-route-registration.test.ts +47 -0
- package/src/__tests__/namespace-dump.test.ts +28 -0
- package/src/__tests__/oauth-providers.test.ts +337 -0
- package/src/__tests__/openapi-coverage.test.ts +144 -0
- package/src/__tests__/pagination.test.ts +59 -0
- package/src/__tests__/password-policy.test.ts +191 -0
- package/src/__tests__/plugin-migrations.test.ts +379 -0
- package/src/__tests__/postgres-batch-compat.test.ts +133 -0
- package/src/__tests__/postgres-dialect.test.ts +328 -0
- package/src/__tests__/postgres-executor.test.ts +79 -0
- package/src/__tests__/postgres-field-ops-compat.test.ts +222 -0
- package/src/__tests__/postgres-schema-init.test.ts +105 -0
- package/src/__tests__/postgres-table-utils.test.ts +107 -0
- package/src/__tests__/presence.test.ts +199 -0
- package/src/__tests__/provider.test.ts +550 -0
- package/src/__tests__/public-user-profile.test.ts +339 -0
- package/src/__tests__/push-handlers.test.ts +179 -0
- package/src/__tests__/push-provider.test.ts +80 -0
- package/src/__tests__/push-token.test.ts +418 -0
- package/src/__tests__/query.test.ts +771 -0
- package/src/__tests__/rate-limit.test.ts +260 -0
- package/src/__tests__/room-access-policy.test.ts +101 -0
- package/src/__tests__/room-handler-context.test.ts +130 -0
- package/src/__tests__/room-monitoring.test.ts +138 -0
- package/src/__tests__/room-runtime-routing.test.ts +222 -0
- package/src/__tests__/room.test.ts +254 -0
- package/src/__tests__/route-parser.test.ts +490 -0
- package/src/__tests__/rules.test.ts +234 -0
- package/src/__tests__/runtime-surface-accounting.test.ts +120 -0
- package/src/__tests__/scheduled.test.ts +80 -0
- package/src/__tests__/schema.test.ts +1273 -0
- package/src/__tests__/security-hardening.test.ts +312 -0
- package/src/__tests__/server.unit.test.ts +333 -0
- package/src/__tests__/service-key-db-proxy.test.ts +650 -0
- package/src/__tests__/service-key-provider-bypass.test.ts +138 -0
- package/src/__tests__/service-key.test.ts +757 -0
- package/src/__tests__/smoke-skip-report.test.ts +72 -0
- package/src/__tests__/sms-provider.test.ts +39 -0
- package/src/__tests__/sql-route.test.ts +218 -0
- package/src/__tests__/storage-hook-context.test.ts +115 -0
- package/src/__tests__/totp.test.ts +200 -0
- package/src/__tests__/uuid.test.ts +144 -0
- package/src/__tests__/validation.test.ts +773 -0
- package/src/__tests__/websocket-pending.test.ts +163 -0
- package/src/_functions-registry.ts +51 -0
- package/src/bench-entry.ts +9 -0
- package/src/cloudflare-test.d.ts +1 -0
- package/src/durable-objects/auth-do.ts +49 -0
- package/src/durable-objects/database-do.ts +2240 -0
- package/src/durable-objects/database-live-do.ts +949 -0
- package/src/durable-objects/logs-do.ts +1200 -0
- package/src/durable-objects/room-runtime-base.ts +1604 -0
- package/src/durable-objects/rooms-do.ts +2191 -0
- package/src/generated-config.ts +6 -0
- package/src/index.ts +382 -0
- package/src/lib/admin-assets.ts +54 -0
- package/src/lib/admin-db-target.ts +301 -0
- package/src/lib/admin-routing.ts +35 -0
- package/src/lib/admin-user-management.ts +464 -0
- package/src/lib/analytics-adapter.ts +103 -0
- package/src/lib/analytics-query.ts +579 -0
- package/src/lib/auth-d1-service.ts +1193 -0
- package/src/lib/auth-d1.ts +1056 -0
- package/src/lib/auth-db-adapter.ts +289 -0
- package/src/lib/auth-redirect.ts +116 -0
- package/src/lib/cidr.ts +115 -0
- package/src/lib/client-ip.ts +51 -0
- package/src/lib/cloudflare-realtime.ts +251 -0
- package/src/lib/control-db.ts +36 -0
- package/src/lib/cron.ts +163 -0
- package/src/lib/d1-handler.ts +1425 -0
- package/src/lib/d1-schema-init.ts +255 -0
- package/src/lib/d1-sql.ts +33 -0
- package/src/lib/database-live-config.ts +24 -0
- package/src/lib/database-live-emitter.ts +111 -0
- package/src/lib/db-sql.ts +66 -0
- package/src/lib/do-retry.ts +36 -0
- package/src/lib/do-router.ts +270 -0
- package/src/lib/do-sql.ts +73 -0
- package/src/lib/email-provider.ts +379 -0
- package/src/lib/email-templates.ts +285 -0
- package/src/lib/email-translations.ts +422 -0
- package/src/lib/errors.ts +151 -0
- package/src/lib/functions.ts +2091 -0
- package/src/lib/hono.ts +56 -0
- package/src/lib/internal-request.ts +56 -0
- package/src/lib/jwt.ts +354 -0
- package/src/lib/log-writer.ts +272 -0
- package/src/lib/namespace-dump.ts +125 -0
- package/src/lib/oauth-providers.ts +1225 -0
- package/src/lib/op-parser.ts +99 -0
- package/src/lib/openapi.ts +146 -0
- package/src/lib/pagination.ts +19 -0
- package/src/lib/password-policy.ts +102 -0
- package/src/lib/password.ts +145 -0
- package/src/lib/plugin-migrations.ts +612 -0
- package/src/lib/postgres-executor.ts +203 -0
- package/src/lib/postgres-handler.ts +1102 -0
- package/src/lib/postgres-schema-init.ts +341 -0
- package/src/lib/postgres-table-utils.ts +87 -0
- package/src/lib/public-user-profile.ts +187 -0
- package/src/lib/push-provider.ts +409 -0
- package/src/lib/push-token.ts +294 -0
- package/src/lib/query-engine.ts +768 -0
- package/src/lib/room-monitoring.ts +97 -0
- package/src/lib/room-runtime.ts +14 -0
- package/src/lib/route-parser.ts +434 -0
- package/src/lib/schema.ts +538 -0
- package/src/lib/schemas.ts +152 -0
- package/src/lib/service-key.ts +419 -0
- package/src/lib/sms-provider.ts +230 -0
- package/src/lib/startup-config.ts +99 -0
- package/src/lib/totp.ts +242 -0
- package/src/lib/uuid.ts +87 -0
- package/src/lib/validation.ts +205 -0
- package/src/lib/version.ts +2 -0
- package/src/lib/websocket-pending.ts +40 -0
- package/src/middleware/auth.ts +169 -0
- package/src/middleware/captcha-verify.ts +217 -0
- package/src/middleware/cors.ts +159 -0
- package/src/middleware/error-handler.ts +54 -0
- package/src/middleware/internal-guard.ts +26 -0
- package/src/middleware/logger.ts +126 -0
- package/src/middleware/rate-limit.ts +283 -0
- package/src/middleware/rules.ts +475 -0
- package/src/routes/admin-auth.ts +447 -0
- package/src/routes/admin.ts +3501 -0
- package/src/routes/analytics-api.ts +290 -0
- package/src/routes/auth.ts +4222 -0
- package/src/routes/backup.ts +1466 -0
- package/src/routes/config.ts +53 -0
- package/src/routes/d1.ts +109 -0
- package/src/routes/database-live.ts +281 -0
- package/src/routes/functions.ts +155 -0
- package/src/routes/health.ts +32 -0
- package/src/routes/kv.ts +167 -0
- package/src/routes/oauth.ts +1055 -0
- package/src/routes/push.ts +1465 -0
- package/src/routes/room.ts +639 -0
- package/src/routes/schema-endpoint.ts +76 -0
- package/src/routes/sql.ts +176 -0
- package/src/routes/storage.ts +1674 -0
- package/src/routes/tables.ts +699 -0
- package/src/routes/users.ts +21 -0
- package/src/routes/vectorize.ts +372 -0
- package/src/types.ts +99 -0
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PushProvider — FCM 단일 프로바이더 (FCM 일원화,)
|
|
3
|
+
*
|
|
4
|
+
* 모든 플랫폼(iOS, Android, Web)에서 Firebase 클라이언트 SDK로 FCM Registration Token을
|
|
5
|
+
* 발급받고, 서버는 FCM HTTP v1 API로만 발송한다.
|
|
6
|
+
*
|
|
7
|
+
* Workers에서는 firebase-admin 불가 → FCM HTTP v1 REST API 직접 호출.
|
|
8
|
+
* 토픽 구독은 모바일: 클라이언트 직접, 웹: 서버 경유 FCM IID API.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { PushConfig } from '@edge-base/shared';
|
|
12
|
+
import type { Env } from '../types.js';
|
|
13
|
+
|
|
14
|
+
// ─── Interfaces ───
|
|
15
|
+
|
|
16
|
+
export interface PushPayload {
|
|
17
|
+
title?: string;
|
|
18
|
+
body?: string;
|
|
19
|
+
image?: string;
|
|
20
|
+
sound?: string;
|
|
21
|
+
badge?: number;
|
|
22
|
+
data?: Record<string, unknown>;
|
|
23
|
+
silent?: boolean;
|
|
24
|
+
collapseId?: string;
|
|
25
|
+
ttl?: number;
|
|
26
|
+
/** FCM-specific overrides (merged into android config) */
|
|
27
|
+
fcm?: Record<string, unknown>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface PushSendOptions {
|
|
31
|
+
token: string;
|
|
32
|
+
platform: string;
|
|
33
|
+
payload: PushPayload;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface PushSendResult {
|
|
37
|
+
success: boolean;
|
|
38
|
+
/** true → token is invalid, should be removed from KV */
|
|
39
|
+
remove?: boolean;
|
|
40
|
+
/** true → transient error, should retry */
|
|
41
|
+
retry?: boolean;
|
|
42
|
+
error?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ─── FCM Provider ───
|
|
46
|
+
|
|
47
|
+
export class FcmProvider {
|
|
48
|
+
private accessToken: string | null = null;
|
|
49
|
+
private tokenExpiry: number = 0;
|
|
50
|
+
private endpoints: { oauth2TokenUrl: string; fcmSendUrl: string; iidBaseUrl: string };
|
|
51
|
+
|
|
52
|
+
constructor(
|
|
53
|
+
private projectId: string,
|
|
54
|
+
private serviceAccountJson: string,
|
|
55
|
+
endpoints?: { oauth2TokenUrl?: string; fcmSendUrl?: string; iidBaseUrl?: string },
|
|
56
|
+
private useMockAccessToken: boolean = false,
|
|
57
|
+
) {
|
|
58
|
+
this.endpoints = {
|
|
59
|
+
oauth2TokenUrl: endpoints?.oauth2TokenUrl ?? 'https://oauth2.googleapis.com/token',
|
|
60
|
+
fcmSendUrl: endpoints?.fcmSendUrl ?? `https://fcm.googleapis.com/v1/projects/${projectId}/messages:send`,
|
|
61
|
+
iidBaseUrl: endpoints?.iidBaseUrl ?? 'https://iid.googleapis.com',
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async send(options: PushSendOptions): Promise<PushSendResult> {
|
|
66
|
+
const token = await this.getAccessToken();
|
|
67
|
+
if (!token) {
|
|
68
|
+
return { success: false, error: 'Failed to obtain FCM access token' };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const message = this.buildMessage(options.payload, { token: options.token });
|
|
72
|
+
|
|
73
|
+
return this.sendMessage(token, message, { canRemove: true });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Send to an FCM topic. Topic subscription is client-driven (mobile)
|
|
78
|
+
* or server-driven via IID API (web).
|
|
79
|
+
*/
|
|
80
|
+
async sendToTopic(topic: string, payload: PushPayload): Promise<PushSendResult> {
|
|
81
|
+
const token = await this.getAccessToken();
|
|
82
|
+
if (!token) {
|
|
83
|
+
return { success: false, error: 'Failed to obtain FCM access token' };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const message = this.buildMessage(payload, { topic });
|
|
87
|
+
|
|
88
|
+
return this.sendMessage(token, message, { canRemove: false });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Broadcast to all devices via /topics/all.
|
|
93
|
+
* Clients auto-subscribe to 'all' topic on register.
|
|
94
|
+
*/
|
|
95
|
+
async broadcast(payload: PushPayload): Promise<PushSendResult> {
|
|
96
|
+
return this.sendToTopic('all', payload);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Subscribe a token to an FCM topic via Instance ID API.
|
|
101
|
+
* Used for web clients (Firebase JS SDK doesn't support client-side subscribeToTopic).
|
|
102
|
+
*/
|
|
103
|
+
async subscribeTokenToTopic(fcmToken: string, topic: string): Promise<{ success: boolean; error?: string }> {
|
|
104
|
+
const accessToken = await this.getAccessToken();
|
|
105
|
+
if (!accessToken) {
|
|
106
|
+
return { success: false, error: 'Failed to obtain FCM access token' };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const url = `${this.endpoints.iidBaseUrl}/iid/v1/${encodeURIComponent(fcmToken)}/rel/topics/${encodeURIComponent(topic)}`;
|
|
110
|
+
const resp = await fetch(url, {
|
|
111
|
+
method: 'POST',
|
|
112
|
+
headers: {
|
|
113
|
+
Authorization: `Bearer ${accessToken}`,
|
|
114
|
+
'Content-Type': 'application/json',
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
if (resp.ok) return { success: true };
|
|
119
|
+
const text = await resp.text().catch(() => '');
|
|
120
|
+
return { success: false, error: `IID ${resp.status}: ${text}` };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Unsubscribe a token from an FCM topic via Instance ID API.
|
|
125
|
+
*/
|
|
126
|
+
async unsubscribeTokenFromTopic(fcmToken: string, topic: string): Promise<{ success: boolean; error?: string }> {
|
|
127
|
+
const accessToken = await this.getAccessToken();
|
|
128
|
+
if (!accessToken) {
|
|
129
|
+
return { success: false, error: 'Failed to obtain FCM access token' };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const url = `${this.endpoints.iidBaseUrl}/iid/v1:batchRemove`;
|
|
133
|
+
const resp = await fetch(url, {
|
|
134
|
+
method: 'POST',
|
|
135
|
+
headers: {
|
|
136
|
+
Authorization: `Bearer ${accessToken}`,
|
|
137
|
+
'Content-Type': 'application/json',
|
|
138
|
+
},
|
|
139
|
+
body: JSON.stringify({
|
|
140
|
+
to: `/topics/${topic}`,
|
|
141
|
+
registration_tokens: [fcmToken],
|
|
142
|
+
}),
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
if (resp.ok) return { success: true };
|
|
146
|
+
const text = await resp.text().catch(() => '');
|
|
147
|
+
return { success: false, error: `IID ${resp.status}: ${text}` };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ─── Private helpers ───
|
|
151
|
+
|
|
152
|
+
private buildMessage(
|
|
153
|
+
payload: PushPayload,
|
|
154
|
+
target: { token?: string; topic?: string },
|
|
155
|
+
): Record<string, unknown> {
|
|
156
|
+
const message: Record<string, unknown> = { ...target };
|
|
157
|
+
|
|
158
|
+
// Notification payload
|
|
159
|
+
if (payload.title || payload.body) {
|
|
160
|
+
message.notification = {
|
|
161
|
+
title: payload.title,
|
|
162
|
+
body: payload.body,
|
|
163
|
+
...(payload.image ? { image: payload.image } : {}),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Data payload — FCM data values must be strings
|
|
168
|
+
if (payload.data) {
|
|
169
|
+
const stringData: Record<string, string> = {};
|
|
170
|
+
for (const [k, v] of Object.entries(payload.data)) {
|
|
171
|
+
stringData[k] = typeof v === 'string' ? v : JSON.stringify(v);
|
|
172
|
+
}
|
|
173
|
+
message.data = stringData;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Android-specific
|
|
177
|
+
const androidConfig: Record<string, unknown> = {};
|
|
178
|
+
if (payload.collapseId) androidConfig.collapse_key = payload.collapseId;
|
|
179
|
+
if (payload.ttl !== undefined) androidConfig.ttl = `${payload.ttl}s`;
|
|
180
|
+
if (payload.sound || payload.image) {
|
|
181
|
+
androidConfig.notification = {
|
|
182
|
+
...(payload.sound ? { sound: payload.sound } : {}),
|
|
183
|
+
...(payload.image ? { image: payload.image } : {}),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
if (payload.fcm) Object.assign(androidConfig, payload.fcm);
|
|
187
|
+
if (Object.keys(androidConfig).length > 0) message.android = androidConfig;
|
|
188
|
+
|
|
189
|
+
const aps: Record<string, unknown> = {};
|
|
190
|
+
if (payload.sound) aps.sound = payload.sound;
|
|
191
|
+
if (payload.badge !== undefined) aps.badge = payload.badge;
|
|
192
|
+
if (Object.keys(aps).length > 0) {
|
|
193
|
+
message.apns = {
|
|
194
|
+
payload: {
|
|
195
|
+
aps,
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Silent push → data-only message (no notification key)
|
|
201
|
+
if (payload.silent) {
|
|
202
|
+
delete message.notification;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return message;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private async sendMessage(
|
|
209
|
+
accessToken: string,
|
|
210
|
+
message: Record<string, unknown>,
|
|
211
|
+
opts: { canRemove: boolean },
|
|
212
|
+
): Promise<PushSendResult> {
|
|
213
|
+
const url = this.endpoints.fcmSendUrl;
|
|
214
|
+
const resp = await fetch(url, {
|
|
215
|
+
method: 'POST',
|
|
216
|
+
headers: {
|
|
217
|
+
Authorization: `Bearer ${accessToken}`,
|
|
218
|
+
'Content-Type': 'application/json',
|
|
219
|
+
},
|
|
220
|
+
body: JSON.stringify({ message }),
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
if (resp.ok) {
|
|
224
|
+
return { success: true };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const status = resp.status;
|
|
228
|
+
// Token invalid — should be removed (only for individual sends, not topics)
|
|
229
|
+
if (opts.canRemove && (status === 404 || status === 410)) {
|
|
230
|
+
return { success: false, remove: true, error: `FCM ${status}: token invalid` };
|
|
231
|
+
}
|
|
232
|
+
// Also check for UNREGISTERED error in response body
|
|
233
|
+
if (status === 400) {
|
|
234
|
+
try {
|
|
235
|
+
const body = await resp.json() as { error?: { details?: Array<{ errorCode?: string }> } };
|
|
236
|
+
if (opts.canRemove && body.error?.details?.some(d => d.errorCode === 'UNREGISTERED')) {
|
|
237
|
+
return { success: false, remove: true, error: 'FCM: UNREGISTERED' };
|
|
238
|
+
}
|
|
239
|
+
} catch { /* skip body parse errors */ }
|
|
240
|
+
return { success: false, error: `FCM ${status}: bad request` };
|
|
241
|
+
}
|
|
242
|
+
// Transient errors — should retry
|
|
243
|
+
if (status === 429 || status === 503) {
|
|
244
|
+
return { success: false, retry: true, error: `FCM ${status}: transient error` };
|
|
245
|
+
}
|
|
246
|
+
// Auth errors
|
|
247
|
+
if (status === 401 || status === 403) {
|
|
248
|
+
this.accessToken = null; // Force token refresh
|
|
249
|
+
return { success: false, error: `FCM ${status}: authentication error` };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return { success: false, error: `FCM ${status}: unexpected error` };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Get a cached or fresh OAuth2 access token.
|
|
257
|
+
* FCM v1 requires: Service Account JWT (RS256) → Google OAuth2 token exchange.
|
|
258
|
+
*/
|
|
259
|
+
private async getAccessToken(): Promise<string | null> {
|
|
260
|
+
if (this.accessToken && Date.now() < this.tokenExpiry) {
|
|
261
|
+
return this.accessToken;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (this.useMockAccessToken) {
|
|
265
|
+
this.accessToken = 'mock-access-token';
|
|
266
|
+
this.tokenExpiry = Date.now() + 55 * 60 * 1000;
|
|
267
|
+
return this.accessToken;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
try {
|
|
271
|
+
const sa = JSON.parse(this.serviceAccountJson) as {
|
|
272
|
+
client_email: string;
|
|
273
|
+
private_key: string;
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
// Import RS256 private key
|
|
277
|
+
const pemContent = sa.private_key
|
|
278
|
+
.replace(/-----BEGIN PRIVATE KEY-----/g, '')
|
|
279
|
+
.replace(/-----END PRIVATE KEY-----/g, '')
|
|
280
|
+
.replace(/\n/g, '');
|
|
281
|
+
const keyData = Uint8Array.from(atob(pemContent), c => c.charCodeAt(0));
|
|
282
|
+
|
|
283
|
+
const privateKey = await crypto.subtle.importKey(
|
|
284
|
+
'pkcs8',
|
|
285
|
+
keyData,
|
|
286
|
+
{ name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
|
|
287
|
+
false,
|
|
288
|
+
['sign'],
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
// Create JWT
|
|
292
|
+
const now = Math.floor(Date.now() / 1000);
|
|
293
|
+
const header = { alg: 'RS256', typ: 'JWT' };
|
|
294
|
+
const claimSet = {
|
|
295
|
+
iss: sa.client_email,
|
|
296
|
+
scope: 'https://www.googleapis.com/auth/firebase.messaging',
|
|
297
|
+
aud: this.endpoints.oauth2TokenUrl,
|
|
298
|
+
iat: now,
|
|
299
|
+
exp: now + 3600,
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
const encHeader = base64urlEncode(JSON.stringify(header));
|
|
303
|
+
const encClaims = base64urlEncode(JSON.stringify(claimSet));
|
|
304
|
+
const signInput = `${encHeader}.${encClaims}`;
|
|
305
|
+
|
|
306
|
+
const signature = await crypto.subtle.sign(
|
|
307
|
+
'RSASSA-PKCS1-v1_5',
|
|
308
|
+
privateKey,
|
|
309
|
+
new TextEncoder().encode(signInput),
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
const jwt = `${signInput}.${base64urlEncode(signature)}`;
|
|
313
|
+
|
|
314
|
+
// Exchange JWT for access token
|
|
315
|
+
const tokenResp = await fetch(this.endpoints.oauth2TokenUrl, {
|
|
316
|
+
method: 'POST',
|
|
317
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
318
|
+
body: `grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=${jwt}`,
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
if (!tokenResp.ok) {
|
|
322
|
+
console.error(
|
|
323
|
+
'[PushProvider:FCM] Token exchange failed:',
|
|
324
|
+
tokenResp.status,
|
|
325
|
+
this.endpoints.oauth2TokenUrl,
|
|
326
|
+
);
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const tokenData = await tokenResp.json() as { access_token: string; expires_in: number };
|
|
331
|
+
this.accessToken = tokenData.access_token;
|
|
332
|
+
this.tokenExpiry = Date.now() + (tokenData.expires_in - 60) * 1000; // 1 min buffer
|
|
333
|
+
return this.accessToken;
|
|
334
|
+
} catch (err) {
|
|
335
|
+
console.error('[PushProvider:FCM] getAccessToken error:', err, this.endpoints.oauth2TokenUrl);
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ─── Factory ───
|
|
342
|
+
|
|
343
|
+
export function resolveFcmEndpoints(
|
|
344
|
+
projectId: string,
|
|
345
|
+
endpoints?: { oauth2TokenUrl?: string; fcmSendUrl?: string; iidBaseUrl?: string },
|
|
346
|
+
env?: Env,
|
|
347
|
+
): { oauth2TokenUrl: string; fcmSendUrl: string; iidBaseUrl: string } {
|
|
348
|
+
const mockBaseUrl = env?.MOCK_FCM_BASE_URL?.trim()?.replace(/\/$/, '');
|
|
349
|
+
if (mockBaseUrl) {
|
|
350
|
+
return {
|
|
351
|
+
oauth2TokenUrl: `${mockBaseUrl}/token`,
|
|
352
|
+
fcmSendUrl: `${mockBaseUrl}/v1/projects/${projectId}/messages:send`,
|
|
353
|
+
iidBaseUrl: mockBaseUrl,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return {
|
|
358
|
+
oauth2TokenUrl: endpoints?.oauth2TokenUrl ?? 'https://oauth2.googleapis.com/token',
|
|
359
|
+
fcmSendUrl: endpoints?.fcmSendUrl ?? `https://fcm.googleapis.com/v1/projects/${projectId}/messages:send`,
|
|
360
|
+
iidBaseUrl: endpoints?.iidBaseUrl ?? 'https://iid.googleapis.com',
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Create FCM push provider from config + env.
|
|
366
|
+
* Returns null if FCM is not configured.
|
|
367
|
+
*
|
|
368
|
+
* Service account resolution order:
|
|
369
|
+
* 1. env.PUSH_FCM_SERVICE_ACCOUNT — direct Worker env binding (production)
|
|
370
|
+
* 2. config.fcm.serviceAccount — config-embedded fallback (test/Docker)
|
|
371
|
+
*/
|
|
372
|
+
export function createPushProvider(
|
|
373
|
+
config?: PushConfig,
|
|
374
|
+
env?: Env,
|
|
375
|
+
): FcmProvider | null {
|
|
376
|
+
if (!config?.fcm) return null;
|
|
377
|
+
|
|
378
|
+
// Primary: direct env binding
|
|
379
|
+
const serviceAccountJson =
|
|
380
|
+
env?.PUSH_FCM_SERVICE_ACCOUNT ?? config.fcm.serviceAccount;
|
|
381
|
+
|
|
382
|
+
if (!serviceAccountJson) return null;
|
|
383
|
+
|
|
384
|
+
const mockBaseUrl = env?.MOCK_FCM_BASE_URL?.trim()?.replace(/\/$/, '');
|
|
385
|
+
|
|
386
|
+
return new FcmProvider(
|
|
387
|
+
config.fcm.projectId,
|
|
388
|
+
serviceAccountJson,
|
|
389
|
+
resolveFcmEndpoints(config.fcm.projectId, config.fcm.endpoints, env),
|
|
390
|
+
Boolean(mockBaseUrl),
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// ─── Helpers ───
|
|
395
|
+
|
|
396
|
+
function base64urlEncode(input: string | ArrayBuffer): string {
|
|
397
|
+
let base64: string;
|
|
398
|
+
if (typeof input === 'string') {
|
|
399
|
+
base64 = btoa(input);
|
|
400
|
+
} else {
|
|
401
|
+
const bytes = new Uint8Array(input);
|
|
402
|
+
let binary = '';
|
|
403
|
+
for (const b of bytes) {
|
|
404
|
+
binary += String.fromCharCode(b);
|
|
405
|
+
}
|
|
406
|
+
base64 = btoa(binary);
|
|
407
|
+
}
|
|
408
|
+
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
409
|
+
}
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Push Token Manager — KV-based device token storage
|
|
3
|
+
*
|
|
4
|
+
* KV key patterns:
|
|
5
|
+
* push:user:{userId} → JSON array [{ deviceId, platform, token, updatedAt }, ...]
|
|
6
|
+
*
|
|
7
|
+
* AUTH_DB._push_devices is the primary source of truth when AUTH_DB is available.
|
|
8
|
+
* KV remains a mirrored cache plus the storage backend for push logs.
|
|
9
|
+
* Failed sends (410/404) remove the specific device from the array.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { AuthDb } from './auth-db-adapter.js';
|
|
13
|
+
|
|
14
|
+
// ─── Types ───
|
|
15
|
+
|
|
16
|
+
export interface DeviceInfo {
|
|
17
|
+
deviceId: string;
|
|
18
|
+
token: string;
|
|
19
|
+
platform: string;
|
|
20
|
+
updatedAt: string;
|
|
21
|
+
/** Device info — name, OS version, app version, locale. */
|
|
22
|
+
deviceInfo?: {
|
|
23
|
+
name?: string;
|
|
24
|
+
osVersion?: string;
|
|
25
|
+
appVersion?: string;
|
|
26
|
+
locale?: string;
|
|
27
|
+
};
|
|
28
|
+
/** Developer custom metadata (≤1KB JSON). */
|
|
29
|
+
metadata?: Record<string, unknown>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ─── Constants ───
|
|
33
|
+
|
|
34
|
+
/** Maximum devices per user */
|
|
35
|
+
const MAX_DEVICES_PER_USER = 10;
|
|
36
|
+
|
|
37
|
+
interface PushTokenStore {
|
|
38
|
+
kv: KVNamespace;
|
|
39
|
+
authDb?: AuthDb | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface PushDeviceRow {
|
|
43
|
+
deviceId: string;
|
|
44
|
+
token: string;
|
|
45
|
+
platform: string;
|
|
46
|
+
updatedAt: string;
|
|
47
|
+
deviceInfo: string | null;
|
|
48
|
+
metadata: string | null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function resolveStore(store: KVNamespace | PushTokenStore): PushTokenStore {
|
|
52
|
+
if ('kv' in store) return store;
|
|
53
|
+
return { kv: store };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function parseJsonObject(value: string | null): Record<string, unknown> | undefined {
|
|
57
|
+
if (!value) return undefined;
|
|
58
|
+
try {
|
|
59
|
+
const parsed = JSON.parse(value) as Record<string, unknown>;
|
|
60
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : undefined;
|
|
61
|
+
} catch {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function parseDeviceInfo(value: string | null): DeviceInfo['deviceInfo'] | undefined {
|
|
67
|
+
const parsed = parseJsonObject(value);
|
|
68
|
+
return parsed as DeviceInfo['deviceInfo'] | undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function parseMetadata(value: string | null): Record<string, unknown> | undefined {
|
|
72
|
+
return parseJsonObject(value);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function readDevicesFromKv(kv: KVNamespace, userId: string): Promise<DeviceInfo[]> {
|
|
76
|
+
const raw = await kv.get(`push:user:${userId}`);
|
|
77
|
+
if (!raw) return [];
|
|
78
|
+
try {
|
|
79
|
+
return JSON.parse(raw) as DeviceInfo[];
|
|
80
|
+
} catch {
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function readDevicesFromAuthDb(authDb: AuthDb, userId: string): Promise<DeviceInfo[]> {
|
|
86
|
+
const rows = await authDb.query<PushDeviceRow>(
|
|
87
|
+
`SELECT deviceId, token, platform, updatedAt, deviceInfo, metadata
|
|
88
|
+
FROM _push_devices
|
|
89
|
+
WHERE userId = ?
|
|
90
|
+
ORDER BY updatedAt ASC, deviceId ASC`,
|
|
91
|
+
[userId],
|
|
92
|
+
);
|
|
93
|
+
return rows.map((row) => ({
|
|
94
|
+
deviceId: row.deviceId,
|
|
95
|
+
token: row.token,
|
|
96
|
+
platform: row.platform,
|
|
97
|
+
updatedAt: row.updatedAt,
|
|
98
|
+
deviceInfo: parseDeviceInfo(row.deviceInfo),
|
|
99
|
+
metadata: parseMetadata(row.metadata),
|
|
100
|
+
}));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function persistDevices(store: PushTokenStore, userId: string, devices: DeviceInfo[]): Promise<void> {
|
|
104
|
+
const { kv, authDb } = store;
|
|
105
|
+
if (devices.length === 0) {
|
|
106
|
+
await kv.delete(`push:user:${userId}`);
|
|
107
|
+
} else {
|
|
108
|
+
await kv.put(`push:user:${userId}`, JSON.stringify(devices));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (!authDb) return;
|
|
112
|
+
|
|
113
|
+
const statements: Array<{ sql: string; params?: unknown[] }> = [
|
|
114
|
+
{ sql: 'DELETE FROM _push_devices WHERE userId = ?', params: [userId] },
|
|
115
|
+
];
|
|
116
|
+
|
|
117
|
+
for (const device of devices) {
|
|
118
|
+
statements.push({
|
|
119
|
+
sql: `INSERT INTO _push_devices (userId, deviceId, token, platform, updatedAt, deviceInfo, metadata)
|
|
120
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
121
|
+
params: [
|
|
122
|
+
userId,
|
|
123
|
+
device.deviceId,
|
|
124
|
+
device.token,
|
|
125
|
+
device.platform,
|
|
126
|
+
device.updatedAt,
|
|
127
|
+
device.deviceInfo ? JSON.stringify(device.deviceInfo) : null,
|
|
128
|
+
device.metadata ? JSON.stringify(device.metadata) : null,
|
|
129
|
+
],
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
await authDb.batch(statements);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function ensureDevices(store: PushTokenStore, userId: string): Promise<DeviceInfo[]> {
|
|
137
|
+
if (!store.authDb) {
|
|
138
|
+
return readDevicesFromKv(store.kv, userId);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const authDevices = await readDevicesFromAuthDb(store.authDb, userId);
|
|
142
|
+
if (authDevices.length > 0) {
|
|
143
|
+
return authDevices;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const kvDevices = await readDevicesFromKv(store.kv, userId);
|
|
147
|
+
if (kvDevices.length > 0) {
|
|
148
|
+
await persistDevices(store, userId, kvDevices);
|
|
149
|
+
}
|
|
150
|
+
return kvDevices;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ─── Public API ───
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Register a device token for a user.
|
|
157
|
+
* If the device already exists, update the token.
|
|
158
|
+
* If the user has too many devices, remove the oldest.
|
|
159
|
+
*/
|
|
160
|
+
export async function registerToken(
|
|
161
|
+
storeOrKv: KVNamespace | PushTokenStore,
|
|
162
|
+
userId: string,
|
|
163
|
+
deviceId: string,
|
|
164
|
+
token: string,
|
|
165
|
+
platform: string,
|
|
166
|
+
deviceInfo?: DeviceInfo['deviceInfo'],
|
|
167
|
+
metadata?: Record<string, unknown>,
|
|
168
|
+
): Promise<void> {
|
|
169
|
+
const now = new Date().toISOString();
|
|
170
|
+
const store = resolveStore(storeOrKv);
|
|
171
|
+
|
|
172
|
+
const devices = await ensureDevices(store, userId);
|
|
173
|
+
const existingIdx = devices.findIndex(d => d.deviceId === deviceId);
|
|
174
|
+
|
|
175
|
+
if (existingIdx >= 0) {
|
|
176
|
+
// Update existing device
|
|
177
|
+
devices[existingIdx] = { deviceId, token, platform, updatedAt: now, deviceInfo, metadata };
|
|
178
|
+
} else {
|
|
179
|
+
// Add new device
|
|
180
|
+
devices.push({ deviceId, token, platform, updatedAt: now, deviceInfo, metadata });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Enforce max devices — remove oldest if exceeded
|
|
184
|
+
if (devices.length > MAX_DEVICES_PER_USER) {
|
|
185
|
+
devices.sort((a, b) => a.updatedAt.localeCompare(b.updatedAt));
|
|
186
|
+
devices.splice(0, devices.length - MAX_DEVICES_PER_USER);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
await persistDevices(store, userId, devices);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Unregister a specific device token.
|
|
194
|
+
*/
|
|
195
|
+
export async function unregisterToken(
|
|
196
|
+
storeOrKv: KVNamespace | PushTokenStore,
|
|
197
|
+
userId: string,
|
|
198
|
+
deviceId: string,
|
|
199
|
+
): Promise<void> {
|
|
200
|
+
await removeDeviceFromUser(storeOrKv, userId, deviceId);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Get all registered devices for a user.
|
|
205
|
+
*/
|
|
206
|
+
export async function getDevicesForUser(
|
|
207
|
+
storeOrKv: KVNamespace | PushTokenStore,
|
|
208
|
+
userId: string,
|
|
209
|
+
): Promise<DeviceInfo[]> {
|
|
210
|
+
return ensureDevices(resolveStore(storeOrKv), userId);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Remove a specific device from a user's device list.
|
|
215
|
+
* Called when push send receives 410/404 (token invalid).
|
|
216
|
+
*/
|
|
217
|
+
export async function removeDeviceFromUser(
|
|
218
|
+
storeOrKv: KVNamespace | PushTokenStore,
|
|
219
|
+
userId: string,
|
|
220
|
+
deviceId: string,
|
|
221
|
+
): Promise<void> {
|
|
222
|
+
const store = resolveStore(storeOrKv);
|
|
223
|
+
const devices = await ensureDevices(store, userId);
|
|
224
|
+
const filtered = devices.filter(d => d.deviceId !== deviceId);
|
|
225
|
+
|
|
226
|
+
if (filtered.length !== devices.length) {
|
|
227
|
+
await persistDevices(store, userId, filtered);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Unregister all device tokens for a user.
|
|
233
|
+
* Called on revokeAllSessions / user deletion.
|
|
234
|
+
*/
|
|
235
|
+
export async function unregisterAllTokens(
|
|
236
|
+
storeOrKv: KVNamespace | PushTokenStore,
|
|
237
|
+
userId: string,
|
|
238
|
+
): Promise<void> {
|
|
239
|
+
await persistDevices(resolveStore(storeOrKv), userId, []);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ─── Push Log ───
|
|
243
|
+
|
|
244
|
+
const LOG_TTL_SECONDS = 24 * 60 * 60; // 24 hours
|
|
245
|
+
|
|
246
|
+
export interface PushLogEntry {
|
|
247
|
+
sentAt: string;
|
|
248
|
+
userId: string;
|
|
249
|
+
platform: string;
|
|
250
|
+
status: 'sent' | 'failed' | 'removed';
|
|
251
|
+
collapseId?: string;
|
|
252
|
+
error?: string;
|
|
253
|
+
runId?: string;
|
|
254
|
+
probeId?: string;
|
|
255
|
+
title?: string;
|
|
256
|
+
body?: string;
|
|
257
|
+
target?: string;
|
|
258
|
+
topic?: string;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Store a push send log entry (24h TTL).
|
|
263
|
+
*/
|
|
264
|
+
export async function storePushLog(
|
|
265
|
+
kv: KVNamespace,
|
|
266
|
+
userId: string,
|
|
267
|
+
entry: PushLogEntry,
|
|
268
|
+
): Promise<void> {
|
|
269
|
+
const logKey = `push:log:${userId}:${Date.now()}:${Math.random().toString(36).slice(2, 6)}`;
|
|
270
|
+
await kv.put(logKey, JSON.stringify(entry), { expirationTtl: LOG_TTL_SECONDS });
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Get push send logs for a user (last 24h).
|
|
275
|
+
*/
|
|
276
|
+
export async function getPushLogs(
|
|
277
|
+
kv: KVNamespace,
|
|
278
|
+
userId: string,
|
|
279
|
+
limit: number = 50,
|
|
280
|
+
): Promise<PushLogEntry[]> {
|
|
281
|
+
const result = await kv.list({ prefix: `push:log:${userId}:`, limit });
|
|
282
|
+
const entries: PushLogEntry[] = [];
|
|
283
|
+
|
|
284
|
+
for (const key of result.keys) {
|
|
285
|
+
const raw = await kv.get(key.name);
|
|
286
|
+
if (raw) {
|
|
287
|
+
try {
|
|
288
|
+
entries.push(JSON.parse(raw) as PushLogEntry);
|
|
289
|
+
} catch { /* skip corrupted */ }
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return entries;
|
|
294
|
+
}
|