@rebasepro/server-postgres 0.9.1-canary.ff338b5 → 0.10.1-canary.18115ba
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 +21 -0
- package/dist/PostgresBackendDriver.d.ts +18 -0
- package/dist/PostgresBootstrapper.d.ts +7 -1
- package/dist/auth/services.d.ts +93 -54
- package/dist/index.es.js +929 -224
- package/dist/index.es.js.map +1 -1
- package/dist/schema/auth-bootstrap-sql.d.ts +1 -1
- package/dist/schema/auth-schema.d.ts +194 -24
- package/dist/schema/introspect-db-logic.d.ts +0 -5
- package/dist/schema/introspect-db-naming.d.ts +10 -0
- package/dist/security/policy-drift.d.ts +24 -0
- package/dist/security/rls-enforcement.d.ts +2 -2
- package/dist/services/channel-history.d.ts +118 -0
- package/dist/services/realtimeService.d.ts +69 -2
- package/package.json +7 -31
- package/src/PostgresBackendDriver.ts +56 -5
- package/src/PostgresBootstrapper.ts +18 -1
- package/src/auth/ensure-tables.ts +187 -19
- package/src/auth/services.ts +309 -170
- package/src/schema/auth-bootstrap-sql.ts +7 -1
- package/src/schema/auth-schema.ts +53 -15
- package/src/schema/introspect-db-inference.ts +1 -1
- package/src/schema/introspect-db-logic.ts +1 -10
- package/src/schema/introspect-db-naming.ts +15 -0
- package/src/schema/introspect-runtime.ts +1 -1
- package/src/security/policy-drift.test.ts +46 -0
- package/src/security/policy-drift.ts +70 -4
- package/src/security/rls-enforcement.ts +11 -5
- package/src/services/channel-history.ts +343 -0
- package/src/services/realtimeService.ts +198 -10
- package/src/websocket.ts +30 -11
|
@@ -21,4 +21,4 @@
|
|
|
21
21
|
* `DROP SCHEMA "rebase" CASCADE` on the next diff. The `auth` schema is safe
|
|
22
22
|
* because the generated schema.sql declares it.
|
|
23
23
|
*/
|
|
24
|
-
export declare const AUTH_BOOTSTRAP_SQL = "-- Auth schema + RLS helper functions (required by the policies below)\nCREATE SCHEMA IF NOT EXISTS auth;\n\nCREATE OR REPLACE FUNCTION auth.uid() RETURNS text AS $$\n SELECT NULLIF(current_setting('app.user_id', true), '');\n$$ LANGUAGE sql STABLE;\n\nCREATE OR REPLACE FUNCTION auth.jwt() RETURNS jsonb AS $$\n SELECT COALESCE(\n NULLIF(current_setting('app.jwt', true), ''),\n '{}'\n )::jsonb;\n$$ LANGUAGE sql STABLE;\n\nCREATE OR REPLACE FUNCTION auth.roles() RETURNS text AS $$\n SELECT COALESCE(NULLIF(current_setting('app.user_roles', true), ''), '');\n$$ LANGUAGE sql STABLE;\n";
|
|
24
|
+
export declare const AUTH_BOOTSTRAP_SQL = "-- Auth schema + RLS helper functions (required by the policies below)\nCREATE SCHEMA IF NOT EXISTS auth;\n\n-- Falls back to the pre-rename `app.user_id` so a database that has taken the\n-- new schema but is still served by an older backend keeps resolving the\n-- principal. Drop the COALESCE once no such deployment remains.\nCREATE OR REPLACE FUNCTION auth.uid() RETURNS text AS $$\n SELECT COALESCE(\n NULLIF(current_setting('app.uid', true), ''),\n NULLIF(current_setting('app.user_id', true), '')\n );\n$$ LANGUAGE sql STABLE;\n\nCREATE OR REPLACE FUNCTION auth.jwt() RETURNS jsonb AS $$\n SELECT COALESCE(\n NULLIF(current_setting('app.jwt', true), ''),\n '{}'\n )::jsonb;\n$$ LANGUAGE sql STABLE;\n\nCREATE OR REPLACE FUNCTION auth.roles() RETURNS text AS $$\n SELECT COALESCE(NULLIF(current_setting('app.user_roles', true), ''), '');\n$$ LANGUAGE sql STABLE;\n";
|
|
@@ -232,6 +232,23 @@ export declare function createAuthSchema(usersSchemaName?: string): {
|
|
|
232
232
|
}, {}, {
|
|
233
233
|
$type: Record<string, unknown>;
|
|
234
234
|
}>;
|
|
235
|
+
tokensValidAfter: import("drizzle-orm/pg-core").PgColumn<{
|
|
236
|
+
name: "tokens_valid_after";
|
|
237
|
+
tableName: "users";
|
|
238
|
+
dataType: "date";
|
|
239
|
+
columnType: "PgTimestamp";
|
|
240
|
+
data: Date;
|
|
241
|
+
driverParam: string;
|
|
242
|
+
notNull: false;
|
|
243
|
+
hasDefault: false;
|
|
244
|
+
isPrimaryKey: false;
|
|
245
|
+
isAutoincrement: false;
|
|
246
|
+
hasRuntimeDefault: false;
|
|
247
|
+
enumValues: undefined;
|
|
248
|
+
baseColumn: never;
|
|
249
|
+
identity: undefined;
|
|
250
|
+
generated: undefined;
|
|
251
|
+
}, {}, {}>;
|
|
235
252
|
createdAt: import("drizzle-orm/pg-core").PgColumn<{
|
|
236
253
|
name: "created_at";
|
|
237
254
|
tableName: "users";
|
|
@@ -290,8 +307,8 @@ export declare function createAuthSchema(usersSchemaName?: string): {
|
|
|
290
307
|
identity: undefined;
|
|
291
308
|
generated: undefined;
|
|
292
309
|
}, {}, {}>;
|
|
293
|
-
|
|
294
|
-
name: "
|
|
310
|
+
uid: import("drizzle-orm/pg-core").PgColumn<{
|
|
311
|
+
name: "uid";
|
|
295
312
|
tableName: "refresh_tokens";
|
|
296
313
|
dataType: "string";
|
|
297
314
|
columnType: "PgUUID";
|
|
@@ -307,6 +324,23 @@ export declare function createAuthSchema(usersSchemaName?: string): {
|
|
|
307
324
|
identity: undefined;
|
|
308
325
|
generated: undefined;
|
|
309
326
|
}, {}, {}>;
|
|
327
|
+
sessionId: import("drizzle-orm/pg-core").PgColumn<{
|
|
328
|
+
name: "session_id";
|
|
329
|
+
tableName: "refresh_tokens";
|
|
330
|
+
dataType: "string";
|
|
331
|
+
columnType: "PgUUID";
|
|
332
|
+
data: string;
|
|
333
|
+
driverParam: string;
|
|
334
|
+
notNull: true;
|
|
335
|
+
hasDefault: true;
|
|
336
|
+
isPrimaryKey: false;
|
|
337
|
+
isAutoincrement: false;
|
|
338
|
+
hasRuntimeDefault: false;
|
|
339
|
+
enumValues: undefined;
|
|
340
|
+
baseColumn: never;
|
|
341
|
+
identity: undefined;
|
|
342
|
+
generated: undefined;
|
|
343
|
+
}, {}, {}>;
|
|
310
344
|
tokenHash: import("drizzle-orm/pg-core").PgColumn<{
|
|
311
345
|
name: "token_hash";
|
|
312
346
|
tableName: "refresh_tokens";
|
|
@@ -343,6 +377,57 @@ export declare function createAuthSchema(usersSchemaName?: string): {
|
|
|
343
377
|
identity: undefined;
|
|
344
378
|
generated: undefined;
|
|
345
379
|
}, {}, {}>;
|
|
380
|
+
revoked: import("drizzle-orm/pg-core").PgColumn<{
|
|
381
|
+
name: "revoked";
|
|
382
|
+
tableName: "refresh_tokens";
|
|
383
|
+
dataType: "boolean";
|
|
384
|
+
columnType: "PgBoolean";
|
|
385
|
+
data: boolean;
|
|
386
|
+
driverParam: boolean;
|
|
387
|
+
notNull: true;
|
|
388
|
+
hasDefault: true;
|
|
389
|
+
isPrimaryKey: false;
|
|
390
|
+
isAutoincrement: false;
|
|
391
|
+
hasRuntimeDefault: false;
|
|
392
|
+
enumValues: undefined;
|
|
393
|
+
baseColumn: never;
|
|
394
|
+
identity: undefined;
|
|
395
|
+
generated: undefined;
|
|
396
|
+
}, {}, {}>;
|
|
397
|
+
rotatedAt: import("drizzle-orm/pg-core").PgColumn<{
|
|
398
|
+
name: "rotated_at";
|
|
399
|
+
tableName: "refresh_tokens";
|
|
400
|
+
dataType: "date";
|
|
401
|
+
columnType: "PgTimestamp";
|
|
402
|
+
data: Date;
|
|
403
|
+
driverParam: string;
|
|
404
|
+
notNull: false;
|
|
405
|
+
hasDefault: false;
|
|
406
|
+
isPrimaryKey: false;
|
|
407
|
+
isAutoincrement: false;
|
|
408
|
+
hasRuntimeDefault: false;
|
|
409
|
+
enumValues: undefined;
|
|
410
|
+
baseColumn: never;
|
|
411
|
+
identity: undefined;
|
|
412
|
+
generated: undefined;
|
|
413
|
+
}, {}, {}>;
|
|
414
|
+
sessionStartedAt: import("drizzle-orm/pg-core").PgColumn<{
|
|
415
|
+
name: "session_started_at";
|
|
416
|
+
tableName: "refresh_tokens";
|
|
417
|
+
dataType: "date";
|
|
418
|
+
columnType: "PgTimestamp";
|
|
419
|
+
data: Date;
|
|
420
|
+
driverParam: string;
|
|
421
|
+
notNull: true;
|
|
422
|
+
hasDefault: true;
|
|
423
|
+
isPrimaryKey: false;
|
|
424
|
+
isAutoincrement: false;
|
|
425
|
+
hasRuntimeDefault: false;
|
|
426
|
+
enumValues: undefined;
|
|
427
|
+
baseColumn: never;
|
|
428
|
+
identity: undefined;
|
|
429
|
+
generated: undefined;
|
|
430
|
+
}, {}, {}>;
|
|
346
431
|
userAgent: import("drizzle-orm/pg-core").PgColumn<{
|
|
347
432
|
name: "user_agent";
|
|
348
433
|
tableName: "refresh_tokens";
|
|
@@ -422,8 +507,8 @@ export declare function createAuthSchema(usersSchemaName?: string): {
|
|
|
422
507
|
identity: undefined;
|
|
423
508
|
generated: undefined;
|
|
424
509
|
}, {}, {}>;
|
|
425
|
-
|
|
426
|
-
name: "
|
|
510
|
+
uid: import("drizzle-orm/pg-core").PgColumn<{
|
|
511
|
+
name: "uid";
|
|
427
512
|
tableName: "password_reset_tokens";
|
|
428
513
|
dataType: "string";
|
|
429
514
|
columnType: "PgUUID";
|
|
@@ -593,8 +678,8 @@ export declare function createAuthSchema(usersSchemaName?: string): {
|
|
|
593
678
|
identity: undefined;
|
|
594
679
|
generated: undefined;
|
|
595
680
|
}, {}, {}>;
|
|
596
|
-
|
|
597
|
-
name: "
|
|
681
|
+
uid: import("drizzle-orm/pg-core").PgColumn<{
|
|
682
|
+
name: "uid";
|
|
598
683
|
tableName: "user_identities";
|
|
599
684
|
dataType: "string";
|
|
600
685
|
columnType: "PgUUID";
|
|
@@ -723,8 +808,8 @@ export declare function createAuthSchema(usersSchemaName?: string): {
|
|
|
723
808
|
identity: undefined;
|
|
724
809
|
generated: undefined;
|
|
725
810
|
}, {}, {}>;
|
|
726
|
-
|
|
727
|
-
name: "
|
|
811
|
+
uid: import("drizzle-orm/pg-core").PgColumn<{
|
|
812
|
+
name: "uid";
|
|
728
813
|
tableName: "mfa_factors";
|
|
729
814
|
dataType: "string";
|
|
730
815
|
columnType: "PgUUID";
|
|
@@ -983,8 +1068,8 @@ export declare function createAuthSchema(usersSchemaName?: string): {
|
|
|
983
1068
|
identity: undefined;
|
|
984
1069
|
generated: undefined;
|
|
985
1070
|
}, {}, {}>;
|
|
986
|
-
|
|
987
|
-
name: "
|
|
1071
|
+
uid: import("drizzle-orm/pg-core").PgColumn<{
|
|
1072
|
+
name: "uid";
|
|
988
1073
|
tableName: "recovery_codes";
|
|
989
1074
|
dataType: "string";
|
|
990
1075
|
columnType: "PgUUID";
|
|
@@ -1077,8 +1162,8 @@ export declare function createAuthSchema(usersSchemaName?: string): {
|
|
|
1077
1162
|
identity: undefined;
|
|
1078
1163
|
generated: undefined;
|
|
1079
1164
|
}, {}, {}>;
|
|
1080
|
-
|
|
1081
|
-
name: "
|
|
1165
|
+
uid: import("drizzle-orm/pg-core").PgColumn<{
|
|
1166
|
+
name: "uid";
|
|
1082
1167
|
tableName: "magic_link_tokens";
|
|
1083
1168
|
dataType: "string";
|
|
1084
1169
|
columnType: "PgUUID";
|
|
@@ -1398,6 +1483,23 @@ export declare const users: import("drizzle-orm/pg-core").PgTableWithColumns<{
|
|
|
1398
1483
|
}, {}, {
|
|
1399
1484
|
$type: Record<string, unknown>;
|
|
1400
1485
|
}>;
|
|
1486
|
+
tokensValidAfter: import("drizzle-orm/pg-core").PgColumn<{
|
|
1487
|
+
name: "tokens_valid_after";
|
|
1488
|
+
tableName: "users";
|
|
1489
|
+
dataType: "date";
|
|
1490
|
+
columnType: "PgTimestamp";
|
|
1491
|
+
data: Date;
|
|
1492
|
+
driverParam: string;
|
|
1493
|
+
notNull: false;
|
|
1494
|
+
hasDefault: false;
|
|
1495
|
+
isPrimaryKey: false;
|
|
1496
|
+
isAutoincrement: false;
|
|
1497
|
+
hasRuntimeDefault: false;
|
|
1498
|
+
enumValues: undefined;
|
|
1499
|
+
baseColumn: never;
|
|
1500
|
+
identity: undefined;
|
|
1501
|
+
generated: undefined;
|
|
1502
|
+
}, {}, {}>;
|
|
1401
1503
|
createdAt: import("drizzle-orm/pg-core").PgColumn<{
|
|
1402
1504
|
name: "created_at";
|
|
1403
1505
|
tableName: "users";
|
|
@@ -1456,8 +1558,8 @@ export declare const refreshTokens: import("drizzle-orm/pg-core").PgTableWithCol
|
|
|
1456
1558
|
identity: undefined;
|
|
1457
1559
|
generated: undefined;
|
|
1458
1560
|
}, {}, {}>;
|
|
1459
|
-
|
|
1460
|
-
name: "
|
|
1561
|
+
uid: import("drizzle-orm/pg-core").PgColumn<{
|
|
1562
|
+
name: "uid";
|
|
1461
1563
|
tableName: "refresh_tokens";
|
|
1462
1564
|
dataType: "string";
|
|
1463
1565
|
columnType: "PgUUID";
|
|
@@ -1473,6 +1575,23 @@ export declare const refreshTokens: import("drizzle-orm/pg-core").PgTableWithCol
|
|
|
1473
1575
|
identity: undefined;
|
|
1474
1576
|
generated: undefined;
|
|
1475
1577
|
}, {}, {}>;
|
|
1578
|
+
sessionId: import("drizzle-orm/pg-core").PgColumn<{
|
|
1579
|
+
name: "session_id";
|
|
1580
|
+
tableName: "refresh_tokens";
|
|
1581
|
+
dataType: "string";
|
|
1582
|
+
columnType: "PgUUID";
|
|
1583
|
+
data: string;
|
|
1584
|
+
driverParam: string;
|
|
1585
|
+
notNull: true;
|
|
1586
|
+
hasDefault: true;
|
|
1587
|
+
isPrimaryKey: false;
|
|
1588
|
+
isAutoincrement: false;
|
|
1589
|
+
hasRuntimeDefault: false;
|
|
1590
|
+
enumValues: undefined;
|
|
1591
|
+
baseColumn: never;
|
|
1592
|
+
identity: undefined;
|
|
1593
|
+
generated: undefined;
|
|
1594
|
+
}, {}, {}>;
|
|
1476
1595
|
tokenHash: import("drizzle-orm/pg-core").PgColumn<{
|
|
1477
1596
|
name: "token_hash";
|
|
1478
1597
|
tableName: "refresh_tokens";
|
|
@@ -1509,6 +1628,57 @@ export declare const refreshTokens: import("drizzle-orm/pg-core").PgTableWithCol
|
|
|
1509
1628
|
identity: undefined;
|
|
1510
1629
|
generated: undefined;
|
|
1511
1630
|
}, {}, {}>;
|
|
1631
|
+
revoked: import("drizzle-orm/pg-core").PgColumn<{
|
|
1632
|
+
name: "revoked";
|
|
1633
|
+
tableName: "refresh_tokens";
|
|
1634
|
+
dataType: "boolean";
|
|
1635
|
+
columnType: "PgBoolean";
|
|
1636
|
+
data: boolean;
|
|
1637
|
+
driverParam: boolean;
|
|
1638
|
+
notNull: true;
|
|
1639
|
+
hasDefault: true;
|
|
1640
|
+
isPrimaryKey: false;
|
|
1641
|
+
isAutoincrement: false;
|
|
1642
|
+
hasRuntimeDefault: false;
|
|
1643
|
+
enumValues: undefined;
|
|
1644
|
+
baseColumn: never;
|
|
1645
|
+
identity: undefined;
|
|
1646
|
+
generated: undefined;
|
|
1647
|
+
}, {}, {}>;
|
|
1648
|
+
rotatedAt: import("drizzle-orm/pg-core").PgColumn<{
|
|
1649
|
+
name: "rotated_at";
|
|
1650
|
+
tableName: "refresh_tokens";
|
|
1651
|
+
dataType: "date";
|
|
1652
|
+
columnType: "PgTimestamp";
|
|
1653
|
+
data: Date;
|
|
1654
|
+
driverParam: string;
|
|
1655
|
+
notNull: false;
|
|
1656
|
+
hasDefault: false;
|
|
1657
|
+
isPrimaryKey: false;
|
|
1658
|
+
isAutoincrement: false;
|
|
1659
|
+
hasRuntimeDefault: false;
|
|
1660
|
+
enumValues: undefined;
|
|
1661
|
+
baseColumn: never;
|
|
1662
|
+
identity: undefined;
|
|
1663
|
+
generated: undefined;
|
|
1664
|
+
}, {}, {}>;
|
|
1665
|
+
sessionStartedAt: import("drizzle-orm/pg-core").PgColumn<{
|
|
1666
|
+
name: "session_started_at";
|
|
1667
|
+
tableName: "refresh_tokens";
|
|
1668
|
+
dataType: "date";
|
|
1669
|
+
columnType: "PgTimestamp";
|
|
1670
|
+
data: Date;
|
|
1671
|
+
driverParam: string;
|
|
1672
|
+
notNull: true;
|
|
1673
|
+
hasDefault: true;
|
|
1674
|
+
isPrimaryKey: false;
|
|
1675
|
+
isAutoincrement: false;
|
|
1676
|
+
hasRuntimeDefault: false;
|
|
1677
|
+
enumValues: undefined;
|
|
1678
|
+
baseColumn: never;
|
|
1679
|
+
identity: undefined;
|
|
1680
|
+
generated: undefined;
|
|
1681
|
+
}, {}, {}>;
|
|
1512
1682
|
userAgent: import("drizzle-orm/pg-core").PgColumn<{
|
|
1513
1683
|
name: "user_agent";
|
|
1514
1684
|
tableName: "refresh_tokens";
|
|
@@ -1588,8 +1758,8 @@ export declare const passwordResetTokens: import("drizzle-orm/pg-core").PgTableW
|
|
|
1588
1758
|
identity: undefined;
|
|
1589
1759
|
generated: undefined;
|
|
1590
1760
|
}, {}, {}>;
|
|
1591
|
-
|
|
1592
|
-
name: "
|
|
1761
|
+
uid: import("drizzle-orm/pg-core").PgColumn<{
|
|
1762
|
+
name: "uid";
|
|
1593
1763
|
tableName: "password_reset_tokens";
|
|
1594
1764
|
dataType: "string";
|
|
1595
1765
|
columnType: "PgUUID";
|
|
@@ -1759,8 +1929,8 @@ export declare const userIdentities: import("drizzle-orm/pg-core").PgTableWithCo
|
|
|
1759
1929
|
identity: undefined;
|
|
1760
1930
|
generated: undefined;
|
|
1761
1931
|
}, {}, {}>;
|
|
1762
|
-
|
|
1763
|
-
name: "
|
|
1932
|
+
uid: import("drizzle-orm/pg-core").PgColumn<{
|
|
1933
|
+
name: "uid";
|
|
1764
1934
|
tableName: "user_identities";
|
|
1765
1935
|
dataType: "string";
|
|
1766
1936
|
columnType: "PgUUID";
|
|
@@ -1889,8 +2059,8 @@ export declare const mfaFactors: import("drizzle-orm/pg-core").PgTableWithColumn
|
|
|
1889
2059
|
identity: undefined;
|
|
1890
2060
|
generated: undefined;
|
|
1891
2061
|
}, {}, {}>;
|
|
1892
|
-
|
|
1893
|
-
name: "
|
|
2062
|
+
uid: import("drizzle-orm/pg-core").PgColumn<{
|
|
2063
|
+
name: "uid";
|
|
1894
2064
|
tableName: "mfa_factors";
|
|
1895
2065
|
dataType: "string";
|
|
1896
2066
|
columnType: "PgUUID";
|
|
@@ -2149,8 +2319,8 @@ export declare const recoveryCodes: import("drizzle-orm/pg-core").PgTableWithCol
|
|
|
2149
2319
|
identity: undefined;
|
|
2150
2320
|
generated: undefined;
|
|
2151
2321
|
}, {}, {}>;
|
|
2152
|
-
|
|
2153
|
-
name: "
|
|
2322
|
+
uid: import("drizzle-orm/pg-core").PgColumn<{
|
|
2323
|
+
name: "uid";
|
|
2154
2324
|
tableName: "recovery_codes";
|
|
2155
2325
|
dataType: "string";
|
|
2156
2326
|
columnType: "PgUUID";
|
|
@@ -2243,8 +2413,8 @@ export declare const magicLinkTokens: import("drizzle-orm/pg-core").PgTableWithC
|
|
|
2243
2413
|
identity: undefined;
|
|
2244
2414
|
generated: undefined;
|
|
2245
2415
|
}, {}, {}>;
|
|
2246
|
-
|
|
2247
|
-
name: "
|
|
2416
|
+
uid: import("drizzle-orm/pg-core").PgColumn<{
|
|
2417
|
+
name: "uid";
|
|
2248
2418
|
tableName: "magic_link_tokens";
|
|
2249
2419
|
dataType: "string";
|
|
2250
2420
|
columnType: "PgUUID";
|
|
@@ -32,11 +32,6 @@ export interface TableMeta {
|
|
|
32
32
|
fks: ForeignKeyRow[];
|
|
33
33
|
}
|
|
34
34
|
export declare function singularize(word: string): string;
|
|
35
|
-
/**
|
|
36
|
-
* Convert a snake_case name to a human-readable Title Case label.
|
|
37
|
-
* e.g. "created_at" -> "Created At", "customer_id" -> "Customer Id"
|
|
38
|
-
*/
|
|
39
|
-
export declare function humanize(snakeName: string): string;
|
|
40
35
|
/**
|
|
41
36
|
* Convert a snake_case table name to a camelCase + "Collection" variable name.
|
|
42
37
|
* e.g. "company_token" -> "companyTokenCollection"
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Naming helpers shared by the introspection modules. These live apart from
|
|
3
|
+
* `introspect-db-logic.ts` because the inference pass needs them too, and
|
|
4
|
+
* importing them from there would close a cycle back through this module.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Convert a snake_case name to a human-readable Title Case label.
|
|
8
|
+
* e.g. "created_at" -> "Created At", "customer_id" -> "Customer Id"
|
|
9
|
+
*/
|
|
10
|
+
export declare function humanize(snakeName: string): string;
|
|
@@ -26,6 +26,14 @@ export interface PolicyRef {
|
|
|
26
26
|
hasUsing: boolean;
|
|
27
27
|
/** Whether a WITH CHECK clause is present at all (not what it says). */
|
|
28
28
|
hasWithCheck: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* The live clause text, when read from `pg_policies`. Present only for live
|
|
31
|
+
* policies (the expected side is parsed from DDL and does not carry it).
|
|
32
|
+
* Used solely for the insecure-tautology scan, not for divergence — Postgres
|
|
33
|
+
* rewrites this text, so it is not safe to diff against expected.
|
|
34
|
+
*/
|
|
35
|
+
qual?: string | null;
|
|
36
|
+
withCheck?: string | null;
|
|
29
37
|
}
|
|
30
38
|
export interface PolicyDrift {
|
|
31
39
|
/** Described by the collections, absent from the database. */
|
|
@@ -38,6 +46,22 @@ export interface PolicyDrift {
|
|
|
38
46
|
actual: PolicyRef;
|
|
39
47
|
differences: string[];
|
|
40
48
|
}[];
|
|
49
|
+
/**
|
|
50
|
+
* A live policy whose expression is the known-permissive tautology
|
|
51
|
+
* `auth.uid() IS NOT NULL` — true for anonymous visitors too, because the
|
|
52
|
+
* user path coerces a blank id to the `'anonymous'` sentinel. This is what
|
|
53
|
+
* `policy.authenticated()` used to compile to, so a database pushed before
|
|
54
|
+
* that fix carries it, and neither the name, roles, command nor clause
|
|
55
|
+
* *presence* differs from the corrected policy — the only thing that changed
|
|
56
|
+
* is the expression text, which this checker otherwise (correctly) ignores.
|
|
57
|
+
* So it is the one drift that hides from every other check here.
|
|
58
|
+
*
|
|
59
|
+
* @see reason a sentence naming the clause and what to do.
|
|
60
|
+
*/
|
|
61
|
+
insecure: {
|
|
62
|
+
policy: PolicyRef;
|
|
63
|
+
reason: string;
|
|
64
|
+
}[];
|
|
41
65
|
}
|
|
42
66
|
export interface Queryable {
|
|
43
67
|
query<R>(text: string, values?: unknown[]): Promise<{
|
|
@@ -51,7 +51,7 @@ export interface ConnectionPosture {
|
|
|
51
51
|
privileged: boolean;
|
|
52
52
|
}
|
|
53
53
|
export interface AuthContext {
|
|
54
|
-
|
|
54
|
+
uid: string;
|
|
55
55
|
/** Raw roles as carried on the user (strings or `{ id }` objects). */
|
|
56
56
|
roles: unknown[];
|
|
57
57
|
}
|
|
@@ -93,7 +93,7 @@ export declare function ensureAppRole(run: RawSqlRunner, schemas: string[]): Pro
|
|
|
93
93
|
* SECURITY: this function is only ever called on the **user** path (the server
|
|
94
94
|
* context uses the base/owner driver and never calls it). The default policies
|
|
95
95
|
* treat `auth.uid() IS NULL` as the trusted server context, and `auth.uid()`
|
|
96
|
-
* is `NULLIF(current_setting('app.
|
|
96
|
+
* is `NULLIF(current_setting('app.uid'), '')` — so an EMPTY user id would
|
|
97
97
|
* be read as NULL and silently escalate a user request to server privileges.
|
|
98
98
|
* Coerce empty/blank ids to `ANONYMOUS_USER_ID` here, at the single chokepoint,
|
|
99
99
|
* rather than trusting every caller (e.g. realtime subscription auth) to do it.
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ordered, replayable per-channel message history.
|
|
3
|
+
*
|
|
4
|
+
* Broadcast on its own is fire-and-forget to whoever is connected at the
|
|
5
|
+
* instant it is sent: fine for presence and for "someone saved" notifications,
|
|
6
|
+
* not enough for op-based collaborative editing, where a client that blinks
|
|
7
|
+
* out for two seconds has to resync a whole document rather than catch up on
|
|
8
|
+
* the four operations it missed. This adds the missing half — every retained
|
|
9
|
+
* broadcast gets a per-channel sequence number, and a client can ask for
|
|
10
|
+
* everything after the last one it saw.
|
|
11
|
+
*
|
|
12
|
+
* Three decisions worth stating, because each rules out a simpler-looking one:
|
|
13
|
+
*
|
|
14
|
+
* - **Retention is server-side and opt-in.** A channel is created by whoever
|
|
15
|
+
* names it, so a client-supplied history depth would let any visitor commit
|
|
16
|
+
* the backend to unbounded storage. And presence channels — the common case
|
|
17
|
+
* — must not pay for this: with no rules configured nothing is written, no
|
|
18
|
+
* table is created, and `broadcast` runs exactly the code it ran before.
|
|
19
|
+
*
|
|
20
|
+
* - **Sequence numbers come from the database, not from a counter in this
|
|
21
|
+
* process.** They have to survive a restart and be shared across instances;
|
|
22
|
+
* an in-memory counter would restart at 1 after a deploy and hand a
|
|
23
|
+
* reconnecting client a replay from the wrong era, silently.
|
|
24
|
+
*
|
|
25
|
+
* - **The cursor row outlives the messages it numbered.** Pruning is what
|
|
26
|
+
* makes retention affordable, but pruning the cursor along with the messages
|
|
27
|
+
* would restart the sequence and make `sinceSeq` mean something different
|
|
28
|
+
* before and after — the worst kind of bug, because replay would still
|
|
29
|
+
* return rows and they would look plausible. Cursors are tiny and are kept
|
|
30
|
+
* forever; see {@link prune}, which touches only `channel_messages`.
|
|
31
|
+
*/
|
|
32
|
+
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
33
|
+
import type { ChannelHistoryEntry, ChannelRetentionRule } from "@rebasepro/types";
|
|
34
|
+
/**
|
|
35
|
+
* Parse a retention TTL into milliseconds.
|
|
36
|
+
*
|
|
37
|
+
* Accepts a raw millisecond count or a short duration string (`"30s"`, `"15m"`,
|
|
38
|
+
* `"24h"`, `"7d"`). Returns undefined for anything unparseable, which the
|
|
39
|
+
* caller treats as "no TTL" — a misspelt duration must not silently become an
|
|
40
|
+
* aggressive one.
|
|
41
|
+
*/
|
|
42
|
+
export declare function parseTtlMs(ttl: number | string | undefined): number | undefined;
|
|
43
|
+
/**
|
|
44
|
+
* Whether `channel` is covered by `rule`.
|
|
45
|
+
*
|
|
46
|
+
* Exact match, or a trailing `*` acting as a prefix. Not a general glob: this
|
|
47
|
+
* decides what reaches disk, and a pattern language whose reach is not obvious
|
|
48
|
+
* at a glance is the wrong tool for that job.
|
|
49
|
+
*/
|
|
50
|
+
export declare function channelMatchesRule(channel: string, rule: ChannelRetentionRule): boolean;
|
|
51
|
+
/** A rule with its TTL already resolved to milliseconds. */
|
|
52
|
+
export interface ResolvedRetention {
|
|
53
|
+
limit?: number;
|
|
54
|
+
ttlMs?: number;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Persistence and replay for retained channels.
|
|
58
|
+
*
|
|
59
|
+
* Inert unless constructed with at least one rule: {@link enabled} is false,
|
|
60
|
+
* {@link ensureTables} does nothing, and {@link retentionFor} answers undefined
|
|
61
|
+
* for every channel, so the realtime service never reaches the SQL below.
|
|
62
|
+
*/
|
|
63
|
+
export declare class ChannelHistoryStore {
|
|
64
|
+
private db;
|
|
65
|
+
private rules;
|
|
66
|
+
/** Resolved rule per channel name, so the match runs once per channel. */
|
|
67
|
+
private resolved;
|
|
68
|
+
/** Channel → timestamp of its last prune, for {@link PRUNE_THROTTLE_MS}. */
|
|
69
|
+
private lastPruned;
|
|
70
|
+
private tablesReady;
|
|
71
|
+
constructor(db: NodePgDatabase<Record<string, unknown>>, rules?: ChannelRetentionRule[]);
|
|
72
|
+
/** Whether any channel retains anything at all. */
|
|
73
|
+
get enabled(): boolean;
|
|
74
|
+
/**
|
|
75
|
+
* The retention that applies to `channel`, or undefined when none does.
|
|
76
|
+
*
|
|
77
|
+
* First matching rule wins, so callers order them most-specific first.
|
|
78
|
+
*/
|
|
79
|
+
retentionFor(channel: string): ResolvedRetention | undefined;
|
|
80
|
+
/**
|
|
81
|
+
* Create the history tables. Idempotent, and a no-op when no rule is set —
|
|
82
|
+
* a deployment that never retains anything gets no schema for it.
|
|
83
|
+
*/
|
|
84
|
+
ensureTables(): Promise<void>;
|
|
85
|
+
/**
|
|
86
|
+
* Append a broadcast and return the sequence number it was given.
|
|
87
|
+
*
|
|
88
|
+
* The sequence is allocated by the same statement that stores the message,
|
|
89
|
+
* so a crash between the two is not a possibility. `ON CONFLICT DO UPDATE`
|
|
90
|
+
* takes a row lock on the channel's cursor, which is what makes concurrent
|
|
91
|
+
* broadcasts to one channel line up in a single order — and what keeps
|
|
92
|
+
* different channels from contending with each other at all.
|
|
93
|
+
*/
|
|
94
|
+
append(channel: string, event: string, payload: unknown, senderId?: string): Promise<{
|
|
95
|
+
seq: number;
|
|
96
|
+
at: string;
|
|
97
|
+
}>;
|
|
98
|
+
/**
|
|
99
|
+
* Everything retained for `channel` after `sinceSeq`, oldest first.
|
|
100
|
+
*
|
|
101
|
+
* `latestSeq` is reported whether or not the messages were capped, so a
|
|
102
|
+
* client that is further behind than one page can tell.
|
|
103
|
+
*/
|
|
104
|
+
replay(channel: string, sinceSeq?: number, limit?: number): Promise<{
|
|
105
|
+
messages: ChannelHistoryEntry[];
|
|
106
|
+
latestSeq: number;
|
|
107
|
+
}>;
|
|
108
|
+
/**
|
|
109
|
+
* Enforce a channel's retention bounds.
|
|
110
|
+
*
|
|
111
|
+
* Throttled per channel, so a burst of operations prunes once rather than
|
|
112
|
+
* once per message — the cost then tracks elapsed time instead of write
|
|
113
|
+
* volume, which is what makes retention affordable on a hot channel.
|
|
114
|
+
*/
|
|
115
|
+
prune(channel: string, retention: ResolvedRetention): Promise<number>;
|
|
116
|
+
/** Forget throttle and match caches. Called on shutdown. */
|
|
117
|
+
clear(): void;
|
|
118
|
+
}
|
|
@@ -4,12 +4,13 @@ import { DataDriver, WebSocketMessage } from "@rebasepro/types";
|
|
|
4
4
|
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
5
5
|
import { RealtimeProvider, CollectionSubscriptionConfig, SingleSubscriptionConfig } from "../interfaces";
|
|
6
6
|
import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
|
|
7
|
+
import type { ChannelRetentionRule } from "@rebasepro/types";
|
|
7
8
|
/**
|
|
8
9
|
* Auth context stored per-subscription so real-time refetches respect RLS.
|
|
9
10
|
* Mirrors the session variables set by PostgresBackendDriver.withAuth().
|
|
10
11
|
*/
|
|
11
12
|
export interface SubscriptionAuthContext {
|
|
12
|
-
|
|
13
|
+
uid: string;
|
|
13
14
|
roles: string[];
|
|
14
15
|
}
|
|
15
16
|
/**
|
|
@@ -24,6 +25,26 @@ export declare class RealtimeService extends EventEmitter implements RealtimePro
|
|
|
24
25
|
private clients;
|
|
25
26
|
private channels;
|
|
26
27
|
private presence;
|
|
28
|
+
/**
|
|
29
|
+
* Ordered, replayable history for channels that opt into it.
|
|
30
|
+
*
|
|
31
|
+
* Undefined until {@link configureChannelHistory} is called, and inert even
|
|
32
|
+
* then unless retention rules were supplied — so presence and ephemeral
|
|
33
|
+
* notification channels never touch it. See `channel-history.ts`.
|
|
34
|
+
*/
|
|
35
|
+
private channelHistory?;
|
|
36
|
+
/**
|
|
37
|
+
* One promise chain per retained channel, so that assigning a sequence
|
|
38
|
+
* number and fanning the message out happen in the same order for every
|
|
39
|
+
* message on that channel.
|
|
40
|
+
*
|
|
41
|
+
* Without it, two concurrent broadcasts can be numbered 4 and 5 by the
|
|
42
|
+
* database and still reach subscribers as 5 then 4 — live order and replay
|
|
43
|
+
* order would disagree, which is exactly the divergence sequence numbers
|
|
44
|
+
* are supposed to rule out. Keyed by channel, so unrelated channels never
|
|
45
|
+
* wait on each other.
|
|
46
|
+
*/
|
|
47
|
+
private channelSendQueues;
|
|
27
48
|
private presenceInterval?;
|
|
28
49
|
private static readonly PRESENCE_TIMEOUT_MS;
|
|
29
50
|
private dataService;
|
|
@@ -197,8 +218,54 @@ export declare class RealtimeService extends EventEmitter implements RealtimePro
|
|
|
197
218
|
joinChannel(clientId: string, channel: string): void;
|
|
198
219
|
/** Leave a broadcast channel */
|
|
199
220
|
leaveChannel(clientId: string, channel: string): void;
|
|
200
|
-
/**
|
|
221
|
+
/**
|
|
222
|
+
* Broadcast a message to all clients in a channel except the sender.
|
|
223
|
+
*
|
|
224
|
+
* On a channel with no retention rule this is what it always was: a
|
|
225
|
+
* synchronous fan-out to whoever is connected, with no sequence number, no
|
|
226
|
+
* SQL and no await — the body below runs to completion before returning.
|
|
227
|
+
*
|
|
228
|
+
* On a retained channel the message is durably numbered first and only then
|
|
229
|
+
* delivered, through a per-channel queue so that delivery order matches
|
|
230
|
+
* sequence order. That ordering is the whole point: a client that catches up
|
|
231
|
+
* with `sinceSeq` has to arrive at the same state as one that never
|
|
232
|
+
* disconnected.
|
|
233
|
+
*/
|
|
201
234
|
broadcastToChannel(clientId: string, channel: string, event: string, payload: unknown): void;
|
|
235
|
+
/**
|
|
236
|
+
* Number a broadcast, store it, then deliver it.
|
|
237
|
+
*
|
|
238
|
+
* A message that cannot be stored is **not** delivered. Delivering it would
|
|
239
|
+
* put it in front of live subscribers while leaving it absent from every
|
|
240
|
+
* future replay — the two views of the channel would disagree permanently,
|
|
241
|
+
* and no later message could repair the gap. Failing loudly to the sender
|
|
242
|
+
* instead lets it retry, which for an operation stream is the only outcome
|
|
243
|
+
* that keeps clients convergent.
|
|
244
|
+
*/
|
|
245
|
+
private persistAndFanOut;
|
|
246
|
+
/** Deliver a broadcast frame to every member of a channel but the sender. */
|
|
247
|
+
private fanOutBroadcast;
|
|
248
|
+
/**
|
|
249
|
+
* Install retention rules and create the tables they need.
|
|
250
|
+
*
|
|
251
|
+
* Safe to call with no rules (and safe not to call at all): the store stays
|
|
252
|
+
* inert, no schema is created, and broadcast keeps its original
|
|
253
|
+
* fire-and-forget path.
|
|
254
|
+
*/
|
|
255
|
+
configureChannelHistory(rules: ChannelRetentionRule[] | undefined): Promise<void>;
|
|
256
|
+
/** Whether any channel is configured to retain messages. */
|
|
257
|
+
isChannelHistoryEnabled(): boolean;
|
|
258
|
+
/**
|
|
259
|
+
* Answer a client's catch-up request.
|
|
260
|
+
*
|
|
261
|
+
* A channel with no retention rule is answered with `retained: false`
|
|
262
|
+
* rather than an empty list, so the client can tell "you missed nothing"
|
|
263
|
+
* apart from "this channel never keeps anything" — the second means its
|
|
264
|
+
* reconnect strategy has to be a full resync, and silence would leave it
|
|
265
|
+
* guessing.
|
|
266
|
+
*/
|
|
267
|
+
private handleChannelHistoryRequest;
|
|
268
|
+
private sendChannelHistory;
|
|
202
269
|
/** Track presence in a channel */
|
|
203
270
|
trackPresence(clientId: string, channel: string, state: Record<string, unknown>): void;
|
|
204
271
|
/** Remove presence from a channel */
|