@rebasepro/server-postgresql 0.1.2 → 0.2.3
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/LICENSE +22 -6
- package/dist/common/src/data/query_builder.d.ts +51 -0
- package/dist/common/src/index.d.ts +1 -0
- package/dist/common/src/util/entities.d.ts +2 -2
- package/dist/common/src/util/relations.d.ts +1 -1
- package/dist/index.es.js +1435 -738
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +1433 -736
- package/dist/index.umd.js.map +1 -1
- package/dist/server-postgresql/src/PostgresAdapter.d.ts +6 -0
- package/dist/server-postgresql/src/PostgresBackendDriver.d.ts +2 -1
- package/dist/server-postgresql/src/PostgresBootstrapper.d.ts +0 -5
- package/dist/server-postgresql/src/auth/ensure-tables.d.ts +2 -1
- package/dist/server-postgresql/src/auth/services.d.ts +37 -15
- package/dist/server-postgresql/src/index.d.ts +1 -0
- package/dist/server-postgresql/src/schema/auth-schema.d.ts +43 -856
- package/dist/server-postgresql/src/schema/default-collections.d.ts +2 -0
- package/dist/server-postgresql/src/schema/doctor.d.ts +10 -1
- package/dist/server-postgresql/src/schema/introspect-db-logic.d.ts +1 -0
- package/dist/server-postgresql/src/services/entity-helpers.d.ts +1 -1
- package/dist/server-postgresql/src/services/realtimeService.d.ts +12 -0
- package/dist/server-postgresql/src/websocket.d.ts +2 -1
- package/dist/types/src/controllers/auth.d.ts +9 -8
- package/dist/types/src/controllers/client.d.ts +3 -0
- package/dist/types/src/controllers/data.d.ts +21 -0
- package/dist/types/src/types/auth_adapter.d.ts +356 -0
- package/dist/types/src/types/collections.d.ts +67 -2
- package/dist/types/src/types/database_adapter.d.ts +94 -0
- package/dist/types/src/types/entity_actions.d.ts +7 -1
- package/dist/types/src/types/entity_callbacks.d.ts +1 -1
- package/dist/types/src/types/entity_views.d.ts +36 -1
- package/dist/types/src/types/index.d.ts +2 -0
- package/dist/types/src/types/plugins.d.ts +1 -1
- package/dist/types/src/types/properties.d.ts +24 -5
- package/dist/types/src/types/property_config.d.ts +6 -2
- package/dist/types/src/types/relations.d.ts +1 -1
- package/dist/types/src/types/translations.d.ts +8 -0
- package/dist/types/src/users/user.d.ts +5 -0
- package/package.json +22 -15
- package/src/PostgresAdapter.ts +59 -0
- package/src/PostgresBackendDriver.ts +66 -13
- package/src/PostgresBootstrapper.ts +35 -15
- package/src/auth/ensure-tables.ts +82 -189
- package/src/auth/services.ts +421 -170
- package/src/cli.ts +49 -13
- package/src/data-transformer.ts +78 -8
- package/src/history/HistoryService.ts +25 -2
- package/src/index.ts +1 -0
- package/src/schema/auth-schema.ts +130 -98
- package/src/schema/default-collections.ts +69 -0
- package/src/schema/doctor-cli.ts +5 -1
- package/src/schema/doctor.ts +166 -48
- package/src/schema/generate-drizzle-schema-logic.ts +74 -27
- package/src/schema/generate-drizzle-schema.ts +13 -3
- package/src/schema/introspect-db-inference.ts +5 -5
- package/src/schema/introspect-db-logic.ts +9 -2
- package/src/schema/introspect-db.ts +14 -3
- package/src/services/EntityFetchService.ts +5 -5
- package/src/services/RelationService.ts +2 -2
- package/src/services/entity-helpers.ts +1 -1
- package/src/services/realtimeService.ts +145 -136
- package/src/utils/drizzle-conditions.ts +16 -2
- package/src/websocket.ts +113 -37
- package/test/auth-services.test.ts +163 -74
- package/test/data-transformer-hardening.test.ts +57 -0
- package/test/data-transformer.test.ts +43 -0
- package/test/generate-drizzle-schema.test.ts +7 -5
- package/test/introspect-db-utils.test.ts +4 -1
- package/test/postgresDataDriver.test.ts +147 -1
- package/test/realtimeService.test.ts +7 -7
- package/test/websocket.test.ts +139 -0
|
@@ -23,6 +23,10 @@ export interface SubscriptionAuthContext {
|
|
|
23
23
|
roles: string[];
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
interface DataDriverWithData extends DataDriver {
|
|
27
|
+
data: unknown;
|
|
28
|
+
}
|
|
29
|
+
|
|
26
30
|
type RealTimeListenCollectionProps = ListenCollectionProps & {
|
|
27
31
|
subscriptionId: string
|
|
28
32
|
};
|
|
@@ -283,29 +287,18 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
283
287
|
});
|
|
284
288
|
|
|
285
289
|
// Send initial data
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
path: request.path,
|
|
290
|
-
collection: collection,
|
|
291
|
-
filter: request.filter,
|
|
292
|
-
orderBy: request.orderBy,
|
|
293
|
-
order: request.order,
|
|
294
|
-
limit: request.limit,
|
|
295
|
-
startAfter: request.startAfter,
|
|
296
|
-
searchString: request.searchString
|
|
297
|
-
});
|
|
298
|
-
} else {
|
|
299
|
-
entities = await this.entityService.fetchCollection(request.path, {
|
|
290
|
+
const entities = await this.fetchCollectionWithAuth(
|
|
291
|
+
request.path,
|
|
292
|
+
{
|
|
300
293
|
filter: request.filter,
|
|
301
294
|
orderBy: request.orderBy,
|
|
302
295
|
order: request.order,
|
|
303
296
|
limit: request.limit,
|
|
304
297
|
startAfter: request.startAfter as Record<string, unknown> | undefined,
|
|
305
|
-
databaseId: request.collection?.databaseId,
|
|
306
298
|
searchString: request.searchString
|
|
307
|
-
}
|
|
308
|
-
|
|
299
|
+
},
|
|
300
|
+
authContext
|
|
301
|
+
);
|
|
309
302
|
|
|
310
303
|
this.sendCollectionUpdate(clientId, subscriptionId, entities);
|
|
311
304
|
|
|
@@ -338,20 +331,11 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
338
331
|
});
|
|
339
332
|
|
|
340
333
|
// Send initial data
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
collection: collection
|
|
347
|
-
});
|
|
348
|
-
} else {
|
|
349
|
-
entity = await this.entityService.fetchEntity(
|
|
350
|
-
request.path,
|
|
351
|
-
request.entityId,
|
|
352
|
-
request.collection?.databaseId
|
|
353
|
-
);
|
|
354
|
-
}
|
|
334
|
+
const entity = await this.fetchEntityWithAuth(
|
|
335
|
+
request.path,
|
|
336
|
+
String(request.entityId),
|
|
337
|
+
authContext
|
|
338
|
+
);
|
|
355
339
|
|
|
356
340
|
this.sendEntityUpdate(clientId, subscriptionId, entity || null);
|
|
357
341
|
|
|
@@ -565,29 +549,28 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
565
549
|
searchString: collectionRequest.searchString
|
|
566
550
|
});
|
|
567
551
|
|
|
568
|
-
//
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
{
|
|
552
|
+
// Always wrap in a transaction with session vars, defaulting to anonymous context if missing
|
|
553
|
+
const activeAuth = authContext || { userId: "anon", roles: ["anon"] };
|
|
554
|
+
return await this.db.transaction(async (tx) => {
|
|
555
|
+
await tx.execute(drizzleSql`SELECT set_config('app.user_id', ${activeAuth.userId}, true)`);
|
|
556
|
+
await tx.execute(drizzleSql`SELECT set_config('app.user_roles', ${activeAuth.roles.join(",")}, true)`);
|
|
557
|
+
await tx.execute(drizzleSql`SELECT set_config('app.jwt', ${JSON.stringify({ sub: activeAuth.userId, roles: activeAuth.roles })}, true)`);
|
|
558
|
+
const txEntityService = new EntityService(tx, this.registry);
|
|
559
|
+
let fetchedEntities;
|
|
560
|
+
if (collectionRequest.searchString) {
|
|
561
|
+
fetchedEntities = await txEntityService.searchEntities(
|
|
562
|
+
notifyPath,
|
|
563
|
+
collectionRequest.searchString,
|
|
564
|
+
{
|
|
582
565
|
filter: collectionRequest.filter as FilterValues<string>,
|
|
583
566
|
orderBy: collectionRequest.orderBy,
|
|
584
567
|
order: collectionRequest.order,
|
|
585
568
|
limit: collectionRequest.limit,
|
|
586
569
|
databaseId: collectionRequest.databaseId
|
|
587
570
|
}
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
571
|
+
);
|
|
572
|
+
} else {
|
|
573
|
+
fetchedEntities = await txEntityService.fetchCollection(notifyPath, {
|
|
591
574
|
filter: collectionRequest.filter as FilterValues<string>,
|
|
592
575
|
orderBy: collectionRequest.orderBy,
|
|
593
576
|
order: collectionRequest.order,
|
|
@@ -596,52 +579,48 @@ roles: authContext.roles })}, true)`);
|
|
|
596
579
|
startAfter: collectionRequest.startAfter,
|
|
597
580
|
databaseId: collectionRequest.databaseId
|
|
598
581
|
});
|
|
599
|
-
|
|
582
|
+
}
|
|
600
583
|
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
584
|
+
// Re-apply `afterRead` lifecycle hooks to ensure consistent data structures
|
|
585
|
+
// between the initial driver fetch and this RLS-bound refetch.
|
|
586
|
+
const registryCollection = this.registry.getCollectionByPath(notifyPath);
|
|
587
|
+
const resolvedCollection = collection ? { ...collection,
|
|
605
588
|
...registryCollection } as EntityCollection : registryCollection as EntityCollection;
|
|
606
589
|
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
return fetchedEntities;
|
|
641
|
-
});
|
|
642
|
-
}
|
|
590
|
+
const callbacks = resolvedCollection?.callbacks;
|
|
591
|
+
const propertyCallbacks = resolvedCollection?.properties ? buildPropertyCallbacks(resolvedCollection.properties) : undefined;
|
|
592
|
+
|
|
593
|
+
if (callbacks?.afterRead || propertyCallbacks?.afterRead) {
|
|
594
|
+
const contextForCallback = {
|
|
595
|
+
user: { uid: activeAuth.userId, roles: activeAuth.roles },
|
|
596
|
+
driver: this.driver,
|
|
597
|
+
data: (this.driver && "data" in this.driver) ? (this.driver as DataDriverWithData).data : undefined
|
|
598
|
+
} as unknown as RebaseCallContext;
|
|
599
|
+
|
|
600
|
+
return await Promise.all(fetchedEntities.map(async (entity) => {
|
|
601
|
+
let processedEntity = entity;
|
|
602
|
+
if (callbacks?.afterRead) {
|
|
603
|
+
processedEntity = await callbacks.afterRead({
|
|
604
|
+
collection: resolvedCollection,
|
|
605
|
+
path: notifyPath,
|
|
606
|
+
entity: processedEntity,
|
|
607
|
+
context: contextForCallback
|
|
608
|
+
}) ?? processedEntity;
|
|
609
|
+
}
|
|
610
|
+
if (propertyCallbacks?.afterRead) {
|
|
611
|
+
processedEntity = await propertyCallbacks.afterRead({
|
|
612
|
+
collection: resolvedCollection,
|
|
613
|
+
path: notifyPath,
|
|
614
|
+
entity: processedEntity,
|
|
615
|
+
context: contextForCallback
|
|
616
|
+
}) ?? processedEntity;
|
|
617
|
+
}
|
|
618
|
+
return processedEntity;
|
|
619
|
+
}));
|
|
620
|
+
}
|
|
643
621
|
|
|
644
|
-
|
|
622
|
+
return fetchedEntities;
|
|
623
|
+
});
|
|
645
624
|
}
|
|
646
625
|
|
|
647
626
|
// No driver — use entityService directly (no auth wrapping possible)
|
|
@@ -726,7 +705,7 @@ roles: authContext.roles },
|
|
|
726
705
|
*/
|
|
727
706
|
private async fetchEntityWithAuth(
|
|
728
707
|
notifyPath: string,
|
|
729
|
-
entityId: string,
|
|
708
|
+
entityId: string | number,
|
|
730
709
|
authContext?: SubscriptionAuthContext
|
|
731
710
|
): Promise<Entity | undefined> {
|
|
732
711
|
if (this.driver) {
|
|
@@ -737,56 +716,51 @@ roles: authContext.roles },
|
|
|
737
716
|
collection
|
|
738
717
|
});
|
|
739
718
|
|
|
740
|
-
//
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
const resolvedCollection = collection ? { ...collection,
|
|
719
|
+
// Always wrap in a transaction with session vars, defaulting to anonymous context if missing
|
|
720
|
+
const activeAuth = authContext || { userId: "anon", roles: ["anon"] };
|
|
721
|
+
return await this.db.transaction(async (tx) => {
|
|
722
|
+
await tx.execute(drizzleSql`SELECT set_config('app.user_id', ${activeAuth.userId}, true)`);
|
|
723
|
+
await tx.execute(drizzleSql`SELECT set_config('app.user_roles', ${activeAuth.roles.join(",")}, true)`);
|
|
724
|
+
await tx.execute(drizzleSql`SELECT set_config('app.jwt', ${JSON.stringify({ sub: activeAuth.userId, roles: activeAuth.roles })}, true)`);
|
|
725
|
+
const txEntityService = new EntityService(tx, this.registry);
|
|
726
|
+
let processedEntity = await txEntityService.fetchEntity(notifyPath, entityId, collection?.databaseId);
|
|
727
|
+
|
|
728
|
+
if (processedEntity) {
|
|
729
|
+
const registryCollection = this.registry.getCollectionByPath(notifyPath);
|
|
730
|
+
const resolvedCollection = collection ? { ...collection,
|
|
753
731
|
...registryCollection } as EntityCollection : registryCollection as EntityCollection;
|
|
754
732
|
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
}) ?? processedEntity;
|
|
781
|
-
}
|
|
733
|
+
const callbacks = resolvedCollection?.callbacks;
|
|
734
|
+
const propertyCallbacks = resolvedCollection?.properties ? buildPropertyCallbacks(resolvedCollection.properties) : undefined;
|
|
735
|
+
|
|
736
|
+
if (callbacks?.afterRead || propertyCallbacks?.afterRead) {
|
|
737
|
+
const contextForCallback = {
|
|
738
|
+
user: { uid: activeAuth.userId, roles: activeAuth.roles },
|
|
739
|
+
driver: this.driver,
|
|
740
|
+
data: (this.driver && "data" in this.driver) ? (this.driver as DataDriverWithData).data : undefined
|
|
741
|
+
} as unknown as RebaseCallContext;
|
|
742
|
+
|
|
743
|
+
if (callbacks?.afterRead) {
|
|
744
|
+
processedEntity = await callbacks.afterRead({
|
|
745
|
+
collection: resolvedCollection,
|
|
746
|
+
path: notifyPath,
|
|
747
|
+
entity: processedEntity,
|
|
748
|
+
context: contextForCallback
|
|
749
|
+
}) ?? processedEntity;
|
|
750
|
+
}
|
|
751
|
+
if (propertyCallbacks?.afterRead) {
|
|
752
|
+
processedEntity = await propertyCallbacks.afterRead({
|
|
753
|
+
collection: resolvedCollection,
|
|
754
|
+
path: notifyPath,
|
|
755
|
+
entity: processedEntity,
|
|
756
|
+
context: contextForCallback
|
|
757
|
+
}) ?? processedEntity;
|
|
782
758
|
}
|
|
783
759
|
}
|
|
760
|
+
}
|
|
784
761
|
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
}
|
|
788
|
-
|
|
789
|
-
return fetchFn();
|
|
762
|
+
return processedEntity;
|
|
763
|
+
});
|
|
790
764
|
}
|
|
791
765
|
|
|
792
766
|
return await this.entityService.fetchEntity(notifyPath, entityId);
|
|
@@ -865,6 +839,41 @@ roles: authContext.roles },
|
|
|
865
839
|
|
|
866
840
|
return parentPaths;
|
|
867
841
|
}
|
|
842
|
+
// =============================================================================
|
|
843
|
+
// Lifecycle / Cleanup
|
|
844
|
+
// =============================================================================
|
|
845
|
+
|
|
846
|
+
/**
|
|
847
|
+
* Gracefully tear down all realtime resources.
|
|
848
|
+
*
|
|
849
|
+
* This MUST be called during process shutdown, **before** `pool.end()`.
|
|
850
|
+
* It ensures:
|
|
851
|
+
* 1. All debounced refetch timers are cancelled (prevents queries after pool closes).
|
|
852
|
+
* 2. All subscription state and callbacks are cleared.
|
|
853
|
+
* 3. The dedicated LISTEN client (outside the pool) is disconnected.
|
|
854
|
+
* 4. All WebSocket clients are removed (but not forcefully closed — the
|
|
855
|
+
* HTTP server close will handle that).
|
|
856
|
+
*/
|
|
857
|
+
async destroy(): Promise<void> {
|
|
858
|
+
// 1. Cancel every pending debounced refetch timer
|
|
859
|
+
for (const [key, timer] of this.refetchTimers) {
|
|
860
|
+
clearTimeout(timer);
|
|
861
|
+
this.refetchTimers.delete(key);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// 2. Clear subscriptions and callbacks
|
|
865
|
+
this._subscriptions.clear();
|
|
866
|
+
this.subscriptionCallbacks.clear();
|
|
867
|
+
|
|
868
|
+
// 3. Disconnect the dedicated LISTEN client
|
|
869
|
+
await this.stopListening();
|
|
870
|
+
|
|
871
|
+
// 4. Drop client references (don't close — server.close drains them)
|
|
872
|
+
this.clients.clear();
|
|
873
|
+
|
|
874
|
+
this.debugLog("🧹 [RealtimeService] destroy() complete — all resources released.");
|
|
875
|
+
}
|
|
876
|
+
|
|
868
877
|
// =============================================================================
|
|
869
878
|
// Cross-Instance LISTEN/NOTIFY
|
|
870
879
|
// =============================================================================
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { and, eq, or, sql, SQL, ilike, inArray } from "drizzle-orm";
|
|
2
|
-
import { AnyPgColumn, PgTable } from "drizzle-orm/pg-core";
|
|
2
|
+
import { AnyPgColumn, PgTable, PgVarchar, PgText, PgChar } from "drizzle-orm/pg-core";
|
|
3
3
|
import { FilterValues, WhereFilterOp, Relation, JoinStep } from "@rebasepro/types";
|
|
4
4
|
import { getColumnName, resolveCollectionRelations } from "@rebasepro/common";
|
|
5
5
|
import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
|
|
@@ -64,8 +64,14 @@ export class DrizzleConditionBuilder {
|
|
|
64
64
|
): SQL | null {
|
|
65
65
|
switch (op) {
|
|
66
66
|
case "==":
|
|
67
|
+
if (value === null || value === undefined) {
|
|
68
|
+
return sql`${column} IS NULL`;
|
|
69
|
+
}
|
|
67
70
|
return eq(column, value);
|
|
68
71
|
case "!=":
|
|
72
|
+
if (value === null || value === undefined) {
|
|
73
|
+
return sql`${column} IS NOT NULL`;
|
|
74
|
+
}
|
|
69
75
|
return sql`${column} != ${value}`;
|
|
70
76
|
case ">":
|
|
71
77
|
return sql`${column} > ${value}`;
|
|
@@ -647,7 +653,15 @@ export class DrizzleConditionBuilder {
|
|
|
647
653
|
if (p.type === "string" && !p.enum && p.isId !== "uuid") {
|
|
648
654
|
const fieldColumn = table[key as keyof typeof table] as AnyPgColumn;
|
|
649
655
|
if (fieldColumn) {
|
|
650
|
-
|
|
656
|
+
// Verify that the underlying database column supports string pattern-matching
|
|
657
|
+
const supportsILike =
|
|
658
|
+
fieldColumn instanceof PgVarchar ||
|
|
659
|
+
fieldColumn instanceof PgText ||
|
|
660
|
+
fieldColumn instanceof PgChar ||
|
|
661
|
+
(fieldColumn && typeof fieldColumn === "object" && !("columnType" in fieldColumn));
|
|
662
|
+
if (supportsILike) {
|
|
663
|
+
searchConditions.push(ilike(fieldColumn, `%${searchString}%`));
|
|
664
|
+
}
|
|
651
665
|
}
|
|
652
666
|
}
|
|
653
667
|
}
|
package/src/websocket.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { RealtimeService } from "./services/realtimeService";
|
|
2
2
|
import { PostgresBackendDriver } from "./PostgresBackendDriver";
|
|
3
|
-
import { DataDriver, DeleteEntityProps, FetchCollectionProps, FetchEntityProps, SaveEntityProps, TableMetadata, BranchInfo, isSQLAdmin, isSchemaAdmin } from "@rebasepro/types";
|
|
3
|
+
import { DataDriver, DeleteEntityProps, FetchCollectionProps, FetchEntityProps, SaveEntityProps, TableMetadata, BranchInfo, isSQLAdmin, isSchemaAdmin, AuthAdapter } from "@rebasepro/types";
|
|
4
4
|
import { WebSocketServer, WebSocket } from "ws";
|
|
5
5
|
import { Server } from "http";
|
|
6
6
|
import { inspect } from "util";
|
|
@@ -9,9 +9,18 @@ import { extractUserFromToken, AccessTokenPayload } from "@rebasepro/server-core
|
|
|
9
9
|
// @ts-ignore
|
|
10
10
|
import { AuthConfig } from "@rebasepro/server-core";
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Normalized user identity for WebSocket sessions.
|
|
14
|
+
*/
|
|
15
|
+
interface WsUserIdentity {
|
|
16
|
+
userId: string;
|
|
17
|
+
roles: string[];
|
|
18
|
+
isAdmin: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
12
21
|
interface ClientSession {
|
|
13
22
|
ws: WebSocket;
|
|
14
|
-
user?:
|
|
23
|
+
user?: WsUserIdentity;
|
|
15
24
|
authenticated: boolean;
|
|
16
25
|
/** Sliding window message counter for rate limiting */
|
|
17
26
|
messageCount: number;
|
|
@@ -38,11 +47,31 @@ const ADMIN_ONLY_TYPES = new Set([
|
|
|
38
47
|
"LIST_BRANCHES"
|
|
39
48
|
]);
|
|
40
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Recursively extract the deepest error message from an error's cause chain (e.g., Drizzle wrapping a PG error).
|
|
52
|
+
*/
|
|
53
|
+
function extractErrorMessage(error: unknown): string {
|
|
54
|
+
if (!error) return "Unknown error";
|
|
55
|
+
if (typeof error === "object") {
|
|
56
|
+
const err = error as Record<string, unknown> & { cause?: unknown; message?: string };
|
|
57
|
+
if (err.cause) {
|
|
58
|
+
return extractErrorMessage(err.cause);
|
|
59
|
+
}
|
|
60
|
+
if (typeof err.message === "string") {
|
|
61
|
+
return err.message;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return String(error);
|
|
65
|
+
}
|
|
66
|
+
|
|
41
67
|
/**
|
|
42
68
|
* Check if the current session belongs to an admin user.
|
|
43
69
|
*/
|
|
44
70
|
function isAdminSession(session: ClientSession | undefined): boolean {
|
|
45
|
-
if (!session?.user
|
|
71
|
+
if (!session?.user) return false;
|
|
72
|
+
// Fast path: new adapter-aware sessions set isAdmin directly
|
|
73
|
+
if (session.user.isAdmin) return true;
|
|
74
|
+
if (!session.user.roles) return false;
|
|
46
75
|
return session.user.roles.some((r: unknown) => {
|
|
47
76
|
if (typeof r === "string") return r === "admin";
|
|
48
77
|
if (r && typeof r === "object" && "isAdmin" in r) return (r as { isAdmin: boolean }).isAdmin;
|
|
@@ -55,7 +84,8 @@ export function createPostgresWebSocket(
|
|
|
55
84
|
server: Server,
|
|
56
85
|
realtimeService: RealtimeService,
|
|
57
86
|
driver: PostgresBackendDriver,
|
|
58
|
-
authConfig?: AuthConfig
|
|
87
|
+
authConfig?: AuthConfig,
|
|
88
|
+
authAdapter?: AuthAdapter
|
|
59
89
|
) {
|
|
60
90
|
const isProduction = process.env.NODE_ENV === "production";
|
|
61
91
|
/** Debug logger that is suppressed in production to prevent PII/data leaks */
|
|
@@ -74,7 +104,11 @@ export function createPostgresWebSocket(
|
|
|
74
104
|
console.error("❌ [WebSocket Server] Error:", err);
|
|
75
105
|
});
|
|
76
106
|
|
|
77
|
-
|
|
107
|
+
// Auth is required when either: an adapter is present (secure by default),
|
|
108
|
+
// OR the config has a jwtSecret and requireAuth !== false.
|
|
109
|
+
const requireAuth = authAdapter
|
|
110
|
+
? true
|
|
111
|
+
: (authConfig?.requireAuth !== false && !!authConfig?.jwtSecret);
|
|
78
112
|
|
|
79
113
|
wss.on("connection", (ws) => {
|
|
80
114
|
const clientId = `client_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
|
@@ -123,21 +157,53 @@ code } }
|
|
|
123
157
|
return;
|
|
124
158
|
}
|
|
125
159
|
|
|
126
|
-
|
|
127
|
-
|
|
160
|
+
// Use the auth adapter when available (custom auth, Clerk, etc.)
|
|
161
|
+
// Fall back to JWT extraction otherwise.
|
|
162
|
+
let verifiedUser: WsUserIdentity | null = null;
|
|
163
|
+
|
|
164
|
+
if (authAdapter) {
|
|
165
|
+
try {
|
|
166
|
+
const adapterUser = authAdapter.verifyToken
|
|
167
|
+
? await authAdapter.verifyToken(token)
|
|
168
|
+
: await authAdapter.verifyRequest(new Request("http://localhost/_ws_auth", {
|
|
169
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
170
|
+
}));
|
|
171
|
+
|
|
172
|
+
if (adapterUser) {
|
|
173
|
+
verifiedUser = {
|
|
174
|
+
userId: adapterUser.uid,
|
|
175
|
+
roles: adapterUser.roles,
|
|
176
|
+
isAdmin: adapterUser.isAdmin,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
} catch {
|
|
180
|
+
// Adapter threw — treat as invalid token
|
|
181
|
+
}
|
|
182
|
+
} else {
|
|
183
|
+
// Standard JWT path
|
|
184
|
+
const jwtPayload = extractUserFromToken(token);
|
|
185
|
+
if (jwtPayload) {
|
|
186
|
+
verifiedUser = {
|
|
187
|
+
userId: jwtPayload.userId,
|
|
188
|
+
roles: jwtPayload.roles ?? [],
|
|
189
|
+
isAdmin: (jwtPayload.roles ?? []).some((r: string) => r === "admin"),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (verifiedUser) {
|
|
128
195
|
const session = clientSessions.get(clientId);
|
|
129
196
|
if (session) {
|
|
130
|
-
session.user =
|
|
197
|
+
session.user = verifiedUser;
|
|
131
198
|
session.authenticated = true;
|
|
132
199
|
}
|
|
133
200
|
wsDebug(`[WS] replying AUTH_SUCCESS for requestId ${requestId}`);
|
|
134
201
|
ws.send(JSON.stringify({
|
|
135
202
|
type: "AUTH_SUCCESS",
|
|
136
203
|
requestId,
|
|
137
|
-
payload: { userId:
|
|
138
|
-
roles: user.roles }
|
|
204
|
+
payload: { userId: verifiedUser.userId, roles: verifiedUser.roles }
|
|
139
205
|
}));
|
|
140
|
-
wsDebug(`🔐 [WebSocket Server] Client ${clientId} authenticated as ${
|
|
206
|
+
wsDebug(`🔐 [WebSocket Server] Client ${clientId} authenticated as ${verifiedUser.userId}`);
|
|
141
207
|
} else {
|
|
142
208
|
wsDebug(`[WS] replying AUTH_ERROR for requestId ${requestId} (invalid token)`);
|
|
143
209
|
sendError("AUTH_ERROR", "INVALID_TOKEN", "Invalid or expired token");
|
|
@@ -183,17 +249,21 @@ roles: user.roles }
|
|
|
183
249
|
// Helper to get correctly scoped delegate for the current request
|
|
184
250
|
const getScopedDelegate = async (): Promise<DataDriver> => {
|
|
185
251
|
const session = clientSessions.get(clientId);
|
|
186
|
-
if (
|
|
252
|
+
if ("withAuth" in driver && typeof (driver as unknown as Record<string, unknown>).withAuth === "function") {
|
|
187
253
|
try {
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
254
|
+
const userForAuth = session?.user
|
|
255
|
+
? {
|
|
256
|
+
uid: session.user.userId,
|
|
257
|
+
roles: session.user.roles ?? []
|
|
258
|
+
}
|
|
259
|
+
: {
|
|
260
|
+
uid: "anon",
|
|
261
|
+
roles: ["anon"]
|
|
262
|
+
};
|
|
193
263
|
return await (driver as unknown as { withAuth: (user: Record<string, unknown>) => Promise<DataDriver> }).withAuth(userForAuth);
|
|
194
264
|
} catch (e) {
|
|
195
|
-
console.error("Failed to create
|
|
196
|
-
|
|
265
|
+
console.error("Failed to create RLS scoped delegate for WS request", e);
|
|
266
|
+
throw new Error("Internal authentication error");
|
|
197
267
|
}
|
|
198
268
|
}
|
|
199
269
|
return driver;
|
|
@@ -306,22 +376,29 @@ colors: true }));
|
|
|
306
376
|
|
|
307
377
|
case "EXECUTE_SQL": {
|
|
308
378
|
const { sql, options } = payload;
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
379
|
+
try {
|
|
380
|
+
const delegate = await getScopedDelegate();
|
|
381
|
+
const admin = delegate.admin;
|
|
382
|
+
if (!isSQLAdmin(admin)) {
|
|
383
|
+
sendError("ERROR", "NOT_SUPPORTED", "SQL execution is not available for this driver.");
|
|
384
|
+
break;
|
|
385
|
+
}
|
|
386
|
+
const result = await admin.executeSql(sql, options);
|
|
387
|
+
if (process.env.NODE_ENV !== "production") {
|
|
388
|
+
wsDebug(`⚡ [WebSocket Server] SQL executed. Returned ${Array.isArray(result) ? result.length : "non-array"} rows.`);
|
|
389
|
+
}
|
|
390
|
+
const response = {
|
|
391
|
+
type: "EXECUTE_SQL_SUCCESS",
|
|
392
|
+
payload: { result },
|
|
393
|
+
requestId
|
|
394
|
+
};
|
|
395
|
+
ws.send(JSON.stringify(response));
|
|
396
|
+
} catch (sqlError: unknown) {
|
|
397
|
+
// This is a query execution error (e.g., syntax error, permission denied).
|
|
398
|
+
// We return it cleanly to the client without logging a server stack trace.
|
|
399
|
+
const errMsg = extractErrorMessage(sqlError);
|
|
400
|
+
sendError("ERROR", "SQL_ERROR", errMsg);
|
|
318
401
|
}
|
|
319
|
-
const response = {
|
|
320
|
-
type: "EXECUTE_SQL_SUCCESS",
|
|
321
|
-
payload: { result },
|
|
322
|
-
requestId
|
|
323
|
-
};
|
|
324
|
-
ws.send(JSON.stringify(response));
|
|
325
402
|
}
|
|
326
403
|
break;
|
|
327
404
|
|
|
@@ -478,9 +555,8 @@ colors: true }));
|
|
|
478
555
|
// Attach auth context from the WS session so RLS-aware refetches work
|
|
479
556
|
const session = clientSessions.get(clientId);
|
|
480
557
|
const authContext = session?.user
|
|
481
|
-
? { userId: session.user.userId,
|
|
482
|
-
|
|
483
|
-
: undefined;
|
|
558
|
+
? { userId: session.user.userId, roles: session.user.roles ?? [] }
|
|
559
|
+
: { userId: "anon", roles: ["anon"] };
|
|
484
560
|
// Let RealtimeService handle these messages
|
|
485
561
|
await realtimeService.handleClientMessage(clientId, {
|
|
486
562
|
type,
|