Highly ambitious ATProtocol AppView service and sdks

switch frontend to sqlite storage adapter instead of kv

+4 -1
frontend/Dockerfile
··· 1 1 FROM denoland/deno:2.3.3 2 2 3 + # Install sqlite3 4 + RUN apt-get update && apt-get install -y sqlite3 && rm -rf /var/lib/apt/lists/* 5 + 3 6 WORKDIR /app 4 7 5 8 COPY . . ··· 8 11 9 12 EXPOSE 8080 10 13 11 - CMD ["run", "-A", "--unstable-kv", "src/main.ts"] 14 + CMD ["run", "-A", "src/main.ts"]
+10
frontend/deno.lock
··· 17 17 "jsr:@std/streams@^1.0.10": "1.0.11", 18 18 "npm:@shikijs/core@^3.7.0": "3.11.0", 19 19 "npm:@shikijs/engine-oniguruma@^3.7.0": "3.11.0", 20 + "npm:@types/node@*": "22.15.15", 20 21 "npm:preact-render-to-string@^6.5.13": "6.5.13_preact@10.27.1", 21 22 "npm:preact@^10.27.1": "10.27.1", 22 23 "npm:shiki@^3.7.0": "3.11.0", ··· 145 146 "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", 146 147 "dependencies": [ 147 148 "@types/unist" 149 + ] 150 + }, 151 + "@types/node@22.15.15": { 152 + "integrity": "sha512-R5muMcZob3/Jjchn5LcO8jdKwSCbzqmPB6ruBxMcf9kbxtniZHP327s6C37iOfuw8mbKK3cAQa7sEl7afLrQ8A==", 153 + "dependencies": [ 154 + "undici-types" 148 155 ] 149 156 }, 150 157 "@types/unist@3.0.3": { ··· 309 316 "dependencies": [ 310 317 "typed-html" 311 318 ] 319 + }, 320 + "undici-types@6.21.0": { 321 + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" 312 322 }, 313 323 "unist-util-is@6.0.0": { 314 324 "integrity": "sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==",
+1 -1
frontend/flake.nix
··· 73 73 WorkingDir = "/"; 74 74 Env = [ 75 75 "PORT=8080" 76 - "KV_PATH=/data/kv.db" 76 + "DATABASE_URL=sqlite:///data/slices.db" 77 77 ]; 78 78 }; 79 79 };
+1
frontend/fly.toml
··· 12 12 [env] 13 13 PORT = '8080' 14 14 API_URL = 'https://slices-api.fly.dev' 15 + DATABASE_URL = '/data/slices.db' 15 16 SLICE_URI = 'at://did:plc:bcgltzqazw5tb6k2g3ttenbj/social.slices.slice/3lwzmbjpqxk2q' 16 17 17 18 [http_service]
-3
frontend/src/client.ts
··· 273 273 httpMethod !== "GET" 274 274 ) { 275 275 try { 276 - console.log("🔄 Got 401, invalidating token and attempting refresh..."); 277 276 // Mark current token as invalid to force refresh 278 277 this.oauthClient.invalidateCurrentToken(); 279 278 // Force token refresh by calling ensureValidToken again 280 279 await this.oauthClient.ensureValidToken(); 281 - console.log("✅ Token refresh successful, retrying request..."); 282 280 // Retry the request once with refreshed tokens 283 281 return this.makeRequestWithRetry(endpoint, method, params, true); 284 282 } catch (_refreshError) { 285 - console.error("❌ Token refresh failed:", _refreshError); 286 283 throw new Error( 287 284 `Authentication required: OAuth tokens are invalid or expired. Please log in again.` 288 285 );
+10 -5
frontend/src/config.ts
··· 1 1 import { AtProtoClient } from "./client.ts"; 2 - import { OAuthClient, DenoKVOAuthStorage } from "@slices/oauth"; 3 - import { getKv } from "./lib/kv.ts"; 2 + import { OAuthClient, SQLiteOAuthStorage } from "@slices/oauth"; 4 3 5 4 const OAUTH_CLIENT_ID = Deno.env.get("OAUTH_CLIENT_ID"); 6 5 const OAUTH_CLIENT_SECRET = Deno.env.get("OAUTH_CLIENT_SECRET"); ··· 23 22 ); 24 23 } 25 24 26 - const kv = await getKv(); 27 - const oauthStorage = new DenoKVOAuthStorage(kv); 25 + const DATABASE_URL = Deno.env.get("DATABASE_URL") || "slices.db"; 26 + const oauthStorage = new SQLiteOAuthStorage(DATABASE_URL); 28 27 29 28 const oauthClient = new OAuthClient( 30 29 { ··· 32 31 clientSecret: OAUTH_CLIENT_SECRET, 33 32 authBaseUrl: OAUTH_AIP_BASE_URL, 34 33 redirectUri: OAUTH_REDIRECT_URI, 35 - scopes: ["openid", "profile", "email", "atproto:atproto", "atproto:transition:generic"], 34 + scopes: [ 35 + "openid", 36 + "profile", 37 + "email", 38 + "atproto:atproto", 39 + "atproto:transition:generic", 40 + ], 36 41 }, 37 42 oauthStorage 38 43 );
-18
frontend/src/lib/kv.ts
··· 1 - // Shared Deno KV instance 2 - let kvInstance: Deno.Kv | null = null; 3 - 4 - export async function getKv(): Promise<Deno.Kv> { 5 - if (!kvInstance) { 6 - try { 7 - // Use persistent file-based KV store 8 - const kvPath = Deno.env.get("KV_PATH") || "./kv.db"; 9 - kvInstance = await Deno.openKv(kvPath); 10 - } catch (error) { 11 - const message = error instanceof Error ? error.message : String(error); 12 - throw new Error( 13 - `Failed to initialize Deno KV: ${message}. Make sure to run with --unstable-kv flag.` 14 - ); 15 - } 16 - } 17 - return kvInstance; 18 - }
+117 -45
frontend/src/lib/session-store.ts
··· 1 - // Deno KV-based session storage for user authentication 1 + // SQLite-based session storage for user authentication 2 2 import { atprotoClient } from "../config.ts"; 3 + import { DatabaseSync } from "node:sqlite"; 3 4 4 5 export interface SessionData { 5 6 userDid: string; ··· 15 16 } 16 17 17 18 export class SessionStore { 18 - constructor(private kv: Deno.Kv) {} 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 + } 19 49 20 50 // Create a new session 21 - async createSession(userDid: string, handle?: string): Promise<string> { 51 + createSession(userDid: string, handle?: string): string { 22 52 const sessionId = crypto.randomUUID(); 23 - const expirationMs = 30 * 24 * 60 * 60 * 1000; // 30 days 53 + const createdAt = Date.now(); 54 + const expiresAt = createdAt + 30 * 24 * 60 * 60 * 1000; // 30 days 24 55 25 - const sessionData: SessionData = { 26 - userDid, 27 - handle, 28 - isAuthenticated: true, 29 - createdAt: Date.now(), 30 - }; 56 + const stmt = this.db.prepare(` 57 + INSERT INTO sessions (session_id, user_did, handle, is_authenticated, created_at, expires_at) 58 + VALUES (?, ?, ?, ?, ?, ?) 59 + `); 31 60 32 - await this.kv.set(["sessions", sessionId], sessionData, { 33 - expireIn: expirationMs, 34 - }); 61 + stmt.run(sessionId, userDid, handle || null, 1, createdAt, expiresAt); 35 62 36 63 return sessionId; 37 64 } 38 65 39 66 // Get session data 40 - async getSession(sessionId: string): Promise<SessionData | null> { 41 - const result = await this.kv.get<SessionData>(["sessions", sessionId]); 42 - return result.value; 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 + }; 43 99 } 44 100 45 101 // Update session data 46 - async updateSession( 102 + updateSession( 47 103 sessionId: string, 48 104 updates: { handle?: string; isAuthenticated?: boolean } 49 - ): Promise<boolean> { 50 - const existing = await this.kv.get<SessionData>(["sessions", sessionId]); 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 + } 51 118 52 - if (!existing.value) { 119 + if (parts.length === 0) { 53 120 return false; 54 121 } 55 122 56 - const updated = { 57 - ...existing.value, 58 - ...updates, 59 - }; 123 + // Extend expiration on update 124 + parts.push("expires_at = ?"); 125 + values.push(Date.now() + 30 * 24 * 60 * 60 * 1000); 60 126 61 - const expirationMs = 30 * 24 * 60 * 60 * 1000; // 30 days 62 - await this.kv.set(["sessions", sessionId], updated, { 63 - expireIn: expirationMs, 64 - }); 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 + `); 65 135 66 - return true; 136 + const result = stmt.run(...values); 137 + return result.changes > 0; 67 138 } 68 139 69 140 // Delete a session (logout) 70 - async deleteSession(sessionId: string): Promise<void> { 71 - await this.kv.delete(["sessions", sessionId]); 141 + deleteSession(sessionId: string): void { 142 + const stmt = this.db.prepare("DELETE FROM sessions WHERE session_id = ?"); 143 + stmt.run(sessionId); 72 144 } 73 145 74 146 // Get user info from OAuth client 75 147 async getUserInfo(): Promise<UserData> { 76 - const tokens = await atprotoClient.oauth?.getTokens(); 77 - console.log("Current access token:", tokens?.accessToken); 78 - console.log("Token type:", tokens?.tokenType); 79 - 80 148 const userInfo = await atprotoClient.oauth?.getUserInfo(); 81 149 console.log("Fetched user info:", userInfo); 82 150 const isAuthenticated = ··· 103 171 } 104 172 105 173 try { 106 - const sessionData = await this.getSession(sessionId); 174 + const sessionData = this.getSession(sessionId); 107 175 108 - if (!sessionData || !sessionData.isAuthenticated) { 176 + if (!sessionData) { 177 + return { isAuthenticated: false }; 178 + } 179 + 180 + if (!sessionData.isAuthenticated) { 109 181 return { isAuthenticated: false }; 110 182 } 111 183 112 184 // Try to ensure valid tokens (this will refresh if needed) 113 185 try { 114 186 await atprotoClient.oauth?.ensureValidToken(); 187 + 115 188 const oauthAuthenticated = 116 189 (await atprotoClient.oauth?.isAuthenticated()) || false; 117 190 118 191 if (!oauthAuthenticated) { 119 192 // Mark session as unauthenticated if OAuth tokens are invalid 120 - await this.updateSession(sessionId, { isAuthenticated: false }); 193 + this.updateSession(sessionId, { isAuthenticated: false }); 121 194 return { isAuthenticated: false }; 122 195 } 123 - } catch (tokenError) { 124 - console.error("Token validation/refresh failed:", tokenError); 196 + } catch (_tokenError) { 125 197 // Mark session as unauthenticated if token refresh fails 126 - await this.updateSession(sessionId, { isAuthenticated: false }); 198 + this.updateSession(sessionId, { isAuthenticated: false }); 127 199 return { isAuthenticated: false }; 128 200 } 129 201 ··· 139 211 } 140 212 141 213 // Create session from user data 142 - async createSessionFromUserData(userData: UserData): Promise<string> { 214 + createSessionFromUserData(userData: UserData): string { 143 215 if (!userData.sub) { 144 216 throw new Error("User DID (sub) is required for session creation"); 145 217 } 146 218 147 - return await this.createSession(userData.sub, userData.handle); 219 + return this.createSession(userData.sub, userData.handle); 148 220 } 149 221 150 222 // Delete session from request 151 - async deleteSessionFromRequest(req: Request): Promise<void> { 223 + deleteSessionFromRequest(req: Request): void { 152 224 const sessionId = getSessionIdFromRequest(req); 153 225 if (sessionId) { 154 - await this.deleteSession(sessionId); 226 + this.deleteSession(sessionId); 155 227 } 156 228 } 157 229 }
+3 -4
frontend/src/lib/stores.ts
··· 1 - // Initialize all stores with shared KV instance 2 - import { getKv } from "./kv.ts"; 1 + // Initialize all stores with shared database 3 2 import { SessionStore } from "./session-store.ts"; 4 3 5 - const kv = await getKv(); 4 + const DATABASE_URL = Deno.env.get("DATABASE_URL") || "sqlite://slices.db"; 6 5 7 - export const sessionStore = new SessionStore(kv); 6 + export const sessionStore = new SessionStore(DATABASE_URL);
+2 -4
frontend/src/routes/pages.tsx
··· 1 1 import type { Route } from "@std/http/unstable-route"; 2 2 import { render } from "preact-render-to-string"; 3 - import { withAuth, requireAuth } from "./middleware.ts"; 3 + import { withAuth } from "./middleware.ts"; 4 4 import { atprotoClient } from "../config.ts"; 5 5 import { buildAtUri } from "../utils/at-uri.ts"; 6 6 import { IndexPage } from "../pages/IndexPage.tsx"; ··· 15 15 16 16 async function handleIndexPage(req: Request): Promise<Response> { 17 17 const context = await withAuth(req); 18 - const authResponse = requireAuth(context); 19 - if (authResponse) return authResponse; 20 - 18 + 21 19 // Slice list page - get real slices from AT Protocol 22 20 let slices: Array<{ id: string; name: string; createdAt: string }> = []; 23 21