v0.4.0 f6d8405

session.server.ts RawHistoryFolder

1import { sessions } from '../db/convex.server';
2import type { Session, SafeUser } from '../types/chat';
3import { createLogger } from '../utils/logger.server';
4
5const log = createLogger('session-server');
6
7// Helper: Compute SHA-256 hash of a message and encode it as a hex string.
8export async function sha256(message: string): Promise<string> {
9	const encoder = new TextEncoder();
10	const data = encoder.encode(message);
11	const hashBuffer = await crypto.subtle.digest('SHA-256', data);
12	const hashArray = Array.from(new Uint8Array(hashBuffer));
13	const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
14	return hashHex;
15}
16
17// Helper: Custom Base32 encoder using the standard alphabet.
18const base32Alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
19function base32Encode(data: Uint8Array): string {
20	let bits = 0;
21	let value = 0;
22	let output = '';
23	for (let i = 0; i < data.length; i++) {
24		value = (value << 8) | data[i];
25		bits += 8;
26		while (bits >= 5) {
27			const index = (value >>> (bits - 5)) & 0x1f;
28			output += base32Alphabet[index];
29			bits -= 5;
30		}
31	}
32	if (bits > 0) {
33		const index = (value << (5 - bits)) & 0x1f;
34		output += base32Alphabet[index];
35	}
36	return output;
37}
38
39// Generate a secure session token by generating 20 random bytes and Base32 encoding them.
40export function generateSessionToken(): string {
41	const randomBytesArray = new Uint8Array(20);
42	crypto.getRandomValues(randomBytesArray);
43	const token = base32Encode(randomBytesArray);
44	log.debug('Generated session token', { tokenPrefix: token.slice(0, 4) + '***' });
45	return token;
46}
47
48// Create a session record once a valid token is available.
49// The session ID is the SHA-256 hash of the token.
50export async function createSession(token: string, userId: string): Promise<Session> {
51	log.debug('Creating session', { userId });
52	const tokenHash = await sha256(token);
53	const createdAt = Date.now();
54	const expiresAt = createdAt + 7 * 24 * 60 * 60 * 1000; // 7 days expiry
55
56	await sessions.create(tokenHash, userId, expiresAt);
57
58	log.debug('Session created', { tokenHash });
59	return { id: tokenHash, userId, createdAt, expiresAt };
60}
61
62/**
63 * Type distributed through our API.
64 * Returns an object with both session and user if valid, or nulls if not.
65 */
66export type SessionValidationResult =
67	{ session: Session; user: SafeUser } | { session: null; user: null };
68
69// Validate a session token by converting it to its SHA-256 hash, checking expiration, and fetching the user.
70export async function validateSessionToken(token: string): Promise<SessionValidationResult> {
71	log.debug('Validating session token');
72	const tokenHash = await sha256(token);
73
74	const result = await sessions.validate(tokenHash);
75
76	if (!result.session || !result.user) {
77		log.debug('Session not found or invalid', { tokenHash });
78		return { session: null, user: null };
79	}
80
81	// Map Convex types to our local types - using native Convex IDs
82	const session: Session = {
83		id: result.session.tokenHash,
84		userId: result.user._id,
85		createdAt: result.session.createdAt,
86		expiresAt: result.session.expiresAt
87	};
88
89	const user: SafeUser = {
90		id: result.user._id,
91		nickname: result.user.nickname,
92		status: result.user.status as SafeUser['status'],
93		avatarUrl: result.user.avatarUrl,
94		lastSeen: result.user.lastSeen,
95		lastReadMentionTimestamp: result.user.lastReadMentionTimestamp
96	};
97
98	log.debug('Session validated', { tokenHash, userId: user.id });
99	return { session, user };
100}
101
102// Invalidate a specific session by deleting it from the database.
103export async function invalidateSession(tokenHash: string): Promise<void> {
104	log.debug('Invalidating session', { tokenHash });
105	await sessions.remove(tokenHash);
106	log.debug('Session invalidated', { tokenHash });
107}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 3

Tags 9

↑↓ moveenter openesc close