@rebasepro/server-postgres 0.9.1-canary.16c42e9 → 0.9.1-canary.26fe4b2
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 +53 -53
- package/dist/index.es.js +730 -194
- 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 +24 -24
- package/dist/schema/doctor.d.ts +1 -1
- 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 +30 -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 +8 -31
- package/src/PostgresBackendDriver.ts +56 -5
- package/src/PostgresBootstrapper.ts +18 -1
- package/src/auth/ensure-tables.ts +97 -17
- package/src/auth/services.ts +134 -133
- package/src/cli.ts +60 -0
- package/src/schema/auth-bootstrap-sql.ts +7 -1
- package/src/schema/auth-schema.ts +13 -13
- package/src/schema/doctor.ts +45 -20
- 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 +106 -1
- package/src/security/policy-drift.ts +56 -0
- 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/services/row-pipeline.ts +27 -3
- package/src/websocket.ts +30 -11
package/dist/index.es.js
CHANGED
|
@@ -353,7 +353,7 @@ function getDeclaredSubcollections(collection) {
|
|
|
353
353
|
/**
|
|
354
354
|
* The id a request without a logged-in user reports as `auth.uid()`.
|
|
355
355
|
*
|
|
356
|
-
* A user-context request always sets `app.
|
|
356
|
+
* A user-context request always sets `app.uid`: blank would read back as
|
|
357
357
|
* `NULL`, and `NULL` is how the trusted server context is recognised, so an
|
|
358
358
|
* anonymous visitor would be promoted to server privileges. The driver
|
|
359
359
|
* therefore substitutes this sentinel at the single chokepoint where the GUC
|
|
@@ -2134,7 +2134,7 @@ function getSubcollections(collection) {
|
|
|
2134
2134
|
* It handles:
|
|
2135
2135
|
* - `field = 'literal'`
|
|
2136
2136
|
* - `field != 'literal'`
|
|
2137
|
-
* - `field = current_setting('app.
|
|
2137
|
+
* - `field = current_setting('app.uid')` (or the legacy `app.user_id`)
|
|
2138
2138
|
* - `A AND B`, `A OR B` — only where the keyword is at the top level
|
|
2139
2139
|
* - `true`
|
|
2140
2140
|
* - `IN (...)` (as optimistic true)
|
|
@@ -2335,7 +2335,7 @@ function findAnonymousGrants(expr) {
|
|
|
2335
2335
|
return found;
|
|
2336
2336
|
}
|
|
2337
2337
|
function parseOperand(str) {
|
|
2338
|
-
if (/current_setting\s*\(\s*'app\.user_id'\s*\)/i.test(str) || /auth\.uid\(\)/i.test(str)) return policy.authUid();
|
|
2338
|
+
if (/current_setting\s*\(\s*'app\.(uid|user_id)'\s*\)/i.test(str) || /auth\.uid\(\)/i.test(str)) return policy.authUid();
|
|
2339
2339
|
const stringMatch = str.match(/^'(.+)'$/);
|
|
2340
2340
|
if (stringMatch) return policy.literal(stringMatch[1]);
|
|
2341
2341
|
if (/^\w+$/.test(str)) return policy.field(str);
|
|
@@ -6524,8 +6524,27 @@ function coerceDeclaredNumbers(row, collection) {
|
|
|
6524
6524
|
return out;
|
|
6525
6525
|
}
|
|
6526
6526
|
/** Render one target row in the requested style. */
|
|
6527
|
+
/**
|
|
6528
|
+
* Drop every column the collection marked `excludeFromApi`.
|
|
6529
|
+
*
|
|
6530
|
+
* Password hashes and verification tokens have to be readable server-side but
|
|
6531
|
+
* must never reach a client — and "never" has to mean every exit from this
|
|
6532
|
+
* pipeline, including relation targets, or a secret leaks through whichever
|
|
6533
|
+
* path was overlooked. Keyed by both the property name and its column name,
|
|
6534
|
+
* since a row can arrive keyed either way depending on the caller.
|
|
6535
|
+
*/
|
|
6536
|
+
function stripExcluded(row, collection) {
|
|
6537
|
+
const properties = collection.properties;
|
|
6538
|
+
if (!properties) return row;
|
|
6539
|
+
for (const [key, property] of Object.entries(properties)) {
|
|
6540
|
+
if (!property?.excludeFromApi) continue;
|
|
6541
|
+
delete row[key];
|
|
6542
|
+
if (property.columnName) delete row[property.columnName];
|
|
6543
|
+
}
|
|
6544
|
+
return row;
|
|
6545
|
+
}
|
|
6527
6546
|
function renderTarget(targetRow, targetCollection, style, registry) {
|
|
6528
|
-
if (style === "inline") return coerceDeclaredNumbers({ ...targetRow }, targetCollection);
|
|
6547
|
+
if (style === "inline") return stripExcluded(coerceDeclaredNumbers({ ...targetRow }, targetCollection), targetCollection);
|
|
6529
6548
|
const address = relationTargetAddress(targetRow, targetCollection, registry);
|
|
6530
6549
|
const path = targetCollection.slug;
|
|
6531
6550
|
return createRelationRefWithData(address, path, {
|
|
@@ -6567,7 +6586,7 @@ function toCmsRow(row, collection, registry) {
|
|
|
6567
6586
|
normalized[key] = relData.map((item) => renderTarget(isJunctionRelation(relation) ? unwrapJunctionRow(item) : item, targetCollection, "ref", registry));
|
|
6568
6587
|
} else if (relation.cardinality === "one" && typeof relData === "object" && !Array.isArray(relData)) normalized[key] = renderTarget(relData, relation.target(), "ref", registry);
|
|
6569
6588
|
}
|
|
6570
|
-
return normalized;
|
|
6589
|
+
return stripExcluded(normalized, collection);
|
|
6571
6590
|
}
|
|
6572
6591
|
/**
|
|
6573
6592
|
* The row REST serves: every column under its own name, with the value Postgres
|
|
@@ -6592,7 +6611,7 @@ function toRestRow(row, collection, registry) {
|
|
|
6592
6611
|
else if (relation && typeof value === "object" && value !== null) flat[key] = renderTarget(value, relation.target(), "inline", registry);
|
|
6593
6612
|
else flat[key] = coerceDeclaredNumber(value, collection.properties?.[key]);
|
|
6594
6613
|
}
|
|
6595
|
-
return flat;
|
|
6614
|
+
return stripExcluded(flat, collection);
|
|
6596
6615
|
}
|
|
6597
6616
|
//#endregion
|
|
6598
6617
|
//#region src/services/FetchService.ts
|
|
@@ -8163,7 +8182,7 @@ async function ensureAppRole(run, schemas) {
|
|
|
8163
8182
|
* SECURITY: this function is only ever called on the **user** path (the server
|
|
8164
8183
|
* context uses the base/owner driver and never calls it). The default policies
|
|
8165
8184
|
* treat `auth.uid() IS NULL` as the trusted server context, and `auth.uid()`
|
|
8166
|
-
* is `NULLIF(current_setting('app.
|
|
8185
|
+
* is `NULLIF(current_setting('app.uid'), '')` — so an EMPTY user id would
|
|
8167
8186
|
* be read as NULL and silently escalate a user request to server privileges.
|
|
8168
8187
|
* Coerce empty/blank ids to `ANONYMOUS_USER_ID` here, at the single chokepoint,
|
|
8169
8188
|
* rather than trusting every caller (e.g. realtime subscription auth) to do it.
|
|
@@ -8171,14 +8190,15 @@ async function ensureAppRole(run, schemas) {
|
|
|
8171
8190
|
* semantics: it is why `auth.uid() IS NOT NULL` is true for anonymous requests.
|
|
8172
8191
|
*/
|
|
8173
8192
|
async function applyAuthContext(tx, auth, userRole) {
|
|
8174
|
-
const
|
|
8193
|
+
const uid = typeof auth.uid === "string" && auth.uid.trim() !== "" ? auth.uid : ANONYMOUS_USER_ID;
|
|
8175
8194
|
const normalizedRoles = auth.roles.map((r) => typeof r === "string" ? r : r?.id ?? String(r));
|
|
8176
8195
|
await tx.execute(sql`
|
|
8177
8196
|
SELECT
|
|
8178
|
-
set_config('app.
|
|
8197
|
+
set_config('app.uid', ${uid}, true),
|
|
8198
|
+
set_config('app.user_id', ${uid}, true),
|
|
8179
8199
|
set_config('app.user_roles', ${normalizedRoles.join(",")}, true),
|
|
8180
8200
|
set_config('app.jwt', ${JSON.stringify({
|
|
8181
|
-
sub:
|
|
8201
|
+
sub: uid,
|
|
8182
8202
|
roles: auth.roles
|
|
8183
8203
|
})}, true)
|
|
8184
8204
|
`);
|
|
@@ -8324,6 +8344,7 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
8324
8344
|
executeSql: (...args) => this.executeSql(...args),
|
|
8325
8345
|
fetchAvailableDatabases: () => this.fetchAvailableDatabases(),
|
|
8326
8346
|
fetchAvailableRoles: () => this.fetchAvailableRoles(),
|
|
8347
|
+
fetchApplicationRoles: () => this.fetchApplicationRoles(),
|
|
8327
8348
|
fetchCurrentDatabase: () => this.fetchCurrentDatabase(),
|
|
8328
8349
|
fetchUnmappedTables: (...args) => this.fetchUnmappedTables(...args),
|
|
8329
8350
|
fetchTableMetadata: (...args) => this.fetchTableMetadata(...args),
|
|
@@ -8977,6 +8998,45 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
8977
8998
|
async fetchAvailableRoles() {
|
|
8978
8999
|
return (await this.executeSql("SELECT rolname FROM pg_roles WHERE pg_has_role(current_user, rolname, 'member') ORDER BY rolname;")).map((r) => r.rolname);
|
|
8979
9000
|
}
|
|
9001
|
+
/**
|
|
9002
|
+
* Application-level roles actually in use in this project.
|
|
9003
|
+
*
|
|
9004
|
+
* Distinct from {@link fetchAvailableRoles}, which returns native
|
|
9005
|
+
* PostgreSQL roles from `pg_roles` (`postgres`, `rebase_user`, …). Those
|
|
9006
|
+
* are the roles the SQL editor can `SET ROLE` to. *These* are the strings
|
|
9007
|
+
* held in the users table's `roles` column, injected per-transaction as
|
|
9008
|
+
* `auth.roles()` and matched by `SecurityRule.roles`. Feeding the pg roles
|
|
9009
|
+
* into a `SecurityRule.roles` field produces a condition no user can ever
|
|
9010
|
+
* satisfy, so the two must not be conflated.
|
|
9011
|
+
*
|
|
9012
|
+
* Roles have no registry table — they were migrated out of
|
|
9013
|
+
* `rebase.user_roles` onto an inline `roles TEXT[]` column — so the live
|
|
9014
|
+
* set is derived from what is assigned. A role that is declared in a policy
|
|
9015
|
+
* but held by nobody yet cannot be discovered here; callers that need it
|
|
9016
|
+
* should union in the roles they already know about.
|
|
9017
|
+
*/
|
|
9018
|
+
async fetchApplicationRoles() {
|
|
9019
|
+
const located = await this.executeSql(`
|
|
9020
|
+
SELECT table_schema, table_name
|
|
9021
|
+
FROM information_schema.columns
|
|
9022
|
+
WHERE column_name = 'roles'
|
|
9023
|
+
AND data_type = 'ARRAY'
|
|
9024
|
+
AND table_name = 'users'
|
|
9025
|
+
AND table_schema NOT IN ('information_schema', 'pg_catalog')
|
|
9026
|
+
ORDER BY (table_schema = 'rebase') DESC, table_schema
|
|
9027
|
+
LIMIT 1;
|
|
9028
|
+
`);
|
|
9029
|
+
if (located.length === 0) return [];
|
|
9030
|
+
const schema = located[0].table_schema;
|
|
9031
|
+
const table = located[0].table_name;
|
|
9032
|
+
const qualified = `"${schema.replace(/"/g, "\"\"")}"."${table.replace(/"/g, "\"\"")}"`;
|
|
9033
|
+
return (await this.executeSql(`
|
|
9034
|
+
SELECT DISTINCT unnest(roles) AS role
|
|
9035
|
+
FROM ${qualified}
|
|
9036
|
+
WHERE roles IS NOT NULL
|
|
9037
|
+
ORDER BY role;
|
|
9038
|
+
`)).map((r) => r.role).filter((r) => typeof r === "string" && r.length > 0);
|
|
9039
|
+
}
|
|
8980
9040
|
async fetchCurrentDatabase() {
|
|
8981
9041
|
return this.poolManager?.defaultDatabaseName;
|
|
8982
9042
|
}
|
|
@@ -9118,15 +9178,15 @@ var AuthenticatedPostgresBackendDriver = class {
|
|
|
9118
9178
|
async withTransaction(operation, options) {
|
|
9119
9179
|
const pendingNotifications = [];
|
|
9120
9180
|
const result = await this.delegate.db.transaction(async (tx) => {
|
|
9121
|
-
let
|
|
9122
|
-
if (!
|
|
9181
|
+
let uid = this.user?.uid;
|
|
9182
|
+
if (!uid) {
|
|
9123
9183
|
logger.warn("[DataDriver] User ID (uid) is missing for authenticated delegate. Using 'anonymous'. User object", { detail: this.user });
|
|
9124
|
-
|
|
9184
|
+
uid = "anonymous";
|
|
9125
9185
|
}
|
|
9126
9186
|
const userRoles = this.user?.roles ?? [];
|
|
9127
9187
|
if (!this.user?.roles) logger.warn("[DataDriver] User roles are missing for authenticated delegate. Using empty array. User object", { detail: this.user });
|
|
9128
9188
|
await applyAuthContext(tx, {
|
|
9129
|
-
|
|
9189
|
+
uid,
|
|
9130
9190
|
roles: userRoles
|
|
9131
9191
|
}, this.delegate.rlsUserRole);
|
|
9132
9192
|
const txEntityService = new DataService(tx, this.delegate.registry);
|
|
@@ -9153,7 +9213,7 @@ var AuthenticatedPostgresBackendDriver = class {
|
|
|
9153
9213
|
*/
|
|
9154
9214
|
injectAuthContext(unsubscribe) {
|
|
9155
9215
|
const authContext = {
|
|
9156
|
-
|
|
9216
|
+
uid: this.user?.uid || "anonymous",
|
|
9157
9217
|
roles: this.user?.roles ?? []
|
|
9158
9218
|
};
|
|
9159
9219
|
const entries = Array.from(this.delegate.realtimeService.subscriptions.entries());
|
|
@@ -9297,19 +9357,19 @@ function createAuthSchema(usersSchemaName = "rebase") {
|
|
|
9297
9357
|
*/
|
|
9298
9358
|
const refreshTokens = tableCreator("refresh_tokens", {
|
|
9299
9359
|
id: uuid("id").defaultRandom().primaryKey(),
|
|
9300
|
-
|
|
9360
|
+
uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
9301
9361
|
tokenHash: varchar("token_hash", { length: 255 }).notNull().unique(),
|
|
9302
9362
|
expiresAt: timestamp("expires_at").notNull(),
|
|
9303
9363
|
userAgent: varchar("user_agent", { length: 500 }),
|
|
9304
9364
|
ipAddress: varchar("ip_address", { length: 45 }),
|
|
9305
9365
|
createdAt: timestamp("created_at").defaultNow().notNull()
|
|
9306
|
-
}, (table) => ({ uniqueDeviceSession: unique("unique_device_session").on(table.
|
|
9366
|
+
}, (table) => ({ uniqueDeviceSession: unique("unique_device_session").on(table.uid, table.userAgent, table.ipAddress) }));
|
|
9307
9367
|
/**
|
|
9308
9368
|
* Password reset tokens for forgot password flow
|
|
9309
9369
|
*/
|
|
9310
9370
|
const passwordResetTokens = tableCreator("password_reset_tokens", {
|
|
9311
9371
|
id: uuid("id").defaultRandom().primaryKey(),
|
|
9312
|
-
|
|
9372
|
+
uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
9313
9373
|
tokenHash: varchar("token_hash", { length: 255 }).notNull().unique(),
|
|
9314
9374
|
expiresAt: timestamp("expires_at").notNull(),
|
|
9315
9375
|
usedAt: timestamp("used_at"),
|
|
@@ -9328,7 +9388,7 @@ function createAuthSchema(usersSchemaName = "rebase") {
|
|
|
9328
9388
|
*/
|
|
9329
9389
|
const userIdentities = tableCreator("user_identities", {
|
|
9330
9390
|
id: uuid("id").defaultRandom().primaryKey(),
|
|
9331
|
-
|
|
9391
|
+
uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
9332
9392
|
provider: varchar("provider", { length: 50 }).notNull(),
|
|
9333
9393
|
providerId: varchar("provider_id", { length: 255 }).notNull(),
|
|
9334
9394
|
profileData: jsonb("profile_data"),
|
|
@@ -9340,7 +9400,7 @@ function createAuthSchema(usersSchemaName = "rebase") {
|
|
|
9340
9400
|
*/
|
|
9341
9401
|
const mfaFactors = tableCreator("mfa_factors", {
|
|
9342
9402
|
id: uuid("id").defaultRandom().primaryKey(),
|
|
9343
|
-
|
|
9403
|
+
uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
9344
9404
|
factorType: varchar("factor_type", { length: 20 }).notNull(),
|
|
9345
9405
|
secretEncrypted: varchar("secret_encrypted", { length: 500 }).notNull(),
|
|
9346
9406
|
friendlyName: varchar("friendly_name", { length: 255 }),
|
|
@@ -9366,14 +9426,14 @@ function createAuthSchema(usersSchemaName = "rebase") {
|
|
|
9366
9426
|
}),
|
|
9367
9427
|
recoveryCodes: tableCreator("recovery_codes", {
|
|
9368
9428
|
id: uuid("id").defaultRandom().primaryKey(),
|
|
9369
|
-
|
|
9429
|
+
uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
9370
9430
|
codeHash: varchar("code_hash", { length: 255 }).notNull(),
|
|
9371
9431
|
usedAt: timestamp("used_at"),
|
|
9372
9432
|
createdAt: timestamp("created_at").defaultNow().notNull()
|
|
9373
9433
|
}),
|
|
9374
9434
|
magicLinkTokens: tableCreator("magic_link_tokens", {
|
|
9375
9435
|
id: uuid("id").defaultRandom().primaryKey(),
|
|
9376
|
-
|
|
9436
|
+
uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
9377
9437
|
tokenHash: varchar("token_hash", { length: 255 }).notNull().unique(),
|
|
9378
9438
|
expiresAt: timestamp("expires_at").notNull(),
|
|
9379
9439
|
usedAt: timestamp("used_at"),
|
|
@@ -9401,20 +9461,20 @@ var usersRelations = relations(users, ({ many }) => ({
|
|
|
9401
9461
|
magicLinkTokens: many(magicLinkTokens)
|
|
9402
9462
|
}));
|
|
9403
9463
|
var refreshTokensRelations = relations(refreshTokens, ({ one }) => ({ user: one(users, {
|
|
9404
|
-
fields: [refreshTokens.
|
|
9464
|
+
fields: [refreshTokens.uid],
|
|
9405
9465
|
references: [users.id]
|
|
9406
9466
|
}) }));
|
|
9407
9467
|
var passwordResetTokensRelations = relations(passwordResetTokens, ({ one }) => ({ user: one(users, {
|
|
9408
|
-
fields: [passwordResetTokens.
|
|
9468
|
+
fields: [passwordResetTokens.uid],
|
|
9409
9469
|
references: [users.id]
|
|
9410
9470
|
}) }));
|
|
9411
9471
|
var userIdentitiesRelations = relations(userIdentities, ({ one }) => ({ user: one(users, {
|
|
9412
|
-
fields: [userIdentities.
|
|
9472
|
+
fields: [userIdentities.uid],
|
|
9413
9473
|
references: [users.id]
|
|
9414
9474
|
}) }));
|
|
9415
9475
|
var mfaFactorsRelations = relations(mfaFactors, ({ one, many }) => ({
|
|
9416
9476
|
user: one(users, {
|
|
9417
|
-
fields: [mfaFactors.
|
|
9477
|
+
fields: [mfaFactors.uid],
|
|
9418
9478
|
references: [users.id]
|
|
9419
9479
|
}),
|
|
9420
9480
|
challenges: many(mfaChallenges)
|
|
@@ -9424,11 +9484,11 @@ var mfaChallengesRelations = relations(mfaChallenges, ({ one }) => ({ factor: on
|
|
|
9424
9484
|
references: [mfaFactors.id]
|
|
9425
9485
|
}) }));
|
|
9426
9486
|
var recoveryCodesRelations = relations(recoveryCodes, ({ one }) => ({ user: one(users, {
|
|
9427
|
-
fields: [recoveryCodes.
|
|
9487
|
+
fields: [recoveryCodes.uid],
|
|
9428
9488
|
references: [users.id]
|
|
9429
9489
|
}) }));
|
|
9430
9490
|
var magicLinkTokensRelations = relations(magicLinkTokens, ({ one }) => ({ user: one(users, {
|
|
9431
|
-
fields: [magicLinkTokens.
|
|
9491
|
+
fields: [magicLinkTokens.uid],
|
|
9432
9492
|
references: [users.id]
|
|
9433
9493
|
}) }));
|
|
9434
9494
|
//#endregion
|
|
@@ -10261,6 +10321,267 @@ var CdcListener = class CdcListener {
|
|
|
10261
10321
|
}
|
|
10262
10322
|
};
|
|
10263
10323
|
//#endregion
|
|
10324
|
+
//#region src/services/channel-history.ts
|
|
10325
|
+
/**
|
|
10326
|
+
* Ordered, replayable per-channel message history.
|
|
10327
|
+
*
|
|
10328
|
+
* Broadcast on its own is fire-and-forget to whoever is connected at the
|
|
10329
|
+
* instant it is sent: fine for presence and for "someone saved" notifications,
|
|
10330
|
+
* not enough for op-based collaborative editing, where a client that blinks
|
|
10331
|
+
* out for two seconds has to resync a whole document rather than catch up on
|
|
10332
|
+
* the four operations it missed. This adds the missing half — every retained
|
|
10333
|
+
* broadcast gets a per-channel sequence number, and a client can ask for
|
|
10334
|
+
* everything after the last one it saw.
|
|
10335
|
+
*
|
|
10336
|
+
* Three decisions worth stating, because each rules out a simpler-looking one:
|
|
10337
|
+
*
|
|
10338
|
+
* - **Retention is server-side and opt-in.** A channel is created by whoever
|
|
10339
|
+
* names it, so a client-supplied history depth would let any visitor commit
|
|
10340
|
+
* the backend to unbounded storage. And presence channels — the common case
|
|
10341
|
+
* — must not pay for this: with no rules configured nothing is written, no
|
|
10342
|
+
* table is created, and `broadcast` runs exactly the code it ran before.
|
|
10343
|
+
*
|
|
10344
|
+
* - **Sequence numbers come from the database, not from a counter in this
|
|
10345
|
+
* process.** They have to survive a restart and be shared across instances;
|
|
10346
|
+
* an in-memory counter would restart at 1 after a deploy and hand a
|
|
10347
|
+
* reconnecting client a replay from the wrong era, silently.
|
|
10348
|
+
*
|
|
10349
|
+
* - **The cursor row outlives the messages it numbered.** Pruning is what
|
|
10350
|
+
* makes retention affordable, but pruning the cursor along with the messages
|
|
10351
|
+
* would restart the sequence and make `sinceSeq` mean something different
|
|
10352
|
+
* before and after — the worst kind of bug, because replay would still
|
|
10353
|
+
* return rows and they would look plausible. Cursors are tiny and are kept
|
|
10354
|
+
* forever; see {@link prune}, which touches only `channel_messages`.
|
|
10355
|
+
*/
|
|
10356
|
+
/** How many messages a replay returns when the caller does not say. */
|
|
10357
|
+
var DEFAULT_REPLAY_LIMIT = 200;
|
|
10358
|
+
/**
|
|
10359
|
+
* Hard ceiling on one replay, whatever the caller asks for.
|
|
10360
|
+
*
|
|
10361
|
+
* A reconnecting client names its own `limit`, so this is the only thing
|
|
10362
|
+
* standing between a stale `sinceSeq` and a single frame carrying a channel's
|
|
10363
|
+
* entire retained history. A client that is further behind than this is told so
|
|
10364
|
+
* via `latestSeq` and can decide to resync wholesale instead of paging.
|
|
10365
|
+
*/
|
|
10366
|
+
var MAX_REPLAY_LIMIT = 1e3;
|
|
10367
|
+
/** Minimum gap between two prunes of the same channel. */
|
|
10368
|
+
var PRUNE_THROTTLE_MS = 3e4;
|
|
10369
|
+
/**
|
|
10370
|
+
* Parse a retention TTL into milliseconds.
|
|
10371
|
+
*
|
|
10372
|
+
* Accepts a raw millisecond count or a short duration string (`"30s"`, `"15m"`,
|
|
10373
|
+
* `"24h"`, `"7d"`). Returns undefined for anything unparseable, which the
|
|
10374
|
+
* caller treats as "no TTL" — a misspelt duration must not silently become an
|
|
10375
|
+
* aggressive one.
|
|
10376
|
+
*/
|
|
10377
|
+
function parseTtlMs(ttl) {
|
|
10378
|
+
if (ttl === void 0 || ttl === null) return void 0;
|
|
10379
|
+
if (typeof ttl === "number") return Number.isFinite(ttl) && ttl > 0 ? ttl : void 0;
|
|
10380
|
+
const match = /^\s*(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)\s*$/i.exec(ttl);
|
|
10381
|
+
if (!match) {
|
|
10382
|
+
logger.warn(`⚠️ [ChannelHistory] Ignoring unparseable retention ttl "${ttl}" — expected e.g. "30s", "15m", "24h", "7d".`);
|
|
10383
|
+
return;
|
|
10384
|
+
}
|
|
10385
|
+
const value = parseFloat(match[1]);
|
|
10386
|
+
const unit = match[2].toLowerCase();
|
|
10387
|
+
const ms = value * (unit === "ms" ? 1 : unit === "s" ? 1e3 : unit === "m" ? 6e4 : unit === "h" ? 36e5 : 864e5);
|
|
10388
|
+
return ms > 0 ? ms : void 0;
|
|
10389
|
+
}
|
|
10390
|
+
/**
|
|
10391
|
+
* Whether `channel` is covered by `rule`.
|
|
10392
|
+
*
|
|
10393
|
+
* Exact match, or a trailing `*` acting as a prefix. Not a general glob: this
|
|
10394
|
+
* decides what reaches disk, and a pattern language whose reach is not obvious
|
|
10395
|
+
* at a glance is the wrong tool for that job.
|
|
10396
|
+
*/
|
|
10397
|
+
function channelMatchesRule(channel, rule) {
|
|
10398
|
+
const pattern = rule.match;
|
|
10399
|
+
if (pattern === "*") return true;
|
|
10400
|
+
if (pattern.endsWith("*")) return channel.startsWith(pattern.slice(0, -1));
|
|
10401
|
+
return channel === pattern;
|
|
10402
|
+
}
|
|
10403
|
+
/**
|
|
10404
|
+
* Persistence and replay for retained channels.
|
|
10405
|
+
*
|
|
10406
|
+
* Inert unless constructed with at least one rule: {@link enabled} is false,
|
|
10407
|
+
* {@link ensureTables} does nothing, and {@link retentionFor} answers undefined
|
|
10408
|
+
* for every channel, so the realtime service never reaches the SQL below.
|
|
10409
|
+
*/
|
|
10410
|
+
var ChannelHistoryStore = class {
|
|
10411
|
+
db;
|
|
10412
|
+
rules;
|
|
10413
|
+
/** Resolved rule per channel name, so the match runs once per channel. */
|
|
10414
|
+
resolved = /* @__PURE__ */ new Map();
|
|
10415
|
+
/** Channel → timestamp of its last prune, for {@link PRUNE_THROTTLE_MS}. */
|
|
10416
|
+
lastPruned = /* @__PURE__ */ new Map();
|
|
10417
|
+
tablesReady = false;
|
|
10418
|
+
constructor(db, rules = []) {
|
|
10419
|
+
this.db = db;
|
|
10420
|
+
this.rules = rules.filter((rule) => {
|
|
10421
|
+
if (!rule?.match) {
|
|
10422
|
+
logger.warn("⚠️ [ChannelHistory] Ignoring a retention rule with no `match`.");
|
|
10423
|
+
return false;
|
|
10424
|
+
}
|
|
10425
|
+
if (!(rule.limit !== void 0 || rule.ttl !== void 0)) {
|
|
10426
|
+
logger.warn(`⚠️ [ChannelHistory] Retention rule "${rule.match}" sets neither \`limit\` nor \`ttl\` — ignoring it, as it would retain forever.`);
|
|
10427
|
+
return false;
|
|
10428
|
+
}
|
|
10429
|
+
return true;
|
|
10430
|
+
});
|
|
10431
|
+
}
|
|
10432
|
+
/** Whether any channel retains anything at all. */
|
|
10433
|
+
get enabled() {
|
|
10434
|
+
return this.rules.length > 0;
|
|
10435
|
+
}
|
|
10436
|
+
/**
|
|
10437
|
+
* The retention that applies to `channel`, or undefined when none does.
|
|
10438
|
+
*
|
|
10439
|
+
* First matching rule wins, so callers order them most-specific first.
|
|
10440
|
+
*/
|
|
10441
|
+
retentionFor(channel) {
|
|
10442
|
+
if (!this.enabled || !channel) return void 0;
|
|
10443
|
+
const cached = this.resolved.get(channel);
|
|
10444
|
+
if (cached !== void 0) return cached ?? void 0;
|
|
10445
|
+
const rule = this.rules.find((r) => channelMatchesRule(channel, r));
|
|
10446
|
+
const resolved = rule ? {
|
|
10447
|
+
limit: rule.limit,
|
|
10448
|
+
ttlMs: parseTtlMs(rule.ttl)
|
|
10449
|
+
} : null;
|
|
10450
|
+
this.resolved.set(channel, resolved);
|
|
10451
|
+
return resolved ?? void 0;
|
|
10452
|
+
}
|
|
10453
|
+
/**
|
|
10454
|
+
* Create the history tables. Idempotent, and a no-op when no rule is set —
|
|
10455
|
+
* a deployment that never retains anything gets no schema for it.
|
|
10456
|
+
*/
|
|
10457
|
+
async ensureTables() {
|
|
10458
|
+
if (!this.enabled || this.tablesReady) return;
|
|
10459
|
+
await this.db.execute(sql`CREATE SCHEMA IF NOT EXISTS rebase`);
|
|
10460
|
+
await this.db.execute(sql`
|
|
10461
|
+
CREATE TABLE IF NOT EXISTS rebase.channel_messages (
|
|
10462
|
+
channel TEXT NOT NULL,
|
|
10463
|
+
seq BIGINT NOT NULL,
|
|
10464
|
+
event TEXT NOT NULL,
|
|
10465
|
+
payload JSONB,
|
|
10466
|
+
sender_id TEXT,
|
|
10467
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
10468
|
+
PRIMARY KEY (channel, seq)
|
|
10469
|
+
)
|
|
10470
|
+
`);
|
|
10471
|
+
await this.db.execute(sql`
|
|
10472
|
+
CREATE INDEX IF NOT EXISTS idx_channel_messages_created
|
|
10473
|
+
ON rebase.channel_messages (created_at)
|
|
10474
|
+
`);
|
|
10475
|
+
await this.db.execute(sql`
|
|
10476
|
+
CREATE TABLE IF NOT EXISTS rebase.channel_cursors (
|
|
10477
|
+
channel TEXT PRIMARY KEY,
|
|
10478
|
+
last_seq BIGINT NOT NULL
|
|
10479
|
+
)
|
|
10480
|
+
`);
|
|
10481
|
+
this.tablesReady = true;
|
|
10482
|
+
logger.info(`✅ [ChannelHistory] Retained channels ready (${this.rules.length} rule(s)).`);
|
|
10483
|
+
}
|
|
10484
|
+
/**
|
|
10485
|
+
* Append a broadcast and return the sequence number it was given.
|
|
10486
|
+
*
|
|
10487
|
+
* The sequence is allocated by the same statement that stores the message,
|
|
10488
|
+
* so a crash between the two is not a possibility. `ON CONFLICT DO UPDATE`
|
|
10489
|
+
* takes a row lock on the channel's cursor, which is what makes concurrent
|
|
10490
|
+
* broadcasts to one channel line up in a single order — and what keeps
|
|
10491
|
+
* different channels from contending with each other at all.
|
|
10492
|
+
*/
|
|
10493
|
+
async append(channel, event, payload, senderId) {
|
|
10494
|
+
const row = (await this.db.execute(sql`
|
|
10495
|
+
WITH next AS (
|
|
10496
|
+
INSERT INTO rebase.channel_cursors (channel, last_seq)
|
|
10497
|
+
VALUES (${channel}, 1)
|
|
10498
|
+
ON CONFLICT (channel)
|
|
10499
|
+
DO UPDATE SET last_seq = rebase.channel_cursors.last_seq + 1
|
|
10500
|
+
RETURNING last_seq
|
|
10501
|
+
)
|
|
10502
|
+
INSERT INTO rebase.channel_messages (channel, seq, event, payload, sender_id)
|
|
10503
|
+
SELECT ${channel}, next.last_seq, ${event}, ${JSON.stringify(payload ?? null)}::jsonb, ${senderId ?? null}
|
|
10504
|
+
FROM next
|
|
10505
|
+
RETURNING seq, created_at
|
|
10506
|
+
`)).rows[0];
|
|
10507
|
+
if (!row) throw new Error(`Failed to append to channel history for "${channel}"`);
|
|
10508
|
+
return {
|
|
10509
|
+
seq: Number(row.seq),
|
|
10510
|
+
at: row.created_at instanceof Date ? row.created_at.toISOString() : String(row.created_at)
|
|
10511
|
+
};
|
|
10512
|
+
}
|
|
10513
|
+
/**
|
|
10514
|
+
* Everything retained for `channel` after `sinceSeq`, oldest first.
|
|
10515
|
+
*
|
|
10516
|
+
* `latestSeq` is reported whether or not the messages were capped, so a
|
|
10517
|
+
* client that is further behind than one page can tell.
|
|
10518
|
+
*/
|
|
10519
|
+
async replay(channel, sinceSeq = 0, limit = DEFAULT_REPLAY_LIMIT) {
|
|
10520
|
+
const capped = Math.max(1, Math.min(Math.floor(limit) || DEFAULT_REPLAY_LIMIT, MAX_REPLAY_LIMIT));
|
|
10521
|
+
const after = Number.isFinite(sinceSeq) && sinceSeq > 0 ? Math.floor(sinceSeq) : 0;
|
|
10522
|
+
const messages = (await this.db.execute(sql`
|
|
10523
|
+
SELECT seq, event, payload, sender_id, created_at
|
|
10524
|
+
FROM rebase.channel_messages
|
|
10525
|
+
WHERE channel = ${channel} AND seq > ${after}
|
|
10526
|
+
ORDER BY seq ASC
|
|
10527
|
+
LIMIT ${capped}
|
|
10528
|
+
`)).rows.map((row) => ({
|
|
10529
|
+
seq: Number(row.seq),
|
|
10530
|
+
event: row.event,
|
|
10531
|
+
payload: row.payload,
|
|
10532
|
+
senderId: row.sender_id ?? void 0,
|
|
10533
|
+
at: row.created_at instanceof Date ? row.created_at.toISOString() : String(row.created_at)
|
|
10534
|
+
}));
|
|
10535
|
+
const cursorRow = (await this.db.execute(sql`
|
|
10536
|
+
SELECT last_seq FROM rebase.channel_cursors WHERE channel = ${channel}
|
|
10537
|
+
`)).rows[0];
|
|
10538
|
+
return {
|
|
10539
|
+
messages,
|
|
10540
|
+
latestSeq: cursorRow ? Number(cursorRow.last_seq) : 0
|
|
10541
|
+
};
|
|
10542
|
+
}
|
|
10543
|
+
/**
|
|
10544
|
+
* Enforce a channel's retention bounds.
|
|
10545
|
+
*
|
|
10546
|
+
* Throttled per channel, so a burst of operations prunes once rather than
|
|
10547
|
+
* once per message — the cost then tracks elapsed time instead of write
|
|
10548
|
+
* volume, which is what makes retention affordable on a hot channel.
|
|
10549
|
+
*/
|
|
10550
|
+
async prune(channel, retention) {
|
|
10551
|
+
const now = Date.now();
|
|
10552
|
+
if (now - (this.lastPruned.get(channel) ?? 0) < PRUNE_THROTTLE_MS) return 0;
|
|
10553
|
+
this.lastPruned.set(channel, now);
|
|
10554
|
+
let deleted = 0;
|
|
10555
|
+
if (retention.ttlMs !== void 0) {
|
|
10556
|
+
const result = await this.db.execute(sql`
|
|
10557
|
+
DELETE FROM rebase.channel_messages
|
|
10558
|
+
WHERE channel = ${channel}
|
|
10559
|
+
AND created_at < NOW() - MAKE_INTERVAL(secs => ${retention.ttlMs / 1e3})
|
|
10560
|
+
`);
|
|
10561
|
+
deleted += result.rowCount ?? 0;
|
|
10562
|
+
}
|
|
10563
|
+
if (retention.limit !== void 0 && retention.limit > 0) {
|
|
10564
|
+
const result = await this.db.execute(sql`
|
|
10565
|
+
DELETE FROM rebase.channel_messages
|
|
10566
|
+
WHERE channel = ${channel}
|
|
10567
|
+
AND seq <= (
|
|
10568
|
+
SELECT seq FROM rebase.channel_messages
|
|
10569
|
+
WHERE channel = ${channel}
|
|
10570
|
+
ORDER BY seq DESC
|
|
10571
|
+
OFFSET ${Math.floor(retention.limit)} LIMIT 1
|
|
10572
|
+
)
|
|
10573
|
+
`);
|
|
10574
|
+
deleted += result.rowCount ?? 0;
|
|
10575
|
+
}
|
|
10576
|
+
return deleted;
|
|
10577
|
+
}
|
|
10578
|
+
/** Forget throttle and match caches. Called on shutdown. */
|
|
10579
|
+
clear() {
|
|
10580
|
+
this.resolved.clear();
|
|
10581
|
+
this.lastPruned.clear();
|
|
10582
|
+
}
|
|
10583
|
+
};
|
|
10584
|
+
//#endregion
|
|
10264
10585
|
//#region src/services/realtimeService.ts
|
|
10265
10586
|
/** Channel name used for Postgres LISTEN/NOTIFY cross-instance realtime. */
|
|
10266
10587
|
var PG_NOTIFY_CHANNEL = "rebase_entity_changes";
|
|
@@ -10276,6 +10597,26 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
10276
10597
|
clients = /* @__PURE__ */ new Map();
|
|
10277
10598
|
channels = /* @__PURE__ */ new Map();
|
|
10278
10599
|
presence = /* @__PURE__ */ new Map();
|
|
10600
|
+
/**
|
|
10601
|
+
* Ordered, replayable history for channels that opt into it.
|
|
10602
|
+
*
|
|
10603
|
+
* Undefined until {@link configureChannelHistory} is called, and inert even
|
|
10604
|
+
* then unless retention rules were supplied — so presence and ephemeral
|
|
10605
|
+
* notification channels never touch it. See `channel-history.ts`.
|
|
10606
|
+
*/
|
|
10607
|
+
channelHistory;
|
|
10608
|
+
/**
|
|
10609
|
+
* One promise chain per retained channel, so that assigning a sequence
|
|
10610
|
+
* number and fanning the message out happen in the same order for every
|
|
10611
|
+
* message on that channel.
|
|
10612
|
+
*
|
|
10613
|
+
* Without it, two concurrent broadcasts can be numbered 4 and 5 by the
|
|
10614
|
+
* database and still reach subscribers as 5 then 4 — live order and replay
|
|
10615
|
+
* order would disagree, which is exactly the divergence sequence numbers
|
|
10616
|
+
* are supposed to rule out. Keyed by channel, so unrelated channels never
|
|
10617
|
+
* wait on each other.
|
|
10618
|
+
*/
|
|
10619
|
+
channelSendQueues = /* @__PURE__ */ new Map();
|
|
10279
10620
|
presenceInterval;
|
|
10280
10621
|
static PRESENCE_TIMEOUT_MS = 3e4;
|
|
10281
10622
|
dataService;
|
|
@@ -10452,6 +10793,9 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
10452
10793
|
case "broadcast":
|
|
10453
10794
|
this.broadcastToChannel(clientId, payload?.channel, payload?.event, payload?.payload);
|
|
10454
10795
|
break;
|
|
10796
|
+
case "channel_history":
|
|
10797
|
+
await this.handleChannelHistoryRequest(clientId, payload?.channel, payload?.sinceSeq, payload?.limit);
|
|
10798
|
+
break;
|
|
10455
10799
|
case "presence_track":
|
|
10456
10800
|
this.joinChannel(clientId, payload?.channel);
|
|
10457
10801
|
this.trackPresence(clientId, payload?.channel, payload?.state ?? {});
|
|
@@ -10662,12 +11006,12 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
10662
11006
|
if (this.driver) {
|
|
10663
11007
|
const collection = this.registry.getCollectionByPath(notifyPath);
|
|
10664
11008
|
const activeAuth = authContext || {
|
|
10665
|
-
|
|
11009
|
+
uid: "anon",
|
|
10666
11010
|
roles: ["anon"]
|
|
10667
11011
|
};
|
|
10668
11012
|
return await this.db.transaction(async (tx) => {
|
|
10669
11013
|
await applyAuthContext(tx, {
|
|
10670
|
-
|
|
11014
|
+
uid: activeAuth.uid,
|
|
10671
11015
|
roles: activeAuth.roles
|
|
10672
11016
|
}, this.rlsUserRole);
|
|
10673
11017
|
const txEntityService = new DataService(tx, this.registry);
|
|
@@ -10699,7 +11043,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
10699
11043
|
if (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead) {
|
|
10700
11044
|
const contextForCallback = {
|
|
10701
11045
|
user: {
|
|
10702
|
-
uid: activeAuth.
|
|
11046
|
+
uid: activeAuth.uid,
|
|
10703
11047
|
roles: activeAuth.roles
|
|
10704
11048
|
},
|
|
10705
11049
|
driver: this.driver,
|
|
@@ -10791,12 +11135,12 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
10791
11135
|
if (this.driver) {
|
|
10792
11136
|
const collection = this.registry.getCollectionByPath(notifyPath);
|
|
10793
11137
|
const activeAuth = authContext || {
|
|
10794
|
-
|
|
11138
|
+
uid: "anon",
|
|
10795
11139
|
roles: ["anon"]
|
|
10796
11140
|
};
|
|
10797
11141
|
return await this.db.transaction(async (tx) => {
|
|
10798
11142
|
await applyAuthContext(tx, {
|
|
10799
|
-
|
|
11143
|
+
uid: activeAuth.uid,
|
|
10800
11144
|
roles: activeAuth.roles
|
|
10801
11145
|
}, this.rlsUserRole);
|
|
10802
11146
|
let processedEntity = await new DataService(tx, this.registry).fetchOne(notifyPath, id, collection?.databaseId);
|
|
@@ -10812,7 +11156,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
10812
11156
|
if (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead) {
|
|
10813
11157
|
const contextForCallback = {
|
|
10814
11158
|
user: {
|
|
10815
|
-
uid: activeAuth.
|
|
11159
|
+
uid: activeAuth.uid,
|
|
10816
11160
|
roles: activeAuth.roles
|
|
10817
11161
|
},
|
|
10818
11162
|
driver: this.driver,
|
|
@@ -10938,15 +11282,67 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
10938
11282
|
}
|
|
10939
11283
|
this.removePresence(clientId, channel);
|
|
10940
11284
|
}
|
|
10941
|
-
/**
|
|
11285
|
+
/**
|
|
11286
|
+
* Broadcast a message to all clients in a channel except the sender.
|
|
11287
|
+
*
|
|
11288
|
+
* On a channel with no retention rule this is what it always was: a
|
|
11289
|
+
* synchronous fan-out to whoever is connected, with no sequence number, no
|
|
11290
|
+
* SQL and no await — the body below runs to completion before returning.
|
|
11291
|
+
*
|
|
11292
|
+
* On a retained channel the message is durably numbered first and only then
|
|
11293
|
+
* delivered, through a per-channel queue so that delivery order matches
|
|
11294
|
+
* sequence order. That ordering is the whole point: a client that catches up
|
|
11295
|
+
* with `sinceSeq` has to arrive at the same state as one that never
|
|
11296
|
+
* disconnected.
|
|
11297
|
+
*/
|
|
10942
11298
|
broadcastToChannel(clientId, channel, event, payload) {
|
|
11299
|
+
const retention = this.channelHistory?.retentionFor(channel);
|
|
11300
|
+
if (!retention) {
|
|
11301
|
+
this.fanOutBroadcast(clientId, channel, event, payload);
|
|
11302
|
+
return;
|
|
11303
|
+
}
|
|
11304
|
+
const next = (this.channelSendQueues.get(channel) ?? Promise.resolve()).catch(() => {}).then(() => this.persistAndFanOut(clientId, channel, event, payload, retention));
|
|
11305
|
+
this.channelSendQueues.set(channel, next);
|
|
11306
|
+
next.finally(() => {
|
|
11307
|
+
if (this.channelSendQueues.get(channel) === next) this.channelSendQueues.delete(channel);
|
|
11308
|
+
});
|
|
11309
|
+
}
|
|
11310
|
+
/**
|
|
11311
|
+
* Number a broadcast, store it, then deliver it.
|
|
11312
|
+
*
|
|
11313
|
+
* A message that cannot be stored is **not** delivered. Delivering it would
|
|
11314
|
+
* put it in front of live subscribers while leaving it absent from every
|
|
11315
|
+
* future replay — the two views of the channel would disagree permanently,
|
|
11316
|
+
* and no later message could repair the gap. Failing loudly to the sender
|
|
11317
|
+
* instead lets it retry, which for an operation stream is the only outcome
|
|
11318
|
+
* that keeps clients convergent.
|
|
11319
|
+
*/
|
|
11320
|
+
async persistAndFanOut(clientId, channel, event, payload, retention) {
|
|
11321
|
+
let seq;
|
|
11322
|
+
try {
|
|
11323
|
+
({seq} = await this.channelHistory.append(channel, event, payload, clientId));
|
|
11324
|
+
} catch (error) {
|
|
11325
|
+
logger.error(`❌ [ChannelHistory] Could not persist broadcast on "${channel}" — message dropped`, { error });
|
|
11326
|
+
this.sendError(clientId, `Could not persist broadcast on retained channel "${channel}"`, void 0, "CHANNEL_HISTORY_WRITE_FAILED");
|
|
11327
|
+
return;
|
|
11328
|
+
}
|
|
11329
|
+
this.fanOutBroadcast(clientId, channel, event, payload, seq);
|
|
11330
|
+
try {
|
|
11331
|
+
await this.channelHistory.prune(channel, retention);
|
|
11332
|
+
} catch (error) {
|
|
11333
|
+
logger.warn(`⚠️ [ChannelHistory] Prune failed for "${channel}"`, { error });
|
|
11334
|
+
}
|
|
11335
|
+
}
|
|
11336
|
+
/** Deliver a broadcast frame to every member of a channel but the sender. */
|
|
11337
|
+
fanOutBroadcast(clientId, channel, event, payload, seq) {
|
|
10943
11338
|
const members = this.channels.get(channel);
|
|
10944
11339
|
if (!members) return;
|
|
10945
11340
|
const message = JSON.stringify({
|
|
10946
11341
|
type: "broadcast",
|
|
10947
11342
|
channel,
|
|
10948
11343
|
event,
|
|
10949
|
-
payload
|
|
11344
|
+
payload,
|
|
11345
|
+
...seq !== void 0 ? { seq } : {}
|
|
10950
11346
|
});
|
|
10951
11347
|
for (const memberId of members) {
|
|
10952
11348
|
if (memberId === clientId) continue;
|
|
@@ -10954,6 +11350,55 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
10954
11350
|
if (ws && ws.readyState === WebSocket.OPEN) ws.send(message);
|
|
10955
11351
|
}
|
|
10956
11352
|
}
|
|
11353
|
+
/**
|
|
11354
|
+
* Install retention rules and create the tables they need.
|
|
11355
|
+
*
|
|
11356
|
+
* Safe to call with no rules (and safe not to call at all): the store stays
|
|
11357
|
+
* inert, no schema is created, and broadcast keeps its original
|
|
11358
|
+
* fire-and-forget path.
|
|
11359
|
+
*/
|
|
11360
|
+
async configureChannelHistory(rules) {
|
|
11361
|
+
this.channelHistory = new ChannelHistoryStore(this.db, rules ?? []);
|
|
11362
|
+
if (!this.channelHistory.enabled) return;
|
|
11363
|
+
await this.channelHistory.ensureTables();
|
|
11364
|
+
}
|
|
11365
|
+
/** Whether any channel is configured to retain messages. */
|
|
11366
|
+
isChannelHistoryEnabled() {
|
|
11367
|
+
return this.channelHistory?.enabled ?? false;
|
|
11368
|
+
}
|
|
11369
|
+
/**
|
|
11370
|
+
* Answer a client's catch-up request.
|
|
11371
|
+
*
|
|
11372
|
+
* A channel with no retention rule is answered with `retained: false`
|
|
11373
|
+
* rather than an empty list, so the client can tell "you missed nothing"
|
|
11374
|
+
* apart from "this channel never keeps anything" — the second means its
|
|
11375
|
+
* reconnect strategy has to be a full resync, and silence would leave it
|
|
11376
|
+
* guessing.
|
|
11377
|
+
*/
|
|
11378
|
+
async handleChannelHistoryRequest(clientId, channel, sinceSeq, limit) {
|
|
11379
|
+
if (!channel) return;
|
|
11380
|
+
if (!this.channelHistory?.retentionFor(channel)) {
|
|
11381
|
+
this.sendChannelHistory(clientId, channel, [], false);
|
|
11382
|
+
return;
|
|
11383
|
+
}
|
|
11384
|
+
try {
|
|
11385
|
+
const { messages, latestSeq } = await this.channelHistory.replay(channel, sinceSeq, limit);
|
|
11386
|
+
this.sendChannelHistory(clientId, channel, messages, true, latestSeq);
|
|
11387
|
+
} catch (error) {
|
|
11388
|
+
logger.error(`❌ [ChannelHistory] Replay failed for "${channel}"`, { error });
|
|
11389
|
+
this.sendError(clientId, `Could not replay history for channel "${channel}"`, void 0, "CHANNEL_HISTORY_READ_FAILED");
|
|
11390
|
+
}
|
|
11391
|
+
}
|
|
11392
|
+
sendChannelHistory(clientId, channel, messages, retained, latestSeq) {
|
|
11393
|
+
const ws = this.clients.get(clientId);
|
|
11394
|
+
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({
|
|
11395
|
+
type: "channel_history",
|
|
11396
|
+
channel,
|
|
11397
|
+
messages,
|
|
11398
|
+
retained,
|
|
11399
|
+
...latestSeq !== void 0 ? { latestSeq } : {}
|
|
11400
|
+
}));
|
|
11401
|
+
}
|
|
10957
11402
|
/** Track presence in a channel */
|
|
10958
11403
|
trackPresence(clientId, channel, state) {
|
|
10959
11404
|
if (!this.presence.has(channel)) this.presence.set(channel, /* @__PURE__ */ new Map());
|
|
@@ -11034,6 +11479,9 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
11034
11479
|
this.subscriptionCallbacks.clear();
|
|
11035
11480
|
this.channels.clear();
|
|
11036
11481
|
this.presence.clear();
|
|
11482
|
+
await Promise.allSettled([...this.channelSendQueues.values()]);
|
|
11483
|
+
this.channelSendQueues.clear();
|
|
11484
|
+
this.channelHistory?.clear();
|
|
11037
11485
|
if (this.presenceInterval) {
|
|
11038
11486
|
clearInterval(this.presenceInterval);
|
|
11039
11487
|
this.presenceInterval = void 0;
|
|
@@ -11375,20 +11823,20 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
|
|
|
11375
11823
|
if (authAdapter) try {
|
|
11376
11824
|
const adapterUser = authAdapter.verifyToken ? await authAdapter.verifyToken(token) : await authAdapter.verifyRequest(new Request("http://localhost/_ws_auth", { headers: { Authorization: `Bearer ${token}` } }));
|
|
11377
11825
|
if (adapterUser) verifiedUser = {
|
|
11378
|
-
|
|
11826
|
+
uid: adapterUser.uid,
|
|
11379
11827
|
roles: adapterUser.roles,
|
|
11380
11828
|
isAdmin: adapterUser.isAdmin
|
|
11381
11829
|
};
|
|
11382
11830
|
} catch {}
|
|
11383
11831
|
else if (authConfig?.serviceKey && safeCompare(token, authConfig.serviceKey)) verifiedUser = {
|
|
11384
|
-
|
|
11832
|
+
uid: "service",
|
|
11385
11833
|
roles: ["admin"],
|
|
11386
11834
|
isAdmin: true
|
|
11387
11835
|
};
|
|
11388
11836
|
else {
|
|
11389
11837
|
const jwtPayload = extractUserFromToken(token);
|
|
11390
11838
|
if (jwtPayload) verifiedUser = {
|
|
11391
|
-
|
|
11839
|
+
uid: jwtPayload.uid,
|
|
11392
11840
|
roles: jwtPayload.roles ?? [],
|
|
11393
11841
|
isAdmin: (jwtPayload.roles ?? []).some((r) => r === "admin")
|
|
11394
11842
|
};
|
|
@@ -11404,11 +11852,11 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
|
|
|
11404
11852
|
type: "AUTH_SUCCESS",
|
|
11405
11853
|
requestId,
|
|
11406
11854
|
payload: {
|
|
11407
|
-
|
|
11855
|
+
uid: verifiedUser.uid,
|
|
11408
11856
|
roles: verifiedUser.roles
|
|
11409
11857
|
}
|
|
11410
11858
|
}));
|
|
11411
|
-
wsDebug(`🔐 [WebSocket Server] Client ${clientId} authenticated as ${verifiedUser.
|
|
11859
|
+
wsDebug(`🔐 [WebSocket Server] Client ${clientId} authenticated as ${verifiedUser.uid}`);
|
|
11412
11860
|
} else {
|
|
11413
11861
|
wsDebug(`[WS] replying AUTH_ERROR for requestId ${requestId} (invalid token)`);
|
|
11414
11862
|
sendError("AUTH_ERROR", "INVALID_TOKEN", "Invalid or expired token");
|
|
@@ -11446,7 +11894,7 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
|
|
|
11446
11894
|
const session = clientSessions.get(clientId);
|
|
11447
11895
|
if (typeof driver.withAuth === "function") try {
|
|
11448
11896
|
const userForAuth = session?.user ? {
|
|
11449
|
-
uid: session.user.
|
|
11897
|
+
uid: session.user.uid,
|
|
11450
11898
|
displayName: null,
|
|
11451
11899
|
email: null,
|
|
11452
11900
|
photoURL: null,
|
|
@@ -11580,7 +12028,7 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
|
|
|
11580
12028
|
sql: typeof sql === "string" ? sql.substring(0, 500) : sql,
|
|
11581
12029
|
options,
|
|
11582
12030
|
resultRows: Array.isArray(result) ? result.length : "unknown",
|
|
11583
|
-
|
|
12031
|
+
uid: auditSession?.user?.uid ?? "unknown",
|
|
11584
12032
|
roles: auditSession?.user?.roles ?? [],
|
|
11585
12033
|
isAdmin: auditSession?.user?.isAdmin ?? false
|
|
11586
12034
|
}));
|
|
@@ -11625,6 +12073,21 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
|
|
|
11625
12073
|
ws.send(JSON.stringify(response));
|
|
11626
12074
|
}
|
|
11627
12075
|
break;
|
|
12076
|
+
case "FETCH_APPLICATION_ROLES":
|
|
12077
|
+
{
|
|
12078
|
+
wsDebug("👤 [WebSocket Server] Processing FETCH_APPLICATION_ROLES request");
|
|
12079
|
+
const admin = (await getScopedDelegate()).admin;
|
|
12080
|
+
let roles = [];
|
|
12081
|
+
if (isSQLAdmin(admin) && admin.fetchApplicationRoles) roles = await admin.fetchApplicationRoles();
|
|
12082
|
+
wsDebug(`👤 [WebSocket Server] Fetched ${roles.length} application roles.`);
|
|
12083
|
+
const response = {
|
|
12084
|
+
type: "FETCH_APPLICATION_ROLES_SUCCESS",
|
|
12085
|
+
payload: { roles },
|
|
12086
|
+
requestId
|
|
12087
|
+
};
|
|
12088
|
+
ws.send(JSON.stringify(response));
|
|
12089
|
+
}
|
|
12090
|
+
break;
|
|
11628
12091
|
case "FETCH_CURRENT_DATABASE":
|
|
11629
12092
|
{
|
|
11630
12093
|
wsDebug("📚 [WebSocket Server] Processing FETCH_CURRENT_DATABASE request");
|
|
@@ -11731,14 +12194,15 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
|
|
|
11731
12194
|
case "broadcast":
|
|
11732
12195
|
case "presence_track":
|
|
11733
12196
|
case "presence_untrack":
|
|
11734
|
-
case "presence_state":
|
|
12197
|
+
case "presence_state":
|
|
12198
|
+
case "channel_history": {
|
|
11735
12199
|
wsDebug("🔄 [WebSocket Server] Routing realtime message to RealtimeService:", type);
|
|
11736
12200
|
const session = clientSessions.get(clientId);
|
|
11737
12201
|
const authContext = session?.user ? {
|
|
11738
|
-
|
|
12202
|
+
uid: session.user.uid,
|
|
11739
12203
|
roles: session.user.roles ?? []
|
|
11740
12204
|
} : {
|
|
11741
|
-
|
|
12205
|
+
uid: "anon",
|
|
11742
12206
|
roles: ["anon"]
|
|
11743
12207
|
};
|
|
11744
12208
|
await realtimeService.handleClientMessage(clientId, {
|
|
@@ -20694,9 +21158,65 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
20694
21158
|
)
|
|
20695
21159
|
`);
|
|
20696
21160
|
await db.execute(sql`
|
|
21161
|
+
CREATE OR REPLACE FUNCTION ${sql.raw(`"${authSchema}"`)}.sync_uid_user_id() RETURNS trigger AS $$
|
|
21162
|
+
BEGIN
|
|
21163
|
+
IF NEW.uid IS NULL AND NEW.user_id IS NOT NULL THEN
|
|
21164
|
+
NEW.uid := NEW.user_id;
|
|
21165
|
+
ELSIF NEW.user_id IS NULL AND NEW.uid IS NOT NULL THEN
|
|
21166
|
+
NEW.user_id := NEW.uid;
|
|
21167
|
+
END IF;
|
|
21168
|
+
RETURN NEW;
|
|
21169
|
+
END $$ LANGUAGE plpgsql
|
|
21170
|
+
`);
|
|
21171
|
+
for (const authTable of [
|
|
21172
|
+
"user_identities",
|
|
21173
|
+
"refresh_tokens",
|
|
21174
|
+
"password_reset_tokens",
|
|
21175
|
+
"magic_link_tokens",
|
|
21176
|
+
"mfa_factors",
|
|
21177
|
+
"recovery_codes"
|
|
21178
|
+
]) {
|
|
21179
|
+
const qualified = `"${authSchema}"."${authTable}"`;
|
|
21180
|
+
await db.execute(sql`
|
|
21181
|
+
DO $$
|
|
21182
|
+
DECLARE
|
|
21183
|
+
has_legacy boolean;
|
|
21184
|
+
has_uid boolean;
|
|
21185
|
+
BEGIN
|
|
21186
|
+
SELECT
|
|
21187
|
+
bool_or(column_name = 'user_id'),
|
|
21188
|
+
bool_or(column_name = 'uid')
|
|
21189
|
+
INTO has_legacy, has_uid
|
|
21190
|
+
FROM information_schema.columns
|
|
21191
|
+
WHERE table_schema = ${sql.raw(`'${authSchema}'`)}
|
|
21192
|
+
AND table_name = ${sql.raw(`'${authTable}'`)};
|
|
21193
|
+
|
|
21194
|
+
-- Table absent, or already uid-only (a fresh install, or
|
|
21195
|
+
-- phase 2 already run): nothing to do.
|
|
21196
|
+
IF has_legacy IS NOT TRUE THEN
|
|
21197
|
+
RETURN;
|
|
21198
|
+
END IF;
|
|
21199
|
+
|
|
21200
|
+
IF has_uid IS NOT TRUE THEN
|
|
21201
|
+
EXECUTE ${sql.raw(`'ALTER TABLE ${qualified} ADD COLUMN uid ${userIdType} REFERENCES ${usersTableName}(id) ON DELETE CASCADE'`)};
|
|
21202
|
+
EXECUTE ${sql.raw(`'UPDATE ${qualified} SET uid = user_id WHERE uid IS NULL'`)};
|
|
21203
|
+
EXECUTE ${sql.raw(`'CREATE INDEX IF NOT EXISTS idx_${authTable}_uid ON ${qualified}(uid)'`)};
|
|
21204
|
+
END IF;
|
|
21205
|
+
|
|
21206
|
+
-- New code inserts uid and never user_id, so the legacy
|
|
21207
|
+
-- column can no longer be NOT NULL. The trigger below
|
|
21208
|
+
-- backfills it, but the constraint is checked first.
|
|
21209
|
+
EXECUTE ${sql.raw(`'ALTER TABLE ${qualified} ALTER COLUMN user_id DROP NOT NULL'`)};
|
|
21210
|
+
|
|
21211
|
+
EXECUTE ${sql.raw(`'DROP TRIGGER IF EXISTS sync_uid_user_id ON ${qualified}'`)};
|
|
21212
|
+
EXECUTE ${sql.raw(`'CREATE TRIGGER sync_uid_user_id BEFORE INSERT OR UPDATE ON ${qualified} FOR EACH ROW EXECUTE FUNCTION "${authSchema}".sync_uid_user_id()'`)};
|
|
21213
|
+
END $$
|
|
21214
|
+
`);
|
|
21215
|
+
}
|
|
21216
|
+
await db.execute(sql`
|
|
20697
21217
|
CREATE TABLE IF NOT EXISTS ${sql.raw(userIdentitiesTable)} (
|
|
20698
21218
|
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
|
20699
|
-
|
|
21219
|
+
uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
|
|
20700
21220
|
provider TEXT NOT NULL,
|
|
20701
21221
|
provider_id TEXT NOT NULL,
|
|
20702
21222
|
profile_data JSONB,
|
|
@@ -20707,18 +21227,18 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
20707
21227
|
`);
|
|
20708
21228
|
await db.execute(sql`
|
|
20709
21229
|
CREATE INDEX IF NOT EXISTS idx_user_identities_user
|
|
20710
|
-
ON ${sql.raw(userIdentitiesTable)}(
|
|
21230
|
+
ON ${sql.raw(userIdentitiesTable)}(uid)
|
|
20711
21231
|
`);
|
|
20712
21232
|
await db.execute(sql`
|
|
20713
21233
|
CREATE TABLE IF NOT EXISTS ${sql.raw(refreshTokensTableName)} (
|
|
20714
21234
|
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
|
20715
|
-
|
|
21235
|
+
uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
|
|
20716
21236
|
token_hash TEXT NOT NULL UNIQUE,
|
|
20717
21237
|
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
|
20718
21238
|
user_agent TEXT,
|
|
20719
21239
|
ip_address TEXT,
|
|
20720
21240
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
20721
|
-
CONSTRAINT unique_device_session UNIQUE (
|
|
21241
|
+
CONSTRAINT unique_device_session UNIQUE (uid, user_agent, ip_address)
|
|
20722
21242
|
)
|
|
20723
21243
|
`);
|
|
20724
21244
|
await db.execute(sql`
|
|
@@ -20727,12 +21247,12 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
20727
21247
|
`);
|
|
20728
21248
|
await db.execute(sql`
|
|
20729
21249
|
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user
|
|
20730
|
-
ON ${sql.raw(refreshTokensTableName)}(
|
|
21250
|
+
ON ${sql.raw(refreshTokensTableName)}(uid)
|
|
20731
21251
|
`);
|
|
20732
21252
|
await db.execute(sql`
|
|
20733
21253
|
CREATE TABLE IF NOT EXISTS ${sql.raw(passwordResetTokensTableName)} (
|
|
20734
21254
|
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
|
20735
|
-
|
|
21255
|
+
uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
|
|
20736
21256
|
token_hash TEXT NOT NULL UNIQUE,
|
|
20737
21257
|
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
|
20738
21258
|
used_at TIMESTAMP WITH TIME ZONE,
|
|
@@ -20745,13 +21265,13 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
20745
21265
|
`);
|
|
20746
21266
|
await db.execute(sql`
|
|
20747
21267
|
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_user
|
|
20748
|
-
ON ${sql.raw(passwordResetTokensTableName)}(
|
|
21268
|
+
ON ${sql.raw(passwordResetTokensTableName)}(uid)
|
|
20749
21269
|
`);
|
|
20750
21270
|
const magicLinkTokensTableName = `"${authSchema}"."magic_link_tokens"`;
|
|
20751
21271
|
await db.execute(sql`
|
|
20752
21272
|
CREATE TABLE IF NOT EXISTS ${sql.raw(magicLinkTokensTableName)} (
|
|
20753
21273
|
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
|
20754
|
-
|
|
21274
|
+
uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
|
|
20755
21275
|
token_hash TEXT NOT NULL UNIQUE,
|
|
20756
21276
|
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
|
20757
21277
|
used_at TIMESTAMP WITH TIME ZONE,
|
|
@@ -20764,7 +21284,7 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
20764
21284
|
`);
|
|
20765
21285
|
await db.execute(sql`
|
|
20766
21286
|
CREATE INDEX IF NOT EXISTS idx_magic_link_tokens_user
|
|
20767
|
-
ON ${sql.raw(magicLinkTokensTableName)}(
|
|
21287
|
+
ON ${sql.raw(magicLinkTokensTableName)}(uid)
|
|
20768
21288
|
`);
|
|
20769
21289
|
await db.execute(sql`
|
|
20770
21290
|
CREATE TABLE IF NOT EXISTS ${sql.raw(appConfigTableName)} (
|
|
@@ -20778,7 +21298,10 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
20778
21298
|
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext('rebase_auth_functions_init'))`);
|
|
20779
21299
|
await tx.execute(sql`
|
|
20780
21300
|
CREATE OR REPLACE FUNCTION auth.uid() RETURNS text AS $$
|
|
20781
|
-
SELECT
|
|
21301
|
+
SELECT COALESCE(
|
|
21302
|
+
NULLIF(current_setting('app.uid', true), ''),
|
|
21303
|
+
NULLIF(current_setting('app.user_id', true), '')
|
|
21304
|
+
);
|
|
20782
21305
|
$$ LANGUAGE sql STABLE
|
|
20783
21306
|
`);
|
|
20784
21307
|
await tx.execute(sql`
|
|
@@ -20841,7 +21364,7 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
20841
21364
|
await db.execute(sql`
|
|
20842
21365
|
CREATE TABLE IF NOT EXISTS ${sql.raw(mfaFactorsTableName)} (
|
|
20843
21366
|
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
|
20844
|
-
|
|
21367
|
+
uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
|
|
20845
21368
|
factor_type TEXT NOT NULL DEFAULT 'totp',
|
|
20846
21369
|
secret_encrypted TEXT NOT NULL,
|
|
20847
21370
|
friendly_name TEXT,
|
|
@@ -20852,7 +21375,7 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
20852
21375
|
`);
|
|
20853
21376
|
await db.execute(sql`
|
|
20854
21377
|
CREATE INDEX IF NOT EXISTS idx_mfa_factors_user
|
|
20855
|
-
ON ${sql.raw(mfaFactorsTableName)}(
|
|
21378
|
+
ON ${sql.raw(mfaFactorsTableName)}(uid)
|
|
20856
21379
|
`);
|
|
20857
21380
|
await db.execute(sql`
|
|
20858
21381
|
CREATE TABLE IF NOT EXISTS ${sql.raw(mfaChallengesTableName)} (
|
|
@@ -20871,7 +21394,7 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
20871
21394
|
await db.execute(sql`
|
|
20872
21395
|
CREATE TABLE IF NOT EXISTS ${sql.raw(recoveryCodesTableName)} (
|
|
20873
21396
|
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
|
20874
|
-
|
|
21397
|
+
uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
|
|
20875
21398
|
code_hash TEXT NOT NULL,
|
|
20876
21399
|
used_at TIMESTAMP WITH TIME ZONE,
|
|
20877
21400
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
@@ -20879,7 +21402,7 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
20879
21402
|
`);
|
|
20880
21403
|
await db.execute(sql`
|
|
20881
21404
|
CREATE INDEX IF NOT EXISTS idx_recovery_codes_user
|
|
20882
|
-
ON ${sql.raw(recoveryCodesTableName)}(
|
|
21405
|
+
ON ${sql.raw(recoveryCodesTableName)}(uid)
|
|
20883
21406
|
`);
|
|
20884
21407
|
try {
|
|
20885
21408
|
const authTablePairs = [
|
|
@@ -20960,7 +21483,7 @@ var UserService = class {
|
|
|
20960
21483
|
* Run a privileged auth write with an explicitly cleared RLS context.
|
|
20961
21484
|
*
|
|
20962
21485
|
* The auth services run on the base/owner connection, which by design
|
|
20963
|
-
* carries a NULL `app.
|
|
21486
|
+
* carries a NULL `app.uid` so the `auth.uid() IS NULL` server-escape
|
|
20964
21487
|
* in the default policies applies. That NULL is normally guaranteed by
|
|
20965
21488
|
* `set_config(..., is_local = true)` resetting at transaction end — but a
|
|
20966
21489
|
* GUC that survives on a pooled connection (or a connection role that
|
|
@@ -20974,7 +21497,8 @@ var UserService = class {
|
|
|
20974
21497
|
async withServerContext(fn) {
|
|
20975
21498
|
return await this.db.transaction(async (tx) => {
|
|
20976
21499
|
await tx.execute(sql`
|
|
20977
|
-
SELECT set_config('app.
|
|
21500
|
+
SELECT set_config('app.uid', '', true),
|
|
21501
|
+
set_config('app.user_id', '', true),
|
|
20978
21502
|
set_config('app.user_roles', '', true),
|
|
20979
21503
|
set_config('app.jwt', '', true)
|
|
20980
21504
|
`);
|
|
@@ -21096,19 +21620,19 @@ var UserService = class {
|
|
|
21096
21620
|
async getUserByIdentity(provider, providerId) {
|
|
21097
21621
|
const userIdCol = getColumn(this.usersTable, "id");
|
|
21098
21622
|
if (!userIdCol) return null;
|
|
21099
|
-
const result = await this.db.select({ user: this.usersTable }).from(this.usersTable).innerJoin(this.userIdentitiesTable, eq(userIdCol, this.userIdentitiesTable.
|
|
21623
|
+
const result = await this.db.select({ user: this.usersTable }).from(this.usersTable).innerJoin(this.userIdentitiesTable, eq(userIdCol, this.userIdentitiesTable.uid)).where(sql`${this.userIdentitiesTable.provider} = ${provider} AND ${this.userIdentitiesTable.providerId} = ${providerId}`).limit(1);
|
|
21100
21624
|
if (result.length === 0) return null;
|
|
21101
21625
|
return this.mapRowToUser(result[0].user);
|
|
21102
21626
|
}
|
|
21103
|
-
async getUserIdentities(
|
|
21627
|
+
async getUserIdentities(uid) {
|
|
21104
21628
|
const schema = getTableConfig(this.userIdentitiesTable).schema || "public";
|
|
21105
21629
|
return (await this.db.execute(sql`
|
|
21106
|
-
SELECT id,
|
|
21630
|
+
SELECT id, uid, provider, provider_id, profile_data, created_at, updated_at
|
|
21107
21631
|
FROM ${sql.raw(`"${schema}"."user_identities"`)}
|
|
21108
|
-
WHERE
|
|
21632
|
+
WHERE uid = ${uid}
|
|
21109
21633
|
`)).rows.map((row) => ({
|
|
21110
21634
|
id: row.id,
|
|
21111
|
-
|
|
21635
|
+
uid: row.uid,
|
|
21112
21636
|
provider: row.provider,
|
|
21113
21637
|
providerId: row.provider_id,
|
|
21114
21638
|
profileData: row.profile_data ?? null,
|
|
@@ -21116,9 +21640,9 @@ var UserService = class {
|
|
|
21116
21640
|
updatedAt: row.updated_at
|
|
21117
21641
|
}));
|
|
21118
21642
|
}
|
|
21119
|
-
async linkUserIdentity(
|
|
21643
|
+
async linkUserIdentity(uid, provider, providerId, profileData) {
|
|
21120
21644
|
await this.withServerContext(async (db) => db.insert(this.userIdentitiesTable).values({
|
|
21121
|
-
|
|
21645
|
+
uid,
|
|
21122
21646
|
provider,
|
|
21123
21647
|
providerId,
|
|
21124
21648
|
profileData: profileData || null
|
|
@@ -21237,10 +21761,10 @@ var UserService = class {
|
|
|
21237
21761
|
/**
|
|
21238
21762
|
* Get roles for a user from database (inline TEXT[] column)
|
|
21239
21763
|
*/
|
|
21240
|
-
async getUserRoles(
|
|
21764
|
+
async getUserRoles(uid) {
|
|
21241
21765
|
const usersTableName = this.getQualifiedUsersTableName();
|
|
21242
21766
|
const result = await this.db.execute(sql`
|
|
21243
|
-
SELECT roles FROM ${sql.raw(usersTableName)} WHERE id = ${
|
|
21767
|
+
SELECT roles FROM ${sql.raw(usersTableName)} WHERE id = ${uid}
|
|
21244
21768
|
`);
|
|
21245
21769
|
if (result.rows.length === 0) return [];
|
|
21246
21770
|
return (result.rows[0].roles ?? []).map((id) => ({
|
|
@@ -21254,10 +21778,10 @@ var UserService = class {
|
|
|
21254
21778
|
/**
|
|
21255
21779
|
* Get role IDs for a user
|
|
21256
21780
|
*/
|
|
21257
|
-
async getUserRoleIds(
|
|
21781
|
+
async getUserRoleIds(uid) {
|
|
21258
21782
|
const usersTableName = this.getQualifiedUsersTableName();
|
|
21259
21783
|
const result = await this.db.execute(sql`
|
|
21260
|
-
SELECT roles FROM ${sql.raw(usersTableName)} WHERE id = ${
|
|
21784
|
+
SELECT roles FROM ${sql.raw(usersTableName)} WHERE id = ${uid}
|
|
21261
21785
|
`);
|
|
21262
21786
|
if (result.rows.length === 0) return [];
|
|
21263
21787
|
return result.rows[0].roles ?? [];
|
|
@@ -21265,35 +21789,35 @@ var UserService = class {
|
|
|
21265
21789
|
/**
|
|
21266
21790
|
* Set roles for a user (replaces existing roles)
|
|
21267
21791
|
*/
|
|
21268
|
-
async setUserRoles(
|
|
21792
|
+
async setUserRoles(uid, roleIds) {
|
|
21269
21793
|
const usersTableName = this.getQualifiedUsersTableName();
|
|
21270
21794
|
const rolesArray = `{${roleIds.join(",")}}`;
|
|
21271
21795
|
await this.withServerContext(async (db) => db.execute(sql`
|
|
21272
21796
|
UPDATE ${sql.raw(usersTableName)}
|
|
21273
21797
|
SET roles = ${rolesArray}::text[], updated_at = NOW()
|
|
21274
|
-
WHERE id = ${
|
|
21798
|
+
WHERE id = ${uid}
|
|
21275
21799
|
`));
|
|
21276
21800
|
}
|
|
21277
21801
|
/**
|
|
21278
21802
|
* Assign a specific role to new user (appends if not present)
|
|
21279
21803
|
*/
|
|
21280
|
-
async assignDefaultRole(
|
|
21804
|
+
async assignDefaultRole(uid, roleId) {
|
|
21281
21805
|
const usersTableName = this.getQualifiedUsersTableName();
|
|
21282
21806
|
await this.withServerContext(async (db) => db.execute(sql`
|
|
21283
21807
|
UPDATE ${sql.raw(usersTableName)}
|
|
21284
21808
|
SET roles = array_append(roles, ${roleId}), updated_at = NOW()
|
|
21285
|
-
WHERE id = ${
|
|
21809
|
+
WHERE id = ${uid} AND NOT (${roleId} = ANY(roles))
|
|
21286
21810
|
`));
|
|
21287
21811
|
}
|
|
21288
21812
|
/**
|
|
21289
21813
|
* Get user with their roles
|
|
21290
21814
|
*/
|
|
21291
|
-
async getUserWithRoles(
|
|
21292
|
-
const user = await this.getUserById(
|
|
21815
|
+
async getUserWithRoles(uid) {
|
|
21816
|
+
const user = await this.getUserById(uid);
|
|
21293
21817
|
if (!user) return null;
|
|
21294
21818
|
return {
|
|
21295
21819
|
user,
|
|
21296
|
-
roles: await this.getUserRoles(
|
|
21820
|
+
roles: await this.getUserRoles(uid)
|
|
21297
21821
|
};
|
|
21298
21822
|
}
|
|
21299
21823
|
};
|
|
@@ -21305,18 +21829,18 @@ var RefreshTokenService = class {
|
|
|
21305
21829
|
if (tableOrTables && (tableOrTables.refreshTokens || tableOrTables.users)) this.refreshTokensTable = tableOrTables.refreshTokens || refreshTokens;
|
|
21306
21830
|
else this.refreshTokensTable = tableOrTables || refreshTokens;
|
|
21307
21831
|
}
|
|
21308
|
-
async createToken(
|
|
21832
|
+
async createToken(uid, tokenHash, expiresAt, userAgent, ipAddress) {
|
|
21309
21833
|
const safeUserAgent = userAgent || "";
|
|
21310
21834
|
const safeIpAddress = ipAddress || "";
|
|
21311
21835
|
await this.db.insert(this.refreshTokensTable).values({
|
|
21312
|
-
|
|
21836
|
+
uid,
|
|
21313
21837
|
tokenHash,
|
|
21314
21838
|
expiresAt,
|
|
21315
21839
|
userAgent: safeUserAgent,
|
|
21316
21840
|
ipAddress: safeIpAddress
|
|
21317
21841
|
}).onConflictDoUpdate({
|
|
21318
21842
|
target: [
|
|
21319
|
-
this.refreshTokensTable.
|
|
21843
|
+
this.refreshTokensTable.uid,
|
|
21320
21844
|
this.refreshTokensTable.userAgent,
|
|
21321
21845
|
this.refreshTokensTable.ipAddress
|
|
21322
21846
|
],
|
|
@@ -21329,7 +21853,7 @@ var RefreshTokenService = class {
|
|
|
21329
21853
|
async findByHash(tokenHash) {
|
|
21330
21854
|
const [token] = await this.db.select({
|
|
21331
21855
|
id: this.refreshTokensTable.id,
|
|
21332
|
-
|
|
21856
|
+
uid: this.refreshTokensTable.uid,
|
|
21333
21857
|
tokenHash: this.refreshTokensTable.tokenHash,
|
|
21334
21858
|
expiresAt: this.refreshTokensTable.expiresAt,
|
|
21335
21859
|
createdAt: this.refreshTokensTable.createdAt,
|
|
@@ -21341,22 +21865,22 @@ var RefreshTokenService = class {
|
|
|
21341
21865
|
async deleteByHash(tokenHash) {
|
|
21342
21866
|
await this.db.delete(this.refreshTokensTable).where(eq(this.refreshTokensTable.tokenHash, tokenHash));
|
|
21343
21867
|
}
|
|
21344
|
-
async deleteAllForUser(
|
|
21345
|
-
await this.db.delete(this.refreshTokensTable).where(eq(this.refreshTokensTable.
|
|
21868
|
+
async deleteAllForUser(uid) {
|
|
21869
|
+
await this.db.delete(this.refreshTokensTable).where(eq(this.refreshTokensTable.uid, uid));
|
|
21346
21870
|
}
|
|
21347
|
-
async listForUser(
|
|
21871
|
+
async listForUser(uid) {
|
|
21348
21872
|
return await this.db.select({
|
|
21349
21873
|
id: this.refreshTokensTable.id,
|
|
21350
|
-
|
|
21874
|
+
uid: this.refreshTokensTable.uid,
|
|
21351
21875
|
tokenHash: this.refreshTokensTable.tokenHash,
|
|
21352
21876
|
expiresAt: this.refreshTokensTable.expiresAt,
|
|
21353
21877
|
createdAt: this.refreshTokensTable.createdAt,
|
|
21354
21878
|
userAgent: this.refreshTokensTable.userAgent,
|
|
21355
21879
|
ipAddress: this.refreshTokensTable.ipAddress
|
|
21356
|
-
}).from(this.refreshTokensTable).where(eq(this.refreshTokensTable.
|
|
21880
|
+
}).from(this.refreshTokensTable).where(eq(this.refreshTokensTable.uid, uid)).orderBy(this.refreshTokensTable.createdAt);
|
|
21357
21881
|
}
|
|
21358
|
-
async deleteById(id,
|
|
21359
|
-
await this.db.delete(this.refreshTokensTable).where(sql`${this.refreshTokensTable.id} = ${id} AND ${this.refreshTokensTable.
|
|
21882
|
+
async deleteById(id, uid) {
|
|
21883
|
+
await this.db.delete(this.refreshTokensTable).where(sql`${this.refreshTokensTable.id} = ${id} AND ${this.refreshTokensTable.uid} = ${uid}`);
|
|
21360
21884
|
}
|
|
21361
21885
|
};
|
|
21362
21886
|
/**
|
|
@@ -21377,14 +21901,14 @@ var PasswordResetTokenService = class {
|
|
|
21377
21901
|
/**
|
|
21378
21902
|
* Create a password reset token
|
|
21379
21903
|
*/
|
|
21380
|
-
async createToken(
|
|
21904
|
+
async createToken(uid, tokenHash, expiresAt) {
|
|
21381
21905
|
const tableName = this.getQualifiedPasswordResetTokensTableName();
|
|
21382
21906
|
await this.db.execute(sql`
|
|
21383
21907
|
DELETE FROM ${sql.raw(tableName)}
|
|
21384
|
-
WHERE
|
|
21908
|
+
WHERE uid = ${uid} AND used_at IS NULL
|
|
21385
21909
|
`);
|
|
21386
21910
|
await this.db.insert(this.passwordResetTokensTable).values({
|
|
21387
|
-
|
|
21911
|
+
uid,
|
|
21388
21912
|
tokenHash,
|
|
21389
21913
|
expiresAt
|
|
21390
21914
|
});
|
|
@@ -21394,13 +21918,13 @@ var PasswordResetTokenService = class {
|
|
|
21394
21918
|
*/
|
|
21395
21919
|
async findValidByHash(tokenHash) {
|
|
21396
21920
|
const [token] = await this.db.select({
|
|
21397
|
-
|
|
21921
|
+
uid: this.passwordResetTokensTable.uid,
|
|
21398
21922
|
expiresAt: this.passwordResetTokensTable.expiresAt
|
|
21399
21923
|
}).from(this.passwordResetTokensTable).where(eq(this.passwordResetTokensTable.tokenHash, tokenHash));
|
|
21400
21924
|
if (!token) return null;
|
|
21401
21925
|
const tableName = this.getQualifiedPasswordResetTokensTableName();
|
|
21402
21926
|
const result = await this.db.execute(sql`
|
|
21403
|
-
SELECT
|
|
21927
|
+
SELECT uid, expires_at
|
|
21404
21928
|
FROM ${sql.raw(tableName)}
|
|
21405
21929
|
WHERE token_hash = ${tokenHash}
|
|
21406
21930
|
AND used_at IS NULL
|
|
@@ -21409,7 +21933,7 @@ var PasswordResetTokenService = class {
|
|
|
21409
21933
|
if (result.rows.length === 0) return null;
|
|
21410
21934
|
const row = result.rows[0];
|
|
21411
21935
|
return {
|
|
21412
|
-
|
|
21936
|
+
uid: row.uid,
|
|
21413
21937
|
expiresAt: new Date(row.expires_at)
|
|
21414
21938
|
};
|
|
21415
21939
|
}
|
|
@@ -21422,8 +21946,8 @@ var PasswordResetTokenService = class {
|
|
|
21422
21946
|
/**
|
|
21423
21947
|
* Delete all tokens for a user
|
|
21424
21948
|
*/
|
|
21425
|
-
async deleteAllForUser(
|
|
21426
|
-
await this.db.delete(this.passwordResetTokensTable).where(eq(this.passwordResetTokensTable.
|
|
21949
|
+
async deleteAllForUser(uid) {
|
|
21950
|
+
await this.db.delete(this.passwordResetTokensTable).where(eq(this.passwordResetTokensTable.uid, uid));
|
|
21427
21951
|
}
|
|
21428
21952
|
/**
|
|
21429
21953
|
* Clean up expired tokens
|
|
@@ -21451,14 +21975,14 @@ var MagicLinkTokenService = class {
|
|
|
21451
21975
|
const name = getTableName(this.magicLinkTokensTable);
|
|
21452
21976
|
return `"${getTableConfig(this.magicLinkTokensTable).schema || "public"}"."${name}"`;
|
|
21453
21977
|
}
|
|
21454
|
-
async createToken(
|
|
21978
|
+
async createToken(uid, tokenHash, expiresAt) {
|
|
21455
21979
|
const tableName = this.getQualifiedTableName();
|
|
21456
21980
|
await this.db.execute(sql`
|
|
21457
21981
|
DELETE FROM ${sql.raw(tableName)}
|
|
21458
|
-
WHERE
|
|
21982
|
+
WHERE uid = ${uid} AND used_at IS NULL
|
|
21459
21983
|
`);
|
|
21460
21984
|
await this.db.insert(this.magicLinkTokensTable).values({
|
|
21461
|
-
|
|
21985
|
+
uid,
|
|
21462
21986
|
tokenHash,
|
|
21463
21987
|
expiresAt
|
|
21464
21988
|
});
|
|
@@ -21466,7 +21990,7 @@ var MagicLinkTokenService = class {
|
|
|
21466
21990
|
async findValidByHash(tokenHash) {
|
|
21467
21991
|
const tableName = this.getQualifiedTableName();
|
|
21468
21992
|
const result = await this.db.execute(sql`
|
|
21469
|
-
SELECT
|
|
21993
|
+
SELECT uid, expires_at
|
|
21470
21994
|
FROM ${sql.raw(tableName)}
|
|
21471
21995
|
WHERE token_hash = ${tokenHash}
|
|
21472
21996
|
AND used_at IS NULL
|
|
@@ -21475,7 +21999,7 @@ var MagicLinkTokenService = class {
|
|
|
21475
21999
|
if (result.rows.length === 0) return null;
|
|
21476
22000
|
const row = result.rows[0];
|
|
21477
22001
|
return {
|
|
21478
|
-
|
|
22002
|
+
uid: row.uid,
|
|
21479
22003
|
expiresAt: new Date(row.expires_at)
|
|
21480
22004
|
};
|
|
21481
22005
|
}
|
|
@@ -21498,8 +22022,8 @@ var PostgresTokenRepository = class {
|
|
|
21498
22022
|
this.passwordResetTokenService = new PasswordResetTokenService(db, tableOrTables);
|
|
21499
22023
|
this.magicLinkTokenService = new MagicLinkTokenService(db, tableOrTables);
|
|
21500
22024
|
}
|
|
21501
|
-
async createRefreshToken(
|
|
21502
|
-
await this.refreshTokenService.createToken(
|
|
22025
|
+
async createRefreshToken(uid, tokenHash, expiresAt, userAgent, ipAddress) {
|
|
22026
|
+
await this.refreshTokenService.createToken(uid, tokenHash, expiresAt, userAgent, ipAddress);
|
|
21503
22027
|
}
|
|
21504
22028
|
async findRefreshTokenByHash(tokenHash) {
|
|
21505
22029
|
return this.refreshTokenService.findByHash(tokenHash);
|
|
@@ -21507,17 +22031,17 @@ var PostgresTokenRepository = class {
|
|
|
21507
22031
|
async deleteRefreshToken(tokenHash) {
|
|
21508
22032
|
await this.refreshTokenService.deleteByHash(tokenHash);
|
|
21509
22033
|
}
|
|
21510
|
-
async deleteAllRefreshTokensForUser(
|
|
21511
|
-
await this.refreshTokenService.deleteAllForUser(
|
|
22034
|
+
async deleteAllRefreshTokensForUser(uid) {
|
|
22035
|
+
await this.refreshTokenService.deleteAllForUser(uid);
|
|
21512
22036
|
}
|
|
21513
|
-
async listRefreshTokensForUser(
|
|
21514
|
-
return this.refreshTokenService.listForUser(
|
|
22037
|
+
async listRefreshTokensForUser(uid) {
|
|
22038
|
+
return this.refreshTokenService.listForUser(uid);
|
|
21515
22039
|
}
|
|
21516
|
-
async deleteRefreshTokenById(id,
|
|
21517
|
-
await this.refreshTokenService.deleteById(id,
|
|
22040
|
+
async deleteRefreshTokenById(id, uid) {
|
|
22041
|
+
await this.refreshTokenService.deleteById(id, uid);
|
|
21518
22042
|
}
|
|
21519
|
-
async createPasswordResetToken(
|
|
21520
|
-
await this.passwordResetTokenService.createToken(
|
|
22043
|
+
async createPasswordResetToken(uid, tokenHash, expiresAt) {
|
|
22044
|
+
await this.passwordResetTokenService.createToken(uid, tokenHash, expiresAt);
|
|
21521
22045
|
}
|
|
21522
22046
|
async findValidPasswordResetToken(tokenHash) {
|
|
21523
22047
|
return this.passwordResetTokenService.findValidByHash(tokenHash);
|
|
@@ -21525,14 +22049,14 @@ var PostgresTokenRepository = class {
|
|
|
21525
22049
|
async markPasswordResetTokenUsed(tokenHash) {
|
|
21526
22050
|
await this.passwordResetTokenService.markAsUsed(tokenHash);
|
|
21527
22051
|
}
|
|
21528
|
-
async deleteAllPasswordResetTokensForUser(
|
|
21529
|
-
await this.passwordResetTokenService.deleteAllForUser(
|
|
22052
|
+
async deleteAllPasswordResetTokensForUser(uid) {
|
|
22053
|
+
await this.passwordResetTokenService.deleteAllForUser(uid);
|
|
21530
22054
|
}
|
|
21531
22055
|
async deleteExpiredTokens() {
|
|
21532
22056
|
await this.passwordResetTokenService.deleteExpired();
|
|
21533
22057
|
}
|
|
21534
|
-
async createMagicLinkToken(
|
|
21535
|
-
await this.magicLinkTokenService.createToken(
|
|
22058
|
+
async createMagicLinkToken(uid, tokenHash, expiresAt) {
|
|
22059
|
+
await this.magicLinkTokenService.createToken(uid, tokenHash, expiresAt);
|
|
21536
22060
|
}
|
|
21537
22061
|
async findValidMagicLinkToken(tokenHash) {
|
|
21538
22062
|
return this.magicLinkTokenService.findValidByHash(tokenHash);
|
|
@@ -21567,11 +22091,11 @@ var PostgresAuthRepository = class {
|
|
|
21567
22091
|
async getUserByIdentity(provider, providerId) {
|
|
21568
22092
|
return this.userService.getUserByIdentity(provider, providerId);
|
|
21569
22093
|
}
|
|
21570
|
-
async getUserIdentities(
|
|
21571
|
-
return this.userService.getUserIdentities(
|
|
22094
|
+
async getUserIdentities(uid) {
|
|
22095
|
+
return this.userService.getUserIdentities(uid);
|
|
21572
22096
|
}
|
|
21573
|
-
async linkUserIdentity(
|
|
21574
|
-
return this.userService.linkUserIdentity(
|
|
22097
|
+
async linkUserIdentity(uid, provider, providerId, profileData) {
|
|
22098
|
+
return this.userService.linkUserIdentity(uid, provider, providerId, profileData);
|
|
21575
22099
|
}
|
|
21576
22100
|
async updateUser(id, data) {
|
|
21577
22101
|
return this.userService.updateUser(id, data);
|
|
@@ -21597,20 +22121,20 @@ var PostgresAuthRepository = class {
|
|
|
21597
22121
|
async getUserByVerificationToken(token) {
|
|
21598
22122
|
return this.userService.getUserByVerificationToken(token);
|
|
21599
22123
|
}
|
|
21600
|
-
async getUserRoles(
|
|
21601
|
-
return this.userService.getUserRoles(
|
|
22124
|
+
async getUserRoles(uid) {
|
|
22125
|
+
return this.userService.getUserRoles(uid);
|
|
21602
22126
|
}
|
|
21603
|
-
async getUserRoleIds(
|
|
21604
|
-
return this.userService.getUserRoleIds(
|
|
22127
|
+
async getUserRoleIds(uid) {
|
|
22128
|
+
return this.userService.getUserRoleIds(uid);
|
|
21605
22129
|
}
|
|
21606
|
-
async setUserRoles(
|
|
21607
|
-
await this.userService.setUserRoles(
|
|
22130
|
+
async setUserRoles(uid, roleIds) {
|
|
22131
|
+
await this.userService.setUserRoles(uid, roleIds);
|
|
21608
22132
|
}
|
|
21609
|
-
async assignDefaultRole(
|
|
21610
|
-
await this.userService.assignDefaultRole(
|
|
22133
|
+
async assignDefaultRole(uid, roleId) {
|
|
22134
|
+
await this.userService.assignDefaultRole(uid, roleId);
|
|
21611
22135
|
}
|
|
21612
|
-
async getUserWithRoles(
|
|
21613
|
-
return this.userService.getUserWithRoles(
|
|
22136
|
+
async getUserWithRoles(uid) {
|
|
22137
|
+
return this.userService.getUserWithRoles(uid);
|
|
21614
22138
|
}
|
|
21615
22139
|
async getRoleById(id) {
|
|
21616
22140
|
return {
|
|
@@ -21665,8 +22189,8 @@ var PostgresAuthRepository = class {
|
|
|
21665
22189
|
};
|
|
21666
22190
|
}
|
|
21667
22191
|
async deleteRole(_id) {}
|
|
21668
|
-
async createRefreshToken(
|
|
21669
|
-
await this.tokenRepository.createRefreshToken(
|
|
22192
|
+
async createRefreshToken(uid, tokenHash, expiresAt, userAgent, ipAddress) {
|
|
22193
|
+
await this.tokenRepository.createRefreshToken(uid, tokenHash, expiresAt, userAgent, ipAddress);
|
|
21670
22194
|
}
|
|
21671
22195
|
async findRefreshTokenByHash(tokenHash) {
|
|
21672
22196
|
return this.tokenRepository.findRefreshTokenByHash(tokenHash);
|
|
@@ -21674,17 +22198,17 @@ var PostgresAuthRepository = class {
|
|
|
21674
22198
|
async deleteRefreshToken(tokenHash) {
|
|
21675
22199
|
await this.tokenRepository.deleteRefreshToken(tokenHash);
|
|
21676
22200
|
}
|
|
21677
|
-
async deleteAllRefreshTokensForUser(
|
|
21678
|
-
await this.tokenRepository.deleteAllRefreshTokensForUser(
|
|
22201
|
+
async deleteAllRefreshTokensForUser(uid) {
|
|
22202
|
+
await this.tokenRepository.deleteAllRefreshTokensForUser(uid);
|
|
21679
22203
|
}
|
|
21680
|
-
async listRefreshTokensForUser(
|
|
21681
|
-
return this.tokenRepository.listRefreshTokensForUser(
|
|
22204
|
+
async listRefreshTokensForUser(uid) {
|
|
22205
|
+
return this.tokenRepository.listRefreshTokensForUser(uid);
|
|
21682
22206
|
}
|
|
21683
|
-
async deleteRefreshTokenById(id,
|
|
21684
|
-
await this.tokenRepository.deleteRefreshTokenById(id,
|
|
22207
|
+
async deleteRefreshTokenById(id, uid) {
|
|
22208
|
+
await this.tokenRepository.deleteRefreshTokenById(id, uid);
|
|
21685
22209
|
}
|
|
21686
|
-
async createPasswordResetToken(
|
|
21687
|
-
await this.tokenRepository.createPasswordResetToken(
|
|
22210
|
+
async createPasswordResetToken(uid, tokenHash, expiresAt) {
|
|
22211
|
+
await this.tokenRepository.createPasswordResetToken(uid, tokenHash, expiresAt);
|
|
21688
22212
|
}
|
|
21689
22213
|
async findValidPasswordResetToken(tokenHash) {
|
|
21690
22214
|
return this.tokenRepository.findValidPasswordResetToken(tokenHash);
|
|
@@ -21692,14 +22216,14 @@ var PostgresAuthRepository = class {
|
|
|
21692
22216
|
async markPasswordResetTokenUsed(tokenHash) {
|
|
21693
22217
|
await this.tokenRepository.markPasswordResetTokenUsed(tokenHash);
|
|
21694
22218
|
}
|
|
21695
|
-
async deleteAllPasswordResetTokensForUser(
|
|
21696
|
-
await this.tokenRepository.deleteAllPasswordResetTokensForUser(
|
|
22219
|
+
async deleteAllPasswordResetTokensForUser(uid) {
|
|
22220
|
+
await this.tokenRepository.deleteAllPasswordResetTokensForUser(uid);
|
|
21697
22221
|
}
|
|
21698
22222
|
async deleteExpiredTokens() {
|
|
21699
22223
|
await this.tokenRepository.deleteExpiredTokens();
|
|
21700
22224
|
}
|
|
21701
|
-
async createMagicLinkToken(
|
|
21702
|
-
await this.tokenRepository.createMagicLinkToken(
|
|
22225
|
+
async createMagicLinkToken(uid, tokenHash, expiresAt) {
|
|
22226
|
+
await this.tokenRepository.createMagicLinkToken(uid, tokenHash, expiresAt);
|
|
21703
22227
|
}
|
|
21704
22228
|
async findValidMagicLinkToken(tokenHash) {
|
|
21705
22229
|
return this.tokenRepository.findValidMagicLinkToken(tokenHash);
|
|
@@ -21712,11 +22236,11 @@ var PostgresAuthRepository = class {
|
|
|
21712
22236
|
if (!this._mfaService) this._mfaService = new MfaService(this.db);
|
|
21713
22237
|
return this._mfaService;
|
|
21714
22238
|
}
|
|
21715
|
-
async createMfaFactor(
|
|
21716
|
-
return this.getMfaService().createMfaFactor(
|
|
22239
|
+
async createMfaFactor(uid, factorType, secretEncrypted, friendlyName) {
|
|
22240
|
+
return this.getMfaService().createMfaFactor(uid, factorType, secretEncrypted, friendlyName);
|
|
21717
22241
|
}
|
|
21718
|
-
async getMfaFactors(
|
|
21719
|
-
return this.getMfaService().getMfaFactors(
|
|
22242
|
+
async getMfaFactors(uid) {
|
|
22243
|
+
return this.getMfaService().getMfaFactors(uid);
|
|
21720
22244
|
}
|
|
21721
22245
|
async getMfaFactorById(factorId) {
|
|
21722
22246
|
return this.getMfaService().getMfaFactorById(factorId);
|
|
@@ -21724,8 +22248,8 @@ var PostgresAuthRepository = class {
|
|
|
21724
22248
|
async verifyMfaFactor(factorId) {
|
|
21725
22249
|
return this.getMfaService().verifyMfaFactor(factorId);
|
|
21726
22250
|
}
|
|
21727
|
-
async deleteMfaFactor(factorId,
|
|
21728
|
-
return this.getMfaService().deleteMfaFactor(factorId,
|
|
22251
|
+
async deleteMfaFactor(factorId, uid) {
|
|
22252
|
+
return this.getMfaService().deleteMfaFactor(factorId, uid);
|
|
21729
22253
|
}
|
|
21730
22254
|
async createMfaChallenge(factorId, ipAddress) {
|
|
21731
22255
|
return this.getMfaService().createMfaChallenge(factorId, ipAddress);
|
|
@@ -21736,20 +22260,20 @@ var PostgresAuthRepository = class {
|
|
|
21736
22260
|
async verifyMfaChallenge(challengeId) {
|
|
21737
22261
|
return this.getMfaService().verifyMfaChallenge(challengeId);
|
|
21738
22262
|
}
|
|
21739
|
-
async createRecoveryCodes(
|
|
21740
|
-
return this.getMfaService().createRecoveryCodes(
|
|
22263
|
+
async createRecoveryCodes(uid, codeHashes) {
|
|
22264
|
+
return this.getMfaService().createRecoveryCodes(uid, codeHashes);
|
|
21741
22265
|
}
|
|
21742
|
-
async useRecoveryCode(
|
|
21743
|
-
return this.getMfaService().useRecoveryCode(
|
|
22266
|
+
async useRecoveryCode(uid, codeHash) {
|
|
22267
|
+
return this.getMfaService().useRecoveryCode(uid, codeHash);
|
|
21744
22268
|
}
|
|
21745
|
-
async getUnusedRecoveryCodeCount(
|
|
21746
|
-
return this.getMfaService().getUnusedRecoveryCodeCount(
|
|
22269
|
+
async getUnusedRecoveryCodeCount(uid) {
|
|
22270
|
+
return this.getMfaService().getUnusedRecoveryCodeCount(uid);
|
|
21747
22271
|
}
|
|
21748
|
-
async deleteAllRecoveryCodes(
|
|
21749
|
-
return this.getMfaService().deleteAllRecoveryCodes(
|
|
22272
|
+
async deleteAllRecoveryCodes(uid) {
|
|
22273
|
+
return this.getMfaService().deleteAllRecoveryCodes(uid);
|
|
21750
22274
|
}
|
|
21751
|
-
async hasVerifiedMfaFactors(
|
|
21752
|
-
return this.getMfaService().hasVerifiedMfaFactors(
|
|
22275
|
+
async hasVerifiedMfaFactors(uid) {
|
|
22276
|
+
return this.getMfaService().hasVerifiedMfaFactors(uid);
|
|
21753
22277
|
}
|
|
21754
22278
|
};
|
|
21755
22279
|
/**
|
|
@@ -21766,16 +22290,16 @@ var MfaService = class {
|
|
|
21766
22290
|
qualify(tableName) {
|
|
21767
22291
|
return `"${this.schemaName}"."${tableName}"`;
|
|
21768
22292
|
}
|
|
21769
|
-
async createMfaFactor(
|
|
22293
|
+
async createMfaFactor(uid, factorType, secretEncrypted, friendlyName) {
|
|
21770
22294
|
const tableName = this.qualify("mfa_factors");
|
|
21771
22295
|
const row = (await this.db.execute(sql`
|
|
21772
|
-
INSERT INTO ${sql.raw(tableName)} (
|
|
21773
|
-
VALUES (${
|
|
21774
|
-
RETURNING id,
|
|
22296
|
+
INSERT INTO ${sql.raw(tableName)} (uid, factor_type, secret_encrypted, friendly_name)
|
|
22297
|
+
VALUES (${uid}, ${factorType}, ${secretEncrypted}, ${friendlyName ?? null})
|
|
22298
|
+
RETURNING id, uid, factor_type, friendly_name, verified, created_at, updated_at
|
|
21775
22299
|
`)).rows[0];
|
|
21776
22300
|
return {
|
|
21777
22301
|
id: row.id,
|
|
21778
|
-
|
|
22302
|
+
uid: row.uid,
|
|
21779
22303
|
factorType: row.factor_type,
|
|
21780
22304
|
friendlyName: row.friendly_name ?? void 0,
|
|
21781
22305
|
verified: row.verified,
|
|
@@ -21783,16 +22307,16 @@ var MfaService = class {
|
|
|
21783
22307
|
updatedAt: new Date(row.updated_at)
|
|
21784
22308
|
};
|
|
21785
22309
|
}
|
|
21786
|
-
async getMfaFactors(
|
|
22310
|
+
async getMfaFactors(uid) {
|
|
21787
22311
|
const tableName = this.qualify("mfa_factors");
|
|
21788
22312
|
return (await this.db.execute(sql`
|
|
21789
|
-
SELECT id,
|
|
22313
|
+
SELECT id, uid, factor_type, friendly_name, verified, created_at, updated_at
|
|
21790
22314
|
FROM ${sql.raw(tableName)}
|
|
21791
|
-
WHERE
|
|
22315
|
+
WHERE uid = ${uid}
|
|
21792
22316
|
ORDER BY created_at
|
|
21793
22317
|
`)).rows.map((row) => ({
|
|
21794
22318
|
id: row.id,
|
|
21795
|
-
|
|
22319
|
+
uid: row.uid,
|
|
21796
22320
|
factorType: row.factor_type,
|
|
21797
22321
|
friendlyName: row.friendly_name ?? void 0,
|
|
21798
22322
|
verified: row.verified,
|
|
@@ -21803,7 +22327,7 @@ var MfaService = class {
|
|
|
21803
22327
|
async getMfaFactorById(factorId) {
|
|
21804
22328
|
const tableName = this.qualify("mfa_factors");
|
|
21805
22329
|
const result = await this.db.execute(sql`
|
|
21806
|
-
SELECT id,
|
|
22330
|
+
SELECT id, uid, factor_type, secret_encrypted, friendly_name, verified, created_at, updated_at
|
|
21807
22331
|
FROM ${sql.raw(tableName)}
|
|
21808
22332
|
WHERE id = ${factorId}
|
|
21809
22333
|
`);
|
|
@@ -21811,7 +22335,7 @@ var MfaService = class {
|
|
|
21811
22335
|
const row = result.rows[0];
|
|
21812
22336
|
return {
|
|
21813
22337
|
id: row.id,
|
|
21814
|
-
|
|
22338
|
+
uid: row.uid,
|
|
21815
22339
|
factorType: row.factor_type,
|
|
21816
22340
|
secretEncrypted: row.secret_encrypted,
|
|
21817
22341
|
friendlyName: row.friendly_name ?? void 0,
|
|
@@ -21828,11 +22352,11 @@ var MfaService = class {
|
|
|
21828
22352
|
WHERE id = ${factorId}
|
|
21829
22353
|
`);
|
|
21830
22354
|
}
|
|
21831
|
-
async deleteMfaFactor(factorId,
|
|
22355
|
+
async deleteMfaFactor(factorId, uid) {
|
|
21832
22356
|
const tableName = this.qualify("mfa_factors");
|
|
21833
22357
|
await this.db.execute(sql`
|
|
21834
22358
|
DELETE FROM ${sql.raw(tableName)}
|
|
21835
|
-
WHERE id = ${factorId} AND
|
|
22359
|
+
WHERE id = ${factorId} AND uid = ${uid}
|
|
21836
22360
|
`);
|
|
21837
22361
|
}
|
|
21838
22362
|
async createMfaChallenge(factorId, ipAddress) {
|
|
@@ -21876,43 +22400,43 @@ var MfaService = class {
|
|
|
21876
22400
|
WHERE id = ${challengeId}
|
|
21877
22401
|
`);
|
|
21878
22402
|
}
|
|
21879
|
-
async createRecoveryCodes(
|
|
22403
|
+
async createRecoveryCodes(uid, codeHashes) {
|
|
21880
22404
|
const tableName = this.qualify("recovery_codes");
|
|
21881
22405
|
await this.db.execute(sql`
|
|
21882
|
-
DELETE FROM ${sql.raw(tableName)} WHERE
|
|
22406
|
+
DELETE FROM ${sql.raw(tableName)} WHERE uid = ${uid}
|
|
21883
22407
|
`);
|
|
21884
22408
|
for (const hash of codeHashes) await this.db.execute(sql`
|
|
21885
|
-
INSERT INTO ${sql.raw(tableName)} (
|
|
21886
|
-
VALUES (${
|
|
22409
|
+
INSERT INTO ${sql.raw(tableName)} (uid, code_hash)
|
|
22410
|
+
VALUES (${uid}, ${hash})
|
|
21887
22411
|
`);
|
|
21888
22412
|
}
|
|
21889
|
-
async useRecoveryCode(
|
|
22413
|
+
async useRecoveryCode(uid, codeHash) {
|
|
21890
22414
|
const tableName = this.qualify("recovery_codes");
|
|
21891
22415
|
return (await this.db.execute(sql`
|
|
21892
22416
|
UPDATE ${sql.raw(tableName)}
|
|
21893
22417
|
SET used_at = NOW()
|
|
21894
|
-
WHERE
|
|
22418
|
+
WHERE uid = ${uid} AND code_hash = ${codeHash} AND used_at IS NULL
|
|
21895
22419
|
RETURNING id
|
|
21896
22420
|
`)).rows.length > 0;
|
|
21897
22421
|
}
|
|
21898
|
-
async getUnusedRecoveryCodeCount(
|
|
22422
|
+
async getUnusedRecoveryCodeCount(uid) {
|
|
21899
22423
|
const tableName = this.qualify("recovery_codes");
|
|
21900
22424
|
return (await this.db.execute(sql`
|
|
21901
22425
|
SELECT COUNT(*)::int as count FROM ${sql.raw(tableName)}
|
|
21902
|
-
WHERE
|
|
22426
|
+
WHERE uid = ${uid} AND used_at IS NULL
|
|
21903
22427
|
`)).rows[0].count;
|
|
21904
22428
|
}
|
|
21905
|
-
async deleteAllRecoveryCodes(
|
|
22429
|
+
async deleteAllRecoveryCodes(uid) {
|
|
21906
22430
|
const tableName = this.qualify("recovery_codes");
|
|
21907
22431
|
await this.db.execute(sql`
|
|
21908
|
-
DELETE FROM ${sql.raw(tableName)} WHERE
|
|
22432
|
+
DELETE FROM ${sql.raw(tableName)} WHERE uid = ${uid}
|
|
21909
22433
|
`);
|
|
21910
22434
|
}
|
|
21911
|
-
async hasVerifiedMfaFactors(
|
|
22435
|
+
async hasVerifiedMfaFactors(uid) {
|
|
21912
22436
|
const tableName = this.qualify("mfa_factors");
|
|
21913
22437
|
return (await this.db.execute(sql`
|
|
21914
22438
|
SELECT COUNT(*)::int as count FROM ${sql.raw(tableName)}
|
|
21915
|
-
WHERE
|
|
22439
|
+
WHERE uid = ${uid} AND verified = TRUE
|
|
21916
22440
|
`)).rows[0].count > 0;
|
|
21917
22441
|
}
|
|
21918
22442
|
};
|
|
@@ -22152,6 +22676,20 @@ function patchPgArrayNullSafety(tables) {
|
|
|
22152
22676
|
if (patchedCount > 0) logger.debug(`[PgArray] Patched ${patchedCount} array column(s) for null-safety`);
|
|
22153
22677
|
}
|
|
22154
22678
|
//#endregion
|
|
22679
|
+
//#region src/schema/introspect-db-naming.ts
|
|
22680
|
+
/**
|
|
22681
|
+
* Naming helpers shared by the introspection modules. These live apart from
|
|
22682
|
+
* `introspect-db-logic.ts` because the inference pass needs them too, and
|
|
22683
|
+
* importing them from there would close a cycle back through this module.
|
|
22684
|
+
*/
|
|
22685
|
+
/**
|
|
22686
|
+
* Convert a snake_case name to a human-readable Title Case label.
|
|
22687
|
+
* e.g. "created_at" -> "Created At", "customer_id" -> "Customer Id"
|
|
22688
|
+
*/
|
|
22689
|
+
function humanize(snakeName) {
|
|
22690
|
+
return snakeName.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
22691
|
+
}
|
|
22692
|
+
//#endregion
|
|
22155
22693
|
//#region src/schema/introspect-db-logic.ts
|
|
22156
22694
|
var IRREGULAR_SINGULARS = {
|
|
22157
22695
|
people: "person",
|
|
@@ -22211,13 +22749,6 @@ function singularize(word) {
|
|
|
22211
22749
|
if (lower.endsWith("s") && !lower.endsWith("ss") && !lower.endsWith("us") && !lower.endsWith("is")) return word.slice(0, -1);
|
|
22212
22750
|
return word;
|
|
22213
22751
|
}
|
|
22214
|
-
/**
|
|
22215
|
-
* Convert a snake_case name to a human-readable Title Case label.
|
|
22216
|
-
* e.g. "created_at" -> "Created At", "customer_id" -> "Customer Id"
|
|
22217
|
-
*/
|
|
22218
|
-
function humanize(snakeName) {
|
|
22219
|
-
return snakeName.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
22220
|
-
}
|
|
22221
22752
|
function getIconForTable(tableName) {
|
|
22222
22753
|
const table = tableName.toLowerCase();
|
|
22223
22754
|
if (table.includes("user") || table.includes("account") || table.includes("member") || table.includes("customer") || table.includes("client") || table.includes("patient")) return "Users";
|
|
@@ -22738,6 +23269,11 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
22738
23269
|
} catch (err) {
|
|
22739
23270
|
logger.warn("⚠️ Could not initialize branch metadata table", { error: err });
|
|
22740
23271
|
}
|
|
23272
|
+
try {
|
|
23273
|
+
await realtimeService.configureChannelHistory(pgConfig.realtime?.channels);
|
|
23274
|
+
} catch (err) {
|
|
23275
|
+
logger.warn("⚠️ Could not initialize channel history tables — retained channels will not replay", { error: err });
|
|
23276
|
+
}
|
|
22741
23277
|
const directUrl = process.env.DATABASE_DIRECT_URL || pgConfig.connectionString;
|
|
22742
23278
|
const validModes = new Set([
|
|
22743
23279
|
"auto",
|