@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,1465 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Push notification route — /api/push
|
|
3
|
+
*
|
|
4
|
+
* Endpoints:
|
|
5
|
+
* POST /api/push/register — Client registers device token (JWT auth)
|
|
6
|
+
* POST /api/push/unregister — Client unregisters device token (JWT auth)
|
|
7
|
+
* POST /api/push/send — Server sends push to userId (Service Key)
|
|
8
|
+
* POST /api/push/send-many — Server sends push to multiple userIds (Service Key)
|
|
9
|
+
* POST /api/push/send-to-token — Server sends directly using FCM token (Service Key)
|
|
10
|
+
* POST /api/push/send-to-topic — Server sends to FCM topic (Service Key)
|
|
11
|
+
* POST /api/push/broadcast — Server broadcasts to all devices (Service Key)
|
|
12
|
+
* POST /api/push/topic/subscribe — Client subscribes to topic via server (JWT, web용)
|
|
13
|
+
* POST /api/push/topic/unsubscribe — Client unsubscribes from topic (JWT, web용)
|
|
14
|
+
* GET /api/push/tokens — Server queries device tokens (Service Key)
|
|
15
|
+
* GET /api/push/logs — Server queries push logs (Service Key)
|
|
16
|
+
* PATCH /api/push/tokens — Server updates device metadata (Service Key)
|
|
17
|
+
*
|
|
18
|
+
* All push delivery goes through FCM only. iOS/Android/Web all use FCM Registration Tokens.
|
|
19
|
+
*/
|
|
20
|
+
import { OpenAPIHono, createRoute, z, type HonoEnv } from '../lib/hono.js';
|
|
21
|
+
import { getPushAccess, getPushHandlers } from '@edge-base/shared';
|
|
22
|
+
import type { AuthContext, PushHookCtx, PushSendInput, PushSendOutput } from '@edge-base/shared';
|
|
23
|
+
import type { Env } from '../types.js';
|
|
24
|
+
import { getD1BindingName, parseConfig, shouldRouteToD1 } from '../lib/do-router.js';
|
|
25
|
+
import { validateKey, buildConstraintCtx } from '../lib/service-key.js';
|
|
26
|
+
import { zodDefaultHook, jsonResponseSchema, errorResponseSchema } from '../lib/schemas.js';
|
|
27
|
+
import { ensureAuthSchema } from '../lib/auth-d1.js';
|
|
28
|
+
import { resolveAuthDb, type AuthDb } from '../lib/auth-db-adapter.js';
|
|
29
|
+
import {
|
|
30
|
+
registerToken,
|
|
31
|
+
unregisterToken,
|
|
32
|
+
getDevicesForUser,
|
|
33
|
+
removeDeviceFromUser,
|
|
34
|
+
storePushLog,
|
|
35
|
+
getPushLogs,
|
|
36
|
+
type PushLogEntry,
|
|
37
|
+
} from '../lib/push-token.js';
|
|
38
|
+
import {
|
|
39
|
+
createPushProvider,
|
|
40
|
+
type FcmProvider,
|
|
41
|
+
type PushPayload,
|
|
42
|
+
type PushSendResult,
|
|
43
|
+
} from '../lib/push-provider.js';
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
export const pushRoute = new OpenAPIHono<HonoEnv>({ defaultHook: zodDefaultHook });
|
|
47
|
+
|
|
48
|
+
/** Chunk size for token-based multicast (500 tokens per batch). */
|
|
49
|
+
const TOKEN_CHUNK_SIZE = 500;
|
|
50
|
+
|
|
51
|
+
/** Max metadata size in bytes. */
|
|
52
|
+
const MAX_METADATA_BYTES = 1024;
|
|
53
|
+
const utf8Encoder = new TextEncoder();
|
|
54
|
+
|
|
55
|
+
function getAuthDb(c: { env: Env }): AuthDb {
|
|
56
|
+
return resolveAuthDb(c.env as unknown as Record<string, unknown>);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function getPushTokenStore(c: { env: Env }): Promise<{ kv: KVNamespace; authDb: AuthDb }> {
|
|
60
|
+
const authDb = getAuthDb(c);
|
|
61
|
+
await ensureAuthSchema(authDb);
|
|
62
|
+
return { kv: c.env.KV, authDb };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function asNonEmptyString(value: unknown): string | undefined {
|
|
66
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function asStringArray(value: unknown): string[] | undefined {
|
|
70
|
+
return Array.isArray(value) && value.every((item) => typeof item === 'string' && item.length > 0)
|
|
71
|
+
? value
|
|
72
|
+
: undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function asPushPayload(value: unknown): PushPayload | undefined {
|
|
76
|
+
if (!value || typeof value !== 'object') return undefined;
|
|
77
|
+
const raw = value as Record<string, unknown>;
|
|
78
|
+
const notification = raw.notification && typeof raw.notification === 'object'
|
|
79
|
+
? raw.notification as Record<string, unknown>
|
|
80
|
+
: undefined;
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
...(raw as PushPayload),
|
|
84
|
+
title: asNonEmptyString(raw.title) ?? asNonEmptyString(notification?.title),
|
|
85
|
+
body: asNonEmptyString(raw.body) ?? asNonEmptyString(notification?.body),
|
|
86
|
+
image: asNonEmptyString(raw.image) ?? asNonEmptyString(notification?.image),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function extractPushTraceValue(
|
|
91
|
+
payload: PushPayload,
|
|
92
|
+
key: 'runId' | 'probeId',
|
|
93
|
+
): string | undefined {
|
|
94
|
+
const value = payload.data?.[key];
|
|
95
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function waitUntilSafe(
|
|
99
|
+
c: { executionCtx?: ExecutionContext },
|
|
100
|
+
promise: Promise<unknown>,
|
|
101
|
+
): void {
|
|
102
|
+
try {
|
|
103
|
+
c.executionCtx?.waitUntil?.(promise);
|
|
104
|
+
} catch {
|
|
105
|
+
void promise.catch((err) => {
|
|
106
|
+
console.error('[Push] background task failed:', err);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function asNullableString(value: unknown): string | null {
|
|
112
|
+
return typeof value === 'string' && value.length > 0 ? value : null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function metadataExceedsByteLimit(metadata: Record<string, unknown> | undefined): boolean {
|
|
116
|
+
if (!metadata) return false;
|
|
117
|
+
return utf8Encoder.encode(JSON.stringify(metadata)).length > MAX_METADATA_BYTES;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function getSharedMirrorDb(env: Env): D1Database | null {
|
|
121
|
+
const config = parseConfig(env);
|
|
122
|
+
if (!shouldRouteToD1('shared', config)) return null;
|
|
123
|
+
|
|
124
|
+
const bindingName = getD1BindingName('shared');
|
|
125
|
+
const candidate = (env as unknown as Record<string, unknown>)[bindingName];
|
|
126
|
+
if (!candidate || typeof candidate !== 'object') return null;
|
|
127
|
+
|
|
128
|
+
return typeof (candidate as D1Database).prepare === 'function'
|
|
129
|
+
? candidate as D1Database
|
|
130
|
+
: null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function isMissingSharedMirrorTable(error: unknown): boolean {
|
|
134
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
135
|
+
return /no such table/i.test(message);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function withSharedMirror(
|
|
139
|
+
env: Env,
|
|
140
|
+
operation: string,
|
|
141
|
+
fn: (db: D1Database) => Promise<void>,
|
|
142
|
+
): Promise<void> {
|
|
143
|
+
const db = getSharedMirrorDb(env);
|
|
144
|
+
if (!db) return;
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
await fn(db);
|
|
148
|
+
} catch (error) {
|
|
149
|
+
if (isMissingSharedMirrorTable(error)) return;
|
|
150
|
+
console.warn(`[Push] shared mirror ${operation} failed:`, error);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function readMetadataString(
|
|
155
|
+
metadata: Record<string, unknown> | undefined,
|
|
156
|
+
...keys: string[]
|
|
157
|
+
): string | null {
|
|
158
|
+
if (!metadata) return null;
|
|
159
|
+
|
|
160
|
+
for (const key of keys) {
|
|
161
|
+
const value = metadata[key];
|
|
162
|
+
if (typeof value === 'string' && value.length > 0) return value;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function mirrorPushDeviceUpsert(
|
|
169
|
+
env: Env,
|
|
170
|
+
entry: {
|
|
171
|
+
userId: string;
|
|
172
|
+
deviceId: string;
|
|
173
|
+
token: string;
|
|
174
|
+
platform: string;
|
|
175
|
+
updatedAt: string;
|
|
176
|
+
deviceInfo?: Record<string, unknown>;
|
|
177
|
+
metadata?: Record<string, unknown>;
|
|
178
|
+
},
|
|
179
|
+
): Promise<void> {
|
|
180
|
+
await withSharedMirror(env, 'device-upsert', async (db) => {
|
|
181
|
+
const rowKey = `${entry.userId}:${entry.deviceId}`;
|
|
182
|
+
const runId = readMetadataString(entry.metadata, 'runId', 'run_id');
|
|
183
|
+
const slotId = readMetadataString(entry.metadata, 'slotId', 'slot_id');
|
|
184
|
+
const sdk = readMetadataString(entry.metadata, 'sdk');
|
|
185
|
+
|
|
186
|
+
await db.prepare(
|
|
187
|
+
`INSERT INTO devices (
|
|
188
|
+
rowKey, runId, userId, deviceId, token, platform, slotId, sdk, updatedAt, deviceInfo, metadata
|
|
189
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
190
|
+
ON CONFLICT(rowKey) DO UPDATE SET
|
|
191
|
+
runId = excluded.runId,
|
|
192
|
+
userId = excluded.userId,
|
|
193
|
+
deviceId = excluded.deviceId,
|
|
194
|
+
token = excluded.token,
|
|
195
|
+
platform = excluded.platform,
|
|
196
|
+
slotId = excluded.slotId,
|
|
197
|
+
sdk = excluded.sdk,
|
|
198
|
+
updatedAt = excluded.updatedAt,
|
|
199
|
+
deviceInfo = excluded.deviceInfo,
|
|
200
|
+
metadata = excluded.metadata`,
|
|
201
|
+
).bind(
|
|
202
|
+
rowKey,
|
|
203
|
+
runId,
|
|
204
|
+
entry.userId,
|
|
205
|
+
entry.deviceId,
|
|
206
|
+
entry.token,
|
|
207
|
+
entry.platform,
|
|
208
|
+
slotId,
|
|
209
|
+
sdk,
|
|
210
|
+
entry.updatedAt,
|
|
211
|
+
entry.deviceInfo ? JSON.stringify(entry.deviceInfo) : null,
|
|
212
|
+
entry.metadata ? JSON.stringify(entry.metadata) : null,
|
|
213
|
+
).run();
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function mirrorPushDeviceDelete(env: Env, userId: string, deviceId: string): Promise<void> {
|
|
218
|
+
await withSharedMirror(env, 'device-delete', async (db) => {
|
|
219
|
+
await db.prepare('DELETE FROM devices WHERE userId = ? AND deviceId = ?')
|
|
220
|
+
.bind(userId, deviceId)
|
|
221
|
+
.run();
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function mirrorPushLog(env: Env, entry: PushLogEntry): Promise<void> {
|
|
226
|
+
await withSharedMirror(env, 'log-insert', async (db) => {
|
|
227
|
+
const logKey = `push:${Date.now()}:${Math.random().toString(36).slice(2, 8)}`;
|
|
228
|
+
await db.prepare(
|
|
229
|
+
`INSERT INTO push_log (
|
|
230
|
+
logKey, runId, probeId, userId, platform, status, sentAt, collapseId,
|
|
231
|
+
error, title, body, target, topic
|
|
232
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
233
|
+
).bind(
|
|
234
|
+
logKey,
|
|
235
|
+
asNullableString(entry.runId),
|
|
236
|
+
asNullableString(entry.probeId),
|
|
237
|
+
entry.userId,
|
|
238
|
+
entry.platform,
|
|
239
|
+
entry.status,
|
|
240
|
+
entry.sentAt,
|
|
241
|
+
asNullableString(entry.collapseId),
|
|
242
|
+
asNullableString(entry.error),
|
|
243
|
+
asNullableString(entry.title),
|
|
244
|
+
asNullableString(entry.body),
|
|
245
|
+
asNullableString(entry.target),
|
|
246
|
+
asNullableString(entry.topic),
|
|
247
|
+
).run();
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function runBeforeSendHook(
|
|
252
|
+
c: { env: Env; executionCtx: ExecutionContext; req: { raw: Request } },
|
|
253
|
+
auth: AuthContext | null,
|
|
254
|
+
input: PushSendInput,
|
|
255
|
+
): Promise<PushSendInput> {
|
|
256
|
+
const hook = getPushHandlers(parseConfig(c.env)?.push)?.hooks?.beforeSend;
|
|
257
|
+
if (!hook) return input;
|
|
258
|
+
|
|
259
|
+
const hookCtx: PushHookCtx = {
|
|
260
|
+
request: c.req.raw,
|
|
261
|
+
waitUntil: (promise: Promise<unknown>) => waitUntilSafe(c, promise),
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const result = await Promise.resolve(hook(auth, input, hookCtx));
|
|
265
|
+
return result ?? input;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function runAfterSendHook(
|
|
269
|
+
c: { env: Env; executionCtx: ExecutionContext; req: { raw: Request } },
|
|
270
|
+
auth: AuthContext | null,
|
|
271
|
+
input: PushSendInput,
|
|
272
|
+
output: PushSendOutput,
|
|
273
|
+
): void {
|
|
274
|
+
const hook = getPushHandlers(parseConfig(c.env)?.push)?.hooks?.afterSend;
|
|
275
|
+
if (!hook) return;
|
|
276
|
+
|
|
277
|
+
const hookCtx: PushHookCtx = {
|
|
278
|
+
request: c.req.raw,
|
|
279
|
+
waitUntil: (promise: Promise<unknown>) => waitUntilSafe(c, promise),
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
waitUntilSafe(c, Promise.resolve(hook(auth, input, output, hookCtx)).catch((err) => {
|
|
283
|
+
console.error('[Push] afterSend hook failed:', err);
|
|
284
|
+
}));
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ─── POST /register — Client device token registration ───
|
|
288
|
+
|
|
289
|
+
const pushRegister = createRoute({
|
|
290
|
+
operationId: 'pushRegister',
|
|
291
|
+
method: 'post',
|
|
292
|
+
path: '/register',
|
|
293
|
+
tags: ['client'],
|
|
294
|
+
summary: 'Register push token',
|
|
295
|
+
request: {
|
|
296
|
+
body: { content: { 'application/json': { schema: z.object({
|
|
297
|
+
deviceId: z.string(),
|
|
298
|
+
token: z.string(),
|
|
299
|
+
platform: z.string(),
|
|
300
|
+
deviceInfo: z.object({
|
|
301
|
+
name: z.string().optional(),
|
|
302
|
+
osVersion: z.string().optional(),
|
|
303
|
+
appVersion: z.string().optional(),
|
|
304
|
+
locale: z.string().optional(),
|
|
305
|
+
}).optional(),
|
|
306
|
+
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
307
|
+
}) } }, required: true },
|
|
308
|
+
},
|
|
309
|
+
responses: {
|
|
310
|
+
200: { description: 'Token registered', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
311
|
+
400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
312
|
+
401: { description: 'Unauthorized', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
313
|
+
},
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
pushRoute.openapi(pushRegister, async (c) => {
|
|
317
|
+
const auth = c.get('auth' as never) as { id: string } | null | undefined;
|
|
318
|
+
if (!auth?.id) {
|
|
319
|
+
return c.json({ code: 401, message: 'Authentication required to register push token' }, 401);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
let body: {
|
|
323
|
+
deviceId?: string;
|
|
324
|
+
token?: string;
|
|
325
|
+
platform?: string;
|
|
326
|
+
deviceInfo?: { name?: string; osVersion?: string; appVersion?: string; locale?: string };
|
|
327
|
+
metadata?: Record<string, unknown>;
|
|
328
|
+
};
|
|
329
|
+
try {
|
|
330
|
+
body = await c.req.json();
|
|
331
|
+
} catch {
|
|
332
|
+
return c.json({ code: 400, message: 'Invalid JSON body' }, 400);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const { deviceId, token, platform } = body;
|
|
336
|
+
if (!deviceId || typeof deviceId !== 'string') {
|
|
337
|
+
return c.json({ code: 400, message: 'deviceId is required' }, 400);
|
|
338
|
+
}
|
|
339
|
+
if (!token || typeof token !== 'string') {
|
|
340
|
+
return c.json({ code: 400, message: 'token is required' }, 400);
|
|
341
|
+
}
|
|
342
|
+
if (!platform || typeof platform !== 'string') {
|
|
343
|
+
return c.json({ code: 400, message: 'platform is required' }, 400);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Validate metadata size (≤1KB)
|
|
347
|
+
if (metadataExceedsByteLimit(body.metadata)) {
|
|
348
|
+
return c.json({ code: 400, message: `metadata exceeds ${MAX_METADATA_BYTES} byte limit` }, 400);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// All tokens are FCM Registration Tokens — store directly
|
|
352
|
+
await registerToken(await getPushTokenStore(c), auth.id, deviceId, token, platform, body.deviceInfo, body.metadata);
|
|
353
|
+
await mirrorPushDeviceUpsert(c.env, {
|
|
354
|
+
userId: auth.id,
|
|
355
|
+
deviceId,
|
|
356
|
+
token,
|
|
357
|
+
platform,
|
|
358
|
+
updatedAt: new Date().toISOString(),
|
|
359
|
+
deviceInfo: body.deviceInfo,
|
|
360
|
+
metadata: body.metadata,
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
// Auto-subscribe token to 'all' topic for broadcast() support (FCM 일원화)
|
|
364
|
+
const config = parseConfig(c.env);
|
|
365
|
+
const provider = createPushProvider(config.push, c.env);
|
|
366
|
+
if (provider) {
|
|
367
|
+
// Best-effort — don't fail registration if topic subscription fails
|
|
368
|
+
await provider.subscribeTokenToTopic(token, 'all').catch(() => {});
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return c.json({ ok: true }, 200);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
// ─── POST /unregister — Client device token removal ───
|
|
375
|
+
|
|
376
|
+
const pushUnregister = createRoute({
|
|
377
|
+
operationId: 'pushUnregister',
|
|
378
|
+
method: 'post',
|
|
379
|
+
path: '/unregister',
|
|
380
|
+
tags: ['client'],
|
|
381
|
+
summary: 'Unregister push token',
|
|
382
|
+
request: {
|
|
383
|
+
body: { content: { 'application/json': { schema: z.object({
|
|
384
|
+
deviceId: z.string(),
|
|
385
|
+
}) } }, required: true },
|
|
386
|
+
},
|
|
387
|
+
responses: {
|
|
388
|
+
200: { description: 'Token unregistered', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
389
|
+
400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
390
|
+
401: { description: 'Unauthorized', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
391
|
+
},
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
pushRoute.openapi(pushUnregister, async (c) => {
|
|
395
|
+
const auth = c.get('auth' as never) as { id: string } | null | undefined;
|
|
396
|
+
if (!auth?.id) {
|
|
397
|
+
return c.json({ code: 401, message: 'Authentication required' }, 401);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
let body: { deviceId?: string };
|
|
401
|
+
try {
|
|
402
|
+
body = await c.req.json();
|
|
403
|
+
} catch {
|
|
404
|
+
return c.json({ code: 400, message: 'Invalid JSON body' }, 400);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (!body.deviceId || typeof body.deviceId !== 'string') {
|
|
408
|
+
return c.json({ code: 400, message: 'deviceId is required' }, 400);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Get the FCM token from user's device array BEFORE removing
|
|
412
|
+
const devices = await getDevicesForUser(await getPushTokenStore(c), auth.id);
|
|
413
|
+
const device = devices.find(d => d.deviceId === body.deviceId);
|
|
414
|
+
if (device?.token) {
|
|
415
|
+
const config = parseConfig(c.env);
|
|
416
|
+
const provider = createPushProvider(config.push, c.env);
|
|
417
|
+
if (provider) {
|
|
418
|
+
// Best-effort — don't fail unregister if topic unsubscription fails
|
|
419
|
+
await provider.unsubscribeTokenFromTopic(device.token, 'all').catch(() => {});
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
await unregisterToken(await getPushTokenStore(c), auth.id, body.deviceId);
|
|
424
|
+
await mirrorPushDeviceDelete(c.env, auth.id, body.deviceId);
|
|
425
|
+
return c.json({ ok: true });
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
// ─── POST /send — Send push to a single user ───
|
|
429
|
+
|
|
430
|
+
const pushSend = createRoute({
|
|
431
|
+
operationId: 'pushSend',
|
|
432
|
+
method: 'post',
|
|
433
|
+
path: '/send',
|
|
434
|
+
tags: ['admin'],
|
|
435
|
+
summary: 'Send push notification to user',
|
|
436
|
+
request: {
|
|
437
|
+
body: { content: { 'application/json': { schema: z.object({
|
|
438
|
+
userId: z.string(),
|
|
439
|
+
payload: z.record(z.string(), z.unknown()),
|
|
440
|
+
}) } }, required: true },
|
|
441
|
+
},
|
|
442
|
+
responses: {
|
|
443
|
+
200: { description: 'Push result', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
444
|
+
400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
445
|
+
401: { description: 'Unauthorized', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
446
|
+
403: { description: 'Forbidden', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
447
|
+
503: { description: 'Push not configured', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
448
|
+
},
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
pushRoute.openapi(pushSend, async (c) => {
|
|
452
|
+
const config = parseConfig(c.env);
|
|
453
|
+
const auth = (c.get('auth' as never) as AuthContext | null | undefined) ?? null;
|
|
454
|
+
|
|
455
|
+
const { result: skResult } = validateKey(
|
|
456
|
+
c.req.header('X-EdgeBase-Service-Key'),
|
|
457
|
+
'push:notification:*:send',
|
|
458
|
+
config,
|
|
459
|
+
c.env,
|
|
460
|
+
undefined,
|
|
461
|
+
buildConstraintCtx(c.env, c.req),
|
|
462
|
+
);
|
|
463
|
+
if (skResult === 'missing') {
|
|
464
|
+
return c.json({ code: 403, message: 'Service Key required for push send' }, 403);
|
|
465
|
+
}
|
|
466
|
+
if (skResult === 'invalid') {
|
|
467
|
+
return c.json({ code: 401, message: 'Unauthorized. Invalid Service Key.' }, 401);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const provider = createPushProvider(config.push, c.env);
|
|
471
|
+
if (!provider) {
|
|
472
|
+
return c.json({ code: 503, message: 'Push notifications are not configured. Add push.fcm config with FCM credentials.' }, 503);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
let body: { userId?: string; payload?: PushPayload };
|
|
476
|
+
try {
|
|
477
|
+
body = await c.req.json();
|
|
478
|
+
} catch {
|
|
479
|
+
return c.json({ code: 400, message: 'Invalid JSON body' }, 400);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
let { userId, payload } = body;
|
|
483
|
+
if (!userId || typeof userId !== 'string') {
|
|
484
|
+
return c.json({ code: 400, message: 'userId is required' }, 400);
|
|
485
|
+
}
|
|
486
|
+
if (!payload || typeof payload !== 'object') {
|
|
487
|
+
return c.json({ code: 400, message: 'payload is required' }, 400);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
({ userId, payload } = await runBeforeSendHook(c, auth, {
|
|
491
|
+
kind: 'user',
|
|
492
|
+
userId,
|
|
493
|
+
payload: payload as Record<string, unknown>,
|
|
494
|
+
}));
|
|
495
|
+
userId = asNonEmptyString(userId);
|
|
496
|
+
payload = asPushPayload(payload);
|
|
497
|
+
if (!userId || !payload) {
|
|
498
|
+
return c.json({ code: 400, message: 'beforeSend must return a valid userId and payload' }, 400);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const payloadStr = JSON.stringify(payload);
|
|
502
|
+
if (payloadStr.length > 4096) {
|
|
503
|
+
return c.json({ code: 400, message: 'Payload exceeds 4KB limit' }, 400);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// Evaluate push send rule (if defined)
|
|
507
|
+
const sendRule = getPushAccess(config.push)?.send;
|
|
508
|
+
if (sendRule) {
|
|
509
|
+
try {
|
|
510
|
+
if (!sendRule(auth, { userId })) {
|
|
511
|
+
return c.json({ code: 403, message: 'Denied by push send rule' }, 403);
|
|
512
|
+
}
|
|
513
|
+
} catch {
|
|
514
|
+
return c.json({ code: 403, message: 'Denied by push send rule' }, 403);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const result = await sendToUser(await getPushTokenStore(c), provider, userId, payload, c.env);
|
|
519
|
+
runAfterSendHook(c, auth, { kind: 'user', userId, payload: payload as Record<string, unknown> }, { ...result, raw: result });
|
|
520
|
+
return c.json(result);
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
// ─── POST /send-many — Send push to multiple users (token-based chunking) ───
|
|
524
|
+
|
|
525
|
+
const pushSendMany = createRoute({
|
|
526
|
+
operationId: 'pushSendMany',
|
|
527
|
+
method: 'post',
|
|
528
|
+
path: '/send-many',
|
|
529
|
+
tags: ['admin'],
|
|
530
|
+
summary: 'Send push to multiple users',
|
|
531
|
+
request: {
|
|
532
|
+
body: { content: { 'application/json': { schema: z.object({
|
|
533
|
+
userIds: z.array(z.string()),
|
|
534
|
+
payload: z.record(z.string(), z.unknown()),
|
|
535
|
+
}) } }, required: true },
|
|
536
|
+
},
|
|
537
|
+
responses: {
|
|
538
|
+
200: { description: 'Push results', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
539
|
+
400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
540
|
+
401: { description: 'Unauthorized', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
541
|
+
403: { description: 'Forbidden', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
542
|
+
503: { description: 'Push not configured', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
543
|
+
},
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
pushRoute.openapi(pushSendMany, async (c) => {
|
|
547
|
+
const config = parseConfig(c.env);
|
|
548
|
+
const auth = (c.get('auth' as never) as AuthContext | null | undefined) ?? null;
|
|
549
|
+
|
|
550
|
+
const { result: skResult } = validateKey(
|
|
551
|
+
c.req.header('X-EdgeBase-Service-Key'),
|
|
552
|
+
'push:notification:*:send',
|
|
553
|
+
config,
|
|
554
|
+
c.env,
|
|
555
|
+
undefined,
|
|
556
|
+
buildConstraintCtx(c.env, c.req),
|
|
557
|
+
);
|
|
558
|
+
if (skResult === 'missing') {
|
|
559
|
+
return c.json({ code: 403, message: 'Service Key required for push send' }, 403);
|
|
560
|
+
}
|
|
561
|
+
if (skResult === 'invalid') {
|
|
562
|
+
return c.json({ code: 401, message: 'Unauthorized. Invalid Service Key.' }, 401);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
const provider = createPushProvider(config.push, c.env);
|
|
566
|
+
if (!provider) {
|
|
567
|
+
return c.json({ code: 503, message: 'Push notifications are not configured. Add push.fcm config with FCM credentials.' }, 503);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
let body: { userIds?: string[]; payload?: PushPayload };
|
|
571
|
+
try {
|
|
572
|
+
body = await c.req.json();
|
|
573
|
+
} catch {
|
|
574
|
+
return c.json({ code: 400, message: 'Invalid JSON body' }, 400);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
let { userIds, payload } = body;
|
|
578
|
+
if (!userIds || !Array.isArray(userIds) || userIds.length === 0) {
|
|
579
|
+
return c.json({ code: 400, message: 'userIds array is required and must not be empty' }, 400);
|
|
580
|
+
}
|
|
581
|
+
if (userIds.length > 10000) {
|
|
582
|
+
return c.json({ code: 400, message: 'userIds array must not exceed 10,000 items' }, 400);
|
|
583
|
+
}
|
|
584
|
+
if (!payload || typeof payload !== 'object') {
|
|
585
|
+
return c.json({ code: 400, message: 'payload is required' }, 400);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
({ userIds, payload } = await runBeforeSendHook(c, auth, {
|
|
589
|
+
kind: 'users',
|
|
590
|
+
userIds,
|
|
591
|
+
payload: payload as Record<string, unknown>,
|
|
592
|
+
}));
|
|
593
|
+
userIds = asStringArray(userIds);
|
|
594
|
+
payload = asPushPayload(payload);
|
|
595
|
+
if (!userIds || userIds.length === 0 || !payload) {
|
|
596
|
+
return c.json({ code: 400, message: 'beforeSend must return userIds[] and payload' }, 400);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const payloadStr = JSON.stringify(payload);
|
|
600
|
+
if (payloadStr.length > 4096) {
|
|
601
|
+
return c.json({ code: 400, message: 'Payload exceeds 4KB limit' }, 400);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// Evaluate push send rule per userId (if defined)
|
|
605
|
+
const sendRule = getPushAccess(config.push)?.send;
|
|
606
|
+
let allowedUserIds = userIds;
|
|
607
|
+
if (sendRule) {
|
|
608
|
+
allowedUserIds = userIds.filter((uid) => {
|
|
609
|
+
try {
|
|
610
|
+
return sendRule(auth, { userId: uid });
|
|
611
|
+
} catch {
|
|
612
|
+
return false; // fail-closed
|
|
613
|
+
}
|
|
614
|
+
});
|
|
615
|
+
if (allowedUserIds.length === 0) {
|
|
616
|
+
return c.json({ code: 403, message: 'Denied by push send rule' }, 403);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// Step 1: Collect all tokens from all userIds
|
|
621
|
+
const pushStore = await getPushTokenStore(c);
|
|
622
|
+
const allTokens: Array<{ userId: string; deviceId: string; token: string; platform: string }> = [];
|
|
623
|
+
for (const uid of allowedUserIds) {
|
|
624
|
+
const devices = await getDevicesForUser(pushStore, uid);
|
|
625
|
+
for (const d of devices) {
|
|
626
|
+
allTokens.push({ userId: uid, deviceId: d.deviceId, token: d.token, platform: d.platform });
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
if (allTokens.length === 0) {
|
|
631
|
+
return c.json({ sent: 0, failed: 0, removed: 0 });
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// Step 2: Chunk by TOKEN_CHUNK_SIZE (500) tokens and send sequentially
|
|
635
|
+
let sent = 0;
|
|
636
|
+
let failed = 0;
|
|
637
|
+
let removed = 0;
|
|
638
|
+
|
|
639
|
+
for (let i = 0; i < allTokens.length; i += TOKEN_CHUNK_SIZE) {
|
|
640
|
+
const chunk = allTokens.slice(i, i + TOKEN_CHUNK_SIZE);
|
|
641
|
+
const results = await Promise.allSettled(
|
|
642
|
+
chunk.map(async (entry) => {
|
|
643
|
+
const result = await provider.send({ token: entry.token, platform: entry.platform, payload });
|
|
644
|
+
return { ...entry, result };
|
|
645
|
+
}),
|
|
646
|
+
);
|
|
647
|
+
|
|
648
|
+
for (const settled of results) {
|
|
649
|
+
if (settled.status === 'rejected') {
|
|
650
|
+
failed++;
|
|
651
|
+
continue;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
const { userId, deviceId, platform, result } = settled.value;
|
|
655
|
+
|
|
656
|
+
if (result.success) {
|
|
657
|
+
sent++;
|
|
658
|
+
} else if (result.remove) {
|
|
659
|
+
removed++;
|
|
660
|
+
await removeDeviceFromUser(pushStore, userId, deviceId);
|
|
661
|
+
await mirrorPushDeviceDelete(c.env, userId, deviceId);
|
|
662
|
+
} else {
|
|
663
|
+
failed++;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
const logEntry: PushLogEntry = {
|
|
667
|
+
sentAt: new Date().toISOString(),
|
|
668
|
+
userId,
|
|
669
|
+
platform,
|
|
670
|
+
status: result.success ? 'sent' : (result.remove ? 'removed' : 'failed'),
|
|
671
|
+
collapseId: payload.collapseId,
|
|
672
|
+
error: result.error,
|
|
673
|
+
runId: extractPushTraceValue(payload, 'runId'),
|
|
674
|
+
probeId: extractPushTraceValue(payload, 'probeId'),
|
|
675
|
+
title: payload.title,
|
|
676
|
+
body: payload.body,
|
|
677
|
+
target: deviceId,
|
|
678
|
+
};
|
|
679
|
+
await storePushLog(c.env.KV, userId, logEntry);
|
|
680
|
+
await mirrorPushLog(c.env, logEntry);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
runAfterSendHook(
|
|
685
|
+
c,
|
|
686
|
+
auth,
|
|
687
|
+
{ kind: 'users', userIds: allowedUserIds, payload: payload as Record<string, unknown> },
|
|
688
|
+
{ sent, failed, removed, raw: { sent, failed, removed } },
|
|
689
|
+
);
|
|
690
|
+
return c.json({ sent, failed, removed });
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
// ─── POST /send-to-token — Send push directly using FCM token (Service Key) ───
|
|
694
|
+
|
|
695
|
+
const pushSendToToken = createRoute({
|
|
696
|
+
operationId: 'pushSendToToken',
|
|
697
|
+
method: 'post',
|
|
698
|
+
path: '/send-to-token',
|
|
699
|
+
tags: ['admin'],
|
|
700
|
+
summary: 'Send push to specific token',
|
|
701
|
+
request: {
|
|
702
|
+
body: { content: { 'application/json': { schema: z.object({
|
|
703
|
+
token: z.string(),
|
|
704
|
+
platform: z.string().optional(),
|
|
705
|
+
payload: z.record(z.string(), z.unknown()),
|
|
706
|
+
}) } }, required: true },
|
|
707
|
+
},
|
|
708
|
+
responses: {
|
|
709
|
+
200: { description: 'Push result', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
710
|
+
400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
711
|
+
401: { description: 'Unauthorized', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
712
|
+
403: { description: 'Forbidden', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
713
|
+
503: { description: 'Push not configured', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
714
|
+
},
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
pushRoute.openapi(pushSendToToken, async (c) => {
|
|
718
|
+
const config = parseConfig(c.env);
|
|
719
|
+
const auth = (c.get('auth' as never) as AuthContext | null | undefined) ?? null;
|
|
720
|
+
|
|
721
|
+
const { result: skResult } = validateKey(
|
|
722
|
+
c.req.header('X-EdgeBase-Service-Key'),
|
|
723
|
+
'push:notification:*:send',
|
|
724
|
+
config,
|
|
725
|
+
c.env,
|
|
726
|
+
undefined,
|
|
727
|
+
buildConstraintCtx(c.env, c.req),
|
|
728
|
+
);
|
|
729
|
+
if (skResult === 'missing') {
|
|
730
|
+
return c.json({ code: 403, message: 'Service Key required for push send' }, 403);
|
|
731
|
+
}
|
|
732
|
+
if (skResult === 'invalid') {
|
|
733
|
+
return c.json({ code: 401, message: 'Unauthorized. Invalid Service Key.' }, 401);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
const provider = createPushProvider(config.push, c.env);
|
|
737
|
+
if (!provider) {
|
|
738
|
+
return c.json({ code: 503, message: 'Push notifications are not configured. Add push.fcm config with FCM credentials.' }, 503);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
let body: { token?: string; platform?: string; payload?: PushPayload };
|
|
742
|
+
try {
|
|
743
|
+
body = await c.req.json();
|
|
744
|
+
} catch {
|
|
745
|
+
return c.json({ code: 400, message: 'Invalid JSON body' }, 400);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
if (!body.token || typeof body.token !== 'string') {
|
|
749
|
+
return c.json({ code: 400, message: 'token is required' }, 400);
|
|
750
|
+
}
|
|
751
|
+
if (!body.payload || typeof body.payload !== 'object') {
|
|
752
|
+
return c.json({ code: 400, message: 'payload is required' }, 400);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
const hookInput = await runBeforeSendHook(c, auth, {
|
|
756
|
+
kind: 'token',
|
|
757
|
+
token: body.token,
|
|
758
|
+
platform: body.platform || 'web',
|
|
759
|
+
payload: body.payload as Record<string, unknown>,
|
|
760
|
+
});
|
|
761
|
+
const token = asNonEmptyString(hookInput.token);
|
|
762
|
+
const platform = asNonEmptyString(hookInput.platform) ?? 'web';
|
|
763
|
+
const payload = asPushPayload(hookInput.payload);
|
|
764
|
+
if (!token || !payload) {
|
|
765
|
+
return c.json({ code: 400, message: 'beforeSend must return token and payload' }, 400);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
const payloadStr = JSON.stringify(payload);
|
|
769
|
+
if (payloadStr.length > 4096) {
|
|
770
|
+
return c.json({ code: 400, message: 'Payload exceeds 4KB limit' }, 400);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
const result = await provider.send({
|
|
774
|
+
token,
|
|
775
|
+
platform,
|
|
776
|
+
payload,
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
const directLogEntry: PushLogEntry = {
|
|
780
|
+
sentAt: new Date().toISOString(),
|
|
781
|
+
userId: '__direct-token__',
|
|
782
|
+
platform,
|
|
783
|
+
status: result.success ? 'sent' : (result.remove ? 'removed' : 'failed'),
|
|
784
|
+
collapseId: payload.collapseId,
|
|
785
|
+
error: result.error,
|
|
786
|
+
runId: extractPushTraceValue(payload, 'runId'),
|
|
787
|
+
probeId: extractPushTraceValue(payload, 'probeId'),
|
|
788
|
+
title: payload.title,
|
|
789
|
+
body: payload.body,
|
|
790
|
+
target: token,
|
|
791
|
+
};
|
|
792
|
+
await storePushLog(c.env.KV, '__direct-token__', directLogEntry);
|
|
793
|
+
await mirrorPushLog(c.env, directLogEntry);
|
|
794
|
+
|
|
795
|
+
runAfterSendHook(
|
|
796
|
+
c,
|
|
797
|
+
auth,
|
|
798
|
+
{
|
|
799
|
+
kind: 'token',
|
|
800
|
+
token,
|
|
801
|
+
platform,
|
|
802
|
+
payload: payload as Record<string, unknown>,
|
|
803
|
+
},
|
|
804
|
+
{
|
|
805
|
+
sent: result.success ? 1 : 0,
|
|
806
|
+
failed: result.success ? 0 : 1,
|
|
807
|
+
...(result.error ? { error: result.error } : {}),
|
|
808
|
+
raw: result,
|
|
809
|
+
},
|
|
810
|
+
);
|
|
811
|
+
|
|
812
|
+
return c.json({
|
|
813
|
+
sent: result.success ? 1 : 0,
|
|
814
|
+
failed: result.success ? 0 : 1,
|
|
815
|
+
...(result.error ? { error: result.error } : {}),
|
|
816
|
+
});
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
// ─── POST /send-to-topic — Send push to FCM topic (Service Key) ───
|
|
820
|
+
|
|
821
|
+
const pushSendToTopic = createRoute({
|
|
822
|
+
operationId: 'pushSendToTopic',
|
|
823
|
+
method: 'post',
|
|
824
|
+
path: '/send-to-topic',
|
|
825
|
+
tags: ['admin'],
|
|
826
|
+
summary: 'Send push to topic',
|
|
827
|
+
request: {
|
|
828
|
+
body: { content: { 'application/json': { schema: z.object({
|
|
829
|
+
topic: z.string(),
|
|
830
|
+
payload: z.record(z.string(), z.unknown()),
|
|
831
|
+
}) } }, required: true },
|
|
832
|
+
},
|
|
833
|
+
responses: {
|
|
834
|
+
200: { description: 'Push result', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
835
|
+
400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
836
|
+
401: { description: 'Unauthorized', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
837
|
+
403: { description: 'Forbidden', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
838
|
+
503: { description: 'Push not configured', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
839
|
+
},
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
pushRoute.openapi(pushSendToTopic, async (c) => {
|
|
843
|
+
const config = parseConfig(c.env);
|
|
844
|
+
const auth = (c.get('auth' as never) as AuthContext | null | undefined) ?? null;
|
|
845
|
+
|
|
846
|
+
const { result: skResult } = validateKey(
|
|
847
|
+
c.req.header('X-EdgeBase-Service-Key'),
|
|
848
|
+
'push:notification:*:send',
|
|
849
|
+
config,
|
|
850
|
+
c.env,
|
|
851
|
+
undefined,
|
|
852
|
+
buildConstraintCtx(c.env, c.req),
|
|
853
|
+
);
|
|
854
|
+
if (skResult === 'missing') {
|
|
855
|
+
return c.json({ code: 403, message: 'Service Key required for push send' }, 403);
|
|
856
|
+
}
|
|
857
|
+
if (skResult === 'invalid') {
|
|
858
|
+
return c.json({ code: 401, message: 'Unauthorized. Invalid Service Key.' }, 401);
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
const provider = createPushProvider(config.push, c.env);
|
|
862
|
+
if (!provider) {
|
|
863
|
+
return c.json({ code: 503, message: 'Push notifications are not configured. Add push.fcm config with FCM credentials.' }, 503);
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
let body: { topic?: string; payload?: PushPayload };
|
|
867
|
+
try {
|
|
868
|
+
body = await c.req.json();
|
|
869
|
+
} catch {
|
|
870
|
+
return c.json({ code: 400, message: 'Invalid JSON body' }, 400);
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
if (!body.topic || typeof body.topic !== 'string') {
|
|
874
|
+
return c.json({ code: 400, message: 'topic is required' }, 400);
|
|
875
|
+
}
|
|
876
|
+
if (!body.payload || typeof body.payload !== 'object') {
|
|
877
|
+
return c.json({ code: 400, message: 'payload is required' }, 400);
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
const hookInput = await runBeforeSendHook(c, auth, {
|
|
881
|
+
kind: 'topic',
|
|
882
|
+
topic: body.topic,
|
|
883
|
+
payload: body.payload as Record<string, unknown>,
|
|
884
|
+
});
|
|
885
|
+
const topic = asNonEmptyString(hookInput.topic);
|
|
886
|
+
const payload = asPushPayload(hookInput.payload);
|
|
887
|
+
if (!topic || !payload) {
|
|
888
|
+
return c.json({ code: 400, message: 'beforeSend must return topic and payload' }, 400);
|
|
889
|
+
}
|
|
890
|
+
const topicInput: PushSendInput = { kind: 'topic', topic, payload: payload as Record<string, unknown> };
|
|
891
|
+
const result = await provider.sendToTopic(topic, payload);
|
|
892
|
+
const topicLogEntry: PushLogEntry = {
|
|
893
|
+
sentAt: new Date().toISOString(),
|
|
894
|
+
userId: `topic:${topic}`,
|
|
895
|
+
platform: 'topic',
|
|
896
|
+
status: result.success ? 'sent' : 'failed',
|
|
897
|
+
collapseId: payload.collapseId,
|
|
898
|
+
error: result.error,
|
|
899
|
+
runId: extractPushTraceValue(payload, 'runId'),
|
|
900
|
+
probeId: extractPushTraceValue(payload, 'probeId'),
|
|
901
|
+
title: payload.title,
|
|
902
|
+
body: payload.body,
|
|
903
|
+
topic,
|
|
904
|
+
};
|
|
905
|
+
await storePushLog(c.env.KV, `topic:${topic}`, topicLogEntry);
|
|
906
|
+
await mirrorPushLog(c.env, topicLogEntry);
|
|
907
|
+
runAfterSendHook(c, auth, topicInput, { raw: result });
|
|
908
|
+
return c.json(result);
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
// ─── POST /broadcast — Broadcast to all devices via /topics/all (Service Key) ───
|
|
912
|
+
|
|
913
|
+
const pushBroadcast = createRoute({
|
|
914
|
+
operationId: 'pushBroadcast',
|
|
915
|
+
method: 'post',
|
|
916
|
+
path: '/broadcast',
|
|
917
|
+
tags: ['admin'],
|
|
918
|
+
summary: 'Broadcast push to all devices',
|
|
919
|
+
request: {
|
|
920
|
+
body: { content: { 'application/json': { schema: z.object({
|
|
921
|
+
payload: z.record(z.string(), z.unknown()),
|
|
922
|
+
}) } }, required: true },
|
|
923
|
+
},
|
|
924
|
+
responses: {
|
|
925
|
+
200: { description: 'Broadcast result', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
926
|
+
400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
927
|
+
401: { description: 'Unauthorized', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
928
|
+
403: { description: 'Forbidden', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
929
|
+
503: { description: 'Push not configured', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
930
|
+
},
|
|
931
|
+
});
|
|
932
|
+
|
|
933
|
+
pushRoute.openapi(pushBroadcast, async (c) => {
|
|
934
|
+
const config = parseConfig(c.env);
|
|
935
|
+
const auth = (c.get('auth' as never) as AuthContext | null | undefined) ?? null;
|
|
936
|
+
|
|
937
|
+
const { result: skResult } = validateKey(
|
|
938
|
+
c.req.header('X-EdgeBase-Service-Key'),
|
|
939
|
+
'push:notification:*:send',
|
|
940
|
+
config,
|
|
941
|
+
c.env,
|
|
942
|
+
undefined,
|
|
943
|
+
buildConstraintCtx(c.env, c.req),
|
|
944
|
+
);
|
|
945
|
+
if (skResult === 'missing') {
|
|
946
|
+
return c.json({ code: 403, message: 'Service Key required for push send' }, 403);
|
|
947
|
+
}
|
|
948
|
+
if (skResult === 'invalid') {
|
|
949
|
+
return c.json({ code: 401, message: 'Unauthorized. Invalid Service Key.' }, 401);
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
const provider = createPushProvider(config.push, c.env);
|
|
953
|
+
if (!provider) {
|
|
954
|
+
return c.json({ code: 503, message: 'Push notifications are not configured. Add push.fcm config with FCM credentials.' }, 503);
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
let body: { payload?: PushPayload };
|
|
958
|
+
try {
|
|
959
|
+
body = await c.req.json();
|
|
960
|
+
} catch {
|
|
961
|
+
return c.json({ code: 400, message: 'Invalid JSON body' }, 400);
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
if (!body.payload || typeof body.payload !== 'object') {
|
|
965
|
+
return c.json({ code: 400, message: 'payload is required' }, 400);
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
const hookInput = await runBeforeSendHook(c, auth, {
|
|
969
|
+
kind: 'broadcast',
|
|
970
|
+
payload: body.payload as Record<string, unknown>,
|
|
971
|
+
});
|
|
972
|
+
const payload = asPushPayload(hookInput.payload);
|
|
973
|
+
if (!payload) {
|
|
974
|
+
return c.json({ code: 400, message: 'beforeSend must return payload' }, 400);
|
|
975
|
+
}
|
|
976
|
+
const broadcastInput: PushSendInput = { kind: 'broadcast', payload: payload as Record<string, unknown> };
|
|
977
|
+
const result = await provider.broadcast(payload);
|
|
978
|
+
const broadcastLogEntry: PushLogEntry = {
|
|
979
|
+
sentAt: new Date().toISOString(),
|
|
980
|
+
userId: 'broadcast:all',
|
|
981
|
+
platform: 'broadcast',
|
|
982
|
+
status: result.success ? 'sent' : 'failed',
|
|
983
|
+
collapseId: payload.collapseId,
|
|
984
|
+
error: result.error,
|
|
985
|
+
runId: extractPushTraceValue(payload, 'runId'),
|
|
986
|
+
probeId: extractPushTraceValue(payload, 'probeId'),
|
|
987
|
+
title: payload.title,
|
|
988
|
+
body: payload.body,
|
|
989
|
+
topic: 'all',
|
|
990
|
+
};
|
|
991
|
+
await storePushLog(c.env.KV, 'broadcast:all', broadcastLogEntry);
|
|
992
|
+
await mirrorPushLog(c.env, broadcastLogEntry);
|
|
993
|
+
runAfterSendHook(c, auth, broadcastInput, { raw: result });
|
|
994
|
+
return c.json(result);
|
|
995
|
+
});
|
|
996
|
+
|
|
997
|
+
// ─── POST /topic/subscribe — Subscribe user's tokens to FCM topic (JWT, web용) ───
|
|
998
|
+
|
|
999
|
+
const pushTopicSubscribe = createRoute({
|
|
1000
|
+
operationId: 'pushTopicSubscribe',
|
|
1001
|
+
method: 'post',
|
|
1002
|
+
path: '/topic/subscribe',
|
|
1003
|
+
tags: ['client'],
|
|
1004
|
+
summary: 'Subscribe token to topic',
|
|
1005
|
+
request: {
|
|
1006
|
+
body: { content: { 'application/json': { schema: z.object({
|
|
1007
|
+
topic: z.string(),
|
|
1008
|
+
}) } }, required: true },
|
|
1009
|
+
},
|
|
1010
|
+
responses: {
|
|
1011
|
+
200: { description: 'Subscribed', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
1012
|
+
400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
1013
|
+
401: { description: 'Unauthorized', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
1014
|
+
503: { description: 'Push not configured', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
1015
|
+
},
|
|
1016
|
+
});
|
|
1017
|
+
|
|
1018
|
+
pushRoute.openapi(pushTopicSubscribe, async (c) => {
|
|
1019
|
+
const auth = c.get('auth' as never) as { id: string } | null | undefined;
|
|
1020
|
+
if (!auth?.id) {
|
|
1021
|
+
return c.json({ code: 401, message: 'Authentication required' }, 401);
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
const config = parseConfig(c.env);
|
|
1025
|
+
const provider = createPushProvider(config.push, c.env);
|
|
1026
|
+
if (!provider) {
|
|
1027
|
+
return c.json({ code: 503, message: 'Push notifications are not configured.' }, 503);
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
let body: { topic?: string };
|
|
1031
|
+
try {
|
|
1032
|
+
body = await c.req.json();
|
|
1033
|
+
} catch {
|
|
1034
|
+
return c.json({ code: 400, message: 'Invalid JSON body' }, 400);
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
if (!body.topic || typeof body.topic !== 'string') {
|
|
1038
|
+
return c.json({ code: 400, message: 'topic is required' }, 400);
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
// Get user's devices and subscribe all tokens to the topic
|
|
1042
|
+
const devices = await getDevicesForUser(await getPushTokenStore(c), auth.id);
|
|
1043
|
+
if (devices.length === 0) {
|
|
1044
|
+
// Topic subscription is a user-level operation — succeeds even with no devices registered.
|
|
1045
|
+
// Devices will be subscribed to the topic when they register later.
|
|
1046
|
+
return c.json({ ok: true, subscribedDevices: 0 }, 200);
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
const results = await Promise.allSettled(
|
|
1050
|
+
devices.map(d => provider.subscribeTokenToTopic(d.token, body.topic!)),
|
|
1051
|
+
);
|
|
1052
|
+
|
|
1053
|
+
const succeeded = results.filter(r => r.status === 'fulfilled' && r.value.success).length;
|
|
1054
|
+
const failedCount = results.length - succeeded;
|
|
1055
|
+
|
|
1056
|
+
return c.json({ ok: true, subscribed: succeeded, failed: failedCount });
|
|
1057
|
+
});
|
|
1058
|
+
|
|
1059
|
+
// ─── POST /topic/unsubscribe — Unsubscribe user's tokens from FCM topic (JWT, web용) ───
|
|
1060
|
+
|
|
1061
|
+
const pushTopicUnsubscribe = createRoute({
|
|
1062
|
+
operationId: 'pushTopicUnsubscribe',
|
|
1063
|
+
method: 'post',
|
|
1064
|
+
path: '/topic/unsubscribe',
|
|
1065
|
+
tags: ['client'],
|
|
1066
|
+
summary: 'Unsubscribe token from topic',
|
|
1067
|
+
request: {
|
|
1068
|
+
body: { content: { 'application/json': { schema: z.object({
|
|
1069
|
+
topic: z.string(),
|
|
1070
|
+
}) } }, required: true },
|
|
1071
|
+
},
|
|
1072
|
+
responses: {
|
|
1073
|
+
200: { description: 'Unsubscribed', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
1074
|
+
400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
1075
|
+
401: { description: 'Unauthorized', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
1076
|
+
503: { description: 'Push not configured', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
1077
|
+
},
|
|
1078
|
+
});
|
|
1079
|
+
|
|
1080
|
+
pushRoute.openapi(pushTopicUnsubscribe, async (c) => {
|
|
1081
|
+
const auth = c.get('auth' as never) as { id: string } | null | undefined;
|
|
1082
|
+
if (!auth?.id) {
|
|
1083
|
+
return c.json({ code: 401, message: 'Authentication required' }, 401);
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
const config = parseConfig(c.env);
|
|
1087
|
+
const provider = createPushProvider(config.push, c.env);
|
|
1088
|
+
if (!provider) {
|
|
1089
|
+
return c.json({ code: 503, message: 'Push notifications are not configured.' }, 503);
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
let body: { topic?: string };
|
|
1093
|
+
try {
|
|
1094
|
+
body = await c.req.json();
|
|
1095
|
+
} catch {
|
|
1096
|
+
return c.json({ code: 400, message: 'Invalid JSON body' }, 400);
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
if (!body.topic || typeof body.topic !== 'string') {
|
|
1100
|
+
return c.json({ code: 400, message: 'topic is required' }, 400);
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
const devices = await getDevicesForUser(await getPushTokenStore(c), auth.id);
|
|
1104
|
+
if (devices.length === 0) {
|
|
1105
|
+
// Topic unsubscription is a user-level operation — succeeds even with no devices registered.
|
|
1106
|
+
return c.json({ ok: true, subscribedDevices: 0 }, 200);
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
const results = await Promise.allSettled(
|
|
1110
|
+
devices.map(d => provider.unsubscribeTokenFromTopic(d.token, body.topic!)),
|
|
1111
|
+
);
|
|
1112
|
+
|
|
1113
|
+
const succeeded = results.filter(r => r.status === 'fulfilled' && r.value.success).length;
|
|
1114
|
+
const failedCount = results.length - succeeded;
|
|
1115
|
+
|
|
1116
|
+
return c.json({ ok: true, unsubscribed: succeeded, failed: failedCount });
|
|
1117
|
+
});
|
|
1118
|
+
|
|
1119
|
+
// ─── GET /logs — Query push send logs ───
|
|
1120
|
+
|
|
1121
|
+
const pushLogsRoute = createRoute({
|
|
1122
|
+
operationId: 'getPushLogs',
|
|
1123
|
+
method: 'get',
|
|
1124
|
+
path: '/logs',
|
|
1125
|
+
tags: ['admin'],
|
|
1126
|
+
summary: 'Get push notification logs',
|
|
1127
|
+
request: {
|
|
1128
|
+
query: z.object({
|
|
1129
|
+
userId: z.string().openapi({ description: 'User ID to query logs for' }),
|
|
1130
|
+
limit: z.string().optional().openapi({ description: 'Max items to return (default 50, max 100)' }),
|
|
1131
|
+
}),
|
|
1132
|
+
},
|
|
1133
|
+
responses: {
|
|
1134
|
+
200: { description: 'Push logs', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
1135
|
+
400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
1136
|
+
401: { description: 'Unauthorized', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
1137
|
+
403: { description: 'Forbidden', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
1138
|
+
},
|
|
1139
|
+
});
|
|
1140
|
+
|
|
1141
|
+
pushRoute.openapi(pushLogsRoute, async (c) => {
|
|
1142
|
+
const config = parseConfig(c.env);
|
|
1143
|
+
|
|
1144
|
+
const { result: skResult } = validateKey(
|
|
1145
|
+
c.req.header('X-EdgeBase-Service-Key'),
|
|
1146
|
+
'push:log:*:read',
|
|
1147
|
+
config,
|
|
1148
|
+
c.env,
|
|
1149
|
+
undefined,
|
|
1150
|
+
buildConstraintCtx(c.env, c.req),
|
|
1151
|
+
);
|
|
1152
|
+
if (skResult === 'missing') {
|
|
1153
|
+
return c.json({ code: 403, message: 'Service Key required for push logs' }, 403);
|
|
1154
|
+
}
|
|
1155
|
+
if (skResult === 'invalid') {
|
|
1156
|
+
return c.json({ code: 401, message: 'Unauthorized. Invalid Service Key.' }, 401);
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
const userId = c.req.query('userId');
|
|
1160
|
+
if (!userId) {
|
|
1161
|
+
return c.json({ code: 400, message: 'userId query parameter is required' }, 400);
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
const limitStr = c.req.query('limit');
|
|
1165
|
+
const limit = limitStr ? Math.min(parseInt(limitStr, 10) || 50, 100) : 50;
|
|
1166
|
+
|
|
1167
|
+
const logs = await getPushLogs(c.env.KV, userId, limit);
|
|
1168
|
+
return c.json({ items: logs });
|
|
1169
|
+
});
|
|
1170
|
+
|
|
1171
|
+
// ─── GET /tokens — Query device tokens for a user ───
|
|
1172
|
+
|
|
1173
|
+
const pushTokensRoute = createRoute({
|
|
1174
|
+
operationId: 'getPushTokens',
|
|
1175
|
+
method: 'get',
|
|
1176
|
+
path: '/tokens',
|
|
1177
|
+
tags: ['admin'],
|
|
1178
|
+
summary: 'Get registered push tokens',
|
|
1179
|
+
request: {
|
|
1180
|
+
query: z.object({
|
|
1181
|
+
userId: z.string().openapi({ description: 'User ID to query tokens for' }),
|
|
1182
|
+
}),
|
|
1183
|
+
},
|
|
1184
|
+
responses: {
|
|
1185
|
+
200: { description: 'Push tokens', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
1186
|
+
400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
1187
|
+
401: { description: 'Unauthorized', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
1188
|
+
403: { description: 'Forbidden', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
1189
|
+
},
|
|
1190
|
+
});
|
|
1191
|
+
|
|
1192
|
+
pushRoute.openapi(pushTokensRoute, async (c) => {
|
|
1193
|
+
const config = parseConfig(c.env);
|
|
1194
|
+
|
|
1195
|
+
const { result: skResult } = validateKey(
|
|
1196
|
+
c.req.header('X-EdgeBase-Service-Key'),
|
|
1197
|
+
'push:token:*:read',
|
|
1198
|
+
config,
|
|
1199
|
+
c.env,
|
|
1200
|
+
undefined,
|
|
1201
|
+
buildConstraintCtx(c.env, c.req),
|
|
1202
|
+
);
|
|
1203
|
+
if (skResult === 'missing') {
|
|
1204
|
+
return c.json({ code: 403, message: 'Service Key required for push tokens' }, 403);
|
|
1205
|
+
}
|
|
1206
|
+
if (skResult === 'invalid') {
|
|
1207
|
+
return c.json({ code: 401, message: 'Unauthorized. Invalid Service Key.' }, 401);
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
const userId = c.req.query('userId');
|
|
1211
|
+
if (!userId) {
|
|
1212
|
+
return c.json({ code: 400, message: 'userId query parameter is required' }, 400);
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
const devices = await getDevicesForUser(await getPushTokenStore(c), userId);
|
|
1216
|
+
return c.json({ items: devices });
|
|
1217
|
+
});
|
|
1218
|
+
|
|
1219
|
+
// ─── PUT /tokens — Upsert a device token (Admin) ───
|
|
1220
|
+
|
|
1221
|
+
const putPushTokens = createRoute({
|
|
1222
|
+
operationId: 'putPushTokens',
|
|
1223
|
+
method: 'put',
|
|
1224
|
+
path: '/tokens',
|
|
1225
|
+
tags: ['admin'],
|
|
1226
|
+
summary: 'Upsert a device token',
|
|
1227
|
+
request: {
|
|
1228
|
+
body: { content: { 'application/json': { schema: z.object({
|
|
1229
|
+
userId: z.string(),
|
|
1230
|
+
deviceId: z.string(),
|
|
1231
|
+
token: z.string(),
|
|
1232
|
+
platform: z.string(),
|
|
1233
|
+
deviceInfo: z.object({
|
|
1234
|
+
name: z.string().optional(),
|
|
1235
|
+
osVersion: z.string().optional(),
|
|
1236
|
+
appVersion: z.string().optional(),
|
|
1237
|
+
locale: z.string().optional(),
|
|
1238
|
+
}).optional(),
|
|
1239
|
+
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
1240
|
+
}) } }, required: true },
|
|
1241
|
+
},
|
|
1242
|
+
responses: {
|
|
1243
|
+
200: { description: 'Token upserted', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
1244
|
+
400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
1245
|
+
401: { description: 'Unauthorized', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
1246
|
+
403: { description: 'Forbidden', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
1247
|
+
},
|
|
1248
|
+
});
|
|
1249
|
+
|
|
1250
|
+
pushRoute.openapi(putPushTokens, async (c) => {
|
|
1251
|
+
const config = parseConfig(c.env);
|
|
1252
|
+
|
|
1253
|
+
const { result: skResult } = validateKey(
|
|
1254
|
+
c.req.header('X-EdgeBase-Service-Key'),
|
|
1255
|
+
'push:token:*:write',
|
|
1256
|
+
config,
|
|
1257
|
+
c.env,
|
|
1258
|
+
undefined,
|
|
1259
|
+
buildConstraintCtx(c.env, c.req),
|
|
1260
|
+
);
|
|
1261
|
+
if (skResult === 'missing') {
|
|
1262
|
+
return c.json({ code: 403, message: 'Service Key required' }, 403);
|
|
1263
|
+
}
|
|
1264
|
+
if (skResult === 'invalid') {
|
|
1265
|
+
return c.json({ code: 401, message: 'Unauthorized. Invalid Service Key.' }, 401);
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
const body = await c.req.json<{
|
|
1269
|
+
userId?: string;
|
|
1270
|
+
deviceId?: string;
|
|
1271
|
+
token?: string;
|
|
1272
|
+
platform?: string;
|
|
1273
|
+
deviceInfo?: { name?: string; osVersion?: string; appVersion?: string; locale?: string };
|
|
1274
|
+
metadata?: Record<string, unknown>;
|
|
1275
|
+
}>();
|
|
1276
|
+
if (!body.userId || !body.deviceId || !body.token || !body.platform) {
|
|
1277
|
+
return c.json({ code: 400, message: 'userId, deviceId, token, and platform are required' }, 400);
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
if (metadataExceedsByteLimit(body.metadata)) {
|
|
1281
|
+
return c.json({ code: 400, message: `metadata exceeds ${MAX_METADATA_BYTES} byte limit` }, 400);
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
await registerToken(
|
|
1285
|
+
await getPushTokenStore(c),
|
|
1286
|
+
body.userId,
|
|
1287
|
+
body.deviceId,
|
|
1288
|
+
body.token,
|
|
1289
|
+
body.platform,
|
|
1290
|
+
body.deviceInfo,
|
|
1291
|
+
body.metadata,
|
|
1292
|
+
);
|
|
1293
|
+
await mirrorPushDeviceUpsert(c.env, {
|
|
1294
|
+
userId: body.userId,
|
|
1295
|
+
deviceId: body.deviceId,
|
|
1296
|
+
token: body.token,
|
|
1297
|
+
platform: body.platform,
|
|
1298
|
+
updatedAt: new Date().toISOString(),
|
|
1299
|
+
deviceInfo: body.deviceInfo,
|
|
1300
|
+
metadata: body.metadata,
|
|
1301
|
+
});
|
|
1302
|
+
|
|
1303
|
+
const provider = createPushProvider(config.push, c.env);
|
|
1304
|
+
if (provider) {
|
|
1305
|
+
await provider.subscribeTokenToTopic(body.token, 'all').catch(() => {});
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
return c.json({ ok: true });
|
|
1309
|
+
});
|
|
1310
|
+
|
|
1311
|
+
// ─── PATCH /tokens — Update metadata for a device token (Admin) ───
|
|
1312
|
+
|
|
1313
|
+
const patchPushTokens = createRoute({
|
|
1314
|
+
operationId: 'patchPushTokens',
|
|
1315
|
+
method: 'patch',
|
|
1316
|
+
path: '/tokens',
|
|
1317
|
+
tags: ['admin'],
|
|
1318
|
+
summary: 'Update device metadata',
|
|
1319
|
+
request: {
|
|
1320
|
+
body: { content: { 'application/json': { schema: z.object({
|
|
1321
|
+
userId: z.string(),
|
|
1322
|
+
deviceId: z.string(),
|
|
1323
|
+
metadata: z.record(z.string(), z.unknown()),
|
|
1324
|
+
}) } }, required: true },
|
|
1325
|
+
},
|
|
1326
|
+
responses: {
|
|
1327
|
+
200: { description: 'Metadata updated', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
1328
|
+
400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
1329
|
+
401: { description: 'Unauthorized', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
1330
|
+
403: { description: 'Forbidden', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
1331
|
+
404: { description: 'Device not found', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
1332
|
+
},
|
|
1333
|
+
});
|
|
1334
|
+
|
|
1335
|
+
pushRoute.openapi(patchPushTokens, async (c) => {
|
|
1336
|
+
const config = parseConfig(c.env);
|
|
1337
|
+
|
|
1338
|
+
const { result: skResult } = validateKey(
|
|
1339
|
+
c.req.header('X-EdgeBase-Service-Key'),
|
|
1340
|
+
'push:token:*:write',
|
|
1341
|
+
config,
|
|
1342
|
+
c.env,
|
|
1343
|
+
undefined,
|
|
1344
|
+
buildConstraintCtx(c.env, c.req),
|
|
1345
|
+
);
|
|
1346
|
+
if (skResult === 'missing') {
|
|
1347
|
+
return c.json({ code: 403, message: 'Service Key required' }, 403);
|
|
1348
|
+
}
|
|
1349
|
+
if (skResult === 'invalid') {
|
|
1350
|
+
return c.json({ code: 401, message: 'Unauthorized. Invalid Service Key.' }, 401);
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
const body = await c.req.json<{ userId?: string; deviceId?: string; metadata?: Record<string, unknown> }>();
|
|
1354
|
+
if (!body.userId || !body.deviceId) {
|
|
1355
|
+
return c.json({ code: 400, message: 'userId and deviceId are required' }, 400);
|
|
1356
|
+
}
|
|
1357
|
+
if (!body.metadata) {
|
|
1358
|
+
return c.json({ code: 400, message: 'metadata is required' }, 400);
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
if (metadataExceedsByteLimit(body.metadata)) {
|
|
1362
|
+
return c.json({ code: 400, message: `metadata exceeds ${MAX_METADATA_BYTES} byte limit` }, 400);
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
const pushStore = await getPushTokenStore(c);
|
|
1366
|
+
const devices = await getDevicesForUser(pushStore, body.userId);
|
|
1367
|
+
const device = devices.find(d => d.deviceId === body.deviceId);
|
|
1368
|
+
if (!device) {
|
|
1369
|
+
return c.json({ code: 404, message: 'Device not found' }, 404);
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
device.metadata = body.metadata;
|
|
1373
|
+
device.updatedAt = new Date().toISOString();
|
|
1374
|
+
await registerToken(
|
|
1375
|
+
pushStore,
|
|
1376
|
+
body.userId,
|
|
1377
|
+
device.deviceId,
|
|
1378
|
+
device.token,
|
|
1379
|
+
device.platform,
|
|
1380
|
+
device.deviceInfo,
|
|
1381
|
+
body.metadata,
|
|
1382
|
+
);
|
|
1383
|
+
await mirrorPushDeviceUpsert(c.env, {
|
|
1384
|
+
userId: body.userId,
|
|
1385
|
+
deviceId: device.deviceId,
|
|
1386
|
+
token: device.token,
|
|
1387
|
+
platform: device.platform,
|
|
1388
|
+
updatedAt: device.updatedAt,
|
|
1389
|
+
deviceInfo: device.deviceInfo,
|
|
1390
|
+
metadata: body.metadata,
|
|
1391
|
+
});
|
|
1392
|
+
|
|
1393
|
+
return c.json({ ok: true });
|
|
1394
|
+
});
|
|
1395
|
+
|
|
1396
|
+
// ─── Internal: Send to a single user's devices ───
|
|
1397
|
+
|
|
1398
|
+
async function sendToUser(
|
|
1399
|
+
store: KVNamespace | { kv: KVNamespace; authDb?: AuthDb | null },
|
|
1400
|
+
provider: FcmProvider,
|
|
1401
|
+
userId: string,
|
|
1402
|
+
payload: PushPayload,
|
|
1403
|
+
env: Env,
|
|
1404
|
+
): Promise<{ sent: number; failed: number; removed: number }> {
|
|
1405
|
+
const kv = 'kv' in store ? store.kv : store;
|
|
1406
|
+
const devices = await getDevicesForUser(store, userId);
|
|
1407
|
+
|
|
1408
|
+
if (devices.length === 0) {
|
|
1409
|
+
return { sent: 0, failed: 0, removed: 0 };
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
let sent = 0;
|
|
1413
|
+
let failed = 0;
|
|
1414
|
+
let removed = 0;
|
|
1415
|
+
|
|
1416
|
+
// Send to all devices in parallel via FCM
|
|
1417
|
+
const sendResults = await Promise.allSettled(
|
|
1418
|
+
devices.map(async (device): Promise<{ deviceId: string; platform: string; result: PushSendResult }> => {
|
|
1419
|
+
const result = await provider.send({
|
|
1420
|
+
token: device.token,
|
|
1421
|
+
platform: device.platform,
|
|
1422
|
+
payload,
|
|
1423
|
+
});
|
|
1424
|
+
return { deviceId: device.deviceId, platform: device.platform, result };
|
|
1425
|
+
}),
|
|
1426
|
+
);
|
|
1427
|
+
|
|
1428
|
+
// Process results
|
|
1429
|
+
for (const settled of sendResults) {
|
|
1430
|
+
if (settled.status === 'rejected') {
|
|
1431
|
+
failed++;
|
|
1432
|
+
continue;
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
const { deviceId, platform, result } = settled.value;
|
|
1436
|
+
|
|
1437
|
+
if (result.success) {
|
|
1438
|
+
sent++;
|
|
1439
|
+
} else if (result.remove) {
|
|
1440
|
+
removed++;
|
|
1441
|
+
await removeDeviceFromUser(store, userId, deviceId);
|
|
1442
|
+
await mirrorPushDeviceDelete(env, userId, deviceId);
|
|
1443
|
+
} else {
|
|
1444
|
+
failed++;
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
const logEntry: PushLogEntry = {
|
|
1448
|
+
sentAt: new Date().toISOString(),
|
|
1449
|
+
userId,
|
|
1450
|
+
platform,
|
|
1451
|
+
status: result.success ? 'sent' : (result.remove ? 'removed' : 'failed'),
|
|
1452
|
+
collapseId: payload.collapseId,
|
|
1453
|
+
error: result.error,
|
|
1454
|
+
runId: extractPushTraceValue(payload, 'runId'),
|
|
1455
|
+
probeId: extractPushTraceValue(payload, 'probeId'),
|
|
1456
|
+
title: payload.title,
|
|
1457
|
+
body: payload.body,
|
|
1458
|
+
target: deviceId,
|
|
1459
|
+
};
|
|
1460
|
+
await storePushLog(kv, userId, logEntry);
|
|
1461
|
+
await mirrorPushLog(env, logEntry);
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
return { sent, failed, removed };
|
|
1465
|
+
}
|