diff --git a/src/backend/clients/database/SqliteDatabaseClient.ts b/src/backend/clients/database/SqliteDatabaseClient.ts
index 825ca37e9..13ff3ce08 100644
--- a/src/backend/clients/database/SqliteDatabaseClient.ts
+++ b/src/backend/clients/database/SqliteDatabaseClient.ts
@@ -79,6 +79,7 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [
[44, ['0048_old-app-names-unique-tuple.sql']],
[45, ['0049_music-player-pdf-player-updates.sql']],
[46, ['0050_add_preamble_version.sql']],
+ [47, ['0051_sessions_v2.sql']],
];
export class SqliteDatabaseClient extends AbstractDatabaseClient {
diff --git a/src/backend/clients/database/migrations/mysql/mysql_mig_7.sql b/src/backend/clients/database/migrations/mysql/mysql_mig_7.sql
index 672922b90..6b5b370e6 100644
--- a/src/backend/clients/database/migrations/mysql/mysql_mig_7.sql
+++ b/src/backend/clients/database/migrations/mysql/mysql_mig_7.sql
@@ -14,5 +14,26 @@
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see .
+--
+-- Idempotent: the ADD COLUMN is guarded by an INFORMATION_SCHEMA check,
+-- so re-running the migration directory is safe (required — the runner
+-- has no per-file tracking).
-ALTER TABLE `subdomains` ADD COLUMN `preamble_version` varchar(64) DEFAULT NULL;
+DROP PROCEDURE IF EXISTS _puter_add_subdomains_preamble_version;
+DELIMITER //
+CREATE PROCEDURE _puter_add_subdomains_preamble_version()
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'subdomains' AND COLUMN_NAME = 'preamble_version'
+ ) THEN
+ ALTER TABLE `subdomains`
+ ADD COLUMN `preamble_version` varchar(64) DEFAULT NULL;
+ END IF;
+END//
+DELIMITER ;
+
+CALL _puter_add_subdomains_preamble_version();
+
+DROP PROCEDURE IF EXISTS _puter_add_subdomains_preamble_version;
diff --git a/src/backend/clients/database/migrations/mysql/mysql_mig_8.sql b/src/backend/clients/database/migrations/mysql/mysql_mig_8.sql
new file mode 100644
index 000000000..b25ef2a93
--- /dev/null
+++ b/src/backend/clients/database/migrations/mysql/mysql_mig_8.sql
@@ -0,0 +1,98 @@
+-- Extend `sessions` so a single row can represent any token kind
+-- (web/app/access_token/asset), carry display metadata for the
+-- manage-sessions UI, and be soft-revoked with row-level expiry.
+-- Mirrors SQLite migration 0050.
+--
+-- Idempotent: each ADD COLUMN / ADD INDEX is guarded by an
+-- INFORMATION_SCHEMA check, so re-running the migration directory
+-- is safe (required — the runner has no per-file tracking).
+
+DROP PROCEDURE IF EXISTS _puter_extend_sessions_v2;
+DELIMITER //
+CREATE PROCEDURE _puter_extend_sessions_v2()
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'kind'
+ ) THEN
+ ALTER TABLE `sessions`
+ ADD COLUMN `kind` ENUM('web', 'app', 'access_token', 'asset')
+ NOT NULL DEFAULT 'web';
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'label'
+ ) THEN
+ ALTER TABLE `sessions` ADD COLUMN `label` VARCHAR(255) DEFAULT NULL;
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'parent_session_id'
+ ) THEN
+ ALTER TABLE `sessions`
+ ADD COLUMN `parent_session_id` VARCHAR(64) DEFAULT NULL;
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'last_ip'
+ ) THEN
+ ALTER TABLE `sessions` ADD COLUMN `last_ip` VARCHAR(64) DEFAULT NULL;
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'last_user_agent'
+ ) THEN
+ ALTER TABLE `sessions`
+ ADD COLUMN `last_user_agent` VARCHAR(512) DEFAULT NULL;
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'revoked_at'
+ ) THEN
+ ALTER TABLE `sessions` ADD COLUMN `revoked_at` BIGINT DEFAULT NULL;
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'expires_at'
+ ) THEN
+ ALTER TABLE `sessions` ADD COLUMN `expires_at` BIGINT DEFAULT NULL;
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'sessions'
+ AND INDEX_NAME = 'idx_sessions_user_revoked'
+ ) THEN
+ ALTER TABLE `sessions`
+ ADD INDEX `idx_sessions_user_revoked` (`user_id`, `revoked_at`);
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'sessions'
+ AND INDEX_NAME = 'idx_sessions_parent'
+ ) THEN
+ ALTER TABLE `sessions`
+ ADD INDEX `idx_sessions_parent` (`parent_session_id`);
+ END IF;
+END//
+DELIMITER ;
+
+CALL _puter_extend_sessions_v2();
+
+DROP PROCEDURE IF EXISTS _puter_extend_sessions_v2;
diff --git a/src/backend/clients/database/migrations/sqlite/0051_sessions_v2.sql b/src/backend/clients/database/migrations/sqlite/0051_sessions_v2.sql
new file mode 100644
index 000000000..f23a21923
--- /dev/null
+++ b/src/backend/clients/database/migrations/sqlite/0051_sessions_v2.sql
@@ -0,0 +1,34 @@
+-- Copyright (C) 2024-present Puter Technologies Inc.
+--
+-- This file is part of Puter.
+--
+-- Puter is free software: you can redistribute it and/or modify
+-- it under the terms of the GNU Affero General Public License as published
+-- by the Free Software Foundation, either version 3 of the License, or
+-- (at your option) any later version.
+--
+-- This program is distributed in the hope that it will be useful,
+-- but WITHOUT ANY WARRANTY; without even the implied warranty of
+-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+-- GNU Affero General Public License for more details.
+--
+-- You should have received a copy of the GNU Affero General Public License
+-- along with this program. If not, see .
+
+-- Extend `sessions` so a single row can represent any token kind
+-- (web/app/access_token/asset), carry display metadata for the
+-- manage-sessions UI, and be soft-revoked.
+
+ALTER TABLE `sessions` ADD COLUMN `kind` TEXT NOT NULL DEFAULT 'web'
+ CHECK (`kind` IN ('web', 'app', 'access_token', 'asset'));
+ALTER TABLE `sessions` ADD COLUMN `label` TEXT;
+ALTER TABLE `sessions` ADD COLUMN `parent_session_id` TEXT;
+ALTER TABLE `sessions` ADD COLUMN `last_ip` TEXT;
+ALTER TABLE `sessions` ADD COLUMN `last_user_agent` TEXT;
+ALTER TABLE `sessions` ADD COLUMN `revoked_at` INTEGER;
+ALTER TABLE `sessions` ADD COLUMN `expires_at` INTEGER;
+
+CREATE INDEX IF NOT EXISTS `idx_sessions_user_revoked`
+ ON `sessions` (`user_id`, `revoked_at`);
+CREATE INDEX IF NOT EXISTS `idx_sessions_parent`
+ ON `sessions` (`parent_session_id`);
diff --git a/src/backend/services/auth/AuthService.ts b/src/backend/services/auth/AuthService.ts
index 5b0d5fd39..0b859b1df 100644
--- a/src/backend/services/auth/AuthService.ts
+++ b/src/backend/services/auth/AuthService.ts
@@ -120,7 +120,12 @@ export class AuthService extends PuterService {
token: string;
gui_token: string;
}> {
- const session = await this.stores.session.create(user.id, meta);
+ const session = await this.stores.session.create(user.id, {
+ meta,
+ kind: 'web',
+ last_ip: (meta.ip as string | undefined) ?? null,
+ last_user_agent: (meta.user_agent as string | undefined) ?? null,
+ });
const token = this.services.token.sign('auth', {
type: 'session',
diff --git a/src/backend/stores/session/SessionStore.js b/src/backend/stores/session/SessionStore.js
index 1a8d096a8..90fafd873 100644
--- a/src/backend/stores/session/SessionStore.js
+++ b/src/backend/stores/session/SessionStore.js
@@ -25,7 +25,10 @@ import { PuterStore } from '../types';
// the cached row doesn't gate anything, and eating a Redis write per
// request isn't worth it.
-const CACHE_KEY_PREFIX = 'sessions';
+// Prefix is versioned so a schema change (new columns added to the
+// row shape) doesn't leak stale cached rows through Redis on a
+// rolling deploy. Bump the suffix on the next schema change.
+const CACHE_KEY_PREFIX = 'sessions:v2';
const CACHE_TTL_SECONDS = 15 * 60;
// Min interval between successive activity flushes per session/user.
// In-memory throttle keeps DB writes bounded; multi-node duplicates
@@ -44,15 +47,21 @@ export class SessionStore extends PuterStore {
#lastSessionTouchMs = new Map();
#lastUserTouchMs = new Map();
- /** Look up a session by its uuid. Returns `null` if not found. */
+ /**
+ * Look up an active session by its uuid. Returns `null` if not
+ * found or soft-revoked.
+ */
async getByUuid(uuid) {
if (!uuid) return null;
const cached = await this.#readCache(uuid);
- if (cached) return cached;
+ if (cached) {
+ if (cached.revoked_at != null) return null;
+ return cached;
+ }
const rows = await this.clients.db.read(
- 'SELECT * FROM `sessions` WHERE `uuid` = ? LIMIT 1',
+ 'SELECT * FROM `sessions` WHERE `uuid` = ? AND `revoked_at` IS NULL LIMIT 1',
[uuid],
);
const normalized = this.#normalizeRow(rows[0]);
@@ -64,23 +73,44 @@ export class SessionStore extends PuterStore {
return normalized;
}
- /** Get all sessions for a user. */
- async getByUserId(userId) {
- const rows = await this.clients.db.read(
- 'SELECT * FROM `sessions` WHERE `user_id` = ?',
- [userId],
- );
+ /**
+ * Get sessions for a user. By default returns only active rows;
+ * pass `{ includeRevoked: true }` to include soft-revoked rows.
+ */
+ async getByUserId(userId, { includeRevoked = false } = {}) {
+ const sql = includeRevoked
+ ? 'SELECT * FROM `sessions` WHERE `user_id` = ?'
+ : 'SELECT * FROM `sessions` WHERE `user_id` = ? AND `revoked_at` IS NULL';
+ const rows = await this.clients.db.read(sql, [userId]);
return rows.map((r) => this.#normalizeRow(r)).filter(Boolean);
}
/**
- * Create a new session.
+ * Create a new session row.
*
* @param userId - User ID (numeric)
- * @param meta - Metadata object (IP, user-agent, etc.)
- * @returns The created session row
+ * @param opts.meta - Request-context metadata (IP, UA, etc.) stored as JSON.
+ * @param opts.kind - 'web' (default), 'app', 'access_token', 'asset'.
+ * @param opts.label - User-editable label for manage-sessions UI.
+ * @param opts.parent_session_id - uuid of root session, for derived kinds.
+ * @param opts.last_ip - Request IP at creation.
+ * @param opts.last_user_agent - Request User-Agent at creation.
+ * @param opts.expires_at - Row-level expiry (unix seconds). NULL means
+ * JWT `exp` is the sole truth. AUTH-4 slides this forward on activity.
+ * @returns The created session row.
*/
- async create(userId, meta = {}) {
+ async create(
+ userId,
+ {
+ meta = {},
+ kind = 'web',
+ label = null,
+ parent_session_id = null,
+ last_ip = null,
+ last_user_agent = null,
+ expires_at = null,
+ } = {},
+ ) {
const uuid = uuidv4();
const now = Math.floor(Date.now() / 1000);
@@ -88,8 +118,20 @@ export class SessionStore extends PuterStore {
meta.created_unix = now;
await this.clients.db.write(
- 'INSERT INTO `sessions` (`uuid`, `user_id`, `meta`, `last_activity`, `created_at`) VALUES (?, ?, ?, ?, ?)',
- [uuid, userId, JSON.stringify(meta), now, now],
+ 'INSERT INTO `sessions` (`uuid`, `user_id`, `meta`, `last_activity`, `created_at`, `kind`, `label`, `parent_session_id`, `last_ip`, `last_user_agent`, `expires_at`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
+ [
+ uuid,
+ userId,
+ JSON.stringify(meta),
+ now,
+ now,
+ kind,
+ label,
+ parent_session_id,
+ last_ip,
+ last_user_agent,
+ expires_at,
+ ],
);
return {
@@ -98,20 +140,62 @@ export class SessionStore extends PuterStore {
meta,
created_at: now,
last_activity: now,
+ kind,
+ label,
+ parent_session_id,
+ last_ip,
+ last_user_agent,
+ revoked_at: null,
+ expires_at,
};
}
- /** Delete a session by uuid. Invalidates cache on this node + peers. */
+ /**
+ * Soft-revoke a session by uuid. The row remains in the table
+ * with `revoked_at` set; subsequent `getByUuid` calls treat it
+ * as not found. Invalidates cache on this node + peers.
+ */
async removeByUuid(uuid) {
- await this.clients.db.write('DELETE FROM `sessions` WHERE `uuid` = ?', [
- uuid,
- ]);
+ const now = Math.floor(Date.now() / 1000);
+ await this.clients.db.write(
+ 'UPDATE `sessions` SET `revoked_at` = ? WHERE `uuid` = ? AND `revoked_at` IS NULL',
+ [now, uuid],
+ );
await this.publishCacheKeys({
keys: [this.#cacheKey(uuid)],
broadcast: true,
});
}
+ /**
+ * Soft-revoke a root session and every derived session that
+ * points back to it via `parent_session_id`. Broadcasts cache
+ * invalidation for each affected row.
+ */
+ async revokeCascade(rootUuid) {
+ if (!rootUuid) return;
+
+ // Collect affected uuids first so we can broadcast cache
+ // invalidation for each row. A single UPDATE ... RETURNING
+ // would be cleaner but isn't portable between sqlite/mysql.
+ const rows = await this.clients.db.read(
+ 'SELECT `uuid` FROM `sessions` WHERE (`uuid` = ? OR `parent_session_id` = ?) AND `revoked_at` IS NULL',
+ [rootUuid, rootUuid],
+ );
+ if (rows.length === 0) return;
+
+ const now = Math.floor(Date.now() / 1000);
+ await this.clients.db.write(
+ 'UPDATE `sessions` SET `revoked_at` = ? WHERE (`uuid` = ? OR `parent_session_id` = ?) AND `revoked_at` IS NULL',
+ [now, rootUuid, rootUuid],
+ );
+
+ await this.publishCacheKeys({
+ keys: rows.map((r) => this.#cacheKey(r.uuid)),
+ broadcast: true,
+ });
+ }
+
/** Update session activity timestamp. */
async updateActivity(uuid, lastActivity) {
await this.clients.db.write(
diff --git a/src/backend/stores/session/SessionStore.test.ts b/src/backend/stores/session/SessionStore.test.ts
new file mode 100644
index 000000000..0dd00562f
--- /dev/null
+++ b/src/backend/stores/session/SessionStore.test.ts
@@ -0,0 +1,257 @@
+/**
+ * Copyright (C) 2024-present Puter Technologies Inc.
+ *
+ * This file is part of Puter.
+ *
+ * Puter is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import { v4 as uuidv4 } from 'uuid';
+import { setupTestServer } from '../../testUtil.ts';
+import { PuterServer } from '../../server.ts';
+
+describe('SessionStore', () => {
+ let server: PuterServer;
+ let target: any;
+
+ beforeAll(async () => {
+ server = await setupTestServer();
+ target = server.stores.session;
+ });
+
+ afterAll(async () => {
+ await server?.shutdown();
+ });
+
+ const makeUser = async () => {
+ const username = `ss-${Math.random().toString(36).slice(2, 10)}`;
+ return server.stores.user.create({
+ username,
+ uuid: uuidv4(),
+ password: null,
+ email: `${username}@test.local`,
+ email_confirmed: 1,
+ } as never);
+ };
+
+ // Reads the raw row (including revoked) so tests can assert
+ // soft-revoke semantics — `getByUuid` filters revoked rows.
+ const rawRow = async (uuid: string) => {
+ const rows = await server.clients.db.read(
+ 'SELECT * FROM `sessions` WHERE `uuid` = ? LIMIT 1',
+ [uuid],
+ );
+ return rows[0] ?? null;
+ };
+
+ describe('create', () => {
+ it('defaults to kind="web" and stores request metadata', async () => {
+ const user = await makeUser();
+ const session = await target.create(user.id, {
+ meta: { ip: '1.2.3.4' },
+ last_ip: '1.2.3.4',
+ last_user_agent: 'test-agent',
+ });
+
+ expect(session.kind).toBe('web');
+ expect(session.parent_session_id).toBeNull();
+ expect(session.revoked_at).toBeNull();
+
+ const row = await rawRow(session.uuid);
+ expect(row.kind).toBe('web');
+ expect(row.last_ip).toBe('1.2.3.4');
+ expect(row.last_user_agent).toBe('test-agent');
+ });
+
+ it('stores expires_at when provided', async () => {
+ const user = await makeUser();
+ const future = Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60;
+ const session = await target.create(user.id, {
+ expires_at: future,
+ });
+
+ expect(session.expires_at).toBe(future);
+ const row = await rawRow(session.uuid);
+ expect(row.expires_at).toBe(future);
+ });
+
+ it('leaves expires_at NULL by default (JWT exp is sole truth)', async () => {
+ const user = await makeUser();
+ const session = await target.create(user.id);
+ expect(session.expires_at).toBeNull();
+ const row = await rawRow(session.uuid);
+ expect(row.expires_at).toBeNull();
+ });
+
+ it('accepts derived kinds with a parent_session_id', async () => {
+ const user = await makeUser();
+ const parent = await target.create(user.id);
+ const child = await target.create(user.id, {
+ kind: 'app',
+ parent_session_id: parent.uuid,
+ label: 'Some App',
+ });
+
+ expect(child.kind).toBe('app');
+ expect(child.parent_session_id).toBe(parent.uuid);
+
+ const row = await rawRow(child.uuid);
+ expect(row.parent_session_id).toBe(parent.uuid);
+ expect(row.label).toBe('Some App');
+ });
+ });
+
+ describe('getByUuid', () => {
+ it('returns the row for an active session', async () => {
+ const user = await makeUser();
+ const session = await target.create(user.id);
+ const fetched = await target.getByUuid(session.uuid);
+ expect(fetched).toBeTruthy();
+ expect(fetched.uuid).toBe(session.uuid);
+ });
+
+ it('returns null for a soft-revoked session', async () => {
+ const user = await makeUser();
+ const session = await target.create(user.id);
+ await target.removeByUuid(session.uuid);
+ const fetched = await target.getByUuid(session.uuid);
+ expect(fetched).toBeNull();
+ });
+
+ it('returns the row even when expires_at is in the past (AUTH-4 owns expiry)', async () => {
+ const user = await makeUser();
+ const past = Math.floor(Date.now() / 1000) - 60;
+ const session = await target.create(user.id, { expires_at: past });
+ const fetched = await target.getByUuid(session.uuid);
+ expect(fetched).toBeTruthy();
+ expect(fetched.uuid).toBe(session.uuid);
+ expect(fetched.expires_at).toBe(past);
+ });
+
+ it('returns null when uuid is empty', async () => {
+ expect(await target.getByUuid('')).toBeNull();
+ expect(await target.getByUuid(undefined)).toBeNull();
+ });
+ });
+
+ describe('getByUserId', () => {
+ it('returns only active sessions by default', async () => {
+ const user = await makeUser();
+ const active = await target.create(user.id);
+ const revoked = await target.create(user.id);
+ await target.removeByUuid(revoked.uuid);
+
+ const rows = await target.getByUserId(user.id);
+ const uuids = rows.map((r: { uuid: string }) => r.uuid);
+ expect(uuids).toContain(active.uuid);
+ expect(uuids).not.toContain(revoked.uuid);
+ });
+
+ it('returns revoked sessions when includeRevoked is true', async () => {
+ const user = await makeUser();
+ const active = await target.create(user.id);
+ const revoked = await target.create(user.id);
+ await target.removeByUuid(revoked.uuid);
+
+ const rows = await target.getByUserId(user.id, {
+ includeRevoked: true,
+ });
+ const uuids = rows.map((r: { uuid: string }) => r.uuid);
+ expect(uuids).toContain(active.uuid);
+ expect(uuids).toContain(revoked.uuid);
+ });
+ });
+
+ describe('removeByUuid', () => {
+ it('soft-revokes — row remains in DB with revoked_at set', async () => {
+ const user = await makeUser();
+ const session = await target.create(user.id);
+
+ await target.removeByUuid(session.uuid);
+
+ const row = await rawRow(session.uuid);
+ expect(row).toBeTruthy();
+ expect(row.revoked_at).not.toBeNull();
+ expect(row.revoked_at).toBeGreaterThan(0);
+ });
+
+ it('is idempotent — second call does not overwrite revoked_at', async () => {
+ const user = await makeUser();
+ const session = await target.create(user.id);
+
+ await target.removeByUuid(session.uuid);
+ const firstRevokedAt = (await rawRow(session.uuid)).revoked_at;
+
+ await new Promise((r) => setTimeout(r, 1100));
+ await target.removeByUuid(session.uuid);
+ const secondRevokedAt = (await rawRow(session.uuid)).revoked_at;
+
+ expect(secondRevokedAt).toBe(firstRevokedAt);
+ });
+ });
+
+ describe('revokeCascade', () => {
+ it('revokes the root session and all child sessions', async () => {
+ const user = await makeUser();
+ const parent = await target.create(user.id);
+ const child1 = await target.create(user.id, {
+ kind: 'app',
+ parent_session_id: parent.uuid,
+ });
+ const child2 = await target.create(user.id, {
+ kind: 'access_token',
+ parent_session_id: parent.uuid,
+ });
+
+ await target.revokeCascade(parent.uuid);
+
+ expect(await target.getByUuid(parent.uuid)).toBeNull();
+ expect(await target.getByUuid(child1.uuid)).toBeNull();
+ expect(await target.getByUuid(child2.uuid)).toBeNull();
+
+ expect((await rawRow(parent.uuid)).revoked_at).not.toBeNull();
+ expect((await rawRow(child1.uuid)).revoked_at).not.toBeNull();
+ expect((await rawRow(child2.uuid)).revoked_at).not.toBeNull();
+ });
+
+ it('does not touch unrelated sessions', async () => {
+ const user = await makeUser();
+ const parent = await target.create(user.id);
+ const sibling = await target.create(user.id);
+ const childOfSibling = await target.create(user.id, {
+ kind: 'app',
+ parent_session_id: sibling.uuid,
+ });
+
+ await target.revokeCascade(parent.uuid);
+
+ expect(await target.getByUuid(sibling.uuid)).toBeTruthy();
+ expect(await target.getByUuid(childOfSibling.uuid)).toBeTruthy();
+ });
+
+ it('is a no-op when the root uuid does not exist', async () => {
+ await expect(
+ target.revokeCascade('nonexistent-uuid'),
+ ).resolves.toBeUndefined();
+ });
+
+ it('is a no-op when called with a falsy uuid', async () => {
+ await expect(target.revokeCascade('')).resolves.toBeUndefined();
+ await expect(
+ target.revokeCascade(undefined),
+ ).resolves.toBeUndefined();
+ });
+ });
+});