@rebasepro/server-postgres 0.13.1-canary.gf57a27e → 0.14.0
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/PostgresBootstrapper.d.ts +26 -0
- package/dist/auth/services.d.ts +21 -0
- package/dist/{auth-users-columns-Dt9g712t.js → auth-users-columns-BfQHf9JE.js} +525 -63
- package/dist/auth-users-columns-BfQHf9JE.js.map +1 -0
- package/dist/{backup-service-Bww-Lg0s.js → backup-service-BH0Dzo_h.js} +2 -3
- package/dist/{backup-service-Bww-Lg0s.js.map → backup-service-BH0Dzo_h.js.map} +1 -1
- package/dist/cli-output.d.ts +34 -0
- 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-8vuu-n4r.js +124 -0
- package/dist/ensure-collection-policies-8vuu-n4r.js.map +1 -0
- package/dist/{ensure-collection-tables-DRxaUG96.js → ensure-collection-tables-CbvaGuVn.js} +89 -10
- package/dist/ensure-collection-tables-CbvaGuVn.js.map +1 -0
- package/dist/index.es.js +1310 -1060
- package/dist/index.es.js.map +1 -1
- package/dist/{rls-bootstrap-sql-Bpv3nUZo.js → rls-bootstrap-sql-69hYT8nr.js} +2 -2
- package/dist/{rls-bootstrap-sql-Bpv3nUZo.js.map → rls-bootstrap-sql-69hYT8nr.js.map} +1 -1
- package/dist/rls-enforcement-BJ_3wxwg.js +425 -0
- package/dist/rls-enforcement-BJ_3wxwg.js.map +1 -0
- package/dist/schema/auth-schema.d.ts +102 -0
- package/dist/schema/doctor-policy-checks.d.ts +28 -0
- package/dist/schema/doctor.d.ts +41 -25
- package/dist/schema/ensure-collection-policies.d.ts +33 -9
- package/dist/schema/ensure-collection-tables.d.ts +60 -6
- package/dist/schema/generate-drizzle-schema-logic.d.ts +9 -1
- package/dist/schema/introspect-db-inference.d.ts +8 -1
- package/dist/schema/introspect-db-logic.d.ts +49 -0
- package/dist/schema/introspect-db-project.d.ts +21 -0
- package/dist/schema/search-column.d.ts +49 -0
- package/dist/security/policy-drift.d.ts +34 -0
- package/dist/security/rls-enforcement.d.ts +8 -3
- package/dist/services/FetchService.d.ts +9 -0
- package/dist/services/PersistService.d.ts +21 -17
- package/dist/services/RelationService.d.ts +9 -57
- package/dist/services/RelationWriteService.d.ts +82 -0
- package/dist/services/collection-helpers.d.ts +42 -0
- package/dist/services/dataService.d.ts +2 -0
- package/dist/services/junction-writes.d.ts +82 -0
- package/dist/services/realtimeService.d.ts +137 -2
- package/dist/services/write-denial.d.ts +36 -0
- package/dist/{src-C_wvdMnl.js → src-DCdn3Val.js} +35 -3
- package/dist/src-DCdn3Val.js.map +1 -0
- package/dist/utils/drizzle-conditions.d.ts +54 -1
- package/dist/{websocket-D0TBU3ia.js → websocket-C8ZqVBiV.js} +75 -18
- package/dist/websocket-C8ZqVBiV.js.map +1 -0
- package/package.json +6 -6
- package/src/PostgresBackendDriver.ts +7 -3
- package/src/PostgresBootstrapper.ts +95 -9
- package/src/auth/ensure-tables.ts +27 -5
- package/src/auth/services.ts +82 -5
- package/src/backup/backup-cli.ts +59 -57
- package/src/cli-errors.ts +6 -6
- package/src/cli-helpers.ts +4 -4
- package/src/cli-output.ts +43 -0
- package/src/cli.ts +155 -147
- package/src/collections/buildRegistry.ts +3 -1
- package/src/data-transformer.ts +111 -25
- package/src/history/ensure-history-table.ts +2 -2
- package/src/schema/auth-schema.ts +17 -1
- package/src/schema/doctor-cli.ts +14 -65
- package/src/schema/doctor-policy-checks.ts +105 -0
- package/src/schema/doctor.ts +149 -72
- package/src/schema/ensure-collection-policies.ts +99 -6
- package/src/schema/ensure-collection-tables.ts +214 -17
- package/src/schema/generate-drizzle-schema-logic.ts +121 -65
- package/src/schema/generate-drizzle-schema.ts +11 -10
- package/src/schema/generate-postgres-ddl-logic.ts +28 -1
- package/src/schema/generate-postgres-ddl.ts +14 -13
- package/src/schema/generated-schema-staleness.ts +7 -5
- package/src/schema/introspect-db-inference.ts +9 -2
- package/src/schema/introspect-db-logic.ts +251 -75
- package/src/schema/introspect-db-project.ts +78 -0
- package/src/schema/introspect-db.ts +42 -25
- package/src/schema/introspect-runtime.ts +14 -2
- package/src/schema/search-column.ts +85 -0
- package/src/security/policy-drift.test.ts +104 -3
- package/src/security/policy-drift.ts +129 -7
- package/src/security/rls-enforcement.ts +9 -4
- package/src/services/FetchService.ts +105 -7
- package/src/services/PersistService.ts +68 -42
- package/src/services/RelationService.ts +35 -695
- package/src/services/RelationWriteService.ts +653 -0
- package/src/services/cdc/trigger-cdc.ts +5 -1
- package/src/services/channel-history.ts +9 -3
- package/src/services/channel-presence.ts +10 -3
- package/src/services/collection-helpers.ts +89 -4
- package/src/services/dataService.ts +2 -0
- package/src/services/junction-writes.ts +295 -0
- package/src/services/pg-notify-listener.ts +1 -1
- package/src/services/realtimeService.ts +337 -82
- package/src/services/write-denial.ts +55 -0
- package/src/utils/drizzle-conditions.ts +211 -34
- package/src/utils/pg-error-utils.ts +8 -3
- package/src/websocket.ts +113 -16
- package/dist/auth-users-columns-Dt9g712t.js.map +0 -1
- package/dist/ensure-collection-policies-CwYUliAa.js +0 -57
- package/dist/ensure-collection-policies-CwYUliAa.js.map +0 -1
- package/dist/ensure-collection-tables-DRxaUG96.js.map +0 -1
- package/dist/policy-CPkCqVTz.js +0 -105
- package/dist/policy-CPkCqVTz.js.map +0 -1
- package/dist/src-C_wvdMnl.js.map +0 -1
- package/dist/websocket-D0TBU3ia.js.map +0 -1
|
@@ -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, LogicalCondition, 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
|
}
|
|
@@ -74,6 +105,13 @@ type RealTimeListenEntityProps = ListenOneProps & { subscriptionId: string };
|
|
|
74
105
|
* Implements the RealtimeProvider interface for database abstraction.
|
|
75
106
|
*/
|
|
76
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
|
+
|
|
77
115
|
private clients = new Map<string, WebSocket>();
|
|
78
116
|
|
|
79
117
|
// Broadcast channels: channel name → set of client IDs
|
|
@@ -131,6 +169,26 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
131
169
|
*/
|
|
132
170
|
private oversizedBroadcastWarned = new Set<string>();
|
|
133
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
|
+
|
|
134
192
|
private presenceInterval?: ReturnType<typeof setInterval>;
|
|
135
193
|
private static readonly PRESENCE_TIMEOUT_MS = 30000; // 30s
|
|
136
194
|
/** How often stale roster rows from other instances are reaped. */
|
|
@@ -378,45 +436,19 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
378
436
|
await this.handleUnsubscribe(clientId, message.subscriptionId!);
|
|
379
437
|
break;
|
|
380
438
|
|
|
381
|
-
// ── 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`.
|
|
382
444
|
case "join_channel":
|
|
383
|
-
this.joinChannel(clientId, payload?.channel as string);
|
|
384
|
-
break;
|
|
385
445
|
case "leave_channel":
|
|
386
|
-
this.leaveChannel(clientId, payload?.channel as string);
|
|
387
|
-
break;
|
|
388
446
|
case "broadcast":
|
|
389
|
-
this.broadcastToChannel(
|
|
390
|
-
clientId,
|
|
391
|
-
payload?.channel as string,
|
|
392
|
-
payload?.event as string,
|
|
393
|
-
payload?.payload
|
|
394
|
-
);
|
|
395
|
-
break;
|
|
396
447
|
case "channel_history":
|
|
397
|
-
await this.handleChannelHistoryRequest(
|
|
398
|
-
clientId,
|
|
399
|
-
payload?.channel as string,
|
|
400
|
-
payload?.sinceSeq as number | undefined,
|
|
401
|
-
payload?.limit as number | undefined
|
|
402
|
-
);
|
|
403
|
-
break;
|
|
404
|
-
|
|
405
|
-
// ── Presence ──
|
|
406
448
|
case "presence_track":
|
|
407
|
-
// Auto-join the channel so presence works without a separate join
|
|
408
|
-
this.joinChannel(clientId, payload?.channel as string);
|
|
409
|
-
this.trackPresence(
|
|
410
|
-
clientId,
|
|
411
|
-
payload?.channel as string,
|
|
412
|
-
payload?.state as Record<string, unknown> ?? {}
|
|
413
|
-
);
|
|
414
|
-
break;
|
|
415
449
|
case "presence_untrack":
|
|
416
|
-
this.removePresence(clientId, payload?.channel as string);
|
|
417
|
-
break;
|
|
418
450
|
case "presence_state":
|
|
419
|
-
this.
|
|
451
|
+
await this.handleChannelMessage(clientId, message.type, payload, authContext);
|
|
420
452
|
break;
|
|
421
453
|
|
|
422
454
|
default:
|
|
@@ -438,15 +470,42 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
438
470
|
return;
|
|
439
471
|
}
|
|
440
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
|
+
|
|
441
489
|
// Bound the client-supplied limit with the SAME guarantee the REST
|
|
442
|
-
// ingress applies (`resolveClientListLimit`):
|
|
443
|
-
//
|
|
444
|
-
// on every matching write, so an unbounded one is a DoS
|
|
445
|
-
// per write — resolve it once and reuse for the stored
|
|
446
|
-
// the initial fetch.
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
+
}
|
|
450
509
|
|
|
451
510
|
// Store subscription with full request parameters and auth context for RLS
|
|
452
511
|
this._subscriptions.set(subscriptionId, {
|
|
@@ -596,7 +655,39 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
596
655
|
}
|
|
597
656
|
|
|
598
657
|
/**
|
|
599
|
-
* Notify subscriptions for a specific path
|
|
658
|
+
* Notify subscriptions for a specific path.
|
|
659
|
+
*
|
|
660
|
+
* **A subscriber only ever receives rows re-read under its own scope.**
|
|
661
|
+
* `row` is used to decide *that* something changed, never to say *what* —
|
|
662
|
+
* every delivery below goes through a refetch that binds the subscription's
|
|
663
|
+
* own auth context.
|
|
664
|
+
*
|
|
665
|
+
* It used to be conditional. The CDC path already did the right thing: it
|
|
666
|
+
* discards the captured tuple and emits `{_rebase_invalidated: true}`, and
|
|
667
|
+
* that marker selected the refetch branch. But the marker is produced in
|
|
668
|
+
* exactly two places, and the *other* side of each branch here shipped the
|
|
669
|
+
* row it was handed straight to the socket. Two of the three entry paths
|
|
670
|
+
* took that side — every API mutation (`PostgresBackendDriver.save` passes
|
|
671
|
+
* the row it just wrote, read under the **writer's** scope) and the legacy
|
|
672
|
+
* cross-instance LISTEN handler (which re-reads on the owner connection,
|
|
673
|
+
* bypassing RLS altogether). Path matching was the only filter applied: the
|
|
674
|
+
* subscription's own `filter`/`logical` was never evaluated, and any
|
|
675
|
+
* `afterRead` redaction was the writer's rather than the reader's.
|
|
676
|
+
*
|
|
677
|
+
* A single-row subscription was the sharpest case. `subscribe_one` on a row
|
|
678
|
+
* RLS denies is accepted and answered `null`; the next update then pushed
|
|
679
|
+
* the full row with no later correction. The collection variant was merely
|
|
680
|
+
* papered over ~300 ms later by the debounced refetch — after the bytes had
|
|
681
|
+
* already reached the browser.
|
|
682
|
+
*
|
|
683
|
+
* The same defect was found and fixed on the Mongo driver in `065e2b615`
|
|
684
|
+
* (see `packages/server-mongo/test/realtime-authorization.test.ts`); this is
|
|
685
|
+
* the Postgres half, stated as one rule rather than three patched branches.
|
|
686
|
+
*
|
|
687
|
+
* The cost is the instant row-level patch that used to precede the refetch:
|
|
688
|
+
* cross-tab feedback now waits for the debounce. That is the price of not
|
|
689
|
+
* being able to know, without asking the database as this subscriber,
|
|
690
|
+
* whether this subscriber may see the row at all.
|
|
600
691
|
*/
|
|
601
692
|
private async notifyPathUpdate(notifyPath: string, originalPath: string, id: string, row: Record<string, unknown> | null, _databaseId?: string) {
|
|
602
693
|
this.debugLog(`📡 [RealtimeService] Notifying path: ${notifyPath} (original: ${originalPath})`);
|
|
@@ -631,21 +722,8 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
631
722
|
for (const [subscriptionId, subscription] of webSocketSubscriptions) {
|
|
632
723
|
try {
|
|
633
724
|
if (subscription.type === "single" && notifyPath === originalPath) {
|
|
634
|
-
|
|
635
|
-
if (row && (row as Record<string, unknown>)?._rebase_invalidated) {
|
|
636
|
-
this.debouncedSingleRefetch(subscriptionId, notifyPath, id, subscription);
|
|
637
|
-
} else {
|
|
638
|
-
this.sendSingleUpdate(subscription.clientId, subscriptionId, row);
|
|
639
|
-
}
|
|
725
|
+
this.debouncedSingleRefetch(subscriptionId, notifyPath, id, subscription);
|
|
640
726
|
} else if (subscription.type === "collection" && subscription.collectionRequest) {
|
|
641
|
-
// Phase 1: Send instant row-level patch (no DB query)
|
|
642
|
-
// This gives immediate cross-tab feedback
|
|
643
|
-
if (!row || !(row as Record<string, unknown>)?._rebase_invalidated) {
|
|
644
|
-
this.sendCollectionPatch(subscription.clientId, subscriptionId, id, row, notifyPath);
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
// Phase 2: Schedule a deferred full refetch for correctness
|
|
648
|
-
// Handles filter/sort changes and ensures consistency
|
|
649
727
|
this.debouncedCollectionRefetch(subscriptionId, notifyPath, subscription);
|
|
650
728
|
}
|
|
651
729
|
} catch (error) {
|
|
@@ -661,12 +739,7 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
661
739
|
if (!callback) continue;
|
|
662
740
|
|
|
663
741
|
if (subscription.type === "single" && notifyPath === originalPath) {
|
|
664
|
-
|
|
665
|
-
this.debouncedSingleDriverRefetch(subscriptionId, notifyPath, id, subscription, callback);
|
|
666
|
-
} else {
|
|
667
|
-
// Call the callback directly with the row (only for exact path matches)
|
|
668
|
-
callback(row);
|
|
669
|
-
}
|
|
742
|
+
this.debouncedSingleDriverRefetch(subscriptionId, notifyPath, id, subscription, callback);
|
|
670
743
|
} else if (subscription.type === "collection" && subscription.collectionRequest) {
|
|
671
744
|
// Debounce collection refetches for DataDriver subscriptions too
|
|
672
745
|
this.debouncedDriverRefetch(subscriptionId, notifyPath, subscription, callback);
|
|
@@ -770,6 +843,10 @@ roles: ["anon"] };
|
|
|
770
843
|
collectionRequest.searchString,
|
|
771
844
|
{
|
|
772
845
|
filter: collectionRequest.filter as FilterValues<string>,
|
|
846
|
+
// The subscription stored a group; the search branch
|
|
847
|
+
// did not pass it on, so a filtered live search
|
|
848
|
+
// widened to every row matching the text.
|
|
849
|
+
logical: collectionRequest.logical,
|
|
773
850
|
orderBy: collectionRequest.orderBy,
|
|
774
851
|
order: collectionRequest.order,
|
|
775
852
|
limit: collectionRequest.limit,
|
|
@@ -845,22 +922,28 @@ roles: activeAuth.roles },
|
|
|
845
922
|
});
|
|
846
923
|
}
|
|
847
924
|
|
|
848
|
-
// No driver — use dataService directly (no auth wrapping possible)
|
|
925
|
+
// No driver — use dataService directly (no auth wrapping possible).
|
|
926
|
+
// The `logical` group is carried here as well: this branch answers the
|
|
927
|
+
// same subscription as the one above, and a fallback that drops a
|
|
928
|
+
// condition returns *more* rows than the path it stands in for.
|
|
849
929
|
if (collectionRequest.searchString) {
|
|
850
930
|
return await this.dataService.searchRows(
|
|
851
931
|
notifyPath,
|
|
852
932
|
collectionRequest.searchString,
|
|
853
933
|
{
|
|
854
934
|
filter: collectionRequest.filter as FilterValues<string>,
|
|
935
|
+
logical: collectionRequest.logical,
|
|
855
936
|
orderBy: collectionRequest.orderBy,
|
|
856
937
|
order: collectionRequest.order,
|
|
857
938
|
limit: collectionRequest.limit,
|
|
858
|
-
databaseId: collectionRequest.databaseId
|
|
939
|
+
databaseId: collectionRequest.databaseId,
|
|
940
|
+
searchExplain: collectionRequest.searchExplain
|
|
859
941
|
}
|
|
860
942
|
);
|
|
861
943
|
}
|
|
862
944
|
return await this.dataService.fetchCollection(notifyPath, {
|
|
863
945
|
filter: collectionRequest.filter as FilterValues<string>,
|
|
946
|
+
logical: collectionRequest.logical,
|
|
864
947
|
orderBy: collectionRequest.orderBy,
|
|
865
948
|
order: collectionRequest.order,
|
|
866
949
|
limit: collectionRequest.limit,
|
|
@@ -1029,23 +1112,6 @@ roles: activeAuth.roles },
|
|
|
1029
1112
|
* columns and no address. The SDK holds no collection config to derive one
|
|
1030
1113
|
* from, so this is the only place the mapping can come from.
|
|
1031
1114
|
*/
|
|
1032
|
-
private sendCollectionPatch(
|
|
1033
|
-
clientId: string,
|
|
1034
|
-
subscriptionId: string,
|
|
1035
|
-
id: string,
|
|
1036
|
-
row: Record<string, unknown> | null,
|
|
1037
|
-
notifyPath: string
|
|
1038
|
-
) {
|
|
1039
|
-
const message: CollectionPatchMessage = {
|
|
1040
|
-
type: "collection_patch",
|
|
1041
|
-
subscriptionId,
|
|
1042
|
-
id,
|
|
1043
|
-
row: row,
|
|
1044
|
-
pks: this.primaryKeysForPath(notifyPath)
|
|
1045
|
-
};
|
|
1046
|
-
this.sendMessage(clientId, message);
|
|
1047
|
-
}
|
|
1048
|
-
|
|
1049
1115
|
/** The key columns of the collection at `path`, if they can be resolved. */
|
|
1050
1116
|
private primaryKeysForPath(path: string): PrimaryKeyInfo[] | undefined {
|
|
1051
1117
|
try {
|
|
@@ -1110,15 +1176,197 @@ roles: activeAuth.roles },
|
|
|
1110
1176
|
// Broadcast Channels
|
|
1111
1177
|
// =============================================================================
|
|
1112
1178
|
|
|
1179
|
+
/**
|
|
1180
|
+
* Install a channel authorizer — see {@link ChannelAuthorizer}.
|
|
1181
|
+
*
|
|
1182
|
+
* Nothing in the framework calls this yet: it is the seam a rules API will
|
|
1183
|
+
* be built on, kept deliberately separate from the membership floor so the
|
|
1184
|
+
* floor holds whether or not anyone uses it.
|
|
1185
|
+
*/
|
|
1186
|
+
setChannelAuthorizer(authorizer: ChannelAuthorizer | undefined): void {
|
|
1187
|
+
this.channelAuthorizer = authorizer;
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
/** Which action each channel frame is asking to perform. */
|
|
1191
|
+
private static readonly CHANNEL_ACTIONS: Record<string, ChannelAction> = {
|
|
1192
|
+
join_channel: "join",
|
|
1193
|
+
broadcast: "broadcast",
|
|
1194
|
+
channel_history: "history",
|
|
1195
|
+
presence_track: "join",
|
|
1196
|
+
presence_state: "presence"
|
|
1197
|
+
};
|
|
1198
|
+
|
|
1199
|
+
/**
|
|
1200
|
+
* The one door every channel frame comes through.
|
|
1201
|
+
*
|
|
1202
|
+
* Returns synchronously — and so dispatches synchronously — unless an
|
|
1203
|
+
* authorizer is installed. That matters: a client sends `join_channel`,
|
|
1204
|
+
* `presence_state` and `channel_history` back to back on connect, and the
|
|
1205
|
+
* socket's message handler processes each frame up to its first `await`,
|
|
1206
|
+
* so a gate that always yielded would let the reads overtake the join that
|
|
1207
|
+
* is about to authorize them.
|
|
1208
|
+
*/
|
|
1209
|
+
private handleChannelMessage(
|
|
1210
|
+
clientId: string,
|
|
1211
|
+
type: string,
|
|
1212
|
+
payload: Record<string, unknown> | undefined,
|
|
1213
|
+
authContext?: SubscriptionAuthContext
|
|
1214
|
+
): void | Promise<void> {
|
|
1215
|
+
const channel = payload?.channel as string;
|
|
1216
|
+
|
|
1217
|
+
// Leaving and untracking only ever remove the caller's own state, so
|
|
1218
|
+
// they need no permission — refusing them could only strand a client.
|
|
1219
|
+
if (type === "leave_channel") {
|
|
1220
|
+
this.leaveChannel(clientId, channel);
|
|
1221
|
+
return;
|
|
1222
|
+
}
|
|
1223
|
+
if (type === "presence_untrack") {
|
|
1224
|
+
this.removePresence(clientId, channel);
|
|
1225
|
+
return;
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
const action = RealtimeService.CHANNEL_ACTIONS[type];
|
|
1229
|
+
const allowed = this.authorizeChannelAction(clientId, channel, action, authContext);
|
|
1230
|
+
if (allowed === false) return;
|
|
1231
|
+
if (allowed === true) return this.dispatchChannelMessage(clientId, type, channel, payload);
|
|
1232
|
+
return allowed.then((ok) => {
|
|
1233
|
+
if (ok) return this.dispatchChannelMessage(clientId, type, channel, payload);
|
|
1234
|
+
});
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
/** Perform an already-authorized channel frame. */
|
|
1238
|
+
private dispatchChannelMessage(
|
|
1239
|
+
clientId: string,
|
|
1240
|
+
type: string,
|
|
1241
|
+
channel: string,
|
|
1242
|
+
payload: Record<string, unknown> | undefined
|
|
1243
|
+
): void | Promise<void> {
|
|
1244
|
+
switch (type) {
|
|
1245
|
+
case "join_channel":
|
|
1246
|
+
this.joinChannel(clientId, channel);
|
|
1247
|
+
return;
|
|
1248
|
+
case "broadcast":
|
|
1249
|
+
this.broadcastToChannel(clientId, channel, payload?.event as string, payload?.payload);
|
|
1250
|
+
return;
|
|
1251
|
+
case "channel_history":
|
|
1252
|
+
return this.handleChannelHistoryRequest(
|
|
1253
|
+
clientId,
|
|
1254
|
+
channel,
|
|
1255
|
+
payload?.sinceSeq as number | undefined,
|
|
1256
|
+
payload?.limit as number | undefined
|
|
1257
|
+
);
|
|
1258
|
+
case "presence_track":
|
|
1259
|
+
// Auto-join the channel so presence works without a separate join
|
|
1260
|
+
this.joinChannel(clientId, channel);
|
|
1261
|
+
this.trackPresence(clientId, channel, payload?.state as Record<string, unknown> ?? {});
|
|
1262
|
+
return;
|
|
1263
|
+
case "presence_state":
|
|
1264
|
+
this.sendPresenceState(clientId, channel);
|
|
1265
|
+
return;
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
/**
|
|
1270
|
+
* Decide whether a client may perform an action on a channel.
|
|
1271
|
+
*
|
|
1272
|
+
* **Membership is the floor.** Reading a channel's presence roster, replaying
|
|
1273
|
+
* its retained history and broadcasting into it all require that this client
|
|
1274
|
+
* has joined it. That is a low bar — joining is open to anyone who can name
|
|
1275
|
+
* the channel — but it is not the bar that was there before, which was none
|
|
1276
|
+
* at all: `channel_history` and `presence_state` answered any socket about
|
|
1277
|
+
* any channel, and a broadcast fanned out to members the sender had never
|
|
1278
|
+
* joined. Two internal tables (`rebase.channel_presence`,
|
|
1279
|
+
* `rebase.channel_messages`) are held outside RLS on the strength of this
|
|
1280
|
+
* check, so it fails closed: an authorizer that throws refuses the frame.
|
|
1281
|
+
*
|
|
1282
|
+
* Anything richer than membership belongs in a {@link ChannelAuthorizer};
|
|
1283
|
+
* this method is where it is consulted, and the only place.
|
|
1284
|
+
*/
|
|
1285
|
+
private authorizeChannelAction(
|
|
1286
|
+
clientId: string,
|
|
1287
|
+
channel: string,
|
|
1288
|
+
action: ChannelAction,
|
|
1289
|
+
authContext?: SubscriptionAuthContext
|
|
1290
|
+
): boolean | Promise<boolean> {
|
|
1291
|
+
// Joining is what establishes membership, so it cannot require it.
|
|
1292
|
+
if (action !== "join" && !this.channels.get(channel)?.has(clientId)) {
|
|
1293
|
+
this.denyChannelAction(clientId, channel, action, "not a member of the channel");
|
|
1294
|
+
return false;
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
const authorizer = this.channelAuthorizer;
|
|
1298
|
+
if (!authorizer) return true;
|
|
1299
|
+
|
|
1300
|
+
let verdict: boolean | Promise<boolean>;
|
|
1301
|
+
try {
|
|
1302
|
+
verdict = authorizer({ channel, action, clientId, user: authContext });
|
|
1303
|
+
} catch (error) {
|
|
1304
|
+
logger.error(`❌ [Channels] Authorizer threw for ${action} on "${channel}" — refusing`, { error });
|
|
1305
|
+
this.denyChannelAction(clientId, channel, action, "channel authorization failed");
|
|
1306
|
+
return false;
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
if (typeof verdict === "boolean") {
|
|
1310
|
+
if (!verdict) this.denyChannelAction(clientId, channel, action, "refused by the channel authorizer");
|
|
1311
|
+
return verdict;
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
return verdict.then(
|
|
1315
|
+
(ok) => {
|
|
1316
|
+
if (!ok) this.denyChannelAction(clientId, channel, action, "refused by the channel authorizer");
|
|
1317
|
+
return ok;
|
|
1318
|
+
},
|
|
1319
|
+
(error) => {
|
|
1320
|
+
logger.error(`❌ [Channels] Authorizer rejected for ${action} on "${channel}" — refusing`, { error });
|
|
1321
|
+
this.denyChannelAction(clientId, channel, action, "channel authorization failed");
|
|
1322
|
+
return false;
|
|
1323
|
+
}
|
|
1324
|
+
);
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
/** Tell the client why its channel frame went nowhere, and say so in the log. */
|
|
1328
|
+
private denyChannelAction(clientId: string, channel: string, action: ChannelAction, reason: string): void {
|
|
1329
|
+
this.debugLog(`🚫 [Channels] Refused ${action} on "${channel}" for ${clientId}: ${reason}`);
|
|
1330
|
+
this.sendError(
|
|
1331
|
+
clientId,
|
|
1332
|
+
`Refused ${action} on channel "${channel}": ${reason}`,
|
|
1333
|
+
undefined,
|
|
1334
|
+
"CHANNEL_FORBIDDEN"
|
|
1335
|
+
);
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1113
1338
|
/** Join a broadcast channel */
|
|
1114
1339
|
joinChannel(clientId: string, channel: string): void {
|
|
1115
1340
|
if (!this.channels.has(channel)) {
|
|
1116
1341
|
this.channels.set(channel, new Set());
|
|
1117
1342
|
}
|
|
1118
1343
|
this.channels.get(channel)!.add(clientId);
|
|
1344
|
+
this.warnIfMemoryBusOnMultiplePods();
|
|
1119
1345
|
this.debugLog(`📡 [Broadcast] Client ${clientId} joined channel: ${channel}`);
|
|
1120
1346
|
}
|
|
1121
1347
|
|
|
1348
|
+
/**
|
|
1349
|
+
* Say something the first time channels are used on a deployment that is
|
|
1350
|
+
* demonstrably multi-pod while the bus is still the in-memory default.
|
|
1351
|
+
*
|
|
1352
|
+
* Every other warning in this subsystem covers a *configured* bus failing —
|
|
1353
|
+
* the case where the operator already knew a bus mattered. The common
|
|
1354
|
+
* misconfiguration is the opposite one: scaled to two replicas, never
|
|
1355
|
+
* touched `realtime.bus`, and broadcast and presence quietly serve a
|
|
1356
|
+
* fraction of the room. The evidence is already in the process, so use it.
|
|
1357
|
+
*/
|
|
1358
|
+
private warnIfMemoryBusOnMultiplePods(): void {
|
|
1359
|
+
if (this.memoryBusWarned) return;
|
|
1360
|
+
if (this.bus.kind !== "memory" || !this.foreignInstanceSeen) return;
|
|
1361
|
+
this.memoryBusWarned = true;
|
|
1362
|
+
logger.warn(
|
|
1363
|
+
"⚠️ [ChannelBus] Channels are in use with the in-memory bus, but notifications from another " +
|
|
1364
|
+
"instance have been seen — this deployment runs more than one process. Broadcast and presence " +
|
|
1365
|
+
"reach only the clients connected to this one. Set `realtime.bus` (or REBASE_REALTIME_BUS=postgres) " +
|
|
1366
|
+
"to make channels cross-instance."
|
|
1367
|
+
);
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1122
1370
|
/** Leave a broadcast channel */
|
|
1123
1371
|
leaveChannel(clientId: string, channel: string): void {
|
|
1124
1372
|
const members = this.channels.get(channel);
|
|
@@ -1823,7 +2071,9 @@ lastSeen: Date.now() });
|
|
|
1823
2071
|
throw err;
|
|
1824
2072
|
}
|
|
1825
2073
|
this.cdcActive = true;
|
|
1826
|
-
|
|
2074
|
+
// The bootstrapper says the same thing one line later, in the
|
|
2075
|
+
// vocabulary of the setting that produced it (REALTIME_CDC).
|
|
2076
|
+
logger.debug(
|
|
1827
2077
|
`📡 [RealtimeService] Database-level change capture ACTIVE — writes from ANY source now emit realtime events ` +
|
|
1828
2078
|
`(${this.cdcTableMap.size} mapped table key(s)).`
|
|
1829
2079
|
);
|
|
@@ -2074,6 +2324,11 @@ lastSeen: Date.now() });
|
|
|
2074
2324
|
// Skip our own notifications — already processed locally
|
|
2075
2325
|
if (sid === this.instanceId) return;
|
|
2076
2326
|
|
|
2327
|
+
// A foreign sid is proof of a second process. Nothing here
|
|
2328
|
+
// needs that fact, but the channel path does — see
|
|
2329
|
+
// `warnIfMemoryBusOnMultiplePods`.
|
|
2330
|
+
this.foreignInstanceSeen = true;
|
|
2331
|
+
|
|
2077
2332
|
this.debugLog(`📡 [RealtimeService] Received cross-instance notification: path=${p}, id=${eid}, from=${sid}`);
|
|
2078
2333
|
|
|
2079
2334
|
// Refetch the row from the DB so row subscriptions
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { and, sql, SQL } from "drizzle-orm";
|
|
2
|
+
import { PgTable } from "drizzle-orm/pg-core";
|
|
3
|
+
import { ApiError } from "@rebasepro/server";
|
|
4
|
+
import { DrizzleClient } from "../interfaces";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Explain a write that matched no rows.
|
|
8
|
+
*
|
|
9
|
+
* Row-level security filters UPDATE and DELETE through the policy's USING
|
|
10
|
+
* clause instead of raising: a denied write is reported by Postgres exactly
|
|
11
|
+
* like a successful one that happened to match nothing. Left unchecked, a
|
|
12
|
+
* caller cannot tell "denied" from "done" — the write returns 200/204 and the
|
|
13
|
+
* row is untouched. An agent handed a key with `orders:delete` and no delete
|
|
14
|
+
* policy is the case that makes it concrete: it deletes nothing, forever, and
|
|
15
|
+
* is told it worked every time.
|
|
16
|
+
*
|
|
17
|
+
* Re-reading the target over the *same* RLS-scoped handle separates the two
|
|
18
|
+
* cases. A visible row means the policy rejected the write (403); an invisible
|
|
19
|
+
* one means there is nothing there to write for this caller (404, matching what
|
|
20
|
+
* a GET would say). The re-read is bound by the caller's own policies, so it
|
|
21
|
+
* discloses nothing a plain read wouldn't.
|
|
22
|
+
*
|
|
23
|
+
* Only reached when zero rows matched, so the happy path pays nothing.
|
|
24
|
+
*
|
|
25
|
+
* It lives here, rather than beside its first caller, because every zero-row
|
|
26
|
+
* write has to answer the same question and answer it identically: the rule
|
|
27
|
+
* that a readable-but-unwritable target is a 403 is the contract, and a second
|
|
28
|
+
* copy of it is a second chance to get it wrong.
|
|
29
|
+
*
|
|
30
|
+
* @param handle The RLS-scoped connection the write ran on — not a fresh
|
|
31
|
+
* one, or the re-read would answer for a different caller.
|
|
32
|
+
* @param table The table the write targeted (the junction, for a link).
|
|
33
|
+
* @param conditions The write's own WHERE terms, reused verbatim.
|
|
34
|
+
* @param denied Message for the 403: the target is there and was refused.
|
|
35
|
+
* @param missing Message for the 404: there is nothing there for this caller.
|
|
36
|
+
*/
|
|
37
|
+
export async function explainZeroRowWrite(
|
|
38
|
+
handle: DrizzleClient,
|
|
39
|
+
table: PgTable,
|
|
40
|
+
conditions: SQL[],
|
|
41
|
+
denied: string,
|
|
42
|
+
missing: string
|
|
43
|
+
): Promise<ApiError> {
|
|
44
|
+
const visible = await handle
|
|
45
|
+
.select({ present: sql<number>`1` })
|
|
46
|
+
.from(table)
|
|
47
|
+
.where(and(...conditions))
|
|
48
|
+
.limit(1);
|
|
49
|
+
|
|
50
|
+
if (visible.length > 0) {
|
|
51
|
+
return ApiError.forbidden(denied, "WRITE_DENIED");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return ApiError.notFound(missing);
|
|
55
|
+
}
|