im_wower
·
2026-04-02
ui-session.ts
1import { randomUUID } from "node:crypto";
2import {
3 createBrowserSessionPrincipal,
4 type AuthPrincipal,
5 type BrowserSessionRole
6} from "../../../packages/auth/dist/index.js";
7
8export const UI_SESSION_COOKIE_NAME = "baa_ui_session";
9export const DEFAULT_UI_SESSION_TTL_SEC = 60 * 60 * 8;
10
11export interface UiSessionManagerOptions {
12 browserAdminPassword: string | null;
13 now?: () => number;
14 readonlyPassword: string | null;
15 ttlSec?: number;
16}
17
18export interface UiSessionRecord {
19 createdAtMs: number;
20 expiresAtMs: number;
21 lastSeenAtMs: number;
22 role: BrowserSessionRole;
23 sessionId: string;
24 subject: string;
25}
26
27export interface UiSessionSnapshot {
28 expires_at: string;
29 issued_at: string;
30 role: BrowserSessionRole;
31 session_id: string;
32 subject: string;
33}
34
35export type UiSessionLoginFailureReason = "invalid_credentials" | "role_not_enabled";
36
37export type UiSessionLoginResult =
38 | {
39 ok: true;
40 session: UiSessionRecord;
41 }
42 | {
43 ok: false;
44 reason: UiSessionLoginFailureReason;
45 };
46
47export class UiSessionManager {
48 private readonly browserAdminPassword: string | null;
49 private readonly now: () => number;
50 private readonly readonlyPassword: string | null;
51 private readonly sessions = new Map<string, UiSessionRecord>();
52 private readonly ttlSec: number;
53
54 constructor(options: UiSessionManagerOptions) {
55 this.browserAdminPassword = normalizeOptionalString(options.browserAdminPassword);
56 this.now = options.now ?? Date.now;
57 this.readonlyPassword = normalizeOptionalString(options.readonlyPassword);
58 this.ttlSec = normalizeTtlSec(options.ttlSec);
59 }
60
61 buildClearCookieHeader(requestProtocol: string): string {
62 return buildSessionCookieHeader("", 0, requestProtocol);
63 }
64
65 buildSessionCookieHeader(sessionId: string, requestProtocol: string): string {
66 return buildSessionCookieHeader(sessionId, this.ttlSec, requestProtocol);
67 }
68
69 getAvailableRoles(): BrowserSessionRole[] {
70 const roles: BrowserSessionRole[] = [];
71
72 if (this.browserAdminPassword != null) {
73 roles.push("browser_admin");
74 }
75
76 if (this.readonlyPassword != null) {
77 roles.push("readonly");
78 }
79
80 return roles;
81 }
82
83 hasConfiguredRoles(): boolean {
84 return this.getAvailableRoles().length > 0;
85 }
86
87 login(role: BrowserSessionRole, password: string): UiSessionLoginResult {
88 const expectedPassword = this.getExpectedPassword(role);
89
90 if (expectedPassword == null) {
91 return {
92 ok: false,
93 reason: "role_not_enabled"
94 };
95 }
96
97 if (password !== expectedPassword) {
98 return {
99 ok: false,
100 reason: "invalid_credentials"
101 };
102 }
103
104 const session = this.createSession(role);
105 return {
106 ok: true,
107 session
108 };
109 }
110
111 logout(cookieHeader: string | undefined): boolean {
112 const sessionId = readCookieValue(cookieHeader, UI_SESSION_COOKIE_NAME);
113
114 if (!sessionId) {
115 return false;
116 }
117
118 return this.sessions.delete(sessionId);
119 }
120
121 resolvePrincipal(cookieHeader: string | undefined): AuthPrincipal | null {
122 const session = this.readSession(cookieHeader);
123
124 if (!session) {
125 return null;
126 }
127
128 return createBrowserSessionPrincipal({
129 expiresAt: toIsoString(session.expiresAtMs),
130 issuedAt: toIsoString(session.createdAtMs),
131 role: session.role,
132 sessionId: session.sessionId,
133 subject: session.subject
134 });
135 }
136
137 touch(cookieHeader: string | undefined): UiSessionRecord | null {
138 const sessionId = readCookieValue(cookieHeader, UI_SESSION_COOKIE_NAME);
139
140 if (!sessionId) {
141 return null;
142 }
143
144 const current = this.sessions.get(sessionId);
145
146 if (!current) {
147 return null;
148 }
149
150 if (current.expiresAtMs <= this.now()) {
151 this.sessions.delete(sessionId);
152 return null;
153 }
154
155 const nowMs = this.now();
156 const updated: UiSessionRecord = {
157 ...current,
158 expiresAtMs: nowMs + this.ttlSec * 1000,
159 lastSeenAtMs: nowMs
160 };
161 this.sessions.set(updated.sessionId, updated);
162 return updated;
163 }
164
165 static toSnapshot(session: UiSessionRecord | null): UiSessionSnapshot | null {
166 if (!session) {
167 return null;
168 }
169
170 return {
171 expires_at: toIsoString(session.expiresAtMs),
172 issued_at: toIsoString(session.createdAtMs),
173 role: session.role,
174 session_id: session.sessionId,
175 subject: session.subject
176 };
177 }
178
179 private createSession(role: BrowserSessionRole): UiSessionRecord {
180 const nowMs = this.now();
181 const session: UiSessionRecord = {
182 createdAtMs: nowMs,
183 expiresAtMs: nowMs + this.ttlSec * 1000,
184 lastSeenAtMs: nowMs,
185 role,
186 sessionId: randomUUID(),
187 subject: `ui:${role}`
188 };
189 this.sessions.set(session.sessionId, session);
190 return session;
191 }
192
193 private getExpectedPassword(role: BrowserSessionRole): string | null {
194 if (role === "browser_admin") {
195 return this.browserAdminPassword;
196 }
197
198 return this.readonlyPassword;
199 }
200
201 private readSession(cookieHeader: string | undefined): UiSessionRecord | null {
202 const sessionId = readCookieValue(cookieHeader, UI_SESSION_COOKIE_NAME);
203
204 if (!sessionId) {
205 return null;
206 }
207
208 const current = this.sessions.get(sessionId);
209
210 if (!current) {
211 return null;
212 }
213
214 if (current.expiresAtMs <= this.now()) {
215 this.sessions.delete(sessionId);
216 return null;
217 }
218
219 return current;
220 }
221}
222
223function buildSessionCookieHeader(value: string, maxAgeSec: number, requestProtocol: string): string {
224 const parts = [`${UI_SESSION_COOKIE_NAME}=${encodeURIComponent(value)}`, "Path=/", "HttpOnly", "SameSite=Lax"];
225
226 if (requestProtocol === "https:") {
227 parts.push("Secure");
228 }
229
230 parts.push(`Max-Age=${Math.max(0, Math.trunc(maxAgeSec))}`);
231 return parts.join("; ");
232}
233
234function normalizeOptionalString(value: string | null | undefined): string | null {
235 if (value == null) {
236 return null;
237 }
238
239 const normalized = value.trim();
240 return normalized === "" ? null : normalized;
241}
242
243function normalizeTtlSec(value: number | undefined): number {
244 if (!Number.isInteger(value) || value == null || value <= 0) {
245 return DEFAULT_UI_SESSION_TTL_SEC;
246 }
247
248 return value;
249}
250
251function readCookieValue(cookieHeader: string | undefined, name: string): string | null {
252 if (!cookieHeader) {
253 return null;
254 }
255
256 for (const part of cookieHeader.split(";")) {
257 const [rawName, ...rawValueParts] = part.split("=");
258 const cookieName = rawName?.trim();
259
260 if (!cookieName || cookieName !== name) {
261 continue;
262 }
263
264 const rawValue = rawValueParts.join("=").trim();
265 return rawValue === "" ? null : decodeURIComponent(rawValue);
266 }
267
268 return null;
269}
270
271function toIsoString(value: number): string {
272 return new Date(value).toISOString();
273}