fabro/apps/arc-web/app/lib/session-storage.server.ts
Bryan Helmkamp 1fd9a8ebfe SQLite-backed sessions with GitHub email and app manifest fix
Replace cookie-based sessions with SQLite-backed storage using
better-sqlite3 and React Router's createSessionStorage. Sessions are
now stored in ~/.arc/arc-web.db with a session ID cookie, enabling
larger payloads and server-side revocation.

- Add db.server.ts (lazy singleton, WAL mode, web_sessions table)
- Add session-storage.server.ts (CRUD ops, probabilistic cleanup)
- Fetch primary verified email from /user/emails during OAuth
- Add emails:read to GitHub App manifest default_permissions
- Expand session data: userUrl, githubId, githubNodeId, email
- Default ARC_API_BASE_URL to localhost:3000
- Whitelist better-sqlite3 in trustedDependencies
- Externalize better-sqlite3 from Vite SSR bundling

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 21:05:59 -05:00

78 lines
2.3 KiB
TypeScript

import { createSessionStorage } from "react-router";
import crypto from "node:crypto";
import { getDatabase } from "./db.server";
interface SessionRow {
id: string;
user_url: string;
data: string;
expires_at: number | null;
}
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
function cleanupExpiredSessions() {
const db = getDatabase();
db.prepare("DELETE FROM web_sessions WHERE expires_at IS NOT NULL AND expires_at < ?").run(
Date.now(),
);
}
export function createSqliteSessionStorage(secret: string) {
return createSessionStorage({
cookie: {
name: "__arc_session",
httpOnly: true,
sameSite: "lax" as const,
secure: process.env.NODE_ENV === "production",
secrets: [secret],
path: "/",
maxAge: 30 * 24 * 60 * 60, // 30 days in seconds
},
async createData(data, expiresAt) {
const db = getDatabase();
const id = crypto.randomUUID();
const userUrl = (data.userUrl as string) ?? "";
const expiresAtMs = expiresAt ? expiresAt.getTime() : Date.now() + THIRTY_DAYS_MS;
db.prepare(
"INSERT INTO web_sessions (id, user_url, data, expires_at) VALUES (?, ?, ?, ?)",
).run(id, userUrl, JSON.stringify(data), expiresAtMs);
// Probabilistic cleanup (~1% of creates)
if (Math.random() < 0.01) {
cleanupExpiredSessions();
}
return id;
},
async readData(id) {
const db = getDatabase();
const row = db
.prepare("SELECT * FROM web_sessions WHERE id = ?")
.get(id) as SessionRow | undefined;
if (!row) return null;
if (row.expires_at && row.expires_at < Date.now()) {
db.prepare("DELETE FROM web_sessions WHERE id = ?").run(id);
return null;
}
return JSON.parse(row.data);
},
async updateData(id, data, expiresAt) {
const db = getDatabase();
const userUrl = (data.userUrl as string) ?? "";
const expiresAtMs = expiresAt ? expiresAt.getTime() : Date.now() + THIRTY_DAYS_MS;
db.prepare(
"UPDATE web_sessions SET user_url = ?, data = ?, expires_at = ? WHERE id = ?",
).run(userUrl, JSON.stringify(data), expiresAtMs, id);
},
async deleteData(id) {
const db = getDatabase();
db.prepare("DELETE FROM web_sessions WHERE id = ?").run(id);
},
});
}