baa-conductor


baa-conductor / apps / conductor-daemon / src
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}