@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.
Files changed (71) hide show
  1. package/LICENSE +22 -6
  2. package/dist/common/src/data/query_builder.d.ts +51 -0
  3. package/dist/common/src/index.d.ts +1 -0
  4. package/dist/common/src/util/entities.d.ts +2 -2
  5. package/dist/common/src/util/relations.d.ts +1 -1
  6. package/dist/index.es.js +1435 -738
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/index.umd.js +1433 -736
  9. package/dist/index.umd.js.map +1 -1
  10. package/dist/server-postgresql/src/PostgresAdapter.d.ts +6 -0
  11. package/dist/server-postgresql/src/PostgresBackendDriver.d.ts +2 -1
  12. package/dist/server-postgresql/src/PostgresBootstrapper.d.ts +0 -5
  13. package/dist/server-postgresql/src/auth/ensure-tables.d.ts +2 -1
  14. package/dist/server-postgresql/src/auth/services.d.ts +37 -15
  15. package/dist/server-postgresql/src/index.d.ts +1 -0
  16. package/dist/server-postgresql/src/schema/auth-schema.d.ts +43 -856
  17. package/dist/server-postgresql/src/schema/default-collections.d.ts +2 -0
  18. package/dist/server-postgresql/src/schema/doctor.d.ts +10 -1
  19. package/dist/server-postgresql/src/schema/introspect-db-logic.d.ts +1 -0
  20. package/dist/server-postgresql/src/services/entity-helpers.d.ts +1 -1
  21. package/dist/server-postgresql/src/services/realtimeService.d.ts +12 -0
  22. package/dist/server-postgresql/src/websocket.d.ts +2 -1
  23. package/dist/types/src/controllers/auth.d.ts +9 -8
  24. package/dist/types/src/controllers/client.d.ts +3 -0
  25. package/dist/types/src/controllers/data.d.ts +21 -0
  26. package/dist/types/src/types/auth_adapter.d.ts +356 -0
  27. package/dist/types/src/types/collections.d.ts +67 -2
  28. package/dist/types/src/types/database_adapter.d.ts +94 -0
  29. package/dist/types/src/types/entity_actions.d.ts +7 -1
  30. package/dist/types/src/types/entity_callbacks.d.ts +1 -1
  31. package/dist/types/src/types/entity_views.d.ts +36 -1
  32. package/dist/types/src/types/index.d.ts +2 -0
  33. package/dist/types/src/types/plugins.d.ts +1 -1
  34. package/dist/types/src/types/properties.d.ts +24 -5
  35. package/dist/types/src/types/property_config.d.ts +6 -2
  36. package/dist/types/src/types/relations.d.ts +1 -1
  37. package/dist/types/src/types/translations.d.ts +8 -0
  38. package/dist/types/src/users/user.d.ts +5 -0
  39. package/package.json +22 -15
  40. package/src/PostgresAdapter.ts +59 -0
  41. package/src/PostgresBackendDriver.ts +66 -13
  42. package/src/PostgresBootstrapper.ts +35 -15
  43. package/src/auth/ensure-tables.ts +82 -189
  44. package/src/auth/services.ts +421 -170
  45. package/src/cli.ts +49 -13
  46. package/src/data-transformer.ts +78 -8
  47. package/src/history/HistoryService.ts +25 -2
  48. package/src/index.ts +1 -0
  49. package/src/schema/auth-schema.ts +130 -98
  50. package/src/schema/default-collections.ts +69 -0
  51. package/src/schema/doctor-cli.ts +5 -1
  52. package/src/schema/doctor.ts +166 -48
  53. package/src/schema/generate-drizzle-schema-logic.ts +74 -27
  54. package/src/schema/generate-drizzle-schema.ts +13 -3
  55. package/src/schema/introspect-db-inference.ts +5 -5
  56. package/src/schema/introspect-db-logic.ts +9 -2
  57. package/src/schema/introspect-db.ts +14 -3
  58. package/src/services/EntityFetchService.ts +5 -5
  59. package/src/services/RelationService.ts +2 -2
  60. package/src/services/entity-helpers.ts +1 -1
  61. package/src/services/realtimeService.ts +145 -136
  62. package/src/utils/drizzle-conditions.ts +16 -2
  63. package/src/websocket.ts +113 -37
  64. package/test/auth-services.test.ts +163 -74
  65. package/test/data-transformer-hardening.test.ts +57 -0
  66. package/test/data-transformer.test.ts +43 -0
  67. package/test/generate-drizzle-schema.test.ts +7 -5
  68. package/test/introspect-db-utils.test.ts +4 -1
  69. package/test/postgresDataDriver.test.ts +147 -1
  70. package/test/realtimeService.test.ts +7 -7
  71. 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
- let entities;
287
- if (this.driver) {
288
- entities = await this.driver.fetchCollection({
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
- let entity;
342
- if (this.driver) {
343
- entity = await this.driver.fetchEntity({
344
- path: request.path,
345
- entityId: request.entityId,
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
- // If we have auth context, wrap in a transaction with session vars
569
- if (authContext) {
570
- return await this.db.transaction(async (tx) => {
571
- await tx.execute(drizzleSql`SELECT set_config('app.user_id', ${authContext.userId}, true)`);
572
- await tx.execute(drizzleSql`SELECT set_config('app.user_roles', ${authContext.roles.join(",")}, true)`);
573
- await tx.execute(drizzleSql`SELECT set_config('app.jwt', ${JSON.stringify({ sub: authContext.userId,
574
- roles: authContext.roles })}, true)`);
575
- const txEntityService = new EntityService(tx, this.registry);
576
- let fetchedEntities;
577
- if (collectionRequest.searchString) {
578
- fetchedEntities = await txEntityService.searchEntities(
579
- notifyPath,
580
- collectionRequest.searchString,
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
- } else {
590
- fetchedEntities = await txEntityService.fetchCollection(notifyPath, {
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
- // Re-apply `afterRead` lifecycle hooks to ensure consistent data structures
602
- // between the initial driver fetch and this RLS-bound refetch.
603
- const registryCollection = this.registry.getCollectionByPath(notifyPath);
604
- const resolvedCollection = collection ? { ...collection,
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
- const callbacks = resolvedCollection?.callbacks;
608
- const propertyCallbacks = resolvedCollection?.properties ? buildPropertyCallbacks(resolvedCollection.properties) : undefined;
609
-
610
- if (callbacks?.afterRead || propertyCallbacks?.afterRead) {
611
- const contextForCallback = {
612
- user: { uid: authContext.userId,
613
- roles: authContext.roles },
614
- driver: this.driver,
615
- data: this.driver ? (this.driver as unknown as Record<string, unknown>).data : undefined
616
- } as unknown as RebaseCallContext;
617
-
618
- return await Promise.all(fetchedEntities.map(async (entity) => {
619
- let processedEntity = entity;
620
- if (callbacks?.afterRead) {
621
- processedEntity = await callbacks.afterRead({
622
- collection: resolvedCollection,
623
- path: notifyPath,
624
- entity: processedEntity,
625
- context: contextForCallback
626
- }) ?? processedEntity;
627
- }
628
- if (propertyCallbacks?.afterRead) {
629
- processedEntity = await propertyCallbacks.afterRead({
630
- collection: resolvedCollection,
631
- path: notifyPath,
632
- entity: processedEntity,
633
- context: contextForCallback
634
- }) ?? processedEntity;
635
- }
636
- return processedEntity;
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
- return fetchFn();
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
- // If we have auth context, wrap in a transaction with session vars
741
- if (authContext) {
742
- return await this.db.transaction(async (tx) => {
743
- await tx.execute(drizzleSql`SELECT set_config('app.user_id', ${authContext.userId}, true)`);
744
- await tx.execute(drizzleSql`SELECT set_config('app.user_roles', ${authContext.roles.join(",")}, true)`);
745
- await tx.execute(drizzleSql`SELECT set_config('app.jwt', ${JSON.stringify({ sub: authContext.userId,
746
- roles: authContext.roles })}, true)`);
747
- const txEntityService = new EntityService(tx, this.registry);
748
- let processedEntity = await txEntityService.fetchEntity(notifyPath, entityId, collection?.databaseId);
749
-
750
- if (processedEntity) {
751
- const registryCollection = this.registry.getCollectionByPath(notifyPath);
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
- const callbacks = resolvedCollection?.callbacks;
756
- const propertyCallbacks = resolvedCollection?.properties ? buildPropertyCallbacks(resolvedCollection.properties) : undefined;
757
-
758
- if (callbacks?.afterRead || propertyCallbacks?.afterRead) {
759
- const contextForCallback = {
760
- user: { uid: authContext.userId,
761
- roles: authContext.roles },
762
- driver: this.driver,
763
- data: this.driver ? (this.driver as unknown as Record<string, unknown>).data : undefined
764
- } as unknown as RebaseCallContext;
765
-
766
- if (callbacks?.afterRead) {
767
- processedEntity = await callbacks.afterRead({
768
- collection: resolvedCollection,
769
- path: notifyPath,
770
- entity: processedEntity,
771
- context: contextForCallback
772
- }) ?? processedEntity;
773
- }
774
- if (propertyCallbacks?.afterRead) {
775
- processedEntity = await propertyCallbacks.afterRead({
776
- collection: resolvedCollection,
777
- path: notifyPath,
778
- entity: processedEntity,
779
- context: contextForCallback
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
- return processedEntity;
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
- searchConditions.push(ilike(fieldColumn, `%${searchString}%`));
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?: AccessTokenPayload;
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?.roles) return false;
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
- const requireAuth = authConfig?.requireAuth !== false && authConfig?.jwtSecret;
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
- const user = extractUserFromToken(token);
127
- if (user) {
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 = 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: user.userId,
138
- roles: user.roles }
204
+ payload: { userId: verifiedUser.userId, roles: verifiedUser.roles }
139
205
  }));
140
- wsDebug(`🔐 [WebSocket Server] Client ${clientId} authenticated as ${user.userId}`);
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 (session?.user && "withAuth" in driver && typeof (driver as unknown as Record<string, unknown>).withAuth === "function") {
252
+ if ("withAuth" in driver && typeof (driver as unknown as Record<string, unknown>).withAuth === "function") {
187
253
  try {
188
- // Map AccessTokenPayload back to User interface for withAuth (roles are already string IDs from JWT)
189
- const userForAuth: Record<string, unknown> = {
190
- uid: session.user.userId,
191
- roles: session.user.roles ?? []
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 authenticated delegate for WS request", e);
196
- return driver;
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
- const delegate = await getScopedDelegate();
310
- const admin = delegate.admin;
311
- if (!isSQLAdmin(admin)) {
312
- sendError("ERROR", "NOT_SUPPORTED", "SQL execution is not available for this driver.");
313
- break;
314
- }
315
- const result = await admin.executeSql(sql, options);
316
- if (process.env.NODE_ENV !== "production") {
317
- wsDebug(`⚡ [WebSocket Server] SQL executed. Returned ${Array.isArray(result) ? result.length : "non-array"} rows.`);
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
- roles: session.user.roles ?? [] }
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,