@rebasepro/server-postgresql 0.4.0 → 0.5.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/README.md +69 -89
- package/dist/common/src/util/permissions.d.ts +14 -6
- package/dist/index.es.js +79 -112
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +79 -112
- package/dist/index.umd.js.map +1 -1
- package/dist/server-postgresql/src/auth/services.d.ts +11 -11
- package/dist/server-postgresql/src/data-transformer.d.ts +0 -3
- package/dist/server-postgresql/src/databasePoolManager.d.ts +1 -1
- package/dist/server-postgresql/src/types.d.ts +3 -0
- package/dist/server-postgresql/src/websocket.d.ts +8 -3
- package/dist/types/src/types/backend.d.ts +36 -1
- package/dist/types/src/types/collections.d.ts +21 -1
- package/dist/types/src/types/properties.d.ts +0 -8
- package/package.json +6 -6
- package/src/PostgresBackendDriver.ts +1 -1
- package/src/PostgresBootstrapper.ts +4 -3
- package/src/auth/services.ts +28 -27
- package/src/cli.ts +50 -23
- package/src/data-transformer.ts +57 -95
- package/src/databasePoolManager.ts +2 -1
- package/src/services/EntityPersistService.ts +29 -12
- package/src/types.ts +4 -0
- package/src/websocket.ts +37 -22
- package/drizzle.test.config.ts +0 -10
package/src/data-transformer.ts
CHANGED
|
@@ -272,41 +272,19 @@ export function serializePropertyToServer(value: unknown, property: Property): u
|
|
|
272
272
|
return value;
|
|
273
273
|
}
|
|
274
274
|
|
|
275
|
-
case "binary":
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
if (base64Data) {
|
|
280
|
-
return Buffer.from(base64Data, "base64");
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
if (Buffer.isBuffer(value)) {
|
|
285
|
-
return value;
|
|
286
|
-
}
|
|
275
|
+
case "binary": {
|
|
276
|
+
const decoded = tryDecodeBase64DataUrl(value);
|
|
277
|
+
if (decoded) return decoded;
|
|
278
|
+
if (Buffer.isBuffer(value)) return value;
|
|
287
279
|
return value;
|
|
280
|
+
}
|
|
288
281
|
|
|
289
282
|
case "string":
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
if (base64Data) {
|
|
294
|
-
return Buffer.from(base64Data, "base64");
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
return value;
|
|
299
|
-
|
|
300
|
-
default:
|
|
301
|
-
if (typeof value === "string") {
|
|
302
|
-
if (value.startsWith("data:application/octet-stream;base64,")) {
|
|
303
|
-
const base64Data = value.split(",")[1];
|
|
304
|
-
if (base64Data) {
|
|
305
|
-
return Buffer.from(base64Data, "base64");
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
}
|
|
283
|
+
default: {
|
|
284
|
+
const decoded = tryDecodeBase64DataUrl(value);
|
|
285
|
+
if (decoded) return decoded;
|
|
309
286
|
return value;
|
|
287
|
+
}
|
|
310
288
|
}
|
|
311
289
|
}
|
|
312
290
|
|
|
@@ -486,24 +464,53 @@ export async function parseDataFromServer<M extends Record<string, unknown>>(
|
|
|
486
464
|
}
|
|
487
465
|
|
|
488
466
|
/**
|
|
489
|
-
*
|
|
467
|
+
* Try to decode a `data:application/octet-stream;base64,...` data URL string
|
|
468
|
+
* into a Buffer. Returns null if the value is not a matching data URL.
|
|
490
469
|
*/
|
|
491
|
-
|
|
492
|
-
if (
|
|
493
|
-
|
|
470
|
+
function tryDecodeBase64DataUrl(value: unknown): Buffer | null {
|
|
471
|
+
if (typeof value !== "string") return null;
|
|
472
|
+
if (!value.startsWith("data:application/octet-stream;base64,")) return null;
|
|
473
|
+
const base64Data = value.split(",")[1];
|
|
474
|
+
return base64Data ? Buffer.from(base64Data, "base64") : null;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Try to resolve an unknown value into a Buffer.
|
|
479
|
+
* Handles native Buffers and `{ type: "Buffer", data: number[] }` objects (from JSON deserialization).
|
|
480
|
+
* Returns null if the value is not a buffer.
|
|
481
|
+
*/
|
|
482
|
+
function tryResolveBuffer(value: unknown): Buffer | null {
|
|
483
|
+
if (Buffer.isBuffer(value)) return value;
|
|
484
|
+
if (typeof value === "object" && value !== null) {
|
|
485
|
+
const rawVal = value as Record<string, unknown>;
|
|
486
|
+
if (rawVal.type === "Buffer" && Array.isArray(rawVal.data)) {
|
|
487
|
+
return Buffer.from(rawVal.data as number[]);
|
|
488
|
+
}
|
|
494
489
|
}
|
|
490
|
+
return null;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Convert a Buffer to a UTF-8 string if all bytes are printable ASCII,
|
|
495
|
+
* otherwise return a base64 data URL.
|
|
496
|
+
*/
|
|
497
|
+
function bufferToStringOrBase64(buf: Buffer): string {
|
|
498
|
+
for (let i = 0; i < buf.length; i++) {
|
|
499
|
+
const b = buf[i];
|
|
500
|
+
// Allow standard printable ASCII + common whitespace (\r, \n, \t)
|
|
501
|
+
if ((b < 32 || b > 126) && b !== 9 && b !== 10 && b !== 13) {
|
|
502
|
+
return `data:application/octet-stream;base64,${buf.toString("base64")}`;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
return buf.toString("utf8");
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
export function parsePropertyFromServer(value: unknown, property: Property, collection: EntityCollection, propertyKey?: string): unknown {
|
|
509
|
+
if (value === null || value === undefined) return value;
|
|
495
510
|
|
|
496
511
|
switch (property.type) {
|
|
497
512
|
case "binary": {
|
|
498
|
-
|
|
499
|
-
if (Buffer.isBuffer(value)) {
|
|
500
|
-
buf = value;
|
|
501
|
-
} else if (typeof value === "object" && value !== null) {
|
|
502
|
-
const rawVal = value as Record<string, unknown>;
|
|
503
|
-
if (rawVal.type === "Buffer" && Array.isArray(rawVal.data)) {
|
|
504
|
-
buf = Buffer.from(rawVal.data as number[]);
|
|
505
|
-
}
|
|
506
|
-
}
|
|
513
|
+
const buf = tryResolveBuffer(value);
|
|
507
514
|
if (buf) {
|
|
508
515
|
return `data:application/octet-stream;base64,${buf.toString("base64")}`;
|
|
509
516
|
}
|
|
@@ -514,32 +521,9 @@ export function parsePropertyFromServer(value: unknown, property: Property, coll
|
|
|
514
521
|
if (typeof value === "string") return value;
|
|
515
522
|
|
|
516
523
|
// Handle Buffer objects (e.g. from PostgreSQL bytea columns)
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
if (Buffer.isBuffer(value)) {
|
|
521
|
-
isBuffer = true;
|
|
522
|
-
buf = value;
|
|
523
|
-
} else if (typeof value === "object" && value !== null) {
|
|
524
|
-
const rawVal = value as Record<string, unknown>;
|
|
525
|
-
if (rawVal.type === "Buffer" && Array.isArray(rawVal.data)) {
|
|
526
|
-
isBuffer = true;
|
|
527
|
-
buf = Buffer.from(rawVal.data as number[]);
|
|
528
|
-
}
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
if (isBuffer && buf) {
|
|
532
|
-
// Heuristic: if all bytes are printable ASCII, return utf8, else base64
|
|
533
|
-
let isPrintable = true;
|
|
534
|
-
for (let i = 0; i < buf.length; i++) {
|
|
535
|
-
const b = buf[i];
|
|
536
|
-
// Allow standard printable ASCII + common whitespace (\r, \n, \t)
|
|
537
|
-
if ((b < 32 || b > 126) && b !== 9 && b !== 10 && b !== 13) {
|
|
538
|
-
isPrintable = false;
|
|
539
|
-
break;
|
|
540
|
-
}
|
|
541
|
-
}
|
|
542
|
-
return isPrintable ? buf.toString("utf8") : `data:application/octet-stream;base64,${buf.toString("base64")}`;
|
|
524
|
+
const buf = tryResolveBuffer(value);
|
|
525
|
+
if (buf) {
|
|
526
|
+
return bufferToStringOrBase64(buf);
|
|
543
527
|
}
|
|
544
528
|
|
|
545
529
|
if (typeof value === "object" && value !== null) {
|
|
@@ -665,32 +649,10 @@ export function parsePropertyFromServer(value: unknown, property: Property, coll
|
|
|
665
649
|
|
|
666
650
|
default: {
|
|
667
651
|
// Fallback for buffers in case they are mapped to something other than string
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
if (Buffer.isBuffer(value)) {
|
|
672
|
-
isBuffer = true;
|
|
673
|
-
buf = value;
|
|
674
|
-
} else if (typeof value === "object" && value !== null) {
|
|
675
|
-
const rawVal = value as Record<string, unknown>;
|
|
676
|
-
if (rawVal.type === "Buffer" && Array.isArray(rawVal.data)) {
|
|
677
|
-
isBuffer = true;
|
|
678
|
-
buf = Buffer.from(rawVal.data as number[]);
|
|
679
|
-
}
|
|
680
|
-
}
|
|
681
|
-
|
|
682
|
-
if (isBuffer && buf) {
|
|
683
|
-
let isPrintable = true;
|
|
684
|
-
for (let i = 0; i < buf.length; i++) {
|
|
685
|
-
const b = buf[i];
|
|
686
|
-
if ((b < 32 || b > 126) && b !== 9 && b !== 10 && b !== 13) {
|
|
687
|
-
isPrintable = false;
|
|
688
|
-
break;
|
|
689
|
-
}
|
|
690
|
-
}
|
|
691
|
-
return isPrintable ? buf.toString("utf8") : `data:application/octet-stream;base64,${buf.toString("base64")}`;
|
|
652
|
+
const buf = tryResolveBuffer(value);
|
|
653
|
+
if (buf) {
|
|
654
|
+
return bufferToStringOrBase64(buf);
|
|
692
655
|
}
|
|
693
|
-
|
|
694
656
|
return value;
|
|
695
657
|
}
|
|
696
658
|
}
|
|
@@ -18,7 +18,7 @@ export class DatabasePoolManager {
|
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
public getDrizzle(databaseName: string): NodePgDatabase<
|
|
21
|
+
public getDrizzle(databaseName: string): NodePgDatabase<Record<string, never>> {
|
|
22
22
|
const existing = this.drizzleInstances.get(databaseName);
|
|
23
23
|
if (existing) {
|
|
24
24
|
return existing;
|
|
@@ -81,5 +81,6 @@ export class DatabasePoolManager {
|
|
|
81
81
|
}
|
|
82
82
|
await Promise.all(promises);
|
|
83
83
|
this.pools.clear();
|
|
84
|
+
this.drizzleInstances.clear();
|
|
84
85
|
}
|
|
85
86
|
}
|
|
@@ -17,6 +17,18 @@ import { EntityFetchService } from "./EntityFetchService";
|
|
|
17
17
|
import { DrizzleClient } from "../interfaces";
|
|
18
18
|
import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
|
|
19
19
|
|
|
20
|
+
/** Shape of PostgreSQL errors with diagnostic metadata. */
|
|
21
|
+
interface PostgresError extends Error {
|
|
22
|
+
code?: string;
|
|
23
|
+
detail?: string;
|
|
24
|
+
hint?: string;
|
|
25
|
+
constraint?: string;
|
|
26
|
+
column?: string;
|
|
27
|
+
table?: string;
|
|
28
|
+
dataType?: string;
|
|
29
|
+
cause?: unknown;
|
|
30
|
+
}
|
|
31
|
+
|
|
20
32
|
/**
|
|
21
33
|
* Service for handling all entity write operations.
|
|
22
34
|
* Handles saving, deleting, and updating entities.
|
|
@@ -391,14 +403,14 @@ export class EntityPersistService {
|
|
|
391
403
|
*/
|
|
392
404
|
private extractCauseMessage(error: unknown): string | null {
|
|
393
405
|
if (!error || typeof error !== "object") return null;
|
|
394
|
-
|
|
406
|
+
if (!(error instanceof Error)) return null;
|
|
395
407
|
|
|
396
|
-
if (
|
|
397
|
-
const deeper = this.extractCauseMessage(
|
|
408
|
+
if (error.cause && typeof error.cause === "object") {
|
|
409
|
+
const deeper = this.extractCauseMessage(error.cause);
|
|
398
410
|
if (deeper) return deeper;
|
|
399
411
|
// The cause itself has a message
|
|
400
|
-
if (
|
|
401
|
-
return
|
|
412
|
+
if (error.cause instanceof Error && error.cause.message) {
|
|
413
|
+
return error.cause.message;
|
|
402
414
|
}
|
|
403
415
|
}
|
|
404
416
|
return null;
|
|
@@ -420,19 +432,24 @@ export class EntityPersistService {
|
|
|
420
432
|
* Extract the underlying PostgreSQL error from a Drizzle wrapper.
|
|
421
433
|
* Drizzle wraps PG errors in a `cause` property.
|
|
422
434
|
*/
|
|
423
|
-
private extractPgError(error: unknown):
|
|
435
|
+
private extractPgError(error: unknown): PostgresError | null {
|
|
424
436
|
if (!error || typeof error !== "object") return null;
|
|
425
|
-
|
|
426
|
-
|
|
437
|
+
if (!(error instanceof Error)) {
|
|
438
|
+
// Check non-Error objects for a cause chain (Drizzle sometimes wraps oddly)
|
|
439
|
+
if ("cause" in error && (error as Record<string, unknown>).cause && typeof (error as Record<string, unknown>).cause === "object") {
|
|
440
|
+
return this.extractPgError((error as Record<string, unknown>).cause);
|
|
441
|
+
}
|
|
442
|
+
return null;
|
|
443
|
+
}
|
|
427
444
|
|
|
428
445
|
// Check if the error itself has a PG error code
|
|
429
|
-
if (
|
|
430
|
-
return
|
|
446
|
+
if ("code" in error && typeof (error as PostgresError).code === "string" && /^[0-9A-Z]{5}$/.test((error as PostgresError).code!)) {
|
|
447
|
+
return error as PostgresError;
|
|
431
448
|
}
|
|
432
449
|
|
|
433
450
|
// Check the cause chain (Drizzle wraps PG errors)
|
|
434
|
-
if (
|
|
435
|
-
return this.extractPgError(
|
|
451
|
+
if (error.cause && typeof error.cause === "object") {
|
|
452
|
+
return this.extractPgError(error.cause);
|
|
436
453
|
}
|
|
437
454
|
|
|
438
455
|
return null;
|
package/src/types.ts
ADDED
package/src/websocket.ts
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
import { RealtimeService } from "./services/realtimeService";
|
|
2
2
|
import { PostgresBackendDriver } from "./PostgresBackendDriver";
|
|
3
|
-
import { DataDriver, DeleteEntityProps, FetchCollectionProps, FetchEntityProps, SaveEntityProps, TableMetadata, BranchInfo,
|
|
3
|
+
import type { DataDriver, DeleteEntityProps, FetchCollectionProps, FetchEntityProps, SaveEntityProps, TableMetadata, BranchInfo, AuthAdapter } from "@rebasepro/types";
|
|
4
|
+
import { isSQLAdmin, isSchemaAdmin } from "@rebasepro/types";
|
|
5
|
+
import type { User } from "@rebasepro/types";
|
|
4
6
|
import { WebSocketServer, WebSocket } from "ws";
|
|
5
7
|
import { Server } from "http";
|
|
6
8
|
import { inspect } from "util";
|
|
7
|
-
// @ts-ignore
|
|
8
9
|
import { extractUserFromToken, AccessTokenPayload } from "@rebasepro/server-core";
|
|
9
|
-
|
|
10
|
-
|
|
10
|
+
|
|
11
|
+
/** Minimal subset of RebaseAuthConfig used by the WebSocket layer. */
|
|
12
|
+
interface WsAuthConfig {
|
|
13
|
+
requireAuth?: boolean;
|
|
14
|
+
jwtSecret?: string;
|
|
15
|
+
}
|
|
11
16
|
|
|
12
17
|
/**
|
|
13
18
|
* Normalized user identity for WebSocket sessions.
|
|
@@ -27,7 +32,7 @@ interface ClientSession {
|
|
|
27
32
|
messageWindowStart: number;
|
|
28
33
|
}
|
|
29
34
|
|
|
30
|
-
|
|
35
|
+
|
|
31
36
|
|
|
32
37
|
/** Maximum messages per client per window */
|
|
33
38
|
const WS_RATE_LIMIT = 2000;
|
|
@@ -52,14 +57,14 @@ const ADMIN_ONLY_TYPES = new Set([
|
|
|
52
57
|
*/
|
|
53
58
|
function extractErrorMessage(error: unknown): string {
|
|
54
59
|
if (!error) return "Unknown error";
|
|
55
|
-
if (
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
return extractErrorMessage(err.cause);
|
|
59
|
-
}
|
|
60
|
-
if (typeof err.message === "string") {
|
|
61
|
-
return err.message;
|
|
60
|
+
if (error instanceof Error) {
|
|
61
|
+
if ("cause" in error && error.cause) {
|
|
62
|
+
return extractErrorMessage(error.cause);
|
|
62
63
|
}
|
|
64
|
+
return error.message;
|
|
65
|
+
}
|
|
66
|
+
if (typeof error === "object" && "message" in error && typeof (error as { message: unknown }).message === "string") {
|
|
67
|
+
return (error as { message: string }).message;
|
|
63
68
|
}
|
|
64
69
|
return String(error);
|
|
65
70
|
}
|
|
@@ -72,21 +77,20 @@ function isAdminSession(session: ClientSession | undefined): boolean {
|
|
|
72
77
|
// Fast path: new adapter-aware sessions set isAdmin directly
|
|
73
78
|
if (session.user.isAdmin) return true;
|
|
74
79
|
if (!session.user.roles) return false;
|
|
75
|
-
return session.user.roles.some((r
|
|
76
|
-
if (typeof r === "string") return r === "admin";
|
|
77
|
-
if (r && typeof r === "object" && "isAdmin" in r) return (r as { isAdmin: boolean }).isAdmin;
|
|
78
|
-
if (r && typeof r === "object" && "id" in r) return (r as { id: string }).id === "admin";
|
|
79
|
-
return false;
|
|
80
|
-
});
|
|
80
|
+
return session.user.roles.some((r) => r === "admin");
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
export function createPostgresWebSocket(
|
|
84
84
|
server: Server,
|
|
85
85
|
realtimeService: RealtimeService,
|
|
86
86
|
driver: PostgresBackendDriver,
|
|
87
|
-
authConfig?:
|
|
87
|
+
authConfig?: WsAuthConfig,
|
|
88
88
|
authAdapter?: AuthAdapter
|
|
89
89
|
) {
|
|
90
|
+
// Session map scoped to this factory invocation — prevents stale sessions
|
|
91
|
+
// leaking across hot reloads or multiple factory calls.
|
|
92
|
+
const clientSessions = new Map<string, ClientSession>();
|
|
93
|
+
|
|
90
94
|
const isProduction = process.env.NODE_ENV === "production";
|
|
91
95
|
/** Debug logger that is suppressed in production to prevent PII/data leaks */
|
|
92
96
|
const wsDebug = (...args: unknown[]) => { if (!isProduction) console.debug(...args); };
|
|
@@ -249,18 +253,29 @@ code } }
|
|
|
249
253
|
// Helper to get correctly scoped delegate for the current request
|
|
250
254
|
const getScopedDelegate = async (): Promise<DataDriver> => {
|
|
251
255
|
const session = clientSessions.get(clientId);
|
|
252
|
-
|
|
256
|
+
// Check if the driver supports RLS-scoped delegates
|
|
257
|
+
if (typeof driver.withAuth === "function") {
|
|
253
258
|
try {
|
|
254
|
-
const userForAuth = session?.user
|
|
259
|
+
const userForAuth: User = session?.user
|
|
255
260
|
? {
|
|
256
261
|
uid: session.user.userId,
|
|
262
|
+
displayName: null,
|
|
263
|
+
email: null,
|
|
264
|
+
photoURL: null,
|
|
265
|
+
providerId: "websocket",
|
|
266
|
+
isAnonymous: false,
|
|
257
267
|
roles: session.user.roles ?? []
|
|
258
268
|
}
|
|
259
269
|
: {
|
|
260
270
|
uid: "anon",
|
|
271
|
+
displayName: null,
|
|
272
|
+
email: null,
|
|
273
|
+
photoURL: null,
|
|
274
|
+
providerId: "websocket",
|
|
275
|
+
isAnonymous: true,
|
|
261
276
|
roles: ["anon"]
|
|
262
277
|
};
|
|
263
|
-
return await
|
|
278
|
+
return await driver.withAuth(userForAuth);
|
|
264
279
|
} catch (e) {
|
|
265
280
|
console.error("Failed to create RLS scoped delegate for WS request", e);
|
|
266
281
|
throw new Error("Internal authentication error");
|
package/drizzle.test.config.ts
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
import { defineConfig } from "drizzle-kit";
|
|
2
|
-
|
|
3
|
-
export default defineConfig({
|
|
4
|
-
schema: "./test-schema-no-policies.ts",
|
|
5
|
-
out: "./drizzle-test-out",
|
|
6
|
-
dialect: "postgresql",
|
|
7
|
-
dbCredentials: {
|
|
8
|
-
url: process.env.DATABASE_URL || "postgres://postgres:postgres@localhost:5432/postgres"
|
|
9
|
-
}
|
|
10
|
-
});
|