Highly ambitious ATProtocol AppView service and sdks

extract session store to separate library and move all oauth/session logic init to the same place

+2 -4
api/scripts/generate-typescript.ts
··· 298 298 ], 299 299 }); 300 300 301 - sourceFile.addInterface({ 301 + sourceFile.addTypeAlias({ 302 302 name: "GetJobHistoryResponse", 303 303 isExported: true, 304 - properties: [ 305 - { name: "jobs", type: "JobStatus[]" }, 306 - ], 304 + type: "JobStatus[]", 307 305 }); 308 306 309 307 sourceFile.addInterface({
+2 -2
frontend/deno.json
··· 15 15 }, 16 16 "compilerOptions": { 17 17 "jsx": "precompile", 18 - "jsxImportSource": "preact", 19 - "types": ["./globals.d.ts"] 18 + "jsxImportSource": "preact" 20 19 }, 21 20 "imports": { 22 21 "@slices/oauth": "jsr:@slices/oauth@^0.3.0", 22 + "@slices/session": "jsr:@slices/session@^0.1.0", 23 23 "@std/assert": "jsr:@std/assert@^1.0.14", 24 24 "preact": "npm:preact@^10.27.1", 25 25 "preact-render-to-string": "npm:preact-render-to-string@^6.5.13",
+79
frontend/deno.lock
··· 3 3 "specifiers": { 4 4 "jsr:@shikijs/shiki@*": "3.7.0", 5 5 "jsr:@slices/oauth@0.3": "0.3.0", 6 + "jsr:@slices/session@0.1": "0.1.0", 6 7 "jsr:@std/assert@^1.0.14": "1.0.14", 7 8 "jsr:@std/cli@^1.0.21": "1.0.21", 8 9 "jsr:@std/encoding@^1.0.10": "1.0.10", ··· 19 20 "npm:@shikijs/engine-oniguruma@^3.7.0": "3.11.0", 20 21 "npm:@shikijs/types@^3.7.0": "3.11.0", 21 22 "npm:@types/node@*": "22.15.15", 23 + "npm:pg@^8.11.0": "8.16.3", 24 + "npm:pg@^8.16.3": "8.16.3", 22 25 "npm:preact-render-to-string@^6.5.13": "6.5.13_preact@10.27.1", 23 26 "npm:preact@^10.27.1": "10.27.1", 24 27 "npm:shiki@^3.7.0": "3.11.0", 28 + "npm:typed-htmx@*": "0.3.1", 25 29 "npm:typed-htmx@~0.3.1": "0.3.1" 26 30 }, 27 31 "jsr": { ··· 35 39 }, 36 40 "@slices/oauth@0.3.0": { 37 41 "integrity": "a6f3296e701291f14b4c8491a7f7a86bd3c8d5caf006eb1d371627558439e3b5" 42 + }, 43 + "@slices/session@0.1.0": { 44 + "integrity": "63a4e35d70dcb2bb58e6117fdccf308f4a86cd9d94cf99412a3de9d35862cabc", 45 + "dependencies": [ 46 + "npm:pg@^8.16.3" 47 + ] 38 48 }, 39 49 "@std/assert@1.0.14": { 40 50 "integrity": "68d0d4a43b365abc927f45a9b85c639ea18a9fab96ad92281e493e4ed84abaa4", ··· 256 266 "regex-recursion" 257 267 ] 258 268 }, 269 + "pg-cloudflare@1.2.7": { 270 + "integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==" 271 + }, 272 + "pg-connection-string@2.9.1": { 273 + "integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==" 274 + }, 275 + "pg-int8@1.0.1": { 276 + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==" 277 + }, 278 + "pg-pool@3.10.1_pg@8.16.3": { 279 + "integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==", 280 + "dependencies": [ 281 + "pg" 282 + ] 283 + }, 284 + "pg-protocol@1.10.3": { 285 + "integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==" 286 + }, 287 + "pg-types@2.2.0": { 288 + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", 289 + "dependencies": [ 290 + "pg-int8", 291 + "postgres-array", 292 + "postgres-bytea", 293 + "postgres-date", 294 + "postgres-interval" 295 + ] 296 + }, 297 + "pg@8.16.3": { 298 + "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", 299 + "dependencies": [ 300 + "pg-connection-string", 301 + "pg-pool", 302 + "pg-protocol", 303 + "pg-types", 304 + "pgpass" 305 + ], 306 + "optionalDependencies": [ 307 + "pg-cloudflare" 308 + ] 309 + }, 310 + "pgpass@1.0.5": { 311 + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", 312 + "dependencies": [ 313 + "split2" 314 + ] 315 + }, 316 + "postgres-array@2.0.0": { 317 + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==" 318 + }, 319 + "postgres-bytea@1.0.0": { 320 + "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==" 321 + }, 322 + "postgres-date@1.0.7": { 323 + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==" 324 + }, 325 + "postgres-interval@1.2.0": { 326 + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", 327 + "dependencies": [ 328 + "xtend" 329 + ] 330 + }, 259 331 "preact-render-to-string@6.5.13_preact@10.27.1": { 260 332 "integrity": "sha512-iGPd+hKPMFKsfpR2vL4kJ6ZPcFIoWZEcBf0Dpm3zOpdVvj77aY8RlLiQji5OMrngEyaxGogeakTb54uS2FvA6w==", 261 333 "dependencies": [ ··· 298 370 }, 299 371 "space-separated-tokens@2.0.2": { 300 372 "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==" 373 + }, 374 + "split2@4.2.0": { 375 + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==" 301 376 }, 302 377 "stringify-entities@4.0.4": { 303 378 "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", ··· 368 443 "vfile-message" 369 444 ] 370 445 }, 446 + "xtend@4.0.2": { 447 + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==" 448 + }, 371 449 "zwitch@2.0.4": { 372 450 "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==" 373 451 } ··· 375 453 "workspace": { 376 454 "dependencies": [ 377 455 "jsr:@slices/oauth@0.3", 456 + "jsr:@slices/session@0.1", 378 457 "jsr:@std/assert@^1.0.14", 379 458 "jsr:@std/http@^1.0.20", 380 459 "npm:preact-render-to-string@^6.5.13",
+1 -3
frontend/globals.d.ts
··· 2 2 3 3 declare module "preact" { 4 4 namespace JSX { 5 - interface HTMLAttributes extends HtmxAttributes { 6 - _?: string; 7 - } 5 + interface HTMLAttributes extends HtmxAttributes {} 8 6 } 9 7 }
+2 -4
frontend/src/client.ts
··· 1 1 // Generated TypeScript client for AT Protocol records 2 - // Generated at: 2025-08-26 18:27:23 UTC 2 + // Generated at: 2025-08-27 05:10:34 UTC 3 3 // Lexicons: 3 4 4 5 5 /** ··· 128 128 limit?: number; 129 129 } 130 130 131 - export interface GetJobHistoryResponse { 132 - jobs: JobStatus[]; 133 - } 131 + export type GetJobHistoryResponse = JobStatus[]; 134 132 135 133 export interface CollectionStats { 136 134 collection: string;
+19 -1
frontend/src/config.ts
··· 1 1 import { AtProtoClient } from "./client.ts"; 2 2 import { OAuthClient, SQLiteOAuthStorage } from "@slices/oauth"; 3 + import { SessionStore, SQLiteAdapter, withOAuthSession } from "@slices/session"; 3 4 4 5 const OAUTH_CLIENT_ID = Deno.env.get("OAUTH_CLIENT_ID"); 5 6 const OAUTH_CLIENT_SECRET = Deno.env.get("OAUTH_CLIENT_SECRET"); ··· 23 24 } 24 25 25 26 const DATABASE_URL = Deno.env.get("DATABASE_URL") || "slices.db"; 27 + 28 + // OAuth setup 26 29 const oauthStorage = new SQLiteOAuthStorage(DATABASE_URL); 27 - 28 30 const oauthClient = new OAuthClient( 29 31 { 30 32 clientId: OAUTH_CLIENT_ID, ··· 41 43 }, 42 44 oauthStorage 43 45 ); 46 + 47 + // Session setup (shared database) 48 + export const sessionStore = new SessionStore({ 49 + adapter: new SQLiteAdapter(DATABASE_URL), 50 + cookieOptions: { 51 + httpOnly: true, 52 + secure: Deno.env.get("DENO_ENV") === "production", 53 + sameSite: "lax", 54 + path: "/", 55 + }, 56 + }); 57 + 58 + // OAuth + Session integration 59 + export const oauthSessions = withOAuthSession(sessionStore, oauthClient, { 60 + autoRefresh: true, 61 + }); 44 62 45 63 export const atprotoClient = new AtProtoClient(API_URL, SLICE_URI, oauthClient);
-254
frontend/src/lib/session-store.ts
··· 1 - // SQLite-based session storage for user authentication 2 - import { atprotoClient } from "../config.ts"; 3 - import { DatabaseSync } from "node:sqlite"; 4 - 5 - export interface SessionData { 6 - userDid: string; 7 - handle?: string; 8 - isAuthenticated: boolean; 9 - createdAt: number; 10 - } 11 - 12 - interface UserData { 13 - handle?: string; 14 - sub?: string; 15 - isAuthenticated: boolean; 16 - } 17 - 18 - export class SessionStore { 19 - private db: DatabaseSync; 20 - 21 - constructor(databaseUrl: string) { 22 - // Extract path from sqlite:// URL or use as-is 23 - const dbPath = databaseUrl.startsWith("sqlite://") 24 - ? databaseUrl.slice(9) 25 - : databaseUrl; 26 - 27 - this.db = new DatabaseSync(dbPath); 28 - this.initializeDatabase(); 29 - } 30 - 31 - private initializeDatabase() { 32 - this.db.exec(` 33 - CREATE TABLE IF NOT EXISTS sessions ( 34 - session_id TEXT PRIMARY KEY, 35 - user_did TEXT NOT NULL, 36 - handle TEXT, 37 - is_authenticated INTEGER NOT NULL, 38 - created_at INTEGER NOT NULL, 39 - expires_at INTEGER NOT NULL 40 - ) 41 - `); 42 - 43 - // Create index for faster cleanup of expired sessions 44 - this.db.exec(` 45 - CREATE INDEX IF NOT EXISTS idx_sessions_expires_at 46 - ON sessions(expires_at) 47 - `); 48 - } 49 - 50 - // Create a new session 51 - createSession(userDid: string, handle?: string): string { 52 - const sessionId = crypto.randomUUID(); 53 - const createdAt = Date.now(); 54 - const expiresAt = createdAt + 30 * 24 * 60 * 60 * 1000; // 30 days 55 - 56 - const stmt = this.db.prepare(` 57 - INSERT INTO sessions (session_id, user_did, handle, is_authenticated, created_at, expires_at) 58 - VALUES (?, ?, ?, ?, ?, ?) 59 - `); 60 - 61 - stmt.run(sessionId, userDid, handle || null, 1, createdAt, expiresAt); 62 - 63 - return sessionId; 64 - } 65 - 66 - // Get session data 67 - getSession(sessionId: string): SessionData | null { 68 - // Clean up expired sessions first 69 - const cleanupStmt = this.db.prepare( 70 - `DELETE FROM sessions WHERE expires_at < ?` 71 - ); 72 - cleanupStmt.run(Date.now()); 73 - 74 - const stmt = this.db.prepare(` 75 - SELECT user_did, handle, is_authenticated, created_at 76 - FROM sessions 77 - WHERE session_id = ? AND expires_at > ? 78 - `); 79 - 80 - const row = stmt.get(sessionId, Date.now()) as 81 - | { 82 - user_did: string; 83 - handle: string | null; 84 - is_authenticated: number; 85 - created_at: number; 86 - } 87 - | undefined; 88 - 89 - if (!row) { 90 - return null; 91 - } 92 - 93 - return { 94 - userDid: row.user_did, 95 - handle: row.handle || undefined, 96 - isAuthenticated: Boolean(row.is_authenticated), 97 - createdAt: row.created_at, 98 - }; 99 - } 100 - 101 - // Update session data 102 - updateSession( 103 - sessionId: string, 104 - updates: { handle?: string; isAuthenticated?: boolean } 105 - ): boolean { 106 - const parts = []; 107 - const values = []; 108 - 109 - if (updates.handle !== undefined) { 110 - parts.push("handle = ?"); 111 - values.push(updates.handle); 112 - } 113 - 114 - if (updates.isAuthenticated !== undefined) { 115 - parts.push("is_authenticated = ?"); 116 - values.push(updates.isAuthenticated ? 1 : 0); 117 - } 118 - 119 - if (parts.length === 0) { 120 - return false; 121 - } 122 - 123 - // Extend expiration on update 124 - parts.push("expires_at = ?"); 125 - values.push(Date.now() + 30 * 24 * 60 * 60 * 1000); 126 - 127 - values.push(sessionId); 128 - values.push(Date.now()); // Only update non-expired sessions 129 - 130 - const stmt = this.db.prepare(` 131 - UPDATE sessions 132 - SET ${parts.join(", ")} 133 - WHERE session_id = ? AND expires_at > ? 134 - `); 135 - 136 - const result = stmt.run(...values); 137 - return result.changes > 0; 138 - } 139 - 140 - // Delete a session (logout) 141 - deleteSession(sessionId: string): void { 142 - const stmt = this.db.prepare("DELETE FROM sessions WHERE session_id = ?"); 143 - stmt.run(sessionId); 144 - } 145 - 146 - // Get user info from OAuth client 147 - async getUserInfo(): Promise<UserData> { 148 - const userInfo = await atprotoClient.oauth?.getUserInfo(); 149 - console.log("Fetched user info:", userInfo); 150 - const isAuthenticated = 151 - (await atprotoClient.oauth?.isAuthenticated()) || false; 152 - 153 - if (userInfo && isAuthenticated) { 154 - return { 155 - handle: userInfo.name || undefined, // Use 'name' field which contains the handle 156 - sub: userInfo.sub, 157 - isAuthenticated: true, 158 - }; 159 - } 160 - return { isAuthenticated: false }; 161 - } 162 - 163 - // Get current user from request 164 - async getCurrentUser( 165 - req: Request 166 - ): Promise<{ handle?: string; sub?: string; isAuthenticated: boolean }> { 167 - const sessionId = getSessionIdFromRequest(req); 168 - 169 - if (!sessionId) { 170 - return { isAuthenticated: false }; 171 - } 172 - 173 - try { 174 - const sessionData = this.getSession(sessionId); 175 - 176 - if (!sessionData) { 177 - return { isAuthenticated: false }; 178 - } 179 - 180 - if (!sessionData.isAuthenticated) { 181 - return { isAuthenticated: false }; 182 - } 183 - 184 - // Try to ensure valid tokens (this will refresh if needed) 185 - try { 186 - await atprotoClient.oauth?.ensureValidToken(); 187 - 188 - const oauthAuthenticated = 189 - (await atprotoClient.oauth?.isAuthenticated()) || false; 190 - 191 - if (!oauthAuthenticated) { 192 - // Mark session as unauthenticated if OAuth tokens are invalid 193 - this.updateSession(sessionId, { isAuthenticated: false }); 194 - return { isAuthenticated: false }; 195 - } 196 - } catch (_tokenError) { 197 - // Mark session as unauthenticated if token refresh fails 198 - this.updateSession(sessionId, { isAuthenticated: false }); 199 - return { isAuthenticated: false }; 200 - } 201 - 202 - return { 203 - handle: sessionData.handle, 204 - sub: sessionData.userDid, 205 - isAuthenticated: true, 206 - }; 207 - } catch (error) { 208 - console.error("Session validation error:", error); 209 - return { isAuthenticated: false }; 210 - } 211 - } 212 - 213 - // Create session from user data 214 - createSessionFromUserData(userData: UserData): string { 215 - if (!userData.sub) { 216 - throw new Error("User DID (sub) is required for session creation"); 217 - } 218 - 219 - return this.createSession(userData.sub, userData.handle); 220 - } 221 - 222 - // Delete session from request 223 - deleteSessionFromRequest(req: Request): void { 224 - const sessionId = getSessionIdFromRequest(req); 225 - if (sessionId) { 226 - this.deleteSession(sessionId); 227 - } 228 - } 229 - } 230 - 231 - // Utility function to extract session ID from request 232 - export function getSessionIdFromRequest(request: Request): string | null { 233 - const cookies = request.headers.get("cookie") || ""; 234 - const sessionCookie = cookies 235 - .split("; ") 236 - .find((row) => row.startsWith("session_id=")); 237 - 238 - if (!sessionCookie) { 239 - return null; 240 - } 241 - 242 - return sessionCookie.split("=")[1]; 243 - } 244 - 245 - // Utility function to create session cookie 246 - export function createSessionCookie(sessionId: string): string { 247 - // HttpOnly, Secure, SameSite=Strict cookie with 30 day expiration 248 - return `session_id=${sessionId}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=2592000`; // 30 days 249 - } 250 - 251 - // Utility function to clear session cookie 252 - export function clearSessionCookie(): string { 253 - return `session_id=; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=0`; 254 - }
-6
frontend/src/lib/stores.ts
··· 1 - // Initialize all stores with shared database 2 - import { SessionStore } from "./session-store.ts"; 3 - 4 - const DATABASE_URL = Deno.env.get("DATABASE_URL") || "sqlite://slices.db"; 5 - 6 - export const sessionStore = new SessionStore(DATABASE_URL);
+1 -1
frontend/src/routes/middleware.ts
··· 1 - import { sessionStore } from "../lib/stores.ts"; 1 + import { sessionStore } from "../config.ts"; 2 2 3 3 export interface AuthenticatedUser { 4 4 handle?: string;
+25 -16
frontend/src/routes/oauth.ts
··· 1 1 import type { Route } from "@std/http/unstable-route"; 2 - import { atprotoClient } from "../config.ts"; 3 - import { sessionStore } from "../lib/stores.ts"; 4 - import { 5 - createSessionCookie, 6 - clearSessionCookie, 7 - } from "../lib/session-store.ts"; 2 + import { atprotoClient, oauthSessions, sessionStore } from "../config.ts"; 8 3 9 4 async function handleOAuthAuthorize(req: Request): Promise<Response> { 10 5 try { ··· 72 67 state, 73 68 }); 74 69 75 - // Get user info from OAuth client 76 - const userData = await sessionStore.getUserInfo(); 70 + // Create OAuth session with auto token management 71 + const sessionId = await oauthSessions.createOAuthSession(); 72 + 73 + if (!sessionId) { 74 + return Response.redirect( 75 + new URL( 76 + "/login?error=" + encodeURIComponent("Failed to create session"), 77 + req.url 78 + ), 79 + 302 80 + ); 81 + } 77 82 78 - // Create new session and get session cookie 79 - const sessionId = await sessionStore.createSessionFromUserData(userData); 80 - const sessionCookie = createSessionCookie(sessionId); 83 + // Create session cookie 84 + const sessionCookie = sessionStore.createSessionCookie(sessionId); 81 85 82 86 return new Response(null, { 83 87 status: 302, ··· 99 103 } 100 104 101 105 async function handleLogout(req: Request): Promise<Response> { 102 - // Delete the session from KV store 103 - await sessionStore.deleteSessionFromRequest(req); 106 + // Get session from request 107 + const session = await sessionStore.getSessionFromRequest(req); 108 + 109 + if (session) { 110 + // Use OAuth session manager to handle logout 111 + await oauthSessions.logout(session.sessionId); 112 + } 104 113 105 - // Logout from OAuth client 106 - await atprotoClient.oauth?.logout(); 114 + // Clear session cookie 115 + const clearCookie = sessionStore.createLogoutCookie(); 107 116 108 117 return new Response(null, { 109 118 status: 302, 110 119 headers: { 111 120 Location: new URL("/login", req.url).toString(), 112 - "Set-Cookie": clearSessionCookie(), 121 + "Set-Cookie": clearCookie, 113 122 }, 114 123 }); 115 124 }
+2 -1
frontend/src/routes/pages.tsx
··· 369 369 return Response.redirect(new URL("/", req.url), 302); 370 370 } 371 371 372 - // Get OAuth access token if available 372 + // Get OAuth access token directly from OAuth client (clean separation) 373 373 let accessToken: string | undefined; 374 374 try { 375 + // Tokens are managed by @slices/oauth, not stored in sessions 375 376 const tokens = await atprotoClient.oauth?.ensureValidToken(); 376 377 accessToken = tokens?.accessToken; 377 378 } catch (error) {