@rebasepro/server-postgresql 0.6.1 → 0.7.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/package.json +24 -18
- package/src/PostgresBackendDriver.ts +65 -44
- package/src/PostgresBootstrapper.ts +34 -2
- package/src/auth/ensure-tables.ts +56 -1
- package/src/auth/services.ts +94 -1
- package/src/cli-errors.ts +162 -0
- package/src/cli-helpers.ts +183 -0
- package/src/cli.ts +198 -251
- package/src/schema/auth-default-policies.ts +90 -0
- package/src/schema/auth-schema.ts +25 -2
- package/src/schema/doctor.ts +2 -4
- package/src/schema/generate-drizzle-schema-logic.ts +4 -3
- package/src/schema/generate-drizzle-schema.ts +3 -5
- package/src/schema/generate-postgres-ddl-logic.ts +524 -0
- package/src/schema/generate-postgres-ddl.ts +116 -0
- package/src/services/EntityPersistService.ts +10 -8
- package/src/services/entityService.ts +28 -3
- package/src/utils/pg-error-utils.ts +16 -0
- package/src/utils/table-classification.ts +16 -0
- package/src/websocket.ts +9 -0
- package/test/auth-default-policies.test.ts +89 -0
- package/test/cli-helpers-extended.test.ts +324 -0
- package/test/cli-helpers.test.ts +59 -0
- package/test/connection.test.ts +292 -0
- package/test/databasePoolManager.test.ts +289 -0
- package/test/doctor-extended.test.ts +443 -0
- package/test/e2e/db-e2e.test.ts +293 -0
- package/test/e2e/pg-setup.ts +79 -0
- package/test/entity-persist-composite-keys.test.ts +451 -0
- package/test/generate-postgres-ddl-edge-cases.test.ts +716 -0
- package/test/generate-postgres-ddl.test.ts +300 -0
- package/test/mfa-service.test.ts +544 -0
- package/test/pg-error-utils.test.ts +50 -1
- package/test/realtimeService-channels.test.ts +696 -0
- package/test/unmapped-tables-safety.test.ts +55 -342
- package/vitest.e2e.config.ts +10 -0
- package/build-errors.txt +0 -37
- package/dist/PostgresAdapter.d.ts +0 -6
- package/dist/PostgresBackendDriver.d.ts +0 -110
- package/dist/PostgresBootstrapper.d.ts +0 -46
- package/dist/auth/ensure-tables.d.ts +0 -10
- package/dist/auth/services.d.ts +0 -231
- package/dist/cli.d.ts +0 -1
- package/dist/collections/PostgresCollectionRegistry.d.ts +0 -47
- package/dist/connection.d.ts +0 -65
- package/dist/data-transformer.d.ts +0 -55
- package/dist/databasePoolManager.d.ts +0 -20
- package/dist/history/HistoryService.d.ts +0 -71
- package/dist/history/ensure-history-table.d.ts +0 -7
- package/dist/index.d.ts +0 -14
- package/dist/index.es.js +0 -10803
- package/dist/index.es.js.map +0 -1
- package/dist/interfaces.d.ts +0 -18
- package/dist/schema/auth-schema.d.ts +0 -2149
- package/dist/schema/doctor-cli.d.ts +0 -2
- package/dist/schema/doctor.d.ts +0 -52
- package/dist/schema/generate-drizzle-schema-logic.d.ts +0 -2
- package/dist/schema/generate-drizzle-schema.d.ts +0 -1
- package/dist/schema/introspect-db-inference.d.ts +0 -5
- package/dist/schema/introspect-db-logic.d.ts +0 -118
- package/dist/schema/introspect-db.d.ts +0 -1
- package/dist/schema/test-schema.d.ts +0 -24
- package/dist/services/BranchService.d.ts +0 -47
- package/dist/services/EntityFetchService.d.ts +0 -214
- package/dist/services/EntityPersistService.d.ts +0 -40
- package/dist/services/RelationService.d.ts +0 -98
- package/dist/services/entity-helpers.d.ts +0 -38
- package/dist/services/entityService.d.ts +0 -110
- package/dist/services/index.d.ts +0 -4
- package/dist/services/realtimeService.d.ts +0 -220
- package/dist/types.d.ts +0 -3
- package/dist/utils/drizzle-conditions.d.ts +0 -138
- package/dist/utils/pg-array-null-patch.d.ts +0 -16
- package/dist/utils/pg-error-utils.d.ts +0 -55
- package/dist/websocket.d.ts +0 -11
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rebasepro/server-postgresql",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.7.0",
|
|
5
5
|
"description": "PostgreSQL data source backend implementation for Rebase with Drizzle ORM",
|
|
6
6
|
"funding": {
|
|
7
7
|
"url": "https://github.com/sponsors/rebaseco"
|
|
@@ -29,11 +29,23 @@
|
|
|
29
29
|
"backend",
|
|
30
30
|
"rebase"
|
|
31
31
|
],
|
|
32
|
+
"scripts": {
|
|
33
|
+
"watch": "vite build --watch",
|
|
34
|
+
"build": "vite build && tsc --emitDeclarationOnly -p tsconfig.prod.json",
|
|
35
|
+
"test:lint": "eslint \"src/**\" --quiet",
|
|
36
|
+
"test": "jest --passWithNoTests",
|
|
37
|
+
"test:e2e": "vitest run --config vitest.e2e.config.ts",
|
|
38
|
+
"clean": "rm -rf dist && find ./src -name '*.js' -type f | xargs rm -f"
|
|
39
|
+
},
|
|
32
40
|
"jest": {
|
|
33
41
|
"transform": {
|
|
34
42
|
"^.+\\.tsx?$": "ts-jest"
|
|
35
43
|
},
|
|
36
44
|
"testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$",
|
|
45
|
+
"testPathIgnorePatterns": [
|
|
46
|
+
"/node_modules/",
|
|
47
|
+
"test/e2e/"
|
|
48
|
+
],
|
|
37
49
|
"moduleFileExtensions": [
|
|
38
50
|
"ts",
|
|
39
51
|
"tsx",
|
|
@@ -62,42 +74,36 @@
|
|
|
62
74
|
"./package.json": "./package.json"
|
|
63
75
|
},
|
|
64
76
|
"dependencies": {
|
|
77
|
+
"@rebasepro/common": "workspace:*",
|
|
78
|
+
"@rebasepro/sdk-generator": "workspace:*",
|
|
79
|
+
"@rebasepro/server-core": "workspace:*",
|
|
80
|
+
"@rebasepro/types": "workspace:*",
|
|
81
|
+
"@rebasepro/utils": "workspace:*",
|
|
65
82
|
"arg": "^5.0.2",
|
|
66
|
-
"chalk": "^
|
|
83
|
+
"chalk": "^4.1.2",
|
|
67
84
|
"chokidar": "5.0.0",
|
|
68
85
|
"dotenv": "^17.4.2",
|
|
69
86
|
"drizzle-orm": "^0.45.2",
|
|
70
87
|
"execa": "^9.6.1",
|
|
71
88
|
"hono": "^4.12.25",
|
|
72
89
|
"pg": "^8.21.0",
|
|
73
|
-
"ws": "^8.21.0"
|
|
74
|
-
"@rebasepro/types": "0.6.1",
|
|
75
|
-
"@rebasepro/server-core": "0.6.1",
|
|
76
|
-
"@rebasepro/sdk-generator": "0.6.1",
|
|
77
|
-
"@rebasepro/common": "0.6.1",
|
|
78
|
-
"@rebasepro/utils": "0.6.1"
|
|
90
|
+
"ws": "^8.21.0"
|
|
79
91
|
},
|
|
80
92
|
"devDependencies": {
|
|
93
|
+
"@ariga/atlas": "^1.2.2",
|
|
81
94
|
"@types/jest": "^30.0.0",
|
|
82
95
|
"@types/node": "^25.9.3",
|
|
83
96
|
"@types/pg": "^8.20.0",
|
|
84
97
|
"@types/ws": "^8.18.1",
|
|
85
98
|
"@vitejs/plugin-react": "^6.0.2",
|
|
86
|
-
"drizzle-kit": "^0.31.10",
|
|
87
99
|
"jest": "^30.4.2",
|
|
88
100
|
"ts-jest": "^29.4.11",
|
|
89
101
|
"typescript": "^6.0.3",
|
|
90
|
-
"vite": "^8.0.16"
|
|
102
|
+
"vite": "^8.0.16",
|
|
103
|
+
"vitest": "^4.1.9"
|
|
91
104
|
},
|
|
92
105
|
"gitHead": "d935eefa5aa8d1009a2398cfac2c1e4ee9aeb6b6",
|
|
93
106
|
"publishConfig": {
|
|
94
107
|
"access": "public"
|
|
95
|
-
},
|
|
96
|
-
"scripts": {
|
|
97
|
-
"watch": "vite build --watch",
|
|
98
|
-
"build": "vite build && tsc --emitDeclarationOnly -p tsconfig.prod.json",
|
|
99
|
-
"test:lint": "eslint \"src/**\" --quiet",
|
|
100
|
-
"test": "jest --passWithNoTests",
|
|
101
|
-
"clean": "rm -rf dist && find ./src -name '*.js' -type f | xargs rm -f"
|
|
102
108
|
}
|
|
103
|
-
}
|
|
109
|
+
}
|
|
@@ -31,6 +31,8 @@ import { PostgresCollectionRegistry } from "./collections/PostgresCollectionRegi
|
|
|
31
31
|
import { HistoryService } from "./history/HistoryService";
|
|
32
32
|
import { mergeDeep } from "@rebasepro/utils";
|
|
33
33
|
import { logger } from "@rebasepro/server-core";
|
|
34
|
+
import { isRoleSwitchingPermissionError } from "./utils/pg-error-utils";
|
|
35
|
+
import { classifyTable, detectJunctionTables } from "./utils/table-classification";
|
|
34
36
|
|
|
35
37
|
export class PostgresBackendDriver implements DataDriver {
|
|
36
38
|
key = "postgres";
|
|
@@ -44,6 +46,14 @@ export class PostgresBackendDriver implements DataDriver {
|
|
|
44
46
|
public data: RebaseData;
|
|
45
47
|
public client?: RebaseClient;
|
|
46
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Auto-set to `true` when a SET LOCAL ROLE fails with insufficient
|
|
51
|
+
* privileges, so subsequent queries skip the doomed attempt.
|
|
52
|
+
* Mirrors the static `DISABLE_DB_ROLE_SWITCHING` env var but is
|
|
53
|
+
* learned at runtime.
|
|
54
|
+
*/
|
|
55
|
+
private _roleSwitchingDisabled = false;
|
|
56
|
+
|
|
47
57
|
/**
|
|
48
58
|
* When true, realtime notifications are deferred until after the
|
|
49
59
|
* wrapping transaction commits. Set by `withAuth` → `withTransaction`.
|
|
@@ -684,10 +694,11 @@ export class PostgresBackendDriver implements DataDriver {
|
|
|
684
694
|
|
|
685
695
|
async executeSql(sqlText: string, options?: {
|
|
686
696
|
database?: string,
|
|
687
|
-
role?: string
|
|
697
|
+
role?: string,
|
|
698
|
+
params?: unknown[]
|
|
688
699
|
}): Promise<Record<string, unknown>[]> {
|
|
689
700
|
if (!options?.database && !options?.role) {
|
|
690
|
-
return this.entityService.executeSql(sqlText);
|
|
701
|
+
return this.entityService.executeSql(sqlText, options?.params);
|
|
691
702
|
}
|
|
692
703
|
|
|
693
704
|
const targetDb = this.getTargetDb(options?.database);
|
|
@@ -698,7 +709,7 @@ export class PostgresBackendDriver implements DataDriver {
|
|
|
698
709
|
// as it's a no-op that can fail on managed Postgres setups where the connection
|
|
699
710
|
// user doesn't have permission to SET ROLE.
|
|
700
711
|
let needsRoleSwitch = false;
|
|
701
|
-
if (options?.role && process.env.DISABLE_DB_ROLE_SWITCHING !== "true") {
|
|
712
|
+
if (options?.role && process.env.DISABLE_DB_ROLE_SWITCHING !== "true" && !this._roleSwitchingDisabled) {
|
|
702
713
|
try {
|
|
703
714
|
const currentRoleResult = await targetDb.execute(drizzleSql.raw("SELECT current_user AS role"));
|
|
704
715
|
const currentRole = (currentRoleResult.rows?.[0] as Record<string, unknown>)?.role as string | undefined;
|
|
@@ -711,14 +722,57 @@ export class PostgresBackendDriver implements DataDriver {
|
|
|
711
722
|
|
|
712
723
|
if (needsRoleSwitch && options?.role) {
|
|
713
724
|
const safeRole = options.role.replace(/"/g, "\"\"");
|
|
714
|
-
|
|
715
|
-
await
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
725
|
+
try {
|
|
726
|
+
return await targetDb.transaction(async (tx) => {
|
|
727
|
+
await tx.execute(drizzleSql.raw(`SET LOCAL ROLE "${safeRole}"`));
|
|
728
|
+
let result;
|
|
729
|
+
if (options?.params && options.params.length > 0) {
|
|
730
|
+
const parts = sqlText.split(/\$(\d+)/);
|
|
731
|
+
const chunks: ReturnType<typeof drizzleSql.raw | typeof drizzleSql.param>[] = [];
|
|
732
|
+
for (let i = 0; i < parts.length; i++) {
|
|
733
|
+
if (i % 2 === 0) {
|
|
734
|
+
if (parts[i].length > 0) chunks.push(drizzleSql.raw(parts[i]));
|
|
735
|
+
} else {
|
|
736
|
+
chunks.push(drizzleSql.param(options.params[Number(parts[i]) - 1]));
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
result = await tx.execute(drizzleSql.join(chunks, drizzleSql.raw("")));
|
|
740
|
+
} else {
|
|
741
|
+
result = await tx.execute(drizzleSql.raw(sqlText));
|
|
742
|
+
}
|
|
743
|
+
return result.rows as Record<string, unknown>[];
|
|
744
|
+
});
|
|
745
|
+
} catch (roleError: unknown) {
|
|
746
|
+
if (isRoleSwitchingPermissionError(roleError)) {
|
|
747
|
+
logger.warn(
|
|
748
|
+
`[PostgresBackendDriver] SET LOCAL ROLE "${safeRole}" failed — ` +
|
|
749
|
+
`the connection user lacks permission. Falling back to executing ` +
|
|
750
|
+
`without role switching. To suppress this warning, set ` +
|
|
751
|
+
`DISABLE_DB_ROLE_SWITCHING=true in your .env file.`
|
|
752
|
+
);
|
|
753
|
+
this._roleSwitchingDisabled = true;
|
|
754
|
+
// Fall through to execute without role switching below
|
|
755
|
+
} else {
|
|
756
|
+
throw roleError;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
719
759
|
}
|
|
720
760
|
|
|
721
|
-
|
|
761
|
+
let result;
|
|
762
|
+
if (options?.params && options.params.length > 0) {
|
|
763
|
+
const parts = sqlText.split(/\$(\d+)/);
|
|
764
|
+
const chunks: ReturnType<typeof drizzleSql.raw | typeof drizzleSql.param>[] = [];
|
|
765
|
+
for (let i = 0; i < parts.length; i++) {
|
|
766
|
+
if (i % 2 === 0) {
|
|
767
|
+
if (parts[i].length > 0) chunks.push(drizzleSql.raw(parts[i]));
|
|
768
|
+
} else {
|
|
769
|
+
chunks.push(drizzleSql.param(options.params[Number(parts[i]) - 1]));
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
result = await targetDb.execute(drizzleSql.join(chunks, drizzleSql.raw("")));
|
|
773
|
+
} else {
|
|
774
|
+
result = await targetDb.execute(drizzleSql.raw(sqlText));
|
|
775
|
+
}
|
|
722
776
|
return result.rows as Record<string, unknown>[];
|
|
723
777
|
} catch (error: unknown) {
|
|
724
778
|
const msg = error instanceof Error ? error.message : String(error);
|
|
@@ -780,48 +834,15 @@ export class PostgresBackendDriver implements DataDriver {
|
|
|
780
834
|
ORDER BY table_name;
|
|
781
835
|
`);
|
|
782
836
|
|
|
783
|
-
const internalPrefixes = ["_rebase_", "_auth_"];
|
|
784
|
-
const internalExact = [
|
|
785
|
-
"users", "roles", "user_roles", "refresh_tokens",
|
|
786
|
-
"password_reset_tokens", "email_verification_tokens"
|
|
787
|
-
];
|
|
788
|
-
|
|
789
837
|
const allTables = result
|
|
790
838
|
.map((r: Record<string, unknown>) => r.table_name as string)
|
|
791
|
-
.filter((name: string) =>
|
|
792
|
-
if (internalPrefixes.some(prefix => name.startsWith(prefix))) return false;
|
|
793
|
-
if (internalExact.includes(name)) return false;
|
|
794
|
-
return true;
|
|
795
|
-
});
|
|
839
|
+
.filter((name: string) => classifyTable(name, "public") !== "rebase-internal");
|
|
796
840
|
|
|
797
841
|
// Detect junction tables: tables where every column is part of a foreign key.
|
|
798
842
|
// These are typically many-to-many connection tables and shouldn't be suggested.
|
|
799
843
|
let junctionTables = new Set<string>();
|
|
800
844
|
try {
|
|
801
|
-
|
|
802
|
-
SELECT t.table_name
|
|
803
|
-
FROM information_schema.tables t
|
|
804
|
-
WHERE t.table_schema = 'public'
|
|
805
|
-
AND t.table_type = 'BASE TABLE'
|
|
806
|
-
AND NOT EXISTS (
|
|
807
|
-
-- Find columns that are NOT part of any foreign key
|
|
808
|
-
SELECT 1
|
|
809
|
-
FROM information_schema.columns c
|
|
810
|
-
WHERE c.table_schema = t.table_schema
|
|
811
|
-
AND c.table_name = t.table_name
|
|
812
|
-
AND c.column_name NOT IN (
|
|
813
|
-
SELECT kcu.column_name
|
|
814
|
-
FROM information_schema.key_column_usage kcu
|
|
815
|
-
JOIN information_schema.table_constraints tc
|
|
816
|
-
ON tc.constraint_name = kcu.constraint_name
|
|
817
|
-
AND tc.table_schema = kcu.table_schema
|
|
818
|
-
WHERE tc.constraint_type = 'FOREIGN KEY'
|
|
819
|
-
AND kcu.table_schema = t.table_schema
|
|
820
|
-
AND kcu.table_name = t.table_name
|
|
821
|
-
)
|
|
822
|
-
);
|
|
823
|
-
`);
|
|
824
|
-
junctionTables = new Set(junctionResult.map((r: Record<string, unknown>) => r.table_name as string));
|
|
845
|
+
junctionTables = await detectJunctionTables(this.executeSql.bind(this));
|
|
825
846
|
} catch (e) {
|
|
826
847
|
logger.warn("Could not detect junction tables", { error: e });
|
|
827
848
|
}
|
|
@@ -55,6 +55,9 @@ export interface PostgresDriverInternals {
|
|
|
55
55
|
poolManager?: DatabasePoolManager;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
// Re-export from shared CLI error utilities
|
|
59
|
+
import { isEconnrefused } from "./cli-errors";
|
|
60
|
+
|
|
58
61
|
/**
|
|
59
62
|
* Default PostgreSQL bootstrapper.
|
|
60
63
|
*
|
|
@@ -117,10 +120,39 @@ export function createPostgresBootstrapper(pgConfig: PostgresDriverConfig): Back
|
|
|
117
120
|
: connection) as import("pg").Pool;
|
|
118
121
|
const schemaAwareDb = createDrizzle(rawClient, { schema: mergedSchema });
|
|
119
122
|
|
|
120
|
-
// Verify connection
|
|
123
|
+
// Verify connection — fail fast if the database is unreachable
|
|
121
124
|
try {
|
|
122
125
|
await schemaAwareDb.execute(sql`SELECT 1`);
|
|
123
|
-
} catch (err) {
|
|
126
|
+
} catch (err: unknown) {
|
|
127
|
+
const isConnectionRefused = isEconnrefused(err);
|
|
128
|
+
if (isConnectionRefused) {
|
|
129
|
+
// Parse host/port from connection string for a helpful message
|
|
130
|
+
let hostInfo = pgConfig.connectionString || "unknown";
|
|
131
|
+
try {
|
|
132
|
+
const parsed = new URL(pgConfig.connectionString || "");
|
|
133
|
+
hostInfo = `${parsed.hostname}:${parsed.port || 5432}`;
|
|
134
|
+
} catch { /* use raw string */ }
|
|
135
|
+
|
|
136
|
+
const message =
|
|
137
|
+
`\n` +
|
|
138
|
+
`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n` +
|
|
139
|
+
` ❌ Cannot connect to PostgreSQL at ${hostInfo}\n` +
|
|
140
|
+
`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n` +
|
|
141
|
+
`\n` +
|
|
142
|
+
` The database server is not running or is not accepting\n` +
|
|
143
|
+
` connections. Common fixes:\n` +
|
|
144
|
+
`\n` +
|
|
145
|
+
` • brew services start postgresql@18\n` +
|
|
146
|
+
` • docker compose up -d postgres\n` +
|
|
147
|
+
` • Verify DATABASE_URL in your .env file\n` +
|
|
148
|
+
`\n` +
|
|
149
|
+
`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`;
|
|
150
|
+
logger.error(message);
|
|
151
|
+
throw new Error(`Cannot connect to PostgreSQL at ${hostInfo}: connection refused. Is the database running?`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// For other errors (timeouts, auth failures, etc.) warn but continue —
|
|
155
|
+
// the pool may recover on subsequent attempts.
|
|
124
156
|
logger.error("❌ Failed to connect to PostgreSQL", { error: err });
|
|
125
157
|
logger.warn("⚠️ Continuing without initial database verification. Drizzle/PG will attempt to connect on subsequent queries.");
|
|
126
158
|
}
|
|
@@ -82,7 +82,37 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Ent
|
|
|
82
82
|
const passwordResetTokensTableName = `"${authSchema}"."password_reset_tokens"`;
|
|
83
83
|
const appConfigTableName = `"${authSchema}"."app_config"`;
|
|
84
84
|
|
|
85
|
-
// ── Create
|
|
85
|
+
// ── Create users table (idempotent) ─────────────────────────────
|
|
86
|
+
// The users table MUST be created before any dependent auth tables
|
|
87
|
+
// (user_identities, refresh_tokens, etc.) because they all hold
|
|
88
|
+
// foreign keys referencing users(id). When a developer runs
|
|
89
|
+
// `pnpm dev` for the first time without `db:migrate`, this ensures
|
|
90
|
+
// the server can self-bootstrap.
|
|
91
|
+
const idDefault = userIdType === "UUID"
|
|
92
|
+
? "DEFAULT gen_random_uuid()"
|
|
93
|
+
: userIdType === "INTEGER"
|
|
94
|
+
? "GENERATED ALWAYS AS IDENTITY"
|
|
95
|
+
: "DEFAULT gen_random_uuid()::text";
|
|
96
|
+
|
|
97
|
+
await db.execute(sql`
|
|
98
|
+
CREATE TABLE IF NOT EXISTS ${sql.raw(usersTableName)} (
|
|
99
|
+
id ${sql.raw(userIdType)} PRIMARY KEY ${sql.raw(idDefault)},
|
|
100
|
+
email VARCHAR(255) UNIQUE NOT NULL,
|
|
101
|
+
display_name VARCHAR(255),
|
|
102
|
+
photo_url VARCHAR(500),
|
|
103
|
+
roles TEXT[] DEFAULT '{}' NOT NULL,
|
|
104
|
+
password_hash VARCHAR(255),
|
|
105
|
+
email_verified BOOLEAN DEFAULT FALSE NOT NULL,
|
|
106
|
+
email_verification_token VARCHAR(255),
|
|
107
|
+
email_verification_sent_at TIMESTAMP WITH TIME ZONE,
|
|
108
|
+
is_anonymous BOOLEAN DEFAULT FALSE NOT NULL,
|
|
109
|
+
metadata JSONB DEFAULT '{}' NOT NULL,
|
|
110
|
+
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
|
|
111
|
+
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL
|
|
112
|
+
)
|
|
113
|
+
`);
|
|
114
|
+
|
|
115
|
+
// ── Create dependent auth tables (idempotent) ───────────────────
|
|
86
116
|
|
|
87
117
|
// Create user_identities table
|
|
88
118
|
await db.execute(sql`
|
|
@@ -155,6 +185,31 @@ export async function ensureAuthTablesExist(db: NodePgDatabase, collection?: Ent
|
|
|
155
185
|
ON ${sql.raw(passwordResetTokensTableName)}(user_id)
|
|
156
186
|
`);
|
|
157
187
|
|
|
188
|
+
// Create magic link tokens table
|
|
189
|
+
const magicLinkTokensTableName = `"${authSchema}"."magic_link_tokens"`;
|
|
190
|
+
await db.execute(sql`
|
|
191
|
+
CREATE TABLE IF NOT EXISTS ${sql.raw(magicLinkTokensTableName)} (
|
|
192
|
+
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
|
193
|
+
user_id ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
|
|
194
|
+
token_hash TEXT NOT NULL UNIQUE,
|
|
195
|
+
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
|
196
|
+
used_at TIMESTAMP WITH TIME ZONE,
|
|
197
|
+
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
198
|
+
)
|
|
199
|
+
`);
|
|
200
|
+
|
|
201
|
+
// Create index on token_hash for magic link lookups
|
|
202
|
+
await db.execute(sql`
|
|
203
|
+
CREATE INDEX IF NOT EXISTS idx_magic_link_tokens_hash
|
|
204
|
+
ON ${sql.raw(magicLinkTokensTableName)}(token_hash)
|
|
205
|
+
`);
|
|
206
|
+
|
|
207
|
+
// Create index on user_id for magic link cleanup
|
|
208
|
+
await db.execute(sql`
|
|
209
|
+
CREATE INDEX IF NOT EXISTS idx_magic_link_tokens_user
|
|
210
|
+
ON ${sql.raw(magicLinkTokensTableName)}(user_id)
|
|
211
|
+
`);
|
|
212
|
+
|
|
158
213
|
// Create app config table
|
|
159
214
|
await db.execute(sql`
|
|
160
215
|
CREATE TABLE IF NOT EXISTS ${sql.raw(appConfigTableName)} (
|
package/src/auth/services.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { eq, getTableName, sql } from "drizzle-orm";
|
|
|
2
2
|
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
3
3
|
import { getTableConfig } from "drizzle-orm/pg-core";
|
|
4
4
|
import type { RebasePgTable } from "../types";
|
|
5
|
-
import { users, refreshTokens, passwordResetTokens, userIdentities } from "../schema/auth-schema";
|
|
5
|
+
import { users, refreshTokens, passwordResetTokens, userIdentities, magicLinkTokens } from "../schema/auth-schema";
|
|
6
6
|
import {
|
|
7
7
|
UserRepository,
|
|
8
8
|
RoleRepository,
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
CreateRoleData,
|
|
16
16
|
RefreshTokenInfo,
|
|
17
17
|
PasswordResetTokenInfo,
|
|
18
|
+
MagicLinkTokenInfo,
|
|
18
19
|
UserIdentityData,
|
|
19
20
|
ListUsersOptions,
|
|
20
21
|
PaginatedUsersResult,
|
|
@@ -693,6 +694,68 @@ export class PasswordResetTokenService {
|
|
|
693
694
|
}
|
|
694
695
|
}
|
|
695
696
|
|
|
697
|
+
/**
|
|
698
|
+
* Magic link token service.
|
|
699
|
+
* Handles magic link token storage for passwordless email login.
|
|
700
|
+
*/
|
|
701
|
+
export class MagicLinkTokenService {
|
|
702
|
+
private magicLinkTokensTable: RebasePgTable;
|
|
703
|
+
|
|
704
|
+
constructor(
|
|
705
|
+
private db: NodePgDatabase,
|
|
706
|
+
tableOrTables?: RebasePgTable | Partial<AuthSchemaTables>
|
|
707
|
+
) {
|
|
708
|
+
this.magicLinkTokensTable = (magicLinkTokens as unknown as RebasePgTable);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
private getQualifiedTableName(): string {
|
|
712
|
+
const name = getTableName(this.magicLinkTokensTable);
|
|
713
|
+
const schema = getTableConfig(this.magicLinkTokensTable).schema || "public";
|
|
714
|
+
return `"${schema}"."${name}"`;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
async createToken(userId: string, tokenHash: string, expiresAt: Date): Promise<void> {
|
|
718
|
+
// Delete any existing unused tokens for this user
|
|
719
|
+
const tableName = this.getQualifiedTableName();
|
|
720
|
+
await this.db.execute(sql`
|
|
721
|
+
DELETE FROM ${sql.raw(tableName)}
|
|
722
|
+
WHERE user_id = ${userId} AND used_at IS NULL
|
|
723
|
+
`);
|
|
724
|
+
|
|
725
|
+
await this.db.insert(this.magicLinkTokensTable).values({
|
|
726
|
+
userId,
|
|
727
|
+
tokenHash,
|
|
728
|
+
expiresAt
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
async findValidByHash(tokenHash: string): Promise<MagicLinkTokenInfo | null> {
|
|
733
|
+
const tableName = this.getQualifiedTableName();
|
|
734
|
+
const result = await this.db.execute(sql`
|
|
735
|
+
SELECT user_id, expires_at
|
|
736
|
+
FROM ${sql.raw(tableName)}
|
|
737
|
+
WHERE token_hash = ${tokenHash}
|
|
738
|
+
AND used_at IS NULL
|
|
739
|
+
AND expires_at > NOW()
|
|
740
|
+
`);
|
|
741
|
+
|
|
742
|
+
if (result.rows.length === 0) return null;
|
|
743
|
+
|
|
744
|
+
const row = result.rows[0] as { user_id: string; expires_at: string | number | Date };
|
|
745
|
+
return {
|
|
746
|
+
userId: row.user_id,
|
|
747
|
+
expiresAt: new Date(row.expires_at)
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
async markAsUsed(tokenHash: string): Promise<void> {
|
|
752
|
+
await this.db
|
|
753
|
+
.update(this.magicLinkTokensTable)
|
|
754
|
+
.set({ usedAt: new Date() })
|
|
755
|
+
.where(eq(this.magicLinkTokensTable.tokenHash, tokenHash));
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
696
759
|
/**
|
|
697
760
|
* PostgreSQL implementation of TokenRepository.
|
|
698
761
|
* Combines refresh token and password reset token operations.
|
|
@@ -700,6 +763,7 @@ export class PasswordResetTokenService {
|
|
|
700
763
|
export class PostgresTokenRepository implements TokenRepository {
|
|
701
764
|
private refreshTokenService: RefreshTokenService;
|
|
702
765
|
private passwordResetTokenService: PasswordResetTokenService;
|
|
766
|
+
private magicLinkTokenService: MagicLinkTokenService;
|
|
703
767
|
|
|
704
768
|
constructor(
|
|
705
769
|
private db: NodePgDatabase,
|
|
@@ -707,6 +771,7 @@ export class PostgresTokenRepository implements TokenRepository {
|
|
|
707
771
|
) {
|
|
708
772
|
this.refreshTokenService = new RefreshTokenService(db, tableOrTables);
|
|
709
773
|
this.passwordResetTokenService = new PasswordResetTokenService(db, tableOrTables);
|
|
774
|
+
this.magicLinkTokenService = new MagicLinkTokenService(db, tableOrTables);
|
|
710
775
|
}
|
|
711
776
|
|
|
712
777
|
// Refresh token operations
|
|
@@ -756,6 +821,20 @@ export class PostgresTokenRepository implements TokenRepository {
|
|
|
756
821
|
async deleteExpiredTokens(): Promise<void> {
|
|
757
822
|
await this.passwordResetTokenService.deleteExpired();
|
|
758
823
|
}
|
|
824
|
+
|
|
825
|
+
// Magic link token operations
|
|
826
|
+
|
|
827
|
+
async createMagicLinkToken(userId: string, tokenHash: string, expiresAt: Date): Promise<void> {
|
|
828
|
+
await this.magicLinkTokenService.createToken(userId, tokenHash, expiresAt);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
async findValidMagicLinkToken(tokenHash: string): Promise<MagicLinkTokenInfo | null> {
|
|
832
|
+
return this.magicLinkTokenService.findValidByHash(tokenHash);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
async markMagicLinkTokenUsed(tokenHash: string): Promise<void> {
|
|
836
|
+
await this.magicLinkTokenService.markAsUsed(tokenHash);
|
|
837
|
+
}
|
|
759
838
|
}
|
|
760
839
|
|
|
761
840
|
/**
|
|
@@ -955,6 +1034,20 @@ collectionPermissions: null }
|
|
|
955
1034
|
await this.tokenRepository.deleteExpiredTokens();
|
|
956
1035
|
}
|
|
957
1036
|
|
|
1037
|
+
// Magic link token operations
|
|
1038
|
+
|
|
1039
|
+
async createMagicLinkToken(userId: string, tokenHash: string, expiresAt: Date): Promise<void> {
|
|
1040
|
+
await this.tokenRepository.createMagicLinkToken(userId, tokenHash, expiresAt);
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
async findValidMagicLinkToken(tokenHash: string): Promise<MagicLinkTokenInfo | null> {
|
|
1044
|
+
return this.tokenRepository.findValidMagicLinkToken(tokenHash);
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
async markMagicLinkTokenUsed(tokenHash: string): Promise<void> {
|
|
1048
|
+
await this.tokenRepository.markMagicLinkTokenUsed(tokenHash);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
958
1051
|
// MFA operations (delegate to MfaService)
|
|
959
1052
|
|
|
960
1053
|
private _mfaService: MfaService | null = null;
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { logger } from "@rebasepro/server-core";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Detect whether an error (or AggregateError wrapping multiple attempts)
|
|
6
|
+
* represents an ECONNREFUSED — i.e. the database is simply not running.
|
|
7
|
+
*
|
|
8
|
+
* Handles:
|
|
9
|
+
* - Direct `{ code: "ECONNREFUSED" }` errors from Node `net`
|
|
10
|
+
* - `AggregateError` from dual-stack IPv4+IPv6 connection attempts
|
|
11
|
+
* - Drizzle's `cause`-wrapped pg errors
|
|
12
|
+
*/
|
|
13
|
+
export function isEconnrefused(err: unknown): boolean {
|
|
14
|
+
if (!err || typeof err !== "object") return false;
|
|
15
|
+
const e = err as { code?: string; cause?: unknown; errors?: unknown[] };
|
|
16
|
+
if (e.code === "ECONNREFUSED") return true;
|
|
17
|
+
// AggregateError from Node net (dual-stack IPv4 + IPv6)
|
|
18
|
+
if (Array.isArray(e.errors)) {
|
|
19
|
+
return e.errors.some(inner =>
|
|
20
|
+
inner && typeof inner === "object" && (inner as { code?: string }).code === "ECONNREFUSED"
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
// Drizzle wraps the pg error in `cause`
|
|
24
|
+
if (e.cause && typeof e.cause === "object") {
|
|
25
|
+
return isEconnrefused(e.cause);
|
|
26
|
+
}
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Detect PostgreSQL authentication failures.
|
|
32
|
+
* PG error codes: 28P01 (invalid_password), 28000 (invalid_authorization_specification)
|
|
33
|
+
*/
|
|
34
|
+
export function isAuthFailure(err: unknown): boolean {
|
|
35
|
+
if (!err || typeof err !== "object") return false;
|
|
36
|
+
const e = err as { code?: string; cause?: unknown };
|
|
37
|
+
if (e.code === "28P01" || e.code === "28000") return true;
|
|
38
|
+
if (e.cause && typeof e.cause === "object") {
|
|
39
|
+
return isAuthFailure(e.cause);
|
|
40
|
+
}
|
|
41
|
+
// Also check the message for common pg auth failure text
|
|
42
|
+
if ("message" in e && typeof (e as { message?: string }).message === "string") {
|
|
43
|
+
const msg = (e as { message: string }).message.toLowerCase();
|
|
44
|
+
if (msg.includes("password authentication failed") || msg.includes("no pg_hba.conf entry")) {
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Parse host:port from a DATABASE_URL for display purposes.
|
|
53
|
+
*/
|
|
54
|
+
function parseHostInfo(databaseUrl: string): string {
|
|
55
|
+
try {
|
|
56
|
+
const parsed = new URL(databaseUrl);
|
|
57
|
+
return `${parsed.hostname}:${parsed.port || 5432}`;
|
|
58
|
+
} catch {
|
|
59
|
+
return "unknown";
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Format a diagnostic banner for ECONNREFUSED errors.
|
|
65
|
+
*/
|
|
66
|
+
function formatConnectionRefusedBanner(databaseUrl: string): string {
|
|
67
|
+
const hostInfo = parseHostInfo(databaseUrl);
|
|
68
|
+
return (
|
|
69
|
+
`\n` +
|
|
70
|
+
`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n` +
|
|
71
|
+
` ❌ Cannot connect to PostgreSQL at ${hostInfo}\n` +
|
|
72
|
+
`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n` +
|
|
73
|
+
`\n` +
|
|
74
|
+
` The database server is not running or is not accepting\n` +
|
|
75
|
+
` connections. Common fixes:\n` +
|
|
76
|
+
`\n` +
|
|
77
|
+
` • brew services start postgresql@18\n` +
|
|
78
|
+
` • docker compose up -d postgres\n` +
|
|
79
|
+
` • Verify DATABASE_URL in your .env file\n` +
|
|
80
|
+
`\n` +
|
|
81
|
+
`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Format a diagnostic banner for authentication failures.
|
|
87
|
+
*/
|
|
88
|
+
function formatAuthFailureBanner(databaseUrl: string): string {
|
|
89
|
+
const hostInfo = parseHostInfo(databaseUrl);
|
|
90
|
+
let username = "unknown";
|
|
91
|
+
try {
|
|
92
|
+
username = new URL(databaseUrl).username || "unknown";
|
|
93
|
+
} catch { /* ignore */ }
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
`\n` +
|
|
97
|
+
`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n` +
|
|
98
|
+
` ❌ Authentication failed for user "${username}" at ${hostInfo}\n` +
|
|
99
|
+
`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n` +
|
|
100
|
+
`\n` +
|
|
101
|
+
` PostgreSQL rejected the credentials. Common fixes:\n` +
|
|
102
|
+
`\n` +
|
|
103
|
+
` • Check the username and password in DATABASE_URL\n` +
|
|
104
|
+
` • Verify the user exists: psql -c "\\du"\n` +
|
|
105
|
+
` • Reset the password: ALTER USER ${username} PASSWORD 'new_password';\n` +
|
|
106
|
+
`\n` +
|
|
107
|
+
`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Pre-flight check: verify that the database is reachable before running
|
|
113
|
+
* a heavy subprocess (Atlas, migrations, etc.).
|
|
114
|
+
*
|
|
115
|
+
* Exits with code 1 and a friendly banner on known failure modes.
|
|
116
|
+
* On unknown errors, logs a warning and allows the caller to proceed.
|
|
117
|
+
*/
|
|
118
|
+
export async function checkDatabaseConnectivity(databaseUrl: string): Promise<void> {
|
|
119
|
+
let client: import("pg").Client | undefined;
|
|
120
|
+
try {
|
|
121
|
+
const { Client } = await import("pg");
|
|
122
|
+
client = new Client({
|
|
123
|
+
connectionString: databaseUrl,
|
|
124
|
+
connectionTimeoutMillis: 5000
|
|
125
|
+
});
|
|
126
|
+
await client.connect();
|
|
127
|
+
await client.query("SELECT 1");
|
|
128
|
+
} catch (err: unknown) {
|
|
129
|
+
if (isEconnrefused(err)) {
|
|
130
|
+
logger.error(formatConnectionRefusedBanner(databaseUrl));
|
|
131
|
+
process.exit(1);
|
|
132
|
+
}
|
|
133
|
+
if (isAuthFailure(err)) {
|
|
134
|
+
logger.error(formatAuthFailureBanner(databaseUrl));
|
|
135
|
+
process.exit(1);
|
|
136
|
+
}
|
|
137
|
+
// Unknown error — warn but don't block; let the downstream tool surface details
|
|
138
|
+
logger.warn(chalk.yellow(` ⚠ Could not verify database connectivity: ${err instanceof Error ? err.message : String(err)}`));
|
|
139
|
+
logger.warn(chalk.gray(" Proceeding anyway — the command may fail if the database is unreachable."));
|
|
140
|
+
} finally {
|
|
141
|
+
try {
|
|
142
|
+
await client?.end();
|
|
143
|
+
} catch {
|
|
144
|
+
// ignore cleanup errors
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Post-hoc error diagnosis for direct database operations (e.g. applyPolicies).
|
|
151
|
+
* Returns a formatted diagnostic string if the error matches a known pattern,
|
|
152
|
+
* or null if unrecognized.
|
|
153
|
+
*/
|
|
154
|
+
export function diagnoseDbError(err: unknown, databaseUrl?: string): string | null {
|
|
155
|
+
if (isEconnrefused(err)) {
|
|
156
|
+
return formatConnectionRefusedBanner(databaseUrl || "");
|
|
157
|
+
}
|
|
158
|
+
if (isAuthFailure(err)) {
|
|
159
|
+
return formatAuthFailureBanner(databaseUrl || "");
|
|
160
|
+
}
|
|
161
|
+
return null;
|
|
162
|
+
}
|