native-session.ts235 lines · main
1/**
2 * briven-engine sessions on Doltgres.
3 */
4
5import { createHash, randomBytes } from 'node:crypto';
6
7import { getEnginePool } from './db.js';
8
9export type EngineSession = {
10 sessionHandle: string;
11 userId: string;
12 tenantId: string;
13 accessToken: string;
14 refreshToken: string;
15 expiresAt: Date;
16};
17
18function newToken(): string {
19 return randomBytes(32).toString('base64url');
20}
21
22function hash(t: string): string {
23 return createHash('sha256').update(t).digest('hex');
24}
25
26export async function createEngineSession(input: {
27 userId: string;
28 tenantId: string;
29 ttlDays?: number;
30}): Promise<EngineSession> {
31 // Block held / archived accounts from minting new sessions (all login paths).
32 try {
33 const { getUserAccessBlock } = await import('./users.js');
34 const block = await getUserAccessBlock(input.userId);
35 if (block === 'held') {
36 throw new Error('user_held');
37 }
38 if (block === 'archived') {
39 throw new Error('user_archived');
40 }
41 } catch (err) {
42 if (err instanceof Error && (err.message === 'user_held' || err.message === 'user_archived')) {
43 throw err;
44 }
45 // Schema not ready — do not brick login.
46 }
47
48 const sessionHandle = `sh_${randomBytes(16).toString('hex')}`;
49 // Phase 2: access cookie value IS the session handle (Doltgres PK lookup).
50 // Keep field name accessToken for FDI/cookie compatibility.
51 const accessToken = sessionHandle;
52 const refreshToken = newToken();
53 const days = input.ttlDays ?? 30;
54 const expiresAt = new Date(Date.now() + days * 24 * 60 * 60 * 1000);
55 const pool = getEnginePool();
56
57 await pool.query(
58 `INSERT INTO be_sessions
59 (session_handle, user_id, tenant_id, refresh_token_hash, access_payload_json, expires_at)
60 VALUES ($1, $2, $3, $4, $5, $6)`,
61 [
62 sessionHandle,
63 input.userId,
64 input.tenantId,
65 hash(refreshToken),
66 JSON.stringify({ sub: input.userId, tenantId: input.tenantId }),
67 expiresAt.toISOString(),
68 ],
69 );
70
71 return {
72 sessionHandle,
73 userId: input.userId,
74 tenantId: input.tenantId,
75 accessToken,
76 refreshToken,
77 expiresAt,
78 };
79}
80
81export async function getSessionByHandle(
82 sessionHandle: string,
83): Promise<{ userId: string; tenantId: string; expiresAt: Date } | null> {
84 const pool = getEnginePool();
85 const res = await pool.query(
86 `SELECT user_id, tenant_id, expires_at FROM be_sessions
87 WHERE session_handle = $1 LIMIT 1`,
88 [sessionHandle],
89 );
90 const row = res.rows[0] as
91 | { user_id: string; tenant_id: string; expires_at: Date | string }
92 | undefined;
93 if (!row) return null;
94 const expiresAt = new Date(row.expires_at);
95 if (expiresAt.getTime() < Date.now()) return null;
96 return { userId: row.user_id, tenantId: row.tenant_id, expiresAt };
97}
98
99export async function revokeEngineSession(sessionHandle: string): Promise<boolean> {
100 const pool = getEnginePool();
101 // Capture user/tenant before delete for audit
102 let userId: string | null = null;
103 let tenantId: string | null = null;
104 try {
105 const prev = await pool.query(
106 `SELECT user_id, tenant_id FROM be_sessions WHERE session_handle = $1 LIMIT 1`,
107 [sessionHandle],
108 );
109 const row = prev.rows[0] as { user_id?: string; tenant_id?: string } | undefined;
110 userId = row?.user_id ?? null;
111 tenantId = row?.tenant_id ?? null;
112 } catch {
113 /* ignore */
114 }
115 const res = await pool.query(`DELETE FROM be_sessions WHERE session_handle = $1`, [
116 sessionHandle,
117 ]);
118 const ok = (res.rowCount ?? 0) > 0;
119 if (ok) {
120 const { recordBrivenEngineAudit } = await import('./audit.js');
121 void recordBrivenEngineAudit({
122 action: 'session.revoked',
123 tenantId,
124 userId,
125 metadata: { sessionHandle },
126 });
127 }
128 return ok;
129}
130
131export async function revokeAllForUser(userId: string): Promise<number> {
132 const pool = getEnginePool();
133 const res = await pool.query(`DELETE FROM be_sessions WHERE user_id = $1`, [userId]);
134 return res.rowCount ?? 0;
135}
136
137export async function listSessionHandles(userId: string): Promise<string[]> {
138 const pool = getEnginePool();
139 const res = await pool.query(
140 `SELECT session_handle FROM be_sessions WHERE user_id = $1 AND expires_at > NOW()`,
141 [userId],
142 );
143 return res.rows.map((r: { session_handle: string }) => r.session_handle);
144}
145
146/**
147 * SuperTokens-style session refresh: prove refresh token → new session handle,
148 * revoke the old handle. Access cookie value remains the opaque session handle.
149 */
150export async function refreshEngineSession(
151 refreshToken: string,
152): Promise<EngineSession | null> {
153 const raw = refreshToken?.trim();
154 if (!raw) return null;
155 const pool = getEnginePool();
156 const res = await pool.query(
157 `SELECT session_handle, user_id, tenant_id, expires_at
158 FROM be_sessions
159 WHERE refresh_token_hash = $1
160 LIMIT 1`,
161 [hash(raw)],
162 );
163 const row = res.rows[0] as
164 | {
165 session_handle: string;
166 user_id: string;
167 tenant_id: string;
168 expires_at: Date | string;
169 }
170 | undefined;
171 if (!row) return null;
172 if (new Date(row.expires_at).getTime() < Date.now()) {
173 await pool.query(`DELETE FROM be_sessions WHERE session_handle = $1`, [
174 row.session_handle,
175 ]);
176 return null;
177 }
178 // Rotate: mint new session then drop old (atomic enough for v1).
179 const next = await createEngineSession({
180 userId: row.user_id,
181 tenantId: row.tenant_id,
182 });
183 await pool.query(`DELETE FROM be_sessions WHERE session_handle = $1`, [
184 row.session_handle,
185 ]);
186 return next;
187}
188
189/** Recent active sessions for yellow dashboard (optionally one tenant). */
190export async function listRecentEngineSessions(
191 limit = 50,
192 opts?: { tenantId?: string },
193): Promise<
194 Array<{
195 handle: string;
196 userId: string;
197 tenantId: string;
198 expiresAt: string;
199 createdAt: string;
200 }>
201> {
202 const pool = getEnginePool();
203 const res = opts?.tenantId
204 ? await pool.query(
205 `SELECT session_handle, user_id, tenant_id, expires_at, created_at
206 FROM be_sessions
207 WHERE expires_at > NOW() AND tenant_id = $1
208 ORDER BY created_at DESC
209 LIMIT $2`,
210 [opts.tenantId, limit],
211 )
212 : await pool.query(
213 `SELECT session_handle, user_id, tenant_id, expires_at, created_at
214 FROM be_sessions
215 WHERE expires_at > NOW()
216 ORDER BY created_at DESC
217 LIMIT $1`,
218 [limit],
219 );
220 return (
221 res.rows as Array<{
222 session_handle: string;
223 user_id: string;
224 tenant_id: string;
225 expires_at: Date | string;
226 created_at: Date | string;
227 }>
228 ).map((r) => ({
229 handle: r.session_handle,
230 userId: r.user_id,
231 tenantId: r.tenant_id,
232 expiresAt: new Date(r.expires_at).toISOString(),
233 createdAt: new Date(r.created_at).toISOString(),
234 }));
235}