@rebasepro/server-postgres 0.9.1-canary.a639738 → 0.9.1-canary.a8dbf1c
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 +21 -0
- package/dist/PostgresBackendDriver.d.ts +18 -0
- package/dist/PostgresBootstrapper.d.ts +7 -1
- package/dist/auth/services.d.ts +53 -53
- package/dist/index.es.js +708 -191
- package/dist/index.es.js.map +1 -1
- package/dist/schema/auth-bootstrap-sql.d.ts +1 -1
- package/dist/schema/auth-schema.d.ts +24 -24
- package/dist/schema/introspect-db-logic.d.ts +0 -5
- package/dist/schema/introspect-db-naming.d.ts +10 -0
- package/dist/security/rls-enforcement.d.ts +2 -2
- package/dist/services/channel-history.d.ts +118 -0
- package/dist/services/realtimeService.d.ts +69 -2
- package/package.json +7 -31
- package/src/PostgresBackendDriver.ts +56 -5
- package/src/PostgresBootstrapper.ts +18 -1
- package/src/auth/ensure-tables.ts +97 -17
- package/src/auth/services.ts +134 -133
- package/src/schema/auth-bootstrap-sql.ts +7 -1
- package/src/schema/auth-schema.ts +13 -13
- package/src/schema/introspect-db-inference.ts +1 -1
- package/src/schema/introspect-db-logic.ts +1 -10
- package/src/schema/introspect-db-naming.ts +15 -0
- package/src/schema/introspect-runtime.ts +1 -1
- package/src/security/rls-enforcement.ts +11 -5
- package/src/services/channel-history.ts +343 -0
- package/src/services/realtimeService.ts +198 -10
- package/src/websocket.ts +30 -11
|
@@ -15,6 +15,8 @@ import { logger } from "@rebasepro/server";
|
|
|
15
15
|
import { sanitizeErrorForClient } from "../utils/pg-error-utils";
|
|
16
16
|
import { CdcListener, type CdcChangeEvent } from "./cdc/CdcListener";
|
|
17
17
|
import { deriveRowAddress, getPrimaryKeys, type PrimaryKeyInfo } from "./collection-helpers";
|
|
18
|
+
import { ChannelHistoryStore, type ResolvedRetention } from "./channel-history";
|
|
19
|
+
import type { ChannelHistoryEntry, ChannelRetentionRule } from "@rebasepro/types";
|
|
18
20
|
|
|
19
21
|
/** Channel name used for Postgres LISTEN/NOTIFY cross-instance realtime. */
|
|
20
22
|
const PG_NOTIFY_CHANNEL = "rebase_entity_changes";
|
|
@@ -24,7 +26,7 @@ const PG_NOTIFY_CHANNEL = "rebase_entity_changes";
|
|
|
24
26
|
* Mirrors the session variables set by PostgresBackendDriver.withAuth().
|
|
25
27
|
*/
|
|
26
28
|
export interface SubscriptionAuthContext {
|
|
27
|
-
|
|
29
|
+
uid: string;
|
|
28
30
|
roles: string[];
|
|
29
31
|
}
|
|
30
32
|
|
|
@@ -52,6 +54,28 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
52
54
|
|
|
53
55
|
// Presence: channel → Map<clientId, { state, lastSeen }>
|
|
54
56
|
private presence = new Map<string, Map<string, { state: Record<string, unknown>; lastSeen: number }>>();
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Ordered, replayable history for channels that opt into it.
|
|
60
|
+
*
|
|
61
|
+
* Undefined until {@link configureChannelHistory} is called, and inert even
|
|
62
|
+
* then unless retention rules were supplied — so presence and ephemeral
|
|
63
|
+
* notification channels never touch it. See `channel-history.ts`.
|
|
64
|
+
*/
|
|
65
|
+
private channelHistory?: ChannelHistoryStore;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* One promise chain per retained channel, so that assigning a sequence
|
|
69
|
+
* number and fanning the message out happen in the same order for every
|
|
70
|
+
* message on that channel.
|
|
71
|
+
*
|
|
72
|
+
* Without it, two concurrent broadcasts can be numbered 4 and 5 by the
|
|
73
|
+
* database and still reach subscribers as 5 then 4 — live order and replay
|
|
74
|
+
* order would disagree, which is exactly the divergence sequence numbers
|
|
75
|
+
* are supposed to rule out. Keyed by channel, so unrelated channels never
|
|
76
|
+
* wait on each other.
|
|
77
|
+
*/
|
|
78
|
+
private channelSendQueues = new Map<string, Promise<void>>();
|
|
55
79
|
private presenceInterval?: ReturnType<typeof setInterval>;
|
|
56
80
|
private static readonly PRESENCE_TIMEOUT_MS = 30000; // 30s
|
|
57
81
|
private dataService: DataService;
|
|
@@ -73,7 +97,7 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
73
97
|
searchString?: string;
|
|
74
98
|
};
|
|
75
99
|
// Auth context for RLS — when set, refetches run in a transaction
|
|
76
|
-
// with set_config('app.
|
|
100
|
+
// with set_config('app.uid', ...) / set_config('app.user_roles', ...)
|
|
77
101
|
authContext?: SubscriptionAuthContext;
|
|
78
102
|
}>();
|
|
79
103
|
|
|
@@ -322,6 +346,14 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
322
346
|
payload?.payload
|
|
323
347
|
);
|
|
324
348
|
break;
|
|
349
|
+
case "channel_history":
|
|
350
|
+
await this.handleChannelHistoryRequest(
|
|
351
|
+
clientId,
|
|
352
|
+
payload?.channel as string,
|
|
353
|
+
payload?.sinceSeq as number | undefined,
|
|
354
|
+
payload?.limit as number | undefined
|
|
355
|
+
);
|
|
356
|
+
break;
|
|
325
357
|
|
|
326
358
|
// ── Presence ──
|
|
327
359
|
case "presence_track":
|
|
@@ -669,10 +701,10 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
669
701
|
// Always wrap in a transaction with session vars, defaulting to anonymous context if missing.
|
|
670
702
|
// Refetches are reads: apply the same GUCs + reader-role downgrade as the
|
|
671
703
|
// driver's read path, so realtime cannot leak rows the initial fetch hid.
|
|
672
|
-
const activeAuth = authContext || {
|
|
704
|
+
const activeAuth = authContext || { uid: "anon",
|
|
673
705
|
roles: ["anon"] };
|
|
674
706
|
return await this.db.transaction(async (tx) => {
|
|
675
|
-
await applyAuthContext(tx, {
|
|
707
|
+
await applyAuthContext(tx, { uid: activeAuth.uid, roles: activeAuth.roles }, this.rlsUserRole);
|
|
676
708
|
const txEntityService = new DataService(tx, this.registry);
|
|
677
709
|
let fetchedEntities;
|
|
678
710
|
if (collectionRequest.searchString) {
|
|
@@ -711,7 +743,7 @@ roles: ["anon"] };
|
|
|
711
743
|
|
|
712
744
|
if (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead) {
|
|
713
745
|
const contextForCallback = {
|
|
714
|
-
user: { uid: activeAuth.
|
|
746
|
+
user: { uid: activeAuth.uid,
|
|
715
747
|
roles: activeAuth.roles },
|
|
716
748
|
driver: this.driver,
|
|
717
749
|
data: (this.driver && "data" in this.driver) ? (this.driver as DataDriverWithData).data : undefined
|
|
@@ -849,10 +881,10 @@ roles: activeAuth.roles },
|
|
|
849
881
|
|
|
850
882
|
// Always wrap in a transaction with session vars, defaulting to anonymous context if missing.
|
|
851
883
|
// Same read isolation as collection refetches: GUCs + reader-role downgrade.
|
|
852
|
-
const activeAuth = authContext || {
|
|
884
|
+
const activeAuth = authContext || { uid: "anon",
|
|
853
885
|
roles: ["anon"] };
|
|
854
886
|
return await this.db.transaction(async (tx) => {
|
|
855
|
-
await applyAuthContext(tx, {
|
|
887
|
+
await applyAuthContext(tx, { uid: activeAuth.uid, roles: activeAuth.roles }, this.rlsUserRole);
|
|
856
888
|
const txEntityService = new DataService(tx, this.registry);
|
|
857
889
|
let processedEntity = await txEntityService.fetchOne(notifyPath, id, collection?.databaseId);
|
|
858
890
|
|
|
@@ -867,7 +899,7 @@ roles: ["anon"] };
|
|
|
867
899
|
|
|
868
900
|
if (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead) {
|
|
869
901
|
const contextForCallback = {
|
|
870
|
-
user: { uid: activeAuth.
|
|
902
|
+
user: { uid: activeAuth.uid,
|
|
871
903
|
roles: activeAuth.roles },
|
|
872
904
|
driver: this.driver,
|
|
873
905
|
data: (this.driver && "data" in this.driver) ? (this.driver as DataDriverWithData).data : undefined
|
|
@@ -1039,8 +1071,85 @@ roles: activeAuth.roles },
|
|
|
1039
1071
|
this.removePresence(clientId, channel);
|
|
1040
1072
|
}
|
|
1041
1073
|
|
|
1042
|
-
/**
|
|
1074
|
+
/**
|
|
1075
|
+
* Broadcast a message to all clients in a channel except the sender.
|
|
1076
|
+
*
|
|
1077
|
+
* On a channel with no retention rule this is what it always was: a
|
|
1078
|
+
* synchronous fan-out to whoever is connected, with no sequence number, no
|
|
1079
|
+
* SQL and no await — the body below runs to completion before returning.
|
|
1080
|
+
*
|
|
1081
|
+
* On a retained channel the message is durably numbered first and only then
|
|
1082
|
+
* delivered, through a per-channel queue so that delivery order matches
|
|
1083
|
+
* sequence order. That ordering is the whole point: a client that catches up
|
|
1084
|
+
* with `sinceSeq` has to arrive at the same state as one that never
|
|
1085
|
+
* disconnected.
|
|
1086
|
+
*/
|
|
1043
1087
|
broadcastToChannel(clientId: string, channel: string, event: string, payload: unknown): void {
|
|
1088
|
+
const retention = this.channelHistory?.retentionFor(channel);
|
|
1089
|
+
if (!retention) {
|
|
1090
|
+
this.fanOutBroadcast(clientId, channel, event, payload);
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
const previous = this.channelSendQueues.get(channel) ?? Promise.resolve();
|
|
1095
|
+
const next = previous
|
|
1096
|
+
// A failed predecessor must not poison the chain — the next message
|
|
1097
|
+
// on this channel is independent and still deserves to be sent.
|
|
1098
|
+
.catch(() => { /* already reported below */ })
|
|
1099
|
+
.then(() => this.persistAndFanOut(clientId, channel, event, payload, retention));
|
|
1100
|
+
|
|
1101
|
+
this.channelSendQueues.set(channel, next);
|
|
1102
|
+
void next.finally(() => {
|
|
1103
|
+
// Only clear if nothing has queued behind us in the meantime.
|
|
1104
|
+
if (this.channelSendQueues.get(channel) === next) this.channelSendQueues.delete(channel);
|
|
1105
|
+
});
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
/**
|
|
1109
|
+
* Number a broadcast, store it, then deliver it.
|
|
1110
|
+
*
|
|
1111
|
+
* A message that cannot be stored is **not** delivered. Delivering it would
|
|
1112
|
+
* put it in front of live subscribers while leaving it absent from every
|
|
1113
|
+
* future replay — the two views of the channel would disagree permanently,
|
|
1114
|
+
* and no later message could repair the gap. Failing loudly to the sender
|
|
1115
|
+
* instead lets it retry, which for an operation stream is the only outcome
|
|
1116
|
+
* that keeps clients convergent.
|
|
1117
|
+
*/
|
|
1118
|
+
private async persistAndFanOut(
|
|
1119
|
+
clientId: string,
|
|
1120
|
+
channel: string,
|
|
1121
|
+
event: string,
|
|
1122
|
+
payload: unknown,
|
|
1123
|
+
retention: ResolvedRetention
|
|
1124
|
+
): Promise<void> {
|
|
1125
|
+
let seq: number;
|
|
1126
|
+
try {
|
|
1127
|
+
({ seq } = await this.channelHistory!.append(channel, event, payload, clientId));
|
|
1128
|
+
} catch (error) {
|
|
1129
|
+
logger.error(`❌ [ChannelHistory] Could not persist broadcast on "${channel}" — message dropped`, { error });
|
|
1130
|
+
this.sendError(
|
|
1131
|
+
clientId,
|
|
1132
|
+
`Could not persist broadcast on retained channel "${channel}"`,
|
|
1133
|
+
undefined,
|
|
1134
|
+
"CHANNEL_HISTORY_WRITE_FAILED"
|
|
1135
|
+
);
|
|
1136
|
+
return;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
this.fanOutBroadcast(clientId, channel, event, payload, seq);
|
|
1140
|
+
|
|
1141
|
+
try {
|
|
1142
|
+
await this.channelHistory!.prune(channel, retention);
|
|
1143
|
+
} catch (error) {
|
|
1144
|
+
// Retention is a housekeeping concern; the message is already
|
|
1145
|
+
// delivered and durable, so a failed prune must not surface as a
|
|
1146
|
+
// broadcast failure. It will be retried on the next message.
|
|
1147
|
+
logger.warn(`⚠️ [ChannelHistory] Prune failed for "${channel}"`, { error });
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
/** Deliver a broadcast frame to every member of a channel but the sender. */
|
|
1152
|
+
private fanOutBroadcast(clientId: string, channel: string, event: string, payload: unknown, seq?: number): void {
|
|
1044
1153
|
const members = this.channels.get(channel);
|
|
1045
1154
|
if (!members) return;
|
|
1046
1155
|
|
|
@@ -1048,7 +1157,8 @@ roles: activeAuth.roles },
|
|
|
1048
1157
|
type: "broadcast",
|
|
1049
1158
|
channel,
|
|
1050
1159
|
event,
|
|
1051
|
-
payload
|
|
1160
|
+
payload,
|
|
1161
|
+
...(seq !== undefined ? { seq } : {})
|
|
1052
1162
|
});
|
|
1053
1163
|
|
|
1054
1164
|
for (const memberId of members) {
|
|
@@ -1060,6 +1170,79 @@ roles: activeAuth.roles },
|
|
|
1060
1170
|
}
|
|
1061
1171
|
}
|
|
1062
1172
|
|
|
1173
|
+
// =============================================================================
|
|
1174
|
+
// Channel History
|
|
1175
|
+
// =============================================================================
|
|
1176
|
+
|
|
1177
|
+
/**
|
|
1178
|
+
* Install retention rules and create the tables they need.
|
|
1179
|
+
*
|
|
1180
|
+
* Safe to call with no rules (and safe not to call at all): the store stays
|
|
1181
|
+
* inert, no schema is created, and broadcast keeps its original
|
|
1182
|
+
* fire-and-forget path.
|
|
1183
|
+
*/
|
|
1184
|
+
async configureChannelHistory(rules: ChannelRetentionRule[] | undefined): Promise<void> {
|
|
1185
|
+
this.channelHistory = new ChannelHistoryStore(this.db, rules ?? []);
|
|
1186
|
+
if (!this.channelHistory.enabled) return;
|
|
1187
|
+
await this.channelHistory.ensureTables();
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
/** Whether any channel is configured to retain messages. */
|
|
1191
|
+
public isChannelHistoryEnabled(): boolean {
|
|
1192
|
+
return this.channelHistory?.enabled ?? false;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
/**
|
|
1196
|
+
* Answer a client's catch-up request.
|
|
1197
|
+
*
|
|
1198
|
+
* A channel with no retention rule is answered with `retained: false`
|
|
1199
|
+
* rather than an empty list, so the client can tell "you missed nothing"
|
|
1200
|
+
* apart from "this channel never keeps anything" — the second means its
|
|
1201
|
+
* reconnect strategy has to be a full resync, and silence would leave it
|
|
1202
|
+
* guessing.
|
|
1203
|
+
*/
|
|
1204
|
+
private async handleChannelHistoryRequest(
|
|
1205
|
+
clientId: string,
|
|
1206
|
+
channel: string,
|
|
1207
|
+
sinceSeq?: number,
|
|
1208
|
+
limit?: number
|
|
1209
|
+
): Promise<void> {
|
|
1210
|
+
if (!channel) return;
|
|
1211
|
+
|
|
1212
|
+
const retention = this.channelHistory?.retentionFor(channel);
|
|
1213
|
+
if (!retention) {
|
|
1214
|
+
this.sendChannelHistory(clientId, channel, [], false);
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
try {
|
|
1219
|
+
const { messages, latestSeq } = await this.channelHistory!.replay(channel, sinceSeq, limit);
|
|
1220
|
+
this.sendChannelHistory(clientId, channel, messages, true, latestSeq);
|
|
1221
|
+
} catch (error) {
|
|
1222
|
+
logger.error(`❌ [ChannelHistory] Replay failed for "${channel}"`, { error });
|
|
1223
|
+
this.sendError(clientId, `Could not replay history for channel "${channel}"`, undefined, "CHANNEL_HISTORY_READ_FAILED");
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
private sendChannelHistory(
|
|
1228
|
+
clientId: string,
|
|
1229
|
+
channel: string,
|
|
1230
|
+
messages: ChannelHistoryEntry[],
|
|
1231
|
+
retained: boolean,
|
|
1232
|
+
latestSeq?: number
|
|
1233
|
+
): void {
|
|
1234
|
+
const ws = this.clients.get(clientId);
|
|
1235
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
1236
|
+
ws.send(JSON.stringify({
|
|
1237
|
+
type: "channel_history",
|
|
1238
|
+
channel,
|
|
1239
|
+
messages,
|
|
1240
|
+
retained,
|
|
1241
|
+
...(latestSeq !== undefined ? { latestSeq } : {})
|
|
1242
|
+
}));
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1063
1246
|
// =============================================================================
|
|
1064
1247
|
// Presence
|
|
1065
1248
|
// =============================================================================
|
|
@@ -1191,6 +1374,11 @@ lastSeen: Date.now() });
|
|
|
1191
1374
|
// 3. Clear broadcast channels and presence
|
|
1192
1375
|
this.channels.clear();
|
|
1193
1376
|
this.presence.clear();
|
|
1377
|
+
// Pending history writes hold the pool open; let them settle before the
|
|
1378
|
+
// caller closes it, but never let a rejected one break shutdown.
|
|
1379
|
+
await Promise.allSettled([...this.channelSendQueues.values()]);
|
|
1380
|
+
this.channelSendQueues.clear();
|
|
1381
|
+
this.channelHistory?.clear();
|
|
1194
1382
|
if (this.presenceInterval) {
|
|
1195
1383
|
clearInterval(this.presenceInterval);
|
|
1196
1384
|
this.presenceInterval = undefined;
|
package/src/websocket.ts
CHANGED
|
@@ -27,7 +27,7 @@ interface WsAuthConfig {
|
|
|
27
27
|
* Normalized user identity for WebSocket sessions.
|
|
28
28
|
*/
|
|
29
29
|
interface WsUserIdentity {
|
|
30
|
-
|
|
30
|
+
uid: string;
|
|
31
31
|
roles: string[];
|
|
32
32
|
isAdmin: boolean;
|
|
33
33
|
}
|
|
@@ -183,7 +183,7 @@ code } }
|
|
|
183
183
|
|
|
184
184
|
if (adapterUser) {
|
|
185
185
|
verifiedUser = {
|
|
186
|
-
|
|
186
|
+
uid: adapterUser.uid,
|
|
187
187
|
roles: adapterUser.roles,
|
|
188
188
|
isAdmin: adapterUser.isAdmin
|
|
189
189
|
};
|
|
@@ -195,13 +195,13 @@ code } }
|
|
|
195
195
|
// Service key: a static secret, not a JWT. Checked
|
|
196
196
|
// before verification, mirroring the HTTP middleware —
|
|
197
197
|
// verifying it as a JWT can only ever fail.
|
|
198
|
-
verifiedUser = {
|
|
198
|
+
verifiedUser = { uid: "service", roles: ["admin"], isAdmin: true };
|
|
199
199
|
} else {
|
|
200
200
|
// Standard JWT path
|
|
201
201
|
const jwtPayload = extractUserFromToken(token);
|
|
202
202
|
if (jwtPayload) {
|
|
203
203
|
verifiedUser = {
|
|
204
|
-
|
|
204
|
+
uid: jwtPayload.uid,
|
|
205
205
|
roles: jwtPayload.roles ?? [],
|
|
206
206
|
isAdmin: (jwtPayload.roles ?? []).some((r: string) => r === "admin")
|
|
207
207
|
};
|
|
@@ -218,10 +218,10 @@ code } }
|
|
|
218
218
|
ws.send(JSON.stringify({
|
|
219
219
|
type: "AUTH_SUCCESS",
|
|
220
220
|
requestId,
|
|
221
|
-
payload: {
|
|
221
|
+
payload: { uid: verifiedUser.uid,
|
|
222
222
|
roles: verifiedUser.roles }
|
|
223
223
|
}));
|
|
224
|
-
wsDebug(`🔐 [WebSocket Server] Client ${clientId} authenticated as ${verifiedUser.
|
|
224
|
+
wsDebug(`🔐 [WebSocket Server] Client ${clientId} authenticated as ${verifiedUser.uid}`);
|
|
225
225
|
} else {
|
|
226
226
|
wsDebug(`[WS] replying AUTH_ERROR for requestId ${requestId} (invalid token)`);
|
|
227
227
|
sendError("AUTH_ERROR", "INVALID_TOKEN", "Invalid or expired token");
|
|
@@ -272,7 +272,7 @@ roles: verifiedUser.roles }
|
|
|
272
272
|
try {
|
|
273
273
|
const userForAuth: User = session?.user
|
|
274
274
|
? {
|
|
275
|
-
uid: session.user.
|
|
275
|
+
uid: session.user.uid,
|
|
276
276
|
displayName: null,
|
|
277
277
|
email: null,
|
|
278
278
|
photoURL: null,
|
|
@@ -421,7 +421,7 @@ colors: true }));
|
|
|
421
421
|
sql: typeof sql === "string" ? sql.substring(0, 500) : sql,
|
|
422
422
|
options,
|
|
423
423
|
resultRows: Array.isArray(result) ? result.length : "unknown",
|
|
424
|
-
|
|
424
|
+
uid: auditSession?.user?.uid ?? "unknown",
|
|
425
425
|
roles: auditSession?.user?.roles ?? [],
|
|
426
426
|
isAdmin: auditSession?.user?.isAdmin ?? false,
|
|
427
427
|
}));
|
|
@@ -476,6 +476,24 @@ colors: true }));
|
|
|
476
476
|
}
|
|
477
477
|
break;
|
|
478
478
|
|
|
479
|
+
case "FETCH_APPLICATION_ROLES": {
|
|
480
|
+
wsDebug("👤 [WebSocket Server] Processing FETCH_APPLICATION_ROLES request");
|
|
481
|
+
const delegate = await getScopedDelegate();
|
|
482
|
+
const admin = delegate.admin;
|
|
483
|
+
let roles: string[] = [];
|
|
484
|
+
if (isSQLAdmin(admin) && admin.fetchApplicationRoles) {
|
|
485
|
+
roles = await admin.fetchApplicationRoles();
|
|
486
|
+
}
|
|
487
|
+
wsDebug(`👤 [WebSocket Server] Fetched ${roles.length} application roles.`);
|
|
488
|
+
const response = {
|
|
489
|
+
type: "FETCH_APPLICATION_ROLES_SUCCESS",
|
|
490
|
+
payload: { roles },
|
|
491
|
+
requestId
|
|
492
|
+
};
|
|
493
|
+
ws.send(JSON.stringify(response));
|
|
494
|
+
}
|
|
495
|
+
break;
|
|
496
|
+
|
|
479
497
|
case "FETCH_CURRENT_DATABASE": {
|
|
480
498
|
wsDebug("📚 [WebSocket Server] Processing FETCH_CURRENT_DATABASE request");
|
|
481
499
|
const delegate = await getScopedDelegate();
|
|
@@ -594,14 +612,15 @@ colors: true }));
|
|
|
594
612
|
case "broadcast":
|
|
595
613
|
case "presence_track":
|
|
596
614
|
case "presence_untrack":
|
|
597
|
-
case "presence_state":
|
|
615
|
+
case "presence_state":
|
|
616
|
+
case "channel_history": {
|
|
598
617
|
wsDebug("🔄 [WebSocket Server] Routing realtime message to RealtimeService:", type);
|
|
599
618
|
// Attach auth context from the WS session so RLS-aware refetches work
|
|
600
619
|
const session = clientSessions.get(clientId);
|
|
601
620
|
const authContext = session?.user
|
|
602
|
-
? {
|
|
621
|
+
? { uid: session.user.uid,
|
|
603
622
|
roles: session.user.roles ?? [] }
|
|
604
|
-
: {
|
|
623
|
+
: { uid: "anon",
|
|
605
624
|
roles: ["anon"] };
|
|
606
625
|
// Let RealtimeService handle these messages
|
|
607
626
|
await realtimeService.handleClientMessage(clientId, {
|