Highly ambitious ATProtocol AppView service and sdks

switch frontend to sqlite storage adapter instead of kv

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