@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,949 @@
|
|
|
1
|
+
import { DurableObject } from 'cloudflare:workers';
|
|
2
|
+
import {
|
|
3
|
+
getTableAccess,
|
|
4
|
+
type EdgeBaseConfig,
|
|
5
|
+
} from '@edge-base/shared';
|
|
6
|
+
import { verifyAccessToken } from '../lib/jwt.js';
|
|
7
|
+
import { parseConfig as getGlobalConfig } from '../lib/do-router.js';
|
|
8
|
+
import { isDbLiveChannel } from '../lib/database-live-emitter.js';
|
|
9
|
+
import { resolveDbLiveAuthTimeoutMs } from '../lib/database-live-config.js';
|
|
10
|
+
|
|
11
|
+
interface DOEnv {
|
|
12
|
+
JWT_USER_SECRET?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface DatabaseLiveEvent {
|
|
16
|
+
type: 'added' | 'modified' | 'removed';
|
|
17
|
+
channel: string;
|
|
18
|
+
table: string;
|
|
19
|
+
docId: string;
|
|
20
|
+
data: Record<string, unknown> | null;
|
|
21
|
+
timestamp: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type DatabaseLiveFilterCondition = [
|
|
25
|
+
string,
|
|
26
|
+
'==' | '!=' | '<' | '<=' | '>' | '>=' | 'in' | 'contains' | 'contains-any' | 'not in',
|
|
27
|
+
unknown,
|
|
28
|
+
];
|
|
29
|
+
type FilterCondition = DatabaseLiveFilterCondition;
|
|
30
|
+
type FilterOperator = FilterCondition[1];
|
|
31
|
+
|
|
32
|
+
interface WSMeta {
|
|
33
|
+
authenticated: boolean;
|
|
34
|
+
userId?: string;
|
|
35
|
+
role?: string;
|
|
36
|
+
connectionId: string;
|
|
37
|
+
subscribedChannels: string[];
|
|
38
|
+
channelFilters: Map<string, FilterCondition[]>;
|
|
39
|
+
channelOrFilters: Map<string, FilterCondition[]>;
|
|
40
|
+
sdkVersion?: string;
|
|
41
|
+
supportsBatch: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const MAX_FILTER_CONDITIONS = 5;
|
|
45
|
+
const VALID_FILTER_OPERATORS = new Set<FilterOperator>([
|
|
46
|
+
'==',
|
|
47
|
+
'!=',
|
|
48
|
+
'<',
|
|
49
|
+
'<=',
|
|
50
|
+
'>',
|
|
51
|
+
'>=',
|
|
52
|
+
'in',
|
|
53
|
+
'contains',
|
|
54
|
+
'contains-any',
|
|
55
|
+
'not in',
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
59
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function normalizeFilterCondition(input: unknown): FilterCondition | null {
|
|
63
|
+
if (Array.isArray(input)) {
|
|
64
|
+
if (input.length !== 3) return null;
|
|
65
|
+
const [field, op, value] = input;
|
|
66
|
+
if (typeof field !== 'string' || typeof op !== 'string' || !VALID_FILTER_OPERATORS.has(op as FilterOperator)) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
return [field, op as FilterOperator, value];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!isRecord(input)) return null;
|
|
73
|
+
|
|
74
|
+
const field = input.field;
|
|
75
|
+
const op = input.op;
|
|
76
|
+
if (typeof field !== 'string' || typeof op !== 'string' || !VALID_FILTER_OPERATORS.has(op as FilterOperator)) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
return [field, op as FilterOperator, input.value];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function normalizeDatabaseLiveFilterPayload(
|
|
83
|
+
input: unknown,
|
|
84
|
+
label: string,
|
|
85
|
+
): { ok: true; value: FilterCondition[] | null | undefined } | { ok: false; message: string } {
|
|
86
|
+
if (input === undefined) {
|
|
87
|
+
return { ok: true, value: undefined };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (input === null) {
|
|
91
|
+
return { ok: true, value: null };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (Array.isArray(input)) {
|
|
95
|
+
if (input.length > MAX_FILTER_CONDITIONS) {
|
|
96
|
+
return {
|
|
97
|
+
ok: false,
|
|
98
|
+
message: `${label} must be an array with at most ${MAX_FILTER_CONDITIONS} conditions`,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
const normalized = input.map(normalizeFilterCondition);
|
|
102
|
+
if (normalized.some((condition) => condition === null)) {
|
|
103
|
+
return {
|
|
104
|
+
ok: false,
|
|
105
|
+
message: `${label} must contain [field, op, value] tuples or { field, op, value } objects`,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
return { ok: true, value: normalized as FilterCondition[] };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (isRecord(input)) {
|
|
112
|
+
const singleCondition = normalizeFilterCondition(input);
|
|
113
|
+
if (singleCondition) {
|
|
114
|
+
return { ok: true, value: [singleCondition] };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const entries = Object.entries(input);
|
|
118
|
+
if (entries.length > MAX_FILTER_CONDITIONS) {
|
|
119
|
+
return {
|
|
120
|
+
ok: false,
|
|
121
|
+
message: `${label} must define at most ${MAX_FILTER_CONDITIONS} equality conditions`,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
ok: true,
|
|
126
|
+
value: entries.map(([field, value]) => [field, '==', value] as FilterCondition),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
ok: false,
|
|
132
|
+
message: `${label} must be an array, a filter object, or an equality map`,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export class DatabaseLiveDO extends DurableObject<DOEnv> {
|
|
137
|
+
private config: EdgeBaseConfig;
|
|
138
|
+
private filterRecoveryNeeded = true;
|
|
139
|
+
private pendingAuth = new Map<string, ReturnType<typeof setTimeout>>();
|
|
140
|
+
private metaCache = new Map<WebSocket, WSMeta>();
|
|
141
|
+
|
|
142
|
+
constructor(ctx: DurableObjectState, env: DOEnv) {
|
|
143
|
+
super(ctx, env);
|
|
144
|
+
this.config = getGlobalConfig(env);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async fetch(request: Request): Promise<Response> {
|
|
148
|
+
const url = new URL(request.url);
|
|
149
|
+
|
|
150
|
+
if (url.pathname === '/internal/event') {
|
|
151
|
+
return this.handleInternalEvent(request);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (url.pathname === '/internal/batch-event') {
|
|
155
|
+
return this.handleInternalBatchEvent(request);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (url.pathname === '/internal/broadcast') {
|
|
159
|
+
return this.handleInternalBroadcast(request);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (url.pathname === '/internal/stats') {
|
|
163
|
+
return this.handleStats();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (request.headers.get('Upgrade') === 'websocket') {
|
|
167
|
+
return this.handleWebSocketUpgrade();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return Response.json({ error: 'Expected WebSocket or internal request' }, { status: 400 });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private handleWebSocketUpgrade(): Response {
|
|
174
|
+
const pair = new WebSocketPair();
|
|
175
|
+
const [client, server] = Object.values(pair);
|
|
176
|
+
|
|
177
|
+
const connectionId = crypto.randomUUID();
|
|
178
|
+
const meta: WSMeta = {
|
|
179
|
+
authenticated: false,
|
|
180
|
+
connectionId,
|
|
181
|
+
subscribedChannels: [],
|
|
182
|
+
channelFilters: new Map(),
|
|
183
|
+
channelOrFilters: new Map(),
|
|
184
|
+
supportsBatch: false,
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
this.ctx.acceptWebSocket(server, [connectionId]);
|
|
188
|
+
this.metaCache.set(server, meta);
|
|
189
|
+
|
|
190
|
+
const authTimeoutMs = resolveDbLiveAuthTimeoutMs(this.config);
|
|
191
|
+
const timer = setTimeout(() => {
|
|
192
|
+
const currentMeta = this.getWSMeta(server);
|
|
193
|
+
if (currentMeta && !currentMeta.authenticated) {
|
|
194
|
+
try {
|
|
195
|
+
server.send(JSON.stringify({
|
|
196
|
+
type: 'error',
|
|
197
|
+
code: 'AUTH_TIMEOUT',
|
|
198
|
+
message: `Authentication required within ${authTimeoutMs}ms`,
|
|
199
|
+
}));
|
|
200
|
+
} catch {
|
|
201
|
+
// Socket may already be closed.
|
|
202
|
+
}
|
|
203
|
+
try {
|
|
204
|
+
server.close(4001, 'Authentication timeout');
|
|
205
|
+
} catch {
|
|
206
|
+
// Ignore redundant close attempts.
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
this.pendingAuth.delete(connectionId);
|
|
210
|
+
}, authTimeoutMs);
|
|
211
|
+
this.pendingAuth.set(connectionId, timer);
|
|
212
|
+
|
|
213
|
+
return new Response(null, { status: 101, webSocket: client });
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
|
|
217
|
+
if (typeof message !== 'string') return;
|
|
218
|
+
|
|
219
|
+
let msg: Record<string, unknown>;
|
|
220
|
+
try {
|
|
221
|
+
msg = JSON.parse(message);
|
|
222
|
+
} catch {
|
|
223
|
+
ws.send(JSON.stringify({ type: 'error', code: 'INVALID_JSON', message: 'Invalid JSON' }));
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const meta = this.getWSMeta(ws);
|
|
228
|
+
if (!meta) return;
|
|
229
|
+
|
|
230
|
+
if (msg.type === 'auth') {
|
|
231
|
+
await this.handleAuth(ws, meta, msg.token as string, msg.sdkVersion as string | undefined);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (!meta.authenticated) {
|
|
236
|
+
ws.send(JSON.stringify({
|
|
237
|
+
type: 'error',
|
|
238
|
+
code: 'NOT_AUTHENTICATED',
|
|
239
|
+
message: 'Send auth message first',
|
|
240
|
+
}));
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (this.filterRecoveryNeeded) {
|
|
245
|
+
this.broadcastToAuthenticated({ type: 'FILTER_RESYNC' });
|
|
246
|
+
this.filterRecoveryNeeded = false;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
switch (msg.type) {
|
|
250
|
+
case 'subscribe':
|
|
251
|
+
await this.handleSubscribe(
|
|
252
|
+
ws,
|
|
253
|
+
meta,
|
|
254
|
+
msg.channel as string,
|
|
255
|
+
msg.filters as FilterCondition[] | undefined,
|
|
256
|
+
msg.orFilters as FilterCondition[] | undefined,
|
|
257
|
+
);
|
|
258
|
+
break;
|
|
259
|
+
case 'unsubscribe':
|
|
260
|
+
this.handleUnsubscribe(ws, meta, msg.channel as string);
|
|
261
|
+
break;
|
|
262
|
+
case 'update_filters':
|
|
263
|
+
this.handleUpdateFilters(
|
|
264
|
+
ws,
|
|
265
|
+
meta,
|
|
266
|
+
msg.channel as string,
|
|
267
|
+
msg.filters as FilterCondition[] | null,
|
|
268
|
+
msg.orFilters as FilterCondition[] | null,
|
|
269
|
+
);
|
|
270
|
+
break;
|
|
271
|
+
case 'ping':
|
|
272
|
+
ws.send(JSON.stringify({ type: 'pong' }));
|
|
273
|
+
break;
|
|
274
|
+
default:
|
|
275
|
+
ws.send(JSON.stringify({ type: 'error', code: 'UNKNOWN_TYPE', message: `Unknown message type: ${msg.type}` }));
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
webSocketClose(ws: WebSocket): void {
|
|
280
|
+
const meta = this.getWSMeta(ws);
|
|
281
|
+
if (!meta) return;
|
|
282
|
+
|
|
283
|
+
const timer = this.pendingAuth.get(meta.connectionId);
|
|
284
|
+
if (timer) {
|
|
285
|
+
clearTimeout(timer);
|
|
286
|
+
this.pendingAuth.delete(meta.connectionId);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
this.metaCache.delete(ws);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
webSocketError(ws: WebSocket): void {
|
|
293
|
+
this.webSocketClose(ws);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
private async handleAuth(
|
|
297
|
+
ws: WebSocket,
|
|
298
|
+
meta: WSMeta,
|
|
299
|
+
token: string,
|
|
300
|
+
sdkVersion?: string,
|
|
301
|
+
): Promise<void> {
|
|
302
|
+
const isReAuth = meta.authenticated;
|
|
303
|
+
|
|
304
|
+
if (!token) {
|
|
305
|
+
ws.send(JSON.stringify({ type: 'error', code: 'AUTH_FAILED', message: 'Token required' }));
|
|
306
|
+
ws.close(4002, 'Authentication failed');
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const secret = this.env.JWT_USER_SECRET;
|
|
311
|
+
if (!secret) {
|
|
312
|
+
ws.send(JSON.stringify({ type: 'error', code: 'SERVER_ERROR', message: 'JWT secret not configured' }));
|
|
313
|
+
ws.close(4003, 'Server configuration error');
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
try {
|
|
318
|
+
const verified = await verifyAccessToken(token, secret);
|
|
319
|
+
meta.authenticated = true;
|
|
320
|
+
meta.userId = verified.sub;
|
|
321
|
+
meta.role = (verified as Record<string, unknown>).role as string | undefined;
|
|
322
|
+
if (sdkVersion) {
|
|
323
|
+
meta.sdkVersion = sdkVersion;
|
|
324
|
+
meta.supportsBatch = true;
|
|
325
|
+
}
|
|
326
|
+
this.setWSMeta(ws, meta);
|
|
327
|
+
|
|
328
|
+
const timer = this.pendingAuth.get(meta.connectionId);
|
|
329
|
+
if (timer) {
|
|
330
|
+
clearTimeout(timer);
|
|
331
|
+
this.pendingAuth.delete(meta.connectionId);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (isReAuth) {
|
|
335
|
+
const revokedChannels: string[] = [];
|
|
336
|
+
for (const channel of [...meta.subscribedChannels]) {
|
|
337
|
+
const allowed = await this.evaluateChannelAccess(channel, meta);
|
|
338
|
+
if (!allowed) {
|
|
339
|
+
meta.subscribedChannels = meta.subscribedChannels.filter((value) => value !== channel);
|
|
340
|
+
meta.channelFilters.delete(channel);
|
|
341
|
+
meta.channelOrFilters.delete(channel);
|
|
342
|
+
revokedChannels.push(channel);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
this.setWSMeta(ws, meta);
|
|
346
|
+
ws.send(JSON.stringify({
|
|
347
|
+
type: 'auth_refreshed',
|
|
348
|
+
userId: verified.sub,
|
|
349
|
+
revokedChannels,
|
|
350
|
+
}));
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
ws.send(JSON.stringify({
|
|
355
|
+
type: 'auth_success',
|
|
356
|
+
userId: verified.sub,
|
|
357
|
+
}));
|
|
358
|
+
} catch {
|
|
359
|
+
if (isReAuth) {
|
|
360
|
+
ws.send(JSON.stringify({
|
|
361
|
+
type: 'error',
|
|
362
|
+
code: 'AUTH_REFRESH_FAILED',
|
|
363
|
+
message: 'Token refresh failed — existing auth preserved',
|
|
364
|
+
}));
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
ws.send(JSON.stringify({ type: 'error', code: 'AUTH_FAILED', message: 'Invalid or expired token' }));
|
|
369
|
+
ws.close(4002, 'Authentication failed');
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
private async handleSubscribe(
|
|
374
|
+
ws: WebSocket,
|
|
375
|
+
meta: WSMeta,
|
|
376
|
+
channel: string,
|
|
377
|
+
filters?: unknown,
|
|
378
|
+
orFilters?: unknown,
|
|
379
|
+
): Promise<void> {
|
|
380
|
+
if (!channel) {
|
|
381
|
+
ws.send(JSON.stringify({ type: 'error', code: 'INVALID_CHANNEL', message: 'Channel name required' }));
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (!isDbLiveChannel(channel)) {
|
|
386
|
+
ws.send(JSON.stringify({
|
|
387
|
+
type: 'error',
|
|
388
|
+
code: 'INVALID_CHANNEL',
|
|
389
|
+
message: `Database live only supports DB channels: ${channel}`,
|
|
390
|
+
}));
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const normalizedFilters = normalizeDatabaseLiveFilterPayload(filters, 'Filters');
|
|
395
|
+
if (!normalizedFilters.ok) {
|
|
396
|
+
ws.send(JSON.stringify({
|
|
397
|
+
type: 'error',
|
|
398
|
+
code: 'INVALID_FILTERS',
|
|
399
|
+
message: normalizedFilters.message,
|
|
400
|
+
}));
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const normalizedOrFilters = normalizeDatabaseLiveFilterPayload(orFilters, 'OR filters');
|
|
405
|
+
if (!normalizedOrFilters.ok) {
|
|
406
|
+
ws.send(JSON.stringify({
|
|
407
|
+
type: 'error',
|
|
408
|
+
code: 'INVALID_FILTERS',
|
|
409
|
+
message: normalizedOrFilters.message,
|
|
410
|
+
}));
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (!(await this.evaluateChannelAccess(channel, meta))) {
|
|
415
|
+
ws.send(JSON.stringify({
|
|
416
|
+
type: 'error',
|
|
417
|
+
code: 'CHANNEL_ACCESS_DENIED',
|
|
418
|
+
message: `Access denied to channel: ${channel}`,
|
|
419
|
+
}));
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (!meta.subscribedChannels.includes(channel)) {
|
|
424
|
+
meta.subscribedChannels.push(channel);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (normalizedFilters.value && normalizedFilters.value.length > 0) {
|
|
428
|
+
meta.channelFilters.set(channel, normalizedFilters.value);
|
|
429
|
+
} else {
|
|
430
|
+
meta.channelFilters.delete(channel);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (normalizedOrFilters.value && normalizedOrFilters.value.length > 0) {
|
|
434
|
+
meta.channelOrFilters.set(channel, normalizedOrFilters.value);
|
|
435
|
+
} else {
|
|
436
|
+
meta.channelOrFilters.delete(channel);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
this.setWSMeta(ws, meta);
|
|
440
|
+
ws.send(JSON.stringify({
|
|
441
|
+
type: 'subscribed',
|
|
442
|
+
channel,
|
|
443
|
+
serverFilter: meta.channelFilters.has(channel) || meta.channelOrFilters.has(channel),
|
|
444
|
+
}));
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
private handleUnsubscribe(ws: WebSocket, meta: WSMeta, channel: string): void {
|
|
448
|
+
meta.subscribedChannels = meta.subscribedChannels.filter((value) => value !== channel);
|
|
449
|
+
meta.channelFilters.delete(channel);
|
|
450
|
+
meta.channelOrFilters.delete(channel);
|
|
451
|
+
this.setWSMeta(ws, meta);
|
|
452
|
+
ws.send(JSON.stringify({ type: 'unsubscribed', channel }));
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
private handleUpdateFilters(
|
|
456
|
+
ws: WebSocket,
|
|
457
|
+
meta: WSMeta,
|
|
458
|
+
channel: string,
|
|
459
|
+
filters: unknown,
|
|
460
|
+
orFilters?: unknown,
|
|
461
|
+
): void {
|
|
462
|
+
if (!channel || !meta.subscribedChannels.includes(channel)) {
|
|
463
|
+
ws.send(JSON.stringify({ type: 'error', code: 'NOT_SUBSCRIBED', message: `Not subscribed to channel: ${channel}` }));
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (filters === null) {
|
|
468
|
+
meta.channelFilters.delete(channel);
|
|
469
|
+
} else if (filters !== undefined) {
|
|
470
|
+
const normalizedFilters = normalizeDatabaseLiveFilterPayload(filters, 'Filters');
|
|
471
|
+
if (!normalizedFilters.ok || normalizedFilters.value === null) {
|
|
472
|
+
ws.send(JSON.stringify({
|
|
473
|
+
type: 'error',
|
|
474
|
+
code: 'INVALID_FILTERS',
|
|
475
|
+
message: normalizedFilters.ok ? 'Filters update must not be null.' : normalizedFilters.message,
|
|
476
|
+
}));
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
const nextFilters = normalizedFilters.value ?? [];
|
|
480
|
+
if (nextFilters.length > 0) {
|
|
481
|
+
meta.channelFilters.set(channel, nextFilters);
|
|
482
|
+
} else {
|
|
483
|
+
meta.channelFilters.delete(channel);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
if (orFilters === null) {
|
|
488
|
+
meta.channelOrFilters.delete(channel);
|
|
489
|
+
} else if (orFilters !== undefined) {
|
|
490
|
+
const normalizedOrFilters = normalizeDatabaseLiveFilterPayload(orFilters, 'OR filters');
|
|
491
|
+
if (!normalizedOrFilters.ok || normalizedOrFilters.value === null) {
|
|
492
|
+
ws.send(JSON.stringify({
|
|
493
|
+
type: 'error',
|
|
494
|
+
code: 'INVALID_FILTERS',
|
|
495
|
+
message: normalizedOrFilters.ok ? 'OR filters update must not be null.' : normalizedOrFilters.message,
|
|
496
|
+
}));
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
const nextOrFilters = normalizedOrFilters.value ?? [];
|
|
500
|
+
if (nextOrFilters.length > 0) {
|
|
501
|
+
meta.channelOrFilters.set(channel, nextOrFilters);
|
|
502
|
+
} else {
|
|
503
|
+
meta.channelOrFilters.delete(channel);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
this.setWSMeta(ws, meta);
|
|
508
|
+
ws.send(JSON.stringify({
|
|
509
|
+
type: 'filters_updated',
|
|
510
|
+
channel,
|
|
511
|
+
serverFilter: meta.channelFilters.has(channel) || meta.channelOrFilters.has(channel),
|
|
512
|
+
}));
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
private async handleInternalEvent(request: Request): Promise<Response> {
|
|
516
|
+
let event: DatabaseLiveEvent;
|
|
517
|
+
try {
|
|
518
|
+
event = await request.json() as DatabaseLiveEvent;
|
|
519
|
+
} catch {
|
|
520
|
+
return Response.json({ error: 'Invalid event body' }, { status: 400 });
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
await this.broadcastWithFilters({
|
|
524
|
+
type: 'db_change',
|
|
525
|
+
channel: event.channel,
|
|
526
|
+
changeType: event.type,
|
|
527
|
+
table: event.table,
|
|
528
|
+
docId: event.docId,
|
|
529
|
+
data: event.data,
|
|
530
|
+
timestamp: event.timestamp,
|
|
531
|
+
}, event.data);
|
|
532
|
+
|
|
533
|
+
return Response.json({ ok: true });
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
private async handleInternalBatchEvent(request: Request): Promise<Response> {
|
|
537
|
+
let batch: {
|
|
538
|
+
type: 'batch_changes';
|
|
539
|
+
channel: string;
|
|
540
|
+
table: string;
|
|
541
|
+
changes: Array<{ type: string; docId: string; data: Record<string, unknown> | null; timestamp: string }>;
|
|
542
|
+
total: number;
|
|
543
|
+
};
|
|
544
|
+
try {
|
|
545
|
+
batch = await request.json() as typeof batch;
|
|
546
|
+
} catch {
|
|
547
|
+
return Response.json({ error: 'Invalid batch event body' }, { status: 400 });
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const sockets = this.ctx.getWebSockets();
|
|
551
|
+
const batchMsg = {
|
|
552
|
+
type: 'batch_changes' as const,
|
|
553
|
+
channel: batch.channel,
|
|
554
|
+
changes: batch.changes.map((change) => ({
|
|
555
|
+
event: change.type,
|
|
556
|
+
docId: change.docId,
|
|
557
|
+
data: change.data,
|
|
558
|
+
timestamp: change.timestamp,
|
|
559
|
+
})),
|
|
560
|
+
total: batch.total,
|
|
561
|
+
};
|
|
562
|
+
const batchMsgStr = JSON.stringify(batchMsg);
|
|
563
|
+
|
|
564
|
+
for (const ws of sockets) {
|
|
565
|
+
const meta = this.getWSMeta(ws);
|
|
566
|
+
if (!meta?.authenticated) continue;
|
|
567
|
+
if (!meta.subscribedChannels.includes(batch.channel)) continue;
|
|
568
|
+
|
|
569
|
+
if (meta.supportsBatch) {
|
|
570
|
+
const filteredChanges: typeof batchMsg.changes = [];
|
|
571
|
+
for (const change of batchMsg.changes) {
|
|
572
|
+
const canRead = await this.evaluateRowReadAccess(batch.table, meta, change.data as Record<string, unknown> | null);
|
|
573
|
+
if (!canRead) continue;
|
|
574
|
+
|
|
575
|
+
let shouldSend = true;
|
|
576
|
+
if (change.data && (meta.channelFilters.size > 0 || meta.channelOrFilters.size > 0)) {
|
|
577
|
+
const filters = meta.channelFilters.get(batch.channel) || [];
|
|
578
|
+
const orFilters = meta.channelOrFilters.get(batch.channel) || [];
|
|
579
|
+
if (filters.length > 0 || orFilters.length > 0) {
|
|
580
|
+
shouldSend = evaluateDatabaseLiveFilters(change.data as Record<string, unknown>, filters, orFilters);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
if (shouldSend) {
|
|
585
|
+
filteredChanges.push(change);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
if (filteredChanges.length > 0) {
|
|
590
|
+
const payload = filteredChanges.length === batchMsg.changes.length
|
|
591
|
+
? batchMsgStr
|
|
592
|
+
: JSON.stringify({
|
|
593
|
+
...batchMsg,
|
|
594
|
+
changes: filteredChanges,
|
|
595
|
+
total: filteredChanges.length,
|
|
596
|
+
});
|
|
597
|
+
try {
|
|
598
|
+
ws.send(payload);
|
|
599
|
+
} catch {
|
|
600
|
+
// Socket may be closing.
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
for (const change of batch.changes) {
|
|
607
|
+
const canRead = await this.evaluateRowReadAccess(batch.table, meta, change.data);
|
|
608
|
+
if (!canRead) continue;
|
|
609
|
+
|
|
610
|
+
let shouldSend = true;
|
|
611
|
+
if (change.data && (meta.channelFilters.size > 0 || meta.channelOrFilters.size > 0)) {
|
|
612
|
+
const filters = meta.channelFilters.get(batch.channel) || [];
|
|
613
|
+
const orFilters = meta.channelOrFilters.get(batch.channel) || [];
|
|
614
|
+
if (filters.length > 0 || orFilters.length > 0) {
|
|
615
|
+
shouldSend = evaluateDatabaseLiveFilters(change.data as Record<string, unknown>, filters, orFilters);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
if (!shouldSend) continue;
|
|
620
|
+
|
|
621
|
+
try {
|
|
622
|
+
ws.send(JSON.stringify({
|
|
623
|
+
type: 'db_change',
|
|
624
|
+
channel: batch.channel,
|
|
625
|
+
changeType: change.type,
|
|
626
|
+
table: batch.table,
|
|
627
|
+
docId: change.docId,
|
|
628
|
+
data: change.data,
|
|
629
|
+
timestamp: change.timestamp,
|
|
630
|
+
}));
|
|
631
|
+
} catch {
|
|
632
|
+
// Socket may be closing.
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
return Response.json({ ok: true });
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Handle server-side broadcast from HookCtx.databaseLive.broadcast().
|
|
642
|
+
* Sends a custom broadcast_event to all clients subscribed to matching DB channels.
|
|
643
|
+
*/
|
|
644
|
+
private async handleInternalBroadcast(request: Request): Promise<Response> {
|
|
645
|
+
let body: { channel?: string; event?: string; payload?: Record<string, unknown> };
|
|
646
|
+
try {
|
|
647
|
+
body = await request.json() as typeof body;
|
|
648
|
+
} catch {
|
|
649
|
+
return Response.json({ error: 'Invalid broadcast body' }, { status: 400 });
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const { channel, event, payload } = body;
|
|
653
|
+
if (!channel || typeof channel !== 'string') {
|
|
654
|
+
return Response.json({ error: 'channel is required' }, { status: 400 });
|
|
655
|
+
}
|
|
656
|
+
if (!event || typeof event !== 'string') {
|
|
657
|
+
return Response.json({ error: 'event is required' }, { status: 400 });
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
const msg = JSON.stringify({
|
|
661
|
+
type: 'broadcast_event',
|
|
662
|
+
channel,
|
|
663
|
+
event,
|
|
664
|
+
payload: payload ?? {},
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
const sockets = this.ctx.getWebSockets();
|
|
668
|
+
for (const ws of sockets) {
|
|
669
|
+
const meta = this.getWSMeta(ws);
|
|
670
|
+
if (!meta?.authenticated) continue;
|
|
671
|
+
// Deliver to clients subscribed to the target channel or any parent channel
|
|
672
|
+
if (!meta.subscribedChannels.some(sub => channel === sub || channel.startsWith(`${sub}:`))) {
|
|
673
|
+
continue;
|
|
674
|
+
}
|
|
675
|
+
try {
|
|
676
|
+
ws.send(msg);
|
|
677
|
+
} catch {
|
|
678
|
+
// Socket may be closing.
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
return Response.json({ ok: true });
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
private handleStats(): Response {
|
|
686
|
+
const sockets = this.ctx.getWebSockets();
|
|
687
|
+
const channelMap = new Map<string, number>();
|
|
688
|
+
let authenticated = 0;
|
|
689
|
+
|
|
690
|
+
for (const ws of sockets) {
|
|
691
|
+
const meta = this.getWSMeta(ws);
|
|
692
|
+
if (!meta) continue;
|
|
693
|
+
if (meta.authenticated) authenticated++;
|
|
694
|
+
for (const channel of meta.subscribedChannels) {
|
|
695
|
+
channelMap.set(channel, (channelMap.get(channel) ?? 0) + 1);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
const channelDetails = Array.from(channelMap.entries())
|
|
700
|
+
.map(([channel, subscribers]) => ({ channel, subscribers }))
|
|
701
|
+
.sort((a, b) => b.subscribers - a.subscribers);
|
|
702
|
+
|
|
703
|
+
return Response.json({
|
|
704
|
+
subsystem: 'database-live',
|
|
705
|
+
activeConnections: sockets.length,
|
|
706
|
+
authenticatedConnections: authenticated,
|
|
707
|
+
channels: channelMap.size,
|
|
708
|
+
channelDetails,
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
private async broadcastWithFilters(
|
|
713
|
+
msg: Record<string, unknown>,
|
|
714
|
+
eventData: Record<string, unknown> | null,
|
|
715
|
+
): Promise<void> {
|
|
716
|
+
const msgStr = JSON.stringify(msg);
|
|
717
|
+
const sockets = this.ctx.getWebSockets();
|
|
718
|
+
const msgChannel = typeof msg.channel === 'string' ? msg.channel : null;
|
|
719
|
+
const tableName = typeof msg.table === 'string' ? msg.table : '';
|
|
720
|
+
|
|
721
|
+
for (const ws of sockets) {
|
|
722
|
+
const meta = this.getWSMeta(ws);
|
|
723
|
+
if (!meta?.authenticated) continue;
|
|
724
|
+
if (msgChannel && !meta.subscribedChannels.includes(msgChannel)) continue;
|
|
725
|
+
|
|
726
|
+
const canRead = await this.evaluateRowReadAccess(tableName, meta, eventData);
|
|
727
|
+
if (!canRead) continue;
|
|
728
|
+
|
|
729
|
+
let shouldSend = true;
|
|
730
|
+
if (eventData && (meta.channelFilters.size > 0 || meta.channelOrFilters.size > 0) && msgChannel) {
|
|
731
|
+
const filters = meta.channelFilters.get(msgChannel) || [];
|
|
732
|
+
const orFilters = meta.channelOrFilters.get(msgChannel) || [];
|
|
733
|
+
if (filters.length > 0 || orFilters.length > 0) {
|
|
734
|
+
shouldSend = evaluateDatabaseLiveFilters(eventData, filters, orFilters);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
if (!shouldSend) continue;
|
|
739
|
+
|
|
740
|
+
try {
|
|
741
|
+
ws.send(msgStr);
|
|
742
|
+
} catch {
|
|
743
|
+
// Socket may be closing.
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
private broadcastToAuthenticated(msg: Record<string, unknown>): void {
|
|
749
|
+
const payload = JSON.stringify(msg);
|
|
750
|
+
for (const ws of this.ctx.getWebSockets()) {
|
|
751
|
+
const meta = this.getWSMeta(ws);
|
|
752
|
+
if (!meta?.authenticated) continue;
|
|
753
|
+
try {
|
|
754
|
+
ws.send(payload);
|
|
755
|
+
} catch {
|
|
756
|
+
// Socket may be closing.
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
private getWSMeta(ws: WebSocket): WSMeta | null {
|
|
762
|
+
const cached = this.metaCache.get(ws);
|
|
763
|
+
if (cached) return cached;
|
|
764
|
+
|
|
765
|
+
try {
|
|
766
|
+
const tags = this.ctx.getTags(ws);
|
|
767
|
+
if (tags.length === 0) return null;
|
|
768
|
+
const connectionId = tags[0];
|
|
769
|
+
const meta: WSMeta = {
|
|
770
|
+
authenticated: false,
|
|
771
|
+
connectionId,
|
|
772
|
+
subscribedChannels: [],
|
|
773
|
+
channelFilters: new Map(),
|
|
774
|
+
channelOrFilters: new Map(),
|
|
775
|
+
supportsBatch: false,
|
|
776
|
+
};
|
|
777
|
+
this.metaCache.set(ws, meta);
|
|
778
|
+
return meta;
|
|
779
|
+
} catch {
|
|
780
|
+
return null;
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
private setWSMeta(ws: WebSocket, meta: WSMeta): void {
|
|
785
|
+
this.metaCache.set(ws, meta);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
private getTableReadRule(tableName: string):
|
|
789
|
+
| ((auth: Record<string, unknown> | null, row: Record<string, unknown>) => boolean | Promise<boolean>)
|
|
790
|
+
| boolean
|
|
791
|
+
| undefined {
|
|
792
|
+
for (const dbBlock of Object.values(this.config.databases ?? {})) {
|
|
793
|
+
const tableConfig = dbBlock.tables?.[tableName];
|
|
794
|
+
if (tableConfig) {
|
|
795
|
+
return getTableAccess(tableConfig)?.read as
|
|
796
|
+
| ((auth: Record<string, unknown> | null, row: Record<string, unknown>) => boolean | Promise<boolean>)
|
|
797
|
+
| boolean
|
|
798
|
+
| undefined;
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
return undefined;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
private async evaluateRowReadAccess(
|
|
806
|
+
tableName: string,
|
|
807
|
+
meta: WSMeta,
|
|
808
|
+
row: Record<string, unknown> | null,
|
|
809
|
+
): Promise<boolean> {
|
|
810
|
+
const rule = this.getTableReadRule(tableName);
|
|
811
|
+
if (rule === undefined) return meta.authenticated;
|
|
812
|
+
if (typeof rule === 'boolean') return rule;
|
|
813
|
+
if (!row) return false;
|
|
814
|
+
|
|
815
|
+
const authCtx = meta.authenticated
|
|
816
|
+
? { id: meta.userId ?? '', role: meta.role ?? null, email: null }
|
|
817
|
+
: null;
|
|
818
|
+
|
|
819
|
+
try {
|
|
820
|
+
const result = await Promise.race([
|
|
821
|
+
Promise.resolve(rule(authCtx as Record<string, unknown> | null, row)),
|
|
822
|
+
new Promise<never>((_, reject) =>
|
|
823
|
+
setTimeout(() => reject(new Error('Database live row access timeout')), 50),
|
|
824
|
+
),
|
|
825
|
+
]);
|
|
826
|
+
return Boolean(result);
|
|
827
|
+
} catch {
|
|
828
|
+
return false;
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
private async evaluateChannelAccess(channel: string, meta: WSMeta): Promise<boolean> {
|
|
833
|
+
if (!meta.authenticated || !meta.userId) return false;
|
|
834
|
+
if (!isDbLiveChannel(channel)) return false;
|
|
835
|
+
|
|
836
|
+
const tableName = this.extractTableName(channel.split(':'));
|
|
837
|
+
if (!tableName) return false;
|
|
838
|
+
|
|
839
|
+
const tableRules = this.getTableReadRule(tableName);
|
|
840
|
+
if (tableRules === undefined) return meta.authenticated;
|
|
841
|
+
if (typeof tableRules === 'boolean') return tableRules;
|
|
842
|
+
|
|
843
|
+
const authCtx = meta.authenticated
|
|
844
|
+
? { id: meta.userId ?? '', role: meta.role ?? null, email: null }
|
|
845
|
+
: null;
|
|
846
|
+
|
|
847
|
+
try {
|
|
848
|
+
const result = await Promise.race([
|
|
849
|
+
Promise.resolve(tableRules(authCtx as Record<string, unknown> | null, {})),
|
|
850
|
+
new Promise<never>((_, reject) =>
|
|
851
|
+
setTimeout(() => reject(new Error('Database live channel access timeout')), 50),
|
|
852
|
+
),
|
|
853
|
+
]);
|
|
854
|
+
return Boolean(result);
|
|
855
|
+
} catch {
|
|
856
|
+
return false;
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
private extractTableName(parts: string[]): string | null {
|
|
861
|
+
if (parts.length === 3) return parts[2];
|
|
862
|
+
if (parts.length === 4) {
|
|
863
|
+
return parts[1] === 'shared' ? parts[2] : parts[3];
|
|
864
|
+
}
|
|
865
|
+
if (parts.length >= 5) return parts[3];
|
|
866
|
+
return null;
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
export function evaluateDatabaseLiveFilters(
|
|
871
|
+
data: Record<string, unknown>,
|
|
872
|
+
filters: readonly unknown[],
|
|
873
|
+
orFilters?: readonly unknown[],
|
|
874
|
+
): boolean {
|
|
875
|
+
const normalizedFilters = filters
|
|
876
|
+
.map(normalizeFilterCondition)
|
|
877
|
+
.filter((condition): condition is FilterCondition => condition !== null);
|
|
878
|
+
if (normalizedFilters.length !== filters.length) return false;
|
|
879
|
+
|
|
880
|
+
const andPass = normalizedFilters.every(([field, op, value]) => {
|
|
881
|
+
const fieldValue = data[field];
|
|
882
|
+
switch (op) {
|
|
883
|
+
case '==':
|
|
884
|
+
return fieldValue === value;
|
|
885
|
+
case '!=':
|
|
886
|
+
return fieldValue !== value;
|
|
887
|
+
case '<':
|
|
888
|
+
return typeof fieldValue === 'number' && typeof value === 'number' && fieldValue < value;
|
|
889
|
+
case '<=':
|
|
890
|
+
return typeof fieldValue === 'number' && typeof value === 'number' && fieldValue <= value;
|
|
891
|
+
case '>':
|
|
892
|
+
return typeof fieldValue === 'number' && typeof value === 'number' && fieldValue > value;
|
|
893
|
+
case '>=':
|
|
894
|
+
return typeof fieldValue === 'number' && typeof value === 'number' && fieldValue >= value;
|
|
895
|
+
case 'in':
|
|
896
|
+
return Array.isArray(value) && value.includes(fieldValue);
|
|
897
|
+
case 'contains':
|
|
898
|
+
if (typeof fieldValue === 'string') return fieldValue.includes(String(value));
|
|
899
|
+
return Array.isArray(fieldValue) && fieldValue.includes(value);
|
|
900
|
+
case 'contains-any':
|
|
901
|
+
return Array.isArray(fieldValue) && Array.isArray(value)
|
|
902
|
+
? value.some((entry) => fieldValue.includes(entry))
|
|
903
|
+
: false;
|
|
904
|
+
case 'not in':
|
|
905
|
+
return Array.isArray(value) ? !value.includes(fieldValue) : true;
|
|
906
|
+
default:
|
|
907
|
+
return false;
|
|
908
|
+
}
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
if (!andPass) return false;
|
|
912
|
+
if (!orFilters || orFilters.length === 0) return true;
|
|
913
|
+
|
|
914
|
+
const normalizedOrFilters = orFilters
|
|
915
|
+
.map(normalizeFilterCondition)
|
|
916
|
+
.filter((condition): condition is FilterCondition => condition !== null);
|
|
917
|
+
if (normalizedOrFilters.length !== orFilters.length) return false;
|
|
918
|
+
|
|
919
|
+
return normalizedOrFilters.some(([field, op, value]) => {
|
|
920
|
+
const fieldValue = data[field];
|
|
921
|
+
switch (op) {
|
|
922
|
+
case '==':
|
|
923
|
+
return fieldValue === value;
|
|
924
|
+
case '!=':
|
|
925
|
+
return fieldValue !== value;
|
|
926
|
+
case '<':
|
|
927
|
+
return typeof fieldValue === 'number' && typeof value === 'number' && fieldValue < value;
|
|
928
|
+
case '<=':
|
|
929
|
+
return typeof fieldValue === 'number' && typeof value === 'number' && fieldValue <= value;
|
|
930
|
+
case '>':
|
|
931
|
+
return typeof fieldValue === 'number' && typeof value === 'number' && fieldValue > value;
|
|
932
|
+
case '>=':
|
|
933
|
+
return typeof fieldValue === 'number' && typeof value === 'number' && fieldValue >= value;
|
|
934
|
+
case 'in':
|
|
935
|
+
return Array.isArray(value) && value.includes(fieldValue);
|
|
936
|
+
case 'contains':
|
|
937
|
+
if (typeof fieldValue === 'string') return fieldValue.includes(String(value));
|
|
938
|
+
return Array.isArray(fieldValue) && fieldValue.includes(value);
|
|
939
|
+
case 'contains-any':
|
|
940
|
+
return Array.isArray(fieldValue) && Array.isArray(value)
|
|
941
|
+
? value.some((entry) => fieldValue.includes(entry))
|
|
942
|
+
: false;
|
|
943
|
+
case 'not in':
|
|
944
|
+
return Array.isArray(value) ? !value.includes(fieldValue) : true;
|
|
945
|
+
default:
|
|
946
|
+
return false;
|
|
947
|
+
}
|
|
948
|
+
});
|
|
949
|
+
}
|