fix(sessions): carry the signed-in sessions across a demo reset and a restore

Both swap the whole database file. The demo reset copies a baseline
saved at first seed, before anyone logged in, so it brings back an empty
user_sessions table; a backup restore brings the backup's own rows, or
none for a backup from before sessions were tracked. A session token
whose row is missing is refused, so the hourly reset signed every demo
visitor and the demo admin out, and the admin who ran a restore got a
401 on the next request. Before session tracking only the password
version applied, and those tokens survived.

Both paths now read the active rows with their owner's email before the
swap and put them back after the reopen, in a fresh fork, for every
account the new file still holds under the same id and email. A row the
new file already has is left as it is, so a session that ended there
stays ended, and a session whose id now names somebody else (a backup
from another install) is dropped instead of handed to them. The password
version check still refuses a token the restored account no longer
matches. Reading or writing them is best effort and never fails the
reset or the restore.
This commit is contained in:
Maurice
2026-10-08 17:24:26 +02:00
parent 9aab9e7513
commit 474beb0dfb
9 changed files with 379 additions and 0 deletions
@@ -1,4 +1,5 @@
import type { UserSessions } from '../entities/UserSessions.entity';
import { Users } from '../entities/Users.entity';
import type { AssertRowKeys } from './_shared/rows';
import { TrekRepository } from './_shared/trek-repository';
@@ -18,6 +19,14 @@ const _userSessionRowKeys: AssertRowKeys<UserSessionRow, UserSessions> = true;
/** What a session list shows: the row without its owner, who is the caller. */
export type UserSessionListRow = Omit<UserSessionRow, 'user_id' | 'revoked_at'>;
/**
* An active session read before a database file swap, with the email its
* owner had then, so it is put back only for that same account.
*/
export interface CarriedUserSessionRow extends Omit<UserSessionRow, 'revoked_at'> {
email: string;
}
/** The columns a new sign-in writes; `last_seen_at` starts at `created_at`. */
export interface NewUserSessionRow {
id: string;
@@ -124,6 +133,70 @@ export class UserSessionsRepository extends TrekRepository<UserSessions> {
);
}
/**
* Every active session with its owner's email, read before the database
* file is swapped (a demo reset, a backup restore):
* `SELECT s.* FROM user_sessions s WHERE s.revoked_at IS NULL AND s.expires_at > ?`,
* then `SELECT id, email FROM users WHERE id IN (...)`.
*/
async listActiveToCarry(now: string): Promise<CarriedUserSessionRow[]> {
const rows = await this.find({ revoked_at: null, expires_at: { $gt: now } }, { orderBy: { id: 'asc' } });
if (rows.length === 0) return [];
const emails = await this.ownerEmails(rows.map((row) => row.user.id));
const carried: CarriedUserSessionRow[] = [];
for (const row of rows) {
const email = emails.get(row.user.id);
if (email === undefined) continue;
carried.push({
id: row.id,
user_id: row.user.id,
email,
created_at: row.created_at,
last_seen_at: row.last_seen_at,
expires_at: row.expires_at,
user_agent: row.user_agent ?? null,
});
}
return carried;
}
/**
* Put the sessions `listActiveToCarry` read back after the swap:
* `INSERT INTO user_sessions (...) VALUES (...) ON CONFLICT (id) DO NOTHING`
* for each one whose user is still there under the same id and the same
* email. A row the swapped-in file already holds is left as it is, revoked
* or not, and a session whose id now belongs to somebody else (a backup
* from another install) is dropped rather than handed to them. Answers how
* many were offered for insert.
*/
async restoreCarried(rows: readonly CarriedUserSessionRow[]): Promise<number> {
if (rows.length === 0) return 0;
const emails = await this.ownerEmails(rows.map((row) => row.user_id));
const kept = rows.filter((row) => emails.get(row.user_id) === row.email);
if (kept.length === 0) return 0;
await this.upsertMany(
kept.map((row) => ({
id: row.id,
user: row.user_id,
created_at: row.created_at,
last_seen_at: row.last_seen_at,
expires_at: row.expires_at,
revoked_at: null,
user_agent: row.user_agent,
})),
{ onConflictFields: ['id'], onConflictAction: 'ignore' },
);
return kept.length;
}
/** `SELECT id, email FROM users WHERE id IN (...)`, as a map. */
private async ownerEmails(userIds: readonly number[]): Promise<Map<number, string>> {
const owners = await this.getEntityManager()
.getRepository(Users)
.find({ id: { $in: [...new Set(userIds)] } }, { fields: ['id', 'email'] });
return new Map(owners.map((owner) => [owner.id, owner.email]));
}
/**
* The nightly purge: `DELETE FROM user_sessions WHERE expires_at <= ? OR revoked_at IS NOT NULL`.
* A row that is gone refuses its token just as a revoked one does, so
+22
View File
@@ -5,6 +5,9 @@ import { readEnv } from '../app-config';
import { resolveDataPaths } from '../app-config/data-paths';
import { DemoRepository, type DemoAdminCredentialsRow, type DemoInstanceKeyRow } from '../db/repositories/DemoRepository';
import { DatabaseConnectionLostError, type DatabaseBackupStrategy } from '../nest/database/database-backup.interface';
import type { CarriedUserSessionRow } from '../db/repositories/UserSessions.repository';
import { UserSessions } from '../db/entities/UserSessions.entity';
import { dbNow } from '../db/types';
const baselinePath = path.join(resolveDataPaths().dataDir, 'travel-baseline.db');
@@ -76,6 +79,17 @@ async function resetDemoUser(database: DatabaseBackupStrategy): Promise<void> {
console.error('[Demo Reset] Failed to read instance API keys:', e instanceof Error ? e.message : e);
}
// Everyone signed in stays signed in. The baseline was saved at first seed,
// before anybody logged in, so it holds no session rows, and a session
// token whose row is gone is refused: without this every demo visitor and
// the demo admin would be signed out on the hour.
let liveSessions: CarriedUserSessionRow[] = [];
try {
liveSessions = await preSwapEm.getRepository(UserSessions).listActiveToCarry(dbNow());
} catch (e: unknown) {
console.error('[Demo Reset] Failed to read the active sessions:', e instanceof Error ? e.message : e);
}
// Flush the WAL into the main file before the connection closes. Best effort.
try {
await database.checkpoint();
@@ -128,6 +142,14 @@ async function resetDemoUser(database: DatabaseBackupStrategy): Promise<void> {
console.error('[Demo Reset] Failed to restore instance API keys:', e instanceof Error ? e.message : e);
}
}
if (liveSessions.length) {
try {
await freshEm.getRepository(UserSessions).restoreCarried(liveSessions);
} catch (e: unknown) {
console.error('[Demo Reset] Failed to restore the active sessions:', e instanceof Error ? e.message : e);
}
}
});
console.log('[Demo Reset] Database restored from baseline');
+44
View File
@@ -4,6 +4,10 @@ import { pipeline } from 'node:stream/promises';
import { readEnv } from '../../app-config';
import { resolveDataPaths } from '../../app-config/data-paths';
import fs from 'fs';
import { RequestContext } from '@mikro-orm/core';
import type { CarriedUserSessionRow } from '../../db/repositories/UserSessions.repository';
import { UserSessions } from '../../db/entities/UserSessions.entity';
import { dbNow } from '../../db/types';
import { logError, logWarn } from '../audit/audit-log.logger';
import type { DatabaseBackupStrategy } from '../database/database-backup.interface';
import { VALID_INTERVALS } from './auto-backup.settings';
@@ -410,6 +414,44 @@ export async function rehydrateUploads(storage: StorageService, extractedUploads
}
}
/**
* The sessions signed in right now, read before the swap. The restored file
* brings the backup's own `user_sessions` rows (none at all for a backup from
* before sessions were tracked), and a session token whose row is missing is
* refused, so without carrying these across the admin who ran the restore and
* everybody else would be signed out by it. Best effort, like the snapshot:
* a missing request context or an unreadable table carries nothing.
*/
async function readSessionsToCarry(): Promise<CarriedUserSessionRow[]> {
try {
const em = RequestContext.getEntityManager();
if (!em) return [];
return await em.getRepository(UserSessions).listActiveToCarry(dbNow());
} catch (err) {
logWarn(`Restore: could not read the active sessions (${err instanceof Error ? err.message : String(err)})`);
return [];
}
}
/**
* Put the carried sessions into the restored database, in a fresh fork so no
* entity read from the replaced file is flushed into it. Only a session whose
* user is still there under the same id and email comes back; the password
* version check still refuses a token the restored account no longer matches.
*/
async function restoreCarriedSessions(rows: readonly CarriedUserSessionRow[]): Promise<void> {
const em = RequestContext.getEntityManager();
if (!em || rows.length === 0) return;
try {
await RequestContext.create(em, async () => {
const fresh = RequestContext.getEntityManager() ?? em;
await fresh.getRepository(UserSessions).restoreCarried(rows);
});
} catch (err) {
logWarn(`Restore: could not keep the active sessions (${err instanceof Error ? err.message : String(err)})`);
}
}
export async function restoreFromZip({ storage, database }: BackupDeps, zipPath: string): Promise<RestoreResult> {
const extractDir = path.join(dataDir, `restore-${Date.now()}`);
let reinitFailed: unknown;
@@ -424,6 +466,7 @@ export async function restoreFromZip({ storage, database }: BackupDeps, zipPath:
}
await database.keepCopyBeforeRestore();
const liveSessions = await readSessionsToCarry();
try {
// Closes the connection, swaps the database in and reopens it, migrating
@@ -465,6 +508,7 @@ export async function restoreFromZip({ storage, database }: BackupDeps, zipPath:
// nothing to read, and the restore is already reported as "restart
// required" below — rehydrating into a stale/guessed config would be worse.
await storage.reloadConfig();
await restoreCarriedSessions(liveSessions);
const extractedUploads = path.join(extractDir, 'uploads');
if (fs.existsSync(extractedUploads)) {