Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ split, begin at v1.0.0 — see [VERSIONING.md](VERSIONING.md) for exactly what

- A running server now sees permission changes made by another process. The local authz adapter built its tuple index once at startup, so a user or role granted with `dune users:create`, `users:grant-role` or `authz:sign` while `dune serve` was running could log in but was refused ("forbidden") until the server restarted, and a `users:revoke-role` did not take effect until then either. The adapter now rescans `data/permissions/` at most once a second and applies added and removed tuple files. (#153)

### Added

- `SessionManager.listForUser(userId)` and `revokeOthers(userId, keepSessionId)`, backed by a new
`SessionStore.listByUserId(userId)` on the local, Deno KV and Redis stores. They let an account page list a user's
live sessions and end all but the current one (plugin-admin builds "Sign out other sessions" on this). Custom
`SessionStore` implementations must add `listByUserId`. (#155)

## [0.36.0] — 2026-11-10

Dune 0.36 moves the admin out of core and freezes the plugin and theme contracts as two small
Expand Down
12 changes: 11 additions & 1 deletion src/session/kv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,20 @@ export function createKVSessionStore(config: KVSessionStoreConfig): SessionStore
await ops.commit();
}

async function listByUserId(userId: string): Promise<Session[]> {
const out: Session[] = [];
for await (const entry of kv.list<boolean>({ prefix: uKey(userId) })) {
const sessionId = entry.key[entry.key.length - 1] as string;
const result = await kv.get<Session>(sKey(sessionId));
if (result.value !== null) out.push(result.value);
}
return out;
}

async function cleanup(): Promise<number> {
// Deno KV handles TTL expiry natively — no manual sweep required.
return 0;
}

return { get, set, delete: del, deleteByUserId, cleanup };
return { get, set, delete: del, deleteByUserId, listByUserId, cleanup };
}
22 changes: 21 additions & 1 deletion src/session/local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,26 @@ export function createLocalSessionStore(config: LocalSessionStoreConfig): Sessio
}
}

async function listByUserId(userId: string): Promise<Session[]> {
const out: Session[] = [];
try {
const entries = await storage.list(sessionsDir);
for (const entry of entries) {
if (entry.isDirectory || !entry.name.endsWith(".json")) continue;
try {
const data = await storage.read(`${sessionsDir}/${entry.name}`);
const session: Session = JSON.parse(new TextDecoder().decode(data));
if (session.userId === userId) out.push(session);
} catch {
// Skip corrupt files
}
}
} catch {
// Sessions dir may not exist yet
}
return out;
}

async function cleanup(): Promise<number> {
let cleaned = 0;
try {
Expand Down Expand Up @@ -110,5 +130,5 @@ export function createLocalSessionStore(config: LocalSessionStoreConfig): Sessio
return cleaned;
}

return { get, set, delete: del, deleteByUserId, cleanup };
return { get, set, delete: del, deleteByUserId, listByUserId, cleanup };
}
23 changes: 22 additions & 1 deletion src/session/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,14 @@ export interface SessionManager {
revoke(sessionId: string): Promise<void>;
/** Revoke all sessions for a user. */
revokeAll(userId: string): Promise<void>;
/**
* The user's live (unexpired) sessions, newest first. A session's `id` is the
* bearer credential in its cookie, so never render it in a page: show a
* derived handle and look the session up again server-side to act on it.
*/
listForUser(userId: string): Promise<Session[]>;
/** Revoke all of a user's sessions except `keepSessionId` (the caller's own). */
revokeOthers(userId: string, keepSessionId: string): Promise<void>;
/** Clean up expired sessions. */
cleanup(): Promise<number>;
}
Expand Down Expand Up @@ -145,11 +153,24 @@ export function createSessionManager(store: SessionStore, lifetimeMs: number): S
await store.deleteByUserId(userId);
}

async function listForUser(userId: string): Promise<Session[]> {
const now = Date.now();
return (await store.listByUserId(userId))
.filter((s) => s.expiresAt >= now)
.sort((a, b) => b.createdAt - a.createdAt);
}

async function revokeOthers(userId: string, keepSessionId: string): Promise<void> {
for (const s of await store.listByUserId(userId)) {
if (s.id !== keepSessionId) await store.delete(s.id);
}
}

async function cleanup(): Promise<number> {
return store.cleanup();
}

return { create, get, revoke, revokeAll, cleanup };
return { create, get, revoke, revokeAll, listForUser, revokeOthers, cleanup };
}

async function generateSessionId(): Promise<string> {
Expand Down
17 changes: 16 additions & 1 deletion src/session/redis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,12 +113,27 @@ export function createRedisSessionStore(config: RedisSessionStoreConfig): Sessio
await client.del(userSetKey(userId));
}

async function listByUserId(userId: string): Promise<Session[]> {
const ids = await client.smembers(userSetKey(userId));
const out: Session[] = [];
for (const id of ids) {
const raw = await client.get(sessionKey(id));
if (raw === null) continue;
try {
out.push(JSON.parse(raw) as Session);
} catch {
// Skip corrupt entries
}
}
return out;
}

async function cleanup(): Promise<number> {
// Redis handles TTL expiry natively — no manual sweep required.
return 0;
}

return { get, set, delete: del, deleteByUserId, cleanup };
return { get, set, delete: del, deleteByUserId, listByUserId, cleanup };
}

/**
Expand Down
10 changes: 8 additions & 2 deletions src/session/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,17 @@ export interface SessionStore {

/**
* Delete all sessions belonging to a user.
* Used on password change, explicit "sign out everywhere", and login
* (to ensure only one active session per user).
* Used on password change and explicit "sign out everywhere".
*/
deleteByUserId(userId: string): Promise<void>;

/**
* All stored sessions belonging to a user, in no particular order. May
* include sessions that have expired but not yet been swept; callers that
* need only live ones filter on `expiresAt` (SessionManager does).
*/
listByUserId(userId: string): Promise<Session[]>;

/**
* Remove expired sessions from the store and return the count deleted.
* Backends that handle TTL natively (KV, Redis) should return 0 immediately —
Expand Down
13 changes: 13 additions & 0 deletions tests/session/kv_session_store_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,19 @@ Deno.test("KVSessionStore: deleteByUserId removes all sessions for a user", asyn
kv.close();
});

Deno.test("KVSessionStore: listByUserId returns only that user's sessions", async () => {
const kv = await Deno.openKv(":memory:");
const store = createKVSessionStore({ kv, lifetimeMs: 3600 * 1000 });
await store.set(makeSession({ id: "s1aaaa", userId: "alice" }));
await store.set(makeSession({ id: "s2bbbb", userId: "alice" }));
await store.set(makeSession({ id: "s3cccc", userId: "bob" }));

const ids = (await store.listByUserId("alice")).map((s) => s.id).sort();
assertEquals(ids, ["s1aaaa", "s2bbbb"]);
assertEquals(await store.listByUserId("nobody"), []);
kv.close();
});

Deno.test("KVSessionStore: deleteByUserId is safe when no sessions exist", async () => {
const kv = await Deno.openKv(":memory:");
const store = createKVSessionStore({ kv, lifetimeMs: 3600 * 1000 });
Expand Down
13 changes: 13 additions & 0 deletions tests/session/local_session_store_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,19 @@ Deno.test("LocalSessionStore: deleteByUserId is safe when no sessions exist", as
await store.deleteByUserId("nobody");
});

// ── listByUserId ───────────────────────────────────────────────────────────────

Deno.test("LocalSessionStore: listByUserId returns only that user's sessions", async () => {
const store = createLocalSessionStore({ storage: createMemoryStorage(), sessionsDir: ".sess", lifetimeMs: 3_600_000 });
await store.set(makeSession({ id: "aaaa", userId: "alice" }));
await store.set(makeSession({ id: "bbbb", userId: "alice" }));
await store.set(makeSession({ id: "cccc", userId: "bob" }));

const ids = (await store.listByUserId("alice")).map((s) => s.id).sort();
assertEquals(ids, ["aaaa", "bbbb"]);
assertEquals(await store.listByUserId("nobody"), []);
});

// ── cleanup ────────────────────────────────────────────────────────────────────

Deno.test("LocalSessionStore: cleanup removes only expired sessions", async () => {
Expand Down
33 changes: 33 additions & 0 deletions tests/session/manager_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,39 @@ Deno.test("createSessionManager: revokeAll deletes every session for a user, lea
assertExists(await mgr.get(b1.id));
});

Deno.test("createSessionManager: listForUser returns the user's live sessions, newest first", async () => {
const mgr = makeManager();
const first = await mgr.create("user-a", "10.0.0.1");
await new Promise((r) => setTimeout(r, 5));
const second = await mgr.create("user-a", "10.0.0.2");
await mgr.create("user-b");

const list = await mgr.listForUser("user-a");
assertEquals(list.map((x) => x.id), [second.id, first.id]);
assertEquals(await mgr.listForUser("nobody"), []);
});

Deno.test("createSessionManager: listForUser leaves out expired sessions", async () => {
const mgr = makeManager(-1000); // sessions are created already expired
await mgr.create("user-a");
assertEquals(await mgr.listForUser("user-a"), []);
});

Deno.test("createSessionManager: revokeOthers keeps only the given session and other users' sessions", async () => {
const mgr = makeManager();
const keep = await mgr.create("user-a");
const other1 = await mgr.create("user-a");
const other2 = await mgr.create("user-a");
const b1 = await mgr.create("user-b");

await mgr.revokeOthers("user-a", keep.id);

assertExists(await mgr.get(keep.id));
assertEquals(await mgr.get(other1.id), null);
assertEquals(await mgr.get(other2.id), null);
assertExists(await mgr.get(b1.id));
});

Deno.test("createSessionManager: cleanup removes expired sessions", async () => {
const mgr = makeManager(-1000); // already expired
await mgr.create("user-1");
Expand Down
16 changes: 16 additions & 0 deletions tests/session/redis_session_store_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,22 @@ Deno.test("RedisSessionStore: deleteByUserId removes all sessions for a user", a
assertNotEquals(await store.get(s3.id), null);
});

Deno.test("RedisSessionStore: listByUserId returns only that user's sessions", async () => {
const store = createRedisSessionStore({
client: new FakeRedisClient(),
lifetimeSec: 3600,
});
const s1 = makeSession({ userId: "alice" });
const s2 = makeSession({ userId: "alice" });
await store.set(s1);
await store.set(s2);
await store.set(makeSession({ userId: "bob" }));

const ids = (await store.listByUserId("alice")).map((s) => s.id).sort();
assertEquals(ids, [s1.id, s2.id].sort());
assertEquals(await store.listByUserId("nobody"), []);
});

Deno.test("RedisSessionStore: deleteByUserId also clears the user's set key", async () => {
const client = new FakeRedisClient();
const store = createRedisSessionStore({ client, lifetimeSec: 3600 });
Expand Down
Loading