@rebasepro/server-postgres 0.9.1-canary.ff338b5 → 0.10.1-canary.18115ba
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -0
- package/dist/PostgresBackendDriver.d.ts +18 -0
- package/dist/PostgresBootstrapper.d.ts +7 -1
- package/dist/auth/services.d.ts +93 -54
- package/dist/index.es.js +929 -224
- package/dist/index.es.js.map +1 -1
- package/dist/schema/auth-bootstrap-sql.d.ts +1 -1
- package/dist/schema/auth-schema.d.ts +194 -24
- package/dist/schema/introspect-db-logic.d.ts +0 -5
- package/dist/schema/introspect-db-naming.d.ts +10 -0
- package/dist/security/policy-drift.d.ts +24 -0
- package/dist/security/rls-enforcement.d.ts +2 -2
- package/dist/services/channel-history.d.ts +118 -0
- package/dist/services/realtimeService.d.ts +69 -2
- package/package.json +7 -31
- package/src/PostgresBackendDriver.ts +56 -5
- package/src/PostgresBootstrapper.ts +18 -1
- package/src/auth/ensure-tables.ts +187 -19
- package/src/auth/services.ts +309 -170
- package/src/schema/auth-bootstrap-sql.ts +7 -1
- package/src/schema/auth-schema.ts +53 -15
- package/src/schema/introspect-db-inference.ts +1 -1
- package/src/schema/introspect-db-logic.ts +1 -10
- package/src/schema/introspect-db-naming.ts +15 -0
- package/src/schema/introspect-runtime.ts +1 -1
- package/src/security/policy-drift.test.ts +46 -0
- package/src/security/policy-drift.ts +70 -4
- package/src/security/rls-enforcement.ts +11 -5
- package/src/services/channel-history.ts +343 -0
- package/src/services/realtimeService.ts +198 -10
- package/src/websocket.ts +30 -11
package/dist/index.es.js
CHANGED
|
@@ -5,7 +5,7 @@ import { Client, Pool } from "pg";
|
|
|
5
5
|
import { drizzle } from "drizzle-orm/node-postgres";
|
|
6
6
|
import { ApiError, createEmailService, extractUserFromToken, loadCollectionsFromDirectory, logger, safeCompare } from "@rebasepro/server";
|
|
7
7
|
import { and, asc, count, desc, eq, getTableColumns, getTableName, gt, ilike, inArray, isTable, lt, or, relations, sql } from "drizzle-orm";
|
|
8
|
-
import { PgArray, PgChar, PgTable, PgText, PgVarchar, bigint, boolean, char, cidr, customType, date, doublePrecision, geometry, getTableConfig, inet, integer, interval, json, jsonb, line, macaddr, macaddr8, numeric, pgSchema, pgTable, point, primaryKey, real, smallint, text, time, timestamp, unique, uuid, varchar, vector } from "drizzle-orm/pg-core";
|
|
8
|
+
import { PgArray, PgChar, PgTable, PgText, PgVarchar, bigint, boolean, char, cidr, customType, date, doublePrecision, geometry, getTableConfig, index, inet, integer, interval, json, jsonb, line, macaddr, macaddr8, numeric, pgSchema, pgTable, point, primaryKey, real, smallint, text, time, timestamp, unique, uuid, varchar, vector } from "drizzle-orm/pg-core";
|
|
9
9
|
import fs, { promises } from "fs";
|
|
10
10
|
import path from "path";
|
|
11
11
|
import chokidar from "chokidar";
|
|
@@ -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);
|
|
@@ -8182,7 +8182,7 @@ async function ensureAppRole(run, schemas) {
|
|
|
8182
8182
|
* SECURITY: this function is only ever called on the **user** path (the server
|
|
8183
8183
|
* context uses the base/owner driver and never calls it). The default policies
|
|
8184
8184
|
* treat `auth.uid() IS NULL` as the trusted server context, and `auth.uid()`
|
|
8185
|
-
* is `NULLIF(current_setting('app.
|
|
8185
|
+
* is `NULLIF(current_setting('app.uid'), '')` — so an EMPTY user id would
|
|
8186
8186
|
* be read as NULL and silently escalate a user request to server privileges.
|
|
8187
8187
|
* Coerce empty/blank ids to `ANONYMOUS_USER_ID` here, at the single chokepoint,
|
|
8188
8188
|
* rather than trusting every caller (e.g. realtime subscription auth) to do it.
|
|
@@ -8190,14 +8190,15 @@ async function ensureAppRole(run, schemas) {
|
|
|
8190
8190
|
* semantics: it is why `auth.uid() IS NOT NULL` is true for anonymous requests.
|
|
8191
8191
|
*/
|
|
8192
8192
|
async function applyAuthContext(tx, auth, userRole) {
|
|
8193
|
-
const
|
|
8193
|
+
const uid = typeof auth.uid === "string" && auth.uid.trim() !== "" ? auth.uid : ANONYMOUS_USER_ID;
|
|
8194
8194
|
const normalizedRoles = auth.roles.map((r) => typeof r === "string" ? r : r?.id ?? String(r));
|
|
8195
8195
|
await tx.execute(sql`
|
|
8196
8196
|
SELECT
|
|
8197
|
-
set_config('app.
|
|
8197
|
+
set_config('app.uid', ${uid}, true),
|
|
8198
|
+
set_config('app.user_id', ${uid}, true),
|
|
8198
8199
|
set_config('app.user_roles', ${normalizedRoles.join(",")}, true),
|
|
8199
8200
|
set_config('app.jwt', ${JSON.stringify({
|
|
8200
|
-
sub:
|
|
8201
|
+
sub: uid,
|
|
8201
8202
|
roles: auth.roles
|
|
8202
8203
|
})}, true)
|
|
8203
8204
|
`);
|
|
@@ -8343,6 +8344,7 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
8343
8344
|
executeSql: (...args) => this.executeSql(...args),
|
|
8344
8345
|
fetchAvailableDatabases: () => this.fetchAvailableDatabases(),
|
|
8345
8346
|
fetchAvailableRoles: () => this.fetchAvailableRoles(),
|
|
8347
|
+
fetchApplicationRoles: () => this.fetchApplicationRoles(),
|
|
8346
8348
|
fetchCurrentDatabase: () => this.fetchCurrentDatabase(),
|
|
8347
8349
|
fetchUnmappedTables: (...args) => this.fetchUnmappedTables(...args),
|
|
8348
8350
|
fetchTableMetadata: (...args) => this.fetchTableMetadata(...args),
|
|
@@ -8996,6 +8998,45 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
8996
8998
|
async fetchAvailableRoles() {
|
|
8997
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);
|
|
8998
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
|
+
}
|
|
8999
9040
|
async fetchCurrentDatabase() {
|
|
9000
9041
|
return this.poolManager?.defaultDatabaseName;
|
|
9001
9042
|
}
|
|
@@ -9137,15 +9178,15 @@ var AuthenticatedPostgresBackendDriver = class {
|
|
|
9137
9178
|
async withTransaction(operation, options) {
|
|
9138
9179
|
const pendingNotifications = [];
|
|
9139
9180
|
const result = await this.delegate.db.transaction(async (tx) => {
|
|
9140
|
-
let
|
|
9141
|
-
if (!
|
|
9181
|
+
let uid = this.user?.uid;
|
|
9182
|
+
if (!uid) {
|
|
9142
9183
|
logger.warn("[DataDriver] User ID (uid) is missing for authenticated delegate. Using 'anonymous'. User object", { detail: this.user });
|
|
9143
|
-
|
|
9184
|
+
uid = "anonymous";
|
|
9144
9185
|
}
|
|
9145
9186
|
const userRoles = this.user?.roles ?? [];
|
|
9146
9187
|
if (!this.user?.roles) logger.warn("[DataDriver] User roles are missing for authenticated delegate. Using empty array. User object", { detail: this.user });
|
|
9147
9188
|
await applyAuthContext(tx, {
|
|
9148
|
-
|
|
9189
|
+
uid,
|
|
9149
9190
|
roles: userRoles
|
|
9150
9191
|
}, this.delegate.rlsUserRole);
|
|
9151
9192
|
const txEntityService = new DataService(tx, this.delegate.registry);
|
|
@@ -9172,7 +9213,7 @@ var AuthenticatedPostgresBackendDriver = class {
|
|
|
9172
9213
|
*/
|
|
9173
9214
|
injectAuthContext(unsubscribe) {
|
|
9174
9215
|
const authContext = {
|
|
9175
|
-
|
|
9216
|
+
uid: this.user?.uid || "anonymous",
|
|
9176
9217
|
roles: this.user?.roles ?? []
|
|
9177
9218
|
};
|
|
9178
9219
|
const entries = Array.from(this.delegate.realtimeService.subscriptions.entries());
|
|
@@ -9308,27 +9349,65 @@ function createAuthSchema(usersSchemaName = "rebase") {
|
|
|
9308
9349
|
isAnonymous: boolean("is_anonymous").default(false).notNull(),
|
|
9309
9350
|
roles: text("roles").array().default([]).notNull(),
|
|
9310
9351
|
metadata: jsonb("metadata").$type().default({}).notNull(),
|
|
9352
|
+
/**
|
|
9353
|
+
* Sessions that began before this instant are dead, whatever tokens
|
|
9354
|
+
* they still hold. Password resets and admin revocations stamp it.
|
|
9355
|
+
*
|
|
9356
|
+
* Deleting the user's refresh-token rows (which we also do) is not
|
|
9357
|
+
* sufficient on its own: a request already in flight can insert a
|
|
9358
|
+
* freshly rotated row microseconds after the delete and survive it.
|
|
9359
|
+
* This timestamp cannot be outrun that way — it is checked against
|
|
9360
|
+
* `refresh_tokens.session_started_at`, which rotation carries forward.
|
|
9361
|
+
*/
|
|
9362
|
+
tokensValidAfter: timestamp("tokens_valid_after"),
|
|
9311
9363
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
9312
9364
|
updatedAt: timestamp("updated_at").defaultNow().notNull()
|
|
9313
9365
|
});
|
|
9314
9366
|
/**
|
|
9315
|
-
* Refresh tokens for long-lived sessions
|
|
9367
|
+
* Refresh tokens for long-lived sessions.
|
|
9368
|
+
*
|
|
9369
|
+
* A row is one token, not one device. Every token minted from the same
|
|
9370
|
+
* sign-in shares a `sessionId`, and rotation ADDS a row rather than
|
|
9371
|
+
* replacing one: the superseded token stays on file, flagged `revoked`
|
|
9372
|
+
* with a `rotatedAt` stamp. That record is what lets the refresh endpoint
|
|
9373
|
+
* tell a client replaying a token it never got an answer for (a response
|
|
9374
|
+
* lost to a redeploy, a second tab racing on boot) apart from a stranger
|
|
9375
|
+
* presenting a token that was never issued. Deleting the old row on sight
|
|
9376
|
+
* — the previous behaviour — made those two cases indistinguishable, and
|
|
9377
|
+
* the legitimate one is overwhelmingly the common one.
|
|
9378
|
+
*
|
|
9379
|
+
* There is deliberately NO unique constraint on (uid, user_agent,
|
|
9380
|
+
* ip_address). Keying a session on the IP meant one row per "device",
|
|
9381
|
+
* so a second browser profile behind the same NAT silently evicted the
|
|
9382
|
+
* first, and a phone changing networks orphaned a row on every hop.
|
|
9383
|
+
* User agent and IP are descriptive metadata for the sessions list;
|
|
9384
|
+
* `sessionId` is the identity.
|
|
9316
9385
|
*/
|
|
9317
9386
|
const refreshTokens = tableCreator("refresh_tokens", {
|
|
9318
9387
|
id: uuid("id").defaultRandom().primaryKey(),
|
|
9319
|
-
|
|
9388
|
+
uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
9389
|
+
sessionId: uuid("session_id").defaultRandom().notNull(),
|
|
9320
9390
|
tokenHash: varchar("token_hash", { length: 255 }).notNull().unique(),
|
|
9321
9391
|
expiresAt: timestamp("expires_at").notNull(),
|
|
9392
|
+
revoked: boolean("revoked").default(false).notNull(),
|
|
9393
|
+
rotatedAt: timestamp("rotated_at"),
|
|
9394
|
+
/**
|
|
9395
|
+
* When the sign-in this token descends from happened — carried across
|
|
9396
|
+
* every rotation, unlike `createdAt`. `users.tokensValidAfter` is
|
|
9397
|
+
* compared against this, so a revocation cannot be outrun by a token
|
|
9398
|
+
* that rotates immediately after it.
|
|
9399
|
+
*/
|
|
9400
|
+
sessionStartedAt: timestamp("session_started_at").defaultNow().notNull(),
|
|
9322
9401
|
userAgent: varchar("user_agent", { length: 500 }),
|
|
9323
9402
|
ipAddress: varchar("ip_address", { length: 45 }),
|
|
9324
9403
|
createdAt: timestamp("created_at").defaultNow().notNull()
|
|
9325
|
-
}, (table) => ({
|
|
9404
|
+
}, (table) => ({ sessionIdx: index("idx_refresh_tokens_session").on(table.sessionId) }));
|
|
9326
9405
|
/**
|
|
9327
9406
|
* Password reset tokens for forgot password flow
|
|
9328
9407
|
*/
|
|
9329
9408
|
const passwordResetTokens = tableCreator("password_reset_tokens", {
|
|
9330
9409
|
id: uuid("id").defaultRandom().primaryKey(),
|
|
9331
|
-
|
|
9410
|
+
uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
9332
9411
|
tokenHash: varchar("token_hash", { length: 255 }).notNull().unique(),
|
|
9333
9412
|
expiresAt: timestamp("expires_at").notNull(),
|
|
9334
9413
|
usedAt: timestamp("used_at"),
|
|
@@ -9347,7 +9426,7 @@ function createAuthSchema(usersSchemaName = "rebase") {
|
|
|
9347
9426
|
*/
|
|
9348
9427
|
const userIdentities = tableCreator("user_identities", {
|
|
9349
9428
|
id: uuid("id").defaultRandom().primaryKey(),
|
|
9350
|
-
|
|
9429
|
+
uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
9351
9430
|
provider: varchar("provider", { length: 50 }).notNull(),
|
|
9352
9431
|
providerId: varchar("provider_id", { length: 255 }).notNull(),
|
|
9353
9432
|
profileData: jsonb("profile_data"),
|
|
@@ -9359,7 +9438,7 @@ function createAuthSchema(usersSchemaName = "rebase") {
|
|
|
9359
9438
|
*/
|
|
9360
9439
|
const mfaFactors = tableCreator("mfa_factors", {
|
|
9361
9440
|
id: uuid("id").defaultRandom().primaryKey(),
|
|
9362
|
-
|
|
9441
|
+
uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
9363
9442
|
factorType: varchar("factor_type", { length: 20 }).notNull(),
|
|
9364
9443
|
secretEncrypted: varchar("secret_encrypted", { length: 500 }).notNull(),
|
|
9365
9444
|
friendlyName: varchar("friendly_name", { length: 255 }),
|
|
@@ -9385,14 +9464,14 @@ function createAuthSchema(usersSchemaName = "rebase") {
|
|
|
9385
9464
|
}),
|
|
9386
9465
|
recoveryCodes: tableCreator("recovery_codes", {
|
|
9387
9466
|
id: uuid("id").defaultRandom().primaryKey(),
|
|
9388
|
-
|
|
9467
|
+
uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
9389
9468
|
codeHash: varchar("code_hash", { length: 255 }).notNull(),
|
|
9390
9469
|
usedAt: timestamp("used_at"),
|
|
9391
9470
|
createdAt: timestamp("created_at").defaultNow().notNull()
|
|
9392
9471
|
}),
|
|
9393
9472
|
magicLinkTokens: tableCreator("magic_link_tokens", {
|
|
9394
9473
|
id: uuid("id").defaultRandom().primaryKey(),
|
|
9395
|
-
|
|
9474
|
+
uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
9396
9475
|
tokenHash: varchar("token_hash", { length: 255 }).notNull().unique(),
|
|
9397
9476
|
expiresAt: timestamp("expires_at").notNull(),
|
|
9398
9477
|
usedAt: timestamp("used_at"),
|
|
@@ -9420,20 +9499,20 @@ var usersRelations = relations(users, ({ many }) => ({
|
|
|
9420
9499
|
magicLinkTokens: many(magicLinkTokens)
|
|
9421
9500
|
}));
|
|
9422
9501
|
var refreshTokensRelations = relations(refreshTokens, ({ one }) => ({ user: one(users, {
|
|
9423
|
-
fields: [refreshTokens.
|
|
9502
|
+
fields: [refreshTokens.uid],
|
|
9424
9503
|
references: [users.id]
|
|
9425
9504
|
}) }));
|
|
9426
9505
|
var passwordResetTokensRelations = relations(passwordResetTokens, ({ one }) => ({ user: one(users, {
|
|
9427
|
-
fields: [passwordResetTokens.
|
|
9506
|
+
fields: [passwordResetTokens.uid],
|
|
9428
9507
|
references: [users.id]
|
|
9429
9508
|
}) }));
|
|
9430
9509
|
var userIdentitiesRelations = relations(userIdentities, ({ one }) => ({ user: one(users, {
|
|
9431
|
-
fields: [userIdentities.
|
|
9510
|
+
fields: [userIdentities.uid],
|
|
9432
9511
|
references: [users.id]
|
|
9433
9512
|
}) }));
|
|
9434
9513
|
var mfaFactorsRelations = relations(mfaFactors, ({ one, many }) => ({
|
|
9435
9514
|
user: one(users, {
|
|
9436
|
-
fields: [mfaFactors.
|
|
9515
|
+
fields: [mfaFactors.uid],
|
|
9437
9516
|
references: [users.id]
|
|
9438
9517
|
}),
|
|
9439
9518
|
challenges: many(mfaChallenges)
|
|
@@ -9443,11 +9522,11 @@ var mfaChallengesRelations = relations(mfaChallenges, ({ one }) => ({ factor: on
|
|
|
9443
9522
|
references: [mfaFactors.id]
|
|
9444
9523
|
}) }));
|
|
9445
9524
|
var recoveryCodesRelations = relations(recoveryCodes, ({ one }) => ({ user: one(users, {
|
|
9446
|
-
fields: [recoveryCodes.
|
|
9525
|
+
fields: [recoveryCodes.uid],
|
|
9447
9526
|
references: [users.id]
|
|
9448
9527
|
}) }));
|
|
9449
9528
|
var magicLinkTokensRelations = relations(magicLinkTokens, ({ one }) => ({ user: one(users, {
|
|
9450
|
-
fields: [magicLinkTokens.
|
|
9529
|
+
fields: [magicLinkTokens.uid],
|
|
9451
9530
|
references: [users.id]
|
|
9452
9531
|
}) }));
|
|
9453
9532
|
//#endregion
|
|
@@ -10280,6 +10359,267 @@ var CdcListener = class CdcListener {
|
|
|
10280
10359
|
}
|
|
10281
10360
|
};
|
|
10282
10361
|
//#endregion
|
|
10362
|
+
//#region src/services/channel-history.ts
|
|
10363
|
+
/**
|
|
10364
|
+
* Ordered, replayable per-channel message history.
|
|
10365
|
+
*
|
|
10366
|
+
* Broadcast on its own is fire-and-forget to whoever is connected at the
|
|
10367
|
+
* instant it is sent: fine for presence and for "someone saved" notifications,
|
|
10368
|
+
* not enough for op-based collaborative editing, where a client that blinks
|
|
10369
|
+
* out for two seconds has to resync a whole document rather than catch up on
|
|
10370
|
+
* the four operations it missed. This adds the missing half — every retained
|
|
10371
|
+
* broadcast gets a per-channel sequence number, and a client can ask for
|
|
10372
|
+
* everything after the last one it saw.
|
|
10373
|
+
*
|
|
10374
|
+
* Three decisions worth stating, because each rules out a simpler-looking one:
|
|
10375
|
+
*
|
|
10376
|
+
* - **Retention is server-side and opt-in.** A channel is created by whoever
|
|
10377
|
+
* names it, so a client-supplied history depth would let any visitor commit
|
|
10378
|
+
* the backend to unbounded storage. And presence channels — the common case
|
|
10379
|
+
* — must not pay for this: with no rules configured nothing is written, no
|
|
10380
|
+
* table is created, and `broadcast` runs exactly the code it ran before.
|
|
10381
|
+
*
|
|
10382
|
+
* - **Sequence numbers come from the database, not from a counter in this
|
|
10383
|
+
* process.** They have to survive a restart and be shared across instances;
|
|
10384
|
+
* an in-memory counter would restart at 1 after a deploy and hand a
|
|
10385
|
+
* reconnecting client a replay from the wrong era, silently.
|
|
10386
|
+
*
|
|
10387
|
+
* - **The cursor row outlives the messages it numbered.** Pruning is what
|
|
10388
|
+
* makes retention affordable, but pruning the cursor along with the messages
|
|
10389
|
+
* would restart the sequence and make `sinceSeq` mean something different
|
|
10390
|
+
* before and after — the worst kind of bug, because replay would still
|
|
10391
|
+
* return rows and they would look plausible. Cursors are tiny and are kept
|
|
10392
|
+
* forever; see {@link prune}, which touches only `channel_messages`.
|
|
10393
|
+
*/
|
|
10394
|
+
/** How many messages a replay returns when the caller does not say. */
|
|
10395
|
+
var DEFAULT_REPLAY_LIMIT = 200;
|
|
10396
|
+
/**
|
|
10397
|
+
* Hard ceiling on one replay, whatever the caller asks for.
|
|
10398
|
+
*
|
|
10399
|
+
* A reconnecting client names its own `limit`, so this is the only thing
|
|
10400
|
+
* standing between a stale `sinceSeq` and a single frame carrying a channel's
|
|
10401
|
+
* entire retained history. A client that is further behind than this is told so
|
|
10402
|
+
* via `latestSeq` and can decide to resync wholesale instead of paging.
|
|
10403
|
+
*/
|
|
10404
|
+
var MAX_REPLAY_LIMIT = 1e3;
|
|
10405
|
+
/** Minimum gap between two prunes of the same channel. */
|
|
10406
|
+
var PRUNE_THROTTLE_MS = 3e4;
|
|
10407
|
+
/**
|
|
10408
|
+
* Parse a retention TTL into milliseconds.
|
|
10409
|
+
*
|
|
10410
|
+
* Accepts a raw millisecond count or a short duration string (`"30s"`, `"15m"`,
|
|
10411
|
+
* `"24h"`, `"7d"`). Returns undefined for anything unparseable, which the
|
|
10412
|
+
* caller treats as "no TTL" — a misspelt duration must not silently become an
|
|
10413
|
+
* aggressive one.
|
|
10414
|
+
*/
|
|
10415
|
+
function parseTtlMs(ttl) {
|
|
10416
|
+
if (ttl === void 0 || ttl === null) return void 0;
|
|
10417
|
+
if (typeof ttl === "number") return Number.isFinite(ttl) && ttl > 0 ? ttl : void 0;
|
|
10418
|
+
const match = /^\s*(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)\s*$/i.exec(ttl);
|
|
10419
|
+
if (!match) {
|
|
10420
|
+
logger.warn(`⚠️ [ChannelHistory] Ignoring unparseable retention ttl "${ttl}" — expected e.g. "30s", "15m", "24h", "7d".`);
|
|
10421
|
+
return;
|
|
10422
|
+
}
|
|
10423
|
+
const value = parseFloat(match[1]);
|
|
10424
|
+
const unit = match[2].toLowerCase();
|
|
10425
|
+
const ms = value * (unit === "ms" ? 1 : unit === "s" ? 1e3 : unit === "m" ? 6e4 : unit === "h" ? 36e5 : 864e5);
|
|
10426
|
+
return ms > 0 ? ms : void 0;
|
|
10427
|
+
}
|
|
10428
|
+
/**
|
|
10429
|
+
* Whether `channel` is covered by `rule`.
|
|
10430
|
+
*
|
|
10431
|
+
* Exact match, or a trailing `*` acting as a prefix. Not a general glob: this
|
|
10432
|
+
* decides what reaches disk, and a pattern language whose reach is not obvious
|
|
10433
|
+
* at a glance is the wrong tool for that job.
|
|
10434
|
+
*/
|
|
10435
|
+
function channelMatchesRule(channel, rule) {
|
|
10436
|
+
const pattern = rule.match;
|
|
10437
|
+
if (pattern === "*") return true;
|
|
10438
|
+
if (pattern.endsWith("*")) return channel.startsWith(pattern.slice(0, -1));
|
|
10439
|
+
return channel === pattern;
|
|
10440
|
+
}
|
|
10441
|
+
/**
|
|
10442
|
+
* Persistence and replay for retained channels.
|
|
10443
|
+
*
|
|
10444
|
+
* Inert unless constructed with at least one rule: {@link enabled} is false,
|
|
10445
|
+
* {@link ensureTables} does nothing, and {@link retentionFor} answers undefined
|
|
10446
|
+
* for every channel, so the realtime service never reaches the SQL below.
|
|
10447
|
+
*/
|
|
10448
|
+
var ChannelHistoryStore = class {
|
|
10449
|
+
db;
|
|
10450
|
+
rules;
|
|
10451
|
+
/** Resolved rule per channel name, so the match runs once per channel. */
|
|
10452
|
+
resolved = /* @__PURE__ */ new Map();
|
|
10453
|
+
/** Channel → timestamp of its last prune, for {@link PRUNE_THROTTLE_MS}. */
|
|
10454
|
+
lastPruned = /* @__PURE__ */ new Map();
|
|
10455
|
+
tablesReady = false;
|
|
10456
|
+
constructor(db, rules = []) {
|
|
10457
|
+
this.db = db;
|
|
10458
|
+
this.rules = rules.filter((rule) => {
|
|
10459
|
+
if (!rule?.match) {
|
|
10460
|
+
logger.warn("⚠️ [ChannelHistory] Ignoring a retention rule with no `match`.");
|
|
10461
|
+
return false;
|
|
10462
|
+
}
|
|
10463
|
+
if (!(rule.limit !== void 0 || rule.ttl !== void 0)) {
|
|
10464
|
+
logger.warn(`⚠️ [ChannelHistory] Retention rule "${rule.match}" sets neither \`limit\` nor \`ttl\` — ignoring it, as it would retain forever.`);
|
|
10465
|
+
return false;
|
|
10466
|
+
}
|
|
10467
|
+
return true;
|
|
10468
|
+
});
|
|
10469
|
+
}
|
|
10470
|
+
/** Whether any channel retains anything at all. */
|
|
10471
|
+
get enabled() {
|
|
10472
|
+
return this.rules.length > 0;
|
|
10473
|
+
}
|
|
10474
|
+
/**
|
|
10475
|
+
* The retention that applies to `channel`, or undefined when none does.
|
|
10476
|
+
*
|
|
10477
|
+
* First matching rule wins, so callers order them most-specific first.
|
|
10478
|
+
*/
|
|
10479
|
+
retentionFor(channel) {
|
|
10480
|
+
if (!this.enabled || !channel) return void 0;
|
|
10481
|
+
const cached = this.resolved.get(channel);
|
|
10482
|
+
if (cached !== void 0) return cached ?? void 0;
|
|
10483
|
+
const rule = this.rules.find((r) => channelMatchesRule(channel, r));
|
|
10484
|
+
const resolved = rule ? {
|
|
10485
|
+
limit: rule.limit,
|
|
10486
|
+
ttlMs: parseTtlMs(rule.ttl)
|
|
10487
|
+
} : null;
|
|
10488
|
+
this.resolved.set(channel, resolved);
|
|
10489
|
+
return resolved ?? void 0;
|
|
10490
|
+
}
|
|
10491
|
+
/**
|
|
10492
|
+
* Create the history tables. Idempotent, and a no-op when no rule is set —
|
|
10493
|
+
* a deployment that never retains anything gets no schema for it.
|
|
10494
|
+
*/
|
|
10495
|
+
async ensureTables() {
|
|
10496
|
+
if (!this.enabled || this.tablesReady) return;
|
|
10497
|
+
await this.db.execute(sql`CREATE SCHEMA IF NOT EXISTS rebase`);
|
|
10498
|
+
await this.db.execute(sql`
|
|
10499
|
+
CREATE TABLE IF NOT EXISTS rebase.channel_messages (
|
|
10500
|
+
channel TEXT NOT NULL,
|
|
10501
|
+
seq BIGINT NOT NULL,
|
|
10502
|
+
event TEXT NOT NULL,
|
|
10503
|
+
payload JSONB,
|
|
10504
|
+
sender_id TEXT,
|
|
10505
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
10506
|
+
PRIMARY KEY (channel, seq)
|
|
10507
|
+
)
|
|
10508
|
+
`);
|
|
10509
|
+
await this.db.execute(sql`
|
|
10510
|
+
CREATE INDEX IF NOT EXISTS idx_channel_messages_created
|
|
10511
|
+
ON rebase.channel_messages (created_at)
|
|
10512
|
+
`);
|
|
10513
|
+
await this.db.execute(sql`
|
|
10514
|
+
CREATE TABLE IF NOT EXISTS rebase.channel_cursors (
|
|
10515
|
+
channel TEXT PRIMARY KEY,
|
|
10516
|
+
last_seq BIGINT NOT NULL
|
|
10517
|
+
)
|
|
10518
|
+
`);
|
|
10519
|
+
this.tablesReady = true;
|
|
10520
|
+
logger.info(`✅ [ChannelHistory] Retained channels ready (${this.rules.length} rule(s)).`);
|
|
10521
|
+
}
|
|
10522
|
+
/**
|
|
10523
|
+
* Append a broadcast and return the sequence number it was given.
|
|
10524
|
+
*
|
|
10525
|
+
* The sequence is allocated by the same statement that stores the message,
|
|
10526
|
+
* so a crash between the two is not a possibility. `ON CONFLICT DO UPDATE`
|
|
10527
|
+
* takes a row lock on the channel's cursor, which is what makes concurrent
|
|
10528
|
+
* broadcasts to one channel line up in a single order — and what keeps
|
|
10529
|
+
* different channels from contending with each other at all.
|
|
10530
|
+
*/
|
|
10531
|
+
async append(channel, event, payload, senderId) {
|
|
10532
|
+
const row = (await this.db.execute(sql`
|
|
10533
|
+
WITH next AS (
|
|
10534
|
+
INSERT INTO rebase.channel_cursors (channel, last_seq)
|
|
10535
|
+
VALUES (${channel}, 1)
|
|
10536
|
+
ON CONFLICT (channel)
|
|
10537
|
+
DO UPDATE SET last_seq = rebase.channel_cursors.last_seq + 1
|
|
10538
|
+
RETURNING last_seq
|
|
10539
|
+
)
|
|
10540
|
+
INSERT INTO rebase.channel_messages (channel, seq, event, payload, sender_id)
|
|
10541
|
+
SELECT ${channel}, next.last_seq, ${event}, ${JSON.stringify(payload ?? null)}::jsonb, ${senderId ?? null}
|
|
10542
|
+
FROM next
|
|
10543
|
+
RETURNING seq, created_at
|
|
10544
|
+
`)).rows[0];
|
|
10545
|
+
if (!row) throw new Error(`Failed to append to channel history for "${channel}"`);
|
|
10546
|
+
return {
|
|
10547
|
+
seq: Number(row.seq),
|
|
10548
|
+
at: row.created_at instanceof Date ? row.created_at.toISOString() : String(row.created_at)
|
|
10549
|
+
};
|
|
10550
|
+
}
|
|
10551
|
+
/**
|
|
10552
|
+
* Everything retained for `channel` after `sinceSeq`, oldest first.
|
|
10553
|
+
*
|
|
10554
|
+
* `latestSeq` is reported whether or not the messages were capped, so a
|
|
10555
|
+
* client that is further behind than one page can tell.
|
|
10556
|
+
*/
|
|
10557
|
+
async replay(channel, sinceSeq = 0, limit = DEFAULT_REPLAY_LIMIT) {
|
|
10558
|
+
const capped = Math.max(1, Math.min(Math.floor(limit) || DEFAULT_REPLAY_LIMIT, MAX_REPLAY_LIMIT));
|
|
10559
|
+
const after = Number.isFinite(sinceSeq) && sinceSeq > 0 ? Math.floor(sinceSeq) : 0;
|
|
10560
|
+
const messages = (await this.db.execute(sql`
|
|
10561
|
+
SELECT seq, event, payload, sender_id, created_at
|
|
10562
|
+
FROM rebase.channel_messages
|
|
10563
|
+
WHERE channel = ${channel} AND seq > ${after}
|
|
10564
|
+
ORDER BY seq ASC
|
|
10565
|
+
LIMIT ${capped}
|
|
10566
|
+
`)).rows.map((row) => ({
|
|
10567
|
+
seq: Number(row.seq),
|
|
10568
|
+
event: row.event,
|
|
10569
|
+
payload: row.payload,
|
|
10570
|
+
senderId: row.sender_id ?? void 0,
|
|
10571
|
+
at: row.created_at instanceof Date ? row.created_at.toISOString() : String(row.created_at)
|
|
10572
|
+
}));
|
|
10573
|
+
const cursorRow = (await this.db.execute(sql`
|
|
10574
|
+
SELECT last_seq FROM rebase.channel_cursors WHERE channel = ${channel}
|
|
10575
|
+
`)).rows[0];
|
|
10576
|
+
return {
|
|
10577
|
+
messages,
|
|
10578
|
+
latestSeq: cursorRow ? Number(cursorRow.last_seq) : 0
|
|
10579
|
+
};
|
|
10580
|
+
}
|
|
10581
|
+
/**
|
|
10582
|
+
* Enforce a channel's retention bounds.
|
|
10583
|
+
*
|
|
10584
|
+
* Throttled per channel, so a burst of operations prunes once rather than
|
|
10585
|
+
* once per message — the cost then tracks elapsed time instead of write
|
|
10586
|
+
* volume, which is what makes retention affordable on a hot channel.
|
|
10587
|
+
*/
|
|
10588
|
+
async prune(channel, retention) {
|
|
10589
|
+
const now = Date.now();
|
|
10590
|
+
if (now - (this.lastPruned.get(channel) ?? 0) < PRUNE_THROTTLE_MS) return 0;
|
|
10591
|
+
this.lastPruned.set(channel, now);
|
|
10592
|
+
let deleted = 0;
|
|
10593
|
+
if (retention.ttlMs !== void 0) {
|
|
10594
|
+
const result = await this.db.execute(sql`
|
|
10595
|
+
DELETE FROM rebase.channel_messages
|
|
10596
|
+
WHERE channel = ${channel}
|
|
10597
|
+
AND created_at < NOW() - MAKE_INTERVAL(secs => ${retention.ttlMs / 1e3})
|
|
10598
|
+
`);
|
|
10599
|
+
deleted += result.rowCount ?? 0;
|
|
10600
|
+
}
|
|
10601
|
+
if (retention.limit !== void 0 && retention.limit > 0) {
|
|
10602
|
+
const result = await this.db.execute(sql`
|
|
10603
|
+
DELETE FROM rebase.channel_messages
|
|
10604
|
+
WHERE channel = ${channel}
|
|
10605
|
+
AND seq <= (
|
|
10606
|
+
SELECT seq FROM rebase.channel_messages
|
|
10607
|
+
WHERE channel = ${channel}
|
|
10608
|
+
ORDER BY seq DESC
|
|
10609
|
+
OFFSET ${Math.floor(retention.limit)} LIMIT 1
|
|
10610
|
+
)
|
|
10611
|
+
`);
|
|
10612
|
+
deleted += result.rowCount ?? 0;
|
|
10613
|
+
}
|
|
10614
|
+
return deleted;
|
|
10615
|
+
}
|
|
10616
|
+
/** Forget throttle and match caches. Called on shutdown. */
|
|
10617
|
+
clear() {
|
|
10618
|
+
this.resolved.clear();
|
|
10619
|
+
this.lastPruned.clear();
|
|
10620
|
+
}
|
|
10621
|
+
};
|
|
10622
|
+
//#endregion
|
|
10283
10623
|
//#region src/services/realtimeService.ts
|
|
10284
10624
|
/** Channel name used for Postgres LISTEN/NOTIFY cross-instance realtime. */
|
|
10285
10625
|
var PG_NOTIFY_CHANNEL = "rebase_entity_changes";
|
|
@@ -10295,6 +10635,26 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
10295
10635
|
clients = /* @__PURE__ */ new Map();
|
|
10296
10636
|
channels = /* @__PURE__ */ new Map();
|
|
10297
10637
|
presence = /* @__PURE__ */ new Map();
|
|
10638
|
+
/**
|
|
10639
|
+
* Ordered, replayable history for channels that opt into it.
|
|
10640
|
+
*
|
|
10641
|
+
* Undefined until {@link configureChannelHistory} is called, and inert even
|
|
10642
|
+
* then unless retention rules were supplied — so presence and ephemeral
|
|
10643
|
+
* notification channels never touch it. See `channel-history.ts`.
|
|
10644
|
+
*/
|
|
10645
|
+
channelHistory;
|
|
10646
|
+
/**
|
|
10647
|
+
* One promise chain per retained channel, so that assigning a sequence
|
|
10648
|
+
* number and fanning the message out happen in the same order for every
|
|
10649
|
+
* message on that channel.
|
|
10650
|
+
*
|
|
10651
|
+
* Without it, two concurrent broadcasts can be numbered 4 and 5 by the
|
|
10652
|
+
* database and still reach subscribers as 5 then 4 — live order and replay
|
|
10653
|
+
* order would disagree, which is exactly the divergence sequence numbers
|
|
10654
|
+
* are supposed to rule out. Keyed by channel, so unrelated channels never
|
|
10655
|
+
* wait on each other.
|
|
10656
|
+
*/
|
|
10657
|
+
channelSendQueues = /* @__PURE__ */ new Map();
|
|
10298
10658
|
presenceInterval;
|
|
10299
10659
|
static PRESENCE_TIMEOUT_MS = 3e4;
|
|
10300
10660
|
dataService;
|
|
@@ -10471,6 +10831,9 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
10471
10831
|
case "broadcast":
|
|
10472
10832
|
this.broadcastToChannel(clientId, payload?.channel, payload?.event, payload?.payload);
|
|
10473
10833
|
break;
|
|
10834
|
+
case "channel_history":
|
|
10835
|
+
await this.handleChannelHistoryRequest(clientId, payload?.channel, payload?.sinceSeq, payload?.limit);
|
|
10836
|
+
break;
|
|
10474
10837
|
case "presence_track":
|
|
10475
10838
|
this.joinChannel(clientId, payload?.channel);
|
|
10476
10839
|
this.trackPresence(clientId, payload?.channel, payload?.state ?? {});
|
|
@@ -10681,12 +11044,12 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
10681
11044
|
if (this.driver) {
|
|
10682
11045
|
const collection = this.registry.getCollectionByPath(notifyPath);
|
|
10683
11046
|
const activeAuth = authContext || {
|
|
10684
|
-
|
|
11047
|
+
uid: "anon",
|
|
10685
11048
|
roles: ["anon"]
|
|
10686
11049
|
};
|
|
10687
11050
|
return await this.db.transaction(async (tx) => {
|
|
10688
11051
|
await applyAuthContext(tx, {
|
|
10689
|
-
|
|
11052
|
+
uid: activeAuth.uid,
|
|
10690
11053
|
roles: activeAuth.roles
|
|
10691
11054
|
}, this.rlsUserRole);
|
|
10692
11055
|
const txEntityService = new DataService(tx, this.registry);
|
|
@@ -10718,7 +11081,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
10718
11081
|
if (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead) {
|
|
10719
11082
|
const contextForCallback = {
|
|
10720
11083
|
user: {
|
|
10721
|
-
uid: activeAuth.
|
|
11084
|
+
uid: activeAuth.uid,
|
|
10722
11085
|
roles: activeAuth.roles
|
|
10723
11086
|
},
|
|
10724
11087
|
driver: this.driver,
|
|
@@ -10810,12 +11173,12 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
10810
11173
|
if (this.driver) {
|
|
10811
11174
|
const collection = this.registry.getCollectionByPath(notifyPath);
|
|
10812
11175
|
const activeAuth = authContext || {
|
|
10813
|
-
|
|
11176
|
+
uid: "anon",
|
|
10814
11177
|
roles: ["anon"]
|
|
10815
11178
|
};
|
|
10816
11179
|
return await this.db.transaction(async (tx) => {
|
|
10817
11180
|
await applyAuthContext(tx, {
|
|
10818
|
-
|
|
11181
|
+
uid: activeAuth.uid,
|
|
10819
11182
|
roles: activeAuth.roles
|
|
10820
11183
|
}, this.rlsUserRole);
|
|
10821
11184
|
let processedEntity = await new DataService(tx, this.registry).fetchOne(notifyPath, id, collection?.databaseId);
|
|
@@ -10831,7 +11194,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
10831
11194
|
if (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead) {
|
|
10832
11195
|
const contextForCallback = {
|
|
10833
11196
|
user: {
|
|
10834
|
-
uid: activeAuth.
|
|
11197
|
+
uid: activeAuth.uid,
|
|
10835
11198
|
roles: activeAuth.roles
|
|
10836
11199
|
},
|
|
10837
11200
|
driver: this.driver,
|
|
@@ -10957,15 +11320,67 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
10957
11320
|
}
|
|
10958
11321
|
this.removePresence(clientId, channel);
|
|
10959
11322
|
}
|
|
10960
|
-
/**
|
|
11323
|
+
/**
|
|
11324
|
+
* Broadcast a message to all clients in a channel except the sender.
|
|
11325
|
+
*
|
|
11326
|
+
* On a channel with no retention rule this is what it always was: a
|
|
11327
|
+
* synchronous fan-out to whoever is connected, with no sequence number, no
|
|
11328
|
+
* SQL and no await — the body below runs to completion before returning.
|
|
11329
|
+
*
|
|
11330
|
+
* On a retained channel the message is durably numbered first and only then
|
|
11331
|
+
* delivered, through a per-channel queue so that delivery order matches
|
|
11332
|
+
* sequence order. That ordering is the whole point: a client that catches up
|
|
11333
|
+
* with `sinceSeq` has to arrive at the same state as one that never
|
|
11334
|
+
* disconnected.
|
|
11335
|
+
*/
|
|
10961
11336
|
broadcastToChannel(clientId, channel, event, payload) {
|
|
11337
|
+
const retention = this.channelHistory?.retentionFor(channel);
|
|
11338
|
+
if (!retention) {
|
|
11339
|
+
this.fanOutBroadcast(clientId, channel, event, payload);
|
|
11340
|
+
return;
|
|
11341
|
+
}
|
|
11342
|
+
const next = (this.channelSendQueues.get(channel) ?? Promise.resolve()).catch(() => {}).then(() => this.persistAndFanOut(clientId, channel, event, payload, retention));
|
|
11343
|
+
this.channelSendQueues.set(channel, next);
|
|
11344
|
+
next.finally(() => {
|
|
11345
|
+
if (this.channelSendQueues.get(channel) === next) this.channelSendQueues.delete(channel);
|
|
11346
|
+
});
|
|
11347
|
+
}
|
|
11348
|
+
/**
|
|
11349
|
+
* Number a broadcast, store it, then deliver it.
|
|
11350
|
+
*
|
|
11351
|
+
* A message that cannot be stored is **not** delivered. Delivering it would
|
|
11352
|
+
* put it in front of live subscribers while leaving it absent from every
|
|
11353
|
+
* future replay — the two views of the channel would disagree permanently,
|
|
11354
|
+
* and no later message could repair the gap. Failing loudly to the sender
|
|
11355
|
+
* instead lets it retry, which for an operation stream is the only outcome
|
|
11356
|
+
* that keeps clients convergent.
|
|
11357
|
+
*/
|
|
11358
|
+
async persistAndFanOut(clientId, channel, event, payload, retention) {
|
|
11359
|
+
let seq;
|
|
11360
|
+
try {
|
|
11361
|
+
({seq} = await this.channelHistory.append(channel, event, payload, clientId));
|
|
11362
|
+
} catch (error) {
|
|
11363
|
+
logger.error(`❌ [ChannelHistory] Could not persist broadcast on "${channel}" — message dropped`, { error });
|
|
11364
|
+
this.sendError(clientId, `Could not persist broadcast on retained channel "${channel}"`, void 0, "CHANNEL_HISTORY_WRITE_FAILED");
|
|
11365
|
+
return;
|
|
11366
|
+
}
|
|
11367
|
+
this.fanOutBroadcast(clientId, channel, event, payload, seq);
|
|
11368
|
+
try {
|
|
11369
|
+
await this.channelHistory.prune(channel, retention);
|
|
11370
|
+
} catch (error) {
|
|
11371
|
+
logger.warn(`⚠️ [ChannelHistory] Prune failed for "${channel}"`, { error });
|
|
11372
|
+
}
|
|
11373
|
+
}
|
|
11374
|
+
/** Deliver a broadcast frame to every member of a channel but the sender. */
|
|
11375
|
+
fanOutBroadcast(clientId, channel, event, payload, seq) {
|
|
10962
11376
|
const members = this.channels.get(channel);
|
|
10963
11377
|
if (!members) return;
|
|
10964
11378
|
const message = JSON.stringify({
|
|
10965
11379
|
type: "broadcast",
|
|
10966
11380
|
channel,
|
|
10967
11381
|
event,
|
|
10968
|
-
payload
|
|
11382
|
+
payload,
|
|
11383
|
+
...seq !== void 0 ? { seq } : {}
|
|
10969
11384
|
});
|
|
10970
11385
|
for (const memberId of members) {
|
|
10971
11386
|
if (memberId === clientId) continue;
|
|
@@ -10973,6 +11388,55 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
10973
11388
|
if (ws && ws.readyState === WebSocket.OPEN) ws.send(message);
|
|
10974
11389
|
}
|
|
10975
11390
|
}
|
|
11391
|
+
/**
|
|
11392
|
+
* Install retention rules and create the tables they need.
|
|
11393
|
+
*
|
|
11394
|
+
* Safe to call with no rules (and safe not to call at all): the store stays
|
|
11395
|
+
* inert, no schema is created, and broadcast keeps its original
|
|
11396
|
+
* fire-and-forget path.
|
|
11397
|
+
*/
|
|
11398
|
+
async configureChannelHistory(rules) {
|
|
11399
|
+
this.channelHistory = new ChannelHistoryStore(this.db, rules ?? []);
|
|
11400
|
+
if (!this.channelHistory.enabled) return;
|
|
11401
|
+
await this.channelHistory.ensureTables();
|
|
11402
|
+
}
|
|
11403
|
+
/** Whether any channel is configured to retain messages. */
|
|
11404
|
+
isChannelHistoryEnabled() {
|
|
11405
|
+
return this.channelHistory?.enabled ?? false;
|
|
11406
|
+
}
|
|
11407
|
+
/**
|
|
11408
|
+
* Answer a client's catch-up request.
|
|
11409
|
+
*
|
|
11410
|
+
* A channel with no retention rule is answered with `retained: false`
|
|
11411
|
+
* rather than an empty list, so the client can tell "you missed nothing"
|
|
11412
|
+
* apart from "this channel never keeps anything" — the second means its
|
|
11413
|
+
* reconnect strategy has to be a full resync, and silence would leave it
|
|
11414
|
+
* guessing.
|
|
11415
|
+
*/
|
|
11416
|
+
async handleChannelHistoryRequest(clientId, channel, sinceSeq, limit) {
|
|
11417
|
+
if (!channel) return;
|
|
11418
|
+
if (!this.channelHistory?.retentionFor(channel)) {
|
|
11419
|
+
this.sendChannelHistory(clientId, channel, [], false);
|
|
11420
|
+
return;
|
|
11421
|
+
}
|
|
11422
|
+
try {
|
|
11423
|
+
const { messages, latestSeq } = await this.channelHistory.replay(channel, sinceSeq, limit);
|
|
11424
|
+
this.sendChannelHistory(clientId, channel, messages, true, latestSeq);
|
|
11425
|
+
} catch (error) {
|
|
11426
|
+
logger.error(`❌ [ChannelHistory] Replay failed for "${channel}"`, { error });
|
|
11427
|
+
this.sendError(clientId, `Could not replay history for channel "${channel}"`, void 0, "CHANNEL_HISTORY_READ_FAILED");
|
|
11428
|
+
}
|
|
11429
|
+
}
|
|
11430
|
+
sendChannelHistory(clientId, channel, messages, retained, latestSeq) {
|
|
11431
|
+
const ws = this.clients.get(clientId);
|
|
11432
|
+
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({
|
|
11433
|
+
type: "channel_history",
|
|
11434
|
+
channel,
|
|
11435
|
+
messages,
|
|
11436
|
+
retained,
|
|
11437
|
+
...latestSeq !== void 0 ? { latestSeq } : {}
|
|
11438
|
+
}));
|
|
11439
|
+
}
|
|
10976
11440
|
/** Track presence in a channel */
|
|
10977
11441
|
trackPresence(clientId, channel, state) {
|
|
10978
11442
|
if (!this.presence.has(channel)) this.presence.set(channel, /* @__PURE__ */ new Map());
|
|
@@ -11053,6 +11517,9 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
11053
11517
|
this.subscriptionCallbacks.clear();
|
|
11054
11518
|
this.channels.clear();
|
|
11055
11519
|
this.presence.clear();
|
|
11520
|
+
await Promise.allSettled([...this.channelSendQueues.values()]);
|
|
11521
|
+
this.channelSendQueues.clear();
|
|
11522
|
+
this.channelHistory?.clear();
|
|
11056
11523
|
if (this.presenceInterval) {
|
|
11057
11524
|
clearInterval(this.presenceInterval);
|
|
11058
11525
|
this.presenceInterval = void 0;
|
|
@@ -11394,20 +11861,20 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
|
|
|
11394
11861
|
if (authAdapter) try {
|
|
11395
11862
|
const adapterUser = authAdapter.verifyToken ? await authAdapter.verifyToken(token) : await authAdapter.verifyRequest(new Request("http://localhost/_ws_auth", { headers: { Authorization: `Bearer ${token}` } }));
|
|
11396
11863
|
if (adapterUser) verifiedUser = {
|
|
11397
|
-
|
|
11864
|
+
uid: adapterUser.uid,
|
|
11398
11865
|
roles: adapterUser.roles,
|
|
11399
11866
|
isAdmin: adapterUser.isAdmin
|
|
11400
11867
|
};
|
|
11401
11868
|
} catch {}
|
|
11402
11869
|
else if (authConfig?.serviceKey && safeCompare(token, authConfig.serviceKey)) verifiedUser = {
|
|
11403
|
-
|
|
11870
|
+
uid: "service",
|
|
11404
11871
|
roles: ["admin"],
|
|
11405
11872
|
isAdmin: true
|
|
11406
11873
|
};
|
|
11407
11874
|
else {
|
|
11408
11875
|
const jwtPayload = extractUserFromToken(token);
|
|
11409
11876
|
if (jwtPayload) verifiedUser = {
|
|
11410
|
-
|
|
11877
|
+
uid: jwtPayload.uid,
|
|
11411
11878
|
roles: jwtPayload.roles ?? [],
|
|
11412
11879
|
isAdmin: (jwtPayload.roles ?? []).some((r) => r === "admin")
|
|
11413
11880
|
};
|
|
@@ -11423,11 +11890,11 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
|
|
|
11423
11890
|
type: "AUTH_SUCCESS",
|
|
11424
11891
|
requestId,
|
|
11425
11892
|
payload: {
|
|
11426
|
-
|
|
11893
|
+
uid: verifiedUser.uid,
|
|
11427
11894
|
roles: verifiedUser.roles
|
|
11428
11895
|
}
|
|
11429
11896
|
}));
|
|
11430
|
-
wsDebug(`🔐 [WebSocket Server] Client ${clientId} authenticated as ${verifiedUser.
|
|
11897
|
+
wsDebug(`🔐 [WebSocket Server] Client ${clientId} authenticated as ${verifiedUser.uid}`);
|
|
11431
11898
|
} else {
|
|
11432
11899
|
wsDebug(`[WS] replying AUTH_ERROR for requestId ${requestId} (invalid token)`);
|
|
11433
11900
|
sendError("AUTH_ERROR", "INVALID_TOKEN", "Invalid or expired token");
|
|
@@ -11465,7 +11932,7 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
|
|
|
11465
11932
|
const session = clientSessions.get(clientId);
|
|
11466
11933
|
if (typeof driver.withAuth === "function") try {
|
|
11467
11934
|
const userForAuth = session?.user ? {
|
|
11468
|
-
uid: session.user.
|
|
11935
|
+
uid: session.user.uid,
|
|
11469
11936
|
displayName: null,
|
|
11470
11937
|
email: null,
|
|
11471
11938
|
photoURL: null,
|
|
@@ -11599,7 +12066,7 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
|
|
|
11599
12066
|
sql: typeof sql === "string" ? sql.substring(0, 500) : sql,
|
|
11600
12067
|
options,
|
|
11601
12068
|
resultRows: Array.isArray(result) ? result.length : "unknown",
|
|
11602
|
-
|
|
12069
|
+
uid: auditSession?.user?.uid ?? "unknown",
|
|
11603
12070
|
roles: auditSession?.user?.roles ?? [],
|
|
11604
12071
|
isAdmin: auditSession?.user?.isAdmin ?? false
|
|
11605
12072
|
}));
|
|
@@ -11644,6 +12111,21 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
|
|
|
11644
12111
|
ws.send(JSON.stringify(response));
|
|
11645
12112
|
}
|
|
11646
12113
|
break;
|
|
12114
|
+
case "FETCH_APPLICATION_ROLES":
|
|
12115
|
+
{
|
|
12116
|
+
wsDebug("👤 [WebSocket Server] Processing FETCH_APPLICATION_ROLES request");
|
|
12117
|
+
const admin = (await getScopedDelegate()).admin;
|
|
12118
|
+
let roles = [];
|
|
12119
|
+
if (isSQLAdmin(admin) && admin.fetchApplicationRoles) roles = await admin.fetchApplicationRoles();
|
|
12120
|
+
wsDebug(`👤 [WebSocket Server] Fetched ${roles.length} application roles.`);
|
|
12121
|
+
const response = {
|
|
12122
|
+
type: "FETCH_APPLICATION_ROLES_SUCCESS",
|
|
12123
|
+
payload: { roles },
|
|
12124
|
+
requestId
|
|
12125
|
+
};
|
|
12126
|
+
ws.send(JSON.stringify(response));
|
|
12127
|
+
}
|
|
12128
|
+
break;
|
|
11647
12129
|
case "FETCH_CURRENT_DATABASE":
|
|
11648
12130
|
{
|
|
11649
12131
|
wsDebug("📚 [WebSocket Server] Processing FETCH_CURRENT_DATABASE request");
|
|
@@ -11750,14 +12232,15 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
|
|
|
11750
12232
|
case "broadcast":
|
|
11751
12233
|
case "presence_track":
|
|
11752
12234
|
case "presence_untrack":
|
|
11753
|
-
case "presence_state":
|
|
12235
|
+
case "presence_state":
|
|
12236
|
+
case "channel_history": {
|
|
11754
12237
|
wsDebug("🔄 [WebSocket Server] Routing realtime message to RealtimeService:", type);
|
|
11755
12238
|
const session = clientSessions.get(clientId);
|
|
11756
12239
|
const authContext = session?.user ? {
|
|
11757
|
-
|
|
12240
|
+
uid: session.user.uid,
|
|
11758
12241
|
roles: session.user.roles ?? []
|
|
11759
12242
|
} : {
|
|
11760
|
-
|
|
12243
|
+
uid: "anon",
|
|
11761
12244
|
roles: ["anon"]
|
|
11762
12245
|
};
|
|
11763
12246
|
await realtimeService.handleClientMessage(clientId, {
|
|
@@ -20708,14 +21191,71 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
20708
21191
|
email_verification_sent_at TIMESTAMP WITH TIME ZONE,
|
|
20709
21192
|
is_anonymous BOOLEAN DEFAULT FALSE NOT NULL,
|
|
20710
21193
|
metadata JSONB DEFAULT '{}' NOT NULL,
|
|
21194
|
+
tokens_valid_after TIMESTAMP WITH TIME ZONE,
|
|
20711
21195
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
|
|
20712
21196
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL
|
|
20713
21197
|
)
|
|
20714
21198
|
`);
|
|
20715
21199
|
await db.execute(sql`
|
|
21200
|
+
CREATE OR REPLACE FUNCTION ${sql.raw(`"${authSchema}"`)}.sync_uid_user_id() RETURNS trigger AS $$
|
|
21201
|
+
BEGIN
|
|
21202
|
+
IF NEW.uid IS NULL AND NEW.user_id IS NOT NULL THEN
|
|
21203
|
+
NEW.uid := NEW.user_id;
|
|
21204
|
+
ELSIF NEW.user_id IS NULL AND NEW.uid IS NOT NULL THEN
|
|
21205
|
+
NEW.user_id := NEW.uid;
|
|
21206
|
+
END IF;
|
|
21207
|
+
RETURN NEW;
|
|
21208
|
+
END $$ LANGUAGE plpgsql
|
|
21209
|
+
`);
|
|
21210
|
+
for (const authTable of [
|
|
21211
|
+
"user_identities",
|
|
21212
|
+
"refresh_tokens",
|
|
21213
|
+
"password_reset_tokens",
|
|
21214
|
+
"magic_link_tokens",
|
|
21215
|
+
"mfa_factors",
|
|
21216
|
+
"recovery_codes"
|
|
21217
|
+
]) {
|
|
21218
|
+
const qualified = `"${authSchema}"."${authTable}"`;
|
|
21219
|
+
await db.execute(sql`
|
|
21220
|
+
DO $$
|
|
21221
|
+
DECLARE
|
|
21222
|
+
has_legacy boolean;
|
|
21223
|
+
has_uid boolean;
|
|
21224
|
+
BEGIN
|
|
21225
|
+
SELECT
|
|
21226
|
+
bool_or(column_name = 'user_id'),
|
|
21227
|
+
bool_or(column_name = 'uid')
|
|
21228
|
+
INTO has_legacy, has_uid
|
|
21229
|
+
FROM information_schema.columns
|
|
21230
|
+
WHERE table_schema = ${sql.raw(`'${authSchema}'`)}
|
|
21231
|
+
AND table_name = ${sql.raw(`'${authTable}'`)};
|
|
21232
|
+
|
|
21233
|
+
-- Table absent, or already uid-only (a fresh install, or
|
|
21234
|
+
-- phase 2 already run): nothing to do.
|
|
21235
|
+
IF has_legacy IS NOT TRUE THEN
|
|
21236
|
+
RETURN;
|
|
21237
|
+
END IF;
|
|
21238
|
+
|
|
21239
|
+
IF has_uid IS NOT TRUE THEN
|
|
21240
|
+
EXECUTE ${sql.raw(`'ALTER TABLE ${qualified} ADD COLUMN uid ${userIdType} REFERENCES ${usersTableName}(id) ON DELETE CASCADE'`)};
|
|
21241
|
+
EXECUTE ${sql.raw(`'UPDATE ${qualified} SET uid = user_id WHERE uid IS NULL'`)};
|
|
21242
|
+
EXECUTE ${sql.raw(`'CREATE INDEX IF NOT EXISTS idx_${authTable}_uid ON ${qualified}(uid)'`)};
|
|
21243
|
+
END IF;
|
|
21244
|
+
|
|
21245
|
+
-- New code inserts uid and never user_id, so the legacy
|
|
21246
|
+
-- column can no longer be NOT NULL. The trigger below
|
|
21247
|
+
-- backfills it, but the constraint is checked first.
|
|
21248
|
+
EXECUTE ${sql.raw(`'ALTER TABLE ${qualified} ALTER COLUMN user_id DROP NOT NULL'`)};
|
|
21249
|
+
|
|
21250
|
+
EXECUTE ${sql.raw(`'DROP TRIGGER IF EXISTS sync_uid_user_id ON ${qualified}'`)};
|
|
21251
|
+
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()'`)};
|
|
21252
|
+
END $$
|
|
21253
|
+
`);
|
|
21254
|
+
}
|
|
21255
|
+
await db.execute(sql`
|
|
20716
21256
|
CREATE TABLE IF NOT EXISTS ${sql.raw(userIdentitiesTable)} (
|
|
20717
21257
|
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
|
20718
|
-
|
|
21258
|
+
uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
|
|
20719
21259
|
provider TEXT NOT NULL,
|
|
20720
21260
|
provider_id TEXT NOT NULL,
|
|
20721
21261
|
profile_data JSONB,
|
|
@@ -20726,18 +21266,21 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
20726
21266
|
`);
|
|
20727
21267
|
await db.execute(sql`
|
|
20728
21268
|
CREATE INDEX IF NOT EXISTS idx_user_identities_user
|
|
20729
|
-
ON ${sql.raw(userIdentitiesTable)}(
|
|
21269
|
+
ON ${sql.raw(userIdentitiesTable)}(uid)
|
|
20730
21270
|
`);
|
|
20731
21271
|
await db.execute(sql`
|
|
20732
21272
|
CREATE TABLE IF NOT EXISTS ${sql.raw(refreshTokensTableName)} (
|
|
20733
21273
|
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
|
20734
|
-
|
|
21274
|
+
uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
|
|
21275
|
+
session_id TEXT NOT NULL DEFAULT gen_random_uuid()::text,
|
|
20735
21276
|
token_hash TEXT NOT NULL UNIQUE,
|
|
20736
21277
|
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
|
21278
|
+
revoked BOOLEAN DEFAULT FALSE NOT NULL,
|
|
21279
|
+
rotated_at TIMESTAMP WITH TIME ZONE,
|
|
21280
|
+
session_started_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
|
|
20737
21281
|
user_agent TEXT,
|
|
20738
21282
|
ip_address TEXT,
|
|
20739
|
-
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
20740
|
-
CONSTRAINT unique_device_session UNIQUE (user_id, user_agent, ip_address)
|
|
21283
|
+
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
20741
21284
|
)
|
|
20742
21285
|
`);
|
|
20743
21286
|
await db.execute(sql`
|
|
@@ -20746,12 +21289,12 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
20746
21289
|
`);
|
|
20747
21290
|
await db.execute(sql`
|
|
20748
21291
|
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user
|
|
20749
|
-
ON ${sql.raw(refreshTokensTableName)}(
|
|
21292
|
+
ON ${sql.raw(refreshTokensTableName)}(uid)
|
|
20750
21293
|
`);
|
|
20751
21294
|
await db.execute(sql`
|
|
20752
21295
|
CREATE TABLE IF NOT EXISTS ${sql.raw(passwordResetTokensTableName)} (
|
|
20753
21296
|
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
|
20754
|
-
|
|
21297
|
+
uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
|
|
20755
21298
|
token_hash TEXT NOT NULL UNIQUE,
|
|
20756
21299
|
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
|
20757
21300
|
used_at TIMESTAMP WITH TIME ZONE,
|
|
@@ -20764,13 +21307,13 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
20764
21307
|
`);
|
|
20765
21308
|
await db.execute(sql`
|
|
20766
21309
|
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_user
|
|
20767
|
-
ON ${sql.raw(passwordResetTokensTableName)}(
|
|
21310
|
+
ON ${sql.raw(passwordResetTokensTableName)}(uid)
|
|
20768
21311
|
`);
|
|
20769
21312
|
const magicLinkTokensTableName = `"${authSchema}"."magic_link_tokens"`;
|
|
20770
21313
|
await db.execute(sql`
|
|
20771
21314
|
CREATE TABLE IF NOT EXISTS ${sql.raw(magicLinkTokensTableName)} (
|
|
20772
21315
|
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
|
20773
|
-
|
|
21316
|
+
uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
|
|
20774
21317
|
token_hash TEXT NOT NULL UNIQUE,
|
|
20775
21318
|
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
|
20776
21319
|
used_at TIMESTAMP WITH TIME ZONE,
|
|
@@ -20783,7 +21326,7 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
20783
21326
|
`);
|
|
20784
21327
|
await db.execute(sql`
|
|
20785
21328
|
CREATE INDEX IF NOT EXISTS idx_magic_link_tokens_user
|
|
20786
|
-
ON ${sql.raw(magicLinkTokensTableName)}(
|
|
21329
|
+
ON ${sql.raw(magicLinkTokensTableName)}(uid)
|
|
20787
21330
|
`);
|
|
20788
21331
|
await db.execute(sql`
|
|
20789
21332
|
CREATE TABLE IF NOT EXISTS ${sql.raw(appConfigTableName)} (
|
|
@@ -20797,7 +21340,10 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
20797
21340
|
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext('rebase_auth_functions_init'))`);
|
|
20798
21341
|
await tx.execute(sql`
|
|
20799
21342
|
CREATE OR REPLACE FUNCTION auth.uid() RETURNS text AS $$
|
|
20800
|
-
SELECT
|
|
21343
|
+
SELECT COALESCE(
|
|
21344
|
+
NULLIF(current_setting('app.uid', true), ''),
|
|
21345
|
+
NULLIF(current_setting('app.user_id', true), '')
|
|
21346
|
+
);
|
|
20801
21347
|
$$ LANGUAGE sql STABLE
|
|
20802
21348
|
`);
|
|
20803
21349
|
await tx.execute(sql`
|
|
@@ -20824,12 +21370,54 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
20824
21370
|
"email_verification_sent_at TIMESTAMP WITH TIME ZONE",
|
|
20825
21371
|
"is_anonymous BOOLEAN DEFAULT FALSE NOT NULL",
|
|
20826
21372
|
"metadata JSONB DEFAULT '{}' NOT NULL",
|
|
21373
|
+
"tokens_valid_after TIMESTAMP WITH TIME ZONE",
|
|
20827
21374
|
"created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL",
|
|
20828
21375
|
"updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL"
|
|
20829
21376
|
]) await db.execute(sql`
|
|
20830
21377
|
ALTER TABLE ${sql.raw(usersTableName)}
|
|
20831
21378
|
ADD COLUMN IF NOT EXISTS ${sql.raw(columnDef)}
|
|
20832
21379
|
`);
|
|
21380
|
+
try {
|
|
21381
|
+
const found = (await db.execute(sql`
|
|
21382
|
+
SELECT table_schema, table_name
|
|
21383
|
+
FROM information_schema.tables
|
|
21384
|
+
WHERE table_name = 'refresh_tokens'
|
|
21385
|
+
`)).rows;
|
|
21386
|
+
logger.info(`🔍 refresh_tokens reconcile: found ${found.length} table(s): ${found.map((r) => `"${r.table_schema}"."${r.table_name}"`).join(", ") || "(none)"}`);
|
|
21387
|
+
for (const { table_schema } of found) {
|
|
21388
|
+
const qualified = `"${table_schema}"."refresh_tokens"`;
|
|
21389
|
+
try {
|
|
21390
|
+
await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS session_id TEXT`);
|
|
21391
|
+
await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS revoked BOOLEAN DEFAULT FALSE NOT NULL`);
|
|
21392
|
+
await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS rotated_at TIMESTAMP WITH TIME ZONE`);
|
|
21393
|
+
await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS session_started_at TIMESTAMP WITH TIME ZONE`);
|
|
21394
|
+
await db.execute(sql`
|
|
21395
|
+
UPDATE ${sql.raw(qualified)}
|
|
21396
|
+
SET session_id = gen_random_uuid()::text
|
|
21397
|
+
WHERE session_id IS NULL
|
|
21398
|
+
`);
|
|
21399
|
+
await db.execute(sql`
|
|
21400
|
+
UPDATE ${sql.raw(qualified)}
|
|
21401
|
+
SET session_started_at = COALESCE(created_at, NOW())
|
|
21402
|
+
WHERE session_started_at IS NULL
|
|
21403
|
+
`);
|
|
21404
|
+
await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ALTER COLUMN session_id SET DEFAULT gen_random_uuid()::text`);
|
|
21405
|
+
await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ALTER COLUMN session_started_at SET DEFAULT NOW()`);
|
|
21406
|
+
await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ALTER COLUMN session_id SET NOT NULL`);
|
|
21407
|
+
await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ALTER COLUMN session_started_at SET NOT NULL`);
|
|
21408
|
+
await db.execute(sql`
|
|
21409
|
+
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_session
|
|
21410
|
+
ON ${sql.raw(qualified)}(session_id)
|
|
21411
|
+
`);
|
|
21412
|
+
await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} DROP CONSTRAINT IF EXISTS unique_device_session`);
|
|
21413
|
+
logger.info(`✅ refresh_tokens reconciled for session-scoped rotation: ${qualified}`);
|
|
21414
|
+
} catch (perTableError) {
|
|
21415
|
+
logger.warn(`⚠️ refresh_tokens reconcile failed for ${qualified}: ${perTableError instanceof Error ? perTableError.message : String(perTableError)}`);
|
|
21416
|
+
}
|
|
21417
|
+
}
|
|
21418
|
+
} catch (migrationError) {
|
|
21419
|
+
logger.warn(`⚠️ refresh_tokens session migration skipped: ${migrationError instanceof Error ? migrationError.message : String(migrationError)}`);
|
|
21420
|
+
}
|
|
20833
21421
|
try {
|
|
20834
21422
|
if ((await db.execute(sql`
|
|
20835
21423
|
SELECT EXISTS (
|
|
@@ -20860,7 +21448,7 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
20860
21448
|
await db.execute(sql`
|
|
20861
21449
|
CREATE TABLE IF NOT EXISTS ${sql.raw(mfaFactorsTableName)} (
|
|
20862
21450
|
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
|
20863
|
-
|
|
21451
|
+
uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
|
|
20864
21452
|
factor_type TEXT NOT NULL DEFAULT 'totp',
|
|
20865
21453
|
secret_encrypted TEXT NOT NULL,
|
|
20866
21454
|
friendly_name TEXT,
|
|
@@ -20871,7 +21459,7 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
20871
21459
|
`);
|
|
20872
21460
|
await db.execute(sql`
|
|
20873
21461
|
CREATE INDEX IF NOT EXISTS idx_mfa_factors_user
|
|
20874
|
-
ON ${sql.raw(mfaFactorsTableName)}(
|
|
21462
|
+
ON ${sql.raw(mfaFactorsTableName)}(uid)
|
|
20875
21463
|
`);
|
|
20876
21464
|
await db.execute(sql`
|
|
20877
21465
|
CREATE TABLE IF NOT EXISTS ${sql.raw(mfaChallengesTableName)} (
|
|
@@ -20890,7 +21478,7 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
20890
21478
|
await db.execute(sql`
|
|
20891
21479
|
CREATE TABLE IF NOT EXISTS ${sql.raw(recoveryCodesTableName)} (
|
|
20892
21480
|
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
|
20893
|
-
|
|
21481
|
+
uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
|
|
20894
21482
|
code_hash TEXT NOT NULL,
|
|
20895
21483
|
used_at TIMESTAMP WITH TIME ZONE,
|
|
20896
21484
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
@@ -20898,7 +21486,7 @@ async function ensureAuthTablesExist(db, collection) {
|
|
|
20898
21486
|
`);
|
|
20899
21487
|
await db.execute(sql`
|
|
20900
21488
|
CREATE INDEX IF NOT EXISTS idx_recovery_codes_user
|
|
20901
|
-
ON ${sql.raw(recoveryCodesTableName)}(
|
|
21489
|
+
ON ${sql.raw(recoveryCodesTableName)}(uid)
|
|
20902
21490
|
`);
|
|
20903
21491
|
try {
|
|
20904
21492
|
const authTablePairs = [
|
|
@@ -20979,7 +21567,7 @@ var UserService = class {
|
|
|
20979
21567
|
* Run a privileged auth write with an explicitly cleared RLS context.
|
|
20980
21568
|
*
|
|
20981
21569
|
* The auth services run on the base/owner connection, which by design
|
|
20982
|
-
* carries a NULL `app.
|
|
21570
|
+
* carries a NULL `app.uid` so the `auth.uid() IS NULL` server-escape
|
|
20983
21571
|
* in the default policies applies. That NULL is normally guaranteed by
|
|
20984
21572
|
* `set_config(..., is_local = true)` resetting at transaction end — but a
|
|
20985
21573
|
* GUC that survives on a pooled connection (or a connection role that
|
|
@@ -20993,7 +21581,8 @@ var UserService = class {
|
|
|
20993
21581
|
async withServerContext(fn) {
|
|
20994
21582
|
return await this.db.transaction(async (tx) => {
|
|
20995
21583
|
await tx.execute(sql`
|
|
20996
|
-
SELECT set_config('app.
|
|
21584
|
+
SELECT set_config('app.uid', '', true),
|
|
21585
|
+
set_config('app.user_id', '', true),
|
|
20997
21586
|
set_config('app.user_roles', '', true),
|
|
20998
21587
|
set_config('app.jwt', '', true)
|
|
20999
21588
|
`);
|
|
@@ -21115,19 +21704,19 @@ var UserService = class {
|
|
|
21115
21704
|
async getUserByIdentity(provider, providerId) {
|
|
21116
21705
|
const userIdCol = getColumn(this.usersTable, "id");
|
|
21117
21706
|
if (!userIdCol) return null;
|
|
21118
|
-
const result = await this.db.select({ user: this.usersTable }).from(this.usersTable).innerJoin(this.userIdentitiesTable, eq(userIdCol, this.userIdentitiesTable.
|
|
21707
|
+
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);
|
|
21119
21708
|
if (result.length === 0) return null;
|
|
21120
21709
|
return this.mapRowToUser(result[0].user);
|
|
21121
21710
|
}
|
|
21122
|
-
async getUserIdentities(
|
|
21711
|
+
async getUserIdentities(uid) {
|
|
21123
21712
|
const schema = getTableConfig(this.userIdentitiesTable).schema || "public";
|
|
21124
21713
|
return (await this.db.execute(sql`
|
|
21125
|
-
SELECT id,
|
|
21714
|
+
SELECT id, uid, provider, provider_id, profile_data, created_at, updated_at
|
|
21126
21715
|
FROM ${sql.raw(`"${schema}"."user_identities"`)}
|
|
21127
|
-
WHERE
|
|
21716
|
+
WHERE uid = ${uid}
|
|
21128
21717
|
`)).rows.map((row) => ({
|
|
21129
21718
|
id: row.id,
|
|
21130
|
-
|
|
21719
|
+
uid: row.uid,
|
|
21131
21720
|
provider: row.provider,
|
|
21132
21721
|
providerId: row.provider_id,
|
|
21133
21722
|
profileData: row.profile_data ?? null,
|
|
@@ -21135,9 +21724,9 @@ var UserService = class {
|
|
|
21135
21724
|
updatedAt: row.updated_at
|
|
21136
21725
|
}));
|
|
21137
21726
|
}
|
|
21138
|
-
async linkUserIdentity(
|
|
21727
|
+
async linkUserIdentity(uid, provider, providerId, profileData) {
|
|
21139
21728
|
await this.withServerContext(async (db) => db.insert(this.userIdentitiesTable).values({
|
|
21140
|
-
|
|
21729
|
+
uid,
|
|
21141
21730
|
provider,
|
|
21142
21731
|
providerId,
|
|
21143
21732
|
profileData: profileData || null
|
|
@@ -21256,10 +21845,10 @@ var UserService = class {
|
|
|
21256
21845
|
/**
|
|
21257
21846
|
* Get roles for a user from database (inline TEXT[] column)
|
|
21258
21847
|
*/
|
|
21259
|
-
async getUserRoles(
|
|
21848
|
+
async getUserRoles(uid) {
|
|
21260
21849
|
const usersTableName = this.getQualifiedUsersTableName();
|
|
21261
21850
|
const result = await this.db.execute(sql`
|
|
21262
|
-
SELECT roles FROM ${sql.raw(usersTableName)} WHERE id = ${
|
|
21851
|
+
SELECT roles FROM ${sql.raw(usersTableName)} WHERE id = ${uid}
|
|
21263
21852
|
`);
|
|
21264
21853
|
if (result.rows.length === 0) return [];
|
|
21265
21854
|
return (result.rows[0].roles ?? []).map((id) => ({
|
|
@@ -21273,10 +21862,10 @@ var UserService = class {
|
|
|
21273
21862
|
/**
|
|
21274
21863
|
* Get role IDs for a user
|
|
21275
21864
|
*/
|
|
21276
|
-
async getUserRoleIds(
|
|
21865
|
+
async getUserRoleIds(uid) {
|
|
21277
21866
|
const usersTableName = this.getQualifiedUsersTableName();
|
|
21278
21867
|
const result = await this.db.execute(sql`
|
|
21279
|
-
SELECT roles FROM ${sql.raw(usersTableName)} WHERE id = ${
|
|
21868
|
+
SELECT roles FROM ${sql.raw(usersTableName)} WHERE id = ${uid}
|
|
21280
21869
|
`);
|
|
21281
21870
|
if (result.rows.length === 0) return [];
|
|
21282
21871
|
return result.rows[0].roles ?? [];
|
|
@@ -21284,98 +21873,172 @@ var UserService = class {
|
|
|
21284
21873
|
/**
|
|
21285
21874
|
* Set roles for a user (replaces existing roles)
|
|
21286
21875
|
*/
|
|
21287
|
-
async setUserRoles(
|
|
21876
|
+
async setUserRoles(uid, roleIds) {
|
|
21288
21877
|
const usersTableName = this.getQualifiedUsersTableName();
|
|
21289
21878
|
const rolesArray = `{${roleIds.join(",")}}`;
|
|
21290
21879
|
await this.withServerContext(async (db) => db.execute(sql`
|
|
21291
21880
|
UPDATE ${sql.raw(usersTableName)}
|
|
21292
21881
|
SET roles = ${rolesArray}::text[], updated_at = NOW()
|
|
21293
|
-
WHERE id = ${
|
|
21882
|
+
WHERE id = ${uid}
|
|
21294
21883
|
`));
|
|
21295
21884
|
}
|
|
21296
21885
|
/**
|
|
21297
21886
|
* Assign a specific role to new user (appends if not present)
|
|
21298
21887
|
*/
|
|
21299
|
-
async assignDefaultRole(
|
|
21888
|
+
async assignDefaultRole(uid, roleId) {
|
|
21300
21889
|
const usersTableName = this.getQualifiedUsersTableName();
|
|
21301
21890
|
await this.withServerContext(async (db) => db.execute(sql`
|
|
21302
21891
|
UPDATE ${sql.raw(usersTableName)}
|
|
21303
21892
|
SET roles = array_append(roles, ${roleId}), updated_at = NOW()
|
|
21304
|
-
WHERE id = ${
|
|
21893
|
+
WHERE id = ${uid} AND NOT (${roleId} = ANY(roles))
|
|
21305
21894
|
`));
|
|
21306
21895
|
}
|
|
21307
21896
|
/**
|
|
21308
21897
|
* Get user with their roles
|
|
21309
21898
|
*/
|
|
21310
|
-
async getUserWithRoles(
|
|
21311
|
-
const user = await this.getUserById(
|
|
21899
|
+
async getUserWithRoles(uid) {
|
|
21900
|
+
const user = await this.getUserById(uid);
|
|
21312
21901
|
if (!user) return null;
|
|
21313
21902
|
return {
|
|
21314
21903
|
user,
|
|
21315
|
-
roles: await this.getUserRoles(
|
|
21904
|
+
roles: await this.getUserRoles(uid)
|
|
21316
21905
|
};
|
|
21317
21906
|
}
|
|
21318
21907
|
};
|
|
21319
21908
|
var RefreshTokenService = class {
|
|
21320
21909
|
db;
|
|
21321
21910
|
refreshTokensTable;
|
|
21911
|
+
usersTable;
|
|
21322
21912
|
constructor(db, tableOrTables) {
|
|
21323
21913
|
this.db = db;
|
|
21324
|
-
if (tableOrTables && (tableOrTables.refreshTokens || tableOrTables.users))
|
|
21325
|
-
|
|
21326
|
-
|
|
21327
|
-
|
|
21328
|
-
|
|
21329
|
-
|
|
21330
|
-
|
|
21331
|
-
userId,
|
|
21332
|
-
tokenHash,
|
|
21333
|
-
expiresAt,
|
|
21334
|
-
userAgent: safeUserAgent,
|
|
21335
|
-
ipAddress: safeIpAddress
|
|
21336
|
-
}).onConflictDoUpdate({
|
|
21337
|
-
target: [
|
|
21338
|
-
this.refreshTokensTable.userId,
|
|
21339
|
-
this.refreshTokensTable.userAgent,
|
|
21340
|
-
this.refreshTokensTable.ipAddress
|
|
21341
|
-
],
|
|
21342
|
-
set: {
|
|
21343
|
-
tokenHash,
|
|
21344
|
-
expiresAt
|
|
21345
|
-
}
|
|
21346
|
-
});
|
|
21914
|
+
if (tableOrTables && (tableOrTables.refreshTokens || tableOrTables.users)) {
|
|
21915
|
+
this.refreshTokensTable = tableOrTables.refreshTokens || refreshTokens;
|
|
21916
|
+
this.usersTable = tableOrTables.users || users;
|
|
21917
|
+
} else {
|
|
21918
|
+
this.refreshTokensTable = tableOrTables || refreshTokens;
|
|
21919
|
+
this.usersTable = users;
|
|
21920
|
+
}
|
|
21347
21921
|
}
|
|
21348
|
-
|
|
21349
|
-
|
|
21922
|
+
/**
|
|
21923
|
+
* Whether the table actually carries a column, so a host application that
|
|
21924
|
+
* supplied its own `refresh_tokens` table — one that predates session
|
|
21925
|
+
* grouping — degrades instead of throwing on every sign-in.
|
|
21926
|
+
*/
|
|
21927
|
+
has(column) {
|
|
21928
|
+
return Boolean(this.refreshTokensTable[column]);
|
|
21929
|
+
}
|
|
21930
|
+
col(column) {
|
|
21931
|
+
return this.refreshTokensTable[column];
|
|
21932
|
+
}
|
|
21933
|
+
/** The columns to read back, narrowed to the ones this table has. */
|
|
21934
|
+
selection() {
|
|
21935
|
+
const selection = {
|
|
21350
21936
|
id: this.refreshTokensTable.id,
|
|
21351
|
-
|
|
21937
|
+
uid: this.refreshTokensTable.uid,
|
|
21352
21938
|
tokenHash: this.refreshTokensTable.tokenHash,
|
|
21353
21939
|
expiresAt: this.refreshTokensTable.expiresAt,
|
|
21354
21940
|
createdAt: this.refreshTokensTable.createdAt,
|
|
21355
21941
|
userAgent: this.refreshTokensTable.userAgent,
|
|
21356
21942
|
ipAddress: this.refreshTokensTable.ipAddress
|
|
21357
|
-
}
|
|
21943
|
+
};
|
|
21944
|
+
for (const optional of [
|
|
21945
|
+
"sessionId",
|
|
21946
|
+
"rotatedAt",
|
|
21947
|
+
"revoked",
|
|
21948
|
+
"sessionStartedAt"
|
|
21949
|
+
]) if (this.has(optional)) selection[optional] = this.col(optional);
|
|
21950
|
+
return selection;
|
|
21951
|
+
}
|
|
21952
|
+
async createToken(uid, tokenHash, expiresAt, userAgent, ipAddress, session) {
|
|
21953
|
+
const values = {
|
|
21954
|
+
uid,
|
|
21955
|
+
tokenHash,
|
|
21956
|
+
expiresAt,
|
|
21957
|
+
userAgent: userAgent || "",
|
|
21958
|
+
ipAddress: ipAddress || ""
|
|
21959
|
+
};
|
|
21960
|
+
if (session && this.has("sessionId")) values.sessionId = session.id;
|
|
21961
|
+
if (session && this.has("sessionStartedAt")) values.sessionStartedAt = session.startedAt;
|
|
21962
|
+
await this.db.insert(this.refreshTokensTable).values(values);
|
|
21963
|
+
}
|
|
21964
|
+
async findByHash(tokenHash) {
|
|
21965
|
+
const [token] = await this.db.select(this.selection()).from(this.refreshTokensTable).where(eq(this.refreshTokensTable.tokenHash, tokenHash));
|
|
21358
21966
|
return token || null;
|
|
21359
21967
|
}
|
|
21968
|
+
/**
|
|
21969
|
+
* Record that a token was rotated away, keeping the row.
|
|
21970
|
+
*
|
|
21971
|
+
* The row is what lets `/auth/refresh` distinguish "you already used this,
|
|
21972
|
+
* here is a fresh one" from "no idea what this is". Deleting it — which is
|
|
21973
|
+
* what this used to do — collapsed both into a 401 and signed the user out
|
|
21974
|
+
* for the crime of losing a response.
|
|
21975
|
+
*/
|
|
21976
|
+
async markRotated(tokenHash) {
|
|
21977
|
+
if (!this.has("rotatedAt")) {
|
|
21978
|
+
await this.deleteByHash(tokenHash);
|
|
21979
|
+
return;
|
|
21980
|
+
}
|
|
21981
|
+
await this.db.update(this.refreshTokensTable).set({ rotatedAt: /* @__PURE__ */ new Date() }).where(eq(this.refreshTokensTable.tokenHash, tokenHash));
|
|
21982
|
+
}
|
|
21983
|
+
/** Final kill of one sign-in: logout, or revoking a device remotely. */
|
|
21984
|
+
async revokeSession(sessionId) {
|
|
21985
|
+
if (!this.has("sessionId")) return;
|
|
21986
|
+
if (this.has("revoked")) {
|
|
21987
|
+
await this.db.update(this.refreshTokensTable).set({
|
|
21988
|
+
revoked: true,
|
|
21989
|
+
...this.has("rotatedAt") ? { rotatedAt: /* @__PURE__ */ new Date() } : {}
|
|
21990
|
+
}).where(eq(this.col("sessionId"), sessionId));
|
|
21991
|
+
return;
|
|
21992
|
+
}
|
|
21993
|
+
await this.db.delete(this.refreshTokensTable).where(eq(this.col("sessionId"), sessionId));
|
|
21994
|
+
}
|
|
21995
|
+
/**
|
|
21996
|
+
* Housekeeping: rotation would otherwise leave a row per refresh forever.
|
|
21997
|
+
* Superseded rows are only needed for as long as a straggler might still
|
|
21998
|
+
* present them, and expired ones are dead weight everywhere.
|
|
21999
|
+
*/
|
|
22000
|
+
async prune(uid, sessionId, supersededBefore) {
|
|
22001
|
+
const uidCol = this.refreshTokensTable.uid;
|
|
22002
|
+
const expiresCol = this.refreshTokensTable.expiresAt;
|
|
22003
|
+
if (!this.has("rotatedAt") || !this.has("sessionId")) {
|
|
22004
|
+
await this.db.delete(this.refreshTokensTable).where(sql`${uidCol} = ${uid} AND ${expiresCol} < NOW()`);
|
|
22005
|
+
return;
|
|
22006
|
+
}
|
|
22007
|
+
const rotatedCol = this.col("rotatedAt");
|
|
22008
|
+
const sessionCol = this.col("sessionId");
|
|
22009
|
+
await this.db.delete(this.refreshTokensTable).where(sql`
|
|
22010
|
+
${uidCol} = ${uid}
|
|
22011
|
+
AND (
|
|
22012
|
+
${expiresCol} < NOW()
|
|
22013
|
+
OR (
|
|
22014
|
+
${sessionCol} = ${sessionId}
|
|
22015
|
+
AND ${rotatedCol} IS NOT NULL
|
|
22016
|
+
AND ${rotatedCol} < ${supersededBefore}
|
|
22017
|
+
)
|
|
22018
|
+
)
|
|
22019
|
+
`);
|
|
22020
|
+
}
|
|
22021
|
+
async getTokensValidAfter(uid) {
|
|
22022
|
+
if (!this.usersTable || !this.usersTable.tokensValidAfter) return null;
|
|
22023
|
+
const [row] = await this.db.select({ tokensValidAfter: this.usersTable.tokensValidAfter }).from(this.usersTable).where(eq(this.usersTable.id, uid));
|
|
22024
|
+
const value = row?.tokensValidAfter;
|
|
22025
|
+
return value ? new Date(value) : null;
|
|
22026
|
+
}
|
|
22027
|
+
async setTokensValidAfter(uid, at) {
|
|
22028
|
+
if (!this.usersTable || !this.usersTable.tokensValidAfter) return;
|
|
22029
|
+
await this.db.update(this.usersTable).set({ tokensValidAfter: at }).where(eq(this.usersTable.id, uid));
|
|
22030
|
+
}
|
|
21360
22031
|
async deleteByHash(tokenHash) {
|
|
21361
22032
|
await this.db.delete(this.refreshTokensTable).where(eq(this.refreshTokensTable.tokenHash, tokenHash));
|
|
21362
22033
|
}
|
|
21363
|
-
async deleteAllForUser(
|
|
21364
|
-
await this.db.delete(this.refreshTokensTable).where(eq(this.refreshTokensTable.
|
|
22034
|
+
async deleteAllForUser(uid) {
|
|
22035
|
+
await this.db.delete(this.refreshTokensTable).where(eq(this.refreshTokensTable.uid, uid));
|
|
21365
22036
|
}
|
|
21366
|
-
async listForUser(
|
|
21367
|
-
return await this.db.select(
|
|
21368
|
-
id: this.refreshTokensTable.id,
|
|
21369
|
-
userId: this.refreshTokensTable.userId,
|
|
21370
|
-
tokenHash: this.refreshTokensTable.tokenHash,
|
|
21371
|
-
expiresAt: this.refreshTokensTable.expiresAt,
|
|
21372
|
-
createdAt: this.refreshTokensTable.createdAt,
|
|
21373
|
-
userAgent: this.refreshTokensTable.userAgent,
|
|
21374
|
-
ipAddress: this.refreshTokensTable.ipAddress
|
|
21375
|
-
}).from(this.refreshTokensTable).where(eq(this.refreshTokensTable.userId, userId)).orderBy(this.refreshTokensTable.createdAt);
|
|
22037
|
+
async listForUser(uid) {
|
|
22038
|
+
return await this.db.select(this.selection()).from(this.refreshTokensTable).where(eq(this.refreshTokensTable.uid, uid)).orderBy(this.refreshTokensTable.createdAt);
|
|
21376
22039
|
}
|
|
21377
|
-
async deleteById(id,
|
|
21378
|
-
await this.db.delete(this.refreshTokensTable).where(sql`${this.refreshTokensTable.id} = ${id} AND ${this.refreshTokensTable.
|
|
22040
|
+
async deleteById(id, uid) {
|
|
22041
|
+
await this.db.delete(this.refreshTokensTable).where(sql`${this.refreshTokensTable.id} = ${id} AND ${this.refreshTokensTable.uid} = ${uid}`);
|
|
21379
22042
|
}
|
|
21380
22043
|
};
|
|
21381
22044
|
/**
|
|
@@ -21396,14 +22059,14 @@ var PasswordResetTokenService = class {
|
|
|
21396
22059
|
/**
|
|
21397
22060
|
* Create a password reset token
|
|
21398
22061
|
*/
|
|
21399
|
-
async createToken(
|
|
22062
|
+
async createToken(uid, tokenHash, expiresAt) {
|
|
21400
22063
|
const tableName = this.getQualifiedPasswordResetTokensTableName();
|
|
21401
22064
|
await this.db.execute(sql`
|
|
21402
22065
|
DELETE FROM ${sql.raw(tableName)}
|
|
21403
|
-
WHERE
|
|
22066
|
+
WHERE uid = ${uid} AND used_at IS NULL
|
|
21404
22067
|
`);
|
|
21405
22068
|
await this.db.insert(this.passwordResetTokensTable).values({
|
|
21406
|
-
|
|
22069
|
+
uid,
|
|
21407
22070
|
tokenHash,
|
|
21408
22071
|
expiresAt
|
|
21409
22072
|
});
|
|
@@ -21413,13 +22076,13 @@ var PasswordResetTokenService = class {
|
|
|
21413
22076
|
*/
|
|
21414
22077
|
async findValidByHash(tokenHash) {
|
|
21415
22078
|
const [token] = await this.db.select({
|
|
21416
|
-
|
|
22079
|
+
uid: this.passwordResetTokensTable.uid,
|
|
21417
22080
|
expiresAt: this.passwordResetTokensTable.expiresAt
|
|
21418
22081
|
}).from(this.passwordResetTokensTable).where(eq(this.passwordResetTokensTable.tokenHash, tokenHash));
|
|
21419
22082
|
if (!token) return null;
|
|
21420
22083
|
const tableName = this.getQualifiedPasswordResetTokensTableName();
|
|
21421
22084
|
const result = await this.db.execute(sql`
|
|
21422
|
-
SELECT
|
|
22085
|
+
SELECT uid, expires_at
|
|
21423
22086
|
FROM ${sql.raw(tableName)}
|
|
21424
22087
|
WHERE token_hash = ${tokenHash}
|
|
21425
22088
|
AND used_at IS NULL
|
|
@@ -21428,7 +22091,7 @@ var PasswordResetTokenService = class {
|
|
|
21428
22091
|
if (result.rows.length === 0) return null;
|
|
21429
22092
|
const row = result.rows[0];
|
|
21430
22093
|
return {
|
|
21431
|
-
|
|
22094
|
+
uid: row.uid,
|
|
21432
22095
|
expiresAt: new Date(row.expires_at)
|
|
21433
22096
|
};
|
|
21434
22097
|
}
|
|
@@ -21441,8 +22104,8 @@ var PasswordResetTokenService = class {
|
|
|
21441
22104
|
/**
|
|
21442
22105
|
* Delete all tokens for a user
|
|
21443
22106
|
*/
|
|
21444
|
-
async deleteAllForUser(
|
|
21445
|
-
await this.db.delete(this.passwordResetTokensTable).where(eq(this.passwordResetTokensTable.
|
|
22107
|
+
async deleteAllForUser(uid) {
|
|
22108
|
+
await this.db.delete(this.passwordResetTokensTable).where(eq(this.passwordResetTokensTable.uid, uid));
|
|
21446
22109
|
}
|
|
21447
22110
|
/**
|
|
21448
22111
|
* Clean up expired tokens
|
|
@@ -21470,14 +22133,14 @@ var MagicLinkTokenService = class {
|
|
|
21470
22133
|
const name = getTableName(this.magicLinkTokensTable);
|
|
21471
22134
|
return `"${getTableConfig(this.magicLinkTokensTable).schema || "public"}"."${name}"`;
|
|
21472
22135
|
}
|
|
21473
|
-
async createToken(
|
|
22136
|
+
async createToken(uid, tokenHash, expiresAt) {
|
|
21474
22137
|
const tableName = this.getQualifiedTableName();
|
|
21475
22138
|
await this.db.execute(sql`
|
|
21476
22139
|
DELETE FROM ${sql.raw(tableName)}
|
|
21477
|
-
WHERE
|
|
22140
|
+
WHERE uid = ${uid} AND used_at IS NULL
|
|
21478
22141
|
`);
|
|
21479
22142
|
await this.db.insert(this.magicLinkTokensTable).values({
|
|
21480
|
-
|
|
22143
|
+
uid,
|
|
21481
22144
|
tokenHash,
|
|
21482
22145
|
expiresAt
|
|
21483
22146
|
});
|
|
@@ -21485,7 +22148,7 @@ var MagicLinkTokenService = class {
|
|
|
21485
22148
|
async findValidByHash(tokenHash) {
|
|
21486
22149
|
const tableName = this.getQualifiedTableName();
|
|
21487
22150
|
const result = await this.db.execute(sql`
|
|
21488
|
-
SELECT
|
|
22151
|
+
SELECT uid, expires_at
|
|
21489
22152
|
FROM ${sql.raw(tableName)}
|
|
21490
22153
|
WHERE token_hash = ${tokenHash}
|
|
21491
22154
|
AND used_at IS NULL
|
|
@@ -21494,7 +22157,7 @@ var MagicLinkTokenService = class {
|
|
|
21494
22157
|
if (result.rows.length === 0) return null;
|
|
21495
22158
|
const row = result.rows[0];
|
|
21496
22159
|
return {
|
|
21497
|
-
|
|
22160
|
+
uid: row.uid,
|
|
21498
22161
|
expiresAt: new Date(row.expires_at)
|
|
21499
22162
|
};
|
|
21500
22163
|
}
|
|
@@ -21517,8 +22180,23 @@ var PostgresTokenRepository = class {
|
|
|
21517
22180
|
this.passwordResetTokenService = new PasswordResetTokenService(db, tableOrTables);
|
|
21518
22181
|
this.magicLinkTokenService = new MagicLinkTokenService(db, tableOrTables);
|
|
21519
22182
|
}
|
|
21520
|
-
async createRefreshToken(
|
|
21521
|
-
await this.refreshTokenService.createToken(
|
|
22183
|
+
async createRefreshToken(uid, tokenHash, expiresAt, userAgent, ipAddress, session) {
|
|
22184
|
+
await this.refreshTokenService.createToken(uid, tokenHash, expiresAt, userAgent, ipAddress, session);
|
|
22185
|
+
}
|
|
22186
|
+
async markRefreshTokenRotated(tokenHash) {
|
|
22187
|
+
await this.refreshTokenService.markRotated(tokenHash);
|
|
22188
|
+
}
|
|
22189
|
+
async revokeRefreshTokenSession(sessionId) {
|
|
22190
|
+
await this.refreshTokenService.revokeSession(sessionId);
|
|
22191
|
+
}
|
|
22192
|
+
async pruneRefreshTokens(uid, sessionId, supersededBefore) {
|
|
22193
|
+
await this.refreshTokenService.prune(uid, sessionId, supersededBefore);
|
|
22194
|
+
}
|
|
22195
|
+
async getTokensValidAfter(uid) {
|
|
22196
|
+
return this.refreshTokenService.getTokensValidAfter(uid);
|
|
22197
|
+
}
|
|
22198
|
+
async setTokensValidAfter(uid, at) {
|
|
22199
|
+
await this.refreshTokenService.setTokensValidAfter(uid, at);
|
|
21522
22200
|
}
|
|
21523
22201
|
async findRefreshTokenByHash(tokenHash) {
|
|
21524
22202
|
return this.refreshTokenService.findByHash(tokenHash);
|
|
@@ -21526,17 +22204,17 @@ var PostgresTokenRepository = class {
|
|
|
21526
22204
|
async deleteRefreshToken(tokenHash) {
|
|
21527
22205
|
await this.refreshTokenService.deleteByHash(tokenHash);
|
|
21528
22206
|
}
|
|
21529
|
-
async deleteAllRefreshTokensForUser(
|
|
21530
|
-
await this.refreshTokenService.deleteAllForUser(
|
|
22207
|
+
async deleteAllRefreshTokensForUser(uid) {
|
|
22208
|
+
await this.refreshTokenService.deleteAllForUser(uid);
|
|
21531
22209
|
}
|
|
21532
|
-
async listRefreshTokensForUser(
|
|
21533
|
-
return this.refreshTokenService.listForUser(
|
|
22210
|
+
async listRefreshTokensForUser(uid) {
|
|
22211
|
+
return this.refreshTokenService.listForUser(uid);
|
|
21534
22212
|
}
|
|
21535
|
-
async deleteRefreshTokenById(id,
|
|
21536
|
-
await this.refreshTokenService.deleteById(id,
|
|
22213
|
+
async deleteRefreshTokenById(id, uid) {
|
|
22214
|
+
await this.refreshTokenService.deleteById(id, uid);
|
|
21537
22215
|
}
|
|
21538
|
-
async createPasswordResetToken(
|
|
21539
|
-
await this.passwordResetTokenService.createToken(
|
|
22216
|
+
async createPasswordResetToken(uid, tokenHash, expiresAt) {
|
|
22217
|
+
await this.passwordResetTokenService.createToken(uid, tokenHash, expiresAt);
|
|
21540
22218
|
}
|
|
21541
22219
|
async findValidPasswordResetToken(tokenHash) {
|
|
21542
22220
|
return this.passwordResetTokenService.findValidByHash(tokenHash);
|
|
@@ -21544,14 +22222,14 @@ var PostgresTokenRepository = class {
|
|
|
21544
22222
|
async markPasswordResetTokenUsed(tokenHash) {
|
|
21545
22223
|
await this.passwordResetTokenService.markAsUsed(tokenHash);
|
|
21546
22224
|
}
|
|
21547
|
-
async deleteAllPasswordResetTokensForUser(
|
|
21548
|
-
await this.passwordResetTokenService.deleteAllForUser(
|
|
22225
|
+
async deleteAllPasswordResetTokensForUser(uid) {
|
|
22226
|
+
await this.passwordResetTokenService.deleteAllForUser(uid);
|
|
21549
22227
|
}
|
|
21550
22228
|
async deleteExpiredTokens() {
|
|
21551
22229
|
await this.passwordResetTokenService.deleteExpired();
|
|
21552
22230
|
}
|
|
21553
|
-
async createMagicLinkToken(
|
|
21554
|
-
await this.magicLinkTokenService.createToken(
|
|
22231
|
+
async createMagicLinkToken(uid, tokenHash, expiresAt) {
|
|
22232
|
+
await this.magicLinkTokenService.createToken(uid, tokenHash, expiresAt);
|
|
21555
22233
|
}
|
|
21556
22234
|
async findValidMagicLinkToken(tokenHash) {
|
|
21557
22235
|
return this.magicLinkTokenService.findValidByHash(tokenHash);
|
|
@@ -21586,11 +22264,11 @@ var PostgresAuthRepository = class {
|
|
|
21586
22264
|
async getUserByIdentity(provider, providerId) {
|
|
21587
22265
|
return this.userService.getUserByIdentity(provider, providerId);
|
|
21588
22266
|
}
|
|
21589
|
-
async getUserIdentities(
|
|
21590
|
-
return this.userService.getUserIdentities(
|
|
22267
|
+
async getUserIdentities(uid) {
|
|
22268
|
+
return this.userService.getUserIdentities(uid);
|
|
21591
22269
|
}
|
|
21592
|
-
async linkUserIdentity(
|
|
21593
|
-
return this.userService.linkUserIdentity(
|
|
22270
|
+
async linkUserIdentity(uid, provider, providerId, profileData) {
|
|
22271
|
+
return this.userService.linkUserIdentity(uid, provider, providerId, profileData);
|
|
21594
22272
|
}
|
|
21595
22273
|
async updateUser(id, data) {
|
|
21596
22274
|
return this.userService.updateUser(id, data);
|
|
@@ -21616,20 +22294,20 @@ var PostgresAuthRepository = class {
|
|
|
21616
22294
|
async getUserByVerificationToken(token) {
|
|
21617
22295
|
return this.userService.getUserByVerificationToken(token);
|
|
21618
22296
|
}
|
|
21619
|
-
async getUserRoles(
|
|
21620
|
-
return this.userService.getUserRoles(
|
|
22297
|
+
async getUserRoles(uid) {
|
|
22298
|
+
return this.userService.getUserRoles(uid);
|
|
21621
22299
|
}
|
|
21622
|
-
async getUserRoleIds(
|
|
21623
|
-
return this.userService.getUserRoleIds(
|
|
22300
|
+
async getUserRoleIds(uid) {
|
|
22301
|
+
return this.userService.getUserRoleIds(uid);
|
|
21624
22302
|
}
|
|
21625
|
-
async setUserRoles(
|
|
21626
|
-
await this.userService.setUserRoles(
|
|
22303
|
+
async setUserRoles(uid, roleIds) {
|
|
22304
|
+
await this.userService.setUserRoles(uid, roleIds);
|
|
21627
22305
|
}
|
|
21628
|
-
async assignDefaultRole(
|
|
21629
|
-
await this.userService.assignDefaultRole(
|
|
22306
|
+
async assignDefaultRole(uid, roleId) {
|
|
22307
|
+
await this.userService.assignDefaultRole(uid, roleId);
|
|
21630
22308
|
}
|
|
21631
|
-
async getUserWithRoles(
|
|
21632
|
-
return this.userService.getUserWithRoles(
|
|
22309
|
+
async getUserWithRoles(uid) {
|
|
22310
|
+
return this.userService.getUserWithRoles(uid);
|
|
21633
22311
|
}
|
|
21634
22312
|
async getRoleById(id) {
|
|
21635
22313
|
return {
|
|
@@ -21684,8 +22362,23 @@ var PostgresAuthRepository = class {
|
|
|
21684
22362
|
};
|
|
21685
22363
|
}
|
|
21686
22364
|
async deleteRole(_id) {}
|
|
21687
|
-
async createRefreshToken(
|
|
21688
|
-
await this.tokenRepository.createRefreshToken(
|
|
22365
|
+
async createRefreshToken(uid, tokenHash, expiresAt, userAgent, ipAddress, session) {
|
|
22366
|
+
await this.tokenRepository.createRefreshToken(uid, tokenHash, expiresAt, userAgent, ipAddress, session);
|
|
22367
|
+
}
|
|
22368
|
+
async markRefreshTokenRotated(tokenHash) {
|
|
22369
|
+
await this.tokenRepository.markRefreshTokenRotated(tokenHash);
|
|
22370
|
+
}
|
|
22371
|
+
async revokeRefreshTokenSession(sessionId) {
|
|
22372
|
+
await this.tokenRepository.revokeRefreshTokenSession(sessionId);
|
|
22373
|
+
}
|
|
22374
|
+
async pruneRefreshTokens(uid, sessionId, supersededBefore) {
|
|
22375
|
+
await this.tokenRepository.pruneRefreshTokens(uid, sessionId, supersededBefore);
|
|
22376
|
+
}
|
|
22377
|
+
async getTokensValidAfter(uid) {
|
|
22378
|
+
return this.tokenRepository.getTokensValidAfter(uid);
|
|
22379
|
+
}
|
|
22380
|
+
async setTokensValidAfter(uid, at) {
|
|
22381
|
+
await this.tokenRepository.setTokensValidAfter(uid, at);
|
|
21689
22382
|
}
|
|
21690
22383
|
async findRefreshTokenByHash(tokenHash) {
|
|
21691
22384
|
return this.tokenRepository.findRefreshTokenByHash(tokenHash);
|
|
@@ -21693,17 +22386,17 @@ var PostgresAuthRepository = class {
|
|
|
21693
22386
|
async deleteRefreshToken(tokenHash) {
|
|
21694
22387
|
await this.tokenRepository.deleteRefreshToken(tokenHash);
|
|
21695
22388
|
}
|
|
21696
|
-
async deleteAllRefreshTokensForUser(
|
|
21697
|
-
await this.tokenRepository.deleteAllRefreshTokensForUser(
|
|
22389
|
+
async deleteAllRefreshTokensForUser(uid) {
|
|
22390
|
+
await this.tokenRepository.deleteAllRefreshTokensForUser(uid);
|
|
21698
22391
|
}
|
|
21699
|
-
async listRefreshTokensForUser(
|
|
21700
|
-
return this.tokenRepository.listRefreshTokensForUser(
|
|
22392
|
+
async listRefreshTokensForUser(uid) {
|
|
22393
|
+
return this.tokenRepository.listRefreshTokensForUser(uid);
|
|
21701
22394
|
}
|
|
21702
|
-
async deleteRefreshTokenById(id,
|
|
21703
|
-
await this.tokenRepository.deleteRefreshTokenById(id,
|
|
22395
|
+
async deleteRefreshTokenById(id, uid) {
|
|
22396
|
+
await this.tokenRepository.deleteRefreshTokenById(id, uid);
|
|
21704
22397
|
}
|
|
21705
|
-
async createPasswordResetToken(
|
|
21706
|
-
await this.tokenRepository.createPasswordResetToken(
|
|
22398
|
+
async createPasswordResetToken(uid, tokenHash, expiresAt) {
|
|
22399
|
+
await this.tokenRepository.createPasswordResetToken(uid, tokenHash, expiresAt);
|
|
21707
22400
|
}
|
|
21708
22401
|
async findValidPasswordResetToken(tokenHash) {
|
|
21709
22402
|
return this.tokenRepository.findValidPasswordResetToken(tokenHash);
|
|
@@ -21711,14 +22404,14 @@ var PostgresAuthRepository = class {
|
|
|
21711
22404
|
async markPasswordResetTokenUsed(tokenHash) {
|
|
21712
22405
|
await this.tokenRepository.markPasswordResetTokenUsed(tokenHash);
|
|
21713
22406
|
}
|
|
21714
|
-
async deleteAllPasswordResetTokensForUser(
|
|
21715
|
-
await this.tokenRepository.deleteAllPasswordResetTokensForUser(
|
|
22407
|
+
async deleteAllPasswordResetTokensForUser(uid) {
|
|
22408
|
+
await this.tokenRepository.deleteAllPasswordResetTokensForUser(uid);
|
|
21716
22409
|
}
|
|
21717
22410
|
async deleteExpiredTokens() {
|
|
21718
22411
|
await this.tokenRepository.deleteExpiredTokens();
|
|
21719
22412
|
}
|
|
21720
|
-
async createMagicLinkToken(
|
|
21721
|
-
await this.tokenRepository.createMagicLinkToken(
|
|
22413
|
+
async createMagicLinkToken(uid, tokenHash, expiresAt) {
|
|
22414
|
+
await this.tokenRepository.createMagicLinkToken(uid, tokenHash, expiresAt);
|
|
21722
22415
|
}
|
|
21723
22416
|
async findValidMagicLinkToken(tokenHash) {
|
|
21724
22417
|
return this.tokenRepository.findValidMagicLinkToken(tokenHash);
|
|
@@ -21731,11 +22424,11 @@ var PostgresAuthRepository = class {
|
|
|
21731
22424
|
if (!this._mfaService) this._mfaService = new MfaService(this.db);
|
|
21732
22425
|
return this._mfaService;
|
|
21733
22426
|
}
|
|
21734
|
-
async createMfaFactor(
|
|
21735
|
-
return this.getMfaService().createMfaFactor(
|
|
22427
|
+
async createMfaFactor(uid, factorType, secretEncrypted, friendlyName) {
|
|
22428
|
+
return this.getMfaService().createMfaFactor(uid, factorType, secretEncrypted, friendlyName);
|
|
21736
22429
|
}
|
|
21737
|
-
async getMfaFactors(
|
|
21738
|
-
return this.getMfaService().getMfaFactors(
|
|
22430
|
+
async getMfaFactors(uid) {
|
|
22431
|
+
return this.getMfaService().getMfaFactors(uid);
|
|
21739
22432
|
}
|
|
21740
22433
|
async getMfaFactorById(factorId) {
|
|
21741
22434
|
return this.getMfaService().getMfaFactorById(factorId);
|
|
@@ -21743,8 +22436,8 @@ var PostgresAuthRepository = class {
|
|
|
21743
22436
|
async verifyMfaFactor(factorId) {
|
|
21744
22437
|
return this.getMfaService().verifyMfaFactor(factorId);
|
|
21745
22438
|
}
|
|
21746
|
-
async deleteMfaFactor(factorId,
|
|
21747
|
-
return this.getMfaService().deleteMfaFactor(factorId,
|
|
22439
|
+
async deleteMfaFactor(factorId, uid) {
|
|
22440
|
+
return this.getMfaService().deleteMfaFactor(factorId, uid);
|
|
21748
22441
|
}
|
|
21749
22442
|
async createMfaChallenge(factorId, ipAddress) {
|
|
21750
22443
|
return this.getMfaService().createMfaChallenge(factorId, ipAddress);
|
|
@@ -21755,20 +22448,20 @@ var PostgresAuthRepository = class {
|
|
|
21755
22448
|
async verifyMfaChallenge(challengeId) {
|
|
21756
22449
|
return this.getMfaService().verifyMfaChallenge(challengeId);
|
|
21757
22450
|
}
|
|
21758
|
-
async createRecoveryCodes(
|
|
21759
|
-
return this.getMfaService().createRecoveryCodes(
|
|
22451
|
+
async createRecoveryCodes(uid, codeHashes) {
|
|
22452
|
+
return this.getMfaService().createRecoveryCodes(uid, codeHashes);
|
|
21760
22453
|
}
|
|
21761
|
-
async useRecoveryCode(
|
|
21762
|
-
return this.getMfaService().useRecoveryCode(
|
|
22454
|
+
async useRecoveryCode(uid, codeHash) {
|
|
22455
|
+
return this.getMfaService().useRecoveryCode(uid, codeHash);
|
|
21763
22456
|
}
|
|
21764
|
-
async getUnusedRecoveryCodeCount(
|
|
21765
|
-
return this.getMfaService().getUnusedRecoveryCodeCount(
|
|
22457
|
+
async getUnusedRecoveryCodeCount(uid) {
|
|
22458
|
+
return this.getMfaService().getUnusedRecoveryCodeCount(uid);
|
|
21766
22459
|
}
|
|
21767
|
-
async deleteAllRecoveryCodes(
|
|
21768
|
-
return this.getMfaService().deleteAllRecoveryCodes(
|
|
22460
|
+
async deleteAllRecoveryCodes(uid) {
|
|
22461
|
+
return this.getMfaService().deleteAllRecoveryCodes(uid);
|
|
21769
22462
|
}
|
|
21770
|
-
async hasVerifiedMfaFactors(
|
|
21771
|
-
return this.getMfaService().hasVerifiedMfaFactors(
|
|
22463
|
+
async hasVerifiedMfaFactors(uid) {
|
|
22464
|
+
return this.getMfaService().hasVerifiedMfaFactors(uid);
|
|
21772
22465
|
}
|
|
21773
22466
|
};
|
|
21774
22467
|
/**
|
|
@@ -21785,16 +22478,16 @@ var MfaService = class {
|
|
|
21785
22478
|
qualify(tableName) {
|
|
21786
22479
|
return `"${this.schemaName}"."${tableName}"`;
|
|
21787
22480
|
}
|
|
21788
|
-
async createMfaFactor(
|
|
22481
|
+
async createMfaFactor(uid, factorType, secretEncrypted, friendlyName) {
|
|
21789
22482
|
const tableName = this.qualify("mfa_factors");
|
|
21790
22483
|
const row = (await this.db.execute(sql`
|
|
21791
|
-
INSERT INTO ${sql.raw(tableName)} (
|
|
21792
|
-
VALUES (${
|
|
21793
|
-
RETURNING id,
|
|
22484
|
+
INSERT INTO ${sql.raw(tableName)} (uid, factor_type, secret_encrypted, friendly_name)
|
|
22485
|
+
VALUES (${uid}, ${factorType}, ${secretEncrypted}, ${friendlyName ?? null})
|
|
22486
|
+
RETURNING id, uid, factor_type, friendly_name, verified, created_at, updated_at
|
|
21794
22487
|
`)).rows[0];
|
|
21795
22488
|
return {
|
|
21796
22489
|
id: row.id,
|
|
21797
|
-
|
|
22490
|
+
uid: row.uid,
|
|
21798
22491
|
factorType: row.factor_type,
|
|
21799
22492
|
friendlyName: row.friendly_name ?? void 0,
|
|
21800
22493
|
verified: row.verified,
|
|
@@ -21802,16 +22495,16 @@ var MfaService = class {
|
|
|
21802
22495
|
updatedAt: new Date(row.updated_at)
|
|
21803
22496
|
};
|
|
21804
22497
|
}
|
|
21805
|
-
async getMfaFactors(
|
|
22498
|
+
async getMfaFactors(uid) {
|
|
21806
22499
|
const tableName = this.qualify("mfa_factors");
|
|
21807
22500
|
return (await this.db.execute(sql`
|
|
21808
|
-
SELECT id,
|
|
22501
|
+
SELECT id, uid, factor_type, friendly_name, verified, created_at, updated_at
|
|
21809
22502
|
FROM ${sql.raw(tableName)}
|
|
21810
|
-
WHERE
|
|
22503
|
+
WHERE uid = ${uid}
|
|
21811
22504
|
ORDER BY created_at
|
|
21812
22505
|
`)).rows.map((row) => ({
|
|
21813
22506
|
id: row.id,
|
|
21814
|
-
|
|
22507
|
+
uid: row.uid,
|
|
21815
22508
|
factorType: row.factor_type,
|
|
21816
22509
|
friendlyName: row.friendly_name ?? void 0,
|
|
21817
22510
|
verified: row.verified,
|
|
@@ -21822,7 +22515,7 @@ var MfaService = class {
|
|
|
21822
22515
|
async getMfaFactorById(factorId) {
|
|
21823
22516
|
const tableName = this.qualify("mfa_factors");
|
|
21824
22517
|
const result = await this.db.execute(sql`
|
|
21825
|
-
SELECT id,
|
|
22518
|
+
SELECT id, uid, factor_type, secret_encrypted, friendly_name, verified, created_at, updated_at
|
|
21826
22519
|
FROM ${sql.raw(tableName)}
|
|
21827
22520
|
WHERE id = ${factorId}
|
|
21828
22521
|
`);
|
|
@@ -21830,7 +22523,7 @@ var MfaService = class {
|
|
|
21830
22523
|
const row = result.rows[0];
|
|
21831
22524
|
return {
|
|
21832
22525
|
id: row.id,
|
|
21833
|
-
|
|
22526
|
+
uid: row.uid,
|
|
21834
22527
|
factorType: row.factor_type,
|
|
21835
22528
|
secretEncrypted: row.secret_encrypted,
|
|
21836
22529
|
friendlyName: row.friendly_name ?? void 0,
|
|
@@ -21847,11 +22540,11 @@ var MfaService = class {
|
|
|
21847
22540
|
WHERE id = ${factorId}
|
|
21848
22541
|
`);
|
|
21849
22542
|
}
|
|
21850
|
-
async deleteMfaFactor(factorId,
|
|
22543
|
+
async deleteMfaFactor(factorId, uid) {
|
|
21851
22544
|
const tableName = this.qualify("mfa_factors");
|
|
21852
22545
|
await this.db.execute(sql`
|
|
21853
22546
|
DELETE FROM ${sql.raw(tableName)}
|
|
21854
|
-
WHERE id = ${factorId} AND
|
|
22547
|
+
WHERE id = ${factorId} AND uid = ${uid}
|
|
21855
22548
|
`);
|
|
21856
22549
|
}
|
|
21857
22550
|
async createMfaChallenge(factorId, ipAddress) {
|
|
@@ -21895,43 +22588,43 @@ var MfaService = class {
|
|
|
21895
22588
|
WHERE id = ${challengeId}
|
|
21896
22589
|
`);
|
|
21897
22590
|
}
|
|
21898
|
-
async createRecoveryCodes(
|
|
22591
|
+
async createRecoveryCodes(uid, codeHashes) {
|
|
21899
22592
|
const tableName = this.qualify("recovery_codes");
|
|
21900
22593
|
await this.db.execute(sql`
|
|
21901
|
-
DELETE FROM ${sql.raw(tableName)} WHERE
|
|
22594
|
+
DELETE FROM ${sql.raw(tableName)} WHERE uid = ${uid}
|
|
21902
22595
|
`);
|
|
21903
22596
|
for (const hash of codeHashes) await this.db.execute(sql`
|
|
21904
|
-
INSERT INTO ${sql.raw(tableName)} (
|
|
21905
|
-
VALUES (${
|
|
22597
|
+
INSERT INTO ${sql.raw(tableName)} (uid, code_hash)
|
|
22598
|
+
VALUES (${uid}, ${hash})
|
|
21906
22599
|
`);
|
|
21907
22600
|
}
|
|
21908
|
-
async useRecoveryCode(
|
|
22601
|
+
async useRecoveryCode(uid, codeHash) {
|
|
21909
22602
|
const tableName = this.qualify("recovery_codes");
|
|
21910
22603
|
return (await this.db.execute(sql`
|
|
21911
22604
|
UPDATE ${sql.raw(tableName)}
|
|
21912
22605
|
SET used_at = NOW()
|
|
21913
|
-
WHERE
|
|
22606
|
+
WHERE uid = ${uid} AND code_hash = ${codeHash} AND used_at IS NULL
|
|
21914
22607
|
RETURNING id
|
|
21915
22608
|
`)).rows.length > 0;
|
|
21916
22609
|
}
|
|
21917
|
-
async getUnusedRecoveryCodeCount(
|
|
22610
|
+
async getUnusedRecoveryCodeCount(uid) {
|
|
21918
22611
|
const tableName = this.qualify("recovery_codes");
|
|
21919
22612
|
return (await this.db.execute(sql`
|
|
21920
22613
|
SELECT COUNT(*)::int as count FROM ${sql.raw(tableName)}
|
|
21921
|
-
WHERE
|
|
22614
|
+
WHERE uid = ${uid} AND used_at IS NULL
|
|
21922
22615
|
`)).rows[0].count;
|
|
21923
22616
|
}
|
|
21924
|
-
async deleteAllRecoveryCodes(
|
|
22617
|
+
async deleteAllRecoveryCodes(uid) {
|
|
21925
22618
|
const tableName = this.qualify("recovery_codes");
|
|
21926
22619
|
await this.db.execute(sql`
|
|
21927
|
-
DELETE FROM ${sql.raw(tableName)} WHERE
|
|
22620
|
+
DELETE FROM ${sql.raw(tableName)} WHERE uid = ${uid}
|
|
21928
22621
|
`);
|
|
21929
22622
|
}
|
|
21930
|
-
async hasVerifiedMfaFactors(
|
|
22623
|
+
async hasVerifiedMfaFactors(uid) {
|
|
21931
22624
|
const tableName = this.qualify("mfa_factors");
|
|
21932
22625
|
return (await this.db.execute(sql`
|
|
21933
22626
|
SELECT COUNT(*)::int as count FROM ${sql.raw(tableName)}
|
|
21934
|
-
WHERE
|
|
22627
|
+
WHERE uid = ${uid} AND verified = TRUE
|
|
21935
22628
|
`)).rows[0].count > 0;
|
|
21936
22629
|
}
|
|
21937
22630
|
};
|
|
@@ -22171,6 +22864,20 @@ function patchPgArrayNullSafety(tables) {
|
|
|
22171
22864
|
if (patchedCount > 0) logger.debug(`[PgArray] Patched ${patchedCount} array column(s) for null-safety`);
|
|
22172
22865
|
}
|
|
22173
22866
|
//#endregion
|
|
22867
|
+
//#region src/schema/introspect-db-naming.ts
|
|
22868
|
+
/**
|
|
22869
|
+
* Naming helpers shared by the introspection modules. These live apart from
|
|
22870
|
+
* `introspect-db-logic.ts` because the inference pass needs them too, and
|
|
22871
|
+
* importing them from there would close a cycle back through this module.
|
|
22872
|
+
*/
|
|
22873
|
+
/**
|
|
22874
|
+
* Convert a snake_case name to a human-readable Title Case label.
|
|
22875
|
+
* e.g. "created_at" -> "Created At", "customer_id" -> "Customer Id"
|
|
22876
|
+
*/
|
|
22877
|
+
function humanize(snakeName) {
|
|
22878
|
+
return snakeName.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
22879
|
+
}
|
|
22880
|
+
//#endregion
|
|
22174
22881
|
//#region src/schema/introspect-db-logic.ts
|
|
22175
22882
|
var IRREGULAR_SINGULARS = {
|
|
22176
22883
|
people: "person",
|
|
@@ -22230,13 +22937,6 @@ function singularize(word) {
|
|
|
22230
22937
|
if (lower.endsWith("s") && !lower.endsWith("ss") && !lower.endsWith("us") && !lower.endsWith("is")) return word.slice(0, -1);
|
|
22231
22938
|
return word;
|
|
22232
22939
|
}
|
|
22233
|
-
/**
|
|
22234
|
-
* Convert a snake_case name to a human-readable Title Case label.
|
|
22235
|
-
* e.g. "created_at" -> "Created At", "customer_id" -> "Customer Id"
|
|
22236
|
-
*/
|
|
22237
|
-
function humanize(snakeName) {
|
|
22238
|
-
return snakeName.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
22239
|
-
}
|
|
22240
22940
|
function getIconForTable(tableName) {
|
|
22241
22941
|
const table = tableName.toLowerCase();
|
|
22242
22942
|
if (table.includes("user") || table.includes("account") || table.includes("member") || table.includes("customer") || table.includes("client") || table.includes("patient")) return "Users";
|
|
@@ -22757,6 +23457,11 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
22757
23457
|
} catch (err) {
|
|
22758
23458
|
logger.warn("⚠️ Could not initialize branch metadata table", { error: err });
|
|
22759
23459
|
}
|
|
23460
|
+
try {
|
|
23461
|
+
await realtimeService.configureChannelHistory(pgConfig.realtime?.channels);
|
|
23462
|
+
} catch (err) {
|
|
23463
|
+
logger.warn("⚠️ Could not initialize channel history tables — retained channels will not replay", { error: err });
|
|
23464
|
+
}
|
|
22760
23465
|
const directUrl = process.env.DATABASE_DIRECT_URL || pgConfig.connectionString;
|
|
22761
23466
|
const validModes = new Set([
|
|
22762
23467
|
"auto",
|