maestro/src/db/browser-session-repo.ts
2026-06-03 05:08:00 +00:00

174 lines
6.5 KiB
TypeScript

import type Database from 'better-sqlite3';
export interface BrowserSessionProfile {
id: number;
ownerId: string;
label: string;
startUrl: string;
matchPatterns: string[];
storageOrigins: string[];
loggedInSelector: string | null;
loginUrlPatterns: string[];
encryptedStateBlob: Buffer | null;
stateVersion: number;
playwrightVersion: string | null;
status: 'pending' | 'active' | 'expired' | 'revoked' | 'error';
lastSavedAt: string | null;
lastUsedAt: string | null;
lastValidatedAt: string | null;
lastError: string | null;
createdAt: string;
updatedAt: string;
}
export interface CreateProfileInput {
ownerId: string;
label: string;
startUrl: string;
matchPatterns: string[];
storageOrigins: string[];
loggedInSelector?: string | null;
loginUrlPatterns: string[];
}
export interface AuditInput {
actorUserId?: string | null;
ownerId?: string | null;
profileId?: number | null;
action:
| 'create' | 'save' | 'decrypt' | 'use' | 'delete'
| 'expire' | 'revoke' | 'test' | 'login_start' | 'login_cancel';
taskId?: number | null;
jobId?: string | null;
result: 'success' | 'error';
reason?: string | null;
}
function rowToProfile(row: Record<string, unknown>): BrowserSessionProfile {
return {
id: row['id'] as number,
ownerId: row['owner_id'] as string,
label: row['label'] as string,
startUrl: row['start_url'] as string,
matchPatterns: JSON.parse((row['match_patterns'] as string) || '[]') as string[],
storageOrigins: JSON.parse((row['storage_origins'] as string) || '[]') as string[],
loggedInSelector: (row['logged_in_selector'] as string | null) ?? null,
loginUrlPatterns: JSON.parse((row['login_url_patterns'] as string) || '[]') as string[],
encryptedStateBlob: (row['encrypted_state_blob'] as Buffer | null) ?? null,
stateVersion: (row['state_version'] as number) ?? 0,
playwrightVersion: (row['playwright_version'] as string | null) ?? null,
status: row['status'] as BrowserSessionProfile['status'],
lastSavedAt: (row['last_saved_at'] as string | null) ?? null,
lastUsedAt: (row['last_used_at'] as string | null) ?? null,
lastValidatedAt: (row['last_validated_at'] as string | null) ?? null,
lastError: (row['last_error'] as string | null) ?? null,
createdAt: row['created_at'] as string,
updatedAt: row['updated_at'] as string,
};
}
export class BrowserSessionRepo {
constructor(private readonly db: Database.Database) {}
// ── DEK management ────────────────────────────────────────────────
setUserDek(userId: string, encryptedDek: Buffer): void {
this.db.prepare(`
INSERT INTO user_deks (user_id, encrypted_dek)
VALUES (?, ?)
ON CONFLICT(user_id) DO UPDATE SET encrypted_dek = excluded.encrypted_dek
`).run(userId, encryptedDek);
}
getUserDek(userId: string): Buffer | null {
const row = this.db.prepare('SELECT encrypted_dek FROM user_deks WHERE user_id = ?').get(userId) as { encrypted_dek: Buffer } | undefined;
return row?.encrypted_dek ?? null;
}
// ── Profiles ──────────────────────────────────────────────────────
createProfile(input: CreateProfileInput): number {
const result = this.db.prepare(`
INSERT INTO browser_session_profiles
(owner_id, label, start_url, match_patterns, storage_origins,
logged_in_selector, login_url_patterns, status)
VALUES (?, ?, ?, ?, ?, ?, ?, 'pending')
`).run(
input.ownerId,
input.label,
input.startUrl,
JSON.stringify(input.matchPatterns),
JSON.stringify(input.storageOrigins),
input.loggedInSelector ?? null,
JSON.stringify(input.loginUrlPatterns),
);
return Number(result.lastInsertRowid);
}
getProfileById(id: number, ownerId: string): BrowserSessionProfile | null {
const row = this.db.prepare('SELECT * FROM browser_session_profiles WHERE id = ? AND owner_id = ?').get(id, ownerId) as Record<string, unknown> | undefined;
return row ? rowToProfile(row) : null;
}
/** Admin / worker path that does NOT enforce ownership. Caller must check elsewhere. */
getProfileByIdUnsafe(id: number): BrowserSessionProfile | null {
const row = this.db.prepare('SELECT * FROM browser_session_profiles WHERE id = ?').get(id) as Record<string, unknown> | undefined;
return row ? rowToProfile(row) : null;
}
listProfilesByOwner(ownerId: string): BrowserSessionProfile[] {
const rows = this.db.prepare('SELECT * FROM browser_session_profiles WHERE owner_id = ? ORDER BY label ASC').all(ownerId) as Array<Record<string, unknown>>;
return rows.map(rowToProfile);
}
saveProfileBlob(id: number, encrypted: Buffer, playwrightVersion: string): void {
this.db.prepare(`
UPDATE browser_session_profiles
SET encrypted_state_blob = ?,
state_version = state_version + 1,
playwright_version = ?,
status = 'active',
last_saved_at = datetime('now'),
last_validated_at = datetime('now'),
last_error = NULL,
updated_at = datetime('now')
WHERE id = ?
`).run(encrypted, playwrightVersion, id);
}
markProfileStatus(id: number, status: BrowserSessionProfile['status'], reason: string | null = null): void {
this.db.prepare(`
UPDATE browser_session_profiles
SET status = ?,
last_error = ?,
updated_at = datetime('now')
WHERE id = ?
`).run(status, reason, id);
}
touchUsed(id: number): void {
this.db.prepare(`UPDATE browser_session_profiles SET last_used_at = datetime('now') WHERE id = ?`).run(id);
}
deleteProfile(id: number, ownerId: string): boolean {
const result = this.db.prepare('DELETE FROM browser_session_profiles WHERE id = ? AND owner_id = ?').run(id, ownerId);
return result.changes > 0;
}
// ── Audit ─────────────────────────────────────────────────────────
audit(input: AuditInput): void {
this.db.prepare(`
INSERT INTO browser_session_audit
(actor_user_id, profile_id, owner_id, action, task_id, job_id, result, reason)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(
input.actorUserId ?? null,
input.profileId ?? null,
input.ownerId ?? null,
input.action,
input.taskId ?? null,
input.jobId ?? null,
input.result,
input.reason ?? null,
);
}
}