@rebasepro/server-postgres 0.13.0 → 0.13.1-canary.g06dbe5b
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/dist/PostgresBackendDriver.d.ts +48 -1
- package/dist/auth/services.d.ts +19 -0
- package/dist/{src-DlPBctw_.js → auth-users-columns-CBEOeYqa.js} +835 -63
- package/dist/auth-users-columns-CBEOeYqa.js.map +1 -0
- package/dist/{backup-service-CD8o_1Sl.js → backup-service-Bww-Lg0s.js} +2 -2
- package/dist/{backup-service-CD8o_1Sl.js.map → backup-service-Bww-Lg0s.js.map} +1 -1
- package/dist/cli-helpers.d.ts +57 -1
- package/dist/data-transformer.d.ts +7 -2
- package/dist/data_driver-ULAyJEi9.js +193 -0
- package/dist/data_driver-ULAyJEi9.js.map +1 -0
- package/dist/{ensure-collection-policies-ViG8XiPn.js → ensure-collection-policies-B_JMGa5K.js} +2 -2
- package/dist/{ensure-collection-policies-ViG8XiPn.js.map → ensure-collection-policies-B_JMGa5K.js.map} +1 -1
- package/dist/{ensure-collection-tables-CBQdOETu.js → ensure-collection-tables-DzeTEvMv.js} +170 -20
- package/dist/ensure-collection-tables-DzeTEvMv.js.map +1 -0
- package/dist/index.es.js +1365 -338
- package/dist/index.es.js.map +1 -1
- package/dist/rls-bootstrap-sql-Bpv3nUZo.js +244 -0
- package/dist/rls-bootstrap-sql-Bpv3nUZo.js.map +1 -0
- package/dist/schema/auth-schema.d.ts +102 -0
- package/dist/schema/auth-users-columns.d.ts +97 -0
- package/dist/schema/doctor-policy-checks.d.ts +28 -0
- package/dist/schema/doctor.d.ts +23 -25
- package/dist/schema/ensure-collection-tables.d.ts +61 -7
- package/dist/schema/generate-drizzle-schema-logic.d.ts +10 -2
- package/dist/schema/generate-postgres-ddl-logic.d.ts +53 -5
- package/dist/schema/generated-schema-staleness.d.ts +39 -0
- package/dist/schema/rls-bootstrap-sql.d.ts +135 -0
- package/dist/schema/search-column.d.ts +248 -0
- package/dist/security/rls-enforcement.d.ts +61 -5
- package/dist/services/FetchService.d.ts +34 -7
- package/dist/services/RelationService.d.ts +30 -0
- package/dist/services/collection-helpers.d.ts +26 -0
- package/dist/services/dataService.d.ts +5 -0
- package/dist/services/realtimeService.d.ts +131 -21
- package/dist/{src-DoU9yPqq.js → src-C_wvdMnl.js} +91 -2
- package/dist/src-C_wvdMnl.js.map +1 -0
- package/dist/utils/drizzle-conditions.d.ts +124 -2
- package/dist/{websocket-B2LsrINK.js → websocket-D1qbmLZ2.js} +75 -18
- package/dist/websocket-D1qbmLZ2.js.map +1 -0
- package/package.json +9 -8
- package/src/PostgresBackendDriver.ts +172 -6
- package/src/PostgresBootstrapper.ts +55 -6
- package/src/auth/ensure-tables.ts +207 -86
- package/src/auth/services.ts +69 -5
- package/src/cli-helpers.ts +129 -10
- package/src/cli.ts +232 -30
- package/src/collections/validate-relations.ts +124 -17
- package/src/data-transformer.ts +120 -17
- package/src/history/ensure-history-table.ts +7 -0
- package/src/schema/auth-schema.ts +17 -1
- package/src/schema/auth-users-columns.ts +131 -0
- package/src/schema/doctor-cli.ts +12 -63
- package/src/schema/doctor-policy-checks.ts +105 -0
- package/src/schema/doctor.ts +135 -76
- package/src/schema/ensure-collection-tables.ts +374 -32
- package/src/schema/generate-drizzle-schema-logic.ts +132 -42
- package/src/schema/generate-postgres-ddl-logic.ts +294 -16
- package/src/schema/generate-postgres-ddl.ts +25 -2
- package/src/schema/generated-schema-staleness.ts +169 -0
- package/src/schema/introspect-db-logic.ts +66 -34
- package/src/schema/non-sql-collections.test.ts +131 -0
- package/src/schema/rls-bootstrap-sql.ts +288 -0
- package/src/schema/search-column.ts +643 -0
- package/src/security/anonymous-grants.test.ts +4 -2
- package/src/security/rls-enforcement.ts +149 -6
- package/src/services/BranchService.ts +5 -0
- package/src/services/FetchService.ts +175 -108
- package/src/services/PersistService.ts +38 -2
- package/src/services/RelationService.ts +110 -67
- package/src/services/channel-history.ts +14 -0
- package/src/services/channel-presence.ts +13 -0
- package/src/services/collection-helpers.ts +54 -1
- package/src/services/dataService.ts +5 -0
- package/src/services/realtimeService.ts +344 -79
- package/src/utils/drizzle-conditions.ts +365 -23
- package/src/utils/pg-error-utils.ts +8 -3
- package/src/websocket.ts +113 -16
- package/dist/ensure-collection-tables-CBQdOETu.js.map +0 -1
- package/dist/policy-CeA1JcxP.js +0 -105
- package/dist/policy-CeA1JcxP.js.map +0 -1
- package/dist/schema/auth-bootstrap-sql.d.ts +0 -24
- package/dist/src-DlPBctw_.js.map +0 -1
- package/dist/src-DoU9yPqq.js.map +0 -1
- package/dist/websocket-B2LsrINK.js.map +0 -1
- package/src/schema/auth-bootstrap-sql.ts +0 -47
|
@@ -4,7 +4,7 @@ import { Client as PgClient } from "pg";
|
|
|
4
4
|
import { randomUUID } from "crypto";
|
|
5
5
|
import { DataService } from "./dataService";
|
|
6
6
|
|
|
7
|
-
import { ANONYMOUS_USER_ID, FetchCollectionProps, ListenCollectionProps, ListenOneProps, DataDriver, CollectionUpdateMessage, SingleUpdateMessage, CollectionPatchMessage, WebSocketMessage, FilterValues, CollectionConfig, RebaseCallContext, resolveClientListLimit } from "@rebasepro/types";
|
|
7
|
+
import { ANONYMOUS_USER_ID, FetchCollectionProps, ListenCollectionProps, ListenOneProps, DataDriver, CollectionUpdateMessage, SingleUpdateMessage, CollectionPatchMessage, WebSocketMessage, FilterValues, LogicalCondition, CollectionConfig, RebaseCallContext, resolveClientListLimit, ListLimitError } from "@rebasepro/types";
|
|
8
8
|
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
9
9
|
import { sql as drizzleSql } from "drizzle-orm";
|
|
10
10
|
import { RealtimeProvider, CollectionSubscriptionConfig, SingleSubscriptionConfig } from "../interfaces";
|
|
@@ -33,6 +33,37 @@ export interface SubscriptionAuthContext {
|
|
|
33
33
|
roles: string[];
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/** What a channel frame is asking to do. */
|
|
37
|
+
export type ChannelAction = "join" | "broadcast" | "presence" | "history";
|
|
38
|
+
|
|
39
|
+
/** Everything an authorizer is told about the frame it is asked to allow. */
|
|
40
|
+
export interface ChannelAuthorizationRequest {
|
|
41
|
+
/** The channel the frame names, exactly as the client wrote it. */
|
|
42
|
+
channel: string;
|
|
43
|
+
action: ChannelAction;
|
|
44
|
+
/** The socket, not the principal — one user may hold several. */
|
|
45
|
+
clientId: string;
|
|
46
|
+
/** The socket's authenticated principal, or the anonymous one. */
|
|
47
|
+
user?: SubscriptionAuthContext;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The extension point for channel access rules.
|
|
52
|
+
*
|
|
53
|
+
* **This is deliberately not a product API yet.** The rule *language* — a
|
|
54
|
+
* config key, a per-pattern DSL, how it composes with `securityRules` — is an
|
|
55
|
+
* open design question (see `docs/channel-authorization.md`), and
|
|
56
|
+
* inventing one here would be inventing the answer. What exists is the single
|
|
57
|
+
* place every channel frame passes through, so that whatever shape the rules
|
|
58
|
+
* eventually take has exactly one seam to plug into and no arm of the switch
|
|
59
|
+
* can be forgotten.
|
|
60
|
+
*
|
|
61
|
+
* Returning `false` — or throwing — refuses the frame. It is consulted *after*
|
|
62
|
+
* the membership floor below, so an authorizer can only ever narrow access,
|
|
63
|
+
* never widen it.
|
|
64
|
+
*/
|
|
65
|
+
export type ChannelAuthorizer = (request: ChannelAuthorizationRequest) => boolean | Promise<boolean>;
|
|
66
|
+
|
|
36
67
|
interface DataDriverWithData extends DataDriver {
|
|
37
68
|
data: unknown;
|
|
38
69
|
}
|
|
@@ -41,6 +72,30 @@ type RealTimeListenCollectionProps = ListenCollectionProps & {
|
|
|
41
72
|
subscriptionId: string
|
|
42
73
|
};
|
|
43
74
|
|
|
75
|
+
/**
|
|
76
|
+
* The narrowing a collection subscription was created with, kept so that every
|
|
77
|
+
* refetch answers the same query the initial fetch did.
|
|
78
|
+
*
|
|
79
|
+
* Named once because it used to be written out inline in five places, and a
|
|
80
|
+
* field missing from one of them is accepted over the wire and then silently
|
|
81
|
+
* ignored: `offset` was declared on the incoming props and never stored, so a
|
|
82
|
+
* live list on page three served page one, and `logical` was never stored
|
|
83
|
+
* either, so an `or(...)` subscription was pushed every row in the table.
|
|
84
|
+
*/
|
|
85
|
+
type StoredCollectionRequest = {
|
|
86
|
+
filter?: Record<string, unknown>;
|
|
87
|
+
logical?: LogicalCondition;
|
|
88
|
+
orderBy?: string;
|
|
89
|
+
order?: "desc" | "asc";
|
|
90
|
+
limit?: number;
|
|
91
|
+
offset?: number;
|
|
92
|
+
startAfter?: Record<string, unknown>;
|
|
93
|
+
databaseId?: string;
|
|
94
|
+
searchString?: string;
|
|
95
|
+
/** Ask each row which declared search field matched — populates `_matches`. */
|
|
96
|
+
searchExplain?: boolean;
|
|
97
|
+
};
|
|
98
|
+
|
|
44
99
|
type RealTimeListenEntityProps = ListenOneProps & { subscriptionId: string };
|
|
45
100
|
|
|
46
101
|
/**
|
|
@@ -50,6 +105,13 @@ type RealTimeListenEntityProps = ListenOneProps & { subscriptionId: string };
|
|
|
50
105
|
* Implements the RealtimeProvider interface for database abstraction.
|
|
51
106
|
*/
|
|
52
107
|
export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
108
|
+
/**
|
|
109
|
+
* Declares to the multi-engine router that channel frames can be handled
|
|
110
|
+
* here. Read by `createRoutedRealtimeService`, which otherwise would have to
|
|
111
|
+
* guess — and guessed "the default provider", whichever engine that is.
|
|
112
|
+
*/
|
|
113
|
+
public readonly supportsChannels = true;
|
|
114
|
+
|
|
53
115
|
private clients = new Map<string, WebSocket>();
|
|
54
116
|
|
|
55
117
|
// Broadcast channels: channel name → set of client IDs
|
|
@@ -107,6 +169,26 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
107
169
|
*/
|
|
108
170
|
private oversizedBroadcastWarned = new Set<string>();
|
|
109
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Optional narrowing on top of the membership floor — see
|
|
174
|
+
* {@link ChannelAuthorizer}. Unset by default, which leaves membership as
|
|
175
|
+
* the whole of the rule.
|
|
176
|
+
*/
|
|
177
|
+
private channelAuthorizer?: ChannelAuthorizer;
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Whether a notification from another instance has ever arrived.
|
|
181
|
+
*
|
|
182
|
+
* The entity LISTEN handler sees a foreign `sid` on every cross-instance
|
|
183
|
+
* change, which is proof that this deployment runs more than one pod — the
|
|
184
|
+
* one fact needed to tell "the memory bus is fine here" from "broadcast and
|
|
185
|
+
* presence silently reach a fraction of your users".
|
|
186
|
+
*/
|
|
187
|
+
private foreignInstanceSeen = false;
|
|
188
|
+
|
|
189
|
+
/** So the multi-pod memory-bus warning is emitted once, not once per join. */
|
|
190
|
+
private memoryBusWarned = false;
|
|
191
|
+
|
|
110
192
|
private presenceInterval?: ReturnType<typeof setInterval>;
|
|
111
193
|
private static readonly PRESENCE_TIMEOUT_MS = 30000; // 30s
|
|
112
194
|
/** How often stale roster rows from other instances are reaped. */
|
|
@@ -119,16 +201,7 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
119
201
|
path: string;
|
|
120
202
|
id?: string | number;
|
|
121
203
|
// Store full collection request parameters for proper refetching
|
|
122
|
-
collectionRequest?:
|
|
123
|
-
filter?: Record<string, unknown>;
|
|
124
|
-
orderBy?: string;
|
|
125
|
-
order?: "desc" | "asc";
|
|
126
|
-
limit?: number;
|
|
127
|
-
offset?: number;
|
|
128
|
-
startAfter?: Record<string, unknown>;
|
|
129
|
-
databaseId?: string;
|
|
130
|
-
searchString?: string;
|
|
131
|
-
};
|
|
204
|
+
collectionRequest?: StoredCollectionRequest;
|
|
132
205
|
// Auth context for RLS — when set, refetches run in a transaction
|
|
133
206
|
// with set_config('app.uid', ...) / set_config('app.user_roles', ...)
|
|
134
207
|
authContext?: SubscriptionAuthContext;
|
|
@@ -212,16 +285,7 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
212
285
|
type: "collection" | "single";
|
|
213
286
|
path: string;
|
|
214
287
|
id?: string | number;
|
|
215
|
-
collectionRequest?:
|
|
216
|
-
filter?: Record<string, unknown>;
|
|
217
|
-
orderBy?: string;
|
|
218
|
-
order?: "desc" | "asc";
|
|
219
|
-
limit?: number;
|
|
220
|
-
offset?: number;
|
|
221
|
-
startAfter?: Record<string, unknown>;
|
|
222
|
-
databaseId?: string;
|
|
223
|
-
searchString?: string;
|
|
224
|
-
};
|
|
288
|
+
collectionRequest?: StoredCollectionRequest;
|
|
225
289
|
authContext?: SubscriptionAuthContext;
|
|
226
290
|
}) {
|
|
227
291
|
this.debugLog("📋 [RealtimeService] Registering DataDriver subscription:", subscriptionId, subscription.authContext ? "(with auth)" : "(no auth)");
|
|
@@ -262,7 +326,8 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
262
326
|
limit: config.limit,
|
|
263
327
|
startAfter: config.startAfter as Record<string, unknown> | undefined,
|
|
264
328
|
databaseId: config.databaseId,
|
|
265
|
-
searchString: config.searchString
|
|
329
|
+
searchString: config.searchString,
|
|
330
|
+
searchExplain: config.searchExplain
|
|
266
331
|
}
|
|
267
332
|
});
|
|
268
333
|
|
|
@@ -371,45 +436,19 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
371
436
|
await this.handleUnsubscribe(clientId, message.subscriptionId!);
|
|
372
437
|
break;
|
|
373
438
|
|
|
374
|
-
// ── Broadcast Channels ──
|
|
439
|
+
// ── Broadcast Channels & Presence ──
|
|
440
|
+
//
|
|
441
|
+
// One arm for all of them, because every one has to pass the same
|
|
442
|
+
// gate and a switch with seven arms is a place to forget it once.
|
|
443
|
+
// See `handleChannelMessage`.
|
|
375
444
|
case "join_channel":
|
|
376
|
-
this.joinChannel(clientId, payload?.channel as string);
|
|
377
|
-
break;
|
|
378
445
|
case "leave_channel":
|
|
379
|
-
this.leaveChannel(clientId, payload?.channel as string);
|
|
380
|
-
break;
|
|
381
446
|
case "broadcast":
|
|
382
|
-
this.broadcastToChannel(
|
|
383
|
-
clientId,
|
|
384
|
-
payload?.channel as string,
|
|
385
|
-
payload?.event as string,
|
|
386
|
-
payload?.payload
|
|
387
|
-
);
|
|
388
|
-
break;
|
|
389
447
|
case "channel_history":
|
|
390
|
-
await this.handleChannelHistoryRequest(
|
|
391
|
-
clientId,
|
|
392
|
-
payload?.channel as string,
|
|
393
|
-
payload?.sinceSeq as number | undefined,
|
|
394
|
-
payload?.limit as number | undefined
|
|
395
|
-
);
|
|
396
|
-
break;
|
|
397
|
-
|
|
398
|
-
// ── Presence ──
|
|
399
448
|
case "presence_track":
|
|
400
|
-
// Auto-join the channel so presence works without a separate join
|
|
401
|
-
this.joinChannel(clientId, payload?.channel as string);
|
|
402
|
-
this.trackPresence(
|
|
403
|
-
clientId,
|
|
404
|
-
payload?.channel as string,
|
|
405
|
-
payload?.state as Record<string, unknown> ?? {}
|
|
406
|
-
);
|
|
407
|
-
break;
|
|
408
449
|
case "presence_untrack":
|
|
409
|
-
this.removePresence(clientId, payload?.channel as string);
|
|
410
|
-
break;
|
|
411
450
|
case "presence_state":
|
|
412
|
-
this.
|
|
451
|
+
await this.handleChannelMessage(clientId, message.type, payload, authContext);
|
|
413
452
|
break;
|
|
414
453
|
|
|
415
454
|
default:
|
|
@@ -431,15 +470,42 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
431
470
|
return;
|
|
432
471
|
}
|
|
433
472
|
|
|
473
|
+
// A vector search cannot be served here, and the parameter used to
|
|
474
|
+
// be read for one thing only — the limit default below — and then
|
|
475
|
+
// dropped: the stored request carries no `vectorSearch` and the
|
|
476
|
+
// refetch has no branch for one. So `.vectorSearch(…).listen()`
|
|
477
|
+
// delivered an ordinary `id DESC` listing, with no `_distance` and
|
|
478
|
+
// no error, forever. Refusing says what the silence did not.
|
|
479
|
+
if (request.vectorSearch) {
|
|
480
|
+
const msg =
|
|
481
|
+
"Realtime subscriptions do not support vector search: a subscription is re-run on every " +
|
|
482
|
+
"matching write, and nothing here computes distances. Use `.vectorSearch(...).find()` for " +
|
|
483
|
+
"the query, and subscribe without it if you need live updates.";
|
|
484
|
+
logger.warn(`[RealtimeService] ${msg}`);
|
|
485
|
+
this.sendError(clientId, msg, subscriptionId, "VECTOR_SEARCH_NOT_LIVE");
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
|
|
434
489
|
// Bound the client-supplied limit with the SAME guarantee the REST
|
|
435
|
-
// ingress applies (`resolveClientListLimit`):
|
|
436
|
-
//
|
|
437
|
-
// on every matching write, so an unbounded one is a DoS
|
|
438
|
-
// per write — resolve it once and reuse for the stored
|
|
439
|
-
// the initial fetch.
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
490
|
+
// ingress applies (`resolveClientListLimit`): default an absent
|
|
491
|
+
// limit by mode, refuse one above the ceiling. A subscription is
|
|
492
|
+
// re-fetched on every matching write, so an unbounded one is a DoS
|
|
493
|
+
// amplified per write — resolve it once and reuse for the stored
|
|
494
|
+
// request and the initial fetch.
|
|
495
|
+
//
|
|
496
|
+
// Refusing matters more here than on the REST route: a
|
|
497
|
+
// `collection_update` frame carries rows and nothing else — no
|
|
498
|
+
// `total`, no `hasMore` — so a subscriber handed a quietly smaller
|
|
499
|
+
// page has no way at all to learn it is not seeing the collection.
|
|
500
|
+
let boundedLimit: number;
|
|
501
|
+
try {
|
|
502
|
+
boundedLimit = resolveClientListLimit(request.limit);
|
|
503
|
+
} catch (e) {
|
|
504
|
+
if (!(e instanceof ListLimitError)) throw e;
|
|
505
|
+
logger.warn(`[RealtimeService] Refused subscription to '${request.path}': ${e.message}`);
|
|
506
|
+
this.sendError(clientId, e.message, subscriptionId, "INVALID_LIMIT");
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
443
509
|
|
|
444
510
|
// Store subscription with full request parameters and auth context for RLS
|
|
445
511
|
this._subscriptions.set(subscriptionId, {
|
|
@@ -448,27 +514,25 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
448
514
|
path: request.path,
|
|
449
515
|
collectionRequest: {
|
|
450
516
|
filter: request.filter,
|
|
517
|
+
logical: request.logical,
|
|
451
518
|
orderBy: request.orderBy,
|
|
452
519
|
order: request.order,
|
|
453
520
|
limit: boundedLimit,
|
|
521
|
+
offset: request.offset,
|
|
454
522
|
startAfter: request.startAfter as Record<string, unknown> | undefined,
|
|
455
523
|
databaseId: request.collection?.databaseId,
|
|
456
|
-
searchString: request.searchString
|
|
524
|
+
searchString: request.searchString,
|
|
525
|
+
searchExplain: request.searchExplain
|
|
457
526
|
},
|
|
458
527
|
authContext
|
|
459
528
|
});
|
|
460
529
|
|
|
461
|
-
// Send initial data
|
|
530
|
+
// Send initial data. Built from the request the subscription just
|
|
531
|
+
// stored, so the first answer and every refetch after it cannot
|
|
532
|
+
// describe different queries.
|
|
462
533
|
const rows = await this.fetchCollectionWithAuth(
|
|
463
534
|
request.path,
|
|
464
|
-
|
|
465
|
-
filter: request.filter,
|
|
466
|
-
orderBy: request.orderBy,
|
|
467
|
-
order: request.order,
|
|
468
|
-
limit: boundedLimit,
|
|
469
|
-
startAfter: request.startAfter as Record<string, unknown> | undefined,
|
|
470
|
-
searchString: request.searchString
|
|
471
|
-
},
|
|
535
|
+
this._subscriptions.get(subscriptionId)!.collectionRequest!,
|
|
472
536
|
authContext
|
|
473
537
|
);
|
|
474
538
|
|
|
@@ -679,7 +743,7 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
679
743
|
private debouncedCollectionRefetch(
|
|
680
744
|
subscriptionId: string,
|
|
681
745
|
notifyPath: string,
|
|
682
|
-
subscription: { clientId: string; collectionRequest?:
|
|
746
|
+
subscription: { clientId: string; collectionRequest?: StoredCollectionRequest; authContext?: SubscriptionAuthContext }
|
|
683
747
|
) {
|
|
684
748
|
const timerKey = `ws_${subscriptionId}`;
|
|
685
749
|
const existing = this.refetchTimers.get(timerKey);
|
|
@@ -705,7 +769,7 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
705
769
|
private debouncedDriverRefetch(
|
|
706
770
|
subscriptionId: string,
|
|
707
771
|
notifyPath: string,
|
|
708
|
-
subscription: { collectionRequest?:
|
|
772
|
+
subscription: { collectionRequest?: StoredCollectionRequest; authContext?: SubscriptionAuthContext },
|
|
709
773
|
callback: (data: Record<string, unknown>[] | Record<string, unknown> | null) => void
|
|
710
774
|
) {
|
|
711
775
|
const timerKey = `drv_${subscriptionId}`;
|
|
@@ -731,7 +795,7 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
731
795
|
*/
|
|
732
796
|
private async fetchCollectionWithAuth(
|
|
733
797
|
notifyPath: string,
|
|
734
|
-
collectionRequest:
|
|
798
|
+
collectionRequest: StoredCollectionRequest,
|
|
735
799
|
authContext?: SubscriptionAuthContext
|
|
736
800
|
): Promise<Record<string, unknown>[]> {
|
|
737
801
|
if (this.driver) {
|
|
@@ -740,12 +804,14 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
740
804
|
path: notifyPath,
|
|
741
805
|
collection: collection,
|
|
742
806
|
filter: collectionRequest.filter as FetchCollectionProps["filter"],
|
|
807
|
+
logical: collectionRequest.logical,
|
|
743
808
|
orderBy: collectionRequest.orderBy,
|
|
744
809
|
order: collectionRequest.order,
|
|
745
810
|
limit: collectionRequest.limit,
|
|
746
811
|
offset: collectionRequest.offset,
|
|
747
812
|
startAfter: collectionRequest.startAfter,
|
|
748
|
-
searchString: collectionRequest.searchString
|
|
813
|
+
searchString: collectionRequest.searchString,
|
|
814
|
+
searchExplain: collectionRequest.searchExplain
|
|
749
815
|
});
|
|
750
816
|
|
|
751
817
|
// Always wrap in a transaction with session vars, defaulting to anonymous context if missing.
|
|
@@ -763,15 +829,21 @@ roles: ["anon"] };
|
|
|
763
829
|
collectionRequest.searchString,
|
|
764
830
|
{
|
|
765
831
|
filter: collectionRequest.filter as FilterValues<string>,
|
|
832
|
+
// The subscription stored a group; the search branch
|
|
833
|
+
// did not pass it on, so a filtered live search
|
|
834
|
+
// widened to every row matching the text.
|
|
835
|
+
logical: collectionRequest.logical,
|
|
766
836
|
orderBy: collectionRequest.orderBy,
|
|
767
837
|
order: collectionRequest.order,
|
|
768
838
|
limit: collectionRequest.limit,
|
|
769
|
-
databaseId: collectionRequest.databaseId
|
|
839
|
+
databaseId: collectionRequest.databaseId,
|
|
840
|
+
searchExplain: collectionRequest.searchExplain
|
|
770
841
|
}
|
|
771
842
|
);
|
|
772
843
|
} else {
|
|
773
844
|
fetchedEntities = await txEntityService.fetchCollection(notifyPath, {
|
|
774
845
|
filter: collectionRequest.filter as FilterValues<string>,
|
|
846
|
+
logical: collectionRequest.logical,
|
|
775
847
|
orderBy: collectionRequest.orderBy,
|
|
776
848
|
order: collectionRequest.order,
|
|
777
849
|
limit: collectionRequest.limit,
|
|
@@ -836,22 +908,28 @@ roles: activeAuth.roles },
|
|
|
836
908
|
});
|
|
837
909
|
}
|
|
838
910
|
|
|
839
|
-
// No driver — use dataService directly (no auth wrapping possible)
|
|
911
|
+
// No driver — use dataService directly (no auth wrapping possible).
|
|
912
|
+
// The `logical` group is carried here as well: this branch answers the
|
|
913
|
+
// same subscription as the one above, and a fallback that drops a
|
|
914
|
+
// condition returns *more* rows than the path it stands in for.
|
|
840
915
|
if (collectionRequest.searchString) {
|
|
841
916
|
return await this.dataService.searchRows(
|
|
842
917
|
notifyPath,
|
|
843
918
|
collectionRequest.searchString,
|
|
844
919
|
{
|
|
845
920
|
filter: collectionRequest.filter as FilterValues<string>,
|
|
921
|
+
logical: collectionRequest.logical,
|
|
846
922
|
orderBy: collectionRequest.orderBy,
|
|
847
923
|
order: collectionRequest.order,
|
|
848
924
|
limit: collectionRequest.limit,
|
|
849
|
-
databaseId: collectionRequest.databaseId
|
|
925
|
+
databaseId: collectionRequest.databaseId,
|
|
926
|
+
searchExplain: collectionRequest.searchExplain
|
|
850
927
|
}
|
|
851
928
|
);
|
|
852
929
|
}
|
|
853
930
|
return await this.dataService.fetchCollection(notifyPath, {
|
|
854
931
|
filter: collectionRequest.filter as FilterValues<string>,
|
|
932
|
+
logical: collectionRequest.logical,
|
|
855
933
|
orderBy: collectionRequest.orderBy,
|
|
856
934
|
order: collectionRequest.order,
|
|
857
935
|
limit: collectionRequest.limit,
|
|
@@ -1101,15 +1179,197 @@ roles: activeAuth.roles },
|
|
|
1101
1179
|
// Broadcast Channels
|
|
1102
1180
|
// =============================================================================
|
|
1103
1181
|
|
|
1182
|
+
/**
|
|
1183
|
+
* Install a channel authorizer — see {@link ChannelAuthorizer}.
|
|
1184
|
+
*
|
|
1185
|
+
* Nothing in the framework calls this yet: it is the seam a rules API will
|
|
1186
|
+
* be built on, kept deliberately separate from the membership floor so the
|
|
1187
|
+
* floor holds whether or not anyone uses it.
|
|
1188
|
+
*/
|
|
1189
|
+
setChannelAuthorizer(authorizer: ChannelAuthorizer | undefined): void {
|
|
1190
|
+
this.channelAuthorizer = authorizer;
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
/** Which action each channel frame is asking to perform. */
|
|
1194
|
+
private static readonly CHANNEL_ACTIONS: Record<string, ChannelAction> = {
|
|
1195
|
+
join_channel: "join",
|
|
1196
|
+
broadcast: "broadcast",
|
|
1197
|
+
channel_history: "history",
|
|
1198
|
+
presence_track: "join",
|
|
1199
|
+
presence_state: "presence"
|
|
1200
|
+
};
|
|
1201
|
+
|
|
1202
|
+
/**
|
|
1203
|
+
* The one door every channel frame comes through.
|
|
1204
|
+
*
|
|
1205
|
+
* Returns synchronously — and so dispatches synchronously — unless an
|
|
1206
|
+
* authorizer is installed. That matters: a client sends `join_channel`,
|
|
1207
|
+
* `presence_state` and `channel_history` back to back on connect, and the
|
|
1208
|
+
* socket's message handler processes each frame up to its first `await`,
|
|
1209
|
+
* so a gate that always yielded would let the reads overtake the join that
|
|
1210
|
+
* is about to authorize them.
|
|
1211
|
+
*/
|
|
1212
|
+
private handleChannelMessage(
|
|
1213
|
+
clientId: string,
|
|
1214
|
+
type: string,
|
|
1215
|
+
payload: Record<string, unknown> | undefined,
|
|
1216
|
+
authContext?: SubscriptionAuthContext
|
|
1217
|
+
): void | Promise<void> {
|
|
1218
|
+
const channel = payload?.channel as string;
|
|
1219
|
+
|
|
1220
|
+
// Leaving and untracking only ever remove the caller's own state, so
|
|
1221
|
+
// they need no permission — refusing them could only strand a client.
|
|
1222
|
+
if (type === "leave_channel") {
|
|
1223
|
+
this.leaveChannel(clientId, channel);
|
|
1224
|
+
return;
|
|
1225
|
+
}
|
|
1226
|
+
if (type === "presence_untrack") {
|
|
1227
|
+
this.removePresence(clientId, channel);
|
|
1228
|
+
return;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
const action = RealtimeService.CHANNEL_ACTIONS[type];
|
|
1232
|
+
const allowed = this.authorizeChannelAction(clientId, channel, action, authContext);
|
|
1233
|
+
if (allowed === false) return;
|
|
1234
|
+
if (allowed === true) return this.dispatchChannelMessage(clientId, type, channel, payload);
|
|
1235
|
+
return allowed.then((ok) => {
|
|
1236
|
+
if (ok) return this.dispatchChannelMessage(clientId, type, channel, payload);
|
|
1237
|
+
});
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
/** Perform an already-authorized channel frame. */
|
|
1241
|
+
private dispatchChannelMessage(
|
|
1242
|
+
clientId: string,
|
|
1243
|
+
type: string,
|
|
1244
|
+
channel: string,
|
|
1245
|
+
payload: Record<string, unknown> | undefined
|
|
1246
|
+
): void | Promise<void> {
|
|
1247
|
+
switch (type) {
|
|
1248
|
+
case "join_channel":
|
|
1249
|
+
this.joinChannel(clientId, channel);
|
|
1250
|
+
return;
|
|
1251
|
+
case "broadcast":
|
|
1252
|
+
this.broadcastToChannel(clientId, channel, payload?.event as string, payload?.payload);
|
|
1253
|
+
return;
|
|
1254
|
+
case "channel_history":
|
|
1255
|
+
return this.handleChannelHistoryRequest(
|
|
1256
|
+
clientId,
|
|
1257
|
+
channel,
|
|
1258
|
+
payload?.sinceSeq as number | undefined,
|
|
1259
|
+
payload?.limit as number | undefined
|
|
1260
|
+
);
|
|
1261
|
+
case "presence_track":
|
|
1262
|
+
// Auto-join the channel so presence works without a separate join
|
|
1263
|
+
this.joinChannel(clientId, channel);
|
|
1264
|
+
this.trackPresence(clientId, channel, payload?.state as Record<string, unknown> ?? {});
|
|
1265
|
+
return;
|
|
1266
|
+
case "presence_state":
|
|
1267
|
+
this.sendPresenceState(clientId, channel);
|
|
1268
|
+
return;
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
/**
|
|
1273
|
+
* Decide whether a client may perform an action on a channel.
|
|
1274
|
+
*
|
|
1275
|
+
* **Membership is the floor.** Reading a channel's presence roster, replaying
|
|
1276
|
+
* its retained history and broadcasting into it all require that this client
|
|
1277
|
+
* has joined it. That is a low bar — joining is open to anyone who can name
|
|
1278
|
+
* the channel — but it is not the bar that was there before, which was none
|
|
1279
|
+
* at all: `channel_history` and `presence_state` answered any socket about
|
|
1280
|
+
* any channel, and a broadcast fanned out to members the sender had never
|
|
1281
|
+
* joined. Two internal tables (`rebase.channel_presence`,
|
|
1282
|
+
* `rebase.channel_messages`) are held outside RLS on the strength of this
|
|
1283
|
+
* check, so it fails closed: an authorizer that throws refuses the frame.
|
|
1284
|
+
*
|
|
1285
|
+
* Anything richer than membership belongs in a {@link ChannelAuthorizer};
|
|
1286
|
+
* this method is where it is consulted, and the only place.
|
|
1287
|
+
*/
|
|
1288
|
+
private authorizeChannelAction(
|
|
1289
|
+
clientId: string,
|
|
1290
|
+
channel: string,
|
|
1291
|
+
action: ChannelAction,
|
|
1292
|
+
authContext?: SubscriptionAuthContext
|
|
1293
|
+
): boolean | Promise<boolean> {
|
|
1294
|
+
// Joining is what establishes membership, so it cannot require it.
|
|
1295
|
+
if (action !== "join" && !this.channels.get(channel)?.has(clientId)) {
|
|
1296
|
+
this.denyChannelAction(clientId, channel, action, "not a member of the channel");
|
|
1297
|
+
return false;
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
const authorizer = this.channelAuthorizer;
|
|
1301
|
+
if (!authorizer) return true;
|
|
1302
|
+
|
|
1303
|
+
let verdict: boolean | Promise<boolean>;
|
|
1304
|
+
try {
|
|
1305
|
+
verdict = authorizer({ channel, action, clientId, user: authContext });
|
|
1306
|
+
} catch (error) {
|
|
1307
|
+
logger.error(`❌ [Channels] Authorizer threw for ${action} on "${channel}" — refusing`, { error });
|
|
1308
|
+
this.denyChannelAction(clientId, channel, action, "channel authorization failed");
|
|
1309
|
+
return false;
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
if (typeof verdict === "boolean") {
|
|
1313
|
+
if (!verdict) this.denyChannelAction(clientId, channel, action, "refused by the channel authorizer");
|
|
1314
|
+
return verdict;
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
return verdict.then(
|
|
1318
|
+
(ok) => {
|
|
1319
|
+
if (!ok) this.denyChannelAction(clientId, channel, action, "refused by the channel authorizer");
|
|
1320
|
+
return ok;
|
|
1321
|
+
},
|
|
1322
|
+
(error) => {
|
|
1323
|
+
logger.error(`❌ [Channels] Authorizer rejected for ${action} on "${channel}" — refusing`, { error });
|
|
1324
|
+
this.denyChannelAction(clientId, channel, action, "channel authorization failed");
|
|
1325
|
+
return false;
|
|
1326
|
+
}
|
|
1327
|
+
);
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
/** Tell the client why its channel frame went nowhere, and say so in the log. */
|
|
1331
|
+
private denyChannelAction(clientId: string, channel: string, action: ChannelAction, reason: string): void {
|
|
1332
|
+
this.debugLog(`🚫 [Channels] Refused ${action} on "${channel}" for ${clientId}: ${reason}`);
|
|
1333
|
+
this.sendError(
|
|
1334
|
+
clientId,
|
|
1335
|
+
`Refused ${action} on channel "${channel}": ${reason}`,
|
|
1336
|
+
undefined,
|
|
1337
|
+
"CHANNEL_FORBIDDEN"
|
|
1338
|
+
);
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1104
1341
|
/** Join a broadcast channel */
|
|
1105
1342
|
joinChannel(clientId: string, channel: string): void {
|
|
1106
1343
|
if (!this.channels.has(channel)) {
|
|
1107
1344
|
this.channels.set(channel, new Set());
|
|
1108
1345
|
}
|
|
1109
1346
|
this.channels.get(channel)!.add(clientId);
|
|
1347
|
+
this.warnIfMemoryBusOnMultiplePods();
|
|
1110
1348
|
this.debugLog(`📡 [Broadcast] Client ${clientId} joined channel: ${channel}`);
|
|
1111
1349
|
}
|
|
1112
1350
|
|
|
1351
|
+
/**
|
|
1352
|
+
* Say something the first time channels are used on a deployment that is
|
|
1353
|
+
* demonstrably multi-pod while the bus is still the in-memory default.
|
|
1354
|
+
*
|
|
1355
|
+
* Every other warning in this subsystem covers a *configured* bus failing —
|
|
1356
|
+
* the case where the operator already knew a bus mattered. The common
|
|
1357
|
+
* misconfiguration is the opposite one: scaled to two replicas, never
|
|
1358
|
+
* touched `realtime.bus`, and broadcast and presence quietly serve a
|
|
1359
|
+
* fraction of the room. The evidence is already in the process, so use it.
|
|
1360
|
+
*/
|
|
1361
|
+
private warnIfMemoryBusOnMultiplePods(): void {
|
|
1362
|
+
if (this.memoryBusWarned) return;
|
|
1363
|
+
if (this.bus.kind !== "memory" || !this.foreignInstanceSeen) return;
|
|
1364
|
+
this.memoryBusWarned = true;
|
|
1365
|
+
logger.warn(
|
|
1366
|
+
"⚠️ [ChannelBus] Channels are in use with the in-memory bus, but notifications from another " +
|
|
1367
|
+
"instance have been seen — this deployment runs more than one process. Broadcast and presence " +
|
|
1368
|
+
"reach only the clients connected to this one. Set `realtime.bus` (or REBASE_REALTIME_BUS=postgres) " +
|
|
1369
|
+
"to make channels cross-instance."
|
|
1370
|
+
);
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1113
1373
|
/** Leave a broadcast channel */
|
|
1114
1374
|
leaveChannel(clientId: string, channel: string): void {
|
|
1115
1375
|
const members = this.channels.get(channel);
|
|
@@ -2065,6 +2325,11 @@ lastSeen: Date.now() });
|
|
|
2065
2325
|
// Skip our own notifications — already processed locally
|
|
2066
2326
|
if (sid === this.instanceId) return;
|
|
2067
2327
|
|
|
2328
|
+
// A foreign sid is proof of a second process. Nothing here
|
|
2329
|
+
// needs that fact, but the channel path does — see
|
|
2330
|
+
// `warnIfMemoryBusOnMultiplePods`.
|
|
2331
|
+
this.foreignInstanceSeen = true;
|
|
2332
|
+
|
|
2068
2333
|
this.debugLog(`📡 [RealtimeService] Received cross-instance notification: path=${p}, id=${eid}, from=${sid}`);
|
|
2069
2334
|
|
|
2070
2335
|
// Refetch the row from the DB so row subscriptions
|