feat: user session management (#2056)

* user session management

* WIP

* cleanup

* license

* cleanup

* don't cache index

* rename current device property

* fix
This commit is contained in:
Philip Okugbe
2026-03-26 20:00:04 +00:00
committed by GitHub
parent 4e8f533b91
commit 803f1f0b81
32 changed files with 928 additions and 49 deletions
@@ -17,6 +17,7 @@ import { KyselyDB } from '@docmost/db/types/kysely.types';
import * as process from 'node:process';
import { MigrationService } from '@docmost/db/services/migration.service';
import { UserTokenRepo } from './repos/user-token/user-token.repo';
import { UserSessionRepo } from '@docmost/db/repos/session/user-session.repo';
import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
import { NotificationRepo } from '@docmost/db/repos/notification/notification.repo';
@@ -76,6 +77,7 @@ import { normalizePostgresUrl } from '../common/helpers';
CommentRepo,
AttachmentRepo,
UserTokenRepo,
UserSessionRepo,
BacklinkRepo,
ShareRepo,
NotificationRepo,
@@ -95,6 +97,7 @@ import { normalizePostgresUrl } from '../common/helpers';
CommentRepo,
AttachmentRepo,
UserTokenRepo,
UserSessionRepo,
BacklinkRepo,
ShareRepo,
NotificationRepo,
@@ -0,0 +1,45 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('user_sessions')
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
)
.addColumn('user_id', 'uuid', (col) =>
col.notNull().references('users.id').onDelete('cascade'),
)
.addColumn('workspace_id', 'uuid', (col) =>
col.notNull().references('workspaces.id').onDelete('cascade'),
)
.addColumn('device_name', 'varchar')
.addColumn('user_agent', 'text')
.addColumn('ip_address', sql`inet`)
.addColumn('geo_location', 'varchar')
.addColumn('last_active_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn('expires_at', 'timestamptz', (col) => col.notNull())
.addColumn('metadata', 'jsonb')
.addColumn('revoked_at', 'timestamptz')
.addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.execute();
await sql`
CREATE INDEX idx_user_sessions_active
ON user_sessions (user_id, workspace_id, last_active_at DESC)
WHERE revoked_at IS NULL
`.execute(db);
await sql`
CREATE INDEX idx_user_sessions_revoked
ON user_sessions (expires_at)
WHERE revoked_at IS NOT NULL
`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('user_sessions').execute();
}
@@ -0,0 +1,162 @@
import {
InsertableUserSession,
UserSession,
} from '@docmost/db/types/entity.types';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { dbOrTx } from '@docmost/db/utils';
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { sql } from 'kysely';
@Injectable()
export class UserSessionRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {}
async insertSession(
session: InsertableUserSession,
trx?: KyselyTransaction,
): Promise<UserSession> {
const db = dbOrTx(this.db, trx);
return db
.insertInto('userSessions')
.values(session)
.returningAll()
.executeTakeFirstOrThrow();
}
async findActiveById(id: string): Promise<UserSession | undefined> {
return this.db
.selectFrom('userSessions')
.selectAll()
.where('id', '=', id)
.where('expiresAt', '>', new Date())
.where('revokedAt', 'is', null)
.executeTakeFirst();
}
async findActiveByUser(
userId: string,
workspaceId: string,
): Promise<UserSession[]> {
return this.db
.selectFrom('userSessions')
.selectAll()
.where('userId', '=', userId)
.where('workspaceId', '=', workspaceId)
.where('expiresAt', '>', new Date())
.where('revokedAt', 'is', null)
.orderBy('lastActiveAt', 'desc')
.execute();
}
async updateLastActiveAt(id: string): Promise<void> {
await this.db
.updateTable('userSessions')
.set({ lastActiveAt: new Date() })
.where('id', '=', id)
.execute();
}
async revokeById(
id: string,
userId: string,
workspaceId: string,
): Promise<void> {
await this.db
.updateTable('userSessions')
.set({ revokedAt: new Date() })
.where('id', '=', id)
.where('userId', '=', userId)
.where('workspaceId', '=', workspaceId)
.where('revokedAt', 'is', null)
.execute();
}
async revokeAllExceptCurrent(
currentSessionId: string,
userId: string,
workspaceId: string,
): Promise<void> {
await this.db
.updateTable('userSessions')
.set({ revokedAt: new Date() })
.where('userId', '=', userId)
.where('workspaceId', '=', workspaceId)
.where('id', '!=', currentSessionId)
.where('revokedAt', 'is', null)
.execute();
}
async revokeByUserId(
userId: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
await db
.updateTable('userSessions')
.set({ revokedAt: new Date() })
.where('userId', '=', userId)
.where('workspaceId', '=', workspaceId)
.where('revokedAt', 'is', null)
.execute();
}
async deleteByUserId(
userId: string,
workspaceId: string,
): Promise<void> {
await this.db
.deleteFrom('userSessions')
.where('userId', '=', userId)
.where('workspaceId', '=', workspaceId)
.execute();
}
async deleteAllExceptCurrent(
currentSessionId: string,
userId: string,
workspaceId: string,
): Promise<void> {
await this.db
.deleteFrom('userSessions')
.where('userId', '=', userId)
.where('workspaceId', '=', workspaceId)
.where('id', '!=', currentSessionId)
.execute();
}
async deleteStale(retentionDays: number): Promise<void> {
const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000);
await this.db
.deleteFrom('userSessions')
.where((eb) =>
eb.or([
eb('revokedAt', '<', cutoff),
eb('expiresAt', '<', cutoff),
]),
)
.execute();
}
async trimExcessSessions(maxPerUser: number): Promise<void> {
const overflowed = await this.db
.selectFrom('userSessions')
.select(['userId', 'workspaceId'])
.groupBy(['userId', 'workspaceId'])
.having(sql`COUNT(*)`, '>', maxPerUser)
.execute();
for (const { userId, workspaceId } of overflowed) {
await sql`
DELETE FROM user_sessions
WHERE id IN (
SELECT id FROM user_sessions
WHERE user_id = ${userId} AND workspace_id = ${workspaceId}
ORDER BY last_active_at DESC
OFFSET ${maxPerUser}
)
`.execute(this.db);
}
}
}
+16
View File
@@ -429,6 +429,21 @@ export interface PagePermissions {
updatedAt: Generated<Timestamp>;
}
export interface UserSessions {
id: Generated<string>;
userId: string;
workspaceId: string;
deviceName: string | null;
userAgent: string | null;
ipAddress: string | null;
geoLocation: string | null;
metadata: Json | null;
lastActiveAt: Generated<Timestamp>;
expiresAt: Timestamp;
revokedAt: Timestamp | null;
createdAt: Generated<Timestamp>;
}
export interface DB {
apiKeys: ApiKeys;
attachments: Attachments;
@@ -451,6 +466,7 @@ export interface DB {
spaces: Spaces;
userMfa: UserMfa;
users: Users;
userSessions: UserSessions;
userTokens: UserTokens;
watchers: Watchers;
workspaceInvitations: WorkspaceInvitations;
@@ -22,6 +22,7 @@ import {
Shares,
FileTasks,
UserMfa as _UserMFA,
UserSessions,
ApiKeys,
Watchers,
Audit as _Audit,
@@ -157,6 +158,11 @@ export type PagePermission = Selectable<_PagePermissions>;
export type InsertablePagePermission = Insertable<_PagePermissions>;
export type UpdatablePagePermission = Updateable<Omit<_PagePermissions, 'id'>>;
// User Session
export type UserSession = Selectable<UserSessions>;
export type InsertableUserSession = Insertable<UserSessions>;
export type UpdatableUserSession = Updateable<Omit<UserSessions, 'id'>>;
// Audit
export type Audit = Selectable<_Audit>;
export type InsertableAudit = Insertable<_Audit>;