sessions: every signed-in browser is a row, and one can be signed out

A session cookie now carries the id of a row in the new sessions table,
and is refused once that row is gone. :sessions lists the browsers
signed in, newest first, and :sessions rm <n> signs one out without
touching the rest; it locks the vault too, since the vault is open for
the whole workspace. Logging out deletes the row, :logout! and a login
password change delete them all, and the sweep drops expired rows.
last_seen is kept in memory and written at most every five minutes.

Cookies issued before this carry no id and are refused: every browser
logs in once more after the upgrade.
This commit is contained in:
nvrl committed 2026-10-20 12:19:26 +02:00
1 parent 6c82264f57
commit 165561f6a2
10 files changed
+540 -56

No files matched your search

+13
View File
@@ -57,6 +57,16 @@ export interface SpaceOverview {
recent: NodeSummary[]
}
/** A browser that is signed in. */
export interface SignedIn {
id: string
created_at: number
last_seen: number
user_agent: string
/** The session asking. */
current: boolean
}
/** A public key allowed to mount the workspace over SFTP. */
export interface SshKey {
id: number
@@ -460,6 +470,9 @@ export const api = {
readShared: (token: string, key: string) =>
request<SharedDocument>(`/share/${encodeURIComponent(token)}/${encodeURIComponent(key)}`),
sessions: () => request<SignedIn[]>('/sessions'),
revokeSession: (id: string) =>
request<{ ok: true }>(`/sessions/${encodeURIComponent(id)}`, { method: 'DELETE' }),
listSshKeys: () => request<SshKey[]>('/ssh-keys'),
addSshKey: (name: string, public_key: string) =>
+54 -1
View File
@@ -1,6 +1,6 @@
import { api, isContainer, type BackupStatus } from './api'
import { workspace } from './store.svelte'
import { formatDay, parseDay, parseDue, sealedNote, type ScrollAmount } from './util'
import { formatDay, parseDay, parseDue, relative, sealedNote, type ScrollAmount } from './util'
export type { ScrollAmount }
@@ -102,6 +102,33 @@ const ago = (at: number | null) => {
return `${Math.round(seconds / 86400)}d ago`
}
/** "firefox on linux", out of a user-agent string, as far as it says. */
function browserOf(agent: string): string {
const browser = /Edg\//.test(agent)
? 'edge'
: /Firefox\//.test(agent)
? 'firefox'
: /Chrome\//.test(agent)
? 'chrome'
: /Safari\//.test(agent)
? 'safari'
: /curl\//.test(agent)
? 'curl'
: 'a browser'
const system = /Android/.test(agent)
? 'android'
: /iPhone|iPad/.test(agent)
? 'ios'
: /Mac OS X/.test(agent)
? 'macos'
: /Windows/.test(agent)
? 'windows'
: /Linux/.test(agent)
? 'linux'
: ''
return system ? `${browser} on ${system}` : browser
}
const describeBackup = (status: BackupStatus) => {
if (!status.configured) {
return workspace.say('proton drive backups are off — run `narl-workspace proton-login` on the server', 'error')
@@ -782,6 +809,32 @@ export const commands: CommandSpec[] = [
}),
},
},
{
name: 'sessions',
args: '[rm <n>]',
group: 'view',
help: 'every browser signed in, numbered — rm signs one of them out, and locks the vault',
run: (arg) =>
workspace.guard(async () => {
const trimmed = arg.trim()
const sessions = await api.sessions()
if (trimmed === '' || trimmed === 'ls' || trimmed === 'list') {
return workspace.say(
sessions
.map((s, i) => `${i + 1} ${s.current ? 'this one' : browserOf(s.user_agent)}, seen ${relative(s.last_seen)}`)
.join(' · '),
)
}
const rmMatch = /^rm\s+(\d+)$/.exec(trimmed)
if (!rmMatch) return workspace.say('usage: :sessions [rm <n>]', 'error')
const target = sessions[Number(rmMatch[1]) - 1]
if (!target) return workspace.say(`no session ${rmMatch[1]} — :sessions lists them`, 'error')
if (target.current) return workspace.say('that is this browser — :logout ends it', 'error')
await api.revokeSession(target.id)
await workspace.loadVault()
workspace.say(`signed out ${browserOf(target.user_agent)}, seen ${relative(target.last_seen)} — the vault is locked`)
}),
},
{
name: 'sshkey',
args: '[add <name> <key> | rm <name> | vault <name> on|off]',