@rebasepro/server-postgres 0.24.0 → 0.24.1-canary.gcb918fc
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/README.md +16 -0
- package/dist/{BranchService-BRt78gfa.js → BranchService-Bsn2wfaj.js} +3 -2
- package/dist/{BranchService-BRt78gfa.js.map → BranchService-Bsn2wfaj.js.map} +1 -1
- package/dist/PostgresBackendDriver.d.ts +91 -2
- package/dist/PostgresBootstrapper.d.ts +101 -2
- package/dist/auth/ensure-tables.d.ts +12 -0
- package/dist/auth/services.d.ts +6 -0
- package/dist/backup/backup-cli.d.ts +14 -0
- package/dist/backup/backup-service.d.ts +2 -2
- package/dist/backup/pg-binary.d.ts +5 -0
- package/dist/{backup-cli-DW5p9_zv.js → backup-cli-DnpJi6I7.js} +37 -29
- package/dist/backup-cli-DnpJi6I7.js.map +1 -0
- package/dist/{backup-service-EwcDVG-8.js → backup-service-BTBB7RXn.js} +55 -21
- package/dist/backup-service-BTBB7RXn.js.map +1 -0
- package/dist/boot-ddl-DVpsFN6r.js +105 -0
- package/dist/boot-ddl-DVpsFN6r.js.map +1 -0
- package/dist/cli-console-Ca6ZlAVU.js +154 -0
- package/dist/cli-console-Ca6ZlAVU.js.map +1 -0
- package/dist/cli-console.d.ts +39 -0
- package/dist/cli-errors-CaI4ntyD.js +1919 -0
- package/dist/cli-errors-CaI4ntyD.js.map +1 -0
- package/dist/cli-errors.d.ts +51 -5
- package/dist/{cli-flags-BglvpjHv.js → cli-flags-JNI-pwSf.js} +6 -3
- package/dist/cli-flags-JNI-pwSf.js.map +1 -0
- package/dist/cli-helpers.d.ts +40 -0
- package/dist/cli-output.d.ts +16 -28
- package/dist/cli-push.d.ts +87 -0
- package/dist/cli.js +566 -182
- package/dist/cli.js.map +1 -1
- package/dist/{column-plan-helpers-1-LQD0yI.js → column-plan-helpers-drZIsSNf.js} +10 -4
- package/dist/{column-plan-helpers-1-LQD0yI.js.map → column-plan-helpers-drZIsSNf.js.map} +1 -1
- package/dist/{databasePoolManager-Bj5FbeAs.js → connection-C9mT8bgf.js} +24 -112
- package/dist/connection-C9mT8bgf.js.map +1 -0
- package/dist/connection.d.ts +13 -2
- package/dist/databasePoolManager-BIuewidb.js +119 -0
- package/dist/databasePoolManager-BIuewidb.js.map +1 -0
- package/dist/{ensure-collection-tables-kkkHk8oo.js → deferred-index-build-DP1_SNFo.js} +484 -346
- package/dist/deferred-index-build-DP1_SNFo.js.map +1 -0
- package/dist/{doctor-C-sYWbmt.js → doctor-C4WiftHm.js} +63 -52
- package/dist/doctor-C4WiftHm.js.map +1 -0
- package/dist/ensure-collection-policies-CXpbeNag.js +541 -0
- package/dist/ensure-collection-policies-CXpbeNag.js.map +1 -0
- package/dist/ensure-collection-tables-BhsU6qUE.js +532 -0
- package/dist/ensure-collection-tables-BhsU6qUE.js.map +1 -0
- package/dist/{ensure-tables-BmI_tRxc.js → ensure-tables-CVm_yvzb.js} +173 -77
- package/dist/ensure-tables-CVm_yvzb.js.map +1 -0
- package/dist/enum-labels-kept-D8fI1MoX.js +56 -0
- package/dist/enum-labels-kept-D8fI1MoX.js.map +1 -0
- package/dist/generate-drizzle-schema-Bz52jH2h.js +57 -0
- package/dist/generate-drizzle-schema-Bz52jH2h.js.map +1 -0
- package/dist/{generate-drizzle-schema-logic-sSFDp6LR.js → generate-drizzle-schema-logic-B74WN37S.js} +5 -5
- package/dist/generate-drizzle-schema-logic-B74WN37S.js.map +1 -0
- package/dist/generate-files-CdEckX6c.js +199 -0
- package/dist/generate-files-CdEckX6c.js.map +1 -0
- package/dist/{generate-postgres-ddl-logic-BJsLaVNX.js → generate-postgres-ddl-logic-B64O2oZF.js} +3 -3
- package/dist/{generate-postgres-ddl-logic-BJsLaVNX.js.map → generate-postgres-ddl-logic-B64O2oZF.js.map} +1 -1
- package/dist/generated-sql.d.ts +8 -2
- package/dist/history/HistoryService.d.ts +64 -5
- package/dist/history/ensure-history-table.d.ts +63 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.es.js +139 -19236
- package/dist/index.es.js.map +1 -1
- package/dist/{introspect-db-logic-kCETE8TY.js → introspect-db-logic-C-Di8dA9.js} +44 -6
- package/dist/introspect-db-logic-C-Di8dA9.js.map +1 -0
- package/dist/{introspect-db-queries-C_Q5VgQw.js → introspect-db-queries-D6kAqReI.js} +23 -1
- package/dist/introspect-db-queries-D6kAqReI.js.map +1 -0
- package/dist/open-rebase-CbOur0TU.js +20858 -0
- package/dist/open-rebase-CbOur0TU.js.map +1 -0
- package/dist/open-rebase.d.ts +86 -0
- package/dist/pg-error-utils-vJezlXdI.js +388 -0
- package/dist/pg-error-utils-vJezlXdI.js.map +1 -0
- package/dist/{plan-schema-DU9exq6C.js → plan-schema-B1YF_tYq.js} +151 -41
- package/dist/plan-schema-B1YF_tYq.js.map +1 -0
- package/dist/{policy-drift-B-J2hhm0.js → policy-drift-Dm8i-s1J.js} +23 -6
- package/dist/policy-drift-Dm8i-s1J.js.map +1 -0
- package/dist/push-index-prebuild-Db4AQF4i.js +45 -0
- package/dist/push-index-prebuild-Db4AQF4i.js.map +1 -0
- package/dist/push-legacy-renames-BK6ilQq2.js +34 -0
- package/dist/push-legacy-renames-BK6ilQq2.js.map +1 -0
- package/dist/{render-ddl-Ds2t_d9V.js → render-ddl-CtC3y9Ol.js} +8 -4
- package/dist/render-ddl-CtC3y9Ol.js.map +1 -0
- package/dist/{rls-bootstrap-sql-_KNnjanK.js → rls-bootstrap-sql-RQUljnJ1.js} +125 -1270
- package/dist/rls-bootstrap-sql-RQUljnJ1.js.map +1 -0
- package/dist/{rls-enforcement-CfXOJJaW.js → rls-enforcement-DvzqlaL1.js} +129 -17
- package/dist/rls-enforcement-DvzqlaL1.js.map +1 -0
- package/dist/schema/auth-schema.d.ts +2 -2
- package/dist/schema/boot-ddl.d.ts +47 -0
- package/dist/schema/collection-index.d.ts +45 -0
- package/dist/schema/column-plan-helpers.d.ts +5 -0
- package/dist/schema/deferred-index-build.d.ts +131 -0
- package/dist/schema/destructive-sql.d.ts +7 -0
- package/dist/schema/doctor-cli.js +110 -6
- package/dist/schema/doctor-cli.js.map +1 -1
- package/dist/schema/doctor-search-scale.d.ts +45 -0
- package/dist/schema/doctor.d.ts +20 -1
- package/dist/schema/ensure-collection-policies.d.ts +73 -35
- package/dist/schema/ensure-collection-tables.d.ts +56 -3
- package/dist/schema/enum-labels-kept.d.ts +26 -0
- package/dist/schema/generate-drizzle-schema.js +1 -1
- package/dist/schema/generate-files.d.ts +44 -0
- package/dist/schema/generate-next-step.d.ts +0 -13
- package/dist/schema/generate-postgres-ddl.js +10 -81
- package/dist/schema/generate-postgres-ddl.js.map +1 -1
- package/dist/schema/introspect-db-logic.d.ts +12 -0
- package/dist/schema/introspect-db-queries.d.ts +11 -1
- package/dist/schema/introspect-db-structure.d.ts +10 -0
- package/dist/schema/introspect-db.js +4 -5
- package/dist/schema/introspect-db.js.map +1 -1
- package/dist/schema/migration-post-process.d.ts +34 -0
- package/dist/schema/plan/diff-plan.d.ts +126 -0
- package/dist/schema/plan/types.d.ts +8 -0
- package/dist/schema/push-index-prebuild.d.ts +25 -0
- package/dist/schema/push-legacy-renames.d.ts +20 -0
- package/dist/schema/rls-bootstrap-sql.d.ts +15 -0
- package/dist/security/rls-enforcement.d.ts +32 -4
- package/dist/services/FetchService.d.ts +58 -8
- package/dist/services/PersistService.d.ts +15 -0
- package/dist/services/RelationService.d.ts +88 -3
- package/dist/services/cdc/CdcListener.d.ts +17 -6
- package/dist/services/cdc/trigger-cdc.d.ts +151 -27
- package/dist/services/channel-bus/index.d.ts +1 -1
- package/dist/services/channel-history.d.ts +15 -4
- package/dist/services/collection-helpers.d.ts +15 -0
- package/dist/services/field-viewer.d.ts +15 -1
- package/dist/services/pg-notify-listener.d.ts +7 -0
- package/dist/services/realtimeService.d.ts +314 -14
- package/dist/services/row-pipeline.d.ts +16 -9
- package/dist/services/socket-liveness.d.ts +42 -2
- package/dist/services/sql-script.d.ts +207 -3
- package/dist/services/vector-iterative-scan.d.ts +40 -0
- package/dist/services/write-depth.d.ts +6 -0
- package/dist/services/write-transaction-scope.d.ts +9 -0
- package/dist/socket-opt-ins.d.ts +64 -0
- package/dist/source-DBDbBTL4.js +811 -0
- package/dist/source-DBDbBTL4.js.map +1 -0
- package/dist/tsx-loader.d.ts +14 -0
- package/dist/utils/drizzle-conditions.d.ts +21 -1
- package/dist/utils/error-cause.d.ts +7 -0
- package/dist/utils/pg-error-utils.d.ts +2 -4
- package/dist/utils/result-rows.d.ts +10 -0
- package/dist/websocket-BLGc5yhs.js +1604 -0
- package/dist/websocket-BLGc5yhs.js.map +1 -0
- package/dist/websocket.d.ts +13 -0
- package/package.json +7 -7
- package/dist/backup-cli-DW5p9_zv.js.map +0 -1
- package/dist/backup-service-EwcDVG-8.js.map +0 -1
- package/dist/cli-errors-DsA-K9uP.js +0 -893
- package/dist/cli-errors-DsA-K9uP.js.map +0 -1
- package/dist/cli-flags-BglvpjHv.js.map +0 -1
- package/dist/cli-output-CNdMql-L.js +0 -47
- package/dist/cli-output-CNdMql-L.js.map +0 -1
- package/dist/collection-index-DdnsxiJ_.js +0 -286
- package/dist/collection-index-DdnsxiJ_.js.map +0 -1
- package/dist/databasePoolManager-Bj5FbeAs.js.map +0 -1
- package/dist/doctor-C-sYWbmt.js.map +0 -1
- package/dist/ensure-collection-policies-B1ureSIV.js +0 -160
- package/dist/ensure-collection-policies-B1ureSIV.js.map +0 -1
- package/dist/ensure-collection-tables-kkkHk8oo.js.map +0 -1
- package/dist/ensure-tables-BmI_tRxc.js.map +0 -1
- package/dist/generate-drizzle-schema-93M0lUxK.js +0 -123
- package/dist/generate-drizzle-schema-93M0lUxK.js.map +0 -1
- package/dist/generate-drizzle-schema-logic-sSFDp6LR.js.map +0 -1
- package/dist/introspect-db-logic-kCETE8TY.js.map +0 -1
- package/dist/introspect-db-queries-C_Q5VgQw.js.map +0 -1
- package/dist/plan-schema-DU9exq6C.js.map +0 -1
- package/dist/policy-drift-B-J2hhm0.js.map +0 -1
- package/dist/render-ddl-Ds2t_d9V.js.map +0 -1
- package/dist/rls-bootstrap-sql-_KNnjanK.js.map +0 -1
- package/dist/rls-enforcement-CfXOJJaW.js.map +0 -1
- package/dist/source-Br7L7GOI.js +0 -410
- package/dist/source-Br7L7GOI.js.map +0 -1
- package/dist/utils/sql-redaction.d.ts +0 -21
|
@@ -0,0 +1,1604 @@
|
|
|
1
|
+
import { createRequire as __createRequire } from "module";
|
|
2
|
+
__createRequire(import.meta.url);
|
|
3
|
+
import { r as __exportAll } from "./__vite-browser-external-BnuHet1e.js";
|
|
4
|
+
import { A as budgetSendBuffers, M as terminateIfBacklogged, N as assertReadRequestReadable, ct as SqlResultTooLargeError, it as effectiveSqlRole, j as reapSilentSockets, lt as isNestedPath, ot as sqlRoleRanAs, st as SQL_RESULT_BYTE_BUDGET, ut as resolveNestedPath } from "./open-rebase-CbOur0TU.js";
|
|
5
|
+
import { r as BranchingUnsupportedError } from "./BranchService-Bsn2wfaj.js";
|
|
6
|
+
import { ApiError, RUNTIME_DEFAULT_MAX_BODY_SIZE, assertFieldOpsValid, assertNestedWriteAllowed, assertNoFieldOpsOnCreate, assertNoUserUpsert, assertUserCreationBodyValid, assertWriteRequestValid, createUserThroughAuthCollection, decideImpersonation, declaredErrorAnswer, extractUserFromToken, getAccessModel, logger, resolveConflictTarget, resolveRequireAuth, safeCompare } from "@rebasepro/server";
|
|
7
|
+
import { ANONYMOUS_USER_ID, ListLimitError, hasAdminRole, isSQLAdmin, isSchemaAdmin, resolveClientListLimit, scopeGrants, scopesForRoles } from "@rebasepro/types";
|
|
8
|
+
import { UnknownFilterOperatorError, deserializeFilter, redactSqlLiterals } from "@rebasepro/common";
|
|
9
|
+
import { unref } from "@rebasepro/utils";
|
|
10
|
+
import { WebSocket, WebSocketServer } from "ws";
|
|
11
|
+
import { inspect } from "util";
|
|
12
|
+
//#region src/socket-opt-ins.ts
|
|
13
|
+
/**
|
|
14
|
+
* Behaviours a client opts into when it opens the socket.
|
|
15
|
+
*
|
|
16
|
+
* The client names them on the socket URL, `?rebase_caps=heartbeat,channel_seq,cancel_sql`,
|
|
17
|
+
* and the server answers with a `hello` frame listing the ones it will honour:
|
|
18
|
+
*
|
|
19
|
+
* ```json
|
|
20
|
+
* { "type": "hello", "capabilities": ["heartbeat", "channel_seq", "cancel_sql"], "heartbeatMs": 30000, "clientId": "client_…" }
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* Both halves are needed, so both directions of a version mismatch keep
|
|
24
|
+
* working. An older client names none and gets no `hello`, no heartbeat and no
|
|
25
|
+
* acknowledgement frames — nothing it would not recognise. An older server
|
|
26
|
+
* ignores the query parameter and sends no `hello`, and a newer client that
|
|
27
|
+
* hears none behaves as it always did.
|
|
28
|
+
*
|
|
29
|
+
* One list for both kinds: what the server sends (`heartbeat`, `channel_seq`)
|
|
30
|
+
* and the frames it takes beyond the base protocol (`cancel_sql`). Named before
|
|
31
|
+
* sign-in, so a socket that never signs in is served as one that does.
|
|
32
|
+
*
|
|
33
|
+
* - `heartbeat`: a `{ "type": "heartbeat" }` frame every `heartbeatMs`. A
|
|
34
|
+
* browser cannot send a WebSocket ping or see one, so a connection that went
|
|
35
|
+
* half-open — a NAT that forgot it, a laptop that slept — looks connected to
|
|
36
|
+
* it forever. With these, two missed beats tell it to redial.
|
|
37
|
+
* - `channel_seq`: on a retained channel, the sender of a broadcast gets
|
|
38
|
+
* `{ "type": "broadcast_ack", "channel", "seq" }` where every other member
|
|
39
|
+
* gets the message. Its own messages then leave no hole in the sequence it
|
|
40
|
+
* watches, so a hole means a message it really missed — see
|
|
41
|
+
* `RealtimeChannel` in the SDK.
|
|
42
|
+
* - `cancel_sql`: the server takes `CANCEL_SQL { requestId }`, which stops a
|
|
43
|
+
* console run (`EXECUTE_SQL`, `mode: "script"`) of the same socket. A client
|
|
44
|
+
* sends it only to a server whose `hello` listed it.
|
|
45
|
+
*
|
|
46
|
+
* @module
|
|
47
|
+
*/
|
|
48
|
+
/** The query parameter a client names its opt-ins in. */
|
|
49
|
+
var SOCKET_OPT_IN_PARAM = "rebase_caps";
|
|
50
|
+
/** Every behaviour this server can be asked for. */
|
|
51
|
+
var SOCKET_OPT_INS = [
|
|
52
|
+
"heartbeat",
|
|
53
|
+
"channel_seq",
|
|
54
|
+
"cancel_sql"
|
|
55
|
+
];
|
|
56
|
+
function isSocketOptIn(name) {
|
|
57
|
+
return SOCKET_OPT_INS.includes(name);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* What the client that opened this connection asked for, from its request
|
|
61
|
+
* URL: `undefined` when it named nothing (a client that predates this, which
|
|
62
|
+
* is sent no `hello`), otherwise the names this server honours — possibly
|
|
63
|
+
* none.
|
|
64
|
+
*/
|
|
65
|
+
function requestedOptIns(url) {
|
|
66
|
+
if (!url) return void 0;
|
|
67
|
+
const query = url.indexOf("?");
|
|
68
|
+
if (query === -1) return void 0;
|
|
69
|
+
const raw = new URLSearchParams(url.slice(query + 1)).get(SOCKET_OPT_IN_PARAM);
|
|
70
|
+
if (raw === null) return void 0;
|
|
71
|
+
return new Set(raw.split(",").map((name) => name.trim()).filter(isSocketOptIn));
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* The `hello` frame for a client that asked for `optIns`. With `channel_seq`
|
|
75
|
+
* it also names the socket's own id, the `senderId` its broadcasts carry in a
|
|
76
|
+
* channel's history, so a client filling a hole from history can tell its own
|
|
77
|
+
* messages (which it is never sent live) from the ones it missed.
|
|
78
|
+
*/
|
|
79
|
+
function helloFrame(optIns, heartbeatMs, clientId) {
|
|
80
|
+
return JSON.stringify({
|
|
81
|
+
type: "hello",
|
|
82
|
+
capabilities: [...optIns],
|
|
83
|
+
...optIns.has("heartbeat") ? { heartbeatMs } : {},
|
|
84
|
+
...optIns.has("channel_seq") ? { clientId } : {}
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
var HEARTBEAT_FRAME = JSON.stringify({ type: "heartbeat" });
|
|
88
|
+
/**
|
|
89
|
+
* Send a heartbeat every `intervalMs` to each socket `sockets()` lists. One
|
|
90
|
+
* timer for the whole server, unref'd so it never holds the process open.
|
|
91
|
+
*
|
|
92
|
+
* @returns Stops it.
|
|
93
|
+
*/
|
|
94
|
+
function startHeartbeats(sockets, intervalMs) {
|
|
95
|
+
const timer = setInterval(() => {
|
|
96
|
+
for (const ws of sockets()) {
|
|
97
|
+
if (ws.readyState !== WebSocket.OPEN) continue;
|
|
98
|
+
try {
|
|
99
|
+
ws.send(HEARTBEAT_FRAME);
|
|
100
|
+
} catch {}
|
|
101
|
+
}
|
|
102
|
+
}, intervalMs);
|
|
103
|
+
unref(timer);
|
|
104
|
+
return () => clearInterval(timer);
|
|
105
|
+
}
|
|
106
|
+
//#endregion
|
|
107
|
+
//#region src/websocket.ts
|
|
108
|
+
var websocket_exports = /* @__PURE__ */ __exportAll({
|
|
109
|
+
ADMIN_PLANE_TYPES: () => ADMIN_PLANE_TYPES,
|
|
110
|
+
PUBLIC_TYPES: () => PUBLIC_TYPES,
|
|
111
|
+
SESSION_ENDED_CLOSE_CODE: () => SESSION_ENDED_CLOSE_CODE,
|
|
112
|
+
TOO_MANY_SOCKETS_CLOSE_CODE: () => TOO_MANY_SOCKETS_CLOSE_CODE,
|
|
113
|
+
createPostgresWebSocket: () => createPostgresWebSocket,
|
|
114
|
+
isSocketRealtimeService: () => isSocketRealtimeService,
|
|
115
|
+
socketRealtimeMissing: () => socketRealtimeMissing
|
|
116
|
+
});
|
|
117
|
+
/**
|
|
118
|
+
* Narrow a bootstrapper's realtime provider to what the socket calls.
|
|
119
|
+
*
|
|
120
|
+
* The provider arrives typed as the engine-agnostic `RealtimeProvider`, which
|
|
121
|
+
* declares none of the client-connection methods. Checked rather than cast, so
|
|
122
|
+
* a provider missing one refuses at boot, naming it, instead of on a client's
|
|
123
|
+
* first frame.
|
|
124
|
+
*/
|
|
125
|
+
function socketRealtimeMissing(provider) {
|
|
126
|
+
return [
|
|
127
|
+
"addClient",
|
|
128
|
+
"rescopeClient",
|
|
129
|
+
"handleClientMessage"
|
|
130
|
+
].filter((method) => !(method in provider) || typeof Reflect.get(provider, method) !== "function");
|
|
131
|
+
}
|
|
132
|
+
function isSocketRealtimeService(provider) {
|
|
133
|
+
return socketRealtimeMissing(provider).length === 0;
|
|
134
|
+
}
|
|
135
|
+
/** What a script run says besides its rows, which an `EXECUTE_SQL` answer carries as `result`. */
|
|
136
|
+
function scriptDescription({ rows: _rows, ...description }) {
|
|
137
|
+
return description;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* The uid the service key authenticates as, here and through the built-in
|
|
141
|
+
* auth adapter — which is how the socket tells that credential apart from a
|
|
142
|
+
* person's session.
|
|
143
|
+
*/
|
|
144
|
+
var SERVICE_KEY_UID = "service";
|
|
145
|
+
/** Maximum messages per client per window */
|
|
146
|
+
var WS_RATE_LIMIT = 2e3;
|
|
147
|
+
/** Rate limit window in milliseconds (60 seconds) */
|
|
148
|
+
var WS_RATE_WINDOW_MS = 6e4;
|
|
149
|
+
/**
|
|
150
|
+
* Channel frames get their own budget, because they are a different workload.
|
|
151
|
+
*
|
|
152
|
+
* 2000/minute is 33/second, which is generous for queries and subscriptions and
|
|
153
|
+
* an order of magnitude below what the documented channel idiom asks for: the
|
|
154
|
+
* capacity note in `docs/backend/realtime.md` uses 60 fps cursor movement as
|
|
155
|
+
* its worked example, and the presence idiom re-`track()`s on every move, so
|
|
156
|
+
* one client sustaining that sends ~120 frames/second — 7200 a minute. Sharing
|
|
157
|
+
* one counter meant the cursor stream ate the query budget and then froze for
|
|
158
|
+
* the rest of the window.
|
|
159
|
+
*
|
|
160
|
+
* The number is sized to that documented workload and nothing more; it is not
|
|
161
|
+
* a considered product limit (see `docs/channel-authorization.md`).
|
|
162
|
+
*/
|
|
163
|
+
var WS_CHANNEL_RATE_LIMIT = 7200;
|
|
164
|
+
/**
|
|
165
|
+
* Realtime frames that read the database as they arrive: a subscription's
|
|
166
|
+
* first frame is a fetch and a count, run as the subscriber, and a history
|
|
167
|
+
* request reads the channel's retained messages. Each is a data request by
|
|
168
|
+
* another name, and counts in the caller's data bucket like a
|
|
169
|
+
* `FETCH_COLLECTION` — past the socket's own allowance.
|
|
170
|
+
*/
|
|
171
|
+
var FETCHING_REALTIME_TYPES = /* @__PURE__ */ new Set([
|
|
172
|
+
"subscribe_collection",
|
|
173
|
+
"subscribe_one",
|
|
174
|
+
"channel_history"
|
|
175
|
+
]);
|
|
176
|
+
/**
|
|
177
|
+
* How many fetching realtime frames one socket may send without counting them
|
|
178
|
+
* in its caller's data bucket: at least as many as a socket may hold
|
|
179
|
+
* subscriptions, so a reconnect that re-subscribes everything it held is
|
|
180
|
+
* never refused, and never spends the budget the person's next request needs.
|
|
181
|
+
* The default data bucket is 1,000 requests per 15 minutes, so counting a
|
|
182
|
+
* reconnect against it would have refused most of one, or locked the person
|
|
183
|
+
* out of the data API until the window turned.
|
|
184
|
+
*/
|
|
185
|
+
var SOCKET_FETCH_ALLOWANCE = 1e3;
|
|
186
|
+
/**
|
|
187
|
+
* How long a spent allowance takes to come back in full, in ms — the data
|
|
188
|
+
* API's default window. A socket churning subscriptions gets its allowance
|
|
189
|
+
* once, and then this much a quarter-hour; past that, every subscribe is a
|
|
190
|
+
* request in its person's bucket, which their HTTP requests and other sockets
|
|
191
|
+
* share.
|
|
192
|
+
*/
|
|
193
|
+
var SOCKET_FETCH_ALLOWANCE_REFILL_MS = 15 * 6e4;
|
|
194
|
+
/**
|
|
195
|
+
* The largest frame a socket that has not authenticated may send, in bytes.
|
|
196
|
+
*
|
|
197
|
+
* All such a socket can usefully say is `AUTHENTICATE` with a token — a few
|
|
198
|
+
* KiB with custom claims — and everything else is answered `UNAUTHORIZED`.
|
|
199
|
+
* Without this, each of those answers came after the whole frame was buffered,
|
|
200
|
+
* stringified and parsed, up to the body limit: a handful of connections that
|
|
201
|
+
* never signed in could hold the process's memory. A larger frame closes the
|
|
202
|
+
* socket with 1009 before it is read.
|
|
203
|
+
*/
|
|
204
|
+
var MAX_UNAUTHENTICATED_FRAME_BYTES = 64 * 1024;
|
|
205
|
+
/**
|
|
206
|
+
* How often a socket that only listens has its identity re-read, in ms.
|
|
207
|
+
*
|
|
208
|
+
* A socket that sends frames is re-checked before each one, as an HTTP request
|
|
209
|
+
* is; a socket that only receives pushes sends none, so without this a
|
|
210
|
+
* subscription opened by an identity that has since signed out everywhere, been
|
|
211
|
+
* demoted or deleted went on receiving rows as that identity for as long as the
|
|
212
|
+
* connection lasted.
|
|
213
|
+
*/
|
|
214
|
+
var DEFAULT_IDENTITY_RECHECK_MS = 3e4;
|
|
215
|
+
/**
|
|
216
|
+
* How many auth-store lookups the sweep runs at once. It shares the pool with
|
|
217
|
+
* every REST request — 20 connections by default — so it takes a few of them,
|
|
218
|
+
* however many sockets are due: 5,000 sockets at ~0.3 ms a lookup is under
|
|
219
|
+
* half a second of sweep, rather than a burst that held the whole pool.
|
|
220
|
+
*/
|
|
221
|
+
var IDENTITY_SWEEP_CONCURRENCY = 4;
|
|
222
|
+
/**
|
|
223
|
+
* Channel frames are re-checked at most this often, in ms. They are the
|
|
224
|
+
* high-rate traffic (see `WS_CHANNEL_RATE_LIMIT`): a database read for every
|
|
225
|
+
* cursor position would make realtime presence the busiest query a backend
|
|
226
|
+
* runs, for a window no wider than this.
|
|
227
|
+
*/
|
|
228
|
+
var CHANNEL_RECHECK_MIN_MS = 1e3;
|
|
229
|
+
/**
|
|
230
|
+
* The close code for a socket whose identity ended: signed out, revoked,
|
|
231
|
+
* demoted out of existence, deleted or expired. In the 4000–4999 range the
|
|
232
|
+
* protocol leaves to applications. The SDK reconnects and authenticates with
|
|
233
|
+
* whatever session it holds now — a refreshed token, or none.
|
|
234
|
+
*/
|
|
235
|
+
var SESSION_ENDED_CLOSE_CODE = 4001;
|
|
236
|
+
/**
|
|
237
|
+
* The close code for a socket refused because its user already holds
|
|
238
|
+
* `REALTIME_MAX_SOCKETS_PER_USER` sockets on this server. The SDK reconnects
|
|
239
|
+
* with backoff, so a tab refused this way gets in once another one closes.
|
|
240
|
+
*/
|
|
241
|
+
var TOO_MANY_SOCKETS_CLOSE_CODE = 4002;
|
|
242
|
+
/**
|
|
243
|
+
* When a JWT expires, in ms, or undefined for a credential that is not one
|
|
244
|
+
* (an API key, the service key, an adapter's opaque token).
|
|
245
|
+
*
|
|
246
|
+
* Read without verifying, and only ever for a token that was verified a moment
|
|
247
|
+
* ago by the same request: the signature is not in question, only the instant.
|
|
248
|
+
*/
|
|
249
|
+
function jwtExpiryMs(token) {
|
|
250
|
+
const parts = token.split(".");
|
|
251
|
+
if (parts.length !== 3) return void 0;
|
|
252
|
+
try {
|
|
253
|
+
const claims = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
|
|
254
|
+
if (typeof claims !== "object" || claims === null || !("exp" in claims)) return void 0;
|
|
255
|
+
return typeof claims.exp === "number" ? claims.exp * 1e3 : void 0;
|
|
256
|
+
} catch {
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
/** Everything about an identity that decides what its frames may do. */
|
|
261
|
+
function identityFingerprint(user) {
|
|
262
|
+
return JSON.stringify([
|
|
263
|
+
user.uid,
|
|
264
|
+
[...user.roles].sort(),
|
|
265
|
+
user.isAdmin,
|
|
266
|
+
user.isAnonymous,
|
|
267
|
+
user.scopes ?? null,
|
|
268
|
+
user.claims ?? null,
|
|
269
|
+
user.apiKey ?? null
|
|
270
|
+
]);
|
|
271
|
+
}
|
|
272
|
+
/** Frames counted against the channel budget rather than the general one. */
|
|
273
|
+
var CHANNEL_MESSAGE_TYPES = /* @__PURE__ */ new Set([
|
|
274
|
+
"join_channel",
|
|
275
|
+
"leave_channel",
|
|
276
|
+
"broadcast",
|
|
277
|
+
"presence_track",
|
|
278
|
+
"presence_untrack",
|
|
279
|
+
"presence_state",
|
|
280
|
+
"channel_history"
|
|
281
|
+
]);
|
|
282
|
+
/**
|
|
283
|
+
* WebSocket message types on the admin plane, and the scope each needs.
|
|
284
|
+
*
|
|
285
|
+
* Exported so the test can READ it. A privileged verb added without a scope
|
|
286
|
+
* check is what this list exists to make impossible: `PUBLIC_TYPES` below is
|
|
287
|
+
* its counterpart, and a test asserts that every `case` this file handles
|
|
288
|
+
* appears in exactly one of the two — so a verb added to neither fails rather
|
|
289
|
+
* than defaulting to reachable.
|
|
290
|
+
*
|
|
291
|
+
* All of these run on the owner connection, outside RLS. `database:read`
|
|
292
|
+
* covers the catalogue — including `FETCH_APPLICATION_ROLES`, which runs
|
|
293
|
+
* `SELECT DISTINCT unnest(roles)` over the users table — and
|
|
294
|
+
* `database:write` covers running SQL and creating or deleting branches.
|
|
295
|
+
*/
|
|
296
|
+
var ADMIN_PLANE_TYPES = /* @__PURE__ */ new Map([
|
|
297
|
+
["EXECUTE_SQL", "database:write"],
|
|
298
|
+
["CANCEL_SQL", "database:write"],
|
|
299
|
+
["CREATE_BRANCH", "database:write"],
|
|
300
|
+
["DELETE_BRANCH", "database:write"],
|
|
301
|
+
["FETCH_DATABASES", "database:read"],
|
|
302
|
+
["FETCH_ROLES", "database:read"],
|
|
303
|
+
["FETCH_APPLICATION_ROLES", "database:read"],
|
|
304
|
+
["FETCH_UNMAPPED_TABLES", "database:read"],
|
|
305
|
+
["FETCH_TABLE_METADATA", "database:read"],
|
|
306
|
+
["FETCH_CURRENT_DATABASE", "database:read"],
|
|
307
|
+
["LIST_BRANCHES", "database:read"]
|
|
308
|
+
]);
|
|
309
|
+
/**
|
|
310
|
+
* The data scope each data verb needs, for a session whose credential narrows
|
|
311
|
+
* it. A person's own session holds the whole data plane; RLS decides its rows.
|
|
312
|
+
*/
|
|
313
|
+
var DATA_VERB_SCOPES = /* @__PURE__ */ new Map([
|
|
314
|
+
["FETCH_COLLECTION", "data:read"],
|
|
315
|
+
["FETCH_ONE", "data:read"],
|
|
316
|
+
["COUNT", "data:read"],
|
|
317
|
+
["CHECK_UNIQUE_FIELD", "data:read"],
|
|
318
|
+
["subscribe_collection", "data:read"],
|
|
319
|
+
["subscribe_one", "data:read"],
|
|
320
|
+
["SAVE", "data:write"],
|
|
321
|
+
["DELETE", "data:delete"]
|
|
322
|
+
]);
|
|
323
|
+
/**
|
|
324
|
+
* Message types deliberately reachable by a non-admin session.
|
|
325
|
+
*
|
|
326
|
+
* Data operations, gated per row by RLS and per request by the same write
|
|
327
|
+
* checks the REST layer applies — not by this list. It exists so that "which
|
|
328
|
+
* bucket is this verb in" is a question with an answer for every verb, and
|
|
329
|
+
* adding one without answering it is a test failure.
|
|
330
|
+
*/
|
|
331
|
+
var PUBLIC_TYPES = /* @__PURE__ */ new Set([
|
|
332
|
+
"FETCH_COLLECTION",
|
|
333
|
+
"FETCH_ONE",
|
|
334
|
+
"COUNT",
|
|
335
|
+
"SAVE",
|
|
336
|
+
"DELETE",
|
|
337
|
+
"CHECK_UNIQUE_FIELD"
|
|
338
|
+
]);
|
|
339
|
+
/**
|
|
340
|
+
* Refuse a path whose collection — its first segment, for a nested path — this
|
|
341
|
+
* server does not have, with the answer REST gives (`NOT_FOUND`).
|
|
342
|
+
*/
|
|
343
|
+
function assertKnownCollection(registry, path) {
|
|
344
|
+
if (!registry || typeof path !== "string") return;
|
|
345
|
+
const root = path.split("/").find((segment) => segment !== "");
|
|
346
|
+
if (root !== void 0 && !registry.getCollectionByPath(root)) throw ApiError.notFound(`Unknown collection '${root}'.`);
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Recursively extract the deepest error message from an error's cause chain (e.g., Drizzle wrapping a PG error).
|
|
350
|
+
*/
|
|
351
|
+
function extractErrorMessage(error) {
|
|
352
|
+
if (!error) return "Unknown error";
|
|
353
|
+
if (error instanceof Error) {
|
|
354
|
+
if ("cause" in error && error.cause) return extractErrorMessage(error.cause);
|
|
355
|
+
return error.message;
|
|
356
|
+
}
|
|
357
|
+
if (typeof error === "object" && "message" in error && typeof error.message === "string") return error.message;
|
|
358
|
+
return String(error);
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Who a socket reads and writes as, for its request frames and its
|
|
362
|
+
* subscriptions alike.
|
|
363
|
+
*
|
|
364
|
+
* One answer because there were two, and they disagreed about guests. The
|
|
365
|
+
* subscriptions carried the session's `isAnonymous`; the request frames wrote
|
|
366
|
+
* `false` for every signed-in session, so a guest read and wrote as an account
|
|
367
|
+
* through `FETCH_*`, `SAVE` and `DELETE` while its subscriptions did not. And a
|
|
368
|
+
* socket with no session was a guest on the request frames (`true`) but not on
|
|
369
|
+
* its subscriptions or over REST: an unauthenticated caller is not a guest
|
|
370
|
+
* session.
|
|
371
|
+
*/
|
|
372
|
+
function sessionAuthContext(session) {
|
|
373
|
+
if (!session?.user) return {
|
|
374
|
+
uid: ANONYMOUS_USER_ID,
|
|
375
|
+
roles: ["anon"],
|
|
376
|
+
isAnonymous: false
|
|
377
|
+
};
|
|
378
|
+
return {
|
|
379
|
+
uid: session.user.uid,
|
|
380
|
+
roles: session.user.roles ?? [],
|
|
381
|
+
isAnonymous: session.user.isAnonymous === true,
|
|
382
|
+
...session.user.claims ? { claims: session.user.claims } : {}
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Everything a session may do: its credential's scopes, or what its roles hold
|
|
387
|
+
* in `model` — the backend's access model.
|
|
388
|
+
*/
|
|
389
|
+
function sessionScopes(session, model) {
|
|
390
|
+
if (!session?.user) return [];
|
|
391
|
+
return session.user.scopes ?? scopesForRoles(session.user.roles, model);
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* The collection a data frame addresses, for a narrowed session's scope check.
|
|
395
|
+
* A nested path (`authors/1/posts`) answers undefined: its target is a
|
|
396
|
+
* relation's, not a slug on the wire, so only an unqualified scope covers it.
|
|
397
|
+
*/
|
|
398
|
+
function frameCollection(type, payload) {
|
|
399
|
+
if (typeof payload !== "object" || payload === null) return void 0;
|
|
400
|
+
let path = "path" in payload ? payload.path : void 0;
|
|
401
|
+
if (type === "DELETE" && "row" in payload && typeof payload.row === "object" && payload.row !== null && "path" in payload.row) path = payload.row.path;
|
|
402
|
+
if (typeof path !== "string" || path === "" || isNestedPath(path)) return void 0;
|
|
403
|
+
return path;
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* A URL path with its trailing `/` dropped, so `/app` and `/app/` are one
|
|
407
|
+
* path — and `/` stays `/`.
|
|
408
|
+
*/
|
|
409
|
+
function normalizeSocketPath(path) {
|
|
410
|
+
const trimmed = (path.startsWith("/") ? path : `/${path}`).replace(/\/+$/, "");
|
|
411
|
+
return trimmed === "" ? "/" : trimmed;
|
|
412
|
+
}
|
|
413
|
+
/** The path an upgrade request asks for, without its query. */
|
|
414
|
+
function upgradePathOf(request) {
|
|
415
|
+
const url = request.url ?? "/";
|
|
416
|
+
const queryAt = url.indexOf("?");
|
|
417
|
+
return normalizeSocketPath(queryAt === -1 ? url : url.slice(0, queryAt));
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Answer an upgrade nobody will take with a plain HTTP 404 and close it, so
|
|
421
|
+
* the client sees a refusal instead of a socket that never opens.
|
|
422
|
+
*/
|
|
423
|
+
function refuseUpgrade(socket) {
|
|
424
|
+
if (!socket.writable) {
|
|
425
|
+
socket.destroy();
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
socket.once("finish", () => socket.destroy());
|
|
429
|
+
socket.end("HTTP/1.1 404 Not Found\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
|
|
430
|
+
}
|
|
431
|
+
/** A frame's size in bytes, however `ws` delivered it. */
|
|
432
|
+
function frameBytes(data) {
|
|
433
|
+
if (Array.isArray(data)) return data.reduce((total, chunk) => total + chunk.length, 0);
|
|
434
|
+
return data.byteLength;
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* Where a connection comes from, as the data API's rate limiter reads an
|
|
438
|
+
* address: the upgrade request's headers (believed only as far as proxies are
|
|
439
|
+
* declared) and the socket's own address.
|
|
440
|
+
*/
|
|
441
|
+
function connectionOrigin(request) {
|
|
442
|
+
return {
|
|
443
|
+
header: (name) => {
|
|
444
|
+
const value = request?.headers[name];
|
|
445
|
+
return Array.isArray(value) ? value.join(", ") : value;
|
|
446
|
+
},
|
|
447
|
+
socketAddress: request?.socket?.remoteAddress
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* A request frame with its `filter` decoded by the codec every other door uses.
|
|
452
|
+
*
|
|
453
|
+
* REST, MCP and the in-process SDK read `where` through `deserializeFilter`,
|
|
454
|
+
* so `{ title: ["foo", "bar"] }` is an implicit `in` and `["!!", "x"]` is
|
|
455
|
+
* refused by name. The socket handed the frame's filter to the driver raw,
|
|
456
|
+
* which read the first element as an operator: one filter, two meanings,
|
|
457
|
+
* depending on the transport. A typed `[op, value]` tuple decodes to itself.
|
|
458
|
+
*/
|
|
459
|
+
function withDecodedFilter(request) {
|
|
460
|
+
if (!request?.filter) return request;
|
|
461
|
+
try {
|
|
462
|
+
return {
|
|
463
|
+
...request,
|
|
464
|
+
filter: deserializeFilter(request.filter)
|
|
465
|
+
};
|
|
466
|
+
} catch (e) {
|
|
467
|
+
if (e instanceof UnknownFilterOperatorError) throw ApiError.badRequest(e.message, e.code, e.details);
|
|
468
|
+
throw e;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* `options` carry the data API's limits, for the same rows reached through this door:
|
|
473
|
+
* frames larger than its body limit close the socket with 1009 before they are
|
|
474
|
+
* read, and every data frame counts in the caller's data-API bucket — the one
|
|
475
|
+
* their HTTP requests count in — rather than only in a budget per connection,
|
|
476
|
+
* which a caller multiplied by opening more.
|
|
477
|
+
*/
|
|
478
|
+
function createPostgresWebSocket(server, realtimeService, driver, authConfig, authAdapter, options) {
|
|
479
|
+
const clientSessions = /* @__PURE__ */ new Map();
|
|
480
|
+
/** The sockets that opted into heartbeats — see `socket-opt-ins.ts`. */
|
|
481
|
+
const heartbeatSockets = /* @__PURE__ */ new Set();
|
|
482
|
+
const heartbeatIntervalMs = options?.heartbeatIntervalMs ?? 3e4;
|
|
483
|
+
/** How many sockets one identity may hold here; unset is no ceiling. See `REALTIME_MAX_SOCKETS_PER_USER`. */
|
|
484
|
+
const maxSocketsPerUser = options?.maxSocketsPerUser;
|
|
485
|
+
const isProduction = process.env.NODE_ENV === "production";
|
|
486
|
+
/**
|
|
487
|
+
* Debug tracing for this socket.
|
|
488
|
+
*
|
|
489
|
+
* Through `logger.debug`, not `console.debug`. A bare console call sits
|
|
490
|
+
* outside the one level system, so `LOG_LEVEL=warn` — a line the scaffold's
|
|
491
|
+
* own `.env.example` ships — silenced every structured line in the process
|
|
492
|
+
* and left these ones printing; the deletion of the old `console.debug`
|
|
493
|
+
* override is what exposed that. It also skipped the redactor, and the
|
|
494
|
+
* extras these calls carry are rows.
|
|
495
|
+
*
|
|
496
|
+
* The production guard stays on top of the level gate rather than being
|
|
497
|
+
* replaced by it: these lines trace requests and their payloads, and
|
|
498
|
+
* `LOG_LEVEL=debug` on a deployment is not consent to put a user's row on
|
|
499
|
+
* stdout.
|
|
500
|
+
*/
|
|
501
|
+
const wsDebug = (message, ...extras) => {
|
|
502
|
+
if (isProduction) return;
|
|
503
|
+
if (extras.length === 0) logger.debug(message);
|
|
504
|
+
else logger.debug(message, { details: extras.length === 1 ? extras[0] : extras });
|
|
505
|
+
};
|
|
506
|
+
const maxPayload = options?.maxPayload ?? RUNTIME_DEFAULT_MAX_BODY_SIZE;
|
|
507
|
+
const wss = new WebSocketServer({
|
|
508
|
+
noServer: true,
|
|
509
|
+
maxPayload: maxPayload > 0 ? maxPayload : 0
|
|
510
|
+
});
|
|
511
|
+
wss.on("error", (err) => {
|
|
512
|
+
logger.error("❌ [WebSocket Server] Error", { error: err });
|
|
513
|
+
});
|
|
514
|
+
const socketPath = normalizeSocketPath(options?.path ?? "/");
|
|
515
|
+
const onUpgrade = (request, socket, head) => {
|
|
516
|
+
if (upgradePathOf(request) !== socketPath) {
|
|
517
|
+
if (server.listenerCount("upgrade") <= 1) refuseUpgrade(socket);
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
wss.handleUpgrade(request, socket, head, (client) => wss.emit("connection", client, request));
|
|
521
|
+
};
|
|
522
|
+
if (typeof server.on === "function") {
|
|
523
|
+
server.on("upgrade", onUpgrade);
|
|
524
|
+
wss.on("close", () => server.off("upgrade", onUpgrade));
|
|
525
|
+
}
|
|
526
|
+
const requireAuth = !!authAdapter || resolveRequireAuth(authConfig);
|
|
527
|
+
const verifySessionToken = options?.verifyAccessToken ?? extractUserFromToken;
|
|
528
|
+
const accessModel = () => options?.accessModel ?? getAccessModel();
|
|
529
|
+
if (requireAuth && !authAdapter && !authConfig?.jwtSecret && !authConfig?.serviceKey) logger.warn("🔐 [WebSocket Server] Authentication is required but no adapter, jwtSecret or serviceKey is configured — no client can complete AUTH, so every realtime message will be refused with UNAUTHORIZED.");
|
|
530
|
+
/**
|
|
531
|
+
* Who a credential is, now: the same verification `AUTHENTICATE` runs, so
|
|
532
|
+
* re-asking it later answers what signing in would answer. A refusal is a
|
|
533
|
+
* value; a store that cannot answer throws.
|
|
534
|
+
*/
|
|
535
|
+
async function resolveCaller(token) {
|
|
536
|
+
if (token.startsWith("rk_")) {
|
|
537
|
+
const resolved = options?.resolveApiKey ? await options.resolveApiKey(token) : void 0;
|
|
538
|
+
if (!resolved || !("uid" in resolved)) return {
|
|
539
|
+
refused: resolved?.message ?? "API keys are not enabled on this server",
|
|
540
|
+
apiKey: true
|
|
541
|
+
};
|
|
542
|
+
return {
|
|
543
|
+
credential: "api-key",
|
|
544
|
+
user: {
|
|
545
|
+
uid: resolved.uid,
|
|
546
|
+
roles: resolved.roles,
|
|
547
|
+
isAdmin: hasAdminRole(resolved.roles),
|
|
548
|
+
isAnonymous: false,
|
|
549
|
+
scopes: resolved.scopes,
|
|
550
|
+
...resolved.apiKey ? { apiKey: {
|
|
551
|
+
id: resolved.apiKey.id,
|
|
552
|
+
rate_limit: resolved.apiKey.rate_limit
|
|
553
|
+
} } : {}
|
|
554
|
+
}
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
if (authAdapter) {
|
|
558
|
+
const adapterUser = authAdapter.verifyToken ? await authAdapter.verifyToken(token) : await authAdapter.verifyRequest(new Request("http://localhost/_ws_auth", { headers: { Authorization: `Bearer ${token}` } }));
|
|
559
|
+
if (!adapterUser) return { refused: "Invalid or expired token" };
|
|
560
|
+
return {
|
|
561
|
+
credential: adapterUser.uid === SERVICE_KEY_UID ? "service-key" : "session",
|
|
562
|
+
user: {
|
|
563
|
+
uid: adapterUser.uid,
|
|
564
|
+
roles: adapterUser.roles,
|
|
565
|
+
isAdmin: adapterUser.isAdmin,
|
|
566
|
+
isAnonymous: adapterUser.isAnonymous === true,
|
|
567
|
+
...adapterUser.claims ? { claims: adapterUser.claims } : {}
|
|
568
|
+
}
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
if (authConfig?.serviceKey && safeCompare(token, authConfig.serviceKey)) return {
|
|
572
|
+
credential: "service-key",
|
|
573
|
+
user: {
|
|
574
|
+
uid: SERVICE_KEY_UID,
|
|
575
|
+
roles: ["admin"],
|
|
576
|
+
isAdmin: true,
|
|
577
|
+
isAnonymous: false
|
|
578
|
+
}
|
|
579
|
+
};
|
|
580
|
+
const jwtPayload = await verifySessionToken(token);
|
|
581
|
+
if (!jwtPayload) return { refused: "Invalid or expired token" };
|
|
582
|
+
return {
|
|
583
|
+
credential: "session",
|
|
584
|
+
user: {
|
|
585
|
+
uid: jwtPayload.uid,
|
|
586
|
+
roles: jwtPayload.roles ?? [],
|
|
587
|
+
isAdmin: hasAdminRole(jwtPayload.roles ?? []),
|
|
588
|
+
isAnonymous: jwtPayload.isAnonymous === true,
|
|
589
|
+
claims: jwtPayload.claims
|
|
590
|
+
}
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* Who this socket runs as: its caller, or — when it asked to `impersonate`
|
|
595
|
+
* — the user it named, by the same decision the data API takes for
|
|
596
|
+
* `x-rebase-impersonate`. A refusal carries the code to answer with.
|
|
597
|
+
*
|
|
598
|
+
* `audit` is passed by `AUTHENTICATE` only: the re-check before every frame
|
|
599
|
+
* decides again without writing a security event each time.
|
|
600
|
+
*/
|
|
601
|
+
async function resolveIdentity(token, impersonate, audit) {
|
|
602
|
+
const caller = await resolveCaller(token);
|
|
603
|
+
if (!("user" in caller) || impersonate === void 0) return caller;
|
|
604
|
+
const decision = await decideImpersonation({
|
|
605
|
+
requestedUid: impersonate,
|
|
606
|
+
credential: caller.credential,
|
|
607
|
+
caller: caller.user,
|
|
608
|
+
resolveUser: authAdapter?.resolveUser ? authAdapter.resolveUser.bind(authAdapter) : void 0,
|
|
609
|
+
audit
|
|
610
|
+
});
|
|
611
|
+
if ("refused" in decision) return {
|
|
612
|
+
refused: decision.refused.message,
|
|
613
|
+
code: decision.refused.code
|
|
614
|
+
};
|
|
615
|
+
const target = decision.granted;
|
|
616
|
+
return { user: {
|
|
617
|
+
uid: target.uid,
|
|
618
|
+
roles: target.roles,
|
|
619
|
+
isAdmin: hasAdminRole(target.roles),
|
|
620
|
+
isAnonymous: target.isAnonymous === true,
|
|
621
|
+
...target.claims ? { claims: target.claims } : {},
|
|
622
|
+
impersonator: decision.impersonator
|
|
623
|
+
} };
|
|
624
|
+
}
|
|
625
|
+
/** Stop honouring the session at the instant its token expires. */
|
|
626
|
+
function scheduleExpiry(clientId, token) {
|
|
627
|
+
const session = clientSessions.get(clientId);
|
|
628
|
+
if (!session) return;
|
|
629
|
+
if (session.expiryTimer) clearTimeout(session.expiryTimer);
|
|
630
|
+
session.expiryTimer = void 0;
|
|
631
|
+
const expiresAt = jwtExpiryMs(token);
|
|
632
|
+
if (expiresAt === void 0) return;
|
|
633
|
+
const delay = Math.max(0, expiresAt - Date.now());
|
|
634
|
+
if (delay > 2147483647) return;
|
|
635
|
+
session.expiryTimer = setTimeout(() => {
|
|
636
|
+
if (clientSessions.get(clientId)?.credential === token) endSession(clientId, "TOKEN_EXPIRED", "The token this socket authenticated with has expired.");
|
|
637
|
+
}, delay);
|
|
638
|
+
session.expiryTimer.unref?.();
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* The identity this socket authenticated as is over: say so, forget it,
|
|
642
|
+
* and close. Closing rather than carrying on anonymously, because every
|
|
643
|
+
* subscription the socket holds was opened as that identity — the SDK
|
|
644
|
+
* reconnects, authenticates with whatever session it has now, and
|
|
645
|
+
* re-subscribes as that.
|
|
646
|
+
*/
|
|
647
|
+
function endSession(clientId, code, message) {
|
|
648
|
+
const session = clientSessions.get(clientId);
|
|
649
|
+
if (!session) return;
|
|
650
|
+
if (session.expiryTimer) clearTimeout(session.expiryTimer);
|
|
651
|
+
session.expiryTimer = void 0;
|
|
652
|
+
session.user = void 0;
|
|
653
|
+
session.credential = void 0;
|
|
654
|
+
session.authenticated = !requireAuth;
|
|
655
|
+
wsDebug(`[WS] ${clientId} session ended: ${code}`);
|
|
656
|
+
try {
|
|
657
|
+
session.ws.send(JSON.stringify({
|
|
658
|
+
type: "AUTH_ERROR",
|
|
659
|
+
payload: { error: {
|
|
660
|
+
code,
|
|
661
|
+
message
|
|
662
|
+
} }
|
|
663
|
+
}));
|
|
664
|
+
} catch {}
|
|
665
|
+
session.ws.close(SESSION_ENDED_CLOSE_CODE, message.slice(0, 120));
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* Is the identity this socket authenticated as still the one its
|
|
669
|
+
* credential names?
|
|
670
|
+
*
|
|
671
|
+
* Asked of the database, through the same verification as
|
|
672
|
+
* `AUTHENTICATE`, before each frame and by the sweep — never by an event
|
|
673
|
+
* one instance publishes to the others. A sign-out, a demotion or a
|
|
674
|
+
* deletion is written to the database by whichever instance served it;
|
|
675
|
+
* re-reading there is correct on every instance with nothing in between,
|
|
676
|
+
* where an event would reach only the instances a bus connects (and the
|
|
677
|
+
* bus is opt-in).
|
|
678
|
+
*
|
|
679
|
+
* - `"live"`: still honoured; roles or claims that changed are applied,
|
|
680
|
+
* and the socket's subscriptions are re-scoped to them.
|
|
681
|
+
* - `"ended"`: refused — revoked, signed out, deleted, expired. The
|
|
682
|
+
* socket is closed.
|
|
683
|
+
* - `"unavailable"`: the store could not answer. The frame is refused,
|
|
684
|
+
* the session kept, and the next frame or sweep asks again.
|
|
685
|
+
*/
|
|
686
|
+
async function recheckIdentity(clientId, resolve = resolveIdentity) {
|
|
687
|
+
const session = clientSessions.get(clientId);
|
|
688
|
+
const credential = session?.credential;
|
|
689
|
+
if (!session?.user || !credential) return "live";
|
|
690
|
+
let outcome;
|
|
691
|
+
try {
|
|
692
|
+
outcome = await resolve(credential, session.impersonate);
|
|
693
|
+
} catch {
|
|
694
|
+
return "unavailable";
|
|
695
|
+
}
|
|
696
|
+
if (session.credential !== credential || !session.user) return session.user ? "live" : "ended";
|
|
697
|
+
session.checkedAt = Date.now();
|
|
698
|
+
if (!("user" in outcome)) {
|
|
699
|
+
if (outcome.code) endSession(clientId, outcome.code, outcome.refused);
|
|
700
|
+
else endSession(clientId, "SESSION_ENDED", "This session has ended: it was signed out, revoked, or its account changed. Authenticate again.");
|
|
701
|
+
return "ended";
|
|
702
|
+
}
|
|
703
|
+
if (identityFingerprint(outcome.user) !== identityFingerprint(session.user)) {
|
|
704
|
+
session.user = outcome.user;
|
|
705
|
+
await realtimeService.rescopeClient(clientId, sessionAuthContext(session));
|
|
706
|
+
}
|
|
707
|
+
return "live";
|
|
708
|
+
}
|
|
709
|
+
/** The per-frame check in flight on each socket, and what it asks. */
|
|
710
|
+
const frameChecks = /* @__PURE__ */ new Map();
|
|
711
|
+
/**
|
|
712
|
+
* {@link recheckIdentity} for a frame, shared by the frames of one socket
|
|
713
|
+
* that arrive while it is in flight.
|
|
714
|
+
*
|
|
715
|
+
* A client that reconnects after a deploy re-sends all its subscriptions
|
|
716
|
+
* at once, and each frame asked the auth store the same question at the
|
|
717
|
+
* same moment: a thousand clients with ten subscriptions were ten thousand
|
|
718
|
+
* lookups where a thousand answer. A frame that arrives while this
|
|
719
|
+
* socket's check of the same credential and impersonation target is under
|
|
720
|
+
* way waits for that answer instead. The answer is the flight's only:
|
|
721
|
+
* dropped as it settles, so the next frame asks afresh. What this trades
|
|
722
|
+
* is bounded by one lookup: a demotion that lands while the shared lookup
|
|
723
|
+
* runs is seen by the next frame rather than by these.
|
|
724
|
+
*/
|
|
725
|
+
function recheckIdentityForFrame(clientId) {
|
|
726
|
+
const session = clientSessions.get(clientId);
|
|
727
|
+
const credential = session?.credential;
|
|
728
|
+
if (!session || !credential) return recheckIdentity(clientId);
|
|
729
|
+
const inFlight = frameChecks.get(clientId);
|
|
730
|
+
if (inFlight && inFlight.credential === credential && inFlight.impersonate === session.impersonate) return inFlight.outcome;
|
|
731
|
+
const outcome = recheckIdentity(clientId).finally(() => {
|
|
732
|
+
if (frameChecks.get(clientId)?.outcome === outcome) frameChecks.delete(clientId);
|
|
733
|
+
});
|
|
734
|
+
frameChecks.set(clientId, {
|
|
735
|
+
credential,
|
|
736
|
+
impersonate: session.impersonate,
|
|
737
|
+
outcome
|
|
738
|
+
});
|
|
739
|
+
return outcome;
|
|
740
|
+
}
|
|
741
|
+
/**
|
|
742
|
+
* A socket's whole allowance of fetching realtime frames: the default, or
|
|
743
|
+
* the realtime service's subscription ceiling where a deployment raised
|
|
744
|
+
* it, so a full socket still re-subscribes without being charged.
|
|
745
|
+
*/
|
|
746
|
+
function fetchAllowanceCapacity() {
|
|
747
|
+
const ceiling = Reflect.get(realtimeService, "maxSubscriptionsPerSocket");
|
|
748
|
+
return typeof ceiling === "number" && Number.isFinite(ceiling) ? Math.max(SOCKET_FETCH_ALLOWANCE, ceiling) : SOCKET_FETCH_ALLOWANCE;
|
|
749
|
+
}
|
|
750
|
+
/**
|
|
751
|
+
* Take one fetching realtime frame from the socket's own allowance,
|
|
752
|
+
* topped up for the time since it was last asked. False when none is
|
|
753
|
+
* left: the frame then counts in the caller's data bucket.
|
|
754
|
+
*/
|
|
755
|
+
function spendFetchAllowance(clientId) {
|
|
756
|
+
const session = clientSessions.get(clientId);
|
|
757
|
+
if (!session) return false;
|
|
758
|
+
const now = Date.now();
|
|
759
|
+
const capacity = fetchAllowanceCapacity();
|
|
760
|
+
const refilled = Math.max(0, now - session.fetchAllowanceAt) * capacity / SOCKET_FETCH_ALLOWANCE_REFILL_MS;
|
|
761
|
+
session.fetchAllowance = Math.min(capacity, session.fetchAllowance + refilled);
|
|
762
|
+
session.fetchAllowanceAt = now;
|
|
763
|
+
if (session.fetchAllowance < 1) return false;
|
|
764
|
+
session.fetchAllowance -= 1;
|
|
765
|
+
return true;
|
|
766
|
+
}
|
|
767
|
+
const identityRecheckMs = Math.max(250, options?.identityRecheckIntervalMs ?? DEFAULT_IDENTITY_RECHECK_MS);
|
|
768
|
+
/**
|
|
769
|
+
* Re-check every socket that is due, asking the auth store once per
|
|
770
|
+
* question.
|
|
771
|
+
*
|
|
772
|
+
* Each socket used to be asked about on its own, all in the same tick: S
|
|
773
|
+
* listening sockets were S concurrent lookups on the pool REST shares,
|
|
774
|
+
* and a person with five tabs was five identical lookups of one token.
|
|
775
|
+
* Sockets are grouped by what they would ask — their credential and the
|
|
776
|
+
* user they impersonate — and each group asks once, at most
|
|
777
|
+
* {@link IDENTITY_SWEEP_CONCURRENCY} groups at a time.
|
|
778
|
+
*
|
|
779
|
+
* The answer is the group's alone, made when the group's turn comes and
|
|
780
|
+
* dropped when it is done; nothing is kept from one sweep to the next, so
|
|
781
|
+
* a credential revoked after a sweep is asked about afresh, and refused,
|
|
782
|
+
* by the next one. A socket that authenticated again while its group
|
|
783
|
+
* waited is asked about the credential it holds now — see `answerFor`.
|
|
784
|
+
*/
|
|
785
|
+
async function sweepIdentities() {
|
|
786
|
+
const now = Date.now();
|
|
787
|
+
const groups = /* @__PURE__ */ new Map();
|
|
788
|
+
for (const [id, session] of clientSessions) {
|
|
789
|
+
if (!session.user || !session.credential || now - session.checkedAt < identityRecheckMs / 2) continue;
|
|
790
|
+
const key = JSON.stringify([session.credential, session.impersonate ?? null]);
|
|
791
|
+
const group = groups.get(key);
|
|
792
|
+
if (group) group.clientIds.push(id);
|
|
793
|
+
else groups.set(key, {
|
|
794
|
+
credential: session.credential,
|
|
795
|
+
impersonate: session.impersonate,
|
|
796
|
+
clientIds: [id]
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
const queue = [...groups.values()];
|
|
800
|
+
const work = async () => {
|
|
801
|
+
for (let group = queue.shift(); group; group = queue.shift()) {
|
|
802
|
+
let answer;
|
|
803
|
+
const answerFor = (token, impersonate) => {
|
|
804
|
+
if (token !== group.credential || impersonate !== group.impersonate) return resolveIdentity(token, impersonate);
|
|
805
|
+
answer ??= resolveIdentity(token, impersonate);
|
|
806
|
+
return answer;
|
|
807
|
+
};
|
|
808
|
+
for (const id of group.clientIds) await recheckIdentity(id, answerFor).catch(() => void 0);
|
|
809
|
+
}
|
|
810
|
+
};
|
|
811
|
+
await Promise.all(Array.from({ length: Math.min(IDENTITY_SWEEP_CONCURRENCY, queue.length) }, work));
|
|
812
|
+
}
|
|
813
|
+
let sweeping = false;
|
|
814
|
+
const identitySweep = setInterval(() => {
|
|
815
|
+
if (sweeping) return;
|
|
816
|
+
sweeping = true;
|
|
817
|
+
sweepIdentities().catch(() => void 0).finally(() => {
|
|
818
|
+
sweeping = false;
|
|
819
|
+
});
|
|
820
|
+
}, identityRecheckMs);
|
|
821
|
+
identitySweep.unref?.();
|
|
822
|
+
const stopSweep = () => clearInterval(identitySweep);
|
|
823
|
+
wss.on("close", stopSweep);
|
|
824
|
+
if (typeof server.on === "function") server.on("close", stopSweep);
|
|
825
|
+
wss.on("connection", (ws, request) => {
|
|
826
|
+
const clientId = `client_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
|
827
|
+
const origin = connectionOrigin(request);
|
|
828
|
+
wsDebug(`WebSocket client connected: ${clientId}`);
|
|
829
|
+
const optIns = requestedOptIns(request?.url);
|
|
830
|
+
if (optIns) {
|
|
831
|
+
ws.send(helloFrame(optIns, heartbeatIntervalMs, clientId));
|
|
832
|
+
realtimeService.setClientOptIns?.(clientId, optIns);
|
|
833
|
+
if (optIns.has("heartbeat")) {
|
|
834
|
+
heartbeatSockets.add(ws);
|
|
835
|
+
ws.once("close", () => heartbeatSockets.delete(ws));
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
clientSessions.set(clientId, {
|
|
839
|
+
ws,
|
|
840
|
+
authenticated: !requireAuth,
|
|
841
|
+
checkedAt: Date.now(),
|
|
842
|
+
messageCount: 0,
|
|
843
|
+
messageWindowStart: Date.now(),
|
|
844
|
+
channelMessageCount: 0,
|
|
845
|
+
channelWindowStart: Date.now(),
|
|
846
|
+
fetchAllowance: fetchAllowanceCapacity(),
|
|
847
|
+
fetchAllowanceAt: Date.now()
|
|
848
|
+
});
|
|
849
|
+
realtimeService.addClient(clientId, ws);
|
|
850
|
+
const sqlRuns = /* @__PURE__ */ new Map();
|
|
851
|
+
ws.on("close", () => {
|
|
852
|
+
wsDebug(`WebSocket client disconnected: ${clientId}`);
|
|
853
|
+
const session = clientSessions.get(clientId);
|
|
854
|
+
if (session?.expiryTimer) clearTimeout(session.expiryTimer);
|
|
855
|
+
clientSessions.delete(clientId);
|
|
856
|
+
});
|
|
857
|
+
ws.on("error", (error) => {
|
|
858
|
+
wsDebug(`WebSocket client ${clientId} error: ${error.message}`);
|
|
859
|
+
});
|
|
860
|
+
ws.on("message", async (message) => {
|
|
861
|
+
if (terminateIfBacklogged(ws, clientId)) return;
|
|
862
|
+
let requestId;
|
|
863
|
+
try {
|
|
864
|
+
if (!clientSessions.get(clientId)?.authenticated && frameBytes(message) > MAX_UNAUTHENTICATED_FRAME_BYTES) {
|
|
865
|
+
ws.close(1009, "Authenticate before sending anything larger than a token.");
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
const { type, payload, requestId: reqId } = JSON.parse(message.toString());
|
|
869
|
+
requestId = reqId;
|
|
870
|
+
wsDebug(`[WS] ${clientId} → ${type}`, requestId ? `(${requestId})` : "");
|
|
871
|
+
const sendError = (errType, code, msg) => {
|
|
872
|
+
const channel = CHANNEL_MESSAGE_TYPES.has(type) ? payload?.channel : void 0;
|
|
873
|
+
ws.send(JSON.stringify({
|
|
874
|
+
type: errType,
|
|
875
|
+
requestId,
|
|
876
|
+
...typeof channel === "string" && { channel },
|
|
877
|
+
payload: {
|
|
878
|
+
error: {
|
|
879
|
+
message: msg,
|
|
880
|
+
code
|
|
881
|
+
},
|
|
882
|
+
...typeof channel === "string" && { channel }
|
|
883
|
+
}
|
|
884
|
+
}));
|
|
885
|
+
};
|
|
886
|
+
{
|
|
887
|
+
const session = clientSessions.get(clientId);
|
|
888
|
+
if (session) {
|
|
889
|
+
const now = Date.now();
|
|
890
|
+
if (CHANNEL_MESSAGE_TYPES.has(type)) {
|
|
891
|
+
if (now - session.channelWindowStart > WS_RATE_WINDOW_MS) {
|
|
892
|
+
session.channelMessageCount = 0;
|
|
893
|
+
session.channelWindowStart = now;
|
|
894
|
+
}
|
|
895
|
+
session.channelMessageCount++;
|
|
896
|
+
if (session.channelMessageCount > WS_CHANNEL_RATE_LIMIT) {
|
|
897
|
+
sendError("ERROR", "RATE_LIMITED", "Too many channel messages. Please slow down.");
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
} else {
|
|
901
|
+
if (now - session.messageWindowStart > WS_RATE_WINDOW_MS) {
|
|
902
|
+
session.messageCount = 0;
|
|
903
|
+
session.messageWindowStart = now;
|
|
904
|
+
}
|
|
905
|
+
session.messageCount++;
|
|
906
|
+
if (session.messageCount > WS_RATE_LIMIT) {
|
|
907
|
+
sendError("ERROR", "RATE_LIMITED", "Too many requests. Please slow down.");
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
if (type === "AUTHENTICATE") {
|
|
914
|
+
const { token, impersonate } = payload || {};
|
|
915
|
+
if (!token) {
|
|
916
|
+
sendError("AUTH_ERROR", "INVALID_INPUT", "Token is required");
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
if (impersonate !== void 0 && typeof impersonate !== "string") {
|
|
920
|
+
sendError("AUTH_ERROR", "INVALID_INPUT", "impersonate must be the uid of the user to act as");
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
let resolved;
|
|
924
|
+
try {
|
|
925
|
+
resolved = await resolveIdentity(token, impersonate, impersonate === void 0 ? void 0 : {
|
|
926
|
+
door: "socket",
|
|
927
|
+
clientId
|
|
928
|
+
});
|
|
929
|
+
} catch (error) {
|
|
930
|
+
const answer = impersonate === void 0 ? void 0 : declaredErrorAnswer(error);
|
|
931
|
+
if (answer) {
|
|
932
|
+
sendError("AUTH_ERROR", answer.code, answer.message);
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
if (token.startsWith("rk_") || !authAdapter) throw error;
|
|
936
|
+
resolved = { refused: "Invalid or expired token" };
|
|
937
|
+
}
|
|
938
|
+
if ("refused" in resolved && (resolved.apiKey || resolved.code)) {
|
|
939
|
+
sendError("AUTH_ERROR", resolved.code ?? "INVALID_TOKEN", resolved.refused);
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
const verifiedUser = "user" in resolved ? resolved.user : null;
|
|
943
|
+
if (verifiedUser) {
|
|
944
|
+
const session = clientSessions.get(clientId);
|
|
945
|
+
if (maxSocketsPerUser !== void 0 && session) {
|
|
946
|
+
let held = 0;
|
|
947
|
+
for (const other of clientSessions.values()) if (other !== session && other.user?.uid === verifiedUser.uid) held++;
|
|
948
|
+
if (held >= maxSocketsPerUser) {
|
|
949
|
+
logger.warn(`[WebSocket Server] Refused a socket for ${verifiedUser.uid}: ${held} already open, ceiling ${maxSocketsPerUser}`);
|
|
950
|
+
sendError("ERROR", "TOO_MANY_SOCKETS", `This user already has ${held} realtime connections open on this server, the most it allows (REALTIME_MAX_SOCKETS_PER_USER). Close another tab or client and try again.`);
|
|
951
|
+
ws.close(TOO_MANY_SOCKETS_CLOSE_CODE, "Too many realtime connections for this user");
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
if (session) {
|
|
956
|
+
session.user = verifiedUser;
|
|
957
|
+
session.authenticated = true;
|
|
958
|
+
session.credential = token;
|
|
959
|
+
session.impersonate = impersonate;
|
|
960
|
+
session.checkedAt = Date.now();
|
|
961
|
+
scheduleExpiry(clientId, token);
|
|
962
|
+
await realtimeService.rescopeClient(clientId, sessionAuthContext(session));
|
|
963
|
+
}
|
|
964
|
+
wsDebug(`[WS] replying AUTH_SUCCESS for requestId ${requestId}`);
|
|
965
|
+
ws.send(JSON.stringify({
|
|
966
|
+
type: "AUTH_SUCCESS",
|
|
967
|
+
requestId,
|
|
968
|
+
payload: {
|
|
969
|
+
uid: verifiedUser.uid,
|
|
970
|
+
roles: verifiedUser.roles
|
|
971
|
+
}
|
|
972
|
+
}));
|
|
973
|
+
wsDebug(`🔐 [WebSocket Server] Client ${clientId} authenticated as ${verifiedUser.uid}`);
|
|
974
|
+
} else {
|
|
975
|
+
wsDebug(`[WS] replying AUTH_ERROR for requestId ${requestId} (invalid token)`);
|
|
976
|
+
sendError("AUTH_ERROR", "INVALID_TOKEN", "Invalid or expired token");
|
|
977
|
+
}
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
{
|
|
981
|
+
const session = clientSessions.get(clientId);
|
|
982
|
+
if (session?.user && session.credential && Date.now() - session.checkedAt >= (CHANNEL_MESSAGE_TYPES.has(type) ? CHANNEL_RECHECK_MIN_MS : 0)) {
|
|
983
|
+
const outcome = await recheckIdentityForFrame(clientId);
|
|
984
|
+
if (outcome === "ended") return;
|
|
985
|
+
if (outcome === "unavailable") {
|
|
986
|
+
sendError("ERROR", "ROLE_LOOKUP_FAILED", "Could not verify your permissions. Please try again.");
|
|
987
|
+
return;
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
if (requireAuth) {
|
|
992
|
+
if (!clientSessions.get(clientId)?.authenticated) {
|
|
993
|
+
sendError("ERROR", "UNAUTHORIZED", "Authentication required");
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
const adminScope = ADMIN_PLANE_TYPES.get(type);
|
|
998
|
+
if (adminScope) {
|
|
999
|
+
if (!scopeGrants(sessionScopes(clientSessions.get(clientId), accessModel()), adminScope)) {
|
|
1000
|
+
sendError("ERROR", "SCOPE_MISSING", `This operation needs the "${adminScope}" scope`);
|
|
1001
|
+
return;
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
const narrowedScopes = clientSessions.get(clientId)?.user?.scopes;
|
|
1005
|
+
if (narrowedScopes) {
|
|
1006
|
+
const dataScope = DATA_VERB_SCOPES.get(type);
|
|
1007
|
+
if (dataScope) {
|
|
1008
|
+
const collection = frameCollection(type, payload);
|
|
1009
|
+
if (!scopeGrants(narrowedScopes, dataScope, collection)) {
|
|
1010
|
+
sendError("ERROR", "SCOPE_MISSING", `This API key does not hold the "${collection ? `${dataScope}:${collection}` : dataScope}" scope`);
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
} else if (CHANNEL_MESSAGE_TYPES.has(type)) {
|
|
1014
|
+
sendError("ERROR", "SCOPE_MISSING", "An API key cannot use channels");
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
if (options?.dataRateLimit && (PUBLIC_TYPES.has(type) || FETCHING_REALTIME_TYPES.has(type) && !spendFetchAllowance(clientId))) {
|
|
1019
|
+
const decision = await options.dataRateLimit({
|
|
1020
|
+
...origin,
|
|
1021
|
+
uid: clientSessions.get(clientId)?.user?.uid,
|
|
1022
|
+
apiKey: clientSessions.get(clientId)?.user?.apiKey
|
|
1023
|
+
});
|
|
1024
|
+
if (decision && !decision.allowed) {
|
|
1025
|
+
sendError("ERROR", "RATE_LIMITED", "Too many requests, please try again later.");
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
/**
|
|
1030
|
+
* Apply the REST layer's write checks to a socket payload.
|
|
1031
|
+
*
|
|
1032
|
+
* Silent when the path names no registered collection: the
|
|
1033
|
+
* driver decides what a path means, and refusing here would
|
|
1034
|
+
* turn "unknown collection" into a validation error.
|
|
1035
|
+
*/
|
|
1036
|
+
const assertWriteRequest = (path, values, status) => {
|
|
1037
|
+
if (!path || !values || typeof values !== "object") return;
|
|
1038
|
+
const collection = driver.registry?.getCollectionByPath(path);
|
|
1039
|
+
if (!collection) return;
|
|
1040
|
+
assertWriteRequestValid(values, collection, {
|
|
1041
|
+
viewer: { roles: clientSessions.get(clientId)?.user?.roles ?? ["anon"] },
|
|
1042
|
+
status
|
|
1043
|
+
});
|
|
1044
|
+
assertFieldOpsValid(values, collection);
|
|
1045
|
+
};
|
|
1046
|
+
/**
|
|
1047
|
+
* Apply the REST layer's read check to a socket request: no
|
|
1048
|
+
* filter, sort, projection or uniqueness probe over a field
|
|
1049
|
+
* this caller may not read. The strip keeps the value off the
|
|
1050
|
+
* wire; without this a `COUNT` still answers whether a hidden
|
|
1051
|
+
* value matches a pattern, one character at a time.
|
|
1052
|
+
*
|
|
1053
|
+
* Same viewer as `assertWriteRequest`, and silent in the same
|
|
1054
|
+
* case: a path that names no registered collection is the
|
|
1055
|
+
* driver's to answer.
|
|
1056
|
+
*/
|
|
1057
|
+
const assertReadRequest = (path, request) => {
|
|
1058
|
+
if (!path) return;
|
|
1059
|
+
let collection;
|
|
1060
|
+
try {
|
|
1061
|
+
collection = driver.registry?.getCollectionByPath(path);
|
|
1062
|
+
} catch {
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1065
|
+
if (!collection) return;
|
|
1066
|
+
const session = clientSessions.get(clientId);
|
|
1067
|
+
assertReadRequestReadable(request, collection, { roles: session?.user?.roles ?? ["anon"] });
|
|
1068
|
+
};
|
|
1069
|
+
/**
|
|
1070
|
+
* Refuse a write through a nested path that sets a relation
|
|
1071
|
+
* this caller may not write — the rule REST's nested routes
|
|
1072
|
+
* apply, from the same function. The body is checked by
|
|
1073
|
+
* `assertWriteRequest`; this is what the address writes.
|
|
1074
|
+
*
|
|
1075
|
+
* Silent for a root path, and for a nested one the registry
|
|
1076
|
+
* cannot walk: the driver refuses that with its own message.
|
|
1077
|
+
*/
|
|
1078
|
+
const assertNestedWrite = (path, kind) => {
|
|
1079
|
+
if (typeof path !== "string" || !isNestedPath(path) || !driver.registry) return;
|
|
1080
|
+
let hop;
|
|
1081
|
+
try {
|
|
1082
|
+
hop = resolveNestedPath(path, driver.registry);
|
|
1083
|
+
} catch {
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
if (!hop) return;
|
|
1087
|
+
const session = clientSessions.get(clientId);
|
|
1088
|
+
assertNestedWriteAllowed(hop, kind, path, { roles: session?.user?.roles ?? ["anon"] });
|
|
1089
|
+
};
|
|
1090
|
+
/** The auth collection at `path`, or `undefined` for any other. */
|
|
1091
|
+
const authCollectionAt = (path) => {
|
|
1092
|
+
if (typeof path !== "string") return void 0;
|
|
1093
|
+
let collection;
|
|
1094
|
+
try {
|
|
1095
|
+
collection = driver.registry?.getCollectionByPath(path);
|
|
1096
|
+
} catch {
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
1099
|
+
const auth = collection?.auth;
|
|
1100
|
+
return auth === true || !!auth && typeof auth === "object" && auth.enabled === true ? collection : void 0;
|
|
1101
|
+
};
|
|
1102
|
+
/**
|
|
1103
|
+
* The auth collection's rows are the users, so a write to one
|
|
1104
|
+
* is user administration — the auth adapter's to check, as it
|
|
1105
|
+
* is on the REST routes and `/admin/users`. `undefined` for any
|
|
1106
|
+
* other collection, or an adapter with nothing to say.
|
|
1107
|
+
*/
|
|
1108
|
+
const userAdminFor = (path) => authAdapter && authCollectionAt(path) ? authAdapter : void 0;
|
|
1109
|
+
/**
|
|
1110
|
+
* An upsert's options, under the rules `POST /api/data`
|
|
1111
|
+
* applies to `?on_conflict=`: refused on a nested path,
|
|
1112
|
+
* whose insert branch stamps the parent's key into the
|
|
1113
|
+
* DO UPDATE and so moves the row it matched under this
|
|
1114
|
+
* parent; and a target that is a declared unique key of
|
|
1115
|
+
* the registry's collection, or a 400 naming the ones
|
|
1116
|
+
* there are rather than Postgres's 42P10.
|
|
1117
|
+
*/
|
|
1118
|
+
const upsertOptions = (request) => {
|
|
1119
|
+
if (request.upsert !== true) return {};
|
|
1120
|
+
if (typeof request.path === "string" && isNestedPath(request.path)) throw ApiError.badRequest("An upsert is not accepted on a nested path: a row it matched would be moved under this parent. Send the upsert to the collection directly.", "INVALID_CONFLICT_TARGET");
|
|
1121
|
+
const collection = driver.registry?.getCollectionByPath(request.path);
|
|
1122
|
+
const onConflict = collection ? resolveConflictTarget(request.onConflict, collection, { where: "`onConflict`" }) : void 0;
|
|
1123
|
+
return {
|
|
1124
|
+
upsert: true,
|
|
1125
|
+
...onConflict && { onConflict }
|
|
1126
|
+
};
|
|
1127
|
+
};
|
|
1128
|
+
const getScopedDelegate = async () => {
|
|
1129
|
+
if (typeof driver.withAuth === "function") try {
|
|
1130
|
+
const who = sessionAuthContext(clientSessions.get(clientId));
|
|
1131
|
+
const userForAuth = {
|
|
1132
|
+
uid: who.uid,
|
|
1133
|
+
displayName: null,
|
|
1134
|
+
email: null,
|
|
1135
|
+
photoURL: null,
|
|
1136
|
+
providerId: "websocket",
|
|
1137
|
+
isAnonymous: who.isAnonymous === true,
|
|
1138
|
+
roles: who.roles,
|
|
1139
|
+
...who.claims ? { claims: who.claims } : {}
|
|
1140
|
+
};
|
|
1141
|
+
return await driver.withAuth(userForAuth);
|
|
1142
|
+
} catch (e) {
|
|
1143
|
+
logger.error("Failed to create RLS scoped delegate for WS request", { error: e });
|
|
1144
|
+
throw new Error("Internal authentication error");
|
|
1145
|
+
}
|
|
1146
|
+
return driver;
|
|
1147
|
+
};
|
|
1148
|
+
if (PUBLIC_TYPES.has(type)) assertKnownCollection(driver.registry, type === "DELETE" ? payload?.row?.path : payload?.path);
|
|
1149
|
+
switch (type) {
|
|
1150
|
+
case "FETCH_COLLECTION":
|
|
1151
|
+
{
|
|
1152
|
+
wsDebug("📋 [WebSocket Server] Processing FETCH_COLLECTION request");
|
|
1153
|
+
const request = withDecodedFilter(payload);
|
|
1154
|
+
assertReadRequest(request.path, request);
|
|
1155
|
+
const rows = await (await getScopedDelegate()).fetchCollection({
|
|
1156
|
+
...request,
|
|
1157
|
+
limit: resolveClientListLimit(request.limit, { vectorSearch: !!request.vectorSearch })
|
|
1158
|
+
});
|
|
1159
|
+
wsDebug("📋 [WebSocket Server] FETCH_COLLECTION result - rows count:", rows.length);
|
|
1160
|
+
const response = {
|
|
1161
|
+
type: "FETCH_COLLECTION_SUCCESS",
|
|
1162
|
+
payload: { rows },
|
|
1163
|
+
requestId
|
|
1164
|
+
};
|
|
1165
|
+
wsDebug("📋 [WebSocket Server] Sending FETCH_COLLECTION_SUCCESS response");
|
|
1166
|
+
ws.send(JSON.stringify(response));
|
|
1167
|
+
}
|
|
1168
|
+
break;
|
|
1169
|
+
case "FETCH_ONE":
|
|
1170
|
+
{
|
|
1171
|
+
wsDebug("📄 [WebSocket Server] Processing FETCH_ENTITY request");
|
|
1172
|
+
const request = payload;
|
|
1173
|
+
const row = await (await getScopedDelegate()).fetchOne(request);
|
|
1174
|
+
wsDebug("📄 [WebSocket Server] FETCH_ENTITY result:", row);
|
|
1175
|
+
const response = {
|
|
1176
|
+
type: "FETCH_ONE_SUCCESS",
|
|
1177
|
+
payload: { row: row ?? null },
|
|
1178
|
+
requestId
|
|
1179
|
+
};
|
|
1180
|
+
wsDebug("📄 [WebSocket Server] Sending FETCH_ENTITY_SUCCESS response");
|
|
1181
|
+
ws.send(JSON.stringify(response));
|
|
1182
|
+
}
|
|
1183
|
+
break;
|
|
1184
|
+
case "SAVE":
|
|
1185
|
+
{
|
|
1186
|
+
wsDebug("💾 [WebSocket Server] Processing SAVE_ENTITY request");
|
|
1187
|
+
const request = payload;
|
|
1188
|
+
wsDebug("💾 [WebSocket Server] Saving row with request:", inspect(request, {
|
|
1189
|
+
depth: null,
|
|
1190
|
+
colors: true
|
|
1191
|
+
}));
|
|
1192
|
+
const upsertedUsers = request.upsert === true ? authCollectionAt(request.path) : void 0;
|
|
1193
|
+
if (upsertedUsers) assertNoUserUpsert(authAdapter, upsertedUsers);
|
|
1194
|
+
const usersCollection = request.status !== "existing" && request.upsert !== true && authAdapter?.prepareUserCreation ? authCollectionAt(request.path) : void 0;
|
|
1195
|
+
const caller = { roles: clientSessions.get(clientId)?.user?.roles ?? ["anon"] };
|
|
1196
|
+
if (usersCollection) {
|
|
1197
|
+
const body = request.values ?? {};
|
|
1198
|
+
assertUserCreationBodyValid(authAdapter, usersCollection, body, caller);
|
|
1199
|
+
assertNoFieldOpsOnCreate(body, "A create", usersCollection);
|
|
1200
|
+
} else assertWriteRequest(request.path, request.values, request.status);
|
|
1201
|
+
assertNestedWrite(request.path, request.id === void 0 || request.status !== "existing" ? "create" : "update");
|
|
1202
|
+
const delegate = await getScopedDelegate();
|
|
1203
|
+
const userAdmin = request.status === "existing" && request.id !== void 0 ? userAdminFor(request.path) : void 0;
|
|
1204
|
+
const [values] = userAdmin?.prepareUserUpdates ? await userAdmin.prepareUserUpdates([{
|
|
1205
|
+
uid: String(request.id),
|
|
1206
|
+
values: request.values
|
|
1207
|
+
}]) : [request.values];
|
|
1208
|
+
const write = (toWrite) => delegate.save({
|
|
1209
|
+
path: request.path,
|
|
1210
|
+
...request.id !== void 0 && { id: request.id },
|
|
1211
|
+
values: toWrite,
|
|
1212
|
+
status: request.status,
|
|
1213
|
+
...upsertOptions(request)
|
|
1214
|
+
});
|
|
1215
|
+
const created = usersCollection ? await createUserThroughAuthCollection(authAdapter, usersCollection, values ?? {}, caller, write) : void 0;
|
|
1216
|
+
const row = created ? {
|
|
1217
|
+
...created.row,
|
|
1218
|
+
...created.delivery
|
|
1219
|
+
} : await write(values);
|
|
1220
|
+
wsDebug("💾 [WebSocket Server] SAVE_ENTITY result:", inspect(row, {
|
|
1221
|
+
depth: null,
|
|
1222
|
+
colors: true
|
|
1223
|
+
}));
|
|
1224
|
+
const response = {
|
|
1225
|
+
type: "SAVE_SUCCESS",
|
|
1226
|
+
payload: { row },
|
|
1227
|
+
requestId
|
|
1228
|
+
};
|
|
1229
|
+
wsDebug("💾 [WebSocket Server] Sending SAVE_ENTITY_SUCCESS response");
|
|
1230
|
+
ws.send(JSON.stringify(response));
|
|
1231
|
+
}
|
|
1232
|
+
break;
|
|
1233
|
+
case "DELETE":
|
|
1234
|
+
{
|
|
1235
|
+
wsDebug("🗑️ [WebSocket Server] Processing DELETE_ENTITY request");
|
|
1236
|
+
const request = payload;
|
|
1237
|
+
wsDebug("🗑️ [WebSocket Server] Deleting row:", request.row);
|
|
1238
|
+
assertNestedWrite(request.row?.path, "unlink");
|
|
1239
|
+
const delegate = await getScopedDelegate();
|
|
1240
|
+
const userAdmin = userAdminFor(request.row?.path);
|
|
1241
|
+
const uids = [String(request.row.id)];
|
|
1242
|
+
await userAdmin?.prepareUserDeletions?.(uids);
|
|
1243
|
+
await delegate.delete({
|
|
1244
|
+
row: {
|
|
1245
|
+
id: request.row.id,
|
|
1246
|
+
path: request.row.path
|
|
1247
|
+
},
|
|
1248
|
+
hard: request.hard
|
|
1249
|
+
});
|
|
1250
|
+
await userAdmin?.finalizeUserDeletions?.(uids);
|
|
1251
|
+
wsDebug("🗑️ [WebSocket Server] DELETE_ENTITY completed successfully");
|
|
1252
|
+
const response = {
|
|
1253
|
+
type: "DELETE_SUCCESS",
|
|
1254
|
+
payload: { success: true },
|
|
1255
|
+
requestId
|
|
1256
|
+
};
|
|
1257
|
+
wsDebug("🗑️ [WebSocket Server] Sending DELETE_ENTITY_SUCCESS response");
|
|
1258
|
+
ws.send(JSON.stringify(response));
|
|
1259
|
+
}
|
|
1260
|
+
break;
|
|
1261
|
+
case "CHECK_UNIQUE_FIELD":
|
|
1262
|
+
{
|
|
1263
|
+
wsDebug("🔍 [WebSocket Server] Processing CHECK_UNIQUE_FIELD request");
|
|
1264
|
+
const { path, name, value, id, collection } = payload;
|
|
1265
|
+
if (typeof name === "string") assertReadRequest(path, { filter: { [name]: ["==", value] } });
|
|
1266
|
+
const isUnique = await (await getScopedDelegate()).checkUniqueField(path, name, value, id, collection);
|
|
1267
|
+
wsDebug("🔍 [WebSocket Server] CHECK_UNIQUE_FIELD result:", isUnique);
|
|
1268
|
+
const response = {
|
|
1269
|
+
type: "CHECK_UNIQUE_FIELD_SUCCESS",
|
|
1270
|
+
payload: { isUnique },
|
|
1271
|
+
requestId
|
|
1272
|
+
};
|
|
1273
|
+
wsDebug("🔍 [WebSocket Server] Sending CHECK_UNIQUE_FIELD_SUCCESS response");
|
|
1274
|
+
ws.send(JSON.stringify(response));
|
|
1275
|
+
}
|
|
1276
|
+
break;
|
|
1277
|
+
case "COUNT":
|
|
1278
|
+
{
|
|
1279
|
+
const request = withDecodedFilter(payload);
|
|
1280
|
+
assertReadRequest(request.path, request);
|
|
1281
|
+
const response = {
|
|
1282
|
+
type: "COUNT_SUCCESS",
|
|
1283
|
+
payload: { count: await (await getScopedDelegate()).count(request) },
|
|
1284
|
+
requestId
|
|
1285
|
+
};
|
|
1286
|
+
ws.send(JSON.stringify(response));
|
|
1287
|
+
}
|
|
1288
|
+
break;
|
|
1289
|
+
case "EXECUTE_SQL":
|
|
1290
|
+
{
|
|
1291
|
+
const { sql, options, mode } = payload;
|
|
1292
|
+
const cancel = mode === "script" && typeof requestId === "string" && !sqlRuns.has(requestId) ? new AbortController() : void 0;
|
|
1293
|
+
if (cancel && requestId) sqlRuns.set(requestId, cancel);
|
|
1294
|
+
try {
|
|
1295
|
+
const admin = (await getScopedDelegate()).admin;
|
|
1296
|
+
if (!isSQLAdmin(admin)) {
|
|
1297
|
+
sendError("ERROR", "NOT_SUPPORTED", "SQL execution is not available for this driver.");
|
|
1298
|
+
break;
|
|
1299
|
+
}
|
|
1300
|
+
const script = mode === "script" && typeof admin.runSqlScript === "function" ? await admin.runSqlScript(String(sql), {
|
|
1301
|
+
database: typeof options?.database === "string" ? options.database : void 0,
|
|
1302
|
+
role: typeof options?.role === "string" ? options.role : void 0,
|
|
1303
|
+
...cancel ? { signal: cancel.signal } : {}
|
|
1304
|
+
}) : void 0;
|
|
1305
|
+
const result = script ? script.rows : await admin.executeSql(sql, {
|
|
1306
|
+
...options,
|
|
1307
|
+
isolateSession: true,
|
|
1308
|
+
maxResultBytes: SQL_RESULT_BYTE_BUDGET
|
|
1309
|
+
});
|
|
1310
|
+
const ranAs = typeof options?.role === "string" && options.role ? await sqlRoleRanAs(admin, options.role, typeof options?.database === "string" ? options.database : void 0) : void 0;
|
|
1311
|
+
if (process.env.NODE_ENV !== "production") wsDebug(`⚡ [WebSocket Server] SQL executed. Returned ${Array.isArray(result) ? result.length : "non-array"} rows.`);
|
|
1312
|
+
const auditSession = clientSessions.get(clientId);
|
|
1313
|
+
logger.info("[SQL Audit] WebSocket SQL execution", {
|
|
1314
|
+
sql: redactSqlLiterals(String(sql)).substring(0, 500),
|
|
1315
|
+
database: options?.database,
|
|
1316
|
+
role: options?.role,
|
|
1317
|
+
effectiveRole: effectiveSqlRole(options?.role),
|
|
1318
|
+
paramCount: Array.isArray(options?.params) ? options.params.length : 0,
|
|
1319
|
+
resultRows: Array.isArray(result) ? result.length : "unknown",
|
|
1320
|
+
uid: auditSession?.user?.uid ?? "unknown",
|
|
1321
|
+
roles: auditSession?.user?.roles ?? [],
|
|
1322
|
+
isAdmin: auditSession?.user?.isAdmin ?? false,
|
|
1323
|
+
...auditSession?.user?.impersonator ? { impersonatedBy: auditSession.user.impersonator } : {},
|
|
1324
|
+
requestId
|
|
1325
|
+
});
|
|
1326
|
+
const response = {
|
|
1327
|
+
type: "EXECUTE_SQL_SUCCESS",
|
|
1328
|
+
payload: {
|
|
1329
|
+
...script ? {
|
|
1330
|
+
...scriptDescription(script),
|
|
1331
|
+
result
|
|
1332
|
+
} : { result },
|
|
1333
|
+
...ranAs ? { effectiveRole: ranAs } : {}
|
|
1334
|
+
},
|
|
1335
|
+
requestId
|
|
1336
|
+
};
|
|
1337
|
+
ws.send(JSON.stringify(response));
|
|
1338
|
+
} catch (sqlError) {
|
|
1339
|
+
if (sqlError instanceof SqlResultTooLargeError) {
|
|
1340
|
+
ws.send(JSON.stringify({
|
|
1341
|
+
type: "ERROR",
|
|
1342
|
+
requestId,
|
|
1343
|
+
payload: { error: {
|
|
1344
|
+
message: sqlError.message,
|
|
1345
|
+
code: "RESULT_TOO_LARGE"
|
|
1346
|
+
} }
|
|
1347
|
+
}));
|
|
1348
|
+
break;
|
|
1349
|
+
}
|
|
1350
|
+
sendError("ERROR", "SQL_ERROR", extractErrorMessage(sqlError));
|
|
1351
|
+
} finally {
|
|
1352
|
+
if (cancel && requestId && sqlRuns.get(requestId) === cancel) sqlRuns.delete(requestId);
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
break;
|
|
1356
|
+
case "CANCEL_SQL":
|
|
1357
|
+
{
|
|
1358
|
+
const target = payload?.requestId;
|
|
1359
|
+
const run = typeof target === "string" ? sqlRuns.get(target) : void 0;
|
|
1360
|
+
run?.abort();
|
|
1361
|
+
ws.send(JSON.stringify({
|
|
1362
|
+
type: "CANCEL_SQL_SUCCESS",
|
|
1363
|
+
requestId,
|
|
1364
|
+
payload: { cancelled: run !== void 0 }
|
|
1365
|
+
}));
|
|
1366
|
+
}
|
|
1367
|
+
break;
|
|
1368
|
+
case "FETCH_DATABASES":
|
|
1369
|
+
{
|
|
1370
|
+
wsDebug("📚 [WebSocket Server] Processing FETCH_DATABASES request");
|
|
1371
|
+
const admin = (await getScopedDelegate()).admin;
|
|
1372
|
+
let databases = [];
|
|
1373
|
+
if (isSQLAdmin(admin) && admin.fetchAvailableDatabases) databases = await admin.fetchAvailableDatabases();
|
|
1374
|
+
wsDebug(`📚 [WebSocket Server] Fetched ${databases.length} databases.`);
|
|
1375
|
+
const response = {
|
|
1376
|
+
type: "FETCH_DATABASES_SUCCESS",
|
|
1377
|
+
payload: { databases },
|
|
1378
|
+
requestId
|
|
1379
|
+
};
|
|
1380
|
+
ws.send(JSON.stringify(response));
|
|
1381
|
+
}
|
|
1382
|
+
break;
|
|
1383
|
+
case "FETCH_ROLES":
|
|
1384
|
+
{
|
|
1385
|
+
wsDebug("👤 [WebSocket Server] Processing FETCH_ROLES request");
|
|
1386
|
+
const admin = (await getScopedDelegate()).admin;
|
|
1387
|
+
let roles = [];
|
|
1388
|
+
if (isSQLAdmin(admin) && admin.fetchAvailableRoles) roles = await admin.fetchAvailableRoles();
|
|
1389
|
+
wsDebug(`👤 [WebSocket Server] Fetched ${roles.length} roles.`);
|
|
1390
|
+
const response = {
|
|
1391
|
+
type: "FETCH_ROLES_SUCCESS",
|
|
1392
|
+
payload: { roles },
|
|
1393
|
+
requestId
|
|
1394
|
+
};
|
|
1395
|
+
ws.send(JSON.stringify(response));
|
|
1396
|
+
}
|
|
1397
|
+
break;
|
|
1398
|
+
case "FETCH_APPLICATION_ROLES":
|
|
1399
|
+
{
|
|
1400
|
+
wsDebug("👤 [WebSocket Server] Processing FETCH_APPLICATION_ROLES request");
|
|
1401
|
+
const admin = (await getScopedDelegate()).admin;
|
|
1402
|
+
let roles = [];
|
|
1403
|
+
if (isSQLAdmin(admin) && admin.fetchApplicationRoles) roles = await admin.fetchApplicationRoles();
|
|
1404
|
+
wsDebug(`👤 [WebSocket Server] Fetched ${roles.length} application roles.`);
|
|
1405
|
+
const response = {
|
|
1406
|
+
type: "FETCH_APPLICATION_ROLES_SUCCESS",
|
|
1407
|
+
payload: { roles },
|
|
1408
|
+
requestId
|
|
1409
|
+
};
|
|
1410
|
+
ws.send(JSON.stringify(response));
|
|
1411
|
+
}
|
|
1412
|
+
break;
|
|
1413
|
+
case "FETCH_CURRENT_DATABASE":
|
|
1414
|
+
{
|
|
1415
|
+
wsDebug("📚 [WebSocket Server] Processing FETCH_CURRENT_DATABASE request");
|
|
1416
|
+
const admin = (await getScopedDelegate()).admin;
|
|
1417
|
+
let database = void 0;
|
|
1418
|
+
if (isSQLAdmin(admin) && admin.fetchCurrentDatabase) database = await admin.fetchCurrentDatabase();
|
|
1419
|
+
const response = {
|
|
1420
|
+
type: "FETCH_CURRENT_DATABASE_SUCCESS",
|
|
1421
|
+
payload: { database },
|
|
1422
|
+
requestId
|
|
1423
|
+
};
|
|
1424
|
+
ws.send(JSON.stringify(response));
|
|
1425
|
+
}
|
|
1426
|
+
break;
|
|
1427
|
+
case "FETCH_UNMAPPED_TABLES":
|
|
1428
|
+
{
|
|
1429
|
+
wsDebug("📋 [WebSocket Server] Processing FETCH_UNMAPPED_TABLES request");
|
|
1430
|
+
const admin = (await getScopedDelegate()).admin;
|
|
1431
|
+
let tables = [];
|
|
1432
|
+
if (isSchemaAdmin(admin) && admin.fetchUnmappedTables) tables = await admin.fetchUnmappedTables(payload?.mappedPaths);
|
|
1433
|
+
wsDebug(`📋 [WebSocket Server] Fetched ${tables.length} unmapped tables.`);
|
|
1434
|
+
const response = {
|
|
1435
|
+
type: "FETCH_UNMAPPED_TABLES_SUCCESS",
|
|
1436
|
+
payload: { tables },
|
|
1437
|
+
requestId
|
|
1438
|
+
};
|
|
1439
|
+
ws.send(JSON.stringify(response));
|
|
1440
|
+
}
|
|
1441
|
+
break;
|
|
1442
|
+
case "FETCH_TABLE_METADATA":
|
|
1443
|
+
{
|
|
1444
|
+
wsDebug("📋 [WebSocket Server] Processing FETCH_TABLE_METADATA request");
|
|
1445
|
+
const { tableName } = payload;
|
|
1446
|
+
const admin = (await getScopedDelegate()).admin;
|
|
1447
|
+
let metadata;
|
|
1448
|
+
if (isSchemaAdmin(admin) && admin.fetchTableMetadata) metadata = await admin.fetchTableMetadata(tableName);
|
|
1449
|
+
wsDebug(`📋 [WebSocket Server] Fetched metadata for table '${tableName}'. (${metadata?.columns?.length ?? 0} columns)`);
|
|
1450
|
+
const response = {
|
|
1451
|
+
type: "FETCH_TABLE_METADATA_SUCCESS",
|
|
1452
|
+
payload: { metadata },
|
|
1453
|
+
requestId
|
|
1454
|
+
};
|
|
1455
|
+
ws.send(JSON.stringify(response));
|
|
1456
|
+
}
|
|
1457
|
+
break;
|
|
1458
|
+
case "CREATE_BRANCH":
|
|
1459
|
+
{
|
|
1460
|
+
wsDebug("🌿 [WebSocket Server] Processing CREATE_BRANCH request");
|
|
1461
|
+
const { name, options } = payload;
|
|
1462
|
+
const delegate = await getScopedDelegate();
|
|
1463
|
+
if (!delegate.admin?.createBranch) {
|
|
1464
|
+
sendError("ERROR", "NOT_SUPPORTED", "Database branching is not available. Configure adminConnectionString.");
|
|
1465
|
+
break;
|
|
1466
|
+
}
|
|
1467
|
+
const branch = await delegate.admin.createBranch(name, options);
|
|
1468
|
+
wsDebug(`🌿 [WebSocket Server] Branch created: ${branch.name}`);
|
|
1469
|
+
const response = {
|
|
1470
|
+
type: "CREATE_BRANCH_SUCCESS",
|
|
1471
|
+
payload: { branch },
|
|
1472
|
+
requestId
|
|
1473
|
+
};
|
|
1474
|
+
ws.send(JSON.stringify(response));
|
|
1475
|
+
}
|
|
1476
|
+
break;
|
|
1477
|
+
case "DELETE_BRANCH":
|
|
1478
|
+
{
|
|
1479
|
+
wsDebug("🗑️ [WebSocket Server] Processing DELETE_BRANCH request");
|
|
1480
|
+
const { name: branchName } = payload;
|
|
1481
|
+
const delegate = await getScopedDelegate();
|
|
1482
|
+
if (!delegate.admin?.deleteBranch) {
|
|
1483
|
+
sendError("ERROR", "NOT_SUPPORTED", "Database branching is not available.");
|
|
1484
|
+
break;
|
|
1485
|
+
}
|
|
1486
|
+
await delegate.admin.deleteBranch(branchName);
|
|
1487
|
+
wsDebug(`🗑️ [WebSocket Server] Branch deleted: ${branchName}`);
|
|
1488
|
+
const response = {
|
|
1489
|
+
type: "DELETE_BRANCH_SUCCESS",
|
|
1490
|
+
payload: { success: true },
|
|
1491
|
+
requestId
|
|
1492
|
+
};
|
|
1493
|
+
ws.send(JSON.stringify(response));
|
|
1494
|
+
}
|
|
1495
|
+
break;
|
|
1496
|
+
case "LIST_BRANCHES":
|
|
1497
|
+
{
|
|
1498
|
+
wsDebug("🌿 [WebSocket Server] Processing LIST_BRANCHES request");
|
|
1499
|
+
const delegate = await getScopedDelegate();
|
|
1500
|
+
let branches = [];
|
|
1501
|
+
if (delegate.admin?.listBranches) branches = await delegate.admin.listBranches();
|
|
1502
|
+
wsDebug(`🌿 [WebSocket Server] Listed ${branches.length} branches.`);
|
|
1503
|
+
const response = {
|
|
1504
|
+
type: "LIST_BRANCHES_SUCCESS",
|
|
1505
|
+
payload: { branches },
|
|
1506
|
+
requestId
|
|
1507
|
+
};
|
|
1508
|
+
ws.send(JSON.stringify(response));
|
|
1509
|
+
}
|
|
1510
|
+
break;
|
|
1511
|
+
case "subscribe_collection":
|
|
1512
|
+
case "subscribe_one":
|
|
1513
|
+
case "unsubscribe":
|
|
1514
|
+
case "join_channel":
|
|
1515
|
+
case "leave_channel":
|
|
1516
|
+
case "broadcast":
|
|
1517
|
+
case "presence_track":
|
|
1518
|
+
case "presence_untrack":
|
|
1519
|
+
case "presence_state":
|
|
1520
|
+
case "channel_history": {
|
|
1521
|
+
wsDebug("🔄 [WebSocket Server] Routing realtime message to RealtimeService:", type);
|
|
1522
|
+
const authContext = sessionAuthContext(clientSessions.get(clientId));
|
|
1523
|
+
await realtimeService.handleClientMessage(clientId, {
|
|
1524
|
+
type,
|
|
1525
|
+
payload,
|
|
1526
|
+
subscriptionId: payload?.subscriptionId
|
|
1527
|
+
}, authContext);
|
|
1528
|
+
break;
|
|
1529
|
+
}
|
|
1530
|
+
default:
|
|
1531
|
+
logger.warn("[WebSocket Server] Unknown message type", { detail: type });
|
|
1532
|
+
throw ApiError.badRequest(`Unknown message type '${String(type)}'.`, "UNKNOWN_MESSAGE_TYPE");
|
|
1533
|
+
}
|
|
1534
|
+
} catch (error) {
|
|
1535
|
+
if (error instanceof ListLimitError) {
|
|
1536
|
+
logger.warn(`[WebSocket Server] Refused a list read: ${error.message}`);
|
|
1537
|
+
ws.send(JSON.stringify({
|
|
1538
|
+
type: "ERROR",
|
|
1539
|
+
requestId,
|
|
1540
|
+
payload: { error: {
|
|
1541
|
+
message: error.message,
|
|
1542
|
+
code: "INVALID_LIMIT"
|
|
1543
|
+
} }
|
|
1544
|
+
}));
|
|
1545
|
+
return;
|
|
1546
|
+
}
|
|
1547
|
+
if (error instanceof BranchingUnsupportedError || error?.name === "BranchingUnsupportedError") {
|
|
1548
|
+
const refusal = error;
|
|
1549
|
+
logger.warn(`[WebSocket Server] Refused a branch operation: ${refusal.message}`);
|
|
1550
|
+
ws.send(JSON.stringify({
|
|
1551
|
+
type: "ERROR",
|
|
1552
|
+
requestId,
|
|
1553
|
+
payload: { error: {
|
|
1554
|
+
message: refusal.message,
|
|
1555
|
+
code: "BRANCHING_UNSUPPORTED"
|
|
1556
|
+
} }
|
|
1557
|
+
}));
|
|
1558
|
+
return;
|
|
1559
|
+
}
|
|
1560
|
+
const answer = declaredErrorAnswer(error);
|
|
1561
|
+
if (answer) {
|
|
1562
|
+
const line = `[WebSocket Server] Refused: ${answer.code} ${answer.message}`;
|
|
1563
|
+
if (answer.expected) logger.debug(line);
|
|
1564
|
+
else logger.warn(line);
|
|
1565
|
+
ws.send(JSON.stringify({
|
|
1566
|
+
type: "ERROR",
|
|
1567
|
+
requestId,
|
|
1568
|
+
payload: { error: {
|
|
1569
|
+
message: answer.message,
|
|
1570
|
+
code: answer.code,
|
|
1571
|
+
...answer.details !== void 0 && { details: answer.details }
|
|
1572
|
+
} }
|
|
1573
|
+
}));
|
|
1574
|
+
return;
|
|
1575
|
+
}
|
|
1576
|
+
logger.error("💥 [WebSocket Server] Error handling message", { error });
|
|
1577
|
+
if (error instanceof Error) logger.error("Stack trace", { detail: error.stack });
|
|
1578
|
+
const errorMessage = process.env.NODE_ENV === "production" ? "An unexpected error occurred" : extractErrorMessage(error);
|
|
1579
|
+
const errorResponse = {
|
|
1580
|
+
type: "ERROR",
|
|
1581
|
+
requestId,
|
|
1582
|
+
payload: { error: {
|
|
1583
|
+
message: errorMessage,
|
|
1584
|
+
code: "INTERNAL_ERROR"
|
|
1585
|
+
} }
|
|
1586
|
+
};
|
|
1587
|
+
ws.send(JSON.stringify(errorResponse));
|
|
1588
|
+
}
|
|
1589
|
+
});
|
|
1590
|
+
});
|
|
1591
|
+
const stopReaping = reapSilentSockets(wss);
|
|
1592
|
+
wss.on("close", stopReaping);
|
|
1593
|
+
if (typeof server.on === "function") server.on("close", stopReaping);
|
|
1594
|
+
const stopBudgeting = budgetSendBuffers(wss);
|
|
1595
|
+
wss.on("close", stopBudgeting);
|
|
1596
|
+
if (typeof server.on === "function") server.on("close", stopBudgeting);
|
|
1597
|
+
const stopHeartbeats = startHeartbeats(() => heartbeatSockets, heartbeatIntervalMs);
|
|
1598
|
+
wss.on("close", stopHeartbeats);
|
|
1599
|
+
if (typeof server.on === "function") server.on("close", stopHeartbeats);
|
|
1600
|
+
}
|
|
1601
|
+
//#endregion
|
|
1602
|
+
export { createPostgresWebSocket as a, websocket_exports as c, TOO_MANY_SOCKETS_CLOSE_CODE as i, PUBLIC_TYPES as n, isSocketRealtimeService as o, SESSION_ENDED_CLOSE_CODE as r, socketRealtimeMissing as s, ADMIN_PLANE_TYPES as t };
|
|
1603
|
+
|
|
1604
|
+
//# sourceMappingURL=websocket-BLGc5yhs.js.map
|