diff --git a/api/scripts/generate-typescript.ts b/api/scripts/generate-typescript.ts index fa1247a..a637f96 100644 --- a/api/scripts/generate-typescript.ts +++ b/api/scripts/generate-typescript.ts @@ -298,12 +298,10 @@ function addBaseInterfaces(): void { ], }); - sourceFile.addInterface({ + sourceFile.addTypeAlias({ name: "GetJobHistoryResponse", isExported: true, - properties: [ - { name: "jobs", type: "JobStatus[]" }, - ], + type: "JobStatus[]", }); sourceFile.addInterface({ diff --git a/frontend/deno.json b/frontend/deno.json index fd97a7b..70d44f4 100644 --- a/frontend/deno.json +++ b/frontend/deno.json @@ -15,11 +15,11 @@ }, "compilerOptions": { "jsx": "precompile", - "jsxImportSource": "preact", - "types": ["./globals.d.ts"] + "jsxImportSource": "preact" }, "imports": { "@slices/oauth": "jsr:@slices/oauth@^0.3.0", + "@slices/session": "jsr:@slices/session@^0.1.0", "@std/assert": "jsr:@std/assert@^1.0.14", "preact": "npm:preact@^10.27.1", "preact-render-to-string": "npm:preact-render-to-string@^6.5.13", diff --git a/frontend/deno.lock b/frontend/deno.lock index 70df71b..57e74f4 100644 --- a/frontend/deno.lock +++ b/frontend/deno.lock @@ -3,6 +3,7 @@ "specifiers": { "jsr:@shikijs/shiki@*": "3.7.0", "jsr:@slices/oauth@0.3": "0.3.0", + "jsr:@slices/session@0.1": "0.1.0", "jsr:@std/assert@^1.0.14": "1.0.14", "jsr:@std/cli@^1.0.21": "1.0.21", "jsr:@std/encoding@^1.0.10": "1.0.10", @@ -19,9 +20,12 @@ "npm:@shikijs/engine-oniguruma@^3.7.0": "3.11.0", "npm:@shikijs/types@^3.7.0": "3.11.0", "npm:@types/node@*": "22.15.15", + "npm:pg@^8.11.0": "8.16.3", + "npm:pg@^8.16.3": "8.16.3", "npm:preact-render-to-string@^6.5.13": "6.5.13_preact@10.27.1", "npm:preact@^10.27.1": "10.27.1", "npm:shiki@^3.7.0": "3.11.0", + "npm:typed-htmx@*": "0.3.1", "npm:typed-htmx@~0.3.1": "0.3.1" }, "jsr": { @@ -36,6 +40,12 @@ "@slices/oauth@0.3.0": { "integrity": "a6f3296e701291f14b4c8491a7f7a86bd3c8d5caf006eb1d371627558439e3b5" }, + "@slices/session@0.1.0": { + "integrity": "63a4e35d70dcb2bb58e6117fdccf308f4a86cd9d94cf99412a3de9d35862cabc", + "dependencies": [ + "npm:pg@^8.16.3" + ] + }, "@std/assert@1.0.14": { "integrity": "68d0d4a43b365abc927f45a9b85c639ea18a9fab96ad92281e493e4ed84abaa4", "dependencies": [ @@ -256,6 +266,68 @@ "regex-recursion" ] }, + "pg-cloudflare@1.2.7": { + "integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==" + }, + "pg-connection-string@2.9.1": { + "integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==" + }, + "pg-int8@1.0.1": { + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==" + }, + "pg-pool@3.10.1_pg@8.16.3": { + "integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==", + "dependencies": [ + "pg" + ] + }, + "pg-protocol@1.10.3": { + "integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==" + }, + "pg-types@2.2.0": { + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "dependencies": [ + "pg-int8", + "postgres-array", + "postgres-bytea", + "postgres-date", + "postgres-interval" + ] + }, + "pg@8.16.3": { + "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", + "dependencies": [ + "pg-connection-string", + "pg-pool", + "pg-protocol", + "pg-types", + "pgpass" + ], + "optionalDependencies": [ + "pg-cloudflare" + ] + }, + "pgpass@1.0.5": { + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "dependencies": [ + "split2" + ] + }, + "postgres-array@2.0.0": { + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==" + }, + "postgres-bytea@1.0.0": { + "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==" + }, + "postgres-date@1.0.7": { + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==" + }, + "postgres-interval@1.2.0": { + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "dependencies": [ + "xtend" + ] + }, "preact-render-to-string@6.5.13_preact@10.27.1": { "integrity": "sha512-iGPd+hKPMFKsfpR2vL4kJ6ZPcFIoWZEcBf0Dpm3zOpdVvj77aY8RlLiQji5OMrngEyaxGogeakTb54uS2FvA6w==", "dependencies": [ @@ -299,6 +371,9 @@ "space-separated-tokens@2.0.2": { "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==" }, + "split2@4.2.0": { + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==" + }, "stringify-entities@4.0.4": { "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", "dependencies": [ @@ -368,6 +443,9 @@ "vfile-message" ] }, + "xtend@4.0.2": { + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==" + }, "zwitch@2.0.4": { "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==" } @@ -375,6 +453,7 @@ "workspace": { "dependencies": [ "jsr:@slices/oauth@0.3", + "jsr:@slices/session@0.1", "jsr:@std/assert@^1.0.14", "jsr:@std/http@^1.0.20", "npm:preact-render-to-string@^6.5.13", diff --git a/frontend/globals.d.ts b/frontend/globals.d.ts index 3490ef0..d066f5f 100644 --- a/frontend/globals.d.ts +++ b/frontend/globals.d.ts @@ -2,8 +2,6 @@ import "typed-htmx"; declare module "preact" { namespace JSX { - interface HTMLAttributes extends HtmxAttributes { - _?: string; - } + interface HTMLAttributes extends HtmxAttributes {} } } diff --git a/frontend/src/client.ts b/frontend/src/client.ts index dbedc5e..4f015e6 100644 --- a/frontend/src/client.ts +++ b/frontend/src/client.ts @@ -1,5 +1,5 @@ // Generated TypeScript client for AT Protocol records -// Generated at: 2025-08-26 18:27:23 UTC +// Generated at: 2025-08-27 05:10:34 UTC // Lexicons: 3 /** @@ -128,9 +128,7 @@ export interface GetJobHistoryParams { limit?: number; } -export interface GetJobHistoryResponse { - jobs: JobStatus[]; -} +export type GetJobHistoryResponse = JobStatus[]; export interface CollectionStats { collection: string; diff --git a/frontend/src/config.ts b/frontend/src/config.ts index e9d0b59..b8f89c9 100644 --- a/frontend/src/config.ts +++ b/frontend/src/config.ts @@ -1,5 +1,6 @@ import { AtProtoClient } from "./client.ts"; import { OAuthClient, SQLiteOAuthStorage } from "@slices/oauth"; +import { SessionStore, SQLiteAdapter, withOAuthSession } from "@slices/session"; const OAUTH_CLIENT_ID = Deno.env.get("OAUTH_CLIENT_ID"); const OAUTH_CLIENT_SECRET = Deno.env.get("OAUTH_CLIENT_SECRET"); @@ -23,8 +24,9 @@ if ( } const DATABASE_URL = Deno.env.get("DATABASE_URL") || "slices.db"; -const oauthStorage = new SQLiteOAuthStorage(DATABASE_URL); +// OAuth setup +const oauthStorage = new SQLiteOAuthStorage(DATABASE_URL); const oauthClient = new OAuthClient( { clientId: OAUTH_CLIENT_ID, @@ -42,4 +44,20 @@ const oauthClient = new OAuthClient( oauthStorage ); +// Session setup (shared database) +export const sessionStore = new SessionStore({ + adapter: new SQLiteAdapter(DATABASE_URL), + cookieOptions: { + httpOnly: true, + secure: Deno.env.get("DENO_ENV") === "production", + sameSite: "lax", + path: "/", + }, +}); + +// OAuth + Session integration +export const oauthSessions = withOAuthSession(sessionStore, oauthClient, { + autoRefresh: true, +}); + export const atprotoClient = new AtProtoClient(API_URL, SLICE_URI, oauthClient); diff --git a/frontend/src/lib/session-store.ts b/frontend/src/lib/session-store.ts deleted file mode 100644 index ab2e11b..0000000 --- a/frontend/src/lib/session-store.ts +++ /dev/null @@ -1,254 +0,0 @@ -// SQLite-based session storage for user authentication -import { atprotoClient } from "../config.ts"; -import { DatabaseSync } from "node:sqlite"; - -export interface SessionData { - userDid: string; - handle?: string; - isAuthenticated: boolean; - createdAt: number; -} - -interface UserData { - handle?: string; - sub?: string; - isAuthenticated: boolean; -} - -export class SessionStore { - private db: DatabaseSync; - - constructor(databaseUrl: string) { - // Extract path from sqlite:// URL or use as-is - const dbPath = databaseUrl.startsWith("sqlite://") - ? databaseUrl.slice(9) - : databaseUrl; - - this.db = new DatabaseSync(dbPath); - this.initializeDatabase(); - } - - private initializeDatabase() { - this.db.exec(` - CREATE TABLE IF NOT EXISTS sessions ( - session_id TEXT PRIMARY KEY, - user_did TEXT NOT NULL, - handle TEXT, - is_authenticated INTEGER NOT NULL, - created_at INTEGER NOT NULL, - expires_at INTEGER NOT NULL - ) - `); - - // Create index for faster cleanup of expired sessions - this.db.exec(` - CREATE INDEX IF NOT EXISTS idx_sessions_expires_at - ON sessions(expires_at) - `); - } - - // Create a new session - createSession(userDid: string, handle?: string): string { - const sessionId = crypto.randomUUID(); - const createdAt = Date.now(); - const expiresAt = createdAt + 30 * 24 * 60 * 60 * 1000; // 30 days - - const stmt = this.db.prepare(` - INSERT INTO sessions (session_id, user_did, handle, is_authenticated, created_at, expires_at) - VALUES (?, ?, ?, ?, ?, ?) - `); - - stmt.run(sessionId, userDid, handle || null, 1, createdAt, expiresAt); - - return sessionId; - } - - // Get session data - getSession(sessionId: string): SessionData | null { - // Clean up expired sessions first - const cleanupStmt = this.db.prepare( - `DELETE FROM sessions WHERE expires_at < ?` - ); - cleanupStmt.run(Date.now()); - - const stmt = this.db.prepare(` - SELECT user_did, handle, is_authenticated, created_at - FROM sessions - WHERE session_id = ? AND expires_at > ? - `); - - const row = stmt.get(sessionId, Date.now()) as - | { - user_did: string; - handle: string | null; - is_authenticated: number; - created_at: number; - } - | undefined; - - if (!row) { - return null; - } - - return { - userDid: row.user_did, - handle: row.handle || undefined, - isAuthenticated: Boolean(row.is_authenticated), - createdAt: row.created_at, - }; - } - - // Update session data - updateSession( - sessionId: string, - updates: { handle?: string; isAuthenticated?: boolean } - ): boolean { - const parts = []; - const values = []; - - if (updates.handle !== undefined) { - parts.push("handle = ?"); - values.push(updates.handle); - } - - if (updates.isAuthenticated !== undefined) { - parts.push("is_authenticated = ?"); - values.push(updates.isAuthenticated ? 1 : 0); - } - - if (parts.length === 0) { - return false; - } - - // Extend expiration on update - parts.push("expires_at = ?"); - values.push(Date.now() + 30 * 24 * 60 * 60 * 1000); - - values.push(sessionId); - values.push(Date.now()); // Only update non-expired sessions - - const stmt = this.db.prepare(` - UPDATE sessions - SET ${parts.join(", ")} - WHERE session_id = ? AND expires_at > ? - `); - - const result = stmt.run(...values); - return result.changes > 0; - } - - // Delete a session (logout) - deleteSession(sessionId: string): void { - const stmt = this.db.prepare("DELETE FROM sessions WHERE session_id = ?"); - stmt.run(sessionId); - } - - // Get user info from OAuth client - async getUserInfo(): Promise { - const userInfo = await atprotoClient.oauth?.getUserInfo(); - console.log("Fetched user info:", userInfo); - const isAuthenticated = - (await atprotoClient.oauth?.isAuthenticated()) || false; - - if (userInfo && isAuthenticated) { - return { - handle: userInfo.name || undefined, // Use 'name' field which contains the handle - sub: userInfo.sub, - isAuthenticated: true, - }; - } - return { isAuthenticated: false }; - } - - // Get current user from request - async getCurrentUser( - req: Request - ): Promise<{ handle?: string; sub?: string; isAuthenticated: boolean }> { - const sessionId = getSessionIdFromRequest(req); - - if (!sessionId) { - return { isAuthenticated: false }; - } - - try { - const sessionData = this.getSession(sessionId); - - if (!sessionData) { - return { isAuthenticated: false }; - } - - if (!sessionData.isAuthenticated) { - return { isAuthenticated: false }; - } - - // Try to ensure valid tokens (this will refresh if needed) - try { - await atprotoClient.oauth?.ensureValidToken(); - - const oauthAuthenticated = - (await atprotoClient.oauth?.isAuthenticated()) || false; - - if (!oauthAuthenticated) { - // Mark session as unauthenticated if OAuth tokens are invalid - this.updateSession(sessionId, { isAuthenticated: false }); - return { isAuthenticated: false }; - } - } catch (_tokenError) { - // Mark session as unauthenticated if token refresh fails - this.updateSession(sessionId, { isAuthenticated: false }); - return { isAuthenticated: false }; - } - - return { - handle: sessionData.handle, - sub: sessionData.userDid, - isAuthenticated: true, - }; - } catch (error) { - console.error("Session validation error:", error); - return { isAuthenticated: false }; - } - } - - // Create session from user data - createSessionFromUserData(userData: UserData): string { - if (!userData.sub) { - throw new Error("User DID (sub) is required for session creation"); - } - - return this.createSession(userData.sub, userData.handle); - } - - // Delete session from request - deleteSessionFromRequest(req: Request): void { - const sessionId = getSessionIdFromRequest(req); - if (sessionId) { - this.deleteSession(sessionId); - } - } -} - -// Utility function to extract session ID from request -export function getSessionIdFromRequest(request: Request): string | null { - const cookies = request.headers.get("cookie") || ""; - const sessionCookie = cookies - .split("; ") - .find((row) => row.startsWith("session_id=")); - - if (!sessionCookie) { - return null; - } - - return sessionCookie.split("=")[1]; -} - -// Utility function to create session cookie -export function createSessionCookie(sessionId: string): string { - // HttpOnly, Secure, SameSite=Strict cookie with 30 day expiration - return `session_id=${sessionId}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=2592000`; // 30 days -} - -// Utility function to clear session cookie -export function clearSessionCookie(): string { - return `session_id=; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=0`; -} diff --git a/frontend/src/lib/stores.ts b/frontend/src/lib/stores.ts deleted file mode 100644 index 35bdd51..0000000 --- a/frontend/src/lib/stores.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Initialize all stores with shared database -import { SessionStore } from "./session-store.ts"; - -const DATABASE_URL = Deno.env.get("DATABASE_URL") || "sqlite://slices.db"; - -export const sessionStore = new SessionStore(DATABASE_URL); \ No newline at end of file diff --git a/frontend/src/routes/middleware.ts b/frontend/src/routes/middleware.ts index 99d7c21..89753de 100644 --- a/frontend/src/routes/middleware.ts +++ b/frontend/src/routes/middleware.ts @@ -1,4 +1,4 @@ -import { sessionStore } from "../lib/stores.ts"; +import { sessionStore } from "../config.ts"; export interface AuthenticatedUser { handle?: string; diff --git a/frontend/src/routes/oauth.ts b/frontend/src/routes/oauth.ts index 6472fe7..fb64395 100644 --- a/frontend/src/routes/oauth.ts +++ b/frontend/src/routes/oauth.ts @@ -1,10 +1,5 @@ import type { Route } from "@std/http/unstable-route"; -import { atprotoClient } from "../config.ts"; -import { sessionStore } from "../lib/stores.ts"; -import { - createSessionCookie, - clearSessionCookie, -} from "../lib/session-store.ts"; +import { atprotoClient, oauthSessions, sessionStore } from "../config.ts"; async function handleOAuthAuthorize(req: Request): Promise { try { @@ -72,12 +67,21 @@ async function handleOAuthCallback(req: Request): Promise { state, }); - // Get user info from OAuth client - const userData = await sessionStore.getUserInfo(); + // Create OAuth session with auto token management + const sessionId = await oauthSessions.createOAuthSession(); + + if (!sessionId) { + return Response.redirect( + new URL( + "/login?error=" + encodeURIComponent("Failed to create session"), + req.url + ), + 302 + ); + } - // Create new session and get session cookie - const sessionId = await sessionStore.createSessionFromUserData(userData); - const sessionCookie = createSessionCookie(sessionId); + // Create session cookie + const sessionCookie = sessionStore.createSessionCookie(sessionId); return new Response(null, { status: 302, @@ -99,17 +103,22 @@ async function handleOAuthCallback(req: Request): Promise { } async function handleLogout(req: Request): Promise { - // Delete the session from KV store - await sessionStore.deleteSessionFromRequest(req); + // Get session from request + const session = await sessionStore.getSessionFromRequest(req); + + if (session) { + // Use OAuth session manager to handle logout + await oauthSessions.logout(session.sessionId); + } - // Logout from OAuth client - await atprotoClient.oauth?.logout(); + // Clear session cookie + const clearCookie = sessionStore.createLogoutCookie(); return new Response(null, { status: 302, headers: { Location: new URL("/login", req.url).toString(), - "Set-Cookie": clearSessionCookie(), + "Set-Cookie": clearCookie, }, }); } diff --git a/frontend/src/routes/pages.tsx b/frontend/src/routes/pages.tsx index f7c4f4d..3f705df 100644 --- a/frontend/src/routes/pages.tsx +++ b/frontend/src/routes/pages.tsx @@ -369,9 +369,10 @@ async function handleSliceApiDocsPage( return Response.redirect(new URL("/", req.url), 302); } - // Get OAuth access token if available + // Get OAuth access token directly from OAuth client (clean separation) let accessToken: string | undefined; try { + // Tokens are managed by @slices/oauth, not stored in sessions const tokens = await atprotoClient.oauth?.ensureValidToken(); accessToken = tokens?.accessToken; } catch (error) {